@hyperframes/studio 0.5.0-alpha.1 → 0.5.0-alpha.11

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 (53) hide show
  1. package/dist/assets/index-Bl4Deziq.js +105 -0
  2. package/dist/assets/index-KioPDrX6.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +494 -185
  6. package/src/captions/components/CaptionOverlay.tsx +2 -1
  7. package/src/captions/keyboard.test.ts +38 -0
  8. package/src/captions/keyboard.ts +8 -0
  9. package/src/components/LintModal.tsx +3 -4
  10. package/src/components/editor/DomEditOverlay.tsx +41 -6
  11. package/src/components/editor/PropertyPanel.tsx +7 -3
  12. package/src/components/editor/domEditing.test.ts +110 -0
  13. package/src/components/editor/domEditing.ts +33 -4
  14. package/src/components/nle/NLELayout.tsx +43 -8
  15. package/src/components/nle/NLEPreview.tsx +5 -1
  16. package/src/components/sidebar/AssetsTab.tsx +3 -4
  17. package/src/components/sidebar/LeftSidebar.tsx +64 -36
  18. package/src/hooks/usePersistentEditHistory.test.ts +255 -0
  19. package/src/hooks/usePersistentEditHistory.ts +336 -0
  20. package/src/icons/SystemIcons.tsx +4 -0
  21. package/src/player/components/AudioWaveform.tsx +44 -29
  22. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  23. package/src/player/components/CompositionThumbnail.tsx +42 -10
  24. package/src/player/components/EditModal.tsx +5 -20
  25. package/src/player/components/PlayerControls.tsx +117 -49
  26. package/src/player/components/Timeline.test.ts +84 -0
  27. package/src/player/components/Timeline.tsx +198 -27
  28. package/src/player/components/timelineEditing.test.ts +2 -2
  29. package/src/player/components/timelineEditing.ts +1 -1
  30. package/src/player/components/timelineTheme.ts +3 -3
  31. package/src/player/components/timelineZoom.test.ts +21 -0
  32. package/src/player/components/timelineZoom.ts +11 -0
  33. package/src/player/hooks/useTimelinePlayer.test.ts +138 -0
  34. package/src/player/hooks/useTimelinePlayer.ts +354 -43
  35. package/src/player/lib/time.test.ts +29 -1
  36. package/src/player/lib/time.ts +26 -0
  37. package/src/player/store/playerStore.test.ts +11 -1
  38. package/src/player/store/playerStore.ts +5 -1
  39. package/src/styles/studio.css +9 -0
  40. package/src/utils/clipboard.test.ts +88 -0
  41. package/src/utils/clipboard.ts +57 -0
  42. package/src/utils/editHistory.test.ts +244 -0
  43. package/src/utils/editHistory.ts +218 -0
  44. package/src/utils/editHistoryStorage.test.ts +37 -0
  45. package/src/utils/editHistoryStorage.ts +99 -0
  46. package/src/utils/frameCapture.test.ts +26 -0
  47. package/src/utils/frameCapture.ts +38 -0
  48. package/src/utils/studioFileHistory.test.ts +156 -0
  49. package/src/utils/studioFileHistory.ts +61 -0
  50. package/src/utils/timelineAssetDrop.test.ts +64 -4
  51. package/src/utils/timelineAssetDrop.ts +27 -5
  52. package/dist/assets/index-Bi30tos-.js +0 -105
  53. package/dist/assets/index-Dm9VsShj.css +0 -1
@@ -26,7 +26,7 @@ import {
26
26
  type TimelineTrackStyle,
27
27
  type TimelineTheme,
28
28
  } from "./timelineTheme";
29
- import { getTimelinePixelsPerSecond } from "./timelineZoom";
29
+ import { getPinchTimelineZoomPercent, getTimelinePixelsPerSecond } from "./timelineZoom";
30
30
  import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
31
31
 
32
32
  /* ── Layout ─────────────────────────────────────────────────────── */
@@ -35,7 +35,7 @@ const TRACK_H = 72;
35
35
  const RULER_H = 24;
36
36
  const CLIP_Y = 3; // vertical inset inside track
37
37
  const CLIP_HANDLE_W = 18;
38
- const TIMELINE_SCROLL_BUFFER = 24;
38
+ const TIMELINE_SCROLL_BUFFER = 20;
39
39
 
40
40
  interface TrackVisualStyle extends TimelineTrackStyle {
41
41
  icon: ReactNode;
@@ -88,16 +88,47 @@ function getStyle(tag: string): TrackVisualStyle {
88
88
  }
89
89
 
90
90
  /* ── Tick Generation ────────────────────────────────────────────── */
91
- export function generateTicks(duration: number): { major: number[]; minor: number[] } {
91
+ function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
92
+ const zoomIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
93
+ if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
94
+ const targetMajorPx = 128;
95
+ return (
96
+ zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600
97
+ );
98
+ }
99
+ const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
100
+ const target = duration / 6;
101
+ return durationIntervals.find((interval) => interval >= target) ?? 60;
102
+ }
103
+
104
+ function getMinorTickInterval(majorInterval: number, pixelsPerSecond?: number): number {
105
+ let interval = majorInterval / 2;
106
+ if (majorInterval >= 30) interval = majorInterval / 6;
107
+ else if (majorInterval >= 15) interval = majorInterval / 3;
108
+ else if (majorInterval >= 5) interval = majorInterval / 5;
109
+ else if (majorInterval >= 1) interval = majorInterval / 4;
110
+
111
+ if (
112
+ Number.isFinite(pixelsPerSecond) &&
113
+ (pixelsPerSecond ?? 0) > 0 &&
114
+ interval * (pixelsPerSecond ?? 0) < 20
115
+ ) {
116
+ return Math.max(0.25, majorInterval / 2);
117
+ }
118
+ return Math.max(0.25, interval);
119
+ }
120
+
121
+ export function generateTicks(
122
+ duration: number,
123
+ pixelsPerSecond?: number,
124
+ ): { major: number[]; minor: number[] } {
92
125
  if (duration <= 0 || !Number.isFinite(duration) || duration > 7200)
93
126
  return { major: [], minor: [] };
94
- const intervals = [0.5, 1, 2, 5, 10, 15, 30, 60];
95
- const target = duration / 6;
96
- const majorInterval = intervals.find((i) => i >= target) ?? 60;
97
- const minorInterval = Math.max(0.25, majorInterval / 2);
127
+ const majorInterval = getMajorTickInterval(duration, pixelsPerSecond);
128
+ const minorInterval = getMinorTickInterval(majorInterval, pixelsPerSecond);
98
129
  const major: number[] = [];
99
130
  const minor: number[] = [];
100
- const maxTicks = 500; // Safety cap to prevent infinite loop
131
+ const maxTicks = 2000; // Safety cap to prevent runaway tick generation
101
132
  for (
102
133
  let t = 0;
103
134
  t <= duration + 0.001 && major.length + minor.length < maxTicks;
@@ -113,6 +144,25 @@ export function generateTicks(duration: number): { major: number[]; minor: numbe
113
144
  return { major, minor };
114
145
  }
115
146
 
147
+ export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
148
+ if (!Number.isFinite(time)) return "0:00";
149
+ const safeTime = Math.max(0, time);
150
+ if (majorInterval < 1) {
151
+ const totalTenths = Math.round(safeTime * 10);
152
+ const wholeSeconds = Math.floor(totalTenths / 10);
153
+ const tenth = totalTenths % 10;
154
+ return `${formatTime(wholeSeconds)}.${tenth}`;
155
+ }
156
+ if (duration >= 3600 || safeTime >= 3600) {
157
+ const totalSeconds = Math.floor(safeTime);
158
+ const hours = Math.floor(totalSeconds / 3600);
159
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
160
+ const seconds = totalSeconds % 60;
161
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
162
+ }
163
+ return formatTime(safeTime);
164
+ }
165
+
116
166
  export function shouldAutoScrollTimeline(
117
167
  zoomMode: ZoomMode,
118
168
  scrollWidth: number,
@@ -131,6 +181,32 @@ export function getTimelineScrollLeftForZoomTransition(
131
181
  if (previousZoomMode === "manual" && nextZoomMode === "fit") return 0;
132
182
  return currentScrollLeft;
133
183
  }
184
+
185
+ export function getTimelineScrollLeftForZoomAnchor(input: {
186
+ pointerX: number;
187
+ currentScrollLeft: number;
188
+ gutter: number;
189
+ currentPixelsPerSecond: number;
190
+ nextPixelsPerSecond: number;
191
+ duration: number;
192
+ }): number {
193
+ const currentPps = Math.max(0, input.currentPixelsPerSecond);
194
+ const nextPps = Math.max(0, input.nextPixelsPerSecond);
195
+ if (
196
+ !Number.isFinite(input.pointerX) ||
197
+ !Number.isFinite(input.currentScrollLeft) ||
198
+ !Number.isFinite(input.duration) ||
199
+ input.duration <= 0 ||
200
+ currentPps <= 0 ||
201
+ nextPps <= 0
202
+ ) {
203
+ return Math.max(0, input.currentScrollLeft);
204
+ }
205
+ const timelineX = Math.max(0, input.currentScrollLeft + input.pointerX - input.gutter);
206
+ const timeAtPointer = Math.max(0, Math.min(input.duration, timelineX / currentPps));
207
+ return Math.max(0, input.gutter + timeAtPointer * nextPps - input.pointerX);
208
+ }
209
+
134
210
  export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
135
211
  if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER;
136
212
  return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
@@ -140,6 +216,14 @@ export function getTimelineCanvasHeight(trackCount: number): number {
140
216
  return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
141
217
  }
142
218
 
219
+ export function shouldShowTimelineShortcutHint(
220
+ scrollHeight: number,
221
+ clientHeight: number,
222
+ ): boolean {
223
+ if (!Number.isFinite(scrollHeight) || !Number.isFinite(clientHeight)) return true;
224
+ return scrollHeight - clientHeight <= 1;
225
+ }
226
+
143
227
  export function shouldHandleTimelineDeleteKey(input: {
144
228
  key: string;
145
229
  metaKey?: boolean;
@@ -304,6 +388,8 @@ export const Timeline = memo(function Timeline({
304
388
  const currentTime = usePlayerStore((s) => s.currentTime);
305
389
  const zoomMode = usePlayerStore((s) => s.zoomMode);
306
390
  const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
391
+ const setZoomMode = usePlayerStore((s) => s.setZoomMode);
392
+ const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
307
393
  const playheadRef = useRef<HTMLDivElement>(null);
308
394
  const containerRef = useRef<HTMLDivElement>(null);
309
395
  const scrollRef = useRef<HTMLDivElement>(null);
@@ -348,30 +434,51 @@ export const Timeline = memo(function Timeline({
348
434
  onDeleteElementRef.current = onDeleteElement;
349
435
  const suppressClickRef = useRef(false);
350
436
  const [showPopover, setShowPopover] = useState(false);
437
+ const [showShortcutHint, setShowShortcutHint] = useState(true);
351
438
  const [viewportWidth, setViewportWidth] = useState(0);
352
439
  const roRef = useRef<ResizeObserver | null>(null);
440
+ const shortcutHintRafRef = useRef(0);
441
+ const syncShortcutHintVisibility = useCallback(() => {
442
+ const scroll = scrollRef.current;
443
+ setShowShortcutHint(
444
+ scroll ? shouldShowTimelineShortcutHint(scroll.scrollHeight, scroll.clientHeight) : true,
445
+ );
446
+ }, []);
447
+ const scheduleShortcutHintVisibilitySync = useCallback(() => {
448
+ if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
449
+ shortcutHintRafRef.current = requestAnimationFrame(() => {
450
+ shortcutHintRafRef.current = 0;
451
+ syncShortcutHintVisibility();
452
+ });
453
+ }, [syncShortcutHintVisibility]);
353
454
 
354
455
  // Callback ref: sets up ResizeObserver when the DOM element actually mounts.
355
456
  // useMountEffect can't work here because the component returns null on first
356
457
  // render (timelineReady=false), so containerRef.current is null when the
357
458
  // effect fires and the ResizeObserver is never created.
358
- const setContainerRef = useCallback((el: HTMLDivElement | null) => {
359
- if (roRef.current) {
360
- roRef.current.disconnect();
361
- roRef.current = null;
362
- }
363
- containerRef.current = el;
364
- if (!el) return;
365
- setViewportWidth(el.clientWidth);
366
- roRef.current = new ResizeObserver(([entry]) => {
367
- setViewportWidth(entry.contentRect.width);
368
- });
369
- roRef.current.observe(el);
370
- }, []);
459
+ const setContainerRef = useCallback(
460
+ (el: HTMLDivElement | null) => {
461
+ if (roRef.current) {
462
+ roRef.current.disconnect();
463
+ roRef.current = null;
464
+ }
465
+ containerRef.current = el;
466
+ if (!el) return;
467
+ setViewportWidth(el.clientWidth);
468
+ scheduleShortcutHintVisibilitySync();
469
+ roRef.current = new ResizeObserver(([entry]) => {
470
+ setViewportWidth(entry.contentRect.width);
471
+ scheduleShortcutHintVisibilitySync();
472
+ });
473
+ roRef.current.observe(el);
474
+ },
475
+ [scheduleShortcutHintVisibilitySync],
476
+ );
371
477
 
372
478
  // Clean up ResizeObserver on unmount
373
479
  useMountEffect(() => () => {
374
480
  roRef.current?.disconnect();
481
+ if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
375
482
  });
376
483
 
377
484
  // Effective duration: max of store duration and the furthest element end.
@@ -416,6 +523,7 @@ export const Timeline = memo(function Timeline({
416
523
  }
417
524
  return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
418
525
  }, [draggedClip, trackOrder]);
526
+ const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
419
527
  const selectedElement = useMemo(
420
528
  () => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
421
529
  [elements, selectedElementId],
@@ -433,7 +541,11 @@ export const Timeline = memo(function Timeline({
433
541
  const trackContentWidth = Math.max(0, effectiveDuration * pps);
434
542
  const zoomModeRef = useRef(zoomMode);
435
543
  zoomModeRef.current = zoomMode;
544
+ const manualZoomPercentRef = useRef(manualZoomPercent);
545
+ manualZoomPercentRef.current = manualZoomPercent;
436
546
  const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
547
+ const fitPpsRef = useRef(fitPps);
548
+ fitPpsRef.current = fitPps;
437
549
 
438
550
  const durationRef = useRef(effectiveDuration);
439
551
  durationRef.current = effectiveDuration;
@@ -922,7 +1034,16 @@ export const Timeline = memo(function Timeline({
922
1034
  cancelAnimationFrame(dragScrollRaf.current);
923
1035
  }, []);
924
1036
 
925
- const { major, minor } = useMemo(() => generateTicks(effectiveDuration), [effectiveDuration]);
1037
+ const { major, minor } = useMemo(
1038
+ () => generateTicks(effectiveDuration, pps),
1039
+ [effectiveDuration, pps],
1040
+ );
1041
+ const majorTickInterval =
1042
+ major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
1043
+ useEffect(() => {
1044
+ syncShortcutHintVisibility();
1045
+ }, [syncShortcutHintVisibility, timelineReady, elements.length, totalH]);
1046
+
926
1047
  const getPreviewElement = useCallback(
927
1048
  (element: TimelineElement): TimelineElement => {
928
1049
  if (resizingClip?.element.id === element.id) {
@@ -1008,6 +1129,57 @@ export const Timeline = memo(function Timeline({
1008
1129
  [onAssetDrop, onFileDrop],
1009
1130
  );
1010
1131
 
1132
+ const handlePinchWheel = useCallback(
1133
+ (e: WheelEvent) => {
1134
+ if (!e.ctrlKey) return;
1135
+ const scroll = scrollRef.current;
1136
+ if (!scroll || durationRef.current <= 0 || fitPpsRef.current <= 0 || ppsRef.current <= 0) {
1137
+ return;
1138
+ }
1139
+
1140
+ e.preventDefault();
1141
+ e.stopPropagation();
1142
+
1143
+ const rect = scroll.getBoundingClientRect();
1144
+ const pointerX = e.clientX - rect.left;
1145
+ const nextZoomPercent = getPinchTimelineZoomPercent(
1146
+ e.deltaY,
1147
+ zoomModeRef.current,
1148
+ manualZoomPercentRef.current,
1149
+ );
1150
+ if (nextZoomPercent === manualZoomPercentRef.current && zoomModeRef.current === "manual") {
1151
+ return;
1152
+ }
1153
+
1154
+ const nextPps = fitPpsRef.current * (nextZoomPercent / 100);
1155
+ const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
1156
+ pointerX,
1157
+ currentScrollLeft: scroll.scrollLeft,
1158
+ gutter: GUTTER,
1159
+ currentPixelsPerSecond: ppsRef.current,
1160
+ nextPixelsPerSecond: nextPps,
1161
+ duration: durationRef.current,
1162
+ });
1163
+
1164
+ setZoomMode("manual");
1165
+ setManualZoomPercent(nextZoomPercent);
1166
+ requestAnimationFrame(() => {
1167
+ const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
1168
+ scroll.scrollLeft = Math.min(maxScrollLeft, nextScrollLeft);
1169
+ });
1170
+ },
1171
+ [setManualZoomPercent, setZoomMode],
1172
+ );
1173
+
1174
+ useEffect(() => {
1175
+ const scroll = scrollRef.current;
1176
+ if (!scroll) return;
1177
+ scroll.addEventListener("wheel", handlePinchWheel, { passive: false, capture: true });
1178
+ return () => {
1179
+ scroll.removeEventListener("wheel", handlePinchWheel, { capture: true });
1180
+ };
1181
+ }, [handlePinchWheel, timelineReady, elements.length]);
1182
+
1011
1183
  if (!timelineReady || elements.length === 0) {
1012
1184
  return (
1013
1185
  <div
@@ -1096,7 +1268,6 @@ export const Timeline = memo(function Timeline({
1096
1268
  );
1097
1269
  }
1098
1270
 
1099
- const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
1100
1271
  const draggedElement = draggedClip?.element ?? null;
1101
1272
  const activeDraggedElement =
1102
1273
  draggedClip?.started === true && draggedElement
@@ -1170,7 +1341,7 @@ export const Timeline = memo(function Timeline({
1170
1341
  <div
1171
1342
  ref={setContainerRef}
1172
1343
  aria-label="Timeline"
1173
- className={`border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
1344
+ className={`relative border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
1174
1345
  style={{
1175
1346
  touchAction: "pan-x pan-y",
1176
1347
  background: theme.shellBackground,
@@ -1239,7 +1410,7 @@ export const Timeline = memo(function Timeline({
1239
1410
  className="text-[9px] font-mono tabular-nums leading-none mb-0.5"
1240
1411
  style={{ color: theme.tickText }}
1241
1412
  >
1242
- {formatTime(t)}
1413
+ {formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
1243
1414
  </span>
1244
1415
  <div className="w-px h-[5px]" style={{ background: theme.tickMajor }} />
1245
1416
  </div>
@@ -1509,8 +1680,8 @@ export const Timeline = memo(function Timeline({
1509
1680
  </div>
1510
1681
  </div>
1511
1682
 
1512
- {/* Keyboard shortcut hint — always visible */}
1513
- {!showPopover && !rangeSelection && (
1683
+ {/* Keyboard shortcut hint */}
1684
+ {showShortcutHint && !showPopover && !rangeSelection && (
1514
1685
  <div className="absolute bottom-2 right-3 pointer-events-none z-20">
1515
1686
  <div
1516
1687
  className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
@@ -248,7 +248,7 @@ describe("getTimelineEditCapabilities", () => {
248
248
  });
249
249
  });
250
250
 
251
- it("disables move and trims for generic motion clips even when patchable", () => {
251
+ it("allows moving generic motion clips while keeping trims blocked", () => {
252
252
  expect(
253
253
  getTimelineEditCapabilities({
254
254
  tag: "section",
@@ -256,7 +256,7 @@ describe("getTimelineEditCapabilities", () => {
256
256
  selector: ".feature-card",
257
257
  }),
258
258
  ).toEqual({
259
- canMove: false,
259
+ canMove: true,
260
260
  canTrimStart: false,
261
261
  canTrimEnd: false,
262
262
  });
@@ -233,7 +233,7 @@ export function getTimelineEditCapabilities(input: {
233
233
  const hasFiniteDuration = Number.isFinite(input.duration) && input.duration > 0;
234
234
  const hasDeterministicWindow = isDeterministicTimelineWindow(input);
235
235
  return {
236
- canMove: canPatch && hasDeterministicWindow,
236
+ canMove: canPatch && (hasDeterministicWindow || hasFiniteDuration),
237
237
  canTrimEnd: canPatch && hasFiniteDuration && hasDeterministicWindow,
238
238
  canTrimStart: canPatch && hasFiniteDuration && canOffsetTrimClipStart(input),
239
239
  };
@@ -63,12 +63,12 @@ const TRACK_STYLES: Record<string, TimelineTrackStyle> = {
63
63
  const DEFAULT_TRACK_STYLE: TimelineTrackStyle = createTrackStyle();
64
64
 
65
65
  export const defaultTimelineTheme: TimelineTheme = {
66
- shellBackground: "#0A0E15",
66
+ shellBackground: "#0A0A0B",
67
67
  shellBorder: "rgba(255,255,255,0.05)",
68
68
  rulerBorder: "rgba(255,255,255,0.045)",
69
- rowBackground: "#0A0E15",
69
+ rowBackground: "#0A0A0B",
70
70
  rowBorder: "rgba(255,255,255,0.05)",
71
- gutterBackground: "#0D121B",
71
+ gutterBackground: "#0A0A0B",
72
72
  gutterBorder: "rgba(255,255,255,0.05)",
73
73
  textPrimary: "#E8EDF5",
74
74
  textSecondary: "#8391A8",
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  clampTimelineZoomPercent,
4
4
  getNextTimelineZoomPercent,
5
+ getPinchTimelineZoomPercent,
5
6
  getTimelinePixelsPerSecond,
6
7
  getTimelineZoomPercent,
7
8
  MAX_TIMELINE_ZOOM_PERCENT,
@@ -60,3 +61,23 @@ describe("getNextTimelineZoomPercent", () => {
60
61
  );
61
62
  });
62
63
  });
64
+
65
+ describe("getPinchTimelineZoomPercent", () => {
66
+ it("zooms in for upward pinch wheel deltas", () => {
67
+ expect(getPinchTimelineZoomPercent(-80, "fit", 100)).toBeGreaterThan(100);
68
+ });
69
+
70
+ it("zooms out for downward pinch wheel deltas", () => {
71
+ expect(getPinchTimelineZoomPercent(80, "manual", 200)).toBeLessThan(200);
72
+ });
73
+
74
+ it("keeps the current zoom for zero or invalid deltas", () => {
75
+ expect(getPinchTimelineZoomPercent(0, "manual", 180)).toBe(180);
76
+ expect(getPinchTimelineZoomPercent(Number.NaN, "manual", 180)).toBe(180);
77
+ });
78
+
79
+ it("clamps pinch zoom to the supported range", () => {
80
+ expect(getPinchTimelineZoomPercent(10000, "manual", 100)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
81
+ expect(getPinchTimelineZoomPercent(-10000, "manual", 100)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
82
+ });
83
+ });
@@ -4,6 +4,7 @@ export const MIN_TIMELINE_ZOOM_PERCENT = 10;
4
4
  export const MAX_TIMELINE_ZOOM_PERCENT = 2000;
5
5
  const ZOOM_OUT_FACTOR = 0.8;
6
6
  const ZOOM_IN_FACTOR = 1.25;
7
+ const PINCH_ZOOM_SENSITIVITY = 0.0035;
7
8
 
8
9
  export function clampTimelineZoomPercent(percent: number): number {
9
10
  if (!Number.isFinite(percent)) return 100;
@@ -36,3 +37,13 @@ export function getNextTimelineZoomPercent(
36
37
  const next = direction === "in" ? current * ZOOM_IN_FACTOR : current * ZOOM_OUT_FACTOR;
37
38
  return clampTimelineZoomPercent(next);
38
39
  }
40
+
41
+ export function getPinchTimelineZoomPercent(
42
+ deltaY: number,
43
+ zoomMode: ZoomMode,
44
+ manualZoomPercent: number,
45
+ ): number {
46
+ const current = getTimelineZoomPercent(zoomMode, manualZoomPercent);
47
+ if (!Number.isFinite(deltaY) || deltaY === 0) return current;
48
+ return clampTimelineZoomPercent(current * Math.exp(-deltaY * PINCH_ZOOM_SENSITIVITY));
49
+ }
@@ -1,10 +1,59 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import { Window } from "happy-dom";
2
3
  import {
3
4
  buildStandaloneRootTimelineElement,
5
+ findTimelineDomNodeForClip,
6
+ getTimelineElementSelector,
7
+ type ClipManifestClip,
4
8
  mergeTimelineElementsPreservingDowngrades,
5
9
  resolveStandaloneRootCompositionSrc,
10
+ shouldIgnorePlaybackShortcutEvent,
11
+ shouldIgnorePlaybackShortcutTarget,
6
12
  } from "./useTimelinePlayer";
7
13
 
14
+ function createDocument(markup: string): Document {
15
+ const window = new Window();
16
+ window.document.body.innerHTML = markup;
17
+ return window.document;
18
+ }
19
+
20
+ function createClip(overrides: Partial<ClipManifestClip>): ClipManifestClip {
21
+ return {
22
+ id: null,
23
+ label: "",
24
+ start: 0,
25
+ duration: 4,
26
+ track: 0,
27
+ kind: "element",
28
+ tagName: "div",
29
+ compositionId: null,
30
+ parentCompositionId: null,
31
+ compositionSrc: null,
32
+ assetUrl: null,
33
+ ...overrides,
34
+ };
35
+ }
36
+
37
+ function mockTargetMatching(selectorNeedle: string): EventTarget {
38
+ return {
39
+ closest: (selector: string) => (selector.includes(selectorNeedle) ? ({} as Element) : null),
40
+ } as unknown as EventTarget;
41
+ }
42
+
43
+ function mockKeyboardEvent(
44
+ code: string,
45
+ overrides: Partial<Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "target">> = {},
46
+ ): Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "code" | "target"> {
47
+ return {
48
+ altKey: false,
49
+ ctrlKey: false,
50
+ metaKey: false,
51
+ code,
52
+ target: mockTargetMatching("[data-missing]"),
53
+ ...overrides,
54
+ };
55
+ }
56
+
8
57
  describe("buildStandaloneRootTimelineElement", () => {
9
58
  it("includes selector and source metadata for standalone composition fallback clips", () => {
10
59
  expect(
@@ -65,6 +114,38 @@ describe("resolveStandaloneRootCompositionSrc", () => {
65
114
  });
66
115
  });
67
116
 
117
+ describe("findTimelineDomNodeForClip", () => {
118
+ it("matches anonymous manifest clips back to repeated DOM nodes in timeline order", () => {
119
+ const doc = createDocument(`
120
+ <div data-composition-id="main" data-start="0" data-duration="8">
121
+ <section id="identity-card" class="clip identity-card" data-start="0" data-duration="4" data-track-index="0"></section>
122
+ <div class="clip duplicate-card first" data-start="0" data-duration="4" data-track-index="1"></div>
123
+ <div class="clip duplicate-card second" data-start="0" data-duration="4" data-track-index="2"></div>
124
+ </div>
125
+ `);
126
+ const used = new Set<Element>();
127
+
128
+ const first = findTimelineDomNodeForClip(
129
+ doc,
130
+ createClip({ id: "__node__index_2", track: 1 }),
131
+ 1,
132
+ used,
133
+ ) as HTMLElement;
134
+ used.add(first);
135
+ const second = findTimelineDomNodeForClip(
136
+ doc,
137
+ createClip({ id: "__node__index_3", track: 2 }),
138
+ 2,
139
+ used,
140
+ ) as HTMLElement;
141
+
142
+ expect(first.className).toBe("clip duplicate-card first");
143
+ expect(second.className).toBe("clip duplicate-card second");
144
+ expect(getTimelineElementSelector(first)).toBe(".duplicate-card");
145
+ expect(getTimelineElementSelector(second)).toBe(".duplicate-card");
146
+ });
147
+ });
148
+
68
149
  describe("mergeTimelineElementsPreservingDowngrades", () => {
69
150
  it("preserves missing current elements when a shorter manifest arrives", () => {
70
151
  expect(
@@ -94,3 +175,60 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
94
175
  ).toEqual([{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }]);
95
176
  });
96
177
  });
178
+
179
+ describe("shouldIgnorePlaybackShortcutTarget", () => {
180
+ it("ignores focused toolbar buttons so Space can activate the button itself", () => {
181
+ expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("button"))).toBe(true);
182
+ });
183
+
184
+ it("ignores the seek slider so ArrowRight reaches the slider key handler", () => {
185
+ expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("[role='slider']"))).toBe(true);
186
+ });
187
+
188
+ it("allows non-interactive preview targets to use playback shortcuts", () => {
189
+ expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("[data-missing]"))).toBe(false);
190
+ });
191
+ });
192
+
193
+ describe("shouldIgnorePlaybackShortcutEvent", () => {
194
+ it("ignores modified playback shortcuts so browser and app chords can handle them", () => {
195
+ expect(
196
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft", { altKey: true })),
197
+ ).toBe(true);
198
+ expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyK", { ctrlKey: true }))).toBe(
199
+ true,
200
+ );
201
+ expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyL", { metaKey: true }))).toBe(
202
+ true,
203
+ );
204
+ });
205
+
206
+ it("defers Arrow frame shortcuts while caption edit mode has selected words", () => {
207
+ const captionSelection = { isCaptionEditMode: true, selectedCaptionSegmentCount: 1 };
208
+
209
+ expect(
210
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft"), captionSelection),
211
+ ).toBe(true);
212
+ expect(
213
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), captionSelection),
214
+ ).toBe(true);
215
+ expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyJ"), captionSelection)).toBe(
216
+ false,
217
+ );
218
+ });
219
+
220
+ it("allows Arrow frame shortcuts when captions are not selected", () => {
221
+ expect(
222
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), {
223
+ isCaptionEditMode: true,
224
+ selectedCaptionSegmentCount: 0,
225
+ }),
226
+ ).toBe(false);
227
+ expect(
228
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), {
229
+ isCaptionEditMode: false,
230
+ selectedCaptionSegmentCount: 1,
231
+ }),
232
+ ).toBe(false);
233
+ });
234
+ });