Auto-transcode on upload, show transcode status to user
This commit is contained in:
@@ -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,26 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" })
|
|||||||
|
|
||||||
await completeMultipartUpload(video.videoKey, uploadId, s3Parts);
|
await completeMultipartUpload(video.videoKey, uploadId, s3Parts);
|
||||||
|
|
||||||
|
// Auto-transcode in background
|
||||||
|
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 +370,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 +391,23 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" })
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const transcodedKey = await transcodeVideo(video.videoKey);
|
// Mark as pending and return immediately
|
||||||
updateTranscodedKey(id, transcodedKey);
|
updateTranscodeStatus(id, "pending");
|
||||||
|
|
||||||
return {
|
// Run transcode in background
|
||||||
...video,
|
const videoKey = video.videoKey;
|
||||||
transcodedKey,
|
transcodeVideo(videoKey)
|
||||||
videoUrl: getStreamUrl(video.id),
|
.then((transcodedKey) => {
|
||||||
mtvUrl: getObjectUrl(video.mtvKey),
|
updateTranscodedKey(id, transcodedKey);
|
||||||
thumbnailUrl: video.thumbnailKey
|
updateTranscodeStatus(id, "done");
|
||||||
? getObjectUrl(video.thumbnailKey)
|
console.log(`[transcode] Done: ${id} -> ${transcodedKey}`);
|
||||||
: 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 }) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ export async function transcodeVideo(originalKey: string): Promise<string> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Stream download from S3 to temp file (avoids loading whole file into RAM)
|
// Stream download from S3 to temp file (avoids loading whole file into RAM)
|
||||||
|
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,
|
||||||
@@ -267,6 +268,7 @@ export async function transcodeVideo(originalKey: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run ffmpeg to transcode
|
// Run ffmpeg to transcode
|
||||||
|
console.log(`[transcode] Running ffmpeg on ${inputPath}...`);
|
||||||
const proc = Bun.spawn(
|
const proc = Bun.spawn(
|
||||||
[
|
[
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
@@ -307,6 +309,7 @@ export async function transcodeVideo(originalKey: string): Promise<string> {
|
|||||||
: originalKey + ".mp4";
|
: originalKey + ".mp4";
|
||||||
|
|
||||||
// Upload transcoded file to S3 using multipart for large files
|
// Upload transcoded file to S3 using multipart for large files
|
||||||
|
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; // 80MB chunks
|
const CHUNK_SIZE = 80 * 1024 * 1024; // 80MB chunks
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user