@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,506 @@
1
+ // The inline LIBRARY SECTION of the single Studio plane (StudioPlane) — the bottom
2
+ // half of the one plane: the durable clip catalog as a list-left / player-right view
3
+ // with the per-row Details expander and per-clip actions, directly below the generate
4
+ // controls (no tabs, no navigation). (The file keeps its historical name; the export
5
+ // is `StudioLibrarySection`.)
6
+ //
7
+ // STATUS (confirmed 2026-07-12, still true): NOT MOUNTED anywhere — StudioPlane.tsx
8
+ // does not import this file. Retained per house rule (never delete, archive instead).
9
+ // Its row `onClick={() => c.playable && setSelected(c.job_id)}` below already IS a
10
+ // working select-to-viewer — but since nothing renders this component, that click
11
+ // handler was unreachable in BOTH plane mounts, which is part of why the viewer read as
12
+ // a stuck, unchangeable default. The reachable equivalent (a minimal, non-tabbed poster/
13
+ // id picker, same idiom) now lives on the viewer itself: StudioViewer.tsx's "▤ Play from
14
+ // library" panel, mounted inside the one shared StudioPlane. Re-mounting this whole
15
+ // section instead would reintroduce the dual-library redundancy Round 9 deliberately
16
+ // removed (see StudioPlane.tsx's header note) — its filters / Details / cancel / use-as-
17
+ // source affordances stay deferred here, for history only.
18
+ //
19
+ // Reuses, does not reinvent:
20
+ // • the clip list + 6s poll come from the plane (useStudioClips) — this section
21
+ // only RENDERS them, so the polling cadence is unchanged;
22
+ // • ClipDetailsPanel + the lazy per-row detail fetch are the original station's;
23
+ // • cancel goes through the SAME media-bus job cancel the rest of the arm uses
24
+ // (POST /video/jobs/<id>/cancel via config.jobCancelUrl) — a studio clip IS a
25
+ // media-bus job, so this is its cancel route (see report note on the brief's
26
+ // /llm/jobs/<id>/cancel, which is the comms store, not the media bus);
27
+ // • "use as source" hands the clip back to the plane, which stages it into the
28
+ // generate controls above (same plane) through applyStage / the studioBridge seam.
29
+ //
30
+ // Filters live in a COMPACT COLLAPSIBLE strip (not a separate tab): STATUS is fully
31
+ // backed by the list; CAPABILITY / MODEL need the detail endpoint (the list projection
32
+ // omits them — flagged in the report), so those two filter over loaded details;
33
+ // failed/cancelled rows auto-load their detail so their error shows inline without
34
+ // expanding, and "Load all details" populates the rest. The Details expander is per-row.
35
+ import { useCallback, useEffect, useRef, useState } from "react";
36
+ import { request, okValue, errorOf, describeAppError } from "../../transport/client";
37
+ import { studioClipDetailUrl, jobCancelUrl } from "../../config";
38
+ import {
39
+ ClipDetailsPanel,
40
+ clipDetailSchema,
41
+ shortId,
42
+ dims,
43
+ whenText,
44
+ type Clip,
45
+ type ClipDetail,
46
+ } from "./studioShared";
47
+
48
+ export interface StudioLibrarySectionProps {
49
+ clips: Clip[];
50
+ loading: boolean;
51
+ error: string | null;
52
+ reload: () => void;
53
+ refresh: () => void;
54
+ /** Stage this clip as the generate controls' source (extend) — same plane. */
55
+ onUseAsSource: (c: Clip) => void;
56
+ /** Stage this clip as a v2v scene-continuity source (restyle, holds identity). */
57
+ onContinueScene: (c: Clip) => void;
58
+ /** Selected clip id + setter — LIFTED to the plane so the player-forward viewer (in the
59
+ * generator column) follows the row picked here across the two-column workbench split. */
60
+ selected: string | null;
61
+ setSelected: (id: string | null) => void;
62
+ }
63
+
64
+ const CANCELLABLE = new Set(["queued", "claimed", "running"]);
65
+
66
+ function capOf(d: ClipDetail | undefined): string | undefined {
67
+ if (!d) return undefined;
68
+ const fromManifest = d.manifest?.capability;
69
+ if (typeof fromManifest === "string") return fromManifest;
70
+ const fromSpec = d.spec?.capability;
71
+ return typeof fromSpec === "string" ? fromSpec : undefined;
72
+ }
73
+
74
+ function modelOf(d: ClipDetail | undefined): string | undefined {
75
+ if (!d) return undefined;
76
+ const fromManifest = d.manifest?.model_id;
77
+ if (typeof fromManifest === "string") return fromManifest;
78
+ const fromSpec = d.spec?.model_id;
79
+ return typeof fromSpec === "string" ? fromSpec : undefined;
80
+ }
81
+
82
+ export function StudioLibrarySection({
83
+ clips,
84
+ loading,
85
+ error,
86
+ reload,
87
+ refresh,
88
+ onUseAsSource,
89
+ onContinueScene,
90
+ selected,
91
+ setSelected,
92
+ }: StudioLibrarySectionProps) {
93
+ const [openRows, setOpenRows] = useState<Set<string>>(new Set());
94
+ const [details, setDetails] = useState<Record<string, ClipDetail>>({});
95
+ const [detailErr, setDetailErr] = useState<Record<string, string>>({});
96
+ const [detailLoading, setDetailLoading] = useState<Set<string>>(new Set());
97
+
98
+ const [showFilters, setShowFilters] = useState(false);
99
+ const [statusFilter, setStatusFilter] = useState("all");
100
+ const [capFilter, setCapFilter] = useState("all");
101
+ const [modelFilter, setModelFilter] = useState("all");
102
+ const [loadingAll, setLoadingAll] = useState(false);
103
+
104
+ const [cancelBusy, setCancelBusy] = useState<Set<string>>(new Set());
105
+ const [copiedId, setCopiedId] = useState<string | null>(null);
106
+
107
+ const mounted = useRef(true);
108
+ useEffect(() => {
109
+ mounted.current = true;
110
+ return () => {
111
+ mounted.current = false;
112
+ };
113
+ }, []);
114
+
115
+ // Lazy per-job detail fetch (cached after — never rides the 6s list poll).
116
+ const fetchDetail = useCallback(
117
+ async (jobId: string) => {
118
+ if (details[jobId] || detailLoading.has(jobId)) return;
119
+ setDetailLoading((prev) => new Set(prev).add(jobId));
120
+ try {
121
+ const res = await request<unknown>(studioClipDetailUrl(jobId), {
122
+ meta: { specKey: "studio", operation: "studio.clip.detail" },
123
+ });
124
+ if (!mounted.current) return;
125
+ if (!res.ok) {
126
+ setDetailErr((p) => ({ ...p, [jobId]: describeAppError(errorOf(res)) }));
127
+ return;
128
+ }
129
+ const parsed = clipDetailSchema.safeParse(okValue(res));
130
+ if (!parsed.success) {
131
+ setDetailErr((p) => ({ ...p, [jobId]: "Malformed clip detail response." }));
132
+ return;
133
+ }
134
+ setDetails((p) => ({ ...p, [jobId]: parsed.data }));
135
+ setDetailErr((p) => {
136
+ const n = { ...p };
137
+ delete n[jobId];
138
+ return n;
139
+ });
140
+ } finally {
141
+ if (mounted.current)
142
+ setDetailLoading((prev) => {
143
+ const n = new Set(prev);
144
+ n.delete(jobId);
145
+ return n;
146
+ });
147
+ }
148
+ },
149
+ [details, detailLoading],
150
+ );
151
+
152
+ // (Auto-select of the newest playable clip is LIFTED to the plane, which owns the
153
+ // shared `selected` so the viewer in the generator column can follow it.)
154
+
155
+ // Failed/cancelled rows are the ones that most need explaining, so auto-load their
156
+ // detail ONCE (targeted + cheap) — the error then shows inline without expanding.
157
+ const autoFetched = useRef<Set<string>>(new Set());
158
+ useEffect(() => {
159
+ for (const c of clips) {
160
+ const s = c.status ?? "";
161
+ if (
162
+ (s === "failed" || s === "cancelled") &&
163
+ !autoFetched.current.has(c.job_id) &&
164
+ !details[c.job_id] &&
165
+ !detailErr[c.job_id]
166
+ ) {
167
+ autoFetched.current.add(c.job_id);
168
+ void fetchDetail(c.job_id);
169
+ }
170
+ }
171
+ }, [clips, details, detailErr, fetchDetail]);
172
+
173
+ const toggleDetails = useCallback(
174
+ (jobId: string) => {
175
+ let willOpen = false;
176
+ setOpenRows((prev) => {
177
+ const next = new Set(prev);
178
+ if (next.has(jobId)) next.delete(jobId);
179
+ else {
180
+ next.add(jobId);
181
+ willOpen = true;
182
+ }
183
+ return next;
184
+ });
185
+ if (willOpen) void fetchDetail(jobId);
186
+ },
187
+ [fetchDetail],
188
+ );
189
+
190
+ const loadAllDetails = useCallback(async () => {
191
+ setLoadingAll(true);
192
+ try {
193
+ for (const c of clips) {
194
+ if (!details[c.job_id] && !detailErr[c.job_id]) {
195
+ // eslint-disable-next-line no-await-in-loop
196
+ await fetchDetail(c.job_id);
197
+ }
198
+ }
199
+ } finally {
200
+ if (mounted.current) setLoadingAll(false);
201
+ }
202
+ }, [clips, details, detailErr, fetchDetail]);
203
+
204
+ const onCancel = useCallback(
205
+ async (jobId: string) => {
206
+ setCancelBusy((prev) => new Set(prev).add(jobId));
207
+ try {
208
+ await request<unknown>(jobCancelUrl(jobId), {
209
+ method: "POST",
210
+ meta: { specKey: "studio", operation: "studio.clip.cancel" },
211
+ });
212
+ if (!mounted.current) return;
213
+ refresh();
214
+ } finally {
215
+ if (mounted.current)
216
+ setCancelBusy((prev) => {
217
+ const n = new Set(prev);
218
+ n.delete(jobId);
219
+ return n;
220
+ });
221
+ }
222
+ },
223
+ [refresh],
224
+ );
225
+
226
+ const onCopyPath = useCallback((jobId: string, uri: string | null | undefined) => {
227
+ if (!uri) return;
228
+ try {
229
+ void navigator.clipboard?.writeText(uri);
230
+ setCopiedId(jobId);
231
+ window.setTimeout(() => {
232
+ if (mounted.current) setCopiedId((c) => (c === jobId ? null : c));
233
+ }, 1500);
234
+ } catch {
235
+ // clipboard unavailable — silently ignore (the path is still shown on expand)
236
+ }
237
+ }, []);
238
+
239
+ // Filter option sets. Status is from the list; capability/model from loaded
240
+ // details (the list projection omits them).
241
+ const statuses = Array.from(
242
+ new Set(clips.map((c) => c.status ?? "").filter(Boolean)),
243
+ ).sort();
244
+ const capabilities = Array.from(
245
+ new Set(clips.map((c) => capOf(details[c.job_id])).filter(Boolean) as string[]),
246
+ ).sort();
247
+ const models = Array.from(
248
+ new Set(clips.map((c) => modelOf(details[c.job_id])).filter(Boolean) as string[]),
249
+ ).sort();
250
+
251
+ const filtered = clips.filter((c) => {
252
+ if (statusFilter !== "all" && (c.status ?? "") !== statusFilter) return false;
253
+ if (capFilter !== "all" && capOf(details[c.job_id]) !== capFilter) return false;
254
+ if (modelFilter !== "all" && modelOf(details[c.job_id]) !== modelFilter) return false;
255
+ return true;
256
+ });
257
+
258
+ const filtersActive = capFilter !== "all" || modelFilter !== "all";
259
+ const activeFilterCount =
260
+ (statusFilter !== "all" ? 1 : 0) +
261
+ (capFilter !== "all" ? 1 : 0) +
262
+ (modelFilter !== "all" ? 1 : 0);
263
+
264
+ return (
265
+ <div className="vi-studio-library">
266
+ <p className="vi-comfy-label" style={{ marginBottom: "0.4rem" }}>
267
+ Clips
268
+ </p>
269
+
270
+ {/* ── compact, collapsible filters strip (not a separate tab) ── */}
271
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", alignItems: "center" }}>
272
+ <button
273
+ type="button"
274
+ className="vi-btn vi-btn-sm vi-btn-ghost"
275
+ aria-expanded={showFilters}
276
+ onClick={() => setShowFilters((v) => !v)}
277
+ >
278
+ {showFilters ? "▾ Filters" : "▸ Filters"}
279
+ {activeFilterCount > 0 ? ` (${activeFilterCount})` : ""}
280
+ </button>
281
+ <button type="button" className="vi-btn vi-btn-sm vi-btn-ghost" onClick={reload}>
282
+ Refresh
283
+ </button>
284
+ <span className="vi-comfy-hint" style={{ opacity: 0.7 }}>
285
+ {filtered.length}/{clips.length} clips
286
+ </span>
287
+ </div>
288
+
289
+ {showFilters && (
290
+ <>
291
+ <div
292
+ className="vi-comfy-bar"
293
+ style={{ flexWrap: "wrap", gap: "0.75rem", alignItems: "flex-end", marginTop: "0.5rem" }}
294
+ >
295
+ <label className="vi-comfy-field" style={{ maxWidth: "10rem" }}>
296
+ <span className="vi-comfy-label">Status</span>
297
+ <select
298
+ className="vi-knob-input"
299
+ value={statusFilter}
300
+ onChange={(e) => setStatusFilter(e.target.value)}
301
+ >
302
+ <option value="all">all</option>
303
+ {statuses.map((s) => (
304
+ <option key={s} value={s}>
305
+ {s}
306
+ </option>
307
+ ))}
308
+ </select>
309
+ </label>
310
+ <label className="vi-comfy-field" style={{ maxWidth: "10rem" }}>
311
+ <span className="vi-comfy-label">Capability</span>
312
+ <select
313
+ className="vi-knob-input"
314
+ value={capFilter}
315
+ onChange={(e) => setCapFilter(e.target.value)}
316
+ >
317
+ <option value="all">all</option>
318
+ {capabilities.map((c) => (
319
+ <option key={c} value={c}>
320
+ {c}
321
+ </option>
322
+ ))}
323
+ </select>
324
+ </label>
325
+ <label className="vi-comfy-field" style={{ maxWidth: "14rem" }}>
326
+ <span className="vi-comfy-label">Model</span>
327
+ <select
328
+ className="vi-knob-input"
329
+ value={modelFilter}
330
+ onChange={(e) => setModelFilter(e.target.value)}
331
+ >
332
+ <option value="all">all</option>
333
+ {models.map((m) => (
334
+ <option key={m} value={m}>
335
+ {m}
336
+ </option>
337
+ ))}
338
+ </select>
339
+ </label>
340
+ <div className="vi-comfy-bar-actions">
341
+ <button
342
+ type="button"
343
+ className="vi-btn vi-btn-ghost"
344
+ onClick={loadAllDetails}
345
+ disabled={loadingAll}
346
+ title="Fetch every clip's detail so the Capability / Model filters cover all rows."
347
+ >
348
+ {loadingAll ? "Loading details…" : "Load all details"}
349
+ </button>
350
+ </div>
351
+ </div>
352
+ {filtersActive && (
353
+ <p className="vi-comfy-hint" role="note" style={{ marginTop: "0.3rem" }}>
354
+ Capability / Model filter over loaded details — use <strong>Load all details</strong>{" "}
355
+ so every clip is covered.
356
+ </p>
357
+ )}
358
+ </>
359
+ )}
360
+
361
+ {/* Clip list only — the player-forward viewer now lives in the GENERATOR column
362
+ (StudioViewer), driven by the lifted `selected`. This is the Library TAB body. */}
363
+ <ul
364
+ className="vi-studio-clips-list"
365
+ style={{
366
+ listStyle: "none",
367
+ margin: "1rem 0 0",
368
+ padding: 0,
369
+ maxHeight: "34rem",
370
+ overflowY: "auto",
371
+ }}
372
+ >
373
+ {loading && <li className="vi-comfy-hint">Loading clips…</li>}
374
+ {!loading && error && (
375
+ <li className="vi-error" role="alert">
376
+ {error}
377
+ </li>
378
+ )}
379
+ {!loading && !error && clips.length === 0 && (
380
+ <li className="vi-comfy-hint">No studio clips yet — generate one above.</li>
381
+ )}
382
+ {!loading && !error && clips.length > 0 && filtered.length === 0 && (
383
+ <li className="vi-comfy-hint">No clips match the current filters.</li>
384
+ )}
385
+ {filtered.map((c) => {
386
+ const isSel = c.job_id === selected;
387
+ const isOpen = openRows.has(c.job_id);
388
+ const status = c.status ?? "";
389
+ const isBad = status === "failed" || status === "cancelled";
390
+ const d = details[c.job_id];
391
+ const inlineErr = isBad && d?.error ? d.error : null;
392
+ const cancellable = CANCELLABLE.has(status);
393
+ return (
394
+ <li key={c.job_id} style={{ marginBottom: "0.5rem" }}>
395
+ <button
396
+ type="button"
397
+ className={isSel ? "vi-btn vi-btn-accent" : "vi-btn vi-btn-ghost"}
398
+ onClick={() => c.playable && setSelected(c.job_id)}
399
+ disabled={!c.playable}
400
+ title={c.job_id}
401
+ style={{
402
+ width: "100%",
403
+ justifyContent: "space-between",
404
+ display: "flex",
405
+ gap: "0.5rem",
406
+ opacity: c.playable ? 1 : 0.6,
407
+ }}
408
+ >
409
+ <span>{shortId(c.job_id)}</span>
410
+ <span style={{ fontVariantNumeric: "tabular-nums", opacity: 0.8 }}>
411
+ {status}
412
+ {dims(c) ? ` · ${dims(c)}` : ""}
413
+ {whenText(c) ? ` · ${whenText(c)}` : ""}
414
+ </span>
415
+ </button>
416
+
417
+ {/* Prominent inline error for a failed/cancelled row (auto-loaded). */}
418
+ {inlineErr && (
419
+ <div
420
+ className="vi-error"
421
+ role="alert"
422
+ style={{ margin: "0.25rem 0", fontSize: "0.82rem" }}
423
+ >
424
+ <strong>
425
+ {status === "cancelled" ? "Cancelled" : "Failed"} [
426
+ {inlineErr.code ?? "error"}]
427
+ </strong>
428
+ {inlineErr.message ? ` — ${inlineErr.message}` : ""}
429
+ </div>
430
+ )}
431
+
432
+ {/* per-clip actions */}
433
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.3rem", marginTop: "0.2rem" }}>
434
+ {c.playable && (
435
+ <button
436
+ type="button"
437
+ className="vi-btn vi-btn-sm vi-btn-ghost"
438
+ onClick={() => setSelected(c.job_id)}
439
+ >
440
+ Play
441
+ </button>
442
+ )}
443
+ {c.output?.uri && (
444
+ <button
445
+ type="button"
446
+ className="vi-btn vi-btn-sm vi-btn-ghost"
447
+ onClick={() => onCopyPath(c.job_id, c.output?.uri)}
448
+ >
449
+ {copiedId === c.job_id ? "Copied" : "Copy path"}
450
+ </button>
451
+ )}
452
+ {c.playable && (
453
+ <button
454
+ type="button"
455
+ className="vi-btn vi-btn-sm vi-btn-ghost"
456
+ onClick={() => onUseAsSource(c)}
457
+ title="Prefill the generator above with this clip as the source (extend from its last frame). If you already have reference images, the identity is carried across."
458
+ >
459
+ Use as source
460
+ </button>
461
+ )}
462
+ {c.playable && (
463
+ <button
464
+ type="button"
465
+ className="vi-btn vi-btn-sm vi-btn-ghost"
466
+ onClick={() => onContinueScene(c)}
467
+ title="Continue this scene: stage the clip as a v2v scene-continuity source (restyle / extend the prior scene while HOLDING an identity). Add or keep 1–4 reference images to lock the subject across the cut."
468
+ >
469
+ Continue scene ↦
470
+ </button>
471
+ )}
472
+ {cancellable && (
473
+ <button
474
+ type="button"
475
+ className="vi-btn vi-btn-sm vi-btn-ghost"
476
+ onClick={() => void onCancel(c.job_id)}
477
+ disabled={cancelBusy.has(c.job_id)}
478
+ >
479
+ {cancelBusy.has(c.job_id) ? "Cancelling…" : "Cancel"}
480
+ </button>
481
+ )}
482
+ <button
483
+ type="button"
484
+ className="vi-btn vi-btn-sm vi-btn-ghost"
485
+ aria-expanded={isOpen}
486
+ onClick={() => toggleDetails(c.job_id)}
487
+ style={{ opacity: 0.85 }}
488
+ >
489
+ {isOpen ? "▾ Details" : "▸ Details"}
490
+ </button>
491
+ </div>
492
+
493
+ {isOpen && (
494
+ <ClipDetailsPanel
495
+ detail={details[c.job_id]}
496
+ loading={detailLoading.has(c.job_id)}
497
+ error={detailErr[c.job_id]}
498
+ />
499
+ )}
500
+ </li>
501
+ );
502
+ })}
503
+ </ul>
504
+ </div>
505
+ );
506
+ }