@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,91 @@
1
+ // The Studio workbench — the studio sub-tabs nav + the active station body. The
2
+ // session-library sidebar and the surrounding `.vi-workbench` grid now live at the
3
+ // shell level (StationShell owns them once, for EVERY route), so this component
4
+ // renders ONLY into the shell's `.vi-workbench-main` column. StationShell mounts
5
+ // ONE WorkbenchStation for the whole studio group (routed on the dynamic
6
+ // `/:workbenchTab` segment), so switching sub-tabs never remounts it. The wrapped
7
+ // station bodies (each a `.station-card`, some using the `.vi-studio` grid) render
8
+ // untouched inside the main column.
9
+ import { NavLink, Navigate, useParams } from "react-router-dom";
10
+ import { STATIONS, DEFAULT_STATION_ID } from "./registry";
11
+ import { PlaceholderStation } from "./PlaceholderStation";
12
+ import type { StationSpec } from "./types";
13
+
14
+ // The workbench sub-tabs, straight off the registry — every built station now
15
+ // shares the "studio" group (image-crop, audio-crop, frames, generate), so this
16
+ // one strip IS the whole tab bar.
17
+ const STUDIO_STATIONS = STATIONS.filter((s) => s.group === "studio");
18
+ // The fallback must be a station that actually HAS a tab. This used to be
19
+ // STUDIO_STATIONS[0], taken before the `!navHidden` filter applied to the strip
20
+ // below — so if the first registered station were ever navHidden (studio-clips
21
+ // and, since 2026-08-04, comfy both are), the redirect target would be a page
22
+ // with no tab in the strip and nothing rendering as active.
23
+ const DEFAULT_STUDIO_ID =
24
+ STUDIO_STATIONS.find((s) => !s.navHidden)?.id ?? DEFAULT_STATION_ID;
25
+
26
+ // Same active-guard the shell uses: the real component when active, else the
27
+ // honest placeholder. Kept local so the workbench mirrors StationBody exactly.
28
+ function StudioBody({ spec }: { spec: StationSpec }) {
29
+ const Body = spec.status === "active" && spec.component ? spec.component : null;
30
+ return Body ? <Body spec={spec} /> : <PlaceholderStation spec={spec} />;
31
+ }
32
+
33
+ interface WorkbenchStationProps {
34
+ /** Mobile drawer open-state (owned by StationShell). */
35
+ sidebarOpen: boolean;
36
+ /** Toggle the mobile session-library drawer. */
37
+ onToggleSidebar: () => void;
38
+ }
39
+
40
+ export function WorkbenchStation({
41
+ sidebarOpen,
42
+ onToggleSidebar,
43
+ }: WorkbenchStationProps) {
44
+ const { workbenchTab } = useParams();
45
+
46
+ const active = STUDIO_STATIONS.find((s) => s.id === workbenchTab);
47
+ // A non-studio param (or bare group route) lands on the default studio sub-tab.
48
+ if (!active) return <Navigate to={`/${DEFAULT_STUDIO_ID}`} replace />;
49
+
50
+ // Renders directly into the shell's `.vi-workbench-main` column: the sub-tabs
51
+ // nav then the active body. No sidebar/grid here — the shell owns those. The
52
+ // leading control in the strip is the mobile-only "Session library" hamburger
53
+ // (hidden at >=62rem via CSS); it toggles the shell's off-canvas drawer.
54
+ return (
55
+ <>
56
+ <nav className="vi-subtabs" aria-label="Studio">
57
+ <button
58
+ type="button"
59
+ className="vi-hamburger"
60
+ aria-label="Session library"
61
+ title="Session library"
62
+ aria-expanded={sidebarOpen}
63
+ onClick={onToggleSidebar}
64
+ >
65
+ <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true" focusable="false">
66
+ <path
67
+ d="M2 4.5h14M2 9h14M2 13.5h14"
68
+ stroke="currentColor"
69
+ strokeWidth="1.6"
70
+ strokeLinecap="round"
71
+ />
72
+ </svg>
73
+ </button>
74
+ {STUDIO_STATIONS.filter((s) => !s.navHidden).map((s) => (
75
+ <NavLink
76
+ key={s.id}
77
+ to={`/${s.id}`}
78
+ className={({ isActive }) =>
79
+ isActive ? "vi-subtab vi-subtab-active" : "vi-subtab"
80
+ }
81
+ >
82
+ {s.title}
83
+ </NavLink>
84
+ ))}
85
+ </nav>
86
+ <div className="vi-workbench-body">
87
+ <StudioBody spec={active} />
88
+ </div>
89
+ </>
90
+ );
91
+ }
@@ -0,0 +1,79 @@
1
+ // Shared ComfyUI-embed helpers — the ONE security gate every ComfyUI URL passes
2
+ // before it can become an iframe `src`, plus the embed-hazard classifier behind
3
+ // the inline warnings.
4
+ //
5
+ // Extracted from ComfyStation.tsx when ComfyUI became an in-page OVERLAY
6
+ // (ComfyPanel, mounted in the sidebar beside Console) so the panel and the
7
+ // station cannot drift apart on the part that matters. There must be exactly one
8
+ // sanitizer: a second, subtly-weaker copy is how a `javascript:` URL eventually
9
+ // reaches an iframe.
10
+ //
11
+ // ── WHY THIS EXISTS (do not delete the gate) ────────────────────────────────
12
+ // The ComfyUI endpoint is USER-EDITABLE and PERSISTED, so a hostile or mistyped
13
+ // value could carry a `javascript:` / `data:` / `blob:` / `file:` scheme. An
14
+ // unsanitized value handed to iframe src executes in the parent context. Every
15
+ // value — typed AND rehydrated from storage — goes through sanitizeEmbedUrl().
16
+ //
17
+ // This is also precisely why a ComfyUI iframe is NOT the same trust case as the
18
+ // console's: the console is first-party, same-origin, fixed-href content (see
19
+ // ConsolePanel's header), while this URL is user-supplied and foreign. The
20
+ // console frame is deliberately unsandboxed; a ComfyUI frame MUST stay sandboxed.
21
+
22
+ /** The ONLY schemes allowed to reach iframe src. Everything else — javascript:,
23
+ * data:, blob:, file:, vbscript:, mailto:, about:, … — is rejected so a
24
+ * persisted or typed hostile URI can never execute in the parent context. */
25
+ const ALLOWED_SCHEMES = new Set(["http:", "https:"]);
26
+
27
+ /**
28
+ * Return `raw` as a normalized URL string ONLY if it parses as an absolute
29
+ * http:// or https:// URL; otherwise null. Uses `new URL()` (so relative junk,
30
+ * bare hosts, and unknown schemes throw or fail the allowlist) plus a strict
31
+ * scheme allowlist. Call it on typed input AND on any persisted value.
32
+ */
33
+ export function sanitizeEmbedUrl(raw: string): string | null {
34
+ const trimmed = raw.trim();
35
+ if (!trimmed) return null;
36
+ let parsed: URL;
37
+ try {
38
+ parsed = new URL(trimmed);
39
+ } catch {
40
+ return null; // relative / malformed / scheme-less — never embeddable.
41
+ }
42
+ if (!ALLOWED_SCHEMES.has(parsed.protocol)) return null; // reject javascript:, data:, blob:, file:, …
43
+ return parsed.href;
44
+ }
45
+
46
+ /** Embed-hazard classification of a (sanitized) URL, for the inline warning. */
47
+ export type EmbedRisk = "http" | "localhost" | null;
48
+
49
+ /**
50
+ * Flag URLs that an HTTPS page cannot embed: http:// (mixed content) or a
51
+ * loopback host (localhost / 127.0.0.1 / ::1 — not reachable/TLS-served from the
52
+ * user's browser through the deployed HTTPS origin). Runs on the sanitized value,
53
+ * so anything non-http/https is already null and returns no risk.
54
+ */
55
+ export function embedRisk(raw: string): EmbedRisk {
56
+ const safe = sanitizeEmbedUrl(raw);
57
+ if (!safe) return null;
58
+ const u = new URL(safe);
59
+ if (u.protocol === "http:") return "http";
60
+ const host = u.hostname.toLowerCase();
61
+ const isLoopback =
62
+ host === "localhost" ||
63
+ host.endsWith(".localhost") ||
64
+ host === "127.0.0.1" ||
65
+ host === "::1" ||
66
+ host === "0.0.0.0";
67
+ return isLoopback ? "localhost" : null;
68
+ }
69
+
70
+ /** The sandbox tokens a ComfyUI frame runs with. Permissive on purpose (ComfyUI
71
+ * needs scripts, same-origin, forms, downloads and popups to function) but
72
+ * NEVER absent: the URL is user-supplied and foreign, unlike the console's. */
73
+ export const COMFY_SANDBOX =
74
+ "allow-scripts allow-same-origin allow-forms allow-downloads allow-popups allow-popups-to-escape-sandbox";
75
+
76
+ /** Session-persisted (per-tab) key for the entered endpoint URL. Shared so the
77
+ * panel and the station show the SAME endpoint instead of two half-configured
78
+ * ones. */
79
+ export const COMFY_URL_KEY = "vi.comfy.url.v1";
@@ -0,0 +1,419 @@
1
+ // The SHARED expand-into-the-screen drawer — the shell behind the sidebar's
2
+ // `.vi-side-toolbar` toggles (Console, ComfyUI, and — 2026-08-06 — Active).
3
+ //
4
+ // ── WHY THIS EXISTS ─────────────────────────────────────────────────────────
5
+ // t52 built the shell inside ConsolePanel. ComfyPanel (2026-08-04) reproduced it
6
+ // verbatim so that "the same as console" would be literally true rather than
7
+ // approximately true — the right instinct, but by the third overlay (the operator
8
+ // ask 2026-08-06: "Active as an overlay too") a copied shell is three chances to
9
+ // drift. So the shell is factored out HERE, exactly as it already existed:
10
+ // same portal-to-<body>, same `.vi-console-panel*` classes, same localStorage
11
+ // open/size persistence, same Esc-to-close, same half/full toggle. Nothing about
12
+ // the rendered DOM changed in the move — the class names deliberately keep the
13
+ // `vi-console-panel` prefix (they are the drawer's classes, not the console's).
14
+ //
15
+ // ── ONE WINDOW, THREE TABS (operator ask 2026-08-06, second round) ──────────
16
+ // "Having the 3 options as tab selectable within the overlay window would be a
17
+ // huge convenience." Three independent drawers meant three open/size states and
18
+ // no way to get from one to the next without closing and reopening — and, since
19
+ // the scrim covers the sidebar at z-index 90, the toolbar toggles are not even
20
+ // clickable while a drawer is open. So there is now exactly ONE drawer instance
21
+ // (one open flag, one size, one persisted record) whose header carries a
22
+ // `role="tablist"` strip; each DrawerTab contributes its own body, header extras
23
+ // and header actions. Selecting a tab swaps the body in place — no close/reopen.
24
+ //
25
+ // ── MOUNTING POLICY: PER TAB, VIA `keepMounted` (deliberate, read this) ─────
26
+ // An unselected tab is one of two things, and the difference is load-bearing:
27
+ // * keepMounted: true → rendered but `hidden`. `display:none` does NOT tear
28
+ // down or reload an <iframe>, so Console and ComfyUI keep their live frame
29
+ // (and its scroll position, its login session, its half-finished ComfyUI
30
+ // graph) across tab switches. Reloading a ComfyUI workspace every time the
31
+ // operator glanced at Active would make the tab strip worse than the three
32
+ // separate drawers it replaces.
33
+ // * keepMounted: false → not rendered at all. This is for a body whose MOUNT
34
+ // is what starts work: ActivePanel's ActiveProcessesStation polls /media/jobs
35
+ // every 2s for as long as it is mounted, so "hidden but mounted" would keep
36
+ // that poll running behind the Console frame — exactly the always-on poll the
37
+ // panel's own header forbids. Unmounting is how the poll stops; there is no
38
+ // separate pause seam to reach for.
39
+ // The whole portal only exists while `open`, so a CLOSED drawer still mounts
40
+ // nothing at all — closing remains the way to drop every frame and every poll.
41
+ //
42
+ // ── PORTALED TO <body> (do not "simplify" this) ─────────────────────────────
43
+ // WorkbenchSidebar lives inside `.vi-workbench-sidebar`, which grows a `transform`
44
+ // at the <62rem breakpoint (the off-canvas drawer). A transformed ancestor becomes
45
+ // the containing block for `position: fixed` descendants, which would trap an
46
+ // "expand into the screen" panel inside the sidebar's own narrow box. Portaling to
47
+ // <body> sidesteps that regardless of where the toggle button lives in the tree.
48
+ import {
49
+ useCallback,
50
+ useEffect,
51
+ useState,
52
+ type ReactNode,
53
+ } from "react";
54
+ import { createPortal } from "react-dom";
55
+
56
+ export type DrawerSize = "half" | "full";
57
+
58
+ interface PersistedState<T extends string> {
59
+ open: boolean;
60
+ size: DrawerSize;
61
+ /** Which tab was showing — restored so reopening lands where you left off. */
62
+ tab: T;
63
+ }
64
+
65
+ /** A pre-tabs per-panel storage key and the tab it became. See migrateLegacy. */
66
+ export interface LegacyDrawerKey<T extends string> {
67
+ key: string;
68
+ tab: T;
69
+ }
70
+
71
+ function parse<T extends string>(
72
+ raw: string | null,
73
+ tabs: readonly T[],
74
+ fallback: PersistedState<T>,
75
+ ): PersistedState<T> | null {
76
+ if (!raw) return null;
77
+ try {
78
+ const p = JSON.parse(raw);
79
+ return {
80
+ open: typeof p.open === "boolean" ? p.open : fallback.open,
81
+ size: p.size === "full" ? "full" : "half",
82
+ // An unknown tab id (a renamed/removed panel in an older record) falls
83
+ // back to the first tab rather than leaving the strip with nothing selected.
84
+ tab: tabs.includes(p.tab) ? (p.tab as T) : fallback.tab,
85
+ };
86
+ } catch {
87
+ return null; // corrupt record — quiet fallback
88
+ }
89
+ }
90
+
91
+ /**
92
+ * One-time upgrade from the three pre-tabs keys (`vi.console.panel.v1`,
93
+ * `vi.comfy.panel.v1`, `vi.active.panel.v1`) to the single tabbed record.
94
+ *
95
+ * An operator who left, say, the ComfyUI drawer open and full-screen should find
96
+ * the unified drawer open, full-screen, on the ComfyUI tab — not collapsed. So:
97
+ * the first legacy key found OPEN wins outright (open + size + its tab); if none
98
+ * was open, the first parseable record still donates its `size` so the half/full
99
+ * preference survives. The stale keys are cleared by useDrawerState's mount
100
+ * effect (a side effect belongs there, not in a render-phase initializer).
101
+ */
102
+ function migrateLegacy<T extends string>(
103
+ tabs: readonly T[],
104
+ legacy: readonly LegacyDrawerKey<T>[],
105
+ fallback: PersistedState<T>,
106
+ ): PersistedState<T> {
107
+ let size: DrawerSize | null = null;
108
+ for (const entry of legacy) {
109
+ const prev = parse(localStorage.getItem(entry.key), tabs, fallback);
110
+ if (!prev) continue;
111
+ if (prev.open) return { open: true, size: prev.size, tab: entry.tab };
112
+ if (size == null) size = prev.size;
113
+ }
114
+ return size == null ? fallback : { ...fallback, size };
115
+ }
116
+
117
+ function readPersisted<T extends string>(
118
+ storageKey: string,
119
+ tabs: readonly T[],
120
+ legacy: readonly LegacyDrawerKey<T>[],
121
+ ): PersistedState<T> {
122
+ const fallback: PersistedState<T> = {
123
+ open: false,
124
+ size: "half",
125
+ tab: tabs[0] as T,
126
+ };
127
+ try {
128
+ // The unified record wins whenever it exists — migration is strictly the
129
+ // "never seen the tabbed drawer before" path, so it can only ever run once.
130
+ return (
131
+ parse(localStorage.getItem(storageKey), tabs, fallback) ??
132
+ migrateLegacy(tabs, legacy, fallback)
133
+ );
134
+ } catch {
135
+ return fallback; // storage unavailable (private mode) — quiet fallback
136
+ }
137
+ }
138
+
139
+ function persist<T extends string>(
140
+ storageKey: string,
141
+ state: PersistedState<T>,
142
+ ): void {
143
+ try {
144
+ localStorage.setItem(storageKey, JSON.stringify(state));
145
+ } catch {
146
+ /* best-effort — the in-memory state still drives this session */
147
+ }
148
+ }
149
+
150
+ export interface DrawerState<T extends string = string> {
151
+ open: boolean;
152
+ size: DrawerSize;
153
+ tab: T;
154
+ /** Open the drawer ON a tab — what a toolbar deep-link does. */
155
+ openTab: (tab: T) => void;
156
+ /** Switch tabs without touching open/size — what the in-window strip does. */
157
+ selectTab: (tab: T) => void;
158
+ close: () => void;
159
+ toggleSize: () => void;
160
+ }
161
+
162
+ /**
163
+ * The drawer's open/size/tab state, persisted under ONE key.
164
+ *
165
+ * Persisting every change is the t52 spec ("feels stable across visits"):
166
+ * reopening /video restores exactly the open/closed + half/full state left
167
+ * behind, instead of always starting collapsed. The selected tab rides along in
168
+ * the same record for the same reason — the operator who lives in ComfyUI should
169
+ * not have to re-pick it every visit.
170
+ *
171
+ * Note the SINGLE state object: the pre-tabs version kept two useState calls,
172
+ * each re-reading storage in its initializer. That was fine while the read was
173
+ * pure, but migration must observe the legacy keys exactly once, so one read /
174
+ * one initializer is now load-bearing.
175
+ */
176
+ export function useDrawerState<T extends string>(
177
+ storageKey: string,
178
+ tabs: readonly T[],
179
+ legacy: readonly LegacyDrawerKey<T>[] = [],
180
+ ): DrawerState<T> {
181
+ const [state, setState] = useState<PersistedState<T>>(() =>
182
+ readPersisted(storageKey, tabs, legacy),
183
+ );
184
+
185
+ useEffect(() => {
186
+ persist(storageKey, state);
187
+ }, [storageKey, state]);
188
+
189
+ // Drop the pre-tabs keys once, after the first commit has already read them.
190
+ // They are pure UI chrome (open/half) and nothing reads them any more, so
191
+ // leaving them would only litter storage with records that can never win again.
192
+ useEffect(() => {
193
+ try {
194
+ for (const entry of legacy) localStorage.removeItem(entry.key);
195
+ } catch {
196
+ /* storage unavailable — nothing to clean up */
197
+ }
198
+ // Intentionally mount-only: `legacy` is a module-level constant at every
199
+ // call site, and re-running this would be a no-op anyway.
200
+ // eslint-disable-next-line react-hooks/exhaustive-deps
201
+ }, []);
202
+
203
+ const openTab = useCallback(
204
+ (tab: T) => setState((s) => ({ ...s, open: true, tab })),
205
+ [],
206
+ );
207
+ const selectTab = useCallback(
208
+ (tab: T) => setState((s) => ({ ...s, tab })),
209
+ [],
210
+ );
211
+ const close = useCallback(() => setState((s) => ({ ...s, open: false })), []);
212
+ const toggleSize = useCallback(
213
+ () =>
214
+ setState((s) => ({ ...s, size: s.size === "full" ? "half" : "full" })),
215
+ [],
216
+ );
217
+
218
+ // ESC closes — only listens while the panel is actually open, mirroring
219
+ // StationShell's own Escape-closes-the-mobile-drawer effect.
220
+ useEffect(() => {
221
+ if (!state.open) return;
222
+ const onKey = (e: KeyboardEvent) => {
223
+ if (e.key === "Escape") close();
224
+ };
225
+ window.addEventListener("keydown", onKey);
226
+ return () => window.removeEventListener("keydown", onKey);
227
+ }, [state.open, close]);
228
+
229
+ return {
230
+ open: state.open,
231
+ size: state.size,
232
+ tab: state.tab,
233
+ openTab,
234
+ selectTab,
235
+ close,
236
+ toggleSize,
237
+ };
238
+ }
239
+
240
+ /** One tab of the unified drawer, contributed by its own panel file. */
241
+ export interface DrawerTab<T extends string = string> {
242
+ id: T;
243
+ /** Tab-strip label, and the noun in "Close X" — a plain string for a11y. */
244
+ label: string;
245
+ /** Tooltip, shared by the toolbar deep-link and the in-window tab. */
246
+ hint: string;
247
+ /** Extra class on the toolbar deep-link (e.g. "vi-console-toggle") — sized by app.css. */
248
+ toggleClassName: string;
249
+ /** Toolbar deep-link content (e.g. "▤ Console") — may carry a glyph. */
250
+ toggleLabel: ReactNode;
251
+ /** The dialog's accessible name while this tab is selected (e.g. "hugpy console"). */
252
+ ariaLabel: string;
253
+ /** Extra class on the panel element while selected (e.g. "vi-comfy-panel"). */
254
+ panelClassName?: string;
255
+ /** Optional header content between the tab strip and the actions (e.g. Comfy's endpoint form). */
256
+ headerExtra?: ReactNode;
257
+ /** Optional actions between Half/Full and the ✕ (e.g. "Open in new tab"). */
258
+ headerActions?: ReactNode;
259
+ /** The tab body. */
260
+ body: ReactNode;
261
+ /**
262
+ * Stay in the DOM (just `hidden`) while another tab is selected — TRUE for
263
+ * iframe bodies so they don't reload, FALSE for a body whose mount starts
264
+ * work (a poll). See this file's mounting-policy note; every tab must make
265
+ * this choice deliberately, so it is not optional.
266
+ */
267
+ keepMounted: boolean;
268
+ }
269
+
270
+ export interface DrawerShellProps<T extends string> {
271
+ /** Open/size/tab state — from useDrawerState, owned by the caller. */
272
+ state: DrawerState<T>;
273
+ /** The tabs, in strip order. The first is the fallback for an unknown id. */
274
+ tabs: readonly DrawerTab<T>[];
275
+ /** aria-label for the tab strip itself. */
276
+ tablistLabel: string;
277
+ }
278
+
279
+ /**
280
+ * The toolbar deep-links + the (portaled) drawer window with its tab strip.
281
+ *
282
+ * WHY THREE TOOLBAR BUTTONS SURVIVED the collapse to one window: the row's idiom
283
+ * is one button per named destination (Console, ComfyUI, Active, Share — Share is
284
+ * a separate modal and keeps its own). Collapsing to a single "▤ Panels" button
285
+ * would trade a named one-click destination for an unnamed click-then-pick, and
286
+ * would leave Share as the odd one out in a row of one generic button. So each
287
+ * button stays a DEEP LINK: it opens the one window already showing that tab.
288
+ * They cost nothing extra now — they share one open/size state.
289
+ */
290
+ export function DrawerShell<T extends string>({
291
+ state,
292
+ tabs,
293
+ tablistLabel,
294
+ }: DrawerShellProps<T>) {
295
+ const { open, size, tab, openTab, selectTab, close, toggleSize } = state;
296
+ const active = tabs.find((t) => t.id === tab) ?? tabs[0];
297
+ const closeText = `Close ${active.label}`;
298
+
299
+ return (
300
+ <>
301
+ {tabs.map((t) => (
302
+ <button
303
+ key={t.id}
304
+ type="button"
305
+ className={`${t.toggleClassName} vi-btn vi-btn-sm`}
306
+ // Accurate disclosure state: this tab's panel is only showing when the
307
+ // drawer is open AND it is the selected one.
308
+ aria-expanded={open && tab === t.id}
309
+ onClick={() => openTab(t.id)}
310
+ title={t.hint}
311
+ >
312
+ {t.toggleLabel}
313
+ </button>
314
+ ))}
315
+
316
+ {/* Portaled to document.body — see the header note. */}
317
+ {open &&
318
+ createPortal(
319
+ <>
320
+ <button
321
+ type="button"
322
+ className="vi-console-scrim"
323
+ aria-label={closeText}
324
+ onClick={close}
325
+ />
326
+ <div
327
+ className={`vi-console-panel vi-console-panel-${size}${
328
+ active.panelClassName ? ` ${active.panelClassName}` : ""
329
+ }`}
330
+ role="dialog"
331
+ aria-modal="true"
332
+ aria-label={active.ariaLabel}
333
+ >
334
+ <div className="vi-console-panel-bar">
335
+ {/* The strip replaces the old single title — the selected tab IS
336
+ the title, and it reuses the segmented `.vi-gen-mode` control
337
+ the sidebar's own tab strip uses, so the two read alike. */}
338
+ <div
339
+ className="vi-gen-mode vi-drawer-tabs"
340
+ role="tablist"
341
+ aria-label={tablistLabel}
342
+ >
343
+ {tabs.map((t) => (
344
+ <button
345
+ key={t.id}
346
+ type="button"
347
+ role="tab"
348
+ id={`vi-drawer-tab-${t.id}`}
349
+ aria-selected={t.id === tab}
350
+ // Only claim to control a panel that is actually in the
351
+ // DOM — a non-keepMounted tab has no element to point at
352
+ // while it is unselected, and a dangling aria-controls is
353
+ // worse than none.
354
+ {...(t.keepMounted || t.id === tab
355
+ ? { "aria-controls": `vi-drawer-panel-${t.id}` }
356
+ : {})}
357
+ className={`vi-gen-mode-btn${
358
+ t.id === tab ? " vi-gen-mode-btn-active" : ""
359
+ }`}
360
+ onClick={() => selectTab(t.id)}
361
+ title={t.hint}
362
+ >
363
+ {t.label}
364
+ </button>
365
+ ))}
366
+ </div>
367
+ {active.headerExtra}
368
+ <div className="vi-console-panel-actions">
369
+ <button
370
+ type="button"
371
+ className="vi-btn vi-btn-sm"
372
+ onClick={toggleSize}
373
+ title={
374
+ size === "full"
375
+ ? "Shrink to half the screen"
376
+ : "Expand to fill the screen"
377
+ }
378
+ >
379
+ {size === "full" ? "Half" : "Full"}
380
+ </button>
381
+ {active.headerActions}
382
+ <button
383
+ type="button"
384
+ className="vi-btn vi-btn-sm"
385
+ onClick={close}
386
+ aria-label={closeText}
387
+ title="Close (Esc)"
388
+ >
389
+ ✕
390
+ </button>
391
+ </div>
392
+ </div>
393
+
394
+ {/* Bodies. An unselected keepMounted tab stays in the DOM behind
395
+ `hidden` (iframes survive); an unselected non-keepMounted tab is
396
+ removed outright (its poll stops). See the mounting-policy note. */}
397
+ {tabs.map((t) => {
398
+ const selected = t.id === tab;
399
+ if (!selected && !t.keepMounted) return null;
400
+ return (
401
+ <div
402
+ key={t.id}
403
+ id={`vi-drawer-panel-${t.id}`}
404
+ role="tabpanel"
405
+ aria-labelledby={`vi-drawer-tab-${t.id}`}
406
+ className="vi-drawer-tabpanel"
407
+ hidden={!selected}
408
+ >
409
+ {t.body}
410
+ </div>
411
+ );
412
+ })}
413
+ </div>
414
+ </>,
415
+ document.body,
416
+ )}
417
+ </>
418
+ );
419
+ }