@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.
@@ -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 = usePlayerStore((s) => s.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={handlePointerDown}
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
- <div
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
+ }