@hugpy/video-intelligence-ui 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/assets/index-DMoT8G1q.css +1 -0
  2. package/dist/assets/index-GeD1mo16.js +4190 -0
  3. package/dist/index.html +2 -2
  4. package/entry.tsx +6 -27
  5. package/package.json +3 -2
  6. package/src/config.ts +11 -27
  7. package/src/demo/demoFetch.ts +0 -9
  8. package/src/nav/Navbar.tsx +3 -3
  9. package/src/session.ts +8 -43
  10. package/src/stations/GenerateStation.tsx +8 -5
  11. package/src/stations/SharePanel.tsx +4 -4
  12. package/src/stations/ToksPanel.tsx +1 -2
  13. package/src/stations/WorkbenchSidebar.tsx +8 -261
  14. package/src/stations/ops/CharacterGroupsPanel.tsx +75 -83
  15. package/src/stations/studio/IdentitiesStation.tsx +66 -75
  16. package/src/stations/studio/StudioGenerateMode.tsx +3 -2
  17. package/src/stations/studio/StudioGenerateTab.tsx +258 -188
  18. package/src/stations/studio/StudioMovieComposer.tsx +321 -46
  19. package/src/stations/studio/StudioPlane.tsx +3 -1
  20. package/src/stations/studio/StudioViewer.tsx +6 -11
  21. package/src/stations/studio/studioShared.tsx +14 -2
  22. package/src/stations/studio/useIdentityCreateJob.ts +26 -21
  23. package/src/stations/studio/useReconstruction.ts +11 -5
  24. package/src/video/PromptAssistButtons.tsx +5 -6
  25. package/src/video/jobStream.ts +119 -0
  26. package/src/video/jobTracker.ts +67 -76
  27. package/src/video/modelsFeed.ts +105 -0
  28. package/src/video/studioOracle.ts +48 -0
  29. package/src/video/useMediaJobs.ts +17 -3
  30. package/src/video/useModels.ts +12 -53
  31. package/src/video/useMovieSessions.ts +15 -6
  32. package/src/video/useOperatorHistory.ts +11 -101
  33. package/src/video/usePromptAssist.ts +6 -3
  34. package/src/video/useToks.ts +14 -6
  35. package/src/video/watchVideoJob.ts +43 -0
  36. package/dist/assets/index-DuT1AMLn.css +0 -1
  37. package/dist/assets/index-_13ZiBDP.js +0 -4191
  38. package/src/video/useWorkerCalls.ts +0 -165
@@ -13,9 +13,8 @@
13
13
  // uniform panel:
14
14
  //
15
15
  // • t2v shape — image inputs are hidden entirely (text-only note);
16
- // • i2v "needs a start image" shape — the START IMAGE input becomes the dominant
17
- // element near the top, with the source-clip picker offered as the "or extend a
18
- // clip" alternative;
16
+ // • i2v shape — the prompt card accepts an optional start image, and the
17
+ // source-clip picker offers extension from a clip;
19
18
  // • v2v (restyle) shape — the SOURCE CLIP picker gets that hero prominence;
20
19
  // • id_lock (identity lock) shape — the REFERENCE-IMAGE slot list (1–4, ordered) is
21
20
  // the dominant accent-bordered element, with an OPTIONAL composition-control input
@@ -28,7 +27,7 @@
28
27
  // Every affordance reuses an EXISTING pattern: presets from useStudioPresets; the
29
28
  // image UPLOAD path is the Generate station's upload→ingest→guard-kind flow
30
29
  // (POST /uploads → POST /video/ingest → MediaRef) and the hero drop surface is the
31
- // shared DropReceptacle; the LIBRARY pick reads useMediaLibrary; the source-clip pick
30
+ // prompt-card file input; the LIBRARY pick reads useMediaLibrary; the source-clip pick
32
31
  // reads the plane's clip list; "Send to Studio" / "use as source" staging arrives via
33
32
  // studioBridge as the `source` prop; the enqueue body uses the exact route field names,
34
33
  // dropped by JSON.stringify when unset.
@@ -52,8 +51,8 @@ import { addToLibrary, useMediaLibrary } from "../../video/mediaLibrary";
52
51
  import { UnnamedWarnPanel, WarnUnnamedToggle, useWarnUnnamedPref, deriveNameFrom } from "../../video/UnnamedWarn";
53
52
  import { LengthRow } from "../../video/LengthRow";
54
53
  import { enqueueResultSchema, mediaRefSchema, uploadResultSchema } from "../../video/contract";
54
+ import { trackJob, useJobTracker } from "../../video/jobTracker";
55
55
  import type { MediaRef } from "../../video/contract";
56
- import { DropReceptacle } from "../../video/DropReceptacle";
57
56
  import type { GoalComposerAttach, GoalComposerAttached } from "../GoalComposer";
58
57
  import { usePromptAssist } from "../../video/usePromptAssist";
59
58
  import { PromptAssistButtons } from "../../video/PromptAssistButtons";
@@ -80,7 +79,7 @@ import {
80
79
  } from "./studioShared";
81
80
  // B2: the Movie authoring surface the mode switch renders. Self-contained (owns its
82
81
  // own goal state, enqueue + poll, and playback) — see StudioMovieComposer.tsx.
83
- import { StudioMovieComposer } from "./StudioMovieComposer";
82
+ import { StudioMovieComposer, type ClipSequenceDraft } from "./StudioMovieComposer";
84
83
  // IDENTITY PROFILES (stage a): the shared "Identity" control block (From profile /
85
84
  // Save as identity profile) — one additive row in the reference area below.
86
85
  import { IdentityProfileControls, type SelectedProfile } from "./IdentityProfileControls";
@@ -97,6 +96,7 @@ import { AboutExpander, PromptCard, PromptCardSettings, PromptFieldTabs, compose
97
96
  // authoritative, and every consumer below falls back to its prior list while
98
97
  // `renderLoaded` is false, so a slow discovery GET can never empty a picker.
99
98
  import { useRenderPresets } from "../../video/useRenderPresets";
99
+ import { planStudioSettings, type OracleSettings } from "../../video/studioOracle";
100
100
  import {
101
101
  CLIP_CAPABILITIES,
102
102
  MOVIE_CAPABILITY,
@@ -165,6 +165,7 @@ export interface StudioGenerateSurfaceProps {
165
165
  * its own internal switcher + useState. "movie" is the Cinema surface (label only — the
166
166
  * wire/tester value stays "movie"). */
167
167
  lockedSurfaceMode?: "clip" | "movie";
168
+ onSelectSurfaceMode?: (mode: "clip" | "movie") => void;
168
169
  }
169
170
 
170
171
  // Labels now come from renderCompat (ONE table both studio surfaces read) so a
@@ -243,6 +244,7 @@ export function StudioGenerateSurface({
243
244
  onEnqueued,
244
245
  settingsHost,
245
246
  lockedSurfaceMode,
247
+ onSelectSurfaceMode,
246
248
  }: StudioGenerateSurfaceProps) {
247
249
  // ── SUB-TAB INPUT MEMORY (operator ask 2026-08-06) ──────────────────────────
248
250
  // Every OPERATOR-AUTHORED field below is `useSessionState` rather than
@@ -264,10 +266,13 @@ export function StudioGenerateSurface({
264
266
  "clip",
265
267
  );
266
268
  const surfaceMode = lockedSurfaceMode ?? internalSurfaceMode;
269
+ const [, setClipSequenceDraft] = useSessionState<ClipSequenceDraft | null>(
270
+ "studio.movie.clipSequenceDraft", null,
271
+ );
267
272
 
268
273
  const [capability, setCapability] = useSessionState<Capability>(
269
274
  "studio.clip.capability",
270
- "i2v",
275
+ "t2v",
271
276
  );
272
277
  const [presetId, setPresetId] = useSessionState("studio.clip.presetId", "");
273
278
 
@@ -295,6 +300,15 @@ export function StudioGenerateSurface({
295
300
  const [steps, setSteps] = useSessionState("studio.clip.steps", "");
296
301
  const [cfg, setCfg] = useSessionState("studio.clip.cfg", "");
297
302
  const [modelId, setModelId] = useSessionState("studio.clip.modelId", "");
303
+ const [settingsMode, setSettingsMode] = useSessionState<"oracle" | "manual">(
304
+ "studio.clip.settingsMode", "oracle",
305
+ );
306
+ const manualSettings = useSessionRef<string[]>("studio.clip.manualSettings", []);
307
+ const markManual = (field: string) => {
308
+ if (!manualSettings.current.includes(field)) {
309
+ manualSettings.current = [...manualSettings.current, field];
310
+ }
311
+ };
298
312
  // Showroom flavor (?demo=1 only): open with a cinematic example prompt (matches
299
313
  // the sunset sample gens) so the brochure's Studio surface tells a coherent story
300
314
  // out of the box. Seed lives in src/demo/ and is injected ONLY behind isCanned() —
@@ -310,11 +324,13 @@ export function StudioGenerateSurface({
310
324
  // k89: the bottom "[▸ Tester]" collapsible that now hosts the cross-model sweep
311
325
  // (the run row's ghost 🧪 button is retired).
312
326
  const [clipTesterOpen, setClipTesterOpen] = useState(false);
327
+ const [sequenceMsg, setSequenceMsg] = useState<string | null>(null);
328
+ const [produceBusy, setProduceBusy] = useState(false);
313
329
  // LLM PROMPT-ASSIST (Enhance / Generate) — the same shared hook + button cluster the
314
330
  // Generate station uses (POSTs promptAssistUrl). Here the draft is the single clip
315
331
  // prompt and apply replaces it wholesale. context.kind = "scene" (a clip is a short
316
332
  // motion sequence, so the assistant leans into camera/motion phrasing).
317
- const assist = usePromptAssist({ kind: "scene", specKey: "studio" });
333
+ const assist = usePromptAssist({ kind: "clip", specKey: "studio" });
318
334
  // Optional human PROJECT name (choose-or-type). Threaded into the enqueue as
319
335
  // `project` (auto-archive NAME); blank = auto-named (the default). The list of
320
336
  // existing names backs the Settings datalist; free-typing a new one creates it.
@@ -393,6 +409,11 @@ export function StudioGenerateSurface({
393
409
 
394
410
  const [genBusy, setGenBusy] = useState(false);
395
411
  const [genMsg, setGenMsg] = useState<string | null>(null);
412
+ const [activeClipJobId, setActiveClipJobId] = useSessionState<string | null>(
413
+ "studio.clip.activeJobId", null,
414
+ );
415
+ const clipJobs = useJobTracker();
416
+ const activeClipJob = clipJobs.find((row) => row.jobId === activeClipJobId);
396
417
 
397
418
  // STUDIO TESTER (operator 2026-08-05) — fan the CURRENT prompt across every
398
419
  // servable model of this surface's type (clip/movie) via POST studioTesterUrl.
@@ -486,6 +507,27 @@ export function StudioGenerateSurface({
486
507
  const isV2V = capability === "v2v";
487
508
  const isIdLock = capability === "id_lock";
488
509
  const isMotion = capability === "motion";
510
+ const clipRoute = isI2V && (startImages[0] || source?.uri || source?.asset_id) ? "i2v" : isI2V ? "t2v" : capability;
511
+ const oraclePlan = async (text: string): Promise<OracleSettings> => {
512
+ if (clipRoute !== "t2v" && clipRoute !== "i2v") {
513
+ throw new Error("Oracle has no executable model for this Clip capability.");
514
+ }
515
+ const planned = await planStudioSettings(clipRoute, text, {
516
+ model_id: modelId || undefined, width, height, fps,
517
+ frames: requestedFrames === "" ? undefined : Number(requestedFrames),
518
+ steps: steps === "" ? undefined : Number(steps),
519
+ cfg: cfg === "" ? undefined : Number(cfg), seed,
520
+ }, manualSettings.current);
521
+ if (planned.model_id != null) setModelId(planned.model_id);
522
+ if (planned.width != null) setWidth(planned.width);
523
+ if (planned.height != null) setHeight(planned.height);
524
+ if (planned.fps != null) setFps(planned.fps);
525
+ if (planned.frames != null) setRequestedFrames(String(planned.frames));
526
+ if (planned.steps != null) setSteps(String(planned.steps));
527
+ if (planned.cfg != null) setCfg(String(planned.cfg));
528
+ if (planned.seed != null) setSeed(planned.seed);
529
+ return planned;
530
+ };
489
531
 
490
532
  // ── the fleet's measured render table + everything derived from it ───────────────
491
533
  // One session-cached GET shared with the Movie composer (module singleton in the
@@ -938,19 +980,16 @@ export function StudioGenerateSurface({
938
980
 
939
981
  // ── required-input gate + preformed SHAPE ──
940
982
  // v2v: a source clip is REQUIRED (nothing to repaint otherwise) — always the hero.
941
- // i2v with a requires_source preset: a START IMAGE or a SOURCE CLIP (extend) is
942
- // required — the start image is the hero, the source clip the alternative.
983
+ // i2v offers a start image; without one the orbit route uses text-to-video.
943
984
  // id_lock: >=1 REFERENCE image is required (the route 400s otherwise) — reference
944
985
  // slots are the hero; the composition control is optional + both-or-neither.
945
986
  // t2v / synthetic i2v / custom: no required input; inputs stay inline + optional.
946
- const presetNeedsInput = preset?.requires_source === true;
947
987
  const hasImage = startImages.length > 0;
948
988
  const hasSource = !!source;
949
989
  const hasReference = referenceImages.length > 0;
950
990
  const canAddImage = startImages.length < MAX_START_IMAGES;
951
991
  const canAddReference = referenceImages.length < MAX_REFERENCE_IMAGES;
952
992
  const needSource = isV2V;
953
- const needImageOrSource = isI2V && presetNeedsInput;
954
993
  const needReference = isIdLock;
955
994
  // MOTION requires a control still and it IS the capability — the exact mirror of
956
995
  // id_lock's reference rule (video_routes: "a motion request with no control carries NO
@@ -966,40 +1005,26 @@ export function StudioGenerateSurface({
966
1005
  const controlAndSource = isMotion && hasControl && hasSource;
967
1006
  const missingRequired =
968
1007
  (needSource && !hasSource) ||
969
- (needImageOrSource && !hasImage && !hasSource) ||
970
1008
  (needReference && !hasReference) ||
971
1009
  (needControl && !hasControl) ||
972
1010
  controlAndSource ||
973
1011
  controlHalfSet;
974
1012
 
975
- const hero: "start-image" | "source" | "reference" | "control" | null = isMotion
1013
+ const hero: "source" | "reference" | "control" | null = isMotion
976
1014
  ? "control"
977
1015
  : isIdLock
978
1016
  ? "reference"
979
1017
  : isV2V
980
1018
  ? "source"
981
- : needImageOrSource
982
- ? "start-image"
983
- : null;
984
- const startImageAttention = needImageOrSource && !hasImage && !hasSource;
1019
+ : null;
985
1020
  const sourceAttention =
986
- (needSource && !hasSource) || (needImageOrSource && !hasImage && !hasSource);
1021
+ needSource && !hasSource;
987
1022
  const referenceAttention = needReference && !hasReference;
988
1023
 
989
1024
  // ── Composer attach idiom (operator ask 2026-07-11, option A) ──
990
- // The prompt composer's inline attach toolbar + chips and the hero shape editor below
991
- // are TWO ACCESS PATHS TO THE SAME STATE — one source of truth, two views. We adopt
992
- // GoalComposer's documented attach idiom (attachments = toolbar buttons, attached =
993
- // removable chips) and wire its callbacks to THIS file's OWN pickers / MediaRef flows,
994
- // offering only the inputs the ACTIVE shape accepts (mirroring the render* predicates
995
- // above). The buttons open the SAME `showLibPicker` / `showSourcePicker` the hero uses,
996
- // so the library pick routes to start (i2v) or reference (v2v / id_lock) exactly as the
997
- // hero's own lib-picker routing does; the chips reflect the SAME startImages / source /
998
- // referenceImages / controlImage state the hero renders, and reuse the hero's existing
999
- // removes. Applicability decides whether a button EXISTS (t2v gets no toolbar — omitted
1000
- // cleanly by GoalComposer when the array is empty); capacity decides whether it is
1001
- // DISABLED (a filled slot mirrors the hero's "you're at the cap" note, not a vanished
1002
- // affordance). The negative composer stays plain.
1025
+ // The prompt card owns the start-image upload, library pick, and removable chip.
1026
+ // Other route-specific inputs use their existing pickers below. All callbacks
1027
+ // update the same MediaRef state consumed by the enqueue route.
1003
1028
  //
1004
1029
  // 2026-07-29: these three are no longer hand-written capability unions — they are the
1005
1030
  // route's own input gates, transcribed once in renderCompat's ACCEPTS matrix and read
@@ -1103,7 +1128,7 @@ export function StudioGenerateSurface({
1103
1128
  ? "Composition control is both-or-neither — add a control image AND a kind, or clear both."
1104
1129
  : needSource && !hasSource
1105
1130
  ? "Restyle (v2v) needs a source clip — add one above."
1106
- : "This generator needs a start image (or a source clip to extend) — add one above.";
1131
+ : "Add the required input above.";
1107
1132
 
1108
1133
  // v2v stays a HARD block (the router refuses an out-of-envelope restyle outright). The
1109
1134
  // envelope itself is now the ratified preset's rather than a mirrored constant.
@@ -1190,14 +1215,18 @@ export function StudioGenerateSurface({
1190
1215
  // on motion (required, definitional) — `slots.controlImage` is that gate.
1191
1216
  const controlReady = (isIdLock || isMotion) && !!controlImage && controlKind !== "";
1192
1217
  const body: Record<string, unknown> = {
1193
- capability,
1218
+ capability: clipRoute,
1219
+ settings_mode: settingsMode,
1220
+ explicit_settings: manualSettings.current,
1194
1221
  resolution: { width, height, fps },
1195
1222
  seed,
1196
1223
  vram_budget_gb: budgetNum,
1197
1224
  steps: stepsNum,
1198
1225
  cfg: cfgNum,
1199
1226
  requested_frames: requestedFrames.trim() !== "" ? Number(requestedFrames) : undefined,
1200
- model_id: modelId.trim() ? modelId.trim() : undefined,
1227
+ // A chosen model is an explicit pin. The engine validates it against the
1228
+ // task picked from inputs; silently dropping it would change the request.
1229
+ model_id: modelId.trim() || undefined,
1201
1230
  // Optional auto-archive NAME (choose-or-type). Blank = auto-named (default).
1202
1231
  project: project.trim() ? project.trim() : undefined,
1203
1232
  prompt: prompt.trim() ? prompt : undefined,
@@ -1230,6 +1259,15 @@ export function StudioGenerateSurface({
1230
1259
  control_image: controlReady ? (controlImage as MediaRef).uri : undefined,
1231
1260
  control_kind: controlReady ? controlKind : undefined,
1232
1261
  };
1262
+ if (settingsMode === "oracle") {
1263
+ const planned = await oraclePlan(prompt);
1264
+ body.resolution = { width: planned.width, height: planned.height, fps: planned.fps };
1265
+ body.model_id = planned.model_id;
1266
+ body.steps = planned.steps;
1267
+ body.cfg = planned.cfg;
1268
+ body.seed = planned.seed;
1269
+ body.requested_frames = planned.frames;
1270
+ }
1233
1271
  const res = await request<unknown>(hugpyConfig.studioI2VEnqueueUrl, {
1234
1272
  method: "POST",
1235
1273
  headers: { "Content-Type": "application/json" },
@@ -1241,28 +1279,34 @@ export function StudioGenerateSurface({
1241
1279
  setGenMsg(`Enqueue failed: ${describeAppError(errorOf(res))}`);
1242
1280
  return;
1243
1281
  }
1282
+ const enq = enqueueResultSchema.safeParse(okValue(res));
1283
+ if (!enq.success) {
1284
+ setGenMsg("The server accepted the request but returned no job id to track.");
1285
+ return;
1286
+ }
1287
+ const routedCapability = enq.data.capability === "i2v" || enq.data.capability === "t2v"
1288
+ ? enq.data.capability : clipRoute;
1289
+ trackJob({
1290
+ jobId: enq.data.job_id, kind: "studio_i2v", station: "studio",
1291
+ label: routedCapability === "i2v" ? "Studio image-to-video" : "Studio text-to-video",
1292
+ libLabel: "studio clip", enqueuedAt: Date.now(),
1293
+ });
1294
+ setActiveClipJobId(enq.data.job_id);
1244
1295
  // Subject-clip auto-stage (armed by onGenerateNewSubject): the FIRST successful
1245
1296
  // enqueue after "Generate new clip" arms the wait on ITS job id — later manual
1246
1297
  // generates (pendingSubjectStage already false by then) are plain renders.
1247
1298
  if (pendingSubjectStage) {
1248
1299
  setPendingSubjectStage(false);
1249
- const enq = enqueueResultSchema.safeParse(okValue(res));
1250
- if (enq.success) {
1251
- setAwaitingSubjectJobId(enq.data.job_id);
1252
- setGenMsg(
1253
- "Enqueued — once this clip is produced it becomes the staged subject automatically.",
1254
- );
1255
- } else {
1256
- setGenMsg(
1257
- "Enqueued, but no job id came back to track — stage the produced clip as the subject manually (From library) once it's done.",
1258
- );
1259
- }
1300
+ setAwaitingSubjectJobId(enq.data.job_id);
1301
+ setGenMsg("Enqueued — once this clip is produced it becomes the staged subject automatically.");
1260
1302
  } else {
1261
- setGenMsg("Enqueued — once the clip is produced it plays in the viewer above and lands in the Session Library.");
1303
+ setGenMsg(`${routedCapability === "i2v" ? "Image-to-video" : "Text-to-video"} queued — follow the job below or in Active Processes.`);
1262
1304
  }
1263
1305
  onEnqueued();
1264
1306
  // A project name coined here should appear in the combobox next time.
1265
1307
  if (project.trim()) refreshProjects();
1308
+ } catch (err) {
1309
+ setGenMsg(err instanceof Error ? err.message : "Oracle planning failed.");
1266
1310
  } finally {
1267
1311
  if (mounted.current) setGenBusy(false);
1268
1312
  }
@@ -1271,6 +1315,7 @@ export function StudioGenerateSurface({
1271
1315
  v2vBadGeom,
1272
1316
  missingReason,
1273
1317
  capability,
1318
+ clipRoute,
1274
1319
  width,
1275
1320
  height,
1276
1321
  fps,
@@ -1295,7 +1340,72 @@ export function StudioGenerateSurface({
1295
1340
  pendingSubjectStage,
1296
1341
  onEnqueued,
1297
1342
  project,
1298
- refreshProjects,, warnUnnamed, project]);
1343
+ refreshProjects,
1344
+ warnUnnamed,
1345
+ settingsMode,
1346
+ oraclePlan,
1347
+ setActiveClipJobId]);
1348
+
1349
+ function addNextPrompt() {
1350
+ // Cinema's existing sequential route owns last-frame carry. Preserve every
1351
+ // Clip input we can transfer; refuse shapes it cannot represent as its root.
1352
+ if (source || isV2V || isIdLock || isMotion || referenceImages.length || controlImage) {
1353
+ setSequenceMsg("This Clip has a source or control input that Cinema cannot use as its first frame. Finish this clip, then start a sequence from its result; no attachment was discarded.");
1354
+ return;
1355
+ }
1356
+ setSequenceMsg(null);
1357
+ setClipSequenceDraft({
1358
+ id: nextSessionSeq("studio.movie.clipSequenceDraftSeq"),
1359
+ prompt, negative, stdNegative,
1360
+ startImage: startImages[0] ?? null,
1361
+ width, height, fps, seed, frames: requestedFrames,
1362
+ modelId, steps, cfg, vramBudget, project,
1363
+ });
1364
+ setInternalSurfaceMode("movie");
1365
+ onSelectSurfaceMode?.("movie");
1366
+ }
1367
+
1368
+ async function generateProductionFromClip() {
1369
+ if (source || isV2V || isIdLock || isMotion || referenceImages.length || controlImage) {
1370
+ setSequenceMsg("Cinema cannot carry this Clip's source or control inputs into a new production.");
1371
+ return;
1372
+ }
1373
+ setProduceBusy(true);
1374
+ setSequenceMsg(null);
1375
+ try {
1376
+ const res = await request<unknown>(hugpyConfig.producerPlanUrl, {
1377
+ method: "POST",
1378
+ headers: { "Content-Type": "application/json" },
1379
+ body: JSON.stringify({
1380
+ premise: prompt.trim() || "Create an original short cinematic sequence.",
1381
+ model: assist.assistModel || undefined,
1382
+ start_image: startImages[0]?.uri,
1383
+ }),
1384
+ meta: { specKey: "studio", operation: "studio.oracle.production" },
1385
+ });
1386
+ if (!res.ok) throw new Error(describeAppError(errorOf(res)));
1387
+ const result = okValue(res) as {
1388
+ segments?: ClipSequenceDraft["plannedSegments"];
1389
+ };
1390
+ if (!result?.segments?.length) throw new Error("Oracle returned no production segments.");
1391
+ const first = result.segments[0]?.settings;
1392
+ setClipSequenceDraft({
1393
+ id: nextSessionSeq("studio.movie.clipSequenceDraftSeq"),
1394
+ prompt, negative, stdNegative,
1395
+ startImage: startImages[0] ?? null,
1396
+ width: first?.width ?? width, height: first?.height ?? height,
1397
+ fps: first?.fps ?? fps, seed, frames: requestedFrames,
1398
+ modelId, steps, cfg, vramBudget, project,
1399
+ plannedSegments: result.segments,
1400
+ });
1401
+ setInternalSurfaceMode("movie");
1402
+ onSelectSurfaceMode?.("movie");
1403
+ } catch (err) {
1404
+ setSequenceMsg(err instanceof Error ? err.message : "Oracle production generation failed.");
1405
+ } finally {
1406
+ setProduceBusy(false);
1407
+ }
1408
+ }
1299
1409
 
1300
1410
  const synthPresets = presets.filter((p) => isSyntheticPreset(p));
1301
1411
  const realPresets = presets.filter((p) => !isSyntheticPreset(p));
@@ -1358,106 +1468,6 @@ export function StudioGenerateSurface({
1358
1468
  );
1359
1469
  }
1360
1470
 
1361
- // ── start-image input (variant-driven: hero = dominant/accented; inline = subdued) ──
1362
- function renderStartImage(variant: "hero" | "inline") {
1363
- const isHero = variant === "hero";
1364
- const drawEye = isHero && startImageAttention;
1365
- const thumb = isHero ? "9rem" : "5rem";
1366
- return (
1367
- <section aria-label="Start image" style={{ marginTop: isHero ? 0 : "0.9rem" }}>
1368
- <p
1369
- className="vi-comfy-label"
1370
- style={{ marginBottom: "0.35rem", opacity: isHero ? 1 : 0.75 }}
1371
- >
1372
- Start image{isHero ? " — required for this generator" : " (optional)"}
1373
- </p>
1374
- {drawEye && (
1375
- <p className="vi-input-callout" role="note">
1376
- This generator needs a start image — the subject is carried from it. Drop one below
1377
- (or extend a source clip instead).
1378
- </p>
1379
- )}
1380
- {hasImage && (
1381
- <div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginBottom: "0.5rem" }}>
1382
- {startImages.map((img, idx) => (
1383
- <div key={img.uri} style={{ display: "flex", flexDirection: "column", gap: "0.2rem" }}>
1384
- <img
1385
- src={mediaBytesUrl(img.uri)}
1386
- alt="start"
1387
- style={{
1388
- width: thumb,
1389
- height: thumb,
1390
- objectFit: "cover",
1391
- borderRadius: "0.4rem",
1392
- background: "#000",
1393
- border: "1px solid var(--vi-border)",
1394
- }}
1395
- />
1396
- <button
1397
- type="button"
1398
- className="vi-btn vi-btn-sm vi-btn-ghost"
1399
- onClick={() => removeStartImage(idx)}
1400
- >
1401
- Remove
1402
- </button>
1403
- </div>
1404
- ))}
1405
- </div>
1406
- )}
1407
- {canAddImage ? (
1408
- isHero && !hasImage ? (
1409
- <DropReceptacle
1410
- onFile={(f) => void onPickImageFile(f, "start")}
1411
- accept="image/*"
1412
- busy={imageBusy}
1413
- title="Drop a start image or click to browse"
1414
- hint="This generator carries the subject from this still."
1415
- className={drawEye ? "vi-dropzone--required" : undefined}
1416
- />
1417
- ) : (
1418
- <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
1419
- <label className="vi-btn vi-btn-accent vi-file-label">
1420
- {imageBusy ? "Working…" : "Upload a still"}
1421
- <input
1422
- type="file"
1423
- accept="image/*"
1424
- className="vi-file-input"
1425
- disabled={imageBusy}
1426
- onChange={(e) => {
1427
- const f = e.target.files?.[0];
1428
- if (f) void onPickImageFile(f, "start");
1429
- e.target.value = "";
1430
- }}
1431
- />
1432
- </label>
1433
- <button
1434
- type="button"
1435
- className="vi-btn vi-btn-ghost"
1436
- onClick={() => setShowLibPicker((v) => !v)}
1437
- aria-expanded={showLibPicker}
1438
- >
1439
- {showLibPicker ? "Hide library" : `Pick from library (${libraryImages.length})`}
1440
- </button>
1441
- </div>
1442
- )
1443
- ) : (
1444
- <p className="vi-comfy-hint" role="note">
1445
- Multi-image conditioning here is identity lock — switch capability to Identity lock to
1446
- add 1–4 reference images.
1447
- </p>
1448
- )}
1449
- {imageError && (
1450
- <p className="vi-error" role="alert" style={{ marginTop: "0.4rem" }}>
1451
- {imageError}
1452
- </p>
1453
- )}
1454
- {showLibPicker && canAddImage && (
1455
- <LibraryImageGrid images={libraryImages} onPick={(ref) => addStartImage(ref)} />
1456
- )}
1457
- </section>
1458
- );
1459
- }
1460
-
1461
1471
  // ── reference-image SLOT LIST: 1–4 ordered stills, reorderable. The SAME list feeds
1462
1472
  // two shapes — id_lock (variant "required": the identity to lock, accent-gated) and
1463
1473
  // v2v scene-continuity (variant "optional": an identity held across the restyle). ──
@@ -1723,15 +1733,11 @@ export function StudioGenerateSurface({
1723
1733
  );
1724
1734
  }
1725
1735
 
1726
- // ── source-clip input (hero = dominant/accented; inline = subdued; inline-alt =
1727
- // the "or extend a clip" alternative under a hero start image) ──
1728
- function renderSource(variant: "hero" | "inline" | "inline-alt") {
1736
+ // ── source-clip input (hero = dominant/accented; inline = subdued) ──
1737
+ function renderSource(variant: "hero" | "inline") {
1729
1738
  const isHero = variant === "hero";
1730
- const isAlt = variant === "inline-alt";
1731
1739
  const drawEye = isHero && sourceAttention;
1732
- const label = isAlt
1733
- ? "…or use a source clip (extend from its last frame)"
1734
- : isHero
1740
+ const label = isHero
1735
1741
  ? "Source clip — required (the clip to repaint)"
1736
1742
  : isV2V
1737
1743
  ? "Source clip — required"
@@ -1974,20 +1980,26 @@ export function StudioGenerateSurface({
1974
1980
 
1975
1981
  const knobGrid = (
1976
1982
  <div className="vi-studio-knobs">
1983
+ <div className="vi-knob vi-studio-knob-wide">
1984
+ <label htmlFor="vi-clip-settings-mode">settings</label>
1985
+ <select id="vi-clip-settings-mode" className="vi-knob-select"
1986
+ value={settingsMode}
1987
+ onChange={(e) => setSettingsMode(e.target.value as "oracle" | "manual")}>
1988
+ <option value="oracle">Oracle-generated (default)</option>
1989
+ <option value="manual">Manual</option>
1990
+ </select>
1991
+ </div>
1977
1992
  {/* ABOUT — the condensed what-is-this / how-to prose (k89), collapsed at the
1978
1993
  top of the Settings-tab content. Absorbs the descriptive halves of the old
1979
1994
  inline callouts; the conditional required-input warnings stay on the form. */}
1980
1995
  <div className="vi-studio-knob-wide" style={{ flex: "1 1 100%" }}>
1981
1996
  <AboutExpander title="About Clip">
1982
1997
  <p>
1983
- Generates ONE studio clip from a prompt plus whatever inputs the chosen
1984
- generator needs.
1998
+ Generates one clip from your prompt and the inputs you provide.
1985
1999
  </p>
1986
2000
  <p>
1987
- Pick a template or capability here and the form morphs to that shape:
1988
- text→video needs nothing, image→video a start image (or a source clip to
1989
- extend), restyle a source clip, identity lock 1–4 reference images, and
1990
- pose/structure a control still.
2001
+ A prompt alone uses text-to-video. Add a start image or source clip to
2002
+ use image-to-video; a source clip continues from its last frame.
1991
2003
  </p>
1992
2004
  <p>
1993
2005
  A missing required input is flagged on the card and gates Generate until
@@ -2138,6 +2150,7 @@ export function StudioGenerateSurface({
2138
2150
  title={isV2V ? geomLockTitle : geomEnvelopeTitle}
2139
2151
  onChange={(e) => {
2140
2152
  geomTouched.current = true;
2153
+ markManual("width");
2141
2154
  setWidth(Number(e.target.value) || 0);
2142
2155
  }}
2143
2156
  />
@@ -2155,6 +2168,7 @@ export function StudioGenerateSurface({
2155
2168
  title={isV2V ? geomLockTitle : geomEnvelopeTitle}
2156
2169
  onChange={(e) => {
2157
2170
  geomTouched.current = true;
2171
+ markManual("height");
2158
2172
  setHeight(Number(e.target.value) || 0);
2159
2173
  }}
2160
2174
  />
@@ -2172,6 +2186,7 @@ export function StudioGenerateSurface({
2172
2186
  title={isV2V ? geomLockTitle : geomEnvelopeTitle}
2173
2187
  onChange={(e) => {
2174
2188
  geomTouched.current = true;
2189
+ markManual("fps");
2175
2190
  setFps(Number(e.target.value) || 0);
2176
2191
  }}
2177
2192
  />
@@ -2190,7 +2205,7 @@ export function StudioGenerateSurface({
2190
2205
  type="number"
2191
2206
  min={0}
2192
2207
  value={seed}
2193
- onChange={(e) => setSeed(Number(e.target.value) || 0)}
2208
+ onChange={(e) => { markManual("seed"); setSeed(Number(e.target.value) || 0); }}
2194
2209
  />
2195
2210
  </div>
2196
2211
  <div className="vi-knob">
@@ -2226,7 +2241,7 @@ export function StudioGenerateSurface({
2226
2241
  max={100}
2227
2242
  value={steps}
2228
2243
  placeholder="model default"
2229
- onChange={(e) => setSteps(e.target.value)}
2244
+ onChange={(e) => { markManual("steps"); setSteps(e.target.value); }}
2230
2245
  />
2231
2246
  </div>
2232
2247
  <div className="vi-knob">
@@ -2240,7 +2255,7 @@ export function StudioGenerateSurface({
2240
2255
  step={0.5}
2241
2256
  value={cfg}
2242
2257
  placeholder="model default"
2243
- onChange={(e) => setCfg(e.target.value)}
2258
+ onChange={(e) => { markManual("cfg"); setCfg(e.target.value); }}
2244
2259
  />
2245
2260
  </div>
2246
2261
  {/* MODEL PIN — FILTERED BY CAPABILITY (2026-07-29).
@@ -2261,9 +2276,12 @@ export function StudioGenerateSurface({
2261
2276
  id="vi-studio-model"
2262
2277
  className="vi-knob-select"
2263
2278
  value={modelId}
2264
- onChange={(e) => setModelId(e.target.value)}
2279
+ onChange={(e) => { markManual("model_id"); setModelId(e.target.value); }}
2265
2280
  >
2266
2281
  <option value="">Auto — router picks by budget + resolution</option>
2282
+ {modelId && !STUDIO_MODEL_IDS.includes(modelId) && (
2283
+ <option value={modelId}>{modelId} · current bound model</option>
2284
+ )}
2267
2285
  {capModels.length > 0 && (
2268
2286
  <optgroup label={`Ratified for ${CAP_LABELS[capability]}`}>
2269
2287
  {capModels.map((id) => (
@@ -2468,21 +2486,35 @@ export function StudioGenerateSurface({
2468
2486
  onStdNegativeChange={setStdNegative}
2469
2487
  />
2470
2488
  {/* PROMPT ASSIST — Enhance enriches the clip prompt, Generate writes a fresh
2471
- one (seeded by any current text). Same shared cluster as the Generate
2489
+ one. Both update the same field. Same shared cluster as the Generate
2472
2490
  station; errors surface inline and never clobber the existing prompt. */}
2473
2491
  <PromptAssistButtons
2474
2492
  busy={assist.assistBusy}
2475
2493
  error={assist.assistError}
2476
2494
  onDismissError={assist.dismissError}
2477
- onEnhance={() => void assist.runAssist("detail", prompt, setPrompt)}
2495
+ onEnhance={() => void (async () => {
2496
+ let enhanced = "";
2497
+ await assist.runAssist("detail", prompt, (p) => {
2498
+ enhanced = p;
2499
+ setPrompt(p);
2500
+ });
2501
+ if (enhanced && settingsMode === "oracle") {
2502
+ try { await oraclePlan(enhanced); }
2503
+ catch (err) { setGenMsg(err instanceof Error ? err.message : "Oracle planning failed."); }
2504
+ }
2505
+ })()}
2478
2506
  onGenerate={() =>
2479
2507
  void (async () => {
2480
2508
  let fresh = "";
2481
- await assist.runAssist("generate", prompt, (p) => {
2509
+ await assist.runAssist("generate", "", (p) => {
2482
2510
  fresh = p;
2483
2511
  setPrompt(p);
2484
2512
  });
2485
2513
  if (!fresh.trim()) return;
2514
+ if (settingsMode === "oracle") {
2515
+ try { await oraclePlan(fresh); }
2516
+ catch (err) { setGenMsg(err instanceof Error ? err.message : "Oracle planning failed."); }
2517
+ }
2486
2518
  // NEGATIVE RIDES ALONG (operator 2026-08-13): generated
2487
2519
  // exclusions APPEND to any typed negative; std tick untouched.
2488
2520
  await assist.runNegative({ subject: fresh }, (neg) => {
@@ -2543,6 +2575,22 @@ export function StudioGenerateSurface({
2543
2575
  )}
2544
2576
  {promptAttachments.length > 0 && (
2545
2577
  <div className="vi-gen-part-attach">
2578
+ {acceptsStartImage && canAddImage && (
2579
+ <label className="vi-btn vi-btn-sm vi-btn-ghost vi-file-label">
2580
+ {imageBusy ? "Adding image…" : "+ image file"}
2581
+ <input
2582
+ type="file"
2583
+ accept="image/*"
2584
+ className="vi-file-input"
2585
+ disabled={imageBusy}
2586
+ onChange={(e) => {
2587
+ const file = e.target.files?.[0];
2588
+ if (file) void onPickImageFile(file, "start");
2589
+ e.target.value = "";
2590
+ }}
2591
+ />
2592
+ </label>
2593
+ )}
2546
2594
  {promptAttachments.map((a, i) => (
2547
2595
  <button
2548
2596
  key={`${a.label}-${i}`}
@@ -2557,6 +2605,12 @@ export function StudioGenerateSurface({
2557
2605
  ))}
2558
2606
  </div>
2559
2607
  )}
2608
+ {acceptsStartImage && imageError && (
2609
+ <p className="vi-error" role="alert">{imageError}</p>
2610
+ )}
2611
+ {acceptsStartImage && showLibPicker && canAddImage && (
2612
+ <LibraryImageGrid images={libraryImages} onPick={addStartImage} />
2613
+ )}
2560
2614
 
2561
2615
  {/* SETTINGS EXPANDER (operator ask 2026-08-13): the clip prompt
2562
2616
  component hosts the FULL knob set, same as Scene/Movie/Cinema
@@ -2567,15 +2621,27 @@ export function StudioGenerateSurface({
2567
2621
  on the Settings tab only: it morphs the form shape (required
2568
2622
  inputs), it is not a render knob. */}
2569
2623
  <PromptCardSettings label="settings — model · size · steps · cfg · seed · budget">
2624
+ <div className="vi-knob vi-studio-knob-wide">
2625
+ <label htmlFor="vi-clipcard-settings-mode">settings</label>
2626
+ <select id="vi-clipcard-settings-mode" className="vi-knob-select"
2627
+ value={settingsMode}
2628
+ onChange={(e) => setSettingsMode(e.target.value as "oracle" | "manual")}>
2629
+ <option value="oracle">Oracle-generated (default)</option>
2630
+ <option value="manual">Manual</option>
2631
+ </select>
2632
+ </div>
2570
2633
  <div className="vi-knob vi-studio-knob-wide" style={{ minWidth: "16rem" }}>
2571
2634
  <label htmlFor="vi-clipcard-model">model pin</label>
2572
2635
  <select
2573
2636
  id="vi-clipcard-model"
2574
2637
  className="vi-knob-select"
2575
2638
  value={modelId}
2576
- onChange={(e) => setModelId(e.target.value)}
2639
+ onChange={(e) => { markManual("model_id"); setModelId(e.target.value); }}
2577
2640
  >
2578
2641
  <option value="">Auto — router picks by budget + resolution</option>
2642
+ {modelId && !STUDIO_MODEL_IDS.includes(modelId) && (
2643
+ <option value={modelId}>{modelId} · current bound model</option>
2644
+ )}
2579
2645
  {capModels.length > 0 && (
2580
2646
  <optgroup label={`Ratified for ${CAP_LABELS[capability]}`}>
2581
2647
  {capModels.map((id) => (
@@ -2600,36 +2666,36 @@ export function StudioGenerateSurface({
2600
2666
  <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2601
2667
  <label htmlFor="vi-clipcard-width">width</label>
2602
2668
  <input id="vi-clipcard-width" className="vi-knob-input" type="number" min={64} step={8}
2603
- value={width} onChange={(e) => setWidth(Number(e.target.value) || 0)} />
2669
+ value={width} onChange={(e) => { markManual("width"); setWidth(Number(e.target.value) || 0); }} />
2604
2670
  </div>
2605
2671
  <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2606
2672
  <label htmlFor="vi-clipcard-height">height</label>
2607
2673
  <input id="vi-clipcard-height" className="vi-knob-input" type="number" min={64} step={8}
2608
- value={height} onChange={(e) => setHeight(Number(e.target.value) || 0)} />
2674
+ value={height} onChange={(e) => { markManual("height"); setHeight(Number(e.target.value) || 0); }} />
2609
2675
  </div>
2610
2676
  {/* LENGTH ROW (operator 2026-08-13): fps | frames | seconds — any two
2611
2677
  set the third; the dimmed one is derived, last-touched wins. */}
2612
2678
  <LengthRow
2613
2679
  idPrefix="vi-clipcard-len"
2614
2680
  fps={fps}
2615
- onFps={(n) => setFps(n)}
2681
+ onFps={(n) => { markManual("fps"); setFps(n); }}
2616
2682
  frames={requestedFrames}
2617
- onFrames={setRequestedFrames}
2683
+ onFrames={(n) => { markManual("frames"); setRequestedFrames(n); }}
2618
2684
  />
2619
2685
  <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2620
2686
  <label htmlFor="vi-clipcard-steps">steps</label>
2621
2687
  <input id="vi-clipcard-steps" className="vi-knob-input" type="number" min={1} max={100}
2622
- value={steps} placeholder="model default" onChange={(e) => setSteps(e.target.value)} />
2688
+ value={steps} placeholder="model default" onChange={(e) => { markManual("steps"); setSteps(e.target.value); }} />
2623
2689
  </div>
2624
2690
  <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2625
2691
  <label htmlFor="vi-clipcard-cfg">cfg</label>
2626
2692
  <input id="vi-clipcard-cfg" className="vi-knob-input" type="number" min={0} max={20} step={0.5}
2627
- value={cfg} placeholder="model default" onChange={(e) => setCfg(e.target.value)} />
2693
+ value={cfg} placeholder="model default" onChange={(e) => { markManual("cfg"); setCfg(e.target.value); }} />
2628
2694
  </div>
2629
2695
  <div className="vi-knob" style={{ maxWidth: "8rem" }}>
2630
2696
  <label htmlFor="vi-clipcard-seed">seed</label>
2631
2697
  <input id="vi-clipcard-seed" className="vi-knob-input" type="number" min={0}
2632
- value={seed} onChange={(e) => setSeed(Number(e.target.value) || 0)} />
2698
+ value={seed} onChange={(e) => { markManual("seed"); setSeed(Number(e.target.value) || 0); }} />
2633
2699
  </div>
2634
2700
  <div className="vi-knob" style={{ maxWidth: "9rem" }}>
2635
2701
  <label htmlFor="vi-clipcard-vram">VRAM budget GB</label>
@@ -2645,6 +2711,15 @@ export function StudioGenerateSurface({
2645
2711
  </PromptCardSettings>
2646
2712
  </PromptCard>
2647
2713
  </ol>
2714
+ <button type="button" className="vi-btn vi-btn-sm" onClick={addNextPrompt}>
2715
+ + Add next prompt
2716
+ </button>
2717
+ <button type="button" className="vi-btn vi-btn-sm"
2718
+ disabled={produceBusy}
2719
+ onClick={() => void generateProductionFromClip()}>
2720
+ {produceBusy ? "Generating production…" : "✨ Generate production"}
2721
+ </button>
2722
+ {sequenceMsg && <p className="vi-knob-hint" role="status">{sequenceMsg}</p>}
2648
2723
  </section>
2649
2724
 
2650
2725
  {/* Required-input callout — surfaces the missing prerequisite PROMINENTLY next to
@@ -2658,16 +2733,6 @@ export function StudioGenerateSurface({
2658
2733
 
2659
2734
  {/* ── 3) SHAPE EDITOR (heroes) — morphs per preformed shape; stays dominant when
2660
2735
  required (accent gating) even though it now sits below the knob grid. ── */}
2661
- {hero === "start-image" && (
2662
- <section
2663
- aria-label="Required input"
2664
- className="vi-studio-shape"
2665
- style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
2666
- >
2667
- {renderStartImage("hero")}
2668
- {renderSource("inline-alt")}
2669
- </section>
2670
- )}
2671
2736
  {hero === "source" && (
2672
2737
  <section
2673
2738
  aria-label="Required input"
@@ -2703,7 +2768,6 @@ export function StudioGenerateSurface({
2703
2768
  )}
2704
2769
 
2705
2770
  {/* LOWER inputs — only when NOT a hero shape (custom / synthetic / t2v). */}
2706
- {!hero && isI2V && <div className="vi-studio-shape">{renderStartImage("inline")}</div>}
2707
2771
  {!hero && !isT2V && !isIdLock && <div className="vi-studio-shape">{renderSource("inline")}</div>}
2708
2772
  {!hero && isT2V && (
2709
2773
  <p className="vi-knob-hint" role="note" style={{ marginTop: "0.9rem" }}>
@@ -2763,6 +2827,12 @@ export function StudioGenerateSurface({
2763
2827
  {genMsg}
2764
2828
  </p>
2765
2829
  )}
2830
+ {activeClipJob && (
2831
+ <p className="vi-knob-hint" role="status" style={{ margin: 0 }}>
2832
+ Clip {activeClipJob.status ?? "queued"} · {activeClipJob.jobId.slice(0, 8)}
2833
+ {activeClipJob.error ? ` — ${activeClipJob.error}` : ""}
2834
+ </p>
2835
+ )}
2766
2836
  </div>
2767
2837
  {nameWarn && (
2768
2838
  <UnnamedWarnPanel
@@ -2792,7 +2862,7 @@ export function StudioGenerateSurface({
2792
2862
  : isMotion
2793
2863
  ? "Generate (pose / structure)"
2794
2864
  : isI2V
2795
- ? "Generate (i2v)"
2865
+ ? clipRoute === "i2v" ? "Generate (i2v)" : "Generate (t2v)"
2796
2866
  : "Generate (t2v)"}
2797
2867
  </button>
2798
2868
  {/* (The cross-model "🧪 Test all models" ghost that sat here moved into