@hyperframes/studio 0.8.75 → 0.8.76
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-BkDxgSKG.js +464 -0
- package/dist/assets/{index-BiFKcNjs.js → index-COX0scwJ.js} +1 -1
- package/dist/assets/{index-BlUYw1MB.js → index-DeHir1up.js} +156 -156
- package/dist/assets/{index-HjexPsny.js → index-FmKUmYhC.js} +1 -1
- package/dist/{chunk-7BP32G62.js → chunk-IYDE66BY.js} +6 -1
- package/dist/chunk-IYDE66BY.js.map +1 -0
- package/dist/{domEditingLayers-UKMVYXT6.js → domEditingLayers-2XBFXXSW.js} +4 -2
- package/dist/index.d.ts +34 -69
- package/dist/index.html +1 -1
- package/dist/index.js +4147 -4253
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanels.types.ts +0 -2
- package/src/components/editor/DomEditOverlay.test.ts +5 -0
- package/src/components/editor/LayersPanel.tsx +3 -14
- package/src/components/editor/PreviewOverlayProvider.test.tsx +6 -1
- package/src/components/editor/domEditing.ts +1 -0
- package/src/components/editor/domEditingLayers.test.ts +27 -0
- package/src/components/editor/domEditingLayers.ts +9 -0
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +57 -35
- package/src/components/editor/overlayFrameLoop.test.ts +16 -0
- package/src/components/editor/overlayFrameLoop.ts +11 -0
- package/src/components/editor/usePreviewCompositionRect.test.tsx +6 -1
- package/src/components/nle/NLEPreview.test.ts +94 -17
- package/src/components/nle/NLEPreview.tsx +14 -0
- package/src/components/nle/PreviewPoster.tsx +99 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +30 -49
- package/src/components/panels/VariablesPanel.tsx +0 -2
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +5 -1
- package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +14 -0
- package/src/components/sidebar/CompositionsTab.test.ts +4 -6
- package/src/components/sidebar/CompositionsTab.tsx +28 -21
- package/src/components/studioColorGradingScope.ts +0 -3
- package/src/hooks/domEditDeleteMembers.ts +0 -2
- package/src/hooks/gsapScriptCommitTypes.ts +0 -2
- package/src/hooks/keyframeCacheAstLoad.test.ts +31 -1
- package/src/hooks/keyframeCacheAstLoad.ts +2 -1
- package/src/hooks/timelineAudioGroupCreate.ts +0 -1
- package/src/hooks/timelineEditingHelpers.ts +0 -3
- package/src/hooks/timelineTimingSync.ts +0 -1
- package/src/hooks/timelineTrackVisibility.ts +0 -1
- package/src/hooks/useBlockHandlers.ts +0 -2
- package/src/hooks/useClipboard.test.ts +69 -10
- package/src/hooks/useClipboard.ts +28 -19
- package/src/hooks/useDomEditCommits.test.tsx +0 -1
- package/src/hooks/useDomEditCommits.ts +0 -4
- package/src/hooks/useEditHistoryActions.test.tsx +31 -14
- package/src/hooks/useEditHistoryActions.ts +12 -7
- package/src/hooks/useEditorSave.ts +0 -3
- package/src/hooks/useElementLifecycleOps.ts +0 -1
- package/src/hooks/useEnableKeyframes.test.ts +9 -1
- package/src/hooks/useFileManager.ts +0 -2
- package/src/hooks/useGroupCommits.ts +0 -1
- package/src/hooks/useGsapScriptCommits.ts +0 -1
- package/src/hooks/useLintModal.test.tsx +34 -1
- package/src/hooks/useLintModal.ts +32 -30
- package/src/hooks/usePersistentEditHistory.test.ts +221 -315
- package/src/hooks/usePersistentEditHistory.ts +155 -342
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +2 -0
- package/src/hooks/usePreviewPersistence.ts +0 -2
- package/src/hooks/useProjectCompositionVariables.ts +1 -3
- package/src/hooks/useRazorSplit.history.test.tsx +46 -67
- package/src/hooks/useRazorSplit.test.ts +17 -47
- package/src/hooks/useRenderClipContent.test.ts +29 -0
- package/src/hooks/useRenderClipContent.ts +6 -3
- package/src/hooks/useSdkSession.lifecycle.test.tsx +34 -4
- package/src/hooks/useSdkSession.ts +8 -2
- package/src/hooks/useSlideshowPersist.ts +0 -2
- package/src/hooks/useStudioContextValue.ts +6 -1
- package/src/hooks/useTimelineAssetDropOps.ts +0 -1
- package/src/hooks/useTimelineDeleteOps.ts +0 -1
- package/src/hooks/useTimelineEditing.test.tsx +0 -1
- package/src/hooks/useTimelineEditingTypes.ts +0 -2
- package/src/hostMount.test.tsx +53 -22
- package/src/main.tsx +3 -0
- package/src/player/components/CompositionThumbnail.tsx +14 -0
- package/src/player/components/Player.test.ts +49 -0
- package/src/player/components/Player.tsx +21 -7
- package/src/player/components/timelineClipDragCommit.test.ts +7 -39
- package/src/player/hooks/timelineDomBinding.test.ts +50 -0
- package/src/player/hooks/timelineSyncHydration.test.ts +25 -1
- package/src/player/hooks/timelineSyncHydration.ts +3 -3
- package/src/player/hooks/useShadowPreviewReload.ts +1 -0
- package/src/player/hooks/useTimelinePlayer.play.test.ts +120 -0
- package/src/player/hooks/useTimelinePlayer.ts +1 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
- package/src/player/lib/timelineElementHelpers.ts +45 -9
- package/src/player/store/playerStore.test.ts +35 -1
- package/src/player/store/playerStore.ts +29 -0
- package/src/player/store/readinessSlice.ts +34 -6
- package/src/utils/blockInstaller.ts +0 -3
- package/src/utils/previewPrefetch.test.ts +22 -0
- package/src/utils/previewPrefetch.ts +14 -0
- package/src/utils/razorSplitTransaction.test.ts +0 -1
- package/src/utils/razorSplitTransaction.ts +1 -1
- package/src/utils/sdkEditTransaction.ts +0 -3
- package/src/utils/studioFileHistory.test.ts +0 -9
- package/src/utils/studioFileHistory.ts +1 -5
- package/src/utils/timelineCompositionInsert.ts +0 -1
- package/dist/assets/hyperframes-player-WbBVbWuO.js +0 -464
- package/dist/chunk-7BP32G62.js.map +0 -1
- package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +0 -60
- package/src/utils/editHistory.test.ts +0 -534
- package/src/utils/editHistory.ts +0 -236
- package/src/utils/editHistoryStorage.test.ts +0 -37
- package/src/utils/editHistoryStorage.ts +0 -99
- /package/dist/{domEditingLayers-UKMVYXT6.js.map → domEditingLayers-2XBFXXSW.js.map} +0 -0
|
@@ -7,13 +7,7 @@ import { splitElementInHtml } from "@hyperframes/studio-server/source-mutation";
|
|
|
7
7
|
import type { TimelineElement } from "../player";
|
|
8
8
|
import { usePlayerStore } from "../player";
|
|
9
9
|
import { useRazorSplit } from "./useRazorSplit";
|
|
10
|
-
import {
|
|
11
|
-
import { createMemoryEditHistoryStorage } from "../utils/editHistoryStorage";
|
|
12
|
-
import {
|
|
13
|
-
createEmptyEditHistory,
|
|
14
|
-
hashEditHistoryContent,
|
|
15
|
-
undoEditHistory,
|
|
16
|
-
} from "../utils/editHistory";
|
|
10
|
+
import type { RecordEditInput } from "./timelineEditingHelpers";
|
|
17
11
|
import { createSplitFetchMock, mountProbe } from "./useRazorSplit.testHelpers";
|
|
18
12
|
|
|
19
13
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
@@ -36,7 +30,10 @@ type SplitAll = (splitTime: number) => Promise<void>;
|
|
|
36
30
|
|
|
37
31
|
interface Harness {
|
|
38
32
|
disk: Record<string, string>;
|
|
39
|
-
|
|
33
|
+
// What the split recorded — folding, undo and undo mismatch guards are the
|
|
34
|
+
// server's own tested behaviour (projectHistory.ts / projectHistory.test.ts,
|
|
35
|
+
// usePersistentEditHistory.test.ts), not re-proven here via a reducer.
|
|
36
|
+
records: RecordEditInput[];
|
|
40
37
|
splitRef: { current: Split | undefined };
|
|
41
38
|
root: ReturnType<typeof mountProbe>;
|
|
42
39
|
expected: string;
|
|
@@ -53,18 +50,7 @@ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}):
|
|
|
53
50
|
const disk: Record<string, string> = { "index.html": ORIGINAL };
|
|
54
51
|
const finalContent = opts.gsap ? SPLIT_GSAP : SPLIT;
|
|
55
52
|
const previewWrites: string[] = [];
|
|
56
|
-
|
|
57
|
-
const storage = createMemoryEditHistoryStorage();
|
|
58
|
-
const store = createPersistentEditHistoryStore({
|
|
59
|
-
projectId: "p1",
|
|
60
|
-
storage,
|
|
61
|
-
initialState: createEmptyEditHistory(),
|
|
62
|
-
now: (() => {
|
|
63
|
-
let t = 1000;
|
|
64
|
-
return () => (t += 10);
|
|
65
|
-
})(),
|
|
66
|
-
onChange: () => {},
|
|
67
|
-
});
|
|
53
|
+
const records: RecordEditInput[] = [];
|
|
68
54
|
|
|
69
55
|
// Faithful stand-in for the atomic server cut: one forward file write and one
|
|
70
56
|
// response carrying the canonical history snapshots.
|
|
@@ -115,7 +101,9 @@ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}):
|
|
|
115
101
|
writeProjectFile: async (path, content) => {
|
|
116
102
|
disk[path] = content;
|
|
117
103
|
},
|
|
118
|
-
recordEdit:
|
|
104
|
+
recordEdit: async (input) => {
|
|
105
|
+
records.push(input);
|
|
106
|
+
},
|
|
119
107
|
reloadPreview: () => {
|
|
120
108
|
if (!opts.previewStamp) return;
|
|
121
109
|
const stamped = ensureHfIds(disk["index.html"]);
|
|
@@ -134,16 +122,7 @@ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}):
|
|
|
134
122
|
|
|
135
123
|
const root = mountProbe(Component);
|
|
136
124
|
|
|
137
|
-
return { disk,
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
async function undoViaDisk(harness: Pick<Harness, "disk" | "store">) {
|
|
141
|
-
return harness.store.undo({
|
|
142
|
-
readFile: async (path) => harness.disk[path],
|
|
143
|
-
writeFile: async (path, content) => {
|
|
144
|
-
harness.disk[path] = content;
|
|
145
|
-
},
|
|
146
|
-
});
|
|
125
|
+
return { disk, records, splitRef, root, expected: finalContent, previewWrites };
|
|
147
126
|
}
|
|
148
127
|
|
|
149
128
|
afterEach(() => {
|
|
@@ -159,15 +138,16 @@ describe("useRazorSplit — split is undoable via edit history", () => {
|
|
|
159
138
|
await harness.splitRef.current!(element, 2);
|
|
160
139
|
});
|
|
161
140
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const currentHash = hashEditHistoryContent(harness.disk["index.html"]);
|
|
165
|
-
expect(entry.files["index.html"].afterHash).toBe(currentHash);
|
|
166
|
-
|
|
167
|
-
const undo = undoEditHistory(snapshot, { "index.html": currentHash }, 2000);
|
|
168
|
-
expect(undo.ok).toBe(true);
|
|
169
|
-
expect(undo.filesToWrite).toEqual({ "index.html": ORIGINAL });
|
|
141
|
+
// The preview reload found every id already stamped, so it never made a
|
|
142
|
+
// second, unrecorded write that would leave the recorded entry stale...
|
|
170
143
|
expect(harness.previewWrites).toHaveLength(0);
|
|
144
|
+
// ...meaning the split's own recorded "after" IS the disk's final content,
|
|
145
|
+
// and undoing it (server-side) would restore exactly the pre-split bytes.
|
|
146
|
+
expect(harness.records).toHaveLength(1);
|
|
147
|
+
expect(harness.records[0]!.files["index.html"]).toEqual({
|
|
148
|
+
before: ORIGINAL,
|
|
149
|
+
after: harness.disk["index.html"],
|
|
150
|
+
});
|
|
171
151
|
|
|
172
152
|
act(() => harness.root.unmount());
|
|
173
153
|
});
|
|
@@ -182,7 +162,7 @@ describe("useRazorSplit — split is undoable via edit history", () => {
|
|
|
182
162
|
act(() => harness.root.unmount());
|
|
183
163
|
});
|
|
184
164
|
|
|
185
|
-
it("records a single 'Split timeline clip'
|
|
165
|
+
it("records a single 'Split timeline clip' entry with the exact bytes undo restores", async () => {
|
|
186
166
|
await act(async () => {
|
|
187
167
|
await harness.splitRef.current!(element, 2);
|
|
188
168
|
});
|
|
@@ -190,15 +170,16 @@ describe("useRazorSplit — split is undoable via edit history", () => {
|
|
|
190
170
|
// The split reached disk.
|
|
191
171
|
expect(harness.disk["index.html"]).toBe(harness.expected);
|
|
192
172
|
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
expect(harness.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
expect(
|
|
200
|
-
|
|
201
|
-
|
|
173
|
+
// Exactly one entry, correctly labeled, carrying the pre/post-split bytes —
|
|
174
|
+
// what a real undo (server-side, projectHistory.ts) restores from.
|
|
175
|
+
expect(harness.records).toHaveLength(1);
|
|
176
|
+
expect(harness.records[0]).toMatchObject({
|
|
177
|
+
label: "Split timeline clip",
|
|
178
|
+
});
|
|
179
|
+
expect(harness.records[0]!.files["index.html"]).toEqual({
|
|
180
|
+
before: ORIGINAL,
|
|
181
|
+
after: harness.expected,
|
|
182
|
+
});
|
|
202
183
|
});
|
|
203
184
|
});
|
|
204
185
|
}
|
|
@@ -222,20 +203,14 @@ const batchElements: TimelineElement[] = [
|
|
|
222
203
|
|
|
223
204
|
interface SplitAllHarness {
|
|
224
205
|
disk: Record<string, string>;
|
|
225
|
-
|
|
206
|
+
records: RecordEditInput[];
|
|
226
207
|
splitAllRef: { current: SplitAll | undefined };
|
|
227
208
|
root: ReturnType<typeof mountProbe>;
|
|
228
209
|
}
|
|
229
210
|
|
|
230
211
|
function mountRazorSplitAll(failOnSplit?: number): SplitAllHarness {
|
|
231
212
|
const disk: Record<string, string> = { ...BATCH_ORIGINALS };
|
|
232
|
-
const
|
|
233
|
-
projectId: "p1",
|
|
234
|
-
storage: createMemoryEditHistoryStorage(),
|
|
235
|
-
initialState: createEmptyEditHistory(),
|
|
236
|
-
now: () => 1000,
|
|
237
|
-
onChange: () => {},
|
|
238
|
-
});
|
|
213
|
+
const records: RecordEditInput[] = [];
|
|
239
214
|
let splitCount = 0;
|
|
240
215
|
|
|
241
216
|
vi.stubGlobal(
|
|
@@ -255,7 +230,9 @@ function mountRazorSplitAll(failOnSplit?: number): SplitAllHarness {
|
|
|
255
230
|
writeProjectFile: async (path, content) => {
|
|
256
231
|
disk[path] = content;
|
|
257
232
|
},
|
|
258
|
-
recordEdit:
|
|
233
|
+
recordEdit: async (input) => {
|
|
234
|
+
records.push(input);
|
|
235
|
+
},
|
|
259
236
|
reloadPreview: () => {},
|
|
260
237
|
});
|
|
261
238
|
splitAllRef.current = handleRazorSplitAll;
|
|
@@ -264,7 +241,7 @@ function mountRazorSplitAll(failOnSplit?: number): SplitAllHarness {
|
|
|
264
241
|
|
|
265
242
|
const root = mountProbe(Component);
|
|
266
243
|
usePlayerStore.setState({ elements: batchElements });
|
|
267
|
-
return { disk,
|
|
244
|
+
return { disk, records, splitAllRef, root };
|
|
268
245
|
}
|
|
269
246
|
|
|
270
247
|
describe("useRazorSplit — split-all batch history", () => {
|
|
@@ -280,22 +257,24 @@ describe("useRazorSplit — split-all batch history", () => {
|
|
|
280
257
|
return harness;
|
|
281
258
|
}
|
|
282
259
|
|
|
283
|
-
it("records one
|
|
260
|
+
it("records one entry whose files cover every split file (one undo restores all of them)", async () => {
|
|
284
261
|
const harness = await runSplitAll();
|
|
285
262
|
|
|
286
|
-
expect(harness.
|
|
287
|
-
const
|
|
288
|
-
expect(
|
|
289
|
-
|
|
290
|
-
|
|
263
|
+
expect(harness.records).toHaveLength(1);
|
|
264
|
+
const files = harness.records[0]!.files;
|
|
265
|
+
expect(Object.keys(files).sort()).toEqual(Object.keys(BATCH_ORIGINALS).sort());
|
|
266
|
+
for (const path of Object.keys(BATCH_ORIGINALS) as (keyof typeof BATCH_ORIGINALS)[]) {
|
|
267
|
+
expect(files[path]?.before).toBe(BATCH_ORIGINALS[path]);
|
|
268
|
+
expect(files[path]?.after).toBe(harness.disk[path]);
|
|
269
|
+
}
|
|
291
270
|
act(() => harness.root.unmount());
|
|
292
271
|
});
|
|
293
272
|
|
|
294
|
-
it("restores completed writes and records no
|
|
273
|
+
it("restores completed writes and records no entry when a later split fails", async () => {
|
|
295
274
|
const harness = await runSplitAll(2);
|
|
296
275
|
|
|
297
276
|
expect(harness.disk).toEqual(BATCH_ORIGINALS);
|
|
298
|
-
expect(harness.
|
|
277
|
+
expect(harness.records).toHaveLength(0);
|
|
299
278
|
act(() => harness.root.unmount());
|
|
300
279
|
});
|
|
301
280
|
});
|
|
@@ -5,9 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
5
5
|
import type { TimelineElement } from "../player";
|
|
6
6
|
import { usePlayerStore } from "../player";
|
|
7
7
|
import { useRazorSplit } from "./useRazorSplit";
|
|
8
|
-
import {
|
|
9
|
-
import { createEmptyEditHistory } from "../utils/editHistory";
|
|
10
|
-
import type { EditHistoryStorageAdapter } from "../utils/editHistoryStorage";
|
|
8
|
+
import type { RecordEditInput } from "./timelineEditingHelpers";
|
|
11
9
|
import { createSplitFetchMock, mountProbe } from "./useRazorSplit.testHelpers";
|
|
12
10
|
|
|
13
11
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
@@ -164,20 +162,14 @@ describe("useRazorSplit — sub-comp coordinate rebasing", () => {
|
|
|
164
162
|
|
|
165
163
|
// ── Bug 1: split must resync the SDK session so undo isn't refused ────────────
|
|
166
164
|
|
|
167
|
-
const memoryStorage = (): EditHistoryStorageAdapter => {
|
|
168
|
-
const store = new Map<string, string>();
|
|
169
|
-
return {
|
|
170
|
-
load: async (k) => store.get(k) ?? null,
|
|
171
|
-
save: async (k, v) => {
|
|
172
|
-
store.set(k, v);
|
|
173
|
-
},
|
|
174
|
-
} as unknown as EditHistoryStorageAdapter;
|
|
175
|
-
};
|
|
176
|
-
|
|
177
165
|
interface UndoHarness {
|
|
178
166
|
singleRef: { current: SingleSplit | undefined };
|
|
179
167
|
disk: Record<string, string>;
|
|
180
|
-
|
|
168
|
+
// What the split recorded, so the server (projectHistory.ts) could fold and
|
|
169
|
+
// later undo it — recording, folding and undo mismatch guards are the
|
|
170
|
+
// server's own tested behaviour (projectHistory.test.ts,
|
|
171
|
+
// usePersistentEditHistory.test.ts), not re-proven here via a reducer.
|
|
172
|
+
records: RecordEditInput[];
|
|
181
173
|
forceReloadSdkSession: ReturnType<typeof vi.fn>;
|
|
182
174
|
root: ReturnType<typeof mountProbe>;
|
|
183
175
|
}
|
|
@@ -186,13 +178,7 @@ function mountRazorSplitWithHistory(): UndoHarness {
|
|
|
186
178
|
const disk: Record<string, string> = {
|
|
187
179
|
[ROOT_FILE]: `<div class="clip" id="root-clip" data-start="0" data-duration="10"></div>`,
|
|
188
180
|
};
|
|
189
|
-
const
|
|
190
|
-
projectId: "p1",
|
|
191
|
-
storage: memoryStorage(),
|
|
192
|
-
initialState: createEmptyEditHistory(),
|
|
193
|
-
now: () => Date.now(),
|
|
194
|
-
onChange: () => {},
|
|
195
|
-
});
|
|
181
|
+
const records: RecordEditInput[] = [];
|
|
196
182
|
const forceReloadSdkSession = vi.fn();
|
|
197
183
|
|
|
198
184
|
const fetchMock = createSplitFetchMock(disk);
|
|
@@ -207,7 +193,9 @@ function mountRazorSplitWithHistory(): UndoHarness {
|
|
|
207
193
|
writeProjectFile: async (path, content) => {
|
|
208
194
|
disk[path] = content;
|
|
209
195
|
},
|
|
210
|
-
recordEdit: (input) =>
|
|
196
|
+
recordEdit: async (input) => {
|
|
197
|
+
records.push(input);
|
|
198
|
+
},
|
|
211
199
|
reloadPreview: () => {},
|
|
212
200
|
forceReloadSdkSession,
|
|
213
201
|
});
|
|
@@ -215,7 +203,7 @@ function mountRazorSplitWithHistory(): UndoHarness {
|
|
|
215
203
|
return null;
|
|
216
204
|
}
|
|
217
205
|
const root = mountProbe(Component);
|
|
218
|
-
return { singleRef, disk,
|
|
206
|
+
return { singleRef, disk, records, forceReloadSdkSession, root };
|
|
219
207
|
}
|
|
220
208
|
|
|
221
209
|
describe("useRazorSplit — undo integrity after split (Bug 1)", () => {
|
|
@@ -227,13 +215,6 @@ describe("useRazorSplit — undo integrity after split (Bug 1)", () => {
|
|
|
227
215
|
act(() => h.root.unmount());
|
|
228
216
|
});
|
|
229
217
|
|
|
230
|
-
const readFile = () => ({
|
|
231
|
-
readFile: async (p: string) => h.disk[p],
|
|
232
|
-
writeFile: async (p: string, c: string) => {
|
|
233
|
-
h.disk[p] = c;
|
|
234
|
-
},
|
|
235
|
-
});
|
|
236
|
-
|
|
237
218
|
it("resyncs the SDK session after a split (matches every other server-write path)", async () => {
|
|
238
219
|
await act(async () => {
|
|
239
220
|
await h.singleRef.current!(rootElement, 4);
|
|
@@ -241,25 +222,14 @@ describe("useRazorSplit — undo integrity after split (Bug 1)", () => {
|
|
|
241
222
|
expect(h.forceReloadSdkSession).toHaveBeenCalledTimes(1);
|
|
242
223
|
});
|
|
243
224
|
|
|
244
|
-
it("
|
|
245
|
-
|
|
246
|
-
await h.singleRef.current!(rootElement, 4);
|
|
247
|
-
});
|
|
248
|
-
const result = await h.store.undo(readFile());
|
|
249
|
-
expect(result.ok).toBe(true);
|
|
250
|
-
expect(result.reason).toBeUndefined();
|
|
251
|
-
// The file is restored to its pre-split bytes.
|
|
252
|
-
expect(h.disk[ROOT_FILE]).not.toContain("<!--split-->");
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
it("still trips the guard when the file is edited externally after a split", async () => {
|
|
225
|
+
it("records the split as one entry whose file carries the exact pre/post-split bytes a real undo restores", async () => {
|
|
226
|
+
const before = h.disk[ROOT_FILE];
|
|
256
227
|
await act(async () => {
|
|
257
228
|
await h.singleRef.current!(rootElement, 4);
|
|
258
229
|
});
|
|
259
|
-
|
|
260
|
-
h.
|
|
261
|
-
|
|
262
|
-
expect(
|
|
263
|
-
expect(result.reason).toBe("content-mismatch");
|
|
230
|
+
expect(h.disk[ROOT_FILE]).toContain("<!--split-->");
|
|
231
|
+
expect(h.records).toHaveLength(1);
|
|
232
|
+
expect(h.records[0]).toMatchObject({ label: "Split timeline clip" });
|
|
233
|
+
expect(h.records[0]!.files[ROOT_FILE]).toEqual({ before, after: h.disk[ROOT_FILE] });
|
|
264
234
|
});
|
|
265
235
|
});
|
|
@@ -7,6 +7,8 @@ import { CompositionThumbnail, VideoThumbnail } from "../player";
|
|
|
7
7
|
import { AudioWaveform } from "../player/components/AudioWaveform";
|
|
8
8
|
import type { TimelineClipRenderContext } from "../player/components/TimelineTypes";
|
|
9
9
|
import { usePlayerStore, type TimelineElement } from "../player/store/playerStore";
|
|
10
|
+
import { buildCompositionThumbnailUrl } from "../player/components/CompositionThumbnail";
|
|
11
|
+
import { compositionCardThumbnailUrl } from "../components/sidebar/CompositionsTab";
|
|
10
12
|
import { normalizeCompositionSrc } from "./useRenderClipContent";
|
|
11
13
|
import { useRenderClipContent } from "./useRenderClipContent";
|
|
12
14
|
|
|
@@ -316,4 +318,31 @@ describe("useRenderClipContent", () => {
|
|
|
316
318
|
});
|
|
317
319
|
}
|
|
318
320
|
});
|
|
321
|
+
|
|
322
|
+
it.each(["compositions/scene-0.html", "compositions/scene [v2].html", "compositions/100%.html"])(
|
|
323
|
+
"asks for the same thumbnail as the card of %s, so one render serves both",
|
|
324
|
+
(compositionSrc) => {
|
|
325
|
+
usePlayerStore.setState({
|
|
326
|
+
thumbnailMode: "adaptive",
|
|
327
|
+
thumbnailRevisions: { [compositionSrc]: 3 },
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const content = renderClipContent({
|
|
331
|
+
id: "scene-0",
|
|
332
|
+
tag: "div",
|
|
333
|
+
start: 10,
|
|
334
|
+
duration: 5,
|
|
335
|
+
track: 0,
|
|
336
|
+
compositionSrc,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
expect(isValidElement(content)).toBe(true);
|
|
340
|
+
if (!isValidElement(content)) return;
|
|
341
|
+
const clipUrl = buildCompositionThumbnailUrl({
|
|
342
|
+
...(content.props as Parameters<typeof buildCompositionThumbnailUrl>[0]),
|
|
343
|
+
origin: window.location.origin,
|
|
344
|
+
});
|
|
345
|
+
expect(clipUrl).toBe(compositionCardThumbnailUrl("my-project", compositionSrc, 3));
|
|
346
|
+
},
|
|
347
|
+
);
|
|
319
348
|
});
|
|
@@ -2,7 +2,10 @@ import { buildProjectApiPath } from "../utils/projectRouting";
|
|
|
2
2
|
import { useCallback, type ReactNode } from "react";
|
|
3
3
|
import { createElement } from "react";
|
|
4
4
|
import { CompositionThumbnail, VideoThumbnail } from "../player";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
compositionPathOfPreviewUrl,
|
|
7
|
+
resolveThumbnailSeekTime,
|
|
8
|
+
} from "../player/components/CompositionThumbnail";
|
|
6
9
|
import type { TimelineElement } from "../player";
|
|
7
10
|
import type { TimelineClipRenderContext } from "../player/components/TimelineTypes";
|
|
8
11
|
import { audioPillFlags } from "../player/components/audioClipLink";
|
|
@@ -157,8 +160,8 @@ export function useRenderClipContent({
|
|
|
157
160
|
label: "",
|
|
158
161
|
labelColor: style.label,
|
|
159
162
|
|
|
160
|
-
seekTime:
|
|
161
|
-
duration:
|
|
163
|
+
seekTime: resolveThumbnailSeekTime(el.duration),
|
|
164
|
+
duration: 0,
|
|
162
165
|
projectId: pid,
|
|
163
166
|
sessionEpoch,
|
|
164
167
|
contentRevision: thumbnailRevisionOf(thumbnailRevisions, compSrc),
|
|
@@ -12,6 +12,7 @@ vi.mock("@hyperframes/sdk", () => ({
|
|
|
12
12
|
|
|
13
13
|
import type { Composition } from "@hyperframes/sdk";
|
|
14
14
|
import { useSdkSession, type SdkSessionHandle } from "./useSdkSession";
|
|
15
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
15
16
|
|
|
16
17
|
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
17
18
|
|
|
@@ -19,6 +20,10 @@ import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
|
19
20
|
|
|
20
21
|
const trackMock = vi.mocked(trackStudioEvent);
|
|
21
22
|
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
usePlayerStore.setState({ timelineProjectId: "project-a", previewBooted: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
22
27
|
function Probe({ projectId }: { projectId: string }) {
|
|
23
28
|
useSdkSession(projectId, "index.html");
|
|
24
29
|
return null;
|
|
@@ -54,6 +59,24 @@ describe("useSdkSession ownership", () => {
|
|
|
54
59
|
vi.unstubAllGlobals();
|
|
55
60
|
});
|
|
56
61
|
|
|
62
|
+
it("does not read or parse the composition until the live preview has booted", async () => {
|
|
63
|
+
usePlayerStore.setState({ previewBooted: false });
|
|
64
|
+
const fetchStub = vi.fn(async () => response("PROJECT_A"));
|
|
65
|
+
vi.stubGlobal("fetch", fetchStub);
|
|
66
|
+
openComposition.mockImplementation(async () => fakeSession());
|
|
67
|
+
|
|
68
|
+
const root = createRoot(document.createElement("div"));
|
|
69
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
70
|
+
await flushAsyncEffects();
|
|
71
|
+
expect(fetchStub).not.toHaveBeenCalled();
|
|
72
|
+
expect(openComposition).not.toHaveBeenCalled();
|
|
73
|
+
|
|
74
|
+
await act(async () => usePlayerStore.getState().markPreviewBooted());
|
|
75
|
+
await flushAsyncEffects();
|
|
76
|
+
expect(openComposition).toHaveBeenCalledWith("PROJECT_A", { history: false });
|
|
77
|
+
act(() => root.unmount());
|
|
78
|
+
});
|
|
79
|
+
|
|
57
80
|
it("hides project A immediately while project B with the same path is still opening", async () => {
|
|
58
81
|
const sessionA = fakeSession();
|
|
59
82
|
const publishedA = fakeSession();
|
|
@@ -94,7 +117,11 @@ describe("useSdkSession ownership", () => {
|
|
|
94
117
|
expect(publication).toBe("published");
|
|
95
118
|
expect(captured.handle?.session).toBe(publishedA);
|
|
96
119
|
|
|
97
|
-
await act(async () =>
|
|
120
|
+
await act(async () => {
|
|
121
|
+
usePlayerStore.getState().beginTimelineSession("project-b");
|
|
122
|
+
usePlayerStore.getState().markPreviewBooted();
|
|
123
|
+
root.render(<Probe projectId="project-b" />);
|
|
124
|
+
});
|
|
98
125
|
expect(captured.handle?.session).toBeNull();
|
|
99
126
|
expect(publishedA.dispose).toHaveBeenCalledOnce();
|
|
100
127
|
expect(
|
|
@@ -164,6 +191,7 @@ describe("useSdkSession unreachable project", () => {
|
|
|
164
191
|
});
|
|
165
192
|
|
|
166
193
|
function probeHandle(projectId: string) {
|
|
194
|
+
usePlayerStore.setState({ timelineProjectId: projectId });
|
|
167
195
|
const captured: { handle: SdkSessionHandle | null } = { handle: null };
|
|
168
196
|
function HandleProbe() {
|
|
169
197
|
captured.handle = useSdkSession(projectId, "index.html");
|
|
@@ -684,11 +712,13 @@ describe("useSdkSession unavailable telemetry", () => {
|
|
|
684
712
|
),
|
|
685
713
|
);
|
|
686
714
|
await flushAsyncEffects();
|
|
687
|
-
await act(async () =>
|
|
715
|
+
await act(async () => {
|
|
716
|
+
usePlayerStore.getState().beginTimelineSession("project-b");
|
|
717
|
+
usePlayerStore.getState().markPreviewBooted();
|
|
688
718
|
root.render(
|
|
689
719
|
<HandleProbe projectId="project-b" path="index.html" onAbsentRead={onAbsentRead} />,
|
|
690
|
-
)
|
|
691
|
-
);
|
|
720
|
+
);
|
|
721
|
+
});
|
|
692
722
|
await flushAsyncEffects();
|
|
693
723
|
|
|
694
724
|
expect(onAbsentRead).toHaveBeenCalledTimes(2);
|
|
@@ -5,6 +5,7 @@ import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
|
|
|
5
5
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
6
6
|
import type { PublishSdkSession } from "../utils/sdkCutover";
|
|
7
7
|
import { addExternalFileReloadListener } from "./externalFileReloadBus";
|
|
8
|
+
import { whenPreviewBooted } from "../player/store/playerStore";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Why an optional project-file read produced no usable content. `stage: "read"`
|
|
@@ -439,9 +440,14 @@ export function useSdkSession(
|
|
|
439
440
|
generation,
|
|
440
441
|
};
|
|
441
442
|
|
|
442
|
-
|
|
443
|
+
// Parsing the source for editing takes the main thread for hundreds of ms on a large film;
|
|
444
|
+
// the first frame and play do not need it, so it waits for the live preview to boot.
|
|
445
|
+
whenPreviewBooted(projectId)
|
|
446
|
+
.then((booted) =>
|
|
447
|
+
cancelled || !booted ? null : readProjectFileOptional(projectId, activeCompPath),
|
|
448
|
+
)
|
|
443
449
|
.then(async (read) => {
|
|
444
|
-
if (cancelled) return;
|
|
450
|
+
if (!read || cancelled) return;
|
|
445
451
|
if (!read.ok) {
|
|
446
452
|
handleReadFailure(read, projectId, activeCompPath);
|
|
447
453
|
return;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import type { Composition } from "@hyperframes/sdk";
|
|
3
3
|
import type { SlideshowManifest } from "@hyperframes/core/slideshow";
|
|
4
|
-
import type { EditHistoryKind } from "../utils/editHistory";
|
|
5
4
|
import type { PublishSdkSession } from "../utils/sdkCutover";
|
|
6
5
|
import { persistSlideshowManifest } from "../utils/setSlideshowManifest";
|
|
7
6
|
|
|
@@ -12,7 +11,6 @@ export interface UseSlideshowPersistParams {
|
|
|
12
11
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
13
12
|
recordEdit: (entry: {
|
|
14
13
|
label: string;
|
|
15
|
-
kind: EditHistoryKind;
|
|
16
14
|
files: Record<string, { before: string; after: string }>;
|
|
17
15
|
}) => Promise<void>;
|
|
18
16
|
reloadPreview: () => void;
|
|
@@ -17,7 +17,12 @@ interface StudioContextInput {
|
|
|
17
17
|
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
|
|
18
18
|
timelineElements: StudioContextValue["timelineElements"];
|
|
19
19
|
isPlaying: boolean;
|
|
20
|
-
editHistory: {
|
|
20
|
+
editHistory: {
|
|
21
|
+
canUndo: boolean;
|
|
22
|
+
canRedo: boolean;
|
|
23
|
+
undoLabel: string | undefined;
|
|
24
|
+
redoLabel: string | undefined;
|
|
25
|
+
};
|
|
21
26
|
handleUndo: StudioContextValue["handleUndo"];
|
|
22
27
|
handleRedo: StudioContextValue["handleRedo"];
|
|
23
28
|
// Was a second copy of the same shape, which meant every field added to the
|
|
@@ -166,7 +166,6 @@ export function useTimelineAssetDropOps({
|
|
|
166
166
|
await saveProjectFilesWithHistory({
|
|
167
167
|
projectId: pid,
|
|
168
168
|
label: "Add timeline asset",
|
|
169
|
-
kind: "timeline",
|
|
170
169
|
files: { [targetPath]: patchedContent },
|
|
171
170
|
readFile: async () => originalContent,
|
|
172
171
|
writeFile: writeProjectFile,
|
|
@@ -157,7 +157,6 @@ export function useTimelineDeleteOps({
|
|
|
157
157
|
await saveProjectFilesWithHistory({
|
|
158
158
|
projectId: pid,
|
|
159
159
|
label: deleteHistoryLabel,
|
|
160
|
-
kind: "timeline",
|
|
161
160
|
coalesceKey,
|
|
162
161
|
files: { [targetPath]: patchedContent },
|
|
163
162
|
readFile: async () => originalContent,
|
|
@@ -112,7 +112,6 @@ function renderTimelineEditingHook(input: {
|
|
|
112
112
|
writeProjectFile?: (path: string, content: string) => Promise<void>;
|
|
113
113
|
recordEdit?: (input: {
|
|
114
114
|
label: string;
|
|
115
|
-
kind: string;
|
|
116
115
|
coalesceKey?: string;
|
|
117
116
|
files: Record<string, { before: string; after: string }>;
|
|
118
117
|
}) => Promise<void>;
|
|
@@ -2,13 +2,11 @@ import type { MutableRefObject, RefObject } from "react";
|
|
|
2
2
|
import type { Composition } from "@hyperframes/sdk";
|
|
3
3
|
import type { TimelineElement } from "../player";
|
|
4
4
|
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
|
|
5
|
-
import type { EditHistoryKind } from "../utils/editHistory";
|
|
6
5
|
import type { PublishSdkSession } from "../utils/sdkCutover";
|
|
7
6
|
import type { CanEditTimelineElement } from "./timelineEditPermission";
|
|
8
7
|
|
|
9
8
|
interface RecordEditInput {
|
|
10
9
|
label: string;
|
|
11
|
-
kind: EditHistoryKind;
|
|
12
10
|
coalesceKey?: string;
|
|
13
11
|
files: Record<string, { before: string; after: string }>;
|
|
14
12
|
}
|