From e5a03707272936777abbe3d08e4097fec4937fd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 22 Sep 2026 08:59:53 -0400 Subject: [PATCH] feat(studio): transition seams blur and carry a badge (#4280) * feat(studio): transition seams blur and carry a badge * fix(studio): keep labelled transitions on one row * fix(studio): render transition crossfade zone * fix(studio): gate seams on transition labels * fix(studio): bridge GSAP transition markers to clips * fix(studio): clear transition seam CI failures * fix(studio): register transition frame classes --- .../src/player/components/TimelineLanes.tsx | 23 ++++--- .../components/TimelineTransitionBadge.tsx | 59 +++++++++++++++++ .../components/TimelineTransitionOverlays.tsx | 30 +++++++++ .../src/player/components/timelineTheme.ts | 6 ++ .../timelineTransitionSeams.test.ts | 48 ++++++++++++++ .../components/timelineTransitionSeams.ts | 36 ++++++++++ .../player/components/timelineZones.test.ts | 18 +++++ .../src/player/components/timelineZones.ts | 5 +- .../studio/src/player/lib/playbackTypes.ts | 2 + .../studio/src/player/lib/timelineDOM.test.ts | 28 ++++++++ packages/studio/src/player/lib/timelineDOM.ts | 26 +++++++- .../src/player/lib/timelineIframeHelpers.ts | 7 ++ .../player/lib/timelineTransitionMetadata.ts | 52 +++++++++++++++ .../src/player/store/timelineElement.ts | 1 + packages/studio/src/styles/studio.css | 66 +++++++++++++++++++ packages/studio/src/styles/theme.css | 3 + skills-manifest.json | 2 +- .../scripts/transitions.mjs | 1 + .../scripts/transitions.test.mjs | 2 + 19 files changed, 403 insertions(+), 12 deletions(-) create mode 100644 packages/studio/src/player/components/TimelineTransitionBadge.tsx create mode 100644 packages/studio/src/player/components/TimelineTransitionOverlays.tsx create mode 100644 packages/studio/src/player/components/timelineTransitionSeams.test.ts create mode 100644 packages/studio/src/player/components/timelineTransitionSeams.ts create mode 100644 packages/studio/src/player/lib/timelineTransitionMetadata.ts diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 3921bece03..a40978b20d 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -32,6 +32,8 @@ import { queryTimelineClipIndex } from "../lib/timelineClipIndex"; import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; import { timelineClipFocusId } from "./timelineNavigationIdentity"; import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor"; +import { deriveTimelineTransitionSeams } from "./timelineTransitionSeams"; +import { TimelineTransitionOverlays } from "./TimelineTransitionOverlays"; export function TimelineLanes({ pps, @@ -104,13 +106,15 @@ export function TimelineLanes({ const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } = useTimelineGroupDisclosure(); const automationLanes = useAutomationLanes(); + const allTransitionElements = useMemo( + () => tracks.flatMap(([, elements]) => elements.map(getPreviewElement)), + [getPreviewElement, tracks], + ); // A group's automation clock is COMPOSITION time (groups doc §1.3), so its // synthetic lane element spans the whole composition rather than a clip. const compositionDuration = usePlayerStore((s) => s.duration); useAutomationSelectionKeyboard({ lanes: automationLanes }); const { logicalRowsByTrack, groupByAnchor } = useTimelineLaneRowIndexes(logicalRows, groups); - // Which tracks are group MEMBERS, so their headers can render the level-2 - // nesting their `aria-level` already reports. const groupMemberTracks = useMemo( () => new Set(groups.flatMap((group) => group.memberTracks)), [groups], @@ -197,11 +201,7 @@ export function TimelineLanes({ const ts = trackStyles.get(trackNum) ?? getTrackStyle(""); const isPendingTrack = draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0; - // All lanes use the same uniform color — no alternating stripes. const rowBackground = theme.rowBackground; - // The beat-dot strip occupies the top of this track's lane (active track, - // or the music track when nothing is selected). When shown, keyframe - // diamonds shrink + drop to the bottom half so they don't collide with it. const beatStripOnTrack = trackShowsBeatStrip(els, beatAnalysis?.beatTimes, { selectedElementId, isMusicTrack, @@ -223,9 +223,10 @@ export function TimelineLanes({ // property lanes are showing. Undefined means "fill the row", which is // right only while it is collapsed and the row is nothing but bar. const clipBarHeight = rowExpanded ? TRACK_H - 2 * CLIP_Y : undefined; - // The clips whose envelopes this row draws, at their dragged positions. - // Once per row, not once per clip in the map below. const automationElements = els.map(getPreviewElement); + const transitionSeams = deriveTimelineTransitionSeams(allTransitionElements).filter( + (seam) => seam.incoming.track === trackNum, + ); // Minted here because this is the only place that sees BOTH ends of // the disclosure: the caret in the sticky header and the diamond lanes // on the canvas. Keyed by display row, not by `trackNum`, which is a @@ -553,6 +554,12 @@ export function TimelineLanes({ ); }) } + {/* The automation lanes belong to the ROW, so they are mounted here rather than under the active clip's property lanes. Hanging off that clip meant selecting a sibling moved the diff --git a/packages/studio/src/player/components/TimelineTransitionBadge.tsx b/packages/studio/src/player/components/TimelineTransitionBadge.tsx new file mode 100644 index 0000000000..1f78a75482 --- /dev/null +++ b/packages/studio/src/player/components/TimelineTransitionBadge.tsx @@ -0,0 +1,59 @@ +import type { CSSProperties } from "react"; + +interface TimelineTransitionBadgeProps { + centerPx: number; + top: number; + widthPx: number; + outgoingSrc?: string; + incomingSrc?: string; +} + +export function TimelineTransitionBadge({ + centerPx, + top, + widthPx, + outgoingSrc, + incomingSrc, +}: TimelineTransitionBadgeProps) { + const style = { + left: centerPx, + top, + "--timeline-transition-zone-width": `${widthPx}px`, + } as CSSProperties; + return ( +