@hugpy/video-intelligence-ui 0.3.3 → 0.3.5

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 (38) hide show
  1. package/dist/assets/index-C_av7KDW.js +4190 -0
  2. package/dist/assets/index-DMoT8G1q.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/entry.tsx +6 -27
  5. package/package.json +3 -2
  6. package/src/config.ts +11 -27
  7. package/src/demo/demoFetch.ts +0 -9
  8. package/src/nav/Navbar.tsx +3 -3
  9. package/src/session.ts +8 -43
  10. package/src/stations/GenerateStation.tsx +8 -5
  11. package/src/stations/SharePanel.tsx +4 -4
  12. package/src/stations/ToksPanel.tsx +1 -2
  13. package/src/stations/WorkbenchSidebar.tsx +8 -261
  14. package/src/stations/ops/CharacterGroupsPanel.tsx +75 -83
  15. package/src/stations/studio/IdentitiesStation.tsx +66 -75
  16. package/src/stations/studio/StudioGenerateMode.tsx +3 -2
  17. package/src/stations/studio/StudioGenerateTab.tsx +258 -188
  18. package/src/stations/studio/StudioMovieComposer.tsx +320 -57
  19. package/src/stations/studio/StudioPlane.tsx +3 -1
  20. package/src/stations/studio/StudioViewer.tsx +6 -11
  21. package/src/stations/studio/studioShared.tsx +14 -2
  22. package/src/stations/studio/useIdentityCreateJob.ts +26 -21
  23. package/src/stations/studio/useReconstruction.ts +11 -5
  24. package/src/video/PromptAssistButtons.tsx +5 -6
  25. package/src/video/jobStream.ts +119 -0
  26. package/src/video/jobTracker.ts +67 -76
  27. package/src/video/modelsFeed.ts +105 -0
  28. package/src/video/studioOracle.ts +48 -0
  29. package/src/video/useMediaJobs.ts +17 -3
  30. package/src/video/useModels.ts +12 -53
  31. package/src/video/useMovieSessions.ts +15 -6
  32. package/src/video/useOperatorHistory.ts +11 -101
  33. package/src/video/usePromptAssist.ts +6 -3
  34. package/src/video/useToks.ts +14 -6
  35. package/src/video/watchVideoJob.ts +43 -0
  36. package/dist/assets/index-DuT1AMLn.css +0 -1
  37. package/dist/assets/index-_13ZiBDP.js +0 -4191
  38. package/src/video/useWorkerCalls.ts +0 -165
@@ -0,0 +1,105 @@
1
+ // SHARED model catalog feed (2026-10-05) — the models twin of jobStream.ts.
2
+ // Every model picker on the page SEEDS from ONE shared GET of /v1/models and
3
+ // /prompt/tasks (the first hook to ask fetches; the rest reuse that answer), then
4
+ // subscribes here. One EventSource on /api/llm/models/rows/stream (core pushes a
5
+ // row the moment a box's `models` / `residents` row changes — DB trigger ->
6
+ // pg_notify) marks the cached answer stale and tells subscribers to re-read it.
7
+ // No focus/visibility revalidation and no timer: the catalog moves only when the
8
+ // DB says it moved. Same reconnect rule as jobStream: re-attach only after a
9
+ // stream that HAD opened drops (the server caps it at an hour).
10
+ import { hugpyConfig } from "../config";
11
+ import { request } from "../transport/client";
12
+
13
+ type Result = Awaited<ReturnType<typeof request<unknown>>>;
14
+ type Listener = () => void;
15
+
16
+ const cache = new Map<string, Promise<Result>>();
17
+ const listeners = new Set<Listener>();
18
+ let es: EventSource | null = null;
19
+ let refs = 0;
20
+ let everOpened = false;
21
+ let retryTimer: ReturnType<typeof setTimeout> | null = null;
22
+ let notifyTimer: ReturnType<typeof setTimeout> | null = null;
23
+ const RETRY_MS = 5000;
24
+ const COALESCE_MS = 500; // one action touches several rows; re-read once
25
+
26
+ /** The shared answer for `url` (one request for the whole page until the DB
27
+ * reports a change). A failed answer is not kept, so the next caller retries. */
28
+ export function sharedRequest(url: string, meta: Record<string, string>): Promise<Result> {
29
+ let p = cache.get(url);
30
+ if (!p) {
31
+ p = request<unknown>(url, { meta } as Parameters<typeof request>[1]);
32
+ cache.set(url, p);
33
+ p.then((r) => {
34
+ if (!r.ok && cache.get(url) === p) cache.delete(url);
35
+ });
36
+ }
37
+ return p;
38
+ }
39
+
40
+ function changed(): void {
41
+ if (notifyTimer) return;
42
+ notifyTimer = setTimeout(() => {
43
+ notifyTimer = null;
44
+ cache.clear();
45
+ for (const fn of listeners) {
46
+ try {
47
+ fn();
48
+ } catch {
49
+ /* one bad subscriber must not stall the rest */
50
+ }
51
+ }
52
+ }, COALESCE_MS);
53
+ }
54
+
55
+ function openStream(): void {
56
+ if (es || typeof window === "undefined" || typeof EventSource === "undefined") return;
57
+ let source: EventSource;
58
+ try {
59
+ source = new EventSource(`${hugpyConfig.apiBase}/llm/models/rows/stream`, {
60
+ withCredentials: true,
61
+ });
62
+ } catch {
63
+ return; // no EventSource — pickers keep their one-time seed
64
+ }
65
+ es = source;
66
+ source.onopen = () => {
67
+ everOpened = true;
68
+ };
69
+ source.onmessage = (e: MessageEvent) => {
70
+ let ev: { stage?: string };
71
+ try {
72
+ ev = JSON.parse(e.data);
73
+ } catch {
74
+ return;
75
+ }
76
+ if (ev.stage === "stream.ready") return; // transport hello, not a row
77
+ changed();
78
+ };
79
+ source.onerror = () => {
80
+ source.close();
81
+ es = null;
82
+ if (!everOpened || refs === 0 || retryTimer) return; // absent endpoint: stay on the seed
83
+ retryTimer = setTimeout(() => {
84
+ retryTimer = null;
85
+ if (refs > 0) openStream();
86
+ }, RETRY_MS);
87
+ };
88
+ }
89
+
90
+ /** Call `fn` whenever the DB reports a model-row change. Returns the unsubscribe. */
91
+ export function subscribeModelChanges(fn: Listener): () => void {
92
+ listeners.add(fn);
93
+ refs += 1;
94
+ openStream();
95
+ return () => {
96
+ listeners.delete(fn);
97
+ refs -= 1;
98
+ if (refs <= 0) {
99
+ refs = 0;
100
+ es?.close();
101
+ es = null;
102
+ everOpened = false;
103
+ }
104
+ };
105
+ }
@@ -0,0 +1,48 @@
1
+ import { hugpyConfig } from "../config";
2
+ import { request, okValue, errorOf, describeAppError } from "../transport/client";
3
+
4
+ export type OracleSettings = {
5
+ model_id?: string;
6
+ width?: number;
7
+ height?: number;
8
+ fps?: number;
9
+ frames?: number;
10
+ steps?: number;
11
+ cfg?: number;
12
+ seed?: number;
13
+ };
14
+
15
+ export async function planStudioSettings(
16
+ capability: "t2v" | "i2v",
17
+ prompt: string,
18
+ settings: OracleSettings,
19
+ explicit: string[],
20
+ ): Promise<OracleSettings> {
21
+ const res = await request<unknown>(hugpyConfig.studioPlanUrl, {
22
+ method: "POST",
23
+ headers: { "Content-Type": "application/json" },
24
+ body: JSON.stringify({ capability, prompt, settings, explicit_settings: explicit }),
25
+ meta: { specKey: "studio", operation: "studio.oracle.plan" },
26
+ });
27
+ if (!res.ok) throw new Error(describeAppError(errorOf(res)));
28
+ const result = okValue(res) as { settings?: OracleSettings };
29
+ if (!result || !result.settings || !result.settings.model_id) {
30
+ throw new Error("Oracle returned no executable Studio settings plan.");
31
+ }
32
+ return result.settings;
33
+ }
34
+
35
+ export async function planStudioMovie(body: Record<string, unknown>): Promise<Record<string, unknown>> {
36
+ const res = await request<unknown>(hugpyConfig.studioPlanUrl, {
37
+ method: "POST",
38
+ headers: { "Content-Type": "application/json" },
39
+ body: JSON.stringify(body),
40
+ meta: { specKey: "studio", operation: "studio.oracle.movie.plan" },
41
+ });
42
+ if (!res.ok) throw new Error(describeAppError(errorOf(res)));
43
+ const result = okValue(res) as { body?: Record<string, unknown>; planning?: unknown[] };
44
+ if (!result || !result.body || !Array.isArray(result.planning)) {
45
+ throw new Error("Oracle returned no executable Cinema plan.");
46
+ }
47
+ return result.body;
48
+ }
@@ -12,13 +12,13 @@
12
12
  import { useCallback, useEffect, useRef, useState } from "react";
13
13
  import { hugpyConfig } from "../config";
14
14
  import { request, okValue } from "../transport/client";
15
+ import { subscribeJobStream } from "./jobStream";
15
16
  // The stage-timeline + failure shapes are the SAME wire objects the studio tab
16
17
  // already renders through `ProcessTimeline`; reusing its types (and, in the panel,
17
18
  // the component itself) keeps one renderer for "what it did / where it broke"
18
19
  // instead of a second, divergent one for the console-wide view.
19
20
  import type { StageEntry, JobFailure } from "../stations/studio/studioShared";
20
21
 
21
- const POLL_MS = 2000;
22
22
  const IN_FLIGHT = new Set(["queued", "claimed", "running", "cancelling"]);
23
23
 
24
24
  /** The numbers behind the bar (omit-when-unset on the wire). */
@@ -230,13 +230,27 @@ export function useMediaJobs(
230
230
  setJobs(parseJobs(okValue(r), includeTerminal));
231
231
  }, [includeTerminal]);
232
232
 
233
+ // Seed ONCE, then refresh on PUSH: a worker-reported transition arrives on the
234
+ // shared job stream and we re-read the catalog projection once (coalesced), so
235
+ // placement / stage-log / phase stay the server's list shape — never a timer.
233
236
  useEffect(() => {
234
237
  mounted.current = true;
235
238
  void load();
236
- const id = window.setInterval(() => void load(), POLL_MS);
239
+ let coalesce: number | null = null;
240
+ const unsub = subscribeJobStream(() => {
241
+ if (coalesce != null) return; // already a reload pending this burst
242
+ coalesce = window.setTimeout(() => {
243
+ coalesce = null;
244
+ void load();
245
+ }, 300);
246
+ });
247
+ const onFocus = () => { void load(); };
248
+ window.addEventListener("focus", onFocus);
237
249
  return () => {
238
250
  mounted.current = false;
239
- window.clearInterval(id);
251
+ if (coalesce != null) window.clearTimeout(coalesce);
252
+ unsub();
253
+ window.removeEventListener("focus", onFocus);
240
254
  };
241
255
  }, [load]);
242
256
 
@@ -7,8 +7,7 @@
7
7
  // Staleness fix: the registry is no longer fetched only once on mount. Models
8
8
  // adopted MID-SESSION (e.g. ae ComfyUI checkpoints that appear once ComfyUI comes
9
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
10
+ // stale-while-revalidates — it re-fetches on window focus / tab visibility and on demand via
12
11
  // the exposed `refresh()`. Background revalidations never blank a working picker:
13
12
  // `loading` flips only for the very first load, and a failed poll keeps the last
14
13
  // good list. The public surface gained `refresh` (additive — existing consumers
@@ -17,6 +16,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
17
16
  import { z } from "zod";
18
17
  import { request, okValue, errorOf, describeAppError } from "../transport/client";
19
18
  import { hugpyConfig } from "../config";
19
+ import { sharedRequest, subscribeModelChanges } from "./modelsFeed";
20
20
 
21
21
  export interface ModelOption {
22
22
  /** The model id sent to the backend. */
@@ -119,10 +119,6 @@ function nonTargetReason(m: z.infer<typeof modelEntrySchema>): string {
119
119
  return "Unclassified — no task derived from this model's files yet.";
120
120
  }
121
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
122
 
127
123
  // Capability = the FULL `tasks` list; the single `task` (primary) is only the
128
124
  // fallback for older payloads. Exported so task-specific pickers (e.g. the Movie
@@ -167,7 +163,7 @@ export function useImageModels(): ImageModelsState {
167
163
  });
168
164
 
169
165
  // `mounted` gates setState after unmount; `inFlight` collapses overlapping
170
- // revalidations (interval + focus + manual) into one; `ctrlRef` lets every
166
+ // revalidations (focus + manual) into one; `ctrlRef` lets every
171
167
  // trigger — including a manual refresh — abort on unmount.
172
168
  const mounted = useRef(true);
173
169
  const inFlight = useRef(false);
@@ -176,17 +172,10 @@ export function useImageModels(): ImageModelsState {
176
172
  const load = useCallback(async (background: boolean) => {
177
173
  if (inFlight.current) return;
178
174
  inFlight.current = true;
179
- const signal = ctrlRef.current?.signal;
180
175
  try {
181
176
  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
- }),
177
+ sharedRequest(hugpyConfig.modelsUrl, { specKey: "frames", operation: "models.list" }),
178
+ sharedRequest(hugpyConfig.promptTasksUrl, { specKey: "frames", operation: "prompt.tasks" }),
190
179
  ]);
191
180
  if (!mounted.current) return;
192
181
 
@@ -284,28 +273,13 @@ export function useImageModels(): ImageModelsState {
284
273
  // Foreground load (the only one that toggles `loading` / surfaces an error).
285
274
  void load(false);
286
275
 
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);
276
+ // Re-read only when the DB reports a model-row change (modelsFeed).
277
+ const off = subscribeModelChanges(() => void load(true));
302
278
 
303
279
  return () => {
304
280
  mounted.current = false;
305
281
  ctrl.abort();
306
- window.removeEventListener("focus", revalidate);
307
- document.removeEventListener("visibilitychange", revalidate);
308
- clearInterval(timer);
282
+ off();
309
283
  };
310
284
  }, [load]);
311
285
 
@@ -328,7 +302,7 @@ export interface TaskModelsState {
328
302
  * task — the generic sibling of useImageModels for non-generation pickers. The
329
303
  * Movie director's judge-model select uses it with "image-text-to-text" to surface
330
304
  * the vision-language models that can SCORE a segment. Same tolerant fetch +
331
- * stale-while-revalidate posture (focus / visibility / interval / manual refresh);
305
+ * stale-while-revalidate posture (focus / visibility / manual refresh);
332
306
  * a failed background revalidation keeps the last good list. No task-defaults fetch
333
307
  * (there is no canonical default for an arbitrary task) — the caller supplies its
334
308
  * own default and keeps it selectable even when the list is empty.
@@ -348,12 +322,8 @@ export function useModelsByTask(task: string): TaskModelsState {
348
322
  async (background: boolean) => {
349
323
  if (inFlight.current) return;
350
324
  inFlight.current = true;
351
- const signal = ctrlRef.current?.signal;
352
325
  try {
353
- const mRes = await request<unknown>(hugpyConfig.modelsUrl, {
354
- signal,
355
- meta: { specKey: "generate", operation: "models.list" },
356
- });
326
+ const mRes = await sharedRequest(hugpyConfig.modelsUrl, { specKey: "generate", operation: "models.list" });
357
327
  if (!mounted.current) return;
358
328
  if (!mRes.ok) {
359
329
  if (background) return;
@@ -386,22 +356,11 @@ export function useModelsByTask(task: string): TaskModelsState {
386
356
  const ctrl = new AbortController();
387
357
  ctrlRef.current = ctrl;
388
358
  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);
359
+ const off = subscribeModelChanges(() => void load(true));
399
360
  return () => {
400
361
  mounted.current = false;
401
362
  ctrl.abort();
402
- window.removeEventListener("focus", revalidate);
403
- document.removeEventListener("visibilitychange", revalidate);
404
- clearInterval(timer);
363
+ off();
405
364
  };
406
365
  }, [load]);
407
366
 
@@ -23,10 +23,7 @@ import {
23
23
  studioMovieResumeUrl,
24
24
  } from "../config";
25
25
  import { request, okValue, errorOf, describeAppError } from "../transport/client";
26
-
27
- // 6s — the same background cadence useStudioClips polls the clip catalog at. A movie
28
- // segment takes minutes, so anything faster only costs a filesystem walk.
29
- const POLL_MS = 6000;
26
+ import { subscribeJobStream } from "./jobStream";
30
27
 
31
28
  // The bus statuses that mean "this session's job is still going to do something" —
32
29
  // the client-side mirror of the route's _MOVIE_INFLIGHT_STATES, used to decide
@@ -223,11 +220,23 @@ export function useMovieSessions(active: boolean): MovieSessionsState {
223
220
  }
224
221
  }, []);
225
222
 
223
+ // Seed once when the panel opens, then re-read the movies root only when a
224
+ // worker reports a job transition (coalesced) — no 6s filesystem-walk poll.
226
225
  useEffect(() => {
227
226
  if (!active) return;
228
227
  void load();
229
- const id = window.setInterval(() => void load(), POLL_MS);
230
- return () => window.clearInterval(id);
228
+ let coalesce: number | null = null;
229
+ const unsub = subscribeJobStream(() => {
230
+ if (coalesce != null) return;
231
+ coalesce = window.setTimeout(() => {
232
+ coalesce = null;
233
+ void load();
234
+ }, 400);
235
+ });
236
+ return () => {
237
+ if (coalesce != null) window.clearTimeout(coalesce);
238
+ unsub();
239
+ };
231
240
  }, [active, load]);
232
241
 
233
242
  // Pause and resume differ only in their url and their telemetry op, so they share
@@ -1,105 +1,15 @@
1
- // OPERATOR HISTORY HYDRATION (operator ask 2026-08-13): the superadmin gets
2
- // COMPLETE visibility of previous renders in the Session Library — not just
3
- // what this browser tab produced. The library itself stays a per-session
4
- // sessionStorage working set (by design); this hook back-fills it from the
5
- // SERVER record once per session for an operator principal:
1
+ // OPERATOR HISTORY HYDRATION — RETIRED (2026-10-05).
6
2
  //
7
- // * probe: GET keysVideoShareUrl — the same 200⇒operator / 401⇒guest probe
8
- // SharePanel uses (members and share guests never hydrate; their lists
9
- // stay owner-scoped server-side).
10
- // * source: GET /video/jobs?all=1 for terminal done generate_image /
11
- // generate_scene / generate_movie rows (the list projection strips
12
- // results, so each job detail is fetched for its outputs). Clips
13
- // (studio_i2v) already hydrate via useStudioClips poll — not repeated.
14
- // * sink: addToLibrary — idempotent by uri, tombstone-respecting (an item
15
- // the operator removed stays removed), genKind-tagged so the Generate
16
- // station can filter the strips by its CURRENT SECTION.
17
- //
18
- // Once-per-session guard in sessionStorage; canned demo never hydrates.
19
- import { useEffect } from "react";
20
- import { request, okValue } from "../transport/client";
21
- import { hugpyConfig, jobStatusUrl } from "../config";
22
- import { addToLibrary, type LibraryGenKind } from "./mediaLibrary";
23
- import { getSessionId } from "../session";
24
- import { isCanned } from "../demo/mode";
25
- import type { MediaRef } from "./contract";
26
-
27
- // generate_studio_movie = CINEMA renders — hydrated too (mapped onto the
28
- // generate_movie library kind: same Videos shelf, no schema change).
29
- const GEN_KINDS: readonly string[] = [
30
- "generate_image", "generate_scene", "generate_movie", "generate_studio_movie",
31
- ];
32
- // v2 (2026-08-13): the once-per-session flag must change whenever the
33
- // HYDRATED KINDS change — a tab that hydrated v1 (no cinema jobs) kept its
34
- // flag through every reload and never saw generate_studio_movie history.
35
- const FLAG_PREFIX = "vi.operatorHistory.v2";
36
- const DETAIL_CAP = 150; // newest-first; enough history without hammering the API
37
-
38
- function flagKey(): string {
39
- try { return `${FLAG_PREFIX}:${getSessionId()}`; } catch { return FLAG_PREFIX; }
40
- }
3
+ // This hook back-filled the Session Library from the server record once per
4
+ // session, but ONLY for a console operator, and it decided operator-ness by
5
+ // probing GET /keys/video-share — the same 200⇒operator / 401⇒guest probe
6
+ // SharePanel used. That route has no backend in this build: the probe only ever
7
+ // 404'd, so the hydration never ran. With no operator signal to replace it (and
8
+ // per the directive to drop the dead /keys/video-share calls), this is now a
9
+ // no-op rather than a dead request fired on every page load. The exported
10
+ // signature is kept so its one caller stays valid.
41
11
 
12
+ /** No-op: operator history hydration is retired (see the file header). */
42
13
  export function useOperatorHistory(): void {
43
- useEffect(() => {
44
- if (isCanned()) return;
45
- try { if (sessionStorage.getItem(flagKey())) return; } catch { /* no-op */ }
46
- let cancelled = false;
47
- void (async () => {
48
- // Operator probe — non-operators (401/403) never hydrate.
49
- const probe = await request<unknown>(hugpyConfig.keysVideoShareUrl, {
50
- meta: { specKey: "media", operation: "operator.probe" },
51
- });
52
- if (cancelled || !probe.ok) return;
53
- // jobStatusUrl("") = `${apiBase}/video/jobs/` — trim the slash and query
54
- // the list route with terminal rows included.
55
- const list = await request<unknown>(
56
- `${jobStatusUrl("").replace(/\/$/, "")}?all=1&limit=300`,
57
- { meta: { specKey: "media", operation: "history.jobs.list" } },
58
- );
59
- if (cancelled || !list.ok) return;
60
- const jobs = ((okValue(list) as { jobs?: unknown[] })?.jobs ?? []) as Array<
61
- Record<string, unknown>
62
- >;
63
- const wanted = jobs
64
- .filter((j) => j?.status === "done" && GEN_KINDS.includes(String(j?.name)))
65
- .slice(0, DETAIL_CAP);
66
- for (const j of wanted) {
67
- if (cancelled) return;
68
- const id = String(j.job_id ?? j.id ?? "");
69
- if (!id) continue;
70
- const det = await request<unknown>(jobStatusUrl(id), {
71
- meta: { specKey: "media", operation: "history.job.detail" },
72
- });
73
- if (!det.ok) continue;
74
- const result = (okValue(det) as { result?: { outputs?: unknown[] } })?.result;
75
- for (const o of (result?.outputs ?? []) as Array<Record<string, unknown>>) {
76
- if (!o?.uri || !o?.asset_id) continue;
77
- const ref = {
78
- asset_id: String(o.asset_id),
79
- kind: (o.kind === "video" ? "video" : "image"),
80
- uri: String(o.uri),
81
- mime: (o.mime as string) ?? (o.kind === "video" ? "video/mp4" : "image/png"),
82
- width: (o.width as number) ?? null,
83
- height: (o.height as number) ?? null,
84
- duration_s: (o.duration_s as number) ?? null,
85
- fps_native: (o.fps_native as number) ?? null,
86
- channels: (o.channels as number) ?? null,
87
- sample_rate: (o.sample_rate as number) ?? null,
88
- } as MediaRef;
89
- addToLibrary(ref, "history", `${String(j.name)} · ${id.slice(0, 8)}`, {
90
- genKind: (j.name === "generate_studio_movie"
91
- ? "generate_movie"
92
- : j.name) as LibraryGenKind,
93
- // groupId = the producing job: each historical run groups as its own
94
- // work (like live pushes) instead of one giant shared "history" pile
95
- // whose identical key collided ACROSS the type sections (expanding
96
- // Videos/history also expanded Images/history).
97
- groupId: id,
98
- });
99
- }
100
- }
101
- try { sessionStorage.setItem(flagKey(), String(Date.now())); } catch { /* no-op */ }
102
- })();
103
- return () => { cancelled = true; };
104
- }, []);
14
+ /* intentionally empty — no /keys/video-share probe, no history back-fill */
105
15
  }
@@ -37,7 +37,7 @@ export type AssistMode = "detail" | "generate";
37
37
  export type AssistBusyMode = AssistMode | "spread" | "negative";
38
38
 
39
39
  /** The active sub-mode, sent as `context.kind` so video modes get motion/camera phrasing. */
40
- export type AssistKind = "image" | "scene" | "movie";
40
+ export type AssistKind = "image" | "scene" | "movie" | "clip";
41
41
 
42
42
  /** The `specKey` a host's telemetry rides under (matches the host's other calls). */
43
43
  type SpecKey = "generate" | "studio";
@@ -53,11 +53,13 @@ export interface UsePromptAssistOptions {
53
53
  export interface AssistModel {
54
54
  /** Catalog key sent back as `body.model`. */
55
55
  model: string;
56
- /** Seated on a worker right now (answers in ~1s) vs present but cold (first call loads it). */
56
+ /** Seated on a worker right now. */
57
57
  serving: boolean;
58
+ /** Present on a worker, but not necessarily loaded. */
59
+ held?: boolean;
58
60
  /** Canonical residency (STATE-MODEL.md #4): serving|loaded = ready; hot = on a
59
61
  * worker drive (pays a t_load); cold = central only (pays a download + load). */
60
- state?: "serving" | "loaded" | "hot" | "cold";
62
+ state?: "serving" | "loaded" | "hot" | "cold" | "unknown";
61
63
  framework?: string | null;
62
64
  /** True for the fleet default — used when the user has never chosen. */
63
65
  default?: boolean;
@@ -183,6 +185,7 @@ async function assistCall(
183
185
  const value = okValue(res) as { ticket?: string; pending?: boolean } | null;
184
186
  const ticket = value?.ticket;
185
187
  if (!ticket) return res; // direct (non-ticket) result — serve as-is
188
+ if (value?.pending === false) return res; // current orbit returns the completed result with its ticket
186
189
  const deadline = Date.now() + TICKET_CEILING_MS;
187
190
  for (;;) {
188
191
  await new Promise((r) => setTimeout(r, TICKET_POLL_MS));
@@ -15,8 +15,8 @@
15
15
  import { useCallback, useEffect, useRef, useState } from "react";
16
16
  import { hugpyConfig } from "../config";
17
17
  import { request, okValue } from "../transport/client";
18
+ import { subscribeJobStream } from "./jobStream";
18
19
 
19
- const POLL_MS = 2000;
20
20
  const RECENT_LIMIT = 100;
21
21
 
22
22
  /** One recent inference sample (omit-when-unset tolerated on the wire). */
@@ -127,22 +127,30 @@ export function useToks(): {
127
127
  setLive(recent.ok || report.ok);
128
128
  }, []);
129
129
 
130
+ // Seed once, then refresh on PUSH: the shared job stream fires on every
131
+ // worker-reported transition (coalesced), so the tok/s tail tracks live work
132
+ // without a 2s timer. No job activity → the last-good snapshot simply stands.
130
133
  useEffect(() => {
131
134
  mounted.current = true;
132
135
  void load();
133
- const id = window.setInterval(() => void load(), POLL_MS);
136
+ let coalesce: number | null = null;
137
+ const unsub = subscribeJobStream(() => {
138
+ if (coalesce != null) return;
139
+ coalesce = window.setTimeout(() => {
140
+ coalesce = null;
141
+ void load();
142
+ }, 400);
143
+ });
134
144
  return () => {
135
145
  mounted.current = false;
136
- window.clearInterval(id);
146
+ if (coalesce != null) window.clearTimeout(coalesce);
147
+ unsub();
137
148
  };
138
149
  }, [load]);
139
150
 
140
151
  return { entries, groups, live };
141
152
  }
142
153
 
143
- /** The poll cadence, exported so the panel header can label it ("every 2s"). */
144
- export const TOKS_POLL_MS = POLL_MS;
145
-
146
154
  /** "12s ago" / "3m ago" from an epoch-seconds ts, "" when unknown. */
147
155
  export function tokAgo(ts: number | null, nowMs: number): string {
148
156
  if (ts == null) return "";
@@ -0,0 +1,43 @@
1
+ /** One status seed followed by pushed job transitions. No periodic GET loop. */
2
+ import { videoJobUrl } from "../config";
3
+ import { request, okValue } from "../transport/client";
4
+ import { subscribeJobStream } from "./jobStream";
5
+
6
+ export function watchVideoJob(
7
+ jobId: string,
8
+ onJob: (job: Record<string, unknown>) => boolean | void,
9
+ onTimeout: () => void,
10
+ timeoutMs: number,
11
+ ): () => void {
12
+ let active = true;
13
+ let pushed = false;
14
+ let unsubscribe: (() => void) | null = null;
15
+ const deadline = Date.now() + timeoutMs;
16
+ const timer = window.setTimeout(() => {
17
+ if (!active || Date.now() < deadline) return;
18
+ stop();
19
+ onTimeout();
20
+ }, timeoutMs);
21
+ const stop = () => {
22
+ if (!active) return;
23
+ active = false;
24
+ window.clearTimeout(timer);
25
+ unsubscribe?.();
26
+ unsubscribe = null;
27
+ };
28
+ const deliver = (row: unknown) => {
29
+ if (!active || !row || typeof row !== "object") return;
30
+ if (onJob(row as Record<string, unknown>) === true) stop();
31
+ };
32
+ unsubscribe = subscribeJobStream((ev) => {
33
+ if (ev.job_id !== jobId || !ev.job) return;
34
+ pushed = true;
35
+ deliver(ev.job);
36
+ });
37
+ void request<unknown>(videoJobUrl(jobId), {
38
+ meta: { specKey: "studio", operation: "video.job.seed" },
39
+ }).then((res) => {
40
+ if (res.ok && !pushed) deliver(okValue(res));
41
+ });
42
+ return stop;
43
+ }