@hyperframes/studio 0.5.0-alpha.5 → 0.5.0-alpha.6

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/index.html CHANGED
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <title>HyperFrames Studio</title>
7
- <script type="module" crossorigin src="/assets/index-Ba6SZOXW.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-BpcIkyVP.css">
7
+ <script type="module" crossorigin src="/assets/index-CDSQavT7.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-0Zt0t13W.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.5.0-alpha.5",
3
+ "version": "0.5.0-alpha.6",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,8 +32,8 @@
32
32
  "@phosphor-icons/react": "^2.1.10",
33
33
  "codemirror": "^6.0.1",
34
34
  "motion": "^12.38.0",
35
- "@hyperframes/core": "0.5.0-alpha.5",
36
- "@hyperframes/player": "0.5.0-alpha.5"
35
+ "@hyperframes/core": "0.5.0-alpha.6",
36
+ "@hyperframes/player": "0.5.0-alpha.6"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/react": "^19.0.0",
@@ -47,7 +47,7 @@
47
47
  "vite": "^6.4.2",
48
48
  "vitest": "^3.2.4",
49
49
  "zustand": "^5.0.0",
50
- "@hyperframes/producer": "0.5.0-alpha.5"
50
+ "@hyperframes/producer": "0.5.0-alpha.6"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "react": "^18.0.0 || ^19.0.0",
package/src/App.tsx CHANGED
@@ -841,6 +841,8 @@ export function StudioApp() {
841
841
  label={el.id || el.tag}
842
842
  labelColor={style.label}
843
843
  accentColor={style.clip}
844
+ selector={el.selector}
845
+ selectorIndex={el.selectorIndex}
844
846
  seekTime={el.start}
845
847
  duration={el.duration}
846
848
  />
@@ -901,6 +903,8 @@ export function StudioApp() {
901
903
  label={el.id || el.tag}
902
904
  labelColor={style.label}
903
905
  accentColor={style.clip}
906
+ selector={el.selector}
907
+ selectorIndex={el.selectorIndex}
904
908
  seekTime={el.start}
905
909
  duration={el.duration}
906
910
  />
@@ -1947,7 +1951,7 @@ export function StudioApp() {
1947
1951
  );
1948
1952
 
1949
1953
  const handlePreviewCanvasMouseDown = useCallback(
1950
- (e: React.MouseEvent<HTMLDivElement>) => {
1954
+ (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
1951
1955
  const iframe = previewIframeRef.current;
1952
1956
  if (!iframe || captionEditMode) return;
1953
1957
  const target = getPreviewTargetFromPointer(iframe, e.clientX, e.clientY);
@@ -1959,7 +1963,7 @@ export function StudioApp() {
1959
1963
  e.preventDefault();
1960
1964
  e.stopPropagation();
1961
1965
  const nextSelection = buildDomSelectionFromTarget(target, {
1962
- preferClipAncestor: true,
1966
+ preferClipAncestor: options?.preferClipAncestor ?? true,
1963
1967
  });
1964
1968
  if (!nextSelection) {
1965
1969
  lastPreviewClickRef.current = null;
@@ -14,7 +14,10 @@ interface OverlayRect {
14
14
  interface DomEditOverlayProps {
15
15
  iframeRef: RefObject<HTMLIFrameElement | null>;
16
16
  selection: DomEditSelection | null;
17
- onCanvasMouseDown: (event: React.MouseEvent<HTMLDivElement>) => void;
17
+ onCanvasMouseDown: (
18
+ event: React.MouseEvent<HTMLDivElement>,
19
+ options?: { preferClipAncestor?: boolean },
20
+ ) => void;
18
21
  onCanvasDoubleClick: (event: React.MouseEvent<HTMLDivElement>) => void;
19
22
  onSelectedDoubleClick: () => void;
20
23
  onBlockedMove: (selection: DomEditSelection) => void;
@@ -85,10 +88,21 @@ function selectionCacheKey(
85
88
  ].join("|");
86
89
  }
87
90
 
91
+ function restoreInlineStyle(
92
+ element: HTMLElement,
93
+ property: "left" | "top" | "width" | "height",
94
+ value: string,
95
+ ) {
96
+ if (value) element.style.setProperty(property, value);
97
+ else element.style.removeProperty(property);
98
+ }
99
+
88
100
  interface GestureState {
89
101
  kind: GestureKind;
90
102
  startX: number;
91
103
  startY: number;
104
+ initialStyleLeft: string;
105
+ initialStyleTop: string;
92
106
  originLeft: number;
93
107
  originTop: number;
94
108
  originWidth: number;
@@ -226,6 +240,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
226
240
  kind,
227
241
  startX: e.clientX,
228
242
  startY: e.clientY,
243
+ initialStyleLeft: sel.element.style.left,
244
+ initialStyleTop: sel.element.style.top,
229
245
  originLeft: rect.left,
230
246
  originTop: rect.top,
231
247
  originWidth: rect.width,
@@ -277,9 +293,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
277
293
  }
278
294
  };
279
295
 
280
- const onPointerUp = () => {
296
+ const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
281
297
  const g = gestureRef.current;
282
298
  const sel = selectionRef.current;
299
+ const box = boxRef.current;
283
300
  blockedMoveRef.current = null;
284
301
  if (!g || !sel) {
285
302
  gestureRef.current = null;
@@ -290,6 +307,21 @@ export const DomEditOverlay = memo(function DomEditOverlay({
290
307
  gestureRef.current = null;
291
308
  rafPausedRef.current = false;
292
309
 
310
+ const movedDistance = Math.hypot(e.clientX - g.startX, e.clientY - g.startY);
311
+ if (g.kind === "drag" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) {
312
+ restoreInlineStyle(sel.element, "left", g.initialStyleLeft);
313
+ restoreInlineStyle(sel.element, "top", g.initialStyleTop);
314
+ if (box) {
315
+ box.style.left = `${g.originLeft}px`;
316
+ box.style.top = `${g.originTop}px`;
317
+ }
318
+ suppressNextBoxClickRef.current = true;
319
+ onCanvasMouseDown(e as unknown as React.MouseEvent<HTMLDivElement>, {
320
+ preferClipAncestor: false,
321
+ });
322
+ return;
323
+ }
324
+
293
325
  if (g.kind === "drag") {
294
326
  const finalLeft = Number.parseFloat(sel.element.style.left) || g.actualLeft;
295
327
  const finalTop = Number.parseFloat(sel.element.style.top) || g.actualTop;
@@ -320,7 +352,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
320
352
  const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
321
353
  const target = event.target as HTMLElement | null;
322
354
  if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
323
- onCanvasMouseDown(event);
355
+ onCanvasMouseDown(event, { preferClipAncestor: false });
324
356
  };
325
357
 
326
358
  const handleOverlayDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {
@@ -339,7 +371,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
339
371
  event.stopPropagation();
340
372
  return;
341
373
  }
342
- onCanvasMouseDown(event);
374
+ onCanvasMouseDown(event, { preferClipAncestor: false });
343
375
  };
344
376
 
345
377
  const clearPointerState = () => {
@@ -108,6 +108,61 @@ describe("resolveDomEditCapabilities", () => {
108
108
  });
109
109
  });
110
110
 
111
+ it("treats identity transforms left behind by animation libraries as movable", () => {
112
+ expect(
113
+ resolveDomEditCapabilities({
114
+ selector: "#card",
115
+ inlineStyles: {
116
+ left: "120px",
117
+ top: "80px",
118
+ width: "240px",
119
+ height: "140px",
120
+ },
121
+ computedStyles: {
122
+ position: "absolute",
123
+ left: "120px",
124
+ top: "80px",
125
+ width: "240px",
126
+ height: "140px",
127
+ transform: "matrix(1, 0, 0, 1, 0, 0)",
128
+ },
129
+ isCompositionHost: false,
130
+ isMasterView: false,
131
+ }),
132
+ ).toMatchObject({
133
+ canMove: true,
134
+ canResize: true,
135
+ canDetachFromLayout: false,
136
+ });
137
+ });
138
+
139
+ it("treats identity matrix3d transforms as movable", () => {
140
+ expect(
141
+ resolveDomEditCapabilities({
142
+ selector: "#card",
143
+ inlineStyles: {
144
+ left: "120px",
145
+ top: "80px",
146
+ width: "240px",
147
+ height: "140px",
148
+ },
149
+ computedStyles: {
150
+ position: "absolute",
151
+ left: "120px",
152
+ top: "80px",
153
+ width: "240px",
154
+ height: "140px",
155
+ transform: "matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)",
156
+ },
157
+ isCompositionHost: false,
158
+ isMasterView: false,
159
+ }),
160
+ ).toMatchObject({
161
+ canMove: true,
162
+ canResize: true,
163
+ });
164
+ });
165
+
111
166
  it("allows imported absolute media to resize from computed px geometry", () => {
112
167
  expect(
113
168
  resolveDomEditCapabilities({
@@ -228,6 +283,24 @@ describe("resolveDomEditSelection", () => {
228
283
  expect(selection?.selector).toBe("#card");
229
284
  });
230
285
 
286
+ it("can resolve the exact child when clip-ancestor preference is disabled", () => {
287
+ const document = createDocument(`
288
+ <section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
289
+ <p id="copy">Hello</p>
290
+ </section>
291
+ `);
292
+
293
+ const child = document.getElementById("copy") as HTMLElement;
294
+ const selection = resolveDomEditSelection(child, {
295
+ activeCompositionPath: null,
296
+ isMasterView: false,
297
+ preferClipAncestor: false,
298
+ });
299
+
300
+ expect(selection?.id).toBe("copy");
301
+ expect(selection?.selector).toBe("#copy");
302
+ });
303
+
231
304
  it("collects simple child text blocks as separate editable fields", () => {
232
305
  const document = createDocument(`
233
306
  <section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
@@ -93,6 +93,32 @@ function parsePx(value: string | undefined): number | null {
93
93
  return Number.isFinite(parsed) ? parsed : null;
94
94
  }
95
95
 
96
+ function isIdentityTransform(value: string | undefined): boolean {
97
+ const transform = (value ?? "none").trim();
98
+ if (!transform || transform === "none") return true;
99
+
100
+ const matrix = transform.match(/^matrix\(([^)]+)\)$/i);
101
+ if (matrix) {
102
+ const values = matrix[1].split(",").map((part) => Number.parseFloat(part.trim()));
103
+ if (values.length !== 6 || values.some((part) => !Number.isFinite(part))) return false;
104
+ return (
105
+ Math.abs(values[0] - 1) < 0.0001 &&
106
+ Math.abs(values[1]) < 0.0001 &&
107
+ Math.abs(values[2]) < 0.0001 &&
108
+ Math.abs(values[3] - 1) < 0.0001 &&
109
+ Math.abs(values[4]) < 0.0001 &&
110
+ Math.abs(values[5]) < 0.0001
111
+ );
112
+ }
113
+
114
+ const matrix3d = transform.match(/^matrix3d\(([^)]+)\)$/i);
115
+ if (!matrix3d) return false;
116
+ const values = matrix3d[1].split(",").map((part) => Number.parseFloat(part.trim()));
117
+ if (values.length !== 16 || values.some((part) => !Number.isFinite(part))) return false;
118
+ const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
119
+ return values.every((part, index) => Math.abs(part - identity[index]) < 0.0001);
120
+ }
121
+
96
122
  function isClipClassName(className: string | undefined): boolean {
97
123
  return Boolean(className?.split(/\s+/).includes("clip"));
98
124
  }
@@ -426,13 +452,13 @@ export function resolveDomEditCapabilities(args: {
426
452
  const top = parsePx(args.inlineStyles.top) ?? parsePx(args.computedStyles.top);
427
453
  const width = parsePx(args.inlineStyles.width) ?? parsePx(args.computedStyles.width);
428
454
  const height = parsePx(args.inlineStyles.height) ?? parsePx(args.computedStyles.height);
429
- const transform = (args.computedStyles.transform ?? "none").trim();
455
+ const hasTransformDrivenGeometry = !isIdentityTransform(args.computedStyles.transform);
430
456
 
431
457
  const canMove =
432
458
  (position === "absolute" || position === "fixed") &&
433
459
  left != null &&
434
460
  top != null &&
435
- transform === "none";
461
+ !hasTransformDrivenGeometry;
436
462
 
437
463
  const canResize = canMove && (width != null || height != null);
438
464
  const isBlockishLayer =
@@ -442,7 +468,7 @@ export function resolveDomEditCapabilities(args: {
442
468
  isBlockishDisplay(args.computedStyles.display);
443
469
  const canDetachFromLayout =
444
470
  !canMove &&
445
- transform === "none" &&
471
+ !hasTransformDrivenGeometry &&
446
472
  isBlockishLayer &&
447
473
  (!isInlineTextTag(args.tagName) || isClipClassName(args.className));
448
474
  const reasonIfDisabled = !canMove
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildCompositionThumbnailUrl } from "./CompositionThumbnail";
3
+
4
+ describe("buildCompositionThumbnailUrl", () => {
5
+ it("includes selector and occurrence index for precise element thumbnails", () => {
6
+ expect(
7
+ buildCompositionThumbnailUrl({
8
+ previewUrl: "/api/projects/demo/preview",
9
+ seekTime: 1,
10
+ duration: 2,
11
+ selector: ".card",
12
+ selectorIndex: 2,
13
+ origin: "http://localhost:3000",
14
+ }),
15
+ ).toBe(
16
+ "http://localhost:3000/api/projects/demo/thumbnail/index.html?t=2.00&v=v2&selector=.card&selectorIndex=2",
17
+ );
18
+ });
19
+ });
@@ -7,6 +7,7 @@ interface CompositionThumbnailProps {
7
7
  labelColor: string;
8
8
  accentColor?: string;
9
9
  selector?: string;
10
+ selectorIndex?: number;
10
11
  seekTime?: number;
11
12
  duration?: number;
12
13
  width?: number;
@@ -16,12 +17,44 @@ interface CompositionThumbnailProps {
16
17
  const CLIP_HEIGHT = 66;
17
18
  const THUMBNAIL_URL_VERSION = "v2";
18
19
 
20
+ export function buildCompositionThumbnailUrl({
21
+ previewUrl,
22
+ seekTime = 2,
23
+ duration = 5,
24
+ selector,
25
+ selectorIndex,
26
+ origin,
27
+ }: {
28
+ previewUrl: string;
29
+ seekTime?: number;
30
+ duration?: number;
31
+ selector?: string;
32
+ selectorIndex?: number;
33
+ origin: string;
34
+ }): string {
35
+ const thumbnailBase = previewUrl
36
+ .replace("/preview/comp/", "/thumbnail/")
37
+ .replace(/\/preview$/, "/thumbnail/index.html");
38
+ const midTime = seekTime + duration / 2;
39
+ const thumbnailUrl = new URL(thumbnailBase, origin);
40
+ thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
41
+ thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
42
+ if (selector) {
43
+ thumbnailUrl.searchParams.set("selector", selector);
44
+ if (selectorIndex != null && selectorIndex > 0) {
45
+ thumbnailUrl.searchParams.set("selectorIndex", String(selectorIndex));
46
+ }
47
+ }
48
+ return thumbnailUrl.toString();
49
+ }
50
+
19
51
  export const CompositionThumbnail = memo(function CompositionThumbnail({
20
52
  previewUrl,
21
53
  label,
22
54
  labelColor,
23
55
  accentColor = "#6B7280",
24
56
  selector,
57
+ selectorIndex,
25
58
  seekTime = 2,
26
59
  duration = 5,
27
60
  }: CompositionThumbnailProps) {
@@ -48,15 +81,14 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
48
81
  roRef.current?.disconnect();
49
82
  });
50
83
 
51
- const thumbnailBase = previewUrl
52
- .replace("/preview/comp/", "/thumbnail/")
53
- .replace(/\/preview$/, "/thumbnail/index.html");
54
- const midTime = seekTime + duration / 2;
55
- const thumbnailUrl = new URL(thumbnailBase, window.location.origin);
56
- thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
57
- thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
58
- if (selector) thumbnailUrl.searchParams.set("selector", selector);
59
- const url = thumbnailUrl.toString();
84
+ const url = buildCompositionThumbnailUrl({
85
+ previewUrl,
86
+ seekTime,
87
+ duration,
88
+ selector,
89
+ selectorIndex,
90
+ origin: window.location.origin,
91
+ });
60
92
  const frameW = Math.max(48, Math.round(CLIP_HEIGHT * aspect));
61
93
  const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
62
94
 
@@ -66,7 +98,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
66
98
  src={url}
67
99
  alt=""
68
100
  draggable={false}
69
- loading="lazy"
101
+ loading="eager"
70
102
  onLoad={(e) => {
71
103
  const img = e.currentTarget;
72
104
  if (img.naturalWidth > 0 && img.naturalHeight > 0) {
@@ -248,7 +248,7 @@ describe("getTimelineEditCapabilities", () => {
248
248
  });
249
249
  });
250
250
 
251
- it("disables move and trims for generic motion clips even when patchable", () => {
251
+ it("allows moving generic motion clips while keeping trims blocked", () => {
252
252
  expect(
253
253
  getTimelineEditCapabilities({
254
254
  tag: "section",
@@ -256,7 +256,7 @@ describe("getTimelineEditCapabilities", () => {
256
256
  selector: ".feature-card",
257
257
  }),
258
258
  ).toEqual({
259
- canMove: false,
259
+ canMove: true,
260
260
  canTrimStart: false,
261
261
  canTrimEnd: false,
262
262
  });
@@ -233,7 +233,7 @@ export function getTimelineEditCapabilities(input: {
233
233
  const hasFiniteDuration = Number.isFinite(input.duration) && input.duration > 0;
234
234
  const hasDeterministicWindow = isDeterministicTimelineWindow(input);
235
235
  return {
236
- canMove: canPatch && hasDeterministicWindow,
236
+ canMove: canPatch && (hasDeterministicWindow || hasFiniteDuration),
237
237
  canTrimEnd: canPatch && hasFiniteDuration && hasDeterministicWindow,
238
238
  canTrimStart: canPatch && hasFiniteDuration && canOffsetTrimClipStart(input),
239
239
  };
@@ -1,10 +1,37 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import { Window } from "happy-dom";
2
3
  import {
3
4
  buildStandaloneRootTimelineElement,
5
+ findTimelineDomNodeForClip,
6
+ getTimelineElementSelector,
7
+ type ClipManifestClip,
4
8
  mergeTimelineElementsPreservingDowngrades,
5
9
  resolveStandaloneRootCompositionSrc,
6
10
  } from "./useTimelinePlayer";
7
11
 
12
+ function createDocument(markup: string): Document {
13
+ const window = new Window();
14
+ window.document.body.innerHTML = markup;
15
+ return window.document;
16
+ }
17
+
18
+ function createClip(overrides: Partial<ClipManifestClip>): ClipManifestClip {
19
+ return {
20
+ id: null,
21
+ label: "",
22
+ start: 0,
23
+ duration: 4,
24
+ track: 0,
25
+ kind: "element",
26
+ tagName: "div",
27
+ compositionId: null,
28
+ parentCompositionId: null,
29
+ compositionSrc: null,
30
+ assetUrl: null,
31
+ ...overrides,
32
+ };
33
+ }
34
+
8
35
  describe("buildStandaloneRootTimelineElement", () => {
9
36
  it("includes selector and source metadata for standalone composition fallback clips", () => {
10
37
  expect(
@@ -65,6 +92,38 @@ describe("resolveStandaloneRootCompositionSrc", () => {
65
92
  });
66
93
  });
67
94
 
95
+ describe("findTimelineDomNodeForClip", () => {
96
+ it("matches anonymous manifest clips back to repeated DOM nodes in timeline order", () => {
97
+ const doc = createDocument(`
98
+ <div data-composition-id="main" data-start="0" data-duration="8">
99
+ <section id="identity-card" class="clip identity-card" data-start="0" data-duration="4" data-track-index="0"></section>
100
+ <div class="clip duplicate-card first" data-start="0" data-duration="4" data-track-index="1"></div>
101
+ <div class="clip duplicate-card second" data-start="0" data-duration="4" data-track-index="2"></div>
102
+ </div>
103
+ `);
104
+ const used = new Set<Element>();
105
+
106
+ const first = findTimelineDomNodeForClip(
107
+ doc,
108
+ createClip({ id: "__node__index_2", track: 1 }),
109
+ 1,
110
+ used,
111
+ ) as HTMLElement;
112
+ used.add(first);
113
+ const second = findTimelineDomNodeForClip(
114
+ doc,
115
+ createClip({ id: "__node__index_3", track: 2 }),
116
+ 2,
117
+ used,
118
+ ) as HTMLElement;
119
+
120
+ expect(first.className).toBe("clip duplicate-card first");
121
+ expect(second.className).toBe("clip duplicate-card second");
122
+ expect(getTimelineElementSelector(first)).toBe(".duplicate-card");
123
+ expect(getTimelineElementSelector(second)).toBe(".duplicate-card");
124
+ });
125
+ });
126
+
68
127
  describe("mergeTimelineElementsPreservingDowngrades", () => {
69
128
  it("preserves missing current elements when a shorter manifest arrives", () => {
70
129
  expect(
@@ -20,7 +20,7 @@ interface TimelineLike {
20
20
  isActive: () => boolean;
21
21
  }
22
22
 
23
- interface ClipManifestClip {
23
+ export interface ClipManifestClip {
24
24
  id: string | null;
25
25
  label: string;
26
26
  start: number;
@@ -193,12 +193,18 @@ function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElem
193
193
  return els;
194
194
  }
195
195
 
196
- function getTimelineElementSelector(el: Element): string | undefined {
197
- if (el instanceof HTMLElement && el.id) return `#${el.id}`;
196
+ function isHtmlElement(el: Element): el is HTMLElement {
197
+ const HtmlElementCtor = el.ownerDocument.defaultView?.HTMLElement ?? globalThis.HTMLElement;
198
+ return typeof HtmlElementCtor !== "undefined" && el instanceof HtmlElementCtor;
199
+ }
200
+
201
+ export function getTimelineElementSelector(el: Element): string | undefined {
202
+ if (isHtmlElement(el) && el.id) return `#${el.id}`;
198
203
  const compId = el.getAttribute("data-composition-id");
199
204
  if (compId) return `[data-composition-id="${compId}"]`;
200
- if (el instanceof HTMLElement) {
201
- const firstClass = el.className.split(/\s+/).find(Boolean);
205
+ if (isHtmlElement(el)) {
206
+ const classes = el.className.split(/\s+/).filter(Boolean);
207
+ const firstClass = classes.find((className) => className !== "clip") ?? classes[0];
202
208
  if (firstClass) return `.${firstClass}`;
203
209
  }
204
210
  return undefined;
@@ -244,6 +250,48 @@ function buildTimelineElementKey(params: {
244
250
  if (params.selector) return `${scope}:${params.selector}:${params.selectorIndex ?? 0}`;
245
251
  return `${scope}:${params.id}:${params.fallbackIndex}`;
246
252
  }
253
+
254
+ function getTimelineDomNodes(doc: Document): Element[] {
255
+ const rootComp = doc.querySelector("[data-composition-id]");
256
+ return Array.from(doc.querySelectorAll("[data-start]")).filter((node) => node !== rootComp);
257
+ }
258
+
259
+ function numbersNearlyEqual(a: number, b: number): boolean {
260
+ return Math.abs(a - b) < 0.001;
261
+ }
262
+
263
+ function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean {
264
+ const tagName = clip.tagName?.toLowerCase();
265
+ if (tagName && node.tagName.toLowerCase() !== tagName) return false;
266
+
267
+ const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
268
+ if (Number.isFinite(start) && !numbersNearlyEqual(start, clip.start)) return false;
269
+
270
+ const duration = Number.parseFloat(node.getAttribute("data-duration") ?? "");
271
+ if (Number.isFinite(duration) && !numbersNearlyEqual(duration, clip.duration)) return false;
272
+
273
+ const track = Number.parseInt(node.getAttribute("data-track-index") ?? "", 10);
274
+ if (Number.isFinite(track) && track !== clip.track) return false;
275
+
276
+ return true;
277
+ }
278
+
279
+ export function findTimelineDomNodeForClip(
280
+ doc: Document,
281
+ clip: ClipManifestClip,
282
+ fallbackIndex: number,
283
+ usedNodes = new Set<Element>(),
284
+ ): Element | null {
285
+ const byIdentity = clip.id ? findTimelineDomNode(doc, clip.id) : null;
286
+ if (byIdentity && !usedNodes.has(byIdentity)) return byIdentity;
287
+
288
+ const candidates = getTimelineDomNodes(doc).filter((node) => !usedNodes.has(node));
289
+ const exact = candidates.find((node) => nodeMatchesManifestClip(node, clip));
290
+ if (exact) return exact;
291
+
292
+ return candidates[fallbackIndex] ?? null;
293
+ }
294
+
247
295
  function findTimelineDomNode(doc: Document, id: string): Element | null {
248
296
  return (
249
297
  doc.getElementById(id) ??
@@ -571,8 +619,18 @@ export function useTimelinePlayer() {
571
619
  const filtered = data.clips.filter(
572
620
  (clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId),
573
621
  );
622
+ let iframeDoc: Document | null = null;
623
+ try {
624
+ iframeDoc = iframeRef.current?.contentDocument ?? null;
625
+ } catch {
626
+ iframeDoc = null;
627
+ }
628
+ const usedHostEls = new Set<Element>();
574
629
  const els: TimelineElement[] = filtered.map((clip, index) => {
575
- let hostEl: Element | null = null;
630
+ let hostEl = iframeDoc
631
+ ? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls)
632
+ : null;
633
+ if (hostEl) usedHostEls.add(hostEl);
576
634
  const id = clip.id || clip.label || clip.tagName || "element";
577
635
  const entry: TimelineElement = {
578
636
  id,
@@ -581,16 +639,7 @@ export function useTimelinePlayer() {
581
639
  duration: clip.duration,
582
640
  track: clip.track,
583
641
  };
584
- try {
585
- const iframeDoc = iframeRef.current?.contentDocument;
586
- if (iframeDoc && entry.id) {
587
- hostEl = findTimelineDomNode(iframeDoc, entry.id);
588
- }
589
- } catch {
590
- /* cross-origin */
591
- }
592
642
  if (hostEl) {
593
- const iframeDoc = iframeRef.current?.contentDocument;
594
643
  entry.domId = hostEl.id || undefined;
595
644
  entry.selector = getTimelineElementSelector(hostEl);
596
645
  entry.selectorIndex =
@@ -606,19 +655,13 @@ export function useTimelinePlayer() {
606
655
  // after inlining, so the clip manifest may not have compositionSrc.
607
656
  // Fall back to reading data-composition-file from the DOM.
608
657
  let resolvedSrc = clip.compositionSrc;
609
- let hostEl: Element | null = null;
610
658
  if (!resolvedSrc) {
611
- try {
612
- const iframeDoc = iframeRef.current?.contentDocument;
613
- hostEl =
614
- iframeDoc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
615
- resolvedSrc =
616
- hostEl?.getAttribute("data-composition-src") ??
617
- hostEl?.getAttribute("data-composition-file") ??
618
- null;
619
- } catch {
620
- /* cross-origin */
621
- }
659
+ hostEl =
660
+ iframeDoc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
661
+ resolvedSrc =
662
+ hostEl?.getAttribute("data-composition-src") ??
663
+ hostEl?.getAttribute("data-composition-file") ??
664
+ null;
622
665
  }
623
666
  if (resolvedSrc) {
624
667
  entry.compositionSrc = resolvedSrc;