@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.
Files changed (77) hide show
  1. package/dist/assets/{hyperframes-player-B30apksz.js → hyperframes-player-CckOoCJ8.js} +1 -1
  2. package/dist/assets/{index-DIJuIBwI.js → index-BmIPrb8I.js} +1 -1
  3. package/dist/assets/{index-BdjA0CLD.js → index-Br1Whc0f.js} +192 -192
  4. package/dist/assets/{index-DXVvWfQG.js → index-DsEkE5_G.js} +1 -1
  5. package/dist/{chunk-4S3AYCOG.js → chunk-E422JDF5.js} +2 -2
  6. package/dist/{chunk-TDWGXBUT.js → chunk-TFLU2MGI.js} +1 -6
  7. package/dist/chunk-TFLU2MGI.js.map +1 -0
  8. package/dist/{chunk-7UQWVN52.js → chunk-UYWON5AX.js} +2 -2
  9. package/dist/{domEditingLayers-IEVLO54Z.js → domEditingLayers-KBTPQKGA.js} +3 -3
  10. package/dist/index.d.ts +2 -1
  11. package/dist/index.html +1 -1
  12. package/dist/index.js +846 -469
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/package.json +8 -8
  16. package/src/App.tsx +9 -4
  17. package/src/components/editor/DomEditOverlay.tsx +9 -5
  18. package/src/components/editor/PropertyPanel.test.tsx +600 -748
  19. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +2 -0
  20. package/src/components/editor/domEditOverlayGeometry.ts +7 -7
  21. package/src/components/editor/domEditOverlayGestures.ts +18 -0
  22. package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +1 -0
  23. package/src/components/editor/domEditOverlayStartGesture.test.ts +3 -0
  24. package/src/components/editor/domEditOverlayStartGesture.ts +9 -1
  25. package/src/components/editor/dragUndoPaint.test.ts +2 -0
  26. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +432 -15
  27. package/src/components/editor/groupDropKeepsSelection.test.ts +1 -0
  28. package/src/components/editor/manualEditsDom.ts +7 -7
  29. package/src/components/editor/manualEditsTypes.ts +5 -0
  30. package/src/components/editor/manualOffsetDrag.test.ts +23 -0
  31. package/src/components/editor/manualOffsetDrag.ts +5 -0
  32. package/src/components/editor/previewGestureStarts.ts +284 -0
  33. package/src/components/editor/rotateInlineSave.test.ts +1 -0
  34. package/src/components/editor/shiftDragAxisLock.test.tsx +1 -0
  35. package/src/components/editor/useDomEditOverlayGestures.ts +67 -71
  36. package/src/components/nle/NLEContext.test.ts +26 -0
  37. package/src/components/nle/NLEContext.tsx +8 -3
  38. package/src/hooks/draggedGsapPosition.ts +8 -0
  39. package/src/hooks/editMoment.ts +13 -0
  40. package/src/hooks/gsapDragCommit.ts +2 -1
  41. package/src/hooks/gsapDragPositionCommit.ts +19 -7
  42. package/src/hooks/gsapPositionDetection.ts +5 -3
  43. package/src/hooks/gsapResizeIntercept.ts +11 -4
  44. package/src/hooks/gsapRuntimeBridge.ts +31 -11
  45. package/src/hooks/gsapValueAtPlayhead.test.ts +15 -0
  46. package/src/hooks/gsapValueAtPlayhead.ts +12 -5
  47. package/src/hooks/timelineEditingHelpers.test.ts +22 -0
  48. package/src/hooks/timelineEditingHelpers.ts +18 -2
  49. package/src/hooks/useDomSelection.test.ts +21 -2
  50. package/src/hooks/useEditHistoryActions.test.tsx +20 -0
  51. package/src/hooks/useEditHistoryActions.ts +2 -0
  52. package/src/hooks/useGsapAwareEditing.test.tsx +38 -0
  53. package/src/hooks/useGsapAwareEditing.ts +148 -136
  54. package/src/hooks/useGsapAwareGroupMove.ts +126 -113
  55. package/src/hooks/useGsapScriptCommits.ts +5 -4
  56. package/src/hooks/usePreviewPersistence.history.test.tsx +7 -2
  57. package/src/hooks/usePreviewPersistence.ts +1 -4
  58. package/src/hooks/useTimelineDeleteOps.test.tsx +10 -0
  59. package/src/hooks/useTimelineDeleteOps.ts +9 -10
  60. package/src/index.ts +1 -0
  61. package/src/player/hooks/useShadowPreviewReload.ts +6 -6
  62. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +14 -0
  63. package/src/player/hooks/useTimelinePlayer.ts +8 -5
  64. package/src/player/lib/thumbnailScheduler.test.ts +20 -1
  65. package/src/player/lib/thumbnailScheduler.ts +1 -1
  66. package/src/player/lib/timelineElementHelpers.ts +0 -11
  67. package/src/player/previewReloading.test.ts +48 -0
  68. package/src/player/previewReloading.ts +48 -0
  69. package/src/utils/rootDuration.ts +5 -4
  70. package/src/utils/studioPendingEdits.test.ts +27 -0
  71. package/src/utils/studioPendingEdits.ts +9 -0
  72. package/src/utils/timelineAssetDrop.test.ts +13 -4
  73. package/src/utils/timelineAssetDrop.ts +6 -2
  74. package/dist/chunk-TDWGXBUT.js.map +0 -1
  75. /package/dist/{chunk-4S3AYCOG.js.map → chunk-E422JDF5.js.map} +0 -0
  76. /package/dist/{chunk-7UQWVN52.js.map → chunk-UYWON5AX.js.map} +0 -0
  77. /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(async (pid: string, compositionPath: string | null, targetPath: string, selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
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(async (pid: string, compositionPath: string | null, targetPath: string, calls: CommitMutationCall[], options: CommitMutationOptions) => {
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([clips, true]);
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 { buildPatchTarget, removeIframeTimelineElements } from "./timelineEditingHelpers";
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 deleteContentEnd = furthestClipEndFromSource(removedContent);
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 (deleteContentEnd > 0 && targetPath === (activeCompPath || "index.html")) {
155
- usePlayerStore.getState().setDuration(deleteContentEnd);
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 { thumbnailScheduler } from "../lib/thumbnailScheduler";
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
- thumbnailScheduler.setPreviewReloading(false);
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
- thumbnailScheduler.setPreviewReloading(false);
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
- thumbnailScheduler.setPreviewReloading(true);
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
- thumbnailScheduler.setPreviewReloading(false);
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
- thumbnailScheduler.setPreviewReloading(false);
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((error: unknown) => {
508
- if (!isCurrent()) return;
509
- logReload("scene-swap-refused", { reason: String(error) });
510
- reloadWholeFilm(url.toString());
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
- const root = new DOMParser()
37
- .parseFromString(source, "text/html")
38
- .querySelector("[data-composition-id]");
39
- const raw = root?.getAttribute("data-duration");
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("is a no-op when content end is 0 (empty timeline keeps its declared length)", () => {
28
- expect(setCompositionDurationToContent(src(12), 0)).toBe(src(12));
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(source: string, contentEnd: number): string {
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;