@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,821 @@
1
+ // Shared internals of the Studio WORKSPACE (the sub-tabbed promotion of the old
2
+ // single-page Studio Clips station). This module holds the pieces BOTH sub-tabs
3
+ // (Generate, Library) read, so neither tab re-declares them and the wire schemas
4
+ // stay in one place:
5
+ //
6
+ // • the tolerant zod schemas + types for the studio clip list and the per-clip
7
+ // detail (manifest / requested-spec / error), extended for the new detail
8
+ // `source` discriminator + bus timestamps the route now returns;
9
+ // • the small display helpers (shortId / dims / whenText / fmtWhen);
10
+ // • DetailRow + ClipDetailsPanel — the creation-parameters expander, carried
11
+ // verbatim from the original station and taught to LABEL a job-record view as
12
+ // REQUESTED (a failed/cancelled render bound no model, so its params are what
13
+ // was asked for, not what ran);
14
+ // • useStudioClips — the durable-catalog list + light background poll + the
15
+ // Session-Library push, extracted from the station so the shell owns it once
16
+ // and both tabs read the same list;
17
+ // • the studio constants (the VACE v2v envelope, a curated model-pin list, and
18
+ // the synthetic-vs-real preset test).
19
+ //
20
+ // Nothing here is new behavior — it is the original station's own code, factored
21
+ // so the workspace can grow two rich tabs without duplicating the contract.
22
+ import { useCallback, useEffect, useRef, useState } from "react";
23
+ import { z } from "zod";
24
+ import { request, okValue, errorOf, describeAppError } from "../../transport/client";
25
+ import { hugpyConfig, studioClipDetailUrl, mediaBytesUrl } from "../../config";
26
+ import { addToLibrary, updateLibraryProvenance } from "../../video/mediaLibrary";
27
+ import type { LibraryItem } from "../../video/mediaLibrary";
28
+ import type { MediaRef } from "../../video/contract";
29
+ import type { StudioPreset } from "../../video/useStudioPresets";
30
+
31
+ // ── studio constants ───────────────────────────────────────────────────────
32
+
33
+ // v2v RESTYLE — the ONLY geometry the Wan 2.1 VACE 1.3B control model accepts:
34
+ // 832x480 LANDSCAPE @16fps at a 6 GB budget. The studio router rejects a
35
+ // portrait/oversized v2v request (RESOLUTION_UNSUPPORTED), so restyle mode LOCKS
36
+ // the form to this envelope; nothing dead-on-arrival can be enqueued.
37
+ export const VACE_W = 832;
38
+ export const VACE_H = 480;
39
+ export const VACE_FPS = 16;
40
+ export const VACE_BUDGET_GB = 6;
41
+
42
+ // ── honesty banner (operator ask 2026-07-12): "leaving VRAM budget blank silently
43
+ // binds the SYNTHETIC tier" bit the operator repeatedly — the only prior hint was
44
+ // a footnote nobody reads. These three pieces are the shared basis for a LOUD
45
+ // pre-submit banner in both Studio surfaces (StudioGenerateTab's Clip mode,
46
+ // StudioMovieComposer's Movie mode).
47
+ //
48
+ // ⚠ THE 0.5 PREDICTION IS GONE (operator ruling 2026-07-27). A blank budget used to
49
+ // mean "server substitutes 0.5", which sat under every real model's floor and bound the
50
+ // synthetic prover — so this file predicted 0.5 to raise the banner. Blank now means
51
+ // AUTOFIT TO THE CARD'S CAPACITY (the reservation engine evicts to free that room), and
52
+ // an unmeasurable worker REFUSES rather than falling back to a number. Predicting 0.5
53
+ // today would warn on every blank submit, which is the wrong lesson to teach.
54
+
55
+ // STUDIO_REAL_FLOOR_GB: the cheapest REAL model footprint among the capabilities
56
+ // that ALSO have a synthetic last-resort registered (t2v, i2v — see
57
+ // studio/models_seed.py: synthetic-t2v/synthetic-i2v are the only synthetic
58
+ // ModelConfigs, and the router's real_first score dimension means a real model
59
+ // ALWAYS wins when one fits — synthetic binds only when NO real model fits the
60
+ // budget). Reuses VACE_BUDGET_GB's number on purpose: wan2.1-vace-1.3b's 6GB INT8
61
+ // envelope is the cheapest real footprint in the whole catalog, so 6 is a single
62
+ // honest floor to advise regardless of which of the two synthetic-shadowed
63
+ // capabilities is active. v2v / id_lock are NOT in this club — models_seed.py
64
+ // registers no synthetic model for either capability, so a too-low budget there
65
+ // fails the router honestly (NO_CAPABLE_MODEL/VRAM_EXCEEDED) instead of silently
66
+ // rendering noise; the honesty banner has nothing to warn about on those shapes.
67
+ export const STUDIO_REAL_FLOOR_GB = VACE_BUDGET_GB;
68
+
69
+ // The banner's one-click fill target. 10 clears STUDIO_REAL_FLOOR_GB with headroom
70
+ // for either shadowed capability's cheapest real model (t2v ~5GB, i2v ~8GB via
71
+ // ltx-video-0.9.7-dev INT8) without hand-tuning per capability.
72
+ export const STUDIO_SAFE_FILL_GB = 10;
73
+
74
+ /** The budget the ROUTE would effectively see, or `null` when the operator has TYPED
75
+ * NOTHING USABLE — blank or garbled, both of which the server now resolves by sizing
76
+ * to the worker's GPU capacity (or refusing outright if it cannot read it).
77
+ *
78
+ * `null` means "autofit — not predictable from here", NOT "zero". Callers must test it
79
+ * explicitly: `n < FLOOR` would be TRUE for null (null coerces to 0 in JS), which would
80
+ * raise the synthetic warning on every blank field — exactly the false alarm this
81
+ * return type exists to prevent. */
82
+ export function effectiveBudgetGb(raw: string): number | null {
83
+ const t = raw.trim();
84
+ if (t === "") return null;
85
+ const n = Number(t);
86
+ return Number.isFinite(n) ? n : null;
87
+ }
88
+
89
+ /** Would this budget bind the synthetic prover? Only a TYPED number under the real
90
+ * floor can; a blank field autofits to capacity server-side and is not a warning.
91
+ *
92
+ * ⚠ `floorGb` (2026-07-29): the caller passes the MEASURED floor for the active
93
+ * capability — `vram_envelope_gb` off GET /video/render/presets, which is the exact
94
+ * number the router compares a budget against (`cfg.vram.fits(budget)` is
95
+ * `gb <= budget`). STUDIO_REAL_FLOOR_GB stays only as the fallback for a page whose
96
+ * discovery GET has not landed, and it is a KNOWN-LOW fallback: it says 6 while the
97
+ * cheapest real row in the ratified table is 8.2, so a budget of 7 binds synthetic and
98
+ * the un-parameterised form of this predicate said nothing. Deriving beats mirroring. */
99
+ export function bindsSyntheticTier(raw: string, floorGb: number = STUDIO_REAL_FLOOR_GB): boolean {
100
+ const n = effectiveBudgetGb(raw);
101
+ return n !== null && n < floorGb;
102
+ }
103
+
104
+ // A studio job does NOT pin a model by default — the capability router picks one
105
+ // from capability + budget + resolution. This is a SENSIBLE, curated list of the
106
+ // studio model ids an operator might PIN (from video_intel/studio/models_seed.py:
107
+ // the Wan tiers + the synthetic provers), offered blank-first ("auto"). It is
108
+ // advisory: an unroutable pin comes back as errors-as-data on the job, never a
109
+ // silent fallback.
110
+ //
111
+ // ⚠ THIS LIST IS NOW THE FALLBACK / ESCAPE HATCH, NOT THE MENU (2026-07-29). It is a
112
+ // hand-maintained mirror of a backend seed file and it is unfiltered: nothing in it
113
+ // knows which of these models can serve the capability the operator has chosen, so
114
+ // pinning wan2.1-t2v-1.3b under capability=v2v was offered as a first-class choice and
115
+ // only failed at render time. The pickers now build their menu from
116
+ // `modelsForCapability` over the MEASURED render-preset table and fall back to this list
117
+ // only while that discovery GET has not landed — plus keep it behind an explicit
118
+ // "show all models" toggle so an operator can still pin off-menu, labelled unverified.
119
+ export const STUDIO_MODEL_IDS: readonly string[] = [
120
+ "wan2.1-t2v-1.3b",
121
+ "wan2.1-i2v-14b-720p",
122
+ // wan2.2-{t2v,i2v}-a14b removed 2026-08-13: battery-measured 22.2GiB peak
123
+ // cannot complete on the fleet 24GB card; registry rows dropped too
124
+ // (models_seed.py tombstone). Weights kept on the shared store for a
125
+ // bigger-card future.
126
+ "wan2.1-vace-1.3b",
127
+ "wan2.1-vace-14b",
128
+ "synthetic-i2v",
129
+ "synthetic-t2v",
130
+ ];
131
+
132
+ // A preset is a SYNTHETIC preview iff it ships the honesty badge (prompt_note) —
133
+ // the seed table sets it ONLY on the no-GPU procedural provers, whose frames are a
134
+ // pure function of seed + geometry (the prompt is recorded, not rendered). Real
135
+ // tiers leave it empty. This is the discriminator the Generate tab groups by.
136
+ export function isSyntheticPreset(p: StudioPreset): boolean {
137
+ return typeof p.prompt_note === "string" && p.prompt_note.trim().length > 0;
138
+ }
139
+
140
+ // ── clip list schemas (tolerant/passthrough — assert only what we read) ──────
141
+
142
+ const clipOutputSchema = z
143
+ .object({
144
+ asset_id: z.string().nullable().optional(),
145
+ uri: z.string().nullable().optional(),
146
+ mime: z.string().nullable().optional(),
147
+ width: z.number().nullable().optional(),
148
+ height: z.number().nullable().optional(),
149
+ duration_s: z.number().nullable().optional(),
150
+ })
151
+ .passthrough();
152
+
153
+ // Placement (Active Processes) — WHERE a clip's render physically executes.
154
+ // Additive + omit-when-unset on the wire; tolerant/passthrough like the rest.
155
+ const placementSchema = z
156
+ .object({
157
+ source: z.string().nullable().optional(),
158
+ host: z.string().nullable().optional(),
159
+ worker_id: z.string().nullable().optional(),
160
+ gpu: z.string().nullable().optional(),
161
+ process: z.string().nullable().optional(),
162
+ reserved_bytes: z.number().nullable().optional(),
163
+ })
164
+ .passthrough();
165
+
166
+ // STAGE TIMELINE (the exhaustive per-process telemetry). One coarse-stage entry a
167
+ // render moved through — deduped server-side (a frame loop is one row with a running
168
+ // `count`). The terminal row also carries the outcome (code/message/retryable +
169
+ // failed_at_stage). RETAINED through terminal, so a failed row shows its full history.
170
+ const stageEntrySchema = z
171
+ .object({
172
+ stage: z.string(),
173
+ ts: z.number().nullable().optional(),
174
+ ts_last: z.number().nullable().optional(),
175
+ count: z.number().nullable().optional(),
176
+ detail: z.string().nullable().optional(),
177
+ // terminal-entry extras
178
+ code: z.string().nullable().optional(),
179
+ message: z.string().nullable().optional(),
180
+ retryable: z.boolean().nullable().optional(),
181
+ failed_at_stage: z.string().nullable().optional(),
182
+ })
183
+ .passthrough();
184
+ export type StageEntry = z.infer<typeof stageEntrySchema>;
185
+
186
+ // Terminal FAILURE summary — the exact "where it's failing": the failing STAGE plus
187
+ // the error code/message/retryable. Present only on a failed/cancelled row.
188
+ const failureSchema = z
189
+ .object({
190
+ stage: z.string().nullable().optional(),
191
+ code: z.string().nullable().optional(),
192
+ message: z.string().nullable().optional(),
193
+ retryable: z.boolean().nullable().optional(),
194
+ })
195
+ .passthrough();
196
+ export type JobFailure = z.infer<typeof failureSchema>;
197
+
198
+ export const clipSchema = z
199
+ .object({
200
+ job_id: z.string(),
201
+ status: z.string().nullable().optional(),
202
+ playable: z.boolean().nullable().optional(),
203
+ created: z.number().nullable().optional(),
204
+ updated: z.number().nullable().optional(),
205
+ output: clipOutputSchema.nullable().optional(),
206
+ // Live progress blob (carries the awaiting_capacity HOLD marker) + placement —
207
+ // both additive; present only when the server sends them.
208
+ progress: z.record(z.unknown()).nullable().optional(),
209
+ placement: placementSchema.nullable().optional(),
210
+ // Exhaustive per-process telemetry (all additive; retained through terminal):
211
+ // the stage TIMELINE, the terminal FAILURE summary, the honest last-movement ts
212
+ // (stall basis), and the current stage.
213
+ stage_log: z.array(stageEntrySchema).nullable().optional(),
214
+ failure: failureSchema.nullable().optional(),
215
+ last_movement_ts: z.number().nullable().optional(),
216
+ current_stage: z.string().nullable().optional(),
217
+ })
218
+ .passthrough();
219
+
220
+ export const clipsResponseSchema = z.object({ clips: z.array(clipSchema) }).passthrough();
221
+
222
+ export type Clip = z.infer<typeof clipSchema>;
223
+
224
+ /** Build a durable video MediaRef from a studio clip row, or null if it carries no
225
+ * streamable output (uri + asset_id). Used by the source-clip picker + "use as
226
+ * source" so both derive the same ref the enqueue body wants. */
227
+ export function clipToMediaRef(c: Clip): MediaRef | null {
228
+ const o = c.output;
229
+ if (!o || !o.uri || !o.asset_id) return null;
230
+ return {
231
+ asset_id: o.asset_id,
232
+ kind: "video",
233
+ uri: o.uri,
234
+ mime: o.mime ?? "video/mp4",
235
+ width: o.width ?? null,
236
+ height: o.height ?? null,
237
+ duration_s: o.duration_s ?? null,
238
+ };
239
+ }
240
+
241
+ // GET /video/studio/clip/<id>/detail — the exact creation params for a row expander.
242
+ // `source` (new): "manifest" = the resolved params from the content-addressed
243
+ // manifest beside a produced clip; "job_record" = a failed/cancelled/running job
244
+ // wrote no manifest, so `spec` is the REQUESTED params and `error` the failure.
245
+ export const clipDetailSchema = z
246
+ .object({
247
+ job_id: z.string(),
248
+ status: z.string().nullable().optional(),
249
+ source: z.string().nullable().optional(),
250
+ spec: z.record(z.unknown()).nullable().optional(),
251
+ manifest: z
252
+ .object({
253
+ content_hash: z.string().nullable().optional(),
254
+ model_id: z.string().nullable().optional(),
255
+ precision: z.string().nullable().optional(),
256
+ resolution: z
257
+ .object({ width: z.number(), height: z.number(), fps: z.number() })
258
+ .nullable()
259
+ .optional(),
260
+ duration_s: z.number().nullable().optional(),
261
+ frames: z.number().nullable().optional(),
262
+ seed: z.number().nullable().optional(),
263
+ sampler: z.record(z.unknown()).nullable().optional(),
264
+ prompt: z.string().nullable().optional(),
265
+ negative_prompt: z.string().nullable().optional(),
266
+ source_video: z.string().nullable().optional(),
267
+ })
268
+ .passthrough()
269
+ .nullable()
270
+ .optional(),
271
+ error: z
272
+ .object({
273
+ code: z.string().nullable().optional(),
274
+ message: z.string().nullable().optional(),
275
+ retryable: z.boolean().nullable().optional(),
276
+ // the failing STAGE (from the retained timeline) — where it broke.
277
+ stage: z.string().nullable().optional(),
278
+ })
279
+ .passthrough()
280
+ .nullable()
281
+ .optional(),
282
+ // Retained stage TIMELINE + current stage + last-movement ts (additive) so a
283
+ // terminal Library row is inspectable with exactly what it did + where it stopped.
284
+ stage_log: z.array(stageEntrySchema).nullable().optional(),
285
+ current_stage: z.string().nullable().optional(),
286
+ last_movement_ts: z.number().nullable().optional(),
287
+ created: z.number().nullable().optional(),
288
+ updated: z.number().nullable().optional(),
289
+ })
290
+ .passthrough();
291
+ export type ClipDetail = z.infer<typeof clipDetailSchema>;
292
+
293
+ // ── display helpers ──────────────────────────────────────────────────────────
294
+
295
+ export function shortId(id: string): string {
296
+ return id.length > 10 ? `${id.slice(0, 8)}…` : id;
297
+ }
298
+
299
+ export function dims(c: Clip): string {
300
+ const o = c.output;
301
+ if (!o || o.width == null || o.height == null) return "";
302
+ const d = o.duration_s != null ? ` · ${o.duration_s.toFixed(1)}s` : "";
303
+ return `${o.width}×${o.height}${d}`;
304
+ }
305
+
306
+ export function whenText(c: Clip): string {
307
+ const t = c.updated ?? c.created;
308
+ if (t == null) return "";
309
+ try {
310
+ return new Date(t * 1000).toLocaleTimeString();
311
+ } catch {
312
+ return "";
313
+ }
314
+ }
315
+
316
+ /** Absolute local date+time for a bus epoch-seconds timestamp (detail expander). */
317
+ export function fmtWhen(t: number | null | undefined): string {
318
+ if (t == null) return "";
319
+ try {
320
+ return new Date(t * 1000).toLocaleString();
321
+ } catch {
322
+ return "";
323
+ }
324
+ }
325
+
326
+ // ── the STAGE TIMELINE view (the exhaustive per-process expandable) ──────────
327
+
328
+ const TIMELINE_TERMINAL = new Set(["done", "failed", "cancelled"]);
329
+ // A render is "stalled" when the current stage has not moved for this long. Honest,
330
+ // tied to the CURRENT stage (server-computed last_movement_ts), not a bare row clock.
331
+ export const PROC_STALL_MS = 90_000;
332
+
333
+ /** "12s" / "3m 4s" / "1h 2m" since an epoch-seconds instant. */
334
+ export function agoText(tsSec: number | null | undefined, nowMs: number): string {
335
+ if (tsSec == null) return "";
336
+ const secs = Math.max(0, Math.floor(nowMs / 1000 - tsSec));
337
+ if (secs < 60) return `${secs}s`;
338
+ const m = Math.floor(secs / 60);
339
+ if (m < 60) return `${m}m ${secs % 60}s`;
340
+ return `${Math.floor(m / 60)}h ${m % 60}m`;
341
+ }
342
+
343
+ /** Is a job stalled? current stage silent past the window (never for a terminal job). */
344
+ export function isStalled(
345
+ lastMovementTs: number | null | undefined,
346
+ terminal: boolean,
347
+ nowMs: number,
348
+ ): boolean {
349
+ if (terminal || lastMovementTs == null) return false;
350
+ return nowMs / 1000 - lastMovementTs > PROC_STALL_MS / 1000;
351
+ }
352
+
353
+ /**
354
+ * The exhaustive per-process detail: the STAGE TIMELINE (each stage with its time +
355
+ * detail; the current stage highlighted, done stages checked), the CURRENT ACTIVITY
356
+ * line, an honest STALL indicator tied to the current stage, and — the whole point —
357
+ * on failure the exact failing STAGE + error code + message, prominently.
358
+ *
359
+ * No fabricated bars: this renders only what the server sent. `nowMs` is threaded in
360
+ * (a ~1s tick from the parent) so the stall clock advances live without a poll.
361
+ */
362
+ export function ProcessTimeline({
363
+ stageLog,
364
+ failure,
365
+ currentStage,
366
+ lastMovementTs,
367
+ terminal = false,
368
+ nowMs,
369
+ }: {
370
+ stageLog: StageEntry[] | null | undefined;
371
+ failure?: JobFailure | null;
372
+ currentStage?: string | null;
373
+ lastMovementTs?: number | null;
374
+ terminal?: boolean;
375
+ nowMs: number;
376
+ }) {
377
+ const log = stageLog ?? [];
378
+ const stalled = isStalled(lastMovementTs, terminal, nowMs);
379
+
380
+ // The current stage's freshest detail = the "what it's doing right now" line.
381
+ const currentEntry =
382
+ !terminal && currentStage
383
+ ? [...log].reverse().find((e) => e.stage === currentStage)
384
+ : undefined;
385
+ const currentDetail = currentEntry?.detail;
386
+
387
+ if (log.length === 0 && !failure) {
388
+ return (
389
+ <p className="vi-timeline-empty">
390
+ No stage history recorded yet — it appears here as the render moves through
391
+ its stages.
392
+ </p>
393
+ );
394
+ }
395
+
396
+ return (
397
+ <div className="vi-timeline">
398
+ {/* FAILURE — the exact where-it-broke, first + loud. */}
399
+ {failure && failure.code && (
400
+ <div className="vi-timeline-failure" role="alert">
401
+ <strong>
402
+ Failed{failure.stage ? ` at ${failure.stage}` : ""} [{failure.code}]
403
+ </strong>
404
+ {failure.retryable ? <span className="vi-timeline-retry"> · retryable</span> : null}
405
+ {failure.message ? <div className="vi-timeline-failmsg">{failure.message}</div> : null}
406
+ </div>
407
+ )}
408
+
409
+ {/* CURRENT ACTIVITY — the live "frame 12/48" / "awaiting capacity …" line. */}
410
+ {!terminal && (currentDetail || currentStage) && (
411
+ <div className={`vi-timeline-current${stalled ? " vi-timeline-current-stalled" : ""}`}>
412
+ <span className="vi-timeline-current-label">now</span>
413
+ <span className="vi-timeline-current-text">
414
+ {currentDetail || currentStage}
415
+ </span>
416
+ {stalled && lastMovementTs != null ? (
417
+ <span className="vi-timeline-stall" title="No stage movement in a while">
418
+ ⚠ no movement {agoText(lastMovementTs, nowMs)}
419
+ </span>
420
+ ) : lastMovementTs != null ? (
421
+ <span className="vi-timeline-move">· {agoText(lastMovementTs, nowMs)} since last movement</span>
422
+ ) : null}
423
+ </div>
424
+ )}
425
+
426
+ {/* THE TIMELINE — every stage the render moved through, in order. */}
427
+ <ol className="vi-timeline-list">
428
+ {log.map((e, i) => {
429
+ const isTerm = TIMELINE_TERMINAL.has(e.stage);
430
+ const isCurrent = !terminal && !isTerm && e.stage === currentStage && i === log.length - 1;
431
+ const failed = e.stage === "failed" || e.stage === "cancelled";
432
+ const marker = failed ? "✕" : isTerm ? "✓" : isCurrent ? "●" : "✓";
433
+ const cls = failed
434
+ ? "vi-timeline-mark-fail"
435
+ : isCurrent
436
+ ? `vi-timeline-mark-current${stalled ? " vi-timeline-mark-stalled" : ""}`
437
+ : "vi-timeline-mark-done";
438
+ return (
439
+ <li key={`${e.stage}-${i}`} className="vi-timeline-item">
440
+ <span className={`vi-timeline-mark ${cls}`} aria-hidden>
441
+ {marker}
442
+ </span>
443
+ <span className="vi-timeline-body">
444
+ <span className="vi-timeline-stage">
445
+ {e.stage.replace(/_/g, " ")}
446
+ {e.count && e.count > 1 ? (
447
+ <span className="vi-timeline-count"> ×{e.count}</span>
448
+ ) : null}
449
+ </span>
450
+ {e.detail ? <span className="vi-timeline-detail">{e.detail}</span> : null}
451
+ </span>
452
+ {e.ts != null ? (
453
+ <span className="vi-timeline-time" title={fmtWhen(e.ts_last ?? e.ts)}>
454
+ {agoText(e.ts_last ?? e.ts, nowMs)}
455
+ </span>
456
+ ) : null}
457
+ </li>
458
+ );
459
+ })}
460
+ </ol>
461
+ </div>
462
+ );
463
+ }
464
+
465
+ // ── shared picker widgets ────────────────────────────────────────────────────
466
+
467
+ // A small library-thumbnail grid; `onPick` receives the chosen ref. Extracted
468
+ // MINIMALLY (operator ask 2026-07-12) out of the Clip surface's original inline
469
+ // `libraryGrid` closure — same markup, same empty-state copy, same keyed-intrinsic-
470
+ // <button> constraint (the repo's JSX typing rejects a `key` on a custom component,
471
+ // so each row stays a plain <button>) — so every studio image-library picker (the
472
+ // Clip surface's start/reference/control slots AND the Movie composer's start-image
473
+ // switcher) shares ONE implementation instead of three copies. `images` is the
474
+ // CALLER's already-kind-filtered list (each surface knows whether it wants image vs
475
+ // video items from its own useMediaLibrary() read).
476
+ export function LibraryImageGrid({
477
+ images,
478
+ onPick,
479
+ }: {
480
+ images: LibraryItem[];
481
+ onPick: (ref: MediaRef) => void;
482
+ }) {
483
+ return (
484
+ <div
485
+ style={{
486
+ display: "flex",
487
+ flexWrap: "wrap",
488
+ gap: "0.4rem",
489
+ marginTop: "0.5rem",
490
+ maxHeight: "12rem",
491
+ overflowY: "auto",
492
+ }}
493
+ >
494
+ {images.length === 0 ? (
495
+ <p className="vi-comfy-hint">
496
+ No images in the session library yet — upload one, or produce frames in the Frames tab.
497
+ </p>
498
+ ) : (
499
+ images.map((it) => (
500
+ <button
501
+ key={it.ref.uri}
502
+ type="button"
503
+ className="vi-btn vi-btn-ghost"
504
+ onClick={() => onPick(it.ref)}
505
+ title={it.label ?? it.ref.asset_id}
506
+ style={{ padding: 2, border: "none" }}
507
+ >
508
+ <img
509
+ src={mediaBytesUrl(it.ref.uri)}
510
+ alt={it.label ?? "library image"}
511
+ style={{
512
+ width: "4.5rem",
513
+ height: "4.5rem",
514
+ objectFit: "cover",
515
+ borderRadius: "0.35rem",
516
+ background: "#000",
517
+ }}
518
+ />
519
+ </button>
520
+ ))
521
+ )}
522
+ </div>
523
+ );
524
+ }
525
+
526
+ // ── the creation-parameters expander (carried from the original station) ─────
527
+
528
+ // One label:value row. Skips null/empty so a compact panel only shows params that
529
+ // actually exist.
530
+ export function DetailRow({ label, value }: { label: string; value: unknown }) {
531
+ if (value == null || value === "") return null;
532
+ return (
533
+ <div style={{ display: "flex", gap: "0.5rem", lineHeight: 1.5 }}>
534
+ <span style={{ opacity: 0.7, minWidth: "7rem" }}>{label}</span>
535
+ <span style={{ fontVariantNumeric: "tabular-nums", wordBreak: "break-word" }}>
536
+ {String(value)}
537
+ </span>
538
+ </div>
539
+ );
540
+ }
541
+
542
+ // Collapsible creation-parameters panel for one clip row. Shows the TRUE rendered
543
+ // params from the manifest (done) and, for a job with NO manifest (failed /
544
+ // cancelled / still running), the error prominently + the REQUESTED spec — clearly
545
+ // labeled as requested (not resolved), because that render never bound a model.
546
+ export function ClipDetailsPanel({
547
+ detail,
548
+ loading,
549
+ error,
550
+ }: {
551
+ detail: ClipDetail | undefined;
552
+ loading: boolean;
553
+ error: string | undefined;
554
+ }) {
555
+ if (loading && !detail) return <p className="vi-comfy-hint">Loading details…</p>;
556
+ if (error)
557
+ return (
558
+ <p className="vi-error" role="alert">
559
+ {error}
560
+ </p>
561
+ );
562
+ if (!detail) return null;
563
+ const m = detail.manifest;
564
+ const s = (detail.spec ?? {}) as Record<string, unknown>;
565
+ const sampler = (m?.sampler ?? {}) as Record<string, unknown>;
566
+ const res = m?.resolution;
567
+ // "job_record" (or simply no manifest) = the params below are REQUESTED, not
568
+ // resolved. Label them so an operator never reads a failed row's asked-for
569
+ // numbers as what actually ran.
570
+ const requested = !m || detail.source === "job_record";
571
+ return (
572
+ <div
573
+ style={{
574
+ marginTop: "0.4rem",
575
+ padding: "0.5rem 0.6rem",
576
+ borderRadius: "0.4rem",
577
+ background: "rgba(127,127,127,0.08)",
578
+ fontSize: "0.85rem",
579
+ }}
580
+ >
581
+ {detail.error && (
582
+ <div className="vi-error" role="alert" style={{ marginBottom: "0.4rem" }}>
583
+ <strong>
584
+ {detail.status === "cancelled" ? "Cancelled" : "Failed"} [
585
+ {detail.error.code ?? "error"}]
586
+ </strong>
587
+ {detail.error.retryable ? " · retryable" : ""}
588
+ {detail.error.message ? <div>{detail.error.message}</div> : null}
589
+ </div>
590
+ )}
591
+ {m ? (
592
+ <>
593
+ <DetailRow label="model" value={m.model_id} />
594
+ <DetailRow label="precision" value={m.precision} />
595
+ <DetailRow
596
+ label="resolution"
597
+ value={res ? `${res.width}×${res.height} @ ${res.fps}fps` : null}
598
+ />
599
+ <DetailRow label="frames" value={m.frames} />
600
+ <DetailRow label="duration" value={m.duration_s != null ? `${m.duration_s}s` : null} />
601
+ <DetailRow label="seed" value={m.seed} />
602
+ <DetailRow
603
+ label="sampler"
604
+ value={
605
+ sampler.steps != null
606
+ ? `${sampler.steps} steps · cfg ${sampler.cfg}${
607
+ sampler.shift != null ? ` · shift ${sampler.shift}` : ""
608
+ }`
609
+ : null
610
+ }
611
+ />
612
+ <DetailRow label="prompt" value={m.prompt} />
613
+ <DetailRow label="negative" value={m.negative_prompt} />
614
+ <DetailRow label="source" value={m.source_video} />
615
+ <DetailRow label="hash" value={m.content_hash} />
616
+ </>
617
+ ) : (
618
+ <>
619
+ {/* No manifest: show the REQUESTED spec so failures are legible, plainly
620
+ labeled as requested (the render bound no model). */}
621
+ <p
622
+ className="vi-comfy-hint"
623
+ role="note"
624
+ style={{ margin: "0 0 0.3rem", fontStyle: "italic", opacity: 0.85 }}
625
+ >
626
+ Requested parameters (this render did not complete — values were never
627
+ resolved; blank steps/cfg = model default).
628
+ </p>
629
+ <DetailRow label="capability" value={s.capability} />
630
+ <DetailRow
631
+ label="resolution"
632
+ value={s.width ? `${s.width}×${s.height} @ ${s.fps}fps` : null}
633
+ />
634
+ <DetailRow label="budget" value={s.vram_budget_gb != null ? `${s.vram_budget_gb} GB` : null} />
635
+ <DetailRow label="seed" value={s.seed} />
636
+ <DetailRow label="steps (req)" value={s.steps} />
637
+ <DetailRow label="cfg (req)" value={s.cfg} />
638
+ <DetailRow label="model (pin)" value={s.model_id} />
639
+ <DetailRow label="prompt" value={s.prompt} />
640
+ <DetailRow label="negative" value={s.negative} />
641
+ <DetailRow label="start image" value={s.start_image} />
642
+ <DetailRow label="source" value={s.source_video} />
643
+ </>
644
+ )}
645
+ {/* Timestamps ride along in every case (from the bus row). */}
646
+ <DetailRow label="created" value={fmtWhen(detail.created)} />
647
+ <DetailRow label="updated" value={fmtWhen(detail.updated)} />
648
+ {requested && !m && (
649
+ <DetailRow label="source" value="job record (requested params)" />
650
+ )}
651
+
652
+ {/* STAGE TIMELINE — the retained per-stage history (what this render did + where
653
+ it stopped). Present for any row the bus recorded a timeline for; a terminal
654
+ failed/cancelled row shows the exact failing stage via the failure block. */}
655
+ {detail.stage_log && detail.stage_log.length > 0 && (
656
+ <div style={{ marginTop: "0.5rem" }}>
657
+ <ProcessTimeline
658
+ stageLog={detail.stage_log}
659
+ failure={
660
+ detail.error && detail.error.code
661
+ ? {
662
+ stage: detail.error.stage ?? null,
663
+ code: detail.error.code ?? null,
664
+ message: detail.error.message ?? null,
665
+ retryable: detail.error.retryable ?? null,
666
+ }
667
+ : null
668
+ }
669
+ currentStage={detail.current_stage}
670
+ lastMovementTs={detail.last_movement_ts}
671
+ terminal
672
+ nowMs={Date.now()}
673
+ />
674
+ </div>
675
+ )}
676
+ </div>
677
+ );
678
+ }
679
+
680
+ // ── the durable clip catalog: list + light poll + Session-Library push ───────
681
+
682
+ export interface StudioClipsState {
683
+ clips: Clip[];
684
+ loading: boolean;
685
+ error: string | null;
686
+ /** Force an immediate (foreground) reload of the clip list. */
687
+ reload: () => void;
688
+ /** Force a quiet (background) reload — used right after an enqueue/cancel. */
689
+ refresh: () => void;
690
+ }
691
+
692
+ /**
693
+ * Load the durable studio clip catalog (GET /video/studio/clips) with a light 6s
694
+ * background poll so a freshly-produced clip appears without a manual refresh, and
695
+ * push every playable clip into the Session Library (idempotent by uri) so the
696
+ * studio's output is visible cross-station and itself "Send to Studio"-able. This
697
+ * is the original station's `load()` extracted verbatim so the shell owns it once.
698
+ */
699
+ export function useStudioClips(): StudioClipsState {
700
+ const [clips, setClips] = useState<Clip[]>([]);
701
+ const [loading, setLoading] = useState(true);
702
+ const [error, setError] = useState<string | null>(null);
703
+
704
+ const mounted = useRef(true);
705
+ const inFlight = useRef(false);
706
+ // Job ids whose per-clip detail we've already fetched for prompt/model back-fill —
707
+ // so the back-fill runs ONCE per clip and never rides the 6s list poll.
708
+ const detailFetched = useRef<Set<string>>(new Set());
709
+
710
+ // Fill the REAL prompt (+ model) into the Session-Library item for a produced clip.
711
+ // The clips-LIST DTO (GET /video/studio/clips) carries no prompt — it lives in the
712
+ // bus row's spec_json and the clip's manifest.json, surfaced only by the per-clip
713
+ // detail route. So each playable clip's detail is fetched ONCE (gated by
714
+ // detailFetched, off the poll) and merged into its library entry, replacing the
715
+ // "(no text prompt)" placeholder the group header showed. Best-effort: an app-error
716
+ // response stays marked (no hammering); a thrown network error un-marks so a later
717
+ // poll can retry.
718
+ const backfillProvenance = useCallback(async (jobId: string, uri: string) => {
719
+ if (detailFetched.current.has(jobId)) return;
720
+ detailFetched.current.add(jobId);
721
+ try {
722
+ const res = await request<unknown>(studioClipDetailUrl(jobId), {
723
+ meta: { specKey: "studio", operation: "studio.clip.detail" },
724
+ });
725
+ if (!mounted.current) return;
726
+ if (!res.ok) return;
727
+ const parsed = clipDetailSchema.safeParse(okValue(res));
728
+ if (!parsed.success) return;
729
+ const d = parsed.data;
730
+ const rawSpecPrompt = d.spec?.prompt;
731
+ const specPrompt =
732
+ typeof rawSpecPrompt === "string" ? rawSpecPrompt : undefined;
733
+ // Prefer the RESOLVED manifest prompt (what actually rendered); fall back to the
734
+ // REQUESTED spec prompt for a clip whose manifest read didn't yield one.
735
+ const prompt = d.manifest?.prompt ?? specPrompt;
736
+ const model = d.manifest?.model_id ?? undefined;
737
+ if (prompt == null && model == null) return;
738
+ updateLibraryProvenance(uri, {
739
+ ...(prompt != null ? { prompt } : {}),
740
+ ...(model != null ? { model } : {}),
741
+ });
742
+ } catch {
743
+ // Transient/network failure — allow a later poll to retry the back-fill.
744
+ detailFetched.current.delete(jobId);
745
+ }
746
+ }, []);
747
+
748
+ const load = useCallback(async (background: boolean) => {
749
+ if (inFlight.current) return;
750
+ inFlight.current = true;
751
+ try {
752
+ // limit=200 (route max, 2026-08-13): the default 50-row window is
753
+ // newest-first with NO status filter, so a burst of failed test/battery
754
+ // jobs used to crowd every DONE clip out of the library. 200 + the
755
+ // archive sweep keeps the render history actually visible.
756
+ const res = await request<unknown>(`${hugpyConfig.studioClipsUrl}?limit=200`, {
757
+ meta: { specKey: "studio", operation: "studio.clips.list" },
758
+ });
759
+ if (!mounted.current) return;
760
+ if (!res.ok) {
761
+ if (background) return; // a failed poll must not wipe a working list
762
+ setLoading(false);
763
+ setError(describeAppError(errorOf(res)));
764
+ return;
765
+ }
766
+ const parsed = clipsResponseSchema.safeParse(okValue(res));
767
+ if (!parsed.success) {
768
+ if (background) return;
769
+ setLoading(false);
770
+ setError("Malformed studio clips response.");
771
+ return;
772
+ }
773
+ const list = parsed.data.clips;
774
+ setClips(list);
775
+ setLoading(false);
776
+ setError(null);
777
+ // Produced studio clips join the Session Library (a video ref per clip,
778
+ // grouped by its job id). addToLibrary de-dups by uri, so this idempotent
779
+ // push on every poll never double-adds. Only playable clips carrying a real
780
+ // uri + asset_id qualify.
781
+ for (const c of list) {
782
+ const o = c.output;
783
+ if (!c.playable || !o || !o.uri || !o.asset_id) continue;
784
+ const ref: MediaRef = {
785
+ asset_id: o.asset_id,
786
+ kind: "video",
787
+ uri: o.uri,
788
+ mime: o.mime ?? "video/mp4",
789
+ width: o.width ?? null,
790
+ height: o.height ?? null,
791
+ duration_s: o.duration_s ?? null,
792
+ };
793
+ addToLibrary(ref, "studio", "studio clip", {
794
+ groupId: c.job_id,
795
+ genKind: "studio_i2v",
796
+ });
797
+ // Back-fill the real prompt/model (the list DTO has neither) so the Session
798
+ // Library group shows the true prompt, not "(no text prompt)". Fire-and-forget,
799
+ // deduped + gated inside backfillProvenance so it costs one detail fetch/clip.
800
+ void backfillProvenance(c.job_id, o.uri);
801
+ }
802
+ } finally {
803
+ inFlight.current = false;
804
+ }
805
+ }, [backfillProvenance]);
806
+
807
+ useEffect(() => {
808
+ mounted.current = true;
809
+ void load(false);
810
+ const iv = window.setInterval(() => void load(true), 6000);
811
+ return () => {
812
+ mounted.current = false;
813
+ window.clearInterval(iv);
814
+ };
815
+ }, [load]);
816
+
817
+ const reload = useCallback(() => void load(false), [load]);
818
+ const refresh = useCallback(() => void load(true), [load]);
819
+
820
+ return { clips, loading, error, reload, refresh };
821
+ }