@hugpy/video-intelligence-ui 0.3.3 → 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 (38) hide show
  1. package/dist/assets/index-DMoT8G1q.css +1 -0
  2. package/dist/assets/index-GeD1mo16.js +4190 -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 +321 -46
  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
@@ -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();
@@ -341,6 +342,7 @@ export function StudioPlane({ registerBridge = false, lockedSurfaceMode }: Studi
341
342
  onEnqueued={clipsState.refresh}
342
343
  settingsHost={reg.settingsHost}
343
344
  lockedSurfaceMode={lockedSurfaceMode}
345
+ onSelectSurfaceMode={onSelectSurfaceMode}
344
346
  />
345
347
  </div>
346
348
  {/* STUDIO-ASSIST LIVE LOG → portaled into the sidebar's ACTIVE (Active
@@ -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"
@@ -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
  );
@@ -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(
@@ -71,14 +71,13 @@ export function PromptAssistButtons({
71
71
  >
72
72
  <option value="">fleet default</option>
73
73
  {models.map((m) => (
74
- // Not decoration (canonical STATE-MODEL.md #4): a non-ready model pays a
75
- // load on first use. `hot` = already on a worker drive (a t_load, seconds);
76
- // `cold` = central only (a download THEN a load). "cold" is reserved for
77
- // not-on-hot-drive, so it never mislabels a hot or loaded model.
74
+ // Only call a model "cold" when the server explicitly reports cold.
75
+ // Older responses lack state; use serving/held before saying unknown.
78
76
  <option key={m.model} value={m.model}>
79
77
  {m.model}
80
- {m.state === "serving" || m.state === "loaded" ? ""
81
- : m.state === "hot" ? " · hot (loads)" : " · cold (downloads)"}
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"}
82
81
  </option>
83
82
  ))}
84
83
  </select>
@@ -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
+ }
@@ -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();