@hugpy/video-intelligence-ui 0.1.0 → 0.1.1
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/package.json +1 -1
- package/src/stations/GenerateStation.tsx +146 -14
- package/src/stations/WorkbenchSidebar.tsx +57 -12
- package/src/stations/studio/StudioGenerateTab.tsx +169 -6
- package/src/stations/studio/StudioMovieComposer.tsx +507 -299
- package/src/stations/studio/{StudioMovieComposer.tsx.bak-sessions-20260812 → StudioMovieComposer.tsx.bak-declutter-20260813} +350 -48
- package/src/style/app.css +19 -0
- package/src/video/LibraryGroups.tsx +81 -0
- package/src/video/UnnamedWarn.tsx +146 -0
- package/src/video/useOperatorHistory.ts +100 -0
- package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +0 -228
- package/src/stations/GenerateStation.tsx.bak-tester-removal +0 -5170
- package/src/stations/studio/StudioMovieSessions.tsx.bak-sessions-20260812 +0 -417
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SESSION LIBRARY GROUPS (operator ask 2026-08-13): the library pickers render
|
|
2
|
+
// as TYPE-CATEGORIZED, COLLAPSIBLE sections instead of one flat grid — inputs
|
|
3
|
+
// (uploads/frames/send-to-studio), generated images, scene outputs, movie
|
|
4
|
+
// outputs, studio clips. ONE component for every library surface (the house
|
|
5
|
+
// one-reusable-component rule); the call site supplies the item renderer so
|
|
6
|
+
// pick semantics stay exactly what they were. Groups with no members render
|
|
7
|
+
// nothing; `defaultOpen` seeds which sections start expanded (the current
|
|
8
|
+
// section rides in via a `key` remount on mode change).
|
|
9
|
+
import { useRef, useState, type ReactNode } from "react";
|
|
10
|
+
import type { LibraryItem, LibraryGenKind } from "./mediaLibrary";
|
|
11
|
+
|
|
12
|
+
const GROUPS: ReadonlyArray<{
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
kinds: ReadonlyArray<LibraryGenKind | null>;
|
|
16
|
+
}> = [
|
|
17
|
+
{ id: "inputs", label: "📤 Inputs — uploads · frames · sent to studio", kinds: [null] },
|
|
18
|
+
{ id: "images", label: "🖼 Generated images", kinds: ["generate_image"] },
|
|
19
|
+
{ id: "scenes", label: "🎬 Scene outputs", kinds: ["generate_scene"] },
|
|
20
|
+
{ id: "movies", label: "🎞 Movie outputs", kinds: ["generate_movie"] },
|
|
21
|
+
{ id: "clips", label: "📹 Studio clips", kinds: ["studio_i2v"] },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export function LibraryGroups({
|
|
25
|
+
items,
|
|
26
|
+
defaultOpen,
|
|
27
|
+
seed,
|
|
28
|
+
renderItem,
|
|
29
|
+
}: {
|
|
30
|
+
items: LibraryItem[];
|
|
31
|
+
/** Group ids expanded on first render — e.g. ["inputs", "images"]. */
|
|
32
|
+
defaultOpen: readonly string[];
|
|
33
|
+
/** Re-seed `defaultOpen` when this changes (the active section) — the scoped
|
|
34
|
+
* tsconfig rejects `key` on locally-declared components, so remount-by-key
|
|
35
|
+
* is not available here; this is the React render-phase reset pattern. */
|
|
36
|
+
seed?: string;
|
|
37
|
+
renderItem: (it: LibraryItem) => ReactNode;
|
|
38
|
+
}) {
|
|
39
|
+
const [open, setOpen] = useState<Set<string>>(() => new Set(defaultOpen));
|
|
40
|
+
const seedRef = useRef(seed);
|
|
41
|
+
if (seedRef.current !== seed) {
|
|
42
|
+
seedRef.current = seed;
|
|
43
|
+
setOpen(new Set(defaultOpen));
|
|
44
|
+
}
|
|
45
|
+
const toggle = (id: string) =>
|
|
46
|
+
setOpen((prev) => {
|
|
47
|
+
const next = new Set(prev);
|
|
48
|
+
if (next.has(id)) next.delete(id);
|
|
49
|
+
else next.add(id);
|
|
50
|
+
return next;
|
|
51
|
+
});
|
|
52
|
+
return (
|
|
53
|
+
<>
|
|
54
|
+
{GROUPS.map((g) => {
|
|
55
|
+
const members = items.filter((it) =>
|
|
56
|
+
g.kinds.includes((it.genKind ?? null) as LibraryGenKind | null),
|
|
57
|
+
);
|
|
58
|
+
if (members.length === 0) return null;
|
|
59
|
+
const isOpen = open.has(g.id);
|
|
60
|
+
return (
|
|
61
|
+
<div key={g.id} className="vi-lib-group">
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
className="vi-timeline-toggle vi-lib-group-head"
|
|
65
|
+
aria-expanded={isOpen}
|
|
66
|
+
onClick={() => toggle(g.id)}
|
|
67
|
+
>
|
|
68
|
+
<span className="vi-timeline-caret" aria-hidden>
|
|
69
|
+
{isOpen ? "▾" : "▸"}
|
|
70
|
+
</span>{" "}
|
|
71
|
+
{g.label} <span className="vi-lib-count">({members.length})</span>
|
|
72
|
+
</button>
|
|
73
|
+
{isOpen ? (
|
|
74
|
+
<div className="vi-gen-picker-grid">{members.map(renderItem)}</div>
|
|
75
|
+
) : null}
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
})}
|
|
79
|
+
</>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// UNNAMED-GENERATION WARNING (operator 2026-08-13): naming applies to EVERY
|
|
2
|
+
// generate section, and the warning is an EXPLICIT per-section OPTION,
|
|
3
|
+
// DEFAULT OFF, remembered per user (localStorage) — a 2-second image render
|
|
4
|
+
// must never suffer a 30-second warning unless its operator asked for one.
|
|
5
|
+
// ONE hook + toggle + panel for every section (house one-component rule);
|
|
6
|
+
// Cinema, Clip and the Generate station all mount these.
|
|
7
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
8
|
+
|
|
9
|
+
const PREF_PREFIX = "vi.warnUnnamed.v1";
|
|
10
|
+
|
|
11
|
+
/** localStorage-backed per-section preference; DEFAULT OFF. */
|
|
12
|
+
export function useWarnUnnamedPref(section: string): [boolean, (v: boolean) => void] {
|
|
13
|
+
const key = `${PREF_PREFIX}:${section}`;
|
|
14
|
+
const [on, setOn] = useState<boolean>(() => {
|
|
15
|
+
try {
|
|
16
|
+
return localStorage.getItem(key) === "1";
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
const set = (v: boolean) => {
|
|
22
|
+
setOn(v);
|
|
23
|
+
try {
|
|
24
|
+
localStorage.setItem(key, v ? "1" : "0");
|
|
25
|
+
} catch {
|
|
26
|
+
/* best-effort — the in-memory state still applies this session */
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
return [on, set];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The explicit option — one checkbox, same copy in every section. */
|
|
33
|
+
export function WarnUnnamedToggle({
|
|
34
|
+
checked,
|
|
35
|
+
onChange,
|
|
36
|
+
}: {
|
|
37
|
+
checked: boolean;
|
|
38
|
+
onChange: (v: boolean) => void;
|
|
39
|
+
}) {
|
|
40
|
+
return (
|
|
41
|
+
<label
|
|
42
|
+
className="vi-knob-hint"
|
|
43
|
+
style={{ display: "inline-flex", gap: "0.35rem", alignItems: "center" }}
|
|
44
|
+
title="When on, generating without a name pauses with an inline name box for 30 seconds, then continues unnamed. Off = never interrupts. Remembered per section."
|
|
45
|
+
>
|
|
46
|
+
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
|
47
|
+
warn when unnamed
|
|
48
|
+
</label>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** First clause of a prompt as a derived name (≤7 words / 48 chars). */
|
|
53
|
+
export function deriveNameFrom(prompt: string): string {
|
|
54
|
+
return prompt
|
|
55
|
+
.split(/[.,;\n]/)[0]
|
|
56
|
+
.trim()
|
|
57
|
+
.split(/\s+/)
|
|
58
|
+
.slice(0, 7)
|
|
59
|
+
.join(" ")
|
|
60
|
+
.slice(0, 48);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The warning panel: inline name box + "Name it" + skip-with-countdown.
|
|
65
|
+
* MOUNTING arms the expiry (default 30s); unmounting disarms it; onProceed
|
|
66
|
+
* fires exactly once (undefined name = continue unnamed).
|
|
67
|
+
*/
|
|
68
|
+
export function UnnamedWarnPanel({
|
|
69
|
+
noun,
|
|
70
|
+
onProceed,
|
|
71
|
+
onDismiss,
|
|
72
|
+
seconds = 30,
|
|
73
|
+
}: {
|
|
74
|
+
noun: string;
|
|
75
|
+
onProceed: (name?: string) => void;
|
|
76
|
+
onDismiss?: () => void;
|
|
77
|
+
seconds?: number;
|
|
78
|
+
}) {
|
|
79
|
+
const [draft, setDraft] = useState("");
|
|
80
|
+
const [left, setLeft] = useState(seconds);
|
|
81
|
+
const draftRef = useRef("");
|
|
82
|
+
draftRef.current = draft;
|
|
83
|
+
const firedRef = useRef(false);
|
|
84
|
+
const proceedRef = useRef(onProceed);
|
|
85
|
+
proceedRef.current = onProceed;
|
|
86
|
+
const fire = (name?: string) => {
|
|
87
|
+
if (firedRef.current) return;
|
|
88
|
+
firedRef.current = true;
|
|
89
|
+
proceedRef.current(name);
|
|
90
|
+
};
|
|
91
|
+
const fireRef = useRef(fire);
|
|
92
|
+
fireRef.current = fire;
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
const id = window.setInterval(() => {
|
|
95
|
+
setLeft((l) => {
|
|
96
|
+
if (l <= 1) {
|
|
97
|
+
window.clearInterval(id);
|
|
98
|
+
fireRef.current(draftRef.current.trim() || undefined);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
return l - 1;
|
|
102
|
+
});
|
|
103
|
+
}, 1000);
|
|
104
|
+
return () => window.clearInterval(id);
|
|
105
|
+
}, []);
|
|
106
|
+
return (
|
|
107
|
+
<div
|
|
108
|
+
className="vi-studio-required-callout"
|
|
109
|
+
role="alert"
|
|
110
|
+
style={{ display: "flex", alignItems: "center", gap: "0.6rem", flexWrap: "wrap", marginBottom: "0.6rem" }}
|
|
111
|
+
>
|
|
112
|
+
<span>
|
|
113
|
+
This {noun} has no name — unnamed work lists as “untitled” beside a bare id
|
|
114
|
+
and is hard to tell apart later.
|
|
115
|
+
</span>
|
|
116
|
+
<input
|
|
117
|
+
type="text"
|
|
118
|
+
className="vi-knob-input"
|
|
119
|
+
style={{ flex: "1 1 12rem", minWidth: "10rem" }}
|
|
120
|
+
placeholder="name it here — or wait and it continues unnamed"
|
|
121
|
+
value={draft}
|
|
122
|
+
autoFocus
|
|
123
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
124
|
+
onKeyDown={(e) => {
|
|
125
|
+
if (e.key === "Enter" && draft.trim()) fire(draft.trim());
|
|
126
|
+
}}
|
|
127
|
+
/>
|
|
128
|
+
<button type="button" className="vi-btn vi-btn-sm" disabled={!draft.trim()} onClick={() => fire(draft.trim())}>
|
|
129
|
+
Name it & generate
|
|
130
|
+
</button>
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
134
|
+
title="Continues automatically when the timer runs out."
|
|
135
|
+
onClick={() => fire(undefined)}
|
|
136
|
+
>
|
|
137
|
+
Generate without a name ({left}s)
|
|
138
|
+
</button>
|
|
139
|
+
{onDismiss && (
|
|
140
|
+
<button type="button" className="vi-btn vi-btn-sm vi-btn-ghost" title="Cancel this submit." onClick={onDismiss}>
|
|
141
|
+
✕
|
|
142
|
+
</button>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// OPERATOR HISTORY HYDRATION (operator ask 2026-08-13): the superadmin gets
|
|
2
|
+
// COMPLETE visibility of previous renders in the Session Library — not just
|
|
3
|
+
// what this browser tab produced. The library itself stays a per-session
|
|
4
|
+
// sessionStorage working set (by design); this hook back-fills it from the
|
|
5
|
+
// SERVER record once per session for an operator principal:
|
|
6
|
+
//
|
|
7
|
+
// * probe: GET keysVideoShareUrl — the same 200⇒operator / 401⇒guest probe
|
|
8
|
+
// SharePanel uses (members and share guests never hydrate; their lists
|
|
9
|
+
// stay owner-scoped server-side).
|
|
10
|
+
// * source: GET /video/jobs?all=1 for terminal done generate_image /
|
|
11
|
+
// generate_scene / generate_movie rows (the list projection strips
|
|
12
|
+
// results, so each job detail is fetched for its outputs). Clips
|
|
13
|
+
// (studio_i2v) already hydrate via useStudioClips poll — not repeated.
|
|
14
|
+
// * sink: addToLibrary — idempotent by uri, tombstone-respecting (an item
|
|
15
|
+
// the operator removed stays removed), genKind-tagged so the Generate
|
|
16
|
+
// station can filter the strips by its CURRENT SECTION.
|
|
17
|
+
//
|
|
18
|
+
// Once-per-session guard in sessionStorage; canned demo never hydrates.
|
|
19
|
+
import { useEffect } from "react";
|
|
20
|
+
import { request, okValue } from "../transport/client";
|
|
21
|
+
import { hugpyConfig, jobStatusUrl } from "../config";
|
|
22
|
+
import { addToLibrary, type LibraryGenKind } from "./mediaLibrary";
|
|
23
|
+
import { getSessionId } from "../session";
|
|
24
|
+
import { isCanned } from "../demo/mode";
|
|
25
|
+
import type { MediaRef } from "./contract";
|
|
26
|
+
|
|
27
|
+
// generate_studio_movie = CINEMA renders — hydrated too (mapped onto the
|
|
28
|
+
// generate_movie library kind: same Videos shelf, no schema change).
|
|
29
|
+
const GEN_KINDS: readonly string[] = [
|
|
30
|
+
"generate_image", "generate_scene", "generate_movie", "generate_studio_movie",
|
|
31
|
+
];
|
|
32
|
+
// v2 (2026-08-13): the once-per-session flag must change whenever the
|
|
33
|
+
// HYDRATED KINDS change — a tab that hydrated v1 (no cinema jobs) kept its
|
|
34
|
+
// flag through every reload and never saw generate_studio_movie history.
|
|
35
|
+
const FLAG_PREFIX = "vi.operatorHistory.v2";
|
|
36
|
+
const DETAIL_CAP = 150; // newest-first; enough history without hammering the API
|
|
37
|
+
|
|
38
|
+
function flagKey(): string {
|
|
39
|
+
try { return `${FLAG_PREFIX}:${getSessionId()}`; } catch { return FLAG_PREFIX; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useOperatorHistory(): void {
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (isCanned()) return;
|
|
45
|
+
try { if (sessionStorage.getItem(flagKey())) return; } catch { /* no-op */ }
|
|
46
|
+
let cancelled = false;
|
|
47
|
+
void (async () => {
|
|
48
|
+
// Operator probe — non-operators (401/403) never hydrate.
|
|
49
|
+
const probe = await request<unknown>(hugpyConfig.keysVideoShareUrl, {
|
|
50
|
+
meta: { specKey: "media", operation: "operator.probe" },
|
|
51
|
+
});
|
|
52
|
+
if (cancelled || !probe.ok) return;
|
|
53
|
+
// jobStatusUrl("") = `${apiBase}/video/jobs/` — trim the slash and query
|
|
54
|
+
// the list route with terminal rows included.
|
|
55
|
+
const list = await request<unknown>(
|
|
56
|
+
`${jobStatusUrl("").replace(/\/$/, "")}?all=1&limit=300`,
|
|
57
|
+
{ meta: { specKey: "media", operation: "history.jobs.list" } },
|
|
58
|
+
);
|
|
59
|
+
if (cancelled || !list.ok) return;
|
|
60
|
+
const jobs = ((okValue(list) as { jobs?: unknown[] })?.jobs ?? []) as Array<
|
|
61
|
+
Record<string, unknown>
|
|
62
|
+
>;
|
|
63
|
+
const wanted = jobs
|
|
64
|
+
.filter((j) => j?.status === "done" && GEN_KINDS.includes(String(j?.name)))
|
|
65
|
+
.slice(0, DETAIL_CAP);
|
|
66
|
+
for (const j of wanted) {
|
|
67
|
+
if (cancelled) return;
|
|
68
|
+
const id = String(j.job_id ?? j.id ?? "");
|
|
69
|
+
if (!id) continue;
|
|
70
|
+
const det = await request<unknown>(jobStatusUrl(id), {
|
|
71
|
+
meta: { specKey: "media", operation: "history.job.detail" },
|
|
72
|
+
});
|
|
73
|
+
if (!det.ok) continue;
|
|
74
|
+
const result = (okValue(det) as { result?: { outputs?: unknown[] } })?.result;
|
|
75
|
+
for (const o of (result?.outputs ?? []) as Array<Record<string, unknown>>) {
|
|
76
|
+
if (!o?.uri || !o?.asset_id) continue;
|
|
77
|
+
const ref = {
|
|
78
|
+
asset_id: String(o.asset_id),
|
|
79
|
+
kind: (o.kind === "video" ? "video" : "image"),
|
|
80
|
+
uri: String(o.uri),
|
|
81
|
+
mime: (o.mime as string) ?? (o.kind === "video" ? "video/mp4" : "image/png"),
|
|
82
|
+
width: (o.width as number) ?? null,
|
|
83
|
+
height: (o.height as number) ?? null,
|
|
84
|
+
duration_s: (o.duration_s as number) ?? null,
|
|
85
|
+
fps_native: (o.fps_native as number) ?? null,
|
|
86
|
+
channels: (o.channels as number) ?? null,
|
|
87
|
+
sample_rate: (o.sample_rate as number) ?? null,
|
|
88
|
+
} as MediaRef;
|
|
89
|
+
addToLibrary(ref, "history", `${String(j.name)} · ${id.slice(0, 8)}`, {
|
|
90
|
+
genKind: (j.name === "generate_studio_movie"
|
|
91
|
+
? "generate_movie"
|
|
92
|
+
: j.name) as LibraryGenKind,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
try { sessionStorage.setItem(flagKey(), String(Date.now())); } catch { /* no-op */ }
|
|
97
|
+
})();
|
|
98
|
+
return () => { cancelled = true; };
|
|
99
|
+
}, []);
|
|
100
|
+
}
|
|
@@ -1,228 +0,0 @@
|
|
|
1
|
-
// ACTIVE PROCESSES — the console-wide, placement-aware view of every in-flight
|
|
2
|
-
// media-bus job (studio / movie / identity / generate / crop / extract), fed by
|
|
3
|
-
// GET /video/jobs. Promotes the studio-only StudioActiveProcesses pattern to the
|
|
4
|
-
// whole console and adds the two things that were invisible before:
|
|
5
|
-
//
|
|
6
|
-
// • WHERE a job physically executes — the `placement` line ("ae · cuda:0 ·
|
|
7
|
-
// P-studio"), with an "external" cue when it runs OFF the central box (an ae
|
|
8
|
-
// worker, ComfyUI, or the identity-render service on :9750), plus reserved
|
|
9
|
-
// VRAM when a GPU reservation is held.
|
|
10
|
-
// • The reservation HOLD phase — a job that can't fit the card yet sits as
|
|
11
|
-
// `awaiting_capacity` (with the shortfall reason on hover + the overtaken
|
|
12
|
-
// count), not a fake "running".
|
|
13
|
-
// • WHAT it is doing right now — a moving progress bar (segments done plus the
|
|
14
|
-
// within-segment denoise fraction the runner reports) and the TAIL of the job's
|
|
15
|
-
// stage timeline, so a running render shows its last few events instead of an
|
|
16
|
-
// opaque "running" chip.
|
|
17
|
-
// • WHY it broke — a terminal-failed job renders its failure envelope VERBATIM
|
|
18
|
-
// (code + message + the stage it died in), never a generic "failed". Terminal
|
|
19
|
-
// rows arrive only with the "Show finished" filter on.
|
|
20
|
-
//
|
|
21
|
-
// HONEST rendering only: no fabricated progress bars — a numeric progress/stage
|
|
22
|
-
// shows only when the server sends one. Cancel hits the same /video/jobs/<id>/
|
|
23
|
-
// cancel seam every station uses; a held job cancels exactly like any queued job.
|
|
24
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
25
|
-
import type { StationSpec } from "./types";
|
|
26
|
-
import { request } from "../transport/client";
|
|
27
|
-
import { jobCancelUrl } from "../config";
|
|
28
|
-
import { ProcessTimeline, shortId } from "./studio/studioShared";
|
|
29
|
-
import {
|
|
30
|
-
useMediaJobs,
|
|
31
|
-
placementLine,
|
|
32
|
-
isExternal,
|
|
33
|
-
reservedGib,
|
|
34
|
-
elapsedSince,
|
|
35
|
-
progressLabel,
|
|
36
|
-
latestEvent,
|
|
37
|
-
type MediaJob,
|
|
38
|
-
} from "../video/useMediaJobs";
|
|
39
|
-
|
|
40
|
-
function PhaseChip({ job }: { job: MediaJob }) {
|
|
41
|
-
if (job.phase === "awaiting_capacity") {
|
|
42
|
-
const reason = job.hold?.reason ? JSON.stringify(job.hold.reason) : "";
|
|
43
|
-
const overtaken = job.hold?.overtaken ?? 0;
|
|
44
|
-
return (
|
|
45
|
-
<span
|
|
46
|
-
className="vi-status vi-status-awaiting_capacity"
|
|
47
|
-
title={
|
|
48
|
-
reason
|
|
49
|
-
? `Held — waiting for GPU capacity. Shortfall: ${reason}`
|
|
50
|
-
: "Held — waiting for GPU capacity"
|
|
51
|
-
}
|
|
52
|
-
>
|
|
53
|
-
awaiting capacity{overtaken > 0 ? ` · jumped ${overtaken}×` : ""}
|
|
54
|
-
</span>
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
return <span className={`vi-status vi-status-${job.phase}`}>{job.phase}</span>;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** The bar + its honest label. Renders nothing when the server sent no number. */
|
|
61
|
-
function ProgressBar({ job }: { job: MediaJob }) {
|
|
62
|
-
if (job.progress == null) return null;
|
|
63
|
-
const pct = Math.max(0, Math.min(100, job.progress * 100));
|
|
64
|
-
return (
|
|
65
|
-
<div className="vi-job-progress">
|
|
66
|
-
<div className="vi-job-progress-track">
|
|
67
|
-
<div className="vi-job-progress-fill" style={{ width: `${pct}%` }} />
|
|
68
|
-
</div>
|
|
69
|
-
<span className="vi-job-progress-label">{progressLabel(job)}</span>
|
|
70
|
-
</div>
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// `spec` is optional because this body has TWO mounts: the registry's /active
|
|
75
|
-
// station (which always passes one) and the sidebar's ActivePanel overlay
|
|
76
|
-
// (2026-08-06), which mounts it inside the shared drawer shell and has no spec
|
|
77
|
-
// to hand it. The prop was already unused here, so optional costs nothing and
|
|
78
|
-
// keeps ONE renderer for the feed instead of an overlay-only copy.
|
|
79
|
-
export function ActiveProcessesStation({ spec: _spec }: { spec?: StationSpec } = {}) {
|
|
80
|
-
const [showFinished, setShowFinished] = useState(false);
|
|
81
|
-
const { jobs, refetch } = useMediaJobs(showFinished);
|
|
82
|
-
// Expanded rows + a 1s clock, exactly as the studio tab's sibling panel does —
|
|
83
|
-
// ProcessTimeline needs `nowMs` to age its "…since last movement" line.
|
|
84
|
-
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
85
|
-
const [nowMs, setNowMs] = useState(() => Date.now());
|
|
86
|
-
useEffect(() => {
|
|
87
|
-
const id = window.setInterval(() => setNowMs(Date.now()), 1000);
|
|
88
|
-
return () => window.clearInterval(id);
|
|
89
|
-
}, []);
|
|
90
|
-
const toggle = useCallback((id: string) => {
|
|
91
|
-
setExpanded((prev) => {
|
|
92
|
-
const n = new Set(prev);
|
|
93
|
-
if (n.has(id)) n.delete(id);
|
|
94
|
-
else n.add(id);
|
|
95
|
-
return n;
|
|
96
|
-
});
|
|
97
|
-
}, []);
|
|
98
|
-
const [cancelBusy, setCancelBusy] = useState<Set<string>>(new Set());
|
|
99
|
-
const mounted = useRef(true);
|
|
100
|
-
|
|
101
|
-
const onCancel = useCallback(
|
|
102
|
-
async (jobId: string) => {
|
|
103
|
-
setCancelBusy((prev) => new Set(prev).add(jobId));
|
|
104
|
-
try {
|
|
105
|
-
await request<unknown>(jobCancelUrl(jobId), {
|
|
106
|
-
method: "POST",
|
|
107
|
-
meta: { specKey: "media", operation: "media.job.cancel" },
|
|
108
|
-
});
|
|
109
|
-
if (!mounted.current) return;
|
|
110
|
-
refetch();
|
|
111
|
-
} finally {
|
|
112
|
-
if (mounted.current)
|
|
113
|
-
setCancelBusy((prev) => {
|
|
114
|
-
const n = new Set(prev);
|
|
115
|
-
n.delete(jobId);
|
|
116
|
-
return n;
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
},
|
|
120
|
-
[refetch],
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
return (
|
|
124
|
-
<section className="station-card station-wide vi-studio-active">
|
|
125
|
-
<h2>Active Processes</h2>
|
|
126
|
-
<p className="vi-knob-hint">
|
|
127
|
-
Every in-flight media build across the console, and WHERE it physically runs.
|
|
128
|
-
A build marked <strong>external</strong> is executing off this box — on the{" "}
|
|
129
|
-
ae worker, ComfyUI, or the identity-render service.
|
|
130
|
-
</p>
|
|
131
|
-
<label className="vi-knob-hint vi-job-filter">
|
|
132
|
-
<input
|
|
133
|
-
type="checkbox"
|
|
134
|
-
checked={showFinished}
|
|
135
|
-
onChange={(e) => setShowFinished(e.target.checked)}
|
|
136
|
-
/>{" "}
|
|
137
|
-
Show finished — recent done/failed/cancelled builds, with each failure's
|
|
138
|
-
full stage timeline and its exact error.
|
|
139
|
-
</label>
|
|
140
|
-
{jobs.length === 0 ? (
|
|
141
|
-
<p className="vi-knob-hint">
|
|
142
|
-
No active builds — enqueue a render and it appears here with its placement.
|
|
143
|
-
</p>
|
|
144
|
-
) : (
|
|
145
|
-
<ul className="vi-studio-active-list">
|
|
146
|
-
{jobs.map((j) => {
|
|
147
|
-
const line = placementLine(j.placement);
|
|
148
|
-
const gib = reservedGib(j.placement);
|
|
149
|
-
const external = isExternal(j.placement);
|
|
150
|
-
const isOpen = expanded.has(j.id);
|
|
151
|
-
const elided = j.stageLogTotal - j.stageLog.length;
|
|
152
|
-
const event = latestEvent(j);
|
|
153
|
-
return (
|
|
154
|
-
<li key={j.id} className="vi-studio-active-row">
|
|
155
|
-
<div className="vi-studio-active-head">
|
|
156
|
-
<button
|
|
157
|
-
type="button"
|
|
158
|
-
className="vi-timeline-toggle"
|
|
159
|
-
onClick={() => toggle(j.id)}
|
|
160
|
-
aria-expanded={isOpen}
|
|
161
|
-
title={isOpen ? "Hide detail" : "Show what it's doing"}
|
|
162
|
-
>
|
|
163
|
-
<span className="vi-timeline-caret" aria-hidden>
|
|
164
|
-
{isOpen ? "▾" : "▸"}
|
|
165
|
-
</span>
|
|
166
|
-
</button>
|
|
167
|
-
<PhaseChip job={j} />
|
|
168
|
-
<span className="vi-studio-active-id">
|
|
169
|
-
{j.name || "job"} · {shortId(j.id)}
|
|
170
|
-
</span>
|
|
171
|
-
</div>
|
|
172
|
-
<div className="vi-studio-active-meta">
|
|
173
|
-
{line ? (
|
|
174
|
-
<span
|
|
175
|
-
className={`vi-placement${external ? " vi-placement-external" : ""}`}
|
|
176
|
-
title={external ? "Runs off the central box" : undefined}
|
|
177
|
-
>
|
|
178
|
-
{external ? "⇄ " : ""}
|
|
179
|
-
{line}
|
|
180
|
-
{external ? " · external" : ""}
|
|
181
|
-
</span>
|
|
182
|
-
) : null}
|
|
183
|
-
{gib ? <span>· {gib}</span> : null}
|
|
184
|
-
{j.stage ? <span>· {j.stage}</span> : null}
|
|
185
|
-
<span>· {elapsedSince(j.createdAt)}</span>
|
|
186
|
-
</div>
|
|
187
|
-
<ProgressBar job={j} />
|
|
188
|
-
{event ? <p className="vi-job-latest">{event}</p> : null}
|
|
189
|
-
{isOpen ? (
|
|
190
|
-
<div className="vi-timeline-panel">
|
|
191
|
-
{/* The SAME renderer the studio tab uses: the failure envelope
|
|
192
|
-
verbatim (code · stage · message), the live "now" line, and
|
|
193
|
-
every stage the render moved through. A live row carries a
|
|
194
|
-
tail; a terminal one carries the whole sequence. */}
|
|
195
|
-
<ProcessTimeline
|
|
196
|
-
stageLog={j.stageLog}
|
|
197
|
-
failure={j.failure}
|
|
198
|
-
currentStage={j.stage}
|
|
199
|
-
lastMovementTs={j.progressedAt}
|
|
200
|
-
terminal={j.terminal}
|
|
201
|
-
nowMs={nowMs}
|
|
202
|
-
/>
|
|
203
|
-
{elided > 0 ? (
|
|
204
|
-
<p className="vi-knob-hint">
|
|
205
|
-
…{elided} earlier event{elided === 1 ? "" : "s"} — open the
|
|
206
|
-
job for its full history.
|
|
207
|
-
</p>
|
|
208
|
-
) : null}
|
|
209
|
-
</div>
|
|
210
|
-
) : null}
|
|
211
|
-
{j.terminal ? null : (
|
|
212
|
-
<button
|
|
213
|
-
type="button"
|
|
214
|
-
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
215
|
-
onClick={() => void onCancel(j.id)}
|
|
216
|
-
disabled={cancelBusy.has(j.id)}
|
|
217
|
-
>
|
|
218
|
-
{cancelBusy.has(j.id) ? "Cancelling…" : "✕ Cancel"}
|
|
219
|
-
</button>
|
|
220
|
-
)}
|
|
221
|
-
</li>
|
|
222
|
-
);
|
|
223
|
-
})}
|
|
224
|
-
</ul>
|
|
225
|
-
)}
|
|
226
|
-
</section>
|
|
227
|
-
);
|
|
228
|
-
}
|