@hyperframes/studio 0.8.58 → 0.8.60
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-zGyOcwwh.js → hyperframes-player-Di7GyCOP.js} +3 -3
- package/dist/assets/{index-DKJpHYYd.css → index-CBKb_0TI.css} +1 -1
- package/dist/assets/{index-d8ferjRc.js → index-Dpq2lkOm.js} +190 -190
- package/dist/assets/{index-ZJaRCDYV.js → index-Dx_pt9m4.js} +1 -1
- package/dist/assets/{index-BcE2OSVA.js → index-jAjhQTrB.js} +1 -1
- package/dist/index.d.ts +549 -13
- package/dist/index.html +2 -2
- package/dist/index.js +23133 -21941
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/components/StudioHeader.tsx +2 -13
- package/src/components/TimelineToolbar.test.tsx +42 -0
- package/src/components/TimelineToolbar.tsx +22 -8
- package/src/components/editor/audioInspectorInput.test.ts +55 -0
- package/src/components/editor/audioInspectorInput.ts +55 -0
- package/src/components/editor/propertyPanelFill.tsx +3 -2
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +172 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +87 -1
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +92 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +13 -9
- package/src/components/editor/propertyPanelFlatSliderReadout.tsx +106 -0
- package/src/components/editor/useColorGradingController.ts +2 -6
- 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 +395 -0
- package/src/components/nle/AudioMeterStrip.tsx +430 -0
- package/src/components/nle/TimelinePane.tsx +29 -23
- 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/contexts/TimelineEditContext.tsx +4 -0
- package/src/hooks/useTimelineAssetDropOps.ts +9 -1
- package/src/index.ts +16 -0
- package/src/player/components/AudioWaveform.tsx +16 -7
- 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/Timeline.test.ts +22 -31
- package/src/player/components/Timeline.tsx +34 -7
- package/src/player/components/TimelineAutomationLane.test.tsx +1 -1
- package/src/player/components/TimelineAutomationLane.tsx +5 -5
- package/src/player/components/TimelineCanvas.tsx +28 -71
- package/src/player/components/TimelineClip.test.tsx +41 -4
- package/src/player/components/TimelineClip.tsx +25 -5
- package/src/player/components/TimelineClipDiamonds.test.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.tsx +2 -2
- package/src/player/components/TimelineClipFades.test.tsx +171 -0
- package/src/player/components/TimelineClipFades.tsx +302 -0
- 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/TimelineOverlays.tsx +164 -165
- package/src/player/components/TimelineParts.test.tsx +101 -0
- package/src/player/components/TimelineParts.tsx +74 -0
- package/src/player/components/TimelinePartsParity.test.tsx +58 -0
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/TimelineProvider.tsx +25 -0
- package/src/player/components/TimelineRazorInteraction.tsx +2 -2
- package/src/player/components/TimelineRuler.tsx +3 -2
- package/src/player/components/TimelineRulerPart.tsx +22 -0
- 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/VideoThumbnail.tsx +3 -5
- package/src/player/components/timelineMotionStyles.test.ts +96 -30
- package/src/player/components/timelineProviderStateBuilders.ts +20 -0
- package/src/player/components/timelineTheme.test.ts +14 -0
- package/src/player/components/timelineTheme.ts +12 -11
- package/src/player/components/useTimelineProviderState.tsx +19 -12
- package/src/player/lib/timelineElementHelpers.ts +20 -0
- package/src/player/store/timelineElement.ts +5 -0
- package/src/styles/hex-baseline.json +4 -23
- package/src/styles/studio.css +43 -47
- package/src/styles/theme.css +109 -1
- package/src/styles/theme.test.ts +9 -0
- package/src/utils/audioMeterMath.test.ts +44 -0
- package/src/utils/audioMeterMath.ts +79 -0
- package/src/utils/audioMeterVisibility.ts +14 -0
- package/src/utils/studioHelpers.ts +21 -0
- package/src/utils/studioUiPreferences.test.ts +10 -0
- package/src/utils/studioUiPreferences.ts +5 -0
- package/src/utils/timelineAssetDrop.test.ts +32 -0
- package/src/utils/timelineAssetDrop.ts +6 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo, useCallback, useMemo, useRef } from "react";
|
|
1
|
+
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
3
|
import { useThumbnailLease } from "../../hooks/useThumbnailLease";
|
|
4
4
|
import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
|
|
@@ -136,11 +136,12 @@ export const AudioWaveform = memo(function AudioWaveform({
|
|
|
136
136
|
const end = Math.max(start + 1, Math.ceil(endFraction * peaks.length));
|
|
137
137
|
const span = end - start;
|
|
138
138
|
const barCount = Math.floor(width / BAR_STEP);
|
|
139
|
+
const waveformBarRgb = getComputedStyle(canvas).getPropertyValue("--timeline-waveform-bar-rgb");
|
|
139
140
|
for (let index = 0; index < barCount; index++) {
|
|
140
141
|
const peakIndex = start + Math.min(span - 1, Math.floor((index / barCount) * span));
|
|
141
142
|
const amplitude = peaks[peakIndex] ?? 0;
|
|
142
143
|
const barHeight = Math.max(2, amplitude * height);
|
|
143
|
-
context.fillStyle = `rgba(
|
|
144
|
+
context.fillStyle = `rgba(${waveformBarRgb},${(0.45 + amplitude * 0.4).toFixed(2)})`;
|
|
144
145
|
context.fillRect(index * BAR_STEP, height - barHeight, BAR_WIDTH, barHeight);
|
|
145
146
|
}
|
|
146
147
|
}, [peaks, trimEndFraction, trimStartFraction]);
|
|
@@ -157,6 +158,16 @@ export const AudioWaveform = memo(function AudioWaveform({
|
|
|
157
158
|
[draw],
|
|
158
159
|
);
|
|
159
160
|
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
const root = document.documentElement;
|
|
163
|
+
const observer = new MutationObserver(draw);
|
|
164
|
+
observer.observe(root, {
|
|
165
|
+
attributes: true,
|
|
166
|
+
attributeFilter: ["class", "data-chrome", "data-theme", "style"],
|
|
167
|
+
});
|
|
168
|
+
return () => observer.disconnect();
|
|
169
|
+
}, [draw]);
|
|
170
|
+
|
|
160
171
|
useMountEffect(() => () => observerRef.current?.disconnect());
|
|
161
172
|
|
|
162
173
|
return (
|
|
@@ -170,8 +181,7 @@ export const AudioWaveform = memo(function AudioWaveform({
|
|
|
170
181
|
<div
|
|
171
182
|
className="absolute inset-x-0 bottom-0 top-4 animate-pulse"
|
|
172
183
|
style={{
|
|
173
|
-
background:
|
|
174
|
-
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
|
|
184
|
+
background: "var(--timeline-thumbnail-shimmer)",
|
|
175
185
|
}}
|
|
176
186
|
/>
|
|
177
187
|
)}
|
|
@@ -187,8 +197,7 @@ export const AudioWaveform = memo(function AudioWaveform({
|
|
|
187
197
|
style={{
|
|
188
198
|
bottom: "20%",
|
|
189
199
|
height: 2,
|
|
190
|
-
background:
|
|
191
|
-
"repeating-linear-gradient(90deg, rgba(75,163,210,0.35) 0 2px, transparent 2px 5px)",
|
|
200
|
+
background: "var(--timeline-waveform-error)",
|
|
192
201
|
}}
|
|
193
202
|
/>
|
|
194
203
|
<span className="relative rounded-sm bg-black/50 px-1 text-[8px] text-neutral-500">
|
|
@@ -200,7 +209,7 @@ export const AudioWaveform = memo(function AudioWaveform({
|
|
|
200
209
|
<div className="absolute inset-x-0 top-0 z-10 px-1.5 py-0.5">
|
|
201
210
|
<span
|
|
202
211
|
className="block truncate text-[9px] font-semibold leading-tight"
|
|
203
|
-
style={{ color: labelColor, textShadow: "
|
|
212
|
+
style={{ color: labelColor, textShadow: "var(--timeline-waveform-label-shadow)" }}
|
|
204
213
|
>
|
|
205
214
|
{label}
|
|
206
215
|
</span>
|
|
@@ -91,8 +91,7 @@ export const ImageThumbnail = memo(function ImageThumbnail({
|
|
|
91
91
|
<div
|
|
92
92
|
className="absolute inset-0 animate-pulse"
|
|
93
93
|
style={{
|
|
94
|
-
background:
|
|
95
|
-
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
|
|
94
|
+
background: "var(--timeline-thumbnail-shimmer)",
|
|
96
95
|
}}
|
|
97
96
|
/>
|
|
98
97
|
)}
|
|
@@ -100,13 +99,12 @@ export const ImageThumbnail = memo(function ImageThumbnail({
|
|
|
100
99
|
<div
|
|
101
100
|
className="absolute inset-x-0 bottom-0 z-10 px-1.5 pb-0.5 pt-3"
|
|
102
101
|
style={{
|
|
103
|
-
background:
|
|
104
|
-
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
|
|
102
|
+
background: "var(--timeline-thumbnail-label-gradient)",
|
|
105
103
|
}}
|
|
106
104
|
>
|
|
107
105
|
<span
|
|
108
106
|
className="block truncate text-[9px] font-semibold leading-tight"
|
|
109
|
-
style={{ color: labelColor, textShadow: "
|
|
107
|
+
style={{ color: labelColor, textShadow: "var(--timeline-thumbnail-label-shadow)" }}
|
|
110
108
|
>
|
|
111
109
|
{label}
|
|
112
110
|
</span>
|
|
@@ -37,8 +37,10 @@ export function LaneToggleButton({
|
|
|
37
37
|
title={`${isExpanded ? "Hide" : "Show"} lanes`}
|
|
38
38
|
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The glyph stays 11px;
|
|
39
39
|
// only the hit box grows.
|
|
40
|
-
className={`ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] leading-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[
|
|
41
|
-
isExpanded
|
|
40
|
+
className={`ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] leading-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)] ${
|
|
41
|
+
isExpanded
|
|
42
|
+
? "text-[var(--timeline-accent)]"
|
|
43
|
+
: "text-[var(--timeline-handle)] hover:text-[var(--timeline-text-solid)]"
|
|
42
44
|
}`}
|
|
43
45
|
onPointerDown={(event) => event.stopPropagation()}
|
|
44
46
|
onClick={(event) => {
|
|
@@ -86,13 +88,16 @@ export function LayerDisclosureRow({
|
|
|
86
88
|
style={{
|
|
87
89
|
width: columnWidth,
|
|
88
90
|
height: TRACK_H,
|
|
89
|
-
color: "
|
|
91
|
+
color: "var(--timeline-text-solid)",
|
|
90
92
|
background: gutterBackground,
|
|
91
93
|
}}
|
|
92
94
|
>
|
|
93
95
|
{/* Decorative: the disclosure button above already names the row's keyframe
|
|
94
96
|
state, and aria-label on a plain span is not exposed reliably anyway. */}
|
|
95
|
-
<span
|
|
97
|
+
<span
|
|
98
|
+
aria-hidden="true"
|
|
99
|
+
className="shrink-0 text-[13px] leading-none text-[var(--timeline-text-dim)]"
|
|
100
|
+
>
|
|
96
101
|
◇
|
|
97
102
|
</span>
|
|
98
103
|
<span title={name} className="min-w-0 flex-1 truncate font-medium leading-tight">
|
|
@@ -26,8 +26,8 @@ interface PlayheadIndicatorProps {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export function PlayheadIndicator({
|
|
29
|
-
color = "var(--
|
|
30
|
-
glowColor = "
|
|
29
|
+
color = "var(--timeline-accent)",
|
|
30
|
+
glowColor = "var(--timeline-playhead-glow)",
|
|
31
31
|
scrubbing = false,
|
|
32
32
|
stickyHead = true,
|
|
33
33
|
}: PlayheadIndicatorProps) {
|
|
@@ -93,7 +93,7 @@ export function PlayheadIndicator({
|
|
|
93
93
|
background: scrubbing ? color : "transparent",
|
|
94
94
|
border: `1.5px solid ${color}`,
|
|
95
95
|
boxSizing: "border-box",
|
|
96
|
-
boxShadow: `0 1px 3px
|
|
96
|
+
boxShadow: `0 1px 3px var(--timeline-playhead-shadow), 0 0 5px ${glowColor}`,
|
|
97
97
|
transform: "rotate(45deg)",
|
|
98
98
|
}}
|
|
99
99
|
/>
|
|
@@ -135,6 +135,26 @@ function renderBasicTimeline() {
|
|
|
135
135
|
return { host, root };
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
function renderSharedAutomationTimeline(selectedElementId?: string) {
|
|
139
|
+
const host = createSizedTimelineHost(720);
|
|
140
|
+
const automation = JSON.stringify({
|
|
141
|
+
version: 1,
|
|
142
|
+
lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
|
|
143
|
+
});
|
|
144
|
+
usePlayerStore.setState({
|
|
145
|
+
duration: 8,
|
|
146
|
+
timelineReady: true,
|
|
147
|
+
...(selectedElementId ? { selectedElementId } : {}),
|
|
148
|
+
elements: [
|
|
149
|
+
{ id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation },
|
|
150
|
+
{ id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation },
|
|
151
|
+
],
|
|
152
|
+
});
|
|
153
|
+
const root = createRoot(host);
|
|
154
|
+
act(() => root.render(React.createElement(Timeline)));
|
|
155
|
+
return { host, root };
|
|
156
|
+
}
|
|
157
|
+
|
|
138
158
|
describe("Timeline provider boundary", () => {
|
|
139
159
|
it("keeps all-collapsed horizontal positions at the gutter plus the pre-t=0 pad", () => {
|
|
140
160
|
usePlayerStore.setState({
|
|
@@ -621,21 +641,7 @@ describe("Timeline provider boundary", () => {
|
|
|
621
641
|
// clip left the row's state depending on the selection, and a collapse that
|
|
622
642
|
// only dropped the active clip left the row stuck open.
|
|
623
643
|
it("expands and collapses every clip on a shared track together", () => {
|
|
624
|
-
const host =
|
|
625
|
-
const automation = JSON.stringify({
|
|
626
|
-
version: 1,
|
|
627
|
-
lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
|
|
628
|
-
});
|
|
629
|
-
usePlayerStore.setState({
|
|
630
|
-
duration: 8,
|
|
631
|
-
timelineReady: true,
|
|
632
|
-
elements: [
|
|
633
|
-
{ id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation },
|
|
634
|
-
{ id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation },
|
|
635
|
-
],
|
|
636
|
-
});
|
|
637
|
-
const root = createRoot(host);
|
|
638
|
-
act(() => root.render(React.createElement(Timeline)));
|
|
644
|
+
const { host, root } = renderSharedAutomationTimeline();
|
|
639
645
|
|
|
640
646
|
const row = host.querySelector<HTMLElement>('[data-el-id="narration-1"]')?.parentElement
|
|
641
647
|
?.parentElement;
|
|
@@ -668,22 +674,7 @@ describe("Timeline provider boundary", () => {
|
|
|
668
674
|
// a lane to select its clip therefore made the handles vanish under the
|
|
669
675
|
// pointer, which is the one gesture the read-only lane exists to support.
|
|
670
676
|
it("keeps the automation lanes mounted when the selection moves along the row", () => {
|
|
671
|
-
const host =
|
|
672
|
-
const automation = JSON.stringify({
|
|
673
|
-
version: 1,
|
|
674
|
-
lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
|
|
675
|
-
});
|
|
676
|
-
usePlayerStore.setState({
|
|
677
|
-
duration: 8,
|
|
678
|
-
timelineReady: true,
|
|
679
|
-
selectedElementId: "narration-2",
|
|
680
|
-
elements: [
|
|
681
|
-
{ id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation },
|
|
682
|
-
{ id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation },
|
|
683
|
-
],
|
|
684
|
-
});
|
|
685
|
-
const root = createRoot(host);
|
|
686
|
-
act(() => root.render(React.createElement(Timeline)));
|
|
677
|
+
const { host, root } = renderSharedAutomationTimeline("narration-2");
|
|
687
678
|
act(() => host.querySelector<HTMLButtonElement>('button[aria-label$=" lanes"]')?.click());
|
|
688
679
|
|
|
689
680
|
const before = [...host.querySelectorAll(".hf-automation-lane")];
|
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
2
|
import type { TimelineProps } from "./TimelineTypes";
|
|
3
|
-
import { TimelineEmptyState } from "./TimelineEmptyState";
|
|
4
|
-
import { TimelineCanvas } from "./TimelineCanvas";
|
|
5
|
-
import { TimelineOverlays } from "./TimelineOverlays";
|
|
6
3
|
import { TimelineProvider, useTimelineContext } from "./TimelineProvider";
|
|
4
|
+
import {
|
|
5
|
+
TimelineEmptyStatePart,
|
|
6
|
+
TimelineEditPopover,
|
|
7
|
+
TimelineClipMenu,
|
|
8
|
+
TimelineFrame,
|
|
9
|
+
TimelineGapMenu,
|
|
10
|
+
TimelineKeyframeMenu,
|
|
11
|
+
TimelineLanes,
|
|
12
|
+
TimelineOverlays,
|
|
13
|
+
TimelinePlayhead,
|
|
14
|
+
TimelineRazorGuide,
|
|
15
|
+
TimelineRuler,
|
|
16
|
+
TimelineShortcutHint,
|
|
17
|
+
} from "./TimelineParts";
|
|
7
18
|
|
|
8
19
|
export * from "./TimelineProvider";
|
|
9
20
|
export {
|
|
@@ -28,23 +39,39 @@ function TimelineView() {
|
|
|
28
39
|
const { state, meta } = useTimelineContext();
|
|
29
40
|
const { timelineReady, elements } = state;
|
|
30
41
|
if (!timelineReady || elements.length === 0) {
|
|
31
|
-
return <
|
|
42
|
+
return <TimelineEmptyStatePart />;
|
|
32
43
|
}
|
|
33
44
|
return (
|
|
34
45
|
<div {...meta.containerProps}>
|
|
35
46
|
<div {...meta.viewportProps}>
|
|
36
|
-
<
|
|
37
|
-
|
|
47
|
+
<TimelineFrame />
|
|
48
|
+
<TimelineRazorGuide />
|
|
38
49
|
</div>
|
|
39
50
|
<TimelineOverlays />
|
|
40
51
|
</div>
|
|
41
52
|
);
|
|
42
53
|
}
|
|
43
54
|
|
|
44
|
-
|
|
55
|
+
const TimelineComposed = memo(function TimelineComposed(props: TimelineProps = {}) {
|
|
45
56
|
return (
|
|
46
57
|
<TimelineProvider {...props}>
|
|
47
58
|
<TimelineView />
|
|
48
59
|
</TimelineProvider>
|
|
49
60
|
);
|
|
50
61
|
});
|
|
62
|
+
|
|
63
|
+
export const Timeline = Object.assign(TimelineComposed, {
|
|
64
|
+
Provider: TimelineProvider,
|
|
65
|
+
Frame: TimelineFrame,
|
|
66
|
+
Ruler: TimelineRuler,
|
|
67
|
+
Lanes: TimelineLanes,
|
|
68
|
+
Playhead: TimelinePlayhead,
|
|
69
|
+
RazorGuide: TimelineRazorGuide,
|
|
70
|
+
ShortcutHint: TimelineShortcutHint,
|
|
71
|
+
EditPopover: TimelineEditPopover,
|
|
72
|
+
ClipMenu: TimelineClipMenu,
|
|
73
|
+
KeyframeMenu: TimelineKeyframeMenu,
|
|
74
|
+
GapMenu: TimelineGapMenu,
|
|
75
|
+
EmptyState: TimelineEmptyStatePart,
|
|
76
|
+
Overlays: TimelineOverlays,
|
|
77
|
+
});
|
|
@@ -483,7 +483,7 @@ describe("TimelineAutomationLane", () => {
|
|
|
483
483
|
// Bigger than the points around them, and ringed rather than recoloured — the
|
|
484
484
|
// fill is the parameter's own colour and stays that way.
|
|
485
485
|
expect(Math.min(...radii)).toBeGreaterThan(Math.max(...plain));
|
|
486
|
-
expect(marked[0]?.getAttribute("stroke")).toBe("
|
|
486
|
+
expect(marked[0]?.getAttribute("stroke")).toBe("var(--timeline-text-solid)");
|
|
487
487
|
});
|
|
488
488
|
|
|
489
489
|
it("marks nothing when there is no range", () => {
|
|
@@ -75,7 +75,7 @@ function pointCircleStyle(
|
|
|
75
75
|
radius: POINT_R * (dragging ? 1.3 : inRange ? 1.15 : 1),
|
|
76
76
|
// A white ring rather than a different fill: the fill is the parameter's
|
|
77
77
|
// own colour, and a lane with two envelopes on it is read by colour first.
|
|
78
|
-
stroke: inRange ? "
|
|
78
|
+
stroke: inRange ? "var(--timeline-text-solid)" : "var(--timeline-automation-point-muted)",
|
|
79
79
|
strokeWidth: inRange ? 1.5 : 1,
|
|
80
80
|
};
|
|
81
81
|
}
|
|
@@ -132,7 +132,7 @@ function ReadOnlyNote({
|
|
|
132
132
|
return (
|
|
133
133
|
<div
|
|
134
134
|
data-automation-readonly-note=""
|
|
135
|
-
className="hf-automation-readonly-note pointer-events-none absolute rounded-[3px] bg-
|
|
135
|
+
className="hf-automation-readonly-note pointer-events-none absolute rounded-[3px] bg-[var(--timeline-overlay-bg-strong)] px-1.5 py-0.5 text-[9px] text-[var(--timeline-overlay-text)]"
|
|
136
136
|
style={{ left: leftPx + 6, top: 2, zIndex: 3, maxWidth: Math.max(120, widthPx - 12) }}
|
|
137
137
|
>
|
|
138
138
|
{note}
|
|
@@ -470,7 +470,7 @@ export function TimelineAutomationLane({
|
|
|
470
470
|
x2={PAD_X + widthPx}
|
|
471
471
|
y1={pad + inner / 2}
|
|
472
472
|
y2={pad + inner / 2}
|
|
473
|
-
stroke="
|
|
473
|
+
stroke="var(--timeline-automation-rail)"
|
|
474
474
|
strokeDasharray="3 4"
|
|
475
475
|
/>
|
|
476
476
|
{rangeSelection ? (
|
|
@@ -541,7 +541,7 @@ export function TimelineAutomationLane({
|
|
|
541
541
|
cx={xOf(playheadSec)}
|
|
542
542
|
cy={yOf(currentValue)}
|
|
543
543
|
r={2.5}
|
|
544
|
-
fill="
|
|
544
|
+
fill="var(--timeline-text-solid)"
|
|
545
545
|
opacity={0.8}
|
|
546
546
|
pointerEvents="none"
|
|
547
547
|
/>
|
|
@@ -561,7 +561,7 @@ export function TimelineAutomationLane({
|
|
|
561
561
|
|
|
562
562
|
{hint ? (
|
|
563
563
|
<div
|
|
564
|
-
className="hf-automation-hint pointer-events-none absolute rounded-[3px] bg-
|
|
564
|
+
className="hf-automation-hint pointer-events-none absolute rounded-[3px] bg-[var(--timeline-overlay-bg)] px-1 py-0.5 font-mono text-[9px] text-[var(--timeline-text-solid)]"
|
|
565
565
|
style={{ left: leftPx + 6, top: 2, zIndex: 3 }}
|
|
566
566
|
>
|
|
567
567
|
{hint}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { TimelineRulerPart } from "./TimelineRulerPart";
|
|
3
3
|
import { PlayheadIndicator } from "./PlayheadIndicator";
|
|
4
4
|
import {
|
|
5
5
|
RULER_H,
|
|
@@ -12,12 +12,8 @@ import {
|
|
|
12
12
|
getTimelineRowTop,
|
|
13
13
|
getTimelineRowHeight,
|
|
14
14
|
} from "./timelineLayout";
|
|
15
|
-
import { type MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
|
16
|
-
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
17
15
|
import { TimelineLanes } from "./TimelineLanes";
|
|
18
|
-
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
19
16
|
import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
|
|
20
|
-
import { resolveSnapGuide } from "./timelineSnapping";
|
|
21
17
|
import { useTimelineContext } from "./TimelineProvider";
|
|
22
18
|
|
|
23
19
|
// A dropped clip's length is unknown until it lands; the preview shows a default.
|
|
@@ -26,8 +22,7 @@ const DROP_PREVIEW_SECONDS = 3;
|
|
|
26
22
|
export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
27
23
|
const { state, actions } = useTimelineContext();
|
|
28
24
|
const props = state.canvas;
|
|
29
|
-
const { draggedClip,
|
|
30
|
-
const snapGuide = resolveSnapGuide(draggedClip, resizingClip);
|
|
25
|
+
const { draggedClip, scrollRef, displayTrackOrder } = props;
|
|
31
26
|
const draggedRowIndex =
|
|
32
27
|
draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1;
|
|
33
28
|
const dropTrackIndex = props.dropPreview
|
|
@@ -43,55 +38,14 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
43
38
|
// ghost and drop placeholder must clamp to it or they stretch to the full
|
|
44
39
|
// expanded row height and stop matching the clip being dragged.
|
|
45
40
|
const draggedClipHeight = Math.min(draggedRowHeight, TRACK_H) - CLIP_Y * 2;
|
|
46
|
-
const {
|
|
47
|
-
onResizeElement,
|
|
48
|
-
onMoveElement,
|
|
49
|
-
onToggleTrackHidden,
|
|
50
|
-
onTogglePropertyGroupKeyframe,
|
|
51
|
-
onRazorSplit,
|
|
52
|
-
onRazorSplitAll,
|
|
53
|
-
} = useTimelineEditContextOptional();
|
|
54
41
|
const beatDragging = props.beatDragging;
|
|
55
|
-
const draggedElement
|
|
56
|
-
const draggedElementIdentity = draggedElement ? getTimelineElementIdentity(draggedElement) : null;
|
|
57
|
-
// The drag ghost follows the cursor freely (both axes) — CapCut-style. The
|
|
58
|
-
// "magnetic" affordance is a highlight on the destination lane (draggedRowIndex),
|
|
59
|
-
// which flips at the MAGNETIC_TRACK_THRESHOLD point; the clip drops into it.
|
|
60
|
-
// Live multi-selection drag: while a selected clip is dragged, ALL selected
|
|
61
|
-
// clips move together as one rigid formation. The GRABBED clip is the free
|
|
62
|
-
// ghost below; its co-selected "passengers" slide by the SAME group-clamped
|
|
63
|
-
// delta (cheap translateX, no re-layout) — the delta is derived from the
|
|
64
|
-
// grabbed clip's ALREADY-clamped previewStart, so the whole formation stops at
|
|
65
|
-
// the wall together and never deforms. Matches what the commit will do — see
|
|
66
|
-
// timelineMultiDragPreview + commit.
|
|
67
|
-
const multiDragPreview: MultiDragPreviewInput | null =
|
|
68
|
-
draggedClip?.started === true && draggedElement && draggedElementIdentity
|
|
69
|
-
? {
|
|
70
|
-
dragStarted: true,
|
|
71
|
-
draggedKey: draggedElementIdentity,
|
|
72
|
-
draggedOriginStart: draggedElement.start,
|
|
73
|
-
draggedPreviewStart: draggedClip.previewStart,
|
|
74
|
-
selectedKeys: selectedElementIds,
|
|
75
|
-
}
|
|
76
|
-
: null;
|
|
42
|
+
const { draggedElement, snapGuide, multiDragPreview } = props;
|
|
77
43
|
return (
|
|
78
44
|
<div
|
|
79
45
|
className="relative"
|
|
80
46
|
style={{ height: props.totalH, width: props.contentOrigin + props.trackContentWidth }}
|
|
81
47
|
>
|
|
82
|
-
<
|
|
83
|
-
major={props.major}
|
|
84
|
-
minor={props.minor}
|
|
85
|
-
pps={props.pps}
|
|
86
|
-
trackContentWidth={props.trackContentWidth}
|
|
87
|
-
totalH={props.totalH}
|
|
88
|
-
effectiveDuration={props.effectiveDuration}
|
|
89
|
-
majorTickInterval={props.majorTickInterval}
|
|
90
|
-
theme={props.theme}
|
|
91
|
-
beatAnalysis={props.beatAnalysis}
|
|
92
|
-
contentOrigin={props.contentOrigin}
|
|
93
|
-
renderTimeRange={props.rowsVirtualized ? props.renderTimeRange : undefined}
|
|
94
|
-
/>
|
|
48
|
+
<TimelineRulerPart />
|
|
95
49
|
|
|
96
50
|
{/* Breathing room between the sticky ruler and the first track lane — the
|
|
97
51
|
top half of the CapCut-style padding (see TRACKS_TOP_PAD). */}
|
|
@@ -104,12 +58,12 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
104
58
|
snapGuide={snapGuide}
|
|
105
59
|
draggedElement={draggedElement}
|
|
106
60
|
multiDragPreview={multiDragPreview}
|
|
107
|
-
onToggleTrackHidden={onToggleTrackHidden}
|
|
108
|
-
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
|
|
109
|
-
onResizeElement={onResizeElement}
|
|
110
|
-
onMoveElement={onMoveElement}
|
|
111
|
-
onRazorSplit={onRazorSplit}
|
|
112
|
-
onRazorSplitAll={onRazorSplitAll}
|
|
61
|
+
onToggleTrackHidden={props.onToggleTrackHidden}
|
|
62
|
+
onTogglePropertyGroupKeyframe={props.onTogglePropertyGroupKeyframe}
|
|
63
|
+
onResizeElement={props.onResizeElement}
|
|
64
|
+
onMoveElement={props.onMoveElement}
|
|
65
|
+
onRazorSplit={props.onRazorSplit}
|
|
66
|
+
onRazorSplitAll={props.onRazorSplitAll}
|
|
113
67
|
/>
|
|
114
68
|
|
|
115
69
|
{/* Breathing room below the last track lane (~1.5 track heights) — a real
|
|
@@ -140,7 +94,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
140
94
|
left: props.contentOrigin + gap.start * props.pps,
|
|
141
95
|
width: Math.max((gap.end - gap.start) * props.pps, 2),
|
|
142
96
|
height: TRACK_H - CLIP_Y * 2,
|
|
143
|
-
background: loud ? "
|
|
97
|
+
background: loud ? "var(--timeline-accent-soft)" : "var(--timeline-accent-faint)",
|
|
144
98
|
borderRadius: 4,
|
|
145
99
|
zIndex: 25,
|
|
146
100
|
}}
|
|
@@ -158,8 +112,8 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
158
112
|
left: props.contentOrigin + draggedClip.previewStart * props.pps,
|
|
159
113
|
width: Math.max(draggedClip.element.duration * props.pps, 4),
|
|
160
114
|
height: draggedClipHeight,
|
|
161
|
-
border: "1px solid color-mix(in srgb, var(--
|
|
162
|
-
background: "color-mix(in srgb, var(--
|
|
115
|
+
border: "1px solid color-mix(in srgb, var(--timeline-accent) 55%, transparent)",
|
|
116
|
+
background: "color-mix(in srgb, var(--timeline-accent) 12%, transparent)",
|
|
163
117
|
borderRadius: 4,
|
|
164
118
|
zIndex: 30,
|
|
165
119
|
}}
|
|
@@ -178,8 +132,8 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
178
132
|
left: props.contentOrigin + props.dropPreview.start * props.pps,
|
|
179
133
|
width: DROP_PREVIEW_SECONDS * props.pps,
|
|
180
134
|
height: TRACK_H - CLIP_Y * 2,
|
|
181
|
-
border: "1px solid color-mix(in srgb, var(--
|
|
182
|
-
background: "color-mix(in srgb, var(--
|
|
135
|
+
border: "1px solid color-mix(in srgb, var(--timeline-accent) 55%, transparent)",
|
|
136
|
+
background: "color-mix(in srgb, var(--timeline-accent) 12%, transparent)",
|
|
183
137
|
borderRadius: 4,
|
|
184
138
|
zIndex: 30,
|
|
185
139
|
}}
|
|
@@ -197,8 +151,8 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
197
151
|
left: props.contentOrigin,
|
|
198
152
|
width: props.trackContentWidth,
|
|
199
153
|
height: 1,
|
|
200
|
-
background: "
|
|
201
|
-
boxShadow: "0 0 3px
|
|
154
|
+
background: "var(--timeline-accent)",
|
|
155
|
+
boxShadow: "0 0 3px var(--timeline-accent-glow)",
|
|
202
156
|
zIndex: 55,
|
|
203
157
|
}}
|
|
204
158
|
/>
|
|
@@ -213,11 +167,14 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
213
167
|
top: RULER_H,
|
|
214
168
|
bottom: 0,
|
|
215
169
|
width: 1,
|
|
216
|
-
background:
|
|
170
|
+
background:
|
|
171
|
+
snapGuide.type === "playhead"
|
|
172
|
+
? "var(--timeline-accent)"
|
|
173
|
+
: "var(--timeline-snap-guide)",
|
|
217
174
|
boxShadow:
|
|
218
175
|
snapGuide.type === "playhead"
|
|
219
|
-
? "0 0 6px
|
|
220
|
-
: "0 0 6px
|
|
176
|
+
? "0 0 6px var(--timeline-accent-glow)"
|
|
177
|
+
: "0 0 6px var(--timeline-text-dim)",
|
|
221
178
|
zIndex: 60,
|
|
222
179
|
}}
|
|
223
180
|
/>
|
|
@@ -247,8 +204,8 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
247
204
|
top: props.marqueeRect.top,
|
|
248
205
|
width: props.marqueeRect.width,
|
|
249
206
|
height: props.marqueeRect.height,
|
|
250
|
-
background: "
|
|
251
|
-
border: "1px dashed
|
|
207
|
+
background: "var(--timeline-accent-fill)",
|
|
208
|
+
border: "1px dashed var(--timeline-accent-border)",
|
|
252
209
|
borderRadius: 2,
|
|
253
210
|
zIndex: 70,
|
|
254
211
|
}}
|
|
@@ -266,9 +223,9 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
266
223
|
width: Math.abs(props.rangeSelection.end - props.rangeSelection.start) * props.pps,
|
|
267
224
|
top: RULER_H,
|
|
268
225
|
bottom: 0,
|
|
269
|
-
backgroundColor: "
|
|
270
|
-
borderLeft: "1px solid
|
|
271
|
-
borderRight: "1px solid
|
|
226
|
+
backgroundColor: "var(--timeline-info-bg)",
|
|
227
|
+
borderLeft: "1px solid var(--timeline-info-border)",
|
|
228
|
+
borderRight: "1px solid var(--timeline-info-border)",
|
|
272
229
|
zIndex: 50,
|
|
273
230
|
}}
|
|
274
231
|
/>
|
|
@@ -69,7 +69,7 @@ function renderClip({
|
|
|
69
69
|
describe("TimelineClip", () => {
|
|
70
70
|
it("renders the clip label above custom content without showing default timecode", () => {
|
|
71
71
|
const { host, root } = renderClip({
|
|
72
|
-
element: { id: "hero", label: "Hero", tag: "div", start: 1, duration:
|
|
72
|
+
element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 1, track: 0 },
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
expect(host.querySelector(".timeline-clip__label")?.textContent).toBe("Hero");
|
|
@@ -78,18 +78,55 @@ describe("TimelineClip", () => {
|
|
|
78
78
|
act(() => root.unmount());
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
-
it("
|
|
81
|
+
it("drops the label chip under 60px even when the clip is selected", () => {
|
|
82
82
|
const { host, root } = renderClip({
|
|
83
|
-
element: { id: "fx", label: "FX", tag: "div", start: 0, duration:
|
|
83
|
+
element: { id: "fx", label: "FX", tag: "div", start: 0, duration: 1, track: 0 },
|
|
84
|
+
pps: 59,
|
|
84
85
|
isSelected: true,
|
|
85
86
|
});
|
|
86
87
|
|
|
87
|
-
expect(host.querySelector(".timeline-clip__label")
|
|
88
|
+
expect(host.querySelector(".timeline-clip__label")).toBeNull();
|
|
88
89
|
expect(host.querySelector(".timeline-clip__timecode")).toBeNull();
|
|
90
|
+
expect(host.querySelector(".timeline-clip")?.getAttribute("data-ladder")).toBe("picture");
|
|
89
91
|
|
|
90
92
|
act(() => root.unmount());
|
|
91
93
|
});
|
|
92
94
|
|
|
95
|
+
it("keeps the label at 60px and fills one frame under 24px", () => {
|
|
96
|
+
const labeled = renderClip({
|
|
97
|
+
element: { id: "wide", label: "City", tag: "video", start: 0, duration: 1, track: 0 },
|
|
98
|
+
pps: 200,
|
|
99
|
+
});
|
|
100
|
+
expect(labeled.host.querySelector(".timeline-clip__label")?.textContent).toBe("City");
|
|
101
|
+
expect(labeled.host.querySelector(".timeline-clip")?.getAttribute("data-ladder")).toBe(
|
|
102
|
+
"labeled",
|
|
103
|
+
);
|
|
104
|
+
expect(labeled.host.querySelector<HTMLElement>(".timeline-clip")?.style.borderRadius).toBe(
|
|
105
|
+
"var(--timeline-clip-radius)",
|
|
106
|
+
);
|
|
107
|
+
act(() => labeled.root.unmount());
|
|
108
|
+
|
|
109
|
+
const frame = renderClip({
|
|
110
|
+
element: { id: "sliver", label: "City", tag: "img", start: 0, duration: 1, track: 0 },
|
|
111
|
+
pps: 23,
|
|
112
|
+
isSelected: true,
|
|
113
|
+
});
|
|
114
|
+
expect(frame.host.querySelector(".timeline-clip__label")).toBeNull();
|
|
115
|
+
expect(frame.host.querySelector(".timeline-clip")?.getAttribute("data-ladder")).toBe("frame");
|
|
116
|
+
act(() => frame.root.unmount());
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("gives audio clips the pill radius", () => {
|
|
120
|
+
const { host, root } = renderClip({
|
|
121
|
+
element: { id: "vo", label: "Voice", tag: "audio", start: 0, duration: 2, track: 1 },
|
|
122
|
+
pps: 100,
|
|
123
|
+
});
|
|
124
|
+
expect(host.querySelector<HTMLElement>(".timeline-clip")?.style.borderRadius).toBe(
|
|
125
|
+
"var(--timeline-clip-audio-radius)",
|
|
126
|
+
);
|
|
127
|
+
act(() => root.unmount());
|
|
128
|
+
});
|
|
129
|
+
|
|
93
130
|
it("marks hidden clips for active-state suppression", () => {
|
|
94
131
|
const { host, root } = renderClip({
|
|
95
132
|
element: {
|