@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.
Files changed (177) hide show
  1. package/LICENSE +41 -0
  2. package/assets/sampels/images/bycycle/bd8603bad35d4f179f4d1134d8faf2ee.mp4 +0 -0
  3. package/assets/sampels/images/bycycle/frame_00000.png +0 -0
  4. package/assets/sampels/images/bycycle/frame_00001.png +0 -0
  5. package/assets/sampels/images/bycycle/frame_00002.png +0 -0
  6. package/assets/sampels/images/bycycle/frame_00003.png +0 -0
  7. package/assets/sampels/images/bycycle/frame_00004.png +0 -0
  8. package/assets/sampels/images/bycycle/frame_00005.png +0 -0
  9. package/assets/sampels/images/forrest/ff23090611b844a6b85c224f388bd542.mp4 +0 -0
  10. package/assets/sampels/images/forrest/frame_00000.png +0 -0
  11. package/assets/sampels/images/forrest/frame_00001.png +0 -0
  12. package/assets/sampels/images/forrest/frame_00002.png +0 -0
  13. package/assets/sampels/images/forrest/frame_00003.png +0 -0
  14. package/assets/sampels/images/forrest/frame_00004.png +0 -0
  15. package/assets/sampels/images/forrest/frame_00005.png +0 -0
  16. package/assets/sampels/images/shenzo/e1a4fcd623084ab4a986d11c8cd00db8.mp4 +0 -0
  17. package/assets/sampels/images/shenzo/frame_00000.png +0 -0
  18. package/assets/sampels/images/shenzo/frame_00001.png +0 -0
  19. package/assets/sampels/images/shenzo/frame_00002.png +0 -0
  20. package/assets/sampels/images/shenzo/frame_00003.png +0 -0
  21. package/assets/sampels/images/shenzo/frame_00004.png +0 -0
  22. package/assets/sampels/images/shenzo/frame_00005.png +0 -0
  23. package/assets/sampels/scenes/man.mp4 +0 -0
  24. package/assets/sampels/scenes/woman.mp4 +0 -0
  25. package/assets/sampels/studio_movies/caverns/_assembly/contrib_00.mp4 +0 -0
  26. package/assets/sampels/studio_movies/caverns/movie.json +67 -0
  27. package/assets/sampels/studio_movies/caverns/movie.mp4 +0 -0
  28. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/clip.mp4 +0 -0
  29. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/manifest.json +87 -0
  30. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/provenance.json +7 -0
  31. package/assets/sampels/studio_movies/caverns/segment_01/branch.png +0 -0
  32. package/assets/sampels/studio_movies/keeper-matrix/_assembly/concat_list.txt +2 -0
  33. package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_00.mp4 +0 -0
  34. package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_01.mp4 +0 -0
  35. package/assets/sampels/studio_movies/keeper-matrix/movie.json +81 -0
  36. package/assets/sampels/studio_movies/keeper-matrix/movie.mp4 +0 -0
  37. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/clip.mp4 +0 -0
  38. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/manifest.json +87 -0
  39. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/provenance.json +7 -0
  40. package/assets/sampels/studio_movies/keeper-matrix/segment_01/branch.png +0 -0
  41. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/clip.mp4 +0 -0
  42. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/manifest.json +87 -0
  43. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/provenance.json +7 -0
  44. package/assets/sampels/videos/a_red_paper_boat_drifting_on_a_calm_pond__gentle_ripples__mo_a62d6c28.mp4 +0 -0
  45. package/assets/sampels/videos/aurora.mp4 +0 -0
  46. package/assets/sampels/videos/dark_night.mp4 +0 -0
  47. package/assets/sampels/videos/fire_and_soul.mp4 +0 -0
  48. package/assets/sampels/videos/harsh_seas.mp4 +0 -0
  49. package/entry.tsx +74 -0
  50. package/index.html +53 -0
  51. package/package.json +42 -0
  52. package/src/assets/hugpy-mark.png +0 -0
  53. package/src/config.ts +676 -0
  54. package/src/demo/DemoBanner.tsx +54 -0
  55. package/src/demo/assets/demo-audio.mp3 +0 -0
  56. package/src/demo/assets/demo-clip.mp4 +0 -0
  57. package/src/demo/assets/demo-crop.mp4 +0 -0
  58. package/src/demo/assets/demo-poster.jpg +0 -0
  59. package/src/demo/assets/frame-1.jpg +0 -0
  60. package/src/demo/assets/frame-2.jpg +0 -0
  61. package/src/demo/assets/frame-3.jpg +0 -0
  62. package/src/demo/assets/frame-4.jpg +0 -0
  63. package/src/demo/assets/frame-5.jpg +0 -0
  64. package/src/demo/assets/frame-6.jpg +0 -0
  65. package/src/demo/assets/gen-1.jpg +0 -0
  66. package/src/demo/assets/gen-2.jpg +0 -0
  67. package/src/demo/assets.d.ts +14 -0
  68. package/src/demo/demo.css +64 -0
  69. package/src/demo/demoFetch.ts +305 -0
  70. package/src/demo/fixtures.ts +814 -0
  71. package/src/demo/index.ts +43 -0
  72. package/src/demo/mode.ts +81 -0
  73. package/src/demo/promptAssist.ts +181 -0
  74. package/src/demo/seed.ts +97 -0
  75. package/src/nav/BrandMark.css +66 -0
  76. package/src/nav/BrandMark.tsx +40 -0
  77. package/src/nav/Navbar.css +13 -0
  78. package/src/nav/Navbar.tsx +68 -0
  79. package/src/regions/SpatialRegionEditor.tsx +375 -0
  80. package/src/regions/TemporalRegionEditor.tsx +485 -0
  81. package/src/regions/types.ts +85 -0
  82. package/src/session.ts +85 -0
  83. package/src/share.ts +77 -0
  84. package/src/stations/ActivePanel.tsx +55 -0
  85. package/src/stations/ActiveProcessesStation.tsx +272 -0
  86. package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +228 -0
  87. package/src/stations/AddFileBar.tsx +59 -0
  88. package/src/stations/AudioCropStation.tsx +157 -0
  89. package/src/stations/ComfyPanel.tsx +210 -0
  90. package/src/stations/ComfyStation.tsx +196 -0
  91. package/src/stations/CondensedKnobStrip.tsx +245 -0
  92. package/src/stations/ConsolePanel.tsx +82 -0
  93. package/src/stations/FrameExtractStation.tsx +148 -0
  94. package/src/stations/GenIdentityBar.tsx +412 -0
  95. package/src/stations/GenerateStation.tsx +5603 -0
  96. package/src/stations/GenerateStation.tsx.bak-tester-removal +5170 -0
  97. package/src/stations/GoalComposer.tsx +139 -0
  98. package/src/stations/ImageCropStation.tsx +153 -0
  99. package/src/stations/KnobInfo.tsx +64 -0
  100. package/src/stations/PlaceholderStation.tsx +26 -0
  101. package/src/stations/SectionTabs.tsx +276 -0
  102. package/src/stations/SharePanel.tsx +298 -0
  103. package/src/stations/SidebarRegistry.tsx +105 -0
  104. package/src/stations/StationShell.tsx +96 -0
  105. package/src/stations/StudioClipsStation.tsx +28 -0
  106. package/src/stations/WorkbenchDrawer.tsx +66 -0
  107. package/src/stations/WorkbenchSidebar.tsx +1224 -0
  108. package/src/stations/WorkbenchStation.tsx +91 -0
  109. package/src/stations/comfyEmbed.ts +79 -0
  110. package/src/stations/drawerShell.tsx +419 -0
  111. package/src/stations/ops/AudioCropCore.tsx +218 -0
  112. package/src/stations/ops/CharacterGroupsPanel.tsx +716 -0
  113. package/src/stations/ops/FrameExtractCore.tsx +695 -0
  114. package/src/stations/ops/ImageCropCore.tsx +164 -0
  115. package/src/stations/ops/VideoCropCore.tsx +193 -0
  116. package/src/stations/ops/useVideoCropJobs.ts +224 -0
  117. package/src/stations/pollCaps.ts +87 -0
  118. package/src/stations/registry.ts +191 -0
  119. package/src/stations/studio/AngleRingEditor.tsx +159 -0
  120. package/src/stations/studio/IdentitiesStation.tsx +2455 -0
  121. package/src/stations/studio/IdentityProfileControls.tsx +262 -0
  122. package/src/stations/studio/StudioActiveProcesses.tsx +224 -0
  123. package/src/stations/studio/StudioAssistLogPanel.tsx +220 -0
  124. package/src/stations/studio/StudioGenerateMode.tsx +29 -0
  125. package/src/stations/studio/StudioGenerateTab.tsx +2688 -0
  126. package/src/stations/studio/StudioItemCard.tsx +334 -0
  127. package/src/stations/studio/StudioLibraryTab.tsx +506 -0
  128. package/src/stations/studio/StudioMovieComposer.tsx +2875 -0
  129. package/src/stations/studio/StudioMovieComposer.tsx.bak-sessions-20260812 +2729 -0
  130. package/src/stations/studio/StudioMovieSessions.tsx +553 -0
  131. package/src/stations/studio/StudioMovieSessions.tsx.bak-sessions-20260812 +417 -0
  132. package/src/stations/studio/StudioPlane.tsx +302 -0
  133. package/src/stations/studio/StudioSplicedRow.tsx +476 -0
  134. package/src/stations/studio/StudioViewer.tsx +339 -0
  135. package/src/stations/studio/TurntableViewer.tsx +220 -0
  136. package/src/stations/studio/movieTimeline.ts +198 -0
  137. package/src/stations/studio/promptCard.tsx +498 -0
  138. package/src/stations/studio/studioItem.ts +87 -0
  139. package/src/stations/studio/studioShared.tsx +821 -0
  140. package/src/stations/studio/useIdentityProfiles.ts +575 -0
  141. package/src/stations/studio/useReconstruction.ts +283 -0
  142. package/src/stations/types.ts +43 -0
  143. package/src/stations/useAudioCropJobs.ts +202 -0
  144. package/src/stations/useAudioExtractJob.ts +144 -0
  145. package/src/stations/useCropJobs.ts +206 -0
  146. package/src/stations/useFrameJobs.ts +140 -0
  147. package/src/stations/useGenerateJob.ts +151 -0
  148. package/src/stations/useGenerateMovieJob.ts +177 -0
  149. package/src/stations/useGenerateRowJobs.ts +184 -0
  150. package/src/stations/useGenerateSceneJob.ts +156 -0
  151. package/src/stations/useGenerateSceneRowJobs.ts +282 -0
  152. package/src/style/app.css +4974 -0
  153. package/src/transport/client.ts +262 -0
  154. package/src/transport/telemetry.ts +97 -0
  155. package/src/video/DropReceptacle.tsx +122 -0
  156. package/src/video/PromptAssistButtons.tsx +118 -0
  157. package/src/video/composerBridge.ts +66 -0
  158. package/src/video/contract.ts +599 -0
  159. package/src/video/jobTracker.ts +702 -0
  160. package/src/video/mediaLibrary.ts +328 -0
  161. package/src/video/renderCompat.ts +512 -0
  162. package/src/video/sessionForm.ts +189 -0
  163. package/src/video/spreadAssist.ts +392 -0
  164. package/src/video/studioBridge.ts +82 -0
  165. package/src/video/useMediaJobs.ts +279 -0
  166. package/src/video/useModels.ts +413 -0
  167. package/src/video/useMoviePresets.ts +97 -0
  168. package/src/video/useMovieSessions.ts +292 -0
  169. package/src/video/usePresets.ts +167 -0
  170. package/src/video/useProjects.ts +87 -0
  171. package/src/video/usePromptAssist.ts +394 -0
  172. package/src/video/useRenderPresets.ts +221 -0
  173. package/src/video/useStudioAssistLog.ts +266 -0
  174. package/src/video/useStudioPresets.ts +128 -0
  175. package/src/video/useWorkerCalls.ts +165 -0
  176. package/tsconfig.json +17 -0
  177. 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
+ }