@hyperframes/studio 0.6.0 → 0.6.1

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 (58) hide show
  1. package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
  2. package/dist/assets/index-hYc4aP7M.js +117 -0
  3. package/dist/index.html +1 -1
  4. package/package.json +4 -4
  5. package/src/App.tsx +2 -13
  6. package/src/captions/components/CaptionOverlay.tsx +13 -246
  7. package/src/captions/components/CaptionOverlayUtils.ts +221 -0
  8. package/src/components/StudioPreviewArea.tsx +6 -2
  9. package/src/components/editor/DomEditOverlay.tsx +88 -1007
  10. package/src/components/editor/EaseCurveEditor.tsx +221 -0
  11. package/src/components/editor/FileTree.tsx +13 -621
  12. package/src/components/editor/FileTreeIcons.tsx +128 -0
  13. package/src/components/editor/FileTreeNodes.tsx +496 -0
  14. package/src/components/editor/MotionPanel.tsx +16 -390
  15. package/src/components/editor/MotionPanelFields.tsx +185 -0
  16. package/src/components/editor/domEditOverlayGeometry.ts +211 -0
  17. package/src/components/editor/domEditOverlayGestures.ts +138 -0
  18. package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
  19. package/src/components/editor/domEditing.ts +44 -1150
  20. package/src/components/editor/domEditingAgentPrompt.ts +97 -0
  21. package/src/components/editor/domEditingDom.ts +266 -0
  22. package/src/components/editor/domEditingElement.ts +329 -0
  23. package/src/components/editor/domEditingLayers.ts +460 -0
  24. package/src/components/editor/domEditingTypes.ts +125 -0
  25. package/src/components/editor/manualEdits.ts +84 -1081
  26. package/src/components/editor/manualEditsDom.ts +436 -0
  27. package/src/components/editor/manualEditsParsing.ts +280 -0
  28. package/src/components/editor/manualEditsSnapshot.ts +333 -0
  29. package/src/components/editor/manualEditsTypes.ts +141 -0
  30. package/src/components/editor/studioMotion.ts +47 -434
  31. package/src/components/editor/studioMotionOps.ts +299 -0
  32. package/src/components/editor/studioMotionTypes.ts +168 -0
  33. package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
  34. package/src/components/editor/useDomEditOverlayRects.ts +207 -0
  35. package/src/components/nle/NLELayout.tsx +60 -144
  36. package/src/components/nle/useCompositionStack.ts +126 -0
  37. package/src/hooks/useToast.ts +20 -0
  38. package/src/player/components/Timeline.tsx +189 -1418
  39. package/src/player/components/TimelineCanvas.tsx +434 -0
  40. package/src/player/components/TimelineEmptyState.tsx +102 -0
  41. package/src/player/components/TimelineRuler.tsx +90 -0
  42. package/src/player/components/timelineIcons.tsx +49 -0
  43. package/src/player/components/timelineLayout.ts +215 -0
  44. package/src/player/components/timelineUtils.ts +211 -0
  45. package/src/player/components/useTimelineClipDrag.ts +388 -0
  46. package/src/player/components/useTimelinePlayhead.ts +200 -0
  47. package/src/player/components/useTimelineRangeSelection.ts +135 -0
  48. package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
  49. package/src/player/hooks/useTimelinePlayer.ts +69 -1372
  50. package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
  51. package/src/player/lib/playbackAdapter.ts +145 -0
  52. package/src/player/lib/playbackShortcuts.ts +68 -0
  53. package/src/player/lib/playbackTypes.ts +60 -0
  54. package/src/player/lib/timelineDOM.ts +373 -0
  55. package/src/player/lib/timelineElementHelpers.ts +303 -0
  56. package/src/player/lib/timelineIframeHelpers.ts +269 -0
  57. package/dist/assets/hyperframes-player-DOFETgjy.js +0 -418
  58. package/dist/assets/index-DUqUmaoH.js +0 -117
@@ -0,0 +1,288 @@
1
+ /**
2
+ * React callbacks for synchronising the player store from iframe runtime data.
3
+ *
4
+ * Covers four related concerns:
5
+ * - processTimelineMessage — turn a clip-manifest postMessage into TimelineElements
6
+ * - enrichMissingCompositions — fill gaps the manifest misses (element-ref starts)
7
+ * - initializeAdapter — called after iframe load: seek, set duration, read elements
8
+ * - onIframeLoad — orchestrates initializeAdapter with a message-based fallback
9
+ */
10
+
11
+ import { useCallback } from "react";
12
+ import { usePlayerStore } from "../store/playerStore";
13
+ import type { TimelineElement } from "../store/playerStore";
14
+ import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/playbackTypes";
15
+ import {
16
+ parseTimelineFromDOM,
17
+ createTimelineElementFromManifestClip,
18
+ findTimelineDomNodeForClip,
19
+ createImplicitTimelineLayersFromDOM,
20
+ buildStandaloneRootTimelineElement,
21
+ mergeTimelineElementsPreservingDowngrades,
22
+ getTimelineElementSelector,
23
+ } from "../lib/timelineDOM";
24
+ import {
25
+ normalizePreviewViewport,
26
+ autoHealMissingCompositionIds,
27
+ unmutePreviewMedia,
28
+ buildMissingCompositionElements,
29
+ } from "../lib/timelineIframeHelpers";
30
+ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
31
+
32
+ interface UseTimelineSyncCallbacksParams {
33
+ iframeRef: React.RefObject<HTMLIFrameElement | null>;
34
+ probeIntervalRef: React.MutableRefObject<ReturnType<typeof setInterval> | undefined>;
35
+ pendingSeekRef: React.MutableRefObject<number | null>;
36
+ isRefreshingRef: React.MutableRefObject<boolean>;
37
+ getAdapter: () => PlaybackAdapter | null;
38
+ syncTimelineElements: (elements: TimelineElement[], nextDuration?: number) => void;
39
+ setDuration: (v: number) => void;
40
+ setCurrentTime: (v: number) => void;
41
+ setTimelineReady: (v: boolean) => void;
42
+ setIsPlaying: (v: boolean) => void;
43
+ attachIframeShortcutListeners: () => void;
44
+ }
45
+
46
+ export function useTimelineSyncCallbacks({
47
+ iframeRef,
48
+ probeIntervalRef,
49
+ pendingSeekRef,
50
+ isRefreshingRef,
51
+ getAdapter,
52
+ syncTimelineElements,
53
+ setDuration,
54
+ setCurrentTime,
55
+ setTimelineReady,
56
+ setIsPlaying,
57
+ attachIframeShortcutListeners,
58
+ }: UseTimelineSyncCallbacksParams) {
59
+ // Convert a runtime timeline message (from iframe postMessage) into TimelineElements
60
+ const processTimelineMessage = useCallback(
61
+ (data: {
62
+ clips: ClipManifestClip[];
63
+ durationInFrames: number;
64
+ scenes?: Array<{ id: string; label: string; start: number; duration: number }>;
65
+ }) => {
66
+ if (!data.clips || data.clips.length === 0) {
67
+ return;
68
+ }
69
+
70
+ // Show root-level clips: no parentCompositionId, OR parent is a "phantom wrapper"
71
+ const clipCompositionIds = new Set(data.clips.map((c) => c.compositionId).filter(Boolean));
72
+ const filtered = data.clips.filter(
73
+ (clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId),
74
+ );
75
+ let iframeDoc: Document | null = null;
76
+ try {
77
+ iframeDoc = iframeRef.current?.contentDocument ?? null;
78
+ } catch {
79
+ iframeDoc = null;
80
+ }
81
+ const usedHostEls = new Set<Element>();
82
+ const els: TimelineElement[] = filtered.map((clip, index) => {
83
+ const hostEl = iframeDoc
84
+ ? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls)
85
+ : null;
86
+ if (hostEl) usedHostEls.add(hostEl);
87
+ return createTimelineElementFromManifestClip({
88
+ clip,
89
+ fallbackIndex: index,
90
+ doc: iframeDoc,
91
+ hostEl,
92
+ });
93
+ });
94
+ const rawDuration = data.durationInFrames / 30;
95
+ // Clamp non-finite or absurdly large durations — the runtime can emit
96
+ // Infinity when it detects a loop-inflated GSAP timeline without an
97
+ // explicit data-duration on the root composition.
98
+ const newDuration = Number.isFinite(rawDuration) && rawDuration < 7200 ? rawDuration : 0;
99
+ const effectiveDuration = newDuration > 0 ? newDuration : usePlayerStore.getState().duration;
100
+ const clampedEls =
101
+ effectiveDuration > 0
102
+ ? els
103
+ .filter((element) => element.start < effectiveDuration)
104
+ .map((element) => ({
105
+ ...element,
106
+ duration: Math.min(element.duration, effectiveDuration - element.start),
107
+ }))
108
+ .filter((element) => element.duration > 0)
109
+ : els;
110
+ const timelineEls =
111
+ iframeDoc && effectiveDuration > 0
112
+ ? [
113
+ ...clampedEls,
114
+ ...createImplicitTimelineLayersFromDOM(iframeDoc, effectiveDuration, clampedEls),
115
+ ]
116
+ : clampedEls;
117
+ if (timelineEls.length > 0) {
118
+ syncTimelineElements(timelineEls, newDuration > 0 ? newDuration : undefined);
119
+ }
120
+ },
121
+ [iframeRef, syncTimelineElements],
122
+ );
123
+
124
+ const enrichMissingCompositions = useCallback(() => {
125
+ try {
126
+ const iframe = iframeRef.current;
127
+ const doc = iframe?.contentDocument;
128
+ const iframeWin = iframe?.contentWindow as IframeWindow | null;
129
+ if (!doc || !iframeWin) return;
130
+
131
+ const currentEls = usePlayerStore.getState().elements;
132
+ const rootDuration = usePlayerStore.getState().duration;
133
+ const { missing, updatedEls, patched } = buildMissingCompositionElements(
134
+ doc,
135
+ iframeWin,
136
+ currentEls,
137
+ rootDuration,
138
+ );
139
+
140
+ if (missing.length > 0 || patched) {
141
+ // Dedup: ensure no missing element duplicates an existing one
142
+ const finalIds = new Set(updatedEls.map((e) => e.id));
143
+ const dedupedMissing = missing.filter((m) => !finalIds.has(m.id));
144
+ syncTimelineElements([...updatedEls, ...dedupedMissing]);
145
+ }
146
+ } catch (err) {
147
+ console.warn("[useTimelinePlayer] enrichMissingCompositions failed", err);
148
+ }
149
+ }, [iframeRef, syncTimelineElements]);
150
+
151
+ const initializeAdapter = useCallback(() => {
152
+ const adapter = getAdapter();
153
+ if (!adapter || adapter.getDuration() <= 0) return false;
154
+
155
+ adapter.pause();
156
+ const seekTo = pendingSeekRef.current;
157
+ pendingSeekRef.current = null;
158
+ const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0;
159
+
160
+ adapter.seek(startTime);
161
+ const adapterDur = adapter.getDuration();
162
+ if (
163
+ Number.isFinite(adapterDur) &&
164
+ adapterDur > 0 &&
165
+ adapterDur < 7200 &&
166
+ adapterDur !== usePlayerStore.getState().duration
167
+ ) {
168
+ setDuration(adapterDur);
169
+ }
170
+ setCurrentTime(startTime);
171
+ if (!isRefreshingRef.current) {
172
+ setTimelineReady(true);
173
+ }
174
+ isRefreshingRef.current = false;
175
+ setIsPlaying(false);
176
+
177
+ try {
178
+ const iframe = iframeRef.current;
179
+ const doc = iframe?.contentDocument;
180
+ const iframeWin = iframe?.contentWindow as IframeWindow | null;
181
+ if (doc && iframeWin) {
182
+ normalizePreviewViewport(doc, iframeWin);
183
+ autoHealMissingCompositionIds(doc);
184
+ attachIframeShortcutListeners();
185
+ }
186
+
187
+ const manifest = iframeWin?.__clipManifest;
188
+ if (manifest && manifest.clips.length > 0) {
189
+ processTimelineMessage(manifest);
190
+ }
191
+ enrichMissingCompositions();
192
+
193
+ if (usePlayerStore.getState().elements.length === 0 && doc) {
194
+ const els = parseTimelineFromDOM(doc, adapter.getDuration());
195
+ if (els.length > 0) syncTimelineElements(els);
196
+ }
197
+ if (usePlayerStore.getState().elements.length === 0 && doc) {
198
+ const rootComp = doc.querySelector("[data-composition-id]");
199
+ const rootDuration = adapter.getDuration();
200
+ if (rootComp && rootDuration > 0) {
201
+ const fallbackElement = buildStandaloneRootTimelineElement({
202
+ compositionId: rootComp.getAttribute("data-composition-id") || "composition",
203
+ tagName: (rootComp as HTMLElement).tagName || "div",
204
+ rootDuration,
205
+ iframeSrc: iframe?.src || "",
206
+ selector: getTimelineElementSelector(rootComp),
207
+ });
208
+ if (fallbackElement) syncTimelineElements([fallbackElement]);
209
+ }
210
+ }
211
+ } catch (err) {
212
+ console.warn("[useTimelinePlayer] Could not read timeline elements from iframe", err);
213
+ }
214
+ return true;
215
+ }, [
216
+ getAdapter,
217
+ setDuration,
218
+ setCurrentTime,
219
+ setTimelineReady,
220
+ setIsPlaying,
221
+ processTimelineMessage,
222
+ enrichMissingCompositions,
223
+ syncTimelineElements,
224
+ attachIframeShortcutListeners,
225
+ iframeRef,
226
+ isRefreshingRef,
227
+ pendingSeekRef,
228
+ ]);
229
+
230
+ const onIframeLoad = useCallback(() => {
231
+ unmutePreviewMedia(iframeRef.current);
232
+ if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
233
+
234
+ // Fast path: adapter already available (in-place reloads, cached compositions)
235
+ if (initializeAdapter()) return;
236
+
237
+ // The runtime posts "state" or "timeline" messages once ready.
238
+ // Listen for those instead of polling.
239
+ const iframe = iframeRef.current;
240
+ let settled = false;
241
+
242
+ const trySettle = () => {
243
+ if (settled) return;
244
+ if (initializeAdapter()) {
245
+ settled = true;
246
+ window.removeEventListener("message", onMessage);
247
+ if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
248
+ }
249
+ };
250
+
251
+ const onMessage = (e: MessageEvent) => {
252
+ if (e.source && iframe && e.source !== iframe.contentWindow) return;
253
+ const data = e.data;
254
+ if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
255
+ trySettle();
256
+ }
257
+ };
258
+ window.addEventListener("message", onMessage);
259
+
260
+ // Safety net: if no message arrives within 5s, try one last time then give up.
261
+ probeIntervalRef.current = setTimeout(() => {
262
+ if (!settled) {
263
+ trySettle();
264
+ if (!settled) {
265
+ console.warn("[useTimelinePlayer] Runtime did not signal readiness within 5s");
266
+ }
267
+ }
268
+ window.removeEventListener("message", onMessage);
269
+ }, 5000) as unknown as ReturnType<typeof setInterval>;
270
+ }, [initializeAdapter, iframeRef, probeIntervalRef]);
271
+
272
+ // Stable refs so mount-effect closures always call the latest version
273
+ const processTimelineMessageRef = { current: processTimelineMessage };
274
+ const enrichMissingCompositionsRef = { current: enrichMissingCompositions };
275
+
276
+ return {
277
+ processTimelineMessage,
278
+ processTimelineMessageRef,
279
+ enrichMissingCompositions,
280
+ enrichMissingCompositionsRef,
281
+ initializeAdapter,
282
+ onIframeLoad,
283
+ };
284
+ }
285
+
286
+ // Re-export the merge helper so the hook can use it via this module (avoids
287
+ // adding another import line to the already-large useTimelinePlayer.ts).
288
+ export { mergeTimelineElementsPreservingDowngrades, getTimelineElementIdentity };
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Playback adapter utilities: factory for the static-seek adapter used when a
3
+ * composition exposes only a `renderSeek` / `seek` API (no native play/pause
4
+ * support), plus a thin wrapper that normalises GSAP-style `TimelineLike`
5
+ * objects to the `PlaybackAdapter` interface.
6
+ */
7
+
8
+ import type {
9
+ PlaybackAdapter,
10
+ RuntimePlaybackAdapter,
11
+ StaticSeekPlaybackClock,
12
+ TimelineLike,
13
+ } from "./playbackTypes";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Pure numeric helpers
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export function isFinitePositive(value: number): boolean {
20
+ return Number.isFinite(value) && value > 0;
21
+ }
22
+
23
+ export function clampTime(time: number, duration: number): number {
24
+ const safeDuration = Math.max(0, Number.isFinite(duration) ? duration : 0);
25
+ const safeTime = Math.max(0, Number.isFinite(time) ? time : 0);
26
+ return safeDuration > 0 ? Math.min(safeTime, safeDuration) : safeTime;
27
+ }
28
+
29
+ export function getAdapterDuration(adapter: PlaybackAdapter | null | undefined): number {
30
+ if (!adapter) return 0;
31
+ try {
32
+ const duration = Number(adapter.getDuration());
33
+ return isFinitePositive(duration) ? duration : 0;
34
+ } catch {
35
+ return 0;
36
+ }
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Clock factory
41
+ // ---------------------------------------------------------------------------
42
+
43
+ export function getDefaultStaticSeekPlaybackClock(win: Window): StaticSeekPlaybackClock {
44
+ return {
45
+ now: () => win.performance.now(),
46
+ requestAnimationFrame: (callback) => win.requestAnimationFrame(callback),
47
+ cancelAnimationFrame: (handle) => win.cancelAnimationFrame(handle),
48
+ };
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Static-seek adapter
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /**
56
+ * Wraps a render-only player (exposes `renderSeek`/`seek` but no native
57
+ * play/pause) and drives playback via `requestAnimationFrame`.
58
+ */
59
+ export function createStaticSeekPlaybackAdapter(
60
+ player: Pick<RuntimePlaybackAdapter, "getTime"> &
61
+ Partial<Pick<RuntimePlaybackAdapter, "renderSeek" | "seek">>,
62
+ duration: number,
63
+ clock: StaticSeekPlaybackClock,
64
+ getPlaybackRate: () => number = () => 1,
65
+ ): PlaybackAdapter {
66
+ const safeDuration = Math.max(0, Number.isFinite(duration) ? duration : 0);
67
+ let currentTime = clampTime(Number(player.getTime?.() ?? 0), safeDuration);
68
+ let playing = false;
69
+ let rafId = 0;
70
+ let playStartTime = currentTime;
71
+ let playStartNow = clock.now();
72
+
73
+ const renderSeek = (time: number) => {
74
+ currentTime = clampTime(time, safeDuration);
75
+ if (typeof player.renderSeek === "function") {
76
+ player.renderSeek(currentTime);
77
+ return;
78
+ }
79
+ player.seek?.(currentTime);
80
+ };
81
+
82
+ const stopTicker = () => {
83
+ if (rafId) {
84
+ clock.cancelAnimationFrame(rafId);
85
+ rafId = 0;
86
+ }
87
+ };
88
+
89
+ const tick: FrameRequestCallback = (now) => {
90
+ if (!playing) return;
91
+ const playbackRate = Math.max(0.1, Number(getPlaybackRate()) || 1);
92
+ const elapsed = ((now - playStartNow) / 1000) * playbackRate;
93
+ renderSeek(playStartTime + elapsed);
94
+ if (currentTime >= safeDuration) {
95
+ playing = false;
96
+ rafId = 0;
97
+ return;
98
+ }
99
+ rafId = clock.requestAnimationFrame(tick);
100
+ };
101
+
102
+ return {
103
+ play: () => {
104
+ if (playing || safeDuration <= 0) return;
105
+ if (currentTime >= safeDuration) renderSeek(0);
106
+ playing = true;
107
+ playStartTime = currentTime;
108
+ playStartNow = clock.now();
109
+ stopTicker();
110
+ rafId = clock.requestAnimationFrame(tick);
111
+ },
112
+ pause: () => {
113
+ playing = false;
114
+ stopTicker();
115
+ },
116
+ seek: (time) => {
117
+ renderSeek(time);
118
+ if (playing) {
119
+ playStartTime = currentTime;
120
+ playStartNow = clock.now();
121
+ }
122
+ },
123
+ getTime: () => currentTime,
124
+ getDuration: () => safeDuration,
125
+ isPlaying: () => playing,
126
+ };
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // GSAP timeline wrapper
131
+ // ---------------------------------------------------------------------------
132
+
133
+ export function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
134
+ return {
135
+ play: () => tl.play(),
136
+ pause: () => tl.pause(),
137
+ seek: (t) => {
138
+ tl.pause();
139
+ tl.seek(t);
140
+ },
141
+ getTime: () => tl.time(),
142
+ getDuration: () => tl.duration(),
143
+ isPlaying: () => tl.isActive(),
144
+ };
145
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Keyboard shortcut filtering logic for playback controls.
3
+ *
4
+ * Determines whether a keydown event should be handled as a playback shortcut
5
+ * or ignored (e.g. when focus is in an input field, or when caption edit mode
6
+ * is active and the user is navigating caption segments).
7
+ */
8
+
9
+ const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]);
10
+
11
+ const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
12
+ "input",
13
+ "textarea",
14
+ "select",
15
+ "button",
16
+ "a[href]",
17
+ "[contenteditable='true']",
18
+ "[role='button']",
19
+ "[role='checkbox']",
20
+ "[role='combobox']",
21
+ "[role='menuitem']",
22
+ "[role='radio']",
23
+ "[role='slider']",
24
+ "[role='spinbutton']",
25
+ "[role='switch']",
26
+ "[role='textbox']",
27
+ ].join(",");
28
+
29
+ export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null): boolean {
30
+ if (!target || typeof target !== "object") return false;
31
+ const candidate = target as { closest?: unknown };
32
+ if (typeof candidate.closest !== "function") return false;
33
+ return (
34
+ (candidate.closest as (selector: string) => Element | null).call(
35
+ target,
36
+ PLAYBACK_SHORTCUT_IGNORED_SELECTOR,
37
+ ) !== null
38
+ );
39
+ }
40
+
41
+ interface PlaybackShortcutCaptionState {
42
+ isCaptionEditMode: boolean;
43
+ selectedCaptionSegmentCount: number;
44
+ }
45
+
46
+ type PlaybackShortcutEvent = Pick<
47
+ KeyboardEvent,
48
+ "altKey" | "ctrlKey" | "metaKey" | "code" | "target"
49
+ >;
50
+
51
+ export function shouldIgnorePlaybackShortcutEvent(
52
+ event: PlaybackShortcutEvent,
53
+ captionState: PlaybackShortcutCaptionState = {
54
+ isCaptionEditMode: false,
55
+ selectedCaptionSegmentCount: 0,
56
+ },
57
+ ): boolean {
58
+ if (event.metaKey || event.ctrlKey || event.altKey) return true;
59
+ if (shouldIgnorePlaybackShortcutTarget(event.target)) return true;
60
+ return (
61
+ PLAYBACK_FRAME_STEP_CODES.has(event.code) &&
62
+ captionState.isCaptionEditMode &&
63
+ captionState.selectedCaptionSegmentCount > 0
64
+ );
65
+ }
66
+
67
+ /** JKL shuttle speeds (×1, ×2, ×4). */
68
+ export const SHUTTLE_SPEEDS = [1, 2, 4] as const;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Shared type definitions for the timeline playback subsystem.
3
+ * Kept in a separate module so adapter, DOM, and hook modules can all import
4
+ * from here without creating circular dependencies.
5
+ */
6
+
7
+ export interface PlaybackAdapter {
8
+ play: () => void;
9
+ pause: () => void;
10
+ seek: (time: number) => void;
11
+ getTime: () => number;
12
+ getDuration: () => number;
13
+ isPlaying: () => boolean;
14
+ }
15
+
16
+ export type RuntimePlaybackAdapter = PlaybackAdapter & {
17
+ renderSeek?: (time: number) => void;
18
+ };
19
+
20
+ export interface StaticSeekPlaybackClock {
21
+ now: () => number;
22
+ requestAnimationFrame: (callback: FrameRequestCallback) => number;
23
+ cancelAnimationFrame: (handle: number) => void;
24
+ }
25
+
26
+ export interface TimelineLike {
27
+ play: () => void;
28
+ pause: () => void;
29
+ seek: (time: number) => void;
30
+ time: () => number;
31
+ duration: () => number;
32
+ isActive: () => boolean;
33
+ }
34
+
35
+ export interface ClipManifestClip {
36
+ id: string | null;
37
+ label: string;
38
+ start: number;
39
+ duration: number;
40
+ track: number;
41
+ kind: "video" | "audio" | "image" | "element" | "composition";
42
+ tagName: string | null;
43
+ compositionId: string | null;
44
+ parentCompositionId: string | null;
45
+ compositionSrc: string | null;
46
+ assetUrl: string | null;
47
+ }
48
+
49
+ export interface ClipManifest {
50
+ clips: ClipManifestClip[];
51
+ scenes: Array<{ id: string; label: string; start: number; duration: number }>;
52
+ durationInFrames: number;
53
+ }
54
+
55
+ export type IframeWindow = Window & {
56
+ __player?: RuntimePlaybackAdapter;
57
+ __timeline?: TimelineLike;
58
+ __timelines?: Record<string, TimelineLike>;
59
+ __clipManifest?: ClipManifest;
60
+ };