Remove server-side transcoding
This commit is contained in:
@@ -12,7 +12,9 @@
|
||||
"elysia": "latest",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"bun-types": "latest",
|
||||
"typescript": "^6.0.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -113,6 +115,8 @@
|
||||
|
||||
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
|
||||
|
||||
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
|
||||
@@ -153,6 +157,8 @@
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
|
||||
|
||||
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"@aws-sdk/s3-request-presigner": "^3.700.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest"
|
||||
"@types/bun": "^1.3.14",
|
||||
"bun-types": "latest",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
getVideoByMapAndPlayer,
|
||||
updateVideoPB,
|
||||
updateTranscodedKey,
|
||||
updateTranscodeStatus,
|
||||
} from "../services/db";
|
||||
import {
|
||||
uploadObject,
|
||||
@@ -21,7 +20,6 @@ import {
|
||||
completeMultipartUpload,
|
||||
abortMultipartUpload,
|
||||
getVideoStream,
|
||||
transcodeVideo,
|
||||
} from "../services/rustfs";
|
||||
import { parseMtvFile } from "../services/mtv-parser";
|
||||
import { getMapInfo } from "../services/momentum-api";
|
||||
@@ -336,26 +334,6 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" })
|
||||
|
||||
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 {
|
||||
...video,
|
||||
videoUrl: getStreamUrl(video.id),
|
||||
@@ -366,50 +344,6 @@ 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.transcodeStatus === "processing" ||
|
||||
video.transcodeStatus === "pending"
|
||||
) {
|
||||
return status(409, {
|
||||
error: "Transcode already in progress",
|
||||
transcodeStatus: video.transcodeStatus,
|
||||
});
|
||||
}
|
||||
|
||||
if (video.transcodedKey) {
|
||||
return {
|
||||
...video,
|
||||
videoUrl: getStreamUrl(video.id),
|
||||
mtvUrl: getObjectUrl(video.mtvKey),
|
||||
thumbnailUrl: video.thumbnailKey
|
||||
? getObjectUrl(video.thumbnailKey)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Mark as pending and return immediately
|
||||
updateTranscodeStatus(id, "pending");
|
||||
|
||||
// Run transcode in background
|
||||
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 { id, transcodeStatus: "pending" };
|
||||
})
|
||||
|
||||
.get("/:id/stream", async ({ params: { id }, request, set }) => {
|
||||
const video = getVideoById(id);
|
||||
if (!video) return status(404, { error: "Not found" });
|
||||
|
||||
@@ -60,11 +60,6 @@ export function initDb(): void {
|
||||
if (!columns.some((col) => col.name === "transcoded_key")) {
|
||||
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 {
|
||||
@@ -158,21 +153,9 @@ function rowToEntry(row: any): VideoEntry {
|
||||
? JSON.parse(row.previous_pbs)
|
||||
: 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(
|
||||
id: string,
|
||||
thumbnailKey: string | null,
|
||||
|
||||
@@ -9,10 +9,6 @@ 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 } from "fs/promises";
|
||||
import { createWriteStream, createReadStream } from "fs";
|
||||
|
||||
const RUSTFS_ENDPOINT = process.env.RUSTFS_ENDPOINT || "http://localhost:9000";
|
||||
const RUSTFS_ACCESS_KEY = process.env.RUSTFS_ACCESS_KEY || "minioadmin";
|
||||
@@ -221,145 +217,3 @@ 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();
|
||||
|
||||
const inputPath = join(tmpdir(), `transcode-input-${Date.now()}`);
|
||||
const outputPath = join(tmpdir(), `transcode-output-${Date.now()}.mp4`);
|
||||
|
||||
try {
|
||||
// Stream download from S3 to temp file (avoids loading whole file into RAM)
|
||||
console.log(`[transcode] Downloading ${originalKey} from S3...`);
|
||||
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 sdkStream = response.Body as any;
|
||||
const webStream: ReadableStream<Uint8Array> =
|
||||
sdkStream.transformToWebStream
|
||||
? sdkStream.transformToWebStream()
|
||||
: sdkStream;
|
||||
|
||||
const fileWriter = createWriteStream(inputPath);
|
||||
const reader = webStream.getReader();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!fileWriter.write(value)) {
|
||||
await new Promise<void>((resolve) =>
|
||||
fileWriter.once("drain", resolve),
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fileWriter.end();
|
||||
reader.releaseLock();
|
||||
await new Promise<void>((resolve) =>
|
||||
fileWriter.on("finish", resolve),
|
||||
);
|
||||
}
|
||||
|
||||
// Run ffmpeg to transcode
|
||||
console.log(`[transcode] Running ffmpeg on ${inputPath}...`);
|
||||
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 using multipart for large files
|
||||
console.log(`[transcode] Uploading ${transcodedKey} to S3...`);
|
||||
const outputFile = Bun.file(outputPath);
|
||||
const fileSize = outputFile.size;
|
||||
const CHUNK_SIZE = 80 * 1024 * 1024; // 80MB chunks
|
||||
|
||||
if (fileSize < CHUNK_SIZE) {
|
||||
// Small enough for single upload
|
||||
const buffer = Buffer.from(await outputFile.arrayBuffer());
|
||||
await uploadObject(transcodedKey, buffer, "video/mp4");
|
||||
} else {
|
||||
// Multipart upload for large files
|
||||
const { uploadId } = await createMultipartUpload(
|
||||
transcodedKey,
|
||||
"video/mp4",
|
||||
);
|
||||
const numParts = Math.ceil(fileSize / CHUNK_SIZE);
|
||||
const parts: { PartNumber: number; ETag: string }[] = [];
|
||||
|
||||
for (let i = 0; i < numParts; i++) {
|
||||
const start = i * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, fileSize);
|
||||
const chunk = Buffer.from(
|
||||
await outputFile.slice(start, end).arrayBuffer(),
|
||||
);
|
||||
|
||||
const partNumber = i + 1;
|
||||
const uploadCmd = new UploadPartCommand({
|
||||
Bucket: RUSTFS_BUCKET,
|
||||
Key: transcodedKey,
|
||||
UploadId: uploadId,
|
||||
PartNumber: partNumber,
|
||||
Body: chunk,
|
||||
});
|
||||
const result = await s3.send(uploadCmd);
|
||||
parts.push({
|
||||
PartNumber: partNumber,
|
||||
ETag: result.ETag!,
|
||||
});
|
||||
}
|
||||
|
||||
await completeMultipartUpload(transcodedKey, uploadId, parts);
|
||||
}
|
||||
|
||||
return transcodedKey;
|
||||
} finally {
|
||||
// Clean up temp files
|
||||
try {
|
||||
await unlink(inputPath);
|
||||
} catch {}
|
||||
try {
|
||||
await unlink(outputPath);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,6 @@ export interface VideoEntry {
|
||||
createdAt: string;
|
||||
previousPbs?: PreviousPB[];
|
||||
transcodedKey?: string;
|
||||
transcodeStatus?: "pending" | "processing" | "done" | "failed" | null;
|
||||
}
|
||||
|
||||
export interface VideoListItem {
|
||||
|
||||
@@ -1,46 +1,8 @@
|
||||
import type { VideoDetail } from "../types/video";
|
||||
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 }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{(video.transcodeStatus === "pending" ||
|
||||
video.transcodeStatus === "processing" ||
|
||||
video.transcodeStatus === "failed") && (
|
||||
<TranscodeBanner status={video.transcodeStatus} />
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-white/5 overflow-hidden">
|
||||
<video
|
||||
src={video.videoUrl}
|
||||
@@ -80,6 +42,5 @@ export default function VideoPlayer({ video }: { video: VideoDetail }) {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,5 +35,4 @@ export interface VideoDetail {
|
||||
jsonStats?: string;
|
||||
createdAt: string;
|
||||
previousPbs?: PreviousPB[];
|
||||
transcodeStatus?: "pending" | "processing" | "done" | "failed" | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user