import type { DocumentInterface } from "@/srv/types/DocumentSchema"; import type { CaptionStyleConfigType } from "@/srv/types/CaptionStyleSchema"; import { serverConfig } from "@/srv/config"; import { statSync } from "node:fs"; const OUTPUT_DIR = "out"; const KILOBYTE = 1024; const MEGABYTE = 1024 * KILOBYTE; const MAX_CAPTURED_STREAM_OUTPUT_CHARS = 20_000; const RENDER_CANCELLATION_POLL_MS = 1_000; export const RENDER_CANCELLED_ERROR_MESSAGE = "Render cancelled"; export type RenderResult = { output: string; }; export type RenderProgress = { renderedFrames: number; totalFrames: number; }; function formatBytes(bytes: number): string { if (bytes >= MEGABYTE) { return `${(bytes / MEGABYTE).toFixed(1)} MB`; } if (bytes >= KILOBYTE) { return `${(bytes / KILOBYTE).toFixed(1)} KB`; } return `${bytes} B`; } function formatDuration(durationMs: number): string { return `${(durationMs / 1000).toFixed(1)}s`; } function appendCapturedOutput(current: string, nextLine: string): string { const combined = current ? `${current}\n${nextLine}` : nextLine; if (combined.length <= MAX_CAPTURED_STREAM_OUTPUT_CHARS) { return combined; } return combined.slice(-MAX_CAPTURED_STREAM_OUTPUT_CHARS); } function parseRenderProgressFromLine( line: string, onProgress?: (progress: RenderProgress) => void, ): void { if (!onProgress) { return; } const frameMatch = line.match( /(?:Rendered?\s+frame|Rendered|Encoded)\s+(\d+)\s*\/\s*(\d+)/i, ); if (!frameMatch) { return; } onProgress({ renderedFrames: parseInt(frameMatch[1], 10), totalFrames: parseInt(frameMatch[2], 10), }); } async function consumeProcessStream( stream: ReadableStream | null | undefined, label: "stdout" | "stderr", onLine?: (line: string) => void, ): Promise { if (!stream) { return ""; } const reader = stream.getReader(); const decoder = new TextDecoder(); let buffer = ""; let captured = ""; try { while (true) { const { done, value } = await reader.read(); if (done) { break; } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { const normalizedLine = line.trimEnd(); if (!normalizedLine) { continue; } captured = appendCapturedOutput(captured, normalizedLine); console.log(`[render:${label}] ${normalizedLine}`); onLine?.(normalizedLine); } } const tail = buffer.trim(); if (tail) { captured = appendCapturedOutput(captured, tail); console.log(`[render:${label}] ${tail}`); onLine?.(tail); } } catch (error) { console.warn( `[render:${label}] Stream reader closed unexpectedly: ${error instanceof Error ? error.message : String(error)}`, ); } return captured; } export async function renderCaptionedVideo( transcription: DocumentInterface, videoSrc: string, filename: string, styleConfig?: CaptionStyleConfigType, onProgress?: (progress: RenderProgress) => void, shouldCancel?: () => boolean | Promise, ): Promise { const uniqueFilename = `${crypto.randomUUID()}-${filename}`; const outputPath = `${OUTPUT_DIR}/${uniqueFilename}`; const propsPath = `${OUTPUT_DIR}/${crypto.randomUUID()}.json`; const props: Record = { videoSrc, transcription, fps: 30, }; if (styleConfig) { props.styleConfig = styleConfig; } await Bun.write(propsPath, JSON.stringify(props)); try { const renderStartedAt = Date.now(); const args = [ "render", "src/index.ts", serverConfig.REMOTION_COMPOSITION_ID, outputPath, "--props", propsPath, ]; const cmd = ["./node_modules/.bin/remotion", ...args]; console.log(`[render] Starting: ${cmd.join(" ")}`); console.log(`[render] Source video: ${videoSrc}`); console.log(`[render] Output: ${outputPath}, Props: ${propsPath}`); const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe", }); let renderCancelled = false; const stopRenderIfCancelled = async () => { if (!shouldCancel || renderCancelled) { return; } const cancellationRequested = await shouldCancel(); if (!cancellationRequested) { return; } renderCancelled = true; proc.kill(); }; const cancellationTimer = shouldCancel ? setInterval(() => { void stopRenderIfCancelled(); }, RENDER_CANCELLATION_POLL_MS) : null; const handleProcessLine = (line: string) => { parseRenderProgressFromLine(line, onProgress); }; const stdoutPromise = consumeProcessStream( proc.stdout, "stdout", handleProcessLine, ); const stderrPromise = consumeProcessStream( proc.stderr, "stderr", handleProcessLine, ); const exitCode = await proc.exited; if (cancellationTimer) { clearInterval(cancellationTimer); } const [capturedStdout, capturedStderr] = await Promise.all([ stdoutPromise, stderrPromise, ]); if (renderCancelled) { throw new Error(RENDER_CANCELLED_ERROR_MESSAGE); } if (exitCode !== 0) { console.error( `[render] Failed after ${formatDuration(Date.now() - renderStartedAt)} (exit ${exitCode})`, ); if (capturedStdout) { console.error(`[render] Last stdout:\n${capturedStdout}`); } if (capturedStderr) { console.error(`[render] Last stderr:\n${capturedStderr}`); } throw new Error( `Render process exited with code ${exitCode}: ${(capturedStderr || capturedStdout).slice(0, 2000)}`, ); } const outputSize = statSync(outputPath).size; console.log( `[render] Completed in ${formatDuration(Date.now() - renderStartedAt)} (${formatBytes(outputSize)})`, ); return { output: outputPath }; } finally { await Bun.file(propsPath).delete().catch(() => {}); } }