@hyperframes/studio 0.7.37 → 0.7.39
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-0P10SwC_.css +1 -0
- package/dist/assets/{index-BXYJbIax.js → index-312a3Ceu.js} +1 -1
- package/dist/assets/index-B9YvRJz1.js +396 -0
- package/dist/assets/{index-Ct-X2wlf.js → index-CQHiecE7.js} +1 -1
- package/dist/{chunk-JND3XUJL.js → chunk-RCLGSZ7C.js} +1 -1
- package/dist/chunk-RCLGSZ7C.js.map +1 -0
- package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-S6YOLVRG.js} +2 -2
- package/dist/index.d.ts +48 -10
- package/dist/index.html +2 -2
- package/dist/index.js +6045 -3507
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +26 -24
- 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/StudioPreviewArea.tsx +8 -0
- package/src/components/StudioRightPanel.tsx +160 -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/DomEditCropHandles.tsx +238 -0
- package/src/components/editor/DomEditOverlay.tsx +111 -108
- package/src/components/editor/DomEditRotateHandle.tsx +41 -0
- package/src/components/editor/PropertyPanel.test.ts +48 -0
- package/src/components/editor/PropertyPanel.tsx +27 -47
- package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
- package/src/components/editor/SnapToolbar.tsx +20 -1
- package/src/components/editor/clipPathHelpers.ts +113 -0
- package/src/components/editor/colorGradingScopePatch.test.ts +74 -0
- package/src/components/editor/colorGradingScopePatch.ts +57 -0
- package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
- package/src/components/editor/domEditOverlayCrop.ts +115 -0
- package/src/components/editor/domEditOverlayGeometry.ts +14 -0
- package/src/components/editor/domEditOverlayShape.ts +39 -0
- package/src/components/editor/domEditing.test.ts +2 -0
- package/src/components/editor/domEditingTypes.ts +3 -0
- package/src/components/editor/manualEditingAvailability.test.ts +0 -12
- package/src/components/editor/manualEditingAvailability.ts +0 -6
- package/src/components/editor/marqueeCommit.ts +3 -2
- 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 +32 -125
- package/src/components/editor/propertyPanelMediaSection.tsx +253 -99
- package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
- package/src/components/editor/propertyPanelTypes.ts +113 -0
- package/src/components/editor/snapTargetCollection.ts +2 -3
- package/src/components/editor/useDomEditCompositionRect.ts +68 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
- package/src/components/editor/useDomEditOverlayRects.ts +5 -3
- 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/sidebar/AssetsTab.test.ts +87 -0
- package/src/components/sidebar/AssetsTab.tsx +113 -15
- package/src/components/sidebar/BlocksTab.tsx +2 -1
- package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
- 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/domSelectionTestHarness.ts +1 -0
- package/src/hooks/useCompositionContentLoader.ts +40 -0
- package/src/hooks/useCropMode.ts +91 -0
- package/src/hooks/useDomEditCommits.test.tsx +2 -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/player/components/ShortcutsPanel.tsx +9 -0
- package/src/player/store/playerStore.ts +14 -0
- package/src/styles/studio.css +61 -0
- package/src/utils/sdkResolverShadow.test.ts +53 -3
- package/src/utils/sdkResolverShadow.ts +41 -1
- 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-DmkOvZns.css +0 -1
- package/dist/assets/index-SYSbQyK4.js +0 -396
- package/dist/chunk-JND3XUJL.js.map +0 -1
- /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-S6YOLVRG.js.map} +0 -0
|
@@ -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
|
+
}
|
|
@@ -30,22 +30,24 @@ const variantStyles: Record<ButtonVariant, string> = {
|
|
|
30
30
|
primary: [
|
|
31
31
|
"bg-white text-neutral-950 font-medium",
|
|
32
32
|
"shadow-btn-primary",
|
|
33
|
-
"hover:bg-neutral-200",
|
|
34
|
-
"active:scale-[0.97]",
|
|
33
|
+
"enabled:hover:bg-neutral-200",
|
|
34
|
+
"enabled:active:scale-[0.97]",
|
|
35
35
|
].join(" "),
|
|
36
36
|
secondary: [
|
|
37
37
|
"bg-transparent text-neutral-300 font-medium",
|
|
38
38
|
"border border-border",
|
|
39
|
-
"hover:bg-surface-hover hover:text-white hover:border-border-strong",
|
|
40
|
-
"active:scale-[0.98]",
|
|
39
|
+
"enabled:hover:bg-surface-hover enabled:hover:text-white enabled:hover:border-border-strong",
|
|
40
|
+
"enabled:active:scale-[0.98]",
|
|
41
|
+
].join(" "),
|
|
42
|
+
danger: [
|
|
43
|
+
"bg-accent-red text-white font-medium",
|
|
44
|
+
"enabled:hover:bg-red-600",
|
|
45
|
+
"enabled:active:scale-[0.97]",
|
|
41
46
|
].join(" "),
|
|
42
|
-
danger: ["bg-accent-red text-white font-medium", "hover:bg-red-600", "active:scale-[0.97]"].join(
|
|
43
|
-
" ",
|
|
44
|
-
),
|
|
45
47
|
ghost: [
|
|
46
48
|
"bg-transparent text-neutral-400",
|
|
47
|
-
"hover:bg-surface-hover hover:text-white",
|
|
48
|
-
"active:scale-[0.98]",
|
|
49
|
+
"enabled:hover:bg-surface-hover enabled:hover:text-white",
|
|
50
|
+
"enabled:active:scale-[0.98]",
|
|
49
51
|
].join(" "),
|
|
50
52
|
};
|
|
51
53
|
|
|
@@ -55,6 +57,8 @@ const sizeStyles: Record<ButtonSize, string> = {
|
|
|
55
57
|
lg: "h-9 px-4 text-base gap-2 rounded-button",
|
|
56
58
|
};
|
|
57
59
|
|
|
60
|
+
// Imported by the shell/renders PRs later in this stack.
|
|
61
|
+
// fallow-ignore-next-line unused-export
|
|
58
62
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
59
63
|
(
|
|
60
64
|
{
|
|
@@ -76,8 +80,11 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
76
80
|
className={[
|
|
77
81
|
"inline-flex items-center justify-center",
|
|
78
82
|
"transition-all duration-press ease-standard",
|
|
79
|
-
|
|
83
|
+
// No pointer-events-none: disabled buttons must still receive hover
|
|
84
|
+
// so a wrapping Tooltip can explain WHY they're disabled (A5).
|
|
85
|
+
"disabled:opacity-40 disabled:cursor-not-allowed",
|
|
80
86
|
"select-none cursor-pointer",
|
|
87
|
+
"outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-studio-accent",
|
|
81
88
|
variantStyles[variant],
|
|
82
89
|
sizeStyles[size],
|
|
83
90
|
className,
|
|
@@ -126,6 +133,7 @@ const iconSizeStyles: Record<ButtonSize, string> = {
|
|
|
126
133
|
lg: "min-w-9 min-h-9 rounded-button", // 36px
|
|
127
134
|
};
|
|
128
135
|
|
|
136
|
+
// fallow-ignore-next-line unused-export
|
|
129
137
|
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
|
130
138
|
({ icon, size = "md", variant = "ghost", className = "", ...props }, ref) => {
|
|
131
139
|
return (
|
|
@@ -134,8 +142,9 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
|
|
134
142
|
className={[
|
|
135
143
|
"inline-flex items-center justify-center",
|
|
136
144
|
"transition-all duration-press ease-standard",
|
|
137
|
-
"disabled:opacity-40 disabled:
|
|
145
|
+
"disabled:opacity-40 disabled:cursor-not-allowed",
|
|
138
146
|
"select-none cursor-pointer",
|
|
147
|
+
"outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-studio-accent",
|
|
139
148
|
variantStyles[variant],
|
|
140
149
|
iconSizeStyles[size],
|
|
141
150
|
className,
|
|
@@ -25,7 +25,7 @@ export function HyperframesLoader({
|
|
|
25
25
|
const markFrameSize = Math.round(size * 1.16);
|
|
26
26
|
|
|
27
27
|
return (
|
|
28
|
-
<div className="hf-loader" draggable={false}>
|
|
28
|
+
<div className="hf-loader" role="status" draggable={false}>
|
|
29
29
|
<div
|
|
30
30
|
className="hf-loader-mark-frame"
|
|
31
31
|
style={{ width: markFrameSize, height: markFrameSize }}
|
|
@@ -83,7 +83,13 @@ export function HyperframesLoader({
|
|
|
83
83
|
<div className="hf-loader-title">{title}</div>
|
|
84
84
|
{detail && <div className="hf-loader-detail">{detail}</div>}
|
|
85
85
|
{boundedProgress !== undefined && (
|
|
86
|
-
<div
|
|
86
|
+
<div
|
|
87
|
+
className="hf-loader-progress"
|
|
88
|
+
role="progressbar"
|
|
89
|
+
aria-valuemin={0}
|
|
90
|
+
aria-valuemax={100}
|
|
91
|
+
aria-valuenow={Math.round(boundedProgress * 100)}
|
|
92
|
+
>
|
|
87
93
|
<div
|
|
88
94
|
className="hf-loader-progress__fill"
|
|
89
95
|
style={{ transform: `scaleX(${boundedProgress})` }}
|
|
@@ -95,6 +101,7 @@ export function HyperframesLoader({
|
|
|
95
101
|
);
|
|
96
102
|
}
|
|
97
103
|
|
|
104
|
+
// fallow-ignore-next-line unused-export
|
|
98
105
|
export function StatusFrame(props: HyperframesLoaderProps) {
|
|
99
106
|
return (
|
|
100
107
|
<div className="hf-frame">
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// fallow-ignore-file unused-file
|
|
2
|
+
// (consumers land in the sidebar/panels PR later in this stack)
|
|
3
|
+
import { type InputHTMLAttributes } from "react";
|
|
4
|
+
|
|
5
|
+
interface SearchInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
|
6
|
+
/** Accessible name — placeholder alone is not one. */
|
|
7
|
+
"aria-label": string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Shared search input — one visual system (panel-input tokens) for every
|
|
12
|
+
* panel search box, with a required accessible name.
|
|
13
|
+
*/
|
|
14
|
+
export function SearchInput({ className = "", ...props }: SearchInputProps) {
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
className={`flex items-center gap-1.5 rounded-md bg-panel-input px-2.5 py-[5px] ${className}`}
|
|
18
|
+
>
|
|
19
|
+
<svg
|
|
20
|
+
width="12"
|
|
21
|
+
height="12"
|
|
22
|
+
viewBox="0 0 256 256"
|
|
23
|
+
fill="none"
|
|
24
|
+
className="flex-shrink-0"
|
|
25
|
+
aria-hidden="true"
|
|
26
|
+
>
|
|
27
|
+
<circle
|
|
28
|
+
cx="116"
|
|
29
|
+
cy="116"
|
|
30
|
+
r="76"
|
|
31
|
+
stroke="currentColor"
|
|
32
|
+
strokeWidth="22"
|
|
33
|
+
className="text-panel-text-5"
|
|
34
|
+
/>
|
|
35
|
+
<line
|
|
36
|
+
x1="170"
|
|
37
|
+
y1="170"
|
|
38
|
+
x2="232"
|
|
39
|
+
y2="232"
|
|
40
|
+
stroke="currentColor"
|
|
41
|
+
strokeWidth="22"
|
|
42
|
+
strokeLinecap="round"
|
|
43
|
+
className="text-panel-text-5"
|
|
44
|
+
/>
|
|
45
|
+
</svg>
|
|
46
|
+
<input
|
|
47
|
+
type="text"
|
|
48
|
+
className="min-w-0 w-full bg-transparent text-[11px] text-panel-text-1 outline-none placeholder:text-panel-text-5"
|
|
49
|
+
{...props}
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useRef, useCallback, type ReactNode } from "react";
|
|
1
|
+
import { useState, useRef, useCallback, useEffect, useId, type ReactNode } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
3
|
|
|
4
4
|
interface TooltipProps {
|
|
@@ -8,22 +8,46 @@ interface TooltipProps {
|
|
|
8
8
|
side?: "top" | "bottom";
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
// Rough bubble height (padding + one text line) used to decide flipping
|
|
12
|
+
// before the bubble has rendered; exact height isn't needed for the guard.
|
|
13
|
+
const APPROX_BUBBLE_H = 28;
|
|
14
|
+
const VIEWPORT_MARGIN = 8;
|
|
15
|
+
|
|
11
16
|
export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipProps) {
|
|
12
17
|
const [visible, setVisible] = useState(false);
|
|
13
18
|
const [pos, setPos] = useState({ x: 0, y: 0 });
|
|
19
|
+
const [resolvedSide, setResolvedSide] = useState<"top" | "bottom">(side);
|
|
14
20
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
15
21
|
const triggerRef = useRef<HTMLSpanElement>(null);
|
|
22
|
+
// WCAG 4.1.2: programmatically associate the bubble with its trigger.
|
|
23
|
+
const tooltipId = useId();
|
|
16
24
|
|
|
17
25
|
const show = useCallback(() => {
|
|
26
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
18
27
|
timerRef.current = setTimeout(() => {
|
|
19
28
|
const el = triggerRef.current;
|
|
20
29
|
if (!el) return;
|
|
21
30
|
const child = el.firstElementChild as HTMLElement | null;
|
|
22
31
|
const rect = (child ?? el).getBoundingClientRect();
|
|
23
32
|
if (rect.width === 0 && rect.height === 0) return;
|
|
33
|
+
// Flip when the preferred side would clip the viewport edge.
|
|
34
|
+
let nextSide = side;
|
|
35
|
+
if (side === "top" && rect.top - APPROX_BUBBLE_H - 6 < VIEWPORT_MARGIN) {
|
|
36
|
+
nextSide = "bottom";
|
|
37
|
+
} else if (
|
|
38
|
+
side === "bottom" &&
|
|
39
|
+
rect.bottom + APPROX_BUBBLE_H + 6 > window.innerHeight - VIEWPORT_MARGIN
|
|
40
|
+
) {
|
|
41
|
+
nextSide = "top";
|
|
42
|
+
}
|
|
43
|
+
const x = Math.min(
|
|
44
|
+
Math.max(rect.left + rect.width / 2, VIEWPORT_MARGIN),
|
|
45
|
+
window.innerWidth - VIEWPORT_MARGIN,
|
|
46
|
+
);
|
|
47
|
+
setResolvedSide(nextSide);
|
|
24
48
|
setPos({
|
|
25
|
-
x
|
|
26
|
-
y:
|
|
49
|
+
x,
|
|
50
|
+
y: nextSide === "top" ? rect.top - 6 : rect.bottom + 6,
|
|
27
51
|
});
|
|
28
52
|
setVisible(true);
|
|
29
53
|
}, delay);
|
|
@@ -37,9 +61,27 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
|
|
|
37
61
|
setVisible(false);
|
|
38
62
|
}, []);
|
|
39
63
|
|
|
64
|
+
// WCAG 1.4.13: tooltip content must be dismissible with Escape.
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!visible) return;
|
|
67
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
68
|
+
if (e.key === "Escape") hide();
|
|
69
|
+
};
|
|
70
|
+
document.addEventListener("keydown", onKeyDown);
|
|
71
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
72
|
+
}, [visible, hide]);
|
|
73
|
+
|
|
40
74
|
return (
|
|
41
75
|
<>
|
|
42
|
-
<span
|
|
76
|
+
<span
|
|
77
|
+
ref={triggerRef}
|
|
78
|
+
onPointerEnter={show}
|
|
79
|
+
onPointerLeave={hide}
|
|
80
|
+
onFocus={show}
|
|
81
|
+
onBlur={hide}
|
|
82
|
+
aria-describedby={visible ? tooltipId : undefined}
|
|
83
|
+
className="contents"
|
|
84
|
+
>
|
|
43
85
|
{children}
|
|
44
86
|
</span>
|
|
45
87
|
{visible &&
|
|
@@ -49,10 +91,14 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
|
|
|
49
91
|
style={{
|
|
50
92
|
left: pos.x,
|
|
51
93
|
top: pos.y,
|
|
52
|
-
transform:
|
|
94
|
+
transform: resolvedSide === "top" ? "translate(-50%, -100%)" : "translate(-50%, 0)",
|
|
53
95
|
}}
|
|
54
96
|
>
|
|
55
|
-
<div
|
|
97
|
+
<div
|
|
98
|
+
role="tooltip"
|
|
99
|
+
id={tooltipId}
|
|
100
|
+
className="px-2 py-1 rounded-md bg-neutral-800 border border-neutral-700/50 text-[10px] font-medium text-neutral-200 whitespace-nowrap shadow-lg"
|
|
101
|
+
>
|
|
56
102
|
{label}
|
|
57
103
|
</div>
|
|
58
104
|
</div>,
|