@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.
Files changed (35) hide show
  1. package/dist/assets/{hyperframes-player-DKWM-Ons.js → hyperframes-player-Cy_AwUa8.js} +1 -1
  2. package/dist/assets/{index-UvW6BEwf.js → index-CTa-6Kso.js} +1 -1
  3. package/dist/assets/{index-HfP3sap2.js → index-DPUJoO8n.js} +191 -191
  4. package/dist/assets/{index-Y4OnpTEM.js → index-DkAzKuPZ.js} +1 -1
  5. package/dist/index.d.ts +440 -10
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +1850 -1532
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/nle/TimelinePane.tsx +11 -1
  11. package/src/hooks/timelineAudioGroupCreate.ts +17 -1
  12. package/src/hooks/timelineAudioGroupVolume.ts +35 -2
  13. package/src/hooks/timelineEditPermission.ts +31 -0
  14. package/src/hooks/timelineElementFxAttribute.ts +34 -2
  15. package/src/hooks/useFileManager.ts +21 -123
  16. package/src/hooks/usePersistentEditHistory.ts +1 -1
  17. package/src/hooks/useProjectFileWriter.ts +150 -0
  18. package/src/hooks/useTimelineAssetDropOps.test.tsx +62 -4
  19. package/src/hooks/useTimelineAssetDropOps.ts +40 -3
  20. package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
  21. package/src/hooks/useTimelineEditing.test.tsx +481 -4
  22. package/src/hooks/useTimelineEditing.ts +151 -27
  23. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  24. package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
  25. package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
  26. package/src/hostMount.test.tsx +222 -0
  27. package/src/index.ts +40 -2
  28. package/src/player/components/Timeline.tsx +6 -6
  29. package/src/player/components/TimelineTypes.ts +3 -0
  30. package/src/player/components/usePublishRangeSelection.test.tsx +60 -0
  31. package/src/player/components/usePublishRangeSelection.ts +42 -0
  32. package/src/player/index.ts +1 -0
  33. package/src/player/store/playerStore.ts +8 -51
  34. package/src/player/store/rangeSelectionSlice.ts +13 -0
  35. 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 { handleTimelineAssetDrop } = useTimelineAssetDropOps({
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: vi.fn().mockResolvedValue([]),
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
- [dropAssetAt, projectIdRef, uploadProjectFiles, isRecordingRef, showToast],
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
+ });