@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";
|
|
@@ -131,7 +132,9 @@ import { useIdentityProfiles } from "./useIdentityProfiles";
|
|
|
131
132
|
// CINEMA SESSIONS (k91): the durable movie sessions on the studio-movies root (resume /
|
|
132
133
|
// pause / watch what rendered), plus the shared live-pace line this composer reuses for
|
|
133
134
|
// its OWN in-flight render. Both live next door so there is one renderer for each.
|
|
134
|
-
|
|
135
|
+
// Cinema sessions moved to the Active display's Sessions panel (operator ask
|
|
136
|
+
// 2026-08-12) — this file keeps only the live-pace renderer for its own render.
|
|
137
|
+
import { MovieLiveProgress } from "./StudioMovieSessions";
|
|
135
138
|
|
|
136
139
|
// B3: the movie endpoint is now a promoted config literal (mirrors studioI2VEnqueueUrl),
|
|
137
140
|
// replacing the earlier inline `${hugpyConfig.apiBase}/video/studio/movie` derivation.
|
|
@@ -283,6 +286,18 @@ interface GoalRow {
|
|
|
283
286
|
seed: string;
|
|
284
287
|
branchFrame: string;
|
|
285
288
|
joint: JointMode;
|
|
289
|
+
// FULL PER-SEGMENT KNOBS (operator ask 2026-08-12): the cinema route has
|
|
290
|
+
// accepted per-goal model_id/steps/cfg/context_frames overrides all along
|
|
291
|
+
// (StudioMovieGoal — "honored when set, not dead fields"); the editor just
|
|
292
|
+
// never offered them. Same string idiom as seed/branchFrame: blank ⇒ the key
|
|
293
|
+
// is dropped from the goal body and the segment inherits the movie-level
|
|
294
|
+
// value (or the router/model default).
|
|
295
|
+
modelId: string;
|
|
296
|
+
steps: string;
|
|
297
|
+
cfg: string;
|
|
298
|
+
contextFrames: string;
|
|
299
|
+
// per-segment CLIP LENGTH in frames (2026-08-13); blank = movie default.
|
|
300
|
+
frames: string;
|
|
286
301
|
}
|
|
287
302
|
|
|
288
303
|
// The counter is SESSION-persisted (not a module `let`) now that the goal rows
|
|
@@ -297,10 +312,15 @@ function blankGoal(joint: JointMode = "still"): GoalRow {
|
|
|
297
312
|
key: newGoalKey(),
|
|
298
313
|
prompt: "",
|
|
299
314
|
negative: "",
|
|
300
|
-
stdNegative:
|
|
315
|
+
stdNegative: true,
|
|
301
316
|
seed: "",
|
|
302
317
|
branchFrame: "",
|
|
303
318
|
joint,
|
|
319
|
+
modelId: "",
|
|
320
|
+
steps: "",
|
|
321
|
+
cfg: "",
|
|
322
|
+
contextFrames: "",
|
|
323
|
+
frames: "",
|
|
304
324
|
};
|
|
305
325
|
}
|
|
306
326
|
|
|
@@ -571,15 +591,35 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
571
591
|
// in-flight job state stay on plain useState on purpose.
|
|
572
592
|
//
|
|
573
593
|
// Goal timeline — at least one row; row 0 is the root (t2v).
|
|
574
|
-
const [
|
|
594
|
+
const [goalsRaw, setGoals] = useSessionState<GoalRow[]>("studio.movie.goals", () => [
|
|
575
595
|
blankGoal(),
|
|
576
596
|
blankGoal(),
|
|
577
597
|
]);
|
|
598
|
+
// Rows restored from a session written BEFORE the per-segment knob round
|
|
599
|
+
// (2026-08-12) lack modelId/steps/cfg/contextFrames — normalize to the blank
|
|
600
|
+
// ("inherit") value so `.trim()` never meets undefined. useMemo, not a write:
|
|
601
|
+
// the stored rows heal on the next real edit.
|
|
602
|
+
const goals = useMemo(
|
|
603
|
+
() =>
|
|
604
|
+
goalsRaw.map((g) => ({
|
|
605
|
+
modelId: "",
|
|
606
|
+
steps: "",
|
|
607
|
+
cfg: "",
|
|
608
|
+
contextFrames: "",
|
|
609
|
+
frames: "",
|
|
610
|
+
...g,
|
|
611
|
+
})),
|
|
612
|
+
[goalsRaw],
|
|
613
|
+
);
|
|
578
614
|
|
|
579
615
|
// Movie-level knobs (mirror the clip surface's defaults + idiom where reusable).
|
|
580
616
|
// Default 480×480: the wan2.1-vace-1.3b IDENTITY sweet spot — the only budget-fitting
|
|
581
617
|
// id-capable model on this fleet tops out at 832×480, so a 512×512 default cannot serve
|
|
582
618
|
// id_lock on it (no_capable_model). 832×480 widescreen is the other valid id envelope.
|
|
619
|
+
// EXPLICIT SEGMENT LENGTH (operator 2026-08-13): movie-level default frames
|
|
620
|
+
// per segment; a segment's own frames (below) overrides. Blank = each bound
|
|
621
|
+
// model's default (81 real). Clamped + 4k+1-snapped by the spine.
|
|
622
|
+
const [framesDefault, setFramesDefault] = useSessionState("studio.movie.frames", "");
|
|
583
623
|
const [width, setWidth] = useSessionState("studio.movie.width", 480);
|
|
584
624
|
const [height, setHeight] = useSessionState("studio.movie.height", 480);
|
|
585
625
|
const [fps, setFps] = useSessionState("studio.movie.fps", 24);
|
|
@@ -1102,18 +1142,54 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
1102
1142
|
// The row's RESOLVED operation only chooses which mode the primary button invokes;
|
|
1103
1143
|
// both explicit buttons stay live underneath, so a router mistake costs one click,
|
|
1104
1144
|
// never a paragraph of the user's work.
|
|
1145
|
+
// The last AUTO-derived movie title. A name the operator typed is sacred; a
|
|
1146
|
+
// name this ref minted may be replaced by the next Generate (operator
|
|
1147
|
+
// 2026-08-13: "purely a product of the same generation method").
|
|
1148
|
+
const autoTitleRef = useRef("");
|
|
1149
|
+
|
|
1105
1150
|
const runRowAssist = useCallback(
|
|
1106
1151
|
(i: number, mode: "detail" | "generate") => {
|
|
1107
1152
|
const g = goals[i];
|
|
1108
1153
|
if (!g) return;
|
|
1109
|
-
void
|
|
1110
|
-
|
|
1111
|
-
|
|
1154
|
+
void (async () => {
|
|
1155
|
+
let fresh = "";
|
|
1156
|
+
await assist.runAssist(mode, g.prompt, (p) => {
|
|
1157
|
+
fresh = p;
|
|
1158
|
+
setGoalField(i, { prompt: p });
|
|
1159
|
+
}, { context: rowContext(i) });
|
|
1160
|
+
if (mode !== "generate" || !fresh.trim()) return;
|
|
1161
|
+
// NEGATIVE RIDES ALONG (operator 2026-08-13): the generated exclusion
|
|
1162
|
+
// list APPENDS to this goal's typed negative, never clobbers it
|
|
1163
|
+
// (composeNegative dedupes the std terms at payload time).
|
|
1164
|
+
await assist.runNegative(
|
|
1165
|
+
{ subject: fresh, extra: { context: rowContext(i) } },
|
|
1166
|
+
(neg) => {
|
|
1167
|
+
const cur = (g.negative ?? "").trim().replace(/,\s*$/, "");
|
|
1168
|
+
setGoalField(i, { negative: cur ? `${cur}, ${neg}` : neg });
|
|
1169
|
+
},
|
|
1170
|
+
);
|
|
1171
|
+
// NAME RIDES ALONG: derive a title from the generated prompt when the
|
|
1172
|
+
// movie has none — or when the current one was itself auto-derived.
|
|
1173
|
+
const t = movieTitle.trim();
|
|
1174
|
+
if (t === "" || t === autoTitleRef.current) {
|
|
1175
|
+
const derived = fresh
|
|
1176
|
+
.split(/[.,;\n]/)[0]
|
|
1177
|
+
.trim()
|
|
1178
|
+
.split(/\s+/)
|
|
1179
|
+
.slice(0, 7)
|
|
1180
|
+
.join(" ")
|
|
1181
|
+
.slice(0, 48);
|
|
1182
|
+
if (derived) {
|
|
1183
|
+
autoTitleRef.current = derived;
|
|
1184
|
+
setMovieTitle(derived);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
})();
|
|
1112
1188
|
// The text is about to change, so the old classification no longer describes it.
|
|
1113
1189
|
intentText.current[g.key] = "";
|
|
1114
1190
|
setIntents((prev) => ({ ...prev, [g.key]: null }));
|
|
1115
1191
|
},
|
|
1116
|
-
[assist, goals, rowContext, setGoalField],
|
|
1192
|
+
[assist, goals, rowContext, setGoalField, movieTitle, setMovieTitle],
|
|
1117
1193
|
);
|
|
1118
1194
|
|
|
1119
1195
|
// ── negative generation (§1b) — an exclusion list for THIS shot ──
|
|
@@ -1269,7 +1345,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
1269
1345
|
}, [pendingFocusKey, goals]);
|
|
1270
1346
|
|
|
1271
1347
|
// ── build the exact request body the route reads ──
|
|
1272
|
-
const buildBody = useCallback((): { body: Record<string, unknown> } | { err: string } => {
|
|
1348
|
+
const buildBody = useCallback((titleOverride?: string): { body: Record<string, unknown> } | { err: string } => {
|
|
1273
1349
|
if (goals.length < 1) return { err: "Add at least one goal (the root segment)." };
|
|
1274
1350
|
for (let i = 0; i < goals.length; i++) {
|
|
1275
1351
|
if (goals[i].prompt.trim() === "") {
|
|
@@ -1279,6 +1355,24 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
1279
1355
|
if (s !== "" && !Number.isInteger(Number(s))) {
|
|
1280
1356
|
return { err: `Goal ${i + 1} seed must be a whole number (or blank).` };
|
|
1281
1357
|
}
|
|
1358
|
+
// Per-segment sampler overrides — the SAME bounds as the movie-level knobs
|
|
1359
|
+
// below (and the schema's _check_steps_cfg): blank = inherit.
|
|
1360
|
+
const gSteps = goals[i].steps.trim();
|
|
1361
|
+
if (gSteps !== "" && (!Number.isInteger(Number(gSteps)) || Number(gSteps) < 1 || Number(gSteps) > 100)) {
|
|
1362
|
+
return { err: `Goal ${i + 1} steps must be a whole number in [1, 100] (or blank to inherit).` };
|
|
1363
|
+
}
|
|
1364
|
+
const gCfg = goals[i].cfg.trim();
|
|
1365
|
+
if (gCfg !== "" && (Number.isNaN(Number(gCfg)) || Number(gCfg) < 0 || Number(gCfg) > 20)) {
|
|
1366
|
+
return { err: `Goal ${i + 1} CFG must be a number in [0, 20] (or blank to inherit).` };
|
|
1367
|
+
}
|
|
1368
|
+
const gFrames = goals[i].frames.trim();
|
|
1369
|
+
if (gFrames !== "" && (!Number.isInteger(Number(gFrames)) || Number(gFrames) < 1)) {
|
|
1370
|
+
return { err: `Goal ${i + 1} length must be a whole number of frames \u2265 1 (or blank to inherit).` };
|
|
1371
|
+
}
|
|
1372
|
+
const gCtx = goals[i].contextFrames.trim();
|
|
1373
|
+
if (gCtx !== "" && (!Number.isInteger(Number(gCtx)) || Number(gCtx) < 1)) {
|
|
1374
|
+
return { err: `Goal ${i + 1} context frames must be a whole number ≥ 1 (or blank to inherit).` };
|
|
1375
|
+
}
|
|
1282
1376
|
// A "cut" carries no frame, so its branch-frame input is disabled + ignored; only
|
|
1283
1377
|
// validate a branch frame for a still / vace_extend joint.
|
|
1284
1378
|
if (i > 0 && goals[i].joint !== "cut") {
|
|
@@ -1302,6 +1396,10 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
1302
1396
|
// Movie-level sampler overrides — same bounds the clip surface (and the movie
|
|
1303
1397
|
// schema's _check_steps_cfg) enforce: steps int [1,100], cfg number [0,20]. Blank
|
|
1304
1398
|
// drops the key (model default).
|
|
1399
|
+
const framesDefNum = framesDefault.trim() === "" ? undefined : Number(framesDefault);
|
|
1400
|
+
if (framesDefNum !== undefined && (!Number.isInteger(framesDefNum) || framesDefNum < 1)) {
|
|
1401
|
+
return { err: "Segment length must be a whole number of frames \u2265 1 (or blank for model defaults)." };
|
|
1402
|
+
}
|
|
1305
1403
|
const stepsNum = steps.trim() === "" ? undefined : Number(steps);
|
|
1306
1404
|
if (
|
|
1307
1405
|
stepsNum !== undefined &&
|
|
@@ -1325,6 +1423,13 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
1325
1423
|
// empty user negative + a tick still sends the standard exclusions.
|
|
1326
1424
|
const effNegative = composeNegative(g.negative, g.stdNegative);
|
|
1327
1425
|
if (effNegative !== "") node.negative = effNegative;
|
|
1426
|
+
// Per-segment model/sampler overrides (2026-08-12): blank drops the key so
|
|
1427
|
+
// the segment inherits the movie-level pin/override (or the router/model
|
|
1428
|
+
// default) — the exact fallback chain StudioMovieGoal documents.
|
|
1429
|
+
if (g.modelId.trim() !== "") node.model_id = g.modelId.trim();
|
|
1430
|
+
if (g.steps.trim() !== "") node.steps = Number(g.steps);
|
|
1431
|
+
if (g.cfg.trim() !== "") node.cfg = Number(g.cfg);
|
|
1432
|
+
if (g.frames.trim() !== "") node.frames = Number(g.frames);
|
|
1328
1433
|
// joint_mode + branch_frame are meaningless on the root (segment 0 has no parent).
|
|
1329
1434
|
if (i > 0) {
|
|
1330
1435
|
// Send a non-default joint_mode ("vace_extend" | "cut"); "still" is the route default.
|
|
@@ -1334,12 +1439,18 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
1334
1439
|
const bf = g.branchFrame.trim();
|
|
1335
1440
|
if (bf !== "") node.branch_frame = Number(bf);
|
|
1336
1441
|
}
|
|
1442
|
+
// context_frames is only consulted for a vace_extend joint (schema note) —
|
|
1443
|
+
// sending it otherwise would imply a knob that does nothing.
|
|
1444
|
+
if (g.joint === "vace_extend" && g.contextFrames.trim() !== "") {
|
|
1445
|
+
node.context_frames = Number(g.contextFrames);
|
|
1446
|
+
}
|
|
1337
1447
|
}
|
|
1338
1448
|
return node;
|
|
1339
1449
|
});
|
|
1340
1450
|
|
|
1341
1451
|
const body: Record<string, unknown> = {
|
|
1342
1452
|
resolution: { width, height, fps },
|
|
1453
|
+
frames: framesDefNum,
|
|
1343
1454
|
seed,
|
|
1344
1455
|
vram_budget_gb: budgetNum,
|
|
1345
1456
|
// Movie-level model pin + sampler overrides (blank ⇒ key dropped = router/model
|
|
@@ -1353,7 +1464,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
1353
1464
|
// The movie's NAME (k91) — free text, display-only: it never changes what renders,
|
|
1354
1465
|
// it is what the sessions list shows instead of a bare dir id. Blank drops the key
|
|
1355
1466
|
// (the schema takes title=None and the session lists as "untitled").
|
|
1356
|
-
title: movieTitle.trim() ? movieTitle.trim() : undefined,
|
|
1467
|
+
title: (titleOverride ?? movieTitle).trim() ? (titleOverride ?? movieTitle).trim() : undefined,
|
|
1357
1468
|
// Optional root conditioning still — the route jail-resolves this abs uri and
|
|
1358
1469
|
// renders segment 0 i2v (drops back to t2v when absent). Mirrors the clip surface.
|
|
1359
1470
|
start_image: startImage ? startImage.uri : undefined,
|
|
@@ -1373,10 +1484,13 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
1373
1484
|
// name. It is a warning, not a gate: the run row then offers "Generate without a
|
|
1374
1485
|
// name", and `onGenerate(true)` skips the check. Cleared whenever a name is typed.
|
|
1375
1486
|
const [nameWarn, setNameWarn] = useState(false);
|
|
1487
|
+
// The warning is an EXPLICIT PER-SECTION OPTION now (operator 2026-08-13),
|
|
1488
|
+
// DEFAULT OFF, remembered in localStorage — shared UnnamedWarn seam.
|
|
1489
|
+
const [warnUnnamed, setWarnUnnamed] = useWarnUnnamedPref("cinema");
|
|
1376
1490
|
|
|
1377
1491
|
const onGenerate = useCallback(
|
|
1378
|
-
(skipNameCheck = false) => {
|
|
1379
|
-
const built = buildBody();
|
|
1492
|
+
(skipNameCheck = false, titleOverride?: string) => {
|
|
1493
|
+
const built = buildBody(titleOverride);
|
|
1380
1494
|
if ("err" in built) {
|
|
1381
1495
|
setFormMsg(built.err);
|
|
1382
1496
|
return;
|
|
@@ -1384,18 +1498,40 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
1384
1498
|
// Warn ONCE per unnamed submit. An unnamed movie is still fully renderable — the
|
|
1385
1499
|
// cost is later, in a sessions list where it reads as "untitled · 7f3a91c2…" and
|
|
1386
1500
|
// nobody can tell which paused render is which.
|
|
1387
|
-
if (!skipNameCheck && movieTitle.trim() === "") {
|
|
1501
|
+
if (!skipNameCheck && warnUnnamed && movieTitle.trim() === "") {
|
|
1388
1502
|
setNameWarn(true);
|
|
1389
1503
|
setFormMsg(null);
|
|
1390
1504
|
return;
|
|
1391
1505
|
}
|
|
1392
1506
|
setNameWarn(false);
|
|
1393
1507
|
setFormMsg(null);
|
|
1508
|
+
if (titleOverride && titleOverride.trim()) setMovieTitle(titleOverride.trim());
|
|
1394
1509
|
job.run(built.body);
|
|
1395
1510
|
},
|
|
1396
|
-
[buildBody, job, movieTitle],
|
|
1511
|
+
[buildBody, job, movieTitle, setMovieTitle, warnUnnamed],
|
|
1397
1512
|
);
|
|
1398
1513
|
|
|
1514
|
+
// SESSION LIBRARY PUSH (operator 2026-08-13): a finished movie's outputs
|
|
1515
|
+
// land in the Session Library IMMEDIATELY — every segment clip plus the
|
|
1516
|
+
// assembled movie (outputs[-1] when assembly produced one) — tagged
|
|
1517
|
+
// generate_movie so the sidebar's Videos section lists them newest-first.
|
|
1518
|
+
// addToLibrary de-dups by uri, so re-renders of the same result are no-ops.
|
|
1519
|
+
useEffect(() => {
|
|
1520
|
+
const r = job.result;
|
|
1521
|
+
if (!r || r.ok === false || !r.outputs || r.outputs.length === 0) return;
|
|
1522
|
+
for (const ref of r.outputs) {
|
|
1523
|
+
if (!ref?.uri || !ref?.asset_id) continue;
|
|
1524
|
+
addToLibrary(ref, "cinema", movieTitle.trim() || "movie", {
|
|
1525
|
+
genKind: "generate_movie",
|
|
1526
|
+
prompt: goals[0]?.prompt,
|
|
1527
|
+
groupId: job.jobId ?? undefined,
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1530
|
+
// movieTitle/goals deliberately excluded: push once per RESULT, labelled
|
|
1531
|
+
// with whatever the fields held when it landed.
|
|
1532
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1533
|
+
}, [job.result, job.jobId]);
|
|
1534
|
+
|
|
1399
1535
|
// ── derive playback refs from the terminal result ──
|
|
1400
1536
|
// Outputs = [segment clips…, movie.mp4 LAST] (the runner appends the assembled
|
|
1401
1537
|
// movie only when assembly produced one). Pair each segment ref with its manifest
|
|
@@ -1759,12 +1895,10 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
1759
1895
|
{settingsHost ? createPortal(settingsNode, settingsHost) : null}
|
|
1760
1896
|
{/* (The intro prose moved into the sidebar "About Cinema" expander — k89.) */}
|
|
1761
1897
|
|
|
1762
|
-
{/* ── CINEMA SESSIONS (
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
controls is a session nobody finds. Its poll runs only while it is open. ── */}
|
|
1767
|
-
<StudioMovieSessions />
|
|
1898
|
+
{/* ── CINEMA SESSIONS: relocated (2026-08-12). The recovery surface now lives in
|
|
1899
|
+
the Active display's Sessions panel (▤ Active drawer / the /active station),
|
|
1900
|
+
sectioned per surface (Scene · Movie · Clip · Cinema) — reachable from every
|
|
1901
|
+
station instead of only this composer. ── */}
|
|
1768
1902
|
|
|
1769
1903
|
{/* ── MOVIE NAME — what this session will be called in the list above. Kept at the
|
|
1770
1904
|
top of the authoring column (not in the sidebar with `project`) because it is
|
|
@@ -1792,8 +1926,84 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
1792
1926
|
if (e.target.value.trim() !== "") setNameWarn(false);
|
|
1793
1927
|
}}
|
|
1794
1928
|
/>
|
|
1929
|
+
<WarnUnnamedToggle checked={warnUnnamed} onChange={setWarnUnnamed} />
|
|
1795
1930
|
</section>
|
|
1796
1931
|
|
|
1932
|
+
{/* ── MOVIE SETTINGS, IN-COLUMN (operator 2026-08-13: "not seeing model,
|
|
1933
|
+
fps, dimension inputs for cinema") — the geometry/model/sampler knobs
|
|
1934
|
+
live in the left-sidebar Settings tab via portal, which is easy to
|
|
1935
|
+
miss (and absent when no host mounts). This expander mirrors the
|
|
1936
|
+
SAME state — two views, one truth, like the Clip card. ── */}
|
|
1937
|
+
<PromptCardSettings label="movie settings — model · size · fps · steps · cfg · budget">
|
|
1938
|
+
<div className="vi-knob vi-studio-knob-wide" style={{ minWidth: "16rem" }}>
|
|
1939
|
+
<label htmlFor="vi-moviecol-model">model — whole movie</label>
|
|
1940
|
+
<select
|
|
1941
|
+
id="vi-moviecol-model"
|
|
1942
|
+
className="vi-knob-select"
|
|
1943
|
+
value={modelId}
|
|
1944
|
+
disabled={disableSubmit}
|
|
1945
|
+
onChange={(e) => setModelId(e.target.value)}
|
|
1946
|
+
>
|
|
1947
|
+
<option value="">Auto — router picks by capability/budget</option>
|
|
1948
|
+
{movieModels.length > 0 && (
|
|
1949
|
+
<optgroup label="Bound by a ratified movie segment">
|
|
1950
|
+
{movieModels.map((id) => (
|
|
1951
|
+
<option key={id} value={id}>{id}</option>
|
|
1952
|
+
))}
|
|
1953
|
+
</optgroup>
|
|
1954
|
+
)}
|
|
1955
|
+
{(showAllModels || !renderLoaded || (modelId !== "" && !movieModels.includes(modelId))) && (
|
|
1956
|
+
<optgroup label={renderLoaded ? "Other studio models — unverified for a movie segment" : "Studio models"}>
|
|
1957
|
+
{STUDIO_MODEL_IDS.filter((id) => !movieModels.includes(id)).map((id) => (
|
|
1958
|
+
<option key={id} value={id}>{id}{renderLoaded ? " · unverified" : ""}</option>
|
|
1959
|
+
))}
|
|
1960
|
+
</optgroup>
|
|
1961
|
+
)}
|
|
1962
|
+
</select>
|
|
1963
|
+
</div>
|
|
1964
|
+
<div className="vi-knob" style={{ maxWidth: "7rem" }}>
|
|
1965
|
+
<label htmlFor="vi-moviecol-width">width</label>
|
|
1966
|
+
<input id="vi-moviecol-width" className="vi-knob-input" type="number" min={64} step={8}
|
|
1967
|
+
value={width} onChange={(e) => setWidth(Number(e.target.value) || 0)} />
|
|
1968
|
+
</div>
|
|
1969
|
+
<div className="vi-knob" style={{ maxWidth: "7rem" }}>
|
|
1970
|
+
<label htmlFor="vi-moviecol-height">height</label>
|
|
1971
|
+
<input id="vi-moviecol-height" className="vi-knob-input" type="number" min={64} step={8}
|
|
1972
|
+
value={height} onChange={(e) => setHeight(Number(e.target.value) || 0)} />
|
|
1973
|
+
</div>
|
|
1974
|
+
<div className="vi-knob" style={{ maxWidth: "6rem" }}>
|
|
1975
|
+
<label htmlFor="vi-moviecol-fps">fps</label>
|
|
1976
|
+
<input id="vi-moviecol-fps" className="vi-knob-input" type="number" min={1}
|
|
1977
|
+
value={fps} onChange={(e) => setFps(Number(e.target.value) || 0)} />
|
|
1978
|
+
</div>
|
|
1979
|
+
<div className="vi-knob" style={{ maxWidth: "12rem" }}>
|
|
1980
|
+
<label htmlFor="vi-moviecol-frames">length — frames / segment</label>
|
|
1981
|
+
<input id="vi-moviecol-frames" className="vi-knob-input" type="number" min={1}
|
|
1982
|
+
value={framesDefault} placeholder="model default (81)"
|
|
1983
|
+
onChange={(e) => setFramesDefault(e.target.value)} />
|
|
1984
|
+
<span className="vi-knob-hint">
|
|
1985
|
+
{framesDefault.trim() !== "" && Number(framesDefault) > 0 && fps > 0
|
|
1986
|
+
? `every segment \u2248 ${(Number(framesDefault) / fps).toFixed(1)}s @ ${fps}fps (a segment\u2019s own length overrides)`
|
|
1987
|
+
: `blank = model default \u2014 81f \u2248 ${fps > 0 ? (81 / fps).toFixed(1) : "?"}s @ ${fps}fps per segment`}
|
|
1988
|
+
</span>
|
|
1989
|
+
</div>
|
|
1990
|
+
<div className="vi-knob" style={{ maxWidth: "7rem" }}>
|
|
1991
|
+
<label htmlFor="vi-moviecol-steps">steps</label>
|
|
1992
|
+
<input id="vi-moviecol-steps" className="vi-knob-input" type="number" min={1} max={100}
|
|
1993
|
+
value={steps} placeholder="model default" onChange={(e) => setSteps(e.target.value)} />
|
|
1994
|
+
</div>
|
|
1995
|
+
<div className="vi-knob" style={{ maxWidth: "7rem" }}>
|
|
1996
|
+
<label htmlFor="vi-moviecol-cfg">cfg</label>
|
|
1997
|
+
<input id="vi-moviecol-cfg" className="vi-knob-input" type="number" min={0} max={20} step={0.5}
|
|
1998
|
+
value={cfg} placeholder="model default" onChange={(e) => setCfg(e.target.value)} />
|
|
1999
|
+
</div>
|
|
2000
|
+
<div className="vi-knob" style={{ maxWidth: "9rem" }}>
|
|
2001
|
+
<label htmlFor="vi-moviecol-vram">VRAM budget GB</label>
|
|
2002
|
+
<input id="vi-moviecol-vram" className="vi-knob-input" type="number" min={1}
|
|
2003
|
+
value={vramBudget} placeholder="autofit" onChange={(e) => setVramBudget(e.target.value)} />
|
|
2004
|
+
</div>
|
|
2005
|
+
</PromptCardSettings>
|
|
2006
|
+
|
|
1797
2007
|
{/* ── Movie start switcher (operator ask 2026-07-12, verbatim): "It needs to be
|
|
1798
2008
|
choosable by the user: any library clip, image, or scene to start — or
|
|
1799
2009
|
start fresh with a prompt to generate a clip. This static starting clip is
|
|
@@ -2302,11 +2512,20 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
2302
2512
|
/>
|
|
2303
2513
|
</div>
|
|
2304
2514
|
|
|
2305
|
-
{/* k88 —
|
|
2306
|
-
|
|
2307
|
-
|
|
2515
|
+
{/* k88 — the shared settings expander (collapsed by default); the joint
|
|
2516
|
+
DESCRIPTOR stays visible in the card header above.
|
|
2517
|
+
2026-08-12: grew from seed-only to the FULL per-segment knob set the
|
|
2518
|
+
route has always accepted (StudioMovieGoal): model pin, steps, cfg,
|
|
2519
|
+
and (vace_extend rows) context frames. Every knob blank ⇒ the key is
|
|
2520
|
+
dropped and the segment inherits the movie-level value — Movie-tab
|
|
2521
|
+
(k92) parity, minus the share ticks (here the movie level IS the
|
|
2522
|
+
shared value, so "inherit" already says it). */}
|
|
2308
2523
|
<PromptCardSettings
|
|
2309
|
-
label={
|
|
2524
|
+
label={
|
|
2525
|
+
i === 0
|
|
2526
|
+
? "settings — seed · model · steps · cfg · length"
|
|
2527
|
+
: "settings — seed · model · steps · cfg · length · joint · branch"
|
|
2528
|
+
}
|
|
2310
2529
|
>
|
|
2311
2530
|
<div className="vi-knob" style={{ maxWidth: "10rem" }}>
|
|
2312
2531
|
<label htmlFor={`vi-movie-seed-${g.key}`}>seed (optional)</label>
|
|
@@ -2320,6 +2539,88 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
2320
2539
|
onChange={(e) => setGoalField(i, { seed: e.target.value })}
|
|
2321
2540
|
/>
|
|
2322
2541
|
</div>
|
|
2542
|
+
<div className="vi-knob" style={{ maxWidth: "16rem" }}>
|
|
2543
|
+
<label htmlFor={`vi-movie-goalmodel-${g.key}`}>model — this segment</label>
|
|
2544
|
+
<select
|
|
2545
|
+
id={`vi-movie-goalmodel-${g.key}`}
|
|
2546
|
+
className="vi-knob-select"
|
|
2547
|
+
value={g.modelId}
|
|
2548
|
+
onChange={(e) => setGoalField(i, { modelId: e.target.value })}
|
|
2549
|
+
>
|
|
2550
|
+
<option value="">
|
|
2551
|
+
{modelId.trim() ? `Inherit — ${modelId.trim()}` : "Inherit — router picks"}
|
|
2552
|
+
</option>
|
|
2553
|
+
{movieModels.length > 0 && (
|
|
2554
|
+
<optgroup label="Bound by a ratified movie segment">
|
|
2555
|
+
{movieModels.map((id) => (
|
|
2556
|
+
<option key={id} value={id}>
|
|
2557
|
+
{id}
|
|
2558
|
+
</option>
|
|
2559
|
+
))}
|
|
2560
|
+
</optgroup>
|
|
2561
|
+
)}
|
|
2562
|
+
{(showAllModels || !renderLoaded || (g.modelId !== "" && !movieModels.includes(g.modelId))) && (
|
|
2563
|
+
<optgroup label={renderLoaded ? "Other studio models — unverified for a movie segment" : "Studio models"}>
|
|
2564
|
+
{STUDIO_MODEL_IDS.filter((id) => !movieModels.includes(id)).map((id) => (
|
|
2565
|
+
<option key={id} value={id}>
|
|
2566
|
+
{id}
|
|
2567
|
+
{renderLoaded ? " · unverified" : ""}
|
|
2568
|
+
</option>
|
|
2569
|
+
))}
|
|
2570
|
+
</optgroup>
|
|
2571
|
+
)}
|
|
2572
|
+
</select>
|
|
2573
|
+
<span className="vi-knob-hint">
|
|
2574
|
+
Pin a model for THIS segment only. Inherit = the movie-level pin, else the
|
|
2575
|
+
capability router. An unroutable pin errors on the job, never silently swaps.
|
|
2576
|
+
</span>
|
|
2577
|
+
</div>
|
|
2578
|
+
<div className="vi-knob" style={{ maxWidth: "8rem" }}>
|
|
2579
|
+
<label htmlFor={`vi-movie-goalsteps-${g.key}`}>steps</label>
|
|
2580
|
+
<input
|
|
2581
|
+
id={`vi-movie-goalsteps-${g.key}`}
|
|
2582
|
+
className="vi-knob-input"
|
|
2583
|
+
type="number"
|
|
2584
|
+
min={1}
|
|
2585
|
+
max={100}
|
|
2586
|
+
value={g.steps}
|
|
2587
|
+
placeholder={steps.trim() || "inherit"}
|
|
2588
|
+
onChange={(e) => setGoalField(i, { steps: e.target.value })}
|
|
2589
|
+
/>
|
|
2590
|
+
<span className="vi-knob-hint">1–100 · blank = movie/model default</span>
|
|
2591
|
+
</div>
|
|
2592
|
+
<div className="vi-knob" style={{ maxWidth: "8rem" }}>
|
|
2593
|
+
<label htmlFor={`vi-movie-goalcfg-${g.key}`}>cfg</label>
|
|
2594
|
+
<input
|
|
2595
|
+
id={`vi-movie-goalcfg-${g.key}`}
|
|
2596
|
+
className="vi-knob-input"
|
|
2597
|
+
type="number"
|
|
2598
|
+
min={0}
|
|
2599
|
+
max={20}
|
|
2600
|
+
step={0.5}
|
|
2601
|
+
value={g.cfg}
|
|
2602
|
+
placeholder={cfg.trim() || "inherit"}
|
|
2603
|
+
onChange={(e) => setGoalField(i, { cfg: e.target.value })}
|
|
2604
|
+
/>
|
|
2605
|
+
<span className="vi-knob-hint">0–20 · blank = movie/model default</span>
|
|
2606
|
+
</div>
|
|
2607
|
+
<div className="vi-knob" style={{ maxWidth: "11rem" }}>
|
|
2608
|
+
<label htmlFor={`vi-movie-goalframes-${g.key}`}>length — frames</label>
|
|
2609
|
+
<input
|
|
2610
|
+
id={`vi-movie-goalframes-${g.key}`}
|
|
2611
|
+
className="vi-knob-input"
|
|
2612
|
+
type="number"
|
|
2613
|
+
min={1}
|
|
2614
|
+
value={g.frames}
|
|
2615
|
+
placeholder={framesDefault.trim() || "model default"}
|
|
2616
|
+
onChange={(e) => setGoalField(i, { frames: e.target.value })}
|
|
2617
|
+
/>
|
|
2618
|
+
<span className="vi-knob-hint">
|
|
2619
|
+
{g.frames.trim() !== "" && Number(g.frames) > 0 && fps > 0
|
|
2620
|
+
? `\u2248 ${(Number(g.frames) / fps).toFixed(1)}s @ ${fps}fps`
|
|
2621
|
+
: "blank = movie default \u00b7 clamped to the model ceiling, snapped to 4k+1"}
|
|
2622
|
+
</span>
|
|
2623
|
+
</div>
|
|
2323
2624
|
{i > 0 && (
|
|
2324
2625
|
<>
|
|
2325
2626
|
<div className="vi-knob" style={{ maxWidth: "13rem" }}>
|
|
@@ -2363,6 +2664,24 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
2363
2664
|
: "Frame index into the previous segment's clip (0-based). Blank ⇒ its last frame (parent plays in full)."}
|
|
2364
2665
|
</span>
|
|
2365
2666
|
</div>
|
|
2667
|
+
{g.joint === "vace_extend" && (
|
|
2668
|
+
<div className="vi-knob" style={{ maxWidth: "10rem" }}>
|
|
2669
|
+
<label htmlFor={`vi-movie-goalctx-${g.key}`}>context frames</label>
|
|
2670
|
+
<input
|
|
2671
|
+
id={`vi-movie-goalctx-${g.key}`}
|
|
2672
|
+
className="vi-knob-input"
|
|
2673
|
+
type="number"
|
|
2674
|
+
min={1}
|
|
2675
|
+
value={g.contextFrames}
|
|
2676
|
+
placeholder="movie default"
|
|
2677
|
+
onChange={(e) => setGoalField(i, { contextFrames: e.target.value })}
|
|
2678
|
+
/>
|
|
2679
|
+
<span className="vi-knob-hint">
|
|
2680
|
+
How many of the parent's trailing frames carry motion through this
|
|
2681
|
+
VACE join. Blank = the movie-level default.
|
|
2682
|
+
</span>
|
|
2683
|
+
</div>
|
|
2684
|
+
)}
|
|
2366
2685
|
</>
|
|
2367
2686
|
)}
|
|
2368
2687
|
</PromptCardSettings>
|
|
@@ -2474,31 +2793,14 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
2474
2793
|
{/* ── UNNAMED-MOVIE NUDGE — a warning with a way through, never a gate. Sits
|
|
2475
2794
|
right above Generate because that is where the decision is made. ── */}
|
|
2476
2795
|
{nameWarn && (
|
|
2477
|
-
<
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
alignItems: "center",
|
|
2483
|
-
gap: "0.6rem",
|
|
2484
|
-
flexWrap: "wrap",
|
|
2485
|
-
marginBottom: "0.6rem",
|
|
2796
|
+
<UnnamedWarnPanel
|
|
2797
|
+
noun="movie"
|
|
2798
|
+
onProceed={(name) => {
|
|
2799
|
+
setNameWarn(false);
|
|
2800
|
+
onGenerate(true, name);
|
|
2486
2801
|
}}
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
This movie has no name — it will list as “untitled” beside a bare id in
|
|
2490
|
-
Cinema sessions, which is hard to tell apart from every other unnamed render
|
|
2491
|
-
when you come back to resume one.
|
|
2492
|
-
</span>
|
|
2493
|
-
<button
|
|
2494
|
-
type="button"
|
|
2495
|
-
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
2496
|
-
disabled={disableSubmit}
|
|
2497
|
-
onClick={() => onGenerate(true)}
|
|
2498
|
-
>
|
|
2499
|
-
Generate without a name
|
|
2500
|
-
</button>
|
|
2501
|
-
</div>
|
|
2802
|
+
onDismiss={() => setNameWarn(false)}
|
|
2803
|
+
/>
|
|
2502
2804
|
)}
|
|
2503
2805
|
|
|
2504
2806
|
{/* ── run row ── */}
|
package/src/style/app.css
CHANGED
|
@@ -4972,3 +4972,22 @@ button.vi-studio-src-thumb {
|
|
|
4972
4972
|
.vi-about-body p {
|
|
4973
4973
|
margin: 0;
|
|
4974
4974
|
}
|
|
4975
|
+
|
|
4976
|
+
/* Session-library grouped sections (LibraryGroups.tsx) */
|
|
4977
|
+
.vi-lib-group { margin: 0.35rem 0; }
|
|
4978
|
+
.vi-lib-group-head { display: flex; align-items: center; gap: 0.35rem; font-size: 0.85rem; }
|
|
4979
|
+
.vi-lib-count { opacity: 0.65; font-size: 0.8em; }
|
|
4980
|
+
|
|
4981
|
+
/* PROMPT ASSIST one-row consolidation (operator 2026-08-13): the model select
|
|
4982
|
+
must never push Enhance/Generate to a second row — it shrinks with an
|
|
4983
|
+
ellipsized closed face (the OPEN dropdown list always shows full names,
|
|
4984
|
+
that is native <select> behavior). Buttons never shrink. */
|
|
4985
|
+
.vi-prompt-assist > select.vi-select-sm {
|
|
4986
|
+
flex: 0 1 auto;
|
|
4987
|
+
min-width: 8rem;
|
|
4988
|
+
max-width: 24rem;
|
|
4989
|
+
overflow: hidden;
|
|
4990
|
+
text-overflow: ellipsis;
|
|
4991
|
+
white-space: nowrap;
|
|
4992
|
+
}
|
|
4993
|
+
.vi-prompt-assist > button { flex: 0 0 auto; }
|