@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
@@ -25,9 +25,10 @@ import { useSyncExternalStore } from "react";
25
25
  import { getSessionId } from "../session";
26
26
  import { isCanned } from "../demo/mode";
27
27
  import { installDemoTransport } from "../demo/demoFetch";
28
- import { jobStatusUrl, jobCancelUrl } from "../config";
28
+ import { jobCancelUrl, videoJobUrl } from "../config";
29
29
  import { request, okValue } from "../transport/client";
30
30
  import { addToLibrary } from "./mediaLibrary";
31
+ import { subscribeJobStream, type JobEvent } from "./jobStream";
31
32
  import {
32
33
  jobRecordSchema,
33
34
  isTerminal,
@@ -38,12 +39,6 @@ import {
38
39
  type AnyProgress,
39
40
  } from "./contract";
40
41
  import type { SpatialRegion, TemporalRegion } from "../regions/types";
41
- import {
42
- GENERATE_POLL_CAP_MS,
43
- JOB_POLL_CAP_MS,
44
- generateScenePollCapMs,
45
- generateMoviePollCapMs,
46
- } from "../stations/pollCaps";
47
42
 
48
43
  /** The job kinds this arm enqueues, one poll-parse branch per kind. */
49
44
  export type JobKind =
@@ -52,7 +47,8 @@ export type JobKind =
52
47
  | "audio_extract"
53
48
  | "generate_image"
54
49
  | "generate_scene"
55
- | "generate_movie";
50
+ | "generate_movie"
51
+ | "studio_i2v";
56
52
 
57
53
  /** The station a job belongs to — also the library origin for crop pushes.
58
54
  * "video-crop" (Studio slice 2b) is the dedicated id for the one-pass spatial+
@@ -69,7 +65,8 @@ export type StationId =
69
65
  | "frames"
70
66
  | "generate"
71
67
  | "generate-tester"
72
- | "generate-scene-parts";
68
+ | "generate-scene-parts"
69
+ | "studio";
73
70
 
74
71
  /**
75
72
  * One tracked job. Persisted to sessionStorage, so — because a record may be
@@ -172,9 +169,6 @@ export interface TrackedJob {
172
169
  /** The single sessionStorage key prefix (build marker — must reach the bundle). */
173
170
  const KEY_PREFIX = "vi.jobTracker.v1";
174
171
 
175
- /** Module poll cadence — matches the retired per-hook POLL_MS exactly. */
176
- const POLL_MS = 800;
177
-
178
172
  // Session-scoped key so two tabs keep independent trackers, and the persisted
179
173
  // list travels with the same session id the uploads/jobs are tagged by.
180
174
  //
@@ -199,7 +193,9 @@ function storageKey(): string {
199
193
  let cache: TrackedJob[] | null = null;
200
194
  const listeners = new Set<() => void>();
201
195
  let storageBound = false;
202
- let pollTimer: number | null = null;
196
+ // The shared job-stream unsubscribe while ≥1 tracked job is active (null = not
197
+ // subscribed). The tracker no longer polls; it applies pushed rows by job_id.
198
+ let streamUnsub: (() => void) | null = null;
203
199
 
204
200
  function readStorage(): TrackedJob[] {
205
201
  try {
@@ -253,36 +249,6 @@ function anyActive(): boolean {
253
249
  return ensureLoaded().some(isJobActive);
254
250
  }
255
251
 
256
- function capFor(
257
- kind: JobKind,
258
- nFrames?: number | null,
259
- chain?: boolean | null,
260
- segments?: number | null,
261
- ): number {
262
- if (kind === "generate_movie")
263
- return generateMoviePollCapMs(segments ?? 1, nFrames ?? undefined, chain ?? false);
264
- if (kind === "generate_scene")
265
- return generateScenePollCapMs(nFrames ?? 1, chain ?? false);
266
- return kind === "generate_image" ? GENERATE_POLL_CAP_MS : JOB_POLL_CAP_MS;
267
- }
268
-
269
- function opFor(kind: JobKind): string {
270
- switch (kind) {
271
- case "crop":
272
- return "crop.poll";
273
- case "frame_extract":
274
- return "frame_extract.poll";
275
- case "audio_extract":
276
- return "audio_extract.poll";
277
- case "generate_image":
278
- return "generate_image.poll";
279
- case "generate_scene":
280
- return "generate_scene.poll";
281
- case "generate_movie":
282
- return "generate_movie.poll";
283
- }
284
- }
285
-
286
252
  function failMsgFor(kind: JobKind): string {
287
253
  switch (kind) {
288
254
  case "crop":
@@ -297,6 +263,8 @@ function failMsgFor(kind: JobKind): string {
297
263
  return "Scene generation failed.";
298
264
  case "generate_movie":
299
265
  return "Movie generation failed.";
266
+ case "studio_i2v":
267
+ return "Clip generation failed.";
300
268
  }
301
269
  }
302
270
 
@@ -383,6 +351,11 @@ function pushOutputs(rec: TrackedJob, outputs: MediaRef[]): void {
383
351
  });
384
352
  return;
385
353
  }
354
+ if (rec.kind === "studio_i2v") {
355
+ const out = outputs.find((m) => m.kind === "video");
356
+ if (out) addToLibrary(out, "studio", rec.libLabel ?? "studio clip");
357
+ return;
358
+ }
386
359
  // audio_extract: intermediate (feeds the temporal editor) — never pushed.
387
360
  }
388
361
 
@@ -432,51 +405,57 @@ function applyPoll(rec: TrackedJob, raw: unknown): void {
432
405
  }
433
406
  }
434
407
 
435
- // One module-level tick: poll every active record, cap the overrun ones. A
436
- // transport error is transient (matches the hooks) — skip and retry next tick.
437
- async function pollTick(): Promise<void> {
438
- for (const rec of ensureLoaded()) {
439
- if (!isJobActive(rec)) continue;
440
- if (
441
- Date.now() - rec.startedAt >
442
- capFor(rec.kind, rec.nFrames, rec.chain, rec.segments)
443
- ) {
444
- // Cap reached — pause polling THIS record, but DON'T fail it.
445
- mutate(rec.jobId, { capped: true });
446
- continue;
408
+ // Apply one pushed row to its tracked record. The stream event carries `job` —
409
+ // the GET /video/jobs/<id> view applyPoll already parses — so a transition is
410
+ // applied the moment a worker reports it, with no poll. Events for untracked or
411
+ // already-terminal ids are ignored; a burst self-coalesces (each is cheap).
412
+ function onStreamEvent(ev: JobEvent): void {
413
+ if (ev.stage === "stream.ready") {
414
+ for (const rec of ensureLoaded().filter((row) => !isJobTerminal(row))) {
415
+ void request<unknown>(videoJobUrl(rec.jobId), {
416
+ meta: { specKey: rec.station, operation: "video.job.reconcile" },
417
+ }).then((res) => {
418
+ const current = ensureLoaded().find((row) => row.jobId === rec.jobId);
419
+ if (res.ok && current && !isJobTerminal(current))
420
+ applyPoll(current, okValue(res));
421
+ disarmIfIdle();
422
+ });
447
423
  }
448
- const r = await request<unknown>(jobStatusUrl(rec.jobId), {
449
- meta: { specKey: rec.station, operation: opFor(rec.kind) },
450
- });
451
- if (!r.ok) continue; // transient — retry on the next tick
452
- applyPoll(rec, okValue(r));
424
+ return;
425
+ }
426
+ const jobId = ev.job_id;
427
+ if (!jobId) return;
428
+ const rec = ensureLoaded().find((r) => r.jobId === jobId);
429
+ if (!rec || !isJobActive(rec)) return;
430
+ if (ev.job == null) {
431
+ // The server no longer holds this job (gone) — mark expired, stop watching it.
432
+ mutate(rec.jobId, { expired: true });
433
+ } else {
434
+ applyPoll(rec, ev.job);
453
435
  }
454
436
  disarmIfIdle();
455
437
  }
456
438
 
457
- // Arm the single interval while ≥1 record is active; a no-op if already armed or
458
- // nothing is active. Fires an immediate tick (matches the retired hooks).
439
+ // Subscribe to the shared job stream while ≥1 record is active; a no-op if
440
+ // already subscribed or nothing is active. Replaces the retired poll interval —
441
+ // there is no timer anywhere now; rows arrive on push.
459
442
  function armPolling(): void {
460
443
  if (typeof window === "undefined") return;
461
- if (pollTimer != null) return;
444
+ if (streamUnsub != null) return;
462
445
  if (!anyActive()) return;
463
- // Ordering guard for the canned demo: this module's initTracker() runs at IMPORT
464
- // time (before entry.tsx's installVideoDemo()), and on a reload it rehydrates any
465
- // persisted in-flight record and immediately polls it. If the fetch shim isn't up
466
- // yet, that first poll would hit the REAL backend (a 401 + a live wire hit — the
467
- // brochure must never do that). installDemoTransport is idempotent, so calling it
468
- // here guarantees the shim is installed before ANY canned poll fires, whatever the
469
- // module import order. No-op outside canned mode.
446
+ // Ordering guard for the canned demo: initTracker() runs at IMPORT time (before
447
+ // entry.tsx's installVideoDemo()); installDemoTransport is idempotent and also
448
+ // shims EventSource, so calling it here guarantees the canned stream shim is up
449
+ // before we subscribe, whatever the module import order. No-op outside canned mode.
470
450
  if (isCanned()) installDemoTransport();
471
- pollTimer = window.setInterval(() => void pollTick(), POLL_MS);
472
- void pollTick();
451
+ streamUnsub = subscribeJobStream(onStreamEvent);
473
452
  }
474
453
 
475
- // Clear the interval once no record is active (keeps it alive for others).
454
+ // Drop the subscription once no record is active (reopened by armPolling later).
476
455
  function disarmIfIdle(): void {
477
- if (pollTimer != null && !anyActive()) {
478
- window.clearInterval(pollTimer);
479
- pollTimer = null;
456
+ if (streamUnsub != null && !anyActive()) {
457
+ streamUnsub();
458
+ streamUnsub = null;
480
459
  }
481
460
  }
482
461
 
@@ -547,6 +526,13 @@ export function trackJob(input: TrackJobInput): void {
547
526
  writeStorage(cache);
548
527
  emit();
549
528
  armPolling();
529
+ void request<unknown>(videoJobUrl(input.jobId), {
530
+ meta: { specKey: "studio", operation: "video.job.seed" },
531
+ }).then((res) => {
532
+ const active = ensureLoaded().find((row) => row.jobId === input.jobId);
533
+ if (res.ok && active && isJobActive(active)) applyPoll(active, okValue(res));
534
+ disarmIfIdle();
535
+ });
550
536
  }
551
537
 
552
538
  /**
@@ -645,6 +631,10 @@ function bindStorageOnce(): void {
645
631
  cache = readStorage();
646
632
  emit();
647
633
  armPolling();
634
+ onStreamEvent({ stage: "stream.ready" });
635
+ });
636
+ window.addEventListener("focus", () => {
637
+ onStreamEvent({ stage: "stream.ready" });
648
638
  });
649
639
  }
650
640
 
@@ -697,6 +687,7 @@ function initTracker(): void {
697
687
  writeStorage(cache);
698
688
  }
699
689
  armPolling();
690
+ onStreamEvent({ stage: "stream.ready" });
700
691
  }
701
692
 
702
693
  initTracker();
File without changes
@@ -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
+ }
File without changes
File without changes
File without changes
File without changes
@@ -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
 
File without changes
@@ -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