@hyperframes/studio 0.8.81 → 0.8.82

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 (98) hide show
  1. package/dist/assets/{hyperframes-player-C68UawR3.js → hyperframes-player-zGIbZhLJ.js} +1 -1
  2. package/dist/assets/{index-CMzPGv9P.js → index-BiLCTunu.js} +1 -1
  3. package/dist/assets/{index-hNVM9Pd2.js → index-DSazIt7R.js} +189 -189
  4. package/dist/assets/{index-CS5SImRT.js → index-DStmfnpZ.js} +1 -1
  5. package/dist/index.d.ts +172 -7
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +2414 -2172
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
  11. package/src/components/TimelineHistoryButtons.tsx +26 -14
  12. package/src/components/TimelineToolbar.test.tsx +9 -1
  13. package/src/components/TimelineToolbar.tsx +13 -5
  14. package/src/components/editor/BlockParamsPanel.tsx +14 -10
  15. package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
  16. package/src/components/editor/manualEdits.test.ts +24 -0
  17. package/src/components/editor/manualEdits.ts +6 -2
  18. package/src/components/editor/manualEditsDom.ts +0 -65
  19. package/src/components/editor/manualEditsSeekReapply.ts +136 -0
  20. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +71 -32
  21. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
  22. package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
  23. package/src/components/editor/propertyPanelHelpers.ts +15 -0
  24. package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
  25. package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
  26. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
  27. package/src/components/editor/useFxLevelling.ts +35 -10
  28. package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
  29. package/src/components/nle/AudioMeterStrip.tsx +23 -12
  30. package/src/components/pointerTargetSize.test.tsx +1 -1
  31. package/src/components/studioColorGradingScope.ts +12 -36
  32. package/src/contexts/TimelineEditContext.test.tsx +49 -0
  33. package/src/contexts/TimelineEditContext.tsx +27 -23
  34. package/src/hooks/canvasQuickSaves.test.tsx +195 -0
  35. package/src/hooks/gsapMutationClient.ts +116 -0
  36. package/src/hooks/resizeObserverTestUtils.ts +14 -0
  37. package/src/hooks/timelineAudioGroupCreate.ts +18 -40
  38. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  39. package/src/hooks/timelineEditingHelpers.ts +66 -61
  40. package/src/hooks/timelineQuickSaves.test.ts +195 -0
  41. package/src/hooks/timelineTimingSync.test.ts +12 -0
  42. package/src/hooks/timelineTimingSync.ts +31 -123
  43. package/src/hooks/timelineTrackVisibility.ts +15 -29
  44. package/src/hooks/useAskAgentModal.test.tsx +78 -0
  45. package/src/hooks/useAskAgentModal.ts +3 -1
  46. package/src/hooks/useClipboard.source.test.ts +32 -0
  47. package/src/hooks/useClipboard.ts +21 -20
  48. package/src/hooks/useDomEditCommits.test.tsx +13 -9
  49. package/src/hooks/useDomEditCommits.ts +88 -97
  50. package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
  51. package/src/hooks/useEditorSave.ts +1 -1
  52. package/src/hooks/useElementLifecycleOps.ts +36 -37
  53. package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
  54. package/src/hooks/useFileManager.ts +21 -12
  55. package/src/hooks/useGroupCommits.ts +23 -24
  56. package/src/hooks/useGsapAnimationOps.ts +26 -8
  57. package/src/hooks/useGsapScriptCommits.ts +1 -0
  58. package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
  59. package/src/hooks/useThumbnailStripSize.ts +31 -0
  60. package/src/hooks/useTimelineAssetDropOps.ts +38 -35
  61. package/src/hooks/useTimelineDeleteOps.ts +53 -56
  62. package/src/hooks/useTimelineEditing.ts +3 -1
  63. package/src/hooks/useTimelineGroupEditing.ts +5 -1
  64. package/src/index.ts +8 -0
  65. package/src/player/components/CompositionThumbnail.test.ts +40 -6
  66. package/src/player/components/CompositionThumbnail.tsx +6 -21
  67. package/src/player/components/ImageThumbnail.test.tsx +27 -6
  68. package/src/player/components/ImageThumbnail.tsx +4 -18
  69. package/src/player/components/PlayerControls.test.tsx +52 -0
  70. package/src/player/components/PlayerControls.tsx +2 -1
  71. package/src/player/components/Timeline.test.ts +28 -3
  72. package/src/player/components/TimelineCanvas.tsx +5 -3
  73. package/src/player/components/VideoThumbnail.test.tsx +33 -11
  74. package/src/player/components/VideoThumbnail.tsx +7 -29
  75. package/src/player/components/thumbnailUtils.test.ts +16 -20
  76. package/src/player/components/thumbnailUtils.ts +6 -7
  77. package/src/player/components/timelineEditing.test.ts +55 -0
  78. package/src/player/components/timelineGroupEditing.ts +13 -6
  79. package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
  80. package/src/player/components/timelinePlayheadTransform.ts +17 -0
  81. package/src/player/components/useTimelinePlayhead.ts +26 -8
  82. package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
  83. package/src/player/hooks/timelinePlayerSync.ts +18 -8
  84. package/src/player/hooks/useShadowPreviewReload.ts +15 -4
  85. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
  86. package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
  87. package/src/player/lib/playbackShortcuts.ts +18 -0
  88. package/src/player/lib/playbackTypes.ts +1 -0
  89. package/src/player/lib/timelineDOM.test.ts +92 -0
  90. package/src/player/lib/timelineDOM.ts +0 -2
  91. package/src/player/lib/timelineElementHelpers.ts +47 -10
  92. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  93. package/src/player/store/timelineElement.ts +2 -1
  94. package/src/timelineChromeExports.test.tsx +79 -0
  95. package/src/utils/authoredSource.ts +15 -0
  96. package/src/utils/blockInstaller.ts +32 -27
  97. package/src/utils/studioFileHistory.test.ts +14 -14
  98. package/src/utils/studioFileHistory.ts +31 -2
@@ -5,11 +5,7 @@ import { FONT_EXT } from "../utils/mediaTypes";
5
5
 
6
6
  import { trackStudioEvent } from "../utils/studioTelemetry";
7
7
  import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
8
- import {
9
- createStudioSaveHttpError,
10
- StudioSaveHttpError,
11
- trackStudioSaveFailure,
12
- } from "../utils/studioSaveDiagnostics";
8
+ import { StudioSaveHttpError, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
13
9
  import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
14
10
  import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
15
11
  import type { CommitDomEditPatchBatches, PersistDomEditOperations } from "./domEditCommitTypes";
@@ -26,15 +22,15 @@ import { useElementLifecycleOps } from "./useElementLifecycleOps";
26
22
  import {
27
23
  AtomicElementPatchConvergenceError,
28
24
  batchesAreInlineStyleOnly,
29
- formatPatchRejectionMessage,
30
25
  formatUnsafeFieldList,
31
- getErrorDetail,
32
26
  patchElementBatches,
33
- readErrorResponseBody,
27
+ postPatchElement,
28
+ writePreparedContent,
34
29
  } from "./useDomEditCommitsHelpers";
35
30
  import type { CutoverResult } from "../utils/sdkCutover";
36
- import { studioWriteHeaders } from "../utils/studioFileVersion";
37
31
  import { reseekPreviewRuntime } from "./timelineTrackVisibility";
32
+ import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
33
+ import { readProjectFileContent } from "../utils/studioFileHistory";
38
34
  interface RecordEditInput {
39
35
  label: string;
40
36
  coalesceKey?: string;
@@ -159,23 +155,13 @@ export function useDomEditCommits({
159
155
  return result;
160
156
  };
161
157
 
162
- const readResponse = await fetch(
163
- buildProjectApiPath(pid, `/files/${encodeURIComponent(targetPath)}`),
164
- );
165
- if (!readResponse.ok) {
166
- throw await createStudioSaveHttpError(readResponse, `Failed to read ${targetPath}`);
167
- }
168
- const readData = (await readResponse.json()) as { content?: string };
169
- const originalContent = readData.content;
170
- if (typeof originalContent !== "string") {
171
- throw new Error(`Missing file contents for ${targetPath}`);
172
- }
173
-
174
- if (projectIdRef.current !== expectedProjectId) {
175
- throw new Error("Active project changed before the edit could be saved");
176
- }
177
-
178
- if (options?.shouldSave && !options.shouldSave()) return;
158
+ const readTarget = async (): Promise<string | null> => {
159
+ const content = await readProjectFileContent(pid, targetPath);
160
+ if (projectIdRef.current !== expectedProjectId) {
161
+ throw new Error("Active project changed before the edit could be saved");
162
+ }
163
+ return options?.shouldSave && !options.shouldSave() ? null : content;
164
+ };
179
165
 
180
166
  // Validate layout values BEFORE any persist path runs. The SDK cutover
181
167
  // path (onTrySdkPersist) returns early on success, so leaving this check
@@ -195,7 +181,10 @@ export function useDomEditCommits({
195
181
  // Skip the SDK path when prepareContent is set (e.g. @font-face injection
196
182
  // for a custom font): sdkCutoverPersist serializes only the patched DOM
197
183
  // and would drop the injected content. Let the server path run prepareContent.
184
+ // The SDK joins the file queue itself and re-reads there; this read is only its fallback.
198
185
  if (onTrySdkPersist && !options?.prepareContent) {
186
+ const originalContent = await readTarget();
187
+ if (originalContent === null) return;
199
188
  const cutover = await onTrySdkPersist(selection, operations, originalContent, targetPath, {
200
189
  label: options?.label,
201
190
  coalesceKey: options?.coalesceKey,
@@ -212,31 +201,41 @@ export function useDomEditCommits({
212
201
  }
213
202
  }
214
203
 
215
- const patchResponse = await fetch(
216
- buildProjectApiPath(pid, `/file-mutations/patch-element/${encodeURIComponent(targetPath)}`),
217
- {
218
- method: "POST",
219
- headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
220
- body: JSON.stringify(patchBody),
221
- },
222
- );
223
- if (!patchResponse.ok) {
224
- showToast(formatPatchRejectionMessage(await readErrorResponseBody(patchResponse)), "error");
225
- throw await createStudioSaveHttpError(patchResponse, `Failed to patch ${targetPath}`, {
226
- alreadyToasted: true,
227
- });
228
- }
229
-
230
- const patchData = (await patchResponse.json()) as {
231
- ok?: boolean;
232
- changed?: boolean;
233
- matched?: boolean;
234
- content?: string;
235
- path?: string;
236
- version?: string;
204
+ const history = {
205
+ label: options?.label ?? "Edit layer",
206
+ coalesceKey: options?.coalesceKey,
207
+ coalesceMs: options?.coalesceMs,
237
208
  };
209
+ const prepare = options?.prepareContent;
210
+ // Read, server patch, follow-up write and history hold the file's queue, so no save lands between them.
211
+ const saved = await serializeStudioFileMutations(writeProjectFile, [targetPath], async () => {
212
+ const originalContent = await readTarget();
213
+ if (originalContent === null) return null;
214
+ const patchData = await postPatchElement(pid, targetPath, patchBody, showToast);
215
+ if (!patchData.changed) return { patchData, patchedContent: null, finalContent: null };
216
+
217
+ const patchedContent =
218
+ typeof patchData.content === "string" ? patchData.content : originalContent;
219
+ const finalContent = prepare
220
+ ? await writePreparedContent(
221
+ targetPath,
222
+ patchedContent,
223
+ prepare,
224
+ writeProjectFile,
225
+ showToast,
226
+ )
227
+ : patchedContent;
228
+
229
+ await editHistory.recordEdit({
230
+ ...history,
231
+ files: { [targetPath]: { before: originalContent, after: finalContent } },
232
+ });
233
+ return { patchData, patchedContent, finalContent };
234
+ });
235
+ if (saved === null) return;
236
+ const { patchData, patchedContent, finalContent } = saved;
238
237
 
239
- if (!patchData.changed) {
238
+ if (finalContent === null) {
240
239
  if (patchData.matched === false) {
241
240
  const targetKey = selection.selector ?? selection.id ?? "selection";
242
241
  if (!reportedUnresolvableRef.current.has(targetKey)) {
@@ -258,36 +257,6 @@ export function useDomEditCommits({
258
257
  false,
259
258
  );
260
259
  }
261
-
262
- const patchedContent =
263
- typeof patchData.content === "string" ? patchData.content : originalContent;
264
-
265
- let finalContent = patchedContent;
266
- if (options?.prepareContent) {
267
- const preparedContent = options.prepareContent(patchedContent, targetPath);
268
- if (preparedContent !== patchedContent) {
269
- try {
270
- await writeProjectFile(targetPath, preparedContent, patchedContent);
271
- finalContent = preparedContent;
272
- } catch (error) {
273
- // The patch above already landed on disk — only the prepareContent
274
- // embellishment (e.g. an injected @font-face) failed to write. Keep
275
- // the already-persisted patchedContent instead of throwing, which
276
- // would otherwise revert a change the server already committed.
277
- showToast(
278
- `Saved, but couldn't finish updating ${targetPath}: ${getErrorDetail(error)}`,
279
- "error",
280
- );
281
- }
282
- }
283
- }
284
-
285
- await editHistory.recordEdit({
286
- label: options?.label ?? "Edit layer",
287
- coalesceKey: options?.coalesceKey,
288
- coalesceMs: options?.coalesceMs,
289
- files: { [targetPath]: { before: originalContent, after: finalContent } },
290
- });
291
260
  forceReloadSdkSession?.();
292
261
 
293
262
  if (!options?.skipRefresh) {
@@ -350,24 +319,38 @@ export function useDomEditCommits({
350
319
  );
351
320
  }
352
321
 
353
- const atomicResult = await patchElementBatches(pid, batches);
354
- const allMatched =
355
- atomicResult.durable && atomicResult.files.every((result) => result.allMatched);
356
- const files = Object.fromEntries(
357
- atomicResult.files
358
- .filter((result) => result.changed)
359
- .map((result) => [result.sourceFile, { before: result.before, after: result.after }]),
322
+ const sourceFiles = batches.map((batch) => batch.sourceFile);
323
+ // The server patch and its history entry hold every touched file's queue.
324
+ const { allMatched, changed } = await serializeStudioFileMutations(
325
+ writeProjectFile,
326
+ sourceFiles,
327
+ async () => {
328
+ const atomicResult = await patchElementBatches(pid, batches);
329
+ const files = Object.fromEntries(
330
+ atomicResult.files
331
+ .filter((result) => result.changed)
332
+ .map((result) => [
333
+ result.sourceFile,
334
+ { before: result.before, after: result.after },
335
+ ]),
336
+ );
337
+ const anyChanged = Object.keys(files).length > 0;
338
+ if (anyChanged) {
339
+ await editHistory.recordEdit({
340
+ label: options.label,
341
+ coalesceKey: options.coalesceKey,
342
+ coalesceMs: options.coalesceMs,
343
+ files,
344
+ });
345
+ }
346
+ return {
347
+ allMatched:
348
+ atomicResult.durable && atomicResult.files.every((result) => result.allMatched),
349
+ changed: anyChanged,
350
+ };
351
+ },
360
352
  );
361
- const changed = Object.keys(files).length > 0;
362
- if (changed) {
363
- await editHistory.recordEdit({
364
- label: options.label,
365
- coalesceKey: options.coalesceKey,
366
- coalesceMs: options.coalesceMs,
367
- files,
368
- });
369
- forceReloadSdkSession?.();
370
- }
353
+ if (changed) forceReloadSdkSession?.();
371
354
  const durable = allMatched;
372
355
  // A z-only reorder already applied its inline styles to the live iframe
373
356
  // DOM (and the store) synchronously, so remounting the iframe here only
@@ -399,7 +382,15 @@ export function useDomEditCommits({
399
382
  throw error;
400
383
  });
401
384
  },
402
- [editHistory, forceReloadSdkSession, projectIdRef, queueDomEditSave, reloadPreview, showToast],
385
+ [
386
+ editHistory,
387
+ forceReloadSdkSession,
388
+ projectIdRef,
389
+ queueDomEditSave,
390
+ reloadPreview,
391
+ showToast,
392
+ writeProjectFile,
393
+ ],
403
394
  );
404
395
 
405
396
  // ── Text & style commits (delegated to useDomEditTextCommits) ──
@@ -1,4 +1,9 @@
1
- import { StudioSaveHttpError, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
1
+ import {
2
+ createStudioSaveHttpError,
3
+ StudioSaveHttpError,
4
+ trackStudioSaveFailure,
5
+ } from "../utils/studioSaveDiagnostics";
6
+ import { buildProjectApiPath } from "../utils/projectRouting";
2
7
  import type { DomEditPatchBatch } from "./domEditCommitTypes";
3
8
  import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
4
9
  import { studioWriteHeaders } from "../utils/studioFileVersion";
@@ -7,11 +12,11 @@ export function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
7
12
  return fields.map((field) => field.path).join(", ");
8
13
  }
9
14
 
10
- export function getErrorDetail(error: unknown): string {
15
+ function getErrorDetail(error: unknown): string {
11
16
  return error instanceof Error ? error.message : String(error);
12
17
  }
13
18
 
14
- export async function readErrorResponseBody(
19
+ async function readErrorResponseBody(
15
20
  response: Response,
16
21
  ): Promise<{ error?: string; fields?: string[] } | null> {
17
22
  const contentType = response.headers.get("content-type") ?? "";
@@ -19,9 +24,7 @@ export async function readErrorResponseBody(
19
24
  return (await response.json().catch(() => null)) as { error?: string; fields?: string[] } | null;
20
25
  }
21
26
 
22
- export function formatPatchRejectionMessage(
23
- body: { error?: string; fields?: string[] } | null,
24
- ): string {
27
+ function formatPatchRejectionMessage(body: { error?: string; fields?: string[] } | null): string {
25
28
  if (!body?.error) return "Couldn't save edit";
26
29
  return `Couldn't save edit: ${body.error}${formatFieldsSuffix(body.fields)}`;
27
30
  }
@@ -157,3 +160,63 @@ export function batchesAreInlineStyleOnly(batches: DomEditPatchBatch[]): boolean
157
160
  batch.patches.every((patch) => patch.operations.every((op) => op.type === "inline-style")),
158
161
  );
159
162
  }
163
+
164
+ export interface PatchElementResponse {
165
+ ok?: boolean;
166
+ changed?: boolean;
167
+ matched?: boolean;
168
+ content?: string;
169
+ path?: string;
170
+ version?: string;
171
+ }
172
+
173
+ type ShowToast = (message: string, tone?: "error" | "info") => void;
174
+
175
+ export async function postPatchElement(
176
+ projectId: string,
177
+ targetPath: string,
178
+ body: unknown,
179
+ showToast: ShowToast,
180
+ ): Promise<PatchElementResponse> {
181
+ const response = await fetch(
182
+ buildProjectApiPath(
183
+ projectId,
184
+ `/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
185
+ ),
186
+ {
187
+ method: "POST",
188
+ headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
189
+ body: JSON.stringify(body),
190
+ },
191
+ );
192
+ if (!response.ok) {
193
+ showToast(formatPatchRejectionMessage(await readErrorResponseBody(response)), "error");
194
+ throw await createStudioSaveHttpError(response, `Failed to patch ${targetPath}`, {
195
+ alreadyToasted: true,
196
+ });
197
+ }
198
+ return (await response.json()) as PatchElementResponse;
199
+ }
200
+
201
+ /** Writes `prepare`'s embellishment over the server's patch; returns what the file ends up holding. */
202
+ export async function writePreparedContent(
203
+ targetPath: string,
204
+ patchedContent: string,
205
+ prepare: (html: string, sourceFile: string) => string,
206
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
207
+ showToast: ShowToast,
208
+ ): Promise<string> {
209
+ const preparedContent = prepare(patchedContent, targetPath);
210
+ if (preparedContent === patchedContent) return patchedContent;
211
+ try {
212
+ await writeProjectFile(targetPath, preparedContent, patchedContent);
213
+ return preparedContent;
214
+ } catch (error) {
215
+ // The patch already landed on disk; keep it rather than revert a committed change.
216
+ showToast(
217
+ `Saved, but couldn't finish updating ${targetPath}: ${getErrorDetail(error)}`,
218
+ "error",
219
+ );
220
+ return patchedContent;
221
+ }
222
+ }
@@ -107,7 +107,7 @@ export function useEditorSave({
107
107
  projectId: candidate.projectId,
108
108
  label: "Edit source",
109
109
  coalesceKey: `source:${candidate.path}`,
110
- files: { [candidate.path]: candidate.content },
110
+ files: { [candidate.path]: () => candidate.content },
111
111
  readFile: readProjectFile,
112
112
  writeFile: writeProjectFile,
113
113
  recordEdit,
@@ -3,7 +3,7 @@ import { useCallback } from "react";
3
3
  import { usePlayerStore } from "../player";
4
4
  import {
5
5
  readProjectFileContent,
6
- saveProjectFilesWithHistory,
6
+ saveServerRewriteWithHistory,
7
7
  type DomEditCommitBaseParams,
8
8
  } from "../utils/studioFileHistory";
9
9
  import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics";
@@ -111,8 +111,6 @@ export function useElementLifecycleOps({
111
111
  (candidate) => (candidate.sourceFile || activeCompPath || "index.html") === targetPath,
112
112
  );
113
113
  try {
114
- const originalContent = await readProjectFileContent(pid, targetPath);
115
-
116
114
  const patchTargets = sameFile.map((member) => buildDomEditPatchTarget(member));
117
115
  if (patchTargets.some((t) => !t.id && !t.selector && !t.hfId)) {
118
116
  throw new Error("Selected element has no patchable target");
@@ -125,6 +123,7 @@ export function useElementLifecycleOps({
125
123
  .map((member) => member.hfId)
126
124
  .filter((hfId): hfId is string => Boolean(hfId));
127
125
  if (onTrySdkDelete && hfIds.length === sameFile.length) {
126
+ const originalContent = await readProjectFileContent(pid, targetPath);
128
127
  let allHandled = true;
129
128
  for (const hfId of hfIds) {
130
129
  // The SDK owns the document it edits, so every member is removed
@@ -150,28 +149,41 @@ export function useElementLifecycleOps({
150
149
  // cost a round trip and a rewrite of the file EACH, and a canvas
151
150
  // selection runs to hundreds of members — the file ended up correct, but
152
151
  // only after long enough that Delete looked like it had done nothing.
153
- const removeResponse = await fetch(
154
- buildProjectApiPath(
155
- pid,
156
- `/file-mutations/remove-elements/${encodeURIComponent(targetPath)}`,
157
- ),
158
- {
159
- method: "POST",
160
- headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
161
- body: JSON.stringify({ targets: patchTargets }),
152
+ const deleted = await saveServerRewriteWithHistory({
153
+ projectId: pid,
154
+ path: targetPath,
155
+ label: "Delete element",
156
+ writeFile: writeProjectFile,
157
+ recordEdit: editHistory.recordEdit,
158
+ rewrite: async (originalContent) => {
159
+ const removeResponse = await fetch(
160
+ buildProjectApiPath(
161
+ pid,
162
+ `/file-mutations/remove-elements/${encodeURIComponent(targetPath)}`,
163
+ ),
164
+ {
165
+ method: "POST",
166
+ headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
167
+ body: JSON.stringify({ targets: patchTargets }),
168
+ },
169
+ );
170
+ if (!removeResponse.ok) {
171
+ throw await createStudioSaveHttpError(
172
+ removeResponse,
173
+ `Failed to delete element from ${targetPath}`,
174
+ );
175
+ }
176
+ const removeData = (await removeResponse.json()) as {
177
+ changed?: boolean;
178
+ content?: string;
179
+ };
180
+ if (!removeData.changed) return null;
181
+ return {
182
+ disk: typeof removeData.content === "string" ? removeData.content : originalContent,
183
+ };
162
184
  },
163
- );
164
- if (!removeResponse.ok) {
165
- throw await createStudioSaveHttpError(
166
- removeResponse,
167
- `Failed to delete element from ${targetPath}`,
168
- );
169
- }
170
- const removeData = (await removeResponse.json()) as {
171
- changed?: boolean;
172
- content?: string;
173
- };
174
- if (!removeData.changed) {
185
+ });
186
+ if (!deleted) {
175
187
  // A member the file no longer holds simply does not match, which is
176
188
  // normal for one nested inside another member already removed. Nothing
177
189
  // matching at all means the preview is describing a document the file
@@ -180,19 +192,6 @@ export function useElementLifecycleOps({
180
192
  showToast("Nothing to delete, the preview was out of date. Try again.");
181
193
  return domEditCommitDeclined("preview-stale");
182
194
  }
183
- const patchedContent =
184
- typeof removeData.content === "string" ? removeData.content : originalContent;
185
- await saveProjectFilesWithHistory({
186
- projectId: pid,
187
- label: "Delete element",
188
- files: { [targetPath]: patchedContent },
189
- readFile: async () => originalContent,
190
- // remove-element already wrote the removal, so disk holds THAT — not
191
- // the content read at the top. Undo still goes back to the original.
192
- diskContent: { [targetPath]: patchedContent },
193
- writeFile: writeProjectFile,
194
- recordEdit: editHistory.recordEdit,
195
- });
196
195
 
197
196
  clearDomSelection();
198
197
  usePlayerStore.getState().setSelectedElementId(null);
@@ -31,19 +31,19 @@ import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
31
31
 
32
32
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
33
33
 
34
- function useTestFileManager(projectId: string) {
34
+ function useTestFileManager(projectId: string, showToast = vi.fn()) {
35
35
  return useFileManager({
36
36
  projectId,
37
- showToast: vi.fn(),
37
+ showToast,
38
38
  recordEdit: vi.fn(async () => {}),
39
39
  setRefreshKey: vi.fn(),
40
40
  });
41
41
  }
42
42
 
43
- async function mountTestFileManager(projectId = "project-a") {
43
+ async function mountTestFileManager(projectId = "project-a", showToast = vi.fn()) {
44
44
  const captured: { manager: ReturnType<typeof useFileManager> | null } = { manager: null };
45
45
  function Probe() {
46
- captured.manager = useTestFileManager(projectId);
46
+ captured.manager = useTestFileManager(projectId, showToast);
47
47
  return null;
48
48
  }
49
49
  const root = createRoot(document.createElement("div"));
@@ -237,3 +237,50 @@ describe("useFileManager project ownership", () => {
237
237
  await act(async () => root.unmount());
238
238
  });
239
239
  });
240
+
241
+ describe("useFileManager uploads", () => {
242
+ afterEach(() => vi.unstubAllGlobals());
243
+
244
+ async function uploadClip(response: Record<string, unknown>) {
245
+ vi.stubGlobal(
246
+ "fetch",
247
+ vi.fn(async () =>
248
+ Response.json(
249
+ { ok: true, files: [], skipped: [], invalid: [], ...response },
250
+ { status: 201 },
251
+ ),
252
+ ),
253
+ );
254
+ const showToast = vi.fn();
255
+ const { manager, root } = await mountTestFileManager("project-a", showToast);
256
+ const added = await act(() => manager.uploadProjectFiles([new File(["x"], "clip.mp4")]));
257
+ await act(async () => root.unmount());
258
+ return { added, showToast };
259
+ }
260
+
261
+ it("says which uploads were added without a media check, and why", async () => {
262
+ const reason =
263
+ "not checked: ffprobe was not found. Install FFmpeg or set HYPERFRAMES_FFPROBE_PATH.";
264
+ const { added, showToast } = await uploadClip({
265
+ files: ["clip.mp4"],
266
+ unchecked: [{ name: "clip.mp4", reason }],
267
+ });
268
+
269
+ expect(added).toEqual(["clip.mp4"]);
270
+ expect(showToast).toHaveBeenCalledWith(`Added clip.mp4, ${reason}`, "info");
271
+ });
272
+
273
+ it("says why each upload was not added", async () => {
274
+ const { added, showToast } = await uploadClip({
275
+ invalid: [
276
+ { name: "clip.mp4", reason: "no supported video stream found" },
277
+ { name: "song.mp3", reason: "no supported audio stream found" },
278
+ ],
279
+ });
280
+
281
+ expect(added).toEqual([]);
282
+ expect(showToast).toHaveBeenCalledWith(
283
+ "Not added: clip.mp4 (no supported video stream found), song.mp3 (no supported audio stream found)",
284
+ );
285
+ });
286
+ });
@@ -4,6 +4,7 @@ import { FONT_EXT, isMediaFile } from "../utils/mediaTypes";
4
4
  import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
5
5
  import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher";
6
6
  import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
7
+ import { serializeStudioFileMutation } from "../utils/studioFileMutationCoordinator";
7
8
  import { useFileTree } from "./useFileTree";
8
9
  import { useEditorSave } from "./useEditorSave";
9
10
  import { useProjectFileWriter } from "./useProjectFileWriter";
@@ -98,16 +99,18 @@ export function useFileManager({
98
99
 
99
100
  const overwriteExternalConflict = useCallback(
100
101
  async (conflict: StudioFileConflictError) => {
101
- if (conflict.currentContent != null) {
102
- await writeProjectFile(
103
- conflict.filePath,
104
- conflict.attemptedContent,
105
- conflict.currentContent,
106
- );
107
- } else {
108
- fileVersions.set(conflict.filePath, conflict.currentVersion);
109
- await writeProjectFile(conflict.filePath, conflict.attemptedContent);
110
- }
102
+ await serializeStudioFileMutation(writeProjectFile, conflict.filePath, async () => {
103
+ if (conflict.currentContent != null) {
104
+ await writeProjectFile(
105
+ conflict.filePath,
106
+ conflict.attemptedContent,
107
+ conflict.currentContent,
108
+ );
109
+ } else {
110
+ fileVersions.set(conflict.filePath, conflict.currentVersion);
111
+ await writeProjectFile(conflict.filePath, conflict.attemptedContent);
112
+ }
113
+ });
111
114
  updateEditingFileContent(conflict.filePath, conflict.attemptedContent);
112
115
  },
113
116
  [fileVersions, updateEditingFileContent, writeProjectFile],
@@ -207,8 +210,14 @@ export function useFileManager({
207
210
  showToast(`Skipped (too large): ${data.skipped.join(", ")}`);
208
211
  }
209
212
  if (data.invalid?.length) {
210
- const names = data.invalid.map((entry: { name: string }) => entry.name).join(", ");
211
- showToast(`Unsupported media skipped: ${names}`);
213
+ const why = data.invalid
214
+ .map((entry: { name: string; reason: string }) => `${entry.name} (${entry.reason})`)
215
+ .join(", ");
216
+ showToast(`Not added: ${why}`);
217
+ }
218
+ if (data.unchecked?.length) {
219
+ const names = data.unchecked.map((entry: { name: string }) => entry.name).join(", ");
220
+ showToast(`Added ${names}, ${data.unchecked[0].reason}`, "info");
212
221
  }
213
222
  await refreshFileTree();
214
223
  setRefreshKey((k) => k + 1);
@@ -1,8 +1,7 @@
1
1
  import { buildProjectApiPath } from "../utils/projectRouting";
2
2
  import { useCallback } from "react";
3
3
  import {
4
- readProjectFileContent,
5
- saveProjectFilesWithHistory,
4
+ saveServerRewriteWithHistory,
6
5
  type DomEditCommitBaseParams,
7
6
  } from "../utils/studioFileHistory";
8
7
  import {
@@ -125,36 +124,36 @@ async function commitStructuralMutation(
125
124
  | "reloadPreview"
126
125
  >,
127
126
  ): Promise<{ content?: string; groupId?: string }> {
128
- const originalContent = await readProjectFileContent(pid, targetPath);
129
-
130
- const mutateResponse = await fetch(
131
- buildProjectApiPath(pid, `/file-mutations/${route}/${encodeURIComponent(targetPath)}`),
132
- {
133
- method: "POST",
134
- headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
135
- body: JSON.stringify(body),
136
- },
137
- );
138
- if (!mutateResponse.ok) {
139
- const errBody = (await mutateResponse.json().catch(() => null)) as { error?: string } | null;
140
- throw new Error(errBody?.error ?? `Failed to ${label.toLowerCase()} in ${targetPath}`);
141
- }
142
- const mutateData = (await mutateResponse.json()) as { content?: string; groupId?: string };
143
- const patchedContent =
144
- typeof mutateData.content === "string" ? mutateData.content : originalContent;
145
-
146
- await saveProjectFilesWithHistory({
127
+ let result: { content?: string; groupId?: string } = {};
128
+ await saveServerRewriteWithHistory({
147
129
  projectId: pid,
130
+ path: targetPath,
148
131
  label,
149
- files: { [targetPath]: patchedContent },
150
- readFile: async () => originalContent,
151
132
  writeFile: deps.writeProjectFile,
152
133
  recordEdit: deps.editHistory.recordEdit,
134
+ rewrite: async (originalContent) => {
135
+ const mutateResponse = await fetch(
136
+ buildProjectApiPath(pid, `/file-mutations/${route}/${encodeURIComponent(targetPath)}`),
137
+ {
138
+ method: "POST",
139
+ headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
140
+ body: JSON.stringify(body),
141
+ },
142
+ );
143
+ if (!mutateResponse.ok) {
144
+ const errBody = (await mutateResponse.json().catch(() => null)) as {
145
+ error?: string;
146
+ } | null;
147
+ throw new Error(errBody?.error ?? `Failed to ${label.toLowerCase()} in ${targetPath}`);
148
+ }
149
+ result = (await mutateResponse.json()) as { content?: string; groupId?: string };
150
+ return { disk: typeof result.content === "string" ? result.content : originalContent };
151
+ },
153
152
  });
154
153
  deps.clearDomSelection();
155
154
  deps.forceReloadSdkSession?.();
156
155
  deps.reloadPreview();
157
- return mutateData;
156
+ return result;
158
157
  }
159
158
 
160
159
  export function useGroupCommits(params: UseGroupCommitsParams) {