@hugpy/video-intelligence-ui 0.1.0 → 0.1.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hugpy/video-intelligence-ui",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "type": "module",
6
6
  "description": "Video-intelligence console \u2014 sibling arm of media_intelligence_ui (Vite + Tailwind v4), served at /video. Four stations (image crop, audio crop, frames/models, generate) over the hugpy job bus. Dials the same-origin current-hugpy API (/api).",
@@ -79,6 +79,9 @@ import { getSessionId } from "../session";
79
79
  import { isCanned } from "../demo/mode";
80
80
  import { DEMO_GENERATE_PROMPT } from "../demo/seed";
81
81
  import { usePromptAssist } from "../video/usePromptAssist";
82
+ import { useOperatorHistory } from "../video/useOperatorHistory";
83
+ import { UnnamedWarnPanel, WarnUnnamedToggle, useWarnUnnamedPref, deriveNameFrom } from "../video/UnnamedWarn";
84
+ import { LibraryGroups } from "../video/LibraryGroups";
82
85
  import { PromptAssistButtons } from "../video/PromptAssistButtons";
83
86
  import { registerComposer, type StageRequest } from "../video/composerBridge";
84
87
  import {
@@ -631,6 +634,20 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
631
634
 
632
635
  // ---- optional auto-archive project name (blank = backend auto-names) ----
633
636
  const [projectName, setProjectName] = useSessionState<string>("gen.projectName", "");
637
+ // UNNAMED WARNING (operator 2026-08-13): explicit PER-SECTION option, default
638
+ // OFF, localStorage-remembered — an image that renders in 2 seconds should
639
+ // never suffer a 30-second warning unless its operator opted in.
640
+ const [warnImagePref, setWarnImagePref] = useWarnUnnamedPref("gen.image");
641
+ const [warnScenePref, setWarnScenePref] = useWarnUnnamedPref("gen.scene");
642
+ const [warnMoviePref, setWarnMoviePref] = useWarnUnnamedPref("gen.movie");
643
+ const warnPref =
644
+ mode === "image" ? warnImagePref : mode === "scene" ? warnScenePref : mode === "movie" ? warnMoviePref : false;
645
+ const setWarnPref =
646
+ mode === "image" ? setWarnImagePref : mode === "scene" ? setWarnScenePref : setWarnMoviePref;
647
+ const [nameWarn, setNameWarn] = useState(false);
648
+ const [pendingRun, setPendingRun] = useState(false);
649
+ // Auto-derived names may be replaced by the next ✨ Generate; typed ones never.
650
+ const autoProjectRef = useRef("");
634
651
 
635
652
  // ---- STUDIO TESTER cross-model battery (operator 2026-08-05) ----
636
653
  // The SAME across-models sweep the studio surfaces expose (POST studioTesterUrl),
@@ -984,6 +1001,18 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
984
1001
 
985
1002
  // ---- library (images are pickable prompt parts; videos too) ----
986
1003
  const library = useMediaLibrary();
1004
+ // OPERATOR HISTORY (2026-08-13): a superadmin session back-fills the library
1005
+ // from the server record once per session (complete visibility); see the hook.
1006
+ useOperatorHistory();
1007
+ // GROUPED LIBRARY (operator ask 2026-08-13, v2): the pickers render the
1008
+ // library as TYPE-CATEGORIZED collapsible sections (LibraryGroups) instead
1009
+ // of a flat, section-filtered grid — everything stays visible + pickable,
1010
+ // organized; the CURRENT section's own group (and Inputs) starts expanded.
1011
+ const MODE_GROUP: Record<GenMode, string> = {
1012
+ image: "images", scene: "scenes", movie: "movies",
1013
+ clip: "clips", cinema: "clips",
1014
+ };
1015
+ const libraryDefaultOpen: readonly string[] = ["inputs", MODE_GROUP[mode]];
987
1016
  const libraryImages = library.filter((it) => it.ref.kind === "image");
988
1017
  const libraryVideos = library.filter((it) => it.ref.kind === "video");
989
1018
 
@@ -1353,12 +1382,30 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
1353
1382
  // composition never doubles a term.
1354
1383
  (neg) => {
1355
1384
  const std = p.stdNegative ?? sceneStdNegative;
1385
+ // APPEND, never clobber (operator 2026-08-13): a GENERATED negative adds
1386
+ // to whatever the operator already typed (Enhance still refines-in-place —
1387
+ // its draft was the existing text, so appending would double it).
1388
+ // composeNegative dedupes, so submit-time composition never doubles a term.
1389
+ const cur = (p.negative ?? "").trim().replace(/,\s*$/, "");
1390
+ const mergedRaw = mode === "generate" && cur ? `${cur}, ${neg}` : neg;
1356
1391
  patchPart(p.key, {
1357
- negative: composeNegative(neg, std),
1392
+ negative: composeNegative(mergedRaw, std),
1358
1393
  stdNegative: std,
1359
1394
  });
1360
1395
  },
1361
1396
  );
1397
+ // NAME RIDES ALONG (operator 2026-08-13): derive the project name from the
1398
+ // fresh prompt when blank — or when the current one was itself derived.
1399
+ if (mode === "generate" && newText.trim()) {
1400
+ const t = projectName.trim();
1401
+ if (t === "" || t === autoProjectRef.current) {
1402
+ const derived = deriveNameFrom(newText);
1403
+ if (derived) {
1404
+ autoProjectRef.current = derived;
1405
+ setProjectName(derived);
1406
+ }
1407
+ }
1408
+ }
1362
1409
  }
1363
1410
 
1364
1411
  // "Test all models" / "per preset" as COMPONENTS — the single reusable
@@ -2366,6 +2413,34 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
2366
2413
  </span>
2367
2414
  </div>
2368
2415
  </div>
2416
+ {/* UNIFORM-CARD PLACEHOLDERS (operator 2026-08-13): the core knob set
2417
+ renders on EVERY prompt component. Slots this tier serves at the
2418
+ MOVIE level (fps, VRAM budget) — or expresses differently (a movie
2419
+ goal's LENGTH is its start/end times, frames = duration x fps) —
2420
+ appear GREYED with the reason, never hidden, so every card in every
2421
+ section reads identically. */}
2422
+ <div className="vi-knob-strip">
2423
+ <div className="vi-knob-strip-line">
2424
+ <span className="vi-knob-mini-group">
2425
+ <label className="vi-knob-mini-label" htmlFor={gid("na-fps")}>fps</label>
2426
+ <input id={gid("na-fps")} className="vi-knob-input" style={{ maxWidth: "4.5rem" }} disabled
2427
+ value={fpsNum ?? ""}
2428
+ title="Movie-level — one fps for the whole timeline; set it in the station knobs." />
2429
+ </span>
2430
+ <span className="vi-knob-mini-group">
2431
+ <label className="vi-knob-mini-label" htmlFor={gid("na-length")}>length</label>
2432
+ <input id={gid("na-length")} className="vi-knob-input" style={{ maxWidth: "6.5rem" }} disabled
2433
+ value={`${g.start || "0"}–${g.end || "?"}s`}
2434
+ title="A movie goal's length IS its start/end times (frames = duration x fps) — edit those on the card; a frames knob does not apply here." />
2435
+ </span>
2436
+ <span className="vi-knob-mini-group">
2437
+ <label className="vi-knob-mini-label" htmlFor={gid("na-vram")}>VRAM</label>
2438
+ <input id={gid("na-vram")} className="vi-knob-input" style={{ maxWidth: "5.5rem" }} disabled
2439
+ value="autofit"
2440
+ title="Movie-level — renders size to the serving worker's free VRAM (autofit); the movie tier has no per-goal budget." />
2441
+ </span>
2442
+ </div>
2443
+ </div>
2369
2444
  </>
2370
2445
  );
2371
2446
  }
@@ -3331,7 +3406,23 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
3331
3406
  void assist.runAssist(assistMode, assembledPrompt, applyAssistedPrompt);
3332
3407
  }
3333
3408
 
3334
- function onRun() {
3409
+ useEffect(() => {
3410
+ // Post-commit re-run after the warn panel names the run — fresh closure,
3411
+ // settled state, so the submit sees the new project name.
3412
+ if (!pendingRun) return;
3413
+ setPendingRun(false);
3414
+ onRun(true);
3415
+ });
3416
+
3417
+ function onRun(skipNameCheck = false) {
3418
+ if (
3419
+ !skipNameCheck && warnPref && projectName.trim() === "" &&
3420
+ (mode === "image" || mode === "scene" || mode === "movie")
3421
+ ) {
3422
+ setNameWarn(true);
3423
+ return;
3424
+ }
3425
+ setNameWarn(false);
3335
3426
  // This guard is for the STATION-level knobs (movie + the image fallback).
3336
3427
  // Scene validates every knob PER-PART in buildSceneRow (station values may be
3337
3428
  // blank/unused when knobs are unshared), so it must not be gated here — else a
@@ -3613,6 +3704,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
3613
3704
  Choose an existing project or type a new name. Saves this run&apos;s frames +
3614
3705
  clip to assets/&lt;project&gt;. Blank = an auto-named folder.
3615
3706
  </span>
3707
+ <WarnUnnamedToggle checked={warnPref} onChange={setWarnPref} />
3616
3708
  </div>
3617
3709
 
3618
3710
  {/* PRESET — a curated "ideal default load". Selecting one prefills every
@@ -4396,8 +4488,11 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
4396
4488
  land here and become pickable.
4397
4489
  </p>
4398
4490
  ) : (
4399
- <div className="vi-gen-picker-grid">
4400
- {libraryImages.map((it) => (
4491
+ <LibraryGroups
4492
+ seed={mode}
4493
+ items={libraryImages}
4494
+ defaultOpen={libraryDefaultOpen}
4495
+ renderItem={(it) => (
4401
4496
  <button
4402
4497
  type="button"
4403
4498
  key={it.ref.uri}
@@ -4416,8 +4511,8 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
4416
4511
  {it.label ? ` · ${it.label}` : ""}
4417
4512
  </span>
4418
4513
  </button>
4419
- ))}
4420
- </div>
4514
+ )}
4515
+ />
4421
4516
  )}
4422
4517
  </div>
4423
4518
  )}
@@ -4450,8 +4545,11 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
4450
4545
  {videoError && <span className="vi-error">{videoError}</span>}
4451
4546
  </div>
4452
4547
  {libraryVideos.length > 0 && (
4453
- <div className="vi-gen-picker-grid">
4454
- {libraryVideos.map((it) => (
4548
+ <LibraryGroups
4549
+ seed={mode}
4550
+ items={libraryVideos}
4551
+ defaultOpen={libraryDefaultOpen}
4552
+ renderItem={(it) => (
4455
4553
  <button
4456
4554
  type="button"
4457
4555
  key={it.ref.uri}
@@ -4466,8 +4564,8 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
4466
4564
  <code>{it.ref.mime}</code> · {it.origin}
4467
4565
  </span>
4468
4566
  </button>
4469
- ))}
4470
- </div>
4567
+ )}
4568
+ />
4471
4569
  )}
4472
4570
  </div>
4473
4571
  )}
@@ -4860,9 +4958,32 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
4860
4958
  )
4861
4959
  }
4862
4960
  onGenerate={() =>
4863
- void assist.runAssist("generate", g.prompt, (p) =>
4864
- editGoal(g.id, "prompt", p),
4865
- )
4961
+ void (async () => {
4962
+ let fresh = "";
4963
+ await assist.runAssist("generate", g.prompt, (p) => {
4964
+ fresh = p;
4965
+ editGoal(g.id, "prompt", p);
4966
+ });
4967
+ if (!fresh.trim()) return;
4968
+ // NEGATIVE RIDES ALONG (operator 2026-08-13): the
4969
+ // generated exclusion list APPENDS to the goal's (or
4970
+ // shared) typed negative, never clobbers it.
4971
+ await assist.runNegative({ subject: fresh }, (neg) => {
4972
+ const cur = (movieShare.negative ? negative : g.negative ?? "")
4973
+ .trim().replace(/,\s*$/, "");
4974
+ const merged = cur ? `${cur}, ${neg}` : neg;
4975
+ if (movieShare.negative) setNegative(merged);
4976
+ else patchGoal(g.id, { negative: merged });
4977
+ });
4978
+ const t = projectName.trim();
4979
+ if (t === "" || t === autoProjectRef.current) {
4980
+ const derived = deriveNameFrom(fresh);
4981
+ if (derived) {
4982
+ autoProjectRef.current = derived;
4983
+ setProjectName(derived);
4984
+ }
4985
+ }
4986
+ })()
4866
4987
  }
4867
4988
  canEnhance={g.prompt.trim() !== ""}
4868
4989
  models={assist.assistModels}
@@ -5557,12 +5678,23 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
5557
5678
  </div>
5558
5679
  )}
5559
5680
  </div>
5681
+ {nameWarn && (
5682
+ <UnnamedWarnPanel
5683
+ noun={mode}
5684
+ onProceed={(name) => {
5685
+ setNameWarn(false);
5686
+ if (name) setProjectName(name);
5687
+ setPendingRun(true);
5688
+ }}
5689
+ onDismiss={() => setNameWarn(false)}
5690
+ />
5691
+ )}
5560
5692
  <div className="vi-station-footer-run">
5561
5693
  <button
5562
5694
  type="button"
5563
5695
  className="vi-btn vi-btn-accent"
5564
5696
  disabled={!canRun}
5565
- onClick={onRun}
5697
+ onClick={() => onRun()}
5566
5698
  >
5567
5699
  {running
5568
5700
  ? mode === "movie"
@@ -1029,7 +1029,30 @@ function LibraryPanel({
1029
1029
  items: LibraryItem[];
1030
1030
  onGenerate: boolean;
1031
1031
  }) {
1032
- const works = useMemo(() => groupLibrary(items), [items]);
1032
+ // TYPE SECTIONS (operator 2026-08-13): the library splits into Images and
1033
+ // Videos (plus a rare Other for audio/unknown), each holding its work groups
1034
+ // newest-first — groupLibrary already sorts both levels freshest-first, so
1035
+ // the per-type ordering is "most recent on top" by construction.
1036
+ const sections = useMemo(() => {
1037
+ const imgs = items.filter((it) => it.ref.kind === "image");
1038
+ const vids = items.filter((it) => it.ref.kind === "video");
1039
+ const rest = items.filter((it) => it.ref.kind !== "image" && it.ref.kind !== "video");
1040
+ return [
1041
+ { id: "images", label: "🖼 Images", count: imgs.length, works: groupLibrary(imgs) },
1042
+ { id: "videos", label: "🎞 Videos", count: vids.length, works: groupLibrary(vids) },
1043
+ { id: "other", label: "📎 Other", count: rest.length, works: groupLibrary(rest) },
1044
+ ].filter((sec) => sec.count > 0);
1045
+ }, [items]);
1046
+ // Both type sections start OPEN (hiding freshly-landed work behind a closed
1047
+ // header is how "it never populates" reports happen); the caret remembers.
1048
+ const [closedTypes, setClosedTypes] = useState<Set<string>>(() => new Set());
1049
+ const toggleType = (id: string) =>
1050
+ setClosedTypes((prev) => {
1051
+ const next = new Set(prev);
1052
+ if (next.has(id)) next.delete(id);
1053
+ else next.add(id);
1054
+ return next;
1055
+ });
1033
1056
  // Expand state keyed by work key — the established discipline (a Set that
1034
1057
  // survives the library's refresh cadence: the works array is rebuilt on every
1035
1058
  // change, but the keys are stable, so an expanded work stays open across a
@@ -1052,17 +1075,39 @@ function LibraryPanel({
1052
1075
  }
1053
1076
 
1054
1077
  return (
1055
- <ul className="vi-lib-list">
1056
- {works.map((w) => (
1057
- <WorkSection
1058
- key={w.key}
1059
- group={w}
1060
- open={openKeys.has(w.key)}
1061
- onToggle={() => toggle(w.key)}
1062
- onGenerate={onGenerate}
1063
- />
1064
- ))}
1065
- </ul>
1078
+ <>
1079
+ {sections.map((sec) => {
1080
+ const isOpen = !closedTypes.has(sec.id);
1081
+ return (
1082
+ <div key={sec.id} className="vi-lib-typegroup">
1083
+ <button
1084
+ type="button"
1085
+ className="vi-lib-group-toggle vi-lib-typegroup-head"
1086
+ aria-expanded={isOpen}
1087
+ onClick={() => toggleType(sec.id)}
1088
+ >
1089
+ <span className="vi-lib-group-caret" aria-hidden>
1090
+ {isOpen ? "▾" : "▸"}
1091
+ </span>{" "}
1092
+ {sec.label} <span className="vi-proc-count">{sec.count}</span>
1093
+ </button>
1094
+ {isOpen && (
1095
+ <ul className="vi-lib-list">
1096
+ {sec.works.map((w) => (
1097
+ <WorkSection
1098
+ key={w.key}
1099
+ group={w}
1100
+ open={openKeys.has(w.key)}
1101
+ onToggle={() => toggle(w.key)}
1102
+ onGenerate={onGenerate}
1103
+ />
1104
+ ))}
1105
+ </ul>
1106
+ )}
1107
+ </div>
1108
+ );
1109
+ })}
1110
+ </>
1066
1111
  );
1067
1112
  }
1068
1113
 
@@ -49,6 +49,7 @@ import { createPortal } from "react-dom";
49
49
  import { request, okValue, errorOf, describeAppError } from "../../transport/client";
50
50
  import { hugpyConfig, mediaBytesUrl } from "../../config";
51
51
  import { addToLibrary, useMediaLibrary } from "../../video/mediaLibrary";
52
+ import { UnnamedWarnPanel, WarnUnnamedToggle, useWarnUnnamedPref, deriveNameFrom } from "../../video/UnnamedWarn";
52
53
  import { enqueueResultSchema, mediaRefSchema, uploadResultSchema } from "../../video/contract";
53
54
  import type { MediaRef } from "../../video/contract";
54
55
  import { DropReceptacle } from "../../video/DropReceptacle";
@@ -85,7 +86,7 @@ import { IdentityProfileControls, type SelectedProfile } from "./IdentityProfile
85
86
  // The SHARED prompt-card system (k88/k89): the Clip surface's single prompt card
86
87
  // rides the same shell + [Prompt][Negative] tabs the Scene/Movie/Cinema lists use,
87
88
  // plus the standard-negative composition helper and the sidebar About expander.
88
- import { AboutExpander, PromptCard, PromptFieldTabs, composeNegative } from "./promptCard";
89
+ import { AboutExpander, PromptCard, PromptCardSettings, PromptFieldTabs, composeNegative } from "./promptCard";
89
90
  // ── COMPATIBILITY AWARENESS (operator ruling 2026-07-29) ───────────────────────────
90
91
  // The fleet's MEASURED render table (GET /video/render/presets) + the pure derivations
91
92
  // over it. Everything this surface offers — capabilities, model pins, geometry, the
@@ -304,7 +305,7 @@ export function StudioGenerateSurface({
304
305
  // k89: the card's [standard negative] tick — default OFF to preserve the Clip
305
306
  // surface's blank-negative default; when on, the standard artifact/junk set is
306
307
  // COMPOSED into the payload at submit (composeNegative — the wire is unchanged).
307
- const [stdNegative, setStdNegative] = useSessionState("studio.clip.stdNegative", false);
308
+ const [stdNegative, setStdNegative] = useSessionState("studio.clip.stdNegative", true);
308
309
  // k89: the bottom "[▸ Tester]" collapsible that now hosts the cross-model sweep
309
310
  // (the run row's ghost 🧪 button is retired).
310
311
  const [clipTesterOpen, setClipTesterOpen] = useState(false);
@@ -317,6 +318,20 @@ export function StudioGenerateSurface({
317
318
  // `project` (auto-archive NAME); blank = auto-named (the default). The list of
318
319
  // existing names backs the Settings datalist; free-typing a new one creates it.
319
320
  const [project, setProject] = useSessionState("studio.clip.project", "");
321
+ // EXPLICIT CLIP LENGTH (operator 2026-08-13: "the length of the video
322
+ // generations has been ambiguous") — requested_frames, blank = the bound
323
+ // model's default (81 for real models). The spine clamps to the model
324
+ // ceiling and snaps to the 4k+1 temporal cadence.
325
+ const [requestedFrames, setRequestedFrames] = useSessionState("studio.clip.frames", "");
326
+ // UNNAMED WARNING (operator 2026-08-13): explicit per-section option,
327
+ // default OFF, localStorage-remembered. The clip's NAME is its `project`
328
+ // (the auto-archive name every generate route accepts).
329
+ const [warnUnnamed, setWarnUnnamed] = useWarnUnnamedPref("clip");
330
+ const [nameWarn, setNameWarn] = useState(false);
331
+ const [pendingRun, setPendingRun] = useState(false);
332
+ // Auto-derived project names may be replaced by the next ✨ Generate; a
333
+ // human-typed project is never overwritten.
334
+ const autoProjectRef = useRef("");
320
335
 
321
336
  // i2v start image (cap 1) — the `start_image` field.
322
337
  const [startImages, setStartImages] = useSessionState<MediaRef[]>(
@@ -1111,7 +1126,20 @@ export function StudioGenerateSurface({
1111
1126
  const wouldBindSynthetic =
1112
1127
  (isT2V || isI2V) && bindsSyntheticTier(vramBudget, realFloorGb);
1113
1128
 
1114
- const onGenerate = useCallback(async () => {
1129
+ // Post-commit re-run after the warn panel names the clip: state has settled,
1130
+ // this effect's closure is fresh, so the submit sees the new project name.
1131
+ useEffect(() => {
1132
+ if (!pendingRun) return;
1133
+ setPendingRun(false);
1134
+ void onGenerate(true);
1135
+ });
1136
+
1137
+ const onGenerate = useCallback(async (skipNameCheck = false) => {
1138
+ if (!skipNameCheck && warnUnnamed && project.trim() === "") {
1139
+ setNameWarn(true);
1140
+ return;
1141
+ }
1142
+ setNameWarn(false);
1115
1143
  if (blockSubmit) {
1116
1144
  setGenMsg(
1117
1145
  v2vBadGeom
@@ -1125,6 +1153,11 @@ export function StudioGenerateSurface({
1125
1153
  setGenMsg("VRAM budget must be a positive number (or blank for the route default).");
1126
1154
  return;
1127
1155
  }
1156
+ const framesNum = requestedFrames.trim() === "" ? undefined : Number(requestedFrames);
1157
+ if (framesNum !== undefined && (!Number.isInteger(framesNum) || framesNum < 1)) {
1158
+ setGenMsg("Length must be a whole number of frames \u2265 1 (or blank for the model default).");
1159
+ return;
1160
+ }
1128
1161
  const stepsNum = steps.trim() === "" ? undefined : Number(steps);
1129
1162
  if (
1130
1163
  stepsNum !== undefined &&
@@ -1152,6 +1185,7 @@ export function StudioGenerateSurface({
1152
1185
  vram_budget_gb: budgetNum,
1153
1186
  steps: stepsNum,
1154
1187
  cfg: cfgNum,
1188
+ requested_frames: requestedFrames.trim() !== "" ? Number(requestedFrames) : undefined,
1155
1189
  model_id: modelId.trim() ? modelId.trim() : undefined,
1156
1190
  // Optional auto-archive NAME (choose-or-type). Blank = auto-named (default).
1157
1191
  project: project.trim() ? project.trim() : undefined,
@@ -1250,8 +1284,7 @@ export function StudioGenerateSurface({
1250
1284
  pendingSubjectStage,
1251
1285
  onEnqueued,
1252
1286
  project,
1253
- refreshProjects,
1254
- ]);
1287
+ refreshProjects,, warnUnnamed, project]);
1255
1288
 
1256
1289
  const synthPresets = presets.filter((p) => isSyntheticPreset(p));
1257
1290
  const realPresets = presets.filter((p) => !isSyntheticPreset(p));
@@ -2431,7 +2464,32 @@ export function StudioGenerateSurface({
2431
2464
  error={assist.assistError}
2432
2465
  onDismissError={assist.dismissError}
2433
2466
  onEnhance={() => void assist.runAssist("detail", prompt, setPrompt)}
2434
- onGenerate={() => void assist.runAssist("generate", prompt, setPrompt)}
2467
+ onGenerate={() =>
2468
+ void (async () => {
2469
+ let fresh = "";
2470
+ await assist.runAssist("generate", prompt, (p) => {
2471
+ fresh = p;
2472
+ setPrompt(p);
2473
+ });
2474
+ if (!fresh.trim()) return;
2475
+ // NEGATIVE RIDES ALONG (operator 2026-08-13): generated
2476
+ // exclusions APPEND to any typed negative; std tick untouched.
2477
+ await assist.runNegative({ subject: fresh }, (neg) => {
2478
+ const cur = negative.trim().replace(/,\s*$/, "");
2479
+ setNegative(cur ? `${cur}, ${neg}` : neg);
2480
+ });
2481
+ // NAME RIDES ALONG: derive the project name from the fresh
2482
+ // prompt when blank or itself auto-derived.
2483
+ const t = project.trim();
2484
+ if (t === "" || t === autoProjectRef.current) {
2485
+ const derived = deriveNameFrom(fresh);
2486
+ if (derived) {
2487
+ autoProjectRef.current = derived;
2488
+ setProject(derived);
2489
+ }
2490
+ }
2491
+ })()
2492
+ }
2435
2493
  canEnhance={prompt.trim() !== ""}
2436
2494
  models={assist.assistModels}
2437
2495
  model={assist.assistModel}
@@ -2488,6 +2546,100 @@ export function StudioGenerateSurface({
2488
2546
  ))}
2489
2547
  </div>
2490
2548
  )}
2549
+
2550
+ {/* SETTINGS EXPANDER (operator ask 2026-08-13): the clip prompt
2551
+ component hosts the FULL knob set, same as Scene/Movie/Cinema
2552
+ prompt components. Clip is a SINGLE-prompt surface, so these
2553
+ bind the SAME state as the left-column Settings tab — change a
2554
+ knob in either place and both update; comprehensive by
2555
+ construction (model pin included). The TEMPLATE chooser stays
2556
+ on the Settings tab only: it morphs the form shape (required
2557
+ inputs), it is not a render knob. */}
2558
+ <PromptCardSettings label="settings — model · size · steps · cfg · seed · budget">
2559
+ <div className="vi-knob vi-studio-knob-wide" style={{ minWidth: "16rem" }}>
2560
+ <label htmlFor="vi-clipcard-model">model pin</label>
2561
+ <select
2562
+ id="vi-clipcard-model"
2563
+ className="vi-knob-select"
2564
+ value={modelId}
2565
+ onChange={(e) => setModelId(e.target.value)}
2566
+ >
2567
+ <option value="">Auto — router picks by budget + resolution</option>
2568
+ {capModels.length > 0 && (
2569
+ <optgroup label={`Ratified for ${CAP_LABELS[capability]}`}>
2570
+ {capModels.map((id) => (
2571
+ <option key={id} value={id}>
2572
+ {id}
2573
+ </option>
2574
+ ))}
2575
+ </optgroup>
2576
+ )}
2577
+ {(showAllModels || !renderLoaded || modelOffMenu) && (
2578
+ <optgroup label={renderLoaded ? "Other studio models — unverified for this capability" : "Studio models"}>
2579
+ {STUDIO_MODEL_IDS.filter((id) => !capModels.includes(id)).map((id) => (
2580
+ <option key={id} value={id}>
2581
+ {id}
2582
+ {renderLoaded ? " · unverified" : ""}
2583
+ </option>
2584
+ ))}
2585
+ </optgroup>
2586
+ )}
2587
+ </select>
2588
+ </div>
2589
+ <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2590
+ <label htmlFor="vi-clipcard-width">width</label>
2591
+ <input id="vi-clipcard-width" className="vi-knob-input" type="number" min={64} step={8}
2592
+ value={width} onChange={(e) => setWidth(Number(e.target.value) || 0)} />
2593
+ </div>
2594
+ <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2595
+ <label htmlFor="vi-clipcard-height">height</label>
2596
+ <input id="vi-clipcard-height" className="vi-knob-input" type="number" min={64} step={8}
2597
+ value={height} onChange={(e) => setHeight(Number(e.target.value) || 0)} />
2598
+ </div>
2599
+ <div className="vi-knob" style={{ maxWidth: "6rem" }}>
2600
+ <label htmlFor="vi-clipcard-fps">fps</label>
2601
+ <input id="vi-clipcard-fps" className="vi-knob-input" type="number" min={1}
2602
+ value={fps} onChange={(e) => setFps(Number(e.target.value) || 0)} />
2603
+ </div>
2604
+ <div className="vi-knob" style={{ maxWidth: "11rem" }}>
2605
+ <label htmlFor="vi-clipcard-frames">length — frames</label>
2606
+ <input id="vi-clipcard-frames" className="vi-knob-input" type="number" min={1}
2607
+ value={requestedFrames} placeholder="model default (81)"
2608
+ onChange={(e) => setRequestedFrames(e.target.value)} />
2609
+ <span className="vi-knob-hint">
2610
+ {requestedFrames.trim() !== "" && Number(requestedFrames) > 0 && fps > 0
2611
+ ? `\u2248 ${(Number(requestedFrames) / fps).toFixed(1)}s @ ${fps}fps`
2612
+ : `blank = model default \u2014 81f \u2248 ${fps > 0 ? (81 / fps).toFixed(1) : "?"}s @ ${fps}fps`}
2613
+ {" \u00b7 clamped to the model ceiling, snapped to 4k+1"}
2614
+ </span>
2615
+ </div>
2616
+ <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2617
+ <label htmlFor="vi-clipcard-steps">steps</label>
2618
+ <input id="vi-clipcard-steps" className="vi-knob-input" type="number" min={1} max={100}
2619
+ value={steps} placeholder="model default" onChange={(e) => setSteps(e.target.value)} />
2620
+ </div>
2621
+ <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2622
+ <label htmlFor="vi-clipcard-cfg">cfg</label>
2623
+ <input id="vi-clipcard-cfg" className="vi-knob-input" type="number" min={0} max={20} step={0.5}
2624
+ value={cfg} placeholder="model default" onChange={(e) => setCfg(e.target.value)} />
2625
+ </div>
2626
+ <div className="vi-knob" style={{ maxWidth: "8rem" }}>
2627
+ <label htmlFor="vi-clipcard-seed">seed</label>
2628
+ <input id="vi-clipcard-seed" className="vi-knob-input" type="number" min={0}
2629
+ value={seed} onChange={(e) => setSeed(Number(e.target.value) || 0)} />
2630
+ </div>
2631
+ <div className="vi-knob" style={{ maxWidth: "9rem" }}>
2632
+ <label htmlFor="vi-clipcard-vram">VRAM budget GB</label>
2633
+ <input id="vi-clipcard-vram" className="vi-knob-input" type="number" min={1}
2634
+ value={vramBudget} placeholder="autofit" onChange={(e) => setVramBudget(e.target.value)} />
2635
+ </div>
2636
+ <div className="vi-knob" style={{ maxWidth: "12rem" }}>
2637
+ <label htmlFor="vi-clipcard-project">project (archive name)</label>
2638
+ <input id="vi-clipcard-project" className="vi-knob-input" type="text"
2639
+ value={project} placeholder="optional" onChange={(e) => setProject(e.target.value)} />
2640
+ <WarnUnnamedToggle checked={warnUnnamed} onChange={setWarnUnnamed} />
2641
+ </div>
2642
+ </PromptCardSettings>
2491
2643
  </PromptCard>
2492
2644
  </ol>
2493
2645
  </section>
@@ -2609,6 +2761,17 @@ export function StudioGenerateSurface({
2609
2761
  </p>
2610
2762
  )}
2611
2763
  </div>
2764
+ {nameWarn && (
2765
+ <UnnamedWarnPanel
2766
+ noun="clip"
2767
+ onProceed={(name) => {
2768
+ setNameWarn(false);
2769
+ if (name) setProject(name);
2770
+ setPendingRun(true);
2771
+ }}
2772
+ onDismiss={() => setNameWarn(false)}
2773
+ />
2774
+ )}
2612
2775
  <div className="vi-station-footer-run">
2613
2776
  <button
2614
2777
  type="button"