From fa9acbd7399b7cd5ca9940399d9066bb33d96973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 29 Oct 2026 04:58:34 +0000 Subject: [PATCH] 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 --- .../src/runtime/adapters/gsap.order.test.ts | 94 ++++++++++++ packages/core/src/runtime/adapters/gsap.ts | 136 ++++++++++++++++++ packages/core/src/runtime/init.ts | 5 +- 3 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/runtime/adapters/gsap.order.test.ts diff --git a/packages/core/src/runtime/adapters/gsap.order.test.ts b/packages/core/src/runtime/adapters/gsap.order.test.ts new file mode 100644 index 0000000000..8a61b593cc --- /dev/null +++ b/packages/core/src/runtime/adapters/gsap.order.test.ts @@ -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 { + const { params, timeline } = overlappingZoom(); + const zoom = new Map(); + 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()); + }); +}); diff --git a/packages/core/src/runtime/adapters/gsap.ts b/packages/core/src/runtime/adapters/gsap.ts index 020f17e8b4..a18ed78b4e 100644 --- a/packages/core/src/runtime/adapters/gsap.ts +++ b/packages/core/src/runtime/adapters/gsap.ts @@ -86,16 +86,151 @@ function gsapNeverRendered(timeline: RuntimeTimelineLike): boolean { return rendered !== undefined && !rendered._initted; } +const lastRenderSeek = new WeakMap(); +const sharedPropertySpans = new WeakMap(); +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 = { + scaleX: "scale", + scaleY: "scale", + autoAlpha: "opacity", + rotate: "rotation", +}; + +type PlacedTween = GsapPlaced & { + vars?: Record; + targets?: () => object[]; +}; +type PropertyWrite = { start: number; end: number; properties: Set | null }; + +function propertiesWritten(vars: Record): Set | null { + const properties = new Set(); + 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(); + 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(); + 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 { diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index a93258938a..299c0f5874 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -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(