@hyperframes/studio 0.8.80 → 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 (110) hide show
  1. package/dist/assets/{hyperframes-player-CgnoxDoD.js → hyperframes-player-zGIbZhLJ.js} +1 -1
  2. package/dist/assets/{index-J9HXe7mU.js → index-BiLCTunu.js} +1 -1
  3. package/dist/assets/{index-CsG24Jub.js → index-DSazIt7R.js} +198 -198
  4. package/dist/assets/{index-DZGVvqDT.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 +2567 -2314
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/App.tsx +8 -9
  11. package/src/components/StudioRightPanels.tsx +4 -48
  12. package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
  13. package/src/components/TimelineHistoryButtons.tsx +26 -14
  14. package/src/components/TimelineToolbar.test.tsx +9 -1
  15. package/src/components/TimelineToolbar.tsx +13 -5
  16. package/src/components/editor/BlockParamsPanel.tsx +14 -10
  17. package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
  18. package/src/components/editor/manualEdits.test.ts +24 -0
  19. package/src/components/editor/manualEdits.ts +6 -2
  20. package/src/components/editor/manualEditsDom.ts +0 -65
  21. package/src/components/editor/manualEditsSeekReapply.ts +136 -0
  22. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +71 -32
  23. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
  24. package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
  25. package/src/components/editor/propertyPanelHelpers.ts +15 -0
  26. package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
  27. package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
  28. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
  29. package/src/components/editor/useFxLevelling.ts +35 -10
  30. package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
  31. package/src/components/nle/AudioMeterStrip.tsx +23 -12
  32. package/src/components/panels/VariablesOtherCompositions.tsx +4 -4
  33. package/src/components/panels/VariablesPanel.test.tsx +86 -0
  34. package/src/components/panels/VariablesPanel.tsx +3 -3
  35. package/src/components/pointerTargetSize.test.tsx +1 -1
  36. package/src/components/sidebar/SidebarLintButton.test.tsx +29 -0
  37. package/src/components/sidebar/SidebarLintButton.tsx +4 -0
  38. package/src/components/studioColorGradingScope.ts +16 -42
  39. package/src/contexts/TimelineEditContext.test.tsx +49 -0
  40. package/src/contexts/TimelineEditContext.tsx +27 -23
  41. package/src/hooks/canvasQuickSaves.test.tsx +195 -0
  42. package/src/hooks/gsapMutationClient.ts +116 -0
  43. package/src/hooks/resizeObserverTestUtils.ts +14 -0
  44. package/src/hooks/timelineAudioGroupCreate.ts +18 -40
  45. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  46. package/src/hooks/timelineEditingHelpers.ts +66 -61
  47. package/src/hooks/timelineQuickSaves.test.ts +195 -0
  48. package/src/hooks/timelineTimingSync.test.ts +12 -0
  49. package/src/hooks/timelineTimingSync.ts +31 -123
  50. package/src/hooks/timelineTrackVisibility.ts +15 -29
  51. package/src/hooks/useApplyColorGradingScope.test.tsx +53 -0
  52. package/src/hooks/useApplyColorGradingScope.ts +54 -0
  53. package/src/hooks/useAskAgentModal.test.tsx +78 -0
  54. package/src/hooks/useAskAgentModal.ts +3 -1
  55. package/src/hooks/useClipboard.source.test.ts +32 -0
  56. package/src/hooks/useClipboard.ts +21 -20
  57. package/src/hooks/useDomEditCommits.test.tsx +13 -9
  58. package/src/hooks/useDomEditCommits.ts +88 -97
  59. package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
  60. package/src/hooks/useEditorSave.ts +1 -1
  61. package/src/hooks/useElementLifecycleOps.ts +36 -37
  62. package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
  63. package/src/hooks/useFileManager.ts +21 -12
  64. package/src/hooks/useGroupCommits.ts +23 -24
  65. package/src/hooks/useGsapAnimationOps.ts +26 -8
  66. package/src/hooks/useGsapScriptCommits.ts +1 -0
  67. package/src/hooks/useLintModal.test.tsx +15 -0
  68. package/src/hooks/useLintModal.ts +5 -2
  69. package/src/hooks/useProjectCompositionVariables.ts +3 -3
  70. package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
  71. package/src/hooks/useThumbnailStripSize.ts +31 -0
  72. package/src/hooks/useTimelineAssetDropOps.ts +38 -35
  73. package/src/hooks/useTimelineDeleteOps.ts +53 -56
  74. package/src/hooks/useTimelineEditing.ts +3 -1
  75. package/src/hooks/useTimelineGroupEditing.ts +5 -1
  76. package/src/index.ts +8 -0
  77. package/src/player/components/CompositionThumbnail.test.ts +40 -6
  78. package/src/player/components/CompositionThumbnail.tsx +6 -21
  79. package/src/player/components/ImageThumbnail.test.tsx +27 -6
  80. package/src/player/components/ImageThumbnail.tsx +4 -18
  81. package/src/player/components/PlayerControls.test.tsx +52 -0
  82. package/src/player/components/PlayerControls.tsx +2 -1
  83. package/src/player/components/Timeline.test.ts +28 -3
  84. package/src/player/components/TimelineCanvas.tsx +5 -3
  85. package/src/player/components/VideoThumbnail.test.tsx +33 -11
  86. package/src/player/components/VideoThumbnail.tsx +7 -29
  87. package/src/player/components/thumbnailUtils.test.ts +16 -20
  88. package/src/player/components/thumbnailUtils.ts +6 -7
  89. package/src/player/components/timelineEditing.test.ts +55 -0
  90. package/src/player/components/timelineGroupEditing.ts +13 -6
  91. package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
  92. package/src/player/components/timelinePlayheadTransform.ts +17 -0
  93. package/src/player/components/useTimelinePlayhead.ts +26 -8
  94. package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
  95. package/src/player/hooks/timelinePlayerSync.ts +18 -8
  96. package/src/player/hooks/useShadowPreviewReload.ts +15 -4
  97. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
  98. package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
  99. package/src/player/lib/playbackShortcuts.ts +18 -0
  100. package/src/player/lib/playbackTypes.ts +1 -0
  101. package/src/player/lib/timelineDOM.test.ts +92 -0
  102. package/src/player/lib/timelineDOM.ts +0 -2
  103. package/src/player/lib/timelineElementHelpers.ts +47 -10
  104. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  105. package/src/player/store/timelineElement.ts +2 -1
  106. package/src/timelineChromeExports.test.tsx +79 -0
  107. package/src/utils/authoredSource.ts +15 -0
  108. package/src/utils/blockInstaller.ts +32 -27
  109. package/src/utils/studioFileHistory.test.ts +14 -14
  110. package/src/utils/studioFileHistory.ts +31 -2
@@ -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) {
@@ -1,4 +1,8 @@
1
1
  import { useCallback } from "react";
2
+ import {
3
+ serializeStudioFileMutation,
4
+ type StudioProjectFileWriter,
5
+ } from "../utils/studioFileMutationCoordinator";
2
6
  import type { Composition } from "@hyperframes/sdk";
3
7
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
8
  import { roundTo3 } from "../utils/rounding";
@@ -27,6 +31,7 @@ interface GsapAnimationOpsParams extends SdkAnimationDeps {
27
31
  commitMutation: CommitMutation;
28
32
  commitMutationSafely: SafeGsapCommitMutation;
29
33
  showToast: (message: string, tone?: "error" | "info") => void;
34
+ writeProjectFile?: StudioProjectFileWriter;
30
35
  }
31
36
 
32
37
  export function useGsapAnimationOps({
@@ -37,6 +42,7 @@ export function useGsapAnimationOps({
37
42
  showToast,
38
43
  sdkSession,
39
44
  sdkDeps,
45
+ writeProjectFile,
40
46
  }: GsapAnimationOpsParams) {
41
47
  const updateGsapMeta = useCallback(
42
48
  async (
@@ -122,13 +128,17 @@ export function useGsapAnimationOps({
122
128
  const pid = projectIdRef.current;
123
129
  const targetPath = selection.sourceFile || activeCompPath || "index.html";
124
130
  if (!pid) return;
125
- const assigned = await assignGsapTargetAutoIdIfNeeded({
126
- projectId: pid,
127
- targetPath,
128
- selection,
129
- autoId,
130
- showToast,
131
- });
131
+ const assign = () =>
132
+ assignGsapTargetAutoIdIfNeeded({
133
+ projectId: pid,
134
+ targetPath,
135
+ selection,
136
+ autoId,
137
+ showToast,
138
+ });
139
+ const assigned = await (writeProjectFile
140
+ ? serializeStudioFileMutation(writeProjectFile, targetPath, assign)
141
+ : assign());
132
142
  if (!assigned) return;
133
143
  }
134
144
 
@@ -181,7 +191,15 @@ export function useGsapAnimationOps({
181
191
  { label: `Add GSAP ${method} animation`, softReload: true },
182
192
  );
183
193
  },
184
- [activeCompPath, commitMutation, projectIdRef, showToast, sdkSession, sdkDeps],
194
+ [
195
+ activeCompPath,
196
+ commitMutation,
197
+ projectIdRef,
198
+ showToast,
199
+ sdkSession,
200
+ sdkDeps,
201
+ writeProjectFile,
202
+ ],
185
203
  );
186
204
 
187
205
  type KeyframeEntry = {
@@ -523,6 +523,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
523
523
  showToast,
524
524
  sdkSession,
525
525
  sdkDeps,
526
+ writeProjectFile,
526
527
  });
527
528
  const keyframeOps = useGsapKeyframeOps({
528
529
  activeCompPath,
@@ -69,6 +69,21 @@ describe("useLintModal hasLintError", () => {
69
69
  });
70
70
  });
71
71
 
72
+ describe("useLintModal lintFindingCount", () => {
73
+ it("counts findings, not files, with the result open and after it closes", async () => {
74
+ const findings = ["a", "b", "c"].map((message) => ({
75
+ severity: "warning",
76
+ message,
77
+ file: "index.html",
78
+ }));
79
+ await mountWithLint(async () => ({ json: async () => ({ findings }) }));
80
+ expect(latest?.lintFindingCount).toBe(3);
81
+ act(() => latest?.closeLintModal());
82
+ expect(latest?.findingsByFile.size).toBe(1);
83
+ expect(latest?.lintFindingCount).toBe(3);
84
+ });
85
+ });
86
+
72
87
  describe("useLintModal background lint", () => {
73
88
  it("waits for the live preview to boot before asking the server to lint", async () => {
74
89
  usePlayerStore.setState({ previewBooted: false });
@@ -98,8 +98,10 @@ export function useLintModal(projectId: string | null, refreshKey?: number) {
98
98
 
99
99
  const findingsByElement = useMemo(() => groupFindings((f) => f.elementId), [groupFindings]);
100
100
  const findingsByFile = useMemo(() => groupFindings((f) => f.file), [groupFindings]);
101
- // Reads the list the badge count reads: the manual result when present, else the background one.
102
- const hasLintError = (lintModal ?? backgroundFindings).some((f) => f.severity === "error");
101
+ // The badge counts findings, not files: the manual result when present, else the background one.
102
+ const badgeFindings = lintModal ?? backgroundFindings;
103
+ const lintFindingCount = badgeFindings.length;
104
+ const hasLintError = badgeFindings.some((f) => f.severity === "error");
103
105
 
104
106
  // Sync lint findings directly to the player store — eliminates the
105
107
  // mirroring useEffect that was previously in App.tsx.
@@ -115,6 +117,7 @@ export function useLintModal(projectId: string | null, refreshKey?: number) {
115
117
  backgroundFindings,
116
118
  findingsByElement,
117
119
  findingsByFile,
120
+ lintFindingCount,
118
121
  hasLintError,
119
122
  };
120
123
  }
@@ -51,7 +51,7 @@ async function readGroup(
51
51
  * full parse, so large projects don't pay N openComposition calls.
52
52
  */
53
53
  export function useProjectCompositionVariables(
54
- fileTree: string[],
54
+ compositionPaths: string[],
55
55
  excludePath: string | null,
56
56
  readProjectFile: (path: string) => Promise<string>,
57
57
  refreshKey: unknown,
@@ -60,7 +60,7 @@ export function useProjectCompositionVariables(
60
60
 
61
61
  useEffect(() => {
62
62
  let cancelled = false;
63
- const htmlFiles = fileTree.filter((p) => p.endsWith(".html") && p !== excludePath);
63
+ const htmlFiles = compositionPaths.filter((p) => p !== excludePath);
64
64
 
65
65
  void (async () => {
66
66
  const out: CompositionVariableGroup[] = [];
@@ -75,7 +75,7 @@ export function useProjectCompositionVariables(
75
75
  cancelled = true;
76
76
  };
77
77
  // eslint-disable-next-line react-hooks/exhaustive-deps
78
- }, [fileTree, excludePath, readProjectFile, refreshKey]);
78
+ }, [compositionPaths, excludePath, readProjectFile, refreshKey]);
79
79
 
80
80
  return groups;
81
81
  }
@@ -0,0 +1,47 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { expect, it } from "vitest";
5
+ import { MockResizeObserver, reportResize } from "./resizeObserverTestUtils";
6
+ import { useThumbnailStripSize } from "./useThumbnailStripSize";
7
+
8
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
9
+
10
+ it("does not re-render the strip when the observer reports the size it already holds", () => {
11
+ const originalResizeObserver = globalThis.ResizeObserver;
12
+ globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
13
+ const host = document.createElement("div");
14
+ document.body.append(host);
15
+ Object.defineProperty(host, "clientWidth", { configurable: true, value: 300 });
16
+ Object.defineProperty(host, "clientHeight", { configurable: true, value: 40 });
17
+ const root = createRoot(host);
18
+ let stripRenders = 0;
19
+ function Strip({ label }: { label: string }) {
20
+ stripRenders += 1;
21
+ return label;
22
+ }
23
+ function Harness() {
24
+ const [size, ref] = useThumbnailStripSize();
25
+ return (
26
+ <div ref={ref}>
27
+ <Strip label={`${size.width}x${size.height}`} />
28
+ </div>
29
+ );
30
+ }
31
+ try {
32
+ act(() => root.render(<Harness />));
33
+ expect(host.textContent).toBe("300x40");
34
+ const settled = stripRenders;
35
+
36
+ act(() => reportResize(300, 40));
37
+ expect(stripRenders).toBe(settled);
38
+
39
+ act(() => reportResize(320, 40));
40
+ expect(stripRenders).toBe(settled + 1);
41
+ expect(host.textContent).toBe("320x40");
42
+ } finally {
43
+ act(() => root.unmount());
44
+ host.remove();
45
+ globalThis.ResizeObserver = originalResizeObserver;
46
+ }
47
+ });
@@ -0,0 +1,31 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+ import { useMountEffect } from "./useMountEffect";
3
+
4
+ interface StripSize {
5
+ width: number;
6
+ height: number;
7
+ }
8
+
9
+ const resize = (width: number, height: number) => (prev: StripSize) =>
10
+ prev.width === width && prev.height === height ? prev : { width, height };
11
+
12
+ /** Size of the thumbnail's parent, kept current by a ResizeObserver; 0x0 until measured. */
13
+ export function useThumbnailStripSize() {
14
+ const [size, setSize] = useState<StripSize>({ width: 0, height: 0 });
15
+ const observerRef = useRef<ResizeObserver | null>(null);
16
+
17
+ const ref = useCallback((element: HTMLDivElement | null) => {
18
+ observerRef.current?.disconnect();
19
+ if (!element) return;
20
+ const target = element.parentElement ?? element;
21
+ setSize(resize(target.clientWidth, target.clientHeight));
22
+ observerRef.current = new ResizeObserver(([entry]) =>
23
+ setSize(resize(entry.contentRect.width, entry.contentRect.height)),
24
+ );
25
+ observerRef.current.observe(target);
26
+ }, []);
27
+
28
+ useMountEffect(() => () => observerRef.current?.disconnect());
29
+
30
+ return [size, ref] as const;
31
+ }
@@ -109,8 +109,6 @@ export function useTimelineAssetDropOps({
109
109
  }
110
110
 
111
111
  try {
112
- const originalContent = await readFileContent(pid, targetPath);
113
-
114
112
  const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
115
113
  const duration =
116
114
  Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0
@@ -120,7 +118,6 @@ export function useTimelineAssetDropOps({
120
118
  // A video with an audio stream lands audible; the mixer only hears a
121
119
  // <video> marked data-has-audio, and a muted drop was losing the sound.
122
120
  const hasAudio = await resolveDroppedAssetHasAudio(pid, assetPath, kind);
123
- const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
124
121
  const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
125
122
 
126
123
  const resolvedTargetPath = targetPath || "index.html";
@@ -129,45 +126,51 @@ export function useTimelineAssetDropOps({
129
126
  );
130
127
  const newElementZIndex = Math.max(1, relevantElements.length + 1);
131
128
 
132
- const { source: sourceWithRoom, track } = resolveDropTrack({
133
- source: originalContent,
134
- // insertRow counts the rows the timeline shows, so plan against those.
135
- elements: relevantElements,
136
- placement,
137
- dropped: {
138
- id: newId,
139
- tag: kind === "image" ? "img" : kind,
140
- start: normalizedStart,
141
- duration: normalizedDuration,
142
- },
143
- });
144
- const patchedContent = extendRootDurationInSource(
145
- insertTimelineAssetIntoSource(
146
- sourceWithRoom,
147
- buildTimelineAssetInsertHtml({
129
+ let newId = "";
130
+ let track = 0;
131
+ const insertAsset = (originalContent: string) => {
132
+ newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
133
+ const resolved = resolveDropTrack({
134
+ source: originalContent,
135
+ // insertRow counts the rows the timeline shows, so plan against those.
136
+ elements: relevantElements,
137
+ placement,
138
+ dropped: {
148
139
  id: newId,
149
- hfId: `hf-${generateId()}`,
150
- assetPath: resolvedAssetSrc,
151
- kind,
140
+ tag: kind === "image" ? "img" : kind,
152
141
  start: normalizedStart,
153
142
  duration: normalizedDuration,
154
- track,
155
- zIndex: newElementZIndex,
156
- hasAudio,
157
- geometry: fitTimelineAssetGeometry(
158
- null,
159
- resolveTimelineAssetCompositionSize(originalContent),
160
- ),
161
- }),
162
- ),
163
- normalizedStart + normalizedDuration,
164
- );
143
+ },
144
+ });
145
+ track = resolved.track;
146
+ return extendRootDurationInSource(
147
+ insertTimelineAssetIntoSource(
148
+ resolved.source,
149
+ buildTimelineAssetInsertHtml({
150
+ id: newId,
151
+ hfId: `hf-${generateId()}`,
152
+ assetPath: resolvedAssetSrc,
153
+ kind,
154
+ start: normalizedStart,
155
+ duration: normalizedDuration,
156
+ track,
157
+ zIndex: newElementZIndex,
158
+ hasAudio,
159
+ geometry: fitTimelineAssetGeometry(
160
+ null,
161
+ resolveTimelineAssetCompositionSize(originalContent),
162
+ ),
163
+ }),
164
+ ),
165
+ normalizedStart + normalizedDuration,
166
+ );
167
+ };
165
168
 
166
169
  await saveProjectFilesWithHistory({
167
170
  projectId: pid,
168
171
  label: "Add timeline asset",
169
- files: { [targetPath]: patchedContent },
170
- readFile: async () => originalContent,
172
+ files: { [targetPath]: insertAsset },
173
+ readFile: (path) => readFileContent(pid, path),
171
174
  writeFile: writeProjectFile,
172
175
  recordEdit,
173
176
  });