@hugpy/video-intelligence-ui 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/package.json +1 -3
  2. package/src/config.ts +36 -0
  3. package/src/demo/assets/generate/cinema-cavern/movie.mp4 +0 -0
  4. package/src/demo/assets/generate/cinema-cavern/seg_00.mp4 +0 -0
  5. package/src/demo/assets/generate/clips/cat-lounge.mp4 +0 -0
  6. package/src/demo/assets/generate/clips/drone-city.mp4 +0 -0
  7. package/src/demo/assets/generate/clips/paper-sails.mp4 +0 -0
  8. package/src/demo/assets/generate/movie-galaxy/movie.mp4 +0 -0
  9. package/src/demo/assets/generate/movie-galaxy/seg_00.mp4 +0 -0
  10. package/src/demo/assets/generate/movie-galaxy/seg_01.mp4 +0 -0
  11. package/src/demo/assets/generate/movie-galaxy/seg_02.mp4 +0 -0
  12. package/src/demo/assets/generate/movie-galaxy/seg_03.mp4 +0 -0
  13. package/src/demo/assets/generate/movie-medow/movie.mp4 +0 -0
  14. package/src/demo/assets/generate/movie-medow/seg_00.mp4 +0 -0
  15. package/src/demo/assets/generate/movie-medow/seg_01.mp4 +0 -0
  16. package/src/demo/assets/generate/scene-desert/frame_00000.png +0 -0
  17. package/src/demo/assets/generate/scene-desert/frame_00001.png +0 -0
  18. package/src/demo/assets/generate/scene-desert/frame_00002.png +0 -0
  19. package/src/demo/assets/generate/scene-desert/frame_00003.png +0 -0
  20. package/src/demo/assets/generate/scene-desert/frame_00004.png +0 -0
  21. package/src/demo/assets/generate/scene-desert/frame_00005.png +0 -0
  22. package/src/demo/assets/generate/scene-desert/scene.mp4 +0 -0
  23. package/src/demo/assets/generate/scene-vintage/frame_00000.png +0 -0
  24. package/src/demo/assets/generate/scene-vintage/frame_00001.png +0 -0
  25. package/src/demo/assets/generate/scene-vintage/frame_00002.png +0 -0
  26. package/src/demo/assets/generate/scene-vintage/frame_00003.png +0 -0
  27. package/src/demo/assets/generate/scene-vintage/frame_00004.png +0 -0
  28. package/src/demo/assets/generate/scene-vintage/frame_00005.png +0 -0
  29. package/src/demo/assets/generate/scene-vintage/scene.mp4 +0 -0
  30. package/src/demo/assets/generate2/s0.png +0 -0
  31. package/src/demo/assets/generate2/s1.png +0 -0
  32. package/src/demo/assets/generate2/s10.png +0 -0
  33. package/src/demo/assets/generate2/s11.png +0 -0
  34. package/src/demo/assets/generate2/s12.png +0 -0
  35. package/src/demo/assets/generate2/s13.png +0 -0
  36. package/src/demo/assets/generate2/s14.png +0 -0
  37. package/src/demo/assets/generate2/s15.png +0 -0
  38. package/src/demo/assets/generate2/s16.png +0 -0
  39. package/src/demo/assets/generate2/s17.png +0 -0
  40. package/src/demo/assets/generate2/s18.png +0 -0
  41. package/src/demo/assets/generate2/s19.png +0 -0
  42. package/src/demo/assets/generate2/s2.png +0 -0
  43. package/src/demo/assets/generate2/s20.png +0 -0
  44. package/src/demo/assets/generate2/s21.mp4 +0 -0
  45. package/src/demo/assets/generate2/s22.mp4 +0 -0
  46. package/src/demo/assets/generate2/s23.mp4 +0 -0
  47. package/src/demo/assets/generate2/s24.mp4 +0 -0
  48. package/src/demo/assets/generate2/s25.mp4 +0 -0
  49. package/src/demo/assets/generate2/s26.mp4 +0 -0
  50. package/src/demo/assets/generate2/s27.mp4 +0 -0
  51. package/src/demo/assets/generate2/s28.mp4 +0 -0
  52. package/src/demo/assets/generate2/s29.mp4 +0 -0
  53. package/src/demo/assets/generate2/s3.png +0 -0
  54. package/src/demo/assets/generate2/s30.mp4 +0 -0
  55. package/src/demo/assets/generate2/s4.png +0 -0
  56. package/src/demo/assets/generate2/s5.png +0 -0
  57. package/src/demo/assets/generate2/s6.png +0 -0
  58. package/src/demo/assets/generate2/s7.png +0 -0
  59. package/src/demo/assets/generate2/s8.png +0 -0
  60. package/src/demo/assets/generate2/s9.png +0 -0
  61. package/src/demo/demoFetch.ts +126 -3
  62. package/src/demo/fixtures.ts +72 -4
  63. package/src/demo/promptAssist.ts +25 -0
  64. package/src/demo/sampleData.ts +960 -0
  65. package/src/demo/sampleSets.ts +172 -0
  66. package/src/stations/ActiveProcessesStation.tsx +18 -3
  67. package/src/stations/GenerateStation.tsx +1031 -364
  68. package/src/stations/GoalComposer.tsx +9 -1
  69. package/src/stations/ScriptFirstStation.tsx +1081 -0
  70. package/src/stations/ToksPanel.tsx +188 -0
  71. package/src/stations/WorkbenchDrawer.tsx +4 -3
  72. package/src/stations/WorkbenchSidebar.tsx +60 -12
  73. package/src/stations/oracle/DagPanel.tsx +391 -0
  74. package/src/stations/oracle/OracleStation.tsx +304 -0
  75. package/src/stations/oracle/SpatialTab.tsx +185 -0
  76. package/src/stations/oracle/StewardPanel.tsx +165 -0
  77. package/src/stations/oracle/oracle.css +467 -0
  78. package/src/stations/oracle/oracleShared.tsx +160 -0
  79. package/src/stations/oracle/oracleTypes.ts +304 -0
  80. package/src/stations/oracle/useOracle.ts +560 -0
  81. package/src/stations/registry.ts +37 -0
  82. package/src/stations/scriptFirst.css +350 -0
  83. package/src/stations/studio/IdentitiesStation.tsx +219 -292
  84. package/src/stations/studio/IdentityCreatePanel.tsx +390 -0
  85. package/src/stations/studio/StudioGenerateTab.tsx +162 -6
  86. package/src/stations/studio/StudioMovieComposer.tsx +763 -318
  87. package/src/stations/studio/{StudioMovieComposer.tsx.bak-sessions-20260812 → StudioMovieComposer.tsx.bak-declutter-20260813} +350 -48
  88. package/src/stations/studio/StudioPlane.tsx +35 -3
  89. package/src/stations/studio/StudioViewer.tsx +116 -14
  90. package/src/stations/studio/coordinationBadge.tsx +206 -0
  91. package/src/stations/studio/identityUpload.ts +66 -0
  92. package/src/stations/studio/promptCard.tsx +362 -59
  93. package/src/stations/studio/useIdentityCreateJob.ts +184 -0
  94. package/src/stations/studio/useIdentityProfiles.ts +66 -0
  95. package/src/stations/useScriptFirstRun.ts +596 -0
  96. package/src/style/app.css +176 -0
  97. package/src/video/LengthRow.tsx +189 -0
  98. package/src/video/LibraryGroups.tsx +81 -0
  99. package/src/video/UnnamedWarn.tsx +146 -0
  100. package/src/video/coordinationReport.ts +266 -0
  101. package/src/video/jobClock.ts +104 -0
  102. package/src/video/spreadAssist.ts +26 -0
  103. package/src/video/useMediaJobs.ts +26 -0
  104. package/src/video/useOperatorHistory.ts +105 -0
  105. package/src/video/useToks.ts +193 -0
  106. package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +0 -228
  107. package/src/stations/GenerateStation.tsx.bak-tester-removal +0 -5170
  108. package/src/stations/studio/StudioMovieSessions.tsx.bak-sessions-20260812 +0 -417
@@ -66,7 +66,9 @@ import {
66
66
  type JobStatus,
67
67
  } from "../../video/contract";
68
68
  import { getSessionId } from "../../session";
69
- import { useMediaLibrary } from "../../video/mediaLibrary";
69
+ import { addToLibrary, useMediaLibrary } from "../../video/mediaLibrary";
70
+ import { UnnamedWarnPanel, WarnUnnamedToggle, useWarnUnnamedPref } from "../../video/UnnamedWarn";
71
+ import { LengthRow } from "../../video/LengthRow";
70
72
  import { GoalComposer } from "../GoalComposer";
71
73
  import { usePromptAssist, type AssistExtras } from "../../video/usePromptAssist";
72
74
  import { PromptAssistButtons } from "../../video/PromptAssistButtons";
@@ -97,6 +99,16 @@ import {
97
99
  type PromptFieldMode,
98
100
  type SpreadGoalInput,
99
101
  } from "../../video/spreadAssist";
102
+ // k121 COORDINATION REVIEW — the words-vs-knobs check that rides every spread.
103
+ // Reader + badge live in their own files so this component stays an editor.
104
+ import {
105
+ knobPatch,
106
+ readCoordinationReport,
107
+ segmentReview,
108
+ type CoordinationDecision,
109
+ type CoordinationReport,
110
+ } from "../../video/coordinationReport";
111
+ import { CoordinationBadge, CoordinationSummary, decisionKey } from "./coordinationBadge";
100
112
  // B3: the visual spliced row + branch-frame scrubber (the picture half). Pure math
101
113
  // lives in movieTimeline.ts; both are new files beside this composer.
102
114
  import { StudioSplicedRow, StudioPendingRow } from "./StudioSplicedRow";
@@ -230,6 +242,11 @@ const progressSegmentSchema = z
230
242
  prompt: z.string().nullable().optional(),
231
243
  status: z.string().nullable().optional(),
232
244
  resumed: z.boolean().nullable().optional(),
245
+ // k120 slice 2 — set when continuity refresh rewrote this segment's prompt:
246
+ // the authored original + the runner's one-line note (what the previous
247
+ // segment's closing frame actually showed, or why the rewrite was skipped).
248
+ prompt_authored: z.string().nullable().optional(),
249
+ refresh_note: z.string().nullable().optional(),
233
250
  })
234
251
  .passthrough();
235
252
 
@@ -295,21 +312,47 @@ interface GoalRow {
295
312
  steps: string;
296
313
  cfg: string;
297
314
  contextFrames: string;
315
+ // per-segment CLIP LENGTH in frames (2026-08-13); blank = movie default.
316
+ frames: string;
298
317
  }
299
318
 
300
319
  // The counter is SESSION-persisted (not a module `let`) now that the goal rows
301
320
  // themselves survive a reload: a module counter restarts at 0 while the restored
302
321
  // rows keep their old keys, so `goal_1` could be minted twice — and this key
303
322
  // doubles as the wire `segment_id`. Same shape, just a counter that remembers.
323
+ // The real-model frame ceiling (Wan family: 81) — the spine clamps to this
324
+ // and snaps to 4k+1. Split-to-fit uses it to materialize CONTINUATION
325
+ // segments instead of letting a longer ask silently degrade.
326
+ const MODEL_FRAME_CEILING = 81;
327
+
304
328
  function newGoalKey(): string {
305
329
  return `goal_${nextSessionSeq("studio.movie.goalKeySeq")}`;
306
330
  }
331
+ // ── CINEMA PRODUCER (k120 slice 1) — POST /video/producer/plan reply shape.
332
+ // The server owns validation/repair; this guard only refuses a reply the row
333
+ // mapper below could not consume.
334
+ const producerPlanSchema = z.object({
335
+ title: z.string().optional(),
336
+ logline: z.string().optional(),
337
+ total_seconds: z.number().optional(),
338
+ segments: z
339
+ .array(
340
+ z.object({
341
+ prompt: z.string(),
342
+ negative: z.string().optional(),
343
+ seconds: z.number(),
344
+ joint: z.string(),
345
+ }),
346
+ )
347
+ .min(1),
348
+ });
349
+
307
350
  function blankGoal(joint: JointMode = "still"): GoalRow {
308
351
  return {
309
352
  key: newGoalKey(),
310
353
  prompt: "",
311
354
  negative: "",
312
- stdNegative: false,
355
+ stdNegative: true,
313
356
  seed: "",
314
357
  branchFrame: "",
315
358
  joint,
@@ -317,6 +360,7 @@ function blankGoal(joint: JointMode = "still"): GoalRow {
317
360
  steps: "",
318
361
  cfg: "",
319
362
  contextFrames: "",
363
+ frames: "",
320
364
  };
321
365
  }
322
366
 
@@ -602,6 +646,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
602
646
  steps: "",
603
647
  cfg: "",
604
648
  contextFrames: "",
649
+ frames: "",
605
650
  ...g,
606
651
  })),
607
652
  [goalsRaw],
@@ -611,6 +656,10 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
611
656
  // Default 480×480: the wan2.1-vace-1.3b IDENTITY sweet spot — the only budget-fitting
612
657
  // id-capable model on this fleet tops out at 832×480, so a 512×512 default cannot serve
613
658
  // id_lock on it (no_capable_model). 832×480 widescreen is the other valid id envelope.
659
+ // EXPLICIT SEGMENT LENGTH (operator 2026-08-13): movie-level default frames
660
+ // per segment; a segment's own frames (below) overrides. Blank = each bound
661
+ // model's default (81 real). Clamped + 4k+1-snapped by the spine.
662
+ const [framesDefault, setFramesDefault] = useSessionState("studio.movie.frames", "");
614
663
  const [width, setWidth] = useSessionState("studio.movie.width", 480);
615
664
  const [height, setHeight] = useSessionState("studio.movie.height", 480);
616
665
  const [fps, setFps] = useSessionState("studio.movie.fps", 24);
@@ -775,6 +824,11 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
775
824
  // The non-destructive result notice: what changed, what did NOT, every warning.
776
825
  // Dismissible; it never edits anything on its own.
777
826
  const [spreadNotice, setSpreadNotice] = useState<string[] | null>(null);
827
+ // k121: the last spread's coordination review, and the proposals the operator
828
+ // has already answered (accepted or rejected). Both are VIEW state — the
829
+ // report is a fact about the last generate, and re-generating replaces it.
830
+ const [coordination, setCoordination] = useState<CoordinationReport | null>(null);
831
+ const [coordSettled, setCoordSettled] = useState<Set<string>>(() => new Set());
778
832
 
779
833
  const selectedCount = useMemo(
780
834
  () => goals.reduce((n, g) => (selectedKeys.has(g.key) ? n + 1 : n), 0),
@@ -1011,6 +1065,66 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1011
1065
  },
1012
1066
  [],
1013
1067
  );
1068
+
1069
+ // k121 ACCEPT / REJECT for a `proposed` coordination decision. A proposal is
1070
+ // anything that REMOVES coordination (dropping a carry, shortening a clip,
1071
+ // swapping an identity) or that spends real work (an IDENTITY_CAPTURE) — the
1072
+ // backend deliberately never applies those itself, so this is where a human
1073
+ // says yes. Both answers are recorded, so a rejected proposal stops asking
1074
+ // rather than nagging on every re-render.
1075
+ const acceptCoordination = useCallback(
1076
+ (d: CoordinationDecision) => {
1077
+ const patch = knobPatch(d);
1078
+ if (patch) {
1079
+ setGoals((prev) =>
1080
+ prev.map((g) => (g.key === d.segment_id ? { ...g, ...(patch as Partial<GoalRow>) } : g)),
1081
+ );
1082
+ }
1083
+ setCoordSettled((prev) => new Set(prev).add(decisionKey(d)));
1084
+ },
1085
+ [],
1086
+ );
1087
+ const rejectCoordination = useCallback((d: CoordinationDecision) => {
1088
+ setCoordSettled((prev) => new Set(prev).add(decisionKey(d)));
1089
+ }, []);
1090
+
1091
+ // SPLIT-TO-FIT (operator 2026-08-13): a length ask beyond the model ceiling
1092
+ // becomes VISIBLE continuation segments — motion-carried (vace_extend),
1093
+ // length-distributed, each editable — never submit-time magic. The
1094
+ // continuation prompt is a plain, honest default; ✨ Generate can refine it.
1095
+ const splitGoalToFit = useCallback(
1096
+ (i: number) => {
1097
+ const g = goals[i];
1098
+ if (!g) return;
1099
+ const ask = Number(g.frames.trim() || framesDefault.trim());
1100
+ if (!Number.isInteger(ask) || ask <= MODEL_FRAME_CEILING) return;
1101
+ const n = Math.ceil(ask / MODEL_FRAME_CEILING);
1102
+ const per = Math.ceil(ask / n);
1103
+ const chunks: number[] = [];
1104
+ let left = ask;
1105
+ for (let k = 0; k < n; k++) {
1106
+ const c = Math.min(per, left);
1107
+ chunks.push(Math.max(5, c));
1108
+ left -= c;
1109
+ }
1110
+ const base = g.prompt.trim();
1111
+ const contPrompt = base
1112
+ ? `the scene continues seamlessly, same setting and motion — ${base.split(/[.,;\n]/)[0].trim().slice(0, 80)}`
1113
+ : "the scene continues seamlessly, same setting and motion";
1114
+ setGoals((prev) => {
1115
+ const next = [...prev];
1116
+ next[i] = { ...next[i], frames: String(chunks[0]) };
1117
+ const conts = chunks.slice(1).map((c) => ({
1118
+ ...blankGoal("vace_extend"),
1119
+ prompt: contPrompt,
1120
+ frames: String(c),
1121
+ }));
1122
+ next.splice(i + 1, 0, ...conts);
1123
+ return next;
1124
+ });
1125
+ },
1126
+ [goals, framesDefault, setGoals],
1127
+ );
1014
1128
  const addGoal = useCallback(() => setGoals((prev) => [...prev, blankGoal()]), []);
1015
1129
  const removeGoal = useCallback((idx: number) => {
1016
1130
  // Keep at least one row — a movie needs a root segment.
@@ -1028,21 +1142,98 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1028
1142
  }, []);
1029
1143
 
1030
1144
  // ── SEGMENTS STEPPER (spec §2) — say how many shots this movie has, up front ──
1031
- // Materializes rows to reach N, TRUNCATES from the tail to shrink to N. Truncation
1032
- // is the branch scrubber's own precedent (onBranchFromSegment below drops the rows
1033
- // after the branch point rather than refusing) — and like it, the affordance names
1034
- // what it will drop instead of dropping it silently. New rows inherit the joint of
1035
- // the last row so a movie built as "extend motion" stays that way as it grows.
1036
- const MAX_SEGMENTS = 24;
1037
- const setSegmentCount = useCallback((n: number) => {
1038
- if (!Number.isInteger(n) || n < 1 || n > MAX_SEGMENTS) return;
1039
- setGoals((prev) => {
1040
- if (n === prev.length) return prev;
1041
- if (n < prev.length) return prev.slice(0, n);
1042
- const joint: JointMode = prev[prev.length - 1]?.joint ?? "still";
1043
- return [...prev, ...Array.from({ length: n - prev.length }, () => blankGoal(joint))];
1145
+ // Materializes rows to reach N; shrinking to N PARKS the tail rows in a
1146
+ // session-scoped stash instead of deleting them (operator 2026-08-27: 3 edited
1147
+ // segments → count 1 → count 5 must bring the 3 edited rows back, not mint
1148
+ // blanks). Growing revives parked rows first — they keep their keys, so
1149
+ // per-key sidecars (ticks, field modes, intents) re-attach — and only then
1150
+ // mints blanks, which inherit the joint of the last row so a movie built as
1151
+ // "extend motion" stays that way as it grows. No upper cap: the count takes
1152
+ // any integer ≥ 1.
1153
+ const [goalStash, setGoalStash] = useSessionState<GoalRow[]>("studio.movie.goalStash", () => []);
1154
+
1155
+ // ── 🎬 PRODUCER (k120 slice 1) — premise → /video/producer/plan → rows. ──
1156
+ // The plan's per-segment seconds convert to frames against the CURRENT movie
1157
+ // fps (frames is what rides the wire; fps is movie-level). Existing edited
1158
+ // rows are parked in the stash first, never deleted.
1159
+ const [premise, setPremise] = useSessionState<string>("studio.movie.premise", "");
1160
+ const [producerNote, setProducerNote] = useSessionState<string>(
1161
+ "studio.movie.producerNote",
1162
+ "",
1163
+ );
1164
+ const [producing, setProducing] = useState(false);
1165
+ const [produceError, setProduceError] = useState<string | null>(null);
1166
+ // k120 slice 2 — render-time continuity: the runner rewrites each next segment
1167
+ // from the previous segment's ACTUAL closing frame. Auto-enabled by a
1168
+ // successful Produce (a scripted movie wants it); always operator-untickable.
1169
+ const [continuityRefresh, setContinuityRefresh] = useSessionState<boolean>(
1170
+ "studio.movie.continuityRefresh",
1171
+ false,
1172
+ );
1173
+ const produceMovie = useCallback(async () => {
1174
+ const p = premise.trim();
1175
+ if (!p || producing) return;
1176
+ setProducing(true);
1177
+ setProduceError(null);
1178
+ const res = await request<unknown>(hugpyConfig.producerPlanUrl, {
1179
+ method: "POST",
1180
+ body: JSON.stringify({ premise: p }),
1181
+ headers: { "Content-Type": "application/json" },
1182
+ meta: { specKey: "studio", operation: "studio.producer.plan" },
1044
1183
  });
1045
- }, []);
1184
+ setProducing(false);
1185
+ if (!res.ok) {
1186
+ setProduceError(describeAppError(errorOf(res)));
1187
+ return;
1188
+ }
1189
+ const parsed = producerPlanSchema.safeParse(okValue(res));
1190
+ if (!parsed.success) {
1191
+ setProduceError("The producer reply did not match the expected plan shape.");
1192
+ return;
1193
+ }
1194
+ const plan = parsed.data;
1195
+ const effFps = fps > 0 ? fps : 24;
1196
+ const rows = plan.segments.map((s, i) => {
1197
+ const joint: JointMode =
1198
+ i === 0
1199
+ ? "still"
1200
+ : s.joint === "cut" || s.joint === "still" || s.joint === "vace_extend"
1201
+ ? (s.joint as JointMode)
1202
+ : "vace_extend";
1203
+ return {
1204
+ ...blankGoal(joint),
1205
+ prompt: s.prompt,
1206
+ negative: s.negative ?? "",
1207
+ frames: String(Math.max(1, Math.round(s.seconds * effFps))),
1208
+ };
1209
+ });
1210
+ if (goals.some((g) => g.prompt.trim() !== "")) {
1211
+ setGoalStash((st) => [...goals, ...st]);
1212
+ }
1213
+ setGoals(rows);
1214
+ setProducerNote([plan.title, plan.logline].filter(Boolean).join(" — "));
1215
+ setContinuityRefresh(true);
1216
+ }, [premise, producing, goals, fps]);
1217
+
1218
+ const setSegmentCount = useCallback(
1219
+ (n: number) => {
1220
+ if (!Number.isInteger(n) || n < 1 || n === goals.length) return;
1221
+ if (n < goals.length) {
1222
+ const cut = goals.slice(n);
1223
+ setGoalStash((st) => [...cut, ...st]);
1224
+ setGoals(goals.slice(0, n));
1225
+ return;
1226
+ }
1227
+ const revived = goalStash.slice(0, n - goals.length);
1228
+ if (revived.length) setGoalStash((st) => st.slice(revived.length));
1229
+ const joint: JointMode = goals[goals.length - 1]?.joint ?? "still";
1230
+ const fresh = Array.from({ length: n - goals.length - revived.length }, () =>
1231
+ blankGoal(joint),
1232
+ );
1233
+ setGoals([...goals, ...revived, ...fresh]);
1234
+ },
1235
+ [goals, goalStash],
1236
+ );
1046
1237
 
1047
1238
  // ── the row → wire projection ──
1048
1239
  // ONE place turns editor rows into typed segment references, so the per-row assist
@@ -1133,18 +1324,54 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1133
1324
  // The row's RESOLVED operation only chooses which mode the primary button invokes;
1134
1325
  // both explicit buttons stay live underneath, so a router mistake costs one click,
1135
1326
  // never a paragraph of the user's work.
1327
+ // The last AUTO-derived movie title. A name the operator typed is sacred; a
1328
+ // name this ref minted may be replaced by the next Generate (operator
1329
+ // 2026-08-13: "purely a product of the same generation method").
1330
+ const autoTitleRef = useRef("");
1331
+
1136
1332
  const runRowAssist = useCallback(
1137
1333
  (i: number, mode: "detail" | "generate") => {
1138
1334
  const g = goals[i];
1139
1335
  if (!g) return;
1140
- void assist.runAssist(mode, g.prompt, (p) => setGoalField(i, { prompt: p }), {
1141
- context: rowContext(i),
1142
- });
1336
+ void (async () => {
1337
+ let fresh = "";
1338
+ await assist.runAssist(mode, g.prompt, (p) => {
1339
+ fresh = p;
1340
+ setGoalField(i, { prompt: p });
1341
+ }, { context: rowContext(i) });
1342
+ if (mode !== "generate" || !fresh.trim()) return;
1343
+ // NEGATIVE RIDES ALONG (operator 2026-08-13): the generated exclusion
1344
+ // list APPENDS to this goal's typed negative, never clobbers it
1345
+ // (composeNegative dedupes the std terms at payload time).
1346
+ await assist.runNegative(
1347
+ { subject: fresh, extra: { context: rowContext(i) } },
1348
+ (neg) => {
1349
+ const cur = (g.negative ?? "").trim().replace(/,\s*$/, "");
1350
+ setGoalField(i, { negative: cur ? `${cur}, ${neg}` : neg });
1351
+ },
1352
+ );
1353
+ // NAME RIDES ALONG: derive a title from the generated prompt when the
1354
+ // movie has none — or when the current one was itself auto-derived.
1355
+ const t = movieTitle.trim();
1356
+ if (t === "" || t === autoTitleRef.current) {
1357
+ const derived = fresh
1358
+ .split(/[.,;\n]/)[0]
1359
+ .trim()
1360
+ .split(/\s+/)
1361
+ .slice(0, 7)
1362
+ .join(" ")
1363
+ .slice(0, 48);
1364
+ if (derived) {
1365
+ autoTitleRef.current = derived;
1366
+ setMovieTitle(derived);
1367
+ }
1368
+ }
1369
+ })();
1143
1370
  // The text is about to change, so the old classification no longer describes it.
1144
1371
  intentText.current[g.key] = "";
1145
1372
  setIntents((prev) => ({ ...prev, [g.key]: null }));
1146
1373
  },
1147
- [assist, goals, rowContext, setGoalField],
1374
+ [assist, goals, rowContext, setGoalField, movieTitle, setMovieTitle],
1148
1375
  );
1149
1376
 
1150
1377
  // ── negative generation (§1b) — an exclusion list for THIS shot ──
@@ -1195,7 +1422,26 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1195
1422
  // An explicit Generate on a SELECTED row is meant to replace that row's prose —
1196
1423
  // that is the point of selecting it. A blank returned negative still falls back
1197
1424
  // to what the user wrote rather than blanking it.
1198
- return { ...g, prompt: hit.prompt, negative: hit.negative || g.negative };
1425
+ //
1426
+ // k121: the row also carries the KNOBS the coordination review turned for
1427
+ // it (ratchet-safe ones only — the backend never removes carry on its own).
1428
+ // ``hit.knobs`` is MECHANICS: no prose from any other row reaches here.
1429
+ const k = hit.knobs ?? {};
1430
+ const applyKnobs: Partial<GoalRow> = {};
1431
+ if (typeof k.joint_mode === "string") {
1432
+ applyKnobs.joint = k.joint_mode as GoalRow["joint"];
1433
+ }
1434
+ if (typeof k.seed === "number") applyKnobs.seed = String(k.seed);
1435
+ if (typeof k.frames === "number") applyKnobs.frames = String(k.frames);
1436
+ if ("branch_frame" in k) {
1437
+ applyKnobs.branchFrame = k.branch_frame == null ? "" : String(k.branch_frame);
1438
+ }
1439
+ return {
1440
+ ...g,
1441
+ prompt: hit.prompt,
1442
+ negative: hit.negative || g.negative,
1443
+ ...applyKnobs,
1444
+ };
1199
1445
  }),
1200
1446
  );
1201
1447
  for (const key of applied) {
@@ -1207,6 +1453,11 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1207
1453
  return next;
1208
1454
  });
1209
1455
  if (res.steering_seed != null) setSteeringSeed(res.steering_seed);
1456
+ // k121: a fresh review replaces the old one, and the answered set resets with
1457
+ // it — a proposal answered against last generation's prose says nothing about
1458
+ // this one.
1459
+ setCoordination(readCoordinationReport(res.coordination));
1460
+ setCoordSettled(new Set());
1210
1461
  setSpreadNotice(spreadNoticeLines(res, applied));
1211
1462
  }, [
1212
1463
  assist,
@@ -1300,7 +1551,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1300
1551
  }, [pendingFocusKey, goals]);
1301
1552
 
1302
1553
  // ── build the exact request body the route reads ──
1303
- const buildBody = useCallback((): { body: Record<string, unknown> } | { err: string } => {
1554
+ const buildBody = useCallback((titleOverride?: string): { body: Record<string, unknown> } | { err: string } => {
1304
1555
  if (goals.length < 1) return { err: "Add at least one goal (the root segment)." };
1305
1556
  for (let i = 0; i < goals.length; i++) {
1306
1557
  if (goals[i].prompt.trim() === "") {
@@ -1320,6 +1571,10 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1320
1571
  if (gCfg !== "" && (Number.isNaN(Number(gCfg)) || Number(gCfg) < 0 || Number(gCfg) > 20)) {
1321
1572
  return { err: `Goal ${i + 1} CFG must be a number in [0, 20] (or blank to inherit).` };
1322
1573
  }
1574
+ const gFrames = goals[i].frames.trim();
1575
+ if (gFrames !== "" && (!Number.isInteger(Number(gFrames)) || Number(gFrames) < 1)) {
1576
+ return { err: `Goal ${i + 1} length must be a whole number of frames \u2265 1 (or blank to inherit).` };
1577
+ }
1323
1578
  const gCtx = goals[i].contextFrames.trim();
1324
1579
  if (gCtx !== "" && (!Number.isInteger(Number(gCtx)) || Number(gCtx) < 1)) {
1325
1580
  return { err: `Goal ${i + 1} context frames must be a whole number ≥ 1 (or blank to inherit).` };
@@ -1347,6 +1602,10 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1347
1602
  // Movie-level sampler overrides — same bounds the clip surface (and the movie
1348
1603
  // schema's _check_steps_cfg) enforce: steps int [1,100], cfg number [0,20]. Blank
1349
1604
  // drops the key (model default).
1605
+ const framesDefNum = framesDefault.trim() === "" ? undefined : Number(framesDefault);
1606
+ if (framesDefNum !== undefined && (!Number.isInteger(framesDefNum) || framesDefNum < 1)) {
1607
+ return { err: "Segment length must be a whole number of frames \u2265 1 (or blank for model defaults)." };
1608
+ }
1350
1609
  const stepsNum = steps.trim() === "" ? undefined : Number(steps);
1351
1610
  if (
1352
1611
  stepsNum !== undefined &&
@@ -1376,6 +1635,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1376
1635
  if (g.modelId.trim() !== "") node.model_id = g.modelId.trim();
1377
1636
  if (g.steps.trim() !== "") node.steps = Number(g.steps);
1378
1637
  if (g.cfg.trim() !== "") node.cfg = Number(g.cfg);
1638
+ if (g.frames.trim() !== "") node.frames = Number(g.frames);
1379
1639
  // joint_mode + branch_frame are meaningless on the root (segment 0 has no parent).
1380
1640
  if (i > 0) {
1381
1641
  // Send a non-default joint_mode ("vace_extend" | "cut"); "still" is the route default.
@@ -1396,6 +1656,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1396
1656
 
1397
1657
  const body: Record<string, unknown> = {
1398
1658
  resolution: { width, height, fps },
1659
+ frames: framesDefNum,
1399
1660
  seed,
1400
1661
  vram_budget_gb: budgetNum,
1401
1662
  // Movie-level model pin + sampler overrides (blank ⇒ key dropped = router/model
@@ -1404,12 +1665,14 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1404
1665
  model_id: modelId.trim() ? modelId.trim() : undefined,
1405
1666
  steps: stepsNum,
1406
1667
  cfg: cfgNum,
1407
- negative_prompt: negative.trim() ? negative : undefined,
1668
+ // negative_prompt (movie-wide) RETIRED from this surface 2026-08-13
1669
+ // (operator: deprecated artifact) — per-goal negatives + the standard
1670
+ // tick compose everything; the route treats absence as None cleanly.
1408
1671
  project: project.trim() ? project.trim() : undefined,
1409
1672
  // The movie's NAME (k91) — free text, display-only: it never changes what renders,
1410
1673
  // it is what the sessions list shows instead of a bare dir id. Blank drops the key
1411
1674
  // (the schema takes title=None and the session lists as "untitled").
1412
- title: movieTitle.trim() ? movieTitle.trim() : undefined,
1675
+ title: (titleOverride ?? movieTitle).trim() ? (titleOverride ?? movieTitle).trim() : undefined,
1413
1676
  // Optional root conditioning still — the route jail-resolves this abs uri and
1414
1677
  // renders segment 0 i2v (drops back to t2v when absent). Mirrors the clip surface.
1415
1678
  start_image: startImage ? startImage.uri : undefined,
@@ -1421,18 +1684,25 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1421
1684
  reference_images:
1422
1685
  !selectedProfile && references.length ? references.map((r) => r.uri) : undefined,
1423
1686
  goals: goalBodies,
1687
+ // k120 slice 2 — PRODUCER CONTINUITY REFRESH: the runner rewrites each
1688
+ // non-root segment's prompt from a vision read of the frame the previous
1689
+ // segment actually ended on. Off drops the key (schema default False).
1690
+ continuity_refresh: continuityRefresh || undefined,
1424
1691
  };
1425
1692
  return { body };
1426
- }, [goals, width, height, fps, seed, vramBudget, modelId, steps, cfg, negative, project, movieTitle, startImage, references, selectedProfile]);
1693
+ }, [goals, width, height, fps, seed, vramBudget, modelId, steps, cfg, negative, project, movieTitle, startImage, references, selectedProfile, continuityRefresh]);
1427
1694
 
1428
1695
  // NAME NUDGE (k91) — set when a submit was held back only because the movie has no
1429
1696
  // name. It is a warning, not a gate: the run row then offers "Generate without a
1430
1697
  // name", and `onGenerate(true)` skips the check. Cleared whenever a name is typed.
1431
1698
  const [nameWarn, setNameWarn] = useState(false);
1699
+ // The warning is an EXPLICIT PER-SECTION OPTION now (operator 2026-08-13),
1700
+ // DEFAULT OFF, remembered in localStorage — shared UnnamedWarn seam.
1701
+ const [warnUnnamed, setWarnUnnamed] = useWarnUnnamedPref("cinema");
1432
1702
 
1433
1703
  const onGenerate = useCallback(
1434
- (skipNameCheck = false) => {
1435
- const built = buildBody();
1704
+ (skipNameCheck = false, titleOverride?: string) => {
1705
+ const built = buildBody(titleOverride);
1436
1706
  if ("err" in built) {
1437
1707
  setFormMsg(built.err);
1438
1708
  return;
@@ -1440,18 +1710,40 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1440
1710
  // Warn ONCE per unnamed submit. An unnamed movie is still fully renderable — the
1441
1711
  // cost is later, in a sessions list where it reads as "untitled · 7f3a91c2…" and
1442
1712
  // nobody can tell which paused render is which.
1443
- if (!skipNameCheck && movieTitle.trim() === "") {
1713
+ if (!skipNameCheck && warnUnnamed && movieTitle.trim() === "") {
1444
1714
  setNameWarn(true);
1445
1715
  setFormMsg(null);
1446
1716
  return;
1447
1717
  }
1448
1718
  setNameWarn(false);
1449
1719
  setFormMsg(null);
1720
+ if (titleOverride && titleOverride.trim()) setMovieTitle(titleOverride.trim());
1450
1721
  job.run(built.body);
1451
1722
  },
1452
- [buildBody, job, movieTitle],
1723
+ [buildBody, job, movieTitle, setMovieTitle, warnUnnamed],
1453
1724
  );
1454
1725
 
1726
+ // SESSION LIBRARY PUSH (operator 2026-08-13): a finished movie's outputs
1727
+ // land in the Session Library IMMEDIATELY — every segment clip plus the
1728
+ // assembled movie (outputs[-1] when assembly produced one) — tagged
1729
+ // generate_movie so the sidebar's Videos section lists them newest-first.
1730
+ // addToLibrary de-dups by uri, so re-renders of the same result are no-ops.
1731
+ useEffect(() => {
1732
+ const r = job.result;
1733
+ if (!r || r.ok === false || !r.outputs || r.outputs.length === 0) return;
1734
+ for (const ref of r.outputs) {
1735
+ if (!ref?.uri || !ref?.asset_id) continue;
1736
+ addToLibrary(ref, "cinema", movieTitle.trim() || "movie", {
1737
+ genKind: "generate_movie",
1738
+ prompt: goals[0]?.prompt,
1739
+ groupId: job.jobId ?? undefined,
1740
+ });
1741
+ }
1742
+ // movieTitle/goals deliberately excluded: push once per RESULT, labelled
1743
+ // with whatever the fields held when it landed.
1744
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1745
+ }, [job.result, job.jobId]);
1746
+
1455
1747
  // ── derive playback refs from the terminal result ──
1456
1748
  // Outputs = [segment clips…, movie.mp4 LAST] (the runner appends the assembled
1457
1749
  // movie only when assembly produced one). Pair each segment ref with its manifest
@@ -1846,226 +2138,80 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1846
2138
  if (e.target.value.trim() !== "") setNameWarn(false);
1847
2139
  }}
1848
2140
  />
2141
+ <WarnUnnamedToggle checked={warnUnnamed} onChange={setWarnUnnamed} />
1849
2142
  </section>
1850
2143
 
1851
- {/* ── Movie start switcher (operator ask 2026-07-12, verbatim): "It needs to be
1852
- choosable by the user: any library clip, image, or scene to start — or
1853
- start fresh with a prompt to generate a clip. This static starting clip is
1854
- not going to work if it cannot be overridden." Same visual row as the Clip
1855
- surface's subject-clip switcher (.vi-studio-src-actions) — four honest ways
1856
- to set (or clear) segment 1's start, none of them a hidden default. ── */}
1857
- <section aria-label="Movie start" style={{ marginBottom: "0.6rem" }}>
1858
- <p className="vi-comfy-label" style={{ marginBottom: "0.25rem", opacity: 0.85 }}>
1859
- Movie start — segment 1 (optional)
1860
- </p>
1861
- <div className="vi-studio-src-actions" role="group" aria-label="Choose how the movie starts">
1862
- <button
1863
- type="button"
1864
- className="vi-btn vi-btn-sm"
1865
- disabled={disableSubmit || startBusy}
1866
- aria-expanded={showStartLibPicker}
1867
- onClick={() => setShowStartLibPicker((v) => !v)}
1868
- >
1869
- {showStartLibPicker ? "Hide library" : `🖼 From library (${startLibraryImages.length})`}
1870
- </button>
1871
- {/* CLIP-AS-START FINDING (stated in the file header above): the movie schema
1872
- has no `start_clip` field, and there is no synchronous "give me this
1873
- clip's last frame" endpoint — frame_extract is its own async job (enqueue
1874
- → poll → pick a frame), not a small addition here. Shown so the operator
1875
- SEES the option exists, disabled so nothing dishonest is implied. */}
1876
- <button
1877
- type="button"
1878
- className="vi-btn vi-btn-sm vi-btn-ghost"
1879
- disabled
1880
- title="Movie-from-clip needs the backend start_clip field — coming. A clip's last frame isn't a quick pick today (frame extraction is its own job); use a library image or upload a still instead."
1881
- >
1882
- 🎬 From library (clip → last frame)
1883
- </button>
1884
- <label className="vi-btn vi-btn-sm vi-file-label">
1885
- {startBusy ? "Working…" : "⇪ Upload still"}
1886
- <input
1887
- type="file"
1888
- accept="image/*"
1889
- className="vi-file-input"
1890
- disabled={disableSubmit || startBusy}
1891
- onChange={(e) => {
1892
- const f = e.target.files?.[0];
1893
- if (f) void onPickStartImage(f);
1894
- e.target.value = "";
1895
- }}
1896
- />
1897
- </label>
1898
- <button
1899
- type="button"
1900
- className="vi-btn vi-btn-sm vi-btn-ghost"
1901
- disabled={disableSubmit || startBusy}
1902
- title="Clear the start still — segment 1 opens from its own prompt instead (text-to-video)."
1903
- onClick={() => {
1904
- setStartImage(null);
1905
- setShowStartLibPicker(false);
1906
- }}
2144
+ {/* ── MOVIE SETTINGS, IN-COLUMN (operator 2026-08-13: "not seeing model,
2145
+ fps, dimension inputs for cinema") — the geometry/model/sampler knobs
2146
+ live in the left-sidebar Settings tab via portal, which is easy to
2147
+ miss (and absent when no host mounts). This expander mirrors the
2148
+ SAME state — two views, one truth, like the Clip card. ── */}
2149
+ <PromptCardSettings label="movie settings — model · size · fps · steps · cfg · budget">
2150
+ <div className="vi-knob vi-studio-knob-wide" style={{ minWidth: "16rem" }}>
2151
+ <label htmlFor="vi-moviecol-model">model — whole movie</label>
2152
+ <select
2153
+ id="vi-moviecol-model"
2154
+ className="vi-knob-select"
2155
+ value={modelId}
2156
+ disabled={disableSubmit}
2157
+ onChange={(e) => setModelId(e.target.value)}
1907
2158
  >
1908
- ✦ Fresh prompt
1909
- </button>
2159
+ <option value="">Auto — router picks by capability/budget</option>
2160
+ {movieModels.length > 0 && (
2161
+ <optgroup label="Bound by a ratified movie segment">
2162
+ {movieModels.map((id) => (
2163
+ <option key={id} value={id}>{id}</option>
2164
+ ))}
2165
+ </optgroup>
2166
+ )}
2167
+ {(showAllModels || !renderLoaded || (modelId !== "" && !movieModels.includes(modelId))) && (
2168
+ <optgroup label={renderLoaded ? "Other studio models — unverified for a movie segment" : "Studio models"}>
2169
+ {STUDIO_MODEL_IDS.filter((id) => !movieModels.includes(id)).map((id) => (
2170
+ <option key={id} value={id}>{id}{renderLoaded ? " · unverified" : ""}</option>
2171
+ ))}
2172
+ </optgroup>
2173
+ )}
2174
+ </select>
1910
2175
  </div>
2176
+ <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2177
+ <label htmlFor="vi-moviecol-width">width</label>
2178
+ <input id="vi-moviecol-width" className="vi-knob-input" type="number" min={64} step={8}
2179
+ value={width} onChange={(e) => setWidth(Number(e.target.value) || 0)} />
2180
+ </div>
2181
+ <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2182
+ <label htmlFor="vi-moviecol-height">height</label>
2183
+ <input id="vi-moviecol-height" className="vi-knob-input" type="number" min={64} step={8}
2184
+ value={height} onChange={(e) => setHeight(Number(e.target.value) || 0)} />
2185
+ </div>
2186
+ {/* LENGTH ROW (operator 2026-08-13): fps | frames/segment | seconds —
2187
+ any two set the third; dimmed = derived, last-touched wins. This is
2188
+ the MOVIE default; a segment's own length overrides it. */}
2189
+ <LengthRow
2190
+ idPrefix="vi-moviecol-len"
2191
+ fps={fps}
2192
+ onFps={(n) => setFps(n)}
2193
+ frames={framesDefault}
2194
+ onFrames={setFramesDefault}
2195
+ framesPlaceholder="model default (81) / segment"
2196
+ />
2197
+ <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2198
+ <label htmlFor="vi-moviecol-steps">steps</label>
2199
+ <input id="vi-moviecol-steps" className="vi-knob-input" type="number" min={1} max={100}
2200
+ value={steps} placeholder="model default" onChange={(e) => setSteps(e.target.value)} />
2201
+ </div>
2202
+ <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2203
+ <label htmlFor="vi-moviecol-cfg">cfg</label>
2204
+ <input id="vi-moviecol-cfg" className="vi-knob-input" type="number" min={0} max={20} step={0.5}
2205
+ value={cfg} placeholder="model default" onChange={(e) => setCfg(e.target.value)} />
2206
+ </div>
2207
+ <div className="vi-knob" style={{ maxWidth: "9rem" }}>
2208
+ <label htmlFor="vi-moviecol-vram">VRAM budget GB</label>
2209
+ <input id="vi-moviecol-vram" className="vi-knob-input" type="number" min={1}
2210
+ value={vramBudget} placeholder="autofit" onChange={(e) => setVramBudget(e.target.value)} />
2211
+ </div>
2212
+ </PromptCardSettings>
1911
2213
 
1912
- {/* Current start state — ALWAYS visible, so the picked (or default) start is
1913
- never implicit (the operator's exact complaint about the old static slot). */}
1914
- {startImage ? (
1915
- <div
1916
- style={{ display: "flex", alignItems: "center", gap: "0.6rem", flexWrap: "wrap", marginTop: "0.5rem" }}
1917
- >
1918
- <img
1919
- src={mediaBytesUrl(startImage.uri)}
1920
- alt="segment 1 start image"
1921
- style={{
1922
- width: "5rem",
1923
- height: "5rem",
1924
- objectFit: "cover",
1925
- borderRadius: "0.4rem",
1926
- background: "#000",
1927
- border: "1px solid var(--vi-border)",
1928
- }}
1929
- />
1930
- <span className="vi-comfy-hint" role="note" style={{ margin: 0 }}>
1931
- Segment 1 renders image-to-video from this still.
1932
- </span>
1933
- <button
1934
- type="button"
1935
- className="vi-btn vi-btn-sm vi-btn-ghost"
1936
- disabled={disableSubmit || startBusy}
1937
- onClick={() => setStartImage(null)}
1938
- >
1939
- ✕ Remove
1940
- </button>
1941
- </div>
1942
- ) : (
1943
- <p className="vi-comfy-hint" role="note" style={{ marginTop: "0.5rem" }}>
1944
- No start still set — opens from your first segment&apos;s prompt (text-to-video
1945
- opening).
1946
- </p>
1947
- )}
1948
-
1949
- {startError && (
1950
- <p className="vi-error" role="alert" style={{ marginTop: "0.3rem" }}>
1951
- {startError}
1952
- </p>
1953
- )}
1954
-
1955
- {showStartLibPicker && (
1956
- <LibraryImageGrid
1957
- images={startLibraryImages}
1958
- onPick={(ref) => {
1959
- setStartImage(ref);
1960
- setShowStartLibPicker(false);
1961
- }}
1962
- />
1963
- )}
1964
- </section>
1965
2214
 
1966
- {/* ── IDENTITY reference(s): the locked subject carried across scene changes ── */}
1967
- <section aria-label="Identity reference" style={{ marginBottom: "0.6rem" }}>
1968
- <p className="vi-comfy-label" style={{ marginBottom: "0.25rem", opacity: 0.85 }}>
1969
- Identity reference — carry a subject across scenes (optional)
1970
- </p>
1971
- {/* The reference thumbnails + upload AND the identity-profile affordances read as
1972
- ONE input block — matching how the Clip Generate tab embeds the same
1973
- <IdentityProfileControls> inline, directly beneath its references
1974
- (StudioGenerateTab.renderReferenceSlots), rather than letting it stand off as a
1975
- separate island row. Presentation/placement only: the props + wiring below are
1976
- byte-identical to before. */}
1977
- <div>
1978
- <div style={{ display: "flex", alignItems: "center", gap: "0.6rem", flexWrap: "wrap" }}>
1979
- {references.map((r, i) => (
1980
- <span key={r.uri} style={{ position: "relative", display: "inline-flex" }}>
1981
- <img
1982
- src={mediaBytesUrl(r.uri)}
1983
- alt={`identity reference ${i + 1}`}
1984
- style={{
1985
- width: "4rem",
1986
- height: "4rem",
1987
- objectFit: "cover",
1988
- borderRadius: "0.4rem",
1989
- background: "#000",
1990
- border: "1px solid var(--vi-border)",
1991
- }}
1992
- />
1993
- <button
1994
- type="button"
1995
- className="vi-btn vi-btn-sm vi-btn-ghost"
1996
- disabled={disableSubmit || refBusy}
1997
- title="Remove this reference"
1998
- onClick={() => {
1999
- setReferences((prev) => prev.filter((x) => x.uri !== r.uri));
2000
- setSelectedProfile(null); // editing the set = an UNSAVED identity
2001
- }}
2002
- style={{ position: "absolute", top: -6, right: -6, padding: "0 0.3rem", lineHeight: 1.2 }}
2003
- >
2004
- ✕
2005
- </button>
2006
- </span>
2007
- ))}
2008
- {references.length < REF_MAX && (
2009
- <label className="vi-btn vi-btn-sm vi-btn-ghost vi-file-label">
2010
- {refBusy ? "Working…" : references.length ? "+ Add reference" : "Upload a reference"}
2011
- <input
2012
- type="file"
2013
- accept="image/*"
2014
- className="vi-file-input"
2015
- disabled={disableSubmit || refBusy}
2016
- onChange={(e) => {
2017
- const f = e.target.files?.[0];
2018
- if (f) void onPickReferenceImage(f);
2019
- e.target.value = "";
2020
- }}
2021
- />
2022
- </label>
2023
- )}
2024
- </div>
2025
- {/* IDENTITY PROFILES (stage a): pick a saved identity profile or save the current
2026
- references — the durable form of this identity. One additive row, embedded
2027
- inline within the reference input block (matching the Clip Generate tab). */}
2028
- <IdentityProfileControls
2029
- refCount={references.length}
2030
- currentRefUris={references.map((r) => r.uri)}
2031
- selectedProfile={selectedProfile}
2032
- onAttach={attachIdentityRefs}
2033
- onSelectProfile={setSelectedProfile}
2034
- maxRefs={REF_MAX}
2035
- disabled={disableSubmit || refBusy}
2036
- />
2037
- </div>
2038
- {/* RESOLUTION FOOTGUN (operator 2026-07-12): the only budget-fitting id-capable
2039
- model (wan2.1-vace-1.3b) tops out at 832×480 — an identity movie above that fails
2040
- no_capable_model. Warn LOUDLY with a one-click snap; never BLOCK (the backend's
2041
- rejection is honest if the operator insists on a bigger box). */}
2042
- {(references.length > 0 || selectedProfile != null) && (width > 832 || height > 480) && (
2043
- <p className="vi-input-callout" role="note" style={{ marginTop: "0.4rem" }}>
2044
- id-capable models on this fleet top out at 832×480 — this movie asks for {width}×{height}.{" "}
2045
- <button
2046
- type="button"
2047
- className="vi-btn vi-btn-sm vi-btn-accent"
2048
- onClick={() => {
2049
- setWidth(480);
2050
- setHeight(480);
2051
- }}
2052
- style={{ marginLeft: "0.3rem" }}
2053
- >
2054
- Set 480×480
2055
- </button>
2056
- </p>
2057
- )}
2058
- <span className="vi-comfy-hint" role="note" style={{ display: "block", marginTop: "0.3rem" }}>
2059
- {references.length
2060
- ? `Identity movie — every scene renders id_lock (Wan-VACE) so this subject carries across every cut. ${references.length}/${REF_MAX} reference(s). Needs a VACE-capable GPU worker; on a GPU-less box it reports deps-missing.`
2061
- : "Leave empty for a plain movie. Add a locked character/subject still to carry the same identity across every scene (needs a VACE-capable GPU worker)."}
2062
- </span>
2063
- {refError && (
2064
- <p className="vi-error" role="alert" style={{ marginTop: "0.3rem" }}>
2065
- {refError}
2066
- </p>
2067
- )}
2068
- </section>
2069
2215
 
2070
2216
  {/* ── the goal timeline (NLE-row order, top → bottom) ──
2071
2217
  Operator addendum 2026-07-12: each segment is now a DISTINCTLY CONTRASTED
@@ -2076,6 +2222,56 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2076
2222
  the follows-hint, and .vi-gen-part-actions (auto-pushed reorder/remove). No
2077
2223
  new look invented — the same classes GenerateStation.tsx uses. */}
2078
2224
  <section aria-label="Goals" className="vi-studio-composer vi-movie-timeline">
2225
+ {/* ── 🎬 PRODUCER (k120 slice 1) — a premise in, a full script out: the
2226
+ plan decides segment count, per-segment prompt/negative/length and
2227
+ how each shot joins the previous one (vace_extend = continue from
2228
+ the last frame). Rows land below as ordinary editable rows; the
2229
+ previous rows are parked in the stash, not deleted. */}
2230
+ <div className="vi-knob" style={{ marginBottom: "0.6rem" }}>
2231
+ <label htmlFor="vi-movie-premise">🎬 producer</label>
2232
+ <div
2233
+ style={{ display: "flex", gap: "0.5rem", alignItems: "flex-start", flexWrap: "wrap" }}
2234
+ >
2235
+ <textarea
2236
+ id="vi-movie-premise"
2237
+ className="vi-knob-input"
2238
+ rows={2}
2239
+ style={{ flex: "1 1 24rem", resize: "vertical" }}
2240
+ placeholder="Premise / logline — the producer writes the whole script: segments, prompts, lengths, joins"
2241
+ value={premise}
2242
+ onChange={(e) => setPremise(e.target.value)}
2243
+ disabled={producing}
2244
+ />
2245
+ <button
2246
+ type="button"
2247
+ className="vi-btn vi-btn-accent"
2248
+ disabled={producing || premise.trim() === ""}
2249
+ onClick={() => void produceMovie()}
2250
+ title="Generate a full script from the premise: segment count, per-segment prompts, negatives, lengths and joins. Current rows are parked in the stash."
2251
+ >
2252
+ {producing ? "Producing…" : "🎬 Produce"}
2253
+ </button>
2254
+ </div>
2255
+ <label
2256
+ className="vi-comfy-hint"
2257
+ style={{ display: "inline-flex", alignItems: "center", gap: "0.35rem" }}
2258
+ title="During the render, a vision model reads the frame each segment actually ended on and a text model rewrites the next segment's prompt to open from that state — true segment-to-segment continuity. Advisory: any failure keeps the authored prompt."
2259
+ >
2260
+ <input
2261
+ type="checkbox"
2262
+ checked={continuityRefresh}
2263
+ onChange={(e) => setContinuityRefresh(e.target.checked)}
2264
+ />
2265
+ continuity refresh — rewrite each next segment from the previous segment's rendered
2266
+ last frame
2267
+ </label>
2268
+ {producerNote ? <span className="vi-knob-hint">{producerNote}</span> : null}
2269
+ {produceError ? (
2270
+ <p className="vi-error" role="alert">
2271
+ {produceError}
2272
+ </p>
2273
+ ) : null}
2274
+ </div>
2079
2275
  {/* ── SEGMENT COUNT + GROUP ASSIST BAR (STUDIO-SPREAD-SPEC §2) ──
2080
2276
  Say how many shots the movie has, tick the ones you want written, and
2081
2277
  write them in ONE coherent pass. The count materializes or truncates
@@ -2087,9 +2283,9 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2087
2283
  label: "Segments",
2088
2284
  value: goals.length,
2089
2285
  min: 1,
2090
- max: MAX_SEGMENTS,
2091
2286
  onChange: setSegmentCount,
2092
- title: `How many shots this movie has (1–${MAX_SEGMENTS}). Lowering it removes rows from the END of the timeline.`,
2287
+ title:
2288
+ "How many shots this movie has. Lowering it parks rows from the END of the timeline; raising it brings them back before adding blanks.",
2093
2289
  }}
2094
2290
  noun="segment"
2095
2291
  selectedCount={selectedCount}
@@ -2176,6 +2372,18 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2176
2372
  </div>
2177
2373
  )}
2178
2374
 
2375
+ {/* k121: what the review DID to this set, in one line. Present even when
2376
+ nothing changed — "reviewed and fine" has to be visible, because the
2377
+ incident this exists for looked exactly like "nothing happened". */}
2378
+ {coordination && (
2379
+ <CoordinationSummary
2380
+ status={coordination.status}
2381
+ counts={coordination.counts}
2382
+ notes={coordination.notes}
2383
+ onDismiss={() => setCoordination(null)}
2384
+ />
2385
+ )}
2386
+
2179
2387
  <ol className="vi-gen-parts">
2180
2388
  {goals.map((g, i) => (
2181
2389
  <PromptCard
@@ -2196,6 +2404,13 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2196
2404
  /* The joint descriptor STAYS in the header (the joint SELECT moved
2197
2405
  into the settings expander below — k88). */
2198
2406
  headerHint={
2407
+ <CoordinationBadge
2408
+ review={segmentReview(coordination, g.key)}
2409
+ onAccept={acceptCoordination}
2410
+ onReject={rejectCoordination}
2411
+ settled={coordSettled}
2412
+ disabled={disableSubmit}
2413
+ >
2199
2414
  <span className="vi-comfy-hint" style={{ opacity: 0.75 }}>
2200
2415
  {i === 0
2201
2416
  ? references.length
@@ -2209,6 +2424,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2209
2424
  ? "extends motion from the previous segment"
2210
2425
  : "continues from a frame of the previous segment"}
2211
2426
  </span>
2427
+ </CoordinationBadge>
2212
2428
  }
2213
2429
  canMoveUp={i > 0}
2214
2430
  canMoveDown={i < goals.length - 1}
@@ -2356,6 +2572,241 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2356
2572
  />
2357
2573
  </div>
2358
2574
 
2575
+ {/* SEGMENT-1 INPUTS, IN-CARD (operator 2026-08-13): the movie-start
2576
+ still and the identity reference moved INTO the prompt component —
2577
+ the same per-part attach idiom Scene uses — decluttering the column.
2578
+ Wiring is byte-identical to the old standalone sections. */}
2579
+ {i === 0 && (
2580
+ <>
2581
+ {/* ── Movie start switcher (operator ask 2026-07-12, verbatim): "It needs to be
2582
+ choosable by the user: any library clip, image, or scene to start — or
2583
+ start fresh with a prompt to generate a clip. This static starting clip is
2584
+ not going to work if it cannot be overridden." Same visual row as the Clip
2585
+ surface's subject-clip switcher (.vi-studio-src-actions) — four honest ways
2586
+ to set (or clear) segment 1's start, none of them a hidden default. ── */}
2587
+ <section aria-label="Movie start" style={{ marginBottom: "0.6rem" }}>
2588
+ <p
2589
+ className="vi-comfy-label"
2590
+ style={{ marginBottom: "0.25rem", opacity: 0.85, cursor: "help" }}
2591
+ title="How segment 1 opens: pick a library image, use a library clip's last frame, upload a still, or leave unset to open from your first segment's prompt (text-to-video). Never a hidden default — whatever is set is shown here."
2592
+ >
2593
+ Movie start — segment 1 (optional) ⓘ
2594
+ </p>
2595
+ <div className="vi-studio-src-actions" role="group" aria-label="Choose how the movie starts">
2596
+ <button
2597
+ type="button"
2598
+ className="vi-btn vi-btn-sm"
2599
+ disabled={disableSubmit || startBusy}
2600
+ aria-expanded={showStartLibPicker}
2601
+ onClick={() => setShowStartLibPicker((v) => !v)}
2602
+ >
2603
+ {showStartLibPicker ? "Hide library" : `🖼 From library (${startLibraryImages.length})`}
2604
+ </button>
2605
+ {/* CLIP-AS-START FINDING (stated in the file header above): the movie schema
2606
+ has no `start_clip` field, and there is no synchronous "give me this
2607
+ clip's last frame" endpoint — frame_extract is its own async job (enqueue
2608
+ → poll → pick a frame), not a small addition here. Shown so the operator
2609
+ SEES the option exists, disabled so nothing dishonest is implied. */}
2610
+ <button
2611
+ type="button"
2612
+ className="vi-btn vi-btn-sm vi-btn-ghost"
2613
+ disabled
2614
+ 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."
2615
+ >
2616
+ 🎬 From library (clip → last frame)
2617
+ </button>
2618
+ <label className="vi-btn vi-btn-sm vi-file-label">
2619
+ {startBusy ? "Working…" : "⇪ Upload still"}
2620
+ <input
2621
+ type="file"
2622
+ accept="image/*"
2623
+ className="vi-file-input"
2624
+ disabled={disableSubmit || startBusy}
2625
+ onChange={(e) => {
2626
+ const f = e.target.files?.[0];
2627
+ if (f) void onPickStartImage(f);
2628
+ e.target.value = "";
2629
+ }}
2630
+ />
2631
+ </label>
2632
+ <button
2633
+ type="button"
2634
+ className="vi-btn vi-btn-sm vi-btn-ghost"
2635
+ disabled={disableSubmit || startBusy}
2636
+ title="Clear the start still — segment 1 opens from its own prompt instead (text-to-video)."
2637
+ onClick={() => {
2638
+ setStartImage(null);
2639
+ setShowStartLibPicker(false);
2640
+ }}
2641
+ >
2642
+ ✦ Fresh prompt
2643
+ </button>
2644
+ </div>
2645
+
2646
+ {/* Current start state — ALWAYS visible, so the picked (or default) start is
2647
+ never implicit (the operator's exact complaint about the old static slot). */}
2648
+ {startImage ? (
2649
+ <div
2650
+ style={{ display: "flex", alignItems: "center", gap: "0.6rem", flexWrap: "wrap", marginTop: "0.5rem" }}
2651
+ >
2652
+ <img
2653
+ src={mediaBytesUrl(startImage.uri)}
2654
+ alt="segment 1 start image"
2655
+ style={{
2656
+ width: "5rem",
2657
+ height: "5rem",
2658
+ objectFit: "cover",
2659
+ borderRadius: "0.4rem",
2660
+ background: "#000",
2661
+ border: "1px solid var(--vi-border)",
2662
+ }}
2663
+ />
2664
+ <span className="vi-comfy-hint" role="note" style={{ margin: 0 }}>
2665
+ Segment 1 renders image-to-video from this still.
2666
+ </span>
2667
+ <button
2668
+ type="button"
2669
+ className="vi-btn vi-btn-sm vi-btn-ghost"
2670
+ disabled={disableSubmit || startBusy}
2671
+ onClick={() => setStartImage(null)}
2672
+ >
2673
+ ✕ Remove
2674
+ </button>
2675
+ </div>
2676
+ ) : (
2677
+ <p className="vi-comfy-hint" role="note" style={{ marginTop: "0.5rem" }}>
2678
+ No start still — text-to-video opening.
2679
+ </p>
2680
+ )}
2681
+
2682
+ {startError && (
2683
+ <p className="vi-error" role="alert" style={{ marginTop: "0.3rem" }}>
2684
+ {startError}
2685
+ </p>
2686
+ )}
2687
+
2688
+ {showStartLibPicker && (
2689
+ <LibraryImageGrid
2690
+ images={startLibraryImages}
2691
+ onPick={(ref) => {
2692
+ setStartImage(ref);
2693
+ setShowStartLibPicker(false);
2694
+ }}
2695
+ />
2696
+ )}
2697
+ </section>
2698
+ {/* ── IDENTITY reference(s): the locked subject carried across scene changes ── */}
2699
+ <section aria-label="Identity reference" style={{ marginBottom: "0.6rem" }}>
2700
+ <p
2701
+ className="vi-comfy-label"
2702
+ style={{ marginBottom: "0.25rem", opacity: 0.85, cursor: "help" }}
2703
+ title="Leave empty for a plain movie. Add a locked character/subject still (or pick a saved identity profile) and EVERY scene renders id_lock (Wan-VACE) so the same identity carries across every cut. Needs a VACE-capable GPU worker; a GPU-less box reports deps-missing."
2704
+ >
2705
+ Identity reference — carry a subject across scenes (optional) ⓘ
2706
+ </p>
2707
+ {/* The reference thumbnails + upload AND the identity-profile affordances read as
2708
+ ONE input block — matching how the Clip Generate tab embeds the same
2709
+ <IdentityProfileControls> inline, directly beneath its references
2710
+ (StudioGenerateTab.renderReferenceSlots), rather than letting it stand off as a
2711
+ separate island row. Presentation/placement only: the props + wiring below are
2712
+ byte-identical to before. */}
2713
+ {/* ONE ROW (operator 2026-08-13): thumbnails, upload AND the profile
2714
+ controls share a single wrapping flex row — no stacked button rows. */}
2715
+ <div style={{ display: "flex", alignItems: "center", gap: "0.6rem", flexWrap: "wrap" }}>
2716
+ <div style={{ display: "contents" }}>
2717
+ {references.map((r, i) => (
2718
+ <span key={r.uri} style={{ position: "relative", display: "inline-flex" }}>
2719
+ <img
2720
+ src={mediaBytesUrl(r.uri)}
2721
+ alt={`identity reference ${i + 1}`}
2722
+ style={{
2723
+ width: "4rem",
2724
+ height: "4rem",
2725
+ objectFit: "cover",
2726
+ borderRadius: "0.4rem",
2727
+ background: "#000",
2728
+ border: "1px solid var(--vi-border)",
2729
+ }}
2730
+ />
2731
+ <button
2732
+ type="button"
2733
+ className="vi-btn vi-btn-sm vi-btn-ghost"
2734
+ disabled={disableSubmit || refBusy}
2735
+ title="Remove this reference"
2736
+ onClick={() => {
2737
+ setReferences((prev) => prev.filter((x) => x.uri !== r.uri));
2738
+ setSelectedProfile(null); // editing the set = an UNSAVED identity
2739
+ }}
2740
+ style={{ position: "absolute", top: -6, right: -6, padding: "0 0.3rem", lineHeight: 1.2 }}
2741
+ >
2742
+ ✕
2743
+ </button>
2744
+ </span>
2745
+ ))}
2746
+ {references.length < REF_MAX && (
2747
+ <label className="vi-btn vi-btn-sm vi-btn-ghost vi-file-label">
2748
+ {refBusy ? "Working…" : references.length ? "+ Add reference" : "Upload a reference"}
2749
+ <input
2750
+ type="file"
2751
+ accept="image/*"
2752
+ className="vi-file-input"
2753
+ disabled={disableSubmit || refBusy}
2754
+ onChange={(e) => {
2755
+ const f = e.target.files?.[0];
2756
+ if (f) void onPickReferenceImage(f);
2757
+ e.target.value = "";
2758
+ }}
2759
+ />
2760
+ </label>
2761
+ )}
2762
+ </div>
2763
+ {/* IDENTITY PROFILES (stage a): pick a saved identity profile or save the current
2764
+ references — the durable form of this identity. One additive row, embedded
2765
+ inline within the reference input block (matching the Clip Generate tab). */}
2766
+ <IdentityProfileControls
2767
+ refCount={references.length}
2768
+ currentRefUris={references.map((r) => r.uri)}
2769
+ selectedProfile={selectedProfile}
2770
+ onAttach={attachIdentityRefs}
2771
+ onSelectProfile={setSelectedProfile}
2772
+ maxRefs={REF_MAX}
2773
+ disabled={disableSubmit || refBusy}
2774
+ />
2775
+ </div>
2776
+ {/* RESOLUTION FOOTGUN (operator 2026-07-12): the only budget-fitting id-capable
2777
+ model (wan2.1-vace-1.3b) tops out at 832×480 — an identity movie above that fails
2778
+ no_capable_model. Warn LOUDLY with a one-click snap; never BLOCK (the backend's
2779
+ rejection is honest if the operator insists on a bigger box). */}
2780
+ {(references.length > 0 || selectedProfile != null) && (width > 832 || height > 480) && (
2781
+ <p className="vi-input-callout" role="note" style={{ marginTop: "0.4rem" }}>
2782
+ id-capable models on this fleet top out at 832×480 — this movie asks for {width}×{height}.{" "}
2783
+ <button
2784
+ type="button"
2785
+ className="vi-btn vi-btn-sm vi-btn-accent"
2786
+ onClick={() => {
2787
+ setWidth(480);
2788
+ setHeight(480);
2789
+ }}
2790
+ style={{ marginLeft: "0.3rem" }}
2791
+ >
2792
+ Set 480×480
2793
+ </button>
2794
+ </p>
2795
+ )}
2796
+ {references.length > 0 && (
2797
+ <span className="vi-comfy-hint" role="note" style={{ display: "block", marginTop: "0.3rem" }}
2798
+ title="Every scene renders id_lock (Wan-VACE) so this subject carries across every cut. Needs a VACE-capable GPU worker.">
2799
+ Identity movie — id_lock every scene · {references.length}/{REF_MAX} reference(s)
2800
+ </span>
2801
+ )}
2802
+ {refError && (
2803
+ <p className="vi-error" role="alert" style={{ marginTop: "0.3rem" }}>
2804
+ {refError}
2805
+ </p>
2806
+ )}
2807
+ </section>
2808
+ </>
2809
+ )}
2359
2810
  {/* k88 — the shared settings expander (collapsed by default); the joint
2360
2811
  DESCRIPTOR stays visible in the card header above.
2361
2812
  2026-08-12: grew from seed-only to the FULL per-segment knob set the
@@ -2367,8 +2818,8 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2367
2818
  <PromptCardSettings
2368
2819
  label={
2369
2820
  i === 0
2370
- ? "settings — seed · model · steps · cfg"
2371
- : "settings — seed · model · steps · cfg · joint · branch"
2821
+ ? "settings — seed · model · steps · cfg · length"
2822
+ : "settings — seed · model · steps · cfg · length · joint · branch"
2372
2823
  }
2373
2824
  >
2374
2825
  <div className="vi-knob" style={{ maxWidth: "10rem" }}>
@@ -2448,6 +2899,48 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2448
2899
  />
2449
2900
  <span className="vi-knob-hint">0–20 · blank = movie/model default</span>
2450
2901
  </div>
2902
+ {/* LENGTH ROW (per-goal): fps is MOVIE-level here (fixed/grey) —
2903
+ frames<->seconds derive against it. Blank = movie default. */}
2904
+ <LengthRow
2905
+ idPrefix={`vi-movie-goal-len-${g.key}`}
2906
+ fps={fps}
2907
+ fpsFixedReason="Movie-level — one fps per movie; change it in movie settings above."
2908
+ frames={g.frames}
2909
+ onFrames={(v) => setGoalField(i, { frames: v })}
2910
+ framesPlaceholder={framesDefault.trim() || "movie default (81)"}
2911
+ />
2912
+ {Number(g.frames.trim() || framesDefault.trim()) > MODEL_FRAME_CEILING && (
2913
+ <div className="vi-knob" style={{ flexBasis: "100%" }}>
2914
+ <button
2915
+ type="button"
2916
+ className="vi-btn vi-btn-sm"
2917
+ title="The model ceiling is 81 frames — materialize motion-carried continuation segments (vace_extend) so the movie honestly reaches this length. They appear in the timeline, fully editable."
2918
+ onClick={() => splitGoalToFit(i)}
2919
+ >
2920
+ ✂ split into {Math.ceil(Number(g.frames.trim() || framesDefault.trim()) / MODEL_FRAME_CEILING)} segments to fit
2921
+ </button>
2922
+ </div>
2923
+ )}
2924
+ {/* UNIFORM-CARD PLACEHOLDERS (operator 2026-08-13): geometry and
2925
+ budget are MOVIE-level by server contract (one StudioMovieSpec
2926
+ geometry per movie) — shown greyed with the reason, never
2927
+ hidden, so every prompt card reads identically. */}
2928
+ <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2929
+ <label htmlFor={`vi-movie-goalw-${g.key}`}>width</label>
2930
+ <input id={`vi-movie-goalw-${g.key}`} className="vi-knob-input" disabled value={width}
2931
+ title="Movie-level — one geometry per movie (StudioMovieSpec); change it in movie settings above." />
2932
+ </div>
2933
+ <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2934
+ <label htmlFor={`vi-movie-goalh-${g.key}`}>height</label>
2935
+ <input id={`vi-movie-goalh-${g.key}`} className="vi-knob-input" disabled value={height}
2936
+ title="Movie-level — one geometry per movie (StudioMovieSpec); change it in movie settings above." />
2937
+ </div>
2938
+ <div className="vi-knob" style={{ maxWidth: "9rem" }}>
2939
+ <label htmlFor={`vi-movie-goalvram-${g.key}`}>VRAM budget GB</label>
2940
+ <input id={`vi-movie-goalvram-${g.key}`} className="vi-knob-input" disabled
2941
+ value={vramBudget.trim() || "autofit"}
2942
+ title="Movie-level — set in movie settings above (blank = autofit to the serving worker's free VRAM)." />
2943
+ </div>
2451
2944
  {i > 0 && (
2452
2945
  <>
2453
2946
  <div className="vi-knob" style={{ maxWidth: "13rem" }}>
@@ -2528,52 +3021,6 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2528
3021
  </div>
2529
3022
  </section>
2530
3023
 
2531
- {/* ── movie-wide NEGATIVE prompt — the DEFAULT every segment inherits (a per-segment
2532
- negative above overrides it for that segment only). Kept in the center column,
2533
- beside the goals, as a wide prompt textarea — the geometry / model pin / sampler
2534
- knobs now live in the left-sidebar Settings tab (see settingsNode above),
2535
- mirroring the Clip surface which keeps its negative out of the knob grid.
2536
- k88: deliberately NOT restyled with PromptFieldTabs — this block is a lone
2537
- negative with no prompt pane to pair it with, so tabs would be one dead tab. ── */}
2538
- <section aria-label="Movie negative prompt" className="vi-studio-knobs" style={{ marginTop: "0.8rem" }}>
2539
- <div className="vi-studio-knob-wide" style={{ flex: "1 1 100%" }}>
2540
- <GoalComposer
2541
- id="vi-movie-negative"
2542
- label="negative prompt (movie-wide default, optional)"
2543
- value={negative}
2544
- placeholder="what to avoid across every segment — each segment can override above"
2545
- onChange={setNegative}
2546
- hint="The default for every segment. A segment's own negative prompt overrides this for that segment."
2547
- />
2548
- {/* The same §1b affordance for the movie-wide default — subject is the movie
2549
- idea if you wrote one, else the opening shot's prompt. */}
2550
- <div className="vi-prompt-assist">
2551
- <span className="vi-prompt-assist-lead" aria-hidden="true">
2552
- negative assist
2553
- </span>
2554
- <button
2555
- type="button"
2556
- className="vi-btn vi-btn-sm vi-btn-ghost"
2557
- disabled={assist.assistBusy != null || disableSubmit}
2558
- title="Write an artifact/quality exclusion list for the whole movie (replaces this field)."
2559
- onClick={() =>
2560
- void assist.runNegative(
2561
- {
2562
- subject: movieQuery.trim() || goals[0]?.prompt || "",
2563
- draft: negative,
2564
- extra: {
2565
- context: identityContext ? { identity_profile: identityContext } : {},
2566
- },
2567
- },
2568
- setNegative,
2569
- )
2570
- }
2571
- >
2572
- {assist.assistBusy === "negative" ? "✨ Writing…" : "✨ Generate negative"}
2573
- </button>
2574
- </div>
2575
- </div>
2576
- </section>
2577
3024
 
2578
3025
  {/* ── SYNTHETIC-TIER HONESTY BANNER — loud, right above Generate movie, so the
2579
3026
  operator sees it at the moment it matters instead of the knob-grid
@@ -2620,31 +3067,14 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2620
3067
  {/* ── UNNAMED-MOVIE NUDGE — a warning with a way through, never a gate. Sits
2621
3068
  right above Generate because that is where the decision is made. ── */}
2622
3069
  {nameWarn && (
2623
- <div
2624
- className="vi-studio-required-callout"
2625
- role="alert"
2626
- style={{
2627
- display: "flex",
2628
- alignItems: "center",
2629
- gap: "0.6rem",
2630
- flexWrap: "wrap",
2631
- marginBottom: "0.6rem",
3070
+ <UnnamedWarnPanel
3071
+ noun="movie"
3072
+ onProceed={(name) => {
3073
+ setNameWarn(false);
3074
+ onGenerate(true, name);
2632
3075
  }}
2633
- >
2634
- <span>
2635
- This movie has no name — it will list as “untitled” beside a bare id in
2636
- Cinema sessions, which is hard to tell apart from every other unnamed render
2637
- when you come back to resume one.
2638
- </span>
2639
- <button
2640
- type="button"
2641
- className="vi-btn vi-btn-sm vi-btn-ghost"
2642
- disabled={disableSubmit}
2643
- onClick={() => onGenerate(true)}
2644
- >
2645
- Generate without a name
2646
- </button>
2647
- </div>
3076
+ onDismiss={() => setNameWarn(false)}
3077
+ />
2648
3078
  )}
2649
3079
 
2650
3080
  {/* ── run row ── */}
@@ -2733,7 +3163,22 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2733
3163
  <span style={{ fontVariantNumeric: "tabular-nums", opacity: 0.7, minWidth: "5.5rem" }}>
2734
3164
  seg {s.index + 1} · {statusBadge(s.status, s.resumed)}
2735
3165
  </span>
2736
- <span style={{ opacity: 0.85, wordBreak: "break-word" }}>{s.prompt ?? ""}</span>
3166
+ <span style={{ opacity: 0.85, wordBreak: "break-word" }}>
3167
+ {s.prompt ?? ""}
3168
+ {s.refresh_note ? (
3169
+ <span
3170
+ className="vi-comfy-hint"
3171
+ style={{ display: "block", opacity: 0.75 }}
3172
+ title={
3173
+ s.prompt_authored
3174
+ ? `authored prompt: ${s.prompt_authored}`
3175
+ : undefined
3176
+ }
3177
+ >
3178
+ 🎬 {s.refresh_note}
3179
+ </span>
3180
+ ) : null}
3181
+ </span>
2737
3182
  </li>
2738
3183
  ))}
2739
3184
  </ul>