@hugpy/video-intelligence-ui 0.1.0

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 (177) hide show
  1. package/LICENSE +41 -0
  2. package/assets/sampels/images/bycycle/bd8603bad35d4f179f4d1134d8faf2ee.mp4 +0 -0
  3. package/assets/sampels/images/bycycle/frame_00000.png +0 -0
  4. package/assets/sampels/images/bycycle/frame_00001.png +0 -0
  5. package/assets/sampels/images/bycycle/frame_00002.png +0 -0
  6. package/assets/sampels/images/bycycle/frame_00003.png +0 -0
  7. package/assets/sampels/images/bycycle/frame_00004.png +0 -0
  8. package/assets/sampels/images/bycycle/frame_00005.png +0 -0
  9. package/assets/sampels/images/forrest/ff23090611b844a6b85c224f388bd542.mp4 +0 -0
  10. package/assets/sampels/images/forrest/frame_00000.png +0 -0
  11. package/assets/sampels/images/forrest/frame_00001.png +0 -0
  12. package/assets/sampels/images/forrest/frame_00002.png +0 -0
  13. package/assets/sampels/images/forrest/frame_00003.png +0 -0
  14. package/assets/sampels/images/forrest/frame_00004.png +0 -0
  15. package/assets/sampels/images/forrest/frame_00005.png +0 -0
  16. package/assets/sampels/images/shenzo/e1a4fcd623084ab4a986d11c8cd00db8.mp4 +0 -0
  17. package/assets/sampels/images/shenzo/frame_00000.png +0 -0
  18. package/assets/sampels/images/shenzo/frame_00001.png +0 -0
  19. package/assets/sampels/images/shenzo/frame_00002.png +0 -0
  20. package/assets/sampels/images/shenzo/frame_00003.png +0 -0
  21. package/assets/sampels/images/shenzo/frame_00004.png +0 -0
  22. package/assets/sampels/images/shenzo/frame_00005.png +0 -0
  23. package/assets/sampels/scenes/man.mp4 +0 -0
  24. package/assets/sampels/scenes/woman.mp4 +0 -0
  25. package/assets/sampels/studio_movies/caverns/_assembly/contrib_00.mp4 +0 -0
  26. package/assets/sampels/studio_movies/caverns/movie.json +67 -0
  27. package/assets/sampels/studio_movies/caverns/movie.mp4 +0 -0
  28. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/clip.mp4 +0 -0
  29. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/manifest.json +87 -0
  30. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/provenance.json +7 -0
  31. package/assets/sampels/studio_movies/caverns/segment_01/branch.png +0 -0
  32. package/assets/sampels/studio_movies/keeper-matrix/_assembly/concat_list.txt +2 -0
  33. package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_00.mp4 +0 -0
  34. package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_01.mp4 +0 -0
  35. package/assets/sampels/studio_movies/keeper-matrix/movie.json +81 -0
  36. package/assets/sampels/studio_movies/keeper-matrix/movie.mp4 +0 -0
  37. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/clip.mp4 +0 -0
  38. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/manifest.json +87 -0
  39. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/provenance.json +7 -0
  40. package/assets/sampels/studio_movies/keeper-matrix/segment_01/branch.png +0 -0
  41. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/clip.mp4 +0 -0
  42. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/manifest.json +87 -0
  43. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/provenance.json +7 -0
  44. package/assets/sampels/videos/a_red_paper_boat_drifting_on_a_calm_pond__gentle_ripples__mo_a62d6c28.mp4 +0 -0
  45. package/assets/sampels/videos/aurora.mp4 +0 -0
  46. package/assets/sampels/videos/dark_night.mp4 +0 -0
  47. package/assets/sampels/videos/fire_and_soul.mp4 +0 -0
  48. package/assets/sampels/videos/harsh_seas.mp4 +0 -0
  49. package/entry.tsx +74 -0
  50. package/index.html +53 -0
  51. package/package.json +42 -0
  52. package/src/assets/hugpy-mark.png +0 -0
  53. package/src/config.ts +676 -0
  54. package/src/demo/DemoBanner.tsx +54 -0
  55. package/src/demo/assets/demo-audio.mp3 +0 -0
  56. package/src/demo/assets/demo-clip.mp4 +0 -0
  57. package/src/demo/assets/demo-crop.mp4 +0 -0
  58. package/src/demo/assets/demo-poster.jpg +0 -0
  59. package/src/demo/assets/frame-1.jpg +0 -0
  60. package/src/demo/assets/frame-2.jpg +0 -0
  61. package/src/demo/assets/frame-3.jpg +0 -0
  62. package/src/demo/assets/frame-4.jpg +0 -0
  63. package/src/demo/assets/frame-5.jpg +0 -0
  64. package/src/demo/assets/frame-6.jpg +0 -0
  65. package/src/demo/assets/gen-1.jpg +0 -0
  66. package/src/demo/assets/gen-2.jpg +0 -0
  67. package/src/demo/assets.d.ts +14 -0
  68. package/src/demo/demo.css +64 -0
  69. package/src/demo/demoFetch.ts +305 -0
  70. package/src/demo/fixtures.ts +814 -0
  71. package/src/demo/index.ts +43 -0
  72. package/src/demo/mode.ts +81 -0
  73. package/src/demo/promptAssist.ts +181 -0
  74. package/src/demo/seed.ts +97 -0
  75. package/src/nav/BrandMark.css +66 -0
  76. package/src/nav/BrandMark.tsx +40 -0
  77. package/src/nav/Navbar.css +13 -0
  78. package/src/nav/Navbar.tsx +68 -0
  79. package/src/regions/SpatialRegionEditor.tsx +375 -0
  80. package/src/regions/TemporalRegionEditor.tsx +485 -0
  81. package/src/regions/types.ts +85 -0
  82. package/src/session.ts +85 -0
  83. package/src/share.ts +77 -0
  84. package/src/stations/ActivePanel.tsx +55 -0
  85. package/src/stations/ActiveProcessesStation.tsx +272 -0
  86. package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +228 -0
  87. package/src/stations/AddFileBar.tsx +59 -0
  88. package/src/stations/AudioCropStation.tsx +157 -0
  89. package/src/stations/ComfyPanel.tsx +210 -0
  90. package/src/stations/ComfyStation.tsx +196 -0
  91. package/src/stations/CondensedKnobStrip.tsx +245 -0
  92. package/src/stations/ConsolePanel.tsx +82 -0
  93. package/src/stations/FrameExtractStation.tsx +148 -0
  94. package/src/stations/GenIdentityBar.tsx +412 -0
  95. package/src/stations/GenerateStation.tsx +5603 -0
  96. package/src/stations/GenerateStation.tsx.bak-tester-removal +5170 -0
  97. package/src/stations/GoalComposer.tsx +139 -0
  98. package/src/stations/ImageCropStation.tsx +153 -0
  99. package/src/stations/KnobInfo.tsx +64 -0
  100. package/src/stations/PlaceholderStation.tsx +26 -0
  101. package/src/stations/SectionTabs.tsx +276 -0
  102. package/src/stations/SharePanel.tsx +298 -0
  103. package/src/stations/SidebarRegistry.tsx +105 -0
  104. package/src/stations/StationShell.tsx +96 -0
  105. package/src/stations/StudioClipsStation.tsx +28 -0
  106. package/src/stations/WorkbenchDrawer.tsx +66 -0
  107. package/src/stations/WorkbenchSidebar.tsx +1224 -0
  108. package/src/stations/WorkbenchStation.tsx +91 -0
  109. package/src/stations/comfyEmbed.ts +79 -0
  110. package/src/stations/drawerShell.tsx +419 -0
  111. package/src/stations/ops/AudioCropCore.tsx +218 -0
  112. package/src/stations/ops/CharacterGroupsPanel.tsx +716 -0
  113. package/src/stations/ops/FrameExtractCore.tsx +695 -0
  114. package/src/stations/ops/ImageCropCore.tsx +164 -0
  115. package/src/stations/ops/VideoCropCore.tsx +193 -0
  116. package/src/stations/ops/useVideoCropJobs.ts +224 -0
  117. package/src/stations/pollCaps.ts +87 -0
  118. package/src/stations/registry.ts +191 -0
  119. package/src/stations/studio/AngleRingEditor.tsx +159 -0
  120. package/src/stations/studio/IdentitiesStation.tsx +2455 -0
  121. package/src/stations/studio/IdentityProfileControls.tsx +262 -0
  122. package/src/stations/studio/StudioActiveProcesses.tsx +224 -0
  123. package/src/stations/studio/StudioAssistLogPanel.tsx +220 -0
  124. package/src/stations/studio/StudioGenerateMode.tsx +29 -0
  125. package/src/stations/studio/StudioGenerateTab.tsx +2688 -0
  126. package/src/stations/studio/StudioItemCard.tsx +334 -0
  127. package/src/stations/studio/StudioLibraryTab.tsx +506 -0
  128. package/src/stations/studio/StudioMovieComposer.tsx +2875 -0
  129. package/src/stations/studio/StudioMovieComposer.tsx.bak-sessions-20260812 +2729 -0
  130. package/src/stations/studio/StudioMovieSessions.tsx +553 -0
  131. package/src/stations/studio/StudioMovieSessions.tsx.bak-sessions-20260812 +417 -0
  132. package/src/stations/studio/StudioPlane.tsx +302 -0
  133. package/src/stations/studio/StudioSplicedRow.tsx +476 -0
  134. package/src/stations/studio/StudioViewer.tsx +339 -0
  135. package/src/stations/studio/TurntableViewer.tsx +220 -0
  136. package/src/stations/studio/movieTimeline.ts +198 -0
  137. package/src/stations/studio/promptCard.tsx +498 -0
  138. package/src/stations/studio/studioItem.ts +87 -0
  139. package/src/stations/studio/studioShared.tsx +821 -0
  140. package/src/stations/studio/useIdentityProfiles.ts +575 -0
  141. package/src/stations/studio/useReconstruction.ts +283 -0
  142. package/src/stations/types.ts +43 -0
  143. package/src/stations/useAudioCropJobs.ts +202 -0
  144. package/src/stations/useAudioExtractJob.ts +144 -0
  145. package/src/stations/useCropJobs.ts +206 -0
  146. package/src/stations/useFrameJobs.ts +140 -0
  147. package/src/stations/useGenerateJob.ts +151 -0
  148. package/src/stations/useGenerateMovieJob.ts +177 -0
  149. package/src/stations/useGenerateRowJobs.ts +184 -0
  150. package/src/stations/useGenerateSceneJob.ts +156 -0
  151. package/src/stations/useGenerateSceneRowJobs.ts +282 -0
  152. package/src/style/app.css +4974 -0
  153. package/src/transport/client.ts +262 -0
  154. package/src/transport/telemetry.ts +97 -0
  155. package/src/video/DropReceptacle.tsx +122 -0
  156. package/src/video/PromptAssistButtons.tsx +118 -0
  157. package/src/video/composerBridge.ts +66 -0
  158. package/src/video/contract.ts +599 -0
  159. package/src/video/jobTracker.ts +702 -0
  160. package/src/video/mediaLibrary.ts +328 -0
  161. package/src/video/renderCompat.ts +512 -0
  162. package/src/video/sessionForm.ts +189 -0
  163. package/src/video/spreadAssist.ts +392 -0
  164. package/src/video/studioBridge.ts +82 -0
  165. package/src/video/useMediaJobs.ts +279 -0
  166. package/src/video/useModels.ts +413 -0
  167. package/src/video/useMoviePresets.ts +97 -0
  168. package/src/video/useMovieSessions.ts +292 -0
  169. package/src/video/usePresets.ts +167 -0
  170. package/src/video/useProjects.ts +87 -0
  171. package/src/video/usePromptAssist.ts +394 -0
  172. package/src/video/useRenderPresets.ts +221 -0
  173. package/src/video/useStudioAssistLog.ts +266 -0
  174. package/src/video/useStudioPresets.ts +128 -0
  175. package/src/video/useWorkerCalls.ts +165 -0
  176. package/tsconfig.json +17 -0
  177. package/vite.config.ts +33 -0
@@ -0,0 +1,553 @@
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
+ type MediaJob,
32
+ } from "../../video/useMediaJobs";
33
+ import { JobRow } from "../ActiveProcessesStation";
34
+ import { shortId, agoText, fmtWhen } from "./studioShared";
35
+
36
+ // Segment statuses that mean "this one is finished" — the rest are pending (dimmed)
37
+ // or failed (error shown verbatim).
38
+ const SEG_DONE = new Set(["done", "resumed"]);
39
+
40
+ /**
41
+ * The live pace of ONE in-flight movie job, from the bus feed the studio arm already
42
+ * polls: the folded 0..1 bar plus "segment 2/14 · step 18/32" and the freshest stage
43
+ * line. This is what makes a 14-segment render legible in its first minute instead of
44
+ * only when segment 1 lands.
45
+ *
46
+ * It owns its own `useMediaJobs` subscription and is therefore mounted ONLY while
47
+ * something is actually running (a live session row, or the composer's own render) —
48
+ * no in-flight movie, no poll. Shared by both call sites so there is one renderer for
49
+ * "how fast is this going".
50
+ */
51
+ export function MovieLiveProgress({ jobId }: { jobId: string | null }) {
52
+ const { jobs } = useMediaJobs();
53
+ const job = jobId ? jobs.find((j) => j.id === jobId) ?? null : null;
54
+ if (!job) return null;
55
+ const label = progressLabel(job);
56
+ const event = latestEvent(job);
57
+ const pct = job.progress == null ? null : Math.max(0, Math.min(100, job.progress * 100));
58
+ return (
59
+ <>
60
+ {(pct != null || label) && (
61
+ <div className="vi-job-progress">
62
+ {pct != null && (
63
+ <div className="vi-job-progress-track">
64
+ <div className="vi-job-progress-fill" style={{ width: `${pct}%` }} />
65
+ </div>
66
+ )}
67
+ {label ? <span className="vi-job-progress-label">{label}</span> : null}
68
+ </div>
69
+ )}
70
+ {event ? <p className="vi-job-latest">{event}</p> : null}
71
+ </>
72
+ );
73
+ }
74
+
75
+ /** True for a segment that has not started — the spec's not-yet-rendered tail, which
76
+ * is listed (dimmed) so a 14-segment movie mid-render never reads as a 2-segment one. */
77
+ function isPending(seg: MovieSegment): boolean {
78
+ const status = seg.status ?? "pending";
79
+ return !SEG_DONE.has(status) && status !== "failed";
80
+ }
81
+
82
+ // NOTE ON `key`: the row bodies below are CONTENT, not the <li> itself — the list
83
+ // element (and its key) stays in the .map. This project's scoped tsconfig rejects a
84
+ // `key` prop on a locally-declared component (the same reason StudioSplicedRow is
85
+ // rendered once, never in a map), so keeping the key on the intrinsic element is the
86
+ // house idiom rather than a workaround invented here.
87
+
88
+ /** One segment's content: playable when its bytes exist, verbatim when it failed. */
89
+ function SegmentRowBody({ seg }: { seg: MovieSegment }) {
90
+ const src = segmentClipSrc(seg);
91
+ const status = seg.status ?? "pending";
92
+ const failed = status === "failed";
93
+ const pending = isPending(seg);
94
+ return (
95
+ <>
96
+ {src ? (
97
+ <video
98
+ src={`${src}#t=0.1`}
99
+ controls
100
+ muted
101
+ playsInline
102
+ preload="metadata"
103
+ style={{
104
+ width: "10rem",
105
+ maxWidth: "34vw",
106
+ borderRadius: "0.4rem",
107
+ background: "#000",
108
+ border: "1px solid var(--vi-border)",
109
+ }}
110
+ />
111
+ ) : null}
112
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.15rem", minWidth: 0 }}>
113
+ <span style={{ fontSize: "0.85rem", fontWeight: 600 }}>
114
+ Segment {seg.index + 1}
115
+ <span className={`vi-status vi-status-${status}`} style={{ marginLeft: "0.5rem" }}>
116
+ {status}
117
+ {seg.resumed ? " · resumed" : ""}
118
+ </span>
119
+ </span>
120
+ {failed && (
121
+ // The runner's own envelope, unsummarised — a code + message an operator can
122
+ // act on beats a generic "failed".
123
+ <span className="vi-error" role="alert" style={{ fontSize: "0.8rem" }}>
124
+ {seg.error?.code ? `[${seg.error.code}] ` : ""}
125
+ {seg.error?.message ?? "failed (no message recorded)"}
126
+ </span>
127
+ )}
128
+ {!src && !failed && !pending && (
129
+ <span className="vi-comfy-hint" style={{ opacity: 0.7 }}>
130
+ rendered, but its clip is not on disk right now
131
+ </span>
132
+ )}
133
+ {(seg.frames != null || seg.duration_s != null) && (
134
+ <span className="vi-comfy-hint" style={{ opacity: 0.7 }}>
135
+ {seg.frames != null ? `${seg.frames} frames` : ""}
136
+ {seg.duration_s != null ? `${seg.frames != null ? " · " : ""}${Math.round(seg.duration_s)}s` : ""}
137
+ </span>
138
+ )}
139
+ {seg.prompt ? (
140
+ <span className="vi-comfy-hint" style={{ opacity: 0.7, wordBreak: "break-word" }}>
141
+ “{seg.prompt}”
142
+ </span>
143
+ ) : null}
144
+ </div>
145
+ </>
146
+ );
147
+ }
148
+
149
+ /** One session's content — head line, meta line, its ONE action, live pace while it
150
+ * runs, and (expanded) the segment strip. The <li className="vi-studio-active-row">
151
+ * wrapper stays in the .map (see the key note above); these stay its direct children
152
+ * so the row's CSS (which orders the button and the full-width panel) still applies. */
153
+ function SessionRowBody({
154
+ session,
155
+ expanded,
156
+ onToggle,
157
+ busy,
158
+ actionError,
159
+ onPause,
160
+ onResume,
161
+ }: {
162
+ session: MovieSession;
163
+ expanded: boolean;
164
+ onToggle: () => void;
165
+ busy: boolean;
166
+ actionError: string | null;
167
+ onPause: () => void;
168
+ onResume: () => void;
169
+ }) {
170
+ const s = session;
171
+ const live = isSessionLive(s);
172
+ const blocked = resumeBlockedReason(s);
173
+ const status = s.status ?? "unknown";
174
+ const done = s.segments_completed ?? 0;
175
+ const total = s.segments_total ?? s.segments?.length ?? 0;
176
+ const movieSrc = movieMediaSrc(s.movie);
177
+ const geometry =
178
+ s.width != null && s.height != null
179
+ ? `${s.width}×${s.height}${s.fps != null ? ` · ${s.fps}fps` : ""}`
180
+ : "";
181
+ return (
182
+ <>
183
+ <div className="vi-studio-active-head">
184
+ <button
185
+ type="button"
186
+ className="vi-timeline-toggle"
187
+ onClick={onToggle}
188
+ aria-expanded={expanded}
189
+ title={expanded ? "Hide segments" : "Show what this movie has rendered"}
190
+ >
191
+ <span className="vi-timeline-caret" aria-hidden>
192
+ {expanded ? "▾" : "▸"}
193
+ </span>
194
+ </button>
195
+ <span className={`vi-status vi-status-${status}`}>{status}</span>
196
+ <span className="vi-studio-active-id">
197
+ {s.title?.trim() ? s.title : "untitled"} · {shortId(s.movie_id)}
198
+ </span>
199
+ </div>
200
+ <div className="vi-studio-active-meta">
201
+ <span>
202
+ {done}/{total} segments
203
+ </span>
204
+ {geometry ? <span>· {geometry}</span> : null}
205
+ {s.id_lock ? <span>· id_lock</span> : null}
206
+ {s.project ? <span>· {s.project}</span> : null}
207
+ {s.updated != null ? (
208
+ <span title={fmtWhen(s.updated)}>· {agoText(s.updated, Date.now())} ago</span>
209
+ ) : null}
210
+ </div>
211
+ {/* ONE action per row, and never both: a live movie gets Pause, everything else
212
+ gets Resume. `status === "running"` is folded into the live test defensively —
213
+ the route only reports running while the bus agrees, but a row that claims to
214
+ be rendering must always be stoppable, and pause is idempotent anyway. */}
215
+ {live || status === "running" ? (
216
+ <button
217
+ type="button"
218
+ className="vi-btn vi-btn-sm vi-btn-ghost"
219
+ disabled={busy}
220
+ title="Stop this movie between segments and park it — everything already rendered stays on disk."
221
+ onClick={onPause}
222
+ >
223
+ {busy ? "Pausing…" : "⏸ Pause"}
224
+ </button>
225
+ ) : (
226
+ // The title rides the WRAPPER: a disabled button swallows pointer events in
227
+ // most browsers, so a tooltip on it is a reason nobody can read.
228
+ <span
229
+ title={
230
+ blocked ??
231
+ "Re-enqueue this movie's spec — segments already rendered come back from the " +
232
+ "clip store without touching a GPU, so it picks up where it stopped."
233
+ }
234
+ >
235
+ <button
236
+ type="button"
237
+ className="vi-btn vi-btn-sm"
238
+ disabled={busy || blocked != null}
239
+ onClick={onResume}
240
+ >
241
+ {busy ? "Resuming…" : "▶ Resume"}
242
+ </button>
243
+ </span>
244
+ )}
245
+ {/* An UNFINISHED movie that cannot be resumed says so in the row itself, not only
246
+ in a tooltip — that is the pre-feature (no spec.json) case, and it is the one
247
+ an operator will otherwise keep clicking. A finished movie needs no excuse. */}
248
+ {blocked && status !== "done" && !live ? (
249
+ <p
250
+ className="vi-knob-hint"
251
+ style={{ flexBasis: "100%", margin: "0.2rem 0 0", wordBreak: "break-word" }}
252
+ >
253
+ {blocked}
254
+ </p>
255
+ ) : null}
256
+ {/* Live pace for a running session — mounted only while it IS running, so a list
257
+ of parked movies costs no job poll. */}
258
+ {live ? <MovieLiveProgress jobId={s.job_id ?? null} /> : null}
259
+ {actionError ? (
260
+ // Verbatim: the 409 bodies ("no persisted spec.json…", "already running —
261
+ // pause it first") are the whole explanation, and paraphrasing them would
262
+ // hide which of the two happened.
263
+ <p className="vi-error" role="alert" style={{ flexBasis: "100%", margin: "0.2rem 0 0" }}>
264
+ {actionError}
265
+ </p>
266
+ ) : null}
267
+ {expanded ? (
268
+ <div className="vi-timeline-panel">
269
+ {movieSrc ? (
270
+ <div style={{ marginBottom: "0.6rem" }}>
271
+ <p className="vi-comfy-label" style={{ marginBottom: "0.3rem" }}>
272
+ Assembled movie
273
+ </p>
274
+ <video
275
+ src={movieSrc}
276
+ controls
277
+ playsInline
278
+ preload="metadata"
279
+ style={{
280
+ width: "18rem",
281
+ maxWidth: "60vw",
282
+ borderRadius: "0.4rem",
283
+ background: "#000",
284
+ border: "1px solid var(--vi-border)",
285
+ }}
286
+ />
287
+ </div>
288
+ ) : null}
289
+ {s.segments?.length ? (
290
+ <ul
291
+ style={{
292
+ listStyle: "none",
293
+ margin: 0,
294
+ padding: 0,
295
+ display: "flex",
296
+ flexDirection: "column",
297
+ gap: "0.6rem",
298
+ }}
299
+ >
300
+ {s.segments.map((seg) => (
301
+ <li
302
+ key={`${s.movie_id}:${seg.index}`}
303
+ style={{
304
+ display: "flex",
305
+ gap: "0.7rem",
306
+ alignItems: "flex-start",
307
+ // Not-yet-started segments are dimmed, not hidden: the strip shows
308
+ // the whole movie, so "how much is left" is visible at a glance.
309
+ opacity: isPending(seg) ? 0.55 : 1,
310
+ }}
311
+ >
312
+ <SegmentRowBody seg={seg} />
313
+ </li>
314
+ ))}
315
+ </ul>
316
+ ) : (
317
+ <p className="vi-knob-hint" style={{ margin: 0 }}>
318
+ No segment records yet — this session was submitted but has not written one.
319
+ </p>
320
+ )}
321
+ </div>
322
+ ) : null}
323
+ </>
324
+ );
325
+ }
326
+
327
+ /** The panel body — split out so the poll it drives exists ONLY while the panel is
328
+ * open (mounting is the visibility signal; see the `active` gate below). */
329
+ function SessionsBody() {
330
+ const [expanded, setExpanded] = useState<Set<string>>(new Set());
331
+ // A hidden browser tab is not a visible panel: pause the walk of the movies root
332
+ // while the console is in the background, and reload the moment it comes back.
333
+ const [docVisible, setDocVisible] = useState(
334
+ () => typeof document === "undefined" || document.visibilityState !== "hidden",
335
+ );
336
+ useEffect(() => {
337
+ const onVis = () => setDocVisible(document.visibilityState !== "hidden");
338
+ document.addEventListener("visibilitychange", onVis);
339
+ return () => document.removeEventListener("visibilitychange", onVis);
340
+ }, []);
341
+
342
+ const { sessions, loading, error, busy, actionError, pause, resume } =
343
+ useMovieSessions(docVisible);
344
+
345
+ const toggle = (movieId: string) =>
346
+ setExpanded((prev) => {
347
+ const next = new Set(prev);
348
+ if (next.has(movieId)) next.delete(movieId);
349
+ else next.add(movieId);
350
+ return next;
351
+ });
352
+
353
+ if (loading && sessions.length === 0) {
354
+ return <p className="vi-knob-hint">Reading the movies root…</p>;
355
+ }
356
+ if (error && sessions.length === 0) {
357
+ return (
358
+ <p className="vi-error" role="alert">
359
+ {error}
360
+ </p>
361
+ );
362
+ }
363
+ if (sessions.length === 0) {
364
+ return (
365
+ <p className="vi-knob-hint">
366
+ No movie sessions on disk yet — generate one below and it appears here, resumable
367
+ even if this tab goes away.
368
+ </p>
369
+ );
370
+ }
371
+ return (
372
+ <ul className="vi-studio-active-list">
373
+ {sessions.map((s) => (
374
+ <li key={s.movie_id} className="vi-studio-active-row">
375
+ <SessionRowBody
376
+ session={s}
377
+ expanded={expanded.has(s.movie_id)}
378
+ onToggle={() => toggle(s.movie_id)}
379
+ busy={busy.has(s.movie_id)}
380
+ actionError={actionError[s.movie_id] ?? null}
381
+ onPause={() => pause(s.movie_id)}
382
+ onResume={() => resume(s.movie_id)}
383
+ />
384
+ </li>
385
+ ))}
386
+ </ul>
387
+ );
388
+ }
389
+
390
+ // ── SESSIONS, PER GENERATE SURFACE (operator ask 2026-08-12) ────────────────
391
+ // The recovery panel moved from the Cinema composer into the Active display and
392
+ // grew one section per generate surface. Each section is the same question —
393
+ // "what work of this kind exists, and what can I do about it" — answered with
394
+ // what the server can actually offer today:
395
+ // • Cinema — the durable movie sessions on disk (this file's original body):
396
+ // resume, pause, watch what already rendered. The only tier with a
397
+ // persisted spec.json, so the only one with true Resume.
398
+ // • Scene / Movie / Clip — that surface's jobs from the SAME media-jobs feed
399
+ // the Active list renders (and through the SAME JobRow renderer — no
400
+ // parallel copy): live builds with progress + Cancel, finished ones when
401
+ // the station's "Show finished" filter is on.
402
+ // When those tiers grow k91-style persisted sessions server-side, their
403
+ // sections upgrade in place — the frame does not change.
404
+ const SESSION_CATEGORIES: ReadonlyArray<{
405
+ id: string;
406
+ label: string;
407
+ kinds: ReadonlyArray<string>;
408
+ hint: string;
409
+ }> = [
410
+ {
411
+ id: "scene",
412
+ label: "Scene",
413
+ kinds: ["generate_scene", "generate_image"],
414
+ hint: "scene & image fan-outs",
415
+ },
416
+ { id: "movie", label: "Movie", kinds: ["generate_movie"], hint: "goal-timeline movies" },
417
+ { id: "clip", label: "Clip", kinds: ["studio_i2v"], hint: "single studio clips" },
418
+ {
419
+ id: "cinema",
420
+ label: "Cinema",
421
+ kinds: ["generate_studio_movie"],
422
+ hint: "movies on disk — resume, pause, or watch what already rendered",
423
+ },
424
+ ];
425
+
426
+ /** One surface's jobs from the shared feed, through the shared row renderer. */
427
+ function CategoryJobsBody({
428
+ jobs,
429
+ nowMs,
430
+ cancelBusy,
431
+ onCancel,
432
+ label,
433
+ }: {
434
+ jobs: MediaJob[];
435
+ nowMs: number;
436
+ cancelBusy: Set<string>;
437
+ onCancel: (id: string) => void;
438
+ label: string;
439
+ }) {
440
+ const [expanded, setExpanded] = useState<Set<string>>(new Set());
441
+ const toggle = (id: string) =>
442
+ setExpanded((prev) => {
443
+ const next = new Set(prev);
444
+ if (next.has(id)) next.delete(id);
445
+ else next.add(id);
446
+ return next;
447
+ });
448
+ if (jobs.length === 0) {
449
+ return (
450
+ <p className="vi-knob-hint">
451
+ No {label.toLowerCase()} builds in the feed — live ones appear here with
452
+ progress and Cancel; tick “Show finished” above for recent history.
453
+ (Durable resumable sessions exist for Cinema today.)
454
+ </p>
455
+ );
456
+ }
457
+ return (
458
+ <ul className="vi-studio-active-list">
459
+ {jobs.map((j) => (
460
+ <li key={j.id} className="vi-studio-active-row">
461
+ <JobRow
462
+ job={j}
463
+ expanded={expanded.has(j.id)}
464
+ onToggle={() => toggle(j.id)}
465
+ nowMs={nowMs}
466
+ cancelBusy={cancelBusy.has(j.id)}
467
+ onCancel={() => onCancel(j.id)}
468
+ />
469
+ </li>
470
+ ))}
471
+ </ul>
472
+ );
473
+ }
474
+
475
+ /**
476
+ * The Sessions panel in the Active display: one collapsible section per generate
477
+ * surface (Scene · Movie · Clip · Cinema). Cinema opens by default — the recovery
478
+ * affordance is the point, and its durable-sessions poll runs only while its
479
+ * section is open (the body unmounts when collapsed, which is what stops it).
480
+ * The other sections cost nothing extra: they filter the feed the station
481
+ * already holds.
482
+ */
483
+ export function SessionsPanel({
484
+ jobs,
485
+ nowMs,
486
+ cancelBusy,
487
+ onCancel,
488
+ }: {
489
+ jobs: MediaJob[];
490
+ nowMs: number;
491
+ cancelBusy: Set<string>;
492
+ onCancel: (id: string) => void;
493
+ }) {
494
+ const [open, setOpen] = useState<Set<string>>(() => new Set(["cinema"]));
495
+ const toggle = (id: string) =>
496
+ setOpen((prev) => {
497
+ const next = new Set(prev);
498
+ if (next.has(id)) next.delete(id);
499
+ else next.add(id);
500
+ return next;
501
+ });
502
+ return (
503
+ <section className="vi-studio-active" aria-label="Sessions">
504
+ <p className="vi-knob-headline" style={{ margin: "0.8rem 0 0.2rem" }}>
505
+ Sessions{" "}
506
+ <span className="vi-knob-headline-sub">
507
+ previous & in-flight work, per surface — Cinema sessions resume from disk
508
+ </span>
509
+ </p>
510
+ {SESSION_CATEGORIES.map((cat) => {
511
+ const catJobs = jobs.filter((j) => cat.kinds.includes(j.name));
512
+ const isOpen = open.has(cat.id);
513
+ return (
514
+ <div key={cat.id}>
515
+ <button
516
+ type="button"
517
+ className="vi-timeline-toggle"
518
+ aria-expanded={isOpen}
519
+ onClick={() => toggle(cat.id)}
520
+ style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}
521
+ >
522
+ <span className="vi-timeline-caret" aria-hidden>
523
+ {isOpen ? "▾" : "▸"}
524
+ </span>
525
+ <span className="vi-knob-headline" style={{ margin: 0 }}>
526
+ {cat.label}{" "}
527
+ <span className="vi-knob-headline-sub">
528
+ {cat.hint}
529
+ {cat.id !== "cinema" && catJobs.length > 0
530
+ ? ` · ${catJobs.length} in feed`
531
+ : ""}
532
+ </span>
533
+ </span>
534
+ </button>
535
+ {isOpen ? (
536
+ cat.id === "cinema" ? (
537
+ <SessionsBody />
538
+ ) : (
539
+ <CategoryJobsBody
540
+ jobs={catJobs}
541
+ nowMs={nowMs}
542
+ cancelBusy={cancelBusy}
543
+ onCancel={onCancel}
544
+ label={cat.label}
545
+ />
546
+ )
547
+ ) : null}
548
+ </div>
549
+ );
550
+ })}
551
+ </section>
552
+ );
553
+ }