@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,59 @@
1
+ // Compact file-intake bar — the single presentation for picking a source in
2
+ // every station. NO drag-drop dropbox: it is just a "Choose …" button (a label
3
+ // wrapping a hidden file input) plus the caller-supplied status / filename /
4
+ // meta line, and an optional Clear affordance. The button still drives the
5
+ // station's existing onPick/upload path — this is presentation only.
6
+ import type { ChangeEvent, ReactNode } from "react";
7
+
8
+ export function AddFileBar({
9
+ accept,
10
+ onFile,
11
+ buttonLabel,
12
+ busy = false,
13
+ onClear,
14
+ children,
15
+ }: {
16
+ accept: string;
17
+ onFile: (file: File) => void;
18
+ buttonLabel: string;
19
+ busy?: boolean;
20
+ /** Optional clear/reset affordance (shown only when provided). */
21
+ onClear?: () => void;
22
+ /** Status / filename / meta rendered inline after the button. */
23
+ children?: ReactNode;
24
+ }) {
25
+ function onChange(e: ChangeEvent<HTMLInputElement>) {
26
+ const file = e.target.files?.[0];
27
+ if (file) onFile(file);
28
+ // Reset so re-picking the SAME file re-fires onChange.
29
+ e.target.value = "";
30
+ }
31
+
32
+ return (
33
+ <div className="vi-addbar">
34
+ <label className="vi-btn vi-btn-accent vi-file-label">
35
+ {buttonLabel}
36
+ <input
37
+ type="file"
38
+ accept={accept}
39
+ className="vi-file-input"
40
+ onChange={onChange}
41
+ disabled={busy}
42
+ />
43
+ </label>
44
+ {children}
45
+ {onClear && (
46
+ <button
47
+ type="button"
48
+ className="vi-btn vi-btn-sm vi-btn-ghost"
49
+ onClick={onClear}
50
+ disabled={busy}
51
+ >
52
+ Clear
53
+ </button>
54
+ )}
55
+ </div>
56
+ );
57
+ }
58
+
59
+ export default AddFileBar;
@@ -0,0 +1,157 @@
1
+ // Audio Crop station (Phase 5) — the temporal sibling of the Image Crop station.
2
+ // Flow:
3
+ // file (audio OR video) → POST /uploads (multipart) → {path}
4
+ // → POST /video/ingest {path} → MediaRef
5
+ // → AudioCropCore: (video→audio extract) + TemporalRegionEditor + enqueue
6
+ // + result grid
7
+ // Every hop goes through request<T>() + config URLs; errors surface via
8
+ // describeAppError.
9
+ //
10
+ // Studio slice 2a: the extract step + region editor + enqueue hook + result grid
11
+ // were extracted VERBATIM into stations/ops/AudioCropCore (the reusable "Components
12
+ // view minus upload"), including the video→audio extract path. This station now owns
13
+ // only the upload/ingest lifecycle + the persistent add-file bar; it renders
14
+ // <AudioCropCore/> in the Components slot. Behaviour is unchanged.
15
+ import { useState } from "react";
16
+ import type { StationSpec } from "./types";
17
+ import { SectionTabs } from "./SectionTabs";
18
+ import { AddFileBar } from "./AddFileBar";
19
+ import { AudioCropCore } from "./ops/AudioCropCore";
20
+ import { request, okValue, errorOf, describeAppError } from "../transport/client";
21
+ import { hugpyConfig } from "../config";
22
+ import { getSessionId } from "../session";
23
+ import {
24
+ mediaRefSchema,
25
+ uploadResultSchema,
26
+ type MediaRef,
27
+ } from "../video/contract";
28
+
29
+ type Phase = "idle" | "uploading" | "ingesting" | "ready";
30
+
31
+ const PHASE_LABEL: Record<Phase, string> = {
32
+ idle: "",
33
+ uploading: "Uploading…",
34
+ ingesting: "Ingesting…",
35
+ ready: "",
36
+ };
37
+
38
+ export function AudioCropStation({ spec }: { spec: StationSpec }) {
39
+ const [source, setSource] = useState<MediaRef | null>(null);
40
+ const [fileName, setFileName] = useState<string | null>(null);
41
+ const [phase, setPhase] = useState<Phase>("idle");
42
+ const [error, setError] = useState<string | null>(null);
43
+
44
+ const busy = phase === "uploading" || phase === "ingesting";
45
+
46
+ // Clear the loaded source back to the empty state. AudioCropCore resets its queued
47
+ // clips + extract + tracker rows when the source reference changes (here → null).
48
+ function clearSource() {
49
+ setSource(null);
50
+ setFileName(null);
51
+ setError(null);
52
+ }
53
+
54
+ async function onPick(file: File) {
55
+ setError(null);
56
+ setFileName(file.name);
57
+ setSource(null);
58
+ setPhase("uploading");
59
+
60
+ // (a) multipart upload — field `file` + field `sid`. No Content-Type header:
61
+ // the browser sets the multipart boundary itself.
62
+ const fd = new FormData();
63
+ fd.append("file", file);
64
+ fd.append("sid", getSessionId());
65
+ const up = await request<unknown>(hugpyConfig.uploadUrl, {
66
+ method: "POST",
67
+ body: fd,
68
+ meta: { specKey: "audio-crop", operation: "upload" },
69
+ });
70
+ if (!up.ok) {
71
+ setError(describeAppError(errorOf(up)));
72
+ setPhase("idle");
73
+ return;
74
+ }
75
+ const upParsed = uploadResultSchema.safeParse(okValue(up));
76
+ if (!upParsed.success) {
77
+ setError("Malformed upload response.");
78
+ setPhase("idle");
79
+ return;
80
+ }
81
+
82
+ // (b) ingest the server path into a typed MediaRef.
83
+ setPhase("ingesting");
84
+ const ing = await request<unknown>(hugpyConfig.videoIngestUrl, {
85
+ method: "POST",
86
+ body: JSON.stringify({ path: upParsed.data.path }),
87
+ headers: { "Content-Type": "application/json" },
88
+ meta: { specKey: "audio-crop", operation: "ingest" },
89
+ });
90
+ if (!ing.ok) {
91
+ setError(describeAppError(errorOf(ing)));
92
+ setPhase("idle");
93
+ return;
94
+ }
95
+ const media = mediaRefSchema.safeParse(okValue(ing));
96
+ if (!media.success) {
97
+ setError("Malformed ingest response.");
98
+ setPhase("idle");
99
+ return;
100
+ }
101
+ if (media.data.kind !== "audio" && media.data.kind !== "video") {
102
+ setError(
103
+ `Ingested asset is "${media.data.kind}", not audio or video — use the Image Crop station for that.`,
104
+ );
105
+ setPhase("idle");
106
+ return;
107
+ }
108
+ setSource(media.data);
109
+ setPhase("ready");
110
+ // A video's audio track is pulled out by AudioCropCore once the source lands.
111
+ }
112
+
113
+ const isVideo = source?.kind === "video";
114
+
115
+ return (
116
+ <section className="station-card station-wide">
117
+ {/* Same shared section framing as Frames/Generate, but with NO Options
118
+ panel — the crop params live inline in the Components view below the
119
+ waveform, so no [Components|Options] toggle / rail renders. */}
120
+ <SectionTabs
121
+ ariaLabel="Audio crop station"
122
+ addFile={
123
+ <>
124
+ {/* ONE compact add control — a Choose button + filename, no dropbox.
125
+ Accepts audio OR video; onPick routes a video through the
126
+ audio_extract chain before cropping. */}
127
+ <AddFileBar
128
+ accept="audio/*,video/*"
129
+ onFile={onPick}
130
+ busy={busy}
131
+ buttonLabel={source ? "Choose another file" : "Choose audio or video"}
132
+ onClear={source && !busy ? clearSource : undefined}
133
+ >
134
+ {busy && <span className="vi-crop-status">{PHASE_LABEL[phase]}</span>}
135
+ {!busy && source && (
136
+ <>
137
+ {fileName && (
138
+ <span className="vi-addbar-name" title={fileName}>
139
+ {fileName}
140
+ </span>
141
+ )}
142
+ <span className="vi-crop-meta">
143
+ <code>{source.mime}</code>
144
+ {source.duration_s != null ? ` · ${source.duration_s}s` : ""}
145
+ {isVideo ? " · video (extracting audio)" : ""}
146
+ </span>
147
+ </>
148
+ )}
149
+ </AddFileBar>
150
+ {error && <p className="vi-error" role="alert">{error}</p>}
151
+ </>
152
+ }
153
+ components={<AudioCropCore source={source} multiRegion />}
154
+ />
155
+ </section>
156
+ );
157
+ }
@@ -0,0 +1,210 @@
1
+ // ComfyPanel — ComfyUI in the shared expand-into-the-screen drawer, reachable
2
+ // from EVERY station without spending a slot in the studio tab strip (operator
3
+ // ask 2026-08-04: "have comfy UI as an overlay the same as console is in the
4
+ // left column").
5
+ //
6
+ // Since 2026-08-06 this file contributes ONE TAB to the single unified drawer
7
+ // (drawerShell.tsx → WorkbenchDrawer.tsx) rather than rendering a drawer of its
8
+ // own — so "the same as console" is now literally the same window. (Until
9
+ // 2026-08-04 that shell was copied into this file verbatim; then it was shared;
10
+ // now there is one instance of it.) Only the endpoint bar, the message strips
11
+ // and the frame below are Comfy-specific.
12
+ //
13
+ // ── WHERE IT DELIBERATELY DIFFERS FROM ConsolePanel (read before editing) ────
14
+ // ConsolePanel frames FIRST-PARTY, SAME-ORIGIN, FIXED-href content ("/console"),
15
+ // and is deliberately UNSANDBOXED because a sandbox without `allow-modals` would
16
+ // silently break the console's confirm()/alert() flows. Its header comment
17
+ // explicitly contrasts itself with "ComfyStation's user-supplied URL".
18
+ //
19
+ // This panel is that contrasting case. The ComfyUI endpoint is USER-EDITABLE and
20
+ // PERSISTED, i.e. attacker-influenceable and foreign-origin. So:
21
+ // * every value passes sanitizeEmbedUrl() (http/https only) before it can ever
22
+ // reach iframe src — typed input AND the value rehydrated from storage;
23
+ // * the frame KEEPS its sandbox (COMFY_SANDBOX).
24
+ // Do NOT "simplify" this by copying ConsolePanel's unsandboxed iframe: that
25
+ // would hand a user-supplied foreign origin full same-origin-ish reign over this
26
+ // page. The shared gate lives in comfyEmbed.ts precisely so the two cannot drift.
27
+ // Sharing ONE drawer window with the console tab does not blur this: the two are
28
+ // distinct <iframe> elements rendered side by side (one hidden), each with its
29
+ // own sandbox attribute — never a single frame whose src is swapped.
30
+ //
31
+ // ── WHY THE FRAME MAY STILL BE BLANK (not this component's bug) ─────────────
32
+ // The video UI is HTTPS, so an http:// or loopback endpoint is blocked as mixed
33
+ // content with NO error, and any endpoint must also permit framing from this
34
+ // origin (`frame-ancestors`, no `X-Frame-Options: DENY`). Both cases are warned
35
+ // about inline, and "Open in new tab" always works regardless.
36
+ import { useState, type FormEvent } from "react";
37
+ import { hugpyConfig } from "../config";
38
+ import type { DrawerTab } from "./drawerShell";
39
+ import {
40
+ sanitizeEmbedUrl,
41
+ embedRisk,
42
+ COMFY_SANDBOX,
43
+ COMFY_URL_KEY,
44
+ } from "./comfyEmbed";
45
+
46
+ // The endpoint URL is session-persisted under the SAME key the station used, so
47
+ // an operator who already configured ComfyUI keeps their endpoint.
48
+ function readPersistedUrl(): string {
49
+ try {
50
+ const stored = sessionStorage.getItem(COMFY_URL_KEY);
51
+ if (stored != null) return stored;
52
+ } catch {
53
+ /* storage unavailable — fall back to config default */
54
+ }
55
+ return hugpyConfig.comfyUrl;
56
+ }
57
+
58
+ function persistUrl(value: string): void {
59
+ try {
60
+ sessionStorage.setItem(COMFY_URL_KEY, value);
61
+ } catch {
62
+ /* best-effort — the in-memory state still drives this session */
63
+ }
64
+ }
65
+
66
+ /**
67
+ * ComfyUI's tab in the unified drawer.
68
+ *
69
+ * A HOOK, not a plain descriptor, because this tab owns state (the endpoint
70
+ * field and the committed value). Hoisting that state to the always-mounted
71
+ * drawer host — rather than to the frame's own body — is what lets a half-typed
72
+ * endpoint survive a switch to the Console tab and back.
73
+ */
74
+ export function useComfyDrawerTab(): DrawerTab<"comfy"> {
75
+ const [url, setUrl] = useState<string>(() => readPersistedUrl());
76
+ const [loaded, setLoaded] = useState<string>(() => readPersistedUrl());
77
+
78
+ // Diagnose the CURRENT text field (immediate feedback while typing).
79
+ const inputSanitized = sanitizeEmbedUrl(url);
80
+ const schemeRejected = url.trim() !== "" && inputSanitized == null;
81
+ // The iframe only ever sees a sanitized value; null → the empty state.
82
+ const embedSrc = sanitizeEmbedUrl(loaded);
83
+ const risk = embedRisk(loaded);
84
+ const openTarget = embedSrc ?? inputSanitized;
85
+
86
+ function onChangeUrl(next: string) {
87
+ setUrl(next);
88
+ persistUrl(next);
89
+ }
90
+
91
+ function onSubmit(e: FormEvent) {
92
+ e.preventDefault();
93
+ setLoaded(url); // commit; embedSrc re-derives through sanitizeEmbedUrl()
94
+ }
95
+
96
+ return {
97
+ id: "comfy",
98
+ label: "ComfyUI",
99
+ hint: "Open ComfyUI alongside this station — no tab switch",
100
+ toggleClassName: "vi-comfy-toggle",
101
+ toggleLabel: "▤ ComfyUI",
102
+ ariaLabel: "ComfyUI",
103
+ panelClassName: "vi-comfy-panel",
104
+ // Stays in the DOM while another tab shows. This one matters most: a
105
+ // ComfyUI workspace is a long-lived, unsaved graph — reloading the frame
106
+ // every time the operator glanced at Active would lose work.
107
+ keepMounted: true,
108
+ headerExtra: (
109
+ /* The endpoint is configurable here because it always was — the station
110
+ owned this field, and dropping it would strand anyone whose ComfyUI is
111
+ not at the configured default. It rides in the shared header, so it only
112
+ appears while THIS tab is selected. */
113
+ <form className="vi-comfy-panel-url" onSubmit={onSubmit}>
114
+ <label className="vi-comfy-panel-url-label" htmlFor="vi-comfy-panel-url">
115
+ Endpoint
116
+ </label>
117
+ <input
118
+ id="vi-comfy-panel-url"
119
+ className="vi-knob-input vi-comfy-input"
120
+ type="url"
121
+ inputMode="url"
122
+ spellCheck={false}
123
+ autoComplete="off"
124
+ placeholder="https://comfy.hugpy.ai"
125
+ value={url}
126
+ onChange={(e) => onChangeUrl(e.target.value)}
127
+ aria-invalid={schemeRejected || undefined}
128
+ />
129
+ <button
130
+ type="submit"
131
+ className="vi-btn vi-btn-sm vi-btn-accent"
132
+ disabled={inputSanitized == null}
133
+ >
134
+ Load
135
+ </button>
136
+ </form>
137
+ ),
138
+ headerActions: openTarget ? (
139
+ <a
140
+ className="vi-btn vi-btn-sm vi-btn-ghost"
141
+ href={openTarget}
142
+ target="_blank"
143
+ rel="noreferrer"
144
+ title="Open ComfyUI in its own tab"
145
+ >
146
+ Open in new tab ↗
147
+ </a>
148
+ ) : (
149
+ <span
150
+ className="vi-btn vi-btn-sm vi-btn-ghost vi-comfy-open-disabled"
151
+ aria-disabled="true"
152
+ >
153
+ Open in new tab ↗
154
+ </span>
155
+ ),
156
+ body: (
157
+ <>
158
+ {/* Scheme rejected: loud, since this is the security gate — the value
159
+ never reaches the frame. */}
160
+ {schemeRejected && (
161
+ <p className="vi-error vi-comfy-panel-msg" role="alert">
162
+ That URL isn’t an http:// or https:// address, so it won’t be
163
+ embedded. Enter a full HTTPS URL (e.g.{" "}
164
+ <code>https://comfy.hugpy.ai</code>).
165
+ </p>
166
+ )}
167
+
168
+ {risk && (
169
+ <p className="vi-comfy-warn vi-comfy-panel-msg" role="alert">
170
+ {risk === "http" ? (
171
+ <>
172
+ This is an <code>http://</code> endpoint. This page is HTTPS, so
173
+ the browser blocks the frame as mixed content — it stays blank
174
+ with no error. Use an HTTPS endpoint such as{" "}
175
+ <code>https://comfy.hugpy.ai</code>, or open it in a new tab.
176
+ </>
177
+ ) : (
178
+ <>
179
+ This points at a <code>localhost</code> / loopback host, which the
180
+ browser can’t reach from an HTTPS page. Expose ComfyUI through an
181
+ HTTPS proxy (e.g. <code>https://comfy.hugpy.ai</code>) instead.
182
+ </>
183
+ )}
184
+ </p>
185
+ )}
186
+
187
+ {embedSrc ? (
188
+ <iframe
189
+ className="vi-console-iframe"
190
+ src={embedSrc}
191
+ title="ComfyUI"
192
+ // SANDBOXED on purpose — user-supplied, foreign origin. See the
193
+ // header note; do not drop this to match ConsolePanel.
194
+ sandbox={COMFY_SANDBOX}
195
+ allow="clipboard-read; clipboard-write; fullscreen"
196
+ />
197
+ ) : (
198
+ <div className="vi-comfy-empty vi-comfy-panel-empty">
199
+ <p className="vi-comfy-empty-title">No endpoint loaded</p>
200
+ <p className="vi-comfy-empty-sub">
201
+ Enter your ComfyUI endpoint above and press <strong>Load</strong> to
202
+ embed it. It must be an HTTPS URL (e.g.{" "}
203
+ <code>https://comfy.hugpy.ai</code>).
204
+ </p>
205
+ </div>
206
+ )}
207
+ </>
208
+ ),
209
+ };
210
+ }
@@ -0,0 +1,196 @@
1
+ // ComfyUI viewer station — a modular "viewer" station that EMBEDS an external
2
+ // ComfyUI endpoint in an <iframe>, with a user-editable, session-persisted URL.
3
+ // Sibling to the studio stations (Generate / Frames / crops), registered the
4
+ // same way (registry.ts, group:"studio").
5
+ //
6
+ // ── MIXED-CONTENT CONSTRAINT (read before touching the iframe src) ──────────
7
+ // The video UI is served over HTTPS (dev.hugpy.ai/video, llm.hugpy.ai/app/video).
8
+ // A page loaded over https:// CANNOT embed an http:// (or http://127.0.0.1:8188)
9
+ // resource — the browser silently blocks the mixed-content subframe and the user
10
+ // sees a blank frame with NO error. So the endpoint MUST be reached over HTTPS
11
+ // (e.g. https://comfy.hugpy.ai). This component detects http:// / localhost /
12
+ // 127.0.0.1 targets and warns that an HTTPS page will refuse to embed them.
13
+ //
14
+ // ── FRAME-ANCESTORS (proxy-side) ────────────────────────────────────────────
15
+ // Even over HTTPS, the endpoint must permit framing FROM the video-UI origin:
16
+ // the comfy.hugpy.ai proxy has to send `Content-Security-Policy: frame-ancestors`
17
+ // listing the video-UI origin (and must NOT send `X-Frame-Options: DENY`).
18
+ // ComfyUI itself has no auth — keep TLS + any auth at the proxy, not in this arm.
19
+ //
20
+ // ── SECURITY (scheme allowlist) ─────────────────────────────────────────────
21
+ // The URL is user-editable AND persisted, so a hostile/typo value could carry a
22
+ // `javascript:` / `data:` / `blob:` / `file:` scheme. We NEVER hand an unsanitized
23
+ // value to iframe src: sanitizeEmbedUrl() parses with `new URL()` and returns the
24
+ // href ONLY for http:/https:, else null — enforced on typed input AND on the
25
+ // value rehydrated from sessionStorage.
26
+ //
27
+ // That gate (and the sandbox token list, and the storage key) now live in
28
+ // ./comfyEmbed so this station and the ComfyPanel overlay share EXACTLY one
29
+ // implementation. A second, subtly-weaker copy is how a `javascript:` URL
30
+ // eventually reaches an iframe — do not re-inline them here.
31
+ //
32
+ // ── THIS STATION IS NO LONGER IN THE TAB STRIP (2026-08-04) ────────────────
33
+ // ComfyUI is reached from the sidebar overlay (ComfyPanel) now. The station is
34
+ // kept, `navHidden`, so an existing /comfy bookmark still resolves instead of
35
+ // bouncing to the default tab.
36
+ import { useState, type FormEvent } from "react";
37
+ import type { StationSpec } from "./types";
38
+ import { hugpyConfig } from "../config";
39
+ import {
40
+ sanitizeEmbedUrl,
41
+ embedRisk,
42
+ COMFY_SANDBOX,
43
+ COMFY_URL_KEY as URL_KEY,
44
+ } from "./comfyEmbed";
45
+
46
+ export { sanitizeEmbedUrl } from "./comfyEmbed";
47
+
48
+ // Read the persisted URL, falling back to the configured default (which itself
49
+ // defaults to "" — no secret is baked in). Best-effort: storage may be unavailable.
50
+ function readPersistedUrl(): string {
51
+ try {
52
+ const stored = sessionStorage.getItem(URL_KEY);
53
+ if (stored != null) return stored;
54
+ } catch {
55
+ /* storage unavailable — fall back to config default */
56
+ }
57
+ return hugpyConfig.comfyUrl;
58
+ }
59
+
60
+ function persistUrl(value: string): void {
61
+ try {
62
+ sessionStorage.setItem(URL_KEY, value);
63
+ } catch {
64
+ /* best-effort — the in-memory state still drives this session */
65
+ }
66
+ }
67
+
68
+ export function ComfyStation({ spec }: { spec: StationSpec }) {
69
+ // `url` is the editable text field (persisted every keystroke). `loaded` is the
70
+ // value actually committed to the iframe — updated on submit and initialized to
71
+ // the persisted/default URL so a valid saved endpoint re-embeds on reload.
72
+ const [url, setUrl] = useState<string>(() => readPersistedUrl());
73
+ const [loaded, setLoaded] = useState<string>(() => readPersistedUrl());
74
+
75
+ // Diagnose the CURRENT text field (immediate feedback while typing).
76
+ const inputSanitized = sanitizeEmbedUrl(url);
77
+ const schemeRejected = url.trim() !== "" && inputSanitized == null;
78
+
79
+ // The iframe only ever sees a sanitized value; null → render the empty state.
80
+ const embedSrc = sanitizeEmbedUrl(loaded);
81
+ const risk = embedRisk(loaded);
82
+ // Best target for the always-available "Open in new tab" affordance: the loaded
83
+ // endpoint if valid, else whatever the user has currently typed (if valid).
84
+ const openTarget = embedSrc ?? inputSanitized;
85
+ const pageIsHttps =
86
+ typeof window !== "undefined" && window.location.protocol === "https:";
87
+
88
+ function onChange(next: string) {
89
+ setUrl(next);
90
+ persistUrl(next);
91
+ }
92
+
93
+ function onSubmit(e: FormEvent) {
94
+ e.preventDefault();
95
+ setLoaded(url); // commit; embedSrc re-derives through sanitizeEmbedUrl()
96
+ }
97
+
98
+ return (
99
+ <section className="station-wide vi-comfy" aria-label="ComfyUI viewer">
100
+ <form className="vi-comfy-bar" onSubmit={onSubmit}>
101
+ <label className="vi-comfy-field">
102
+ <span className="vi-comfy-label">ComfyUI endpoint URL</span>
103
+ <input
104
+ className="vi-knob-input vi-comfy-input"
105
+ type="url"
106
+ inputMode="url"
107
+ spellCheck={false}
108
+ autoComplete="off"
109
+ placeholder="https://comfy.hugpy.ai"
110
+ value={url}
111
+ onChange={(e) => onChange(e.target.value)}
112
+ aria-invalid={schemeRejected || undefined}
113
+ />
114
+ </label>
115
+ <div className="vi-comfy-bar-actions">
116
+ <button type="submit" className="vi-btn vi-btn-accent" disabled={inputSanitized == null}>
117
+ Load
118
+ </button>
119
+ {/* Always available: lets the user reach ComfyUI directly even when the
120
+ frame is blocked (mixed content) or refuses embedding (X-Frame-Options). */}
121
+ {openTarget ? (
122
+ <a className="vi-btn vi-btn-ghost" href={openTarget} target="_blank" rel="noreferrer">
123
+ Open in new tab ↗
124
+ </a>
125
+ ) : (
126
+ <span className="vi-btn vi-btn-ghost vi-comfy-open-disabled" aria-disabled="true">
127
+ Open in new tab ↗
128
+ </span>
129
+ )}
130
+ </div>
131
+ </form>
132
+
133
+ {/* Scheme rejected (javascript:/data:/blob:/file:/relative/…): explicit and
134
+ loud, since this is the security gate — the value never reaches the frame. */}
135
+ {schemeRejected && (
136
+ <p className="vi-error" role="alert">
137
+ That URL isn’t an http:// or https:// address, so it won’t be embedded. Enter
138
+ a full HTTPS URL (e.g. <code>https://comfy.hugpy.ai</code>).
139
+ </p>
140
+ )}
141
+
142
+ {/* Mixed-content / loopback warning: an HTTPS page can’t frame these. */}
143
+ {risk && (
144
+ <p className="vi-comfy-warn" role="alert">
145
+ {risk === "http" ? (
146
+ <>
147
+ This is an <code>http://</code> endpoint.{" "}
148
+ {pageIsHttps
149
+ ? "This page is HTTPS, so the browser blocks the frame as mixed content — it stays blank with no error."
150
+ : "On the deployed HTTPS site (dev.hugpy.ai/video, llm.hugpy.ai/app/video) the browser will block this frame as mixed content."}{" "}
151
+ Use an HTTPS endpoint such as <code>https://comfy.hugpy.ai</code>, or open it
152
+ in a new tab.
153
+ </>
154
+ ) : (
155
+ <>
156
+ This points at a <code>localhost</code> / loopback host, which the browser
157
+ can’t reach from an HTTPS page and won’t serve over TLS. Expose ComfyUI
158
+ through an HTTPS proxy (e.g. <code>https://comfy.hugpy.ai</code>) instead.
159
+ </>
160
+ )}
161
+ </p>
162
+ )}
163
+
164
+ {embedSrc ? (
165
+ <div className="vi-comfy-frame">
166
+ <iframe
167
+ className="vi-comfy-iframe"
168
+ src={embedSrc}
169
+ title="ComfyUI"
170
+ // Permissive on purpose: ComfyUI is a trusted internal tool that needs
171
+ // scripts, same-origin, forms, downloads and popups to function. Shared
172
+ // with ComfyPanel so the two frames can never diverge on sandboxing.
173
+ sandbox={COMFY_SANDBOX}
174
+ allow="clipboard-read; clipboard-write; fullscreen"
175
+ />
176
+ </div>
177
+ ) : (
178
+ <div className="vi-comfy-empty">
179
+ <p className="vi-comfy-empty-title">No endpoint loaded</p>
180
+ <p className="vi-comfy-empty-sub">
181
+ Enter your ComfyUI endpoint above and press <strong>Load</strong> to embed it.
182
+ It must be an HTTPS URL (e.g. <code>https://comfy.hugpy.ai</code>).
183
+ </p>
184
+ </div>
185
+ )}
186
+
187
+ {embedSrc && (
188
+ <p className="vi-comfy-hint">
189
+ If the frame stays blank, the endpoint is likely refusing to be embedded
190
+ (<code>X-Frame-Options</code> / missing <code>frame-ancestors</code>). Open it in
191
+ a new tab instead — the “Open in new tab” link above works regardless.
192
+ </p>
193
+ )}
194
+ </section>
195
+ );
196
+ }