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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/assets/index-Bl4Deziq.js +105 -0
  2. package/dist/assets/index-KioPDrX6.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +494 -185
  6. package/src/captions/components/CaptionOverlay.tsx +2 -1
  7. package/src/captions/keyboard.test.ts +38 -0
  8. package/src/captions/keyboard.ts +8 -0
  9. package/src/components/LintModal.tsx +3 -4
  10. package/src/components/editor/DomEditOverlay.tsx +41 -6
  11. package/src/components/editor/PropertyPanel.tsx +7 -3
  12. package/src/components/editor/domEditing.test.ts +110 -0
  13. package/src/components/editor/domEditing.ts +33 -4
  14. package/src/components/nle/NLELayout.tsx +43 -8
  15. package/src/components/nle/NLEPreview.tsx +5 -1
  16. package/src/components/sidebar/AssetsTab.tsx +3 -4
  17. package/src/components/sidebar/LeftSidebar.tsx +64 -36
  18. package/src/hooks/usePersistentEditHistory.test.ts +255 -0
  19. package/src/hooks/usePersistentEditHistory.ts +336 -0
  20. package/src/icons/SystemIcons.tsx +4 -0
  21. package/src/player/components/AudioWaveform.tsx +44 -29
  22. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  23. package/src/player/components/CompositionThumbnail.tsx +42 -10
  24. package/src/player/components/EditModal.tsx +5 -20
  25. package/src/player/components/PlayerControls.tsx +117 -49
  26. package/src/player/components/Timeline.test.ts +84 -0
  27. package/src/player/components/Timeline.tsx +198 -27
  28. package/src/player/components/timelineEditing.test.ts +2 -2
  29. package/src/player/components/timelineEditing.ts +1 -1
  30. package/src/player/components/timelineTheme.ts +3 -3
  31. package/src/player/components/timelineZoom.test.ts +21 -0
  32. package/src/player/components/timelineZoom.ts +11 -0
  33. package/src/player/hooks/useTimelinePlayer.test.ts +138 -0
  34. package/src/player/hooks/useTimelinePlayer.ts +354 -43
  35. package/src/player/lib/time.test.ts +29 -1
  36. package/src/player/lib/time.ts +26 -0
  37. package/src/player/store/playerStore.test.ts +11 -1
  38. package/src/player/store/playerStore.ts +5 -1
  39. package/src/styles/studio.css +9 -0
  40. package/src/utils/clipboard.test.ts +88 -0
  41. package/src/utils/clipboard.ts +57 -0
  42. package/src/utils/editHistory.test.ts +244 -0
  43. package/src/utils/editHistory.ts +218 -0
  44. package/src/utils/editHistoryStorage.test.ts +37 -0
  45. package/src/utils/editHistoryStorage.ts +99 -0
  46. package/src/utils/frameCapture.test.ts +26 -0
  47. package/src/utils/frameCapture.ts +38 -0
  48. package/src/utils/studioFileHistory.test.ts +156 -0
  49. package/src/utils/studioFileHistory.ts +61 -0
  50. package/src/utils/timelineAssetDrop.test.ts +64 -4
  51. package/src/utils/timelineAssetDrop.ts +27 -5
  52. package/dist/assets/index-Bi30tos-.js +0 -105
  53. package/dist/assets/index-Dm9VsShj.css +0 -1
@@ -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
- interface ClipManifestClip {
25
+ export interface ClipManifestClip {
24
26
  id: string | null;
25
27
  label: string;
26
28
  start: number;
@@ -105,6 +107,64 @@ function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): voi
105
107
  }
106
108
  }
107
109
 
110
+ const SHUTTLE_SPEEDS = [1, 2, 4] as const;
111
+ const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]);
112
+ const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
113
+ "input",
114
+ "textarea",
115
+ "select",
116
+ "button",
117
+ "a[href]",
118
+ "[contenteditable='true']",
119
+ "[role='button']",
120
+ "[role='checkbox']",
121
+ "[role='combobox']",
122
+ "[role='menuitem']",
123
+ "[role='radio']",
124
+ "[role='slider']",
125
+ "[role='spinbutton']",
126
+ "[role='switch']",
127
+ "[role='textbox']",
128
+ ].join(",");
129
+
130
+ export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null): boolean {
131
+ if (!target || typeof target !== "object") return false;
132
+ const candidate = target as { closest?: unknown };
133
+ if (typeof candidate.closest !== "function") return false;
134
+ return (
135
+ (candidate.closest as (selector: string) => Element | null).call(
136
+ target,
137
+ PLAYBACK_SHORTCUT_IGNORED_SELECTOR,
138
+ ) !== null
139
+ );
140
+ }
141
+
142
+ interface PlaybackShortcutCaptionState {
143
+ isCaptionEditMode: boolean;
144
+ selectedCaptionSegmentCount: number;
145
+ }
146
+
147
+ type PlaybackShortcutEvent = Pick<
148
+ KeyboardEvent,
149
+ "altKey" | "ctrlKey" | "metaKey" | "code" | "target"
150
+ >;
151
+
152
+ export function shouldIgnorePlaybackShortcutEvent(
153
+ event: PlaybackShortcutEvent,
154
+ captionState: PlaybackShortcutCaptionState = {
155
+ isCaptionEditMode: false,
156
+ selectedCaptionSegmentCount: 0,
157
+ },
158
+ ): boolean {
159
+ if (event.metaKey || event.ctrlKey || event.altKey) return true;
160
+ if (shouldIgnorePlaybackShortcutTarget(event.target)) return true;
161
+ return (
162
+ PLAYBACK_FRAME_STEP_CODES.has(event.code) &&
163
+ captionState.isCaptionEditMode &&
164
+ captionState.selectedCaptionSegmentCount > 0
165
+ );
166
+ }
167
+
108
168
  /**
109
169
  * Parse [data-start] elements from a Document into TimelineElement[].
110
170
  * Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
@@ -193,12 +253,18 @@ function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElem
193
253
  return els;
194
254
  }
195
255
 
196
- function getTimelineElementSelector(el: Element): string | undefined {
197
- 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}`;
198
263
  const compId = el.getAttribute("data-composition-id");
199
264
  if (compId) return `[data-composition-id="${compId}"]`;
200
- if (el instanceof HTMLElement) {
201
- 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];
202
268
  if (firstClass) return `.${firstClass}`;
203
269
  }
204
270
  return undefined;
@@ -244,6 +310,48 @@ function buildTimelineElementKey(params: {
244
310
  if (params.selector) return `${scope}:${params.selector}:${params.selectorIndex ?? 0}`;
245
311
  return `${scope}:${params.id}:${params.fallbackIndex}`;
246
312
  }
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
+
247
355
  function findTimelineDomNode(doc: Document, id: string): Element | null {
248
356
  return (
249
357
  doc.getElementById(id) ??
@@ -404,6 +512,13 @@ export function useTimelinePlayer() {
404
512
  const probeIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
405
513
  const pendingSeekRef = useRef<number | null>(null);
406
514
  const isRefreshingRef = useRef(false);
515
+ const reverseRafRef = useRef<number>(0);
516
+ const shuttleDirectionRef = useRef<"forward" | "backward" | null>(null);
517
+ const shuttleSpeedIndexRef = useRef(0);
518
+ const pressedCodesRef = useRef(new Set<string>());
519
+ const iframeShortcutCleanupRef = useRef<(() => void) | null>(null);
520
+ const playbackKeyDownRef = useRef<(e: KeyboardEvent) => void>(() => {});
521
+ const playbackKeyUpRef = useRef<(e: KeyboardEvent) => void>(() => {});
407
522
 
408
523
  // ZERO store subscriptions — this hook never causes re-renders.
409
524
  // All reads use getState() (point-in-time), all writes use the stable setters.
@@ -462,6 +577,10 @@ export function useTimelinePlayer() {
462
577
  }
463
578
  }, []);
464
579
 
580
+ const stopReverseLoop = useCallback(() => {
581
+ cancelAnimationFrame(reverseRafRef.current);
582
+ }, []);
583
+
465
584
  const startRAFLoop = useCallback(() => {
466
585
  const tick = () => {
467
586
  const adapter = getAdapter();
@@ -470,6 +589,14 @@ export function useTimelinePlayer() {
470
589
  const dur = adapter.getDuration();
471
590
  liveTime.notify(time); // direct DOM updates, no React re-render
472
591
  if (time >= dur && !adapter.isPlaying()) {
592
+ if (usePlayerStore.getState().loopEnabled && dur > 0) {
593
+ adapter.seek(0);
594
+ liveTime.notify(0);
595
+ adapter.play();
596
+ setIsPlaying(true);
597
+ rafRef.current = requestAnimationFrame(tick);
598
+ return;
599
+ }
473
600
  setCurrentTime(time); // sync Zustand once at end
474
601
  setIsPlaying(false);
475
602
  cancelAnimationFrame(rafRef.current);
@@ -512,6 +639,8 @@ export function useTimelinePlayer() {
512
639
  }, []);
513
640
 
514
641
  const play = useCallback(() => {
642
+ stopRAFLoop();
643
+ stopReverseLoop();
515
644
  const adapter = getAdapter();
516
645
  if (!adapter) return;
517
646
  if (adapter.getTime() >= adapter.getDuration()) {
@@ -520,18 +649,68 @@ export function useTimelinePlayer() {
520
649
  unmutePreviewMedia(iframeRef.current);
521
650
  applyPlaybackRate(usePlayerStore.getState().playbackRate);
522
651
  adapter.play();
652
+ shuttleDirectionRef.current = "forward";
523
653
  setIsPlaying(true);
524
654
  startRAFLoop();
525
- }, [getAdapter, setIsPlaying, startRAFLoop, applyPlaybackRate]);
655
+ }, [getAdapter, setIsPlaying, startRAFLoop, applyPlaybackRate, stopRAFLoop, stopReverseLoop]);
656
+
657
+ const playBackward = useCallback(
658
+ (rate: number) => {
659
+ stopRAFLoop();
660
+ stopReverseLoop();
661
+ const adapter = getAdapter();
662
+ if (!adapter) return;
663
+ const duration = Math.max(0, adapter.getDuration());
664
+ const initialTime = adapter.getTime() <= 0 && duration > 0 ? duration : adapter.getTime();
665
+ adapter.pause();
666
+ if (initialTime !== adapter.getTime()) adapter.seek(initialTime);
667
+ unmutePreviewMedia(iframeRef.current);
668
+ const speed = Math.max(0.1, Math.min(4, rate));
669
+ let startTime = initialTime;
670
+ let startedAt = performance.now();
671
+
672
+ const tick = (now: number) => {
673
+ const elapsed = ((now - startedAt) / 1000) * speed;
674
+ let nextTime = startTime - elapsed;
675
+ if (nextTime <= 0) {
676
+ if (usePlayerStore.getState().loopEnabled && duration > 0) {
677
+ startTime = duration;
678
+ startedAt = now;
679
+ nextTime = duration;
680
+ } else {
681
+ adapter.seek(0);
682
+ liveTime.notify(0);
683
+ setCurrentTime(0);
684
+ setIsPlaying(false);
685
+ shuttleDirectionRef.current = null;
686
+ reverseRafRef.current = 0;
687
+ return;
688
+ }
689
+ }
690
+ adapter.seek(Math.max(0, nextTime));
691
+ liveTime.notify(Math.max(0, nextTime));
692
+ setIsPlaying(true);
693
+ reverseRafRef.current = requestAnimationFrame(tick);
694
+ };
695
+
696
+ setIsPlaying(true);
697
+ shuttleDirectionRef.current = "backward";
698
+ reverseRafRef.current = requestAnimationFrame(tick);
699
+ },
700
+ [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
701
+ );
526
702
 
527
703
  const pause = useCallback(() => {
704
+ stopReverseLoop();
528
705
  const adapter = getAdapter();
529
706
  if (!adapter) return;
530
707
  adapter.pause();
531
708
  setCurrentTime(adapter.getTime()); // sync store so Split/Delete have accurate time
532
709
  setIsPlaying(false);
710
+ shuttleDirectionRef.current = null;
711
+ shuttleSpeedIndexRef.current = 0;
533
712
  stopRAFLoop();
534
- }, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop]);
713
+ }, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]);
535
714
 
536
715
  const togglePlay = useCallback(() => {
537
716
  if (usePlayerStore.getState().isPlaying) {
@@ -543,18 +722,136 @@ export function useTimelinePlayer() {
543
722
 
544
723
  const seek = useCallback(
545
724
  (time: number) => {
725
+ stopReverseLoop();
546
726
  const adapter = getAdapter();
547
727
  if (!adapter) return;
548
- adapter.seek(time);
549
- liveTime.notify(time); // Direct DOM updates (playhead, timecode, progress) no re-render
550
- setCurrentTime(time); // sync store so Split/Delete have accurate time
728
+ const duration = Math.max(0, adapter.getDuration());
729
+ const nextTime = Math.max(0, duration > 0 ? Math.min(duration, time) : time);
730
+ adapter.seek(nextTime);
731
+ liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render
732
+ setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
551
733
  stopRAFLoop();
552
734
  // Only update store if state actually changes (avoids unnecessary re-renders)
553
735
  if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
736
+ shuttleDirectionRef.current = null;
737
+ shuttleSpeedIndexRef.current = 0;
554
738
  },
555
- [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop],
739
+ [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
556
740
  );
557
741
 
742
+ const stepFrames = useCallback(
743
+ (deltaFrames: number) => {
744
+ const adapter = getAdapter();
745
+ const currentTime = adapter?.getTime() ?? usePlayerStore.getState().currentTime;
746
+ seek(stepFrameTime(currentTime, deltaFrames, STUDIO_PREVIEW_FPS));
747
+ },
748
+ [getAdapter, seek],
749
+ );
750
+
751
+ const shuttle = useCallback(
752
+ (direction: "forward" | "backward") => {
753
+ if (shuttleDirectionRef.current === direction) {
754
+ shuttleSpeedIndexRef.current = Math.min(
755
+ shuttleSpeedIndexRef.current + 1,
756
+ SHUTTLE_SPEEDS.length - 1,
757
+ );
758
+ } else {
759
+ shuttleSpeedIndexRef.current = 0;
760
+ }
761
+ const speed = SHUTTLE_SPEEDS[shuttleSpeedIndexRef.current];
762
+ usePlayerStore.getState().setPlaybackRate(speed);
763
+ if (direction === "forward") {
764
+ play();
765
+ } else {
766
+ playBackward(speed);
767
+ }
768
+ },
769
+ [play, playBackward],
770
+ );
771
+
772
+ const handlePlaybackKeyDown = useCallback(
773
+ (e: KeyboardEvent) => {
774
+ if (e.defaultPrevented) return;
775
+ const captionState = useCaptionStore.getState();
776
+ if (
777
+ shouldIgnorePlaybackShortcutEvent(e, {
778
+ isCaptionEditMode: captionState.isEditMode,
779
+ selectedCaptionSegmentCount: captionState.selectedSegmentIds.size,
780
+ })
781
+ ) {
782
+ return;
783
+ }
784
+ pressedCodesRef.current.add(e.code);
785
+ if (e.code === "Space") {
786
+ e.preventDefault();
787
+ togglePlay();
788
+ return;
789
+ }
790
+ if (e.code === "ArrowLeft") {
791
+ e.preventDefault();
792
+ stepFrames(e.shiftKey ? -10 : -1);
793
+ return;
794
+ }
795
+ if (e.code === "ArrowRight") {
796
+ e.preventDefault();
797
+ stepFrames(e.shiftKey ? 10 : 1);
798
+ return;
799
+ }
800
+ if (e.repeat) return;
801
+ if (e.code === "KeyK") {
802
+ e.preventDefault();
803
+ pause();
804
+ return;
805
+ }
806
+ if (e.code === "KeyJ") {
807
+ e.preventDefault();
808
+ if (pressedCodesRef.current.has("KeyK")) {
809
+ stepFrames(-1);
810
+ return;
811
+ }
812
+ shuttle("backward");
813
+ return;
814
+ }
815
+ if (e.code === "KeyL") {
816
+ e.preventDefault();
817
+ if (pressedCodesRef.current.has("KeyK")) {
818
+ stepFrames(1);
819
+ return;
820
+ }
821
+ shuttle("forward");
822
+ }
823
+ },
824
+ [pause, shuttle, stepFrames, togglePlay],
825
+ );
826
+
827
+ const handlePlaybackKeyUp = useCallback((e: KeyboardEvent) => {
828
+ pressedCodesRef.current.delete(e.code);
829
+ }, []);
830
+ playbackKeyDownRef.current = handlePlaybackKeyDown;
831
+ playbackKeyUpRef.current = handlePlaybackKeyUp;
832
+
833
+ const attachIframeShortcutListeners = useCallback(() => {
834
+ iframeShortcutCleanupRef.current?.();
835
+ iframeShortcutCleanupRef.current = null;
836
+
837
+ const iframeWin = iframeRef.current?.contentWindow;
838
+ const iframeDoc = iframeRef.current?.contentDocument;
839
+ if (!iframeWin && !iframeDoc) return;
840
+
841
+ const handleIframeKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
842
+ const handleIframeKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
843
+ iframeWin?.addEventListener("keydown", handleIframeKeyDown, true);
844
+ iframeWin?.addEventListener("keyup", handleIframeKeyUp, true);
845
+ iframeDoc?.addEventListener("keydown", handleIframeKeyDown, true);
846
+ iframeDoc?.addEventListener("keyup", handleIframeKeyUp, true);
847
+ iframeShortcutCleanupRef.current = () => {
848
+ iframeWin?.removeEventListener("keydown", handleIframeKeyDown, true);
849
+ iframeWin?.removeEventListener("keyup", handleIframeKeyUp, true);
850
+ iframeDoc?.removeEventListener("keydown", handleIframeKeyDown, true);
851
+ iframeDoc?.removeEventListener("keyup", handleIframeKeyUp, true);
852
+ };
853
+ }, []);
854
+
558
855
  // Convert a runtime timeline message (from iframe postMessage) into TimelineElements
559
856
  const processTimelineMessage = useCallback(
560
857
  (data: {
@@ -571,8 +868,18 @@ export function useTimelinePlayer() {
571
868
  const filtered = data.clips.filter(
572
869
  (clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId),
573
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>();
574
878
  const els: TimelineElement[] = filtered.map((clip, index) => {
575
- let hostEl: Element | null = null;
879
+ let hostEl = iframeDoc
880
+ ? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls)
881
+ : null;
882
+ if (hostEl) usedHostEls.add(hostEl);
576
883
  const id = clip.id || clip.label || clip.tagName || "element";
577
884
  const entry: TimelineElement = {
578
885
  id,
@@ -581,16 +888,7 @@ export function useTimelinePlayer() {
581
888
  duration: clip.duration,
582
889
  track: clip.track,
583
890
  };
584
- try {
585
- const iframeDoc = iframeRef.current?.contentDocument;
586
- if (iframeDoc && entry.id) {
587
- hostEl = findTimelineDomNode(iframeDoc, entry.id);
588
- }
589
- } catch {
590
- /* cross-origin */
591
- }
592
891
  if (hostEl) {
593
- const iframeDoc = iframeRef.current?.contentDocument;
594
892
  entry.domId = hostEl.id || undefined;
595
893
  entry.selector = getTimelineElementSelector(hostEl);
596
894
  entry.selectorIndex =
@@ -606,19 +904,13 @@ export function useTimelinePlayer() {
606
904
  // after inlining, so the clip manifest may not have compositionSrc.
607
905
  // Fall back to reading data-composition-file from the DOM.
608
906
  let resolvedSrc = clip.compositionSrc;
609
- let hostEl: Element | null = null;
610
907
  if (!resolvedSrc) {
611
- try {
612
- const iframeDoc = iframeRef.current?.contentDocument;
613
- hostEl =
614
- iframeDoc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
615
- resolvedSrc =
616
- hostEl?.getAttribute("data-composition-src") ??
617
- hostEl?.getAttribute("data-composition-file") ??
618
- null;
619
- } catch {
620
- /* cross-origin */
621
- }
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;
622
914
  }
623
915
  if (resolvedSrc) {
624
916
  entry.compositionSrc = resolvedSrc;
@@ -863,6 +1155,7 @@ export function useTimelinePlayer() {
863
1155
  if (doc && iframeWin) {
864
1156
  normalizePreviewViewport(doc, iframeWin);
865
1157
  autoHealMissingCompositionIds(doc);
1158
+ attachIframeShortcutListeners();
866
1159
  }
867
1160
 
868
1161
  // Try reading __clipManifest if already available (fast path)
@@ -929,6 +1222,7 @@ export function useTimelinePlayer() {
929
1222
  processTimelineMessage,
930
1223
  enrichMissingCompositions,
931
1224
  syncTimelineElements,
1225
+ attachIframeShortcutListeners,
932
1226
  ]);
933
1227
 
934
1228
  /** Save the current playback time so the next onIframeLoad restores it. */
@@ -939,11 +1233,25 @@ export function useTimelinePlayer() {
939
1233
  : (usePlayerStore.getState().currentTime ?? 0);
940
1234
  isRefreshingRef.current = true;
941
1235
  stopRAFLoop();
1236
+ stopReverseLoop();
942
1237
  setIsPlaying(false);
943
- }, [getAdapter, stopRAFLoop, setIsPlaying]);
1238
+ }, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
944
1239
 
945
1240
  const togglePlayRef = useRef(togglePlay);
946
1241
  togglePlayRef.current = togglePlay;
1242
+
1243
+ const refreshPlayer = useCallback(() => {
1244
+ const iframe = iframeRef.current;
1245
+ if (!iframe) return;
1246
+
1247
+ saveSeekPosition();
1248
+
1249
+ const src = iframe.src;
1250
+ const url = new URL(src, window.location.origin);
1251
+ url.searchParams.set("_t", String(Date.now()));
1252
+ iframe.src = url.toString();
1253
+ }, [saveSeekPosition]);
1254
+
947
1255
  const getAdapterRef = useRef(getAdapter);
948
1256
  getAdapterRef.current = getAdapter;
949
1257
  const processTimelineMessageRef = useRef(processTimelineMessage);
@@ -952,12 +1260,8 @@ export function useTimelinePlayer() {
952
1260
  enrichMissingCompositionsRef.current = enrichMissingCompositions;
953
1261
 
954
1262
  useMountEffect(() => {
955
- const handleKeyDown = (e: KeyboardEvent) => {
956
- if (e.code === "Space" && e.target === document.body) {
957
- e.preventDefault();
958
- togglePlayRef.current();
959
- }
960
- };
1263
+ const handleWindowKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
1264
+ const handleWindowKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
961
1265
 
962
1266
  // Listen for timeline messages from the iframe runtime.
963
1267
  // The runtime sends this AFTER all external compositions load,
@@ -1030,14 +1334,19 @@ export function useTimelinePlayer() {
1030
1334
  }
1031
1335
  };
1032
1336
 
1033
- window.addEventListener("keydown", handleKeyDown);
1337
+ window.addEventListener("keydown", handleWindowKeyDown, true);
1338
+ window.addEventListener("keyup", handleWindowKeyUp, true);
1034
1339
  window.addEventListener("message", handleMessage);
1035
1340
  document.addEventListener("visibilitychange", handleVisibilityChange);
1036
1341
  return () => {
1037
- window.removeEventListener("keydown", handleKeyDown);
1342
+ window.removeEventListener("keydown", handleWindowKeyDown, true);
1343
+ window.removeEventListener("keyup", handleWindowKeyUp, true);
1344
+ iframeShortcutCleanupRef.current?.();
1345
+ iframeShortcutCleanupRef.current = null;
1038
1346
  window.removeEventListener("message", handleMessage);
1039
1347
  document.removeEventListener("visibilitychange", handleVisibilityChange);
1040
1348
  stopRAFLoop();
1349
+ stopReverseLoop();
1041
1350
  if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
1042
1351
  };
1043
1352
  });
@@ -1045,9 +1354,10 @@ export function useTimelinePlayer() {
1045
1354
  /** Reset the player store (elements, duration, etc.) — call when switching sessions. */
1046
1355
  const resetPlayer = useCallback(() => {
1047
1356
  stopRAFLoop();
1357
+ stopReverseLoop();
1048
1358
  if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
1049
1359
  usePlayerStore.getState().reset();
1050
- }, [stopRAFLoop]);
1360
+ }, [stopRAFLoop, stopReverseLoop]);
1051
1361
 
1052
1362
  return {
1053
1363
  iframeRef,
@@ -1056,6 +1366,7 @@ export function useTimelinePlayer() {
1056
1366
  togglePlay,
1057
1367
  seek,
1058
1368
  onIframeLoad,
1369
+ refreshPlayer,
1059
1370
  saveSeekPosition,
1060
1371
  resetPlayer,
1061
1372
  };
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { formatTime } from "./time";
2
+ import { formatFrameTime, frameToSeconds, secondsToFrame, stepFrameTime, formatTime } from "./time";
3
3
 
4
4
  describe("formatTime", () => {
5
5
  it("formats zero seconds", () => {
@@ -55,3 +55,31 @@ describe("formatTime", () => {
55
55
  expect(formatTime(Infinity)).toBe("0:00");
56
56
  });
57
57
  });
58
+
59
+ describe("frame helpers", () => {
60
+ it("converts seconds to frames at the Studio preview rate", () => {
61
+ expect(secondsToFrame(0)).toBe(0);
62
+ expect(secondsToFrame(1)).toBe(30);
63
+ expect(secondsToFrame(1.5)).toBe(45);
64
+ });
65
+
66
+ it("converts frames to seconds at the Studio preview rate", () => {
67
+ expect(frameToSeconds(0)).toBe(0);
68
+ expect(frameToSeconds(30)).toBe(1);
69
+ expect(frameToSeconds(45)).toBe(1.5);
70
+ });
71
+
72
+ it("formats current and total frame display", () => {
73
+ expect(formatFrameTime(1, 5)).toBe("30f / 150f");
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
+ });
@@ -1,6 +1,32 @@
1
+ export const STUDIO_PREVIEW_FPS = 30;
2
+
1
3
  export function formatTime(time: number): string {
2
4
  if (!Number.isFinite(time) || time < 0) return "0:00";
3
5
  const mins = Math.floor(time / 60);
4
6
  const secs = Math.floor(time % 60);
5
7
  return `${mins}:${secs.toString().padStart(2, "0")}`;
6
8
  }
9
+
10
+ export function secondsToFrame(time: number, fps = STUDIO_PREVIEW_FPS): number {
11
+ if (!Number.isFinite(time) || time <= 0) return 0;
12
+ if (!Number.isFinite(fps) || fps <= 0) return 0;
13
+ return Math.round(time * fps);
14
+ }
15
+
16
+ export function frameToSeconds(frame: number, fps = STUDIO_PREVIEW_FPS): number {
17
+ if (!Number.isFinite(frame) || frame <= 0) return 0;
18
+ if (!Number.isFinite(fps) || fps <= 0) return 0;
19
+ return frame / fps;
20
+ }
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
+ export function formatFrameTime(time: number, duration: number, fps = STUDIO_PREVIEW_FPS): string {
29
+ const currentFrame = secondsToFrame(time, fps);
30
+ const totalFrames = secondsToFrame(duration, fps);
31
+ return `${currentFrame}f / ${totalFrames}f`;
32
+ }
@@ -16,6 +16,7 @@ describe("usePlayerStore", () => {
16
16
  expect(state.elements).toEqual([]);
17
17
  expect(state.selectedElementId).toBeNull();
18
18
  expect(state.playbackRate).toBe(1);
19
+ expect(state.loopEnabled).toBe(false);
19
20
  expect(state.zoomMode).toBe("fit");
20
21
  expect(state.manualZoomPercent).toBe(100);
21
22
  });
@@ -61,6 +62,13 @@ describe("usePlayerStore", () => {
61
62
  });
62
63
  });
63
64
 
65
+ describe("setLoopEnabled", () => {
66
+ it("updates loopEnabled", () => {
67
+ usePlayerStore.getState().setLoopEnabled(true);
68
+ expect(usePlayerStore.getState().loopEnabled).toBe(true);
69
+ });
70
+ });
71
+
64
72
  describe("setTimelineReady", () => {
65
73
  it("updates timelineReady", () => {
66
74
  usePlayerStore.getState().setTimelineReady(true);
@@ -205,9 +213,10 @@ describe("usePlayerStore", () => {
205
213
  expect(state.selectedElementId).toBeNull();
206
214
  });
207
215
 
208
- it("does not reset playbackRate, zoomMode, or manualZoomPercent", () => {
216
+ it("does not reset playbackRate, loopEnabled, zoomMode, or manualZoomPercent", () => {
209
217
  const store = usePlayerStore.getState();
210
218
  store.setPlaybackRate(2);
219
+ store.setLoopEnabled(true);
211
220
  store.setZoomMode("manual");
212
221
  store.setManualZoomPercent(200);
213
222
 
@@ -216,6 +225,7 @@ describe("usePlayerStore", () => {
216
225
  const state = usePlayerStore.getState();
217
226
  // reset() only resets the fields explicitly listed in the reset function
218
227
  expect(state.playbackRate).toBe(2);
228
+ expect(state.loopEnabled).toBe(true);
219
229
  expect(state.zoomMode).toBe("manual");
220
230
  expect(state.manualZoomPercent).toBe(200);
221
231
  });