@hugpy/video-intelligence-ui 0.1.0 → 0.1.1

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.
@@ -66,7 +66,8 @@ 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";
70
71
  import { GoalComposer } from "../GoalComposer";
71
72
  import { usePromptAssist, type AssistExtras } from "../../video/usePromptAssist";
72
73
  import { PromptAssistButtons } from "../../video/PromptAssistButtons";
@@ -295,12 +296,19 @@ interface GoalRow {
295
296
  steps: string;
296
297
  cfg: string;
297
298
  contextFrames: string;
299
+ // per-segment CLIP LENGTH in frames (2026-08-13); blank = movie default.
300
+ frames: string;
298
301
  }
299
302
 
300
303
  // The counter is SESSION-persisted (not a module `let`) now that the goal rows
301
304
  // themselves survive a reload: a module counter restarts at 0 while the restored
302
305
  // rows keep their old keys, so `goal_1` could be minted twice — and this key
303
306
  // doubles as the wire `segment_id`. Same shape, just a counter that remembers.
307
+ // The real-model frame ceiling (Wan family: 81) — the spine clamps to this
308
+ // and snaps to 4k+1. Split-to-fit uses it to materialize CONTINUATION
309
+ // segments instead of letting a longer ask silently degrade.
310
+ const MODEL_FRAME_CEILING = 81;
311
+
304
312
  function newGoalKey(): string {
305
313
  return `goal_${nextSessionSeq("studio.movie.goalKeySeq")}`;
306
314
  }
@@ -309,7 +317,7 @@ function blankGoal(joint: JointMode = "still"): GoalRow {
309
317
  key: newGoalKey(),
310
318
  prompt: "",
311
319
  negative: "",
312
- stdNegative: false,
320
+ stdNegative: true,
313
321
  seed: "",
314
322
  branchFrame: "",
315
323
  joint,
@@ -317,6 +325,7 @@ function blankGoal(joint: JointMode = "still"): GoalRow {
317
325
  steps: "",
318
326
  cfg: "",
319
327
  contextFrames: "",
328
+ frames: "",
320
329
  };
321
330
  }
322
331
 
@@ -602,6 +611,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
602
611
  steps: "",
603
612
  cfg: "",
604
613
  contextFrames: "",
614
+ frames: "",
605
615
  ...g,
606
616
  })),
607
617
  [goalsRaw],
@@ -611,6 +621,10 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
611
621
  // Default 480×480: the wan2.1-vace-1.3b IDENTITY sweet spot — the only budget-fitting
612
622
  // id-capable model on this fleet tops out at 832×480, so a 512×512 default cannot serve
613
623
  // id_lock on it (no_capable_model). 832×480 widescreen is the other valid id envelope.
624
+ // EXPLICIT SEGMENT LENGTH (operator 2026-08-13): movie-level default frames
625
+ // per segment; a segment's own frames (below) overrides. Blank = each bound
626
+ // model's default (81 real). Clamped + 4k+1-snapped by the spine.
627
+ const [framesDefault, setFramesDefault] = useSessionState("studio.movie.frames", "");
614
628
  const [width, setWidth] = useSessionState("studio.movie.width", 480);
615
629
  const [height, setHeight] = useSessionState("studio.movie.height", 480);
616
630
  const [fps, setFps] = useSessionState("studio.movie.fps", 24);
@@ -1011,6 +1025,44 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1011
1025
  },
1012
1026
  [],
1013
1027
  );
1028
+
1029
+ // SPLIT-TO-FIT (operator 2026-08-13): a length ask beyond the model ceiling
1030
+ // becomes VISIBLE continuation segments — motion-carried (vace_extend),
1031
+ // length-distributed, each editable — never submit-time magic. The
1032
+ // continuation prompt is a plain, honest default; ✨ Generate can refine it.
1033
+ const splitGoalToFit = useCallback(
1034
+ (i: number) => {
1035
+ const g = goals[i];
1036
+ if (!g) return;
1037
+ const ask = Number(g.frames.trim() || framesDefault.trim());
1038
+ if (!Number.isInteger(ask) || ask <= MODEL_FRAME_CEILING) return;
1039
+ const n = Math.ceil(ask / MODEL_FRAME_CEILING);
1040
+ const per = Math.ceil(ask / n);
1041
+ const chunks: number[] = [];
1042
+ let left = ask;
1043
+ for (let k = 0; k < n; k++) {
1044
+ const c = Math.min(per, left);
1045
+ chunks.push(Math.max(5, c));
1046
+ left -= c;
1047
+ }
1048
+ const base = g.prompt.trim();
1049
+ const contPrompt = base
1050
+ ? `the scene continues seamlessly, same setting and motion — ${base.split(/[.,;\n]/)[0].trim().slice(0, 80)}`
1051
+ : "the scene continues seamlessly, same setting and motion";
1052
+ setGoals((prev) => {
1053
+ const next = [...prev];
1054
+ next[i] = { ...next[i], frames: String(chunks[0]) };
1055
+ const conts = chunks.slice(1).map((c) => ({
1056
+ ...blankGoal("vace_extend"),
1057
+ prompt: contPrompt,
1058
+ frames: String(c),
1059
+ }));
1060
+ next.splice(i + 1, 0, ...conts);
1061
+ return next;
1062
+ });
1063
+ },
1064
+ [goals, framesDefault, setGoals],
1065
+ );
1014
1066
  const addGoal = useCallback(() => setGoals((prev) => [...prev, blankGoal()]), []);
1015
1067
  const removeGoal = useCallback((idx: number) => {
1016
1068
  // Keep at least one row — a movie needs a root segment.
@@ -1133,18 +1185,54 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1133
1185
  // The row's RESOLVED operation only chooses which mode the primary button invokes;
1134
1186
  // both explicit buttons stay live underneath, so a router mistake costs one click,
1135
1187
  // never a paragraph of the user's work.
1188
+ // The last AUTO-derived movie title. A name the operator typed is sacred; a
1189
+ // name this ref minted may be replaced by the next Generate (operator
1190
+ // 2026-08-13: "purely a product of the same generation method").
1191
+ const autoTitleRef = useRef("");
1192
+
1136
1193
  const runRowAssist = useCallback(
1137
1194
  (i: number, mode: "detail" | "generate") => {
1138
1195
  const g = goals[i];
1139
1196
  if (!g) return;
1140
- void assist.runAssist(mode, g.prompt, (p) => setGoalField(i, { prompt: p }), {
1141
- context: rowContext(i),
1142
- });
1197
+ void (async () => {
1198
+ let fresh = "";
1199
+ await assist.runAssist(mode, g.prompt, (p) => {
1200
+ fresh = p;
1201
+ setGoalField(i, { prompt: p });
1202
+ }, { context: rowContext(i) });
1203
+ if (mode !== "generate" || !fresh.trim()) return;
1204
+ // NEGATIVE RIDES ALONG (operator 2026-08-13): the generated exclusion
1205
+ // list APPENDS to this goal's typed negative, never clobbers it
1206
+ // (composeNegative dedupes the std terms at payload time).
1207
+ await assist.runNegative(
1208
+ { subject: fresh, extra: { context: rowContext(i) } },
1209
+ (neg) => {
1210
+ const cur = (g.negative ?? "").trim().replace(/,\s*$/, "");
1211
+ setGoalField(i, { negative: cur ? `${cur}, ${neg}` : neg });
1212
+ },
1213
+ );
1214
+ // NAME RIDES ALONG: derive a title from the generated prompt when the
1215
+ // movie has none — or when the current one was itself auto-derived.
1216
+ const t = movieTitle.trim();
1217
+ if (t === "" || t === autoTitleRef.current) {
1218
+ const derived = fresh
1219
+ .split(/[.,;\n]/)[0]
1220
+ .trim()
1221
+ .split(/\s+/)
1222
+ .slice(0, 7)
1223
+ .join(" ")
1224
+ .slice(0, 48);
1225
+ if (derived) {
1226
+ autoTitleRef.current = derived;
1227
+ setMovieTitle(derived);
1228
+ }
1229
+ }
1230
+ })();
1143
1231
  // The text is about to change, so the old classification no longer describes it.
1144
1232
  intentText.current[g.key] = "";
1145
1233
  setIntents((prev) => ({ ...prev, [g.key]: null }));
1146
1234
  },
1147
- [assist, goals, rowContext, setGoalField],
1235
+ [assist, goals, rowContext, setGoalField, movieTitle, setMovieTitle],
1148
1236
  );
1149
1237
 
1150
1238
  // ── negative generation (§1b) — an exclusion list for THIS shot ──
@@ -1300,7 +1388,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1300
1388
  }, [pendingFocusKey, goals]);
1301
1389
 
1302
1390
  // ── build the exact request body the route reads ──
1303
- const buildBody = useCallback((): { body: Record<string, unknown> } | { err: string } => {
1391
+ const buildBody = useCallback((titleOverride?: string): { body: Record<string, unknown> } | { err: string } => {
1304
1392
  if (goals.length < 1) return { err: "Add at least one goal (the root segment)." };
1305
1393
  for (let i = 0; i < goals.length; i++) {
1306
1394
  if (goals[i].prompt.trim() === "") {
@@ -1320,6 +1408,10 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1320
1408
  if (gCfg !== "" && (Number.isNaN(Number(gCfg)) || Number(gCfg) < 0 || Number(gCfg) > 20)) {
1321
1409
  return { err: `Goal ${i + 1} CFG must be a number in [0, 20] (or blank to inherit).` };
1322
1410
  }
1411
+ const gFrames = goals[i].frames.trim();
1412
+ if (gFrames !== "" && (!Number.isInteger(Number(gFrames)) || Number(gFrames) < 1)) {
1413
+ return { err: `Goal ${i + 1} length must be a whole number of frames \u2265 1 (or blank to inherit).` };
1414
+ }
1323
1415
  const gCtx = goals[i].contextFrames.trim();
1324
1416
  if (gCtx !== "" && (!Number.isInteger(Number(gCtx)) || Number(gCtx) < 1)) {
1325
1417
  return { err: `Goal ${i + 1} context frames must be a whole number ≥ 1 (or blank to inherit).` };
@@ -1347,6 +1439,10 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1347
1439
  // Movie-level sampler overrides — same bounds the clip surface (and the movie
1348
1440
  // schema's _check_steps_cfg) enforce: steps int [1,100], cfg number [0,20]. Blank
1349
1441
  // drops the key (model default).
1442
+ const framesDefNum = framesDefault.trim() === "" ? undefined : Number(framesDefault);
1443
+ if (framesDefNum !== undefined && (!Number.isInteger(framesDefNum) || framesDefNum < 1)) {
1444
+ return { err: "Segment length must be a whole number of frames \u2265 1 (or blank for model defaults)." };
1445
+ }
1350
1446
  const stepsNum = steps.trim() === "" ? undefined : Number(steps);
1351
1447
  if (
1352
1448
  stepsNum !== undefined &&
@@ -1376,6 +1472,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1376
1472
  if (g.modelId.trim() !== "") node.model_id = g.modelId.trim();
1377
1473
  if (g.steps.trim() !== "") node.steps = Number(g.steps);
1378
1474
  if (g.cfg.trim() !== "") node.cfg = Number(g.cfg);
1475
+ if (g.frames.trim() !== "") node.frames = Number(g.frames);
1379
1476
  // joint_mode + branch_frame are meaningless on the root (segment 0 has no parent).
1380
1477
  if (i > 0) {
1381
1478
  // Send a non-default joint_mode ("vace_extend" | "cut"); "still" is the route default.
@@ -1396,6 +1493,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1396
1493
 
1397
1494
  const body: Record<string, unknown> = {
1398
1495
  resolution: { width, height, fps },
1496
+ frames: framesDefNum,
1399
1497
  seed,
1400
1498
  vram_budget_gb: budgetNum,
1401
1499
  // Movie-level model pin + sampler overrides (blank ⇒ key dropped = router/model
@@ -1404,12 +1502,14 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1404
1502
  model_id: modelId.trim() ? modelId.trim() : undefined,
1405
1503
  steps: stepsNum,
1406
1504
  cfg: cfgNum,
1407
- negative_prompt: negative.trim() ? negative : undefined,
1505
+ // negative_prompt (movie-wide) RETIRED from this surface 2026-08-13
1506
+ // (operator: deprecated artifact) — per-goal negatives + the standard
1507
+ // tick compose everything; the route treats absence as None cleanly.
1408
1508
  project: project.trim() ? project.trim() : undefined,
1409
1509
  // The movie's NAME (k91) — free text, display-only: it never changes what renders,
1410
1510
  // it is what the sessions list shows instead of a bare dir id. Blank drops the key
1411
1511
  // (the schema takes title=None and the session lists as "untitled").
1412
- title: movieTitle.trim() ? movieTitle.trim() : undefined,
1512
+ title: (titleOverride ?? movieTitle).trim() ? (titleOverride ?? movieTitle).trim() : undefined,
1413
1513
  // Optional root conditioning still — the route jail-resolves this abs uri and
1414
1514
  // renders segment 0 i2v (drops back to t2v when absent). Mirrors the clip surface.
1415
1515
  start_image: startImage ? startImage.uri : undefined,
@@ -1429,10 +1529,13 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1429
1529
  // name. It is a warning, not a gate: the run row then offers "Generate without a
1430
1530
  // name", and `onGenerate(true)` skips the check. Cleared whenever a name is typed.
1431
1531
  const [nameWarn, setNameWarn] = useState(false);
1532
+ // The warning is an EXPLICIT PER-SECTION OPTION now (operator 2026-08-13),
1533
+ // DEFAULT OFF, remembered in localStorage — shared UnnamedWarn seam.
1534
+ const [warnUnnamed, setWarnUnnamed] = useWarnUnnamedPref("cinema");
1432
1535
 
1433
1536
  const onGenerate = useCallback(
1434
- (skipNameCheck = false) => {
1435
- const built = buildBody();
1537
+ (skipNameCheck = false, titleOverride?: string) => {
1538
+ const built = buildBody(titleOverride);
1436
1539
  if ("err" in built) {
1437
1540
  setFormMsg(built.err);
1438
1541
  return;
@@ -1440,18 +1543,40 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1440
1543
  // Warn ONCE per unnamed submit. An unnamed movie is still fully renderable — the
1441
1544
  // cost is later, in a sessions list where it reads as "untitled · 7f3a91c2…" and
1442
1545
  // nobody can tell which paused render is which.
1443
- if (!skipNameCheck && movieTitle.trim() === "") {
1546
+ if (!skipNameCheck && warnUnnamed && movieTitle.trim() === "") {
1444
1547
  setNameWarn(true);
1445
1548
  setFormMsg(null);
1446
1549
  return;
1447
1550
  }
1448
1551
  setNameWarn(false);
1449
1552
  setFormMsg(null);
1553
+ if (titleOverride && titleOverride.trim()) setMovieTitle(titleOverride.trim());
1450
1554
  job.run(built.body);
1451
1555
  },
1452
- [buildBody, job, movieTitle],
1556
+ [buildBody, job, movieTitle, setMovieTitle, warnUnnamed],
1453
1557
  );
1454
1558
 
1559
+ // SESSION LIBRARY PUSH (operator 2026-08-13): a finished movie's outputs
1560
+ // land in the Session Library IMMEDIATELY — every segment clip plus the
1561
+ // assembled movie (outputs[-1] when assembly produced one) — tagged
1562
+ // generate_movie so the sidebar's Videos section lists them newest-first.
1563
+ // addToLibrary de-dups by uri, so re-renders of the same result are no-ops.
1564
+ useEffect(() => {
1565
+ const r = job.result;
1566
+ if (!r || r.ok === false || !r.outputs || r.outputs.length === 0) return;
1567
+ for (const ref of r.outputs) {
1568
+ if (!ref?.uri || !ref?.asset_id) continue;
1569
+ addToLibrary(ref, "cinema", movieTitle.trim() || "movie", {
1570
+ genKind: "generate_movie",
1571
+ prompt: goals[0]?.prompt,
1572
+ groupId: job.jobId ?? undefined,
1573
+ });
1574
+ }
1575
+ // movieTitle/goals deliberately excluded: push once per RESULT, labelled
1576
+ // with whatever the fields held when it landed.
1577
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1578
+ }, [job.result, job.jobId]);
1579
+
1455
1580
  // ── derive playback refs from the terminal result ──
1456
1581
  // Outputs = [segment clips…, movie.mp4 LAST] (the runner appends the assembled
1457
1582
  // movie only when assembly produced one). Pair each segment ref with its manifest
@@ -1846,226 +1971,85 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1846
1971
  if (e.target.value.trim() !== "") setNameWarn(false);
1847
1972
  }}
1848
1973
  />
1974
+ <WarnUnnamedToggle checked={warnUnnamed} onChange={setWarnUnnamed} />
1849
1975
  </section>
1850
1976
 
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
- }}
1977
+ {/* ── MOVIE SETTINGS, IN-COLUMN (operator 2026-08-13: "not seeing model,
1978
+ fps, dimension inputs for cinema") — the geometry/model/sampler knobs
1979
+ live in the left-sidebar Settings tab via portal, which is easy to
1980
+ miss (and absent when no host mounts). This expander mirrors the
1981
+ SAME state — two views, one truth, like the Clip card. ── */}
1982
+ <PromptCardSettings label="movie settings — model · size · fps · steps · cfg · budget">
1983
+ <div className="vi-knob vi-studio-knob-wide" style={{ minWidth: "16rem" }}>
1984
+ <label htmlFor="vi-moviecol-model">model — whole movie</label>
1985
+ <select
1986
+ id="vi-moviecol-model"
1987
+ className="vi-knob-select"
1988
+ value={modelId}
1989
+ disabled={disableSubmit}
1990
+ onChange={(e) => setModelId(e.target.value)}
1907
1991
  >
1908
- ✦ Fresh prompt
1909
- </button>
1992
+ <option value="">Auto — router picks by capability/budget</option>
1993
+ {movieModels.length > 0 && (
1994
+ <optgroup label="Bound by a ratified movie segment">
1995
+ {movieModels.map((id) => (
1996
+ <option key={id} value={id}>{id}</option>
1997
+ ))}
1998
+ </optgroup>
1999
+ )}
2000
+ {(showAllModels || !renderLoaded || (modelId !== "" && !movieModels.includes(modelId))) && (
2001
+ <optgroup label={renderLoaded ? "Other studio models — unverified for a movie segment" : "Studio models"}>
2002
+ {STUDIO_MODEL_IDS.filter((id) => !movieModels.includes(id)).map((id) => (
2003
+ <option key={id} value={id}>{id}{renderLoaded ? " · unverified" : ""}</option>
2004
+ ))}
2005
+ </optgroup>
2006
+ )}
2007
+ </select>
1910
2008
  </div>
2009
+ <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2010
+ <label htmlFor="vi-moviecol-width">width</label>
2011
+ <input id="vi-moviecol-width" className="vi-knob-input" type="number" min={64} step={8}
2012
+ value={width} onChange={(e) => setWidth(Number(e.target.value) || 0)} />
2013
+ </div>
2014
+ <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2015
+ <label htmlFor="vi-moviecol-height">height</label>
2016
+ <input id="vi-moviecol-height" className="vi-knob-input" type="number" min={64} step={8}
2017
+ value={height} onChange={(e) => setHeight(Number(e.target.value) || 0)} />
2018
+ </div>
2019
+ <div className="vi-knob" style={{ maxWidth: "6rem" }}>
2020
+ <label htmlFor="vi-moviecol-fps">fps</label>
2021
+ <input id="vi-moviecol-fps" className="vi-knob-input" type="number" min={1}
2022
+ value={fps} onChange={(e) => setFps(Number(e.target.value) || 0)} />
2023
+ </div>
2024
+ <div className="vi-knob" style={{ maxWidth: "12rem" }}>
2025
+ <label htmlFor="vi-moviecol-frames">length — frames / segment</label>
2026
+ <input id="vi-moviecol-frames" className="vi-knob-input" type="number" min={1}
2027
+ value={framesDefault} placeholder="model default (81)"
2028
+ onChange={(e) => setFramesDefault(e.target.value)} />
2029
+ <span className="vi-knob-hint">
2030
+ {framesDefault.trim() !== "" && Number(framesDefault) > 0 && fps > 0
2031
+ ? `every segment \u2248 ${(Number(framesDefault) / fps).toFixed(1)}s @ ${fps}fps (a segment\u2019s own length overrides)`
2032
+ : `blank = model default \u2014 81f \u2248 ${fps > 0 ? (81 / fps).toFixed(1) : "?"}s @ ${fps}fps per segment`}
2033
+ </span>
2034
+ </div>
2035
+ <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2036
+ <label htmlFor="vi-moviecol-steps">steps</label>
2037
+ <input id="vi-moviecol-steps" className="vi-knob-input" type="number" min={1} max={100}
2038
+ value={steps} placeholder="model default" onChange={(e) => setSteps(e.target.value)} />
2039
+ </div>
2040
+ <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2041
+ <label htmlFor="vi-moviecol-cfg">cfg</label>
2042
+ <input id="vi-moviecol-cfg" className="vi-knob-input" type="number" min={0} max={20} step={0.5}
2043
+ value={cfg} placeholder="model default" onChange={(e) => setCfg(e.target.value)} />
2044
+ </div>
2045
+ <div className="vi-knob" style={{ maxWidth: "9rem" }}>
2046
+ <label htmlFor="vi-moviecol-vram">VRAM budget GB</label>
2047
+ <input id="vi-moviecol-vram" className="vi-knob-input" type="number" min={1}
2048
+ value={vramBudget} placeholder="autofit" onChange={(e) => setVramBudget(e.target.value)} />
2049
+ </div>
2050
+ </PromptCardSettings>
1911
2051
 
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
2052
 
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
2053
 
2070
2054
  {/* ── the goal timeline (NLE-row order, top → bottom) ──
2071
2055
  Operator addendum 2026-07-12: each segment is now a DISTINCTLY CONTRASTED
@@ -2356,6 +2340,241 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2356
2340
  />
2357
2341
  </div>
2358
2342
 
2343
+ {/* SEGMENT-1 INPUTS, IN-CARD (operator 2026-08-13): the movie-start
2344
+ still and the identity reference moved INTO the prompt component —
2345
+ the same per-part attach idiom Scene uses — decluttering the column.
2346
+ Wiring is byte-identical to the old standalone sections. */}
2347
+ {i === 0 && (
2348
+ <>
2349
+ {/* ── Movie start switcher (operator ask 2026-07-12, verbatim): "It needs to be
2350
+ choosable by the user: any library clip, image, or scene to start — or
2351
+ start fresh with a prompt to generate a clip. This static starting clip is
2352
+ not going to work if it cannot be overridden." Same visual row as the Clip
2353
+ surface's subject-clip switcher (.vi-studio-src-actions) — four honest ways
2354
+ to set (or clear) segment 1's start, none of them a hidden default. ── */}
2355
+ <section aria-label="Movie start" style={{ marginBottom: "0.6rem" }}>
2356
+ <p
2357
+ className="vi-comfy-label"
2358
+ style={{ marginBottom: "0.25rem", opacity: 0.85, cursor: "help" }}
2359
+ 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."
2360
+ >
2361
+ Movie start — segment 1 (optional) ⓘ
2362
+ </p>
2363
+ <div className="vi-studio-src-actions" role="group" aria-label="Choose how the movie starts">
2364
+ <button
2365
+ type="button"
2366
+ className="vi-btn vi-btn-sm"
2367
+ disabled={disableSubmit || startBusy}
2368
+ aria-expanded={showStartLibPicker}
2369
+ onClick={() => setShowStartLibPicker((v) => !v)}
2370
+ >
2371
+ {showStartLibPicker ? "Hide library" : `🖼 From library (${startLibraryImages.length})`}
2372
+ </button>
2373
+ {/* CLIP-AS-START FINDING (stated in the file header above): the movie schema
2374
+ has no `start_clip` field, and there is no synchronous "give me this
2375
+ clip's last frame" endpoint — frame_extract is its own async job (enqueue
2376
+ → poll → pick a frame), not a small addition here. Shown so the operator
2377
+ SEES the option exists, disabled so nothing dishonest is implied. */}
2378
+ <button
2379
+ type="button"
2380
+ className="vi-btn vi-btn-sm vi-btn-ghost"
2381
+ disabled
2382
+ 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."
2383
+ >
2384
+ 🎬 From library (clip → last frame)
2385
+ </button>
2386
+ <label className="vi-btn vi-btn-sm vi-file-label">
2387
+ {startBusy ? "Working…" : "⇪ Upload still"}
2388
+ <input
2389
+ type="file"
2390
+ accept="image/*"
2391
+ className="vi-file-input"
2392
+ disabled={disableSubmit || startBusy}
2393
+ onChange={(e) => {
2394
+ const f = e.target.files?.[0];
2395
+ if (f) void onPickStartImage(f);
2396
+ e.target.value = "";
2397
+ }}
2398
+ />
2399
+ </label>
2400
+ <button
2401
+ type="button"
2402
+ className="vi-btn vi-btn-sm vi-btn-ghost"
2403
+ disabled={disableSubmit || startBusy}
2404
+ title="Clear the start still — segment 1 opens from its own prompt instead (text-to-video)."
2405
+ onClick={() => {
2406
+ setStartImage(null);
2407
+ setShowStartLibPicker(false);
2408
+ }}
2409
+ >
2410
+ ✦ Fresh prompt
2411
+ </button>
2412
+ </div>
2413
+
2414
+ {/* Current start state — ALWAYS visible, so the picked (or default) start is
2415
+ never implicit (the operator's exact complaint about the old static slot). */}
2416
+ {startImage ? (
2417
+ <div
2418
+ style={{ display: "flex", alignItems: "center", gap: "0.6rem", flexWrap: "wrap", marginTop: "0.5rem" }}
2419
+ >
2420
+ <img
2421
+ src={mediaBytesUrl(startImage.uri)}
2422
+ alt="segment 1 start image"
2423
+ style={{
2424
+ width: "5rem",
2425
+ height: "5rem",
2426
+ objectFit: "cover",
2427
+ borderRadius: "0.4rem",
2428
+ background: "#000",
2429
+ border: "1px solid var(--vi-border)",
2430
+ }}
2431
+ />
2432
+ <span className="vi-comfy-hint" role="note" style={{ margin: 0 }}>
2433
+ Segment 1 renders image-to-video from this still.
2434
+ </span>
2435
+ <button
2436
+ type="button"
2437
+ className="vi-btn vi-btn-sm vi-btn-ghost"
2438
+ disabled={disableSubmit || startBusy}
2439
+ onClick={() => setStartImage(null)}
2440
+ >
2441
+ ✕ Remove
2442
+ </button>
2443
+ </div>
2444
+ ) : (
2445
+ <p className="vi-comfy-hint" role="note" style={{ marginTop: "0.5rem" }}>
2446
+ No start still — text-to-video opening.
2447
+ </p>
2448
+ )}
2449
+
2450
+ {startError && (
2451
+ <p className="vi-error" role="alert" style={{ marginTop: "0.3rem" }}>
2452
+ {startError}
2453
+ </p>
2454
+ )}
2455
+
2456
+ {showStartLibPicker && (
2457
+ <LibraryImageGrid
2458
+ images={startLibraryImages}
2459
+ onPick={(ref) => {
2460
+ setStartImage(ref);
2461
+ setShowStartLibPicker(false);
2462
+ }}
2463
+ />
2464
+ )}
2465
+ </section>
2466
+ {/* ── IDENTITY reference(s): the locked subject carried across scene changes ── */}
2467
+ <section aria-label="Identity reference" style={{ marginBottom: "0.6rem" }}>
2468
+ <p
2469
+ className="vi-comfy-label"
2470
+ style={{ marginBottom: "0.25rem", opacity: 0.85, cursor: "help" }}
2471
+ 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."
2472
+ >
2473
+ Identity reference — carry a subject across scenes (optional) ⓘ
2474
+ </p>
2475
+ {/* The reference thumbnails + upload AND the identity-profile affordances read as
2476
+ ONE input block — matching how the Clip Generate tab embeds the same
2477
+ <IdentityProfileControls> inline, directly beneath its references
2478
+ (StudioGenerateTab.renderReferenceSlots), rather than letting it stand off as a
2479
+ separate island row. Presentation/placement only: the props + wiring below are
2480
+ byte-identical to before. */}
2481
+ {/* ONE ROW (operator 2026-08-13): thumbnails, upload AND the profile
2482
+ controls share a single wrapping flex row — no stacked button rows. */}
2483
+ <div style={{ display: "flex", alignItems: "center", gap: "0.6rem", flexWrap: "wrap" }}>
2484
+ <div style={{ display: "contents" }}>
2485
+ {references.map((r, i) => (
2486
+ <span key={r.uri} style={{ position: "relative", display: "inline-flex" }}>
2487
+ <img
2488
+ src={mediaBytesUrl(r.uri)}
2489
+ alt={`identity reference ${i + 1}`}
2490
+ style={{
2491
+ width: "4rem",
2492
+ height: "4rem",
2493
+ objectFit: "cover",
2494
+ borderRadius: "0.4rem",
2495
+ background: "#000",
2496
+ border: "1px solid var(--vi-border)",
2497
+ }}
2498
+ />
2499
+ <button
2500
+ type="button"
2501
+ className="vi-btn vi-btn-sm vi-btn-ghost"
2502
+ disabled={disableSubmit || refBusy}
2503
+ title="Remove this reference"
2504
+ onClick={() => {
2505
+ setReferences((prev) => prev.filter((x) => x.uri !== r.uri));
2506
+ setSelectedProfile(null); // editing the set = an UNSAVED identity
2507
+ }}
2508
+ style={{ position: "absolute", top: -6, right: -6, padding: "0 0.3rem", lineHeight: 1.2 }}
2509
+ >
2510
+ ✕
2511
+ </button>
2512
+ </span>
2513
+ ))}
2514
+ {references.length < REF_MAX && (
2515
+ <label className="vi-btn vi-btn-sm vi-btn-ghost vi-file-label">
2516
+ {refBusy ? "Working…" : references.length ? "+ Add reference" : "Upload a reference"}
2517
+ <input
2518
+ type="file"
2519
+ accept="image/*"
2520
+ className="vi-file-input"
2521
+ disabled={disableSubmit || refBusy}
2522
+ onChange={(e) => {
2523
+ const f = e.target.files?.[0];
2524
+ if (f) void onPickReferenceImage(f);
2525
+ e.target.value = "";
2526
+ }}
2527
+ />
2528
+ </label>
2529
+ )}
2530
+ </div>
2531
+ {/* IDENTITY PROFILES (stage a): pick a saved identity profile or save the current
2532
+ references — the durable form of this identity. One additive row, embedded
2533
+ inline within the reference input block (matching the Clip Generate tab). */}
2534
+ <IdentityProfileControls
2535
+ refCount={references.length}
2536
+ currentRefUris={references.map((r) => r.uri)}
2537
+ selectedProfile={selectedProfile}
2538
+ onAttach={attachIdentityRefs}
2539
+ onSelectProfile={setSelectedProfile}
2540
+ maxRefs={REF_MAX}
2541
+ disabled={disableSubmit || refBusy}
2542
+ />
2543
+ </div>
2544
+ {/* RESOLUTION FOOTGUN (operator 2026-07-12): the only budget-fitting id-capable
2545
+ model (wan2.1-vace-1.3b) tops out at 832×480 — an identity movie above that fails
2546
+ no_capable_model. Warn LOUDLY with a one-click snap; never BLOCK (the backend's
2547
+ rejection is honest if the operator insists on a bigger box). */}
2548
+ {(references.length > 0 || selectedProfile != null) && (width > 832 || height > 480) && (
2549
+ <p className="vi-input-callout" role="note" style={{ marginTop: "0.4rem" }}>
2550
+ id-capable models on this fleet top out at 832×480 — this movie asks for {width}×{height}.{" "}
2551
+ <button
2552
+ type="button"
2553
+ className="vi-btn vi-btn-sm vi-btn-accent"
2554
+ onClick={() => {
2555
+ setWidth(480);
2556
+ setHeight(480);
2557
+ }}
2558
+ style={{ marginLeft: "0.3rem" }}
2559
+ >
2560
+ Set 480×480
2561
+ </button>
2562
+ </p>
2563
+ )}
2564
+ {references.length > 0 && (
2565
+ <span className="vi-comfy-hint" role="note" style={{ display: "block", marginTop: "0.3rem" }}
2566
+ title="Every scene renders id_lock (Wan-VACE) so this subject carries across every cut. Needs a VACE-capable GPU worker.">
2567
+ Identity movie — id_lock every scene · {references.length}/{REF_MAX} reference(s)
2568
+ </span>
2569
+ )}
2570
+ {refError && (
2571
+ <p className="vi-error" role="alert" style={{ marginTop: "0.3rem" }}>
2572
+ {refError}
2573
+ </p>
2574
+ )}
2575
+ </section>
2576
+ </>
2577
+ )}
2359
2578
  {/* k88 — the shared settings expander (collapsed by default); the joint
2360
2579
  DESCRIPTOR stays visible in the card header above.
2361
2580
  2026-08-12: grew from seed-only to the FULL per-segment knob set the
@@ -2367,8 +2586,8 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2367
2586
  <PromptCardSettings
2368
2587
  label={
2369
2588
  i === 0
2370
- ? "settings — seed · model · steps · cfg"
2371
- : "settings — seed · model · steps · cfg · joint · branch"
2589
+ ? "settings — seed · model · steps · cfg · length"
2590
+ : "settings — seed · model · steps · cfg · length · joint · branch"
2372
2591
  }
2373
2592
  >
2374
2593
  <div className="vi-knob" style={{ maxWidth: "10rem" }}>
@@ -2448,6 +2667,58 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2448
2667
  />
2449
2668
  <span className="vi-knob-hint">0–20 · blank = movie/model default</span>
2450
2669
  </div>
2670
+ <div className="vi-knob" style={{ maxWidth: "11rem" }}>
2671
+ <label htmlFor={`vi-movie-goalframes-${g.key}`}>length — frames</label>
2672
+ <input
2673
+ id={`vi-movie-goalframes-${g.key}`}
2674
+ className="vi-knob-input"
2675
+ type="number"
2676
+ min={1}
2677
+ value={g.frames}
2678
+ placeholder={framesDefault.trim() || "model default"}
2679
+ onChange={(e) => setGoalField(i, { frames: e.target.value })}
2680
+ />
2681
+ <span className="vi-knob-hint">
2682
+ {g.frames.trim() !== "" && Number(g.frames) > 0 && fps > 0
2683
+ ? `\u2248 ${(Number(g.frames) / fps).toFixed(1)}s @ ${fps}fps`
2684
+ : "blank = movie default \u00b7 clamped to the model ceiling, snapped to 4k+1"}
2685
+ </span>
2686
+ {Number(g.frames.trim() || framesDefault.trim()) > MODEL_FRAME_CEILING && (
2687
+ <button
2688
+ type="button"
2689
+ className="vi-btn vi-btn-sm"
2690
+ 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."
2691
+ onClick={() => splitGoalToFit(i)}
2692
+ >
2693
+ ✂ split into {Math.ceil(Number(g.frames.trim() || framesDefault.trim()) / MODEL_FRAME_CEILING)} segments to fit
2694
+ </button>
2695
+ )}
2696
+ </div>
2697
+ {/* UNIFORM-CARD PLACEHOLDERS (operator 2026-08-13): geometry and
2698
+ budget are MOVIE-level by server contract (one StudioMovieSpec
2699
+ geometry per movie) — shown greyed with the reason, never
2700
+ hidden, so every prompt card reads identically. */}
2701
+ <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2702
+ <label htmlFor={`vi-movie-goalw-${g.key}`}>width</label>
2703
+ <input id={`vi-movie-goalw-${g.key}`} className="vi-knob-input" disabled value={width}
2704
+ title="Movie-level — one geometry per movie (StudioMovieSpec); change it in movie settings above." />
2705
+ </div>
2706
+ <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2707
+ <label htmlFor={`vi-movie-goalh-${g.key}`}>height</label>
2708
+ <input id={`vi-movie-goalh-${g.key}`} className="vi-knob-input" disabled value={height}
2709
+ title="Movie-level — one geometry per movie (StudioMovieSpec); change it in movie settings above." />
2710
+ </div>
2711
+ <div className="vi-knob" style={{ maxWidth: "6rem" }}>
2712
+ <label htmlFor={`vi-movie-goalfps-${g.key}`}>fps</label>
2713
+ <input id={`vi-movie-goalfps-${g.key}`} className="vi-knob-input" disabled value={fps}
2714
+ title="Movie-level — one fps per movie; change it in movie settings above." />
2715
+ </div>
2716
+ <div className="vi-knob" style={{ maxWidth: "9rem" }}>
2717
+ <label htmlFor={`vi-movie-goalvram-${g.key}`}>VRAM budget GB</label>
2718
+ <input id={`vi-movie-goalvram-${g.key}`} className="vi-knob-input" disabled
2719
+ value={vramBudget.trim() || "autofit"}
2720
+ title="Movie-level — set in movie settings above (blank = autofit to the serving worker's free VRAM)." />
2721
+ </div>
2451
2722
  {i > 0 && (
2452
2723
  <>
2453
2724
  <div className="vi-knob" style={{ maxWidth: "13rem" }}>
@@ -2528,52 +2799,6 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2528
2799
  </div>
2529
2800
  </section>
2530
2801
 
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
2802
 
2578
2803
  {/* ── SYNTHETIC-TIER HONESTY BANNER — loud, right above Generate movie, so the
2579
2804
  operator sees it at the moment it matters instead of the knob-grid
@@ -2620,31 +2845,14 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2620
2845
  {/* ── UNNAMED-MOVIE NUDGE — a warning with a way through, never a gate. Sits
2621
2846
  right above Generate because that is where the decision is made. ── */}
2622
2847
  {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",
2848
+ <UnnamedWarnPanel
2849
+ noun="movie"
2850
+ onProceed={(name) => {
2851
+ setNameWarn(false);
2852
+ onGenerate(true, name);
2632
2853
  }}
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>
2854
+ onDismiss={() => setNameWarn(false)}
2855
+ />
2648
2856
  )}
2649
2857
 
2650
2858
  {/* ── run row ── */}