@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
@@ -1,6 +1,8 @@
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";
5
+ import { useCaptionStore } from "../../captions/store";
4
6
 
5
7
  interface PlaybackAdapter {
6
8
  play: () => void;
@@ -20,7 +22,7 @@ interface TimelineLike {
20
22
  isActive: () => boolean;
21
23
  }
22
24
 
23
- export interface ClipManifestClip {
25
+ interface ClipManifestClip {
24
26
  id: string | null;
25
27
  label: string;
26
28
  start: number;
@@ -62,9 +64,12 @@ function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
62
64
  }
63
65
 
64
66
  function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageElement | null {
65
- if (el instanceof HTMLMediaElement || el instanceof HTMLImageElement) return el;
67
+ const win = el.ownerDocument.defaultView ?? window;
68
+ const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
69
+ const ImageElementCtor = win.HTMLImageElement ?? globalThis.HTMLImageElement;
70
+ if (el instanceof MediaElementCtor || el instanceof ImageElementCtor) return el;
66
71
  const candidate = el.querySelector("video, audio, img");
67
- return candidate instanceof HTMLMediaElement || candidate instanceof HTMLImageElement
72
+ return candidate instanceof MediaElementCtor || candidate instanceof ImageElementCtor
68
73
  ? candidate
69
74
  : null;
70
75
  }
@@ -90,7 +95,9 @@ function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): voi
90
95
  const src = mediaEl.getAttribute("src");
91
96
  if (src) entry.src = src;
92
97
 
93
- if (!(mediaEl instanceof HTMLMediaElement)) return;
98
+ const win = mediaEl.ownerDocument.defaultView ?? window;
99
+ const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
100
+ if (typeof MediaElementCtor === "undefined" || !(mediaEl instanceof MediaElementCtor)) return;
94
101
 
95
102
  const sourceDurationAttr =
96
103
  el.getAttribute("data-source-duration") ?? mediaEl.getAttribute("data-source-duration");
@@ -105,11 +112,82 @@ function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): voi
105
112
  }
106
113
  }
107
114
 
115
+ const SHUTTLE_SPEEDS = [1, 2, 4] as const;
116
+ const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]);
117
+ const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
118
+ "input",
119
+ "textarea",
120
+ "select",
121
+ "button",
122
+ "a[href]",
123
+ "[contenteditable='true']",
124
+ "[role='button']",
125
+ "[role='checkbox']",
126
+ "[role='combobox']",
127
+ "[role='menuitem']",
128
+ "[role='radio']",
129
+ "[role='slider']",
130
+ "[role='spinbutton']",
131
+ "[role='switch']",
132
+ "[role='textbox']",
133
+ ].join(",");
134
+
135
+ export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null): boolean {
136
+ if (!target || typeof target !== "object") return false;
137
+ const candidate = target as { closest?: unknown };
138
+ if (typeof candidate.closest !== "function") return false;
139
+ return (
140
+ (candidate.closest as (selector: string) => Element | null).call(
141
+ target,
142
+ PLAYBACK_SHORTCUT_IGNORED_SELECTOR,
143
+ ) !== null
144
+ );
145
+ }
146
+
147
+ interface PlaybackShortcutCaptionState {
148
+ isCaptionEditMode: boolean;
149
+ selectedCaptionSegmentCount: number;
150
+ }
151
+
152
+ type PlaybackShortcutEvent = Pick<
153
+ KeyboardEvent,
154
+ "altKey" | "ctrlKey" | "metaKey" | "code" | "target"
155
+ >;
156
+
157
+ export function shouldIgnorePlaybackShortcutEvent(
158
+ event: PlaybackShortcutEvent,
159
+ captionState: PlaybackShortcutCaptionState = {
160
+ isCaptionEditMode: false,
161
+ selectedCaptionSegmentCount: 0,
162
+ },
163
+ ): boolean {
164
+ if (event.metaKey || event.ctrlKey || event.altKey) return true;
165
+ if (shouldIgnorePlaybackShortcutTarget(event.target)) return true;
166
+ return (
167
+ PLAYBACK_FRAME_STEP_CODES.has(event.code) &&
168
+ captionState.isCaptionEditMode &&
169
+ captionState.selectedCaptionSegmentCount > 0
170
+ );
171
+ }
172
+
173
+ function getTimelineElementDisplayLabel(input: {
174
+ id?: string | null;
175
+ label?: string | null;
176
+ tag?: string | null;
177
+ }): string {
178
+ const label = input.label?.trim();
179
+ if (label) return label;
180
+ const id = input.id?.trim();
181
+ if (id) return id;
182
+ const tag = input.tag?.trim().toLowerCase();
183
+ return tag ? `${tag} clip` : "Timeline clip";
184
+ }
185
+
108
186
  /**
109
187
  * Parse [data-start] elements from a Document into TimelineElement[].
110
188
  * Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
111
189
  */
112
- function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElement[] {
190
+ export function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElement[] {
113
191
  const rootComp = doc.querySelector("[data-composition-id]");
114
192
  const nodes = doc.querySelectorAll("[data-start]");
115
193
  const els: TimelineElement[] = [];
@@ -140,17 +218,24 @@ function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElem
140
218
  const selector = getTimelineElementSelector(el);
141
219
  const sourceFile = getTimelineElementSourceFile(el);
142
220
  const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
143
- const id = el.id || compId || el.className?.split(" ")[0] || tagLower;
221
+ const label = getTimelineElementDisplayLabel({
222
+ id: el.id || compId || null,
223
+ label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
224
+ tag: tagLower,
225
+ });
226
+ const identity = buildTimelineElementIdentity({
227
+ preferredId: el.id || compId || null,
228
+ label,
229
+ fallbackIndex: els.length,
230
+ domId: el.id || undefined,
231
+ selector,
232
+ selectorIndex,
233
+ sourceFile,
234
+ });
144
235
  const entry: TimelineElement = {
145
- id,
146
- key: buildTimelineElementKey({
147
- id,
148
- fallbackIndex: els.length,
149
- domId: el.id || undefined,
150
- selector,
151
- selectorIndex,
152
- sourceFile,
153
- }),
236
+ id: identity.id,
237
+ label,
238
+ key: identity.key,
154
239
  tag: tagLower,
155
240
  start,
156
241
  duration: dur,
@@ -251,6 +336,40 @@ function buildTimelineElementKey(params: {
251
336
  return `${scope}:${params.id}:${params.fallbackIndex}`;
252
337
  }
253
338
 
339
+ function buildTimelineElementIdentity(params: {
340
+ preferredId?: string | null;
341
+ label: string;
342
+ fallbackIndex: number;
343
+ domId?: string;
344
+ selector?: string;
345
+ selectorIndex?: number;
346
+ sourceFile?: string;
347
+ }): { id: string; key: string } {
348
+ const id =
349
+ params.preferredId?.trim() ||
350
+ buildTimelineElementKey({
351
+ id: params.label,
352
+ fallbackIndex: params.fallbackIndex,
353
+ domId: params.domId,
354
+ selector: params.selector,
355
+ selectorIndex: params.selectorIndex,
356
+ sourceFile: params.sourceFile,
357
+ });
358
+ const key = buildTimelineElementKey({
359
+ id,
360
+ fallbackIndex: params.fallbackIndex,
361
+ domId: params.domId,
362
+ selector: params.selector,
363
+ selectorIndex: params.selectorIndex,
364
+ sourceFile: params.sourceFile,
365
+ });
366
+ return { id, key };
367
+ }
368
+
369
+ function getTimelineElementIdentity(element: TimelineElement): string {
370
+ return element.key ?? element.id;
371
+ }
372
+
254
373
  function getTimelineDomNodes(doc: Document): Element[] {
255
374
  const rootComp = doc.querySelector("[data-composition-id]");
256
375
  return Array.from(doc.querySelectorAll("[data-start]")).filter((node) => node !== rootComp);
@@ -292,6 +411,103 @@ export function findTimelineDomNodeForClip(
292
411
  return candidates[fallbackIndex] ?? null;
293
412
  }
294
413
 
414
+ export function createTimelineElementFromManifestClip(params: {
415
+ clip: ClipManifestClip;
416
+ fallbackIndex: number;
417
+ doc?: Document | null;
418
+ hostEl?: Element | null;
419
+ }): TimelineElement {
420
+ const { clip, fallbackIndex, doc } = params;
421
+ let hostEl = params.hostEl ?? null;
422
+ const label = getTimelineElementDisplayLabel({
423
+ id: clip.id,
424
+ label: clip.label,
425
+ tag: clip.tagName || clip.kind,
426
+ });
427
+
428
+ let domId: string | undefined;
429
+ let selector: string | undefined;
430
+ let selectorIndex: number | undefined;
431
+ let sourceFile: string | undefined;
432
+
433
+ if (hostEl) {
434
+ domId = hostEl.id || undefined;
435
+ selector = getTimelineElementSelector(hostEl);
436
+ selectorIndex =
437
+ doc && selector ? getTimelineElementSelectorIndex(doc, hostEl, selector) : undefined;
438
+ sourceFile = getTimelineElementSourceFile(hostEl);
439
+ }
440
+
441
+ const identity = buildTimelineElementIdentity({
442
+ preferredId: clip.id,
443
+ label,
444
+ fallbackIndex,
445
+ domId,
446
+ selector,
447
+ selectorIndex,
448
+ sourceFile,
449
+ });
450
+ const entry: TimelineElement = {
451
+ id: identity.id,
452
+ label,
453
+ key: identity.key,
454
+ tag: clip.tagName || clip.kind,
455
+ start: clip.start,
456
+ duration: clip.duration,
457
+ track: clip.track,
458
+ domId,
459
+ selector,
460
+ selectorIndex,
461
+ sourceFile,
462
+ };
463
+
464
+ if (hostEl) {
465
+ applyMediaMetadataFromElement(entry, hostEl);
466
+ }
467
+ if (clip.assetUrl) entry.src = clip.assetUrl;
468
+ if (clip.kind === "composition" && clip.compositionId) {
469
+ let resolvedSrc = clip.compositionSrc;
470
+ if (!resolvedSrc) {
471
+ hostEl = doc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
472
+ resolvedSrc =
473
+ hostEl?.getAttribute("data-composition-src") ??
474
+ hostEl?.getAttribute("data-composition-file") ??
475
+ null;
476
+ }
477
+ if (resolvedSrc) {
478
+ entry.compositionSrc = resolvedSrc;
479
+ } else if (hostEl) {
480
+ const innerVideo = hostEl.querySelector("video[src]");
481
+ if (innerVideo) {
482
+ entry.src = innerVideo.getAttribute("src") || undefined;
483
+ entry.tag = "video";
484
+ }
485
+ }
486
+ if (hostEl) {
487
+ entry.domId = hostEl.id || undefined;
488
+ entry.selector = getTimelineElementSelector(hostEl);
489
+ entry.selectorIndex =
490
+ doc && entry.selector
491
+ ? getTimelineElementSelectorIndex(doc, hostEl, entry.selector)
492
+ : undefined;
493
+ entry.sourceFile = getTimelineElementSourceFile(hostEl);
494
+ const nextIdentity = buildTimelineElementIdentity({
495
+ preferredId: clip.id,
496
+ label,
497
+ fallbackIndex,
498
+ domId: entry.domId,
499
+ selector: entry.selector,
500
+ selectorIndex: entry.selectorIndex,
501
+ sourceFile: entry.sourceFile,
502
+ });
503
+ entry.id = nextIdentity.id;
504
+ entry.key = nextIdentity.key;
505
+ }
506
+ }
507
+
508
+ return entry;
509
+ }
510
+
295
511
  function findTimelineDomNode(doc: Document, id: string): Element | null {
296
512
  return (
297
513
  doc.getElementById(id) ??
@@ -320,6 +536,10 @@ export function buildStandaloneRootTimelineElement(params: {
320
536
 
321
537
  return {
322
538
  id: params.compositionId,
539
+ label: getTimelineElementDisplayLabel({
540
+ id: params.compositionId,
541
+ tag: params.tagName,
542
+ }),
323
543
  key: buildTimelineElementKey({
324
544
  id: params.compositionId,
325
545
  fallbackIndex: 0,
@@ -337,6 +557,7 @@ export function buildStandaloneRootTimelineElement(params: {
337
557
  sourceFile: compositionSrc,
338
558
  };
339
559
  }
560
+
340
561
  function normalizePreviewViewport(doc: Document, win: Window): void {
341
562
  if (doc.documentElement) {
342
563
  doc.documentElement.style.overflow = "hidden";
@@ -440,8 +661,10 @@ export function mergeTimelineElementsPreservingDowngrades(
440
661
  return nextElements;
441
662
  }
442
663
 
443
- const nextIds = new Set(nextElements.map((element) => element.id));
444
- const preserved = currentElements.filter((element) => !nextIds.has(element.id));
664
+ const nextIdentities = new Set(nextElements.map(getTimelineElementIdentity));
665
+ const preserved = currentElements.filter(
666
+ (element) => !nextIdentities.has(getTimelineElementIdentity(element)),
667
+ );
445
668
  if (preserved.length === 0) return nextElements;
446
669
  return [...nextElements, ...preserved];
447
670
  }
@@ -452,6 +675,13 @@ export function useTimelinePlayer() {
452
675
  const probeIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
453
676
  const pendingSeekRef = useRef<number | null>(null);
454
677
  const isRefreshingRef = useRef(false);
678
+ const reverseRafRef = useRef<number>(0);
679
+ const shuttleDirectionRef = useRef<"forward" | "backward" | null>(null);
680
+ const shuttleSpeedIndexRef = useRef(0);
681
+ const pressedCodesRef = useRef(new Set<string>());
682
+ const iframeShortcutCleanupRef = useRef<(() => void) | null>(null);
683
+ const playbackKeyDownRef = useRef<(e: KeyboardEvent) => void>(() => {});
684
+ const playbackKeyUpRef = useRef<(e: KeyboardEvent) => void>(() => {});
455
685
 
456
686
  // ZERO store subscriptions — this hook never causes re-renders.
457
687
  // All reads use getState() (point-in-time), all writes use the stable setters.
@@ -510,6 +740,10 @@ export function useTimelinePlayer() {
510
740
  }
511
741
  }, []);
512
742
 
743
+ const stopReverseLoop = useCallback(() => {
744
+ cancelAnimationFrame(reverseRafRef.current);
745
+ }, []);
746
+
513
747
  const startRAFLoop = useCallback(() => {
514
748
  const tick = () => {
515
749
  const adapter = getAdapter();
@@ -518,6 +752,14 @@ export function useTimelinePlayer() {
518
752
  const dur = adapter.getDuration();
519
753
  liveTime.notify(time); // direct DOM updates, no React re-render
520
754
  if (time >= dur && !adapter.isPlaying()) {
755
+ if (usePlayerStore.getState().loopEnabled && dur > 0) {
756
+ adapter.seek(0);
757
+ liveTime.notify(0);
758
+ adapter.play();
759
+ setIsPlaying(true);
760
+ rafRef.current = requestAnimationFrame(tick);
761
+ return;
762
+ }
521
763
  setCurrentTime(time); // sync Zustand once at end
522
764
  setIsPlaying(false);
523
765
  cancelAnimationFrame(rafRef.current);
@@ -560,6 +802,8 @@ export function useTimelinePlayer() {
560
802
  }, []);
561
803
 
562
804
  const play = useCallback(() => {
805
+ stopRAFLoop();
806
+ stopReverseLoop();
563
807
  const adapter = getAdapter();
564
808
  if (!adapter) return;
565
809
  if (adapter.getTime() >= adapter.getDuration()) {
@@ -568,18 +812,68 @@ export function useTimelinePlayer() {
568
812
  unmutePreviewMedia(iframeRef.current);
569
813
  applyPlaybackRate(usePlayerStore.getState().playbackRate);
570
814
  adapter.play();
815
+ shuttleDirectionRef.current = "forward";
571
816
  setIsPlaying(true);
572
817
  startRAFLoop();
573
- }, [getAdapter, setIsPlaying, startRAFLoop, applyPlaybackRate]);
818
+ }, [getAdapter, setIsPlaying, startRAFLoop, applyPlaybackRate, stopRAFLoop, stopReverseLoop]);
819
+
820
+ const playBackward = useCallback(
821
+ (rate: number) => {
822
+ stopRAFLoop();
823
+ stopReverseLoop();
824
+ const adapter = getAdapter();
825
+ if (!adapter) return;
826
+ const duration = Math.max(0, adapter.getDuration());
827
+ const initialTime = adapter.getTime() <= 0 && duration > 0 ? duration : adapter.getTime();
828
+ adapter.pause();
829
+ if (initialTime !== adapter.getTime()) adapter.seek(initialTime);
830
+ unmutePreviewMedia(iframeRef.current);
831
+ const speed = Math.max(0.1, Math.min(4, rate));
832
+ let startTime = initialTime;
833
+ let startedAt = performance.now();
834
+
835
+ const tick = (now: number) => {
836
+ const elapsed = ((now - startedAt) / 1000) * speed;
837
+ let nextTime = startTime - elapsed;
838
+ if (nextTime <= 0) {
839
+ if (usePlayerStore.getState().loopEnabled && duration > 0) {
840
+ startTime = duration;
841
+ startedAt = now;
842
+ nextTime = duration;
843
+ } else {
844
+ adapter.seek(0);
845
+ liveTime.notify(0);
846
+ setCurrentTime(0);
847
+ setIsPlaying(false);
848
+ shuttleDirectionRef.current = null;
849
+ reverseRafRef.current = 0;
850
+ return;
851
+ }
852
+ }
853
+ adapter.seek(Math.max(0, nextTime));
854
+ liveTime.notify(Math.max(0, nextTime));
855
+ setIsPlaying(true);
856
+ reverseRafRef.current = requestAnimationFrame(tick);
857
+ };
858
+
859
+ setIsPlaying(true);
860
+ shuttleDirectionRef.current = "backward";
861
+ reverseRafRef.current = requestAnimationFrame(tick);
862
+ },
863
+ [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
864
+ );
574
865
 
575
866
  const pause = useCallback(() => {
867
+ stopReverseLoop();
576
868
  const adapter = getAdapter();
577
869
  if (!adapter) return;
578
870
  adapter.pause();
579
871
  setCurrentTime(adapter.getTime()); // sync store so Split/Delete have accurate time
580
872
  setIsPlaying(false);
873
+ shuttleDirectionRef.current = null;
874
+ shuttleSpeedIndexRef.current = 0;
581
875
  stopRAFLoop();
582
- }, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop]);
876
+ }, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]);
583
877
 
584
878
  const togglePlay = useCallback(() => {
585
879
  if (usePlayerStore.getState().isPlaying) {
@@ -591,18 +885,136 @@ export function useTimelinePlayer() {
591
885
 
592
886
  const seek = useCallback(
593
887
  (time: number) => {
888
+ stopReverseLoop();
594
889
  const adapter = getAdapter();
595
890
  if (!adapter) return;
596
- adapter.seek(time);
597
- liveTime.notify(time); // Direct DOM updates (playhead, timecode, progress) no re-render
598
- setCurrentTime(time); // sync store so Split/Delete have accurate time
891
+ const duration = Math.max(0, adapter.getDuration());
892
+ const nextTime = Math.max(0, duration > 0 ? Math.min(duration, time) : time);
893
+ adapter.seek(nextTime);
894
+ liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render
895
+ setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
599
896
  stopRAFLoop();
600
897
  // Only update store if state actually changes (avoids unnecessary re-renders)
601
898
  if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
899
+ shuttleDirectionRef.current = null;
900
+ shuttleSpeedIndexRef.current = 0;
901
+ },
902
+ [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
903
+ );
904
+
905
+ const stepFrames = useCallback(
906
+ (deltaFrames: number) => {
907
+ const adapter = getAdapter();
908
+ const currentTime = adapter?.getTime() ?? usePlayerStore.getState().currentTime;
909
+ seek(stepFrameTime(currentTime, deltaFrames, STUDIO_PREVIEW_FPS));
910
+ },
911
+ [getAdapter, seek],
912
+ );
913
+
914
+ const shuttle = useCallback(
915
+ (direction: "forward" | "backward") => {
916
+ if (shuttleDirectionRef.current === direction) {
917
+ shuttleSpeedIndexRef.current = Math.min(
918
+ shuttleSpeedIndexRef.current + 1,
919
+ SHUTTLE_SPEEDS.length - 1,
920
+ );
921
+ } else {
922
+ shuttleSpeedIndexRef.current = 0;
923
+ }
924
+ const speed = SHUTTLE_SPEEDS[shuttleSpeedIndexRef.current];
925
+ usePlayerStore.getState().setPlaybackRate(speed);
926
+ if (direction === "forward") {
927
+ play();
928
+ } else {
929
+ playBackward(speed);
930
+ }
931
+ },
932
+ [play, playBackward],
933
+ );
934
+
935
+ const handlePlaybackKeyDown = useCallback(
936
+ (e: KeyboardEvent) => {
937
+ if (e.defaultPrevented) return;
938
+ const captionState = useCaptionStore.getState();
939
+ if (
940
+ shouldIgnorePlaybackShortcutEvent(e, {
941
+ isCaptionEditMode: captionState.isEditMode,
942
+ selectedCaptionSegmentCount: captionState.selectedSegmentIds.size,
943
+ })
944
+ ) {
945
+ return;
946
+ }
947
+ pressedCodesRef.current.add(e.code);
948
+ if (e.code === "Space") {
949
+ e.preventDefault();
950
+ togglePlay();
951
+ return;
952
+ }
953
+ if (e.code === "ArrowLeft") {
954
+ e.preventDefault();
955
+ stepFrames(e.shiftKey ? -10 : -1);
956
+ return;
957
+ }
958
+ if (e.code === "ArrowRight") {
959
+ e.preventDefault();
960
+ stepFrames(e.shiftKey ? 10 : 1);
961
+ return;
962
+ }
963
+ if (e.repeat) return;
964
+ if (e.code === "KeyK") {
965
+ e.preventDefault();
966
+ pause();
967
+ return;
968
+ }
969
+ if (e.code === "KeyJ") {
970
+ e.preventDefault();
971
+ if (pressedCodesRef.current.has("KeyK")) {
972
+ stepFrames(-1);
973
+ return;
974
+ }
975
+ shuttle("backward");
976
+ return;
977
+ }
978
+ if (e.code === "KeyL") {
979
+ e.preventDefault();
980
+ if (pressedCodesRef.current.has("KeyK")) {
981
+ stepFrames(1);
982
+ return;
983
+ }
984
+ shuttle("forward");
985
+ }
602
986
  },
603
- [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop],
987
+ [pause, shuttle, stepFrames, togglePlay],
604
988
  );
605
989
 
990
+ const handlePlaybackKeyUp = useCallback((e: KeyboardEvent) => {
991
+ pressedCodesRef.current.delete(e.code);
992
+ }, []);
993
+ playbackKeyDownRef.current = handlePlaybackKeyDown;
994
+ playbackKeyUpRef.current = handlePlaybackKeyUp;
995
+
996
+ const attachIframeShortcutListeners = useCallback(() => {
997
+ iframeShortcutCleanupRef.current?.();
998
+ iframeShortcutCleanupRef.current = null;
999
+
1000
+ const iframeWin = iframeRef.current?.contentWindow;
1001
+ const iframeDoc = iframeRef.current?.contentDocument;
1002
+ if (!iframeWin && !iframeDoc) return;
1003
+
1004
+ const handleIframeKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
1005
+ const handleIframeKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
1006
+ iframeWin?.addEventListener("keydown", handleIframeKeyDown, true);
1007
+ iframeWin?.addEventListener("keyup", handleIframeKeyUp, true);
1008
+ iframeDoc?.addEventListener("keydown", handleIframeKeyDown, true);
1009
+ iframeDoc?.addEventListener("keyup", handleIframeKeyUp, true);
1010
+ iframeShortcutCleanupRef.current = () => {
1011
+ iframeWin?.removeEventListener("keydown", handleIframeKeyDown, true);
1012
+ iframeWin?.removeEventListener("keyup", handleIframeKeyUp, true);
1013
+ iframeDoc?.removeEventListener("keydown", handleIframeKeyDown, true);
1014
+ iframeDoc?.removeEventListener("keyup", handleIframeKeyUp, true);
1015
+ };
1016
+ }, []);
1017
+
606
1018
  // Convert a runtime timeline message (from iframe postMessage) into TimelineElements
607
1019
  const processTimelineMessage = useCallback(
608
1020
  (data: {
@@ -627,72 +1039,16 @@ export function useTimelinePlayer() {
627
1039
  }
628
1040
  const usedHostEls = new Set<Element>();
629
1041
  const els: TimelineElement[] = filtered.map((clip, index) => {
630
- let hostEl = iframeDoc
1042
+ const hostEl = iframeDoc
631
1043
  ? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls)
632
1044
  : null;
633
1045
  if (hostEl) usedHostEls.add(hostEl);
634
- const id = clip.id || clip.label || clip.tagName || "element";
635
- const entry: TimelineElement = {
636
- id,
637
- tag: clip.tagName || clip.kind,
638
- start: clip.start,
639
- duration: clip.duration,
640
- track: clip.track,
641
- };
642
- if (hostEl) {
643
- entry.domId = hostEl.id || undefined;
644
- entry.selector = getTimelineElementSelector(hostEl);
645
- entry.selectorIndex =
646
- iframeDoc && entry.selector
647
- ? getTimelineElementSelectorIndex(iframeDoc, hostEl, entry.selector)
648
- : undefined;
649
- entry.sourceFile = getTimelineElementSourceFile(hostEl);
650
- applyMediaMetadataFromElement(entry, hostEl);
651
- }
652
- if (clip.assetUrl) entry.src = clip.assetUrl;
653
- if (clip.kind === "composition" && clip.compositionId) {
654
- // The bundler renames data-composition-src to data-composition-file
655
- // after inlining, so the clip manifest may not have compositionSrc.
656
- // Fall back to reading data-composition-file from the DOM.
657
- let resolvedSrc = clip.compositionSrc;
658
- if (!resolvedSrc) {
659
- hostEl =
660
- iframeDoc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
661
- resolvedSrc =
662
- hostEl?.getAttribute("data-composition-src") ??
663
- hostEl?.getAttribute("data-composition-file") ??
664
- null;
665
- }
666
- if (resolvedSrc) {
667
- entry.compositionSrc = resolvedSrc;
668
- } else if (hostEl) {
669
- // Inline composition (no external file) — expose inner video for thumbnails
670
- const innerVideo = hostEl.querySelector("video[src]");
671
- if (innerVideo) {
672
- entry.src = innerVideo.getAttribute("src") || undefined;
673
- entry.tag = "video";
674
- }
675
- }
676
- if (hostEl) {
677
- const iframeDoc = iframeRef.current?.contentDocument;
678
- entry.domId = hostEl.id || undefined;
679
- entry.selector = getTimelineElementSelector(hostEl);
680
- entry.selectorIndex =
681
- iframeDoc && entry.selector
682
- ? getTimelineElementSelectorIndex(iframeDoc, hostEl, entry.selector)
683
- : undefined;
684
- entry.sourceFile = getTimelineElementSourceFile(hostEl);
685
- }
686
- }
687
- entry.key = buildTimelineElementKey({
688
- id,
1046
+ return createTimelineElementFromManifestClip({
1047
+ clip,
689
1048
  fallbackIndex: index,
690
- domId: entry.domId,
691
- selector: entry.selector,
692
- selectorIndex: entry.selectorIndex,
693
- sourceFile: entry.sourceFile,
1049
+ doc: iframeDoc,
1050
+ hostEl,
694
1051
  });
695
- return entry;
696
1052
  });
697
1053
  const rawDuration = data.durationInFrames / 30;
698
1054
  // Clamp non-finite or absurdly large durations — the runtime can emit
@@ -806,17 +1162,24 @@ export function useTimelinePlayer() {
806
1162
  const selector = getTimelineElementSelector(el);
807
1163
  const sourceFile = getTimelineElementSourceFile(el);
808
1164
  const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
809
- const id = el.id || compId;
1165
+ const label = getTimelineElementDisplayLabel({
1166
+ id: el.id || compId || null,
1167
+ label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
1168
+ tag: el.tagName,
1169
+ });
1170
+ const identity = buildTimelineElementIdentity({
1171
+ preferredId: el.id || compId || null,
1172
+ label,
1173
+ fallbackIndex: missing.length,
1174
+ domId: el.id || undefined,
1175
+ selector,
1176
+ selectorIndex,
1177
+ sourceFile,
1178
+ });
810
1179
  const entry: TimelineElement = {
811
- id,
812
- key: buildTimelineElementKey({
813
- id,
814
- fallbackIndex: missing.length,
815
- domId: el.id || undefined,
816
- selector,
817
- selectorIndex,
818
- sourceFile,
819
- }),
1180
+ id: identity.id,
1181
+ label,
1182
+ key: identity.key,
820
1183
  tag: el.tagName.toLowerCase(),
821
1184
  start,
822
1185
  duration: dur,
@@ -906,6 +1269,7 @@ export function useTimelinePlayer() {
906
1269
  if (doc && iframeWin) {
907
1270
  normalizePreviewViewport(doc, iframeWin);
908
1271
  autoHealMissingCompositionIds(doc);
1272
+ attachIframeShortcutListeners();
909
1273
  }
910
1274
 
911
1275
  // Try reading __clipManifest if already available (fast path)
@@ -972,6 +1336,7 @@ export function useTimelinePlayer() {
972
1336
  processTimelineMessage,
973
1337
  enrichMissingCompositions,
974
1338
  syncTimelineElements,
1339
+ attachIframeShortcutListeners,
975
1340
  ]);
976
1341
 
977
1342
  /** Save the current playback time so the next onIframeLoad restores it. */
@@ -982,11 +1347,22 @@ export function useTimelinePlayer() {
982
1347
  : (usePlayerStore.getState().currentTime ?? 0);
983
1348
  isRefreshingRef.current = true;
984
1349
  stopRAFLoop();
1350
+ stopReverseLoop();
985
1351
  setIsPlaying(false);
986
- }, [getAdapter, stopRAFLoop, setIsPlaying]);
1352
+ }, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
1353
+
1354
+ const refreshPlayer = useCallback(() => {
1355
+ const iframe = iframeRef.current;
1356
+ if (!iframe) return;
1357
+
1358
+ saveSeekPosition();
1359
+
1360
+ const src = iframe.src;
1361
+ const url = new URL(src, window.location.origin);
1362
+ url.searchParams.set("_t", String(Date.now()));
1363
+ iframe.src = url.toString();
1364
+ }, [saveSeekPosition]);
987
1365
 
988
- const togglePlayRef = useRef(togglePlay);
989
- togglePlayRef.current = togglePlay;
990
1366
  const getAdapterRef = useRef(getAdapter);
991
1367
  getAdapterRef.current = getAdapter;
992
1368
  const processTimelineMessageRef = useRef(processTimelineMessage);
@@ -995,12 +1371,8 @@ export function useTimelinePlayer() {
995
1371
  enrichMissingCompositionsRef.current = enrichMissingCompositions;
996
1372
 
997
1373
  useMountEffect(() => {
998
- const handleKeyDown = (e: KeyboardEvent) => {
999
- if (e.code === "Space" && e.target === document.body) {
1000
- e.preventDefault();
1001
- togglePlayRef.current();
1002
- }
1003
- };
1374
+ const handleWindowKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
1375
+ const handleWindowKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
1004
1376
 
1005
1377
  // Listen for timeline messages from the iframe runtime.
1006
1378
  // The runtime sends this AFTER all external compositions load,
@@ -1073,24 +1445,32 @@ export function useTimelinePlayer() {
1073
1445
  }
1074
1446
  };
1075
1447
 
1076
- window.addEventListener("keydown", handleKeyDown);
1448
+ window.addEventListener("keydown", handleWindowKeyDown, true);
1449
+ window.addEventListener("keyup", handleWindowKeyUp, true);
1077
1450
  window.addEventListener("message", handleMessage);
1078
1451
  document.addEventListener("visibilitychange", handleVisibilityChange);
1079
1452
  return () => {
1080
- window.removeEventListener("keydown", handleKeyDown);
1453
+ window.removeEventListener("keydown", handleWindowKeyDown, true);
1454
+ window.removeEventListener("keyup", handleWindowKeyUp, true);
1455
+ iframeShortcutCleanupRef.current?.();
1456
+ iframeShortcutCleanupRef.current = null;
1081
1457
  window.removeEventListener("message", handleMessage);
1082
1458
  document.removeEventListener("visibilitychange", handleVisibilityChange);
1083
1459
  stopRAFLoop();
1460
+ stopReverseLoop();
1084
1461
  if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
1462
+ // Don't reset() on cleanup — preserve timeline elements across iframe refreshes
1463
+ // to prevent blink. New data will replace old when the iframe reloads.
1085
1464
  };
1086
1465
  });
1087
1466
 
1088
1467
  /** Reset the player store (elements, duration, etc.) — call when switching sessions. */
1089
1468
  const resetPlayer = useCallback(() => {
1090
1469
  stopRAFLoop();
1470
+ stopReverseLoop();
1091
1471
  if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
1092
1472
  usePlayerStore.getState().reset();
1093
- }, [stopRAFLoop]);
1473
+ }, [stopRAFLoop, stopReverseLoop]);
1094
1474
 
1095
1475
  return {
1096
1476
  iframeRef,
@@ -1099,6 +1479,7 @@ export function useTimelinePlayer() {
1099
1479
  togglePlay,
1100
1480
  seek,
1101
1481
  onIframeLoad,
1482
+ refreshPlayer,
1102
1483
  saveSeekPosition,
1103
1484
  resetPlayer,
1104
1485
  };