@hyperframes/studio 0.6.0 → 0.6.2
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/hyperframes-player-CzwFysqv.js +418 -0
- package/dist/assets/index-hYc4aP7M.js +117 -0
- package/dist/index.html +1 -1
- package/package.json +4 -4
- package/src/App.tsx +2 -13
- package/src/captions/components/CaptionOverlay.tsx +13 -246
- package/src/captions/components/CaptionOverlayUtils.ts +221 -0
- package/src/components/StudioPreviewArea.tsx +6 -2
- package/src/components/editor/DomEditOverlay.tsx +88 -1007
- package/src/components/editor/EaseCurveEditor.tsx +221 -0
- package/src/components/editor/FileTree.tsx +13 -621
- package/src/components/editor/FileTreeIcons.tsx +128 -0
- package/src/components/editor/FileTreeNodes.tsx +496 -0
- package/src/components/editor/MotionPanel.tsx +16 -390
- package/src/components/editor/MotionPanelFields.tsx +185 -0
- package/src/components/editor/domEditOverlayGeometry.ts +211 -0
- package/src/components/editor/domEditOverlayGestures.ts +138 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
- package/src/components/editor/domEditing.ts +44 -1150
- package/src/components/editor/domEditingAgentPrompt.ts +97 -0
- package/src/components/editor/domEditingDom.ts +266 -0
- package/src/components/editor/domEditingElement.ts +329 -0
- package/src/components/editor/domEditingLayers.ts +460 -0
- package/src/components/editor/domEditingTypes.ts +125 -0
- package/src/components/editor/manualEdits.ts +84 -1081
- package/src/components/editor/manualEditsDom.ts +436 -0
- package/src/components/editor/manualEditsParsing.ts +280 -0
- package/src/components/editor/manualEditsSnapshot.ts +333 -0
- package/src/components/editor/manualEditsTypes.ts +141 -0
- package/src/components/editor/studioMotion.ts +47 -434
- package/src/components/editor/studioMotionOps.ts +299 -0
- package/src/components/editor/studioMotionTypes.ts +168 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
- package/src/components/editor/useDomEditOverlayRects.ts +207 -0
- package/src/components/nle/NLELayout.tsx +60 -144
- package/src/components/nle/useCompositionStack.ts +126 -0
- package/src/hooks/useToast.ts +20 -0
- package/src/player/components/Timeline.tsx +189 -1418
- package/src/player/components/TimelineCanvas.tsx +434 -0
- package/src/player/components/TimelineEmptyState.tsx +102 -0
- package/src/player/components/TimelineRuler.tsx +90 -0
- package/src/player/components/timelineIcons.tsx +49 -0
- package/src/player/components/timelineLayout.ts +215 -0
- package/src/player/components/timelineUtils.ts +211 -0
- package/src/player/components/useTimelineClipDrag.ts +388 -0
- package/src/player/components/useTimelinePlayhead.ts +200 -0
- package/src/player/components/useTimelineRangeSelection.ts +135 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
- package/src/player/hooks/useTimelinePlayer.ts +69 -1372
- package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
- package/src/player/lib/playbackAdapter.ts +145 -0
- package/src/player/lib/playbackShortcuts.ts +68 -0
- package/src/player/lib/playbackTypes.ts +60 -0
- package/src/player/lib/timelineDOM.ts +373 -0
- package/src/player/lib/timelineElementHelpers.ts +303 -0
- package/src/player/lib/timelineIframeHelpers.ts +269 -0
- package/dist/assets/hyperframes-player-DOFETgjy.js +0 -418
- 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
|
+
};
|