diff --git a/packages/core/src/runtime/adapters/gsap.steps.test.ts b/packages/core/src/runtime/adapters/gsap.steps.test.ts index 68a2920659..a04a1e5d9c 100644 --- a/packages/core/src/runtime/adapters/gsap.steps.test.ts +++ b/packages/core/src/runtime/adapters/gsap.steps.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import gsap from "gsap"; import { describe, expect, it } from "vitest"; -import { createGsapAdapter, rerenderGsapTimelineAt } from "./gsap"; +import { createGsapAdapter, rerenderGsapTimelineAt, seekGsapTimeline } from "./gsap"; import type { RuntimeTimelineLike } from "../types"; // Every 0.1 s: hide all three frames, then show one, as frame-by-frame films do. @@ -531,3 +531,51 @@ describe("gsap adapter just after a tween's start", () => { } }); }); + +describe("seekGsapTimeline", () => { + function hiddenAtZero() { + const word = document.body.appendChild(document.createElement("span")); + let calls = 0; + const timeline = gsap.timeline({ paused: true }); + timeline.set(word, { visibility: "hidden" }, 0); + timeline.call(() => void calls++, [], 0); + timeline.set(word, { visibility: "visible" }, 0.5); + return { word, timeline: timeline as unknown as RuntimeTimelineLike, calls: () => calls }; + } + + it("renders a never-rendered timeline seeked to the time it sits at", () => { + const film = hiddenAtZero(); + seekGsapTimeline(film.timeline, 0, false); + expect(film.word.style.visibility).toBe("hidden"); + }); + + it("redraws a timeline parked at one time once, not on every seek", () => { + const film = hiddenAtZero(); + const real = film.timeline; + let moves = 0; + const counted = { + ...real, + time: () => real.time(), + totalTime: (time?: number, suppressEvents?: boolean) => { + if (time === undefined) return real.totalTime!(); + moves++; + return real.totalTime!(time, suppressEvents); + }, + getChildren: real.getChildren!.bind(real), + } as RuntimeTimelineLike; + seekGsapTimeline(counted, 0, false); + const firstSeekMoves = moves; + seekGsapTimeline(counted, 0, false); + seekGsapTimeline(counted, 0, false); + expect(firstSeekMoves).toBeGreaterThan(1); + expect(moves - firstSeekMoves).toBe(2); + }); + + it("fires a call at that time once across the next seeks", () => { + const film = hiddenAtZero(); + seekGsapTimeline(film.timeline, 0, false); + seekGsapTimeline(film.timeline, 0, false); + seekGsapTimeline(film.timeline, 0.1, false); + expect(film.calls()).toBe(1); + }); +}); diff --git a/packages/core/src/runtime/adapters/gsap.ts b/packages/core/src/runtime/adapters/gsap.ts index e152b986d8..b2c7b5cd45 100644 --- a/packages/core/src/runtime/adapters/gsap.ts +++ b/packages/core/src/runtime/adapters/gsap.ts @@ -49,6 +49,37 @@ export function rerenderGsapTimelineAt( } } +type GsapRenderState = { _initted?: number }; + +function gsapNeverRendered(timeline: RuntimeTimelineLike): boolean { + const [child] = (timeline.getChildren?.(false, true, true) ?? []) as { parent?: unknown }[]; + const rendered = child?.parent as GsapRenderState | undefined; + return rendered !== undefined && !rendered._initted; +} + +export function seekGsapTimeline( + timeline: RuntimeTimelineLike, + t: number, + suppressEvents: boolean, +): boolean { + if (typeof timeline.totalTime !== "function") { + timeline.seek(t, suppressEvents); + return false; + } + const gsapSkipsRenderInPlace = Number(timeline.totalTime()) === t; + timeline.totalTime(t, suppressEvents); + if (!gsapSkipsRenderInPlace || !gsapNeverRendered(timeline)) return false; + rerenderGsapTimelineAt( + { + totalTime: timeline.totalTime.bind(timeline), + totalDuration: timeline.totalDuration?.bind(timeline), + getChildren: timeline.getChildren?.bind(timeline), + }, + t, + ); + return true; +} + type GsapParent = { _dur: number; _tDur: number; diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index abac71baef..40a6b06a48 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -3119,7 +3119,7 @@ describe("initSandboxRuntimeModular", () => { const rootTimeline = createMockTimeline(10); const originalTotalTime = rootTimeline.totalTime; rootTimeline.totalTime = (time: number, suppressEvents?: boolean) => { - seekCalls.push({ time, suppressEvents }); + if (time !== undefined) seekCalls.push({ time, suppressEvents }); return originalTotalTime?.(time, suppressEvents); }; @@ -3155,7 +3155,7 @@ describe("initSandboxRuntimeModular", () => { const rootTimeline = createMockTimeline(10); const originalTotalTime = rootTimeline.totalTime; rootTimeline.totalTime = (time: number, suppressEvents?: boolean) => { - seekCalls.push({ time, suppressEvents }); + if (time !== undefined) seekCalls.push({ time, suppressEvents }); return originalTotalTime?.(time, suppressEvents); }; Object.assign(rootTimeline, { @@ -3177,6 +3177,26 @@ describe("initSandboxRuntimeModular", () => { expect(seekCalls).toEqual([{ time: 2, suppressEvents: false }]); }); + it("keeps a later root tween's value over a nested scene the root already drew", () => { + document.body.innerHTML = `
`; + const box = document.getElementById("box")!; + const options = { duration: 2, ease: "none", immediateRender: false, lazy: false }; + const child = gsap.timeline({ smoothChildTiming: false }); + child.fromTo(box, { opacity: 0 }, { ...options, opacity: 1, overwrite: false }, 0); + const main = gsap.timeline({ paused: true, smoothChildTiming: false }); + main.add(child, 0); + main.fromTo(box, { opacity: 0.8 }, { ...options, opacity: 1, overwrite: false }, 0); + window.__timelines = { main, child }; + initSandboxRuntimeModular(); + + window.__player?.renderSeek(1); + expect(Number(box.style.opacity)).toBeCloseTo(0.9, 5); + + child.set({}, {}, 1.5); + window.__player?.renderSeek(1); + expect(Number(box.style.opacity)).toBeCloseTo(0.9, 5); + }); + it("fires a call added after the first seek exactly once", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index d85e18a6a5..0dc4ad4ba4 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -16,7 +16,7 @@ import { import { initRuntimeAnalytics, emitAnalyticsEvent } from "./analytics"; import { injectCompositionCssVariables } from "./getVariables"; import { createCssAdapter } from "./adapters/css"; -import { createGsapAdapter, rerenderGsapTimelineAt } from "./adapters/gsap"; +import { createGsapAdapter, rerenderGsapTimelineAt, seekGsapTimeline } from "./adapters/gsap"; import { createAnimeJsAdapter } from "./adapters/animejs"; import { createLottieAdapter } from "./adapters/lottie"; import { createThreeAdapter } from "./adapters/three"; @@ -1663,8 +1663,7 @@ export function initSandboxRuntimeModular(): void { // Without this, children added after the root was created may still show initial styles. if (autoNestedChildren.length > 0) { try { - const currentTime = rootTimeline.time(); - rootTimeline.seek(currentTime, false); // false = don't suppress events + seekGsapTimeline(rootTimeline, rootTimeline.time(), false); } catch { /* ignore */ } @@ -2323,7 +2322,7 @@ export function initSandboxRuntimeModular(): void { // abort restoring the transport time below (the catch would swallow it too). pauseTimelineIfPossible(state.capturedTimeline); if (typeof state.capturedTimeline.seek === "function") { - state.capturedTimeline.seek(previousTime, false); + seekGsapTimeline(state.capturedTimeline, previousTime, false); } } catch (err) { // keep runtime resilient even if a timeline implementation throws @@ -4240,11 +4239,7 @@ export function initSandboxRuntimeModular(): void { const suppressEvents = options?.suppressEvents === true; // Guarded: a partial timeline without pause() must still get its seek. pauseTimelineIfPossible(timeline); - if (typeof timeline.totalTime === "function") { - timeline.totalTime(timeSeconds, suppressEvents); - } else { - timeline.seek(timeSeconds, suppressEvents); - } + seekGsapTimeline(timeline, timeSeconds, suppressEvents); } catch (err) { swallow(swallowLabel, err); } @@ -4399,8 +4394,8 @@ export function initSandboxRuntimeModular(): void { } try { if (typeof tl.totalTime === "function") { - tl.totalTime(tlSeekTime, suppressEvents); - if (!suppressEvents && !hasZeroDurationCallbackTween(tl)) { + const redrawn = seekGsapTimeline(tl, tlSeekTime, suppressEvents); + if (!redrawn && !suppressEvents && !hasZeroDurationCallbackTween(tl)) { // The first seek is the only eventful one; the re-render only refreshes styles. rerenderGsapTimelineAt( { diff --git a/packages/producer/scripts/test-classification.mjs b/packages/producer/scripts/test-classification.mjs index 0bf0e4f5d2..3bf6f6b093 100644 --- a/packages/producer/scripts/test-classification.mjs +++ b/packages/producer/scripts/test-classification.mjs @@ -30,6 +30,7 @@ const INTEGRATION_TEST_FILES = new Set([ "src/services/assetMediaType.test.ts", "src/services/htmlCompiler.mediaType.test.ts", "src/services/htmlCompiler.naturalDuration.test.ts", + "src/services/render/firstFrameStartingState.integration.test.ts", "src/services/render/hlsRender.integration.test.ts", "src/services/render/motionBlurVideo.integration.test.ts", "src/services/render/renderCancel.integration.test.ts", diff --git a/packages/producer/src/services/render/firstFrameStartingState.integration.test.ts b/packages/producer/src/services/render/firstFrameStartingState.integration.test.ts new file mode 100644 index 0000000000..0e2c35788c --- /dev/null +++ b/packages/producer/src/services/render/firstFrameStartingState.integration.test.ts @@ -0,0 +1,114 @@ +// Frame 0 of a real render shows the starting state that a sub-composition's timeline sets at +// t=0, not the page's pre-timeline styles. Needs Chrome + ffmpeg: integration lane. + +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { resolveConfig } from "@hyperframes/engine"; +import { createRenderJob, executeRenderJob } from "../renderOrchestrator.js"; +import { HAS_FFMPEG, frameLevels } from "./frameLevelsTestFixtures.js"; + +const WIDTH = 160; +const HEIGHT = 90; +const FPS = 30; +const REVEAL_FRAME = 15; + +const INDEX = ` + + + + + + + +
+
+
+ + + +`; + +// The words have no hidden CSS state: only the timeline's t=0 steps hide them. +const WORDS = ` + + + + + +`; + +let root: string; + +describe.skipIf(!HAS_FFMPEG)( + "first frame of a real render — sub-composition starting state", + () => { + let levels: number[]; + + beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), "hf-first-frame-")); + mkdirSync(join(root, "assets"), { recursive: true }); + mkdirSync(join(root, "compositions"), { recursive: true }); + copyFileSync( + resolve(import.meta.dirname, "../../../../core/node_modules/gsap/dist/gsap.min.js"), + join(root, "assets/gsap.min.js"), + ); + copyFileSync( + resolve( + import.meta.dirname, + "../../../tests/heygen-promo-preview-assets/src/TT_Norms_Pro_Medium.woff2", + ), + join(root, "assets/fixture.woff2"), + ); + writeFileSync(join(root, "index.html"), INDEX); + writeFileSync(join(root, "compositions/words.html"), WORDS); + const job = createRenderJob({ + fps: FPS, + quality: "draft", + format: "mp4", + hdrMode: "force-sdr", + workers: 1, + producerConfig: resolveConfig({ browserGpuMode: "software" }), + }); + const output = join(root, "out.mp4"); + await executeRenderJob(job, root, output); + levels = frameLevels(output, WIDTH, HEIGHT); + }, 120_000); + + afterAll(() => { + if (root) rmSync(root, { recursive: true, force: true }); + }); + + it("frame 0 matches frame 1: the words are already hidden", () => { + expect(levels[0]).toBeCloseTo(levels[1]!, 0); + expect(levels[0]).toBeLessThan(1); + }); + + it("the words appear when the timeline reveals them", () => { + expect(levels[REVEAL_FRAME]! - levels[0]!).toBeGreaterThan(2); + }); + }, +); diff --git a/packages/producer/src/services/render/frameLevelsTestFixtures.ts b/packages/producer/src/services/render/frameLevelsTestFixtures.ts new file mode 100644 index 0000000000..7f89021e4c --- /dev/null +++ b/packages/producer/src/services/render/frameLevelsTestFixtures.ts @@ -0,0 +1,23 @@ +import { spawnSync } from "node:child_process"; +import { getFfmpegBinary } from "@hyperframes/engine"; + +export const FFMPEG = getFfmpegBinary(); +export const HAS_FFMPEG = spawnSync(FFMPEG, ["-version"], { encoding: "utf-8" }).status === 0; + +/** Mean grey level of every frame of an encoded video, 0-255. */ +export function frameLevels(videoPath: string, width: number, height: number): number[] { + const raw = spawnSync( + FFMPEG, + ["-v", "error", "-i", videoPath, "-f", "rawvideo", "-pix_fmt", "gray", "-"], + { maxBuffer: 64 * 1024 * 1024 }, + ); + if (raw.status !== 0) throw new Error(`decode failed: ${raw.stderr}`); + const frameBytes = width * height; + const levels: number[] = []; + for (let offset = 0; offset + frameBytes <= raw.stdout.length; offset += frameBytes) { + let sum = 0; + for (let i = offset; i < offset + frameBytes; i++) sum += raw.stdout[i]!; + levels.push(sum / frameBytes); + } + return levels; +} diff --git a/packages/producer/src/services/render/segmentedColorGrading.integration.test.ts b/packages/producer/src/services/render/segmentedColorGrading.integration.test.ts index 57356f9698..33d3012daf 100644 --- a/packages/producer/src/services/render/segmentedColorGrading.integration.test.ts +++ b/packages/producer/src/services/render/segmentedColorGrading.integration.test.ts @@ -7,11 +7,9 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { getFfmpegBinary, resolveConfig } from "@hyperframes/engine"; +import { resolveConfig } from "@hyperframes/engine"; import { createRenderJob, executeRenderJob } from "../renderOrchestrator.js"; - -const FFMPEG = getFfmpegBinary(); -const HAS_FFMPEG = spawnSync(FFMPEG, ["-version"], { encoding: "utf-8" }).status === 0; +import { FFMPEG, HAS_FFMPEG, frameLevels } from "./frameLevelsTestFixtures.js"; const WIDTH = 160; const HEIGHT = 90; @@ -55,26 +53,6 @@ const COMPOSITION = ` let root: string; -/** Mean RGB level of every frame of an encoded video, 0-255. */ -function frameLevels(videoPath: string): number[] { - const raw = spawnSync( - FFMPEG, - ["-v", "error", "-i", videoPath, "-f", "rawvideo", "-pix_fmt", "gray", "-"], - { - maxBuffer: 64 * 1024 * 1024, - }, - ); - if (raw.status !== 0) throw new Error(`decode failed: ${raw.stderr}`); - const frameBytes = WIDTH * HEIGHT; - const levels: number[] = []; - for (let offset = 0; offset + frameBytes <= raw.stdout.length; offset += frameBytes) { - let sum = 0; - for (let i = offset; i < offset + frameBytes; i++) sum += raw.stdout[i]!; - levels.push(sum / frameBytes); - } - return levels; -} - interface Render { levels: number[]; phases: string[]; @@ -112,7 +90,7 @@ async function render(name: string, segmented: boolean, workers: number): Promis try { const output = join(root, `${name}.mp4`); await executeRenderJob(job, root, output); - return { levels: frameLevels(output), phases, workerCount }; + return { levels: frameLevels(output, WIDTH, HEIGHT), phases, workerCount }; } finally { if (saved.capture === undefined) delete process.env.HF_SEGMENTED_CAPTURE; else process.env.HF_SEGMENTED_CAPTURE = saved.capture; diff --git a/scripts/test-reachability.json b/scripts/test-reachability.json index bbf5feddd9..621c2140fb 100644 --- a/scripts/test-reachability.json +++ b/scripts/test-reachability.json @@ -61,7 +61,7 @@ ], "sources": { "packages/producer/scripts/run-test-lane.mjs": "638885e14480d03290803389325a655f3749442167d784e07bb3af3404598af7", - "packages/producer/scripts/test-classification.mjs": "11102d43fdae5ab611c2f840e3d7fb4f367683fbddf9dfc770e0a623deb6aef2", + "packages/producer/scripts/test-classification.mjs": "18ec188edb061f2a729d261a64fb22682064e4f438413fdeb4d502e709cc8684", ".github/workflows/ci.yml#job#producer-source-tests": "45da72aa6cf08cdfd5bcf5311e89f12af4842f9eebc6735df19da8c02f4d4864", "packages/producer/package.json#script#test:unit": "ae6beda8319d166774be958ea5593cd27dbf7f6c9bce1d787a3fe28dfc3477b1", "packages/producer/package.json#script#test:integration": "008589f37d026877595a13501cbb66abd5cbe50e21fdcac6904f45466c3f475d",