@hyperframes/studio 0.6.0 → 0.6.2
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/assets/hyperframes-player-CzwFysqv.js +418 -0
- package/dist/assets/index-hYc4aP7M.js +117 -0
- package/dist/index.html +1 -1
- package/package.json +4 -4
- package/src/App.tsx +2 -13
- package/src/captions/components/CaptionOverlay.tsx +13 -246
- package/src/captions/components/CaptionOverlayUtils.ts +221 -0
- package/src/components/StudioPreviewArea.tsx +6 -2
- package/src/components/editor/DomEditOverlay.tsx +88 -1007
- package/src/components/editor/EaseCurveEditor.tsx +221 -0
- package/src/components/editor/FileTree.tsx +13 -621
- package/src/components/editor/FileTreeIcons.tsx +128 -0
- package/src/components/editor/FileTreeNodes.tsx +496 -0
- package/src/components/editor/MotionPanel.tsx +16 -390
- package/src/components/editor/MotionPanelFields.tsx +185 -0
- package/src/components/editor/domEditOverlayGeometry.ts +211 -0
- package/src/components/editor/domEditOverlayGestures.ts +138 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
- package/src/components/editor/domEditing.ts +44 -1150
- package/src/components/editor/domEditingAgentPrompt.ts +97 -0
- package/src/components/editor/domEditingDom.ts +266 -0
- package/src/components/editor/domEditingElement.ts +329 -0
- package/src/components/editor/domEditingLayers.ts +460 -0
- package/src/components/editor/domEditingTypes.ts +125 -0
- package/src/components/editor/manualEdits.ts +84 -1081
- package/src/components/editor/manualEditsDom.ts +436 -0
- package/src/components/editor/manualEditsParsing.ts +280 -0
- package/src/components/editor/manualEditsSnapshot.ts +333 -0
- package/src/components/editor/manualEditsTypes.ts +141 -0
- package/src/components/editor/studioMotion.ts +47 -434
- package/src/components/editor/studioMotionOps.ts +299 -0
- package/src/components/editor/studioMotionTypes.ts +168 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
- package/src/components/editor/useDomEditOverlayRects.ts +207 -0
- package/src/components/nle/NLELayout.tsx +60 -144
- package/src/components/nle/useCompositionStack.ts +126 -0
- package/src/hooks/useToast.ts +20 -0
- package/src/player/components/Timeline.tsx +189 -1418
- package/src/player/components/TimelineCanvas.tsx +434 -0
- package/src/player/components/TimelineEmptyState.tsx +102 -0
- package/src/player/components/TimelineRuler.tsx +90 -0
- package/src/player/components/timelineIcons.tsx +49 -0
- package/src/player/components/timelineLayout.ts +215 -0
- package/src/player/components/timelineUtils.ts +211 -0
- package/src/player/components/useTimelineClipDrag.ts +388 -0
- package/src/player/components/useTimelinePlayhead.ts +200 -0
- package/src/player/components/useTimelineRangeSelection.ts +135 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
- package/src/player/hooks/useTimelinePlayer.ts +69 -1372
- package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
- package/src/player/lib/playbackAdapter.ts +145 -0
- package/src/player/lib/playbackShortcuts.ts +68 -0
- package/src/player/lib/playbackTypes.ts +60 -0
- package/src/player/lib/timelineDOM.ts +373 -0
- package/src/player/lib/timelineElementHelpers.ts +303 -0
- package/src/player/lib/timelineIframeHelpers.ts +269 -0
- package/dist/assets/hyperframes-player-DOFETgjy.js +0 -418
- package/dist/assets/index-DUqUmaoH.js +0 -117
|
@@ -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
|
+
}
|