@hyperframes/studio 0.5.0-alpha.7 → 0.5.0-alpha.9
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-DKaNgV2Z.css +1 -0
- package/dist/assets/index-peNJzL-4.js +105 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +66 -26
- 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 +5 -2
- package/src/components/editor/PropertyPanel.tsx +7 -3
- package/src/components/editor/domEditing.test.ts +37 -0
- package/src/components/editor/domEditing.ts +4 -1
- package/src/components/nle/NLEPreview.tsx +5 -1
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/PlayerControls.tsx +120 -11
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/Timeline.tsx +197 -27
- package/src/player/components/timelineZoom.test.ts +21 -0
- package/src/player/components/timelineZoom.ts +11 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +79 -0
- package/src/player/hooks/useTimelinePlayer.ts +284 -16
- package/src/player/lib/time.test.ts +19 -1
- package/src/player/lib/time.ts +20 -0
- package/src/player/store/playerStore.test.ts +11 -1
- package/src/player/store/playerStore.ts +5 -1
- package/src/utils/clipboard.test.ts +88 -0
- package/src/utils/clipboard.ts +57 -0
- package/dist/assets/index-0Zt0t13W.css +0 -1
- package/dist/assets/index-CDSQavT7.js +0 -105
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import {
|
|
3
3
|
clampTimelineZoomPercent,
|
|
4
4
|
getNextTimelineZoomPercent,
|
|
5
|
+
getPinchTimelineZoomPercent,
|
|
5
6
|
getTimelinePixelsPerSecond,
|
|
6
7
|
getTimelineZoomPercent,
|
|
7
8
|
MAX_TIMELINE_ZOOM_PERCENT,
|
|
@@ -60,3 +61,23 @@ describe("getNextTimelineZoomPercent", () => {
|
|
|
60
61
|
);
|
|
61
62
|
});
|
|
62
63
|
});
|
|
64
|
+
|
|
65
|
+
describe("getPinchTimelineZoomPercent", () => {
|
|
66
|
+
it("zooms in for upward pinch wheel deltas", () => {
|
|
67
|
+
expect(getPinchTimelineZoomPercent(-80, "fit", 100)).toBeGreaterThan(100);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("zooms out for downward pinch wheel deltas", () => {
|
|
71
|
+
expect(getPinchTimelineZoomPercent(80, "manual", 200)).toBeLessThan(200);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("keeps the current zoom for zero or invalid deltas", () => {
|
|
75
|
+
expect(getPinchTimelineZoomPercent(0, "manual", 180)).toBe(180);
|
|
76
|
+
expect(getPinchTimelineZoomPercent(Number.NaN, "manual", 180)).toBe(180);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("clamps pinch zoom to the supported range", () => {
|
|
80
|
+
expect(getPinchTimelineZoomPercent(10000, "manual", 100)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
|
|
81
|
+
expect(getPinchTimelineZoomPercent(-10000, "manual", 100)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -4,6 +4,7 @@ export const MIN_TIMELINE_ZOOM_PERCENT = 10;
|
|
|
4
4
|
export const MAX_TIMELINE_ZOOM_PERCENT = 2000;
|
|
5
5
|
const ZOOM_OUT_FACTOR = 0.8;
|
|
6
6
|
const ZOOM_IN_FACTOR = 1.25;
|
|
7
|
+
const PINCH_ZOOM_SENSITIVITY = 0.0035;
|
|
7
8
|
|
|
8
9
|
export function clampTimelineZoomPercent(percent: number): number {
|
|
9
10
|
if (!Number.isFinite(percent)) return 100;
|
|
@@ -36,3 +37,13 @@ export function getNextTimelineZoomPercent(
|
|
|
36
37
|
const next = direction === "in" ? current * ZOOM_IN_FACTOR : current * ZOOM_OUT_FACTOR;
|
|
37
38
|
return clampTimelineZoomPercent(next);
|
|
38
39
|
}
|
|
40
|
+
|
|
41
|
+
export function getPinchTimelineZoomPercent(
|
|
42
|
+
deltaY: number,
|
|
43
|
+
zoomMode: ZoomMode,
|
|
44
|
+
manualZoomPercent: number,
|
|
45
|
+
): number {
|
|
46
|
+
const current = getTimelineZoomPercent(zoomMode, manualZoomPercent);
|
|
47
|
+
if (!Number.isFinite(deltaY) || deltaY === 0) return current;
|
|
48
|
+
return clampTimelineZoomPercent(current * Math.exp(-deltaY * PINCH_ZOOM_SENSITIVITY));
|
|
49
|
+
}
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
type ClipManifestClip,
|
|
8
8
|
mergeTimelineElementsPreservingDowngrades,
|
|
9
9
|
resolveStandaloneRootCompositionSrc,
|
|
10
|
+
shouldIgnorePlaybackShortcutEvent,
|
|
11
|
+
shouldIgnorePlaybackShortcutTarget,
|
|
10
12
|
} from "./useTimelinePlayer";
|
|
11
13
|
|
|
12
14
|
function createDocument(markup: string): Document {
|
|
@@ -32,6 +34,26 @@ function createClip(overrides: Partial<ClipManifestClip>): ClipManifestClip {
|
|
|
32
34
|
};
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
function mockTargetMatching(selectorNeedle: string): EventTarget {
|
|
38
|
+
return {
|
|
39
|
+
closest: (selector: string) => (selector.includes(selectorNeedle) ? ({} as Element) : null),
|
|
40
|
+
} as unknown as EventTarget;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function mockKeyboardEvent(
|
|
44
|
+
code: string,
|
|
45
|
+
overrides: Partial<Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "target">> = {},
|
|
46
|
+
): Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "code" | "target"> {
|
|
47
|
+
return {
|
|
48
|
+
altKey: false,
|
|
49
|
+
ctrlKey: false,
|
|
50
|
+
metaKey: false,
|
|
51
|
+
code,
|
|
52
|
+
target: mockTargetMatching("[data-missing]"),
|
|
53
|
+
...overrides,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
35
57
|
describe("buildStandaloneRootTimelineElement", () => {
|
|
36
58
|
it("includes selector and source metadata for standalone composition fallback clips", () => {
|
|
37
59
|
expect(
|
|
@@ -153,3 +175,60 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
|
|
|
153
175
|
).toEqual([{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }]);
|
|
154
176
|
});
|
|
155
177
|
});
|
|
178
|
+
|
|
179
|
+
describe("shouldIgnorePlaybackShortcutTarget", () => {
|
|
180
|
+
it("ignores focused toolbar buttons so Space can activate the button itself", () => {
|
|
181
|
+
expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("button"))).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("ignores the seek slider so ArrowRight reaches the slider key handler", () => {
|
|
185
|
+
expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("[role='slider']"))).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("allows non-interactive preview targets to use playback shortcuts", () => {
|
|
189
|
+
expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("[data-missing]"))).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("shouldIgnorePlaybackShortcutEvent", () => {
|
|
194
|
+
it("ignores modified playback shortcuts so browser and app chords can handle them", () => {
|
|
195
|
+
expect(
|
|
196
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft", { altKey: true })),
|
|
197
|
+
).toBe(true);
|
|
198
|
+
expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyK", { ctrlKey: true }))).toBe(
|
|
199
|
+
true,
|
|
200
|
+
);
|
|
201
|
+
expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyL", { metaKey: true }))).toBe(
|
|
202
|
+
true,
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("defers Arrow frame shortcuts while caption edit mode has selected words", () => {
|
|
207
|
+
const captionSelection = { isCaptionEditMode: true, selectedCaptionSegmentCount: 1 };
|
|
208
|
+
|
|
209
|
+
expect(
|
|
210
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft"), captionSelection),
|
|
211
|
+
).toBe(true);
|
|
212
|
+
expect(
|
|
213
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), captionSelection),
|
|
214
|
+
).toBe(true);
|
|
215
|
+
expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyJ"), captionSelection)).toBe(
|
|
216
|
+
false,
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("allows Arrow frame shortcuts when captions are not selected", () => {
|
|
221
|
+
expect(
|
|
222
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), {
|
|
223
|
+
isCaptionEditMode: true,
|
|
224
|
+
selectedCaptionSegmentCount: 0,
|
|
225
|
+
}),
|
|
226
|
+
).toBe(false);
|
|
227
|
+
expect(
|
|
228
|
+
shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), {
|
|
229
|
+
isCaptionEditMode: false,
|
|
230
|
+
selectedCaptionSegmentCount: 1,
|
|
231
|
+
}),
|
|
232
|
+
).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -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 { frameToSeconds, STUDIO_PREVIEW_FPS } from "../lib/time";
|
|
5
|
+
import { useCaptionStore } from "../../captions/store";
|
|
4
6
|
|
|
5
7
|
interface PlaybackAdapter {
|
|
6
8
|
play: () => void;
|
|
@@ -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.
|
|
@@ -452,6 +512,13 @@ export function useTimelinePlayer() {
|
|
|
452
512
|
const probeIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
|
453
513
|
const pendingSeekRef = useRef<number | null>(null);
|
|
454
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>(() => {});
|
|
455
522
|
|
|
456
523
|
// ZERO store subscriptions — this hook never causes re-renders.
|
|
457
524
|
// All reads use getState() (point-in-time), all writes use the stable setters.
|
|
@@ -510,6 +577,10 @@ export function useTimelinePlayer() {
|
|
|
510
577
|
}
|
|
511
578
|
}, []);
|
|
512
579
|
|
|
580
|
+
const stopReverseLoop = useCallback(() => {
|
|
581
|
+
cancelAnimationFrame(reverseRafRef.current);
|
|
582
|
+
}, []);
|
|
583
|
+
|
|
513
584
|
const startRAFLoop = useCallback(() => {
|
|
514
585
|
const tick = () => {
|
|
515
586
|
const adapter = getAdapter();
|
|
@@ -518,6 +589,14 @@ export function useTimelinePlayer() {
|
|
|
518
589
|
const dur = adapter.getDuration();
|
|
519
590
|
liveTime.notify(time); // direct DOM updates, no React re-render
|
|
520
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
|
+
}
|
|
521
600
|
setCurrentTime(time); // sync Zustand once at end
|
|
522
601
|
setIsPlaying(false);
|
|
523
602
|
cancelAnimationFrame(rafRef.current);
|
|
@@ -560,6 +639,8 @@ export function useTimelinePlayer() {
|
|
|
560
639
|
}, []);
|
|
561
640
|
|
|
562
641
|
const play = useCallback(() => {
|
|
642
|
+
stopRAFLoop();
|
|
643
|
+
stopReverseLoop();
|
|
563
644
|
const adapter = getAdapter();
|
|
564
645
|
if (!adapter) return;
|
|
565
646
|
if (adapter.getTime() >= adapter.getDuration()) {
|
|
@@ -568,18 +649,68 @@ export function useTimelinePlayer() {
|
|
|
568
649
|
unmutePreviewMedia(iframeRef.current);
|
|
569
650
|
applyPlaybackRate(usePlayerStore.getState().playbackRate);
|
|
570
651
|
adapter.play();
|
|
652
|
+
shuttleDirectionRef.current = "forward";
|
|
571
653
|
setIsPlaying(true);
|
|
572
654
|
startRAFLoop();
|
|
573
|
-
}, [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
|
+
);
|
|
574
702
|
|
|
575
703
|
const pause = useCallback(() => {
|
|
704
|
+
stopReverseLoop();
|
|
576
705
|
const adapter = getAdapter();
|
|
577
706
|
if (!adapter) return;
|
|
578
707
|
adapter.pause();
|
|
579
708
|
setCurrentTime(adapter.getTime()); // sync store so Split/Delete have accurate time
|
|
580
709
|
setIsPlaying(false);
|
|
710
|
+
shuttleDirectionRef.current = null;
|
|
711
|
+
shuttleSpeedIndexRef.current = 0;
|
|
581
712
|
stopRAFLoop();
|
|
582
|
-
}, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop]);
|
|
713
|
+
}, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]);
|
|
583
714
|
|
|
584
715
|
const togglePlay = useCallback(() => {
|
|
585
716
|
if (usePlayerStore.getState().isPlaying) {
|
|
@@ -591,18 +722,136 @@ export function useTimelinePlayer() {
|
|
|
591
722
|
|
|
592
723
|
const seek = useCallback(
|
|
593
724
|
(time: number) => {
|
|
725
|
+
stopReverseLoop();
|
|
594
726
|
const adapter = getAdapter();
|
|
595
727
|
if (!adapter) return;
|
|
596
|
-
adapter.
|
|
597
|
-
|
|
598
|
-
|
|
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
|
|
599
733
|
stopRAFLoop();
|
|
600
734
|
// Only update store if state actually changes (avoids unnecessary re-renders)
|
|
601
735
|
if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
|
|
736
|
+
shuttleDirectionRef.current = null;
|
|
737
|
+
shuttleSpeedIndexRef.current = 0;
|
|
738
|
+
},
|
|
739
|
+
[getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
const stepFrames = useCallback(
|
|
743
|
+
(deltaFrames: number) => {
|
|
744
|
+
const adapter = getAdapter();
|
|
745
|
+
const currentTime = adapter?.getTime() ?? usePlayerStore.getState().currentTime;
|
|
746
|
+
seek(currentTime + frameToSeconds(deltaFrames, STUDIO_PREVIEW_FPS));
|
|
602
747
|
},
|
|
603
|
-
[getAdapter,
|
|
748
|
+
[getAdapter, seek],
|
|
604
749
|
);
|
|
605
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
|
+
|
|
606
855
|
// Convert a runtime timeline message (from iframe postMessage) into TimelineElements
|
|
607
856
|
const processTimelineMessage = useCallback(
|
|
608
857
|
(data: {
|
|
@@ -906,6 +1155,7 @@ export function useTimelinePlayer() {
|
|
|
906
1155
|
if (doc && iframeWin) {
|
|
907
1156
|
normalizePreviewViewport(doc, iframeWin);
|
|
908
1157
|
autoHealMissingCompositionIds(doc);
|
|
1158
|
+
attachIframeShortcutListeners();
|
|
909
1159
|
}
|
|
910
1160
|
|
|
911
1161
|
// Try reading __clipManifest if already available (fast path)
|
|
@@ -972,6 +1222,7 @@ export function useTimelinePlayer() {
|
|
|
972
1222
|
processTimelineMessage,
|
|
973
1223
|
enrichMissingCompositions,
|
|
974
1224
|
syncTimelineElements,
|
|
1225
|
+
attachIframeShortcutListeners,
|
|
975
1226
|
]);
|
|
976
1227
|
|
|
977
1228
|
/** Save the current playback time so the next onIframeLoad restores it. */
|
|
@@ -982,11 +1233,25 @@ export function useTimelinePlayer() {
|
|
|
982
1233
|
: (usePlayerStore.getState().currentTime ?? 0);
|
|
983
1234
|
isRefreshingRef.current = true;
|
|
984
1235
|
stopRAFLoop();
|
|
1236
|
+
stopReverseLoop();
|
|
985
1237
|
setIsPlaying(false);
|
|
986
|
-
}, [getAdapter, stopRAFLoop, setIsPlaying]);
|
|
1238
|
+
}, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
|
|
987
1239
|
|
|
988
1240
|
const togglePlayRef = useRef(togglePlay);
|
|
989
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
|
+
|
|
990
1255
|
const getAdapterRef = useRef(getAdapter);
|
|
991
1256
|
getAdapterRef.current = getAdapter;
|
|
992
1257
|
const processTimelineMessageRef = useRef(processTimelineMessage);
|
|
@@ -995,12 +1260,8 @@ export function useTimelinePlayer() {
|
|
|
995
1260
|
enrichMissingCompositionsRef.current = enrichMissingCompositions;
|
|
996
1261
|
|
|
997
1262
|
useMountEffect(() => {
|
|
998
|
-
const
|
|
999
|
-
|
|
1000
|
-
e.preventDefault();
|
|
1001
|
-
togglePlayRef.current();
|
|
1002
|
-
}
|
|
1003
|
-
};
|
|
1263
|
+
const handleWindowKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
|
|
1264
|
+
const handleWindowKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
|
|
1004
1265
|
|
|
1005
1266
|
// Listen for timeline messages from the iframe runtime.
|
|
1006
1267
|
// The runtime sends this AFTER all external compositions load,
|
|
@@ -1073,14 +1334,19 @@ export function useTimelinePlayer() {
|
|
|
1073
1334
|
}
|
|
1074
1335
|
};
|
|
1075
1336
|
|
|
1076
|
-
window.addEventListener("keydown",
|
|
1337
|
+
window.addEventListener("keydown", handleWindowKeyDown, true);
|
|
1338
|
+
window.addEventListener("keyup", handleWindowKeyUp, true);
|
|
1077
1339
|
window.addEventListener("message", handleMessage);
|
|
1078
1340
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
1079
1341
|
return () => {
|
|
1080
|
-
window.removeEventListener("keydown",
|
|
1342
|
+
window.removeEventListener("keydown", handleWindowKeyDown, true);
|
|
1343
|
+
window.removeEventListener("keyup", handleWindowKeyUp, true);
|
|
1344
|
+
iframeShortcutCleanupRef.current?.();
|
|
1345
|
+
iframeShortcutCleanupRef.current = null;
|
|
1081
1346
|
window.removeEventListener("message", handleMessage);
|
|
1082
1347
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
1083
1348
|
stopRAFLoop();
|
|
1349
|
+
stopReverseLoop();
|
|
1084
1350
|
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
1085
1351
|
};
|
|
1086
1352
|
});
|
|
@@ -1088,9 +1354,10 @@ export function useTimelinePlayer() {
|
|
|
1088
1354
|
/** Reset the player store (elements, duration, etc.) — call when switching sessions. */
|
|
1089
1355
|
const resetPlayer = useCallback(() => {
|
|
1090
1356
|
stopRAFLoop();
|
|
1357
|
+
stopReverseLoop();
|
|
1091
1358
|
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
1092
1359
|
usePlayerStore.getState().reset();
|
|
1093
|
-
}, [stopRAFLoop]);
|
|
1360
|
+
}, [stopRAFLoop, stopReverseLoop]);
|
|
1094
1361
|
|
|
1095
1362
|
return {
|
|
1096
1363
|
iframeRef,
|
|
@@ -1099,6 +1366,7 @@ export function useTimelinePlayer() {
|
|
|
1099
1366
|
togglePlay,
|
|
1100
1367
|
seek,
|
|
1101
1368
|
onIframeLoad,
|
|
1369
|
+
refreshPlayer,
|
|
1102
1370
|
saveSeekPosition,
|
|
1103
1371
|
resetPlayer,
|
|
1104
1372
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { formatTime } from "./time";
|
|
2
|
+
import { formatFrameTime, frameToSeconds, secondsToFrame, formatTime } from "./time";
|
|
3
3
|
|
|
4
4
|
describe("formatTime", () => {
|
|
5
5
|
it("formats zero seconds", () => {
|
|
@@ -55,3 +55,21 @@ 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
|
+
});
|
package/src/player/lib/time.ts
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
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 formatFrameTime(time: number, duration: number, fps = STUDIO_PREVIEW_FPS): string {
|
|
23
|
+
const currentFrame = secondsToFrame(time, fps);
|
|
24
|
+
const totalFrames = secondsToFrame(duration, fps);
|
|
25
|
+
return `${currentFrame}f / ${totalFrames}f`;
|
|
26
|
+
}
|
|
@@ -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
|
});
|