@hyperframes/studio 0.8.127 → 0.8.128
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-uqzDaxVL.js → hyperframes-player-CKCx0CFA.js} +1 -1
- package/dist/assets/{index-DXSYlqmV.js → index-CF31Pq1E.js} +1 -1
- package/dist/assets/{index-COhbL1xq.js → index-ClLzyuwb.js} +171 -171
- package/dist/assets/{index-CLBNYlDY.js → index-CwS08gWW.js} +1 -1
- package/dist/{chunk-3F47EFCT.js → chunk-7UQWVN52.js} +17 -10
- package/dist/chunk-7UQWVN52.js.map +1 -0
- package/dist/{chunk-246VB2G3.js → chunk-DRVWEPO5.js} +35 -7
- package/dist/chunk-DRVWEPO5.js.map +1 -0
- package/dist/{chunk-5EKWJZLM.js → chunk-TDWGXBUT.js} +1 -1
- package/dist/chunk-TDWGXBUT.js.map +1 -0
- package/dist/{domEditingLayers-OL7GLSS4.js → domEditingLayers-IEVLO54Z.js} +3 -3
- package/dist/index.d.ts +8 -9
- package/dist/index.html +1 -1
- package/dist/index.js +1193 -869
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/package.json +8 -8
- package/src/components/TimelineToolbar.tsx +6 -7
- package/src/components/editor/commitPreflightHandles.test.tsx +32 -0
- package/src/components/editor/domEditingLayers.test.ts +45 -18
- package/src/components/editor/domEditingLayers.ts +11 -9
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/keyframeRetime.ts +11 -4
- package/src/components/editor/probeSourceElement.ts +17 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +4 -14
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +0 -7
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +27 -42
- package/src/components/nle/useTimelineEditCallbacks.ts +3 -2
- package/src/hooks/gsapDragCommit.ts +8 -5
- package/src/hooks/gsapDragPositionCommit.ts +90 -17
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +50 -0
- package/src/hooks/gsapKeyframeCacheHelpers.ts +21 -2
- package/src/hooks/gsapMutationClient.ts +35 -7
- package/src/hooks/gsapOvershootEase.test.ts +67 -0
- package/src/hooks/gsapParsedTween.ts +5 -6
- package/src/hooks/gsapPositionDetection.ts +5 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
- package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
- package/src/hooks/gsapRuntimeKeyframes.ts +19 -59
- package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
- package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
- package/src/hooks/gsapShared.test.ts +48 -35
- package/src/hooks/gsapShared.ts +30 -32
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +93 -3
- package/src/hooks/gsapValueAtPlayhead.test.ts +13 -0
- package/src/hooks/gsapValueAtPlayhead.ts +3 -4
- package/src/hooks/timelineEditingHelpers.test.ts +21 -0
- package/src/hooks/timelineEditingHelpers.ts +40 -14
- package/src/hooks/timelineLinkEditPlan.ts +6 -4
- package/src/hooks/timelineMoveAdapter.ts +3 -5
- package/src/hooks/timelineTimingSync.test.ts +83 -125
- package/src/hooks/timelineTimingSync.ts +62 -42
- package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
- package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
- package/src/hooks/useCommitPreflightCapabilities.ts +42 -7
- package/src/hooks/useDomEditPreviewSync.ts +8 -2
- package/src/hooks/useDomSelection.test.ts +27 -0
- package/src/hooks/useDomSelection.ts +12 -13
- package/src/hooks/useDomSelectionTypes.ts +1 -0
- package/src/hooks/useEditHistoryActions.test.tsx +54 -3
- package/src/hooks/useEditHistoryActions.ts +9 -6
- package/src/hooks/useEnableKeyframes.test.ts +20 -15
- package/src/hooks/useEnableKeyframes.ts +54 -71
- package/src/hooks/useGestureCommit.ts +2 -1
- package/src/hooks/useGsapTweenCache.ts +2 -17
- package/src/hooks/usePersistentEditHistory.test.ts +45 -0
- package/src/hooks/usePersistentEditHistory.ts +13 -2
- package/src/hooks/usePreviewPersistence.history.test.tsx +83 -0
- package/src/hooks/usePreviewPersistence.ts +26 -4
- package/src/hooks/useTimelineClipTimingEditing.ts +343 -0
- package/src/hooks/useTimelineDeleteOps.test.tsx +20 -1
- package/src/hooks/useTimelineDeleteOps.ts +5 -13
- package/src/hooks/useTimelineEditing.test.tsx +63 -4
- package/src/hooks/useTimelineEditing.ts +21 -295
- package/src/hooks/useTimelineGroupEditing.test.tsx +91 -0
- package/src/hooks/useTimelineGroupEditing.ts +15 -38
- package/src/player/components/Timeline.test.ts +12 -4
- package/src/player/components/TimelineClip.tsx +3 -0
- package/src/player/components/TimelineCompactDiamonds.tsx +4 -0
- package/src/player/components/TimelineLanes.test.tsx +61 -5
- package/src/player/components/TimelineLanes.tsx +20 -28
- package/src/player/components/TimelinePropertyLanes.tsx +10 -1
- package/src/player/components/timelineCallbacks.ts +1 -4
- package/src/player/components/timelineClipDragCommit.test.ts +31 -0
- package/src/player/components/timelineClipDragCommit.ts +39 -28
- package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.test.ts +6 -34
- package/src/player/components/timelineClipGestureHandlers.ts +2 -20
- package/src/player/components/timelineEditing.ts +1 -3
- package/src/player/components/timelineGroupResizeCommit.ts +10 -9
- package/src/player/components/timelineLinkSelection.ts +1 -35
- package/src/player/components/timelineOptimisticRevision.ts +7 -4
- package/src/player/components/trackHeaderLaneState.ts +9 -2
- package/src/player/components/trackHeaderLaneValues.test.ts +89 -0
- package/src/player/components/trackHeaderLaneValues.ts +54 -13
- package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +4 -6
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +4 -7
- package/src/player/components/useTimelineClipDrag.ts +0 -3
- package/src/player/components/useTimelineKeyframeHandlers.ts +17 -1
- package/src/player/store/batchElementUpdates.test.ts +43 -0
- package/src/player/store/batchElementUpdates.ts +9 -0
- package/src/player/store/elementPatchQueue.ts +35 -0
- package/src/player/store/playerStore.ts +5 -6
- package/src/utils/domEditHelpers.ts +5 -0
- package/src/utils/globalTimeCompiler.test.ts +0 -4
- package/src/utils/globalTimeCompiler.ts +6 -4
- package/src/utils/gsapKeyframeEases.ts +79 -0
- package/src/utils/sameData.ts +9 -0
- package/src/utils/studioFileHistory.ts +1 -0
- package/src/utils/studioPendingEdits.ts +15 -1
- package/dist/chunk-246VB2G3.js.map +0 -1
- package/dist/chunk-3F47EFCT.js.map +0 -1
- package/dist/chunk-5EKWJZLM.js.map +0 -1
- /package/dist/{domEditingLayers-OL7GLSS4.js.map → domEditingLayers-IEVLO54Z.js.map} +0 -0
|
@@ -568,6 +568,19 @@ describe("planValueAtPlayhead", () => {
|
|
|
568
568
|
});
|
|
569
569
|
});
|
|
570
570
|
|
|
571
|
+
it("refuses an inner node of a motionPath tween, which GSAP runs power1.out", () => {
|
|
572
|
+
const path = kf([
|
|
573
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
574
|
+
{ percentage: 50, properties: { x: 100 } },
|
|
575
|
+
{ percentage: 100, properties: { x: 300 } },
|
|
576
|
+
]);
|
|
577
|
+
const anim = { ...path, keyframes: { ...path.keyframes!, fromMotionPath: true as const } };
|
|
578
|
+
expect(plan({ anim, at: { time: 2 }, values: { x: 5 } })).toEqual({
|
|
579
|
+
ok: false,
|
|
580
|
+
reason: "eased-keyframes",
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
|
|
571
584
|
it("changes an inner keyframe picked by its percentage, which the ease does not move", () => {
|
|
572
585
|
const anim = {
|
|
573
586
|
...kf([
|
|
@@ -2,6 +2,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
|
2
2
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
3
3
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
4
4
|
import { resolveTweenDuration, resolveTweenStart } from "../utils/globalTimeCompiler";
|
|
5
|
+
import { ARRAY_STEP_EASE, PERCENTAGE_SEGMENT_EASE, runEaseOf } from "../utils/gsapKeyframeEases";
|
|
5
6
|
import { roundTo3 } from "../utils/rounding";
|
|
6
7
|
import {
|
|
7
8
|
materializeIfDynamic,
|
|
@@ -66,10 +67,8 @@ export type PlayheadEditPlan =
|
|
|
66
67
|
}
|
|
67
68
|
| { ok: false; reason: PlayheadEditRefusal };
|
|
68
69
|
|
|
69
|
-
// GSAP 3.14 defaults: a percentage keyframe eases its segment power1.inOut; an array step is linear.
|
|
70
|
-
const PERCENTAGE_SEGMENT_EASE = "power1.inOut";
|
|
71
|
-
const ARRAY_STEP_EASE = "none";
|
|
72
70
|
const roundPct = (pct: number) => Math.round(pct * 1000) / 1000;
|
|
71
|
+
|
|
73
72
|
const isLinear = (ease: string | undefined) => !ease || ease === "none" || ease === "linear";
|
|
74
73
|
|
|
75
74
|
interface Normalized {
|
|
@@ -92,7 +91,7 @@ function normalize(edit: PlayheadEdit): Normalized | { reason: PlayheadEditRefus
|
|
|
92
91
|
const blocked = stepListBlock(anim);
|
|
93
92
|
if (blocked) return { reason: blocked };
|
|
94
93
|
return {
|
|
95
|
-
runEase:
|
|
94
|
+
runEase: runEaseOf(anim),
|
|
96
95
|
keyframes: data.keyframes.map((kf) => ({
|
|
97
96
|
...kf,
|
|
98
97
|
properties: { ...kf.properties },
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
import {
|
|
4
|
+
applyTimelineMoveAttributes,
|
|
4
5
|
applyTimelineStackingReorder,
|
|
5
6
|
buildTimelineMoveTimingPatch,
|
|
6
7
|
buildTimelineResizeTimingPatch,
|
|
@@ -316,6 +317,26 @@ describe("persistTimelineBatchEdit", () => {
|
|
|
316
317
|
vi.unstubAllGlobals();
|
|
317
318
|
});
|
|
318
319
|
|
|
320
|
+
it("syncs each file's root duration once, landing where per-member sync would", async () => {
|
|
321
|
+
const source = `<div id="root" data-composition-id="main" data-duration="4"><video id="a" class="clip" data-start="1" data-duration="1"></video><video id="b" class="clip" data-start="2" data-duration="1"></video><video id="c" class="clip" data-start="3" data-duration="1"></video></div>`;
|
|
322
|
+
const members = ["a", "b", "c"].map((id, i) => ({
|
|
323
|
+
element: el({ id, tag: "video", domId: id, start: i + 1, duration: 1 }),
|
|
324
|
+
buildPatches: (original: string, target: Parameters<typeof applyTimelineMoveAttributes>[1]) =>
|
|
325
|
+
applyTimelineMoveAttributes(original, target, i + 6, 1),
|
|
326
|
+
}));
|
|
327
|
+
stubReadFileContent(source);
|
|
328
|
+
const writes: Array<[string, string]> = [];
|
|
329
|
+
await persistTimelineBatchEdit(batchInput(members, writes));
|
|
330
|
+
|
|
331
|
+
const perMember = members.reduce(
|
|
332
|
+
(current, { element }, i) =>
|
|
333
|
+
buildTimelineMoveTimingPatch(current, { id: element.id }, i + 6, 1),
|
|
334
|
+
source,
|
|
335
|
+
);
|
|
336
|
+
expect(writes).toEqual([["index.html", perMember]]);
|
|
337
|
+
expect(perMember).toContain('data-duration="9"');
|
|
338
|
+
});
|
|
339
|
+
|
|
319
340
|
it("skips no-op members instead of aborting the batch (track-insert renumber)", async () => {
|
|
320
341
|
// A track-insert renumber can include a member whose attributes already
|
|
321
342
|
// hold the target values — its patch is string-identical. The batch must
|
|
@@ -225,7 +225,7 @@ function resolveResizePlaybackStart(
|
|
|
225
225
|
};
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
export function
|
|
228
|
+
export function applyTimelineMoveAttributes(
|
|
229
229
|
original: string,
|
|
230
230
|
target: PatchTarget,
|
|
231
231
|
start: number,
|
|
@@ -235,7 +235,7 @@ export function buildTimelineMoveTimingPatch(
|
|
|
235
235
|
): string {
|
|
236
236
|
if (!Number.isFinite(start) || !Number.isFinite(duration)) {
|
|
237
237
|
console.warn(
|
|
238
|
-
`[Timeline]
|
|
238
|
+
`[Timeline] applyTimelineMoveAttributes: non-finite timing (start=${start}, duration=${duration}) — patch skipped`,
|
|
239
239
|
);
|
|
240
240
|
return original;
|
|
241
241
|
}
|
|
@@ -257,15 +257,31 @@ export function buildTimelineMoveTimingPatch(
|
|
|
257
257
|
property: "data-audio-group",
|
|
258
258
|
value: null,
|
|
259
259
|
});
|
|
260
|
-
|
|
261
|
-
// from the PATCHED SOURCE (raw data-duration), so it grows if a clip moved
|
|
262
|
-
// past the end and shrinks if the furthest clip moved left. Measured from the
|
|
263
|
-
// source, NOT the store — store durations are runtime-truncated to the current
|
|
264
|
-
// comp length, which would ratchet the duration down every move.
|
|
265
|
-
return setCompositionDurationToContent(patched, furthestClipEndFromSource(patched));
|
|
260
|
+
return patched;
|
|
266
261
|
}
|
|
267
262
|
|
|
268
|
-
|
|
263
|
+
// Root data-duration follows the furthest clip end in the source; the store's durations are
|
|
264
|
+
// truncated to the current length, so reading them would ratchet the duration down every edit.
|
|
265
|
+
export function syncCompositionDurationToContent(source: string): string {
|
|
266
|
+
return setCompositionDurationToContent(source, furthestClipEndFromSource(source));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function buildTimelineMoveTimingPatch(
|
|
270
|
+
original: string,
|
|
271
|
+
target: PatchTarget,
|
|
272
|
+
start: number,
|
|
273
|
+
duration: number,
|
|
274
|
+
track?: number,
|
|
275
|
+
audioGroup?: null,
|
|
276
|
+
): string {
|
|
277
|
+
const patched = applyTimelineMoveAttributes(original, target, start, duration, track, audioGroup);
|
|
278
|
+
// A non-finite timing skips the patch above; skip the duration sync with it.
|
|
279
|
+
return !(Number.isFinite(start) && Number.isFinite(duration))
|
|
280
|
+
? patched
|
|
281
|
+
: syncCompositionDurationToContent(patched);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function applyTimelineResizeAttributes(
|
|
269
285
|
original: string,
|
|
270
286
|
target: PatchTarget,
|
|
271
287
|
element: TimelineElement,
|
|
@@ -289,10 +305,13 @@ export function buildTimelineResizeTimingPatch(
|
|
|
289
305
|
value: formatTimelineMediaOffset(pbs.value),
|
|
290
306
|
});
|
|
291
307
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
308
|
+
return patched;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function buildTimelineResizeTimingPatch(
|
|
312
|
+
...args: Parameters<typeof applyTimelineResizeAttributes>
|
|
313
|
+
): string {
|
|
314
|
+
return syncCompositionDurationToContent(applyTimelineResizeAttributes(...args));
|
|
296
315
|
}
|
|
297
316
|
|
|
298
317
|
export interface PersistTimelineEditInput {
|
|
@@ -305,6 +324,7 @@ export interface PersistTimelineEditInput {
|
|
|
305
324
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
306
325
|
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
|
|
307
326
|
coalesceKey?: string;
|
|
327
|
+
coalesceMs?: number;
|
|
308
328
|
}
|
|
309
329
|
|
|
310
330
|
export async function persistTimelineEdit(input: PersistTimelineEditInput): Promise<void> {
|
|
@@ -319,6 +339,7 @@ export async function persistTimelineEdit(input: PersistTimelineEditInput): Prom
|
|
|
319
339
|
projectId: input.projectId,
|
|
320
340
|
label: input.label,
|
|
321
341
|
coalesceKey: input.coalesceKey,
|
|
342
|
+
coalesceMs: input.coalesceMs,
|
|
322
343
|
files: {
|
|
323
344
|
[targetPath]: (current) => {
|
|
324
345
|
const patched = input.buildPatches(current, patchTarget);
|
|
@@ -391,7 +412,12 @@ export async function persistTimelineBatchEdit(
|
|
|
391
412
|
changesByPath.set(targetPath, [...(changesByPath.get(targetPath) ?? []), change]);
|
|
392
413
|
}
|
|
393
414
|
const buildFile = (targetPath: string) => (original: string) => {
|
|
394
|
-
const
|
|
415
|
+
const patched = patchTimelineChangesInSource(
|
|
416
|
+
original,
|
|
417
|
+
targetPath,
|
|
418
|
+
changesByPath.get(targetPath)!,
|
|
419
|
+
);
|
|
420
|
+
const next = syncCompositionDurationToContent(patched);
|
|
395
421
|
if (next !== original) input.pendingTimelineEditPathRef.current.add(targetPath);
|
|
396
422
|
return next;
|
|
397
423
|
};
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { TimelineElement } from "../player";
|
|
2
2
|
import type { TimelineLinkEdit } from "../player/components/timelineCallbacks";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
buildPatchTarget,
|
|
5
|
+
syncCompositionDurationToContent,
|
|
6
|
+
type PatchTarget,
|
|
7
|
+
} from "./timelineEditingHelpers";
|
|
4
8
|
import { sharesLinkGroup } from "../player/components/audioClipLink";
|
|
5
9
|
import { toAuthoredStart } from "../player/store/timelineElement";
|
|
6
10
|
import { formatTimelineMediaOffset } from "../player/components/timelineEditing";
|
|
7
11
|
import { applyPatchByTarget } from "../utils/sourcePatcher";
|
|
8
|
-
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
|
|
9
|
-
import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
|
|
10
12
|
import {
|
|
11
13
|
detachAudioInSource,
|
|
12
14
|
linkInSource,
|
|
@@ -64,7 +66,7 @@ function planSyncEdit(edit: SyncEdit): LinkEditPlan | null {
|
|
|
64
66
|
property: "start",
|
|
65
67
|
value,
|
|
66
68
|
});
|
|
67
|
-
return
|
|
69
|
+
return syncCompositionDurationToContent(moved);
|
|
68
70
|
},
|
|
69
71
|
};
|
|
70
72
|
}
|
|
@@ -27,7 +27,6 @@ export type TimelineMoveEditsHandler = (
|
|
|
27
27
|
edits: TimelineAtomicMoveEdit[],
|
|
28
28
|
coalesceKey?: string,
|
|
29
29
|
operation?: TimelineMoveOperation,
|
|
30
|
-
coalesceMs?: number,
|
|
31
30
|
) => Promise<void>;
|
|
32
31
|
|
|
33
32
|
export function persistTimelineMoveEditsAtomically(
|
|
@@ -35,7 +34,6 @@ export function persistTimelineMoveEditsAtomically(
|
|
|
35
34
|
coalesceKey: string | undefined,
|
|
36
35
|
operation: TimelineMoveOperation,
|
|
37
36
|
deps: AtomicMoveDeps,
|
|
38
|
-
coalesceMs?: number,
|
|
39
37
|
): Promise<void> {
|
|
40
38
|
return deps.handleTimelineGroupMove(
|
|
41
39
|
edits.map(({ element, updates }) => ({
|
|
@@ -48,7 +46,7 @@ export function persistTimelineMoveEditsAtomically(
|
|
|
48
46
|
// ("timing") omit it so they stay eligible for the SDK fast path.
|
|
49
47
|
track: operation === "timing" ? undefined : updates.track,
|
|
50
48
|
})),
|
|
51
|
-
{ coalesceKey
|
|
49
|
+
{ coalesceKey },
|
|
52
50
|
);
|
|
53
51
|
}
|
|
54
52
|
|
|
@@ -56,9 +54,9 @@ export function useTimelineMoveEditsHandler(
|
|
|
56
54
|
handleTimelineGroupMove: AtomicMoveDeps["handleTimelineGroupMove"],
|
|
57
55
|
): TimelineMoveEditsHandler {
|
|
58
56
|
return useCallback(
|
|
59
|
-
async (edits, coalesceKey, operation: TimelineMoveOperation = "timing"
|
|
57
|
+
async (edits, coalesceKey, operation: TimelineMoveOperation = "timing") => {
|
|
60
58
|
const deps = { handleTimelineGroupMove };
|
|
61
|
-
await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps
|
|
59
|
+
await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps);
|
|
62
60
|
},
|
|
63
61
|
[handleTimelineGroupMove],
|
|
64
62
|
);
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
captureDurationRollback,
|
|
8
8
|
finishClipTimingFallback,
|
|
9
9
|
finishGroupTimingGsapFallback,
|
|
10
|
+
finishTimelineTimingFallback,
|
|
11
|
+
foldGsapMutationIntoHistory,
|
|
10
12
|
readFileContent,
|
|
11
13
|
shiftGsapPositions,
|
|
12
14
|
} from "./timelineTimingSync";
|
|
@@ -509,7 +511,7 @@ describe("nothing-to-rewrite timing edits rebind in place (no script re-executio
|
|
|
509
511
|
changes: [{ element }, { element }],
|
|
510
512
|
resolveChangePath: () => "index.html",
|
|
511
513
|
// No domId → nothing to rewrite for ANY change (the gap-close blink path).
|
|
512
|
-
|
|
514
|
+
mutationFor: () => null,
|
|
513
515
|
});
|
|
514
516
|
|
|
515
517
|
expect(reloadPreview).not.toHaveBeenCalled();
|
|
@@ -539,7 +541,7 @@ describe("nothing-to-rewrite timing edits rebind in place (no script re-executio
|
|
|
539
541
|
{ element: { sourceFile: "scenes/intro.html" } as TimelineElement },
|
|
540
542
|
],
|
|
541
543
|
resolveChangePath: (el) => el.sourceFile ?? "index.html",
|
|
542
|
-
|
|
544
|
+
mutationFor: () => null,
|
|
543
545
|
});
|
|
544
546
|
|
|
545
547
|
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
@@ -658,32 +660,53 @@ function ownedMutation(contents: Map<string, string>, path: string, before: stri
|
|
|
658
660
|
return { mutated: true, scriptText: null, before, after };
|
|
659
661
|
}
|
|
660
662
|
|
|
661
|
-
|
|
662
|
-
const element = { sourceFile: "index.html" } as TimelineElement;
|
|
663
|
+
type OwnedStep = () => Promise<ReturnType<typeof ownedMutation>>;
|
|
663
664
|
|
|
665
|
+
/** Runs `steps` as one owned same-file transaction, wired the way a group edit wires it. */
|
|
666
|
+
function runOwnedChain(
|
|
667
|
+
steps: readonly OwnedStep[],
|
|
668
|
+
options: {
|
|
669
|
+
reloadPreview?: () => void;
|
|
670
|
+
recordEdit?: (edit: never) => Promise<void>;
|
|
671
|
+
} = {},
|
|
672
|
+
) {
|
|
673
|
+
const onGsapError = (err: unknown) =>
|
|
674
|
+
console.error("[Timeline] Failed to shift GSAP positions", err);
|
|
675
|
+
return finishTimelineTimingFallback({
|
|
676
|
+
iframe: buildLivePreviewIframe().iframe,
|
|
677
|
+
projectId: "p1",
|
|
678
|
+
reloadPreview: options.reloadPreview ?? vi.fn(),
|
|
679
|
+
gsapMutation: () =>
|
|
680
|
+
foldGsapMutationIntoHistory({
|
|
681
|
+
writeFile: writeProjectFile,
|
|
682
|
+
paths: ["index.html"],
|
|
683
|
+
projectId: "p1",
|
|
684
|
+
label: "Move timeline clips",
|
|
685
|
+
recordEdit: (options.recordEdit ?? vi.fn(async () => {})) as never,
|
|
686
|
+
gsapMutation: async (runOwnedMutation) => {
|
|
687
|
+
let status = { mutated: false, scriptText: null } as Awaited<ReturnType<OwnedStep>>;
|
|
688
|
+
for (const step of steps) status = await runOwnedMutation("index.html", step);
|
|
689
|
+
return status;
|
|
690
|
+
},
|
|
691
|
+
onRollbackError: onGsapError,
|
|
692
|
+
}),
|
|
693
|
+
onGsapError,
|
|
694
|
+
rebindWhenUnmutated: true,
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
|
|
664
699
|
it("reverse-rolls back every successful same-file step after a late failure", async () => {
|
|
665
700
|
const contents = new Map<string, string>([["index.html", "ORIGINAL"]]);
|
|
666
701
|
const server = installOwnedFileServer(contents);
|
|
667
|
-
let clipIndex = 0;
|
|
668
702
|
|
|
669
|
-
await
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
reloadPreview: vi.fn(),
|
|
674
|
-
label: "Move timeline clips",
|
|
675
|
-
errorLabel: "Failed to shift GSAP positions",
|
|
676
|
-
recordEdit: vi.fn(async () => {}) as never,
|
|
677
|
-
activeCompPath: "index.html",
|
|
678
|
-
changes: [{ element }, { element }, { element }],
|
|
679
|
-
resolveChangePath: () => "index.html",
|
|
680
|
-
mutateChange: async () => {
|
|
681
|
-
clipIndex += 1;
|
|
682
|
-
if (clipIndex === 1) return ownedMutation(contents, "index.html", "ORIGINAL", "STEP-1");
|
|
683
|
-
if (clipIndex === 2) return ownedMutation(contents, "index.html", "STEP-1", "STEP-2");
|
|
703
|
+
await runOwnedChain([
|
|
704
|
+
async () => ownedMutation(contents, "index.html", "ORIGINAL", "STEP-1"),
|
|
705
|
+
async () => ownedMutation(contents, "index.html", "STEP-1", "STEP-2"),
|
|
706
|
+
async () => {
|
|
684
707
|
throw new Error("late failure");
|
|
685
708
|
},
|
|
686
|
-
|
|
709
|
+
]);
|
|
687
710
|
|
|
688
711
|
expect(server.rollbacks).toEqual([
|
|
689
712
|
{ path: "index.html", expected: "STEP-2", restore: "STEP-1", conflict: false },
|
|
@@ -695,27 +718,13 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
|
|
|
695
718
|
it("uses the endpoint's atomic before when a foreign write precedes mutation", async () => {
|
|
696
719
|
const contents = new Map<string, string>([["index.html", "FOREIGN"]]);
|
|
697
720
|
const server = installOwnedFileServer(contents);
|
|
698
|
-
let clipIndex = 0;
|
|
699
721
|
|
|
700
|
-
await
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
iframe: buildLivePreviewIframe().iframe,
|
|
704
|
-
reloadPreview: vi.fn(),
|
|
705
|
-
label: "Move timeline clips",
|
|
706
|
-
errorLabel: "Failed to shift GSAP positions",
|
|
707
|
-
recordEdit: vi.fn(async () => {}) as never,
|
|
708
|
-
activeCompPath: "index.html",
|
|
709
|
-
changes: [{ element }, { element }],
|
|
710
|
-
resolveChangePath: () => "index.html",
|
|
711
|
-
mutateChange: async () => {
|
|
712
|
-
clipIndex += 1;
|
|
713
|
-
if (clipIndex === 1) {
|
|
714
|
-
return ownedMutation(contents, "index.html", "FOREIGN", "FOREIGN+OWNED");
|
|
715
|
-
}
|
|
722
|
+
await runOwnedChain([
|
|
723
|
+
async () => ownedMutation(contents, "index.html", "FOREIGN", "FOREIGN+OWNED"),
|
|
724
|
+
async () => {
|
|
716
725
|
throw new Error("late failure");
|
|
717
726
|
},
|
|
718
|
-
|
|
727
|
+
]);
|
|
719
728
|
|
|
720
729
|
expect(server.readCount()).toBe(0);
|
|
721
730
|
expect(server.rollbacks[0]).toEqual({
|
|
@@ -732,26 +741,17 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
|
|
|
732
741
|
const server = installOwnedFileServer(contents, {
|
|
733
742
|
beforeRollback: () => contents.set("index.html", "SUCCESSOR"),
|
|
734
743
|
});
|
|
735
|
-
let clipIndex = 0;
|
|
736
|
-
|
|
737
744
|
const reloadPreview = vi.fn();
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
resolveChangePath: () => "index.html",
|
|
749
|
-
mutateChange: async () => {
|
|
750
|
-
clipIndex += 1;
|
|
751
|
-
if (clipIndex === 1) return ownedMutation(contents, "index.html", "ORIGINAL", "OWNED");
|
|
752
|
-
throw new Error("late failure");
|
|
753
|
-
},
|
|
754
|
-
});
|
|
745
|
+
|
|
746
|
+
await runOwnedChain(
|
|
747
|
+
[
|
|
748
|
+
async () => ownedMutation(contents, "index.html", "ORIGINAL", "OWNED"),
|
|
749
|
+
async () => {
|
|
750
|
+
throw new Error("late failure");
|
|
751
|
+
},
|
|
752
|
+
],
|
|
753
|
+
{ reloadPreview },
|
|
754
|
+
);
|
|
755
755
|
|
|
756
756
|
expect(server.rollbacks[0]?.conflict).toBe(true);
|
|
757
757
|
expect(contents.get("index.html")).toBe("SUCCESSOR");
|
|
@@ -763,25 +763,16 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
|
|
|
763
763
|
installOwnedFileServer(contents, { failRollback: true });
|
|
764
764
|
const reloadPreview = vi.fn();
|
|
765
765
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
766
|
-
let clipIndex = 0;
|
|
767
766
|
|
|
768
|
-
await
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
changes: [{ element }, { element }],
|
|
778
|
-
resolveChangePath: () => "index.html",
|
|
779
|
-
mutateChange: async () => {
|
|
780
|
-
clipIndex += 1;
|
|
781
|
-
if (clipIndex === 1) return ownedMutation(contents, "index.html", "ORIGINAL", "OWNED");
|
|
782
|
-
throw new Error("late failure");
|
|
783
|
-
},
|
|
784
|
-
});
|
|
767
|
+
await runOwnedChain(
|
|
768
|
+
[
|
|
769
|
+
async () => ownedMutation(contents, "index.html", "ORIGINAL", "OWNED"),
|
|
770
|
+
async () => {
|
|
771
|
+
throw new Error("late failure");
|
|
772
|
+
},
|
|
773
|
+
],
|
|
774
|
+
{ reloadPreview },
|
|
775
|
+
);
|
|
785
776
|
|
|
786
777
|
expect(contents.get("index.html")).toBe("OWNED");
|
|
787
778
|
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
@@ -796,23 +787,13 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
|
|
|
796
787
|
const server = installOwnedFileServer(contents);
|
|
797
788
|
const recordEdit = vi.fn(async () => {});
|
|
798
789
|
|
|
799
|
-
await
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
recordEdit: recordEdit as never,
|
|
807
|
-
activeCompPath: "index.html",
|
|
808
|
-
changes: [{ element }, { element }],
|
|
809
|
-
resolveChangePath: () => "index.html",
|
|
810
|
-
mutateChange: async (_change, _path) => {
|
|
811
|
-
const before = contents.get("index.html")!;
|
|
812
|
-
const after = before === "FOREIGN" ? "FOREIGN+ONE" : "FOREIGN+ONE+TWO";
|
|
813
|
-
return ownedMutation(contents, "index.html", before, after);
|
|
814
|
-
},
|
|
815
|
-
});
|
|
790
|
+
await runOwnedChain(
|
|
791
|
+
[
|
|
792
|
+
async () => ownedMutation(contents, "index.html", "FOREIGN", "FOREIGN+ONE"),
|
|
793
|
+
async () => ownedMutation(contents, "index.html", "FOREIGN+ONE", "FOREIGN+ONE+TWO"),
|
|
794
|
+
],
|
|
795
|
+
{ recordEdit },
|
|
796
|
+
);
|
|
816
797
|
|
|
817
798
|
expect(server.readCount()).toBe(1); // final ownership verification only
|
|
818
799
|
expect(recordEdit).toHaveBeenCalledWith(
|
|
@@ -828,26 +809,15 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
|
|
|
828
809
|
const contents = new Map<string, string>([["index.html", "ORIGINAL"]]);
|
|
829
810
|
const server = installOwnedFileServer(contents);
|
|
830
811
|
const recordEdit = vi.fn(async () => {});
|
|
831
|
-
let clipIndex = 0;
|
|
832
|
-
|
|
833
812
|
const reloadPreview = vi.fn();
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
activeCompPath: "index.html",
|
|
843
|
-
changes: [{ element }, { element }],
|
|
844
|
-
resolveChangePath: () => "index.html",
|
|
845
|
-
mutateChange: async () => {
|
|
846
|
-
clipIndex += 1;
|
|
847
|
-
if (clipIndex === 1) return ownedMutation(contents, "index.html", "ORIGINAL", "STEP-1");
|
|
848
|
-
return ownedMutation(contents, "index.html", "FOREIGN", "FOREIGN+STEP-2");
|
|
849
|
-
},
|
|
850
|
-
});
|
|
813
|
+
|
|
814
|
+
await runOwnedChain(
|
|
815
|
+
[
|
|
816
|
+
async () => ownedMutation(contents, "index.html", "ORIGINAL", "STEP-1"),
|
|
817
|
+
async () => ownedMutation(contents, "index.html", "FOREIGN", "FOREIGN+STEP-2"),
|
|
818
|
+
],
|
|
819
|
+
{ recordEdit, reloadPreview },
|
|
820
|
+
);
|
|
851
821
|
|
|
852
822
|
expect(recordEdit).not.toHaveBeenCalled();
|
|
853
823
|
expect(server.rollbacks).toEqual([
|
|
@@ -867,19 +837,7 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
|
|
|
867
837
|
const contents = new Map<string, string>([["index.html", "ORIGINAL"]]);
|
|
868
838
|
const server = installOwnedFileServer(contents, { failReadAt: 1 });
|
|
869
839
|
|
|
870
|
-
await
|
|
871
|
-
writeProjectFile,
|
|
872
|
-
projectId: "p1",
|
|
873
|
-
iframe: buildLivePreviewIframe().iframe,
|
|
874
|
-
reloadPreview: vi.fn(),
|
|
875
|
-
label: "Move timeline clips",
|
|
876
|
-
errorLabel: "Failed to shift GSAP positions",
|
|
877
|
-
recordEdit: vi.fn(async () => {}) as never,
|
|
878
|
-
activeCompPath: "index.html",
|
|
879
|
-
changes: [{ element }],
|
|
880
|
-
resolveChangePath: () => "index.html",
|
|
881
|
-
mutateChange: async () => ownedMutation(contents, "index.html", "ORIGINAL", "OWNED"),
|
|
882
|
-
});
|
|
840
|
+
await runOwnedChain([async () => ownedMutation(contents, "index.html", "ORIGINAL", "OWNED")]);
|
|
883
841
|
|
|
884
842
|
expect(server.rollbacks[0]).toEqual({
|
|
885
843
|
path: "index.html",
|