@hyperframes/studio 0.8.138 → 0.8.139
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-B30apksz.js → hyperframes-player-CckOoCJ8.js} +1 -1
- package/dist/assets/{index-DIJuIBwI.js → index-BmIPrb8I.js} +1 -1
- package/dist/assets/{index-BdjA0CLD.js → index-Br1Whc0f.js} +192 -192
- package/dist/assets/{index-DXVvWfQG.js → index-DsEkE5_G.js} +1 -1
- package/dist/{chunk-4S3AYCOG.js → chunk-E422JDF5.js} +2 -2
- package/dist/{chunk-TDWGXBUT.js → chunk-TFLU2MGI.js} +1 -6
- package/dist/chunk-TFLU2MGI.js.map +1 -0
- package/dist/{chunk-7UQWVN52.js → chunk-UYWON5AX.js} +2 -2
- package/dist/{domEditingLayers-IEVLO54Z.js → domEditingLayers-KBTPQKGA.js} +3 -3
- package/dist/index.d.ts +2 -1
- package/dist/index.html +1 -1
- package/dist/index.js +846 -469
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/package.json +8 -8
- package/src/App.tsx +9 -4
- package/src/components/editor/DomEditOverlay.tsx +9 -5
- package/src/components/editor/PropertyPanel.test.tsx +600 -748
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +2 -0
- package/src/components/editor/domEditOverlayGeometry.ts +7 -7
- package/src/components/editor/domEditOverlayGestures.ts +18 -0
- package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.test.ts +3 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +9 -1
- package/src/components/editor/dragUndoPaint.test.ts +2 -0
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +432 -15
- package/src/components/editor/groupDropKeepsSelection.test.ts +1 -0
- package/src/components/editor/manualEditsDom.ts +7 -7
- package/src/components/editor/manualEditsTypes.ts +5 -0
- package/src/components/editor/manualOffsetDrag.test.ts +23 -0
- package/src/components/editor/manualOffsetDrag.ts +5 -0
- package/src/components/editor/previewGestureStarts.ts +284 -0
- package/src/components/editor/rotateInlineSave.test.ts +1 -0
- package/src/components/editor/shiftDragAxisLock.test.tsx +1 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +67 -71
- package/src/components/nle/NLEContext.test.ts +26 -0
- package/src/components/nle/NLEContext.tsx +8 -3
- package/src/hooks/draggedGsapPosition.ts +8 -0
- package/src/hooks/editMoment.ts +13 -0
- package/src/hooks/gsapDragCommit.ts +2 -1
- package/src/hooks/gsapDragPositionCommit.ts +19 -7
- package/src/hooks/gsapPositionDetection.ts +5 -3
- package/src/hooks/gsapResizeIntercept.ts +11 -4
- package/src/hooks/gsapRuntimeBridge.ts +31 -11
- package/src/hooks/gsapValueAtPlayhead.test.ts +15 -0
- package/src/hooks/gsapValueAtPlayhead.ts +12 -5
- package/src/hooks/timelineEditingHelpers.test.ts +22 -0
- package/src/hooks/timelineEditingHelpers.ts +18 -2
- package/src/hooks/useDomSelection.test.ts +21 -2
- package/src/hooks/useEditHistoryActions.test.tsx +20 -0
- package/src/hooks/useEditHistoryActions.ts +2 -0
- package/src/hooks/useGsapAwareEditing.test.tsx +38 -0
- package/src/hooks/useGsapAwareEditing.ts +148 -136
- package/src/hooks/useGsapAwareGroupMove.ts +126 -113
- package/src/hooks/useGsapScriptCommits.ts +5 -4
- package/src/hooks/usePreviewPersistence.history.test.tsx +7 -2
- package/src/hooks/usePreviewPersistence.ts +1 -4
- package/src/hooks/useTimelineDeleteOps.test.tsx +10 -0
- package/src/hooks/useTimelineDeleteOps.ts +9 -10
- package/src/index.ts +1 -0
- package/src/player/hooks/useShadowPreviewReload.ts +6 -6
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +14 -0
- package/src/player/hooks/useTimelinePlayer.ts +8 -5
- package/src/player/lib/thumbnailScheduler.test.ts +20 -1
- package/src/player/lib/thumbnailScheduler.ts +1 -1
- package/src/player/lib/timelineElementHelpers.ts +0 -11
- package/src/player/previewReloading.test.ts +48 -0
- package/src/player/previewReloading.ts +48 -0
- package/src/utils/rootDuration.ts +5 -4
- package/src/utils/studioPendingEdits.test.ts +27 -0
- package/src/utils/studioPendingEdits.ts +9 -0
- package/src/utils/timelineAssetDrop.test.ts +13 -4
- package/src/utils/timelineAssetDrop.ts +6 -2
- package/dist/chunk-TDWGXBUT.js.map +0 -1
- /package/dist/{chunk-4S3AYCOG.js.map → chunk-E422JDF5.js.map} +0 -0
- /package/dist/{chunk-7UQWVN52.js.map → chunk-UYWON5AX.js.map} +0 -0
- /package/dist/{domEditingLayers-IEVLO54Z.js.map → domEditingLayers-KBTPQKGA.js.map} +0 -0
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
useSafeGsapCommitMutation,
|
|
46
46
|
} from "./useSafeGsapCommitMutation";
|
|
47
47
|
import { requestGsapMutation } from "./gsapMutationClient";
|
|
48
|
+
import { whileScriptWrites } from "../player/previewReloading";
|
|
48
49
|
|
|
49
50
|
async function mutateGsapScript(
|
|
50
51
|
projectId: string,
|
|
@@ -373,7 +374,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
373
374
|
});
|
|
374
375
|
}, [projectIdRef, previewIframeRef, reloadPreview, onCacheInvalidate, onFileContentChanged, forceReloadSdkSession, recordMutationEdit]);
|
|
375
376
|
|
|
376
|
-
const runCommit = useCallback(
|
|
377
|
+
const runCommit = useCallback((pid: string, compositionPath: string | null, targetPath: string, selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => whileScriptWrites(async () => {
|
|
377
378
|
const result = await runMutationRequest([mutation], options, showToast, () =>
|
|
378
379
|
mutateGsapScript(pid, "gsap-mutations", targetPath, mutation),
|
|
379
380
|
);
|
|
@@ -381,9 +382,9 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
381
382
|
trackKeyframeCommit([mutation], result, options);
|
|
382
383
|
options.onResult?.(result);
|
|
383
384
|
await finalizeSuccessfulMutation(pid, compositionPath, selection, mutation, targetPath, result, options);
|
|
384
|
-
}, [showToast, finalizeSuccessfulMutation]);
|
|
385
|
+
}), [showToast, finalizeSuccessfulMutation]);
|
|
385
386
|
|
|
386
|
-
const runBatchCommit = useCallback(
|
|
387
|
+
const runBatchCommit = useCallback((pid: string, compositionPath: string | null, targetPath: string, calls: CommitMutationCall[], options: CommitMutationOptions) => whileScriptWrites(async () => {
|
|
387
388
|
const first = calls[0];
|
|
388
389
|
const last = calls.at(-1);
|
|
389
390
|
if (!first || !last) return;
|
|
@@ -402,7 +403,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
402
403
|
const { instantPatch: _instantPatch, ...batchOptions } = options;
|
|
403
404
|
const allPatched = instantPatches.length === calls.length;
|
|
404
405
|
await finalizeSuccessfulMutation(pid, compositionPath, last.selection, last.mutation, targetPath, result, allPatched ? { ...batchOptions, instantPatches } : batchOptions);
|
|
405
|
-
}, [showToast, finalizeSuccessfulMutation]);
|
|
406
|
+
}), [showToast, finalizeSuccessfulMutation]);
|
|
406
407
|
|
|
407
408
|
// Every GSAP-script commit is a read-modify-write of one file. Overlapping
|
|
408
409
|
// commits to the SAME file (any op type, any animation) interleave server-side,
|
|
@@ -19,10 +19,11 @@ afterEach(() => {
|
|
|
19
19
|
});
|
|
20
20
|
|
|
21
21
|
describe("undo that reloads the preview", () => {
|
|
22
|
-
it("keeps the timeline's clips until the reloaded preview reports its own", async () => {
|
|
22
|
+
it("keeps the timeline's clips and selection until the reloaded preview reports its own", async () => {
|
|
23
23
|
const clips = [{ id: "a" }, { id: "b" }] as unknown as TimelineElement[];
|
|
24
24
|
usePlayerStore.getState().setElements(clips);
|
|
25
25
|
usePlayerStore.getState().setTimelineReady(true);
|
|
26
|
+
usePlayerStore.getState().setSelectedElementId("a");
|
|
26
27
|
const reloadPreview = vi.fn();
|
|
27
28
|
let sync: ReturnType<typeof usePreviewPersistence>["syncHistoryPreviewAfterApply"] | null =
|
|
28
29
|
null;
|
|
@@ -46,7 +47,11 @@ describe("undo that reloads the preview", () => {
|
|
|
46
47
|
|
|
47
48
|
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
48
49
|
const player = usePlayerStore.getState();
|
|
49
|
-
expect([player.elements, player.timelineReady]).toEqual([
|
|
50
|
+
expect([player.elements, player.timelineReady, player.selectedElementId]).toEqual([
|
|
51
|
+
clips,
|
|
52
|
+
true,
|
|
53
|
+
"a",
|
|
54
|
+
]);
|
|
50
55
|
});
|
|
51
56
|
});
|
|
52
57
|
|
|
@@ -248,10 +248,7 @@ export function usePreviewPersistence({
|
|
|
248
248
|
reloadPreview,
|
|
249
249
|
nestedFiles,
|
|
250
250
|
);
|
|
251
|
-
if (strategy === "full")
|
|
252
|
-
usePlayerStore.getState().setSelectedElementId(null);
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
251
|
+
if (strategy === "full") return;
|
|
255
252
|
// A soft restore patched the reverted attributes onto the live preview, but the
|
|
256
253
|
// player store keeps its own copy and that copy is what the automation lanes
|
|
257
254
|
// draw — so without this an undone envelope edit stayed invisible until a
|
|
@@ -275,6 +275,16 @@ describe("useTimelineDeleteOps: undo race", () => {
|
|
|
275
275
|
await flushStudioPendingEdits();
|
|
276
276
|
});
|
|
277
277
|
});
|
|
278
|
+
|
|
279
|
+
it("shows the shortened length while the last clip's delete is still saving", async () => {
|
|
280
|
+
const { gate, recordEdit, deleteSelection } = mountRaceHarness();
|
|
281
|
+
usePlayerStore.getState().setDuration(6);
|
|
282
|
+
const deletePromise = deleteSelection();
|
|
283
|
+
await vi.waitFor(() => expect(recordEdit).toHaveBeenCalled());
|
|
284
|
+
expect(usePlayerStore.getState().duration).toBe(4);
|
|
285
|
+
gate.resolve();
|
|
286
|
+
await deletePromise;
|
|
287
|
+
});
|
|
278
288
|
});
|
|
279
289
|
|
|
280
290
|
// Regression: the live preview kept a deleted clip until its reload landed, and composition
|
|
@@ -8,11 +8,13 @@ import { usePlayerStore } from "../player";
|
|
|
8
8
|
import { saveServerRewriteWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
|
|
9
9
|
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
10
10
|
import { getTimelineElementLabel } from "../utils/studioHelpers";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
buildPatchTarget,
|
|
13
|
+
removeIframeTimelineElements,
|
|
14
|
+
syncCompositionDurationWithEnd,
|
|
15
|
+
} from "./timelineEditingHelpers";
|
|
12
16
|
import { captureDurationRollback, timingGestureStep } from "./timelineTimingSync";
|
|
13
17
|
import { setLinkInSource } from "../components/editor/mediaLinkEdits";
|
|
14
|
-
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
|
|
15
|
-
import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
|
|
16
18
|
import {
|
|
17
19
|
resolveMainTrackDeleteRippleShifts,
|
|
18
20
|
resolveShiftedElements,
|
|
@@ -143,16 +145,13 @@ export function useTimelineDeleteOps({
|
|
|
143
145
|
typeof removeData.content === "string" ? removeData.content : originalContent;
|
|
144
146
|
// Shrink to the furthest remaining clip end, read from the post-removal source:
|
|
145
147
|
// store durations are runtime-truncated.
|
|
146
|
-
const
|
|
147
|
-
const patchedContent = unlinkInSource(
|
|
148
|
-
setCompositionDurationToContent(removedContent, deleteContentEnd),
|
|
149
|
-
alsoUnlink,
|
|
150
|
-
);
|
|
148
|
+
const synced = syncCompositionDurationWithEnd(removedContent);
|
|
149
|
+
const patchedContent = unlinkInSource(synced.source, alsoUnlink);
|
|
151
150
|
// Optimistically reflect the shrunk length in the readout/seek bar,
|
|
152
151
|
// rolling it back if the persist below fails (see captureDurationRollback).
|
|
153
152
|
rollbackDuration = captureDurationRollback(previewIframeRef.current);
|
|
154
|
-
if (
|
|
155
|
-
usePlayerStore.getState().setDuration(
|
|
153
|
+
if (synced.contentEnd > 0 && targetPath === (activeCompPath || "index.html")) {
|
|
154
|
+
usePlayerStore.getState().setDuration(synced.contentEnd);
|
|
156
155
|
}
|
|
157
156
|
return { disk: removedContent, after: patchedContent };
|
|
158
157
|
},
|
package/src/index.ts
CHANGED
|
@@ -160,6 +160,7 @@ export type {
|
|
|
160
160
|
// can race a write still in flight (see useTrackPendingTimelineEdit.ts).
|
|
161
161
|
export { flushStudioPendingEdits } from "./utils/studioPendingEdits";
|
|
162
162
|
export { revertNewestStudioPendingEdit } from "./utils/studioPendingEdits";
|
|
163
|
+
export { cancelNewestStudioWaitingPress } from "./utils/studioPendingEdits";
|
|
163
164
|
export type { StudioPendingEditsDrainResult } from "./utils/studioPendingEdits";
|
|
164
165
|
export type {
|
|
165
166
|
CanEditTimelineElement,
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
type UseTimelineSyncCallbacksParams,
|
|
19
19
|
} from "./useTimelineSyncCallbacks";
|
|
20
20
|
import type { IframeWindow, PlaybackAdapter } from "../lib/playbackTypes";
|
|
21
|
-
import {
|
|
21
|
+
import { setPreviewReloading } from "../previewReloading";
|
|
22
22
|
import { announcePreviewPromoted } from "../sceneSwap";
|
|
23
23
|
import { afterStudioPendingEdits, isStudioEditSaving } from "../../utils/studioPendingEdits";
|
|
24
24
|
import { usePlayerStore } from "../store/playerStore";
|
|
@@ -110,7 +110,7 @@ export function useShadowPreviewReload({
|
|
|
110
110
|
isRefreshingRef.current = false;
|
|
111
111
|
pendingSeekRef.current = null;
|
|
112
112
|
setPreviewSlots(planShadowDiscard);
|
|
113
|
-
|
|
113
|
+
setPreviewReloading(false);
|
|
114
114
|
const message = `The preview did not reload (${cause}). The previous preview is still showing.`;
|
|
115
115
|
logReload("shadow-failed", { cause });
|
|
116
116
|
console.error(`[studio] ${message}`);
|
|
@@ -188,7 +188,7 @@ export function useShadowPreviewReload({
|
|
|
188
188
|
if (liveTime != null) handOverPlaybackRef.current(liveTime, playing);
|
|
189
189
|
announcePreviewPromoted({ retired, live: shadow });
|
|
190
190
|
onPromotedRef.current?.();
|
|
191
|
-
|
|
191
|
+
setPreviewReloading(false);
|
|
192
192
|
},
|
|
193
193
|
[
|
|
194
194
|
stopPendingShadow,
|
|
@@ -285,7 +285,7 @@ export function useShadowPreviewReload({
|
|
|
285
285
|
armReadyTimer(gen);
|
|
286
286
|
setPreviewSlots((prev) => planShadowReload(prev, gen, url));
|
|
287
287
|
// Thumbnails of the edit wait for the new preview instead of competing with it.
|
|
288
|
-
|
|
288
|
+
setPreviewReloading(true);
|
|
289
289
|
},
|
|
290
290
|
[stopPendingShadow, armReadyTimer, gestureSavesOnScreen],
|
|
291
291
|
);
|
|
@@ -300,7 +300,7 @@ export function useShadowPreviewReload({
|
|
|
300
300
|
pendingSeekRef.current = null;
|
|
301
301
|
usePlayerStore.getState().setTimelineReady(false);
|
|
302
302
|
setPreviewSlots(planShadowDiscard);
|
|
303
|
-
|
|
303
|
+
setPreviewReloading(false);
|
|
304
304
|
}, [stopPendingShadow, isRefreshingRef, pendingSeekRef]);
|
|
305
305
|
|
|
306
306
|
// Hiding the tab pauses the budget; showing it again restarts the full budget for a pending shadow.
|
|
@@ -317,7 +317,7 @@ export function useShadowPreviewReload({
|
|
|
317
317
|
return () => {
|
|
318
318
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
319
319
|
stopPendingShadow();
|
|
320
|
-
|
|
320
|
+
setPreviewReloading(false);
|
|
321
321
|
};
|
|
322
322
|
});
|
|
323
323
|
|
|
@@ -6,6 +6,7 @@ import { createRoot } from "react-dom/client";
|
|
|
6
6
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
7
|
import { SHADOW_READY_BUDGETS, SHADOW_READY_TIMEOUT_MS } from "./useShadowPreviewReload";
|
|
8
8
|
import { usePlayerStore } from "../store/playerStore";
|
|
9
|
+
import { isPreviewChanging } from "../previewReloading";
|
|
9
10
|
import { NLEProvider, useNLEContext, type NLEContextValue } from "../../components/nle/NLEContext";
|
|
10
11
|
import {
|
|
11
12
|
makeAdapterWindow,
|
|
@@ -752,6 +753,19 @@ describe("useTimelinePlayer scene swap", () => {
|
|
|
752
753
|
expect(roles(getApi())).toEqual(["live"]);
|
|
753
754
|
});
|
|
754
755
|
|
|
756
|
+
it("counts as a preview change until the swap lands, so a canvas press waits for the new scenes", async () => {
|
|
757
|
+
let reply: (r: Response) => void = () => {};
|
|
758
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(
|
|
759
|
+
() => new Promise<Response>((resolve) => (reply = resolve)),
|
|
760
|
+
);
|
|
761
|
+
const { getApi } = liveFilm(vi.fn(async () => {}));
|
|
762
|
+
act(() => getApi().refreshPlayer());
|
|
763
|
+
expect(isPreviewChanging()).toBe(true);
|
|
764
|
+
reply(new Response("<html>v2</html>"));
|
|
765
|
+
await settle();
|
|
766
|
+
expect(isPreviewChanging()).toBe(false);
|
|
767
|
+
});
|
|
768
|
+
|
|
755
769
|
it("falls back to the full reload when the preview refuses the swap", async () => {
|
|
756
770
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html>v2</html>"));
|
|
757
771
|
const { getApi } = liveFilm(async () => {
|
|
@@ -43,6 +43,7 @@ import { createPreviewMessageHandler } from "./previewMessageRouter";
|
|
|
43
43
|
import { timelineElementsChanged } from "./timelinePlayerSync";
|
|
44
44
|
import { safeContentDocument } from "./timelineSyncHydration";
|
|
45
45
|
import { sceneSwapFor } from "../sceneSwap";
|
|
46
|
+
import { whileScriptWrites } from "../previewReloading";
|
|
46
47
|
|
|
47
48
|
export interface UseTimelinePlayerOptions {
|
|
48
49
|
/** Runs right after a reloaded preview becomes the live iframe. */
|
|
@@ -504,11 +505,13 @@ export function useTimelinePlayer({
|
|
|
504
505
|
!!iframe.contentDocument && isStudioManualEditGestureLiveIn(iframe.contentDocument);
|
|
505
506
|
if (!swap || isRefreshingRef.current || swapWouldReplaceGestureNode)
|
|
506
507
|
return reloadWholeFilm(url.toString());
|
|
507
|
-
swap(url.toString(), isCurrent, cancel.signal).catch(
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
508
|
+
whileScriptWrites(() => swap(url.toString(), isCurrent, cancel.signal)).catch(
|
|
509
|
+
(error: unknown) => {
|
|
510
|
+
if (!isCurrent()) return;
|
|
511
|
+
logReload("scene-swap-refused", { reason: String(error) });
|
|
512
|
+
reloadWholeFilm(url.toString());
|
|
513
|
+
},
|
|
514
|
+
);
|
|
512
515
|
}, [reloadWholeFilm, previewGeneration]);
|
|
513
516
|
const pauseRef = useRef(pause);
|
|
514
517
|
pauseRef.current = pause;
|
|
@@ -219,6 +219,26 @@ describe("ThumbnailScheduler", () => {
|
|
|
219
219
|
expect(scheduler.getDiagnostics()).toMatchObject({ queued: 0, active: 0, leases: 0 });
|
|
220
220
|
});
|
|
221
221
|
|
|
222
|
+
it("starts nothing that a release later in the same batch drops", async () => {
|
|
223
|
+
const scheduler = new ThumbnailScheduler(
|
|
224
|
+
resolveTimelineViewportBudgets({ concurrentVideoDecodes: 1 }),
|
|
225
|
+
);
|
|
226
|
+
const video = (key: string, load: ThumbnailRequest["load"]) =>
|
|
227
|
+
scheduler.acquire(request(key, load, "visible", { kind: "video" }), vi.fn());
|
|
228
|
+
const activeLease = video("active", () => deferred<ThumbnailLoadedResult>().promise);
|
|
229
|
+
const droppedLoad = vi.fn(async () => result("dropped"));
|
|
230
|
+
const droppedLease = video("dropped", droppedLoad);
|
|
231
|
+
const keptLoad = vi.fn(async () => result("kept"));
|
|
232
|
+
video("kept", keptLoad);
|
|
233
|
+
|
|
234
|
+
// A timeline jump unmounts its clips in one commit, the loading one first.
|
|
235
|
+
activeLease.release();
|
|
236
|
+
droppedLease.release();
|
|
237
|
+
await flush();
|
|
238
|
+
expect(droppedLoad).not.toHaveBeenCalled();
|
|
239
|
+
expect(keptLoad).toHaveBeenCalledTimes(1);
|
|
240
|
+
});
|
|
241
|
+
|
|
222
242
|
it("frees an aborted job's slot at once, even when its loader ignores the abort", async () => {
|
|
223
243
|
const scheduler = new ThumbnailScheduler(
|
|
224
244
|
resolveTimelineViewportBudgets({ concurrentVideoDecodes: 1 }),
|
|
@@ -231,7 +251,6 @@ describe("ThumbnailScheduler", () => {
|
|
|
231
251
|
lease.release();
|
|
232
252
|
const nextLoad = vi.fn(async () => result("next"));
|
|
233
253
|
scheduler.acquire(request("next", nextLoad, "visible", { kind: "video" }), vi.fn());
|
|
234
|
-
await flush();
|
|
235
254
|
expect(nextLoad).toHaveBeenCalledTimes(1);
|
|
236
255
|
});
|
|
237
256
|
|
|
@@ -316,7 +316,7 @@ export class ThumbnailScheduler {
|
|
|
316
316
|
slotFree = true;
|
|
317
317
|
this.activeByBucket[bucket]--;
|
|
318
318
|
this.activeByKind[entry.request.kind]--;
|
|
319
|
-
this.pump();
|
|
319
|
+
queueMicrotask(() => this.pump());
|
|
320
320
|
};
|
|
321
321
|
controller.signal.addEventListener("abort", freeSlot, { once: true });
|
|
322
322
|
this.notify(entry);
|
|
@@ -130,17 +130,6 @@ export function readTimelineDurationFromDocument(doc: Document | null | undefine
|
|
|
130
130
|
return furthestClipEndFromDocument(doc);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
/**
|
|
134
|
-
* Furthest clip end parsed straight from a composition SOURCE STRING (the HTML
|
|
135
|
-
* being saved). Uses raw `data-duration`, so it is the correct input for syncing
|
|
136
|
-
* the root duration after an edit — reading the store instead would use the
|
|
137
|
-
* runtime-truncated durations and shrink the composition (the feedback loop).
|
|
138
|
-
*/
|
|
139
|
-
export function furthestClipEndFromSource(source: string): number {
|
|
140
|
-
if (!source) return 0;
|
|
141
|
-
return furthestClipEndFromDocument(new DOMParser().parseFromString(source, "text/html"));
|
|
142
|
-
}
|
|
143
|
-
|
|
144
133
|
// ---------------------------------------------------------------------------
|
|
145
134
|
// DOM element type guards
|
|
146
135
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
giveUpOnPreviewChange,
|
|
4
|
+
isPreviewChanging,
|
|
5
|
+
previewReloadBegun,
|
|
6
|
+
requestPreviewReload,
|
|
7
|
+
setPreviewReloading,
|
|
8
|
+
whileScriptWrites,
|
|
9
|
+
} from "./previewReloading";
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
previewReloadBegun();
|
|
13
|
+
setPreviewReloading(false);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("the preview is changing", () => {
|
|
17
|
+
it("from a script write's send until it lands", async () => {
|
|
18
|
+
let land: () => void = () => {};
|
|
19
|
+
const write = whileScriptWrites(() => new Promise<void>((resolve) => (land = resolve)));
|
|
20
|
+
expect(isPreviewChanging()).toBe(true);
|
|
21
|
+
land();
|
|
22
|
+
await write;
|
|
23
|
+
expect(isPreviewChanging()).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("until a write that fails gives up, so a press never hangs on it", async () => {
|
|
27
|
+
await expect(whileScriptWrites(() => Promise.reject(new Error("rejected")))).rejects.toThrow();
|
|
28
|
+
expect(isPreviewChanging()).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("from a reload's request until it has begun", () => {
|
|
32
|
+
requestPreviewReload();
|
|
33
|
+
expect(isPreviewChanging()).toBe(true);
|
|
34
|
+
previewReloadBegun();
|
|
35
|
+
expect(isPreviewChanging()).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("stops counting a change a press gave up on, until the preview goes idle", async () => {
|
|
39
|
+
let land: () => void = () => {};
|
|
40
|
+
const stuck = whileScriptWrites(() => new Promise<void>((resolve) => (land = resolve)));
|
|
41
|
+
giveUpOnPreviewChange();
|
|
42
|
+
expect(isPreviewChanging()).toBe(false);
|
|
43
|
+
land();
|
|
44
|
+
await stuck;
|
|
45
|
+
requestPreviewReload();
|
|
46
|
+
expect(isPreviewChanging(), "the next change counts again").toBe(true);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { thumbnailScheduler } from "./lib/thumbnailScheduler";
|
|
2
|
+
|
|
3
|
+
let reloading = false;
|
|
4
|
+
let reloadRequested = false;
|
|
5
|
+
let scriptWrites = 0;
|
|
6
|
+
// A press gave up on the change in progress (a save that never returns); later ones do not wait on it again.
|
|
7
|
+
let givenUp = false;
|
|
8
|
+
|
|
9
|
+
const changing = () => reloading || reloadRequested || scriptWrites > 0;
|
|
10
|
+
const noteIdle = () => {
|
|
11
|
+
if (!changing()) givenUp = false;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function giveUpOnPreviewChange(): void {
|
|
15
|
+
givenUp = changing();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function setPreviewReloading(next: boolean): void {
|
|
19
|
+
reloading = next;
|
|
20
|
+
thumbnailScheduler.setPreviewReloading(next);
|
|
21
|
+
noteIdle();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Studio asked for a reload that has not begun yet; `previewReloadBegun` closes that gap. */
|
|
25
|
+
export function requestPreviewReload(): void {
|
|
26
|
+
reloadRequested = true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function previewReloadBegun(): void {
|
|
30
|
+
reloadRequested = false;
|
|
31
|
+
noteIdle();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A script write or scene swap counts from its send until its preview update is applied, or it fails. */
|
|
35
|
+
export async function whileScriptWrites<T>(write: () => Promise<T>): Promise<T> {
|
|
36
|
+
scriptWrites += 1;
|
|
37
|
+
try {
|
|
38
|
+
return await write();
|
|
39
|
+
} finally {
|
|
40
|
+
scriptWrites -= 1;
|
|
41
|
+
noteIdle();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The one owner of "the preview is about to change": a canvas press waits for it. */
|
|
46
|
+
export function isPreviewChanging(): boolean {
|
|
47
|
+
return changing() && !givenUp;
|
|
48
|
+
}
|
|
@@ -33,10 +33,11 @@ const DATA_DURATION_ATTR_RE = /(\bdata-duration\s*=\s*)(["'])[^"']*\2/i;
|
|
|
33
33
|
* Deterministic and render-safe: DOMParser is the only DOM global used.
|
|
34
34
|
*/
|
|
35
35
|
export function readRootCompositionDuration(source: string): number | null {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
return rootCompositionDurationOf(new DOMParser().parseFromString(source, "text/html"));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function rootCompositionDurationOf(doc: Document): number | null {
|
|
40
|
+
const raw = doc.querySelector("[data-composition-id]")?.getAttribute("data-duration");
|
|
40
41
|
if (raw == null) return null;
|
|
41
42
|
return Number.parseFloat(raw);
|
|
42
43
|
}
|
|
@@ -5,15 +5,42 @@ import {
|
|
|
5
5
|
adoptingStudioPendingEdit,
|
|
6
6
|
beginStudioPendingEdit,
|
|
7
7
|
flushStudioPendingEdits,
|
|
8
|
+
afterStudioPendingEdits,
|
|
8
9
|
hasStudioPendingEdits,
|
|
9
10
|
isStudioEditSaving,
|
|
10
11
|
paintBackNewestStudioPendingEdit,
|
|
12
|
+
setStudioWaitingPressCancel,
|
|
11
13
|
trackStudioPendingEdit,
|
|
12
14
|
trackedStudioEdit,
|
|
13
15
|
} from "./studioPendingEdits";
|
|
14
16
|
import { StudioFileConflictError } from "./studioSaveDiagnostics";
|
|
15
17
|
import { revertNewestStudioPendingEdit as hostRevert } from "../index";
|
|
16
18
|
|
|
19
|
+
describe("a canvas press still waiting to run", () => {
|
|
20
|
+
it("is not what a host's revert takes back, since the host steps history after it", () => {
|
|
21
|
+
const cancel = vi.fn(() => true);
|
|
22
|
+
setStudioWaitingPressCancel(cancel);
|
|
23
|
+
try {
|
|
24
|
+
expect(hostRevert()).toBeNull();
|
|
25
|
+
expect(cancel).not.toHaveBeenCalled();
|
|
26
|
+
} finally {
|
|
27
|
+
setStudioWaitingPressCancel(null);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("is never a pending edit, since a reload waits for those and the press waits for the reload", () => {
|
|
32
|
+
setStudioWaitingPressCancel(() => true);
|
|
33
|
+
const reload = vi.fn();
|
|
34
|
+
try {
|
|
35
|
+
afterStudioPendingEdits(reload);
|
|
36
|
+
expect(reload).toHaveBeenCalledOnce();
|
|
37
|
+
expect(hasStudioPendingEdits()).toBe(false);
|
|
38
|
+
} finally {
|
|
39
|
+
setStudioWaitingPressCancel(null);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
17
44
|
describe("studio pending edit flush", () => {
|
|
18
45
|
it("waits for mounted panels to persist pending local edits", async () => {
|
|
19
46
|
const persist = vi.fn(async () => undefined);
|
|
@@ -34,6 +34,15 @@ export function setStudioPendingEditClaimClock(read: (() => number) | null): voi
|
|
|
34
34
|
historyClaims = read ?? (() => 0);
|
|
35
35
|
}
|
|
36
36
|
const NOT_SAVED = () => Promise.resolve(false);
|
|
37
|
+
let cancelWaitingPress: (() => boolean) | null = null;
|
|
38
|
+
|
|
39
|
+
export function setStudioWaitingPressCancel(cancel: (() => boolean) | null): void {
|
|
40
|
+
cancelWaitingPress = cancel;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function cancelNewestStudioWaitingPress(): boolean {
|
|
44
|
+
return cancelWaitingPress?.() ?? false;
|
|
45
|
+
}
|
|
37
46
|
let adopting: StudioEditInFlight | null = null;
|
|
38
47
|
|
|
39
48
|
export function adoptingStudioPendingEdit(): StudioEditInFlight | null {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
3
|
import {
|
|
4
4
|
buildTimelineFileDropPlacements,
|
|
5
5
|
buildTimelineAssetInsertHtml,
|
|
@@ -24,9 +24,18 @@ describe("setCompositionDurationToContent", () => {
|
|
|
24
24
|
expect(setCompositionDurationToContent(src(5), 12)).toContain('data-duration="12"');
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
it(
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
|
|
28
|
+
"keeps the declared length without parsing when content end is %s",
|
|
29
|
+
(contentEnd) => {
|
|
30
|
+
const parse = vi.spyOn(DOMParser.prototype, "parseFromString");
|
|
31
|
+
try {
|
|
32
|
+
expect(setCompositionDurationToContent(src(12), contentEnd)).toBe(src(12));
|
|
33
|
+
expect(parse).not.toHaveBeenCalled();
|
|
34
|
+
} finally {
|
|
35
|
+
parse.mockRestore();
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
);
|
|
30
39
|
|
|
31
40
|
it("is a no-op when already equal", () => {
|
|
32
41
|
expect(setCompositionDurationToContent(src(9), 9)).toBe(src(9));
|
|
@@ -155,9 +155,13 @@ export function extendCompositionDurationIfNeeded(source: string, requiredEnd: n
|
|
|
155
155
|
* reduce the furthest clip end (delete/trim). No-op when `contentEnd` is not > 0, so
|
|
156
156
|
* an empty timeline keeps its declared duration instead of collapsing to 0.
|
|
157
157
|
*/
|
|
158
|
-
export function setCompositionDurationToContent(
|
|
158
|
+
export function setCompositionDurationToContent(
|
|
159
|
+
source: string,
|
|
160
|
+
contentEnd: number,
|
|
161
|
+
rootDuration?: number | null,
|
|
162
|
+
): string {
|
|
159
163
|
if (!Number.isFinite(contentEnd) || contentEnd <= 0) return source;
|
|
160
|
-
const rootDur = readRootCompositionDuration(source);
|
|
164
|
+
const rootDur = rootDuration === undefined ? readRootCompositionDuration(source) : rootDuration;
|
|
161
165
|
if (rootDur == null) return source;
|
|
162
166
|
const next = roundToCenti(contentEnd);
|
|
163
167
|
if (rootDur === next) return source;
|