@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
|
@@ -1,897 +1,47 @@
|
|
|
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 {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
compositionSrc: string | null;
|
|
46
|
-
assetUrl: string | null;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
interface ClipManifest {
|
|
50
|
-
clips: ClipManifestClip[];
|
|
51
|
-
scenes: Array<{ id: string; label: string; start: number; duration: number }>;
|
|
52
|
-
durationInFrames: number;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
type IframeWindow = Window & {
|
|
56
|
-
__player?: RuntimePlaybackAdapter;
|
|
57
|
-
__timeline?: TimelineLike;
|
|
58
|
-
__timelines?: Record<string, TimelineLike>;
|
|
59
|
-
__clipManifest?: ClipManifest;
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
function isFinitePositive(value: number): boolean {
|
|
63
|
-
return Number.isFinite(value) && value > 0;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function clampTime(time: number, duration: number): number {
|
|
67
|
-
const safeDuration = Math.max(0, Number.isFinite(duration) ? duration : 0);
|
|
68
|
-
const safeTime = Math.max(0, Number.isFinite(time) ? time : 0);
|
|
69
|
-
return safeDuration > 0 ? Math.min(safeTime, safeDuration) : safeTime;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function readDurationAttribute(el: Element | null | undefined): number {
|
|
73
|
-
if (!el) return 0;
|
|
74
|
-
const duration =
|
|
75
|
-
Number.parseFloat(el.getAttribute("data-duration") ?? "") ||
|
|
76
|
-
Number.parseFloat(el.getAttribute("data-hf-authored-duration") ?? "");
|
|
77
|
-
return isFinitePositive(duration) ? duration : 0;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export function readTimelineDurationFromDocument(doc: Document | null | undefined): number {
|
|
81
|
-
if (!doc) return 0;
|
|
82
|
-
const rootDuration = readDurationAttribute(doc.querySelector("[data-composition-id]"));
|
|
83
|
-
if (rootDuration > 0) return rootDuration;
|
|
84
|
-
|
|
85
|
-
let maxEnd = 0;
|
|
86
|
-
for (const node of Array.from(doc.querySelectorAll("[data-start]"))) {
|
|
87
|
-
const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
|
|
88
|
-
const duration = readDurationAttribute(node);
|
|
89
|
-
if (!Number.isFinite(start) || start < 0 || duration <= 0) continue;
|
|
90
|
-
maxEnd = Math.max(maxEnd, start + duration);
|
|
91
|
-
}
|
|
92
|
-
return maxEnd;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function getAdapterDuration(adapter: PlaybackAdapter | null | undefined): number {
|
|
96
|
-
if (!adapter) return 0;
|
|
97
|
-
try {
|
|
98
|
-
const duration = Number(adapter.getDuration());
|
|
99
|
-
return isFinitePositive(duration) ? duration : 0;
|
|
100
|
-
} catch {
|
|
101
|
-
return 0;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function getDefaultStaticSeekPlaybackClock(win: Window): StaticSeekPlaybackClock {
|
|
106
|
-
return {
|
|
107
|
-
now: () => win.performance.now(),
|
|
108
|
-
requestAnimationFrame: (callback) => win.requestAnimationFrame(callback),
|
|
109
|
-
cancelAnimationFrame: (handle) => win.cancelAnimationFrame(handle),
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function createStaticSeekPlaybackAdapter(
|
|
114
|
-
player: Pick<RuntimePlaybackAdapter, "getTime"> &
|
|
115
|
-
Partial<Pick<RuntimePlaybackAdapter, "renderSeek" | "seek">>,
|
|
116
|
-
duration: number,
|
|
117
|
-
clock: StaticSeekPlaybackClock,
|
|
118
|
-
getPlaybackRate: () => number = () => 1,
|
|
119
|
-
): PlaybackAdapter {
|
|
120
|
-
const safeDuration = Math.max(0, Number.isFinite(duration) ? duration : 0);
|
|
121
|
-
let currentTime = clampTime(Number(player.getTime?.() ?? 0), safeDuration);
|
|
122
|
-
let playing = false;
|
|
123
|
-
let rafId = 0;
|
|
124
|
-
let playStartTime = currentTime;
|
|
125
|
-
let playStartNow = clock.now();
|
|
126
|
-
|
|
127
|
-
const renderSeek = (time: number) => {
|
|
128
|
-
currentTime = clampTime(time, safeDuration);
|
|
129
|
-
if (typeof player.renderSeek === "function") {
|
|
130
|
-
player.renderSeek(currentTime);
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
player.seek?.(currentTime);
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
const stopTicker = () => {
|
|
137
|
-
if (rafId) {
|
|
138
|
-
clock.cancelAnimationFrame(rafId);
|
|
139
|
-
rafId = 0;
|
|
140
|
-
}
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
const tick: FrameRequestCallback = (now) => {
|
|
144
|
-
if (!playing) return;
|
|
145
|
-
const playbackRate = Math.max(0.1, Number(getPlaybackRate()) || 1);
|
|
146
|
-
const elapsed = ((now - playStartNow) / 1000) * playbackRate;
|
|
147
|
-
renderSeek(playStartTime + elapsed);
|
|
148
|
-
if (currentTime >= safeDuration) {
|
|
149
|
-
playing = false;
|
|
150
|
-
rafId = 0;
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
rafId = clock.requestAnimationFrame(tick);
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
return {
|
|
157
|
-
play: () => {
|
|
158
|
-
if (playing || safeDuration <= 0) return;
|
|
159
|
-
if (currentTime >= safeDuration) renderSeek(0);
|
|
160
|
-
playing = true;
|
|
161
|
-
playStartTime = currentTime;
|
|
162
|
-
playStartNow = clock.now();
|
|
163
|
-
stopTicker();
|
|
164
|
-
rafId = clock.requestAnimationFrame(tick);
|
|
165
|
-
},
|
|
166
|
-
pause: () => {
|
|
167
|
-
playing = false;
|
|
168
|
-
stopTicker();
|
|
169
|
-
},
|
|
170
|
-
seek: (time) => {
|
|
171
|
-
renderSeek(time);
|
|
172
|
-
if (playing) {
|
|
173
|
-
playStartTime = currentTime;
|
|
174
|
-
playStartNow = clock.now();
|
|
175
|
-
}
|
|
176
|
-
},
|
|
177
|
-
getTime: () => currentTime,
|
|
178
|
-
getDuration: () => safeDuration,
|
|
179
|
-
isPlaying: () => playing,
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
|
|
184
|
-
return {
|
|
185
|
-
play: () => tl.play(),
|
|
186
|
-
pause: () => tl.pause(),
|
|
187
|
-
seek: (t) => {
|
|
188
|
-
tl.pause();
|
|
189
|
-
tl.seek(t);
|
|
190
|
-
},
|
|
191
|
-
getTime: () => tl.time(),
|
|
192
|
-
getDuration: () => tl.duration(),
|
|
193
|
-
isPlaying: () => tl.isActive(),
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageElement | null {
|
|
198
|
-
const win = el.ownerDocument.defaultView ?? window;
|
|
199
|
-
const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
|
|
200
|
-
const ImageElementCtor = win.HTMLImageElement ?? globalThis.HTMLImageElement;
|
|
201
|
-
if (el instanceof MediaElementCtor || el instanceof ImageElementCtor) return el;
|
|
202
|
-
const candidate = el.querySelector("video, audio, img");
|
|
203
|
-
return candidate instanceof MediaElementCtor || candidate instanceof ImageElementCtor
|
|
204
|
-
? candidate
|
|
205
|
-
: null;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void {
|
|
209
|
-
const mediaStartAttr = el.getAttribute("data-playback-start")
|
|
210
|
-
? "playback-start"
|
|
211
|
-
: el.getAttribute("data-media-start")
|
|
212
|
-
? "media-start"
|
|
213
|
-
: undefined;
|
|
214
|
-
const mediaStartValue =
|
|
215
|
-
el.getAttribute("data-playback-start") ?? el.getAttribute("data-media-start");
|
|
216
|
-
if (mediaStartValue != null) {
|
|
217
|
-
const playbackStart = parseFloat(mediaStartValue);
|
|
218
|
-
if (Number.isFinite(playbackStart)) entry.playbackStart = playbackStart;
|
|
219
|
-
}
|
|
220
|
-
if (mediaStartAttr) entry.playbackStartAttr = mediaStartAttr;
|
|
221
|
-
|
|
222
|
-
const mediaEl = resolveMediaElement(el);
|
|
223
|
-
if (!mediaEl) return;
|
|
224
|
-
|
|
225
|
-
entry.tag = mediaEl.tagName.toLowerCase();
|
|
226
|
-
const src = mediaEl.getAttribute("src");
|
|
227
|
-
if (src) entry.src = src;
|
|
228
|
-
|
|
229
|
-
const win = mediaEl.ownerDocument.defaultView ?? window;
|
|
230
|
-
const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
|
|
231
|
-
if (typeof MediaElementCtor === "undefined" || !(mediaEl instanceof MediaElementCtor)) return;
|
|
232
|
-
|
|
233
|
-
const sourceDurationAttr =
|
|
234
|
-
el.getAttribute("data-source-duration") ?? mediaEl.getAttribute("data-source-duration");
|
|
235
|
-
const sourceDuration = sourceDurationAttr ? parseFloat(sourceDurationAttr) : mediaEl.duration;
|
|
236
|
-
if (Number.isFinite(sourceDuration) && sourceDuration > 0) {
|
|
237
|
-
entry.sourceDuration = sourceDuration;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const playbackRate = mediaEl.defaultPlaybackRate;
|
|
241
|
-
if (Number.isFinite(playbackRate) && playbackRate > 0) {
|
|
242
|
-
entry.playbackRate = playbackRate;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const SHUTTLE_SPEEDS = [1, 2, 4] as const;
|
|
247
|
-
const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]);
|
|
248
|
-
const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
|
|
249
|
-
"input",
|
|
250
|
-
"textarea",
|
|
251
|
-
"select",
|
|
252
|
-
"button",
|
|
253
|
-
"a[href]",
|
|
254
|
-
"[contenteditable='true']",
|
|
255
|
-
"[role='button']",
|
|
256
|
-
"[role='checkbox']",
|
|
257
|
-
"[role='combobox']",
|
|
258
|
-
"[role='menuitem']",
|
|
259
|
-
"[role='radio']",
|
|
260
|
-
"[role='slider']",
|
|
261
|
-
"[role='spinbutton']",
|
|
262
|
-
"[role='switch']",
|
|
263
|
-
"[role='textbox']",
|
|
264
|
-
].join(",");
|
|
265
|
-
|
|
266
|
-
export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null): boolean {
|
|
267
|
-
if (!target || typeof target !== "object") return false;
|
|
268
|
-
const candidate = target as { closest?: unknown };
|
|
269
|
-
if (typeof candidate.closest !== "function") return false;
|
|
270
|
-
return (
|
|
271
|
-
(candidate.closest as (selector: string) => Element | null).call(
|
|
272
|
-
target,
|
|
273
|
-
PLAYBACK_SHORTCUT_IGNORED_SELECTOR,
|
|
274
|
-
) !== null
|
|
275
|
-
);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
interface PlaybackShortcutCaptionState {
|
|
279
|
-
isCaptionEditMode: boolean;
|
|
280
|
-
selectedCaptionSegmentCount: number;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
type PlaybackShortcutEvent = Pick<
|
|
284
|
-
KeyboardEvent,
|
|
285
|
-
"altKey" | "ctrlKey" | "metaKey" | "code" | "target"
|
|
286
|
-
>;
|
|
287
|
-
|
|
288
|
-
export function shouldIgnorePlaybackShortcutEvent(
|
|
289
|
-
event: PlaybackShortcutEvent,
|
|
290
|
-
captionState: PlaybackShortcutCaptionState = {
|
|
291
|
-
isCaptionEditMode: false,
|
|
292
|
-
selectedCaptionSegmentCount: 0,
|
|
293
|
-
},
|
|
294
|
-
): boolean {
|
|
295
|
-
if (event.metaKey || event.ctrlKey || event.altKey) return true;
|
|
296
|
-
if (shouldIgnorePlaybackShortcutTarget(event.target)) return true;
|
|
297
|
-
return (
|
|
298
|
-
PLAYBACK_FRAME_STEP_CODES.has(event.code) &&
|
|
299
|
-
captionState.isCaptionEditMode &&
|
|
300
|
-
captionState.selectedCaptionSegmentCount > 0
|
|
301
|
-
);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
function getTimelineElementDisplayLabel(input: {
|
|
305
|
-
id?: string | null;
|
|
306
|
-
label?: string | null;
|
|
307
|
-
tag?: string | null;
|
|
308
|
-
}): string {
|
|
309
|
-
const label = input.label?.trim();
|
|
310
|
-
if (label) return label;
|
|
311
|
-
const id = input.id?.trim();
|
|
312
|
-
if (id) return id;
|
|
313
|
-
const tag = input.tag?.trim().toLowerCase();
|
|
314
|
-
return tag ? `${tag} clip` : "Timeline clip";
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
const IMPLICIT_TIMELINE_LAYER_SKIP_TAGS = new Set([
|
|
318
|
-
"base",
|
|
319
|
-
"link",
|
|
320
|
-
"meta",
|
|
321
|
-
"noscript",
|
|
322
|
-
"script",
|
|
323
|
-
"style",
|
|
324
|
-
"template",
|
|
325
|
-
]);
|
|
326
|
-
|
|
327
|
-
function humanizeTimelineIdentifier(value: string): string {
|
|
328
|
-
return value
|
|
329
|
-
.trim()
|
|
330
|
-
.replace(/[_-]+/g, " ")
|
|
331
|
-
.replace(/\s+/g, " ")
|
|
332
|
-
.replace(/\b\w/g, (match) => match.toUpperCase());
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function getImplicitTimelineLayerLabel(el: HTMLElement): string {
|
|
336
|
-
const explicitLabel =
|
|
337
|
-
el.getAttribute("data-timeline-label") ??
|
|
338
|
-
el.getAttribute("data-label") ??
|
|
339
|
-
el.getAttribute("aria-label");
|
|
340
|
-
if (explicitLabel?.trim()) return explicitLabel.trim();
|
|
341
|
-
if (el.id.trim()) return humanizeTimelineIdentifier(el.id);
|
|
342
|
-
const classes = el.className.split(/\s+/).filter(Boolean);
|
|
343
|
-
const className = classes.find((value) => value !== "clip") ?? classes[0];
|
|
344
|
-
if (className) return humanizeTimelineIdentifier(className);
|
|
345
|
-
return getTimelineElementDisplayLabel({ tag: el.tagName });
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function isImplicitTimelineLayerCandidate(root: Element, el: Element): el is HTMLElement {
|
|
349
|
-
if (!isHtmlElement(el)) return false;
|
|
350
|
-
if (el.parentElement !== root) return false;
|
|
351
|
-
const tagName = el.tagName.toLowerCase();
|
|
352
|
-
if (IMPLICIT_TIMELINE_LAYER_SKIP_TAGS.has(tagName)) return false;
|
|
353
|
-
if (el.hasAttribute("data-start") || el.hasAttribute("data-track-index")) return false;
|
|
354
|
-
return Boolean(getTimelineElementSelector(el));
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
export function createImplicitTimelineLayersFromDOM(
|
|
358
|
-
doc: Document,
|
|
359
|
-
rootDuration: number,
|
|
360
|
-
existingElements: readonly TimelineElement[] = [],
|
|
361
|
-
): TimelineElement[] {
|
|
362
|
-
if (!Number.isFinite(rootDuration) || rootDuration <= 0) return [];
|
|
363
|
-
const rootComp = doc.querySelector("[data-composition-id]");
|
|
364
|
-
if (!rootComp) return [];
|
|
365
|
-
|
|
366
|
-
const existingKeys = new Set(existingElements.map(getTimelineElementIdentity));
|
|
367
|
-
const maxTrack = existingElements.reduce(
|
|
368
|
-
(max, element) => Math.max(max, Number.isFinite(element.track) ? element.track : 0),
|
|
369
|
-
-1,
|
|
370
|
-
);
|
|
371
|
-
const layers: TimelineElement[] = [];
|
|
372
|
-
|
|
373
|
-
for (const child of Array.from(rootComp.children)) {
|
|
374
|
-
if (!isImplicitTimelineLayerCandidate(rootComp, child)) continue;
|
|
375
|
-
|
|
376
|
-
const selector = getTimelineElementSelector(child);
|
|
377
|
-
if (!selector) continue;
|
|
378
|
-
const selectorIndex = getTimelineElementSelectorIndex(doc, child, selector);
|
|
379
|
-
const sourceFile = getTimelineElementSourceFile(child);
|
|
380
|
-
const label = getImplicitTimelineLayerLabel(child);
|
|
381
|
-
const identity = buildTimelineElementIdentity({
|
|
382
|
-
preferredId: child.id || null,
|
|
383
|
-
label,
|
|
384
|
-
fallbackIndex: existingElements.length + layers.length,
|
|
385
|
-
domId: child.id || undefined,
|
|
386
|
-
selector,
|
|
387
|
-
selectorIndex,
|
|
388
|
-
sourceFile,
|
|
389
|
-
});
|
|
390
|
-
if (existingKeys.has(identity.key) || existingKeys.has(identity.id)) continue;
|
|
391
|
-
|
|
392
|
-
layers.push({
|
|
393
|
-
domId: child.id || undefined,
|
|
394
|
-
duration: rootDuration,
|
|
395
|
-
id: identity.id,
|
|
396
|
-
key: identity.key,
|
|
397
|
-
label,
|
|
398
|
-
selector,
|
|
399
|
-
selectorIndex,
|
|
400
|
-
sourceFile,
|
|
401
|
-
start: 0,
|
|
402
|
-
tag: child.tagName.toLowerCase(),
|
|
403
|
-
timingSource: "implicit",
|
|
404
|
-
track: maxTrack + 1 + layers.length,
|
|
405
|
-
});
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
return layers;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
/**
|
|
412
|
-
* Parse [data-start] elements from a Document into TimelineElement[].
|
|
413
|
-
* Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
|
|
414
|
-
*/
|
|
415
|
-
export function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElement[] {
|
|
416
|
-
const rootComp = doc.querySelector("[data-composition-id]");
|
|
417
|
-
const nodes = doc.querySelectorAll("[data-start]");
|
|
418
|
-
const els: TimelineElement[] = [];
|
|
419
|
-
let trackCounter = 0;
|
|
420
|
-
|
|
421
|
-
nodes.forEach((node) => {
|
|
422
|
-
if (node === rootComp) return;
|
|
423
|
-
const el = node as HTMLElement;
|
|
424
|
-
const startStr = el.getAttribute("data-start");
|
|
425
|
-
if (startStr == null) return;
|
|
426
|
-
const start = parseFloat(startStr);
|
|
427
|
-
if (isNaN(start)) return;
|
|
428
|
-
if (Number.isFinite(rootDuration) && rootDuration > 0 && start >= rootDuration) return;
|
|
429
|
-
|
|
430
|
-
const tagLower = el.tagName.toLowerCase();
|
|
431
|
-
let dur = 0;
|
|
432
|
-
const durStr = el.getAttribute("data-duration");
|
|
433
|
-
if (durStr != null) dur = parseFloat(durStr);
|
|
434
|
-
if (isNaN(dur) || dur <= 0) dur = Math.max(0, rootDuration - start);
|
|
435
|
-
if (Number.isFinite(rootDuration) && rootDuration > 0) {
|
|
436
|
-
dur = Math.min(dur, Math.max(0, rootDuration - start));
|
|
437
|
-
}
|
|
438
|
-
if (!Number.isFinite(dur) || dur <= 0) return;
|
|
439
|
-
|
|
440
|
-
const trackStr = el.getAttribute("data-track-index");
|
|
441
|
-
const track = trackStr != null ? parseInt(trackStr, 10) : trackCounter++;
|
|
442
|
-
const compId = el.getAttribute("data-composition-id");
|
|
443
|
-
const selector = getTimelineElementSelector(el);
|
|
444
|
-
const sourceFile = getTimelineElementSourceFile(el);
|
|
445
|
-
const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
|
|
446
|
-
const label = getTimelineElementDisplayLabel({
|
|
447
|
-
id: el.id || compId || null,
|
|
448
|
-
label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
|
|
449
|
-
tag: tagLower,
|
|
450
|
-
});
|
|
451
|
-
const identity = buildTimelineElementIdentity({
|
|
452
|
-
preferredId: el.id || compId || null,
|
|
453
|
-
label,
|
|
454
|
-
fallbackIndex: els.length,
|
|
455
|
-
domId: el.id || undefined,
|
|
456
|
-
selector,
|
|
457
|
-
selectorIndex,
|
|
458
|
-
sourceFile,
|
|
459
|
-
});
|
|
460
|
-
const entry: TimelineElement = {
|
|
461
|
-
id: identity.id,
|
|
462
|
-
label,
|
|
463
|
-
key: identity.key,
|
|
464
|
-
tag: tagLower,
|
|
465
|
-
start,
|
|
466
|
-
duration: dur,
|
|
467
|
-
track: isNaN(track) ? 0 : track,
|
|
468
|
-
domId: el.id || undefined,
|
|
469
|
-
selector,
|
|
470
|
-
selectorIndex,
|
|
471
|
-
sourceFile,
|
|
472
|
-
timingSource: "authored",
|
|
473
|
-
};
|
|
474
|
-
|
|
475
|
-
const mediaEl = resolveMediaElement(el);
|
|
476
|
-
if (mediaEl) {
|
|
477
|
-
if (mediaEl.tagName === "IMG") {
|
|
478
|
-
entry.tag = "img";
|
|
479
|
-
}
|
|
480
|
-
const src = mediaEl.getAttribute("src");
|
|
481
|
-
if (src) entry.src = src;
|
|
482
|
-
const vol = el.getAttribute("data-volume") ?? mediaEl.getAttribute("data-volume");
|
|
483
|
-
if (vol) entry.volume = parseFloat(vol);
|
|
484
|
-
applyMediaMetadataFromElement(entry, el);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// Sub-compositions
|
|
488
|
-
const compSrc =
|
|
489
|
-
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
|
|
490
|
-
if (compSrc) {
|
|
491
|
-
entry.compositionSrc = compSrc;
|
|
492
|
-
} else if (compId && compId !== rootComp?.getAttribute("data-composition-id")) {
|
|
493
|
-
// Inline composition — expose inner video for thumbnails
|
|
494
|
-
const innerVideo = el.querySelector("video[src]");
|
|
495
|
-
if (innerVideo) {
|
|
496
|
-
entry.src = innerVideo.getAttribute("src") || undefined;
|
|
497
|
-
entry.tag = "video";
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
els.push(entry);
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
return [...els, ...createImplicitTimelineLayersFromDOM(doc, rootDuration, els)];
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
function isHtmlElement(el: Element): el is HTMLElement {
|
|
508
|
-
const HtmlElementCtor = el.ownerDocument.defaultView?.HTMLElement ?? globalThis.HTMLElement;
|
|
509
|
-
return typeof HtmlElementCtor !== "undefined" && el instanceof HtmlElementCtor;
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
export function getTimelineElementSelector(el: Element): string | undefined {
|
|
513
|
-
if (isHtmlElement(el) && el.id) return `#${el.id}`;
|
|
514
|
-
const compId = el.getAttribute("data-composition-id");
|
|
515
|
-
if (compId) return `[data-composition-id="${compId}"]`;
|
|
516
|
-
if (isHtmlElement(el)) {
|
|
517
|
-
const classes = el.className.split(/\s+/).filter(Boolean);
|
|
518
|
-
const firstClass = classes.find((className) => className !== "clip") ?? classes[0];
|
|
519
|
-
if (firstClass) return `.${firstClass}`;
|
|
520
|
-
}
|
|
521
|
-
return undefined;
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
function getTimelineElementSourceFile(el: Element): string | undefined {
|
|
525
|
-
const ownerRoot = el.parentElement?.closest("[data-composition-id]");
|
|
526
|
-
return (
|
|
527
|
-
ownerRoot?.getAttribute("data-composition-file") ??
|
|
528
|
-
ownerRoot?.getAttribute("data-composition-src") ??
|
|
529
|
-
undefined
|
|
530
|
-
);
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
function getTimelineElementSelectorIndex(
|
|
534
|
-
doc: Document,
|
|
535
|
-
el: Element,
|
|
536
|
-
selector: string | undefined,
|
|
537
|
-
): number | undefined {
|
|
538
|
-
if (!selector || selector.startsWith("#") || selector.startsWith("[data-composition-id=")) {
|
|
539
|
-
return undefined;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
try {
|
|
543
|
-
const matches = Array.from(doc.querySelectorAll(selector));
|
|
544
|
-
const matchIndex = matches.indexOf(el);
|
|
545
|
-
return matchIndex >= 0 ? matchIndex : undefined;
|
|
546
|
-
} catch {
|
|
547
|
-
return undefined;
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
function buildTimelineElementKey(params: {
|
|
552
|
-
id: string;
|
|
553
|
-
fallbackIndex: number;
|
|
554
|
-
domId?: string;
|
|
555
|
-
selector?: string;
|
|
556
|
-
selectorIndex?: number;
|
|
557
|
-
sourceFile?: string;
|
|
558
|
-
}): string {
|
|
559
|
-
const scope = params.sourceFile ?? "index.html";
|
|
560
|
-
if (params.domId) return `${scope}#${params.domId}`;
|
|
561
|
-
if (params.selector) return `${scope}:${params.selector}:${params.selectorIndex ?? 0}`;
|
|
562
|
-
return `${scope}:${params.id}:${params.fallbackIndex}`;
|
|
563
|
-
}
|
|
564
|
-
function buildTimelineElementIdentity(params: {
|
|
565
|
-
preferredId?: string | null;
|
|
566
|
-
label: string;
|
|
567
|
-
fallbackIndex: number;
|
|
568
|
-
domId?: string;
|
|
569
|
-
selector?: string;
|
|
570
|
-
selectorIndex?: number;
|
|
571
|
-
sourceFile?: string;
|
|
572
|
-
}): { id: string; key: string } {
|
|
573
|
-
const id =
|
|
574
|
-
params.preferredId?.trim() ||
|
|
575
|
-
buildTimelineElementKey({
|
|
576
|
-
id: params.label,
|
|
577
|
-
fallbackIndex: params.fallbackIndex,
|
|
578
|
-
domId: params.domId,
|
|
579
|
-
selector: params.selector,
|
|
580
|
-
selectorIndex: params.selectorIndex,
|
|
581
|
-
sourceFile: params.sourceFile,
|
|
582
|
-
});
|
|
583
|
-
const key = buildTimelineElementKey({
|
|
584
|
-
id,
|
|
585
|
-
fallbackIndex: params.fallbackIndex,
|
|
586
|
-
domId: params.domId,
|
|
587
|
-
selector: params.selector,
|
|
588
|
-
selectorIndex: params.selectorIndex,
|
|
589
|
-
sourceFile: params.sourceFile,
|
|
590
|
-
});
|
|
591
|
-
return { id, key };
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
function getTimelineElementIdentity(element: TimelineElement): string {
|
|
595
|
-
return element.key ?? element.id;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
function getTimelineDomNodes(doc: Document): Element[] {
|
|
599
|
-
const rootComp = doc.querySelector("[data-composition-id]");
|
|
600
|
-
return Array.from(doc.querySelectorAll("[data-start]")).filter((node) => node !== rootComp);
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
function numbersNearlyEqual(a: number, b: number): boolean {
|
|
604
|
-
return Math.abs(a - b) < 0.001;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean {
|
|
608
|
-
const tagName = clip.tagName?.toLowerCase();
|
|
609
|
-
if (tagName && node.tagName.toLowerCase() !== tagName) return false;
|
|
610
|
-
|
|
611
|
-
const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
|
|
612
|
-
if (Number.isFinite(start) && !numbersNearlyEqual(start, clip.start)) return false;
|
|
613
|
-
|
|
614
|
-
const duration = Number.parseFloat(node.getAttribute("data-duration") ?? "");
|
|
615
|
-
if (Number.isFinite(duration) && !numbersNearlyEqual(duration, clip.duration)) return false;
|
|
616
|
-
|
|
617
|
-
const track = Number.parseInt(node.getAttribute("data-track-index") ?? "", 10);
|
|
618
|
-
if (Number.isFinite(track) && track !== clip.track) return false;
|
|
619
|
-
|
|
620
|
-
return true;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
export function findTimelineDomNodeForClip(
|
|
624
|
-
doc: Document,
|
|
625
|
-
clip: ClipManifestClip,
|
|
626
|
-
fallbackIndex: number,
|
|
627
|
-
usedNodes = new Set<Element>(),
|
|
628
|
-
): Element | null {
|
|
629
|
-
const byIdentity = clip.id ? findTimelineDomNode(doc, clip.id) : null;
|
|
630
|
-
if (byIdentity && !usedNodes.has(byIdentity)) return byIdentity;
|
|
631
|
-
|
|
632
|
-
const candidates = getTimelineDomNodes(doc).filter((node) => !usedNodes.has(node));
|
|
633
|
-
const exact = candidates.find((node) => nodeMatchesManifestClip(node, clip));
|
|
634
|
-
if (exact) return exact;
|
|
635
|
-
|
|
636
|
-
return candidates[fallbackIndex] ?? null;
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
export function createTimelineElementFromManifestClip(params: {
|
|
640
|
-
clip: ClipManifestClip;
|
|
641
|
-
fallbackIndex: number;
|
|
642
|
-
doc?: Document | null;
|
|
643
|
-
hostEl?: Element | null;
|
|
644
|
-
}): TimelineElement {
|
|
645
|
-
const { clip, fallbackIndex, doc } = params;
|
|
646
|
-
let hostEl = params.hostEl ?? null;
|
|
647
|
-
const label = getTimelineElementDisplayLabel({
|
|
648
|
-
id: clip.id,
|
|
649
|
-
label: clip.label,
|
|
650
|
-
tag: clip.tagName || clip.kind,
|
|
651
|
-
});
|
|
652
|
-
|
|
653
|
-
let domId: string | undefined;
|
|
654
|
-
let selector: string | undefined;
|
|
655
|
-
let selectorIndex: number | undefined;
|
|
656
|
-
let sourceFile: string | undefined;
|
|
657
|
-
|
|
658
|
-
if (hostEl) {
|
|
659
|
-
domId = hostEl.id || undefined;
|
|
660
|
-
selector = getTimelineElementSelector(hostEl);
|
|
661
|
-
selectorIndex =
|
|
662
|
-
doc && selector ? getTimelineElementSelectorIndex(doc, hostEl, selector) : undefined;
|
|
663
|
-
sourceFile = getTimelineElementSourceFile(hostEl);
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
const identity = buildTimelineElementIdentity({
|
|
667
|
-
preferredId: clip.id,
|
|
668
|
-
label,
|
|
669
|
-
fallbackIndex,
|
|
670
|
-
domId,
|
|
671
|
-
selector,
|
|
672
|
-
selectorIndex,
|
|
673
|
-
sourceFile,
|
|
674
|
-
});
|
|
675
|
-
const entry: TimelineElement = {
|
|
676
|
-
id: identity.id,
|
|
677
|
-
label,
|
|
678
|
-
key: identity.key,
|
|
679
|
-
tag: clip.tagName || clip.kind,
|
|
680
|
-
start: clip.start,
|
|
681
|
-
duration: clip.duration,
|
|
682
|
-
track: clip.track,
|
|
683
|
-
domId,
|
|
684
|
-
selector,
|
|
685
|
-
selectorIndex,
|
|
686
|
-
sourceFile,
|
|
687
|
-
};
|
|
688
|
-
|
|
689
|
-
if (hostEl) {
|
|
690
|
-
applyMediaMetadataFromElement(entry, hostEl);
|
|
691
|
-
}
|
|
692
|
-
if (clip.assetUrl) entry.src = clip.assetUrl;
|
|
693
|
-
if (clip.kind === "composition" && clip.compositionId) {
|
|
694
|
-
let resolvedSrc = clip.compositionSrc;
|
|
695
|
-
if (!resolvedSrc) {
|
|
696
|
-
hostEl = doc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
|
|
697
|
-
resolvedSrc =
|
|
698
|
-
hostEl?.getAttribute("data-composition-src") ??
|
|
699
|
-
hostEl?.getAttribute("data-composition-file") ??
|
|
700
|
-
null;
|
|
701
|
-
}
|
|
702
|
-
if (resolvedSrc) {
|
|
703
|
-
entry.compositionSrc = resolvedSrc;
|
|
704
|
-
} else if (hostEl) {
|
|
705
|
-
const innerVideo = hostEl.querySelector("video[src]");
|
|
706
|
-
if (innerVideo) {
|
|
707
|
-
entry.src = innerVideo.getAttribute("src") || undefined;
|
|
708
|
-
entry.tag = "video";
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
if (hostEl) {
|
|
712
|
-
entry.domId = hostEl.id || undefined;
|
|
713
|
-
entry.selector = getTimelineElementSelector(hostEl);
|
|
714
|
-
entry.selectorIndex =
|
|
715
|
-
doc && entry.selector
|
|
716
|
-
? getTimelineElementSelectorIndex(doc, hostEl, entry.selector)
|
|
717
|
-
: undefined;
|
|
718
|
-
entry.sourceFile = getTimelineElementSourceFile(hostEl);
|
|
719
|
-
const nextIdentity = buildTimelineElementIdentity({
|
|
720
|
-
preferredId: clip.id,
|
|
721
|
-
label,
|
|
722
|
-
fallbackIndex,
|
|
723
|
-
domId: entry.domId,
|
|
724
|
-
selector: entry.selector,
|
|
725
|
-
selectorIndex: entry.selectorIndex,
|
|
726
|
-
sourceFile: entry.sourceFile,
|
|
727
|
-
});
|
|
728
|
-
entry.id = nextIdentity.id;
|
|
729
|
-
entry.key = nextIdentity.key;
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
return entry;
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
function findTimelineDomNode(doc: Document, id: string): Element | null {
|
|
737
|
-
return (
|
|
738
|
-
doc.getElementById(id) ??
|
|
739
|
-
doc.querySelector(`[data-composition-id="${id}"]`) ??
|
|
740
|
-
doc.querySelector(`.${id}`) ??
|
|
741
|
-
null
|
|
742
|
-
);
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
export function resolveStandaloneRootCompositionSrc(iframeSrc: string): string | undefined {
|
|
746
|
-
const compPathMatch = iframeSrc.match(/\/preview\/comp\/(.+?)(?:\?|$)/);
|
|
747
|
-
return compPathMatch ? decodeURIComponent(compPathMatch[1]) : undefined;
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
export function buildStandaloneRootTimelineElement(params: {
|
|
751
|
-
compositionId: string;
|
|
752
|
-
tagName: string;
|
|
753
|
-
rootDuration: number;
|
|
754
|
-
iframeSrc: string;
|
|
755
|
-
selector?: string;
|
|
756
|
-
selectorIndex?: number;
|
|
757
|
-
}): TimelineElement | null {
|
|
758
|
-
if (!Number.isFinite(params.rootDuration) || params.rootDuration <= 0) return null;
|
|
759
|
-
|
|
760
|
-
const compositionSrc = resolveStandaloneRootCompositionSrc(params.iframeSrc);
|
|
761
|
-
|
|
762
|
-
return {
|
|
763
|
-
id: params.compositionId,
|
|
764
|
-
label: getTimelineElementDisplayLabel({
|
|
765
|
-
id: params.compositionId,
|
|
766
|
-
tag: params.tagName,
|
|
767
|
-
}),
|
|
768
|
-
key: buildTimelineElementKey({
|
|
769
|
-
id: params.compositionId,
|
|
770
|
-
fallbackIndex: 0,
|
|
771
|
-
selector: params.selector,
|
|
772
|
-
selectorIndex: params.selectorIndex,
|
|
773
|
-
sourceFile: compositionSrc,
|
|
774
|
-
}),
|
|
775
|
-
tag: params.tagName.toLowerCase() || "div",
|
|
776
|
-
start: 0,
|
|
777
|
-
duration: params.rootDuration,
|
|
778
|
-
track: 0,
|
|
779
|
-
compositionSrc,
|
|
780
|
-
selector: params.selector,
|
|
781
|
-
selectorIndex: params.selectorIndex,
|
|
782
|
-
sourceFile: compositionSrc,
|
|
783
|
-
};
|
|
784
|
-
}
|
|
785
|
-
function normalizePreviewViewport(doc: Document, win: Window): void {
|
|
786
|
-
if (doc.documentElement) {
|
|
787
|
-
doc.documentElement.style.overflow = "hidden";
|
|
788
|
-
doc.documentElement.style.margin = "0";
|
|
789
|
-
}
|
|
790
|
-
if (doc.body) {
|
|
791
|
-
doc.body.style.overflow = "hidden";
|
|
792
|
-
doc.body.style.margin = "0";
|
|
793
|
-
}
|
|
794
|
-
win.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
function autoHealMissingCompositionIds(doc: Document): void {
|
|
798
|
-
const compositionIdRe = /data-composition-id=["']([^"']+)["']/gi;
|
|
799
|
-
const referencedIds = new Set<string>();
|
|
800
|
-
const scopedNodes = Array.from(doc.querySelectorAll("style, script"));
|
|
801
|
-
for (const node of scopedNodes) {
|
|
802
|
-
const text = node.textContent || "";
|
|
803
|
-
if (!text) continue;
|
|
804
|
-
let match: RegExpExecArray | null;
|
|
805
|
-
while ((match = compositionIdRe.exec(text)) !== null) {
|
|
806
|
-
const id = (match[1] || "").trim();
|
|
807
|
-
if (id) referencedIds.add(id);
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
if (referencedIds.size === 0) return;
|
|
812
|
-
|
|
813
|
-
const existingIds = new Set<string>();
|
|
814
|
-
const existingNodes = Array.from(doc.querySelectorAll<HTMLElement>("[data-composition-id]"));
|
|
815
|
-
for (const node of existingNodes) {
|
|
816
|
-
const id = node.getAttribute("data-composition-id");
|
|
817
|
-
if (id) existingIds.add(id);
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
for (const compId of referencedIds) {
|
|
821
|
-
if (compId === "root" || existingIds.has(compId)) continue;
|
|
822
|
-
const host =
|
|
823
|
-
doc.getElementById(`${compId}-layer`) ||
|
|
824
|
-
doc.getElementById(`${compId}-comp`) ||
|
|
825
|
-
doc.getElementById(compId);
|
|
826
|
-
if (!host) continue;
|
|
827
|
-
if (!host.getAttribute("data-composition-id")) {
|
|
828
|
-
host.setAttribute("data-composition-id", compId);
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
function unmutePreviewMedia(iframe: HTMLIFrameElement | null): void {
|
|
834
|
-
if (!iframe) return;
|
|
835
|
-
try {
|
|
836
|
-
iframe.contentWindow?.postMessage(
|
|
837
|
-
{ source: "hf-parent", type: "control", action: "set-muted", muted: false },
|
|
838
|
-
"*",
|
|
839
|
-
);
|
|
840
|
-
} catch (err) {
|
|
841
|
-
console.warn("[useTimelinePlayer] Failed to unmute preview media", err);
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
/**
|
|
846
|
-
* Resolve the underlying iframe from any host element. Supports:
|
|
847
|
-
* - Direct `<iframe>` element (most common — studio's own `Player.tsx`)
|
|
848
|
-
* - Custom elements (e.g. `<hyperframes-player>`) whose shadow DOM contains an iframe
|
|
849
|
-
* - Wrapper elements whose light DOM contains a descendant iframe
|
|
850
|
-
*
|
|
851
|
-
* Exported so web-component consumers can pre-resolve the iframe before
|
|
852
|
-
* assigning it to `iframeRef` returned by `useTimelinePlayer`. Returns `null`
|
|
853
|
-
* when the element has no associated iframe yet.
|
|
854
|
-
*
|
|
855
|
-
* @example
|
|
856
|
-
* ```tsx
|
|
857
|
-
* const { iframeRef } = useTimelinePlayer();
|
|
858
|
-
* const playerElRef = useRef<HyperframesPlayer>(null);
|
|
859
|
-
*
|
|
860
|
-
* useEffect(() => {
|
|
861
|
-
* iframeRef.current = resolveIframe(playerElRef.current);
|
|
862
|
-
* }, [iframeRef]);
|
|
863
|
-
* ```
|
|
864
|
-
*/
|
|
865
|
-
export function resolveIframe(el: Element | null): HTMLIFrameElement | null {
|
|
866
|
-
if (!el) return null;
|
|
867
|
-
if (el instanceof HTMLIFrameElement) return el;
|
|
868
|
-
return el.shadowRoot?.querySelector("iframe") ?? el.querySelector("iframe") ?? null;
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
export function mergeTimelineElementsPreservingDowngrades(
|
|
872
|
-
currentElements: TimelineElement[],
|
|
873
|
-
nextElements: TimelineElement[],
|
|
874
|
-
currentDuration: number,
|
|
875
|
-
nextDuration: number,
|
|
876
|
-
): TimelineElement[] {
|
|
877
|
-
const safeCurrentDuration = Number.isFinite(currentDuration) ? currentDuration : 0;
|
|
878
|
-
const safeNextDuration = Number.isFinite(nextDuration) ? nextDuration : 0;
|
|
879
|
-
|
|
880
|
-
if (
|
|
881
|
-
currentElements.length === 0 ||
|
|
882
|
-
nextElements.length >= currentElements.length ||
|
|
883
|
-
safeNextDuration > safeCurrentDuration
|
|
884
|
-
) {
|
|
885
|
-
return nextElements;
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
const nextIdentities = new Set(nextElements.map(getTimelineElementIdentity));
|
|
889
|
-
const preserved = currentElements.filter(
|
|
890
|
-
(element) => !nextIdentities.has(getTimelineElementIdentity(element)),
|
|
891
|
-
);
|
|
892
|
-
if (preserved.length === 0) return nextElements;
|
|
893
|
-
return [...nextElements, ...preserved];
|
|
894
|
-
}
|
|
4
|
+
import { usePlaybackKeyboard } from "./usePlaybackKeyboard";
|
|
5
|
+
import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
|
|
6
|
+
|
|
7
|
+
// Re-export public API consumed by tests and external modules.
|
|
8
|
+
// All of these were previously defined in this file; they now live in focused
|
|
9
|
+
// sub-modules but are re-exported here so existing import sites don't change.
|
|
10
|
+
export type { PlaybackAdapter, ClipManifestClip } from "../lib/playbackTypes";
|
|
11
|
+
export { createStaticSeekPlaybackAdapter } from "../lib/playbackAdapter";
|
|
12
|
+
export {
|
|
13
|
+
getTimelineElementSelector,
|
|
14
|
+
readTimelineDurationFromDocument,
|
|
15
|
+
parseTimelineFromDOM,
|
|
16
|
+
createTimelineElementFromManifestClip,
|
|
17
|
+
findTimelineDomNodeForClip,
|
|
18
|
+
buildStandaloneRootTimelineElement,
|
|
19
|
+
mergeTimelineElementsPreservingDowngrades,
|
|
20
|
+
resolveStandaloneRootCompositionSrc,
|
|
21
|
+
resolveIframe,
|
|
22
|
+
} from "../lib/timelineDOM";
|
|
23
|
+
export {
|
|
24
|
+
shouldIgnorePlaybackShortcutEvent,
|
|
25
|
+
shouldIgnorePlaybackShortcutTarget,
|
|
26
|
+
} from "../lib/playbackShortcuts";
|
|
27
|
+
|
|
28
|
+
import type { PlaybackAdapter, RuntimePlaybackAdapter, IframeWindow } from "../lib/playbackTypes";
|
|
29
|
+
import {
|
|
30
|
+
getAdapterDuration,
|
|
31
|
+
wrapTimeline,
|
|
32
|
+
createStaticSeekPlaybackAdapter,
|
|
33
|
+
getDefaultStaticSeekPlaybackClock,
|
|
34
|
+
} from "../lib/playbackAdapter";
|
|
35
|
+
import {
|
|
36
|
+
readTimelineDurationFromDocument,
|
|
37
|
+
mergeTimelineElementsPreservingDowngrades,
|
|
38
|
+
parseTimelineFromDOM,
|
|
39
|
+
} from "../lib/timelineDOM";
|
|
40
|
+
import { unmutePreviewMedia } from "../lib/timelineIframeHelpers";
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Hook
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
895
45
|
|
|
896
46
|
export function useTimelinePlayer() {
|
|
897
47
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
@@ -902,10 +52,7 @@ export function useTimelinePlayer() {
|
|
|
902
52
|
const reverseRafRef = useRef<number>(0);
|
|
903
53
|
const shuttleDirectionRef = useRef<"forward" | "backward" | null>(null);
|
|
904
54
|
const shuttleSpeedIndexRef = useRef(0);
|
|
905
|
-
const pressedCodesRef = useRef(new Set<string>());
|
|
906
55
|
const iframeShortcutCleanupRef = useRef<(() => void) | null>(null);
|
|
907
|
-
const playbackKeyDownRef = useRef<(e: KeyboardEvent) => void>(() => {});
|
|
908
|
-
const playbackKeyUpRef = useRef<(e: KeyboardEvent) => void>(() => {});
|
|
909
56
|
const lastTimelineMessageRef = useRef<number>(0);
|
|
910
57
|
const staticSeekAdapterRef = useRef<{
|
|
911
58
|
player: RuntimePlaybackAdapter;
|
|
@@ -1162,14 +309,6 @@ export function useTimelinePlayer() {
|
|
|
1162
309
|
stopRAFLoop();
|
|
1163
310
|
}, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]);
|
|
1164
311
|
|
|
1165
|
-
const togglePlay = useCallback(() => {
|
|
1166
|
-
if (usePlayerStore.getState().isPlaying) {
|
|
1167
|
-
pause();
|
|
1168
|
-
} else {
|
|
1169
|
-
play();
|
|
1170
|
-
}
|
|
1171
|
-
}, [play, pause]);
|
|
1172
|
-
|
|
1173
312
|
const seek = useCallback(
|
|
1174
313
|
(time: number) => {
|
|
1175
314
|
stopReverseLoop();
|
|
@@ -1181,7 +320,6 @@ export function useTimelinePlayer() {
|
|
|
1181
320
|
liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render
|
|
1182
321
|
setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
|
|
1183
322
|
stopRAFLoop();
|
|
1184
|
-
// Only update store if state actually changes (avoids unnecessary re-renders)
|
|
1185
323
|
if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
|
|
1186
324
|
shuttleDirectionRef.current = null;
|
|
1187
325
|
shuttleSpeedIndexRef.current = 0;
|
|
@@ -1189,463 +327,34 @@ export function useTimelinePlayer() {
|
|
|
1189
327
|
[getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
|
|
1190
328
|
);
|
|
1191
329
|
|
|
1192
|
-
const
|
|
1193
|
-
(
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
shuttleSpeedIndexRef.current = Math.min(
|
|
1205
|
-
shuttleSpeedIndexRef.current + 1,
|
|
1206
|
-
SHUTTLE_SPEEDS.length - 1,
|
|
1207
|
-
);
|
|
1208
|
-
} else {
|
|
1209
|
-
shuttleSpeedIndexRef.current = 0;
|
|
1210
|
-
}
|
|
1211
|
-
const speed = SHUTTLE_SPEEDS[shuttleSpeedIndexRef.current];
|
|
1212
|
-
usePlayerStore.getState().setPlaybackRate(speed);
|
|
1213
|
-
if (direction === "forward") {
|
|
1214
|
-
play();
|
|
1215
|
-
} else {
|
|
1216
|
-
playBackward(speed);
|
|
1217
|
-
}
|
|
1218
|
-
},
|
|
1219
|
-
[play, playBackward],
|
|
1220
|
-
);
|
|
1221
|
-
|
|
1222
|
-
const handlePlaybackKeyDown = useCallback(
|
|
1223
|
-
(e: KeyboardEvent) => {
|
|
1224
|
-
if (e.defaultPrevented) return;
|
|
1225
|
-
const captionState = useCaptionStore.getState();
|
|
1226
|
-
if (
|
|
1227
|
-
shouldIgnorePlaybackShortcutEvent(e, {
|
|
1228
|
-
isCaptionEditMode: captionState.isEditMode,
|
|
1229
|
-
selectedCaptionSegmentCount: captionState.selectedSegmentIds.size,
|
|
1230
|
-
})
|
|
1231
|
-
) {
|
|
1232
|
-
return;
|
|
1233
|
-
}
|
|
1234
|
-
pressedCodesRef.current.add(e.code);
|
|
1235
|
-
if (e.code === "Space") {
|
|
1236
|
-
e.preventDefault();
|
|
1237
|
-
togglePlay();
|
|
1238
|
-
return;
|
|
1239
|
-
}
|
|
1240
|
-
if (e.code === "ArrowLeft") {
|
|
1241
|
-
e.preventDefault();
|
|
1242
|
-
stepFrames(e.shiftKey ? -10 : -1);
|
|
1243
|
-
return;
|
|
1244
|
-
}
|
|
1245
|
-
if (e.code === "ArrowRight") {
|
|
1246
|
-
e.preventDefault();
|
|
1247
|
-
stepFrames(e.shiftKey ? 10 : 1);
|
|
1248
|
-
return;
|
|
1249
|
-
}
|
|
1250
|
-
if (e.repeat) return;
|
|
1251
|
-
if (e.code === "KeyK") {
|
|
1252
|
-
e.preventDefault();
|
|
1253
|
-
pause();
|
|
1254
|
-
return;
|
|
1255
|
-
}
|
|
1256
|
-
if (e.code === "KeyJ") {
|
|
1257
|
-
e.preventDefault();
|
|
1258
|
-
if (pressedCodesRef.current.has("KeyK")) {
|
|
1259
|
-
stepFrames(-1);
|
|
1260
|
-
return;
|
|
1261
|
-
}
|
|
1262
|
-
shuttle("backward");
|
|
1263
|
-
return;
|
|
1264
|
-
}
|
|
1265
|
-
if (e.code === "KeyL") {
|
|
1266
|
-
e.preventDefault();
|
|
1267
|
-
if (pressedCodesRef.current.has("KeyK")) {
|
|
1268
|
-
stepFrames(1);
|
|
1269
|
-
return;
|
|
1270
|
-
}
|
|
1271
|
-
shuttle("forward");
|
|
1272
|
-
}
|
|
1273
|
-
},
|
|
1274
|
-
[pause, shuttle, stepFrames, togglePlay],
|
|
1275
|
-
);
|
|
1276
|
-
|
|
1277
|
-
const handlePlaybackKeyUp = useCallback((e: KeyboardEvent) => {
|
|
1278
|
-
pressedCodesRef.current.delete(e.code);
|
|
1279
|
-
}, []);
|
|
1280
|
-
playbackKeyDownRef.current = handlePlaybackKeyDown;
|
|
1281
|
-
playbackKeyUpRef.current = handlePlaybackKeyUp;
|
|
1282
|
-
|
|
1283
|
-
const attachIframeShortcutListeners = useCallback(() => {
|
|
1284
|
-
iframeShortcutCleanupRef.current?.();
|
|
1285
|
-
iframeShortcutCleanupRef.current = null;
|
|
1286
|
-
|
|
1287
|
-
const iframeWin = iframeRef.current?.contentWindow;
|
|
1288
|
-
const iframeDoc = iframeRef.current?.contentDocument;
|
|
1289
|
-
if (!iframeWin && !iframeDoc) return;
|
|
1290
|
-
|
|
1291
|
-
const handleIframeKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
|
|
1292
|
-
const handleIframeKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
|
|
1293
|
-
iframeWin?.addEventListener("keydown", handleIframeKeyDown, true);
|
|
1294
|
-
iframeWin?.addEventListener("keyup", handleIframeKeyUp, true);
|
|
1295
|
-
iframeDoc?.addEventListener("keydown", handleIframeKeyDown, true);
|
|
1296
|
-
iframeDoc?.addEventListener("keyup", handleIframeKeyUp, true);
|
|
1297
|
-
iframeShortcutCleanupRef.current = () => {
|
|
1298
|
-
iframeWin?.removeEventListener("keydown", handleIframeKeyDown, true);
|
|
1299
|
-
iframeWin?.removeEventListener("keyup", handleIframeKeyUp, true);
|
|
1300
|
-
iframeDoc?.removeEventListener("keydown", handleIframeKeyDown, true);
|
|
1301
|
-
iframeDoc?.removeEventListener("keyup", handleIframeKeyUp, true);
|
|
1302
|
-
};
|
|
1303
|
-
}, []);
|
|
1304
|
-
|
|
1305
|
-
// Convert a runtime timeline message (from iframe postMessage) into TimelineElements
|
|
1306
|
-
const processTimelineMessage = useCallback(
|
|
1307
|
-
(data: {
|
|
1308
|
-
clips: ClipManifestClip[];
|
|
1309
|
-
durationInFrames: number;
|
|
1310
|
-
scenes?: Array<{ id: string; label: string; start: number; duration: number }>;
|
|
1311
|
-
}) => {
|
|
1312
|
-
if (!data.clips || data.clips.length === 0) {
|
|
1313
|
-
return;
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
// Show root-level clips: no parentCompositionId, OR parent is a "phantom wrapper"
|
|
1317
|
-
const clipCompositionIds = new Set(data.clips.map((c) => c.compositionId).filter(Boolean));
|
|
1318
|
-
const filtered = data.clips.filter(
|
|
1319
|
-
(clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId),
|
|
1320
|
-
);
|
|
1321
|
-
let iframeDoc: Document | null = null;
|
|
1322
|
-
try {
|
|
1323
|
-
iframeDoc = iframeRef.current?.contentDocument ?? null;
|
|
1324
|
-
} catch {
|
|
1325
|
-
iframeDoc = null;
|
|
1326
|
-
}
|
|
1327
|
-
const usedHostEls = new Set<Element>();
|
|
1328
|
-
const els: TimelineElement[] = filtered.map((clip, index) => {
|
|
1329
|
-
const hostEl = iframeDoc
|
|
1330
|
-
? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls)
|
|
1331
|
-
: null;
|
|
1332
|
-
if (hostEl) usedHostEls.add(hostEl);
|
|
1333
|
-
return createTimelineElementFromManifestClip({
|
|
1334
|
-
clip,
|
|
1335
|
-
fallbackIndex: index,
|
|
1336
|
-
doc: iframeDoc,
|
|
1337
|
-
hostEl,
|
|
1338
|
-
});
|
|
1339
|
-
});
|
|
1340
|
-
const rawDuration = data.durationInFrames / 30;
|
|
1341
|
-
// Clamp non-finite or absurdly large durations — the runtime can emit
|
|
1342
|
-
// Infinity when it detects a loop-inflated GSAP timeline without an
|
|
1343
|
-
// explicit data-duration on the root composition.
|
|
1344
|
-
const newDuration = Number.isFinite(rawDuration) && rawDuration < 7200 ? rawDuration : 0;
|
|
1345
|
-
const effectiveDuration = newDuration > 0 ? newDuration : usePlayerStore.getState().duration;
|
|
1346
|
-
const clampedEls =
|
|
1347
|
-
effectiveDuration > 0
|
|
1348
|
-
? els
|
|
1349
|
-
.filter((element) => element.start < effectiveDuration)
|
|
1350
|
-
.map((element) => ({
|
|
1351
|
-
...element,
|
|
1352
|
-
duration: Math.min(element.duration, effectiveDuration - element.start),
|
|
1353
|
-
}))
|
|
1354
|
-
.filter((element) => element.duration > 0)
|
|
1355
|
-
: els;
|
|
1356
|
-
const timelineEls =
|
|
1357
|
-
iframeDoc && effectiveDuration > 0
|
|
1358
|
-
? [
|
|
1359
|
-
...clampedEls,
|
|
1360
|
-
...createImplicitTimelineLayersFromDOM(iframeDoc, effectiveDuration, clampedEls),
|
|
1361
|
-
]
|
|
1362
|
-
: clampedEls;
|
|
1363
|
-
if (timelineEls.length > 0) {
|
|
1364
|
-
syncTimelineElements(timelineEls, newDuration > 0 ? newDuration : undefined);
|
|
1365
|
-
}
|
|
1366
|
-
},
|
|
1367
|
-
[syncTimelineElements],
|
|
1368
|
-
);
|
|
1369
|
-
|
|
1370
|
-
/**
|
|
1371
|
-
* Scan the iframe DOM for composition hosts missing from the current
|
|
1372
|
-
* timeline elements and add them. The CDN runtime often fails to resolve
|
|
1373
|
-
* element-reference starts (`data-start="intro"`) so composition hosts
|
|
1374
|
-
* are silently dropped from `__clipManifest`. This pass reads the DOM +
|
|
1375
|
-
* GSAP timeline registry directly to fill the gaps.
|
|
1376
|
-
*/
|
|
1377
|
-
const enrichMissingCompositions = useCallback(() => {
|
|
1378
|
-
try {
|
|
1379
|
-
const iframe = iframeRef.current;
|
|
1380
|
-
const doc = iframe?.contentDocument;
|
|
1381
|
-
const iframeWin = iframe?.contentWindow as IframeWindow | null;
|
|
1382
|
-
if (!doc || !iframeWin) return;
|
|
1383
|
-
|
|
1384
|
-
const currentEls = usePlayerStore.getState().elements;
|
|
1385
|
-
const existingIds = new Set(currentEls.map((e) => e.id));
|
|
1386
|
-
const rootComp = doc.querySelector("[data-composition-id]");
|
|
1387
|
-
const rootCompId = rootComp?.getAttribute("data-composition-id");
|
|
1388
|
-
// Use [data-composition-id][data-start] — the composition loader strips
|
|
1389
|
-
// data-composition-src after loading, so we can't rely on it.
|
|
1390
|
-
const hosts = doc.querySelectorAll("[data-composition-id][data-start]");
|
|
1391
|
-
const missing: TimelineElement[] = [];
|
|
1392
|
-
|
|
1393
|
-
hosts.forEach((host) => {
|
|
1394
|
-
const el = host as HTMLElement;
|
|
1395
|
-
const compId = el.getAttribute("data-composition-id");
|
|
1396
|
-
if (!compId || compId === rootCompId) return;
|
|
1397
|
-
if (existingIds.has(el.id) || existingIds.has(compId)) return;
|
|
1398
|
-
|
|
1399
|
-
// Resolve start: numeric or element-reference
|
|
1400
|
-
const startAttr = el.getAttribute("data-start") ?? "0";
|
|
1401
|
-
let start = parseFloat(startAttr);
|
|
1402
|
-
if (isNaN(start)) {
|
|
1403
|
-
const ref =
|
|
1404
|
-
doc.getElementById(startAttr) ||
|
|
1405
|
-
doc.querySelector(`[data-composition-id="${startAttr}"]`);
|
|
1406
|
-
if (ref) {
|
|
1407
|
-
const refStartAttr = ref.getAttribute("data-start") ?? "0";
|
|
1408
|
-
let refStart = parseFloat(refStartAttr);
|
|
1409
|
-
// Recursively resolve one level of reference for the ref's own start
|
|
1410
|
-
if (isNaN(refStart)) {
|
|
1411
|
-
const refRef =
|
|
1412
|
-
doc.getElementById(refStartAttr) ||
|
|
1413
|
-
doc.querySelector(`[data-composition-id="${refStartAttr}"]`);
|
|
1414
|
-
const rrStart = parseFloat(refRef?.getAttribute("data-start") ?? "0") || 0;
|
|
1415
|
-
const rrCompId = refRef?.getAttribute("data-composition-id");
|
|
1416
|
-
const rrDur =
|
|
1417
|
-
parseFloat(refRef?.getAttribute("data-duration") ?? "") ||
|
|
1418
|
-
(rrCompId
|
|
1419
|
-
? ((
|
|
1420
|
-
iframeWin.__timelines?.[rrCompId] as TimelineLike | undefined
|
|
1421
|
-
)?.duration?.() ?? 0)
|
|
1422
|
-
: 0);
|
|
1423
|
-
refStart = rrStart + rrDur;
|
|
1424
|
-
}
|
|
1425
|
-
const refCompId = ref.getAttribute("data-composition-id");
|
|
1426
|
-
const refDur =
|
|
1427
|
-
parseFloat(ref.getAttribute("data-duration") ?? "") ||
|
|
1428
|
-
(refCompId
|
|
1429
|
-
? ((iframeWin.__timelines?.[refCompId] as TimelineLike | undefined)?.duration?.() ??
|
|
1430
|
-
0)
|
|
1431
|
-
: 0);
|
|
1432
|
-
start = refStart + refDur;
|
|
1433
|
-
} else {
|
|
1434
|
-
start = 0;
|
|
1435
|
-
}
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
|
-
// Resolve duration from data-duration or GSAP timeline
|
|
1439
|
-
let dur = parseFloat(el.getAttribute("data-duration") ?? "");
|
|
1440
|
-
if (isNaN(dur) || dur <= 0) {
|
|
1441
|
-
dur = (iframeWin.__timelines?.[compId] as TimelineLike | undefined)?.duration?.() ?? 0;
|
|
1442
|
-
}
|
|
1443
|
-
if (!Number.isFinite(dur) || dur <= 0) return;
|
|
1444
|
-
if (!Number.isFinite(start)) start = 0;
|
|
1445
|
-
const rootDuration = usePlayerStore.getState().duration;
|
|
1446
|
-
if (Number.isFinite(rootDuration) && rootDuration > 0) {
|
|
1447
|
-
if (start >= rootDuration) return;
|
|
1448
|
-
dur = Math.min(dur, Math.max(0, rootDuration - start));
|
|
1449
|
-
if (dur <= 0) return;
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
const trackStr = el.getAttribute("data-track-index");
|
|
1453
|
-
const track = trackStr != null ? parseInt(trackStr, 10) : 0;
|
|
1454
|
-
const compSrc =
|
|
1455
|
-
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
|
|
1456
|
-
const selector = getTimelineElementSelector(el);
|
|
1457
|
-
const sourceFile = getTimelineElementSourceFile(el);
|
|
1458
|
-
const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
|
|
1459
|
-
const label = getTimelineElementDisplayLabel({
|
|
1460
|
-
id: el.id || compId || null,
|
|
1461
|
-
label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
|
|
1462
|
-
tag: el.tagName,
|
|
1463
|
-
});
|
|
1464
|
-
const identity = buildTimelineElementIdentity({
|
|
1465
|
-
preferredId: el.id || compId || null,
|
|
1466
|
-
label,
|
|
1467
|
-
fallbackIndex: missing.length,
|
|
1468
|
-
domId: el.id || undefined,
|
|
1469
|
-
selector,
|
|
1470
|
-
selectorIndex,
|
|
1471
|
-
sourceFile,
|
|
1472
|
-
});
|
|
1473
|
-
const entry: TimelineElement = {
|
|
1474
|
-
id: identity.id,
|
|
1475
|
-
label,
|
|
1476
|
-
key: identity.key,
|
|
1477
|
-
tag: el.tagName.toLowerCase(),
|
|
1478
|
-
start,
|
|
1479
|
-
duration: dur,
|
|
1480
|
-
track: isNaN(track) ? 0 : track,
|
|
1481
|
-
domId: el.id || undefined,
|
|
1482
|
-
selector,
|
|
1483
|
-
selectorIndex,
|
|
1484
|
-
sourceFile,
|
|
1485
|
-
};
|
|
1486
|
-
if (compSrc) {
|
|
1487
|
-
entry.compositionSrc = compSrc;
|
|
1488
|
-
} else {
|
|
1489
|
-
// Inline composition — expose inner video for thumbnails
|
|
1490
|
-
const innerVideo = el.querySelector("video[src]");
|
|
1491
|
-
if (innerVideo) {
|
|
1492
|
-
entry.src = innerVideo.getAttribute("src") || undefined;
|
|
1493
|
-
entry.tag = "video";
|
|
1494
|
-
}
|
|
1495
|
-
}
|
|
1496
|
-
missing.push(entry);
|
|
1497
|
-
});
|
|
1498
|
-
|
|
1499
|
-
// Patch existing elements that are missing compositionSrc
|
|
1500
|
-
let patched = false;
|
|
1501
|
-
const updatedEls = currentEls.map((existing) => {
|
|
1502
|
-
if (existing.compositionSrc) return existing;
|
|
1503
|
-
// Find the matching DOM host by element id or composition id
|
|
1504
|
-
const host =
|
|
1505
|
-
doc.getElementById(existing.id) ??
|
|
1506
|
-
doc.querySelector(`[data-composition-id="${existing.id}"]`);
|
|
1507
|
-
if (!host) return existing;
|
|
1508
|
-
const compSrc =
|
|
1509
|
-
host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");
|
|
1510
|
-
if (compSrc) {
|
|
1511
|
-
patched = true;
|
|
1512
|
-
return { ...existing, compositionSrc: compSrc };
|
|
1513
|
-
}
|
|
1514
|
-
return existing;
|
|
1515
|
-
});
|
|
1516
|
-
|
|
1517
|
-
if (missing.length > 0 || patched) {
|
|
1518
|
-
// Dedup: ensure no missing element duplicates an existing one
|
|
1519
|
-
const finalIds = new Set(updatedEls.map((e) => e.id));
|
|
1520
|
-
const dedupedMissing = missing.filter((m) => !finalIds.has(m.id));
|
|
1521
|
-
syncTimelineElements([...updatedEls, ...dedupedMissing]);
|
|
1522
|
-
}
|
|
1523
|
-
} catch (err) {
|
|
1524
|
-
console.warn("[useTimelinePlayer] enrichMissingCompositions failed", err);
|
|
1525
|
-
}
|
|
1526
|
-
}, [syncTimelineElements]);
|
|
1527
|
-
|
|
1528
|
-
const initializeAdapter = useCallback(() => {
|
|
1529
|
-
const adapter = getAdapter();
|
|
1530
|
-
if (!adapter || adapter.getDuration() <= 0) return false;
|
|
1531
|
-
|
|
1532
|
-
adapter.pause();
|
|
1533
|
-
const seekTo = pendingSeekRef.current;
|
|
1534
|
-
pendingSeekRef.current = null;
|
|
1535
|
-
const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0;
|
|
1536
|
-
|
|
1537
|
-
adapter.seek(startTime);
|
|
1538
|
-
const adapterDur = adapter.getDuration();
|
|
1539
|
-
if (
|
|
1540
|
-
Number.isFinite(adapterDur) &&
|
|
1541
|
-
adapterDur > 0 &&
|
|
1542
|
-
adapterDur < 7200 &&
|
|
1543
|
-
adapterDur !== usePlayerStore.getState().duration
|
|
1544
|
-
) {
|
|
1545
|
-
setDuration(adapterDur);
|
|
1546
|
-
}
|
|
1547
|
-
setCurrentTime(startTime);
|
|
1548
|
-
if (!isRefreshingRef.current) {
|
|
1549
|
-
setTimelineReady(true);
|
|
1550
|
-
}
|
|
1551
|
-
isRefreshingRef.current = false;
|
|
1552
|
-
setIsPlaying(false);
|
|
1553
|
-
|
|
1554
|
-
try {
|
|
1555
|
-
const iframe = iframeRef.current;
|
|
1556
|
-
const doc = iframe?.contentDocument;
|
|
1557
|
-
const iframeWin = iframe?.contentWindow as IframeWindow | null;
|
|
1558
|
-
if (doc && iframeWin) {
|
|
1559
|
-
normalizePreviewViewport(doc, iframeWin);
|
|
1560
|
-
autoHealMissingCompositionIds(doc);
|
|
1561
|
-
attachIframeShortcutListeners();
|
|
1562
|
-
}
|
|
1563
|
-
|
|
1564
|
-
const manifest = iframeWin?.__clipManifest;
|
|
1565
|
-
if (manifest && manifest.clips.length > 0) {
|
|
1566
|
-
processTimelineMessage(manifest);
|
|
1567
|
-
}
|
|
1568
|
-
enrichMissingCompositions();
|
|
1569
|
-
|
|
1570
|
-
if (usePlayerStore.getState().elements.length === 0 && doc) {
|
|
1571
|
-
const els = parseTimelineFromDOM(doc, adapter.getDuration());
|
|
1572
|
-
if (els.length > 0) syncTimelineElements(els);
|
|
1573
|
-
}
|
|
1574
|
-
if (usePlayerStore.getState().elements.length === 0 && doc) {
|
|
1575
|
-
const rootComp = doc.querySelector("[data-composition-id]");
|
|
1576
|
-
const rootDuration = adapter.getDuration();
|
|
1577
|
-
if (rootComp && rootDuration > 0) {
|
|
1578
|
-
const fallbackElement = buildStandaloneRootTimelineElement({
|
|
1579
|
-
compositionId: rootComp.getAttribute("data-composition-id") || "composition",
|
|
1580
|
-
tagName: (rootComp as HTMLElement).tagName || "div",
|
|
1581
|
-
rootDuration,
|
|
1582
|
-
iframeSrc: iframe?.src || "",
|
|
1583
|
-
selector: getTimelineElementSelector(rootComp),
|
|
1584
|
-
});
|
|
1585
|
-
if (fallbackElement) syncTimelineElements([fallbackElement]);
|
|
1586
|
-
}
|
|
1587
|
-
}
|
|
1588
|
-
} catch (err) {
|
|
1589
|
-
console.warn("[useTimelinePlayer] Could not read timeline elements from iframe", err);
|
|
1590
|
-
}
|
|
1591
|
-
return true;
|
|
1592
|
-
}, [
|
|
1593
|
-
getAdapter,
|
|
1594
|
-
setDuration,
|
|
1595
|
-
setCurrentTime,
|
|
1596
|
-
setTimelineReady,
|
|
1597
|
-
setIsPlaying,
|
|
1598
|
-
processTimelineMessage,
|
|
1599
|
-
enrichMissingCompositions,
|
|
1600
|
-
syncTimelineElements,
|
|
1601
|
-
attachIframeShortcutListeners,
|
|
1602
|
-
]);
|
|
1603
|
-
|
|
1604
|
-
const onIframeLoad = useCallback(() => {
|
|
1605
|
-
unmutePreviewMedia(iframeRef.current);
|
|
1606
|
-
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
1607
|
-
|
|
1608
|
-
// Fast path: adapter already available (in-place reloads, cached compositions)
|
|
1609
|
-
if (initializeAdapter()) return;
|
|
1610
|
-
|
|
1611
|
-
// The runtime posts "state" or "timeline" messages once ready.
|
|
1612
|
-
// Listen for those instead of polling. Use a short-lived message
|
|
1613
|
-
// listener that fires initializeAdapter on the first signal.
|
|
1614
|
-
const iframe = iframeRef.current;
|
|
1615
|
-
let settled = false;
|
|
1616
|
-
|
|
1617
|
-
const trySettle = () => {
|
|
1618
|
-
if (settled) return;
|
|
1619
|
-
if (initializeAdapter()) {
|
|
1620
|
-
settled = true;
|
|
1621
|
-
window.removeEventListener("message", onMessage);
|
|
1622
|
-
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
1623
|
-
}
|
|
1624
|
-
};
|
|
1625
|
-
|
|
1626
|
-
const onMessage = (e: MessageEvent) => {
|
|
1627
|
-
if (e.source && iframe && e.source !== iframe.contentWindow) return;
|
|
1628
|
-
const data = e.data;
|
|
1629
|
-
if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
|
|
1630
|
-
trySettle();
|
|
1631
|
-
}
|
|
1632
|
-
};
|
|
1633
|
-
window.addEventListener("message", onMessage);
|
|
330
|
+
const { playbackKeyDownRef, playbackKeyUpRef, attachIframeShortcutListeners, togglePlay } =
|
|
331
|
+
usePlaybackKeyboard({
|
|
332
|
+
iframeRef,
|
|
333
|
+
shuttleDirectionRef,
|
|
334
|
+
shuttleSpeedIndexRef,
|
|
335
|
+
iframeShortcutCleanupRef,
|
|
336
|
+
getAdapter,
|
|
337
|
+
play,
|
|
338
|
+
playBackward,
|
|
339
|
+
pause,
|
|
340
|
+
seek,
|
|
341
|
+
});
|
|
1634
342
|
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
343
|
+
const { processTimelineMessageRef, enrichMissingCompositionsRef, onIframeLoad } =
|
|
344
|
+
useTimelineSyncCallbacks({
|
|
345
|
+
iframeRef,
|
|
346
|
+
probeIntervalRef,
|
|
347
|
+
pendingSeekRef,
|
|
348
|
+
isRefreshingRef,
|
|
349
|
+
getAdapter,
|
|
350
|
+
syncTimelineElements,
|
|
351
|
+
setDuration,
|
|
352
|
+
setCurrentTime,
|
|
353
|
+
setTimelineReady,
|
|
354
|
+
setIsPlaying,
|
|
355
|
+
attachIframeShortcutListeners,
|
|
356
|
+
});
|
|
1647
357
|
|
|
1648
|
-
/** Save the current playback time so the next onIframeLoad restores it. */
|
|
1649
358
|
const saveSeekPosition = useCallback(() => {
|
|
1650
359
|
const adapter = getAdapter();
|
|
1651
360
|
pendingSeekRef.current = adapter
|
|
@@ -1657,9 +366,6 @@ export function useTimelinePlayer() {
|
|
|
1657
366
|
setIsPlaying(false);
|
|
1658
367
|
}, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
|
|
1659
368
|
|
|
1660
|
-
const togglePlayRef = useRef(togglePlay);
|
|
1661
|
-
togglePlayRef.current = togglePlay;
|
|
1662
|
-
|
|
1663
369
|
const refreshPlayer = useCallback(() => {
|
|
1664
370
|
const iframe = iframeRef.current;
|
|
1665
371
|
if (!iframe) return;
|
|
@@ -1674,10 +380,6 @@ export function useTimelinePlayer() {
|
|
|
1674
380
|
|
|
1675
381
|
const getAdapterRef = useRef(getAdapter);
|
|
1676
382
|
getAdapterRef.current = getAdapter;
|
|
1677
|
-
const processTimelineMessageRef = useRef(processTimelineMessage);
|
|
1678
|
-
processTimelineMessageRef.current = processTimelineMessage;
|
|
1679
|
-
const enrichMissingCompositionsRef = useRef(enrichMissingCompositions);
|
|
1680
|
-
enrichMissingCompositionsRef.current = enrichMissingCompositions;
|
|
1681
383
|
|
|
1682
384
|
useMountEffect(() => {
|
|
1683
385
|
const handleWindowKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
|
|
@@ -1693,9 +395,7 @@ export function useTimelinePlayer() {
|
|
|
1693
395
|
if (e.source && ourIframe && e.source !== ourIframe.contentWindow) {
|
|
1694
396
|
return;
|
|
1695
397
|
}
|
|
1696
|
-
// Also handle the runtime's state message which includes timeline data
|
|
1697
398
|
if (data?.source === "hf-preview" && data?.type === "state") {
|
|
1698
|
-
// State message means the runtime is alive — check for elements
|
|
1699
399
|
try {
|
|
1700
400
|
if (usePlayerStore.getState().elements.length === 0) {
|
|
1701
401
|
const iframeWin = ourIframe?.contentWindow as IframeWindow | null;
|
|
@@ -1707,8 +407,7 @@ export function useTimelinePlayer() {
|
|
|
1707
407
|
// Enrich only when the timeline has settled — skip during the window
|
|
1708
408
|
// right after a "timeline" message to avoid the enrichment adding
|
|
1709
409
|
// elements that fight with the manifest's authoritative element list,
|
|
1710
|
-
// causing duration oscillation
|
|
1711
|
-
// REPLACE and PRESERVE when element counts fluctuate).
|
|
410
|
+
// causing duration oscillation.
|
|
1712
411
|
const msSinceTimeline = Date.now() - lastTimelineMessageRef.current;
|
|
1713
412
|
if (msSinceTimeline > 500) {
|
|
1714
413
|
enrichMissingCompositionsRef.current();
|
|
@@ -1720,9 +419,7 @@ export function useTimelinePlayer() {
|
|
|
1720
419
|
if (data?.source === "hf-preview" && data?.type === "timeline" && Array.isArray(data.clips)) {
|
|
1721
420
|
lastTimelineMessageRef.current = Date.now();
|
|
1722
421
|
processTimelineMessageRef.current(data);
|
|
1723
|
-
// Fill in composition hosts the manifest missed (element-reference starts)
|
|
1724
422
|
enrichMissingCompositionsRef.current();
|
|
1725
|
-
// If manifest produced 0 elements after filtering, try DOM fallback
|
|
1726
423
|
if (usePlayerStore.getState().elements.length === 0) {
|
|
1727
424
|
try {
|
|
1728
425
|
const doc = ourIframe?.contentDocument;
|
|
@@ -1743,7 +440,7 @@ export function useTimelinePlayer() {
|
|
|
1743
440
|
}
|
|
1744
441
|
};
|
|
1745
442
|
|
|
1746
|
-
// Pause video when tab loses focus
|
|
443
|
+
// Pause video when tab loses focus
|
|
1747
444
|
const handleVisibilityChange = () => {
|
|
1748
445
|
if (document.hidden && usePlayerStore.getState().isPlaying) {
|
|
1749
446
|
const adapter = getAdapterRef.current?.();
|