@hyperframes/studio 0.6.0-alpha.9 → 0.6.1

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 (111) hide show
  1. package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
  2. package/dist/assets/index-D1JDq7Gg.css +1 -0
  3. package/dist/assets/index-hYc4aP7M.js +117 -0
  4. package/dist/favicon.svg +14 -0
  5. package/dist/index.html +3 -2
  6. package/package.json +9 -9
  7. package/src/App.tsx +421 -4303
  8. package/src/captions/components/CaptionOverlay.tsx +13 -246
  9. package/src/captions/components/CaptionOverlayUtils.ts +221 -0
  10. package/src/components/AskAgentModal.tsx +120 -0
  11. package/src/components/StudioHeader.tsx +133 -0
  12. package/src/components/StudioLeftSidebar.tsx +125 -0
  13. package/src/components/StudioPreviewArea.tsx +167 -0
  14. package/src/components/StudioRightPanel.tsx +198 -0
  15. package/src/components/TimelineToolbar.tsx +89 -0
  16. package/src/components/editor/DomEditOverlay.tsx +88 -993
  17. package/src/components/editor/EaseCurveEditor.tsx +221 -0
  18. package/src/components/editor/FileTree.tsx +13 -621
  19. package/src/components/editor/FileTreeIcons.tsx +128 -0
  20. package/src/components/editor/FileTreeNodes.tsx +496 -0
  21. package/src/components/editor/MotionPanel.tsx +16 -390
  22. package/src/components/editor/MotionPanelFields.tsx +185 -0
  23. package/src/components/editor/PropertyPanel.test.ts +0 -49
  24. package/src/components/editor/PropertyPanel.tsx +132 -2763
  25. package/src/components/editor/domEditOverlayGeometry.ts +211 -0
  26. package/src/components/editor/domEditOverlayGestures.ts +138 -0
  27. package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
  28. package/src/components/editor/domEditing.ts +44 -1117
  29. package/src/components/editor/domEditingAgentPrompt.ts +97 -0
  30. package/src/components/editor/domEditingDom.ts +266 -0
  31. package/src/components/editor/domEditingElement.ts +329 -0
  32. package/src/components/editor/domEditingLayers.ts +460 -0
  33. package/src/components/editor/domEditingTypes.ts +125 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +2 -2
  35. package/src/components/editor/manualEditingAvailability.ts +1 -1
  36. package/src/components/editor/manualEdits.ts +84 -1049
  37. package/src/components/editor/manualEditsDom.ts +436 -0
  38. package/src/components/editor/manualEditsParsing.ts +280 -0
  39. package/src/components/editor/manualEditsSnapshot.ts +333 -0
  40. package/src/components/editor/manualEditsTypes.ts +141 -0
  41. package/src/components/editor/propertyPanelColor.tsx +371 -0
  42. package/src/components/editor/propertyPanelFill.tsx +421 -0
  43. package/src/components/editor/propertyPanelFont.tsx +455 -0
  44. package/src/components/editor/propertyPanelHelpers.ts +401 -0
  45. package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
  46. package/src/components/editor/propertyPanelSections.tsx +453 -0
  47. package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
  48. package/src/components/editor/studioMotion.ts +47 -434
  49. package/src/components/editor/studioMotionOps.ts +299 -0
  50. package/src/components/editor/studioMotionTypes.ts +168 -0
  51. package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
  52. package/src/components/editor/useDomEditOverlayRects.ts +207 -0
  53. package/src/components/nle/NLELayout.tsx +68 -155
  54. package/src/components/nle/NLEPreview.tsx +3 -0
  55. package/src/components/nle/useCompositionStack.ts +126 -0
  56. package/src/components/renders/RenderQueue.tsx +102 -31
  57. package/src/components/renders/useRenderQueue.ts +8 -2
  58. package/src/components/sidebar/LeftSidebar.tsx +186 -186
  59. package/src/contexts/DomEditContext.tsx +137 -0
  60. package/src/contexts/FileManagerContext.tsx +110 -0
  61. package/src/contexts/PanelLayoutContext.tsx +68 -0
  62. package/src/contexts/StudioContext.tsx +135 -0
  63. package/src/hooks/useAppHotkeys.ts +326 -0
  64. package/src/hooks/useAskAgentModal.ts +162 -0
  65. package/src/hooks/useCaptionDetection.ts +132 -0
  66. package/src/hooks/useCompositionDimensions.ts +25 -0
  67. package/src/hooks/useConsoleErrorCapture.ts +60 -0
  68. package/src/hooks/useDomEditCommits.ts +437 -0
  69. package/src/hooks/useDomEditSession.ts +342 -0
  70. package/src/hooks/useDomEditTextCommits.ts +330 -0
  71. package/src/hooks/useDomSelection.ts +398 -0
  72. package/src/hooks/useFileManager.ts +431 -0
  73. package/src/hooks/useFrameCapture.ts +77 -0
  74. package/src/hooks/useLintModal.ts +35 -0
  75. package/src/hooks/useManifestPersistence.ts +492 -0
  76. package/src/hooks/usePanelLayout.ts +68 -0
  77. package/src/hooks/usePreviewInteraction.ts +153 -0
  78. package/src/hooks/useRenderClipContent.ts +124 -0
  79. package/src/hooks/useTimelineEditing.ts +472 -0
  80. package/src/hooks/useToast.ts +20 -0
  81. package/src/player/components/Player.tsx +33 -2
  82. package/src/player/components/Timeline.test.ts +0 -8
  83. package/src/player/components/Timeline.tsx +196 -1518
  84. package/src/player/components/TimelineCanvas.tsx +434 -0
  85. package/src/player/components/TimelineClip.tsx +9 -244
  86. package/src/player/components/TimelineEmptyState.tsx +102 -0
  87. package/src/player/components/TimelineRuler.tsx +90 -0
  88. package/src/player/components/timelineIcons.tsx +49 -0
  89. package/src/player/components/timelineLayout.ts +215 -0
  90. package/src/player/components/timelineUtils.ts +211 -0
  91. package/src/player/components/useTimelineClipDrag.ts +388 -0
  92. package/src/player/components/useTimelinePlayhead.ts +200 -0
  93. package/src/player/components/useTimelineRangeSelection.ts +135 -0
  94. package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
  95. package/src/player/hooks/useTimelinePlayer.ts +105 -1371
  96. package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
  97. package/src/player/lib/playbackAdapter.ts +145 -0
  98. package/src/player/lib/playbackShortcuts.ts +68 -0
  99. package/src/player/lib/playbackTypes.ts +60 -0
  100. package/src/player/lib/timelineDOM.ts +373 -0
  101. package/src/player/lib/timelineElementHelpers.ts +303 -0
  102. package/src/player/lib/timelineIframeHelpers.ts +269 -0
  103. package/src/utils/domEditHelpers.ts +50 -0
  104. package/src/utils/studioFontHelpers.ts +83 -0
  105. package/src/utils/studioHelpers.ts +214 -0
  106. package/src/utils/studioPreviewHelpers.ts +185 -0
  107. package/src/utils/timelineDiscovery.ts +1 -1
  108. package/dist/assets/hyperframes-player-DjsVzYFP.js +0 -418
  109. package/dist/assets/index-14zH9lqh.css +0 -1
  110. package/dist/assets/index-DYCiFGWQ.js +0 -108
  111. package/src/player/components/TimelineClip.test.ts +0 -92
@@ -0,0 +1,431 @@
1
+ import { useState, useCallback, useRef, useEffect, useMemo } from "react";
2
+ import type { EditingFile } from "../utils/studioHelpers";
3
+ import { FONT_EXT, isMediaFile } from "../utils/mediaTypes";
4
+ import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
5
+ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
6
+ import type { EditHistoryKind } from "../utils/editHistory";
7
+
8
+ // ── Types ──
9
+
10
+ interface RecordEditInput {
11
+ label: string;
12
+ kind: EditHistoryKind;
13
+ coalesceKey?: string;
14
+ files: Record<string, { before: string; after: string }>;
15
+ }
16
+
17
+ interface UseFileManagerOptions {
18
+ projectId: string | null;
19
+ showToast: (message: string, tone?: "error" | "info") => void;
20
+ recordEdit: (input: RecordEditInput) => Promise<void>;
21
+ domEditSaveTimestampRef: React.MutableRefObject<number>;
22
+ setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
23
+ }
24
+
25
+ // ── Hook ──
26
+
27
+ export function useFileManager({
28
+ projectId,
29
+ showToast,
30
+ recordEdit,
31
+ domEditSaveTimestampRef,
32
+ setRefreshKey,
33
+ }: UseFileManagerOptions) {
34
+ // ── State ──
35
+
36
+ const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
37
+ const [projectDir, setProjectDir] = useState<string | null>(null);
38
+ const [fileTree, setFileTree] = useState<string[]>([]);
39
+
40
+ // ── Refs ──
41
+
42
+ const editingPathRef = useRef(editingFile?.path);
43
+ editingPathRef.current = editingFile?.path;
44
+
45
+ const projectIdRef = useRef(projectId);
46
+ projectIdRef.current = projectId;
47
+
48
+ const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
49
+ const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
50
+ const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
51
+
52
+ // ── Load file tree when projectId changes ──
53
+
54
+ // eslint-disable-next-line no-restricted-syntax
55
+ useEffect(() => {
56
+ if (!projectId) return;
57
+ let cancelled = false;
58
+ fetch(`/api/projects/${projectId}`)
59
+ .then((r) => r.json())
60
+ .then((data: { files?: string[]; dir?: string }) => {
61
+ if (!cancelled && data.files) setFileTree(data.files);
62
+ if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null);
63
+ })
64
+ .catch(() => {
65
+ if (!cancelled) setProjectDir(null);
66
+ });
67
+ return () => {
68
+ cancelled = true;
69
+ };
70
+ }, [projectId]);
71
+
72
+ // ── Core file I/O ──
73
+
74
+ const readProjectFile = useCallback(async (path: string): Promise<string> => {
75
+ const pid = projectIdRef.current;
76
+ if (!pid) throw new Error("No active project");
77
+ const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
78
+ if (!response.ok) throw new Error(`Failed to read ${path}`);
79
+ const data = (await response.json()) as { content?: string };
80
+ if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
81
+ return data.content;
82
+ }, []);
83
+
84
+ const writeProjectFile = useCallback(async (path: string, content: string): Promise<void> => {
85
+ const pid = projectIdRef.current;
86
+ if (!pid) throw new Error("No active project");
87
+ const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
88
+ method: "PUT",
89
+ headers: { "Content-Type": "text/plain" },
90
+ body: content,
91
+ });
92
+ if (!response.ok) throw new Error(`Failed to save ${path}`);
93
+ if (editingPathRef.current === path) {
94
+ setEditingFile({ path, content });
95
+ }
96
+ }, []);
97
+
98
+ const readOptionalProjectFile = useCallback(async (path: string): Promise<string> => {
99
+ const pid = projectIdRef.current;
100
+ if (!pid) throw new Error("No active project");
101
+ const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
102
+ if (response.status === 404) return "";
103
+ if (!response.ok) throw new Error(`Failed to read ${path}`);
104
+ const data = (await response.json()) as { content?: string };
105
+ return typeof data.content === "string" ? data.content : "";
106
+ }, []);
107
+
108
+ // ── File select ──
109
+
110
+ const handleFileSelect = useCallback((path: string) => {
111
+ const pid = projectIdRef.current;
112
+ if (!pid) return;
113
+ // Skip fetching binary content for media files — just set the path for preview
114
+ if (isMediaFile(path)) {
115
+ setEditingFile({ path, content: null });
116
+ return;
117
+ }
118
+ fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`)
119
+ .then((r) => r.json())
120
+ .then((data: { content?: string }) => {
121
+ if (data.content != null) {
122
+ setEditingFile({ path, content: data.content });
123
+ }
124
+ })
125
+ .catch(() => {});
126
+ }, []);
127
+
128
+ // ── Content change (debounced save) ──
129
+
130
+ const handleContentChange = useCallback(
131
+ (content: string) => {
132
+ const pid = projectIdRef.current;
133
+ if (!pid) return;
134
+ const path = editingPathRef.current;
135
+ if (!path) return;
136
+
137
+ // Debounce the server write (600ms)
138
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
139
+ saveTimerRef.current = setTimeout(() => {
140
+ // Suppress the file-change watcher echo — the save callback triggers
141
+ // its own refresh, so a second one from the watcher causes a double-reload
142
+ // race that can leave the player in a non-playable state.
143
+ domEditSaveTimestampRef.current = Date.now();
144
+ saveProjectFilesWithHistory({
145
+ projectId: pid,
146
+ label: "Edit source",
147
+ kind: "source",
148
+ coalesceKey: `source:${path}`,
149
+ files: { [path]: content },
150
+ readFile: readProjectFile,
151
+ writeFile: writeProjectFile,
152
+ recordEdit,
153
+ })
154
+ .then(() => {
155
+ if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
156
+ refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
157
+ })
158
+ .catch(() => {});
159
+ }, 600);
160
+ },
161
+ [domEditSaveTimestampRef, readProjectFile, recordEdit, setRefreshKey, writeProjectFile],
162
+ );
163
+
164
+ // ── File tree refresh ──
165
+
166
+ const refreshFileTree = useCallback(async () => {
167
+ const pid = projectIdRef.current;
168
+ if (!pid) return;
169
+ const res = await fetch(`/api/projects/${pid}`);
170
+ const data = await res.json();
171
+ if (data.files) setFileTree(data.files);
172
+ }, []);
173
+
174
+ // ── Upload ──
175
+
176
+ const uploadProjectFiles = useCallback(
177
+ async (files: Iterable<File>, dir?: string): Promise<string[]> => {
178
+ const pid = projectIdRef.current;
179
+ const fileList = Array.from(files);
180
+ if (!pid || fileList.length === 0) return [];
181
+
182
+ const formData = new FormData();
183
+ for (const file of fileList) {
184
+ formData.append("file", file);
185
+ }
186
+
187
+ const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
188
+ try {
189
+ const res = await fetch(`/api/projects/${pid}/upload${qs}`, {
190
+ method: "POST",
191
+ body: formData,
192
+ });
193
+ if (res.ok) {
194
+ const data = await res.json();
195
+ if (data.skipped?.length) {
196
+ showToast(`Skipped (too large): ${data.skipped.join(", ")}`);
197
+ }
198
+ if (data.invalid?.length) {
199
+ const names = data.invalid.map((entry: { name: string }) => entry.name).join(", ");
200
+ showToast(`Unsupported media skipped: ${names}`);
201
+ }
202
+ await refreshFileTree();
203
+ setRefreshKey((k) => k + 1);
204
+ return Array.isArray(data.files) ? data.files : [];
205
+ } else if (res.status === 413) {
206
+ showToast("Upload rejected: payload too large");
207
+ } else {
208
+ showToast(`Upload failed (${res.status})`);
209
+ }
210
+ } catch {
211
+ showToast("Upload failed: network error");
212
+ }
213
+ return [];
214
+ },
215
+ [refreshFileTree, setRefreshKey, showToast],
216
+ );
217
+
218
+ // ── File management handlers ──
219
+
220
+ const handleCreateFile = useCallback(
221
+ async (path: string) => {
222
+ const pid = projectIdRef.current;
223
+ if (!pid) return;
224
+ let content = "";
225
+ if (path.endsWith(".html")) {
226
+ content =
227
+ '<!DOCTYPE html>\n<html>\n<head>\n <meta charset="UTF-8">\n</head>\n<body>\n\n</body>\n</html>\n';
228
+ }
229
+ const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
230
+ method: "POST",
231
+ headers: { "Content-Type": "text/plain" },
232
+ body: content,
233
+ });
234
+ if (res.ok) {
235
+ await refreshFileTree();
236
+ handleFileSelect(path);
237
+ } else {
238
+ const err = await res.json().catch(() => ({ error: "unknown" }));
239
+ console.error(`Create file failed: ${err.error}`);
240
+ }
241
+ },
242
+ [refreshFileTree, handleFileSelect],
243
+ );
244
+
245
+ const handleCreateFolder = useCallback(
246
+ async (path: string) => {
247
+ const pid = projectIdRef.current;
248
+ if (!pid) return;
249
+ // Create a .gitkeep inside the folder so it appears in the tree
250
+ const res = await fetch(
251
+ `/api/projects/${pid}/files/${encodeURIComponent(path + "/.gitkeep")}`,
252
+ {
253
+ method: "POST",
254
+ headers: { "Content-Type": "text/plain" },
255
+ body: "",
256
+ },
257
+ );
258
+ if (res.ok) {
259
+ await refreshFileTree();
260
+ } else {
261
+ const err = await res.json().catch(() => ({ error: "unknown" }));
262
+ console.error(`Create folder failed: ${err.error}`);
263
+ }
264
+ },
265
+ [refreshFileTree],
266
+ );
267
+
268
+ const handleDeleteFile = useCallback(
269
+ async (path: string) => {
270
+ const pid = projectIdRef.current;
271
+ if (!pid) return;
272
+ const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
273
+ method: "DELETE",
274
+ });
275
+ if (res.ok) {
276
+ if (editingPathRef.current === path) setEditingFile(null);
277
+ await refreshFileTree();
278
+ } else {
279
+ const err = await res.json().catch(() => ({ error: "unknown" }));
280
+ console.error(`Delete failed: ${err.error}`);
281
+ }
282
+ },
283
+ [refreshFileTree],
284
+ );
285
+
286
+ const handleRenameFile = useCallback(
287
+ async (oldPath: string, newPath: string) => {
288
+ const pid = projectIdRef.current;
289
+ if (!pid) return;
290
+ const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(oldPath)}`, {
291
+ method: "PATCH",
292
+ headers: { "Content-Type": "application/json" },
293
+ body: JSON.stringify({ newPath }),
294
+ });
295
+ if (res.ok) {
296
+ if (editingPathRef.current === oldPath) {
297
+ handleFileSelect(newPath);
298
+ }
299
+ await refreshFileTree();
300
+ // Refresh preview — references in compositions may have been updated
301
+ setRefreshKey((k) => k + 1);
302
+ } else {
303
+ const err = await res.json().catch(() => ({ error: "unknown" }));
304
+ console.error(`Rename failed: ${err.error}`);
305
+ }
306
+ },
307
+ [refreshFileTree, handleFileSelect, setRefreshKey],
308
+ );
309
+
310
+ const handleDuplicateFile = useCallback(
311
+ async (path: string) => {
312
+ const pid = projectIdRef.current;
313
+ if (!pid) return;
314
+ const res = await fetch(`/api/projects/${pid}/duplicate-file`, {
315
+ method: "POST",
316
+ headers: { "Content-Type": "application/json" },
317
+ body: JSON.stringify({ path }),
318
+ });
319
+ if (res.ok) {
320
+ const data = await res.json();
321
+ await refreshFileTree();
322
+ if (data.path) handleFileSelect(data.path);
323
+ } else {
324
+ const err = await res.json().catch(() => ({ error: "unknown" }));
325
+ console.error(`Duplicate failed: ${err.error}`);
326
+ }
327
+ },
328
+ [refreshFileTree, handleFileSelect],
329
+ );
330
+
331
+ const handleMoveFile = handleRenameFile;
332
+
333
+ const handleImportFiles = useCallback(
334
+ async (files: FileList | File[], dir?: string) => {
335
+ return uploadProjectFiles(Array.from(files), dir);
336
+ },
337
+ [uploadProjectFiles],
338
+ );
339
+
340
+ const handleImportFonts = useCallback(
341
+ async (files: FileList | File[]): Promise<ImportedFontAsset[]> => {
342
+ const uploaded = await uploadProjectFiles(
343
+ Array.from(files).filter((file) => FONT_EXT.test(file.name)),
344
+ "assets/fonts",
345
+ );
346
+ const pid = projectIdRef.current;
347
+ const imported = uploaded
348
+ .filter((asset) => FONT_EXT.test(asset))
349
+ .map((asset) => ({
350
+ family: fontFamilyFromAssetPath(asset),
351
+ path: asset,
352
+ url: `/api/projects/${pid}/preview/${asset}`,
353
+ }));
354
+ importedFontAssetsRef.current = [
355
+ ...imported,
356
+ ...importedFontAssetsRef.current.filter(
357
+ (existing) =>
358
+ !imported.some((font) => font.family.toLowerCase() === existing.family.toLowerCase()),
359
+ ),
360
+ ];
361
+ return imported;
362
+ },
363
+ [uploadProjectFiles],
364
+ );
365
+
366
+ // ── Derived state ──
367
+
368
+ const compositions = useMemo(
369
+ () => fileTree.filter((f) => f === "index.html" || f.startsWith("compositions/")),
370
+ [fileTree],
371
+ );
372
+
373
+ const assets = useMemo(
374
+ () =>
375
+ fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")),
376
+ [fileTree],
377
+ );
378
+
379
+ const fontAssets = useMemo<ImportedFontAsset[]>(
380
+ () =>
381
+ assets
382
+ .filter((asset) => FONT_EXT.test(asset))
383
+ .map((asset) => ({
384
+ family: fontFamilyFromAssetPath(asset),
385
+ path: asset,
386
+ url: `/api/projects/${projectId}/preview/${asset}`,
387
+ })),
388
+ [assets, projectId],
389
+ );
390
+
391
+ // ── Return ──
392
+
393
+ return {
394
+ // State
395
+ editingFile,
396
+ setEditingFile,
397
+ projectDir,
398
+ fileTree,
399
+ setFileTree,
400
+
401
+ // Refs
402
+ editingPathRef,
403
+ projectIdRef,
404
+ saveTimerRef,
405
+ importedFontAssetsRef,
406
+
407
+ // Core I/O
408
+ readProjectFile,
409
+ writeProjectFile,
410
+ readOptionalProjectFile,
411
+
412
+ // Callbacks
413
+ handleFileSelect,
414
+ handleContentChange,
415
+ refreshFileTree,
416
+ uploadProjectFiles,
417
+ handleCreateFile,
418
+ handleCreateFolder,
419
+ handleDeleteFile,
420
+ handleRenameFile,
421
+ handleDuplicateFile,
422
+ handleMoveFile,
423
+ handleImportFiles,
424
+ handleImportFonts,
425
+
426
+ // Derived
427
+ compositions,
428
+ assets,
429
+ fontAssets,
430
+ };
431
+ }
@@ -0,0 +1,77 @@
1
+ import { useState, useCallback, type MouseEvent } from "react";
2
+ import { useMountEffect } from "./useMountEffect";
3
+ import { liveTime, usePlayerStore } from "../player";
4
+ import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "../utils/frameCapture";
5
+
6
+ interface UseFrameCaptureParams {
7
+ projectId: string | null;
8
+ activeCompPath: string | null;
9
+ showToast: (message: string, tone?: "error" | "info") => void;
10
+ waitForPendingDomEditSaves: () => Promise<void>;
11
+ }
12
+
13
+ export function useFrameCapture({
14
+ projectId,
15
+ activeCompPath,
16
+ showToast,
17
+ waitForPendingDomEditSaves,
18
+ }: UseFrameCaptureParams) {
19
+ const [captureFrameTime, setCaptureFrameTime] = useState(0);
20
+
21
+ useMountEffect(() => {
22
+ setCaptureFrameTime(usePlayerStore.getState().currentTime);
23
+ return liveTime.subscribe(setCaptureFrameTime);
24
+ });
25
+
26
+ const refreshCaptureFrameTime = useCallback(() => {
27
+ setCaptureFrameTime(usePlayerStore.getState().currentTime);
28
+ }, []);
29
+
30
+ const handleCaptureFrameClick = useCallback(
31
+ async (event: MouseEvent<HTMLAnchorElement>) => {
32
+ if (!projectId) return;
33
+ event.preventDefault();
34
+ const time = usePlayerStore.getState().currentTime;
35
+ setCaptureFrameTime(time);
36
+ await waitForPendingDomEditSaves();
37
+ const href = buildFrameCaptureUrl({
38
+ projectId,
39
+ compositionPath: activeCompPath,
40
+ currentTime: time,
41
+ });
42
+ const filename = buildFrameCaptureFilename(activeCompPath, time);
43
+ try {
44
+ const response = await fetch(href, { cache: "no-store" });
45
+ if (!response.ok) throw new Error(`Capture failed (${response.status})`);
46
+ const blob = await response.blob();
47
+ const blobUrl = URL.createObjectURL(blob);
48
+ const link = document.createElement("a");
49
+ link.href = blobUrl;
50
+ link.download = filename;
51
+ document.body.appendChild(link);
52
+ link.click();
53
+ link.remove();
54
+ setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
55
+ } catch (err) {
56
+ showToast(err instanceof Error ? err.message : "Capture failed");
57
+ }
58
+ },
59
+ [activeCompPath, projectId, showToast, waitForPendingDomEditSaves],
60
+ );
61
+
62
+ const captureFrameHref = projectId
63
+ ? buildFrameCaptureUrl({
64
+ projectId,
65
+ compositionPath: activeCompPath,
66
+ currentTime: captureFrameTime,
67
+ })
68
+ : "#";
69
+ const captureFrameFilename = buildFrameCaptureFilename(activeCompPath, captureFrameTime);
70
+
71
+ return {
72
+ captureFrameHref,
73
+ captureFrameFilename,
74
+ handleCaptureFrameClick,
75
+ refreshCaptureFrameTime,
76
+ };
77
+ }
@@ -0,0 +1,35 @@
1
+ import { useState, useCallback } from "react";
2
+ import type { LintFinding } from "../components/LintModal";
3
+
4
+ export function useLintModal(projectId: string | null) {
5
+ const [lintModal, setLintModal] = useState<LintFinding[] | null>(null);
6
+ const [linting, setLinting] = useState(false);
7
+
8
+ const handleLint = useCallback(async () => {
9
+ if (!projectId) return;
10
+ setLinting(true);
11
+ try {
12
+ const res = await fetch(`/api/projects/${projectId}/lint`);
13
+ const data = await res.json();
14
+ setLintModal(
15
+ (data.findings ?? []).map(
16
+ (f: { severity?: string; message?: string; file?: string; fixHint?: string }) => ({
17
+ severity: f.severity === "error" ? ("error" as const) : ("warning" as const),
18
+ message: f.message ?? "",
19
+ file: f.file,
20
+ fixHint: f.fixHint,
21
+ }),
22
+ ),
23
+ );
24
+ } catch (err) {
25
+ const msg = err instanceof Error ? err.message : String(err);
26
+ setLintModal([{ severity: "error", message: `Failed to run lint: ${msg}` }]);
27
+ } finally {
28
+ setLinting(false);
29
+ }
30
+ }, [projectId]);
31
+
32
+ const closeLintModal = useCallback(() => setLintModal(null), []);
33
+
34
+ return { lintModal, linting, handleLint, closeLintModal };
35
+ }