@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,512 @@
1
+ // COMPATIBILITY DERIVATIONS over the measured render-preset table (useRenderPresets).
2
+ //
3
+ // THE RULE THIS MODULE IMPLEMENTS (operator ruling 2026-07-29): the studio must be
4
+ // COMPATIBILITY-AWARE of all user inputs — every input the user makes filters the
5
+ // options subsequently offered — and a preset must be a WORKFLOW guaranteed to render,
6
+ // not a canned scene, absorbing the session's inputs into their proper slots.
7
+ //
8
+ // Everything here is a PURE function of the payload + the current form state. No React,
9
+ // no fetch, no component coupling — the Clip surface and the Movie composer both derive
10
+ // their pickers from these, so the two surfaces can never disagree about what the fleet
11
+ // can do.
12
+ //
13
+ // ── THE COURTESY-LAYER BOUNDARY, STATED ONCE ────────────────────────────────────────
14
+ // These filters decide what to OFFER. They are NOT a gate and must never be treated as
15
+ // one: POST /video/studio/i2v's capability gate (video_routes.py) and the router's own
16
+ // refusal stay authoritative, and every consumer here falls back to its UNFILTERED list
17
+ // when the discovery fetch has not landed (`loaded === false`). A filter that empties a
18
+ // picker because a GET was slow is a worse failure than the one it prevents.
19
+ //
20
+ // ── WHY SOME RULES ARE NOT IN THE PRESET TABLE ──────────────────────────────────────
21
+ // A `RenderPreset.inputs[]` names what the caller MUST supply to reach that binding. The
22
+ // route additionally accepts several OPTIONAL inputs the table has no field for, and
23
+ // refuses some combinations the table cannot express. Both are transcribed below with
24
+ // the route line that owns them, because a UI that only read `inputs[]` would delete
25
+ // working features (v2v scene-continuity would lose its identity references) and offer
26
+ // broken ones (a control still beside a source clip, which the route refuses by name).
27
+ import type { RenderPreset, UnavailableCapability } from "./useRenderPresets";
28
+
29
+ /** The capability strings the studio's CLIP surface can author end to end. Every one is
30
+ * servable on the fleet today; `motion` is the newest (video_routes widened the
31
+ * control_image gate from id_lock-only on 2026-07-27, which is what turned it from a
32
+ * phantom into a route). Ordered as the picker shows them. */
33
+ export const CLIP_CAPABILITIES = ["t2v", "i2v", "v2v", "id_lock", "motion"] as const;
34
+ export type ClipCapability = (typeof CLIP_CAPABILITIES)[number];
35
+
36
+ /** The MOVIE composer's capability — the multi-segment orchestration stage. */
37
+ export const MOVIE_CAPABILITY = "assemble";
38
+
39
+ export const CAPABILITY_LABELS: Record<string, string> = {
40
+ t2v: "Text → video (t2v)",
41
+ i2v: "Image → video (i2v)",
42
+ v2v: "Video → video (v2v restyle)",
43
+ id_lock: "Identity lock (reference → video)",
44
+ motion: "Pose / structure control (motion)",
45
+ assemble: "Multi-segment movie (assemble)",
46
+ upres: "Upscale a clip (upres)",
47
+ interp: "Interpolate frames (interp)",
48
+ keyframe: "Keyframe (start + end frame)",
49
+ inpaint: "Inpaint a region",
50
+ outpaint: "Outpaint the canvas",
51
+ retake: "Re-take a frame range",
52
+ audio: "Audio track",
53
+ lipsync: "Lip sync",
54
+ restore: "Restore / face fix",
55
+ stream: "Streaming generation",
56
+ };
57
+
58
+ export function capabilityLabel(cap: string): string {
59
+ return CAPABILITY_LABELS[cap] ?? cap;
60
+ }
61
+
62
+ // ── the four attachment slots the Clip surface owns ─────────────────────────────────
63
+ // Named for the ROUTE FIELD each one becomes, so the matrix below can be read against
64
+ // video_routes.py directly.
65
+ export interface Attachments {
66
+ /** i2v `start_image` — a single first frame. */
67
+ startImage: boolean;
68
+ /** `source_video` — the clip to extend (i2v) or repaint (v2v). */
69
+ sourceVideo: boolean;
70
+ /** `reference_images` — 1–4 ordered stills (identity). */
71
+ referenceImages: boolean;
72
+ /** `control_image` (+ `control_kind`) — one pose/depth/sketch still. */
73
+ controlImage: boolean;
74
+ }
75
+
76
+ export const NO_ATTACHMENTS: Attachments = {
77
+ startImage: false,
78
+ sourceVideo: false,
79
+ referenceImages: false,
80
+ controlImage: false,
81
+ };
82
+
83
+ // WHICH CAPABILITIES ACCEPT WHICH INPUT — transcribed from the route's own gates so the
84
+ // UI narrows to exactly what the server admits, and never one option wider.
85
+ //
86
+ // start_image i2v only. t2v DELIBERATELY drops it (video_routes: "a t2v
87
+ // clip is a pure function of prompt + seed +
88
+ // geometry"); the VACE capabilities have no
89
+ // start-frame channel.
90
+ // source_video i2v | v2v. t2v drops it; refused alongside control_image.
91
+ // reference_images id_lock | v2v. _REF_CAPS — "rejected for any other capability so
92
+ // a non-VACE runner can never silently ignore them".
93
+ // REQUIRED on id_lock, OPTIONAL on v2v (the
94
+ // scene-continuity identity carry).
95
+ // control_image id_lock | motion. _CONTROL_CAPS — OPTIONAL composition blocking on
96
+ // id_lock, REQUIRED and definitional on motion.
97
+ const ACCEPTS: Record<keyof Attachments, readonly string[]> = {
98
+ startImage: ["i2v"],
99
+ sourceVideo: ["i2v", "v2v"],
100
+ referenceImages: ["id_lock", "v2v"],
101
+ controlImage: ["id_lock", "motion"],
102
+ };
103
+
104
+ /** Human copy for "why this capability is greyed out given what you have attached". */
105
+ const ATTACHMENT_NOUNS: Record<keyof Attachments, string> = {
106
+ startImage: "a start image",
107
+ sourceVideo: "a source clip",
108
+ referenceImages: "reference images",
109
+ controlImage: "a control still",
110
+ };
111
+
112
+ // ── the table, sliced ───────────────────────────────────────────────────────────────
113
+
114
+ /** Every capability at least one ratified preset covers — what the fleet can do today. */
115
+ export function servableCapabilities(presets: RenderPreset[]): string[] {
116
+ const out = new Set<string>();
117
+ for (const p of presets) {
118
+ out.add(p.capability);
119
+ for (const c of p.capabilities ?? []) out.add(c);
120
+ }
121
+ return [...out].sort();
122
+ }
123
+
124
+ /** Every ratified preset serving `cap`, in the registry's own (meaningful) order. */
125
+ export function presetsForCapability(presets: RenderPreset[], cap: string): RenderPreset[] {
126
+ return presets.filter((p) => p.capability === cap || (p.capabilities ?? []).includes(cap));
127
+ }
128
+
129
+ export function isServable(presets: RenderPreset[], cap: string): boolean {
130
+ return presetsForCapability(presets, cap).length > 0;
131
+ }
132
+
133
+ /**
134
+ * PROVEN = this capability has a ratified preset that has produced pixels on this fleet.
135
+ * `false` is not a defect and must not be hidden — it is the row telling the truth about
136
+ * itself (clip-i2v-480p is 0 of 47 landed Wan clips; clip-motion-480p is a real branch
137
+ * the route only unlocked on 2026-07-27). The UI badges it rather than suppressing it.
138
+ * `null` = no preset at all, i.e. unservable — a different answer from "unproven".
139
+ */
140
+ export function provenForCapability(presets: RenderPreset[], cap: string): boolean | null {
141
+ const rows = presetsForCapability(presets, cap);
142
+ if (rows.length === 0) return null;
143
+ return rows.some((p) => p.proven === true);
144
+ }
145
+
146
+ /**
147
+ * The model ids a pin may name for `cap` without leaving the ratified set. For the movie
148
+ * capability the segment bindings count too — `composes` names the preset ids whose
149
+ * models a multi-segment render actually binds, so pinning one of those is legitimate
150
+ * even though the movie row itself declares a single `model`.
151
+ */
152
+ export function modelsForCapability(presets: RenderPreset[], cap: string): string[] {
153
+ const rows = presetsForCapability(presets, cap);
154
+ const byId = new Map(presets.map((p) => [p.id, p]));
155
+ const out = new Set<string>();
156
+ for (const p of rows) {
157
+ out.add(p.model);
158
+ for (const composedId of p.composes ?? []) {
159
+ const c = byId.get(composedId);
160
+ if (c) out.add(c.model);
161
+ }
162
+ }
163
+ return [...out].sort();
164
+ }
165
+
166
+ export interface Geometry {
167
+ width: number;
168
+ height: number;
169
+ fps: number;
170
+ /** The preset the envelope came from — named in the UI so a lock is never anonymous. */
171
+ presetId: string;
172
+ presetTitle: string;
173
+ }
174
+
175
+ /**
176
+ * The geometry envelope a capability renders at, or null when its presets declare none
177
+ * (the two ffmpeg enhance rows inherit the source clip's geometry — `width: null` is the
178
+ * table's honest encoding of that, and a 0 would be a lie with a shape).
179
+ *
180
+ * Every video row on this fleet is 832x480@16 today, so "the first row that declares
181
+ * one" is unambiguous; if a second envelope is ever ratified for the same capability
182
+ * this returns the first and `withinGeometry` widens to accept either.
183
+ */
184
+ export function geometryForCapability(presets: RenderPreset[], cap: string): Geometry | null {
185
+ for (const p of presetsForCapability(presets, cap)) {
186
+ if (p.width != null && p.height != null && p.fps != null) {
187
+ return {
188
+ width: p.width,
189
+ height: p.height,
190
+ fps: p.fps,
191
+ presetId: p.id,
192
+ presetTitle: p.title,
193
+ };
194
+ }
195
+ }
196
+ return null;
197
+ }
198
+
199
+ /** Is w×h inside SOME ratified envelope for this capability? A capability with no
200
+ * declared geometry accepts anything (the enhance rows take the source's). */
201
+ export function withinGeometry(
202
+ presets: RenderPreset[],
203
+ cap: string,
204
+ width: number,
205
+ height: number,
206
+ ): boolean {
207
+ const rows = presetsForCapability(presets, cap).filter(
208
+ (p) => p.width != null && p.height != null,
209
+ );
210
+ if (rows.length === 0) return true;
211
+ if (width <= 0 || height <= 0) return false;
212
+ return rows.some((p) => width <= (p.width as number) && height <= (p.height as number));
213
+ }
214
+
215
+ /**
216
+ * The SMALLEST `vram_budget_gb` that can bind a real model for this capability.
217
+ *
218
+ * This is the number the studio console used to mirror by hand as
219
+ * `STUDIO_REAL_FLOOR_GB = 6` — a constant that had drifted BELOW every real row in the
220
+ * table (the cheapest is 8.2), so the honesty banner it fed under-warned. It is derived
221
+ * here from `vram_envelope_gb`, which the backend reads off the same
222
+ * `ModelConfig.vram` map the router compares a budget against (`cfg.vram.fits(budget)`
223
+ * is `gb <= budget`).
224
+ *
225
+ * Returns null when the payload has not landed or carries no envelope — callers then
226
+ * keep their fallback constant rather than inventing a floor.
227
+ */
228
+ export function minBudgetGbForCapability(
229
+ presets: RenderPreset[],
230
+ cap: string,
231
+ ): number | null {
232
+ const vals = presetsForCapability(presets, cap)
233
+ .map((p) => p.vram_envelope_gb)
234
+ .filter((v): v is number => typeof v === "number" && v > 0);
235
+ return vals.length ? Math.min(...vals) : null;
236
+ }
237
+
238
+ /** The budget to PREFILL for a capability: its cheapest binding, rounded up to a whole
239
+ * GB so the field reads like something a person typed and can never land a hair under
240
+ * the envelope through float display. */
241
+ export function suggestedBudgetGb(presets: RenderPreset[], cap: string): number | null {
242
+ const min = minBudgetGbForCapability(presets, cap);
243
+ return min == null ? null : Math.ceil(min);
244
+ }
245
+
246
+ // ── input-driven filtering (the "every input filters what comes next" half) ─────────
247
+
248
+ export interface CapabilityOffer {
249
+ capability: string;
250
+ label: string;
251
+ /** Servable on the fleet AND compatible with what is currently attached. */
252
+ offered: boolean;
253
+ /** Covered by a ratified preset at all. */
254
+ servable: boolean;
255
+ /** Has produced pixels on this fleet (null when unservable). */
256
+ proven: boolean | null;
257
+ /** Why it is not offered — the backend's refusal reason for an unservable
258
+ * capability, or this module's attachment explanation for a servable-but-
259
+ * incompatible one. Empty when offered. */
260
+ reason: string;
261
+ }
262
+
263
+ /**
264
+ * THE MATRIX, applied: which capabilities remain reachable given what the user has
265
+ * already attached. A capability is offered iff it is servable AND every attachment the
266
+ * user is holding is an input that capability accepts.
267
+ *
268
+ * This is the "filters the options subsequently offered" direction. It is deliberately
269
+ * ADVISORY at the picker level: an incompatible capability is shown DISABLED with the
270
+ * reason rather than vanishing, because a user who attached a start image and then
271
+ * wonders where restyle went is worse off than one who can see it greyed out saying
272
+ * "restyle does not take a start image".
273
+ *
274
+ * Attachments are never auto-dropped to make a capability fit — clobbering the user's
275
+ * inputs is the defect class this whole slice deletes.
276
+ */
277
+ export function capabilityOffers(
278
+ presets: RenderPreset[],
279
+ unavailable: UnavailableCapability[],
280
+ attached: Attachments,
281
+ candidates: readonly string[] = CLIP_CAPABILITIES,
282
+ ): CapabilityOffer[] {
283
+ const refusals = new Map(unavailable.map((u) => [u.capability, u]));
284
+ return candidates.map((cap) => {
285
+ const servable = isServable(presets, cap);
286
+ if (!servable) {
287
+ const u = refusals.get(cap);
288
+ return {
289
+ capability: cap,
290
+ label: capabilityLabel(cap),
291
+ offered: false,
292
+ servable: false,
293
+ proven: null,
294
+ // The backend's own wording, verbatim — re-phrasing it here would give the
295
+ // console and the log two different explanations of one fact.
296
+ reason: u?.reason || u?.refusal || "no ratified preset covers it on this fleet",
297
+ };
298
+ }
299
+ const blockers: string[] = [];
300
+ (Object.keys(ACCEPTS) as Array<keyof Attachments>).forEach((slot) => {
301
+ if (attached[slot] && !ACCEPTS[slot].includes(cap)) {
302
+ blockers.push(ATTACHMENT_NOUNS[slot]);
303
+ }
304
+ });
305
+ // The route refuses this pair by name (the VACE control channel takes ONE input and
306
+ // source_video wins, so the control still would be silently dropped). Surface it as
307
+ // an offer-level blocker rather than letting the user build it and get a 400.
308
+ if (attached.controlImage && attached.sourceVideo && cap === "motion") {
309
+ blockers.push("both a control still and a source clip (motion takes one control)");
310
+ }
311
+ return {
312
+ capability: cap,
313
+ label: capabilityLabel(cap),
314
+ offered: blockers.length === 0,
315
+ servable: true,
316
+ proven: provenForCapability(presets, cap),
317
+ reason: blockers.length
318
+ ? `${capabilityLabel(cap)} does not take ${blockers.join(" or ")} — remove it to switch here.`
319
+ : "",
320
+ };
321
+ });
322
+ }
323
+
324
+ /** The inverse direction, for the AUTO-PICK on an external stage: the best capability
325
+ * for what is attached right now, or null when the current one is already fine.
326
+ * Ordered by specificity — the most constrained attachment wins, because it is the one
327
+ * the user went to the most trouble to provide. */
328
+ export function capabilityForAttachments(
329
+ presets: RenderPreset[],
330
+ attached: Attachments,
331
+ current: string,
332
+ ): string | null {
333
+ const offers = capabilityOffers(presets, [], attached);
334
+ const ok = (cap: string) => offers.find((o) => o.capability === cap)?.offered === true;
335
+ if (ok(current)) return null; // the user's choice already fits — never override it
336
+ if (attached.controlImage && ok("motion")) return "motion";
337
+ if (attached.referenceImages && ok("id_lock")) return "id_lock";
338
+ if (attached.sourceVideo && ok("v2v")) return "v2v";
339
+ if (attached.startImage && ok("i2v")) return "i2v";
340
+ return offers.find((o) => o.offered)?.capability ?? null;
341
+ }
342
+
343
+ /** Which attachment slots the given capability accepts — drives whether an input widget
344
+ * is even OFFERED (the surface's existing accepts* predicates, now data-derived). */
345
+ export function acceptedSlots(cap: string): Attachments {
346
+ return {
347
+ startImage: ACCEPTS.startImage.includes(cap),
348
+ sourceVideo: ACCEPTS.sourceVideo.includes(cap),
349
+ referenceImages: ACCEPTS.referenceImages.includes(cap),
350
+ controlImage: ACCEPTS.controlImage.includes(cap),
351
+ };
352
+ }
353
+
354
+ /** Which inputs the ratified preset for this capability REQUIRES (its `inputs[]`, minus
355
+ * `prompt` which every row lists and no picker gates on). Used for the required-input
356
+ * callouts so "what this generator needs" comes from the measured table. */
357
+ export function requiredInputs(presets: RenderPreset[], cap: string): string[] {
358
+ const rows = presetsForCapability(presets, cap);
359
+ if (rows.length === 0) return [];
360
+ return (rows[0].inputs ?? []).filter((i) => i !== "prompt" && i !== "goals");
361
+ }
362
+
363
+ // ── merge-not-overwrite: the preset-apply philosophy, in one place ──────────────────
364
+
365
+ /**
366
+ * A preset supplies the WORKFLOW (capability, geometry, cadence, budget, model). The
367
+ * user's SESSION INPUTS (prompt, negative, identity, attachments, a seed they chose) are
368
+ * theirs and survive every apply.
369
+ *
370
+ * `fillIfEmpty` is the whole rule for text: a preset's example prompt is a scaffold for
371
+ * an empty box, never a replacement for something the user wrote. Before 2026-07-29 the
372
+ * studio's `onPickPreset` assigned `p.prompt ?? ""` — so picking a preset with no prompt
373
+ * of its own BLANKED whatever the user had typed. That is the defect class this deletes.
374
+ */
375
+ export function fillIfEmpty(current: string, offered: string | null | undefined): string {
376
+ if (current.trim() !== "") return current;
377
+ return offered ?? "";
378
+ }
379
+
380
+ /** Same rule for a numeric knob held as a string ("" = unset). A preset fills a blank
381
+ * field; it never overwrites a number the user typed. */
382
+ export function fillNumberIfEmpty(
383
+ current: string,
384
+ offered: number | null | undefined,
385
+ ): string {
386
+ if (current.trim() !== "") return current;
387
+ return offered == null ? current : String(offered);
388
+ }
389
+
390
+ // ── generalized MOVIE workflow presets ──────────────────────────────────────────────
391
+
392
+ /** The joint modes the movie preset ratifies, with what each one MEANS and what it
393
+ * costs. Copy is derived from the movie row's own evidence, not invented here. */
394
+ const JOINT_WORKFLOWS: Record<
395
+ string,
396
+ { name: string; hint: string; segmentCapability: string }
397
+ > = {
398
+ cut: {
399
+ name: "Multi-shot movie — hard cuts",
400
+ hint:
401
+ "Each shot is a fresh render joined by a scene cut; no frames carry across. Every "
402
+ + "segment stays on the proven 1.3B binding, so this is the cheapest and most "
403
+ + "reliable way to chain shots. Attach an identity below and every shot locks to it.",
404
+ segmentCapability: "t2v",
405
+ },
406
+ vace_extend: {
407
+ name: "Continuous motion — VACE extend",
408
+ hint:
409
+ "Each shot continues the previous one's motion from a branch frame. Same proven "
410
+ + "1.3B weights as a cut, reached through the restyle wiring — use it when the "
411
+ + "camera or the action should carry across the joint.",
412
+ segmentCapability: "v2v",
413
+ },
414
+ still: {
415
+ name: "Still-carry chain",
416
+ hint:
417
+ "Each shot starts from the previous one's last frame as an image. Honest cost: this "
418
+ + "joint binds the 14B image-to-video row per segment, which has never completed a "
419
+ + "clip on this fleet and re-reads ~90 GB of weights per segment. Prefer cuts or "
420
+ + "VACE extend unless you specifically need a still hand-off.",
421
+ segmentCapability: "i2v",
422
+ },
423
+ };
424
+
425
+ export interface MovieWorkflowPreset {
426
+ id: string;
427
+ name: string;
428
+ hint: string;
429
+ /** Applied to segments 1..N (segment 0 is the root and has no joint). */
430
+ joint: string;
431
+ /** The geometry the ratified movie preset renders at. */
432
+ geometry: Geometry | null;
433
+ /** The minimum budget for the binding THIS joint actually uses. */
434
+ minBudgetGb: number | null;
435
+ /** Has the segment binding this joint uses produced pixels on this fleet? */
436
+ proven: boolean;
437
+ /** The fewest segments this workflow means anything with. NEVER a replacement for
438
+ * the user's existing rows — the apply path grows to this and no further. */
439
+ minSegments: number;
440
+ }
441
+
442
+ /**
443
+ * The movie composer's preset list, DERIVED — one workflow per joint mode the ratified
444
+ * `movie-480p` row declares, sized against the binding that joint actually uses.
445
+ *
446
+ * This replaces a hardcoded array of scene prose ("Beach & volleyball — character day
447
+ * out", with a baked-in subject, a baked-in identity slug and two baked-in scene
448
+ * prompts). A preset here names a WORKFLOW SHAPE and nothing about the content: the
449
+ * user's own goal prompts, identity and start image are the content, and the apply path
450
+ * slots the workflow around them without touching any of it.
451
+ *
452
+ * Returns [] when the payload has not landed — the caller shows its loading state rather
453
+ * than a picker built on guesses.
454
+ */
455
+ export function movieWorkflowPresets(presets: RenderPreset[]): MovieWorkflowPreset[] {
456
+ const movieRows = presetsForCapability(presets, MOVIE_CAPABILITY);
457
+ if (movieRows.length === 0) return [];
458
+ const byId = new Map(presets.map((p) => [p.id, p]));
459
+ const out: MovieWorkflowPreset[] = [];
460
+ for (const row of movieRows) {
461
+ const geometry = geometryForCapability(presets, MOVIE_CAPABILITY);
462
+ for (const joint of row.joints ?? []) {
463
+ const wf = JOINT_WORKFLOWS[joint];
464
+ if (!wf) continue; // a joint the console does not model yet — never invent copy
465
+ // The segment binding this joint really uses, found among the row's `composes`.
466
+ const segRow = (row.composes ?? [])
467
+ .map((id) => byId.get(id))
468
+ .find((p): p is RenderPreset => !!p && p.capability === wf.segmentCapability);
469
+ out.push({
470
+ id: `${row.id}:${joint}`,
471
+ name: wf.name,
472
+ hint: wf.hint,
473
+ joint,
474
+ geometry,
475
+ minBudgetGb: segRow?.vram_envelope_gb ?? row.vram_envelope_gb ?? null,
476
+ // A composite can never be more proven than the weakest thing it composes —
477
+ // the same rule the backend's test_presets.py enforces on the table itself.
478
+ proven: (segRow?.proven ?? row.proven) === true,
479
+ minSegments: 2,
480
+ });
481
+ }
482
+ }
483
+ return out;
484
+ }
485
+
486
+ /**
487
+ * The budget a movie actually needs, given the joint AND whether an identity is attached.
488
+ *
489
+ * The identity case is the one a static number gets wrong: the moment a movie carries
490
+ * references, EVERY segment renders id_lock (studio_movie.py) and binds the VACE row, so
491
+ * the floor is that row's envelope regardless of which joint was chosen.
492
+ */
493
+ export function movieBudgetGb(
494
+ presets: RenderPreset[],
495
+ opts: { joint: string; identity: boolean },
496
+ ): number | null {
497
+ if (opts.identity) return suggestedBudgetGb(presets, "id_lock");
498
+ const wf = JOINT_WORKFLOWS[opts.joint];
499
+ if (!wf) return suggestedBudgetGb(presets, MOVIE_CAPABILITY);
500
+ const seg = suggestedBudgetGb(presets, wf.segmentCapability);
501
+ const base = suggestedBudgetGb(presets, MOVIE_CAPABILITY);
502
+ if (seg == null) return base;
503
+ if (base == null) return seg;
504
+ return Math.max(seg, base);
505
+ }
506
+
507
+ /** The Wan frame cadence check — `num_frames` must be 4k+1 or the runner snaps down.
508
+ * Exposed so a surface can say so BEFORE six minutes of denoise. */
509
+ export function isWanCadence(frames: number, cadence: number | null): boolean {
510
+ if (!cadence || cadence < 1) return true;
511
+ return frames >= 1 && (frames - 1) % cadence === 0;
512
+ }