@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,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
|
+
}
|