diff --git a/packages/studio/src/hooks/gsapShared.test.ts b/packages/studio/src/hooks/gsapShared.test.ts index 98717e1a8..9df0b2f65 100644 --- a/packages/studio/src/hooks/gsapShared.test.ts +++ b/packages/studio/src/hooks/gsapShared.test.ts @@ -217,6 +217,31 @@ describe("resolveClipTimingBasis", () => { }); }); + it("treats a child whose parent composition is missing as starting at 0", () => { + // The mount is unknowable, so the only safe frame is the child's own. The + // old `?? 0` handed back `start` unchanged, which is a main-timeline value + // masquerading as a composition-local one and caches negative percentages. + const pill = { + id: "pill", + domId: "pill", + start: 7, + duration: 3, + parentCompositionId: "not-in-elements", + }; + expect(resolveClipTimingBasis("pill", "scene.html", [pill], [])).toEqual({ + elStart: 0, + elDuration: 3, + }); + }); + + it("keeps a main-timeline clip's own start when it names no parent", () => { + const box = { id: "box", domId: "box", start: 4, duration: 2 }; + expect(resolveClipTimingBasis("box", "index.html", [box], [])).toEqual({ + elStart: 4, + elDuration: 2, + }); + }); + it("falls back to a unit window when neither the element nor a host resolves", () => { expect(resolveClipTimingBasis("ghost", "index.html", [], [])).toEqual({ elStart: 0, diff --git a/packages/studio/src/hooks/gsapShared.ts b/packages/studio/src/hooks/gsapShared.ts index d80db4ad3..a92b2ae71 100644 --- a/packages/studio/src/hooks/gsapShared.ts +++ b/packages/studio/src/hooks/gsapShared.ts @@ -299,8 +299,16 @@ export function resolveClipTimingBasis( const parent = parentId ? elements.find((el) => el.domId === parentId || el.id === parentId) : undefined; - const mount = direct.expandedParentStart ?? parent?.start ?? 0; - return { elStart: direct.start - mount, elDuration: direct.duration }; + const mount = direct.expandedParentStart ?? parent?.start; + if (mount !== undefined) return { elStart: direct.start - mount, elDuration: direct.duration }; + // No parent composition named, so this IS a main-timeline clip and its own + // start is already the basis. + if (!parentId) return { elStart: direct.start, elDuration: direct.duration }; + // It named a parent we cannot find, so the mount is unknowable. Its tweens + // are still composition-local, so treat its own window as the frame rather + // than subtracting nothing and handing back a main-timeline start, which is + // exactly the mixed-frame subtraction this function exists to prevent. + return { elStart: 0, elDuration: direct.duration }; } const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId; const host = hostId diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts index 204c1509a..f7100997b 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts @@ -143,6 +143,51 @@ describe("buildExpandedElements", () => { expect(child.sourceFile).toBe("c.html"); // C's file, not b.html or a.html }); + it("keeps the middle host's row when drilling two levels deep", () => { + // A embeds B; C lives in B. Drilling into B must leave BOTH host rows + // standing: sparing only the top-level one drops B's row, and its keyframe + // lane goes with it because diamonds render per row. + const elements = [ + el({ id: "A", domId: "A", start: 10, duration: 8, compositionSrc: "a.html" }), + el({ id: "B", domId: "B", start: 12, duration: 4, track: 1, compositionSrc: "b.html" }), + ]; + const manifest = [ + clip({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" }), + clip({ id: "B", start: 12, duration: 4, compositionSrc: "b.html" }), + clip({ id: "C", start: 13, duration: 2 }), + ]; + const parentMap = new Map([ + ["B", "A"], + ["C", "B"], + ]); + + const out = buildExpandedElements(elements, manifest, parentMap, "A", "B"); + const rows = out.map((e) => e.domId ?? e.id); + expect(rows).toContain("B"); + // The child sits under its own host, not under the top-level row. + expect(rows.indexOf("C")).toBeGreaterThan(rows.indexOf("B")); + }); + + it("still drills a host that exists only in the manifest, without a row for it", () => { + // Same shape, but B has no store element, so there is no row to spare. The + // children stay anchored to the top-level row rather than vanishing. + const elements = [ + el({ id: "A", domId: "A", start: 10, duration: 8, compositionSrc: "a.html" }), + ]; + const manifest = [ + clip({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" }), + clip({ id: "B", start: 12, duration: 4, compositionSrc: "b.html" }), + clip({ id: "C", start: 13, duration: 2 }), + ]; + const parentMap = new Map([ + ["B", "A"], + ["C", "B"], + ]); + + const out = buildExpandedElements(elements, manifest, parentMap, "A", "B"); + expect(out.map((e) => e.domId ?? e.id)).toEqual(["A", "C"]); + }); + // Regression: an expanded child must share one identity (`key`) with the flat // store element for the same DOM id. Before the fix the child key fell back to // the colon form (`index.html:eyebrow:N`) while the store/selection used the diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts index 57eae95b6..8a1c95e58 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts @@ -284,16 +284,39 @@ export function buildExpandedElements( ); if (expanded.length === 0) return filterToTopLevel(elements, parentMap); + // Every host between the drilled one and the top level owns a row, so the + // drill has to spare all of them, not just the top. A middle host is still a + // host: dropping its row drops its keyframe lane with it. + const drillPath = new Set(); + for (let cursor: string | undefined = siblingParentId; cursor; ) { + if (drillPath.has(cursor)) break; + drillPath.add(cursor); + if (cursor === topLevelId) break; + cursor = parentMap.get(cursor); + } + // Children hang under the DEEPEST host on that path, so anchor them there + // when it has a row of its own and fall back to the top-level row when it + // does not (a host that lives only in the manifest never had one). + const anchorsChildren = (el: TimelineElement): boolean => + drillPath.has(siblingParentId) && elements.some((e) => (e.domId ?? e.id) === siblingParentId) + ? (el.domId ?? el.id) === siblingParentId + : (el.key ?? el.id) === parentKey; + // ADDITIVE drill-in: the host row stays and its children are appended under // it. Expansion is also triggered by the playhead alone (paused auto-expand), - // so substituting the host row made it vanish on an ordinary seek — and with + // so substituting the host row made it vanish on an ordinary seek, and with // it the host's keyframe lane, since diamonds render per row from // `keyframeCache.get(elementKey)`. The synthetic fractional lanes above sit // strictly between the host's lane and the next integer, so the children have // their own rows without the host having to give up its own. return elements - .filter((el) => (el.key ?? el.id) === parentKey || !parentMap.has(el.domId ?? el.id)) - .flatMap((el) => ((el.key ?? el.id) === parentKey ? [el, ...expanded] : [el])); + .filter( + (el) => + (el.key ?? el.id) === parentKey || + drillPath.has(el.domId ?? el.id) || + !parentMap.has(el.domId ?? el.id), + ) + .flatMap((el) => (anchorsChildren(el) ? [el, ...expanded] : [el])); } export function useExpandedTimelineElements(): TimelineElement[] {