@hugpy/video-intelligence-ui 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/assets/index-C_av7KDW.js +4190 -0
  2. package/dist/assets/index-DMoT8G1q.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/entry.tsx +6 -27
  5. package/package.json +3 -2
  6. package/src/config.ts +11 -27
  7. package/src/demo/demoFetch.ts +0 -9
  8. package/src/nav/Navbar.tsx +3 -3
  9. package/src/session.ts +8 -43
  10. package/src/stations/GenerateStation.tsx +8 -5
  11. package/src/stations/SharePanel.tsx +4 -4
  12. package/src/stations/ToksPanel.tsx +1 -2
  13. package/src/stations/WorkbenchSidebar.tsx +8 -261
  14. package/src/stations/ops/CharacterGroupsPanel.tsx +75 -83
  15. package/src/stations/studio/IdentitiesStation.tsx +66 -75
  16. package/src/stations/studio/StudioGenerateMode.tsx +3 -2
  17. package/src/stations/studio/StudioGenerateTab.tsx +258 -188
  18. package/src/stations/studio/StudioMovieComposer.tsx +320 -57
  19. package/src/stations/studio/StudioPlane.tsx +3 -1
  20. package/src/stations/studio/StudioViewer.tsx +6 -11
  21. package/src/stations/studio/studioShared.tsx +14 -2
  22. package/src/stations/studio/useIdentityCreateJob.ts +26 -21
  23. package/src/stations/studio/useReconstruction.ts +11 -5
  24. package/src/video/PromptAssistButtons.tsx +5 -6
  25. package/src/video/jobStream.ts +119 -0
  26. package/src/video/jobTracker.ts +67 -76
  27. package/src/video/modelsFeed.ts +105 -0
  28. package/src/video/studioOracle.ts +48 -0
  29. package/src/video/useMediaJobs.ts +17 -3
  30. package/src/video/useModels.ts +12 -53
  31. package/src/video/useMovieSessions.ts +15 -6
  32. package/src/video/useOperatorHistory.ts +11 -101
  33. package/src/video/usePromptAssist.ts +6 -3
  34. package/src/video/useToks.ts +14 -6
  35. package/src/video/watchVideoJob.ts +43 -0
  36. package/dist/assets/index-DuT1AMLn.css +0 -1
  37. package/dist/assets/index-_13ZiBDP.js +0 -4191
  38. package/src/video/useWorkerCalls.ts +0 -165
@@ -42,21 +42,18 @@ import {
42
42
  cancelJob,
43
43
  isJobActive,
44
44
  isJobTerminal,
45
- dismissAllTerminal,
46
45
  type TrackedJob,
47
46
  type StationId,
48
47
  } from "../video/jobTracker";
49
48
  import { STILL_RUNNING_MESSAGE } from "./pollCaps";
50
49
  import { requestAddPart, requestStage } from "../video/composerBridge";
51
50
  import { requestStudioStage } from "../video/studioBridge";
52
- import { useWorkerCalls, type WorkerCall } from "../video/useWorkerCalls";
53
51
  import { useSidebarRegistry } from "./SidebarRegistry";
54
52
  import { WorkbenchDrawer } from "./WorkbenchDrawer";
55
53
  import { SharePanel } from "./SharePanel";
56
54
  import {
57
55
  mediaBytesUrl,
58
56
  studioClipArchiveUrl,
59
- llmJobCancelUrl,
60
57
  } from "../config";
61
58
  import { request, errorOf, describeAppError } from "../transport/client";
62
59
  import type { MediaRef } from "../video/contract";
@@ -73,6 +70,8 @@ const STATION_NAME: Record<StationId, string> = {
73
70
  frames: "Frames & Models",
74
71
  generate: "Generate",
75
72
  "generate-tester": "Generate (tester)",
73
+ "generate-scene-parts": "Generate (scene parts)",
74
+ studio: "Studio Clip",
76
75
  };
77
76
 
78
77
  // The route a process row links to. It is the station id by default (id === route
@@ -82,6 +81,7 @@ const STATION_NAME: Record<StationId, string> = {
82
81
  // default studio sub-tab.
83
82
  const STATION_ROUTE: Partial<Record<StationId, string>> = {
84
83
  "video-crop": "studio-clips",
84
+ studio: "studio-clips",
85
85
  // The tester is a surface INSIDE the Generate station, not a route of its own —
86
86
  // without this the default (id === path) would link a tester row to /generate-tester,
87
87
  // which resolves to nothing.
@@ -190,182 +190,10 @@ function fmtAgo(ms: number): string {
190
190
  return `${m}m ${(total % 60).toString().padStart(2, "0")}s`;
191
191
  }
192
192
 
193
- // The scrolling log tail in the expanded panel: a monospace <pre> capped by CSS
194
- // max-height with its own scroll, pinned to the BOTTOM (newest line) whenever the
195
- // tail changes — so a live render's newest output is always in view without the
196
- // operator chasing it. Empty/absent tail degrades to a quiet "no log yet".
197
- function WorkerCallLog({ lines }: { lines: string[] }) {
198
- const ref = useRef<HTMLPreElement | null>(null);
199
- // Re-pin only when the content actually changes (count + newest line), not on
200
- // every parent re-render — otherwise a manual scroll-up would be yanked back
201
- // down every poll tick.
202
- const key = lines.length ? `${lines.length}:${lines[lines.length - 1]}` : "";
203
- useEffect(() => {
204
- const el = ref.current;
205
- if (el) el.scrollTop = el.scrollHeight;
206
- }, [key]);
207
- if (lines.length === 0) {
208
- return <p className="vi-proc-log-empty">No log yet.</p>;
209
- }
210
- return (
211
- <pre className="vi-proc-log" ref={ref} aria-label="Recent log output">
212
- {lines.join("\n")}
213
- </pre>
214
- );
215
- }
216
-
217
- // One in-flight WORKER CALL row (from the fleet-wide /llm/jobs feed) — the calls
218
- // this browser session did NOT launch (identity reconstruction, another station's
219
- // render, another transport). Presentational sibling of ProcessRow: no NavLink
220
- // (these jobs have no station route here), a unified hard-Cancel, and — unlike the
221
- // old flat row that read green-active even when wedged — an EXPANDABLE panel with a
222
- // live progress bar, current stage, an honest stalled state, and a scrolling log.
223
- function WorkerCallRow({
224
- call,
225
- now,
226
- cancelling,
227
- expanded,
228
- onToggle,
229
- onCancel,
230
- }: {
231
- call: WorkerCall;
232
- now: number;
233
- cancelling: boolean;
234
- expanded: boolean;
235
- onToggle: (id: string) => void;
236
- onCancel: (call: WorkerCall) => void;
237
- }) {
238
- const meta = [call.model, call.worker].filter(Boolean).join(" · ");
239
- // Honest stalled signal: the server said so, OR movement has been silent past the
240
- // stall window. Recomputed off `now` (the ~1s panel tick), so a live render that
241
- // freezes flips to stalled on its own without a status change.
242
- const lastMoveMs = call.updatedAt != null ? Math.max(0, now - call.updatedAt) : null;
243
- const stalled =
244
- call.stalled || (lastMoveMs != null && lastMoveMs > WORKER_CALL_STALL_MS);
245
- const pct = call.progress != null ? Math.round(call.progress * 100) : null;
246
- // A requested-and-acknowledged cancel also latches the button into "cancelling…"
247
- // (survives across polls until the row drops), not just the local in-flight click.
248
- const cancelBusy = cancelling || call.cancelRequested;
249
- return (
250
- <li className="vi-proc-item vi-proc-item-worker">
251
- <div className="vi-proc-top">
252
- <button
253
- type="button"
254
- className="vi-proc-toggle"
255
- aria-expanded={expanded}
256
- aria-label={expanded ? "Collapse details" : "Expand — progress, stage, log"}
257
- onClick={() => onToggle(call.id)}
258
- >
259
- <span className="vi-proc-caret" aria-hidden>
260
- {expanded ? "▾" : "▸"}
261
- </span>
262
- </button>
263
- {stalled ? (
264
- <span className="vi-proc-stall-dot" aria-hidden />
265
- ) : (
266
- <span className="vi-proc-spinner" aria-hidden />
267
- )}
268
- <span className="vi-proc-station">{humanizeWorkerKind(call.kind)}</span>
269
- <span className={`vi-status vi-status-${call.status}`}>{call.status}</span>
270
- {stalled && <span className="vi-proc-stall-badge">stalled</span>}
271
- {pct != null && <span className="vi-proc-progress">{pct}%</span>}
272
- <span className="vi-proc-elapsed">{fmtElapsed(now - call.startedAt)}</span>
273
- </div>
274
- {meta && <span className="vi-proc-label">{meta}</span>}
275
-
276
- {expanded && (
277
- <div className="vi-proc-detail">
278
- {/* Progress bar: determinate when the feed gives a fraction, otherwise an
279
- indeterminate "working" sweep — never a fabricated percentage. Goes
280
- amber when stalled so a frozen bar doesn't read as healthy progress. */}
281
- <div className="vi-proc-progbar-row">
282
- <div
283
- className={`vi-proc-progbar${
284
- pct == null ? " vi-proc-progbar-indeterminate" : ""
285
- }${stalled ? " vi-proc-progbar-stalled" : ""}`}
286
- role="progressbar"
287
- aria-valuemin={0}
288
- aria-valuemax={100}
289
- {...(pct != null ? { "aria-valuenow": pct } : {})}
290
- >
291
- <span
292
- className="vi-proc-progbar-fill"
293
- style={pct != null ? { width: `${pct}%` } : undefined}
294
- />
295
- </div>
296
- <span className="vi-proc-stage">
297
- {call.stage ? call.stage : pct == null ? "working…" : `${pct}%`}
298
- </span>
299
- </div>
300
-
301
- {call.message && <p className="vi-proc-detail-msg">{call.message}</p>}
302
-
303
- <div className="vi-proc-detail-meta">
304
- <span
305
- className={`vi-proc-lastmove${
306
- stalled ? " vi-proc-lastmove-stalled" : ""
307
- }`}
308
- >
309
- {lastMoveMs != null
310
- ? `last movement ${fmtAgo(lastMoveMs)} ago`
311
- : "last movement unknown"}
312
- </span>
313
- </div>
314
-
315
- <WorkerCallLog lines={call.logTail} />
316
- </div>
317
- )}
318
-
319
- <div className="vi-proc-msg">
320
- <button
321
- type="button"
322
- className="vi-btn vi-btn-sm vi-proc-btn"
323
- disabled={cancelBusy}
324
- onClick={() => onCancel(call)}
325
- title="Stop this worker call — a step already in flight finishes first"
326
- >
327
- {cancelBusy ? "Cancelling…" : "✕ Cancel"}
328
- </button>
329
- </div>
330
- </li>
331
- );
332
- }
333
-
334
- // STUDIO SCOPE (operator, 2026-07-31): the studio workbench's Active Processes must
335
- // list ONLY studio-originated work — NEVER a general fleet model call (a discord chat,
336
- // a web /v1 completion, another transport's LLM call). Those must not be visible here
337
- // and must not be cancellable from inside the studio. The fleet-wide /llm/jobs feed
338
- // carries every transport, so we keep only rows that are RENDER/MEDIA work:
339
- // * transport === "media" — the media/render bus (the authoritative signal: every
340
- // job on it is video-arm work — studio clips/movies, scene/image gen, identity
341
- // reconstruction/mesh/char360, mlt render), OR
342
- // * a known render/media job KIND (defensive fallback if a row's transport is blank).
343
- // A general LLM call (transport web/v1/discord/cli, kind chat/completion/…) is dropped.
344
- // The console-wide ActiveProcessesStation still shows the fleet superset — this scoping
345
- // is the studio sidebar's alone.
346
- const STUDIO_MEDIA_KINDS = new Set([
347
- "studio_i2v",
348
- "generate_studio_movie",
349
- "generate_scene",
350
- "generate_movie",
351
- "generate_image",
352
- "identity_reconstruction",
353
- "identity_mesh_build",
354
- "identity_video_extract",
355
- "mlt_render",
356
- "crop",
357
- "frame_extract",
358
- "audio_extract",
359
- ]);
360
-
361
- function isStudioOriginated(c: WorkerCall): boolean {
362
- return c.transport === "media" || STUDIO_MEDIA_KINDS.has(c.kind);
363
- }
364
-
365
- /** Non-done tracked jobs, newest first — the Active Processes tab body. */
193
+ /** Only live tracked jobs belong in Active Processes. */
366
194
  function activeJobsOf(records: TrackedJob[]): TrackedJob[] {
367
195
  return records
368
- .filter((r) => r.status !== "done")
196
+ .filter((r) => !isJobTerminal(r))
369
197
  .slice()
370
198
  .sort((a, b) => b.enqueuedAt - a.enqueuedAt);
371
199
  }
@@ -378,64 +206,8 @@ function ProcessesPanel() {
378
206
  const records = useJobTracker();
379
207
  const jobs = useMemo(() => activeJobsOf(records), [records]);
380
208
 
381
- // The fleet-wide in-flight worker calls (identity reconstruction, other stations'
382
- // renders, calls from any transport) — the work THIS browser session never
383
- // launched, so the session jobTracker can't see it. Merged below, DEDUPED against
384
- // the tracker: a /llm/jobs row whose id equals a TrackedJob.jobId is dropped (the
385
- // tracker row is richer — station link, progress, provenance — so it wins).
386
- const { calls, refetch } = useWorkerCalls();
387
- const trackerIds = useMemo(() => new Set(jobs.map((j) => j.jobId)), [jobs]);
388
- const workerCalls = useMemo(
389
- // Deduped against the session tracker AND scoped to studio-originated (media/
390
- // render) work only — a general fleet model call never surfaces or cancels here.
391
- () => calls.filter((c) => !trackerIds.has(c.id) && isStudioOriginated(c)),
392
- [calls, trackerIds],
393
- );
394
-
395
- // Terminal rows (failed / cancelled / expired) linger in this list after the
396
- // active ones drop off — this drives the opt-in "Clear finished" broom below.
397
- const hasTerminal = useMemo(() => jobs.some(isJobTerminal), [jobs]);
398
-
399
- // Per-id busy flag for the worker-call Cancel buttons (immediate feedback; the
400
- // next poll / the forced refetch drops the row once the server observes it).
401
- const [cancelling, setCancelling] = useState<Set<string>>(new Set());
402
- const onCancelWorkerCall = useCallback(
403
- async (call: WorkerCall) => {
404
- setCancelling((prev) => new Set(prev).add(call.id));
405
- // ONE unified hard-cancel for every transport: the feed's own cancel route
406
- // (POST /llm/jobs/<id>/cancel → {cancelled,status}) fans the stop out to the
407
- // right bus server-side, so the row no longer has to route media vs chat itself.
408
- await request<unknown>(llmJobCancelUrl(call.id), {
409
- method: "POST",
410
- meta: { specKey: "workers", operation: "worker.call.cancel" },
411
- });
412
- refetch();
413
- setCancelling((prev) => {
414
- const n = new Set(prev);
415
- n.delete(call.id);
416
- return n;
417
- });
418
- },
419
- [refetch],
420
- );
421
-
422
- // Per-id expanded state for the worker-call rows (progress / stage / stalled /
423
- // log). Keyed by job id so a row's panel stays open across poll ticks; a job that
424
- // finishes and drops from the feed just leaves a stale id in the set (harmless).
425
- const [expanded, setExpanded] = useState<Set<string>>(new Set());
426
- const toggleExpanded = useCallback((id: string) => {
427
- setExpanded((prev) => {
428
- const n = new Set(prev);
429
- if (n.has(id)) n.delete(id);
430
- else n.add(id);
431
- return n;
432
- });
433
- }, []);
434
-
435
- // Tick ~1s while anything is live so every elapsed clock stays current: tracker
436
- // actives/capped OR any in-flight worker call (those are always live).
437
- const ticking =
438
- jobs.some((r) => isJobActive(r) || r.capped) || workerCalls.length > 0;
209
+ // Tick ~1s while anything is live so every elapsed clock stays current.
210
+ const ticking = jobs.some((r) => isJobActive(r) || r.capped);
439
211
  const [now, setNow] = useState(() => Date.now());
440
212
  useEffect(() => {
441
213
  if (!ticking) {
@@ -452,7 +224,7 @@ function ProcessesPanel() {
452
224
  // target is stable; the empty copy is suppressed when a station has portaled rows in.
453
225
  const extraHost = <div className="vi-proc-extra" ref={reg.setActiveExtraHost} />;
454
226
 
455
- const total = jobs.length + workerCalls.length;
227
+ const total = jobs.length;
456
228
 
457
229
  if (total === 0) {
458
230
  return (
@@ -473,36 +245,11 @@ function ProcessesPanel() {
473
245
  <div className="vi-proc-header">
474
246
  <h2 className="vi-proc-title">Active processes</h2>
475
247
  <span className="vi-proc-count">{total}</span>
476
- {/* Only shown when there's a finished/failed row to clear; forgets the
477
- LOCAL terminal rows only (never server work), and leaves every
478
- still-running job polling and visible. */}
479
- {hasTerminal && (
480
- <button
481
- type="button"
482
- className="vi-btn vi-btn-sm vi-btn-ghost"
483
- onClick={() => dismissAllTerminal()}
484
- title="Remove finished and failed items from this view — your saved files on disk are kept."
485
- aria-label="Remove finished and failed jobs from this list — still-running jobs stay, and your saved files on disk are kept."
486
- >
487
- Clear finished
488
- </button>
489
- )}
490
248
  </div>
491
249
  <ul className="vi-proc-list">
492
250
  {jobs.map((job) => (
493
251
  <ProcessRow key={job.jobId} job={job} now={now} />
494
252
  ))}
495
- {workerCalls.map((call) => (
496
- <WorkerCallRow
497
- key={call.id}
498
- call={call}
499
- now={now}
500
- cancelling={cancelling.has(call.id)}
501
- expanded={expanded.has(call.id)}
502
- onToggle={toggleExpanded}
503
- onCancel={onCancelWorkerCall}
504
- />
505
- ))}
506
253
  </ul>
507
254
  {extraHost}
508
255
  </>
@@ -20,7 +20,6 @@
20
20
  import { useEffect, useRef, useState } from "react";
21
21
  import {
22
22
  mediaBytesUrl,
23
- videoJobUrl,
24
23
  hugpyConfig,
25
24
  identityGenerateUrl,
26
25
  identityMeshStatusUrl,
@@ -28,6 +27,7 @@ import {
28
27
  import { request, okValue, errorOf, describeAppError } from "../../transport/client";
29
28
  import type { MediaRef } from "../../video/contract";
30
29
  import type { IdentityProfilesState } from "../studio/useIdentityProfiles";
30
+ import { watchVideoJob } from "../../video/watchVideoJob";
31
31
  import { MeshViewer } from "../studio/IdentitiesStation";
32
32
 
33
33
  // ── S1 review manifest shapes (tolerant reads — the poll result is cast, exactly
@@ -134,18 +134,18 @@ export function CharacterGroupsPanel({
134
134
  // slug can run concurrently, mirrors the group-level state being a map too).
135
135
  const [mesh3D, setMesh3D] = useState<Record<string, Mesh3DState>>({});
136
136
  const meshActive = useRef<Record<string, boolean>>({});
137
- const meshTimer = useRef<Record<string, number>>({});
137
+ const meshStop = useRef<Record<string, () => void>>({});
138
138
 
139
139
  const active = useRef(false);
140
- const timer = useRef<number | null>(null);
140
+ const reviewStop = useRef<(() => void) | null>(null);
141
141
  const idSeq = useRef(0);
142
142
 
143
143
  useEffect(() => {
144
144
  return () => {
145
145
  active.current = false;
146
- if (timer.current != null) window.clearTimeout(timer.current);
146
+ reviewStop.current?.();
147
147
  for (const slug of Object.keys(meshActive.current)) meshActive.current[slug] = false;
148
- for (const slug of Object.keys(meshTimer.current)) window.clearTimeout(meshTimer.current[slug]);
148
+ for (const stop of Object.values(meshStop.current)) stop();
149
149
  };
150
150
  }, []);
151
151
 
@@ -194,56 +194,42 @@ export function CharacterGroupsPanel({
194
194
  }
195
195
 
196
196
  const jobId = enq.jobId;
197
- const deadline = Date.now() + 20 * 60 * 1000; // ~20 min bound, mirrors ExtractFromVideoPanel
198
- const pollOnce = async () => {
199
- if (!active.current) return;
200
- if (Date.now() > deadline) {
197
+ reviewStop.current?.();
198
+ reviewStop.current = watchVideoJob(jobId, (row) => {
199
+ if (!active.current) return true;
200
+ const job = row as {
201
+ progress?: number | null;
202
+ result?: {
203
+ ok?: boolean;
204
+ groups?: ReviewManifest;
205
+ error?: { code?: string; message?: string; retryable?: boolean };
206
+ } | null;
207
+ stage?: string | null;
208
+ message?: string | null;
209
+ };
210
+ const result = job.result;
211
+ if (result && typeof result.ok === "boolean") {
201
212
  active.current = false;
202
213
  setBusy(false);
203
- setStatus(null);
204
- setError("Timed out waiting for the character review.");
205
- return;
206
- }
207
- const res = await request<unknown>(videoJobUrl(jobId), {
208
- meta: { specKey: "studio", operation: "identity.groups.review.status" },
209
- });
210
- if (!active.current) return;
211
- if (res.ok) {
212
- const job = okValue(res) as {
213
- progress?: number | null;
214
- result?: {
215
- ok?: boolean;
216
- groups?: ReviewManifest;
217
- error?: { code?: string; message?: string; retryable?: boolean };
218
- } | null;
219
- stage?: string | null;
220
- message?: string | null;
221
- };
222
- const result = job.result;
223
- if (result && typeof result.ok === "boolean") {
224
- active.current = false;
225
- setBusy(false);
226
- if (result.ok) {
227
- setStatus("done");
228
- if (result.groups && Array.isArray(result.groups.groups)) {
229
- loadManifest(result.groups);
230
- } else {
231
- setError("Review finished but returned no character groups.");
232
- }
233
- } else {
234
- setStatus(null);
235
- setError((result.error && result.error.message) || "Character review failed.");
236
- }
237
- return;
214
+ if (result.ok) {
215
+ setStatus("done");
216
+ if (result.groups && Array.isArray(result.groups.groups)) loadManifest(result.groups);
217
+ else setError("Review finished but returned no character groups.");
218
+ } else {
219
+ setStatus(null);
220
+ setError((result.error && result.error.message) || "Character review failed.");
238
221
  }
239
- const pct = typeof job.progress === "number" ? ` (${Math.round(job.progress * 100)}%)` : "";
240
- const stage = job.stage || job.message;
241
- setStatus(`Reviewing…${pct}${stage ? ` — ${stage}` : ""}`);
222
+ return true;
242
223
  }
243
- // queued/running/transient-poll-failure → keep polling.
244
- timer.current = window.setTimeout(() => void pollOnce(), 5000);
245
- };
246
- timer.current = window.setTimeout(() => void pollOnce(), 5000);
224
+ const pct = typeof job.progress === "number" ? ` (${Math.round(job.progress * 100)}%)` : "";
225
+ const stage = job.stage || job.message;
226
+ setStatus(`Reviewing…${pct}${stage ? ` — ${stage}` : ""}`);
227
+ }, () => {
228
+ active.current = false;
229
+ setBusy(false);
230
+ setStatus(null);
231
+ setError("Timed out waiting for the character review.");
232
+ }, 20 * 60 * 1000);
247
233
  }
248
234
 
249
235
  // ── selection helpers ─────────────────────────────────────────────────────
@@ -414,41 +400,47 @@ export function CharacterGroupsPanel({
414
400
  return;
415
401
  }
416
402
 
417
- const deadline = Date.now() + 20 * 60 * 1000; // ~20 min bound, mirrors Studio's generate poll
418
- const pollOnce = async () => {
419
- if (!meshActive.current[slug]) return;
420
- if (Date.now() > deadline) {
421
- meshActive.current[slug] = false;
422
- setMesh(slug, { phase: "error", status: null, error: "Timed out waiting for the 3D build." });
423
- return;
424
- }
425
- const sres = await request<unknown>(identityMeshStatusUrl(slug, reconId), {
426
- meta: { specKey: "studio", operation: "identity.groups.mesh.status" },
427
- });
428
- if (!meshActive.current[slug]) return;
429
- if (sres.ok) {
430
- const ms = okValue(sres) as { status?: string; error?: string | null; glb_path?: string | null };
431
- const st = ms.status ?? "queued";
432
- if (st === "done") {
433
- meshActive.current[slug] = false;
434
- if (ms.glb_path) {
435
- setMesh(slug, { phase: "done", status: "done", glbUrl: mediaBytesUrl(ms.glb_path), error: null });
436
- } else {
437
- setMesh(slug, { phase: "error", status: null, error: "Build finished but returned no model." });
438
- }
439
- return;
440
- }
441
- if (st === "error" || st === "cancelled") {
403
+ if (!enq.job_id) {
404
+ meshActive.current[slug] = false;
405
+ setMesh(slug, { phase: "error", status: null, error: "Generate response had no job_id." });
406
+ return;
407
+ }
408
+ meshStop.current[slug]?.();
409
+ meshStop.current[slug] = watchVideoJob(enq.job_id, (row) => {
410
+ if (!meshActive.current[slug]) return true;
411
+ const result = row.result as { ok?: boolean; error?: { message?: string } } | null;
412
+ if (result && typeof result.ok === "boolean") {
413
+ if (!result.ok) {
442
414
  meshActive.current[slug] = false;
443
- setMesh(slug, { phase: "error", status: null, error: ms.error || `Build ${st}.` });
444
- return;
415
+ setMesh(slug, { phase: "error", status: null,
416
+ error: result.error?.message || "3D build failed." });
417
+ } else {
418
+ void request<unknown>(identityMeshStatusUrl(slug, reconId), {
419
+ meta: { specKey: "studio", operation: "identity.groups.mesh.final" },
420
+ }).then((sres) => {
421
+ if (!meshActive.current[slug]) return;
422
+ meshActive.current[slug] = false;
423
+ if (!sres.ok) {
424
+ setMesh(slug, { phase: "error", status: null,
425
+ error: describeAppError(errorOf(sres)) });
426
+ return;
427
+ }
428
+ const ms = okValue(sres) as { status?: string; error?: string | null; glb_path?: string | null };
429
+ if (ms.status === "done" && ms.glb_path) {
430
+ setMesh(slug, { phase: "done", status: "done", glbUrl: mediaBytesUrl(ms.glb_path), error: null });
431
+ } else {
432
+ setMesh(slug, { phase: "error", status: null,
433
+ error: ms.error || "Build finished but returned no model." });
434
+ }
435
+ });
445
436
  }
446
- setMesh(slug, { status: `Generating… (${st})` });
437
+ return true;
447
438
  }
448
- // queued/running/transient-poll-failure → keep polling.
449
- meshTimer.current[slug] = window.setTimeout(() => void pollOnce(), 5000);
450
- };
451
- meshTimer.current[slug] = window.setTimeout(() => void pollOnce(), 5000);
439
+ setMesh(slug, { status: `Generating… (${String(row.current_stage || row.status || "running")})` });
440
+ }, () => {
441
+ meshActive.current[slug] = false;
442
+ setMesh(slug, { phase: "error", status: null, error: "Timed out waiting for the 3D build." });
443
+ }, 20 * 60 * 1000);
452
444
  }
453
445
 
454
446
  // ── render ─────────────────────────────────────────────────────────────────