mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(core): keep nested scenes in place during a drag when the root has no timeline (#5115)
* fix(core): keep nested scenes in place during a drag when the root has no timeline * fix(core): reuse the missing-root composite so a late root timeline still binds * fix(core): count a nested scene in its composite length so a rebind keeps it at the playhead * fix(core): rebuild a held composite whose length went stale so the player length stays right * test(core): reuse the no-root-timeline loader for the stale-length case
This commit is contained in:
@@ -2,6 +2,7 @@ import gsap from "gsap";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { initSandboxRuntimeModular } from "./init";
|
||||
import type { RuntimeTimelineLike } from "./types";
|
||||
import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../editing/draftMarkers";
|
||||
import { resetRuntimeFixtureDom } from "./runtimeSeekFixture.test-helpers";
|
||||
|
||||
// Pins where a running runtime places a sub-composition's timeline, against what a fresh load does.
|
||||
@@ -14,6 +15,7 @@ describe("runtime sub-composition placement", () => {
|
||||
afterEach(() => {
|
||||
window.__hfRuntimeTeardown?.();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
delete window.__player;
|
||||
delete window.__playerReady;
|
||||
delete window.__hf;
|
||||
@@ -98,6 +100,123 @@ describe("runtime sub-composition placement", () => {
|
||||
expect(intro.startTime()).toBe(2);
|
||||
});
|
||||
|
||||
/** Transport frames on demand; the clock moves a second per frame, past the change-service rate limit. */
|
||||
function stubFrames() {
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
let next = 0;
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
||||
frames.set(++next, cb);
|
||||
return next;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id));
|
||||
let now = Date.now();
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
return async (count: number) => {
|
||||
for (let frame = 1; frame <= count; frame++) {
|
||||
now += 1000;
|
||||
await Promise.resolve();
|
||||
const due = [...frames.values()];
|
||||
frames.clear();
|
||||
for (const run of due) run(frame * 16);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** A root with no timeline of its own around one scene clip, whose timeline slides `#s` from 0 to 100 over 4 s. */
|
||||
function loadWithoutRootTimeline(rootMarkup = "", rootLength = ` data-duration="10"`) {
|
||||
document.body.innerHTML =
|
||||
`<div data-composition-id="main" data-root="true"${rootLength}>${rootMarkup}` +
|
||||
`<div id="host" class="clip" data-composition-id="scene" data-start="1" data-duration="4">` +
|
||||
`<div id="s"></div></div></div>`;
|
||||
const scene = gsap
|
||||
.timeline({ paused: true })
|
||||
.to("#s", { x: 100, duration: 4, ease: "none" }, 0);
|
||||
window.__timelines = { scene } as unknown as Record<string, RuntimeTimelineLike>;
|
||||
initSandboxRuntimeModular();
|
||||
return scene;
|
||||
}
|
||||
|
||||
it("keeps a nested scene where the playhead left it during a drag when the root has no timeline", async () => {
|
||||
const runFrames = stubFrames();
|
||||
loadWithoutRootTimeline();
|
||||
window.__player?.seek(4);
|
||||
expect(shownX()).toBe(75);
|
||||
|
||||
// Every transform GSAP writes while the drag is held, including ones a later write in the same tick hides.
|
||||
const shown: string[] = [];
|
||||
const writes = new MutationObserver((records) =>
|
||||
shown.push(...records.map((r) => r.oldValue ?? "")),
|
||||
);
|
||||
writes.observe(document.getElementById("s")!, {
|
||||
attributes: true,
|
||||
attributeFilter: ["style"],
|
||||
attributeOldValue: true,
|
||||
});
|
||||
// A Studio press marks the element; a timing edit then wakes the transport's rebind mid-drag.
|
||||
document.getElementById("s")!.setAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR, "gesture-1:move");
|
||||
document.getElementById("host")!.setAttribute("data-duration", "4");
|
||||
await runFrames(30);
|
||||
|
||||
shown.push(
|
||||
...writes.takeRecords().map((r) => r.oldValue ?? ""),
|
||||
document.getElementById("s")!.getAttribute("style") ?? "",
|
||||
);
|
||||
writes.disconnect();
|
||||
const xs = shown.map((style) => /translate(?:3d)?\(([-\d.]+)/.exec(style)?.[1]).filter(Boolean);
|
||||
expect(xs.length).toBeGreaterThan(0);
|
||||
expect(new Set(xs)).toEqual(new Set(["75"]));
|
||||
});
|
||||
|
||||
it("keeps a dragged scene where the playhead left it when another scene registers mid-drag", async () => {
|
||||
const runFrames = stubFrames();
|
||||
loadWithoutRootTimeline(
|
||||
`<div id="later" class="clip" data-composition-id="later" data-start="0" data-duration="2"><div id="l"></div></div>`,
|
||||
);
|
||||
window.__player?.seek(4);
|
||||
document.getElementById("s")!.setAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR, "gesture-1:move");
|
||||
(window.__timelines as Record<string, unknown>).later = gsap
|
||||
.timeline({ paused: true })
|
||||
.to("#l", { x: 100, duration: 2, ease: "none" }, 0);
|
||||
await runFrames(30);
|
||||
|
||||
expect(shownX()).toBe(75);
|
||||
});
|
||||
|
||||
it("reports a scene's new length after a script extends it, when the root declares none", async () => {
|
||||
const runFrames = stubFrames();
|
||||
const scene = loadWithoutRootTimeline("", "");
|
||||
await runFrames(3);
|
||||
window.__player?.seek(2);
|
||||
scene.to({}, { duration: 8 }, 0);
|
||||
await runFrames(5);
|
||||
|
||||
expect(window.__player?.getDuration()).toBe(9);
|
||||
});
|
||||
|
||||
it("binds a root timeline that registers after the runtime started on its children", async () => {
|
||||
const runFrames = stubFrames();
|
||||
loadWithoutRootTimeline(`<div id="r"></div>`);
|
||||
const root = gsap
|
||||
.timeline({ paused: true })
|
||||
.to("#r", { x: 100, duration: 10, ease: "none" }, 0);
|
||||
(window.__timelines as Record<string, unknown>).main = root;
|
||||
await runFrames(30);
|
||||
|
||||
window.__player?.seek(5);
|
||||
expect(Number(gsap.getProperty("#r", "x"))).toBe(50);
|
||||
expect(shownX()).toBe(100);
|
||||
});
|
||||
|
||||
it("places a scene at its host's new start on a rebind when the root has no timeline", () => {
|
||||
const scene = loadWithoutRootTimeline();
|
||||
document.getElementById("host")!.setAttribute("data-start", "4");
|
||||
window.__hfForceTimelineRebind?.();
|
||||
|
||||
expect(scene.startTime()).toBe(4);
|
||||
window.__player?.seek(5);
|
||||
expect(shownX()).toBe(25);
|
||||
});
|
||||
|
||||
it("leaves an unmoved scene in place on a rebind, whatever its start rounds to", () => {
|
||||
const { root, scene } = load("0.33333333");
|
||||
const remove = vi.spyOn(root, "remove");
|
||||
|
||||
@@ -1375,6 +1375,8 @@ export function initSandboxRuntimeModular(): void {
|
||||
|
||||
// Sub-composition timelines the runtime nested into the root: the host composition id, and where.
|
||||
const autoNested = new WeakMap<object, { hostId: string; parent: unknown; at: number }>();
|
||||
// The composite that holds the children of a root with no timeline, and how many it holds.
|
||||
let missingRootComposite: { timeline: RuntimeTimelineLike; size: number } | null = null;
|
||||
|
||||
const resolveRootTimelineFromDocument = (): TimelineResolution => {
|
||||
const timelines = (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>;
|
||||
@@ -1430,16 +1432,25 @@ export function initSandboxRuntimeModular(): void {
|
||||
parent.add(candidate.timeline, at);
|
||||
autoNested.set(candidate.timeline, { hostId: candidate.compositionId, parent, at });
|
||||
};
|
||||
/** Placed by the runtime in `root` and still there; under another root a re-run script owns its place. */
|
||||
const placedIn = (
|
||||
root: RuntimeTimelineLike,
|
||||
candidate: { compositionId: string; timeline: RuntimeTimelineLike },
|
||||
) => {
|
||||
const nested = candidate.timeline as RuntimeTimelineLike & RuntimeTimelineChildLike;
|
||||
const placed = autoNested.get(nested);
|
||||
return placed && placed.parent === root && nested.parent === (root as unknown)
|
||||
? placed
|
||||
: null;
|
||||
};
|
||||
const atHostStart = (placed: { at: number }, candidate: { compositionId: string }) =>
|
||||
Math.abs(placed.at - resolveCompositionStartSeconds(candidate.compositionId)) < 1e-6;
|
||||
const followHostStart = (
|
||||
root: RuntimeTimelineLike,
|
||||
candidate: { compositionId: string; timeline: RuntimeTimelineLike },
|
||||
): void => {
|
||||
const nested = candidate.timeline as RuntimeTimelineLike & RuntimeTimelineChildLike;
|
||||
const placed = autoNested.get(nested);
|
||||
// Under another root, a re-run script placed it, and owns that place as on a fresh load.
|
||||
if (!placed || placed.parent !== root || nested.parent !== (root as unknown)) return;
|
||||
if (Math.abs(placed.at - resolveCompositionStartSeconds(candidate.compositionId)) < 1e-6)
|
||||
return;
|
||||
const placed = placedIn(root, candidate);
|
||||
if (!placed || atHostStart(placed, candidate)) return;
|
||||
// GSAP takes a child out of its parent before adding it; the rebind's unpause aligns it to the root.
|
||||
nestAtHostStart(root, candidate);
|
||||
};
|
||||
@@ -1454,6 +1465,8 @@ export function initSandboxRuntimeModular(): void {
|
||||
if (!gsapApi || typeof gsapApi.timeline !== "function") return null;
|
||||
const compositeTimeline = gsapApi.timeline({ paused: true }) as RuntimeTimelineLike;
|
||||
for (const candidate of candidates) nestAtHostStart(compositeTimeline, candidate);
|
||||
// Before any duration read: GSAP leaves paused children out of the duration it caches.
|
||||
ensureChildCandidatesActive(nestedCandidates(compositeTimeline, candidates));
|
||||
return compositeTimeline;
|
||||
};
|
||||
const createDurationFloorTimeline = (
|
||||
@@ -1632,7 +1645,6 @@ export function initSandboxRuntimeModular(): void {
|
||||
const compositeTimeline = createCompositeTimelineFromCandidates(rootChildCandidates);
|
||||
const compositeDurationSeconds = getTimelineDurationSeconds(compositeTimeline);
|
||||
if (compositeTimeline && isUsableTimelineDuration(compositeDurationSeconds)) {
|
||||
ensureChildCandidatesActive(nestedCandidates(compositeTimeline, rootChildCandidates));
|
||||
return {
|
||||
timeline: compositeTimeline,
|
||||
selectedTimelineIds,
|
||||
@@ -1786,10 +1798,25 @@ export function initSandboxRuntimeModular(): void {
|
||||
}
|
||||
if (rootChildCandidates.length > 0) {
|
||||
const selectedTimelineIds = rootChildCandidates.map((candidate) => candidate.compositionId);
|
||||
const compositeTimeline = createCompositeTimelineFromCandidates(rootChildCandidates);
|
||||
// A rebuild re-parents every child at time 0 and paints it there, under a paused drag too,
|
||||
// so the same children, each still at its host's start, keep the composite they sit in.
|
||||
const held = missingRootComposite;
|
||||
const reused =
|
||||
held?.size === rootChildCandidates.length &&
|
||||
isUsableTimelineDuration(getTimelineDurationSeconds(held.timeline)) &&
|
||||
rootChildCandidates.every((candidate) => {
|
||||
const placed = placedIn(held.timeline, candidate);
|
||||
return placed !== null && atHostStart(placed, candidate);
|
||||
})
|
||||
? held.timeline
|
||||
: null;
|
||||
const compositeTimeline =
|
||||
reused ?? createCompositeTimelineFromCandidates(rootChildCandidates);
|
||||
const compositeDurationSeconds = getTimelineDurationSeconds(compositeTimeline);
|
||||
if (compositeTimeline) {
|
||||
ensureChildCandidatesActive(nestedCandidates(compositeTimeline, rootChildCandidates));
|
||||
if (!reused) {
|
||||
missingRootComposite = { timeline: compositeTimeline, size: rootChildCandidates.length };
|
||||
}
|
||||
return {
|
||||
timeline: compositeTimeline,
|
||||
selectedTimelineIds,
|
||||
|
||||
Reference in New Issue
Block a user