@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,266 @@
1
+ // STUDIO-ASSIST LIVE LOG — the client half (operator directive, 2026-07-31).
2
+ //
3
+ // One shared EventSource for the whole studio: the log panel (and any future
4
+ // mount) subscribes to THIS module rather than each opening its own stream.
5
+ // Structurally the video-arm twin of the console's evictionStream.js — a
6
+ // module-scope store, a reference-counted connection (opens on first subscriber,
7
+ // closes on last), a de-dup set spanning the backfill + the SSE replay window,
8
+ // and a rowid cursor tail so the stream is correct across gunicorn workers.
9
+ //
10
+ // It differs from evictionStream in one way: an assist attempt is logged as ONE
11
+ // terminal record (the generation choke point emits on failure; the mode handler
12
+ // emits served/parse_error on success — see comms/studio_assist_log.py), so the
13
+ // store is keyed by run_id and the newest record for a run_id wins. No folding of
14
+ // many stage-events into a card is needed; a row IS an attempt.
15
+ //
16
+ // Auth rides the same path every other /video call uses: a console operator's
17
+ // same-origin session cookie (credentials:"include" via the EventSource
18
+ // withCredentials flag) or a stored ?share= key appended to the stream URL (an
19
+ // EventSource cannot carry the X-Video-Share header the XHR transport uses).
20
+ import { useEffect, useState } from "react";
21
+ import { request, okValue } from "../transport/client";
22
+ import { hugpyConfig } from "../config";
23
+ import { videoShareKey } from "../share";
24
+
25
+ /** One stored generate attempt — the wire shape of comms/studio_assist_log.py. */
26
+ export interface AssistAttempt {
27
+ _id?: number;
28
+ ts?: number;
29
+ seq?: number;
30
+ worker_id?: string;
31
+ run_id?: string;
32
+ mode?: string;
33
+ kind?: string;
34
+ model_requested?: string;
35
+ model_resolved?: string;
36
+ /** The UNTRUNCATED model reply — the whole point of the log. */
37
+ raw?: string;
38
+ /** The think-stripped prose that would have been used as the prompt. */
39
+ text?: string;
40
+ /** The reasoning that was stripped out. */
41
+ reasoning?: string;
42
+ /** True when `text` was salvaged FROM the reasoning (no prose came back). */
43
+ from_reasoning?: boolean;
44
+ outcome?: "served" | "empty" | "parse_error" | "worker_error" | "resolve_error" | string;
45
+ error?: string;
46
+ elapsed_ms?: number;
47
+ /** The backfill route bounds a huge reply on the page load only. */
48
+ raw_truncated?: boolean;
49
+ /** Transport marker ("stream.ready") — never an attempt; filtered out. */
50
+ stage?: string;
51
+ }
52
+
53
+ export type ConnState = "idle" | "connecting" | "live" | "disconnected";
54
+
55
+ const MAX_ATTEMPTS = 300; // oldest attempts drop off the front
56
+ const BACKFILL_LIMIT = 300;
57
+ const RETRY_MS = 5000;
58
+
59
+ let attempts: AssistAttempt[] = []; // oldest-first
60
+ let conn: ConnState = "idle";
61
+ let error: string | null = null;
62
+ let version = 0;
63
+
64
+ const index = new Map<string, number>(); // run_id (or synthetic) -> position
65
+ const seenId = new Set<number>(); // de-dup by store rowid across backfill + replay
66
+ const listeners = new Set<() => void>();
67
+
68
+ let es: EventSource | null = null;
69
+ let refs = 0;
70
+ let retryTimer: ReturnType<typeof setTimeout> | null = null;
71
+ let backfilled = false;
72
+
73
+ function emit(): void {
74
+ version++;
75
+ for (const fn of listeners) {
76
+ try {
77
+ fn();
78
+ } catch {
79
+ /* one bad subscriber must not stall the rest */
80
+ }
81
+ }
82
+ }
83
+
84
+ function keyOf(rec: AssistAttempt): string {
85
+ return rec.run_id || `solo:${rec._id ?? rec.seq ?? rec.ts ?? Math.random()}`;
86
+ }
87
+
88
+ function ingest(batch: AssistAttempt[]): void {
89
+ let changed = false;
90
+ for (const rec of batch) {
91
+ if (!rec || typeof rec !== "object") continue;
92
+ if (rec.stage === "stream.ready") continue; // transport marker, not an attempt
93
+ if (typeof rec._id === "number") {
94
+ if (seenId.has(rec._id)) continue;
95
+ seenId.add(rec._id);
96
+ }
97
+ const k = keyOf(rec);
98
+ const at = index.get(k);
99
+ if (at == null) {
100
+ index.set(k, attempts.length);
101
+ attempts = attempts.concat(rec);
102
+ } else {
103
+ // Newest record for a run_id wins (a terminal record supersedes any
104
+ // provisional one that arrived first).
105
+ const next = attempts.slice();
106
+ next[at] = { ...next[at], ...rec };
107
+ attempts = next;
108
+ }
109
+ changed = true;
110
+ }
111
+ if (!changed) return;
112
+ if (attempts.length > MAX_ATTEMPTS) {
113
+ attempts = attempts.slice(attempts.length - MAX_ATTEMPTS);
114
+ // Rebuild the index after trimming the front.
115
+ index.clear();
116
+ attempts.forEach((r, i) => index.set(keyOf(r), i));
117
+ }
118
+ emit();
119
+ }
120
+
121
+ function setConn(c: ConnState): void {
122
+ if (conn === c) return;
123
+ conn = c;
124
+ emit();
125
+ }
126
+
127
+ function streamUrl(): string {
128
+ const key = videoShareKey();
129
+ if (!key) return hugpyConfig.promptAssistLogStreamUrl;
130
+ const base = hugpyConfig.promptAssistLogStreamUrl;
131
+ const sep = base.includes("?") ? "&" : "?";
132
+ return `${base}${sep}share=${encodeURIComponent(key)}`;
133
+ }
134
+
135
+ function openStream(): void {
136
+ if (es) return;
137
+ setConn("connecting");
138
+ let source: EventSource;
139
+ try {
140
+ source = new EventSource(streamUrl(), { withCredentials: true });
141
+ } catch {
142
+ setConn("disconnected");
143
+ return;
144
+ }
145
+ es = source;
146
+ source.onopen = () => setConn("live");
147
+ source.onmessage = (e: MessageEvent) => {
148
+ let rec: AssistAttempt;
149
+ try {
150
+ rec = JSON.parse(e.data);
151
+ } catch {
152
+ return;
153
+ }
154
+ ingest([rec]);
155
+ };
156
+ source.onerror = () => {
157
+ source.close();
158
+ if (es === source) es = null;
159
+ setConn("disconnected");
160
+ // Reconnect only while someone is still watching. The server caps a stream at
161
+ // an hour and expects re-attach; the replay window + de-dup set mean no gap.
162
+ if (refs > 0) {
163
+ if (retryTimer) clearTimeout(retryTimer);
164
+ retryTimer = setTimeout(() => {
165
+ if (refs > 0) openStream();
166
+ }, RETRY_MS);
167
+ }
168
+ };
169
+ }
170
+
171
+ function closeStream(): void {
172
+ if (retryTimer) {
173
+ clearTimeout(retryTimer);
174
+ retryTimer = null;
175
+ }
176
+ if (es) {
177
+ es.close();
178
+ es = null;
179
+ }
180
+ setConn("idle");
181
+ }
182
+
183
+ function backfillOnce(): void {
184
+ if (backfilled) return;
185
+ backfilled = true;
186
+ request<{ events?: AssistAttempt[] }>(
187
+ `${hugpyConfig.promptAssistLogUrl}?limit=${BACKFILL_LIMIT}`,
188
+ { method: "GET", meta: { specKey: "studio", operation: "assist-log-backfill" } },
189
+ )
190
+ .then((res) => {
191
+ if (res.ok) {
192
+ error = null;
193
+ const value = okValue(res);
194
+ ingest(Array.isArray(value?.events) ? value.events : []);
195
+ emit();
196
+ } else {
197
+ error = "could not load the assist log";
198
+ emit();
199
+ }
200
+ })
201
+ .catch(() => {
202
+ error = "could not load the assist log";
203
+ emit();
204
+ });
205
+ }
206
+
207
+ function acquire(): void {
208
+ refs++;
209
+ if (refs === 1) {
210
+ backfillOnce();
211
+ openStream();
212
+ }
213
+ }
214
+
215
+ function release(): void {
216
+ refs = Math.max(0, refs - 1);
217
+ if (refs === 0) closeStream();
218
+ }
219
+
220
+ /** Force a reconnect (the panel's Reconnect button). */
221
+ export function reconnectAssistLog(): void {
222
+ closeStream();
223
+ if (refs > 0) openStream();
224
+ }
225
+
226
+ export interface UseStudioAssistLog {
227
+ attempts: AssistAttempt[];
228
+ conn: ConnState;
229
+ error: string | null;
230
+ reconnect: () => void;
231
+ }
232
+
233
+ /** Subscribe to the shared studio-assist log stream. */
234
+ export function useStudioAssistLog(): UseStudioAssistLog {
235
+ const [snap, setSnap] = useState(() => ({ attempts, conn, error, version }));
236
+
237
+ useEffect(() => {
238
+ acquire();
239
+ const onChange = () => setSnap({ attempts, conn, error, version });
240
+ onChange();
241
+ listeners.add(onChange);
242
+ return () => {
243
+ listeners.delete(onChange);
244
+ release();
245
+ };
246
+ }, []);
247
+
248
+ return {
249
+ attempts: snap.attempts,
250
+ conn: snap.conn,
251
+ error: snap.error,
252
+ reconnect: reconnectAssistLog,
253
+ };
254
+ }
255
+
256
+ /** Test/diagnostic reset — drops everything and detaches. */
257
+ export function _resetForTests(): void {
258
+ closeStream();
259
+ attempts = [];
260
+ index.clear();
261
+ seenId.clear();
262
+ listeners.clear();
263
+ refs = 0;
264
+ backfilled = false;
265
+ error = null;
266
+ }
@@ -0,0 +1,128 @@
1
+ // The Studio Clips station's PRESET dropdown source. Fetches the curated studio
2
+ // clip presets (GET /video/studio/presets), validates them with a tolerant schema,
3
+ // and exposes the list + loading/error as data (never throws) — the same
4
+ // data-not-throws contract as useMoviePresets.ts, which it is cloned from.
5
+ //
6
+ // Two deliberate parities with useMoviePresets:
7
+ // 1. The wire is the {presets:[…]} envelope (matches /video/presets + /movie/presets).
8
+ // 2. The fetch is GATED behind an `active` flag so a caller can keep it idle until
9
+ // the station is entered (and it stays cached after). The Studio Clips station
10
+ // passes active=true (it only mounts when its tab is open).
11
+ //
12
+ // A studio preset pins a CAPABILITY ("i2v"/"t2v") + geometry + a routing
13
+ // `vram_budget_gb` (NOT a model_key — the studio router picks the model); picking one
14
+ // prefills the generate affordance and drives its enqueue.
15
+ import { useCallback, useEffect, useRef, useState } from "react";
16
+ import { z } from "zod";
17
+ import { request, okValue, errorOf, describeAppError } from "../transport/client";
18
+ import { hugpyConfig } from "../config";
19
+
20
+ // Tolerant/passthrough — assert only the fields the station reads; a preset may omit
21
+ // the advisory ones (the station keeps its current value). Kept INLINE (like
22
+ // StudioClipsStation's clipSchema) so the frozen contract module stays untouched.
23
+ export const studioPresetSchema = z
24
+ .object({
25
+ id: z.string(),
26
+ name: z.string(),
27
+ description: z.string().nullable().optional(),
28
+ capability: z.string(),
29
+ width: z.number(),
30
+ height: z.number(),
31
+ fps: z.number(),
32
+ vram_budget_gb: z.number().nullable().optional(),
33
+ seed: z.number().nullable().optional(),
34
+ prompt: z.string().nullable().optional(),
35
+ negative: z.string().nullable().optional(),
36
+ recommended: z.string().nullable().optional(),
37
+ // Slice (a) / v2v: a restyle preset (capability "v2v") is a video TRANSFORM — it
38
+ // needs a staged source clip to mean anything. Drives the station's restyle flow.
39
+ requires_source: z.boolean().nullable().optional(),
40
+ // Identity lock (capability "id_lock"): the preset needs ≥1 REFERENCE image (the
41
+ // subject to lock). Exact sibling of requires_source — same consumption pattern;
42
+ // the surface gates the id_lock reference-slot flow on it (and on the capability).
43
+ requires_reference: z.boolean().nullable().optional(),
44
+ // UI-honesty badge: a note about the prompt for this preset. Set on SYNTHETIC
45
+ // previews to state the prompt is recorded but NOT rendered (frames = seed +
46
+ // geometry), so an evocative scaffold never misleads. "" / absent = no badge.
47
+ prompt_note: z.string().nullable().optional(),
48
+ })
49
+ .passthrough();
50
+ export type StudioPreset = z.infer<typeof studioPresetSchema>;
51
+
52
+ // GET /video/studio/presets returns a {presets:[…]} envelope (matches /video/presets).
53
+ const studioPresetsResponseSchema = z.object({ presets: z.array(studioPresetSchema) });
54
+
55
+ export interface StudioPresetsState {
56
+ presets: StudioPreset[];
57
+ loading: boolean;
58
+ error: string | null;
59
+ /** Force an immediate re-fetch of the studio-preset list. */
60
+ refresh: () => void;
61
+ }
62
+
63
+ /**
64
+ * Load the curated studio clip presets. `active` gates the network call; while
65
+ * inactive the hook reports loading:false / an empty list (nothing to show).
66
+ */
67
+ export function useStudioPresets(active: boolean): StudioPresetsState {
68
+ const [state, setState] = useState<Omit<StudioPresetsState, "refresh">>({
69
+ presets: [],
70
+ loading: false,
71
+ error: null,
72
+ });
73
+
74
+ const mounted = useRef(true);
75
+ const inFlight = useRef(false);
76
+ const loadedRef = useRef(false);
77
+ const ctrlRef = useRef<AbortController | null>(null);
78
+
79
+ const load = useCallback(async (background: boolean) => {
80
+ if (inFlight.current) return;
81
+ inFlight.current = true;
82
+ if (!background) setState((s) => ({ ...s, loading: true }));
83
+ const signal = ctrlRef.current?.signal;
84
+ try {
85
+ const res = await request<unknown>(hugpyConfig.studioPresetsUrl, {
86
+ signal,
87
+ meta: { specKey: "studio", operation: "studio.presets.list" },
88
+ });
89
+ if (!mounted.current) return;
90
+
91
+ if (!res.ok) {
92
+ // A background refresh that fails must not wipe a working picker.
93
+ if (background) return;
94
+ setState({ presets: [], loading: false, error: describeAppError(errorOf(res)) });
95
+ return;
96
+ }
97
+ const parsed = studioPresetsResponseSchema.safeParse(okValue(res));
98
+ if (!parsed.success) {
99
+ if (background) return;
100
+ setState({ presets: [], loading: false, error: "Malformed studio presets response." });
101
+ return;
102
+ }
103
+ loadedRef.current = true;
104
+ setState({ presets: parsed.data.presets, loading: false, error: null });
105
+ } finally {
106
+ inFlight.current = false;
107
+ }
108
+ }, []);
109
+
110
+ // Load once, the first time the station becomes active. Stays cached after.
111
+ useEffect(() => {
112
+ mounted.current = true;
113
+ if (!active || loadedRef.current) return;
114
+ const ctrl = new AbortController();
115
+ ctrlRef.current = ctrl;
116
+ void load(false);
117
+ return () => {
118
+ mounted.current = false;
119
+ ctrl.abort();
120
+ };
121
+ }, [active, load]);
122
+
123
+ const refresh = useCallback(() => {
124
+ void load(loadedRef.current);
125
+ }, [load]);
126
+
127
+ return { ...state, refresh };
128
+ }
@@ -0,0 +1,165 @@
1
+ // A live view of EVERY in-flight worker call across ALL transports (web / v1 /
2
+ // discord / cli / media) — the fleet-wide feed the session jobTracker CANNOT see,
3
+ // because the tracker only knows jobs THIS browser enqueued. GET /llm/jobs is the
4
+ // authoritative unified feed; this hook polls it, keeps the in-flight rows
5
+ // (pending / processing / streaming), drops `download` provisioning (shown
6
+ // elsewhere, and there can be dozens), and hands the survivors to the shared Active
7
+ // Processes panel so an identity reconstruction (kind:"identity_reconstruction",
8
+ // transport:"media") — or any render from another station — shows up even though
9
+ // this browser never launched it.
10
+ //
11
+ // Cadence ~2s (the panel doesn't need the tracker's 800ms poll); a fetch error
12
+ // keeps the last-good list rather than flashing empty. Mounted-ref guarded so a
13
+ // late poll never setState after unmount, and the interval is cleared on unmount.
14
+ import { useCallback, useEffect, useRef, useState } from "react";
15
+ import { hugpyConfig } from "../config";
16
+ import { request, okValue } from "../transport/client";
17
+
18
+ /** Poll cadence for the unified worker-call feed. */
19
+ const POLL_MS = 2000;
20
+
21
+ /** In-flight statuses from the /llm/jobs feed (everything else is terminal/idle). */
22
+ const IN_FLIGHT = new Set(["pending", "processing", "streaming"]);
23
+
24
+ /** One in-flight worker call, projected from a /llm/jobs row for the panel. */
25
+ export interface WorkerCall {
26
+ /** Server job id — the cancel handle + the dedup key against a TrackedJob.jobId. */
27
+ id: string;
28
+ /** Raw job kind (humanized for display in the row). */
29
+ kind: string;
30
+ /** In-flight status: pending | processing | streaming. */
31
+ status: string;
32
+ /** Transport that launched it — decides the cancel endpoint (media vs llm chat). */
33
+ transport: string;
34
+ /** Model label if the feed carries one (model_name preferred over model). */
35
+ model: string | null;
36
+ /** Serving worker name if present. */
37
+ worker: string | null;
38
+ /**
39
+ * ms-epoch start anchor, derived from the server `elapsed` seconds at poll time —
40
+ * the row's live clock reads (now - startedAt), so the elapsed ticks between polls
41
+ * and re-aligns to the server's truth on every refresh.
42
+ */
43
+ startedAt: number;
44
+ /**
45
+ * Fractional progress 0..1 if the feed carries it, else null (unknown → the row
46
+ * shows an indeterminate "working" bar rather than a fabricated percentage).
47
+ */
48
+ progress: number | null;
49
+ /** Free-text status line the worker is currently emitting (if any). */
50
+ message: string | null;
51
+ /** Coarse pipeline stage label (e.g. "sampling", "encoding") if the feed carries it. */
52
+ stage: string | null;
53
+ /** Last ≤40 log lines, NEWEST LAST — the scrolling tail in the expanded panel. */
54
+ logTail: string[];
55
+ /** Server-asserted wedged flag: the render is not making progress. */
56
+ stalled: boolean;
57
+ /** True once a cancel was requested and the server acknowledged it. */
58
+ cancelRequested: boolean;
59
+ /**
60
+ * ms-epoch of the last observed movement (server `updated_at`), or null. The row
61
+ * derives "last movement Ns ago" as (now - updatedAt) and flips to a stalled look
62
+ * when that exceeds the stall window, so a frozen render stops reading as live.
63
+ */
64
+ updatedAt: number | null;
65
+ }
66
+
67
+ function str(v: unknown): string | null {
68
+ return typeof v === "string" && v.trim() ? v : null;
69
+ }
70
+
71
+ /** Clamp an arbitrary progress reading into the renderable 0..1 band. */
72
+ function clamp01(v: number): number {
73
+ return v < 0 ? 0 : v > 1 ? 1 : v;
74
+ }
75
+
76
+ // A server timestamp → ms-epoch. Tolerates epoch SECONDS (the arm's usual wire
77
+ // form, ~1.7e9), epoch MILLISECONDS (~1.7e12), and ISO strings; anything else → null.
78
+ function toMs(v: unknown): number | null {
79
+ if (typeof v === "number" && isFinite(v)) return v < 1e12 ? v * 1000 : v;
80
+ if (typeof v === "string" && v.trim()) {
81
+ const t = Date.parse(v);
82
+ return isFinite(t) ? t : null;
83
+ }
84
+ return null;
85
+ }
86
+
87
+ // Project the /llm/jobs envelope into the in-flight WorkerCall rows. Defensive by
88
+ // construction (no zod schema for this feed, and strictNullChecks is off in the
89
+ // arm's tsconfig): a malformed row is skipped, never thrown.
90
+ function parseJobs(raw: unknown, at: number): WorkerCall[] {
91
+ const jobs = (raw as { jobs?: unknown } | null)?.jobs;
92
+ if (!Array.isArray(jobs)) return [];
93
+ const out: WorkerCall[] = [];
94
+ for (const j of jobs) {
95
+ if (!j || typeof j !== "object") continue;
96
+ const row = j as Record<string, unknown>;
97
+ const id = str(row.id);
98
+ if (!id) continue;
99
+ const kind = str(row.kind) ?? "";
100
+ if (kind === "download") continue; // provisioning — shown elsewhere
101
+ const status = str(row.status) ?? "";
102
+ if (!IN_FLIGHT.has(status)) continue;
103
+ const elapsed =
104
+ typeof row.elapsed === "number" && isFinite(row.elapsed) ? row.elapsed : 0;
105
+ // NEW fields (a parallel backend change adds progress/stage/log_tail; until it
106
+ // ships these read absent) — all optional, projected defensively so a bare
107
+ // legacy row still renders, just without the richer expanded detail.
108
+ const progress =
109
+ typeof row.progress === "number" && isFinite(row.progress)
110
+ ? clamp01(row.progress)
111
+ : null;
112
+ const logTail = Array.isArray(row.log_tail)
113
+ ? row.log_tail.filter((l): l is string => typeof l === "string").slice(-40)
114
+ : [];
115
+ out.push({
116
+ id,
117
+ kind,
118
+ status,
119
+ transport: str(row.transport) ?? "",
120
+ model: str(row.model_name) ?? str(row.model),
121
+ worker: str(row.worker),
122
+ startedAt: at - Math.max(0, elapsed) * 1000,
123
+ progress,
124
+ message: str(row.message),
125
+ stage: str(row.stage),
126
+ logTail,
127
+ stalled: row.stalled === true,
128
+ cancelRequested: row.cancel_requested === true,
129
+ updatedAt: toMs(row.updated_at),
130
+ });
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * The live in-flight worker calls + a manual `refetch` (fired right after a cancel,
137
+ * so the row disappears without waiting for the next poll tick). Polls only while a
138
+ * consumer is mounted — the panel mounts it only on the Active Processes tab, so no
139
+ * background polling when that tab is closed.
140
+ */
141
+ export function useWorkerCalls(): { calls: WorkerCall[]; refetch: () => void } {
142
+ const [calls, setCalls] = useState<WorkerCall[]>([]);
143
+ const mounted = useRef(true);
144
+
145
+ const load = useCallback(async () => {
146
+ const r = await request<unknown>(hugpyConfig.llmJobsUrl, {
147
+ meta: { specKey: "workers", operation: "worker.calls.poll" },
148
+ });
149
+ if (!mounted.current) return;
150
+ if (!r.ok) return; // transient — keep last-good, retry next tick
151
+ setCalls(parseJobs(okValue(r), Date.now()));
152
+ }, []);
153
+
154
+ useEffect(() => {
155
+ mounted.current = true;
156
+ void load();
157
+ const id = window.setInterval(() => void load(), POLL_MS);
158
+ return () => {
159
+ mounted.current = false;
160
+ window.clearInterval(id);
161
+ };
162
+ }, [load]);
163
+
164
+ return { calls, refetch: load };
165
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
7
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
8
+ "skipLibCheck": true,
9
+ "strict": false,
10
+ "esModuleInterop": true,
11
+ "allowSyntheticDefaultImports": true,
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "noEmit": true
15
+ },
16
+ "include": ["src", "entry.tsx"]
17
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+ import tailwindcss from "@tailwindcss/vite";
4
+
5
+ // Standalone build of the video-intelligence "arm" of the hugpy ecosystem —
6
+ // sibling of media_intelligence_ui with the same posture:
7
+ // - Served at /video by the hugpy dev UI's origin, so `base` makes every asset
8
+ // URL resolve under that path.
9
+ // - Native toolchain (Vite + Tailwind v4), deliberately NOT integrated into the
10
+ // dev UI's webpack build.
11
+ // - Dials the SAME-ORIGIN current-hugpy API (/api) — see src/config.ts. Override
12
+ // the base with VITE_HUGPY_API_BASE at build time if needed.
13
+ export default defineConfig({
14
+ base: "/video/",
15
+ plugins: [react(), tailwindcss()],
16
+ build: {
17
+ outDir: "dist",
18
+ emptyOutDir: true,
19
+ },
20
+ // Standalone `vite dev` / `vite preview` reach the same-origin API by proxying
21
+ // /api to the local hugpy Flask backend, stripping the /api prefix exactly like
22
+ // the webpack devServer (dev/ui) and prod nginx do. Override the target with
23
+ // HUGPY_API_URL.
24
+ server: {
25
+ proxy: {
26
+ "/api": {
27
+ target: process.env.HUGPY_API_URL || "http://127.0.0.1:7002",
28
+ changeOrigin: true,
29
+ rewrite: (p) => p.replace(/^\/api/, ""),
30
+ },
31
+ },
32
+ },
33
+ });