@hugpy/video-intelligence-ui 0.3.1 → 0.3.3

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