@hyperframes/studio 0.4.12 → 0.4.13-alpha.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.
Files changed (33) hide show
  1. package/dist/assets/hyperframes-player-BOs_kypk.js +198 -0
  2. package/dist/assets/index-BKkR67xb.css +1 -0
  3. package/dist/assets/index-rN5doSq1.js +93 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +289 -11
  7. package/src/components/nle/NLELayout.tsx +24 -7
  8. package/src/components/nle/NLEPreview.test.ts +32 -0
  9. package/src/components/nle/NLEPreview.tsx +12 -1
  10. package/src/player/components/CompositionThumbnail.tsx +94 -17
  11. package/src/player/components/EditModal.tsx +48 -29
  12. package/src/player/components/Player.tsx +5 -2
  13. package/src/player/components/PlayerControls.test.ts +20 -0
  14. package/src/player/components/PlayerControls.tsx +12 -1
  15. package/src/player/components/Timeline.test.ts +44 -1
  16. package/src/player/components/Timeline.tsx +686 -169
  17. package/src/player/components/TimelineClip.tsx +112 -16
  18. package/src/player/components/timelineEditing.test.ts +310 -0
  19. package/src/player/components/timelineEditing.ts +213 -0
  20. package/src/player/components/timelineTheme.test.ts +56 -0
  21. package/src/player/components/timelineTheme.ts +141 -0
  22. package/src/player/components/timelineZoom.test.ts +62 -0
  23. package/src/player/components/timelineZoom.ts +38 -0
  24. package/src/player/hooks/useTimelinePlayer.test.ts +96 -0
  25. package/src/player/hooks/useTimelinePlayer.ts +313 -59
  26. package/src/player/store/playerStore.test.ts +30 -12
  27. package/src/player/store/playerStore.ts +23 -9
  28. package/src/types/hyperframes-player.d.ts +1 -0
  29. package/src/utils/sourcePatcher.test.ts +84 -0
  30. package/src/utils/sourcePatcher.ts +143 -0
  31. package/dist/assets/hyperframes-player-5iD9BZnx.js +0 -198
  32. package/dist/assets/index-CVDXfFQ6.js +0 -93
  33. package/dist/assets/index-jmDaI2F7.css +0 -1
@@ -61,6 +61,50 @@ function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
61
61
  };
62
62
  }
63
63
 
64
+ function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageElement | null {
65
+ if (el instanceof HTMLMediaElement || el instanceof HTMLImageElement) return el;
66
+ const candidate = el.querySelector("video, audio, img");
67
+ return candidate instanceof HTMLMediaElement || candidate instanceof HTMLImageElement
68
+ ? candidate
69
+ : null;
70
+ }
71
+
72
+ function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void {
73
+ const mediaStartAttr = el.getAttribute("data-playback-start")
74
+ ? "playback-start"
75
+ : el.getAttribute("data-media-start")
76
+ ? "media-start"
77
+ : undefined;
78
+ const mediaStartValue =
79
+ el.getAttribute("data-playback-start") ?? el.getAttribute("data-media-start");
80
+ if (mediaStartValue != null) {
81
+ const playbackStart = parseFloat(mediaStartValue);
82
+ if (Number.isFinite(playbackStart)) entry.playbackStart = playbackStart;
83
+ }
84
+ if (mediaStartAttr) entry.playbackStartAttr = mediaStartAttr;
85
+
86
+ const mediaEl = resolveMediaElement(el);
87
+ if (!mediaEl) return;
88
+
89
+ entry.tag = mediaEl.tagName.toLowerCase();
90
+ const src = mediaEl.getAttribute("src");
91
+ if (src) entry.src = src;
92
+
93
+ if (!(mediaEl instanceof HTMLMediaElement)) return;
94
+
95
+ const sourceDurationAttr =
96
+ el.getAttribute("data-source-duration") ?? mediaEl.getAttribute("data-source-duration");
97
+ const sourceDuration = sourceDurationAttr ? parseFloat(sourceDurationAttr) : mediaEl.duration;
98
+ if (Number.isFinite(sourceDuration) && sourceDuration > 0) {
99
+ entry.sourceDuration = sourceDuration;
100
+ }
101
+
102
+ const playbackRate = mediaEl.defaultPlaybackRate;
103
+ if (Number.isFinite(playbackRate) && playbackRate > 0) {
104
+ entry.playbackRate = playbackRate;
105
+ }
106
+ }
107
+
64
108
  /**
65
109
  * Parse [data-start] elements from a Document into TimelineElement[].
66
110
  * Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
@@ -78,37 +122,60 @@ function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElem
78
122
  if (startStr == null) return;
79
123
  const start = parseFloat(startStr);
80
124
  if (isNaN(start)) return;
125
+ if (Number.isFinite(rootDuration) && rootDuration > 0 && start >= rootDuration) return;
81
126
 
82
127
  const tagLower = el.tagName.toLowerCase();
83
128
  let dur = 0;
84
129
  const durStr = el.getAttribute("data-duration");
85
130
  if (durStr != null) dur = parseFloat(durStr);
86
131
  if (isNaN(dur) || dur <= 0) dur = Math.max(0, rootDuration - start);
132
+ if (Number.isFinite(rootDuration) && rootDuration > 0) {
133
+ dur = Math.min(dur, Math.max(0, rootDuration - start));
134
+ }
135
+ if (!Number.isFinite(dur) || dur <= 0) return;
87
136
 
88
137
  const trackStr = el.getAttribute("data-track-index");
89
138
  const track = trackStr != null ? parseInt(trackStr, 10) : trackCounter++;
139
+ const compId = el.getAttribute("data-composition-id");
140
+ const selector = getTimelineElementSelector(el);
141
+ const sourceFile = getTimelineElementSourceFile(el);
142
+ const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
143
+ const id = el.id || compId || el.className?.split(" ")[0] || tagLower;
90
144
  const entry: TimelineElement = {
91
- id: el.id || el.className?.split(" ")[0] || tagLower,
145
+ id,
146
+ key: buildTimelineElementKey({
147
+ id,
148
+ fallbackIndex: els.length,
149
+ domId: el.id || undefined,
150
+ selector,
151
+ selectorIndex,
152
+ sourceFile,
153
+ }),
92
154
  tag: tagLower,
93
155
  start,
94
156
  duration: dur,
95
157
  track: isNaN(track) ? 0 : track,
158
+ domId: el.id || undefined,
159
+ selector,
160
+ selectorIndex,
161
+ sourceFile,
96
162
  };
97
163
 
98
- // Media elements
99
- if (tagLower === "video" || tagLower === "audio" || tagLower === "img") {
100
- const src = el.getAttribute("src");
164
+ const mediaEl = resolveMediaElement(el);
165
+ if (mediaEl) {
166
+ if (mediaEl.tagName === "IMG") {
167
+ entry.tag = "img";
168
+ }
169
+ const src = mediaEl.getAttribute("src");
101
170
  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");
171
+ const vol = el.getAttribute("data-volume") ?? mediaEl.getAttribute("data-volume");
105
172
  if (vol) entry.volume = parseFloat(vol);
173
+ applyMediaMetadataFromElement(entry, el);
106
174
  }
107
175
 
108
176
  // Sub-compositions
109
177
  const compSrc =
110
178
  el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
111
- const compId = el.getAttribute("data-composition-id");
112
179
  if (compSrc) {
113
180
  entry.compositionSrc = compSrc;
114
181
  } else if (compId && compId !== rootComp?.getAttribute("data-composition-id")) {
@@ -126,6 +193,104 @@ function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElem
126
193
  return els;
127
194
  }
128
195
 
196
+ function getTimelineElementSelector(el: Element): string | undefined {
197
+ if (el instanceof HTMLElement && el.id) return `#${el.id}`;
198
+ const compId = el.getAttribute("data-composition-id");
199
+ if (compId) return `[data-composition-id="${compId}"]`;
200
+ if (el instanceof HTMLElement) {
201
+ const firstClass = el.className.split(/\s+/).find(Boolean);
202
+ if (firstClass) return `.${firstClass}`;
203
+ }
204
+ return undefined;
205
+ }
206
+
207
+ function getTimelineElementSourceFile(el: Element): string | undefined {
208
+ const ownerRoot = el.parentElement?.closest("[data-composition-id]");
209
+ return (
210
+ ownerRoot?.getAttribute("data-composition-file") ??
211
+ ownerRoot?.getAttribute("data-composition-src") ??
212
+ undefined
213
+ );
214
+ }
215
+
216
+ function getTimelineElementSelectorIndex(
217
+ doc: Document,
218
+ el: Element,
219
+ selector: string | undefined,
220
+ ): number | undefined {
221
+ if (!selector || selector.startsWith("#") || selector.startsWith("[data-composition-id=")) {
222
+ return undefined;
223
+ }
224
+
225
+ try {
226
+ const matches = Array.from(doc.querySelectorAll(selector));
227
+ const matchIndex = matches.indexOf(el);
228
+ return matchIndex >= 0 ? matchIndex : undefined;
229
+ } catch {
230
+ return undefined;
231
+ }
232
+ }
233
+
234
+ function buildTimelineElementKey(params: {
235
+ id: string;
236
+ fallbackIndex: number;
237
+ domId?: string;
238
+ selector?: string;
239
+ selectorIndex?: number;
240
+ sourceFile?: string;
241
+ }): string {
242
+ const scope = params.sourceFile ?? "index.html";
243
+ if (params.domId) return `${scope}#${params.domId}`;
244
+ if (params.selector) return `${scope}:${params.selector}:${params.selectorIndex ?? 0}`;
245
+ return `${scope}:${params.id}:${params.fallbackIndex}`;
246
+ }
247
+
248
+ function findTimelineDomNode(doc: Document, id: string): Element | null {
249
+ return (
250
+ doc.getElementById(id) ??
251
+ doc.querySelector(`[data-composition-id="${id}"]`) ??
252
+ doc.querySelector(`.${id}`) ??
253
+ null
254
+ );
255
+ }
256
+
257
+ export function resolveStandaloneRootCompositionSrc(iframeSrc: string): string | undefined {
258
+ const compPathMatch = iframeSrc.match(/\/preview\/comp\/(.+?)(?:\?|$)/);
259
+ return compPathMatch ? decodeURIComponent(compPathMatch[1]) : undefined;
260
+ }
261
+
262
+ export function buildStandaloneRootTimelineElement(params: {
263
+ compositionId: string;
264
+ tagName: string;
265
+ rootDuration: number;
266
+ iframeSrc: string;
267
+ selector?: string;
268
+ selectorIndex?: number;
269
+ }): TimelineElement | null {
270
+ if (!Number.isFinite(params.rootDuration) || params.rootDuration <= 0) return null;
271
+
272
+ const compositionSrc = resolveStandaloneRootCompositionSrc(params.iframeSrc);
273
+
274
+ return {
275
+ id: params.compositionId,
276
+ key: buildTimelineElementKey({
277
+ id: params.compositionId,
278
+ fallbackIndex: 0,
279
+ selector: params.selector,
280
+ selectorIndex: params.selectorIndex,
281
+ sourceFile: compositionSrc,
282
+ }),
283
+ tag: params.tagName.toLowerCase() || "div",
284
+ start: 0,
285
+ duration: params.rootDuration,
286
+ track: 0,
287
+ compositionSrc,
288
+ selector: params.selector,
289
+ selectorIndex: params.selectorIndex,
290
+ sourceFile: compositionSrc,
291
+ };
292
+ }
293
+
129
294
  function normalizePreviewViewport(doc: Document, win: Window): void {
130
295
  if (doc.documentElement) {
131
296
  doc.documentElement.style.overflow = "hidden";
@@ -212,6 +377,29 @@ export function resolveIframe(el: Element | null): HTMLIFrameElement | null {
212
377
  return el.shadowRoot?.querySelector("iframe") ?? el.querySelector("iframe") ?? null;
213
378
  }
214
379
 
380
+ export function mergeTimelineElementsPreservingDowngrades(
381
+ currentElements: TimelineElement[],
382
+ nextElements: TimelineElement[],
383
+ currentDuration: number,
384
+ nextDuration: number,
385
+ ): TimelineElement[] {
386
+ const safeCurrentDuration = Number.isFinite(currentDuration) ? currentDuration : 0;
387
+ const safeNextDuration = Number.isFinite(nextDuration) ? nextDuration : 0;
388
+
389
+ if (
390
+ currentElements.length === 0 ||
391
+ nextElements.length >= currentElements.length ||
392
+ safeNextDuration > safeCurrentDuration
393
+ ) {
394
+ return nextElements;
395
+ }
396
+
397
+ const nextIds = new Set(nextElements.map((element) => element.id));
398
+ const preserved = currentElements.filter((element) => !nextIds.has(element.id));
399
+ if (preserved.length === 0) return nextElements;
400
+ return [...nextElements, ...preserved];
401
+ }
402
+
215
403
  export function useTimelinePlayer() {
216
404
  const iframeRef = useRef<HTMLIFrameElement | null>(null);
217
405
  const rafRef = useRef<number>(0);
@@ -224,6 +412,24 @@ export function useTimelinePlayer() {
224
412
  const { setIsPlaying, setCurrentTime, setDuration, setTimelineReady, setElements } =
225
413
  usePlayerStore.getState();
226
414
 
415
+ const syncTimelineElements = useCallback(
416
+ (elements: TimelineElement[], nextDuration?: number) => {
417
+ const state = usePlayerStore.getState();
418
+ const mergedElements = mergeTimelineElementsPreservingDowngrades(
419
+ state.elements,
420
+ elements,
421
+ state.duration,
422
+ nextDuration ?? state.duration,
423
+ );
424
+ setElements(mergedElements);
425
+ if (Number.isFinite(nextDuration) && (nextDuration ?? 0) > 0) {
426
+ setDuration(nextDuration ?? 0);
427
+ }
428
+ setTimelineReady(true);
429
+ },
430
+ [setElements, setTimelineReady, setDuration],
431
+ );
432
+
227
433
  const getAdapter = useCallback((): PlaybackAdapter | null => {
228
434
  try {
229
435
  const iframe = iframeRef.current;
@@ -363,14 +569,35 @@ export function useTimelinePlayer() {
363
569
  const filtered = data.clips.filter(
364
570
  (clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId),
365
571
  );
366
- const els: TimelineElement[] = filtered.map((clip) => {
572
+ const els: TimelineElement[] = filtered.map((clip, index) => {
573
+ let hostEl: Element | null = null;
574
+ const id = clip.id || clip.label || clip.tagName || "element";
367
575
  const entry: TimelineElement = {
368
- id: clip.id || clip.label || clip.tagName || "element",
576
+ id,
369
577
  tag: clip.tagName || clip.kind,
370
578
  start: clip.start,
371
579
  duration: clip.duration,
372
580
  track: clip.track,
373
581
  };
582
+ try {
583
+ const iframeDoc = iframeRef.current?.contentDocument;
584
+ if (iframeDoc && entry.id) {
585
+ hostEl = findTimelineDomNode(iframeDoc, entry.id);
586
+ }
587
+ } catch {
588
+ /* cross-origin */
589
+ }
590
+ if (hostEl) {
591
+ const iframeDoc = iframeRef.current?.contentDocument;
592
+ entry.domId = hostEl.id || undefined;
593
+ entry.selector = getTimelineElementSelector(hostEl);
594
+ entry.selectorIndex =
595
+ iframeDoc && entry.selector
596
+ ? getTimelineElementSelectorIndex(iframeDoc, hostEl, entry.selector)
597
+ : undefined;
598
+ entry.sourceFile = getTimelineElementSourceFile(hostEl);
599
+ applyMediaMetadataFromElement(entry, hostEl);
600
+ }
374
601
  if (clip.assetUrl) entry.src = clip.assetUrl;
375
602
  if (clip.kind === "composition" && clip.compositionId) {
376
603
  // The bundler renames data-composition-src to data-composition-file
@@ -382,7 +609,7 @@ export function useTimelinePlayer() {
382
609
  try {
383
610
  const iframeDoc = iframeRef.current?.contentDocument;
384
611
  hostEl =
385
- iframeDoc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? null;
612
+ iframeDoc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
386
613
  resolvedSrc =
387
614
  hostEl?.getAttribute("data-composition-src") ??
388
615
  hostEl?.getAttribute("data-composition-file") ??
@@ -401,32 +628,48 @@ export function useTimelinePlayer() {
401
628
  entry.tag = "video";
402
629
  }
403
630
  }
631
+ if (hostEl) {
632
+ const iframeDoc = iframeRef.current?.contentDocument;
633
+ entry.domId = hostEl.id || undefined;
634
+ entry.selector = getTimelineElementSelector(hostEl);
635
+ entry.selectorIndex =
636
+ iframeDoc && entry.selector
637
+ ? getTimelineElementSelectorIndex(iframeDoc, hostEl, entry.selector)
638
+ : undefined;
639
+ entry.sourceFile = getTimelineElementSourceFile(hostEl);
640
+ }
404
641
  }
642
+ entry.key = buildTimelineElementKey({
643
+ id,
644
+ fallbackIndex: index,
645
+ domId: entry.domId,
646
+ selector: entry.selector,
647
+ selectorIndex: entry.selectorIndex,
648
+ sourceFile: entry.sourceFile,
649
+ });
405
650
  return entry;
406
651
  });
407
- // Don't downgrade: if we already have more elements with a longer duration,
408
- // skip updates that would show fewer clips (transient runtime state).
409
- const currentElements = usePlayerStore.getState().elements;
410
- const currentDuration = usePlayerStore.getState().duration;
411
652
  const rawDuration = data.durationInFrames / 30;
412
653
  // Clamp non-finite or absurdly large durations — the runtime can emit
413
654
  // Infinity when it detects a loop-inflated GSAP timeline without an
414
655
  // explicit data-duration on the root composition.
415
656
  const newDuration = Number.isFinite(rawDuration) && rawDuration < 7200 ? rawDuration : 0;
416
- if (currentElements.length > els.length && newDuration <= currentDuration) {
417
- return; // skip transient downgrade
418
- }
419
- setElements(els);
420
- // Ensure duration covers the furthest clip end so fit-zoom shows everything
421
- if (els.length > 0) {
422
- const maxEnd = Math.max(...els.map((e) => e.start + e.duration));
423
- const effectiveDur = Math.max(newDuration, maxEnd);
424
- if (Number.isFinite(effectiveDur) && effectiveDur > currentDuration)
425
- setDuration(effectiveDur);
657
+ const effectiveDuration = newDuration > 0 ? newDuration : usePlayerStore.getState().duration;
658
+ const clampedEls =
659
+ effectiveDuration > 0
660
+ ? els
661
+ .filter((element) => element.start < effectiveDuration)
662
+ .map((element) => ({
663
+ ...element,
664
+ duration: Math.min(element.duration, effectiveDuration - element.start),
665
+ }))
666
+ .filter((element) => element.duration > 0)
667
+ : els;
668
+ if (clampedEls.length > 0) {
669
+ syncTimelineElements(clampedEls, newDuration > 0 ? newDuration : undefined);
426
670
  }
427
- if (els.length > 0) setTimelineReady(true);
428
671
  },
429
- [setElements, setTimelineReady, setDuration],
672
+ [syncTimelineElements],
430
673
  );
431
674
 
432
675
  /**
@@ -504,17 +747,39 @@ export function useTimelinePlayer() {
504
747
  }
505
748
  if (!Number.isFinite(dur) || dur <= 0) return;
506
749
  if (!Number.isFinite(start)) start = 0;
750
+ const rootDuration = usePlayerStore.getState().duration;
751
+ if (Number.isFinite(rootDuration) && rootDuration > 0) {
752
+ if (start >= rootDuration) return;
753
+ dur = Math.min(dur, Math.max(0, rootDuration - start));
754
+ if (dur <= 0) return;
755
+ }
507
756
 
508
757
  const trackStr = el.getAttribute("data-track-index");
509
758
  const track = trackStr != null ? parseInt(trackStr, 10) : 0;
510
759
  const compSrc =
511
760
  el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
761
+ const selector = getTimelineElementSelector(el);
762
+ const sourceFile = getTimelineElementSourceFile(el);
763
+ const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
764
+ const id = el.id || compId;
512
765
  const entry: TimelineElement = {
513
- id: el.id || compId,
766
+ id,
767
+ key: buildTimelineElementKey({
768
+ id,
769
+ fallbackIndex: missing.length,
770
+ domId: el.id || undefined,
771
+ selector,
772
+ selectorIndex,
773
+ sourceFile,
774
+ }),
514
775
  tag: el.tagName.toLowerCase(),
515
776
  start,
516
777
  duration: dur,
517
778
  track: isNaN(track) ? 0 : track,
779
+ domId: el.id || undefined,
780
+ selector,
781
+ selectorIndex,
782
+ sourceFile,
518
783
  };
519
784
  if (compSrc) {
520
785
  entry.compositionSrc = compSrc;
@@ -551,13 +816,12 @@ export function useTimelinePlayer() {
551
816
  // Dedup: ensure no missing element duplicates an existing one
552
817
  const finalIds = new Set(updatedEls.map((e) => e.id));
553
818
  const dedupedMissing = missing.filter((m) => !finalIds.has(m.id));
554
- setElements([...updatedEls, ...dedupedMissing]);
555
- setTimelineReady(true);
819
+ syncTimelineElements([...updatedEls, ...dedupedMissing]);
556
820
  }
557
821
  } catch (err) {
558
822
  console.warn("[useTimelinePlayer] enrichMissingCompositions failed", err);
559
823
  }
560
- }, [setElements, setTimelineReady]);
824
+ }, [syncTimelineElements]);
561
825
 
562
826
  const onIframeLoad = useCallback(() => {
563
827
  unmutePreviewMedia(iframeRef.current);
@@ -613,8 +877,7 @@ export function useTimelinePlayer() {
613
877
  // Fallback: parse data-start elements directly from DOM (raw HTML without runtime)
614
878
  const els = parseTimelineFromDOM(doc, adapter.getDuration());
615
879
  if (els.length > 0) {
616
- setElements(els);
617
- setTimelineReady(true);
880
+ syncTimelineElements(els);
618
881
  }
619
882
  }
620
883
 
@@ -626,27 +889,18 @@ export function useTimelinePlayer() {
626
889
  const rootComp = doc.querySelector("[data-composition-id]");
627
890
  const rootDuration = adapter.getDuration();
628
891
  if (rootComp && rootDuration > 0) {
629
- const rootId = rootComp.getAttribute("data-composition-id") || "composition";
630
- // Derive compositionSrc from the iframe URL for thumbnail rendering.
631
- // URL pattern: /api/projects/{id}/preview/comp/{path}
632
- const iframeSrc = iframe?.src || "";
633
- const compPathMatch = iframeSrc.match(/\/preview\/comp\/(.+?)(?:\?|$)/);
634
- const compositionSrc = compPathMatch
635
- ? decodeURIComponent(compPathMatch[1])
636
- : undefined;
637
- // Always show the root composition as a single clip — guarantees
638
- // the timeline is never empty when a valid composition is loaded.
639
- setElements([
640
- {
641
- id: rootId,
642
- tag: (rootComp as HTMLElement).tagName?.toLowerCase() || "div",
643
- start: 0,
644
- duration: rootDuration,
645
- track: 0,
646
- compositionSrc,
647
- },
648
- ]);
649
- setTimelineReady(true);
892
+ const fallbackElement = buildStandaloneRootTimelineElement({
893
+ compositionId: rootComp.getAttribute("data-composition-id") || "composition",
894
+ tagName: (rootComp as HTMLElement).tagName || "div",
895
+ rootDuration,
896
+ iframeSrc: iframe?.src || "",
897
+ selector: getTimelineElementSelector(rootComp),
898
+ });
899
+ if (fallbackElement) {
900
+ // Always show the root composition as a single clip — guarantees
901
+ // the timeline is never empty when a valid composition is loaded.
902
+ syncTimelineElements([fallbackElement]);
903
+ }
650
904
  }
651
905
  }
652
906
  // The runtime will also postMessage the full timeline after all compositions load.
@@ -672,6 +926,7 @@ export function useTimelinePlayer() {
672
926
  setIsPlaying,
673
927
  processTimelineMessage,
674
928
  enrichMissingCompositions,
929
+ syncTimelineElements,
675
930
  ]);
676
931
 
677
932
  /** Save the current playback time so the next onIframeLoad restores it. */
@@ -745,12 +1000,12 @@ export function useTimelinePlayer() {
745
1000
  processTimelineMessageRef.current(data);
746
1001
  // Fill in composition hosts the manifest missed (element-reference starts)
747
1002
  enrichMissingCompositionsRef.current();
748
- // Update duration only if the new value is longer (don't downgrade during generation)
749
1003
  if (data.durationInFrames > 0 && Number.isFinite(data.durationInFrames)) {
750
1004
  const fps = 30;
751
1005
  const dur = data.durationInFrames / fps;
752
- const currentDur = usePlayerStore.getState().duration;
753
- if (dur > currentDur) usePlayerStore.getState().setDuration(dur);
1006
+ if (dur > 0 && dur < 7200) {
1007
+ usePlayerStore.getState().setDuration(dur);
1008
+ }
754
1009
  }
755
1010
  // If manifest produced 0 elements after filtering, try DOM fallback
756
1011
  if (usePlayerStore.getState().elements.length === 0) {
@@ -760,8 +1015,7 @@ export function useTimelinePlayer() {
760
1015
  if (doc && adapter) {
761
1016
  const els = parseTimelineFromDOM(doc, adapter.getDuration());
762
1017
  if (els.length > 0) {
763
- setElements(els);
764
- setTimelineReady(true);
1018
+ syncTimelineElements(els);
765
1019
  }
766
1020
  }
767
1021
  } catch (err) {
@@ -17,7 +17,7 @@ describe("usePlayerStore", () => {
17
17
  expect(state.selectedElementId).toBeNull();
18
18
  expect(state.playbackRate).toBe(1);
19
19
  expect(state.zoomMode).toBe("fit");
20
- expect(state.pixelsPerSecond).toBe(100);
20
+ expect(state.manualZoomPercent).toBe(100);
21
21
  });
22
22
  });
23
23
 
@@ -132,6 +132,19 @@ describe("usePlayerStore", () => {
132
132
  usePlayerStore.getState().updateElement("nonexistent", { start: 10 });
133
133
  expect(usePlayerStore.getState().elements[0].start).toBe(0);
134
134
  });
135
+
136
+ it("prefers the stable element key when duplicate ids exist", () => {
137
+ usePlayerStore.getState().setElements([
138
+ { id: "headline", key: "a", tag: "div", start: 0, duration: 5, track: 0 },
139
+ { id: "headline", key: "b", tag: "div", start: 5, duration: 5, track: 1 },
140
+ ]);
141
+
142
+ usePlayerStore.getState().updateElement("b", { start: 9 });
143
+
144
+ const elements = usePlayerStore.getState().elements;
145
+ expect(elements[0].start).toBe(0);
146
+ expect(elements[1].start).toBe(9);
147
+ });
135
148
  });
136
149
 
137
150
  describe("setZoomMode", () => {
@@ -147,20 +160,25 @@ describe("usePlayerStore", () => {
147
160
  });
148
161
  });
149
162
 
150
- describe("setPixelsPerSecond", () => {
151
- it("updates pixelsPerSecond", () => {
152
- usePlayerStore.getState().setPixelsPerSecond(200);
153
- expect(usePlayerStore.getState().pixelsPerSecond).toBe(200);
163
+ describe("setManualZoomPercent", () => {
164
+ it("updates the manual zoom percent", () => {
165
+ usePlayerStore.getState().setManualZoomPercent(200);
166
+ expect(usePlayerStore.getState().manualZoomPercent).toBe(200);
154
167
  });
155
168
 
156
169
  it("clamps to minimum of 10", () => {
157
- usePlayerStore.getState().setPixelsPerSecond(5);
158
- expect(usePlayerStore.getState().pixelsPerSecond).toBe(10);
170
+ usePlayerStore.getState().setManualZoomPercent(5);
171
+ expect(usePlayerStore.getState().manualZoomPercent).toBe(10);
159
172
  });
160
173
 
161
174
  it("clamps negative values to 10", () => {
162
- usePlayerStore.getState().setPixelsPerSecond(-50);
163
- expect(usePlayerStore.getState().pixelsPerSecond).toBe(10);
175
+ usePlayerStore.getState().setManualZoomPercent(-50);
176
+ expect(usePlayerStore.getState().manualZoomPercent).toBe(10);
177
+ });
178
+
179
+ it("clamps to the maximum supported zoom percent", () => {
180
+ usePlayerStore.getState().setManualZoomPercent(5000);
181
+ expect(usePlayerStore.getState().manualZoomPercent).toBe(2000);
164
182
  });
165
183
  });
166
184
 
@@ -187,11 +205,11 @@ describe("usePlayerStore", () => {
187
205
  expect(state.selectedElementId).toBeNull();
188
206
  });
189
207
 
190
- it("does not reset playbackRate, zoomMode, or pixelsPerSecond", () => {
208
+ it("does not reset playbackRate, zoomMode, or manualZoomPercent", () => {
191
209
  const store = usePlayerStore.getState();
192
210
  store.setPlaybackRate(2);
193
211
  store.setZoomMode("manual");
194
- store.setPixelsPerSecond(200);
212
+ store.setManualZoomPercent(200);
195
213
 
196
214
  usePlayerStore.getState().reset();
197
215
 
@@ -199,7 +217,7 @@ describe("usePlayerStore", () => {
199
217
  // reset() only resets the fields explicitly listed in the reset function
200
218
  expect(state.playbackRate).toBe(2);
201
219
  expect(state.zoomMode).toBe("manual");
202
- expect(state.pixelsPerSecond).toBe(200);
220
+ expect(state.manualZoomPercent).toBe(200);
203
221
  });
204
222
  });
205
223
  });