@hugpy/video-intelligence-ui 0.1.0
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/LICENSE +41 -0
- package/assets/sampels/images/bycycle/bd8603bad35d4f179f4d1134d8faf2ee.mp4 +0 -0
- package/assets/sampels/images/bycycle/frame_00000.png +0 -0
- package/assets/sampels/images/bycycle/frame_00001.png +0 -0
- package/assets/sampels/images/bycycle/frame_00002.png +0 -0
- package/assets/sampels/images/bycycle/frame_00003.png +0 -0
- package/assets/sampels/images/bycycle/frame_00004.png +0 -0
- package/assets/sampels/images/bycycle/frame_00005.png +0 -0
- package/assets/sampels/images/forrest/ff23090611b844a6b85c224f388bd542.mp4 +0 -0
- package/assets/sampels/images/forrest/frame_00000.png +0 -0
- package/assets/sampels/images/forrest/frame_00001.png +0 -0
- package/assets/sampels/images/forrest/frame_00002.png +0 -0
- package/assets/sampels/images/forrest/frame_00003.png +0 -0
- package/assets/sampels/images/forrest/frame_00004.png +0 -0
- package/assets/sampels/images/forrest/frame_00005.png +0 -0
- package/assets/sampels/images/shenzo/e1a4fcd623084ab4a986d11c8cd00db8.mp4 +0 -0
- package/assets/sampels/images/shenzo/frame_00000.png +0 -0
- package/assets/sampels/images/shenzo/frame_00001.png +0 -0
- package/assets/sampels/images/shenzo/frame_00002.png +0 -0
- package/assets/sampels/images/shenzo/frame_00003.png +0 -0
- package/assets/sampels/images/shenzo/frame_00004.png +0 -0
- package/assets/sampels/images/shenzo/frame_00005.png +0 -0
- package/assets/sampels/scenes/man.mp4 +0 -0
- package/assets/sampels/scenes/woman.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/_assembly/contrib_00.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/movie.json +67 -0
- package/assets/sampels/studio_movies/caverns/movie.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/manifest.json +87 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/provenance.json +7 -0
- package/assets/sampels/studio_movies/caverns/segment_01/branch.png +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/concat_list.txt +2 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_00.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_01.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/movie.json +81 -0
- package/assets/sampels/studio_movies/keeper-matrix/movie.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/manifest.json +87 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/provenance.json +7 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/branch.png +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/manifest.json +87 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/provenance.json +7 -0
- package/assets/sampels/videos/a_red_paper_boat_drifting_on_a_calm_pond__gentle_ripples__mo_a62d6c28.mp4 +0 -0
- package/assets/sampels/videos/aurora.mp4 +0 -0
- package/assets/sampels/videos/dark_night.mp4 +0 -0
- package/assets/sampels/videos/fire_and_soul.mp4 +0 -0
- package/assets/sampels/videos/harsh_seas.mp4 +0 -0
- package/entry.tsx +74 -0
- package/index.html +53 -0
- package/package.json +42 -0
- package/src/assets/hugpy-mark.png +0 -0
- package/src/config.ts +676 -0
- package/src/demo/DemoBanner.tsx +54 -0
- package/src/demo/assets/demo-audio.mp3 +0 -0
- package/src/demo/assets/demo-clip.mp4 +0 -0
- package/src/demo/assets/demo-crop.mp4 +0 -0
- package/src/demo/assets/demo-poster.jpg +0 -0
- package/src/demo/assets/frame-1.jpg +0 -0
- package/src/demo/assets/frame-2.jpg +0 -0
- package/src/demo/assets/frame-3.jpg +0 -0
- package/src/demo/assets/frame-4.jpg +0 -0
- package/src/demo/assets/frame-5.jpg +0 -0
- package/src/demo/assets/frame-6.jpg +0 -0
- package/src/demo/assets/gen-1.jpg +0 -0
- package/src/demo/assets/gen-2.jpg +0 -0
- package/src/demo/assets.d.ts +14 -0
- package/src/demo/demo.css +64 -0
- package/src/demo/demoFetch.ts +305 -0
- package/src/demo/fixtures.ts +814 -0
- package/src/demo/index.ts +43 -0
- package/src/demo/mode.ts +81 -0
- package/src/demo/promptAssist.ts +181 -0
- package/src/demo/seed.ts +97 -0
- package/src/nav/BrandMark.css +66 -0
- package/src/nav/BrandMark.tsx +40 -0
- package/src/nav/Navbar.css +13 -0
- package/src/nav/Navbar.tsx +68 -0
- package/src/regions/SpatialRegionEditor.tsx +375 -0
- package/src/regions/TemporalRegionEditor.tsx +485 -0
- package/src/regions/types.ts +85 -0
- package/src/session.ts +85 -0
- package/src/share.ts +77 -0
- package/src/stations/ActivePanel.tsx +55 -0
- package/src/stations/ActiveProcessesStation.tsx +272 -0
- package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +228 -0
- package/src/stations/AddFileBar.tsx +59 -0
- package/src/stations/AudioCropStation.tsx +157 -0
- package/src/stations/ComfyPanel.tsx +210 -0
- package/src/stations/ComfyStation.tsx +196 -0
- package/src/stations/CondensedKnobStrip.tsx +245 -0
- package/src/stations/ConsolePanel.tsx +82 -0
- package/src/stations/FrameExtractStation.tsx +148 -0
- package/src/stations/GenIdentityBar.tsx +412 -0
- package/src/stations/GenerateStation.tsx +5603 -0
- package/src/stations/GenerateStation.tsx.bak-tester-removal +5170 -0
- package/src/stations/GoalComposer.tsx +139 -0
- package/src/stations/ImageCropStation.tsx +153 -0
- package/src/stations/KnobInfo.tsx +64 -0
- package/src/stations/PlaceholderStation.tsx +26 -0
- package/src/stations/SectionTabs.tsx +276 -0
- package/src/stations/SharePanel.tsx +298 -0
- package/src/stations/SidebarRegistry.tsx +105 -0
- package/src/stations/StationShell.tsx +96 -0
- package/src/stations/StudioClipsStation.tsx +28 -0
- package/src/stations/WorkbenchDrawer.tsx +66 -0
- package/src/stations/WorkbenchSidebar.tsx +1224 -0
- package/src/stations/WorkbenchStation.tsx +91 -0
- package/src/stations/comfyEmbed.ts +79 -0
- package/src/stations/drawerShell.tsx +419 -0
- package/src/stations/ops/AudioCropCore.tsx +218 -0
- package/src/stations/ops/CharacterGroupsPanel.tsx +716 -0
- package/src/stations/ops/FrameExtractCore.tsx +695 -0
- package/src/stations/ops/ImageCropCore.tsx +164 -0
- package/src/stations/ops/VideoCropCore.tsx +193 -0
- package/src/stations/ops/useVideoCropJobs.ts +224 -0
- package/src/stations/pollCaps.ts +87 -0
- package/src/stations/registry.ts +191 -0
- package/src/stations/studio/AngleRingEditor.tsx +159 -0
- package/src/stations/studio/IdentitiesStation.tsx +2455 -0
- package/src/stations/studio/IdentityProfileControls.tsx +262 -0
- package/src/stations/studio/StudioActiveProcesses.tsx +224 -0
- package/src/stations/studio/StudioAssistLogPanel.tsx +220 -0
- package/src/stations/studio/StudioGenerateMode.tsx +29 -0
- package/src/stations/studio/StudioGenerateTab.tsx +2688 -0
- package/src/stations/studio/StudioItemCard.tsx +334 -0
- package/src/stations/studio/StudioLibraryTab.tsx +506 -0
- package/src/stations/studio/StudioMovieComposer.tsx +2875 -0
- package/src/stations/studio/StudioMovieComposer.tsx.bak-sessions-20260812 +2729 -0
- package/src/stations/studio/StudioMovieSessions.tsx +553 -0
- package/src/stations/studio/StudioMovieSessions.tsx.bak-sessions-20260812 +417 -0
- package/src/stations/studio/StudioPlane.tsx +302 -0
- package/src/stations/studio/StudioSplicedRow.tsx +476 -0
- package/src/stations/studio/StudioViewer.tsx +339 -0
- package/src/stations/studio/TurntableViewer.tsx +220 -0
- package/src/stations/studio/movieTimeline.ts +198 -0
- package/src/stations/studio/promptCard.tsx +498 -0
- package/src/stations/studio/studioItem.ts +87 -0
- package/src/stations/studio/studioShared.tsx +821 -0
- package/src/stations/studio/useIdentityProfiles.ts +575 -0
- package/src/stations/studio/useReconstruction.ts +283 -0
- package/src/stations/types.ts +43 -0
- package/src/stations/useAudioCropJobs.ts +202 -0
- package/src/stations/useAudioExtractJob.ts +144 -0
- package/src/stations/useCropJobs.ts +206 -0
- package/src/stations/useFrameJobs.ts +140 -0
- package/src/stations/useGenerateJob.ts +151 -0
- package/src/stations/useGenerateMovieJob.ts +177 -0
- package/src/stations/useGenerateRowJobs.ts +184 -0
- package/src/stations/useGenerateSceneJob.ts +156 -0
- package/src/stations/useGenerateSceneRowJobs.ts +282 -0
- package/src/style/app.css +4974 -0
- package/src/transport/client.ts +262 -0
- package/src/transport/telemetry.ts +97 -0
- package/src/video/DropReceptacle.tsx +122 -0
- package/src/video/PromptAssistButtons.tsx +118 -0
- package/src/video/composerBridge.ts +66 -0
- package/src/video/contract.ts +599 -0
- package/src/video/jobTracker.ts +702 -0
- package/src/video/mediaLibrary.ts +328 -0
- package/src/video/renderCompat.ts +512 -0
- package/src/video/sessionForm.ts +189 -0
- package/src/video/spreadAssist.ts +392 -0
- package/src/video/studioBridge.ts +82 -0
- package/src/video/useMediaJobs.ts +279 -0
- package/src/video/useModels.ts +413 -0
- package/src/video/useMoviePresets.ts +97 -0
- package/src/video/useMovieSessions.ts +292 -0
- package/src/video/usePresets.ts +167 -0
- package/src/video/useProjects.ts +87 -0
- package/src/video/usePromptAssist.ts +394 -0
- package/src/video/useRenderPresets.ts +221 -0
- package/src/video/useStudioAssistLog.ts +266 -0
- package/src/video/useStudioPresets.ts +128 -0
- package/src/video/useWorkerCalls.ts +165 -0
- package/tsconfig.json +17 -0
- package/vite.config.ts +33 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// k9 — the SHARE affordance. An operator mints a video-scoped share link and
|
|
2
|
+
// hands it to an outside party, who can then drive the /video features WITHOUT a
|
|
3
|
+
// console login (the link carries a `?share=<key>` credential the SPA lifts on
|
|
4
|
+
// load — see src/share.ts). The minted key opens the /video surface ONLY; it can
|
|
5
|
+
// never reach the console/operator routes, and — crucially — never the mint route
|
|
6
|
+
// itself, so there is no key-minting-by-key.
|
|
7
|
+
//
|
|
8
|
+
// VISIBILITY IS AUTH-DECIDED, NOT GUESSED. This button renders only for a
|
|
9
|
+
// CONSOLE-AUTHENTICATED session. It probes the operator-gated list endpoint
|
|
10
|
+
// (GET /api/keys/video-share) once on mount: 200 ⇒ operator ⇒ show; 401 ⇒ a share
|
|
11
|
+
// party or anon ⇒ render nothing. So a share-principal session (which CAN pass the
|
|
12
|
+
// /video gate) never sees a working Share button — the probe 401s exactly as the
|
|
13
|
+
// mint POST would.
|
|
14
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
15
|
+
import { createPortal } from "react-dom";
|
|
16
|
+
import { request, okValue } from "../transport/client";
|
|
17
|
+
import { hugpyConfig, keysVideoShareRevokeUrl } from "../config";
|
|
18
|
+
|
|
19
|
+
interface ShareKey {
|
|
20
|
+
id: string;
|
|
21
|
+
label: string;
|
|
22
|
+
prefix: string;
|
|
23
|
+
created_at: number;
|
|
24
|
+
expires_at: number | null;
|
|
25
|
+
expired?: boolean;
|
|
26
|
+
revoked?: boolean;
|
|
27
|
+
last_used?: number | null;
|
|
28
|
+
}
|
|
29
|
+
interface MintResult extends ShareKey {
|
|
30
|
+
key: string;
|
|
31
|
+
url: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Expiry choices for the mint form (days). 0 = a non-expiring link.
|
|
35
|
+
const TTL_OPTIONS: { label: string; days: number }[] = [
|
|
36
|
+
{ label: "7 days", days: 7 },
|
|
37
|
+
{ label: "30 days", days: 30 },
|
|
38
|
+
{ label: "90 days", days: 90 },
|
|
39
|
+
{ label: "No expiry", days: 0 },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function fmtExpiry(k: ShareKey): string {
|
|
43
|
+
if (k.expired) return "expired";
|
|
44
|
+
if (!k.expires_at) return "no expiry";
|
|
45
|
+
const days = Math.max(0, Math.round((k.expires_at * 1000 - Date.now()) / 86_400_000));
|
|
46
|
+
return days <= 0 ? "expiring" : `${days}d left`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The full share link for a minted key — rebuilt from THIS origin so it's always
|
|
50
|
+
* correct regardless of how the backend saw its forwarded host. */
|
|
51
|
+
function linkFor(key: string): string {
|
|
52
|
+
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
|
53
|
+
return `${origin}/video/?share=${encodeURIComponent(key)}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function SharePanel() {
|
|
57
|
+
// null = probing (render nothing), false = not an operator (render nothing),
|
|
58
|
+
// true = operator (render the button).
|
|
59
|
+
const [authed, setAuthed] = useState<boolean | null>(null);
|
|
60
|
+
const [open, setOpen] = useState(false);
|
|
61
|
+
const [keys, setKeys] = useState<ShareKey[]>([]);
|
|
62
|
+
const [label, setLabel] = useState("");
|
|
63
|
+
const [ttl, setTtl] = useState(30);
|
|
64
|
+
const [minting, setMinting] = useState(false);
|
|
65
|
+
const [minted, setMinted] = useState<MintResult | null>(null);
|
|
66
|
+
const [copied, setCopied] = useState(false);
|
|
67
|
+
const [error, setError] = useState<string | null>(null);
|
|
68
|
+
|
|
69
|
+
const refresh = useCallback(async () => {
|
|
70
|
+
const res = await request<{ keys: ShareKey[] }>(hugpyConfig.keysVideoShareUrl, {
|
|
71
|
+
method: "GET",
|
|
72
|
+
meta: { specKey: "share", operation: "share.list" },
|
|
73
|
+
});
|
|
74
|
+
if (res.ok) {
|
|
75
|
+
setAuthed(true);
|
|
76
|
+
setKeys(okValue(res).keys ?? []);
|
|
77
|
+
} else {
|
|
78
|
+
// 401/403 ⇒ not an operator: keep the affordance hidden entirely.
|
|
79
|
+
setAuthed(false);
|
|
80
|
+
}
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
// Probe once on mount to decide visibility (and seed the existing-links list).
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
void refresh();
|
|
86
|
+
}, [refresh]);
|
|
87
|
+
|
|
88
|
+
const mint = useCallback(async () => {
|
|
89
|
+
setMinting(true);
|
|
90
|
+
setError(null);
|
|
91
|
+
setMinted(null);
|
|
92
|
+
setCopied(false);
|
|
93
|
+
const res = await request<MintResult>(hugpyConfig.keysVideoShareUrl, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: { "Content-Type": "application/json" },
|
|
96
|
+
body: JSON.stringify({ label: label.trim(), ttl_days: ttl }),
|
|
97
|
+
meta: { specKey: "share", operation: "share.mint" },
|
|
98
|
+
});
|
|
99
|
+
setMinting(false);
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
setError("Could not create a share link — are you still signed in to the console?");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const m = okValue(res);
|
|
105
|
+
setMinted({ ...m, url: linkFor(m.key) });
|
|
106
|
+
setLabel("");
|
|
107
|
+
void refresh();
|
|
108
|
+
}, [label, ttl, refresh]);
|
|
109
|
+
|
|
110
|
+
const revoke = useCallback(
|
|
111
|
+
async (id: string) => {
|
|
112
|
+
const res = await request<unknown>(keysVideoShareRevokeUrl(id), {
|
|
113
|
+
method: "DELETE",
|
|
114
|
+
meta: { specKey: "share", operation: "share.revoke" },
|
|
115
|
+
});
|
|
116
|
+
if (res.ok) void refresh();
|
|
117
|
+
},
|
|
118
|
+
[refresh],
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const copy = useCallback(async (text: string) => {
|
|
122
|
+
try {
|
|
123
|
+
await navigator.clipboard.writeText(text);
|
|
124
|
+
setCopied(true);
|
|
125
|
+
window.setTimeout(() => setCopied(false), 1800);
|
|
126
|
+
} catch {
|
|
127
|
+
setCopied(false);
|
|
128
|
+
}
|
|
129
|
+
}, []);
|
|
130
|
+
|
|
131
|
+
const close = useCallback(() => {
|
|
132
|
+
setOpen(false);
|
|
133
|
+
setMinted(null);
|
|
134
|
+
setError(null);
|
|
135
|
+
}, []);
|
|
136
|
+
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (!open) return;
|
|
139
|
+
const onKey = (e: KeyboardEvent) => {
|
|
140
|
+
if (e.key === "Escape") close();
|
|
141
|
+
};
|
|
142
|
+
window.addEventListener("keydown", onKey);
|
|
143
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
144
|
+
}, [open, close]);
|
|
145
|
+
|
|
146
|
+
const activeKeys = useMemo(() => keys.filter((k) => !k.revoked), [keys]);
|
|
147
|
+
|
|
148
|
+
// Hidden entirely until the probe confirms an operator session.
|
|
149
|
+
if (authed !== true) return null;
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<>
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
className="vi-share-toggle vi-btn vi-btn-sm"
|
|
156
|
+
aria-expanded={open}
|
|
157
|
+
onClick={() => setOpen(true)}
|
|
158
|
+
title="Create a share link that lets someone use the video features without a console login"
|
|
159
|
+
>
|
|
160
|
+
⤴ Share
|
|
161
|
+
</button>
|
|
162
|
+
|
|
163
|
+
{open &&
|
|
164
|
+
createPortal(
|
|
165
|
+
<>
|
|
166
|
+
<button
|
|
167
|
+
type="button"
|
|
168
|
+
className="vi-share-scrim"
|
|
169
|
+
aria-label="Close share links"
|
|
170
|
+
onClick={close}
|
|
171
|
+
/>
|
|
172
|
+
<div
|
|
173
|
+
className="vi-share-panel"
|
|
174
|
+
role="dialog"
|
|
175
|
+
aria-modal="true"
|
|
176
|
+
aria-label="Share links"
|
|
177
|
+
>
|
|
178
|
+
<div className="vi-share-bar">
|
|
179
|
+
<span className="vi-share-title">Share the video features</span>
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
className="vi-btn vi-btn-sm"
|
|
183
|
+
onClick={close}
|
|
184
|
+
aria-label="Close"
|
|
185
|
+
title="Close (Esc)"
|
|
186
|
+
>
|
|
187
|
+
✕
|
|
188
|
+
</button>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<div className="vi-share-body">
|
|
192
|
+
<p className="vi-share-hint">
|
|
193
|
+
A share link lets the person you send it to use the video features
|
|
194
|
+
here — no login. It works only for these video tools, nothing else
|
|
195
|
+
in the console. Revoke it any time.
|
|
196
|
+
</p>
|
|
197
|
+
|
|
198
|
+
<div className="vi-share-form">
|
|
199
|
+
<label className="vi-share-field">
|
|
200
|
+
<span>Label (optional)</span>
|
|
201
|
+
<input
|
|
202
|
+
type="text"
|
|
203
|
+
value={label}
|
|
204
|
+
placeholder="e.g. Alex — review cut"
|
|
205
|
+
onChange={(e) => setLabel(e.target.value)}
|
|
206
|
+
/>
|
|
207
|
+
</label>
|
|
208
|
+
<label className="vi-share-field">
|
|
209
|
+
<span>Expires</span>
|
|
210
|
+
<select
|
|
211
|
+
value={ttl}
|
|
212
|
+
onChange={(e) => setTtl(Number(e.target.value))}
|
|
213
|
+
>
|
|
214
|
+
{TTL_OPTIONS.map((o) => (
|
|
215
|
+
<option key={o.days} value={o.days}>
|
|
216
|
+
{o.label}
|
|
217
|
+
</option>
|
|
218
|
+
))}
|
|
219
|
+
</select>
|
|
220
|
+
</label>
|
|
221
|
+
<button
|
|
222
|
+
type="button"
|
|
223
|
+
className="vi-btn vi-btn-sm vi-btn-accent"
|
|
224
|
+
disabled={minting}
|
|
225
|
+
onClick={() => void mint()}
|
|
226
|
+
>
|
|
227
|
+
{minting ? "Creating…" : "Create share link"}
|
|
228
|
+
</button>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
{error && (
|
|
232
|
+
<p className="vi-share-error" role="alert">
|
|
233
|
+
{error}
|
|
234
|
+
</p>
|
|
235
|
+
)}
|
|
236
|
+
|
|
237
|
+
{minted && (
|
|
238
|
+
<div className="vi-share-minted">
|
|
239
|
+
<p className="vi-share-minted-note">
|
|
240
|
+
Copy this now — it won't be shown again.
|
|
241
|
+
</p>
|
|
242
|
+
<div className="vi-share-minted-row">
|
|
243
|
+
<input
|
|
244
|
+
className="vi-share-link"
|
|
245
|
+
type="text"
|
|
246
|
+
readOnly
|
|
247
|
+
value={minted.url}
|
|
248
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
249
|
+
/>
|
|
250
|
+
<button
|
|
251
|
+
type="button"
|
|
252
|
+
className="vi-btn vi-btn-sm vi-btn-accent"
|
|
253
|
+
onClick={() => void copy(minted.url)}
|
|
254
|
+
>
|
|
255
|
+
{copied ? "Copied ✓" : "Copy"}
|
|
256
|
+
</button>
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
{activeKeys.length > 0 && (
|
|
262
|
+
<div className="vi-share-list">
|
|
263
|
+
<h3 className="vi-share-list-title">Active links</h3>
|
|
264
|
+
<ul>
|
|
265
|
+
{activeKeys.map((k) => (
|
|
266
|
+
<li key={k.id} className="vi-share-list-item">
|
|
267
|
+
<span className="vi-share-item-label">
|
|
268
|
+
{k.label || "unnamed"}
|
|
269
|
+
</span>
|
|
270
|
+
<code className="vi-share-item-prefix">{k.prefix}…</code>
|
|
271
|
+
<span
|
|
272
|
+
className={`vi-share-item-exp${
|
|
273
|
+
k.expired ? " vi-share-item-exp-dead" : ""
|
|
274
|
+
}`}
|
|
275
|
+
>
|
|
276
|
+
{fmtExpiry(k)}
|
|
277
|
+
</span>
|
|
278
|
+
<button
|
|
279
|
+
type="button"
|
|
280
|
+
className="vi-btn vi-btn-sm"
|
|
281
|
+
onClick={() => void revoke(k.id)}
|
|
282
|
+
title="Revoke this link — it stops working immediately"
|
|
283
|
+
>
|
|
284
|
+
Revoke
|
|
285
|
+
</button>
|
|
286
|
+
</li>
|
|
287
|
+
))}
|
|
288
|
+
</ul>
|
|
289
|
+
</div>
|
|
290
|
+
)}
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
</>,
|
|
294
|
+
document.body,
|
|
295
|
+
)}
|
|
296
|
+
</>
|
|
297
|
+
);
|
|
298
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Round 9 — the ONE left sidebar of the arm shell grows a THIRD, GUARDED tab:
|
|
2
|
+
// "Settings". The shell's WorkbenchSidebar owns the [ Active Processes | Session
|
|
3
|
+
// Library | Settings ] tab strip, but the Settings tab's CONTENTS (and whether it
|
|
4
|
+
// appears at all) belong to whatever station is currently mounted. This tiny context
|
|
5
|
+
// is the seam:
|
|
6
|
+
//
|
|
7
|
+
// • A station that has config knobs REGISTERS on mount (registerSettings) — that
|
|
8
|
+
// flips `settingsActive` true so the sidebar reveals the Settings tab. Stations
|
|
9
|
+
// that do NOT register (crop stations, and — for now — the Generate image/scene/
|
|
10
|
+
// movie modes) are completely unaffected: no third tab, zero behaviour change.
|
|
11
|
+
// • The sidebar publishes the Settings tabpanel's DOM node via `setSettingsHost`
|
|
12
|
+
// (a callback ref). The mounted station renders its knobs INTO that node with a
|
|
13
|
+
// React portal, so the knob STATE lives in the station (reactive) while the DOM
|
|
14
|
+
// lands in the shared sidebar.
|
|
15
|
+
// • The same pattern carries STUDIO's in-flight renders into the shared Active
|
|
16
|
+
// Processes tab (studio jobs are not in the jobTracker — StationId has no
|
|
17
|
+
// "studio" — so they cannot join it the normal way). `activeExtraHost` is a
|
|
18
|
+
// portal target rendered at the foot of the Active Processes panel;
|
|
19
|
+
// `activeExtraActive` lets the panel suppress its "no active processes" empty
|
|
20
|
+
// copy when a station has portaled rows in.
|
|
21
|
+
//
|
|
22
|
+
// Additive by construction: a station outside the provider gets an inert fallback
|
|
23
|
+
// (no-op setters, everything false/null) so nothing throws.
|
|
24
|
+
import {
|
|
25
|
+
createContext,
|
|
26
|
+
useCallback,
|
|
27
|
+
useContext,
|
|
28
|
+
useMemo,
|
|
29
|
+
useState,
|
|
30
|
+
type ReactNode,
|
|
31
|
+
} from "react";
|
|
32
|
+
|
|
33
|
+
export interface SidebarRegistryValue {
|
|
34
|
+
/** The Settings tabpanel DOM node (portal target) — null until that tab renders. */
|
|
35
|
+
settingsHost: HTMLElement | null;
|
|
36
|
+
/** Callback ref the sidebar attaches to its Settings tabpanel. */
|
|
37
|
+
setSettingsHost: (el: HTMLElement | null) => void;
|
|
38
|
+
/** True when ≥1 station has registered Settings content — gates the Settings tab. */
|
|
39
|
+
settingsActive: boolean;
|
|
40
|
+
/** A station calls this on mount; the returned fn unregisters on unmount. */
|
|
41
|
+
registerSettings: () => () => void;
|
|
42
|
+
|
|
43
|
+
/** The Active-Processes "extra rows" portal target (studio in-flight renders). */
|
|
44
|
+
activeExtraHost: HTMLElement | null;
|
|
45
|
+
setActiveExtraHost: (el: HTMLElement | null) => void;
|
|
46
|
+
/** True when a station has registered extra Active rows — suppresses empty copy. */
|
|
47
|
+
activeExtraActive: boolean;
|
|
48
|
+
registerActiveExtra: () => () => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const FALLBACK: SidebarRegistryValue = {
|
|
52
|
+
settingsHost: null,
|
|
53
|
+
setSettingsHost: () => {},
|
|
54
|
+
settingsActive: false,
|
|
55
|
+
registerSettings: () => () => {},
|
|
56
|
+
activeExtraHost: null,
|
|
57
|
+
setActiveExtraHost: () => {},
|
|
58
|
+
activeExtraActive: false,
|
|
59
|
+
registerActiveExtra: () => () => {},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const SidebarRegistryContext = createContext<SidebarRegistryValue | null>(null);
|
|
63
|
+
|
|
64
|
+
export function SidebarRegistryProvider({ children }: { children: ReactNode }) {
|
|
65
|
+
const [settingsHost, setSettingsHost] = useState<HTMLElement | null>(null);
|
|
66
|
+
const [settingsCount, setSettingsCount] = useState(0);
|
|
67
|
+
const [activeExtraHost, setActiveExtraHost] = useState<HTMLElement | null>(null);
|
|
68
|
+
const [activeExtraCount, setActiveExtraCount] = useState(0);
|
|
69
|
+
|
|
70
|
+
// Reference-counted so overlapping mounts (or React 18 StrictMode's double-invoke)
|
|
71
|
+
// never leave the tab stuck visible or hidden.
|
|
72
|
+
const registerSettings = useCallback(() => {
|
|
73
|
+
setSettingsCount((n) => n + 1);
|
|
74
|
+
return () => setSettingsCount((n) => Math.max(0, n - 1));
|
|
75
|
+
}, []);
|
|
76
|
+
const registerActiveExtra = useCallback(() => {
|
|
77
|
+
setActiveExtraCount((n) => n + 1);
|
|
78
|
+
return () => setActiveExtraCount((n) => Math.max(0, n - 1));
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
const value = useMemo<SidebarRegistryValue>(
|
|
82
|
+
() => ({
|
|
83
|
+
settingsHost,
|
|
84
|
+
setSettingsHost,
|
|
85
|
+
settingsActive: settingsCount > 0,
|
|
86
|
+
registerSettings,
|
|
87
|
+
activeExtraHost,
|
|
88
|
+
setActiveExtraHost,
|
|
89
|
+
activeExtraActive: activeExtraCount > 0,
|
|
90
|
+
registerActiveExtra,
|
|
91
|
+
}),
|
|
92
|
+
[settingsHost, settingsCount, activeExtraHost, activeExtraCount, registerSettings, registerActiveExtra],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<SidebarRegistryContext.Provider value={value}>
|
|
97
|
+
{children}
|
|
98
|
+
</SidebarRegistryContext.Provider>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Read the sidebar registry. Returns an inert fallback outside a provider (never throws). */
|
|
103
|
+
export function useSidebarRegistry(): SidebarRegistryValue {
|
|
104
|
+
return useContext(SidebarRegistryContext) ?? FALLBACK;
|
|
105
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The arm shell: the sitewide hugpy navbar + the persistent session-library
|
|
2
|
+
// sidebar + the routed workbench. Every built station now shares ONE workbench
|
|
3
|
+
// (the `group:"studio"` group): its `.vi-subtabs` strip — rendered by
|
|
4
|
+
// WorkbenchStation on the dynamic `/:workbenchTab` route — is the only tab bar,
|
|
5
|
+
// and the old Studio/Generate top-level split is retired. The sidebar mounts
|
|
6
|
+
// ONCE here so it stays static across every sub-tab (including Generate). Deep
|
|
7
|
+
// links (/video/frames, /video/generate …) resolve under the router basename.
|
|
8
|
+
import { useEffect, useState } from "react";
|
|
9
|
+
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
|
10
|
+
import { DEFAULT_STATION_ID } from "./registry";
|
|
11
|
+
import { WorkbenchStation } from "./WorkbenchStation";
|
|
12
|
+
import { WorkbenchSidebar } from "./WorkbenchSidebar";
|
|
13
|
+
import { SidebarRegistryProvider } from "./SidebarRegistry";
|
|
14
|
+
import Navbar from "../nav/Navbar";
|
|
15
|
+
import DemoBanner from "../demo/DemoBanner";
|
|
16
|
+
|
|
17
|
+
export function StationShell() {
|
|
18
|
+
// Narrow-width (<62rem) only: the sidebar becomes an off-canvas drawer toggled
|
|
19
|
+
// by the tab-strip hamburger (WorkbenchStation). At >=62rem CSS keeps the
|
|
20
|
+
// sidebar in its grid column regardless of this flag, so it stays inert there.
|
|
21
|
+
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
22
|
+
const location = useLocation();
|
|
23
|
+
|
|
24
|
+
// Close the drawer whenever the route/sub-tab changes. Desktop is unaffected —
|
|
25
|
+
// the sidebar shows via the grid no matter what this flag says.
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
setSidebarOpen(false);
|
|
28
|
+
}, [location.pathname]);
|
|
29
|
+
|
|
30
|
+
// Close the drawer on Escape while it is open.
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!sidebarOpen) return;
|
|
33
|
+
const onKey = (e: KeyboardEvent) => {
|
|
34
|
+
if (e.key === "Escape") setSidebarOpen(false);
|
|
35
|
+
};
|
|
36
|
+
window.addEventListener("keydown", onKey);
|
|
37
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
38
|
+
}, [sidebarOpen]);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="vi-shell">
|
|
42
|
+
{/* Nav + demo banner = ONE header stack pinned at the top (operator
|
|
43
|
+
directive 2026-07-21). In .vi-shell (a 100dvh flex column that never
|
|
44
|
+
scrolls — content scrolls deep inside the workbench body) the stack is
|
|
45
|
+
simply the non-growing first flex item, so the banner sits directly
|
|
46
|
+
under the nav and pushes the content region down with no offset math. */}
|
|
47
|
+
<header className="vi-header-stack">
|
|
48
|
+
<Navbar />
|
|
49
|
+
<DemoBanner />
|
|
50
|
+
</header>
|
|
51
|
+
<main className="vi-main">
|
|
52
|
+
{/* Round 9: the whole workbench (the shared sidebar AND the routed stations)
|
|
53
|
+
shares the sidebar registry, so a mounted station can register knobs into
|
|
54
|
+
the sidebar's Settings tab / in-flight rows into Active Processes. */}
|
|
55
|
+
<SidebarRegistryProvider>
|
|
56
|
+
<div className={sidebarOpen ? "vi-workbench is-open" : "vi-workbench"}>
|
|
57
|
+
<aside className="vi-workbench-sidebar">
|
|
58
|
+
<WorkbenchSidebar />
|
|
59
|
+
</aside>
|
|
60
|
+
<div className="vi-workbench-main">
|
|
61
|
+
<Routes>
|
|
62
|
+
{/* The single studio workbench: ONE WorkbenchStation on the dynamic
|
|
63
|
+
sub-tab param. It stays mounted across sub-tab switches (incl.
|
|
64
|
+
Generate → GenerateStation as the 4th sub-tab) so the sidebar is
|
|
65
|
+
static. React-Router ranks the `*` redirect below it, so `/`
|
|
66
|
+
(index) and any unknown path land on the default station. The
|
|
67
|
+
sidebar toggle is drilled in so the mobile hamburger can live as
|
|
68
|
+
the leading element of the sub-tab strip. */}
|
|
69
|
+
<Route
|
|
70
|
+
path="/:workbenchTab"
|
|
71
|
+
element={
|
|
72
|
+
<WorkbenchStation
|
|
73
|
+
sidebarOpen={sidebarOpen}
|
|
74
|
+
onToggleSidebar={() => setSidebarOpen((v) => !v)}
|
|
75
|
+
/>
|
|
76
|
+
}
|
|
77
|
+
/>
|
|
78
|
+
<Route path="*" element={<Navigate to={`/${DEFAULT_STATION_ID}`} replace />} />
|
|
79
|
+
</Routes>
|
|
80
|
+
</div>
|
|
81
|
+
{/* Mobile-only scrim behind the drawer — click to close. Fixed-position,
|
|
82
|
+
so its DOM slot here is cosmetic; CSS hides it at >=62rem. */}
|
|
83
|
+
{sidebarOpen && (
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
className="vi-scrim"
|
|
87
|
+
aria-label="Close session library"
|
|
88
|
+
onClick={() => setSidebarOpen(false)}
|
|
89
|
+
/>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
</SidebarRegistryProvider>
|
|
93
|
+
</main>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The STUDIO station — a SINGLE-PLANE workspace over the cinema-studio spine (Movie-tab
|
|
2
|
+
// style): one scroll with the generate controls on top and the clip viewer + list
|
|
3
|
+
// inline below. It renders the shared StudioPlane (the same plane the Generate station's
|
|
4
|
+
// "studio" mode mounts), passing registerBridge so this standalone station is the
|
|
5
|
+
// "Send to Studio" target.
|
|
6
|
+
//
|
|
7
|
+
// History (house rule: never delete, archive):
|
|
8
|
+
// • the original single-page test station → _archive/StudioClipsStation.2026-07-07.pre-workspace.tsx.bak
|
|
9
|
+
// • the two-tab (Generate | Library) workspace → _archive/StudioClipsStation.2026-07-08.tabs-workspace.tsx.bak
|
|
10
|
+
// The two tabs are retired: the single plane shows generation AND the viewer AND the
|
|
11
|
+
// list together, so there is nothing a tab covered that the plane does not. All the
|
|
12
|
+
// behaviors are preserved inside the plane's pieces — preset shapes + hero gating,
|
|
13
|
+
// prompt_note badge, the durable clip list + 6s poll + Session-Library push, the
|
|
14
|
+
// per-row Details expander, filters (now a compact collapsible strip), per-clip actions
|
|
15
|
+
// (play / copy path / use-as-source / cancel), failed-row inline errors, and the
|
|
16
|
+
// studioBridge "Send to Studio" staging (owned here via registerBridge).
|
|
17
|
+
import type { StationSpec } from "./types";
|
|
18
|
+
import { StudioPlane } from "./studio/StudioPlane";
|
|
19
|
+
|
|
20
|
+
export function StudioClipsStation({ spec }: { spec: StationSpec }) {
|
|
21
|
+
return (
|
|
22
|
+
<section className="station-wide vi-studio-clips" aria-label="Studio workspace">
|
|
23
|
+
{/* The single plane. registerBridge: this standalone station is the target of the
|
|
24
|
+
shell sidebar's "Send to Studio" (the Generate-mode plane does not register). */}
|
|
25
|
+
<StudioPlane registerBridge />
|
|
26
|
+
</section>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// WorkbenchDrawer — the ONE overlay window behind the sidebar's
|
|
2
|
+
// `.vi-side-toolbar` deep-links, with Console | ComfyUI | Active as tabs inside it.
|
|
3
|
+
//
|
|
4
|
+
// ── WHY ONE WINDOW (operator ask, 2026-08-06) ──────────────────────────────
|
|
5
|
+
// "Having the 3 options as tab selectable within the overlay window would be a
|
|
6
|
+
// huge convenience." Before this, each panel owned its own drawer instance, its
|
|
7
|
+
// own open/size state and its own storage key. That made switching between them
|
|
8
|
+
// a close-then-reopen — and worse, the scrim sits at z-index 90 OVER the sidebar,
|
|
9
|
+
// so while any drawer was open the other two toggles were not even clickable.
|
|
10
|
+
// Now there is a single drawer: one open flag, one half/full size, one persisted
|
|
11
|
+
// record, and a tab strip in its header.
|
|
12
|
+
//
|
|
13
|
+
// ── WHAT EACH FILE STILL OWNS ──────────────────────────────────────────────
|
|
14
|
+
// The shell (drawerShell.tsx) owns the window: portal-to-<body>, sizing,
|
|
15
|
+
// Esc-to-close, persistence, the tab strip, and the mount policy. Each panel file
|
|
16
|
+
// owns its own tab descriptor and nothing else — crucially, its own security
|
|
17
|
+
// posture, which this host must never normalise:
|
|
18
|
+
// * ConsolePanel — first-party, same-origin iframe, deliberately UNSANDBOXED
|
|
19
|
+
// (a sandbox without allow-modals breaks its confirm() flows).
|
|
20
|
+
// * ComfyPanel — user-supplied FOREIGN endpoint: sanitize-then-sandbox gate
|
|
21
|
+
// (comfyEmbed.ts) plus its endpoint form in the header.
|
|
22
|
+
// * ActivePanel — a real component whose MOUNT starts a 2s poll.
|
|
23
|
+
// They render as three separate elements side by side, so "one window" never
|
|
24
|
+
// means "one frame whose src is swapped": the console frame and the comfy frame
|
|
25
|
+
// are distinct <iframe>s with independent sandbox attributes, exactly as before.
|
|
26
|
+
//
|
|
27
|
+
// ── STATE LIVES HERE, NOT IN THE PORTAL ────────────────────────────────────
|
|
28
|
+
// This component is mounted by WorkbenchSidebar and stays mounted whether the
|
|
29
|
+
// drawer is open or shut, so tab-owned state hoisted into it (ComfyUI's endpoint
|
|
30
|
+
// field) survives both tab switches and a close/reopen.
|
|
31
|
+
import { useComfyDrawerTab } from "./ComfyPanel";
|
|
32
|
+
import { consoleDrawerTab } from "./ConsolePanel";
|
|
33
|
+
import { activeDrawerTab } from "./ActivePanel";
|
|
34
|
+
import { DrawerShell, useDrawerState, type LegacyDrawerKey } from "./drawerShell";
|
|
35
|
+
|
|
36
|
+
type DrawerTabId = "console" | "comfy" | "active";
|
|
37
|
+
|
|
38
|
+
// Strip order = the order the panels arrived in (Console t52, ComfyUI 2026-08-04,
|
|
39
|
+
// Active 2026-08-06), which is also the toolbar's existing left-to-right order —
|
|
40
|
+
// so the tab strip and the deep-link row read the same way round.
|
|
41
|
+
const TAB_IDS = ["console", "comfy", "active"] as const;
|
|
42
|
+
|
|
43
|
+
// ONE key now holds {open, size, tab}, replacing the three per-panel records.
|
|
44
|
+
const STORAGE_KEY = "vi.workbench.drawer.v1";
|
|
45
|
+
|
|
46
|
+
// The pre-tabs keys, in strip order. useDrawerState reads them once (first one
|
|
47
|
+
// found OPEN wins, so an operator who left ComfyUI open full-screen reopens on
|
|
48
|
+
// the ComfyUI tab, full-screen) and then clears them. See migrateLegacy.
|
|
49
|
+
const LEGACY_KEYS: readonly LegacyDrawerKey<DrawerTabId>[] = [
|
|
50
|
+
{ key: "vi.console.panel.v1", tab: "console" },
|
|
51
|
+
{ key: "vi.comfy.panel.v1", tab: "comfy" },
|
|
52
|
+
{ key: "vi.active.panel.v1", tab: "active" },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
export function WorkbenchDrawer() {
|
|
56
|
+
const drawer = useDrawerState<DrawerTabId>(STORAGE_KEY, TAB_IDS, LEGACY_KEYS);
|
|
57
|
+
// ComfyUI's tab is a hook (it owns the endpoint state); the other two are pure
|
|
58
|
+
// descriptors. All three are rebuilt every render — a descriptor is just JSX,
|
|
59
|
+
// and an unselected tab's body element is only MOUNTED if the shell renders it.
|
|
60
|
+
const comfy = useComfyDrawerTab();
|
|
61
|
+
const tabs = [consoleDrawerTab(), comfy, activeDrawerTab()];
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<DrawerShell state={drawer} tabs={tabs} tablistLabel="Overlay panels" />
|
|
65
|
+
);
|
|
66
|
+
}
|