diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 54d55d6e1..83d3bab5c 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1834,6 +1834,10 @@ export function initSandboxRuntimeModular(): void { state.capturedTimeline, getSafeTimelineDurationSeconds(state.capturedTimeline, 0), volumeKeyframeCache, + { + allowLiveTimelineSeek: !(window as Window & { __HF_RENDER_CAPTURE_MODE?: boolean }) + .__HF_RENDER_CAPTURE_MODE, + }, ); }; diff --git a/packages/core/src/runtime/mediaVolumeEnvelope.test.ts b/packages/core/src/runtime/mediaVolumeEnvelope.test.ts index a7b0350c5..87ea462c3 100644 --- a/packages/core/src/runtime/mediaVolumeEnvelope.test.ts +++ b/packages/core/src/runtime/mediaVolumeEnvelope.test.ts @@ -3,6 +3,32 @@ import { describe, expect, it } from "vitest"; import { probeAndCacheElementVolume } from "./mediaVolumeEnvelope"; describe("probeAndCacheElementVolume", () => { + it("does not seek or cache when live timeline probing is disabled", () => { + const audio = document.createElement("audio"); + audio.dataset.volume = "1"; + document.body.append(audio); + + let seekCount = 0; + const timeline = { + totalTime(next?: number) { + if (next !== undefined) { + seekCount += 1; + audio.volume = next >= 1 ? 0 : 1; + } + return 0.75; + }, + }; + const cache = new WeakMap(); + + probeAndCacheElementVolume(audio, timeline, 1, cache, { + allowLiveTimelineSeek: false, + }); + + expect(seekCount).toBe(0); + expect(audio.volume).toBe(1); + expect(cache.has(audio)).toBe(false); + }); + it("restores the timeline playhead after sampling volume automation", () => { const audio = document.createElement("audio"); audio.dataset.volume = "1"; diff --git a/packages/core/src/runtime/mediaVolumeEnvelope.ts b/packages/core/src/runtime/mediaVolumeEnvelope.ts index 94856bb04..f32c12549 100644 --- a/packages/core/src/runtime/mediaVolumeEnvelope.ts +++ b/packages/core/src/runtime/mediaVolumeEnvelope.ts @@ -129,6 +129,18 @@ export function probeElementVolumeKeyframes( export type RuntimeTimelineRef = Partial>; +export interface VolumeProbeOptions { + /** + * Render/probe pages must not sample the live visual timeline during runtime + * initialization. The producer discovers audio automation in its own + * isolated pass and bakes it before frame capture, so seeking here is both + * redundant and capable of materializing future zero-duration GSAP state. + * + * Preview callers omit this option and retain live automation discovery. + */ + allowLiveTimelineSeek?: boolean; +} + /** * Probe a media element and, if volume automation is detected, store the * keyframes in `cache`. Safe to call with a null timeline — returns early. @@ -138,7 +150,9 @@ export function probeAndCacheElementVolume( timeline: RuntimeTimelineRef | null | undefined, compositionDuration: number, cache: WeakMap, + options: VolumeProbeOptions = {}, ): void { + if (options.allowLiveTimelineSeek === false) return; if (!timeline) return; if (!(mediaEl instanceof HTMLAudioElement) && !(mediaEl instanceof HTMLVideoElement)) return; if (compositionDuration <= 0) return; diff --git a/packages/producer/src/services/fileServer.test.ts b/packages/producer/src/services/fileServer.test.ts index 8d77b524c..dd5170875 100644 --- a/packages/producer/src/services/fileServer.test.ts +++ b/packages/producer/src/services/fileServer.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { closeFileServerSafely, createFileServer, + RENDER_CAPTURE_MODE_SHIM, HF_BRIDGE_SCRIPT, HF_EARLY_STUB, injectScriptsAtHeadStart, @@ -315,6 +316,33 @@ describe("parseRangeHeader", () => { }); describe("createFileServer", () => { + it("marks producer pages as render capture before the runtime loads", async () => { + const projectDir = mkdtempSync(join(tmpdir(), "hf-file-server-render-mode-")); + try { + writeFileSync( + join(projectDir, "index.html"), + "", + ); + const runtimeScript = "globalThis.__hfRuntimeLoaded = true;"; + const server = await createFileServer({ + projectDir, + preHeadScripts: [], + headScripts: [runtimeScript], + }); + try { + const response = await fetch(`${server.url}/index.html`); + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain(RENDER_CAPTURE_MODE_SHIM); + expect(html.indexOf(RENDER_CAPTURE_MODE_SHIM)).toBeLessThan(html.indexOf(runtimeScript)); + } finally { + server.close(); + } + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + it("serves ES modules with a JavaScript MIME type", async () => { const projectDir = mkdtempSync(join(tmpdir(), "hf-file-server-mjs-")); try { diff --git a/packages/producer/src/services/fileServer.ts b/packages/producer/src/services/fileServer.ts index 4760de574..007e59e44 100644 --- a/packages/producer/src/services/fileServer.ts +++ b/packages/producer/src/services/fileServer.ts @@ -677,6 +677,14 @@ export interface FileServerHandle { addPreHeadScript: (script: string) => void; } +/** + * Set before the Hyperframes runtime executes so render/probe pages can avoid + * preview-only initialization work that mutates the live visual timeline. + * Audio automation is discovered by the producer in an isolated pass and + * baked before frame capture. + */ +export const RENDER_CAPTURE_MODE_SHIM = "globalThis.__HF_RENDER_CAPTURE_MODE = true;"; + /** * Close a file server handle, swallowing and logging any error. * @@ -710,7 +718,11 @@ export function createFileServer(options: FileServerOptions): Promise, render mode in const headScripts = options.headScripts ?? [getVerifiedHyperframeRuntimeSource()]; const bodyScripts = options.bodyScripts ?? [buildRenderModeScript(options.fps), HF_BRIDGE_SCRIPT]; diff --git a/packages/producer/tests/distributed/static-volume-future-set/meta.json b/packages/producer/tests/distributed/static-volume-future-set/meta.json new file mode 100644 index 000000000..aa32bc112 --- /dev/null +++ b/packages/producer/tests/distributed/static-volume-future-set/meta.json @@ -0,0 +1,13 @@ +{ + "name": "Distributed: static audio must not materialize future nested GSAP state", + "description": "Regression for #2809. A static-volume audio element must not trigger live-timeline envelope probing in a non-zero-start chunk and prematurely apply a nested composition's future terminal visibility set.", + "tags": ["distributed", "audio", "sub-composition", "regression"], + "minPsnr": 40, + "maxFrameFailures": 0, + "minAudioCorrelation": 0, + "maxAudioLagWindows": 120, + "renderConfig": { + "fps": 24, + "chunkSize": 120 + } +} diff --git a/packages/producer/tests/distributed/static-volume-future-set/output/compiled.html b/packages/producer/tests/distributed/static-volume-future-set/output/compiled.html new file mode 100644 index 000000000..c09aef5fd --- /dev/null +++ b/packages/producer/tests/distributed/static-volume-future-set/output/compiled.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44f07badd4fc8b99f7430ea23d7009263787656ac725d0a70a9c68f5c15c9ab1 +size 87166 diff --git a/packages/producer/tests/distributed/static-volume-future-set/output/output.mp4 b/packages/producer/tests/distributed/static-volume-future-set/output/output.mp4 new file mode 100644 index 000000000..3c889bf30 --- /dev/null +++ b/packages/producer/tests/distributed/static-volume-future-set/output/output.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:29abd7d2f00d654654348b10cdef4c9c6f91e52451c1a8f007605bef7ab12017 +size 61986 diff --git a/packages/producer/tests/distributed/static-volume-future-set/src/assets/silence.wav b/packages/producer/tests/distributed/static-volume-future-set/src/assets/silence.wav new file mode 100644 index 000000000..bac65c6ba Binary files /dev/null and b/packages/producer/tests/distributed/static-volume-future-set/src/assets/silence.wav differ diff --git a/packages/producer/tests/distributed/static-volume-future-set/src/compositions/child.html b/packages/producer/tests/distributed/static-volume-future-set/src/compositions/child.html new file mode 100644 index 000000000..f55a431ba --- /dev/null +++ b/packages/producer/tests/distributed/static-volume-future-set/src/compositions/child.html @@ -0,0 +1,56 @@ + diff --git a/packages/producer/tests/distributed/static-volume-future-set/src/index.html b/packages/producer/tests/distributed/static-volume-future-set/src/index.html new file mode 100644 index 000000000..ed07f0f8c --- /dev/null +++ b/packages/producer/tests/distributed/static-volume-future-set/src/index.html @@ -0,0 +1,59 @@ + + + + + Static volume with future nested set + + + + +
+
+ +
+ + +