@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.
- package/dist/assets/index-Bl4Deziq.js +105 -0
- package/dist/assets/index-KioPDrX6.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +494 -185
- package/src/captions/components/CaptionOverlay.tsx +2 -1
- package/src/captions/keyboard.test.ts +38 -0
- package/src/captions/keyboard.ts +8 -0
- package/src/components/LintModal.tsx +3 -4
- package/src/components/editor/DomEditOverlay.tsx +41 -6
- package/src/components/editor/PropertyPanel.tsx +7 -3
- package/src/components/editor/domEditing.test.ts +110 -0
- package/src/components/editor/domEditing.ts +33 -4
- package/src/components/nle/NLELayout.tsx +43 -8
- package/src/components/nle/NLEPreview.tsx +5 -1
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- package/src/components/sidebar/LeftSidebar.tsx +64 -36
- package/src/hooks/usePersistentEditHistory.test.ts +255 -0
- package/src/hooks/usePersistentEditHistory.ts +336 -0
- package/src/icons/SystemIcons.tsx +4 -0
- package/src/player/components/AudioWaveform.tsx +44 -29
- package/src/player/components/CompositionThumbnail.test.ts +19 -0
- package/src/player/components/CompositionThumbnail.tsx +42 -10
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/PlayerControls.tsx +117 -49
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/Timeline.tsx +198 -27
- package/src/player/components/timelineEditing.test.ts +2 -2
- package/src/player/components/timelineEditing.ts +1 -1
- package/src/player/components/timelineTheme.ts +3 -3
- package/src/player/components/timelineZoom.test.ts +21 -0
- package/src/player/components/timelineZoom.ts +11 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +138 -0
- package/src/player/hooks/useTimelinePlayer.ts +354 -43
- package/src/player/lib/time.test.ts +29 -1
- package/src/player/lib/time.ts +26 -0
- package/src/player/store/playerStore.test.ts +11 -1
- package/src/player/store/playerStore.ts +5 -1
- package/src/styles/studio.css +9 -0
- package/src/utils/clipboard.test.ts +88 -0
- package/src/utils/clipboard.ts +57 -0
- package/src/utils/editHistory.test.ts +244 -0
- package/src/utils/editHistory.ts +218 -0
- package/src/utils/editHistoryStorage.test.ts +37 -0
- package/src/utils/editHistoryStorage.ts +99 -0
- package/src/utils/frameCapture.test.ts +26 -0
- package/src/utils/frameCapture.ts +38 -0
- package/src/utils/studioFileHistory.test.ts +156 -0
- package/src/utils/studioFileHistory.ts +61 -0
- package/src/utils/timelineAssetDrop.test.ts +64 -4
- package/src/utils/timelineAssetDrop.ts +27 -5
- package/dist/assets/index-Bi30tos-.js +0 -105
- 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
|
|
197
|
-
|
|
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
|
|
201
|
-
const
|
|
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.
|
|
549
|
-
|
|
550
|
-
|
|
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
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
|
956
|
-
|
|
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",
|
|
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",
|
|
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
|
+
});
|
package/src/player/lib/time.ts
CHANGED
|
@@ -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
|
});
|