@hyperframes/studio 0.7.36 → 0.7.38

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 (75) hide show
  1. package/dist/assets/hyperframes-player-BBrKzTGd.js +459 -0
  2. package/dist/assets/index-BLRTwY5l.js +396 -0
  3. package/dist/assets/{index-BltxqwG6.js → index-ClUipc8i.js} +1 -1
  4. package/dist/assets/index-DJaiR8T2.css +1 -0
  5. package/dist/assets/{index-_bjyggFK.js → index-Ykq7ihge.js} +1 -1
  6. package/dist/index.d.ts +35 -10
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +5147 -3322
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +19 -18
  12. package/src/components/AskAgentModal.tsx +23 -6
  13. package/src/components/LintModal.tsx +40 -10
  14. package/src/components/MediaPreview.tsx +36 -1
  15. package/src/components/SaveQueuePausedBanner.tsx +7 -6
  16. package/src/components/StudioErrorBoundary.tsx +19 -31
  17. package/src/components/StudioFeedbackBar.tsx +11 -2
  18. package/src/components/StudioGlobalDragOverlay.tsx +1 -1
  19. package/src/components/StudioHeader.tsx +184 -106
  20. package/src/components/StudioLeftSidebar.tsx +24 -3
  21. package/src/components/StudioOverlays.tsx +34 -8
  22. package/src/components/StudioRightPanel.tsx +154 -9
  23. package/src/components/StudioSplash.tsx +6 -3
  24. package/src/components/StudioToast.tsx +12 -11
  25. package/src/components/TimelineToolbar.test.tsx +9 -3
  26. package/src/components/TimelineToolbar.tsx +63 -29
  27. package/src/components/editor/PropertyPanel.tsx +18 -17
  28. package/src/components/editor/colorGradingScopePatch.test.ts +74 -0
  29. package/src/components/editor/colorGradingScopePatch.ts +57 -0
  30. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  31. package/src/components/editor/manualEditingAvailability.ts +0 -6
  32. package/src/components/editor/propertyPanelColorGradingControls.tsx +371 -329
  33. package/src/components/editor/propertyPanelColorGradingSection.tsx +274 -55
  34. package/src/components/editor/propertyPanelColorGradingSlider.tsx +275 -0
  35. package/src/components/editor/propertyPanelHelpers.ts +22 -91
  36. package/src/components/editor/propertyPanelMediaSection.tsx +253 -99
  37. package/src/components/editor/propertyPanelTypes.ts +111 -0
  38. package/src/components/nle/CompositionBreadcrumb.tsx +3 -2
  39. package/src/components/nle/NLELayout.tsx +54 -51
  40. package/src/components/nle/NLEPreview.tsx +41 -5
  41. package/src/components/nle/TimelineResizeDivider.tsx +97 -0
  42. package/src/components/nle/usePreviewBlockDrop.ts +31 -7
  43. package/src/components/renders/RenderQueue.tsx +125 -24
  44. package/src/components/renders/RenderQueueItem.tsx +132 -64
  45. package/src/components/renders/useRenderQueue.ts +136 -24
  46. package/src/components/storyboard/StoryboardFrameFocus.tsx +39 -16
  47. package/src/components/storyboard/StoryboardLoaded.tsx +19 -2
  48. package/src/components/storyboard/StoryboardView.tsx +6 -0
  49. package/src/components/studioColorGradingScope.ts +124 -0
  50. package/src/components/studioMediaJobs.ts +123 -0
  51. package/src/components/ui/Button.tsx +20 -11
  52. package/src/components/ui/HyperframesLoader.tsx +9 -2
  53. package/src/components/ui/SearchInput.tsx +53 -0
  54. package/src/components/ui/Tooltip.tsx +52 -6
  55. package/src/components/ui/VideoFrameThumbnail.tsx +26 -3
  56. package/src/components/ui/useDialogBehavior.ts +83 -0
  57. package/src/contexts/PanelLayoutContext.tsx +3 -0
  58. package/src/contexts/StudioContext.tsx +7 -0
  59. package/src/hooks/useCompositionContentLoader.ts +40 -0
  60. package/src/hooks/useEditorSave.ts +14 -0
  61. package/src/hooks/useFileManager.ts +39 -25
  62. package/src/hooks/useFrameCapture.ts +12 -1
  63. package/src/hooks/usePanelLayout.ts +1 -0
  64. package/src/hooks/usePreviewPersistence.ts +24 -15
  65. package/src/hooks/useStudioContextValue.ts +5 -0
  66. package/src/hooks/useToast.ts +66 -13
  67. package/src/styles/studio.css +61 -0
  68. package/src/utils/sdkResolverShadow.test.ts +70 -3
  69. package/src/utils/sdkResolverShadow.ts +47 -4
  70. package/src/utils/studioPendingEdits.test.ts +43 -0
  71. package/src/utils/studioPendingEdits.ts +45 -0
  72. package/src/utils/studioUiPreferences.ts +4 -0
  73. package/dist/assets/hyperframes-player-BGuumSiM.js +0 -459
  74. package/dist/assets/index-DfeE1_Rl.js +0 -396
  75. package/dist/assets/index-DmkOvZns.css +0 -1
@@ -36,23 +36,66 @@ export interface StartRenderOptions {
36
36
  composition?: string;
37
37
  }
38
38
 
39
+ // "Hide" (formerly "Clear") is a view operation, not a delete: hidden ids are
40
+ // remembered here so hidden renders don't resurrect from the on-disk history
41
+ // on the next load. Per-project key so projects don't hide each other's rows.
42
+ function hiddenIdsKey(projectId: string): string {
43
+ return `hf-studio-hidden-renders:${projectId}`;
44
+ }
45
+
46
+ function readHiddenIds(projectId: string): Set<string> {
47
+ try {
48
+ const raw = window.localStorage.getItem(hiddenIdsKey(projectId));
49
+ const parsed: unknown = raw ? JSON.parse(raw) : [];
50
+ return new Set(Array.isArray(parsed) ? parsed.filter((v) => typeof v === "string") : []);
51
+ } catch {
52
+ return new Set();
53
+ }
54
+ }
55
+
56
+ function writeHiddenIds(projectId: string, ids: Set<string>): void {
57
+ try {
58
+ // Cap the list so it doesn't grow unbounded across months of renders.
59
+ window.localStorage.setItem(hiddenIdsKey(projectId), JSON.stringify([...ids].slice(-200)));
60
+ } catch {
61
+ /* localStorage may be unavailable or full */
62
+ }
63
+ }
64
+
39
65
  export function useRenderQueue(projectId: string | null) {
40
66
  const [jobs, setJobs] = useState<RenderJob[]>([]);
67
+ // History fetch failure — distinguished from "no renders yet" so the panel
68
+ // never shows a false empty state.
69
+ const [loadError, setLoadError] = useState<string | null>(null);
70
+ // Failure of a user action (delete/cancel), surfaced inline in the panel.
71
+ const [actionError, setActionError] = useState<string | null>(null);
41
72
  const eventSourceRef = useRef<EventSource | null>(null);
42
73
  const activeJobRef = useRef<string | null>(null);
43
74
 
75
+ const closeActiveEventSource = useCallback((jobId?: string) => {
76
+ if (jobId && activeJobRef.current !== jobId) return;
77
+ eventSourceRef.current?.close();
78
+ eventSourceRef.current = null;
79
+ activeJobRef.current = null;
80
+ }, []);
81
+
44
82
  // Load completed renders from the server
45
83
  const loadRenders = useCallback(async () => {
46
84
  if (!projectId) return;
47
85
  try {
48
86
  const res = await fetch(`/api/projects/${projectId}/renders`);
49
- if (!res.ok) return;
87
+ if (!res.ok) {
88
+ setLoadError(`Couldn't load render history (server error ${res.status}).`);
89
+ return;
90
+ }
50
91
  const data = await res.json();
92
+ setLoadError(null);
51
93
  if (Array.isArray(data.renders)) {
94
+ const hidden = readHiddenIds(projectId);
52
95
  setJobs((prev) => {
53
96
  const existing = new Set(prev.map((j) => j.id));
54
97
  const fromServer: RenderJob[] = data.renders
55
- .filter((r: { id: string }) => !existing.has(r.id))
98
+ .filter((r: { id: string }) => !existing.has(r.id) && !hidden.has(r.id))
56
99
  .map(
57
100
  (r: {
58
101
  id: string;
@@ -74,7 +117,7 @@ export function useRenderQueue(projectId: string | null) {
74
117
  });
75
118
  }
76
119
  } catch {
77
- // ignore
120
+ setLoadError("Couldn't load render history. Is the studio server running?");
78
121
  }
79
122
  }, [projectId]);
80
123
 
@@ -175,6 +218,8 @@ export function useRenderQueue(projectId: string | null) {
175
218
  es.addEventListener("progress", (event) => {
176
219
  try {
177
220
  const data = JSON.parse(event.data);
221
+ const terminal =
222
+ data.status === "complete" || data.status === "failed" || data.status === "cancelled";
178
223
  setJobs((prev) =>
179
224
  prev.map((j) =>
180
225
  j.id === jobId
@@ -182,21 +227,15 @@ export function useRenderQueue(projectId: string | null) {
182
227
  ...j,
183
228
  progress: data.progress ?? j.progress,
184
229
  stage: data.stage ?? data.message ?? j.stage,
185
- status:
186
- data.status === "complete"
187
- ? "complete"
188
- : data.status === "failed"
189
- ? "failed"
190
- : j.status,
230
+ status: terminal ? (data.status as RenderJob["status"]) : j.status,
191
231
  durationMs: data.status === "complete" ? Date.now() - startTime : undefined,
192
232
  error: data.error ?? j.error,
193
233
  }
194
234
  : j,
195
235
  ),
196
236
  );
197
- if (data.status === "complete" || data.status === "failed") {
198
- es.close();
199
- activeJobRef.current = null;
237
+ if (terminal) {
238
+ closeActiveEventSource(jobId);
200
239
  }
201
240
  } catch {
202
241
  // ignore parse errors
@@ -221,21 +260,78 @@ export function useRenderQueue(projectId: string | null) {
221
260
 
222
261
  return jobId;
223
262
  },
224
- [projectId],
263
+ [projectId, closeActiveEventSource],
225
264
  );
226
265
 
227
- const deleteRender = useCallback(async (jobId: string) => {
228
- try {
229
- await fetch(`/api/render/${jobId}`, { method: "DELETE" });
230
- } catch {
231
- // ignore
232
- }
233
- setJobs((prev) => prev.filter((j) => j.id !== jobId));
234
- }, []);
266
+ // Cancel an in-flight render. The job row stays (as "cancelled") so the
267
+ // user sees the outcome; the SSE stream is closed either way.
268
+ const cancelRender = useCallback(
269
+ async (jobId: string) => {
270
+ setActionError(null);
271
+ closeActiveEventSource(jobId);
272
+ setJobs((prev) =>
273
+ prev.map((j) =>
274
+ j.id === jobId && j.status === "rendering" ? { ...j, status: "cancelled" } : j,
275
+ ),
276
+ );
277
+ try {
278
+ const res = await fetch(`/api/render/${jobId}/cancel`, { method: "POST" });
279
+ if (!res.ok && res.status !== 404) {
280
+ setActionError("Couldn't cancel on the server — the render may still be running.");
281
+ return;
282
+ }
283
+ // Reconcile with the status the route reports: if the render actually
284
+ // finished (or failed) before the cancel landed, don't leave the row
285
+ // stuck on the optimistic "cancelled" — reload to pick up the real
286
+ // outcome (and the finished file's metadata).
287
+ if (res.ok) {
288
+ const body = (await res.json().catch(() => null)) as { status?: string } | null;
289
+ if (body?.status && body.status !== "cancelled") {
290
+ void loadRenders();
291
+ }
292
+ }
293
+ } catch {
294
+ setActionError("Couldn't reach the server to cancel — the render may still be running.");
295
+ }
296
+ },
297
+ [closeActiveEventSource, loadRenders],
298
+ );
235
299
 
300
+ const deleteRender = useCallback(
301
+ async (jobId: string) => {
302
+ setActionError(null);
303
+ closeActiveEventSource(jobId);
304
+ try {
305
+ const res = await fetch(`/api/render/${jobId}`, { method: "DELETE" });
306
+ if (!res.ok) {
307
+ setActionError("Couldn't delete the render — it's still on disk.");
308
+ return;
309
+ }
310
+ } catch {
311
+ setActionError("Couldn't reach the server to delete the render.");
312
+ return;
313
+ }
314
+ setJobs((prev) => prev.filter((j) => j.id !== jobId));
315
+ },
316
+ [closeActiveEventSource],
317
+ );
318
+
319
+ // Hide finished rows from the list (view-only — files stay on disk and can
320
+ // be recovered from the renders/ directory). Remembered per project so the
321
+ // rows don't resurrect from history on reload.
236
322
  const clearCompleted = useCallback(() => {
237
- setJobs((prev) => prev.filter((j) => j.status === "rendering"));
238
- }, []);
323
+ setJobs((prev) => {
324
+ const finished = prev.filter((j) => j.status !== "rendering");
325
+ if (projectId && finished.length > 0) {
326
+ const hidden = readHiddenIds(projectId);
327
+ for (const j of finished) hidden.add(j.id);
328
+ writeHiddenIds(projectId, hidden);
329
+ }
330
+ return prev.filter((j) => j.status === "rendering");
331
+ });
332
+ }, [projectId]);
333
+
334
+ const dismissActionError = useCallback(() => setActionError(null), []);
239
335
 
240
336
  // Clean up EventSource on unmount or projectId change
241
337
  useEffect(() => {
@@ -250,10 +346,26 @@ export function useRenderQueue(projectId: string | null) {
250
346
  () => ({
251
347
  jobs,
252
348
  isRendering,
349
+ loadError,
350
+ actionError,
351
+ dismissActionError,
352
+ reloadRenders: loadRenders,
253
353
  deleteRender,
354
+ cancelRender,
254
355
  clearCompleted,
255
356
  startRender: startRender as (options: unknown) => Promise<void>,
256
357
  }),
257
- [jobs, isRendering, deleteRender, clearCompleted, startRender],
358
+ [
359
+ jobs,
360
+ isRendering,
361
+ loadError,
362
+ actionError,
363
+ dismissActionError,
364
+ loadRenders,
365
+ deleteRender,
366
+ cancelRender,
367
+ clearCompleted,
368
+ startRender,
369
+ ],
258
370
  );
259
371
  }
@@ -3,6 +3,7 @@ import { setFrameStatus, setFrameVoiceover, type FrameStatus } from "@hyperframe
3
3
  import type { StoryboardFrameView } from "../../hooks/useStoryboard";
4
4
  import { useFileManagerContext } from "../../contexts/FileManagerContext";
5
5
  import { useViewMode } from "../../contexts/ViewModeContext";
6
+ import { Button } from "../ui/Button";
6
7
  import { FramePoster, posterTime } from "./FramePoster";
7
8
  import { FRAME_STATUS_META, FRAME_STATUS_ORDER } from "./frameStatus";
8
9
 
@@ -67,7 +68,25 @@ export function StoryboardFrameFocus({
67
68
  const dirty = draft !== (frame.voiceover ?? "");
68
69
  const canOpenPreview = frame.srcExists && Boolean(frame.src);
69
70
 
70
- // Leaving the frame drops the in-memory voiceover draft; confirm when it's dirty.
71
+ const saveVoiceover = useCallback(() => {
72
+ return applyEdit((src) => setFrameVoiceover(src, frame.index, draft));
73
+ }, [applyEdit, frame.index, draft]);
74
+
75
+ // Closing the tab with a dirty voiceover would lose it silently — same
76
+ // guard the sibling markdown editor registers for the same class of loss.
77
+ useEffect(() => {
78
+ if (!dirty) return;
79
+ const onBeforeUnload = (e: BeforeUnloadEvent) => {
80
+ e.preventDefault();
81
+ };
82
+ window.addEventListener("beforeunload", onBeforeUnload);
83
+ return () => window.removeEventListener("beforeunload", onBeforeUnload);
84
+ }, [dirty]);
85
+
86
+ // Leaving the frame drops the in-memory voiceover draft; confirm while it's
87
+ // dirty. An in-flight save does NOT count as safe: if it fails after unmount
88
+ // the error lands on an unmounted component and the draft is silently lost,
89
+ // so keep confirming until the save actually lands (dirty clears on success).
71
90
  const confirmLeave = () => !dirty || window.confirm("Discard unsaved voiceover changes?");
72
91
  const handleBack = () => {
73
92
  if (confirmLeave()) onBack();
@@ -158,18 +177,27 @@ export function StoryboardFrameFocus({
158
177
  <h3 className="text-xs font-semibold uppercase tracking-wider text-neutral-400">
159
178
  🎙 Voiceover <span className="font-normal normal-case text-neutral-600">guide</span>
160
179
  </h3>
161
- <button
162
- type="button"
163
- onClick={() => applyEdit((src) => setFrameVoiceover(src, frame.index, draft))}
164
- disabled={!dirty || busy}
165
- className="rounded bg-emerald-600 px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40"
180
+ <Button
181
+ size="sm"
182
+ variant="primary"
183
+ onClick={saveVoiceover}
184
+ disabled={!dirty}
185
+ loading={busy}
186
+ className="bg-emerald-600 text-white enabled:hover:bg-emerald-500 shadow-none"
166
187
  >
167
- Save
168
- </button>
188
+ {busy ? "Saving…" : "Save"}
189
+ </Button>
169
190
  </div>
170
191
  <textarea
171
192
  value={draft}
172
193
  onChange={(e) => setDraft(e.target.value)}
194
+ onBlur={() => {
195
+ // Same autosave paradigm as the status row above — mixed save
196
+ // models inside one panel taught users the panel autosaves,
197
+ // then lost their voiceover. Explicit Save stays as the
198
+ // affordance; blur is the safety net.
199
+ if (dirty && !busy) void saveVoiceover();
200
+ }}
173
201
  rows={3}
174
202
  placeholder="What the narrator says over this frame…"
175
203
  className="w-full resize-y rounded border border-neutral-800 bg-neutral-900 p-2 text-sm text-neutral-200 outline-none focus:border-neutral-600"
@@ -189,14 +217,9 @@ export function StoryboardFrameFocus({
189
217
  </section>
190
218
  )}
191
219
 
192
- <button
193
- type="button"
194
- onClick={openInPreview}
195
- disabled={!canOpenPreview}
196
- className="rounded border border-neutral-700 px-3 py-1.5 text-xs font-medium text-neutral-200 hover:bg-neutral-800 disabled:opacity-40"
197
- >
220
+ <Button size="sm" variant="secondary" onClick={openInPreview} disabled={!canOpenPreview}>
198
221
  Open in Preview →
199
- </button>
222
+ </Button>
200
223
  </div>
201
224
  </div>
202
225
  </div>
@@ -217,7 +240,7 @@ function NavButton({
217
240
  type="button"
218
241
  onClick={onClick}
219
242
  disabled={disabled}
220
- className="rounded px-2 py-1 text-xs font-medium text-neutral-300 hover:bg-neutral-800 disabled:opacity-30"
243
+ className="rounded px-2 py-1 text-xs font-medium text-neutral-300 hover:bg-neutral-800 enabled:active:scale-[0.98] transition-transform disabled:opacity-30"
221
244
  >
222
245
  {label}
223
246
  </button>
@@ -113,16 +113,33 @@ const SUB_VIEWS: Array<{ value: SubView; label: string }> = [
113
113
  ];
114
114
 
115
115
  function SubViewToggle({ value, onChange }: { value: SubView; onChange: (next: SubView) => void }) {
116
+ // Complete tabs contract: roving tabIndex + arrow-key navigation (the roles
117
+ // alone promised keyboard behavior the buttons didn't have).
118
+ const handleKeyDown = (e: React.KeyboardEvent) => {
119
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
120
+ e.preventDefault();
121
+ const currentIndex = SUB_VIEWS.findIndex((v) => v.value === value);
122
+ const delta = e.key === "ArrowRight" ? 1 : -1;
123
+ const next = SUB_VIEWS[(currentIndex + delta + SUB_VIEWS.length) % SUB_VIEWS.length];
124
+ if (next) onChange(next.value);
125
+ };
126
+
116
127
  return (
117
- <div className="flex items-center gap-0.5 rounded-md bg-neutral-900 p-0.5" role="tablist">
128
+ <div
129
+ className="flex items-center gap-0.5 rounded-md bg-neutral-900 p-0.5"
130
+ role="tablist"
131
+ aria-label="Storyboard view"
132
+ onKeyDown={handleKeyDown}
133
+ >
118
134
  {SUB_VIEWS.map((option) => (
119
135
  <button
120
136
  key={option.value}
121
137
  type="button"
122
138
  role="tab"
123
139
  aria-selected={value === option.value}
140
+ tabIndex={value === option.value ? 0 : -1}
124
141
  onClick={() => onChange(option.value)}
125
- className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
142
+ className={`rounded px-3 py-1 text-xs font-medium transition-colors active:scale-[0.98] outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent ${
126
143
  value === option.value
127
144
  ? "bg-neutral-700 text-neutral-100"
128
145
  : "text-neutral-400 hover:text-neutral-200"
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { useStoryboard } from "../../hooks/useStoryboard";
3
+ import { Button } from "../ui/Button";
3
4
  import { StoryboardLoaded } from "./StoryboardLoaded";
4
5
 
5
6
  export interface StoryboardViewProps {
@@ -22,6 +23,11 @@ export function StoryboardView({ projectId, onSelectComposition }: StoryboardVie
22
23
  return (
23
24
  <StoryboardFrame>
24
25
  <Message tone="error">Couldn’t load the storyboard: {error}</Message>
26
+ <div className="flex justify-center">
27
+ <Button size="sm" variant="secondary" onClick={reload}>
28
+ Retry
29
+ </Button>
30
+ </div>
25
31
  </StoryboardFrame>
26
32
  );
27
33
  }
@@ -0,0 +1,124 @@
1
+ import type { MutableRefObject } from "react";
2
+ import type { EditHistoryKind } from "../utils/editHistory";
3
+ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
4
+ import { patchMediaColorGradingInHtml } from "./editor/colorGradingScopePatch";
5
+ import { hasRelativeLutSource } from "./studioMediaJobs";
6
+
7
+ export type ColorGradingScope = "source-file" | "project";
8
+ export type ColorGradingScopeResult = { changedFiles: number; changedElements: number };
9
+
10
+ type ProjectFileReader = (path: string) => Promise<string>;
11
+ type ProjectFileWriter = (path: string, content: string) => Promise<void>;
12
+ type ShowToast = (message: string, tone?: "error" | "info") => void;
13
+ type RecordEdit = (entry: {
14
+ label: string;
15
+ kind: EditHistoryKind;
16
+ files: Record<string, { before: string; after: string }>;
17
+ }) => Promise<void>;
18
+
19
+ export const EMPTY_COLOR_GRADING_SCOPE_RESULT: ColorGradingScopeResult = {
20
+ changedFiles: 0,
21
+ changedElements: 0,
22
+ };
23
+
24
+ interface ApplyColorGradingScopeOptions {
25
+ scope: ColorGradingScope;
26
+ value: string | null;
27
+ selectedSourceFile: string;
28
+ fileTree: string[];
29
+ projectId: string;
30
+ domEditSaveTimestampRef: MutableRefObject<number>;
31
+ waitForPendingDomEditSaves: () => Promise<void>;
32
+ readProjectFile: ProjectFileReader;
33
+ writeProjectFile: ProjectFileWriter;
34
+ recordEdit: RecordEdit;
35
+ reloadPreview: () => void;
36
+ showToast: ShowToast;
37
+ }
38
+
39
+ function colorGradingScopePaths(
40
+ scope: ColorGradingScope,
41
+ selectedSourceFile: string,
42
+ fileTree: string[],
43
+ ): string[] {
44
+ return scope === "source-file"
45
+ ? [selectedSourceFile]
46
+ : fileTree.filter((path) => /\.html?$/i.test(path));
47
+ }
48
+
49
+ async function patchColorGradingScopeFiles(
50
+ paths: string[],
51
+ value: string | null,
52
+ readProjectFile: ProjectFileReader,
53
+ ): Promise<{ files: Record<string, string>; changedElements: number }> {
54
+ const snapshots = await Promise.all(
55
+ Array.from(new Set(paths)).map(async (path) => ({
56
+ path,
57
+ before: await readProjectFile(path),
58
+ })),
59
+ );
60
+ const files: Record<string, string> = {};
61
+ let changedElements = 0;
62
+
63
+ for (const { path, before } of snapshots) {
64
+ const result = patchMediaColorGradingInHtml(before, value);
65
+ if (result.html !== before) {
66
+ files[path] = result.html;
67
+ changedElements += result.count;
68
+ }
69
+ }
70
+
71
+ return { files, changedElements };
72
+ }
73
+
74
+ // fallow-ignore-next-line complexity
75
+ export async function applyColorGradingScopeUpdate({
76
+ scope,
77
+ value,
78
+ selectedSourceFile,
79
+ fileTree,
80
+ projectId,
81
+ domEditSaveTimestampRef,
82
+ waitForPendingDomEditSaves,
83
+ readProjectFile,
84
+ writeProjectFile,
85
+ recordEdit,
86
+ reloadPreview,
87
+ showToast,
88
+ }: ApplyColorGradingScopeOptions): Promise<ColorGradingScopeResult> {
89
+ await waitForPendingDomEditSaves();
90
+ if (scope === "project" && hasRelativeLutSource(value)) {
91
+ showToast(
92
+ "Project-wide color grading cannot copy relative LUT paths. Apply to this file or use a URL/data LUT.",
93
+ "error",
94
+ );
95
+ return EMPTY_COLOR_GRADING_SCOPE_RESULT;
96
+ }
97
+
98
+ const { files, changedElements } = await patchColorGradingScopeFiles(
99
+ colorGradingScopePaths(scope, selectedSourceFile, fileTree),
100
+ value,
101
+ readProjectFile,
102
+ );
103
+ if (Object.keys(files).length === 0) {
104
+ showToast("No color grading changed", "info");
105
+ return EMPTY_COLOR_GRADING_SCOPE_RESULT;
106
+ }
107
+
108
+ domEditSaveTimestampRef.current = Date.now();
109
+ const changedPaths = await saveProjectFilesWithHistory({
110
+ projectId,
111
+ label: value ? "Apply color grading" : "Clear color grading",
112
+ kind: "manual",
113
+ files,
114
+ readFile: readProjectFile,
115
+ writeFile: writeProjectFile,
116
+ recordEdit,
117
+ });
118
+ reloadPreview();
119
+ showToast(
120
+ `${value ? "Applied" : "Cleared"} color grading on ${changedElements} media item${changedElements === 1 ? "" : "s"}`,
121
+ "info",
122
+ );
123
+ return { changedFiles: changedPaths.length, changedElements };
124
+ }
@@ -0,0 +1,123 @@
1
+ import type {
2
+ BackgroundRemovalProgress,
3
+ BackgroundRemovalResult,
4
+ } from "./editor/propertyPanelTypes";
5
+
6
+ const MEDIA_JOB_RECONNECT_TIMEOUT_MS = 15_000;
7
+ const ABSOLUTE_OR_ROOT_SOURCE_RE = /^(?:[a-z][a-z0-9+.-]*:|\/)/i;
8
+
9
+ function parseSerializedColorGrading(value: string): { lut?: { src?: unknown } } | null {
10
+ try {
11
+ return JSON.parse(value) as { lut?: { src?: unknown } } | null;
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ function readLutSource(value: string | null): string {
18
+ const src = value ? parseSerializedColorGrading(value)?.lut?.src : null;
19
+ return typeof src === "string" ? src.trim() : "";
20
+ }
21
+
22
+ export function hasRelativeLutSource(value: string | null): boolean {
23
+ const src = readLutSource(value);
24
+ return src !== "" && !ABSOLUTE_OR_ROOT_SOURCE_RE.test(src);
25
+ }
26
+
27
+ function parseProgressEvent(event: Event): BackgroundRemovalProgress | Error {
28
+ try {
29
+ return JSON.parse((event as MessageEvent).data) as BackgroundRemovalProgress;
30
+ } catch {
31
+ return new Error("Invalid background-removal progress event");
32
+ }
33
+ }
34
+
35
+ function getCompleteProgressResult(
36
+ progress: BackgroundRemovalProgress,
37
+ ): BackgroundRemovalResult | Error {
38
+ if (!progress.outputPath) return new Error("Background removal finished without an output path");
39
+ return {
40
+ outputPath: progress.outputPath,
41
+ backgroundOutputPath: progress.backgroundOutputPath,
42
+ provider: progress.provider,
43
+ };
44
+ }
45
+
46
+ function getTerminalProgressResult(
47
+ progress: BackgroundRemovalProgress,
48
+ ): BackgroundRemovalResult | Error | null {
49
+ switch (progress.status) {
50
+ case "complete":
51
+ return getCompleteProgressResult(progress);
52
+ case "failed":
53
+ return new Error(progress.error || "Background removal failed");
54
+ default:
55
+ return null;
56
+ }
57
+ }
58
+
59
+ export function waitForMediaJob(
60
+ jobId: string,
61
+ onProgress?: (progress: BackgroundRemovalProgress) => void,
62
+ signal?: AbortSignal,
63
+ ): Promise<BackgroundRemovalResult> {
64
+ return new Promise((resolve, reject) => {
65
+ if (signal?.aborted) {
66
+ reject(new DOMException("Background removal was cancelled", "AbortError"));
67
+ return;
68
+ }
69
+ const events = new EventSource(`/api/media-jobs/${encodeURIComponent(jobId)}/progress`);
70
+ let settled = false;
71
+ let reconnectTimer: number | null = null;
72
+
73
+ const clearReconnectTimer = () => {
74
+ if (reconnectTimer === null) return;
75
+ window.clearTimeout(reconnectTimer);
76
+ reconnectTimer = null;
77
+ };
78
+ const finish = (callback: () => void) => {
79
+ if (settled) return;
80
+ settled = true;
81
+ clearReconnectTimer();
82
+ signal?.removeEventListener("abort", handleAbort);
83
+ events.close();
84
+ callback();
85
+ };
86
+ const finishReject = (error: Error) => finish(() => reject(error));
87
+ const finishResolve = (result: BackgroundRemovalResult) => finish(() => resolve(result));
88
+ const handleAbort = () => {
89
+ finishReject(new DOMException("Background removal was cancelled", "AbortError"));
90
+ };
91
+ signal?.addEventListener("abort", handleAbort, { once: true });
92
+
93
+ // fallow-ignore-next-line complexity
94
+ events.addEventListener("progress", (event) => {
95
+ const progress = parseProgressEvent(event);
96
+ if (progress instanceof Error) {
97
+ finishReject(progress);
98
+ return;
99
+ }
100
+ clearReconnectTimer();
101
+ onProgress?.(progress);
102
+ const terminalResult = getTerminalProgressResult(progress);
103
+ if (!terminalResult) return;
104
+ if (terminalResult instanceof Error) {
105
+ finishReject(terminalResult);
106
+ } else {
107
+ finishResolve(terminalResult);
108
+ }
109
+ });
110
+ events.onopen = clearReconnectTimer;
111
+ events.onerror = () => {
112
+ if (events.readyState === EventSource.CLOSED) {
113
+ finishReject(new Error("Lost connection to background-removal job"));
114
+ return;
115
+ }
116
+ if (reconnectTimer === null) {
117
+ reconnectTimer = window.setTimeout(() => {
118
+ finishReject(new Error("Lost connection to background-removal job"));
119
+ }, MEDIA_JOB_RECONNECT_TIMEOUT_MS);
120
+ }
121
+ };
122
+ });
123
+ }