@hyperframes/studio 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-BEwJNmPo.js +92 -0
- package/dist/assets/index-BnvciBdD.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +10 -4
- package/src/App.tsx +744 -271
- package/src/components/editor/FileTree.tsx +186 -32
- package/src/components/editor/SourceEditor.tsx +3 -1
- package/src/components/nle/NLELayout.tsx +125 -23
- package/src/components/renders/RenderQueue.tsx +123 -0
- package/src/components/renders/RenderQueueItem.tsx +137 -0
- package/src/components/renders/useRenderQueue.ts +193 -0
- package/src/components/sidebar/AssetsTab.tsx +360 -0
- package/src/components/sidebar/CompositionsTab.tsx +227 -0
- package/src/components/sidebar/LeftSidebar.tsx +102 -0
- package/src/components/ui/ExpandOnHover.tsx +194 -0
- package/src/hooks/useCodeEditor.ts +1 -1
- package/src/hooks/useElementPicker.ts +5 -1
- package/src/index.ts +10 -2
- package/src/player/components/AudioWaveform.tsx +168 -0
- package/src/player/components/CompositionThumbnail.tsx +140 -0
- package/src/player/components/EditModal.tsx +165 -0
- package/src/player/components/Player.tsx +6 -5
- package/src/player/components/PlayerControls.tsx +78 -39
- package/src/player/components/Timeline.test.ts +110 -0
- package/src/player/components/Timeline.tsx +537 -260
- package/src/player/components/TimelineClip.tsx +80 -0
- package/src/player/components/VideoThumbnail.tsx +196 -0
- package/src/player/hooks/useTimelinePlayer.ts +404 -112
- package/src/player/index.ts +3 -3
- package/src/player/lib/time.test.ts +57 -0
- package/src/player/lib/time.ts +1 -0
- package/src/player/store/playerStore.test.ts +265 -0
- package/src/player/store/playerStore.ts +44 -16
- package/src/utils/htmlEditor.ts +164 -0
- package/dist/assets/index-Df6fO-S6.js +0 -78
- package/dist/assets/index-KoBceNoU.css +0 -1
- package/src/player/components/AgentActivityTrack.tsx +0 -93
- package/src/player/lib/useMountEffect.ts +0 -10
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { useRef, useCallback } from "react";
|
|
2
2
|
import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore";
|
|
3
|
-
import { useMountEffect } from "
|
|
3
|
+
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
4
|
|
|
5
|
-
interface
|
|
5
|
+
interface PlaybackAdapter {
|
|
6
6
|
play: () => void;
|
|
7
7
|
pause: () => void;
|
|
8
8
|
seek: (time: number) => void;
|
|
@@ -41,32 +41,12 @@ interface ClipManifest {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
type IframeWindow = Window & {
|
|
44
|
-
__player?:
|
|
44
|
+
__player?: PlaybackAdapter;
|
|
45
45
|
__timeline?: TimelineLike;
|
|
46
46
|
__timelines?: Record<string, TimelineLike>;
|
|
47
47
|
__clipManifest?: ClipManifest;
|
|
48
48
|
};
|
|
49
49
|
|
|
50
|
-
interface PlaybackAdapter {
|
|
51
|
-
play: () => void;
|
|
52
|
-
pause: () => void;
|
|
53
|
-
seek: (time: number) => void;
|
|
54
|
-
getTime: () => number;
|
|
55
|
-
getDuration: () => number;
|
|
56
|
-
isPlaying: () => boolean;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function wrapPlayer(p: PlayerAPI): PlaybackAdapter {
|
|
60
|
-
return {
|
|
61
|
-
play: () => p.play(),
|
|
62
|
-
pause: () => p.pause(),
|
|
63
|
-
seek: (t) => p.seek(t),
|
|
64
|
-
getTime: () => p.getTime(),
|
|
65
|
-
getDuration: () => p.getDuration(),
|
|
66
|
-
isPlaying: () => p.isPlaying(),
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
50
|
function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
|
|
71
51
|
return {
|
|
72
52
|
play: () => tl.play(),
|
|
@@ -81,6 +61,71 @@ function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
|
|
|
81
61
|
};
|
|
82
62
|
}
|
|
83
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Parse [data-start] elements from a Document into TimelineElement[].
|
|
66
|
+
* Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
|
|
67
|
+
*/
|
|
68
|
+
function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElement[] {
|
|
69
|
+
const rootComp = doc.querySelector("[data-composition-id]");
|
|
70
|
+
const nodes = doc.querySelectorAll("[data-start]");
|
|
71
|
+
const els: TimelineElement[] = [];
|
|
72
|
+
let trackCounter = 0;
|
|
73
|
+
|
|
74
|
+
nodes.forEach((node) => {
|
|
75
|
+
if (node === rootComp) return;
|
|
76
|
+
const el = node as HTMLElement;
|
|
77
|
+
const startStr = el.getAttribute("data-start");
|
|
78
|
+
if (startStr == null) return;
|
|
79
|
+
const start = parseFloat(startStr);
|
|
80
|
+
if (isNaN(start)) return;
|
|
81
|
+
|
|
82
|
+
const tagLower = el.tagName.toLowerCase();
|
|
83
|
+
let dur = 0;
|
|
84
|
+
const durStr = el.getAttribute("data-duration");
|
|
85
|
+
if (durStr != null) dur = parseFloat(durStr);
|
|
86
|
+
if (isNaN(dur) || dur <= 0) dur = Math.max(0, rootDuration - start);
|
|
87
|
+
|
|
88
|
+
const trackStr = el.getAttribute("data-track-index");
|
|
89
|
+
const track = trackStr != null ? parseInt(trackStr, 10) : trackCounter++;
|
|
90
|
+
const entry: TimelineElement = {
|
|
91
|
+
id: el.id || el.className?.split(" ")[0] || tagLower,
|
|
92
|
+
tag: tagLower,
|
|
93
|
+
start,
|
|
94
|
+
duration: dur,
|
|
95
|
+
track: isNaN(track) ? 0 : track,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Media elements
|
|
99
|
+
if (tagLower === "video" || tagLower === "audio" || tagLower === "img") {
|
|
100
|
+
const src = el.getAttribute("src");
|
|
101
|
+
if (src) entry.src = src;
|
|
102
|
+
const ms = el.getAttribute("data-media-start");
|
|
103
|
+
if (ms) entry.playbackStart = parseFloat(ms);
|
|
104
|
+
const vol = el.getAttribute("data-volume");
|
|
105
|
+
if (vol) entry.volume = parseFloat(vol);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Sub-compositions
|
|
109
|
+
const compSrc =
|
|
110
|
+
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
|
|
111
|
+
const compId = el.getAttribute("data-composition-id");
|
|
112
|
+
if (compSrc) {
|
|
113
|
+
entry.compositionSrc = compSrc;
|
|
114
|
+
} else if (compId && compId !== rootComp?.getAttribute("data-composition-id")) {
|
|
115
|
+
// Inline composition — expose inner video for thumbnails
|
|
116
|
+
const innerVideo = el.querySelector("video[src]");
|
|
117
|
+
if (innerVideo) {
|
|
118
|
+
entry.src = innerVideo.getAttribute("src") || undefined;
|
|
119
|
+
entry.tag = "video";
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
els.push(entry);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return els;
|
|
127
|
+
}
|
|
128
|
+
|
|
84
129
|
function normalizePreviewViewport(doc: Document, win: Window): void {
|
|
85
130
|
if (doc.documentElement) {
|
|
86
131
|
doc.documentElement.style.overflow = "hidden";
|
|
@@ -136,13 +181,8 @@ function unmutePreviewMedia(iframe: HTMLIFrameElement | null): void {
|
|
|
136
181
|
{ source: "hf-parent", type: "control", action: "set-muted", muted: false },
|
|
137
182
|
"*",
|
|
138
183
|
);
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
{ source: "hf-parent", type: "control", action: "set-muted", muted: false },
|
|
142
|
-
"*",
|
|
143
|
-
);
|
|
144
|
-
} catch {
|
|
145
|
-
/* ignore */
|
|
184
|
+
} catch (err) {
|
|
185
|
+
console.warn("[useTimelinePlayer] Failed to unmute preview media", err);
|
|
146
186
|
}
|
|
147
187
|
}
|
|
148
188
|
|
|
@@ -155,7 +195,7 @@ export function useTimelinePlayer() {
|
|
|
155
195
|
|
|
156
196
|
// ZERO store subscriptions — this hook never causes re-renders.
|
|
157
197
|
// All reads use getState() (point-in-time), all writes use the stable setters.
|
|
158
|
-
const { setIsPlaying, setCurrentTime, setDuration, setTimelineReady, setElements
|
|
198
|
+
const { setIsPlaying, setCurrentTime, setDuration, setTimelineReady, setElements } =
|
|
159
199
|
usePlayerStore.getState();
|
|
160
200
|
|
|
161
201
|
const getAdapter = useCallback((): PlaybackAdapter | null => {
|
|
@@ -164,7 +204,7 @@ export function useTimelinePlayer() {
|
|
|
164
204
|
if (!win) return null;
|
|
165
205
|
|
|
166
206
|
if (win.__player && typeof win.__player.play === "function") {
|
|
167
|
-
return
|
|
207
|
+
return win.__player;
|
|
168
208
|
}
|
|
169
209
|
|
|
170
210
|
if (win.__timeline) return wrapTimeline(win.__timeline);
|
|
@@ -175,7 +215,8 @@ export function useTimelinePlayer() {
|
|
|
175
215
|
}
|
|
176
216
|
|
|
177
217
|
return null;
|
|
178
|
-
} catch {
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.warn("[useTimelinePlayer] Could not get playback adapter (cross-origin)", err);
|
|
179
220
|
return null;
|
|
180
221
|
}
|
|
181
222
|
}, []);
|
|
@@ -211,10 +252,6 @@ export function useTimelinePlayer() {
|
|
|
211
252
|
{ source: "hf-parent", type: "control", action: "set-playback-rate", playbackRate: rate },
|
|
212
253
|
"*",
|
|
213
254
|
);
|
|
214
|
-
iframe.contentWindow?.postMessage(
|
|
215
|
-
{ source: "hf-parent", type: "control", action: "set-playback-rate", playbackRate: rate },
|
|
216
|
-
"*",
|
|
217
|
-
);
|
|
218
255
|
// Also set directly on GSAP timeline if accessible
|
|
219
256
|
try {
|
|
220
257
|
const win = iframe.contentWindow as IframeWindow | null;
|
|
@@ -228,8 +265,8 @@ export function useTimelinePlayer() {
|
|
|
228
265
|
}
|
|
229
266
|
}
|
|
230
267
|
}
|
|
231
|
-
} catch {
|
|
232
|
-
|
|
268
|
+
} catch (err) {
|
|
269
|
+
console.warn("[useTimelinePlayer] Could not set playback rate (cross-origin)", err);
|
|
233
270
|
}
|
|
234
271
|
}, []);
|
|
235
272
|
|
|
@@ -250,9 +287,10 @@ export function useTimelinePlayer() {
|
|
|
250
287
|
const adapter = getAdapter();
|
|
251
288
|
if (!adapter) return;
|
|
252
289
|
adapter.pause();
|
|
290
|
+
setCurrentTime(adapter.getTime()); // sync store so Split/Delete have accurate time
|
|
253
291
|
setIsPlaying(false);
|
|
254
292
|
stopRAFLoop();
|
|
255
|
-
}, [getAdapter, setIsPlaying, stopRAFLoop]);
|
|
293
|
+
}, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop]);
|
|
256
294
|
|
|
257
295
|
const togglePlay = useCallback(() => {
|
|
258
296
|
if (usePlayerStore.getState().isPlaying) {
|
|
@@ -268,41 +306,222 @@ export function useTimelinePlayer() {
|
|
|
268
306
|
if (!adapter) return;
|
|
269
307
|
adapter.seek(time);
|
|
270
308
|
liveTime.notify(time); // Direct DOM updates (playhead, timecode, progress) — no re-render
|
|
309
|
+
setCurrentTime(time); // sync store so Split/Delete have accurate time
|
|
271
310
|
stopRAFLoop();
|
|
272
311
|
// Only update store if state actually changes (avoids unnecessary re-renders)
|
|
273
312
|
if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
|
|
274
313
|
},
|
|
275
|
-
[getAdapter, setIsPlaying, stopRAFLoop],
|
|
314
|
+
[getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop],
|
|
276
315
|
);
|
|
277
316
|
|
|
278
317
|
// Convert a runtime timeline message (from iframe postMessage) into TimelineElements
|
|
279
318
|
const processTimelineMessage = useCallback(
|
|
280
319
|
(data: { clips: ClipManifestClip[]; durationInFrames: number }) => {
|
|
281
|
-
if (!data.clips || data.clips.length === 0)
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
320
|
+
if (!data.clips || data.clips.length === 0) {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Show root-level clips: no parentCompositionId, OR parent is a "phantom wrapper"
|
|
325
|
+
const clipCompositionIds = new Set(data.clips.map((c) => c.compositionId).filter(Boolean));
|
|
326
|
+
const filtered = data.clips.filter(
|
|
327
|
+
(clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId),
|
|
328
|
+
);
|
|
329
|
+
const els: TimelineElement[] = filtered.map((clip) => {
|
|
330
|
+
const entry: TimelineElement = {
|
|
331
|
+
id: clip.id || clip.label || clip.tagName || "element",
|
|
332
|
+
tag: clip.tagName || clip.kind,
|
|
333
|
+
start: clip.start,
|
|
334
|
+
duration: clip.duration,
|
|
335
|
+
track: clip.track,
|
|
336
|
+
};
|
|
337
|
+
if (clip.assetUrl) entry.src = clip.assetUrl;
|
|
338
|
+
if (clip.kind === "composition" && clip.compositionId) {
|
|
339
|
+
// The bundler renames data-composition-src to data-composition-file
|
|
340
|
+
// after inlining, so the clip manifest may not have compositionSrc.
|
|
341
|
+
// Fall back to reading data-composition-file from the DOM.
|
|
342
|
+
let resolvedSrc = clip.compositionSrc;
|
|
343
|
+
let hostEl: Element | null = null;
|
|
344
|
+
if (!resolvedSrc) {
|
|
345
|
+
try {
|
|
346
|
+
const iframeDoc = iframeRef.current?.contentDocument;
|
|
347
|
+
hostEl =
|
|
348
|
+
iframeDoc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? null;
|
|
349
|
+
resolvedSrc =
|
|
350
|
+
hostEl?.getAttribute("data-composition-src") ??
|
|
351
|
+
hostEl?.getAttribute("data-composition-file") ??
|
|
352
|
+
null;
|
|
353
|
+
} catch {
|
|
354
|
+
/* cross-origin */
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (resolvedSrc) {
|
|
358
|
+
entry.compositionSrc = resolvedSrc;
|
|
359
|
+
} else if (hostEl) {
|
|
360
|
+
// Inline composition (no external file) — expose inner video for thumbnails
|
|
361
|
+
const innerVideo = hostEl.querySelector("video[src]");
|
|
362
|
+
if (innerVideo) {
|
|
363
|
+
entry.src = innerVideo.getAttribute("src") || undefined;
|
|
364
|
+
entry.tag = "video";
|
|
365
|
+
}
|
|
298
366
|
}
|
|
299
|
-
|
|
300
|
-
|
|
367
|
+
}
|
|
368
|
+
return entry;
|
|
369
|
+
});
|
|
370
|
+
// Don't downgrade: if we already have more elements with a longer duration,
|
|
371
|
+
// skip updates that would show fewer clips (transient runtime state).
|
|
372
|
+
const currentElements = usePlayerStore.getState().elements;
|
|
373
|
+
const currentDuration = usePlayerStore.getState().duration;
|
|
374
|
+
const rawDuration = data.durationInFrames / 30;
|
|
375
|
+
// Clamp non-finite or absurdly large durations — the runtime can emit
|
|
376
|
+
// Infinity when it detects a loop-inflated GSAP timeline without an
|
|
377
|
+
// explicit data-duration on the root composition.
|
|
378
|
+
const newDuration = Number.isFinite(rawDuration) && rawDuration < 7200 ? rawDuration : 0;
|
|
379
|
+
if (currentElements.length > els.length && newDuration <= currentDuration) {
|
|
380
|
+
return; // skip transient downgrade
|
|
381
|
+
}
|
|
301
382
|
setElements(els);
|
|
383
|
+
// Ensure duration covers the furthest clip end so fit-zoom shows everything
|
|
384
|
+
if (els.length > 0) {
|
|
385
|
+
const maxEnd = Math.max(...els.map((e) => e.start + e.duration));
|
|
386
|
+
const effectiveDur = Math.max(newDuration, maxEnd);
|
|
387
|
+
if (Number.isFinite(effectiveDur) && effectiveDur > currentDuration)
|
|
388
|
+
setDuration(effectiveDur);
|
|
389
|
+
}
|
|
390
|
+
if (els.length > 0) setTimelineReady(true);
|
|
302
391
|
},
|
|
303
|
-
[setElements],
|
|
392
|
+
[setElements, setTimelineReady, setDuration],
|
|
304
393
|
);
|
|
305
394
|
|
|
395
|
+
/**
|
|
396
|
+
* Scan the iframe DOM for composition hosts missing from the current
|
|
397
|
+
* timeline elements and add them. The CDN runtime often fails to resolve
|
|
398
|
+
* element-reference starts (`data-start="intro"`) so composition hosts
|
|
399
|
+
* are silently dropped from `__clipManifest`. This pass reads the DOM +
|
|
400
|
+
* GSAP timeline registry directly to fill the gaps.
|
|
401
|
+
*/
|
|
402
|
+
const enrichMissingCompositions = useCallback(() => {
|
|
403
|
+
try {
|
|
404
|
+
const iframe = iframeRef.current;
|
|
405
|
+
const doc = iframe?.contentDocument;
|
|
406
|
+
const iframeWin = iframe?.contentWindow as IframeWindow | null;
|
|
407
|
+
if (!doc || !iframeWin) return;
|
|
408
|
+
|
|
409
|
+
const currentEls = usePlayerStore.getState().elements;
|
|
410
|
+
const existingIds = new Set(currentEls.map((e) => e.id));
|
|
411
|
+
const rootComp = doc.querySelector("[data-composition-id]");
|
|
412
|
+
const rootCompId = rootComp?.getAttribute("data-composition-id");
|
|
413
|
+
// Use [data-composition-id][data-start] — the composition loader strips
|
|
414
|
+
// data-composition-src after loading, so we can't rely on it.
|
|
415
|
+
const hosts = doc.querySelectorAll("[data-composition-id][data-start]");
|
|
416
|
+
const missing: TimelineElement[] = [];
|
|
417
|
+
|
|
418
|
+
hosts.forEach((host) => {
|
|
419
|
+
const el = host as HTMLElement;
|
|
420
|
+
const compId = el.getAttribute("data-composition-id");
|
|
421
|
+
if (!compId || compId === rootCompId) return;
|
|
422
|
+
if (existingIds.has(el.id) || existingIds.has(compId)) return;
|
|
423
|
+
|
|
424
|
+
// Resolve start: numeric or element-reference
|
|
425
|
+
const startAttr = el.getAttribute("data-start") ?? "0";
|
|
426
|
+
let start = parseFloat(startAttr);
|
|
427
|
+
if (isNaN(start)) {
|
|
428
|
+
const ref =
|
|
429
|
+
doc.getElementById(startAttr) ||
|
|
430
|
+
doc.querySelector(`[data-composition-id="${startAttr}"]`);
|
|
431
|
+
if (ref) {
|
|
432
|
+
const refStartAttr = ref.getAttribute("data-start") ?? "0";
|
|
433
|
+
let refStart = parseFloat(refStartAttr);
|
|
434
|
+
// Recursively resolve one level of reference for the ref's own start
|
|
435
|
+
if (isNaN(refStart)) {
|
|
436
|
+
const refRef =
|
|
437
|
+
doc.getElementById(refStartAttr) ||
|
|
438
|
+
doc.querySelector(`[data-composition-id="${refStartAttr}"]`);
|
|
439
|
+
const rrStart = parseFloat(refRef?.getAttribute("data-start") ?? "0") || 0;
|
|
440
|
+
const rrCompId = refRef?.getAttribute("data-composition-id");
|
|
441
|
+
const rrDur =
|
|
442
|
+
parseFloat(refRef?.getAttribute("data-duration") ?? "") ||
|
|
443
|
+
(rrCompId
|
|
444
|
+
? ((
|
|
445
|
+
iframeWin.__timelines?.[rrCompId] as TimelineLike | undefined
|
|
446
|
+
)?.duration?.() ?? 0)
|
|
447
|
+
: 0);
|
|
448
|
+
refStart = rrStart + rrDur;
|
|
449
|
+
}
|
|
450
|
+
const refCompId = ref.getAttribute("data-composition-id");
|
|
451
|
+
const refDur =
|
|
452
|
+
parseFloat(ref.getAttribute("data-duration") ?? "") ||
|
|
453
|
+
(refCompId
|
|
454
|
+
? ((iframeWin.__timelines?.[refCompId] as TimelineLike | undefined)?.duration?.() ??
|
|
455
|
+
0)
|
|
456
|
+
: 0);
|
|
457
|
+
start = refStart + refDur;
|
|
458
|
+
} else {
|
|
459
|
+
start = 0;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Resolve duration from data-duration or GSAP timeline
|
|
464
|
+
let dur = parseFloat(el.getAttribute("data-duration") ?? "");
|
|
465
|
+
if (isNaN(dur) || dur <= 0) {
|
|
466
|
+
dur = (iframeWin.__timelines?.[compId] as TimelineLike | undefined)?.duration?.() ?? 0;
|
|
467
|
+
}
|
|
468
|
+
if (!Number.isFinite(dur) || dur <= 0) return;
|
|
469
|
+
if (!Number.isFinite(start)) start = 0;
|
|
470
|
+
|
|
471
|
+
const trackStr = el.getAttribute("data-track-index");
|
|
472
|
+
const track = trackStr != null ? parseInt(trackStr, 10) : 0;
|
|
473
|
+
const compSrc =
|
|
474
|
+
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
|
|
475
|
+
const entry: TimelineElement = {
|
|
476
|
+
id: el.id || compId,
|
|
477
|
+
tag: el.tagName.toLowerCase(),
|
|
478
|
+
start,
|
|
479
|
+
duration: dur,
|
|
480
|
+
track: isNaN(track) ? 0 : track,
|
|
481
|
+
};
|
|
482
|
+
if (compSrc) {
|
|
483
|
+
entry.compositionSrc = compSrc;
|
|
484
|
+
} else {
|
|
485
|
+
// Inline composition — expose inner video for thumbnails
|
|
486
|
+
const innerVideo = el.querySelector("video[src]");
|
|
487
|
+
if (innerVideo) {
|
|
488
|
+
entry.src = innerVideo.getAttribute("src") || undefined;
|
|
489
|
+
entry.tag = "video";
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
missing.push(entry);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// Patch existing elements that are missing compositionSrc
|
|
496
|
+
let patched = false;
|
|
497
|
+
const updatedEls = currentEls.map((existing) => {
|
|
498
|
+
if (existing.compositionSrc) return existing;
|
|
499
|
+
// Find the matching DOM host by element id or composition id
|
|
500
|
+
const host =
|
|
501
|
+
doc.getElementById(existing.id) ??
|
|
502
|
+
doc.querySelector(`[data-composition-id="${existing.id}"]`);
|
|
503
|
+
if (!host) return existing;
|
|
504
|
+
const compSrc =
|
|
505
|
+
host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");
|
|
506
|
+
if (compSrc) {
|
|
507
|
+
patched = true;
|
|
508
|
+
return { ...existing, compositionSrc: compSrc };
|
|
509
|
+
}
|
|
510
|
+
return existing;
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
if (missing.length > 0 || patched) {
|
|
514
|
+
// Dedup: ensure no missing element duplicates an existing one
|
|
515
|
+
const finalIds = new Set(updatedEls.map((e) => e.id));
|
|
516
|
+
const dedupedMissing = missing.filter((m) => !finalIds.has(m.id));
|
|
517
|
+
setElements([...updatedEls, ...dedupedMissing]);
|
|
518
|
+
setTimelineReady(true);
|
|
519
|
+
}
|
|
520
|
+
} catch (err) {
|
|
521
|
+
console.warn("[useTimelinePlayer] enrichMissingCompositions failed", err);
|
|
522
|
+
}
|
|
523
|
+
}, [setElements, setTimelineReady]);
|
|
524
|
+
|
|
306
525
|
const onIframeLoad = useCallback(() => {
|
|
307
526
|
unmutePreviewMedia(iframeRef.current);
|
|
308
527
|
|
|
@@ -323,7 +542,10 @@ export function useTimelinePlayer() {
|
|
|
323
542
|
const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0;
|
|
324
543
|
|
|
325
544
|
adapter.seek(startTime);
|
|
326
|
-
|
|
545
|
+
const adapterDur = adapter.getDuration();
|
|
546
|
+
// Cap at 7200s (2h) to guard against loop-inflated GSAP timelines
|
|
547
|
+
if (Number.isFinite(adapterDur) && adapterDur > 0 && adapterDur < 7200)
|
|
548
|
+
setDuration(adapterDur);
|
|
327
549
|
setCurrentTime(startTime);
|
|
328
550
|
if (!isRefreshingRef.current) {
|
|
329
551
|
setTimelineReady(true);
|
|
@@ -343,55 +565,57 @@ export function useTimelinePlayer() {
|
|
|
343
565
|
const manifest = iframeWin?.__clipManifest;
|
|
344
566
|
if (manifest && manifest.clips.length > 0) {
|
|
345
567
|
processTimelineMessage(manifest);
|
|
346
|
-
}
|
|
568
|
+
}
|
|
569
|
+
// Enrich: fill in composition hosts the manifest missed
|
|
570
|
+
enrichMissingCompositions();
|
|
571
|
+
|
|
572
|
+
// Run DOM fallback if still no elements were populated
|
|
573
|
+
// (manifest may exist but all clips filtered out by parentCompositionId logic)
|
|
574
|
+
if (usePlayerStore.getState().elements.length === 0 && doc) {
|
|
347
575
|
// Fallback: parse data-start elements directly from DOM (raw HTML without runtime)
|
|
576
|
+
const els = parseTimelineFromDOM(doc, adapter.getDuration());
|
|
577
|
+
if (els.length > 0) {
|
|
578
|
+
setElements(els);
|
|
579
|
+
setTimelineReady(true);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Final fallback for standalone composition previews: if still no
|
|
584
|
+
// elements, build timeline entries from the DOM inside the root
|
|
585
|
+
// composition. This ensures the timeline always shows content when
|
|
586
|
+
// viewing a single composition (where elements lack data-start).
|
|
587
|
+
if (usePlayerStore.getState().elements.length === 0 && doc) {
|
|
348
588
|
const rootComp = doc.querySelector("[data-composition-id]");
|
|
349
|
-
const nodes = doc.querySelectorAll("[data-start]");
|
|
350
|
-
const els: TimelineElement[] = [];
|
|
351
|
-
let trackCounter = 0;
|
|
352
589
|
const rootDuration = adapter.getDuration();
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
if (tagLower === "video" || tagLower === "audio" || tagLower === "img") {
|
|
377
|
-
const src = el.getAttribute("src");
|
|
378
|
-
if (src) entry.src = src;
|
|
379
|
-
}
|
|
380
|
-
// Detect sub-compositions
|
|
381
|
-
const compSrc = el.getAttribute("data-composition-src");
|
|
382
|
-
const compId = el.getAttribute("data-composition-id");
|
|
383
|
-
if (compSrc || (compId && compId !== rootComp?.getAttribute("data-composition-id"))) {
|
|
384
|
-
entry.compositionSrc = compSrc || `compositions/${compId}.html`;
|
|
385
|
-
}
|
|
386
|
-
els.push(entry);
|
|
387
|
-
});
|
|
388
|
-
if (els.length > 0) setElements(els);
|
|
590
|
+
if (rootComp && rootDuration > 0) {
|
|
591
|
+
const rootId = rootComp.getAttribute("data-composition-id") || "composition";
|
|
592
|
+
// Derive compositionSrc from the iframe URL for thumbnail rendering.
|
|
593
|
+
// URL pattern: /api/projects/{id}/preview/comp/{path}
|
|
594
|
+
const iframeSrc = iframeRef.current?.src || "";
|
|
595
|
+
const compPathMatch = iframeSrc.match(/\/preview\/comp\/(.+?)(?:\?|$)/);
|
|
596
|
+
const compositionSrc = compPathMatch
|
|
597
|
+
? decodeURIComponent(compPathMatch[1])
|
|
598
|
+
: undefined;
|
|
599
|
+
// Always show the root composition as a single clip — guarantees
|
|
600
|
+
// the timeline is never empty when a valid composition is loaded.
|
|
601
|
+
setElements([
|
|
602
|
+
{
|
|
603
|
+
id: rootId,
|
|
604
|
+
tag: (rootComp as HTMLElement).tagName?.toLowerCase() || "div",
|
|
605
|
+
start: 0,
|
|
606
|
+
duration: rootDuration,
|
|
607
|
+
track: 0,
|
|
608
|
+
compositionSrc,
|
|
609
|
+
},
|
|
610
|
+
]);
|
|
611
|
+
setTimelineReady(true);
|
|
612
|
+
}
|
|
389
613
|
}
|
|
390
614
|
// The runtime will also postMessage the full timeline after all compositions load.
|
|
391
615
|
// That message is handled by the window listener below, which will update elements
|
|
392
616
|
// with the complete data (including async-loaded compositions).
|
|
393
|
-
} catch {
|
|
394
|
-
|
|
617
|
+
} catch (err) {
|
|
618
|
+
console.warn("[useTimelinePlayer] Could not read timeline elements from iframe", err);
|
|
395
619
|
}
|
|
396
620
|
|
|
397
621
|
return;
|
|
@@ -401,7 +625,7 @@ export function useTimelinePlayer() {
|
|
|
401
625
|
console.warn("Could not find __player, __timeline, or __timelines on iframe after 5s");
|
|
402
626
|
}
|
|
403
627
|
}, 200);
|
|
404
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
628
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
405
629
|
}, [
|
|
406
630
|
getAdapter,
|
|
407
631
|
setDuration,
|
|
@@ -409,6 +633,7 @@ export function useTimelinePlayer() {
|
|
|
409
633
|
setTimelineReady,
|
|
410
634
|
setIsPlaying,
|
|
411
635
|
processTimelineMessage,
|
|
636
|
+
enrichMissingCompositions,
|
|
412
637
|
]);
|
|
413
638
|
|
|
414
639
|
/** Save the current playback time so the next onIframeLoad restores it. */
|
|
@@ -436,8 +661,12 @@ export function useTimelinePlayer() {
|
|
|
436
661
|
|
|
437
662
|
const togglePlayRef = useRef(togglePlay);
|
|
438
663
|
togglePlayRef.current = togglePlay;
|
|
664
|
+
const getAdapterRef = useRef(getAdapter);
|
|
665
|
+
getAdapterRef.current = getAdapter;
|
|
439
666
|
const processTimelineMessageRef = useRef(processTimelineMessage);
|
|
440
667
|
processTimelineMessageRef.current = processTimelineMessage;
|
|
668
|
+
const enrichMissingCompositionsRef = useRef(enrichMissingCompositions);
|
|
669
|
+
enrichMissingCompositionsRef.current = enrichMissingCompositions;
|
|
441
670
|
|
|
442
671
|
useMountEffect(() => {
|
|
443
672
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
@@ -452,33 +681,95 @@ export function useTimelinePlayer() {
|
|
|
452
681
|
// so we get the complete clip list (not just the first few).
|
|
453
682
|
const handleMessage = (e: MessageEvent) => {
|
|
454
683
|
const data = e.data;
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
684
|
+
// Only process messages from the main preview iframe — ignore MediaPanel/ClipThumbnail iframes
|
|
685
|
+
if (e.source && iframeRef.current && e.source !== iframeRef.current.contentWindow) {
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
// Also handle the runtime's state message which includes timeline data
|
|
689
|
+
if (data?.source === "hf-preview" && data?.type === "state") {
|
|
690
|
+
// State message means the runtime is alive — check for elements
|
|
691
|
+
try {
|
|
692
|
+
if (usePlayerStore.getState().elements.length === 0) {
|
|
693
|
+
const iframe = iframeRef.current;
|
|
694
|
+
const iframeWin = iframe?.contentWindow as IframeWindow | null;
|
|
695
|
+
const manifest = iframeWin?.__clipManifest;
|
|
696
|
+
if (manifest && manifest.clips.length > 0) {
|
|
697
|
+
processTimelineMessageRef.current(manifest);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
// Always try to enrich — timelines may have registered since the last check
|
|
701
|
+
enrichMissingCompositionsRef.current();
|
|
702
|
+
} catch (err) {
|
|
703
|
+
console.warn("[useTimelinePlayer] Could not read clip manifest from iframe", err);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
if (data?.source === "hf-preview" && data?.type === "timeline" && Array.isArray(data.clips)) {
|
|
460
707
|
processTimelineMessageRef.current(data);
|
|
708
|
+
// Fill in composition hosts the manifest missed (element-reference starts)
|
|
709
|
+
enrichMissingCompositionsRef.current();
|
|
461
710
|
// Update duration only if the new value is longer (don't downgrade during generation)
|
|
462
|
-
if (data.durationInFrames > 0) {
|
|
711
|
+
if (data.durationInFrames > 0 && Number.isFinite(data.durationInFrames)) {
|
|
463
712
|
const fps = 30;
|
|
464
713
|
const dur = data.durationInFrames / fps;
|
|
465
714
|
const currentDur = usePlayerStore.getState().duration;
|
|
466
715
|
if (dur > currentDur) usePlayerStore.getState().setDuration(dur);
|
|
467
716
|
}
|
|
717
|
+
// If manifest produced 0 elements after filtering, try DOM fallback
|
|
718
|
+
if (usePlayerStore.getState().elements.length === 0) {
|
|
719
|
+
try {
|
|
720
|
+
const iframe = iframeRef.current;
|
|
721
|
+
const doc = iframe?.contentDocument;
|
|
722
|
+
const adapter = getAdapter();
|
|
723
|
+
if (doc && adapter) {
|
|
724
|
+
const els = parseTimelineFromDOM(doc, adapter.getDuration());
|
|
725
|
+
if (els.length > 0) {
|
|
726
|
+
setElements(els);
|
|
727
|
+
setTimelineReady(true);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
} catch (err) {
|
|
731
|
+
console.warn(
|
|
732
|
+
"[useTimelinePlayer] Could not read timeline elements on navigate (cross-origin)",
|
|
733
|
+
err,
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
// Pause video when tab loses focus (user switches away)
|
|
741
|
+
const handleVisibilityChange = () => {
|
|
742
|
+
if (document.hidden && usePlayerStore.getState().isPlaying) {
|
|
743
|
+
const adapter = getAdapterRef.current?.();
|
|
744
|
+
if (adapter) {
|
|
745
|
+
adapter.pause();
|
|
746
|
+
setIsPlaying(false);
|
|
747
|
+
stopRAFLoop();
|
|
748
|
+
}
|
|
468
749
|
}
|
|
469
750
|
};
|
|
470
751
|
|
|
471
752
|
window.addEventListener("keydown", handleKeyDown);
|
|
472
753
|
window.addEventListener("message", handleMessage);
|
|
754
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
473
755
|
return () => {
|
|
474
756
|
window.removeEventListener("keydown", handleKeyDown);
|
|
475
757
|
window.removeEventListener("message", handleMessage);
|
|
758
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
476
759
|
stopRAFLoop();
|
|
477
760
|
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
478
|
-
reset()
|
|
761
|
+
// Don't reset() on cleanup — preserve timeline elements across iframe refreshes
|
|
762
|
+
// to prevent blink. New data will replace old when the iframe reloads.
|
|
479
763
|
};
|
|
480
764
|
});
|
|
481
765
|
|
|
766
|
+
/** Reset the player store (elements, duration, etc.) — call when switching sessions. */
|
|
767
|
+
const resetPlayer = useCallback(() => {
|
|
768
|
+
stopRAFLoop();
|
|
769
|
+
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
770
|
+
usePlayerStore.getState().reset();
|
|
771
|
+
}, [stopRAFLoop]);
|
|
772
|
+
|
|
482
773
|
return {
|
|
483
774
|
iframeRef,
|
|
484
775
|
play,
|
|
@@ -488,5 +779,6 @@ export function useTimelinePlayer() {
|
|
|
488
779
|
onIframeLoad,
|
|
489
780
|
refreshPlayer,
|
|
490
781
|
saveSeekPosition,
|
|
782
|
+
resetPlayer,
|
|
491
783
|
};
|
|
492
784
|
}
|