From 3e342cff9c642258509df64e0a47da38502591bd Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 25 Jul 2026 21:06:23 +0200 Subject: [PATCH] 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. --- .../hooks/useExpandedTimelineElements.test.ts | 43 +++++++++++++++++++ .../hooks/useExpandedTimelineElements.ts | 24 ++++++++--- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts index d0f078f17..12adcd2cd 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts @@ -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 }), diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts index d0ccaa268..7ce6c5d66 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts @@ -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, activeIds: Set) { let depth = 0; let parent = parentMap.get(id); @@ -65,11 +70,20 @@ function findActiveExpandableCompositionId( parentMap: Map, ): string | null { const parentIds = new Set(parentMap.values()); - const activeIds = new Set(); - 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(); + 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) {