@hyperframes/studio 0.8.63 → 0.8.65
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-CwFrSySS.js +464 -0
- package/dist/assets/{index-CjueZL58.js → index-Cs1lQrQ6.js} +142 -142
- package/dist/assets/{index-DCqmNhmi.js → index-a5nEhdAU.js} +1 -1
- package/dist/assets/{index-CISQydDK.js → index-wQFEeTL3.js} +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.html +1 -1
- package/dist/index.js +852 -755
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/manualEdits.ts +5 -1
- package/src/components/editor/manualEditsParsing.ts +15 -3
- package/src/components/sidebar/CompositionsPanel.masterComposition.test.tsx +1 -1
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +2 -2
- package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +1 -1
- package/src/components/sidebar/CompositionsTab.tsx +3 -2
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +30 -3
- package/src/hooks/useExternalFileChangeCoordinator.ts +12 -5
- package/src/hooks/useRenderClipContent.test.ts +21 -2
- package/src/hooks/useRenderClipContent.ts +10 -5
- package/src/hooks/useSdkSession.lifecycle.test.tsx +78 -1
- package/src/hooks/useSdkSession.ts +75 -13
- package/src/hooks/useStudioExternalFileChanges.ts +6 -3
- package/src/player/components/CompositionThumbnail.tsx +6 -0
- package/src/player/components/Timeline.virtualization.test.tsx +49 -4
- package/src/player/components/TimelineLanes.test.tsx +28 -0
- package/src/player/components/TimelineLanes.tsx +5 -4
- package/src/player/components/TimelineTransitionOverlays.tsx +3 -8
- package/src/player/components/timelineClipChildren.tsx +11 -1
- package/src/player/components/timelineProviderStateBuilders.test.ts +0 -7
- package/src/player/components/timelineProviderStateBuilders.ts +0 -9
- package/src/player/components/timelineTransitionSeams.test.ts +21 -1
- package/src/player/components/timelineTransitionSeams.ts +21 -0
- package/src/player/components/useTimelineProviderState.tsx +11 -5
- package/src/player/hooks/useShadowPreviewReload.ts +7 -0
- package/src/player/lib/thumbnailScheduler.test.ts +33 -0
- package/src/player/lib/thumbnailScheduler.ts +34 -0
- package/src/player/store/playerStore.test.ts +22 -8
- package/src/player/store/thumbnailSlice.ts +28 -6
- package/dist/assets/hyperframes-player-FmXtbvEE.js +0 -464
|
@@ -140,7 +140,7 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
|
|
|
140
140
|
usePlayerStore.getState().reset();
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
-
it("
|
|
143
|
+
it("keeps a clip's picture on screen while the timeline scrolls", async () => {
|
|
144
144
|
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
|
|
145
145
|
import("./Timeline"),
|
|
146
146
|
import("../store/playerStore"),
|
|
@@ -167,17 +167,62 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
|
|
|
167
167
|
expect(scroller).not.toBeNull();
|
|
168
168
|
expect(clip).not.toBeNull();
|
|
169
169
|
expect(clip?.title).toBe("Clip 0 • 0.0s – 10.0s");
|
|
170
|
-
|
|
170
|
+
const picture = host.querySelector("[data-rich-content]");
|
|
171
|
+
expect(picture).not.toBeNull();
|
|
171
172
|
|
|
172
173
|
if (scroller) await dispatchScroll(scroller);
|
|
173
174
|
expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
|
|
174
|
-
expect(host.querySelector("[data-rich-content]")).
|
|
175
|
+
expect(host.querySelector("[data-rich-content]")).toBe(picture);
|
|
175
176
|
|
|
176
177
|
await act(async () => {
|
|
177
178
|
await new Promise((resolve) => setTimeout(resolve, 110));
|
|
178
179
|
});
|
|
179
180
|
expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
|
|
180
|
-
expect(host.querySelector("[data-rich-content]")).
|
|
181
|
+
expect(host.querySelector("[data-rich-content]")).toBe(picture);
|
|
182
|
+
} finally {
|
|
183
|
+
act(() => root.unmount());
|
|
184
|
+
usePlayerStore.getState().reset();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("gives a clip that appears mid-scroll its picture once the scroll settles", async () => {
|
|
189
|
+
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
|
|
190
|
+
import("./Timeline"),
|
|
191
|
+
import("../store/playerStore"),
|
|
192
|
+
]);
|
|
193
|
+
const clip = (id: string, start: number) => ({
|
|
194
|
+
id,
|
|
195
|
+
label: id,
|
|
196
|
+
tag: "div",
|
|
197
|
+
start,
|
|
198
|
+
duration: 5,
|
|
199
|
+
track: 0,
|
|
200
|
+
});
|
|
201
|
+
usePlayerStore.setState({ duration: 60, timelineReady: true, elements: [clip("clip-0", 0)] });
|
|
202
|
+
|
|
203
|
+
const { host, root } = await mountTimeline(
|
|
204
|
+
React.createElement(Timeline, {
|
|
205
|
+
renderClipContent: (element: { id: string }) =>
|
|
206
|
+
React.createElement("span", { "data-rich-content": element.id }),
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
try {
|
|
210
|
+
await act(async () => {
|
|
211
|
+
await new Promise((resolve) => setTimeout(resolve, 110));
|
|
212
|
+
});
|
|
213
|
+
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
|
|
214
|
+
if (scroller) await dispatchScroll(scroller);
|
|
215
|
+
act(() => {
|
|
216
|
+
usePlayerStore.setState({ elements: [clip("clip-0", 0), clip("clip-1", 5)] });
|
|
217
|
+
});
|
|
218
|
+
expect(host.querySelector('[data-el-id="clip-1"]')).not.toBeNull();
|
|
219
|
+
expect(host.querySelector('[data-rich-content="clip-1"]')).toBeNull();
|
|
220
|
+
expect(host.querySelector('[data-rich-content="clip-0"]')).not.toBeNull();
|
|
221
|
+
|
|
222
|
+
await act(async () => {
|
|
223
|
+
await new Promise((resolve) => setTimeout(resolve, 110));
|
|
224
|
+
});
|
|
225
|
+
expect(host.querySelector('[data-rich-content="clip-1"]')).not.toBeNull();
|
|
181
226
|
} finally {
|
|
182
227
|
act(() => root.unmount());
|
|
183
228
|
usePlayerStore.getState().reset();
|
|
@@ -14,6 +14,16 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
|
14
14
|
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
|
15
15
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
16
16
|
import type { DraggedClipState, BlockedClipState } from "./useTimelineClipDrag";
|
|
17
|
+
import * as transitionSeams from "./timelineTransitionSeams";
|
|
18
|
+
|
|
19
|
+
vi.mock("./timelineTransitionSeams", async (importOriginal) => {
|
|
20
|
+
const actual = await importOriginal<typeof import("./timelineTransitionSeams")>();
|
|
21
|
+
return {
|
|
22
|
+
...actual,
|
|
23
|
+
deriveTimelineTransitionSeams: vi.fn(actual.deriveTimelineTransitionSeams),
|
|
24
|
+
deriveTimelineTransitionSeamsByTrack: vi.fn(actual.deriveTimelineTransitionSeamsByTrack),
|
|
25
|
+
};
|
|
26
|
+
});
|
|
17
27
|
|
|
18
28
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
19
29
|
|
|
@@ -497,3 +507,21 @@ describe("TimelineLanes clip joins", () => {
|
|
|
497
507
|
act(() => view.root.unmount());
|
|
498
508
|
});
|
|
499
509
|
});
|
|
510
|
+
|
|
511
|
+
describe("TimelineLanes transition seams", () => {
|
|
512
|
+
it("derives transition seams once for every row, not once per row", () => {
|
|
513
|
+
const derivations = [
|
|
514
|
+
transitionSeams.deriveTimelineTransitionSeams,
|
|
515
|
+
transitionSeams.deriveTimelineTransitionSeamsByTrack,
|
|
516
|
+
].map((derive) => vi.mocked(derive));
|
|
517
|
+
for (const derive of derivations) derive.mockClear();
|
|
518
|
+
|
|
519
|
+
const view = renderLanes({
|
|
520
|
+
elements: [element("clip-a", 0), element("clip-b", TRACK_A), element("clip-c", TRACK_B)],
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
expect(view.host.querySelectorAll("[data-timeline-row]").length).toBeGreaterThanOrEqual(3);
|
|
524
|
+
expect(derivations.reduce((calls, derive) => calls + derive.mock.calls.length, 0)).toBe(1);
|
|
525
|
+
act(() => view.root.unmount());
|
|
526
|
+
});
|
|
527
|
+
});
|
|
@@ -33,6 +33,7 @@ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
|
33
33
|
import { timelineClipFocusId } from "./timelineNavigationIdentity";
|
|
34
34
|
import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
|
|
35
35
|
import { TimelineTransitionOverlays } from "./TimelineTransitionOverlays";
|
|
36
|
+
import { deriveTimelineTransitionSeamsByTrack } from "./timelineTransitionSeams";
|
|
36
37
|
|
|
37
38
|
export function TimelineLanes({
|
|
38
39
|
pps,
|
|
@@ -105,8 +106,9 @@ export function TimelineLanes({
|
|
|
105
106
|
const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
|
|
106
107
|
useTimelineGroupDisclosure();
|
|
107
108
|
const automationLanes = useAutomationLanes();
|
|
108
|
-
const
|
|
109
|
-
() =>
|
|
109
|
+
const transitionSeamsByTrack = useMemo(
|
|
110
|
+
() =>
|
|
111
|
+
deriveTimelineTransitionSeamsByTrack(tracks.flatMap(([, els]) => els.map(getPreviewElement))),
|
|
110
112
|
[getPreviewElement, tracks],
|
|
111
113
|
);
|
|
112
114
|
// A group's automation clock is COMPOSITION time (groups doc §1.3), so its
|
|
@@ -551,8 +553,7 @@ export function TimelineLanes({
|
|
|
551
553
|
})
|
|
552
554
|
}
|
|
553
555
|
<TimelineTransitionOverlays
|
|
554
|
-
|
|
555
|
-
track={trackNum}
|
|
556
|
+
seams={transitionSeamsByTrack.get(trackNum) ?? []}
|
|
556
557
|
rowElements={draggedClip?.started ? [] : automationElements}
|
|
557
558
|
rowBackground={rowBackground}
|
|
558
559
|
pixelsPerSecond={pps}
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { CLIP_Y } from "./timelineLayout";
|
|
2
2
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
3
3
|
import type { TimelineElement } from "../store/playerStore";
|
|
4
|
-
import {
|
|
4
|
+
import type { TimelineTransitionSeam } from "./timelineTransitionSeams";
|
|
5
5
|
import { TimelineTransitionBadge } from "./TimelineTransitionBadge";
|
|
6
6
|
import { TimelineClipJoins } from "./TimelineClipJoins";
|
|
7
7
|
|
|
8
8
|
interface TimelineTransitionOverlaysProps {
|
|
9
|
-
|
|
10
|
-
track: number;
|
|
9
|
+
seams: readonly TimelineTransitionSeam[];
|
|
11
10
|
rowElements: readonly TimelineElement[];
|
|
12
11
|
rowBackground: string;
|
|
13
12
|
pixelsPerSecond: number;
|
|
@@ -17,8 +16,7 @@ interface TimelineTransitionOverlaysProps {
|
|
|
17
16
|
|
|
18
17
|
/** What a row draws where its clips meet: a hairline at an exact join, a badge over a transition. */
|
|
19
18
|
export function TimelineTransitionOverlays({
|
|
20
|
-
|
|
21
|
-
track,
|
|
19
|
+
seams,
|
|
22
20
|
rowElements,
|
|
23
21
|
rowBackground,
|
|
24
22
|
pixelsPerSecond,
|
|
@@ -26,9 +24,6 @@ export function TimelineTransitionOverlays({
|
|
|
26
24
|
clipBarHeight,
|
|
27
25
|
}: TimelineTransitionOverlaysProps) {
|
|
28
26
|
const top = CLIP_Y + (clipBarHeight ?? rowHeight - 2 * CLIP_Y) / 2;
|
|
29
|
-
const seams = deriveTimelineTransitionSeams(transitionElements).filter(
|
|
30
|
-
(seam) => seam.incoming.track === track,
|
|
31
|
-
);
|
|
32
27
|
return (
|
|
33
28
|
<>
|
|
34
29
|
<TimelineClipJoins
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ReactNode } from "react";
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
2
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
3
3
|
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
|
|
4
4
|
import type { TrackVisualStyle } from "./timelineIcons";
|
|
@@ -29,6 +29,16 @@ function ClipLintDot({ element }: { element: TimelineElement }) {
|
|
|
29
29
|
);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Mounts a clip's content only once the timeline is at rest, then keeps it through later scrolls,
|
|
34
|
+
* so a scroll never blanks a picture already on screen and never mounts a screenful of new ones.
|
|
35
|
+
*/
|
|
36
|
+
export function ClipContentOnceShown({ hold, children }: { hold: boolean; children: ReactNode }) {
|
|
37
|
+
const [shown, setShown] = useState(!hold);
|
|
38
|
+
if (!shown && !hold) setShown(true);
|
|
39
|
+
return shown ? children : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
export function renderClipChildren(
|
|
33
43
|
element: TimelineElement,
|
|
34
44
|
clipStyle: TrackVisualStyle,
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import { describe, expect, it, vi } from "vitest";
|
|
4
4
|
import {
|
|
5
5
|
buildTimelineMeta,
|
|
6
|
-
resolveRenderClipContent,
|
|
7
6
|
resolveResizingElementIds,
|
|
8
7
|
shouldIgnoreTimelinePointerDown,
|
|
9
8
|
type TimelineMetaBuilderInputs,
|
|
@@ -85,12 +84,6 @@ describe("buildTimelineMeta", () => {
|
|
|
85
84
|
});
|
|
86
85
|
|
|
87
86
|
describe("timeline provider branch helpers", () => {
|
|
88
|
-
it("resolves virtualized clip content", () => {
|
|
89
|
-
const render = () => null;
|
|
90
|
-
expect(resolveRenderClipContent(true, true, render)).toBeUndefined();
|
|
91
|
-
expect(resolveRenderClipContent(false, true, render)).toBe(render);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
87
|
it("resolves resizing element ids", () => {
|
|
95
88
|
expect(resolveResizingElementIds(null)).toBeUndefined();
|
|
96
89
|
expect(resolveResizingElementIds({ element: { id: "clip" } } as never)).toEqual(["clip"]);
|
|
@@ -4,7 +4,6 @@ import type {
|
|
|
4
4
|
TimelineViewportProps,
|
|
5
5
|
} from "./TimelineProvider";
|
|
6
6
|
import type { ResizingClipState } from "./useTimelineClipDrag";
|
|
7
|
-
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
|
|
8
7
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
9
8
|
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
|
10
9
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
@@ -34,14 +33,6 @@ export function resolveResizingElementIds(
|
|
|
34
33
|
return undefined;
|
|
35
34
|
}
|
|
36
35
|
|
|
37
|
-
export function resolveRenderClipContent(
|
|
38
|
-
rowVirtualizationActive: boolean,
|
|
39
|
-
isScrolling: boolean,
|
|
40
|
-
renderClipContent: TimelineLaneBaseProps["renderClipContent"],
|
|
41
|
-
): TimelineLaneBaseProps["renderClipContent"] {
|
|
42
|
-
return rowVirtualizationActive && isScrolling ? undefined : renderClipContent;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
36
|
export function shouldIgnoreTimelinePointerDown(target: EventTarget | null): boolean {
|
|
46
37
|
return target instanceof Element && target.closest("button, input, select, a") !== null;
|
|
47
38
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
deriveTimelineTransitionSeams,
|
|
4
|
+
deriveTimelineTransitionSeamsByTrack,
|
|
5
|
+
} from "./timelineTransitionSeams";
|
|
3
6
|
import type { TimelineElement } from "../store/playerStore";
|
|
4
7
|
|
|
5
8
|
const clip = (
|
|
@@ -46,3 +49,20 @@ describe("deriveTimelineTransitionSeams", () => {
|
|
|
46
49
|
expect(deriveTimelineTransitionSeams([clip("a", 0, 2), clip("b", 2, 1)])).toEqual([]);
|
|
47
50
|
});
|
|
48
51
|
});
|
|
52
|
+
|
|
53
|
+
describe("deriveTimelineTransitionSeamsByTrack", () => {
|
|
54
|
+
it("finds each track's transitions even when another track's clip starts between them", () => {
|
|
55
|
+
const label = "hf:transition:out:in:crossfade";
|
|
56
|
+
const other = "hf:transition:a:b:wipe";
|
|
57
|
+
const byTrack = deriveTimelineTransitionSeamsByTrack([
|
|
58
|
+
clip("out", 0, 2, 0, label),
|
|
59
|
+
clip("in", 1.8, 2, 0, label),
|
|
60
|
+
clip("a", 5, 2, 2, other),
|
|
61
|
+
clip("b", 6.5, 2, 2, other),
|
|
62
|
+
clip("alone", 0, 3, 1),
|
|
63
|
+
]);
|
|
64
|
+
expect(byTrack.get(0)?.map((seam) => seam.incoming.id)).toEqual(["in"]);
|
|
65
|
+
expect(byTrack.get(2)?.map((seam) => seam.incoming.id)).toEqual(["b"]);
|
|
66
|
+
expect(byTrack.has(1)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -34,3 +34,24 @@ export function deriveTimelineTransitionSeams(
|
|
|
34
34
|
}
|
|
35
35
|
return seams;
|
|
36
36
|
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Transition seams keyed by track, derived once for every row. Pairs are found within a track, so a clip on
|
|
40
|
+
* another track that starts between a transition's two clips cannot hide it.
|
|
41
|
+
*/
|
|
42
|
+
export function deriveTimelineTransitionSeamsByTrack(
|
|
43
|
+
elements: readonly TimelineElement[],
|
|
44
|
+
): ReadonlyMap<number, readonly TimelineTransitionSeam[]> {
|
|
45
|
+
const tracks = new Map<number, TimelineElement[]>();
|
|
46
|
+
for (const element of elements) {
|
|
47
|
+
const track = tracks.get(element.track);
|
|
48
|
+
if (track) track.push(element);
|
|
49
|
+
else tracks.set(element.track, [element]);
|
|
50
|
+
}
|
|
51
|
+
const byTrack = new Map<number, readonly TimelineTransitionSeam[]>();
|
|
52
|
+
for (const [track, trackElements] of tracks) {
|
|
53
|
+
const seams = deriveTimelineTransitionSeams(trackElements);
|
|
54
|
+
if (seams.length > 0) byTrack.set(track, seams);
|
|
55
|
+
}
|
|
56
|
+
return byTrack;
|
|
57
|
+
}
|
|
@@ -11,7 +11,6 @@ import type { ClipContextMenuState, TimelineContextValue } from "./TimelineProvi
|
|
|
11
11
|
import {
|
|
12
12
|
buildTimelineMeta,
|
|
13
13
|
resolveMultiDragPreview,
|
|
14
|
-
resolveRenderClipContent,
|
|
15
14
|
resolveResizingElementIds,
|
|
16
15
|
shouldIgnoreTimelinePointerDown,
|
|
17
16
|
} from "./timelineProviderStateBuilders";
|
|
@@ -22,6 +21,7 @@ import { useTimelineGeometry } from "./useTimelineGeometry";
|
|
|
22
21
|
import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
|
|
23
22
|
import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD } from "./timelineLayout";
|
|
24
23
|
import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
|
|
24
|
+
import { ClipContentOnceShown } from "./timelineClipChildren";
|
|
25
25
|
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
|
|
26
26
|
import type { TimelineProps } from "./TimelineTypes";
|
|
27
27
|
import {
|
|
@@ -509,10 +509,16 @@ export function useTimelineProviderState({
|
|
|
509
509
|
onRazorSplit: editContext.onRazorSplit,
|
|
510
510
|
onRazorSplitAll: editContext.onRazorSplitAll,
|
|
511
511
|
};
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
512
|
+
const holdNewClipContent = timelineFocus.rowVirtualizationActive && viewport.isScrolling;
|
|
513
|
+
const timelineRenderClipContent = useMemo<typeof renderClipContent>(
|
|
514
|
+
() =>
|
|
515
|
+
renderClipContent &&
|
|
516
|
+
((element, style, context) => (
|
|
517
|
+
<ClipContentOnceShown hold={holdNewClipContent}>
|
|
518
|
+
{renderClipContent(element, style, context)}
|
|
519
|
+
</ClipContentOnceShown>
|
|
520
|
+
)),
|
|
521
|
+
[holdNewClipContent, renderClipContent],
|
|
516
522
|
);
|
|
517
523
|
const timelineMeta = buildTimelineMeta({
|
|
518
524
|
emptyState: {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type UseTimelineSyncCallbacksParams,
|
|
14
14
|
} from "./useTimelineSyncCallbacks";
|
|
15
15
|
import type { PlaybackAdapter } from "../lib/playbackTypes";
|
|
16
|
+
import { thumbnailScheduler } from "../lib/thumbnailScheduler";
|
|
16
17
|
|
|
17
18
|
// The single wait budget for a shadow: the player's 8s asset cap plus its 0.42s loader fade
|
|
18
19
|
// leaves about 6.5s for the document load and runtime boot. Nothing shorter may fail the swap.
|
|
@@ -82,6 +83,7 @@ export function useShadowPreviewReload({
|
|
|
82
83
|
isRefreshingRef.current = false;
|
|
83
84
|
pendingSeekRef.current = null;
|
|
84
85
|
setPreviewSlots(planShadowDiscard);
|
|
86
|
+
thumbnailScheduler.setPreviewReloading(false);
|
|
85
87
|
const message = `The preview did not reload (${cause}). The previous preview is still showing.`;
|
|
86
88
|
logReload("shadow-failed", { cause });
|
|
87
89
|
console.error(`[studio] ${message}`);
|
|
@@ -105,6 +107,7 @@ export function useShadowPreviewReload({
|
|
|
105
107
|
applyPreviewAudioState();
|
|
106
108
|
setPreviewSlots((prev) => planShadowPromotion(prev, gen));
|
|
107
109
|
onPromotedRef.current?.();
|
|
110
|
+
thumbnailScheduler.setPreviewReloading(false);
|
|
108
111
|
},
|
|
109
112
|
[stopPendingShadow, iframeRef, attachIframeShortcutListeners, applyPreviewAudioState],
|
|
110
113
|
);
|
|
@@ -175,6 +178,8 @@ export function useShadowPreviewReload({
|
|
|
175
178
|
budgetGenRef.current = gen;
|
|
176
179
|
armReadyTimer(gen);
|
|
177
180
|
setPreviewSlots((prev) => planShadowReload(prev, gen, url));
|
|
181
|
+
// Thumbnails of the edit wait for the new preview instead of competing with it.
|
|
182
|
+
thumbnailScheduler.setPreviewReloading(true);
|
|
178
183
|
},
|
|
179
184
|
[stopPendingShadow, armReadyTimer],
|
|
180
185
|
);
|
|
@@ -187,6 +192,7 @@ export function useShadowPreviewReload({
|
|
|
187
192
|
isRefreshingRef.current = false;
|
|
188
193
|
pendingSeekRef.current = null;
|
|
189
194
|
setPreviewSlots(planShadowDiscard);
|
|
195
|
+
thumbnailScheduler.setPreviewReloading(false);
|
|
190
196
|
}, [stopPendingShadow, isRefreshingRef, pendingSeekRef]);
|
|
191
197
|
|
|
192
198
|
// Hiding the tab pauses the budget; showing it again restarts the full budget for a pending shadow.
|
|
@@ -203,6 +209,7 @@ export function useShadowPreviewReload({
|
|
|
203
209
|
return () => {
|
|
204
210
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
205
211
|
stopPendingShadow();
|
|
212
|
+
thumbnailScheduler.setPreviewReloading(false);
|
|
206
213
|
};
|
|
207
214
|
});
|
|
208
215
|
|
|
@@ -153,6 +153,39 @@ describe("ThumbnailScheduler", () => {
|
|
|
153
153
|
expect(richLoad).toHaveBeenCalledTimes(1);
|
|
154
154
|
});
|
|
155
155
|
|
|
156
|
+
it("holds composition renders while the preview reloads and re-runs the ones it preempted", async () => {
|
|
157
|
+
const scheduler = new ThumbnailScheduler();
|
|
158
|
+
const signals: AbortSignal[] = [];
|
|
159
|
+
const composition = vi.fn((signal: AbortSignal) => {
|
|
160
|
+
signals.push(signal);
|
|
161
|
+
return new Promise<ThumbnailLoadedResult>((resolve, reject) => {
|
|
162
|
+
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")));
|
|
163
|
+
if (signals.length > 1) resolve(result("scene"));
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
const image = vi.fn(async () => result("still"));
|
|
167
|
+
const scene = request("scene", composition, "visible", { kind: "composition" });
|
|
168
|
+
const still = request("still", image);
|
|
169
|
+
|
|
170
|
+
scheduler.acquire(scene, vi.fn());
|
|
171
|
+
expect(composition).toHaveBeenCalledTimes(1);
|
|
172
|
+
scheduler.setPreviewReloading(true);
|
|
173
|
+
await flush();
|
|
174
|
+
expect(signals[0]?.aborted).toBe(true);
|
|
175
|
+
expect(scheduler.getSnapshot(scene)).toEqual({ status: "queued" });
|
|
176
|
+
scheduler.acquire(still, vi.fn());
|
|
177
|
+
expect(image).toHaveBeenCalledTimes(1);
|
|
178
|
+
expect(composition).toHaveBeenCalledTimes(1);
|
|
179
|
+
|
|
180
|
+
scheduler.setPreviewReloading(false);
|
|
181
|
+
await flush();
|
|
182
|
+
expect(composition).toHaveBeenCalledTimes(2);
|
|
183
|
+
expect(scheduler.getSnapshot(scene)).toMatchObject({
|
|
184
|
+
status: "ready",
|
|
185
|
+
value: { url: "scene" },
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
156
189
|
it("aborts queued and active jobs after the final release", async () => {
|
|
157
190
|
const scheduler = new ThumbnailScheduler(
|
|
158
191
|
resolveTimelineViewportBudgets({ concurrentVideoDecodes: 1 }),
|
|
@@ -65,6 +65,8 @@ interface ThumbnailEntry {
|
|
|
65
65
|
cached: boolean;
|
|
66
66
|
lastAccess: number;
|
|
67
67
|
snapshot: ThumbnailSnapshot;
|
|
68
|
+
/** Aborted by the preview-reload hold, to run again once the hold lifts. */
|
|
69
|
+
preempted: boolean;
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
const PRIORITY_SCORE: Readonly<Record<ThumbnailPriority, number>> = {
|
|
@@ -112,6 +114,7 @@ export class ThumbnailScheduler {
|
|
|
112
114
|
private nextLeaseId = 1;
|
|
113
115
|
private nextSequence = 1;
|
|
114
116
|
private scrolling = false;
|
|
117
|
+
private previewReloading = false;
|
|
115
118
|
private cacheBytes = 0;
|
|
116
119
|
private waveformCacheBytes = 0;
|
|
117
120
|
private readonly activeByBucket = { video: 0, composition: 0, general: 0 };
|
|
@@ -155,6 +158,7 @@ export class ThumbnailScheduler {
|
|
|
155
158
|
cached: false,
|
|
156
159
|
lastAccess: this.nextSequence++,
|
|
157
160
|
snapshot: Object.freeze({ status: "queued" }),
|
|
161
|
+
preempted: false,
|
|
158
162
|
};
|
|
159
163
|
this.entries.set(scopedKey, entry);
|
|
160
164
|
}
|
|
@@ -210,6 +214,24 @@ export class ThumbnailScheduler {
|
|
|
210
214
|
if (!scrolling) this.pump();
|
|
211
215
|
}
|
|
212
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Hold server-rendered composition thumbnails while the preview loads a new document: both
|
|
219
|
+
* are served by the same Studio server, and the preview is what the person is waiting for.
|
|
220
|
+
*/
|
|
221
|
+
setPreviewReloading(reloading: boolean): void {
|
|
222
|
+
if (this.previewReloading === reloading) return;
|
|
223
|
+
this.previewReloading = reloading;
|
|
224
|
+
if (!reloading) {
|
|
225
|
+
this.pump();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
for (const entry of this.entries.values()) {
|
|
229
|
+
if (entry.state !== "loading" || entry.request.kind !== "composition") continue;
|
|
230
|
+
entry.preempted = true;
|
|
231
|
+
entry.controller?.abort();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
213
235
|
/** Evict project cache entries that are no longer owned by mounted consumers. */
|
|
214
236
|
invalidateProject(projectId: string): void {
|
|
215
237
|
for (const [key, entry] of this.entries) {
|
|
@@ -257,6 +279,7 @@ export class ThumbnailScheduler {
|
|
|
257
279
|
|
|
258
280
|
for (const entry of queued) {
|
|
259
281
|
if (this.scrolling && entry.request.rich) continue;
|
|
282
|
+
if (this.previewReloading && entry.request.kind === "composition") continue;
|
|
260
283
|
const bucket = concurrencyBucket(entry.request.kind);
|
|
261
284
|
if (this.activeByBucket[bucket] >= this.bucketLimit(bucket)) continue;
|
|
262
285
|
this.start(entry, bucket);
|
|
@@ -281,6 +304,7 @@ export class ThumbnailScheduler {
|
|
|
281
304
|
this.deleteEntry(entry.scopedKey, entry);
|
|
282
305
|
return;
|
|
283
306
|
}
|
|
307
|
+
if (this.requeuePreempted(entry)) return;
|
|
284
308
|
entry.state = "error";
|
|
285
309
|
entry.error = errorFrom(reason);
|
|
286
310
|
entry.failedAt = this.now();
|
|
@@ -303,6 +327,7 @@ export class ThumbnailScheduler {
|
|
|
303
327
|
): void {
|
|
304
328
|
if (controller.signal.aborted || this.entries.get(entry.scopedKey) !== entry) {
|
|
305
329
|
this.safeDispose(result.dispose);
|
|
330
|
+
this.requeuePreempted(entry);
|
|
306
331
|
return;
|
|
307
332
|
}
|
|
308
333
|
this.validateResult(result);
|
|
@@ -317,6 +342,15 @@ export class ThumbnailScheduler {
|
|
|
317
342
|
if (!entry.cached && entry.leases.size === 0) this.deleteEntry(entry.scopedKey, entry);
|
|
318
343
|
}
|
|
319
344
|
|
|
345
|
+
private requeuePreempted(entry: ThumbnailEntry): boolean {
|
|
346
|
+
if (!entry.preempted || this.entries.get(entry.scopedKey) !== entry) return false;
|
|
347
|
+
entry.preempted = false;
|
|
348
|
+
entry.state = "queued";
|
|
349
|
+
entry.snapshot = Object.freeze({ status: "queued" });
|
|
350
|
+
this.notify(entry);
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
|
|
320
354
|
private validateResult(result: ThumbnailLoadedResult): void {
|
|
321
355
|
if (Number.isFinite(result.weight) && result.weight >= 0) return;
|
|
322
356
|
this.safeDispose(result.dispose);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
3
3
|
import { usePlayerStore, liveTime, type TimelineElement } from "./playerStore";
|
|
4
|
+
import { thumbnailRevisionOf } from "./thumbnailSlice";
|
|
4
5
|
|
|
5
6
|
/** The playback/selection state `reset()` restores (persistent prefs asserted separately). */
|
|
6
7
|
function expectResettableDefaults(state: ReturnType<typeof usePlayerStore.getState>): void {
|
|
@@ -32,25 +33,38 @@ describe("usePlayerStore", () => {
|
|
|
32
33
|
});
|
|
33
34
|
|
|
34
35
|
describe("thumbnail content revision", () => {
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
const revisionOf = (path: string) =>
|
|
37
|
+
thumbnailRevisionOf(usePlayerStore.getState().thumbnailRevisions, path);
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
it("advances only the compositions an accepted file event names", () => {
|
|
40
|
+
const scene = revisionOf("compositions/scene-a.html");
|
|
41
|
+
const sibling = revisionOf("compositions/scene-b.html");
|
|
39
42
|
|
|
40
|
-
|
|
43
|
+
usePlayerStore.getState().bumpThumbnailRevisions(["compositions/scene-a.html"]);
|
|
44
|
+
|
|
45
|
+
expect(revisionOf("compositions/scene-a.html")).toBe(scene + 1);
|
|
46
|
+
expect(revisionOf("compositions/scene-b.html")).toBe(sibling);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("advances every composition when the event names none", () => {
|
|
50
|
+
const sibling = revisionOf("compositions/scene-b.html");
|
|
51
|
+
|
|
52
|
+
usePlayerStore.getState().bumpThumbnailRevisions(null);
|
|
53
|
+
|
|
54
|
+
expect(revisionOf("compositions/scene-b.html")).toBe(sibling + 1);
|
|
41
55
|
});
|
|
42
56
|
|
|
43
57
|
it("remains monotonic across soft resets while project identity stays with the session epoch", () => {
|
|
44
58
|
const store = usePlayerStore.getState();
|
|
45
59
|
store.beginTimelineSession("project-a");
|
|
46
|
-
store.
|
|
47
|
-
const revision =
|
|
60
|
+
store.bumpThumbnailRevisions(null);
|
|
61
|
+
const revision = revisionOf("index.html");
|
|
48
62
|
const firstEpoch = usePlayerStore.getState().timelineSessionEpoch;
|
|
49
63
|
|
|
50
64
|
store.reset();
|
|
51
|
-
expect(
|
|
65
|
+
expect(revisionOf("index.html")).toBe(revision);
|
|
52
66
|
store.beginTimelineSession("project-b");
|
|
53
|
-
expect(
|
|
67
|
+
expect(revisionOf("index.html")).toBe(revision);
|
|
54
68
|
expect(usePlayerStore.getState().timelineSessionEpoch).toBe(firstEpoch + 1);
|
|
55
69
|
});
|
|
56
70
|
});
|
|
@@ -2,23 +2,45 @@ import type { StoreApi } from "zustand";
|
|
|
2
2
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
|
3
3
|
import { defaultThumbnailMode, type ThumbnailMode } from "../lib/thumbnailPolicy";
|
|
4
4
|
|
|
5
|
+
/** Revision that moves every composition's thumbnails at once. */
|
|
6
|
+
const EVERY_COMPOSITION = "*";
|
|
7
|
+
|
|
8
|
+
/** Mounts may be written `./scene.html`; the server reports `scene.html`. */
|
|
9
|
+
function revisionKey(path: string): string {
|
|
10
|
+
return (path.split(/[?#]/)[0] ?? "").replace(/\\/g, "/").replace(/^\.?\//, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
5
13
|
export interface ThumbnailSlice {
|
|
6
14
|
thumbnailMode: ThumbnailMode;
|
|
7
|
-
/** Monotonic identity for persisted
|
|
8
|
-
|
|
15
|
+
/** Monotonic identity, per composition path, for persisted content shown by mounted thumbnails. */
|
|
16
|
+
thumbnailRevisions: Readonly<Record<string, number>>;
|
|
9
17
|
setThumbnailMode: (mode: ThumbnailMode) => void;
|
|
10
|
-
|
|
18
|
+
/** `null` moves every composition, e.g. after a root or asset change. */
|
|
19
|
+
bumpThumbnailRevisions: (compositions: readonly string[] | null) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function thumbnailRevisionOf(
|
|
23
|
+
revisions: Readonly<Record<string, number>>,
|
|
24
|
+
compositionPath: string,
|
|
25
|
+
): number {
|
|
26
|
+
return (revisions[EVERY_COMPOSITION] ?? 0) + (revisions[revisionKey(compositionPath)] ?? 0);
|
|
11
27
|
}
|
|
12
28
|
|
|
13
29
|
export function createThumbnailSlice(set: StoreApi<ThumbnailSlice>["setState"]): ThumbnailSlice {
|
|
14
30
|
return {
|
|
15
31
|
thumbnailMode: defaultThumbnailMode(readStudioUiPreferences().thumbnailMode),
|
|
16
|
-
|
|
32
|
+
thumbnailRevisions: {},
|
|
17
33
|
setThumbnailMode: (mode) => {
|
|
18
34
|
writeStudioUiPreferences({ thumbnailMode: mode });
|
|
19
35
|
set({ thumbnailMode: mode });
|
|
20
36
|
},
|
|
21
|
-
|
|
22
|
-
set((
|
|
37
|
+
bumpThumbnailRevisions: (compositions) =>
|
|
38
|
+
set(({ thumbnailRevisions }) => {
|
|
39
|
+
const next = { ...thumbnailRevisions };
|
|
40
|
+
for (const path of compositions?.map(revisionKey) ?? [EVERY_COMPOSITION]) {
|
|
41
|
+
next[path] = (next[path] ?? 0) + 1;
|
|
42
|
+
}
|
|
43
|
+
return { thumbnailRevisions: next };
|
|
44
|
+
}),
|
|
23
45
|
};
|
|
24
46
|
}
|