diff --git a/packages/core/src/runtime/init.subCompositionPlacement.test.ts b/packages/core/src/runtime/init.subCompositionPlacement.test.ts index 5907bdf89a..a5fc0bac14 100644 --- a/packages/core/src/runtime/init.subCompositionPlacement.test.ts +++ b/packages/core/src/runtime/init.subCompositionPlacement.test.ts @@ -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(); + 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 = + `
${rootMarkup}` + + `
` + + `
`; + const scene = gsap + .timeline({ paused: true }) + .to("#s", { x: 100, duration: 4, ease: "none" }, 0); + window.__timelines = { scene } as unknown as Record; + 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( + `
`, + ); + window.__player?.seek(4); + document.getElementById("s")!.setAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR, "gesture-1:move"); + (window.__timelines as Record).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(`
`); + const root = gsap + .timeline({ paused: true }) + .to("#r", { x: 100, duration: 10, ease: "none" }, 0); + (window.__timelines as Record).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"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index fa9731725a..91fa173700 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -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(); + // 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; @@ -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,