@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,2875 @@
1
+ // The Studio MOVIE authoring surface (slice B2 — UI only).
2
+ //
3
+ // A studio movie is an ordered strip of REAL studio clips conjoined at splice
4
+ // points, like a single NLE timeline ROW: `[segment 0 | segment 1 | …]` (see
5
+ // video_intel/studio_movie_schema.py). This surface AUTHORS that strip and plays
6
+ // it back — it is the Movie sibling of StudioGenerateTab's single-clip Clip
7
+ // surface, mounted by the mode switch at the top of StudioGenerateSurface.
8
+ //
9
+ // V0/B3 BOUNDARY (stated plainly, mirroring the schema/runner headers):
10
+ // • Authoring + playback + (B3) a VISUAL SPLICED ROW & branch-frame scrubber. After a
11
+ // movie renders, StudioSplicedRow draws the cut (blocks sized to EFFECTIVE frames,
12
+ // scissor notch per splice) and a click scrubs any segment to branch a new take from
13
+ // a chosen frame; you can still TYPE a branch_frame per goal below (blank = the
14
+ // parent's last frame). The take-tree UI (sibling divergence in place) is later — see
15
+ // onBranchFromSegment's truncate note.
16
+ // • The take-tree parent_segment_id field EXISTS in the schema (sibling
17
+ // divergence is planned growth) but v0 authors a LINEAR CHAIN only — so we send
18
+ // neither segment_id nor parent_segment_id and let the route auto-fill "seg_NN"
19
+ // + the linear parent chain (video_routes.py video_studio_movie).
20
+ // • Movie-level start_image (segment 0 renders i2v from a still instead of t2v) is now
21
+ // WIRED (B3 stretch) behind a MOVIE START SWITCHER (operator ask 2026-07-12 — "any
22
+ // library clip, image, or scene to start — or start fresh with a prompt"): the
23
+ // operator picks segment 1's start from the session LIBRARY, UPLOADS a fresh still
24
+ // (the original upload→ingest→MediaRef idiom), or clears it back to a FRESH PROMPT
25
+ // (t2v). "Start from a CLIP" is shown but DISABLED with an honest tooltip: the
26
+ // backend schema has no `start_clip` field, and there is no synchronous "give me
27
+ // this clip's last frame" endpoint on the client — frame_extract is its own ASYNC
28
+ // JOB (enqueue → poll → pick a frame from a paginated grid), so deriving a start
29
+ // still from a clip client-side is not a small addition here. The button names the
30
+ // backend gap instead of faking a still. Empty start_image = segment 0 t2v.
31
+ // • Per-segment negative/model/steps/cfg are schema-overridable; v0 keeps those
32
+ // movie-level (one negative field) and per-segment only what the strip needs
33
+ // to read as a timeline: prompt, an optional seed, and (rows>0) a branch_frame.
34
+ //
35
+ // The request body this surface POSTs mirrors the route's field names EXACTLY
36
+ // (resolution{width,height,fps}, seed, vram_budget_gb, negative_prompt, goals:
37
+ // [{prompt, seed?, branch_frame?}]) — every optional dropped by JSON.stringify when
38
+ // unset. The job is polled on the SAME generic GET /video/jobs/<id> the studio flow
39
+ // uses; on done the assembled movie.mp4 (outputs[-1]) plays in the viewer idiom and
40
+ // the segment clips list below with their honest joint metadata.
41
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
42
+ // Sub-tab input memory — see the useSessionState block at the top of the
43
+ // composer and the rationale in video/sessionForm.ts.
44
+ import {
45
+ nextSessionSeq,
46
+ useSessionRef,
47
+ useSessionState,
48
+ } from "../../video/sessionForm";
49
+ import { createPortal } from "react-dom";
50
+ import { z } from "zod";
51
+ import {
52
+ request,
53
+ okValue,
54
+ errorOf,
55
+ describeAppError,
56
+ } from "../../transport/client";
57
+ import { hugpyConfig, mediaBytesUrl, jobStatusUrl } from "../../config";
58
+ import {
59
+ mediaRefSchema,
60
+ uploadResultSchema,
61
+ jobErrorSchema,
62
+ JOB_STATUSES,
63
+ isTerminal,
64
+ enqueueResultSchema,
65
+ type MediaRef,
66
+ type JobStatus,
67
+ } from "../../video/contract";
68
+ import { getSessionId } from "../../session";
69
+ import { useMediaLibrary } from "../../video/mediaLibrary";
70
+ import { GoalComposer } from "../GoalComposer";
71
+ import { usePromptAssist, type AssistExtras } from "../../video/usePromptAssist";
72
+ import { PromptAssistButtons } from "../../video/PromptAssistButtons";
73
+ // The SHARED prompt-card system (k88): toolbar, row shell, [Prompt][Negative]
74
+ // tabs and the settings expander this composer and the Movie timeline both
75
+ // render, plus the standard-negative composition helper.
76
+ import {
77
+ AboutExpander,
78
+ PromptCard,
79
+ PromptCardSettings,
80
+ PromptFieldTabs,
81
+ PromptListToolbar,
82
+ composeNegative,
83
+ } from "./promptCard";
84
+ // STUDIO SPREAD (STUDIO-SPREAD-SPEC §2) — the pure half: wire shapes, the ONE
85
+ // spread-request builder, the tolerant readers and the intent→action table. Kept
86
+ // beside the hook rather than inlined here so this component stays an editor.
87
+ import {
88
+ assistModeFor,
89
+ buildSpreadBody,
90
+ detectionText,
91
+ operationLabel,
92
+ resolveOperation,
93
+ segmentRef,
94
+ spreadNoticeLines,
95
+ type IdentityContextWire,
96
+ type IntentResult,
97
+ type PromptFieldMode,
98
+ type SpreadGoalInput,
99
+ } from "../../video/spreadAssist";
100
+ // B3: the visual spliced row + branch-frame scrubber (the picture half). Pure math
101
+ // lives in movieTimeline.ts; both are new files beside this composer.
102
+ import { StudioSplicedRow, StudioPendingRow } from "./StudioSplicedRow";
103
+ // Movie start switcher (operator ask 2026-07-12): the SAME session-library
104
+ // thumbnail grid the Clip surface's start/reference/control pickers use, extracted
105
+ // into studioShared.tsx — see the "Movie start" section below.
106
+ import {
107
+ LibraryImageGrid,
108
+ STUDIO_MODEL_IDS,
109
+ STUDIO_REAL_FLOOR_GB,
110
+ STUDIO_SAFE_FILL_GB,
111
+ bindsSyntheticTier,
112
+ } from "./studioShared";
113
+ // COMPATIBILITY AWARENESS (2026-07-29) — the same measured render table the Clip surface
114
+ // reads (one session-cached GET, shared via the hook's module singleton). It supplies the
115
+ // movie's generalized workflow presets, its geometry envelope, its budget floor and the
116
+ // model pins that can actually serve a segment.
117
+ import { useRenderPresets } from "../../video/useRenderPresets";
118
+ import {
119
+ MOVIE_CAPABILITY,
120
+ movieWorkflowPresets,
121
+ movieBudgetGb,
122
+ geometryForCapability,
123
+ modelsForCapability,
124
+ withinGeometry,
125
+ suggestedBudgetGb,
126
+ } from "../../video/renderCompat";
127
+ // IDENTITY PROFILES (stage a): the shared "Identity" control block + the hook (the
128
+ // composer also reads the saved list so a template's `profile` slug can auto-attach).
129
+ import { IdentityProfileControls, type SelectedProfile } from "./IdentityProfileControls";
130
+ import { useIdentityProfiles } from "./useIdentityProfiles";
131
+ // CINEMA SESSIONS (k91): the durable movie sessions on the studio-movies root (resume /
132
+ // pause / watch what rendered), plus the shared live-pace line this composer reuses for
133
+ // its OWN in-flight render. Both live next door so there is one renderer for each.
134
+ // Cinema sessions moved to the Active display's Sessions panel (operator ask
135
+ // 2026-08-12) — this file keeps only the live-pace renderer for its own render.
136
+ import { MovieLiveProgress } from "./StudioMovieSessions";
137
+
138
+ // B3: the movie endpoint is now a promoted config literal (mirrors studioI2VEnqueueUrl),
139
+ // replacing the earlier inline `${hugpyConfig.apiBase}/video/studio/movie` derivation.
140
+ // jobStatusUrl for the poll is likewise imported from config.
141
+ const studioMovieUrl = hugpyConfig.studioMovieUrl;
142
+
143
+ // ── local wire schemas ──────────────────────────────────────────────────────
144
+ // The studio-movie job view does NOT fit contract.ts's movieProgressSchema /
145
+ // jobRecordSchema (those were shaped for the OTHER generate_movie flow, whose
146
+ // progress `current` is a flat JobProgress; the studio runner's `current` is a
147
+ // small {segment_id, …} dict). So we parse the studio-movie shapes with our own
148
+ // tolerant/passthrough schemas here, reusing the shared mediaRefSchema /
149
+ // jobErrorSchema / JOB_STATUSES / enqueueResultSchema from the contract. Every
150
+ // field beyond the ones we read is optional — a drifted-forward payload never
151
+ // breaks the parse.
152
+
153
+ // One node in the movie.json manifest's `segments` (the runner's seg_records).
154
+ const manifestSegmentSchema = z
155
+ .object({
156
+ index: z.number(),
157
+ segment_id: z.string().nullable().optional(),
158
+ parent_segment_id: z.string().nullable().optional(),
159
+ prompt: z.string().nullable().optional(),
160
+ capability: z.string().nullable().optional(),
161
+ seed: z.number().nullable().optional(),
162
+ // the AUTHORED branch value (may be null = "the parent's last frame").
163
+ branch_frame: z.number().nullable().optional(),
164
+ // the RESOLVED frame index into the PARENT (null for the root segment).
165
+ resolved_branch: z.number().nullable().optional(),
166
+ // how this segment was spliced onto its parent: "still" | "vace_extend" | "cut".
167
+ joint_mode: z.string().nullable().optional(),
168
+ frames: z.number().nullable().optional(),
169
+ width: z.number().nullable().optional(),
170
+ height: z.number().nullable().optional(),
171
+ duration_s: z.number().nullable().optional(),
172
+ resumed: z.boolean().nullable().optional(),
173
+ status: z.string().nullable().optional(),
174
+ })
175
+ .passthrough();
176
+ type ManifestSegment = z.infer<typeof manifestSegmentSchema>;
177
+
178
+ const manifestJointSchema = z
179
+ .object({
180
+ parent_segment_id: z.string().nullable().optional(),
181
+ child_segment_id: z.string().nullable().optional(),
182
+ branch_frame: z.number().nullable().optional(),
183
+ trim_frames: z.number().nullable().optional(),
184
+ // splice mode: "still" | "vace_extend" | "cut" (drives the row notch label).
185
+ mode: z.string().nullable().optional(),
186
+ })
187
+ .passthrough();
188
+
189
+ // The `movie` manifest block the runner echoes on JobResult (from movie.json).
190
+ const movieManifestSchema = z
191
+ .object({
192
+ kind: z.string().nullable().optional(),
193
+ drift: z.string().nullable().optional(),
194
+ fps: z.number().nullable().optional(),
195
+ width: z.number().nullable().optional(),
196
+ height: z.number().nullable().optional(),
197
+ vram_budget_gb: z.number().nullable().optional(),
198
+ segments: z.array(manifestSegmentSchema).optional(),
199
+ joints: z.array(manifestJointSchema).optional(),
200
+ assembly: z
201
+ .object({
202
+ movie: z.string().nullable().optional(),
203
+ total_frames: z.number().nullable().optional(),
204
+ })
205
+ .passthrough()
206
+ .nullable()
207
+ .optional(),
208
+ partial: z.boolean().nullable().optional(),
209
+ segments_completed: z.number().nullable().optional(),
210
+ segments_total: z.number().nullable().optional(),
211
+ })
212
+ .passthrough();
213
+
214
+ const movieJobResultSchema = z
215
+ .object({
216
+ ok: z.boolean(),
217
+ outputs: z.array(mediaRefSchema).optional(),
218
+ error: jobErrorSchema.nullable().optional(),
219
+ movie: movieManifestSchema.nullable().optional(),
220
+ project: z.record(z.unknown()).nullable().optional(),
221
+ })
222
+ .passthrough();
223
+ type MovieJobResult = z.infer<typeof movieJobResultSchema>;
224
+
225
+ // One segment's live entry in the nested progress (the runner's segments_meta).
226
+ const progressSegmentSchema = z
227
+ .object({
228
+ index: z.number(),
229
+ segment_id: z.string().nullable().optional(),
230
+ prompt: z.string().nullable().optional(),
231
+ status: z.string().nullable().optional(),
232
+ resumed: z.boolean().nullable().optional(),
233
+ })
234
+ .passthrough();
235
+
236
+ const movieProgressSchema = z
237
+ .object({
238
+ stage: z.string().nullable().optional(),
239
+ segment_done: z.number().nullable().optional(),
240
+ segment_total: z.number().nullable().optional(),
241
+ segments: z.array(progressSegmentSchema).optional(),
242
+ // `current` is a small {segment_id, index?, prompt?, capability?, branch_frame?}
243
+ // dict OR null — read defensively, never trusted to a fixed shape.
244
+ current: z.record(z.unknown()).nullable().optional(),
245
+ started_at: z.number().nullable().optional(),
246
+ eta_s: z.number().nullable().optional(),
247
+ })
248
+ .passthrough();
249
+ type MovieProgress = z.infer<typeof movieProgressSchema>;
250
+
251
+ const movieJobSchema = z
252
+ .object({
253
+ job_id: z.string(),
254
+ status: z.enum(JOB_STATUSES).nullable(),
255
+ result: movieJobResultSchema.nullable().optional(),
256
+ progress: movieProgressSchema.nullable().optional(),
257
+ })
258
+ .passthrough();
259
+
260
+ // ── the authored goal row (local editor state) ──────────────────────────────
261
+ // Numbers are kept as STRINGS so "blank = unset" is distinct from 0 (the clip
262
+ // surface's idiom): a blank per-goal seed lets the runner derive `seed + index`;
263
+ // a blank branch_frame means "the parent's last frame".
264
+ // How a segment (row >= 1) is spliced onto the one before it:
265
+ // • "still" — continue: condition i2v on ONE branch frame (motion NOT carried).
266
+ // • "vace_extend" — extend motion: condition on the parent's trailing frames (VACE).
267
+ // • "cut" — scene cut: NO frame carry, a fresh render; the parent plays in full
268
+ // (in an identity movie the SUBJECT still carries via the references).
269
+ // Row 0 (the root) has no parent, so its `joint` is ignored.
270
+ type JointMode = "still" | "vace_extend" | "cut";
271
+
272
+ interface GoalRow {
273
+ key: string;
274
+ prompt: string;
275
+ // Per-segment negative (operator addendum 2026-07-12). Blank ⇒ the goal body omits
276
+ // `negative`, and the runner falls back to the movie-level negative (schema:
277
+ // StudioMovieGoal.negative, None ⇒ movie default). Non-blank overrides it for this
278
+ // segment only. Kept as a STRING so "blank = fall back" is distinct from an empty
279
+ // override, matching the seed/branchFrame idiom.
280
+ negative: string;
281
+ // k88: [standard negative] tick — when on, the STANDARD artifact/junk set is
282
+ // COMPOSED into this row's effective negative at the point a request payload
283
+ // (or spread projection) is built. Client-side only; the wire is unchanged.
284
+ stdNegative: boolean;
285
+ seed: string;
286
+ branchFrame: string;
287
+ joint: JointMode;
288
+ // FULL PER-SEGMENT KNOBS (operator ask 2026-08-12): the cinema route has
289
+ // accepted per-goal model_id/steps/cfg/context_frames overrides all along
290
+ // (StudioMovieGoal — "honored when set, not dead fields"); the editor just
291
+ // never offered them. Same string idiom as seed/branchFrame: blank ⇒ the key
292
+ // is dropped from the goal body and the segment inherits the movie-level
293
+ // value (or the router/model default).
294
+ modelId: string;
295
+ steps: string;
296
+ cfg: string;
297
+ contextFrames: string;
298
+ }
299
+
300
+ // The counter is SESSION-persisted (not a module `let`) now that the goal rows
301
+ // themselves survive a reload: a module counter restarts at 0 while the restored
302
+ // rows keep their old keys, so `goal_1` could be minted twice — and this key
303
+ // doubles as the wire `segment_id`. Same shape, just a counter that remembers.
304
+ function newGoalKey(): string {
305
+ return `goal_${nextSessionSeq("studio.movie.goalKeySeq")}`;
306
+ }
307
+ function blankGoal(joint: JointMode = "still"): GoalRow {
308
+ return {
309
+ key: newGoalKey(),
310
+ prompt: "",
311
+ negative: "",
312
+ stdNegative: false,
313
+ seed: "",
314
+ branchFrame: "",
315
+ joint,
316
+ modelId: "",
317
+ steps: "",
318
+ cfg: "",
319
+ contextFrames: "",
320
+ };
321
+ }
322
+
323
+ // ── the poll hook (local) ───────────────────────────────────────────────────
324
+ // StudioGenerateTab's Clip surface never polls — it enqueues and lets the durable
325
+ // /video/studio/clips catalog surface the clip. But that catalog lists ONLY
326
+ // studio_i2v jobs, so a movie's outputs come back on GET /video/jobs/<id>. This
327
+ // small hook mirrors the enqueue→track idiom (useGenerateMovieJob) but keeps its
328
+ // OWN poll loop against jobStatusUrl since no shared tracker owns this kind.
329
+ interface StudioMovieJobApi {
330
+ jobId: string | null;
331
+ status: JobStatus;
332
+ progress: MovieProgress | null;
333
+ result: MovieJobResult | null;
334
+ /** Human message on enqueue error or a failed/cancelled job. */
335
+ error: string | null;
336
+ running: boolean;
337
+ run: (body: unknown) => void;
338
+ reset: () => void;
339
+ }
340
+
341
+ function useStudioMovieJob(): StudioMovieJobApi {
342
+ const [jobId, setJobId] = useState<string | null>(null);
343
+ const [status, setStatus] = useState<JobStatus>(null);
344
+ const [progress, setProgress] = useState<MovieProgress | null>(null);
345
+ const [result, setResult] = useState<MovieJobResult | null>(null);
346
+ const [error, setError] = useState<string | null>(null);
347
+ const [running, setRunning] = useState(false);
348
+
349
+ const mounted = useRef(true);
350
+ const timer = useRef<number | null>(null);
351
+ const jobRef = useRef<string | null>(null);
352
+
353
+ const stopPoll = useCallback(() => {
354
+ if (timer.current != null) {
355
+ window.clearInterval(timer.current);
356
+ timer.current = null;
357
+ }
358
+ }, []);
359
+
360
+ useEffect(() => {
361
+ mounted.current = true;
362
+ return () => {
363
+ mounted.current = false;
364
+ if (timer.current != null) window.clearInterval(timer.current);
365
+ };
366
+ }, []);
367
+
368
+ const poll = useCallback(async () => {
369
+ const id = jobRef.current;
370
+ if (!id) return;
371
+ const res = await request<unknown>(jobStatusUrl(id), {
372
+ meta: { specKey: "studio", operation: "studio.movie.status" },
373
+ });
374
+ if (!mounted.current) return;
375
+ // A transient poll failure must NOT wipe a working readout — just wait for the
376
+ // next tick (the terminal parse below stops the loop on its own).
377
+ if (!res.ok) return;
378
+ const parsed = movieJobSchema.safeParse(okValue(res));
379
+ if (!parsed.success) return;
380
+ const jr = parsed.data;
381
+ setStatus(jr.status ?? null);
382
+ if (jr.progress) setProgress(jr.progress);
383
+ if (jr.result) setResult(jr.result);
384
+ if (isTerminal(jr.status)) {
385
+ stopPoll();
386
+ setRunning(false);
387
+ if (jr.status === "failed" || jr.status === "cancelled") {
388
+ setError(jr.result?.error?.message ?? `Job ${jr.status}.`);
389
+ }
390
+ }
391
+ }, [stopPoll]);
392
+
393
+ const run = useCallback(
394
+ (body: unknown) => {
395
+ stopPoll();
396
+ setJobId(null);
397
+ jobRef.current = null;
398
+ setStatus(null);
399
+ setProgress(null);
400
+ setResult(null);
401
+ setError(null);
402
+ setRunning(true);
403
+ void request<unknown>(studioMovieUrl, {
404
+ method: "POST",
405
+ body: JSON.stringify(body),
406
+ headers: { "Content-Type": "application/json" },
407
+ meta: { specKey: "studio", operation: "studio.movie.enqueue" },
408
+ }).then((r) => {
409
+ if (!mounted.current) return;
410
+ if (!r.ok) {
411
+ setError(describeAppError(errorOf(r)));
412
+ setStatus("failed");
413
+ setRunning(false);
414
+ return;
415
+ }
416
+ const p = enqueueResultSchema.safeParse(okValue(r));
417
+ if (!p.success) {
418
+ setError("Malformed enqueue response.");
419
+ setStatus("failed");
420
+ setRunning(false);
421
+ return;
422
+ }
423
+ const id = p.data.job_id;
424
+ setJobId(id);
425
+ jobRef.current = id;
426
+ setStatus("queued");
427
+ void poll();
428
+ timer.current = window.setInterval(() => void poll(), 2500);
429
+ });
430
+ },
431
+ [poll, stopPoll],
432
+ );
433
+
434
+ const reset = useCallback(() => {
435
+ stopPoll();
436
+ setJobId(null);
437
+ jobRef.current = null;
438
+ setStatus(null);
439
+ setProgress(null);
440
+ setResult(null);
441
+ setError(null);
442
+ setRunning(false);
443
+ }, [stopPoll]);
444
+
445
+ return { jobId, status, progress, result, error, running, run, reset };
446
+ }
447
+
448
+ // ── honest joint copy for one produced segment (uses the manifest, no invention) ─
449
+ // Root: "root · <capability>". A later segment: "branched from frame N · trimmed
450
+ // parent to N+1" (exactly the requested wording), with honest annotations when the
451
+ // authored branch was blank (parent's last frame) or the trim keeps the parent
452
+ // whole (no real cut).
453
+ function jointText(segs: ManifestSegment[], i: number): string {
454
+ const seg = segs[i];
455
+ if (!seg) return "";
456
+ const cap = seg.capability ?? "t2v";
457
+ if (i === 0) return `root segment · ${cap}`;
458
+ const mode = seg.joint_mode ?? "still";
459
+ // A "cut" carries no frame — the parent plays in full and the child is a fresh render
460
+ // (id_lock in an identity movie, so the subject carries even though no pixels do).
461
+ if (mode === "cut") {
462
+ return `✂ scene cut · fresh ${cap} render — no frame carry (parent plays in full)`;
463
+ }
464
+ const rb = seg.resolved_branch;
465
+ if (rb == null) return "branched from parent";
466
+ const trim = rb + 1;
467
+ const parentFrames = segs[i - 1]?.frames;
468
+ const carry = mode === "vace_extend" ? "extended motion from" : "branched from";
469
+ let s = `${carry} frame ${rb} · trimmed parent to ${trim}`;
470
+ if (seg.branch_frame == null) s += " (blank branch = parent's last frame)";
471
+ if (parentFrames != null && trim >= parentFrames) s += " — parent plays in full";
472
+ return s;
473
+ }
474
+
475
+ function statusBadge(status: string | null | undefined, resumed?: boolean | null): string {
476
+ if (resumed) return "resumed";
477
+ return status ?? "pending";
478
+ }
479
+
480
+ // ── movie PRESETS — GENERALIZED WORKFLOWS, not canned scenes (operator ruling 2026-07-29)
481
+ //
482
+ // WHAT THIS REPLACES AND WHY. This slot used to hold a `TEMPLATES` const of two
483
+ // scene-specific prefills — "Beach & volleyball — character day out", carrying a baked-in
484
+ // subject ("a young woman in a light sundress"), a baked-in art direction ("anime
485
+ // illustration style"), a baked-in identity slug ("mira") and two baked-in scene prompts.
486
+ // Applying one REPLACED the operator's entire goal timeline with that content. Three
487
+ // problems, all of them the same problem:
488
+ // • it answered ONE user's request rather than describing a workflow, so anyone whose
489
+ // movie was not about a beach had to delete the preset's prose before starting;
490
+ // • its numbers (480×480, budget 10) were hand-picked against a fleet fact and could
491
+ // drift out from under it silently;
492
+ // • and it clobbered the user's own session inputs, which is the defect class this
493
+ // whole slice deletes.
494
+ //
495
+ // A preset here now names a WORKFLOW SHAPE and nothing about the content: which JOINT
496
+ // stitches the segments, what geometry the fleet has ratified for it, what that joint's
497
+ // binding really costs, and whether it has ever completed here. The CONTENT is the user's
498
+ // — their goal prompts, their identity, their start image — and `applyWorkflow` slots the
499
+ // workflow around all of it without touching any of it.
500
+ //
501
+ // SOURCED FROM THE BACKEND. The list is derived by `movieWorkflowPresets` from the
502
+ // ratified `movie-480p` RenderPreset: one row per joint mode the preset declares
503
+ // (`joints`), sized against the segment binding that joint actually uses (found via
504
+ // `composes`), and badged `proven` by the same rule the backend enforces on itself — a
505
+ // composite can never be more proven than the weakest thing it composes. Ratifying a
506
+ // fourth joint server-side makes a fourth workflow appear here with no frontend change.
507
+ //
508
+ // IDENTITY (operator 2026-07-12, unchanged and now the model for everything else):
509
+ // applying a preset NEVER clobbers an attached identity. That instinct was always right
510
+ // here; it is now the rule for every field.
511
+
512
+ // ── movie templates (RESTORED 2026-08-01, operator ask) ────────────────────────────
513
+ // The lightweight scene-PREFILL mechanism removed by e43d9e7 in favor of workflow
514
+ // presets. The operator wants BOTH: a workflow preset sets the SHAPE (joint/geometry/
515
+ // budget) and never touches prompts; a movie template prefills the SCENES themselves —
516
+ // goal prompts + joints + optional seed offset, resolution, budget, and an optional
517
+ // saved identity profile. Same rules as before: applying a template NEVER clobbers an
518
+ // already-attached identity (auto-attach only into an empty slot, from a SAVED profile
519
+ // the template names), and the movie START is left untouched.
520
+ interface MovieTemplate {
521
+ id: string;
522
+ name: string;
523
+ hint: string;
524
+ goals: Array<{ prompt: string; joint: JointMode; seed?: string }>;
525
+ /** Prefill the movie resolution (the vace-1.3b id sweet spot is 480×480 / 832×480). */
526
+ resolution?: { width: number; height: number };
527
+ /** Prefill the VRAM budget field (a real id-capable model needs a real budget). */
528
+ vramBudgetGb?: number;
529
+ /** A saved identity-profile slug — auto-attached on apply when saved (else: nudge). */
530
+ profile?: string;
531
+ }
532
+ const TEMPLATES: MovieTemplate[] = [
533
+ {
534
+ id: "character-short-two-scenes",
535
+ name: "Character short — two scenes",
536
+ hint:
537
+ "Add one identity reference below (or pick a saved profile), then Generate: your character on a sunny beach, then a scene cut to playing volleyball.",
538
+ goals: [
539
+ { prompt: "on a sunny beach, gentle waves, warm golden-hour light", joint: "still" },
540
+ { prompt: "playing volleyball on the sand, mid-jump, dynamic action", joint: "cut" },
541
+ ],
542
+ },
543
+ {
544
+ id: "beach-volleyball-character-day-out",
545
+ name: "Beach & volleyball — character day out",
546
+ hint:
547
+ "One click: a young woman on a sunny beach, then a scene cut to beach volleyball. Attaches the saved “Mira” identity if you have one; otherwise add a reference (or pick a profile) below, then Generate.",
548
+ resolution: { width: 480, height: 480 },
549
+ vramBudgetGb: 10,
550
+ profile: "mira",
551
+ goals: [
552
+ {
553
+ prompt:
554
+ "the same young woman in a light sundress on a sunny beach by the water, ocean waves and bright sky behind her, hair moving in the breeze, warm golden-hour light, anime illustration style, high detail",
555
+ joint: "still",
556
+ },
557
+ {
558
+ prompt:
559
+ "the same young woman playing beach volleyball, mid-action leaping to spike the ball, sand kicking up, dynamic angle, sunny beach court, anime illustration style, high detail",
560
+ joint: "cut",
561
+ seed: "1000",
562
+ },
563
+ ],
564
+ },
565
+ ];
566
+
567
+ const JOINT_LABELS: Record<JointMode, string> = {
568
+ still: "continue (still)",
569
+ vace_extend: "extend motion (vace)",
570
+ cut: "scene cut",
571
+ };
572
+
573
+ export interface StudioMovieComposerProps {
574
+ /** The sidebar "Settings" tab host to portal the Movie-templates dropdown into (mirrors
575
+ * the Clip surface's knobGrid portal). null until the sidebar mounts its Settings panel. */
576
+ settingsHost?: HTMLElement | null;
577
+ }
578
+
579
+ // ── the composer ────────────────────────────────────────────────────────────
580
+ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps = {}) {
581
+ // ── SUB-TAB INPUT MEMORY (operator ask 2026-08-06) ──────────────────────────
582
+ // This composer unmounts on every Cinema→Clip switch and every workbench tab
583
+ // switch, which used to throw away an entire written-out goal timeline. Every
584
+ // OPERATOR-AUTHORED field below therefore uses `useSessionState` (same tuple as
585
+ // useState, value outlives the mount, per-tab, session-scoped) — see
586
+ // video/sessionForm.ts. Spread/assist bookkeeping, upload phases, errors and
587
+ // in-flight job state stay on plain useState on purpose.
588
+ //
589
+ // Goal timeline — at least one row; row 0 is the root (t2v).
590
+ const [goalsRaw, setGoals] = useSessionState<GoalRow[]>("studio.movie.goals", () => [
591
+ blankGoal(),
592
+ blankGoal(),
593
+ ]);
594
+ // Rows restored from a session written BEFORE the per-segment knob round
595
+ // (2026-08-12) lack modelId/steps/cfg/contextFrames — normalize to the blank
596
+ // ("inherit") value so `.trim()` never meets undefined. useMemo, not a write:
597
+ // the stored rows heal on the next real edit.
598
+ const goals = useMemo(
599
+ () =>
600
+ goalsRaw.map((g) => ({
601
+ modelId: "",
602
+ steps: "",
603
+ cfg: "",
604
+ contextFrames: "",
605
+ ...g,
606
+ })),
607
+ [goalsRaw],
608
+ );
609
+
610
+ // Movie-level knobs (mirror the clip surface's defaults + idiom where reusable).
611
+ // Default 480×480: the wan2.1-vace-1.3b IDENTITY sweet spot — the only budget-fitting
612
+ // id-capable model on this fleet tops out at 832×480, so a 512×512 default cannot serve
613
+ // id_lock on it (no_capable_model). 832×480 widescreen is the other valid id envelope.
614
+ const [width, setWidth] = useSessionState("studio.movie.width", 480);
615
+ const [height, setHeight] = useSessionState("studio.movie.height", 480);
616
+ const [fps, setFps] = useSessionState("studio.movie.fps", 24);
617
+ const [seed, setSeed] = useSessionState("studio.movie.seed", 0);
618
+ // blank = autofit to the card's capacity
619
+ const [vramBudget, setVramBudget] = useSessionState("studio.movie.vramBudget", "");
620
+ const [negative, setNegative] = useSessionState("studio.movie.negative", "");
621
+ const [project, setProject] = useSessionState("studio.movie.project", "");
622
+ // MOVIE NAME (k91) — the spec's `title`, display-only on the wire and written into
623
+ // movie.json's manifest, which is what the sessions panel above lists a movie BY. An
624
+ // unnamed movie is still perfectly renderable (the route takes title=None), so a blank
625
+ // name warns once and offers submit-anyway rather than blocking — but a root full of
626
+ // "untitled · 7f3a91c2…" is exactly the state that made lost renders unrecoverable, so
627
+ // the warning is worth the one extra click. Session-persisted like every other authored
628
+ // field here.
629
+ const [movieTitle, setMovieTitle] = useSessionState("studio.movie.title", "");
630
+ // Movie-level MODEL PIN + sampler overrides (operator ask 2026-07-13: the Settings tab
631
+ // wants "the model selection" + a wider range of options). All BLANK-first ⇒ router
632
+ // default: modelId "" = the capability router picks by budget/resolution (an unroutable
633
+ // pin comes back as errors-as-data, never a silent fallback); steps/cfg "" = the model
634
+ // default. Kept as STRINGS so "blank = unset" is distinct from 0, matching the clip
635
+ // surface's idiom. The movie route validates these at the movie level (studio_movie_schema
636
+ // _check_steps_cfg: steps int [1,100], cfg number [0,20]) — mirrored in buildBody below.
637
+ const [modelId, setModelId] = useSessionState("studio.movie.modelId", "");
638
+ const [steps, setSteps] = useSessionState("studio.movie.steps", "");
639
+ const [cfg, setCfg] = useSessionState("studio.movie.cfg", "");
640
+ // MERGE-NOT-OVERWRITE bookkeeping — true once the OPERATOR has typed a budget. A
641
+ // workflow preset then fills nothing over it. A ref, not state: it only ever gates a
642
+ // write and must not cause a render. Session-scoped so it is restored WITH the
643
+ // budget it guards (see the clip surface's matching note).
644
+ const budgetTouched = useSessionRef("studio.movie.budgetTouched", false);
645
+ // Advanced escape hatch for the model pin (mirrors the Clip surface): off by default so
646
+ // the menu is the ratified set for a movie segment; on to reach every studio model id,
647
+ // each labelled unverified rather than presented as equivalent.
648
+ const [showAllModels, setShowAllModels] = useState(false);
649
+
650
+ // ── the fleet's measured render table ──────────────────────────────────────────────
651
+ // Session-cached and shared with the Clip surface. `renderLoaded` gates every filter
652
+ // below so a slow discovery GET degrades to this composer's prior behavior rather than
653
+ // to an empty picker.
654
+ const {
655
+ presets: renderPresets,
656
+ loaded: renderLoaded,
657
+ renderBox,
658
+ } = useRenderPresets(true);
659
+ const workflowPresets = useMemo(
660
+ () => movieWorkflowPresets(renderPresets),
661
+ [renderPresets],
662
+ );
663
+ const movieGeometry = renderLoaded
664
+ ? geometryForCapability(renderPresets, MOVIE_CAPABILITY)
665
+ : null;
666
+ const movieModels = renderLoaded ? modelsForCapability(renderPresets, MOVIE_CAPABILITY) : [];
667
+
668
+ const [formMsg, setFormMsg] = useState<string | null>(null);
669
+ // B3: key of a just-appended branch goal to focus once it mounts (empty prompt).
670
+ const [pendingFocusKey, setPendingFocusKey] = useState<string | null>(null);
671
+
672
+ // B3 stretch: OPTIONAL movie-level start_image for SEGMENT 0. When set, the root
673
+ // renders i2v from this still instead of t2v (schema field `start_image`). Reuses the
674
+ // clip surface's upload→ingest→guard-kind idiom verbatim, importing only config /
675
+ // contract / session — it touches NO forbidden file.
676
+ const [startImage, setStartImage] = useSessionState<MediaRef | null>(
677
+ "studio.movie.startImage",
678
+ null,
679
+ );
680
+ const [startPhase, setStartPhase] = useState<"idle" | "uploading" | "ingesting">("idle");
681
+ const [startError, setStartError] = useState<string | null>(null);
682
+ const startBusy = startPhase !== "idle";
683
+ // Movie start switcher (operator ask 2026-07-12): toggles the session-library
684
+ // picker open/closed, mirroring the Clip surface's showLibPicker idiom.
685
+ const [showStartLibPicker, setShowStartLibPicker] = useState(false);
686
+
687
+ // IDENTITY LOCK: the movie-level subject reference image(s). When any are set the movie is
688
+ // an IDENTITY MOVIE — every segment renders id_lock so the locked subject carries across
689
+ // scene changes (the operator's "take that id and use it for a video"). Up to 4 (diffusers
690
+ // 0.39 consumes each as a VACE reference latent). Reuses the same upload→ingest→guard-kind
691
+ // idiom as the start-image picker; the `.uri`s are threaded into the enqueue.
692
+ const [references, setReferences] = useSessionState<MediaRef[]>(
693
+ "studio.movie.references",
694
+ [],
695
+ );
696
+ const [refPhase, setRefPhase] = useState<"idle" | "uploading" | "ingesting">("idle");
697
+ const [refError, setRefError] = useState<string | null>(null);
698
+ const refBusy = refPhase !== "idle";
699
+ const REF_MAX = 4;
700
+ // IDENTITY PROFILES (stage a): when the identity came from a SAVED profile, this holds
701
+ // it — the enqueue then sends `identity_profile:<slug>` (canonical) and omits raw refs.
702
+ // Any ad-hoc edit (upload/remove) clears it → the set becomes an "unsaved identity".
703
+ const [selectedProfile, setSelectedProfile] = useSessionState<SelectedProfile | null>(
704
+ "studio.movie.selectedProfile",
705
+ null,
706
+ );
707
+ // Profiles read for the restored movie TEMPLATES (2026-08-01): a template may name a
708
+ // saved identity slug and auto-attach it on apply — that needs the saved list + a ref
709
+ // resolver here. A one-off stale read is fine for template-apply (same as pre-e43d9e7).
710
+ const { profiles: savedProfiles, resolveRefs: resolveProfileRefs } = useIdentityProfiles();
711
+ // ⚠ (2026-07-29 note, now partially reversed by the templates restore above.) It existed for ONE reason: the old
712
+ // scene template named a saved identity slug ("mira") and auto-attached it on apply. A
713
+ // generalized workflow preset names no subject at all — the identity is the operator's
714
+ // session input, attached through IdentityProfileControls below (which owns its own
715
+ // instance of the hook) — so the composer no longer needs a second, staler copy of the
716
+ // profile list to resolve a slug nobody typed.
717
+ // A profile pick REPLACES the identity (a profile IS the whole identity); ad-hoc appends.
718
+ const attachIdentityRefs = useCallback((refs: MediaRef[], replace: boolean) => {
719
+ if (replace) {
720
+ setReferences(refs.slice(0, REF_MAX));
721
+ } else {
722
+ setReferences((prev) => {
723
+ const room = REF_MAX - prev.length;
724
+ return room <= 0 ? prev : [...prev, ...refs.slice(0, room)];
725
+ });
726
+ }
727
+ }, []);
728
+
729
+ const job = useStudioMovieJob();
730
+
731
+ // LLM PROMPT-ASSIST (Enhance / Generate) — the SAME shared hook + button cluster the
732
+ // Generate + Clip surfaces use (POSTs promptAssistUrl). A movie has a prompt PER GOAL,
733
+ // so the cluster is rendered under each goal's prompt below: Enhance enriches that
734
+ // goal's text, Generate writes a fresh one for it. ONE hook instance guards the whole
735
+ // composer to one in-flight assist at a time (across all rows), matching the doctrine;
736
+ // context.kind = "movie" so the assistant leans into scene/motion phrasing.
737
+ const assist = usePromptAssist({ kind: "movie", specKey: "studio" });
738
+
739
+ // ── STUDIO SPREAD state (STUDIO-SPREAD-SPEC §2) ────────────────────────────
740
+ //
741
+ // WHAT THIS FIXES. Per-row Generate was actively INCOHERENT: the assist route
742
+ // re-randomizes its steering axes on every call, so six rows produced six unrelated
743
+ // worlds. A spread is ONE call that writes every SELECTED row against one shared
744
+ // steering set, with the UNSELECTED rows sent as locked context — so the shots land
745
+ // in the same movie instead of six of them.
746
+ //
747
+ // The three pieces of state that makes possible, all keyed by the goal row's own
748
+ // `key` (which doubles as the wire `segment_id` — stable across reorder/insert, so a
749
+ // reply can never be applied to the wrong row):
750
+ // • `selectedKeys` — which rows the group actions may rewrite. NOTHING else is
751
+ // ever touched: unselected prompts, every negative the user typed, identity,
752
+ // images, seeds and the budget are all out of scope by construction.
753
+ // • `fieldModes` — the per-field Auto · Direction · Scene prompt tri-state.
754
+ // An explicit choice SKIPS the router entirely (§1d.2).
755
+ // • `intents` — the router's last answer per row. A LABEL on a button, never an
756
+ // action: no classification ever starts a generation on its own.
757
+ const [selectedKeys, setSelectedKeys] = useState<ReadonlySet<string>>(() => new Set());
758
+ // Keyed by goal row key, so it rides along with the persisted `goals`.
759
+ const [fieldModes, setFieldModes] = useSessionState<Record<string, PromptFieldMode>>(
760
+ "studio.movie.fieldModes",
761
+ {},
762
+ );
763
+ const [intents, setIntents] = useState<Record<string, IntentResult | null>>({});
764
+ // Which rows are mid-classification (a tiny "…" beside the control, not a blocker).
765
+ const [intentBusy, setIntentBusy] = useState<Record<string, boolean>>({});
766
+ // The text each row was last classified FOR, so a blur that changed nothing is free.
767
+ const intentText = useRef<Record<string, string>>({});
768
+ // The overall request the spread writes against ("a chase through a night market").
769
+ // Optional: with it blank the generator works from the rows themselves.
770
+ const [movieQuery, setMovieQuery] = useSessionState("studio.movie.query", "");
771
+ // The steering set's seed, echoed by the last spread. Re-sending it puts a follow-up
772
+ // spread into the SAME world — how you re-roll two shots without leaving the movie.
773
+ const [steeringSeed, setSteeringSeed] = useState<number | null>(null);
774
+ const [pinSteering, setPinSteering] = useState(true);
775
+ // The non-destructive result notice: what changed, what did NOT, every warning.
776
+ // Dismissible; it never edits anything on its own.
777
+ const [spreadNotice, setSpreadNotice] = useState<string[] | null>(null);
778
+
779
+ const selectedCount = useMemo(
780
+ () => goals.reduce((n, g) => (selectedKeys.has(g.key) ? n + 1 : n), 0),
781
+ [goals, selectedKeys],
782
+ );
783
+ const allSelected = goals.length > 0 && selectedCount === goals.length;
784
+
785
+ const toggleSelected = useCallback((key: string) => {
786
+ setSelectedKeys((prev) => {
787
+ const next = new Set(prev);
788
+ if (next.has(key)) next.delete(key);
789
+ else next.add(key);
790
+ return next;
791
+ });
792
+ }, []);
793
+ // Keyed off the LIVE row count, not the set's size: a removed row can leave a stale
794
+ // key behind, and "select all" must mean the rows on screen, not that residue.
795
+ const toggleSelectAll = useCallback(() => {
796
+ setSelectedKeys(() => (allSelected ? new Set<string>() : new Set(goals.map((g) => g.key))));
797
+ }, [allSelected, goals]);
798
+ // Toolbar [standard negatives] (k88): tick [standard negative] ON for the
799
+ // selected rows — or for EVERY row when nothing is selected. Set, not toggle:
800
+ // the control is a bulk "add the standard set", and untick stays per-row.
801
+ const onStandardNegatives = useCallback(() => {
802
+ setGoals((prev) => {
803
+ const anySelected = prev.some((g) => selectedKeys.has(g.key));
804
+ return prev.map((g) =>
805
+ !anySelected || selectedKeys.has(g.key) ? { ...g, stdNegative: true } : g,
806
+ );
807
+ });
808
+ }, [selectedKeys]);
809
+
810
+ // Movie start switcher's library picker: the SAME session-library image list the
811
+ // Clip surface's start/reference/control pickers read (useMediaLibrary), filtered
812
+ // to images (a movie start is a single still, like the i2v start image).
813
+ const library = useMediaLibrary();
814
+ const startLibraryImages = library.filter((it) => it.ref.kind === "image");
815
+
816
+ // Upload → ingest → guard kind=image → hold the MediaRef (its `.uri` is threaded into
817
+ // the enqueue as `start_image`). Mirrors StudioGenerateSurface.onPickImageFile.
818
+ const onPickStartImage = useCallback(async (file: File) => {
819
+ setStartError(null);
820
+ setStartPhase("uploading");
821
+ const fd = new FormData();
822
+ fd.append("file", file);
823
+ fd.append("sid", getSessionId());
824
+ const up = await request<unknown>(hugpyConfig.uploadUrl, {
825
+ method: "POST",
826
+ body: fd,
827
+ meta: { specKey: "studio", operation: "studio.movie.start_image.upload" },
828
+ });
829
+ if (!up.ok) {
830
+ setStartError(describeAppError(errorOf(up)));
831
+ setStartPhase("idle");
832
+ return;
833
+ }
834
+ const upParsed = uploadResultSchema.safeParse(okValue(up));
835
+ if (!upParsed.success) {
836
+ setStartError("Malformed upload response.");
837
+ setStartPhase("idle");
838
+ return;
839
+ }
840
+ setStartPhase("ingesting");
841
+ const ing = await request<unknown>(hugpyConfig.videoIngestUrl, {
842
+ method: "POST",
843
+ body: JSON.stringify({ path: upParsed.data.path }),
844
+ headers: { "Content-Type": "application/json" },
845
+ meta: { specKey: "studio", operation: "studio.movie.start_image.ingest" },
846
+ });
847
+ if (!ing.ok) {
848
+ setStartError(describeAppError(errorOf(ing)));
849
+ setStartPhase("idle");
850
+ return;
851
+ }
852
+ const media = mediaRefSchema.safeParse(okValue(ing));
853
+ if (!media.success) {
854
+ setStartError("Malformed ingest response.");
855
+ setStartPhase("idle");
856
+ return;
857
+ }
858
+ if (media.data.kind !== "image") {
859
+ setStartError(`Ingested asset is "${media.data.kind}", not an image — pick a still.`);
860
+ setStartPhase("idle");
861
+ return;
862
+ }
863
+ setStartImage(media.data);
864
+ setStartPhase("idle");
865
+ }, []);
866
+
867
+ // Upload → ingest → guard kind=image → APPEND a subject reference (identity lock). Mirrors
868
+ // onPickStartImage; caps at REF_MAX (a clean caller error, never a silent drop).
869
+ const onPickReferenceImage = useCallback(async (file: File) => {
870
+ setRefError(null);
871
+ setReferences((prev) => {
872
+ if (prev.length >= REF_MAX) setRefError(`At most ${REF_MAX} reference images.`);
873
+ return prev;
874
+ });
875
+ setRefPhase("uploading");
876
+ const fd = new FormData();
877
+ fd.append("file", file);
878
+ fd.append("sid", getSessionId());
879
+ const up = await request<unknown>(hugpyConfig.uploadUrl, {
880
+ method: "POST",
881
+ body: fd,
882
+ meta: { specKey: "studio", operation: "studio.movie.reference.upload" },
883
+ });
884
+ if (!up.ok) {
885
+ setRefError(describeAppError(errorOf(up)));
886
+ setRefPhase("idle");
887
+ return;
888
+ }
889
+ const upParsed = uploadResultSchema.safeParse(okValue(up));
890
+ if (!upParsed.success) {
891
+ setRefError("Malformed upload response.");
892
+ setRefPhase("idle");
893
+ return;
894
+ }
895
+ setRefPhase("ingesting");
896
+ const ing = await request<unknown>(hugpyConfig.videoIngestUrl, {
897
+ method: "POST",
898
+ body: JSON.stringify({ path: upParsed.data.path }),
899
+ headers: { "Content-Type": "application/json" },
900
+ meta: { specKey: "studio", operation: "studio.movie.reference.ingest" },
901
+ });
902
+ if (!ing.ok) {
903
+ setRefError(describeAppError(errorOf(ing)));
904
+ setRefPhase("idle");
905
+ return;
906
+ }
907
+ const media = mediaRefSchema.safeParse(okValue(ing));
908
+ if (!media.success) {
909
+ setRefError("Malformed ingest response.");
910
+ setRefPhase("idle");
911
+ return;
912
+ }
913
+ if (media.data.kind !== "image") {
914
+ setRefError(`Ingested asset is "${media.data.kind}", not an image — pick a still.`);
915
+ setRefPhase("idle");
916
+ return;
917
+ }
918
+ setReferences((prev) => (prev.length >= REF_MAX ? prev : [...prev, media.data]));
919
+ setSelectedProfile(null); // an ad-hoc upload makes this an UNSAVED identity
920
+ setRefPhase("idle");
921
+ }, []);
922
+
923
+ // ── APPLYING A WORKFLOW PRESET IS A MERGE (operator ruling 2026-07-29) ──
924
+ //
925
+ // The preset supplies the WORKFLOW SLOTS — the joint that stitches the segments, the
926
+ // ratified geometry, and a budget sized for the binding this joint actually uses. The
927
+ // CONTENT is the operator's and survives untouched:
928
+ //
929
+ // • every goal PROMPT stays exactly as written (the old path replaced the whole
930
+ // timeline with the template's own scene prose);
931
+ // • every per-goal SEED stays;
932
+ // • the identity — references or a saved profile — is never clobbered (that rule was
933
+ // already right here and is now the rule for everything);
934
+ // • the movie START (start switcher) is untouched, as it always was;
935
+ // • rows are only ADDED, never removed: a workflow needs at least two segments to
936
+ // mean anything, so an empty timeline grows to two and a five-row timeline stays
937
+ // five. Losing a shot the operator wrote is not a preset's business.
938
+ //
939
+ // A budget the operator TYPED is likewise left alone (`budgetTouched`); a blank or
940
+ // preset-filled one is re-sized for the new joint, because that IS a workflow slot.
941
+ const applyWorkflow = useCallback(
942
+ (id: string) => {
943
+ const wf = workflowPresets.find((x) => x.id === id);
944
+ if (!wf) return;
945
+ const joint = wf.joint as JointMode;
946
+ setGoals((prev) => {
947
+ // Grow to the workflow's minimum, preserving every existing row verbatim.
948
+ const rows = prev.length >= wf.minSegments
949
+ ? prev.slice()
950
+ : [...prev, ...Array.from({ length: wf.minSegments - prev.length }, () => blankGoal())];
951
+ // The joint is the workflow. Row 0 is the ROOT and has no joint onto anything.
952
+ return rows.map((g, i) => (i === 0 ? g : { ...g, joint }));
953
+ });
954
+ if (wf.geometry) {
955
+ setWidth(wf.geometry.width);
956
+ setHeight(wf.geometry.height);
957
+ setFps(wf.geometry.fps);
958
+ }
959
+ if (!budgetTouched.current) {
960
+ const need = movieBudgetGb(renderPresets, {
961
+ joint,
962
+ identity: references.length > 0 || selectedProfile != null,
963
+ });
964
+ if (need != null) setVramBudget(String(need));
965
+ }
966
+ setFormMsg(null);
967
+ },
968
+ [workflowPresets, renderPresets, references.length, selectedProfile],
969
+ );
970
+
971
+ // ── template PREFILL (restored 2026-08-01; from the sidebar Settings-tab dropdown) ──
972
+ // Picking a template prefills EVERYTHING it names: the goal rows (prompt + joint + an
973
+ // optional seed offset), and — when carried — the resolution and vram budget. The movie
974
+ // START is deliberately left untouched (operator's established rule). IDENTITY: applying
975
+ // a template NEVER clobbers an already-attached identity; only when NONE is attached AND
976
+ // the template names a SAVED profile is that profile auto-attached — otherwise the
977
+ // existing "attach a reference" nudge stands.
978
+ const applyTemplate = useCallback((id: string) => {
979
+ const t = TEMPLATES.find((x) => x.id === id);
980
+ if (!t) return;
981
+ setGoals(t.goals.map((g) => ({ ...blankGoal(g.joint), prompt: g.prompt, seed: g.seed ?? "" })));
982
+ if (t.resolution) {
983
+ setWidth(t.resolution.width);
984
+ setHeight(t.resolution.height);
985
+ }
986
+ if (t.vramBudgetGb != null) {
987
+ setVramBudget(String(t.vramBudgetGb));
988
+ budgetTouched.current = false; // preset-filled, not typed — workflows may re-size it
989
+ }
990
+ setFormMsg(null);
991
+ // Never clobber an attached identity. Auto-attach only into an EMPTY identity slot
992
+ // and only from a SAVED profile the template names.
993
+ const hasIdentity = references.length > 0 || selectedProfile != null;
994
+ if (!hasIdentity && t.profile) {
995
+ const p = savedProfiles.find((x) => x.slug === t.profile);
996
+ if (p) {
997
+ void resolveProfileRefs(p).then((refs) => {
998
+ if (refs.length) {
999
+ setReferences(refs.slice(0, REF_MAX));
1000
+ setSelectedProfile({ slug: p.slug, name: p.name });
1001
+ }
1002
+ });
1003
+ }
1004
+ }
1005
+ }, [references.length, selectedProfile, savedProfiles, resolveProfileRefs]);
1006
+
1007
+ // ── goal-row editing ──
1008
+ const setGoalField = useCallback(
1009
+ (idx: number, patch: Partial<GoalRow>) => {
1010
+ setGoals((prev) => prev.map((g, i) => (i === idx ? { ...g, ...patch } : g)));
1011
+ },
1012
+ [],
1013
+ );
1014
+ const addGoal = useCallback(() => setGoals((prev) => [...prev, blankGoal()]), []);
1015
+ const removeGoal = useCallback((idx: number) => {
1016
+ // Keep at least one row — a movie needs a root segment.
1017
+ setGoals((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== idx)));
1018
+ }, []);
1019
+ const moveGoal = useCallback((idx: number, dir: -1 | 1) => {
1020
+ setGoals((prev) => {
1021
+ const j = idx + dir;
1022
+ if (j < 0 || j >= prev.length) return prev;
1023
+ const next = prev.slice();
1024
+ const [it] = next.splice(idx, 1);
1025
+ next.splice(j, 0, it);
1026
+ return next;
1027
+ });
1028
+ }, []);
1029
+
1030
+ // ── SEGMENTS STEPPER (spec §2) — say how many shots this movie has, up front ──
1031
+ // Materializes rows to reach N, TRUNCATES from the tail to shrink to N. Truncation
1032
+ // is the branch scrubber's own precedent (onBranchFromSegment below drops the rows
1033
+ // after the branch point rather than refusing) — and like it, the affordance names
1034
+ // what it will drop instead of dropping it silently. New rows inherit the joint of
1035
+ // the last row so a movie built as "extend motion" stays that way as it grows.
1036
+ const MAX_SEGMENTS = 24;
1037
+ const setSegmentCount = useCallback((n: number) => {
1038
+ if (!Number.isInteger(n) || n < 1 || n > MAX_SEGMENTS) return;
1039
+ setGoals((prev) => {
1040
+ if (n === prev.length) return prev;
1041
+ if (n < prev.length) return prev.slice(0, n);
1042
+ const joint: JointMode = prev[prev.length - 1]?.joint ?? "still";
1043
+ return [...prev, ...Array.from({ length: n - prev.length }, () => blankGoal(joint))];
1044
+ });
1045
+ }, []);
1046
+
1047
+ // ── the row → wire projection ──
1048
+ // ONE place turns editor rows into typed segment references, so the per-row assist
1049
+ // calls, the spread and the negatives all describe a row identically. `isDirection`
1050
+ // is the RESOLVED per-field operation, which is what makes the user's own text ride
1051
+ // as a `direction` (an instruction to act on) rather than as a scene to enhance.
1052
+ const goalInputs = useMemo<SpreadGoalInput[]>(
1053
+ () =>
1054
+ goals.map((g, i) => {
1055
+ const op = resolveOperation(
1056
+ fieldModes[g.key] ?? "auto",
1057
+ intents[g.key] ?? null,
1058
+ g.prompt.trim() === "",
1059
+ );
1060
+ return {
1061
+ key: g.key,
1062
+ prompt: g.prompt,
1063
+ // The EFFECTIVE negative (user text + the standard set when ticked) —
1064
+ // the spread/assist context describes the row as it will render.
1065
+ negative: composeNegative(g.negative, g.stdNegative),
1066
+ seed: g.seed,
1067
+ branchFrame: g.branchFrame,
1068
+ joint: g.joint,
1069
+ index: i,
1070
+ isDirection: op === "generate_from_direction",
1071
+ };
1072
+ }),
1073
+ [goals, fieldModes, intents],
1074
+ );
1075
+
1076
+ // The LOCKED identity block (§1e): sent ONLY for a SAVED profile, because the
1077
+ // backend requires a name and an unsaved pile of uploads has none — inventing one
1078
+ // ("the attached subject") is exactly the invention this block exists to prevent.
1079
+ const identityContext = useMemo<IdentityContextWire | undefined>(() => {
1080
+ if (!selectedProfile) return undefined;
1081
+ return {
1082
+ identity_id: selectedProfile.slug,
1083
+ name: selectedProfile.name,
1084
+ reference_asset_ids: references.map((r) => r.uri),
1085
+ };
1086
+ }, [selectedProfile, references]);
1087
+
1088
+ // TYPED CONTEXT for one row (§1c) — this segment, its neighbours, the locked
1089
+ // identity. This is what `context.hint` was never able to carry and what nothing
1090
+ // was ever filling: the joint mode arrives as a sentence, the branch frame as
1091
+ // "starts from frame N of the shot before it".
1092
+ const rowContext = useCallback(
1093
+ (i: number): AssistExtras["context"] => {
1094
+ const ctx: NonNullable<AssistExtras["context"]> = {};
1095
+ const here = goalInputs[i];
1096
+ if (!here) return ctx;
1097
+ ctx.segment = segmentRef(here);
1098
+ if (i > 0) ctx.previous_segment = segmentRef(goalInputs[i - 1]);
1099
+ if (i < goalInputs.length - 1) ctx.next_segment = segmentRef(goalInputs[i + 1]);
1100
+ if (identityContext) ctx.identity_profile = identityContext;
1101
+ return ctx;
1102
+ },
1103
+ [goalInputs, identityContext],
1104
+ );
1105
+
1106
+ // ── intent routing (§1d): classify on BLUR, never per keystroke ──
1107
+ // Skipped entirely when the user has picked an explicit mode (their choice wins) and
1108
+ // when the field is blank (blank is unambiguously "generate" — the backend
1109
+ // short-circuits it too, but not paying the round trip is better than being told).
1110
+ // Re-classifying identical text is also skipped: the backend caches by hash, which
1111
+ // is a latency nicety, not a licence to spam it.
1112
+ const classifyRow = useCallback(
1113
+ (key: string, text: string) => {
1114
+ if ((fieldModes[key] ?? "auto") !== "auto") return;
1115
+ const t = text.trim();
1116
+ if (t === "") {
1117
+ setIntents((prev) => (prev[key] == null ? prev : { ...prev, [key]: null }));
1118
+ intentText.current[key] = "";
1119
+ return;
1120
+ }
1121
+ if (intentText.current[key] === t) return;
1122
+ intentText.current[key] = t;
1123
+ setIntentBusy((prev) => ({ ...prev, [key]: true }));
1124
+ void assist.classifyIntent(t, "segment").then((res) => {
1125
+ setIntents((prev) => ({ ...prev, [key]: res }));
1126
+ setIntentBusy((prev) => ({ ...prev, [key]: false }));
1127
+ });
1128
+ },
1129
+ [assist, fieldModes],
1130
+ );
1131
+
1132
+ // ── per-row assist, now context-aware ──
1133
+ // The row's RESOLVED operation only chooses which mode the primary button invokes;
1134
+ // both explicit buttons stay live underneath, so a router mistake costs one click,
1135
+ // never a paragraph of the user's work.
1136
+ const runRowAssist = useCallback(
1137
+ (i: number, mode: "detail" | "generate") => {
1138
+ const g = goals[i];
1139
+ if (!g) return;
1140
+ void assist.runAssist(mode, g.prompt, (p) => setGoalField(i, { prompt: p }), {
1141
+ context: rowContext(i),
1142
+ });
1143
+ // The text is about to change, so the old classification no longer describes it.
1144
+ intentText.current[g.key] = "";
1145
+ setIntents((prev) => ({ ...prev, [g.key]: null }));
1146
+ },
1147
+ [assist, goals, rowContext, setGoalField],
1148
+ );
1149
+
1150
+ // ── negative generation (§1b) — an exclusion list for THIS shot ──
1151
+ const runRowNegative = useCallback(
1152
+ (i: number) => {
1153
+ const g = goals[i];
1154
+ if (!g) return;
1155
+ void assist.runNegative(
1156
+ { subject: g.prompt, draft: g.negative, extra: { context: rowContext(i) } },
1157
+ (neg) => setGoalField(i, { negative: neg }),
1158
+ );
1159
+ },
1160
+ [assist, goals, rowContext, setGoalField],
1161
+ );
1162
+
1163
+ // ── GROUP ACTION: one coherent spread across the selected rows (§1a) ──
1164
+ // ONE call. Unselected rows ride as LOCKED context so the generator can see the
1165
+ // whole timeline, and the reply may only land on rows whose segment_id came back —
1166
+ // both halves are enforced here as well as server-side, because "the checkbox means
1167
+ // something" is the property the whole feature rests on.
1168
+ const onSpreadSelected = useCallback(async () => {
1169
+ const body = buildSpreadBody({
1170
+ goals: goalInputs,
1171
+ selectedKeys,
1172
+ movieQuery,
1173
+ globalNegative: negative,
1174
+ steeringSeed: pinSteering ? steeringSeed : null,
1175
+ context: identityContext ? { identity_profile: identityContext } : undefined,
1176
+ // The only style_bible key we can fill WITHOUT inventing anything: the saved
1177
+ // identity's own name. Everything else in the bible is authored content this
1178
+ // surface does not collect, and guessing it would be the invention defect.
1179
+ styleBible: selectedProfile ? { subject: selectedProfile.name } : undefined,
1180
+ });
1181
+ if (!body) return;
1182
+ setSpreadNotice(null);
1183
+ const res = await assist.runSpread(body);
1184
+ if (!res) return;
1185
+
1186
+ const bySeg = new Map(res.segments.map((s) => [s.segment_id, s]));
1187
+ const applied = goals
1188
+ .filter((g) => selectedKeys.has(g.key) && bySeg.has(g.key))
1189
+ .map((g) => g.key);
1190
+ setGoals((prev) =>
1191
+ prev.map((g) => {
1192
+ if (!selectedKeys.has(g.key)) return g; // an unselected row is never touched
1193
+ const hit = bySeg.get(g.key);
1194
+ if (!hit) return g; // a row the generator skipped: unchanged
1195
+ // An explicit Generate on a SELECTED row is meant to replace that row's prose —
1196
+ // that is the point of selecting it. A blank returned negative still falls back
1197
+ // to what the user wrote rather than blanking it.
1198
+ return { ...g, prompt: hit.prompt, negative: hit.negative || g.negative };
1199
+ }),
1200
+ );
1201
+ for (const key of applied) {
1202
+ intentText.current[key] = "";
1203
+ }
1204
+ setIntents((prev) => {
1205
+ const next = { ...prev };
1206
+ for (const key of applied) next[key] = null;
1207
+ return next;
1208
+ });
1209
+ if (res.steering_seed != null) setSteeringSeed(res.steering_seed);
1210
+ setSpreadNotice(spreadNoticeLines(res, applied));
1211
+ }, [
1212
+ assist,
1213
+ goalInputs,
1214
+ goals,
1215
+ identityContext,
1216
+ movieQuery,
1217
+ negative,
1218
+ pinSteering,
1219
+ selectedKeys,
1220
+ selectedProfile,
1221
+ steeringSeed,
1222
+ ]);
1223
+
1224
+ // ── GROUP ACTION: negatives for the selected rows ──
1225
+ // Sequential per-row calls ON PURPOSE, unlike prompts: a negative is an exclusion
1226
+ // list for ONE shot, so there is no cross-row coherence to lose — and each row's
1227
+ // own prompt is the subject being negated. Stops at the first failure rather than
1228
+ // hammering a dead worker N times; the reason is on the shared assist error.
1229
+ const [negBatch, setNegBatch] = useState<{ done: number; total: number } | null>(null);
1230
+ const onNegativesSelected = useCallback(async () => {
1231
+ const rows = goals.map((g, i) => ({ g, i })).filter(({ g }) => selectedKeys.has(g.key));
1232
+ if (rows.length === 0) return;
1233
+ setSpreadNotice(null);
1234
+ let done = 0;
1235
+ setNegBatch({ done: 0, total: rows.length });
1236
+ for (const { g, i } of rows) {
1237
+ let wrote = false;
1238
+ await assist.runNegative(
1239
+ { subject: g.prompt, draft: g.negative, extra: { context: rowContext(i) } },
1240
+ (neg) => {
1241
+ wrote = true;
1242
+ setGoalField(i, { negative: neg });
1243
+ },
1244
+ );
1245
+ if (!wrote) break;
1246
+ done += 1;
1247
+ setNegBatch({ done, total: rows.length });
1248
+ }
1249
+ setNegBatch(null);
1250
+ setSpreadNotice([
1251
+ done === rows.length
1252
+ ? `${done} negative prompt(s) written.`
1253
+ : `${done} of ${rows.length} negative prompt(s) written — the rest are unchanged.`,
1254
+ ]);
1255
+ }, [assist, goals, rowContext, selectedKeys, setGoalField]);
1256
+
1257
+ // ── B3 scrubber → append a BRANCH goal (the "Branch from frame N" action) ──
1258
+ // MID-ROW BRANCH CHOICE (option A — TRUNCATE, stated in the report + here): v0 authors
1259
+ // a LINEAR chain, so a branch is a new child appended after `segmentIndex`, conditioned
1260
+ // on `frame` of that segment (its parent). Branching from the LAST authored row is a
1261
+ // clean extension. Branching from a NON-final row cannot keep the old tail as a SIBLING
1262
+ // take in a linear timeline (that needs the schema's take-tree — the next slice), so we
1263
+ // TRUNCATE the authored rows after `segmentIndex` and re-continue from there. We chose
1264
+ // truncate over refuse so the operator's "divergence anywhere · the user's options
1265
+ // decide" works everywhere in v0; the scrubber's action LABEL names the dropped rows so
1266
+ // the cut is never silent (honesty doctrine). The render itself is a fresh enqueue —
1267
+ // studio jobs are IMMUTABLE, so this authors a NEW extended movie, never mutating the old.
1268
+ const onBranchFromSegment = useCallback((segmentIndex: number, frame: number) => {
1269
+ const key = newGoalKey();
1270
+ setGoals((prev) => {
1271
+ const kept = prev.slice(0, Math.min(prev.length, segmentIndex + 1));
1272
+ // A scrubber-branched take continues from the chosen frame — a "still" joint.
1273
+ return [
1274
+ ...kept,
1275
+ {
1276
+ key,
1277
+ prompt: "",
1278
+ negative: "",
1279
+ stdNegative: false,
1280
+ seed: "",
1281
+ branchFrame: String(frame),
1282
+ joint: "still" as JointMode,
1283
+ },
1284
+ ];
1285
+ });
1286
+ setPendingFocusKey(key);
1287
+ setFormMsg(null);
1288
+ }, []);
1289
+
1290
+ // Focus the just-appended branch goal's prompt (and bring it into view) once mounted.
1291
+ useEffect(() => {
1292
+ if (!pendingFocusKey) return;
1293
+ // k88: the prompt pane now lives inside PromptFieldTabs — `${idPrefix}-prompt`.
1294
+ const el = document.getElementById(`vi-movie-${pendingFocusKey}-prompt`);
1295
+ if (el) {
1296
+ (el as HTMLTextAreaElement).focus();
1297
+ el.scrollIntoView({ block: "center", behavior: "smooth" });
1298
+ }
1299
+ setPendingFocusKey(null);
1300
+ }, [pendingFocusKey, goals]);
1301
+
1302
+ // ── build the exact request body the route reads ──
1303
+ const buildBody = useCallback((): { body: Record<string, unknown> } | { err: string } => {
1304
+ if (goals.length < 1) return { err: "Add at least one goal (the root segment)." };
1305
+ for (let i = 0; i < goals.length; i++) {
1306
+ if (goals[i].prompt.trim() === "") {
1307
+ return { err: `Goal ${i + 1} needs a prompt (every segment renders its text).` };
1308
+ }
1309
+ const s = goals[i].seed.trim();
1310
+ if (s !== "" && !Number.isInteger(Number(s))) {
1311
+ return { err: `Goal ${i + 1} seed must be a whole number (or blank).` };
1312
+ }
1313
+ // Per-segment sampler overrides — the SAME bounds as the movie-level knobs
1314
+ // below (and the schema's _check_steps_cfg): blank = inherit.
1315
+ const gSteps = goals[i].steps.trim();
1316
+ if (gSteps !== "" && (!Number.isInteger(Number(gSteps)) || Number(gSteps) < 1 || Number(gSteps) > 100)) {
1317
+ return { err: `Goal ${i + 1} steps must be a whole number in [1, 100] (or blank to inherit).` };
1318
+ }
1319
+ const gCfg = goals[i].cfg.trim();
1320
+ if (gCfg !== "" && (Number.isNaN(Number(gCfg)) || Number(gCfg) < 0 || Number(gCfg) > 20)) {
1321
+ return { err: `Goal ${i + 1} CFG must be a number in [0, 20] (or blank to inherit).` };
1322
+ }
1323
+ const gCtx = goals[i].contextFrames.trim();
1324
+ if (gCtx !== "" && (!Number.isInteger(Number(gCtx)) || Number(gCtx) < 1)) {
1325
+ return { err: `Goal ${i + 1} context frames must be a whole number ≥ 1 (or blank to inherit).` };
1326
+ }
1327
+ // A "cut" carries no frame, so its branch-frame input is disabled + ignored; only
1328
+ // validate a branch frame for a still / vace_extend joint.
1329
+ if (i > 0 && goals[i].joint !== "cut") {
1330
+ const bf = goals[i].branchFrame.trim();
1331
+ if (bf !== "" && (!Number.isInteger(Number(bf)) || Number(bf) < 0)) {
1332
+ return {
1333
+ err: `Goal ${i + 1} branch frame must be a whole number ≥ 0 (or blank = last frame).`,
1334
+ };
1335
+ }
1336
+ }
1337
+ }
1338
+ for (const [name, val] of [["width", width], ["height", height], ["fps", fps]] as const) {
1339
+ if (!Number.isInteger(val) || val <= 0) {
1340
+ return { err: `${name} must be a positive whole number.` };
1341
+ }
1342
+ }
1343
+ const budgetNum = vramBudget.trim() === "" ? undefined : Number(vramBudget);
1344
+ if (budgetNum !== undefined && (Number.isNaN(budgetNum) || budgetNum <= 0)) {
1345
+ return { err: "VRAM budget must be a positive number (or blank for the route default)." };
1346
+ }
1347
+ // Movie-level sampler overrides — same bounds the clip surface (and the movie
1348
+ // schema's _check_steps_cfg) enforce: steps int [1,100], cfg number [0,20]. Blank
1349
+ // drops the key (model default).
1350
+ const stepsNum = steps.trim() === "" ? undefined : Number(steps);
1351
+ if (
1352
+ stepsNum !== undefined &&
1353
+ (!Number.isInteger(stepsNum) || stepsNum < 1 || stepsNum > 100)
1354
+ ) {
1355
+ return { err: "Steps must be a whole number in [1, 100] (or blank for the model default)." };
1356
+ }
1357
+ const cfgNum = cfg.trim() === "" ? undefined : Number(cfg);
1358
+ if (cfgNum !== undefined && (Number.isNaN(cfgNum) || cfgNum < 0 || cfgNum > 20)) {
1359
+ return { err: "CFG must be a number in [0, 20] (or blank for the model default)." };
1360
+ }
1361
+
1362
+ const goalBodies = goals.map((g, i) => {
1363
+ const node: Record<string, unknown> = { prompt: g.prompt };
1364
+ const s = g.seed.trim();
1365
+ if (s !== "") node.seed = Number(s);
1366
+ // Per-segment negative (operator addendum 2026-07-12): send `negative` ONLY when
1367
+ // the operator typed one — blank drops the key so the runner falls back to the
1368
+ // movie-level negative_prompt (schema: StudioMovieGoal.negative None ⇒ default).
1369
+ // k88: the standard set is COMPOSED in here when the row's tick is on, so an
1370
+ // empty user negative + a tick still sends the standard exclusions.
1371
+ const effNegative = composeNegative(g.negative, g.stdNegative);
1372
+ if (effNegative !== "") node.negative = effNegative;
1373
+ // Per-segment model/sampler overrides (2026-08-12): blank drops the key so
1374
+ // the segment inherits the movie-level pin/override (or the router/model
1375
+ // default) — the exact fallback chain StudioMovieGoal documents.
1376
+ if (g.modelId.trim() !== "") node.model_id = g.modelId.trim();
1377
+ if (g.steps.trim() !== "") node.steps = Number(g.steps);
1378
+ if (g.cfg.trim() !== "") node.cfg = Number(g.cfg);
1379
+ // joint_mode + branch_frame are meaningless on the root (segment 0 has no parent).
1380
+ if (i > 0) {
1381
+ // Send a non-default joint_mode ("vace_extend" | "cut"); "still" is the route default.
1382
+ if (g.joint !== "still") node.joint_mode = g.joint;
1383
+ // branch_frame only for still / vace_extend (a cut carries no frame).
1384
+ if (g.joint !== "cut") {
1385
+ const bf = g.branchFrame.trim();
1386
+ if (bf !== "") node.branch_frame = Number(bf);
1387
+ }
1388
+ // context_frames is only consulted for a vace_extend joint (schema note) —
1389
+ // sending it otherwise would imply a knob that does nothing.
1390
+ if (g.joint === "vace_extend" && g.contextFrames.trim() !== "") {
1391
+ node.context_frames = Number(g.contextFrames);
1392
+ }
1393
+ }
1394
+ return node;
1395
+ });
1396
+
1397
+ const body: Record<string, unknown> = {
1398
+ resolution: { width, height, fps },
1399
+ seed,
1400
+ vram_budget_gb: budgetNum,
1401
+ // Movie-level model pin + sampler overrides (blank ⇒ key dropped = router/model
1402
+ // default). The route reads these movie-level and applies them to every segment a
1403
+ // node doesn't override (studio_movie_schema: model_id/steps/cfg movie-level defaults).
1404
+ model_id: modelId.trim() ? modelId.trim() : undefined,
1405
+ steps: stepsNum,
1406
+ cfg: cfgNum,
1407
+ negative_prompt: negative.trim() ? negative : undefined,
1408
+ project: project.trim() ? project.trim() : undefined,
1409
+ // The movie's NAME (k91) — free text, display-only: it never changes what renders,
1410
+ // it is what the sessions list shows instead of a bare dir id. Blank drops the key
1411
+ // (the schema takes title=None and the session lists as "untitled").
1412
+ title: movieTitle.trim() ? movieTitle.trim() : undefined,
1413
+ // Optional root conditioning still — the route jail-resolves this abs uri and
1414
+ // renders segment 0 i2v (drops back to t2v when absent). Mirrors the clip surface.
1415
+ start_image: startImage ? startImage.uri : undefined,
1416
+ // IDENTITY (unified): a SAVED profile is the canonical identity — send its slug (the
1417
+ // route resolves it server-side; a later profile edit re-resolves). Non-empty identity
1418
+ // (either form) -> an identity movie (every segment renders id_lock so the subject
1419
+ // carries across scene changes). Raw reference_images ride only for an UNSAVED identity.
1420
+ identity_profile: selectedProfile ? selectedProfile.slug : undefined,
1421
+ reference_images:
1422
+ !selectedProfile && references.length ? references.map((r) => r.uri) : undefined,
1423
+ goals: goalBodies,
1424
+ };
1425
+ return { body };
1426
+ }, [goals, width, height, fps, seed, vramBudget, modelId, steps, cfg, negative, project, movieTitle, startImage, references, selectedProfile]);
1427
+
1428
+ // NAME NUDGE (k91) — set when a submit was held back only because the movie has no
1429
+ // name. It is a warning, not a gate: the run row then offers "Generate without a
1430
+ // name", and `onGenerate(true)` skips the check. Cleared whenever a name is typed.
1431
+ const [nameWarn, setNameWarn] = useState(false);
1432
+
1433
+ const onGenerate = useCallback(
1434
+ (skipNameCheck = false) => {
1435
+ const built = buildBody();
1436
+ if ("err" in built) {
1437
+ setFormMsg(built.err);
1438
+ return;
1439
+ }
1440
+ // Warn ONCE per unnamed submit. An unnamed movie is still fully renderable — the
1441
+ // cost is later, in a sessions list where it reads as "untitled · 7f3a91c2…" and
1442
+ // nobody can tell which paused render is which.
1443
+ if (!skipNameCheck && movieTitle.trim() === "") {
1444
+ setNameWarn(true);
1445
+ setFormMsg(null);
1446
+ return;
1447
+ }
1448
+ setNameWarn(false);
1449
+ setFormMsg(null);
1450
+ job.run(built.body);
1451
+ },
1452
+ [buildBody, job, movieTitle],
1453
+ );
1454
+
1455
+ // ── derive playback refs from the terminal result ──
1456
+ // Outputs = [segment clips…, movie.mp4 LAST] (the runner appends the assembled
1457
+ // movie only when assembly produced one). Pair each segment ref with its manifest
1458
+ // record for the joint metadata; the movie.mp4 is the trailing ref.
1459
+ const view = useMemo(() => {
1460
+ const result = job.result;
1461
+ if (!result) return null;
1462
+ const outputs: MediaRef[] = result.outputs ?? [];
1463
+ const segs = result.movie?.segments ?? [];
1464
+ const segCount = segs.length || outputs.length;
1465
+ const assembled = !!result.movie?.assembly?.movie && outputs.length > segCount;
1466
+ const movieRef = assembled ? outputs[outputs.length - 1] : null;
1467
+ const segmentRefs = outputs.slice(0, segCount);
1468
+ return { result, segs, segmentRefs, movieRef, assembled };
1469
+ }, [job.result]);
1470
+
1471
+ const isDone = job.status === "done";
1472
+ const isFailed = job.status === "failed" || job.status === "cancelled";
1473
+
1474
+ const disableSubmit = job.running;
1475
+
1476
+ // ── SYNTHETIC-TIER HONESTY BANNER (operator ask 2026-07-12) ──
1477
+ // Same footgun as the Clip surface (see StudioGenerateTab's wouldBindSynthetic):
1478
+ // a blank/sub-floor budget silently binds synthetic-t2v (root has no start_image)
1479
+ // or synthetic-i2v (root has one) instead of a real render. IDENTITY MOVIES are
1480
+ // exempt — the moment `references` is non-empty every segment renders id_lock
1481
+ // (see the enqueue body below), and id_lock has no synthetic model registered at
1482
+ // all (studio/models_seed.py), so a too-low budget there fails the router
1483
+ // honestly instead of ever producing colored noise.
1484
+ //
1485
+ // ⚠ THE FLOOR IS MEASURED NOW (2026-07-29): it comes from the movie capability's own
1486
+ // `vram_envelope_gb` in the ratified table, not from the hand-mirrored
1487
+ // STUDIO_REAL_FLOOR_GB — which said 6 while the cheapest real row is 8.2, so a budget
1488
+ // of 7 bound the synthetic prover and this banner stayed quiet. The constant survives
1489
+ // only as the fallback for a page whose discovery GET has not landed.
1490
+ const hasIdentity = references.length > 0 || selectedProfile != null;
1491
+ const movieFloorGb =
1492
+ (renderLoaded ? suggestedBudgetGb(renderPresets, MOVIE_CAPABILITY) : null)
1493
+ ?? STUDIO_REAL_FLOOR_GB;
1494
+ // What THIS movie actually needs, given its joints and whether an identity is attached
1495
+ // (an identity movie renders EVERY segment id_lock, so it binds the VACE row whatever
1496
+ // the joint says). Offered as the one-click fill rather than a fixed 10.
1497
+ const movieNeedGb =
1498
+ (renderLoaded
1499
+ ? movieBudgetGb(renderPresets, {
1500
+ joint: goals[1]?.joint ?? "cut",
1501
+ identity: hasIdentity,
1502
+ })
1503
+ : null) ?? STUDIO_SAFE_FILL_GB;
1504
+ const movieGeomOutOfEnvelope =
1505
+ renderLoaded && !withinGeometry(renderPresets, MOVIE_CAPABILITY, width, height);
1506
+ const wouldBindSynthetic =
1507
+ references.length === 0 && bindsSyntheticTier(vramBudget, movieFloorGb);
1508
+
1509
+ // Movie-mode SETTINGS panel — portaled into the sidebar Settings tab (the SAME host the
1510
+ // Clip surface's knobGrid uses), so the movie's generation options live on the shared
1511
+ // Settings surface exactly like the Clip surface's knobs (operator ask 2026-07-13: "the
1512
+ // Settings tab only consists of templates — I'd like a wide range of options including the
1513
+ // model selection"). ONE portaled node (a single `vi-studio-knobs` grid) grown from the
1514
+ // old templates-only node, mirroring the Clip knobGrid idiom — templates first, then the
1515
+ // model pin + sampler overrides + geometry/seed/budget/project. The movie-wide NEGATIVE
1516
+ // stays in the center column (a wide prompt textarea, kept beside the goals like the Clip
1517
+ // surface keeps its negative out of the knob grid).
1518
+ const settingsNode = (
1519
+ <div className="vi-studio-knobs">
1520
+ {/* ABOUT — the condensed what-is-this / how-to prose (k89), collapsed at the
1521
+ top of the Settings-tab content. Absorbs the old inline intro block and
1522
+ the "movie idea" explanation (one short functional line stays inline). */}
1523
+ <div className="vi-studio-knob-wide" style={{ flex: "1 1 100%" }}>
1524
+ <AboutExpander title="About Cinema">
1525
+ <p>
1526
+ Cinema renders an ordered strip of REAL studio clips — each goal below is
1527
+ one segment, spliced onto the last at its joint.
1528
+ </p>
1529
+ <p>
1530
+ Per segment, pick how it follows: continue (from one frame), extend
1531
+ motion (VACE), or a scene cut (a fresh render; the previous segment plays
1532
+ in full).
1533
+ </p>
1534
+ <p>
1535
+ Add an identity reference to carry the SAME subject across every scene —
1536
+ each segment then renders id_lock.
1537
+ </p>
1538
+ <p>
1539
+ The movie idea + ✨ Generate prompts write every ticked segment against
1540
+ ONE shared world; the steering-seed pin keeps a follow-up spread in that
1541
+ same world.
1542
+ </p>
1543
+ <p>
1544
+ v0 authors a linear chain; once rendered, the spliced row shows each
1545
+ joint and lets you scrub a segment to branch a new take.
1546
+ </p>
1547
+ </AboutExpander>
1548
+ </div>
1549
+ {/* WORKFLOW PRESETS — derived from the ratified movie preset's own joint modes.
1550
+ Each row is a SHAPE, not a scene: it sets the joint on your segments, the
1551
+ ratified geometry and a budget sized for the binding that joint really uses,
1552
+ and leaves every prompt, seed, identity and start image exactly as you left
1553
+ them. Unproven joints say so. */}
1554
+ {/* MOVIE TEMPLATES (restored 2026-08-01) — a SCENE prefill, distinct from the
1555
+ workflow presets below: a template writes the goal prompts/joints/seeds (and,
1556
+ when carried, resolution + budget + a saved identity), where a workflow only
1557
+ sets the shape. */}
1558
+ <div className="vi-knob vi-studio-knob-wide">
1559
+ <label htmlFor="vi-movie-scene-template">Movie templates (scenes)</label>
1560
+ <select
1561
+ id="vi-movie-scene-template"
1562
+ className="vi-knob-select"
1563
+ value=""
1564
+ disabled={disableSubmit}
1565
+ onChange={(e) => {
1566
+ const v = e.target.value;
1567
+ if (v) applyTemplate(v);
1568
+ }}
1569
+ >
1570
+ <option value="">Choose a template — prefills the scenes</option>
1571
+ {TEMPLATES.map((t) => (
1572
+ <option key={t.id} value={t.id} title={t.hint}>
1573
+ {t.name}
1574
+ </option>
1575
+ ))}
1576
+ </select>
1577
+ <span className="vi-knob-hint">
1578
+ Prefills the goal rows below (prompts, joints, seeds). An attached identity is
1579
+ never replaced — add your reference or pick a profile, then Generate.
1580
+ </span>
1581
+ </div>
1582
+
1583
+ <div className="vi-knob vi-studio-knob-wide">
1584
+ <label htmlFor="vi-movie-template">Movie preset (workflow)</label>
1585
+ <select
1586
+ id="vi-movie-template"
1587
+ className="vi-knob-select"
1588
+ value=""
1589
+ disabled={disableSubmit || workflowPresets.length === 0}
1590
+ onChange={(e) => {
1591
+ const v = e.target.value;
1592
+ if (v) applyWorkflow(v);
1593
+ }}
1594
+ >
1595
+ <option value="">
1596
+ {renderLoaded
1597
+ ? "Choose a workflow — sets the joint, geometry and budget"
1598
+ : "Loading what this fleet can render…"}
1599
+ </option>
1600
+ {workflowPresets.map((w) => (
1601
+ <option key={w.id} value={w.id} title={w.hint}>
1602
+ {w.name}
1603
+ {w.proven ? "" : " · unproven"}
1604
+ {w.minBudgetGb != null ? ` · ≥${Math.ceil(w.minBudgetGb)} GB` : ""}
1605
+ </option>
1606
+ ))}
1607
+ </select>
1608
+ <span className="vi-knob-hint">
1609
+ A preset sets the WORKFLOW — which joint stitches your shots, the geometry this
1610
+ fleet has ratified, and a budget sized for that joint&apos;s binding. Your shot
1611
+ prompts, seeds, identity and start image are never touched; rows are only added
1612
+ if you have fewer than two. Rows marked “unproven” have a ratified path that has
1613
+ not yet produced a clip{renderBox ? ` on ${renderBox}` : ""}.
1614
+ </span>
1615
+ </div>
1616
+
1617
+ {/* MODEL PIN — blank-first "Auto" (the capability router picks by budget + resolution);
1618
+ an explicit pin overrides for every segment a node doesn't itself override. Mirrors
1619
+ the Clip surface's model select (StudioGenerateTab knobGrid).
1620
+ 2026-07-29: the menu is now the models a RATIFIED movie preset actually binds —
1621
+ the movie row's own model plus every model named by the segment presets it
1622
+ `composes` — instead of the unfiltered STUDIO_MODEL_IDS mirror. The escape hatch
1623
+ keeps the whole list reachable, labelled unverified. */}
1624
+ <div className="vi-knob vi-studio-knob-wide">
1625
+ <label htmlFor="vi-movie-model">model</label>
1626
+ <select
1627
+ id="vi-movie-model"
1628
+ className="vi-knob-select"
1629
+ value={modelId}
1630
+ disabled={disableSubmit}
1631
+ onChange={(e) => setModelId(e.target.value)}
1632
+ >
1633
+ <option value="">Auto — router picks by capability/budget</option>
1634
+ {movieModels.length > 0 && (
1635
+ <optgroup label="Bound by a ratified movie segment">
1636
+ {movieModels.map((id) => (
1637
+ <option key={id} value={id}>
1638
+ {id}
1639
+ </option>
1640
+ ))}
1641
+ </optgroup>
1642
+ )}
1643
+ {(showAllModels || !renderLoaded || (modelId !== "" && !movieModels.includes(modelId))) && (
1644
+ <optgroup label={renderLoaded ? "Other studio models — unverified for a movie segment" : "Studio models"}>
1645
+ {STUDIO_MODEL_IDS.filter((id) => !movieModels.includes(id)).map((id) => (
1646
+ <option key={id} value={id}>
1647
+ {id}
1648
+ {renderLoaded ? " · unverified" : ""}
1649
+ </option>
1650
+ ))}
1651
+ </optgroup>
1652
+ )}
1653
+ </select>
1654
+ <span className="vi-knob-hint">
1655
+ Blank = the router picks per segment. An unroutable pin comes back as an error on
1656
+ the job, never a silent fallback.
1657
+ </span>
1658
+ {renderLoaded && (
1659
+ <label className="vi-knob-hint" style={{ display: "flex", gap: "0.35rem", alignItems: "center" }}>
1660
+ <input
1661
+ type="checkbox"
1662
+ checked={showAllModels}
1663
+ disabled={disableSubmit}
1664
+ onChange={(e) => setShowAllModels(e.target.checked)}
1665
+ />
1666
+ Advanced — show every studio model (off-menu pins are unverified for a movie segment)
1667
+ </label>
1668
+ )}
1669
+ </div>
1670
+
1671
+ {/* SAMPLER overrides — blank = the model default. Same bounds the clip surface + the
1672
+ movie schema enforce (steps [1,100], cfg [0,20]). */}
1673
+ <div className="vi-knob">
1674
+ <label htmlFor="vi-movie-steps">steps</label>
1675
+ <input
1676
+ id="vi-movie-steps"
1677
+ className="vi-knob-input"
1678
+ type="number"
1679
+ min={1}
1680
+ max={100}
1681
+ value={steps}
1682
+ placeholder="model default"
1683
+ disabled={disableSubmit}
1684
+ onChange={(e) => setSteps(e.target.value)}
1685
+ />
1686
+ </div>
1687
+ <div className="vi-knob">
1688
+ <label htmlFor="vi-movie-cfg">cfg</label>
1689
+ <input
1690
+ id="vi-movie-cfg"
1691
+ className="vi-knob-input"
1692
+ type="number"
1693
+ min={0}
1694
+ max={20}
1695
+ step={0.5}
1696
+ value={cfg}
1697
+ placeholder="model default"
1698
+ disabled={disableSubmit}
1699
+ onChange={(e) => setCfg(e.target.value)}
1700
+ />
1701
+ </div>
1702
+
1703
+ {/* GEOMETRY — moved here from the center column so the Settings tab is the single home
1704
+ for the movie's generation options (like the Clip surface's knob grid). */}
1705
+ <div className="vi-knob">
1706
+ <label htmlFor="vi-movie-w">width (px)</label>
1707
+ <input
1708
+ id="vi-movie-w"
1709
+ className="vi-knob-input"
1710
+ type="number"
1711
+ min={64}
1712
+ step={8}
1713
+ value={width}
1714
+ disabled={disableSubmit}
1715
+ onChange={(e) => setWidth(Number(e.target.value) || 0)}
1716
+ />
1717
+ </div>
1718
+ <div className="vi-knob">
1719
+ <label htmlFor="vi-movie-h">height (px)</label>
1720
+ <input
1721
+ id="vi-movie-h"
1722
+ className="vi-knob-input"
1723
+ type="number"
1724
+ min={64}
1725
+ step={8}
1726
+ value={height}
1727
+ disabled={disableSubmit}
1728
+ onChange={(e) => setHeight(Number(e.target.value) || 0)}
1729
+ />
1730
+ </div>
1731
+ <div className="vi-knob">
1732
+ <label htmlFor="vi-movie-fps">fps</label>
1733
+ <input
1734
+ id="vi-movie-fps"
1735
+ className="vi-knob-input"
1736
+ type="number"
1737
+ min={1}
1738
+ max={60}
1739
+ value={fps}
1740
+ disabled={disableSubmit}
1741
+ title={
1742
+ movieGeometry
1743
+ ? `Ratified movie envelope: ${movieGeometry.width}×${movieGeometry.height} @ ${movieGeometry.fps}fps (“${movieGeometry.presetTitle}”).`
1744
+ : undefined
1745
+ }
1746
+ onChange={(e) => setFps(Number(e.target.value) || 0)}
1747
+ />
1748
+ {movieGeomOutOfEnvelope && (
1749
+ <span className="vi-knob-flag">
1750
+ {width}×{height} is outside the ratified movie envelope
1751
+ {movieGeometry ? ` (${movieGeometry.width}×${movieGeometry.height})` : ""} — no
1752
+ proven segment binding covers it and the router may refuse. An identity movie is
1753
+ the strictest case: every segment renders id_lock, whose only budget-fitting
1754
+ model tops out at 832×480.
1755
+ </span>
1756
+ )}
1757
+ </div>
1758
+ <div className="vi-knob">
1759
+ <label htmlFor="vi-movie-seed">base seed</label>
1760
+ <input
1761
+ id="vi-movie-seed"
1762
+ className="vi-knob-input"
1763
+ type="number"
1764
+ min={0}
1765
+ value={seed}
1766
+ disabled={disableSubmit}
1767
+ onChange={(e) => setSeed(Number(e.target.value) || 0)}
1768
+ />
1769
+ <span className="vi-knob-hint">A segment with no seed uses base seed + its index.</span>
1770
+ </div>
1771
+ <div className="vi-knob">
1772
+ <label htmlFor="vi-movie-vram">vram budget (GB)</label>
1773
+ <input
1774
+ id="vi-movie-vram"
1775
+ className="vi-knob-input"
1776
+ type="number"
1777
+ min={0}
1778
+ step={0.5}
1779
+ value={vramBudget}
1780
+ placeholder="route default"
1781
+ disabled={disableSubmit}
1782
+ onChange={(e) => {
1783
+ // Marks the field OPERATOR-OWNED: a workflow preset then fills nothing over
1784
+ // it. Clearing the field hands it back to the presets.
1785
+ budgetTouched.current = e.target.value.trim() !== "";
1786
+ setVramBudget(e.target.value);
1787
+ }}
1788
+ />
1789
+ <span className="vi-knob-hint">
1790
+ {renderLoaded
1791
+ ? `Blank = autofit to the card. This movie needs at least ${movieNeedGb} GB${hasIdentity ? " — an identity movie renders every segment id_lock, which binds the VACE row whatever the joint says" : ""}.`
1792
+ : "Blank = route default (sub-real binds the synthetic tier)."}
1793
+ </span>
1794
+ </div>
1795
+ <div className="vi-knob vi-studio-knob-wide">
1796
+ <label htmlFor="vi-movie-project">project name (optional)</label>
1797
+ <input
1798
+ id="vi-movie-project"
1799
+ className="vi-knob-input"
1800
+ type="text"
1801
+ value={project}
1802
+ placeholder="auto-named"
1803
+ disabled={disableSubmit}
1804
+ onChange={(e) => setProject(e.target.value)}
1805
+ />
1806
+ </div>
1807
+ </div>
1808
+ );
1809
+
1810
+ return (
1811
+ <div className="vi-studio-generate">
1812
+ {/* The movie Settings panel lives in the left-sidebar Settings tab (portaled into
1813
+ settingsHost, like the Clip surface's knobGrid): templates + model pin + sampler
1814
+ + geometry/seed/budget/project; null until that panel mounts. */}
1815
+ {settingsHost ? createPortal(settingsNode, settingsHost) : null}
1816
+ {/* (The intro prose moved into the sidebar "About Cinema" expander — k89.) */}
1817
+
1818
+ {/* ── CINEMA SESSIONS: relocated (2026-08-12). The recovery surface now lives in
1819
+ the Active display's Sessions panel (▤ Active drawer / the /active station),
1820
+ sectioned per surface (Scene · Movie · Clip · Cinema) — reachable from every
1821
+ station instead of only this composer. ── */}
1822
+
1823
+ {/* ── MOVIE NAME — what this session will be called in the list above. Kept at the
1824
+ top of the authoring column (not in the sidebar with `project`) because it is
1825
+ the one field that decides whether a paused render is identifiable later. ── */}
1826
+ <section aria-label="Movie name" style={{ margin: "0.7rem 0 0.6rem" }}>
1827
+ <label
1828
+ htmlFor="vi-movie-title"
1829
+ className="vi-comfy-label"
1830
+ style={{ display: "block", marginBottom: "0.25rem", opacity: 0.85 }}
1831
+ >
1832
+ Movie name (optional, but names the session)
1833
+ </label>
1834
+ <input
1835
+ id="vi-movie-title"
1836
+ className="vi-knob-input"
1837
+ type="text"
1838
+ value={movieTitle}
1839
+ placeholder="e.g. Mira — beach day"
1840
+ disabled={disableSubmit}
1841
+ style={{ maxWidth: "26rem", width: "100%" }}
1842
+ onChange={(e) => {
1843
+ setMovieTitle(e.target.value);
1844
+ // Typing a name answers the nudge — clear it rather than making the operator
1845
+ // dismiss a warning about a field they just filled in.
1846
+ if (e.target.value.trim() !== "") setNameWarn(false);
1847
+ }}
1848
+ />
1849
+ </section>
1850
+
1851
+ {/* ── Movie start switcher (operator ask 2026-07-12, verbatim): "It needs to be
1852
+ choosable by the user: any library clip, image, or scene to start — or
1853
+ start fresh with a prompt to generate a clip. This static starting clip is
1854
+ not going to work if it cannot be overridden." Same visual row as the Clip
1855
+ surface's subject-clip switcher (.vi-studio-src-actions) — four honest ways
1856
+ to set (or clear) segment 1's start, none of them a hidden default. ── */}
1857
+ <section aria-label="Movie start" style={{ marginBottom: "0.6rem" }}>
1858
+ <p className="vi-comfy-label" style={{ marginBottom: "0.25rem", opacity: 0.85 }}>
1859
+ Movie start — segment 1 (optional)
1860
+ </p>
1861
+ <div className="vi-studio-src-actions" role="group" aria-label="Choose how the movie starts">
1862
+ <button
1863
+ type="button"
1864
+ className="vi-btn vi-btn-sm"
1865
+ disabled={disableSubmit || startBusy}
1866
+ aria-expanded={showStartLibPicker}
1867
+ onClick={() => setShowStartLibPicker((v) => !v)}
1868
+ >
1869
+ {showStartLibPicker ? "Hide library" : `🖼 From library (${startLibraryImages.length})`}
1870
+ </button>
1871
+ {/* CLIP-AS-START FINDING (stated in the file header above): the movie schema
1872
+ has no `start_clip` field, and there is no synchronous "give me this
1873
+ clip's last frame" endpoint — frame_extract is its own async job (enqueue
1874
+ → poll → pick a frame), not a small addition here. Shown so the operator
1875
+ SEES the option exists, disabled so nothing dishonest is implied. */}
1876
+ <button
1877
+ type="button"
1878
+ className="vi-btn vi-btn-sm vi-btn-ghost"
1879
+ disabled
1880
+ title="Movie-from-clip needs the backend start_clip field — coming. A clip's last frame isn't a quick pick today (frame extraction is its own job); use a library image or upload a still instead."
1881
+ >
1882
+ 🎬 From library (clip → last frame)
1883
+ </button>
1884
+ <label className="vi-btn vi-btn-sm vi-file-label">
1885
+ {startBusy ? "Working…" : "⇪ Upload still"}
1886
+ <input
1887
+ type="file"
1888
+ accept="image/*"
1889
+ className="vi-file-input"
1890
+ disabled={disableSubmit || startBusy}
1891
+ onChange={(e) => {
1892
+ const f = e.target.files?.[0];
1893
+ if (f) void onPickStartImage(f);
1894
+ e.target.value = "";
1895
+ }}
1896
+ />
1897
+ </label>
1898
+ <button
1899
+ type="button"
1900
+ className="vi-btn vi-btn-sm vi-btn-ghost"
1901
+ disabled={disableSubmit || startBusy}
1902
+ title="Clear the start still — segment 1 opens from its own prompt instead (text-to-video)."
1903
+ onClick={() => {
1904
+ setStartImage(null);
1905
+ setShowStartLibPicker(false);
1906
+ }}
1907
+ >
1908
+ ✦ Fresh prompt
1909
+ </button>
1910
+ </div>
1911
+
1912
+ {/* Current start state — ALWAYS visible, so the picked (or default) start is
1913
+ never implicit (the operator's exact complaint about the old static slot). */}
1914
+ {startImage ? (
1915
+ <div
1916
+ style={{ display: "flex", alignItems: "center", gap: "0.6rem", flexWrap: "wrap", marginTop: "0.5rem" }}
1917
+ >
1918
+ <img
1919
+ src={mediaBytesUrl(startImage.uri)}
1920
+ alt="segment 1 start image"
1921
+ style={{
1922
+ width: "5rem",
1923
+ height: "5rem",
1924
+ objectFit: "cover",
1925
+ borderRadius: "0.4rem",
1926
+ background: "#000",
1927
+ border: "1px solid var(--vi-border)",
1928
+ }}
1929
+ />
1930
+ <span className="vi-comfy-hint" role="note" style={{ margin: 0 }}>
1931
+ Segment 1 renders image-to-video from this still.
1932
+ </span>
1933
+ <button
1934
+ type="button"
1935
+ className="vi-btn vi-btn-sm vi-btn-ghost"
1936
+ disabled={disableSubmit || startBusy}
1937
+ onClick={() => setStartImage(null)}
1938
+ >
1939
+ ✕ Remove
1940
+ </button>
1941
+ </div>
1942
+ ) : (
1943
+ <p className="vi-comfy-hint" role="note" style={{ marginTop: "0.5rem" }}>
1944
+ No start still set — opens from your first segment&apos;s prompt (text-to-video
1945
+ opening).
1946
+ </p>
1947
+ )}
1948
+
1949
+ {startError && (
1950
+ <p className="vi-error" role="alert" style={{ marginTop: "0.3rem" }}>
1951
+ {startError}
1952
+ </p>
1953
+ )}
1954
+
1955
+ {showStartLibPicker && (
1956
+ <LibraryImageGrid
1957
+ images={startLibraryImages}
1958
+ onPick={(ref) => {
1959
+ setStartImage(ref);
1960
+ setShowStartLibPicker(false);
1961
+ }}
1962
+ />
1963
+ )}
1964
+ </section>
1965
+
1966
+ {/* ── IDENTITY reference(s): the locked subject carried across scene changes ── */}
1967
+ <section aria-label="Identity reference" style={{ marginBottom: "0.6rem" }}>
1968
+ <p className="vi-comfy-label" style={{ marginBottom: "0.25rem", opacity: 0.85 }}>
1969
+ Identity reference — carry a subject across scenes (optional)
1970
+ </p>
1971
+ {/* The reference thumbnails + upload AND the identity-profile affordances read as
1972
+ ONE input block — matching how the Clip Generate tab embeds the same
1973
+ <IdentityProfileControls> inline, directly beneath its references
1974
+ (StudioGenerateTab.renderReferenceSlots), rather than letting it stand off as a
1975
+ separate island row. Presentation/placement only: the props + wiring below are
1976
+ byte-identical to before. */}
1977
+ <div>
1978
+ <div style={{ display: "flex", alignItems: "center", gap: "0.6rem", flexWrap: "wrap" }}>
1979
+ {references.map((r, i) => (
1980
+ <span key={r.uri} style={{ position: "relative", display: "inline-flex" }}>
1981
+ <img
1982
+ src={mediaBytesUrl(r.uri)}
1983
+ alt={`identity reference ${i + 1}`}
1984
+ style={{
1985
+ width: "4rem",
1986
+ height: "4rem",
1987
+ objectFit: "cover",
1988
+ borderRadius: "0.4rem",
1989
+ background: "#000",
1990
+ border: "1px solid var(--vi-border)",
1991
+ }}
1992
+ />
1993
+ <button
1994
+ type="button"
1995
+ className="vi-btn vi-btn-sm vi-btn-ghost"
1996
+ disabled={disableSubmit || refBusy}
1997
+ title="Remove this reference"
1998
+ onClick={() => {
1999
+ setReferences((prev) => prev.filter((x) => x.uri !== r.uri));
2000
+ setSelectedProfile(null); // editing the set = an UNSAVED identity
2001
+ }}
2002
+ style={{ position: "absolute", top: -6, right: -6, padding: "0 0.3rem", lineHeight: 1.2 }}
2003
+ >
2004
+ ✕
2005
+ </button>
2006
+ </span>
2007
+ ))}
2008
+ {references.length < REF_MAX && (
2009
+ <label className="vi-btn vi-btn-sm vi-btn-ghost vi-file-label">
2010
+ {refBusy ? "Working…" : references.length ? "+ Add reference" : "Upload a reference"}
2011
+ <input
2012
+ type="file"
2013
+ accept="image/*"
2014
+ className="vi-file-input"
2015
+ disabled={disableSubmit || refBusy}
2016
+ onChange={(e) => {
2017
+ const f = e.target.files?.[0];
2018
+ if (f) void onPickReferenceImage(f);
2019
+ e.target.value = "";
2020
+ }}
2021
+ />
2022
+ </label>
2023
+ )}
2024
+ </div>
2025
+ {/* IDENTITY PROFILES (stage a): pick a saved identity profile or save the current
2026
+ references — the durable form of this identity. One additive row, embedded
2027
+ inline within the reference input block (matching the Clip Generate tab). */}
2028
+ <IdentityProfileControls
2029
+ refCount={references.length}
2030
+ currentRefUris={references.map((r) => r.uri)}
2031
+ selectedProfile={selectedProfile}
2032
+ onAttach={attachIdentityRefs}
2033
+ onSelectProfile={setSelectedProfile}
2034
+ maxRefs={REF_MAX}
2035
+ disabled={disableSubmit || refBusy}
2036
+ />
2037
+ </div>
2038
+ {/* RESOLUTION FOOTGUN (operator 2026-07-12): the only budget-fitting id-capable
2039
+ model (wan2.1-vace-1.3b) tops out at 832×480 — an identity movie above that fails
2040
+ no_capable_model. Warn LOUDLY with a one-click snap; never BLOCK (the backend's
2041
+ rejection is honest if the operator insists on a bigger box). */}
2042
+ {(references.length > 0 || selectedProfile != null) && (width > 832 || height > 480) && (
2043
+ <p className="vi-input-callout" role="note" style={{ marginTop: "0.4rem" }}>
2044
+ id-capable models on this fleet top out at 832×480 — this movie asks for {width}×{height}.{" "}
2045
+ <button
2046
+ type="button"
2047
+ className="vi-btn vi-btn-sm vi-btn-accent"
2048
+ onClick={() => {
2049
+ setWidth(480);
2050
+ setHeight(480);
2051
+ }}
2052
+ style={{ marginLeft: "0.3rem" }}
2053
+ >
2054
+ Set 480×480
2055
+ </button>
2056
+ </p>
2057
+ )}
2058
+ <span className="vi-comfy-hint" role="note" style={{ display: "block", marginTop: "0.3rem" }}>
2059
+ {references.length
2060
+ ? `Identity movie — every scene renders id_lock (Wan-VACE) so this subject carries across every cut. ${references.length}/${REF_MAX} reference(s). Needs a VACE-capable GPU worker; on a GPU-less box it reports deps-missing.`
2061
+ : "Leave empty for a plain movie. Add a locked character/subject still to carry the same identity across every scene (needs a VACE-capable GPU worker)."}
2062
+ </span>
2063
+ {refError && (
2064
+ <p className="vi-error" role="alert" style={{ marginTop: "0.3rem" }}>
2065
+ {refError}
2066
+ </p>
2067
+ )}
2068
+ </section>
2069
+
2070
+ {/* ── the goal timeline (NLE-row order, top → bottom) ──
2071
+ Operator addendum 2026-07-12: each segment is now a DISTINCTLY CONTRASTED
2072
+ panel, reusing the Generate-station Movie-mode goal-card idiom verbatim —
2073
+ .vi-gen-parts (the list rhythm) → .vi-gen-part.vi-movie-goal (the bordered
2074
+ + var(--vi-surface-raised) panel that lifts off the black page) with a
2075
+ .vi-gen-part-head carrying a .vi-movie-badge chip, a .vi-gen-part-idx #N,
2076
+ the follows-hint, and .vi-gen-part-actions (auto-pushed reorder/remove). No
2077
+ new look invented — the same classes GenerateStation.tsx uses. */}
2078
+ <section aria-label="Goals" className="vi-studio-composer vi-movie-timeline">
2079
+ {/* ── SEGMENT COUNT + GROUP ASSIST BAR (STUDIO-SPREAD-SPEC §2) ──
2080
+ Say how many shots the movie has, tick the ones you want written, and
2081
+ write them in ONE coherent pass. The count materializes or truncates
2082
+ rows; the group actions touch ONLY ticked rows — never a prompt you
2083
+ kept, never the identity, images, seeds or budget. */}
2084
+ <PromptListToolbar
2085
+ count={{
2086
+ id: "vi-movie-segment-count",
2087
+ label: "Segments",
2088
+ value: goals.length,
2089
+ min: 1,
2090
+ max: MAX_SEGMENTS,
2091
+ onChange: setSegmentCount,
2092
+ title: `How many shots this movie has (1–${MAX_SEGMENTS}). Lowering it removes rows from the END of the timeline.`,
2093
+ }}
2094
+ noun="segment"
2095
+ selectedCount={selectedCount}
2096
+ itemCount={goals.length}
2097
+ allSelected={allSelected}
2098
+ onToggleSelectAll={toggleSelectAll}
2099
+ generateLabel={
2100
+ assist.assistBusy === "spread"
2101
+ ? "✨ Writing the spread…"
2102
+ : `✨ Generate prompts (${selectedCount} selected)`
2103
+ }
2104
+ generateTitle="ONE call writes every ticked segment against one shared world; the unticked ones ride along as locked context so the shots belong to the same movie."
2105
+ generateDisabledTitle="Tick the segments you want written."
2106
+ onGenerate={() => void onSpreadSelected()}
2107
+ negativesLabel={
2108
+ negBatch
2109
+ ? `✨ Negatives ${negBatch.done}/${negBatch.total}…`
2110
+ : `✨ Generate negatives (${selectedCount} selected)`
2111
+ }
2112
+ negativesTitle="Write an artifact/quality exclusion list for each ticked segment, from that segment's own prompt."
2113
+ onNegatives={() => void onNegativesSelected()}
2114
+ assistBusy={assist.assistBusy != null}
2115
+ onStandardNegatives={onStandardNegatives}
2116
+ standardNegativesTitle="Tick [standard negative] on for the selected segments (or every segment when none are selected) — the standard artifact/junk set is composed into their negatives at submit."
2117
+ disabled={disableSubmit}
2118
+ />
2119
+
2120
+ {/* The overall request the spread writes against + the world pin. Both optional:
2121
+ blank, the generator works from the rows themselves; pinned, a follow-up
2122
+ spread re-rolls a couple of shots INTO THE SAME WORLD instead of a new one. */}
2123
+ <div className="vi-knob vi-studio-knob-wide" style={{ marginBottom: "0.6rem" }}>
2124
+ <label htmlFor="vi-movie-query">movie idea — the overall request (optional)</label>
2125
+ <input
2126
+ id="vi-movie-query"
2127
+ className="vi-knob-input"
2128
+ type="text"
2129
+ value={movieQuery}
2130
+ placeholder="e.g. a chase through a night market that ends on a rooftop"
2131
+ disabled={disableSubmit}
2132
+ onChange={(e) => setMovieQuery(e.target.value)}
2133
+ />
2134
+ {/* k89: the explanation moved into "About Cinema" — one functional line
2135
+ (+ the world pin, which needs point-of-use context) stays inline. */}
2136
+ <span className="vi-knob-hint">
2137
+ Steers “Generate prompts” — one shared world across the ticked segments.
2138
+ {steeringSeed != null && (
2139
+ <>
2140
+ {" "}
2141
+ <label style={{ display: "inline-flex", alignItems: "center", gap: "0.3rem" }}>
2142
+ <input
2143
+ type="checkbox"
2144
+ checked={pinSteering}
2145
+ disabled={disableSubmit}
2146
+ onChange={(e) => setPinSteering(e.target.checked)}
2147
+ />
2148
+ keep the last spread’s world (steering seed {steeringSeed})
2149
+ </label>
2150
+ </>
2151
+ )}
2152
+ </span>
2153
+ </div>
2154
+
2155
+ {/* The spread's RESULT NOTICE — what changed, what did NOT, and every warning
2156
+ the backend surfaced (skipped rows, discarded unselected rows, invented
2157
+ identity attributes, a model that answered in place of the one requested).
2158
+ Dismissible and inert: it reports, it never edits. */}
2159
+ {spreadNotice && spreadNotice.length > 0 && (
2160
+ <div className="vi-input-callout" role="status" style={{ marginBottom: "0.6rem" }}>
2161
+ <ul style={{ margin: 0, paddingLeft: "1.1rem" }}>
2162
+ {spreadNotice.map((line, i) => (
2163
+ <li key={i} style={{ wordBreak: "break-word" }}>
2164
+ {line}
2165
+ </li>
2166
+ ))}
2167
+ </ul>
2168
+ <button
2169
+ type="button"
2170
+ className="vi-btn vi-btn-sm vi-btn-ghost"
2171
+ style={{ marginTop: "0.35rem" }}
2172
+ onClick={() => setSpreadNotice(null)}
2173
+ >
2174
+ ✕ Dismiss
2175
+ </button>
2176
+ </div>
2177
+ )}
2178
+
2179
+ <ol className="vi-gen-parts">
2180
+ {goals.map((g, i) => (
2181
+ <PromptCard
2182
+ key={g.key}
2183
+ badge="segment"
2184
+ index={i}
2185
+ noun="segment"
2186
+ /* SELECTION — the group actions may rewrite ticked rows and nothing
2187
+ else. An unticked row rides the spread as LOCKED context. */
2188
+ select={{
2189
+ checked: selectedKeys.has(g.key),
2190
+ onChange: () => toggleSelected(g.key),
2191
+ disabled: disableSubmit,
2192
+ title:
2193
+ "Tick to let the group actions rewrite this segment. Unticked rows are held as locked context.",
2194
+ label: `Select segment ${i + 1} for group prompt assist`,
2195
+ }}
2196
+ /* The joint descriptor STAYS in the header (the joint SELECT moved
2197
+ into the settings expander below — k88). */
2198
+ headerHint={
2199
+ <span className="vi-comfy-hint" style={{ opacity: 0.75 }}>
2200
+ {i === 0
2201
+ ? references.length
2202
+ ? "root · identity render (id_lock)"
2203
+ : startImage
2204
+ ? "root · image → video (from start image)"
2205
+ : "root · text → video"
2206
+ : g.joint === "cut"
2207
+ ? "✂ scene cut · fresh render"
2208
+ : g.joint === "vace_extend"
2209
+ ? "extends motion from the previous segment"
2210
+ : "continues from a frame of the previous segment"}
2211
+ </span>
2212
+ }
2213
+ canMoveUp={i > 0}
2214
+ canMoveDown={i < goals.length - 1}
2215
+ onMoveUp={() => moveGoal(i, -1)}
2216
+ onMoveDown={() => moveGoal(i, 1)}
2217
+ moveUpTitle="Move earlier"
2218
+ moveDownTitle="Move later"
2219
+ canRemove={goals.length > 1}
2220
+ onRemove={() => removeGoal(i)}
2221
+ removeTitle={
2222
+ goals.length <= 1 ? "A movie needs at least one segment" : "Remove segment"
2223
+ }
2224
+ >
2225
+ {/* k88 — [Prompt][Negative] tabs over ONE GoalComposer pane; the
2226
+ [standard negative] tick rides inline with the tabs. The negative
2227
+ pane keeps its own "✨ Generate negative" affordance (§1b). */}
2228
+ <PromptFieldTabs
2229
+ idPrefix={`vi-movie-${g.key}`}
2230
+ promptLabel={`prompt — segment ${i + 1}`}
2231
+ prompt={g.prompt}
2232
+ onPromptChange={(v) => setGoalField(i, { prompt: v })}
2233
+ onPromptBlur={() => classifyRow(g.key, g.prompt)}
2234
+ promptPlaceholder={
2235
+ i === 0 ? "describe the opening shot" : "describe how this segment continues"
2236
+ }
2237
+ rows={2}
2238
+ negative={g.negative}
2239
+ onNegativeChange={(v) => setGoalField(i, { negative: v })}
2240
+ negativePlaceholder="what to avoid — blank = movie-level default"
2241
+ negativeHint="Blank falls back to the movie-level negative below; [standard negative] composes the standard set in either way."
2242
+ stdNegative={g.stdNegative}
2243
+ onStdNegativeChange={(v) => setGoalField(i, { stdNegative: v })}
2244
+ negativeExtras={
2245
+ /* NEGATIVE ASSIST (§1b) — its OWN mode, not a prompt call with
2246
+ different words. Seeded by this shot's own prompt as the
2247
+ subject being negated. */
2248
+ <div className="vi-prompt-assist">
2249
+ <span className="vi-prompt-assist-lead" aria-hidden="true">
2250
+ negative assist
2251
+ </span>
2252
+ <button
2253
+ type="button"
2254
+ className="vi-btn vi-btn-sm vi-btn-ghost"
2255
+ disabled={assist.assistBusy != null || disableSubmit}
2256
+ title="Write an artifact/quality exclusion list for this shot (replaces this field)."
2257
+ onClick={() => runRowNegative(i)}
2258
+ >
2259
+ {assist.assistBusy === "negative" ? "✨ Writing…" : "✨ Generate negative"}
2260
+ </button>
2261
+ </div>
2262
+ }
2263
+ />
2264
+ {/* ── Auto · Direction · Scene prompt (spec §2/§1d) ──
2265
+ What you type into a prompt box is one of two different things:
2266
+ a SCENE ("a woman enters a red room" — enhance it) or a DIRECTION
2267
+ ("make the framing wider and colder" — write a new prompt FROM it).
2268
+ Word count is backwards on both of those, so in Auto a 3B router
2269
+ classifies on BLUR and the answer LABELS the primary button below.
2270
+ It never runs anything: a router mistake costs one click, never a
2271
+ paragraph of your work. Picking Direction or Scene prompt yourself
2272
+ skips the router entirely. */}
2273
+ <div className="vi-prompt-card-assist">
2274
+ {(() => {
2275
+ const mode: PromptFieldMode = fieldModes[g.key] ?? "auto";
2276
+ const intent = intents[g.key] ?? null;
2277
+ const blank = g.prompt.trim() === "";
2278
+ const op = resolveOperation(mode, intent, blank);
2279
+ const detected = mode === "auto" ? detectionText(intent, op) : "";
2280
+ return (
2281
+ <div
2282
+ className="vi-prompt-assist"
2283
+ role="group"
2284
+ aria-label={`How to treat segment ${i + 1}'s prompt text`}
2285
+ >
2286
+ <span className="vi-prompt-assist-lead" aria-hidden="true">
2287
+ treat as
2288
+ </span>
2289
+ <select
2290
+ className="vi-select vi-select-sm"
2291
+ value={mode}
2292
+ disabled={disableSubmit}
2293
+ aria-label="Treat this text as a direction or as a scene prompt"
2294
+ onChange={(e) => {
2295
+ const next = e.target.value as PromptFieldMode;
2296
+ setFieldModes((prev) => ({ ...prev, [g.key]: next }));
2297
+ // Switching back to Auto asks the router about the CURRENT text.
2298
+ if (next === "auto") classifyRow(g.key, g.prompt);
2299
+ }}
2300
+ >
2301
+ <option value="auto">Auto</option>
2302
+ <option value="direction">Direction</option>
2303
+ <option value="scene_prompt">Scene prompt</option>
2304
+ </select>
2305
+ {op && (
2306
+ <button
2307
+ type="button"
2308
+ className="vi-btn vi-btn-sm vi-btn-ghost"
2309
+ disabled={assist.assistBusy != null || disableSubmit}
2310
+ title={
2311
+ op === "enhance_scene"
2312
+ ? "Enrich this scene, preserving its subject and intent."
2313
+ : op === "generate_from_direction"
2314
+ ? "Write a new prompt that carries out this direction, using the surrounding shots as context."
2315
+ : "Write a prompt for this shot from scratch."
2316
+ }
2317
+ onClick={() => runRowAssist(i, assistModeFor(op))}
2318
+ >
2319
+ ✨ {operationLabel(op)}
2320
+ </button>
2321
+ )}
2322
+ {intentBusy[g.key] ? (
2323
+ <span className="vi-comfy-hint" style={{ opacity: 0.7 }}>
2324
+ reading…
2325
+ </span>
2326
+ ) : (
2327
+ detected && (
2328
+ <span className="vi-comfy-hint" style={{ opacity: 0.75 }}>
2329
+ {detected}
2330
+ {intent && !intent.degraded && intent.confidence > 0
2331
+ ? ` (${Math.round(intent.confidence * 100)}%)`
2332
+ : ""}
2333
+ </span>
2334
+ )
2335
+ )}
2336
+ </div>
2337
+ );
2338
+ })()}
2339
+ {/* PROMPT ASSIST for THIS segment — Enhance enriches this goal's prompt,
2340
+ Generate writes a fresh one. The shared cluster + hook; one in-flight
2341
+ assist at a time across all segments. Errors surface inline and never
2342
+ clobber the goal's existing prompt. Both stay live whatever the router
2343
+ said: the classification above is a default, not a lock.
2344
+ Both now carry TYPED CONTEXT (§1c) — this shot, its neighbours, the
2345
+ locked identity — which is what `context.hint` never carried. */}
2346
+ <PromptAssistButtons
2347
+ busy={assist.assistBusy}
2348
+ error={assist.assistError}
2349
+ onDismissError={assist.dismissError}
2350
+ onEnhance={() => runRowAssist(i, "detail")}
2351
+ onGenerate={() => runRowAssist(i, "generate")}
2352
+ canEnhance={g.prompt.trim() !== ""}
2353
+ models={assist.assistModels}
2354
+ model={assist.assistModel}
2355
+ onModelChange={assist.setAssistModel}
2356
+ />
2357
+ </div>
2358
+
2359
+ {/* k88 — the shared settings expander (collapsed by default); the joint
2360
+ DESCRIPTOR stays visible in the card header above.
2361
+ 2026-08-12: grew from seed-only to the FULL per-segment knob set the
2362
+ route has always accepted (StudioMovieGoal): model pin, steps, cfg,
2363
+ and (vace_extend rows) context frames. Every knob blank ⇒ the key is
2364
+ dropped and the segment inherits the movie-level value — Movie-tab
2365
+ (k92) parity, minus the share ticks (here the movie level IS the
2366
+ shared value, so "inherit" already says it). */}
2367
+ <PromptCardSettings
2368
+ label={
2369
+ i === 0
2370
+ ? "settings — seed · model · steps · cfg"
2371
+ : "settings — seed · model · steps · cfg · joint · branch"
2372
+ }
2373
+ >
2374
+ <div className="vi-knob" style={{ maxWidth: "10rem" }}>
2375
+ <label htmlFor={`vi-movie-seed-${g.key}`}>seed (optional)</label>
2376
+ <input
2377
+ id={`vi-movie-seed-${g.key}`}
2378
+ className="vi-knob-input"
2379
+ type="number"
2380
+ min={0}
2381
+ value={g.seed}
2382
+ placeholder="movie seed + index"
2383
+ onChange={(e) => setGoalField(i, { seed: e.target.value })}
2384
+ />
2385
+ </div>
2386
+ <div className="vi-knob" style={{ maxWidth: "16rem" }}>
2387
+ <label htmlFor={`vi-movie-goalmodel-${g.key}`}>model — this segment</label>
2388
+ <select
2389
+ id={`vi-movie-goalmodel-${g.key}`}
2390
+ className="vi-knob-select"
2391
+ value={g.modelId}
2392
+ onChange={(e) => setGoalField(i, { modelId: e.target.value })}
2393
+ >
2394
+ <option value="">
2395
+ {modelId.trim() ? `Inherit — ${modelId.trim()}` : "Inherit — router picks"}
2396
+ </option>
2397
+ {movieModels.length > 0 && (
2398
+ <optgroup label="Bound by a ratified movie segment">
2399
+ {movieModels.map((id) => (
2400
+ <option key={id} value={id}>
2401
+ {id}
2402
+ </option>
2403
+ ))}
2404
+ </optgroup>
2405
+ )}
2406
+ {(showAllModels || !renderLoaded || (g.modelId !== "" && !movieModels.includes(g.modelId))) && (
2407
+ <optgroup label={renderLoaded ? "Other studio models — unverified for a movie segment" : "Studio models"}>
2408
+ {STUDIO_MODEL_IDS.filter((id) => !movieModels.includes(id)).map((id) => (
2409
+ <option key={id} value={id}>
2410
+ {id}
2411
+ {renderLoaded ? " · unverified" : ""}
2412
+ </option>
2413
+ ))}
2414
+ </optgroup>
2415
+ )}
2416
+ </select>
2417
+ <span className="vi-knob-hint">
2418
+ Pin a model for THIS segment only. Inherit = the movie-level pin, else the
2419
+ capability router. An unroutable pin errors on the job, never silently swaps.
2420
+ </span>
2421
+ </div>
2422
+ <div className="vi-knob" style={{ maxWidth: "8rem" }}>
2423
+ <label htmlFor={`vi-movie-goalsteps-${g.key}`}>steps</label>
2424
+ <input
2425
+ id={`vi-movie-goalsteps-${g.key}`}
2426
+ className="vi-knob-input"
2427
+ type="number"
2428
+ min={1}
2429
+ max={100}
2430
+ value={g.steps}
2431
+ placeholder={steps.trim() || "inherit"}
2432
+ onChange={(e) => setGoalField(i, { steps: e.target.value })}
2433
+ />
2434
+ <span className="vi-knob-hint">1–100 · blank = movie/model default</span>
2435
+ </div>
2436
+ <div className="vi-knob" style={{ maxWidth: "8rem" }}>
2437
+ <label htmlFor={`vi-movie-goalcfg-${g.key}`}>cfg</label>
2438
+ <input
2439
+ id={`vi-movie-goalcfg-${g.key}`}
2440
+ className="vi-knob-input"
2441
+ type="number"
2442
+ min={0}
2443
+ max={20}
2444
+ step={0.5}
2445
+ value={g.cfg}
2446
+ placeholder={cfg.trim() || "inherit"}
2447
+ onChange={(e) => setGoalField(i, { cfg: e.target.value })}
2448
+ />
2449
+ <span className="vi-knob-hint">0–20 · blank = movie/model default</span>
2450
+ </div>
2451
+ {i > 0 && (
2452
+ <>
2453
+ <div className="vi-knob" style={{ maxWidth: "13rem" }}>
2454
+ <label htmlFor={`vi-movie-joint-${g.key}`}>joint — how it follows</label>
2455
+ <select
2456
+ id={`vi-movie-joint-${g.key}`}
2457
+ className="vi-knob-input"
2458
+ value={g.joint}
2459
+ onChange={(e) => setGoalField(i, { joint: e.target.value as JointMode })}
2460
+ >
2461
+ <option value="still">{JOINT_LABELS.still}</option>
2462
+ <option value="vace_extend">{JOINT_LABELS.vace_extend}</option>
2463
+ <option value="cut">{JOINT_LABELS.cut}</option>
2464
+ </select>
2465
+ <span className="vi-knob-hint">
2466
+ {g.joint === "cut"
2467
+ ? "Hard scene cut — the previous segment plays in full; this one is a fresh render (identity carries when a reference is set)."
2468
+ : g.joint === "vace_extend"
2469
+ ? "Carry motion across the join from the previous segment's trailing frames (needs a VACE GPU worker)."
2470
+ : "Continue from ONE frame of the previous segment (motion not carried)."}
2471
+ </span>
2472
+ </div>
2473
+ <div className="vi-knob" style={{ maxWidth: "16rem" }}>
2474
+ <label htmlFor={`vi-movie-branch-${g.key}`}>
2475
+ branch from frame — blank = last frame
2476
+ </label>
2477
+ <input
2478
+ id={`vi-movie-branch-${g.key}`}
2479
+ className="vi-knob-input"
2480
+ type="number"
2481
+ min={0}
2482
+ value={g.joint === "cut" ? "" : g.branchFrame}
2483
+ placeholder={g.joint === "cut" ? "n/a — a scene cut carries no frame" : "last frame"}
2484
+ disabled={g.joint === "cut"}
2485
+ title={g.joint === "cut" ? "A scene cut carries no frame — the previous segment plays in full." : undefined}
2486
+ onChange={(e) => setGoalField(i, { branchFrame: e.target.value })}
2487
+ />
2488
+ <span className="vi-knob-hint">
2489
+ {g.joint === "cut"
2490
+ ? "A scene cut carries no frame — the previous segment plays in full."
2491
+ : "Frame index into the previous segment's clip (0-based). Blank ⇒ its last frame (parent plays in full)."}
2492
+ </span>
2493
+ </div>
2494
+ {g.joint === "vace_extend" && (
2495
+ <div className="vi-knob" style={{ maxWidth: "10rem" }}>
2496
+ <label htmlFor={`vi-movie-goalctx-${g.key}`}>context frames</label>
2497
+ <input
2498
+ id={`vi-movie-goalctx-${g.key}`}
2499
+ className="vi-knob-input"
2500
+ type="number"
2501
+ min={1}
2502
+ value={g.contextFrames}
2503
+ placeholder="movie default"
2504
+ onChange={(e) => setGoalField(i, { contextFrames: e.target.value })}
2505
+ />
2506
+ <span className="vi-knob-hint">
2507
+ How many of the parent&apos;s trailing frames carry motion through this
2508
+ VACE join. Blank = the movie-level default.
2509
+ </span>
2510
+ </div>
2511
+ )}
2512
+ </>
2513
+ )}
2514
+ </PromptCardSettings>
2515
+ </PromptCard>
2516
+ ))}
2517
+ </ol>
2518
+
2519
+ {/* Timeline foot — the Add-segment affordance, mirroring the reference
2520
+ .vi-movie-timeline-foot (Add goal + summary) in GenerateStation Movie mode. */}
2521
+ <div className="vi-movie-timeline-foot">
2522
+ <button type="button" className="vi-btn vi-btn-ghost" onClick={addGoal} disabled={disableSubmit}>
2523
+ + Add segment
2524
+ </button>
2525
+ <span className="vi-movie-summary">
2526
+ {goals.length} segment{goals.length === 1 ? "" : "s"}
2527
+ </span>
2528
+ </div>
2529
+ </section>
2530
+
2531
+ {/* ── movie-wide NEGATIVE prompt — the DEFAULT every segment inherits (a per-segment
2532
+ negative above overrides it for that segment only). Kept in the center column,
2533
+ beside the goals, as a wide prompt textarea — the geometry / model pin / sampler
2534
+ knobs now live in the left-sidebar Settings tab (see settingsNode above),
2535
+ mirroring the Clip surface which keeps its negative out of the knob grid.
2536
+ k88: deliberately NOT restyled with PromptFieldTabs — this block is a lone
2537
+ negative with no prompt pane to pair it with, so tabs would be one dead tab. ── */}
2538
+ <section aria-label="Movie negative prompt" className="vi-studio-knobs" style={{ marginTop: "0.8rem" }}>
2539
+ <div className="vi-studio-knob-wide" style={{ flex: "1 1 100%" }}>
2540
+ <GoalComposer
2541
+ id="vi-movie-negative"
2542
+ label="negative prompt (movie-wide default, optional)"
2543
+ value={negative}
2544
+ placeholder="what to avoid across every segment — each segment can override above"
2545
+ onChange={setNegative}
2546
+ hint="The default for every segment. A segment's own negative prompt overrides this for that segment."
2547
+ />
2548
+ {/* The same §1b affordance for the movie-wide default — subject is the movie
2549
+ idea if you wrote one, else the opening shot's prompt. */}
2550
+ <div className="vi-prompt-assist">
2551
+ <span className="vi-prompt-assist-lead" aria-hidden="true">
2552
+ negative assist
2553
+ </span>
2554
+ <button
2555
+ type="button"
2556
+ className="vi-btn vi-btn-sm vi-btn-ghost"
2557
+ disabled={assist.assistBusy != null || disableSubmit}
2558
+ title="Write an artifact/quality exclusion list for the whole movie (replaces this field)."
2559
+ onClick={() =>
2560
+ void assist.runNegative(
2561
+ {
2562
+ subject: movieQuery.trim() || goals[0]?.prompt || "",
2563
+ draft: negative,
2564
+ extra: {
2565
+ context: identityContext ? { identity_profile: identityContext } : {},
2566
+ },
2567
+ },
2568
+ setNegative,
2569
+ )
2570
+ }
2571
+ >
2572
+ {assist.assistBusy === "negative" ? "✨ Writing…" : "✨ Generate negative"}
2573
+ </button>
2574
+ </div>
2575
+ </div>
2576
+ </section>
2577
+
2578
+ {/* ── SYNTHETIC-TIER HONESTY BANNER — loud, right above Generate movie, so the
2579
+ operator sees it at the moment it matters instead of the knob-grid
2580
+ footnote three fields up. Identity movies get the positive note instead
2581
+ (they can never bind synthetic — see wouldBindSynthetic above); a plain
2582
+ movie under the real floor gets the warning + one-click fill. ── */}
2583
+ {references.length > 0 ? (
2584
+ <p className="vi-comfy-hint" role="note" style={{ margin: "0 0 0.6rem" }}>
2585
+ ⓘ identity segments render on the real tier automatically.
2586
+ </p>
2587
+ ) : (
2588
+ wouldBindSynthetic && (
2589
+ <div
2590
+ className="vi-studio-required-callout"
2591
+ role="alert"
2592
+ style={{
2593
+ display: "flex",
2594
+ alignItems: "center",
2595
+ gap: "0.6rem",
2596
+ flexWrap: "wrap",
2597
+ marginBottom: "0.6rem",
2598
+ }}
2599
+ >
2600
+ <span>
2601
+ ⚠ This will render the SYNTHETIC TEST tier (colored noise, no real model). Set VRAM
2602
+ budget ≥ {movieFloorGb} for a real render on the GPU worker
2603
+ {renderLoaded ? " — the measured envelope of the cheapest real segment binding" : ""}.
2604
+ </span>
2605
+ <button
2606
+ type="button"
2607
+ className="vi-btn vi-btn-sm vi-btn-accent"
2608
+ disabled={disableSubmit}
2609
+ onClick={() => {
2610
+ budgetTouched.current = true;
2611
+ setVramBudget(String(movieNeedGb));
2612
+ }}
2613
+ >
2614
+ Set budget to {movieNeedGb}
2615
+ </button>
2616
+ </div>
2617
+ )
2618
+ )}
2619
+
2620
+ {/* ── UNNAMED-MOVIE NUDGE — a warning with a way through, never a gate. Sits
2621
+ right above Generate because that is where the decision is made. ── */}
2622
+ {nameWarn && (
2623
+ <div
2624
+ className="vi-studio-required-callout"
2625
+ role="alert"
2626
+ style={{
2627
+ display: "flex",
2628
+ alignItems: "center",
2629
+ gap: "0.6rem",
2630
+ flexWrap: "wrap",
2631
+ marginBottom: "0.6rem",
2632
+ }}
2633
+ >
2634
+ <span>
2635
+ This movie has no name — it will list as “untitled” beside a bare id in
2636
+ Cinema sessions, which is hard to tell apart from every other unnamed render
2637
+ when you come back to resume one.
2638
+ </span>
2639
+ <button
2640
+ type="button"
2641
+ className="vi-btn vi-btn-sm vi-btn-ghost"
2642
+ disabled={disableSubmit}
2643
+ onClick={() => onGenerate(true)}
2644
+ >
2645
+ Generate without a name
2646
+ </button>
2647
+ </div>
2648
+ )}
2649
+
2650
+ {/* ── run row ── */}
2651
+ <div className="vi-station-footer vi-studio-run">
2652
+ <div className="vi-station-footer-main">
2653
+ {formMsg && (
2654
+ <p className="vi-knob-flag" role="alert" style={{ margin: 0 }}>
2655
+ {formMsg}
2656
+ </p>
2657
+ )}
2658
+ {job.status && !formMsg && (
2659
+ <p className="vi-knob-hint" role="status" style={{ margin: 0 }}>
2660
+ {job.running
2661
+ ? `Rendering — status: ${job.status}${
2662
+ job.progress?.stage ? ` · ${job.progress.stage}` : ""
2663
+ }`
2664
+ : `Status: ${job.status}`}
2665
+ {job.jobId ? ` · job ${job.jobId.slice(0, 8)}` : ""}
2666
+ </p>
2667
+ )}
2668
+ {/* LIVE PACE (k91) — the bus feed's folded fraction + "segment 2/14 · step
2669
+ 18/32" + the freshest stage line, so the first minute of a long render
2670
+ already says how fast it is going. Mounted only while this movie runs (the
2671
+ component owns the poll), and it renders nothing the server didn't send.
2672
+ Wrapped in a WRAP ROW because the bar's shared classes are written for the
2673
+ Active-Processes row (`flex-basis: 100%`), and this footer column would
2674
+ read that basis as a HEIGHT — the exact trap app.css documents on
2675
+ `.vi-station-footer`. */}
2676
+ {job.running ? (
2677
+ <div
2678
+ style={{
2679
+ display: "flex",
2680
+ flexWrap: "wrap",
2681
+ alignItems: "center",
2682
+ gap: "0.3rem 0.6rem",
2683
+ width: "100%",
2684
+ minWidth: 0,
2685
+ }}
2686
+ >
2687
+ <MovieLiveProgress jobId={job.jobId} />
2688
+ </div>
2689
+ ) : null}
2690
+ </div>
2691
+ <div className="vi-station-footer-run">
2692
+ <button
2693
+ type="button"
2694
+ className="vi-btn vi-btn-accent"
2695
+ disabled={disableSubmit}
2696
+ onClick={() => onGenerate()}
2697
+ >
2698
+ {job.running ? "Rendering…" : "Generate movie"}
2699
+ </button>
2700
+ </div>
2701
+ </div>
2702
+
2703
+ {/* ── B3 pending spliced row: one block per authored goal, tinted by live
2704
+ per-segment progress (equal widths — effective frames aren't known until
2705
+ each clip exists; the sized row + scrubber arrive on done). ── */}
2706
+ {job.running && (
2707
+ <section aria-label="Rendering timeline" style={{ marginTop: "0.8rem" }}>
2708
+ <StudioPendingRow
2709
+ goalPrompts={goals.map((g) => g.prompt)}
2710
+ progressSegments={(job.progress?.segments ?? []).map((s) => ({
2711
+ index: s.index,
2712
+ status: s.status ?? undefined,
2713
+ resumed: s.resumed ?? undefined,
2714
+ }))}
2715
+ />
2716
+ </section>
2717
+ )}
2718
+
2719
+ {/* ── live per-segment progress (render what the payload gives, honestly) ── */}
2720
+ {job.running && job.progress && (
2721
+ <section aria-label="Progress" style={{ marginTop: "0.8rem" }}>
2722
+ <p className="vi-comfy-label" style={{ marginBottom: "0.3rem" }}>
2723
+ {job.progress.stage ?? "working"} · segment {job.progress.segment_done ?? 0}/
2724
+ {job.progress.segment_total ?? goals.length}
2725
+ {job.progress.eta_s != null ? ` · ~${Math.round(job.progress.eta_s)}s left` : ""}
2726
+ </p>
2727
+ <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: "0.3rem" }}>
2728
+ {(job.progress.segments ?? []).map((s) => (
2729
+ <li
2730
+ key={s.index}
2731
+ style={{ display: "flex", gap: "0.6rem", alignItems: "baseline", fontSize: "0.85rem" }}
2732
+ >
2733
+ <span style={{ fontVariantNumeric: "tabular-nums", opacity: 0.7, minWidth: "5.5rem" }}>
2734
+ seg {s.index + 1} · {statusBadge(s.status, s.resumed)}
2735
+ </span>
2736
+ <span style={{ opacity: 0.85, wordBreak: "break-word" }}>{s.prompt ?? ""}</span>
2737
+ </li>
2738
+ ))}
2739
+ </ul>
2740
+ </section>
2741
+ )}
2742
+
2743
+ {/* ── failure: JobError as DATA (no toast-and-swallow) ── */}
2744
+ {isFailed && (
2745
+ <section aria-label="Movie error" style={{ marginTop: "0.8rem" }}>
2746
+ <p className="vi-error" role="alert">
2747
+ <strong>{job.status === "cancelled" ? "Cancelled" : "Failed"}</strong>
2748
+ {job.result?.error?.code ? ` [${job.result.error.code}]` : ""}
2749
+ {job.result?.error?.retryable ? " · retryable" : ""}
2750
+ {job.error ? <span style={{ display: "block" }}>{job.error}</span> : null}
2751
+ </p>
2752
+ {job.result?.movie && (
2753
+ <p className="vi-comfy-hint" role="note" style={{ marginTop: "0.3rem" }}>
2754
+ A partial movie may remain on disk:{" "}
2755
+ {job.result.movie.segments_completed ?? 0}/{job.result.movie.segments_total ?? goals.length}{" "}
2756
+ segment(s) completed
2757
+ {job.result.project && typeof (job.result.project as { dir?: unknown }).dir === "string"
2758
+ ? ` · ${(job.result.project as { dir: string }).dir}`
2759
+ : ""}
2760
+ .
2761
+ </p>
2762
+ )}
2763
+ </section>
2764
+ )}
2765
+
2766
+ {/* ── done: play movie.mp4 in the viewer idiom, list segments below ── */}
2767
+ {isDone && view && (
2768
+ <section aria-label="Movie" style={{ marginTop: "0.9rem", display: "flex", flexDirection: "column", gap: "0.7rem" }}>
2769
+ {view.movieRef ? (
2770
+ <div className="vi-studio-viewer-wrap">
2771
+ <div className="vi-studio-viewer">
2772
+ <video
2773
+ key={view.movieRef.uri}
2774
+ className="vi-studio-clip-video"
2775
+ controls
2776
+ autoPlay
2777
+ loop
2778
+ playsInline
2779
+ src={mediaBytesUrl(view.movieRef.uri)}
2780
+ />
2781
+ </div>
2782
+ <p className="vi-comfy-hint" role="note" style={{ marginTop: "0.3rem" }}>
2783
+ Assembled movie.mp4
2784
+ {view.result.movie?.assembly?.total_frames != null
2785
+ ? ` · ${view.result.movie.assembly.total_frames} frames`
2786
+ : ""}
2787
+ {" "}— {view.segmentRefs.length} segment(s) stitched at their splice points.
2788
+ </p>
2789
+ </div>
2790
+ ) : (
2791
+ <p className="vi-comfy-hint" role="note">
2792
+ Segments rendered but the assembled movie.mp4 is not in the outputs (assembly
2793
+ may not have produced one) — the segment clips are below.
2794
+ </p>
2795
+ )}
2796
+
2797
+ {view.result.movie?.drift && (
2798
+ <p className="vi-comfy-hint" role="note" style={{ fontStyle: "italic", opacity: 0.8 }}>
2799
+ ⓘ {view.result.movie.drift}
2800
+ </p>
2801
+ )}
2802
+
2803
+ {/* ── B3: the visual SPLICED ROW + branch-frame scrubber — the operator's
2804
+ "spliced video conjoined at those points". Blocks sized to EFFECTIVE
2805
+ frames (parent trimmed at its child's splice), scissor notch per join,
2806
+ click a block to scrub + branch a new goal. Rendered ONCE (never in a
2807
+ .map) so no `key` lands on the custom component. ── */}
2808
+ {view.segs.length > 0 && (
2809
+ <StudioSplicedRow
2810
+ segments={view.segs}
2811
+ joints={view.result.movie?.joints ?? []}
2812
+ segmentRefs={view.segmentRefs}
2813
+ fps={view.result.movie?.fps ?? 0}
2814
+ fpsFallback={fps}
2815
+ goalCount={goals.length}
2816
+ onBranch={onBranchFromSegment}
2817
+ />
2818
+ )}
2819
+
2820
+ <div>
2821
+ <p className="vi-comfy-label" style={{ marginBottom: "0.35rem" }}>
2822
+ Segment clips
2823
+ </p>
2824
+ <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: "0.6rem" }}>
2825
+ {view.segmentRefs.map((ref, i) => {
2826
+ const seg = view.segs[i];
2827
+ return (
2828
+ <li
2829
+ key={ref.uri}
2830
+ style={{ display: "flex", gap: "0.7rem", alignItems: "flex-start" }}
2831
+ >
2832
+ <video
2833
+ src={`${mediaBytesUrl(ref.uri)}#t=0.1`}
2834
+ controls
2835
+ muted
2836
+ playsInline
2837
+ preload="metadata"
2838
+ style={{
2839
+ width: "12rem",
2840
+ maxWidth: "40vw",
2841
+ borderRadius: "0.4rem",
2842
+ background: "#000",
2843
+ border: "1px solid var(--vi-border)",
2844
+ }}
2845
+ />
2846
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.15rem", fontSize: "0.85rem" }}>
2847
+ <span style={{ fontWeight: 600 }}>
2848
+ Segment {i + 1}
2849
+ {seg?.segment_id ? ` · ${seg.segment_id}` : ""}
2850
+ {seg?.resumed ? " · resumed" : ""}
2851
+ </span>
2852
+ <span style={{ opacity: 0.85 }}>{jointText(view.segs, i)}</span>
2853
+ {seg?.frames != null && (
2854
+ <span className="vi-comfy-hint" style={{ opacity: 0.7 }}>
2855
+ {seg.frames} frames
2856
+ {seg.width != null && seg.height != null ? ` · ${seg.width}×${seg.height}` : ""}
2857
+ {seg.duration_s != null ? ` · ${Math.round(seg.duration_s)}s` : ""}
2858
+ </span>
2859
+ )}
2860
+ {seg?.prompt && (
2861
+ <span className="vi-comfy-hint" style={{ opacity: 0.7, wordBreak: "break-word" }}>
2862
+ “{seg.prompt}”
2863
+ </span>
2864
+ )}
2865
+ </div>
2866
+ </li>
2867
+ );
2868
+ })}
2869
+ </ul>
2870
+ </div>
2871
+ </section>
2872
+ )}
2873
+ </div>
2874
+ );
2875
+ }