@hyperframes/studio 0.6.0-alpha.9 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
- package/dist/assets/index-D1JDq7Gg.css +1 -0
- package/dist/assets/index-hYc4aP7M.js +117 -0
- package/dist/favicon.svg +14 -0
- package/dist/index.html +3 -2
- package/package.json +9 -9
- package/src/App.tsx +421 -4303
- package/src/captions/components/CaptionOverlay.tsx +13 -246
- package/src/captions/components/CaptionOverlayUtils.ts +221 -0
- package/src/components/AskAgentModal.tsx +120 -0
- package/src/components/StudioHeader.tsx +133 -0
- package/src/components/StudioLeftSidebar.tsx +125 -0
- package/src/components/StudioPreviewArea.tsx +167 -0
- package/src/components/StudioRightPanel.tsx +198 -0
- package/src/components/TimelineToolbar.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +88 -993
- 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/PropertyPanel.test.ts +0 -49
- package/src/components/editor/PropertyPanel.tsx +132 -2763
- 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 -1117
- 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/manualEditingAvailability.test.ts +2 -2
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +84 -1049
- 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/propertyPanelColor.tsx +371 -0
- package/src/components/editor/propertyPanelFill.tsx +421 -0
- package/src/components/editor/propertyPanelFont.tsx +455 -0
- package/src/components/editor/propertyPanelHelpers.ts +401 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
- package/src/components/editor/propertyPanelSections.tsx +453 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +411 -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 +68 -155
- package/src/components/nle/NLEPreview.tsx +3 -0
- package/src/components/nle/useCompositionStack.ts +126 -0
- package/src/components/renders/RenderQueue.tsx +102 -31
- package/src/components/renders/useRenderQueue.ts +8 -2
- package/src/components/sidebar/LeftSidebar.tsx +186 -186
- package/src/contexts/DomEditContext.tsx +137 -0
- package/src/contexts/FileManagerContext.tsx +110 -0
- package/src/contexts/PanelLayoutContext.tsx +68 -0
- package/src/contexts/StudioContext.tsx +135 -0
- package/src/hooks/useAppHotkeys.ts +326 -0
- package/src/hooks/useAskAgentModal.ts +162 -0
- package/src/hooks/useCaptionDetection.ts +132 -0
- package/src/hooks/useCompositionDimensions.ts +25 -0
- package/src/hooks/useConsoleErrorCapture.ts +60 -0
- package/src/hooks/useDomEditCommits.ts +437 -0
- package/src/hooks/useDomEditSession.ts +342 -0
- package/src/hooks/useDomEditTextCommits.ts +330 -0
- package/src/hooks/useDomSelection.ts +398 -0
- package/src/hooks/useFileManager.ts +431 -0
- package/src/hooks/useFrameCapture.ts +77 -0
- package/src/hooks/useLintModal.ts +35 -0
- package/src/hooks/useManifestPersistence.ts +492 -0
- package/src/hooks/usePanelLayout.ts +68 -0
- package/src/hooks/usePreviewInteraction.ts +153 -0
- package/src/hooks/useRenderClipContent.ts +124 -0
- package/src/hooks/useTimelineEditing.ts +472 -0
- package/src/hooks/useToast.ts +20 -0
- package/src/player/components/Player.tsx +33 -2
- package/src/player/components/Timeline.test.ts +0 -8
- package/src/player/components/Timeline.tsx +196 -1518
- package/src/player/components/TimelineCanvas.tsx +434 -0
- package/src/player/components/TimelineClip.tsx +9 -244
- 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 +105 -1371
- 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/src/utils/domEditHelpers.ts +50 -0
- package/src/utils/studioFontHelpers.ts +83 -0
- package/src/utils/studioHelpers.ts +214 -0
- package/src/utils/studioPreviewHelpers.ts +185 -0
- package/src/utils/timelineDiscovery.ts +1 -1
- package/dist/assets/hyperframes-player-DjsVzYFP.js +0 -418
- package/dist/assets/index-14zH9lqh.css +0 -1
- package/dist/assets/index-DYCiFGWQ.js +0 -108
- package/src/player/components/TimelineClip.test.ts +0 -92
|
@@ -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,8 @@ 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
|
|
908
|
-
const playbackKeyUpRef = useRef<(e: KeyboardEvent) => void>(() => {});
|
|
56
|
+
const lastTimelineMessageRef = useRef<number>(0);
|
|
909
57
|
const staticSeekAdapterRef = useRef<{
|
|
910
58
|
player: RuntimePlaybackAdapter;
|
|
911
59
|
duration: number;
|
|
@@ -920,17 +68,40 @@ export function useTimelinePlayer() {
|
|
|
920
68
|
const syncTimelineElements = useCallback(
|
|
921
69
|
(elements: TimelineElement[], nextDuration?: number) => {
|
|
922
70
|
const state = usePlayerStore.getState();
|
|
71
|
+
const resolvedDuration = nextDuration ?? state.duration;
|
|
923
72
|
const mergedElements = mergeTimelineElementsPreservingDowngrades(
|
|
924
73
|
state.elements,
|
|
925
74
|
elements,
|
|
926
75
|
state.duration,
|
|
927
|
-
|
|
76
|
+
resolvedDuration,
|
|
928
77
|
);
|
|
929
|
-
|
|
930
|
-
|
|
78
|
+
|
|
79
|
+
const elementsChanged =
|
|
80
|
+
mergedElements.length !== state.elements.length ||
|
|
81
|
+
mergedElements.some((el, i) => {
|
|
82
|
+
const prev = state.elements[i];
|
|
83
|
+
return (
|
|
84
|
+
!prev ||
|
|
85
|
+
el.id !== prev.id ||
|
|
86
|
+
el.start !== prev.start ||
|
|
87
|
+
el.duration !== prev.duration ||
|
|
88
|
+
el.track !== prev.track
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (elementsChanged) {
|
|
93
|
+
setElements(mergedElements);
|
|
94
|
+
}
|
|
95
|
+
if (
|
|
96
|
+
Number.isFinite(nextDuration) &&
|
|
97
|
+
(nextDuration ?? 0) > 0 &&
|
|
98
|
+
nextDuration !== state.duration
|
|
99
|
+
) {
|
|
931
100
|
setDuration(nextDuration ?? 0);
|
|
932
101
|
}
|
|
933
|
-
|
|
102
|
+
if (!state.timelineReady) {
|
|
103
|
+
setTimelineReady(true);
|
|
104
|
+
}
|
|
934
105
|
},
|
|
935
106
|
[setElements, setTimelineReady, setDuration],
|
|
936
107
|
);
|
|
@@ -1138,14 +309,6 @@ export function useTimelinePlayer() {
|
|
|
1138
309
|
stopRAFLoop();
|
|
1139
310
|
}, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]);
|
|
1140
311
|
|
|
1141
|
-
const togglePlay = useCallback(() => {
|
|
1142
|
-
if (usePlayerStore.getState().isPlaying) {
|
|
1143
|
-
pause();
|
|
1144
|
-
} else {
|
|
1145
|
-
play();
|
|
1146
|
-
}
|
|
1147
|
-
}, [play, pause]);
|
|
1148
|
-
|
|
1149
312
|
const seek = useCallback(
|
|
1150
313
|
(time: number) => {
|
|
1151
314
|
stopReverseLoop();
|
|
@@ -1157,7 +320,6 @@ export function useTimelinePlayer() {
|
|
|
1157
320
|
liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render
|
|
1158
321
|
setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
|
|
1159
322
|
stopRAFLoop();
|
|
1160
|
-
// Only update store if state actually changes (avoids unnecessary re-renders)
|
|
1161
323
|
if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
|
|
1162
324
|
shuttleDirectionRef.current = null;
|
|
1163
325
|
shuttleSpeedIndexRef.current = 0;
|
|
@@ -1165,451 +327,34 @@ export function useTimelinePlayer() {
|
|
|
1165
327
|
[getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
|
|
1166
328
|
);
|
|
1167
329
|
|
|
1168
|
-
const
|
|
1169
|
-
(
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
shuttleSpeedIndexRef.current = Math.min(
|
|
1181
|
-
shuttleSpeedIndexRef.current + 1,
|
|
1182
|
-
SHUTTLE_SPEEDS.length - 1,
|
|
1183
|
-
);
|
|
1184
|
-
} else {
|
|
1185
|
-
shuttleSpeedIndexRef.current = 0;
|
|
1186
|
-
}
|
|
1187
|
-
const speed = SHUTTLE_SPEEDS[shuttleSpeedIndexRef.current];
|
|
1188
|
-
usePlayerStore.getState().setPlaybackRate(speed);
|
|
1189
|
-
if (direction === "forward") {
|
|
1190
|
-
play();
|
|
1191
|
-
} else {
|
|
1192
|
-
playBackward(speed);
|
|
1193
|
-
}
|
|
1194
|
-
},
|
|
1195
|
-
[play, playBackward],
|
|
1196
|
-
);
|
|
1197
|
-
|
|
1198
|
-
const handlePlaybackKeyDown = useCallback(
|
|
1199
|
-
(e: KeyboardEvent) => {
|
|
1200
|
-
if (e.defaultPrevented) return;
|
|
1201
|
-
const captionState = useCaptionStore.getState();
|
|
1202
|
-
if (
|
|
1203
|
-
shouldIgnorePlaybackShortcutEvent(e, {
|
|
1204
|
-
isCaptionEditMode: captionState.isEditMode,
|
|
1205
|
-
selectedCaptionSegmentCount: captionState.selectedSegmentIds.size,
|
|
1206
|
-
})
|
|
1207
|
-
) {
|
|
1208
|
-
return;
|
|
1209
|
-
}
|
|
1210
|
-
pressedCodesRef.current.add(e.code);
|
|
1211
|
-
if (e.code === "Space") {
|
|
1212
|
-
e.preventDefault();
|
|
1213
|
-
togglePlay();
|
|
1214
|
-
return;
|
|
1215
|
-
}
|
|
1216
|
-
if (e.code === "ArrowLeft") {
|
|
1217
|
-
e.preventDefault();
|
|
1218
|
-
stepFrames(e.shiftKey ? -10 : -1);
|
|
1219
|
-
return;
|
|
1220
|
-
}
|
|
1221
|
-
if (e.code === "ArrowRight") {
|
|
1222
|
-
e.preventDefault();
|
|
1223
|
-
stepFrames(e.shiftKey ? 10 : 1);
|
|
1224
|
-
return;
|
|
1225
|
-
}
|
|
1226
|
-
if (e.repeat) return;
|
|
1227
|
-
if (e.code === "KeyK") {
|
|
1228
|
-
e.preventDefault();
|
|
1229
|
-
pause();
|
|
1230
|
-
return;
|
|
1231
|
-
}
|
|
1232
|
-
if (e.code === "KeyJ") {
|
|
1233
|
-
e.preventDefault();
|
|
1234
|
-
if (pressedCodesRef.current.has("KeyK")) {
|
|
1235
|
-
stepFrames(-1);
|
|
1236
|
-
return;
|
|
1237
|
-
}
|
|
1238
|
-
shuttle("backward");
|
|
1239
|
-
return;
|
|
1240
|
-
}
|
|
1241
|
-
if (e.code === "KeyL") {
|
|
1242
|
-
e.preventDefault();
|
|
1243
|
-
if (pressedCodesRef.current.has("KeyK")) {
|
|
1244
|
-
stepFrames(1);
|
|
1245
|
-
return;
|
|
1246
|
-
}
|
|
1247
|
-
shuttle("forward");
|
|
1248
|
-
}
|
|
1249
|
-
},
|
|
1250
|
-
[pause, shuttle, stepFrames, togglePlay],
|
|
1251
|
-
);
|
|
1252
|
-
|
|
1253
|
-
const handlePlaybackKeyUp = useCallback((e: KeyboardEvent) => {
|
|
1254
|
-
pressedCodesRef.current.delete(e.code);
|
|
1255
|
-
}, []);
|
|
1256
|
-
playbackKeyDownRef.current = handlePlaybackKeyDown;
|
|
1257
|
-
playbackKeyUpRef.current = handlePlaybackKeyUp;
|
|
1258
|
-
|
|
1259
|
-
const attachIframeShortcutListeners = useCallback(() => {
|
|
1260
|
-
iframeShortcutCleanupRef.current?.();
|
|
1261
|
-
iframeShortcutCleanupRef.current = null;
|
|
1262
|
-
|
|
1263
|
-
const iframeWin = iframeRef.current?.contentWindow;
|
|
1264
|
-
const iframeDoc = iframeRef.current?.contentDocument;
|
|
1265
|
-
if (!iframeWin && !iframeDoc) return;
|
|
1266
|
-
|
|
1267
|
-
const handleIframeKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
|
|
1268
|
-
const handleIframeKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
|
|
1269
|
-
iframeWin?.addEventListener("keydown", handleIframeKeyDown, true);
|
|
1270
|
-
iframeWin?.addEventListener("keyup", handleIframeKeyUp, true);
|
|
1271
|
-
iframeDoc?.addEventListener("keydown", handleIframeKeyDown, true);
|
|
1272
|
-
iframeDoc?.addEventListener("keyup", handleIframeKeyUp, true);
|
|
1273
|
-
iframeShortcutCleanupRef.current = () => {
|
|
1274
|
-
iframeWin?.removeEventListener("keydown", handleIframeKeyDown, true);
|
|
1275
|
-
iframeWin?.removeEventListener("keyup", handleIframeKeyUp, true);
|
|
1276
|
-
iframeDoc?.removeEventListener("keydown", handleIframeKeyDown, true);
|
|
1277
|
-
iframeDoc?.removeEventListener("keyup", handleIframeKeyUp, true);
|
|
1278
|
-
};
|
|
1279
|
-
}, []);
|
|
1280
|
-
|
|
1281
|
-
// Convert a runtime timeline message (from iframe postMessage) into TimelineElements
|
|
1282
|
-
const processTimelineMessage = useCallback(
|
|
1283
|
-
(data: {
|
|
1284
|
-
clips: ClipManifestClip[];
|
|
1285
|
-
durationInFrames: number;
|
|
1286
|
-
scenes?: Array<{ id: string; label: string; start: number; duration: number }>;
|
|
1287
|
-
}) => {
|
|
1288
|
-
if (!data.clips || data.clips.length === 0) {
|
|
1289
|
-
return;
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
// Show root-level clips: no parentCompositionId, OR parent is a "phantom wrapper"
|
|
1293
|
-
const clipCompositionIds = new Set(data.clips.map((c) => c.compositionId).filter(Boolean));
|
|
1294
|
-
const filtered = data.clips.filter(
|
|
1295
|
-
(clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId),
|
|
1296
|
-
);
|
|
1297
|
-
let iframeDoc: Document | null = null;
|
|
1298
|
-
try {
|
|
1299
|
-
iframeDoc = iframeRef.current?.contentDocument ?? null;
|
|
1300
|
-
} catch {
|
|
1301
|
-
iframeDoc = null;
|
|
1302
|
-
}
|
|
1303
|
-
const usedHostEls = new Set<Element>();
|
|
1304
|
-
const els: TimelineElement[] = filtered.map((clip, index) => {
|
|
1305
|
-
const hostEl = iframeDoc
|
|
1306
|
-
? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls)
|
|
1307
|
-
: null;
|
|
1308
|
-
if (hostEl) usedHostEls.add(hostEl);
|
|
1309
|
-
return createTimelineElementFromManifestClip({
|
|
1310
|
-
clip,
|
|
1311
|
-
fallbackIndex: index,
|
|
1312
|
-
doc: iframeDoc,
|
|
1313
|
-
hostEl,
|
|
1314
|
-
});
|
|
1315
|
-
});
|
|
1316
|
-
const rawDuration = data.durationInFrames / 30;
|
|
1317
|
-
// Clamp non-finite or absurdly large durations — the runtime can emit
|
|
1318
|
-
// Infinity when it detects a loop-inflated GSAP timeline without an
|
|
1319
|
-
// explicit data-duration on the root composition.
|
|
1320
|
-
const newDuration = Number.isFinite(rawDuration) && rawDuration < 7200 ? rawDuration : 0;
|
|
1321
|
-
const effectiveDuration = newDuration > 0 ? newDuration : usePlayerStore.getState().duration;
|
|
1322
|
-
const clampedEls =
|
|
1323
|
-
effectiveDuration > 0
|
|
1324
|
-
? els
|
|
1325
|
-
.filter((element) => element.start < effectiveDuration)
|
|
1326
|
-
.map((element) => ({
|
|
1327
|
-
...element,
|
|
1328
|
-
duration: Math.min(element.duration, effectiveDuration - element.start),
|
|
1329
|
-
}))
|
|
1330
|
-
.filter((element) => element.duration > 0)
|
|
1331
|
-
: els;
|
|
1332
|
-
const timelineEls =
|
|
1333
|
-
iframeDoc && effectiveDuration > 0
|
|
1334
|
-
? [
|
|
1335
|
-
...clampedEls,
|
|
1336
|
-
...createImplicitTimelineLayersFromDOM(iframeDoc, effectiveDuration, clampedEls),
|
|
1337
|
-
]
|
|
1338
|
-
: clampedEls;
|
|
1339
|
-
if (timelineEls.length > 0) {
|
|
1340
|
-
syncTimelineElements(timelineEls, newDuration > 0 ? newDuration : undefined);
|
|
1341
|
-
}
|
|
1342
|
-
},
|
|
1343
|
-
[syncTimelineElements],
|
|
1344
|
-
);
|
|
1345
|
-
|
|
1346
|
-
/**
|
|
1347
|
-
* Scan the iframe DOM for composition hosts missing from the current
|
|
1348
|
-
* timeline elements and add them. The CDN runtime often fails to resolve
|
|
1349
|
-
* element-reference starts (`data-start="intro"`) so composition hosts
|
|
1350
|
-
* are silently dropped from `__clipManifest`. This pass reads the DOM +
|
|
1351
|
-
* GSAP timeline registry directly to fill the gaps.
|
|
1352
|
-
*/
|
|
1353
|
-
const enrichMissingCompositions = useCallback(() => {
|
|
1354
|
-
try {
|
|
1355
|
-
const iframe = iframeRef.current;
|
|
1356
|
-
const doc = iframe?.contentDocument;
|
|
1357
|
-
const iframeWin = iframe?.contentWindow as IframeWindow | null;
|
|
1358
|
-
if (!doc || !iframeWin) return;
|
|
1359
|
-
|
|
1360
|
-
const currentEls = usePlayerStore.getState().elements;
|
|
1361
|
-
const existingIds = new Set(currentEls.map((e) => e.id));
|
|
1362
|
-
const rootComp = doc.querySelector("[data-composition-id]");
|
|
1363
|
-
const rootCompId = rootComp?.getAttribute("data-composition-id");
|
|
1364
|
-
// Use [data-composition-id][data-start] — the composition loader strips
|
|
1365
|
-
// data-composition-src after loading, so we can't rely on it.
|
|
1366
|
-
const hosts = doc.querySelectorAll("[data-composition-id][data-start]");
|
|
1367
|
-
const missing: TimelineElement[] = [];
|
|
1368
|
-
|
|
1369
|
-
hosts.forEach((host) => {
|
|
1370
|
-
const el = host as HTMLElement;
|
|
1371
|
-
const compId = el.getAttribute("data-composition-id");
|
|
1372
|
-
if (!compId || compId === rootCompId) return;
|
|
1373
|
-
if (existingIds.has(el.id) || existingIds.has(compId)) return;
|
|
1374
|
-
|
|
1375
|
-
// Resolve start: numeric or element-reference
|
|
1376
|
-
const startAttr = el.getAttribute("data-start") ?? "0";
|
|
1377
|
-
let start = parseFloat(startAttr);
|
|
1378
|
-
if (isNaN(start)) {
|
|
1379
|
-
const ref =
|
|
1380
|
-
doc.getElementById(startAttr) ||
|
|
1381
|
-
doc.querySelector(`[data-composition-id="${startAttr}"]`);
|
|
1382
|
-
if (ref) {
|
|
1383
|
-
const refStartAttr = ref.getAttribute("data-start") ?? "0";
|
|
1384
|
-
let refStart = parseFloat(refStartAttr);
|
|
1385
|
-
// Recursively resolve one level of reference for the ref's own start
|
|
1386
|
-
if (isNaN(refStart)) {
|
|
1387
|
-
const refRef =
|
|
1388
|
-
doc.getElementById(refStartAttr) ||
|
|
1389
|
-
doc.querySelector(`[data-composition-id="${refStartAttr}"]`);
|
|
1390
|
-
const rrStart = parseFloat(refRef?.getAttribute("data-start") ?? "0") || 0;
|
|
1391
|
-
const rrCompId = refRef?.getAttribute("data-composition-id");
|
|
1392
|
-
const rrDur =
|
|
1393
|
-
parseFloat(refRef?.getAttribute("data-duration") ?? "") ||
|
|
1394
|
-
(rrCompId
|
|
1395
|
-
? ((
|
|
1396
|
-
iframeWin.__timelines?.[rrCompId] as TimelineLike | undefined
|
|
1397
|
-
)?.duration?.() ?? 0)
|
|
1398
|
-
: 0);
|
|
1399
|
-
refStart = rrStart + rrDur;
|
|
1400
|
-
}
|
|
1401
|
-
const refCompId = ref.getAttribute("data-composition-id");
|
|
1402
|
-
const refDur =
|
|
1403
|
-
parseFloat(ref.getAttribute("data-duration") ?? "") ||
|
|
1404
|
-
(refCompId
|
|
1405
|
-
? ((iframeWin.__timelines?.[refCompId] as TimelineLike | undefined)?.duration?.() ??
|
|
1406
|
-
0)
|
|
1407
|
-
: 0);
|
|
1408
|
-
start = refStart + refDur;
|
|
1409
|
-
} else {
|
|
1410
|
-
start = 0;
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
// Resolve duration from data-duration or GSAP timeline
|
|
1415
|
-
let dur = parseFloat(el.getAttribute("data-duration") ?? "");
|
|
1416
|
-
if (isNaN(dur) || dur <= 0) {
|
|
1417
|
-
dur = (iframeWin.__timelines?.[compId] as TimelineLike | undefined)?.duration?.() ?? 0;
|
|
1418
|
-
}
|
|
1419
|
-
if (!Number.isFinite(dur) || dur <= 0) return;
|
|
1420
|
-
if (!Number.isFinite(start)) start = 0;
|
|
1421
|
-
const rootDuration = usePlayerStore.getState().duration;
|
|
1422
|
-
if (Number.isFinite(rootDuration) && rootDuration > 0) {
|
|
1423
|
-
if (start >= rootDuration) return;
|
|
1424
|
-
dur = Math.min(dur, Math.max(0, rootDuration - start));
|
|
1425
|
-
if (dur <= 0) return;
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
|
-
const trackStr = el.getAttribute("data-track-index");
|
|
1429
|
-
const track = trackStr != null ? parseInt(trackStr, 10) : 0;
|
|
1430
|
-
const compSrc =
|
|
1431
|
-
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
|
|
1432
|
-
const selector = getTimelineElementSelector(el);
|
|
1433
|
-
const sourceFile = getTimelineElementSourceFile(el);
|
|
1434
|
-
const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
|
|
1435
|
-
const label = getTimelineElementDisplayLabel({
|
|
1436
|
-
id: el.id || compId || null,
|
|
1437
|
-
label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
|
|
1438
|
-
tag: el.tagName,
|
|
1439
|
-
});
|
|
1440
|
-
const identity = buildTimelineElementIdentity({
|
|
1441
|
-
preferredId: el.id || compId || null,
|
|
1442
|
-
label,
|
|
1443
|
-
fallbackIndex: missing.length,
|
|
1444
|
-
domId: el.id || undefined,
|
|
1445
|
-
selector,
|
|
1446
|
-
selectorIndex,
|
|
1447
|
-
sourceFile,
|
|
1448
|
-
});
|
|
1449
|
-
const entry: TimelineElement = {
|
|
1450
|
-
id: identity.id,
|
|
1451
|
-
label,
|
|
1452
|
-
key: identity.key,
|
|
1453
|
-
tag: el.tagName.toLowerCase(),
|
|
1454
|
-
start,
|
|
1455
|
-
duration: dur,
|
|
1456
|
-
track: isNaN(track) ? 0 : track,
|
|
1457
|
-
domId: el.id || undefined,
|
|
1458
|
-
selector,
|
|
1459
|
-
selectorIndex,
|
|
1460
|
-
sourceFile,
|
|
1461
|
-
};
|
|
1462
|
-
if (compSrc) {
|
|
1463
|
-
entry.compositionSrc = compSrc;
|
|
1464
|
-
} else {
|
|
1465
|
-
// Inline composition — expose inner video for thumbnails
|
|
1466
|
-
const innerVideo = el.querySelector("video[src]");
|
|
1467
|
-
if (innerVideo) {
|
|
1468
|
-
entry.src = innerVideo.getAttribute("src") || undefined;
|
|
1469
|
-
entry.tag = "video";
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
missing.push(entry);
|
|
1473
|
-
});
|
|
1474
|
-
|
|
1475
|
-
// Patch existing elements that are missing compositionSrc
|
|
1476
|
-
let patched = false;
|
|
1477
|
-
const updatedEls = currentEls.map((existing) => {
|
|
1478
|
-
if (existing.compositionSrc) return existing;
|
|
1479
|
-
// Find the matching DOM host by element id or composition id
|
|
1480
|
-
const host =
|
|
1481
|
-
doc.getElementById(existing.id) ??
|
|
1482
|
-
doc.querySelector(`[data-composition-id="${existing.id}"]`);
|
|
1483
|
-
if (!host) return existing;
|
|
1484
|
-
const compSrc =
|
|
1485
|
-
host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");
|
|
1486
|
-
if (compSrc) {
|
|
1487
|
-
patched = true;
|
|
1488
|
-
return { ...existing, compositionSrc: compSrc };
|
|
1489
|
-
}
|
|
1490
|
-
return existing;
|
|
1491
|
-
});
|
|
1492
|
-
|
|
1493
|
-
if (missing.length > 0 || patched) {
|
|
1494
|
-
// Dedup: ensure no missing element duplicates an existing one
|
|
1495
|
-
const finalIds = new Set(updatedEls.map((e) => e.id));
|
|
1496
|
-
const dedupedMissing = missing.filter((m) => !finalIds.has(m.id));
|
|
1497
|
-
syncTimelineElements([...updatedEls, ...dedupedMissing]);
|
|
1498
|
-
}
|
|
1499
|
-
} catch (err) {
|
|
1500
|
-
console.warn("[useTimelinePlayer] enrichMissingCompositions failed", err);
|
|
1501
|
-
}
|
|
1502
|
-
}, [syncTimelineElements]);
|
|
1503
|
-
|
|
1504
|
-
const onIframeLoad = useCallback(() => {
|
|
1505
|
-
unmutePreviewMedia(iframeRef.current);
|
|
1506
|
-
|
|
1507
|
-
let attempts = 0;
|
|
1508
|
-
const maxAttempts = 25;
|
|
1509
|
-
|
|
1510
|
-
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
1511
|
-
|
|
1512
|
-
probeIntervalRef.current = setInterval(() => {
|
|
1513
|
-
attempts++;
|
|
1514
|
-
const adapter = getAdapter();
|
|
1515
|
-
if (adapter && adapter.getDuration() > 0) {
|
|
1516
|
-
clearInterval(probeIntervalRef.current);
|
|
1517
|
-
adapter.pause();
|
|
1518
|
-
|
|
1519
|
-
const seekTo = pendingSeekRef.current;
|
|
1520
|
-
pendingSeekRef.current = null;
|
|
1521
|
-
const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0;
|
|
1522
|
-
|
|
1523
|
-
adapter.seek(startTime);
|
|
1524
|
-
const adapterDur = adapter.getDuration();
|
|
1525
|
-
// Cap at 7200s (2h) to guard against loop-inflated GSAP timelines
|
|
1526
|
-
if (Number.isFinite(adapterDur) && adapterDur > 0 && adapterDur < 7200)
|
|
1527
|
-
setDuration(adapterDur);
|
|
1528
|
-
setCurrentTime(startTime);
|
|
1529
|
-
if (!isRefreshingRef.current) {
|
|
1530
|
-
setTimelineReady(true);
|
|
1531
|
-
}
|
|
1532
|
-
isRefreshingRef.current = false;
|
|
1533
|
-
setIsPlaying(false);
|
|
1534
|
-
|
|
1535
|
-
try {
|
|
1536
|
-
const iframe = iframeRef.current;
|
|
1537
|
-
const doc = iframe?.contentDocument;
|
|
1538
|
-
const iframeWin = iframe?.contentWindow as IframeWindow | null;
|
|
1539
|
-
if (doc && iframeWin) {
|
|
1540
|
-
normalizePreviewViewport(doc, iframeWin);
|
|
1541
|
-
autoHealMissingCompositionIds(doc);
|
|
1542
|
-
attachIframeShortcutListeners();
|
|
1543
|
-
}
|
|
1544
|
-
|
|
1545
|
-
// Try reading __clipManifest if already available (fast path)
|
|
1546
|
-
const manifest = iframeWin?.__clipManifest;
|
|
1547
|
-
if (manifest && manifest.clips.length > 0) {
|
|
1548
|
-
processTimelineMessage(manifest);
|
|
1549
|
-
}
|
|
1550
|
-
// Enrich: fill in composition hosts the manifest missed
|
|
1551
|
-
enrichMissingCompositions();
|
|
1552
|
-
|
|
1553
|
-
// Run DOM fallback if still no elements were populated
|
|
1554
|
-
// (manifest may exist but all clips filtered out by parentCompositionId logic)
|
|
1555
|
-
if (usePlayerStore.getState().elements.length === 0 && doc) {
|
|
1556
|
-
// Fallback: parse data-start elements directly from DOM (raw HTML without runtime)
|
|
1557
|
-
const els = parseTimelineFromDOM(doc, adapter.getDuration());
|
|
1558
|
-
if (els.length > 0) {
|
|
1559
|
-
syncTimelineElements(els);
|
|
1560
|
-
}
|
|
1561
|
-
}
|
|
1562
|
-
|
|
1563
|
-
// Final fallback for standalone composition previews: if still no
|
|
1564
|
-
// elements, build timeline entries from the DOM inside the root
|
|
1565
|
-
// composition. This ensures the timeline always shows content when
|
|
1566
|
-
// viewing a single composition (where elements lack data-start).
|
|
1567
|
-
if (usePlayerStore.getState().elements.length === 0 && doc) {
|
|
1568
|
-
const rootComp = doc.querySelector("[data-composition-id]");
|
|
1569
|
-
const rootDuration = adapter.getDuration();
|
|
1570
|
-
if (rootComp && rootDuration > 0) {
|
|
1571
|
-
const fallbackElement = buildStandaloneRootTimelineElement({
|
|
1572
|
-
compositionId: rootComp.getAttribute("data-composition-id") || "composition",
|
|
1573
|
-
tagName: (rootComp as HTMLElement).tagName || "div",
|
|
1574
|
-
rootDuration,
|
|
1575
|
-
iframeSrc: iframe?.src || "",
|
|
1576
|
-
selector: getTimelineElementSelector(rootComp),
|
|
1577
|
-
});
|
|
1578
|
-
if (fallbackElement) {
|
|
1579
|
-
// Always show the root composition as a single clip — guarantees
|
|
1580
|
-
// the timeline is never empty when a valid composition is loaded.
|
|
1581
|
-
syncTimelineElements([fallbackElement]);
|
|
1582
|
-
}
|
|
1583
|
-
}
|
|
1584
|
-
}
|
|
1585
|
-
// The runtime will also postMessage the full timeline after all compositions load.
|
|
1586
|
-
// That message is handled by the window listener below, which will update elements
|
|
1587
|
-
// with the complete data (including async-loaded compositions).
|
|
1588
|
-
} catch (err) {
|
|
1589
|
-
console.warn("[useTimelinePlayer] Could not read timeline elements from iframe", err);
|
|
1590
|
-
}
|
|
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
|
+
});
|
|
1591
342
|
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
processTimelineMessage,
|
|
1607
|
-
enrichMissingCompositions,
|
|
1608
|
-
syncTimelineElements,
|
|
1609
|
-
attachIframeShortcutListeners,
|
|
1610
|
-
]);
|
|
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
|
+
});
|
|
1611
357
|
|
|
1612
|
-
/** Save the current playback time so the next onIframeLoad restores it. */
|
|
1613
358
|
const saveSeekPosition = useCallback(() => {
|
|
1614
359
|
const adapter = getAdapter();
|
|
1615
360
|
pendingSeekRef.current = adapter
|
|
@@ -1621,9 +366,6 @@ export function useTimelinePlayer() {
|
|
|
1621
366
|
setIsPlaying(false);
|
|
1622
367
|
}, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
|
|
1623
368
|
|
|
1624
|
-
const togglePlayRef = useRef(togglePlay);
|
|
1625
|
-
togglePlayRef.current = togglePlay;
|
|
1626
|
-
|
|
1627
369
|
const refreshPlayer = useCallback(() => {
|
|
1628
370
|
const iframe = iframeRef.current;
|
|
1629
371
|
if (!iframe) return;
|
|
@@ -1638,10 +380,6 @@ export function useTimelinePlayer() {
|
|
|
1638
380
|
|
|
1639
381
|
const getAdapterRef = useRef(getAdapter);
|
|
1640
382
|
getAdapterRef.current = getAdapter;
|
|
1641
|
-
const processTimelineMessageRef = useRef(processTimelineMessage);
|
|
1642
|
-
processTimelineMessageRef.current = processTimelineMessage;
|
|
1643
|
-
const enrichMissingCompositionsRef = useRef(enrichMissingCompositions);
|
|
1644
|
-
enrichMissingCompositionsRef.current = enrichMissingCompositions;
|
|
1645
383
|
|
|
1646
384
|
useMountEffect(() => {
|
|
1647
385
|
const handleWindowKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
|
|
@@ -1657,9 +395,7 @@ export function useTimelinePlayer() {
|
|
|
1657
395
|
if (e.source && ourIframe && e.source !== ourIframe.contentWindow) {
|
|
1658
396
|
return;
|
|
1659
397
|
}
|
|
1660
|
-
// Also handle the runtime's state message which includes timeline data
|
|
1661
398
|
if (data?.source === "hf-preview" && data?.type === "state") {
|
|
1662
|
-
// State message means the runtime is alive — check for elements
|
|
1663
399
|
try {
|
|
1664
400
|
if (usePlayerStore.getState().elements.length === 0) {
|
|
1665
401
|
const iframeWin = ourIframe?.contentWindow as IframeWindow | null;
|
|
@@ -1668,24 +404,22 @@ export function useTimelinePlayer() {
|
|
|
1668
404
|
processTimelineMessageRef.current(manifest);
|
|
1669
405
|
}
|
|
1670
406
|
}
|
|
1671
|
-
//
|
|
1672
|
-
|
|
407
|
+
// Enrich only when the timeline has settled — skip during the window
|
|
408
|
+
// right after a "timeline" message to avoid the enrichment adding
|
|
409
|
+
// elements that fight with the manifest's authoritative element list,
|
|
410
|
+
// causing duration oscillation.
|
|
411
|
+
const msSinceTimeline = Date.now() - lastTimelineMessageRef.current;
|
|
412
|
+
if (msSinceTimeline > 500) {
|
|
413
|
+
enrichMissingCompositionsRef.current();
|
|
414
|
+
}
|
|
1673
415
|
} catch (err) {
|
|
1674
416
|
console.warn("[useTimelinePlayer] Could not read clip manifest from iframe", err);
|
|
1675
417
|
}
|
|
1676
418
|
}
|
|
1677
419
|
if (data?.source === "hf-preview" && data?.type === "timeline" && Array.isArray(data.clips)) {
|
|
420
|
+
lastTimelineMessageRef.current = Date.now();
|
|
1678
421
|
processTimelineMessageRef.current(data);
|
|
1679
|
-
// Fill in composition hosts the manifest missed (element-reference starts)
|
|
1680
422
|
enrichMissingCompositionsRef.current();
|
|
1681
|
-
if (data.durationInFrames > 0 && Number.isFinite(data.durationInFrames)) {
|
|
1682
|
-
const fps = 30;
|
|
1683
|
-
const dur = data.durationInFrames / fps;
|
|
1684
|
-
if (dur > 0 && dur < 7200) {
|
|
1685
|
-
usePlayerStore.getState().setDuration(dur);
|
|
1686
|
-
}
|
|
1687
|
-
}
|
|
1688
|
-
// If manifest produced 0 elements after filtering, try DOM fallback
|
|
1689
423
|
if (usePlayerStore.getState().elements.length === 0) {
|
|
1690
424
|
try {
|
|
1691
425
|
const doc = ourIframe?.contentDocument;
|
|
@@ -1706,7 +440,7 @@ export function useTimelinePlayer() {
|
|
|
1706
440
|
}
|
|
1707
441
|
};
|
|
1708
442
|
|
|
1709
|
-
// Pause video when tab loses focus
|
|
443
|
+
// Pause video when tab loses focus
|
|
1710
444
|
const handleVisibilityChange = () => {
|
|
1711
445
|
if (document.hidden && usePlayerStore.getState().isPlaying) {
|
|
1712
446
|
const adapter = getAdapterRef.current?.();
|