@hyperframes/studio 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/assets/index-Bj0pPj_X.js +92 -0
  2. package/dist/assets/index-BnvciBdD.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +10 -4
  5. package/src/App.tsx +744 -271
  6. package/src/components/editor/FileTree.tsx +186 -32
  7. package/src/components/editor/SourceEditor.tsx +3 -1
  8. package/src/components/nle/NLELayout.tsx +125 -23
  9. package/src/components/renders/RenderQueue.tsx +123 -0
  10. package/src/components/renders/RenderQueueItem.tsx +133 -0
  11. package/src/components/renders/useRenderQueue.ts +161 -0
  12. package/src/components/sidebar/AssetsTab.tsx +360 -0
  13. package/src/components/sidebar/CompositionsTab.tsx +227 -0
  14. package/src/components/sidebar/LeftSidebar.tsx +102 -0
  15. package/src/components/ui/ExpandOnHover.tsx +194 -0
  16. package/src/hooks/useCodeEditor.ts +1 -1
  17. package/src/hooks/useElementPicker.ts +5 -1
  18. package/src/index.ts +10 -2
  19. package/src/player/components/AudioWaveform.tsx +168 -0
  20. package/src/player/components/CompositionThumbnail.tsx +140 -0
  21. package/src/player/components/EditModal.tsx +165 -0
  22. package/src/player/components/Player.tsx +6 -5
  23. package/src/player/components/PlayerControls.tsx +78 -39
  24. package/src/player/components/Timeline.test.ts +110 -0
  25. package/src/player/components/Timeline.tsx +537 -260
  26. package/src/player/components/TimelineClip.tsx +80 -0
  27. package/src/player/components/VideoThumbnail.tsx +196 -0
  28. package/src/player/hooks/useTimelinePlayer.ts +404 -112
  29. package/src/player/index.ts +3 -3
  30. package/src/player/lib/time.test.ts +57 -0
  31. package/src/player/lib/time.ts +1 -0
  32. package/src/player/store/playerStore.test.ts +265 -0
  33. package/src/player/store/playerStore.ts +44 -16
  34. package/src/utils/htmlEditor.ts +164 -0
  35. package/dist/assets/index-Df6fO-S6.js +0 -78
  36. package/dist/assets/index-KoBceNoU.css +0 -1
  37. package/src/player/components/AgentActivityTrack.tsx +0 -93
  38. 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 "../lib/useMountEffect";
3
+ import { useMountEffect } from "../../hooks/useMountEffect";
4
4
 
5
- interface PlayerAPI {
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?: PlayerAPI;
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
- // Fallback for CDN runtime that still uses the old source name
140
- iframe.contentWindow?.postMessage(
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, reset } =
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 wrapPlayer(win.__player);
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
- /* cross-origin */
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) return;
282
-
283
- // Show only root-level clips: those with no parentCompositionId (direct children of root).
284
- // Sub-composition children (parentCompositionId !== null) belong to the drill-down view.
285
- const els: TimelineElement[] = data.clips
286
- .filter((clip) => !clip.parentCompositionId)
287
- .map((clip) => {
288
- const entry: TimelineElement = {
289
- id: clip.id || clip.label || clip.tagName || "element",
290
- tag: clip.tagName || clip.kind,
291
- start: clip.start,
292
- duration: clip.duration,
293
- track: clip.track,
294
- };
295
- if (clip.assetUrl) entry.src = clip.assetUrl;
296
- if (clip.kind === "composition" && clip.compositionId) {
297
- entry.compositionSrc = clip.compositionSrc || `compositions/${clip.compositionId}.html`;
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
- return entry;
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
- setDuration(adapter.getDuration());
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
- } else if (doc) {
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
- nodes.forEach((node) => {
354
- if (node === rootComp) return;
355
- const el = node as HTMLElement;
356
- const startStr = el.getAttribute("data-start");
357
- if (startStr == null) return;
358
- const start = parseFloat(startStr);
359
- if (isNaN(start)) return;
360
-
361
- const tagLower = el.tagName.toLowerCase();
362
- let dur = 0;
363
- const durStr = el.getAttribute("data-duration");
364
- if (durStr != null) dur = parseFloat(durStr);
365
- if (isNaN(dur) || dur <= 0) dur = Math.max(0, rootDuration - start);
366
-
367
- const trackStr = el.getAttribute("data-track-index");
368
- const track = trackStr != null ? parseInt(trackStr, 10) : trackCounter++;
369
- const entry: TimelineElement = {
370
- id: el.id || el.className?.split(" ")[0] || tagLower,
371
- tag: tagLower,
372
- start,
373
- duration: dur,
374
- track: isNaN(track) ? 0 : track,
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
- // Cross-origin or DOM access error
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 -- setElements is a stable zustand setter
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
- if (
456
- (data?.source === "hf-preview" || data?.source === "hf-preview") &&
457
- data?.type === "timeline" &&
458
- Array.isArray(data.clips)
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
  }