mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user