@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.
@@ -1,417 +0,0 @@
1
- // CINEMA SESSIONS PANEL — "which of these movies is waiting on me", answered from
2
- // the studio-movies root instead of from a job id that may be long gone.
3
- //
4
- // A movie is ONE bus job that can run for hours; when that job ended (cancelled,
5
- // reaped, or lost with the browser tab) its rendered segments stayed on disk and
6
- // became unreachable — real GPU hours with no way to see or continue them. This panel
7
- // is the handle on those dirs: every session with its status, how far it got, what it
8
- // has ALREADY rendered (each done segment playable inline, as it lands), and the two
9
- // actions — Resume (re-enqueue the persisted spec; the clips are the checkpoint) and
10
- // Pause (cooperative cancel + "parked, not abandoned").
11
- //
12
- // Idioms borrowed rather than reinvented: the row/chip/caret shapes are
13
- // StudioActiveProcesses', the progress bar + latest-event line are the console-wide
14
- // Active Processes renderers (useMediaJobs' `progressLabel`/`latestEvent`), and the
15
- // list hook mirrors useStudioClips. Nothing here fabricates a number: a bar appears
16
- // only when the server sent one, and a disabled Resume always says WHY.
17
- import { useEffect, useState } from "react";
18
- import {
19
- useMovieSessions,
20
- segmentClipSrc,
21
- movieMediaSrc,
22
- isSessionLive,
23
- resumeBlockedReason,
24
- type MovieSession,
25
- type MovieSegment,
26
- } from "../../video/useMovieSessions";
27
- import {
28
- useMediaJobs,
29
- progressLabel,
30
- latestEvent,
31
- } from "../../video/useMediaJobs";
32
- import { shortId, agoText, fmtWhen } from "./studioShared";
33
-
34
- // Segment statuses that mean "this one is finished" — the rest are pending (dimmed)
35
- // or failed (error shown verbatim).
36
- const SEG_DONE = new Set(["done", "resumed"]);
37
-
38
- /**
39
- * The live pace of ONE in-flight movie job, from the bus feed the studio arm already
40
- * polls: the folded 0..1 bar plus "segment 2/14 · step 18/32" and the freshest stage
41
- * line. This is what makes a 14-segment render legible in its first minute instead of
42
- * only when segment 1 lands.
43
- *
44
- * It owns its own `useMediaJobs` subscription and is therefore mounted ONLY while
45
- * something is actually running (a live session row, or the composer's own render) —
46
- * no in-flight movie, no poll. Shared by both call sites so there is one renderer for
47
- * "how fast is this going".
48
- */
49
- export function MovieLiveProgress({ jobId }: { jobId: string | null }) {
50
- const { jobs } = useMediaJobs();
51
- const job = jobId ? jobs.find((j) => j.id === jobId) ?? null : null;
52
- if (!job) return null;
53
- const label = progressLabel(job);
54
- const event = latestEvent(job);
55
- const pct = job.progress == null ? null : Math.max(0, Math.min(100, job.progress * 100));
56
- return (
57
- <>
58
- {(pct != null || label) && (
59
- <div className="vi-job-progress">
60
- {pct != null && (
61
- <div className="vi-job-progress-track">
62
- <div className="vi-job-progress-fill" style={{ width: `${pct}%` }} />
63
- </div>
64
- )}
65
- {label ? <span className="vi-job-progress-label">{label}</span> : null}
66
- </div>
67
- )}
68
- {event ? <p className="vi-job-latest">{event}</p> : null}
69
- </>
70
- );
71
- }
72
-
73
- /** True for a segment that has not started — the spec's not-yet-rendered tail, which
74
- * is listed (dimmed) so a 14-segment movie mid-render never reads as a 2-segment one. */
75
- function isPending(seg: MovieSegment): boolean {
76
- const status = seg.status ?? "pending";
77
- return !SEG_DONE.has(status) && status !== "failed";
78
- }
79
-
80
- // NOTE ON `key`: the row bodies below are CONTENT, not the <li> itself — the list
81
- // element (and its key) stays in the .map. This project's scoped tsconfig rejects a
82
- // `key` prop on a locally-declared component (the same reason StudioSplicedRow is
83
- // rendered once, never in a map), so keeping the key on the intrinsic element is the
84
- // house idiom rather than a workaround invented here.
85
-
86
- /** One segment's content: playable when its bytes exist, verbatim when it failed. */
87
- function SegmentRowBody({ seg }: { seg: MovieSegment }) {
88
- const src = segmentClipSrc(seg);
89
- const status = seg.status ?? "pending";
90
- const failed = status === "failed";
91
- const pending = isPending(seg);
92
- return (
93
- <>
94
- {src ? (
95
- <video
96
- src={`${src}#t=0.1`}
97
- controls
98
- muted
99
- playsInline
100
- preload="metadata"
101
- style={{
102
- width: "10rem",
103
- maxWidth: "34vw",
104
- borderRadius: "0.4rem",
105
- background: "#000",
106
- border: "1px solid var(--vi-border)",
107
- }}
108
- />
109
- ) : null}
110
- <div style={{ display: "flex", flexDirection: "column", gap: "0.15rem", minWidth: 0 }}>
111
- <span style={{ fontSize: "0.85rem", fontWeight: 600 }}>
112
- Segment {seg.index + 1}
113
- <span className={`vi-status vi-status-${status}`} style={{ marginLeft: "0.5rem" }}>
114
- {status}
115
- {seg.resumed ? " · resumed" : ""}
116
- </span>
117
- </span>
118
- {failed && (
119
- // The runner's own envelope, unsummarised — a code + message an operator can
120
- // act on beats a generic "failed".
121
- <span className="vi-error" role="alert" style={{ fontSize: "0.8rem" }}>
122
- {seg.error?.code ? `[${seg.error.code}] ` : ""}
123
- {seg.error?.message ?? "failed (no message recorded)"}
124
- </span>
125
- )}
126
- {!src && !failed && !pending && (
127
- <span className="vi-comfy-hint" style={{ opacity: 0.7 }}>
128
- rendered, but its clip is not on disk right now
129
- </span>
130
- )}
131
- {(seg.frames != null || seg.duration_s != null) && (
132
- <span className="vi-comfy-hint" style={{ opacity: 0.7 }}>
133
- {seg.frames != null ? `${seg.frames} frames` : ""}
134
- {seg.duration_s != null ? `${seg.frames != null ? " · " : ""}${Math.round(seg.duration_s)}s` : ""}
135
- </span>
136
- )}
137
- {seg.prompt ? (
138
- <span className="vi-comfy-hint" style={{ opacity: 0.7, wordBreak: "break-word" }}>
139
- “{seg.prompt}”
140
- </span>
141
- ) : null}
142
- </div>
143
- </>
144
- );
145
- }
146
-
147
- /** One session's content — head line, meta line, its ONE action, live pace while it
148
- * runs, and (expanded) the segment strip. The <li className="vi-studio-active-row">
149
- * wrapper stays in the .map (see the key note above); these stay its direct children
150
- * so the row's CSS (which orders the button and the full-width panel) still applies. */
151
- function SessionRowBody({
152
- session,
153
- expanded,
154
- onToggle,
155
- busy,
156
- actionError,
157
- onPause,
158
- onResume,
159
- }: {
160
- session: MovieSession;
161
- expanded: boolean;
162
- onToggle: () => void;
163
- busy: boolean;
164
- actionError: string | null;
165
- onPause: () => void;
166
- onResume: () => void;
167
- }) {
168
- const s = session;
169
- const live = isSessionLive(s);
170
- const blocked = resumeBlockedReason(s);
171
- const status = s.status ?? "unknown";
172
- const done = s.segments_completed ?? 0;
173
- const total = s.segments_total ?? s.segments?.length ?? 0;
174
- const movieSrc = movieMediaSrc(s.movie);
175
- const geometry =
176
- s.width != null && s.height != null
177
- ? `${s.width}×${s.height}${s.fps != null ? ` · ${s.fps}fps` : ""}`
178
- : "";
179
- return (
180
- <>
181
- <div className="vi-studio-active-head">
182
- <button
183
- type="button"
184
- className="vi-timeline-toggle"
185
- onClick={onToggle}
186
- aria-expanded={expanded}
187
- title={expanded ? "Hide segments" : "Show what this movie has rendered"}
188
- >
189
- <span className="vi-timeline-caret" aria-hidden>
190
- {expanded ? "▾" : "▸"}
191
- </span>
192
- </button>
193
- <span className={`vi-status vi-status-${status}`}>{status}</span>
194
- <span className="vi-studio-active-id">
195
- {s.title?.trim() ? s.title : "untitled"} · {shortId(s.movie_id)}
196
- </span>
197
- </div>
198
- <div className="vi-studio-active-meta">
199
- <span>
200
- {done}/{total} segments
201
- </span>
202
- {geometry ? <span>· {geometry}</span> : null}
203
- {s.id_lock ? <span>· id_lock</span> : null}
204
- {s.project ? <span>· {s.project}</span> : null}
205
- {s.updated != null ? (
206
- <span title={fmtWhen(s.updated)}>· {agoText(s.updated, Date.now())} ago</span>
207
- ) : null}
208
- </div>
209
- {/* ONE action per row, and never both: a live movie gets Pause, everything else
210
- gets Resume. `status === "running"` is folded into the live test defensively —
211
- the route only reports running while the bus agrees, but a row that claims to
212
- be rendering must always be stoppable, and pause is idempotent anyway. */}
213
- {live || status === "running" ? (
214
- <button
215
- type="button"
216
- className="vi-btn vi-btn-sm vi-btn-ghost"
217
- disabled={busy}
218
- title="Stop this movie between segments and park it — everything already rendered stays on disk."
219
- onClick={onPause}
220
- >
221
- {busy ? "Pausing…" : "⏸ Pause"}
222
- </button>
223
- ) : (
224
- // The title rides the WRAPPER: a disabled button swallows pointer events in
225
- // most browsers, so a tooltip on it is a reason nobody can read.
226
- <span
227
- title={
228
- blocked ??
229
- "Re-enqueue this movie's spec — segments already rendered come back from the " +
230
- "clip store without touching a GPU, so it picks up where it stopped."
231
- }
232
- >
233
- <button
234
- type="button"
235
- className="vi-btn vi-btn-sm"
236
- disabled={busy || blocked != null}
237
- onClick={onResume}
238
- >
239
- {busy ? "Resuming…" : "▶ Resume"}
240
- </button>
241
- </span>
242
- )}
243
- {/* An UNFINISHED movie that cannot be resumed says so in the row itself, not only
244
- in a tooltip — that is the pre-feature (no spec.json) case, and it is the one
245
- an operator will otherwise keep clicking. A finished movie needs no excuse. */}
246
- {blocked && status !== "done" && !live ? (
247
- <p
248
- className="vi-knob-hint"
249
- style={{ flexBasis: "100%", margin: "0.2rem 0 0", wordBreak: "break-word" }}
250
- >
251
- {blocked}
252
- </p>
253
- ) : null}
254
- {/* Live pace for a running session — mounted only while it IS running, so a list
255
- of parked movies costs no job poll. */}
256
- {live ? <MovieLiveProgress jobId={s.job_id ?? null} /> : null}
257
- {actionError ? (
258
- // Verbatim: the 409 bodies ("no persisted spec.json…", "already running —
259
- // pause it first") are the whole explanation, and paraphrasing them would
260
- // hide which of the two happened.
261
- <p className="vi-error" role="alert" style={{ flexBasis: "100%", margin: "0.2rem 0 0" }}>
262
- {actionError}
263
- </p>
264
- ) : null}
265
- {expanded ? (
266
- <div className="vi-timeline-panel">
267
- {movieSrc ? (
268
- <div style={{ marginBottom: "0.6rem" }}>
269
- <p className="vi-comfy-label" style={{ marginBottom: "0.3rem" }}>
270
- Assembled movie
271
- </p>
272
- <video
273
- src={movieSrc}
274
- controls
275
- playsInline
276
- preload="metadata"
277
- style={{
278
- width: "18rem",
279
- maxWidth: "60vw",
280
- borderRadius: "0.4rem",
281
- background: "#000",
282
- border: "1px solid var(--vi-border)",
283
- }}
284
- />
285
- </div>
286
- ) : null}
287
- {s.segments?.length ? (
288
- <ul
289
- style={{
290
- listStyle: "none",
291
- margin: 0,
292
- padding: 0,
293
- display: "flex",
294
- flexDirection: "column",
295
- gap: "0.6rem",
296
- }}
297
- >
298
- {s.segments.map((seg) => (
299
- <li
300
- key={`${s.movie_id}:${seg.index}`}
301
- style={{
302
- display: "flex",
303
- gap: "0.7rem",
304
- alignItems: "flex-start",
305
- // Not-yet-started segments are dimmed, not hidden: the strip shows
306
- // the whole movie, so "how much is left" is visible at a glance.
307
- opacity: isPending(seg) ? 0.55 : 1,
308
- }}
309
- >
310
- <SegmentRowBody seg={seg} />
311
- </li>
312
- ))}
313
- </ul>
314
- ) : (
315
- <p className="vi-knob-hint" style={{ margin: 0 }}>
316
- No segment records yet — this session was submitted but has not written one.
317
- </p>
318
- )}
319
- </div>
320
- ) : null}
321
- </>
322
- );
323
- }
324
-
325
- /** The panel body — split out so the poll it drives exists ONLY while the panel is
326
- * open (mounting is the visibility signal; see the `active` gate below). */
327
- function SessionsBody() {
328
- const [expanded, setExpanded] = useState<Set<string>>(new Set());
329
- // A hidden browser tab is not a visible panel: pause the walk of the movies root
330
- // while the console is in the background, and reload the moment it comes back.
331
- const [docVisible, setDocVisible] = useState(
332
- () => typeof document === "undefined" || document.visibilityState !== "hidden",
333
- );
334
- useEffect(() => {
335
- const onVis = () => setDocVisible(document.visibilityState !== "hidden");
336
- document.addEventListener("visibilitychange", onVis);
337
- return () => document.removeEventListener("visibilitychange", onVis);
338
- }, []);
339
-
340
- const { sessions, loading, error, busy, actionError, pause, resume } =
341
- useMovieSessions(docVisible);
342
-
343
- const toggle = (movieId: string) =>
344
- setExpanded((prev) => {
345
- const next = new Set(prev);
346
- if (next.has(movieId)) next.delete(movieId);
347
- else next.add(movieId);
348
- return next;
349
- });
350
-
351
- if (loading && sessions.length === 0) {
352
- return <p className="vi-knob-hint">Reading the movies root…</p>;
353
- }
354
- if (error && sessions.length === 0) {
355
- return (
356
- <p className="vi-error" role="alert">
357
- {error}
358
- </p>
359
- );
360
- }
361
- if (sessions.length === 0) {
362
- return (
363
- <p className="vi-knob-hint">
364
- No movie sessions on disk yet — generate one below and it appears here, resumable
365
- even if this tab goes away.
366
- </p>
367
- );
368
- }
369
- return (
370
- <ul className="vi-studio-active-list">
371
- {sessions.map((s) => (
372
- <li key={s.movie_id} className="vi-studio-active-row">
373
- <SessionRowBody
374
- session={s}
375
- expanded={expanded.has(s.movie_id)}
376
- onToggle={() => toggle(s.movie_id)}
377
- busy={busy.has(s.movie_id)}
378
- actionError={actionError[s.movie_id] ?? null}
379
- onPause={() => pause(s.movie_id)}
380
- onResume={() => resume(s.movie_id)}
381
- />
382
- </li>
383
- ))}
384
- </ul>
385
- );
386
- }
387
-
388
- /**
389
- * The collapsible Cinema-sessions panel. Open by default (the recovery affordance is
390
- * the point — an operator coming back to a lost render should not have to know to look
391
- * for it), and the body unmounts when collapsed, which is what stops the poll.
392
- */
393
- export function StudioMovieSessions() {
394
- const [open, setOpen] = useState(true);
395
- return (
396
- <section className="vi-studio-active" aria-label="Cinema sessions">
397
- <button
398
- type="button"
399
- className="vi-timeline-toggle"
400
- aria-expanded={open}
401
- onClick={() => setOpen((v) => !v)}
402
- style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}
403
- >
404
- <span className="vi-timeline-caret" aria-hidden>
405
- {open ? "▾" : "▸"}
406
- </span>
407
- <span className="vi-knob-headline" style={{ margin: 0 }}>
408
- Cinema sessions{" "}
409
- <span className="vi-knob-headline-sub">
410
- movies on disk — resume, pause, or watch what already rendered
411
- </span>
412
- </span>
413
- </button>
414
- {open ? <SessionsBody /> : null}
415
- </section>
416
- );
417
- }