@hugpy/video-intelligence-ui 0.3.3 → 0.3.5

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-C_av7KDW.js +4190 -0
  2. package/dist/assets/index-DMoT8G1q.css +1 -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 +320 -57
  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
@@ -1163,21 +1306,18 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1163
1306
  );
1164
1307
  const [producing, setProducing] = useState(false);
1165
1308
  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
1309
  const produceMovie = useCallback(async () => {
1174
- const p = premise.trim();
1310
+ const p = premise.trim() || movieQuery.trim();
1175
1311
  if (!p || producing) return;
1176
1312
  setProducing(true);
1177
1313
  setProduceError(null);
1178
1314
  const res = await request<unknown>(hugpyConfig.producerPlanUrl, {
1179
1315
  method: "POST",
1180
- body: JSON.stringify({ premise: p }),
1316
+ body: JSON.stringify({
1317
+ premise: p,
1318
+ model: assist.assistModel || undefined,
1319
+ start_image: startImage?.uri,
1320
+ }),
1181
1321
  headers: { "Content-Type": "application/json" },
1182
1322
  meta: { specKey: "studio", operation: "studio.producer.plan" },
1183
1323
  });
@@ -1197,23 +1337,33 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1197
1337
  const joint: JointMode =
1198
1338
  i === 0
1199
1339
  ? "still"
1200
- : s.joint === "cut" || s.joint === "still" || s.joint === "vace_extend"
1340
+ : s.joint === "cut" || s.joint === "still"
1201
1341
  ? (s.joint as JointMode)
1202
- : "vace_extend";
1342
+ : "still";
1203
1343
  return {
1204
1344
  ...blankGoal(joint),
1205
1345
  prompt: s.prompt,
1206
1346
  negative: s.negative ?? "",
1207
- frames: String(Math.max(1, Math.round(s.seconds * effFps))),
1347
+ modelId: s.settings?.model_id ?? "",
1348
+ frames: String(s.settings?.frames ?? Math.max(1, Math.round(s.seconds * effFps))),
1349
+ steps: s.settings?.steps == null ? "" : String(s.settings.steps),
1350
+ cfg: s.settings?.cfg == null ? "" : String(s.settings.cfg),
1351
+ seed: s.settings?.seed == null ? "" : String(s.settings.seed),
1208
1352
  };
1209
1353
  });
1210
1354
  if (goals.some((g) => g.prompt.trim() !== "")) {
1211
1355
  setGoalStash((st) => [...goals, ...st]);
1212
1356
  }
1213
1357
  setGoals(rows);
1358
+ const first = plan.segments[0]?.settings;
1359
+ if (first) {
1360
+ if (first.width != null && !manualSettings.current.includes("width")) setWidth(first.width);
1361
+ if (first.height != null && !manualSettings.current.includes("height")) setHeight(first.height);
1362
+ if (first.fps != null && !manualSettings.current.includes("fps")) setFps(first.fps);
1363
+ }
1214
1364
  setProducerNote([plan.title, plan.logline].filter(Boolean).join(" — "));
1215
- setContinuityRefresh(true);
1216
- }, [premise, producing, goals, fps]);
1365
+ }, [premise, movieQuery, producing, goals, fps, assist.assistModel, startImage,
1366
+ setWidth, setHeight, setFps]);
1217
1367
 
1218
1368
  const setSegmentCount = useCallback(
1219
1369
  (n: number) => {
@@ -1328,6 +1478,42 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1328
1478
  // name this ref minted may be replaced by the next Generate (operator
1329
1479
  // 2026-08-13: "purely a product of the same generation method").
1330
1480
  const autoTitleRef = useRef("");
1481
+ const planGoalPrompt = useCallback(async (
1482
+ i: number, text: string,
1483
+ coordinated?: { seed?: number; frames?: number; joint_mode?: string },
1484
+ ) => {
1485
+ if (settingsMode !== "oracle") return;
1486
+ const g = goals[i];
1487
+ if (!g) return;
1488
+ const capability = i === 0
1489
+ ? (startImage ? "i2v" : "t2v")
1490
+ : ((coordinated?.joint_mode || g.joint) === "cut" ? "t2v" : "i2v");
1491
+ const explicit = [...new Set([
1492
+ ...manualSettings.current,
1493
+ ...(goalManualSettings.current[g.key] || []),
1494
+ ...(coordinated?.seed != null ? ["seed"] : []),
1495
+ ...(coordinated?.frames != null ? ["frames"] : []),
1496
+ ])];
1497
+ const planned = await planStudioSettings(capability, text, {
1498
+ model_id: g.modelId || undefined,
1499
+ width, height, fps,
1500
+ frames: coordinated?.frames ?? (g.frames ? Number(g.frames) : framesDefault ? Number(framesDefault) : undefined),
1501
+ steps: g.steps ? Number(g.steps) : steps ? Number(steps) : undefined,
1502
+ cfg: g.cfg ? Number(g.cfg) : cfg ? Number(cfg) : undefined,
1503
+ seed: coordinated?.seed ?? (g.seed ? Number(g.seed) : seed),
1504
+ }, explicit);
1505
+ setGoalField(i, {
1506
+ modelId: planned.model_id || "",
1507
+ frames: planned.frames == null ? g.frames : String(planned.frames),
1508
+ steps: planned.steps == null ? g.steps : String(planned.steps),
1509
+ cfg: planned.cfg == null ? g.cfg : String(planned.cfg),
1510
+ seed: planned.seed == null ? g.seed : String(planned.seed),
1511
+ });
1512
+ if (planned.width != null) setWidth(planned.width);
1513
+ if (planned.height != null) setHeight(planned.height);
1514
+ if (planned.fps != null) setFps(planned.fps);
1515
+ }, [settingsMode, goals, startImage, width, height, fps, framesDefault, steps, cfg,
1516
+ seed, setGoalField, setWidth, setHeight, setFps]);
1331
1517
 
1332
1518
  const runRowAssist = useCallback(
1333
1519
  (i: number, mode: "detail" | "generate") => {
@@ -1335,10 +1521,14 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1335
1521
  if (!g) return;
1336
1522
  void (async () => {
1337
1523
  let fresh = "";
1338
- await assist.runAssist(mode, g.prompt, (p) => {
1524
+ await assist.runAssist(mode, mode === "generate" ? "" : g.prompt, (p) => {
1339
1525
  fresh = p;
1340
1526
  setGoalField(i, { prompt: p });
1341
1527
  }, { context: rowContext(i) });
1528
+ if (fresh.trim() && settingsMode === "oracle") {
1529
+ try { await planGoalPrompt(i, fresh); }
1530
+ catch (err) { setFormMsg(err instanceof Error ? err.message : "Oracle planning failed."); }
1531
+ }
1342
1532
  if (mode !== "generate" || !fresh.trim()) return;
1343
1533
  // NEGATIVE RIDES ALONG (operator 2026-08-13): the generated exclusion
1344
1534
  // list APPENDS to this goal's typed negative, never clobbers it
@@ -1371,7 +1561,8 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1371
1561
  intentText.current[g.key] = "";
1372
1562
  setIntents((prev) => ({ ...prev, [g.key]: null }));
1373
1563
  },
1374
- [assist, goals, rowContext, setGoalField, movieTitle, setMovieTitle],
1564
+ [assist, goals, rowContext, setGoalField, movieTitle, setMovieTitle,
1565
+ settingsMode, planGoalPrompt],
1375
1566
  );
1376
1567
 
1377
1568
  // ── negative generation (§1b) — an exclusion list for THIS shot ──
@@ -1444,6 +1635,25 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1444
1635
  };
1445
1636
  }),
1446
1637
  );
1638
+ let oracleNotice: string | null = null;
1639
+ if (settingsMode === "oracle") {
1640
+ for (const key of applied) {
1641
+ const index = goals.findIndex((g) => g.key === key);
1642
+ const hit = bySeg.get(key);
1643
+ if (index < 0 || !hit) continue;
1644
+ try {
1645
+ const k = hit.knobs || {};
1646
+ await planGoalPrompt(index, hit.prompt, {
1647
+ seed: typeof k.seed === "number" ? k.seed : undefined,
1648
+ frames: typeof k.frames === "number" ? k.frames : undefined,
1649
+ joint_mode: typeof k.joint_mode === "string" ? k.joint_mode : undefined,
1650
+ });
1651
+ } catch (err) {
1652
+ oracleNotice = err instanceof Error ? err.message : "Oracle planning failed.";
1653
+ break;
1654
+ }
1655
+ }
1656
+ }
1447
1657
  for (const key of applied) {
1448
1658
  intentText.current[key] = "";
1449
1659
  }
@@ -1458,7 +1668,10 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1458
1668
  // this one.
1459
1669
  setCoordination(readCoordinationReport(res.coordination));
1460
1670
  setCoordSettled(new Set());
1461
- setSpreadNotice(spreadNoticeLines(res, applied));
1671
+ setSpreadNotice([
1672
+ ...spreadNoticeLines(res, applied),
1673
+ ...(oracleNotice ? [`Oracle settings: ${oracleNotice}`] : []),
1674
+ ]);
1462
1675
  }, [
1463
1676
  assist,
1464
1677
  goalInputs,
@@ -1470,6 +1683,8 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1470
1683
  selectedKeys,
1471
1684
  selectedProfile,
1472
1685
  steeringSeed,
1686
+ settingsMode,
1687
+ planGoalPrompt,
1473
1688
  ]);
1474
1689
 
1475
1690
  // ── GROUP ACTION: negatives for the selected rows ──
@@ -1655,6 +1870,8 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1655
1870
  });
1656
1871
 
1657
1872
  const body: Record<string, unknown> = {
1873
+ settings_mode: settingsMode,
1874
+ explicit_settings: manualSettings.current,
1658
1875
  resolution: { width, height, fps },
1659
1876
  frames: framesDefNum,
1660
1877
  seed,
@@ -1684,13 +1901,9 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1684
1901
  reference_images:
1685
1902
  !selectedProfile && references.length ? references.map((r) => r.uri) : undefined,
1686
1903
  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,
1691
1904
  };
1692
1905
  return { body };
1693
- }, [goals, width, height, fps, seed, vramBudget, modelId, steps, cfg, negative, project, movieTitle, startImage, references, selectedProfile, continuityRefresh]);
1906
+ }, [goals, width, height, fps, seed, vramBudget, modelId, steps, cfg, negative, project, movieTitle, startImage, references, selectedProfile, settingsMode]);
1694
1907
 
1695
1908
  // NAME NUDGE (k91) — set when a submit was held back only because the movie has no
1696
1909
  // name. It is a warning, not a gate: the run row then offers "Generate without a
@@ -1701,7 +1914,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1701
1914
  const [warnUnnamed, setWarnUnnamed] = useWarnUnnamedPref("cinema");
1702
1915
 
1703
1916
  const onGenerate = useCallback(
1704
- (skipNameCheck = false, titleOverride?: string) => {
1917
+ async (skipNameCheck = false, titleOverride?: string) => {
1705
1918
  const built = buildBody(titleOverride);
1706
1919
  if ("err" in built) {
1707
1920
  setFormMsg(built.err);
@@ -1718,9 +1931,36 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1718
1931
  setNameWarn(false);
1719
1932
  setFormMsg(null);
1720
1933
  if (titleOverride && titleOverride.trim()) setMovieTitle(titleOverride.trim());
1721
- job.run(built.body);
1934
+ let body = built.body;
1935
+ if (settingsMode === "oracle") {
1936
+ try {
1937
+ body = await planStudioMovie(body);
1938
+ const resolution = body.resolution as { width?: number; height?: number; fps?: number } | undefined;
1939
+ if (resolution?.width != null) setWidth(resolution.width);
1940
+ if (resolution?.height != null) setHeight(resolution.height);
1941
+ if (resolution?.fps != null) setFps(resolution.fps);
1942
+ const plannedGoals = body.goals as Array<Record<string, unknown>> | undefined;
1943
+ if (plannedGoals) setGoals((prev) => prev.map((g, i) => {
1944
+ const p = plannedGoals[i];
1945
+ if (!p) return g;
1946
+ return {
1947
+ ...g,
1948
+ modelId: typeof p.model_id === "string" ? p.model_id : g.modelId,
1949
+ steps: typeof p.steps === "number" ? String(p.steps) : g.steps,
1950
+ cfg: typeof p.cfg === "number" ? String(p.cfg) : g.cfg,
1951
+ seed: typeof p.seed === "number" ? String(p.seed) : g.seed,
1952
+ frames: typeof p.frames === "number" ? String(p.frames) : g.frames,
1953
+ };
1954
+ }));
1955
+ } catch (err) {
1956
+ setFormMsg(err instanceof Error ? err.message : "Oracle planning failed.");
1957
+ return;
1958
+ }
1959
+ }
1960
+ job.run(body);
1722
1961
  },
1723
- [buildBody, job, movieTitle, setMovieTitle, warnUnnamed],
1962
+ [buildBody, job, movieTitle, setMovieTitle, warnUnnamed, settingsMode,
1963
+ setWidth, setHeight, setFps, setGoals],
1724
1964
  );
1725
1965
 
1726
1966
  // SESSION LIBRARY PUSH (operator 2026-08-13): a finished movie's outputs
@@ -1920,9 +2160,12 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1920
2160
  className="vi-knob-select"
1921
2161
  value={modelId}
1922
2162
  disabled={disableSubmit}
1923
- onChange={(e) => setModelId(e.target.value)}
2163
+ onChange={(e) => { markManual("model_id"); setModelId(e.target.value); }}
1924
2164
  >
1925
2165
  <option value="">Auto — router picks by capability/budget</option>
2166
+ {modelId && !STUDIO_MODEL_IDS.includes(modelId) && (
2167
+ <option value={modelId}>{modelId} · current bound model</option>
2168
+ )}
1926
2169
  {movieModels.length > 0 && (
1927
2170
  <optgroup label="Bound by a ratified movie segment">
1928
2171
  {movieModels.map((id) => (
@@ -1973,7 +2216,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1973
2216
  value={steps}
1974
2217
  placeholder="model default"
1975
2218
  disabled={disableSubmit}
1976
- onChange={(e) => setSteps(e.target.value)}
2219
+ onChange={(e) => { markManual("steps"); setSteps(e.target.value); }}
1977
2220
  />
1978
2221
  </div>
1979
2222
  <div className="vi-knob">
@@ -1988,7 +2231,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1988
2231
  value={cfg}
1989
2232
  placeholder="model default"
1990
2233
  disabled={disableSubmit}
1991
- onChange={(e) => setCfg(e.target.value)}
2234
+ onChange={(e) => { markManual("cfg"); setCfg(e.target.value); }}
1992
2235
  />
1993
2236
  </div>
1994
2237
 
@@ -2004,7 +2247,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2004
2247
  step={8}
2005
2248
  value={width}
2006
2249
  disabled={disableSubmit}
2007
- onChange={(e) => setWidth(Number(e.target.value) || 0)}
2250
+ onChange={(e) => { markManual("width"); setWidth(Number(e.target.value) || 0); }}
2008
2251
  />
2009
2252
  </div>
2010
2253
  <div className="vi-knob">
@@ -2017,7 +2260,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2017
2260
  step={8}
2018
2261
  value={height}
2019
2262
  disabled={disableSubmit}
2020
- onChange={(e) => setHeight(Number(e.target.value) || 0)}
2263
+ onChange={(e) => { markManual("height"); setHeight(Number(e.target.value) || 0); }}
2021
2264
  />
2022
2265
  </div>
2023
2266
  <div className="vi-knob">
@@ -2035,7 +2278,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2035
2278
  ? `Ratified movie envelope: ${movieGeometry.width}×${movieGeometry.height} @ ${movieGeometry.fps}fps (“${movieGeometry.presetTitle}”).`
2036
2279
  : undefined
2037
2280
  }
2038
- onChange={(e) => setFps(Number(e.target.value) || 0)}
2281
+ onChange={(e) => { markManual("fps"); setFps(Number(e.target.value) || 0); }}
2039
2282
  />
2040
2283
  {movieGeomOutOfEnvelope && (
2041
2284
  <span className="vi-knob-flag">
@@ -2056,7 +2299,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2056
2299
  min={0}
2057
2300
  value={seed}
2058
2301
  disabled={disableSubmit}
2059
- onChange={(e) => setSeed(Number(e.target.value) || 0)}
2302
+ onChange={(e) => { markManual("seed"); setSeed(Number(e.target.value) || 0); }}
2060
2303
  />
2061
2304
  <span className="vi-knob-hint">A segment with no seed uses base seed + its index.</span>
2062
2305
  </div>
@@ -2101,6 +2344,12 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2101
2344
 
2102
2345
  return (
2103
2346
  <div className="vi-studio-generate">
2347
+ {clipImportBackup && (
2348
+ <button type="button" className="vi-btn vi-btn-sm vi-btn-ghost"
2349
+ onClick={restorePreviousCinemaDraft}>
2350
+ Restore previous Cinema draft
2351
+ </button>
2352
+ )}
2104
2353
  {/* The movie Settings panel lives in the left-sidebar Settings tab (portaled into
2105
2354
  settingsHost, like the Clip surface's knobGrid): templates + model pin + sampler
2106
2355
  + geometry/seed/budget/project; null until that panel mounts. */}
@@ -2147,6 +2396,15 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2147
2396
  miss (and absent when no host mounts). This expander mirrors the
2148
2397
  SAME state — two views, one truth, like the Clip card. ── */}
2149
2398
  <PromptCardSettings label="movie settings — model · size · fps · steps · cfg · budget">
2399
+ <div className="vi-knob vi-studio-knob-wide">
2400
+ <label htmlFor="vi-movie-settings-mode">settings</label>
2401
+ <select id="vi-movie-settings-mode" className="vi-knob-select"
2402
+ value={settingsMode}
2403
+ onChange={(e) => setSettingsMode(e.target.value as "oracle" | "manual")}>
2404
+ <option value="oracle">Oracle-generated (default)</option>
2405
+ <option value="manual">Manual</option>
2406
+ </select>
2407
+ </div>
2150
2408
  <div className="vi-knob vi-studio-knob-wide" style={{ minWidth: "16rem" }}>
2151
2409
  <label htmlFor="vi-moviecol-model">model — whole movie</label>
2152
2410
  <select
@@ -2154,9 +2412,12 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2154
2412
  className="vi-knob-select"
2155
2413
  value={modelId}
2156
2414
  disabled={disableSubmit}
2157
- onChange={(e) => setModelId(e.target.value)}
2415
+ onChange={(e) => { markManual("model_id"); setModelId(e.target.value); }}
2158
2416
  >
2159
2417
  <option value="">Auto — router picks by capability/budget</option>
2418
+ {modelId && !STUDIO_MODEL_IDS.includes(modelId) && (
2419
+ <option value={modelId}>{modelId} · current bound model</option>
2420
+ )}
2160
2421
  {movieModels.length > 0 && (
2161
2422
  <optgroup label="Bound by a ratified movie segment">
2162
2423
  {movieModels.map((id) => (
@@ -2176,12 +2437,12 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2176
2437
  <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2177
2438
  <label htmlFor="vi-moviecol-width">width</label>
2178
2439
  <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)} />
2440
+ value={width} onChange={(e) => { markManual("width"); setWidth(Number(e.target.value) || 0); }} />
2180
2441
  </div>
2181
2442
  <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2182
2443
  <label htmlFor="vi-moviecol-height">height</label>
2183
2444
  <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)} />
2445
+ value={height} onChange={(e) => { markManual("height"); setHeight(Number(e.target.value) || 0); }} />
2185
2446
  </div>
2186
2447
  {/* LENGTH ROW (operator 2026-08-13): fps | frames/segment | seconds —
2187
2448
  any two set the third; dimmed = derived, last-touched wins. This is
@@ -2189,20 +2450,20 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2189
2450
  <LengthRow
2190
2451
  idPrefix="vi-moviecol-len"
2191
2452
  fps={fps}
2192
- onFps={(n) => setFps(n)}
2453
+ onFps={(n) => { markManual("fps"); setFps(n); }}
2193
2454
  frames={framesDefault}
2194
- onFrames={setFramesDefault}
2455
+ onFrames={(v) => { markManual("frames"); setFramesDefault(v); }}
2195
2456
  framesPlaceholder="model default (81) / segment"
2196
2457
  />
2197
2458
  <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2198
2459
  <label htmlFor="vi-moviecol-steps">steps</label>
2199
2460
  <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)} />
2461
+ value={steps} placeholder="model default" onChange={(e) => { markManual("steps"); setSteps(e.target.value); }} />
2201
2462
  </div>
2202
2463
  <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2203
2464
  <label htmlFor="vi-moviecol-cfg">cfg</label>
2204
2465
  <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)} />
2466
+ value={cfg} placeholder="model default" onChange={(e) => { markManual("cfg"); setCfg(e.target.value); }} />
2206
2467
  </div>
2207
2468
  <div className="vi-knob" style={{ maxWidth: "9rem" }}>
2208
2469
  <label htmlFor="vi-moviecol-vram">VRAM budget GB</label>
@@ -2237,7 +2498,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2237
2498
  className="vi-knob-input"
2238
2499
  rows={2}
2239
2500
  style={{ flex: "1 1 24rem", resize: "vertical" }}
2240
- placeholder="Premise / logline — the producer writes the whole script: segments, prompts, lengths, joins"
2501
+ placeholder="Describe the whole production — Oracle creates the needed sequential prompt cards and settings"
2241
2502
  value={premise}
2242
2503
  onChange={(e) => setPremise(e.target.value)}
2243
2504
  disabled={producing}
@@ -2245,25 +2506,24 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2245
2506
  <button
2246
2507
  type="button"
2247
2508
  className="vi-btn vi-btn-accent"
2248
- disabled={producing || premise.trim() === ""}
2509
+ disabled={producing || (premise.trim() === "" && movieQuery.trim() === "")}
2249
2510
  onClick={() => void produceMovie()}
2250
2511
  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
2512
  >
2252
- {producing ? "Producing…" : "🎬 Produce"}
2513
+ {producing ? "Generating production…" : "✨ Generate production"}
2253
2514
  </button>
2254
2515
  </div>
2255
2516
  <label
2256
2517
  className="vi-comfy-hint"
2257
2518
  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."
2519
+ title="Render-time continuity refresh is not yet available on the orbit route. The default still joint continues from the previous last frame."
2259
2520
  >
2260
2521
  <input
2261
2522
  type="checkbox"
2262
- checked={continuityRefresh}
2263
- onChange={(e) => setContinuityRefresh(e.target.checked)}
2523
+ checked={false}
2524
+ disabled
2264
2525
  />
2265
- continuity refresh — rewrite each next segment from the previous segment's rendered
2266
- last frame
2526
+ render-time continuity refresh unavailable; still joints carry the last frame
2267
2527
  </label>
2268
2528
  {producerNote ? <span className="vi-knob-hint">{producerNote}</span> : null}
2269
2529
  {produceError ? (
@@ -2831,7 +3091,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2831
3091
  min={0}
2832
3092
  value={g.seed}
2833
3093
  placeholder="movie seed + index"
2834
- onChange={(e) => setGoalField(i, { seed: e.target.value })}
3094
+ onChange={(e) => { markGoalManual(g.key, "seed"); setGoalField(i, { seed: e.target.value }); }}
2835
3095
  />
2836
3096
  </div>
2837
3097
  <div className="vi-knob" style={{ maxWidth: "16rem" }}>
@@ -2840,11 +3100,14 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2840
3100
  id={`vi-movie-goalmodel-${g.key}`}
2841
3101
  className="vi-knob-select"
2842
3102
  value={g.modelId}
2843
- onChange={(e) => setGoalField(i, { modelId: e.target.value })}
3103
+ onChange={(e) => { markGoalManual(g.key, "model_id"); setGoalField(i, { modelId: e.target.value }); }}
2844
3104
  >
2845
3105
  <option value="">
2846
3106
  {modelId.trim() ? `Inherit — ${modelId.trim()}` : "Inherit — router picks"}
2847
3107
  </option>
3108
+ {g.modelId && !STUDIO_MODEL_IDS.includes(g.modelId) && (
3109
+ <option value={g.modelId}>{g.modelId} · current bound model</option>
3110
+ )}
2848
3111
  {movieModels.length > 0 && (
2849
3112
  <optgroup label="Bound by a ratified movie segment">
2850
3113
  {movieModels.map((id) => (
@@ -2880,7 +3143,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2880
3143
  max={100}
2881
3144
  value={g.steps}
2882
3145
  placeholder={steps.trim() || "inherit"}
2883
- onChange={(e) => setGoalField(i, { steps: e.target.value })}
3146
+ onChange={(e) => { markGoalManual(g.key, "steps"); setGoalField(i, { steps: e.target.value }); }}
2884
3147
  />
2885
3148
  <span className="vi-knob-hint">1–100 · blank = movie/model default</span>
2886
3149
  </div>
@@ -2895,7 +3158,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2895
3158
  step={0.5}
2896
3159
  value={g.cfg}
2897
3160
  placeholder={cfg.trim() || "inherit"}
2898
- onChange={(e) => setGoalField(i, { cfg: e.target.value })}
3161
+ onChange={(e) => { markGoalManual(g.key, "cfg"); setGoalField(i, { cfg: e.target.value }); }}
2899
3162
  />
2900
3163
  <span className="vi-knob-hint">0–20 · blank = movie/model default</span>
2901
3164
  </div>
@@ -2906,7 +3169,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2906
3169
  fps={fps}
2907
3170
  fpsFixedReason="Movie-level — one fps per movie; change it in movie settings above."
2908
3171
  frames={g.frames}
2909
- onFrames={(v) => setGoalField(i, { frames: v })}
3172
+ onFrames={(v) => { markGoalManual(g.key, "frames"); setGoalField(i, { frames: v }); }}
2910
3173
  framesPlaceholder={framesDefault.trim() || "movie default (81)"}
2911
3174
  />
2912
3175
  {Number(g.frames.trim() || framesDefault.trim()) > MODEL_FRAME_CEILING && (