From 89d9e91d43ffcb17f3a524ee01461985d85d2ab4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 8 May 2026 17:27:26 +0000 Subject: [PATCH] feat(producer): add frame range rendering, probe, and audio-only endpoints - startFrame/endFrame/skipAudio on render body for chunk rendering - /probe endpoint returns composition metadata (totalFrames, fps, etc.) - /render/audio endpoint extracts audio track only (for parallel stitch) - frameRange and skipAudio passed through RenderConfig to orchestrator Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/producer/src/server.ts | 145 +++++++++++++++++- .../src/services/renderOrchestrator.ts | 10 ++ 2 files changed, 153 insertions(+), 2 deletions(-) diff --git a/packages/producer/src/server.ts b/packages/producer/src/server.ts index 861141a7d9..c585ef9e9b 100644 --- a/packages/producer/src/server.ts +++ b/packages/producer/src/server.ts @@ -7,7 +7,9 @@ * Routes: * POST /render — blocking render, returns JSON * POST /render/stream — SSE streaming render with progress + * POST /render/audio — render audio track only * GET /render/queue — current render queue status + * POST /probe — probe composition metadata (frames, duration, etc.) * POST /lint — blocking Hyperframe lint * GET /health — health check * GET /outputs/:token — download rendered MP4 @@ -34,6 +36,8 @@ import { createRenderJob, executeRenderJob, } from "./services/renderOrchestrator.js"; +import { compileForRender } from "./services/htmlCompiler.js"; +import { processCompositionAudio } from "./services/audioMixer.js"; import { prepareHyperframeLintBody, runHyperframeLint } from "./services/hyperframeLint.js"; import { resolveRenderPaths } from "./utils/paths.js"; import { defaultLogger, type ProducerLogger } from "./logger.js"; @@ -75,6 +79,9 @@ interface RenderInput { useGpu: boolean; debug: boolean; entryFile?: string; + startFrame?: number; + endFrame?: number; + skipAudio?: boolean; } interface PreparedRenderInput { @@ -106,7 +113,25 @@ function parseRenderOptions(body: Record): Omit Promise; renderStream: (c: Context) => Response | Promise; + renderAudio: (c: Context) => Promise; + probe: (c: Context) => Promise; lint: (c: Context) => Promise; health: (c: Context) => Response; outputs: (c: Context) => Response; @@ -342,6 +369,11 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle debug: input.debug, entryFile: input.entryFile, logger: log, + frameRange: + input.startFrame != null && input.endFrame != null + ? { startFrame: input.startFrame, endFrame: input.endFrame } + : undefined, + skipAudio: input.skipAudio, }); let lastLoggedPct = -10; @@ -456,6 +488,11 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle debug: input.debug, entryFile: input.entryFile, logger: log, + frameRange: + input.startFrame != null && input.endFrame != null + ? { startFrame: input.startFrame, endFrame: input.endFrame } + : undefined, + skipAudio: input.skipAudio, }); const abortController = new AbortController(); const onRequestAbort = () => @@ -575,7 +612,109 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle queuedRenders: renderSemaphore.waitingCount, }); - return { render, renderStream, lint, health, outputs, queue }; + const probe = async (c: Context): Promise => { + const body = (await c.req.json().catch(() => ({}))) as Record; + const result = await prepareRenderBody(body); + if ("error" in result) return c.json({ error: result.error }, 400); + const { prepared } = result; + const { input } = prepared; + + const fps = input.fps; + const entryFile = input.entryFile ?? "index.html"; + const htmlPath = resolve(input.projectDir, entryFile); + const downloadDir = resolve(input.projectDir, ".probe-downloads"); + + try { + mkdirSync(downloadDir, { recursive: true }); + const compiled = await compileForRender(input.projectDir, htmlPath, downloadDir); + const metadata = { + totalFrames: Math.ceil(compiled.staticDuration * fps), + durationSeconds: compiled.staticDuration, + fps, + width: compiled.width, + height: compiled.height, + hasAudio: compiled.audios.length > 0, + videoCount: compiled.videos.length, + hasShaderTransitions: compiled.hasShaderTransitions, + }; + + rmSync(downloadDir, { recursive: true, force: true }); + if (prepared.cleanupProjectDir) + rmSync(prepared.cleanupProjectDir, { recursive: true, force: true }); + return c.json(metadata); + } catch (err) { + rmSync(downloadDir, { recursive: true, force: true }); + if (prepared.cleanupProjectDir) + rmSync(prepared.cleanupProjectDir, { recursive: true, force: true }); + return c.json({ error: err instanceof Error ? err.message : String(err) }, 500); + } + }; + + const renderAudio = async (c: Context): Promise => { + const body = (await c.req.json().catch(() => ({}))) as Record; + const result = await prepareRenderBody(body); + if ("error" in result) return c.json({ error: result.error }, 400); + const { prepared } = result; + const { input } = prepared; + + const entryFile = input.entryFile ?? "index.html"; + const htmlPath = resolve(input.projectDir, entryFile); + const downloadDir = resolve(input.projectDir, ".audio-downloads"); + const audioWorkDir = resolve(input.projectDir, ".audio-work"); + + try { + mkdirSync(downloadDir, { recursive: true }); + mkdirSync(audioWorkDir, { recursive: true }); + const compiled = await compileForRender(input.projectDir, htmlPath, downloadDir); + + if (compiled.audios.length === 0) { + rmSync(downloadDir, { recursive: true, force: true }); + rmSync(audioWorkDir, { recursive: true, force: true }); + if (prepared.cleanupProjectDir) + rmSync(prepared.cleanupProjectDir, { recursive: true, force: true }); + return c.json({ hasAudio: false, outputPath: null }); + } + + const outputPath = input.outputPath || resolve(input.projectDir, "audio.aac"); + const compiledDir = resolve(input.projectDir, ".audio-downloads"); + + const audioResult = await processCompositionAudio( + compiled.audios, + input.projectDir, + audioWorkDir, + outputPath, + compiled.staticDuration, + undefined, + undefined, + compiledDir, + ); + + rmSync(downloadDir, { recursive: true, force: true }); + rmSync(audioWorkDir, { recursive: true, force: true }); + if (prepared.cleanupProjectDir) + rmSync(prepared.cleanupProjectDir, { recursive: true, force: true }); + + if (!audioResult.success) { + return c.json({ error: audioResult.error || "Audio processing failed" }, 500); + } + + return c.json({ + hasAudio: true, + outputPath, + durationSeconds: compiled.staticDuration, + tracksProcessed: audioResult.tracksProcessed, + durationMs: audioResult.durationMs, + }); + } catch (err) { + rmSync(downloadDir, { recursive: true, force: true }); + rmSync(audioWorkDir, { recursive: true, force: true }); + if (prepared.cleanupProjectDir) + rmSync(prepared.cleanupProjectDir, { recursive: true, force: true }); + return c.json({ error: err instanceof Error ? err.message : String(err) }, 500); + } + }; + + return { render, renderStream, renderAudio, probe, lint, health, outputs, queue }; } // --------------------------------------------------------------------------- @@ -590,8 +729,10 @@ export function createProducerApp(options: HandlerOptions = {}): Hono { const handlers = createRenderHandlers(options); app.get("/health", handlers.health); + app.post("/probe", handlers.probe); app.post("/render", handlers.render); app.post("/render/stream", handlers.renderStream); + app.post("/render/audio", handlers.renderAudio); app.get("/render/queue", handlers.queue); app.post("/lint", handlers.lint); app.get("/outputs/:token", handlers.outputs); diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index c7026c55de..a96322d9c7 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -287,6 +287,16 @@ export interface RenderConfig { * HDR constraints. */ outputResolution?: CanvasResolution; + /** + * Restrict rendering to a sub-range of frames. Used by chunk rendering to + * split a composition across multiple workers. + */ + frameRange?: FrameRange; + /** + * When `true`, skip audio processing entirely. Chunk renderers produce + * video-only segments and mix audio in a separate pass. + */ + skipAudio?: boolean; } export interface RenderPerfSummary {