fix(core): show a scene's starting state on a render's first frame (#5614)

* fix(core): show a scene's starting state on a render's first frame

A seek to the time a GSAP timeline already sits at renders nothing, so a
sub-composition timeline at 0 never applied its t=0 steps before frame 0
was captured. Every runtime seek now goes through seekGsapTimeline, which
redraws the timeline silently when the seek did not move it.

* test(producer): share the frame-level reader across render tests

* fix(core): name the in-place render skip instead of commenting it

* perf(core): redraw a parked scene timeline once instead of every frame

* fix(core): redraw only a scene timeline GSAP has never drawn
This commit is contained in:
Miguel Ángel
2026-10-14 19:50:07 +00:00
committed by GitHub
parent c0636d017f
commit 8c00ebad80
9 changed files with 250 additions and 40 deletions
@@ -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);
});
});
@@ -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;
+22 -2
View File
@@ -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 = `<div data-composition-id="main" data-root="true" data-start="0" data-duration="2" data-width="1920" data-height="1080"><div data-composition-id="child" data-start="0" data-duration="2"><div id="box"></div></div></div>`;
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");
+6 -11
View File
@@ -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(
{
@@ -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",
@@ -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 = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<script src="assets/gsap.min.js"></script>
<style>* { margin: 0; padding: 0; } body { overflow: hidden; background: #000; }</style>
</head>
<body>
<div id="root" data-composition-id="main" data-start="0" data-duration="1"
data-width="${WIDTH}" data-height="${HEIGHT}" data-fps="${FPS}">
<div id="s-words" data-composition-id="words" data-composition-src="compositions/words.html"
data-start="0" data-duration="1" data-track-index="0"
data-width="${WIDTH}" data-height="${HEIGHT}"></div>
</div>
<script>window.__timelines["main"] = gsap.timeline({ paused: true });</script>
</body>
</html>
`;
// The words have no hidden CSS state: only the timeline's t=0 steps hide them.
const WORDS = `<!DOCTYPE html>
<html>
<body>
<template id="words-template">
<style>
@font-face { font-family: "Fixture"; src: url("assets/fixture.woff2") format("woff2"); font-weight: 500; }
#words { position: absolute; inset: 0; background: #000; }
#words .w { position: absolute; top: 20px; font: 500 40px "Fixture", serif; color: #fff; }
</style>
<div id="words" data-composition-id="words" data-width="${WIDTH}" data-height="${HEIGHT}" data-duration="1">
<span class="w">Aa</span><span class="w">Bb</span>
</div>
<script>
(() => {
const tl = gsap.timeline({ paused: true });
const words = [...document.querySelectorAll("#words .w")];
words.forEach((w) => tl.set(w, { visibility: "hidden" }, 0));
words.forEach((w, i) => tl.set(w, { visibility: "visible", left: 10 + i * 70 }, ${REVEAL_FRAME / FPS}));
tl.set({}, {}, 1);
window.__timelines["words"] = tl;
})();
</script>
</template>
</body>
</html>
`;
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);
});
},
);
@@ -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;
}
@@ -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 = `<!DOCTYPE html>
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;
+1 -1
View File
@@ -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",