@hyperframes/studio 0.7.56 → 0.7.58
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-CtTDO63S.js +459 -0
- package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
- package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
- package/dist/assets/{index-C4csZims.js → index-DeQPzqwH.js} +1 -1
- package/dist/assets/index-uahwWkgw.css +1 -0
- package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
- package/dist/chunk-OBAG3GWK.js.map +1 -0
- package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
- package/dist/index.d.ts +41 -4
- package/dist/index.html +2 -2
- package/dist/index.js +7436 -5423
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +3 -3
- package/src/components/StudioFeedbackBar.tsx +2 -2
- package/src/components/editor/CanvasContextMenu.test.tsx +166 -2
- package/src/components/editor/CanvasContextMenu.tsx +100 -20
- package/src/components/editor/DomEditOverlay.tsx +34 -62
- package/src/components/editor/LayersPanel.tsx +111 -91
- package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
- package/src/components/editor/domEditingDom.ts +5 -6
- package/src/components/editor/domEditingElement.ts +15 -32
- package/src/components/editor/layersPanelSort.ts +80 -0
- package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
- package/src/components/editor/useCanvasContextMenuState.ts +92 -0
- package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
- package/src/components/editor/useLayerRevealOverride.ts +389 -0
- package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
- package/src/components/nle/PreviewOverlays.tsx +61 -16
- package/src/components/nle/TimelinePane.test.ts +3 -0
- package/src/components/nle/TimelinePane.tsx +11 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
- package/src/components/nle/zLaneGesture.test.ts +176 -0
- package/src/components/nle/zLaneGesture.ts +77 -0
- package/src/components/sidebar/AssetCard.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +19 -2
- package/src/components/sidebar/AudioRow.tsx +19 -2
- package/src/hooks/domEditCommitTypes.ts +29 -2
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- package/src/hooks/timelineEditingHelpers.test.ts +183 -1
- package/src/hooks/timelineEditingHelpers.ts +63 -259
- package/src/hooks/timelineMoveAdapter.test.ts +44 -40
- package/src/hooks/timelineMoveAdapter.ts +7 -4
- package/src/hooks/timelineTimingSync.test.ts +820 -0
- package/src/hooks/timelineTimingSync.ts +597 -0
- package/src/hooks/timelineTrackVisibility.test.ts +3 -1
- package/src/hooks/timelineTrackVisibility.ts +4 -3
- package/src/hooks/useCaptionDetection.ts +2 -0
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useDomEditCommits.test.tsx +392 -23
- package/src/hooks/useDomEditCommits.ts +180 -53
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
- package/src/hooks/useElementLifecycleOps.ts +175 -69
- package/src/hooks/useElementPicker.ts +7 -8
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +591 -277
- package/src/hooks/useTimelineEditing.ts +202 -282
- package/src/hooks/useTimelineEditingTypes.ts +1 -1
- package/src/hooks/useTimelineGroupEditing.ts +187 -134
- package/src/player/components/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +7 -2
- package/src/player/components/Timeline.test.ts +105 -18
- package/src/player/components/Timeline.tsx +69 -98
- package/src/player/components/TimelineCanvas.tsx +47 -7
- package/src/player/components/TimelineLanes.tsx +32 -10
- package/src/player/components/TimelineOverlays.tsx +36 -0
- package/src/player/components/TimelineRuler.tsx +70 -47
- package/src/player/components/TrackGapContextMenu.tsx +116 -0
- package/src/player/components/timelineCallbacks.ts +4 -1
- package/src/player/components/timelineClipDragCommit.test.ts +370 -11
- package/src/player/components/timelineClipDragCommit.ts +267 -68
- package/src/player/components/timelineGapCommit.test.ts +163 -0
- package/src/player/components/timelineGapCommit.ts +103 -0
- package/src/player/components/timelineGaps.test.ts +221 -0
- package/src/player/components/timelineGaps.ts +182 -0
- package/src/player/components/timelineLayout.test.ts +2 -1
- package/src/player/components/timelineLayout.ts +119 -30
- package/src/player/components/timelineMarquee.test.ts +20 -10
- package/src/player/components/timelineMarquee.ts +9 -2
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +102 -1
- package/src/player/components/timelineStackingSync.ts +45 -5
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZMirror.test.ts +667 -0
- package/src/player/components/timelineZMirror.ts +393 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- package/src/player/components/timelineZoom.test.ts +2 -2
- package/src/player/components/timelineZoom.ts +7 -3
- package/src/player/components/useTimelineClipDrag.ts +1 -1
- package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
- package/src/player/components/useTimelineGapHighlights.ts +110 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -3
- package/src/player/components/useTimelineRangeSelection.ts +6 -4
- package/src/player/components/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineScrollViewport.ts +73 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
- package/src/player/components/useTimelineStackingSync.ts +17 -4
- package/src/player/components/useTimelineTrackDerivations.ts +38 -0
- package/src/player/components/useTrackGapMenu.ts +155 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
- package/src/player/hooks/useExpandedTimelineElements.ts +19 -1
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
- package/src/player/hooks/useTimelinePlayer.ts +4 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
- package/src/player/index.ts +3 -1
- package/src/player/lib/layerOrdering.test.ts +1 -15
- package/src/player/lib/layerOrdering.ts +5 -28
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- package/src/player/lib/timelineDOM.test.ts +72 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/lib/timelineElementHelpers.ts +54 -11
- package/src/player/lib/timelineIframeHelpers.ts +2 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +49 -1
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -0
- package/src/utils/domEditSaveQueue.ts +4 -3
- package/src/utils/editHistory.test.ts +82 -0
- package/src/utils/editHistory.ts +14 -2
- package/src/utils/gsapSoftReload.test.ts +54 -115
- package/src/utils/gsapSoftReload.ts +42 -152
- package/src/utils/gsapUndoRestore.test.ts +269 -0
- package/src/utils/gsapUndoRestore.ts +269 -0
- package/src/utils/sourceScopedSelectorIndex.ts +29 -0
- package/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +62 -86
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/assets/index-D-GyYi2d.css +0 -1
- package/dist/chunk-5QSIMBEJ.js.map +0 -1
- /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
|
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
|
|
|
5
5
|
import { openComposition } from "@hyperframes/sdk";
|
|
6
6
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
7
|
import { usePlayerStore, type TimelineElement } from "../player";
|
|
8
|
+
import { jsonResponse, requestUrl } from "./fetchStubTestUtils";
|
|
8
9
|
import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
|
9
10
|
import { useTimelineEditing } from "./useTimelineEditing";
|
|
10
11
|
|
|
@@ -77,6 +78,21 @@ function timelineElement(input: {
|
|
|
77
78
|
};
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
/** Mount a harness component under act() and return its unmount hook. */
|
|
82
|
+
function mountHarness(node: React.ReactElement): { unmount: () => void } {
|
|
83
|
+
const host = document.createElement("div");
|
|
84
|
+
document.body.append(host);
|
|
85
|
+
const root = createRoot(host);
|
|
86
|
+
act(() => {
|
|
87
|
+
root.render(node);
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
unmount: () => {
|
|
91
|
+
act(() => root.unmount());
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
80
96
|
function renderTimelineEditingHook(input: {
|
|
81
97
|
timelineElements: TimelineElement[];
|
|
82
98
|
iframe: HTMLIFrameElement;
|
|
@@ -92,17 +108,20 @@ function renderTimelineEditingHook(input: {
|
|
|
92
108
|
reloadPreview?: () => void;
|
|
93
109
|
sdkSession?: Awaited<ReturnType<typeof openComposition>> | null;
|
|
94
110
|
forceReloadSdkSession?: () => void;
|
|
111
|
+
showToast?: (message: string, kind?: string) => void;
|
|
95
112
|
}): {
|
|
96
113
|
move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
|
|
97
114
|
resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"];
|
|
98
115
|
groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"];
|
|
99
116
|
groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"];
|
|
117
|
+
del: ReturnType<typeof useTimelineEditing>["handleTimelineElementDelete"];
|
|
100
118
|
unmount: () => void;
|
|
101
119
|
} {
|
|
102
120
|
let move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"] | null = null;
|
|
103
121
|
let resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"] | null = null;
|
|
104
122
|
let groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"] | null = null;
|
|
105
123
|
let groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"] | null = null;
|
|
124
|
+
let del: ReturnType<typeof useTimelineEditing>["handleTimelineElementDelete"] | null = null;
|
|
106
125
|
|
|
107
126
|
function Harness() {
|
|
108
127
|
const commitRef = useRef(input.onZIndexCommit);
|
|
@@ -111,7 +130,7 @@ function renderTimelineEditingHook(input: {
|
|
|
111
130
|
projectId: input.projectId ?? null,
|
|
112
131
|
activeCompPath: "index.html",
|
|
113
132
|
timelineElements: input.timelineElements,
|
|
114
|
-
showToast: vi.fn(),
|
|
133
|
+
showToast: input.showToast ?? vi.fn(),
|
|
115
134
|
writeProjectFile: input.writeProjectFile ?? vi.fn(),
|
|
116
135
|
recordEdit: input.recordEdit ?? vi.fn(),
|
|
117
136
|
domEditSaveTimestampRef: { current: 0 },
|
|
@@ -127,29 +146,17 @@ function renderTimelineEditingHook(input: {
|
|
|
127
146
|
resize = hook.handleTimelineElementResize;
|
|
128
147
|
groupMove = hook.handleTimelineGroupMove;
|
|
129
148
|
groupResize = hook.handleTimelineGroupResize;
|
|
149
|
+
del = hook.handleTimelineElementDelete;
|
|
130
150
|
return null;
|
|
131
151
|
}
|
|
132
152
|
|
|
133
|
-
const
|
|
134
|
-
document.body.append(host);
|
|
135
|
-
const root = createRoot(host);
|
|
136
|
-
act(() => {
|
|
137
|
-
root.render(<Harness />);
|
|
138
|
-
});
|
|
139
|
-
|
|
153
|
+
const { unmount } = mountHarness(<Harness />);
|
|
140
154
|
if (!move) throw new Error("Expected hook to expose move handler");
|
|
141
155
|
if (!resize) throw new Error("Expected hook to expose resize handler");
|
|
142
156
|
if (!groupMove) throw new Error("Expected hook to expose group move handler");
|
|
143
157
|
if (!groupResize) throw new Error("Expected hook to expose group resize handler");
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
resize,
|
|
147
|
-
groupMove,
|
|
148
|
-
groupResize,
|
|
149
|
-
unmount: () => {
|
|
150
|
-
act(() => root.unmount());
|
|
151
|
-
},
|
|
152
|
-
};
|
|
158
|
+
if (!del) throw new Error("Expected hook to expose delete handler");
|
|
159
|
+
return { move, resize, groupMove, groupResize, del, unmount };
|
|
153
160
|
}
|
|
154
161
|
|
|
155
162
|
type TimelineRecordEdit = NonNullable<
|
|
@@ -159,7 +166,11 @@ type TimelineRecordEdit = NonNullable<
|
|
|
159
166
|
function renderTimelineEditingHookWithLifecycle(input: {
|
|
160
167
|
timelineElements: TimelineElement[];
|
|
161
168
|
iframe: HTMLIFrameElement;
|
|
162
|
-
commitDomEditPatchBatches: ReturnType<
|
|
169
|
+
commitDomEditPatchBatches: ReturnType<
|
|
170
|
+
typeof vi.fn<
|
|
171
|
+
(...args: unknown[]) => Promise<{ durable: boolean; allMatched: boolean; changed: boolean }>
|
|
172
|
+
>
|
|
173
|
+
>;
|
|
163
174
|
}): {
|
|
164
175
|
move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
|
|
165
176
|
unmount: () => void;
|
|
@@ -198,172 +209,209 @@ function renderTimelineEditingHookWithLifecycle(input: {
|
|
|
198
209
|
return null;
|
|
199
210
|
}
|
|
200
211
|
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
212
|
+
const { unmount } = mountHarness(<Harness />);
|
|
213
|
+
if (!move) throw new Error("Expected hook to expose move handler");
|
|
214
|
+
return { move, unmount };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function flushAsyncWork(): Promise<void> {
|
|
218
|
+
for (let i = 0; i < 8; i += 1) {
|
|
219
|
+
await Promise.resolve();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Stub global fetch for project "p1": serves file contents (a single source
|
|
225
|
+
* string, or a path → content map) and answers the GSAP-mutation endpoint
|
|
226
|
+
* with `gsapBody`. Returns the mock for call inspection.
|
|
227
|
+
*/
|
|
228
|
+
function stubProjectFetch(files: string | Record<string, string>, gsapBody?: unknown) {
|
|
229
|
+
// Keep this test server's capability, file-read, and mutation routes together;
|
|
230
|
+
// splitting the fixture would obscure the request sequence asserted by callers.
|
|
231
|
+
// fallow-ignore-next-line complexity
|
|
232
|
+
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
233
|
+
const url = requestUrl(input);
|
|
234
|
+
if (url.includes("/api/projects/p1/gsap-mutation-capabilities")) {
|
|
235
|
+
return jsonResponse({ atomicOwnershipPairs: true });
|
|
236
|
+
}
|
|
237
|
+
if (url.includes("/api/projects/p1/files/")) {
|
|
238
|
+
if (typeof files === "string") return jsonResponse({ content: files });
|
|
239
|
+
const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html");
|
|
240
|
+
return jsonResponse({ content: files[path] });
|
|
241
|
+
}
|
|
242
|
+
if (url.includes("/api/projects/p1/gsap-mutations/")) {
|
|
243
|
+
const path = decodeURIComponent(url.split("/gsap-mutations/")[1] ?? "index.html");
|
|
244
|
+
const content = typeof files === "string" ? files : (files[path] ?? "");
|
|
245
|
+
return jsonResponse(
|
|
246
|
+
gsapBody ?? { mutated: false, scriptText: null, before: content, after: content },
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
206
250
|
});
|
|
251
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
252
|
+
return fetchMock;
|
|
253
|
+
}
|
|
207
254
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
255
|
+
/**
|
|
256
|
+
* One clip on track 0 + the editing hook wired to project "p1" — shared setup
|
|
257
|
+
* for the single-clip move-path tests (horizontal, vertical-only, track-only,
|
|
258
|
+
* diagonal) so their arrange blocks aren't clones of each other.
|
|
259
|
+
*/
|
|
260
|
+
function setupSingleClipHarness(options?: {
|
|
261
|
+
source?: string;
|
|
262
|
+
clipStyle?: string;
|
|
263
|
+
onZIndexCommit?: (entries: ZIndexEntry[]) => Promise<void>;
|
|
264
|
+
}) {
|
|
265
|
+
const iframe = createPreviewIframe([{ id: "clip", track: 0, style: options?.clipStyle }]);
|
|
266
|
+
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
|
267
|
+
const commit =
|
|
268
|
+
options?.onZIndexCommit ??
|
|
269
|
+
vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockResolvedValue(undefined);
|
|
270
|
+
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
271
|
+
const reloadPreview = vi.fn();
|
|
272
|
+
const fetchMock = stubProjectFetch(
|
|
273
|
+
options?.source ?? '<div id="clip" data-start="0" data-track-index="0"></div>',
|
|
274
|
+
);
|
|
275
|
+
const hook = renderTimelineEditingHook({
|
|
276
|
+
timelineElements: [clip],
|
|
277
|
+
iframe,
|
|
278
|
+
onZIndexCommit: commit,
|
|
279
|
+
projectId: "p1",
|
|
280
|
+
writeProjectFile,
|
|
281
|
+
recordEdit: vi.fn(async () => {}),
|
|
282
|
+
reloadPreview,
|
|
283
|
+
});
|
|
284
|
+
return { iframe, clip, commit, writeProjectFile, reloadPreview, fetchMock, ...hook };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Assert a lane write landed in both the live iframe DOM and the persisted file. */
|
|
288
|
+
function expectLanePersisted(
|
|
289
|
+
iframe: HTMLIFrameElement,
|
|
290
|
+
writeProjectFile: { mock: { calls: unknown[][] } },
|
|
291
|
+
track: string,
|
|
292
|
+
): void {
|
|
293
|
+
const doc = iframe.contentDocument;
|
|
294
|
+
if (!doc) throw new Error("Expected iframe document");
|
|
295
|
+
expect(doc.getElementById("clip")?.getAttribute("data-track-index")).toBe(track);
|
|
296
|
+
expect(writeProjectFile.mock.calls[0]![1]).toContain(`data-track-index="${track}"`);
|
|
215
297
|
}
|
|
216
298
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
299
|
+
/**
|
|
300
|
+
* Two 1s clips — a@0s on track 0, b@1s on track 1 — plus the matching iframe.
|
|
301
|
+
* Shared by the group-move tests; `bSourceFile` puts b in its own file for the
|
|
302
|
+
* cross-file partition test.
|
|
303
|
+
*/
|
|
304
|
+
function makeTwoClipPair(bSourceFile?: string) {
|
|
305
|
+
const iframe = createPreviewIframe([
|
|
306
|
+
{ id: "a", track: 0 },
|
|
307
|
+
{ id: "b", track: 1 },
|
|
308
|
+
]);
|
|
309
|
+
const a = timelineElement({ id: "a", track: 0, zIndex: 0, start: 0, duration: 1 });
|
|
310
|
+
const b = timelineElement({
|
|
311
|
+
id: "b",
|
|
312
|
+
track: 1,
|
|
313
|
+
zIndex: 0,
|
|
314
|
+
start: 1,
|
|
315
|
+
duration: 1,
|
|
316
|
+
sourceFile: bSourceFile,
|
|
221
317
|
});
|
|
318
|
+
return { iframe, a, b };
|
|
222
319
|
}
|
|
223
320
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
321
|
+
const ROOT_DURATION_FALLBACK_SOURCE = [
|
|
322
|
+
`<div data-composition-id="main" data-duration="4">`,
|
|
323
|
+
` <div id="clip" data-hf-id="hf-clip" data-start="0" data-duration="2"></div>`,
|
|
324
|
+
`</div>`,
|
|
325
|
+
].join("\n");
|
|
326
|
+
|
|
327
|
+
/** Shared setup for the SDK-fallback root-duration tests: one 2s clip in a 4s comp. */
|
|
328
|
+
async function setupRootDurationFallback() {
|
|
329
|
+
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
|
|
330
|
+
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
|
331
|
+
const sdkSession = await openComposition(ROOT_DURATION_FALLBACK_SOURCE);
|
|
332
|
+
const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
|
|
333
|
+
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
334
|
+
const recordEdit = vi.fn<TimelineRecordEdit>(async () => {});
|
|
335
|
+
const forceReloadSdkSession = vi.fn();
|
|
336
|
+
const reloadPreview = vi.fn();
|
|
337
|
+
const iframeWindow = iframe.contentWindow;
|
|
338
|
+
if (!iframeWindow) throw new Error("Expected iframe window");
|
|
339
|
+
const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
|
|
340
|
+
stubProjectFetch(ROOT_DURATION_FALLBACK_SOURCE, {
|
|
341
|
+
mutated: false,
|
|
342
|
+
scriptText: null,
|
|
343
|
+
before: ROOT_DURATION_FALLBACK_SOURCE,
|
|
344
|
+
after: ROOT_DURATION_FALLBACK_SOURCE,
|
|
345
|
+
});
|
|
346
|
+
usePlayerStore.getState().setDuration(4);
|
|
347
|
+
const hook = renderTimelineEditingHook({
|
|
348
|
+
timelineElements: [clip],
|
|
349
|
+
iframe,
|
|
350
|
+
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
351
|
+
projectId: "p1",
|
|
352
|
+
writeProjectFile,
|
|
353
|
+
recordEdit,
|
|
354
|
+
sdkSession,
|
|
355
|
+
forceReloadSdkSession,
|
|
356
|
+
reloadPreview,
|
|
357
|
+
});
|
|
358
|
+
return {
|
|
359
|
+
hook,
|
|
360
|
+
clip,
|
|
361
|
+
setTimingSpy,
|
|
362
|
+
writeProjectFile,
|
|
363
|
+
forceReloadSdkSession,
|
|
364
|
+
reloadPreview,
|
|
365
|
+
postMessageSpy,
|
|
366
|
+
};
|
|
228
367
|
}
|
|
229
368
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
369
|
+
/** Shared assertions: the fallback path grew the root to 5s and did ONE full reload. */
|
|
370
|
+
function expectRootDurationExtendedViaFallback(
|
|
371
|
+
ctx: Awaited<ReturnType<typeof setupRootDurationFallback>>,
|
|
372
|
+
): void {
|
|
373
|
+
expect(ctx.setTimingSpy).not.toHaveBeenCalled();
|
|
374
|
+
expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain(
|
|
375
|
+
'data-composition-id="main" data-duration="5"',
|
|
376
|
+
);
|
|
377
|
+
expect(usePlayerStore.getState().duration).toBe(5);
|
|
378
|
+
expect(ctx.forceReloadSdkSession).toHaveBeenCalledTimes(1);
|
|
379
|
+
// The GSAP endpoint returned no rewritten scriptText, so the timing sync
|
|
380
|
+
// escalates from the flash-free soft reload to ONE full reload. The root
|
|
381
|
+
// duration travels via the persisted content-driven `data-duration` (above),
|
|
382
|
+
// not a `set-root-duration` postMessage.
|
|
383
|
+
expect(ctx.reloadPreview).toHaveBeenCalledTimes(1);
|
|
384
|
+
expect(ctx.postMessageSpy).not.toHaveBeenCalledWith(
|
|
385
|
+
expect.objectContaining({ action: "set-root-duration" }),
|
|
386
|
+
"*",
|
|
387
|
+
);
|
|
234
388
|
}
|
|
235
389
|
|
|
236
390
|
describe("useTimelineEditing timeline z-index reorder", () => {
|
|
237
391
|
it("extends root duration through the fallback path when an SDK-backed move passes the end", async () => {
|
|
238
|
-
const
|
|
239
|
-
`<div data-composition-id="main" data-duration="4">`,
|
|
240
|
-
` <div id="clip" data-hf-id="hf-clip" data-start="0" data-duration="2"></div>`,
|
|
241
|
-
`</div>`,
|
|
242
|
-
].join("\n");
|
|
243
|
-
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
|
|
244
|
-
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
|
245
|
-
const sdkSession = await openComposition(source);
|
|
246
|
-
const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
|
|
247
|
-
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
248
|
-
const recordEdit = vi.fn<TimelineRecordEdit>(async () => {});
|
|
249
|
-
const forceReloadSdkSession = vi.fn();
|
|
250
|
-
const reloadPreview = vi.fn();
|
|
251
|
-
const iframeWindow = iframe.contentWindow;
|
|
252
|
-
if (!iframeWindow) throw new Error("Expected iframe window");
|
|
253
|
-
const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
|
|
254
|
-
vi.stubGlobal(
|
|
255
|
-
"fetch",
|
|
256
|
-
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
257
|
-
const url = requestUrl(input);
|
|
258
|
-
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
|
|
259
|
-
if (url.includes("/api/projects/p1/gsap-mutations/")) {
|
|
260
|
-
return jsonResponse({ ok: true, mutated: false });
|
|
261
|
-
}
|
|
262
|
-
throw new Error(`Unexpected fetch: ${url}`);
|
|
263
|
-
}),
|
|
264
|
-
);
|
|
265
|
-
usePlayerStore.getState().setDuration(4);
|
|
266
|
-
const { move, unmount } = renderTimelineEditingHook({
|
|
267
|
-
timelineElements: [clip],
|
|
268
|
-
iframe,
|
|
269
|
-
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
270
|
-
projectId: "p1",
|
|
271
|
-
writeProjectFile,
|
|
272
|
-
recordEdit,
|
|
273
|
-
sdkSession,
|
|
274
|
-
forceReloadSdkSession,
|
|
275
|
-
reloadPreview,
|
|
276
|
-
});
|
|
392
|
+
const ctx = await setupRootDurationFallback();
|
|
277
393
|
|
|
278
394
|
await act(async () => {
|
|
279
|
-
await move(clip, { start: 3, track: clip.track });
|
|
395
|
+
await ctx.hook.move(ctx.clip, { start: 3, track: ctx.clip.track });
|
|
280
396
|
});
|
|
281
397
|
|
|
282
|
-
expect(
|
|
283
|
-
|
|
284
|
-
'data-composition-id="main" data-duration="5"',
|
|
285
|
-
);
|
|
286
|
-
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="3"');
|
|
287
|
-
expect(usePlayerStore.getState().duration).toBe(5);
|
|
288
|
-
expect(forceReloadSdkSession).toHaveBeenCalledTimes(1);
|
|
289
|
-
expect(reloadPreview).not.toHaveBeenCalled();
|
|
290
|
-
expect(postMessageSpy).toHaveBeenCalledWith(
|
|
291
|
-
{
|
|
292
|
-
source: "hf-parent",
|
|
293
|
-
type: "control",
|
|
294
|
-
action: "set-root-duration",
|
|
295
|
-
durationSeconds: 5,
|
|
296
|
-
},
|
|
297
|
-
"*",
|
|
298
|
-
);
|
|
398
|
+
expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain('data-start="3"');
|
|
399
|
+
expectRootDurationExtendedViaFallback(ctx);
|
|
299
400
|
|
|
300
|
-
unmount();
|
|
401
|
+
ctx.hook.unmount();
|
|
301
402
|
});
|
|
302
403
|
|
|
303
404
|
it("extends root duration through the fallback path when an SDK-backed resize passes the end", async () => {
|
|
304
|
-
const
|
|
305
|
-
`<div data-composition-id="main" data-duration="4">`,
|
|
306
|
-
` <div id="clip" data-hf-id="hf-clip" data-start="0" data-duration="2"></div>`,
|
|
307
|
-
`</div>`,
|
|
308
|
-
].join("\n");
|
|
309
|
-
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
|
|
310
|
-
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
|
311
|
-
const sdkSession = await openComposition(source);
|
|
312
|
-
const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
|
|
313
|
-
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
314
|
-
const recordEdit = vi.fn<TimelineRecordEdit>(async () => {});
|
|
315
|
-
const forceReloadSdkSession = vi.fn();
|
|
316
|
-
const reloadPreview = vi.fn();
|
|
317
|
-
const iframeWindow = iframe.contentWindow;
|
|
318
|
-
if (!iframeWindow) throw new Error("Expected iframe window");
|
|
319
|
-
const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
|
|
320
|
-
vi.stubGlobal(
|
|
321
|
-
"fetch",
|
|
322
|
-
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
323
|
-
const url = requestUrl(input);
|
|
324
|
-
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
|
|
325
|
-
if (url.includes("/api/projects/p1/gsap-mutations/")) {
|
|
326
|
-
return jsonResponse({ ok: true, mutated: false });
|
|
327
|
-
}
|
|
328
|
-
throw new Error(`Unexpected fetch: ${url}`);
|
|
329
|
-
}),
|
|
330
|
-
);
|
|
331
|
-
usePlayerStore.getState().setDuration(4);
|
|
332
|
-
const { resize, unmount } = renderTimelineEditingHook({
|
|
333
|
-
timelineElements: [clip],
|
|
334
|
-
iframe,
|
|
335
|
-
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
336
|
-
projectId: "p1",
|
|
337
|
-
writeProjectFile,
|
|
338
|
-
recordEdit,
|
|
339
|
-
sdkSession,
|
|
340
|
-
forceReloadSdkSession,
|
|
341
|
-
reloadPreview,
|
|
342
|
-
});
|
|
405
|
+
const ctx = await setupRootDurationFallback();
|
|
343
406
|
|
|
344
407
|
await act(async () => {
|
|
345
|
-
await resize(clip, { start: 0, duration: 5, playbackStart: undefined });
|
|
408
|
+
await ctx.hook.resize(ctx.clip, { start: 0, duration: 5, playbackStart: undefined });
|
|
346
409
|
});
|
|
347
410
|
|
|
348
|
-
expect(
|
|
349
|
-
|
|
350
|
-
'data-composition-id="main" data-duration="5"',
|
|
351
|
-
);
|
|
352
|
-
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-duration="5"></div>');
|
|
353
|
-
expect(usePlayerStore.getState().duration).toBe(5);
|
|
354
|
-
expect(forceReloadSdkSession).toHaveBeenCalledTimes(1);
|
|
355
|
-
expect(reloadPreview).not.toHaveBeenCalled();
|
|
356
|
-
expect(postMessageSpy).toHaveBeenCalledWith(
|
|
357
|
-
{
|
|
358
|
-
source: "hf-parent",
|
|
359
|
-
type: "control",
|
|
360
|
-
action: "set-root-duration",
|
|
361
|
-
durationSeconds: 5,
|
|
362
|
-
},
|
|
363
|
-
"*",
|
|
364
|
-
);
|
|
411
|
+
expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain('data-duration="5"></div>');
|
|
412
|
+
expectRootDurationExtendedViaFallback(ctx);
|
|
365
413
|
|
|
366
|
-
unmount();
|
|
414
|
+
ctx.hook.unmount();
|
|
367
415
|
});
|
|
368
416
|
|
|
369
417
|
it("routes a vertical drag through the shared z-index commit without writing track-index", async () => {
|
|
@@ -481,7 +529,9 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
481
529
|
]);
|
|
482
530
|
const front = timelineElement({ id: "front", track: 0, zIndex: 0 });
|
|
483
531
|
const back = timelineElement({ id: "back", track: 1, zIndex: 0 });
|
|
484
|
-
const commitDomEditPatchBatches = vi.fn<
|
|
532
|
+
const commitDomEditPatchBatches = vi.fn<
|
|
533
|
+
(...args: unknown[]) => Promise<{ durable: boolean; allMatched: boolean; changed: boolean }>
|
|
534
|
+
>(async () => ({ durable: true, allMatched: true, changed: true }));
|
|
485
535
|
const { move, unmount } = renderTimelineEditingHookWithLifecycle({
|
|
486
536
|
timelineElements: [front, back],
|
|
487
537
|
iframe,
|
|
@@ -526,7 +576,9 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
526
576
|
]);
|
|
527
577
|
const saveError = new Error("save failed");
|
|
528
578
|
const commitDomEditPatchBatches = vi
|
|
529
|
-
.fn<
|
|
579
|
+
.fn<
|
|
580
|
+
(...args: unknown[]) => Promise<{ durable: boolean; allMatched: boolean; changed: boolean }>
|
|
581
|
+
>()
|
|
530
582
|
.mockRejectedValueOnce(saveError);
|
|
531
583
|
const { move, unmount } = renderTimelineEditingHookWithLifecycle({
|
|
532
584
|
timelineElements: [front, back],
|
|
@@ -590,8 +642,12 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
590
642
|
releaseBatch = resolve;
|
|
591
643
|
});
|
|
592
644
|
const commitDomEditPatchBatches = vi
|
|
593
|
-
.fn<
|
|
594
|
-
|
|
645
|
+
.fn<
|
|
646
|
+
(...args: unknown[]) => Promise<{ durable: boolean; allMatched: boolean; changed: boolean }>
|
|
647
|
+
>()
|
|
648
|
+
.mockReturnValueOnce(
|
|
649
|
+
batchSave.then(() => ({ durable: true, allMatched: true, changed: true })),
|
|
650
|
+
);
|
|
595
651
|
const { move, unmount } = renderTimelineEditingHookWithLifecycle({
|
|
596
652
|
timelineElements: [front, back],
|
|
597
653
|
iframe,
|
|
@@ -632,90 +688,59 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
632
688
|
});
|
|
633
689
|
|
|
634
690
|
it("keeps horizontal-only drag on the timing and GSAP shift path without z-index writes", async () => {
|
|
635
|
-
const
|
|
636
|
-
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
|
637
|
-
const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockResolvedValue(undefined);
|
|
638
|
-
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
639
|
-
const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
|
|
640
|
-
const reloadPreview = vi.fn();
|
|
641
|
-
const fetchMock = vi.fn(
|
|
642
|
-
async (
|
|
643
|
-
input: Parameters<typeof fetch>[0],
|
|
644
|
-
_init?: Parameters<typeof fetch>[1],
|
|
645
|
-
): Promise<Response> => {
|
|
646
|
-
const url = requestUrl(input);
|
|
647
|
-
if (url.includes("/api/projects/p1/files/")) {
|
|
648
|
-
return jsonResponse({
|
|
649
|
-
content: '<div id="clip" data-start="0" data-track-index="0"></div>',
|
|
650
|
-
});
|
|
651
|
-
}
|
|
652
|
-
if (url.includes("/api/projects/p1/gsap-mutations/")) {
|
|
653
|
-
return jsonResponse({ ok: true });
|
|
654
|
-
}
|
|
655
|
-
throw new Error(`Unexpected fetch: ${url}`);
|
|
656
|
-
},
|
|
657
|
-
);
|
|
658
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
659
|
-
const { move, unmount } = renderTimelineEditingHook({
|
|
660
|
-
timelineElements: [clip],
|
|
661
|
-
iframe,
|
|
662
|
-
onZIndexCommit: commit,
|
|
663
|
-
projectId: "p1",
|
|
664
|
-
writeProjectFile,
|
|
665
|
-
recordEdit,
|
|
666
|
-
reloadPreview,
|
|
667
|
-
});
|
|
691
|
+
const h = setupSingleClipHarness();
|
|
668
692
|
|
|
669
693
|
await act(async () => {
|
|
670
|
-
await move(clip, { start: 1.25, track: clip.track });
|
|
694
|
+
await h.move(h.clip, { start: 1.25, track: h.clip.track });
|
|
671
695
|
});
|
|
672
696
|
|
|
673
|
-
const doc = iframe.contentDocument;
|
|
697
|
+
const doc = h.iframe.contentDocument;
|
|
674
698
|
if (!doc) throw new Error("Expected iframe document");
|
|
675
699
|
expect(doc.getElementById("clip")?.getAttribute("data-start")).toBe("1.25");
|
|
676
700
|
expect(doc.getElementById("clip")?.getAttribute("data-track-index")).toBe("0");
|
|
677
|
-
expect(commit).not.toHaveBeenCalled();
|
|
678
|
-
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="1.25"');
|
|
679
|
-
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-track-index="0"');
|
|
680
|
-
expect(writeProjectFile.mock.calls[0]![1]).not.toContain("z-index");
|
|
701
|
+
expect(h.commit).not.toHaveBeenCalled();
|
|
702
|
+
expect(h.writeProjectFile.mock.calls[0]![1]).toContain('data-start="1.25"');
|
|
703
|
+
expect(h.writeProjectFile.mock.calls[0]![1]).toContain('data-track-index="0"');
|
|
704
|
+
expect(h.writeProjectFile.mock.calls[0]![1]).not.toContain("z-index");
|
|
681
705
|
expect(
|
|
682
|
-
fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")),
|
|
706
|
+
h.fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")),
|
|
683
707
|
).toBe(true);
|
|
684
708
|
|
|
685
|
-
unmount();
|
|
709
|
+
h.unmount();
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it("persists a vertical-only lane move (start unchanged) through the single-element fallback", async () => {
|
|
713
|
+
// Regression: `if (!startChanged) return` ran BEFORE the file persist, so a
|
|
714
|
+
// pure lane change routed through onMoveElement (no onMoveElements wired)
|
|
715
|
+
// wrote NOTHING — the lane snapped back on the next reload.
|
|
716
|
+
const h = setupSingleClipHarness();
|
|
717
|
+
|
|
718
|
+
await act(async () => {
|
|
719
|
+
// Vertical-only: same start, new track (already authored-space on this path).
|
|
720
|
+
await h.move(h.clip, { start: h.clip.start, track: 2 });
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
// Live DOM patched (no pre-reload lane snap-back) and the file write
|
|
724
|
+
// carries the new data-track-index with start intact.
|
|
725
|
+
expectLanePersisted(h.iframe, h.writeProjectFile, "2");
|
|
726
|
+
expect(h.writeProjectFile.mock.calls[0]![1]).toContain('data-start="0"');
|
|
727
|
+
|
|
728
|
+
h.unmount();
|
|
686
729
|
});
|
|
687
730
|
|
|
688
731
|
it("orders the timing write after the z-index commit so a diagonal drag can't clobber the restack", async () => {
|
|
689
|
-
const iframe = createPreviewIframe([
|
|
690
|
-
{ id: "clip", track: 0, style: "position: relative; z-index: 0" },
|
|
691
|
-
]);
|
|
692
|
-
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
|
693
732
|
// Gate the z-index commit so we can observe whether the timing write waits.
|
|
694
733
|
let releaseCommit!: () => void;
|
|
695
734
|
const commitGate = new Promise<void>((resolve) => {
|
|
696
735
|
releaseCommit = resolve;
|
|
697
736
|
});
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
return jsonResponse({
|
|
704
|
-
content: '<div id="clip" data-start="0" data-track-index="0"></div>',
|
|
705
|
-
});
|
|
706
|
-
}
|
|
707
|
-
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
|
|
708
|
-
throw new Error(`Unexpected fetch: ${url}`);
|
|
709
|
-
});
|
|
710
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
711
|
-
const { move, unmount } = renderTimelineEditingHook({
|
|
712
|
-
timelineElements: [clip],
|
|
713
|
-
iframe,
|
|
714
|
-
onZIndexCommit: commit,
|
|
715
|
-
projectId: "p1",
|
|
716
|
-
writeProjectFile,
|
|
717
|
-
recordEdit: vi.fn(async () => {}),
|
|
737
|
+
const h = setupSingleClipHarness({
|
|
738
|
+
clipStyle: "position: relative; z-index: 0",
|
|
739
|
+
onZIndexCommit: vi
|
|
740
|
+
.fn<(entries: ZIndexEntry[]) => Promise<void>>()
|
|
741
|
+
.mockReturnValue(commitGate),
|
|
718
742
|
});
|
|
743
|
+
const { clip, commit, writeProjectFile, move, unmount } = h;
|
|
719
744
|
|
|
720
745
|
// Diagonal drag: both a time move (start change) and a restack (z-index change).
|
|
721
746
|
let movePromise!: Promise<unknown>;
|
|
@@ -766,15 +791,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
766
791
|
];
|
|
767
792
|
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
768
793
|
const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
|
|
769
|
-
|
|
770
|
-
"fetch",
|
|
771
|
-
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
772
|
-
const url = requestUrl(input);
|
|
773
|
-
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
|
|
774
|
-
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
|
|
775
|
-
throw new Error(`Unexpected fetch: ${url}`);
|
|
776
|
-
}),
|
|
777
|
-
);
|
|
794
|
+
stubProjectFetch(source);
|
|
778
795
|
const { groupMove, unmount } = renderTimelineEditingHook({
|
|
779
796
|
timelineElements: clips,
|
|
780
797
|
iframe,
|
|
@@ -808,33 +825,10 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
808
825
|
"index.html": '<div id="a" data-start="0" data-duration="1"></div>',
|
|
809
826
|
"scene.html": '<div id="b" data-start="1" data-duration="1"></div>',
|
|
810
827
|
};
|
|
811
|
-
const iframe =
|
|
812
|
-
{ id: "a", track: 0 },
|
|
813
|
-
{ id: "b", track: 1 },
|
|
814
|
-
]);
|
|
815
|
-
const a = timelineElement({ id: "a", track: 0, zIndex: 0, start: 0, duration: 1 });
|
|
816
|
-
const b = timelineElement({
|
|
817
|
-
id: "b",
|
|
818
|
-
track: 1,
|
|
819
|
-
zIndex: 0,
|
|
820
|
-
start: 1,
|
|
821
|
-
duration: 1,
|
|
822
|
-
sourceFile: "scene.html",
|
|
823
|
-
});
|
|
828
|
+
const { iframe, a, b } = makeTwoClipPair("scene.html");
|
|
824
829
|
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
825
830
|
const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
|
|
826
|
-
|
|
827
|
-
"fetch",
|
|
828
|
-
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
829
|
-
const url = requestUrl(input);
|
|
830
|
-
if (url.includes("/api/projects/p1/files/")) {
|
|
831
|
-
const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html");
|
|
832
|
-
return jsonResponse({ content: files[path] });
|
|
833
|
-
}
|
|
834
|
-
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
|
|
835
|
-
throw new Error(`Unexpected fetch: ${url}`);
|
|
836
|
-
}),
|
|
837
|
-
);
|
|
831
|
+
stubProjectFetch(files);
|
|
838
832
|
const { groupMove, unmount } = renderTimelineEditingHook({
|
|
839
833
|
timelineElements: [a, b],
|
|
840
834
|
iframe,
|
|
@@ -875,15 +869,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
875
869
|
releaseCommit = resolve;
|
|
876
870
|
});
|
|
877
871
|
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
878
|
-
|
|
879
|
-
"fetch",
|
|
880
|
-
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
881
|
-
const url = requestUrl(input);
|
|
882
|
-
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
|
|
883
|
-
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
|
|
884
|
-
throw new Error(`Unexpected fetch: ${url}`);
|
|
885
|
-
}),
|
|
886
|
-
);
|
|
872
|
+
stubProjectFetch(source);
|
|
887
873
|
const { groupMove, unmount } = renderTimelineEditingHook({
|
|
888
874
|
timelineElements: [clip],
|
|
889
875
|
iframe,
|
|
@@ -910,16 +896,152 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
910
896
|
unmount();
|
|
911
897
|
});
|
|
912
898
|
|
|
899
|
+
it("skips the GSAP fallback and reload for a TRACK-ONLY group move (z-mirror lane move)", async () => {
|
|
900
|
+
// The mirrored z-order lane move persists {start: unchanged, track: new}.
|
|
901
|
+
// Nothing timing-related changed — data-track-index is never read by the
|
|
902
|
+
// renderer — so the persist must NOT be followed by the GSAP round-trip or
|
|
903
|
+
// the full preview reload (the reload is what blinked the canvas).
|
|
904
|
+
const h = setupSingleClipHarness({
|
|
905
|
+
source: '<div id="clip" data-start="0" data-duration="2" data-track-index="0"></div>',
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
await act(async () => {
|
|
909
|
+
await h.groupMove([{ element: h.clip, start: h.clip.start, track: 2 }]);
|
|
910
|
+
await flushAsyncWork();
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
// The lane write still persisted (live DOM + file)...
|
|
914
|
+
expectLanePersisted(h.iframe, h.writeProjectFile, "2");
|
|
915
|
+
expect(h.writeProjectFile).toHaveBeenCalledTimes(1);
|
|
916
|
+
// ...but no GSAP mutation ran and the preview was NOT reloaded.
|
|
917
|
+
expect(
|
|
918
|
+
h.fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")),
|
|
919
|
+
).toBe(false);
|
|
920
|
+
expect(h.reloadPreview).not.toHaveBeenCalled();
|
|
921
|
+
|
|
922
|
+
h.unmount();
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
it("rebinds the preview in place (no blink) for a time-move of a no-domId clip", async () => {
|
|
926
|
+
// The gap-close blink path: "Close gap" issues pure TIME moves through
|
|
927
|
+
// handleTimelineGroupMove. A selector-addressed clip (no domId — e.g. a
|
|
928
|
+
// .sub caption) has no id-addressed GSAP tweens, so the mutation step has
|
|
929
|
+
// nothing to rewrite and used to fall through to a full reloadPreview().
|
|
930
|
+
// The sync must instead rebind the runtime timing in place — seek + rebind
|
|
931
|
+
// re-derive the clip windows from the already-patched DOM — WITHOUT
|
|
932
|
+
// re-executing any script (a comp's init-style scripts, e.g. a three.js
|
|
933
|
+
// setup, must never run twice) and without the full-reload blink.
|
|
934
|
+
const iframe = document.createElement("iframe");
|
|
935
|
+
document.body.append(iframe);
|
|
936
|
+
const doc = iframe.contentDocument;
|
|
937
|
+
if (!doc) throw new Error("Expected iframe document");
|
|
938
|
+
doc.body.innerHTML =
|
|
939
|
+
'<div class="cap" data-start="2" data-duration="1" data-track-index="0"></div>';
|
|
940
|
+
const liveScript = doc.createElement("script");
|
|
941
|
+
liveScript.textContent =
|
|
942
|
+
'window.__timelines = window.__timelines || {}; window.__timelines["root"] = { kill: function () {} };';
|
|
943
|
+
doc.body.appendChild(liveScript);
|
|
944
|
+
const win = iframe.contentWindow as unknown as Record<string, unknown>;
|
|
945
|
+
win.gsap = { timeline: vi.fn(), set: vi.fn() };
|
|
946
|
+
win.__timelines = { root: { kill: vi.fn() } };
|
|
947
|
+
win.__hfForceTimelineRebind = vi.fn();
|
|
948
|
+
win.__player = { getTime: () => 0, seek: vi.fn() };
|
|
949
|
+
|
|
950
|
+
const cap: TimelineElement = {
|
|
951
|
+
...timelineElement({ id: "cap", track: 0, zIndex: 0, start: 2, duration: 1 }),
|
|
952
|
+
domId: undefined,
|
|
953
|
+
selector: ".cap",
|
|
954
|
+
selectorIndex: 0,
|
|
955
|
+
};
|
|
956
|
+
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
957
|
+
const reloadPreview = vi.fn();
|
|
958
|
+
const fetchMock = stubProjectFetch(
|
|
959
|
+
'<div class="cap" data-hf-id="hf-cap" data-start="2" data-duration="1" data-track-index="0"></div>',
|
|
960
|
+
);
|
|
961
|
+
const { groupMove, unmount } = renderTimelineEditingHook({
|
|
962
|
+
timelineElements: [cap],
|
|
963
|
+
iframe,
|
|
964
|
+
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
965
|
+
projectId: "p1",
|
|
966
|
+
writeProjectFile,
|
|
967
|
+
recordEdit: vi.fn(async () => {}),
|
|
968
|
+
reloadPreview,
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
await act(async () => {
|
|
972
|
+
// Mirror timelineGapCommit: a pure time move keeping the current lane.
|
|
973
|
+
await groupMove([{ element: cap, start: 1, track: cap.track }]);
|
|
974
|
+
await flushAsyncWork();
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
// The move persisted (live DOM + file)...
|
|
978
|
+
expect(doc.querySelector(".cap")?.getAttribute("data-start")).toBe("1");
|
|
979
|
+
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="1"');
|
|
980
|
+
// ...no GSAP mutation ran (nothing id-addressed to rewrite)...
|
|
981
|
+
expect(
|
|
982
|
+
fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")),
|
|
983
|
+
).toBe(false);
|
|
984
|
+
// ...and the preview was rebound in place, NOT full-reloaded (blink),
|
|
985
|
+
// with the live script element left untouched (no re-execution).
|
|
986
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
987
|
+
expect(win.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
|
|
988
|
+
expect(doc.body.contains(liveScript)).toBe(true);
|
|
989
|
+
expect(doc.querySelectorAll("script")).toHaveLength(1);
|
|
990
|
+
|
|
991
|
+
unmount();
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it("keeps the GSAP fallback + reload for a MIXED batch (any start change)", async () => {
|
|
995
|
+
// One clip changes lane only, the other shifts in time — the batch is not
|
|
996
|
+
// track-only, so the existing behavior (GSAP shift + full reload when no
|
|
997
|
+
// rewritten scriptText comes back — this stub iframe has no runtime rebind
|
|
998
|
+
// hook, so the in-place rebind can't apply) must be preserved.
|
|
999
|
+
const source = [
|
|
1000
|
+
'<div id="a" data-start="0" data-duration="1" data-track-index="0"></div>',
|
|
1001
|
+
'<div id="b" data-start="1" data-duration="1" data-track-index="1"></div>',
|
|
1002
|
+
].join("\n");
|
|
1003
|
+
const { iframe, a, b } = makeTwoClipPair();
|
|
1004
|
+
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
1005
|
+
const reloadPreview = vi.fn();
|
|
1006
|
+
const fetchMock = stubProjectFetch(source, {
|
|
1007
|
+
mutated: false,
|
|
1008
|
+
scriptText: null,
|
|
1009
|
+
before: source,
|
|
1010
|
+
after: source,
|
|
1011
|
+
});
|
|
1012
|
+
const { groupMove, unmount } = renderTimelineEditingHook({
|
|
1013
|
+
timelineElements: [a, b],
|
|
1014
|
+
iframe,
|
|
1015
|
+
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
1016
|
+
projectId: "p1",
|
|
1017
|
+
writeProjectFile,
|
|
1018
|
+
recordEdit: vi.fn(async () => {}),
|
|
1019
|
+
reloadPreview,
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
await act(async () => {
|
|
1023
|
+
await groupMove([
|
|
1024
|
+
{ element: a, start: a.start, track: 2 },
|
|
1025
|
+
{ element: b, start: 1.5, track: b.track },
|
|
1026
|
+
]);
|
|
1027
|
+
await flushAsyncWork();
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
expect(writeProjectFile).toHaveBeenCalledTimes(1);
|
|
1031
|
+
// The time-shifted clip still goes through the GSAP shift endpoint...
|
|
1032
|
+
expect(
|
|
1033
|
+
fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")),
|
|
1034
|
+
).toBe(true);
|
|
1035
|
+
// ...and with no rewritten scriptText the sync escalates to one full reload.
|
|
1036
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
1037
|
+
|
|
1038
|
+
unmount();
|
|
1039
|
+
});
|
|
1040
|
+
|
|
913
1041
|
it("matches the single-clip move output when a group move contains one clip", async () => {
|
|
914
1042
|
const source = '<div id="clip" data-start="0" data-duration="1"></div>';
|
|
915
1043
|
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 });
|
|
916
|
-
|
|
917
|
-
const url = requestUrl(input);
|
|
918
|
-
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
|
|
919
|
-
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
|
|
920
|
-
throw new Error(`Unexpected fetch: ${url}`);
|
|
921
|
-
});
|
|
922
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
1044
|
+
stubProjectFetch(source);
|
|
923
1045
|
|
|
924
1046
|
const singleWrite = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
925
1047
|
const single = renderTimelineEditingHook({
|
|
@@ -952,3 +1074,195 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
952
1074
|
group.unmount();
|
|
953
1075
|
});
|
|
954
1076
|
});
|
|
1077
|
+
|
|
1078
|
+
describe("useTimelineEditing duration rollback on failed persist", () => {
|
|
1079
|
+
const ROLLBACK_SOURCE = [
|
|
1080
|
+
`<div data-composition-id="main" data-duration="4">`,
|
|
1081
|
+
` <div id="clip" data-start="0" data-duration="2" data-track-index="0"></div>`,
|
|
1082
|
+
`</div>`,
|
|
1083
|
+
].join("\n");
|
|
1084
|
+
|
|
1085
|
+
/** Iframe with a comp root so the optimistic sync (and its rollback) can patch data-duration. */
|
|
1086
|
+
function createRootedIframe(source: string = ROLLBACK_SOURCE): HTMLIFrameElement {
|
|
1087
|
+
const iframe = document.createElement("iframe");
|
|
1088
|
+
document.body.append(iframe);
|
|
1089
|
+
const doc = iframe.contentDocument;
|
|
1090
|
+
if (!doc) throw new Error("Expected iframe document");
|
|
1091
|
+
doc.body.innerHTML = source;
|
|
1092
|
+
return iframe;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function rootDurationAttr(iframe: HTMLIFrameElement): string | null | undefined {
|
|
1096
|
+
return iframe.contentDocument
|
|
1097
|
+
?.querySelector("[data-composition-id]")
|
|
1098
|
+
?.getAttribute("data-duration");
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function setupFailedPersist() {
|
|
1102
|
+
const iframe = createRootedIframe();
|
|
1103
|
+
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
|
1104
|
+
const writeError = new Error("write failed");
|
|
1105
|
+
const writeProjectFile = vi
|
|
1106
|
+
.fn<(...args: unknown[]) => Promise<void>>()
|
|
1107
|
+
.mockRejectedValue(writeError);
|
|
1108
|
+
stubProjectFetch(ROLLBACK_SOURCE);
|
|
1109
|
+
usePlayerStore.getState().setDuration(4);
|
|
1110
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
1111
|
+
const hook = renderTimelineEditingHook({
|
|
1112
|
+
timelineElements: [clip],
|
|
1113
|
+
iframe,
|
|
1114
|
+
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
1115
|
+
projectId: "p1",
|
|
1116
|
+
writeProjectFile,
|
|
1117
|
+
recordEdit: vi.fn(async () => {}),
|
|
1118
|
+
reloadPreview: vi.fn(),
|
|
1119
|
+
});
|
|
1120
|
+
return { iframe, clip, hook, writeError };
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Run a persist that must reject with the harness's writeError, then assert
|
|
1125
|
+
* the store duration AND the live root rolled back to the pre-edit 4s —
|
|
1126
|
+
* the shared act/assert core of the four failed-persist tests below.
|
|
1127
|
+
*/
|
|
1128
|
+
async function expectPersistRollback(
|
|
1129
|
+
ctx: ReturnType<typeof setupFailedPersist>,
|
|
1130
|
+
run: () => Promise<unknown>,
|
|
1131
|
+
): Promise<void> {
|
|
1132
|
+
let rejection: unknown;
|
|
1133
|
+
await act(async () => {
|
|
1134
|
+
await run().catch((error) => {
|
|
1135
|
+
rejection = error;
|
|
1136
|
+
});
|
|
1137
|
+
await flushAsyncWork();
|
|
1138
|
+
});
|
|
1139
|
+
expect(rejection).toBe(ctx.writeError);
|
|
1140
|
+
expect(usePlayerStore.getState().duration).toBe(4);
|
|
1141
|
+
expect(rootDurationAttr(ctx.iframe)).toBe("4");
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
it("rolls back the store duration and live root when a move persist fails", async () => {
|
|
1145
|
+
const ctx = setupFailedPersist();
|
|
1146
|
+
// Move past the end: the optimistic sync grows the readout to 5s.
|
|
1147
|
+
await expectPersistRollback(ctx, () =>
|
|
1148
|
+
ctx.hook.move(ctx.clip, { start: 3, track: ctx.clip.track }),
|
|
1149
|
+
);
|
|
1150
|
+
ctx.hook.unmount();
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
it("rolls back the store duration and live root when a resize persist fails", async () => {
|
|
1154
|
+
const ctx = setupFailedPersist();
|
|
1155
|
+
await expectPersistRollback(ctx, () =>
|
|
1156
|
+
ctx.hook.resize(ctx.clip, { start: 0, duration: 6, playbackStart: undefined }),
|
|
1157
|
+
);
|
|
1158
|
+
ctx.hook.unmount();
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
it("rolls back the store duration and live root when a group move persist fails", async () => {
|
|
1162
|
+
const ctx = setupFailedPersist();
|
|
1163
|
+
await expectPersistRollback(ctx, () => ctx.hook.groupMove([{ element: ctx.clip, start: 3.5 }]));
|
|
1164
|
+
ctx.hook.unmount();
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
it("rolls back the store duration and live root when a group resize persist fails", async () => {
|
|
1168
|
+
const ctx = setupFailedPersist();
|
|
1169
|
+
await expectPersistRollback(ctx, () =>
|
|
1170
|
+
ctx.hook.groupResize([{ element: ctx.clip, start: 0, duration: 7 }]),
|
|
1171
|
+
);
|
|
1172
|
+
ctx.hook.unmount();
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
it("rolls back the store duration and live root when a delete persist fails", async () => {
|
|
1176
|
+
// Two clips; deleting the furthest one shrinks the content-driven duration
|
|
1177
|
+
// optimistically (4s -> 2s), so a failed write must roll that shrink back.
|
|
1178
|
+
const DELETE_SOURCE = [
|
|
1179
|
+
`<div data-composition-id="main" data-duration="4">`,
|
|
1180
|
+
` <div id="clip" data-start="0" data-duration="2" data-track-index="0"></div>`,
|
|
1181
|
+
` <div id="tail" data-start="2" data-duration="2" data-track-index="0"></div>`,
|
|
1182
|
+
`</div>`,
|
|
1183
|
+
].join("\n");
|
|
1184
|
+
const DELETE_REMOVED_SOURCE = [
|
|
1185
|
+
`<div data-composition-id="main" data-duration="4">`,
|
|
1186
|
+
` <div id="clip" data-start="0" data-duration="2" data-track-index="0"></div>`,
|
|
1187
|
+
`</div>`,
|
|
1188
|
+
].join("\n");
|
|
1189
|
+
|
|
1190
|
+
const iframe = createRootedIframe(DELETE_SOURCE);
|
|
1191
|
+
const tail = timelineElement({ id: "tail", track: 0, zIndex: 0, start: 2, duration: 2 });
|
|
1192
|
+
const writeError = new Error("write failed");
|
|
1193
|
+
const writeProjectFile = vi
|
|
1194
|
+
.fn<(...args: unknown[]) => Promise<void>>()
|
|
1195
|
+
.mockRejectedValue(writeError);
|
|
1196
|
+
// The delete path reads the file, then asks the server-side remove-element
|
|
1197
|
+
// mutation for the post-removal source before persisting it.
|
|
1198
|
+
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
1199
|
+
const url = requestUrl(input);
|
|
1200
|
+
if (url.includes("/api/projects/p1/files/")) {
|
|
1201
|
+
return jsonResponse({ content: DELETE_SOURCE });
|
|
1202
|
+
}
|
|
1203
|
+
if (url.includes("/api/projects/p1/file-mutations/remove-element/")) {
|
|
1204
|
+
return jsonResponse({ changed: true, content: DELETE_REMOVED_SOURCE });
|
|
1205
|
+
}
|
|
1206
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
1207
|
+
});
|
|
1208
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1209
|
+
usePlayerStore.getState().setDuration(4);
|
|
1210
|
+
const showToast = vi.fn();
|
|
1211
|
+
const hook = renderTimelineEditingHook({
|
|
1212
|
+
timelineElements: [tail],
|
|
1213
|
+
iframe,
|
|
1214
|
+
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
1215
|
+
projectId: "p1",
|
|
1216
|
+
writeProjectFile,
|
|
1217
|
+
recordEdit: vi.fn(async () => {}),
|
|
1218
|
+
reloadPreview: vi.fn(),
|
|
1219
|
+
showToast,
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
await act(async () => {
|
|
1223
|
+
// Unlike move/resize, the delete handler swallows the persist failure
|
|
1224
|
+
// into a toast, so the promise resolves.
|
|
1225
|
+
await hook.del(tail);
|
|
1226
|
+
await flushAsyncWork();
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
// The optimistic shrink reached the persist attempt (root patched to the
|
|
1230
|
+
// furthest remaining clip end, 2s)...
|
|
1231
|
+
expect(writeProjectFile).toHaveBeenCalledTimes(1);
|
|
1232
|
+
expect(String(writeProjectFile.mock.calls[0]![1])).toContain(
|
|
1233
|
+
'data-composition-id="main" data-duration="2"',
|
|
1234
|
+
);
|
|
1235
|
+
expect(showToast).toHaveBeenCalledWith("write failed");
|
|
1236
|
+
// ...and the failed write rolled the readout AND the live root back.
|
|
1237
|
+
expect(usePlayerStore.getState().duration).toBe(4);
|
|
1238
|
+
expect(rootDurationAttr(iframe)).toBe("4");
|
|
1239
|
+
|
|
1240
|
+
hook.unmount();
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
it("keeps the grown duration when the persist succeeds", async () => {
|
|
1244
|
+
const { iframe, clip, hook } = setupFailedPersist();
|
|
1245
|
+
// Same harness, but with a write that succeeds this time.
|
|
1246
|
+
hook.unmount();
|
|
1247
|
+
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
1248
|
+
const succeeding = renderTimelineEditingHook({
|
|
1249
|
+
timelineElements: [clip],
|
|
1250
|
+
iframe,
|
|
1251
|
+
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
1252
|
+
projectId: "p1",
|
|
1253
|
+
writeProjectFile,
|
|
1254
|
+
recordEdit: vi.fn(async () => {}),
|
|
1255
|
+
reloadPreview: vi.fn(),
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
await act(async () => {
|
|
1259
|
+
await succeeding.move(clip, { start: 3, track: clip.track });
|
|
1260
|
+
await flushAsyncWork();
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
expect(usePlayerStore.getState().duration).toBe(5);
|
|
1264
|
+
expect(rootDurationAttr(iframe)).toBe("5");
|
|
1265
|
+
|
|
1266
|
+
succeeding.unmount();
|
|
1267
|
+
});
|
|
1268
|
+
});
|