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(/]*>([\s\S]*?)<\/script>/gi)] + .map((match) => match[1] ?? "") + .join("\n"); + if (!scriptBodies) return false; + + return ( + /\.\s*volume\s*=/i.test(scriptBodies) || + /\b(?:gsap|tl|timeline|tween)\s*\.\s*(?:to|fromTo|set)\s*\([\s\S]{0,2000}\bvolume\s*:/i.test( + scriptBodies, + ) + ); +} + export async function runProbeStage(input: ProbeStageInput): Promise { const { projectDir, @@ -109,10 +125,10 @@ export async function runProbeStage(input: ProbeStageInput): Promise 0 && - / 0 ||