@hyperframes/studio 0.4.38 → 0.4.39

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.
@@ -64,9 +64,12 @@ function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
64
64
  }
65
65
 
66
66
  function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageElement | null {
67
- if (el instanceof HTMLMediaElement || el instanceof HTMLImageElement) return el;
67
+ const win = el.ownerDocument.defaultView ?? window;
68
+ const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
69
+ const ImageElementCtor = win.HTMLImageElement ?? globalThis.HTMLImageElement;
70
+ if (el instanceof MediaElementCtor || el instanceof ImageElementCtor) return el;
68
71
  const candidate = el.querySelector("video, audio, img");
69
- return candidate instanceof HTMLMediaElement || candidate instanceof HTMLImageElement
72
+ return candidate instanceof MediaElementCtor || candidate instanceof ImageElementCtor
70
73
  ? candidate
71
74
  : null;
72
75
  }
@@ -92,7 +95,9 @@ function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): voi
92
95
  const src = mediaEl.getAttribute("src");
93
96
  if (src) entry.src = src;
94
97
 
95
- if (!(mediaEl instanceof HTMLMediaElement)) return;
98
+ const win = mediaEl.ownerDocument.defaultView ?? window;
99
+ const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
100
+ if (typeof MediaElementCtor === "undefined" || !(mediaEl instanceof MediaElementCtor)) return;
96
101
 
97
102
  const sourceDurationAttr =
98
103
  el.getAttribute("data-source-duration") ?? mediaEl.getAttribute("data-source-duration");
@@ -165,11 +170,24 @@ export function shouldIgnorePlaybackShortcutEvent(
165
170
  );
166
171
  }
167
172
 
173
+ function getTimelineElementDisplayLabel(input: {
174
+ id?: string | null;
175
+ label?: string | null;
176
+ tag?: string | null;
177
+ }): string {
178
+ const label = input.label?.trim();
179
+ if (label) return label;
180
+ const id = input.id?.trim();
181
+ if (id) return id;
182
+ const tag = input.tag?.trim().toLowerCase();
183
+ return tag ? `${tag} clip` : "Timeline clip";
184
+ }
185
+
168
186
  /**
169
187
  * Parse [data-start] elements from a Document into TimelineElement[].
170
188
  * Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
171
189
  */
172
- function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElement[] {
190
+ export function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElement[] {
173
191
  const rootComp = doc.querySelector("[data-composition-id]");
174
192
  const nodes = doc.querySelectorAll("[data-start]");
175
193
  const els: TimelineElement[] = [];
@@ -200,17 +218,24 @@ function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElem
200
218
  const selector = getTimelineElementSelector(el);
201
219
  const sourceFile = getTimelineElementSourceFile(el);
202
220
  const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
203
- const id = el.id || compId || el.className?.split(" ")[0] || tagLower;
221
+ const label = getTimelineElementDisplayLabel({
222
+ id: el.id || compId || null,
223
+ label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
224
+ tag: tagLower,
225
+ });
226
+ const identity = buildTimelineElementIdentity({
227
+ preferredId: el.id || compId || null,
228
+ label,
229
+ fallbackIndex: els.length,
230
+ domId: el.id || undefined,
231
+ selector,
232
+ selectorIndex,
233
+ sourceFile,
234
+ });
204
235
  const entry: TimelineElement = {
205
- id,
206
- key: buildTimelineElementKey({
207
- id,
208
- fallbackIndex: els.length,
209
- domId: el.id || undefined,
210
- selector,
211
- selectorIndex,
212
- sourceFile,
213
- }),
236
+ id: identity.id,
237
+ label,
238
+ key: identity.key,
214
239
  tag: tagLower,
215
240
  start,
216
241
  duration: dur,
@@ -253,12 +278,18 @@ function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElem
253
278
  return els;
254
279
  }
255
280
 
256
- function getTimelineElementSelector(el: Element): string | undefined {
257
- if (el instanceof HTMLElement && el.id) return `#${el.id}`;
281
+ function isHtmlElement(el: Element): el is HTMLElement {
282
+ const HtmlElementCtor = el.ownerDocument.defaultView?.HTMLElement ?? globalThis.HTMLElement;
283
+ return typeof HtmlElementCtor !== "undefined" && el instanceof HtmlElementCtor;
284
+ }
285
+
286
+ export function getTimelineElementSelector(el: Element): string | undefined {
287
+ if (isHtmlElement(el) && el.id) return `#${el.id}`;
258
288
  const compId = el.getAttribute("data-composition-id");
259
289
  if (compId) return `[data-composition-id="${compId}"]`;
260
- if (el instanceof HTMLElement) {
261
- const firstClass = el.className.split(/\s+/).find(Boolean);
290
+ if (isHtmlElement(el)) {
291
+ const classes = el.className.split(/\s+/).filter(Boolean);
292
+ const firstClass = classes.find((className) => className !== "clip") ?? classes[0];
262
293
  if (firstClass) return `.${firstClass}`;
263
294
  }
264
295
  return undefined;
@@ -305,6 +336,178 @@ function buildTimelineElementKey(params: {
305
336
  return `${scope}:${params.id}:${params.fallbackIndex}`;
306
337
  }
307
338
 
339
+ function buildTimelineElementIdentity(params: {
340
+ preferredId?: string | null;
341
+ label: string;
342
+ fallbackIndex: number;
343
+ domId?: string;
344
+ selector?: string;
345
+ selectorIndex?: number;
346
+ sourceFile?: string;
347
+ }): { id: string; key: string } {
348
+ const id =
349
+ params.preferredId?.trim() ||
350
+ buildTimelineElementKey({
351
+ id: params.label,
352
+ fallbackIndex: params.fallbackIndex,
353
+ domId: params.domId,
354
+ selector: params.selector,
355
+ selectorIndex: params.selectorIndex,
356
+ sourceFile: params.sourceFile,
357
+ });
358
+ const key = buildTimelineElementKey({
359
+ id,
360
+ fallbackIndex: params.fallbackIndex,
361
+ domId: params.domId,
362
+ selector: params.selector,
363
+ selectorIndex: params.selectorIndex,
364
+ sourceFile: params.sourceFile,
365
+ });
366
+ return { id, key };
367
+ }
368
+
369
+ function getTimelineElementIdentity(element: TimelineElement): string {
370
+ return element.key ?? element.id;
371
+ }
372
+
373
+ function getTimelineDomNodes(doc: Document): Element[] {
374
+ const rootComp = doc.querySelector("[data-composition-id]");
375
+ return Array.from(doc.querySelectorAll("[data-start]")).filter((node) => node !== rootComp);
376
+ }
377
+
378
+ function numbersNearlyEqual(a: number, b: number): boolean {
379
+ return Math.abs(a - b) < 0.001;
380
+ }
381
+
382
+ function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean {
383
+ const tagName = clip.tagName?.toLowerCase();
384
+ if (tagName && node.tagName.toLowerCase() !== tagName) return false;
385
+
386
+ const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
387
+ if (Number.isFinite(start) && !numbersNearlyEqual(start, clip.start)) return false;
388
+
389
+ const duration = Number.parseFloat(node.getAttribute("data-duration") ?? "");
390
+ if (Number.isFinite(duration) && !numbersNearlyEqual(duration, clip.duration)) return false;
391
+
392
+ const track = Number.parseInt(node.getAttribute("data-track-index") ?? "", 10);
393
+ if (Number.isFinite(track) && track !== clip.track) return false;
394
+
395
+ return true;
396
+ }
397
+
398
+ export function findTimelineDomNodeForClip(
399
+ doc: Document,
400
+ clip: ClipManifestClip,
401
+ fallbackIndex: number,
402
+ usedNodes = new Set<Element>(),
403
+ ): Element | null {
404
+ const byIdentity = clip.id ? findTimelineDomNode(doc, clip.id) : null;
405
+ if (byIdentity && !usedNodes.has(byIdentity)) return byIdentity;
406
+
407
+ const candidates = getTimelineDomNodes(doc).filter((node) => !usedNodes.has(node));
408
+ const exact = candidates.find((node) => nodeMatchesManifestClip(node, clip));
409
+ if (exact) return exact;
410
+
411
+ return candidates[fallbackIndex] ?? null;
412
+ }
413
+
414
+ export function createTimelineElementFromManifestClip(params: {
415
+ clip: ClipManifestClip;
416
+ fallbackIndex: number;
417
+ doc?: Document | null;
418
+ hostEl?: Element | null;
419
+ }): TimelineElement {
420
+ const { clip, fallbackIndex, doc } = params;
421
+ let hostEl = params.hostEl ?? null;
422
+ const label = getTimelineElementDisplayLabel({
423
+ id: clip.id,
424
+ label: clip.label,
425
+ tag: clip.tagName || clip.kind,
426
+ });
427
+
428
+ let domId: string | undefined;
429
+ let selector: string | undefined;
430
+ let selectorIndex: number | undefined;
431
+ let sourceFile: string | undefined;
432
+
433
+ if (hostEl) {
434
+ domId = hostEl.id || undefined;
435
+ selector = getTimelineElementSelector(hostEl);
436
+ selectorIndex =
437
+ doc && selector ? getTimelineElementSelectorIndex(doc, hostEl, selector) : undefined;
438
+ sourceFile = getTimelineElementSourceFile(hostEl);
439
+ }
440
+
441
+ const identity = buildTimelineElementIdentity({
442
+ preferredId: clip.id,
443
+ label,
444
+ fallbackIndex,
445
+ domId,
446
+ selector,
447
+ selectorIndex,
448
+ sourceFile,
449
+ });
450
+ const entry: TimelineElement = {
451
+ id: identity.id,
452
+ label,
453
+ key: identity.key,
454
+ tag: clip.tagName || clip.kind,
455
+ start: clip.start,
456
+ duration: clip.duration,
457
+ track: clip.track,
458
+ domId,
459
+ selector,
460
+ selectorIndex,
461
+ sourceFile,
462
+ };
463
+
464
+ if (hostEl) {
465
+ applyMediaMetadataFromElement(entry, hostEl);
466
+ }
467
+ if (clip.assetUrl) entry.src = clip.assetUrl;
468
+ if (clip.kind === "composition" && clip.compositionId) {
469
+ let resolvedSrc = clip.compositionSrc;
470
+ if (!resolvedSrc) {
471
+ hostEl = doc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
472
+ resolvedSrc =
473
+ hostEl?.getAttribute("data-composition-src") ??
474
+ hostEl?.getAttribute("data-composition-file") ??
475
+ null;
476
+ }
477
+ if (resolvedSrc) {
478
+ entry.compositionSrc = resolvedSrc;
479
+ } else if (hostEl) {
480
+ const innerVideo = hostEl.querySelector("video[src]");
481
+ if (innerVideo) {
482
+ entry.src = innerVideo.getAttribute("src") || undefined;
483
+ entry.tag = "video";
484
+ }
485
+ }
486
+ if (hostEl) {
487
+ entry.domId = hostEl.id || undefined;
488
+ entry.selector = getTimelineElementSelector(hostEl);
489
+ entry.selectorIndex =
490
+ doc && entry.selector
491
+ ? getTimelineElementSelectorIndex(doc, hostEl, entry.selector)
492
+ : undefined;
493
+ entry.sourceFile = getTimelineElementSourceFile(hostEl);
494
+ const nextIdentity = buildTimelineElementIdentity({
495
+ preferredId: clip.id,
496
+ label,
497
+ fallbackIndex,
498
+ domId: entry.domId,
499
+ selector: entry.selector,
500
+ selectorIndex: entry.selectorIndex,
501
+ sourceFile: entry.sourceFile,
502
+ });
503
+ entry.id = nextIdentity.id;
504
+ entry.key = nextIdentity.key;
505
+ }
506
+ }
507
+
508
+ return entry;
509
+ }
510
+
308
511
  function findTimelineDomNode(doc: Document, id: string): Element | null {
309
512
  return (
310
513
  doc.getElementById(id) ??
@@ -333,6 +536,10 @@ export function buildStandaloneRootTimelineElement(params: {
333
536
 
334
537
  return {
335
538
  id: params.compositionId,
539
+ label: getTimelineElementDisplayLabel({
540
+ id: params.compositionId,
541
+ tag: params.tagName,
542
+ }),
336
543
  key: buildTimelineElementKey({
337
544
  id: params.compositionId,
338
545
  fallbackIndex: 0,
@@ -454,8 +661,10 @@ export function mergeTimelineElementsPreservingDowngrades(
454
661
  return nextElements;
455
662
  }
456
663
 
457
- const nextIds = new Set(nextElements.map((element) => element.id));
458
- const preserved = currentElements.filter((element) => !nextIds.has(element.id));
664
+ const nextIdentities = new Set(nextElements.map(getTimelineElementIdentity));
665
+ const preserved = currentElements.filter(
666
+ (element) => !nextIdentities.has(getTimelineElementIdentity(element)),
667
+ );
459
668
  if (preserved.length === 0) return nextElements;
460
669
  return [...nextElements, ...preserved];
461
670
  }
@@ -822,85 +1031,24 @@ export function useTimelinePlayer() {
822
1031
  const filtered = data.clips.filter(
823
1032
  (clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId),
824
1033
  );
1034
+ let iframeDoc: Document | null = null;
1035
+ try {
1036
+ iframeDoc = iframeRef.current?.contentDocument ?? null;
1037
+ } catch {
1038
+ iframeDoc = null;
1039
+ }
1040
+ const usedHostEls = new Set<Element>();
825
1041
  const els: TimelineElement[] = filtered.map((clip, index) => {
826
- let hostEl: Element | null = null;
827
- const id = clip.id || clip.label || clip.tagName || "element";
828
- const entry: TimelineElement = {
829
- id,
830
- tag: clip.tagName || clip.kind,
831
- start: clip.start,
832
- duration: clip.duration,
833
- track: clip.track,
834
- };
835
- try {
836
- const iframeDoc = iframeRef.current?.contentDocument;
837
- if (iframeDoc && entry.id) {
838
- hostEl = findTimelineDomNode(iframeDoc, entry.id);
839
- }
840
- } catch {
841
- /* cross-origin */
842
- }
843
- if (hostEl) {
844
- const iframeDoc = iframeRef.current?.contentDocument;
845
- entry.domId = hostEl.id || undefined;
846
- entry.selector = getTimelineElementSelector(hostEl);
847
- entry.selectorIndex =
848
- iframeDoc && entry.selector
849
- ? getTimelineElementSelectorIndex(iframeDoc, hostEl, entry.selector)
850
- : undefined;
851
- entry.sourceFile = getTimelineElementSourceFile(hostEl);
852
- applyMediaMetadataFromElement(entry, hostEl);
853
- }
854
- if (clip.assetUrl) entry.src = clip.assetUrl;
855
- if (clip.kind === "composition" && clip.compositionId) {
856
- // The bundler renames data-composition-src to data-composition-file
857
- // after inlining, so the clip manifest may not have compositionSrc.
858
- // Fall back to reading data-composition-file from the DOM.
859
- let resolvedSrc = clip.compositionSrc;
860
- let hostEl: Element | null = null;
861
- if (!resolvedSrc) {
862
- try {
863
- const iframeDoc = iframeRef.current?.contentDocument;
864
- hostEl =
865
- iframeDoc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
866
- resolvedSrc =
867
- hostEl?.getAttribute("data-composition-src") ??
868
- hostEl?.getAttribute("data-composition-file") ??
869
- null;
870
- } catch {
871
- /* cross-origin */
872
- }
873
- }
874
- if (resolvedSrc) {
875
- entry.compositionSrc = resolvedSrc;
876
- } else if (hostEl) {
877
- // Inline composition (no external file) — expose inner video for thumbnails
878
- const innerVideo = hostEl.querySelector("video[src]");
879
- if (innerVideo) {
880
- entry.src = innerVideo.getAttribute("src") || undefined;
881
- entry.tag = "video";
882
- }
883
- }
884
- if (hostEl) {
885
- const iframeDoc = iframeRef.current?.contentDocument;
886
- entry.domId = hostEl.id || undefined;
887
- entry.selector = getTimelineElementSelector(hostEl);
888
- entry.selectorIndex =
889
- iframeDoc && entry.selector
890
- ? getTimelineElementSelectorIndex(iframeDoc, hostEl, entry.selector)
891
- : undefined;
892
- entry.sourceFile = getTimelineElementSourceFile(hostEl);
893
- }
894
- }
895
- entry.key = buildTimelineElementKey({
896
- id,
1042
+ const hostEl = iframeDoc
1043
+ ? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls)
1044
+ : null;
1045
+ if (hostEl) usedHostEls.add(hostEl);
1046
+ return createTimelineElementFromManifestClip({
1047
+ clip,
897
1048
  fallbackIndex: index,
898
- domId: entry.domId,
899
- selector: entry.selector,
900
- selectorIndex: entry.selectorIndex,
901
- sourceFile: entry.sourceFile,
1049
+ doc: iframeDoc,
1050
+ hostEl,
902
1051
  });
903
- return entry;
904
1052
  });
905
1053
  const rawDuration = data.durationInFrames / 30;
906
1054
  // Clamp non-finite or absurdly large durations — the runtime can emit
@@ -1014,17 +1162,24 @@ export function useTimelinePlayer() {
1014
1162
  const selector = getTimelineElementSelector(el);
1015
1163
  const sourceFile = getTimelineElementSourceFile(el);
1016
1164
  const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
1017
- const id = el.id || compId;
1165
+ const label = getTimelineElementDisplayLabel({
1166
+ id: el.id || compId || null,
1167
+ label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
1168
+ tag: el.tagName,
1169
+ });
1170
+ const identity = buildTimelineElementIdentity({
1171
+ preferredId: el.id || compId || null,
1172
+ label,
1173
+ fallbackIndex: missing.length,
1174
+ domId: el.id || undefined,
1175
+ selector,
1176
+ selectorIndex,
1177
+ sourceFile,
1178
+ });
1018
1179
  const entry: TimelineElement = {
1019
- id,
1020
- key: buildTimelineElementKey({
1021
- id,
1022
- fallbackIndex: missing.length,
1023
- domId: el.id || undefined,
1024
- selector,
1025
- selectorIndex,
1026
- sourceFile,
1027
- }),
1180
+ id: identity.id,
1181
+ label,
1182
+ key: identity.key,
1028
1183
  tag: el.tagName.toLowerCase(),
1029
1184
  start,
1030
1185
  duration: dur,
@@ -2,6 +2,7 @@ import { create } from "zustand";
2
2
 
3
3
  export interface TimelineElement {
4
4
  id: string;
5
+ label?: string;
5
6
  key?: string;
6
7
  tag: string;
7
8
  start: number;