@hugpy/video-intelligence-ui 0.3.2 → 0.3.4

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 (152) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +9 -0
  3. package/dist/assets/hugpy-mark-BXktFcOQ.png +0 -0
  4. package/dist/assets/index-DMoT8G1q.css +1 -0
  5. package/dist/assets/index-GeD1mo16.js +4190 -0
  6. package/dist/index.html +71 -0
  7. package/entry.tsx +6 -27
  8. package/index.html +4 -4
  9. package/package.json +44 -5
  10. package/src/assets/hugpy-mark.png +0 -0
  11. package/src/config.ts +11 -27
  12. package/src/demo/DemoBanner.tsx +0 -0
  13. package/src/demo/demo.css +0 -0
  14. package/src/demo/demoFetch.ts +0 -9
  15. package/src/demo/fixtures.ts +0 -0
  16. package/src/demo/identityFixture.ts +0 -0
  17. package/src/demo/index.ts +0 -0
  18. package/src/demo/mediaBase.ts +0 -0
  19. package/src/demo/mode.ts +0 -0
  20. package/src/demo/promptAssist.ts +0 -0
  21. package/src/demo/sampleData.ts +0 -0
  22. package/src/demo/sampleSets.ts +0 -0
  23. package/src/demo/seed.ts +0 -0
  24. package/src/nav/BrandMark.css +0 -0
  25. package/src/nav/BrandMark.tsx +0 -0
  26. package/src/nav/Navbar.css +0 -0
  27. package/src/nav/Navbar.tsx +8 -3
  28. package/src/regions/SpatialRegionEditor.tsx +0 -0
  29. package/src/regions/TemporalRegionEditor.tsx +0 -0
  30. package/src/regions/types.ts +0 -0
  31. package/src/session.ts +8 -43
  32. package/src/share.ts +0 -0
  33. package/src/stations/ActivePanel.tsx +0 -0
  34. package/src/stations/ActiveProcessesStation.tsx +0 -0
  35. package/src/stations/AddFileBar.tsx +0 -0
  36. package/src/stations/AudioCropStation.tsx +0 -0
  37. package/src/stations/ComfyPanel.tsx +0 -0
  38. package/src/stations/ComfyStation.tsx +0 -0
  39. package/src/stations/CondensedKnobStrip.tsx +0 -0
  40. package/src/stations/ConsolePanel.tsx +0 -0
  41. package/src/stations/FrameExtractStation.tsx +0 -0
  42. package/src/stations/GenIdentityBar.tsx +0 -0
  43. package/src/stations/GenerateStation.tsx +10 -6
  44. package/src/stations/GoalComposer.tsx +0 -0
  45. package/src/stations/ImageCropStation.tsx +0 -0
  46. package/src/stations/KnobInfo.tsx +0 -0
  47. package/src/stations/PlaceholderStation.tsx +0 -0
  48. package/src/stations/ScriptFirstStation.tsx +4 -0
  49. package/src/stations/SectionTabs.tsx +0 -0
  50. package/src/stations/SharePanel.tsx +4 -4
  51. package/src/stations/SidebarRegistry.tsx +0 -0
  52. package/src/stations/StationShell.tsx +0 -0
  53. package/src/stations/StudioClipsStation.tsx +0 -0
  54. package/src/stations/TemplateStepsPanel.tsx +190 -0
  55. package/src/stations/ToksPanel.tsx +1 -2
  56. package/src/stations/WorkbenchDrawer.tsx +0 -0
  57. package/src/stations/WorkbenchSidebar.tsx +8 -261
  58. package/src/stations/WorkbenchStation.tsx +0 -0
  59. package/src/stations/comfyEmbed.ts +0 -0
  60. package/src/stations/drawerShell.tsx +0 -0
  61. package/src/stations/ops/AudioCropCore.tsx +0 -0
  62. package/src/stations/ops/CharacterGroupsPanel.tsx +75 -83
  63. package/src/stations/ops/FrameExtractCore.tsx +0 -0
  64. package/src/stations/ops/ImageCropCore.tsx +0 -0
  65. package/src/stations/ops/VideoCropCore.tsx +0 -0
  66. package/src/stations/ops/useVideoCropJobs.ts +0 -0
  67. package/src/stations/oracle/DagPanel.tsx +0 -0
  68. package/src/stations/oracle/OracleStation.tsx +2 -0
  69. package/src/stations/oracle/PerformanceComposer.tsx +119 -0
  70. package/src/stations/oracle/SpatialTab.tsx +0 -0
  71. package/src/stations/oracle/StewardPanel.tsx +0 -0
  72. package/src/stations/oracle/oracle.css +33 -0
  73. package/src/stations/oracle/oracleShared.tsx +0 -0
  74. package/src/stations/oracle/oracleTypes.ts +0 -0
  75. package/src/stations/oracle/useOracle.ts +0 -0
  76. package/src/stations/pollCaps.ts +0 -0
  77. package/src/stations/registry.ts +0 -0
  78. package/src/stations/scriptFirst.css +23 -0
  79. package/src/stations/studio/AngleRingEditor.tsx +0 -0
  80. package/src/stations/studio/IdentitiesStation.tsx +86 -76
  81. package/src/stations/studio/IdentityCreatePanel.tsx +0 -0
  82. package/src/stations/studio/IdentityProfileControls.tsx +0 -0
  83. package/src/stations/studio/StudioActiveProcesses.tsx +0 -0
  84. package/src/stations/studio/StudioAssistLogPanel.tsx +0 -0
  85. package/src/stations/studio/StudioGenerateMode.tsx +3 -2
  86. package/src/stations/studio/StudioGenerateTab.tsx +278 -190
  87. package/src/stations/studio/StudioItemCard.tsx +0 -0
  88. package/src/stations/studio/StudioLibraryTab.tsx +0 -0
  89. package/src/stations/studio/StudioMovieComposer.tsx +321 -46
  90. package/src/stations/studio/StudioMovieSessions.tsx +12 -19
  91. package/src/stations/studio/StudioPlane.tsx +28 -5
  92. package/src/stations/studio/StudioSplicedRow.tsx +0 -0
  93. package/src/stations/studio/StudioViewer.tsx +6 -11
  94. package/src/stations/studio/TurntableViewer.tsx +0 -0
  95. package/src/stations/studio/coordinationBadge.tsx +0 -0
  96. package/src/stations/studio/identityUpload.ts +0 -0
  97. package/src/stations/studio/movieTimeline.ts +0 -0
  98. package/src/stations/studio/promptCard.tsx +0 -0
  99. package/src/stations/studio/studioItem.ts +0 -0
  100. package/src/stations/studio/studioShared.tsx +14 -2
  101. package/src/stations/studio/useIdentityCreateJob.ts +26 -21
  102. package/src/stations/studio/useIdentityProfiles.ts +0 -0
  103. package/src/stations/studio/useReconstruction.ts +11 -5
  104. package/src/stations/types.ts +0 -0
  105. package/src/stations/useAudioCropJobs.ts +0 -0
  106. package/src/stations/useAudioExtractJob.ts +0 -0
  107. package/src/stations/useCropJobs.ts +0 -0
  108. package/src/stations/useFrameJobs.ts +0 -0
  109. package/src/stations/useGenerateJob.ts +0 -0
  110. package/src/stations/useGenerateMovieJob.ts +0 -0
  111. package/src/stations/useGenerateRowJobs.ts +0 -0
  112. package/src/stations/useGenerateSceneJob.ts +0 -0
  113. package/src/stations/useGenerateSceneRowJobs.ts +0 -0
  114. package/src/stations/useScriptFirstRun.ts +0 -0
  115. package/src/style/app.css +49 -0
  116. package/src/transport/client.ts +0 -0
  117. package/src/transport/telemetry.ts +0 -0
  118. package/src/video/DropReceptacle.tsx +0 -0
  119. package/src/video/LengthRow.tsx +0 -0
  120. package/src/video/LibraryGroups.tsx +0 -0
  121. package/src/video/PromptAssistButtons.tsx +5 -4
  122. package/src/video/UnnamedWarn.tsx +0 -0
  123. package/src/video/composerBridge.ts +0 -0
  124. package/src/video/contract.ts +0 -0
  125. package/src/video/coordinationReport.ts +0 -0
  126. package/src/video/jobClock.ts +0 -0
  127. package/src/video/jobStream.ts +119 -0
  128. package/src/video/jobTracker.ts +67 -76
  129. package/src/video/mediaLibrary.ts +0 -0
  130. package/src/video/modelsFeed.ts +105 -0
  131. package/src/video/renderCompat.ts +0 -0
  132. package/src/video/sessionForm.ts +0 -0
  133. package/src/video/spreadAssist.ts +0 -0
  134. package/src/video/studioBridge.ts +0 -0
  135. package/src/video/studioOracle.ts +48 -0
  136. package/src/video/useMediaJobs.ts +17 -3
  137. package/src/video/useModels.ts +12 -53
  138. package/src/video/useMoviePresets.ts +0 -0
  139. package/src/video/useMovieSessions.ts +15 -6
  140. package/src/video/useOperatorHistory.ts +11 -101
  141. package/src/video/usePresets.ts +0 -0
  142. package/src/video/useProjects.ts +0 -0
  143. package/src/video/usePromptAssist.ts +70 -25
  144. package/src/video/useRenderPresets.ts +0 -0
  145. package/src/video/useStudioAssistLog.ts +0 -0
  146. package/src/video/useStudioPresets.ts +0 -0
  147. package/src/video/useToks.ts +14 -6
  148. package/src/video/watchVideoJob.ts +43 -0
  149. package/tsconfig.json +0 -0
  150. package/vite.config.ts +0 -0
  151. package/src/stations/studio/StudioMovieComposer.tsx.bak-declutter-20260813 +0 -3031
  152. package/src/video/useWorkerCalls.ts +0 -165
@@ -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
  }
File without changes
File without changes
@@ -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,8 +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;
60
+ /** Canonical residency (STATE-MODEL.md #4): serving|loaded = ready; hot = on a
61
+ * worker drive (pays a t_load); cold = central only (pays a download + load). */
62
+ state?: "serving" | "loaded" | "hot" | "cold" | "unknown";
58
63
  framework?: string | null;
59
64
  /** True for the fleet default — used when the user has never chosen. */
60
65
  default?: boolean;
@@ -154,6 +159,61 @@ function readAssistPrompt(value: unknown): string | null {
154
159
  return null;
155
160
  }
156
161
 
162
+ // Ticket flow (2026-08-28): a cold model load measured 6m12s on the live
163
+ // fleet, and a held browser connection dies long before that (ClientGone) —
164
+ // killing the load, which restarts on the next click, forever. POST with
165
+ // {async:true} returns {ticket} immediately and the generation keeps running
166
+ // server-side; we poll the ticket until the prompt lands. No connection has
167
+ // to survive the load. An old server without the ticket route answers the
168
+ // POST with the direct result, which passes through unchanged.
169
+ const TICKET_POLL_MS = 3_000;
170
+ const TICKET_CEILING_MS = 12 * 60_000;
171
+
172
+ async function assistCall(
173
+ body: Record<string, unknown>,
174
+ specKey: string | undefined,
175
+ operation: string,
176
+ ): Promise<ReturnType<typeof request<unknown>>> {
177
+ const res = await request<unknown>(hugpyConfig.promptAssistUrl, {
178
+ method: "POST",
179
+ headers: { "Content-Type": "application/json" },
180
+ body: JSON.stringify({ ...body, async: true }),
181
+ timeoutMs: 30_000,
182
+ meta: { specKey, operation },
183
+ });
184
+ if (!res.ok) return res;
185
+ const value = okValue(res) as { ticket?: string; pending?: boolean } | null;
186
+ const ticket = value?.ticket;
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
189
+ const deadline = Date.now() + TICKET_CEILING_MS;
190
+ for (;;) {
191
+ await new Promise((r) => setTimeout(r, TICKET_POLL_MS));
192
+ const poll = await request<unknown>(
193
+ `${hugpyConfig.promptAssistUrl}/ticket/${encodeURIComponent(ticket)}`,
194
+ {
195
+ method: "GET",
196
+ timeoutMs: 15_000,
197
+ meta: { specKey, operation: `${operation}.poll` },
198
+ },
199
+ );
200
+ if (!poll.ok) return poll;
201
+ const row = okValue(poll) as { pending?: boolean } | null;
202
+ if (!row?.pending) return poll;
203
+ if (Date.now() >= deadline) {
204
+ return {
205
+ ok: false,
206
+ error: {
207
+ kind: "timeout",
208
+ message:
209
+ "the assistant is still working after 12 minutes — the model may be stuck loading; try again",
210
+ requestId: `assist-ticket-${ticket.slice(0, 8)}`,
211
+ },
212
+ } as Awaited<ReturnType<typeof request<unknown>>>;
213
+ }
214
+ }
215
+ }
216
+
157
217
  export function usePromptAssist({ kind, specKey }: UsePromptAssistOptions): PromptAssistApi {
158
218
  // One in-flight call at a time: `assistBusy` holds the ACTIVE mode or null when idle —
159
219
  // it both blocks concurrent calls and drives which button shows its loading label.
@@ -246,13 +306,10 @@ export function usePromptAssist({ kind, specKey }: UsePromptAssistOptions): Prom
246
306
  context: { kind, ...(extra?.context ?? {}) },
247
307
  };
248
308
 
249
- const res = await request<unknown>(hugpyConfig.promptAssistUrl, {
250
- method: "POST",
251
- headers: { "Content-Type": "application/json" },
252
- body: JSON.stringify(body),
253
- timeoutMs: 90_000, // a few seconds typically; generous for an on-demand worker
254
- meta: { specKey, operation: `prompt.assist.${mode}` },
255
- });
309
+ // Ticket flow — see assistCall: the POST returns immediately and we poll,
310
+ // so a cold model load (measured 6m12s) can finish without the browser
311
+ // holding a connection through it.
312
+ const res = await assistCall(body, specKey, `prompt.assist.${mode}`);
256
313
  if (!res.ok) {
257
314
  setAssistError(describeAppError(errorOf(res)));
258
315
  setAssistBusy(null);
@@ -292,13 +349,7 @@ export function usePromptAssist({ kind, specKey }: UsePromptAssistOptions): Prom
292
349
  const negModel = opts.extra?.model ?? assistModel;
293
350
  if (negModel) body.model = negModel;
294
351
 
295
- const res = await request<unknown>(hugpyConfig.promptAssistUrl, {
296
- method: "POST",
297
- headers: { "Content-Type": "application/json" },
298
- body: JSON.stringify(body),
299
- timeoutMs: 90_000,
300
- meta: { specKey, operation: "prompt.assist.negative" },
301
- });
352
+ const res = await assistCall(body, specKey, "prompt.assist.negative");
302
353
  if (!res.ok) {
303
354
  setAssistError(describeAppError(errorOf(res)));
304
355
  setAssistBusy(null);
@@ -328,15 +379,9 @@ export function usePromptAssist({ kind, specKey }: UsePromptAssistOptions): Prom
328
379
  setAssistError(null);
329
380
  setAssistBusy("spread");
330
381
  const payload = { ...body, ...(assistModel ? { model: assistModel } : {}) };
331
- const res = await request<unknown>(hugpyConfig.promptAssistUrl, {
332
- method: "POST",
333
- headers: { "Content-Type": "application/json" },
334
- body: JSON.stringify(payload),
335
- // A spread writes N paragraphs in one generation, so it is the longest assist
336
- // call there is — and on a cold generator it pays a model load first.
337
- timeoutMs: 180_000,
338
- meta: { specKey, operation: "prompt.assist.spread" },
339
- });
382
+ // A spread writes N paragraphs in one generation, the longest assist call
383
+ // there is — the ticket flow means a cold generator's load can't kill it.
384
+ const res = await assistCall(payload, specKey, "prompt.assist.spread");
340
385
  if (!res.ok) {
341
386
  setAssistError(describeAppError(errorOf(res)));
342
387
  setAssistBusy(null);
File without changes
File without changes
File without changes
@@ -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
+ }
package/tsconfig.json CHANGED
File without changes
package/vite.config.ts CHANGED
File without changes