@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.
- package/dist/assets/index-DMoT8G1q.css +1 -0
- package/dist/assets/index-GeD1mo16.js +4190 -0
- package/dist/index.html +2 -2
- package/entry.tsx +6 -27
- package/package.json +3 -2
- package/src/config.ts +11 -27
- package/src/demo/demoFetch.ts +0 -9
- package/src/nav/Navbar.tsx +3 -3
- package/src/session.ts +8 -43
- package/src/stations/GenerateStation.tsx +8 -5
- package/src/stations/SharePanel.tsx +4 -4
- package/src/stations/ToksPanel.tsx +1 -2
- package/src/stations/WorkbenchSidebar.tsx +8 -261
- package/src/stations/ops/CharacterGroupsPanel.tsx +75 -83
- package/src/stations/studio/IdentitiesStation.tsx +66 -75
- package/src/stations/studio/StudioGenerateMode.tsx +3 -2
- package/src/stations/studio/StudioGenerateTab.tsx +258 -188
- package/src/stations/studio/StudioMovieComposer.tsx +321 -46
- package/src/stations/studio/StudioPlane.tsx +3 -1
- package/src/stations/studio/StudioViewer.tsx +6 -11
- package/src/stations/studio/studioShared.tsx +14 -2
- package/src/stations/studio/useIdentityCreateJob.ts +26 -21
- package/src/stations/studio/useReconstruction.ts +11 -5
- package/src/video/PromptAssistButtons.tsx +5 -6
- package/src/video/jobStream.ts +119 -0
- package/src/video/jobTracker.ts +67 -76
- package/src/video/modelsFeed.ts +105 -0
- package/src/video/studioOracle.ts +48 -0
- package/src/video/useMediaJobs.ts +17 -3
- package/src/video/useModels.ts +12 -53
- package/src/video/useMovieSessions.ts +15 -6
- package/src/video/useOperatorHistory.ts +11 -101
- package/src/video/usePromptAssist.ts +6 -3
- package/src/video/useToks.ts +14 -6
- package/src/video/watchVideoJob.ts +43 -0
- package/dist/assets/index-DuT1AMLn.css +0 -1
- package/dist/assets/index-_13ZiBDP.js +0 -4191
- 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
|
-
|
|
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
|
|
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
|
-
//
|
|
382
|
-
|
|
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
|
|
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
|
|
137
|
+
const meshStop = useRef<Record<string, () => void>>({});
|
|
138
138
|
|
|
139
139
|
const active = useRef(false);
|
|
140
|
-
const
|
|
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
|
-
|
|
146
|
+
reviewStop.current?.();
|
|
147
147
|
for (const slug of Object.keys(meshActive.current)) meshActive.current[slug] = false;
|
|
148
|
-
for (const
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
if (!active.current) return;
|
|
200
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
240
|
-
const stage = job.stage || job.message;
|
|
241
|
-
setStatus(`Reviewing…${pct}${stage ? ` — ${stage}` : ""}`);
|
|
222
|
+
return true;
|
|
242
223
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
const
|
|
426
|
-
|
|
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,
|
|
444
|
-
|
|
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
|
-
|
|
437
|
+
return true;
|
|
447
438
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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 ─────────────────────────────────────────────────────────────────
|