Add video transcoding to H264/MP4, use high quality CRF 18
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
updateTier,
|
||||
getVideoByMapAndPlayer,
|
||||
updateVideoPB,
|
||||
updateTranscodedKey,
|
||||
} from "../services/db";
|
||||
import {
|
||||
uploadObject,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
completeMultipartUpload,
|
||||
abortMultipartUpload,
|
||||
getVideoStream,
|
||||
transcodeVideo,
|
||||
} from "../services/rustfs";
|
||||
import { parseMtvFile } from "../services/mtv-parser";
|
||||
import { getMapInfo } from "../services/momentum-api";
|
||||
@@ -343,6 +345,35 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" })
|
||||
};
|
||||
})
|
||||
|
||||
.post("/:id/transcode", async ({ params: { id } }) => {
|
||||
const video = getVideoById(id);
|
||||
if (!video) return status(404, { error: "Not found" });
|
||||
|
||||
if (video.transcodedKey) {
|
||||
return {
|
||||
...video,
|
||||
videoUrl: getStreamUrl(video.id),
|
||||
mtvUrl: getObjectUrl(video.mtvKey),
|
||||
thumbnailUrl: video.thumbnailKey
|
||||
? getObjectUrl(video.thumbnailKey)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const transcodedKey = await transcodeVideo(video.videoKey);
|
||||
updateTranscodedKey(id, transcodedKey);
|
||||
|
||||
return {
|
||||
...video,
|
||||
transcodedKey,
|
||||
videoUrl: getStreamUrl(video.id),
|
||||
mtvUrl: getObjectUrl(video.mtvKey),
|
||||
thumbnailUrl: video.thumbnailKey
|
||||
? getObjectUrl(video.thumbnailKey)
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
|
||||
.get("/:id/stream", async ({ params: { id }, request, set }) => {
|
||||
const video = getVideoById(id);
|
||||
if (!video) return status(404, { error: "Not found" });
|
||||
@@ -350,7 +381,10 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" })
|
||||
const rangeHeader = request.headers.get("Range");
|
||||
|
||||
try {
|
||||
const result = await getVideoStream(video.videoKey, rangeHeader);
|
||||
const result = await getVideoStream(
|
||||
video.transcodedKey ?? video.videoKey,
|
||||
rangeHeader,
|
||||
);
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Content-Type", result.contentType);
|
||||
@@ -465,6 +499,8 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" })
|
||||
deleteObject(video.mtvKey),
|
||||
];
|
||||
if (video.thumbnailKey) deletes.push(deleteObject(video.thumbnailKey));
|
||||
if (video.transcodedKey)
|
||||
deletes.push(deleteObject(video.transcodedKey));
|
||||
await Promise.all(deletes);
|
||||
|
||||
deleteVideoById(id);
|
||||
|
||||
@@ -57,6 +57,9 @@ export function initDb(): void {
|
||||
if (!columns.some((col) => col.name === "previous_pbs")) {
|
||||
db.exec("ALTER TABLE videos ADD COLUMN previous_pbs TEXT");
|
||||
}
|
||||
if (!columns.some((col) => col.name === "transcoded_key")) {
|
||||
db.exec("ALTER TABLE videos ADD COLUMN transcoded_key TEXT");
|
||||
}
|
||||
}
|
||||
|
||||
function getDb(): Database {
|
||||
@@ -142,6 +145,7 @@ function rowToEntry(row: any): VideoEntry {
|
||||
videoKey: row.video_key,
|
||||
mtvKey: row.mtv_key,
|
||||
thumbnailKey: row.thumbnail_key ?? undefined,
|
||||
transcodedKey: row.transcoded_key ?? undefined,
|
||||
tier: row.tier ?? undefined,
|
||||
mapId: row.map_id ?? undefined,
|
||||
jsonStats: row.json_stats ?? undefined,
|
||||
@@ -197,7 +201,8 @@ export function updateVideoPB(
|
||||
map_id = ?,
|
||||
json_stats = ?,
|
||||
created_at = ?,
|
||||
previous_pbs = ?
|
||||
previous_pbs = ?,
|
||||
transcoded_key = ?
|
||||
WHERE id = ?`,
|
||||
).run(
|
||||
entry.title,
|
||||
@@ -216,10 +221,22 @@ export function updateVideoPB(
|
||||
entry.jsonStats ?? null,
|
||||
entry.createdAt,
|
||||
JSON.stringify(previousPbs),
|
||||
entry.transcodedKey ?? null,
|
||||
id,
|
||||
);
|
||||
}
|
||||
|
||||
export function updateTranscodedKey(
|
||||
id: string,
|
||||
transcodedKey: string | null,
|
||||
): boolean {
|
||||
const d = getDb();
|
||||
const result = d
|
||||
.prepare("UPDATE videos SET transcoded_key = ? WHERE id = ?")
|
||||
.run(transcodedKey, id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function updateTier(id: string, tier: number | null): boolean {
|
||||
const d = getDb();
|
||||
const result = d
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
GetObjectCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { unlink, writeFile } from "fs/promises";
|
||||
|
||||
const RUSTFS_ENDPOINT = process.env.RUSTFS_ENDPOINT || "http://localhost:9000";
|
||||
const RUSTFS_ACCESS_KEY = process.env.RUSTFS_ACCESS_KEY || "minioadmin";
|
||||
@@ -217,3 +220,79 @@ export async function getVideoStream(
|
||||
export function getStreamUrl(videoId: string): string {
|
||||
return `/api/videos/${videoId}/stream`;
|
||||
}
|
||||
|
||||
export async function transcodeVideo(originalKey: string): Promise<string> {
|
||||
const s3 = getRustFsClient();
|
||||
|
||||
// Download original from S3 to temp file
|
||||
const inputPath = join(tmpdir(), `transcode-input-${Date.now()}`);
|
||||
const outputPath = join(tmpdir(), `transcode-output-${Date.now()}.mp4`);
|
||||
|
||||
try {
|
||||
const getCmd = new GetObjectCommand({
|
||||
Bucket: RUSTFS_BUCKET,
|
||||
Key: originalKey,
|
||||
});
|
||||
const response = await s3.send(getCmd);
|
||||
if (!response.Body) {
|
||||
throw new Error("Empty response body from RustFS");
|
||||
}
|
||||
const bytes = await response.Body.transformToByteArray();
|
||||
await writeFile(inputPath, bytes);
|
||||
|
||||
// Run ffmpeg to transcode
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-i",
|
||||
inputPath,
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"slow",
|
||||
"-crf",
|
||||
"18",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"192k",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-y",
|
||||
outputPath,
|
||||
],
|
||||
{
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
throw new Error(`ffmpeg exited with code ${exitCode}: ${stderr}`);
|
||||
}
|
||||
|
||||
// Compute the transcoded key (replace extension with .mp4)
|
||||
const lastDot = originalKey.lastIndexOf(".");
|
||||
const transcodedKey =
|
||||
lastDot !== -1
|
||||
? originalKey.substring(0, lastDot) + ".mp4"
|
||||
: originalKey + ".mp4";
|
||||
|
||||
// Upload transcoded file to S3
|
||||
const file = Bun.file(outputPath);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await uploadObject(transcodedKey, buffer, "video/mp4");
|
||||
|
||||
return transcodedKey;
|
||||
} finally {
|
||||
// Clean up temp files
|
||||
try {
|
||||
await unlink(inputPath);
|
||||
} catch {}
|
||||
try {
|
||||
await unlink(outputPath);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface VideoEntry {
|
||||
videoKey: string;
|
||||
mtvKey: string;
|
||||
thumbnailKey?: string;
|
||||
transcodedKey?: string;
|
||||
tier?: number | null;
|
||||
mapId?: number | null;
|
||||
jsonStats?: string;
|
||||
|
||||
Reference in New Issue
Block a user