@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.
@@ -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 &amp; 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&apos;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
- }