@hugpy/video-intelligence-ui 0.3.3 → 0.3.4

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.
Files changed (38) hide show
  1. package/dist/assets/index-DMoT8G1q.css +1 -0
  2. package/dist/assets/index-GeD1mo16.js +4190 -0
  3. package/dist/index.html +2 -2
  4. package/entry.tsx +6 -27
  5. package/package.json +3 -2
  6. package/src/config.ts +11 -27
  7. package/src/demo/demoFetch.ts +0 -9
  8. package/src/nav/Navbar.tsx +3 -3
  9. package/src/session.ts +8 -43
  10. package/src/stations/GenerateStation.tsx +8 -5
  11. package/src/stations/SharePanel.tsx +4 -4
  12. package/src/stations/ToksPanel.tsx +1 -2
  13. package/src/stations/WorkbenchSidebar.tsx +8 -261
  14. package/src/stations/ops/CharacterGroupsPanel.tsx +75 -83
  15. package/src/stations/studio/IdentitiesStation.tsx +66 -75
  16. package/src/stations/studio/StudioGenerateMode.tsx +3 -2
  17. package/src/stations/studio/StudioGenerateTab.tsx +258 -188
  18. package/src/stations/studio/StudioMovieComposer.tsx +321 -46
  19. package/src/stations/studio/StudioPlane.tsx +3 -1
  20. package/src/stations/studio/StudioViewer.tsx +6 -11
  21. package/src/stations/studio/studioShared.tsx +14 -2
  22. package/src/stations/studio/useIdentityCreateJob.ts +26 -21
  23. package/src/stations/studio/useReconstruction.ts +11 -5
  24. package/src/video/PromptAssistButtons.tsx +5 -6
  25. package/src/video/jobStream.ts +119 -0
  26. package/src/video/jobTracker.ts +67 -76
  27. package/src/video/modelsFeed.ts +105 -0
  28. package/src/video/studioOracle.ts +48 -0
  29. package/src/video/useMediaJobs.ts +17 -3
  30. package/src/video/useModels.ts +12 -53
  31. package/src/video/useMovieSessions.ts +15 -6
  32. package/src/video/useOperatorHistory.ts +11 -101
  33. package/src/video/usePromptAssist.ts +6 -3
  34. package/src/video/useToks.ts +14 -6
  35. package/src/video/watchVideoJob.ts +43 -0
  36. package/dist/assets/index-DuT1AMLn.css +0 -1
  37. package/dist/assets/index-_13ZiBDP.js +0 -4191
  38. package/src/video/useWorkerCalls.ts +0 -165
@@ -67,6 +67,7 @@ import {
67
67
  } from "../../video/contract";
68
68
  import { getSessionId } from "../../session";
69
69
  import { addToLibrary, useMediaLibrary } from "../../video/mediaLibrary";
70
+ import { subscribeJobStream } from "../../video/jobStream";
70
71
  import { UnnamedWarnPanel, WarnUnnamedToggle, useWarnUnnamedPref } from "../../video/UnnamedWarn";
71
72
  import { LengthRow } from "../../video/LengthRow";
72
73
  import { GoalComposer } from "../GoalComposer";
@@ -109,6 +110,7 @@ import {
109
110
  type CoordinationReport,
110
111
  } from "../../video/coordinationReport";
111
112
  import { CoordinationBadge, CoordinationSummary, decisionKey } from "./coordinationBadge";
113
+ import { planStudioSettings, planStudioMovie } from "../../video/studioOracle";
112
114
  // B3: the visual spliced row + branch-frame scrubber (the picture half). Pure math
113
115
  // lives in movieTimeline.ts; both are new files beside this composer.
114
116
  import { StudioSplicedRow, StudioPendingRow } from "./StudioSplicedRow";
@@ -316,6 +318,40 @@ interface GoalRow {
316
318
  frames: string;
317
319
  }
318
320
 
321
+ /** A single Clip prompt promoted into the existing sequential Cinema composer. */
322
+ export interface ClipSequenceDraft {
323
+ id: number;
324
+ prompt: string;
325
+ negative: string;
326
+ stdNegative: boolean;
327
+ startImage: MediaRef | null;
328
+ width: number;
329
+ height: number;
330
+ fps: number;
331
+ seed: number;
332
+ frames: string;
333
+ modelId: string;
334
+ steps: string;
335
+ cfg: string;
336
+ vramBudget: string;
337
+ project: string;
338
+ plannedSegments?: Array<{
339
+ prompt: string;
340
+ negative: string;
341
+ joint: "still" | "cut";
342
+ settings?: {
343
+ model_id?: string; steps?: number; cfg?: number;
344
+ seed?: number; frames?: number; width?: number;
345
+ height?: number; fps?: number;
346
+ };
347
+ }>;
348
+ }
349
+
350
+ interface CinemaImportBackup extends Omit<ClipSequenceDraft, "id" | "prompt" | "negative" | "stdNegative"> {
351
+ goals: GoalRow[];
352
+ movieTitle: string;
353
+ }
354
+
319
355
  // The counter is SESSION-persisted (not a module `let`) now that the goal rows
320
356
  // themselves survive a reload: a module counter restarts at 0 while the restored
321
357
  // rows keep their old keys, so `goal_1` could be minted twice — and this key
@@ -342,6 +378,16 @@ const producerPlanSchema = z.object({
342
378
  negative: z.string().optional(),
343
379
  seconds: z.number(),
344
380
  joint: z.string(),
381
+ settings: z.object({
382
+ model_id: z.string().optional(),
383
+ width: z.number().optional(),
384
+ height: z.number().optional(),
385
+ fps: z.number().optional(),
386
+ frames: z.number().optional(),
387
+ steps: z.number().optional(),
388
+ cfg: z.number().optional(),
389
+ seed: z.number().optional(),
390
+ }).optional(),
345
391
  }),
346
392
  )
347
393
  .min(1),
@@ -391,13 +437,15 @@ function useStudioMovieJob(): StudioMovieJobApi {
391
437
  const [running, setRunning] = useState(false);
392
438
 
393
439
  const mounted = useRef(true);
394
- const timer = useRef<number | null>(null);
440
+ // Unsubscribe from the shared job stream while this movie job runs (replaces
441
+ // the retired 2.5s poll interval — a transition re-reads on push).
442
+ const unsub = useRef<null | (() => void)>(null);
395
443
  const jobRef = useRef<string | null>(null);
396
444
 
397
445
  const stopPoll = useCallback(() => {
398
- if (timer.current != null) {
399
- window.clearInterval(timer.current);
400
- timer.current = null;
446
+ if (unsub.current != null) {
447
+ unsub.current();
448
+ unsub.current = null;
401
449
  }
402
450
  }, []);
403
451
 
@@ -469,7 +517,8 @@ function useStudioMovieJob(): StudioMovieJobApi {
469
517
  jobRef.current = id;
470
518
  setStatus("queued");
471
519
  void poll();
472
- timer.current = window.setInterval(() => void poll(), 2500);
520
+ if (unsub.current != null) unsub.current();
521
+ unsub.current = subscribeJobStream(() => void poll());
473
522
  });
474
523
  },
475
524
  [poll, stopPoll],
@@ -635,6 +684,9 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
635
684
  blankGoal(),
636
685
  blankGoal(),
637
686
  ]);
687
+ const [clipSequenceDraft, setClipSequenceDraft] = useSessionState<ClipSequenceDraft | null>(
688
+ "studio.movie.clipSequenceDraft", null,
689
+ );
638
690
  // Rows restored from a session written BEFORE the per-segment knob round
639
691
  // (2026-08-12) lack modelId/steps/cfg/contextFrames — normalize to the blank
640
692
  // ("inherit") value so `.trim()` never meets undefined. useMemo, not a write:
@@ -684,6 +736,26 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
684
736
  // surface's idiom. The movie route validates these at the movie level (studio_movie_schema
685
737
  // _check_steps_cfg: steps int [1,100], cfg number [0,20]) — mirrored in buildBody below.
686
738
  const [modelId, setModelId] = useSessionState("studio.movie.modelId", "");
739
+ const [settingsMode, setSettingsMode] = useSessionState<"oracle" | "manual">(
740
+ "studio.movie.settingsMode", "oracle",
741
+ );
742
+ const manualSettings = useSessionRef<string[]>("studio.movie.manualSettings", []);
743
+ const goalManualSettings = useSessionRef<Record<string, string[]>>(
744
+ "studio.movie.goalManualSettings", {},
745
+ );
746
+ const markManual = (field: string) => {
747
+ if (!manualSettings.current.includes(field)) {
748
+ manualSettings.current = [...manualSettings.current, field];
749
+ }
750
+ };
751
+ const markGoalManual = (key: string, field: string) => {
752
+ const current = goalManualSettings.current[key] || [];
753
+ if (!current.includes(field)) {
754
+ goalManualSettings.current = {
755
+ ...goalManualSettings.current, [key]: [...current, field],
756
+ };
757
+ }
758
+ };
687
759
  const [steps, setSteps] = useSessionState("studio.movie.steps", "");
688
760
  const [cfg, setCfg] = useSessionState("studio.movie.cfg", "");
689
761
  // MERGE-NOT-OVERWRITE bookkeeping — true once the OPERATOR has typed a budget. A
@@ -1151,6 +1223,77 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1151
1223
  // "extend motion" stays that way as it grows. No upper cap: the count takes
1152
1224
  // any integer ≥ 1.
1153
1225
  const [goalStash, setGoalStash] = useSessionState<GoalRow[]>("studio.movie.goalStash", () => []);
1226
+ const [clipImportBackup, setClipImportBackup] = useSessionState<CinemaImportBackup | null>(
1227
+ "studio.movie.clipImportBackup", null,
1228
+ );
1229
+ const importedClipDraftRef = useRef<number | null>(null);
1230
+ useEffect(() => {
1231
+ const draft = clipSequenceDraft;
1232
+ if (!draft || importedClipDraftRef.current === draft.id) return;
1233
+ importedClipDraftRef.current = draft.id;
1234
+ // A Clip import starts a new sequence. Preserve the previous Cinema draft
1235
+ // separately so the goal-count stash cannot inject old rows into this one.
1236
+ if (goalsRaw.some((g) => g.prompt.trim() !== "")) {
1237
+ setClipImportBackup({ goals: goalsRaw, startImage, width, height, fps, seed,
1238
+ frames: framesDefault, modelId, steps, cfg, vramBudget, project, movieTitle });
1239
+ }
1240
+ setGoals(draft.plannedSegments?.length
1241
+ ? draft.plannedSegments.map((s, i) => ({
1242
+ ...blankGoal(i === 0 ? "still" : s.joint),
1243
+ prompt: s.prompt, negative: s.negative,
1244
+ stdNegative: draft.stdNegative,
1245
+ modelId: s.settings?.model_id || "",
1246
+ steps: s.settings?.steps == null ? "" : String(s.settings.steps),
1247
+ cfg: s.settings?.cfg == null ? "" : String(s.settings.cfg),
1248
+ seed: s.settings?.seed == null ? "" : String(s.settings.seed),
1249
+ frames: s.settings?.frames == null ? "" : String(s.settings.frames),
1250
+ }))
1251
+ : [
1252
+ { ...blankGoal(), prompt: draft.prompt, negative: draft.negative,
1253
+ stdNegative: draft.stdNegative },
1254
+ blankGoal("still"),
1255
+ ]);
1256
+ setStartImage(draft.startImage);
1257
+ setWidth(draft.width);
1258
+ setHeight(draft.height);
1259
+ setFps(draft.fps);
1260
+ setSeed(draft.seed);
1261
+ setFramesDefault(draft.frames);
1262
+ setModelId(draft.modelId);
1263
+ setSteps(draft.steps);
1264
+ setCfg(draft.cfg);
1265
+ setVramBudget(draft.vramBudget);
1266
+ setProject(draft.project);
1267
+ setMovieTitle(draft.project);
1268
+ setFormMsg(draft.plannedSegments?.length
1269
+ ? `Oracle generated ${draft.plannedSegments.length} sequential prompt cards. Review them, then Generate movie.`
1270
+ : "Clip prompt is goal 1. Add the next prompt in goal 2; it continues from goal 1's last frame by default.");
1271
+ setClipSequenceDraft(null);
1272
+ }, [clipSequenceDraft, goalsRaw, startImage, width, height, fps, seed,
1273
+ framesDefault, modelId, steps, cfg, vramBudget, project, movieTitle,
1274
+ setClipSequenceDraft, setClipImportBackup, setGoals,
1275
+ setStartImage, setWidth, setHeight, setFps, setSeed, setFramesDefault,
1276
+ setModelId, setSteps, setCfg, setVramBudget, setProject, setMovieTitle]);
1277
+
1278
+ function restorePreviousCinemaDraft() {
1279
+ const prior = clipImportBackup;
1280
+ if (!prior) return;
1281
+ setGoals(prior.goals);
1282
+ setStartImage(prior.startImage);
1283
+ setWidth(prior.width);
1284
+ setHeight(prior.height);
1285
+ setFps(prior.fps);
1286
+ setSeed(prior.seed);
1287
+ setFramesDefault(prior.frames);
1288
+ setModelId(prior.modelId);
1289
+ setSteps(prior.steps);
1290
+ setCfg(prior.cfg);
1291
+ setVramBudget(prior.vramBudget);
1292
+ setProject(prior.project);
1293
+ setMovieTitle(prior.movieTitle);
1294
+ setClipImportBackup(null);
1295
+ setFormMsg("Previous Cinema draft restored.");
1296
+ }
1154
1297
 
1155
1298
  // ── 🎬 PRODUCER (k120 slice 1) — premise → /video/producer/plan → rows. ──
1156
1299
  // The plan's per-segment seconds convert to frames against the CURRENT movie
@@ -1171,13 +1314,17 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1171
1314
  false,
1172
1315
  );
1173
1316
  const produceMovie = useCallback(async () => {
1174
- const p = premise.trim();
1317
+ const p = premise.trim() || movieQuery.trim();
1175
1318
  if (!p || producing) return;
1176
1319
  setProducing(true);
1177
1320
  setProduceError(null);
1178
1321
  const res = await request<unknown>(hugpyConfig.producerPlanUrl, {
1179
1322
  method: "POST",
1180
- body: JSON.stringify({ premise: p }),
1323
+ body: JSON.stringify({
1324
+ premise: p,
1325
+ model: assist.assistModel || undefined,
1326
+ start_image: startImage?.uri,
1327
+ }),
1181
1328
  headers: { "Content-Type": "application/json" },
1182
1329
  meta: { specKey: "studio", operation: "studio.producer.plan" },
1183
1330
  });
@@ -1197,23 +1344,34 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1197
1344
  const joint: JointMode =
1198
1345
  i === 0
1199
1346
  ? "still"
1200
- : s.joint === "cut" || s.joint === "still" || s.joint === "vace_extend"
1347
+ : s.joint === "cut" || s.joint === "still"
1201
1348
  ? (s.joint as JointMode)
1202
- : "vace_extend";
1349
+ : "still";
1203
1350
  return {
1204
1351
  ...blankGoal(joint),
1205
1352
  prompt: s.prompt,
1206
1353
  negative: s.negative ?? "",
1207
- frames: String(Math.max(1, Math.round(s.seconds * effFps))),
1354
+ modelId: s.settings?.model_id ?? "",
1355
+ frames: String(s.settings?.frames ?? Math.max(1, Math.round(s.seconds * effFps))),
1356
+ steps: s.settings?.steps == null ? "" : String(s.settings.steps),
1357
+ cfg: s.settings?.cfg == null ? "" : String(s.settings.cfg),
1358
+ seed: s.settings?.seed == null ? "" : String(s.settings.seed),
1208
1359
  };
1209
1360
  });
1210
1361
  if (goals.some((g) => g.prompt.trim() !== "")) {
1211
1362
  setGoalStash((st) => [...goals, ...st]);
1212
1363
  }
1213
1364
  setGoals(rows);
1365
+ const first = plan.segments[0]?.settings;
1366
+ if (first) {
1367
+ if (first.width != null && !manualSettings.current.includes("width")) setWidth(first.width);
1368
+ if (first.height != null && !manualSettings.current.includes("height")) setHeight(first.height);
1369
+ if (first.fps != null && !manualSettings.current.includes("fps")) setFps(first.fps);
1370
+ }
1214
1371
  setProducerNote([plan.title, plan.logline].filter(Boolean).join(" — "));
1215
- setContinuityRefresh(true);
1216
- }, [premise, producing, goals, fps]);
1372
+ setContinuityRefresh(false);
1373
+ }, [premise, movieQuery, producing, goals, fps, assist.assistModel, startImage,
1374
+ setWidth, setHeight, setFps, setContinuityRefresh]);
1217
1375
 
1218
1376
  const setSegmentCount = useCallback(
1219
1377
  (n: number) => {
@@ -1328,6 +1486,42 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1328
1486
  // name this ref minted may be replaced by the next Generate (operator
1329
1487
  // 2026-08-13: "purely a product of the same generation method").
1330
1488
  const autoTitleRef = useRef("");
1489
+ const planGoalPrompt = useCallback(async (
1490
+ i: number, text: string,
1491
+ coordinated?: { seed?: number; frames?: number; joint_mode?: string },
1492
+ ) => {
1493
+ if (settingsMode !== "oracle") return;
1494
+ const g = goals[i];
1495
+ if (!g) return;
1496
+ const capability = i === 0
1497
+ ? (startImage ? "i2v" : "t2v")
1498
+ : ((coordinated?.joint_mode || g.joint) === "cut" ? "t2v" : "i2v");
1499
+ const explicit = [...new Set([
1500
+ ...manualSettings.current,
1501
+ ...(goalManualSettings.current[g.key] || []),
1502
+ ...(coordinated?.seed != null ? ["seed"] : []),
1503
+ ...(coordinated?.frames != null ? ["frames"] : []),
1504
+ ])];
1505
+ const planned = await planStudioSettings(capability, text, {
1506
+ model_id: g.modelId || undefined,
1507
+ width, height, fps,
1508
+ frames: coordinated?.frames ?? (g.frames ? Number(g.frames) : framesDefault ? Number(framesDefault) : undefined),
1509
+ steps: g.steps ? Number(g.steps) : steps ? Number(steps) : undefined,
1510
+ cfg: g.cfg ? Number(g.cfg) : cfg ? Number(cfg) : undefined,
1511
+ seed: coordinated?.seed ?? (g.seed ? Number(g.seed) : seed),
1512
+ }, explicit);
1513
+ setGoalField(i, {
1514
+ modelId: planned.model_id || "",
1515
+ frames: planned.frames == null ? g.frames : String(planned.frames),
1516
+ steps: planned.steps == null ? g.steps : String(planned.steps),
1517
+ cfg: planned.cfg == null ? g.cfg : String(planned.cfg),
1518
+ seed: planned.seed == null ? g.seed : String(planned.seed),
1519
+ });
1520
+ if (planned.width != null) setWidth(planned.width);
1521
+ if (planned.height != null) setHeight(planned.height);
1522
+ if (planned.fps != null) setFps(planned.fps);
1523
+ }, [settingsMode, goals, startImage, width, height, fps, framesDefault, steps, cfg,
1524
+ seed, setGoalField, setWidth, setHeight, setFps]);
1331
1525
 
1332
1526
  const runRowAssist = useCallback(
1333
1527
  (i: number, mode: "detail" | "generate") => {
@@ -1335,10 +1529,14 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1335
1529
  if (!g) return;
1336
1530
  void (async () => {
1337
1531
  let fresh = "";
1338
- await assist.runAssist(mode, g.prompt, (p) => {
1532
+ await assist.runAssist(mode, mode === "generate" ? "" : g.prompt, (p) => {
1339
1533
  fresh = p;
1340
1534
  setGoalField(i, { prompt: p });
1341
1535
  }, { context: rowContext(i) });
1536
+ if (fresh.trim() && settingsMode === "oracle") {
1537
+ try { await planGoalPrompt(i, fresh); }
1538
+ catch (err) { setFormMsg(err instanceof Error ? err.message : "Oracle planning failed."); }
1539
+ }
1342
1540
  if (mode !== "generate" || !fresh.trim()) return;
1343
1541
  // NEGATIVE RIDES ALONG (operator 2026-08-13): the generated exclusion
1344
1542
  // list APPENDS to this goal's typed negative, never clobbers it
@@ -1371,7 +1569,8 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1371
1569
  intentText.current[g.key] = "";
1372
1570
  setIntents((prev) => ({ ...prev, [g.key]: null }));
1373
1571
  },
1374
- [assist, goals, rowContext, setGoalField, movieTitle, setMovieTitle],
1572
+ [assist, goals, rowContext, setGoalField, movieTitle, setMovieTitle,
1573
+ settingsMode, planGoalPrompt],
1375
1574
  );
1376
1575
 
1377
1576
  // ── negative generation (§1b) — an exclusion list for THIS shot ──
@@ -1444,6 +1643,25 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1444
1643
  };
1445
1644
  }),
1446
1645
  );
1646
+ let oracleNotice: string | null = null;
1647
+ if (settingsMode === "oracle") {
1648
+ for (const key of applied) {
1649
+ const index = goals.findIndex((g) => g.key === key);
1650
+ const hit = bySeg.get(key);
1651
+ if (index < 0 || !hit) continue;
1652
+ try {
1653
+ const k = hit.knobs || {};
1654
+ await planGoalPrompt(index, hit.prompt, {
1655
+ seed: typeof k.seed === "number" ? k.seed : undefined,
1656
+ frames: typeof k.frames === "number" ? k.frames : undefined,
1657
+ joint_mode: typeof k.joint_mode === "string" ? k.joint_mode : undefined,
1658
+ });
1659
+ } catch (err) {
1660
+ oracleNotice = err instanceof Error ? err.message : "Oracle planning failed.";
1661
+ break;
1662
+ }
1663
+ }
1664
+ }
1447
1665
  for (const key of applied) {
1448
1666
  intentText.current[key] = "";
1449
1667
  }
@@ -1458,7 +1676,10 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1458
1676
  // this one.
1459
1677
  setCoordination(readCoordinationReport(res.coordination));
1460
1678
  setCoordSettled(new Set());
1461
- setSpreadNotice(spreadNoticeLines(res, applied));
1679
+ setSpreadNotice([
1680
+ ...spreadNoticeLines(res, applied),
1681
+ ...(oracleNotice ? [`Oracle settings: ${oracleNotice}`] : []),
1682
+ ]);
1462
1683
  }, [
1463
1684
  assist,
1464
1685
  goalInputs,
@@ -1470,6 +1691,8 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1470
1691
  selectedKeys,
1471
1692
  selectedProfile,
1472
1693
  steeringSeed,
1694
+ settingsMode,
1695
+ planGoalPrompt,
1473
1696
  ]);
1474
1697
 
1475
1698
  // ── GROUP ACTION: negatives for the selected rows ──
@@ -1655,6 +1878,8 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1655
1878
  });
1656
1879
 
1657
1880
  const body: Record<string, unknown> = {
1881
+ settings_mode: settingsMode,
1882
+ explicit_settings: manualSettings.current,
1658
1883
  resolution: { width, height, fps },
1659
1884
  frames: framesDefNum,
1660
1885
  seed,
@@ -1690,7 +1915,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1690
1915
  continuity_refresh: continuityRefresh || undefined,
1691
1916
  };
1692
1917
  return { body };
1693
- }, [goals, width, height, fps, seed, vramBudget, modelId, steps, cfg, negative, project, movieTitle, startImage, references, selectedProfile, continuityRefresh]);
1918
+ }, [goals, width, height, fps, seed, vramBudget, modelId, steps, cfg, negative, project, movieTitle, startImage, references, selectedProfile, continuityRefresh, settingsMode]);
1694
1919
 
1695
1920
  // NAME NUDGE (k91) — set when a submit was held back only because the movie has no
1696
1921
  // name. It is a warning, not a gate: the run row then offers "Generate without a
@@ -1701,7 +1926,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1701
1926
  const [warnUnnamed, setWarnUnnamed] = useWarnUnnamedPref("cinema");
1702
1927
 
1703
1928
  const onGenerate = useCallback(
1704
- (skipNameCheck = false, titleOverride?: string) => {
1929
+ async (skipNameCheck = false, titleOverride?: string) => {
1705
1930
  const built = buildBody(titleOverride);
1706
1931
  if ("err" in built) {
1707
1932
  setFormMsg(built.err);
@@ -1718,9 +1943,36 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1718
1943
  setNameWarn(false);
1719
1944
  setFormMsg(null);
1720
1945
  if (titleOverride && titleOverride.trim()) setMovieTitle(titleOverride.trim());
1721
- job.run(built.body);
1946
+ let body = built.body;
1947
+ if (settingsMode === "oracle") {
1948
+ try {
1949
+ body = await planStudioMovie(body);
1950
+ const resolution = body.resolution as { width?: number; height?: number; fps?: number } | undefined;
1951
+ if (resolution?.width != null) setWidth(resolution.width);
1952
+ if (resolution?.height != null) setHeight(resolution.height);
1953
+ if (resolution?.fps != null) setFps(resolution.fps);
1954
+ const plannedGoals = body.goals as Array<Record<string, unknown>> | undefined;
1955
+ if (plannedGoals) setGoals((prev) => prev.map((g, i) => {
1956
+ const p = plannedGoals[i];
1957
+ if (!p) return g;
1958
+ return {
1959
+ ...g,
1960
+ modelId: typeof p.model_id === "string" ? p.model_id : g.modelId,
1961
+ steps: typeof p.steps === "number" ? String(p.steps) : g.steps,
1962
+ cfg: typeof p.cfg === "number" ? String(p.cfg) : g.cfg,
1963
+ seed: typeof p.seed === "number" ? String(p.seed) : g.seed,
1964
+ frames: typeof p.frames === "number" ? String(p.frames) : g.frames,
1965
+ };
1966
+ }));
1967
+ } catch (err) {
1968
+ setFormMsg(err instanceof Error ? err.message : "Oracle planning failed.");
1969
+ return;
1970
+ }
1971
+ }
1972
+ job.run(body);
1722
1973
  },
1723
- [buildBody, job, movieTitle, setMovieTitle, warnUnnamed],
1974
+ [buildBody, job, movieTitle, setMovieTitle, warnUnnamed, settingsMode,
1975
+ setWidth, setHeight, setFps, setGoals],
1724
1976
  );
1725
1977
 
1726
1978
  // SESSION LIBRARY PUSH (operator 2026-08-13): a finished movie's outputs
@@ -1920,9 +2172,12 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1920
2172
  className="vi-knob-select"
1921
2173
  value={modelId}
1922
2174
  disabled={disableSubmit}
1923
- onChange={(e) => setModelId(e.target.value)}
2175
+ onChange={(e) => { markManual("model_id"); setModelId(e.target.value); }}
1924
2176
  >
1925
2177
  <option value="">Auto — router picks by capability/budget</option>
2178
+ {modelId && !STUDIO_MODEL_IDS.includes(modelId) && (
2179
+ <option value={modelId}>{modelId} · current bound model</option>
2180
+ )}
1926
2181
  {movieModels.length > 0 && (
1927
2182
  <optgroup label="Bound by a ratified movie segment">
1928
2183
  {movieModels.map((id) => (
@@ -1973,7 +2228,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1973
2228
  value={steps}
1974
2229
  placeholder="model default"
1975
2230
  disabled={disableSubmit}
1976
- onChange={(e) => setSteps(e.target.value)}
2231
+ onChange={(e) => { markManual("steps"); setSteps(e.target.value); }}
1977
2232
  />
1978
2233
  </div>
1979
2234
  <div className="vi-knob">
@@ -1988,7 +2243,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1988
2243
  value={cfg}
1989
2244
  placeholder="model default"
1990
2245
  disabled={disableSubmit}
1991
- onChange={(e) => setCfg(e.target.value)}
2246
+ onChange={(e) => { markManual("cfg"); setCfg(e.target.value); }}
1992
2247
  />
1993
2248
  </div>
1994
2249
 
@@ -2004,7 +2259,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2004
2259
  step={8}
2005
2260
  value={width}
2006
2261
  disabled={disableSubmit}
2007
- onChange={(e) => setWidth(Number(e.target.value) || 0)}
2262
+ onChange={(e) => { markManual("width"); setWidth(Number(e.target.value) || 0); }}
2008
2263
  />
2009
2264
  </div>
2010
2265
  <div className="vi-knob">
@@ -2017,7 +2272,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2017
2272
  step={8}
2018
2273
  value={height}
2019
2274
  disabled={disableSubmit}
2020
- onChange={(e) => setHeight(Number(e.target.value) || 0)}
2275
+ onChange={(e) => { markManual("height"); setHeight(Number(e.target.value) || 0); }}
2021
2276
  />
2022
2277
  </div>
2023
2278
  <div className="vi-knob">
@@ -2035,7 +2290,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2035
2290
  ? `Ratified movie envelope: ${movieGeometry.width}×${movieGeometry.height} @ ${movieGeometry.fps}fps (“${movieGeometry.presetTitle}”).`
2036
2291
  : undefined
2037
2292
  }
2038
- onChange={(e) => setFps(Number(e.target.value) || 0)}
2293
+ onChange={(e) => { markManual("fps"); setFps(Number(e.target.value) || 0); }}
2039
2294
  />
2040
2295
  {movieGeomOutOfEnvelope && (
2041
2296
  <span className="vi-knob-flag">
@@ -2056,7 +2311,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2056
2311
  min={0}
2057
2312
  value={seed}
2058
2313
  disabled={disableSubmit}
2059
- onChange={(e) => setSeed(Number(e.target.value) || 0)}
2314
+ onChange={(e) => { markManual("seed"); setSeed(Number(e.target.value) || 0); }}
2060
2315
  />
2061
2316
  <span className="vi-knob-hint">A segment with no seed uses base seed + its index.</span>
2062
2317
  </div>
@@ -2101,6 +2356,12 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2101
2356
 
2102
2357
  return (
2103
2358
  <div className="vi-studio-generate">
2359
+ {clipImportBackup && (
2360
+ <button type="button" className="vi-btn vi-btn-sm vi-btn-ghost"
2361
+ onClick={restorePreviousCinemaDraft}>
2362
+ Restore previous Cinema draft
2363
+ </button>
2364
+ )}
2104
2365
  {/* The movie Settings panel lives in the left-sidebar Settings tab (portaled into
2105
2366
  settingsHost, like the Clip surface's knobGrid): templates + model pin + sampler
2106
2367
  + geometry/seed/budget/project; null until that panel mounts. */}
@@ -2147,6 +2408,15 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2147
2408
  miss (and absent when no host mounts). This expander mirrors the
2148
2409
  SAME state — two views, one truth, like the Clip card. ── */}
2149
2410
  <PromptCardSettings label="movie settings — model · size · fps · steps · cfg · budget">
2411
+ <div className="vi-knob vi-studio-knob-wide">
2412
+ <label htmlFor="vi-movie-settings-mode">settings</label>
2413
+ <select id="vi-movie-settings-mode" className="vi-knob-select"
2414
+ value={settingsMode}
2415
+ onChange={(e) => setSettingsMode(e.target.value as "oracle" | "manual")}>
2416
+ <option value="oracle">Oracle-generated (default)</option>
2417
+ <option value="manual">Manual</option>
2418
+ </select>
2419
+ </div>
2150
2420
  <div className="vi-knob vi-studio-knob-wide" style={{ minWidth: "16rem" }}>
2151
2421
  <label htmlFor="vi-moviecol-model">model — whole movie</label>
2152
2422
  <select
@@ -2154,9 +2424,12 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2154
2424
  className="vi-knob-select"
2155
2425
  value={modelId}
2156
2426
  disabled={disableSubmit}
2157
- onChange={(e) => setModelId(e.target.value)}
2427
+ onChange={(e) => { markManual("model_id"); setModelId(e.target.value); }}
2158
2428
  >
2159
2429
  <option value="">Auto — router picks by capability/budget</option>
2430
+ {modelId && !STUDIO_MODEL_IDS.includes(modelId) && (
2431
+ <option value={modelId}>{modelId} · current bound model</option>
2432
+ )}
2160
2433
  {movieModels.length > 0 && (
2161
2434
  <optgroup label="Bound by a ratified movie segment">
2162
2435
  {movieModels.map((id) => (
@@ -2176,12 +2449,12 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2176
2449
  <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2177
2450
  <label htmlFor="vi-moviecol-width">width</label>
2178
2451
  <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)} />
2452
+ value={width} onChange={(e) => { markManual("width"); setWidth(Number(e.target.value) || 0); }} />
2180
2453
  </div>
2181
2454
  <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2182
2455
  <label htmlFor="vi-moviecol-height">height</label>
2183
2456
  <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)} />
2457
+ value={height} onChange={(e) => { markManual("height"); setHeight(Number(e.target.value) || 0); }} />
2185
2458
  </div>
2186
2459
  {/* LENGTH ROW (operator 2026-08-13): fps | frames/segment | seconds —
2187
2460
  any two set the third; dimmed = derived, last-touched wins. This is
@@ -2189,20 +2462,20 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2189
2462
  <LengthRow
2190
2463
  idPrefix="vi-moviecol-len"
2191
2464
  fps={fps}
2192
- onFps={(n) => setFps(n)}
2465
+ onFps={(n) => { markManual("fps"); setFps(n); }}
2193
2466
  frames={framesDefault}
2194
- onFrames={setFramesDefault}
2467
+ onFrames={(v) => { markManual("frames"); setFramesDefault(v); }}
2195
2468
  framesPlaceholder="model default (81) / segment"
2196
2469
  />
2197
2470
  <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2198
2471
  <label htmlFor="vi-moviecol-steps">steps</label>
2199
2472
  <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)} />
2473
+ value={steps} placeholder="model default" onChange={(e) => { markManual("steps"); setSteps(e.target.value); }} />
2201
2474
  </div>
2202
2475
  <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2203
2476
  <label htmlFor="vi-moviecol-cfg">cfg</label>
2204
2477
  <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)} />
2478
+ value={cfg} placeholder="model default" onChange={(e) => { markManual("cfg"); setCfg(e.target.value); }} />
2206
2479
  </div>
2207
2480
  <div className="vi-knob" style={{ maxWidth: "9rem" }}>
2208
2481
  <label htmlFor="vi-moviecol-vram">VRAM budget GB</label>
@@ -2237,7 +2510,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2237
2510
  className="vi-knob-input"
2238
2511
  rows={2}
2239
2512
  style={{ flex: "1 1 24rem", resize: "vertical" }}
2240
- placeholder="Premise / logline — the producer writes the whole script: segments, prompts, lengths, joins"
2513
+ placeholder="Describe the whole production — Oracle creates the needed sequential prompt cards and settings"
2241
2514
  value={premise}
2242
2515
  onChange={(e) => setPremise(e.target.value)}
2243
2516
  disabled={producing}
@@ -2245,25 +2518,24 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2245
2518
  <button
2246
2519
  type="button"
2247
2520
  className="vi-btn vi-btn-accent"
2248
- disabled={producing || premise.trim() === ""}
2521
+ disabled={producing || (premise.trim() === "" && movieQuery.trim() === "")}
2249
2522
  onClick={() => void produceMovie()}
2250
2523
  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
2524
  >
2252
- {producing ? "Producing…" : "🎬 Produce"}
2525
+ {producing ? "Generating production…" : "✨ Generate production"}
2253
2526
  </button>
2254
2527
  </div>
2255
2528
  <label
2256
2529
  className="vi-comfy-hint"
2257
2530
  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."
2531
+ title="Render-time continuity refresh is not yet available on the orbit route. The default still joint continues from the previous last frame."
2259
2532
  >
2260
2533
  <input
2261
2534
  type="checkbox"
2262
- checked={continuityRefresh}
2263
- onChange={(e) => setContinuityRefresh(e.target.checked)}
2535
+ checked={false}
2536
+ disabled
2264
2537
  />
2265
- continuity refresh — rewrite each next segment from the previous segment's rendered
2266
- last frame
2538
+ render-time continuity refresh unavailable; still joints carry the last frame
2267
2539
  </label>
2268
2540
  {producerNote ? <span className="vi-knob-hint">{producerNote}</span> : null}
2269
2541
  {produceError ? (
@@ -2831,7 +3103,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2831
3103
  min={0}
2832
3104
  value={g.seed}
2833
3105
  placeholder="movie seed + index"
2834
- onChange={(e) => setGoalField(i, { seed: e.target.value })}
3106
+ onChange={(e) => { markGoalManual(g.key, "seed"); setGoalField(i, { seed: e.target.value }); }}
2835
3107
  />
2836
3108
  </div>
2837
3109
  <div className="vi-knob" style={{ maxWidth: "16rem" }}>
@@ -2840,11 +3112,14 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2840
3112
  id={`vi-movie-goalmodel-${g.key}`}
2841
3113
  className="vi-knob-select"
2842
3114
  value={g.modelId}
2843
- onChange={(e) => setGoalField(i, { modelId: e.target.value })}
3115
+ onChange={(e) => { markGoalManual(g.key, "model_id"); setGoalField(i, { modelId: e.target.value }); }}
2844
3116
  >
2845
3117
  <option value="">
2846
3118
  {modelId.trim() ? `Inherit — ${modelId.trim()}` : "Inherit — router picks"}
2847
3119
  </option>
3120
+ {g.modelId && !STUDIO_MODEL_IDS.includes(g.modelId) && (
3121
+ <option value={g.modelId}>{g.modelId} · current bound model</option>
3122
+ )}
2848
3123
  {movieModels.length > 0 && (
2849
3124
  <optgroup label="Bound by a ratified movie segment">
2850
3125
  {movieModels.map((id) => (
@@ -2880,7 +3155,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2880
3155
  max={100}
2881
3156
  value={g.steps}
2882
3157
  placeholder={steps.trim() || "inherit"}
2883
- onChange={(e) => setGoalField(i, { steps: e.target.value })}
3158
+ onChange={(e) => { markGoalManual(g.key, "steps"); setGoalField(i, { steps: e.target.value }); }}
2884
3159
  />
2885
3160
  <span className="vi-knob-hint">1–100 · blank = movie/model default</span>
2886
3161
  </div>
@@ -2895,7 +3170,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2895
3170
  step={0.5}
2896
3171
  value={g.cfg}
2897
3172
  placeholder={cfg.trim() || "inherit"}
2898
- onChange={(e) => setGoalField(i, { cfg: e.target.value })}
3173
+ onChange={(e) => { markGoalManual(g.key, "cfg"); setGoalField(i, { cfg: e.target.value }); }}
2899
3174
  />
2900
3175
  <span className="vi-knob-hint">0–20 · blank = movie/model default</span>
2901
3176
  </div>
@@ -2906,7 +3181,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2906
3181
  fps={fps}
2907
3182
  fpsFixedReason="Movie-level — one fps per movie; change it in movie settings above."
2908
3183
  frames={g.frames}
2909
- onFrames={(v) => setGoalField(i, { frames: v })}
3184
+ onFrames={(v) => { markGoalManual(g.key, "frames"); setGoalField(i, { frames: v }); }}
2910
3185
  framesPlaceholder={framesDefault.trim() || "movie default (81)"}
2911
3186
  />
2912
3187
  {Number(g.frames.trim() || framesDefault.trim()) > MODEL_FRAME_CEILING && (