@hyperframes/studio 0.8.74 → 0.8.76
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-BkDxgSKG.js +464 -0
- package/dist/assets/{index-vccPjvCl.js → index-COX0scwJ.js} +1 -1
- package/dist/assets/{index-DhTyI9zH.js → index-DeHir1up.js} +178 -178
- package/dist/assets/{index-B86QTmD2.js → index-FmKUmYhC.js} +1 -1
- package/dist/{chunk-7BP32G62.js → chunk-IYDE66BY.js} +6 -1
- package/dist/chunk-IYDE66BY.js.map +1 -0
- package/dist/{domEditingLayers-UKMVYXT6.js → domEditingLayers-2XBFXXSW.js} +4 -2
- package/dist/index.d.ts +35 -70
- package/dist/index.html +1 -1
- package/dist/index.js +4518 -4568
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanels.types.ts +0 -2
- package/src/components/editor/DomEditOverlay.test.ts +5 -0
- package/src/components/editor/LayersPanel.tsx +3 -14
- package/src/components/editor/PreviewOverlayProvider.test.tsx +6 -1
- package/src/components/editor/domEditing.ts +1 -0
- package/src/components/editor/domEditingLayers.test.ts +27 -0
- package/src/components/editor/domEditingLayers.ts +9 -0
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +57 -35
- package/src/components/editor/overlayFrameLoop.test.ts +16 -0
- package/src/components/editor/overlayFrameLoop.ts +11 -0
- package/src/components/editor/usePreviewCompositionRect.test.tsx +6 -1
- package/src/components/nle/NLEPreview.test.ts +94 -17
- package/src/components/nle/NLEPreview.tsx +14 -0
- package/src/components/nle/PreviewPoster.tsx +99 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +30 -49
- package/src/components/panels/VariablesPanel.tsx +0 -2
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +5 -1
- package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +14 -0
- package/src/components/sidebar/CompositionsTab.test.ts +4 -6
- package/src/components/sidebar/CompositionsTab.tsx +28 -21
- package/src/components/studioColorGradingScope.ts +0 -3
- package/src/hooks/domEditDeleteMembers.ts +0 -2
- package/src/hooks/gsapScriptCommitTypes.ts +0 -2
- package/src/hooks/keyframeCacheAstLoad.test.ts +31 -1
- package/src/hooks/keyframeCacheAstLoad.ts +2 -1
- package/src/hooks/timelineAudioGroupCreate.ts +0 -1
- package/src/hooks/timelineEditingHelpers.ts +11 -3
- package/src/hooks/timelineTimingSync.ts +0 -1
- package/src/hooks/timelineTrackVisibility.ts +0 -1
- package/src/hooks/useBlockHandlers.ts +0 -2
- package/src/hooks/useClipboard.test.ts +69 -10
- package/src/hooks/useClipboard.ts +28 -19
- package/src/hooks/useDomEditCommits.test.tsx +0 -1
- package/src/hooks/useDomEditCommits.ts +0 -4
- package/src/hooks/useEditHistoryActions.test.tsx +31 -14
- package/src/hooks/useEditHistoryActions.ts +12 -7
- package/src/hooks/useEditorSave.ts +0 -3
- package/src/hooks/useElementLifecycleOps.ts +0 -1
- package/src/hooks/useEnableKeyframes.test.ts +9 -1
- package/src/hooks/useFileManager.ts +0 -2
- package/src/hooks/useGroupCommits.ts +0 -1
- package/src/hooks/useGsapScriptCommits.ts +0 -1
- package/src/hooks/useLintModal.test.tsx +34 -1
- package/src/hooks/useLintModal.ts +32 -30
- package/src/hooks/usePersistentEditHistory.test.ts +221 -315
- package/src/hooks/usePersistentEditHistory.ts +155 -342
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +2 -0
- package/src/hooks/usePreviewPersistence.ts +0 -2
- package/src/hooks/useProjectCompositionVariables.ts +1 -3
- package/src/hooks/useRazorSplit.history.test.tsx +46 -67
- package/src/hooks/useRazorSplit.test.ts +17 -47
- package/src/hooks/useRenderClipContent.test.ts +29 -0
- package/src/hooks/useRenderClipContent.ts +6 -3
- package/src/hooks/useSdkSession.lifecycle.test.tsx +34 -4
- package/src/hooks/useSdkSession.ts +8 -2
- package/src/hooks/useSlideshowPersist.ts +0 -2
- package/src/hooks/useStudioContextValue.ts +6 -1
- package/src/hooks/useTimelineAssetDropOps.ts +0 -1
- package/src/hooks/useTimelineDeleteOps.test.tsx +85 -2
- package/src/hooks/useTimelineDeleteOps.ts +8 -8
- package/src/hooks/useTimelineEditing.test.tsx +0 -1
- package/src/hooks/useTimelineEditingTypes.ts +0 -2
- package/src/hostMount.test.tsx +53 -22
- package/src/main.tsx +3 -0
- package/src/player/components/CompositionThumbnail.tsx +14 -0
- package/src/player/components/Player.test.ts +49 -0
- package/src/player/components/Player.tsx +21 -7
- package/src/player/components/PlayheadIndicator.tsx +4 -1
- package/src/player/components/Timeline.test.ts +1 -3
- package/src/player/components/Timeline.virtualization.test.tsx +21 -0
- package/src/player/components/TimelineCanvas.tsx +14 -12
- package/src/player/components/TimelinePlayheadLayer.tsx +33 -0
- package/src/player/components/TimelineRuler.tsx +7 -40
- package/src/player/components/timelineClipDragCommit.test.ts +7 -39
- package/src/player/components/useSettledScrollLeft.ts +38 -0
- package/src/player/hooks/timelineDomBinding.test.ts +50 -0
- package/src/player/hooks/timelineSyncHydration.test.ts +25 -1
- package/src/player/hooks/timelineSyncHydration.ts +3 -3
- package/src/player/hooks/useShadowPreviewReload.ts +20 -1
- package/src/player/hooks/useTimelinePlayer.play.test.ts +120 -0
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +85 -0
- package/src/player/hooks/useTimelinePlayer.ts +15 -3
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
- package/src/player/lib/timelineElementHelpers.ts +45 -9
- package/src/player/store/playerStore.test.ts +35 -1
- package/src/player/store/playerStore.ts +29 -0
- package/src/player/store/readinessSlice.ts +34 -6
- package/src/utils/blockInstaller.ts +0 -3
- package/src/utils/previewPrefetch.test.ts +22 -0
- package/src/utils/previewPrefetch.ts +14 -0
- package/src/utils/razorSplitTransaction.test.ts +0 -1
- package/src/utils/razorSplitTransaction.ts +1 -1
- package/src/utils/sdkEditTransaction.ts +0 -3
- package/src/utils/studioFileHistory.test.ts +0 -9
- package/src/utils/studioFileHistory.ts +1 -5
- package/src/utils/timelineCompositionInsert.ts +0 -1
- package/dist/assets/hyperframes-player-Bs9qG2hg.js +0 -464
- package/dist/chunk-7BP32G62.js.map +0 -1
- package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +0 -60
- package/src/utils/editHistory.test.ts +0 -534
- package/src/utils/editHistory.ts +0 -236
- package/src/utils/editHistoryStorage.test.ts +0 -37
- package/src/utils/editHistoryStorage.ts +0 -99
- /package/dist/{domEditingLayers-UKMVYXT6.js.map → domEditingLayers-2XBFXXSW.js.map} +0 -0
|
@@ -8,11 +8,6 @@ import {
|
|
|
8
8
|
type DragCommitDeps,
|
|
9
9
|
type TimelineMoveEdit,
|
|
10
10
|
} from "./timelineClipDragCommit";
|
|
11
|
-
import {
|
|
12
|
-
buildEditHistoryEntry,
|
|
13
|
-
createEmptyEditHistory,
|
|
14
|
-
pushEditHistoryEntry,
|
|
15
|
-
} from "../../utils/editHistory";
|
|
16
11
|
import { normalizeToZones } from "./timelineZones";
|
|
17
12
|
import { resolveZMirrorLaneMove } from "./timelineZMirror";
|
|
18
13
|
import type { StackingPatch } from "./timelineStackingSync";
|
|
@@ -1151,48 +1146,21 @@ describe("commitDraggedClipMove", () => {
|
|
|
1151
1146
|
return { onMoveElements, onStackingPatches };
|
|
1152
1147
|
};
|
|
1153
1148
|
|
|
1154
|
-
it("threads ONE shared coalesceKey to both the move persist and the z-sync, so the
|
|
1149
|
+
it("threads ONE shared coalesceKey to both the move persist and the z-sync, so the server can fold them into a single undo entry", async () => {
|
|
1155
1150
|
const { onMoveElements, onStackingPatches } = commitLaneChange(overlapping());
|
|
1156
1151
|
await flushMicrotasks();
|
|
1157
1152
|
|
|
1158
|
-
// Both sides receive the SAME non-empty gesture key (second arg).
|
|
1153
|
+
// Both sides receive the SAME non-empty gesture key (second arg). That
|
|
1154
|
+
// shared, non-empty coalesceKey is what lets the server
|
|
1155
|
+
// (projectHistory.ts, "claim: a writer that records after writing")
|
|
1156
|
+
// fold the "Move timeline clips" write and the "Reorder layers" z patch,
|
|
1157
|
+
// both to the same file, into one undo entry — folding itself is the
|
|
1158
|
+
// server's own tested behaviour, not re-proven here via a reducer.
|
|
1159
1159
|
const moveKey = onMoveElements.mock.calls[0][1];
|
|
1160
1160
|
const zKey = onStackingPatches.mock.calls[0][1];
|
|
1161
1161
|
expect(typeof moveKey).toBe("string");
|
|
1162
1162
|
expect(moveKey).not.toBe("");
|
|
1163
1163
|
expect(zKey).toBe(moveKey);
|
|
1164
|
-
|
|
1165
|
-
// With that shared key, editHistory folds the two consecutive records (the
|
|
1166
|
-
// "Move timeline clips" write + the "Reorder layers" z patch, same file,
|
|
1167
|
-
// inside the coalesce window) into ONE undo entry spanning before→after.
|
|
1168
|
-
const now = 1_000;
|
|
1169
|
-
const moveEntry = buildEditHistoryEntry({
|
|
1170
|
-
id: "m",
|
|
1171
|
-
projectId: "p",
|
|
1172
|
-
label: "Move timeline clips",
|
|
1173
|
-
kind: "timeline",
|
|
1174
|
-
coalesceKey: moveKey,
|
|
1175
|
-
now,
|
|
1176
|
-
files: { "index.html": { before: "<v0>", after: "<v1>" } },
|
|
1177
|
-
});
|
|
1178
|
-
const zEntry = buildEditHistoryEntry({
|
|
1179
|
-
id: "z",
|
|
1180
|
-
projectId: "p",
|
|
1181
|
-
label: "Reorder layers",
|
|
1182
|
-
kind: "timeline",
|
|
1183
|
-
coalesceKey: zKey,
|
|
1184
|
-
now: now + 50,
|
|
1185
|
-
files: { "index.html": { before: "<v1>", after: "<v2>" } },
|
|
1186
|
-
});
|
|
1187
|
-
const state = pushEditHistoryEntry(
|
|
1188
|
-
pushEditHistoryEntry(createEmptyEditHistory(), moveEntry),
|
|
1189
|
-
zEntry,
|
|
1190
|
-
);
|
|
1191
|
-
expect(state.undo).toHaveLength(1);
|
|
1192
|
-
expect(state.undo[0].files["index.html"]).toMatchObject({
|
|
1193
|
-
before: "<v0>",
|
|
1194
|
-
after: "<v2>",
|
|
1195
|
-
});
|
|
1196
1164
|
});
|
|
1197
1165
|
|
|
1198
1166
|
it("distinct gestures get distinct keys (independent moves never cross-merge)", async () => {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useCallback, useRef, useSyncExternalStore, type RefObject } from "react";
|
|
2
|
+
import { TIMELINE_SCROLL_SETTLE_MS } from "./useTimelineScrollViewport";
|
|
3
|
+
|
|
4
|
+
// The timeline viewport's scrollLeft once a scroll settles, or null while it is scrolling. Readers
|
|
5
|
+
// re-render twice per gesture, never per frame: per-frame work cost 2-3x the viewport gate's budget
|
|
6
|
+
// on a large unvirtualized timeline.
|
|
7
|
+
export function useSettledScrollLeft(scrollRef: RefObject<HTMLDivElement | null>): number | null {
|
|
8
|
+
const scrollingRef = useRef(false);
|
|
9
|
+
const subscribe = useCallback(
|
|
10
|
+
(onChange: () => void) => {
|
|
11
|
+
const el = scrollRef.current;
|
|
12
|
+
let settle: ReturnType<typeof setTimeout> | undefined;
|
|
13
|
+
const onScroll = () => {
|
|
14
|
+
if (!scrollingRef.current) {
|
|
15
|
+
scrollingRef.current = true;
|
|
16
|
+
onChange();
|
|
17
|
+
}
|
|
18
|
+
clearTimeout(settle);
|
|
19
|
+
settle = setTimeout(() => {
|
|
20
|
+
scrollingRef.current = false;
|
|
21
|
+
onChange();
|
|
22
|
+
}, TIMELINE_SCROLL_SETTLE_MS);
|
|
23
|
+
};
|
|
24
|
+
el?.addEventListener("scroll", onScroll, { passive: true });
|
|
25
|
+
return () => {
|
|
26
|
+
clearTimeout(settle);
|
|
27
|
+
scrollingRef.current = false;
|
|
28
|
+
el?.removeEventListener("scroll", onScroll);
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
[scrollRef],
|
|
32
|
+
);
|
|
33
|
+
return useSyncExternalStore(
|
|
34
|
+
subscribe,
|
|
35
|
+
() => (scrollingRef.current ? null : (scrollRef.current?.scrollLeft ?? 0)),
|
|
36
|
+
() => null,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -47,6 +47,56 @@ describe("live DOM manifest hydration", () => {
|
|
|
47
47
|
).toHaveLength(2);
|
|
48
48
|
}
|
|
49
49
|
});
|
|
50
|
+
it("binds a root clip to its own element when a mounted sub-composition repeats its id", () => {
|
|
51
|
+
// The sub-composition's copy comes first in document order, so a bare id lookup finds it.
|
|
52
|
+
const doc = documentWith(
|
|
53
|
+
'<div id="strip" data-composition-id="strip" data-composition-src="compositions/strip.html" data-start="0" data-duration="4" data-track-index="1">' +
|
|
54
|
+
'<video id="frame-1" data-hf-id="hf-inner" data-start="0"></video></div>' +
|
|
55
|
+
'<video id="frame-1" data-hf-id="hf-root" data-start="6" data-duration="1" data-track-index="7"></video>',
|
|
56
|
+
);
|
|
57
|
+
const [element] = buildTimelineElementsFromClips(
|
|
58
|
+
[
|
|
59
|
+
clip({
|
|
60
|
+
id: "frame-1",
|
|
61
|
+
tagName: "video",
|
|
62
|
+
kind: "video",
|
|
63
|
+
start: 6,
|
|
64
|
+
duration: 1,
|
|
65
|
+
track: 7,
|
|
66
|
+
compositionAncestors: ["main"],
|
|
67
|
+
}),
|
|
68
|
+
],
|
|
69
|
+
doc,
|
|
70
|
+
);
|
|
71
|
+
expect([element.hfId, element.sourceFile]).toEqual(["hf-root", undefined]);
|
|
72
|
+
});
|
|
73
|
+
it("keeps a unique id bound while its host's composition id is healed after the manifest", () => {
|
|
74
|
+
// The wrapper's data-composition-id arrived after the runtime recorded the clip's chain as ["main"].
|
|
75
|
+
const doc = documentWith(
|
|
76
|
+
'<div id="ho" data-hf-id="ho" data-start="0" data-duration="4"></div>' +
|
|
77
|
+
'<div id="hs-layer" data-composition-id="hs-comp">' +
|
|
78
|
+
'<div id="hs" data-hf-id="hs" data-start="0" data-duration="4"></div></div>',
|
|
79
|
+
);
|
|
80
|
+
const [element] = buildTimelineElementsFromClips(
|
|
81
|
+
[clip({ id: "hs", compositionAncestors: ["main"] })],
|
|
82
|
+
doc,
|
|
83
|
+
);
|
|
84
|
+
expect(element.hfId).toBe("hs");
|
|
85
|
+
});
|
|
86
|
+
it("binds an id-less host by its composition id over a lone id inside that sub-composition", () => {
|
|
87
|
+
const doc = documentWith(
|
|
88
|
+
'<div data-composition-id="card" data-composition-src="compositions/card.html" data-start="0" data-duration="4">' +
|
|
89
|
+
'<div data-hf-inner-root><div id="card" data-start="0" data-duration="4"></div></div></div>',
|
|
90
|
+
);
|
|
91
|
+
const host = doc.querySelector('[data-composition-src="compositions/card.html"]');
|
|
92
|
+
const card = clip({
|
|
93
|
+
id: "card",
|
|
94
|
+
kind: "composition",
|
|
95
|
+
compositionId: "card",
|
|
96
|
+
compositionAncestors: ["main"],
|
|
97
|
+
});
|
|
98
|
+
expect(findTimelineDomNodeForClip(doc, card, 0)).toBe(host);
|
|
99
|
+
});
|
|
50
100
|
it("does not bind a cross-tag direct identity", () => {
|
|
51
101
|
const doc = documentWith('<div id="collision" data-start="0" data-duration="4"></div>');
|
|
52
102
|
expect(
|
|
@@ -43,7 +43,7 @@ function mountScene(): Document {
|
|
|
43
43
|
|
|
44
44
|
// A composition clip is keyed by its ELEMENT id, not its `data-composition-id`
|
|
45
45
|
// (the runtime's clip tree publishes `scene-2-slot`), so the collector resolves
|
|
46
|
-
// the host
|
|
46
|
+
// the host by clip.id in the clip's own composition, exactly as the sibling walk does.
|
|
47
47
|
const sceneClips = [clip({ id: "scene-2-slot", kind: "composition", compositionId: "scene-2" })];
|
|
48
48
|
|
|
49
49
|
describe("collectSubCompositionHostState", () => {
|
|
@@ -83,6 +83,30 @@ describe("collectSubCompositionHostState", () => {
|
|
|
83
83
|
expect(state.get("scene-2-video")?.hidden).toBe(true);
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
+
it("reads its own host when an earlier sub-composition repeats the host's id", () => {
|
|
87
|
+
document.body.innerHTML = `
|
|
88
|
+
<main data-composition-id="main">
|
|
89
|
+
<div id="intro-slot" data-composition-id="intro" data-composition-src="intro.html">
|
|
90
|
+
<div data-hf-inner-root data-composition-id="intro">
|
|
91
|
+
<div id="scene-2-slot"><p id="intro-note" data-hidden></p></div>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
<div id="scene-2-slot" data-composition-id="scene-2" data-composition-src="scene-2.html">
|
|
95
|
+
<div data-hf-inner-root><div id="scene-2-title" data-hidden></div></div>
|
|
96
|
+
</div>
|
|
97
|
+
</main>`;
|
|
98
|
+
const host = clip({
|
|
99
|
+
id: "scene-2-slot",
|
|
100
|
+
kind: "composition",
|
|
101
|
+
compositionId: "scene-2",
|
|
102
|
+
compositionAncestors: ["main"],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect([...collectSubCompositionHostState(document, [host]).keys()]).toEqual(["scene-2-title"]);
|
|
106
|
+
const rows = collectSubCompositionDomChildren(document, [host], new Map());
|
|
107
|
+
expect(rows.map((child) => child.id)).toEqual(["scene-2-title"]);
|
|
108
|
+
});
|
|
109
|
+
|
|
86
110
|
it("returns empty without a document, rather than throwing", () => {
|
|
87
111
|
expect(collectSubCompositionHostState(null, sceneClips).size).toBe(0);
|
|
88
112
|
});
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* an argument, so each is callable — and readable — on its own.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { createTimelineDomNodeResolver } from "../lib/timelineElementHelpers";
|
|
12
|
+
import { createTimelineDomNodeResolver, findClipElementById } from "../lib/timelineElementHelpers";
|
|
13
13
|
import { usePlayerStore } from "../store/playerStore";
|
|
14
14
|
import type { TimelineElement, DomClipChild, SubCompositionHostState } from "../store/playerStore";
|
|
15
15
|
import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
|
|
@@ -128,7 +128,7 @@ export function collectSubCompositionDomChildren(
|
|
|
128
128
|
if (!iframeDoc) return out;
|
|
129
129
|
for (const clip of clips) {
|
|
130
130
|
if (clip.kind !== "composition" || !clip.id) continue;
|
|
131
|
-
const hostEl = iframeDoc
|
|
131
|
+
const hostEl = findClipElementById(iframeDoc, clip);
|
|
132
132
|
if (!hostEl) continue;
|
|
133
133
|
const innerRoot = hostEl.querySelector("[data-hf-inner-root]") ?? hostEl;
|
|
134
134
|
collectHostDomChildren(clip.id, innerRoot, clip.id, parentMap, out);
|
|
@@ -172,7 +172,7 @@ export function collectSubCompositionHostState(
|
|
|
172
172
|
if (!iframeDoc) return out;
|
|
173
173
|
for (const clip of clips) {
|
|
174
174
|
if (clip.kind !== "composition" || !clip.id) continue;
|
|
175
|
-
const hostEl = iframeDoc
|
|
175
|
+
const hostEl = findClipElementById(iframeDoc, clip);
|
|
176
176
|
if (!hostEl) continue;
|
|
177
177
|
for (const el of Array.from(hostEl.querySelectorAll("[id]"))) {
|
|
178
178
|
const state = readSubCompositionHostState(el);
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "./useTimelineSyncCallbacks";
|
|
15
15
|
import type { PlaybackAdapter } from "../lib/playbackTypes";
|
|
16
16
|
import { thumbnailScheduler } from "../lib/thumbnailScheduler";
|
|
17
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
17
18
|
|
|
18
19
|
// One wait budget for a shadow: the player's 8s asset cap plus its 0.42s loader fade
|
|
19
20
|
// leaves about 6.5s for the document load and runtime boot. Nothing shorter may fail the swap.
|
|
@@ -35,6 +36,8 @@ type UseShadowPreviewReloadParams = Omit<
|
|
|
35
36
|
onPromoted?: () => void;
|
|
36
37
|
/** A shadow that never became ready was dropped; the live preview is unchanged. */
|
|
37
38
|
onReloadFailed?: (message: string) => void;
|
|
39
|
+
/** Puts the promoted document at the live frame's time, playing if the live frame was. */
|
|
40
|
+
handOverPlayback: (time: number, playing: boolean) => void;
|
|
38
41
|
};
|
|
39
42
|
|
|
40
43
|
export function useShadowPreviewReload({
|
|
@@ -51,6 +54,7 @@ export function useShadowPreviewReload({
|
|
|
51
54
|
applyPreviewAudioState,
|
|
52
55
|
onPromoted,
|
|
53
56
|
onReloadFailed,
|
|
57
|
+
handOverPlayback,
|
|
54
58
|
}: UseShadowPreviewReloadParams) {
|
|
55
59
|
const shadowIframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
56
60
|
const shadowProbeIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
|
@@ -62,6 +66,8 @@ export function useShadowPreviewReload({
|
|
|
62
66
|
onPromotedRef.current = onPromoted;
|
|
63
67
|
const onReloadFailedRef = useRef(onReloadFailed);
|
|
64
68
|
onReloadFailedRef.current = onReloadFailed;
|
|
69
|
+
const handOverPlaybackRef = useRef(handOverPlayback);
|
|
70
|
+
handOverPlaybackRef.current = handOverPlayback;
|
|
65
71
|
const readyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
66
72
|
const budgetsSpentRef = useRef(0);
|
|
67
73
|
// The shadow still owed a wait budget, so a hidden tab can resume it when it becomes visible.
|
|
@@ -102,6 +108,11 @@ export function useShadowPreviewReload({
|
|
|
102
108
|
const ready = pending?.gen === gen && visuallyReadyGenRef.current === gen;
|
|
103
109
|
if (!shadow || !pending || !ready || gen !== shadowGenRef.current) return;
|
|
104
110
|
stopPendingShadow();
|
|
111
|
+
// The live frame kept playing, stopped at the end or was seeked while the shadow loaded.
|
|
112
|
+
const live = getAdapter();
|
|
113
|
+
const liveTime = live?.getTime();
|
|
114
|
+
const playing = usePlayerStore.getState().isPlaying;
|
|
115
|
+
live?.pause();
|
|
105
116
|
// The store takes the new document's timeline only now that it is the one on screen.
|
|
106
117
|
pending.commit();
|
|
107
118
|
iframeRef.current = shadow;
|
|
@@ -109,10 +120,17 @@ export function useShadowPreviewReload({
|
|
|
109
120
|
attachIframeShortcutListeners();
|
|
110
121
|
applyPreviewAudioState();
|
|
111
122
|
setPreviewSlots((prev) => planShadowPromotion(prev, gen));
|
|
123
|
+
if (liveTime != null) handOverPlaybackRef.current(liveTime, playing);
|
|
112
124
|
onPromotedRef.current?.();
|
|
113
125
|
thumbnailScheduler.setPreviewReloading(false);
|
|
114
126
|
},
|
|
115
|
-
[
|
|
127
|
+
[
|
|
128
|
+
stopPendingShadow,
|
|
129
|
+
getAdapter,
|
|
130
|
+
iframeRef,
|
|
131
|
+
attachIframeShortcutListeners,
|
|
132
|
+
applyPreviewAudioState,
|
|
133
|
+
],
|
|
116
134
|
);
|
|
117
135
|
|
|
118
136
|
const getShadowAdapter = useCallback(() => getAdapter(shadowIframeRef.current), [getAdapter]);
|
|
@@ -201,6 +219,7 @@ export function useShadowPreviewReload({
|
|
|
201
219
|
shadowIframeRef.current = null;
|
|
202
220
|
isRefreshingRef.current = false;
|
|
203
221
|
pendingSeekRef.current = null;
|
|
222
|
+
usePlayerStore.getState().setTimelineReady(false);
|
|
204
223
|
setPreviewSlots(planShadowDiscard);
|
|
205
224
|
thumbnailScheduler.setPreviewReloading(false);
|
|
206
225
|
}, [stopPendingShadow, isRefreshingRef, pendingSeekRef]);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
makeAdapterWindow,
|
|
7
|
+
makeFakeIframe,
|
|
8
|
+
renderTimelinePlayerHarness,
|
|
9
|
+
resetPlayerStore,
|
|
10
|
+
} from "./timelinePlayerTestHarness";
|
|
11
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
12
|
+
|
|
13
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
document.body.innerHTML = "";
|
|
17
|
+
resetPlayerStore();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("does not start playback before the timeline is ready, so iframe load cannot swallow it", () => {
|
|
21
|
+
const { api, root } = renderTimelinePlayerHarness();
|
|
22
|
+
const { adapter, win } = makeAdapterWindow();
|
|
23
|
+
// The runtime has booted (adapter exists) but the iframe's load event has not fired yet.
|
|
24
|
+
act(() => {
|
|
25
|
+
api.iframeRef.current = makeFakeIframe(win);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
act(() => api.play());
|
|
29
|
+
expect(adapter.play).not.toHaveBeenCalled();
|
|
30
|
+
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
31
|
+
|
|
32
|
+
act(() => {
|
|
33
|
+
api.onIframeLoad();
|
|
34
|
+
usePlayerStore.setState({ timelineReady: true });
|
|
35
|
+
});
|
|
36
|
+
act(() => api.play());
|
|
37
|
+
expect(adapter.play).toHaveBeenCalledTimes(1);
|
|
38
|
+
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
|
39
|
+
act(() => root.unmount());
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("does not start playback while a switched-to composition's preview is still loading", () => {
|
|
43
|
+
const { api, root } = renderTimelinePlayerHarness();
|
|
44
|
+
act(() => {
|
|
45
|
+
api.iframeRef.current = makeFakeIframe(makeAdapterWindow().win);
|
|
46
|
+
api.onIframeLoad();
|
|
47
|
+
usePlayerStore.setState({ timelineReady: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Drill into another composition: a fresh preview whose runtime is up but whose load step hasn't run.
|
|
51
|
+
const next = makeAdapterWindow();
|
|
52
|
+
act(() => {
|
|
53
|
+
api.resetPreviewSlots();
|
|
54
|
+
api.iframeRef.current = makeFakeIframe(next.win);
|
|
55
|
+
});
|
|
56
|
+
act(() => api.play());
|
|
57
|
+
expect(next.adapter.play).not.toHaveBeenCalled();
|
|
58
|
+
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
59
|
+
act(() => root.unmount());
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function postFromRuntime(win: object, data: Record<string, unknown>) {
|
|
63
|
+
act(() => {
|
|
64
|
+
window.dispatchEvent(
|
|
65
|
+
new MessageEvent("message", {
|
|
66
|
+
source: win as unknown as Window,
|
|
67
|
+
data: { source: "hf-preview", ...data },
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function expectPlayStillOff() {
|
|
74
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
75
|
+
expect(usePlayerStore.getState().timelineReady).toBe(false);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Duration 0: the adapter cannot enable Play itself, so only the message path can, after the load step.
|
|
79
|
+
it.each([30, 0])(
|
|
80
|
+
"waits for the preview's load step before a timeline message enables Play (adapter duration %i)",
|
|
81
|
+
async (duration) => {
|
|
82
|
+
const { api, root } = renderTimelinePlayerHarness();
|
|
83
|
+
const { win } = makeAdapterWindow({ duration });
|
|
84
|
+
act(() => {
|
|
85
|
+
api.iframeRef.current = makeFakeIframe(win);
|
|
86
|
+
});
|
|
87
|
+
// The runtime reports its timeline before the document's load event, as a film with large images does.
|
|
88
|
+
const clip = { id: "clip", label: "Clip", start: 0, duration: 1, track: 0, kind: "element" };
|
|
89
|
+
postFromRuntime(win, { type: "timeline", clips: [clip], durationInFrames: 30, fps: 30 });
|
|
90
|
+
await expectPlayStillOff();
|
|
91
|
+
|
|
92
|
+
act(() => api.onIframeLoad());
|
|
93
|
+
await vi.waitFor(() => expect(usePlayerStore.getState().timelineReady).toBe(true));
|
|
94
|
+
act(() => root.unmount());
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
it("does not let the blank page's load step enable Play for the preview that replaces it", async () => {
|
|
99
|
+
const { api, root } = renderTimelinePlayerHarness();
|
|
100
|
+
// The iframe's first load is about:blank, before the runtime exists; its load step waits for runtime messages.
|
|
101
|
+
const iframe = makeFakeIframe({ ...makeAdapterWindow().win, __player: undefined });
|
|
102
|
+
act(() => {
|
|
103
|
+
api.iframeRef.current = iframe;
|
|
104
|
+
api.onIframeLoad();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// The same iframe navigates to the preview, whose runtime reports ready before its own load event.
|
|
108
|
+
const { win } = makeAdapterWindow();
|
|
109
|
+
Object.defineProperty(iframe, "contentWindow", { value: win, configurable: true });
|
|
110
|
+
Object.defineProperty(iframe, "contentDocument", {
|
|
111
|
+
value: document.implementation.createHTMLDocument("preview"),
|
|
112
|
+
configurable: true,
|
|
113
|
+
});
|
|
114
|
+
postFromRuntime(win, { type: "state" });
|
|
115
|
+
await expectPlayStillOff();
|
|
116
|
+
|
|
117
|
+
act(() => api.onIframeLoad());
|
|
118
|
+
await vi.waitFor(() => expect(usePlayerStore.getState().timelineReady).toBe(true));
|
|
119
|
+
act(() => root.unmount());
|
|
120
|
+
});
|
|
@@ -224,6 +224,91 @@ describe("shadow reload readiness and failure", () => {
|
|
|
224
224
|
unmount(root);
|
|
225
225
|
});
|
|
226
226
|
|
|
227
|
+
it("keeps a playing film playing through a reload, from where the live frame had reached", () => {
|
|
228
|
+
const { getApi, root } = renderTimelinePlayerHarness();
|
|
229
|
+
const { adapter: liveAdapter, win } = makeAdapterWindow();
|
|
230
|
+
act(() => {
|
|
231
|
+
getApi().iframeRef.current = makeFakeIframe(win);
|
|
232
|
+
getApi().onIframeLoad();
|
|
233
|
+
});
|
|
234
|
+
usePlayerStore.setState({ timelineReady: true });
|
|
235
|
+
act(() => getApi().play());
|
|
236
|
+
liveAdapter.pause.mockClear();
|
|
237
|
+
liveAdapter.seek(4.2);
|
|
238
|
+
|
|
239
|
+
act(() => getApi().refreshPlayer());
|
|
240
|
+
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
|
241
|
+
expect(liveAdapter.pause).not.toHaveBeenCalled();
|
|
242
|
+
|
|
243
|
+
liveAdapter.seek(5.5);
|
|
244
|
+
const gen = getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
|
|
245
|
+
const shadow = makeShadowWithSpies();
|
|
246
|
+
act(() => {
|
|
247
|
+
getApi().setShadowIframeNode(shadow.iframe);
|
|
248
|
+
getApi().onShadowIframeLoad(gen);
|
|
249
|
+
getApi().onShadowReadyChange(gen, true);
|
|
250
|
+
});
|
|
251
|
+
expect(getApi().iframeRef.current).toBe(shadow.iframe);
|
|
252
|
+
expect(liveAdapter.pause).toHaveBeenCalled();
|
|
253
|
+
expect(shadow.adapter.getTime()).toBe(5.5);
|
|
254
|
+
expect(shadow.adapter.isPlaying()).toBe(true);
|
|
255
|
+
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
|
256
|
+
unmount(root);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("keeps where a film was paused while the reload loaded", () => {
|
|
260
|
+
const { getApi, root } = renderTimelinePlayerHarness();
|
|
261
|
+
const { adapter: liveAdapter, win } = makeAdapterWindow();
|
|
262
|
+
act(() => {
|
|
263
|
+
getApi().iframeRef.current = makeFakeIframe(win);
|
|
264
|
+
getApi().onIframeLoad();
|
|
265
|
+
});
|
|
266
|
+
usePlayerStore.setState({ timelineReady: true });
|
|
267
|
+
act(() => getApi().play());
|
|
268
|
+
liveAdapter.seek(4.2);
|
|
269
|
+
act(() => getApi().refreshPlayer());
|
|
270
|
+
|
|
271
|
+
liveAdapter.seek(7);
|
|
272
|
+
act(() => getApi().pause());
|
|
273
|
+
const gen = getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
|
|
274
|
+
const shadow = makeShadowWithSpies();
|
|
275
|
+
act(() => {
|
|
276
|
+
getApi().setShadowIframeNode(shadow.iframe);
|
|
277
|
+
getApi().onShadowIframeLoad(gen);
|
|
278
|
+
getApi().onShadowReadyChange(gen, true);
|
|
279
|
+
});
|
|
280
|
+
expect(getApi().iframeRef.current).toBe(shadow.iframe);
|
|
281
|
+
expect(shadow.adapter.getTime()).toBe(7);
|
|
282
|
+
expect(shadow.adapter.isPlaying()).toBe(false);
|
|
283
|
+
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
284
|
+
unmount(root);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("stops a playing film at the new end when the edit cut it short of the live time", () => {
|
|
288
|
+
const { getApi, root } = renderTimelinePlayerHarness();
|
|
289
|
+
const { adapter: liveAdapter, win } = makeAdapterWindow();
|
|
290
|
+
act(() => {
|
|
291
|
+
getApi().iframeRef.current = makeFakeIframe(win);
|
|
292
|
+
getApi().onIframeLoad();
|
|
293
|
+
});
|
|
294
|
+
usePlayerStore.setState({ timelineReady: true });
|
|
295
|
+
act(() => getApi().play());
|
|
296
|
+
act(() => getApi().refreshPlayer());
|
|
297
|
+
|
|
298
|
+
liveAdapter.seek(12);
|
|
299
|
+
const gen = getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
|
|
300
|
+
const shorter = makeAdapterWindow({ duration: 10 });
|
|
301
|
+
act(() => {
|
|
302
|
+
getApi().setShadowIframeNode(makeFakeIframe(shorter.win));
|
|
303
|
+
getApi().onShadowIframeLoad(gen);
|
|
304
|
+
getApi().onShadowReadyChange(gen, true);
|
|
305
|
+
});
|
|
306
|
+
expect(shorter.adapter.getTime()).toBe(10);
|
|
307
|
+
expect(shorter.adapter.isPlaying()).toBe(false);
|
|
308
|
+
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
309
|
+
unmount(root);
|
|
310
|
+
});
|
|
311
|
+
|
|
227
312
|
it("does not promote a shadow whose loader is still up, and promotes once it clears", () => {
|
|
228
313
|
const { getApi, live, gen, root } = beginReload();
|
|
229
314
|
const shadow = makeShadowWithSpies();
|
|
@@ -180,6 +180,7 @@ export function useTimelinePlayer({
|
|
|
180
180
|
applyPreviewAudioFlags(iframeRef.current, audioMuted, audioVolume);
|
|
181
181
|
}, []);
|
|
182
182
|
const play = useCallback(() => {
|
|
183
|
+
if (!usePlayerStore.getState().timelineReady) return;
|
|
183
184
|
stopRAFLoop();
|
|
184
185
|
stopReverseLoop();
|
|
185
186
|
stopScrubPreviewAudio();
|
|
@@ -400,6 +401,13 @@ export function useTimelinePlayer({
|
|
|
400
401
|
applyPreviewAudioState,
|
|
401
402
|
onPromoted: onShadowPromoted,
|
|
402
403
|
onReloadFailed: onPreviewReloadFailed,
|
|
404
|
+
handOverPlayback: (time, playing) => {
|
|
405
|
+
// keepPlaying: move the playhead without the paused-seek audio scrub.
|
|
406
|
+
seek(time, { keepPlaying: true });
|
|
407
|
+
const adapter = getAdapter();
|
|
408
|
+
// An edit that cut the film short of the live time stops it at the new end, as playback does.
|
|
409
|
+
if (playing && adapter && adapter.getTime() < adapter.getDuration()) play();
|
|
410
|
+
},
|
|
403
411
|
});
|
|
404
412
|
|
|
405
413
|
const saveSeekPosition = useCallback(() => {
|
|
@@ -422,17 +430,21 @@ export function useTimelinePlayer({
|
|
|
422
430
|
}
|
|
423
431
|
}
|
|
424
432
|
isRefreshingRef.current = true;
|
|
425
|
-
|
|
433
|
+
// Forward playback runs on in the live frame; the promotion hands it to the new document.
|
|
434
|
+
const keepPlaying =
|
|
435
|
+
usePlayerStore.getState().isPlaying && shuttleDirectionRef.current !== "backward";
|
|
426
436
|
stopReverseLoop();
|
|
437
|
+
if (keepPlaying) return true;
|
|
438
|
+
stopRAFLoop();
|
|
427
439
|
setIsPlaying(false);
|
|
440
|
+
return false;
|
|
428
441
|
}, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
|
|
429
442
|
const refreshPlayer = useCallback(() => {
|
|
430
443
|
const iframe = iframeRef.current;
|
|
431
444
|
if (!iframe) return;
|
|
432
445
|
logReload("refreshPlayer", () => ({ stack: new Error("refreshPlayer").stack }));
|
|
433
|
-
saveSeekPosition();
|
|
434
446
|
// The old iframe is no longer navigated away, so stop its playback (and audio) here.
|
|
435
|
-
getAdapter()?.pause();
|
|
447
|
+
if (!saveSeekPosition()) getAdapter()?.pause();
|
|
436
448
|
// The live iframe is never hidden; the reload loads in a shadow and is promoted once painted.
|
|
437
449
|
const src = iframe.src;
|
|
438
450
|
const url = new URL(src, window.location.origin);
|
|
@@ -295,6 +295,8 @@ export function useTimelineSyncCallbacks({
|
|
|
295
295
|
|
|
296
296
|
const onIframeLoad = useCallback(
|
|
297
297
|
(context?: number) => {
|
|
298
|
+
const loadedDoc = safeContentDocument(iframeRef.current);
|
|
299
|
+
if (loadedDoc) usePlayerStore.getState().markPreviewLoadStep(loadedDoc);
|
|
298
300
|
applyPreviewAudioState();
|
|
299
301
|
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
300
302
|
stopWaitingRef.current?.();
|
|
@@ -428,14 +428,50 @@ function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean
|
|
|
428
428
|
});
|
|
429
429
|
}
|
|
430
430
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
431
|
+
/** Whether `node` sits in the composition the clip was read from, the chain the runtime records outermost first
|
|
432
|
+
* (`resolveNearestCompositionContext` in core's runtime/timeline.ts). A clip without that scope accepts any node. */
|
|
433
|
+
function nodeInClipScope(node: Element, clip: ClipManifestClip): boolean {
|
|
434
|
+
const scope = clip.compositionAncestors;
|
|
435
|
+
if (!scope) return true;
|
|
436
|
+
const ids: string[] = [];
|
|
437
|
+
for (let cursor = node.parentElement; cursor; cursor = cursor.parentElement) {
|
|
438
|
+
const id = cursor.getAttribute("data-composition-id");
|
|
439
|
+
if (id) ids.unshift(id);
|
|
440
|
+
}
|
|
441
|
+
return ids.length === scope.length && ids.every((id, index) => id === scope[index]);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** The first match in the clip's composition across `selectors`; a lone match stands only when none is in scope, as a
|
|
445
|
+
* healed host can stale the clip's chain for a pass. An id can repeat in a sub-composition earlier in the document. */
|
|
446
|
+
function findInClipScope(
|
|
447
|
+
doc: Document,
|
|
448
|
+
clip: ClipManifestClip,
|
|
449
|
+
selectors: string[],
|
|
450
|
+
): Element | null {
|
|
451
|
+
let lone: Element | null = null;
|
|
452
|
+
for (const selector of selectors) {
|
|
453
|
+
const nodes = Array.from(doc.querySelectorAll(selector));
|
|
454
|
+
const scoped = nodes.find((node) => nodeInClipScope(node, clip));
|
|
455
|
+
if (scoped) return scoped;
|
|
456
|
+
if (nodes.length === 1) lone ??= nodes[0];
|
|
457
|
+
}
|
|
458
|
+
return lone;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function findClipElementById(doc: Document, clip: ClipManifestClip): Element | null {
|
|
462
|
+
if (!clip.id) return null;
|
|
463
|
+
const first = doc.getElementById(clip.id);
|
|
464
|
+
if (!first || nodeInClipScope(first, clip)) return first;
|
|
465
|
+
return findInClipScope(doc, clip, [`[id="${CSS.escape(clip.id)}"]`]);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function findTimelineDomNode(doc: Document, clip: ClipManifestClip): Element | null {
|
|
469
|
+
if (!clip.id) return null;
|
|
470
|
+
const first = doc.getElementById(clip.id);
|
|
471
|
+
if (first && nodeInClipScope(first, clip)) return first;
|
|
472
|
+
const id = CSS.escape(clip.id);
|
|
473
|
+
const byOtherKeys = [`[data-hf-id="${id}"]`, `[data-composition-id="${id}"]`, `.${id}`];
|
|
474
|
+
return findInClipScope(doc, clip, first ? [`[id="${id}"]`, ...byOtherKeys] : byOtherKeys);
|
|
439
475
|
}
|
|
440
476
|
|
|
441
477
|
export function findTimelineDomNodeForClip(
|
|
@@ -445,7 +481,7 @@ export function findTimelineDomNodeForClip(
|
|
|
445
481
|
usedNodes = new Set<Element>(),
|
|
446
482
|
getCandidates = () => getTimelineDomNodes(doc),
|
|
447
483
|
): Element | null {
|
|
448
|
-
const byIdentity =
|
|
484
|
+
const byIdentity = findTimelineDomNode(doc, clip);
|
|
449
485
|
if (byIdentity && !usedNodes.has(byIdentity) && nodeMatchesClipTag(byIdentity, clip))
|
|
450
486
|
return byIdentity;
|
|
451
487
|
|