@hyperframes/studio 0.8.72 → 0.8.74
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-Bs9qG2hg.js +464 -0
- package/dist/assets/{index-DpVD1igA.js → index-B86QTmD2.js} +1 -1
- package/dist/assets/{index-BqUGSWqN.js → index-DhTyI9zH.js} +173 -173
- package/dist/assets/{index-HuYqM2Fi.js → index-vccPjvCl.js} +1 -1
- package/dist/index.d.ts +14 -1
- package/dist/index.html +1 -1
- package/dist/index.js +1434 -1332
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/hooks/appHotkeysDispatch.ts +9 -3
- package/src/index.ts +2 -0
- package/src/player/components/PlayerControls.tsx +5 -0
- package/src/player/components/ShortcutsPanel.test.tsx +51 -0
- package/src/player/components/ShortcutsPanel.tsx +7 -90
- package/src/player/components/TimelineRuler.test.tsx +98 -0
- package/src/player/components/TimelineRuler.tsx +99 -2
- package/src/player/components/TimelineRulerPart.tsx +1 -0
- package/src/player/components/studioShortcuts.ts +101 -0
- package/src/player/components/useTimelinePlayhead.test.tsx +65 -0
- package/src/player/components/useTimelinePlayhead.ts +2 -1
- package/src/player/components/useTimelineScrollViewport.ts +4 -1
- package/src/player/hooks/useShadowPreviewReload.ts +15 -5
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +43 -6
- package/src/player/lib/timelineDOM.test.ts +86 -0
- package/src/player/lib/timelineDOM.ts +5 -5
- package/src/player/lib/timelineElementHelpers.ts +10 -0
- package/dist/assets/hyperframes-player-jTC1EDAI.js +0 -464
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act, createRef } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { useTimelinePlayhead } from "./useTimelinePlayhead";
|
|
7
|
+
|
|
8
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
9
|
+
|
|
10
|
+
function scrollBox(scrollLeft: number) {
|
|
11
|
+
const el = document.createElement("div");
|
|
12
|
+
let left = scrollLeft;
|
|
13
|
+
Object.defineProperties(el, {
|
|
14
|
+
clientWidth: { value: 800 },
|
|
15
|
+
scrollWidth: { value: 20_000 },
|
|
16
|
+
scrollLeft: { get: () => left, set: (v: number) => (left = v) },
|
|
17
|
+
});
|
|
18
|
+
return el;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function Harness({ pps, scroll }: { pps: number; scroll: HTMLDivElement }) {
|
|
22
|
+
useTimelinePlayhead({
|
|
23
|
+
playheadRef: createRef(),
|
|
24
|
+
scrollRef: { current: scroll },
|
|
25
|
+
ppsRef: { current: pps },
|
|
26
|
+
durationRef: { current: 60 },
|
|
27
|
+
isDragging: { current: false },
|
|
28
|
+
currentTime: 0,
|
|
29
|
+
zoomMode: "manual",
|
|
30
|
+
manualZoomPercent: 100,
|
|
31
|
+
zoomModeRef: { current: "manual" },
|
|
32
|
+
manualZoomPercentRef: { current: 100 },
|
|
33
|
+
fitPps: pps,
|
|
34
|
+
fitPpsRef: { current: pps },
|
|
35
|
+
effectiveDuration: 60,
|
|
36
|
+
pps,
|
|
37
|
+
timelineReady: true,
|
|
38
|
+
elementsLength: 1,
|
|
39
|
+
setZoomMode: () => {},
|
|
40
|
+
setManualZoomPercent: () => {},
|
|
41
|
+
contentOrigin: 32,
|
|
42
|
+
});
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function zoom(scrollLeft: number, fromPps: number, toPps: number) {
|
|
47
|
+
const scroll = scrollBox(scrollLeft);
|
|
48
|
+
const host = document.createElement("div");
|
|
49
|
+
const root = createRoot(host);
|
|
50
|
+
act(() => root.render(<Harness pps={fromPps} scroll={scroll} />));
|
|
51
|
+
act(() => root.render(<Harness pps={toPps} scroll={scroll} />));
|
|
52
|
+
act(() => root.unmount());
|
|
53
|
+
return scroll.scrollLeft;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("useTimelinePlayhead centre anchor", () => {
|
|
57
|
+
it("keeps a view at the start at the start when the scale changes", () => {
|
|
58
|
+
expect(zoom(0, 100, 114)).toBe(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("keeps the time at the viewport centre when the view is scrolled", () => {
|
|
62
|
+
// Centre time (400 + 400 - 32) / 100 = 7.68s lands at 32 + 7.68 * 200 - 400.
|
|
63
|
+
expect(zoom(400, 100, 200)).toBe(1168);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -75,7 +75,8 @@ export function useTimelinePlayhead({
|
|
|
75
75
|
// it true and the next toolbar zoom would wrongly skip center-anchoring.
|
|
76
76
|
const skip = skipCenterAnchorRef.current;
|
|
77
77
|
skipCenterAnchorRef.current = false;
|
|
78
|
-
|
|
78
|
+
// A view at the start stays there: a resize or a host zoom must not hide 00:00.
|
|
79
|
+
if (!scroll || pps === prevPps || skip || scroll.scrollLeft < 1) return;
|
|
79
80
|
const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
|
|
80
81
|
pointerX: scroll.clientWidth / 2,
|
|
81
82
|
currentScrollLeft: scroll.scrollLeft,
|
|
@@ -3,6 +3,9 @@ import { useMountEffect } from "../../hooks/useMountEffect";
|
|
|
3
3
|
import { shouldShowTimelineShortcutHint } from "./timelineLayout";
|
|
4
4
|
import { STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED } from "./timelineRowVirtualizationFlag";
|
|
5
5
|
|
|
6
|
+
/** How long the timeline waits after the last scroll event before treating the scroll as settled. */
|
|
7
|
+
export const TIMELINE_SCROLL_SETTLE_MS = 100;
|
|
8
|
+
|
|
6
9
|
export interface TimelineScrollViewportSnapshot {
|
|
7
10
|
readonly scrollLeft: number;
|
|
8
11
|
readonly scrollTop: number;
|
|
@@ -91,7 +94,7 @@ export function useTimelineScrollViewport(
|
|
|
91
94
|
scrollingRef.current = false;
|
|
92
95
|
if (viewportRafRef.current) cancelAnimationFrame(viewportRafRef.current);
|
|
93
96
|
publish();
|
|
94
|
-
},
|
|
97
|
+
}, TIMELINE_SCROLL_SETTLE_MS);
|
|
95
98
|
}, []);
|
|
96
99
|
|
|
97
100
|
const syncShortcutHintVisibility = useCallback(() => {
|
|
@@ -15,10 +15,12 @@ import {
|
|
|
15
15
|
import type { PlaybackAdapter } from "../lib/playbackTypes";
|
|
16
16
|
import { thumbnailScheduler } from "../lib/thumbnailScheduler";
|
|
17
17
|
|
|
18
|
-
//
|
|
18
|
+
// One wait budget for a shadow: the player's 8s asset cap plus its 0.42s loader fade
|
|
19
19
|
// leaves about 6.5s for the document load and runtime boot. Nothing shorter may fail the swap.
|
|
20
20
|
// It only runs while the tab is visible: readiness is frame-driven, and a hidden tab renders none.
|
|
21
21
|
export const SHADOW_READY_TIMEOUT_MS = 15_000;
|
|
22
|
+
// A busy machine can need more than one budget; the shadow keeps loading for this many before it is dropped.
|
|
23
|
+
export const SHADOW_READY_BUDGETS = 3;
|
|
22
24
|
|
|
23
25
|
function isDocumentHidden(): boolean {
|
|
24
26
|
return typeof document !== "undefined" && document.visibilityState === "hidden";
|
|
@@ -61,6 +63,7 @@ export function useShadowPreviewReload({
|
|
|
61
63
|
const onReloadFailedRef = useRef(onReloadFailed);
|
|
62
64
|
onReloadFailedRef.current = onReloadFailed;
|
|
63
65
|
const readyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
66
|
+
const budgetsSpentRef = useRef(0);
|
|
64
67
|
// The shadow still owed a wait budget, so a hidden tab can resume it when it becomes visible.
|
|
65
68
|
const budgetGenRef = useRef<number | null>(null);
|
|
66
69
|
const cancelPendingLoadRef = useRef<() => void>(() => {});
|
|
@@ -160,10 +163,16 @@ export function useShadowPreviewReload({
|
|
|
160
163
|
(gen: number) => {
|
|
161
164
|
clearTimeout(readyTimerRef.current);
|
|
162
165
|
if (isDocumentHidden()) return;
|
|
163
|
-
readyTimerRef.current = setTimeout(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
166
|
+
readyTimerRef.current = setTimeout(() => {
|
|
167
|
+
if (gen !== shadowGenRef.current) return;
|
|
168
|
+
budgetsSpentRef.current += 1;
|
|
169
|
+
if (budgetsSpentRef.current < SHADOW_READY_BUDGETS) {
|
|
170
|
+
logReload("shadow-slow", { budgetsSpent: budgetsSpentRef.current });
|
|
171
|
+
armReadyTimerRef.current(gen);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
failShadow(gen, "it took too long to load");
|
|
175
|
+
}, SHADOW_READY_TIMEOUT_MS);
|
|
167
176
|
},
|
|
168
177
|
[failShadow],
|
|
169
178
|
);
|
|
@@ -176,6 +185,7 @@ export function useShadowPreviewReload({
|
|
|
176
185
|
const gen = shadowGenRef.current;
|
|
177
186
|
stopPendingShadow();
|
|
178
187
|
budgetGenRef.current = gen;
|
|
188
|
+
budgetsSpentRef.current = 0;
|
|
179
189
|
armReadyTimer(gen);
|
|
180
190
|
setPreviewSlots((prev) => planShadowReload(prev, gen, url));
|
|
181
191
|
// Thumbnails of the edit wait for the new preview instead of competing with it.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import React, { act } from "react";
|
|
5
5
|
import { createRoot } from "react-dom/client";
|
|
6
6
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
|
-
import { SHADOW_READY_TIMEOUT_MS } from "./useShadowPreviewReload";
|
|
7
|
+
import { SHADOW_READY_BUDGETS, SHADOW_READY_TIMEOUT_MS } from "./useShadowPreviewReload";
|
|
8
8
|
import { usePlayerStore } from "../store/playerStore";
|
|
9
9
|
import { NLEProvider, useNLEContext, type NLEContextValue } from "../../components/nle/NLEContext";
|
|
10
10
|
import {
|
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
|
|
17
17
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
18
18
|
|
|
19
|
+
const FULL_WAIT_MS = SHADOW_READY_TIMEOUT_MS * SHADOW_READY_BUDGETS;
|
|
20
|
+
|
|
19
21
|
vi.mock("../../utils/gsapSoftReload", async (importOriginal) => ({
|
|
20
22
|
...(await importOriginal<typeof import("../../utils/gsapSoftReload")>()),
|
|
21
23
|
ensureMotionPathPluginLoaded: vi.fn(),
|
|
@@ -258,7 +260,7 @@ describe("shadow reload readiness and failure", () => {
|
|
|
258
260
|
const { getApi, live, root } = beginReload({ onPreviewReloadFailed });
|
|
259
261
|
expect(getApi().previewSlots).toHaveLength(2);
|
|
260
262
|
|
|
261
|
-
act(() => void vi.advanceTimersByTime(
|
|
263
|
+
act(() => void vi.advanceTimersByTime(FULL_WAIT_MS));
|
|
262
264
|
|
|
263
265
|
expect(getApi().previewSlots).toEqual([{ gen: 0, role: "live" }]);
|
|
264
266
|
expect(getApi().iframeRef.current).toBe(live);
|
|
@@ -268,7 +270,7 @@ describe("shadow reload readiness and failure", () => {
|
|
|
268
270
|
unmount(root);
|
|
269
271
|
});
|
|
270
272
|
|
|
271
|
-
it("keeps a slow shadow pending past 5s and fails it only
|
|
273
|
+
it("keeps a slow shadow pending past 5s and fails it only once all budgets are spent", () => {
|
|
272
274
|
vi.useFakeTimers();
|
|
273
275
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
274
276
|
const onPreviewReloadFailed = vi.fn();
|
|
@@ -283,12 +285,47 @@ describe("shadow reload readiness and failure", () => {
|
|
|
283
285
|
expect(getApi().previewSlots).toHaveLength(2);
|
|
284
286
|
expect(onPreviewReloadFailed).not.toHaveBeenCalled();
|
|
285
287
|
|
|
286
|
-
act(() => void vi.advanceTimersByTime(
|
|
288
|
+
act(() => void vi.advanceTimersByTime(FULL_WAIT_MS - 5001 - 1));
|
|
289
|
+
expect(onPreviewReloadFailed).not.toHaveBeenCalled();
|
|
290
|
+
act(() => void vi.advanceTimersByTime(1));
|
|
287
291
|
expect(getApi().previewSlots).toEqual([{ gen: 0, role: "live" }]);
|
|
288
292
|
expect(onPreviewReloadFailed).toHaveBeenCalledWith(expect.stringContaining("too long"));
|
|
289
293
|
unmount(root);
|
|
290
294
|
});
|
|
291
295
|
|
|
296
|
+
// A busy machine: the edit's shadow paints after one budget; dropping it left the stage on the pre-edit file.
|
|
297
|
+
it("promotes a shadow that paints after its first budget instead of dropping the edit", () => {
|
|
298
|
+
vi.useFakeTimers();
|
|
299
|
+
const onPreviewReloadFailed = vi.fn();
|
|
300
|
+
const { getApi, gen, root } = beginReload({ onPreviewReloadFailed });
|
|
301
|
+
const shadow = makeShadowWithSpies();
|
|
302
|
+
act(() => {
|
|
303
|
+
getApi().setShadowIframeNode(shadow.iframe);
|
|
304
|
+
getApi().onShadowIframeLoad(gen);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS + 2000));
|
|
308
|
+
expect(onPreviewReloadFailed).not.toHaveBeenCalled();
|
|
309
|
+
act(() => getApi().onShadowReadyChange(gen, true));
|
|
310
|
+
expect(getApi().iframeRef.current).toBe(shadow.iframe);
|
|
311
|
+
unmount(root);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("gives a reload that replaces a slow one its own full set of budgets", () => {
|
|
315
|
+
vi.useFakeTimers();
|
|
316
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
317
|
+
const onPreviewReloadFailed = vi.fn();
|
|
318
|
+
const { getApi, root } = beginReload({ onPreviewReloadFailed });
|
|
319
|
+
act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS + 1000));
|
|
320
|
+
|
|
321
|
+
act(() => getApi().refreshPlayer());
|
|
322
|
+
act(() => void vi.advanceTimersByTime(FULL_WAIT_MS - 1));
|
|
323
|
+
expect(onPreviewReloadFailed).not.toHaveBeenCalled();
|
|
324
|
+
act(() => void vi.advanceTimersByTime(1));
|
|
325
|
+
expect(onPreviewReloadFailed).toHaveBeenCalledWith(expect.stringContaining("too long"));
|
|
326
|
+
unmount(root);
|
|
327
|
+
});
|
|
328
|
+
|
|
292
329
|
it("does not spend the budget while the tab is hidden, and promotes once it is visible", () => {
|
|
293
330
|
vi.useFakeTimers();
|
|
294
331
|
const onPreviewReloadFailed = vi.fn();
|
|
@@ -325,7 +362,7 @@ describe("shadow reload readiness and failure", () => {
|
|
|
325
362
|
expect(onPreviewReloadFailed).not.toHaveBeenCalled();
|
|
326
363
|
|
|
327
364
|
act(() => setVisibility("visible"));
|
|
328
|
-
act(() => void vi.advanceTimersByTime(
|
|
365
|
+
act(() => void vi.advanceTimersByTime(FULL_WAIT_MS - 1));
|
|
329
366
|
expect(onPreviewReloadFailed).not.toHaveBeenCalled();
|
|
330
367
|
act(() => void vi.advanceTimersByTime(1));
|
|
331
368
|
expect(onPreviewReloadFailed).toHaveBeenCalledWith(expect.stringContaining("too long"));
|
|
@@ -431,7 +468,7 @@ describe("shadow reload store ownership and readiness", () => {
|
|
|
431
468
|
const { getApi, rerender, root } = setup({ onPreviewReloadFailed: first });
|
|
432
469
|
expect(getApi().previewSlots).toHaveLength(2);
|
|
433
470
|
rerender({ onPreviewReloadFailed: latest });
|
|
434
|
-
act(() => void vi.advanceTimersByTime(
|
|
471
|
+
act(() => void vi.advanceTimersByTime(FULL_WAIT_MS));
|
|
435
472
|
expect(latest).toHaveBeenCalled();
|
|
436
473
|
expect(first).not.toHaveBeenCalled();
|
|
437
474
|
unmount(root);
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
mergeTimelineElementsPreservingDowngrades,
|
|
8
8
|
} from "./timelineDOM";
|
|
9
9
|
import { isTimelineIgnoredElement } from "./timelineElementHelpers";
|
|
10
|
+
import { computeResizePreview } from "../components/timelineClipDragPreview";
|
|
10
11
|
import { invalidateGroupInfoCache } from "./timelineGroupInfo";
|
|
11
12
|
import type { TimelineElement } from "../store/playerStore";
|
|
12
13
|
|
|
@@ -392,3 +393,88 @@ describe("audio FX attributes on parsed elements", () => {
|
|
|
392
393
|
expect(bgm?.automation).toBeUndefined();
|
|
393
394
|
});
|
|
394
395
|
});
|
|
396
|
+
|
|
397
|
+
// A composition clip trimmed by 30 px collapsed to its first nested video's 1.77 s: that length capped the trim.
|
|
398
|
+
describe("a composition clip's source length", () => {
|
|
399
|
+
const scene = `
|
|
400
|
+
<div data-composition-id="root">
|
|
401
|
+
<div id="host" data-composition-id="scene" data-composition-src="scene.html"
|
|
402
|
+
data-start="0" data-duration="18" data-track-index="1">
|
|
403
|
+
<div data-composition-id="scene">
|
|
404
|
+
<video id="bg" src="bg.mp4" data-source-duration="1.77" data-start="2" data-duration="1.75"></video>
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
<div id="wrapper" class="clip" data-start="0" data-duration="3" data-track-index="2">
|
|
408
|
+
<video src="talk.mp4" data-source-duration="4"></video>
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
`;
|
|
412
|
+
|
|
413
|
+
it("is not taken from media inside the composition, parsed from the DOM", () => {
|
|
414
|
+
const parsed = parseTimelineFromDOM(makeDoc(scene), 18);
|
|
415
|
+
const host = parsed.find((entry) => entry.domId === "host");
|
|
416
|
+
expect(host?.sourceDuration).toBeUndefined();
|
|
417
|
+
expect(host?.tag).not.toBe("video");
|
|
418
|
+
expect(parsed.find((entry) => entry.domId === "wrapper")?.sourceDuration).toBe(4);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("does not cap trimming the composition's end at that media's length", () => {
|
|
422
|
+
const host = parseTimelineFromDOM(makeDoc(scene), 18).find((entry) => entry.domId === "host");
|
|
423
|
+
const trimmed = computeResizePreview(
|
|
424
|
+
{
|
|
425
|
+
element: host!,
|
|
426
|
+
edge: "end",
|
|
427
|
+
originClientX: 360,
|
|
428
|
+
previewStart: 0,
|
|
429
|
+
previewDuration: 18,
|
|
430
|
+
started: true,
|
|
431
|
+
},
|
|
432
|
+
330,
|
|
433
|
+
{ scroll: null, pps: 20, buildSnapTargets: () => [] },
|
|
434
|
+
);
|
|
435
|
+
expect(trimmed.previewDuration).toBeCloseTo(16.5, 3);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("is not taken from media inside the composition, from the runtime manifest", () => {
|
|
439
|
+
const doc = makeDoc(scene);
|
|
440
|
+
const element = createTimelineElementFromManifestClip({
|
|
441
|
+
clip: {
|
|
442
|
+
id: "host",
|
|
443
|
+
label: "Scene",
|
|
444
|
+
kind: "composition",
|
|
445
|
+
tagName: "div",
|
|
446
|
+
start: 0,
|
|
447
|
+
duration: 18,
|
|
448
|
+
track: 1,
|
|
449
|
+
compositionId: "scene",
|
|
450
|
+
parentCompositionId: "root",
|
|
451
|
+
compositionSrc: "scene.html",
|
|
452
|
+
playbackStart: null,
|
|
453
|
+
playbackRate: null,
|
|
454
|
+
assetUrl: null,
|
|
455
|
+
},
|
|
456
|
+
fallbackIndex: 0,
|
|
457
|
+
doc,
|
|
458
|
+
hostEl: doc.getElementById("host"),
|
|
459
|
+
});
|
|
460
|
+
expect(element.sourceDuration).toBeUndefined();
|
|
461
|
+
expect(element.src).toBeUndefined();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("still shows an inline composition's image as its thumbnail", () => {
|
|
465
|
+
const parsed = parseTimelineFromDOM(
|
|
466
|
+
makeDoc(`
|
|
467
|
+
<div data-composition-id="root">
|
|
468
|
+
<div id="card" data-composition-id="card" data-start="0" data-duration="5" data-track-index="1">
|
|
469
|
+
<img src="card.png" />
|
|
470
|
+
</div>
|
|
471
|
+
</div>
|
|
472
|
+
`),
|
|
473
|
+
5,
|
|
474
|
+
);
|
|
475
|
+
const card = parsed.find((entry) => entry.domId === "card");
|
|
476
|
+
expect(card?.tag).toBe("img");
|
|
477
|
+
expect(card?.src).toBe("card.png");
|
|
478
|
+
expect(card?.sourceDuration).toBeUndefined();
|
|
479
|
+
});
|
|
480
|
+
});
|
|
@@ -346,11 +346,11 @@ export function parseTimelineFromDOM(
|
|
|
346
346
|
if (compSrc) {
|
|
347
347
|
entry.compositionSrc = compSrc;
|
|
348
348
|
} else if (compId && compId !== rootComp?.getAttribute("data-composition-id")) {
|
|
349
|
-
// Inline composition — expose inner video for thumbnails
|
|
350
|
-
const
|
|
351
|
-
if (
|
|
352
|
-
entry.src =
|
|
353
|
-
entry.tag = "video";
|
|
349
|
+
// Inline composition — expose inner video or image for thumbnails
|
|
350
|
+
const innerMedia = el.querySelector("video[src], img[src]");
|
|
351
|
+
if (innerMedia) {
|
|
352
|
+
entry.src = innerMedia.getAttribute("src") || undefined;
|
|
353
|
+
entry.tag = innerMedia.tagName === "IMG" ? "img" : "video";
|
|
354
354
|
}
|
|
355
355
|
}
|
|
356
356
|
if (entry.kind === "composition") {
|
|
@@ -146,11 +146,21 @@ function isHtmlElement(el: Element): el is HTMLElement {
|
|
|
146
146
|
return typeof HtmlElementCtor !== "undefined" && el instanceof HtmlElementCtor;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
function isCompositionHost(el: Element): boolean {
|
|
150
|
+
return (
|
|
151
|
+
el.hasAttribute("data-composition-id") ||
|
|
152
|
+
el.hasAttribute("data-composition-src") ||
|
|
153
|
+
el.hasAttribute("data-composition-file")
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
149
157
|
export function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageElement | null {
|
|
150
158
|
const win = el.ownerDocument.defaultView ?? window;
|
|
151
159
|
const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
|
|
152
160
|
const ImageElementCtor = win.HTMLImageElement ?? globalThis.HTMLImageElement;
|
|
153
161
|
if (el instanceof MediaElementCtor || el instanceof ImageElementCtor) return el;
|
|
162
|
+
// A composition's media belongs to its own timeline, not the clip's: its length would cap a trim.
|
|
163
|
+
if (isCompositionHost(el)) return null;
|
|
154
164
|
const candidate = el.querySelector("video, audio, img");
|
|
155
165
|
return candidate instanceof MediaElementCtor || candidate instanceof ImageElementCtor
|
|
156
166
|
? candidate
|