@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,1224 @@
1
+ // The static left sidebar of the Studio workbench, split into two switchable
2
+ // tabs (role="tablist" above the tab bodies):
3
+ //
4
+ // • Active Processes — the session-wide "memory of processes": every in-flight
5
+ // or recently-relevant tracked job (live status/spinner via isJobActive, a
6
+ // Check-again for capped jobs, and a Cancel affordance wired to cancelJob).
7
+ // • Session Library — the completed outputs. Generate-station outputs are
8
+ // GROUPED by their producing run (expandable), each group annotated with the
9
+ // prompt + model that produced it and offering Continue (extend a scene from
10
+ // its last frame) / Replicate (re-stage prompt+model) — both wired through
11
+ // the composer seam (requestStage), NOT a parallel generation path. Every
12
+ // output row keeps the existing Add-to-prompt / assess / Remove affordances.
13
+ // Non-generation media (frames, crops, uploads) list flat below the groups.
14
+ //
15
+ // This is still a VIEW over the library + tracker seams: it never enqueues jobs
16
+ // and never DELETES server media. "Remove" / "Clear all" forget LOCAL entries
17
+ // only (house policy: no deletion) — EXCEPT for a produced STUDIO clip (genKind
18
+ // "studio_i2v"), which is the one library-family item with real server identity
19
+ // (a job_id in the durable clips catalog). For those, Remove/Clear-all ARCHIVE it
20
+ // server side too (POST studioClipArchiveUrl — never-delete: the clip's bytes and
21
+ // bus row are untouched, only hidden from the catalog list, reversible via the
22
+ // matching unarchive route) — otherwise the clip's own list poll (studioShared.tsx
23
+ // useStudioClips, ~6s) re-adds it to this library on the very next tick, which is
24
+ // exactly the reported "removed clips just reappear" bug. Everything else (frames,
25
+ // crops, uploads, generate_image/scene/movie outputs) has no durable per-item
26
+ // catalog to resurrect FROM, so those stay a clean local forget. Only the server
27
+ // MediaRef is retained — pixels are never persisted — so "assess" opens the live
28
+ // server asset (valid for the session); Continue/Replicate work off the retained
29
+ // prompt+model regardless.
30
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
31
+ import { NavLink, useLocation, useNavigate } from "react-router-dom";
32
+ import {
33
+ useMediaLibrary,
34
+ removeFromLibrary,
35
+ clearLibrary,
36
+ type LibraryItem,
37
+ type LibraryGenKind,
38
+ } from "../video/mediaLibrary";
39
+ import {
40
+ useJobTracker,
41
+ resumeJob,
42
+ cancelJob,
43
+ isJobActive,
44
+ isJobTerminal,
45
+ dismissAllTerminal,
46
+ type TrackedJob,
47
+ type StationId,
48
+ } from "../video/jobTracker";
49
+ import { STILL_RUNNING_MESSAGE } from "./pollCaps";
50
+ import { requestAddPart, requestStage } from "../video/composerBridge";
51
+ import { requestStudioStage } from "../video/studioBridge";
52
+ import { useWorkerCalls, type WorkerCall } from "../video/useWorkerCalls";
53
+ import { useSidebarRegistry } from "./SidebarRegistry";
54
+ import { WorkbenchDrawer } from "./WorkbenchDrawer";
55
+ import { SharePanel } from "./SharePanel";
56
+ import {
57
+ mediaBytesUrl,
58
+ studioClipArchiveUrl,
59
+ llmJobCancelUrl,
60
+ } from "../config";
61
+ import { request, errorOf, describeAppError } from "../transport/client";
62
+ import type { MediaRef } from "../video/contract";
63
+
64
+ /** Which sidebar tab is showing. Round 9: "settings" is guarded — it only appears
65
+ * when the mounted station has registered knobs into the sidebar registry. */
66
+ type SideTab = "processes" | "library" | "settings";
67
+
68
+ // Human station names for the process rows (mirrors the registry titles).
69
+ const STATION_NAME: Record<StationId, string> = {
70
+ "image-crop": "Image Crop",
71
+ "video-crop": "Video Crop",
72
+ "audio-crop": "Audio Crop",
73
+ frames: "Frames & Models",
74
+ generate: "Generate",
75
+ "generate-tester": "Generate (tester)",
76
+ };
77
+
78
+ // The route a process row links to. It is the station id by default (id === route
79
+ // path for the routed stations), but "video-crop" has NO route of its own — a video
80
+ // crop is an INLINE op on a studio item (Studio slice 2b), so its process row links
81
+ // to the studio workbench where that item lives instead of falling through to the
82
+ // default studio sub-tab.
83
+ const STATION_ROUTE: Partial<Record<StationId, string>> = {
84
+ "video-crop": "studio-clips",
85
+ // The tester is a surface INSIDE the Generate station, not a route of its own —
86
+ // without this the default (id === path) would link a tester row to /generate-tester,
87
+ // which resolves to nothing.
88
+ "generate-tester": "generate",
89
+ };
90
+
91
+ // mm:ss elapsed since enqueue.
92
+ function fmtElapsed(ms: number): string {
93
+ const total = Math.max(0, Math.floor(ms / 1000));
94
+ const m = Math.floor(total / 60);
95
+ const s = total % 60;
96
+ return `${m}:${s.toString().padStart(2, "0")}`;
97
+ }
98
+
99
+ // One active/recent process row — the always-visible "memory of processes". It
100
+ // links to its station sub-tab; a capped row exposes the verbatim still-running
101
+ // copy + a Check-again button, and an active row exposes a Cancel button — both
102
+ // outside the link so they don't also navigate.
103
+ function ProcessRow({ job, now }: { job: TrackedJob; now: number }) {
104
+ const statusKey = job.expired ? "failed" : job.status ?? "queued";
105
+ const statusText = job.expired ? "unknown" : job.status ?? "queued";
106
+ const active = isJobActive(job);
107
+ return (
108
+ <li className="vi-proc-item">
109
+ <NavLink to={`/${STATION_ROUTE[job.station] ?? job.station}`} className="vi-proc-link">
110
+ <div className="vi-proc-top">
111
+ {active ? (
112
+ <span className="vi-proc-spinner" aria-hidden />
113
+ ) : (
114
+ <span className="vi-proc-dot" aria-hidden />
115
+ )}
116
+ <span className="vi-proc-station">{STATION_NAME[job.station]}</span>
117
+ <span className={`vi-status vi-status-${statusKey}`}>{statusText}</span>
118
+ {active && job.progress && (
119
+ <span className="vi-proc-progress">
120
+ image {job.progress.done}/{job.progress.total}
121
+ </span>
122
+ )}
123
+ <span className="vi-proc-elapsed">{fmtElapsed(now - job.enqueuedAt)}</span>
124
+ </div>
125
+ <span className="vi-proc-label">{job.label}</span>
126
+ </NavLink>
127
+ {active && (
128
+ <div className="vi-proc-msg">
129
+ <button
130
+ type="button"
131
+ className="vi-btn vi-btn-sm vi-proc-btn"
132
+ disabled={job.status === "cancelling"}
133
+ onClick={() => cancelJob(job.jobId)}
134
+ title="Stop this job — a frame already in flight finishes first"
135
+ >
136
+ {job.status === "cancelling" ? "Cancelling…" : "✕ Cancel"}
137
+ </button>
138
+ </div>
139
+ )}
140
+ {job.capped && (
141
+ <div className="vi-proc-msg">
142
+ <span>{STILL_RUNNING_MESSAGE}</span>
143
+ <button
144
+ type="button"
145
+ className="vi-btn vi-btn-sm vi-proc-btn"
146
+ onClick={() => resumeJob(job.jobId)}
147
+ >
148
+ Check again
149
+ </button>
150
+ </div>
151
+ )}
152
+ {job.status === "failed" && job.error && (
153
+ <span className="vi-proc-msg vi-proc-err">{job.error}</span>
154
+ )}
155
+ {job.expired && (
156
+ <span className="vi-proc-msg">
157
+ The server no longer tracks this job — it may have expired.
158
+ </span>
159
+ )}
160
+ </li>
161
+ );
162
+ }
163
+
164
+ // Friendly names for the unified /llm/jobs feed's known media kinds; anything
165
+ // unmapped falls through to its raw kind (never blank).
166
+ const WORKER_CALL_KIND_LABEL: Record<string, string> = {
167
+ identity_reconstruction: "Identity render",
168
+ studio_i2v: "Studio clip",
169
+ generate_studio_movie: "Studio movie",
170
+ generate_scene: "Scene",
171
+ generate_movie: "Movie",
172
+ generate_image: "Image",
173
+ };
174
+ function humanizeWorkerKind(kind: string): string {
175
+ return WORKER_CALL_KIND_LABEL[kind] ?? (kind || "Worker call");
176
+ }
177
+
178
+ // A render is treated as STALLED (frozen, not merely slow) once it has gone this
179
+ // long with no observed movement — even if the server hasn't flagged it. This is
180
+ // the honest-status window: past it the row drops its live/green look for an amber
181
+ // stalled state, so a wedged render can never keep reading as active.
182
+ const WORKER_CALL_STALL_MS = 90_000;
183
+
184
+ // "12s" / "3m 04s" since some past moment — the expanded panel's relative
185
+ // last-movement clock (sibling of fmtElapsed, which counts UP from start).
186
+ function fmtAgo(ms: number): string {
187
+ const total = Math.max(0, Math.floor(ms / 1000));
188
+ if (total < 60) return `${total}s`;
189
+ const m = Math.floor(total / 60);
190
+ return `${m}m ${(total % 60).toString().padStart(2, "0")}s`;
191
+ }
192
+
193
+ // The scrolling log tail in the expanded panel: a monospace <pre> capped by CSS
194
+ // max-height with its own scroll, pinned to the BOTTOM (newest line) whenever the
195
+ // tail changes — so a live render's newest output is always in view without the
196
+ // operator chasing it. Empty/absent tail degrades to a quiet "no log yet".
197
+ function WorkerCallLog({ lines }: { lines: string[] }) {
198
+ const ref = useRef<HTMLPreElement | null>(null);
199
+ // Re-pin only when the content actually changes (count + newest line), not on
200
+ // every parent re-render — otherwise a manual scroll-up would be yanked back
201
+ // down every poll tick.
202
+ const key = lines.length ? `${lines.length}:${lines[lines.length - 1]}` : "";
203
+ useEffect(() => {
204
+ const el = ref.current;
205
+ if (el) el.scrollTop = el.scrollHeight;
206
+ }, [key]);
207
+ if (lines.length === 0) {
208
+ return <p className="vi-proc-log-empty">No log yet.</p>;
209
+ }
210
+ return (
211
+ <pre className="vi-proc-log" ref={ref} aria-label="Recent log output">
212
+ {lines.join("\n")}
213
+ </pre>
214
+ );
215
+ }
216
+
217
+ // One in-flight WORKER CALL row (from the fleet-wide /llm/jobs feed) — the calls
218
+ // this browser session did NOT launch (identity reconstruction, another station's
219
+ // render, another transport). Presentational sibling of ProcessRow: no NavLink
220
+ // (these jobs have no station route here), a unified hard-Cancel, and — unlike the
221
+ // old flat row that read green-active even when wedged — an EXPANDABLE panel with a
222
+ // live progress bar, current stage, an honest stalled state, and a scrolling log.
223
+ function WorkerCallRow({
224
+ call,
225
+ now,
226
+ cancelling,
227
+ expanded,
228
+ onToggle,
229
+ onCancel,
230
+ }: {
231
+ call: WorkerCall;
232
+ now: number;
233
+ cancelling: boolean;
234
+ expanded: boolean;
235
+ onToggle: (id: string) => void;
236
+ onCancel: (call: WorkerCall) => void;
237
+ }) {
238
+ const meta = [call.model, call.worker].filter(Boolean).join(" · ");
239
+ // Honest stalled signal: the server said so, OR movement has been silent past the
240
+ // stall window. Recomputed off `now` (the ~1s panel tick), so a live render that
241
+ // freezes flips to stalled on its own without a status change.
242
+ const lastMoveMs = call.updatedAt != null ? Math.max(0, now - call.updatedAt) : null;
243
+ const stalled =
244
+ call.stalled || (lastMoveMs != null && lastMoveMs > WORKER_CALL_STALL_MS);
245
+ const pct = call.progress != null ? Math.round(call.progress * 100) : null;
246
+ // A requested-and-acknowledged cancel also latches the button into "cancelling…"
247
+ // (survives across polls until the row drops), not just the local in-flight click.
248
+ const cancelBusy = cancelling || call.cancelRequested;
249
+ return (
250
+ <li className="vi-proc-item vi-proc-item-worker">
251
+ <div className="vi-proc-top">
252
+ <button
253
+ type="button"
254
+ className="vi-proc-toggle"
255
+ aria-expanded={expanded}
256
+ aria-label={expanded ? "Collapse details" : "Expand — progress, stage, log"}
257
+ onClick={() => onToggle(call.id)}
258
+ >
259
+ <span className="vi-proc-caret" aria-hidden>
260
+ {expanded ? "▾" : "▸"}
261
+ </span>
262
+ </button>
263
+ {stalled ? (
264
+ <span className="vi-proc-stall-dot" aria-hidden />
265
+ ) : (
266
+ <span className="vi-proc-spinner" aria-hidden />
267
+ )}
268
+ <span className="vi-proc-station">{humanizeWorkerKind(call.kind)}</span>
269
+ <span className={`vi-status vi-status-${call.status}`}>{call.status}</span>
270
+ {stalled && <span className="vi-proc-stall-badge">stalled</span>}
271
+ {pct != null && <span className="vi-proc-progress">{pct}%</span>}
272
+ <span className="vi-proc-elapsed">{fmtElapsed(now - call.startedAt)}</span>
273
+ </div>
274
+ {meta && <span className="vi-proc-label">{meta}</span>}
275
+
276
+ {expanded && (
277
+ <div className="vi-proc-detail">
278
+ {/* Progress bar: determinate when the feed gives a fraction, otherwise an
279
+ indeterminate "working" sweep — never a fabricated percentage. Goes
280
+ amber when stalled so a frozen bar doesn't read as healthy progress. */}
281
+ <div className="vi-proc-progbar-row">
282
+ <div
283
+ className={`vi-proc-progbar${
284
+ pct == null ? " vi-proc-progbar-indeterminate" : ""
285
+ }${stalled ? " vi-proc-progbar-stalled" : ""}`}
286
+ role="progressbar"
287
+ aria-valuemin={0}
288
+ aria-valuemax={100}
289
+ {...(pct != null ? { "aria-valuenow": pct } : {})}
290
+ >
291
+ <span
292
+ className="vi-proc-progbar-fill"
293
+ style={pct != null ? { width: `${pct}%` } : undefined}
294
+ />
295
+ </div>
296
+ <span className="vi-proc-stage">
297
+ {call.stage ? call.stage : pct == null ? "working…" : `${pct}%`}
298
+ </span>
299
+ </div>
300
+
301
+ {call.message && <p className="vi-proc-detail-msg">{call.message}</p>}
302
+
303
+ <div className="vi-proc-detail-meta">
304
+ <span
305
+ className={`vi-proc-lastmove${
306
+ stalled ? " vi-proc-lastmove-stalled" : ""
307
+ }`}
308
+ >
309
+ {lastMoveMs != null
310
+ ? `last movement ${fmtAgo(lastMoveMs)} ago`
311
+ : "last movement unknown"}
312
+ </span>
313
+ </div>
314
+
315
+ <WorkerCallLog lines={call.logTail} />
316
+ </div>
317
+ )}
318
+
319
+ <div className="vi-proc-msg">
320
+ <button
321
+ type="button"
322
+ className="vi-btn vi-btn-sm vi-proc-btn"
323
+ disabled={cancelBusy}
324
+ onClick={() => onCancel(call)}
325
+ title="Stop this worker call — a step already in flight finishes first"
326
+ >
327
+ {cancelBusy ? "Cancelling…" : "✕ Cancel"}
328
+ </button>
329
+ </div>
330
+ </li>
331
+ );
332
+ }
333
+
334
+ // STUDIO SCOPE (operator, 2026-07-31): the studio workbench's Active Processes must
335
+ // list ONLY studio-originated work — NEVER a general fleet model call (a discord chat,
336
+ // a web /v1 completion, another transport's LLM call). Those must not be visible here
337
+ // and must not be cancellable from inside the studio. The fleet-wide /llm/jobs feed
338
+ // carries every transport, so we keep only rows that are RENDER/MEDIA work:
339
+ // * transport === "media" — the media/render bus (the authoritative signal: every
340
+ // job on it is video-arm work — studio clips/movies, scene/image gen, identity
341
+ // reconstruction/mesh/char360, mlt render), OR
342
+ // * a known render/media job KIND (defensive fallback if a row's transport is blank).
343
+ // A general LLM call (transport web/v1/discord/cli, kind chat/completion/…) is dropped.
344
+ // The console-wide ActiveProcessesStation still shows the fleet superset — this scoping
345
+ // is the studio sidebar's alone.
346
+ const STUDIO_MEDIA_KINDS = new Set([
347
+ "studio_i2v",
348
+ "generate_studio_movie",
349
+ "generate_scene",
350
+ "generate_movie",
351
+ "generate_image",
352
+ "identity_reconstruction",
353
+ "identity_mesh_build",
354
+ "identity_video_extract",
355
+ "mlt_render",
356
+ "crop",
357
+ "frame_extract",
358
+ "audio_extract",
359
+ ]);
360
+
361
+ function isStudioOriginated(c: WorkerCall): boolean {
362
+ return c.transport === "media" || STUDIO_MEDIA_KINDS.has(c.kind);
363
+ }
364
+
365
+ /** Non-done tracked jobs, newest first — the Active Processes tab body. */
366
+ function activeJobsOf(records: TrackedJob[]): TrackedJob[] {
367
+ return records
368
+ .filter((r) => r.status !== "done")
369
+ .slice()
370
+ .sort((a, b) => b.enqueuedAt - a.enqueuedAt);
371
+ }
372
+
373
+ // The Active Processes tab body: every non-done tracked job, newest first. Done
374
+ // rows drop off once their artifact is in the library tab. Ticks ~1s so elapsed
375
+ // stays live while anything is running or paused-capped.
376
+ function ProcessesPanel() {
377
+ const reg = useSidebarRegistry();
378
+ const records = useJobTracker();
379
+ const jobs = useMemo(() => activeJobsOf(records), [records]);
380
+
381
+ // The fleet-wide in-flight worker calls (identity reconstruction, other stations'
382
+ // renders, calls from any transport) — the work THIS browser session never
383
+ // launched, so the session jobTracker can't see it. Merged below, DEDUPED against
384
+ // the tracker: a /llm/jobs row whose id equals a TrackedJob.jobId is dropped (the
385
+ // tracker row is richer — station link, progress, provenance — so it wins).
386
+ const { calls, refetch } = useWorkerCalls();
387
+ const trackerIds = useMemo(() => new Set(jobs.map((j) => j.jobId)), [jobs]);
388
+ const workerCalls = useMemo(
389
+ // Deduped against the session tracker AND scoped to studio-originated (media/
390
+ // render) work only — a general fleet model call never surfaces or cancels here.
391
+ () => calls.filter((c) => !trackerIds.has(c.id) && isStudioOriginated(c)),
392
+ [calls, trackerIds],
393
+ );
394
+
395
+ // Terminal rows (failed / cancelled / expired) linger in this list after the
396
+ // active ones drop off — this drives the opt-in "Clear finished" broom below.
397
+ const hasTerminal = useMemo(() => jobs.some(isJobTerminal), [jobs]);
398
+
399
+ // Per-id busy flag for the worker-call Cancel buttons (immediate feedback; the
400
+ // next poll / the forced refetch drops the row once the server observes it).
401
+ const [cancelling, setCancelling] = useState<Set<string>>(new Set());
402
+ const onCancelWorkerCall = useCallback(
403
+ async (call: WorkerCall) => {
404
+ setCancelling((prev) => new Set(prev).add(call.id));
405
+ // ONE unified hard-cancel for every transport: the feed's own cancel route
406
+ // (POST /llm/jobs/<id>/cancel → {cancelled,status}) fans the stop out to the
407
+ // right bus server-side, so the row no longer has to route media vs chat itself.
408
+ await request<unknown>(llmJobCancelUrl(call.id), {
409
+ method: "POST",
410
+ meta: { specKey: "workers", operation: "worker.call.cancel" },
411
+ });
412
+ refetch();
413
+ setCancelling((prev) => {
414
+ const n = new Set(prev);
415
+ n.delete(call.id);
416
+ return n;
417
+ });
418
+ },
419
+ [refetch],
420
+ );
421
+
422
+ // Per-id expanded state for the worker-call rows (progress / stage / stalled /
423
+ // log). Keyed by job id so a row's panel stays open across poll ticks; a job that
424
+ // finishes and drops from the feed just leaves a stale id in the set (harmless).
425
+ const [expanded, setExpanded] = useState<Set<string>>(new Set());
426
+ const toggleExpanded = useCallback((id: string) => {
427
+ setExpanded((prev) => {
428
+ const n = new Set(prev);
429
+ if (n.has(id)) n.delete(id);
430
+ else n.add(id);
431
+ return n;
432
+ });
433
+ }, []);
434
+
435
+ // Tick ~1s while anything is live so every elapsed clock stays current: tracker
436
+ // actives/capped OR any in-flight worker call (those are always live).
437
+ const ticking =
438
+ jobs.some((r) => isJobActive(r) || r.capped) || workerCalls.length > 0;
439
+ const [now, setNow] = useState(() => Date.now());
440
+ useEffect(() => {
441
+ if (!ticking) {
442
+ setNow(Date.now());
443
+ return;
444
+ }
445
+ const id = window.setInterval(() => setNow(Date.now()), 1000);
446
+ return () => window.clearInterval(id);
447
+ }, [ticking]);
448
+
449
+ // Round 9: the mounted station (e.g. STUDIO — whose jobs are not in the jobTracker)
450
+ // portals its own in-flight rows into this host, so the ONE Active Processes view
451
+ // shows tracker jobs AND studio renders. The host is ALWAYS rendered so the portal
452
+ // target is stable; the empty copy is suppressed when a station has portaled rows in.
453
+ const extraHost = <div className="vi-proc-extra" ref={reg.setActiveExtraHost} />;
454
+
455
+ const total = jobs.length + workerCalls.length;
456
+
457
+ if (total === 0) {
458
+ return (
459
+ <>
460
+ {!reg.activeExtraActive && (
461
+ <p className="vi-lib-empty">
462
+ No active processes — jobs you run appear here with live status until they
463
+ finish, then their outputs land in the Session Library.
464
+ </p>
465
+ )}
466
+ {extraHost}
467
+ </>
468
+ );
469
+ }
470
+
471
+ return (
472
+ <>
473
+ <div className="vi-proc-header">
474
+ <h2 className="vi-proc-title">Active processes</h2>
475
+ <span className="vi-proc-count">{total}</span>
476
+ {/* Only shown when there's a finished/failed row to clear; forgets the
477
+ LOCAL terminal rows only (never server work), and leaves every
478
+ still-running job polling and visible. */}
479
+ {hasTerminal && (
480
+ <button
481
+ type="button"
482
+ className="vi-btn vi-btn-sm vi-btn-ghost"
483
+ onClick={() => dismissAllTerminal()}
484
+ title="Remove finished and failed items from this view — your saved files on disk are kept."
485
+ aria-label="Remove finished and failed jobs from this list — still-running jobs stay, and your saved files on disk are kept."
486
+ >
487
+ Clear finished
488
+ </button>
489
+ )}
490
+ </div>
491
+ <ul className="vi-proc-list">
492
+ {jobs.map((job) => (
493
+ <ProcessRow key={job.jobId} job={job} now={now} />
494
+ ))}
495
+ {workerCalls.map((call) => (
496
+ <WorkerCallRow
497
+ key={call.id}
498
+ call={call}
499
+ now={now}
500
+ cancelling={cancelling.has(call.id)}
501
+ expanded={expanded.has(call.id)}
502
+ onToggle={toggleExpanded}
503
+ onCancel={onCancelWorkerCall}
504
+ />
505
+ ))}
506
+ </ul>
507
+ {extraHost}
508
+ </>
509
+ );
510
+ }
511
+
512
+ // The kind-driven media preview, shared verbatim by the library rows AND the
513
+ // collapsed group header (so the two thumbnail renderers can never drift apart):
514
+ // images get a lazy thumbnail, video gets a poster-like frame still (a muted,
515
+ // metadata-only <video> seeked to an early frame — there is no server-side poster
516
+ // to lean on; /video/media is Range-aware so only the seeked byte range is
517
+ // fetched), audio gets an inline player, and anything else (unknown) gets a cheap
518
+ // placeholder tile. `className` names the image/video tile wrapper so a compact
519
+ // caller (the group header, .vi-lib-group-thumb) can size it smaller while the
520
+ // rows keep the full-width .vi-lib-thumb. NOTE: the prop is `media` (not `ref`) —
521
+ // `ref` is a reserved prop name on React 18 function components and would not be
522
+ // forwarded as a normal prop.
523
+ function RefThumb({
524
+ media,
525
+ name,
526
+ className = "vi-lib-thumb",
527
+ }: {
528
+ media: MediaRef;
529
+ name: string;
530
+ className?: string;
531
+ }) {
532
+ const kind = media.kind;
533
+ if (kind === "image") {
534
+ return (
535
+ <div className={className}>
536
+ <img src={mediaBytesUrl(media.uri)} loading="lazy" alt={name} />
537
+ </div>
538
+ );
539
+ }
540
+ if (kind === "video") {
541
+ return (
542
+ <div className={className}>
543
+ <video
544
+ src={`${mediaBytesUrl(media.uri)}#t=0.1`}
545
+ muted
546
+ playsInline
547
+ preload="metadata"
548
+ aria-label={name}
549
+ />
550
+ </div>
551
+ );
552
+ }
553
+ if (kind === "audio") {
554
+ return (
555
+ <audio
556
+ className="vi-audio-player"
557
+ controls
558
+ preload="none"
559
+ src={mediaBytesUrl(media.uri)}
560
+ />
561
+ );
562
+ }
563
+ return (
564
+ <div className="vi-lib-placeholder" aria-hidden>
565
+ <span>{kind}</span>
566
+ </div>
567
+ );
568
+ }
569
+
570
+ // A single library row. Kind drives the preview: images get a lazy thumbnail,
571
+ // video gets a poster-like frame still (a muted metadata-only <video> seeked to an
572
+ // early frame — there is no server-side thumbnail to lean on), audio gets an inline
573
+ // player, and anything else (unknown) gets a cheap placeholder tile.
574
+ function LibraryRow({ it, onGenerate }: { it: LibraryItem; onGenerate: boolean }) {
575
+ const navigate = useNavigate();
576
+ const { ref } = it;
577
+ // label wins, then origin, then a short asset-id stub (|| so blanks fall through).
578
+ const name = it.label || it.origin || ref.asset_id.slice(0, 8);
579
+ const kind = ref.kind;
580
+ const canGenerate = kind === "image" || kind === "video";
581
+ const canAssess = kind === "image" || kind === "video";
582
+ // The B2 movie->studio chain: any VIDEO ref (a movie/scene mp4, or a produced
583
+ // studio clip) can seed a studio i2v render that EXTENDS it from its last frame.
584
+ const canSendToStudio = kind === "video";
585
+ // A produced STUDIO clip is the one library item with real SERVER identity —
586
+ // its job_id rides along as `groupId` (see studioShared.tsx's addToLibrary
587
+ // call). Remove must archive that job server-side (see this file's header
588
+ // note) instead of only forgetting the local entry, or the clip list's ~6s
589
+ // poll re-adds it on the very next tick.
590
+ const studioJobId = it.genKind === "studio_i2v" ? it.groupId : undefined;
591
+
592
+ const dims =
593
+ ref.width != null && ref.height != null ? `${ref.width}×${ref.height}` : null;
594
+ const dur = ref.duration_s != null ? `${Math.round(ref.duration_s)}s` : null;
595
+
596
+ // Optimistic remove for the studio-clip path: the row hides IMMEDIATELY (before
597
+ // the archive call resolves) and only forgets the local entry once the server
598
+ // confirms it archived — so a failed/slow archive un-hides the row with the
599
+ // error shown, instead of the item silently vanishing while the server still
600
+ // lists it (which would just re-trigger the same resurrection bug one poll
601
+ // later). Non-studio items skip all of this — removeFromLibrary is a same-tick,
602
+ // can't-fail local operation for them, exactly as before.
603
+ const [hidden, setHidden] = useState(false);
604
+ const [removeError, setRemoveError] = useState<string | null>(null);
605
+ // In-place ("▸ Play here") playback for VIDEO rows — the same function as the
606
+ // studio viewer's Play here (StudioViewer.tsx). When true, the poster still is
607
+ // swapped for a real, audible, autoplaying inline <video> right here in the row
608
+ // (no new browser tab). Local to the row: each row plays independently by design.
609
+ const [playing, setPlaying] = useState(false);
610
+
611
+ async function handleRemove() {
612
+ if (!studioJobId) {
613
+ removeFromLibrary(ref.uri);
614
+ return;
615
+ }
616
+ setRemoveError(null);
617
+ setHidden(true);
618
+ const res = await request<unknown>(studioClipArchiveUrl(studioJobId), {
619
+ method: "POST",
620
+ meta: { specKey: "studio", operation: "studio.clip.archive" },
621
+ });
622
+ if (!res.ok) {
623
+ setHidden(false);
624
+ setRemoveError(describeAppError(errorOf(res)));
625
+ return;
626
+ }
627
+ // Archived server-side (or already was — archive is idempotent): the clip is
628
+ // gone from GET .../clips for good, so forgetting the local entry now is
629
+ // permanent — no future poll can ever re-add it.
630
+ removeFromLibrary(ref.uri);
631
+ }
632
+
633
+ if (hidden) return null;
634
+
635
+ return (
636
+ <li className="vi-lib-item">
637
+ <div className="vi-lib-top">
638
+ <span className="vi-lib-badge">{kind}</span>
639
+ <span className="vi-lib-name" title={ref.uri}>
640
+ {name}
641
+ </span>
642
+ </div>
643
+
644
+ {/* Preview extracted into <RefThumb> (shared with the group header). Movie /
645
+ scene / studio clips are video/mp4 and render a poster-like #t=0.1 still.
646
+ When a VIDEO row is "▸ Play here"-playing, swap that still for a real
647
+ inline player (audible, autoplaying) in the SAME slot + sizing wrapper
648
+ (.vi-lib-thumb) so the row doesn't jump — no #t=0.1, not muted, so it
649
+ actually plays with audio. */}
650
+ {kind === "video" && playing ? (
651
+ <div className="vi-lib-thumb">
652
+ <video
653
+ src={mediaBytesUrl(ref.uri)}
654
+ controls
655
+ autoPlay
656
+ playsInline
657
+ aria-label={name}
658
+ />
659
+ </div>
660
+ ) : (
661
+ <RefThumb media={ref} name={name} />
662
+ )}
663
+
664
+ <div className="vi-lib-meta">
665
+ {kind === "image" && dims ? <span>{dims}</span> : null}
666
+ {(kind === "audio" || kind === "video") && dur ? <span>{dur}</span> : null}
667
+ <code>{ref.mime}</code>
668
+ </div>
669
+
670
+ <div className="vi-lib-actions">
671
+ {kind === "video" && (
672
+ <button
673
+ type="button"
674
+ className={`vi-btn vi-btn-sm${playing ? "" : " vi-btn-accent"}`}
675
+ onClick={() => setPlaying((v) => !v)}
676
+ title={
677
+ playing
678
+ ? "Stop inline playback"
679
+ : "Play this clip here, in place (same as the studio viewer's Play here) — no new tab"
680
+ }
681
+ >
682
+ {playing ? "■ Stop" : "▸ Play here"}
683
+ </button>
684
+ )}
685
+ {canGenerate ? (
686
+ onGenerate ? (
687
+ <button
688
+ type="button"
689
+ className="vi-btn vi-btn-sm vi-btn-accent"
690
+ onClick={() =>
691
+ requestAddPart({
692
+ ref,
693
+ origin: it.origin,
694
+ ...(it.label != null ? { label: it.label } : {}),
695
+ })
696
+ }
697
+ >
698
+ Add to prompt
699
+ </button>
700
+ ) : (
701
+ <button
702
+ type="button"
703
+ className="vi-btn vi-btn-sm vi-btn-accent"
704
+ onClick={() => {
705
+ // Stage the part FIRST — composerBridge BUFFERS it (Generate isn't
706
+ // mounted yet) — THEN navigate. GenerateStation drains the buffer on
707
+ // mount, so the item actually lands in the prompt instead of just
708
+ // being "available in the picker" (which read as doing nothing). Same
709
+ // effect as "Add to prompt", with the route hop.
710
+ requestAddPart({
711
+ ref,
712
+ origin: it.origin,
713
+ ...(it.label != null ? { label: it.label } : {}),
714
+ });
715
+ navigate("/generate");
716
+ }}
717
+ title="Add this to the Generate prompt and open the Generate station."
718
+ >
719
+ Use in Generate
720
+ </button>
721
+ )
722
+ ) : null}
723
+ {canSendToStudio && (
724
+ <>
725
+ <button
726
+ type="button"
727
+ className="vi-btn vi-btn-sm vi-btn-accent"
728
+ onClick={() => {
729
+ requestStudioStage({
730
+ sourceVideo: ref,
731
+ mode: "extend",
732
+ provenance: {
733
+ origin: it.origin,
734
+ prompt: it.prompt,
735
+ model: it.model,
736
+ genKind: it.genKind,
737
+ parentUri: ref.uri,
738
+ },
739
+ });
740
+ navigate("/studio-clips");
741
+ }}
742
+ title="Open Studio Clips staged to EXTEND this clip from its last frame (i2v, movie → studio chain)."
743
+ >
744
+ Send to Studio
745
+ </button>
746
+ <button
747
+ type="button"
748
+ className="vi-btn vi-btn-sm"
749
+ onClick={() => {
750
+ requestStudioStage({
751
+ sourceVideo: ref,
752
+ mode: "restyle",
753
+ provenance: {
754
+ origin: it.origin,
755
+ prompt: it.prompt,
756
+ model: it.model,
757
+ genKind: it.genKind,
758
+ parentUri: ref.uri,
759
+ },
760
+ });
761
+ navigate("/studio-clips");
762
+ }}
763
+ title="Open Studio Clips staged to RESTYLE this clip with a prompt (v2v · Wan VACE, 480p landscape)."
764
+ >
765
+ Restyle
766
+ </button>
767
+ </>
768
+ )}
769
+ {canAssess && (
770
+ <a
771
+ className="vi-btn vi-btn-sm vi-btn-ghost"
772
+ href={mediaBytesUrl(ref.uri)}
773
+ target="_blank"
774
+ rel="noreferrer"
775
+ title="Open this output at full resolution in a new tab (read-only)."
776
+ >
777
+ assess
778
+ </a>
779
+ )}
780
+ <button
781
+ type="button"
782
+ className="vi-btn vi-btn-sm"
783
+ onClick={() => void handleRemove()}
784
+ title={
785
+ studioJobId
786
+ ? "Archive this clip on the server (never deleted, reversible) and remove it here — it won't come back."
787
+ : "Remove from the library for good — tombstoned so it can't repopulate. The server asset is untouched (not deleted)."
788
+ }
789
+ aria-label={`Remove ${name} from the session library for good${
790
+ studioJobId ? " (archives the clip on the server)" : " (won't repopulate)"
791
+ }`}
792
+ >
793
+ Remove
794
+ </button>
795
+ </div>
796
+ {removeError && (
797
+ <p className="vi-lib-error" role="alert">
798
+ Couldn't archive — {removeError} (row restored, try again)
799
+ </p>
800
+ )}
801
+ </li>
802
+ );
803
+ }
804
+
805
+ /** A WORK — a component/lineage whose iterations live inside it (t24). Everything
806
+ * in the library is a work now: a generation RUN (keyed by its producing job id,
807
+ * carrying prompt+model+genKind, so Continue/Replicate apply), OR a non-gen
808
+ * lineage (frames / crops / uploads / demo) keyed by ORIGIN. Uniform shape — a
809
+ * one-iteration work renders as a section exactly like a many-iteration one. */
810
+ interface WorkGroup {
811
+ /** Stable key: the producing groupId when present, else a synthetic origin key. */
812
+ key: string;
813
+ /** True for a generation run (has groupId + genKind) — enables Continue/Replicate. */
814
+ isGen: boolean;
815
+ genKind: LibraryGenKind | null;
816
+ prompt: string;
817
+ model: string;
818
+ origin: string;
819
+ items: LibraryItem[];
820
+ newestAt: number;
821
+ }
822
+
823
+ // Regroup the WHOLE library into work sections (t24 — invert the old
824
+ // groups-then-flat split). GROUPING KEY, strongest natural key present with NO
825
+ // data-shape change:
826
+ // • a generation run → `groupId` (the producing job id; already carried), so
827
+ // each run is its own work with its prompt/model.
828
+ // • everything else → `origin` (frames / crops / uploads / demo). Non-gen
829
+ // items carry no per-run id on the wire, so origin is the strongest lineage
830
+ // key present — all frames form the "frames" work, all uploads the "uploads"
831
+ // work, etc. (A per-extraction key would need a data-shape change, which this
832
+ // slice forbids.) Prefixed "origin:" so it can never collide with a groupId.
833
+ // Both levels are freshest-first: works by their newest iteration, iterations
834
+ // within a work newest-first too.
835
+ function groupLibrary(items: LibraryItem[]): WorkGroup[] {
836
+ const map = new Map<string, WorkGroup>();
837
+ for (const it of items) {
838
+ const isGen = !!(it.groupId && it.genKind);
839
+ const key = isGen ? (it.groupId as string) : `origin:${it.origin || "other"}`;
840
+ const g = map.get(key);
841
+ if (g) {
842
+ g.items.push(it);
843
+ g.newestAt = Math.max(g.newestAt, it.addedAt);
844
+ // A gen run keeps the provenance from its first-seen item; a non-gen work
845
+ // has none. (All items under a groupId share prompt/model by construction.)
846
+ } else {
847
+ map.set(key, {
848
+ key,
849
+ isGen,
850
+ genKind: isGen ? (it.genKind as LibraryGenKind) : null,
851
+ prompt: it.prompt ?? "",
852
+ model: it.model ?? "",
853
+ origin: it.origin || "other",
854
+ items: [it],
855
+ newestAt: it.addedAt,
856
+ });
857
+ }
858
+ }
859
+ const works = [...map.values()].sort((a, b) => b.newestAt - a.newestAt);
860
+ // Iterations newest-first within each work (both levels freshest-first).
861
+ for (const w of works) w.items.sort((a, b) => b.addedAt - a.addedAt);
862
+ return works;
863
+ }
864
+
865
+ // A human title for a non-gen work (the origin lineage). Gen works title
866
+ // themselves from prompt/model in the section header instead.
867
+ function originTitle(origin: string): string {
868
+ switch (origin) {
869
+ case "frames": return "Extracted frames";
870
+ case "upload": return "Uploads";
871
+ case "studio": return "Studio clips";
872
+ case "demo": return "Demo assets";
873
+ default: return origin ? origin[0].toUpperCase() + origin.slice(1) : "Other";
874
+ }
875
+ }
876
+
877
+ // One WORK section (t24) — a collapsed-by-default row showing a representative
878
+ // summary (poster + title/prompt + iteration count), expandable to reveal THAT
879
+ // work's iterations (LibraryRow reused verbatim). Uniform for gen runs and non-gen
880
+ // lineages: a gen run adds the Continue/Replicate composer affordances + prompt/
881
+ // model header; a non-gen work (frames/uploads/…) shows its origin title. `open`
882
+ // is CONTROLLED by the panel (a keyed Set that survives the refresh cadence).
883
+ function WorkSection({
884
+ group,
885
+ open,
886
+ onToggle,
887
+ onGenerate,
888
+ }: {
889
+ group: WorkGroup;
890
+ open: boolean;
891
+ onToggle: () => void;
892
+ onGenerate: boolean;
893
+ }) {
894
+ const navigate = useNavigate();
895
+ const isScene = group.genKind === "generate_scene";
896
+ // A studio i2v run: its output is a produced CLIP. The Generate-composer
897
+ // affordances (Continue/Replicate) don't apply — the clip's chain affordance is
898
+ // "Send to Studio", which lives on each video row (LibraryRow) below.
899
+ const isStudio = group.genKind === "studio_i2v";
900
+ const badge = group.isGen
901
+ ? (isStudio ? "clip" : isScene ? "scene" : "image")
902
+ : originTitle(group.origin).toLowerCase();
903
+ // The header text: a gen run shows its prompt; a non-gen work shows its origin
904
+ // title. Freshest-first means items[0] is the newest iteration.
905
+ const headerText = group.isGen
906
+ ? group.prompt.trim() || "(no text prompt)"
907
+ : originTitle(group.origin);
908
+ // Representative poster: the FRESHEST iteration that can render a still (image
909
+ // or video) — items are newest-first, so the first renderable is the latest.
910
+ // Reuses the same <RefThumb> the expanded rows use, so collapsed poster and row
911
+ // still can never diverge.
912
+ const thumb = group.items.find(
913
+ (i) => i.ref.kind === "image" || i.ref.kind === "video",
914
+ );
915
+ const thumbName = thumb
916
+ ? thumb.label || thumb.origin || thumb.ref.asset_id.slice(0, 8)
917
+ : "";
918
+
919
+ // Both affordances funnel through the SAME composer seam the Run path reads,
920
+ // then navigate so the (possibly unmounted) Generate station mounts and drains
921
+ // the staged request.
922
+ function replicate() {
923
+ requestStage({
924
+ replace: true,
925
+ mode: isScene ? "scene" : "image",
926
+ ...(group.model ? { model: group.model } : {}),
927
+ ...(group.prompt ? { text: group.prompt } : {}),
928
+ });
929
+ navigate("/generate");
930
+ }
931
+ function continueScene() {
932
+ // Iterations are newest-first now; the "last frame" to continue from is the
933
+ // FRESHEST image (items[0..]), so scan from the front for the first image.
934
+ const last = group.items.find((i) => i.ref.kind === "image");
935
+ requestStage({
936
+ replace: true,
937
+ mode: "scene",
938
+ ...(group.model ? { model: group.model } : {}),
939
+ ...(group.prompt ? { text: group.prompt } : {}),
940
+ ...(last
941
+ ? {
942
+ parts: [
943
+ { ref: last.ref, origin: "generate", label: "continue: last frame" },
944
+ ],
945
+ }
946
+ : {}),
947
+ });
948
+ navigate("/generate");
949
+ }
950
+
951
+ return (
952
+ <li className="vi-lib-group">
953
+ <button
954
+ type="button"
955
+ className="vi-lib-group-toggle"
956
+ aria-expanded={open}
957
+ onClick={onToggle}
958
+ >
959
+ <span className="vi-lib-group-caret" aria-hidden>
960
+ {open ? "▾" : "▸"}
961
+ </span>
962
+ <span className="vi-lib-badge">{badge}</span>
963
+ {group.isGen && (
964
+ <span className="vi-lib-group-model" title={`Model: ${group.model || "—"}`}>
965
+ {group.model || "—"}
966
+ </span>
967
+ )}
968
+ <span className="vi-lib-count">{group.items.length}</span>
969
+ </button>
970
+
971
+ {/* Poster + title/prompt read as the work's header: the representative thumb
972
+ sits to the LEFT. The thumb lives OUTSIDE the toggle <button> so the
973
+ expander's a11y/hit-target is untouched (no media nested in it). */}
974
+ <div className="vi-lib-group-head">
975
+ {thumb && (
976
+ <RefThumb
977
+ media={thumb.ref}
978
+ name={thumbName}
979
+ className="vi-lib-group-thumb"
980
+ />
981
+ )}
982
+ <p className="vi-lib-group-prompt" title={headerText}>
983
+ {headerText}
984
+ </p>
985
+ </div>
986
+
987
+ {group.isGen && !isStudio && (
988
+ <div className="vi-lib-actions">
989
+ {isScene && (
990
+ <button
991
+ type="button"
992
+ className="vi-btn vi-btn-sm vi-btn-accent"
993
+ onClick={continueScene}
994
+ title="Open Generate in Scene mode, staged from this scene's last frame to extend it."
995
+ >
996
+ Continue frames
997
+ </button>
998
+ )}
999
+ <button
1000
+ type="button"
1001
+ className="vi-btn vi-btn-sm"
1002
+ onClick={replicate}
1003
+ title="Open Generate with this run's prompt + model staged."
1004
+ >
1005
+ Replicate prompt
1006
+ </button>
1007
+ </div>
1008
+ )}
1009
+
1010
+ {open && (
1011
+ <ul className="vi-lib-list vi-lib-group-list">
1012
+ {group.items.map((it) => (
1013
+ <LibraryRow key={it.ref.uri} it={it} onGenerate={onGenerate} />
1014
+ ))}
1015
+ </ul>
1016
+ )}
1017
+ </li>
1018
+ );
1019
+ }
1020
+
1021
+ // The Session Library tab body (t24): every entry is a WORK section — a collapsed
1022
+ // row you expand to see that work's iterations. No flat rows; a one-iteration work
1023
+ // renders as a section like any other (uniformity beats cleverness). Sections are
1024
+ // collapsed by default (the library shows works, not churn) and freshest-first.
1025
+ function LibraryPanel({
1026
+ items,
1027
+ onGenerate,
1028
+ }: {
1029
+ items: LibraryItem[];
1030
+ onGenerate: boolean;
1031
+ }) {
1032
+ const works = useMemo(() => groupLibrary(items), [items]);
1033
+ // Expand state keyed by work key — the established discipline (a Set that
1034
+ // survives the library's refresh cadence: the works array is rebuilt on every
1035
+ // change, but the keys are stable, so an expanded work stays open across a
1036
+ // refresh). Default posture is COLLAPSED (empty set).
1037
+ const [openKeys, setOpenKeys] = useState<Set<string>>(() => new Set());
1038
+ const toggle = (key: string) =>
1039
+ setOpenKeys((prev) => {
1040
+ const next = new Set(prev);
1041
+ if (next.has(key)) next.delete(key);
1042
+ else next.add(key);
1043
+ return next;
1044
+ });
1045
+
1046
+ if (items.length === 0) {
1047
+ return (
1048
+ <p className="vi-lib-empty">
1049
+ No clips yet — produced crops, frames, and generations land here.
1050
+ </p>
1051
+ );
1052
+ }
1053
+
1054
+ return (
1055
+ <ul className="vi-lib-list">
1056
+ {works.map((w) => (
1057
+ <WorkSection
1058
+ key={w.key}
1059
+ group={w}
1060
+ open={openKeys.has(w.key)}
1061
+ onToggle={() => toggle(w.key)}
1062
+ onGenerate={onGenerate}
1063
+ />
1064
+ ))}
1065
+ </ul>
1066
+ );
1067
+ }
1068
+
1069
+ export function WorkbenchSidebar() {
1070
+ const reg = useSidebarRegistry();
1071
+ const library = useMediaLibrary();
1072
+ const records = useJobTracker();
1073
+ const location = useLocation();
1074
+ const [tab, setTab] = useState<SideTab>("processes");
1075
+
1076
+ // Compute the route gate ONCE here (not per row). Same segment convention the
1077
+ // shell uses: the first path segment === "generate" means the composer is live.
1078
+ const onGenerate =
1079
+ location.pathname.replace(/^\/+/, "").split("/")[0] === "generate";
1080
+
1081
+ const activeCount = useMemo(() => activeJobsOf(records).length, [records]);
1082
+ const libraryCount = library.length;
1083
+
1084
+ // "Clear all" wipes the WHOLE session library in one tick (unlike per-row
1085
+ // Remove, it has never been optimistic-with-restore, and a bulk operation has
1086
+ // no single row to un-hide on a partial failure) — but any STUDIO clip in that
1087
+ // list still needs the server-side archive, or it resurrects on the next clip
1088
+ // poll exactly like an un-wired single Remove would. Fire-and-forget (mirrors
1089
+ // jobTracker.cancelJob's idiom in this same arm): archive every studio job id
1090
+ // present, then clear locally immediately. A dropped archive call here just
1091
+ // means that one clip reappears on the next poll — recoverable via its own
1092
+ // Remove — never a stuck or corrupted library.
1093
+ function handleClearAll() {
1094
+ const studioJobIds = new Set(
1095
+ library
1096
+ .filter((it) => it.genKind === "studio_i2v" && it.groupId)
1097
+ .map((it) => it.groupId as string),
1098
+ );
1099
+ for (const jobId of studioJobIds) {
1100
+ void request<unknown>(studioClipArchiveUrl(jobId), {
1101
+ method: "POST",
1102
+ meta: { specKey: "studio", operation: "studio.clip.archive" },
1103
+ });
1104
+ }
1105
+ clearLibrary();
1106
+ }
1107
+
1108
+ // Round 9: the guarded Settings tab. If the mounted station un-registers its
1109
+ // settings (e.g. you navigate off Studio) while the Settings tab is showing,
1110
+ // fall back to Active Processes so the sidebar is never on a vanished tab.
1111
+ const { settingsActive } = reg;
1112
+ useEffect(() => {
1113
+ if (!settingsActive && tab === "settings") setTab("processes");
1114
+ }, [settingsActive, tab]);
1115
+
1116
+ return (
1117
+ <div className="vi-lib">
1118
+ {/* t52: the overlay affordances — always visible above the tab strip
1119
+ regardless of which sidebar tab is showing, so they survive tab
1120
+ switches exactly like the tabs themselves. This row is just toggles;
1121
+ the panels themselves expand into in-page overlays.
1122
+
1123
+ 2026-08-06 (operator): "having the 3 options as tab selectable within
1124
+ the overlay window would be a huge convenience". Console, ComfyUI and
1125
+ Active now share ONE drawer window with a tab strip in its header, so
1126
+ switching between them is a click instead of a close-and-reopen (the
1127
+ scrim covers this row while a drawer is open, so these buttons could
1128
+ never have done it). WorkbenchDrawer renders all three toggles — they
1129
+ stay one-per-destination DEEP LINKS into the shared window, matching
1130
+ this row's idiom (and Share, which is a separate modal). Each panel's
1131
+ own posture is untouched: the console frame is still unsandboxed
1132
+ first-party, the ComfyUI frame still sandboxed behind its scheme gate,
1133
+ and Active still polls only while its tab is the selected one. See
1134
+ WorkbenchDrawer.tsx. */}
1135
+ <div className="vi-side-toolbar">
1136
+ <WorkbenchDrawer />
1137
+ {/* k9: the Share affordance sits beside them. It renders ONLY for a
1138
+ console-authenticated session (it probes an operator-gated endpoint),
1139
+ so a share-link visitor never sees a working Share button. */}
1140
+ <SharePanel />
1141
+ </div>
1142
+
1143
+ {/* Tab switcher — Active Processes | Session Library | (Settings). Reuses the
1144
+ segmented .vi-gen-mode control idiom, above the tab bodies. Settings only
1145
+ appears when a mounted station has registered knobs into it. */}
1146
+ <div className="vi-gen-mode vi-side-tabs" role="tablist" aria-label="Sidebar">
1147
+ <button
1148
+ type="button"
1149
+ role="tab"
1150
+ aria-selected={tab === "processes"}
1151
+ className={`vi-gen-mode-btn${
1152
+ tab === "processes" ? " vi-gen-mode-btn-active" : ""
1153
+ }`}
1154
+ onClick={() => setTab("processes")}
1155
+ >
1156
+ Active Processes
1157
+ {activeCount > 0 && <span className="vi-side-tab-badge">{activeCount}</span>}
1158
+ </button>
1159
+ <button
1160
+ type="button"
1161
+ role="tab"
1162
+ aria-selected={tab === "library"}
1163
+ className={`vi-gen-mode-btn${
1164
+ tab === "library" ? " vi-gen-mode-btn-active" : ""
1165
+ }`}
1166
+ onClick={() => setTab("library")}
1167
+ >
1168
+ Session Library
1169
+ {libraryCount > 0 && (
1170
+ <span className="vi-side-tab-badge">{libraryCount}</span>
1171
+ )}
1172
+ </button>
1173
+ {settingsActive && (
1174
+ <button
1175
+ type="button"
1176
+ role="tab"
1177
+ aria-selected={tab === "settings"}
1178
+ className={`vi-gen-mode-btn${
1179
+ tab === "settings" ? " vi-gen-mode-btn-active" : ""
1180
+ }`}
1181
+ onClick={() => setTab("settings")}
1182
+ >
1183
+ Settings
1184
+ </button>
1185
+ )}
1186
+ </div>
1187
+
1188
+ {tab === "processes" && (
1189
+ <section className="vi-proc" aria-label="Active processes">
1190
+ <ProcessesPanel />
1191
+ </section>
1192
+ )}
1193
+ {tab === "library" && (
1194
+ <section aria-label="Session library">
1195
+ <div className="vi-lib-header">
1196
+ <h2 className="vi-lib-title">Session library</h2>
1197
+ <span className="vi-lib-count">{libraryCount}</span>
1198
+ {libraryCount > 0 && (
1199
+ <button
1200
+ type="button"
1201
+ className="vi-btn vi-btn-sm vi-btn-ghost"
1202
+ onClick={handleClearAll}
1203
+ title="Forget every local entry — server media is untouched, except produced studio clips, which are archived (never deleted, reversible)."
1204
+ >
1205
+ Clear all
1206
+ </button>
1207
+ )}
1208
+ </div>
1209
+ <LibraryPanel items={library} onGenerate={onGenerate} />
1210
+ </section>
1211
+ )}
1212
+ {/* Settings tab body — the DOM host the mounted station portals its knobs into.
1213
+ Only rendered when the tab is active AND a station is registered; the knob
1214
+ STATE lives in the station, so unmounting this host never loses input. */}
1215
+ {tab === "settings" && settingsActive && (
1216
+ <section
1217
+ aria-label="Settings"
1218
+ className="vi-side-settings"
1219
+ ref={reg.setSettingsHost}
1220
+ />
1221
+ )}
1222
+ </div>
1223
+ );
1224
+ }