@hyperframes/studio 0.6.89 → 0.6.91
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-CgYcO2PV.js +146 -0
- package/dist/index.html +1 -1
- package/package.json +4 -4
- package/src/App.tsx +2 -0
- package/src/components/StudioPreviewArea.tsx +6 -0
- package/src/components/TimelineToolbar.tsx +52 -14
- package/src/components/editor/manualEditingAvailability.test.ts +12 -0
- package/src/components/editor/manualEditingAvailability.ts +12 -0
- package/src/components/nle/NLELayout.tsx +6 -18
- package/src/components/nle/TimelineEditorNotice.tsx +2 -25
- package/src/hooks/useAppHotkeys.ts +48 -1
- package/src/hooks/useContextMenuDismiss.ts +29 -0
- package/src/hooks/useDomEditSession.ts +7 -4
- package/src/hooks/useRazorSplit.ts +303 -0
- package/src/hooks/useTimelineEditing.ts +15 -98
- package/src/player/components/ClipContextMenu.tsx +5 -21
- package/src/player/components/KeyframeDiamondContextMenu.tsx +3 -20
- package/src/player/components/PlayheadIndicator.tsx +43 -0
- package/src/player/components/Timeline.tsx +38 -35
- package/src/player/components/TimelineCanvas.tsx +29 -22
- package/src/player/components/timelineCallbacks.ts +44 -0
- package/src/player/components/timelineDragDrop.ts +2 -14
- package/src/player/components/useTimelineZoom.ts +18 -0
- package/src/player/store/playerStore.ts +8 -0
- package/src/utils/timelineElementSplit.ts +16 -0
- package/dist/assets/index-2SbRRd33.js +0 -146
|
@@ -2,12 +2,12 @@ import { useRef, useMemo, useCallback, useState, useEffect, memo, type ReactNode
|
|
|
2
2
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
3
3
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
4
|
import { EditPopover } from "./EditModal";
|
|
5
|
-
import { type BlockedTimelineEditIntent } from "./timelineEditing";
|
|
6
5
|
import { defaultTimelineTheme, type TimelineTheme } from "./timelineTheme";
|
|
7
6
|
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
|
|
8
7
|
import { useTimelinePlayhead } from "./useTimelinePlayhead";
|
|
9
8
|
import { type TrackVisualStyle, getTrackStyle } from "./timelineIcons";
|
|
10
9
|
import { getTimelinePixelsPerSecond } from "./timelineZoom";
|
|
10
|
+
import { useTimelineZoom } from "./useTimelineZoom";
|
|
11
11
|
import { useTimelineAssetDrop } from "./timelineDragDrop";
|
|
12
12
|
import { TimelineEmptyState } from "./TimelineEmptyState";
|
|
13
13
|
import { TimelineCanvas } from "./TimelineCanvas";
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
getTimelineCanvasHeight,
|
|
24
24
|
shouldShowTimelineShortcutHint,
|
|
25
25
|
} from "./timelineLayout";
|
|
26
|
+
import type { TimelineEditCallbacks, TimelineDropCallbacks } from "./timelineCallbacks";
|
|
26
27
|
|
|
27
28
|
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
|
|
28
29
|
export {
|
|
@@ -39,7 +40,7 @@ export {
|
|
|
39
40
|
getDefaultDroppedTrack,
|
|
40
41
|
} from "./timelineLayout";
|
|
41
42
|
|
|
42
|
-
interface TimelineProps {
|
|
43
|
+
interface TimelineProps extends TimelineEditCallbacks, TimelineDropCallbacks {
|
|
43
44
|
onSeek?: (time: number) => void;
|
|
44
45
|
onDrillDown?: (element: TimelineElement) => void;
|
|
45
46
|
renderClipContent?: (
|
|
@@ -47,35 +48,8 @@ interface TimelineProps {
|
|
|
47
48
|
style: { clip: string; label: string },
|
|
48
49
|
) => ReactNode;
|
|
49
50
|
renderClipOverlay?: (element: TimelineElement) => ReactNode;
|
|
50
|
-
onFileDrop?: (
|
|
51
|
-
files: File[],
|
|
52
|
-
placement?: { start: number; track: number },
|
|
53
|
-
) => Promise<void> | void;
|
|
54
|
-
onAssetDrop?: (
|
|
55
|
-
assetPath: string,
|
|
56
|
-
placement: { start: number; track: number },
|
|
57
|
-
) => Promise<void> | void;
|
|
58
|
-
onBlockDrop?: (
|
|
59
|
-
blockName: string,
|
|
60
|
-
placement: { start: number; track: number },
|
|
61
|
-
) => Promise<void> | void;
|
|
62
51
|
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
|
|
63
|
-
onMoveElement?: (
|
|
64
|
-
element: TimelineElement,
|
|
65
|
-
updates: Pick<TimelineElement, "start" | "track">,
|
|
66
|
-
) => Promise<void> | void;
|
|
67
|
-
onResizeElement?: (
|
|
68
|
-
element: TimelineElement,
|
|
69
|
-
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
70
|
-
) => Promise<void> | void;
|
|
71
|
-
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
72
|
-
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
73
52
|
onSelectElement?: (element: TimelineElement | null) => void;
|
|
74
|
-
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
|
|
75
|
-
onDeleteAllKeyframes?: (elementId: string) => void;
|
|
76
|
-
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
|
|
77
|
-
onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
|
|
78
|
-
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
|
|
79
53
|
theme?: Partial<TimelineTheme>;
|
|
80
54
|
}
|
|
81
55
|
|
|
@@ -92,6 +66,8 @@ export const Timeline = memo(function Timeline({
|
|
|
92
66
|
onResizeElement,
|
|
93
67
|
onBlockedEditAttempt,
|
|
94
68
|
onSplitElement,
|
|
69
|
+
onRazorSplit,
|
|
70
|
+
onRazorSplitAll,
|
|
95
71
|
onSelectElement,
|
|
96
72
|
onDeleteKeyframe,
|
|
97
73
|
onDeleteAllKeyframes,
|
|
@@ -107,17 +83,16 @@ export const Timeline = memo(function Timeline({
|
|
|
107
83
|
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
|
108
84
|
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
109
85
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
110
|
-
const zoomMode =
|
|
111
|
-
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
|
112
|
-
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
|
|
113
|
-
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
|
|
86
|
+
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
|
|
114
87
|
|
|
115
88
|
const playheadRef = useRef<HTMLDivElement>(null);
|
|
116
89
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
117
90
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
91
|
+
const activeTool = usePlayerStore((s) => s.activeTool);
|
|
118
92
|
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
|
|
119
93
|
const isDragging = useRef(false);
|
|
120
94
|
const [shiftHeld, setShiftHeld] = useState(false);
|
|
95
|
+
const [razorGuideX, setRazorGuideX] = useState<number | null>(null);
|
|
121
96
|
|
|
122
97
|
useMountEffect(() => {
|
|
123
98
|
const down = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(true);
|
|
@@ -388,7 +363,14 @@ export const Timeline = memo(function Timeline({
|
|
|
388
363
|
<div
|
|
389
364
|
ref={setContainerRef}
|
|
390
365
|
aria-label="Timeline"
|
|
391
|
-
className={`relative border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
|
366
|
+
className={`relative border-t select-none h-full overflow-hidden ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
|
367
|
+
onMouseMove={(e) => {
|
|
368
|
+
if (activeTool === "razor" && scrollRef.current) {
|
|
369
|
+
const rect = scrollRef.current.getBoundingClientRect();
|
|
370
|
+
setRazorGuideX(e.clientX - rect.left + scrollRef.current.scrollLeft);
|
|
371
|
+
}
|
|
372
|
+
}}
|
|
373
|
+
onMouseLeave={() => setRazorGuideX(null)}
|
|
392
374
|
style={{
|
|
393
375
|
touchAction: "pan-x pan-y",
|
|
394
376
|
background: theme.shellBackground,
|
|
@@ -402,7 +384,16 @@ export const Timeline = memo(function Timeline({
|
|
|
402
384
|
onDragOver={handleAssetDragOver}
|
|
403
385
|
onDragLeave={() => setIsDragOver(false)}
|
|
404
386
|
onDrop={handleAssetDrop}
|
|
405
|
-
onPointerDown={
|
|
387
|
+
onPointerDown={(e) => {
|
|
388
|
+
if (activeTool === "razor" && e.shiftKey && e.button === 0 && scrollRef.current) {
|
|
389
|
+
const rect = scrollRef.current.getBoundingClientRect();
|
|
390
|
+
const x = e.clientX - rect.left + scrollRef.current.scrollLeft - GUTTER;
|
|
391
|
+
const splitTime = Math.max(0, x / pps);
|
|
392
|
+
onRazorSplitAll?.(splitTime);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
handlePointerDown(e);
|
|
396
|
+
}}
|
|
406
397
|
onPointerMove={handlePointerMove}
|
|
407
398
|
onPointerUp={handlePointerUp}
|
|
408
399
|
onLostPointerCapture={handlePointerUp}
|
|
@@ -488,7 +479,19 @@ export const Timeline = memo(function Timeline({
|
|
|
488
479
|
onSelectElement?.(el);
|
|
489
480
|
setClipContextMenu({ x: e.clientX, y: e.clientY, element: el });
|
|
490
481
|
}}
|
|
482
|
+
onRazorSplit={onRazorSplit}
|
|
483
|
+
onRazorSplitAll={onRazorSplitAll}
|
|
491
484
|
/>
|
|
485
|
+
{activeTool === "razor" && razorGuideX !== null && (
|
|
486
|
+
<div
|
|
487
|
+
className="absolute top-0 bottom-0 pointer-events-none z-10"
|
|
488
|
+
style={{
|
|
489
|
+
left: razorGuideX,
|
|
490
|
+
width: 1,
|
|
491
|
+
background: "rgba(239,68,68,0.7)",
|
|
492
|
+
}}
|
|
493
|
+
/>
|
|
494
|
+
)}
|
|
492
495
|
</div>
|
|
493
496
|
|
|
494
497
|
{showShortcutHint && !showPopover && !rangeSelection && (
|
|
@@ -2,6 +2,7 @@ import { memo, type ReactNode } from "react";
|
|
|
2
2
|
import { TimelineClip } from "./TimelineClip";
|
|
3
3
|
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
|
|
4
4
|
import { TimelineRuler } from "./TimelineRuler";
|
|
5
|
+
import { PlayheadIndicator } from "./PlayheadIndicator";
|
|
5
6
|
import {
|
|
6
7
|
getTimelineEditCapabilities,
|
|
7
8
|
resolveBlockedTimelineEditIntent,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
|
|
18
19
|
import type { TrackVisualStyle } from "./timelineIcons";
|
|
19
20
|
import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
|
|
21
|
+
import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
|
|
20
22
|
|
|
21
23
|
function ClipLabel({ element, color }: { element: TimelineElement; color: string }) {
|
|
22
24
|
const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id));
|
|
@@ -91,6 +93,8 @@ interface TimelineCanvasProps {
|
|
|
91
93
|
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
|
|
92
94
|
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
|
|
93
95
|
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
|
|
96
|
+
onRazorSplit?: (element: TimelineElement, splitTime: number) => void;
|
|
97
|
+
onRazorSplitAll?: (splitTime: number) => void;
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
export const TimelineCanvas = memo(function TimelineCanvas({
|
|
@@ -141,6 +145,8 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
141
145
|
onContextMenuKeyframe,
|
|
142
146
|
onContextMenuClip,
|
|
143
147
|
onToggleKeyframeAtPlayhead: _onToggleKeyframeAtPlayhead,
|
|
148
|
+
onRazorSplit,
|
|
149
|
+
onRazorSplitAll,
|
|
144
150
|
}: TimelineCanvasProps) {
|
|
145
151
|
const draggedElement = draggedClip?.element ?? null;
|
|
146
152
|
const activeDraggedElement =
|
|
@@ -305,6 +311,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
305
311
|
}}
|
|
306
312
|
onPointerDown={(e) => {
|
|
307
313
|
if (e.button !== 0) return;
|
|
314
|
+
if (usePlayerStore.getState().activeTool === "razor") return;
|
|
308
315
|
if (e.shiftKey) {
|
|
309
316
|
shiftClickClipRef.current = {
|
|
310
317
|
element: el,
|
|
@@ -358,6 +365,27 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
358
365
|
onClick={(e) => {
|
|
359
366
|
e.stopPropagation();
|
|
360
367
|
if (suppressClickRef.current) return;
|
|
368
|
+
const { activeTool } = usePlayerStore.getState();
|
|
369
|
+
if (activeTool === "razor" && onRazorSplit) {
|
|
370
|
+
const clipRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
371
|
+
const clickOffsetX = e.clientX - clipRect.left;
|
|
372
|
+
const splitTime = previewElement.start + clickOffsetX / pps;
|
|
373
|
+
const clampedTime = Math.max(
|
|
374
|
+
previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
|
|
375
|
+
Math.min(
|
|
376
|
+
previewElement.start +
|
|
377
|
+
previewElement.duration -
|
|
378
|
+
SPLIT_BOUNDARY_EPSILON_S,
|
|
379
|
+
splitTime,
|
|
380
|
+
),
|
|
381
|
+
);
|
|
382
|
+
if (e.shiftKey && onRazorSplitAll) {
|
|
383
|
+
onRazorSplitAll(clampedTime);
|
|
384
|
+
} else {
|
|
385
|
+
onRazorSplit(el, clampedTime);
|
|
386
|
+
}
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
361
389
|
const nextElement = isSelected ? null : el;
|
|
362
390
|
setSelectedElementId(nextElement ? elementKey : null);
|
|
363
391
|
onSelectElement?.(nextElement);
|
|
@@ -457,28 +485,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
457
485
|
className="absolute top-0 bottom-0 pointer-events-none"
|
|
458
486
|
style={{ left: `${GUTTER}px`, zIndex: 100 }}
|
|
459
487
|
>
|
|
460
|
-
<
|
|
461
|
-
className="absolute top-0 bottom-0"
|
|
462
|
-
style={{
|
|
463
|
-
left: "50%",
|
|
464
|
-
width: 2,
|
|
465
|
-
marginLeft: -1,
|
|
466
|
-
background: "var(--hf-accent, #3CE6AC)",
|
|
467
|
-
boxShadow: "0 0 8px rgba(60,230,172,0.5)",
|
|
468
|
-
}}
|
|
469
|
-
/>
|
|
470
|
-
<div className="absolute" style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}>
|
|
471
|
-
<div
|
|
472
|
-
style={{
|
|
473
|
-
width: 0,
|
|
474
|
-
height: 0,
|
|
475
|
-
borderLeft: "6px solid transparent",
|
|
476
|
-
borderRight: "6px solid transparent",
|
|
477
|
-
borderTop: "8px solid var(--hf-accent, #3CE6AC)",
|
|
478
|
-
filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
|
|
479
|
-
}}
|
|
480
|
-
/>
|
|
481
|
-
</div>
|
|
488
|
+
<PlayheadIndicator />
|
|
482
489
|
</div>
|
|
483
490
|
</div>
|
|
484
491
|
);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// fallow-ignore-file code-duplication
|
|
2
|
+
// fallow-ignore-file dead-code
|
|
3
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
4
|
+
import type { BlockedTimelineEditIntent } from "./timelineEditing";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shared callback signatures for timeline editing operations.
|
|
8
|
+
* Used by NLELayout, Timeline, and any component that passes through
|
|
9
|
+
* the standard set of timeline mutation handlers.
|
|
10
|
+
*/
|
|
11
|
+
export interface TimelineDropCallbacks {
|
|
12
|
+
onFileDrop?: (
|
|
13
|
+
files: File[],
|
|
14
|
+
placement?: { start: number; track: number },
|
|
15
|
+
) => Promise<void> | void;
|
|
16
|
+
onAssetDrop?: (
|
|
17
|
+
assetPath: string,
|
|
18
|
+
placement: { start: number; track: number },
|
|
19
|
+
) => Promise<void> | void;
|
|
20
|
+
onBlockDrop?: (
|
|
21
|
+
blockName: string,
|
|
22
|
+
placement: { start: number; track: number },
|
|
23
|
+
) => Promise<void> | void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TimelineEditCallbacks {
|
|
27
|
+
onMoveElement?: (
|
|
28
|
+
element: TimelineElement,
|
|
29
|
+
updates: Pick<TimelineElement, "start" | "track">,
|
|
30
|
+
) => Promise<void> | void;
|
|
31
|
+
onResizeElement?: (
|
|
32
|
+
element: TimelineElement,
|
|
33
|
+
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
34
|
+
) => Promise<void> | void;
|
|
35
|
+
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
36
|
+
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
37
|
+
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
38
|
+
onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
|
|
39
|
+
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
|
|
40
|
+
onDeleteAllKeyframes?: (elementId: string) => void;
|
|
41
|
+
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
|
|
42
|
+
onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
|
|
43
|
+
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
|
|
44
|
+
}
|
|
@@ -1,25 +1,13 @@
|
|
|
1
|
-
// fallow-ignore-file clone-families
|
|
2
1
|
import { useCallback, useState, type RefObject } from "react";
|
|
3
2
|
import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
|
4
3
|
import { TRACK_H, resolveTimelineAssetDrop } from "./timelineLayout";
|
|
4
|
+
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
|
5
5
|
|
|
6
|
-
interface UseTimelineAssetDropOptions {
|
|
6
|
+
interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
|
|
7
7
|
scrollRef: RefObject<HTMLDivElement | null>;
|
|
8
8
|
ppsRef: RefObject<number>;
|
|
9
9
|
durationRef: RefObject<number>;
|
|
10
10
|
trackOrderRef: RefObject<number[]>;
|
|
11
|
-
onFileDrop?: (
|
|
12
|
-
files: File[],
|
|
13
|
-
placement?: { start: number; track: number },
|
|
14
|
-
) => Promise<void> | void;
|
|
15
|
-
onAssetDrop?: (
|
|
16
|
-
assetPath: string,
|
|
17
|
-
placement: { start: number; track: number },
|
|
18
|
-
) => Promise<void> | void;
|
|
19
|
-
onBlockDrop?: (
|
|
20
|
-
blockName: string,
|
|
21
|
-
placement: { start: number; track: number },
|
|
22
|
-
) => Promise<void> | void;
|
|
23
11
|
}
|
|
24
12
|
|
|
25
13
|
export function useTimelineAssetDrop({
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// fallow-ignore-file dead-code
|
|
2
|
+
import { usePlayerStore, type ZoomMode } from "../store/playerStore";
|
|
3
|
+
|
|
4
|
+
export interface TimelineZoomState {
|
|
5
|
+
zoomMode: ZoomMode;
|
|
6
|
+
manualZoomPercent: number;
|
|
7
|
+
setZoomMode: (mode: ZoomMode) => void;
|
|
8
|
+
setManualZoomPercent: (percent: number) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Shared zoom-related store selectors used by Timeline and TimelineToolbar. */
|
|
12
|
+
export function useTimelineZoom(): TimelineZoomState {
|
|
13
|
+
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
|
14
|
+
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
|
15
|
+
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
|
|
16
|
+
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
|
|
17
|
+
return { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent };
|
|
18
|
+
}
|
|
@@ -45,6 +45,7 @@ export interface TimelineElement {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export type ZoomMode = "fit" | "manual";
|
|
48
|
+
type TimelineTool = "select" | "razor";
|
|
48
49
|
|
|
49
50
|
interface PlayerState {
|
|
50
51
|
isPlaying: boolean;
|
|
@@ -65,6 +66,9 @@ interface PlayerState {
|
|
|
65
66
|
/** Work-area out-point (seconds). When set, loop ends here and E jumps here. */
|
|
66
67
|
outPoint: number | null;
|
|
67
68
|
|
|
69
|
+
activeTool: TimelineTool;
|
|
70
|
+
setActiveTool: (tool: TimelineTool) => void;
|
|
71
|
+
|
|
68
72
|
/** Set of selected keyframe keys in format `${elementId}:${percentage}`. */
|
|
69
73
|
selectedKeyframes: Set<string>;
|
|
70
74
|
toggleSelectedKeyframe: (key: string) => void;
|
|
@@ -153,6 +157,9 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
153
157
|
inPoint: null,
|
|
154
158
|
outPoint: null,
|
|
155
159
|
|
|
160
|
+
activeTool: "select",
|
|
161
|
+
setActiveTool: (tool) => set({ activeTool: tool }),
|
|
162
|
+
|
|
156
163
|
selectedKeyframes: new Set(),
|
|
157
164
|
toggleSelectedKeyframe: (key) =>
|
|
158
165
|
set((s) => {
|
|
@@ -262,6 +269,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
262
269
|
selectedElementId: null,
|
|
263
270
|
inPoint: null,
|
|
264
271
|
outPoint: null,
|
|
272
|
+
activeTool: "select",
|
|
265
273
|
selectedKeyframes: new Set(),
|
|
266
274
|
selectedElementIds: new Set(),
|
|
267
275
|
expandedTimelineElements: new Set(),
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TimelineElement } from "../player/store/playerStore";
|
|
2
|
+
|
|
3
|
+
export { buildPatchTarget, readFileContent } from "../hooks/timelineEditingHelpers";
|
|
4
|
+
|
|
5
|
+
/** Minimum distance (seconds) from clip boundaries to allow a split. */
|
|
6
|
+
export const SPLIT_BOUNDARY_EPSILON_S = 0.03;
|
|
7
|
+
|
|
8
|
+
export function canSplitElement(el: TimelineElement): boolean {
|
|
9
|
+
return (
|
|
10
|
+
!el.timelineLocked &&
|
|
11
|
+
el.timingSource !== "implicit" &&
|
|
12
|
+
!el.compositionSrc &&
|
|
13
|
+
!!el.duration &&
|
|
14
|
+
Number.isFinite(el.duration)
|
|
15
|
+
);
|
|
16
|
+
}
|