Add video transcoding to H264/MP4, use high quality CRF 18
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
|||||||
updateTier,
|
updateTier,
|
||||||
getVideoByMapAndPlayer,
|
getVideoByMapAndPlayer,
|
||||||
updateVideoPB,
|
updateVideoPB,
|
||||||
|
updateTranscodedKey,
|
||||||
} from "../services/db";
|
} from "../services/db";
|
||||||
import {
|
import {
|
||||||
uploadObject,
|
uploadObject,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
completeMultipartUpload,
|
completeMultipartUpload,
|
||||||
abortMultipartUpload,
|
abortMultipartUpload,
|
||||||
getVideoStream,
|
getVideoStream,
|
||||||
|
transcodeVideo,
|
||||||
} from "../services/rustfs";
|
} from "../services/rustfs";
|
||||||
import { parseMtvFile } from "../services/mtv-parser";
|
import { parseMtvFile } from "../services/mtv-parser";
|
||||||
import { getMapInfo } from "../services/momentum-api";
|
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 }) => {
|
.get("/:id/stream", async ({ params: { id }, request, set }) => {
|
||||||
const video = getVideoById(id);
|
const video = getVideoById(id);
|
||||||
if (!video) return status(404, { error: "Not found" });
|
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");
|
const rangeHeader = request.headers.get("Range");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getVideoStream(video.videoKey, rangeHeader);
|
const result = await getVideoStream(
|
||||||
|
video.transcodedKey ?? video.videoKey,
|
||||||
|
rangeHeader,
|
||||||
|
);
|
||||||
|
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
headers.set("Content-Type", result.contentType);
|
headers.set("Content-Type", result.contentType);
|
||||||
@@ -465,6 +499,8 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" })
|
|||||||
deleteObject(video.mtvKey),
|
deleteObject(video.mtvKey),
|
||||||
];
|
];
|
||||||
if (video.thumbnailKey) deletes.push(deleteObject(video.thumbnailKey));
|
if (video.thumbnailKey) deletes.push(deleteObject(video.thumbnailKey));
|
||||||
|
if (video.transcodedKey)
|
||||||
|
deletes.push(deleteObject(video.transcodedKey));
|
||||||
await Promise.all(deletes);
|
await Promise.all(deletes);
|
||||||
|
|
||||||
deleteVideoById(id);
|
deleteVideoById(id);
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ export function initDb(): void {
|
|||||||
if (!columns.some((col) => col.name === "previous_pbs")) {
|
if (!columns.some((col) => col.name === "previous_pbs")) {
|
||||||
db.exec("ALTER TABLE videos ADD COLUMN previous_pbs TEXT");
|
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 {
|
function getDb(): Database {
|
||||||
@@ -142,6 +145,7 @@ 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,
|
||||||
@@ -197,7 +201,8 @@ export function updateVideoPB(
|
|||||||
map_id = ?,
|
map_id = ?,
|
||||||
json_stats = ?,
|
json_stats = ?,
|
||||||
created_at = ?,
|
created_at = ?,
|
||||||
previous_pbs = ?
|
previous_pbs = ?,
|
||||||
|
transcoded_key = ?
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
).run(
|
).run(
|
||||||
entry.title,
|
entry.title,
|
||||||
@@ -216,10 +221,22 @@ export function updateVideoPB(
|
|||||||
entry.jsonStats ?? null,
|
entry.jsonStats ?? null,
|
||||||
entry.createdAt,
|
entry.createdAt,
|
||||||
JSON.stringify(previousPbs),
|
JSON.stringify(previousPbs),
|
||||||
|
entry.transcodedKey ?? null,
|
||||||
id,
|
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 {
|
export function updateTier(id: string, tier: number | null): boolean {
|
||||||
const d = getDb();
|
const d = getDb();
|
||||||
const result = d
|
const result = d
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
GetObjectCommand,
|
GetObjectCommand,
|
||||||
} from "@aws-sdk/client-s3";
|
} from "@aws-sdk/client-s3";
|
||||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
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_ENDPOINT = process.env.RUSTFS_ENDPOINT || "http://localhost:9000";
|
||||||
const RUSTFS_ACCESS_KEY = process.env.RUSTFS_ACCESS_KEY || "minioadmin";
|
const RUSTFS_ACCESS_KEY = process.env.RUSTFS_ACCESS_KEY || "minioadmin";
|
||||||
@@ -217,3 +220,79 @@ export async function getVideoStream(
|
|||||||
export function getStreamUrl(videoId: string): string {
|
export function getStreamUrl(videoId: string): string {
|
||||||
return `/api/videos/${videoId}/stream`;
|
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;
|
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;
|
||||||
|
|||||||
@@ -259,35 +259,44 @@ export default function UploadForm() {
|
|||||||
const { parts, uploadId } = initRes.presignedUrls;
|
const { parts, uploadId } = initRes.presignedUrls;
|
||||||
const totalSize = videoFile.size;
|
const totalSize = videoFile.size;
|
||||||
const CHUNK_SIZE = 80 * 1024 * 1024;
|
const CHUNK_SIZE = 80 * 1024 * 1024;
|
||||||
|
const CONCURRENT_UPLOADS = 3;
|
||||||
const uploadedParts: { partNumber: number; eTag: string }[] = [];
|
const uploadedParts: { partNumber: number; eTag: string }[] = [];
|
||||||
|
const progressPerChunk: number[] = new Array(parts.length).fill(0);
|
||||||
|
|
||||||
for (let i = 0; i < parts.length; i++) {
|
const uploadChunk = (index: number) => {
|
||||||
const part = parts[i]!;
|
const part = parts[index]!;
|
||||||
const start = i * CHUNK_SIZE;
|
const start = index * CHUNK_SIZE;
|
||||||
const end = Math.min(start + CHUNK_SIZE, totalSize);
|
const end = Math.min(start + CHUNK_SIZE, totalSize);
|
||||||
const chunk = videoFile.slice(start, end);
|
const chunk = videoFile.slice(start, end);
|
||||||
|
|
||||||
const eTag = await new Promise<string>((resolve, reject) => {
|
return new Promise<{ partNumber: number; eTag: string }>(
|
||||||
|
(resolve, reject) => {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.upload.addEventListener("progress", (e) => {
|
xhr.upload.addEventListener("progress", (e) => {
|
||||||
if (e.lengthComputable) {
|
if (e.lengthComputable) {
|
||||||
const chunkProgress = e.loaded / e.total;
|
progressPerChunk[index] = e.loaded;
|
||||||
const overallUploaded = start + e.loaded;
|
const total = progressPerChunk.reduce(
|
||||||
|
(a, b) => a + b,
|
||||||
|
0,
|
||||||
|
);
|
||||||
setProgress(
|
setProgress(
|
||||||
5 +
|
5 + Math.round((total / totalSize) * 90),
|
||||||
Math.round(
|
|
||||||
(overallUploaded / totalSize) * 90,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
xhr.addEventListener("load", () => {
|
xhr.addEventListener("load", () => {
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
const etag = xhr.getResponseHeader("ETag");
|
const etag = xhr.getResponseHeader("ETag");
|
||||||
if (etag) resolve(etag);
|
if (etag)
|
||||||
|
resolve({
|
||||||
|
partNumber: part.partNumber,
|
||||||
|
eTag: etag,
|
||||||
|
});
|
||||||
else
|
else
|
||||||
reject(
|
reject(
|
||||||
new Error("No ETag returned from upload"),
|
new Error(
|
||||||
|
"No ETag returned from upload",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
reject(
|
reject(
|
||||||
@@ -306,10 +315,26 @@ export default function UploadForm() {
|
|||||||
videoFile.type || "video/webm",
|
videoFile.type || "video/webm",
|
||||||
);
|
);
|
||||||
xhr.send(chunk);
|
xhr.send(chunk);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
uploadedParts.push({ partNumber: part.partNumber, eTag });
|
let nextIndex = 0;
|
||||||
}
|
const uploadNext = async (): Promise<void> => {
|
||||||
|
if (nextIndex >= parts.length) return;
|
||||||
|
const index = nextIndex++;
|
||||||
|
const result = await uploadChunk(index);
|
||||||
|
uploadedParts.push(result);
|
||||||
|
await uploadNext();
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(
|
||||||
|
{ length: Math.min(CONCURRENT_UPLOADS, parts.length) },
|
||||||
|
() => uploadNext(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
uploadedParts.sort((a, b) => a.partNumber - b.partNumber);
|
||||||
|
|
||||||
setProgress(98);
|
setProgress(98);
|
||||||
await uploadVideoComplete(initRes.id, uploadedParts, uploadId);
|
await uploadVideoComplete(initRes.id, uploadedParts, uploadId);
|
||||||
|
|||||||
Reference in New Issue
Block a user