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