@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
@@ -326,7 +326,7 @@ function SessionRowBody({
326
326
 
327
327
  /** The panel body — split out so the poll it drives exists ONLY while the panel is
328
328
  * open (mounting is the visibility signal; see the `active` gate below). */
329
- function SessionsBody() {
329
+ export function SessionsBody() {
330
330
  const [expanded, setExpanded] = useState<Set<string>>(new Set());
331
331
  // A hidden browser tab is not a visible panel: pause the walk of the movies root
332
332
  // while the console is in the background, and reload the moment it comes back.
@@ -419,7 +419,7 @@ const SESSION_CATEGORIES: ReadonlyArray<{
419
419
  id: "cinema",
420
420
  label: "Cinema",
421
421
  kinds: ["generate_studio_movie"],
422
- hint: "movies on disk — resume, pause, or watch what already rendered",
422
+ hint: "cinema movies",
423
423
  },
424
424
  ];
425
425
 
@@ -450,7 +450,6 @@ function CategoryJobsBody({
450
450
  <p className="vi-knob-hint">
451
451
  No {label.toLowerCase()} builds in the feed — live ones appear here with
452
452
  progress and Cancel; tick “Show finished” above for recent history.
453
- (Durable resumable sessions exist for Cinema today.)
454
453
  </p>
455
454
  );
456
455
  }
@@ -491,7 +490,7 @@ export function SessionsPanel({
491
490
  cancelBusy: Set<string>;
492
491
  onCancel: (id: string) => void;
493
492
  }) {
494
- const [open, setOpen] = useState<Set<string>>(() => new Set(["cinema"]));
493
+ const [open, setOpen] = useState<Set<string>>(() => new Set());
495
494
  const toggle = (id: string) =>
496
495
  setOpen((prev) => {
497
496
  const next = new Set(prev);
@@ -504,7 +503,7 @@ export function SessionsPanel({
504
503
  <p className="vi-knob-headline" style={{ margin: "0.8rem 0 0.2rem" }}>
505
504
  Sessions{" "}
506
505
  <span className="vi-knob-headline-sub">
507
- previous & in-flight work, per surface — Cinema sessions resume from disk
506
+ previous & in-flight work, per surface
508
507
  </span>
509
508
  </p>
510
509
  {SESSION_CATEGORIES.map((cat) => {
@@ -526,24 +525,18 @@ export function SessionsPanel({
526
525
  {cat.label}{" "}
527
526
  <span className="vi-knob-headline-sub">
528
527
  {cat.hint}
529
- {cat.id !== "cinema" && catJobs.length > 0
530
- ? ` · ${catJobs.length} in feed`
531
- : ""}
528
+ {catJobs.length > 0 ? ` · ${catJobs.length} in feed` : ""}
532
529
  </span>
533
530
  </span>
534
531
  </button>
535
532
  {isOpen ? (
536
- cat.id === "cinema" ? (
537
- <SessionsBody />
538
- ) : (
539
- <CategoryJobsBody
540
- jobs={catJobs}
541
- nowMs={nowMs}
542
- cancelBusy={cancelBusy}
543
- onCancel={onCancel}
544
- label={cat.label}
545
- />
546
- )
533
+ <CategoryJobsBody
534
+ jobs={catJobs}
535
+ nowMs={nowMs}
536
+ cancelBusy={cancelBusy}
537
+ onCancel={onCancel}
538
+ label={cat.label}
539
+ />
547
540
  ) : null}
548
541
  </div>
549
542
  );
@@ -61,9 +61,10 @@ export interface StudioPlaneProps {
61
61
  * that sub-mode and hide its internal Clip|Cinema switcher. Omitted by the standalone
62
62
  * /studio-clips station, which keeps the internal switcher. */
63
63
  lockedSurfaceMode?: "clip" | "movie";
64
+ onSelectSurfaceMode?: (mode: "clip" | "movie") => void;
64
65
  }
65
66
 
66
- export function StudioPlane({ registerBridge = false, lockedSurfaceMode }: StudioPlaneProps) {
67
+ export function StudioPlane({ registerBridge = false, lockedSurfaceMode, onSelectSurfaceMode }: StudioPlaneProps) {
67
68
  const reg = useSidebarRegistry();
68
69
  const { presets, loading: presetsLoading } = useStudioPresets(true);
69
70
  const clipsState = useStudioClips();
@@ -111,6 +112,9 @@ export function StudioPlane({ registerBridge = false, lockedSurfaceMode }: Studi
111
112
  const [pinned, setPinned] = useState(false);
112
113
 
113
114
  const topRef = useRef<HTMLDivElement | null>(null);
115
+ const monitorRef = useRef<HTMLDivElement | null>(null);
116
+ const binRef = useRef<HTMLDivElement | null>(null);
117
+ const renderRef = useRef<HTMLDivElement | null>(null);
114
118
 
115
119
  // Round 9: fold into the ONE shell sidebar — reveal its guarded Settings tab (knobs +
116
120
  // template dropdown portal there). registerSettings is stable, so this runs once per
@@ -275,10 +279,24 @@ export function StudioPlane({ registerBridge = false, lockedSurfaceMode }: Studi
275
279
 
276
280
  return (
277
281
  <div className="vi-studio-workbench" ref={topRef}>
282
+ <nav className="vi-studio-workflow" aria-label="Studio workflow">
283
+ <button type="button" onClick={() => monitorRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })}>
284
+ <span className="vi-studio-workflow-step">01 · Monitor</span>
285
+ <span>Review the current take</span>
286
+ </button>
287
+ <button type="button" onClick={() => binRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })}>
288
+ <span className="vi-studio-workflow-step">02 · Shot bin</span>
289
+ <span>{items.length} staged {items.length === 1 ? "item" : "items"}</span>
290
+ </button>
291
+ <button type="button" onClick={() => renderRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })}>
292
+ <span className="vi-studio-workflow-step">03 · Render</span>
293
+ <span>Clip or cinema</span>
294
+ </button>
295
+ </nav>
278
296
  {/* CENTER, movie skeleton: viewer on top → generator below. Viewer header controls
279
297
  (Play from library / Clear / follow-newest toggle) are wired here so both
280
298
  plane mounts get them for free — see the `pinned` note above. */}
281
- <StudioViewer
299
+ <div ref={monitorRef} className="vi-studio-workflow-target"><StudioViewer
282
300
  clips={clipsState.clips}
283
301
  library={libraryMedia}
284
302
  selected={selected}
@@ -286,7 +304,7 @@ export function StudioPlane({ registerBridge = false, lockedSurfaceMode }: Studi
286
304
  onPick={pickViewerClip}
287
305
  onClear={clearViewer}
288
306
  onToggleFollow={toggleFollowNewest}
289
- />
307
+ /></div>
290
308
 
291
309
  {/* Slice 2b: the staged studio items as a strip of first-class cards with inline
292
310
  per-item ops. Additive — it sits ABOVE the generate surface and does not touch
@@ -294,7 +312,8 @@ export function StudioPlane({ registerBridge = false, lockedSurfaceMode }: Studi
294
312
  mounts identically (this is the one shared StudioPlane). Each card is wrapped in
295
313
  a keyed intrinsic <div> so the map key never lands on a component (which the
296
314
  bare-tsc env flags as a spurious prop). */}
297
- {items.length > 0 && (
315
+ <div ref={binRef} className="vi-studio-workflow-target" aria-label="Shot bin">
316
+ {items.length > 0 ? (
298
317
  <div className="vi-studio-item-strip" aria-label="Studio items">
299
318
  {items.map((it) => (
300
319
  <div className="vi-studio-item-cell" key={it.key}>
@@ -308,8 +327,10 @@ export function StudioPlane({ registerBridge = false, lockedSurfaceMode }: Studi
308
327
  </div>
309
328
  ))}
310
329
  </div>
311
- )}
330
+ ) : <p className="vi-studio-bin-empty">Staged media will appear here. Send a library item to Studio or add a source in the render desk.</p>}
331
+ </div>
312
332
 
333
+ <div ref={renderRef} className="vi-studio-workflow-target">
313
334
  <StudioGenerateSurface
314
335
  presets={presets}
315
336
  presetsLoading={presetsLoading}
@@ -321,7 +342,9 @@ export function StudioPlane({ registerBridge = false, lockedSurfaceMode }: Studi
321
342
  onEnqueued={clipsState.refresh}
322
343
  settingsHost={reg.settingsHost}
323
344
  lockedSurfaceMode={lockedSurfaceMode}
345
+ onSelectSurfaceMode={onSelectSurfaceMode}
324
346
  />
347
+ </div>
325
348
  {/* STUDIO-ASSIST LIVE LOG → portaled into the sidebar's ACTIVE (Active
326
349
  Processes) tab via the registry's activeExtra host (operator correction
327
350
  2026-08-05: "active tab" = Active Processes). Single mount, shared stream. */}
File without changes
@@ -109,18 +109,13 @@ export function StudioViewer({
109
109
  const [sentMsg, setSentMsg] = useState<string | null>(null);
110
110
  const [sendErr, setSendErr] = useState<string | null>(null);
111
111
 
112
+ // The operator probe (GET /keys/video-share) was dropped (2026-10-05): the
113
+ // route has no backend here, so it only 404'd — i.e. always resolved to
114
+ // "not operator". Set that outcome directly, with no dead request. (The
115
+ // Send-to-Editor target /video/studio/clip/<id>/to-editor is itself a 501 in
116
+ // this build, so the affordance has nothing to call anyway.)
112
117
  useEffect(() => {
113
- let alive = true;
114
- void (async () => {
115
- const res = await request<unknown>(hugpyConfig.keysVideoShareUrl, {
116
- method: "GET",
117
- meta: { specKey: "studio", operation: "studio.operatorProbe" },
118
- });
119
- if (alive) setOperator(res.ok);
120
- })();
121
- return () => {
122
- alive = false;
123
- };
118
+ setOperator(false);
124
119
  }, []);
125
120
 
126
121
  // Clear any transient send message when the shown clip changes, so a "sent"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -24,6 +24,7 @@ import { z } from "zod";
24
24
  import { request, okValue, errorOf, describeAppError } from "../../transport/client";
25
25
  import { hugpyConfig, studioClipDetailUrl, mediaBytesUrl } from "../../config";
26
26
  import { addToLibrary, updateLibraryProvenance } from "../../video/mediaLibrary";
27
+ import { subscribeJobStream } from "../../video/jobStream";
27
28
  import type { LibraryItem } from "../../video/mediaLibrary";
28
29
  import type { MediaRef } from "../../video/contract";
29
30
  import type { StudioPreset } from "../../video/useStudioPresets";
@@ -804,13 +805,24 @@ export function useStudioClips(): StudioClipsState {
804
805
  }
805
806
  }, [backfillProvenance]);
806
807
 
808
+ // Seed once, then refresh the clip catalog only when a worker reports a job
809
+ // transition (coalesced) — a produced clip appears on the DONE push, not on a
810
+ // 6s timer.
807
811
  useEffect(() => {
808
812
  mounted.current = true;
809
813
  void load(false);
810
- const iv = window.setInterval(() => void load(true), 6000);
814
+ let coalesce: number | null = null;
815
+ const unsub = subscribeJobStream(() => {
816
+ if (coalesce != null) return;
817
+ coalesce = window.setTimeout(() => {
818
+ coalesce = null;
819
+ void load(true);
820
+ }, 400);
821
+ });
811
822
  return () => {
812
823
  mounted.current = false;
813
- window.clearInterval(iv);
824
+ if (coalesce != null) window.clearTimeout(coalesce);
825
+ unsub();
814
826
  };
815
827
  }, [load]);
816
828
 
@@ -1,8 +1,8 @@
1
1
  // k94 — the ONE-PATH "Create identity" job watcher.
2
2
  //
3
3
  // Both create routes (from-video → identity_from_video, from-images →
4
- // identity_mesh_build) hand back a bus job_id; this hook polls the generic
5
- // GET /video/jobs/<id> (`videoJobUrl`) the way the other identity flows in this
4
+ // identity_mesh_build) hand back a bus job_id; this hook seeds once from the generic
5
+ // GET /video/jobs/<id> (`videoJobUrl`) and follows pushed job transitions.
6
6
  // station do, and reduces the reply to what the panel shows on ONE line:
7
7
  // stage · % · log tail
8
8
  // `progress` is the relay's mirrored blob ({stage, progress 0..1, log_tail[]}
@@ -10,16 +10,17 @@
10
10
  // bus's numeric bar, `result` is the terminal JobResult ({ok, error?,
11
11
  // identities?}). Terminal = `result.ok` is a boolean. Bounded (90 min — char360
12
12
  // plus a textured Hunyuan3D mesh PER character runs minutes each; clownworld's
13
- // own ceiling) so a wedged remote never spins the panel forever.
13
+ // own ceiling) so a wedged remote never holds the panel forever.
14
14
  //
15
15
  // Why not the session job tracker (video/jobTracker.ts)? Its JobKind/StationId
16
16
  // unions + per-kind terminal parse are closed over the crop/frames/generate
17
17
  // arms; widening them is a cross-station change outside this task's files. The
18
- // poll shape here is the same one ExtractFromVideoPanel / generateFullIdentity
18
+ // job shape here is the same one ExtractFromVideoPanel / generateFullIdentity
19
19
  // already use in this station.
20
20
  import { useCallback, useEffect, useRef, useState } from "react";
21
21
  import { request, okValue } from "../../transport/client";
22
22
  import { videoJobUrl } from "../../config";
23
+ import { subscribeJobStream } from "../../video/jobStream";
23
24
 
24
25
  export type CreateJobPhase = "idle" | "queued" | "running" | "done" | "error";
25
26
 
@@ -52,7 +53,6 @@ const IDLE: CreateJobView = {
52
53
  phase: "idle", jobId: null, stage: null, pct: null, logLine: null, error: null, identities: [],
53
54
  };
54
55
 
55
- const POLL_MS = 4000;
56
56
  const CAP_MS = 90 * 60 * 1000;
57
57
 
58
58
  type JobReply = {
@@ -79,6 +79,7 @@ export interface CreateJobApi {
79
79
  export function useIdentityCreateJob(): CreateJobApi {
80
80
  const [view, setView] = useState<CreateJobView>(IDLE);
81
81
  const timer = useRef<number | null>(null);
82
+ const unsubscribe = useRef<(() => void) | null>(null);
82
83
  const active = useRef(false);
83
84
 
84
85
  const stop = useCallback(() => {
@@ -87,6 +88,8 @@ export function useIdentityCreateJob(): CreateJobApi {
87
88
  window.clearTimeout(timer.current);
88
89
  timer.current = null;
89
90
  }
91
+ unsubscribe.current?.();
92
+ unsubscribe.current = null;
90
93
  }, []);
91
94
 
92
95
  useEffect(() => stop, [stop]);
@@ -98,19 +101,14 @@ export function useIdentityCreateJob(): CreateJobApi {
98
101
  setView({ ...IDLE, phase: "queued", jobId });
99
102
  const deadline = Date.now() + CAP_MS;
100
103
 
101
- const pollOnce = async () => {
102
- if (!active.current) return;
103
- if (Date.now() > deadline) {
104
- stop();
105
- setView((v) => ({ ...v, phase: "error", error: "Timed out waiting for the identity build." }));
106
- return;
107
- }
108
- const res = await request<unknown>(videoJobUrl(jobId), {
109
- meta: { specKey: "studio", operation: "identity.create.status" },
110
- });
104
+ timer.current = window.setTimeout(() => {
105
+ if (!active.current || Date.now() < deadline) return;
106
+ stop();
107
+ setView((v) => ({ ...v, phase: "error", error: "Timed out waiting for the identity build." }));
108
+ }, CAP_MS);
109
+
110
+ const accept = (job: JobReply) => {
111
111
  if (!active.current) return;
112
- if (res.ok) {
113
- const job = okValue(res) as JobReply;
114
112
  const result = job.result;
115
113
  if (result && typeof result.ok === "boolean") {
116
114
  stop();
@@ -150,11 +148,18 @@ export function useIdentityCreateJob(): CreateJobApi {
150
148
  pct: ratio == null ? null : Math.max(0, Math.min(100, Math.round(ratio * 100))),
151
149
  logLine: typeof last === "string" ? last : null,
152
150
  }));
153
- }
154
- // queued / running / transient poll failure → keep polling.
155
- timer.current = window.setTimeout(() => void pollOnce(), POLL_MS);
156
151
  };
157
- timer.current = window.setTimeout(() => void pollOnce(), POLL_MS);
152
+ let pushed = false;
153
+ unsubscribe.current = subscribeJobStream((ev) => {
154
+ if (ev.job_id !== jobId || !ev.job) return;
155
+ pushed = true;
156
+ accept(ev.job as JobReply);
157
+ });
158
+ void request<unknown>(videoJobUrl(jobId), {
159
+ meta: { specKey: "studio", operation: "identity.create.status" },
160
+ }).then((res) => {
161
+ if (res.ok && !pushed) accept(okValue(res) as JobReply);
162
+ });
158
163
  },
159
164
  [stop],
160
165
  );
File without changes
@@ -9,6 +9,7 @@ import {
9
9
  } from "../../config";
10
10
  import { jobRecordSchema, isTerminal } from "../../video/contract";
11
11
  import type { JobStatus } from "../../video/contract";
12
+ import { subscribeJobStream } from "../../video/jobStream";
12
13
 
13
14
  export const DEFAULT_VIEWS = ["front", "three_quarter", "profile", "back"] as const;
14
15
  export type ViewName = (typeof DEFAULT_VIEWS)[number];
@@ -69,16 +70,18 @@ export function useReconstruction(slug: string, onComplete: () => void): Reconst
69
70
  const [error, setError] = useState<string | null>(null);
70
71
 
71
72
  const mounted = useRef(true);
72
- const timer = useRef<number | null>(null);
73
+ // Unsubscribe from the shared job stream while this run's jobs are in flight
74
+ // (replaces the retired 2s poll interval — a transition re-reads on push).
75
+ const unsub = useRef<null | (() => void)>(null);
73
76
  const idsRef = useRef<string[]>([]);
74
77
  const statusesRef = useRef<Record<string, JobStatus>>({});
75
78
  const onCompleteRef = useRef(onComplete);
76
79
  onCompleteRef.current = onComplete;
77
80
 
78
81
  const stopPoll = useCallback(() => {
79
- if (timer.current != null) {
80
- window.clearInterval(timer.current);
81
- timer.current = null;
82
+ if (unsub.current != null) {
83
+ unsub.current();
84
+ unsub.current = null;
82
85
  }
83
86
  }, []);
84
87
 
@@ -156,7 +159,10 @@ export function useReconstruction(slug: string, onComplete: () => void): Reconst
156
159
  statusesRef.current = seeded;
157
160
  setStatuses(seeded);
158
161
  void poll();
159
- timer.current = window.setInterval(() => void poll(), 2000);
162
+ // Re-read this run's job ids whenever a worker reports ANY transition. The
163
+ // set is tiny and poll() is idempotent, so no per-id event filtering is needed.
164
+ if (unsub.current != null) unsub.current();
165
+ unsub.current = subscribeJobStream(() => void poll());
160
166
  }, [poll]);
161
167
 
162
168
  const generate = useCallback(
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/src/style/app.css CHANGED
@@ -3406,6 +3406,55 @@ button.vi-studio-src-thumb {
3406
3406
  min-width: 0;
3407
3407
  }
3408
3408
 
3409
+ .vi-studio-workflow {
3410
+ display: grid;
3411
+ grid-template-columns: repeat(3, minmax(0, 1fr));
3412
+ gap: 0.5rem;
3413
+ border-bottom: 1px solid var(--vi-border);
3414
+ padding-bottom: 0.7rem;
3415
+ }
3416
+
3417
+ .vi-studio-workflow button {
3418
+ display: flex;
3419
+ flex-direction: column;
3420
+ gap: 0.25rem;
3421
+ text-align: left;
3422
+ min-width: 0;
3423
+ padding: 0.65rem 0.75rem;
3424
+ border: 1px solid var(--vi-border);
3425
+ border-radius: 8px;
3426
+ background: var(--vi-surface-raised);
3427
+ color: var(--vi-text-dim);
3428
+ cursor: pointer;
3429
+ }
3430
+
3431
+ .vi-studio-workflow button:hover,
3432
+ .vi-studio-workflow button:focus-visible {
3433
+ border-color: var(--vi-accent);
3434
+ color: var(--vi-text);
3435
+ }
3436
+
3437
+ .vi-studio-workflow-step {
3438
+ color: var(--vi-text);
3439
+ font-family: var(--vi-mono);
3440
+ font-size: 0.76rem;
3441
+ font-weight: 600;
3442
+ }
3443
+
3444
+ .vi-studio-workflow-target { scroll-margin-top: 1rem; min-width: 0; }
3445
+ .vi-studio-bin-empty {
3446
+ margin: 0;
3447
+ padding: 0.7rem 0.85rem;
3448
+ border: 1px dashed var(--vi-border);
3449
+ border-radius: 8px;
3450
+ color: var(--vi-text-dim);
3451
+ font-size: 0.78rem;
3452
+ }
3453
+
3454
+ @media (max-width: 35rem) {
3455
+ .vi-studio-workflow { grid-template-columns: 1fr; }
3456
+ }
3457
+
3409
3458
  /* Round 9: the studio no longer carries its OWN sidebar — it folds into the arm shell's
3410
3459
  ONE left sidebar (Settings / Active / Library, via the sidebar registry). So the
3411
3460
  workbench is a SINGLE centre column (viewer on top → generator below), the Movie
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -71,12 +71,13 @@ export function PromptAssistButtons({
71
71
  >
72
72
  <option value="">fleet default</option>
73
73
  {models.map((m) => (
74
- // "· cold" is not decoration: a non-seated model pays a load on first use,
75
- // which is minutes, not milliseconds. Saying so up front stops that reading
76
- // as a hang.
74
+ // Only call a model "cold" when the server explicitly reports cold.
75
+ // Older responses lack state; use serving/held before saying unknown.
77
76
  <option key={m.model} value={m.model}>
78
77
  {m.model}
79
- {m.serving ? "" : " · cold"}
78
+ {m.state === "serving" || m.state === "loaded" || m.serving ? " · ready"
79
+ : m.state === "hot" || m.held ? " · available (loads)"
80
+ : m.state === "cold" ? " · cold (downloads)" : " · availability unknown"}
80
81
  </option>
81
82
  ))}
82
83
  </select>
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -0,0 +1,119 @@
1
+ // SHARED job-event stream — the client half of design 1 (2026-10-05). The /video
2
+ // page no longer polls GET /video/jobs on a timer. Every list/tracker surface
3
+ // SEEDS ONCE from its own GET, then subscribes here; this module holds ONE
4
+ // EventSource on /api/video/jobs/stream for the whole page (ref-counted: opens on
5
+ // the first subscriber, closes on the last) and hands each pushed row to every
6
+ // subscriber. A row is pushed the moment a worker reports a transition — never on
7
+ // a timer.
8
+ //
9
+ // Structural twin of video/useStudioAssistLog.ts (the same module-scope store +
10
+ // reference-counted connection), with ONE deliberate difference: it is
11
+ // 404-TOLERANT and NEVER polls. If the stream endpoint is absent (first connect
12
+ // errors before it ever opened), we do NOT reconnect — each consumer already did
13
+ // its one-time seed, so the page simply stays on that seed. We reconnect ONLY
14
+ // after a stream that HAD opened drops (the server caps a stream at an hour and
15
+ // expects a re-attach); that is at most ~1/hour, not a poll.
16
+ import { hugpyConfig } from "../config";
17
+ import { videoShareKey } from "../share";
18
+
19
+ /** One pushed job row (jobstream.py). `job` is the GET /video/jobs/<id> view, or
20
+ * null when the job is gone; `stage:"stream.ready"` marks the transport hello. */
21
+ export interface JobEvent {
22
+ job_id?: string;
23
+ status?: string | null;
24
+ owner?: string | null;
25
+ private?: boolean;
26
+ job?: unknown;
27
+ ts?: number;
28
+ stage?: string;
29
+ }
30
+
31
+ type Listener = (ev: JobEvent) => void;
32
+
33
+ const listeners = new Set<Listener>();
34
+ let es: EventSource | null = null;
35
+ let refs = 0;
36
+ let everOpened = false;
37
+ let retryTimer: ReturnType<typeof setTimeout> | null = null;
38
+ const RETRY_MS = 5000;
39
+
40
+ function streamUrl(): string {
41
+ const base = hugpyConfig.jobsStreamUrl;
42
+ const key = videoShareKey();
43
+ if (!key) return base;
44
+ const sep = base.includes("?") ? "&" : "?";
45
+ return `${base}${sep}share=${encodeURIComponent(key)}`;
46
+ }
47
+
48
+ function openStream(): void {
49
+ if (es || typeof window === "undefined" || typeof EventSource === "undefined") return;
50
+ let source: EventSource;
51
+ try {
52
+ source = new EventSource(streamUrl(), { withCredentials: true });
53
+ } catch {
54
+ return; // EventSource unavailable — consumers keep their one-time seed
55
+ }
56
+ es = source;
57
+ source.onopen = () => {
58
+ everOpened = true;
59
+ };
60
+ source.onmessage = (e: MessageEvent) => {
61
+ let ev: JobEvent;
62
+ try {
63
+ ev = JSON.parse(e.data);
64
+ } catch {
65
+ return;
66
+ }
67
+ // Let consumers re-read their current rows after every attach. A transition
68
+ // can happen between their initial GET and the stream becoming ready.
69
+ for (const fn of listeners) {
70
+ try {
71
+ fn(ev);
72
+ } catch {
73
+ /* one bad subscriber must not stall the rest */
74
+ }
75
+ }
76
+ };
77
+ source.onerror = () => {
78
+ source.close();
79
+ if (es === source) es = null;
80
+ // RECONNECT only if this stream had actually opened before dropping (the
81
+ // server's hourly re-attach). A first connect that errors before opening =
82
+ // the endpoint is absent/unsupported → do NOT retry, do NOT poll; the page
83
+ // stays on each consumer's one-time seed.
84
+ if (refs > 0 && everOpened) {
85
+ everOpened = false;
86
+ if (retryTimer) clearTimeout(retryTimer);
87
+ retryTimer = setTimeout(() => {
88
+ if (refs > 0) openStream();
89
+ }, RETRY_MS);
90
+ }
91
+ };
92
+ }
93
+
94
+ function closeStream(): void {
95
+ if (retryTimer) {
96
+ clearTimeout(retryTimer);
97
+ retryTimer = null;
98
+ }
99
+ if (es) {
100
+ es.close();
101
+ es = null;
102
+ }
103
+ everOpened = false;
104
+ }
105
+
106
+ /**
107
+ * Subscribe to pushed job rows. Returns an unsubscribe. The underlying
108
+ * EventSource is opened on the first subscriber and closed on the last.
109
+ */
110
+ export function subscribeJobStream(fn: Listener): () => void {
111
+ listeners.add(fn);
112
+ refs += 1;
113
+ if (refs === 1) openStream();
114
+ return () => {
115
+ listeners.delete(fn);
116
+ refs = Math.max(0, refs - 1);
117
+ if (refs === 0) closeStream();
118
+ };
119
+ }