@hyperframes/studio 0.8.64 → 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-DLjjm1nC.js → index-Cs1lQrQ6.js} +142 -142
- package/dist/assets/{index-BEyJVAiH.js → index-a5nEhdAU.js} +1 -1
- package/dist/assets/{index-RRyhgH2k.js → index-wQFEeTL3.js} +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.html +1 -1
- package/dist/index.js +823 -748
- 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/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-pWkneUHC.js +0 -464
|
@@ -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
|
}
|