@hugpy/video-intelligence-ui 0.1.0 → 0.3.0

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