@hugpy/video-intelligence-ui 0.1.1 → 0.3.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/README.md +43 -0
- package/index.html +2 -0
- package/package.json +1 -4
- package/src/config.ts +44 -0
- package/src/demo/demoFetch.ts +126 -3
- package/src/demo/fixtures.ts +97 -27
- package/src/demo/mediaBase.ts +62 -0
- package/src/demo/promptAssist.ts +25 -0
- package/src/demo/sampleData.ts +961 -0
- package/src/demo/sampleSets.ts +173 -0
- package/src/stations/ActiveProcessesStation.tsx +18 -3
- package/src/stations/GenerateStation.tsx +901 -366
- 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 +6 -3
- 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 +10 -17
- package/src/stations/studio/StudioMovieComposer.tsx +296 -59
- 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 +157 -0
- package/src/video/LengthRow.tsx +189 -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 +5 -0
- package/src/video/useToks.ts +193 -0
- package/assets/sampels/images/bycycle/bd8603bad35d4f179f4d1134d8faf2ee.mp4 +0 -0
- package/assets/sampels/images/bycycle/frame_00000.png +0 -0
- package/assets/sampels/images/bycycle/frame_00001.png +0 -0
- package/assets/sampels/images/bycycle/frame_00002.png +0 -0
- package/assets/sampels/images/bycycle/frame_00003.png +0 -0
- package/assets/sampels/images/bycycle/frame_00004.png +0 -0
- package/assets/sampels/images/bycycle/frame_00005.png +0 -0
- package/assets/sampels/images/forrest/ff23090611b844a6b85c224f388bd542.mp4 +0 -0
- package/assets/sampels/images/forrest/frame_00000.png +0 -0
- package/assets/sampels/images/forrest/frame_00001.png +0 -0
- package/assets/sampels/images/forrest/frame_00002.png +0 -0
- package/assets/sampels/images/forrest/frame_00003.png +0 -0
- package/assets/sampels/images/forrest/frame_00004.png +0 -0
- package/assets/sampels/images/forrest/frame_00005.png +0 -0
- package/assets/sampels/images/shenzo/e1a4fcd623084ab4a986d11c8cd00db8.mp4 +0 -0
- package/assets/sampels/images/shenzo/frame_00000.png +0 -0
- package/assets/sampels/images/shenzo/frame_00001.png +0 -0
- package/assets/sampels/images/shenzo/frame_00002.png +0 -0
- package/assets/sampels/images/shenzo/frame_00003.png +0 -0
- package/assets/sampels/images/shenzo/frame_00004.png +0 -0
- package/assets/sampels/images/shenzo/frame_00005.png +0 -0
- package/assets/sampels/scenes/man.mp4 +0 -0
- package/assets/sampels/scenes/woman.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/_assembly/contrib_00.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/movie.json +0 -67
- package/assets/sampels/studio_movies/caverns/movie.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/manifest.json +0 -87
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/provenance.json +0 -7
- package/assets/sampels/studio_movies/caverns/segment_01/branch.png +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/concat_list.txt +0 -2
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_00.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_01.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/movie.json +0 -81
- package/assets/sampels/studio_movies/keeper-matrix/movie.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/manifest.json +0 -87
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/provenance.json +0 -7
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/branch.png +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/manifest.json +0 -87
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/provenance.json +0 -7
- package/assets/sampels/videos/a_red_paper_boat_drifting_on_a_calm_pond__gentle_ripples__mo_a62d6c28.mp4 +0 -0
- package/assets/sampels/videos/aurora.mp4 +0 -0
- package/assets/sampels/videos/dark_night.mp4 +0 -0
- package/assets/sampels/videos/fire_and_soul.mp4 +0 -0
- package/assets/sampels/videos/harsh_seas.mp4 +0 -0
- package/src/demo/assets/demo-audio.mp3 +0 -0
- package/src/demo/assets/demo-clip.mp4 +0 -0
- package/src/demo/assets/demo-crop.mp4 +0 -0
- package/src/demo/assets/demo-poster.jpg +0 -0
- package/src/demo/assets/frame-1.jpg +0 -0
- package/src/demo/assets/frame-2.jpg +0 -0
- package/src/demo/assets/frame-3.jpg +0 -0
- package/src/demo/assets/frame-4.jpg +0 -0
- package/src/demo/assets/frame-5.jpg +0 -0
- package/src/demo/assets/frame-6.jpg +0 -0
- package/src/demo/assets/gen-1.jpg +0 -0
- package/src/demo/assets/gen-2.jpg +0 -0
- package/src/demo/assets.d.ts +0 -14
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
import { getSessionId } from "../../session";
|
|
69
69
|
import { addToLibrary, useMediaLibrary } from "../../video/mediaLibrary";
|
|
70
70
|
import { UnnamedWarnPanel, WarnUnnamedToggle, useWarnUnnamedPref } from "../../video/UnnamedWarn";
|
|
71
|
+
import { LengthRow } from "../../video/LengthRow";
|
|
71
72
|
import { GoalComposer } from "../GoalComposer";
|
|
72
73
|
import { usePromptAssist, type AssistExtras } from "../../video/usePromptAssist";
|
|
73
74
|
import { PromptAssistButtons } from "../../video/PromptAssistButtons";
|
|
@@ -98,6 +99,16 @@ import {
|
|
|
98
99
|
type PromptFieldMode,
|
|
99
100
|
type SpreadGoalInput,
|
|
100
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";
|
|
101
112
|
// B3: the visual spliced row + branch-frame scrubber (the picture half). Pure math
|
|
102
113
|
// lives in movieTimeline.ts; both are new files beside this composer.
|
|
103
114
|
import { StudioSplicedRow, StudioPendingRow } from "./StudioSplicedRow";
|
|
@@ -231,6 +242,11 @@ const progressSegmentSchema = z
|
|
|
231
242
|
prompt: z.string().nullable().optional(),
|
|
232
243
|
status: z.string().nullable().optional(),
|
|
233
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(),
|
|
234
250
|
})
|
|
235
251
|
.passthrough();
|
|
236
252
|
|
|
@@ -312,6 +328,25 @@ const MODEL_FRAME_CEILING = 81;
|
|
|
312
328
|
function newGoalKey(): string {
|
|
313
329
|
return `goal_${nextSessionSeq("studio.movie.goalKeySeq")}`;
|
|
314
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
|
+
|
|
315
350
|
function blankGoal(joint: JointMode = "still"): GoalRow {
|
|
316
351
|
return {
|
|
317
352
|
key: newGoalKey(),
|
|
@@ -789,6 +824,11 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
789
824
|
// The non-destructive result notice: what changed, what did NOT, every warning.
|
|
790
825
|
// Dismissible; it never edits anything on its own.
|
|
791
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());
|
|
792
832
|
|
|
793
833
|
const selectedCount = useMemo(
|
|
794
834
|
() => goals.reduce((n, g) => (selectedKeys.has(g.key) ? n + 1 : n), 0),
|
|
@@ -1026,6 +1066,28 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
1026
1066
|
[],
|
|
1027
1067
|
);
|
|
1028
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
|
+
|
|
1029
1091
|
// SPLIT-TO-FIT (operator 2026-08-13): a length ask beyond the model ceiling
|
|
1030
1092
|
// becomes VISIBLE continuation segments — motion-carried (vace_extend),
|
|
1031
1093
|
// length-distributed, each editable — never submit-time magic. The
|
|
@@ -1080,21 +1142,98 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
1080
1142
|
}, []);
|
|
1081
1143
|
|
|
1082
1144
|
// ── SEGMENTS STEPPER (spec §2) — say how many shots this movie has, up front ──
|
|
1083
|
-
// Materializes rows to reach N
|
|
1084
|
-
//
|
|
1085
|
-
//
|
|
1086
|
-
//
|
|
1087
|
-
//
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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" },
|
|
1096
1183
|
});
|
|
1097
|
-
|
|
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
|
+
);
|
|
1098
1237
|
|
|
1099
1238
|
// ── the row → wire projection ──
|
|
1100
1239
|
// ONE place turns editor rows into typed segment references, so the per-row assist
|
|
@@ -1283,7 +1422,26 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
1283
1422
|
// An explicit Generate on a SELECTED row is meant to replace that row's prose —
|
|
1284
1423
|
// that is the point of selecting it. A blank returned negative still falls back
|
|
1285
1424
|
// to what the user wrote rather than blanking it.
|
|
1286
|
-
|
|
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
|
+
};
|
|
1287
1445
|
}),
|
|
1288
1446
|
);
|
|
1289
1447
|
for (const key of applied) {
|
|
@@ -1295,6 +1453,11 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
1295
1453
|
return next;
|
|
1296
1454
|
});
|
|
1297
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());
|
|
1298
1461
|
setSpreadNotice(spreadNoticeLines(res, applied));
|
|
1299
1462
|
}, [
|
|
1300
1463
|
assist,
|
|
@@ -1521,9 +1684,13 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
1521
1684
|
reference_images:
|
|
1522
1685
|
!selectedProfile && references.length ? references.map((r) => r.uri) : undefined,
|
|
1523
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,
|
|
1524
1691
|
};
|
|
1525
1692
|
return { body };
|
|
1526
|
-
}, [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]);
|
|
1527
1694
|
|
|
1528
1695
|
// NAME NUDGE (k91) — set when a submit was held back only because the movie has no
|
|
1529
1696
|
// name. It is a warning, not a gate: the run row then offers "Generate without a
|
|
@@ -2016,22 +2183,17 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
2016
2183
|
<input id="vi-moviecol-height" className="vi-knob-input" type="number" min={64} step={8}
|
|
2017
2184
|
value={height} onChange={(e) => setHeight(Number(e.target.value) || 0)} />
|
|
2018
2185
|
</div>
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
{framesDefault.trim() !== "" && Number(framesDefault) > 0 && fps > 0
|
|
2031
|
-
? `every segment \u2248 ${(Number(framesDefault) / fps).toFixed(1)}s @ ${fps}fps (a segment\u2019s own length overrides)`
|
|
2032
|
-
: `blank = model default \u2014 81f \u2248 ${fps > 0 ? (81 / fps).toFixed(1) : "?"}s @ ${fps}fps per segment`}
|
|
2033
|
-
</span>
|
|
2034
|
-
</div>
|
|
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
|
+
/>
|
|
2035
2197
|
<div className="vi-knob" style={{ maxWidth: "7rem" }}>
|
|
2036
2198
|
<label htmlFor="vi-moviecol-steps">steps</label>
|
|
2037
2199
|
<input id="vi-moviecol-steps" className="vi-knob-input" type="number" min={1} max={100}
|
|
@@ -2060,6 +2222,56 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
2060
2222
|
the follows-hint, and .vi-gen-part-actions (auto-pushed reorder/remove). No
|
|
2061
2223
|
new look invented — the same classes GenerateStation.tsx uses. */}
|
|
2062
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>
|
|
2063
2275
|
{/* ── SEGMENT COUNT + GROUP ASSIST BAR (STUDIO-SPREAD-SPEC §2) ──
|
|
2064
2276
|
Say how many shots the movie has, tick the ones you want written, and
|
|
2065
2277
|
write them in ONE coherent pass. The count materializes or truncates
|
|
@@ -2071,9 +2283,9 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
2071
2283
|
label: "Segments",
|
|
2072
2284
|
value: goals.length,
|
|
2073
2285
|
min: 1,
|
|
2074
|
-
max: MAX_SEGMENTS,
|
|
2075
2286
|
onChange: setSegmentCount,
|
|
2076
|
-
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.",
|
|
2077
2289
|
}}
|
|
2078
2290
|
noun="segment"
|
|
2079
2291
|
selectedCount={selectedCount}
|
|
@@ -2160,6 +2372,18 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
2160
2372
|
</div>
|
|
2161
2373
|
)}
|
|
2162
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
|
+
|
|
2163
2387
|
<ol className="vi-gen-parts">
|
|
2164
2388
|
{goals.map((g, i) => (
|
|
2165
2389
|
<PromptCard
|
|
@@ -2180,6 +2404,13 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
2180
2404
|
/* The joint descriptor STAYS in the header (the joint SELECT moved
|
|
2181
2405
|
into the settings expander below — k88). */
|
|
2182
2406
|
headerHint={
|
|
2407
|
+
<CoordinationBadge
|
|
2408
|
+
review={segmentReview(coordination, g.key)}
|
|
2409
|
+
onAccept={acceptCoordination}
|
|
2410
|
+
onReject={rejectCoordination}
|
|
2411
|
+
settled={coordSettled}
|
|
2412
|
+
disabled={disableSubmit}
|
|
2413
|
+
>
|
|
2183
2414
|
<span className="vi-comfy-hint" style={{ opacity: 0.75 }}>
|
|
2184
2415
|
{i === 0
|
|
2185
2416
|
? references.length
|
|
@@ -2193,6 +2424,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
2193
2424
|
? "extends motion from the previous segment"
|
|
2194
2425
|
: "continues from a frame of the previous segment"}
|
|
2195
2426
|
</span>
|
|
2427
|
+
</CoordinationBadge>
|
|
2196
2428
|
}
|
|
2197
2429
|
canMoveUp={i > 0}
|
|
2198
2430
|
canMoveDown={i < goals.length - 1}
|
|
@@ -2667,23 +2899,18 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
2667
2899
|
/>
|
|
2668
2900
|
<span className="vi-knob-hint">0–20 · blank = movie/model default</span>
|
|
2669
2901
|
</div>
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
<
|
|
2682
|
-
{g.frames.trim() !== "" && Number(g.frames) > 0 && fps > 0
|
|
2683
|
-
? `\u2248 ${(Number(g.frames) / fps).toFixed(1)}s @ ${fps}fps`
|
|
2684
|
-
: "blank = movie default \u00b7 clamped to the model ceiling, snapped to 4k+1"}
|
|
2685
|
-
</span>
|
|
2686
|
-
{Number(g.frames.trim() || framesDefault.trim()) > MODEL_FRAME_CEILING && (
|
|
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%" }}>
|
|
2687
2914
|
<button
|
|
2688
2915
|
type="button"
|
|
2689
2916
|
className="vi-btn vi-btn-sm"
|
|
@@ -2692,8 +2919,8 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
2692
2919
|
>
|
|
2693
2920
|
✂ split into {Math.ceil(Number(g.frames.trim() || framesDefault.trim()) / MODEL_FRAME_CEILING)} segments to fit
|
|
2694
2921
|
</button>
|
|
2695
|
-
|
|
2696
|
-
|
|
2922
|
+
</div>
|
|
2923
|
+
)}
|
|
2697
2924
|
{/* UNIFORM-CARD PLACEHOLDERS (operator 2026-08-13): geometry and
|
|
2698
2925
|
budget are MOVIE-level by server contract (one StudioMovieSpec
|
|
2699
2926
|
geometry per movie) — shown greyed with the reason, never
|
|
@@ -2708,11 +2935,6 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
2708
2935
|
<input id={`vi-movie-goalh-${g.key}`} className="vi-knob-input" disabled value={height}
|
|
2709
2936
|
title="Movie-level — one geometry per movie (StudioMovieSpec); change it in movie settings above." />
|
|
2710
2937
|
</div>
|
|
2711
|
-
<div className="vi-knob" style={{ maxWidth: "6rem" }}>
|
|
2712
|
-
<label htmlFor={`vi-movie-goalfps-${g.key}`}>fps</label>
|
|
2713
|
-
<input id={`vi-movie-goalfps-${g.key}`} className="vi-knob-input" disabled value={fps}
|
|
2714
|
-
title="Movie-level — one fps per movie; change it in movie settings above." />
|
|
2715
|
-
</div>
|
|
2716
2938
|
<div className="vi-knob" style={{ maxWidth: "9rem" }}>
|
|
2717
2939
|
<label htmlFor={`vi-movie-goalvram-${g.key}`}>VRAM budget GB</label>
|
|
2718
2940
|
<input id={`vi-movie-goalvram-${g.key}`} className="vi-knob-input" disabled
|
|
@@ -2941,7 +3163,22 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
2941
3163
|
<span style={{ fontVariantNumeric: "tabular-nums", opacity: 0.7, minWidth: "5.5rem" }}>
|
|
2942
3164
|
seg {s.index + 1} · {statusBadge(s.status, s.resumed)}
|
|
2943
3165
|
</span>
|
|
2944
|
-
<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>
|
|
2945
3182
|
</li>
|
|
2946
3183
|
))}
|
|
2947
3184
|
</ul>
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
// (StudioClipsStation) and the Generate station's "studio" mode (StudioGenerateMode) — so the
|
|
29
29
|
// workbench is identical and a job enqueued from either is byte-identical. The only per-mount
|
|
30
30
|
// difference is `registerBridge` (the standalone station owns the "Send to Studio" target).
|
|
31
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
31
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
32
32
|
// Sub-tab input memory for the staged source + item strip — see
|
|
33
33
|
// video/sessionForm.ts.
|
|
34
34
|
import { useSessionState } from "../../video/sessionForm";
|
|
@@ -38,6 +38,7 @@ import { StudioAssistLogPanel } from "./StudioAssistLogPanel";
|
|
|
38
38
|
import type { MediaRef } from "../../video/contract";
|
|
39
39
|
import { useStudioPresets } from "../../video/useStudioPresets";
|
|
40
40
|
import { useStudioClips } from "./studioShared";
|
|
41
|
+
import { useMediaLibrary } from "../../video/mediaLibrary";
|
|
41
42
|
import {
|
|
42
43
|
makeStudioItem,
|
|
43
44
|
applyOp,
|
|
@@ -140,10 +141,40 @@ export function StudioPlane({ registerBridge = false, lockedSurfaceMode }: Studi
|
|
|
140
141
|
// now it re-syncs to the newest playable clip on every clip-list change, same as the
|
|
141
142
|
// comment always claimed — gated on `pinned` so a manual pick/clear (see above) is
|
|
142
143
|
// respected instead of being clobbered by the next poll tick.
|
|
144
|
+
// Session-library media from OTHER tabs (cinema movies, scene clips/frames, crops,
|
|
145
|
+
// …) — everything that finished anywhere and carries pixels, deduped against the
|
|
146
|
+
// studio clip catalog by uri (the studio-clips poll mirrors every playable clip
|
|
147
|
+
// into the library, so without the dedup each clip would list twice). Operator
|
|
148
|
+
// ask 2026-08-27: generated media from ANY tab auto-displays in this viewer, and
|
|
149
|
+
// "Play from library" offers recent generations — not just studio_i2v clips.
|
|
150
|
+
const libraryAll = useMediaLibrary();
|
|
151
|
+
const libraryMedia = useMemo(() => {
|
|
152
|
+
const clipUris = new Set(
|
|
153
|
+
clipsState.clips.map((c) => c.output?.uri).filter((u): u is string => !!u),
|
|
154
|
+
);
|
|
155
|
+
return libraryAll
|
|
156
|
+
.filter(
|
|
157
|
+
(it) =>
|
|
158
|
+
(it.ref.kind === "video" || it.ref.kind === "image") &&
|
|
159
|
+
!!it.ref.uri &&
|
|
160
|
+
!clipUris.has(it.ref.uri),
|
|
161
|
+
)
|
|
162
|
+
.sort((a, b) => b.addedAt - a.addedAt);
|
|
163
|
+
}, [libraryAll, clipsState.clips]);
|
|
164
|
+
|
|
143
165
|
useEffect(() => {
|
|
144
166
|
if (pinned) return;
|
|
145
|
-
|
|
146
|
-
|
|
167
|
+
const clip = clipsState.clips.find((c) => c.playable) ?? null;
|
|
168
|
+
const clipAt = clip ? (clip.updated ?? clip.created ?? 0) * 1000 : -1;
|
|
169
|
+
// "history" items are server back-fill stamped with the MOUNT time, not the
|
|
170
|
+
// generation time — following them would surface stale renders as "newest".
|
|
171
|
+
const item = libraryMedia.find((it) => it.origin !== "history") ?? null;
|
|
172
|
+
if (item && item.addedAt > clipAt) {
|
|
173
|
+
setSelected(`lib:${item.ref.asset_id}`);
|
|
174
|
+
} else {
|
|
175
|
+
setSelected(clip?.job_id ?? null);
|
|
176
|
+
}
|
|
177
|
+
}, [clipsState.clips, libraryMedia, pinned]);
|
|
147
178
|
|
|
148
179
|
// Manual pick from the viewer's "▤ Play from library" panel — pins so auto-follow
|
|
149
180
|
// leaves the choice alone.
|
|
@@ -249,6 +280,7 @@ export function StudioPlane({ registerBridge = false, lockedSurfaceMode }: Studi
|
|
|
249
280
|
plane mounts get them for free — see the `pinned` note above. */}
|
|
250
281
|
<StudioViewer
|
|
251
282
|
clips={clipsState.clips}
|
|
283
|
+
library={libraryMedia}
|
|
252
284
|
selected={selected}
|
|
253
285
|
pinned={pinned}
|
|
254
286
|
onPick={pickViewerClip}
|