Skip to content

Commit 3299c8a

Browse files
committed
feat(media-server): add POST /video/convert streaming mp4 response
Made-with: Cursor
1 parent 84c417a commit 3299c8a

File tree

2 files changed

+137
-0
lines changed

2 files changed

+137
-0
lines changed

apps/media-server/src/app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ app.get("/", (c) => {
2525
"/video/status",
2626
"/video/probe",
2727
"/video/thumbnail",
28+
"/video/convert",
2829
"/video/process",
2930
"/video/process/:jobId/status",
3031
"/video/process/:jobId/cancel",

apps/media-server/src/routes/video.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { file } from "bun";
12
import { Hono } from "hono";
23
import { z } from "zod";
34
import type { ResilientInputFlags } from "../lib/ffmpeg-video";
@@ -48,6 +49,11 @@ const thumbnailSchema = z.object({
4849
quality: z.number().min(1).max(100).optional(),
4950
});
5051

52+
const convertSchema = z.object({
53+
videoUrl: z.string().url(),
54+
inputExtension: z.string().optional(),
55+
});
56+
5157
const processSchema = z.object({
5258
videoId: z.string(),
5359
userId: z.string(),
@@ -75,6 +81,65 @@ function isTimeoutError(err: unknown): boolean {
7581
return err instanceof Error && err.message.includes("timed out");
7682
}
7783

84+
async function cleanupTempFiles(
85+
files: Array<TempFileHandle | null>,
86+
): Promise<void> {
87+
await Promise.all(
88+
files.map(async (tempFile) => {
89+
if (!tempFile) return;
90+
try {
91+
await tempFile.cleanup();
92+
} catch {}
93+
}),
94+
);
95+
}
96+
97+
async function createVideoDownloadResponse(
98+
outputTempFile: TempFileHandle,
99+
tempFiles: TempFileHandle[],
100+
): Promise<Response> {
101+
const outputFile = file(outputTempFile.path);
102+
const outputSize = await outputFile.size;
103+
let cleanedUp = false;
104+
105+
const cleanup = async () => {
106+
if (cleanedUp) return;
107+
cleanedUp = true;
108+
await cleanupTempFiles(tempFiles);
109+
};
110+
111+
const stream = new ReadableStream<Uint8Array>({
112+
async start(controller) {
113+
const reader = outputFile.stream().getReader();
114+
115+
try {
116+
while (true) {
117+
const { done, value } = await reader.read();
118+
if (done) break;
119+
if (value) controller.enqueue(value);
120+
}
121+
controller.close();
122+
} catch (error) {
123+
controller.error(error);
124+
} finally {
125+
reader.releaseLock();
126+
await cleanup();
127+
}
128+
},
129+
async cancel() {
130+
await cleanup();
131+
},
132+
});
133+
134+
return new Response(stream, {
135+
headers: {
136+
"Content-Type": "video/mp4",
137+
"Cache-Control": "no-store",
138+
"Content-Length": outputSize.toString(),
139+
},
140+
});
141+
}
142+
78143
video.get("/status", (c) => {
79144
const jobs = getAllJobs();
80145
const resources = getSystemResources();
@@ -229,6 +294,77 @@ video.post("/thumbnail", async (c) => {
229294
}
230295
});
231296

297+
video.post("/convert", async (c) => {
298+
const body = await c.req.json();
299+
const result = convertSchema.safeParse(body);
300+
301+
if (!result.success) {
302+
return c.json(
303+
{
304+
error: "Invalid request",
305+
code: "INVALID_REQUEST",
306+
details: result.error.message,
307+
},
308+
400,
309+
);
310+
}
311+
312+
let inputTempFile: TempFileHandle | null = null;
313+
let outputTempFile: TempFileHandle | null = null;
314+
315+
try {
316+
inputTempFile = await downloadVideoToTemp(
317+
result.data.videoUrl,
318+
result.data.inputExtension,
319+
);
320+
321+
const metadata = await probeVideoFile(inputTempFile.path);
322+
outputTempFile = await processVideo(inputTempFile.path, metadata, {
323+
maxWidth: metadata.width > 0 ? metadata.width : undefined,
324+
maxHeight: metadata.height > 0 ? metadata.height : undefined,
325+
});
326+
327+
return await createVideoDownloadResponse(outputTempFile, [
328+
inputTempFile,
329+
outputTempFile,
330+
]);
331+
} catch (err) {
332+
await cleanupTempFiles([outputTempFile, inputTempFile]);
333+
console.error("[video/convert] Error:", err);
334+
335+
if (isBusyError(err)) {
336+
return c.json(
337+
{
338+
error: "Server is busy",
339+
code: "SERVER_BUSY",
340+
details: "Too many concurrent requests, please retry later",
341+
},
342+
503,
343+
);
344+
}
345+
346+
if (isTimeoutError(err)) {
347+
return c.json(
348+
{
349+
error: "Request timed out",
350+
code: "TIMEOUT",
351+
details: err instanceof Error ? err.message : String(err),
352+
},
353+
504,
354+
);
355+
}
356+
357+
return c.json(
358+
{
359+
error: "Failed to convert video",
360+
code: "FFMPEG_ERROR",
361+
details: err instanceof Error ? err.message : String(err),
362+
},
363+
500,
364+
);
365+
}
366+
});
367+
232368
video.post("/process", async (c) => {
233369
const body = await c.req.json();
234370
const result = processSchema.safeParse(body);

0 commit comments

Comments
 (0)