Auto-transcode on upload, show transcode status to user

This commit is contained in:
CallMeVerity
2026-06-03 04:38:51 +01:00
parent 8121369a58
commit ff2c3997a0
6 changed files with 141 additions and 47 deletions
+44 -11
View File
@@ -9,6 +9,7 @@ import {
getVideoByMapAndPlayer, getVideoByMapAndPlayer,
updateVideoPB, updateVideoPB,
updateTranscodedKey, updateTranscodedKey,
updateTranscodeStatus,
} from "../services/db"; } from "../services/db";
import { import {
uploadObject, uploadObject,
@@ -335,6 +336,25 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" })
await completeMultipartUpload(video.videoKey, uploadId, s3Parts); await completeMultipartUpload(video.videoKey, uploadId, s3Parts);
if (
!video.transcodedKey &&
video.transcodeStatus !== "pending" &&
video.transcodeStatus !== "processing"
) {
updateTranscodeStatus(id, "pending");
const videoKey = video.videoKey;
transcodeVideo(videoKey)
.then((transcodedKey) => {
updateTranscodedKey(id, transcodedKey);
updateTranscodeStatus(id, "done");
console.log(`[transcode] Done: ${id} -> ${transcodedKey}`);
})
.catch((err) => {
console.error(`[transcode] Failed: ${id}`, err);
updateTranscodeStatus(id, "failed");
});
}
return { return {
...video, ...video,
videoUrl: getStreamUrl(video.id), videoUrl: getStreamUrl(video.id),
@@ -349,6 +369,16 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" })
const video = getVideoById(id); const video = getVideoById(id);
if (!video) return status(404, { error: "Not found" }); if (!video) return status(404, { error: "Not found" });
if (
video.transcodeStatus === "processing" ||
video.transcodeStatus === "pending"
) {
return status(409, {
error: "Transcode already in progress",
transcodeStatus: video.transcodeStatus,
});
}
if (video.transcodedKey) { if (video.transcodedKey) {
return { return {
...video, ...video,
@@ -360,18 +390,21 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" })
}; };
} }
const transcodedKey = await transcodeVideo(video.videoKey); updateTranscodeStatus(id, "pending");
updateTranscodedKey(id, transcodedKey);
return { const videoKey = video.videoKey;
...video, transcodeVideo(videoKey)
transcodedKey, .then((transcodedKey) => {
videoUrl: getStreamUrl(video.id), updateTranscodedKey(id, transcodedKey);
mtvUrl: getObjectUrl(video.mtvKey), updateTranscodeStatus(id, "done");
thumbnailUrl: video.thumbnailKey console.log(`[transcode] Done: ${id} -> ${transcodedKey}`);
? getObjectUrl(video.thumbnailKey) })
: undefined, .catch((err) => {
}; console.error(`[transcode] Failed: ${id}`, err);
updateTranscodeStatus(id, "failed");
});
return { id, transcodeStatus: "pending" };
}) })
.get("/:id/stream", async ({ params: { id }, request, set }) => { .get("/:id/stream", async ({ params: { id }, request, set }) => {
+18 -1
View File
@@ -60,6 +60,11 @@ export function initDb(): void {
if (!columns.some((col) => col.name === "transcoded_key")) { if (!columns.some((col) => col.name === "transcoded_key")) {
db.exec("ALTER TABLE videos ADD COLUMN transcoded_key TEXT"); db.exec("ALTER TABLE videos ADD COLUMN transcoded_key TEXT");
} }
if (!columns.some((col) => col.name === "transcode_status")) {
db.exec(
"ALTER TABLE videos ADD COLUMN transcode_status TEXT DEFAULT NULL",
);
}
} }
function getDb(): Database { function getDb(): Database {
@@ -145,7 +150,6 @@ function rowToEntry(row: any): VideoEntry {
videoKey: row.video_key, videoKey: row.video_key,
mtvKey: row.mtv_key, mtvKey: row.mtv_key,
thumbnailKey: row.thumbnail_key ?? undefined, thumbnailKey: row.thumbnail_key ?? undefined,
transcodedKey: row.transcoded_key ?? undefined,
tier: row.tier ?? undefined, tier: row.tier ?? undefined,
mapId: row.map_id ?? undefined, mapId: row.map_id ?? undefined,
jsonStats: row.json_stats ?? undefined, jsonStats: row.json_stats ?? undefined,
@@ -153,9 +157,22 @@ function rowToEntry(row: any): VideoEntry {
previousPbs: row.previous_pbs previousPbs: row.previous_pbs
? JSON.parse(row.previous_pbs) ? JSON.parse(row.previous_pbs)
: undefined, : undefined,
transcodedKey: row.transcoded_key ?? undefined,
transcodeStatus: row.transcode_status ?? undefined,
}; };
} }
export function updateTranscodeStatus(
id: string,
status: "pending" | "processing" | "done" | "failed" | null,
): boolean {
const d = getDb();
const result = d
.prepare("UPDATE videos SET transcode_status = ? WHERE id = ?")
.run(status, id);
return result.changes > 0;
}
export function updateThumbnailKey( export function updateThumbnailKey(
id: string, id: string,
thumbnailKey: string | null, thumbnailKey: string | null,
+3
View File
@@ -224,6 +224,7 @@ export async function transcodeVideo(originalKey: string): Promise<string> {
const outputPath = join(tmpdir(), `transcode-output-${Date.now()}.mp4`); const outputPath = join(tmpdir(), `transcode-output-${Date.now()}.mp4`);
try { try {
console.log(`[transcode] Downloading ${originalKey} from S3...`);
const getCmd = new GetObjectCommand({ const getCmd = new GetObjectCommand({
Bucket: RUSTFS_BUCKET, Bucket: RUSTFS_BUCKET,
Key: originalKey, Key: originalKey,
@@ -260,6 +261,7 @@ export async function transcodeVideo(originalKey: string): Promise<string> {
); );
} }
console.log(`[transcode] Running ffmpeg on ${inputPath}...`);
const proc = Bun.spawn( const proc = Bun.spawn(
[ [
"ffmpeg", "ffmpeg",
@@ -298,6 +300,7 @@ export async function transcodeVideo(originalKey: string): Promise<string> {
? originalKey.substring(0, lastDot) + ".mp4" ? originalKey.substring(0, lastDot) + ".mp4"
: originalKey + ".mp4"; : originalKey + ".mp4";
console.log(`[transcode] Uploading ${transcodedKey} to S3...`);
const outputFile = Bun.file(outputPath); const outputFile = Bun.file(outputPath);
const fileSize = outputFile.size; const fileSize = outputFile.size;
const CHUNK_SIZE = 80 * 1024 * 1024; const CHUNK_SIZE = 80 * 1024 * 1024;
+2 -1
View File
@@ -51,12 +51,13 @@ export interface VideoEntry {
videoKey: string; videoKey: string;
mtvKey: string; mtvKey: string;
thumbnailKey?: string; thumbnailKey?: string;
transcodedKey?: string;
tier?: number | null; tier?: number | null;
mapId?: number | null; mapId?: number | null;
jsonStats?: string; jsonStats?: string;
createdAt: string; createdAt: string;
previousPbs?: PreviousPB[]; previousPbs?: PreviousPB[];
transcodedKey?: string;
transcodeStatus?: "pending" | "processing" | "done" | "failed" | null;
} }
export interface VideoListItem { export interface VideoListItem {
+73 -34
View File
@@ -1,45 +1,84 @@
import type { VideoDetail } from "../types/video"; import type { VideoDetail } from "../types/video";
import { formatRunTime } from "../api/client"; import { formatRunTime } from "../api/client";
function TranscodeBanner({ status }: { status: string }) {
if (status === "done" || !status) return null;
const isPending = status === "pending" || status === "processing";
return (
<div
className={`rounded-lg border px-4 py-3 text-sm ${
isPending
? "border-yellow-500/30 bg-yellow-500/10 text-yellow-300"
: "border-red-500/30 bg-red-500/10 text-red-300"
}`}
>
{isPending ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full border-2 border-current border-t-transparent animate-spin" />
<span>
Video is being prepared for playback. This may take a
few minutes depending on file size.
</span>
</div>
) : (
<span>
Video processing failed. The original format may not be
supported by your browser.
</span>
)}
</div>
);
}
export default function VideoPlayer({ video }: { video: VideoDetail }) { export default function VideoPlayer({ video }: { video: VideoDetail }) {
return ( return (
<div className="rounded-lg border border-white/5 overflow-hidden"> <div className="flex flex-col gap-3">
<video {(video.transcodeStatus === "pending" ||
src={video.videoUrl} video.transcodeStatus === "processing" ||
controls video.transcodeStatus === "failed") && (
playsInline <TranscodeBanner status={video.transcodeStatus} />
preload="metadata" )}
poster={video.thumbnailUrl}
className="w-full aspect-video bg-black object-contain"
>
Your browser does not support video playback.
</video>
<div className="px-4 py-3 flex items-center justify-between border-t border-white/5 bg-white/3"> <div className="rounded-lg border border-white/5 overflow-hidden">
<a <video
href={video.mtvUrl} src={video.videoUrl}
download controls
className="inline-flex items-center gap-2 rounded-md bg-white/4 border border-white/7 px-3 py-1.5 text-xs font-medium text-white/50 hover:bg-white/8 hover:text-white/80 hover:border-white/15 transition-colors no-underline" playsInline
preload="metadata"
poster={video.thumbnailUrl}
className="w-full aspect-video bg-black object-contain"
> >
<svg Your browser does not support video playback.
className="w-3.5 h-3.5" </video>
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
.mtv replay
</a>
<span className="font-mono text-sm text-white/80"> <div className="px-4 py-3 flex items-center justify-between border-t border-white/5 bg-white/3">
{formatRunTime(video.runTime)} <a
</span> href={video.mtvUrl}
download
className="inline-flex items-center gap-2 rounded-md bg-white/4 border border-white/7 px-3 py-1.5 text-xs font-medium text-white/50 hover:bg-white/8 hover:text-white/80 hover:border-white/15 transition-colors no-underline"
>
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
.mtv replay
</a>
<span className="font-mono text-sm text-white/80">
{formatRunTime(video.runTime)}
</span>
</div>
</div> </div>
</div> </div>
); );
+1
View File
@@ -35,4 +35,5 @@ export interface VideoDetail {
jsonStats?: string; jsonStats?: string;
createdAt: string; createdAt: string;
previousPbs?: PreviousPB[]; previousPbs?: PreviousPB[];
transcodeStatus?: "pending" | "processing" | "done" | "failed" | null;
} }