@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,245 @@
1
+ // CONDENSED KNOB STRIP + CARRY TICKS — extracted from GenerateStation.tsx (task
2
+ // k88) so the Scene/Clip prompt-card migration (k89) can consume the same strip
3
+ // without importing the whole station. Everything below moved VERBATIM (values,
4
+ // flags, ticks, layout); GenerateStation imports it back unchanged.
5
+ import type { ReactNode } from "react";
6
+ import { KnobInfo } from "./KnobInfo";
7
+ import type { CarrySettingKey, CarryTicks } from "../video/contract";
8
+
9
+ // Parse a text field into a number, or null when blank/invalid (mirrors the
10
+ // Frames station's helper — no silent coercion of garbage to 0). Exported so
11
+ // GenerateStation (its original home) keeps its single source.
12
+ export function toNumberOrNull(raw: string): number | null {
13
+ const s = raw.trim();
14
+ if (s === "") return null;
15
+ const n = Number(s);
16
+ return Number.isFinite(n) ? n : null;
17
+ }
18
+
19
+ /** The five numeric knobs the condensed strip edits, as the live text they are. */
20
+ export interface KnobValues {
21
+ width: string;
22
+ height: string;
23
+ steps: string;
24
+ guidance: string;
25
+ seed: string;
26
+ }
27
+
28
+ /**
29
+ * The strip's validation flags, derived from the raw text — the SAME rules the
30
+ * station's Run gate uses (widthValid/heightValid/stepsValid/guidanceValid/
31
+ * seedValid + the multiple-of-8 warning), restated here so the component is
32
+ * self-contained and every host (base composer, every tester row) flags identically.
33
+ * Returned as a list so the strip itself stays ONE line and the flags stack under it.
34
+ */
35
+ function knobFlags(v: KnobValues): string[] {
36
+ const out: string[] = [];
37
+ const w = toNumberOrNull(v.width);
38
+ const h = toNumberOrNull(v.height);
39
+ const st = toNumberOrNull(v.steps);
40
+ const g = toNumberOrNull(v.guidance);
41
+ const sd = toNumberOrNull(v.seed);
42
+ const wOk = w != null && Number.isInteger(w) && w > 0;
43
+ const hOk = h != null && Number.isInteger(h) && h > 0;
44
+ if (!wOk) out.push("width: enter a positive integer.");
45
+ else if (w % 8 !== 0) out.push("width: not a multiple of 8 — the model may round it.");
46
+ if (!hOk) out.push("height: enter a positive integer.");
47
+ else if (h % 8 !== 0) out.push("height: not a multiple of 8 — the model may round it.");
48
+ if (!(st != null && Number.isInteger(st) && st > 0))
49
+ out.push("steps: enter a positive integer.");
50
+ if (!(g != null && g >= 0)) out.push("guidance: enter a number ≥ 0.");
51
+ if (!(v.seed.trim() === "" || (sd != null && Number.isInteger(sd) && sd >= 0)))
52
+ out.push("seed: enter a non-negative integer or leave blank.");
53
+ return out;
54
+ }
55
+
56
+ /**
57
+ * ONE carry tick — the small checkbox that rides in front of a setting's label
58
+ * (operator ask 2026-08-04, k63). Checked = this setting is carried into the
59
+ * component created from this one; unchecked = that component gets the mode
60
+ * default instead. Deliberately label-less and title-only: the strip is condensed,
61
+ * and a word of copy per tick would undo the condensing.
62
+ */
63
+ export function CarryTick({
64
+ id,
65
+ setting,
66
+ checked,
67
+ onChange,
68
+ }: {
69
+ id: string;
70
+ setting: string;
71
+ checked: boolean;
72
+ onChange: (v: boolean) => void;
73
+ }) {
74
+ return (
75
+ <input
76
+ id={id}
77
+ type="checkbox"
78
+ className="vi-carry-tick"
79
+ checked={checked}
80
+ onChange={(e) => onChange(e.target.checked)}
81
+ aria-label={`Carry ${setting} into the next component`}
82
+ title={`Carry ${setting} into the component created from this one. Unticked = that component gets the default instead.`}
83
+ />
84
+ );
85
+ }
86
+
87
+ /**
88
+ * CONDENSED KNOB STRIP (operator ask 2026-08-04, k63) — the generation knobs as
89
+ * ONE inline line rather than a column of full-width blocks:
90
+ *
91
+ * [model ▾] ☑ size [512] × [512] ☑ steps [4] ☑ guidance [0] ☑ seed [random]
92
+ *
93
+ * WHY a component and not just JSX: the tester rows must look and behave EXACTLY
94
+ * like the base composer's settings, because the whole point of a row is "the same
95
+ * generation with one thing changed". Two hand-written copies of the same seven
96
+ * inputs is how they drift. Both hosts render this; there is one visual language.
97
+ *
98
+ * Deliberately presentational: it owns no state. Values come in, `onPatch` goes
99
+ * out (the base host maps it onto the station setters, a tester row onto
100
+ * patchTestRow), and the carry ticks are the host's state too. `lead`/`trail` are
101
+ * the host-specific extremities — the model select cluster (which differs: the base
102
+ * carries a refresh button + capability line, a row does not) and the row's inline
103
+ * negative field.
104
+ */
105
+ export function CondensedKnobStrip({
106
+ idPrefix,
107
+ values,
108
+ onPatch,
109
+ carry,
110
+ onCarry,
111
+ lead,
112
+ trail,
113
+ footer,
114
+ }: {
115
+ /** DOM id namespace — must be unique per instance (ids collide across rows otherwise). */
116
+ idPrefix: string;
117
+ values: KnobValues;
118
+ onPatch: (patch: Partial<KnobValues>) => void;
119
+ /** Omit to render the strip without carry ticks (nothing is seeded from it). */
120
+ carry?: CarryTicks;
121
+ onCarry?: (key: CarrySettingKey, value: boolean) => void;
122
+ lead?: ReactNode;
123
+ trail?: ReactNode;
124
+ footer?: ReactNode;
125
+ }) {
126
+ const flags = knobFlags(values);
127
+ const tick = (key: CarrySettingKey, setting: string) =>
128
+ carry ? (
129
+ <CarryTick
130
+ id={`${idPrefix}-carry-${key}`}
131
+ setting={setting}
132
+ checked={carry[key]}
133
+ onChange={(v) => onCarry?.(key, v)}
134
+ />
135
+ ) : null;
136
+
137
+ return (
138
+ <div className="vi-knob-strip">
139
+ <div className="vi-knob-strip-line">
140
+ {lead}
141
+
142
+ {/* width × height read as ONE setting ("size"), so they pair behind one
143
+ tick and one ⓘ — a literal × between two mini inputs. */}
144
+ <span className="vi-knob-mini-group">
145
+ {tick("size", "size")}
146
+ <label className="vi-knob-mini-label" htmlFor={`${idPrefix}-width`}>
147
+ size
148
+ </label>
149
+ <input
150
+ id={`${idPrefix}-width`}
151
+ type="text"
152
+ inputMode="numeric"
153
+ className="vi-knob-input vi-knob-mini"
154
+ aria-label="width"
155
+ value={values.width}
156
+ onChange={(e) => onPatch({ width: e.target.value })}
157
+ />
158
+ <span className="vi-knob-mini-x" aria-hidden>
159
+ ×
160
+ </span>
161
+ <input
162
+ id={`${idPrefix}-height`}
163
+ type="text"
164
+ inputMode="numeric"
165
+ className="vi-knob-input vi-knob-mini"
166
+ aria-label="height"
167
+ value={values.height}
168
+ onChange={(e) => onPatch({ height: e.target.value })}
169
+ />
170
+ <KnobInfo
171
+ label="size"
172
+ text="Output width × height in pixels. Multiples of 8 avoid the model silently rounding them."
173
+ />
174
+ </span>
175
+
176
+ <span className="vi-knob-mini-group">
177
+ {tick("steps", "steps")}
178
+ <label className="vi-knob-mini-label" htmlFor={`${idPrefix}-steps`}>
179
+ steps
180
+ </label>
181
+ <input
182
+ id={`${idPrefix}-steps`}
183
+ type="text"
184
+ inputMode="numeric"
185
+ className="vi-knob-input vi-knob-mini"
186
+ value={values.steps}
187
+ onChange={(e) => onPatch({ steps: e.target.value })}
188
+ />
189
+ <KnobInfo label="steps" text="Denoising steps. sd-turbo runs well at ~4." />
190
+ </span>
191
+
192
+ <span className="vi-knob-mini-group">
193
+ {tick("guidance", "guidance")}
194
+ <label className="vi-knob-mini-label" htmlFor={`${idPrefix}-guidance`}>
195
+ guidance
196
+ </label>
197
+ <input
198
+ id={`${idPrefix}-guidance`}
199
+ type="text"
200
+ inputMode="decimal"
201
+ className="vi-knob-input vi-knob-mini"
202
+ value={values.guidance}
203
+ onChange={(e) => onPatch({ guidance: e.target.value })}
204
+ />
205
+ <KnobInfo
206
+ label="guidance"
207
+ text="CFG scale. sd-turbo uses 0 (guidance-free)."
208
+ />
209
+ </span>
210
+
211
+ <span className="vi-knob-mini-group">
212
+ {tick("seed", "seed")}
213
+ <label className="vi-knob-mini-label" htmlFor={`${idPrefix}-seed`}>
214
+ seed
215
+ </label>
216
+ <input
217
+ id={`${idPrefix}-seed`}
218
+ type="text"
219
+ inputMode="numeric"
220
+ className="vi-knob-input vi-knob-mini vi-knob-mini-seed"
221
+ placeholder="random"
222
+ value={values.seed}
223
+ onChange={(e) => onPatch({ seed: e.target.value })}
224
+ />
225
+ <KnobInfo label="seed" text="Optional. Blank = random each run." />
226
+ </span>
227
+
228
+ {trail}
229
+ </div>
230
+
231
+ {/* Flags stack BELOW the line so a warning never breaks the strip's single
232
+ row. Same rules, same amber .vi-knob-flag as the rail knobs had. */}
233
+ {flags.length > 0 && (
234
+ <div className="vi-knob-strip-flags">
235
+ {flags.map((f) => (
236
+ <span className="vi-knob-flag" key={f}>
237
+ {f}
238
+ </span>
239
+ ))}
240
+ </div>
241
+ )}
242
+ {footer}
243
+ </div>
244
+ );
245
+ }
@@ -0,0 +1,82 @@
1
+ // ConsolePanel — the hugpy console (dev/ui's `/console`) embedded in the shared
2
+ // expand-into-the-screen drawer, so the operator never has to keep a second
3
+ // browser tab open just to watch it. Reachable from every station (the drawer is
4
+ // mounted once by WorkbenchSidebar, the shared sidebar), without duplicating the
5
+ // affordance per-station.
6
+ //
7
+ // Since 2026-08-06 this file no longer renders a drawer of its own: it
8
+ // contributes ONE TAB to the single unified drawer (drawerShell.tsx →
9
+ // WorkbenchDrawer.tsx), alongside ComfyUI and Active. The shell owns the window
10
+ // (portal-to-<body>, half/full sizing, Esc-to-close, persisted open/size/tab);
11
+ // this file owns only the console-specific part, i.e. the iframe below and its
12
+ // security posture.
13
+ //
14
+ // ── SAME-ORIGIN, NOT SANDBOXED (read before touching the iframe) ────────────
15
+ // The console SPA (dev/ui App.jsx) is a SIBLING route of THIS one, served from
16
+ // the SAME origin on dev.hugpy.ai (and on a local `hugpy serve`, per the
17
+ // product's self-hosted-first posture) — never a foreign site. Reusing
18
+ // nav/Navbar's own `siteBase`/`onHugpyOrigin` rule (the SAME computation the
19
+ // Navbar's plain `<a href="/console">` link already uses) means the href is
20
+ // "/console" (origin-relative → guaranteed same-origin) on every real hugpy
21
+ // deployment; it only falls through to the full `siteUrl` off a hugpy hostname
22
+ // (e.g. a bare local Vite preview), exactly like the Navbar link already does.
23
+ // Same-origin means the browser sends the operator's existing console session
24
+ // cookie — logged in outside, logged in inside the panel; logged out outside,
25
+ // the panel shows the console's own front door/login, same as opening the tab
26
+ // would. No new auth surface, nothing to weaken.
27
+ //
28
+ // Deliberately UNSANDBOXED: the console (ApiAccess "Revoke key", ModelTable
29
+ // "Delete"/"Prune", …) relies on window.confirm()/alert() for destructive
30
+ // actions and navigator.clipboard for copy buttons. A sandboxed iframe without
31
+ // `allow-modals` silently swallows confirm()/alert() (the dialog never shows;
32
+ // confirm() just returns false) — Delete/Revoke would look broken. Since this
33
+ // is first-party same-origin content (not a foreign/untrusted embed like
34
+ // ComfyStation's user-supplied URL), there is no isolation benefit to
35
+ // sandboxing it, only breakage — so `sandbox` is omitted entirely. Sharing a
36
+ // window with the SANDBOXED ComfyUI tab changes nothing here: they are separate
37
+ // <iframe> elements with independent sandbox attributes, never one reused frame.
38
+ //
39
+ // ── FRAME-ANCESTORS (pre-checked, see task notes) ────────────────────────────
40
+ // Neither the Flask app (abstract_hugpy_dev/flask_app) nor the dev.hugpy.ai
41
+ // nginx vhost sends X-Frame-Options or a frame-ancestors CSP, so the browser
42
+ // does not block this frame. Re-verify after any nginx/Flask header change.
43
+ import { siteBase } from "../nav/Navbar";
44
+ import type { DrawerTab } from "./drawerShell";
45
+
46
+ // Same origin-relative rule the Navbar's own Console link uses.
47
+ const consoleHref = `${siteBase}/console`;
48
+
49
+ /** The console's tab in the unified drawer. */
50
+ export function consoleDrawerTab(): DrawerTab<"console"> {
51
+ return {
52
+ id: "console",
53
+ label: "Console",
54
+ hint: "Open the hugpy console alongside this station — no tab switch",
55
+ toggleClassName: "vi-console-toggle",
56
+ toggleLabel: "▤ Console",
57
+ ariaLabel: "hugpy console",
58
+ // Stays in the DOM while another tab shows: an iframe that is merely
59
+ // `display:none` keeps its document, its session and its scroll position, so
60
+ // switching to Active and back does not throw the operator out to a reload.
61
+ keepMounted: true,
62
+ headerActions: (
63
+ <a
64
+ className="vi-btn vi-btn-sm vi-btn-ghost"
65
+ href={consoleHref}
66
+ target="_blank"
67
+ rel="noreferrer"
68
+ title="Open the console in its own tab"
69
+ >
70
+ Open in new tab ↗
71
+ </a>
72
+ ),
73
+ body: (
74
+ <iframe
75
+ className="vi-console-iframe"
76
+ src={consoleHref}
77
+ title="hugpy console"
78
+ allow="clipboard-read; clipboard-write; fullscreen"
79
+ />
80
+ ),
81
+ };
82
+ }
@@ -0,0 +1,148 @@
1
+ // Frames & Models station (Phase 4) — the sample image's left rail + frame grid.
2
+ // Flow:
3
+ // video file → POST /uploads (multipart) → {path}
4
+ // → POST /video/ingest {path} → MediaRef (guard kind==="video")
5
+ // → FrameExtractCore: knob rail + frame_extract job + frame grid + pick
6
+ // Every hop goes through request<T>() + config URLs; errors surface via
7
+ // describeAppError.
8
+ //
9
+ // Studio slice 2a: the Options knob rail + useFrameJobs + frame grid + frame-pick
10
+ // were extracted VERBATIM into stations/ops/FrameExtractCore. Because the Frames
11
+ // station has an Options panel (whose knob state is shared with the working view),
12
+ // the Core owns the whole SectionTabs and takes this station's add-file bar node +
13
+ // `busy` flag as props. This station now owns only the upload/ingest lifecycle.
14
+ // Behaviour is unchanged.
15
+ import { useState } from "react";
16
+ import type { StationSpec } from "./types";
17
+ import { AddFileBar } from "./AddFileBar";
18
+ import { FrameExtractCore } from "./ops/FrameExtractCore";
19
+ import { request, okValue, errorOf, describeAppError } from "../transport/client";
20
+ import { hugpyConfig } from "../config";
21
+ import { getSessionId } from "../session";
22
+ import {
23
+ mediaRefSchema,
24
+ uploadResultSchema,
25
+ type MediaRef,
26
+ } from "../video/contract";
27
+
28
+ type Phase = "idle" | "uploading" | "ingesting" | "ready";
29
+
30
+ const PHASE_LABEL: Record<Phase, string> = {
31
+ idle: "",
32
+ uploading: "Uploading…",
33
+ ingesting: "Ingesting…",
34
+ ready: "",
35
+ };
36
+
37
+ export function FrameExtractStation({ spec }: { spec: StationSpec }) {
38
+ const [source, setSource] = useState<MediaRef | null>(null);
39
+ const [fileName, setFileName] = useState<string | null>(null);
40
+ const [phase, setPhase] = useState<Phase>("idle");
41
+ const [error, setError] = useState<string | null>(null);
42
+
43
+ const busy = phase === "uploading" || phase === "ingesting";
44
+
45
+ async function onPick(file: File) {
46
+ setError(null);
47
+ setFileName(file.name);
48
+ setSource(null);
49
+ setPhase("uploading");
50
+
51
+ // (a) multipart upload — field `file` + field `sid`. No Content-Type header:
52
+ // the browser sets the multipart boundary itself.
53
+ const fd = new FormData();
54
+ fd.append("file", file);
55
+ fd.append("sid", getSessionId());
56
+ const up = await request<unknown>(hugpyConfig.uploadUrl, {
57
+ method: "POST",
58
+ body: fd,
59
+ meta: { specKey: "frames", operation: "upload" },
60
+ });
61
+ if (!up.ok) {
62
+ setError(describeAppError(errorOf(up)));
63
+ setPhase("idle");
64
+ return;
65
+ }
66
+ const upParsed = uploadResultSchema.safeParse(okValue(up));
67
+ if (!upParsed.success) {
68
+ setError("Malformed upload response.");
69
+ setPhase("idle");
70
+ return;
71
+ }
72
+
73
+ // (b) ingest the server path into a typed MediaRef.
74
+ setPhase("ingesting");
75
+ const ing = await request<unknown>(hugpyConfig.videoIngestUrl, {
76
+ method: "POST",
77
+ body: JSON.stringify({ path: upParsed.data.path }),
78
+ headers: { "Content-Type": "application/json" },
79
+ meta: { specKey: "frames", operation: "ingest" },
80
+ });
81
+ if (!ing.ok) {
82
+ setError(describeAppError(errorOf(ing)));
83
+ setPhase("idle");
84
+ return;
85
+ }
86
+ const media = mediaRefSchema.safeParse(okValue(ing));
87
+ if (!media.success) {
88
+ setError("Malformed ingest response.");
89
+ setPhase("idle");
90
+ return;
91
+ }
92
+ if (media.data.kind !== "video") {
93
+ setError(
94
+ `Ingested asset is "${media.data.kind}", not a video — use the Image Crop or Audio station for that.`,
95
+ );
96
+ setPhase("idle");
97
+ return;
98
+ }
99
+ setSource(media.data);
100
+ setPhase("ready");
101
+ }
102
+
103
+ // Clear the loaded source back to the empty state. FrameExtractCore resets the job
104
+ // + selection when the source reference changes (here → null).
105
+ function clearSource() {
106
+ setSource(null);
107
+ setFileName(null);
108
+ setError(null);
109
+ }
110
+
111
+ return (
112
+ <section className="station-card station-wide">
113
+ <FrameExtractCore
114
+ source={source}
115
+ busy={busy}
116
+ addFile={
117
+ <>
118
+ <AddFileBar
119
+ accept="video/*"
120
+ onFile={onPick}
121
+ busy={busy}
122
+ buttonLabel={source ? "Choose another video" : "Choose video"}
123
+ onClear={source && !busy ? clearSource : undefined}
124
+ >
125
+ {busy && <span className="vi-crop-status">{PHASE_LABEL[phase]}</span>}
126
+ {!busy && source && (
127
+ <>
128
+ {fileName && (
129
+ <span className="vi-addbar-name" title={fileName}>
130
+ {fileName}
131
+ </span>
132
+ )}
133
+ <span className="vi-crop-meta">
134
+ <code>{source.mime}</code>
135
+ {source.width && source.height ? ` · ${source.width}×${source.height}` : ""}
136
+ {source.duration_s != null ? ` · ${source.duration_s}s` : ""}
137
+ {source.fps_native != null ? ` · ${source.fps_native} fps native` : ""}
138
+ </span>
139
+ </>
140
+ )}
141
+ </AddFileBar>
142
+ {error && <p className="vi-error" role="alert">{error}</p>}
143
+ </>
144
+ }
145
+ />
146
+ </section>
147
+ );
148
+ }