@hyperframes/studio 0.7.38 → 0.7.40
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/{index-ClUipc8i.js → index-89kPtC4s.js} +1 -1
- package/dist/assets/{index-BLRTwY5l.js → index-B2Utv-2b.js} +200 -200
- package/dist/assets/index-BpM6cnfP.css +1 -0
- package/dist/assets/{index-Ykq7ihge.js → index-CjX8Ljc8.js} +1 -1
- package/dist/{chunk-JND3XUJL.js → chunk-BA66NM4L.js} +1 -1
- package/dist/chunk-BA66NM4L.js.map +1 -0
- package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-H7LDFIJ7.js} +2 -2
- package/dist/index.d.ts +47 -29
- package/dist/index.html +2 -2
- package/dist/index.js +4432 -3045
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +9 -7
- package/src/components/StudioPreviewArea.tsx +14 -0
- package/src/components/StudioRightPanel.tsx +9 -0
- package/src/components/editor/DomEditCropHandles.tsx +238 -0
- package/src/components/editor/DomEditOverlay.tsx +140 -151
- package/src/components/editor/DomEditRotateHandle.tsx +41 -0
- package/src/components/editor/PropertyPanel.test.ts +73 -0
- package/src/components/editor/PropertyPanel.tsx +42 -37
- package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
- package/src/components/editor/SnapToolbar.tsx +20 -1
- package/src/components/editor/clipPathHelpers.ts +113 -0
- package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
- package/src/components/editor/domEditOverlayCrop.ts +115 -0
- package/src/components/editor/domEditOverlayGeometry.ts +14 -0
- package/src/components/editor/domEditOverlayShape.ts +39 -0
- package/src/components/editor/domEditing.test.ts +2 -0
- package/src/components/editor/domEditingElement.ts +8 -1
- package/src/components/editor/domEditingTypes.ts +3 -0
- package/src/components/editor/manualEditsDom.ts +1 -18
- package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
- package/src/components/editor/marqueeCommit.ts +3 -2
- package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
- package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
- package/src/components/editor/propertyPanelHelpers.ts +21 -34
- package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
- package/src/components/editor/propertyPanelTypes.ts +3 -0
- package/src/components/editor/snapTargetCollection.ts +2 -3
- package/src/components/editor/useDomEditCompositionRect.ts +68 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
- package/src/components/editor/useDomEditOverlayRects.ts +5 -3
- package/src/components/sidebar/AssetsTab.test.ts +87 -0
- package/src/components/sidebar/AssetsTab.tsx +113 -15
- package/src/components/sidebar/BlocksTab.tsx +2 -1
- package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/hooks/domSelectionTestHarness.ts +1 -0
- package/src/hooks/gsapDragCommit.test.ts +56 -0
- package/src/hooks/gsapDragCommit.ts +41 -109
- package/src/hooks/gsapDragPositionCommit.ts +3 -0
- package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
- package/src/hooks/gsapRuntimeBridge.ts +23 -2
- package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
- package/src/hooks/timelineEditingHelpers.ts +17 -5
- package/src/hooks/timelineTrackVisibility.test.ts +202 -0
- package/src/hooks/timelineTrackVisibility.ts +371 -0
- package/src/hooks/useCropMode.ts +91 -0
- package/src/hooks/useDomEditCommits.test.tsx +2 -0
- package/src/hooks/useRenderClipContent.test.ts +68 -2
- package/src/hooks/useRenderClipContent.ts +5 -6
- package/src/hooks/useTimelineEditing.ts +35 -30
- package/src/hooks/useTimelineEditingTypes.ts +30 -0
- package/src/icons/SystemIcons.tsx +12 -4
- package/src/player/components/AudioWaveform.tsx +11 -8
- package/src/player/components/CompositionThumbnail.tsx +14 -11
- package/src/player/components/PlayheadIndicator.tsx +21 -11
- package/src/player/components/ShortcutsPanel.tsx +9 -0
- package/src/player/components/Timeline.test.ts +105 -5
- package/src/player/components/Timeline.tsx +37 -47
- package/src/player/components/TimelineCanvas.tsx +275 -256
- package/src/player/components/TimelineClip.test.tsx +105 -0
- package/src/player/components/TimelineClip.tsx +44 -57
- package/src/player/components/TimelineRuler.tsx +17 -23
- package/src/player/components/TimelineShortcutHint.tsx +26 -0
- package/src/player/components/TimelineTypes.ts +18 -0
- package/src/player/components/VideoThumbnail.tsx +16 -13
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineIcons.tsx +2 -44
- package/src/player/components/timelineLayout.ts +18 -22
- package/src/player/components/timelineMotionStyles.test.ts +101 -0
- package/src/player/components/timelineTheme.test.ts +21 -4
- package/src/player/components/timelineTheme.ts +15 -15
- package/src/player/components/useTimelineActiveClips.test.ts +93 -0
- package/src/player/components/useTimelineActiveClips.ts +125 -0
- package/src/player/components/useTimelinePlayhead.ts +6 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
- package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
- package/src/player/lib/time.test.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +48 -1
- package/src/player/lib/timelineDOM.ts +7 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +19 -1
- package/src/styles/studio.css +108 -0
- package/src/utils/sourcePatcher.test.ts +11 -0
- package/src/utils/sourcePatcher.ts +6 -4
- package/dist/assets/index-DJaiR8T2.css +0 -1
- package/dist/chunk-JND3XUJL.js.map +0 -1
- /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-H7LDFIJ7.js.map} +0 -0
|
@@ -0,0 +1,105 @@
|
|
|
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, vi } from "vitest";
|
|
6
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
7
|
+
import { TimelineClip } from "./TimelineClip";
|
|
8
|
+
import type { TimelineEditCapabilities } from "./timelineEditing";
|
|
9
|
+
|
|
10
|
+
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
11
|
+
configurable: true,
|
|
12
|
+
value: true,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
document.body.innerHTML = "";
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const capabilities: TimelineEditCapabilities = {
|
|
20
|
+
canMove: true,
|
|
21
|
+
canTrimStart: true,
|
|
22
|
+
canTrimEnd: true,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function renderClip({
|
|
26
|
+
element,
|
|
27
|
+
pps = 100,
|
|
28
|
+
isSelected = false,
|
|
29
|
+
hasCustomContent = true,
|
|
30
|
+
}: {
|
|
31
|
+
element: TimelineElement;
|
|
32
|
+
pps?: number;
|
|
33
|
+
isSelected?: boolean;
|
|
34
|
+
hasCustomContent?: boolean;
|
|
35
|
+
}) {
|
|
36
|
+
const host = document.createElement("div");
|
|
37
|
+
document.body.append(host);
|
|
38
|
+
const root = createRoot(host);
|
|
39
|
+
|
|
40
|
+
act(() => {
|
|
41
|
+
root.render(
|
|
42
|
+
<TimelineClip
|
|
43
|
+
el={element}
|
|
44
|
+
pps={pps}
|
|
45
|
+
clipY={0}
|
|
46
|
+
isSelected={isSelected}
|
|
47
|
+
isHovered={false}
|
|
48
|
+
hasCustomContent={hasCustomContent}
|
|
49
|
+
capabilities={capabilities}
|
|
50
|
+
isComposition={false}
|
|
51
|
+
onHoverStart={vi.fn()}
|
|
52
|
+
onHoverEnd={vi.fn()}
|
|
53
|
+
onClick={vi.fn()}
|
|
54
|
+
onDoubleClick={vi.fn()}
|
|
55
|
+
>
|
|
56
|
+
<div data-custom-content="true" />
|
|
57
|
+
</TimelineClip>,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return { host, root };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("TimelineClip", () => {
|
|
65
|
+
it("renders the clip label above custom content without showing default timecode", () => {
|
|
66
|
+
const { host, root } = renderClip({
|
|
67
|
+
element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 0.5, track: 0 },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(host.querySelector(".timeline-clip__label")?.textContent).toBe("Hero");
|
|
71
|
+
expect(host.querySelector(".timeline-clip__timecode")).toBeNull();
|
|
72
|
+
|
|
73
|
+
act(() => root.unmount());
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("keeps selected narrow clips labeled even when they render custom content", () => {
|
|
77
|
+
const { host, root } = renderClip({
|
|
78
|
+
element: { id: "fx", label: "FX", tag: "div", start: 0, duration: 0.1, track: 0 },
|
|
79
|
+
isSelected: true,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(host.querySelector(".timeline-clip__label")?.textContent).toBe("FX");
|
|
83
|
+
expect(host.querySelector(".timeline-clip__timecode")).toBeNull();
|
|
84
|
+
|
|
85
|
+
act(() => root.unmount());
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("marks hidden clips for active-state suppression", () => {
|
|
89
|
+
const { host, root } = renderClip({
|
|
90
|
+
element: {
|
|
91
|
+
id: "hidden",
|
|
92
|
+
label: "Hidden",
|
|
93
|
+
tag: "div",
|
|
94
|
+
start: 0,
|
|
95
|
+
duration: 1,
|
|
96
|
+
track: 0,
|
|
97
|
+
hidden: true,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(host.querySelector(".timeline-clip")?.getAttribute("data-clip-hidden")).toBe("true");
|
|
102
|
+
|
|
103
|
+
act(() => root.unmount());
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import type
|
|
2
|
-
|
|
3
|
-
import { memo, type ReactNode } from "react";
|
|
1
|
+
import { memo, type CSSProperties, type ReactNode } from "react";
|
|
4
2
|
import type { TimelineElement } from "../store/playerStore";
|
|
5
3
|
import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "./timelineTheme";
|
|
6
4
|
import type { TimelineEditCapabilities } from "./timelineEditing";
|
|
@@ -15,7 +13,6 @@ interface TimelineClipProps {
|
|
|
15
13
|
hasCustomContent: boolean;
|
|
16
14
|
capabilities: TimelineEditCapabilities;
|
|
17
15
|
theme?: TimelineTheme;
|
|
18
|
-
trackStyle: TimelineTrackStyle;
|
|
19
16
|
isComposition: boolean;
|
|
20
17
|
onHoverStart: () => void;
|
|
21
18
|
onHoverEnd: () => void;
|
|
@@ -27,6 +24,7 @@ interface TimelineClipProps {
|
|
|
27
24
|
children?: ReactNode;
|
|
28
25
|
}
|
|
29
26
|
|
|
27
|
+
// fallow-ignore-next-line complexity
|
|
30
28
|
export const TimelineClip = memo(function TimelineClip({
|
|
31
29
|
el,
|
|
32
30
|
pps,
|
|
@@ -37,7 +35,6 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
37
35
|
hasCustomContent,
|
|
38
36
|
capabilities,
|
|
39
37
|
theme = defaultTimelineTheme,
|
|
40
|
-
trackStyle,
|
|
41
38
|
isComposition,
|
|
42
39
|
onHoverStart,
|
|
43
40
|
onHoverEnd,
|
|
@@ -51,45 +48,43 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
51
48
|
const leftPx = el.start * pps;
|
|
52
49
|
const widthPx = Math.max(el.duration * pps, 4);
|
|
53
50
|
const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging });
|
|
54
|
-
|
|
55
|
-
const borderColor = isSelected
|
|
56
|
-
? trackStyle.accent
|
|
57
|
-
: isHovered
|
|
58
|
-
? theme.clipBorderHover
|
|
59
|
-
: theme.clipBorder;
|
|
60
|
-
const boxShadow = isDragging
|
|
61
|
-
? theme.clipShadowDragging
|
|
62
|
-
: isSelected
|
|
63
|
-
? `0 0 0 1px ${trackStyle.accent}80, 0 0 8px ${trackStyle.accent}25`
|
|
64
|
-
: isHovered
|
|
65
|
-
? theme.clipShadowHover
|
|
66
|
-
: theme.clipShadow;
|
|
67
51
|
const displayLabel = el.label || el.id || el.tag;
|
|
68
|
-
const showHandles = handleOpacity > 0.01;
|
|
52
|
+
const showHandles = handleOpacity > 0.01 && (widthPx >= 32 || isSelected);
|
|
53
|
+
const showLabel = widthPx >= 40 || isSelected;
|
|
54
|
+
const showDefaultText = !hasCustomContent && (widthPx >= 40 || isSelected);
|
|
55
|
+
const startLabel = el.start.toFixed(1);
|
|
56
|
+
const endLabel = (el.start + el.duration).toFixed(1);
|
|
57
|
+
const clipClassName = [
|
|
58
|
+
"timeline-clip",
|
|
59
|
+
"absolute",
|
|
60
|
+
hasCustomContent ? "overflow-visible" : "overflow-hidden",
|
|
61
|
+
isSelected ? "is-selected" : "",
|
|
62
|
+
isHovered ? "is-hovered" : "",
|
|
63
|
+
isDragging ? "is-dragging" : "",
|
|
64
|
+
showDefaultText ? "" : "is-micro",
|
|
65
|
+
]
|
|
66
|
+
.filter((className) => className.length > 0)
|
|
67
|
+
.join(" ");
|
|
68
|
+
const style: CSSProperties = {
|
|
69
|
+
left: leftPx,
|
|
70
|
+
width: widthPx,
|
|
71
|
+
top: clipY,
|
|
72
|
+
bottom: clipY,
|
|
73
|
+
borderRadius: theme.clipRadius,
|
|
74
|
+
zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
|
|
75
|
+
cursor: capabilities.canMove ? "grab" : "default",
|
|
76
|
+
transform: isDragging ? "translateY(-1px)" : undefined,
|
|
77
|
+
};
|
|
69
78
|
|
|
70
79
|
return (
|
|
71
80
|
<div
|
|
72
81
|
data-clip="true"
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
style={
|
|
79
|
-
left: leftPx,
|
|
80
|
-
width: widthPx,
|
|
81
|
-
top: clipY,
|
|
82
|
-
bottom: clipY,
|
|
83
|
-
borderRadius: theme.clipRadius,
|
|
84
|
-
background: trackStyle.clip,
|
|
85
|
-
border: `1px solid ${borderColor}`,
|
|
86
|
-
boxShadow,
|
|
87
|
-
transition: "border-color 100ms, box-shadow 100ms",
|
|
88
|
-
zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
|
|
89
|
-
cursor: capabilities.canMove ? "grab" : "default",
|
|
90
|
-
transform: isDragging ? "translateY(-1px)" : undefined,
|
|
91
|
-
opacity: isDragging ? 0.92 : 1,
|
|
92
|
-
}}
|
|
82
|
+
data-el-id={el.key ?? el.id}
|
|
83
|
+
data-clip-start={el.start}
|
|
84
|
+
data-clip-end={el.start + el.duration}
|
|
85
|
+
data-clip-hidden={el.hidden ? "true" : undefined}
|
|
86
|
+
className={clipClassName}
|
|
87
|
+
style={style}
|
|
93
88
|
title={
|
|
94
89
|
isComposition
|
|
95
90
|
? `${el.compositionSrc} • Double-click to open`
|
|
@@ -102,22 +97,6 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
102
97
|
onDoubleClick={onDoubleClick}
|
|
103
98
|
onContextMenu={onContextMenu}
|
|
104
99
|
>
|
|
105
|
-
{/* Left accent stripe — wider + brighter for expanded sub-comp children */}
|
|
106
|
-
<div
|
|
107
|
-
aria-hidden="true"
|
|
108
|
-
style={{
|
|
109
|
-
position: "absolute",
|
|
110
|
-
left: 0,
|
|
111
|
-
top: 0,
|
|
112
|
-
bottom: 0,
|
|
113
|
-
width: el.expandedParentStart !== undefined ? 4 : 3,
|
|
114
|
-
background: trackStyle.accent,
|
|
115
|
-
opacity: el.expandedParentStart !== undefined ? 0.8 : isSelected ? 0.7 : 0.3,
|
|
116
|
-
borderRadius: `${theme.clipRadius} 0 0 ${theme.clipRadius}`,
|
|
117
|
-
zIndex: 2,
|
|
118
|
-
pointerEvents: "none",
|
|
119
|
-
}}
|
|
120
|
-
/>
|
|
121
100
|
{/* Left trim handle */}
|
|
122
101
|
{showHandles && capabilities.canTrimStart && (
|
|
123
102
|
<div
|
|
@@ -134,6 +113,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
134
113
|
}}
|
|
135
114
|
>
|
|
136
115
|
<div
|
|
116
|
+
className="timeline-clip__handle-bar"
|
|
137
117
|
style={{
|
|
138
118
|
position: "absolute",
|
|
139
119
|
left: 4,
|
|
@@ -141,7 +121,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
141
121
|
bottom: 6,
|
|
142
122
|
width: 2,
|
|
143
123
|
borderRadius: 1,
|
|
144
|
-
background:
|
|
124
|
+
background: "rgba(255, 255, 255, 0.55)",
|
|
145
125
|
opacity: handleOpacity * 0.6,
|
|
146
126
|
}}
|
|
147
127
|
/>
|
|
@@ -163,6 +143,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
163
143
|
}}
|
|
164
144
|
>
|
|
165
145
|
<div
|
|
146
|
+
className="timeline-clip__handle-bar"
|
|
166
147
|
style={{
|
|
167
148
|
position: "absolute",
|
|
168
149
|
right: 4,
|
|
@@ -170,12 +151,18 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
170
151
|
bottom: 6,
|
|
171
152
|
width: 2,
|
|
172
153
|
borderRadius: 1,
|
|
173
|
-
background:
|
|
154
|
+
background: "rgba(255, 255, 255, 0.55)",
|
|
174
155
|
opacity: handleOpacity * 0.6,
|
|
175
156
|
}}
|
|
176
157
|
/>
|
|
177
158
|
</div>
|
|
178
159
|
)}
|
|
160
|
+
{showLabel && <span className="timeline-clip__label">{displayLabel}</span>}
|
|
161
|
+
{showDefaultText && (
|
|
162
|
+
<span className="timeline-clip__timecode">
|
|
163
|
+
{startLabel}-{endLabel}s
|
|
164
|
+
</span>
|
|
165
|
+
)}
|
|
179
166
|
{children}
|
|
180
167
|
</div>
|
|
181
168
|
);
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
2
|
import type { TimelineTheme } from "./timelineTheme";
|
|
3
|
-
import type { TimelineRangeSelection } from "./timelineEditing";
|
|
4
3
|
import { GUTTER, RULER_H, formatTimelineTickLabel } from "./timelineLayout";
|
|
5
4
|
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
|
6
5
|
|
|
@@ -12,8 +11,6 @@ interface TimelineRulerProps {
|
|
|
12
11
|
totalH: number;
|
|
13
12
|
effectiveDuration: number;
|
|
14
13
|
majorTickInterval: number;
|
|
15
|
-
shiftHeld: boolean;
|
|
16
|
-
rangeSelection: TimelineRangeSelection | null;
|
|
17
14
|
theme: TimelineTheme;
|
|
18
15
|
beatAnalysis?: MusicBeatAnalysis | null;
|
|
19
16
|
}
|
|
@@ -26,8 +23,6 @@ export const TimelineRuler = memo(function TimelineRuler({
|
|
|
26
23
|
totalH,
|
|
27
24
|
effectiveDuration,
|
|
28
25
|
majorTickInterval,
|
|
29
|
-
shiftHeld,
|
|
30
|
-
rangeSelection,
|
|
31
26
|
theme,
|
|
32
27
|
beatAnalysis,
|
|
33
28
|
}: TimelineRulerProps) {
|
|
@@ -87,35 +82,34 @@ export const TimelineRuler = memo(function TimelineRuler({
|
|
|
87
82
|
{/* Ruler */}
|
|
88
83
|
<div
|
|
89
84
|
className="relative overflow-hidden"
|
|
90
|
-
style={{
|
|
85
|
+
style={{
|
|
86
|
+
height: RULER_H,
|
|
87
|
+
marginLeft: GUTTER,
|
|
88
|
+
width: trackContentWidth,
|
|
89
|
+
background: theme.gutterBackground,
|
|
90
|
+
borderBottom: `1px solid ${theme.rulerBorder}`,
|
|
91
|
+
}}
|
|
91
92
|
>
|
|
92
|
-
{shiftHeld && !rangeSelection && (
|
|
93
|
-
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
|
94
|
-
<span className="text-[9px] font-medium" style={{ color: theme.textSecondary }}>
|
|
95
|
-
Drag or click a clip to edit range
|
|
96
|
-
</span>
|
|
97
|
-
</div>
|
|
98
|
-
)}
|
|
99
|
-
|
|
100
93
|
{minor.map((t) => (
|
|
101
94
|
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
|
|
102
|
-
<div className="w-px h-
|
|
95
|
+
<div className="w-px h-2" style={{ background: theme.tickMinor }} />
|
|
103
96
|
</div>
|
|
104
97
|
))}
|
|
105
98
|
|
|
106
99
|
{major.map((t) => (
|
|
107
|
-
<div
|
|
108
|
-
key={`M-${t}`}
|
|
109
|
-
className="absolute bottom-0 flex flex-col items-center"
|
|
110
|
-
style={{ left: t * pps }}
|
|
111
|
-
>
|
|
100
|
+
<div key={`M-${t}`} className="absolute top-0" style={{ left: t * pps }}>
|
|
112
101
|
<span
|
|
113
|
-
className="
|
|
114
|
-
style={{
|
|
102
|
+
className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
|
|
103
|
+
style={{
|
|
104
|
+
color: theme.tickText,
|
|
105
|
+
left: 5,
|
|
106
|
+
top: 5,
|
|
107
|
+
fontSize: 10,
|
|
108
|
+
}}
|
|
115
109
|
>
|
|
116
110
|
{formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
|
|
117
111
|
</span>
|
|
118
|
-
<div className="w-px
|
|
112
|
+
<div className="w-px" style={{ height: RULER_H, background: theme.tickMajor }} />
|
|
119
113
|
</div>
|
|
120
114
|
))}
|
|
121
115
|
</div>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { TimelineTheme } from "./timelineTheme";
|
|
2
|
+
|
|
3
|
+
interface TimelineShortcutHintProps {
|
|
4
|
+
theme: TimelineTheme;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function TimelineShortcutHint({ theme }: TimelineShortcutHintProps) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="absolute bottom-2 right-3 pointer-events-none z-20">
|
|
10
|
+
<div
|
|
11
|
+
className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
|
|
12
|
+
style={{ background: "rgba(17,23,35,0.84)", borderColor: theme.gutterBorder }}
|
|
13
|
+
>
|
|
14
|
+
<kbd
|
|
15
|
+
className="text-[9px] font-mono px-1 py-0.5 rounded"
|
|
16
|
+
style={{ color: theme.textSecondary, background: "rgba(255,255,255,0.06)" }}
|
|
17
|
+
>
|
|
18
|
+
Shift
|
|
19
|
+
</kbd>
|
|
20
|
+
<span className="text-[9px]" style={{ color: theme.textSecondary }}>
|
|
21
|
+
+ drag/click to edit range
|
|
22
|
+
</span>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
|
4
|
+
import type { TimelineTheme } from "./timelineTheme";
|
|
5
|
+
import type { TimelineEditOverrides } from "./useResolvedTimelineEditCallbacks";
|
|
6
|
+
|
|
7
|
+
export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides {
|
|
8
|
+
onSeek?: (time: number) => void;
|
|
9
|
+
onDrillDown?: (element: TimelineElement) => void;
|
|
10
|
+
renderClipContent?: (
|
|
11
|
+
element: TimelineElement,
|
|
12
|
+
style: { clip: string; label: string },
|
|
13
|
+
) => ReactNode;
|
|
14
|
+
renderClipOverlay?: (element: TimelineElement) => ReactNode;
|
|
15
|
+
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
|
|
16
|
+
onSelectElement?: (element: TimelineElement | null) => void;
|
|
17
|
+
theme?: Partial<TimelineTheme>;
|
|
18
|
+
}
|
|
@@ -47,6 +47,7 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
|
|
47
47
|
},
|
|
48
48
|
{ rootMargin: "200px" },
|
|
49
49
|
);
|
|
50
|
+
// fallow-ignore-next-line code-duplication
|
|
50
51
|
ioRef.current.observe(el);
|
|
51
52
|
|
|
52
53
|
const target = el.parentElement || el;
|
|
@@ -177,20 +178,22 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
|
|
177
178
|
/>
|
|
178
179
|
)}
|
|
179
180
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
<span
|
|
188
|
-
className="text-[9px] font-semibold truncate block leading-tight"
|
|
189
|
-
style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
|
|
181
|
+
{label && (
|
|
182
|
+
<div
|
|
183
|
+
className="absolute bottom-0 left-0 right-0 z-10 px-1.5 pb-0.5 pt-3"
|
|
184
|
+
style={{
|
|
185
|
+
background:
|
|
186
|
+
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
|
|
187
|
+
}}
|
|
190
188
|
>
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
189
|
+
<span
|
|
190
|
+
className="text-[9px] font-semibold truncate block leading-tight"
|
|
191
|
+
style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
|
|
192
|
+
>
|
|
193
|
+
{label}
|
|
194
|
+
</span>
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
194
197
|
</div>
|
|
195
198
|
);
|
|
196
199
|
});
|
|
@@ -32,6 +32,7 @@ export interface TimelineEditCallbacks {
|
|
|
32
32
|
element: TimelineElement,
|
|
33
33
|
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
34
34
|
) => Promise<void> | void;
|
|
35
|
+
onToggleTrackHidden?: (track: number, hidden: boolean) => Promise<void> | void;
|
|
35
36
|
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
36
37
|
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
37
38
|
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
@@ -1,52 +1,10 @@
|
|
|
1
|
-
import type { ReactNode } from "react";
|
|
2
1
|
import { getTimelineTrackStyle, type TimelineTrackStyle } from "./timelineTheme";
|
|
3
2
|
|
|
4
|
-
export
|
|
5
|
-
icon: ReactNode;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
const ICON_BASE = "/icons/timeline";
|
|
9
|
-
function TimelineIcon({ src }: { src: string }) {
|
|
10
|
-
return (
|
|
11
|
-
<img
|
|
12
|
-
src={src}
|
|
13
|
-
alt=""
|
|
14
|
-
width={12}
|
|
15
|
-
height={12}
|
|
16
|
-
style={{ filter: "brightness(0) invert(1)" }}
|
|
17
|
-
draggable={false}
|
|
18
|
-
/>
|
|
19
|
-
);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const IconCaptions = <TimelineIcon src={`${ICON_BASE}/captions.svg`} />;
|
|
23
|
-
const IconImage = <TimelineIcon src={`${ICON_BASE}/image.svg`} />;
|
|
24
|
-
const IconMusic = <TimelineIcon src={`${ICON_BASE}/music.svg`} />;
|
|
25
|
-
const IconText = <TimelineIcon src={`${ICON_BASE}/text.svg`} />;
|
|
26
|
-
const IconComposition = <TimelineIcon src={`${ICON_BASE}/composition.svg`} />;
|
|
27
|
-
const IconAudio = <TimelineIcon src={`${ICON_BASE}/audio.svg`} />;
|
|
28
|
-
|
|
29
|
-
const ICONS: Record<string, ReactNode> = {
|
|
30
|
-
video: IconImage,
|
|
31
|
-
audio: IconMusic,
|
|
32
|
-
img: IconImage,
|
|
33
|
-
div: IconComposition,
|
|
34
|
-
span: IconCaptions,
|
|
35
|
-
p: IconText,
|
|
36
|
-
h1: IconText,
|
|
37
|
-
section: IconComposition,
|
|
38
|
-
sfx: IconAudio,
|
|
39
|
-
};
|
|
3
|
+
export type TrackVisualStyle = TimelineTrackStyle;
|
|
40
4
|
|
|
41
5
|
export function getTrackStyle(tag: string): TrackVisualStyle {
|
|
42
6
|
// Defensive: callers may pass an empty/undefined tag; fall back to "div"
|
|
43
7
|
// (restores the #1679 null-guard that a restack had dropped).
|
|
44
8
|
const safeTag = tag || "div";
|
|
45
|
-
|
|
46
|
-
const normalized = safeTag.toLowerCase();
|
|
47
|
-
const icon =
|
|
48
|
-
normalized.startsWith("h") && normalized.length === 2 && "123456".includes(normalized[1] ?? "")
|
|
49
|
-
? ICONS.h1
|
|
50
|
-
: (ICONS[normalized] ?? IconComposition);
|
|
51
|
-
return { ...trackStyle, icon };
|
|
9
|
+
return getTimelineTrackStyle(safeTag);
|
|
52
10
|
}
|
|
@@ -11,9 +11,9 @@ const TIMELINE_SCROLL_BUFFER = 20;
|
|
|
11
11
|
|
|
12
12
|
/* ── Tick generation ──────────────────────────────────────────────── */
|
|
13
13
|
function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
|
|
14
|
-
const zoomIntervals = [0.
|
|
14
|
+
const zoomIntervals = [0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
|
|
15
15
|
if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
|
|
16
|
-
const targetMajorPx =
|
|
16
|
+
const targetMajorPx = 88;
|
|
17
17
|
return (
|
|
18
18
|
zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600
|
|
19
19
|
);
|
|
@@ -24,20 +24,14 @@ function getMajorTickInterval(duration: number, pixelsPerSecond?: number): numbe
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
function getMinorTickInterval(majorInterval: number, pixelsPerSecond?: number): number {
|
|
27
|
-
let interval = majorInterval / 2;
|
|
28
|
-
if (majorInterval >= 30) interval = majorInterval / 6;
|
|
29
|
-
else if (majorInterval >= 15) interval = majorInterval / 3;
|
|
30
|
-
else if (majorInterval >= 5) interval = majorInterval / 5;
|
|
31
|
-
else if (majorInterval >= 1) interval = majorInterval / 4;
|
|
32
|
-
|
|
33
27
|
if (
|
|
34
28
|
Number.isFinite(pixelsPerSecond) &&
|
|
35
29
|
(pixelsPerSecond ?? 0) > 0 &&
|
|
36
|
-
|
|
30
|
+
(majorInterval / 2) * (pixelsPerSecond ?? 0) < 12
|
|
37
31
|
) {
|
|
38
|
-
return
|
|
32
|
+
return 0;
|
|
39
33
|
}
|
|
40
|
-
return
|
|
34
|
+
return majorInterval / 2;
|
|
41
35
|
}
|
|
42
36
|
|
|
43
37
|
export function generateTicks(
|
|
@@ -51,17 +45,13 @@ export function generateTicks(
|
|
|
51
45
|
const major: number[] = [];
|
|
52
46
|
const minor: number[] = [];
|
|
53
47
|
const maxTicks = 2000; // Safety cap to prevent runaway tick generation
|
|
54
|
-
for (
|
|
55
|
-
let t = 0;
|
|
56
|
-
t <= duration + 0.001 && major.length + minor.length < maxTicks;
|
|
57
|
-
t += minorInterval
|
|
58
|
-
) {
|
|
48
|
+
for (let t = 0; t <= duration + 0.001 && major.length < maxTicks; t += majorInterval) {
|
|
59
49
|
const rounded = Math.round(t * 100) / 100;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
Math.
|
|
63
|
-
|
|
64
|
-
|
|
50
|
+
major.push(rounded);
|
|
51
|
+
if (minorInterval > 0 && major.length + minor.length < maxTicks) {
|
|
52
|
+
const midpoint = Math.round((t + minorInterval) * 100) / 100;
|
|
53
|
+
if (midpoint <= duration + 0.001) minor.push(midpoint);
|
|
54
|
+
}
|
|
65
55
|
}
|
|
66
56
|
return { major, minor };
|
|
67
57
|
}
|
|
@@ -69,6 +59,12 @@ export function generateTicks(
|
|
|
69
59
|
export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
|
|
70
60
|
if (!Number.isFinite(time)) return "0:00";
|
|
71
61
|
const safeTime = Math.max(0, time);
|
|
62
|
+
if (majorInterval < 0.1) {
|
|
63
|
+
const totalHundredths = Math.round(safeTime * 100);
|
|
64
|
+
const wholeSeconds = Math.floor(totalHundredths / 100);
|
|
65
|
+
const hundredth = totalHundredths % 100;
|
|
66
|
+
return `${formatTime(wholeSeconds)}.${hundredth.toString().padStart(2, "0")}`;
|
|
67
|
+
}
|
|
72
68
|
if (majorInterval < 1) {
|
|
73
69
|
const totalTenths = Math.round(safeTime * 10);
|
|
74
70
|
const wholeSeconds = Math.floor(totalTenths / 10);
|
|
@@ -101,7 +97,7 @@ export function getTimelineScrollLeftForZoomTransition(
|
|
|
101
97
|
nextZoomMode: ZoomMode,
|
|
102
98
|
currentScrollLeft: number,
|
|
103
99
|
): number {
|
|
104
|
-
if (
|
|
100
|
+
if (nextZoomMode === "fit") return 0;
|
|
105
101
|
return currentScrollLeft;
|
|
106
102
|
}
|
|
107
103
|
|