fix(core): a multi-worker or chunked render lands every frame where an in-order render does (#6707)

* fix(core): a multi-worker or chunked render lands every frame where an in-order render does

* fix(core): replay only where tweens overlap, and from any off-grid scene time

* fix(core): a film with paused scenes replays only where its tweens overlap
This commit is contained in:
Miguel Ángel
2026-10-29 04:58:34 +00:00
committed by GitHub
parent 11312b359b
commit fa9acbd739
3 changed files with 233 additions and 2 deletions
@@ -0,0 +1,94 @@
import gsap from "gsap";
import { describe, expect, it } from "vitest";
import { seekGsapTimeline } from "./gsap";
import type { RuntimeTimelineLike } from "../types";
const FPS = 60;
const FRAMES = 150;
// A zoom punch still running when a cut sets the zoom and a slower move starts from wherever the zoom is.
function overlappingZoom() {
const params = { z: 1 };
const timeline = gsap.timeline({ paused: true });
timeline.to(params, { z: 1.45, duration: 0.3, ease: "power4.out" }, 0.573);
timeline.set(params, { z: 1.04 }, 0.8);
timeline.to(params, { z: 1.12, duration: 1.39, ease: "power1.in" }, 0.869);
return { params, timeline: timeline as unknown as RuntimeTimelineLike };
}
/** The zoom at each frame a renderer captures, in order; a scene's clock starts at `hostStart`. */
function capture(frames: number[], hostStart = 0, seekFirst?: number): Map<number, number> {
const { params, timeline } = overlappingZoom();
const zoom = new Map<number, number>();
if (seekFirst !== undefined) seekGsapTimeline(timeline, seekFirst, true, undefined, FPS);
for (const frame of frames) {
seekGsapTimeline(timeline, frame / FPS - hostStart, true, undefined, FPS);
zoom.set(frame, params.z);
}
return zoom;
}
const range = (from: number, to: number, step = 1) =>
Array.from({ length: Math.ceil((to - from) / step) }, (_, i) => from + i * step);
describe("a render that captures frames out of order", () => {
const inOrder = capture(range(0, FRAMES));
it("gives every frame the same value when 8 workers each capture every 8th frame", () => {
for (let worker = 0; worker < 8; worker++) {
for (const [frame, z] of capture(range(worker, FRAMES, 8))) {
expect([frame, z]).toEqual([frame, inOrder.get(frame)]);
}
}
});
it("gives every frame the same value when a chunk starts inside the overlap", () => {
for (const [frame, z] of capture(range(53, FRAMES))) {
expect([frame, z]).toEqual([frame, inOrder.get(frame)]);
}
});
it("gives every frame the same value in a scene whose host starts between two frames", () => {
const hostStart = 0.0125;
const sceneInOrder = capture(range(1, FRAMES), hostStart);
for (let worker = 0; worker < 8; worker++) {
for (const [frame, z] of capture(range(worker + 1, FRAMES, 8), hostStart)) {
expect(z).toBeCloseTo(sceneInOrder.get(frame)!, 9);
}
}
});
it("gives every frame the same value when a scene held at 0 jumps to a chunk start between two frames", () => {
const hostStart = 0.0125;
const sceneInOrder = capture(range(1, FRAMES), hostStart);
for (const [frame, z] of capture(range(53, FRAMES), hostStart, 0)) {
expect(z).toBeCloseTo(sceneInOrder.get(frame)!, 9);
}
});
it("replays no frame when no two tweens write the same property at once", () => {
const rendersOfTwoSeeks = (fps?: number) => {
const [a, b, c, d] = [{ x: 0 }, { x: 0 }, { x: 0 }, { x: 0 }];
const timeline = gsap.timeline({ paused: true });
timeline
.to(a, { x: 1, duration: 2 }, 0)
.to(b, { x: 1, duration: 2 }, 0)
.to(a, { x: 2, duration: 1 }, 2);
const pausedScene = gsap
.timeline()
.to(c, { x: 1, duration: 1 })
.to(c, { x: 2, duration: 1 }, 0.5);
timeline.add(pausedScene, 0.3);
pausedScene.pause();
timeline.add(gsap.timeline({ repeat: 3, yoyo: true }).to(d, { x: 1, duration: 0.5 }), 0);
const film = timeline as unknown as RuntimeTimelineLike;
let renders = 0;
const totalTime = film.totalTime!.bind(film);
film.totalTime = (...args) => (args.length ? (renders++, totalTime(...args)) : totalTime());
seekGsapTimeline(film, 0, true, undefined, fps);
seekGsapTimeline(film, 170 / FPS, true, undefined, fps);
return [renders, a.x, b.x, c.x, d.x];
};
expect(rendersOfTwoSeeks(FPS)).toEqual(rendersOfTwoSeeks());
});
});
+136
View File
@@ -86,16 +86,151 @@ function gsapNeverRendered(timeline: RuntimeTimelineLike): boolean {
return rendered !== undefined && !rendered._initted;
}
const lastRenderSeek = new WeakMap<object, number>();
const sharedPropertySpans = new WeakMap<object, { version: string; spans: [number, number][] }>();
const NOT_PROPERTIES = new Set([
"duration",
"ease",
"delay",
"repeat",
"repeatDelay",
"yoyo",
"yoyoEase",
"repeatRefresh",
"stagger",
"overwrite",
"immediateRender",
"lazy",
"id",
"paused",
"reversed",
"callbackScope",
"runBackwards",
"data",
"inherit",
"defaults",
]);
const EVERY_PROPERTY = new Set(["keyframes", "startAt", "transform"]);
const SAME_PROPERTY: Record<string, string> = {
scaleX: "scale",
scaleY: "scale",
autoAlpha: "opacity",
rotate: "rotation",
};
type PlacedTween = GsapPlaced & {
vars?: Record<string, unknown>;
targets?: () => object[];
};
type PropertyWrite = { start: number; end: number; properties: Set<string> | null };
function propertiesWritten(vars: Record<string, unknown>): Set<string> | null {
const properties = new Set<string>();
for (const key of Object.keys(vars)) {
if (EVERY_PROPERTY.has(key)) return null;
if (!key.startsWith("on") && !NOT_PROPERTIES.has(key))
properties.add(SAME_PROPERTY[key] ?? key);
}
return properties;
}
function spanOnParent(
[start, length]: [number, number],
parent: GsapPlaced,
): [number, number] | null {
const speed = Number(parent._ts);
if (!speed) return null;
const [from, span] =
parent._repeat || speed < 0
? [0, Number(parent.totalDuration?.() ?? Infinity)]
: [start, length];
return [Number(parent._start) + from / Math.abs(speed), span / Math.abs(speed)];
}
function writeOf(tween: PlacedTween, timeline: RuntimeTimelineLike): PropertyWrite | null {
let span: [number, number] | null = [Number(tween._start), Number(tween.totalDuration?.() ?? 0)];
for (let parent = tween.parent; span && parent && parent !== timeline; parent = parent.parent) {
span = spanOnParent(span, parent);
}
return (
span && {
start: span[0],
end: span[0] + span[1],
properties: propertiesWritten(tween.vars ?? {}),
}
);
}
function spansWhereTweensShareAProperty(timeline: RuntimeTimelineLike): [number, number][] {
const writes = new Map<object, PropertyWrite[]>();
for (const child of timeline.getChildren?.(true, true, false) ?? []) {
const tween = child as PlacedTween;
const write = writeOf(tween, timeline);
if (!write) continue;
for (const target of tween.targets?.() ?? [])
writes.set(target, [...(writes.get(target) ?? []), write]);
}
return [...writes.values()].flatMap(overlapsOnOneTarget);
}
function sharesAProperty(a: PropertyWrite, b: PropertyWrite): boolean {
return !a.properties || !b.properties || [...a.properties].some((p) => b.properties!.has(p));
}
function overlapsOnOneTarget(writes: PropertyWrite[]): [number, number][] {
const spans: [number, number][] = [];
writes.sort((x, y) => x.start - y.start);
writes.forEach((first, i) => {
for (const second of writes.slice(i + 1)) {
if (second.start >= first.end) break;
if (sharesAProperty(first, second))
spans.push([second.start, Math.min(first.end, second.end)]);
}
});
return spans;
}
function framesSkippedSinceLastSeek(
timeline: RuntimeTimelineLike,
t: number,
fps: number,
version = "",
): number[] {
const last = lastRenderSeek.get(timeline);
lastRenderSeek.set(timeline, t);
const skipped =
last === undefined ? Math.floor(t * fps + 1e-6) : Math.ceil((t - last) * fps - 1e-6) - 1;
if (skipped < 1) return [];
let cached = sharedPropertySpans.get(timeline);
if (cached?.version !== version) {
cached = { version, spans: spansWhereTweensShareAProperty(timeline) };
sharedPropertySpans.set(timeline, cached);
}
const replay = new Set<number>();
for (const [from, to] of cached.spans) {
const nearest = Math.max(1, Math.ceil((t - to) * fps - 1e-6) - 1);
const farthest = Math.min(skipped, Math.floor((t - from) * fps + 1e-6) + 1);
for (let back = nearest; back <= farthest; back++) replay.add(back);
}
return [...replay].sort((x, y) => y - x).map((back) => t - back / fps);
}
export function seekGsapTimeline(
timeline: RuntimeTimelineLike,
t: number,
suppressEvents: boolean,
structure?: GsapStructureKey,
renderFps?: number,
): boolean {
if (typeof timeline.totalTime !== "function") {
if (typeof timeline.seek === "function") timeline.seek(t, suppressEvents);
return false;
}
if (renderFps) {
for (const time of framesSkippedSinceLastSeek(timeline, t, renderFps, structure?.version)) {
timeline.totalTime(time, suppressEvents);
}
}
const from = Number(timeline.totalTime());
timeline.totalTime(t, suppressEvents);
if (t < from && structure) reapplyStepsInAuthoredOrder(timeline, structure, t);
@@ -175,6 +310,7 @@ type GsapPlaced = {
_repeat?: number;
parent?: GsapPlaced | null;
getChildren?: unknown;
totalDuration?: () => number;
};
function startOnOwner(child: GsapPlaced, owner: object): number | null {
+3 -2
View File
@@ -549,6 +549,7 @@ export function initSandboxRuntimeModular(): void {
ensureAudioGroupInertStyle(document);
ensureColorGroupInertStyle(document);
const exportRenderFps = resolveExportRenderFps();
const renderFps = exportRenderFps.fps ?? undefined;
state.canonicalFps = exportRenderFps.fps ?? compositionFps() ?? state.canonicalFps;
setRuntimeProtocolFps(state.canonicalFps);
if (window.__HF_EXPORT_RENDER_SEEK_CONFIG) {
@@ -5084,7 +5085,7 @@ export function initSandboxRuntimeModular(): void {
const suppressEvents = options?.suppressEvents === true;
// Guarded: a partial timeline without pause() must still get its seek.
pauseTimelineIfPossible(timeline);
seekGsapTimeline(timeline, timeSeconds, suppressEvents, gsapStructureOf(timeline));
seekGsapTimeline(timeline, timeSeconds, suppressEvents, gsapStructureOf(timeline), renderFps);
} catch (err) {
// The scene's own callback threw. A warning, not a script error: it must not turn a
// timed-out timeline wait into a script failure.
@@ -5315,7 +5316,7 @@ export function initSandboxRuntimeModular(): void {
try {
if (typeof tl.totalTime === "function") {
const structure = gsapStructureOf(tl);
const redrawn = seekGsapTimeline(tl, tlSeekTime, suppressEvents, structure);
const redrawn = seekGsapTimeline(tl, tlSeekTime, suppressEvents, structure, renderFps);
if (!redrawn && !suppressEvents && !hasZeroDurationCallbackTween(tl, structure)) {
// The first seek is the only eventful one; the re-render only refreshes styles.
rerenderGsapTimelineAt(