@hyperframes/studio 0.4.38 → 0.5.0-alpha.10

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 (51) hide show
  1. package/dist/assets/index-DKaNgV2Z.css +1 -0
  2. package/dist/assets/index-peNJzL-4.js +105 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +1431 -196
  6. package/src/components/LintModal.tsx +3 -4
  7. package/src/components/editor/DomEditOverlay.tsx +445 -0
  8. package/src/components/editor/PropertyPanel.tsx +2466 -206
  9. package/src/components/editor/colorValue.test.ts +82 -0
  10. package/src/components/editor/colorValue.ts +175 -0
  11. package/src/components/editor/domEditing.test.ts +537 -0
  12. package/src/components/editor/domEditing.ts +762 -0
  13. package/src/components/editor/floatingPanel.test.ts +34 -0
  14. package/src/components/editor/floatingPanel.ts +54 -0
  15. package/src/components/editor/fontAssets.ts +32 -0
  16. package/src/components/editor/fontCatalog.ts +126 -0
  17. package/src/components/editor/gradientValue.test.ts +89 -0
  18. package/src/components/editor/gradientValue.ts +445 -0
  19. package/src/components/nle/NLELayout.tsx +17 -47
  20. package/src/components/nle/NLEPreview.tsx +50 -5
  21. package/src/components/sidebar/AssetsTab.tsx +3 -4
  22. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  23. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  24. package/src/components/sidebar/LeftSidebar.tsx +34 -55
  25. package/src/icons/SystemIcons.tsx +0 -2
  26. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  27. package/src/player/components/CompositionThumbnail.tsx +42 -10
  28. package/src/player/components/EditModal.tsx +5 -20
  29. package/src/player/components/Player.tsx +18 -70
  30. package/src/player/components/PlayerControls.tsx +44 -3
  31. package/src/player/components/Timeline.test.ts +12 -0
  32. package/src/player/components/Timeline.tsx +51 -20
  33. package/src/player/components/TimelineClip.tsx +20 -7
  34. package/src/player/components/timelineEditing.test.ts +2 -4
  35. package/src/player/components/timelineEditing.ts +1 -3
  36. package/src/player/components/timelineTheme.ts +3 -3
  37. package/src/player/hooks/useTimelinePlayer.test.ts +59 -0
  38. package/src/player/hooks/useTimelinePlayer.ts +74 -32
  39. package/src/player/lib/time.test.ts +1 -11
  40. package/src/player/lib/time.ts +0 -6
  41. package/src/utils/clipboard.test.ts +88 -0
  42. package/src/utils/clipboard.ts +57 -0
  43. package/src/utils/mediaTypes.ts +1 -1
  44. package/src/utils/sourcePatcher.test.ts +128 -1
  45. package/src/utils/sourcePatcher.ts +130 -18
  46. package/src/utils/timelineAssetDrop.test.ts +31 -11
  47. package/src/utils/timelineAssetDrop.ts +22 -2
  48. package/dist/assets/index-18P_dZeo.js +0 -93
  49. package/dist/assets/index-BLrgRQSu.css +0 -1
  50. package/src/utils/frameCapture.test.ts +0 -26
  51. package/src/utils/frameCapture.ts +0 -38
@@ -1,6 +1,10 @@
1
1
  import { useRef, useState, useCallback, useEffect, memo } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
- import { formatFrameTime, frameToSeconds, stepFrameTime, formatTime } from "../lib/time";
3
+ import {
4
+ TIMELINE_TOGGLE_SHORTCUT_LABEL,
5
+ getTimelineToggleTitle,
6
+ } from "../../utils/timelineDiscovery";
7
+ import { formatFrameTime, frameToSeconds, formatTime } from "../lib/time";
4
8
  import { usePlayerStore, liveTime } from "../store/playerStore";
5
9
 
6
10
  const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
@@ -26,11 +30,15 @@ export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth:
26
30
  interface PlayerControlsProps {
27
31
  onTogglePlay: () => void;
28
32
  onSeek: (time: number) => void;
33
+ timelineVisible?: boolean;
34
+ onToggleTimeline?: () => void;
29
35
  }
30
36
 
31
37
  export const PlayerControls = memo(function PlayerControls({
32
38
  onTogglePlay,
33
39
  onSeek,
40
+ timelineVisible,
41
+ onToggleTimeline,
34
42
  }: PlayerControlsProps) {
35
43
  // Subscribe to only the fields we render — each selector prevents cascading re-renders
36
44
  const isPlaying = usePlayerStore((s) => s.isPlaying);
@@ -208,10 +216,10 @@ export const PlayerControls = memo(function PlayerControls({
208
216
  const step = e.shiftKey ? 10 : 1;
209
217
  if (e.key === "ArrowLeft") {
210
218
  e.preventDefault();
211
- onSeek(stepFrameTime(currentTimeRef.current, -step));
219
+ onSeek(Math.max(0, currentTimeRef.current - frameToSeconds(step)));
212
220
  } else if (e.key === "ArrowRight") {
213
221
  e.preventDefault();
214
- onSeek(Math.min(duration, stepFrameTime(currentTimeRef.current, step)));
222
+ onSeek(Math.min(duration, currentTimeRef.current + frameToSeconds(step)));
215
223
  }
216
224
  },
217
225
  [timelineReady, duration, onSeek],
@@ -429,6 +437,39 @@ export const PlayerControls = memo(function PlayerControls({
429
437
  </span>
430
438
  ))}
431
439
  </div>
440
+
441
+ {/* Timeline toggle */}
442
+ {onToggleTimeline !== undefined && (
443
+ <button
444
+ type="button"
445
+ onClick={onToggleTimeline}
446
+ className={`h-7 flex items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-medium transition-colors ${
447
+ timelineVisible
448
+ ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
449
+ : "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
450
+ }`}
451
+ title={getTimelineToggleTitle(Boolean(timelineVisible))}
452
+ aria-label={timelineVisible ? "Hide timeline editor" : "Show timeline editor"}
453
+ >
454
+ <svg
455
+ width="13"
456
+ height="13"
457
+ viewBox="0 0 24 24"
458
+ fill="none"
459
+ stroke="currentColor"
460
+ strokeWidth="2"
461
+ strokeLinecap="round"
462
+ >
463
+ <rect x="3" y="13" width="18" height="8" rx="1" />
464
+ <line x1="3" y1="9" x2="21" y2="9" />
465
+ <line x1="3" y1="5" x2="21" y2="5" />
466
+ </svg>
467
+ <span>Timeline</span>
468
+ <span className="hidden md:inline rounded bg-black/20 px-1 py-0.5 text-[9px] font-mono opacity-70">
469
+ {TIMELINE_TOGGLE_SHORTCUT_LABEL}
470
+ </span>
471
+ </button>
472
+ )}
432
473
  </div>
433
474
  );
434
475
  });
@@ -8,6 +8,7 @@ import {
8
8
  getTimelinePlayheadLeft,
9
9
  getTimelineScrollLeftForZoomAnchor,
10
10
  getTimelineScrollLeftForZoomTransition,
11
+ shouldShowTimelineShortcutHint,
11
12
  shouldHandleTimelineDeleteKey,
12
13
  shouldAutoScrollTimeline,
13
14
  } from "./Timeline";
@@ -237,6 +238,17 @@ describe("getTimelineCanvasHeight", () => {
237
238
  });
238
239
  });
239
240
 
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
+
240
252
  describe("shouldHandleTimelineDeleteKey", () => {
241
253
  it("handles Delete and Backspace when focus is not in an editor", () => {
242
254
  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 = 24;
38
+ const TIMELINE_SCROLL_BUFFER = 20;
39
39
 
40
40
  interface TrackVisualStyle extends TimelineTrackStyle {
41
41
  icon: ReactNode;
@@ -216,6 +216,14 @@ 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
+
219
227
  export function shouldHandleTimelineDeleteKey(input: {
220
228
  key: string;
221
229
  metaKey?: boolean;
@@ -279,7 +287,6 @@ export function resolveTimelineAssetDrop(
279
287
  track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
280
288
  };
281
289
  }
282
-
283
290
  /* ── Component ──────────────────────────────────────────────────── */
284
291
  interface TimelineProps {
285
292
  /** Called when user seeks via ruler/track click or playhead drag */
@@ -427,30 +434,51 @@ export const Timeline = memo(function Timeline({
427
434
  onDeleteElementRef.current = onDeleteElement;
428
435
  const suppressClickRef = useRef(false);
429
436
  const [showPopover, setShowPopover] = useState(false);
437
+ const [showShortcutHint, setShowShortcutHint] = useState(true);
430
438
  const [viewportWidth, setViewportWidth] = useState(0);
431
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]);
432
454
 
433
455
  // Callback ref: sets up ResizeObserver when the DOM element actually mounts.
434
456
  // useMountEffect can't work here because the component returns null on first
435
457
  // render (timelineReady=false), so containerRef.current is null when the
436
458
  // effect fires and the ResizeObserver is never created.
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
- }, []);
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
+ );
450
477
 
451
478
  // Clean up ResizeObserver on unmount
452
479
  useMountEffect(() => () => {
453
480
  roRef.current?.disconnect();
481
+ if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
454
482
  });
455
483
 
456
484
  // Effective duration: max of store duration and the furthest element end.
@@ -495,6 +523,7 @@ export const Timeline = memo(function Timeline({
495
523
  }
496
524
  return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
497
525
  }, [draggedClip, trackOrder]);
526
+ const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
498
527
  const selectedElement = useMemo(
499
528
  () => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
500
529
  [elements, selectedElementId],
@@ -544,7 +573,6 @@ export const Timeline = memo(function Timeline({
544
573
  );
545
574
  previousZoomModeRef.current = zoomMode;
546
575
  }, [zoomMode]);
547
-
548
576
  useMountEffect(() => {
549
577
  const unsub = liveTime.subscribe((t) => {
550
578
  const dur = durationRef.current;
@@ -1012,6 +1040,10 @@ export const Timeline = memo(function Timeline({
1012
1040
  );
1013
1041
  const majorTickInterval =
1014
1042
  major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
1043
+ useEffect(() => {
1044
+ syncShortcutHintVisibility();
1045
+ }, [syncShortcutHintVisibility, timelineReady, elements.length, totalH]);
1046
+
1015
1047
  const getPreviewElement = useCallback(
1016
1048
  (element: TimelineElement): TimelineElement => {
1017
1049
  if (resizingClip?.element.id === element.id) {
@@ -1236,7 +1268,6 @@ export const Timeline = memo(function Timeline({
1236
1268
  );
1237
1269
  }
1238
1270
 
1239
- const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
1240
1271
  const draggedElement = draggedClip?.element ?? null;
1241
1272
  const activeDraggedElement =
1242
1273
  draggedClip?.started === true && draggedElement
@@ -1310,7 +1341,7 @@ export const Timeline = memo(function Timeline({
1310
1341
  <div
1311
1342
  ref={setContainerRef}
1312
1343
  aria-label="Timeline"
1313
- 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"}`}
1314
1345
  style={{
1315
1346
  touchAction: "pan-x pan-y",
1316
1347
  background: theme.shellBackground,
@@ -1649,8 +1680,8 @@ export const Timeline = memo(function Timeline({
1649
1680
  </div>
1650
1681
  </div>
1651
1682
 
1652
- {/* Keyboard shortcut hint — always visible */}
1653
- {!showPopover && !rangeSelection && (
1683
+ {/* Keyboard shortcut hint */}
1684
+ {showShortcutHint && !showPopover && !rangeSelection && (
1654
1685
  <div className="absolute bottom-2 right-3 pointer-events-none z-20">
1655
1686
  <div
1656
1687
  className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
@@ -62,6 +62,25 @@ export const TimelineClip = memo(function TimelineClip({
62
62
  : theme.clipShadow;
63
63
  const capabilities = getTimelineEditCapabilities(el);
64
64
  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(", ");
65
84
 
66
85
  return (
67
86
  <div
@@ -75,13 +94,7 @@ export const TimelineClip = memo(function TimelineClip({
75
94
  top: clipY,
76
95
  bottom: clipY,
77
96
  borderRadius: theme.clipRadius,
78
- background: isSelected
79
- ? `linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}22, transparent 28%), ${theme.clipBackgroundActive}`
80
- : `linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}1e, transparent 28%), ${theme.clipBackground}`,
81
- backgroundImage:
82
- isComposition && !hasCustomContent
83
- ? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)`
84
- : undefined,
97
+ backgroundImage: clipBackgroundImage,
85
98
  border: `1px solid ${borderColor}`,
86
99
  boxShadow,
87
100
  transition:
@@ -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
  });
@@ -428,7 +428,6 @@ describe("buildClipRangeSelection", () => {
428
428
  });
429
429
  });
430
430
  });
431
-
432
431
  describe("resolveTimelineAutoScroll", () => {
433
432
  it("does not scroll when the pointer stays away from the edges", () => {
434
433
  expect(
@@ -512,7 +511,6 @@ describe("buildTimelineElementAgentPrompt", () => {
512
511
  ).toContain("If this clip is animated with GSAP");
513
512
  });
514
513
  });
515
-
516
514
  describe("resolveTimelineResize", () => {
517
515
  it("shrinks clip duration from the right edge", () => {
518
516
  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,
236
+ canMove: canPatch && (hasDeterministicWindow || hasFiniteDuration),
237
237
  canTrimEnd: canPatch && hasFiniteDuration && hasDeterministicWindow,
238
238
  canTrimStart: canPatch && hasFiniteDuration && canOffsetTrimClipStart(input),
239
239
  };
@@ -273,7 +273,6 @@ export function buildClipRangeSelection(
273
273
  anchorY: anchor.anchorY,
274
274
  };
275
275
  }
276
-
277
276
  export function buildTimelineAgentPrompt({
278
277
  rangeStart,
279
278
  rangeEnd,
@@ -347,7 +346,6 @@ export function buildTimelineElementAgentPrompt(element: {
347
346
 
348
347
  return lines.join("\n");
349
348
  }
350
-
351
349
  export function formatTimelineAttributeNumber(value: number): string {
352
350
  return Number(roundToCentiseconds(value).toFixed(2)).toString();
353
351
  }
@@ -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: "#0A0A0B",
66
+ shellBackground: "#0A0E15",
67
67
  shellBorder: "rgba(255,255,255,0.05)",
68
68
  rulerBorder: "rgba(255,255,255,0.045)",
69
- rowBackground: "#0A0A0B",
69
+ rowBackground: "#0A0E15",
70
70
  rowBorder: "rgba(255,255,255,0.05)",
71
- gutterBackground: "#0A0A0B",
71
+ gutterBackground: "#0D121B",
72
72
  gutterBorder: "rgba(255,255,255,0.05)",
73
73
  textPrimary: "#E8EDF5",
74
74
  textSecondary: "#8391A8",
@@ -1,12 +1,39 @@
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,
6
10
  shouldIgnorePlaybackShortcutEvent,
7
11
  shouldIgnorePlaybackShortcutTarget,
8
12
  } from "./useTimelinePlayer";
9
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
+
10
37
  function mockTargetMatching(selectorNeedle: string): EventTarget {
11
38
  return {
12
39
  closest: (selector: string) => (selector.includes(selectorNeedle) ? ({} as Element) : null),
@@ -87,6 +114,38 @@ describe("resolveStandaloneRootCompositionSrc", () => {
87
114
  });
88
115
  });
89
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
+
90
149
  describe("mergeTimelineElementsPreservingDowngrades", () => {
91
150
  it("preserves missing current elements when a shorter manifest arrives", () => {
92
151
  expect(
@@ -1,7 +1,7 @@
1
1
  import { useRef, useCallback } from "react";
2
2
  import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore";
3
3
  import { useMountEffect } from "../../hooks/useMountEffect";
4
- import { stepFrameTime, STUDIO_PREVIEW_FPS } from "../lib/time";
4
+ import { frameToSeconds, STUDIO_PREVIEW_FPS } from "../lib/time";
5
5
  import { useCaptionStore } from "../../captions/store";
6
6
 
7
7
  interface PlaybackAdapter {
@@ -22,7 +22,7 @@ interface TimelineLike {
22
22
  isActive: () => boolean;
23
23
  }
24
24
 
25
- interface ClipManifestClip {
25
+ export interface ClipManifestClip {
26
26
  id: string | null;
27
27
  label: string;
28
28
  start: number;
@@ -253,12 +253,18 @@ function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElem
253
253
  return els;
254
254
  }
255
255
 
256
- function getTimelineElementSelector(el: Element): string | undefined {
257
- if (el instanceof HTMLElement && el.id) return `#${el.id}`;
256
+ function isHtmlElement(el: Element): el is HTMLElement {
257
+ const HtmlElementCtor = el.ownerDocument.defaultView?.HTMLElement ?? globalThis.HTMLElement;
258
+ return typeof HtmlElementCtor !== "undefined" && el instanceof HtmlElementCtor;
259
+ }
260
+
261
+ export function getTimelineElementSelector(el: Element): string | undefined {
262
+ if (isHtmlElement(el) && el.id) return `#${el.id}`;
258
263
  const compId = el.getAttribute("data-composition-id");
259
264
  if (compId) return `[data-composition-id="${compId}"]`;
260
- if (el instanceof HTMLElement) {
261
- const firstClass = el.className.split(/\s+/).find(Boolean);
265
+ if (isHtmlElement(el)) {
266
+ const classes = el.className.split(/\s+/).filter(Boolean);
267
+ const firstClass = classes.find((className) => className !== "clip") ?? classes[0];
262
268
  if (firstClass) return `.${firstClass}`;
263
269
  }
264
270
  return undefined;
@@ -305,6 +311,47 @@ function buildTimelineElementKey(params: {
305
311
  return `${scope}:${params.id}:${params.fallbackIndex}`;
306
312
  }
307
313
 
314
+ function getTimelineDomNodes(doc: Document): Element[] {
315
+ const rootComp = doc.querySelector("[data-composition-id]");
316
+ return Array.from(doc.querySelectorAll("[data-start]")).filter((node) => node !== rootComp);
317
+ }
318
+
319
+ function numbersNearlyEqual(a: number, b: number): boolean {
320
+ return Math.abs(a - b) < 0.001;
321
+ }
322
+
323
+ function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean {
324
+ const tagName = clip.tagName?.toLowerCase();
325
+ if (tagName && node.tagName.toLowerCase() !== tagName) return false;
326
+
327
+ const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
328
+ if (Number.isFinite(start) && !numbersNearlyEqual(start, clip.start)) return false;
329
+
330
+ const duration = Number.parseFloat(node.getAttribute("data-duration") ?? "");
331
+ if (Number.isFinite(duration) && !numbersNearlyEqual(duration, clip.duration)) return false;
332
+
333
+ const track = Number.parseInt(node.getAttribute("data-track-index") ?? "", 10);
334
+ if (Number.isFinite(track) && track !== clip.track) return false;
335
+
336
+ return true;
337
+ }
338
+
339
+ export function findTimelineDomNodeForClip(
340
+ doc: Document,
341
+ clip: ClipManifestClip,
342
+ fallbackIndex: number,
343
+ usedNodes = new Set<Element>(),
344
+ ): Element | null {
345
+ const byIdentity = clip.id ? findTimelineDomNode(doc, clip.id) : null;
346
+ if (byIdentity && !usedNodes.has(byIdentity)) return byIdentity;
347
+
348
+ const candidates = getTimelineDomNodes(doc).filter((node) => !usedNodes.has(node));
349
+ const exact = candidates.find((node) => nodeMatchesManifestClip(node, clip));
350
+ if (exact) return exact;
351
+
352
+ return candidates[fallbackIndex] ?? null;
353
+ }
354
+
308
355
  function findTimelineDomNode(doc: Document, id: string): Element | null {
309
356
  return (
310
357
  doc.getElementById(id) ??
@@ -350,7 +397,6 @@ export function buildStandaloneRootTimelineElement(params: {
350
397
  sourceFile: compositionSrc,
351
398
  };
352
399
  }
353
-
354
400
  function normalizePreviewViewport(doc: Document, win: Window): void {
355
401
  if (doc.documentElement) {
356
402
  doc.documentElement.style.overflow = "hidden";
@@ -697,7 +743,7 @@ export function useTimelinePlayer() {
697
743
  (deltaFrames: number) => {
698
744
  const adapter = getAdapter();
699
745
  const currentTime = adapter?.getTime() ?? usePlayerStore.getState().currentTime;
700
- seek(stepFrameTime(currentTime, deltaFrames, STUDIO_PREVIEW_FPS));
746
+ seek(currentTime + frameToSeconds(deltaFrames, STUDIO_PREVIEW_FPS));
701
747
  },
702
748
  [getAdapter, seek],
703
749
  );
@@ -822,8 +868,18 @@ export function useTimelinePlayer() {
822
868
  const filtered = data.clips.filter(
823
869
  (clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId),
824
870
  );
871
+ let iframeDoc: Document | null = null;
872
+ try {
873
+ iframeDoc = iframeRef.current?.contentDocument ?? null;
874
+ } catch {
875
+ iframeDoc = null;
876
+ }
877
+ const usedHostEls = new Set<Element>();
825
878
  const els: TimelineElement[] = filtered.map((clip, index) => {
826
- let hostEl: Element | null = null;
879
+ let hostEl = iframeDoc
880
+ ? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls)
881
+ : null;
882
+ if (hostEl) usedHostEls.add(hostEl);
827
883
  const id = clip.id || clip.label || clip.tagName || "element";
828
884
  const entry: TimelineElement = {
829
885
  id,
@@ -832,16 +888,7 @@ export function useTimelinePlayer() {
832
888
  duration: clip.duration,
833
889
  track: clip.track,
834
890
  };
835
- try {
836
- const iframeDoc = iframeRef.current?.contentDocument;
837
- if (iframeDoc && entry.id) {
838
- hostEl = findTimelineDomNode(iframeDoc, entry.id);
839
- }
840
- } catch {
841
- /* cross-origin */
842
- }
843
891
  if (hostEl) {
844
- const iframeDoc = iframeRef.current?.contentDocument;
845
892
  entry.domId = hostEl.id || undefined;
846
893
  entry.selector = getTimelineElementSelector(hostEl);
847
894
  entry.selectorIndex =
@@ -857,19 +904,13 @@ export function useTimelinePlayer() {
857
904
  // after inlining, so the clip manifest may not have compositionSrc.
858
905
  // Fall back to reading data-composition-file from the DOM.
859
906
  let resolvedSrc = clip.compositionSrc;
860
- let hostEl: Element | null = null;
861
907
  if (!resolvedSrc) {
862
- try {
863
- const iframeDoc = iframeRef.current?.contentDocument;
864
- hostEl =
865
- iframeDoc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
866
- resolvedSrc =
867
- hostEl?.getAttribute("data-composition-src") ??
868
- hostEl?.getAttribute("data-composition-file") ??
869
- null;
870
- } catch {
871
- /* cross-origin */
872
- }
908
+ hostEl =
909
+ iframeDoc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
910
+ resolvedSrc =
911
+ hostEl?.getAttribute("data-composition-src") ??
912
+ hostEl?.getAttribute("data-composition-file") ??
913
+ null;
873
914
  }
874
915
  if (resolvedSrc) {
875
916
  entry.compositionSrc = resolvedSrc;
@@ -1196,6 +1237,9 @@ export function useTimelinePlayer() {
1196
1237
  setIsPlaying(false);
1197
1238
  }, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
1198
1239
 
1240
+ const togglePlayRef = useRef(togglePlay);
1241
+ togglePlayRef.current = togglePlay;
1242
+
1199
1243
  const refreshPlayer = useCallback(() => {
1200
1244
  const iframe = iframeRef.current;
1201
1245
  if (!iframe) return;
@@ -1304,8 +1348,6 @@ export function useTimelinePlayer() {
1304
1348
  stopRAFLoop();
1305
1349
  stopReverseLoop();
1306
1350
  if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
1307
- // Don't reset() on cleanup — preserve timeline elements across iframe refreshes
1308
- // to prevent blink. New data will replace old when the iframe reloads.
1309
1351
  };
1310
1352
  });
1311
1353
 
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { formatFrameTime, frameToSeconds, secondsToFrame, stepFrameTime, formatTime } from "./time";
2
+ import { formatFrameTime, frameToSeconds, secondsToFrame, formatTime } from "./time";
3
3
 
4
4
  describe("formatTime", () => {
5
5
  it("formats zero seconds", () => {
@@ -72,14 +72,4 @@ describe("frame helpers", () => {
72
72
  it("formats current and total frame display", () => {
73
73
  expect(formatFrameTime(1, 5)).toBe("30f / 150f");
74
74
  });
75
-
76
- it("steps from a truncated runtime time by integer frame index", () => {
77
- expect(stepFrameTime(0.0333333, 1)).toBe(2 / 30);
78
- expect(stepFrameTime(0.0666666, 1)).toBe(3 / 30);
79
- expect(stepFrameTime(0.0666666, -1)).toBe(1 / 30);
80
- });
81
-
82
- it("clamps frame stepping at zero", () => {
83
- expect(stepFrameTime(0, -1)).toBe(0);
84
- });
85
75
  });
@@ -19,12 +19,6 @@ export function frameToSeconds(frame: number, fps = STUDIO_PREVIEW_FPS): number
19
19
  return frame / fps;
20
20
  }
21
21
 
22
- export function stepFrameTime(time: number, deltaFrames: number, fps = STUDIO_PREVIEW_FPS): number {
23
- const currentFrame = secondsToFrame(time, fps);
24
- const nextFrame = Math.max(0, currentFrame + deltaFrames);
25
- return frameToSeconds(nextFrame, fps);
26
- }
27
-
28
22
  export function formatFrameTime(time: number, duration: number, fps = STUDIO_PREVIEW_FPS): string {
29
23
  const currentFrame = secondsToFrame(time, fps);
30
24
  const totalFrames = secondsToFrame(duration, fps);