@vosjs/render-core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @vosso/render-core
1
+ # @vosjs/render-core
2
2
 
3
3
  The orchestration math and Node-side media plumbing for deterministic vos
4
4
  renders. Pixels never leave the browser — every frame is encoded in-page via
@@ -6,9 +6,15 @@ WebCodecs + [mediabunny](https://mediabunny.dev). This package owns everything
6
6
  around that: how a timeline is sharded into parallel chunks, and how the encoded
7
7
  chunks are stitched back into one file without re-encoding.
8
8
 
9
- Pure Node, no browser and no WebGL. Consumed in-source by
10
- [`@vosso/vos-plugin`](../vos-plugin) (bundled via tsup `noExternal`) and by the
11
- vosso render worker's finalize path. MIT.
9
+ Pure Node, no browser and no WebGL. Consumed by the `vos` CLI's local render
10
+ (`../cli`) and by any host that fans a render out across browser sessions and
11
+ stitches the parts back together. MIT.
12
+
13
+ What is deliberately NOT here: the pages a hosted fleet runs (a finalize page
14
+ that fetches parts from an ingest route, an audio mix page, a digest page) and
15
+ any queue, storage or session-pool policy. Those are a host's opinions about its
16
+ own infrastructure; this package hands them the plan and the mux and nothing
17
+ else.
12
18
 
13
19
  ## Why sharding is correct here
14
20
 
@@ -31,25 +37,24 @@ priming seams never exist.
31
37
 
32
38
  ## Exports
33
39
 
34
- | Export | Purpose |
35
- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
36
- | `planChunks(totalFrames, fps, policy)` | Split a timeline into balanced frame ranges (floor `DEFAULT_MIN_FRAMES_PER_CHUNK` per chunk). Pure math. |
37
- | `concatEncodedVideo(chunks, options)` | Stream-copy encoded chunks into one video (mediabunny demux/mux, no re-encode). |
38
- | `countVideoPackets(bytes)` | Count video packets in an encoded file — used by parity checks. |
39
- | `buildFinalizeConcatPage(options)` | Build the in-browser finalize page: fetch chunk parts, concat, mux produced audio, upload the result. Runs in a browser page (its memory, not the 128 MB isolate). |
40
- | `audioProducerCode()` / `dataHasAudio(data)` | Page-JS mirror of the client audio exporter — produces the mixed audio buffer at finalize from the lowered Voila data. |
40
+ | Export | Purpose |
41
+ | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
42
+ | `planChunks(totalFrames, fps, policy)` | Split a timeline into balanced frame ranges (floor `DEFAULT_MIN_FRAMES_PER_CHUNK` per chunk). Pure math. |
43
+ | `concatEncodedVideo(chunks, options)` | Stream-copy encoded chunks into one video (mediabunny demux/mux, no re-encode). |
44
+ | `countVideoPackets(bytes)` | Count video packets in an encoded file — used by parity checks. |
45
+ | `audioProducerCode()` / `dataHasAudio(data)` | Page-JS mirror of the client audio exporter produces the mixed audio buffer at finalize from the lowered Voila data. |
41
46
 
42
47
  ## Contract: the concat mirror
43
48
 
44
- `concat.ts` and the concat inside `buildFinalizeConcatPage` must stay
45
- **packet-identical** the finalize page runs in a browser, the standalone concat
46
- runs in Node, and cloud exports fan-in through the page while the CLI uses the Node
47
- path. `vos-plugin`'s `verify-finalize-page` asserts they produce identical output;
48
- keep the two in sync when touching either.
49
+ A host that concatenates chunk parts inside a browser page (a fleet's finalize
50
+ fallback, where the Node mux cannot run) must stay **packet-identical** to
51
+ `concat.ts`: the CLI uses the Node path, a fleet may use a page, and the two
52
+ must produce the same file. Keep any such page in sync with `concat.ts` and
53
+ assert the parity in the host's own harness.
49
54
 
50
55
  ## Development
51
56
 
52
57
  ```bash
53
- pnpm --filter @vosso/render-core test # vitest
54
- pnpm --filter @vosso/render-core typecheck
58
+ pnpm --filter @vosjs/render-core test # vitest
59
+ pnpm --filter @vosjs/render-core typecheck
55
60
  ```
package/dist/index.d.ts CHANGED
@@ -122,68 +122,6 @@ declare function concatEncodedVideo(chunks: ConcatChunk[], options: ConcatOption
122
122
  */
123
123
  declare function countVideoPackets(data: Uint8Array): Promise<number>;
124
124
 
125
- /**
126
- * Finalize page — an HTML page that stream-copy concatenates encoded chunk
127
- * videos IN A BROWSER and PUTs the result to an upload URL.
128
- *
129
- * Why a page and not the Worker: concat needs every chunk in memory plus the
130
- * output buffer, which busts a 128MB isolate on real exports; a browser page
131
- * (the same Browser Run substrate that rendered the chunks) has gigabytes,
132
- * mediabunny already proven in it, and gives the later audio-mix step a home
133
- * (OfflineAudioContext exists only in browsers).
134
- *
135
- * The in-page algorithm MIRRORS ./concat.ts (concatEncodedVideo) — same
136
- * packet walk, same plan-derived timestamp offsets, same keyframe guard.
137
- * KEEP THEM IN SYNC; scripts/verify-finalize-page.ts (vos-plugin) asserts the
138
- * two implementations produce packet-identical output.
139
- *
140
- * Failure contract: any error (fetch, codec mismatch, upload) lands in
141
- * `__renderComplete = { success: false, error }` — there is no base64
142
- * fallback here (finals can be huge); the caller fails the job.
143
- */
144
- interface FinalizePart {
145
- /** Where the page fetches this chunk's encoded bytes (CORS-accessible). */
146
- url: string;
147
- /** The chunk's PLANNED duration in seconds (frameCount / fps) — the
148
- * timestamp offset source, never demuxed durations (rounding drifts). */
149
- duration: number;
150
- }
151
- interface FinalizeConcatPageOptions {
152
- parts: FinalizePart[];
153
- format: 'webm' | 'mp4';
154
- /** Stamped into the output track metadata. */
155
- frameRate: number;
156
- /** The finished file is PUT here (Content-Type set from `format`). */
157
- uploadUrl: string;
158
- /**
159
- * Audio-once-at-finalize (the rendering plan's audio model — chunks are
160
- * video-only by construction, so encoder priming seams never exist):
161
- * `producerCode` defines `window.__vosAudioProducer__({ data, duration,
162
- * sampleRate }) => AudioBuffer | null` (the studio clips' plan rides the
163
- * code itself, baked by `audioProducerCode({ plan })`, since this
164
- * page passes none), evaluated before the concat; a
165
- * returned buffer is muxed as the output's audio track (AAC for mp4 with
166
- * an Opus fallback, Opus for webm). Null/absent = video-only.
167
- */
168
- audio?: {
169
- producerCode: string;
170
- data: unknown;
171
- /** Total OUTPUT duration in seconds (the mix length). */
172
- duration: number;
173
- };
174
- /**
175
- * Pre-encoded audio: the `audio` ingest part produced by the audio
176
- * mix page. Its packets are STREAM-COPIED into the output — no decode, no
177
- * OfflineAudioContext, no producer — so a fallback finalize on this page
178
- * carries none of the audio-production memory bill. Mutually exclusive
179
- * with `audio`.
180
- */
181
- audioPart?: {
182
- url: string;
183
- };
184
- }
185
- declare function buildFinalizeConcatPage(options: FinalizeConcatPageOptions): string;
186
-
187
125
  /**
188
126
  * The take audio producer — page JavaScript implementing the export audio mix
189
127
  * for the take data schema, as a source string for injection into render
@@ -267,128 +205,4 @@ declare function audioProducerCode(options?: AudioProducerCodeOptions): string;
267
205
  */
268
206
  declare function dataHasAudio(data: unknown, stack?: unknown, plan?: AudioPlanJson | null): boolean;
269
207
 
270
- /**
271
- * Audio mix page — an HTML page that renders a chunked export's audio track
272
- * ALONE and PUTs it to the ingest route as the `audio` part.
273
- *
274
- * Why a page: the mix needs OfflineAudioContext + WebCodecs AudioEncoder,
275
- * which exist only in browsers. Why ALONE: the finalize page used to produce
276
- * audio AND concat video in one context, and the combined memory bill
277
- * (whole source recording + full-length PCM + all parts + output buffer)
278
- * is what the fleet kills at peak (job 288257ae). This page's
279
- * live set stays ~tens of MB by construction:
280
- *
281
- * - the source recording is never fetched whole: `__vosStreamSplice__`
282
- * (installed here, consumed by the shared audio producer) streams-decodes
283
- * ONLY the needed source spans through mediabunny's UrlSource, whose
284
- * read cache is bounded (~8MiB) — the asset route serves Range/206.
285
- * - the spliced voice buffer is capped at the REQUESTED output duration,
286
- * which is also the duration fix (a duration-capped render must not carry the
287
- * full take's audio).
288
- * - the encoded result is ~1MB of Opus in WebM.
289
- *
290
- * The whole-file decodeAudioData path stays as the in-page fallback rung
291
- * (unsupported codec, no Range support): the producer falls back on any
292
- * stream-splice failure, so a mix is never LOST to the optimization.
293
- *
294
- * Runs CONCURRENTLY with the chunk phase — it depends only on the source
295
- * recording and the doc, never on a part.
296
- *
297
- * Failure contract: mirrors the finalize page — any error lands in
298
- * `__renderComplete = { success: false, error }`; stage markers ride
299
- * `__finalizeStage` (heap-stamped) so a death localizes itself.
300
- */
301
-
302
- interface AudioMixPageOptions {
303
- /** The vos's resolved ctx.data (asset URLs absolute, render token baked). */
304
- data: unknown;
305
- /**
306
- * The studio clips as an engine audio plan, built by the host from
307
- * the stored config's `vosso.studio` stack entry; null when the take has
308
- * no music/SFX. Rides the page's CONFIG, once.
309
- */
310
- plan?: AudioPlanJson | null;
311
- /** Requested OUTPUT duration in seconds — the mix length, the trim bound. */
312
- duration: number;
313
- /** The finished audio file is PUT here (ingest `?part=audio`). */
314
- uploadUrl: string;
315
- }
316
- declare function buildAudioMixPage(options: AudioMixPageOptions): string;
317
-
318
- /**
319
- * Image diff page — computes the RMS difference between two images in a
320
- * browser page. Workers can't decode images (no canvas/Image APIs), so the
321
- * golden-frame canary hands both images to a page and reads one number back.
322
- *
323
- * Same completion contract as every render page: `__renderComplete` with
324
- * `{ success, rms, width, height }` or `{ success: false, error }`.
325
- * RMS is over RGB (alpha ignored) in 0–255 units: WebP re-encode jitter of
326
- * the same frame lands well under 5; a real engine/fleet drift (missing
327
- * layer, changed shader, font fallback) lands far above 10.
328
- */
329
- interface ImageDiffPageOptions {
330
- /** data: URLs — the canary inlines both images (they are ~tens of KB). */
331
- candidateUrl: string;
332
- goldenUrl: string;
333
- }
334
- declare function buildImageDiffPage(options: ImageDiffPageOptions): string;
335
-
336
- /**
337
- * Digest page — the fleet's eyes for a take. One bare page decodes the
338
- * recording through a <video> and does what the CLI's digest page does
339
- * locally: per-source-second motion bins (64×36 luma diff, changed-pixel
340
- * fraction), the scene instants those bins reveal, one FOOTAGE frame plus a
341
- * crop per moment, and a contact sheet. Every image PUTs itself to the ingest
342
- * route as a `digest-*` part (the page's bytes never marshal through the
343
- * browser session), then a manifest with the bins and every image's size.
344
- *
345
- * Same completion contract as every render page: `__renderComplete` with
346
- * `{ success, uploaded: true }` or `{ success: false, error }`.
347
- *
348
- * The bins must be byte-comparable with the CLI's (`MOTION_DELTA` 24, one
349
- * seek per source second at i + 0.5, 64×36): they feed the speed planner.
350
- */
351
- interface DigestShot {
352
- /** Part stem; the page writes `<name>.full.png` and, with a box, `<name>.crop.png`. */
353
- name: string;
354
- /** Source instant, seconds. */
355
- t: number;
356
- /** Crop box in FRAME px, or null for a full-only moment. */
357
- box: {
358
- x: number;
359
- y: number;
360
- w: number;
361
- h: number;
362
- } | null;
363
- /** A short label for the contact sheet. */
364
- label: string;
365
- }
366
- interface DigestPageOptions {
367
- /** The recording, reachable from the page (a `?rt=` asset URL). */
368
- videoUrl: string;
369
- /** Source duration, seconds. */
370
- durationS: number;
371
- /** The region of the frame the doc renders (a window take's crop, or all). */
372
- region: {
373
- x: number;
374
- y: number;
375
- w: number;
376
- h: number;
377
- };
378
- shots: DigestShot[];
379
- /** Long edges (px) of the emitted images. */
380
- fullMax: number;
381
- cropMax: number;
382
- /** Changed-pixel luma threshold for the bins. */
383
- motionDelta: number;
384
- /** Scene rule: a bin at/above `motion` after one at/below `quiet`. */
385
- scene: {
386
- motion: number;
387
- quiet: number;
388
- };
389
- /** `…/api/render/ingest/{jobId}?token=…` — parts append `&part=digest-<name>`. */
390
- uploadUrl: string;
391
- }
392
- declare function buildDigestPage(options: DigestPageOptions): string;
393
-
394
- export { type AudioMixPageOptions, type AudioPlanJson, type AudioProducerCodeOptions, CORE_AUDIO_CDN_URL, type ChunkPlanPolicy, type ConcatChunk, type ConcatOptions, type ConcatResult, DEFAULT_MIN_FRAMES_PER_CHUNK, type DigestPageOptions, type DigestShot, type FinalizeConcatPageOptions, type FinalizePart, type ImageDiffPageOptions, type MuxExportOptions, type RenderChunk, audioProducerCode, buildAudioMixPage, buildDigestPage, buildFinalizeConcatPage, buildImageDiffPage, concatEncodedVideo, countVideoPackets, dataHasAudio, muxEncodedExport, planChunks, studioEntryData };
208
+ export { type AudioPlanJson, type AudioProducerCodeOptions, CORE_AUDIO_CDN_URL, type ChunkPlanPolicy, type ConcatChunk, type ConcatOptions, type ConcatResult, DEFAULT_MIN_FRAMES_PER_CHUNK, type MuxExportOptions, type RenderChunk, audioProducerCode, concatEncodedVideo, countVideoPackets, dataHasAudio, muxEncodedExport, planChunks, studioEntryData };
package/dist/index.js CHANGED
@@ -161,190 +161,6 @@ async function countVideoPackets(data) {
161
161
  return count;
162
162
  }
163
163
 
164
- // src/finalizePage.ts
165
- var MEDIABUNNY_URL = "https://esm.sh/mediabunny@1.27.3?target=es2022";
166
- function buildFinalizeConcatPage(options) {
167
- if (options.parts.length === 0) {
168
- throw new Error("buildFinalizeConcatPage: no parts");
169
- }
170
- if (options.audio && options.audioPart) {
171
- throw new Error(
172
- "buildFinalizeConcatPage: audio and audioPart are mutually exclusive"
173
- );
174
- }
175
- const contentType = options.format === "mp4" ? "video/mp4" : "video/webm";
176
- const config = JSON.stringify({
177
- parts: options.parts,
178
- format: options.format,
179
- frameRate: options.frameRate,
180
- uploadUrl: options.uploadUrl,
181
- contentType,
182
- audioData: options.audio ? options.audio.data : null,
183
- audioDuration: options.audio ? options.audio.duration : 0,
184
- audioPartUrl: options.audioPart ? options.audioPart.url : null
185
- });
186
- const audioProducerBlock = options.audio ? options.audio.producerCode : "";
187
- return `<!doctype html>
188
- <html>
189
- <head><meta charset="utf-8"><title>vos finalize</title></head>
190
- <body>
191
- <script type="module">
192
- const CONFIG = ${config};
193
- ${audioProducerBlock}
194
-
195
- // Stage marker: the supervising worker polls this every tick and reports the
196
- // last stage seen when the page dies without a result \u2014 the only way to
197
- // localize a page death on a fleet with no devtools (a diagnosis aid).
198
- // Each stage carries the V8 heap sample when available, so a death
199
- // names its memory peak too. Heap only covers JS objects (ArrayBuffers are
200
- // external), so treat it as a floor, not the whole bill. The stage NAME must
201
- // stay the first space-delimited token \u2014 renderPolicy's finalizeDeathStage
202
- // parses it out of the error message.
203
- const stage = (s) => {
204
- const m = performance.memory;
205
- window.__finalizeStage = m ? s + ' heap=' + Math.round(m.usedJSHeapSize / 1048576) + 'MB' : s;
206
- };
207
- stage('boot');
208
-
209
- ;(async () => {
210
- stage('import-mediabunny');
211
- const MB = await import(${JSON.stringify(MEDIABUNNY_URL)});
212
-
213
- // Audio first: a producer failure aborts before any concat work.
214
- let audioBuffer = null;
215
- if (window.__vosAudioProducer__ && CONFIG.audioData != null) {
216
- stage('audio-produce');
217
- audioBuffer = await window.__vosAudioProducer__({
218
- data: CONFIG.audioData,
219
- duration: CONFIG.audioDuration,
220
- sampleRate: 48000,
221
- });
222
- }
223
-
224
- // Pre-encoded audio part: stream-copy, no production memory bill.
225
- let audioPartTrack = null;
226
- let audioPartConfig = null;
227
- if (CONFIG.audioPartUrl) {
228
- stage('audio-part-open');
229
- const abytes = await (await fetch(CONFIG.audioPartUrl)).arrayBuffer();
230
- const ainput = new MB.Input({ formats: MB.ALL_FORMATS, source: new MB.BufferSource(abytes) });
231
- audioPartTrack = await ainput.getPrimaryAudioTrack();
232
- if (!audioPartTrack || !audioPartTrack.codec) {
233
- throw new Error('Audio part has no readable audio track');
234
- }
235
- audioPartConfig = await audioPartTrack.getDecoderConfig();
236
- if (!audioPartConfig) throw new Error('Audio part has no decoder config');
237
- }
238
-
239
- const openTrack = async (bytes, index) => {
240
- const input = new MB.Input({ formats: MB.ALL_FORMATS, source: new MB.BufferSource(bytes) });
241
- const track = await input.getPrimaryVideoTrack();
242
- if (!track) throw new Error('Chunk ' + index + ' has no video track');
243
- return track;
244
- };
245
-
246
- stage('fetch-part-0');
247
- const first = await (await fetch(CONFIG.parts[0].url)).arrayBuffer();
248
- stage('open-part-0');
249
- const firstTrack = await openTrack(first, 0);
250
- const codec = firstTrack.codec;
251
- if (!codec) throw new Error('Chunk 0 video codec could not be determined');
252
- const decoderConfig = await firstTrack.getDecoderConfig();
253
- if (!decoderConfig) throw new Error('Chunk 0 has no decoder config');
254
-
255
- const output = new MB.Output({
256
- format: CONFIG.format === 'mp4' ? new MB.Mp4OutputFormat() : new MB.WebMOutputFormat(),
257
- target: new MB.BufferTarget(),
258
- });
259
- const videoSource = new MB.EncodedVideoPacketSource(codec);
260
- output.addVideoTrack(videoSource, { frameRate: CONFIG.frameRate });
261
-
262
- let audioSource = null;
263
- let audioPacketSource = null;
264
- if (audioPartTrack) {
265
- audioPacketSource = new MB.EncodedAudioPacketSource(audioPartTrack.codec);
266
- output.addAudioTrack(audioPacketSource);
267
- } else if (audioBuffer) {
268
- // AAC first for mp4 (compatibility), Opus fallback \u2014 AAC encode is
269
- // unavailable on some fleets (probe: aacEncode false on Linux).
270
- const preferred = CONFIG.format === 'mp4' ? 'aac' : 'opus';
271
- const audioCodec =
272
- preferred === 'aac' && !(await MB.canEncodeAudio('aac')) ? 'opus' : preferred;
273
- audioSource = new MB.AudioBufferSource({ codec: audioCodec, bitrate: MB.QUALITY_HIGH });
274
- output.addAudioTrack(audioSource);
275
- }
276
-
277
- await output.start();
278
-
279
- if (audioPacketSource && audioPartTrack) {
280
- stage('audio-part-copy');
281
- const asink = new MB.EncodedPacketSink(audioPartTrack);
282
- let firstAudio = true;
283
- for await (const packet of asink.packets()) {
284
- await audioPacketSource.add(
285
- packet,
286
- firstAudio ? { decoderConfig: audioPartConfig } : undefined,
287
- );
288
- firstAudio = false;
289
- }
290
- audioPacketSource.close();
291
- } else if (audioSource && audioBuffer) {
292
- await audioSource.add(audioBuffer);
293
- audioSource.close();
294
- }
295
-
296
- // Mirrors render-core concat.ts: plan-derived offsets, keyframe-per-chunk
297
- // guard, decode-order packet walk.
298
- let offset = 0;
299
- let packetCount = 0;
300
- for (let i = 0; i < CONFIG.parts.length; i++) {
301
- stage('concat-part-' + i);
302
- const bytes = i === 0 ? first : await (await fetch(CONFIG.parts[i].url)).arrayBuffer();
303
- const track = i === 0 ? firstTrack : await openTrack(bytes, i);
304
- if (track.codec !== codec) {
305
- throw new Error('Chunk ' + i + ' codec ' + track.codec + ' != chunk 0 codec ' + codec);
306
- }
307
- const sink = new MB.EncodedPacketSink(track);
308
- let firstOfChunk = true;
309
- for await (const packet of sink.packets()) {
310
- if (firstOfChunk && packet.type !== 'key') {
311
- throw new Error('Chunk ' + i + ' does not start on a keyframe');
312
- }
313
- const shifted = packet.clone({ timestamp: packet.timestamp + offset });
314
- await videoSource.add(shifted, firstOfChunk && i === 0 ? { decoderConfig } : undefined);
315
- firstOfChunk = false;
316
- packetCount++;
317
- }
318
- offset += CONFIG.parts[i].duration;
319
- }
320
-
321
- stage('finalize-output');
322
- await output.finalize();
323
- const buffer = output.target.buffer;
324
- if (!buffer) throw new Error('Concat produced no output buffer');
325
-
326
- stage('upload');
327
- const res = await fetch(CONFIG.uploadUrl, {
328
- method: 'PUT',
329
- headers: { 'Content-Type': CONFIG.contentType },
330
- body: buffer,
331
- });
332
- if (!res.ok) throw new Error('Upload failed: HTTP ' + res.status);
333
-
334
- window.__renderComplete = {
335
- success: true,
336
- uploaded: true,
337
- size: buffer.byteLength,
338
- packetCount,
339
- };
340
- })().catch((e) => {
341
- window.__renderComplete = { success: false, error: String((e && e.stack) || e) };
342
- });
343
- </script>
344
- </body>
345
- </html>`;
346
- }
347
-
348
164
  // src/audioProducer.ts
349
165
  var CORE_AUDIO_CDN_URL = "https://esm.sh/@vosjs/core@0.23.1/audio?target=es2022";
350
166
  var STUDIO_ENTRY_ID = "vosso.studio";
@@ -523,411 +339,10 @@ function dataHasAudio(data, stack, plan) {
523
339
  const hasClips = !!plan && plan.tracks.length > 0 || Array.isArray(entryAudio) && entryAudio.length > 0;
524
340
  return hasVoice || hasClips;
525
341
  }
526
-
527
- // src/audioMixPage.ts
528
- var MEDIABUNNY_URL2 = "https://esm.sh/mediabunny@1.27.3?target=es2022";
529
- function buildAudioMixPage(options) {
530
- const config = JSON.stringify({
531
- data: options.data,
532
- plan: options.plan ?? null,
533
- duration: options.duration,
534
- uploadUrl: options.uploadUrl
535
- });
536
- return `<!doctype html>
537
- <html>
538
- <head><meta charset="utf-8"><title>vos audio mix</title></head>
539
- <body>
540
- <script type="module">
541
- const CONFIG = ${config};
542
-
543
- // Same stage contract as the finalize page: worker polls __finalizeStage,
544
- // reports the last stage on a silent death. Stage NAME stays the first
545
- // space-delimited token (renderPolicy.finalizeDeathStage parses it).
546
- const stage = (s) => {
547
- const m = performance.memory;
548
- window.__finalizeStage = m ? s + ' heap=' + Math.round(m.usedJSHeapSize / 1048576) + 'MB' : s;
549
- };
550
- stage('boot');
551
-
552
- ;(async () => {
553
- stage('import-mediabunny');
554
- const MB = await import(${JSON.stringify(MEDIABUNNY_URL2)});
555
-
556
- // Streaming splice seam consumed by the audio producer below: decode ONLY
557
- // the source spans the segments keep, capped at maxSeconds of output.
558
- // Mirrors spliceAudio's piece math (copy at rate 1, linear resample
559
- // otherwise); the producer's whole-file path remains the authoritative
560
- // fallback, so a divergence here degrades to slower, never to wrong.
561
- window.__vosStreamSplice__ = async (url, segments, maxSeconds) => {
562
- const input = new MB.Input({ formats: MB.ALL_FORMATS, source: new MB.UrlSource(url) });
563
- try {
564
- const track = await input.getPrimaryAudioTrack();
565
- if (!track) return null;
566
- if (!(await track.canDecode())) return null;
567
- const sr = track.sampleRate;
568
- const ch = Math.min(2, Math.max(1, track.numberOfChannels || 2));
569
- const srcDur = await input.computeDuration();
570
-
571
- const spans = Array.isArray(segments) && segments.length
572
- ? segments
573
- : [{ in: 0, out: srcDur }];
574
- const pieces = [];
575
- let outTotal = 0;
576
- for (const s of spans) {
577
- const start = Math.max(0, Math.min(s.in, srcDur));
578
- const end = Math.max(start, Math.min(s.out, srcDur));
579
- const rate = s.rate !== undefined && s.rate > 0 ? s.rate : 1;
580
- let outLen = (end - start) / rate;
581
- if (outTotal + outLen > maxSeconds) outLen = maxSeconds - outTotal;
582
- if (outLen <= 0) break;
583
- pieces.push({ in: start, out: start + outLen * rate, rate, outLen });
584
- outTotal += outLen;
585
- if (outTotal >= maxSeconds) break;
586
- }
587
- if (pieces.length === 0) return null;
588
-
589
- const totalFrames = Math.max(1, Math.round(outTotal * sr));
590
- const out = new AudioBuffer({ length: totalFrames, numberOfChannels: ch, sampleRate: sr });
591
- const sink = new MB.AudioBufferSink(track);
592
- let outOffset = 0;
593
- for (let pi = 0; pi < pieces.length; pi++) {
594
- const p = pieces[pi];
595
- stage('voice-piece-' + pi);
596
- // Materialize ONE piece's source span (bounded), then splice from it.
597
- const pieceFrames = Math.max(1, Math.round((p.out - p.in) * sr));
598
- const temp = [];
599
- for (let c = 0; c < ch; c++) temp.push(new Float32Array(pieceFrames));
600
- for await (const wrapped of sink.buffers(p.in, p.out)) {
601
- const b = wrapped.buffer;
602
- const at = Math.round((wrapped.timestamp - p.in) * sr);
603
- for (let c = 0; c < ch; c++) {
604
- const src = b.getChannelData(Math.min(c, b.numberOfChannels - 1));
605
- let from = 0, to = at, n = src.length;
606
- if (to < 0) { from = -to; to = 0; n -= from; }
607
- n = Math.min(n, pieceFrames - to);
608
- if (n > 0) temp[c].set(src.subarray(from, from + n), to);
609
- }
610
- }
611
- const outFrames = Math.min(Math.round(p.outLen * sr), totalFrames - outOffset);
612
- for (let c = 0; c < ch; c++) {
613
- const dst = out.getChannelData(c);
614
- const src = temp[c];
615
- if (p.rate === 1) {
616
- dst.set(src.subarray(0, Math.min(outFrames, src.length)), outOffset);
617
- } else {
618
- const last = src.length - 1;
619
- for (let i = 0; i < outFrames; i++) {
620
- const pos = i * p.rate;
621
- const j = Math.min(Math.floor(pos), last);
622
- const a = src[j];
623
- const b2 = src[Math.min(j + 1, last)];
624
- dst[outOffset + i] = a + (b2 - a) * (pos - j);
625
- }
626
- }
627
- }
628
- outOffset += outFrames;
629
- }
630
- return out;
631
- } finally {
632
- input.dispose();
633
- }
634
- };
635
-
636
- ${audioProducerCode()}
637
-
638
- stage('mix');
639
- let mixed = await window.__vosAudioProducer__({
640
- data: CONFIG.data,
641
- plan: CONFIG.plan,
642
- duration: CONFIG.duration,
643
- sampleRate: 48000,
644
- });
645
- if (!mixed) {
646
- // Every declared source failed to decode (fetch denied, undecodable
647
- // bytes). Reporting "no buffer" here used to route finalize onto the
648
- // browser concat fallback \u2014 whose in-page producer faces the SAME
649
- // failures and whose page-memory concat is the known OOM death (job
650
- // cdf6e026 died twice at concat-part-10 after this very branch). Land a
651
- // duration-true SILENT track instead so the worker mux still runs: the
652
- // audible outcome matches the client exporter's documented fail-open
653
- // (a source that won't decode loses its sound, never the export).
654
- console.warn('[audio-mix] no source decoded; landing a silent track');
655
- mixed = new AudioBuffer({
656
- length: Math.max(1, Math.ceil(CONFIG.duration * 48000)),
657
- numberOfChannels: 2,
658
- sampleRate: 48000,
659
- });
660
- }
661
-
662
- stage('encode');
663
- // Opus always: the fleet is Linux Chrome, which has no AAC encoder, and
664
- // the worker mux stream-copies whatever codec arrives here.
665
- const output = new MB.Output({ format: new MB.WebMOutputFormat(), target: new MB.BufferTarget() });
666
- const source = new MB.AudioBufferSource({ codec: 'opus', bitrate: MB.QUALITY_HIGH });
667
- output.addAudioTrack(source);
668
- await output.start();
669
- await source.add(mixed);
670
- source.close();
671
- await output.finalize();
672
- const buffer = output.target.buffer;
673
- if (!buffer) throw new Error('Audio encode produced no output buffer');
674
-
675
- stage('upload');
676
- const res = await fetch(CONFIG.uploadUrl, {
677
- method: 'PUT',
678
- headers: { 'Content-Type': 'audio/webm' },
679
- body: buffer,
680
- });
681
- if (!res.ok) throw new Error('Upload failed: HTTP ' + res.status);
682
-
683
- window.__renderComplete = { success: true, uploaded: true, size: buffer.byteLength };
684
- })().catch((e) => {
685
- window.__renderComplete = { success: false, error: String((e && e.stack) || e) };
686
- });
687
- </script>
688
- </body>
689
- </html>`;
690
- }
691
-
692
- // src/imageDiffPage.ts
693
- function buildImageDiffPage(options) {
694
- const config = JSON.stringify({
695
- candidate: options.candidateUrl,
696
- golden: options.goldenUrl
697
- }).replace(/</g, "\\u003c");
698
- return `<!doctype html>
699
- <html>
700
- <head><meta charset="utf-8"><title>vos golden diff</title></head>
701
- <body>
702
- <script type="module">
703
- const CONFIG = ${config};
704
-
705
- const load = (src) =>
706
- new Promise((resolve, reject) => {
707
- const img = new Image();
708
- img.onload = () => resolve(img);
709
- img.onerror = () => reject(new Error('image failed to load'));
710
- img.src = src;
711
- });
712
-
713
- ;(async () => {
714
- const [candidate, golden] = await Promise.all([
715
- load(CONFIG.candidate),
716
- load(CONFIG.golden),
717
- ]);
718
- if (candidate.width !== golden.width || candidate.height !== golden.height) {
719
- throw new Error(
720
- 'size mismatch: candidate ' + candidate.width + 'x' + candidate.height +
721
- ' vs golden ' + golden.width + 'x' + golden.height,
722
- );
723
- }
724
- const draw = (img) => {
725
- const canvas = document.createElement('canvas');
726
- canvas.width = img.width;
727
- canvas.height = img.height;
728
- const ctx = canvas.getContext('2d');
729
- ctx.drawImage(img, 0, 0);
730
- return ctx.getImageData(0, 0, img.width, img.height).data;
731
- };
732
- const a = draw(candidate);
733
- const b = draw(golden);
734
- let sum = 0;
735
- let n = 0;
736
- for (let i = 0; i < a.length; i += 4) {
737
- for (let c = 0; c < 3; c++) {
738
- const d = a[i + c] - b[i + c];
739
- sum += d * d;
740
- n++;
741
- }
742
- }
743
- const rms = Math.sqrt(sum / n);
744
- window.__renderComplete = {
745
- success: true,
746
- rms,
747
- width: candidate.width,
748
- height: candidate.height,
749
- };
750
- })().catch((e) => {
751
- window.__renderComplete = { success: false, error: String((e && e.message) || e) };
752
- });
753
- </script>
754
- </body>
755
- </html>`;
756
- }
757
-
758
- // src/digestPage.ts
759
- function buildDigestPage(options) {
760
- const config = JSON.stringify(options).replace(/</g, "\\u003c");
761
- return `<!doctype html>
762
- <html>
763
- <head><meta charset="utf-8"><title>vos digest</title><link rel="icon" href="data:,">
764
- <style>html,body{margin:0;background:#000}video{position:absolute;left:-99999px}</style>
765
- </head>
766
- <body>
767
- <script type="module">
768
- const cfg = ${config}
769
- const raf = () => new Promise((r) => requestAnimationFrame(r))
770
- const done = (v) => { window.__renderComplete = v }
771
- try {
772
- // One page per job: the whole recording comes down once, then every seek
773
- // is local (a network-backed paused <video> gets suspended within seconds).
774
- const blob = await (await fetch(cfg.videoUrl)).blob()
775
- const v = document.createElement('video')
776
- v.muted = true
777
- v.preload = 'auto'
778
- v.src = URL.createObjectURL(blob)
779
- document.body.appendChild(v)
780
- await new Promise((res, rej) => {
781
- v.addEventListener('loadeddata', () => res(), { once: true })
782
- v.addEventListener('error', () => rej(new Error('video failed to load')), { once: true })
783
- })
784
- const settle = async () => {
785
- const t0 = performance.now()
786
- let last = ''
787
- while (performance.now() - t0 < 4000) {
788
- await raf()
789
- if (!v.seeking && v.readyState >= 2) {
790
- const now = v.currentTime.toFixed(3)
791
- if (now === last) return
792
- last = now
793
- } else last = ''
794
- }
795
- }
796
- const seek = async (t) => {
797
- const target = Math.max(0, Math.min(t, Math.max(0, (v.duration || t) - 0.02)))
798
- if (Math.abs(v.currentTime - target) > 0.0005 || v.readyState < 2) {
799
- await new Promise((res) => {
800
- const on = () => { v.removeEventListener('seeked', on); res() }
801
- v.addEventListener('seeked', on)
802
- v.currentTime = target
803
- })
804
- }
805
- await settle()
806
- }
807
- const W = v.videoWidth
808
- const H = v.videoHeight
809
- const region = {
810
- x: Math.max(0, Math.min(cfg.region.x, W)),
811
- y: Math.max(0, Math.min(cfg.region.y, H)),
812
- w: Math.max(1, Math.min(cfg.region.w, W - cfg.region.x)),
813
- h: Math.max(1, Math.min(cfg.region.h, H - cfg.region.y)),
814
- }
815
-
816
- // Pass 1: motion bins, one per source second.
817
- const bw = 64, bh = 36
818
- const bc = document.createElement('canvas')
819
- bc.width = bw; bc.height = bh
820
- const bctx = bc.getContext('2d', { willReadFrequently: true })
821
- let prev = null
822
- const bins = []
823
- const n = Math.max(1, Math.ceil(cfg.durationS))
824
- for (let i = 0; i < n; i++) {
825
- await seek(Math.min(cfg.durationS, i + 0.5))
826
- bctx.drawImage(v, 0, 0, bw, bh)
827
- const d = bctx.getImageData(0, 0, bw, bh).data
828
- const luma = new Uint8ClampedArray(bw * bh)
829
- for (let p = 0; p < luma.length; p++) {
830
- const o = p * 4
831
- luma[p] = (d[o] * 299 + d[o + 1] * 587 + d[o + 2] * 114) / 1000
832
- }
833
- if (!prev) bins.push(0)
834
- else {
835
- let changed = 0
836
- for (let p = 0; p < luma.length; p++) if (Math.abs(luma[p] - prev[p]) > cfg.motionDelta) changed++
837
- bins.push(Math.round((changed / luma.length) * 1000) / 1000)
838
- }
839
- prev = luma
840
- window.__renderProgress = 0.4 * ((i + 1) / n)
841
- }
842
- const scenes = []
843
- for (let i = 1; i < bins.length; i++) {
844
- if (bins[i] >= cfg.scene.motion && bins[i - 1] <= cfg.scene.quiet) scenes.push(i)
845
- }
846
-
847
- // Pass 2: frames, crops, the sheet \u2014 each PUT as its own part.
848
- const sizes = {}
849
- const toPng = (c) => new Promise((res, rej) => c.toBlob((b) => (b ? res(b) : rej(new Error('toBlob failed'))), 'image/png'))
850
- const put = async (name, body, type) => {
851
- const r = await fetch(cfg.uploadUrl + '&part=digest-' + name, { method: 'PUT', headers: { 'content-type': type }, body })
852
- if (!r.ok) throw new Error('upload of ' + name + ' failed: ' + r.status)
853
- }
854
- const save = async (name, c) => {
855
- await put(name, await toPng(c), 'image/png')
856
- sizes[name] = { width: c.width, height: c.height }
857
- }
858
- const fit = (w, h, max) => {
859
- const s = Math.min(1, max / Math.max(w, h))
860
- return { w: Math.max(1, Math.round(w * s)), h: Math.max(1, Math.round(h * s)) }
861
- }
862
- const draw = (src, sx, sy, sw, sh, max) => {
863
- const c = document.createElement('canvas')
864
- const f = fit(sw, sh, max)
865
- c.width = f.w; c.height = f.h
866
- c.getContext('2d').drawImage(src, sx, sy, sw, sh, 0, 0, f.w, f.h)
867
- return c
868
- }
869
- const tiles = []
870
- const shots = cfg.shots.map((s) => ({ ...s, scene: false }))
871
- for (const s of scenes) shots.push({ name: 'scene-' + s, t: Math.min(cfg.durationS, s + 0.04), box: null, label: 'scene ' + s + 's', scene: true })
872
- shots.sort((a, b) => a.t - b.t)
873
- for (let i = 0; i < shots.length; i++) {
874
- const s = shots[i]
875
- await seek(s.t)
876
- const full = draw(v, region.x, region.y, region.w, region.h, cfg.fullMax)
877
- await save(s.name + '.full.png', full)
878
- let tileSrc = full
879
- if (s.box) {
880
- const b = s.box
881
- const crop = draw(v, b.x, b.y, b.w, b.h, cfg.cropMax)
882
- await save(s.name + '.crop.png', crop)
883
- tileSrc = crop
884
- }
885
- const tw = 240
886
- const th = Math.max(1, Math.round((tw * tileSrc.height) / tileSrc.width))
887
- const tile = document.createElement('canvas')
888
- tile.width = tw; tile.height = th
889
- tile.getContext('2d').drawImage(tileSrc, 0, 0, tw, th)
890
- tiles.push({ label: s.label, c: tile })
891
- window.__renderProgress = 0.4 + 0.55 * ((i + 1) / shots.length)
892
- }
893
- if (tiles.length) {
894
- const cols = Math.min(6, tiles.length)
895
- const tw = 240
896
- const th = Math.max(...tiles.map((t) => t.c.height))
897
- const rows = Math.ceil(tiles.length / cols)
898
- const sheet = document.createElement('canvas')
899
- sheet.width = cols * (tw + 8) + 8
900
- sheet.height = rows * (th + 26) + 8
901
- const ctx = sheet.getContext('2d')
902
- ctx.fillStyle = '#111'
903
- ctx.fillRect(0, 0, sheet.width, sheet.height)
904
- ctx.font = '12px ui-monospace, Menlo, monospace'
905
- tiles.forEach((t, i) => {
906
- const x = 8 + (i % cols) * (tw + 8)
907
- const y = 8 + Math.floor(i / cols) * (th + 26)
908
- ctx.drawImage(t.c, x, y)
909
- ctx.fillStyle = '#eee'
910
- ctx.fillText(t.label, x, y + th + 16)
911
- })
912
- await save('sheet.png', sheet)
913
- }
914
- await put('manifest.json', new Blob([JSON.stringify({ width: W, height: H, bins, scenes, sizes })], { type: 'application/json' }), 'application/json')
915
- done({ success: true, uploaded: true, bins: bins.length, shots: shots.length })
916
- } catch (e) {
917
- done({ success: false, error: String((e && e.stack) || e) })
918
- }
919
- </script>
920
- </body>
921
- </html>`;
922
- }
923
342
  export {
924
343
  CORE_AUDIO_CDN_URL,
925
344
  DEFAULT_MIN_FRAMES_PER_CHUNK,
926
345
  audioProducerCode,
927
- buildAudioMixPage,
928
- buildDigestPage,
929
- buildFinalizeConcatPage,
930
- buildImageDiffPage,
931
346
  concatEncodedVideo,
932
347
  countVideoPackets,
933
348
  dataHasAudio,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/chunkPlanner.ts","../src/concat.ts","../src/finalizePage.ts","../src/audioProducer.ts","../src/audioMixPage.ts","../src/imageDiffPage.ts","../src/digestPage.ts"],"sourcesContent":["/**\n * Chunk planner — timeline sharding for deterministic renders.\n *\n * Because vos evaluation is a pure function of time (seek(t) — the whole\n * point of the sampler tween backend), any frame range can render in\n * isolation. The planner splits [0, totalFrames) into balanced, contiguous\n * ranges; each chunk encodes independently (so it starts on a keyframe by\n * construction) with chunk-LOCAL timestamps starting at 0, and the finalize\n * step (concat.ts) offsets packets back onto the global timeline.\n *\n * Pure math — no I/O — so the policy stays unit-testable and reusable by\n * every harness (CLI, local server, render service).\n */\n\nexport interface ChunkPlanPolicy {\n /** Upper bound on simultaneous chunk renders (browser pages/sessions). */\n maxParallel: number\n /**\n * Below this many frames per chunk, extra parallelism costs more in\n * per-chunk fixed overhead (page load, module import, first seek) than it\n * saves — stop splitting. (Remotion's floor is 5; ours is higher because\n * chunk startup includes CDN module imports.)\n */\n minFramesPerChunk?: number\n}\n\nexport const DEFAULT_MIN_FRAMES_PER_CHUNK = 24\n\nexport interface RenderChunk {\n /** 0-based chunk index; also the concat order. */\n index: number\n /** First frame of the chunk (inclusive, global frame numbering). */\n startFrame: number\n /** End frame (exclusive). */\n endFrame: number\n frameCount: number\n /** Global start time in seconds (startFrame / fps). */\n startTime: number\n /** Exact chunk duration in seconds (frameCount / fps). */\n duration: number\n}\n\n/**\n * Split `totalFrames` into at most `maxParallel` balanced contiguous chunks.\n * Sizes differ by at most one frame; chunk boundaries are exact frame\n * indices so no frame is rendered twice or skipped.\n */\nexport function planChunks(\n totalFrames: number,\n fps: number,\n policy: ChunkPlanPolicy,\n): RenderChunk[] {\n if (!Number.isInteger(totalFrames) || totalFrames <= 0) {\n throw new Error(\n `planChunks: totalFrames must be a positive integer, got ${totalFrames}`,\n )\n }\n if (!(fps > 0)) {\n throw new Error(`planChunks: fps must be positive, got ${fps}`)\n }\n const minFrames = policy.minFramesPerChunk ?? DEFAULT_MIN_FRAMES_PER_CHUNK\n const chunkCount = Math.max(\n 1,\n Math.min(\n Math.floor(policy.maxParallel),\n Math.floor(totalFrames / minFrames),\n ),\n )\n\n const base = Math.floor(totalFrames / chunkCount)\n const remainder = totalFrames % chunkCount\n\n const chunks: RenderChunk[] = []\n let startFrame = 0\n for (let index = 0; index < chunkCount; index++) {\n // The first `remainder` chunks carry one extra frame.\n const frameCount = base + (index < remainder ? 1 : 0)\n const endFrame = startFrame + frameCount\n chunks.push({\n index,\n startFrame,\n endFrame,\n frameCount,\n startTime: startFrame / fps,\n duration: frameCount / fps,\n })\n startFrame = endFrame\n }\n return chunks\n}\n","/**\n * Finalize concat — stream-copy chunk videos into one file, no re-encode.\n *\n * Each chunk is an independently encoded file whose timestamps start at 0\n * (chunk-local). Concat demuxes every chunk's video packets and re-muxes\n * them into one output, offsetting timestamps by the chunk's global start\n * time. Because chunks were encoded with identical codec/params (the caller's\n * responsibility — one encoder config per render) and every independently\n * encoded file begins with a keyframe, packet-level concatenation is valid\n * with zero quality loss and near-zero CPU: pure container work, runs in\n * plain Node (mediabunny demux/mux is pure JS; no WebCodecs needed).\n *\n * Audio is deliberately absent here: per the rendering plan, audio renders\n * ONCE centrally and gets muxed against the concatenated video, so encoder\n * priming seams never exist.\n */\n\nimport {\n ALL_FORMATS,\n BufferSource,\n BufferTarget,\n EncodedAudioPacketSource,\n EncodedPacketSink,\n EncodedVideoPacketSource,\n Input,\n Mp4OutputFormat,\n Output,\n WebMOutputFormat,\n} from 'mediabunny'\nimport type { EncodedPacket, InputVideoTrack, VideoCodec } from 'mediabunny'\n\nexport interface ConcatChunk {\n /** Encoded chunk file bytes (WebM or MP4, matching `format`). */\n data: Uint8Array\n /**\n * The chunk's exact intended duration in seconds (frameCount / fps, from\n * the chunk plan). Used as the timestamp offset for the NEXT chunk —\n * derived from the plan, not from packet rounding, so drift can't\n * accumulate across chunks.\n */\n duration: number\n}\n\nexport interface ConcatOptions {\n format: 'webm' | 'mp4'\n /** Stamped into the output track metadata (players use it as a hint). */\n frameRate?: number\n}\n\nexport interface ConcatResult {\n bytes: Uint8Array\n /** Total video packets written. */\n packetCount: number\n /** Codec copied through (from the first chunk). */\n codec: VideoCodec\n}\n\nasync function openVideoTrack(\n data: Uint8Array,\n chunkIndex: number,\n): Promise<{ input: Input; track: InputVideoTrack }> {\n const input = new Input({\n formats: ALL_FORMATS,\n source: new BufferSource(data),\n })\n const track = await input.getPrimaryVideoTrack()\n if (!track) throw new Error(`Chunk ${chunkIndex} has no video track`)\n return { input, track }\n}\n\nexport interface MuxExportOptions extends ConcatOptions {\n /**\n * Video parts in plan order. An async iterable lets the caller feed parts\n * ONE at a time from storage instead of materializing all of them first\n * (the worker finalize's memory shape).\n */\n video: AsyncIterable<ConcatChunk> | Iterable<ConcatChunk>\n /**\n * Optional encoded audio file (any container mediabunny reads — the audio\n * mix page produces Opus in WebM). Its packets are STREAM-COPIED into the\n * output's audio track: no decode, no WebCodecs, pure container work, so\n * this runs in a Worker isolate exactly like the video concat.\n */\n audio?: Uint8Array\n}\n\n/**\n * Mux a chunked export's final artifact: stream-copy the video parts with\n * plan-derived timestamp offsets, and stream-copy the pre-encoded audio\n * track when one is provided. `packetCount` counts VIDEO packets only —\n * that is the frame-parity contract callers assert against the plan.\n */\nexport async function muxEncodedExport(\n options: MuxExportOptions,\n): Promise<ConcatResult> {\n const output = new Output({\n format:\n options.format === 'mp4' ? new Mp4OutputFormat() : new WebMOutputFormat(),\n target: new BufferTarget(),\n })\n\n // Audio track first (mirrors the finalize page: audio is small and fully\n // known up front; the muxer interleaves at finalize).\n let audioTrack: {\n source: EncodedAudioPacketSource\n sink: EncodedPacketSink\n decoderConfig: AudioDecoderConfig\n } | null = null\n if (options.audio) {\n const input = new Input({\n formats: ALL_FORMATS,\n source: new BufferSource(options.audio),\n })\n const track = await input.getPrimaryAudioTrack()\n if (!track || !track.codec) {\n throw new Error('Audio part has no readable audio track')\n }\n const decoderConfig = await track.getDecoderConfig()\n if (!decoderConfig) throw new Error('Audio part has no decoder config')\n const source = new EncodedAudioPacketSource(track.codec)\n output.addAudioTrack(source)\n audioTrack = { source, sink: new EncodedPacketSink(track), decoderConfig }\n }\n\n let videoSource: EncodedVideoPacketSource | null = null\n let codec: VideoCodec | null = null\n let decoderConfig: VideoDecoderConfig | null = null\n let started = false\n let offset = 0\n let packetCount = 0\n let index = 0\n\n for await (const chunk of options.video) {\n const i = index++\n const { track } = await openVideoTrack(chunk.data, i)\n if (i === 0) {\n codec = track.codec\n if (!codec) throw new Error('Chunk 0 video codec could not be determined')\n decoderConfig = await track.getDecoderConfig()\n if (!decoderConfig) throw new Error('Chunk 0 has no decoder config')\n videoSource = new EncodedVideoPacketSource(codec)\n output.addVideoTrack(\n videoSource,\n options.frameRate ? { frameRate: options.frameRate } : undefined,\n )\n await output.start()\n started = true\n\n if (audioTrack) {\n let firstAudio = true\n for await (const packet of audioTrack.sink.packets()) {\n await audioTrack.source.add(\n packet,\n firstAudio\n ? { decoderConfig: audioTrack.decoderConfig }\n : undefined,\n )\n firstAudio = false\n }\n audioTrack.source.close()\n }\n } else if (track.codec !== codec) {\n throw new Error(\n `Chunk ${i} codec ${String(track.codec)} != chunk 0 codec ${String(codec)} — chunks must share one encoder config`,\n )\n }\n\n const sink = new EncodedPacketSink(track)\n let firstOfChunk = true\n for await (const packet of sink.packets()) {\n if (firstOfChunk && packet.type !== 'key') {\n throw new Error(\n `Chunk ${i} does not start on a keyframe — was it encoded as an independent chunk?`,\n )\n }\n const shifted: EncodedPacket = packet.clone({\n timestamp: packet.timestamp + offset,\n })\n await videoSource!.add(\n shifted,\n firstOfChunk && i === 0 && decoderConfig\n ? { decoderConfig }\n : undefined,\n )\n firstOfChunk = false\n packetCount++\n }\n\n // Advance by the PLANNED duration (frames/fps), not the demuxed one —\n // container timestamp rounding must not accumulate across chunks.\n offset += chunk.duration\n }\n\n if (!started || !videoSource || !codec) {\n throw new Error('muxEncodedExport: no video parts')\n }\n\n await output.finalize()\n const bytes = output.target.buffer\n if (!bytes) throw new Error('Concat produced no output buffer')\n return { bytes: new Uint8Array(bytes), packetCount, codec }\n}\n\n/**\n * Concatenate independently encoded chunk videos into one stream-copied file.\n * Chunks must share codec and encoder params; the first packet of every\n * chunk must be a keyframe (violations throw — better a loud failure at\n * finalize than a corrupt artifact). Thin wrapper over muxEncodedExport,\n * kept as the CLI's finalize entry point.\n */\nexport async function concatEncodedVideo(\n chunks: ConcatChunk[],\n options: ConcatOptions,\n): Promise<ConcatResult> {\n if (chunks.length === 0) throw new Error('concatEncodedVideo: no chunks')\n return muxEncodedExport({ ...options, video: chunks })\n}\n\n/**\n * Count video packets in an encoded file — cheap integrity check used by\n * parity verification (chunked and single-flight renders of the same\n * composition must contain the same number of frames).\n */\nexport async function countVideoPackets(data: Uint8Array): Promise<number> {\n const { track } = await openVideoTrack(data, 0)\n const sink = new EncodedPacketSink(track)\n let count = 0\n for await (const packet of sink.packets()) {\n void packet\n count++\n }\n return count\n}\n","/**\n * Finalize page — an HTML page that stream-copy concatenates encoded chunk\n * videos IN A BROWSER and PUTs the result to an upload URL.\n *\n * Why a page and not the Worker: concat needs every chunk in memory plus the\n * output buffer, which busts a 128MB isolate on real exports; a browser page\n * (the same Browser Run substrate that rendered the chunks) has gigabytes,\n * mediabunny already proven in it, and gives the later audio-mix step a home\n * (OfflineAudioContext exists only in browsers).\n *\n * The in-page algorithm MIRRORS ./concat.ts (concatEncodedVideo) — same\n * packet walk, same plan-derived timestamp offsets, same keyframe guard.\n * KEEP THEM IN SYNC; scripts/verify-finalize-page.ts (vos-plugin) asserts the\n * two implementations produce packet-identical output.\n *\n * Failure contract: any error (fetch, codec mismatch, upload) lands in\n * `__renderComplete = { success: false, error }` — there is no base64\n * fallback here (finals can be huge); the caller fails the job.\n */\n\nconst MEDIABUNNY_URL = 'https://esm.sh/mediabunny@1.27.3?target=es2022'\n\nexport interface FinalizePart {\n /** Where the page fetches this chunk's encoded bytes (CORS-accessible). */\n url: string\n /** The chunk's PLANNED duration in seconds (frameCount / fps) — the\n * timestamp offset source, never demuxed durations (rounding drifts). */\n duration: number\n}\n\nexport interface FinalizeConcatPageOptions {\n parts: FinalizePart[]\n format: 'webm' | 'mp4'\n /** Stamped into the output track metadata. */\n frameRate: number\n /** The finished file is PUT here (Content-Type set from `format`). */\n uploadUrl: string\n /**\n * Audio-once-at-finalize (the rendering plan's audio model — chunks are\n * video-only by construction, so encoder priming seams never exist):\n * `producerCode` defines `window.__vosAudioProducer__({ data, duration,\n * sampleRate }) => AudioBuffer | null` (the studio clips' plan rides the\n * code itself, baked by `audioProducerCode({ plan })`, since this\n * page passes none), evaluated before the concat; a\n * returned buffer is muxed as the output's audio track (AAC for mp4 with\n * an Opus fallback, Opus for webm). Null/absent = video-only.\n */\n audio?: {\n producerCode: string\n data: unknown\n /** Total OUTPUT duration in seconds (the mix length). */\n duration: number\n }\n /**\n * Pre-encoded audio: the `audio` ingest part produced by the audio\n * mix page. Its packets are STREAM-COPIED into the output — no decode, no\n * OfflineAudioContext, no producer — so a fallback finalize on this page\n * carries none of the audio-production memory bill. Mutually exclusive\n * with `audio`.\n */\n audioPart?: { url: string }\n}\n\nexport function buildFinalizeConcatPage(\n options: FinalizeConcatPageOptions,\n): string {\n if (options.parts.length === 0) {\n throw new Error('buildFinalizeConcatPage: no parts')\n }\n if (options.audio && options.audioPart) {\n throw new Error(\n 'buildFinalizeConcatPage: audio and audioPart are mutually exclusive',\n )\n }\n const contentType = options.format === 'mp4' ? 'video/mp4' : 'video/webm'\n const config = JSON.stringify({\n parts: options.parts,\n format: options.format,\n frameRate: options.frameRate,\n uploadUrl: options.uploadUrl,\n contentType,\n audioData: options.audio ? options.audio.data : null,\n audioDuration: options.audio ? options.audio.duration : 0,\n audioPartUrl: options.audioPart ? options.audioPart.url : null,\n })\n const audioProducerBlock = options.audio ? options.audio.producerCode : ''\n\n return `<!doctype html>\n<html>\n<head><meta charset=\"utf-8\"><title>vos finalize</title></head>\n<body>\n<script type=\"module\">\nconst CONFIG = ${config};\n${audioProducerBlock}\n\n// Stage marker: the supervising worker polls this every tick and reports the\n// last stage seen when the page dies without a result — the only way to\n// localize a page death on a fleet with no devtools (a diagnosis aid).\n// Each stage carries the V8 heap sample when available, so a death\n// names its memory peak too. Heap only covers JS objects (ArrayBuffers are\n// external), so treat it as a floor, not the whole bill. The stage NAME must\n// stay the first space-delimited token — renderPolicy's finalizeDeathStage\n// parses it out of the error message.\nconst stage = (s) => {\n const m = performance.memory;\n window.__finalizeStage = m ? s + ' heap=' + Math.round(m.usedJSHeapSize / 1048576) + 'MB' : s;\n};\nstage('boot');\n\n;(async () => {\n stage('import-mediabunny');\n const MB = await import(${JSON.stringify(MEDIABUNNY_URL)});\n\n // Audio first: a producer failure aborts before any concat work.\n let audioBuffer = null;\n if (window.__vosAudioProducer__ && CONFIG.audioData != null) {\n stage('audio-produce');\n audioBuffer = await window.__vosAudioProducer__({\n data: CONFIG.audioData,\n duration: CONFIG.audioDuration,\n sampleRate: 48000,\n });\n }\n\n // Pre-encoded audio part: stream-copy, no production memory bill.\n let audioPartTrack = null;\n let audioPartConfig = null;\n if (CONFIG.audioPartUrl) {\n stage('audio-part-open');\n const abytes = await (await fetch(CONFIG.audioPartUrl)).arrayBuffer();\n const ainput = new MB.Input({ formats: MB.ALL_FORMATS, source: new MB.BufferSource(abytes) });\n audioPartTrack = await ainput.getPrimaryAudioTrack();\n if (!audioPartTrack || !audioPartTrack.codec) {\n throw new Error('Audio part has no readable audio track');\n }\n audioPartConfig = await audioPartTrack.getDecoderConfig();\n if (!audioPartConfig) throw new Error('Audio part has no decoder config');\n }\n\n const openTrack = async (bytes, index) => {\n const input = new MB.Input({ formats: MB.ALL_FORMATS, source: new MB.BufferSource(bytes) });\n const track = await input.getPrimaryVideoTrack();\n if (!track) throw new Error('Chunk ' + index + ' has no video track');\n return track;\n };\n\n stage('fetch-part-0');\n const first = await (await fetch(CONFIG.parts[0].url)).arrayBuffer();\n stage('open-part-0');\n const firstTrack = await openTrack(first, 0);\n const codec = firstTrack.codec;\n if (!codec) throw new Error('Chunk 0 video codec could not be determined');\n const decoderConfig = await firstTrack.getDecoderConfig();\n if (!decoderConfig) throw new Error('Chunk 0 has no decoder config');\n\n const output = new MB.Output({\n format: CONFIG.format === 'mp4' ? new MB.Mp4OutputFormat() : new MB.WebMOutputFormat(),\n target: new MB.BufferTarget(),\n });\n const videoSource = new MB.EncodedVideoPacketSource(codec);\n output.addVideoTrack(videoSource, { frameRate: CONFIG.frameRate });\n\n let audioSource = null;\n let audioPacketSource = null;\n if (audioPartTrack) {\n audioPacketSource = new MB.EncodedAudioPacketSource(audioPartTrack.codec);\n output.addAudioTrack(audioPacketSource);\n } else if (audioBuffer) {\n // AAC first for mp4 (compatibility), Opus fallback — AAC encode is\n // unavailable on some fleets (probe: aacEncode false on Linux).\n const preferred = CONFIG.format === 'mp4' ? 'aac' : 'opus';\n const audioCodec =\n preferred === 'aac' && !(await MB.canEncodeAudio('aac')) ? 'opus' : preferred;\n audioSource = new MB.AudioBufferSource({ codec: audioCodec, bitrate: MB.QUALITY_HIGH });\n output.addAudioTrack(audioSource);\n }\n\n await output.start();\n\n if (audioPacketSource && audioPartTrack) {\n stage('audio-part-copy');\n const asink = new MB.EncodedPacketSink(audioPartTrack);\n let firstAudio = true;\n for await (const packet of asink.packets()) {\n await audioPacketSource.add(\n packet,\n firstAudio ? { decoderConfig: audioPartConfig } : undefined,\n );\n firstAudio = false;\n }\n audioPacketSource.close();\n } else if (audioSource && audioBuffer) {\n await audioSource.add(audioBuffer);\n audioSource.close();\n }\n\n // Mirrors render-core concat.ts: plan-derived offsets, keyframe-per-chunk\n // guard, decode-order packet walk.\n let offset = 0;\n let packetCount = 0;\n for (let i = 0; i < CONFIG.parts.length; i++) {\n stage('concat-part-' + i);\n const bytes = i === 0 ? first : await (await fetch(CONFIG.parts[i].url)).arrayBuffer();\n const track = i === 0 ? firstTrack : await openTrack(bytes, i);\n if (track.codec !== codec) {\n throw new Error('Chunk ' + i + ' codec ' + track.codec + ' != chunk 0 codec ' + codec);\n }\n const sink = new MB.EncodedPacketSink(track);\n let firstOfChunk = true;\n for await (const packet of sink.packets()) {\n if (firstOfChunk && packet.type !== 'key') {\n throw new Error('Chunk ' + i + ' does not start on a keyframe');\n }\n const shifted = packet.clone({ timestamp: packet.timestamp + offset });\n await videoSource.add(shifted, firstOfChunk && i === 0 ? { decoderConfig } : undefined);\n firstOfChunk = false;\n packetCount++;\n }\n offset += CONFIG.parts[i].duration;\n }\n\n stage('finalize-output');\n await output.finalize();\n const buffer = output.target.buffer;\n if (!buffer) throw new Error('Concat produced no output buffer');\n\n stage('upload');\n const res = await fetch(CONFIG.uploadUrl, {\n method: 'PUT',\n headers: { 'Content-Type': CONFIG.contentType },\n body: buffer,\n });\n if (!res.ok) throw new Error('Upload failed: HTTP ' + res.status);\n\n window.__renderComplete = {\n success: true,\n uploaded: true,\n size: buffer.byteLength,\n packetCount,\n };\n})().catch((e) => {\n window.__renderComplete = { success: false, error: String((e && e.stack) || e) };\n});\n</script>\n</body>\n</html>`\n}\n","/**\n * The take audio producer — page JavaScript implementing the export audio mix\n * for the take data schema, as a source string for injection into render\n * pages (the engine's `capture.audioProducerCode` seam for single-flight\n * captures, the audio mix page, and the finalize page for chunked exports).\n *\n * MIRRORS the client exporter's audio path (the platform's\n * decodeAudio → spliceAudio → mixExportAudio) — the same\n * algorithm, so a cloud export sounds identical to a client export of the\n * same composition. KEEP THEM IN SYNC when the client mixer changes.\n *\n * The producer is `window.__vosAudioProducer__({ data, plan, duration,\n * sampleRate })`. Inputs read from `data` (all optional):\n * videoSrc recording URL (requires hasAudio; the VOICE on legacy takes,\n * the SYSTEM track on split takes)\n * micSrc mic sidecar URL (the mic/system split) — the voice; when present the\n * recording's own track mixes as system audio under sysGain\n * hasAudio whether the recording carries audio\n * segments [{ in, out, rate? }] SOURCE spans — mic is spliced/resampled\n * so trims and speed changes stay lip-synced (pitch shifts with\n * rate, matching preservesPitch=false preview playback)\n * micGain voice master fader (default 1)\n * sysGain system-audio master fader (default 1; split takes only)\n *\n * The music/SFX clips no longer ride `data`: they live on the\n * studio stack entry (`vosso.studio`), and the host builds an ENGINE audio\n * plan from them ahead of the page (studio-core's `studioAudioPlan`), the\n * shape `@vosjs/core/audio`'s `mixAudio` renders. The page imports that\n * mixer from the CDN and renders the plan to ONE buffer beside the\n * recording's tracks. `plan` reaches the producer either as a call argument\n * (a page's CONFIG) or baked into the code as `window.__vosAudioPlan__`\n * (the engine's capture template calls the producer with `{ data, duration,\n * sampleRate }` only, so single-flight captures bake it); no plan, no clips.\n */\n\n/**\n * The `@vosjs/core/audio` build a render page imports for `mixAudio`.\n * Pinned to the version the api installs (`coreAudioCdn.test.ts` there\n * holds it to the installed package): render-core has no engine dependency.\n */\nexport const CORE_AUDIO_CDN_URL =\n 'https://esm.sh/@vosjs/core@0.23.1/audio?target=es2022'\n\n/**\n * A sampled audio plan, structurally `@vosjs/core/audio`'s `AudioPlan` (and\n * studio-core's `StudioAudioPlan`), typed here so render-core depends on\n * neither.\n */\nexport interface AudioPlanJson {\n duration: number\n step: number\n tracks: {\n id: string\n src: string\n loop: boolean\n points: { t: number; on: boolean; pos: number; gain: number }[]\n }[]\n}\n\nexport interface AudioProducerCodeOptions {\n /**\n * Bake the plan into the code (`window.__vosAudioPlan__`) for callers\n * that cannot pass one at call time (the engine's capture template).\n */\n plan?: AudioPlanJson | null\n /** Override the mixer import (tests, a self-hosted engine). */\n coreAudioUrl?: string\n}\n\n/** The studio stack entry id (studio-core's `STUDIO_ENTRY_ID`). */\nexport const STUDIO_ENTRY_ID = 'vosso.studio'\n\n/**\n * The studio entry's own data out of a stack in either shape: the STORED\n * config's `stack` array (`[{ id, data, … }]`) or the lowered record keyed\n * by entry id (`{ 'vosso.studio': data }`). Null when there is none.\n */\nexport function studioEntryData(\n stack: unknown,\n): Record<string, unknown> | null {\n if (stack == null || typeof stack !== 'object') return null\n const asData = (value: unknown) =>\n value && typeof value === 'object'\n ? (value as Record<string, unknown>)\n : null\n if (Array.isArray(stack)) {\n for (const entry of stack) {\n if (\n entry &&\n typeof entry === 'object' &&\n (entry as { id?: unknown }).id === STUDIO_ENTRY_ID\n ) {\n return asData((entry as { data?: unknown }).data)\n }\n }\n return null\n }\n return asData((stack as Record<string, unknown>)[STUDIO_ENTRY_ID])\n}\n\nexport function audioProducerCode(\n options: AudioProducerCodeOptions = {},\n): string {\n const coreAudioUrl = options.coreAudioUrl ?? CORE_AUDIO_CDN_URL\n const bakedPlan = options.plan\n ? `window.__vosAudioPlan__ = ${JSON.stringify(options.plan)};`\n : ''\n return `\n${bakedPlan}\nwindow.__vosAudioProducer__ = async ({ data, plan, duration, sampleRate }) => {\n const rate = sampleRate || 48000;\n const audioPlan = plan === undefined ? window.__vosAudioPlan__ : plan;\n const CORE_AUDIO_URL = ${JSON.stringify(coreAudioUrl)};\n\n const decodeAudio = async (url) => {\n try {\n const buf = await (await fetch(url)).arrayBuffer();\n const ac = new AudioContext();\n try {\n return await ac.decodeAudioData(buf);\n } finally {\n void ac.close();\n }\n } catch (e) {\n console.warn('[audio-producer] source not decodable:', e);\n return null;\n }\n };\n\n // Cut mic audio to the kept source segments; rated segments resample\n // piecewise with linear interpolation (tape-style pitch shift).\n const spliceAudio = (buf, segments) => {\n const sr = buf.sampleRate;\n const pieces = segments.map((s) => {\n const start = Math.max(0, Math.min(Math.round(s.in * sr), buf.length));\n const end = Math.max(start, Math.min(Math.round(s.out * sr), buf.length));\n const r = s.rate !== undefined && s.rate > 0 ? s.rate : 1;\n return { start, end, rate: r, outLen: Math.round((end - start) / r) };\n });\n const total = pieces.reduce((sum, p) => sum + p.outLen, 0);\n if (total <= 0) return buf;\n if (pieces.length === 1 && pieces[0].start === 0 && pieces[0].rate === 1 && total === buf.length) {\n return buf;\n }\n const out = new AudioBuffer({ length: total, numberOfChannels: buf.numberOfChannels, sampleRate: sr });\n for (let ch = 0; ch < buf.numberOfChannels; ch++) {\n const src = buf.getChannelData(ch);\n const dst = out.getChannelData(ch);\n let offset = 0;\n for (const p of pieces) {\n if (p.rate === 1) {\n dst.set(src.subarray(p.start, p.end), offset);\n } else {\n const last = buf.length - 1;\n for (let i = 0; i < p.outLen; i++) {\n const pos = p.start + i * p.rate;\n const j = Math.min(Math.floor(pos), last);\n const a = src[j];\n const b = src[Math.min(j + 1, last)];\n dst[offset + i] = a + (b - a) * (pos - j);\n }\n }\n offset += p.outLen;\n }\n }\n return out;\n };\n\n const videoSrc = typeof data?.videoSrc === 'string' ? data.videoSrc : null;\n const segments = Array.isArray(data?.segments) ? data.segments : [];\n const tracks = audioPlan && Array.isArray(audioPlan.tracks) ? audioPlan.tracks : [];\n const micGain = typeof data?.micGain === 'number' ? data.micGain : 1;\n const sysGain = typeof data?.sysGain === 'number' ? data.sysGain : 1;\n const micSrc = typeof data?.micSrc === 'string' ? data.micSrc : null;\n // Voice: the mic sidecar (AT split), else the legacy track on the recording.\n // System: the recording's own track, only when the take is split.\n const voiceSrc = micSrc ?? (data?.hasAudio && videoSrc ? videoSrc : null);\n const sysSrc = micSrc && data?.hasAudio && videoSrc ? videoSrc : null;\n\n // Recording tracks load through a pluggable seam: a host page may\n // install window.__vosStreamSplice__(url, segments, maxSeconds) — the\n // audio mix page does, backed by mediabunny streaming decode of ONLY the\n // needed source spans, capped at the output duration — and the producer\n // prefers it, falling back to the whole-file decodeAudioData path on any\n // failure. Standalone pages (single-flight capture) have no seam and\n // behave exactly as before.\n const loadRecordingTrack = async (url) => {\n const streamSplice = window.__vosStreamSplice__;\n if (streamSplice) {\n try {\n const buf = await streamSplice(url, segments, duration);\n if (buf) return buf;\n } catch (e) {\n console.warn('[audio-producer] stream splice failed, falling back:', e);\n }\n }\n const raw = await decodeAudio(url);\n return raw && segments.length ? spliceAudio(raw, segments) : raw;\n };\n\n let mic = voiceSrc ? await loadRecordingTrack(voiceSrc) : null;\n let sys = sysSrc ? await loadRecordingTrack(sysSrc) : null;\n\n // The clips: decode each distinct plan source to plain PCM, then render\n // the plan with the engine's mixer (one buffer, every clip at its\n // interpolated position and gain, the duck curve already folded in).\n const clipPcm = new Map();\n for (const src of new Set(tracks.map((t) => t.src))) {\n const buf = await decodeAudio(src);\n if (!buf) continue;\n const channels = [];\n for (let c = 0; c < buf.numberOfChannels; c++) channels.push(buf.getChannelData(c));\n clipPcm.set(src, { sampleRate: buf.sampleRate, length: buf.length, channels });\n }\n if (!mic && !sys && clipPcm.size === 0) return null;\n // Voice-only at unity gain: skip the offline pass — but ONLY when the\n // buffer fits the requested output duration. A spliced take can run longer\n // than a duration-capped render asks for (Render API maxDuration), and\n // returning it whole used to mux extra seconds of audio onto the video\n // Overlong falls through to the offline render, whose\n // length IS the duration by construction.\n if (\n clipPcm.size === 0 && mic && !sys && micGain === 1 &&\n mic.length <= Math.ceil(duration * mic.sampleRate)\n ) return mic;\n\n const off = new OfflineAudioContext(2, Math.max(1, Math.ceil(duration * rate)), rate);\n if (mic) {\n const src = off.createBufferSource();\n src.buffer = mic;\n const gain = off.createGain();\n gain.gain.value = micGain;\n src.connect(gain);\n gain.connect(off.destination);\n src.start(0);\n }\n if (sys) {\n const src = off.createBufferSource();\n src.buffer = sys;\n const gain = off.createGain();\n gain.gain.value = sysGain;\n src.connect(gain);\n gain.connect(off.destination);\n src.start(0);\n }\n if (clipPcm.size > 0) {\n const { mixAudio } = await import(CORE_AUDIO_URL);\n const pcm = mixAudio(audioPlan, clipPcm, { sampleRate: rate, channels: 2 });\n const buf = off.createBuffer(pcm.channels.length, pcm.length, pcm.sampleRate);\n for (let c = 0; c < pcm.channels.length; c++) buf.copyToChannel(pcm.channels[c], c);\n const src = off.createBufferSource();\n src.buffer = buf;\n src.connect(off.destination);\n src.start(0);\n }\n return off.startRendering();\n};\n`\n}\n\n/**\n * Does this composition carry anything the audio producer could mix? The\n * voice reads off `data`; the clips off the studio stack entry (`stack` in\n * either shape `studioEntryData` reads) or an already-built plan.\n */\nexport function dataHasAudio(\n data: unknown,\n stack?: unknown,\n plan?: AudioPlanJson | null,\n): boolean {\n const d =\n data != null && typeof data === 'object'\n ? (data as Record<string, unknown>)\n : null\n const hasVoice =\n !!d &&\n (typeof d.micSrc === 'string' ||\n (!!d.hasAudio && typeof d.videoSrc === 'string'))\n const entryAudio = studioEntryData(stack)?.audio\n const hasClips =\n (!!plan && plan.tracks.length > 0) ||\n (Array.isArray(entryAudio) && entryAudio.length > 0)\n return hasVoice || hasClips\n}\n","/**\n * Audio mix page — an HTML page that renders a chunked export's audio track\n * ALONE and PUTs it to the ingest route as the `audio` part.\n *\n * Why a page: the mix needs OfflineAudioContext + WebCodecs AudioEncoder,\n * which exist only in browsers. Why ALONE: the finalize page used to produce\n * audio AND concat video in one context, and the combined memory bill\n * (whole source recording + full-length PCM + all parts + output buffer)\n * is what the fleet kills at peak (job 288257ae). This page's\n * live set stays ~tens of MB by construction:\n *\n * - the source recording is never fetched whole: `__vosStreamSplice__`\n * (installed here, consumed by the shared audio producer) streams-decodes\n * ONLY the needed source spans through mediabunny's UrlSource, whose\n * read cache is bounded (~8MiB) — the asset route serves Range/206.\n * - the spliced voice buffer is capped at the REQUESTED output duration,\n * which is also the duration fix (a duration-capped render must not carry the\n * full take's audio).\n * - the encoded result is ~1MB of Opus in WebM.\n *\n * The whole-file decodeAudioData path stays as the in-page fallback rung\n * (unsupported codec, no Range support): the producer falls back on any\n * stream-splice failure, so a mix is never LOST to the optimization.\n *\n * Runs CONCURRENTLY with the chunk phase — it depends only on the source\n * recording and the doc, never on a part.\n *\n * Failure contract: mirrors the finalize page — any error lands in\n * `__renderComplete = { success: false, error }`; stage markers ride\n * `__finalizeStage` (heap-stamped) so a death localizes itself.\n */\n\nimport { audioProducerCode } from './audioProducer'\nimport type { AudioPlanJson } from './audioProducer'\n\nconst MEDIABUNNY_URL = 'https://esm.sh/mediabunny@1.27.3?target=es2022'\n\nexport interface AudioMixPageOptions {\n /** The vos's resolved ctx.data (asset URLs absolute, render token baked). */\n data: unknown\n /**\n * The studio clips as an engine audio plan, built by the host from\n * the stored config's `vosso.studio` stack entry; null when the take has\n * no music/SFX. Rides the page's CONFIG, once.\n */\n plan?: AudioPlanJson | null\n /** Requested OUTPUT duration in seconds — the mix length, the trim bound. */\n duration: number\n /** The finished audio file is PUT here (ingest `?part=audio`). */\n uploadUrl: string\n}\n\nexport function buildAudioMixPage(options: AudioMixPageOptions): string {\n const config = JSON.stringify({\n data: options.data,\n plan: options.plan ?? null,\n duration: options.duration,\n uploadUrl: options.uploadUrl,\n })\n\n return `<!doctype html>\n<html>\n<head><meta charset=\"utf-8\"><title>vos audio mix</title></head>\n<body>\n<script type=\"module\">\nconst CONFIG = ${config};\n\n// Same stage contract as the finalize page: worker polls __finalizeStage,\n// reports the last stage on a silent death. Stage NAME stays the first\n// space-delimited token (renderPolicy.finalizeDeathStage parses it).\nconst stage = (s) => {\n const m = performance.memory;\n window.__finalizeStage = m ? s + ' heap=' + Math.round(m.usedJSHeapSize / 1048576) + 'MB' : s;\n};\nstage('boot');\n\n;(async () => {\n stage('import-mediabunny');\n const MB = await import(${JSON.stringify(MEDIABUNNY_URL)});\n\n // Streaming splice seam consumed by the audio producer below: decode ONLY\n // the source spans the segments keep, capped at maxSeconds of output.\n // Mirrors spliceAudio's piece math (copy at rate 1, linear resample\n // otherwise); the producer's whole-file path remains the authoritative\n // fallback, so a divergence here degrades to slower, never to wrong.\n window.__vosStreamSplice__ = async (url, segments, maxSeconds) => {\n const input = new MB.Input({ formats: MB.ALL_FORMATS, source: new MB.UrlSource(url) });\n try {\n const track = await input.getPrimaryAudioTrack();\n if (!track) return null;\n if (!(await track.canDecode())) return null;\n const sr = track.sampleRate;\n const ch = Math.min(2, Math.max(1, track.numberOfChannels || 2));\n const srcDur = await input.computeDuration();\n\n const spans = Array.isArray(segments) && segments.length\n ? segments\n : [{ in: 0, out: srcDur }];\n const pieces = [];\n let outTotal = 0;\n for (const s of spans) {\n const start = Math.max(0, Math.min(s.in, srcDur));\n const end = Math.max(start, Math.min(s.out, srcDur));\n const rate = s.rate !== undefined && s.rate > 0 ? s.rate : 1;\n let outLen = (end - start) / rate;\n if (outTotal + outLen > maxSeconds) outLen = maxSeconds - outTotal;\n if (outLen <= 0) break;\n pieces.push({ in: start, out: start + outLen * rate, rate, outLen });\n outTotal += outLen;\n if (outTotal >= maxSeconds) break;\n }\n if (pieces.length === 0) return null;\n\n const totalFrames = Math.max(1, Math.round(outTotal * sr));\n const out = new AudioBuffer({ length: totalFrames, numberOfChannels: ch, sampleRate: sr });\n const sink = new MB.AudioBufferSink(track);\n let outOffset = 0;\n for (let pi = 0; pi < pieces.length; pi++) {\n const p = pieces[pi];\n stage('voice-piece-' + pi);\n // Materialize ONE piece's source span (bounded), then splice from it.\n const pieceFrames = Math.max(1, Math.round((p.out - p.in) * sr));\n const temp = [];\n for (let c = 0; c < ch; c++) temp.push(new Float32Array(pieceFrames));\n for await (const wrapped of sink.buffers(p.in, p.out)) {\n const b = wrapped.buffer;\n const at = Math.round((wrapped.timestamp - p.in) * sr);\n for (let c = 0; c < ch; c++) {\n const src = b.getChannelData(Math.min(c, b.numberOfChannels - 1));\n let from = 0, to = at, n = src.length;\n if (to < 0) { from = -to; to = 0; n -= from; }\n n = Math.min(n, pieceFrames - to);\n if (n > 0) temp[c].set(src.subarray(from, from + n), to);\n }\n }\n const outFrames = Math.min(Math.round(p.outLen * sr), totalFrames - outOffset);\n for (let c = 0; c < ch; c++) {\n const dst = out.getChannelData(c);\n const src = temp[c];\n if (p.rate === 1) {\n dst.set(src.subarray(0, Math.min(outFrames, src.length)), outOffset);\n } else {\n const last = src.length - 1;\n for (let i = 0; i < outFrames; i++) {\n const pos = i * p.rate;\n const j = Math.min(Math.floor(pos), last);\n const a = src[j];\n const b2 = src[Math.min(j + 1, last)];\n dst[outOffset + i] = a + (b2 - a) * (pos - j);\n }\n }\n }\n outOffset += outFrames;\n }\n return out;\n } finally {\n input.dispose();\n }\n };\n\n ${audioProducerCode()}\n\n stage('mix');\n let mixed = await window.__vosAudioProducer__({\n data: CONFIG.data,\n plan: CONFIG.plan,\n duration: CONFIG.duration,\n sampleRate: 48000,\n });\n if (!mixed) {\n // Every declared source failed to decode (fetch denied, undecodable\n // bytes). Reporting \"no buffer\" here used to route finalize onto the\n // browser concat fallback — whose in-page producer faces the SAME\n // failures and whose page-memory concat is the known OOM death (job\n // cdf6e026 died twice at concat-part-10 after this very branch). Land a\n // duration-true SILENT track instead so the worker mux still runs: the\n // audible outcome matches the client exporter's documented fail-open\n // (a source that won't decode loses its sound, never the export).\n console.warn('[audio-mix] no source decoded; landing a silent track');\n mixed = new AudioBuffer({\n length: Math.max(1, Math.ceil(CONFIG.duration * 48000)),\n numberOfChannels: 2,\n sampleRate: 48000,\n });\n }\n\n stage('encode');\n // Opus always: the fleet is Linux Chrome, which has no AAC encoder, and\n // the worker mux stream-copies whatever codec arrives here.\n const output = new MB.Output({ format: new MB.WebMOutputFormat(), target: new MB.BufferTarget() });\n const source = new MB.AudioBufferSource({ codec: 'opus', bitrate: MB.QUALITY_HIGH });\n output.addAudioTrack(source);\n await output.start();\n await source.add(mixed);\n source.close();\n await output.finalize();\n const buffer = output.target.buffer;\n if (!buffer) throw new Error('Audio encode produced no output buffer');\n\n stage('upload');\n const res = await fetch(CONFIG.uploadUrl, {\n method: 'PUT',\n headers: { 'Content-Type': 'audio/webm' },\n body: buffer,\n });\n if (!res.ok) throw new Error('Upload failed: HTTP ' + res.status);\n\n window.__renderComplete = { success: true, uploaded: true, size: buffer.byteLength };\n})().catch((e) => {\n window.__renderComplete = { success: false, error: String((e && e.stack) || e) };\n});\n</script>\n</body>\n</html>`\n}\n","/**\n * Image diff page — computes the RMS difference between two images in a\n * browser page. Workers can't decode images (no canvas/Image APIs), so the\n * golden-frame canary hands both images to a page and reads one number back.\n *\n * Same completion contract as every render page: `__renderComplete` with\n * `{ success, rms, width, height }` or `{ success: false, error }`.\n * RMS is over RGB (alpha ignored) in 0–255 units: WebP re-encode jitter of\n * the same frame lands well under 5; a real engine/fleet drift (missing\n * layer, changed shader, font fallback) lands far above 10.\n */\n\nexport interface ImageDiffPageOptions {\n /** data: URLs — the canary inlines both images (they are ~tens of KB). */\n candidateUrl: string\n goldenUrl: string\n}\n\nexport function buildImageDiffPage(options: ImageDiffPageOptions): string {\n // <-escape so no input can ever close the script tag (data URLs are\n // base64 and can't contain '<', but the builder shouldn't rely on that).\n const config = JSON.stringify({\n candidate: options.candidateUrl,\n golden: options.goldenUrl,\n }).replace(/</g, '\\\\u003c')\n return `<!doctype html>\n<html>\n<head><meta charset=\"utf-8\"><title>vos golden diff</title></head>\n<body>\n<script type=\"module\">\nconst CONFIG = ${config};\n\nconst load = (src) =>\n new Promise((resolve, reject) => {\n const img = new Image();\n img.onload = () => resolve(img);\n img.onerror = () => reject(new Error('image failed to load'));\n img.src = src;\n });\n\n;(async () => {\n const [candidate, golden] = await Promise.all([\n load(CONFIG.candidate),\n load(CONFIG.golden),\n ]);\n if (candidate.width !== golden.width || candidate.height !== golden.height) {\n throw new Error(\n 'size mismatch: candidate ' + candidate.width + 'x' + candidate.height +\n ' vs golden ' + golden.width + 'x' + golden.height,\n );\n }\n const draw = (img) => {\n const canvas = document.createElement('canvas');\n canvas.width = img.width;\n canvas.height = img.height;\n const ctx = canvas.getContext('2d');\n ctx.drawImage(img, 0, 0);\n return ctx.getImageData(0, 0, img.width, img.height).data;\n };\n const a = draw(candidate);\n const b = draw(golden);\n let sum = 0;\n let n = 0;\n for (let i = 0; i < a.length; i += 4) {\n for (let c = 0; c < 3; c++) {\n const d = a[i + c] - b[i + c];\n sum += d * d;\n n++;\n }\n }\n const rms = Math.sqrt(sum / n);\n window.__renderComplete = {\n success: true,\n rms,\n width: candidate.width,\n height: candidate.height,\n };\n})().catch((e) => {\n window.__renderComplete = { success: false, error: String((e && e.message) || e) };\n});\n</script>\n</body>\n</html>`\n}\n","/**\n * Digest page — the fleet's eyes for a take. One bare page decodes the\n * recording through a <video> and does what the CLI's digest page does\n * locally: per-source-second motion bins (64×36 luma diff, changed-pixel\n * fraction), the scene instants those bins reveal, one FOOTAGE frame plus a\n * crop per moment, and a contact sheet. Every image PUTs itself to the ingest\n * route as a `digest-*` part (the page's bytes never marshal through the\n * browser session), then a manifest with the bins and every image's size.\n *\n * Same completion contract as every render page: `__renderComplete` with\n * `{ success, uploaded: true }` or `{ success: false, error }`.\n *\n * The bins must be byte-comparable with the CLI's (`MOTION_DELTA` 24, one\n * seek per source second at i + 0.5, 64×36): they feed the speed planner.\n */\n\nexport interface DigestShot {\n /** Part stem; the page writes `<name>.full.png` and, with a box, `<name>.crop.png`. */\n name: string\n /** Source instant, seconds. */\n t: number\n /** Crop box in FRAME px, or null for a full-only moment. */\n box: { x: number; y: number; w: number; h: number } | null\n /** A short label for the contact sheet. */\n label: string\n}\n\nexport interface DigestPageOptions {\n /** The recording, reachable from the page (a `?rt=` asset URL). */\n videoUrl: string\n /** Source duration, seconds. */\n durationS: number\n /** The region of the frame the doc renders (a window take's crop, or all). */\n region: { x: number; y: number; w: number; h: number }\n shots: DigestShot[]\n /** Long edges (px) of the emitted images. */\n fullMax: number\n cropMax: number\n /** Changed-pixel luma threshold for the bins. */\n motionDelta: number\n /** Scene rule: a bin at/above `motion` after one at/below `quiet`. */\n scene: { motion: number; quiet: number }\n /** `…/api/render/ingest/{jobId}?token=…` — parts append `&part=digest-<name>`. */\n uploadUrl: string\n}\n\nexport function buildDigestPage(options: DigestPageOptions): string {\n const config = JSON.stringify(options).replace(/</g, '\\\\u003c')\n return `<!doctype html>\n<html>\n<head><meta charset=\"utf-8\"><title>vos digest</title><link rel=\"icon\" href=\"data:,\">\n<style>html,body{margin:0;background:#000}video{position:absolute;left:-99999px}</style>\n</head>\n<body>\n<script type=\"module\">\nconst cfg = ${config}\nconst raf = () => new Promise((r) => requestAnimationFrame(r))\nconst done = (v) => { window.__renderComplete = v }\ntry {\n // One page per job: the whole recording comes down once, then every seek\n // is local (a network-backed paused <video> gets suspended within seconds).\n const blob = await (await fetch(cfg.videoUrl)).blob()\n const v = document.createElement('video')\n v.muted = true\n v.preload = 'auto'\n v.src = URL.createObjectURL(blob)\n document.body.appendChild(v)\n await new Promise((res, rej) => {\n v.addEventListener('loadeddata', () => res(), { once: true })\n v.addEventListener('error', () => rej(new Error('video failed to load')), { once: true })\n })\n const settle = async () => {\n const t0 = performance.now()\n let last = ''\n while (performance.now() - t0 < 4000) {\n await raf()\n if (!v.seeking && v.readyState >= 2) {\n const now = v.currentTime.toFixed(3)\n if (now === last) return\n last = now\n } else last = ''\n }\n }\n const seek = async (t) => {\n const target = Math.max(0, Math.min(t, Math.max(0, (v.duration || t) - 0.02)))\n if (Math.abs(v.currentTime - target) > 0.0005 || v.readyState < 2) {\n await new Promise((res) => {\n const on = () => { v.removeEventListener('seeked', on); res() }\n v.addEventListener('seeked', on)\n v.currentTime = target\n })\n }\n await settle()\n }\n const W = v.videoWidth\n const H = v.videoHeight\n const region = {\n x: Math.max(0, Math.min(cfg.region.x, W)),\n y: Math.max(0, Math.min(cfg.region.y, H)),\n w: Math.max(1, Math.min(cfg.region.w, W - cfg.region.x)),\n h: Math.max(1, Math.min(cfg.region.h, H - cfg.region.y)),\n }\n\n // Pass 1: motion bins, one per source second.\n const bw = 64, bh = 36\n const bc = document.createElement('canvas')\n bc.width = bw; bc.height = bh\n const bctx = bc.getContext('2d', { willReadFrequently: true })\n let prev = null\n const bins = []\n const n = Math.max(1, Math.ceil(cfg.durationS))\n for (let i = 0; i < n; i++) {\n await seek(Math.min(cfg.durationS, i + 0.5))\n bctx.drawImage(v, 0, 0, bw, bh)\n const d = bctx.getImageData(0, 0, bw, bh).data\n const luma = new Uint8ClampedArray(bw * bh)\n for (let p = 0; p < luma.length; p++) {\n const o = p * 4\n luma[p] = (d[o] * 299 + d[o + 1] * 587 + d[o + 2] * 114) / 1000\n }\n if (!prev) bins.push(0)\n else {\n let changed = 0\n for (let p = 0; p < luma.length; p++) if (Math.abs(luma[p] - prev[p]) > cfg.motionDelta) changed++\n bins.push(Math.round((changed / luma.length) * 1000) / 1000)\n }\n prev = luma\n window.__renderProgress = 0.4 * ((i + 1) / n)\n }\n const scenes = []\n for (let i = 1; i < bins.length; i++) {\n if (bins[i] >= cfg.scene.motion && bins[i - 1] <= cfg.scene.quiet) scenes.push(i)\n }\n\n // Pass 2: frames, crops, the sheet — each PUT as its own part.\n const sizes = {}\n const toPng = (c) => new Promise((res, rej) => c.toBlob((b) => (b ? res(b) : rej(new Error('toBlob failed'))), 'image/png'))\n const put = async (name, body, type) => {\n const r = await fetch(cfg.uploadUrl + '&part=digest-' + name, { method: 'PUT', headers: { 'content-type': type }, body })\n if (!r.ok) throw new Error('upload of ' + name + ' failed: ' + r.status)\n }\n const save = async (name, c) => {\n await put(name, await toPng(c), 'image/png')\n sizes[name] = { width: c.width, height: c.height }\n }\n const fit = (w, h, max) => {\n const s = Math.min(1, max / Math.max(w, h))\n return { w: Math.max(1, Math.round(w * s)), h: Math.max(1, Math.round(h * s)) }\n }\n const draw = (src, sx, sy, sw, sh, max) => {\n const c = document.createElement('canvas')\n const f = fit(sw, sh, max)\n c.width = f.w; c.height = f.h\n c.getContext('2d').drawImage(src, sx, sy, sw, sh, 0, 0, f.w, f.h)\n return c\n }\n const tiles = []\n const shots = cfg.shots.map((s) => ({ ...s, scene: false }))\n for (const s of scenes) shots.push({ name: 'scene-' + s, t: Math.min(cfg.durationS, s + 0.04), box: null, label: 'scene ' + s + 's', scene: true })\n shots.sort((a, b) => a.t - b.t)\n for (let i = 0; i < shots.length; i++) {\n const s = shots[i]\n await seek(s.t)\n const full = draw(v, region.x, region.y, region.w, region.h, cfg.fullMax)\n await save(s.name + '.full.png', full)\n let tileSrc = full\n if (s.box) {\n const b = s.box\n const crop = draw(v, b.x, b.y, b.w, b.h, cfg.cropMax)\n await save(s.name + '.crop.png', crop)\n tileSrc = crop\n }\n const tw = 240\n const th = Math.max(1, Math.round((tw * tileSrc.height) / tileSrc.width))\n const tile = document.createElement('canvas')\n tile.width = tw; tile.height = th\n tile.getContext('2d').drawImage(tileSrc, 0, 0, tw, th)\n tiles.push({ label: s.label, c: tile })\n window.__renderProgress = 0.4 + 0.55 * ((i + 1) / shots.length)\n }\n if (tiles.length) {\n const cols = Math.min(6, tiles.length)\n const tw = 240\n const th = Math.max(...tiles.map((t) => t.c.height))\n const rows = Math.ceil(tiles.length / cols)\n const sheet = document.createElement('canvas')\n sheet.width = cols * (tw + 8) + 8\n sheet.height = rows * (th + 26) + 8\n const ctx = sheet.getContext('2d')\n ctx.fillStyle = '#111'\n ctx.fillRect(0, 0, sheet.width, sheet.height)\n ctx.font = '12px ui-monospace, Menlo, monospace'\n tiles.forEach((t, i) => {\n const x = 8 + (i % cols) * (tw + 8)\n const y = 8 + Math.floor(i / cols) * (th + 26)\n ctx.drawImage(t.c, x, y)\n ctx.fillStyle = '#eee'\n ctx.fillText(t.label, x, y + th + 16)\n })\n await save('sheet.png', sheet)\n }\n await put('manifest.json', new Blob([JSON.stringify({ width: W, height: H, bins, scenes, sizes })], { type: 'application/json' }), 'application/json')\n done({ success: true, uploaded: true, bins: bins.length, shots: shots.length })\n} catch (e) {\n done({ success: false, error: String((e && e.stack) || e) })\n}\n</script>\n</body>\n</html>`\n}\n"],"mappings":"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","names":["decoderConfig","MEDIABUNNY_URL"]}
1
+ {"version":3,"sources":["../src/chunkPlanner.ts","../src/concat.ts","../src/audioProducer.ts"],"sourcesContent":["/**\n * Chunk planner — timeline sharding for deterministic renders.\n *\n * Because vos evaluation is a pure function of time (seek(t) — the whole\n * point of the sampler tween backend), any frame range can render in\n * isolation. The planner splits [0, totalFrames) into balanced, contiguous\n * ranges; each chunk encodes independently (so it starts on a keyframe by\n * construction) with chunk-LOCAL timestamps starting at 0, and the finalize\n * step (concat.ts) offsets packets back onto the global timeline.\n *\n * Pure math — no I/O — so the policy stays unit-testable and reusable by\n * every harness (CLI, local server, render service).\n */\n\nexport interface ChunkPlanPolicy {\n /** Upper bound on simultaneous chunk renders (browser pages/sessions). */\n maxParallel: number\n /**\n * Below this many frames per chunk, extra parallelism costs more in\n * per-chunk fixed overhead (page load, module import, first seek) than it\n * saves — stop splitting. (Remotion's floor is 5; ours is higher because\n * chunk startup includes CDN module imports.)\n */\n minFramesPerChunk?: number\n}\n\nexport const DEFAULT_MIN_FRAMES_PER_CHUNK = 24\n\nexport interface RenderChunk {\n /** 0-based chunk index; also the concat order. */\n index: number\n /** First frame of the chunk (inclusive, global frame numbering). */\n startFrame: number\n /** End frame (exclusive). */\n endFrame: number\n frameCount: number\n /** Global start time in seconds (startFrame / fps). */\n startTime: number\n /** Exact chunk duration in seconds (frameCount / fps). */\n duration: number\n}\n\n/**\n * Split `totalFrames` into at most `maxParallel` balanced contiguous chunks.\n * Sizes differ by at most one frame; chunk boundaries are exact frame\n * indices so no frame is rendered twice or skipped.\n */\nexport function planChunks(\n totalFrames: number,\n fps: number,\n policy: ChunkPlanPolicy,\n): RenderChunk[] {\n if (!Number.isInteger(totalFrames) || totalFrames <= 0) {\n throw new Error(\n `planChunks: totalFrames must be a positive integer, got ${totalFrames}`,\n )\n }\n if (!(fps > 0)) {\n throw new Error(`planChunks: fps must be positive, got ${fps}`)\n }\n const minFrames = policy.minFramesPerChunk ?? DEFAULT_MIN_FRAMES_PER_CHUNK\n const chunkCount = Math.max(\n 1,\n Math.min(\n Math.floor(policy.maxParallel),\n Math.floor(totalFrames / minFrames),\n ),\n )\n\n const base = Math.floor(totalFrames / chunkCount)\n const remainder = totalFrames % chunkCount\n\n const chunks: RenderChunk[] = []\n let startFrame = 0\n for (let index = 0; index < chunkCount; index++) {\n // The first `remainder` chunks carry one extra frame.\n const frameCount = base + (index < remainder ? 1 : 0)\n const endFrame = startFrame + frameCount\n chunks.push({\n index,\n startFrame,\n endFrame,\n frameCount,\n startTime: startFrame / fps,\n duration: frameCount / fps,\n })\n startFrame = endFrame\n }\n return chunks\n}\n","/**\n * Finalize concat — stream-copy chunk videos into one file, no re-encode.\n *\n * Each chunk is an independently encoded file whose timestamps start at 0\n * (chunk-local). Concat demuxes every chunk's video packets and re-muxes\n * them into one output, offsetting timestamps by the chunk's global start\n * time. Because chunks were encoded with identical codec/params (the caller's\n * responsibility — one encoder config per render) and every independently\n * encoded file begins with a keyframe, packet-level concatenation is valid\n * with zero quality loss and near-zero CPU: pure container work, runs in\n * plain Node (mediabunny demux/mux is pure JS; no WebCodecs needed).\n *\n * Audio is deliberately absent here: per the rendering plan, audio renders\n * ONCE centrally and gets muxed against the concatenated video, so encoder\n * priming seams never exist.\n */\n\nimport {\n ALL_FORMATS,\n BufferSource,\n BufferTarget,\n EncodedAudioPacketSource,\n EncodedPacketSink,\n EncodedVideoPacketSource,\n Input,\n Mp4OutputFormat,\n Output,\n WebMOutputFormat,\n} from 'mediabunny'\nimport type { EncodedPacket, InputVideoTrack, VideoCodec } from 'mediabunny'\n\nexport interface ConcatChunk {\n /** Encoded chunk file bytes (WebM or MP4, matching `format`). */\n data: Uint8Array\n /**\n * The chunk's exact intended duration in seconds (frameCount / fps, from\n * the chunk plan). Used as the timestamp offset for the NEXT chunk —\n * derived from the plan, not from packet rounding, so drift can't\n * accumulate across chunks.\n */\n duration: number\n}\n\nexport interface ConcatOptions {\n format: 'webm' | 'mp4'\n /** Stamped into the output track metadata (players use it as a hint). */\n frameRate?: number\n}\n\nexport interface ConcatResult {\n bytes: Uint8Array\n /** Total video packets written. */\n packetCount: number\n /** Codec copied through (from the first chunk). */\n codec: VideoCodec\n}\n\nasync function openVideoTrack(\n data: Uint8Array,\n chunkIndex: number,\n): Promise<{ input: Input; track: InputVideoTrack }> {\n const input = new Input({\n formats: ALL_FORMATS,\n source: new BufferSource(data),\n })\n const track = await input.getPrimaryVideoTrack()\n if (!track) throw new Error(`Chunk ${chunkIndex} has no video track`)\n return { input, track }\n}\n\nexport interface MuxExportOptions extends ConcatOptions {\n /**\n * Video parts in plan order. An async iterable lets the caller feed parts\n * ONE at a time from storage instead of materializing all of them first\n * (the worker finalize's memory shape).\n */\n video: AsyncIterable<ConcatChunk> | Iterable<ConcatChunk>\n /**\n * Optional encoded audio file (any container mediabunny reads — the audio\n * mix page produces Opus in WebM). Its packets are STREAM-COPIED into the\n * output's audio track: no decode, no WebCodecs, pure container work, so\n * this runs in a Worker isolate exactly like the video concat.\n */\n audio?: Uint8Array\n}\n\n/**\n * Mux a chunked export's final artifact: stream-copy the video parts with\n * plan-derived timestamp offsets, and stream-copy the pre-encoded audio\n * track when one is provided. `packetCount` counts VIDEO packets only —\n * that is the frame-parity contract callers assert against the plan.\n */\nexport async function muxEncodedExport(\n options: MuxExportOptions,\n): Promise<ConcatResult> {\n const output = new Output({\n format:\n options.format === 'mp4' ? new Mp4OutputFormat() : new WebMOutputFormat(),\n target: new BufferTarget(),\n })\n\n // Audio track first (mirrors the finalize page: audio is small and fully\n // known up front; the muxer interleaves at finalize).\n let audioTrack: {\n source: EncodedAudioPacketSource\n sink: EncodedPacketSink\n decoderConfig: AudioDecoderConfig\n } | null = null\n if (options.audio) {\n const input = new Input({\n formats: ALL_FORMATS,\n source: new BufferSource(options.audio),\n })\n const track = await input.getPrimaryAudioTrack()\n if (!track || !track.codec) {\n throw new Error('Audio part has no readable audio track')\n }\n const decoderConfig = await track.getDecoderConfig()\n if (!decoderConfig) throw new Error('Audio part has no decoder config')\n const source = new EncodedAudioPacketSource(track.codec)\n output.addAudioTrack(source)\n audioTrack = { source, sink: new EncodedPacketSink(track), decoderConfig }\n }\n\n let videoSource: EncodedVideoPacketSource | null = null\n let codec: VideoCodec | null = null\n let decoderConfig: VideoDecoderConfig | null = null\n let started = false\n let offset = 0\n let packetCount = 0\n let index = 0\n\n for await (const chunk of options.video) {\n const i = index++\n const { track } = await openVideoTrack(chunk.data, i)\n if (i === 0) {\n codec = track.codec\n if (!codec) throw new Error('Chunk 0 video codec could not be determined')\n decoderConfig = await track.getDecoderConfig()\n if (!decoderConfig) throw new Error('Chunk 0 has no decoder config')\n videoSource = new EncodedVideoPacketSource(codec)\n output.addVideoTrack(\n videoSource,\n options.frameRate ? { frameRate: options.frameRate } : undefined,\n )\n await output.start()\n started = true\n\n if (audioTrack) {\n let firstAudio = true\n for await (const packet of audioTrack.sink.packets()) {\n await audioTrack.source.add(\n packet,\n firstAudio\n ? { decoderConfig: audioTrack.decoderConfig }\n : undefined,\n )\n firstAudio = false\n }\n audioTrack.source.close()\n }\n } else if (track.codec !== codec) {\n throw new Error(\n `Chunk ${i} codec ${String(track.codec)} != chunk 0 codec ${String(codec)} — chunks must share one encoder config`,\n )\n }\n\n const sink = new EncodedPacketSink(track)\n let firstOfChunk = true\n for await (const packet of sink.packets()) {\n if (firstOfChunk && packet.type !== 'key') {\n throw new Error(\n `Chunk ${i} does not start on a keyframe — was it encoded as an independent chunk?`,\n )\n }\n const shifted: EncodedPacket = packet.clone({\n timestamp: packet.timestamp + offset,\n })\n await videoSource!.add(\n shifted,\n firstOfChunk && i === 0 && decoderConfig\n ? { decoderConfig }\n : undefined,\n )\n firstOfChunk = false\n packetCount++\n }\n\n // Advance by the PLANNED duration (frames/fps), not the demuxed one —\n // container timestamp rounding must not accumulate across chunks.\n offset += chunk.duration\n }\n\n if (!started || !videoSource || !codec) {\n throw new Error('muxEncodedExport: no video parts')\n }\n\n await output.finalize()\n const bytes = output.target.buffer\n if (!bytes) throw new Error('Concat produced no output buffer')\n return { bytes: new Uint8Array(bytes), packetCount, codec }\n}\n\n/**\n * Concatenate independently encoded chunk videos into one stream-copied file.\n * Chunks must share codec and encoder params; the first packet of every\n * chunk must be a keyframe (violations throw — better a loud failure at\n * finalize than a corrupt artifact). Thin wrapper over muxEncodedExport,\n * kept as the CLI's finalize entry point.\n */\nexport async function concatEncodedVideo(\n chunks: ConcatChunk[],\n options: ConcatOptions,\n): Promise<ConcatResult> {\n if (chunks.length === 0) throw new Error('concatEncodedVideo: no chunks')\n return muxEncodedExport({ ...options, video: chunks })\n}\n\n/**\n * Count video packets in an encoded file — cheap integrity check used by\n * parity verification (chunked and single-flight renders of the same\n * composition must contain the same number of frames).\n */\nexport async function countVideoPackets(data: Uint8Array): Promise<number> {\n const { track } = await openVideoTrack(data, 0)\n const sink = new EncodedPacketSink(track)\n let count = 0\n for await (const packet of sink.packets()) {\n void packet\n count++\n }\n return count\n}\n","/**\n * The take audio producer — page JavaScript implementing the export audio mix\n * for the take data schema, as a source string for injection into render\n * pages (the engine's `capture.audioProducerCode` seam for single-flight\n * captures, the audio mix page, and the finalize page for chunked exports).\n *\n * MIRRORS the client exporter's audio path (the platform's\n * decodeAudio → spliceAudio → mixExportAudio) — the same\n * algorithm, so a cloud export sounds identical to a client export of the\n * same composition. KEEP THEM IN SYNC when the client mixer changes.\n *\n * The producer is `window.__vosAudioProducer__({ data, plan, duration,\n * sampleRate })`. Inputs read from `data` (all optional):\n * videoSrc recording URL (requires hasAudio; the VOICE on legacy takes,\n * the SYSTEM track on split takes)\n * micSrc mic sidecar URL (the mic/system split) — the voice; when present the\n * recording's own track mixes as system audio under sysGain\n * hasAudio whether the recording carries audio\n * segments [{ in, out, rate? }] SOURCE spans — mic is spliced/resampled\n * so trims and speed changes stay lip-synced (pitch shifts with\n * rate, matching preservesPitch=false preview playback)\n * micGain voice master fader (default 1)\n * sysGain system-audio master fader (default 1; split takes only)\n *\n * The music/SFX clips no longer ride `data`: they live on the\n * studio stack entry (`vosso.studio`), and the host builds an ENGINE audio\n * plan from them ahead of the page (studio-core's `studioAudioPlan`), the\n * shape `@vosjs/core/audio`'s `mixAudio` renders. The page imports that\n * mixer from the CDN and renders the plan to ONE buffer beside the\n * recording's tracks. `plan` reaches the producer either as a call argument\n * (a page's CONFIG) or baked into the code as `window.__vosAudioPlan__`\n * (the engine's capture template calls the producer with `{ data, duration,\n * sampleRate }` only, so single-flight captures bake it); no plan, no clips.\n */\n\n/**\n * The `@vosjs/core/audio` build a render page imports for `mixAudio`.\n * Pinned to the version the api installs (`coreAudioCdn.test.ts` there\n * holds it to the installed package): render-core has no engine dependency.\n */\nexport const CORE_AUDIO_CDN_URL =\n 'https://esm.sh/@vosjs/core@0.23.1/audio?target=es2022'\n\n/**\n * A sampled audio plan, structurally `@vosjs/core/audio`'s `AudioPlan` (and\n * studio-core's `StudioAudioPlan`), typed here so render-core depends on\n * neither.\n */\nexport interface AudioPlanJson {\n duration: number\n step: number\n tracks: {\n id: string\n src: string\n loop: boolean\n points: { t: number; on: boolean; pos: number; gain: number }[]\n }[]\n}\n\nexport interface AudioProducerCodeOptions {\n /**\n * Bake the plan into the code (`window.__vosAudioPlan__`) for callers\n * that cannot pass one at call time (the engine's capture template).\n */\n plan?: AudioPlanJson | null\n /** Override the mixer import (tests, a self-hosted engine). */\n coreAudioUrl?: string\n}\n\n/** The studio stack entry id (studio-core's `STUDIO_ENTRY_ID`). */\nexport const STUDIO_ENTRY_ID = 'vosso.studio'\n\n/**\n * The studio entry's own data out of a stack in either shape: the STORED\n * config's `stack` array (`[{ id, data, … }]`) or the lowered record keyed\n * by entry id (`{ 'vosso.studio': data }`). Null when there is none.\n */\nexport function studioEntryData(\n stack: unknown,\n): Record<string, unknown> | null {\n if (stack == null || typeof stack !== 'object') return null\n const asData = (value: unknown) =>\n value && typeof value === 'object'\n ? (value as Record<string, unknown>)\n : null\n if (Array.isArray(stack)) {\n for (const entry of stack) {\n if (\n entry &&\n typeof entry === 'object' &&\n (entry as { id?: unknown }).id === STUDIO_ENTRY_ID\n ) {\n return asData((entry as { data?: unknown }).data)\n }\n }\n return null\n }\n return asData((stack as Record<string, unknown>)[STUDIO_ENTRY_ID])\n}\n\nexport function audioProducerCode(\n options: AudioProducerCodeOptions = {},\n): string {\n const coreAudioUrl = options.coreAudioUrl ?? CORE_AUDIO_CDN_URL\n const bakedPlan = options.plan\n ? `window.__vosAudioPlan__ = ${JSON.stringify(options.plan)};`\n : ''\n return `\n${bakedPlan}\nwindow.__vosAudioProducer__ = async ({ data, plan, duration, sampleRate }) => {\n const rate = sampleRate || 48000;\n const audioPlan = plan === undefined ? window.__vosAudioPlan__ : plan;\n const CORE_AUDIO_URL = ${JSON.stringify(coreAudioUrl)};\n\n const decodeAudio = async (url) => {\n try {\n const buf = await (await fetch(url)).arrayBuffer();\n const ac = new AudioContext();\n try {\n return await ac.decodeAudioData(buf);\n } finally {\n void ac.close();\n }\n } catch (e) {\n console.warn('[audio-producer] source not decodable:', e);\n return null;\n }\n };\n\n // Cut mic audio to the kept source segments; rated segments resample\n // piecewise with linear interpolation (tape-style pitch shift).\n const spliceAudio = (buf, segments) => {\n const sr = buf.sampleRate;\n const pieces = segments.map((s) => {\n const start = Math.max(0, Math.min(Math.round(s.in * sr), buf.length));\n const end = Math.max(start, Math.min(Math.round(s.out * sr), buf.length));\n const r = s.rate !== undefined && s.rate > 0 ? s.rate : 1;\n return { start, end, rate: r, outLen: Math.round((end - start) / r) };\n });\n const total = pieces.reduce((sum, p) => sum + p.outLen, 0);\n if (total <= 0) return buf;\n if (pieces.length === 1 && pieces[0].start === 0 && pieces[0].rate === 1 && total === buf.length) {\n return buf;\n }\n const out = new AudioBuffer({ length: total, numberOfChannels: buf.numberOfChannels, sampleRate: sr });\n for (let ch = 0; ch < buf.numberOfChannels; ch++) {\n const src = buf.getChannelData(ch);\n const dst = out.getChannelData(ch);\n let offset = 0;\n for (const p of pieces) {\n if (p.rate === 1) {\n dst.set(src.subarray(p.start, p.end), offset);\n } else {\n const last = buf.length - 1;\n for (let i = 0; i < p.outLen; i++) {\n const pos = p.start + i * p.rate;\n const j = Math.min(Math.floor(pos), last);\n const a = src[j];\n const b = src[Math.min(j + 1, last)];\n dst[offset + i] = a + (b - a) * (pos - j);\n }\n }\n offset += p.outLen;\n }\n }\n return out;\n };\n\n const videoSrc = typeof data?.videoSrc === 'string' ? data.videoSrc : null;\n const segments = Array.isArray(data?.segments) ? data.segments : [];\n const tracks = audioPlan && Array.isArray(audioPlan.tracks) ? audioPlan.tracks : [];\n const micGain = typeof data?.micGain === 'number' ? data.micGain : 1;\n const sysGain = typeof data?.sysGain === 'number' ? data.sysGain : 1;\n const micSrc = typeof data?.micSrc === 'string' ? data.micSrc : null;\n // Voice: the mic sidecar (AT split), else the legacy track on the recording.\n // System: the recording's own track, only when the take is split.\n const voiceSrc = micSrc ?? (data?.hasAudio && videoSrc ? videoSrc : null);\n const sysSrc = micSrc && data?.hasAudio && videoSrc ? videoSrc : null;\n\n // Recording tracks load through a pluggable seam: a host page may\n // install window.__vosStreamSplice__(url, segments, maxSeconds) — the\n // audio mix page does, backed by mediabunny streaming decode of ONLY the\n // needed source spans, capped at the output duration — and the producer\n // prefers it, falling back to the whole-file decodeAudioData path on any\n // failure. Standalone pages (single-flight capture) have no seam and\n // behave exactly as before.\n const loadRecordingTrack = async (url) => {\n const streamSplice = window.__vosStreamSplice__;\n if (streamSplice) {\n try {\n const buf = await streamSplice(url, segments, duration);\n if (buf) return buf;\n } catch (e) {\n console.warn('[audio-producer] stream splice failed, falling back:', e);\n }\n }\n const raw = await decodeAudio(url);\n return raw && segments.length ? spliceAudio(raw, segments) : raw;\n };\n\n let mic = voiceSrc ? await loadRecordingTrack(voiceSrc) : null;\n let sys = sysSrc ? await loadRecordingTrack(sysSrc) : null;\n\n // The clips: decode each distinct plan source to plain PCM, then render\n // the plan with the engine's mixer (one buffer, every clip at its\n // interpolated position and gain, the duck curve already folded in).\n const clipPcm = new Map();\n for (const src of new Set(tracks.map((t) => t.src))) {\n const buf = await decodeAudio(src);\n if (!buf) continue;\n const channels = [];\n for (let c = 0; c < buf.numberOfChannels; c++) channels.push(buf.getChannelData(c));\n clipPcm.set(src, { sampleRate: buf.sampleRate, length: buf.length, channels });\n }\n if (!mic && !sys && clipPcm.size === 0) return null;\n // Voice-only at unity gain: skip the offline pass — but ONLY when the\n // buffer fits the requested output duration. A spliced take can run longer\n // than a duration-capped render asks for (Render API maxDuration), and\n // returning it whole used to mux extra seconds of audio onto the video\n // Overlong falls through to the offline render, whose\n // length IS the duration by construction.\n if (\n clipPcm.size === 0 && mic && !sys && micGain === 1 &&\n mic.length <= Math.ceil(duration * mic.sampleRate)\n ) return mic;\n\n const off = new OfflineAudioContext(2, Math.max(1, Math.ceil(duration * rate)), rate);\n if (mic) {\n const src = off.createBufferSource();\n src.buffer = mic;\n const gain = off.createGain();\n gain.gain.value = micGain;\n src.connect(gain);\n gain.connect(off.destination);\n src.start(0);\n }\n if (sys) {\n const src = off.createBufferSource();\n src.buffer = sys;\n const gain = off.createGain();\n gain.gain.value = sysGain;\n src.connect(gain);\n gain.connect(off.destination);\n src.start(0);\n }\n if (clipPcm.size > 0) {\n const { mixAudio } = await import(CORE_AUDIO_URL);\n const pcm = mixAudio(audioPlan, clipPcm, { sampleRate: rate, channels: 2 });\n const buf = off.createBuffer(pcm.channels.length, pcm.length, pcm.sampleRate);\n for (let c = 0; c < pcm.channels.length; c++) buf.copyToChannel(pcm.channels[c], c);\n const src = off.createBufferSource();\n src.buffer = buf;\n src.connect(off.destination);\n src.start(0);\n }\n return off.startRendering();\n};\n`\n}\n\n/**\n * Does this composition carry anything the audio producer could mix? The\n * voice reads off `data`; the clips off the studio stack entry (`stack` in\n * either shape `studioEntryData` reads) or an already-built plan.\n */\nexport function dataHasAudio(\n data: unknown,\n stack?: unknown,\n plan?: AudioPlanJson | null,\n): boolean {\n const d =\n data != null && typeof data === 'object'\n ? (data as Record<string, unknown>)\n : null\n const hasVoice =\n !!d &&\n (typeof d.micSrc === 'string' ||\n (!!d.hasAudio && typeof d.videoSrc === 'string'))\n const entryAudio = studioEntryData(stack)?.audio\n const hasClips =\n (!!plan && plan.tracks.length > 0) ||\n (Array.isArray(entryAudio) && entryAudio.length > 0)\n return hasVoice || hasClips\n}\n"],"mappings":"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","names":["decoderConfig"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vosjs/render-core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "The render harness for deterministic vos renders: timeline-sharded chunk planning and stream-copy chunk concat. Pixels stay in the browser; this package owns the orchestration math and the Node-side media plumbing.",
5
5
  "license": "MIT",
6
6
  "author": "vosso",