@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.
- package/dist/assets/hyperframes-player-BBrKzTGd.js +459 -0
- package/dist/assets/index-BLRTwY5l.js +396 -0
- package/dist/assets/{index-BltxqwG6.js → index-ClUipc8i.js} +1 -1
- package/dist/assets/index-DJaiR8T2.css +1 -0
- package/dist/assets/{index-_bjyggFK.js → index-Ykq7ihge.js} +1 -1
- package/dist/index.d.ts +35 -10
- package/dist/index.html +2 -2
- package/dist/index.js +5147 -3322
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +19 -18
- package/src/components/AskAgentModal.tsx +23 -6
- package/src/components/LintModal.tsx +40 -10
- package/src/components/MediaPreview.tsx +36 -1
- package/src/components/SaveQueuePausedBanner.tsx +7 -6
- package/src/components/StudioErrorBoundary.tsx +19 -31
- package/src/components/StudioFeedbackBar.tsx +11 -2
- package/src/components/StudioGlobalDragOverlay.tsx +1 -1
- package/src/components/StudioHeader.tsx +184 -106
- package/src/components/StudioLeftSidebar.tsx +24 -3
- package/src/components/StudioOverlays.tsx +34 -8
- package/src/components/StudioRightPanel.tsx +154 -9
- package/src/components/StudioSplash.tsx +6 -3
- package/src/components/StudioToast.tsx +12 -11
- package/src/components/TimelineToolbar.test.tsx +9 -3
- package/src/components/TimelineToolbar.tsx +63 -29
- package/src/components/editor/PropertyPanel.tsx +18 -17
- package/src/components/editor/colorGradingScopePatch.test.ts +74 -0
- package/src/components/editor/colorGradingScopePatch.ts +57 -0
- package/src/components/editor/manualEditingAvailability.test.ts +0 -12
- package/src/components/editor/manualEditingAvailability.ts +0 -6
- package/src/components/editor/propertyPanelColorGradingControls.tsx +371 -329
- package/src/components/editor/propertyPanelColorGradingSection.tsx +274 -55
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +275 -0
- package/src/components/editor/propertyPanelHelpers.ts +22 -91
- package/src/components/editor/propertyPanelMediaSection.tsx +253 -99
- package/src/components/editor/propertyPanelTypes.ts +111 -0
- package/src/components/nle/CompositionBreadcrumb.tsx +3 -2
- package/src/components/nle/NLELayout.tsx +54 -51
- package/src/components/nle/NLEPreview.tsx +41 -5
- package/src/components/nle/TimelineResizeDivider.tsx +97 -0
- package/src/components/nle/usePreviewBlockDrop.ts +31 -7
- package/src/components/renders/RenderQueue.tsx +125 -24
- package/src/components/renders/RenderQueueItem.tsx +132 -64
- package/src/components/renders/useRenderQueue.ts +136 -24
- package/src/components/storyboard/StoryboardFrameFocus.tsx +39 -16
- package/src/components/storyboard/StoryboardLoaded.tsx +19 -2
- package/src/components/storyboard/StoryboardView.tsx +6 -0
- package/src/components/studioColorGradingScope.ts +124 -0
- package/src/components/studioMediaJobs.ts +123 -0
- package/src/components/ui/Button.tsx +20 -11
- package/src/components/ui/HyperframesLoader.tsx +9 -2
- package/src/components/ui/SearchInput.tsx +53 -0
- package/src/components/ui/Tooltip.tsx +52 -6
- package/src/components/ui/VideoFrameThumbnail.tsx +26 -3
- package/src/components/ui/useDialogBehavior.ts +83 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -0
- package/src/contexts/StudioContext.tsx +7 -0
- package/src/hooks/useCompositionContentLoader.ts +40 -0
- package/src/hooks/useEditorSave.ts +14 -0
- package/src/hooks/useFileManager.ts +39 -25
- package/src/hooks/useFrameCapture.ts +12 -1
- package/src/hooks/usePanelLayout.ts +1 -0
- package/src/hooks/usePreviewPersistence.ts +24 -15
- package/src/hooks/useStudioContextValue.ts +5 -0
- package/src/hooks/useToast.ts +66 -13
- package/src/styles/studio.css +61 -0
- package/src/utils/sdkResolverShadow.test.ts +70 -3
- package/src/utils/sdkResolverShadow.ts +47 -4
- package/src/utils/studioPendingEdits.test.ts +43 -0
- package/src/utils/studioPendingEdits.ts +45 -0
- package/src/utils/studioUiPreferences.ts +4 -0
- package/dist/assets/hyperframes-player-BGuumSiM.js +0 -459
- package/dist/assets/index-DfeE1_Rl.js +0 -396
- 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)
|
|
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
|
-
|
|
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 (
|
|
198
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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) =>
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
-
<
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
|
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
|
+
}
|