@hyperframes/studio 0.4.12 → 0.4.13-alpha.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.
- package/dist/assets/hyperframes-player-BOs_kypk.js +198 -0
- package/dist/assets/index-BKkR67xb.css +1 -0
- package/dist/assets/index-rN5doSq1.js +93 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +289 -11
- package/src/components/nle/NLELayout.tsx +24 -7
- package/src/components/nle/NLEPreview.test.ts +32 -0
- package/src/components/nle/NLEPreview.tsx +12 -1
- package/src/player/components/CompositionThumbnail.tsx +94 -17
- package/src/player/components/EditModal.tsx +48 -29
- package/src/player/components/Player.tsx +5 -2
- package/src/player/components/PlayerControls.test.ts +20 -0
- package/src/player/components/PlayerControls.tsx +12 -1
- package/src/player/components/Timeline.test.ts +44 -1
- package/src/player/components/Timeline.tsx +686 -169
- package/src/player/components/TimelineClip.tsx +112 -16
- package/src/player/components/timelineEditing.test.ts +310 -0
- package/src/player/components/timelineEditing.ts +213 -0
- package/src/player/components/timelineTheme.test.ts +56 -0
- package/src/player/components/timelineTheme.ts +141 -0
- package/src/player/components/timelineZoom.test.ts +62 -0
- package/src/player/components/timelineZoom.ts +38 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +96 -0
- package/src/player/hooks/useTimelinePlayer.ts +313 -59
- package/src/player/store/playerStore.test.ts +30 -12
- package/src/player/store/playerStore.ts +23 -9
- package/src/types/hyperframes-player.d.ts +1 -0
- package/src/utils/sourcePatcher.test.ts +84 -0
- package/src/utils/sourcePatcher.ts +143 -0
- package/dist/assets/hyperframes-player-5iD9BZnx.js +0 -198
- package/dist/assets/index-CVDXfFQ6.js +0 -93
- package/dist/assets/index-jmDaI2F7.css +0 -1
|
@@ -61,6 +61,50 @@ function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
|
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageElement | null {
|
|
65
|
+
if (el instanceof HTMLMediaElement || el instanceof HTMLImageElement) return el;
|
|
66
|
+
const candidate = el.querySelector("video, audio, img");
|
|
67
|
+
return candidate instanceof HTMLMediaElement || candidate instanceof HTMLImageElement
|
|
68
|
+
? candidate
|
|
69
|
+
: null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void {
|
|
73
|
+
const mediaStartAttr = el.getAttribute("data-playback-start")
|
|
74
|
+
? "playback-start"
|
|
75
|
+
: el.getAttribute("data-media-start")
|
|
76
|
+
? "media-start"
|
|
77
|
+
: undefined;
|
|
78
|
+
const mediaStartValue =
|
|
79
|
+
el.getAttribute("data-playback-start") ?? el.getAttribute("data-media-start");
|
|
80
|
+
if (mediaStartValue != null) {
|
|
81
|
+
const playbackStart = parseFloat(mediaStartValue);
|
|
82
|
+
if (Number.isFinite(playbackStart)) entry.playbackStart = playbackStart;
|
|
83
|
+
}
|
|
84
|
+
if (mediaStartAttr) entry.playbackStartAttr = mediaStartAttr;
|
|
85
|
+
|
|
86
|
+
const mediaEl = resolveMediaElement(el);
|
|
87
|
+
if (!mediaEl) return;
|
|
88
|
+
|
|
89
|
+
entry.tag = mediaEl.tagName.toLowerCase();
|
|
90
|
+
const src = mediaEl.getAttribute("src");
|
|
91
|
+
if (src) entry.src = src;
|
|
92
|
+
|
|
93
|
+
if (!(mediaEl instanceof HTMLMediaElement)) return;
|
|
94
|
+
|
|
95
|
+
const sourceDurationAttr =
|
|
96
|
+
el.getAttribute("data-source-duration") ?? mediaEl.getAttribute("data-source-duration");
|
|
97
|
+
const sourceDuration = sourceDurationAttr ? parseFloat(sourceDurationAttr) : mediaEl.duration;
|
|
98
|
+
if (Number.isFinite(sourceDuration) && sourceDuration > 0) {
|
|
99
|
+
entry.sourceDuration = sourceDuration;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const playbackRate = mediaEl.defaultPlaybackRate;
|
|
103
|
+
if (Number.isFinite(playbackRate) && playbackRate > 0) {
|
|
104
|
+
entry.playbackRate = playbackRate;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
64
108
|
/**
|
|
65
109
|
* Parse [data-start] elements from a Document into TimelineElement[].
|
|
66
110
|
* Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
|
|
@@ -78,37 +122,60 @@ function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElem
|
|
|
78
122
|
if (startStr == null) return;
|
|
79
123
|
const start = parseFloat(startStr);
|
|
80
124
|
if (isNaN(start)) return;
|
|
125
|
+
if (Number.isFinite(rootDuration) && rootDuration > 0 && start >= rootDuration) return;
|
|
81
126
|
|
|
82
127
|
const tagLower = el.tagName.toLowerCase();
|
|
83
128
|
let dur = 0;
|
|
84
129
|
const durStr = el.getAttribute("data-duration");
|
|
85
130
|
if (durStr != null) dur = parseFloat(durStr);
|
|
86
131
|
if (isNaN(dur) || dur <= 0) dur = Math.max(0, rootDuration - start);
|
|
132
|
+
if (Number.isFinite(rootDuration) && rootDuration > 0) {
|
|
133
|
+
dur = Math.min(dur, Math.max(0, rootDuration - start));
|
|
134
|
+
}
|
|
135
|
+
if (!Number.isFinite(dur) || dur <= 0) return;
|
|
87
136
|
|
|
88
137
|
const trackStr = el.getAttribute("data-track-index");
|
|
89
138
|
const track = trackStr != null ? parseInt(trackStr, 10) : trackCounter++;
|
|
139
|
+
const compId = el.getAttribute("data-composition-id");
|
|
140
|
+
const selector = getTimelineElementSelector(el);
|
|
141
|
+
const sourceFile = getTimelineElementSourceFile(el);
|
|
142
|
+
const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
|
|
143
|
+
const id = el.id || compId || el.className?.split(" ")[0] || tagLower;
|
|
90
144
|
const entry: TimelineElement = {
|
|
91
|
-
id
|
|
145
|
+
id,
|
|
146
|
+
key: buildTimelineElementKey({
|
|
147
|
+
id,
|
|
148
|
+
fallbackIndex: els.length,
|
|
149
|
+
domId: el.id || undefined,
|
|
150
|
+
selector,
|
|
151
|
+
selectorIndex,
|
|
152
|
+
sourceFile,
|
|
153
|
+
}),
|
|
92
154
|
tag: tagLower,
|
|
93
155
|
start,
|
|
94
156
|
duration: dur,
|
|
95
157
|
track: isNaN(track) ? 0 : track,
|
|
158
|
+
domId: el.id || undefined,
|
|
159
|
+
selector,
|
|
160
|
+
selectorIndex,
|
|
161
|
+
sourceFile,
|
|
96
162
|
};
|
|
97
163
|
|
|
98
|
-
|
|
99
|
-
if (
|
|
100
|
-
|
|
164
|
+
const mediaEl = resolveMediaElement(el);
|
|
165
|
+
if (mediaEl) {
|
|
166
|
+
if (mediaEl.tagName === "IMG") {
|
|
167
|
+
entry.tag = "img";
|
|
168
|
+
}
|
|
169
|
+
const src = mediaEl.getAttribute("src");
|
|
101
170
|
if (src) entry.src = src;
|
|
102
|
-
const
|
|
103
|
-
if (ms) entry.playbackStart = parseFloat(ms);
|
|
104
|
-
const vol = el.getAttribute("data-volume");
|
|
171
|
+
const vol = el.getAttribute("data-volume") ?? mediaEl.getAttribute("data-volume");
|
|
105
172
|
if (vol) entry.volume = parseFloat(vol);
|
|
173
|
+
applyMediaMetadataFromElement(entry, el);
|
|
106
174
|
}
|
|
107
175
|
|
|
108
176
|
// Sub-compositions
|
|
109
177
|
const compSrc =
|
|
110
178
|
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
|
|
111
|
-
const compId = el.getAttribute("data-composition-id");
|
|
112
179
|
if (compSrc) {
|
|
113
180
|
entry.compositionSrc = compSrc;
|
|
114
181
|
} else if (compId && compId !== rootComp?.getAttribute("data-composition-id")) {
|
|
@@ -126,6 +193,104 @@ function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElem
|
|
|
126
193
|
return els;
|
|
127
194
|
}
|
|
128
195
|
|
|
196
|
+
function getTimelineElementSelector(el: Element): string | undefined {
|
|
197
|
+
if (el instanceof HTMLElement && el.id) return `#${el.id}`;
|
|
198
|
+
const compId = el.getAttribute("data-composition-id");
|
|
199
|
+
if (compId) return `[data-composition-id="${compId}"]`;
|
|
200
|
+
if (el instanceof HTMLElement) {
|
|
201
|
+
const firstClass = el.className.split(/\s+/).find(Boolean);
|
|
202
|
+
if (firstClass) return `.${firstClass}`;
|
|
203
|
+
}
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function getTimelineElementSourceFile(el: Element): string | undefined {
|
|
208
|
+
const ownerRoot = el.parentElement?.closest("[data-composition-id]");
|
|
209
|
+
return (
|
|
210
|
+
ownerRoot?.getAttribute("data-composition-file") ??
|
|
211
|
+
ownerRoot?.getAttribute("data-composition-src") ??
|
|
212
|
+
undefined
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function getTimelineElementSelectorIndex(
|
|
217
|
+
doc: Document,
|
|
218
|
+
el: Element,
|
|
219
|
+
selector: string | undefined,
|
|
220
|
+
): number | undefined {
|
|
221
|
+
if (!selector || selector.startsWith("#") || selector.startsWith("[data-composition-id=")) {
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const matches = Array.from(doc.querySelectorAll(selector));
|
|
227
|
+
const matchIndex = matches.indexOf(el);
|
|
228
|
+
return matchIndex >= 0 ? matchIndex : undefined;
|
|
229
|
+
} catch {
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function buildTimelineElementKey(params: {
|
|
235
|
+
id: string;
|
|
236
|
+
fallbackIndex: number;
|
|
237
|
+
domId?: string;
|
|
238
|
+
selector?: string;
|
|
239
|
+
selectorIndex?: number;
|
|
240
|
+
sourceFile?: string;
|
|
241
|
+
}): string {
|
|
242
|
+
const scope = params.sourceFile ?? "index.html";
|
|
243
|
+
if (params.domId) return `${scope}#${params.domId}`;
|
|
244
|
+
if (params.selector) return `${scope}:${params.selector}:${params.selectorIndex ?? 0}`;
|
|
245
|
+
return `${scope}:${params.id}:${params.fallbackIndex}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function findTimelineDomNode(doc: Document, id: string): Element | null {
|
|
249
|
+
return (
|
|
250
|
+
doc.getElementById(id) ??
|
|
251
|
+
doc.querySelector(`[data-composition-id="${id}"]`) ??
|
|
252
|
+
doc.querySelector(`.${id}`) ??
|
|
253
|
+
null
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function resolveStandaloneRootCompositionSrc(iframeSrc: string): string | undefined {
|
|
258
|
+
const compPathMatch = iframeSrc.match(/\/preview\/comp\/(.+?)(?:\?|$)/);
|
|
259
|
+
return compPathMatch ? decodeURIComponent(compPathMatch[1]) : undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function buildStandaloneRootTimelineElement(params: {
|
|
263
|
+
compositionId: string;
|
|
264
|
+
tagName: string;
|
|
265
|
+
rootDuration: number;
|
|
266
|
+
iframeSrc: string;
|
|
267
|
+
selector?: string;
|
|
268
|
+
selectorIndex?: number;
|
|
269
|
+
}): TimelineElement | null {
|
|
270
|
+
if (!Number.isFinite(params.rootDuration) || params.rootDuration <= 0) return null;
|
|
271
|
+
|
|
272
|
+
const compositionSrc = resolveStandaloneRootCompositionSrc(params.iframeSrc);
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
id: params.compositionId,
|
|
276
|
+
key: buildTimelineElementKey({
|
|
277
|
+
id: params.compositionId,
|
|
278
|
+
fallbackIndex: 0,
|
|
279
|
+
selector: params.selector,
|
|
280
|
+
selectorIndex: params.selectorIndex,
|
|
281
|
+
sourceFile: compositionSrc,
|
|
282
|
+
}),
|
|
283
|
+
tag: params.tagName.toLowerCase() || "div",
|
|
284
|
+
start: 0,
|
|
285
|
+
duration: params.rootDuration,
|
|
286
|
+
track: 0,
|
|
287
|
+
compositionSrc,
|
|
288
|
+
selector: params.selector,
|
|
289
|
+
selectorIndex: params.selectorIndex,
|
|
290
|
+
sourceFile: compositionSrc,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
129
294
|
function normalizePreviewViewport(doc: Document, win: Window): void {
|
|
130
295
|
if (doc.documentElement) {
|
|
131
296
|
doc.documentElement.style.overflow = "hidden";
|
|
@@ -212,6 +377,29 @@ export function resolveIframe(el: Element | null): HTMLIFrameElement | null {
|
|
|
212
377
|
return el.shadowRoot?.querySelector("iframe") ?? el.querySelector("iframe") ?? null;
|
|
213
378
|
}
|
|
214
379
|
|
|
380
|
+
export function mergeTimelineElementsPreservingDowngrades(
|
|
381
|
+
currentElements: TimelineElement[],
|
|
382
|
+
nextElements: TimelineElement[],
|
|
383
|
+
currentDuration: number,
|
|
384
|
+
nextDuration: number,
|
|
385
|
+
): TimelineElement[] {
|
|
386
|
+
const safeCurrentDuration = Number.isFinite(currentDuration) ? currentDuration : 0;
|
|
387
|
+
const safeNextDuration = Number.isFinite(nextDuration) ? nextDuration : 0;
|
|
388
|
+
|
|
389
|
+
if (
|
|
390
|
+
currentElements.length === 0 ||
|
|
391
|
+
nextElements.length >= currentElements.length ||
|
|
392
|
+
safeNextDuration > safeCurrentDuration
|
|
393
|
+
) {
|
|
394
|
+
return nextElements;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const nextIds = new Set(nextElements.map((element) => element.id));
|
|
398
|
+
const preserved = currentElements.filter((element) => !nextIds.has(element.id));
|
|
399
|
+
if (preserved.length === 0) return nextElements;
|
|
400
|
+
return [...nextElements, ...preserved];
|
|
401
|
+
}
|
|
402
|
+
|
|
215
403
|
export function useTimelinePlayer() {
|
|
216
404
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
217
405
|
const rafRef = useRef<number>(0);
|
|
@@ -224,6 +412,24 @@ export function useTimelinePlayer() {
|
|
|
224
412
|
const { setIsPlaying, setCurrentTime, setDuration, setTimelineReady, setElements } =
|
|
225
413
|
usePlayerStore.getState();
|
|
226
414
|
|
|
415
|
+
const syncTimelineElements = useCallback(
|
|
416
|
+
(elements: TimelineElement[], nextDuration?: number) => {
|
|
417
|
+
const state = usePlayerStore.getState();
|
|
418
|
+
const mergedElements = mergeTimelineElementsPreservingDowngrades(
|
|
419
|
+
state.elements,
|
|
420
|
+
elements,
|
|
421
|
+
state.duration,
|
|
422
|
+
nextDuration ?? state.duration,
|
|
423
|
+
);
|
|
424
|
+
setElements(mergedElements);
|
|
425
|
+
if (Number.isFinite(nextDuration) && (nextDuration ?? 0) > 0) {
|
|
426
|
+
setDuration(nextDuration ?? 0);
|
|
427
|
+
}
|
|
428
|
+
setTimelineReady(true);
|
|
429
|
+
},
|
|
430
|
+
[setElements, setTimelineReady, setDuration],
|
|
431
|
+
);
|
|
432
|
+
|
|
227
433
|
const getAdapter = useCallback((): PlaybackAdapter | null => {
|
|
228
434
|
try {
|
|
229
435
|
const iframe = iframeRef.current;
|
|
@@ -363,14 +569,35 @@ export function useTimelinePlayer() {
|
|
|
363
569
|
const filtered = data.clips.filter(
|
|
364
570
|
(clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId),
|
|
365
571
|
);
|
|
366
|
-
const els: TimelineElement[] = filtered.map((clip) => {
|
|
572
|
+
const els: TimelineElement[] = filtered.map((clip, index) => {
|
|
573
|
+
let hostEl: Element | null = null;
|
|
574
|
+
const id = clip.id || clip.label || clip.tagName || "element";
|
|
367
575
|
const entry: TimelineElement = {
|
|
368
|
-
id
|
|
576
|
+
id,
|
|
369
577
|
tag: clip.tagName || clip.kind,
|
|
370
578
|
start: clip.start,
|
|
371
579
|
duration: clip.duration,
|
|
372
580
|
track: clip.track,
|
|
373
581
|
};
|
|
582
|
+
try {
|
|
583
|
+
const iframeDoc = iframeRef.current?.contentDocument;
|
|
584
|
+
if (iframeDoc && entry.id) {
|
|
585
|
+
hostEl = findTimelineDomNode(iframeDoc, entry.id);
|
|
586
|
+
}
|
|
587
|
+
} catch {
|
|
588
|
+
/* cross-origin */
|
|
589
|
+
}
|
|
590
|
+
if (hostEl) {
|
|
591
|
+
const iframeDoc = iframeRef.current?.contentDocument;
|
|
592
|
+
entry.domId = hostEl.id || undefined;
|
|
593
|
+
entry.selector = getTimelineElementSelector(hostEl);
|
|
594
|
+
entry.selectorIndex =
|
|
595
|
+
iframeDoc && entry.selector
|
|
596
|
+
? getTimelineElementSelectorIndex(iframeDoc, hostEl, entry.selector)
|
|
597
|
+
: undefined;
|
|
598
|
+
entry.sourceFile = getTimelineElementSourceFile(hostEl);
|
|
599
|
+
applyMediaMetadataFromElement(entry, hostEl);
|
|
600
|
+
}
|
|
374
601
|
if (clip.assetUrl) entry.src = clip.assetUrl;
|
|
375
602
|
if (clip.kind === "composition" && clip.compositionId) {
|
|
376
603
|
// The bundler renames data-composition-src to data-composition-file
|
|
@@ -382,7 +609,7 @@ export function useTimelinePlayer() {
|
|
|
382
609
|
try {
|
|
383
610
|
const iframeDoc = iframeRef.current?.contentDocument;
|
|
384
611
|
hostEl =
|
|
385
|
-
iframeDoc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ??
|
|
612
|
+
iframeDoc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
|
|
386
613
|
resolvedSrc =
|
|
387
614
|
hostEl?.getAttribute("data-composition-src") ??
|
|
388
615
|
hostEl?.getAttribute("data-composition-file") ??
|
|
@@ -401,32 +628,48 @@ export function useTimelinePlayer() {
|
|
|
401
628
|
entry.tag = "video";
|
|
402
629
|
}
|
|
403
630
|
}
|
|
631
|
+
if (hostEl) {
|
|
632
|
+
const iframeDoc = iframeRef.current?.contentDocument;
|
|
633
|
+
entry.domId = hostEl.id || undefined;
|
|
634
|
+
entry.selector = getTimelineElementSelector(hostEl);
|
|
635
|
+
entry.selectorIndex =
|
|
636
|
+
iframeDoc && entry.selector
|
|
637
|
+
? getTimelineElementSelectorIndex(iframeDoc, hostEl, entry.selector)
|
|
638
|
+
: undefined;
|
|
639
|
+
entry.sourceFile = getTimelineElementSourceFile(hostEl);
|
|
640
|
+
}
|
|
404
641
|
}
|
|
642
|
+
entry.key = buildTimelineElementKey({
|
|
643
|
+
id,
|
|
644
|
+
fallbackIndex: index,
|
|
645
|
+
domId: entry.domId,
|
|
646
|
+
selector: entry.selector,
|
|
647
|
+
selectorIndex: entry.selectorIndex,
|
|
648
|
+
sourceFile: entry.sourceFile,
|
|
649
|
+
});
|
|
405
650
|
return entry;
|
|
406
651
|
});
|
|
407
|
-
// Don't downgrade: if we already have more elements with a longer duration,
|
|
408
|
-
// skip updates that would show fewer clips (transient runtime state).
|
|
409
|
-
const currentElements = usePlayerStore.getState().elements;
|
|
410
|
-
const currentDuration = usePlayerStore.getState().duration;
|
|
411
652
|
const rawDuration = data.durationInFrames / 30;
|
|
412
653
|
// Clamp non-finite or absurdly large durations — the runtime can emit
|
|
413
654
|
// Infinity when it detects a loop-inflated GSAP timeline without an
|
|
414
655
|
// explicit data-duration on the root composition.
|
|
415
656
|
const newDuration = Number.isFinite(rawDuration) && rawDuration < 7200 ? rawDuration : 0;
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
657
|
+
const effectiveDuration = newDuration > 0 ? newDuration : usePlayerStore.getState().duration;
|
|
658
|
+
const clampedEls =
|
|
659
|
+
effectiveDuration > 0
|
|
660
|
+
? els
|
|
661
|
+
.filter((element) => element.start < effectiveDuration)
|
|
662
|
+
.map((element) => ({
|
|
663
|
+
...element,
|
|
664
|
+
duration: Math.min(element.duration, effectiveDuration - element.start),
|
|
665
|
+
}))
|
|
666
|
+
.filter((element) => element.duration > 0)
|
|
667
|
+
: els;
|
|
668
|
+
if (clampedEls.length > 0) {
|
|
669
|
+
syncTimelineElements(clampedEls, newDuration > 0 ? newDuration : undefined);
|
|
426
670
|
}
|
|
427
|
-
if (els.length > 0) setTimelineReady(true);
|
|
428
671
|
},
|
|
429
|
-
[
|
|
672
|
+
[syncTimelineElements],
|
|
430
673
|
);
|
|
431
674
|
|
|
432
675
|
/**
|
|
@@ -504,17 +747,39 @@ export function useTimelinePlayer() {
|
|
|
504
747
|
}
|
|
505
748
|
if (!Number.isFinite(dur) || dur <= 0) return;
|
|
506
749
|
if (!Number.isFinite(start)) start = 0;
|
|
750
|
+
const rootDuration = usePlayerStore.getState().duration;
|
|
751
|
+
if (Number.isFinite(rootDuration) && rootDuration > 0) {
|
|
752
|
+
if (start >= rootDuration) return;
|
|
753
|
+
dur = Math.min(dur, Math.max(0, rootDuration - start));
|
|
754
|
+
if (dur <= 0) return;
|
|
755
|
+
}
|
|
507
756
|
|
|
508
757
|
const trackStr = el.getAttribute("data-track-index");
|
|
509
758
|
const track = trackStr != null ? parseInt(trackStr, 10) : 0;
|
|
510
759
|
const compSrc =
|
|
511
760
|
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
|
|
761
|
+
const selector = getTimelineElementSelector(el);
|
|
762
|
+
const sourceFile = getTimelineElementSourceFile(el);
|
|
763
|
+
const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
|
|
764
|
+
const id = el.id || compId;
|
|
512
765
|
const entry: TimelineElement = {
|
|
513
|
-
id
|
|
766
|
+
id,
|
|
767
|
+
key: buildTimelineElementKey({
|
|
768
|
+
id,
|
|
769
|
+
fallbackIndex: missing.length,
|
|
770
|
+
domId: el.id || undefined,
|
|
771
|
+
selector,
|
|
772
|
+
selectorIndex,
|
|
773
|
+
sourceFile,
|
|
774
|
+
}),
|
|
514
775
|
tag: el.tagName.toLowerCase(),
|
|
515
776
|
start,
|
|
516
777
|
duration: dur,
|
|
517
778
|
track: isNaN(track) ? 0 : track,
|
|
779
|
+
domId: el.id || undefined,
|
|
780
|
+
selector,
|
|
781
|
+
selectorIndex,
|
|
782
|
+
sourceFile,
|
|
518
783
|
};
|
|
519
784
|
if (compSrc) {
|
|
520
785
|
entry.compositionSrc = compSrc;
|
|
@@ -551,13 +816,12 @@ export function useTimelinePlayer() {
|
|
|
551
816
|
// Dedup: ensure no missing element duplicates an existing one
|
|
552
817
|
const finalIds = new Set(updatedEls.map((e) => e.id));
|
|
553
818
|
const dedupedMissing = missing.filter((m) => !finalIds.has(m.id));
|
|
554
|
-
|
|
555
|
-
setTimelineReady(true);
|
|
819
|
+
syncTimelineElements([...updatedEls, ...dedupedMissing]);
|
|
556
820
|
}
|
|
557
821
|
} catch (err) {
|
|
558
822
|
console.warn("[useTimelinePlayer] enrichMissingCompositions failed", err);
|
|
559
823
|
}
|
|
560
|
-
}, [
|
|
824
|
+
}, [syncTimelineElements]);
|
|
561
825
|
|
|
562
826
|
const onIframeLoad = useCallback(() => {
|
|
563
827
|
unmutePreviewMedia(iframeRef.current);
|
|
@@ -613,8 +877,7 @@ export function useTimelinePlayer() {
|
|
|
613
877
|
// Fallback: parse data-start elements directly from DOM (raw HTML without runtime)
|
|
614
878
|
const els = parseTimelineFromDOM(doc, adapter.getDuration());
|
|
615
879
|
if (els.length > 0) {
|
|
616
|
-
|
|
617
|
-
setTimelineReady(true);
|
|
880
|
+
syncTimelineElements(els);
|
|
618
881
|
}
|
|
619
882
|
}
|
|
620
883
|
|
|
@@ -626,27 +889,18 @@ export function useTimelinePlayer() {
|
|
|
626
889
|
const rootComp = doc.querySelector("[data-composition-id]");
|
|
627
890
|
const rootDuration = adapter.getDuration();
|
|
628
891
|
if (rootComp && rootDuration > 0) {
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
id: rootId,
|
|
642
|
-
tag: (rootComp as HTMLElement).tagName?.toLowerCase() || "div",
|
|
643
|
-
start: 0,
|
|
644
|
-
duration: rootDuration,
|
|
645
|
-
track: 0,
|
|
646
|
-
compositionSrc,
|
|
647
|
-
},
|
|
648
|
-
]);
|
|
649
|
-
setTimelineReady(true);
|
|
892
|
+
const fallbackElement = buildStandaloneRootTimelineElement({
|
|
893
|
+
compositionId: rootComp.getAttribute("data-composition-id") || "composition",
|
|
894
|
+
tagName: (rootComp as HTMLElement).tagName || "div",
|
|
895
|
+
rootDuration,
|
|
896
|
+
iframeSrc: iframe?.src || "",
|
|
897
|
+
selector: getTimelineElementSelector(rootComp),
|
|
898
|
+
});
|
|
899
|
+
if (fallbackElement) {
|
|
900
|
+
// Always show the root composition as a single clip — guarantees
|
|
901
|
+
// the timeline is never empty when a valid composition is loaded.
|
|
902
|
+
syncTimelineElements([fallbackElement]);
|
|
903
|
+
}
|
|
650
904
|
}
|
|
651
905
|
}
|
|
652
906
|
// The runtime will also postMessage the full timeline after all compositions load.
|
|
@@ -672,6 +926,7 @@ export function useTimelinePlayer() {
|
|
|
672
926
|
setIsPlaying,
|
|
673
927
|
processTimelineMessage,
|
|
674
928
|
enrichMissingCompositions,
|
|
929
|
+
syncTimelineElements,
|
|
675
930
|
]);
|
|
676
931
|
|
|
677
932
|
/** Save the current playback time so the next onIframeLoad restores it. */
|
|
@@ -745,12 +1000,12 @@ export function useTimelinePlayer() {
|
|
|
745
1000
|
processTimelineMessageRef.current(data);
|
|
746
1001
|
// Fill in composition hosts the manifest missed (element-reference starts)
|
|
747
1002
|
enrichMissingCompositionsRef.current();
|
|
748
|
-
// Update duration only if the new value is longer (don't downgrade during generation)
|
|
749
1003
|
if (data.durationInFrames > 0 && Number.isFinite(data.durationInFrames)) {
|
|
750
1004
|
const fps = 30;
|
|
751
1005
|
const dur = data.durationInFrames / fps;
|
|
752
|
-
|
|
753
|
-
|
|
1006
|
+
if (dur > 0 && dur < 7200) {
|
|
1007
|
+
usePlayerStore.getState().setDuration(dur);
|
|
1008
|
+
}
|
|
754
1009
|
}
|
|
755
1010
|
// If manifest produced 0 elements after filtering, try DOM fallback
|
|
756
1011
|
if (usePlayerStore.getState().elements.length === 0) {
|
|
@@ -760,8 +1015,7 @@ export function useTimelinePlayer() {
|
|
|
760
1015
|
if (doc && adapter) {
|
|
761
1016
|
const els = parseTimelineFromDOM(doc, adapter.getDuration());
|
|
762
1017
|
if (els.length > 0) {
|
|
763
|
-
|
|
764
|
-
setTimelineReady(true);
|
|
1018
|
+
syncTimelineElements(els);
|
|
765
1019
|
}
|
|
766
1020
|
}
|
|
767
1021
|
} catch (err) {
|
|
@@ -17,7 +17,7 @@ describe("usePlayerStore", () => {
|
|
|
17
17
|
expect(state.selectedElementId).toBeNull();
|
|
18
18
|
expect(state.playbackRate).toBe(1);
|
|
19
19
|
expect(state.zoomMode).toBe("fit");
|
|
20
|
-
expect(state.
|
|
20
|
+
expect(state.manualZoomPercent).toBe(100);
|
|
21
21
|
});
|
|
22
22
|
});
|
|
23
23
|
|
|
@@ -132,6 +132,19 @@ describe("usePlayerStore", () => {
|
|
|
132
132
|
usePlayerStore.getState().updateElement("nonexistent", { start: 10 });
|
|
133
133
|
expect(usePlayerStore.getState().elements[0].start).toBe(0);
|
|
134
134
|
});
|
|
135
|
+
|
|
136
|
+
it("prefers the stable element key when duplicate ids exist", () => {
|
|
137
|
+
usePlayerStore.getState().setElements([
|
|
138
|
+
{ id: "headline", key: "a", tag: "div", start: 0, duration: 5, track: 0 },
|
|
139
|
+
{ id: "headline", key: "b", tag: "div", start: 5, duration: 5, track: 1 },
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
usePlayerStore.getState().updateElement("b", { start: 9 });
|
|
143
|
+
|
|
144
|
+
const elements = usePlayerStore.getState().elements;
|
|
145
|
+
expect(elements[0].start).toBe(0);
|
|
146
|
+
expect(elements[1].start).toBe(9);
|
|
147
|
+
});
|
|
135
148
|
});
|
|
136
149
|
|
|
137
150
|
describe("setZoomMode", () => {
|
|
@@ -147,20 +160,25 @@ describe("usePlayerStore", () => {
|
|
|
147
160
|
});
|
|
148
161
|
});
|
|
149
162
|
|
|
150
|
-
describe("
|
|
151
|
-
it("updates
|
|
152
|
-
usePlayerStore.getState().
|
|
153
|
-
expect(usePlayerStore.getState().
|
|
163
|
+
describe("setManualZoomPercent", () => {
|
|
164
|
+
it("updates the manual zoom percent", () => {
|
|
165
|
+
usePlayerStore.getState().setManualZoomPercent(200);
|
|
166
|
+
expect(usePlayerStore.getState().manualZoomPercent).toBe(200);
|
|
154
167
|
});
|
|
155
168
|
|
|
156
169
|
it("clamps to minimum of 10", () => {
|
|
157
|
-
usePlayerStore.getState().
|
|
158
|
-
expect(usePlayerStore.getState().
|
|
170
|
+
usePlayerStore.getState().setManualZoomPercent(5);
|
|
171
|
+
expect(usePlayerStore.getState().manualZoomPercent).toBe(10);
|
|
159
172
|
});
|
|
160
173
|
|
|
161
174
|
it("clamps negative values to 10", () => {
|
|
162
|
-
usePlayerStore.getState().
|
|
163
|
-
expect(usePlayerStore.getState().
|
|
175
|
+
usePlayerStore.getState().setManualZoomPercent(-50);
|
|
176
|
+
expect(usePlayerStore.getState().manualZoomPercent).toBe(10);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("clamps to the maximum supported zoom percent", () => {
|
|
180
|
+
usePlayerStore.getState().setManualZoomPercent(5000);
|
|
181
|
+
expect(usePlayerStore.getState().manualZoomPercent).toBe(2000);
|
|
164
182
|
});
|
|
165
183
|
});
|
|
166
184
|
|
|
@@ -187,11 +205,11 @@ describe("usePlayerStore", () => {
|
|
|
187
205
|
expect(state.selectedElementId).toBeNull();
|
|
188
206
|
});
|
|
189
207
|
|
|
190
|
-
it("does not reset playbackRate, zoomMode, or
|
|
208
|
+
it("does not reset playbackRate, zoomMode, or manualZoomPercent", () => {
|
|
191
209
|
const store = usePlayerStore.getState();
|
|
192
210
|
store.setPlaybackRate(2);
|
|
193
211
|
store.setZoomMode("manual");
|
|
194
|
-
store.
|
|
212
|
+
store.setManualZoomPercent(200);
|
|
195
213
|
|
|
196
214
|
usePlayerStore.getState().reset();
|
|
197
215
|
|
|
@@ -199,7 +217,7 @@ describe("usePlayerStore", () => {
|
|
|
199
217
|
// reset() only resets the fields explicitly listed in the reset function
|
|
200
218
|
expect(state.playbackRate).toBe(2);
|
|
201
219
|
expect(state.zoomMode).toBe("manual");
|
|
202
|
-
expect(state.
|
|
220
|
+
expect(state.manualZoomPercent).toBe(200);
|
|
203
221
|
});
|
|
204
222
|
});
|
|
205
223
|
});
|