@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,2688 @@
1
+ // The Studio GENERATE SURFACE — ONE source of truth for the studio generate CONTROLS
2
+ // (preset tiers + morphing param panel + hero inputs + Generate). It is the TOP of
3
+ // the single Studio plane (StudioPlane), which renders it above the inline clip
4
+ // viewer + list; that one plane is mounted in both places the studio appears — the
5
+ // standalone Studio station (StudioClipsStation) and the Generate station's "studio"
6
+ // mode (StudioGenerateMode). It is a pure CONTROLLED component — the source clip + its
7
+ // staged mode + the clip list arrive as props, so each mount owns that wiring (and the
8
+ // studioBridge registration) while this form stays identical in both. (The file keeps
9
+ // its historical name; the exported component is `StudioGenerateSurface`.)
10
+ //
11
+ // Tier-first: pick a curated preset (grouped REAL tiers vs SYNTHETIC previews), and
12
+ // the form MORPHS into that generator's PREFORMED SHAPE instead of showing one
13
+ // uniform panel:
14
+ //
15
+ // • t2v shape — image inputs are hidden entirely (text-only note);
16
+ // • i2v "needs a start image" shape — the START IMAGE input becomes the dominant
17
+ // element near the top, with the source-clip picker offered as the "or extend a
18
+ // clip" alternative;
19
+ // • v2v (restyle) shape — the SOURCE CLIP picker gets that hero prominence;
20
+ // • id_lock (identity lock) shape — the REFERENCE-IMAGE slot list (1–4, ordered) is
21
+ // the dominant accent-bordered element, with an OPTIONAL composition-control input
22
+ // (a static pose/depth/sketch anchor) subdued below;
23
+ // • fully custom (no preset) — the current full panel, inputs inline + optional.
24
+ //
25
+ // Required-vs-optional visual language: a required-and-empty input gets the accent
26
+ // attention treatment and the Generate gate names it; optional inputs stay subdued.
27
+ //
28
+ // Every affordance reuses an EXISTING pattern: presets from useStudioPresets; the
29
+ // image UPLOAD path is the Generate station's upload→ingest→guard-kind flow
30
+ // (POST /uploads → POST /video/ingest → MediaRef) and the hero drop surface is the
31
+ // shared DropReceptacle; the LIBRARY pick reads useMediaLibrary; the source-clip pick
32
+ // reads the plane's clip list; "Send to Studio" / "use as source" staging arrives via
33
+ // studioBridge as the `source` prop; the enqueue body uses the exact route field names,
34
+ // dropped by JSON.stringify when unset.
35
+ //
36
+ // FIELDS ARE DISTINCT: `start_image` (i2v, exactly 1 — the first frame) and
37
+ // `reference_images` (id_lock, 1–4 ORDERED — identity to lock; the hash keys on order)
38
+ // are separate route fields with separate slot lists; `control_image`+`control_kind`
39
+ // are id_lock-only and both-or-neither.
40
+ import { useCallback, useEffect, useRef, useState } from "react";
41
+ // Sub-tab input memory — see the block of useSessionState calls in
42
+ // StudioGenerateSurface and the rationale in video/sessionForm.ts.
43
+ import {
44
+ nextSessionSeq,
45
+ useSessionRef,
46
+ useSessionState,
47
+ } from "../../video/sessionForm";
48
+ import { createPortal } from "react-dom";
49
+ import { request, okValue, errorOf, describeAppError } from "../../transport/client";
50
+ import { hugpyConfig, mediaBytesUrl } from "../../config";
51
+ import { addToLibrary, useMediaLibrary } from "../../video/mediaLibrary";
52
+ import { enqueueResultSchema, mediaRefSchema, uploadResultSchema } from "../../video/contract";
53
+ import type { MediaRef } from "../../video/contract";
54
+ import { DropReceptacle } from "../../video/DropReceptacle";
55
+ import type { GoalComposerAttach, GoalComposerAttached } from "../GoalComposer";
56
+ import { usePromptAssist } from "../../video/usePromptAssist";
57
+ import { PromptAssistButtons } from "../../video/PromptAssistButtons";
58
+ import { getSessionId } from "../../session";
59
+ import { isCanned } from "../../demo/mode";
60
+ import { DEMO_STUDIO_PROMPT } from "../../demo/seed";
61
+ import type { StudioPreset } from "../../video/useStudioPresets";
62
+ import { useProjects } from "../../video/useProjects";
63
+ import type { StudioMode } from "../../video/studioBridge";
64
+ import {
65
+ VACE_W,
66
+ VACE_H,
67
+ VACE_FPS,
68
+ VACE_BUDGET_GB,
69
+ STUDIO_MODEL_IDS,
70
+ STUDIO_REAL_FLOOR_GB,
71
+ STUDIO_SAFE_FILL_GB,
72
+ bindsSyntheticTier,
73
+ isSyntheticPreset,
74
+ shortId,
75
+ clipToMediaRef,
76
+ LibraryImageGrid,
77
+ type Clip,
78
+ } from "./studioShared";
79
+ // B2: the Movie authoring surface the mode switch renders. Self-contained (owns its
80
+ // own goal state, enqueue + poll, and playback) — see StudioMovieComposer.tsx.
81
+ import { StudioMovieComposer } from "./StudioMovieComposer";
82
+ // IDENTITY PROFILES (stage a): the shared "Identity" control block (From profile /
83
+ // Save as identity profile) — one additive row in the reference area below.
84
+ import { IdentityProfileControls, type SelectedProfile } from "./IdentityProfileControls";
85
+ // The SHARED prompt-card system (k88/k89): the Clip surface's single prompt card
86
+ // rides the same shell + [Prompt][Negative] tabs the Scene/Movie/Cinema lists use,
87
+ // plus the standard-negative composition helper and the sidebar About expander.
88
+ import { AboutExpander, PromptCard, PromptFieldTabs, composeNegative } from "./promptCard";
89
+ // ── COMPATIBILITY AWARENESS (operator ruling 2026-07-29) ───────────────────────────
90
+ // The fleet's MEASURED render table (GET /video/render/presets) + the pure derivations
91
+ // over it. Everything this surface offers — capabilities, model pins, geometry, the
92
+ // budget floor, the proven badges — is now filtered by what the fleet can actually
93
+ // render and by what the user has already attached, instead of by hand-maintained
94
+ // mirrors of backend facts. Courtesy layer only: the route's capability gate stays
95
+ // authoritative, and every consumer below falls back to its prior list while
96
+ // `renderLoaded` is false, so a slow discovery GET can never empty a picker.
97
+ import { useRenderPresets } from "../../video/useRenderPresets";
98
+ import {
99
+ CLIP_CAPABILITIES,
100
+ MOVIE_CAPABILITY,
101
+ capabilityOffers,
102
+ capabilityForAttachments,
103
+ capabilityLabel,
104
+ acceptedSlots,
105
+ geometryForCapability,
106
+ withinGeometry,
107
+ modelsForCapability,
108
+ suggestedBudgetGb,
109
+ minBudgetGbForCapability,
110
+ provenForCapability,
111
+ fillIfEmpty,
112
+ type Attachments,
113
+ } from "../../video/renderCompat";
114
+
115
+ // `motion` joined the union on 2026-07-29: it is a SERVABLE capability (video_routes
116
+ // widened the control_image gate from id_lock-only on 2026-07-27, making the VACE
117
+ // structural-control branch reachable) and this surface already owns the only input it
118
+ // needs — the pose/depth/sketch control still. It was simply never offered.
119
+ type Capability = "t2v" | "i2v" | "v2v" | "id_lock" | "motion";
120
+ type ControlKind = "" | "pose" | "depth" | "sketch";
121
+ type UploadPhase = "idle" | "uploading" | "ingesting";
122
+ /** Where an ingested image slots (a single upload/library flow feeds all three). */
123
+ type ImageTarget = "start" | "reference" | "control";
124
+
125
+ // i2v `start_image` is a SINGLE first frame (spine takes one). id_lock `reference_images`
126
+ // is a DIFFERENT field: 1–4 ordered stills the render locks identity to (order is
127
+ // semantic — the content hash keys on it), max 4 enforced by the route.
128
+ const MAX_START_IMAGES = 1;
129
+ const MAX_REFERENCE_IMAGES = 4;
130
+
131
+ // Stable per-slot key for the reorderable reference list (order matters, and the same
132
+ // still may legitimately appear twice — so key by slot identity, not by uri).
133
+ // The counter is SESSION-persisted now that the reference list itself survives a
134
+ // reload: a module `let` would restart at 0 and mint `ref_1` a second time,
135
+ // colliding with a restored slot.
136
+ function refKey(): string {
137
+ return `ref_${nextSessionSeq("studio.clip.refKeySeq")}`;
138
+ }
139
+
140
+ export interface StudioGenerateSurfaceProps {
141
+ presets: StudioPreset[];
142
+ presetsLoading: boolean;
143
+ /** The host's durable clip list — the source-clip picker chooses from it. */
144
+ clips: Clip[];
145
+ /** Host-owned staged/picked source clip (Send to Studio / use-as-source). */
146
+ source: MediaRef | null;
147
+ setSource: (m: MediaRef | null) => void;
148
+ /** Consume mode from the last external stage (drives capability on handoff). */
149
+ sourceMode: StudioMode;
150
+ /** Bumps on each external stage so the capability handoff runs exactly once. */
151
+ stageSeq: number;
152
+ /** Quiet-refresh the clip list after an enqueue — the new job then appears in the
153
+ * inline clip list below (same plane), no navigation. */
154
+ onEnqueued: () => void;
155
+ /** Round 8: the DOM host for the left-sidebar "Settings" tab. When present, the knob
156
+ * grid renders THERE (via a studio-local portal) instead of the center — folding the
157
+ * former inline knob column into the sidebar Settings tab (movie-shape canonical). Null
158
+ * until the sidebar mounts its Settings panel; the knob state lives here regardless. */
159
+ settingsHost: HTMLElement | null;
160
+ /** When PROVIDED, the top-level Generate tab (Clip / Cinema) owns the clip-vs-movie
161
+ * choice, so this surface is LOCKED to that value and the internal Clip|Movie switcher
162
+ * is hidden. When ABSENT (the standalone /studio-clips station mount), the surface keeps
163
+ * its own internal switcher + useState. "movie" is the Cinema surface (label only — the
164
+ * wire/tester value stays "movie"). */
165
+ lockedSurfaceMode?: "clip" | "movie";
166
+ }
167
+
168
+ // Labels now come from renderCompat (ONE table both studio surfaces read) so a
169
+ // capability can never be named two things in two pickers. Kept as a local alias so the
170
+ // existing call sites read unchanged.
171
+ const CAP_LABELS: Record<Capability, string> = {
172
+ t2v: capabilityLabel("t2v"),
173
+ i2v: capabilityLabel("i2v"),
174
+ v2v: capabilityLabel("v2v"),
175
+ id_lock: capabilityLabel("id_lock"),
176
+ motion: capabilityLabel("motion"),
177
+ };
178
+
179
+ // A compact source-clip poster that plays inline ON DEMAND — the studio-local twin of
180
+ // the Session Library's <RefThumb>, factored so the SELECTED-source summary and every
181
+ // picker row share ONE poster/preview renderer (no duplicated <video> markup). The
182
+ // still is a muted, metadata-only <video> seeked to an early frame; /video/media is
183
+ // Range-aware so only the seeked byte range loads until the viewer presses play.
184
+ // Clicking the poster (or a row's Preview button) flips `playing`, swapping in a
185
+ // <video controls autoPlay> so the clip is previewable IN PLACE. The poster lives in a
186
+ // <button> (it is a non-interactive muted still, so nesting it is valid); the playing
187
+ // state is a bare <div> wrapper (a <video controls> must NEVER sit inside a <button>).
188
+ // EXPORTED (operator ask 2026-07-12): StudioViewer's "▤ Play from library" panel reuses
189
+ // this verbatim for its own poster/id rows, so the viewer's picker and this surface's
190
+ // source-clip picker share one poster implementation instead of a fourth copy.
191
+ export function SourceThumb({
192
+ uri,
193
+ name,
194
+ playing,
195
+ onToggle,
196
+ }: {
197
+ uri: string;
198
+ name: string;
199
+ playing: boolean;
200
+ onToggle: () => void;
201
+ }) {
202
+ const poster = `${mediaBytesUrl(uri)}#t=0.1`;
203
+ if (playing) {
204
+ return (
205
+ <div className="vi-studio-src-thumb vi-studio-src-thumb--playing">
206
+ <video
207
+ src={poster}
208
+ controls
209
+ autoPlay
210
+ playsInline
211
+ preload="metadata"
212
+ aria-label={`${name} preview`}
213
+ />
214
+ </div>
215
+ );
216
+ }
217
+ return (
218
+ <button
219
+ type="button"
220
+ className="vi-studio-src-thumb vi-studio-src-thumb-btn"
221
+ onClick={onToggle}
222
+ title={`Preview ${name}`}
223
+ aria-label={`Preview ${name}`}
224
+ >
225
+ <video src={poster} muted playsInline preload="metadata" aria-hidden="true" />
226
+ <span className="vi-studio-src-play" aria-hidden="true">
227
+ ▶
228
+ </span>
229
+ </button>
230
+ );
231
+ }
232
+
233
+ export function StudioGenerateSurface({
234
+ presets,
235
+ presetsLoading,
236
+ clips,
237
+ source,
238
+ setSource,
239
+ sourceMode,
240
+ stageSeq,
241
+ onEnqueued,
242
+ settingsHost,
243
+ lockedSurfaceMode,
244
+ }: StudioGenerateSurfaceProps) {
245
+ // ── SUB-TAB INPUT MEMORY (operator ask 2026-08-06) ──────────────────────────
246
+ // Every OPERATOR-AUTHORED field below is `useSessionState` rather than
247
+ // `useState`, so it survives this surface being unmounted — which happens on
248
+ // every Clip↔Cinema switch (the two surfaces swap), every Scene/Movie↔Clip
249
+ // switch (StudioGenerateMode unmounts), and every workbench tab switch
250
+ // (WorkbenchStation renders one station body). Same tuple, same updaters; the
251
+ // store is session-scoped and per-tab. See video/sessionForm.ts for why a store
252
+ // beats keep-mounted-but-hidden here. Transient UI below (pickers, previews,
253
+ // upload phases, busy/error flags, in-flight job ids) deliberately stays on
254
+ // plain useState — restoring a stale "uploading…" would be its own bug.
255
+ //
256
+ // B2 mode switch — Clip (this existing surface, default) | Movie/Cinema (StudioMovieComposer).
257
+ // When the top-level Generate tab locks the choice (Clip / Cinema tabs), `lockedSurfaceMode`
258
+ // is the effective value and the internal switcher below is hidden; the standalone
259
+ // /studio-clips station passes nothing, so its own internal state + switcher drive it.
260
+ const [internalSurfaceMode, setInternalSurfaceMode] = useSessionState<"clip" | "movie">(
261
+ "studio.clip.surfaceMode",
262
+ "clip",
263
+ );
264
+ const surfaceMode = lockedSurfaceMode ?? internalSurfaceMode;
265
+
266
+ const [capability, setCapability] = useSessionState<Capability>(
267
+ "studio.clip.capability",
268
+ "i2v",
269
+ );
270
+ const [presetId, setPresetId] = useSessionState("studio.clip.presetId", "");
271
+
272
+ const [width, setWidth] = useSessionState("studio.clip.width", 512);
273
+ const [height, setHeight] = useSessionState("studio.clip.height", 512);
274
+ const [fps, setFps] = useSessionState("studio.clip.fps", 24);
275
+ const [seed, setSeed] = useSessionState("studio.clip.seed", 0);
276
+ // Text fields (blank = "unset"): the route/model fills the default. Kept as
277
+ // strings so "empty" is distinct from 0.
278
+ const [vramBudget, setVramBudget] = useSessionState("studio.clip.vramBudget", "");
279
+ // MERGE-NOT-OVERWRITE bookkeeping: true once the OPERATOR has typed in the budget
280
+ // field. A preset/capability change fills a budget the user never touched (it is a
281
+ // workflow slot) but never overwrites a number they chose. One ref, not state — it
282
+ // only ever gates a write, so it must not cause a render of its own. SESSION-scoped
283
+ // for the same reason the values are: the autofit effect below runs on every mount,
284
+ // so a mount that restored a hand-typed budget but a fresh `false` here would
285
+ // immediately overwrite it.
286
+ const budgetTouched = useSessionRef("studio.clip.budgetTouched", false);
287
+ // Same discipline for GEOMETRY. The snap below is a workflow slot and fires on every
288
+ // capability change and once when the measured table lands — which would otherwise
289
+ // overwrite a width the operator typed in the first moments of the page. v2v is
290
+ // deliberately exempt: its inputs are disabled (a hard router-backed lock), so there is
291
+ // nothing there for a user to have touched.
292
+ const geomTouched = useSessionRef("studio.clip.geomTouched", false);
293
+ const [steps, setSteps] = useSessionState("studio.clip.steps", "");
294
+ const [cfg, setCfg] = useSessionState("studio.clip.cfg", "");
295
+ const [modelId, setModelId] = useSessionState("studio.clip.modelId", "");
296
+ // Showroom flavor (?demo=1 only): open with a cinematic example prompt (matches
297
+ // the sunset sample gens) so the brochure's Studio surface tells a coherent story
298
+ // out of the box. Seed lives in src/demo/ and is injected ONLY behind isCanned() —
299
+ // the live path stays an empty prompt.
300
+ const [prompt, setPrompt] = useSessionState("studio.clip.prompt", () =>
301
+ isCanned() ? DEMO_STUDIO_PROMPT : "",
302
+ );
303
+ const [negative, setNegative] = useSessionState("studio.clip.negative", "");
304
+ // k89: the card's [standard negative] tick — default OFF to preserve the Clip
305
+ // surface's blank-negative default; when on, the standard artifact/junk set is
306
+ // COMPOSED into the payload at submit (composeNegative — the wire is unchanged).
307
+ const [stdNegative, setStdNegative] = useSessionState("studio.clip.stdNegative", false);
308
+ // k89: the bottom "[▸ Tester]" collapsible that now hosts the cross-model sweep
309
+ // (the run row's ghost 🧪 button is retired).
310
+ const [clipTesterOpen, setClipTesterOpen] = useState(false);
311
+ // LLM PROMPT-ASSIST (Enhance / Generate) — the same shared hook + button cluster the
312
+ // Generate station uses (POSTs promptAssistUrl). Here the draft is the single clip
313
+ // prompt and apply replaces it wholesale. context.kind = "scene" (a clip is a short
314
+ // motion sequence, so the assistant leans into camera/motion phrasing).
315
+ const assist = usePromptAssist({ kind: "scene", specKey: "studio" });
316
+ // Optional human PROJECT name (choose-or-type). Threaded into the enqueue as
317
+ // `project` (auto-archive NAME); blank = auto-named (the default). The list of
318
+ // existing names backs the Settings datalist; free-typing a new one creates it.
319
+ const [project, setProject] = useSessionState("studio.clip.project", "");
320
+
321
+ // i2v start image (cap 1) — the `start_image` field.
322
+ const [startImages, setStartImages] = useSessionState<MediaRef[]>(
323
+ "studio.clip.startImages",
324
+ [],
325
+ );
326
+ // id_lock reference images (1–4, ordered) — the `reference_images` field.
327
+ // Only the server MediaRef travels (never pixels), so this is as cheap to keep
328
+ // in session storage as the media library's own entries.
329
+ const [referenceImages, setReferenceImages] = useSessionState<
330
+ Array<{ key: string; ref: MediaRef }>
331
+ >("studio.clip.referenceImages", []);
332
+ // IDENTITY PROFILES (stage a): when the reference set came from a SAVED profile,
333
+ // this holds it — the enqueue then sends `identity_profile:<slug>` (canonical) and
334
+ // omits raw reference_images. ANY ad-hoc edit (upload/library-pick/remove/reorder)
335
+ // clears it back to null: the set is then an "unsaved identity" sent as raw paths.
336
+ const [selectedProfile, setSelectedProfile] = useSessionState<SelectedProfile | null>(
337
+ "studio.clip.selectedProfile",
338
+ null,
339
+ );
340
+ // id_lock optional composition control — `control_image` + `control_kind` (both/neither).
341
+ const [controlImage, setControlImage] = useSessionState<MediaRef | null>(
342
+ "studio.clip.controlImage",
343
+ null,
344
+ );
345
+ const [controlKind, setControlKind] = useSessionState<ControlKind>(
346
+ "studio.clip.controlKind",
347
+ "",
348
+ );
349
+
350
+ const [imagePhase, setImagePhase] = useState<UploadPhase>("idle");
351
+ const [imageError, setImageError] = useState<string | null>(null);
352
+ const imageBusy = imagePhase === "uploading" || imagePhase === "ingesting";
353
+
354
+ // ── Subject-clip switcher (operator ask 2026-07-12) ──
355
+ // "Generate new clip" is a two-step flow: pendingSubjectStage marks the INTENT
356
+ // (source cleared, capability forced to t2v, note shown) between the click and the
357
+ // next successful enqueue; awaitingSubjectJobId then names the in-flight job whose
358
+ // completion (read off the `clips` poll the viewer/library already share) auto-stages
359
+ // its clip as the new subject. Only one is ever truthy at a time.
360
+ const [pendingSubjectStage, setPendingSubjectStage] = useState(false);
361
+ const [awaitingSubjectJobId, setAwaitingSubjectJobId] = useState<string | null>(null);
362
+
363
+ // Advanced escape hatch for the model pin: off by default so the menu is the ratified
364
+ // set, on when an operator deliberately wants every studio model id (labelled
365
+ // unverified for the active capability, never presented as equivalent).
366
+ const [showAllModels, setShowAllModels] = useState(false);
367
+
368
+ const [showLibPicker, setShowLibPicker] = useState(false); // start/reference (one active per shape)
369
+ const [showControlLib, setShowControlLib] = useState(false);
370
+ const [showSourcePicker, setShowSourcePicker] = useState(false);
371
+ // Which picker row is previewing inline (job_id), and whether the SELECTED-source
372
+ // summary poster is previewing. Only one renderSource variant mounts at a time (hero
373
+ // is a single value), so these component-level flags are never shared across two
374
+ // visible pickers. Cleared on select/clear so a stale row never keeps auto-playing.
375
+ const [previewId, setPreviewId] = useState<string | null>(null);
376
+ const [sourcePreview, setSourcePreview] = useState(false);
377
+
378
+ const [genBusy, setGenBusy] = useState(false);
379
+ const [genMsg, setGenMsg] = useState<string | null>(null);
380
+
381
+ // STUDIO TESTER (operator 2026-08-05) — fan the CURRENT prompt across every
382
+ // servable model of this surface's type (clip/movie) via POST studioTesterUrl.
383
+ // Operator-gated + background job; per-model results stream to the generation
384
+ // log directly below this surface (StudioAssistLogPanel). One prompt in, a
385
+ // battery out — no per-model clicking.
386
+ const [testerBusy, setTesterBusy] = useState(false);
387
+ // The FILTERED sweep (operator 2026-08-05): each surface computes the model
388
+ // subset that fits its current capability + geometry (see clipTesterModels /
389
+ // movieTesterModels below) and passes it as `models`. An EMPTY subset means the
390
+ // measured render table has not landed yet (or the current geometry sits outside
391
+ // every ratified envelope) — we omit `models` so the backend sweeps the whole
392
+ // type roster, the same courtesy-layer fallback every picker here uses when
393
+ // `renderLoaded` is false. A dead button would be a worse failure than a wider
394
+ // sweep.
395
+ const onTestAllModels = useCallback(
396
+ async (opts: {
397
+ category: string;
398
+ models: string[];
399
+ width?: number;
400
+ height?: number;
401
+ fps?: number;
402
+ seed?: number;
403
+ }) => {
404
+ const p = prompt.trim();
405
+ if (!p) {
406
+ setGenMsg("Enter a prompt to test across every model of this type.");
407
+ return;
408
+ }
409
+ setTesterBusy(true);
410
+ setGenMsg(null);
411
+ try {
412
+ const body: Record<string, unknown> = { category: opts.category, prompt: p };
413
+ if (opts.width != null) body.width = opts.width;
414
+ if (opts.height != null) body.height = opts.height;
415
+ if (opts.fps != null) body.fps = opts.fps;
416
+ if (opts.seed != null) body.seed = opts.seed;
417
+ if (opts.models.length > 0) body.models = opts.models;
418
+ const res = await request<unknown>(hugpyConfig.studioTesterUrl, {
419
+ method: "POST",
420
+ headers: { "Content-Type": "application/json" },
421
+ body: JSON.stringify(body),
422
+ meta: { specKey: "studio", operation: "studio.tester.enqueue" },
423
+ });
424
+ if (!mounted.current) return;
425
+ if (!res.ok) {
426
+ setGenMsg(`Tester failed: ${describeAppError(errorOf(res))}`);
427
+ return;
428
+ }
429
+ setGenMsg(
430
+ opts.models.length > 0
431
+ ? `Testing this prompt across ${opts.models.length} ${opts.category} ` +
432
+ `model${opts.models.length === 1 ? "" : "s"} that fit the current filter — ` +
433
+ `each result appears in the log below as it finishes.`
434
+ : `Testing this prompt across every ${opts.category} model — each model's ` +
435
+ `result appears in the log below as it finishes.`,
436
+ );
437
+ } finally {
438
+ if (mounted.current) setTesterBusy(false);
439
+ }
440
+ },
441
+ [prompt],
442
+ );
443
+
444
+ const mounted = useRef(true);
445
+ useEffect(() => {
446
+ mounted.current = true;
447
+ return () => {
448
+ mounted.current = false;
449
+ };
450
+ }, []);
451
+
452
+ const library = useMediaLibrary();
453
+ const libraryImages = library.filter((it) => it.ref.kind === "image");
454
+ // Existing project names for the Settings "Project" combobox (choose-or-type).
455
+ const { projects: knownProjects, refresh: refreshProjects } = useProjects(true);
456
+
457
+ const preset = presets.find((p) => p.id === presetId) ?? null;
458
+ const isT2V = capability === "t2v";
459
+ const isI2V = capability === "i2v";
460
+ const isV2V = capability === "v2v";
461
+ const isIdLock = capability === "id_lock";
462
+ const isMotion = capability === "motion";
463
+
464
+ // ── the fleet's measured render table + everything derived from it ───────────────
465
+ // One session-cached GET shared with the Movie composer (module singleton in the
466
+ // hook). `renderLoaded` gates EVERY filter below: while it is false each picker shows
467
+ // its full prior list, so a slow or failed discovery call degrades to exactly the
468
+ // behavior this surface had before, never to an empty menu.
469
+ const {
470
+ presets: renderPresets,
471
+ unavailable: renderUnavailable,
472
+ loaded: renderLoaded,
473
+ renderBox,
474
+ } = useRenderPresets(true);
475
+
476
+ // What the user has attached RIGHT NOW — the input side of the compatibility matrix.
477
+ // Every picker below narrows against this, which is the operator's "every input the
478
+ // user makes filters the options subsequently offered".
479
+ const attached: Attachments = {
480
+ startImage: startImages.length > 0,
481
+ sourceVideo: !!source,
482
+ referenceImages: referenceImages.length > 0,
483
+ controlImage: !!controlImage,
484
+ };
485
+
486
+ // The capability menu, as data: servable-on-this-fleet AND compatible-with-what-is-
487
+ // attached, each unavailable row carrying the backend's OWN refusal wording for its
488
+ // tooltip. Not a gate — an incompatible capability renders DISABLED with the reason,
489
+ // because a user who attached a start image and cannot find restyle is worse off than
490
+ // one who can see why it is greyed out.
491
+ const capOffers = capabilityOffers(
492
+ renderPresets,
493
+ renderUnavailable,
494
+ attached,
495
+ CLIP_CAPABILITIES,
496
+ );
497
+ const offerFor = (c: string) => capOffers.find((o) => o.capability === c);
498
+
499
+ // The ratified geometry envelope for the active capability (832x480@16 on every video
500
+ // row today). Drives the snap-on-pick below and the out-of-envelope flag — the same
501
+ // footgun guard v2v always had, now derived for every capability instead of hardcoded
502
+ // for one.
503
+ const capGeometry = renderLoaded ? geometryForCapability(renderPresets, capability) : null;
504
+ const geomOutOfEnvelope =
505
+ renderLoaded && !withinGeometry(renderPresets, capability, width, height);
506
+
507
+ // The MINIMUM budget that can bind a real model for this capability, read off the
508
+ // preset table's `vram_envelope_gb` (the exact number the router compares a budget
509
+ // against). Replaces the hand-mirrored STUDIO_REAL_FLOOR_GB, which had drifted to 6 —
510
+ // below every real row in the table (cheapest is 8.2).
511
+ const capFloorGb = renderLoaded ? minBudgetGbForCapability(renderPresets, capability) : null;
512
+ const capSuggestGb = renderLoaded ? suggestedBudgetGb(renderPresets, capability) : null;
513
+
514
+ // Model pins that stay inside the ratified set for this capability. The escape hatch
515
+ // below keeps every studio model id reachable — an operator may still pin off-menu,
516
+ // and the option says so rather than pretending the pin is verified.
517
+ const capModels = renderLoaded ? modelsForCapability(renderPresets, capability) : [];
518
+ const modelOffMenu =
519
+ renderLoaded && modelId.trim() !== "" && !capModels.includes(modelId.trim());
520
+
521
+ // STUDIO TESTER rosters (operator 2026-08-05) — the model subset each surface's
522
+ // "Test all models" sweep is scoped to, so a battery only ever runs the models
523
+ // that fit the CURRENT FILTER, not every servable model blindly.
524
+ // CLIP: the ratified models for the ACTIVE capability, kept only while the
525
+ // current width×height is inside that capability's envelope. Geometry is a
526
+ // PER-CAPABILITY envelope in the render table (not per-model), so this is
527
+ // correctly all-or-nothing — out of envelope ⇒ [] ⇒ the button falls back to
528
+ // the whole roster (see onTestAllModels).
529
+ // CINEMA: the movie/assemble capability's ratified models, which via the
530
+ // preset's `composes` already fold in the per-segment t2v/i2v bindings the
531
+ // movie actually renders.
532
+ const clipTesterModels =
533
+ renderLoaded && withinGeometry(renderPresets, capability, width, height)
534
+ ? capModels
535
+ : [];
536
+
537
+ // Has this capability ever produced pixels here? Badged, never hidden — `false` is the
538
+ // table telling the truth about itself.
539
+ const capProven = renderLoaded ? provenForCapability(renderPresets, capability) : null;
540
+
541
+ // Which input slots the ACTIVE capability accepts, from the route's own gates.
542
+ const slots = acceptedSlots(capability);
543
+
544
+ // Geometry tooltips, named rather than anonymous: a lock the user cannot explain is a
545
+ // lock they will fight. Both quote the RATIFIED preset the envelope came from.
546
+ const geomLockTitle = capGeometry
547
+ ? `Restyle is locked to ${capGeometry.width}×${capGeometry.height} @ ${capGeometry.fps}fps — the envelope “${capGeometry.presetTitle}” is ratified at. The router refuses anything outside it.`
548
+ : `Restyle is locked to ${VACE_W}×${VACE_H} @ ${VACE_FPS}fps (the VACE envelope).`;
549
+ const geomEnvelopeTitle = capGeometry
550
+ ? `Ratified envelope for this capability: ${capGeometry.width}×${capGeometry.height} @ ${capGeometry.fps}fps (“${capGeometry.presetTitle}”). Outside it there is no proven binding.`
551
+ : undefined;
552
+
553
+ const addStartImage = useCallback((ref: MediaRef) => {
554
+ setStartImages((prev) => (prev.length >= MAX_START_IMAGES ? prev : [...prev, ref]));
555
+ setShowLibPicker(false);
556
+ }, []);
557
+ const removeStartImage = useCallback((idx: number) => {
558
+ setStartImages((prev) => prev.filter((_, i) => i !== idx));
559
+ }, []);
560
+
561
+ const addReferenceImage = useCallback((ref: MediaRef) => {
562
+ setReferenceImages((prev) =>
563
+ prev.length >= MAX_REFERENCE_IMAGES ? prev : [...prev, { key: refKey(), ref }],
564
+ );
565
+ setShowLibPicker(false);
566
+ setSelectedProfile(null); // an ad-hoc add makes this an UNSAVED identity
567
+ }, []);
568
+ const removeReferenceImage = useCallback((idx: number) => {
569
+ setReferenceImages((prev) => prev.filter((_, i) => i !== idx));
570
+ setSelectedProfile(null);
571
+ }, []);
572
+ const moveReferenceImage = useCallback((idx: number, dir: -1 | 1) => {
573
+ setReferenceImages((prev) => {
574
+ const j = idx + dir;
575
+ if (j < 0 || j >= prev.length) return prev;
576
+ const next = prev.slice();
577
+ const [it] = next.splice(idx, 1);
578
+ next.splice(j, 0, it);
579
+ return next;
580
+ });
581
+ setSelectedProfile(null); // reorder diverges from the profile's canonical order
582
+ }, []);
583
+
584
+ // IDENTITY PROFILES: attach a profile's resolved reference set. A profile pick
585
+ // REPLACES the current set (a profile IS the whole identity); ad-hoc callers append.
586
+ const attachIdentityRefs = useCallback((refs: MediaRef[], replace: boolean) => {
587
+ if (replace) {
588
+ setReferenceImages(refs.slice(0, MAX_REFERENCE_IMAGES).map((ref) => ({ key: refKey(), ref })));
589
+ } else {
590
+ setReferenceImages((prev) => {
591
+ const room = MAX_REFERENCE_IMAGES - prev.length;
592
+ return room <= 0 ? prev : [...prev, ...refs.slice(0, room).map((ref) => ({ key: refKey(), ref }))];
593
+ });
594
+ }
595
+ setShowLibPicker(false);
596
+ }, []);
597
+
598
+ // Capability handoff: when the shell stages a source (Send to Studio / use as
599
+ // source), adopt its mode — restyle ⇒ v2v, extend ⇒ i2v. Keyed on stageSeq so it
600
+ // runs once per stage, never clobbering a manual capability choice.
601
+ //
602
+ // CROSS-TIER CARRY: an "extend" stage normally lands in i2v, but if the surface is
603
+ // ALREADY holding reference images, land in v2v scene-continuity instead — v2v shows
604
+ // AND sends the carried refs, so chaining scene→scene keeps the identity lock without
605
+ // re-picking (i2v would silently drop the refs). The reference slots themselves are
606
+ // never cleared by a stage, so the lock rides along.
607
+ //
608
+ // 2026-07-29: the landing capability is now VALIDATED against the compatibility
609
+ // matrix instead of assumed. The intent ordering is unchanged (an explicit "restyle"
610
+ // stage means v2v; carried references keep the identity lock alive rather than letting
611
+ // i2v silently drop them), but each candidate is checked with `capabilityForAttachments`
612
+ // so a stage can never land the form on a capability that refuses what is attached.
613
+ useEffect(() => {
614
+ if (stageSeq === 0) return;
615
+ const next: Attachments = {
616
+ startImage: startImages.length > 0,
617
+ sourceVideo: true, // a stage IS a staged source clip
618
+ referenceImages: referenceImages.length > 0,
619
+ controlImage: !!controlImage,
620
+ };
621
+ const intent: Capability =
622
+ sourceMode === "restyle" || referenceImages.length > 0 ? "v2v" : "i2v";
623
+ const resolved = capabilityForAttachments(renderPresets, next, intent);
624
+ setCapability((resolved as Capability) ?? intent);
625
+ setPresetId("");
626
+ // eslint-disable-next-line react-hooks/exhaustive-deps
627
+ }, [stageSeq]);
628
+
629
+ // FOOTGUN GUARD, GENERALIZED (2026-07-29). This used to be a v2v-only hardcode of the
630
+ // 832x480@16 VACE envelope. It is now derived: whenever the capability changes, the
631
+ // geometry snaps to the envelope the RATIFIED preset for that capability declares, and
632
+ // the budget floor comes from that preset's `vram_envelope_gb`. Every video row on the
633
+ // fleet is 832x480@16 today, so in practice this makes t2v/i2v/id_lock/motion land on
634
+ // the same guaranteed-to-render envelope v2v was already pinned to — which is also the
635
+ // route's own default (video_routes: a 512x512 default "was a GUARANTEED FAIL for two
636
+ // whole capabilities").
637
+ //
638
+ // The v2v HARD LOCK below (disabled inputs + the submit block) is unchanged: the router
639
+ // refuses an out-of-envelope restyle outright, so that one stays a lock rather than a
640
+ // snap. Other capabilities snap once and remain editable, with an honest flag if the
641
+ // operator moves outside the envelope.
642
+ //
643
+ // MERGE DISCIPLINE: the budget is filled only when the operator has not typed one.
644
+ useEffect(() => {
645
+ if (isV2V) {
646
+ setWidth(capGeometry?.width ?? VACE_W);
647
+ setHeight(capGeometry?.height ?? VACE_H);
648
+ setFps(capGeometry?.fps ?? VACE_FPS);
649
+ if (!budgetTouched.current && vramBudget.trim() === "") {
650
+ setVramBudget(String(capSuggestGb ?? VACE_BUDGET_GB));
651
+ }
652
+ return;
653
+ }
654
+ if (capGeometry && !geomTouched.current) {
655
+ setWidth(capGeometry.width);
656
+ setHeight(capGeometry.height);
657
+ setFps(capGeometry.fps);
658
+ }
659
+ if (!budgetTouched.current && vramBudget.trim() === "" && capSuggestGb != null) {
660
+ setVramBudget(String(capSuggestGb));
661
+ }
662
+ // eslint-disable-next-line react-hooks/exhaustive-deps
663
+ }, [capability, capGeometry?.presetId, capSuggestGb]);
664
+
665
+ // ── APPLYING A PRESET IS A MERGE, NEVER AN OVERWRITE (operator ruling 2026-07-29) ──
666
+ //
667
+ // A preset supplies the WORKFLOW — capability, geometry, cadence, budget. The user's
668
+ // SESSION INPUTS are theirs: prompt, negative, identity, every attachment, and a seed
669
+ // they chose all survive the apply and are slotted into the workflow around them.
670
+ //
671
+ // WHAT THIS REPLACES, precisely, because it was a data-loss bug and not a preference:
672
+ // the previous body ended `setPrompt(p.prompt ?? ""); setNegative(p.negative ?? "")`.
673
+ // Most studio presets carry no prompt of their own, so picking one BLANKED whatever
674
+ // the operator had just written (and any prompt-assist enhancement they had run on
675
+ // it). It also stamped the preset's seed and budget over hand-typed values. The
676
+ // Generate station's `applyPresetLoad` had the right instinct all along — fill only
677
+ // what the preset actually names — and this is that rule, tightened for text to
678
+ // fill-only-when-empty (`fillIfEmpty`).
679
+ const onPickPreset = useCallback(
680
+ (id: string) => {
681
+ setPresetId(id);
682
+ const p = presets.find((x) => x.id === id) ?? null;
683
+ if (!p) return;
684
+ const cap = (p.capability as Capability) ?? "i2v";
685
+ setCapability(cap);
686
+ // A manual preset pick abandons any armed/in-flight "generate new subject"
687
+ // wait (see the subject-clip switcher below) — the operator has taken over.
688
+ setPendingSubjectStage(false);
689
+ setAwaitingSubjectJobId(null);
690
+ // WORKFLOW SLOTS — the preset owns these. Geometry prefers the RATIFIED envelope
691
+ // for the capability over the curated preset's own numbers when the two disagree,
692
+ // because the ratified table is the one measured against the render box; the
693
+ // curated preset's geometry is intent.
694
+ const geom = renderLoaded ? geometryForCapability(renderPresets, cap) : null;
695
+ // An explicit preset pick IS a request for that workflow's envelope, so it hands
696
+ // geometry back to the presets (a later hand-edit takes it again).
697
+ geomTouched.current = false;
698
+ setWidth(geom?.width ?? p.width);
699
+ setHeight(geom?.height ?? p.height);
700
+ setFps(geom?.fps ?? p.fps);
701
+ // SESSION INPUTS — preserved. A seed the operator moved off 0 is a choice; a seed
702
+ // still at 0 is untouched scaffolding a preset may fill.
703
+ setSeed((cur) => (cur !== 0 ? cur : typeof p.seed === "number" ? p.seed : cur));
704
+ if (!budgetTouched.current) {
705
+ const floor = renderLoaded ? suggestedBudgetGb(renderPresets, cap) : null;
706
+ // Never offer a budget under the capability's real floor — a preset curated
707
+ // before the floor was measured would otherwise silently bind the synthetic tier.
708
+ const wanted =
709
+ typeof p.vram_budget_gb === "number"
710
+ ? floor != null
711
+ ? Math.max(p.vram_budget_gb, floor)
712
+ : p.vram_budget_gb
713
+ : floor;
714
+ if (wanted != null) setVramBudget(String(wanted));
715
+ }
716
+ // The preset's example prompt is a scaffold for an EMPTY box, never a replacement.
717
+ setPrompt((cur) => fillIfEmpty(cur, p.prompt));
718
+ setNegative((cur) => fillIfEmpty(cur, p.negative));
719
+ },
720
+ [presets, renderPresets, renderLoaded],
721
+ );
722
+
723
+ // Manual capability change drops any picked preset (so it can't fight the choice); the
724
+ // geometry/budget snap for the new capability runs in the derived effect above.
725
+ //
726
+ // 2026-07-29: it now also RE-VALIDATES the user's other inputs against the new
727
+ // capability instead of leaving them to fail at render time. Nothing is deleted — the
728
+ // attachments are the user's — but a model pin that cannot serve the new capability is
729
+ // cleared back to Auto (the router then picks a model that CAN), and an incompatible
730
+ // attachment is named in the callout so the operator can act on it.
731
+ const onPickCapability = useCallback(
732
+ (c: Capability) => {
733
+ setCapability(c);
734
+ setPresetId("");
735
+ // Same abandon-on-manual-override as onPickPreset above.
736
+ setPendingSubjectStage(false);
737
+ setAwaitingSubjectJobId(null);
738
+ if (renderLoaded && modelId.trim() !== "") {
739
+ const ok = modelsForCapability(renderPresets, c);
740
+ if (ok.length > 0 && !ok.includes(modelId.trim())) setModelId("");
741
+ }
742
+ },
743
+ [renderPresets, renderLoaded, modelId],
744
+ );
745
+
746
+ // ── image upload → ingest → guard kind=image, then route the MediaRef to its slot
747
+ // (start | reference | control). One flow for all three; joins the Session Library. ──
748
+ const onPickImageFile = useCallback(
749
+ async (file: File, target: ImageTarget) => {
750
+ setImageError(null);
751
+ setImagePhase("uploading");
752
+ const fd = new FormData();
753
+ fd.append("file", file);
754
+ fd.append("sid", getSessionId());
755
+ const up = await request<unknown>(hugpyConfig.uploadUrl, {
756
+ method: "POST",
757
+ body: fd,
758
+ meta: { specKey: "studio", operation: "upload" },
759
+ });
760
+ if (!mounted.current) return;
761
+ if (!up.ok) {
762
+ setImageError(describeAppError(errorOf(up)));
763
+ setImagePhase("idle");
764
+ return;
765
+ }
766
+ const upParsed = uploadResultSchema.safeParse(okValue(up));
767
+ if (!upParsed.success) {
768
+ setImageError("Malformed upload response.");
769
+ setImagePhase("idle");
770
+ return;
771
+ }
772
+ setImagePhase("ingesting");
773
+ const ing = await request<unknown>(hugpyConfig.videoIngestUrl, {
774
+ method: "POST",
775
+ body: JSON.stringify({ path: upParsed.data.path }),
776
+ headers: { "Content-Type": "application/json" },
777
+ meta: { specKey: "studio", operation: "ingest" },
778
+ });
779
+ if (!mounted.current) return;
780
+ if (!ing.ok) {
781
+ setImageError(describeAppError(errorOf(ing)));
782
+ setImagePhase("idle");
783
+ return;
784
+ }
785
+ const media = mediaRefSchema.safeParse(okValue(ing));
786
+ if (!media.success) {
787
+ setImageError("Malformed ingest response.");
788
+ setImagePhase("idle");
789
+ return;
790
+ }
791
+ if (media.data.kind !== "image") {
792
+ setImageError(`Ingested asset is "${media.data.kind}", not an image — pick a still.`);
793
+ setImagePhase("idle");
794
+ return;
795
+ }
796
+ addToLibrary(media.data, "upload", media.data.mime);
797
+ if (target === "start") addStartImage(media.data);
798
+ else if (target === "reference") addReferenceImage(media.data);
799
+ else setControlImage(media.data);
800
+ setImagePhase("idle");
801
+ },
802
+ [addStartImage, addReferenceImage],
803
+ );
804
+
805
+ // ── video upload → ingest → guard kind=video → straight to the staged subject. ──
806
+ // The subject-clip switcher's "Upload video" path (operator ask 2026-07-12): the
807
+ // EXACT mirror of onPickImageFile above (same upload/ingest endpoints, same MediaRef
808
+ // shape) but guarding the OTHER kind this surface consumes, landing straight in
809
+ // `source` instead of a slot list. Shares imagePhase/imageError with the image
810
+ // uploads — one in-flight-upload state machine for every upload this surface offers,
811
+ // so an image upload and a video upload never race each other.
812
+ const onPickVideoFile = useCallback(
813
+ async (file: File) => {
814
+ setImageError(null);
815
+ setImagePhase("uploading");
816
+ const fd = new FormData();
817
+ fd.append("file", file);
818
+ fd.append("sid", getSessionId());
819
+ const up = await request<unknown>(hugpyConfig.uploadUrl, {
820
+ method: "POST",
821
+ body: fd,
822
+ meta: { specKey: "studio", operation: "studio.subject.upload" },
823
+ });
824
+ if (!mounted.current) return;
825
+ if (!up.ok) {
826
+ setImageError(describeAppError(errorOf(up)));
827
+ setImagePhase("idle");
828
+ return;
829
+ }
830
+ const upParsed = uploadResultSchema.safeParse(okValue(up));
831
+ if (!upParsed.success) {
832
+ setImageError("Malformed upload response.");
833
+ setImagePhase("idle");
834
+ return;
835
+ }
836
+ setImagePhase("ingesting");
837
+ const ing = await request<unknown>(hugpyConfig.videoIngestUrl, {
838
+ method: "POST",
839
+ body: JSON.stringify({ path: upParsed.data.path }),
840
+ headers: { "Content-Type": "application/json" },
841
+ meta: { specKey: "studio", operation: "studio.subject.ingest" },
842
+ });
843
+ if (!mounted.current) return;
844
+ if (!ing.ok) {
845
+ setImageError(describeAppError(errorOf(ing)));
846
+ setImagePhase("idle");
847
+ return;
848
+ }
849
+ const media = mediaRefSchema.safeParse(okValue(ing));
850
+ if (!media.success) {
851
+ setImageError("Malformed ingest response.");
852
+ setImagePhase("idle");
853
+ return;
854
+ }
855
+ if (media.data.kind !== "video") {
856
+ setImageError(`Ingested asset is "${media.data.kind}", not a video — pick a clip.`);
857
+ setImagePhase("idle");
858
+ return;
859
+ }
860
+ addToLibrary(media.data, "upload", media.data.mime);
861
+ setSource(media.data);
862
+ setSourcePreview(false);
863
+ setShowSourcePicker(false);
864
+ setPendingSubjectStage(false);
865
+ setAwaitingSubjectJobId(null);
866
+ setImagePhase("idle");
867
+ },
868
+ [setSource],
869
+ );
870
+
871
+ // ── "generate new clip" — the switcher's third swap path. Clears the staged
872
+ // subject, forces the no-source t2v shape (renderSource itself stops rendering in
873
+ // t2v, so the always-visible note below carries the "what's happening" signal in
874
+ // its place), and arms pendingSubjectStage so the NEXT successful enqueue starts the
875
+ // auto-stage wait below. ──
876
+ const onGenerateNewSubject = useCallback(() => {
877
+ setSource(null);
878
+ setSourcePreview(false);
879
+ setShowSourcePicker(false);
880
+ setAwaitingSubjectJobId(null);
881
+ setCapability("t2v");
882
+ setPresetId("");
883
+ setPendingSubjectStage(true);
884
+ setGenMsg(null);
885
+ }, [setSource]);
886
+
887
+ // Watch the shared clip list for the job armed by onGenerateNewSubject (via
888
+ // onGenerate below). `clips` is the SAME 6s-polled list the viewer/library read, so
889
+ // this rides that poll instead of opening a second one. Once the awaited job shows
890
+ // up playable, its clip becomes the staged subject automatically; a failed/cancelled
891
+ // job stops the wait with an honest message instead of hanging forever.
892
+ useEffect(() => {
893
+ if (!awaitingSubjectJobId) return;
894
+ const match = clips.find((c) => c.job_id === awaitingSubjectJobId);
895
+ if (!match) return; // not in the list yet — keep waiting for the next poll tick
896
+ if (match.playable) {
897
+ const ref = clipToMediaRef(match);
898
+ if (ref) {
899
+ setSource(ref);
900
+ setSourcePreview(false);
901
+ setGenMsg("The new clip is done — staged as the subject.");
902
+ }
903
+ setAwaitingSubjectJobId(null);
904
+ return;
905
+ }
906
+ const st = match.status ?? "";
907
+ if (st === "failed" || st === "cancelled") {
908
+ setGenMsg(`The subject render ${st} — add a subject another way, or try Generate again.`);
909
+ setAwaitingSubjectJobId(null);
910
+ }
911
+ }, [clips, awaitingSubjectJobId, setSource]);
912
+
913
+ // ── required-input gate + preformed SHAPE ──
914
+ // v2v: a source clip is REQUIRED (nothing to repaint otherwise) — always the hero.
915
+ // i2v with a requires_source preset: a START IMAGE or a SOURCE CLIP (extend) is
916
+ // required — the start image is the hero, the source clip the alternative.
917
+ // id_lock: >=1 REFERENCE image is required (the route 400s otherwise) — reference
918
+ // slots are the hero; the composition control is optional + both-or-neither.
919
+ // t2v / synthetic i2v / custom: no required input; inputs stay inline + optional.
920
+ const presetNeedsInput = preset?.requires_source === true;
921
+ const hasImage = startImages.length > 0;
922
+ const hasSource = !!source;
923
+ const hasReference = referenceImages.length > 0;
924
+ const canAddImage = startImages.length < MAX_START_IMAGES;
925
+ const canAddReference = referenceImages.length < MAX_REFERENCE_IMAGES;
926
+ const needSource = isV2V;
927
+ const needImageOrSource = isI2V && presetNeedsInput;
928
+ const needReference = isIdLock;
929
+ // MOTION requires a control still and it IS the capability — the exact mirror of
930
+ // id_lock's reference rule (video_routes: "a motion request with no control carries NO
931
+ // conditioning this runner can tell apart from a plain t2v/v2v render").
932
+ const needControl = isMotion;
933
+ const hasControl = !!controlImage;
934
+ // both-or-neither: image XOR kind set is invalid. Applies to BOTH capabilities that
935
+ // accept a control (id_lock optional, motion required).
936
+ const controlHalfSet = (isIdLock || isMotion) && !!controlImage !== (controlKind !== "");
937
+ // The route refuses this pair by name: the VACE control channel takes ONE input and
938
+ // source_video wins the elif, so the control still would be silently dropped and the
939
+ // render would come back a plain restyle. Block it here rather than let the 400 land.
940
+ const controlAndSource = isMotion && hasControl && hasSource;
941
+ const missingRequired =
942
+ (needSource && !hasSource) ||
943
+ (needImageOrSource && !hasImage && !hasSource) ||
944
+ (needReference && !hasReference) ||
945
+ (needControl && !hasControl) ||
946
+ controlAndSource ||
947
+ controlHalfSet;
948
+
949
+ const hero: "start-image" | "source" | "reference" | "control" | null = isMotion
950
+ ? "control"
951
+ : isIdLock
952
+ ? "reference"
953
+ : isV2V
954
+ ? "source"
955
+ : needImageOrSource
956
+ ? "start-image"
957
+ : null;
958
+ const startImageAttention = needImageOrSource && !hasImage && !hasSource;
959
+ const sourceAttention =
960
+ (needSource && !hasSource) || (needImageOrSource && !hasImage && !hasSource);
961
+ const referenceAttention = needReference && !hasReference;
962
+
963
+ // ── Composer attach idiom (operator ask 2026-07-11, option A) ──
964
+ // The prompt composer's inline attach toolbar + chips and the hero shape editor below
965
+ // are TWO ACCESS PATHS TO THE SAME STATE — one source of truth, two views. We adopt
966
+ // GoalComposer's documented attach idiom (attachments = toolbar buttons, attached =
967
+ // removable chips) and wire its callbacks to THIS file's OWN pickers / MediaRef flows,
968
+ // offering only the inputs the ACTIVE shape accepts (mirroring the render* predicates
969
+ // above). The buttons open the SAME `showLibPicker` / `showSourcePicker` the hero uses,
970
+ // so the library pick routes to start (i2v) or reference (v2v / id_lock) exactly as the
971
+ // hero's own lib-picker routing does; the chips reflect the SAME startImages / source /
972
+ // referenceImages / controlImage state the hero renders, and reuse the hero's existing
973
+ // removes. Applicability decides whether a button EXISTS (t2v gets no toolbar — omitted
974
+ // cleanly by GoalComposer when the array is empty); capacity decides whether it is
975
+ // DISABLED (a filled slot mirrors the hero's "you're at the cap" note, not a vanished
976
+ // affordance). The negative composer stays plain.
977
+ //
978
+ // 2026-07-29: these three are no longer hand-written capability unions — they are the
979
+ // route's own input gates, transcribed once in renderCompat's ACCEPTS matrix and read
980
+ // here via `acceptedSlots`. Same values for the four original capabilities; `motion`
981
+ // gets its control slot for free, and a future gate change is a one-line edit in the
982
+ // module that mirrors the route rather than a hunt through two components.
983
+ const acceptsStartImage = slots.startImage; // i2v carries the subject from a start still
984
+ const acceptsSourceClip = slots.sourceVideo; // i2v extend / v2v restyle take a source clip
985
+ const acceptsReferenceImage = slots.referenceImages; // v2v continuity / id_lock lock
986
+
987
+ const promptAttachments: GoalComposerAttach[] = [];
988
+ // i2v ⇒ start image; v2v / id_lock ⇒ reference image (mutually exclusive — never both).
989
+ if (acceptsStartImage) {
990
+ promptAttachments.push({
991
+ label: "+ image from library",
992
+ onClick: () => setShowLibPicker(true),
993
+ disabled: !canAddImage,
994
+ title: canAddImage
995
+ ? "Pick a start image from the session library"
996
+ : "A start image is already added — remove it to pick another.",
997
+ });
998
+ } else if (acceptsReferenceImage) {
999
+ promptAttachments.push({
1000
+ label: "+ reference image",
1001
+ onClick: () => setShowLibPicker(true),
1002
+ disabled: !canAddReference,
1003
+ title: canAddReference
1004
+ ? "Pick a reference image from the session library"
1005
+ : `Maximum ${MAX_REFERENCE_IMAGES} reference images.`,
1006
+ });
1007
+ }
1008
+ if (acceptsSourceClip) {
1009
+ promptAttachments.push({
1010
+ label: "+ video",
1011
+ onClick: () => setShowSourcePicker(true),
1012
+ title: isV2V ? "Pick the source clip to restyle" : "Pick a source clip to extend",
1013
+ });
1014
+ }
1015
+
1016
+ // Chips mirror the picked state the ACTIVE shape shows, with the hero's own removes.
1017
+ const promptAttached: GoalComposerAttached[] = [];
1018
+ if (acceptsStartImage) {
1019
+ startImages.forEach((img, idx) =>
1020
+ promptAttached.push({
1021
+ ref: img,
1022
+ label: "start image",
1023
+ onRemove: () => removeStartImage(idx),
1024
+ }),
1025
+ );
1026
+ }
1027
+ if (acceptsSourceClip && source) {
1028
+ promptAttached.push({
1029
+ ref: source,
1030
+ label: source.asset_id.slice(0, 8),
1031
+ // Same clear the hero's Clear button performs (drop the ref + stop any preview).
1032
+ onRemove: () => {
1033
+ setSource(null);
1034
+ setSourcePreview(false);
1035
+ },
1036
+ });
1037
+ }
1038
+ if (acceptsReferenceImage) {
1039
+ referenceImages.forEach((slot, idx) =>
1040
+ promptAttached.push({
1041
+ ref: slot.ref,
1042
+ label: `ref ${idx + 1}`,
1043
+ onRemove: () => removeReferenceImage(idx),
1044
+ }),
1045
+ );
1046
+ }
1047
+ if (slots.controlImage && controlImage) {
1048
+ promptAttached.push({
1049
+ ref: controlImage,
1050
+ // Mirrors the hero's control Remove (setControlImage(null)); kind is left as-is,
1051
+ // exactly like that button — the both-or-neither gate then flags the half-set state.
1052
+ label: controlKind ? `control · ${controlKind}` : "control",
1053
+ onRemove: () => setControlImage(null),
1054
+ });
1055
+ }
1056
+
1057
+ // Preset-aware honesty copy for the id_lock reference hero (lock-person / lock-thing /
1058
+ // scene-bias templates each want different guidance; the generic id_lock keeps a
1059
+ // neutral line). Shown as the accent callout while the required refs are empty.
1060
+ const referenceCallout =
1061
+ presetId === "lock-person"
1062
+ ? "Add 1–4 photos of the SAME person; multiple angles (front, 3/4, profile) help hold the face and build across motion. Order matters."
1063
+ : presetId === "lock-thing"
1064
+ ? "Add up to 4 clean shots of the object (a plain background and a couple of angles help). Its shape, markings and colour are preserved. Order matters."
1065
+ : presetId === "scene-bias"
1066
+ ? "Add a photo of the place. Honest: a place BIASES the setting, lighting and palette — it does not hard-lock like a person or object does."
1067
+ : `This generator locks identity to these reference images — add 1–${MAX_REFERENCE_IMAGES} (a person, place, or thing). Order matters.`;
1068
+
1069
+ const missingReason =
1070
+ needReference && !hasReference
1071
+ ? "Identity lock needs at least one reference image (1–4) — add one above."
1072
+ : needControl && !hasControl
1073
+ ? "Pose / structure control needs a control still (pose, depth or sketch) — it IS the control that makes this a motion render rather than a plain restyle."
1074
+ : controlAndSource
1075
+ ? "A control still and a source clip cannot be combined — the control channel takes one input and the source clip wins, so the still would be silently ignored. Clear one (control still ⇒ motion, source clip ⇒ restyle)."
1076
+ : controlHalfSet
1077
+ ? "Composition control is both-or-neither — add a control image AND a kind, or clear both."
1078
+ : needSource && !hasSource
1079
+ ? "Restyle (v2v) needs a source clip — add one above."
1080
+ : "This generator needs a start image (or a source clip to extend) — add one above.";
1081
+
1082
+ // v2v stays a HARD block (the router refuses an out-of-envelope restyle outright). The
1083
+ // envelope itself is now the ratified preset's rather than a mirrored constant.
1084
+ const v2vW = capGeometry?.width ?? VACE_W;
1085
+ const v2vH = capGeometry?.height ?? VACE_H;
1086
+ const v2vBadGeom =
1087
+ isV2V && (width > v2vW || height > v2vH || width <= 0 || height <= 0);
1088
+ const blockSubmit = missingRequired || v2vBadGeom;
1089
+
1090
+ // ── SYNTHETIC-TIER HONESTY BANNER (operator ask 2026-07-12) ──
1091
+ // "Leaving VRAM budget blank silently binds the synthetic (colored-noise) tier"
1092
+ // bit the operator repeatedly — the footnote on the knob below was never enough.
1093
+ // Only t2v and i2v have a synthetic last-resort model registered at all
1094
+ // (studio/models_seed.py: synthetic-t2v / synthetic-i2v are the only synthetic
1095
+ // ModelConfigs) — v2v and id_lock have NO synthetic candidate, so a too-low
1096
+ // budget there fails the router honestly instead of rendering fake frames. That
1097
+ // structurally excludes both: isV2V is caught by `!(isT2V || isI2V)` directly,
1098
+ // and separately v2v's own footgun-guard effect above already floors a blank
1099
+ // budget to VACE_BUDGET_GB the moment restyle is picked — so this predicate
1100
+ // mirrors the surface's OWN "would this bind sub-real" logic rather than
1101
+ // re-deriving a parallel one.
1102
+ //
1103
+ // ⚠ THE FLOOR IS NOW MEASURED, NOT MIRRORED (2026-07-29). `STUDIO_REAL_FLOOR_GB` was a
1104
+ // hand-kept copy of a backend constant and it had drifted to 6 — UNDER the cheapest
1105
+ // real row in the ratified table (wan2.1-t2v-1.3b @fp16 is 8.2). A budget of 7 would
1106
+ // therefore bind the synthetic prover while this banner stayed silent. The floor now
1107
+ // comes from the capability's own `vram_envelope_gb`, with the old constant kept only
1108
+ // as the fallback for a page whose discovery GET has not landed.
1109
+ const realFloorGb = capFloorGb ?? STUDIO_REAL_FLOOR_GB;
1110
+ const safeFillGb = capSuggestGb ?? STUDIO_SAFE_FILL_GB;
1111
+ const wouldBindSynthetic =
1112
+ (isT2V || isI2V) && bindsSyntheticTier(vramBudget, realFloorGb);
1113
+
1114
+ const onGenerate = useCallback(async () => {
1115
+ if (blockSubmit) {
1116
+ setGenMsg(
1117
+ v2vBadGeom
1118
+ ? "Restyle geometry must be 832×480 landscape (the VACE envelope)."
1119
+ : missingReason,
1120
+ );
1121
+ return;
1122
+ }
1123
+ const budgetNum = vramBudget.trim() === "" ? undefined : Number(vramBudget);
1124
+ if (budgetNum !== undefined && (Number.isNaN(budgetNum) || budgetNum <= 0)) {
1125
+ setGenMsg("VRAM budget must be a positive number (or blank for the route default).");
1126
+ return;
1127
+ }
1128
+ const stepsNum = steps.trim() === "" ? undefined : Number(steps);
1129
+ if (
1130
+ stepsNum !== undefined &&
1131
+ (!Number.isInteger(stepsNum) || stepsNum < 1 || stepsNum > 100)
1132
+ ) {
1133
+ setGenMsg("Steps must be a whole number in [1, 100] (or blank for the model default).");
1134
+ return;
1135
+ }
1136
+ const cfgNum = cfg.trim() === "" ? undefined : Number(cfg);
1137
+ if (cfgNum !== undefined && (Number.isNaN(cfgNum) || cfgNum < 0 || cfgNum > 20)) {
1138
+ setGenMsg("CFG must be a number in [0, 20] (or blank for the model default).");
1139
+ return;
1140
+ }
1141
+
1142
+ setGenBusy(true);
1143
+ setGenMsg(null);
1144
+ try {
1145
+ // The route accepts control_image on id_lock (optional composition blocking) AND
1146
+ // on motion (required, definitional) — `slots.controlImage` is that gate.
1147
+ const controlReady = (isIdLock || isMotion) && !!controlImage && controlKind !== "";
1148
+ const body: Record<string, unknown> = {
1149
+ capability,
1150
+ resolution: { width, height, fps },
1151
+ seed,
1152
+ vram_budget_gb: budgetNum,
1153
+ steps: stepsNum,
1154
+ cfg: cfgNum,
1155
+ model_id: modelId.trim() ? modelId.trim() : undefined,
1156
+ // Optional auto-archive NAME (choose-or-type). Blank = auto-named (default).
1157
+ project: project.trim() ? project.trim() : undefined,
1158
+ prompt: prompt.trim() ? prompt : undefined,
1159
+ // k89: the standard set is composed in when the card's tick is on — an
1160
+ // empty user negative + the tick still sends the standard exclusions.
1161
+ negative_prompt: (() => {
1162
+ const eff = composeNegative(negative, stdNegative);
1163
+ return eff !== "" ? eff : undefined;
1164
+ })(),
1165
+ // i2v start image (single first frame). t2v/v2v/id_lock drop it.
1166
+ start_image: isI2V && startImages[0] ? startImages[0].uri : undefined,
1167
+ // Source clip: i2v (extend) / v2v (restyle) only. Prefer the jail-resolvable
1168
+ // abs uri; fall back to the catalog asset id.
1169
+ source_video: (isI2V || isV2V) && source?.uri ? source.uri : undefined,
1170
+ source_asset_id:
1171
+ (isI2V || isV2V) && source && !source.uri ? source.asset_id : undefined,
1172
+ // IDENTITY (unified): a SAVED profile is the canonical identity — send its slug
1173
+ // (the route resolves it server-side; a later profile edit re-resolves). Raw
1174
+ // reference_images ride only for an UNSAVED identity (no profile bound).
1175
+ identity_profile:
1176
+ (isIdLock || isV2V) && selectedProfile ? selectedProfile.slug : undefined,
1177
+ // ORDERED reference images (the hash keys on order — preserve it). id_lock: the
1178
+ // identity to lock (required). v2v: an OPTIONAL identity held across the restyle
1179
+ // (scene-continuity). The route accepts reference_images on BOTH capabilities.
1180
+ reference_images:
1181
+ (isIdLock || isV2V) && !selectedProfile && referenceImages.length
1182
+ ? referenceImages.map((s) => s.ref.uri)
1183
+ : undefined,
1184
+ // id_lock optional composition control (both-or-neither; sent only when both set).
1185
+ control_image: controlReady ? (controlImage as MediaRef).uri : undefined,
1186
+ control_kind: controlReady ? controlKind : undefined,
1187
+ };
1188
+ const res = await request<unknown>(hugpyConfig.studioI2VEnqueueUrl, {
1189
+ method: "POST",
1190
+ headers: { "Content-Type": "application/json" },
1191
+ body: JSON.stringify(body),
1192
+ meta: { specKey: "studio", operation: "studio.i2v.enqueue" },
1193
+ });
1194
+ if (!mounted.current) return;
1195
+ if (!res.ok) {
1196
+ setGenMsg(`Enqueue failed: ${describeAppError(errorOf(res))}`);
1197
+ return;
1198
+ }
1199
+ // Subject-clip auto-stage (armed by onGenerateNewSubject): the FIRST successful
1200
+ // enqueue after "Generate new clip" arms the wait on ITS job id — later manual
1201
+ // generates (pendingSubjectStage already false by then) are plain renders.
1202
+ if (pendingSubjectStage) {
1203
+ setPendingSubjectStage(false);
1204
+ const enq = enqueueResultSchema.safeParse(okValue(res));
1205
+ if (enq.success) {
1206
+ setAwaitingSubjectJobId(enq.data.job_id);
1207
+ setGenMsg(
1208
+ "Enqueued — once this clip is produced it becomes the staged subject automatically.",
1209
+ );
1210
+ } else {
1211
+ setGenMsg(
1212
+ "Enqueued, but no job id came back to track — stage the produced clip as the subject manually (From library) once it's done.",
1213
+ );
1214
+ }
1215
+ } else {
1216
+ setGenMsg("Enqueued — once the clip is produced it plays in the viewer above and lands in the Session Library.");
1217
+ }
1218
+ onEnqueued();
1219
+ // A project name coined here should appear in the combobox next time.
1220
+ if (project.trim()) refreshProjects();
1221
+ } finally {
1222
+ if (mounted.current) setGenBusy(false);
1223
+ }
1224
+ }, [
1225
+ blockSubmit,
1226
+ v2vBadGeom,
1227
+ missingReason,
1228
+ capability,
1229
+ width,
1230
+ height,
1231
+ fps,
1232
+ seed,
1233
+ vramBudget,
1234
+ steps,
1235
+ cfg,
1236
+ modelId,
1237
+ prompt,
1238
+ negative,
1239
+ stdNegative,
1240
+ isI2V,
1241
+ isV2V,
1242
+ isIdLock,
1243
+ isMotion,
1244
+ startImages,
1245
+ source,
1246
+ referenceImages,
1247
+ selectedProfile,
1248
+ controlImage,
1249
+ controlKind,
1250
+ pendingSubjectStage,
1251
+ onEnqueued,
1252
+ project,
1253
+ refreshProjects,
1254
+ ]);
1255
+
1256
+ const synthPresets = presets.filter((p) => isSyntheticPreset(p));
1257
+ const realPresets = presets.filter((p) => !isSyntheticPreset(p));
1258
+ // The identity-lock family (id_lock TEMPLATES + the v2v scene-continuity template) gets
1259
+ // its OWN first-class group so the lock/continuity cards are not buried among the plain
1260
+ // t2v/i2v/v2v tiers.
1261
+ const lockPresets = realPresets.filter(
1262
+ (p) => p.capability === "id_lock" || p.id === "scene-continuity",
1263
+ );
1264
+ const generalPresets = realPresets.filter((p) => !lockPresets.includes(p));
1265
+ const sourceClips = clips.filter((c) => c.playable && c.output?.uri);
1266
+
1267
+ // The image-library thumbnail grid used below is now the SHARED
1268
+ // `LibraryImageGrid` (studioShared.tsx) — extracted out of this file's original
1269
+ // inline closure so the Movie composer's start-image switcher reuses the exact
1270
+ // same picker instead of a parallel implementation (operator ask 2026-07-12).
1271
+
1272
+ // A preset card (rendered as a <button> so `key` sits on an intrinsic element).
1273
+ function presetCard(p: StudioPreset) {
1274
+ const selected = p.id === presetId;
1275
+ return (
1276
+ <button
1277
+ key={p.id}
1278
+ type="button"
1279
+ className={selected ? "vi-btn vi-btn-accent" : "vi-btn vi-btn-ghost"}
1280
+ onClick={() => onPickPreset(p.id)}
1281
+ aria-pressed={selected}
1282
+ style={{
1283
+ display: "flex",
1284
+ flexDirection: "column",
1285
+ alignItems: "flex-start",
1286
+ gap: "0.2rem",
1287
+ textAlign: "left",
1288
+ padding: "0.55rem 0.7rem",
1289
+ flex: "1 1 15rem",
1290
+ minWidth: "13rem",
1291
+ maxWidth: "20rem",
1292
+ whiteSpace: "normal",
1293
+ height: "auto",
1294
+ }}
1295
+ >
1296
+ <span style={{ fontWeight: 600 }}>{p.name}</span>
1297
+ <span style={{ fontSize: "0.78rem", opacity: 0.85 }}>
1298
+ {p.capability}
1299
+ {typeof p.vram_budget_gb === "number" ? ` · ${p.vram_budget_gb} GB` : ""}
1300
+ {p.requires_source || p.requires_reference ? " · needs input" : ""}
1301
+ {p.recommended ? ` · ${p.recommended}` : ""}
1302
+ </span>
1303
+ {p.description ? (
1304
+ <span style={{ fontSize: "0.75rem", opacity: 0.7, lineHeight: 1.35 }}>
1305
+ {p.description}
1306
+ </span>
1307
+ ) : null}
1308
+ {p.prompt_note ? (
1309
+ <span style={{ fontSize: "0.72rem", fontStyle: "italic", opacity: 0.7 }}>
1310
+ ⓘ {p.prompt_note}
1311
+ </span>
1312
+ ) : null}
1313
+ </button>
1314
+ );
1315
+ }
1316
+
1317
+ // ── start-image input (variant-driven: hero = dominant/accented; inline = subdued) ──
1318
+ function renderStartImage(variant: "hero" | "inline") {
1319
+ const isHero = variant === "hero";
1320
+ const drawEye = isHero && startImageAttention;
1321
+ const thumb = isHero ? "9rem" : "5rem";
1322
+ return (
1323
+ <section aria-label="Start image" style={{ marginTop: isHero ? 0 : "0.9rem" }}>
1324
+ <p
1325
+ className="vi-comfy-label"
1326
+ style={{ marginBottom: "0.35rem", opacity: isHero ? 1 : 0.75 }}
1327
+ >
1328
+ Start image{isHero ? " — required for this generator" : " (optional)"}
1329
+ </p>
1330
+ {drawEye && (
1331
+ <p className="vi-input-callout" role="note">
1332
+ This generator needs a start image — the subject is carried from it. Drop one below
1333
+ (or extend a source clip instead).
1334
+ </p>
1335
+ )}
1336
+ {hasImage && (
1337
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginBottom: "0.5rem" }}>
1338
+ {startImages.map((img, idx) => (
1339
+ <div key={img.uri} style={{ display: "flex", flexDirection: "column", gap: "0.2rem" }}>
1340
+ <img
1341
+ src={mediaBytesUrl(img.uri)}
1342
+ alt="start"
1343
+ style={{
1344
+ width: thumb,
1345
+ height: thumb,
1346
+ objectFit: "cover",
1347
+ borderRadius: "0.4rem",
1348
+ background: "#000",
1349
+ border: "1px solid var(--vi-border)",
1350
+ }}
1351
+ />
1352
+ <button
1353
+ type="button"
1354
+ className="vi-btn vi-btn-sm vi-btn-ghost"
1355
+ onClick={() => removeStartImage(idx)}
1356
+ >
1357
+ Remove
1358
+ </button>
1359
+ </div>
1360
+ ))}
1361
+ </div>
1362
+ )}
1363
+ {canAddImage ? (
1364
+ isHero && !hasImage ? (
1365
+ <DropReceptacle
1366
+ onFile={(f) => void onPickImageFile(f, "start")}
1367
+ accept="image/*"
1368
+ busy={imageBusy}
1369
+ title="Drop a start image or click to browse"
1370
+ hint="This generator carries the subject from this still."
1371
+ className={drawEye ? "vi-dropzone--required" : undefined}
1372
+ />
1373
+ ) : (
1374
+ <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
1375
+ <label className="vi-btn vi-btn-accent vi-file-label">
1376
+ {imageBusy ? "Working…" : "Upload a still"}
1377
+ <input
1378
+ type="file"
1379
+ accept="image/*"
1380
+ className="vi-file-input"
1381
+ disabled={imageBusy}
1382
+ onChange={(e) => {
1383
+ const f = e.target.files?.[0];
1384
+ if (f) void onPickImageFile(f, "start");
1385
+ e.target.value = "";
1386
+ }}
1387
+ />
1388
+ </label>
1389
+ <button
1390
+ type="button"
1391
+ className="vi-btn vi-btn-ghost"
1392
+ onClick={() => setShowLibPicker((v) => !v)}
1393
+ aria-expanded={showLibPicker}
1394
+ >
1395
+ {showLibPicker ? "Hide library" : `Pick from library (${libraryImages.length})`}
1396
+ </button>
1397
+ </div>
1398
+ )
1399
+ ) : (
1400
+ <p className="vi-comfy-hint" role="note">
1401
+ Multi-image conditioning here is identity lock — switch capability to Identity lock to
1402
+ add 1–4 reference images.
1403
+ </p>
1404
+ )}
1405
+ {imageError && (
1406
+ <p className="vi-error" role="alert" style={{ marginTop: "0.4rem" }}>
1407
+ {imageError}
1408
+ </p>
1409
+ )}
1410
+ {showLibPicker && canAddImage && (
1411
+ <LibraryImageGrid images={libraryImages} onPick={(ref) => addStartImage(ref)} />
1412
+ )}
1413
+ </section>
1414
+ );
1415
+ }
1416
+
1417
+ // ── reference-image SLOT LIST: 1–4 ordered stills, reorderable. The SAME list feeds
1418
+ // two shapes — id_lock (variant "required": the identity to lock, accent-gated) and
1419
+ // v2v scene-continuity (variant "optional": an identity held across the restyle). ──
1420
+ function renderReferenceSlots(variant: "required" | "optional") {
1421
+ const required = variant === "required";
1422
+ const drawEye = required && referenceAttention;
1423
+ return (
1424
+ <section aria-label="Reference images" style={{ marginTop: 0 }}>
1425
+ <p
1426
+ className="vi-comfy-label"
1427
+ style={{ marginBottom: "0.35rem", opacity: required ? 1 : 0.75 }}
1428
+ >
1429
+ {required
1430
+ ? `Reference images — required (1–${MAX_REFERENCE_IMAGES}, order matters)`
1431
+ : `Hold an identity (optional) — 1–${MAX_REFERENCE_IMAGES} reference images, order matters`}
1432
+ </p>
1433
+ {required ? (
1434
+ drawEye && (
1435
+ <p className="vi-input-callout" role="note">
1436
+ {referenceCallout}
1437
+ </p>
1438
+ )
1439
+ ) : (
1440
+ <p className="vi-comfy-hint" role="note" style={{ marginTop: 0 }}>
1441
+ Optionally carry a subject&apos;s identity across the restyle — add 1–
1442
+ {MAX_REFERENCE_IMAGES} reference images (order matters). Leave empty for a plain restyle.
1443
+ </p>
1444
+ )}
1445
+ <div className={drawEye ? "vi-input-required" : undefined}>
1446
+ {hasReference && (
1447
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.6rem", marginBottom: "0.5rem" }}>
1448
+ {referenceImages.map((slot, idx) => (
1449
+ <div
1450
+ key={slot.key}
1451
+ style={{ display: "flex", flexDirection: "column", gap: "0.2rem", alignItems: "center" }}
1452
+ >
1453
+ <div style={{ position: "relative" }}>
1454
+ <img
1455
+ src={mediaBytesUrl(slot.ref.uri)}
1456
+ alt={`reference ${idx + 1}`}
1457
+ style={{
1458
+ width: "7rem",
1459
+ height: "7rem",
1460
+ objectFit: "cover",
1461
+ borderRadius: "0.4rem",
1462
+ background: "#000",
1463
+ border: "1px solid var(--vi-border)",
1464
+ }}
1465
+ />
1466
+ <span
1467
+ aria-hidden="true"
1468
+ style={{
1469
+ position: "absolute",
1470
+ top: 3,
1471
+ left: 3,
1472
+ background: "var(--vi-accent)",
1473
+ color: "#000",
1474
+ borderRadius: "0.3rem",
1475
+ padding: "0 0.4rem",
1476
+ fontSize: "0.72rem",
1477
+ fontWeight: 700,
1478
+ fontVariantNumeric: "tabular-nums",
1479
+ }}
1480
+ >
1481
+ {idx + 1}
1482
+ </span>
1483
+ </div>
1484
+ <div style={{ display: "flex", gap: "0.2rem" }}>
1485
+ <button
1486
+ type="button"
1487
+ className="vi-btn vi-btn-sm vi-btn-ghost"
1488
+ disabled={idx === 0}
1489
+ onClick={() => moveReferenceImage(idx, -1)}
1490
+ title="Move earlier"
1491
+ >
1492
+ ◀
1493
+ </button>
1494
+ <button
1495
+ type="button"
1496
+ className="vi-btn vi-btn-sm vi-btn-ghost"
1497
+ disabled={idx === referenceImages.length - 1}
1498
+ onClick={() => moveReferenceImage(idx, 1)}
1499
+ title="Move later"
1500
+ >
1501
+ ▶
1502
+ </button>
1503
+ <button
1504
+ type="button"
1505
+ className="vi-btn vi-btn-sm vi-btn-ghost"
1506
+ onClick={() => removeReferenceImage(idx)}
1507
+ title="Remove"
1508
+ >
1509
+ ✕
1510
+ </button>
1511
+ </div>
1512
+ </div>
1513
+ ))}
1514
+ </div>
1515
+ )}
1516
+ {canAddReference ? (
1517
+ <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
1518
+ <label className="vi-btn vi-btn-accent vi-file-label">
1519
+ {imageBusy ? "Working…" : hasReference ? "Add another reference" : "Upload a reference"}
1520
+ <input
1521
+ type="file"
1522
+ accept="image/*"
1523
+ className="vi-file-input"
1524
+ disabled={imageBusy}
1525
+ onChange={(e) => {
1526
+ const f = e.target.files?.[0];
1527
+ if (f) void onPickImageFile(f, "reference");
1528
+ e.target.value = "";
1529
+ }}
1530
+ />
1531
+ </label>
1532
+ <button
1533
+ type="button"
1534
+ className="vi-btn vi-btn-ghost"
1535
+ onClick={() => setShowLibPicker((v) => !v)}
1536
+ aria-expanded={showLibPicker}
1537
+ >
1538
+ {showLibPicker ? "Hide library" : `Pick from library (${libraryImages.length})`}
1539
+ </button>
1540
+ <span className="vi-comfy-hint" style={{ opacity: 0.7 }}>
1541
+ {referenceImages.length}/{MAX_REFERENCE_IMAGES}
1542
+ </span>
1543
+ </div>
1544
+ ) : (
1545
+ <p className="vi-comfy-hint" role="note">
1546
+ Maximum {MAX_REFERENCE_IMAGES} reference images.
1547
+ </p>
1548
+ )}
1549
+ {imageError && (
1550
+ <p className="vi-error" role="alert" style={{ marginTop: "0.4rem" }}>
1551
+ {imageError}
1552
+ </p>
1553
+ )}
1554
+ {showLibPicker && canAddReference && (
1555
+ <LibraryImageGrid images={libraryImages} onPick={(ref) => addReferenceImage(ref)} />
1556
+ )}
1557
+ {/* IDENTITY PROFILES (stage a): attach a saved identity profile's reference
1558
+ set, or save the current one — the durable form of this identity. */}
1559
+ <IdentityProfileControls
1560
+ refCount={referenceImages.length}
1561
+ currentRefUris={referenceImages.map((s) => s.ref.uri)}
1562
+ selectedProfile={selectedProfile}
1563
+ onAttach={attachIdentityRefs}
1564
+ onSelectProfile={setSelectedProfile}
1565
+ maxRefs={MAX_REFERENCE_IMAGES}
1566
+ disabled={imageBusy}
1567
+ />
1568
+ </div>
1569
+ </section>
1570
+ );
1571
+ }
1572
+
1573
+ // ── id_lock OPTIONAL composition control (subdued): a single pose/depth/sketch still. ──
1574
+ // 2026-07-29: the SAME block now serves the `motion` capability, where the control
1575
+ // is not optional decoration but the whole render — so it takes the hero treatment
1576
+ // (accent gating + the required copy) exactly like the reference slots do on id_lock.
1577
+ function renderControl(variant: "optional" | "required" = "optional") {
1578
+ const required = variant === "required";
1579
+ const drawEye = required && !hasControl;
1580
+ return (
1581
+ <section aria-label="Composition control" style={{ marginTop: required ? 0 : "0.9rem" }}>
1582
+ <p className="vi-comfy-label" style={{ opacity: required ? 1 : 0.75, marginBottom: "0.2rem" }}>
1583
+ {required
1584
+ ? "Control still — required (pose, depth or sketch)"
1585
+ : "Composition control (optional)"}
1586
+ </p>
1587
+ {/* Conditional required-input warning — KEPT (functional, k89), wording
1588
+ condensed; the general what-a-control-is prose lives in "About Clip". */}
1589
+ {drawEye && (
1590
+ <p className="vi-input-callout" role="note">
1591
+ This generator is driven BY the control still — a STATIC pose/depth/sketch
1592
+ anchor repeated across every frame. A source clip cannot ride along (the
1593
+ control channel takes one input and the clip would win).
1594
+ </p>
1595
+ )}
1596
+ <p className="vi-comfy-hint" role="note" style={{ marginTop: 0 }}>
1597
+ Both-or-neither: set the image and its kind together.
1598
+ </p>
1599
+ <div
1600
+ style={{ display: "flex", gap: "0.75rem", flexWrap: "wrap", alignItems: "flex-end", marginTop: "0.4rem" }}
1601
+ >
1602
+ {controlImage ? (
1603
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.2rem" }}>
1604
+ <img
1605
+ src={mediaBytesUrl(controlImage.uri)}
1606
+ alt="composition control"
1607
+ style={{
1608
+ width: "5.5rem",
1609
+ height: "5.5rem",
1610
+ objectFit: "cover",
1611
+ borderRadius: "0.4rem",
1612
+ background: "#000",
1613
+ border: "1px solid var(--vi-border)",
1614
+ }}
1615
+ />
1616
+ <button
1617
+ type="button"
1618
+ className="vi-btn vi-btn-sm vi-btn-ghost"
1619
+ onClick={() => setControlImage(null)}
1620
+ >
1621
+ Remove
1622
+ </button>
1623
+ </div>
1624
+ ) : (
1625
+ <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
1626
+ <label className="vi-btn vi-btn-ghost vi-file-label">
1627
+ {imageBusy ? "Working…" : "Upload control image"}
1628
+ <input
1629
+ type="file"
1630
+ accept="image/*"
1631
+ className="vi-file-input"
1632
+ disabled={imageBusy}
1633
+ onChange={(e) => {
1634
+ const f = e.target.files?.[0];
1635
+ if (f) void onPickImageFile(f, "control");
1636
+ e.target.value = "";
1637
+ }}
1638
+ />
1639
+ </label>
1640
+ <button
1641
+ type="button"
1642
+ className="vi-btn vi-btn-ghost"
1643
+ onClick={() => setShowControlLib((v) => !v)}
1644
+ aria-expanded={showControlLib}
1645
+ >
1646
+ {showControlLib ? "Hide library" : "Pick from library"}
1647
+ </button>
1648
+ </div>
1649
+ )}
1650
+ <label className="vi-comfy-field" style={{ maxWidth: "10rem" }}>
1651
+ <span className="vi-comfy-label">Control kind</span>
1652
+ <select
1653
+ className="vi-knob-input"
1654
+ value={controlKind}
1655
+ onChange={(e) => setControlKind(e.target.value as ControlKind)}
1656
+ >
1657
+ <option value="">— none —</option>
1658
+ <option value="pose">pose</option>
1659
+ <option value="depth">depth</option>
1660
+ <option value="sketch">sketch</option>
1661
+ </select>
1662
+ </label>
1663
+ </div>
1664
+ {controlHalfSet && (
1665
+ <p className="vi-comfy-hint" role="alert" style={{ marginTop: "0.3rem" }}>
1666
+ Composition control is both-or-neither — add a control image AND a kind, or clear both.
1667
+ </p>
1668
+ )}
1669
+ {showControlLib && !controlImage && (
1670
+ <LibraryImageGrid
1671
+ images={libraryImages}
1672
+ onPick={(ref) => {
1673
+ setControlImage(ref);
1674
+ setShowControlLib(false);
1675
+ }}
1676
+ />
1677
+ )}
1678
+ </section>
1679
+ );
1680
+ }
1681
+
1682
+ // ── source-clip input (hero = dominant/accented; inline = subdued; inline-alt =
1683
+ // the "or extend a clip" alternative under a hero start image) ──
1684
+ function renderSource(variant: "hero" | "inline" | "inline-alt") {
1685
+ const isHero = variant === "hero";
1686
+ const isAlt = variant === "inline-alt";
1687
+ const drawEye = isHero && sourceAttention;
1688
+ const label = isAlt
1689
+ ? "…or use a source clip (extend from its last frame)"
1690
+ : isHero
1691
+ ? "Source clip — required (the clip to repaint)"
1692
+ : isV2V
1693
+ ? "Source clip — required"
1694
+ : "Source clip (optional — extend from its last frame)";
1695
+ return (
1696
+ <section aria-label="Source clip" style={{ marginTop: isHero ? 0 : "0.9rem" }}>
1697
+ <p
1698
+ className="vi-comfy-label"
1699
+ style={{ marginBottom: "0.35rem", opacity: isHero ? 1 : 0.75 }}
1700
+ >
1701
+ {label}
1702
+ </p>
1703
+ {drawEye && (
1704
+ <p className="vi-input-callout" role="note">
1705
+ This generator needs a source clip to repaint — pick one below (or send a clip to the
1706
+ studio from the Library / Movie Maker).
1707
+ </p>
1708
+ )}
1709
+ <div className={drawEye ? "vi-input-required" : undefined}>
1710
+ {source ? (
1711
+ <>
1712
+ <div className="vi-studio-src-selected">
1713
+ <SourceThumb
1714
+ uri={source.uri}
1715
+ name={source.asset_id.slice(0, 8)}
1716
+ playing={sourcePreview}
1717
+ onToggle={() => setSourcePreview((v) => !v)}
1718
+ />
1719
+ <p className="vi-comfy-hint" role="note" style={{ margin: 0 }}>
1720
+ <strong>{isV2V ? "Restyling" : "Extending"}</strong>{" "}
1721
+ <code>{source.asset_id.slice(0, 8)}</code>
1722
+ {source.width != null && source.height != null
1723
+ ? ` · ${source.width}×${source.height}`
1724
+ : ""}
1725
+ {source.duration_s != null ? ` · ${Math.round(source.duration_s)}s` : ""}
1726
+ </p>
1727
+ </div>
1728
+ {/* Subject-clip switcher (operator ask 2026-07-12): "there isn't a way to
1729
+ actually switch it out" — this row is the ONE concrete front door to
1730
+ all three swap paths the operator named. It CONSOLIDATES the 2026-07-10
1731
+ "Change" button (clear + reopen the library picker, kept as "From
1732
+ library" below — removed from here, not duplicated) plus the two new
1733
+ paths, alongside the plain unstage. */}
1734
+ <div className="vi-studio-src-actions" role="group" aria-label="Change subject clip">
1735
+ <button
1736
+ type="button"
1737
+ className="vi-btn vi-btn-sm"
1738
+ onClick={() => {
1739
+ setSource(null);
1740
+ setSourcePreview(false);
1741
+ setShowSourcePicker(true);
1742
+ }}
1743
+ >
1744
+ ⇄ From library
1745
+ </button>
1746
+ <label className="vi-btn vi-btn-sm vi-file-label">
1747
+ {imageBusy ? "Working…" : "⇪ Upload video"}
1748
+ <input
1749
+ type="file"
1750
+ accept="video/*"
1751
+ className="vi-file-input"
1752
+ disabled={imageBusy}
1753
+ onChange={(e) => {
1754
+ const f = e.target.files?.[0];
1755
+ if (f) void onPickVideoFile(f);
1756
+ e.target.value = "";
1757
+ }}
1758
+ />
1759
+ </label>
1760
+ <button type="button" className="vi-btn vi-btn-sm" onClick={onGenerateNewSubject}>
1761
+ ✦ Generate new
1762
+ </button>
1763
+ <button
1764
+ type="button"
1765
+ className="vi-btn vi-btn-sm vi-btn-ghost"
1766
+ onClick={() => {
1767
+ setSource(null);
1768
+ setSourcePreview(false);
1769
+ }}
1770
+ >
1771
+ ✕ Clear
1772
+ </button>
1773
+ </div>
1774
+ </>
1775
+ ) : (
1776
+ <div className="vi-studio-src-actions" role="group" aria-label="Add subject clip">
1777
+ <button
1778
+ type="button"
1779
+ className={isHero ? "vi-btn vi-btn-accent" : "vi-btn vi-btn-ghost"}
1780
+ onClick={() => setShowSourcePicker((v) => !v)}
1781
+ aria-expanded={showSourcePicker}
1782
+ >
1783
+ {showSourcePicker ? "Hide clips" : `⇄ From library (${sourceClips.length})`}
1784
+ </button>
1785
+ <label
1786
+ className={`${isHero ? "vi-btn vi-btn-accent" : "vi-btn vi-btn-ghost"} vi-file-label`}
1787
+ >
1788
+ {imageBusy ? "Working…" : "⇪ Upload video"}
1789
+ <input
1790
+ type="file"
1791
+ accept="video/*"
1792
+ className="vi-file-input"
1793
+ disabled={imageBusy}
1794
+ onChange={(e) => {
1795
+ const f = e.target.files?.[0];
1796
+ if (f) void onPickVideoFile(f);
1797
+ e.target.value = "";
1798
+ }}
1799
+ />
1800
+ </label>
1801
+ <button type="button" className="vi-btn vi-btn-ghost" onClick={onGenerateNewSubject}>
1802
+ ✦ Generate new clip
1803
+ </button>
1804
+ </div>
1805
+ )}
1806
+ {imageError && (
1807
+ <p className="vi-error" role="alert" style={{ marginTop: "0.4rem" }}>
1808
+ {imageError}
1809
+ </p>
1810
+ )}
1811
+ {showSourcePicker && !source && (
1812
+ <ul
1813
+ style={{
1814
+ listStyle: "none",
1815
+ margin: "0.5rem 0 0",
1816
+ padding: 0,
1817
+ maxHeight: "14rem",
1818
+ overflowY: "auto",
1819
+ }}
1820
+ >
1821
+ {sourceClips.length === 0 ? (
1822
+ <li className="vi-comfy-hint">No playable studio clips yet.</li>
1823
+ ) : (
1824
+ sourceClips.map((c) => {
1825
+ // Restructured row (mirrors the Library's LibraryRow): a poster/preview
1826
+ // thumbnail + metadata + a DISTINCT select affordance. The row is no
1827
+ // longer one big <button> — a playable <video> can't nest in a button.
1828
+ const ref = clipToMediaRef(c);
1829
+ const w = c.output?.width;
1830
+ const h = c.output?.height;
1831
+ const dur = c.output?.duration_s;
1832
+ const isPreviewing = previewId === c.job_id;
1833
+ const togglePreview = () =>
1834
+ setPreviewId((cur) => (cur === c.job_id ? null : c.job_id));
1835
+ return (
1836
+ <li key={c.job_id} className="vi-studio-src-row">
1837
+ {ref ? (
1838
+ <SourceThumb
1839
+ uri={ref.uri}
1840
+ name={shortId(c.job_id)}
1841
+ playing={isPreviewing}
1842
+ onToggle={togglePreview}
1843
+ />
1844
+ ) : (
1845
+ <div className="vi-studio-src-thumb vi-lib-placeholder" aria-hidden>
1846
+ <span>clip</span>
1847
+ </div>
1848
+ )}
1849
+ <div className="vi-studio-src-body">
1850
+ <div className="vi-studio-src-meta" title={c.job_id}>
1851
+ <code>{shortId(c.job_id)}</code>
1852
+ {w != null && h != null ? (
1853
+ <span>
1854
+ {w}×{h}
1855
+ </span>
1856
+ ) : null}
1857
+ {dur != null ? <span>{Math.round(dur)}s</span> : null}
1858
+ </div>
1859
+ <div className="vi-studio-src-actions">
1860
+ <button
1861
+ type="button"
1862
+ className="vi-btn vi-btn-sm vi-btn-ghost"
1863
+ onClick={togglePreview}
1864
+ disabled={!ref}
1865
+ aria-pressed={isPreviewing}
1866
+ >
1867
+ {isPreviewing ? "◾ Stop" : "▶ Preview"}
1868
+ </button>
1869
+ <button
1870
+ type="button"
1871
+ className="vi-btn vi-btn-sm vi-btn-accent"
1872
+ onClick={() => {
1873
+ if (ref) setSource(ref);
1874
+ setShowSourcePicker(false);
1875
+ setPreviewId(null);
1876
+ // A manual library pick takes back control from any
1877
+ // armed/in-flight "generate new subject" wait.
1878
+ setPendingSubjectStage(false);
1879
+ setAwaitingSubjectJobId(null);
1880
+ }}
1881
+ disabled={!ref}
1882
+ title={
1883
+ ref
1884
+ ? isV2V
1885
+ ? "Use this clip as the restyle source"
1886
+ : "Extend from this clip's last frame"
1887
+ : "This clip has no streamable output yet"
1888
+ }
1889
+ >
1890
+ {isV2V ? "Use · restyle" : "Use · extend"}
1891
+ </button>
1892
+ </div>
1893
+ </div>
1894
+ </li>
1895
+ );
1896
+ })
1897
+ )}
1898
+ </ul>
1899
+ )}
1900
+ </div>
1901
+ </section>
1902
+ );
1903
+ }
1904
+
1905
+ // Round 8: the Movie .vi-knob grid renders into the LEFT-SIDEBAR "Settings" tab (via a
1906
+ // studio-local portal into `settingsHost`), NOT the center — folding the former inline
1907
+ // knob column into the sidebar (movie-shape canonical). Knob STATE stays in this
1908
+ // component; the portal only relocates where the inputs render.
1909
+ // ── PRESET HONESTY (2026-07-29): a preset offered = a preset whose capability is
1910
+ // servable on this fleet, and an UNPROVEN capability says so on the row.
1911
+ //
1912
+ // The curated studio presets (GET /video/studio/presets) are INTENT; the ratified
1913
+ // render table (GET /video/render/presets) is MEASUREMENT. Joining them per capability
1914
+ // is what turns the dropdown from "eight things we wrote down" into "the things that
1915
+ // will come back as pixels, and which of them have". Nothing is hidden: an option that
1916
+ // cannot be picked right now renders DISABLED carrying the backend's own reason, so
1917
+ // the fleet's shape stays visible instead of silently shrinking the menu.
1918
+ function presetOption(p: StudioPreset) {
1919
+ const o = renderLoaded ? offerFor(p.capability) : undefined;
1920
+ const unproven = o?.servable === true && o.proven === false;
1921
+ const label = `${p.name}${unproven ? " · unproven" : ""}`;
1922
+ const disabled = !!o && !o.offered;
1923
+ return (
1924
+ <option key={p.id} value={p.id} disabled={disabled} title={o?.reason || p.description || ""}>
1925
+ {label}
1926
+ {disabled ? " — unavailable" : ""}
1927
+ </option>
1928
+ );
1929
+ }
1930
+
1931
+ const knobGrid = (
1932
+ <div className="vi-studio-knobs">
1933
+ {/* ABOUT — the condensed what-is-this / how-to prose (k89), collapsed at the
1934
+ top of the Settings-tab content. Absorbs the descriptive halves of the old
1935
+ inline callouts; the conditional required-input warnings stay on the form. */}
1936
+ <div className="vi-studio-knob-wide" style={{ flex: "1 1 100%" }}>
1937
+ <AboutExpander title="About Clip">
1938
+ <p>
1939
+ Generates ONE studio clip from a prompt plus whatever inputs the chosen
1940
+ generator needs.
1941
+ </p>
1942
+ <p>
1943
+ Pick a template or capability here and the form morphs to that shape:
1944
+ text→video needs nothing, image→video a start image (or a source clip to
1945
+ extend), restyle a source clip, identity lock 1–4 reference images, and
1946
+ pose/structure a control still.
1947
+ </p>
1948
+ <p>
1949
+ A missing required input is flagged on the card and gates Generate until
1950
+ you add it.
1951
+ </p>
1952
+ <p>
1953
+ Finished clips play in the viewer above and land in the Session Library;
1954
+ the Tester at the bottom sweeps one prompt across every model.
1955
+ </p>
1956
+ </AboutExpander>
1957
+ </div>
1958
+ {/* TEMPLATE dropdown — the tier chooser, mirroring Movie's right-rail PRESET
1959
+ dropdown. Picking one = clicking a tier card: sets capability/shape + prefills
1960
+ the knobs below. (The center cards are retired — this is the one chooser.) */}
1961
+ <div className="vi-knob vi-studio-knob-wide">
1962
+ <label htmlFor="vi-studio-template">Template</label>
1963
+ <select
1964
+ id="vi-studio-template"
1965
+ className="vi-knob-select"
1966
+ value={presetId}
1967
+ onChange={(e) => (e.target.value ? onPickPreset(e.target.value) : setPresetId(""))}
1968
+ >
1969
+ <option value="">Custom — no template</option>
1970
+ {generalPresets.length > 0 && (
1971
+ <optgroup label="Tiers">{generalPresets.map((p) => presetOption(p))}</optgroup>
1972
+ )}
1973
+ {lockPresets.length > 0 && (
1974
+ <optgroup label="Identity lock & continuity">
1975
+ {lockPresets.map((p) => presetOption(p))}
1976
+ </optgroup>
1977
+ )}
1978
+ {synthPresets.length > 0 && (
1979
+ <optgroup label="Synthetic previews">{synthPresets.map((p) => presetOption(p))}</optgroup>
1980
+ )}
1981
+ </select>
1982
+ <span className="vi-knob-hint">
1983
+ A template sets the WORKFLOW — capability, geometry, cadence, budget — and slots
1984
+ it around what you have already written and attached. Your prompt, negative,
1985
+ identity, images and any seed you chose are never overwritten; an empty prompt
1986
+ box is the only thing a template&apos;s example text fills.
1987
+ {renderLoaded ? " Rows marked “unproven” have a ratified path that has not yet produced a clip on this fleet." : ""}
1988
+ </span>
1989
+ </div>
1990
+ {/* PROJECT — choose an existing name or free-type a new one (Movie's "PROJECT NAME
1991
+ (OPTIONAL)" idiom). Threaded into the enqueue as `project`; blank = auto-named. */}
1992
+ <div className="vi-knob vi-studio-knob-wide">
1993
+ <label htmlFor="vi-studio-project">Project name (optional)</label>
1994
+ <input
1995
+ id="vi-studio-project"
1996
+ className="vi-knob-input"
1997
+ type="text"
1998
+ list="vi-studio-project-list"
1999
+ value={project}
2000
+ placeholder="auto-named — or pick / type a project"
2001
+ onChange={(e) => setProject(e.target.value)}
2002
+ />
2003
+ <datalist id="vi-studio-project-list">
2004
+ {knownProjects.map((name) => (
2005
+ <option key={name} value={name} />
2006
+ ))}
2007
+ </datalist>
2008
+ <span className="vi-knob-hint">
2009
+ Choose an existing project or type a new name to create it. Blank keeps the
2010
+ auto-named default.
2011
+ </span>
2012
+ </div>
2013
+ <div className="vi-knob vi-studio-knob-wide">
2014
+ <label htmlFor="vi-studio-cap">Capability</label>
2015
+ <select
2016
+ id="vi-studio-cap"
2017
+ className="vi-knob-select"
2018
+ value={capability}
2019
+ onChange={(e) => onPickCapability(e.target.value as Capability)}
2020
+ >
2021
+ {/* SOURCED FROM THE SERVABLE SET (2026-07-29), not from a hand-curated union.
2022
+ Every row here is a capability at least one RATIFIED preset covers; a row
2023
+ that the fleet cannot serve, or that the inputs you have already attached
2024
+ rule out, renders DISABLED with the backend's own reason as its tooltip
2025
+ rather than vanishing — the same disabled-with-honest-title idiom the
2026
+ movie start switcher uses for "start from a clip".
2027
+
2028
+ id_lock stays non-selectable-by-hand: it is set by an identity-lock
2029
+ TEMPLATE or by attaching a profile/references. It must still RENDER while
2030
+ active — this is a controlled <select>, and a value with no matching
2031
+ <option> shows blank. */}
2032
+ {capOffers.map((o) => {
2033
+ const viaTemplate = o.capability === "id_lock";
2034
+ const disabled = !o.offered || (viaTemplate && !isIdLock);
2035
+ if (viaTemplate && !isIdLock && !o.servable) return null;
2036
+ const suffix = !o.servable
2037
+ ? " — not on this fleet"
2038
+ : viaTemplate
2039
+ ? " — via template"
2040
+ : !o.offered
2041
+ ? " — incompatible with your inputs"
2042
+ : o.proven === false
2043
+ ? " · unproven"
2044
+ : "";
2045
+ return (
2046
+ <option
2047
+ key={o.capability}
2048
+ value={o.capability}
2049
+ disabled={disabled}
2050
+ title={o.reason || undefined}
2051
+ >
2052
+ {o.label}
2053
+ {suffix}
2054
+ </option>
2055
+ );
2056
+ })}
2057
+ {/* THE OTHER HALF OF THE ANSWER, and the reason /video/render/presets publishes
2058
+ it: every capability the enum declares that NO ratified preset covers, with
2059
+ the MEASURED blocker as its tooltip. Shown disabled rather than omitted so a
2060
+ user learns "studio cannot render 'keyframe' on this fleet: … no end-frame
2061
+ input exists in the spine" at DISCOVERY time instead of after a render. The
2062
+ wording is the registry's, verbatim. */}
2063
+ {renderUnavailable.length > 0 && (
2064
+ <optgroup label="Not renderable on this fleet">
2065
+ {renderUnavailable.map((u) => (
2066
+ <option
2067
+ key={u.capability}
2068
+ value={u.capability}
2069
+ disabled
2070
+ title={u.refusal || u.reason || undefined}
2071
+ >
2072
+ {capabilityLabel(u.capability)} — {u.reason ? u.reason.slice(0, 90) : "no preset covers it"}
2073
+ </option>
2074
+ ))}
2075
+ </optgroup>
2076
+ )}
2077
+ </select>
2078
+ <span className="vi-knob-hint">
2079
+ {renderLoaded
2080
+ ? `What this fleet can render today${renderBox ? ` on ${renderBox}` : ""}. Greyed-out rows say why — hover for the measured reason.`
2081
+ : "Loading what this fleet can render…"}
2082
+ </span>
2083
+ </div>
2084
+ <div className="vi-knob">
2085
+ <label htmlFor="vi-studio-w">width (px)</label>
2086
+ <input
2087
+ id="vi-studio-w"
2088
+ className="vi-knob-input"
2089
+ type="number"
2090
+ min={64}
2091
+ step={8}
2092
+ value={width}
2093
+ disabled={isV2V}
2094
+ title={isV2V ? geomLockTitle : geomEnvelopeTitle}
2095
+ onChange={(e) => {
2096
+ geomTouched.current = true;
2097
+ setWidth(Number(e.target.value) || 0);
2098
+ }}
2099
+ />
2100
+ </div>
2101
+ <div className="vi-knob">
2102
+ <label htmlFor="vi-studio-h">height (px)</label>
2103
+ <input
2104
+ id="vi-studio-h"
2105
+ className="vi-knob-input"
2106
+ type="number"
2107
+ min={64}
2108
+ step={8}
2109
+ value={height}
2110
+ disabled={isV2V}
2111
+ title={isV2V ? geomLockTitle : geomEnvelopeTitle}
2112
+ onChange={(e) => {
2113
+ geomTouched.current = true;
2114
+ setHeight(Number(e.target.value) || 0);
2115
+ }}
2116
+ />
2117
+ </div>
2118
+ <div className="vi-knob">
2119
+ <label htmlFor="vi-studio-fps">fps</label>
2120
+ <input
2121
+ id="vi-studio-fps"
2122
+ className="vi-knob-input"
2123
+ type="number"
2124
+ min={1}
2125
+ max={60}
2126
+ value={fps}
2127
+ disabled={isV2V}
2128
+ title={isV2V ? geomLockTitle : geomEnvelopeTitle}
2129
+ onChange={(e) => {
2130
+ geomTouched.current = true;
2131
+ setFps(Number(e.target.value) || 0);
2132
+ }}
2133
+ />
2134
+ {geomOutOfEnvelope && !isV2V && (
2135
+ <span className="vi-knob-flag">
2136
+ Outside the ratified envelope{capGeometry ? ` (${capGeometry.width}×${capGeometry.height})` : ""} —
2137
+ this geometry has no proven binding for {CAP_LABELS[capability]} and the router may refuse it.
2138
+ </span>
2139
+ )}
2140
+ </div>
2141
+ <div className="vi-knob">
2142
+ <label htmlFor="vi-studio-seed">seed</label>
2143
+ <input
2144
+ id="vi-studio-seed"
2145
+ className="vi-knob-input"
2146
+ type="number"
2147
+ min={0}
2148
+ value={seed}
2149
+ onChange={(e) => setSeed(Number(e.target.value) || 0)}
2150
+ />
2151
+ </div>
2152
+ <div className="vi-knob">
2153
+ <label htmlFor="vi-studio-vram">vram budget (GB)</label>
2154
+ <input
2155
+ id="vi-studio-vram"
2156
+ className="vi-knob-input"
2157
+ type="number"
2158
+ min={0}
2159
+ step={0.5}
2160
+ value={vramBudget}
2161
+ placeholder="route default"
2162
+ onChange={(e) => {
2163
+ // Marks the field OPERATOR-OWNED: from here on a preset or capability change
2164
+ // fills nothing over it. Clearing the field hands it back to the presets.
2165
+ budgetTouched.current = e.target.value.trim() !== "";
2166
+ setVramBudget(e.target.value);
2167
+ }}
2168
+ />
2169
+ <span className="vi-knob-hint">
2170
+ {capFloorGb != null
2171
+ ? `Blank = autofit to the card. ${CAP_LABELS[capability]} needs at least ${capFloorGb} GB to bind a real model — measured, not guessed.`
2172
+ : "Blank = route default (sub-real binds synthetic)."}
2173
+ </span>
2174
+ </div>
2175
+ <div className="vi-knob">
2176
+ <label htmlFor="vi-studio-steps">steps</label>
2177
+ <input
2178
+ id="vi-studio-steps"
2179
+ className="vi-knob-input"
2180
+ type="number"
2181
+ min={1}
2182
+ max={100}
2183
+ value={steps}
2184
+ placeholder="model default"
2185
+ onChange={(e) => setSteps(e.target.value)}
2186
+ />
2187
+ </div>
2188
+ <div className="vi-knob">
2189
+ <label htmlFor="vi-studio-cfg">cfg</label>
2190
+ <input
2191
+ id="vi-studio-cfg"
2192
+ className="vi-knob-input"
2193
+ type="number"
2194
+ min={0}
2195
+ max={20}
2196
+ step={0.5}
2197
+ value={cfg}
2198
+ placeholder="model default"
2199
+ onChange={(e) => setCfg(e.target.value)}
2200
+ />
2201
+ </div>
2202
+ {/* MODEL PIN — FILTERED BY CAPABILITY (2026-07-29).
2203
+ It used to offer STUDIO_MODEL_IDS: a static, unfiltered mirror of a backend seed
2204
+ file. Nothing in it knew which models can serve the capability you had chosen,
2205
+ so pinning wan2.1-t2v-1.3b under capability=v2v was a first-class menu choice
2206
+ that could only fail at render time. The menu is now the models that appear in a
2207
+ RATIFIED preset for the active capability.
2208
+
2209
+ THE ESCAPE HATCH IS DELIBERATE. "Show every studio model" keeps the whole list
2210
+ reachable — an operator may legitimately want to pin off-menu (to prove a new
2211
+ binding, or to reproduce an old job) — and every off-menu row is LABELLED
2212
+ unverified rather than quietly presented as equivalent. An unroutable pin still
2213
+ comes back as errors-as-data on the job; this only changes what we recommend. */}
2214
+ <div className="vi-knob vi-studio-knob-wide">
2215
+ <label htmlFor="vi-studio-model">model pin</label>
2216
+ <select
2217
+ id="vi-studio-model"
2218
+ className="vi-knob-select"
2219
+ value={modelId}
2220
+ onChange={(e) => setModelId(e.target.value)}
2221
+ >
2222
+ <option value="">Auto — router picks by budget + resolution</option>
2223
+ {capModels.length > 0 && (
2224
+ <optgroup label={`Ratified for ${CAP_LABELS[capability]}`}>
2225
+ {capModels.map((id) => (
2226
+ <option key={id} value={id}>
2227
+ {id}
2228
+ </option>
2229
+ ))}
2230
+ </optgroup>
2231
+ )}
2232
+ {(showAllModels || !renderLoaded || modelOffMenu) && (
2233
+ <optgroup label={renderLoaded ? "Other studio models — unverified for this capability" : "Studio models"}>
2234
+ {STUDIO_MODEL_IDS.filter((id) => !capModels.includes(id)).map((id) => (
2235
+ <option key={id} value={id}>
2236
+ {id}
2237
+ {renderLoaded ? " · unverified" : ""}
2238
+ </option>
2239
+ ))}
2240
+ </optgroup>
2241
+ )}
2242
+ </select>
2243
+ {renderLoaded && (
2244
+ <label className="vi-knob-hint" style={{ display: "flex", gap: "0.35rem", alignItems: "center" }}>
2245
+ <input
2246
+ type="checkbox"
2247
+ checked={showAllModels}
2248
+ onChange={(e) => setShowAllModels(e.target.checked)}
2249
+ />
2250
+ Advanced — show every studio model (off-menu pins are unverified for this capability)
2251
+ </label>
2252
+ )}
2253
+ {modelOffMenu && (
2254
+ <span className="vi-knob-flag">
2255
+ {modelId} has no ratified preset for {CAP_LABELS[capability]} — the router may
2256
+ refuse the pin, and it will say so on the job rather than fall back silently.
2257
+ </span>
2258
+ )}
2259
+ </div>
2260
+ </div>
2261
+ );
2262
+
2263
+ return (
2264
+ <div className="vi-studio-generate">
2265
+ {/* B2 mode switch: Clip (existing surface, default, unchanged) | Cinema (movie).
2266
+ Rendered ONLY on the standalone /studio-clips mount (no lockedSurfaceMode). When
2267
+ the top-level Generate tab locks the choice (Clip / Cinema tabs), this switcher is
2268
+ hidden — the tab IS the choice. The "movie" surfaceMode value is unchanged (label
2269
+ reads "Cinema"; the wire/tester value stays "movie"). */}
2270
+ {!lockedSurfaceMode && (
2271
+ <div
2272
+ role="tablist"
2273
+ aria-label="Studio generate mode"
2274
+ style={{ display: "flex", gap: "0.4rem", marginBottom: "0.7rem" }}
2275
+ >
2276
+ <button
2277
+ type="button"
2278
+ role="tab"
2279
+ aria-selected={surfaceMode === "clip"}
2280
+ className={surfaceMode === "clip" ? "vi-btn vi-btn-accent" : "vi-btn vi-btn-ghost"}
2281
+ onClick={() => setInternalSurfaceMode("clip")}
2282
+ >
2283
+ Clip
2284
+ </button>
2285
+ <button
2286
+ type="button"
2287
+ role="tab"
2288
+ aria-selected={surfaceMode === "movie"}
2289
+ className={surfaceMode === "movie" ? "vi-btn vi-btn-accent" : "vi-btn vi-btn-ghost"}
2290
+ onClick={() => setInternalSurfaceMode("movie")}
2291
+ >
2292
+ Cinema
2293
+ </button>
2294
+ </div>
2295
+ )}
2296
+ {surfaceMode === "movie" ? (
2297
+ /* Movie mode portals its own "Movie templates" dropdown into the SAME Settings-tab
2298
+ host the Clip knobGrid uses (the composer owns that portal).
2299
+ NB no "Test all models" here: Cinema is the multi-GOAL composer (its prompt
2300
+ lives per-goal inside StudioMovieComposer, not in this surface's single
2301
+ `prompt`), so a single-prompt sweep doesn't map. The top-level Movie tab
2302
+ already offers a movie-model sweep from one prompt. */
2303
+ <StudioMovieComposer settingsHost={settingsHost} />
2304
+ ) : (
2305
+ <>
2306
+ {/* Round 8: the knob grid renders in the left-sidebar Settings tab (portaled into
2307
+ settingsHost); null until the sidebar mounts its Settings panel. */}
2308
+ {settingsHost ? createPortal(knobGrid, settingsHost) : null}
2309
+ {/* Subject-clip "generate new" note — ALWAYS visible (unlike renderSource, which
2310
+ stops rendering entirely once onGenerateNewSubject forces the t2v shape), so
2311
+ the "what's happening" signal survives that shape change. Clears itself the
2312
+ moment the wait resolves (success, failure, or a manual override elsewhere). */}
2313
+ {(pendingSubjectStage || awaitingSubjectJobId) && (
2314
+ <p className="vi-input-callout" role="note">
2315
+ {awaitingSubjectJobId
2316
+ ? "Waiting for the new clip to finish rendering — it becomes the staged subject automatically."
2317
+ : "No subject staged. Write a prompt and press Generate below — the clip it produces becomes the staged subject."}
2318
+ </p>
2319
+ )}
2320
+ {/* ── 1) TEMPLATE/TIER CARDS — RETIRED from the centre (Round 9): template
2321
+ selection now lives in the Settings-tab dropdown (mirrors Movie's PRESET
2322
+ dropdown). The card grid is kept below, ARCHIVED + never rendered (`false &&`),
2323
+ per the no-delete house rule. ── */}
2324
+ {false && (
2325
+ <section aria-label="Studio templates" className="vi-studio-templates">
2326
+ <p className="vi-knob-headline">
2327
+ Choose a template{" "}
2328
+ <span className="vi-knob-headline-sub">
2329
+ the tier you pick is the mode — it sets the shape and prefills the knobs below
2330
+ </span>
2331
+ </p>
2332
+ {presetsLoading ? (
2333
+ <p className="vi-knob-hint">Loading templates…</p>
2334
+ ) : generalPresets.length === 0 ? (
2335
+ <p className="vi-knob-hint">No real tiers registered.</p>
2336
+ ) : (
2337
+ <div className="vi-studio-card-grid">{generalPresets.map((p) => presetCard(p))}</div>
2338
+ )}
2339
+
2340
+ {lockPresets.length > 0 && (
2341
+ <>
2342
+ <p className="vi-knob-subhead">
2343
+ Identity lock &amp; scene continuity{" "}
2344
+ <span className="vi-knob-headline-sub">
2345
+ — lock a person / thing / place to reference images, or continue a prior scene
2346
+ </span>
2347
+ </p>
2348
+ <div className="vi-studio-card-grid">{lockPresets.map((p) => presetCard(p))}</div>
2349
+ </>
2350
+ )}
2351
+
2352
+ {synthPresets.length > 0 && (
2353
+ <>
2354
+ <p className="vi-knob-subhead">
2355
+ Synthetic previews{" "}
2356
+ <span className="vi-knob-headline-sub">
2357
+ — no GPU; the prompt is recorded, not rendered
2358
+ </span>
2359
+ </p>
2360
+ <div className="vi-studio-card-grid">{synthPresets.map((p) => presetCard(p))}</div>
2361
+ </>
2362
+ )}
2363
+ {presetId && (
2364
+ <button
2365
+ type="button"
2366
+ className="vi-btn vi-btn-sm vi-btn-ghost"
2367
+ onClick={() => setPresetId("")}
2368
+ style={{ marginTop: "0.5rem" }}
2369
+ >
2370
+ Clear template (custom)
2371
+ </button>
2372
+ )}
2373
+ </section>
2374
+ )}
2375
+
2376
+ {/* ── 2) COMPOSER — prompt + negative, in the CENTER. Round 8: the Movie knob
2377
+ grid moved to the left-sidebar Settings tab (portaled above), so the
2378
+ center is cards → composer → shape heroes → Run (movie-shape canonical). ── */}
2379
+ <section aria-label="Prompt" className="vi-studio-composer">
2380
+ {/* k89: the clip's prompt + negative rebuilt on the SHARED PromptCard +
2381
+ PromptFieldTabs (the same shell every Studio prompt list renders). A
2382
+ single-prompt surface: no up/down/split/✕ callbacks (the action cluster
2383
+ hides), and deliberately NO PromptListToolbar — a count/select toolbar
2384
+ over one card would be noise. Same state; the attach toolbar + chips
2385
+ move into the uniform attach-row position below the assist cluster. */}
2386
+ <ol className="vi-gen-parts">
2387
+ <PromptCard
2388
+ badge="clip"
2389
+ index={0}
2390
+ noun="clip"
2391
+ headerHint={
2392
+ <span className="vi-comfy-hint" style={{ opacity: 0.75 }}>
2393
+ {isV2V
2394
+ ? "video → video (restyle)"
2395
+ : isMotion
2396
+ ? "pose / structure → video"
2397
+ : hasImage
2398
+ ? "image → video"
2399
+ : "text → video"}
2400
+ {capProven === false ? " · unproven on this fleet" : ""}
2401
+ </span>
2402
+ }
2403
+ >
2404
+ <PromptFieldTabs
2405
+ idPrefix="vi-studio-clip"
2406
+ promptLabel="prompt"
2407
+ prompt={prompt}
2408
+ onPromptChange={setPrompt}
2409
+ promptPlaceholder={
2410
+ isV2V ? "repaint this scene as … (describe the new look)" : "prompt"
2411
+ }
2412
+ promptExtras={
2413
+ preset?.prompt_note ? (
2414
+ <span className="vi-knob-hint">
2415
+ <em>ⓘ {preset.prompt_note}</em>
2416
+ </span>
2417
+ ) : undefined
2418
+ }
2419
+ negativeLabel="negative prompt"
2420
+ negative={negative}
2421
+ onNegativeChange={setNegative}
2422
+ negativePlaceholder="what to avoid"
2423
+ stdNegative={stdNegative}
2424
+ onStdNegativeChange={setStdNegative}
2425
+ />
2426
+ {/* PROMPT ASSIST — Enhance enriches the clip prompt, Generate writes a fresh
2427
+ one (seeded by any current text). Same shared cluster as the Generate
2428
+ station; errors surface inline and never clobber the existing prompt. */}
2429
+ <PromptAssistButtons
2430
+ busy={assist.assistBusy}
2431
+ error={assist.assistError}
2432
+ onDismissError={assist.dismissError}
2433
+ onEnhance={() => void assist.runAssist("detail", prompt, setPrompt)}
2434
+ onGenerate={() => void assist.runAssist("generate", prompt, setPrompt)}
2435
+ canEnhance={prompt.trim() !== ""}
2436
+ models={assist.assistModels}
2437
+ model={assist.assistModel}
2438
+ onModelChange={assist.setAssistModel}
2439
+ />
2440
+ {/* ATTACH ROW — the same chips + buttons GoalComposer used to render
2441
+ inline, now in the uniform per-card attach position. Same state and
2442
+ callbacks (promptAttachments / promptAttached above); the chip markup
2443
+ mirrors GoalComposer's .vi-goal-composer-chip idiom verbatim. */}
2444
+ {promptAttached.length > 0 && (
2445
+ <div className="vi-goal-composer-chips">
2446
+ {promptAttached.map((a, i) => (
2447
+ <span key={`${a.ref.uri}-${i}`} className="vi-goal-composer-chip">
2448
+ {a.ref.kind === "image" ? (
2449
+ <img
2450
+ src={mediaBytesUrl(a.ref.uri)}
2451
+ alt={a.label ?? "attachment"}
2452
+ className="vi-goal-composer-chip-thumb"
2453
+ />
2454
+ ) : (
2455
+ <span className="vi-goal-composer-chip-kind">{a.ref.kind}</span>
2456
+ )}
2457
+ <span className="vi-goal-composer-chip-label">
2458
+ {a.label ?? a.ref.asset_id.slice(0, 8)}
2459
+ </span>
2460
+ {a.onRemove && (
2461
+ <button
2462
+ type="button"
2463
+ className="vi-goal-composer-chip-x"
2464
+ onClick={a.onRemove}
2465
+ aria-label="Remove attachment"
2466
+ title="Remove"
2467
+ >
2468
+ ✕
2469
+ </button>
2470
+ )}
2471
+ </span>
2472
+ ))}
2473
+ </div>
2474
+ )}
2475
+ {promptAttachments.length > 0 && (
2476
+ <div className="vi-gen-part-attach">
2477
+ {promptAttachments.map((a, i) => (
2478
+ <button
2479
+ key={`${a.label}-${i}`}
2480
+ type="button"
2481
+ className="vi-btn vi-btn-sm vi-btn-ghost"
2482
+ onClick={a.onClick}
2483
+ disabled={a.disabled}
2484
+ title={a.title}
2485
+ >
2486
+ {a.label}
2487
+ </button>
2488
+ ))}
2489
+ </div>
2490
+ )}
2491
+ </PromptCard>
2492
+ </ol>
2493
+ </section>
2494
+
2495
+ {/* Required-input callout — surfaces the missing prerequisite PROMINENTLY next to
2496
+ the composer now that the tier cards (which used to carry the "needs input" cue)
2497
+ are retired. The required input itself is the hero shape directly below. */}
2498
+ {missingRequired && (
2499
+ <p className="vi-studio-required-callout" role="note">
2500
+ ⚠ {missingReason}
2501
+ </p>
2502
+ )}
2503
+
2504
+ {/* ── 3) SHAPE EDITOR (heroes) — morphs per preformed shape; stays dominant when
2505
+ required (accent gating) even though it now sits below the knob grid. ── */}
2506
+ {hero === "start-image" && (
2507
+ <section
2508
+ aria-label="Required input"
2509
+ className="vi-studio-shape"
2510
+ style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
2511
+ >
2512
+ {renderStartImage("hero")}
2513
+ {renderSource("inline-alt")}
2514
+ </section>
2515
+ )}
2516
+ {hero === "source" && (
2517
+ <section
2518
+ aria-label="Required input"
2519
+ className="vi-studio-shape"
2520
+ style={{ display: "flex", flexDirection: "column", gap: "0.6rem" }}
2521
+ >
2522
+ {renderSource("hero")}
2523
+ {/* scene-continuity: OPTIONALLY hold a subject's identity across the restyle. */}
2524
+ {renderReferenceSlots("optional")}
2525
+ </section>
2526
+ )}
2527
+ {hero === "reference" && (
2528
+ <section
2529
+ aria-label="Required input"
2530
+ className="vi-studio-shape"
2531
+ style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
2532
+ >
2533
+ {renderReferenceSlots("required")}
2534
+ {renderControl()}
2535
+ </section>
2536
+ )}
2537
+ {/* motion (2026-07-29): the control still IS the render, so it is the hero and the
2538
+ only input this shape offers — no source clip (the route refuses the pair), no
2539
+ references (a non-VACE-reference branch would silently ignore them). */}
2540
+ {hero === "control" && (
2541
+ <section
2542
+ aria-label="Required input"
2543
+ className="vi-studio-shape"
2544
+ style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
2545
+ >
2546
+ {renderControl("required")}
2547
+ </section>
2548
+ )}
2549
+
2550
+ {/* LOWER inputs — only when NOT a hero shape (custom / synthetic / t2v). */}
2551
+ {!hero && isI2V && <div className="vi-studio-shape">{renderStartImage("inline")}</div>}
2552
+ {!hero && !isT2V && !isIdLock && <div className="vi-studio-shape">{renderSource("inline")}</div>}
2553
+ {!hero && isT2V && (
2554
+ <p className="vi-knob-hint" role="note" style={{ marginTop: "0.9rem" }}>
2555
+ Text-to-video: no image inputs needed — the clip is a function of prompt + seed +
2556
+ geometry.
2557
+ </p>
2558
+ )}
2559
+
2560
+ {/* ── SYNTHETIC-TIER HONESTY BANNER — loud, right above Generate, so the
2561
+ operator sees it at the moment it matters instead of a footnote three
2562
+ fields up. Only fires when the CURRENT form state would actually bind
2563
+ synthetic (see wouldBindSynthetic above); the one-click fill jumps
2564
+ straight past STUDIO_REAL_FLOOR_GB with headroom (STUDIO_SAFE_FILL_GB). ── */}
2565
+ {wouldBindSynthetic && (
2566
+ <div
2567
+ className="vi-studio-required-callout"
2568
+ role="alert"
2569
+ style={{
2570
+ display: "flex",
2571
+ alignItems: "center",
2572
+ gap: "0.6rem",
2573
+ flexWrap: "wrap",
2574
+ marginBottom: "0.6rem",
2575
+ }}
2576
+ >
2577
+ <span>
2578
+ ⚠ This will render the SYNTHETIC TEST tier (colored noise, no real model). Set VRAM
2579
+ budget ≥ {realFloorGb} for a real render on the GPU worker
2580
+ {capFloorGb != null ? " — the measured envelope of the cheapest real model for this capability" : ""}.
2581
+ </span>
2582
+ <button
2583
+ type="button"
2584
+ className="vi-btn vi-btn-sm vi-btn-accent"
2585
+ disabled={genBusy}
2586
+ onClick={() => {
2587
+ budgetTouched.current = true;
2588
+ setVramBudget(String(safeFillGb));
2589
+ }}
2590
+ >
2591
+ Set budget to {safeFillGb}
2592
+ </button>
2593
+ </div>
2594
+ )}
2595
+
2596
+ {/* ── 4) RUN ROW — the Movie-mode .vi-station-footer idiom (flags/errors on the
2597
+ left, the primary Run affordance + status on the right). ── */}
2598
+ <div className="vi-station-footer vi-studio-run">
2599
+ <div className="vi-station-footer-main">
2600
+ {missingRequired && <span className="vi-knob-flag">{missingReason}</span>}
2601
+ {v2vBadGeom && (
2602
+ <span className="vi-knob-flag">
2603
+ Restyle geometry must be 832×480 landscape (the VACE envelope).
2604
+ </span>
2605
+ )}
2606
+ {genMsg && (
2607
+ <p className="vi-knob-hint" role="status" style={{ margin: 0 }}>
2608
+ {genMsg}
2609
+ </p>
2610
+ )}
2611
+ </div>
2612
+ <div className="vi-station-footer-run">
2613
+ <button
2614
+ type="button"
2615
+ className="vi-btn vi-btn-accent"
2616
+ disabled={genBusy || blockSubmit}
2617
+ onClick={() => void onGenerate()}
2618
+ title={blockSubmit ? (v2vBadGeom ? "Fix the geometry" : missingReason) : undefined}
2619
+ >
2620
+ {genBusy
2621
+ ? "Enqueuing…"
2622
+ : isV2V
2623
+ ? "Restyle clip"
2624
+ : isIdLock
2625
+ ? "Generate (identity lock)"
2626
+ : isMotion
2627
+ ? "Generate (pose / structure)"
2628
+ : isI2V
2629
+ ? "Generate (i2v)"
2630
+ : "Generate (t2v)"}
2631
+ </button>
2632
+ {/* (The cross-model "🧪 Test all models" ghost that sat here moved into
2633
+ the bottom Tester collapsible below — k89.) */}
2634
+ </div>
2635
+ </div>
2636
+
2637
+ {/* CLIP TESTER (k89) — no row-based tester on this surface, so the bottom
2638
+ "[▸ Tester]" collapsible holds just the cross-model sweep folded in from
2639
+ the run row (same handler, untouched). Its enqueue/result feedback rides
2640
+ the shared genMsg line in the run row above; per-model results stream to
2641
+ the generation log below this surface. */}
2642
+ <div className="vi-gen-tester">
2643
+ <div className="vi-gen-tester-head">
2644
+ <button
2645
+ type="button"
2646
+ className="vi-btn vi-btn-sm vi-btn-ghost"
2647
+ aria-expanded={clipTesterOpen}
2648
+ onClick={() => setClipTesterOpen((v) => !v)}
2649
+ title="Sweep the prompt across every clip model"
2650
+ >
2651
+ {clipTesterOpen ? "▾" : "▸"} Tester
2652
+ </button>
2653
+ <span className="vi-comfy-hint">
2654
+ Sweep one prompt across every clip model — results stream to the
2655
+ generation log below.
2656
+ </span>
2657
+ </div>
2658
+ {clipTesterOpen && (
2659
+ <div className="vi-gen-tester-actions">
2660
+ <button
2661
+ type="button"
2662
+ className="vi-btn vi-btn-sm vi-btn-tester"
2663
+ disabled={testerBusy || !prompt.trim()}
2664
+ onClick={() =>
2665
+ void onTestAllModels({
2666
+ category: surfaceMode,
2667
+ models: clipTesterModels,
2668
+ width,
2669
+ height,
2670
+ fps,
2671
+ seed,
2672
+ })
2673
+ }
2674
+ title="Run this prompt across every model that FITS THE CURRENT FILTER (this surface's capability + geometry) — one row per model in the log below (a battery). Falls back to the whole roster when none match."
2675
+ >
2676
+ {testerBusy ? "Starting…" : "🧪 Test all models"}
2677
+ </button>
2678
+ </div>
2679
+ )}
2680
+ </div>
2681
+ </>
2682
+ )}
2683
+ {/* STUDIO-ASSIST LIVE LOG: renders in the sidebar's ACTIVE tab now (operator
2684
+ correction 2026-08-05 — "active" meant Active Processes, not the active
2685
+ generate surface). StudioPlane portals it via reg.activeExtraHost. */}
2686
+ </div>
2687
+ );
2688
+ }