@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.
- package/dist/assets/{hyperframes-player-CgnoxDoD.js → hyperframes-player-zGIbZhLJ.js} +1 -1
- package/dist/assets/{index-J9HXe7mU.js → index-BiLCTunu.js} +1 -1
- package/dist/assets/{index-CsG24Jub.js → index-DSazIt7R.js} +198 -198
- package/dist/assets/{index-DZGVvqDT.js → index-DStmfnpZ.js} +1 -1
- package/dist/index.d.ts +172 -7
- package/dist/index.html +1 -1
- package/dist/index.js +2567 -2314
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +8 -9
- package/src/components/StudioRightPanels.tsx +4 -48
- package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
- package/src/components/TimelineHistoryButtons.tsx +26 -14
- package/src/components/TimelineToolbar.test.tsx +9 -1
- package/src/components/TimelineToolbar.tsx +13 -5
- package/src/components/editor/BlockParamsPanel.tsx +14 -10
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
- package/src/components/editor/manualEdits.test.ts +24 -0
- package/src/components/editor/manualEdits.ts +6 -2
- package/src/components/editor/manualEditsDom.ts +0 -65
- package/src/components/editor/manualEditsSeekReapply.ts +136 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +71 -32
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
- package/src/components/editor/propertyPanelHelpers.ts +15 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
- package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
- package/src/components/editor/useFxLevelling.ts +35 -10
- package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
- package/src/components/nle/AudioMeterStrip.tsx +23 -12
- package/src/components/panels/VariablesOtherCompositions.tsx +4 -4
- package/src/components/panels/VariablesPanel.test.tsx +86 -0
- package/src/components/panels/VariablesPanel.tsx +3 -3
- package/src/components/pointerTargetSize.test.tsx +1 -1
- package/src/components/sidebar/SidebarLintButton.test.tsx +29 -0
- package/src/components/sidebar/SidebarLintButton.tsx +4 -0
- package/src/components/studioColorGradingScope.ts +16 -42
- package/src/contexts/TimelineEditContext.test.tsx +49 -0
- package/src/contexts/TimelineEditContext.tsx +27 -23
- package/src/hooks/canvasQuickSaves.test.tsx +195 -0
- package/src/hooks/gsapMutationClient.ts +116 -0
- package/src/hooks/resizeObserverTestUtils.ts +14 -0
- package/src/hooks/timelineAudioGroupCreate.ts +18 -40
- package/src/hooks/timelineEditingHelpers.test.ts +21 -0
- package/src/hooks/timelineEditingHelpers.ts +66 -61
- package/src/hooks/timelineQuickSaves.test.ts +195 -0
- package/src/hooks/timelineTimingSync.test.ts +12 -0
- package/src/hooks/timelineTimingSync.ts +31 -123
- package/src/hooks/timelineTrackVisibility.ts +15 -29
- package/src/hooks/useApplyColorGradingScope.test.tsx +53 -0
- package/src/hooks/useApplyColorGradingScope.ts +54 -0
- package/src/hooks/useAskAgentModal.test.tsx +78 -0
- package/src/hooks/useAskAgentModal.ts +3 -1
- package/src/hooks/useClipboard.source.test.ts +32 -0
- package/src/hooks/useClipboard.ts +21 -20
- package/src/hooks/useDomEditCommits.test.tsx +13 -9
- package/src/hooks/useDomEditCommits.ts +88 -97
- package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useElementLifecycleOps.ts +36 -37
- package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
- package/src/hooks/useFileManager.ts +21 -12
- package/src/hooks/useGroupCommits.ts +23 -24
- package/src/hooks/useGsapAnimationOps.ts +26 -8
- package/src/hooks/useGsapScriptCommits.ts +1 -0
- package/src/hooks/useLintModal.test.tsx +15 -0
- package/src/hooks/useLintModal.ts +5 -2
- package/src/hooks/useProjectCompositionVariables.ts +3 -3
- package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
- package/src/hooks/useThumbnailStripSize.ts +31 -0
- package/src/hooks/useTimelineAssetDropOps.ts +38 -35
- package/src/hooks/useTimelineDeleteOps.ts +53 -56
- package/src/hooks/useTimelineEditing.ts +3 -1
- package/src/hooks/useTimelineGroupEditing.ts +5 -1
- package/src/index.ts +8 -0
- package/src/player/components/CompositionThumbnail.test.ts +40 -6
- package/src/player/components/CompositionThumbnail.tsx +6 -21
- package/src/player/components/ImageThumbnail.test.tsx +27 -6
- package/src/player/components/ImageThumbnail.tsx +4 -18
- package/src/player/components/PlayerControls.test.tsx +52 -0
- package/src/player/components/PlayerControls.tsx +2 -1
- package/src/player/components/Timeline.test.ts +28 -3
- package/src/player/components/TimelineCanvas.tsx +5 -3
- package/src/player/components/VideoThumbnail.test.tsx +33 -11
- package/src/player/components/VideoThumbnail.tsx +7 -29
- package/src/player/components/thumbnailUtils.test.ts +16 -20
- package/src/player/components/thumbnailUtils.ts +6 -7
- package/src/player/components/timelineEditing.test.ts +55 -0
- package/src/player/components/timelineGroupEditing.ts +13 -6
- package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
- package/src/player/components/timelinePlayheadTransform.ts +17 -0
- package/src/player/components/useTimelinePlayhead.ts +26 -8
- package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
- package/src/player/hooks/timelinePlayerSync.ts +18 -8
- package/src/player/hooks/useShadowPreviewReload.ts +15 -4
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
- package/src/player/lib/playbackShortcuts.ts +18 -0
- package/src/player/lib/playbackTypes.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +92 -0
- package/src/player/lib/timelineDOM.ts +0 -2
- package/src/player/lib/timelineElementHelpers.ts +47 -10
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/player/store/timelineElement.ts +2 -1
- package/src/timelineChromeExports.test.tsx +79 -0
- package/src/utils/authoredSource.ts +15 -0
- package/src/utils/blockInstaller.ts +32 -27
- package/src/utils/studioFileHistory.test.ts +14 -14
- 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
|
-
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
[
|
|
194
|
+
[
|
|
195
|
+
activeCompPath,
|
|
196
|
+
commitMutation,
|
|
197
|
+
projectIdRef,
|
|
198
|
+
showToast,
|
|
199
|
+
sdkSession,
|
|
200
|
+
sdkDeps,
|
|
201
|
+
writeProjectFile,
|
|
202
|
+
],
|
|
185
203
|
);
|
|
186
204
|
|
|
187
205
|
type KeyframeEntry = {
|
|
@@ -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
|
-
//
|
|
102
|
-
const
|
|
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
|
-
|
|
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 =
|
|
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
|
-
}, [
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
150
|
-
assetPath: resolvedAssetSrc,
|
|
151
|
-
kind,
|
|
140
|
+
tag: kind === "image" ? "img" : kind,
|
|
152
141
|
start: normalizedStart,
|
|
153
142
|
duration: normalizedDuration,
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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]:
|
|
170
|
-
readFile:
|
|
172
|
+
files: { [targetPath]: insertAsset },
|
|
173
|
+
readFile: (path) => readFileContent(pid, path),
|
|
171
174
|
writeFile: writeProjectFile,
|
|
172
175
|
recordEdit,
|
|
173
176
|
});
|