fix(studio): keep sub-compositions expanded at the end of the timeline

Auto-expansion picks the composition whose clip window contains the
playhead, and those windows are half-open. With the playhead parked on
the last clip's end - where playback stops - it sat inside nothing, so
every expanded sub-composition row and its keyframe lanes collapsed to a
single host row.

Accept the closing boundary, but only when the strict pass matched
nothing, so a playhead on the seam between two adjacent clips still
expands the one that is starting.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 00:40:43 +02:00
parent 099d891fd3
commit 3e342cff9c
2 changed files with 62 additions and 5 deletions
@@ -278,6 +278,49 @@ describe("resolveTimelineExpansionRawId", () => {
).toBe("scene");
});
it("THE BUG: keeps a composition expanded with the playhead parked on its end", () => {
// Clip windows are half-open, so at the very end of the timeline the
// playhead was inside nothing and every expanded row collapsed.
const manifest = [
clip({ id: "scene", start: 0, duration: 12 }),
clip({ id: "headline", start: 0, duration: 12 }),
];
const parentMap = new Map([["headline", "scene"]]);
expect(
resolveTimelineExpansionRawId({
selectedElementId: null,
isPlaying: false,
currentTime: 12,
manifest,
parentMap,
}),
).toBe("scene");
});
it("prefers the starting clip over the ending one on a shared seam", () => {
const manifest = [
clip({ id: "first", start: 0, duration: 5 }),
clip({ id: "first-child", start: 0, duration: 5 }),
clip({ id: "second", start: 5, duration: 5 }),
clip({ id: "second-child", start: 5, duration: 5 }),
];
const parentMap = new Map([
["first-child", "first"],
["second-child", "second"],
]);
expect(
resolveTimelineExpansionRawId({
selectedElementId: null,
isPlaying: false,
currentTime: 5,
manifest,
parentMap,
}),
).toBe("second");
});
it("auto-expands the innermost active nested composition when paused", () => {
const manifest = [
clip({ id: "outer", start: 0, duration: 10 }),
@@ -45,6 +45,11 @@ function clipContainsTime(clip: ClipManifestClip, time: number): boolean {
return Number.isFinite(time) && time >= clip.start && time < clip.start + clip.duration;
}
/** Half-open containment plus the closing boundary — see the fallback below. */
function clipTouchesTime(clip: ClipManifestClip, time: number): boolean {
return Number.isFinite(time) && time >= clip.start && time <= clip.start + clip.duration;
}
function getActiveParentDepth(id: string, parentMap: Map<string, string>, activeIds: Set<string>) {
let depth = 0;
let parent = parentMap.get(id);
@@ -65,11 +70,20 @@ function findActiveExpandableCompositionId(
parentMap: Map<string, string>,
): string | null {
const parentIds = new Set(parentMap.values());
const activeIds = new Set<string>();
for (const clip of manifest) {
if (!clip.id || !parentIds.has(clip.id) || !clipContainsTime(clip, currentTime)) continue;
activeIds.add(clip.id);
}
const collect = (matches: (clip: ClipManifestClip, time: number) => boolean) => {
const ids = new Set<string>();
for (const clip of manifest) {
if (clip.id && parentIds.has(clip.id) && matches(clip, currentTime)) ids.add(clip.id);
}
return ids;
};
// Clip windows are half-open, so a playhead parked exactly on a clip's end is
// inside nothing — at the end of the timeline that collapsed every expanded
// sub-composition row and its keyframe lanes. Only when the strict pass finds
// nothing do we accept the closing boundary, so a playhead landing on the seam
// between two adjacent clips still expands the one that is starting.
const strict = collect(clipContainsTime);
const activeIds = strict.size > 0 ? strict : collect(clipTouchesTime);
let bestId: string | null = null;
let bestDepth = -1;
for (const id of activeIds) {