@vosjs/render-core 0.3.7 → 0.3.8

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/dist/index.d.ts CHANGED
@@ -161,7 +161,7 @@ declare function countVideoPackets(data: Uint8Array): Promise<number>;
161
161
  * Pinned to the version the api installs (`coreAudioCdn.test.ts` there
162
162
  * holds it to the installed package): render-core has no engine dependency.
163
163
  */
164
- declare const CORE_AUDIO_CDN_URL = "https://esm.sh/@vosjs/core@0.24.1/audio?target=es2022";
164
+ declare const CORE_AUDIO_CDN_URL = "https://esm.sh/@vosjs/core@0.25.2/audio?target=es2022";
165
165
  /**
166
166
  * A sampled audio plan, structurally `@vosjs/core/audio`'s `AudioPlan` (and
167
167
  * studio-core's `StudioAudioPlan`), typed here so render-core depends on
package/dist/index.js CHANGED
@@ -162,7 +162,7 @@ async function countVideoPackets(data) {
162
162
  }
163
163
 
164
164
  // src/audioProducer.ts
165
- var CORE_AUDIO_CDN_URL = "https://esm.sh/@vosjs/core@0.24.1/audio?target=es2022";
165
+ var CORE_AUDIO_CDN_URL = "https://esm.sh/@vosjs/core@0.25.2/audio?target=es2022";
166
166
  var STUDIO_ENTRY_ID = "vosso.studio";
167
167
  function studioEntryData(stack) {
168
168
  if (stack == null || typeof stack !== "object") return null;
package/dist/index.js.map CHANGED
@@ -1 +1 @@
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.24.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 // Decode AT THE MIX RATE: decodeAudioData resamples to its context's rate,\n // and a live AudioContext runs at the output device's, so a headset in its\n // hands-free profile (24 kHz) would hand the encoder a rate it refuses.\n // An offline context holds no device and needs no gesture.\n const decodeAudio = async (url) => {\n try {\n const buf = await (await fetch(url)).arrayBuffer();\n const ac = new OfflineAudioContext(1, 1, rate);\n return await ac.decodeAudioData(buf);\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 // A piece of ANOTHER media (concat) is silence in this track's splice:\n // its seconds are not this file's, and its own audio is not spliced\n // into the cut yet. It still occupies its output time.\n const silent = typeof s.media === 'string' && s.media !== '';\n const r = s.rate !== undefined && s.rate > 0 ? s.rate : 1;\n if (silent) {\n const outLen = Math.max(0, Math.round((s.out - s.in) * sr / r));\n return { start: 0, end: 0, rate: r, outLen, silent: true };\n }\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 return { start, end, rate: r, outLen: Math.round((end - start) / r), silent: false };\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.silent) {\n offset += p.outLen;\n continue;\n }\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"]}
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.25.2/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 // Decode AT THE MIX RATE: decodeAudioData resamples to its context's rate,\n // and a live AudioContext runs at the output device's, so a headset in its\n // hands-free profile (24 kHz) would hand the encoder a rate it refuses.\n // An offline context holds no device and needs no gesture.\n const decodeAudio = async (url) => {\n try {\n const buf = await (await fetch(url)).arrayBuffer();\n const ac = new OfflineAudioContext(1, 1, rate);\n return await ac.decodeAudioData(buf);\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 // A piece of ANOTHER media (concat) is silence in this track's splice:\n // its seconds are not this file's, and its own audio is not spliced\n // into the cut yet. It still occupies its output time.\n const silent = typeof s.media === 'string' && s.media !== '';\n const r = s.rate !== undefined && s.rate > 0 ? s.rate : 1;\n if (silent) {\n const outLen = Math.max(0, Math.round((s.out - s.in) * sr / r));\n return { start: 0, end: 0, rate: r, outLen, silent: true };\n }\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 return { start, end, rate: r, outLen: Math.round((end - start) / r), silent: false };\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.silent) {\n offset += p.outLen;\n continue;\n }\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":";AA0BO,IAAM,+BAA+B;AAqBrC,SAAS,WACd,aACA,KACA,QACe;AACf,MAAI,CAAC,OAAO,UAAU,WAAW,KAAK,eAAe,GAAG;AACtD,UAAM,IAAI;AAAA,MACR,2DAA2D,WAAW;AAAA,IACxE;AAAA,EACF;AACA,MAAI,EAAE,MAAM,IAAI;AACd,UAAM,IAAI,MAAM,yCAAyC,GAAG,EAAE;AAAA,EAChE;AACA,QAAM,YAAY,OAAO,qBAAqB;AAC9C,QAAM,aAAa,KAAK;AAAA,IACtB;AAAA,IACA,KAAK;AAAA,MACH,KAAK,MAAM,OAAO,WAAW;AAAA,MAC7B,KAAK,MAAM,cAAc,SAAS;AAAA,IACpC;AAAA,EACF;AAEA,QAAM,OAAO,KAAK,MAAM,cAAc,UAAU;AAChD,QAAM,YAAY,cAAc;AAEhC,QAAM,SAAwB,CAAC;AAC/B,MAAI,aAAa;AACjB,WAAS,QAAQ,GAAG,QAAQ,YAAY,SAAS;AAE/C,UAAM,aAAa,QAAQ,QAAQ,YAAY,IAAI;AACnD,UAAM,WAAW,aAAa;AAC9B,WAAO,KAAK;AAAA,MACV;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,WAAW,aAAa;AAAA,MACxB,UAAU,aAAa;AAAA,IACzB,CAAC;AACD,iBAAa;AAAA,EACf;AACA,SAAO;AACT;;;ACxEA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AA6BP,eAAe,eACb,MACA,YACmD;AACnD,QAAM,QAAQ,IAAI,MAAM;AAAA,IACtB,SAAS;AAAA,IACT,QAAQ,IAAI,aAAa,IAAI;AAAA,EAC/B,CAAC;AACD,QAAM,QAAQ,MAAM,MAAM,qBAAqB;AAC/C,MAAI,CAAC,MAAO,OAAM,IAAI,MAAM,SAAS,UAAU,qBAAqB;AACpE,SAAO,EAAE,OAAO,MAAM;AACxB;AAwBA,eAAsB,iBACpB,SACuB;AACvB,QAAM,SAAS,IAAI,OAAO;AAAA,IACxB,QACE,QAAQ,WAAW,QAAQ,IAAI,gBAAgB,IAAI,IAAI,iBAAiB;AAAA,IAC1E,QAAQ,IAAI,aAAa;AAAA,EAC3B,CAAC;AAID,MAAI,aAIO;AACX,MAAI,QAAQ,OAAO;AACjB,UAAM,QAAQ,IAAI,MAAM;AAAA,MACtB,SAAS;AAAA,MACT,QAAQ,IAAI,aAAa,QAAQ,KAAK;AAAA,IACxC,CAAC;AACD,UAAM,QAAQ,MAAM,MAAM,qBAAqB;AAC/C,QAAI,CAAC,SAAS,CAAC,MAAM,OAAO;AAC1B,YAAM,IAAI,MAAM,wCAAwC;AAAA,IAC1D;AACA,UAAMA,iBAAgB,MAAM,MAAM,iBAAiB;AACnD,QAAI,CAACA,eAAe,OAAM,IAAI,MAAM,kCAAkC;AACtE,UAAM,SAAS,IAAI,yBAAyB,MAAM,KAAK;AACvD,WAAO,cAAc,MAAM;AAC3B,iBAAa,EAAE,QAAQ,MAAM,IAAI,kBAAkB,KAAK,GAAG,eAAAA,eAAc;AAAA,EAC3E;AAEA,MAAI,cAA+C;AACnD,MAAI,QAA2B;AAC/B,MAAI,gBAA2C;AAC/C,MAAI,UAAU;AACd,MAAI,SAAS;AACb,MAAI,cAAc;AAClB,MAAI,QAAQ;AAEZ,mBAAiB,SAAS,QAAQ,OAAO;AACvC,UAAM,IAAI;AACV,UAAM,EAAE,MAAM,IAAI,MAAM,eAAe,MAAM,MAAM,CAAC;AACpD,QAAI,MAAM,GAAG;AACX,cAAQ,MAAM;AACd,UAAI,CAAC,MAAO,OAAM,IAAI,MAAM,6CAA6C;AACzE,sBAAgB,MAAM,MAAM,iBAAiB;AAC7C,UAAI,CAAC,cAAe,OAAM,IAAI,MAAM,+BAA+B;AACnE,oBAAc,IAAI,yBAAyB,KAAK;AAChD,aAAO;AAAA,QACL;AAAA,QACA,QAAQ,YAAY,EAAE,WAAW,QAAQ,UAAU,IAAI;AAAA,MACzD;AACA,YAAM,OAAO,MAAM;AACnB,gBAAU;AAEV,UAAI,YAAY;AACd,YAAI,aAAa;AACjB,yBAAiB,UAAU,WAAW,KAAK,QAAQ,GAAG;AACpD,gBAAM,WAAW,OAAO;AAAA,YACtB;AAAA,YACA,aACI,EAAE,eAAe,WAAW,cAAc,IAC1C;AAAA,UACN;AACA,uBAAa;AAAA,QACf;AACA,mBAAW,OAAO,MAAM;AAAA,MAC1B;AAAA,IACF,WAAW,MAAM,UAAU,OAAO;AAChC,YAAM,IAAI;AAAA,QACR,SAAS,CAAC,UAAU,OAAO,MAAM,KAAK,CAAC,qBAAqB,OAAO,KAAK,CAAC;AAAA,MAC3E;AAAA,IACF;AAEA,UAAM,OAAO,IAAI,kBAAkB,KAAK;AACxC,QAAI,eAAe;AACnB,qBAAiB,UAAU,KAAK,QAAQ,GAAG;AACzC,UAAI,gBAAgB,OAAO,SAAS,OAAO;AACzC,cAAM,IAAI;AAAA,UACR,SAAS,CAAC;AAAA,QACZ;AAAA,MACF;AACA,YAAM,UAAyB,OAAO,MAAM;AAAA,QAC1C,WAAW,OAAO,YAAY;AAAA,MAChC,CAAC;AACD,YAAM,YAAa;AAAA,QACjB;AAAA,QACA,gBAAgB,MAAM,KAAK,gBACvB,EAAE,cAAc,IAChB;AAAA,MACN;AACA,qBAAe;AACf;AAAA,IACF;AAIA,cAAU,MAAM;AAAA,EAClB;AAEA,MAAI,CAAC,WAAW,CAAC,eAAe,CAAC,OAAO;AACtC,UAAM,IAAI,MAAM,kCAAkC;AAAA,EACpD;AAEA,QAAM,OAAO,SAAS;AACtB,QAAM,QAAQ,OAAO,OAAO;AAC5B,MAAI,CAAC,MAAO,OAAM,IAAI,MAAM,kCAAkC;AAC9D,SAAO,EAAE,OAAO,IAAI,WAAW,KAAK,GAAG,aAAa,MAAM;AAC5D;AASA,eAAsB,mBACpB,QACA,SACuB;AACvB,MAAI,OAAO,WAAW,EAAG,OAAM,IAAI,MAAM,+BAA+B;AACxE,SAAO,iBAAiB,EAAE,GAAG,SAAS,OAAO,OAAO,CAAC;AACvD;AAOA,eAAsB,kBAAkB,MAAmC;AACzE,QAAM,EAAE,MAAM,IAAI,MAAM,eAAe,MAAM,CAAC;AAC9C,QAAM,OAAO,IAAI,kBAAkB,KAAK;AACxC,MAAI,QAAQ;AACZ,mBAAiB,UAAU,KAAK,QAAQ,GAAG;AACzC,SAAK;AACL;AAAA,EACF;AACA,SAAO;AACT;;;AChMO,IAAM,qBACX;AA6BK,IAAM,kBAAkB;AAOxB,SAAS,gBACd,OACgC;AAChC,MAAI,SAAS,QAAQ,OAAO,UAAU,SAAU,QAAO;AACvD,QAAM,SAAS,CAAC,UACd,SAAS,OAAO,UAAU,WACrB,QACD;AACN,MAAI,MAAM,QAAQ,KAAK,GAAG;AACxB,eAAW,SAAS,OAAO;AACzB,UACE,SACA,OAAO,UAAU,YAChB,MAA2B,OAAO,iBACnC;AACA,eAAO,OAAQ,MAA6B,IAAI;AAAA,MAClD;AAAA,IACF;AACA,WAAO;AAAA,EACT;AACA,SAAO,OAAQ,MAAkC,eAAe,CAAC;AACnE;AAEO,SAAS,kBACd,UAAoC,CAAC,GAC7B;AACR,QAAM,eAAe,QAAQ,gBAAgB;AAC7C,QAAM,YAAY,QAAQ,OACtB,6BAA6B,KAAK,UAAU,QAAQ,IAAI,CAAC,MACzD;AACJ,SAAO;AAAA,EACP,SAAS;AAAA;AAAA;AAAA;AAAA,2BAIgB,KAAK,UAAU,YAAY,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA8JvD;AAOO,SAAS,aACd,MACA,OACA,MACS;AACT,QAAM,IACJ,QAAQ,QAAQ,OAAO,SAAS,WAC3B,OACD;AACN,QAAM,WACJ,CAAC,CAAC,MACD,OAAO,EAAE,WAAW,YAClB,CAAC,CAAC,EAAE,YAAY,OAAO,EAAE,aAAa;AAC3C,QAAM,aAAa,gBAAgB,KAAK,GAAG;AAC3C,QAAM,WACH,CAAC,CAAC,QAAQ,KAAK,OAAO,SAAS,KAC/B,MAAM,QAAQ,UAAU,KAAK,WAAW,SAAS;AACpD,SAAO,YAAY;AACrB;","names":["decoderConfig"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vosjs/render-core",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
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",
@@ -32,7 +32,7 @@
32
32
  "sideEffects": false,
33
33
  "dependencies": {
34
34
  "mediabunny": "^1.55.7",
35
- "@vosjs/studio-core": "^0.33.0"
35
+ "@vosjs/studio-core": "^0.33.1"
36
36
  },
37
37
  "devDependencies": {
38
38
  "tsup": "^8.5.0",