@hyperframes/studio 0.6.90 → 0.6.92

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.
Files changed (58) hide show
  1. package/dist/assets/{index-DSLrl2tB.js → index-CDy8BuGq.js} +24 -24
  2. package/dist/assets/index-CmRIkCwI.js +251 -0
  3. package/dist/assets/index-rm9tn9nH.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +2 -0
  7. package/src/components/StudioPreviewArea.tsx +54 -13
  8. package/src/components/TimelineToolbar.tsx +52 -35
  9. package/src/components/editor/DomEditOverlay.tsx +79 -0
  10. package/src/components/editor/PropertyPanel.tsx +19 -10
  11. package/src/components/editor/gsapAnimatesProperty.ts +30 -0
  12. package/src/components/editor/manualEditingAvailability.test.ts +12 -0
  13. package/src/components/editor/manualEditingAvailability.ts +16 -0
  14. package/src/components/editor/manualEditsDom.ts +25 -5
  15. package/src/components/editor/manualEditsDomPatches.test.ts +1 -0
  16. package/src/components/editor/manualEditsDomPatches.ts +17 -1
  17. package/src/components/editor/manualEditsSnapshot.ts +16 -0
  18. package/src/components/editor/propertyPanel3dTransform.tsx +19 -4
  19. package/src/components/editor/useOffScreenIndicators.ts +197 -0
  20. package/src/components/nle/NLELayout.tsx +22 -32
  21. package/src/components/nle/TimelineEditorNotice.tsx +2 -25
  22. package/src/contexts/DomEditContext.tsx +4 -0
  23. package/src/hooks/gsapDragCommit.ts +119 -43
  24. package/src/hooks/gsapKeyframeCacheHelpers.ts +9 -4
  25. package/src/hooks/gsapRuntimeBridge.ts +266 -41
  26. package/src/hooks/gsapRuntimeReaders.ts +16 -2
  27. package/src/hooks/useAnimatedPropertyCommit.ts +11 -5
  28. package/src/hooks/useAppHotkeys.ts +48 -1
  29. package/src/hooks/useContextMenuDismiss.ts +29 -0
  30. package/src/hooks/useDomEditCommits.ts +7 -1
  31. package/src/hooks/useDomEditSession.ts +20 -4
  32. package/src/hooks/useEnableKeyframes.ts +3 -1
  33. package/src/hooks/useGestureCommit.ts +99 -13
  34. package/src/hooks/useGestureRecording.ts +18 -2
  35. package/src/hooks/useGsapScriptCommits.ts +24 -3
  36. package/src/hooks/useGsapSelectionHandlers.ts +19 -3
  37. package/src/hooks/useGsapTweenCache.ts +30 -10
  38. package/src/hooks/useRazorSplit.ts +298 -0
  39. package/src/hooks/useTimelineEditing.ts +15 -98
  40. package/src/player/components/ClipContextMenu.tsx +14 -25
  41. package/src/player/components/KeyframeDiamondContextMenu.tsx +16 -112
  42. package/src/player/components/PlayheadIndicator.tsx +43 -0
  43. package/src/player/components/Timeline.tsx +45 -38
  44. package/src/player/components/TimelineCanvas.tsx +29 -22
  45. package/src/player/components/TimelineClipDiamonds.tsx +3 -1
  46. package/src/player/components/timelineCallbacks.ts +44 -0
  47. package/src/player/components/timelineDragDrop.ts +2 -14
  48. package/src/player/components/useTimelineZoom.ts +18 -0
  49. package/src/player/store/playerStore.ts +20 -0
  50. package/src/utils/globalTimeCompiler.test.ts +2 -2
  51. package/src/utils/globalTimeCompiler.ts +2 -1
  52. package/src/utils/gsapSoftReload.test.ts +16 -0
  53. package/src/utils/gsapSoftReload.ts +43 -8
  54. package/src/utils/rdpSimplify.ts +3 -2
  55. package/src/utils/timelineElementSplit.test.ts +50 -0
  56. package/src/utils/timelineElementSplit.ts +32 -0
  57. package/dist/assets/index-BKuDHMYl.js +0 -146
  58. package/dist/assets/index-D2NkPomd.css +0 -1
@@ -0,0 +1,43 @@
1
+ // fallow-ignore-file dead-code
2
+ /**
3
+ * Shared playhead visual used by TimelineCanvas (real playhead) and
4
+ * TimelineEditorNotice (animated illustration).
5
+ */
6
+ interface PlayheadIndicatorProps {
7
+ /** CSS color, defaults to the HF accent variable */
8
+ color?: string;
9
+ /** Glow shadow color, defaults to translucent accent */
10
+ glowColor?: string;
11
+ }
12
+
13
+ export function PlayheadIndicator({
14
+ color = "var(--hf-accent, #3CE6AC)",
15
+ glowColor = "rgba(60,230,172,0.5)",
16
+ }: PlayheadIndicatorProps) {
17
+ return (
18
+ <>
19
+ <div
20
+ className="absolute top-0 bottom-0"
21
+ style={{
22
+ left: "50%",
23
+ width: 2,
24
+ marginLeft: -1,
25
+ background: color,
26
+ boxShadow: `0 0 8px ${glowColor}`,
27
+ }}
28
+ />
29
+ <div className="absolute" style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}>
30
+ <div
31
+ style={{
32
+ width: 0,
33
+ height: 0,
34
+ borderLeft: "6px solid transparent",
35
+ borderRight: "6px solid transparent",
36
+ borderTop: `8px solid ${color}`,
37
+ filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
38
+ }}
39
+ />
40
+ </div>
41
+ </>
42
+ );
43
+ }
@@ -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}
@@ -457,6 +448,9 @@ export const Timeline = memo(function Timeline({
457
448
  onSelectElement?.(el);
458
449
  const absTime = el.start + (pct / 100) * el.duration;
459
450
  onSeek?.(absTime);
451
+ const kfData = keyframeCache?.get(elKey);
452
+ const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.5);
453
+ usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null);
460
454
  }}
461
455
  onShiftClickKeyframe={(elId, pct) => {
462
456
  toggleSelectedKeyframe(`${elId}:${pct}`);
@@ -473,12 +467,13 @@ export const Timeline = memo(function Timeline({
473
467
  onSeek?.(absTime);
474
468
  }
475
469
  const kfData = keyframeCache.get(elId);
476
- const kf = kfData?.keyframes.find((k) => k.percentage === pct);
470
+ const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
477
471
  setKfContextMenu({
478
- x: e.clientX,
479
- y: e.clientY,
472
+ x: e.clientX + 4,
473
+ y: e.clientY + 2,
480
474
  elementId: elId,
481
475
  percentage: pct,
476
+ tweenPercentage: kf?.tweenPercentage,
482
477
  currentEase: kf?.ease ?? kfData?.ease,
483
478
  });
484
479
  }}
@@ -488,7 +483,19 @@ export const Timeline = memo(function Timeline({
488
483
  onSelectElement?.(el);
489
484
  setClipContextMenu({ x: e.clientX, y: e.clientY, element: el });
490
485
  }}
486
+ onRazorSplit={onRazorSplit}
487
+ onRazorSplitAll={onRazorSplitAll}
491
488
  />
489
+ {activeTool === "razor" && razorGuideX !== null && (
490
+ <div
491
+ className="absolute top-0 bottom-0 pointer-events-none z-10"
492
+ style={{
493
+ left: razorGuideX,
494
+ width: 1,
495
+ background: "rgba(239,68,68,0.7)",
496
+ }}
497
+ />
498
+ )}
492
499
  </div>
493
500
 
494
501
  {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
  );
@@ -123,7 +123,8 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
123
123
  const kfKey = `${elementId}:${kf.percentage}`;
124
124
  const isKfSelected = selectedKeyframes.has(kfKey);
125
125
  const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
126
- const color = isKfSelected || atPlayhead ? accentColor : "#a3a3a3";
126
+ const isHighlighted = isKfSelected || atPlayhead;
127
+ const color = isHighlighted ? accentColor : "#a3a3a3";
127
128
  return (
128
129
  <button
129
130
  key={`${i}-${kf.percentage}`}
@@ -135,6 +136,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
135
136
  transform: "translateY(-50%)",
136
137
  width: diamondSize,
137
138
  height: diamondSize,
139
+ zIndex: isHighlighted ? 2 : 1,
138
140
  pointerEvents: "auto",
139
141
  background: "none",
140
142
  border: "none",
@@ -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
+ }
@@ -6,6 +6,10 @@ export interface KeyframeCacheEntry {
6
6
  format: string;
7
7
  keyframes: Array<{
8
8
  percentage: number;
9
+ /** Original tween-relative percentage (server mutations need this, not the clip-relative `percentage`). */
10
+ tweenPercentage?: number;
11
+ /** Which property group the source tween belongs to (position, scale, rotation, visual, etc.). */
12
+ propertyGroup?: string;
9
13
  properties: Record<string, number | string>;
10
14
  ease?: string;
11
15
  }>;
@@ -45,6 +49,7 @@ export interface TimelineElement {
45
49
  }
46
50
 
47
51
  export type ZoomMode = "fit" | "manual";
52
+ type TimelineTool = "select" | "razor";
48
53
 
49
54
  interface PlayerState {
50
55
  isPlaying: boolean;
@@ -65,11 +70,19 @@ interface PlayerState {
65
70
  /** Work-area out-point (seconds). When set, loop ends here and E jumps here. */
66
71
  outPoint: number | null;
67
72
 
73
+ activeTool: TimelineTool;
74
+ setActiveTool: (tool: TimelineTool) => void;
75
+
68
76
  /** Set of selected keyframe keys in format `${elementId}:${percentage}`. */
69
77
  selectedKeyframes: Set<string>;
70
78
  toggleSelectedKeyframe: (key: string) => void;
71
79
  clearSelectedKeyframes: () => void;
72
80
 
81
+ /** Tween-relative percentage of the last-clicked keyframe diamond. Operations
82
+ * (drag, resize, rotate) target this instead of recomputing from playhead. */
83
+ activeKeyframePct: number | null;
84
+ setActiveKeyframePct: (pct: number | null) => void;
85
+
73
86
  /** Multi-select: additional selected elements beyond selectedElementId. */
74
87
  selectedElementIds: Set<string>;
75
88
  toggleSelectedElementId: (id: string) => void;
@@ -153,6 +166,9 @@ export const usePlayerStore = create<PlayerState>((set) => ({
153
166
  inPoint: null,
154
167
  outPoint: null,
155
168
 
169
+ activeTool: "select",
170
+ setActiveTool: (tool) => set({ activeTool: tool }),
171
+
156
172
  selectedKeyframes: new Set(),
157
173
  toggleSelectedKeyframe: (key) =>
158
174
  set((s) => {
@@ -163,6 +179,9 @@ export const usePlayerStore = create<PlayerState>((set) => ({
163
179
  }),
164
180
  clearSelectedKeyframes: () => set({ selectedKeyframes: new Set() }),
165
181
 
182
+ activeKeyframePct: null,
183
+ setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
184
+
166
185
  keyframeClipboard: null,
167
186
  setKeyframeClipboard: (data) => set({ keyframeClipboard: data }),
168
187
 
@@ -262,6 +281,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
262
281
  selectedElementId: null,
263
282
  inPoint: null,
264
283
  outPoint: null,
284
+ activeTool: "select",
265
285
  selectedKeyframes: new Set(),
266
286
  selectedElementIds: new Set(),
267
287
  expandedTimelineElements: new Set(),
@@ -103,8 +103,8 @@ describe("resolveTweenDuration", () => {
103
103
  expect(resolveTweenDuration(makeAnim({ duration: 2 }))).toBe(2);
104
104
  });
105
105
 
106
- test("missing duration defaults to 1", () => {
107
- expect(resolveTweenDuration(makeAnim({ duration: undefined }))).toBe(1);
106
+ test("missing duration defaults to GSAP default (0.5)", () => {
107
+ expect(resolveTweenDuration(makeAnim({ duration: undefined }))).toBe(0.5);
108
108
  });
109
109
  });
110
110
 
@@ -27,6 +27,7 @@ export function isTimeWithinTween(
27
27
  }
28
28
 
29
29
  export function resolveTweenStart(animation: GsapAnimation): number | null {
30
+ if (animation.resolvedStart != null) return animation.resolvedStart;
30
31
  if (typeof animation.position === "number") return animation.position;
31
32
  const parsed = Number.parseFloat(animation.position as string);
32
33
  if (!Number.isNaN(parsed)) return parsed;
@@ -34,7 +35,7 @@ export function resolveTweenStart(animation: GsapAnimation): number | null {
34
35
  }
35
36
 
36
37
  export function resolveTweenDuration(animation: GsapAnimation): number {
37
- return animation.duration ?? 1;
38
+ return animation.duration ?? 0.5;
38
39
  }
39
40
 
40
41
  export function findTweenAtTime(
@@ -28,10 +28,26 @@ function buildMockIframe(overrides: Record<string, unknown> = {}) {
28
28
  ...overrides,
29
29
  };
30
30
 
31
+ // Intercept appendChild: when a <script> is appended, simulate execution by
32
+ // repopulating __timelines (mimicking what the real GSAP script would do).
33
+ const realAppendChild = container.appendChild.bind(container);
34
+ container.appendChild = <T extends Node>(node: T): T => {
35
+ const result = realAppendChild(node);
36
+ if (node instanceof HTMLScriptElement && node.textContent?.includes("gsap.timeline")) {
37
+ // Simulate the script populating __timelines
38
+ const cw = contentWindow as { __timelines?: Record<string, unknown> };
39
+ if (cw.__timelines) {
40
+ cw.__timelines.root = { kill: vi.fn(), pause: vi.fn() };
41
+ }
42
+ }
43
+ return result;
44
+ };
45
+
31
46
  const contentDocument = {
32
47
  querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []),
33
48
  createElement: (tag: string) => document.createElement(tag),
34
49
  body: container,
50
+ head: document.createElement("div"),
35
51
  };
36
52
 
37
53
  return {
@@ -7,6 +7,8 @@ type IframeWindow = Window & {
7
7
  gsap?: {
8
8
  timeline?: (...args: unknown[]) => unknown;
9
9
  registerPlugin?: (...plugins: unknown[]) => unknown;
10
+ set?: (targets: Element | Element[], vars: Record<string, unknown>) => void;
11
+ globalTimeline?: { getChildren?: (deep: boolean) => Array<{ kill?: () => void }> };
10
12
  };
11
13
  MotionPathPlugin?: unknown;
12
14
  };
@@ -29,6 +31,14 @@ function findGsapScriptElements(doc: Document): HTMLScriptElement[] {
29
31
  return results;
30
32
  }
31
33
 
34
+ /** Check that the new script repopulated __timelines with at least one entry. */
35
+ function verifyTimelinesPopulated(win: IframeWindow): boolean {
36
+ const tlKeys = win.__timelines
37
+ ? Object.keys(win.__timelines).filter((k) => k !== "__proxied")
38
+ : [];
39
+ return tlKeys.length > 0;
40
+ }
41
+
32
42
  /**
33
43
  * Replace the GSAP script in the live iframe without reloading. This preserves
34
44
  * the WebGL context and shader transition cache.
@@ -56,24 +66,30 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
56
66
 
57
67
  const currentTime = win.__player?.getTime?.() ?? 0;
58
68
 
69
+ // Track whether the MotionPath async path was taken. When it is, the script
70
+ // executes inside pluginScript.onload — after applySoftReload has already
71
+ // returned. We optimistically return true because the script WILL execute
72
+ // once the plugin loads; the alternative (returning false) would trigger a
73
+ // full iframe reload that destroys the very WebGL context we're preserving.
74
+ let deferredToAsync = false;
75
+
59
76
  const doReload = () => {
60
77
  const timelines = win.__timelines;
78
+ const allTargets: Element[] = [];
79
+
61
80
  if (timelines) {
62
81
  for (const key of Object.keys(timelines)) {
82
+ if (key === "__proxied") continue;
63
83
  try {
64
84
  const tl = timelines[key] as {
65
85
  kill?: () => void;
66
86
  getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
67
87
  };
68
- const allTargets: Element[] = [];
69
88
  if (tl?.getChildren) {
70
89
  try {
71
90
  for (const child of tl.getChildren(true)) {
72
91
  if (typeof child.targets === "function") {
73
- for (const t of child.targets()) {
74
- allTargets.push(t);
75
- delete (t as unknown as Record<string, unknown>)._gsap;
76
- }
92
+ for (const t of child.targets()) allTargets.push(t);
77
93
  }
78
94
  }
79
95
  } catch {}
@@ -84,6 +100,23 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
84
100
  }
85
101
  }
86
102
 
103
+ // Kill bare gsap.to/from tweens not registered on __timelines
104
+ if (win.gsap?.globalTimeline?.getChildren) {
105
+ try {
106
+ for (const child of win.gsap.globalTimeline.getChildren(false)) {
107
+ child.kill?.();
108
+ }
109
+ } catch {}
110
+ }
111
+
112
+ // Clear residual inline transforms left by killed tweens so from() tweens
113
+ // don't read stale end values from the DOM on re-execution
114
+ if (allTargets.length > 0 && win.gsap?.set) {
115
+ try {
116
+ win.gsap.set(allTargets, { clearProps: "all" });
117
+ } catch {}
118
+ }
119
+
87
120
  oldScriptEl.remove();
88
121
 
89
122
  const executeScript = () => {
@@ -98,10 +131,9 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
98
131
  win.__hfStudioManualEditsApply?.();
99
132
  };
100
133
 
101
- // Load MotionPathPlugin on demand if the script uses motionPath.
102
- // Uses the same CDN as composition templates (GSAP_CDN in constants.ts).
103
134
  const needsMotionPath = /motionPath\s*[:{]/.test(scriptText);
104
135
  if (needsMotionPath && !win.MotionPathPlugin && win.gsap) {
136
+ deferredToAsync = true;
105
137
  const pluginScript = doc.createElement("script");
106
138
  pluginScript.src = "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js";
107
139
  pluginScript.onload = () => executeScript();
@@ -119,7 +151,10 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
119
151
  } else {
120
152
  doReload();
121
153
  }
122
- return true;
154
+ // When MotionPath needs async loading, the script hasn't executed yet —
155
+ // skip the __timelines check and return true optimistically.
156
+ if (deferredToAsync) return true;
157
+ return verifyTimelinesPopulated(win);
123
158
  } catch {
124
159
  return false;
125
160
  }