@hyperframes/studio 0.4.37 → 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.
- package/dist/assets/index-BLrgRQSu.css +1 -0
- package/dist/assets/index-D4-n3yWG.js +93 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +139 -56
- package/src/components/nle/NLELayout.tsx +43 -8
- package/src/components/sidebar/LeftSidebar.tsx +26 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/PlayerControls.tsx +3 -44
- package/src/player/components/Timeline.tsx +5 -2
- package/src/player/components/TimelineClip.tsx +2 -1
- package/src/player/components/timelineTheme.test.ts +19 -0
- package/src/player/components/timelineTheme.ts +8 -4
- package/src/player/hooks/useTimelinePlayer.test.ts +198 -0
- package/src/player/hooks/useTimelinePlayer.ts +263 -108
- package/src/player/lib/time.test.ts +11 -1
- package/src/player/lib/time.ts +6 -0
- package/src/player/store/playerStore.ts +1 -0
- package/src/utils/frameCapture.test.ts +26 -0
- package/src/utils/frameCapture.ts +38 -0
- package/dist/assets/index-Bj3m6A02.js +0 -93
- package/dist/assets/index-_h8opaGY.css +0 -1
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { Window } from "happy-dom";
|
|
2
3
|
import {
|
|
3
4
|
buildStandaloneRootTimelineElement,
|
|
5
|
+
createTimelineElementFromManifestClip,
|
|
6
|
+
findTimelineDomNodeForClip,
|
|
7
|
+
getTimelineElementSelector,
|
|
8
|
+
parseTimelineFromDOM,
|
|
9
|
+
type ClipManifestClip,
|
|
4
10
|
mergeTimelineElementsPreservingDowngrades,
|
|
5
11
|
resolveStandaloneRootCompositionSrc,
|
|
6
12
|
shouldIgnorePlaybackShortcutEvent,
|
|
@@ -27,6 +33,29 @@ function mockKeyboardEvent(
|
|
|
27
33
|
};
|
|
28
34
|
}
|
|
29
35
|
|
|
36
|
+
function createDocument(markup: string): Document {
|
|
37
|
+
const window = new Window();
|
|
38
|
+
window.document.body.innerHTML = markup;
|
|
39
|
+
return window.document;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createClip(overrides: Partial<ClipManifestClip>): ClipManifestClip {
|
|
43
|
+
return {
|
|
44
|
+
id: null,
|
|
45
|
+
label: "Element",
|
|
46
|
+
start: 0,
|
|
47
|
+
duration: 4,
|
|
48
|
+
track: 0,
|
|
49
|
+
kind: "element",
|
|
50
|
+
tagName: "div",
|
|
51
|
+
compositionId: null,
|
|
52
|
+
parentCompositionId: null,
|
|
53
|
+
compositionSrc: null,
|
|
54
|
+
assetUrl: null,
|
|
55
|
+
...overrides,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
30
59
|
describe("buildStandaloneRootTimelineElement", () => {
|
|
31
60
|
it("includes selector and source metadata for standalone composition fallback clips", () => {
|
|
32
61
|
expect(
|
|
@@ -39,6 +68,7 @@ describe("buildStandaloneRootTimelineElement", () => {
|
|
|
39
68
|
}),
|
|
40
69
|
).toEqual({
|
|
41
70
|
id: "hero",
|
|
71
|
+
label: "hero",
|
|
42
72
|
key: 'scenes/hero.html:[data-composition-id="hero"]:0',
|
|
43
73
|
tag: "div",
|
|
44
74
|
start: 0,
|
|
@@ -87,6 +117,115 @@ describe("resolveStandaloneRootCompositionSrc", () => {
|
|
|
87
117
|
});
|
|
88
118
|
});
|
|
89
119
|
|
|
120
|
+
describe("findTimelineDomNodeForClip", () => {
|
|
121
|
+
it("matches anonymous manifest clips back to repeated DOM nodes in timeline order", () => {
|
|
122
|
+
const doc = createDocument(`
|
|
123
|
+
<div data-composition-id="main" data-start="0" data-duration="8">
|
|
124
|
+
<section id="identity-card" class="clip identity-card" data-start="0" data-duration="4" data-track-index="0"></section>
|
|
125
|
+
<div class="clip duplicate-card first" data-start="0" data-duration="4" data-track-index="1"></div>
|
|
126
|
+
<div class="clip duplicate-card second" data-start="0" data-duration="4" data-track-index="2"></div>
|
|
127
|
+
</div>
|
|
128
|
+
`);
|
|
129
|
+
const used = new Set<Element>();
|
|
130
|
+
|
|
131
|
+
const first = findTimelineDomNodeForClip(
|
|
132
|
+
doc,
|
|
133
|
+
createClip({ id: "__node__index_2", track: 1 }),
|
|
134
|
+
1,
|
|
135
|
+
used,
|
|
136
|
+
) as HTMLElement;
|
|
137
|
+
used.add(first);
|
|
138
|
+
const second = findTimelineDomNodeForClip(
|
|
139
|
+
doc,
|
|
140
|
+
createClip({ id: "__node__index_3", track: 2 }),
|
|
141
|
+
2,
|
|
142
|
+
used,
|
|
143
|
+
) as HTMLElement;
|
|
144
|
+
|
|
145
|
+
expect(first.className).toBe("clip duplicate-card first");
|
|
146
|
+
expect(second.className).toBe("clip duplicate-card second");
|
|
147
|
+
expect(getTimelineElementSelector(first)).toBe(".duplicate-card");
|
|
148
|
+
expect(getTimelineElementSelector(second)).toBe(".duplicate-card");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("anonymous timeline identity", () => {
|
|
153
|
+
it("keeps fallback-parsed anonymous clips distinct when labels match", () => {
|
|
154
|
+
const doc = createDocument(`
|
|
155
|
+
<div data-composition-id="main" data-start="0" data-duration="8">
|
|
156
|
+
<div class="clip card" data-label="Card" data-start="0" data-duration="3" data-track-index="0"></div>
|
|
157
|
+
<div class="clip card" data-label="Card" data-start="3" data-duration="3" data-track-index="1"></div>
|
|
158
|
+
</div>
|
|
159
|
+
`);
|
|
160
|
+
|
|
161
|
+
const elements = parseTimelineFromDOM(doc, 8);
|
|
162
|
+
|
|
163
|
+
expect(elements).toHaveLength(2);
|
|
164
|
+
expect(elements.map((element) => element.label)).toEqual(["Card", "Card"]);
|
|
165
|
+
expect(new Set(elements.map((element) => element.id)).size).toBe(2);
|
|
166
|
+
expect(new Set(elements.map((element) => element.key)).size).toBe(2);
|
|
167
|
+
expect(elements.map((element) => element.selectorIndex)).toEqual([0, 1]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("keeps runtime-manifest anonymous clips distinct when labels match", () => {
|
|
171
|
+
const doc = createDocument(`
|
|
172
|
+
<div data-composition-id="main" data-start="0" data-duration="8">
|
|
173
|
+
<div class="clip card" data-start="0" data-duration="3" data-track-index="0"></div>
|
|
174
|
+
<div class="clip card" data-start="3" data-duration="3" data-track-index="1"></div>
|
|
175
|
+
</div>
|
|
176
|
+
`);
|
|
177
|
+
const clips = [
|
|
178
|
+
createClip({ id: null, label: "Card", start: 0, duration: 3, track: 0 }),
|
|
179
|
+
createClip({ id: null, label: "Card", start: 3, duration: 3, track: 1 }),
|
|
180
|
+
];
|
|
181
|
+
const used = new Set<Element>();
|
|
182
|
+
const elements = clips.map((clip, index) => {
|
|
183
|
+
const hostEl = findTimelineDomNodeForClip(doc, clip, index, used);
|
|
184
|
+
if (hostEl) used.add(hostEl);
|
|
185
|
+
return createTimelineElementFromManifestClip({
|
|
186
|
+
clip,
|
|
187
|
+
fallbackIndex: index,
|
|
188
|
+
doc,
|
|
189
|
+
hostEl,
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(elements.map((element) => element.label)).toEqual(["Card", "Card"]);
|
|
194
|
+
expect(new Set(elements.map((element) => element.id)).size).toBe(2);
|
|
195
|
+
expect(new Set(elements.map((element) => element.key)).size).toBe(2);
|
|
196
|
+
expect(elements.map((element) => element.selectorIndex)).toEqual([0, 1]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("reads media metadata from owner-window media elements", () => {
|
|
200
|
+
const doc = createDocument(`
|
|
201
|
+
<div data-composition-id="main" data-start="0" data-duration="8">
|
|
202
|
+
<div class="clip video-card" data-start="0" data-duration="3" data-track-index="0">
|
|
203
|
+
<video src="/clip.mp4" data-source-duration="12"></video>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
`);
|
|
207
|
+
const hostEl = doc.querySelector(".video-card");
|
|
208
|
+
const video = hostEl?.querySelector("video");
|
|
209
|
+
if (!hostEl || !video) throw new Error("missing video test fixture");
|
|
210
|
+
Object.defineProperty(video, "defaultPlaybackRate", {
|
|
211
|
+
value: 1.5,
|
|
212
|
+
configurable: true,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const element = createTimelineElementFromManifestClip({
|
|
216
|
+
clip: createClip({ kind: "video", tagName: "div" }),
|
|
217
|
+
fallbackIndex: 0,
|
|
218
|
+
doc,
|
|
219
|
+
hostEl,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(element.tag).toBe("video");
|
|
223
|
+
expect(element.src).toBe("/clip.mp4");
|
|
224
|
+
expect(element.sourceDuration).toBe(12);
|
|
225
|
+
expect(element.playbackRate).toBe(1.5);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
90
229
|
describe("mergeTimelineElementsPreservingDowngrades", () => {
|
|
91
230
|
it("preserves missing current elements when a shorter manifest arrives", () => {
|
|
92
231
|
expect(
|
|
@@ -115,6 +254,65 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
|
|
|
115
254
|
),
|
|
116
255
|
).toEqual([{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }]);
|
|
117
256
|
});
|
|
257
|
+
|
|
258
|
+
it("preserves distinct anonymous clips that share the same friendly id label", () => {
|
|
259
|
+
expect(
|
|
260
|
+
mergeTimelineElementsPreservingDowngrades(
|
|
261
|
+
[
|
|
262
|
+
{
|
|
263
|
+
id: "Card",
|
|
264
|
+
key: "index.html:.card:0",
|
|
265
|
+
label: "Card",
|
|
266
|
+
tag: "div",
|
|
267
|
+
start: 0,
|
|
268
|
+
duration: 3,
|
|
269
|
+
track: 0,
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
id: "Card",
|
|
273
|
+
key: "index.html:.card:1",
|
|
274
|
+
label: "Card",
|
|
275
|
+
tag: "div",
|
|
276
|
+
start: 3,
|
|
277
|
+
duration: 3,
|
|
278
|
+
track: 1,
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
[
|
|
282
|
+
{
|
|
283
|
+
id: "Card",
|
|
284
|
+
key: "index.html:.card:0",
|
|
285
|
+
label: "Card",
|
|
286
|
+
tag: "div",
|
|
287
|
+
start: 0,
|
|
288
|
+
duration: 3,
|
|
289
|
+
track: 0,
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
8,
|
|
293
|
+
8,
|
|
294
|
+
),
|
|
295
|
+
).toEqual([
|
|
296
|
+
{
|
|
297
|
+
id: "Card",
|
|
298
|
+
key: "index.html:.card:0",
|
|
299
|
+
label: "Card",
|
|
300
|
+
tag: "div",
|
|
301
|
+
start: 0,
|
|
302
|
+
duration: 3,
|
|
303
|
+
track: 0,
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
id: "Card",
|
|
307
|
+
key: "index.html:.card:1",
|
|
308
|
+
label: "Card",
|
|
309
|
+
tag: "div",
|
|
310
|
+
start: 3,
|
|
311
|
+
duration: 3,
|
|
312
|
+
track: 1,
|
|
313
|
+
},
|
|
314
|
+
]);
|
|
315
|
+
});
|
|
118
316
|
});
|
|
119
317
|
|
|
120
318
|
describe("shouldIgnorePlaybackShortcutTarget", () => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useRef, useCallback } from "react";
|
|
2
2
|
import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore";
|
|
3
3
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
|
-
import {
|
|
4
|
+
import { stepFrameTime, STUDIO_PREVIEW_FPS } from "../lib/time";
|
|
5
5
|
import { useCaptionStore } from "../../captions/store";
|
|
6
6
|
|
|
7
7
|
interface PlaybackAdapter {
|
|
@@ -64,9 +64,12 @@ function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageElement | null {
|
|
67
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
207
|
-
|
|
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
|
|
257
|
-
|
|
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
|
|
261
|
-
const
|
|
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
|
|
458
|
-
const preserved = currentElements.filter(
|
|
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
|
}
|
|
@@ -697,7 +906,7 @@ export function useTimelinePlayer() {
|
|
|
697
906
|
(deltaFrames: number) => {
|
|
698
907
|
const adapter = getAdapter();
|
|
699
908
|
const currentTime = adapter?.getTime() ?? usePlayerStore.getState().currentTime;
|
|
700
|
-
seek(currentTime
|
|
909
|
+
seek(stepFrameTime(currentTime, deltaFrames, STUDIO_PREVIEW_FPS));
|
|
701
910
|
},
|
|
702
911
|
[getAdapter, seek],
|
|
703
912
|
);
|
|
@@ -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
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
-
|
|
899
|
-
|
|
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
|
|
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
|
-
|
|
1021
|
-
|
|
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,
|