@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.
- package/package.json +1 -1
- package/src/stations/GenerateStation.tsx +146 -14
- package/src/stations/WorkbenchSidebar.tsx +57 -12
- package/src/stations/studio/StudioGenerateTab.tsx +169 -6
- package/src/stations/studio/StudioMovieComposer.tsx +507 -299
- package/src/stations/studio/{StudioMovieComposer.tsx.bak-sessions-20260812 → StudioMovieComposer.tsx.bak-declutter-20260813} +350 -48
- package/src/style/app.css +19 -0
- package/src/video/LibraryGroups.tsx +81 -0
- package/src/video/UnnamedWarn.tsx +146 -0
- package/src/video/useOperatorHistory.ts +100 -0
- package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +0 -228
- package/src/stations/GenerateStation.tsx.bak-tester-removal +0 -5170
- package/src/stations/studio/StudioMovieSessions.tsx.bak-sessions-20260812 +0 -417
|
@@ -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:
|
|
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
|
|
1141
|
-
|
|
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
|
|
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
|
-
{/* ──
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
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
|
-
|
|
1909
|
-
|
|
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'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
|
-
<
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
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
|
-
|
|
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 ── */}
|