@hugpy/video-intelligence-ui 0.1.1 → 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 (102) 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 +901 -366
  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 +6 -3
  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 +10 -17
  86. package/src/stations/studio/StudioMovieComposer.tsx +296 -59
  87. package/src/stations/studio/StudioPlane.tsx +35 -3
  88. package/src/stations/studio/StudioViewer.tsx +116 -14
  89. package/src/stations/studio/coordinationBadge.tsx +206 -0
  90. package/src/stations/studio/identityUpload.ts +66 -0
  91. package/src/stations/studio/promptCard.tsx +362 -59
  92. package/src/stations/studio/useIdentityCreateJob.ts +184 -0
  93. package/src/stations/studio/useIdentityProfiles.ts +66 -0
  94. package/src/stations/useScriptFirstRun.ts +596 -0
  95. package/src/style/app.css +157 -0
  96. package/src/video/LengthRow.tsx +189 -0
  97. package/src/video/coordinationReport.ts +266 -0
  98. package/src/video/jobClock.ts +104 -0
  99. package/src/video/spreadAssist.ts +26 -0
  100. package/src/video/useMediaJobs.ts +26 -0
  101. package/src/video/useOperatorHistory.ts +5 -0
  102. package/src/video/useToks.ts +193 -0
@@ -68,6 +68,7 @@ import {
68
68
  import { getSessionId } from "../../session";
69
69
  import { addToLibrary, useMediaLibrary } from "../../video/mediaLibrary";
70
70
  import { UnnamedWarnPanel, WarnUnnamedToggle, useWarnUnnamedPref } from "../../video/UnnamedWarn";
71
+ import { LengthRow } from "../../video/LengthRow";
71
72
  import { GoalComposer } from "../GoalComposer";
72
73
  import { usePromptAssist, type AssistExtras } from "../../video/usePromptAssist";
73
74
  import { PromptAssistButtons } from "../../video/PromptAssistButtons";
@@ -98,6 +99,16 @@ import {
98
99
  type PromptFieldMode,
99
100
  type SpreadGoalInput,
100
101
  } from "../../video/spreadAssist";
102
+ // k121 COORDINATION REVIEW — the words-vs-knobs check that rides every spread.
103
+ // Reader + badge live in their own files so this component stays an editor.
104
+ import {
105
+ knobPatch,
106
+ readCoordinationReport,
107
+ segmentReview,
108
+ type CoordinationDecision,
109
+ type CoordinationReport,
110
+ } from "../../video/coordinationReport";
111
+ import { CoordinationBadge, CoordinationSummary, decisionKey } from "./coordinationBadge";
101
112
  // B3: the visual spliced row + branch-frame scrubber (the picture half). Pure math
102
113
  // lives in movieTimeline.ts; both are new files beside this composer.
103
114
  import { StudioSplicedRow, StudioPendingRow } from "./StudioSplicedRow";
@@ -231,6 +242,11 @@ const progressSegmentSchema = z
231
242
  prompt: z.string().nullable().optional(),
232
243
  status: z.string().nullable().optional(),
233
244
  resumed: z.boolean().nullable().optional(),
245
+ // k120 slice 2 — set when continuity refresh rewrote this segment's prompt:
246
+ // the authored original + the runner's one-line note (what the previous
247
+ // segment's closing frame actually showed, or why the rewrite was skipped).
248
+ prompt_authored: z.string().nullable().optional(),
249
+ refresh_note: z.string().nullable().optional(),
234
250
  })
235
251
  .passthrough();
236
252
 
@@ -312,6 +328,25 @@ const MODEL_FRAME_CEILING = 81;
312
328
  function newGoalKey(): string {
313
329
  return `goal_${nextSessionSeq("studio.movie.goalKeySeq")}`;
314
330
  }
331
+ // ── CINEMA PRODUCER (k120 slice 1) — POST /video/producer/plan reply shape.
332
+ // The server owns validation/repair; this guard only refuses a reply the row
333
+ // mapper below could not consume.
334
+ const producerPlanSchema = z.object({
335
+ title: z.string().optional(),
336
+ logline: z.string().optional(),
337
+ total_seconds: z.number().optional(),
338
+ segments: z
339
+ .array(
340
+ z.object({
341
+ prompt: z.string(),
342
+ negative: z.string().optional(),
343
+ seconds: z.number(),
344
+ joint: z.string(),
345
+ }),
346
+ )
347
+ .min(1),
348
+ });
349
+
315
350
  function blankGoal(joint: JointMode = "still"): GoalRow {
316
351
  return {
317
352
  key: newGoalKey(),
@@ -789,6 +824,11 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
789
824
  // The non-destructive result notice: what changed, what did NOT, every warning.
790
825
  // Dismissible; it never edits anything on its own.
791
826
  const [spreadNotice, setSpreadNotice] = useState<string[] | null>(null);
827
+ // k121: the last spread's coordination review, and the proposals the operator
828
+ // has already answered (accepted or rejected). Both are VIEW state — the
829
+ // report is a fact about the last generate, and re-generating replaces it.
830
+ const [coordination, setCoordination] = useState<CoordinationReport | null>(null);
831
+ const [coordSettled, setCoordSettled] = useState<Set<string>>(() => new Set());
792
832
 
793
833
  const selectedCount = useMemo(
794
834
  () => goals.reduce((n, g) => (selectedKeys.has(g.key) ? n + 1 : n), 0),
@@ -1026,6 +1066,28 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1026
1066
  [],
1027
1067
  );
1028
1068
 
1069
+ // k121 ACCEPT / REJECT for a `proposed` coordination decision. A proposal is
1070
+ // anything that REMOVES coordination (dropping a carry, shortening a clip,
1071
+ // swapping an identity) or that spends real work (an IDENTITY_CAPTURE) — the
1072
+ // backend deliberately never applies those itself, so this is where a human
1073
+ // says yes. Both answers are recorded, so a rejected proposal stops asking
1074
+ // rather than nagging on every re-render.
1075
+ const acceptCoordination = useCallback(
1076
+ (d: CoordinationDecision) => {
1077
+ const patch = knobPatch(d);
1078
+ if (patch) {
1079
+ setGoals((prev) =>
1080
+ prev.map((g) => (g.key === d.segment_id ? { ...g, ...(patch as Partial<GoalRow>) } : g)),
1081
+ );
1082
+ }
1083
+ setCoordSettled((prev) => new Set(prev).add(decisionKey(d)));
1084
+ },
1085
+ [],
1086
+ );
1087
+ const rejectCoordination = useCallback((d: CoordinationDecision) => {
1088
+ setCoordSettled((prev) => new Set(prev).add(decisionKey(d)));
1089
+ }, []);
1090
+
1029
1091
  // SPLIT-TO-FIT (operator 2026-08-13): a length ask beyond the model ceiling
1030
1092
  // becomes VISIBLE continuation segments — motion-carried (vace_extend),
1031
1093
  // length-distributed, each editable — never submit-time magic. The
@@ -1080,21 +1142,98 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1080
1142
  }, []);
1081
1143
 
1082
1144
  // ── SEGMENTS STEPPER (spec §2) — say how many shots this movie has, up front ──
1083
- // Materializes rows to reach N, TRUNCATES from the tail to shrink to N. Truncation
1084
- // is the branch scrubber's own precedent (onBranchFromSegment below drops the rows
1085
- // after the branch point rather than refusing) — and like it, the affordance names
1086
- // what it will drop instead of dropping it silently. New rows inherit the joint of
1087
- // the last row so a movie built as "extend motion" stays that way as it grows.
1088
- const MAX_SEGMENTS = 24;
1089
- const setSegmentCount = useCallback((n: number) => {
1090
- if (!Number.isInteger(n) || n < 1 || n > MAX_SEGMENTS) return;
1091
- setGoals((prev) => {
1092
- if (n === prev.length) return prev;
1093
- if (n < prev.length) return prev.slice(0, n);
1094
- const joint: JointMode = prev[prev.length - 1]?.joint ?? "still";
1095
- return [...prev, ...Array.from({ length: n - prev.length }, () => blankGoal(joint))];
1145
+ // Materializes rows to reach N; shrinking to N PARKS the tail rows in a
1146
+ // session-scoped stash instead of deleting them (operator 2026-08-27: 3 edited
1147
+ // segments → count 1 → count 5 must bring the 3 edited rows back, not mint
1148
+ // blanks). Growing revives parked rows first — they keep their keys, so
1149
+ // per-key sidecars (ticks, field modes, intents) re-attach — and only then
1150
+ // mints blanks, which inherit the joint of the last row so a movie built as
1151
+ // "extend motion" stays that way as it grows. No upper cap: the count takes
1152
+ // any integer ≥ 1.
1153
+ const [goalStash, setGoalStash] = useSessionState<GoalRow[]>("studio.movie.goalStash", () => []);
1154
+
1155
+ // ── 🎬 PRODUCER (k120 slice 1) — premise → /video/producer/plan → rows. ──
1156
+ // The plan's per-segment seconds convert to frames against the CURRENT movie
1157
+ // fps (frames is what rides the wire; fps is movie-level). Existing edited
1158
+ // rows are parked in the stash first, never deleted.
1159
+ const [premise, setPremise] = useSessionState<string>("studio.movie.premise", "");
1160
+ const [producerNote, setProducerNote] = useSessionState<string>(
1161
+ "studio.movie.producerNote",
1162
+ "",
1163
+ );
1164
+ const [producing, setProducing] = useState(false);
1165
+ const [produceError, setProduceError] = useState<string | null>(null);
1166
+ // k120 slice 2 — render-time continuity: the runner rewrites each next segment
1167
+ // from the previous segment's ACTUAL closing frame. Auto-enabled by a
1168
+ // successful Produce (a scripted movie wants it); always operator-untickable.
1169
+ const [continuityRefresh, setContinuityRefresh] = useSessionState<boolean>(
1170
+ "studio.movie.continuityRefresh",
1171
+ false,
1172
+ );
1173
+ const produceMovie = useCallback(async () => {
1174
+ const p = premise.trim();
1175
+ if (!p || producing) return;
1176
+ setProducing(true);
1177
+ setProduceError(null);
1178
+ const res = await request<unknown>(hugpyConfig.producerPlanUrl, {
1179
+ method: "POST",
1180
+ body: JSON.stringify({ premise: p }),
1181
+ headers: { "Content-Type": "application/json" },
1182
+ meta: { specKey: "studio", operation: "studio.producer.plan" },
1096
1183
  });
1097
- }, []);
1184
+ setProducing(false);
1185
+ if (!res.ok) {
1186
+ setProduceError(describeAppError(errorOf(res)));
1187
+ return;
1188
+ }
1189
+ const parsed = producerPlanSchema.safeParse(okValue(res));
1190
+ if (!parsed.success) {
1191
+ setProduceError("The producer reply did not match the expected plan shape.");
1192
+ return;
1193
+ }
1194
+ const plan = parsed.data;
1195
+ const effFps = fps > 0 ? fps : 24;
1196
+ const rows = plan.segments.map((s, i) => {
1197
+ const joint: JointMode =
1198
+ i === 0
1199
+ ? "still"
1200
+ : s.joint === "cut" || s.joint === "still" || s.joint === "vace_extend"
1201
+ ? (s.joint as JointMode)
1202
+ : "vace_extend";
1203
+ return {
1204
+ ...blankGoal(joint),
1205
+ prompt: s.prompt,
1206
+ negative: s.negative ?? "",
1207
+ frames: String(Math.max(1, Math.round(s.seconds * effFps))),
1208
+ };
1209
+ });
1210
+ if (goals.some((g) => g.prompt.trim() !== "")) {
1211
+ setGoalStash((st) => [...goals, ...st]);
1212
+ }
1213
+ setGoals(rows);
1214
+ setProducerNote([plan.title, plan.logline].filter(Boolean).join(" — "));
1215
+ setContinuityRefresh(true);
1216
+ }, [premise, producing, goals, fps]);
1217
+
1218
+ const setSegmentCount = useCallback(
1219
+ (n: number) => {
1220
+ if (!Number.isInteger(n) || n < 1 || n === goals.length) return;
1221
+ if (n < goals.length) {
1222
+ const cut = goals.slice(n);
1223
+ setGoalStash((st) => [...cut, ...st]);
1224
+ setGoals(goals.slice(0, n));
1225
+ return;
1226
+ }
1227
+ const revived = goalStash.slice(0, n - goals.length);
1228
+ if (revived.length) setGoalStash((st) => st.slice(revived.length));
1229
+ const joint: JointMode = goals[goals.length - 1]?.joint ?? "still";
1230
+ const fresh = Array.from({ length: n - goals.length - revived.length }, () =>
1231
+ blankGoal(joint),
1232
+ );
1233
+ setGoals([...goals, ...revived, ...fresh]);
1234
+ },
1235
+ [goals, goalStash],
1236
+ );
1098
1237
 
1099
1238
  // ── the row → wire projection ──
1100
1239
  // ONE place turns editor rows into typed segment references, so the per-row assist
@@ -1283,7 +1422,26 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1283
1422
  // An explicit Generate on a SELECTED row is meant to replace that row's prose —
1284
1423
  // that is the point of selecting it. A blank returned negative still falls back
1285
1424
  // to what the user wrote rather than blanking it.
1286
- return { ...g, prompt: hit.prompt, negative: hit.negative || g.negative };
1425
+ //
1426
+ // k121: the row also carries the KNOBS the coordination review turned for
1427
+ // it (ratchet-safe ones only — the backend never removes carry on its own).
1428
+ // ``hit.knobs`` is MECHANICS: no prose from any other row reaches here.
1429
+ const k = hit.knobs ?? {};
1430
+ const applyKnobs: Partial<GoalRow> = {};
1431
+ if (typeof k.joint_mode === "string") {
1432
+ applyKnobs.joint = k.joint_mode as GoalRow["joint"];
1433
+ }
1434
+ if (typeof k.seed === "number") applyKnobs.seed = String(k.seed);
1435
+ if (typeof k.frames === "number") applyKnobs.frames = String(k.frames);
1436
+ if ("branch_frame" in k) {
1437
+ applyKnobs.branchFrame = k.branch_frame == null ? "" : String(k.branch_frame);
1438
+ }
1439
+ return {
1440
+ ...g,
1441
+ prompt: hit.prompt,
1442
+ negative: hit.negative || g.negative,
1443
+ ...applyKnobs,
1444
+ };
1287
1445
  }),
1288
1446
  );
1289
1447
  for (const key of applied) {
@@ -1295,6 +1453,11 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1295
1453
  return next;
1296
1454
  });
1297
1455
  if (res.steering_seed != null) setSteeringSeed(res.steering_seed);
1456
+ // k121: a fresh review replaces the old one, and the answered set resets with
1457
+ // it — a proposal answered against last generation's prose says nothing about
1458
+ // this one.
1459
+ setCoordination(readCoordinationReport(res.coordination));
1460
+ setCoordSettled(new Set());
1298
1461
  setSpreadNotice(spreadNoticeLines(res, applied));
1299
1462
  }, [
1300
1463
  assist,
@@ -1521,9 +1684,13 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
1521
1684
  reference_images:
1522
1685
  !selectedProfile && references.length ? references.map((r) => r.uri) : undefined,
1523
1686
  goals: goalBodies,
1687
+ // k120 slice 2 — PRODUCER CONTINUITY REFRESH: the runner rewrites each
1688
+ // non-root segment's prompt from a vision read of the frame the previous
1689
+ // segment actually ended on. Off drops the key (schema default False).
1690
+ continuity_refresh: continuityRefresh || undefined,
1524
1691
  };
1525
1692
  return { body };
1526
- }, [goals, width, height, fps, seed, vramBudget, modelId, steps, cfg, negative, project, movieTitle, startImage, references, selectedProfile]);
1693
+ }, [goals, width, height, fps, seed, vramBudget, modelId, steps, cfg, negative, project, movieTitle, startImage, references, selectedProfile, continuityRefresh]);
1527
1694
 
1528
1695
  // NAME NUDGE (k91) — set when a submit was held back only because the movie has no
1529
1696
  // name. It is a warning, not a gate: the run row then offers "Generate without a
@@ -2016,22 +2183,17 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2016
2183
  <input id="vi-moviecol-height" className="vi-knob-input" type="number" min={64} step={8}
2017
2184
  value={height} onChange={(e) => setHeight(Number(e.target.value) || 0)} />
2018
2185
  </div>
2019
- <div className="vi-knob" style={{ maxWidth: "6rem" }}>
2020
- <label htmlFor="vi-moviecol-fps">fps</label>
2021
- <input id="vi-moviecol-fps" className="vi-knob-input" type="number" min={1}
2022
- value={fps} onChange={(e) => setFps(Number(e.target.value) || 0)} />
2023
- </div>
2024
- <div className="vi-knob" style={{ maxWidth: "12rem" }}>
2025
- <label htmlFor="vi-moviecol-frames">length — frames / segment</label>
2026
- <input id="vi-moviecol-frames" className="vi-knob-input" type="number" min={1}
2027
- value={framesDefault} placeholder="model default (81)"
2028
- onChange={(e) => setFramesDefault(e.target.value)} />
2029
- <span className="vi-knob-hint">
2030
- {framesDefault.trim() !== "" && Number(framesDefault) > 0 && fps > 0
2031
- ? `every segment \u2248 ${(Number(framesDefault) / fps).toFixed(1)}s @ ${fps}fps (a segment\u2019s own length overrides)`
2032
- : `blank = model default \u2014 81f \u2248 ${fps > 0 ? (81 / fps).toFixed(1) : "?"}s @ ${fps}fps per segment`}
2033
- </span>
2034
- </div>
2186
+ {/* LENGTH ROW (operator 2026-08-13): fps | frames/segment | seconds —
2187
+ any two set the third; dimmed = derived, last-touched wins. This is
2188
+ the MOVIE default; a segment's own length overrides it. */}
2189
+ <LengthRow
2190
+ idPrefix="vi-moviecol-len"
2191
+ fps={fps}
2192
+ onFps={(n) => setFps(n)}
2193
+ frames={framesDefault}
2194
+ onFrames={setFramesDefault}
2195
+ framesPlaceholder="model default (81) / segment"
2196
+ />
2035
2197
  <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2036
2198
  <label htmlFor="vi-moviecol-steps">steps</label>
2037
2199
  <input id="vi-moviecol-steps" className="vi-knob-input" type="number" min={1} max={100}
@@ -2060,6 +2222,56 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2060
2222
  the follows-hint, and .vi-gen-part-actions (auto-pushed reorder/remove). No
2061
2223
  new look invented — the same classes GenerateStation.tsx uses. */}
2062
2224
  <section aria-label="Goals" className="vi-studio-composer vi-movie-timeline">
2225
+ {/* ── 🎬 PRODUCER (k120 slice 1) — a premise in, a full script out: the
2226
+ plan decides segment count, per-segment prompt/negative/length and
2227
+ how each shot joins the previous one (vace_extend = continue from
2228
+ the last frame). Rows land below as ordinary editable rows; the
2229
+ previous rows are parked in the stash, not deleted. */}
2230
+ <div className="vi-knob" style={{ marginBottom: "0.6rem" }}>
2231
+ <label htmlFor="vi-movie-premise">🎬 producer</label>
2232
+ <div
2233
+ style={{ display: "flex", gap: "0.5rem", alignItems: "flex-start", flexWrap: "wrap" }}
2234
+ >
2235
+ <textarea
2236
+ id="vi-movie-premise"
2237
+ className="vi-knob-input"
2238
+ rows={2}
2239
+ style={{ flex: "1 1 24rem", resize: "vertical" }}
2240
+ placeholder="Premise / logline — the producer writes the whole script: segments, prompts, lengths, joins"
2241
+ value={premise}
2242
+ onChange={(e) => setPremise(e.target.value)}
2243
+ disabled={producing}
2244
+ />
2245
+ <button
2246
+ type="button"
2247
+ className="vi-btn vi-btn-accent"
2248
+ disabled={producing || premise.trim() === ""}
2249
+ onClick={() => void produceMovie()}
2250
+ title="Generate a full script from the premise: segment count, per-segment prompts, negatives, lengths and joins. Current rows are parked in the stash."
2251
+ >
2252
+ {producing ? "Producing…" : "🎬 Produce"}
2253
+ </button>
2254
+ </div>
2255
+ <label
2256
+ className="vi-comfy-hint"
2257
+ style={{ display: "inline-flex", alignItems: "center", gap: "0.35rem" }}
2258
+ title="During the render, a vision model reads the frame each segment actually ended on and a text model rewrites the next segment's prompt to open from that state — true segment-to-segment continuity. Advisory: any failure keeps the authored prompt."
2259
+ >
2260
+ <input
2261
+ type="checkbox"
2262
+ checked={continuityRefresh}
2263
+ onChange={(e) => setContinuityRefresh(e.target.checked)}
2264
+ />
2265
+ continuity refresh — rewrite each next segment from the previous segment's rendered
2266
+ last frame
2267
+ </label>
2268
+ {producerNote ? <span className="vi-knob-hint">{producerNote}</span> : null}
2269
+ {produceError ? (
2270
+ <p className="vi-error" role="alert">
2271
+ {produceError}
2272
+ </p>
2273
+ ) : null}
2274
+ </div>
2063
2275
  {/* ── SEGMENT COUNT + GROUP ASSIST BAR (STUDIO-SPREAD-SPEC §2) ──
2064
2276
  Say how many shots the movie has, tick the ones you want written, and
2065
2277
  write them in ONE coherent pass. The count materializes or truncates
@@ -2071,9 +2283,9 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2071
2283
  label: "Segments",
2072
2284
  value: goals.length,
2073
2285
  min: 1,
2074
- max: MAX_SEGMENTS,
2075
2286
  onChange: setSegmentCount,
2076
- title: `How many shots this movie has (1–${MAX_SEGMENTS}). Lowering it removes rows from the END of the timeline.`,
2287
+ title:
2288
+ "How many shots this movie has. Lowering it parks rows from the END of the timeline; raising it brings them back before adding blanks.",
2077
2289
  }}
2078
2290
  noun="segment"
2079
2291
  selectedCount={selectedCount}
@@ -2160,6 +2372,18 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2160
2372
  </div>
2161
2373
  )}
2162
2374
 
2375
+ {/* k121: what the review DID to this set, in one line. Present even when
2376
+ nothing changed — "reviewed and fine" has to be visible, because the
2377
+ incident this exists for looked exactly like "nothing happened". */}
2378
+ {coordination && (
2379
+ <CoordinationSummary
2380
+ status={coordination.status}
2381
+ counts={coordination.counts}
2382
+ notes={coordination.notes}
2383
+ onDismiss={() => setCoordination(null)}
2384
+ />
2385
+ )}
2386
+
2163
2387
  <ol className="vi-gen-parts">
2164
2388
  {goals.map((g, i) => (
2165
2389
  <PromptCard
@@ -2180,6 +2404,13 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2180
2404
  /* The joint descriptor STAYS in the header (the joint SELECT moved
2181
2405
  into the settings expander below — k88). */
2182
2406
  headerHint={
2407
+ <CoordinationBadge
2408
+ review={segmentReview(coordination, g.key)}
2409
+ onAccept={acceptCoordination}
2410
+ onReject={rejectCoordination}
2411
+ settled={coordSettled}
2412
+ disabled={disableSubmit}
2413
+ >
2183
2414
  <span className="vi-comfy-hint" style={{ opacity: 0.75 }}>
2184
2415
  {i === 0
2185
2416
  ? references.length
@@ -2193,6 +2424,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2193
2424
  ? "extends motion from the previous segment"
2194
2425
  : "continues from a frame of the previous segment"}
2195
2426
  </span>
2427
+ </CoordinationBadge>
2196
2428
  }
2197
2429
  canMoveUp={i > 0}
2198
2430
  canMoveDown={i < goals.length - 1}
@@ -2667,23 +2899,18 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2667
2899
  />
2668
2900
  <span className="vi-knob-hint">0–20 · blank = movie/model default</span>
2669
2901
  </div>
2670
- <div className="vi-knob" style={{ maxWidth: "11rem" }}>
2671
- <label htmlFor={`vi-movie-goalframes-${g.key}`}>length — frames</label>
2672
- <input
2673
- id={`vi-movie-goalframes-${g.key}`}
2674
- className="vi-knob-input"
2675
- type="number"
2676
- min={1}
2677
- value={g.frames}
2678
- placeholder={framesDefault.trim() || "model default"}
2679
- onChange={(e) => setGoalField(i, { frames: e.target.value })}
2680
- />
2681
- <span className="vi-knob-hint">
2682
- {g.frames.trim() !== "" && Number(g.frames) > 0 && fps > 0
2683
- ? `\u2248 ${(Number(g.frames) / fps).toFixed(1)}s @ ${fps}fps`
2684
- : "blank = movie default \u00b7 clamped to the model ceiling, snapped to 4k+1"}
2685
- </span>
2686
- {Number(g.frames.trim() || framesDefault.trim()) > MODEL_FRAME_CEILING && (
2902
+ {/* LENGTH ROW (per-goal): fps is MOVIE-level here (fixed/grey) —
2903
+ frames<->seconds derive against it. Blank = movie default. */}
2904
+ <LengthRow
2905
+ idPrefix={`vi-movie-goal-len-${g.key}`}
2906
+ fps={fps}
2907
+ fpsFixedReason="Movie-level — one fps per movie; change it in movie settings above."
2908
+ frames={g.frames}
2909
+ onFrames={(v) => setGoalField(i, { frames: v })}
2910
+ framesPlaceholder={framesDefault.trim() || "movie default (81)"}
2911
+ />
2912
+ {Number(g.frames.trim() || framesDefault.trim()) > MODEL_FRAME_CEILING && (
2913
+ <div className="vi-knob" style={{ flexBasis: "100%" }}>
2687
2914
  <button
2688
2915
  type="button"
2689
2916
  className="vi-btn vi-btn-sm"
@@ -2692,8 +2919,8 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2692
2919
  >
2693
2920
  ✂ split into {Math.ceil(Number(g.frames.trim() || framesDefault.trim()) / MODEL_FRAME_CEILING)} segments to fit
2694
2921
  </button>
2695
- )}
2696
- </div>
2922
+ </div>
2923
+ )}
2697
2924
  {/* UNIFORM-CARD PLACEHOLDERS (operator 2026-08-13): geometry and
2698
2925
  budget are MOVIE-level by server contract (one StudioMovieSpec
2699
2926
  geometry per movie) — shown greyed with the reason, never
@@ -2708,11 +2935,6 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2708
2935
  <input id={`vi-movie-goalh-${g.key}`} className="vi-knob-input" disabled value={height}
2709
2936
  title="Movie-level — one geometry per movie (StudioMovieSpec); change it in movie settings above." />
2710
2937
  </div>
2711
- <div className="vi-knob" style={{ maxWidth: "6rem" }}>
2712
- <label htmlFor={`vi-movie-goalfps-${g.key}`}>fps</label>
2713
- <input id={`vi-movie-goalfps-${g.key}`} className="vi-knob-input" disabled value={fps}
2714
- title="Movie-level — one fps per movie; change it in movie settings above." />
2715
- </div>
2716
2938
  <div className="vi-knob" style={{ maxWidth: "9rem" }}>
2717
2939
  <label htmlFor={`vi-movie-goalvram-${g.key}`}>VRAM budget GB</label>
2718
2940
  <input id={`vi-movie-goalvram-${g.key}`} className="vi-knob-input" disabled
@@ -2941,7 +3163,22 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
2941
3163
  <span style={{ fontVariantNumeric: "tabular-nums", opacity: 0.7, minWidth: "5.5rem" }}>
2942
3164
  seg {s.index + 1} · {statusBadge(s.status, s.resumed)}
2943
3165
  </span>
2944
- <span style={{ opacity: 0.85, wordBreak: "break-word" }}>{s.prompt ?? ""}</span>
3166
+ <span style={{ opacity: 0.85, wordBreak: "break-word" }}>
3167
+ {s.prompt ?? ""}
3168
+ {s.refresh_note ? (
3169
+ <span
3170
+ className="vi-comfy-hint"
3171
+ style={{ display: "block", opacity: 0.75 }}
3172
+ title={
3173
+ s.prompt_authored
3174
+ ? `authored prompt: ${s.prompt_authored}`
3175
+ : undefined
3176
+ }
3177
+ >
3178
+ 🎬 {s.refresh_note}
3179
+ </span>
3180
+ ) : null}
3181
+ </span>
2945
3182
  </li>
2946
3183
  ))}
2947
3184
  </ul>
@@ -28,7 +28,7 @@
28
28
  // (StudioClipsStation) and the Generate station's "studio" mode (StudioGenerateMode) — so the
29
29
  // workbench is identical and a job enqueued from either is byte-identical. The only per-mount
30
30
  // difference is `registerBridge` (the standalone station owns the "Send to Studio" target).
31
- import { useCallback, useEffect, useRef, useState } from "react";
31
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
32
32
  // Sub-tab input memory for the staged source + item strip — see
33
33
  // video/sessionForm.ts.
34
34
  import { useSessionState } from "../../video/sessionForm";
@@ -38,6 +38,7 @@ import { StudioAssistLogPanel } from "./StudioAssistLogPanel";
38
38
  import type { MediaRef } from "../../video/contract";
39
39
  import { useStudioPresets } from "../../video/useStudioPresets";
40
40
  import { useStudioClips } from "./studioShared";
41
+ import { useMediaLibrary } from "../../video/mediaLibrary";
41
42
  import {
42
43
  makeStudioItem,
43
44
  applyOp,
@@ -140,10 +141,40 @@ export function StudioPlane({ registerBridge = false, lockedSurfaceMode }: Studi
140
141
  // now it re-syncs to the newest playable clip on every clip-list change, same as the
141
142
  // comment always claimed — gated on `pinned` so a manual pick/clear (see above) is
142
143
  // respected instead of being clobbered by the next poll tick.
144
+ // Session-library media from OTHER tabs (cinema movies, scene clips/frames, crops,
145
+ // …) — everything that finished anywhere and carries pixels, deduped against the
146
+ // studio clip catalog by uri (the studio-clips poll mirrors every playable clip
147
+ // into the library, so without the dedup each clip would list twice). Operator
148
+ // ask 2026-08-27: generated media from ANY tab auto-displays in this viewer, and
149
+ // "Play from library" offers recent generations — not just studio_i2v clips.
150
+ const libraryAll = useMediaLibrary();
151
+ const libraryMedia = useMemo(() => {
152
+ const clipUris = new Set(
153
+ clipsState.clips.map((c) => c.output?.uri).filter((u): u is string => !!u),
154
+ );
155
+ return libraryAll
156
+ .filter(
157
+ (it) =>
158
+ (it.ref.kind === "video" || it.ref.kind === "image") &&
159
+ !!it.ref.uri &&
160
+ !clipUris.has(it.ref.uri),
161
+ )
162
+ .sort((a, b) => b.addedAt - a.addedAt);
163
+ }, [libraryAll, clipsState.clips]);
164
+
143
165
  useEffect(() => {
144
166
  if (pinned) return;
145
- 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}