@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
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { TimelineCanvas as TimelineCanvasImpl } from "./TimelineCanvas";
|
|
2
|
+
import {
|
|
3
|
+
TimelineClipMenuOverlay as TimelineClipMenuImpl,
|
|
4
|
+
TimelineEditPopoverOverlay as TimelineEditPopoverImpl,
|
|
5
|
+
TimelineGapMenuOverlay as TimelineGapMenuImpl,
|
|
6
|
+
TimelineKeyframeMenuOverlay as TimelineKeyframeMenuImpl,
|
|
7
|
+
TimelineShortcutHintOverlay as TimelineShortcutHintImpl,
|
|
8
|
+
} from "./TimelineOverlays";
|
|
9
|
+
import { TimelineLanes as TimelineLanesImpl } from "./TimelineLanes";
|
|
10
|
+
import { PlayheadIndicator } from "./PlayheadIndicator";
|
|
11
|
+
import { useTimelineContext } from "./TimelineProvider";
|
|
12
|
+
import { TimelineEmptyState } from "./TimelineEmptyState";
|
|
13
|
+
import { TimelineRulerPart } from "./TimelineRulerPart";
|
|
14
|
+
|
|
15
|
+
/** The provider-backed canvas shell. */
|
|
16
|
+
export function TimelineFrame() {
|
|
17
|
+
return <TimelineCanvasImpl />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The provider-backed ruler. */
|
|
21
|
+
export const TimelineRuler = TimelineRulerPart;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The provider-backed lane renderer. TimelineFrame remains the composed Studio
|
|
25
|
+
* canvas; this part is exported for hosts that own the canvas arrangement.
|
|
26
|
+
*/
|
|
27
|
+
export function TimelineLanes() {
|
|
28
|
+
const { state, actions } = useTimelineContext();
|
|
29
|
+
const props = state.canvas;
|
|
30
|
+
return (
|
|
31
|
+
<TimelineLanesImpl
|
|
32
|
+
{...props}
|
|
33
|
+
renderClipContent={actions.renderClipContent}
|
|
34
|
+
renderClipOverlay={actions.renderClipOverlay}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The provider-backed playhead indicator. */
|
|
40
|
+
export function TimelinePlayhead() {
|
|
41
|
+
const { state } = useTimelineContext();
|
|
42
|
+
const props = state.canvas;
|
|
43
|
+
return <PlayheadIndicator scrubbing={props.isScrubbing} />;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The provider-backed razor guide. */
|
|
47
|
+
export function TimelineRazorGuide() {
|
|
48
|
+
const { meta } = useTimelineContext();
|
|
49
|
+
return meta.razorGuide;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The composed overlay surface used by the Studio variant. */
|
|
53
|
+
export { TimelineOverlays } from "./TimelineOverlays";
|
|
54
|
+
|
|
55
|
+
export function TimelineEmptyStatePart() {
|
|
56
|
+
const { meta } = useTimelineContext();
|
|
57
|
+
return <TimelineEmptyState {...meta.emptyState} />;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function TimelineShortcutHint() {
|
|
61
|
+
return <TimelineShortcutHintImpl />;
|
|
62
|
+
}
|
|
63
|
+
export function TimelineEditPopover() {
|
|
64
|
+
return <TimelineEditPopoverImpl />;
|
|
65
|
+
}
|
|
66
|
+
export function TimelineClipMenu() {
|
|
67
|
+
return <TimelineClipMenuImpl />;
|
|
68
|
+
}
|
|
69
|
+
export function TimelineKeyframeMenu() {
|
|
70
|
+
return <TimelineKeyframeMenuImpl />;
|
|
71
|
+
}
|
|
72
|
+
export function TimelineGapMenu() {
|
|
73
|
+
return <TimelineGapMenuImpl />;
|
|
74
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { Timeline } from "./Timeline";
|
|
7
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
8
|
+
import { useTimelineContext } from "./TimelineProvider";
|
|
9
|
+
|
|
10
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
document.body.innerHTML = "";
|
|
14
|
+
usePlayerStore.getState().reset();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function TimelinePartsVariant() {
|
|
18
|
+
const { meta } = useTimelineContext();
|
|
19
|
+
return React.createElement(
|
|
20
|
+
"div",
|
|
21
|
+
meta.containerProps,
|
|
22
|
+
React.createElement(
|
|
23
|
+
"div",
|
|
24
|
+
meta.viewportProps,
|
|
25
|
+
React.createElement(Timeline.Frame),
|
|
26
|
+
React.createElement(Timeline.RazorGuide),
|
|
27
|
+
),
|
|
28
|
+
React.createElement(Timeline.Overlays),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("Timeline provider boundary", () => {
|
|
33
|
+
it("keeps the composed Timeline markup equal to its provider parts", () => {
|
|
34
|
+
usePlayerStore.setState({
|
|
35
|
+
duration: 4,
|
|
36
|
+
timelineReady: true,
|
|
37
|
+
elements: [{ id: "parity-clip", tag: "div", start: 0, duration: 2, track: 0 }],
|
|
38
|
+
});
|
|
39
|
+
const composedHost = document.createElement("div");
|
|
40
|
+
const partsHost = document.createElement("div");
|
|
41
|
+
document.body.append(composedHost, partsHost);
|
|
42
|
+
const composedRoot = createRoot(composedHost);
|
|
43
|
+
const partsRoot = createRoot(partsHost);
|
|
44
|
+
act(() => {
|
|
45
|
+
composedRoot.render(React.createElement(Timeline));
|
|
46
|
+
partsRoot.render(
|
|
47
|
+
React.createElement(Timeline.Provider, null, React.createElement(TimelinePartsVariant)),
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
const normalizeMarkup = (markup: string) =>
|
|
51
|
+
markup.replaceAll(/timeline-lanes_[^"]+/g, "timeline-lanes");
|
|
52
|
+
expect(normalizeMarkup(partsHost.innerHTML)).toBe(normalizeMarkup(composedHost.innerHTML));
|
|
53
|
+
act(() => {
|
|
54
|
+
composedRoot.unmount();
|
|
55
|
+
partsRoot.unmount();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -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
|
|
|
@@ -17,6 +17,8 @@ import type { Rect } from "../../utils/marqueeGeometry";
|
|
|
17
17
|
import type { ResizingClipState } from "./useTimelineClipDrag";
|
|
18
18
|
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
|
|
19
19
|
import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
|
|
20
|
+
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
|
21
|
+
import type { TimelineSnapTarget } from "./timelineSnapping";
|
|
20
22
|
import type { TimelineElement } from "../store/playerStore";
|
|
21
23
|
import type { KeyframeCacheEntry } from "../store/keyframeSlice";
|
|
22
24
|
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
|
@@ -24,6 +26,10 @@ import type { TimelineTheme } from "./timelineTheme";
|
|
|
24
26
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
25
27
|
import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
|
26
28
|
import { useTimelineProviderState } from "./useTimelineProviderState";
|
|
29
|
+
import {
|
|
30
|
+
TimelineEditProvider,
|
|
31
|
+
useTimelineEditContextValue,
|
|
32
|
+
} from "../../contexts/TimelineEditContext";
|
|
27
33
|
|
|
28
34
|
export type TimelineCanvasState = Omit<
|
|
29
35
|
TimelineLaneBaseProps,
|
|
@@ -51,6 +57,13 @@ export type TimelineCanvasState = Omit<
|
|
|
51
57
|
beatDragging: boolean;
|
|
52
58
|
onResizeElement: TimelineEditCallbacks["onResizeElement"];
|
|
53
59
|
onMoveElement: TimelineEditCallbacks["onMoveElement"];
|
|
60
|
+
draggedElement: TimelineElement | null;
|
|
61
|
+
snapGuide: TimelineSnapTarget | null;
|
|
62
|
+
multiDragPreview: MultiDragPreviewInput | null;
|
|
63
|
+
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
64
|
+
onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
|
65
|
+
onRazorSplit: TimelineEditCallbacks["onRazorSplit"];
|
|
66
|
+
onRazorSplitAll: TimelineEditCallbacks["onRazorSplitAll"];
|
|
54
67
|
};
|
|
55
68
|
|
|
56
69
|
export interface ClipContextMenuState {
|
|
@@ -166,6 +179,18 @@ export interface TimelineContextValue {
|
|
|
166
179
|
const TimelineContext = createContext<TimelineContextValue | null>(null);
|
|
167
180
|
|
|
168
181
|
export function TimelineProvider({ children, ...props }: TimelineProps & { children: ReactNode }) {
|
|
182
|
+
const editContext = useTimelineEditContextValue();
|
|
183
|
+
if (!editContext) {
|
|
184
|
+
return (
|
|
185
|
+
<TimelineEditProvider value={props}>
|
|
186
|
+
<TimelineProviderState {...props}>{children}</TimelineProviderState>
|
|
187
|
+
</TimelineEditProvider>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return <TimelineProviderState {...props}>{children}</TimelineProviderState>;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function TimelineProviderState({ children, ...props }: TimelineProps & { children: ReactNode }) {
|
|
169
194
|
const value = useTimelineProviderState(props);
|
|
170
195
|
return <TimelineContext.Provider value={value}>{children}</TimelineContext.Provider>;
|
|
171
196
|
}
|
|
@@ -50,11 +50,11 @@ export function useTimelineRazorInteraction({
|
|
|
50
50
|
return { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
export function
|
|
53
|
+
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
|
}}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { TimelineRuler as TimelineRulerImpl } from "./TimelineRuler";
|
|
2
|
+
import { useTimelineContext } from "./TimelineProvider";
|
|
3
|
+
|
|
4
|
+
export function TimelineRulerPart() {
|
|
5
|
+
const { state } = useTimelineContext();
|
|
6
|
+
const props = state.canvas;
|
|
7
|
+
return (
|
|
8
|
+
<TimelineRulerImpl
|
|
9
|
+
major={props.major}
|
|
10
|
+
minor={props.minor}
|
|
11
|
+
pps={props.pps}
|
|
12
|
+
trackContentWidth={props.trackContentWidth}
|
|
13
|
+
totalH={props.totalH}
|
|
14
|
+
effectiveDuration={props.effectiveDuration}
|
|
15
|
+
majorTickInterval={props.majorTickInterval}
|
|
16
|
+
theme={props.theme}
|
|
17
|
+
beatAnalysis={props.beatAnalysis}
|
|
18
|
+
contentOrigin={props.contentOrigin}
|
|
19
|
+
renderTimeRange={props.rowsVirtualized ? props.renderTimeRange : undefined}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -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
|
|
@@ -112,8 +112,7 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
|
|
112
112
|
<div
|
|
113
113
|
className="absolute inset-0 animate-pulse motion-reduce:animate-none"
|
|
114
114
|
style={{
|
|
115
|
-
background:
|
|
116
|
-
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
|
|
115
|
+
background: "var(--timeline-thumbnail-shimmer)",
|
|
117
116
|
}}
|
|
118
117
|
/>
|
|
119
118
|
)}
|
|
@@ -128,13 +127,12 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
|
|
128
127
|
<div
|
|
129
128
|
className="absolute inset-x-0 bottom-0 z-10 px-1.5 pb-0.5 pt-3"
|
|
130
129
|
style={{
|
|
131
|
-
background:
|
|
132
|
-
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
|
|
130
|
+
background: "var(--timeline-thumbnail-label-gradient)",
|
|
133
131
|
}}
|
|
134
132
|
>
|
|
135
133
|
<span
|
|
136
134
|
className="block truncate text-[9px] font-semibold leading-tight"
|
|
137
|
-
style={{ color: labelColor, textShadow: "
|
|
135
|
+
style={{ color: labelColor, textShadow: "var(--timeline-thumbnail-label-shadow)" }}
|
|
138
136
|
>
|
|
139
137
|
{label}
|
|
140
138
|
</span>
|
|
@@ -2,17 +2,17 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
3
|
|
|
4
4
|
const studioCss = readFileSync(new URL("../../styles/studio.css", import.meta.url), "utf8");
|
|
5
|
+
const themeCss = readFileSync(new URL("../../styles/theme.css", import.meta.url), "utf8");
|
|
6
|
+
const timelineOverlaySources = [
|
|
7
|
+
"TimelineShortcutHint.tsx",
|
|
8
|
+
"LayerDisclosureRow.tsx",
|
|
9
|
+
"ImageThumbnail.tsx",
|
|
10
|
+
"AudioWaveform.tsx",
|
|
11
|
+
"VideoThumbnail.tsx",
|
|
12
|
+
].map((fileName) => readFileSync(new URL(`./${fileName}`, import.meta.url), "utf8"));
|
|
5
13
|
const timelineClipSource = readFileSync(new URL("./TimelineClip.tsx", import.meta.url), "utf8");
|
|
6
14
|
const playheadSource = readFileSync(new URL("./PlayheadIndicator.tsx", import.meta.url), "utf8");
|
|
7
15
|
|
|
8
|
-
const allowedTimelineTransitionProperties = [
|
|
9
|
-
"background-color",
|
|
10
|
-
"border-color",
|
|
11
|
-
"box-shadow",
|
|
12
|
-
"color",
|
|
13
|
-
"opacity",
|
|
14
|
-
];
|
|
15
|
-
|
|
16
16
|
function expectRule(css: string, selector: string): string {
|
|
17
17
|
const selectorStart = css.indexOf(`${selector} {`);
|
|
18
18
|
expect(selectorStart).toBeGreaterThanOrEqual(0);
|
|
@@ -31,28 +31,26 @@ function expectDeclaration(ruleBody: string, property: string): string {
|
|
|
31
31
|
return declarationMatch?.[1].trim() ?? "";
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
function
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
for (const char of transitionDeclaration) {
|
|
40
|
-
if (char === "(") depth += 1;
|
|
41
|
-
if (char === ")") depth -= 1;
|
|
42
|
-
if (char === "," && depth === 0) {
|
|
43
|
-
items.push(item.trim());
|
|
44
|
-
item = "";
|
|
45
|
-
continue;
|
|
46
|
-
}
|
|
47
|
-
item += char;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (item.trim().length > 0) items.push(item.trim());
|
|
51
|
-
|
|
52
|
-
return items.map((transition) => transition.split(/\s+/)[0]);
|
|
34
|
+
function themeTokenValue(token: string): string {
|
|
35
|
+
const match = new RegExp(`${token}:\\s*([^;]+);`).exec(themeCss);
|
|
36
|
+
expect(match?.[1]).toBeDefined();
|
|
37
|
+
return match?.[1].trim() ?? "";
|
|
53
38
|
}
|
|
54
39
|
|
|
55
40
|
describe("timeline motion styles", () => {
|
|
41
|
+
it.each([
|
|
42
|
+
["--timeline-track-label", "rgba(255, 255, 255, 0.5)"],
|
|
43
|
+
["--timeline-tick-text", "rgba(255, 255, 255, 0.34)"],
|
|
44
|
+
["--timeline-border-strong", "rgba(255, 255, 255, 0.2)"],
|
|
45
|
+
["--timeline-group-member-tint", "rgba(255, 255, 255, 0.035)"],
|
|
46
|
+
["--timeline-overlay-text", "rgba(255, 255, 255, 0.8)"],
|
|
47
|
+
["--timeline-clip-shadow-dragging", "rgba(0, 0, 0, 0.4)"],
|
|
48
|
+
["--timeline-playhead-glow", "rgba(60, 230, 172, 0.14)"],
|
|
49
|
+
["--timeline-playhead-shadow", "rgba(0, 0, 0, 0.55)"],
|
|
50
|
+
])("keeps the migrated default for %s", (token, expected) => {
|
|
51
|
+
expect(themeTokenValue(token)).toBe(expected);
|
|
52
|
+
});
|
|
53
|
+
|
|
56
54
|
it("keeps clip motion reduced-motion gated and layout safe", () => {
|
|
57
55
|
const mediaStart = studioCss.indexOf("@media (prefers-reduced-motion: no-preference)");
|
|
58
56
|
expect(mediaStart).toBeGreaterThanOrEqual(0);
|
|
@@ -63,19 +61,73 @@ describe("timeline motion styles", () => {
|
|
|
63
61
|
|
|
64
62
|
const motionMediaCss = studioCss.slice(mediaStart);
|
|
65
63
|
const timelineClipMotionRule = expectRule(motionMediaCss, ".timeline-clip");
|
|
66
|
-
const clipTransition = expectDeclaration(timelineClipMotionRule, "transition");
|
|
67
64
|
|
|
68
|
-
expect(
|
|
69
|
-
|
|
65
|
+
expect(expectDeclaration(timelineClipMotionRule, "transition")).toBe(
|
|
66
|
+
"background-color 120ms ease-out",
|
|
67
|
+
);
|
|
70
68
|
});
|
|
71
69
|
|
|
72
70
|
it("layers the active mint bloom through opacity instead of a gradient background swap", () => {
|
|
73
71
|
const baseTimelineClipRule = expectRule(studioCss, ".timeline-clip");
|
|
72
|
+
const timelineClipLabelRule = expectRule(studioCss, ".timeline-clip__label");
|
|
73
|
+
const timelineClipTimecodeRule = expectRule(studioCss, ".timeline-clip__timecode");
|
|
74
|
+
const audioClipRule = expectRule(studioCss, ".timeline-clip.is-audio");
|
|
75
|
+
const audioClipHoverRule = expectRule(studioCss, ".timeline-clip.is-audio.is-hovered");
|
|
76
|
+
const audioClipDraggingRule = expectRule(studioCss, ".timeline-clip.is-audio.is-dragging");
|
|
74
77
|
const activeTimelineClipRule = expectRule(studioCss, ".timeline-clip[data-active]");
|
|
78
|
+
const selectedTimelineClipRule = expectRule(studioCss, ".timeline-clip.is-selected");
|
|
79
|
+
const activeSelectedTimelineClipRule = expectRule(
|
|
80
|
+
studioCss,
|
|
81
|
+
".timeline-clip[data-active].is-selected",
|
|
82
|
+
);
|
|
75
83
|
const bloomOverlayRule = expectRule(studioCss, ".timeline-clip::before");
|
|
76
84
|
const activeBloomOverlayRule = expectRule(studioCss, ".timeline-clip[data-active]::before");
|
|
77
85
|
|
|
78
86
|
expect(baseTimelineClipRule).toContain("background-color: var(--clip-bg)");
|
|
87
|
+
expect(baseTimelineClipRule).toContain("border: 1px solid var(--clip-border)");
|
|
88
|
+
expect(baseTimelineClipRule).toContain("box-shadow:");
|
|
89
|
+
expect(baseTimelineClipRule).toContain("inset 0 1px 0 var(--timeline-clip-highlight)");
|
|
90
|
+
expect(baseTimelineClipRule).toContain("var(--timeline-clip-shadow)");
|
|
91
|
+
expect(timelineClipLabelRule).toContain("background: var(--timeline-clip-chip-bg)");
|
|
92
|
+
expect(timelineClipLabelRule).toContain("color: var(--timeline-clip-chip-text)");
|
|
93
|
+
expect(timelineClipLabelRule).toContain("text-overflow: ellipsis");
|
|
94
|
+
expect(timelineClipLabelRule).toContain("max-width: calc(100% - 22px)");
|
|
95
|
+
expect(timelineClipTimecodeRule).toContain("color: var(--timeline-tick-text)");
|
|
96
|
+
expect(themeCss).toContain("--timeline-clip-bg: rgba(255, 255, 255, 0.12)");
|
|
97
|
+
expect(themeCss).toContain("--timeline-clip-border: rgba(255, 255, 255, 0.22)");
|
|
98
|
+
expect(themeCss).toContain("--timeline-track-label: rgba(255, 255, 255, 0.5)");
|
|
99
|
+
expect(themeCss).toContain("--timeline-tick-text: rgba(255, 255, 255, 0.34)");
|
|
100
|
+
expect(themeCss).toContain("--timeline-clip-label-active: rgba(232, 255, 247, 0.95)");
|
|
101
|
+
expect(themeCss).toContain("--timeline-clip-label-shadow: 0 1px 2px rgba(0, 0, 0, 0.85)");
|
|
102
|
+
expect(themeCss).toContain("--timeline-clip-selection: rgba(255, 255, 255, 0.85)");
|
|
103
|
+
expect(themeCss).toContain("--timeline-clip-selection-highlight: rgb(255 255 255 / 0.55)");
|
|
104
|
+
expect(themeCss).toContain("--timeline-clip-radius: 8px");
|
|
105
|
+
expect(themeCss).toContain("--timeline-clip-audio-radius: 21px");
|
|
106
|
+
expect(themeCss).toContain("--timeline-clip-highlight: rgb(255 255 255 / 0.08)");
|
|
107
|
+
expect(themeCss).toContain("--timeline-clip-shadow: 0 2px 6px rgb(0 0 0 / 0.3)");
|
|
108
|
+
expect(themeCss).toContain("--timeline-clip-chip-bg: rgba(15, 22, 18, 0.73)");
|
|
109
|
+
expect(themeCss).toContain("--timeline-clip-chip-audio-bg: rgba(25, 16, 38, 0.7)");
|
|
110
|
+
expect(themeCss).toContain("--timeline-clip-chip-text: #ffffff");
|
|
111
|
+
expect(themeCss).toContain("--timeline-row-bg: #edf0ed");
|
|
112
|
+
expect(themeCss).toContain("--timeline-clip-shadow: 0 2px 6px rgb(0 0 0 / 0.12)");
|
|
113
|
+
expect(themeCss).toContain("--timeline-clip-selection: #0f766e");
|
|
114
|
+
expect(themeCss).toContain("--timeline-clip-audio-bg: rgba(167, 139, 250, 0.16)");
|
|
115
|
+
expect(themeCss).toContain("--timeline-clip-audio-border: rgba(167, 139, 250, 0.4)");
|
|
116
|
+
expect(themeCss).toContain("--timeline-clip-audio-bg-hover: rgba(167, 139, 250, 0.24)");
|
|
117
|
+
expect(themeCss).toContain("--timeline-clip-audio-bg-dragging: rgba(60, 52, 84, 0.96)");
|
|
118
|
+
expect(audioClipRule).toContain("background-color: var(--timeline-clip-audio-bg)");
|
|
119
|
+
expect(audioClipRule).toContain("border-color: var(--timeline-clip-audio-border)");
|
|
120
|
+
expect(audioClipHoverRule).toContain("background-color: var(--timeline-clip-audio-bg-hover)");
|
|
121
|
+
expect(audioClipDraggingRule).toContain("var(--timeline-clip-audio-bg-dragging)");
|
|
122
|
+
expect(themeCss).toContain("--timeline-clip-selection: rgba(255, 255, 255, 0.85)");
|
|
123
|
+
expect(selectedTimelineClipRule).toContain(
|
|
124
|
+
"outline: 1.5px solid var(--timeline-clip-selection)",
|
|
125
|
+
);
|
|
126
|
+
expect(selectedTimelineClipRule).toContain("outline-offset: -1.5px");
|
|
127
|
+
expect(activeSelectedTimelineClipRule).toContain(
|
|
128
|
+
"outline: 1.5px solid var(--timeline-clip-selection)",
|
|
129
|
+
);
|
|
130
|
+
expect(activeSelectedTimelineClipRule).toContain("outline-offset: -1.5px");
|
|
79
131
|
expect(activeTimelineClipRule).not.toContain("background: linear-gradient");
|
|
80
132
|
expect(activeTimelineClipRule).toContain("border-color: var(--clip-border-active)");
|
|
81
133
|
expect(activeTimelineClipRule).not.toContain("box-shadow");
|
|
@@ -85,6 +137,20 @@ describe("timeline motion styles", () => {
|
|
|
85
137
|
expect(activeBloomOverlayRule).toContain("opacity: 1");
|
|
86
138
|
});
|
|
87
139
|
|
|
140
|
+
it("routes timeline overlay colours through theme tokens", () => {
|
|
141
|
+
const overlaySource = timelineOverlaySources.join("\n");
|
|
142
|
+
|
|
143
|
+
expect(overlaySource).toContain("var(--timeline-shortcut-bg)");
|
|
144
|
+
expect(overlaySource).toContain("var(--timeline-thumbnail-shimmer)");
|
|
145
|
+
expect(overlaySource).toContain("var(--timeline-thumbnail-label-gradient)");
|
|
146
|
+
expect(overlaySource).toContain("var(--timeline-thumbnail-label-shadow)");
|
|
147
|
+
expect(overlaySource).toContain('"--timeline-waveform-bar-rgb"');
|
|
148
|
+
expect(overlaySource).toContain("var(--timeline-waveform-error)");
|
|
149
|
+
expect(overlaySource).toContain("var(--timeline-waveform-label-shadow)");
|
|
150
|
+
expect(overlaySource).toContain("var(--timeline-text-solid)");
|
|
151
|
+
expect(overlaySource).toContain("var(--timeline-accent)");
|
|
152
|
+
});
|
|
153
|
+
|
|
88
154
|
it("targets trim handle bars without changing drag geometry", () => {
|
|
89
155
|
const handleClassMatches = timelineClipSource.match(/className="timeline-clip__handle-bar"/g);
|
|
90
156
|
|
|
@@ -5,6 +5,26 @@ import type {
|
|
|
5
5
|
} from "./TimelineProvider";
|
|
6
6
|
import type { ResizingClipState } from "./useTimelineClipDrag";
|
|
7
7
|
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
|
|
8
|
+
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
9
|
+
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
|
10
|
+
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
11
|
+
|
|
12
|
+
export function resolveMultiDragPreview(
|
|
13
|
+
draggedClip: DraggedClipState | null,
|
|
14
|
+
selectedKeys: ReadonlySet<string>,
|
|
15
|
+
): MultiDragPreviewInput | null {
|
|
16
|
+
// The dragged clip is a free ghost; selected companions follow the same
|
|
17
|
+
// clamped delta so the formation stays rigid at the lane boundary.
|
|
18
|
+
if (!draggedClip?.started) return null;
|
|
19
|
+
const draggedKey = getTimelineElementIdentity(draggedClip.element);
|
|
20
|
+
return {
|
|
21
|
+
dragStarted: true,
|
|
22
|
+
draggedKey,
|
|
23
|
+
draggedOriginStart: draggedClip.element.start,
|
|
24
|
+
draggedPreviewStart: draggedClip.previewStart,
|
|
25
|
+
selectedKeys,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
8
28
|
|
|
9
29
|
export function resolveResizingElementIds(
|
|
10
30
|
resizingClip: ResizingClipState | null,
|
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
|
+
clipWidthLadder,
|
|
4
|
+
defaultTimelineTheme,
|
|
3
5
|
getClipHandleOpacity,
|
|
4
6
|
getRenderedTimelineElement,
|
|
5
7
|
getTimelineTrackStyle,
|
|
6
8
|
} from "./timelineTheme";
|
|
7
9
|
import { getTrackStyle } from "./timelineIcons";
|
|
8
10
|
|
|
11
|
+
describe("clipWidthLadder", () => {
|
|
12
|
+
it("labels from 60px, keeps the picture below that, and uses one frame below 24px", () => {
|
|
13
|
+
expect(clipWidthLadder(200)).toBe("labeled");
|
|
14
|
+
expect(clipWidthLadder(60)).toBe("labeled");
|
|
15
|
+
expect(clipWidthLadder(59)).toBe("picture");
|
|
16
|
+
expect(clipWidthLadder(24)).toBe("picture");
|
|
17
|
+
expect(clipWidthLadder(23)).toBe("frame");
|
|
18
|
+
expect(defaultTimelineTheme.clipRadius).toBe("var(--timeline-clip-radius)");
|
|
19
|
+
expect(defaultTimelineTheme.audioClipRadius).toBe("var(--timeline-clip-audio-radius)");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
9
23
|
describe("getTimelineTrackStyle", () => {
|
|
10
24
|
it("uses one neutral clip style for every timeline tag", () => {
|
|
11
25
|
const expectedStyle = {
|
|
@@ -27,14 +27,11 @@ export interface TimelineTheme {
|
|
|
27
27
|
clipBorder: string;
|
|
28
28
|
clipBorderHover: string;
|
|
29
29
|
clipBorderActive: string;
|
|
30
|
-
clipShadow: string;
|
|
31
|
-
clipShadowHover: string;
|
|
32
|
-
clipShadowActive: string;
|
|
33
|
-
clipShadowDragging: string;
|
|
34
30
|
handleColor: string;
|
|
35
31
|
panelResizeSeam: string;
|
|
36
32
|
panelResizeActive: string;
|
|
37
33
|
clipRadius: string;
|
|
34
|
+
audioClipRadius: string;
|
|
38
35
|
}
|
|
39
36
|
|
|
40
37
|
const TRACK_STYLE: TimelineTrackStyle = {
|
|
@@ -67,18 +64,22 @@ export const defaultTimelineTheme: TimelineTheme = {
|
|
|
67
64
|
clipBorder: "var(--timeline-clip-border)",
|
|
68
65
|
clipBorderHover: "var(--timeline-clip-border-hover)",
|
|
69
66
|
clipBorderActive: "var(--timeline-clip-border-active)",
|
|
70
|
-
// Shadows stay literal: a light host still wants a dark contact shadow, so
|
|
71
|
-
// these are not part of the surface/hairline/text/clip-colour theming ask.
|
|
72
|
-
clipShadow: "none",
|
|
73
|
-
clipShadowHover: "0 2px 8px rgba(0,0,0,0.2)",
|
|
74
|
-
clipShadowActive: "0 2px 8px rgba(0,0,0,0.2), 0 0 0 1px rgba(255,255,255,0.04)",
|
|
75
|
-
clipShadowDragging: "0 8px 24px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.06)",
|
|
76
67
|
handleColor: "var(--timeline-handle)",
|
|
77
68
|
panelResizeSeam: "var(--timeline-resize-seam)",
|
|
78
69
|
panelResizeActive: "var(--timeline-resize-active)",
|
|
79
|
-
clipRadius: "var(--radius
|
|
70
|
+
clipRadius: "var(--timeline-clip-radius)",
|
|
71
|
+
audioClipRadius: "var(--timeline-clip-audio-radius)",
|
|
80
72
|
};
|
|
81
73
|
|
|
74
|
+
export type ClipWidthLadder = "labeled" | "picture" | "frame";
|
|
75
|
+
|
|
76
|
+
/** Label chip from 60px. Under 24px the clip is one cropped frame. */
|
|
77
|
+
export function clipWidthLadder(widthPx: number): ClipWidthLadder {
|
|
78
|
+
if (widthPx < 24) return "frame";
|
|
79
|
+
if (widthPx < 60) return "picture";
|
|
80
|
+
return "labeled";
|
|
81
|
+
}
|
|
82
|
+
|
|
82
83
|
export function getTimelineTrackStyle(_tag: string): TimelineTrackStyle {
|
|
83
84
|
return TRACK_STYLE;
|
|
84
85
|
}
|