|
| 1 | +import { file } from "bun"; |
1 | 2 | import { Hono } from "hono"; |
2 | 3 | import { z } from "zod"; |
3 | 4 | import type { ResilientInputFlags } from "../lib/ffmpeg-video"; |
@@ -48,6 +49,11 @@ const thumbnailSchema = z.object({ |
48 | 49 | quality: z.number().min(1).max(100).optional(), |
49 | 50 | }); |
50 | 51 |
|
| 52 | +const convertSchema = z.object({ |
| 53 | + videoUrl: z.string().url(), |
| 54 | + inputExtension: z.string().optional(), |
| 55 | +}); |
| 56 | + |
51 | 57 | const processSchema = z.object({ |
52 | 58 | videoId: z.string(), |
53 | 59 | userId: z.string(), |
@@ -75,6 +81,65 @@ function isTimeoutError(err: unknown): boolean { |
75 | 81 | return err instanceof Error && err.message.includes("timed out"); |
76 | 82 | } |
77 | 83 |
|
| 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 | + |
78 | 143 | video.get("/status", (c) => { |
79 | 144 | const jobs = getAllJobs(); |
80 | 145 | const resources = getSystemResources(); |
@@ -229,6 +294,77 @@ video.post("/thumbnail", async (c) => { |
229 | 294 | } |
230 | 295 | }); |
231 | 296 |
|
| 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 | + |
232 | 368 | video.post("/process", async (c) => { |
233 | 369 | const body = await c.req.json(); |
234 | 370 | const result = processSchema.safeParse(body); |
|
0 commit comments