diff --git a/packages/engine/src/services/audioMixer.ts b/packages/engine/src/services/audioMixer.ts
index 1730f45d8..05bf083cb 100644
--- a/packages/engine/src/services/audioMixer.ts
+++ b/packages/engine/src/services/audioMixer.ts
@@ -27,7 +27,7 @@ function formatFilterNumber(value: number): string {
}
function escapeExpressionCommas(expression: string): string {
- return expression.replace(/,/g, "\\,");
+ return expression.replace(/\\/g, "\\\\").replace(/,/g, "\\,");
}
function buildVolumeExpression(track: AudioTrack): string {
diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts
index 04d363465..c83d55048 100644
--- a/packages/producer/src/services/htmlCompiler.test.ts
+++ b/packages/producer/src/services/htmlCompiler.test.ts
@@ -8,6 +8,7 @@ import {
compileForRender,
detectRenderModeHints,
detectShaderTransitionUsage,
+ discoverAudioVolumeAutomationFromTimeline,
inlineExternalScripts,
recompileWithResolutions,
} from "./htmlCompiler.js";
@@ -795,3 +796,72 @@ describe("text-rendering rule injection", () => {
expect(compiled.html.replace(/\s+/g, "")).toContain("text-rendering:geometricPrecision");
});
});
+
+describe("discoverAudioVolumeAutomationFromTimeline", () => {
+ it("samples video-derived audio volume without firing GSAP callbacks", async () => {
+ class TestAudioElement {}
+ class TestVideoElement {
+ id = "bg-video";
+ dataset = { start: "0", duration: "1", volume: "0" };
+ volume = 0;
+ }
+
+ const video = new TestVideoElement();
+ const seekCalls: { time: number; suppressEvents: boolean | undefined }[] = [];
+ const previousWindow = globalThis.window;
+ const previousDocument = globalThis.document;
+ const previousAudioElement = globalThis.HTMLAudioElement;
+ const previousVideoElement = globalThis.HTMLVideoElement;
+
+ globalThis.window = {
+ __timelines: {
+ root: {
+ totalTime: (time: number, suppressEvents?: boolean) => {
+ seekCalls.push({ time, suppressEvents });
+ video.volume = Math.min(1, Math.max(0, time));
+ },
+ },
+ },
+ } as any;
+ globalThis.document = {
+ querySelector: (selector: string) =>
+ selector === "[data-composition-id]"
+ ? { getAttribute: (name: string) => (name === "data-composition-id" ? "root" : null) }
+ : null,
+ getElementById: (id: string) => (id === "bg-video" ? video : null),
+ } as any;
+ globalThis.HTMLAudioElement = TestAudioElement as any;
+ globalThis.HTMLVideoElement = TestVideoElement as any;
+
+ try {
+ const page = {
+ evaluate: async (fn: (arg: unknown) => unknown, arg: unknown) => fn(arg),
+ } as any;
+
+ const result = await discoverAudioVolumeAutomationFromTimeline(
+ page,
+ ["bg-video-audio"],
+ 1,
+ 2,
+ );
+
+ expect(result).toEqual([
+ {
+ id: "bg-video-audio",
+ keyframes: [
+ { time: 0, volume: 0 },
+ { time: 0.5, volume: 0.5 },
+ { time: 1, volume: 1 },
+ ],
+ },
+ ]);
+ expect(seekCalls.length).toBeGreaterThan(0);
+ expect(seekCalls.every((call) => call.suppressEvents === true)).toBe(true);
+ } finally {
+ globalThis.window = previousWindow;
+ globalThis.document = previousDocument;
+ globalThis.HTMLAudioElement = previousAudioElement;
+ globalThis.HTMLVideoElement = previousVideoElement;
+ }
+ });
+});
diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts
index cbf203dbc..dfd77acba 100644
--- a/packages/producer/src/services/htmlCompiler.ts
+++ b/packages/producer/src/services/htmlCompiler.ts
@@ -1155,9 +1155,9 @@ export async function discoverAudioVolumeAutomationFromTimeline(
const seekTl = (t: number) => {
if (typeof tl.totalTime === "function") {
- tl.totalTime(t, false);
+ tl.totalTime(t, true);
} else if (typeof tl.seek === "function") {
- tl.seek(t, false);
+ tl.seek(t, true);
}
};
diff --git a/packages/producer/src/services/render/stages/probeStage.test.ts b/packages/producer/src/services/render/stages/probeStage.test.ts
new file mode 100644
index 000000000..e26416b25
--- /dev/null
+++ b/packages/producer/src/services/render/stages/probeStage.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "bun:test";
+import { hasScriptedAudioVolumeAutomation } from "./probeStage.js";
+
+describe("hasScriptedAudioVolumeAutomation", () => {
+ it("ignores non-script volume text", () => {
+ expect(
+ hasScriptedAudioVolumeAutomation(
+ ``,
+ 1,
+ ),
+ ).toBe(false);
+ });
+
+ it("detects direct media volume writes", () => {
+ expect(hasScriptedAudioVolumeAutomation(``, 1)).toBe(true);
+ });
+
+ it("detects GSAP volume tweens", () => {
+ expect(
+ hasScriptedAudioVolumeAutomation(``, 1),
+ ).toBe(true);
+ });
+
+ it("requires audio metadata", () => {
+ expect(
+ hasScriptedAudioVolumeAutomation(``, 0),
+ ).toBe(false);
+ });
+});
diff --git a/packages/producer/src/services/render/stages/probeStage.ts b/packages/producer/src/services/render/stages/probeStage.ts
index 00349b686..c67333fdf 100644
--- a/packages/producer/src/services/render/stages/probeStage.ts
+++ b/packages/producer/src/services/render/stages/probeStage.ts
@@ -88,6 +88,22 @@ export interface ProbeStageResult {
browserProbeMs: number;
}
+export function hasScriptedAudioVolumeAutomation(html: string, audioCount: number): boolean {
+ if (audioCount <= 0) return false;
+
+ const scriptBodies = [...html.matchAll(/