@hyperframes/studio 0.6.0-alpha.9 → 0.6.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 (111) hide show
  1. package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
  2. package/dist/assets/index-D1JDq7Gg.css +1 -0
  3. package/dist/assets/index-hYc4aP7M.js +117 -0
  4. package/dist/favicon.svg +14 -0
  5. package/dist/index.html +3 -2
  6. package/package.json +9 -9
  7. package/src/App.tsx +421 -4303
  8. package/src/captions/components/CaptionOverlay.tsx +13 -246
  9. package/src/captions/components/CaptionOverlayUtils.ts +221 -0
  10. package/src/components/AskAgentModal.tsx +120 -0
  11. package/src/components/StudioHeader.tsx +133 -0
  12. package/src/components/StudioLeftSidebar.tsx +125 -0
  13. package/src/components/StudioPreviewArea.tsx +167 -0
  14. package/src/components/StudioRightPanel.tsx +198 -0
  15. package/src/components/TimelineToolbar.tsx +89 -0
  16. package/src/components/editor/DomEditOverlay.tsx +88 -993
  17. package/src/components/editor/EaseCurveEditor.tsx +221 -0
  18. package/src/components/editor/FileTree.tsx +13 -621
  19. package/src/components/editor/FileTreeIcons.tsx +128 -0
  20. package/src/components/editor/FileTreeNodes.tsx +496 -0
  21. package/src/components/editor/MotionPanel.tsx +16 -390
  22. package/src/components/editor/MotionPanelFields.tsx +185 -0
  23. package/src/components/editor/PropertyPanel.test.ts +0 -49
  24. package/src/components/editor/PropertyPanel.tsx +132 -2763
  25. package/src/components/editor/domEditOverlayGeometry.ts +211 -0
  26. package/src/components/editor/domEditOverlayGestures.ts +138 -0
  27. package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
  28. package/src/components/editor/domEditing.ts +44 -1117
  29. package/src/components/editor/domEditingAgentPrompt.ts +97 -0
  30. package/src/components/editor/domEditingDom.ts +266 -0
  31. package/src/components/editor/domEditingElement.ts +329 -0
  32. package/src/components/editor/domEditingLayers.ts +460 -0
  33. package/src/components/editor/domEditingTypes.ts +125 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +2 -2
  35. package/src/components/editor/manualEditingAvailability.ts +1 -1
  36. package/src/components/editor/manualEdits.ts +84 -1049
  37. package/src/components/editor/manualEditsDom.ts +436 -0
  38. package/src/components/editor/manualEditsParsing.ts +280 -0
  39. package/src/components/editor/manualEditsSnapshot.ts +333 -0
  40. package/src/components/editor/manualEditsTypes.ts +141 -0
  41. package/src/components/editor/propertyPanelColor.tsx +371 -0
  42. package/src/components/editor/propertyPanelFill.tsx +421 -0
  43. package/src/components/editor/propertyPanelFont.tsx +455 -0
  44. package/src/components/editor/propertyPanelHelpers.ts +401 -0
  45. package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
  46. package/src/components/editor/propertyPanelSections.tsx +453 -0
  47. package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
  48. package/src/components/editor/studioMotion.ts +47 -434
  49. package/src/components/editor/studioMotionOps.ts +299 -0
  50. package/src/components/editor/studioMotionTypes.ts +168 -0
  51. package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
  52. package/src/components/editor/useDomEditOverlayRects.ts +207 -0
  53. package/src/components/nle/NLELayout.tsx +68 -155
  54. package/src/components/nle/NLEPreview.tsx +3 -0
  55. package/src/components/nle/useCompositionStack.ts +126 -0
  56. package/src/components/renders/RenderQueue.tsx +102 -31
  57. package/src/components/renders/useRenderQueue.ts +8 -2
  58. package/src/components/sidebar/LeftSidebar.tsx +186 -186
  59. package/src/contexts/DomEditContext.tsx +137 -0
  60. package/src/contexts/FileManagerContext.tsx +110 -0
  61. package/src/contexts/PanelLayoutContext.tsx +68 -0
  62. package/src/contexts/StudioContext.tsx +135 -0
  63. package/src/hooks/useAppHotkeys.ts +326 -0
  64. package/src/hooks/useAskAgentModal.ts +162 -0
  65. package/src/hooks/useCaptionDetection.ts +132 -0
  66. package/src/hooks/useCompositionDimensions.ts +25 -0
  67. package/src/hooks/useConsoleErrorCapture.ts +60 -0
  68. package/src/hooks/useDomEditCommits.ts +437 -0
  69. package/src/hooks/useDomEditSession.ts +342 -0
  70. package/src/hooks/useDomEditTextCommits.ts +330 -0
  71. package/src/hooks/useDomSelection.ts +398 -0
  72. package/src/hooks/useFileManager.ts +431 -0
  73. package/src/hooks/useFrameCapture.ts +77 -0
  74. package/src/hooks/useLintModal.ts +35 -0
  75. package/src/hooks/useManifestPersistence.ts +492 -0
  76. package/src/hooks/usePanelLayout.ts +68 -0
  77. package/src/hooks/usePreviewInteraction.ts +153 -0
  78. package/src/hooks/useRenderClipContent.ts +124 -0
  79. package/src/hooks/useTimelineEditing.ts +472 -0
  80. package/src/hooks/useToast.ts +20 -0
  81. package/src/player/components/Player.tsx +33 -2
  82. package/src/player/components/Timeline.test.ts +0 -8
  83. package/src/player/components/Timeline.tsx +196 -1518
  84. package/src/player/components/TimelineCanvas.tsx +434 -0
  85. package/src/player/components/TimelineClip.tsx +9 -244
  86. package/src/player/components/TimelineEmptyState.tsx +102 -0
  87. package/src/player/components/TimelineRuler.tsx +90 -0
  88. package/src/player/components/timelineIcons.tsx +49 -0
  89. package/src/player/components/timelineLayout.ts +215 -0
  90. package/src/player/components/timelineUtils.ts +211 -0
  91. package/src/player/components/useTimelineClipDrag.ts +388 -0
  92. package/src/player/components/useTimelinePlayhead.ts +200 -0
  93. package/src/player/components/useTimelineRangeSelection.ts +135 -0
  94. package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
  95. package/src/player/hooks/useTimelinePlayer.ts +105 -1371
  96. package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
  97. package/src/player/lib/playbackAdapter.ts +145 -0
  98. package/src/player/lib/playbackShortcuts.ts +68 -0
  99. package/src/player/lib/playbackTypes.ts +60 -0
  100. package/src/player/lib/timelineDOM.ts +373 -0
  101. package/src/player/lib/timelineElementHelpers.ts +303 -0
  102. package/src/player/lib/timelineIframeHelpers.ts +269 -0
  103. package/src/utils/domEditHelpers.ts +50 -0
  104. package/src/utils/studioFontHelpers.ts +83 -0
  105. package/src/utils/studioHelpers.ts +214 -0
  106. package/src/utils/studioPreviewHelpers.ts +185 -0
  107. package/src/utils/timelineDiscovery.ts +1 -1
  108. package/dist/assets/hyperframes-player-DjsVzYFP.js +0 -418
  109. package/dist/assets/index-14zH9lqh.css +0 -1
  110. package/dist/assets/index-DYCiFGWQ.js +0 -108
  111. package/src/player/components/TimelineClip.test.ts +0 -92
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Higher-level timeline DOM operations: element factories, DOM-to-element
3
+ * parsing, timeline merging, and standalone composition helpers.
4
+ *
5
+ * Preview iframe utilities (normaliseViewport, autoHeal, unmute, resolveIframe,
6
+ * buildMissingCompositionElements) live in timelineIframeHelpers.ts.
7
+ *
8
+ * Pure functions (no React, no store reads) — testable in isolation.
9
+ */
10
+
11
+ import type { TimelineElement } from "../store/playerStore";
12
+ import type { ClipManifestClip } from "./playbackTypes";
13
+ import {
14
+ resolveMediaElement,
15
+ applyMediaMetadataFromElement,
16
+ getTimelineElementDisplayLabel,
17
+ getImplicitTimelineLayerLabel,
18
+ isImplicitTimelineLayerCandidate,
19
+ getTimelineElementSelector,
20
+ getTimelineElementSourceFile,
21
+ getTimelineElementSelectorIndex,
22
+ buildTimelineElementKey,
23
+ buildTimelineElementIdentity,
24
+ getTimelineElementIdentity,
25
+ } from "./timelineElementHelpers";
26
+
27
+ // Re-export helpers that were previously public from this module so that
28
+ // existing import sites (hook + tests) don't need to change.
29
+ export {
30
+ readTimelineDurationFromDocument,
31
+ resolveMediaElement,
32
+ applyMediaMetadataFromElement,
33
+ getTimelineElementSelector,
34
+ getTimelineElementSourceFile,
35
+ getTimelineElementSelectorIndex,
36
+ buildTimelineElementIdentity,
37
+ getTimelineElementIdentity,
38
+ findTimelineDomNodeForClip,
39
+ } from "./timelineElementHelpers";
40
+
41
+ // Re-export iframe helpers so the hook can keep a single import source.
42
+ export {
43
+ normalizePreviewViewport,
44
+ autoHealMissingCompositionIds,
45
+ unmutePreviewMedia,
46
+ resolveIframe,
47
+ buildMissingCompositionElements,
48
+ } from "./timelineIframeHelpers";
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // TimelineElement factories
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export function createTimelineElementFromManifestClip(params: {
55
+ clip: ClipManifestClip;
56
+ fallbackIndex: number;
57
+ doc?: Document | null;
58
+ hostEl?: Element | null;
59
+ }): TimelineElement {
60
+ const { clip, fallbackIndex, doc } = params;
61
+ let hostEl = params.hostEl ?? null;
62
+ const label = getTimelineElementDisplayLabel({
63
+ id: clip.id,
64
+ label: clip.label,
65
+ tag: clip.tagName || clip.kind,
66
+ });
67
+
68
+ let domId: string | undefined;
69
+ let selector: string | undefined;
70
+ let selectorIndex: number | undefined;
71
+ let sourceFile: string | undefined;
72
+
73
+ if (hostEl) {
74
+ domId = hostEl.id || undefined;
75
+ selector = getTimelineElementSelector(hostEl);
76
+ selectorIndex =
77
+ doc && selector ? getTimelineElementSelectorIndex(doc, hostEl, selector) : undefined;
78
+ sourceFile = getTimelineElementSourceFile(hostEl);
79
+ }
80
+
81
+ const identity = buildTimelineElementIdentity({
82
+ preferredId: clip.id,
83
+ label,
84
+ fallbackIndex,
85
+ domId,
86
+ selector,
87
+ selectorIndex,
88
+ sourceFile,
89
+ });
90
+ const entry: TimelineElement = {
91
+ id: identity.id,
92
+ label,
93
+ key: identity.key,
94
+ tag: clip.tagName || clip.kind,
95
+ start: clip.start,
96
+ duration: clip.duration,
97
+ track: clip.track,
98
+ domId,
99
+ selector,
100
+ selectorIndex,
101
+ sourceFile,
102
+ };
103
+
104
+ if (hostEl) {
105
+ applyMediaMetadataFromElement(entry, hostEl);
106
+ }
107
+ if (clip.assetUrl) entry.src = clip.assetUrl;
108
+ if (clip.kind === "composition" && clip.compositionId) {
109
+ let resolvedSrc = clip.compositionSrc;
110
+ if (!resolvedSrc) {
111
+ hostEl = doc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
112
+ resolvedSrc =
113
+ hostEl?.getAttribute("data-composition-src") ??
114
+ hostEl?.getAttribute("data-composition-file") ??
115
+ null;
116
+ }
117
+ if (resolvedSrc) {
118
+ entry.compositionSrc = resolvedSrc;
119
+ } else if (hostEl) {
120
+ const innerVideo = hostEl.querySelector("video[src]");
121
+ if (innerVideo) {
122
+ entry.src = innerVideo.getAttribute("src") || undefined;
123
+ entry.tag = "video";
124
+ }
125
+ }
126
+ if (hostEl) {
127
+ entry.domId = hostEl.id || undefined;
128
+ entry.selector = getTimelineElementSelector(hostEl);
129
+ entry.selectorIndex =
130
+ doc && entry.selector
131
+ ? getTimelineElementSelectorIndex(doc, hostEl, entry.selector)
132
+ : undefined;
133
+ entry.sourceFile = getTimelineElementSourceFile(hostEl);
134
+ const nextIdentity = buildTimelineElementIdentity({
135
+ preferredId: clip.id,
136
+ label,
137
+ fallbackIndex,
138
+ domId: entry.domId,
139
+ selector: entry.selector,
140
+ selectorIndex: entry.selectorIndex,
141
+ sourceFile: entry.sourceFile,
142
+ });
143
+ entry.id = nextIdentity.id;
144
+ entry.key = nextIdentity.key;
145
+ }
146
+ }
147
+
148
+ return entry;
149
+ }
150
+
151
+ export function createImplicitTimelineLayersFromDOM(
152
+ doc: Document,
153
+ rootDuration: number,
154
+ existingElements: readonly TimelineElement[] = [],
155
+ ): TimelineElement[] {
156
+ if (!Number.isFinite(rootDuration) || rootDuration <= 0) return [];
157
+ const rootComp = doc.querySelector("[data-composition-id]");
158
+ if (!rootComp) return [];
159
+
160
+ const existingKeys = new Set(existingElements.map(getTimelineElementIdentity));
161
+ const maxTrack = existingElements.reduce(
162
+ (max, element) => Math.max(max, Number.isFinite(element.track) ? element.track : 0),
163
+ -1,
164
+ );
165
+ const layers: TimelineElement[] = [];
166
+
167
+ for (const child of Array.from(rootComp.children)) {
168
+ if (!isImplicitTimelineLayerCandidate(rootComp, child)) continue;
169
+
170
+ const selector = getTimelineElementSelector(child);
171
+ if (!selector) continue;
172
+ const selectorIndex = getTimelineElementSelectorIndex(doc, child, selector);
173
+ const sourceFile = getTimelineElementSourceFile(child);
174
+ const label = getImplicitTimelineLayerLabel(child);
175
+ const identity = buildTimelineElementIdentity({
176
+ preferredId: child.id || null,
177
+ label,
178
+ fallbackIndex: existingElements.length + layers.length,
179
+ domId: child.id || undefined,
180
+ selector,
181
+ selectorIndex,
182
+ sourceFile,
183
+ });
184
+ if (existingKeys.has(identity.key) || existingKeys.has(identity.id)) continue;
185
+
186
+ layers.push({
187
+ domId: child.id || undefined,
188
+ duration: rootDuration,
189
+ id: identity.id,
190
+ key: identity.key,
191
+ label,
192
+ selector,
193
+ selectorIndex,
194
+ sourceFile,
195
+ start: 0,
196
+ tag: child.tagName.toLowerCase(),
197
+ timingSource: "implicit",
198
+ track: maxTrack + 1 + layers.length,
199
+ });
200
+ }
201
+
202
+ return layers;
203
+ }
204
+
205
+ /**
206
+ * Parse [data-start] elements from a Document into TimelineElement[].
207
+ * Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
208
+ */
209
+ export function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElement[] {
210
+ const rootComp = doc.querySelector("[data-composition-id]");
211
+ const nodes = doc.querySelectorAll("[data-start]");
212
+ const els: TimelineElement[] = [];
213
+ let trackCounter = 0;
214
+
215
+ nodes.forEach((node) => {
216
+ if (node === rootComp) return;
217
+ const el = node as HTMLElement;
218
+ const startStr = el.getAttribute("data-start");
219
+ if (startStr == null) return;
220
+ const start = parseFloat(startStr);
221
+ if (isNaN(start)) return;
222
+ if (Number.isFinite(rootDuration) && rootDuration > 0 && start >= rootDuration) return;
223
+
224
+ const tagLower = el.tagName.toLowerCase();
225
+ let dur = 0;
226
+ const durStr = el.getAttribute("data-duration");
227
+ if (durStr != null) dur = parseFloat(durStr);
228
+ if (isNaN(dur) || dur <= 0) dur = Math.max(0, rootDuration - start);
229
+ if (Number.isFinite(rootDuration) && rootDuration > 0) {
230
+ dur = Math.min(dur, Math.max(0, rootDuration - start));
231
+ }
232
+ if (!Number.isFinite(dur) || dur <= 0) return;
233
+
234
+ const trackStr = el.getAttribute("data-track-index");
235
+ const track = trackStr != null ? parseInt(trackStr, 10) : trackCounter++;
236
+ const compId = el.getAttribute("data-composition-id");
237
+ const selector = getTimelineElementSelector(el);
238
+ const sourceFile = getTimelineElementSourceFile(el);
239
+ const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
240
+ const label = getTimelineElementDisplayLabel({
241
+ id: el.id || compId || null,
242
+ label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
243
+ tag: tagLower,
244
+ });
245
+ const identity = buildTimelineElementIdentity({
246
+ preferredId: el.id || compId || null,
247
+ label,
248
+ fallbackIndex: els.length,
249
+ domId: el.id || undefined,
250
+ selector,
251
+ selectorIndex,
252
+ sourceFile,
253
+ });
254
+ const entry: TimelineElement = {
255
+ id: identity.id,
256
+ label,
257
+ key: identity.key,
258
+ tag: tagLower,
259
+ start,
260
+ duration: dur,
261
+ track: isNaN(track) ? 0 : track,
262
+ domId: el.id || undefined,
263
+ selector,
264
+ selectorIndex,
265
+ sourceFile,
266
+ timingSource: "authored",
267
+ };
268
+
269
+ const mediaEl = resolveMediaElement(el);
270
+ if (mediaEl) {
271
+ if (mediaEl.tagName === "IMG") {
272
+ entry.tag = "img";
273
+ }
274
+ const src = mediaEl.getAttribute("src");
275
+ if (src) entry.src = src;
276
+ const vol = el.getAttribute("data-volume") ?? mediaEl.getAttribute("data-volume");
277
+ if (vol) entry.volume = parseFloat(vol);
278
+ applyMediaMetadataFromElement(entry, el);
279
+ }
280
+
281
+ // Sub-compositions
282
+ const compSrc =
283
+ el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
284
+ if (compSrc) {
285
+ entry.compositionSrc = compSrc;
286
+ } else if (compId && compId !== rootComp?.getAttribute("data-composition-id")) {
287
+ // Inline composition — expose inner video for thumbnails
288
+ const innerVideo = el.querySelector("video[src]");
289
+ if (innerVideo) {
290
+ entry.src = innerVideo.getAttribute("src") || undefined;
291
+ entry.tag = "video";
292
+ }
293
+ }
294
+
295
+ els.push(entry);
296
+ });
297
+
298
+ return [...els, ...createImplicitTimelineLayersFromDOM(doc, rootDuration, els)];
299
+ }
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // Merge helpers
303
+ // ---------------------------------------------------------------------------
304
+
305
+ export function mergeTimelineElementsPreservingDowngrades(
306
+ currentElements: TimelineElement[],
307
+ nextElements: TimelineElement[],
308
+ currentDuration: number,
309
+ nextDuration: number,
310
+ ): TimelineElement[] {
311
+ const safeCurrentDuration = Number.isFinite(currentDuration) ? currentDuration : 0;
312
+ const safeNextDuration = Number.isFinite(nextDuration) ? nextDuration : 0;
313
+
314
+ if (
315
+ currentElements.length === 0 ||
316
+ nextElements.length >= currentElements.length ||
317
+ safeNextDuration > safeCurrentDuration
318
+ ) {
319
+ return nextElements;
320
+ }
321
+
322
+ const nextIdentities = new Set(nextElements.map(getTimelineElementIdentity));
323
+ const preserved = currentElements.filter(
324
+ (element) => !nextIdentities.has(getTimelineElementIdentity(element)),
325
+ );
326
+ if (preserved.length === 0) return nextElements;
327
+ return [...nextElements, ...preserved];
328
+ }
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // Standalone composition helpers
332
+ // ---------------------------------------------------------------------------
333
+
334
+ export function resolveStandaloneRootCompositionSrc(iframeSrc: string): string | undefined {
335
+ const compPathMatch = iframeSrc.match(/\/preview\/comp\/(.+?)(?:\?|$)/);
336
+ return compPathMatch ? decodeURIComponent(compPathMatch[1]) : undefined;
337
+ }
338
+
339
+ export function buildStandaloneRootTimelineElement(params: {
340
+ compositionId: string;
341
+ tagName: string;
342
+ rootDuration: number;
343
+ iframeSrc: string;
344
+ selector?: string;
345
+ selectorIndex?: number;
346
+ }): TimelineElement | null {
347
+ if (!Number.isFinite(params.rootDuration) || params.rootDuration <= 0) return null;
348
+
349
+ const compositionSrc = resolveStandaloneRootCompositionSrc(params.iframeSrc);
350
+
351
+ return {
352
+ id: params.compositionId,
353
+ label: getTimelineElementDisplayLabel({
354
+ id: params.compositionId,
355
+ tag: params.tagName,
356
+ }),
357
+ key: buildTimelineElementKey({
358
+ id: params.compositionId,
359
+ fallbackIndex: 0,
360
+ selector: params.selector,
361
+ selectorIndex: params.selectorIndex,
362
+ sourceFile: compositionSrc,
363
+ }),
364
+ tag: params.tagName.toLowerCase() || "div",
365
+ start: 0,
366
+ duration: params.rootDuration,
367
+ track: 0,
368
+ compositionSrc,
369
+ selector: params.selector,
370
+ selectorIndex: params.selectorIndex,
371
+ sourceFile: compositionSrc,
372
+ };
373
+ }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Low-level helpers for building and identifying TimelineElement objects.
3
+ *
4
+ * Covers: duration reading, media-element metadata extraction, selector/key/
5
+ * identity builders, DOM node lookup, and implicit layer detection. These are
6
+ * intentionally dependency-free (no store, no hooks) so they can be used in
7
+ * both the React hook and test environments.
8
+ */
9
+
10
+ import type { TimelineElement } from "../store/playerStore";
11
+ import type { ClipManifestClip } from "./playbackTypes";
12
+ import { isFinitePositive } from "./playbackAdapter";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Duration attribute helpers
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export function readDurationAttribute(el: Element | null | undefined): number {
19
+ if (!el) return 0;
20
+ const duration =
21
+ Number.parseFloat(el.getAttribute("data-duration") ?? "") ||
22
+ Number.parseFloat(el.getAttribute("data-hf-authored-duration") ?? "");
23
+ return isFinitePositive(duration) ? duration : 0;
24
+ }
25
+
26
+ export function readTimelineDurationFromDocument(doc: Document | null | undefined): number {
27
+ if (!doc) return 0;
28
+ const rootDuration = readDurationAttribute(doc.querySelector("[data-composition-id]"));
29
+ if (rootDuration > 0) return rootDuration;
30
+
31
+ let maxEnd = 0;
32
+ for (const node of Array.from(doc.querySelectorAll("[data-start]"))) {
33
+ const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
34
+ const duration = readDurationAttribute(node);
35
+ if (!Number.isFinite(start) || start < 0 || duration <= 0) continue;
36
+ maxEnd = Math.max(maxEnd, start + duration);
37
+ }
38
+ return maxEnd;
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // DOM element type guards
43
+ // ---------------------------------------------------------------------------
44
+
45
+ export function isHtmlElement(el: Element): el is HTMLElement {
46
+ const HtmlElementCtor = el.ownerDocument.defaultView?.HTMLElement ?? globalThis.HTMLElement;
47
+ return typeof HtmlElementCtor !== "undefined" && el instanceof HtmlElementCtor;
48
+ }
49
+
50
+ export function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageElement | null {
51
+ const win = el.ownerDocument.defaultView ?? window;
52
+ const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
53
+ const ImageElementCtor = win.HTMLImageElement ?? globalThis.HTMLImageElement;
54
+ if (el instanceof MediaElementCtor || el instanceof ImageElementCtor) return el;
55
+ const candidate = el.querySelector("video, audio, img");
56
+ return candidate instanceof MediaElementCtor || candidate instanceof ImageElementCtor
57
+ ? candidate
58
+ : null;
59
+ }
60
+
61
+ export function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void {
62
+ const mediaStartAttr = el.getAttribute("data-playback-start")
63
+ ? "playback-start"
64
+ : el.getAttribute("data-media-start")
65
+ ? "media-start"
66
+ : undefined;
67
+ const mediaStartValue =
68
+ el.getAttribute("data-playback-start") ?? el.getAttribute("data-media-start");
69
+ if (mediaStartValue != null) {
70
+ const playbackStart = parseFloat(mediaStartValue);
71
+ if (Number.isFinite(playbackStart)) entry.playbackStart = playbackStart;
72
+ }
73
+ if (mediaStartAttr) entry.playbackStartAttr = mediaStartAttr;
74
+
75
+ const mediaEl = resolveMediaElement(el);
76
+ if (!mediaEl) return;
77
+
78
+ entry.tag = mediaEl.tagName.toLowerCase();
79
+ const src = mediaEl.getAttribute("src");
80
+ if (src) entry.src = src;
81
+
82
+ const win = mediaEl.ownerDocument.defaultView ?? window;
83
+ const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
84
+ if (typeof MediaElementCtor === "undefined" || !(mediaEl instanceof MediaElementCtor)) return;
85
+
86
+ const sourceDurationAttr =
87
+ el.getAttribute("data-source-duration") ?? mediaEl.getAttribute("data-source-duration");
88
+ const sourceDuration = sourceDurationAttr ? parseFloat(sourceDurationAttr) : mediaEl.duration;
89
+ if (Number.isFinite(sourceDuration) && sourceDuration > 0) {
90
+ entry.sourceDuration = sourceDuration;
91
+ }
92
+
93
+ const playbackRate = mediaEl.defaultPlaybackRate;
94
+ if (Number.isFinite(playbackRate) && playbackRate > 0) {
95
+ entry.playbackRate = playbackRate;
96
+ }
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Label helpers
101
+ // ---------------------------------------------------------------------------
102
+
103
+ export function getTimelineElementDisplayLabel(input: {
104
+ id?: string | null;
105
+ label?: string | null;
106
+ tag?: string | null;
107
+ }): string {
108
+ const label = input.label?.trim();
109
+ if (label) return label;
110
+ const id = input.id?.trim();
111
+ if (id) return id;
112
+ const tag = input.tag?.trim().toLowerCase();
113
+ return tag ? `${tag} clip` : "Timeline clip";
114
+ }
115
+
116
+ export const IMPLICIT_TIMELINE_LAYER_SKIP_TAGS = new Set([
117
+ "base",
118
+ "link",
119
+ "meta",
120
+ "noscript",
121
+ "script",
122
+ "style",
123
+ "template",
124
+ ]);
125
+
126
+ export function humanizeTimelineIdentifier(value: string): string {
127
+ return value
128
+ .trim()
129
+ .replace(/[_-]+/g, " ")
130
+ .replace(/\s+/g, " ")
131
+ .replace(/\b\w/g, (match) => match.toUpperCase());
132
+ }
133
+
134
+ export function getImplicitTimelineLayerLabel(el: HTMLElement): string {
135
+ const explicitLabel =
136
+ el.getAttribute("data-timeline-label") ??
137
+ el.getAttribute("data-label") ??
138
+ el.getAttribute("aria-label");
139
+ if (explicitLabel?.trim()) return explicitLabel.trim();
140
+ if (el.id.trim()) return humanizeTimelineIdentifier(el.id);
141
+ const classes = el.className.split(/\s+/).filter(Boolean);
142
+ const className = classes.find((value) => value !== "clip") ?? classes[0];
143
+ if (className) return humanizeTimelineIdentifier(className);
144
+ return getTimelineElementDisplayLabel({ tag: el.tagName });
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Selector / identity / key builders
149
+ // ---------------------------------------------------------------------------
150
+
151
+ export function getTimelineElementSelector(el: Element): string | undefined {
152
+ if (isHtmlElement(el) && el.id) return `#${el.id}`;
153
+ const compId = el.getAttribute("data-composition-id");
154
+ if (compId) return `[data-composition-id="${compId}"]`;
155
+ if (isHtmlElement(el)) {
156
+ const classes = el.className.split(/\s+/).filter(Boolean);
157
+ const firstClass = classes.find((className) => className !== "clip") ?? classes[0];
158
+ if (firstClass) return `.${firstClass}`;
159
+ }
160
+ return undefined;
161
+ }
162
+
163
+ export function getTimelineElementSourceFile(el: Element): string | undefined {
164
+ const ownerRoot = el.parentElement?.closest("[data-composition-id]");
165
+ return (
166
+ ownerRoot?.getAttribute("data-composition-file") ??
167
+ ownerRoot?.getAttribute("data-composition-src") ??
168
+ undefined
169
+ );
170
+ }
171
+
172
+ export function getTimelineElementSelectorIndex(
173
+ doc: Document,
174
+ el: Element,
175
+ selector: string | undefined,
176
+ ): number | undefined {
177
+ if (!selector || selector.startsWith("#") || selector.startsWith("[data-composition-id=")) {
178
+ return undefined;
179
+ }
180
+
181
+ try {
182
+ const matches = Array.from(doc.querySelectorAll(selector));
183
+ const matchIndex = matches.indexOf(el);
184
+ return matchIndex >= 0 ? matchIndex : undefined;
185
+ } catch {
186
+ return undefined;
187
+ }
188
+ }
189
+
190
+ export function buildTimelineElementKey(params: {
191
+ id: string;
192
+ fallbackIndex: number;
193
+ domId?: string;
194
+ selector?: string;
195
+ selectorIndex?: number;
196
+ sourceFile?: string;
197
+ }): string {
198
+ const scope = params.sourceFile ?? "index.html";
199
+ if (params.domId) return `${scope}#${params.domId}`;
200
+ if (params.selector) return `${scope}:${params.selector}:${params.selectorIndex ?? 0}`;
201
+ return `${scope}:${params.id}:${params.fallbackIndex}`;
202
+ }
203
+
204
+ export function buildTimelineElementIdentity(params: {
205
+ preferredId?: string | null;
206
+ label: string;
207
+ fallbackIndex: number;
208
+ domId?: string;
209
+ selector?: string;
210
+ selectorIndex?: number;
211
+ sourceFile?: string;
212
+ }): { id: string; key: string } {
213
+ const id =
214
+ params.preferredId?.trim() ||
215
+ buildTimelineElementKey({
216
+ id: params.label,
217
+ fallbackIndex: params.fallbackIndex,
218
+ domId: params.domId,
219
+ selector: params.selector,
220
+ selectorIndex: params.selectorIndex,
221
+ sourceFile: params.sourceFile,
222
+ });
223
+ const key = buildTimelineElementKey({
224
+ id,
225
+ fallbackIndex: params.fallbackIndex,
226
+ domId: params.domId,
227
+ selector: params.selector,
228
+ selectorIndex: params.selectorIndex,
229
+ sourceFile: params.sourceFile,
230
+ });
231
+ return { id, key };
232
+ }
233
+
234
+ export function getTimelineElementIdentity(element: TimelineElement): string {
235
+ return element.key ?? element.id;
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // DOM node querying
240
+ // ---------------------------------------------------------------------------
241
+
242
+ export function getTimelineDomNodes(doc: Document): Element[] {
243
+ const rootComp = doc.querySelector("[data-composition-id]");
244
+ return Array.from(doc.querySelectorAll("[data-start]")).filter((node) => node !== rootComp);
245
+ }
246
+
247
+ function numbersNearlyEqual(a: number, b: number): boolean {
248
+ return Math.abs(a - b) < 0.001;
249
+ }
250
+
251
+ function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean {
252
+ const tagName = clip.tagName?.toLowerCase();
253
+ if (tagName && node.tagName.toLowerCase() !== tagName) return false;
254
+
255
+ const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
256
+ if (Number.isFinite(start) && !numbersNearlyEqual(start, clip.start)) return false;
257
+
258
+ const duration = Number.parseFloat(node.getAttribute("data-duration") ?? "");
259
+ if (Number.isFinite(duration) && !numbersNearlyEqual(duration, clip.duration)) return false;
260
+
261
+ const track = Number.parseInt(node.getAttribute("data-track-index") ?? "", 10);
262
+ if (Number.isFinite(track) && track !== clip.track) return false;
263
+
264
+ return true;
265
+ }
266
+
267
+ function findTimelineDomNode(doc: Document, id: string): Element | null {
268
+ return (
269
+ doc.getElementById(id) ??
270
+ doc.querySelector(`[data-composition-id="${id}"]`) ??
271
+ doc.querySelector(`.${id}`) ??
272
+ null
273
+ );
274
+ }
275
+
276
+ export function findTimelineDomNodeForClip(
277
+ doc: Document,
278
+ clip: ClipManifestClip,
279
+ fallbackIndex: number,
280
+ usedNodes = new Set<Element>(),
281
+ ): Element | null {
282
+ const byIdentity = clip.id ? findTimelineDomNode(doc, clip.id) : null;
283
+ if (byIdentity && !usedNodes.has(byIdentity)) return byIdentity;
284
+
285
+ const candidates = getTimelineDomNodes(doc).filter((node) => !usedNodes.has(node));
286
+ const exact = candidates.find((node) => nodeMatchesManifestClip(node, clip));
287
+ if (exact) return exact;
288
+
289
+ return candidates[fallbackIndex] ?? null;
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // Implicit layer detection
294
+ // ---------------------------------------------------------------------------
295
+
296
+ export function isImplicitTimelineLayerCandidate(root: Element, el: Element): el is HTMLElement {
297
+ if (!isHtmlElement(el)) return false;
298
+ if (el.parentElement !== root) return false;
299
+ const tagName = el.tagName.toLowerCase();
300
+ if (IMPLICIT_TIMELINE_LAYER_SKIP_TAGS.has(tagName)) return false;
301
+ if (el.hasAttribute("data-start") || el.hasAttribute("data-track-index")) return false;
302
+ return Boolean(getTimelineElementSelector(el));
303
+ }