@hyperframes/studio 0.8.137 → 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 (85) hide show
  1. package/dist/assets/{hyperframes-player-D_Mh6hbx.js → hyperframes-player-CckOoCJ8.js} +1 -1
  2. package/dist/assets/{index-CHEctGzc.js → index-BmIPrb8I.js} +1 -1
  3. package/dist/assets/{index-B3lJsA-e.js → index-Br1Whc0f.js} +189 -189
  4. package/dist/assets/{index-CC_E-Cbl.css → index-CVd4O6eH.css} +1 -1
  5. package/dist/assets/{index-rtccfg9b.js → index-DsEkE5_G.js} +1 -1
  6. package/dist/{chunk-4S3AYCOG.js → chunk-E422JDF5.js} +2 -2
  7. package/dist/{chunk-TDWGXBUT.js → chunk-TFLU2MGI.js} +1 -6
  8. package/dist/chunk-TFLU2MGI.js.map +1 -0
  9. package/dist/{chunk-7UQWVN52.js → chunk-UYWON5AX.js} +2 -2
  10. package/dist/{domEditingLayers-IEVLO54Z.js → domEditingLayers-KBTPQKGA.js} +3 -3
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.html +2 -2
  13. package/dist/index.js +867 -499
  14. package/dist/index.js.map +1 -1
  15. package/dist/player.js +2 -2
  16. package/dist/styles.css +1 -1
  17. package/package.json +9 -8
  18. package/src/App.tsx +11 -11
  19. package/src/components/editor/DomEditOverlay.tsx +9 -5
  20. package/src/components/editor/LayersPanel.test.ts +25 -0
  21. package/src/components/editor/LayersPanel.tsx +1 -1
  22. package/src/components/editor/PropertyPanel.test.tsx +600 -748
  23. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +2 -0
  24. package/src/components/editor/domEditOverlayGeometry.ts +7 -7
  25. package/src/components/editor/domEditOverlayGestures.ts +18 -0
  26. package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +1 -0
  27. package/src/components/editor/domEditOverlayStartGesture.test.ts +3 -0
  28. package/src/components/editor/domEditOverlayStartGesture.ts +9 -1
  29. package/src/components/editor/dragUndoPaint.test.ts +2 -0
  30. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +432 -15
  31. package/src/components/editor/groupDropKeepsSelection.test.ts +1 -0
  32. package/src/components/editor/manualEditsDom.ts +7 -7
  33. package/src/components/editor/manualEditsTypes.ts +5 -0
  34. package/src/components/editor/manualOffsetDrag.test.ts +23 -0
  35. package/src/components/editor/manualOffsetDrag.ts +5 -0
  36. package/src/components/editor/previewGestureStarts.ts +284 -0
  37. package/src/components/editor/rotateInlineSave.test.ts +1 -0
  38. package/src/components/editor/shiftDragAxisLock.test.tsx +1 -0
  39. package/src/components/editor/useDomEditOverlayGestures.ts +67 -71
  40. package/src/components/nle/NLEContext.test.ts +26 -0
  41. package/src/components/nle/NLEContext.tsx +8 -3
  42. package/src/hooks/draggedGsapPosition.ts +8 -0
  43. package/src/hooks/editMoment.ts +13 -0
  44. package/src/hooks/gsapDragCommit.ts +2 -1
  45. package/src/hooks/gsapDragPositionCommit.ts +19 -7
  46. package/src/hooks/gsapPositionDetection.ts +5 -3
  47. package/src/hooks/gsapResizeIntercept.ts +11 -4
  48. package/src/hooks/gsapRuntimeBridge.ts +31 -11
  49. package/src/hooks/gsapValueAtPlayhead.test.ts +15 -0
  50. package/src/hooks/gsapValueAtPlayhead.ts +12 -5
  51. package/src/hooks/timelineEditingHelpers.test.ts +22 -0
  52. package/src/hooks/timelineEditingHelpers.ts +18 -2
  53. package/src/hooks/useConsoleErrorCapture.test.tsx +87 -0
  54. package/src/hooks/useConsoleErrorCapture.ts +18 -22
  55. package/src/hooks/useDomSelection.test.ts +21 -2
  56. package/src/hooks/useEditHistoryActions.test.tsx +20 -0
  57. package/src/hooks/useEditHistoryActions.ts +2 -0
  58. package/src/hooks/useGsapAwareEditing.test.tsx +38 -0
  59. package/src/hooks/useGsapAwareEditing.ts +148 -136
  60. package/src/hooks/useGsapAwareGroupMove.ts +126 -113
  61. package/src/hooks/useGsapScriptCommits.ts +5 -4
  62. package/src/hooks/usePreviewPersistence.history.test.tsx +7 -2
  63. package/src/hooks/usePreviewPersistence.ts +1 -4
  64. package/src/hooks/useTimelineDeleteOps.test.tsx +10 -0
  65. package/src/hooks/useTimelineDeleteOps.ts +9 -10
  66. package/src/index.ts +1 -0
  67. package/src/player/hooks/useShadowPreviewReload.ts +6 -6
  68. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +14 -0
  69. package/src/player/hooks/useTimelinePlayer.ts +8 -5
  70. package/src/player/lib/thumbnailScheduler.test.ts +20 -1
  71. package/src/player/lib/thumbnailScheduler.ts +1 -1
  72. package/src/player/lib/timelineElementHelpers.ts +0 -11
  73. package/src/player/previewReloading.test.ts +48 -0
  74. package/src/player/previewReloading.ts +48 -0
  75. package/src/styles/studio.css +9 -0
  76. package/src/utils/gsapUndoRestore.browser.test.ts +21 -12
  77. package/src/utils/rootDuration.ts +5 -4
  78. package/src/utils/studioPendingEdits.test.ts +27 -0
  79. package/src/utils/studioPendingEdits.ts +9 -0
  80. package/src/utils/timelineAssetDrop.test.ts +13 -4
  81. package/src/utils/timelineAssetDrop.ts +6 -2
  82. package/dist/chunk-TDWGXBUT.js.map +0 -1
  83. /package/dist/{chunk-4S3AYCOG.js.map → chunk-E422JDF5.js.map} +0 -0
  84. /package/dist/{chunk-7UQWVN52.js.map → chunk-UYWON5AX.js.map} +0 -0
  85. /package/dist/{domEditingLayers-IEVLO54Z.js.map → domEditingLayers-KBTPQKGA.js.map} +0 -0
@@ -8,8 +8,11 @@ import { observeGsapGesture } from "./gsapGestureOutcome";
8
8
  import { editsPlainCss } from "./gsapRuntimeKeyframes";
9
9
  import { refuseGsapTakeover } from "./elementOffsetStager";
10
10
  import { tryGsapDragIntercept } from "./gsapRuntimeBridge";
11
+ import { freezeDragStamp } from "./draggedGsapPosition";
11
12
  import { assertGsapEditPersisted } from "./gsapEditOutcome";
12
13
  import { firstPreflightFailure } from "./gsapGroupPreflight";
14
+ import { whileScriptWrites } from "../player/previewReloading";
15
+ import type { GeometryCommitResult } from "../utils/previewFeatureUsage";
13
16
 
14
17
  // A distinct key keeps consecutive group drags in separate undo entries.
15
18
  let groupDragCommitCounter = 0;
@@ -36,131 +39,141 @@ export function useGsapAwareGroupMove({
36
39
  async (
37
40
  updates: DomEditGroupPathOffsetCommit[],
38
41
  options: { refusalToast?: boolean } = {},
39
- ): Promise<import("../utils/previewFeatureUsage").GeometryCommitResult> => {
42
+ ): Promise<GeometryCommitResult> => {
40
43
  const writes = observeGsapGesture(gsapCommitMutation);
41
44
  const writer = writes.commit;
42
45
  if (!writer) return { ok: true, changed: false };
43
- const toastRefusal = options.refusalToast !== false;
44
- // One coalesce key across slow writes keeps the group drag in one undo entry.
45
- const coalesceKey = `group-drag:${++groupDragCommitCounter}`;
46
- // Hold the render until every member is saved, or unwritten members snap back.
47
- let renderOnCommit = false;
48
- const previewFallbackLatch = { pending: false };
49
- const withGroupOptions = (options: CommitMutationOptions): CommitMutationOptions => ({
50
- ...options,
51
- coalesceKey,
52
- coalesceMs: Number.POSITIVE_INFINITY,
53
- deferPreviewSync: !renderOnCommit,
54
- previewFallbackLatch,
55
- });
56
- // Batch members in the same file to avoid repeating its read, parse, write and preview patch.
57
- const queued: CommitMutationCall[] = [];
58
- const flushQueued = async () => {
59
- if (queued.length === 0) return;
60
- const calls = queued.splice(0, queued.length);
61
- if (!writer.batch) {
62
- for (const call of calls) {
63
- await writer(call.selection, call.mutation, call.options);
64
- }
65
- return;
66
- }
67
- await writer.batch(calls, {
68
- ...(calls.at(-1)?.options ?? { label: "Move animated layer (group)" }),
69
- label: "Move animated layer (group)",
46
+ const scripted = updates.some(
47
+ ({ selection, plainTranslate }) =>
48
+ !(plainTranslate ?? editsPlainCss(selection.element, "move")),
49
+ );
50
+ const commitGroup = async (): Promise<GeometryCommitResult> => {
51
+ const stamps = new Map(
52
+ updates.map((u) => [u.selection, freezeDragStamp(u.selection.element)]),
53
+ );
54
+ const toastRefusal = options.refusalToast !== false;
55
+ // One coalesce key across slow writes keeps the group drag in one undo entry.
56
+ const coalesceKey = `group-drag:${++groupDragCommitCounter}`;
57
+ // Hold the render until every member is saved, or unwritten members snap back.
58
+ let renderOnCommit = false;
59
+ const previewFallbackLatch = { pending: false };
60
+ const withGroupOptions = (options: CommitMutationOptions): CommitMutationOptions => ({
61
+ ...options,
62
+ coalesceKey,
63
+ coalesceMs: Number.POSITIVE_INFINITY,
64
+ deferPreviewSync: !renderOnCommit,
65
+ previewFallbackLatch,
70
66
  });
71
- };
72
- const coalescedCommit: typeof gsapCommitMutation = (selection, mutation, options) => {
73
- queued.push({ selection, mutation, options: withGroupOptions(options) });
74
- return Promise.resolve();
75
- };
76
- const preflightAnimations = new Map<DomEditSelection, GsapAnimation[]>();
77
- // Members saved on themselves, each with its route: true for its CSS translate.
78
- const offsetMembers = new Map<DomEditSelection, boolean>();
79
- // Editability is user-atomic: prove every member can be written before the first source
80
- // mutation, so a blocked member never leaves earlier siblings partially moved. Preflights
81
- // write nothing and share one in-flight parse per file, so they run together.
82
- const preflightResults = await Promise.allSettled(
83
- updates.map(async ({ selection, next, plainTranslate }) => {
84
- if (plainTranslate ?? editsPlainCss(selection.element, "move")) {
85
- refuseGsapTakeover(selection.element, toastRefusal ? showToast : () => {});
86
- return void offsetMembers.set(selection, true);
67
+ // Batch members in the same file to avoid repeating its read, parse, write and preview patch.
68
+ const queued: CommitMutationCall[] = [];
69
+ const flushQueued = async () => {
70
+ if (queued.length === 0) return;
71
+ const calls = queued.splice(0, queued.length);
72
+ if (!writer.batch) {
73
+ for (const call of calls) {
74
+ await writer(call.selection, call.mutation, call.options);
75
+ }
76
+ return;
87
77
  }
88
- const animations = await makeFetchFallback(selection, { failOnFetchError: true })();
89
- preflightAnimations.set(selection, animations);
90
- const outcome = await tryGsapDragIntercept(
91
- selection,
92
- next,
93
- animations,
94
- previewIframeRef.current,
95
- coalescedCommit,
96
- undefined,
97
- { preflightOnly: true, group: true },
98
- );
99
- if (outcome.status === "element-offset") offsetMembers.set(selection, false);
100
- assertGsapEditPersisted(outcome);
101
- }),
102
- );
103
- const preflightFailure = firstPreflightFailure(
104
- preflightResults,
105
- updates,
106
- offsetMembers,
107
- activeCompPath,
108
- );
109
- if (preflightFailure) {
110
- trackGsapInteractionFailure(
111
- preflightFailure.error,
112
- preflightFailure.selection,
113
- "drag",
114
- "Move animated layer (group)",
115
- toastRefusal,
78
+ await writer.batch(calls, {
79
+ ...(calls.at(-1)?.options ?? { label: "Move animated layer (group)" }),
80
+ label: "Move animated layer (group)",
81
+ });
82
+ };
83
+ const coalescedCommit: typeof gsapCommitMutation = (selection, mutation, options) => {
84
+ queued.push({ selection, mutation, options: withGroupOptions(options) });
85
+ return Promise.resolve();
86
+ };
87
+ const preflightAnimations = new Map<DomEditSelection, GsapAnimation[]>();
88
+ // Members saved on themselves, each with its route: true for its CSS translate.
89
+ const offsetMembers = new Map<DomEditSelection, boolean>();
90
+ // Editability is user-atomic: prove every member can be written before the first source
91
+ // mutation, so a blocked member never leaves earlier siblings partially moved. Preflights
92
+ // write nothing and share one in-flight parse per file, so they run together.
93
+ const preflightResults = await Promise.allSettled(
94
+ updates.map(async ({ selection, next, plainTranslate }) => {
95
+ if (plainTranslate ?? editsPlainCss(selection.element, "move")) {
96
+ refuseGsapTakeover(selection.element, toastRefusal ? showToast : () => {});
97
+ return void offsetMembers.set(selection, true);
98
+ }
99
+ const animations = await makeFetchFallback(selection, { failOnFetchError: true })();
100
+ preflightAnimations.set(selection, animations);
101
+ const outcome = await tryGsapDragIntercept(
102
+ selection,
103
+ next,
104
+ animations,
105
+ previewIframeRef.current,
106
+ coalescedCommit,
107
+ undefined,
108
+ { preflightOnly: true, group: true },
109
+ );
110
+ if (outcome.status === "element-offset") offsetMembers.set(selection, false);
111
+ assertGsapEditPersisted(outcome);
112
+ }),
116
113
  );
117
- throw preflightFailure.error;
118
- }
119
- const lastScriptWrite = updates.findLastIndex(
120
- ({ selection }) => !offsetMembers.has(selection),
121
- );
122
- for (const [index, { selection, next }] of updates.entries()) {
123
- renderOnCommit = index === lastScriptWrite;
124
- const plain = offsetMembers.get(selection);
125
- if (plain !== undefined) {
126
- const staged = writes.drawKeepingUndone(() =>
127
- stageElementPositionOffset(selection, next, plain, coalesceKey),
114
+ const preflightFailure = firstPreflightFailure(
115
+ preflightResults,
116
+ updates,
117
+ offsetMembers,
118
+ activeCompPath,
119
+ );
120
+ if (preflightFailure) {
121
+ trackGsapInteractionFailure(
122
+ preflightFailure.error,
123
+ preflightFailure.selection,
124
+ "drag",
125
+ "Move animated layer (group)",
126
+ toastRefusal,
128
127
  );
129
- const result = await staged.save();
130
- writes.recordDomResult(result);
131
- continue;
128
+ throw preflightFailure.error;
129
+ }
130
+ const lastScriptWrite = updates.findLastIndex(
131
+ ({ selection }) => !offsetMembers.has(selection),
132
+ );
133
+ for (const [index, { selection, next }] of updates.entries()) {
134
+ renderOnCommit = index === lastScriptWrite;
135
+ const plain = offsetMembers.get(selection);
136
+ if (plain !== undefined) {
137
+ const staged = writes.drawKeepingUndone(() =>
138
+ stageElementPositionOffset(selection, next, plain, coalesceKey),
139
+ );
140
+ const result = await staged.save();
141
+ writes.recordDomResult(result);
142
+ continue;
143
+ }
144
+ try {
145
+ const outcome = await tryGsapDragIntercept(
146
+ selection,
147
+ next,
148
+ preflightAnimations.get(selection) ?? [],
149
+ previewIframeRef.current,
150
+ coalescedCommit,
151
+ // The intercept re-reads the file to resolve a stale or shared tween.
152
+ // Anything already queued has to be on disk before that read, or it
153
+ // resolves against a file missing writes it is about to build on.
154
+ async () => {
155
+ await flushQueued();
156
+ return makeFetchFallback(selection, { fresh: true })();
157
+ },
158
+ { preflightPassed: true, stamp: stamps.get(selection) },
159
+ );
160
+ assertGsapEditPersisted(outcome);
161
+ } catch (error) {
162
+ trackGsapInteractionFailure(error, selection, "drag", "Move animated layer (group)");
163
+ throw error;
164
+ }
132
165
  }
133
166
  try {
134
- const outcome = await tryGsapDragIntercept(
135
- selection,
136
- next,
137
- preflightAnimations.get(selection) ?? [],
138
- previewIframeRef.current,
139
- coalescedCommit,
140
- // The intercept re-reads the file to resolve a stale or shared tween.
141
- // Anything already queued has to be on disk before that read, or it
142
- // resolves against a file missing writes it is about to build on.
143
- async () => {
144
- await flushQueued();
145
- return makeFetchFallback(selection, { fresh: true })();
146
- },
147
- { preflightPassed: true },
148
- );
149
- assertGsapEditPersisted(outcome);
167
+ await flushQueued();
168
+ return writes.finish();
150
169
  } catch (error) {
151
- trackGsapInteractionFailure(error, selection, "drag", "Move animated layer (group)");
170
+ // The aggregate write has no uniquely failing member; do not misattribute
171
+ // its telemetry to whichever member happened to be last in the array.
172
+ trackGsapInteractionFailure(error, null, "drag", "Move animated layer (group)");
152
173
  throw error;
153
174
  }
154
- }
155
- try {
156
- await flushQueued();
157
- return writes.finish();
158
- } catch (error) {
159
- // The aggregate write has no uniquely failing member; do not misattribute
160
- // its telemetry to whichever member happened to be last in the array.
161
- trackGsapInteractionFailure(error, null, "drag", "Move animated layer (group)");
162
- throw error;
163
- }
175
+ };
176
+ return scripted ? whileScriptWrites(commitGroup) : commitGroup();
164
177
  },
165
178
  [
166
179
  gsapCommitMutation,
@@ -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
+ });