fix(studio): keep every host row on the drill path, not just the top

Drilling two levels deep spared only the top-level row, so the middle host
lost its row and its keyframe lane with it. Spare every host between the
drilled one and the top, and anchor the children under the deepest host that
actually has a row.

Also stops resolveClipTimingBasis handing back a main-timeline start when a
clip names a parent composition that is absent from the element list. The
mount is unknowable there, so the child's own window is the only safe frame.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 19:02:05 +02:00
parent d48440a9ba
commit acad7b268e
4 changed files with 106 additions and 5 deletions
@@ -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,
+10 -2
View File
@@ -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
@@ -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
@@ -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<string>();
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[] {