@hyperframes/studio 0.5.0-alpha.8 → 0.5.0

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 (69) hide show
  1. package/dist/assets/hyperframes-player-CoI5h1xv.js +353 -0
  2. package/dist/assets/index-BKjcNNNd.css +1 -0
  3. package/dist/assets/index-CqiisJmo.js +93 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +208 -1436
  7. package/src/captions/components/CaptionOverlay.tsx +2 -1
  8. package/src/captions/generator.test.ts +19 -0
  9. package/src/captions/generator.ts +9 -2
  10. package/src/captions/hooks/useCaptionSync.ts +6 -1
  11. package/src/captions/keyboard.test.ts +38 -0
  12. package/src/captions/keyboard.ts +8 -0
  13. package/src/captions/parser.test.ts +14 -0
  14. package/src/captions/parser.ts +1 -0
  15. package/src/components/LintModal.tsx +4 -3
  16. package/src/components/editor/PropertyPanel.tsx +206 -2462
  17. package/src/components/nle/NLELayout.tsx +47 -17
  18. package/src/components/nle/NLEPreview.tsx +9 -50
  19. package/src/components/sidebar/AssetsTab.tsx +4 -3
  20. package/src/components/sidebar/CompositionsTab.test.ts +1 -16
  21. package/src/components/sidebar/CompositionsTab.tsx +45 -117
  22. package/src/components/sidebar/LeftSidebar.tsx +55 -34
  23. package/src/components/ui/HyperframesLoader.tsx +104 -0
  24. package/src/components/ui/index.ts +2 -0
  25. package/src/icons/SystemIcons.tsx +2 -0
  26. package/src/player/components/CompositionThumbnail.tsx +10 -42
  27. package/src/player/components/EditModal.tsx +20 -5
  28. package/src/player/components/Player.tsx +129 -28
  29. package/src/player/components/PlayerControls.tsx +117 -49
  30. package/src/player/components/Timeline.test.ts +0 -12
  31. package/src/player/components/Timeline.tsx +25 -52
  32. package/src/player/components/TimelineClip.tsx +9 -21
  33. package/src/player/components/timelineEditing.test.ts +4 -2
  34. package/src/player/components/timelineEditing.ts +3 -1
  35. package/src/player/components/timelineTheme.test.ts +19 -0
  36. package/src/player/components/timelineTheme.ts +8 -4
  37. package/src/player/hooks/useTimelinePlayer.test.ts +219 -1
  38. package/src/player/hooks/useTimelinePlayer.ts +487 -106
  39. package/src/player/lib/time.test.ts +29 -1
  40. package/src/player/lib/time.ts +26 -0
  41. package/src/player/store/playerStore.test.ts +11 -1
  42. package/src/player/store/playerStore.ts +6 -1
  43. package/src/styles/studio.css +112 -0
  44. package/src/utils/frameCapture.test.ts +26 -0
  45. package/src/utils/frameCapture.ts +40 -0
  46. package/src/utils/mediaTypes.ts +1 -1
  47. package/src/utils/projectRouting.test.ts +87 -0
  48. package/src/utils/projectRouting.ts +27 -0
  49. package/src/utils/sourcePatcher.test.ts +1 -128
  50. package/src/utils/sourcePatcher.ts +18 -130
  51. package/src/utils/timelineAssetDrop.test.ts +11 -31
  52. package/src/utils/timelineAssetDrop.ts +2 -22
  53. package/dist/assets/hyperframes-player-vibA20NC.js +0 -198
  54. package/dist/assets/index-0Zt0t13W.css +0 -1
  55. package/dist/assets/index-C9f5eif8.js +0 -105
  56. package/src/components/editor/DomEditOverlay.tsx +0 -442
  57. package/src/components/editor/colorValue.test.ts +0 -82
  58. package/src/components/editor/colorValue.ts +0 -175
  59. package/src/components/editor/domEditing.test.ts +0 -537
  60. package/src/components/editor/domEditing.ts +0 -762
  61. package/src/components/editor/floatingPanel.test.ts +0 -34
  62. package/src/components/editor/floatingPanel.ts +0 -54
  63. package/src/components/editor/fontAssets.ts +0 -32
  64. package/src/components/editor/fontCatalog.ts +0 -126
  65. package/src/components/editor/gradientValue.test.ts +0 -89
  66. package/src/components/editor/gradientValue.ts +0 -445
  67. package/src/player/components/CompositionThumbnail.test.ts +0 -19
  68. package/src/utils/clipboard.test.ts +0 -88
  69. package/src/utils/clipboard.ts +0 -57
@@ -8,7 +8,6 @@ import {
8
8
  getTimelinePlayheadLeft,
9
9
  getTimelineScrollLeftForZoomAnchor,
10
10
  getTimelineScrollLeftForZoomTransition,
11
- shouldShowTimelineShortcutHint,
12
11
  shouldHandleTimelineDeleteKey,
13
12
  shouldAutoScrollTimeline,
14
13
  } from "./Timeline";
@@ -238,17 +237,6 @@ describe("getTimelineCanvasHeight", () => {
238
237
  });
239
238
  });
240
239
 
241
- describe("shouldShowTimelineShortcutHint", () => {
242
- it("shows the hint when the timeline does not vertically overflow", () => {
243
- expect(shouldShowTimelineShortcutHint(220, 220)).toBe(true);
244
- expect(shouldShowTimelineShortcutHint(220.5, 220)).toBe(true);
245
- });
246
-
247
- it("hides the hint when timeline tracks need vertical scrolling", () => {
248
- expect(shouldShowTimelineShortcutHint(221.5, 220)).toBe(false);
249
- });
250
- });
251
-
252
240
  describe("shouldHandleTimelineDeleteKey", () => {
253
241
  it("handles Delete and Backspace when focus is not in an editor", () => {
254
242
  expect(shouldHandleTimelineDeleteKey({ key: "Delete" })).toBe(true);
@@ -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 = 20;
38
+ const TIMELINE_SCROLL_BUFFER = 24;
39
39
 
40
40
  interface TrackVisualStyle extends TimelineTrackStyle {
41
41
  icon: ReactNode;
@@ -216,14 +216,6 @@ export function getTimelineCanvasHeight(trackCount: number): number {
216
216
  return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
217
217
  }
218
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
-
227
219
  export function shouldHandleTimelineDeleteKey(input: {
228
220
  key: string;
229
221
  metaKey?: boolean;
@@ -287,6 +279,7 @@ export function resolveTimelineAssetDrop(
287
279
  track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
288
280
  };
289
281
  }
282
+
290
283
  /* ── Component ──────────────────────────────────────────────────── */
291
284
  interface TimelineProps {
292
285
  /** Called when user seeks via ruler/track click or playhead drag */
@@ -434,51 +427,30 @@ export const Timeline = memo(function Timeline({
434
427
  onDeleteElementRef.current = onDeleteElement;
435
428
  const suppressClickRef = useRef(false);
436
429
  const [showPopover, setShowPopover] = useState(false);
437
- const [showShortcutHint, setShowShortcutHint] = useState(true);
438
430
  const [viewportWidth, setViewportWidth] = useState(0);
439
431
  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]);
454
432
 
455
433
  // Callback ref: sets up ResizeObserver when the DOM element actually mounts.
456
434
  // useMountEffect can't work here because the component returns null on first
457
435
  // render (timelineReady=false), so containerRef.current is null when the
458
436
  // effect fires and the ResizeObserver is never created.
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
- );
437
+ const setContainerRef = useCallback((el: HTMLDivElement | null) => {
438
+ if (roRef.current) {
439
+ roRef.current.disconnect();
440
+ roRef.current = null;
441
+ }
442
+ containerRef.current = el;
443
+ if (!el) return;
444
+ setViewportWidth(el.clientWidth);
445
+ roRef.current = new ResizeObserver(([entry]) => {
446
+ setViewportWidth(entry.contentRect.width);
447
+ });
448
+ roRef.current.observe(el);
449
+ }, []);
477
450
 
478
451
  // Clean up ResizeObserver on unmount
479
452
  useMountEffect(() => () => {
480
453
  roRef.current?.disconnect();
481
- if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
482
454
  });
483
455
 
484
456
  // Effective duration: max of store duration and the furthest element end.
@@ -523,7 +495,6 @@ export const Timeline = memo(function Timeline({
523
495
  }
524
496
  return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
525
497
  }, [draggedClip, trackOrder]);
526
- const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
527
498
  const selectedElement = useMemo(
528
499
  () => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
529
500
  [elements, selectedElementId],
@@ -573,6 +544,7 @@ export const Timeline = memo(function Timeline({
573
544
  );
574
545
  previousZoomModeRef.current = zoomMode;
575
546
  }, [zoomMode]);
547
+
576
548
  useMountEffect(() => {
577
549
  const unsub = liveTime.subscribe((t) => {
578
550
  const dur = durationRef.current;
@@ -1040,12 +1012,12 @@ export const Timeline = memo(function Timeline({
1040
1012
  );
1041
1013
  const majorTickInterval =
1042
1014
  major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
1043
- useEffect(() => {
1044
- syncShortcutHintVisibility();
1045
- }, [syncShortcutHintVisibility, timelineReady, elements.length, totalH]);
1046
1015
  const getPreviewElement = useCallback(
1047
1016
  (element: TimelineElement): TimelineElement => {
1048
- if (resizingClip?.element.id === element.id) {
1017
+ if (
1018
+ resizingClip &&
1019
+ (resizingClip.element.key ?? resizingClip.element.id) === (element.key ?? element.id)
1020
+ ) {
1049
1021
  return {
1050
1022
  ...element,
1051
1023
  start: resizingClip.previewStart,
@@ -1267,12 +1239,13 @@ export const Timeline = memo(function Timeline({
1267
1239
  );
1268
1240
  }
1269
1241
 
1242
+ const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
1270
1243
  const draggedElement = draggedClip?.element ?? null;
1271
1244
  const activeDraggedElement =
1272
1245
  draggedClip?.started === true && draggedElement
1273
1246
  ? getRenderedTimelineElement({
1274
1247
  element: draggedElement,
1275
- draggedElementId: draggedElement.id,
1248
+ draggedElementId: draggedElement.key ?? draggedElement.id,
1276
1249
  previewStart: draggedClip.previewStart,
1277
1250
  previewTrack: draggedClip.previewTrack,
1278
1251
  })
@@ -1340,7 +1313,7 @@ export const Timeline = memo(function Timeline({
1340
1313
  <div
1341
1314
  ref={setContainerRef}
1342
1315
  aria-label="Timeline"
1343
- className={`relative border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
1316
+ className={`border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
1344
1317
  style={{
1345
1318
  touchAction: "pan-x pan-y",
1346
1319
  background: theme.shellBackground,
@@ -1679,8 +1652,8 @@ export const Timeline = memo(function Timeline({
1679
1652
  </div>
1680
1653
  </div>
1681
1654
 
1682
- {/* Keyboard shortcut hint */}
1683
- {showShortcutHint && !showPopover && !rangeSelection && (
1655
+ {/* Keyboard shortcut hint — always visible */}
1656
+ {!showPopover && !rangeSelection && (
1684
1657
  <div className="absolute bottom-2 right-3 pointer-events-none z-20">
1685
1658
  <div
1686
1659
  className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
@@ -61,26 +61,8 @@ export const TimelineClip = memo(function TimelineClip({
61
61
  ? theme.clipShadowHover
62
62
  : theme.clipShadow;
63
63
  const capabilities = getTimelineEditCapabilities(el);
64
+ const displayLabel = el.label || el.id || el.tag;
64
65
  const showHandles = handleOpacity > 0.01;
65
- const baseBackgroundImage = isSelected ? theme.clipBackgroundActive : theme.clipBackground;
66
- const glossBackgroundImage = isSelected
67
- ? "linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0))"
68
- : "linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0))";
69
- const accentBackgroundImage = `linear-gradient(120deg, ${trackStyle.accent}${
70
- isSelected ? "22" : "1e"
71
- }, transparent 28%)`;
72
- const compositionStripeBackgroundImage =
73
- isComposition && !hasCustomContent
74
- ? "repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)"
75
- : undefined;
76
- const clipBackgroundImage = [
77
- compositionStripeBackgroundImage,
78
- glossBackgroundImage,
79
- accentBackgroundImage,
80
- baseBackgroundImage,
81
- ]
82
- .filter(Boolean)
83
- .join(", ");
84
66
 
85
67
  return (
86
68
  <div
@@ -94,7 +76,13 @@ export const TimelineClip = memo(function TimelineClip({
94
76
  top: clipY,
95
77
  bottom: clipY,
96
78
  borderRadius: theme.clipRadius,
97
- backgroundImage: clipBackgroundImage,
79
+ background: isSelected
80
+ ? `linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}22, transparent 28%), ${theme.clipBackgroundActive}`
81
+ : `linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}1e, transparent 28%), ${theme.clipBackground}`,
82
+ backgroundImage:
83
+ isComposition && !hasCustomContent
84
+ ? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)`
85
+ : undefined,
98
86
  border: `1px solid ${borderColor}`,
99
87
  boxShadow,
100
88
  transition:
@@ -106,7 +94,7 @@ export const TimelineClip = memo(function TimelineClip({
106
94
  title={
107
95
  isComposition
108
96
  ? `${el.compositionSrc} \u2022 Double-click to open`
109
- : `${el.id || el.tag} \u2022 ${el.start.toFixed(1)}s \u2013 ${(el.start + el.duration).toFixed(1)}s`
97
+ : `${displayLabel} \u2022 ${el.start.toFixed(1)}s \u2013 ${(el.start + el.duration).toFixed(1)}s`
110
98
  }
111
99
  onPointerEnter={onHoverStart}
112
100
  onPointerLeave={onHoverEnd}
@@ -248,7 +248,7 @@ describe("getTimelineEditCapabilities", () => {
248
248
  });
249
249
  });
250
250
 
251
- it("allows moving generic motion clips while keeping trims blocked", () => {
251
+ it("disables move and trims for generic motion clips even when patchable", () => {
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: true,
259
+ canMove: false,
260
260
  canTrimStart: false,
261
261
  canTrimEnd: false,
262
262
  });
@@ -428,6 +428,7 @@ describe("buildClipRangeSelection", () => {
428
428
  });
429
429
  });
430
430
  });
431
+
431
432
  describe("resolveTimelineAutoScroll", () => {
432
433
  it("does not scroll when the pointer stays away from the edges", () => {
433
434
  expect(
@@ -511,6 +512,7 @@ describe("buildTimelineElementAgentPrompt", () => {
511
512
  ).toContain("If this clip is animated with GSAP");
512
513
  });
513
514
  });
515
+
514
516
  describe("resolveTimelineResize", () => {
515
517
  it("shrinks clip duration from the right edge", () => {
516
518
  expect(
@@ -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 || hasFiniteDuration),
236
+ canMove: canPatch && hasDeterministicWindow,
237
237
  canTrimEnd: canPatch && hasFiniteDuration && hasDeterministicWindow,
238
238
  canTrimStart: canPatch && hasFiniteDuration && canOffsetTrimClipStart(input),
239
239
  };
@@ -273,6 +273,7 @@ export function buildClipRangeSelection(
273
273
  anchorY: anchor.anchorY,
274
274
  };
275
275
  }
276
+
276
277
  export function buildTimelineAgentPrompt({
277
278
  rangeStart,
278
279
  rangeEnd,
@@ -346,6 +347,7 @@ export function buildTimelineElementAgentPrompt(element: {
346
347
 
347
348
  return lines.join("\n");
348
349
  }
350
+
349
351
  export function formatTimelineAttributeNumber(value: number): string {
350
352
  return Number(roundToCentiseconds(value).toFixed(2)).toString();
351
353
  }
@@ -53,4 +53,23 @@ describe("getRenderedTimelineElement", () => {
53
53
  }),
54
54
  ).toEqual({ ...element, start: 2.4, track: 3 });
55
55
  });
56
+
57
+ it("uses key before id when matching the dragged clip", () => {
58
+ const element = {
59
+ id: "Card",
60
+ key: "index.html:.card:1",
61
+ tag: "div",
62
+ start: 1,
63
+ duration: 2,
64
+ track: 0,
65
+ };
66
+ expect(
67
+ getRenderedTimelineElement({
68
+ element,
69
+ draggedElementId: "index.html:.card:1",
70
+ previewStart: 2.4,
71
+ previewTrack: 3,
72
+ }),
73
+ ).toEqual({ ...element, start: 2.4, track: 3 });
74
+ });
56
75
  });
@@ -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",
@@ -130,7 +130,11 @@ export function getRenderedTimelineElement({
130
130
  previewStart: number | null;
131
131
  previewTrack: number | null;
132
132
  }): TimelineElement {
133
- if (element.id !== draggedElementId || previewStart === null || previewTrack === null) {
133
+ if (
134
+ (element.key ?? element.id) !== draggedElementId ||
135
+ previewStart === null ||
136
+ previewTrack === null
137
+ ) {
134
138
  return element;
135
139
  }
136
140
  return {
@@ -2,13 +2,37 @@ import { describe, expect, it } from "vitest";
2
2
  import { Window } from "happy-dom";
3
3
  import {
4
4
  buildStandaloneRootTimelineElement,
5
+ createTimelineElementFromManifestClip,
5
6
  findTimelineDomNodeForClip,
6
7
  getTimelineElementSelector,
8
+ parseTimelineFromDOM,
7
9
  type ClipManifestClip,
8
10
  mergeTimelineElementsPreservingDowngrades,
9
11
  resolveStandaloneRootCompositionSrc,
12
+ shouldIgnorePlaybackShortcutEvent,
13
+ shouldIgnorePlaybackShortcutTarget,
10
14
  } from "./useTimelinePlayer";
11
15
 
16
+ function mockTargetMatching(selectorNeedle: string): EventTarget {
17
+ return {
18
+ closest: (selector: string) => (selector.includes(selectorNeedle) ? ({} as Element) : null),
19
+ } as unknown as EventTarget;
20
+ }
21
+
22
+ function mockKeyboardEvent(
23
+ code: string,
24
+ overrides: Partial<Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "target">> = {},
25
+ ): Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "code" | "target"> {
26
+ return {
27
+ altKey: false,
28
+ ctrlKey: false,
29
+ metaKey: false,
30
+ code,
31
+ target: mockTargetMatching("[data-missing]"),
32
+ ...overrides,
33
+ };
34
+ }
35
+
12
36
  function createDocument(markup: string): Document {
13
37
  const window = new Window();
14
38
  window.document.body.innerHTML = markup;
@@ -18,7 +42,7 @@ function createDocument(markup: string): Document {
18
42
  function createClip(overrides: Partial<ClipManifestClip>): ClipManifestClip {
19
43
  return {
20
44
  id: null,
21
- label: "",
45
+ label: "Element",
22
46
  start: 0,
23
47
  duration: 4,
24
48
  track: 0,
@@ -44,6 +68,7 @@ describe("buildStandaloneRootTimelineElement", () => {
44
68
  }),
45
69
  ).toEqual({
46
70
  id: "hero",
71
+ label: "hero",
47
72
  key: 'scenes/hero.html:[data-composition-id="hero"]:0',
48
73
  tag: "div",
49
74
  start: 0,
@@ -124,6 +149,83 @@ describe("findTimelineDomNodeForClip", () => {
124
149
  });
125
150
  });
126
151
 
152
+ describe("anonymous timeline identity", () => {
153
+ it("keeps fallback-parsed anonymous clips distinct when labels match", () => {
154
+ const doc = createDocument(`
155
+ <div data-composition-id="main" data-start="0" data-duration="8">
156
+ <div class="clip card" data-label="Card" data-start="0" data-duration="3" data-track-index="0"></div>
157
+ <div class="clip card" data-label="Card" data-start="3" data-duration="3" data-track-index="1"></div>
158
+ </div>
159
+ `);
160
+
161
+ const elements = parseTimelineFromDOM(doc, 8);
162
+
163
+ expect(elements).toHaveLength(2);
164
+ expect(elements.map((element) => element.label)).toEqual(["Card", "Card"]);
165
+ expect(new Set(elements.map((element) => element.id)).size).toBe(2);
166
+ expect(new Set(elements.map((element) => element.key)).size).toBe(2);
167
+ expect(elements.map((element) => element.selectorIndex)).toEqual([0, 1]);
168
+ });
169
+
170
+ it("keeps runtime-manifest anonymous clips distinct when labels match", () => {
171
+ const doc = createDocument(`
172
+ <div data-composition-id="main" data-start="0" data-duration="8">
173
+ <div class="clip card" data-start="0" data-duration="3" data-track-index="0"></div>
174
+ <div class="clip card" data-start="3" data-duration="3" data-track-index="1"></div>
175
+ </div>
176
+ `);
177
+ const clips = [
178
+ createClip({ id: null, label: "Card", start: 0, duration: 3, track: 0 }),
179
+ createClip({ id: null, label: "Card", start: 3, duration: 3, track: 1 }),
180
+ ];
181
+ const used = new Set<Element>();
182
+ const elements = clips.map((clip, index) => {
183
+ const hostEl = findTimelineDomNodeForClip(doc, clip, index, used);
184
+ if (hostEl) used.add(hostEl);
185
+ return createTimelineElementFromManifestClip({
186
+ clip,
187
+ fallbackIndex: index,
188
+ doc,
189
+ hostEl,
190
+ });
191
+ });
192
+
193
+ expect(elements.map((element) => element.label)).toEqual(["Card", "Card"]);
194
+ expect(new Set(elements.map((element) => element.id)).size).toBe(2);
195
+ expect(new Set(elements.map((element) => element.key)).size).toBe(2);
196
+ expect(elements.map((element) => element.selectorIndex)).toEqual([0, 1]);
197
+ });
198
+
199
+ it("reads media metadata from owner-window media elements", () => {
200
+ const doc = createDocument(`
201
+ <div data-composition-id="main" data-start="0" data-duration="8">
202
+ <div class="clip video-card" data-start="0" data-duration="3" data-track-index="0">
203
+ <video src="/clip.mp4" data-source-duration="12"></video>
204
+ </div>
205
+ </div>
206
+ `);
207
+ const hostEl = doc.querySelector(".video-card");
208
+ const video = hostEl?.querySelector("video");
209
+ if (!hostEl || !video) throw new Error("missing video test fixture");
210
+ Object.defineProperty(video, "defaultPlaybackRate", {
211
+ value: 1.5,
212
+ configurable: true,
213
+ });
214
+
215
+ const element = createTimelineElementFromManifestClip({
216
+ clip: createClip({ kind: "video", tagName: "div" }),
217
+ fallbackIndex: 0,
218
+ doc,
219
+ hostEl,
220
+ });
221
+
222
+ expect(element.tag).toBe("video");
223
+ expect(element.src).toBe("/clip.mp4");
224
+ expect(element.sourceDuration).toBe(12);
225
+ expect(element.playbackRate).toBe(1.5);
226
+ });
227
+ });
228
+
127
229
  describe("mergeTimelineElementsPreservingDowngrades", () => {
128
230
  it("preserves missing current elements when a shorter manifest arrives", () => {
129
231
  expect(
@@ -152,4 +254,120 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
152
254
  ),
153
255
  ).toEqual([{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }]);
154
256
  });
257
+
258
+ it("preserves distinct anonymous clips that share the same friendly id label", () => {
259
+ expect(
260
+ mergeTimelineElementsPreservingDowngrades(
261
+ [
262
+ {
263
+ id: "Card",
264
+ key: "index.html:.card:0",
265
+ label: "Card",
266
+ tag: "div",
267
+ start: 0,
268
+ duration: 3,
269
+ track: 0,
270
+ },
271
+ {
272
+ id: "Card",
273
+ key: "index.html:.card:1",
274
+ label: "Card",
275
+ tag: "div",
276
+ start: 3,
277
+ duration: 3,
278
+ track: 1,
279
+ },
280
+ ],
281
+ [
282
+ {
283
+ id: "Card",
284
+ key: "index.html:.card:0",
285
+ label: "Card",
286
+ tag: "div",
287
+ start: 0,
288
+ duration: 3,
289
+ track: 0,
290
+ },
291
+ ],
292
+ 8,
293
+ 8,
294
+ ),
295
+ ).toEqual([
296
+ {
297
+ id: "Card",
298
+ key: "index.html:.card:0",
299
+ label: "Card",
300
+ tag: "div",
301
+ start: 0,
302
+ duration: 3,
303
+ track: 0,
304
+ },
305
+ {
306
+ id: "Card",
307
+ key: "index.html:.card:1",
308
+ label: "Card",
309
+ tag: "div",
310
+ start: 3,
311
+ duration: 3,
312
+ track: 1,
313
+ },
314
+ ]);
315
+ });
316
+ });
317
+
318
+ describe("shouldIgnorePlaybackShortcutTarget", () => {
319
+ it("ignores focused toolbar buttons so Space can activate the button itself", () => {
320
+ expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("button"))).toBe(true);
321
+ });
322
+
323
+ it("ignores the seek slider so ArrowRight reaches the slider key handler", () => {
324
+ expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("[role='slider']"))).toBe(true);
325
+ });
326
+
327
+ it("allows non-interactive preview targets to use playback shortcuts", () => {
328
+ expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("[data-missing]"))).toBe(false);
329
+ });
330
+ });
331
+
332
+ describe("shouldIgnorePlaybackShortcutEvent", () => {
333
+ it("ignores modified playback shortcuts so browser and app chords can handle them", () => {
334
+ expect(
335
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft", { altKey: true })),
336
+ ).toBe(true);
337
+ expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyK", { ctrlKey: true }))).toBe(
338
+ true,
339
+ );
340
+ expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyL", { metaKey: true }))).toBe(
341
+ true,
342
+ );
343
+ });
344
+
345
+ it("defers Arrow frame shortcuts while caption edit mode has selected words", () => {
346
+ const captionSelection = { isCaptionEditMode: true, selectedCaptionSegmentCount: 1 };
347
+
348
+ expect(
349
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft"), captionSelection),
350
+ ).toBe(true);
351
+ expect(
352
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), captionSelection),
353
+ ).toBe(true);
354
+ expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyJ"), captionSelection)).toBe(
355
+ false,
356
+ );
357
+ });
358
+
359
+ it("allows Arrow frame shortcuts when captions are not selected", () => {
360
+ expect(
361
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), {
362
+ isCaptionEditMode: true,
363
+ selectedCaptionSegmentCount: 0,
364
+ }),
365
+ ).toBe(false);
366
+ expect(
367
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), {
368
+ isCaptionEditMode: false,
369
+ selectedCaptionSegmentCount: 1,
370
+ }),
371
+ ).toBe(false);
372
+ });
155
373
  });