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 @@
+
+
+
+ MUST BE VISIBLE
+ static audio cannot seek visual state
+
+
+
+
+
+
+
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 @@
+
+
+