@hyperframes/studio 0.8.59 → 0.8.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-BBub3ZdI.js → hyperframes-player-nmVkPQza.js} +1 -1
- package/dist/assets/{index-CI7WON-E.js → index-2-HsQB2C.js} +1 -1
- package/dist/assets/{index-D1KNPijK.js → index-BK36b0XY.js} +162 -162
- package/dist/assets/{index-BxZ5Cw5w.js → index-COG0K-CQ.js} +1 -1
- package/dist/assets/{index-DZBHG9TW.css → index-DS0kLbuM.css} +1 -1
- package/dist/index.d.ts +5 -6
- package/dist/index.html +2 -2
- package/dist/index.js +4977 -4273
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +2 -0
- package/src/components/StudioHeader.tsx +2 -13
- package/src/components/TimelineToolbar.test.tsx +42 -0
- package/src/components/TimelineToolbar.tsx +5 -10
- package/src/components/editor/propertyPanelFill.tsx +3 -2
- package/src/components/icons/AudioMetersIcon.tsx +16 -0
- package/src/components/icons/InspectorIcon.tsx +18 -0
- package/src/components/icons/ReverseGradientIcon.tsx +23 -0
- package/src/components/icons/RippleEditIcon.tsx +19 -0
- package/src/components/nle/AudioMeterStrip.test.tsx +278 -7
- package/src/components/nle/AudioMeterStrip.tsx +264 -47
- package/src/components/ui/Menu.test.tsx +3 -4
- package/src/components/ui/Menu.tsx +18 -5
- package/src/components/ui/Select.tsx +7 -1
- package/src/components/ui/Tooltip.tsx +6 -1
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +56 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +13 -0
- package/src/hooks/useRenderClipContent.test.ts +33 -4
- package/src/hooks/useRenderClipContent.ts +7 -3
- package/src/hooks/useSdkSession.lifecycle.test.tsx +179 -0
- package/src/hooks/useSdkSession.ts +84 -14
- package/src/hooks/useStudioSdkSessions.ts +8 -1
- package/src/player/components/AudioWaveform.test.tsx +53 -1
- package/src/player/components/AudioWaveform.tsx +124 -65
- package/src/player/components/ImageThumbnail.tsx +3 -5
- package/src/player/components/LayerDisclosureRow.tsx +9 -4
- package/src/player/components/PlayheadIndicator.tsx +3 -3
- package/src/player/components/TimelineAutomationLane.test.tsx +1 -1
- package/src/player/components/TimelineAutomationLane.tsx +5 -5
- package/src/player/components/TimelineCanvas.tsx +18 -15
- package/src/player/components/TimelineClip.test.tsx +41 -4
- package/src/player/components/TimelineClip.tsx +11 -4
- package/src/player/components/TimelineClipDiamonds.test.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.tsx +2 -2
- package/src/player/components/TimelineClipFades.tsx +64 -31
- package/src/player/components/TimelineDiamondConnectors.tsx +2 -2
- package/src/player/components/TimelineEmptyState.tsx +1 -1
- package/src/player/components/TimelineFxButton.tsx +10 -8
- package/src/player/components/TimelineGroupHeader.tsx +18 -9
- package/src/player/components/TimelineGroupLaneLabels.tsx +12 -5
- package/src/player/components/TimelineGroupRow.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +14 -0
- package/src/player/components/TimelineLanes.tsx +15 -8
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/TimelineRazorInteraction.tsx +1 -1
- package/src/player/components/TimelineRuler.tsx +3 -2
- package/src/player/components/TimelineShortcutHint.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
- package/src/player/components/TimelineTrackHeader.tsx +4 -6
- package/src/player/components/TimelineTrackPlainHeader.tsx +10 -3
- package/src/player/components/TimelineTransitionBadge.tsx +59 -0
- package/src/player/components/TimelineTransitionOverlays.tsx +30 -0
- package/src/player/components/VideoThumbnail.test.tsx +23 -12
- package/src/player/components/VideoThumbnail.tsx +86 -32
- package/src/player/components/audioClipLink.test.ts +34 -0
- package/src/player/components/audioClipLink.ts +33 -0
- package/src/player/components/audioWaveformPeaks.test.ts +32 -0
- package/src/player/components/audioWaveformPeaks.ts +46 -0
- package/src/player/components/thumbnailUtils.test.ts +17 -0
- package/src/player/components/thumbnailUtils.ts +7 -0
- package/src/player/components/timelineMotionStyles.test.ts +96 -30
- package/src/player/components/timelineTheme.test.ts +14 -0
- package/src/player/components/timelineTheme.ts +18 -11
- package/src/player/components/timelineTransitionSeams.test.ts +48 -0
- package/src/player/components/timelineTransitionSeams.ts +36 -0
- package/src/player/components/timelineZones.test.ts +18 -0
- package/src/player/components/timelineZones.ts +4 -1
- package/src/player/lib/playbackTypes.ts +2 -0
- package/src/player/lib/timelineDOM.test.ts +28 -0
- package/src/player/lib/timelineDOM.ts +24 -2
- package/src/player/lib/timelineIframeHelpers.ts +7 -0
- package/src/player/lib/timelineTransitionMetadata.ts +52 -0
- package/src/player/lib/timelineViewportBudgets.ts +2 -1
- package/src/player/store/timelineElement.ts +1 -0
- package/src/styles/hex-baseline.json +3 -22
- package/src/styles/studio.css +126 -46
- package/src/styles/theme.css +113 -1
- package/src/styles/theme.test.ts +9 -0
- package/src/utils/audioMeterVisibility.ts +2 -2
- package/src/utils/sdkCutover.test.ts +102 -1
- package/src/utils/sdkCutover.ts +16 -21
- package/src/utils/sdkEditTransaction.ts +50 -1
- package/src/utils/studioUiPreferences.ts +1 -1
|
@@ -192,13 +192,14 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
|
|
|
192
192
|
viewBox={`0 0 ${Math.max(widthPx, 1)} 100`}
|
|
193
193
|
preserveAspectRatio="none"
|
|
194
194
|
>
|
|
195
|
-
{/*
|
|
195
|
+
{/* Keep the #4250 wedge geometry, but let the real waveform read through it. */}
|
|
196
196
|
{showIn && (
|
|
197
197
|
<>
|
|
198
198
|
<polygon
|
|
199
199
|
data-testid="clip-fade-in"
|
|
200
200
|
points={`0,0 ${inPx},0 0,100`}
|
|
201
201
|
fill="var(--timeline-fade-shade)"
|
|
202
|
+
fillOpacity={0.35}
|
|
202
203
|
/>
|
|
203
204
|
<line
|
|
204
205
|
x1={0}
|
|
@@ -218,6 +219,7 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
|
|
|
218
219
|
data-testid="clip-fade-out"
|
|
219
220
|
points={`${widthPx - outPx},0 ${widthPx},0 ${widthPx},100`}
|
|
220
221
|
fill="var(--timeline-fade-shade)"
|
|
222
|
+
fillOpacity={0.35}
|
|
221
223
|
/>
|
|
222
224
|
<line
|
|
223
225
|
x1={widthPx - outPx}
|
|
@@ -235,54 +237,85 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
|
|
|
235
237
|
)}
|
|
236
238
|
{canEdit && (
|
|
237
239
|
<>
|
|
238
|
-
<
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
aria-valuemin={0}
|
|
243
|
-
aria-valuemax={Math.max(0, el.duration - fades.fadeOut)}
|
|
244
|
-
aria-valuenow={fades.fadeIn}
|
|
245
|
-
aria-valuetext={`${formatFadeSeconds(fades.fadeIn)}s`}
|
|
246
|
-
data-testid="clip-fade-handle-in"
|
|
247
|
-
title={`Fade in: ${formatFadeSeconds(fades.fadeIn)}s — drag to change`}
|
|
240
|
+
<FadeHandle
|
|
241
|
+
direction="in"
|
|
242
|
+
value={fades.fadeIn}
|
|
243
|
+
max={Math.max(0, el.duration - fades.fadeOut)}
|
|
248
244
|
style={handleStyle(inPx)}
|
|
245
|
+
dragging={dragging === "in"}
|
|
249
246
|
onPointerDown={onHandlePointerDown("in")}
|
|
250
247
|
onPointerMove={onHandlePointerMove}
|
|
251
248
|
onPointerUp={(e) => finish(e, false)}
|
|
252
249
|
onPointerCancel={(e) => finish(e, true)}
|
|
253
250
|
onKeyDown={onHandleKeyDown}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
<div
|
|
260
|
-
role="slider"
|
|
261
|
-
tabIndex={-1}
|
|
262
|
-
aria-label="Fade out"
|
|
263
|
-
aria-valuemin={0}
|
|
264
|
-
aria-valuemax={Math.max(0, el.duration - fades.fadeIn)}
|
|
265
|
-
aria-valuenow={fades.fadeOut}
|
|
266
|
-
aria-valuetext={`${formatFadeSeconds(fades.fadeOut)}s`}
|
|
267
|
-
data-testid="clip-fade-handle-out"
|
|
268
|
-
title={`Fade out: ${formatFadeSeconds(fades.fadeOut)}s — drag to change`}
|
|
251
|
+
/>
|
|
252
|
+
<FadeHandle
|
|
253
|
+
direction="out"
|
|
254
|
+
value={fades.fadeOut}
|
|
255
|
+
max={Math.max(0, el.duration - fades.fadeIn)}
|
|
269
256
|
style={handleStyle(widthPx - outPx)}
|
|
257
|
+
dragging={dragging === "out"}
|
|
270
258
|
onPointerDown={onHandlePointerDown("out")}
|
|
271
259
|
onPointerMove={onHandlePointerMove}
|
|
272
260
|
onPointerUp={(e) => finish(e, false)}
|
|
273
261
|
onPointerCancel={(e) => finish(e, true)}
|
|
274
262
|
onKeyDown={onHandleKeyDown}
|
|
275
|
-
|
|
276
|
-
onDoubleClick={(e) => e.stopPropagation()}
|
|
277
|
-
>
|
|
278
|
-
<FadeDot active={dragging === "out"} />
|
|
279
|
-
</div>
|
|
263
|
+
/>
|
|
280
264
|
</>
|
|
281
265
|
)}
|
|
282
266
|
</>
|
|
283
267
|
);
|
|
284
268
|
}
|
|
285
269
|
|
|
270
|
+
function FadeHandle({
|
|
271
|
+
direction,
|
|
272
|
+
value,
|
|
273
|
+
max,
|
|
274
|
+
style,
|
|
275
|
+
dragging,
|
|
276
|
+
onPointerDown,
|
|
277
|
+
onPointerMove,
|
|
278
|
+
onPointerUp,
|
|
279
|
+
onPointerCancel,
|
|
280
|
+
onKeyDown,
|
|
281
|
+
}: {
|
|
282
|
+
direction: "in" | "out";
|
|
283
|
+
value: number;
|
|
284
|
+
max: number;
|
|
285
|
+
style: CSSProperties;
|
|
286
|
+
dragging: boolean;
|
|
287
|
+
onPointerDown: (event: PointerEvent<HTMLDivElement>) => void;
|
|
288
|
+
onPointerMove: (event: PointerEvent<HTMLDivElement>) => void;
|
|
289
|
+
onPointerUp: (event: PointerEvent<HTMLDivElement>) => void;
|
|
290
|
+
onPointerCancel: (event: PointerEvent<HTMLDivElement>) => void;
|
|
291
|
+
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
|
|
292
|
+
}) {
|
|
293
|
+
const label = direction === "in" ? "Fade in" : "Fade out";
|
|
294
|
+
return (
|
|
295
|
+
<div
|
|
296
|
+
role="slider"
|
|
297
|
+
tabIndex={-1}
|
|
298
|
+
aria-label={label}
|
|
299
|
+
aria-valuemin={0}
|
|
300
|
+
aria-valuemax={max}
|
|
301
|
+
aria-valuenow={value}
|
|
302
|
+
aria-valuetext={`${formatFadeSeconds(value)}s`}
|
|
303
|
+
data-testid={`clip-fade-handle-${direction}`}
|
|
304
|
+
title={`${label}: ${formatFadeSeconds(value)}s — drag to change`}
|
|
305
|
+
style={style}
|
|
306
|
+
onPointerDown={onPointerDown}
|
|
307
|
+
onPointerMove={onPointerMove}
|
|
308
|
+
onPointerUp={onPointerUp}
|
|
309
|
+
onPointerCancel={onPointerCancel}
|
|
310
|
+
onKeyDown={onKeyDown}
|
|
311
|
+
onClick={(e) => e.stopPropagation()}
|
|
312
|
+
onDoubleClick={(e) => e.stopPropagation()}
|
|
313
|
+
>
|
|
314
|
+
<FadeDot active={dragging} />
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
286
319
|
function FadeDot({ active }: { active: boolean }) {
|
|
287
320
|
return (
|
|
288
321
|
<span
|
|
@@ -213,8 +213,8 @@ function SegmentEaseControl({
|
|
|
213
213
|
zIndex: 3,
|
|
214
214
|
pointerEvents: "auto",
|
|
215
215
|
padding: 0,
|
|
216
|
-
border: "1px solid
|
|
217
|
-
background: "
|
|
216
|
+
border: "1px solid var(--timeline-diamond-border)",
|
|
217
|
+
background: "var(--timeline-diamond-bg)",
|
|
218
218
|
cursor: "pointer",
|
|
219
219
|
}}
|
|
220
220
|
onPointerDown={(e) => {
|
|
@@ -18,7 +18,7 @@ export function TimelineEmptyState({
|
|
|
18
18
|
}: TimelineEmptyStateProps) {
|
|
19
19
|
return (
|
|
20
20
|
<div
|
|
21
|
-
className={`h-full border-t bg-[
|
|
21
|
+
className={`h-full border-t bg-[var(--timeline-shell-bg)] flex flex-col select-none transition-colors duration-150 ${
|
|
22
22
|
isDragOver ? "border-studio-accent/50 bg-studio-accent/3" : "border-neutral-800/50"
|
|
23
23
|
}`}
|
|
24
24
|
onDragOver={onDragOver}
|
|
@@ -79,7 +79,7 @@ function GroupNameDialog({
|
|
|
79
79
|
<div
|
|
80
80
|
role="dialog"
|
|
81
81
|
aria-label="This track cannot be grouped"
|
|
82
|
-
className="z-200 w-64 rounded-md border border-
|
|
82
|
+
className="z-200 w-64 rounded-md border border-[var(--timeline-tick-major)] bg-[var(--timeline-dialog-bg)] p-3 text-[11px] leading-snug text-[var(--timeline-text-soft)] shadow-xl"
|
|
83
83
|
style={{ position: "fixed", ...groupDialogPosition(anchorRect) }}
|
|
84
84
|
onPointerDown={(event) => event.stopPropagation()}
|
|
85
85
|
onKeyDown={(event) => {
|
|
@@ -97,7 +97,7 @@ function GroupNameDialog({
|
|
|
97
97
|
<div
|
|
98
98
|
role="dialog"
|
|
99
99
|
aria-label="Name this group"
|
|
100
|
-
className="z-200 w-64 rounded-md border border-
|
|
100
|
+
className="z-200 w-64 rounded-md border border-[var(--timeline-tick-major)] bg-[var(--timeline-dialog-bg)] p-3 text-[11px] text-[var(--timeline-text-soft)] shadow-xl"
|
|
101
101
|
style={{ position: "fixed", ...groupDialogPosition(anchorRect) }}
|
|
102
102
|
onPointerDown={(event) => event.stopPropagation()}
|
|
103
103
|
onKeyDown={(event) => {
|
|
@@ -108,14 +108,14 @@ function GroupNameDialog({
|
|
|
108
108
|
if (event.key === "Enter") confirm();
|
|
109
109
|
}}
|
|
110
110
|
>
|
|
111
|
-
<p className="mb-1.5 font-medium text-
|
|
111
|
+
<p className="mb-1.5 font-medium text-[var(--timeline-text-solid)]">Name this group</p>
|
|
112
112
|
<input
|
|
113
113
|
ref={inputRef}
|
|
114
114
|
type="text"
|
|
115
115
|
aria-label="Group name"
|
|
116
116
|
value={label}
|
|
117
117
|
onChange={(event) => setLabel(event.currentTarget.value)}
|
|
118
|
-
className="w-full rounded-sm border border-
|
|
118
|
+
className="w-full rounded-sm border border-[var(--timeline-border-strong)] bg-[var(--timeline-input-bg)] px-1.5 py-1 text-[11px] text-[var(--timeline-text-solid)] outline-hidden focus:border-[var(--timeline-accent)]"
|
|
119
119
|
/>
|
|
120
120
|
{/* The sentence. No jargon, and it names both things a bus does. */}
|
|
121
121
|
<p className="mt-2 leading-snug">
|
|
@@ -125,14 +125,14 @@ function GroupNameDialog({
|
|
|
125
125
|
<div className="mt-2.5 flex justify-end gap-1.5">
|
|
126
126
|
<button
|
|
127
127
|
type="button"
|
|
128
|
-
className="rounded-sm border border-
|
|
128
|
+
className="rounded-sm border border-[var(--timeline-border-strong)] px-2 py-1 text-[10px] text-[var(--timeline-text-soft)] hover:bg-[var(--timeline-tick-major)]"
|
|
129
129
|
onClick={onCancel}
|
|
130
130
|
>
|
|
131
131
|
Cancel
|
|
132
132
|
</button>
|
|
133
133
|
<button
|
|
134
134
|
type="button"
|
|
135
|
-
className="rounded-sm border border-[
|
|
135
|
+
className="rounded-sm border border-[var(--timeline-accent)] bg-[var(--timeline-accent-button-bg)] px-2 py-1 text-[10px] font-semibold text-[var(--timeline-accent)] hover:bg-[var(--timeline-accent-hover)]"
|
|
136
136
|
onClick={confirm}
|
|
137
137
|
>
|
|
138
138
|
Group
|
|
@@ -212,7 +212,7 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
|
|
|
212
212
|
ref={buttonRef}
|
|
213
213
|
aria-label="Effects — group these clips first"
|
|
214
214
|
title="Group these clips to add effects to all of them"
|
|
215
|
-
className="flex h-6 items-center justify-center rounded-sm border-0 bg-transparent px-1 text-[10px] font-semibold text-
|
|
215
|
+
className="flex h-6 items-center justify-center rounded-sm border-0 bg-transparent px-1 text-[10px] font-semibold text-[var(--timeline-text-faint)] hover:text-[var(--timeline-text-soft)]"
|
|
216
216
|
onPointerDown={(event) => event.stopPropagation()}
|
|
217
217
|
onClick={(event) => {
|
|
218
218
|
event.stopPropagation();
|
|
@@ -253,7 +253,9 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
|
|
|
253
253
|
aria-label={nodeCount > 0 ? `Effects — ${nodeCount} applied` : "Effects"}
|
|
254
254
|
title="Effects"
|
|
255
255
|
className={`flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[10px] font-semibold transition-colors ${
|
|
256
|
-
open || nodeCount > 0
|
|
256
|
+
open || nodeCount > 0
|
|
257
|
+
? "text-[var(--timeline-accent)]"
|
|
258
|
+
: "text-[var(--timeline-text-faint)] hover:text-[var(--timeline-text-soft)]"
|
|
257
259
|
}`}
|
|
258
260
|
onPointerDown={(event) => event.stopPropagation()}
|
|
259
261
|
onClick={(event) => {
|
|
@@ -55,21 +55,24 @@ function GroupNameButton({
|
|
|
55
55
|
// No `flex-1`: the row's control group owns the slack now (`ml-auto`), so
|
|
56
56
|
// claiming it here would push the controls off the right edge — and the
|
|
57
57
|
// count with them, since it rides inside this button.
|
|
58
|
-
className="flex h-6 min-w-0 items-center gap-1.5 rounded-sm border-0 bg-transparent p-0 text-left text-[11px] text-
|
|
58
|
+
className="flex h-6 min-w-0 items-center gap-1.5 rounded-sm border-0 bg-transparent p-0 text-left text-[11px] text-[var(--timeline-text-solid)] hover:text-[var(--timeline-accent)] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)]"
|
|
59
59
|
onPointerDown={(event) => event.stopPropagation()}
|
|
60
60
|
onClick={(event) => {
|
|
61
61
|
event.stopPropagation();
|
|
62
62
|
onOpenFxRack();
|
|
63
63
|
}}
|
|
64
64
|
>
|
|
65
|
-
<span
|
|
65
|
+
<span
|
|
66
|
+
aria-hidden="true"
|
|
67
|
+
className="shrink-0 text-[12px] leading-none text-[var(--timeline-track-label)]"
|
|
68
|
+
>
|
|
66
69
|
▤
|
|
67
70
|
</span>
|
|
68
71
|
<span title={label} className="min-w-0 truncate text-left font-medium leading-tight">
|
|
69
72
|
{label}
|
|
70
73
|
</span>
|
|
71
74
|
<span
|
|
72
|
-
className="shrink-0 rounded-full bg-
|
|
75
|
+
className="shrink-0 rounded-full bg-[var(--timeline-tick-major)] px-1 text-[9px] leading-[14px] tabular-nums text-[var(--timeline-handle)]"
|
|
73
76
|
aria-hidden="true"
|
|
74
77
|
title={`${memberCount} tracks`}
|
|
75
78
|
>
|
|
@@ -103,7 +106,7 @@ export function TimelineGroupHeader({
|
|
|
103
106
|
style={{
|
|
104
107
|
width: columnWidth,
|
|
105
108
|
height: TRACK_H,
|
|
106
|
-
color: "
|
|
109
|
+
color: "var(--timeline-text-solid)",
|
|
107
110
|
background: theme.gutterBackground,
|
|
108
111
|
borderRight: `1px solid ${theme.gutterBorder}`,
|
|
109
112
|
}}
|
|
@@ -119,8 +122,10 @@ export function TimelineGroupHeader({
|
|
|
119
122
|
// 13px mono, matching the property panel's preset-run caret
|
|
120
123
|
// (`hf-fx-preset-run-caret`) — the same disclosure, so the same glyph
|
|
121
124
|
// at the same size rather than a smaller one unique to this row.
|
|
122
|
-
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 font-mono text-[13px] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[
|
|
123
|
-
isExpanded
|
|
125
|
+
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 font-mono text-[13px] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)] ${
|
|
126
|
+
isExpanded
|
|
127
|
+
? "text-[var(--timeline-text-solid)]"
|
|
128
|
+
: "text-[var(--timeline-handle)] hover:text-[var(--timeline-text-solid)]"
|
|
124
129
|
}`}
|
|
125
130
|
onPointerDown={(event) => event.stopPropagation()}
|
|
126
131
|
onClick={(event) => {
|
|
@@ -157,8 +162,10 @@ export function TimelineGroupHeader({
|
|
|
157
162
|
aria-label={`${isLaneOpen ? "Hide" : "Show"} ${label} lanes`}
|
|
158
163
|
title={`${isLaneOpen ? "Hide" : "Show"} lanes`}
|
|
159
164
|
// Anchored right, matching every other header's lane toggle.
|
|
160
|
-
className={`ml-auto flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[11px] leading-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[
|
|
161
|
-
isLaneOpen
|
|
165
|
+
className={`ml-auto flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[11px] leading-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)] ${
|
|
166
|
+
isLaneOpen
|
|
167
|
+
? "text-[var(--timeline-accent)]"
|
|
168
|
+
: "text-[var(--timeline-handle)] hover:text-[var(--timeline-text-solid)]"
|
|
162
169
|
}`}
|
|
163
170
|
onPointerDown={(event) => event.stopPropagation()}
|
|
164
171
|
onClick={(event) => {
|
|
@@ -167,7 +174,9 @@ export function TimelineGroupHeader({
|
|
|
167
174
|
}}
|
|
168
175
|
>
|
|
169
176
|
<span aria-hidden="true">∿</span>
|
|
170
|
-
<span className="text-[9px] tabular-nums text-
|
|
177
|
+
<span className="text-[9px] tabular-nums text-[var(--timeline-handle)]">
|
|
178
|
+
{laneCount}
|
|
179
|
+
</span>
|
|
171
180
|
</button>
|
|
172
181
|
)}
|
|
173
182
|
</div>
|
|
@@ -64,7 +64,7 @@ export function TimelineGroupLaneLabels({
|
|
|
64
64
|
key={lane.target}
|
|
65
65
|
data-group-lane-label={lane.target}
|
|
66
66
|
aria-label={`Show ${groupLabel} ${parts.name}${parts.param ? ` ${parts.param}` : ""} in the effect rack`}
|
|
67
|
-
className="absolute left-0 flex items-center gap-1.5 overflow-hidden border-0 px-1.5 text-left text-[10px] text-
|
|
67
|
+
className="absolute left-0 flex items-center gap-1.5 overflow-hidden border-0 px-1.5 text-left text-[10px] text-[var(--timeline-text-secondary)] hover:text-[var(--timeline-text-solid)] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)]"
|
|
68
68
|
style={{
|
|
69
69
|
top: top + index * AUTOMATION_LANE_H,
|
|
70
70
|
width: columnWidth,
|
|
@@ -79,16 +79,23 @@ export function TimelineGroupLaneLabels({
|
|
|
79
79
|
onReveal?.(lane.target);
|
|
80
80
|
}}
|
|
81
81
|
>
|
|
82
|
-
<span
|
|
82
|
+
<span
|
|
83
|
+
aria-hidden="true"
|
|
84
|
+
className="shrink-0 text-[11px] text-[var(--timeline-text-dim)]"
|
|
85
|
+
>
|
|
83
86
|
▤
|
|
84
87
|
</span>
|
|
85
88
|
<span className="flex min-w-0 flex-1 flex-col justify-center leading-tight">
|
|
86
|
-
<span className="truncate font-mono text-[9px] text-
|
|
89
|
+
<span className="truncate font-mono text-[9px] text-[var(--timeline-text-bright)]">
|
|
90
|
+
{parts.name}
|
|
91
|
+
</span>
|
|
87
92
|
{parts.param ? (
|
|
88
|
-
<span className="truncate font-mono text-[9px] text-
|
|
93
|
+
<span className="truncate font-mono text-[9px] text-[var(--timeline-text-dim)]">
|
|
94
|
+
{parts.param}
|
|
95
|
+
</span>
|
|
89
96
|
) : null}
|
|
90
97
|
</span>
|
|
91
|
-
<span className="shrink-0 font-mono text-[9px] tabular-nums text-
|
|
98
|
+
<span className="shrink-0 font-mono text-[9px] tabular-nums text-[var(--timeline-handle)]">
|
|
92
99
|
{value.toFixed(2)}
|
|
93
100
|
</span>
|
|
94
101
|
</button>
|
|
@@ -21,7 +21,7 @@ import { usePlayerStore } from "../store/playerStore";
|
|
|
21
21
|
|
|
22
22
|
/** Accent rail on a group-owned lane — the same green the member rail uses, so
|
|
23
23
|
* "this belongs to the group" reads the same in both places (groups doc §5). */
|
|
24
|
-
const GROUP_LANE_ACCENT = "
|
|
24
|
+
const GROUP_LANE_ACCENT = "var(--timeline-accent)";
|
|
25
25
|
|
|
26
26
|
interface TimelineGroupRowProps {
|
|
27
27
|
index: number;
|
|
@@ -432,6 +432,20 @@ describe("TimelineLanes selection", () => {
|
|
|
432
432
|
});
|
|
433
433
|
|
|
434
434
|
describe("TimelineLanes clip thumbnails", () => {
|
|
435
|
+
it("keeps thumbnail content inside a selected clip", () => {
|
|
436
|
+
const selected = element("clip-a", TRACK_A);
|
|
437
|
+
const view = renderLanes({
|
|
438
|
+
elements: [selected],
|
|
439
|
+
selectedElementIds: new Set([selected.id]),
|
|
440
|
+
renderClipContent: () => <div className="absolute inset-0 bg-neutral-900" data-thumbnail />,
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
const clip = view.host.querySelector('[data-el-id="clip-a"]');
|
|
444
|
+
expect(clip?.classList.contains("is-selected")).toBe(true);
|
|
445
|
+
expect(clip?.querySelector("[data-thumbnail]")).not.toBeNull();
|
|
446
|
+
act(() => view.root.unmount());
|
|
447
|
+
});
|
|
448
|
+
|
|
435
449
|
it("asks for the same frames at rest, hovered and selected", () => {
|
|
436
450
|
const rich: unknown[] = [];
|
|
437
451
|
const renderClipContent = vi.fn(
|
|
@@ -32,6 +32,8 @@ import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
|
|
|
32
32
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
33
33
|
import { timelineClipFocusId } from "./timelineNavigationIdentity";
|
|
34
34
|
import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
|
|
35
|
+
import { deriveTimelineTransitionSeams } from "./timelineTransitionSeams";
|
|
36
|
+
import { TimelineTransitionOverlays } from "./TimelineTransitionOverlays";
|
|
35
37
|
|
|
36
38
|
export function TimelineLanes({
|
|
37
39
|
pps,
|
|
@@ -104,13 +106,15 @@ export function TimelineLanes({
|
|
|
104
106
|
const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
|
|
105
107
|
useTimelineGroupDisclosure();
|
|
106
108
|
const automationLanes = useAutomationLanes();
|
|
109
|
+
const allTransitionElements = useMemo(
|
|
110
|
+
() => tracks.flatMap(([, elements]) => elements.map(getPreviewElement)),
|
|
111
|
+
[getPreviewElement, tracks],
|
|
112
|
+
);
|
|
107
113
|
// A group's automation clock is COMPOSITION time (groups doc §1.3), so its
|
|
108
114
|
// synthetic lane element spans the whole composition rather than a clip.
|
|
109
115
|
const compositionDuration = usePlayerStore((s) => s.duration);
|
|
110
116
|
useAutomationSelectionKeyboard({ lanes: automationLanes });
|
|
111
117
|
const { logicalRowsByTrack, groupByAnchor } = useTimelineLaneRowIndexes(logicalRows, groups);
|
|
112
|
-
// Which tracks are group MEMBERS, so their headers can render the level-2
|
|
113
|
-
// nesting their `aria-level` already reports.
|
|
114
118
|
const groupMemberTracks = useMemo(
|
|
115
119
|
() => new Set(groups.flatMap((group) => group.memberTracks)),
|
|
116
120
|
[groups],
|
|
@@ -197,11 +201,7 @@ export function TimelineLanes({
|
|
|
197
201
|
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
|
|
198
202
|
const isPendingTrack =
|
|
199
203
|
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
|
|
200
|
-
// All lanes use the same uniform color — no alternating stripes.
|
|
201
204
|
const rowBackground = theme.rowBackground;
|
|
202
|
-
// The beat-dot strip occupies the top of this track's lane (active track,
|
|
203
|
-
// or the music track when nothing is selected). When shown, keyframe
|
|
204
|
-
// diamonds shrink + drop to the bottom half so they don't collide with it.
|
|
205
205
|
const beatStripOnTrack = trackShowsBeatStrip(els, beatAnalysis?.beatTimes, {
|
|
206
206
|
selectedElementId,
|
|
207
207
|
isMusicTrack,
|
|
@@ -223,9 +223,10 @@ export function TimelineLanes({
|
|
|
223
223
|
// property lanes are showing. Undefined means "fill the row", which is
|
|
224
224
|
// right only while it is collapsed and the row is nothing but bar.
|
|
225
225
|
const clipBarHeight = rowExpanded ? TRACK_H - 2 * CLIP_Y : undefined;
|
|
226
|
-
// The clips whose envelopes this row draws, at their dragged positions.
|
|
227
|
-
// Once per row, not once per clip in the map below.
|
|
228
226
|
const automationElements = els.map(getPreviewElement);
|
|
227
|
+
const transitionSeams = deriveTimelineTransitionSeams(allTransitionElements).filter(
|
|
228
|
+
(seam) => seam.incoming.track === trackNum,
|
|
229
|
+
);
|
|
229
230
|
// Minted here because this is the only place that sees BOTH ends of
|
|
230
231
|
// the disclosure: the caret in the sticky header and the diamond lanes
|
|
231
232
|
// on the canvas. Keyed by display row, not by `trackNum`, which is a
|
|
@@ -553,6 +554,12 @@ export function TimelineLanes({
|
|
|
553
554
|
);
|
|
554
555
|
})
|
|
555
556
|
}
|
|
557
|
+
<TimelineTransitionOverlays
|
|
558
|
+
seams={transitionSeams}
|
|
559
|
+
pixelsPerSecond={pps}
|
|
560
|
+
rowHeight={rowHeight}
|
|
561
|
+
clipBarHeight={clipBarHeight}
|
|
562
|
+
/>
|
|
556
563
|
{/* The automation lanes belong to the ROW, so they are mounted
|
|
557
564
|
here rather than under the active clip's property lanes.
|
|
558
565
|
Hanging off that clip meant selecting a sibling moved the
|
|
@@ -386,7 +386,7 @@ describe("TimelinePropertyLanes", () => {
|
|
|
386
386
|
const positionFill = laneDiamonds(host, "position")[0]?.querySelector("path:last-child");
|
|
387
387
|
const visualFill = laneDiamonds(host, "visual")[0]?.querySelector("path:last-child");
|
|
388
388
|
expect(positionFill?.getAttribute("fill")).toBe("#4ba3d2");
|
|
389
|
-
expect(visualFill?.getAttribute("fill")).toBe("
|
|
389
|
+
expect(visualFill?.getAttribute("fill")).toBe("var(--timeline-diamond-muted)");
|
|
390
390
|
act(() => root.unmount());
|
|
391
391
|
});
|
|
392
392
|
|
|
@@ -54,7 +54,7 @@ export function TimelineRazorGuideOverlay({ x }: { x: number }) {
|
|
|
54
54
|
return (
|
|
55
55
|
<div
|
|
56
56
|
className="absolute top-0 bottom-0 pointer-events-none z-10"
|
|
57
|
-
style={{ left: x, width: 1, background: "
|
|
57
|
+
style={{ left: x, width: 1, background: "var(--timeline-danger)" }}
|
|
58
58
|
/>
|
|
59
59
|
);
|
|
60
60
|
}
|
|
@@ -69,7 +69,8 @@ export const TimelineRuler = memo(function TimelineRuler({
|
|
|
69
69
|
y1={0}
|
|
70
70
|
x2={x}
|
|
71
71
|
y2={totalH}
|
|
72
|
-
stroke=
|
|
72
|
+
stroke="var(--timeline-beat)"
|
|
73
|
+
strokeOpacity={opacity}
|
|
73
74
|
strokeWidth="1"
|
|
74
75
|
/>
|
|
75
76
|
);
|
|
@@ -99,7 +100,7 @@ export const TimelineRuler = memo(function TimelineRuler({
|
|
|
99
100
|
style={{
|
|
100
101
|
height: RULER_H,
|
|
101
102
|
width: trackContentWidth,
|
|
102
|
-
// Ruler background = panel surface
|
|
103
|
+
// Ruler background = the panel surface token — no bottom border,
|
|
103
104
|
// no tick lines (CapCut-style clean ruler, labels only).
|
|
104
105
|
background: theme.shellBackground,
|
|
105
106
|
}}
|
|
@@ -9,11 +9,11 @@ export function TimelineShortcutHint({ theme }: TimelineShortcutHintProps) {
|
|
|
9
9
|
<div className="absolute bottom-2 right-3 pointer-events-none z-20">
|
|
10
10
|
<div
|
|
11
11
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
|
|
12
|
-
style={{ background: "
|
|
12
|
+
style={{ background: "var(--timeline-shortcut-bg)", borderColor: theme.gutterBorder }}
|
|
13
13
|
>
|
|
14
14
|
<kbd
|
|
15
15
|
className="text-[9px] font-mono px-1 py-0.5 rounded-sm"
|
|
16
|
-
style={{ color: theme.textSecondary, background: "
|
|
16
|
+
style={{ color: theme.textSecondary, background: "var(--timeline-tick-minor)" }}
|
|
17
17
|
>
|
|
18
18
|
Shift
|
|
19
19
|
</kbd>
|
|
@@ -793,7 +793,7 @@ describe("TimelineTrackHeader", () => {
|
|
|
793
793
|
isGroupMember: true,
|
|
794
794
|
});
|
|
795
795
|
expect(header()?.style.paddingLeft).toBe("14px");
|
|
796
|
-
expect(header()?.style.borderLeft).toContain("
|
|
796
|
+
expect(header()?.style.borderLeft).toContain("var(--timeline-accent-rail)");
|
|
797
797
|
act(() => view.root.unmount());
|
|
798
798
|
});
|
|
799
799
|
|
|
@@ -26,12 +26,10 @@ import { useMemo } from "react";
|
|
|
26
26
|
|
|
27
27
|
/** Accent rail + inset marking a row as a group MEMBER, matching the level-2
|
|
28
28
|
* nesting its `aria-level` already reports. */
|
|
29
|
-
const GROUP_MEMBER_RAIL = "
|
|
29
|
+
const GROUP_MEMBER_RAIL = "var(--timeline-accent-rail)";
|
|
30
30
|
const GROUP_MEMBER_INDENT = 14;
|
|
31
|
-
/** A
|
|
32
|
-
|
|
33
|
-
* it tracks whatever the theme's gutter is. */
|
|
34
|
-
const GROUP_MEMBER_TINT = "rgba(255,255,255,0.035)";
|
|
31
|
+
/** A neutral wash keeps group membership visible without changing the row's hue. */
|
|
32
|
+
const GROUP_MEMBER_TINT = "var(--timeline-group-member-tint)";
|
|
35
33
|
|
|
36
34
|
/** The gutter fill for a row, tinted when it belongs to a group. */
|
|
37
35
|
export function gutterFill(base: string, isGroupMember: boolean): string {
|
|
@@ -288,7 +286,7 @@ export function TimelineTrackHeader({
|
|
|
288
286
|
<div
|
|
289
287
|
className={
|
|
290
288
|
showTrackLabel
|
|
291
|
-
? "flex flex-col justify-center gap-0.5 px-1.5 text-
|
|
289
|
+
? "flex flex-col justify-center gap-0.5 px-1.5 text-[var(--timeline-handle)]"
|
|
292
290
|
: "flex flex-col items-center justify-center gap-0.5"
|
|
293
291
|
}
|
|
294
292
|
style={{ height: TRACK_H }}
|
|
@@ -39,8 +39,10 @@ export function VisibilityButton({
|
|
|
39
39
|
type="button"
|
|
40
40
|
aria-label={label}
|
|
41
41
|
title={label}
|
|
42
|
-
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-[
|
|
43
|
-
hidden
|
|
42
|
+
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-[var(--timeline-accent)] ${
|
|
43
|
+
hidden
|
|
44
|
+
? "text-[var(--timeline-accent)] hover:text-[var(--timeline-text-solid)]"
|
|
45
|
+
: "text-[var(--timeline-text-faint)] hover:text-[var(--timeline-text-soft)]"
|
|
44
46
|
}`}
|
|
45
47
|
onPointerDown={(event) => event.stopPropagation()}
|
|
46
48
|
onClick={(event) => {
|
|
@@ -89,7 +91,12 @@ export function PlainTrackHeader({
|
|
|
89
91
|
and the name gives way instead. */}
|
|
90
92
|
<div className="flex min-w-0 items-center gap-1">
|
|
91
93
|
{isAudioTrack && (
|
|
92
|
-
<Music
|
|
94
|
+
<Music
|
|
95
|
+
size={12}
|
|
96
|
+
weight="fill"
|
|
97
|
+
aria-hidden="true"
|
|
98
|
+
className="text-[var(--timeline-text-faint)]"
|
|
99
|
+
/>
|
|
93
100
|
)}
|
|
94
101
|
{/* No `flex-1`: the name takes only the width it needs, so the clip
|
|
95
102
|
count sits against it rather than being pushed out to meet the
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
interface TimelineTransitionBadgeProps {
|
|
4
|
+
centerPx: number;
|
|
5
|
+
top: number;
|
|
6
|
+
widthPx: number;
|
|
7
|
+
outgoingSrc?: string;
|
|
8
|
+
incomingSrc?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function TimelineTransitionBadge({
|
|
12
|
+
centerPx,
|
|
13
|
+
top,
|
|
14
|
+
widthPx,
|
|
15
|
+
outgoingSrc,
|
|
16
|
+
incomingSrc,
|
|
17
|
+
}: TimelineTransitionBadgeProps) {
|
|
18
|
+
const style = {
|
|
19
|
+
left: centerPx,
|
|
20
|
+
top,
|
|
21
|
+
"--timeline-transition-zone-width": `${widthPx}px`,
|
|
22
|
+
} as CSSProperties;
|
|
23
|
+
return (
|
|
24
|
+
<div className="timeline-transition" style={style} aria-hidden="true">
|
|
25
|
+
<div className="timeline-transition__zone">
|
|
26
|
+
<div
|
|
27
|
+
className="timeline-transition__image timeline-transition__image--out"
|
|
28
|
+
style={outgoingSrc ? { backgroundImage: `url("${outgoingSrc}")` } : undefined}
|
|
29
|
+
/>
|
|
30
|
+
<div
|
|
31
|
+
className="timeline-transition__image timeline-transition__image--in"
|
|
32
|
+
style={incomingSrc ? { backgroundImage: `url("${incomingSrc}")` } : undefined}
|
|
33
|
+
/>
|
|
34
|
+
</div>
|
|
35
|
+
<div className="timeline-transition__badge">
|
|
36
|
+
<svg viewBox="0 0 20 20" role="presentation">
|
|
37
|
+
<defs>
|
|
38
|
+
<filter id="timeline-transition-glyph-blur">
|
|
39
|
+
<feGaussianBlur stdDeviation="0.55" />
|
|
40
|
+
</filter>
|
|
41
|
+
</defs>
|
|
42
|
+
<path
|
|
43
|
+
className="timeline-transition__frame timeline-transition__frame--out"
|
|
44
|
+
d="M3 5h8v10H3z"
|
|
45
|
+
/>
|
|
46
|
+
<path
|
|
47
|
+
className="timeline-transition__frame timeline-transition__frame--in"
|
|
48
|
+
d="M9 5h8v10H9z"
|
|
49
|
+
/>
|
|
50
|
+
<path
|
|
51
|
+
className="timeline-transition__intersection"
|
|
52
|
+
d="M9 5h2v10H9z"
|
|
53
|
+
filter="url(#timeline-transition-glyph-blur)"
|
|
54
|
+
/>
|
|
55
|
+
</svg>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { CLIP_Y } from "./timelineLayout";
|
|
2
|
+
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
3
|
+
import type { TimelineTransitionSeam } from "./timelineTransitionSeams";
|
|
4
|
+
import { TimelineTransitionBadge } from "./TimelineTransitionBadge";
|
|
5
|
+
|
|
6
|
+
interface TimelineTransitionOverlaysProps {
|
|
7
|
+
seams: readonly TimelineTransitionSeam[];
|
|
8
|
+
pixelsPerSecond: number;
|
|
9
|
+
rowHeight: number;
|
|
10
|
+
clipBarHeight?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function TimelineTransitionOverlays({
|
|
14
|
+
seams,
|
|
15
|
+
pixelsPerSecond,
|
|
16
|
+
rowHeight,
|
|
17
|
+
clipBarHeight,
|
|
18
|
+
}: TimelineTransitionOverlaysProps) {
|
|
19
|
+
const top = CLIP_Y + (clipBarHeight ?? rowHeight - 2 * CLIP_Y) / 2;
|
|
20
|
+
return seams.map((seam) => (
|
|
21
|
+
<TimelineTransitionBadge
|
|
22
|
+
key={`${getTimelineElementIdentity(seam.outgoing)}-${getTimelineElementIdentity(seam.incoming)}`}
|
|
23
|
+
centerPx={seam.centerTime * pixelsPerSecond}
|
|
24
|
+
top={top}
|
|
25
|
+
widthPx={Math.min(Math.max(seam.duration * pixelsPerSecond, 24), 32)}
|
|
26
|
+
outgoingSrc={seam.outgoing.src}
|
|
27
|
+
incomingSrc={seam.incoming.src}
|
|
28
|
+
/>
|
|
29
|
+
));
|
|
30
|
+
}
|