From 7dc0164a54fc122964792c33a7aaeeddf72c77fd Mon Sep 17 00:00:00 2001 From: Hongbin Li Date: Fri, 17 Jul 2026 00:14:15 -0700 Subject: [PATCH] feat(core): capture range, pinned encoder params, upload sink + progress for capture-video templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - capture.range { startFrame, endFrame }: render a sub-range of the composition as an independent segment — frames are evaluated at their global timeline time but captured with segment-local timestamps, so a host can render disjoint ranges (distributed or resumable rendering) and concatenate the files externally. Validated against ceil(duration*fps). - capture.encoder { codec, bitrate }: pin encoder settings explicitly so every segment of one render shares a single configuration; defaults unchanged (avc/vp9 by format, QUALITY_HIGH). - capture.uploadUrl: PUT the finished bytes instead of embedding base64 in __renderComplete; fail-open to embedding with an uploadError field so a render is never lost. Avoids marshalling large outputs through strings. - window.__renderProgress = { framesDone, totalFrames } during the loop. - deterministic video handling in the capture loop: __vos__.isPaused = true and the two-phase waitForVideosReady settle (matching the client exporter), so compositions with video sources capture frame-accurately. --- .changeset/capture-range-encoder-upload.md | 11 ++ .../core/src/__tests__/renderTemplate.test.ts | 95 +++++++++++++ packages/core/src/runtime/renderTemplate.ts | 131 +++++++++++++++--- 3 files changed, 214 insertions(+), 23 deletions(-) create mode 100644 .changeset/capture-range-encoder-upload.md diff --git a/.changeset/capture-range-encoder-upload.md b/.changeset/capture-range-encoder-upload.md new file mode 100644 index 0000000..f71bee7 --- /dev/null +++ b/.changeset/capture-range-encoder-upload.md @@ -0,0 +1,11 @@ +--- +'@vosjs/core': minor +--- + +capture-video templates gain segment-friendly capture controls: + +- `capture.range?: { startFrame, endFrame }` — render only a sub-range of the composition as an independent segment (frames evaluate at global composition time; output timestamps start at 0), enabling distributed or resumable rendering with external concatenation. +- `capture.encoder?: { codec?, bitrate? }` — pin encoder settings explicitly so every segment of one render shares a single configuration (defaults unchanged: avc/vp9 by format, QUALITY_HIGH). +- `capture.uploadUrl?` — PUT the finished bytes to a URL instead of embedding base64 in `__renderComplete` (fail-open: on upload failure the bytes are embedded with an `uploadError` field). +- structured `window.__renderProgress = { framesDone, totalFrames }` during the capture loop. +- deterministic video handling in the capture loop: `__vos__.isPaused = true` plus the two-phase `waitForVideosReady` settle (matching the client exporter), so compositions with video sources capture frame-accurately. diff --git a/packages/core/src/__tests__/renderTemplate.test.ts b/packages/core/src/__tests__/renderTemplate.test.ts index 2274b28..0ab4e45 100644 --- a/packages/core/src/__tests__/renderTemplate.test.ts +++ b/packages/core/src/__tests__/renderTemplate.test.ts @@ -150,6 +150,101 @@ describe('generateRenderTemplate', () => { }) }) + describe('capture-video: range, encoder, upload, progress', () => { + const capture = { width: 640, height: 360, duration: 5, fps: 30 } + + it('defaults to the full frame range with local == global timestamps', () => { + const html = generateRenderTemplate(sampleCode, { + mode: 'capture-video', + capture, + }) + expect(html).toContain('const startFrame = 0;') + expect(html).toContain('const endFrame = 150;') + // Segment-local capture timestamps (identical to global when start=0). + expect(html).toContain('videoSource.add((frame - startFrame) / 30, 1 / 30)') + }) + + it('captures a sub-range as an independent segment', () => { + const html = generateRenderTemplate(sampleCode, { + mode: 'capture-video', + capture: { ...capture, range: { startFrame: 30, endFrame: 60 } }, + }) + expect(html).toContain('const startFrame = 30;') + expect(html).toContain('const endFrame = 60;') + // Frames are still evaluated at global composition time. + expect(html).toContain('const time = frame / 30;') + }) + + it.each([ + [{ startFrame: -1, endFrame: 10 }], + [{ startFrame: 10, endFrame: 10 }], + [{ startFrame: 20, endFrame: 10 }], + [{ startFrame: 0, endFrame: 151 }], + [{ startFrame: 0.5, endFrame: 10 }], + ])('rejects invalid range %j', (range) => { + expect(() => + generateRenderTemplate(sampleCode, { + mode: 'capture-video', + capture: { ...capture, range }, + }), + ).toThrow(/capture\.range/) + }) + + it('uses format-default encoder settings when not pinned', () => { + const webm = generateRenderTemplate(sampleCode, { + mode: 'capture-video', + capture, + }) + expect(webm).toContain('codec: "vp9"') + expect(webm).toContain('bitrate: QUALITY_HIGH') + const mp4 = generateRenderTemplate(sampleCode, { + mode: 'capture-video', + capture: { ...capture, format: 'mp4' as const }, + }) + expect(mp4).toContain('codec: "avc"') + }) + + it('pins explicit encoder settings verbatim', () => { + const html = generateRenderTemplate(sampleCode, { + mode: 'capture-video', + capture: { + ...capture, + encoder: { codec: 'vp8' as const, bitrate: 5_000_000 }, + }, + }) + expect(html).toContain('codec: "vp8"') + expect(html).toContain('bitrate: 5000000') + expect(html).not.toContain('bitrate: QUALITY_HIGH') + }) + + it('PUTs to uploadUrl when provided, embeds base64 otherwise', () => { + const withUpload = generateRenderTemplate(sampleCode, { + mode: 'capture-video', + capture: { ...capture, uploadUrl: 'https://example.com/put-here' }, + }) + expect(withUpload).toContain('"https://example.com/put-here"') + expect(withUpload).toContain("method: 'PUT'") + expect(withUpload).toContain('uploaded: true') + + const without = generateRenderTemplate(sampleCode, { + mode: 'capture-video', + capture, + }) + expect(without).toContain('const uploadUrl = null;') + expect(without).toContain('base64Data') + }) + + it('reports structured progress and settles videos deterministically', () => { + const html = generateRenderTemplate(sampleCode, { + mode: 'capture-video', + capture, + }) + expect(html).toContain('window.__renderProgress') + expect(html).toContain('waitForVideosReady') + expect(html).toContain('window.__vos__.isPaused = true') + }) + }) + describe('Three.js version customization', () => { it('uses custom three version in importmap', () => { const html = generateRenderTemplate(sampleCode, { diff --git a/packages/core/src/runtime/renderTemplate.ts b/packages/core/src/runtime/renderTemplate.ts index a84ec16..5a9190d 100644 --- a/packages/core/src/runtime/renderTemplate.ts +++ b/packages/core/src/runtime/renderTemplate.ts @@ -34,6 +34,36 @@ export interface RenderTemplateOptions { thumbnailTime?: number /** Output format for capture-video mode (default 'webm') */ format?: 'webm' | 'mp4' + /** + * Capture only frames `[startFrame, endFrame)` of the composition + * (capture-video mode). The output file is an independent segment: it + * starts on a keyframe and its timestamps begin at 0, so a host can + * render disjoint ranges (distributed or resumable rendering) and + * concatenate the segments externally. Defaults to the full + * `[0, ceil(duration * fps))`. Frames are still *evaluated* at their + * global composition time — only the output timestamps are local. + */ + range?: { startFrame: number; endFrame: number } + /** + * Explicit encoder settings (capture-video mode). When omitted, the + * format defaults apply (`avc` for mp4, `vp9` for webm, QUALITY_HIGH + * bitrate). Range-based workflows should pin these so every segment of + * one render shares a single encoder configuration. + */ + encoder?: { + codec?: 'avc' | 'hevc' | 'vp8' | 'vp9' | 'av1' + /** Bits per second (default: mediabunny's QUALITY_HIGH). */ + bitrate?: number + } + /** + * PUT the finished capture bytes to this URL instead of embedding them + * as base64 in `__renderComplete.data` (capture-video mode). On success + * `__renderComplete` is `{ success: true, uploaded: true, size }`; if + * the upload fails the bytes are embedded as usual with an `uploadError` + * field, so the render is never lost. Avoids marshalling large outputs + * through strings. + */ + uploadUrl?: string } /** URLs to generate hints for */ preloadModuleUrls?: string[] @@ -556,26 +586,40 @@ function generateCaptureVideoBody( const { width, height, duration, fps, format = 'webm' } = capture const transformedCode = transformModuleCode(animationCode, 'server') - // Format-specific configuration + const totalFrames = Math.ceil(duration * fps) + const startFrame = capture.range?.startFrame ?? 0 + const endFrame = capture.range?.endFrame ?? totalFrames + if (capture.range) { + if ( + !Number.isInteger(startFrame) || + !Number.isInteger(endFrame) || + startFrame < 0 || + endFrame <= startFrame || + endFrame > totalFrames + ) { + throw new Error( + `capture.range must satisfy 0 <= startFrame < endFrame <= ${totalFrames} ` + + `(ceil(duration * fps)); got [${startFrame}, ${endFrame})`, + ) + } + } + + // Format-specific configuration. Encoder settings may be pinned explicitly + // so multi-segment renders share one configuration. const isMp4 = format === 'mp4' - const formatSetup = isMp4 - ? `const { Output, CanvasSource, BufferTarget, Mp4OutputFormat, QUALITY_HIGH } = await import('mediabunny'); + const codec = capture.encoder?.codec ?? (isMp4 ? 'avc' : 'vp9') + const bitrate = + capture.encoder?.bitrate !== undefined + ? String(capture.encoder.bitrate) + : 'QUALITY_HIGH' + const formatSetup = `const { Output, CanvasSource, BufferTarget, ${isMp4 ? 'Mp4OutputFormat' : 'WebMOutputFormat'}, QUALITY_HIGH } = await import('mediabunny'); const output = new Output({ - format: new Mp4OutputFormat(), + format: new ${isMp4 ? 'Mp4OutputFormat' : 'WebMOutputFormat'}(), target: new BufferTarget(), }); const videoSource = new CanvasSource(canvas, { - codec: 'avc', - bitrate: QUALITY_HIGH, - });` - : `const { Output, CanvasSource, BufferTarget, WebMOutputFormat, QUALITY_HIGH } = await import('mediabunny'); - const output = new Output({ - format: new WebMOutputFormat(), - target: new BufferTarget(), - }); - const videoSource = new CanvasSource(canvas, { - codec: 'vp9', - bitrate: QUALITY_HIGH, + codec: ${JSON.stringify(codec)}, + bitrate: ${bitrate}, });` const mimeType = isMp4 ? 'video/mp4' : 'video/webm' @@ -586,6 +630,10 @@ function generateCaptureVideoBody( ${elementsBlock} + // Deterministic capture: compositions must SEEK their videos, never + // play them (same contract as the client-side exporter). + window.__vos__.isPaused = true; + // Override window dimensions Object.defineProperty(window, 'innerWidth', { value: ${width}, configurable: true }); Object.defineProperty(window, 'innerHeight', { value: ${height}, configurable: true }); @@ -647,15 +695,30 @@ ${elementsBlock} output.addVideoTrack(videoSource, { frameRate: ${fps} }); await output.start(); - const totalFrames = Math.ceil(${duration} * ${fps}); + // Frames [startFrame, endFrame) — evaluated at global composition + // time, captured with segment-local timestamps (the file starts + // at t=0 regardless of where the range sits on the timeline). + const startFrame = ${startFrame}; + const endFrame = ${endFrame}; + const wvr = () => (window.__vos__.waitForVideosReady ? window.__vos__.waitForVideosReady() : null); + const pendingDecodes = () => (window.__vos__.pendingDecodes ? window.__vos__.pendingDecodes.size : 0); - for (let frame = 0; frame < totalFrames; frame++) { + for (let frame = startFrame; frame < endFrame; frame++) { const time = frame / ${fps}; timeline.seek(time, false); + // Two-phase video settle (same as the client exporter): wait for + // seeked decodes, render, then re-check decodes the render + // itself triggered. + await wvr(); await new Promise(r => requestAnimationFrame(r)); + if (pendingDecodes() > 0) { + await wvr(); + await new Promise(r => requestAnimationFrame(r)); + } if (gl) gl.finish(); - await videoSource.add(time, 1 / ${fps}); + await videoSource.add((frame - startFrame) / ${fps}, 1 / ${fps}); + window.__renderProgress = { framesDone: frame - startFrame + 1, totalFrames: endFrame - startFrame }; } await output.finalize(); @@ -665,6 +728,28 @@ ${elementsBlock} throw new Error('Export failed: output buffer is null'); } + // Cleanup before the (potentially slow) result handoff + if (cleanup) cleanup(); + + const uploadUrl = ${JSON.stringify(capture.uploadUrl ?? null)}; + let uploadError = null; + if (uploadUrl) { + try { + const res = await fetch(uploadUrl, { + method: 'PUT', + headers: { 'Content-Type': '${mimeType}' }, + body: buffer, + }); + if (!res.ok) throw new Error('HTTP ' + res.status); + window.__renderComplete = { success: true, uploaded: true, size: buffer.byteLength }; + return; + } catch (e) { + // Fall back to embedding so the render is never lost; the + // host reads uploadError and decides. + uploadError = String((e && e.message) || e); + } + } + // Convert to base64 const bytes = new Uint8Array(buffer); let binary = ''; @@ -673,15 +758,15 @@ ${elementsBlock} } const base64Data = 'data:${mimeType};base64,' + btoa(binary); - // Cleanup - if (cleanup) cleanup(); - - // Signal completion - window.__renderComplete = { + // Signal completion (built fully before assignment — hosts poll + // __renderComplete and must never observe a half-written result) + const complete = { success: true, data: base64Data, size: bytes.length }; + if (uploadError) complete.uploadError = uploadError; + window.__renderComplete = complete; } catch (error) { console.error('Render error:', error);