@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.
- package/dist/assets/{hyperframes-player-D_Mh6hbx.js → hyperframes-player-CckOoCJ8.js} +1 -1
- package/dist/assets/{index-CHEctGzc.js → index-BmIPrb8I.js} +1 -1
- package/dist/assets/{index-B3lJsA-e.js → index-Br1Whc0f.js} +189 -189
- package/dist/assets/{index-CC_E-Cbl.css → index-CVd4O6eH.css} +1 -1
- package/dist/assets/{index-rtccfg9b.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 +2 -2
- package/dist/index.js +867 -499
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +9 -8
- package/src/App.tsx +11 -11
- package/src/components/editor/DomEditOverlay.tsx +9 -5
- package/src/components/editor/LayersPanel.test.ts +25 -0
- package/src/components/editor/LayersPanel.tsx +1 -1
- 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/useConsoleErrorCapture.test.tsx +87 -0
- package/src/hooks/useConsoleErrorCapture.ts +18 -22
- 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/styles/studio.css +9 -0
- package/src/utils/gsapUndoRestore.browser.test.ts +21 -12
- 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
|
@@ -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<
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
+
});
|