@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,279 @@
1
+ // A live view of every IN-FLIGHT media-bus job (studio / movie / identity /
2
+ // generate / crop / extract) WITH PLACEMENT — the console-wide "Active Processes"
3
+ // feed. Polls GET /video/jobs (the media CATALOG projection), which unlike the
4
+ // unified /llm/jobs feed carries a `placement` object: WHERE each render
5
+ // physically executes (ae · cuda:0 · P-studio), and surfaces the reservation
6
+ // `awaiting_capacity` HOLD phase + its shortfall reason + overtaken count.
7
+ //
8
+ // Honest rendering only: `progress` is projected ONLY when the server sends it —
9
+ // no fabricated bars. Cadence ~2s; a fetch error keeps the last-good list rather
10
+ // than flashing empty. Mounted-ref guarded so a late poll never setState after
11
+ // unmount, and the interval is cleared on unmount.
12
+ import { useCallback, useEffect, useRef, useState } from "react";
13
+ import { hugpyConfig } from "../config";
14
+ import { request, okValue } from "../transport/client";
15
+ // The stage-timeline + failure shapes are the SAME wire objects the studio tab
16
+ // already renders through `ProcessTimeline`; reusing its types (and, in the panel,
17
+ // the component itself) keeps one renderer for "what it did / where it broke"
18
+ // instead of a second, divergent one for the console-wide view.
19
+ import type { StageEntry, JobFailure } from "../stations/studio/studioShared";
20
+
21
+ const POLL_MS = 2000;
22
+ const IN_FLIGHT = new Set(["queued", "claimed", "running", "cancelling"]);
23
+
24
+ /** The numbers behind the bar (omit-when-unset on the wire). */
25
+ export interface ProgressDetail {
26
+ segmentDone: number | null;
27
+ segmentTotal: number | null;
28
+ step: number | null;
29
+ steps: number | null;
30
+ fraction: number | null;
31
+ }
32
+
33
+ /** WHERE a job physically executes (omit-when-unset on the wire). */
34
+ export interface Placement {
35
+ source: string | null; // "reservation" | "template"
36
+ host: string | null; // box: "ae" | "central" | ...
37
+ workerId: string | null;
38
+ gpu: string | null; // device: "cuda:0" | null (cpu)
39
+ process: string | null; // "P-studio" | "P-comfy" | "P-identity" | "ffmpeg" | ...
40
+ reservedBytes: number | null;
41
+ }
42
+
43
+ /** The reservation HOLD marker (progress.phase === "awaiting_capacity"). */
44
+ export interface HoldInfo {
45
+ reason: Record<string, unknown> | null;
46
+ heldSince: number | null;
47
+ overtaken: number;
48
+ }
49
+
50
+ /** One in-flight media-bus job, projected for the Active Processes panel. */
51
+ export interface MediaJob {
52
+ id: string;
53
+ name: string; // bus job kind
54
+ status: string; // queued | claimed | running | cancelling
55
+ createdAt: number | null; // epoch seconds
56
+ principal: string | null;
57
+ /** Effective phase chip: the reservation hold overrides the raw status. */
58
+ phase: string; // queued | awaiting_capacity | running | claimed | cancelling
59
+ hold: HoldInfo | null; // set only when phase === "awaiting_capacity"
60
+ progress: number | null; // 0..1 when the server sends one, else null
61
+ progressDetail: ProgressDetail | null; // the numbers behind the bar
62
+ stage: string | null;
63
+ placement: Placement | null;
64
+ /** The server's stage timeline: a TAIL while live, the whole thing when terminal. */
65
+ stageLog: StageEntry[];
66
+ stageLogTotal: number; // how many entries exist server-side (tail elides the rest)
67
+ /** The failure envelope of a terminal-failed job — rendered verbatim, never "failed". */
68
+ failure: JobFailure | null;
69
+ terminal: boolean; // done | failed | cancelled (only present with the history filter)
70
+ progressedAt: number | null; // last REAL movement (the stall/stale basis)
71
+ }
72
+
73
+ function str(v: unknown): string | null {
74
+ return typeof v === "string" && v.trim() ? v : null;
75
+ }
76
+ function num(v: unknown): number | null {
77
+ return typeof v === "number" && isFinite(v) ? v : null;
78
+ }
79
+
80
+ function parsePlacement(raw: unknown): Placement | null {
81
+ if (!raw || typeof raw !== "object") return null;
82
+ const p = raw as Record<string, unknown>;
83
+ return {
84
+ source: str(p.source),
85
+ host: str(p.host),
86
+ workerId: str(p.worker_id),
87
+ gpu: str(p.gpu),
88
+ process: str(p.process),
89
+ reservedBytes: num(p.reserved_bytes),
90
+ };
91
+ }
92
+
93
+ /** The timeline entries, kept in their WIRE shape for `ProcessTimeline`. */
94
+ function parseStageLog(raw: unknown): StageEntry[] {
95
+ if (!Array.isArray(raw)) return [];
96
+ return raw.filter(
97
+ (e): e is StageEntry =>
98
+ !!e && typeof e === "object" && typeof (e as StageEntry).stage === "string",
99
+ );
100
+ }
101
+
102
+ /**
103
+ * The failure envelope, passed through UNTOUCHED. Whatever code/message the
104
+ * backend produced (e.g. `deps_missing` with its pip remedy) is what the panel
105
+ * renders — no summarising, no generic "failed".
106
+ */
107
+ function parseFailure(raw: unknown): JobFailure | null {
108
+ if (!raw || typeof raw !== "object") return null;
109
+ const f = raw as JobFailure;
110
+ return f.code || f.message ? f : null;
111
+ }
112
+
113
+ function parseProgressDetail(raw: unknown): ProgressDetail | null {
114
+ if (!raw || typeof raw !== "object") return null;
115
+ const d = raw as Record<string, unknown>;
116
+ return {
117
+ segmentDone: num(d.segment_done),
118
+ segmentTotal: num(d.segment_total),
119
+ step: num(d.step),
120
+ steps: num(d.steps),
121
+ fraction: num(d.fraction),
122
+ };
123
+ }
124
+
125
+ function parseJobs(raw: unknown, includeTerminal: boolean): MediaJob[] {
126
+ const jobs = (raw as { jobs?: unknown } | null)?.jobs;
127
+ if (!Array.isArray(jobs)) return [];
128
+ const out: MediaJob[] = [];
129
+ for (const j of jobs) {
130
+ if (!j || typeof j !== "object") continue;
131
+ const row = j as Record<string, unknown>;
132
+ const id = str(row.job_id);
133
+ if (!id) continue;
134
+ const status = str(row.status) ?? "";
135
+ const terminal = !IN_FLIGHT.has(status);
136
+ if (terminal && !includeTerminal) continue;
137
+ const progress = (row.progress as Record<string, unknown> | null) ?? null;
138
+ const phaseRaw =
139
+ progress && typeof progress === "object" ? str(progress.phase) : null;
140
+ const isHold = phaseRaw === "awaiting_capacity";
141
+ let hold: HoldInfo | null = null;
142
+ if (isHold && progress) {
143
+ hold = {
144
+ reason:
145
+ progress.reason && typeof progress.reason === "object"
146
+ ? (progress.reason as Record<string, unknown>)
147
+ : null,
148
+ heldSince: num(progress.held_since),
149
+ overtaken: num(progress.overtaken) ?? 0,
150
+ };
151
+ }
152
+ out.push({
153
+ id,
154
+ name: str(row.name) ?? "",
155
+ status,
156
+ createdAt: num(row.created),
157
+ principal: str(row.principal),
158
+ phase: isHold ? "awaiting_capacity" : status,
159
+ hold,
160
+ // Honest: only surface a numeric progress the server actually sent. The
161
+ // server now computes it (segments done + the within-segment denoise
162
+ // fraction) and sends null when the runner reports nothing measurable, so a
163
+ // missing bar still means "unknown", never "zero".
164
+ progress: num(row.progress_ratio),
165
+ progressDetail: parseProgressDetail(row.progress_detail),
166
+ stage: str(row.current_stage) ?? (progress ? str(progress.stage) : null),
167
+ placement: parsePlacement(row.placement),
168
+ stageLog: parseStageLog(row.stage_log),
169
+ stageLogTotal: num(row.stage_log_total) ?? 0,
170
+ failure: parseFailure(row.failure),
171
+ terminal,
172
+ progressedAt: num(row.progressed_at) ?? num(row.last_movement_ts),
173
+ });
174
+ }
175
+ return out;
176
+ }
177
+
178
+ /**
179
+ * The live in-flight media jobs + a manual `refetch` (fired right after a cancel
180
+ * so the row disappears without waiting for the next poll tick). Polls only while
181
+ * a consumer is mounted.
182
+ *
183
+ * `includeTerminal` asks the server for recent terminal rows too (?all=1) — the
184
+ * panel's "history" filter, which is how a FAILED render (and its verbatim failure
185
+ * envelope) stays inspectable after it leaves the in-flight set. The default feed
186
+ * is live work only: the server hides in-flight rows abandoned by a dead process,
187
+ * so days-old ghosts no longer crowd the panel.
188
+ */
189
+ export function useMediaJobs(
190
+ includeTerminal = false,
191
+ ): { jobs: MediaJob[]; refetch: () => void } {
192
+ const [jobs, setJobs] = useState<MediaJob[]>([]);
193
+ const mounted = useRef(true);
194
+
195
+ const load = useCallback(async () => {
196
+ const url = includeTerminal
197
+ ? `${hugpyConfig.mediaJobsUrl}?all=1`
198
+ : hugpyConfig.mediaJobsUrl;
199
+ const r = await request<unknown>(url, {
200
+ meta: { specKey: "media", operation: "media.jobs.poll" },
201
+ });
202
+ if (!mounted.current) return;
203
+ if (!r.ok) return; // transient — keep last-good, retry next tick
204
+ setJobs(parseJobs(okValue(r), includeTerminal));
205
+ }, [includeTerminal]);
206
+
207
+ useEffect(() => {
208
+ mounted.current = true;
209
+ void load();
210
+ const id = window.setInterval(() => void load(), POLL_MS);
211
+ return () => {
212
+ mounted.current = false;
213
+ window.clearInterval(id);
214
+ };
215
+ }, [load]);
216
+
217
+ return { jobs, refetch: load };
218
+ }
219
+
220
+ // ── shared render helpers (used by the console-wide panel + the studio tab) ──
221
+
222
+ /** "ae · cuda:0 · P-studio" — the placement one-liner, or "" when unknown. */
223
+ export function placementLine(p: Placement | null): string {
224
+ if (!p) return "";
225
+ const parts = [p.host, p.gpu ?? "cpu", p.process].filter(
226
+ (x): x is string => typeof x === "string" && x.length > 0,
227
+ );
228
+ return parts.join(" · ");
229
+ }
230
+
231
+ /** True when the job runs OFF the central box (an external worker/service). */
232
+ export function isExternal(p: Placement | null): boolean {
233
+ return !!p && !!p.host && p.host !== "central";
234
+ }
235
+
236
+ /** Human GiB for a reserved-bytes value, or "" when absent. */
237
+ export function reservedGib(p: Placement | null): string {
238
+ if (!p || p.reservedBytes == null) return "";
239
+ return `${(p.reservedBytes / 1024 ** 3).toFixed(1)} GB reserved`;
240
+ }
241
+
242
+ /**
243
+ * "42% · segment 1/2 · step 12/30" — the label under the bar, built only from the
244
+ * numbers the server actually sent. "" when there is nothing honest to say.
245
+ */
246
+ export function progressLabel(job: MediaJob): string {
247
+ const parts: string[] = [];
248
+ if (job.progress != null) parts.push(`${Math.round(job.progress * 100)}%`);
249
+ const d = job.progressDetail;
250
+ if (d?.segmentTotal != null && d.segmentDone != null) {
251
+ // 1-based for humans: while segment 0 renders, an operator reads "segment 1/2".
252
+ const at = Math.min(d.segmentDone + 1, d.segmentTotal);
253
+ parts.push(`segment ${at}/${d.segmentTotal}`);
254
+ }
255
+ if (d?.step != null && d.steps != null) parts.push(`step ${d.step}/${d.steps}`);
256
+ return parts.join(" · ");
257
+ }
258
+
259
+ /**
260
+ * The freshest timeline line — "segment 0/2 · t2v · worker rendering". This is the
261
+ * one-line answer to "what is it doing", shown WITHOUT expanding the row, because
262
+ * "no logs are ever shown" was the operator's complaint and a detail that needs a
263
+ * click is still a detail nobody sees.
264
+ */
265
+ export function latestEvent(job: MediaJob): string {
266
+ for (let i = job.stageLog.length - 1; i >= 0; i -= 1) {
267
+ const d = job.stageLog[i]?.detail;
268
+ if (typeof d === "string" && d.trim()) return d;
269
+ }
270
+ return "";
271
+ }
272
+
273
+ /** Elapsed since an epoch-seconds start, as "12s" / "3m 4s". */
274
+ export function elapsedSince(createdAt: number | null): string {
275
+ if (createdAt == null) return "";
276
+ const secs = Math.max(0, Math.floor(Date.now() / 1000 - createdAt));
277
+ if (secs < 60) return `${secs}s`;
278
+ return `${Math.floor(secs / 60)}m ${secs % 60}s`;
279
+ }
@@ -0,0 +1,413 @@
1
+ // The shared image-model dropdown source. Fetches the model registry and the task
2
+ // defaults, filters to the GENERATION image tasks (text-to-image + image-to-image)
3
+ // off the FULL `tasks` capability list, and exposes the options + the default id +
4
+ // loading/error as data (never throws). Both the Frames station (config surface)
5
+ // and the later Generate station import this verbatim.
6
+ //
7
+ // Staleness fix: the registry is no longer fetched only once on mount. Models
8
+ // adopted MID-SESSION (e.g. ae ComfyUI checkpoints that appear once ComfyUI comes
9
+ // up) must enter the picker WITHOUT a full page reload, so the hook now
10
+ // stale-while-revalidates — it re-fetches on window focus / tab visibility, on a
11
+ // gentle background interval (skipped while the tab is hidden), and on demand via
12
+ // the exposed `refresh()`. Background revalidations never blank a working picker:
13
+ // `loading` flips only for the very first load, and a failed poll keeps the last
14
+ // good list. The public surface gained `refresh` (additive — existing consumers
15
+ // that destructure the four data fields are unaffected).
16
+ import { useCallback, useEffect, useRef, useState } from "react";
17
+ import { z } from "zod";
18
+ import { request, okValue, errorOf, describeAppError } from "../transport/client";
19
+ import { hugpyConfig } from "../config";
20
+
21
+ export interface ModelOption {
22
+ /** The model id sent to the backend. */
23
+ id: string;
24
+ /** Human label for the dropdown (hub_id when present, else the id). */
25
+ label: string;
26
+ /**
27
+ * The model's FULL generation-capability list (from `capabilitiesOf`) — carried
28
+ * through so the Generate station can drive REQUIRED inputs from it (see
29
+ * `requiredInputsFor`). Previously discarded after the label was built.
30
+ */
31
+ tasks: string[];
32
+ /**
33
+ * Present but NOT pickable — a LoRA adapter or a pipeline component (an
34
+ * encoder/VAE split). Rendered greyed with `reason` rather than offered
35
+ * (it would refuse) or hidden (the operator then can't see the file they
36
+ * downloaded). See the k61 incident note on GENERATION_IMAGE_TASKS.
37
+ */
38
+ disabled?: boolean;
39
+ /** Why it can't be picked — shown as the option's title. */
40
+ reason?: string;
41
+ }
42
+
43
+ export interface ImageModelsState {
44
+ models: ModelOption[];
45
+ defaultId: string | null;
46
+ loading: boolean;
47
+ error: string | null;
48
+ /**
49
+ * Force an immediate re-fetch of the registry (manual refresh affordance).
50
+ * Runs as a background revalidation — it does not clear the current list.
51
+ */
52
+ refresh: () => void;
53
+ }
54
+
55
+ // Tolerant schemas — the registry carries many fields we ignore; passthrough keeps
56
+ // them and we only assert the three we read.
57
+ const modelEntrySchema = z
58
+ .object({
59
+ id: z.string(),
60
+ task: z.string().nullable().optional(),
61
+ // Full capability list (0.1.122+): `task` is only the PRIMARY — filtering
62
+ // on it alone hid secondary capabilities (dual t2i+img2img models).
63
+ tasks: z.array(z.string()).nullable().optional(),
64
+ hub_id: z.string().nullable().optional(),
65
+ // Why a present model still can't serve (0.1.x+, k61): adapters and
66
+ // pipeline components are real files that are not servable on their own.
67
+ adapter: z.boolean().nullable().optional(),
68
+ serveable: z.boolean().nullable().optional(),
69
+ unserveable_reason: z.string().nullable().optional(),
70
+ })
71
+ .passthrough();
72
+
73
+ const modelsResponseSchema = z
74
+ .object({ data: z.array(modelEntrySchema) })
75
+ .passthrough();
76
+
77
+ const promptTasksSchema = z
78
+ .object({
79
+ defaults: z.record(z.string()).nullable().optional(),
80
+ })
81
+ .passthrough();
82
+
83
+ const IMAGE_TASK = "text-to-image";
84
+ // Since the img2img go-live (map §12.5 follow-up), image-to-image models
85
+ // (Qwen-Image-Edit, flux-klein) are first-class generate targets — but they
86
+ // REQUIRE a start image (no text-only path), so their labels say so.
87
+ const IMG2IMG_TASK = "image-to-image";
88
+ // The full set of GENERATION / EDITING image tasks this workspace can drive: t2i
89
+ // produces from text, img2img edits a start image. Both stations consume exactly
90
+ // these. Analysis-only image tasks (classification, captioning, detection, …) are
91
+ // NOT generate targets, so they are deliberately excluded — a model must advertise
92
+ // one of these in its capability list to enter the picker.
93
+ const GENERATION_IMAGE_TASKS: readonly string[] = [IMAGE_TASK, IMG2IMG_TASK];
94
+
95
+ // Rows that are NOT generation targets but must still be explicable in an image
96
+ // picker (k61, 2026-07-31). The flux2 incident put a text-encoder COMPONENT and
97
+ // an image LoRA in front of the operator as if they were models: the encoder was
98
+ // selectable in image UIs where it can never serve, and the LoRA — left
99
+ // unclassified and defaulted to text-generation — refused with
100
+ // "supported: ['text-generation']". They now enter the list GREYED, carrying the
101
+ // backend's own reason, so the affordance says what the file is instead of
102
+ // pretending it can generate.
103
+ const ADAPTER_TASK = "adapter";
104
+ const COMPONENT_TASK = "pipeline-component";
105
+ const NEEDS_CLASSIFICATION_TASK = "needs-classification";
106
+ const EXPLAINED_NON_TARGETS: readonly string[] = [
107
+ ADAPTER_TASK,
108
+ COMPONENT_TASK,
109
+ NEEDS_CLASSIFICATION_TASK,
110
+ ];
111
+
112
+ function nonTargetReason(m: z.infer<typeof modelEntrySchema>): string {
113
+ if (m.unserveable_reason) return m.unserveable_reason;
114
+ const caps = capabilitiesOf(m);
115
+ if (m.adapter || caps.includes(ADAPTER_TASK))
116
+ return "LoRA adapter — apply it to a base image model; it can't generate on its own.";
117
+ if (caps.includes(COMPONENT_TASK))
118
+ return "Pipeline component (encoder/VAE) — served through its parent pipeline, not on its own.";
119
+ return "Unclassified — no task derived from this model's files yet.";
120
+ }
121
+
122
+ // Gentle background revalidation cadence (ms). Cheap same-origin GET; only ONE
123
+ // picker instance is mounted at a time (studio sub-tabs share one WorkbenchStation),
124
+ // and polls are skipped while the tab is hidden.
125
+ const REFRESH_INTERVAL_MS = 45_000;
126
+
127
+ // Capability = the FULL `tasks` list; the single `task` (primary) is only the
128
+ // fallback for older payloads. Exported so task-specific pickers (e.g. the Movie
129
+ // director's judge-VLM select) can filter the registry the same way.
130
+ export function capabilitiesOf(m: z.infer<typeof modelEntrySchema>): string[] {
131
+ return m.tasks?.length ? m.tasks : m.task ? [m.task] : [];
132
+ }
133
+
134
+ /**
135
+ * The REQUIRED-input contract for a model, derived purely from its capability
136
+ * `tasks[]`. The Generate station drives its explicit prompt/start-image controls
137
+ * off this instead of leaving the user to hand-add parts. Truth table:
138
+ *
139
+ * tasks includes… → image
140
+ * ─────────────────────────────────────────
141
+ * text-to-image only → "none" (t2i can't take a start image)
142
+ * text-to-image + img2img → "optional" (start image ⇒ img2img edit)
143
+ * image-to-image only → "required" (edit model — no text-only path)
144
+ * empty / unrecognized → "optional" (safe default: text + optional image)
145
+ *
146
+ * `text` is always "required" — every generation path here needs a prompt.
147
+ */
148
+ export function requiredInputsFor(
149
+ tasks: string[],
150
+ ): { text: "required"; image: "required" | "optional" | "none" } {
151
+ const t2i = tasks.includes(IMAGE_TASK);
152
+ const i2i = tasks.includes(IMG2IMG_TASK);
153
+ let image: "required" | "optional" | "none";
154
+ if (i2i && !t2i) image = "required"; // edit-only model
155
+ else if (t2i && i2i) image = "optional"; // dual t2i + img2img
156
+ else if (t2i && !i2i) image = "none"; // pure text-to-image
157
+ else image = "optional"; // empty / unknown capabilities → safe default
158
+ return { text: "required", image };
159
+ }
160
+
161
+ export function useImageModels(): ImageModelsState {
162
+ const [state, setState] = useState<Omit<ImageModelsState, "refresh">>({
163
+ models: [],
164
+ defaultId: null,
165
+ loading: true,
166
+ error: null,
167
+ });
168
+
169
+ // `mounted` gates setState after unmount; `inFlight` collapses overlapping
170
+ // revalidations (interval + focus + manual) into one; `ctrlRef` lets every
171
+ // trigger — including a manual refresh — abort on unmount.
172
+ const mounted = useRef(true);
173
+ const inFlight = useRef(false);
174
+ const ctrlRef = useRef<AbortController | null>(null);
175
+
176
+ const load = useCallback(async (background: boolean) => {
177
+ if (inFlight.current) return;
178
+ inFlight.current = true;
179
+ const signal = ctrlRef.current?.signal;
180
+ try {
181
+ const [mRes, tRes] = await Promise.all([
182
+ request<unknown>(hugpyConfig.modelsUrl, {
183
+ signal,
184
+ meta: { specKey: "frames", operation: "models.list" },
185
+ }),
186
+ request<unknown>(hugpyConfig.promptTasksUrl, {
187
+ signal,
188
+ meta: { specKey: "frames", operation: "prompt.tasks" },
189
+ }),
190
+ ]);
191
+ if (!mounted.current) return;
192
+
193
+ if (!mRes.ok) {
194
+ // A background revalidation that fails must NOT wipe a working picker —
195
+ // keep the last good list; only the first (foreground) load surfaces it.
196
+ if (background) return;
197
+ setState({
198
+ models: [],
199
+ defaultId: null,
200
+ loading: false,
201
+ error: describeAppError(errorOf(mRes)),
202
+ });
203
+ return;
204
+ }
205
+ const mParsed = modelsResponseSchema.safeParse(okValue(mRes));
206
+ if (!mParsed.success) {
207
+ if (background) return;
208
+ setState({
209
+ models: [],
210
+ defaultId: null,
211
+ loading: false,
212
+ error: "Malformed models response.",
213
+ });
214
+ return;
215
+ }
216
+
217
+ // Only a model that ADVERTISES a generation image task is pickable. Adapters,
218
+ // pipeline components and unclassified rows are appended after them, greyed,
219
+ // so they are explained rather than offered or hidden.
220
+ const rows = mParsed.data.data.filter((m) => {
221
+ const caps = capabilitiesOf(m);
222
+ return (
223
+ caps.some((t) => GENERATION_IMAGE_TASKS.includes(t)) ||
224
+ m.adapter === true ||
225
+ caps.some((t) => EXPLAINED_NON_TARGETS.includes(t))
226
+ );
227
+ });
228
+ const models: ModelOption[] = rows
229
+ .map((m) => {
230
+ const caps = capabilitiesOf(m);
231
+ const t2i = caps.includes(IMAGE_TASK);
232
+ const i2i = caps.includes(IMG2IMG_TASK);
233
+ if (!t2i && !i2i) {
234
+ const reason = nonTargetReason(m);
235
+ return {
236
+ id: m.id,
237
+ label: `${m.hub_id ?? m.id} · not selectable`,
238
+ tasks: caps,
239
+ disabled: true,
240
+ reason,
241
+ };
242
+ }
243
+ const suffix = i2i && !t2i
244
+ ? " · img2img (needs a start image)"
245
+ : i2i && t2i
246
+ ? " · t2i + img2img"
247
+ : "";
248
+ // Carry the full capability list through — the Generate station reads it
249
+ // via `requiredInputsFor(...)` to drive its required prompt/start-image UI.
250
+ return { id: m.id, label: (m.hub_id ?? m.id) + suffix, tasks: caps };
251
+ })
252
+ .sort((a, b) => Number(a.disabled ?? false) - Number(b.disabled ?? false));
253
+
254
+ // Task defaults are best-effort — a failure there still yields a usable list.
255
+ let defaultId: string | null = null;
256
+ if (tRes.ok) {
257
+ const tParsed = promptTasksSchema.safeParse(okValue(tRes));
258
+ if (tParsed.success) {
259
+ const d = tParsed.data.defaults?.[IMAGE_TASK];
260
+ if (typeof d === "string") defaultId = d;
261
+ }
262
+ }
263
+ // Guarantee a SELECTABLE default: fall back to the first pickable option
264
+ // when the task default is absent, greyed, or not in the list at all.
265
+ const pickable = models.filter((m) => !m.disabled);
266
+ if (
267
+ (!defaultId || !pickable.some((m) => m.id === defaultId)) &&
268
+ pickable.length > 0
269
+ ) {
270
+ defaultId = pickable[0].id;
271
+ }
272
+
273
+ setState({ models, defaultId, loading: false, error: null });
274
+ } finally {
275
+ inFlight.current = false;
276
+ }
277
+ }, []);
278
+
279
+ useEffect(() => {
280
+ mounted.current = true;
281
+ const ctrl = new AbortController();
282
+ ctrlRef.current = ctrl;
283
+
284
+ // Foreground load (the only one that toggles `loading` / surfaces an error).
285
+ void load(false);
286
+
287
+ // Revalidate when the tab/window regains focus or becomes visible — a
288
+ // returning operator sees models adopted while they were away, no reload.
289
+ const revalidate = () => {
290
+ if (document.visibilityState === "hidden") return;
291
+ void load(true);
292
+ };
293
+ window.addEventListener("focus", revalidate);
294
+ document.addEventListener("visibilitychange", revalidate);
295
+
296
+ // Gentle interval backstop for the same-tab case (ComfyUI comes up while the
297
+ // operator is looking at the station). Skipped while the tab is hidden.
298
+ const timer = setInterval(() => {
299
+ if (document.visibilityState === "hidden") return;
300
+ void load(true);
301
+ }, REFRESH_INTERVAL_MS);
302
+
303
+ return () => {
304
+ mounted.current = false;
305
+ ctrl.abort();
306
+ window.removeEventListener("focus", revalidate);
307
+ document.removeEventListener("visibilitychange", revalidate);
308
+ clearInterval(timer);
309
+ };
310
+ }, [load]);
311
+
312
+ const refresh = useCallback(() => {
313
+ void load(true);
314
+ }, [load]);
315
+
316
+ return { ...state, refresh };
317
+ }
318
+
319
+ export interface TaskModelsState {
320
+ models: ModelOption[];
321
+ loading: boolean;
322
+ error: string | null;
323
+ refresh: () => void;
324
+ }
325
+
326
+ /**
327
+ * A registry list filtered to models whose capability `tasks[]` include ONE given
328
+ * task — the generic sibling of useImageModels for non-generation pickers. The
329
+ * Movie director's judge-model select uses it with "image-text-to-text" to surface
330
+ * the vision-language models that can SCORE a segment. Same tolerant fetch +
331
+ * stale-while-revalidate posture (focus / visibility / interval / manual refresh);
332
+ * a failed background revalidation keeps the last good list. No task-defaults fetch
333
+ * (there is no canonical default for an arbitrary task) — the caller supplies its
334
+ * own default and keeps it selectable even when the list is empty.
335
+ */
336
+ export function useModelsByTask(task: string): TaskModelsState {
337
+ const [state, setState] = useState<Omit<TaskModelsState, "refresh">>({
338
+ models: [],
339
+ loading: true,
340
+ error: null,
341
+ });
342
+
343
+ const mounted = useRef(true);
344
+ const inFlight = useRef(false);
345
+ const ctrlRef = useRef<AbortController | null>(null);
346
+
347
+ const load = useCallback(
348
+ async (background: boolean) => {
349
+ if (inFlight.current) return;
350
+ inFlight.current = true;
351
+ const signal = ctrlRef.current?.signal;
352
+ try {
353
+ const mRes = await request<unknown>(hugpyConfig.modelsUrl, {
354
+ signal,
355
+ meta: { specKey: "generate", operation: "models.list" },
356
+ });
357
+ if (!mounted.current) return;
358
+ if (!mRes.ok) {
359
+ if (background) return;
360
+ setState({ models: [], loading: false, error: describeAppError(errorOf(mRes)) });
361
+ return;
362
+ }
363
+ const mParsed = modelsResponseSchema.safeParse(okValue(mRes));
364
+ if (!mParsed.success) {
365
+ if (background) return;
366
+ setState({ models: [], loading: false, error: "Malformed models response." });
367
+ return;
368
+ }
369
+ const models: ModelOption[] = mParsed.data.data
370
+ .filter((m) => capabilitiesOf(m).includes(task))
371
+ .map((m) => ({
372
+ id: m.id,
373
+ label: m.hub_id ?? m.id,
374
+ tasks: capabilitiesOf(m),
375
+ }));
376
+ setState({ models, loading: false, error: null });
377
+ } finally {
378
+ inFlight.current = false;
379
+ }
380
+ },
381
+ [task],
382
+ );
383
+
384
+ useEffect(() => {
385
+ mounted.current = true;
386
+ const ctrl = new AbortController();
387
+ ctrlRef.current = ctrl;
388
+ void load(false);
389
+ const revalidate = () => {
390
+ if (document.visibilityState === "hidden") return;
391
+ void load(true);
392
+ };
393
+ window.addEventListener("focus", revalidate);
394
+ document.addEventListener("visibilitychange", revalidate);
395
+ const timer = setInterval(() => {
396
+ if (document.visibilityState === "hidden") return;
397
+ void load(true);
398
+ }, REFRESH_INTERVAL_MS);
399
+ return () => {
400
+ mounted.current = false;
401
+ ctrl.abort();
402
+ window.removeEventListener("focus", revalidate);
403
+ document.removeEventListener("visibilitychange", revalidate);
404
+ clearInterval(timer);
405
+ };
406
+ }, [load]);
407
+
408
+ const refresh = useCallback(() => {
409
+ void load(true);
410
+ }, [load]);
411
+
412
+ return { ...state, refresh };
413
+ }