@hugpy/video-intelligence-ui 0.3.2 → 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 (152) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +9 -0
  3. package/dist/assets/hugpy-mark-BXktFcOQ.png +0 -0
  4. package/dist/assets/index-DMoT8G1q.css +1 -0
  5. package/dist/assets/index-GeD1mo16.js +4190 -0
  6. package/dist/index.html +71 -0
  7. package/entry.tsx +6 -27
  8. package/index.html +4 -4
  9. package/package.json +44 -5
  10. package/src/assets/hugpy-mark.png +0 -0
  11. package/src/config.ts +11 -27
  12. package/src/demo/DemoBanner.tsx +0 -0
  13. package/src/demo/demo.css +0 -0
  14. package/src/demo/demoFetch.ts +0 -9
  15. package/src/demo/fixtures.ts +0 -0
  16. package/src/demo/identityFixture.ts +0 -0
  17. package/src/demo/index.ts +0 -0
  18. package/src/demo/mediaBase.ts +0 -0
  19. package/src/demo/mode.ts +0 -0
  20. package/src/demo/promptAssist.ts +0 -0
  21. package/src/demo/sampleData.ts +0 -0
  22. package/src/demo/sampleSets.ts +0 -0
  23. package/src/demo/seed.ts +0 -0
  24. package/src/nav/BrandMark.css +0 -0
  25. package/src/nav/BrandMark.tsx +0 -0
  26. package/src/nav/Navbar.css +0 -0
  27. package/src/nav/Navbar.tsx +8 -3
  28. package/src/regions/SpatialRegionEditor.tsx +0 -0
  29. package/src/regions/TemporalRegionEditor.tsx +0 -0
  30. package/src/regions/types.ts +0 -0
  31. package/src/session.ts +8 -43
  32. package/src/share.ts +0 -0
  33. package/src/stations/ActivePanel.tsx +0 -0
  34. package/src/stations/ActiveProcessesStation.tsx +0 -0
  35. package/src/stations/AddFileBar.tsx +0 -0
  36. package/src/stations/AudioCropStation.tsx +0 -0
  37. package/src/stations/ComfyPanel.tsx +0 -0
  38. package/src/stations/ComfyStation.tsx +0 -0
  39. package/src/stations/CondensedKnobStrip.tsx +0 -0
  40. package/src/stations/ConsolePanel.tsx +0 -0
  41. package/src/stations/FrameExtractStation.tsx +0 -0
  42. package/src/stations/GenIdentityBar.tsx +0 -0
  43. package/src/stations/GenerateStation.tsx +10 -6
  44. package/src/stations/GoalComposer.tsx +0 -0
  45. package/src/stations/ImageCropStation.tsx +0 -0
  46. package/src/stations/KnobInfo.tsx +0 -0
  47. package/src/stations/PlaceholderStation.tsx +0 -0
  48. package/src/stations/ScriptFirstStation.tsx +4 -0
  49. package/src/stations/SectionTabs.tsx +0 -0
  50. package/src/stations/SharePanel.tsx +4 -4
  51. package/src/stations/SidebarRegistry.tsx +0 -0
  52. package/src/stations/StationShell.tsx +0 -0
  53. package/src/stations/StudioClipsStation.tsx +0 -0
  54. package/src/stations/TemplateStepsPanel.tsx +190 -0
  55. package/src/stations/ToksPanel.tsx +1 -2
  56. package/src/stations/WorkbenchDrawer.tsx +0 -0
  57. package/src/stations/WorkbenchSidebar.tsx +8 -261
  58. package/src/stations/WorkbenchStation.tsx +0 -0
  59. package/src/stations/comfyEmbed.ts +0 -0
  60. package/src/stations/drawerShell.tsx +0 -0
  61. package/src/stations/ops/AudioCropCore.tsx +0 -0
  62. package/src/stations/ops/CharacterGroupsPanel.tsx +75 -83
  63. package/src/stations/ops/FrameExtractCore.tsx +0 -0
  64. package/src/stations/ops/ImageCropCore.tsx +0 -0
  65. package/src/stations/ops/VideoCropCore.tsx +0 -0
  66. package/src/stations/ops/useVideoCropJobs.ts +0 -0
  67. package/src/stations/oracle/DagPanel.tsx +0 -0
  68. package/src/stations/oracle/OracleStation.tsx +2 -0
  69. package/src/stations/oracle/PerformanceComposer.tsx +119 -0
  70. package/src/stations/oracle/SpatialTab.tsx +0 -0
  71. package/src/stations/oracle/StewardPanel.tsx +0 -0
  72. package/src/stations/oracle/oracle.css +33 -0
  73. package/src/stations/oracle/oracleShared.tsx +0 -0
  74. package/src/stations/oracle/oracleTypes.ts +0 -0
  75. package/src/stations/oracle/useOracle.ts +0 -0
  76. package/src/stations/pollCaps.ts +0 -0
  77. package/src/stations/registry.ts +0 -0
  78. package/src/stations/scriptFirst.css +23 -0
  79. package/src/stations/studio/AngleRingEditor.tsx +0 -0
  80. package/src/stations/studio/IdentitiesStation.tsx +86 -76
  81. package/src/stations/studio/IdentityCreatePanel.tsx +0 -0
  82. package/src/stations/studio/IdentityProfileControls.tsx +0 -0
  83. package/src/stations/studio/StudioActiveProcesses.tsx +0 -0
  84. package/src/stations/studio/StudioAssistLogPanel.tsx +0 -0
  85. package/src/stations/studio/StudioGenerateMode.tsx +3 -2
  86. package/src/stations/studio/StudioGenerateTab.tsx +278 -190
  87. package/src/stations/studio/StudioItemCard.tsx +0 -0
  88. package/src/stations/studio/StudioLibraryTab.tsx +0 -0
  89. package/src/stations/studio/StudioMovieComposer.tsx +321 -46
  90. package/src/stations/studio/StudioMovieSessions.tsx +12 -19
  91. package/src/stations/studio/StudioPlane.tsx +28 -5
  92. package/src/stations/studio/StudioSplicedRow.tsx +0 -0
  93. package/src/stations/studio/StudioViewer.tsx +6 -11
  94. package/src/stations/studio/TurntableViewer.tsx +0 -0
  95. package/src/stations/studio/coordinationBadge.tsx +0 -0
  96. package/src/stations/studio/identityUpload.ts +0 -0
  97. package/src/stations/studio/movieTimeline.ts +0 -0
  98. package/src/stations/studio/promptCard.tsx +0 -0
  99. package/src/stations/studio/studioItem.ts +0 -0
  100. package/src/stations/studio/studioShared.tsx +14 -2
  101. package/src/stations/studio/useIdentityCreateJob.ts +26 -21
  102. package/src/stations/studio/useIdentityProfiles.ts +0 -0
  103. package/src/stations/studio/useReconstruction.ts +11 -5
  104. package/src/stations/types.ts +0 -0
  105. package/src/stations/useAudioCropJobs.ts +0 -0
  106. package/src/stations/useAudioExtractJob.ts +0 -0
  107. package/src/stations/useCropJobs.ts +0 -0
  108. package/src/stations/useFrameJobs.ts +0 -0
  109. package/src/stations/useGenerateJob.ts +0 -0
  110. package/src/stations/useGenerateMovieJob.ts +0 -0
  111. package/src/stations/useGenerateRowJobs.ts +0 -0
  112. package/src/stations/useGenerateSceneJob.ts +0 -0
  113. package/src/stations/useGenerateSceneRowJobs.ts +0 -0
  114. package/src/stations/useScriptFirstRun.ts +0 -0
  115. package/src/style/app.css +49 -0
  116. package/src/transport/client.ts +0 -0
  117. package/src/transport/telemetry.ts +0 -0
  118. package/src/video/DropReceptacle.tsx +0 -0
  119. package/src/video/LengthRow.tsx +0 -0
  120. package/src/video/LibraryGroups.tsx +0 -0
  121. package/src/video/PromptAssistButtons.tsx +5 -4
  122. package/src/video/UnnamedWarn.tsx +0 -0
  123. package/src/video/composerBridge.ts +0 -0
  124. package/src/video/contract.ts +0 -0
  125. package/src/video/coordinationReport.ts +0 -0
  126. package/src/video/jobClock.ts +0 -0
  127. package/src/video/jobStream.ts +119 -0
  128. package/src/video/jobTracker.ts +67 -76
  129. package/src/video/mediaLibrary.ts +0 -0
  130. package/src/video/modelsFeed.ts +105 -0
  131. package/src/video/renderCompat.ts +0 -0
  132. package/src/video/sessionForm.ts +0 -0
  133. package/src/video/spreadAssist.ts +0 -0
  134. package/src/video/studioBridge.ts +0 -0
  135. package/src/video/studioOracle.ts +48 -0
  136. package/src/video/useMediaJobs.ts +17 -3
  137. package/src/video/useModels.ts +12 -53
  138. package/src/video/useMoviePresets.ts +0 -0
  139. package/src/video/useMovieSessions.ts +15 -6
  140. package/src/video/useOperatorHistory.ts +11 -101
  141. package/src/video/usePresets.ts +0 -0
  142. package/src/video/useProjects.ts +0 -0
  143. package/src/video/usePromptAssist.ts +70 -25
  144. package/src/video/useRenderPresets.ts +0 -0
  145. package/src/video/useStudioAssistLog.ts +0 -0
  146. package/src/video/useStudioPresets.ts +0 -0
  147. package/src/video/useToks.ts +14 -6
  148. package/src/video/watchVideoJob.ts +43 -0
  149. package/tsconfig.json +0 -0
  150. package/vite.config.ts +0 -0
  151. package/src/stations/studio/StudioMovieComposer.tsx.bak-declutter-20260813 +0 -3031
  152. 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.
@@ -416,6 +437,11 @@ export function StudioGenerateSurface({
416
437
  height?: number;
417
438
  fps?: number;
418
439
  seed?: number;
440
+ startImage?: string;
441
+ steps?: number;
442
+ cfg?: number;
443
+ requestedFrames?: number;
444
+ negative?: string;
419
445
  }) => {
420
446
  const p = prompt.trim();
421
447
  if (!p) {
@@ -430,6 +456,11 @@ export function StudioGenerateSurface({
430
456
  if (opts.height != null) body.height = opts.height;
431
457
  if (opts.fps != null) body.fps = opts.fps;
432
458
  if (opts.seed != null) body.seed = opts.seed;
459
+ if (opts.startImage) body.start_image = opts.startImage;
460
+ if (opts.steps != null) body.steps = opts.steps;
461
+ if (opts.cfg != null) body.cfg = opts.cfg;
462
+ if (opts.requestedFrames != null) body.requested_frames = opts.requestedFrames;
463
+ if (opts.negative) body.negative = opts.negative;
433
464
  if (opts.models.length > 0) body.models = opts.models;
434
465
  const res = await request<unknown>(hugpyConfig.studioTesterUrl, {
435
466
  method: "POST",
@@ -476,6 +507,27 @@ export function StudioGenerateSurface({
476
507
  const isV2V = capability === "v2v";
477
508
  const isIdLock = capability === "id_lock";
478
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
+ };
479
531
 
480
532
  // ── the fleet's measured render table + everything derived from it ───────────────
481
533
  // One session-cached GET shared with the Movie composer (module singleton in the
@@ -928,19 +980,16 @@ export function StudioGenerateSurface({
928
980
 
929
981
  // ── required-input gate + preformed SHAPE ──
930
982
  // v2v: a source clip is REQUIRED (nothing to repaint otherwise) — always the hero.
931
- // i2v with a requires_source preset: a START IMAGE or a SOURCE CLIP (extend) is
932
- // 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.
933
984
  // id_lock: >=1 REFERENCE image is required (the route 400s otherwise) — reference
934
985
  // slots are the hero; the composition control is optional + both-or-neither.
935
986
  // t2v / synthetic i2v / custom: no required input; inputs stay inline + optional.
936
- const presetNeedsInput = preset?.requires_source === true;
937
987
  const hasImage = startImages.length > 0;
938
988
  const hasSource = !!source;
939
989
  const hasReference = referenceImages.length > 0;
940
990
  const canAddImage = startImages.length < MAX_START_IMAGES;
941
991
  const canAddReference = referenceImages.length < MAX_REFERENCE_IMAGES;
942
992
  const needSource = isV2V;
943
- const needImageOrSource = isI2V && presetNeedsInput;
944
993
  const needReference = isIdLock;
945
994
  // MOTION requires a control still and it IS the capability — the exact mirror of
946
995
  // id_lock's reference rule (video_routes: "a motion request with no control carries NO
@@ -956,40 +1005,26 @@ export function StudioGenerateSurface({
956
1005
  const controlAndSource = isMotion && hasControl && hasSource;
957
1006
  const missingRequired =
958
1007
  (needSource && !hasSource) ||
959
- (needImageOrSource && !hasImage && !hasSource) ||
960
1008
  (needReference && !hasReference) ||
961
1009
  (needControl && !hasControl) ||
962
1010
  controlAndSource ||
963
1011
  controlHalfSet;
964
1012
 
965
- const hero: "start-image" | "source" | "reference" | "control" | null = isMotion
1013
+ const hero: "source" | "reference" | "control" | null = isMotion
966
1014
  ? "control"
967
1015
  : isIdLock
968
1016
  ? "reference"
969
1017
  : isV2V
970
1018
  ? "source"
971
- : needImageOrSource
972
- ? "start-image"
973
- : null;
974
- const startImageAttention = needImageOrSource && !hasImage && !hasSource;
1019
+ : null;
975
1020
  const sourceAttention =
976
- (needSource && !hasSource) || (needImageOrSource && !hasImage && !hasSource);
1021
+ needSource && !hasSource;
977
1022
  const referenceAttention = needReference && !hasReference;
978
1023
 
979
1024
  // ── Composer attach idiom (operator ask 2026-07-11, option A) ──
980
- // The prompt composer's inline attach toolbar + chips and the hero shape editor below
981
- // are TWO ACCESS PATHS TO THE SAME STATE — one source of truth, two views. We adopt
982
- // GoalComposer's documented attach idiom (attachments = toolbar buttons, attached =
983
- // removable chips) and wire its callbacks to THIS file's OWN pickers / MediaRef flows,
984
- // offering only the inputs the ACTIVE shape accepts (mirroring the render* predicates
985
- // above). The buttons open the SAME `showLibPicker` / `showSourcePicker` the hero uses,
986
- // so the library pick routes to start (i2v) or reference (v2v / id_lock) exactly as the
987
- // hero's own lib-picker routing does; the chips reflect the SAME startImages / source /
988
- // referenceImages / controlImage state the hero renders, and reuse the hero's existing
989
- // removes. Applicability decides whether a button EXISTS (t2v gets no toolbar — omitted
990
- // cleanly by GoalComposer when the array is empty); capacity decides whether it is
991
- // DISABLED (a filled slot mirrors the hero's "you're at the cap" note, not a vanished
992
- // 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.
993
1028
  //
994
1029
  // 2026-07-29: these three are no longer hand-written capability unions — they are the
995
1030
  // route's own input gates, transcribed once in renderCompat's ACCEPTS matrix and read
@@ -1093,7 +1128,7 @@ export function StudioGenerateSurface({
1093
1128
  ? "Composition control is both-or-neither — add a control image AND a kind, or clear both."
1094
1129
  : needSource && !hasSource
1095
1130
  ? "Restyle (v2v) needs a source clip — add one above."
1096
- : "This generator needs a start image (or a source clip to extend) — add one above.";
1131
+ : "Add the required input above.";
1097
1132
 
1098
1133
  // v2v stays a HARD block (the router refuses an out-of-envelope restyle outright). The
1099
1134
  // envelope itself is now the ratified preset's rather than a mirrored constant.
@@ -1180,14 +1215,18 @@ export function StudioGenerateSurface({
1180
1215
  // on motion (required, definitional) — `slots.controlImage` is that gate.
1181
1216
  const controlReady = (isIdLock || isMotion) && !!controlImage && controlKind !== "";
1182
1217
  const body: Record<string, unknown> = {
1183
- capability,
1218
+ capability: clipRoute,
1219
+ settings_mode: settingsMode,
1220
+ explicit_settings: manualSettings.current,
1184
1221
  resolution: { width, height, fps },
1185
1222
  seed,
1186
1223
  vram_budget_gb: budgetNum,
1187
1224
  steps: stepsNum,
1188
1225
  cfg: cfgNum,
1189
1226
  requested_frames: requestedFrames.trim() !== "" ? Number(requestedFrames) : undefined,
1190
- 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,
1191
1230
  // Optional auto-archive NAME (choose-or-type). Blank = auto-named (default).
1192
1231
  project: project.trim() ? project.trim() : undefined,
1193
1232
  prompt: prompt.trim() ? prompt : undefined,
@@ -1220,6 +1259,15 @@ export function StudioGenerateSurface({
1220
1259
  control_image: controlReady ? (controlImage as MediaRef).uri : undefined,
1221
1260
  control_kind: controlReady ? controlKind : undefined,
1222
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
+ }
1223
1271
  const res = await request<unknown>(hugpyConfig.studioI2VEnqueueUrl, {
1224
1272
  method: "POST",
1225
1273
  headers: { "Content-Type": "application/json" },
@@ -1231,28 +1279,34 @@ export function StudioGenerateSurface({
1231
1279
  setGenMsg(`Enqueue failed: ${describeAppError(errorOf(res))}`);
1232
1280
  return;
1233
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);
1234
1295
  // Subject-clip auto-stage (armed by onGenerateNewSubject): the FIRST successful
1235
1296
  // enqueue after "Generate new clip" arms the wait on ITS job id — later manual
1236
1297
  // generates (pendingSubjectStage already false by then) are plain renders.
1237
1298
  if (pendingSubjectStage) {
1238
1299
  setPendingSubjectStage(false);
1239
- const enq = enqueueResultSchema.safeParse(okValue(res));
1240
- if (enq.success) {
1241
- setAwaitingSubjectJobId(enq.data.job_id);
1242
- setGenMsg(
1243
- "Enqueued — once this clip is produced it becomes the staged subject automatically.",
1244
- );
1245
- } else {
1246
- setGenMsg(
1247
- "Enqueued, but no job id came back to track — stage the produced clip as the subject manually (From library) once it's done.",
1248
- );
1249
- }
1300
+ setAwaitingSubjectJobId(enq.data.job_id);
1301
+ setGenMsg("Enqueued — once this clip is produced it becomes the staged subject automatically.");
1250
1302
  } else {
1251
- 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.`);
1252
1304
  }
1253
1305
  onEnqueued();
1254
1306
  // A project name coined here should appear in the combobox next time.
1255
1307
  if (project.trim()) refreshProjects();
1308
+ } catch (err) {
1309
+ setGenMsg(err instanceof Error ? err.message : "Oracle planning failed.");
1256
1310
  } finally {
1257
1311
  if (mounted.current) setGenBusy(false);
1258
1312
  }
@@ -1261,6 +1315,7 @@ export function StudioGenerateSurface({
1261
1315
  v2vBadGeom,
1262
1316
  missingReason,
1263
1317
  capability,
1318
+ clipRoute,
1264
1319
  width,
1265
1320
  height,
1266
1321
  fps,
@@ -1285,7 +1340,72 @@ export function StudioGenerateSurface({
1285
1340
  pendingSubjectStage,
1286
1341
  onEnqueued,
1287
1342
  project,
1288
- 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
+ }
1289
1409
 
1290
1410
  const synthPresets = presets.filter((p) => isSyntheticPreset(p));
1291
1411
  const realPresets = presets.filter((p) => !isSyntheticPreset(p));
@@ -1348,106 +1468,6 @@ export function StudioGenerateSurface({
1348
1468
  );
1349
1469
  }
1350
1470
 
1351
- // ── start-image input (variant-driven: hero = dominant/accented; inline = subdued) ──
1352
- function renderStartImage(variant: "hero" | "inline") {
1353
- const isHero = variant === "hero";
1354
- const drawEye = isHero && startImageAttention;
1355
- const thumb = isHero ? "9rem" : "5rem";
1356
- return (
1357
- <section aria-label="Start image" style={{ marginTop: isHero ? 0 : "0.9rem" }}>
1358
- <p
1359
- className="vi-comfy-label"
1360
- style={{ marginBottom: "0.35rem", opacity: isHero ? 1 : 0.75 }}
1361
- >
1362
- Start image{isHero ? " — required for this generator" : " (optional)"}
1363
- </p>
1364
- {drawEye && (
1365
- <p className="vi-input-callout" role="note">
1366
- This generator needs a start image — the subject is carried from it. Drop one below
1367
- (or extend a source clip instead).
1368
- </p>
1369
- )}
1370
- {hasImage && (
1371
- <div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginBottom: "0.5rem" }}>
1372
- {startImages.map((img, idx) => (
1373
- <div key={img.uri} style={{ display: "flex", flexDirection: "column", gap: "0.2rem" }}>
1374
- <img
1375
- src={mediaBytesUrl(img.uri)}
1376
- alt="start"
1377
- style={{
1378
- width: thumb,
1379
- height: thumb,
1380
- objectFit: "cover",
1381
- borderRadius: "0.4rem",
1382
- background: "#000",
1383
- border: "1px solid var(--vi-border)",
1384
- }}
1385
- />
1386
- <button
1387
- type="button"
1388
- className="vi-btn vi-btn-sm vi-btn-ghost"
1389
- onClick={() => removeStartImage(idx)}
1390
- >
1391
- Remove
1392
- </button>
1393
- </div>
1394
- ))}
1395
- </div>
1396
- )}
1397
- {canAddImage ? (
1398
- isHero && !hasImage ? (
1399
- <DropReceptacle
1400
- onFile={(f) => void onPickImageFile(f, "start")}
1401
- accept="image/*"
1402
- busy={imageBusy}
1403
- title="Drop a start image or click to browse"
1404
- hint="This generator carries the subject from this still."
1405
- className={drawEye ? "vi-dropzone--required" : undefined}
1406
- />
1407
- ) : (
1408
- <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
1409
- <label className="vi-btn vi-btn-accent vi-file-label">
1410
- {imageBusy ? "Working…" : "Upload a still"}
1411
- <input
1412
- type="file"
1413
- accept="image/*"
1414
- className="vi-file-input"
1415
- disabled={imageBusy}
1416
- onChange={(e) => {
1417
- const f = e.target.files?.[0];
1418
- if (f) void onPickImageFile(f, "start");
1419
- e.target.value = "";
1420
- }}
1421
- />
1422
- </label>
1423
- <button
1424
- type="button"
1425
- className="vi-btn vi-btn-ghost"
1426
- onClick={() => setShowLibPicker((v) => !v)}
1427
- aria-expanded={showLibPicker}
1428
- >
1429
- {showLibPicker ? "Hide library" : `Pick from library (${libraryImages.length})`}
1430
- </button>
1431
- </div>
1432
- )
1433
- ) : (
1434
- <p className="vi-comfy-hint" role="note">
1435
- Multi-image conditioning here is identity lock — switch capability to Identity lock to
1436
- add 1–4 reference images.
1437
- </p>
1438
- )}
1439
- {imageError && (
1440
- <p className="vi-error" role="alert" style={{ marginTop: "0.4rem" }}>
1441
- {imageError}
1442
- </p>
1443
- )}
1444
- {showLibPicker && canAddImage && (
1445
- <LibraryImageGrid images={libraryImages} onPick={(ref) => addStartImage(ref)} />
1446
- )}
1447
- </section>
1448
- );
1449
- }
1450
-
1451
1471
  // ── reference-image SLOT LIST: 1–4 ordered stills, reorderable. The SAME list feeds
1452
1472
  // two shapes — id_lock (variant "required": the identity to lock, accent-gated) and
1453
1473
  // v2v scene-continuity (variant "optional": an identity held across the restyle). ──
@@ -1713,15 +1733,11 @@ export function StudioGenerateSurface({
1713
1733
  );
1714
1734
  }
1715
1735
 
1716
- // ── source-clip input (hero = dominant/accented; inline = subdued; inline-alt =
1717
- // the "or extend a clip" alternative under a hero start image) ──
1718
- function renderSource(variant: "hero" | "inline" | "inline-alt") {
1736
+ // ── source-clip input (hero = dominant/accented; inline = subdued) ──
1737
+ function renderSource(variant: "hero" | "inline") {
1719
1738
  const isHero = variant === "hero";
1720
- const isAlt = variant === "inline-alt";
1721
1739
  const drawEye = isHero && sourceAttention;
1722
- const label = isAlt
1723
- ? "…or use a source clip (extend from its last frame)"
1724
- : isHero
1740
+ const label = isHero
1725
1741
  ? "Source clip — required (the clip to repaint)"
1726
1742
  : isV2V
1727
1743
  ? "Source clip — required"
@@ -1964,20 +1980,26 @@ export function StudioGenerateSurface({
1964
1980
 
1965
1981
  const knobGrid = (
1966
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>
1967
1992
  {/* ABOUT — the condensed what-is-this / how-to prose (k89), collapsed at the
1968
1993
  top of the Settings-tab content. Absorbs the descriptive halves of the old
1969
1994
  inline callouts; the conditional required-input warnings stay on the form. */}
1970
1995
  <div className="vi-studio-knob-wide" style={{ flex: "1 1 100%" }}>
1971
1996
  <AboutExpander title="About Clip">
1972
1997
  <p>
1973
- Generates ONE studio clip from a prompt plus whatever inputs the chosen
1974
- generator needs.
1998
+ Generates one clip from your prompt and the inputs you provide.
1975
1999
  </p>
1976
2000
  <p>
1977
- Pick a template or capability here and the form morphs to that shape:
1978
- text→video needs nothing, image→video a start image (or a source clip to
1979
- extend), restyle a source clip, identity lock 1–4 reference images, and
1980
- 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.
1981
2003
  </p>
1982
2004
  <p>
1983
2005
  A missing required input is flagged on the card and gates Generate until
@@ -2128,6 +2150,7 @@ export function StudioGenerateSurface({
2128
2150
  title={isV2V ? geomLockTitle : geomEnvelopeTitle}
2129
2151
  onChange={(e) => {
2130
2152
  geomTouched.current = true;
2153
+ markManual("width");
2131
2154
  setWidth(Number(e.target.value) || 0);
2132
2155
  }}
2133
2156
  />
@@ -2145,6 +2168,7 @@ export function StudioGenerateSurface({
2145
2168
  title={isV2V ? geomLockTitle : geomEnvelopeTitle}
2146
2169
  onChange={(e) => {
2147
2170
  geomTouched.current = true;
2171
+ markManual("height");
2148
2172
  setHeight(Number(e.target.value) || 0);
2149
2173
  }}
2150
2174
  />
@@ -2162,6 +2186,7 @@ export function StudioGenerateSurface({
2162
2186
  title={isV2V ? geomLockTitle : geomEnvelopeTitle}
2163
2187
  onChange={(e) => {
2164
2188
  geomTouched.current = true;
2189
+ markManual("fps");
2165
2190
  setFps(Number(e.target.value) || 0);
2166
2191
  }}
2167
2192
  />
@@ -2180,7 +2205,7 @@ export function StudioGenerateSurface({
2180
2205
  type="number"
2181
2206
  min={0}
2182
2207
  value={seed}
2183
- onChange={(e) => setSeed(Number(e.target.value) || 0)}
2208
+ onChange={(e) => { markManual("seed"); setSeed(Number(e.target.value) || 0); }}
2184
2209
  />
2185
2210
  </div>
2186
2211
  <div className="vi-knob">
@@ -2216,7 +2241,7 @@ export function StudioGenerateSurface({
2216
2241
  max={100}
2217
2242
  value={steps}
2218
2243
  placeholder="model default"
2219
- onChange={(e) => setSteps(e.target.value)}
2244
+ onChange={(e) => { markManual("steps"); setSteps(e.target.value); }}
2220
2245
  />
2221
2246
  </div>
2222
2247
  <div className="vi-knob">
@@ -2230,7 +2255,7 @@ export function StudioGenerateSurface({
2230
2255
  step={0.5}
2231
2256
  value={cfg}
2232
2257
  placeholder="model default"
2233
- onChange={(e) => setCfg(e.target.value)}
2258
+ onChange={(e) => { markManual("cfg"); setCfg(e.target.value); }}
2234
2259
  />
2235
2260
  </div>
2236
2261
  {/* MODEL PIN — FILTERED BY CAPABILITY (2026-07-29).
@@ -2251,9 +2276,12 @@ export function StudioGenerateSurface({
2251
2276
  id="vi-studio-model"
2252
2277
  className="vi-knob-select"
2253
2278
  value={modelId}
2254
- onChange={(e) => setModelId(e.target.value)}
2279
+ onChange={(e) => { markManual("model_id"); setModelId(e.target.value); }}
2255
2280
  >
2256
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
+ )}
2257
2285
  {capModels.length > 0 && (
2258
2286
  <optgroup label={`Ratified for ${CAP_LABELS[capability]}`}>
2259
2287
  {capModels.map((id) => (
@@ -2458,21 +2486,35 @@ export function StudioGenerateSurface({
2458
2486
  onStdNegativeChange={setStdNegative}
2459
2487
  />
2460
2488
  {/* PROMPT ASSIST — Enhance enriches the clip prompt, Generate writes a fresh
2461
- 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
2462
2490
  station; errors surface inline and never clobber the existing prompt. */}
2463
2491
  <PromptAssistButtons
2464
2492
  busy={assist.assistBusy}
2465
2493
  error={assist.assistError}
2466
2494
  onDismissError={assist.dismissError}
2467
- 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
+ })()}
2468
2506
  onGenerate={() =>
2469
2507
  void (async () => {
2470
2508
  let fresh = "";
2471
- await assist.runAssist("generate", prompt, (p) => {
2509
+ await assist.runAssist("generate", "", (p) => {
2472
2510
  fresh = p;
2473
2511
  setPrompt(p);
2474
2512
  });
2475
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
+ }
2476
2518
  // NEGATIVE RIDES ALONG (operator 2026-08-13): generated
2477
2519
  // exclusions APPEND to any typed negative; std tick untouched.
2478
2520
  await assist.runNegative({ subject: fresh }, (neg) => {
@@ -2533,6 +2575,22 @@ export function StudioGenerateSurface({
2533
2575
  )}
2534
2576
  {promptAttachments.length > 0 && (
2535
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
+ )}
2536
2594
  {promptAttachments.map((a, i) => (
2537
2595
  <button
2538
2596
  key={`${a.label}-${i}`}
@@ -2547,6 +2605,12 @@ export function StudioGenerateSurface({
2547
2605
  ))}
2548
2606
  </div>
2549
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
+ )}
2550
2614
 
2551
2615
  {/* SETTINGS EXPANDER (operator ask 2026-08-13): the clip prompt
2552
2616
  component hosts the FULL knob set, same as Scene/Movie/Cinema
@@ -2557,15 +2621,27 @@ export function StudioGenerateSurface({
2557
2621
  on the Settings tab only: it morphs the form shape (required
2558
2622
  inputs), it is not a render knob. */}
2559
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>
2560
2633
  <div className="vi-knob vi-studio-knob-wide" style={{ minWidth: "16rem" }}>
2561
2634
  <label htmlFor="vi-clipcard-model">model pin</label>
2562
2635
  <select
2563
2636
  id="vi-clipcard-model"
2564
2637
  className="vi-knob-select"
2565
2638
  value={modelId}
2566
- onChange={(e) => setModelId(e.target.value)}
2639
+ onChange={(e) => { markManual("model_id"); setModelId(e.target.value); }}
2567
2640
  >
2568
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
+ )}
2569
2645
  {capModels.length > 0 && (
2570
2646
  <optgroup label={`Ratified for ${CAP_LABELS[capability]}`}>
2571
2647
  {capModels.map((id) => (
@@ -2590,36 +2666,36 @@ export function StudioGenerateSurface({
2590
2666
  <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2591
2667
  <label htmlFor="vi-clipcard-width">width</label>
2592
2668
  <input id="vi-clipcard-width" className="vi-knob-input" type="number" min={64} step={8}
2593
- value={width} onChange={(e) => setWidth(Number(e.target.value) || 0)} />
2669
+ value={width} onChange={(e) => { markManual("width"); setWidth(Number(e.target.value) || 0); }} />
2594
2670
  </div>
2595
2671
  <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2596
2672
  <label htmlFor="vi-clipcard-height">height</label>
2597
2673
  <input id="vi-clipcard-height" className="vi-knob-input" type="number" min={64} step={8}
2598
- value={height} onChange={(e) => setHeight(Number(e.target.value) || 0)} />
2674
+ value={height} onChange={(e) => { markManual("height"); setHeight(Number(e.target.value) || 0); }} />
2599
2675
  </div>
2600
2676
  {/* LENGTH ROW (operator 2026-08-13): fps | frames | seconds — any two
2601
2677
  set the third; the dimmed one is derived, last-touched wins. */}
2602
2678
  <LengthRow
2603
2679
  idPrefix="vi-clipcard-len"
2604
2680
  fps={fps}
2605
- onFps={(n) => setFps(n)}
2681
+ onFps={(n) => { markManual("fps"); setFps(n); }}
2606
2682
  frames={requestedFrames}
2607
- onFrames={setRequestedFrames}
2683
+ onFrames={(n) => { markManual("frames"); setRequestedFrames(n); }}
2608
2684
  />
2609
2685
  <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2610
2686
  <label htmlFor="vi-clipcard-steps">steps</label>
2611
2687
  <input id="vi-clipcard-steps" className="vi-knob-input" type="number" min={1} max={100}
2612
- 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); }} />
2613
2689
  </div>
2614
2690
  <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2615
2691
  <label htmlFor="vi-clipcard-cfg">cfg</label>
2616
2692
  <input id="vi-clipcard-cfg" className="vi-knob-input" type="number" min={0} max={20} step={0.5}
2617
- 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); }} />
2618
2694
  </div>
2619
2695
  <div className="vi-knob" style={{ maxWidth: "8rem" }}>
2620
2696
  <label htmlFor="vi-clipcard-seed">seed</label>
2621
2697
  <input id="vi-clipcard-seed" className="vi-knob-input" type="number" min={0}
2622
- value={seed} onChange={(e) => setSeed(Number(e.target.value) || 0)} />
2698
+ value={seed} onChange={(e) => { markManual("seed"); setSeed(Number(e.target.value) || 0); }} />
2623
2699
  </div>
2624
2700
  <div className="vi-knob" style={{ maxWidth: "9rem" }}>
2625
2701
  <label htmlFor="vi-clipcard-vram">VRAM budget GB</label>
@@ -2635,6 +2711,15 @@ export function StudioGenerateSurface({
2635
2711
  </PromptCardSettings>
2636
2712
  </PromptCard>
2637
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>}
2638
2723
  </section>
2639
2724
 
2640
2725
  {/* Required-input callout — surfaces the missing prerequisite PROMINENTLY next to
@@ -2648,16 +2733,6 @@ export function StudioGenerateSurface({
2648
2733
 
2649
2734
  {/* ── 3) SHAPE EDITOR (heroes) — morphs per preformed shape; stays dominant when
2650
2735
  required (accent gating) even though it now sits below the knob grid. ── */}
2651
- {hero === "start-image" && (
2652
- <section
2653
- aria-label="Required input"
2654
- className="vi-studio-shape"
2655
- style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
2656
- >
2657
- {renderStartImage("hero")}
2658
- {renderSource("inline-alt")}
2659
- </section>
2660
- )}
2661
2736
  {hero === "source" && (
2662
2737
  <section
2663
2738
  aria-label="Required input"
@@ -2693,7 +2768,6 @@ export function StudioGenerateSurface({
2693
2768
  )}
2694
2769
 
2695
2770
  {/* LOWER inputs — only when NOT a hero shape (custom / synthetic / t2v). */}
2696
- {!hero && isI2V && <div className="vi-studio-shape">{renderStartImage("inline")}</div>}
2697
2771
  {!hero && !isT2V && !isIdLock && <div className="vi-studio-shape">{renderSource("inline")}</div>}
2698
2772
  {!hero && isT2V && (
2699
2773
  <p className="vi-knob-hint" role="note" style={{ marginTop: "0.9rem" }}>
@@ -2753,6 +2827,12 @@ export function StudioGenerateSurface({
2753
2827
  {genMsg}
2754
2828
  </p>
2755
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
+ )}
2756
2836
  </div>
2757
2837
  {nameWarn && (
2758
2838
  <UnnamedWarnPanel
@@ -2782,7 +2862,7 @@ export function StudioGenerateSurface({
2782
2862
  : isMotion
2783
2863
  ? "Generate (pose / structure)"
2784
2864
  : isI2V
2785
- ? "Generate (i2v)"
2865
+ ? clipRoute === "i2v" ? "Generate (i2v)" : "Generate (t2v)"
2786
2866
  : "Generate (t2v)"}
2787
2867
  </button>
2788
2868
  {/* (The cross-model "🧪 Test all models" ghost that sat here moved into
@@ -2813,10 +2893,13 @@ export function StudioGenerateSurface({
2813
2893
  </div>
2814
2894
  {clipTesterOpen && (
2815
2895
  <div className="vi-gen-tester-actions">
2896
+ {!isT2V && !(isI2V && hasImage) && (
2897
+ <span className="vi-knob-hint" role="note">The current sweep supports text-to-video and image-to-video with a start image. Choose one of those shapes to run it.</span>
2898
+ )}
2816
2899
  <button
2817
2900
  type="button"
2818
2901
  className="vi-btn vi-btn-sm vi-btn-tester"
2819
- disabled={testerBusy || !prompt.trim()}
2902
+ disabled={testerBusy || !prompt.trim() || (!isT2V && !(isI2V && hasImage))}
2820
2903
  onClick={() =>
2821
2904
  void onTestAllModels({
2822
2905
  category: surfaceMode,
@@ -2825,9 +2908,14 @@ export function StudioGenerateSurface({
2825
2908
  height,
2826
2909
  fps,
2827
2910
  seed,
2911
+ startImage: isI2V ? startImages[0]?.uri : undefined,
2912
+ steps: steps.trim() ? Number(steps) : undefined,
2913
+ cfg: cfg.trim() ? Number(cfg) : undefined,
2914
+ requestedFrames: requestedFrames.trim() ? Number(requestedFrames) : undefined,
2915
+ negative: negative.trim() || undefined,
2828
2916
  })
2829
2917
  }
2830
- title="Run this prompt across every model that FITS THE CURRENT FILTER (this surface's capability + geometry) — one row per model in the log below (a battery). Falls back to the whole roster when none match."
2918
+ title={!isT2V && !(isI2V && hasImage) ? "This sweep currently supports text-to-video or image-to-video with a start image." : "Run this prompt across each model that fits the current filter; one battery row per model."}
2831
2919
  >
2832
2920
  {testerBusy ? "Starting…" : "🧪 Test all models"}
2833
2921
  </button>