@hyperframes/studio 0.8.53 → 0.8.54
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-DKWM-Ons.js → hyperframes-player-Cy_AwUa8.js} +1 -1
- package/dist/assets/{index-UvW6BEwf.js → index-CTa-6Kso.js} +1 -1
- package/dist/assets/{index-HfP3sap2.js → index-DPUJoO8n.js} +191 -191
- package/dist/assets/{index-Y4OnpTEM.js → index-DkAzKuPZ.js} +1 -1
- package/dist/index.d.ts +440 -10
- package/dist/index.html +1 -1
- package/dist/index.js +1850 -1532
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/nle/TimelinePane.tsx +11 -1
- package/src/hooks/timelineAudioGroupCreate.ts +17 -1
- package/src/hooks/timelineAudioGroupVolume.ts +35 -2
- package/src/hooks/timelineEditPermission.ts +31 -0
- package/src/hooks/timelineElementFxAttribute.ts +34 -2
- package/src/hooks/useFileManager.ts +21 -123
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/useProjectFileWriter.ts +150 -0
- package/src/hooks/useTimelineAssetDropOps.test.tsx +62 -4
- package/src/hooks/useTimelineAssetDropOps.ts +40 -3
- package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
- package/src/hooks/useTimelineEditing.test.tsx +481 -4
- package/src/hooks/useTimelineEditing.ts +151 -27
- package/src/hooks/useTimelineEditingTypes.ts +3 -0
- package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
- package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
- package/src/hostMount.test.tsx +222 -0
- package/src/index.ts +40 -2
- package/src/player/components/Timeline.tsx +6 -6
- package/src/player/components/TimelineTypes.ts +3 -0
- package/src/player/components/usePublishRangeSelection.test.tsx +60 -0
- package/src/player/components/usePublishRangeSelection.ts +42 -0
- package/src/player/index.ts +1 -0
- package/src/player/store/playerStore.ts +8 -51
- package/src/player/store/rangeSelectionSlice.ts +13 -0
- package/src/player/store/timelineResetState.ts +51 -0
|
@@ -16,19 +16,29 @@ afterEach(() => {
|
|
|
16
16
|
});
|
|
17
17
|
|
|
18
18
|
type DropFn = ReturnType<typeof useTimelineAssetDropOps>["handleTimelineAssetDrop"];
|
|
19
|
+
type FileDropFn = ReturnType<typeof useTimelineAssetDropOps>["handleTimelineFileDrop"];
|
|
20
|
+
type CompositionDropFn = ReturnType<
|
|
21
|
+
typeof useTimelineAssetDropOps
|
|
22
|
+
>["handleTimelineCompositionDrop"];
|
|
19
23
|
|
|
20
24
|
function renderDropHook(
|
|
21
25
|
sourceContent: string,
|
|
22
26
|
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
|
23
27
|
timelineElements: TimelineElement[] = [],
|
|
28
|
+
checkEditable?: (targets: readonly TimelineElement[]) => boolean,
|
|
29
|
+
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]> = vi
|
|
30
|
+
.fn()
|
|
31
|
+
.mockResolvedValue([]),
|
|
24
32
|
) {
|
|
25
33
|
vi.stubGlobal(
|
|
26
34
|
"fetch",
|
|
27
35
|
vi.fn().mockResolvedValue({ ok: true, json: async () => ({ content: sourceContent }) }),
|
|
28
36
|
);
|
|
29
37
|
let drop: DropFn | null = null;
|
|
38
|
+
let fileDrop: FileDropFn | null = null;
|
|
39
|
+
let compositionDrop: CompositionDropFn | null = null;
|
|
30
40
|
function Harness() {
|
|
31
|
-
const
|
|
41
|
+
const handlers = useTimelineAssetDropOps({
|
|
32
42
|
projectIdRef: { current: "project" },
|
|
33
43
|
activeCompPath: "index.html",
|
|
34
44
|
timelineElements,
|
|
@@ -36,13 +46,19 @@ function renderDropHook(
|
|
|
36
46
|
writeProjectFile,
|
|
37
47
|
recordEdit: vi.fn().mockResolvedValue(undefined),
|
|
38
48
|
reloadPreview: vi.fn(),
|
|
39
|
-
uploadProjectFiles
|
|
49
|
+
uploadProjectFiles,
|
|
50
|
+
checkEditable,
|
|
40
51
|
});
|
|
41
|
-
drop = handleTimelineAssetDrop;
|
|
52
|
+
drop = handlers.handleTimelineAssetDrop;
|
|
53
|
+
fileDrop = handlers.handleTimelineFileDrop;
|
|
54
|
+
compositionDrop = handlers.handleTimelineCompositionDrop;
|
|
42
55
|
return null;
|
|
43
56
|
}
|
|
44
57
|
mountReactHarness(<Harness />);
|
|
45
|
-
return () => drop
|
|
58
|
+
return Object.assign(() => drop!, {
|
|
59
|
+
file: () => fileDrop!,
|
|
60
|
+
composition: () => compositionDrop!,
|
|
61
|
+
});
|
|
46
62
|
}
|
|
47
63
|
|
|
48
64
|
describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
|
|
@@ -124,4 +140,46 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
|
|
|
124
140
|
expect(written).toContain('id="hidden" data-start="0" data-track-index="1"');
|
|
125
141
|
expect(written).toMatch(/<video id="clip"[^>]*data-track-index="1"/);
|
|
126
142
|
});
|
|
143
|
+
|
|
144
|
+
it("refuses an asset drop before reading or writing when editing is blocked", async () => {
|
|
145
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
146
|
+
const checkEditable = vi.fn(() => false);
|
|
147
|
+
const getDrop = renderDropHook(
|
|
148
|
+
'<main data-composition-id="scene" data-duration="10"></main>',
|
|
149
|
+
writeProjectFile,
|
|
150
|
+
[],
|
|
151
|
+
checkEditable,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
await act(async () => {
|
|
155
|
+
await getDrop()("clip.mp4", { start: 1, track: 0 }, 2);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
159
|
+
expect(checkEditable).toHaveBeenCalledWith([
|
|
160
|
+
expect.objectContaining({ sourceFile: "index.html", start: 1, track: 0 }),
|
|
161
|
+
]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("refuses file and composition drops before their writes when editing is blocked", async () => {
|
|
165
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
166
|
+
const uploadProjectFiles = vi.fn().mockResolvedValue(["clip.mp4"]);
|
|
167
|
+
const getDrop = renderDropHook(
|
|
168
|
+
'<main data-composition-id="scene" data-duration="10"></main>',
|
|
169
|
+
writeProjectFile,
|
|
170
|
+
[],
|
|
171
|
+
() => false,
|
|
172
|
+
uploadProjectFiles,
|
|
173
|
+
);
|
|
174
|
+
const fetchMock = vi.mocked(globalThis.fetch);
|
|
175
|
+
|
|
176
|
+
await act(async () => {
|
|
177
|
+
await getDrop.file()([new File(["clip"], "clip.mp4")], { start: 1, track: 0 });
|
|
178
|
+
await getDrop.composition()("scene.html", { start: 1, track: 0 });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(uploadProjectFiles).not.toHaveBeenCalled();
|
|
182
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
183
|
+
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
184
|
+
});
|
|
127
185
|
});
|
|
@@ -38,6 +38,20 @@ function fileDropPlacement(
|
|
|
38
38
|
return index === 0 ? { ...dropped, ...next } : { ...next, track: landedTrack ?? next.track };
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function timelineDropTarget(
|
|
42
|
+
sourceFile: string,
|
|
43
|
+
placement: Pick<TimelineElement, "start" | "track">,
|
|
44
|
+
): TimelineElement {
|
|
45
|
+
return {
|
|
46
|
+
id: "timeline-drop",
|
|
47
|
+
tag: "div",
|
|
48
|
+
start: placement.start,
|
|
49
|
+
duration: 0,
|
|
50
|
+
track: placement.track,
|
|
51
|
+
sourceFile,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
41
55
|
interface UseTimelineAssetDropOpsOptions {
|
|
42
56
|
projectIdRef: MutableRefObject<string | null>;
|
|
43
57
|
activeCompPath: string | null;
|
|
@@ -50,6 +64,7 @@ interface UseTimelineAssetDropOpsOptions {
|
|
|
50
64
|
isRecordingRef?: RefObject<boolean>;
|
|
51
65
|
forceReloadSdkSession?: () => void;
|
|
52
66
|
observeProjectFileVersion?: (path: string, version: string | null) => void;
|
|
67
|
+
checkEditable?: (targets: readonly TimelineElement[]) => boolean;
|
|
53
68
|
}
|
|
54
69
|
|
|
55
70
|
export function useTimelineAssetDropOps({
|
|
@@ -64,6 +79,7 @@ export function useTimelineAssetDropOps({
|
|
|
64
79
|
isRecordingRef,
|
|
65
80
|
forceReloadSdkSession,
|
|
66
81
|
observeProjectFileVersion,
|
|
82
|
+
checkEditable,
|
|
67
83
|
}: UseTimelineAssetDropOpsOptions) {
|
|
68
84
|
// fallow-ignore-next-line complexity
|
|
69
85
|
const dropAssetAt = useCallback(
|
|
@@ -77,6 +93,10 @@ export function useTimelineAssetDropOps({
|
|
|
77
93
|
showToast("Cannot edit timeline while recording", "error");
|
|
78
94
|
return undefined;
|
|
79
95
|
}
|
|
96
|
+
const targetPath = activeCompPath || "index.html";
|
|
97
|
+
if (checkEditable && !checkEditable([timelineDropTarget(targetPath, placement)])) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
80
100
|
const pid = projectIdRef.current;
|
|
81
101
|
if (!pid) throw new Error("No active project");
|
|
82
102
|
|
|
@@ -86,7 +106,6 @@ export function useTimelineAssetDropOps({
|
|
|
86
106
|
return undefined;
|
|
87
107
|
}
|
|
88
108
|
|
|
89
|
-
const targetPath = activeCompPath || "index.html";
|
|
90
109
|
try {
|
|
91
110
|
const originalContent = await readFileContent(pid, targetPath);
|
|
92
111
|
|
|
@@ -172,6 +191,7 @@ export function useTimelineAssetDropOps({
|
|
|
172
191
|
reloadPreview,
|
|
173
192
|
isRecordingRef,
|
|
174
193
|
forceReloadSdkSession,
|
|
194
|
+
checkEditable,
|
|
175
195
|
],
|
|
176
196
|
);
|
|
177
197
|
|
|
@@ -190,6 +210,11 @@ export function useTimelineAssetDropOps({
|
|
|
190
210
|
showToast("Cannot edit timeline while recording", "error");
|
|
191
211
|
return;
|
|
192
212
|
}
|
|
213
|
+
const targetPath = activeCompPath || "index.html";
|
|
214
|
+
const initialPlacement = placement ?? { start: 0, track: 0 };
|
|
215
|
+
if (checkEditable && !checkEditable([timelineDropTarget(targetPath, initialPlacement)])) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
193
218
|
const pid = projectIdRef.current;
|
|
194
219
|
if (!pid) return;
|
|
195
220
|
const uploaded = await uploadProjectFiles(files);
|
|
@@ -215,7 +240,15 @@ export function useTimelineAssetDropOps({
|
|
|
215
240
|
if (index === 0) landedTrack = track;
|
|
216
241
|
}
|
|
217
242
|
},
|
|
218
|
-
[
|
|
243
|
+
[
|
|
244
|
+
activeCompPath,
|
|
245
|
+
checkEditable,
|
|
246
|
+
dropAssetAt,
|
|
247
|
+
projectIdRef,
|
|
248
|
+
uploadProjectFiles,
|
|
249
|
+
isRecordingRef,
|
|
250
|
+
showToast,
|
|
251
|
+
],
|
|
219
252
|
);
|
|
220
253
|
|
|
221
254
|
const handleTimelineCompositionDrop = useCallback(
|
|
@@ -224,9 +257,12 @@ export function useTimelineAssetDropOps({
|
|
|
224
257
|
showToast("Cannot edit timeline while recording", "error");
|
|
225
258
|
return;
|
|
226
259
|
}
|
|
260
|
+
const targetPath = activeCompPath || "index.html";
|
|
261
|
+
if (checkEditable && !checkEditable([timelineDropTarget(targetPath, placement)])) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
227
264
|
const pid = projectIdRef.current;
|
|
228
265
|
if (!pid) throw new Error("No active project");
|
|
229
|
-
const targetPath = activeCompPath || "index.html";
|
|
230
266
|
try {
|
|
231
267
|
await commitTimelineCompositionInsertion({
|
|
232
268
|
projectId: pid,
|
|
@@ -251,6 +287,7 @@ export function useTimelineAssetDropOps({
|
|
|
251
287
|
},
|
|
252
288
|
[
|
|
253
289
|
activeCompPath,
|
|
290
|
+
checkEditable,
|
|
254
291
|
forceReloadSdkSession,
|
|
255
292
|
isRecordingRef,
|
|
256
293
|
observeProjectFileVersion,
|
|
@@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
5
5
|
import type { TimelineElement } from "../player";
|
|
6
6
|
import { applyRippleShifts, useTimelineDeleteOps } from "./useTimelineDeleteOps";
|
|
7
7
|
import { installReactActEnvironment, mountReactHarness } from "./domSelectionTestHarness";
|
|
8
|
+
import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
|
|
9
|
+
import { flushStudioPendingEdits } from "../utils/studioPendingEdits";
|
|
8
10
|
|
|
9
11
|
installReactActEnvironment();
|
|
10
12
|
|
|
@@ -117,3 +119,112 @@ describe("useTimelineDeleteOps: ripple undo label", () => {
|
|
|
117
119
|
);
|
|
118
120
|
});
|
|
119
121
|
});
|
|
122
|
+
|
|
123
|
+
// Regression: a delete's write can reach disk before its history entry is
|
|
124
|
+
// recorded, so an Undo fired in that window found nothing to restore.
|
|
125
|
+
// Fails without useTrackPendingTimelineEdit's wrap (see that file).
|
|
126
|
+
describe("useTimelineDeleteOps: undo race", () => {
|
|
127
|
+
// data-composition-id (readRootCompositionDuration's key) plus a
|
|
128
|
+
// remove-element mock that actually drops hf-c: needed for a real diff.
|
|
129
|
+
const html = `<!DOCTYPE html><html data-composition-variables='[]'><body>
|
|
130
|
+
<div data-hf-id="hf-stage" data-composition-id="main" data-duration="6">
|
|
131
|
+
<div data-hf-id="hf-a" data-start="0" data-duration="2"></div>
|
|
132
|
+
<div data-hf-id="hf-b" data-start="2" data-duration="2"></div>
|
|
133
|
+
<div data-hf-id="hf-c" data-start="4" data-duration="2"></div>
|
|
134
|
+
</div>
|
|
135
|
+
</body></html>`;
|
|
136
|
+
const htmlAfterDeletingC = `<!DOCTYPE html><html data-composition-variables='[]'><body>
|
|
137
|
+
<div data-hf-id="hf-stage" data-composition-id="main" data-duration="6">
|
|
138
|
+
<div data-hf-id="hf-a" data-start="0" data-duration="2"></div>
|
|
139
|
+
<div data-hf-id="hf-b" data-start="2" data-duration="2"></div>
|
|
140
|
+
</div>
|
|
141
|
+
</body></html>`;
|
|
142
|
+
|
|
143
|
+
beforeEach(() => {
|
|
144
|
+
vi.stubGlobal(
|
|
145
|
+
"fetch",
|
|
146
|
+
vi.fn(async (input: RequestInfo | URL) => {
|
|
147
|
+
const url = String(input);
|
|
148
|
+
if (url.includes("/file-mutations/remove-element/")) {
|
|
149
|
+
return new Response(JSON.stringify({ changed: true, content: htmlAfterDeletingC }), {
|
|
150
|
+
status: 200,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return new Response(JSON.stringify({ content: html }), { status: 200 });
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
afterEach(() => {
|
|
159
|
+
document.body.innerHTML = "";
|
|
160
|
+
vi.unstubAllGlobals();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// A manually-resolved gate stands in for the delete's write landing — a
|
|
164
|
+
// real timer raced the fix's own microtasks unpredictably (verified: it
|
|
165
|
+
// kept passing even with the fix's tracking call deleted).
|
|
166
|
+
function deferred<T>() {
|
|
167
|
+
let resolve!: (value: T) => void;
|
|
168
|
+
const promise = new Promise<T>((r) => (resolve = r));
|
|
169
|
+
return { promise, resolve };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function mountRaceHarness() {
|
|
173
|
+
const elements = [el("hf-a", 0, 2), el("hf-b", 2, 2), el("hf-c", 4, 2)];
|
|
174
|
+
const gate = deferred<void>();
|
|
175
|
+
const recordEdit = vi.fn(() => gate.promise);
|
|
176
|
+
let tracked: ((elements: TimelineElement[]) => Promise<void>) | null = null;
|
|
177
|
+
function Harness() {
|
|
178
|
+
const track = useTrackPendingTimelineEdit();
|
|
179
|
+
const hook = useTimelineDeleteOps({
|
|
180
|
+
projectIdRef: { current: "test-project" },
|
|
181
|
+
activeCompPath: "index.html",
|
|
182
|
+
timelineElements: elements,
|
|
183
|
+
showToast: vi.fn(),
|
|
184
|
+
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
|
185
|
+
recordEdit,
|
|
186
|
+
reloadPreview: vi.fn(),
|
|
187
|
+
previewIframeRef: { current: null },
|
|
188
|
+
handleTimelineGroupMove: vi.fn().mockResolvedValue(undefined),
|
|
189
|
+
});
|
|
190
|
+
tracked = track(hook.handleTimelineElementsDelete);
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
mountReactHarness(<Harness />);
|
|
194
|
+
// Deletes the LAST clip (not the middle one): only this shrinks the
|
|
195
|
+
// composition's furthest-clip-end duration, so the write actually
|
|
196
|
+
// differs from the original content and recordEdit is reached — the
|
|
197
|
+
// middle clip's delete leaves duration unchanged and is a no-op write.
|
|
198
|
+
return { gate, recordEdit, deleteSelection: () => tracked!([elements[2]]) };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
it("the pending-edit flush waits while the delete's history write is still landing", async () => {
|
|
202
|
+
const { gate, recordEdit, deleteSelection } = mountRaceHarness();
|
|
203
|
+
const order: string[] = [];
|
|
204
|
+
const deletePromise = deleteSelection().then(() => order.push("deleted"));
|
|
205
|
+
// Let the delete run up to (and call) recordEdit, which is now gated —
|
|
206
|
+
// this is the exact window a fast Undo used to race into.
|
|
207
|
+
await vi.waitFor(() => expect(recordEdit).toHaveBeenCalled());
|
|
208
|
+
// No React state changes happen inside the flush itself here (no
|
|
209
|
+
// pending-edit-flush listeners are registered in this harness), so it
|
|
210
|
+
// does not need act()'s wrapping — only awaiting the delete's own
|
|
211
|
+
// resolution below does.
|
|
212
|
+
const flushPromise = flushStudioPendingEdits().then(() => order.push("flushed"));
|
|
213
|
+
await Promise.resolve();
|
|
214
|
+
await Promise.resolve();
|
|
215
|
+
expect(order).toEqual([]);
|
|
216
|
+
gate.resolve();
|
|
217
|
+
await deletePromise;
|
|
218
|
+
await flushPromise;
|
|
219
|
+
expect(order).toEqual(["deleted", "flushed"]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("the pending-edit flush resolves immediately once the delete has already finished", async () => {
|
|
223
|
+
const { gate, deleteSelection } = mountRaceHarness();
|
|
224
|
+
gate.resolve();
|
|
225
|
+
await deleteSelection();
|
|
226
|
+
await act(async () => {
|
|
227
|
+
await flushStudioPendingEdits();
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|