From 47872e64f272425b205d4b66bc1141cfbde1a24d Mon Sep 17 00:00:00 2001 From: Val Date: Wed, 2 Sep 2026 14:53:26 -0700 Subject: [PATCH 1/5] fix(producer): window auto-start video to GSAP opacity, not .scene Keep-local: visibility discovery sampled .scene (usually 1), so probe injected the file for the whole host window. Seek via __hf.seek so nested GSAP updates, and bind mediaStart to the first visible frame. Never pull start earlier than host-map. Do not upstream (wizard-emit untimed video). --- .../src/services/htmlCompiler.test.ts | 100 +++++++++++++----- .../producer/src/services/htmlCompiler.ts | 62 +++++++---- .../services/render/stages/probeStage.test.ts | 32 +++++- 3 files changed, 145 insertions(+), 49 deletions(-) diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts index 29d2334bdc0..9c63ca24408 100644 --- a/packages/producer/src/services/htmlCompiler.test.ts +++ b/packages/producer/src/services/htmlCompiler.test.ts @@ -2686,25 +2686,29 @@ describe("resolveCompositionDurations strict literal timing", () => { }); describe("discoverVideoVisibilityFromTimeline", () => { - it("returns scene visibility windows for auto-start videos", async () => { - const duration = 2; - const sampleStep = 0.1; - const windows = [ - { id: "v0", start: 0.2, end: 0.7 }, - { id: "v1", start: 0.5, end: 1.2 }, - { id: "v2", start: 1.0, end: 1.8 }, - { id: "v3", start: 0.0, end: 0.4 }, - ]; - - type SceneEl = { opacityAt: (t: number) => number }; + type OpacityEl = { + id?: string; + opacityAt: (t: number) => number; + parentElement: OpacityEl | null; + }; + + async function runVisibilityProbe( + windows: { id: string; start: number; end: number }[], + duration: number, + sceneOpacity = 1, + hfSeek?: (t: number) => void, + ) { const videos = windows.map((win) => { - const sceneEl: SceneEl = { - opacityAt: (t) => (t >= win.start && t <= win.end ? 1 : 0), + const sceneEl: OpacityEl = { + opacityAt: () => sceneOpacity, + parentElement: null, }; - return { + const videoEl: OpacityEl = { id: win.id, - closest: (selector: string) => (selector === ".scene" ? sceneEl : null), + opacityAt: (t) => (t >= win.start && t <= win.end ? 1 : 0), + parentElement: sceneEl, }; + return videoEl; }); let currentTime = 0; @@ -2712,6 +2716,16 @@ describe("discoverVideoVisibilityFromTimeline", () => { const previousDocument = globalThis.document; globalThis.window = { + ...(hfSeek + ? { + __hf: { + seek: (time: number) => { + currentTime = time; + hfSeek(time); + }, + }, + } + : {}), __timelines: { root: { totalTime: (time: number) => { @@ -2719,7 +2733,7 @@ describe("discoverVideoVisibilityFromTimeline", () => { }, }, }, - getComputedStyle: (el: SceneEl) => ({ + getComputedStyle: (el: OpacityEl) => ({ opacity: String(el.opacityAt(currentTime)), }), } as typeof globalThis.window; @@ -2736,22 +2750,52 @@ describe("discoverVideoVisibilityFromTimeline", () => { const page = { evaluate: async (fn: (arg: number) => unknown, arg: number) => fn(arg), }; - - const result = await discoverVideoVisibilityFromTimeline(page as never, duration); - - expect(result).toHaveLength(windows.length); - for (const win of windows) { - const found = result.find((entry) => entry.videoId === win.id); - expect(found).toBeDefined(); - expect(found!.visibleStart).toBeGreaterThanOrEqual(win.start - sampleStep); - expect(found!.visibleStart).toBeLessThanOrEqual(win.start + sampleStep); - expect(found!.visibleEnd).toBeGreaterThanOrEqual(win.end - sampleStep); - expect(found!.visibleEnd).toBeLessThanOrEqual(win.end + sampleStep); - } + return await discoverVideoVisibilityFromTimeline(page as never, duration); } finally { globalThis.window = previousWindow; globalThis.document = previousDocument; } + } + + it("returns video effective-opacity windows for auto-start videos", async () => { + const duration = 2; + const sampleStep = 0.1; + const windows = [ + { id: "v0", start: 0.2, end: 0.7 }, + { id: "v1", start: 0.5, end: 1.2 }, + { id: "v2", start: 1.0, end: 1.8 }, + { id: "v3", start: 0.0, end: 0.4 }, + ]; + + const result = await runVisibilityProbe(windows, duration); + + expect(result).toHaveLength(windows.length); + for (const win of windows) { + const found = result.find((entry) => entry.videoId === win.id); + expect(found).toBeDefined(); + expect(found!.visibleStart).toBeGreaterThanOrEqual(win.start - sampleStep); + expect(found!.visibleStart).toBeLessThanOrEqual(win.start + sampleStep); + expect(found!.visibleEnd).toBeGreaterThanOrEqual(win.end - sampleStep); + expect(found!.visibleEnd).toBeLessThanOrEqual(win.end + sampleStep); + } + }); + + it("does not treat a fully-opaque .scene as the video window", async () => { + const result = await runVisibilityProbe([{ id: "clip", start: 0.8, end: 1.4 }], 2, 1); + expect(result).toHaveLength(1); + expect(result[0]!.videoId).toBe("clip"); + expect(result[0]!.visibleStart).toBeGreaterThan(0.6); + expect(result[0]!.visibleStart).toBeLessThanOrEqual(0.9); + expect(result[0]!.visibleEnd).toBeGreaterThanOrEqual(1.3); + expect(result[0]!.visibleEnd).toBeLessThanOrEqual(1.5); + }); + + it("prefers window.__hf.seek so nested GSAP updates", async () => { + const sought: number[] = []; + await runVisibilityProbe([{ id: "clip", start: 0.5, end: 1.2 }], 2, 1, (t) => { + sought.push(t); + }); + expect(sought.length).toBeGreaterThan(0); }); }); describe("sub-composition variable injection (render path, #2064)", () => { diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 1dbc2d211fa..10f5bf76bff 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -2606,8 +2606,10 @@ export interface VideoVisibilityWindow { } /** - * Seek the GSAP timeline to discover when each video's parent scene is visible. - * Only processes videos with the data-hf-auto-start sentinel (auto-injected timing). + * Seek the composition and discover when each auto-start video is visible. + * Visibility is the video's effective opacity (element × ancestors), not `.scene`. + * Prefers `window.__hf.seek` so nested scene GSAP (paused sibling timelines) + * actually updates — root `totalTime` does not propagate to them. */ export async function discoverVideoVisibilityFromTimeline( page: Page, @@ -2620,6 +2622,11 @@ export async function discoverVideoVisibilityFromTimeline( const videos = document.querySelectorAll("video[data-hf-auto-start]"); if (videos.length === 0) return results; + const hfSeek = ( + window as unknown as { + __hf?: { seek?: (t: number, o?: { suppressEvents?: boolean }) => void }; + } + ).__hf?.seek; const timelines = (window as unknown as { __timelines?: Record }).__timelines; if (!timelines) return results; @@ -2643,14 +2650,35 @@ export async function discoverVideoVisibilityFromTimeline( } }; + const seekComposition = (t: number) => { + if (typeof hfSeek === "function") { + try { + hfSeek(t, { suppressEvents: true }); + } catch {} + return; + } + seekTl(t); + }; + + // Same walk as videoFrameInjector: GSAP often animates a wrapper, not the + //