@hugpy/video-intelligence-ui 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/package.json +1 -3
  2. package/src/config.ts +36 -0
  3. package/src/demo/assets/generate/cinema-cavern/movie.mp4 +0 -0
  4. package/src/demo/assets/generate/cinema-cavern/seg_00.mp4 +0 -0
  5. package/src/demo/assets/generate/clips/cat-lounge.mp4 +0 -0
  6. package/src/demo/assets/generate/clips/drone-city.mp4 +0 -0
  7. package/src/demo/assets/generate/clips/paper-sails.mp4 +0 -0
  8. package/src/demo/assets/generate/movie-galaxy/movie.mp4 +0 -0
  9. package/src/demo/assets/generate/movie-galaxy/seg_00.mp4 +0 -0
  10. package/src/demo/assets/generate/movie-galaxy/seg_01.mp4 +0 -0
  11. package/src/demo/assets/generate/movie-galaxy/seg_02.mp4 +0 -0
  12. package/src/demo/assets/generate/movie-galaxy/seg_03.mp4 +0 -0
  13. package/src/demo/assets/generate/movie-medow/movie.mp4 +0 -0
  14. package/src/demo/assets/generate/movie-medow/seg_00.mp4 +0 -0
  15. package/src/demo/assets/generate/movie-medow/seg_01.mp4 +0 -0
  16. package/src/demo/assets/generate/scene-desert/frame_00000.png +0 -0
  17. package/src/demo/assets/generate/scene-desert/frame_00001.png +0 -0
  18. package/src/demo/assets/generate/scene-desert/frame_00002.png +0 -0
  19. package/src/demo/assets/generate/scene-desert/frame_00003.png +0 -0
  20. package/src/demo/assets/generate/scene-desert/frame_00004.png +0 -0
  21. package/src/demo/assets/generate/scene-desert/frame_00005.png +0 -0
  22. package/src/demo/assets/generate/scene-desert/scene.mp4 +0 -0
  23. package/src/demo/assets/generate/scene-vintage/frame_00000.png +0 -0
  24. package/src/demo/assets/generate/scene-vintage/frame_00001.png +0 -0
  25. package/src/demo/assets/generate/scene-vintage/frame_00002.png +0 -0
  26. package/src/demo/assets/generate/scene-vintage/frame_00003.png +0 -0
  27. package/src/demo/assets/generate/scene-vintage/frame_00004.png +0 -0
  28. package/src/demo/assets/generate/scene-vintage/frame_00005.png +0 -0
  29. package/src/demo/assets/generate/scene-vintage/scene.mp4 +0 -0
  30. package/src/demo/assets/generate2/s0.png +0 -0
  31. package/src/demo/assets/generate2/s1.png +0 -0
  32. package/src/demo/assets/generate2/s10.png +0 -0
  33. package/src/demo/assets/generate2/s11.png +0 -0
  34. package/src/demo/assets/generate2/s12.png +0 -0
  35. package/src/demo/assets/generate2/s13.png +0 -0
  36. package/src/demo/assets/generate2/s14.png +0 -0
  37. package/src/demo/assets/generate2/s15.png +0 -0
  38. package/src/demo/assets/generate2/s16.png +0 -0
  39. package/src/demo/assets/generate2/s17.png +0 -0
  40. package/src/demo/assets/generate2/s18.png +0 -0
  41. package/src/demo/assets/generate2/s19.png +0 -0
  42. package/src/demo/assets/generate2/s2.png +0 -0
  43. package/src/demo/assets/generate2/s20.png +0 -0
  44. package/src/demo/assets/generate2/s21.mp4 +0 -0
  45. package/src/demo/assets/generate2/s22.mp4 +0 -0
  46. package/src/demo/assets/generate2/s23.mp4 +0 -0
  47. package/src/demo/assets/generate2/s24.mp4 +0 -0
  48. package/src/demo/assets/generate2/s25.mp4 +0 -0
  49. package/src/demo/assets/generate2/s26.mp4 +0 -0
  50. package/src/demo/assets/generate2/s27.mp4 +0 -0
  51. package/src/demo/assets/generate2/s28.mp4 +0 -0
  52. package/src/demo/assets/generate2/s29.mp4 +0 -0
  53. package/src/demo/assets/generate2/s3.png +0 -0
  54. package/src/demo/assets/generate2/s30.mp4 +0 -0
  55. package/src/demo/assets/generate2/s4.png +0 -0
  56. package/src/demo/assets/generate2/s5.png +0 -0
  57. package/src/demo/assets/generate2/s6.png +0 -0
  58. package/src/demo/assets/generate2/s7.png +0 -0
  59. package/src/demo/assets/generate2/s8.png +0 -0
  60. package/src/demo/assets/generate2/s9.png +0 -0
  61. package/src/demo/demoFetch.ts +126 -3
  62. package/src/demo/fixtures.ts +72 -4
  63. package/src/demo/promptAssist.ts +25 -0
  64. package/src/demo/sampleData.ts +960 -0
  65. package/src/demo/sampleSets.ts +172 -0
  66. package/src/stations/ActiveProcessesStation.tsx +18 -3
  67. package/src/stations/GenerateStation.tsx +1031 -364
  68. package/src/stations/GoalComposer.tsx +9 -1
  69. package/src/stations/ScriptFirstStation.tsx +1081 -0
  70. package/src/stations/ToksPanel.tsx +188 -0
  71. package/src/stations/WorkbenchDrawer.tsx +4 -3
  72. package/src/stations/WorkbenchSidebar.tsx +60 -12
  73. package/src/stations/oracle/DagPanel.tsx +391 -0
  74. package/src/stations/oracle/OracleStation.tsx +304 -0
  75. package/src/stations/oracle/SpatialTab.tsx +185 -0
  76. package/src/stations/oracle/StewardPanel.tsx +165 -0
  77. package/src/stations/oracle/oracle.css +467 -0
  78. package/src/stations/oracle/oracleShared.tsx +160 -0
  79. package/src/stations/oracle/oracleTypes.ts +304 -0
  80. package/src/stations/oracle/useOracle.ts +560 -0
  81. package/src/stations/registry.ts +37 -0
  82. package/src/stations/scriptFirst.css +350 -0
  83. package/src/stations/studio/IdentitiesStation.tsx +219 -292
  84. package/src/stations/studio/IdentityCreatePanel.tsx +390 -0
  85. package/src/stations/studio/StudioGenerateTab.tsx +162 -6
  86. package/src/stations/studio/StudioMovieComposer.tsx +763 -318
  87. package/src/stations/studio/{StudioMovieComposer.tsx.bak-sessions-20260812 → StudioMovieComposer.tsx.bak-declutter-20260813} +350 -48
  88. package/src/stations/studio/StudioPlane.tsx +35 -3
  89. package/src/stations/studio/StudioViewer.tsx +116 -14
  90. package/src/stations/studio/coordinationBadge.tsx +206 -0
  91. package/src/stations/studio/identityUpload.ts +66 -0
  92. package/src/stations/studio/promptCard.tsx +362 -59
  93. package/src/stations/studio/useIdentityCreateJob.ts +184 -0
  94. package/src/stations/studio/useIdentityProfiles.ts +66 -0
  95. package/src/stations/useScriptFirstRun.ts +596 -0
  96. package/src/style/app.css +176 -0
  97. package/src/video/LengthRow.tsx +189 -0
  98. package/src/video/LibraryGroups.tsx +81 -0
  99. package/src/video/UnnamedWarn.tsx +146 -0
  100. package/src/video/coordinationReport.ts +266 -0
  101. package/src/video/jobClock.ts +104 -0
  102. package/src/video/spreadAssist.ts +26 -0
  103. package/src/video/useMediaJobs.ts +26 -0
  104. package/src/video/useOperatorHistory.ts +105 -0
  105. package/src/video/useToks.ts +193 -0
  106. package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +0 -228
  107. package/src/stations/GenerateStation.tsx.bak-tester-removal +0 -5170
  108. package/src/stations/studio/StudioMovieSessions.tsx.bak-sessions-20260812 +0 -417
@@ -37,13 +37,16 @@ import {
37
37
  PromptFieldTabs,
38
38
  PromptListToolbar,
39
39
  STANDARD_NEGATIVE,
40
+ applyStandardNegative,
40
41
  composeNegative,
42
+ hasStandardNegative,
41
43
  } from "./studio/promptCard";
42
44
  // Movie's group "Generate prompts" rides the SAME one-call spread the Cinema
43
45
  // composer uses (usePromptAssist.runSpread + the pure builders/readers).
44
46
  import {
45
47
  buildSpreadBody,
46
48
  spreadNoticeLines,
49
+ type AssistMediaWire,
47
50
  type SpreadGoalInput,
48
51
  } from "../video/spreadAssist";
49
52
  import { StudioGenerateMode } from "./studio/StudioGenerateMode";
@@ -51,7 +54,7 @@ import { useIdentityProfiles, type IdentityProfile } from "./studio/useIdentityP
51
54
  import { GenIdentityBar, type BoundIdentity } from "./GenIdentityBar";
52
55
  import { useSidebarRegistry } from "./SidebarRegistry";
53
56
  import { useProjects } from "../video/useProjects";
54
- import { cancelJob } from "../video/jobTracker";
57
+ import { cancelJob, trackJob, useJobTracker } from "../video/jobTracker";
55
58
  import { STILL_RUNNING_MESSAGE } from "./pollCaps";
56
59
  import { AddFileBar } from "./AddFileBar";
57
60
  import { useImageModels, useModelsByTask, requiredInputsFor } from "../video/useModels";
@@ -65,7 +68,6 @@ import { useMoviePresets } from "../video/useMoviePresets";
65
68
  import {
66
69
  addToLibrary,
67
70
  useMediaLibrary,
68
- type LibraryItem,
69
71
  } from "../video/mediaLibrary";
70
72
  import {
71
73
  request,
@@ -79,12 +81,17 @@ import { getSessionId } from "../session";
79
81
  import { isCanned } from "../demo/mode";
80
82
  import { DEMO_GENERATE_PROMPT } from "../demo/seed";
81
83
  import { usePromptAssist } from "../video/usePromptAssist";
84
+ import { useOperatorHistory } from "../video/useOperatorHistory";
85
+ import { UnnamedWarnPanel, WarnUnnamedToggle, useWarnUnnamedPref, deriveNameFrom } from "../video/UnnamedWarn";
86
+ import { LibraryGroups } from "../video/LibraryGroups";
82
87
  import { PromptAssistButtons } from "../video/PromptAssistButtons";
83
88
  import { registerComposer, type StageRequest } from "../video/composerBridge";
84
89
  import {
85
90
  mediaRefSchema,
86
91
  uploadResultSchema,
92
+ enqueueResultSchema,
87
93
  isMovieProgress,
94
+ type FrameExtractRequest,
88
95
  type GenPromptPart,
89
96
  type GenerateImageRequest,
90
97
  type GenerateSceneRequest,
@@ -230,7 +237,7 @@ type GoalRow = {
230
237
  // k92 — a movie goal IS a prompt component (same architecture as a Scene part):
231
238
  // it may carry its OWN generation knobs. Held as the same live strings the station
232
239
  // setters use so a goal's override parses/validates exactly like the shared value
233
- // it defaults from. Empty = inherit the movie-level value (see goalKnobValues /
240
+ // it defaults from. Empty = falls back to the movie-level value (see goalKnobValues /
234
241
  // goalExtra). `knobs` = the 4 CondensedKnobStrip knobs (size/steps/guidance/seed);
235
242
  // `extra` = model + the goal-appropriate scene knobs (strength/chain/motion).
236
243
  knobs?: Partial<KnobValues>;
@@ -334,6 +341,14 @@ type PartEntry = {
334
341
  // Per-part PROMPT-ASSIST model (which text generator writes THIS prompt's
335
342
  // Enhance/Generate). Undefined = use the shared assist model / fleet default.
336
343
  assistModel?: string | null;
344
+ // k93 — per-card `negatives: [on/off]`: whether THIS card's Enhance/Generate
345
+ // also writes its Negative field. Undefined = on (today's behavior).
346
+ assistNegatives?: boolean;
347
+ // k93 — a frame PULLED from this part's attached video (the frame-extract
348
+ // job's output). The part keeps its video AND the frame; buildPartPromptParts
349
+ // emits the frame image BEFORE the video so the runner's image_paths[0] (the
350
+ // start frame) is the pulled still.
351
+ frame?: PartMedia;
337
352
  };
338
353
  // The share-tick keys for EVERY per-part setting. "share" = one value across all
339
354
  // prompts; unticked = each prompt keeps its own. (Scene owns its own share state
@@ -488,8 +503,15 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
488
503
  // example prompt (matches the sample gens) so the brochure opens with a coherent,
489
504
  // runnable story instead of a blank composer. The seed string lives in src/demo/
490
505
  // and is injected ONLY behind isCanned() — the live path stays an empty prompt.
506
+ // k93: a new TEXT part opens with the standard negative VISIBLE in its Negative
507
+ // field (operator: standard negatives are the default) — explicit text, sent
508
+ // as-is; the std. tick reads its state from the field and can remove it.
491
509
  const [parts, setParts] = useSessionState<PartEntry[]>("gen.parts", () => [
492
- { key: partKey(), text: isCanned() ? DEMO_GENERATE_PROMPT : "" },
510
+ {
511
+ key: partKey(),
512
+ text: isCanned() ? DEMO_GENERATE_PROMPT : "",
513
+ negative: STANDARD_NEGATIVE,
514
+ },
493
515
  ]);
494
516
  const [showImagePicker, setShowImagePicker] = useState<boolean>(false);
495
517
  const [showVideoPicker, setShowVideoPicker] = useState<boolean>(false);
@@ -560,25 +582,18 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
560
582
  const [guidance, setGuidance] = useSessionState<string>("gen.guidance", DEFAULT_KNOBS.guidance);
561
583
  // blank = null (random)
562
584
  const [seed, setSeed] = useSessionState<string>("gen.seed", DEFAULT_KNOBS.seed);
563
- // k89: no longer SEEDED with the standard-negative TEXT — the standard set now
564
- // rides the [standard negative] checkbox (sceneStdNegative here for Scene; goal
565
- // #1's stdNegative for Movie, seeded ON below) and is COMPOSED in at submit via
566
- // composeNegative, so the effective default payload is byte-identical to the old
567
- // prefill while the field itself opens empty. Presets/drafts still overwrite it.
585
+ // k89: no longer SEEDED with the standard-negative TEXT — for MOVIE the standard
586
+ // set rides goal #1's stdNegative tick (seeded ON below) and is COMPOSED in at
587
+ // submit via composeNegative. For SCENE (k93) this shared value is NOT read at
588
+ // all any more: each part sends exactly what its own visible Negative field
589
+ // holds (blank = blank, nothing implied); presets WRITE into part #1's field.
568
590
  const [negative, setNegative] = useSessionState<string>("gen.negative", "");
569
- // Scene's [standard negative] tick — station-wide like `negative` itself (ONE
570
- // negative per scene), rendered on part #1's tabbed Negative pane. Defaults ON to
571
- // preserve the pre-k89 prefill behavior.
572
- const [sceneStdNegative, setSceneStdNegative] = useSessionState<boolean>(
573
- "gen.sceneStdNegative",
574
- true,
575
- );
576
591
 
577
592
  // ---- per-setting CARRY ticks on the base composer strip (operator ask
578
593
  // 2026-08-04, k63) ----
579
594
  // "Bring this setting into the post created component": every setting in the
580
595
  // condensed strip carries a tick, and the tick decides what a component SEEDED
581
- // from this one inherits (today: a tester row via "+ Add row" / the per-model /
596
+ // from this one receives (today: a tester row via "+ Add row" / the per-model /
582
597
  // per-preset expanders; the same rule is what any future scene-carry flow reads).
583
598
  // Untick a setting and the new component gets the mode default instead — that is
584
599
  // how you build a sweep that varies exactly one thing. Purely client-side: no
@@ -631,6 +646,20 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
631
646
 
632
647
  // ---- optional auto-archive project name (blank = backend auto-names) ----
633
648
  const [projectName, setProjectName] = useSessionState<string>("gen.projectName", "");
649
+ // UNNAMED WARNING (operator 2026-08-13): explicit PER-SECTION option, default
650
+ // OFF, localStorage-remembered — an image that renders in 2 seconds should
651
+ // never suffer a 30-second warning unless its operator opted in.
652
+ const [warnImagePref, setWarnImagePref] = useWarnUnnamedPref("gen.image");
653
+ const [warnScenePref, setWarnScenePref] = useWarnUnnamedPref("gen.scene");
654
+ const [warnMoviePref, setWarnMoviePref] = useWarnUnnamedPref("gen.movie");
655
+ const warnPref =
656
+ mode === "image" ? warnImagePref : mode === "scene" ? warnScenePref : mode === "movie" ? warnMoviePref : false;
657
+ const setWarnPref =
658
+ mode === "image" ? setWarnImagePref : mode === "scene" ? setWarnScenePref : setWarnMoviePref;
659
+ const [nameWarn, setNameWarn] = useState(false);
660
+ const [pendingRun, setPendingRun] = useState(false);
661
+ // Auto-derived names may be replaced by the next ✨ Generate; typed ones never.
662
+ const autoProjectRef = useRef("");
634
663
 
635
664
  // ---- STUDIO TESTER cross-model battery (operator 2026-08-05) ----
636
665
  // The SAME across-models sweep the studio surfaces expose (POST studioTesterUrl),
@@ -655,7 +684,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
655
684
  // and produces its OWN job (see useGenerateRowJobs); "Run all rows" fires them
656
685
  // together so the results land beside each other.
657
686
  //
658
- // Rows deliberately do NOT get their own media. They inherit the base composer's
687
+ // Rows deliberately do NOT get their own media. They reuse the base composer's
659
688
  // image/video/identity parts, so a sweep holds the reference material fixed and
660
689
  // varies only the thing under test — otherwise the comparison proves nothing.
661
690
  const [testerOpen, setTesterOpen] = useState<boolean>(false);
@@ -676,6 +705,8 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
676
705
  const [goals, setGoals] = useSessionState<GoalRow[]>("gen.movie.goals", () => [
677
706
  { id: goalKey(), start: "0", end: String(DEFAULT_GOAL_LEN), prompt: "", stdNegative: true },
678
707
  ]);
708
+ // Rows parked by lowering the goal count — revived (keys intact) when it grows.
709
+ const [goalStash, setGoalStash] = useSessionState<GoalRow[]>("gen.movie.goalStash", () => []);
679
710
  // When set, the image picker attaches its pick as THIS goal's reference (vs a part).
680
711
  const [goalAttachTarget, setGoalAttachTarget] = useState<string | null>(null);
681
712
  // ---- Movie prompt-list toolbar state (k88 — mirrors Cinema's group assist) ----
@@ -693,6 +724,64 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
693
724
  const [sceneSelectedIds, setSceneSelectedIds] = useState<ReadonlySet<string>>(() => new Set());
694
725
  const [sceneSpreadNotice, setSceneSpreadNotice] = useState<string[] | null>(null);
695
726
  const [sceneSteeringSeed, setSceneSteeringSeed] = useState<number | null>(null);
727
+ // ---- k93 Scene group toolbar state ----
728
+ // `negatives: [on/off]` — when ON the group generate/enhance also write each
729
+ // selected part's Negative field (the old separate "Generate negative" button
730
+ // folded in here). Session-remembered like the other toolbar prefs.
731
+ const [sceneGroupNegatives, setSceneGroupNegatives] = useSessionState<boolean>(
732
+ "gen.sceneGroupNegatives",
733
+ true,
734
+ );
735
+ // The last EXPLICIT range (1-based, over text parts) — what "select all" goes
736
+ // back to when unticked. Null until the user has touched the range inputs /
737
+ // per-card ticks.
738
+ const sceneRangeMemo = useRef<{ start: number; end: number } | null>(null);
739
+ // The toolbar-level `[+ video]` PRETEXT: ONE video (library pick or upload)
740
+ // sent as context.media on the group generate/enhance so the backend describes
741
+ // it and feeds that description to the generator. Not a prompt part.
742
+ const [scenePretext, setScenePretext] = useState<PartMedia | null>(null);
743
+ const [scenePretextOpen, setScenePretextOpen] = useState<boolean>(false);
744
+ // Which card has its inline attach panel expanded (one at a time), and what.
745
+ const [partAttachOpen, setPartAttachOpen] = useState<
746
+ { key: string; kind: "image" | "video" | "library" } | null
747
+ >(null);
748
+ // Whether the inline `+ identity` expander is open per card key.
749
+ const [identityOpen, setIdentityOpen] = useState<ReadonlySet<string>>(() => new Set());
750
+ // Pull-frame: per part, the time (seconds, as typed) + the in-flight job id.
751
+ const [pullFrameAt, setPullFrameAt] = useState<Record<string, string>>({});
752
+ const [pullFrameJobs, setPullFrameJobs] = useState<Record<string, string>>({});
753
+ const [pullFrameError, setPullFrameError] = useState<Record<string, string>>({});
754
+ // Watch the tracker for the pull-frame jobs: on `done` the first output becomes
755
+ // this part's `frame` (the tracker has ALREADY pushed it into the library as
756
+ // "frames / frame 1"); on failure the message surfaces on the card.
757
+ const trackedJobs = useJobTracker();
758
+ useEffect(() => {
759
+ const entries = Object.entries(pullFrameJobs);
760
+ if (entries.length === 0) return;
761
+ for (const [key, jobId] of entries) {
762
+ const rec = trackedJobs.find((r) => r.jobId === jobId);
763
+ if (!rec) continue;
764
+ if (rec.status === "done") {
765
+ const out = rec.outputs[0];
766
+ setParts((prev) =>
767
+ prev.map((p) =>
768
+ p.key === key && out
769
+ ? { ...p, frame: { ref: out, origin: "frames", label: rec.label.replace(/^pull /, "") } }
770
+ : p,
771
+ ),
772
+ );
773
+ if (!out) setPullFrameError((prev) => ({ ...prev, [key]: "The job produced no frame." }));
774
+ } else if (rec.status === "failed" || rec.status === "cancelled" || rec.expired) {
775
+ setPullFrameError((prev) => ({ ...prev, [key]: rec.error ?? "Frame pull failed." }));
776
+ } else {
777
+ continue; // still running
778
+ }
779
+ setPullFrameJobs((prev) => {
780
+ const { [key]: _drop, ...rest } = prev;
781
+ return rest;
782
+ });
783
+ }
784
+ }, [trackedJobs, pullFrameJobs, setParts]);
696
785
  // ---- Director knobs (opt-in vision scoring / re-roll loop) ----
697
786
  const [visionEnabled, setVisionEnabled] = useSessionState<boolean>(
698
787
  "gen.visionEnabled",
@@ -806,7 +895,13 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
806
895
  // the prefilled text) — so the standard-set tick clears too, keeping the
807
896
  // effective payload identical to the old override semantics.
808
897
  setNegative(d.negative);
809
- setSceneStdNegative(false);
898
+ // k93 (Scene): presets WRITE the negative into part #1's visible field —
899
+ // never a silent merge at submit. Replaces the whole field (override).
900
+ const presetNeg = d.negative;
901
+ setParts((prev) => {
902
+ const i = prev.findIndex((p) => !p.media);
903
+ return i < 0 ? prev : prev.map((p, j) => (j === i ? { ...p, negative: presetNeg } : p));
904
+ });
810
905
  setGoals((prev) =>
811
906
  prev.map((g, i) => (i === 0 ? { ...g, stdNegative: false } : g)),
812
907
  );
@@ -984,6 +1079,18 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
984
1079
 
985
1080
  // ---- library (images are pickable prompt parts; videos too) ----
986
1081
  const library = useMediaLibrary();
1082
+ // OPERATOR HISTORY (2026-08-13): a superadmin session back-fills the library
1083
+ // from the server record once per session (complete visibility); see the hook.
1084
+ useOperatorHistory();
1085
+ // GROUPED LIBRARY (operator ask 2026-08-13, v2): the pickers render the
1086
+ // library as TYPE-CATEGORIZED collapsible sections (LibraryGroups) instead
1087
+ // of a flat, section-filtered grid — everything stays visible + pickable,
1088
+ // organized; the CURRENT section's own group (and Inputs) starts expanded.
1089
+ const MODE_GROUP: Record<GenMode, string> = {
1090
+ image: "images", scene: "scenes", movie: "movies",
1091
+ clip: "clips", cinema: "clips",
1092
+ };
1093
+ const libraryDefaultOpen: readonly string[] = ["inputs", MODE_GROUP[mode]];
987
1094
  const libraryImages = library.filter((it) => it.ref.kind === "image");
988
1095
  const libraryVideos = library.filter((it) => it.ref.kind === "video");
989
1096
 
@@ -1172,7 +1279,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
1172
1279
 
1173
1280
  // ---- part mutations ----
1174
1281
  function addText() {
1175
- setParts((prev) => [...prev, { key: partKey(), text: "" }]);
1282
+ setParts((prev) => [...prev, { key: partKey(), text: "", negative: STANDARD_NEGATIVE }]);
1176
1283
  }
1177
1284
  function editText(key: string, text: string) {
1178
1285
  setParts((prev) => prev.map((p) => (p.key === key ? { ...p, text } : p)));
@@ -1321,14 +1428,34 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
1321
1428
  );
1322
1429
  }
1323
1430
 
1324
- // Per-part Enhance/Generate — writes the prompt AND its negative (operator:
1325
- // the negative is part of the assist). The negative is derived from the NEW
1326
- // prompt (captured from runAssist's apply); Enhance refines the existing
1431
+ // k93 §C (client half): the toolbar's `[+ video]` pretext as the typed
1432
+ // `context.media` the assist routes accept — {uri, mime, label}. Undefined
1433
+ // when nothing is attached so the body stays byte-identical to today.
1434
+ function scenePretextWire(): AssistMediaWire | undefined {
1435
+ if (!scenePretext) return undefined;
1436
+ const media: AssistMediaWire = {
1437
+ uri: scenePretext.ref.uri,
1438
+ mime: scenePretext.ref.mime,
1439
+ };
1440
+ if (scenePretext.label) media.label = scenePretext.label;
1441
+ return media;
1442
+ }
1443
+
1444
+ // Per-part Enhance/Generate — writes the prompt AND (when this card's
1445
+ // `negatives` toggle is on — default) its negative. The negative is derived from
1446
+ // the NEW prompt (captured from runAssist's apply); Enhance refines the existing
1327
1447
  // negative, Generate writes a fresh one. Sequential because the assist hook
1328
- // allows one in-flight call at a time.
1329
- async function runPartAssist(p: PartEntry, mode: "detail" | "generate") {
1448
+ // allows one in-flight call at a time. `opts.negatives` lets the GROUP enhance
1449
+ // override the per-card toggle with the toolbar's; `opts.media` is the group
1450
+ // pretext (k93 §C) — the per-card buttons pass none.
1451
+ async function runPartAssist(
1452
+ p: PartEntry,
1453
+ mode: "detail" | "generate",
1454
+ opts: { negatives?: boolean; media?: AssistMediaWire } = {},
1455
+ ) {
1330
1456
  // This part's OWN assist model (falls back to the shared pick / fleet default).
1331
1457
  const model = p.assistModel ?? assist.assistModel ?? null;
1458
+ const context = opts.media ? { media: opts.media } : undefined;
1332
1459
  let newText = p.text;
1333
1460
  await assist.runAssist(
1334
1461
  mode,
@@ -1337,28 +1464,42 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
1337
1464
  newText = t;
1338
1465
  editText(p.key, t);
1339
1466
  },
1340
- { model },
1467
+ { model, ...(context ? { context } : {}) },
1341
1468
  );
1342
1469
  if (newText.trim() === "") return;
1470
+ const writeNegative = opts.negatives ?? p.assistNegatives ?? true;
1471
+ if (!writeNegative) return;
1343
1472
  await assist.runNegative(
1344
1473
  {
1345
1474
  subject: newText,
1346
1475
  draft: mode === "detail" ? p.negative ?? "" : "",
1347
- extra: { model },
1476
+ extra: { model, ...(context ? { context } : {}) },
1348
1477
  },
1349
- // Fold the STANDARD negative into the generated one so the anti-frame terms
1350
- // are visibly IN the field (operator: standard negatives are default and part
1351
- // of Generate), not just appended silently at submit. Keep this part's std
1352
- // tick ON so it stays applied. composeNegative dedupes, so submit-time
1353
- // composition never doubles a term.
1478
+ // k93: everything the wire will carry is IN the field. If the field held
1479
+ // the standard set before this call, re-insert it (the std. tick stays
1480
+ // honest — it reads the field); nothing is composed at submit.
1354
1481
  (neg) => {
1355
- const std = p.stdNegative ?? sceneStdNegative;
1356
- patchPart(p.key, {
1357
- negative: composeNegative(neg, std),
1358
- stdNegative: std,
1359
- });
1482
+ const std = hasStandardNegative(p.negative ?? "");
1483
+ // APPEND, never clobber (operator 2026-08-13): a GENERATED negative adds
1484
+ // to whatever the operator already typed (Enhance still refines-in-place —
1485
+ // its draft was the existing text, so appending would double it).
1486
+ const cur = (p.negative ?? "").trim().replace(/,\s*$/, "");
1487
+ const mergedRaw = mode === "generate" && cur ? `${cur}, ${neg}` : neg;
1488
+ patchPart(p.key, { negative: composeNegative(mergedRaw, std) });
1360
1489
  },
1361
1490
  );
1491
+ // NAME RIDES ALONG (operator 2026-08-13): derive the project name from the
1492
+ // fresh prompt when blank — or when the current one was itself derived.
1493
+ if (mode === "generate" && newText.trim()) {
1494
+ const t = projectName.trim();
1495
+ if (t === "" || t === autoProjectRef.current) {
1496
+ const derived = deriveNameFrom(newText);
1497
+ if (derived) {
1498
+ autoProjectRef.current = derived;
1499
+ setProjectName(derived);
1500
+ }
1501
+ }
1502
+ }
1362
1503
  }
1363
1504
 
1364
1505
  // "Test all models" / "per preset" as COMPONENTS — the single reusable
@@ -1369,6 +1510,11 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
1369
1510
  const p = parts.find((x) => !x.media && x.text.trim() !== "");
1370
1511
  return p ? p.text : assembledPrompt;
1371
1512
  }
1513
+ /** The base part's VISIBLE negative — a clone sends what its source shows (k93). */
1514
+ function basePartNegative(): string {
1515
+ const p = parts.find((x) => !x.media && x.text.trim() !== "") ?? parts.find((x) => !x.media);
1516
+ return p?.negative ?? "";
1517
+ }
1372
1518
  function addPartPerModel(): void {
1373
1519
  const text = basePromptText();
1374
1520
  if (text.trim() === "") return;
@@ -1387,6 +1533,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
1387
1533
  ...targets.map((m) => ({
1388
1534
  key: partKey(),
1389
1535
  text,
1536
+ negative: basePartNegative(),
1390
1537
  extra: { model: m.id } as Partial<PartExtra>,
1391
1538
  })),
1392
1539
  ]);
@@ -1413,16 +1560,13 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
1413
1560
  const part: PartEntry = {
1414
1561
  key: partKey(),
1415
1562
  text,
1563
+ negative: basePartNegative(),
1416
1564
  extra: { model: pre.model_key || modelId },
1417
1565
  knobs,
1418
1566
  };
1419
- // A preset's negative REPLACES the standard set (mirrors applyPresetLoad's
1420
- // setSceneStdNegative(false)) — otherwise the standard exclusions get
1421
- // appended and the component sends a different negative than the preset.
1422
- if (d.negative != null) {
1423
- part.negative = d.negative;
1424
- part.stdNegative = false;
1425
- }
1567
+ // A preset's negative REPLACES the field outright (visible, sent as-is —
1568
+ // k93: nothing is composed at submit).
1569
+ if (d.negative != null) part.negative = d.negative;
1426
1570
  return part;
1427
1571
  }),
1428
1572
  ]);
@@ -1909,6 +2053,9 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
1909
2053
  function buildPartPromptParts(p: PartEntry): GenPromptPart[] {
1910
2054
  const out: GenPromptPart[] = [];
1911
2055
  if (!p.media || p.text.trim() !== "") out.push({ kind: "text", text: p.text });
2056
+ // k93: a frame pulled from the attached video rides as an IMAGE part placed
2057
+ // before the video, so it is the start frame (image_paths[0]).
2058
+ if (p.frame) out.push({ kind: "image", media: p.frame.ref });
1912
2059
  if (p.media)
1913
2060
  out.push(
1914
2061
  p.media.ref.kind === "video"
@@ -1950,10 +2097,8 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
1950
2097
 
1951
2098
  // Build ONE scene request from a component, or null when any resolved knob is
1952
2099
  // invalid (skip, never submit coerced garbage). `lead` = the shared media that
1953
- // conditions every prompt. Negative: the part's own value wins, else the shared
1954
- // station negative (so presets / the toolbar negative writer / the standard set
1955
- // still apply); the standard set composes in per the part's tick (default = the
1956
- // station's sceneStdNegative, i.e. ON).
2100
+ // conditions every prompt. Negative (k93): EXACTLY what this part's visible
2101
+ // Negative field holds — blank is blank; no shared fallback, nothing composed.
1957
2102
  function buildSceneRow(
1958
2103
  p: PartEntry,
1959
2104
  lead: GenPromptPart[],
@@ -1992,9 +2137,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
1992
2137
  const partNeedsImage =
1993
2138
  requiredInputsFor(partModel?.tasks ?? []).image === "required";
1994
2139
  if (partNeedsImage && !reqParts.some((x) => x.kind === "image")) return null;
1995
- const baseNeg = (p.negative ?? "").trim() || negative.trim();
1996
- const std = p.stdNegative ?? sceneStdNegative;
1997
- const neg = composeNegative(baseNeg, std).trim();
2140
+ const neg = (p.negative ?? "").trim();
1998
2141
  return {
1999
2142
  rowId: p.key,
2000
2143
  req: {
@@ -2033,31 +2176,106 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
2033
2176
  setParts((prev) => prev.filter((p) => p.key !== key));
2034
2177
  if (attachTarget === key) setAttachTarget(null);
2035
2178
  }
2036
- /** Unpair a part's media, keeping its text (the part becomes a text part). */
2179
+ /** Unpair a part's media (and any frame pulled from it), keeping its text. */
2037
2180
  function detachMedia(key: string) {
2038
2181
  setParts((prev) =>
2039
- prev.map((p) => (p.key === key ? { ...p, media: undefined } : p)),
2182
+ prev.map((p) => (p.key === key ? { ...p, media: undefined, frame: undefined } : p)),
2040
2183
  );
2041
2184
  }
2042
- /** Open a picker in ATTACH mode: the selection pairs onto part `key`. */
2043
- function openAttachPicker(key: string, kind: "image" | "video") {
2044
- setAttachTarget(key);
2045
- setShowImagePicker(kind === "image");
2046
- setShowVideoPicker(kind === "video");
2185
+ /** Drop only the pulled frame; the video stays attached. */
2186
+ function detachFrame(key: string) {
2187
+ setParts((prev) => prev.map((p) => (p.key === key ? { ...p, frame: undefined } : p)));
2188
+ }
2189
+ /**
2190
+ * k93 §B.5: open the INLINE attach panel on card `key` (toggles closed when the
2191
+ * same one is clicked again). image/video pair the pick onto this part
2192
+ * (attachTarget routes the shared upload/ingest handlers here); "library" is
2193
+ * the old "+ from library" APPEND path, rendered inline as well.
2194
+ */
2195
+ function openAttachPicker(key: string, kind: "image" | "video" | "library") {
2196
+ const same = partAttachOpen?.key === key && partAttachOpen.kind === kind;
2197
+ if (same) {
2198
+ setPartAttachOpen(null);
2199
+ setAttachTarget(null);
2200
+ return;
2201
+ }
2202
+ setPartAttachOpen({ key, kind });
2203
+ setAttachTarget(kind === "library" ? null : key);
2204
+ // The station-level pickers are the APPEND path; the card owns attach now.
2205
+ setShowImagePicker(false);
2206
+ setShowVideoPicker(false);
2047
2207
  }
2048
2208
  // Route a picked media: pair it onto the attach-target part when one is set,
2049
2209
  // else append it as a new part (empty caption, editable in place).
2050
2210
  function placeMedia(media: PartMedia) {
2211
+ setPartAttachOpen(null);
2051
2212
  if (attachTarget != null) {
2052
2213
  const target = attachTarget;
2053
2214
  setParts((prev) =>
2054
- prev.map((p) => (p.key === target ? { ...p, media } : p)),
2215
+ prev.map((p) => (p.key === target ? { ...p, media, frame: undefined } : p)),
2055
2216
  );
2056
2217
  setAttachTarget(null);
2057
2218
  return;
2058
2219
  }
2059
2220
  setParts((prev) => [...prev, { key: partKey(), text: "", media }]);
2060
2221
  }
2222
+
2223
+ // ── k93 §B.6 pull-frame: ONE frame at `t` seconds out of this part's attached
2224
+ // video, through the existing frame-extract job (station "frames", so the
2225
+ // tracker's terminal push lands the frame in the library exactly like the
2226
+ // Frames station does). A 0.25 s window at fps 4 yields exactly one frame, and
2227
+ // max_frames: 1 keeps the backend's loud cap honest.
2228
+ function pullFrame(p: PartEntry) {
2229
+ if (!p.media || p.media.ref.kind !== "video") return;
2230
+ const raw = (pullFrameAt[p.key] ?? "0").trim();
2231
+ const t = toNumberOrNull(raw === "" ? "0" : raw);
2232
+ const dur = p.media.ref.duration_s;
2233
+ if (t == null || t < 0 || (dur != null && t >= dur)) {
2234
+ setPullFrameError((prev) => ({
2235
+ ...prev,
2236
+ [p.key]: dur != null ? `Enter a time between 0 and ${dur.toFixed(2)} s.` : "Enter a time ≥ 0 s.",
2237
+ }));
2238
+ return;
2239
+ }
2240
+ setPullFrameError((prev) => {
2241
+ const { [p.key]: _drop, ...rest } = prev;
2242
+ return rest;
2243
+ });
2244
+ const req: FrameExtractRequest = {
2245
+ source: p.media.ref,
2246
+ fps: 4,
2247
+ quality: 90,
2248
+ fmt: "jpg",
2249
+ window: { start_s: t, end_s: t + 0.25 },
2250
+ max_frames: 1,
2251
+ };
2252
+ const enqueuedAt = Date.now();
2253
+ void request<unknown>(hugpyConfig.frameExtractEnqueueUrl, {
2254
+ method: "POST",
2255
+ body: JSON.stringify(req),
2256
+ headers: { "Content-Type": "application/json" },
2257
+ meta: { specKey: "frames", operation: "frame_extract.enqueue" },
2258
+ }).then((r) => {
2259
+ if (!r.ok) {
2260
+ setPullFrameError((prev) => ({ ...prev, [p.key]: describeAppError(errorOf(r)) }));
2261
+ return;
2262
+ }
2263
+ const parsed = enqueueResultSchema.safeParse(okValue(r));
2264
+ if (!parsed.success) {
2265
+ setPullFrameError((prev) => ({ ...prev, [p.key]: "Malformed enqueue response." }));
2266
+ return;
2267
+ }
2268
+ trackJob({
2269
+ jobId: parsed.data.job_id,
2270
+ kind: "frame_extract",
2271
+ station: "frames",
2272
+ rowKey: p.key,
2273
+ label: `pull frame @ ${t}s`,
2274
+ enqueuedAt,
2275
+ });
2276
+ setPullFrameJobs((prev) => ({ ...prev, [p.key]: parsed.data.job_id }));
2277
+ });
2278
+ }
2061
2279
  function movePart(key: string, dir: -1 | 1) {
2062
2280
  setParts((prev) => {
2063
2281
  const i = prev.findIndex((p) => p.key === key);
@@ -2366,6 +2584,34 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
2366
2584
  </span>
2367
2585
  </div>
2368
2586
  </div>
2587
+ {/* UNIFORM-CARD PLACEHOLDERS (operator 2026-08-13): the core knob set
2588
+ renders on EVERY prompt component. Slots this tier serves at the
2589
+ MOVIE level (fps, VRAM budget) — or expresses differently (a movie
2590
+ goal's LENGTH is its start/end times, frames = duration x fps) —
2591
+ appear GREYED with the reason, never hidden, so every card in every
2592
+ section reads identically. */}
2593
+ <div className="vi-knob-strip">
2594
+ <div className="vi-knob-strip-line">
2595
+ <span className="vi-knob-mini-group">
2596
+ <label className="vi-knob-mini-label" htmlFor={gid("na-fps")}>fps</label>
2597
+ <input id={gid("na-fps")} className="vi-knob-input" style={{ maxWidth: "4.5rem" }} disabled
2598
+ value={fpsNum ?? ""}
2599
+ title="Movie-level — one fps for the whole timeline; set it in the station knobs." />
2600
+ </span>
2601
+ <span className="vi-knob-mini-group">
2602
+ <label className="vi-knob-mini-label" htmlFor={gid("na-length")}>length</label>
2603
+ <input id={gid("na-length")} className="vi-knob-input" style={{ maxWidth: "6.5rem" }} disabled
2604
+ value={`${g.start || "0"}–${g.end || "?"}s`}
2605
+ 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." />
2606
+ </span>
2607
+ <span className="vi-knob-mini-group">
2608
+ <label className="vi-knob-mini-label" htmlFor={gid("na-vram")}>VRAM</label>
2609
+ <input id={gid("na-vram")} className="vi-knob-input" style={{ maxWidth: "5.5rem" }} disabled
2610
+ value="autofit"
2611
+ title="Movie-level — renders size to the serving worker's free VRAM (autofit); the movie tier has no per-goal budget." />
2612
+ </span>
2613
+ </div>
2614
+ </div>
2369
2615
  </>
2370
2616
  );
2371
2617
  }
@@ -2547,27 +2793,32 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
2547
2793
  }
2548
2794
 
2549
2795
  // ---- Movie prompt-list toolbar actions (k88 — the shared PromptListToolbar) ----
2550
- /** Cap + semantics mirror Cinema's Segments stepper: grow by appending default-
2551
- length windows after the last row, TRUNCATE from the tail to shrink. */
2552
- const MAX_MOVIE_GOALS = 24;
2796
+ /** Semantics mirror Cinema's Segments stepper (2026-08-27): shrinking PARKS the
2797
+ tail rows in a session stash instead of deleting them; growing revives parked
2798
+ rows (keys intact) before appending default-length windows after the last
2799
+ row. No upper cap. */
2553
2800
  function setGoalCount(n: number) {
2554
- if (!Number.isInteger(n) || n < 1 || n > MAX_MOVIE_GOALS) return;
2555
- setGoals((prev) => {
2556
- if (n === prev.length) return prev;
2557
- if (n < prev.length) return prev.slice(0, n);
2558
- const next = prev.slice();
2559
- while (next.length < n) {
2560
- const last = next[next.length - 1];
2561
- const start = last ? toNumberOrNull(last.end) ?? 0 : 0;
2562
- next.push({
2563
- id: goalKey(),
2564
- start: String(start),
2565
- end: String(start + DEFAULT_GOAL_LEN),
2566
- prompt: "",
2567
- });
2568
- }
2569
- return next;
2570
- });
2801
+ if (!Number.isInteger(n) || n < 1 || n === goals.length) return;
2802
+ if (n < goals.length) {
2803
+ const cut = goals.slice(n);
2804
+ setGoalStash((st) => [...cut, ...st]);
2805
+ setGoals(goals.slice(0, n));
2806
+ return;
2807
+ }
2808
+ const revived = goalStash.slice(0, n - goals.length);
2809
+ if (revived.length) setGoalStash((st) => st.slice(revived.length));
2810
+ const next = [...goals, ...revived];
2811
+ while (next.length < n) {
2812
+ const last = next[next.length - 1];
2813
+ const start = last ? toNumberOrNull(last.end) ?? 0 : 0;
2814
+ next.push({
2815
+ id: goalKey(),
2816
+ start: String(start),
2817
+ end: String(start + DEFAULT_GOAL_LEN),
2818
+ prompt: "",
2819
+ });
2820
+ }
2821
+ setGoals(next);
2571
2822
  }
2572
2823
  function toggleMovieSelected(id: string) {
2573
2824
  setMovieSelectedIds((prev) => {
@@ -2665,7 +2916,8 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
2665
2916
  if (n === prev.length) return prev;
2666
2917
  if (n > prev.length) {
2667
2918
  const next = prev.slice();
2668
- while (next.length < n) next.push({ key: partKey(), text: "" });
2919
+ while (next.length < n)
2920
+ next.push({ key: partKey(), text: "", negative: STANDARD_NEGATIVE });
2669
2921
  return next;
2670
2922
  }
2671
2923
  const next = prev.slice();
@@ -2683,14 +2935,6 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
2683
2935
  return next;
2684
2936
  });
2685
2937
  }
2686
- function toggleSceneSelected(key: string) {
2687
- setSceneSelectedIds((prev) => {
2688
- const next = new Set(prev);
2689
- if (next.has(key)) next.delete(key);
2690
- else next.add(key);
2691
- return next;
2692
- });
2693
- }
2694
2938
  // Selection is over TEXT parts only — the group actions rewrite prompts, and a
2695
2939
  // media part's caption rides as locked context (its checkbox renders disabled).
2696
2940
  // Live-row math, same as Movie: a removed part can leave a stale key behind.
@@ -2701,36 +2945,103 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
2701
2945
  );
2702
2946
  const sceneAllSelected =
2703
2947
  sceneTextParts.length > 0 && sceneSelectedCount === sceneTextParts.length;
2948
+ // k93 §A.1/2 — the RANGE view of the selection set (1-based over text parts):
2949
+ // min/max of the ticked text parts, or null when nothing is ticked. The SET
2950
+ // stays the source of truth; the range inputs, select-all and the per-card
2951
+ // ticks all write into it.
2952
+ const sceneSelectedIdx = sceneTextParts
2953
+ .map((p, i) => (sceneSelectedIds.has(p.key) ? i + 1 : 0))
2954
+ .filter((n) => n > 0);
2955
+ const sceneRangeStart = sceneSelectedIdx.length ? Math.min(...sceneSelectedIdx) : null;
2956
+ const sceneRangeEnd = sceneSelectedIdx.length ? Math.max(...sceneSelectedIdx) : null;
2957
+ /** Select text parts #start … #end (1-based, inclusive, already clamped by the toolbar). */
2958
+ function selectSceneRange(start: number, end: number) {
2959
+ const s = Math.max(1, Math.min(start, sceneTextParts.length));
2960
+ const e = Math.max(s, Math.min(end, sceneTextParts.length));
2961
+ sceneRangeMemo.current = { start: s, end: e };
2962
+ setSceneSelectedIds(new Set(sceneTextParts.slice(s - 1, e).map((p) => p.key)));
2963
+ }
2964
+ function toggleSceneSelected(key: string) {
2965
+ setSceneSelectedIds((prev) => {
2966
+ const next = new Set(prev);
2967
+ if (next.has(key)) next.delete(key);
2968
+ else next.add(key);
2969
+ // A per-card tick un-checks select-all implicitly (the set is no longer
2970
+ // every text part) and the range inputs show the new min/max; remember
2971
+ // that span as the explicit range select-all returns to.
2972
+ const idx = sceneTextParts
2973
+ .map((p, i) => (next.has(p.key) ? i + 1 : 0))
2974
+ .filter((n) => n > 0);
2975
+ sceneRangeMemo.current = idx.length
2976
+ ? { start: Math.min(...idx), end: Math.max(...idx) }
2977
+ : null;
2978
+ return next;
2979
+ });
2980
+ }
2981
+ // select all: checked ⇒ #1 … #last; unchecked ⇒ back to the remembered
2982
+ // explicit [start, end] (or nothing when no range was ever set).
2704
2983
  function toggleSceneSelectAll() {
2705
- setSceneSelectedIds(
2706
- sceneAllSelected ? new Set() : new Set(sceneTextParts.map((p) => p.key)),
2707
- );
2984
+ if (sceneAllSelected) {
2985
+ const memo = sceneRangeMemo.current;
2986
+ if (memo && !(memo.start === 1 && memo.end === sceneTextParts.length)) {
2987
+ selectSceneRange(memo.start, memo.end);
2988
+ } else {
2989
+ setSceneSelectedIds(new Set());
2990
+ }
2991
+ return;
2992
+ }
2993
+ if (sceneRangeStart != null && sceneRangeEnd != null) {
2994
+ sceneRangeMemo.current = { start: sceneRangeStart, end: sceneRangeEnd };
2995
+ }
2996
+ setSceneSelectedIds(new Set(sceneTextParts.map((p) => p.key)));
2997
+ }
2998
+ // enhance (M selected): the selected text parts with a NON-BLANK prompt.
2999
+ const sceneSelectedParts = sceneTextParts.filter((p) => sceneSelectedIds.has(p.key));
3000
+ const sceneEnhanceCount = sceneSelectedParts.filter((p) => p.text.trim() !== "").length;
3001
+ // The toolbar std. tick reads the FIELDS: checked iff every selected part's
3002
+ // Negative already holds the standard set.
3003
+ const sceneStdAllOn =
3004
+ sceneSelectedParts.length > 0 &&
3005
+ sceneSelectedParts.every((p) => hasStandardNegative(p.negative ?? ""));
3006
+
3007
+ // The scene's group-assist negative (k93): part #1's VISIBLE Negative field,
3008
+ // explicitly — the first text part's field, sent as `global_negative` (its tab
3009
+ // tooltip says so). No std composition, no shared fallback.
3010
+ function sceneGroupNegative(): string {
3011
+ return (sceneTextParts[0]?.negative ?? "").trim();
2708
3012
  }
2709
3013
 
2710
- // GROUP ACTION (k89): ONE coherent spread across the ticked text parts — the same
2711
- // one-call rule Cinema's onSpreadSelected / Movie's onMovieSpreadSelected enforce:
2712
- // selected parts are targets, everything else (unticked text, media captions)
2713
- // rides as LOCKED context, and the reply may only land on parts whose key came back.
3014
+ // GROUP ACTION (k89/k93): ONE coherent spread across the ticked text parts — the
3015
+ // same one-call rule Cinema's onSpreadSelected / Movie's onMovieSpreadSelected
3016
+ // enforce: selected parts are targets, everything else (unticked text, media
3017
+ // captions) rides as LOCKED context, and the reply may only land on parts whose
3018
+ // key came back. With `negatives: on` the returned per-part negative is WRITTEN
3019
+ // into that part's visible Negative field (the old separate "Generate negative"
3020
+ // button folded in); off ⇒ prompts only. context.media carries the `[+ video]`
3021
+ // pretext (§C) when one is attached.
2714
3022
  async function onSceneSpreadSelected() {
2715
- const wiredNegative = composeNegative(negative, sceneStdNegative);
3023
+ const wiredNegative = sceneGroupNegative();
2716
3024
  const inputs: SpreadGoalInput[] = parts.map((p, i) => ({
2717
3025
  key: p.key,
2718
3026
  prompt: p.text,
2719
- negative: i === 0 ? wiredNegative : "",
3027
+ negative: p.negative ?? "",
2720
3028
  seed: "",
2721
3029
  branchFrame: "",
2722
3030
  joint: "still",
2723
3031
  index: i,
2724
3032
  }));
3033
+ const media = scenePretextWire();
2725
3034
  const body = buildSpreadBody({
2726
3035
  goals: inputs,
2727
3036
  selectedKeys: sceneSelectedIds,
2728
3037
  movieQuery: "",
2729
3038
  globalNegative: wiredNegative,
2730
3039
  steeringSeed: sceneSteeringSeed,
3040
+ context: { kind: "scene", ...(media ? { media } : {}) },
2731
3041
  });
2732
3042
  if (!body) return;
2733
3043
  setSceneSpreadNotice(null);
3044
+ const writeNegatives = sceneGroupNegatives;
2734
3045
  const res = await assist.runSpread(body);
2735
3046
  if (!res) return;
2736
3047
  const bySeg = new Map(res.segments.map((s) => [s.segment_id, s]));
@@ -2743,33 +3054,47 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
2743
3054
  // may linger in the select set — never overwrite a media caption).
2744
3055
  if (!sceneSelectedIds.has(p.key) || p.media) return p;
2745
3056
  const hit = bySeg.get(p.key);
2746
- // ONE scene negative on the wire — a returned per-part negative is dropped
2747
- // here rather than written somewhere it could never be sent (Movie's rule).
2748
- return hit ? { ...p, text: hit.prompt } : p;
3057
+ if (!hit) return p;
3058
+ if (!writeNegatives || hit.negative.trim() === "") return { ...p, text: hit.prompt };
3059
+ // Keep the standard set if the field held it (the std. tick reads the field).
3060
+ const std = hasStandardNegative(p.negative ?? "");
3061
+ return { ...p, text: hit.prompt, negative: composeNegative(hit.negative, std) };
2749
3062
  }),
2750
3063
  );
2751
3064
  if (res.steering_seed != null) setSceneSteeringSeed(res.steering_seed);
2752
3065
  setSceneSpreadNotice(spreadNoticeLines(res, applied));
2753
3066
  }
2754
3067
 
2755
- // GROUP ACTION (k89): write the scene's ONE wired negative from the ticked parts'
2756
- // text — Movie's single-runNegative approach, not Cinema's per-row loop (the wire
2757
- // carries one negative per scene).
2758
- async function onSceneNegativesSelected() {
2759
- const rows = parts.filter((p) => sceneSelectedIds.has(p.key) && !p.media);
3068
+ // GROUP ACTION (k93 §A.5): enhance (M selected) — the "detail" assist over every
3069
+ // selected text part with a non-blank prompt. The spread endpoint carries no
3070
+ // mode, so this runs SEQUENTIALLY per part through runPartAssist (one in-flight
3071
+ // assist at a time — the hook's guard), each call carrying the toolbar's
3072
+ // negatives toggle and the `[+ video]` pretext.
3073
+ async function onSceneEnhanceSelected() {
3074
+ const rows = sceneSelectedParts.filter((p) => p.text.trim() !== "");
2760
3075
  if (rows.length === 0) return;
2761
3076
  setSceneSpreadNotice(null);
2762
- const subject = rows
2763
- .map((p) => p.text.trim())
2764
- .filter((t) => t !== "")
2765
- .join("; ");
2766
- await assist.runNegative({ subject, draft: negative }, setNegative);
3077
+ const media = scenePretextWire();
3078
+ for (const p of rows) {
3079
+ await runPartAssist(p, "detail", { negatives: sceneGroupNegatives, media });
3080
+ }
2767
3081
  }
2768
3082
 
2769
- // Toolbar [standard negatives]: ONE wired negative (part #1's Negative tab), so
2770
- // this ticks that pane's [standard negative] on.
2771
- function onSceneStandardNegatives() {
2772
- setSceneStdNegative(true);
3083
+ // Toolbar `[ ] std.` (k93 §A.4): tick ⇒ INSERT the standard set into every
3084
+ // selected part's Negative field (deduped, comma-joined); untick ⇒ remove
3085
+ // exactly those terms. Visible in the field — nothing composed at submit.
3086
+ function onSceneStdChange(on: boolean) {
3087
+ setParts((prev) =>
3088
+ prev.map((p) =>
3089
+ sceneSelectedIds.has(p.key) && !p.media
3090
+ ? { ...p, negative: applyStandardNegative(p.negative ?? "", on) }
3091
+ : p,
3092
+ ),
3093
+ );
3094
+ }
3095
+ /** Per-card std. tick (k93 §B.3): same explicit insert/remove on this part only. */
3096
+ function onPartStdChange(p: PartEntry, on: boolean) {
3097
+ patchPart(p.key, { negative: applyStandardNegative(p.negative ?? "", on) });
2773
3098
  }
2774
3099
  // Route a picked image: onto the attach-target GOAL (Movie mode) when set, else
2775
3100
  // through the parts placeMedia path (Image/Scene). Keeps the picker/upload code
@@ -2784,18 +3109,18 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
2784
3109
  placeMedia(media);
2785
3110
  }
2786
3111
 
2787
- function addImage(item: LibraryItem) {
2788
- placePickedImage({ ref: item.ref, origin: item.origin, label: item.label });
3112
+ function addImageMedia(media: PartMedia) {
3113
+ placePickedImage(media);
2789
3114
  setShowImagePicker(false);
2790
3115
  }
2791
- function addVideoFromLibrary(item: LibraryItem) {
2792
- placeMedia({ ref: item.ref, origin: item.origin, label: item.label });
3116
+ function addVideoMedia(media: PartMedia) {
3117
+ placeMedia(media);
2793
3118
  setShowVideoPicker(false);
2794
3119
  }
2795
3120
 
2796
3121
  // Upload a raw video, ingest it into a MediaRef, guard kind==="video", then
2797
3122
  // append it as a video part (and add it to the library so it is reusable).
2798
- async function onPickVideo(file: File) {
3123
+ async function onPickVideo(file: File, place: (media: PartMedia) => void = placeMedia) {
2799
3124
  setVideoError(null);
2800
3125
  setVideoPhase("uploading");
2801
3126
 
@@ -2845,7 +3170,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
2845
3170
  return;
2846
3171
  }
2847
3172
  addToLibrary(media.data, "upload", media.data.mime);
2848
- placeMedia({ ref: media.data, origin: "upload", label: media.data.mime });
3173
+ place({ ref: media.data, origin: "upload", label: media.data.mime });
2849
3174
  setVideoPhase("idle");
2850
3175
  setShowVideoPicker(false);
2851
3176
  }
@@ -2854,7 +3179,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
2854
3179
  // was library-only for images, which forced a detour through another
2855
3180
  // station just to use a file that's sitting on disk). Upload → ingest →
2856
3181
  // guard kind==="image" → attach as the part + into the library for reuse.
2857
- async function onPickImage(file: File) {
3182
+ async function onPickImage(file: File, place: (media: PartMedia) => void = placePickedImage) {
2858
3183
  setImageError(null);
2859
3184
  setImagePhase("uploading");
2860
3185
 
@@ -2904,11 +3229,149 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
2904
3229
  return;
2905
3230
  }
2906
3231
  addToLibrary(media.data, "upload", media.data.mime);
2907
- placePickedImage({ ref: media.data, origin: "upload", label: media.data.mime });
3232
+ place({ ref: media.data, origin: "upload", label: media.data.mime });
2908
3233
  setImagePhase("idle");
2909
3234
  setShowImagePicker(false);
2910
3235
  }
2911
3236
 
3237
+ // ── k93 §B.5 — the picker BODIES (browse-from-disk + library grid), factored so
3238
+ // the same markup renders inline inside a card, under the toolbar (the `[+ video]`
3239
+ // pretext) and in the station-level append pickers. `onPick` receives the chosen
3240
+ // media as a PartMedia; the caller decides where it lands.
3241
+ function renderImagePickerBody(onPick: (media: PartMedia) => void) {
3242
+ return (
3243
+ <>
3244
+ <div className="vi-gen-video-upload">
3245
+ <AddFileBar
3246
+ accept="image/*"
3247
+ onFile={(f) => onPickImage(f, onPick)}
3248
+ busy={imageBusy}
3249
+ buttonLabel="Choose image from disk"
3250
+ >
3251
+ {imageBusy && <span className="vi-crop-status">Uploading…</span>}
3252
+ </AddFileBar>
3253
+ {imageError && <span className="vi-error">{imageError}</span>}
3254
+ </div>
3255
+ {libraryImages.length === 0 ? (
3256
+ <p className="vi-crop-hint">
3257
+ Nothing in the session library yet — browse above, pick frames in the
3258
+ Frames station, or generate an image; results land here and become
3259
+ pickable.
3260
+ </p>
3261
+ ) : (
3262
+ <LibraryGroups
3263
+ seed={mode}
3264
+ items={libraryImages}
3265
+ defaultOpen={libraryDefaultOpen}
3266
+ renderItem={(it) => (
3267
+ <button
3268
+ type="button"
3269
+ key={it.ref.uri}
3270
+ className="vi-gen-pick-cell"
3271
+ onClick={() => onPick({ ref: it.ref, origin: it.origin, label: it.label })}
3272
+ title={it.ref.uri}
3273
+ >
3274
+ <img
3275
+ className="vi-gen-pick-thumb"
3276
+ src={mediaBytesUrl(it.ref.uri)}
3277
+ alt={it.label ?? "library image"}
3278
+ loading="lazy"
3279
+ />
3280
+ <span className="vi-gen-pick-meta">
3281
+ {it.origin}
3282
+ {it.label ? ` · ${it.label}` : ""}
3283
+ </span>
3284
+ </button>
3285
+ )}
3286
+ />
3287
+ )}
3288
+ </>
3289
+ );
3290
+ }
3291
+ function renderVideoPickerBody(onPick: (media: PartMedia) => void) {
3292
+ return (
3293
+ <>
3294
+ <div className="vi-gen-video-upload">
3295
+ <AddFileBar
3296
+ accept="video/*"
3297
+ onFile={(f) => onPickVideo(f, onPick)}
3298
+ busy={videoBusy}
3299
+ buttonLabel="Choose video from disk"
3300
+ >
3301
+ {videoBusy && <span className="vi-crop-status">Uploading…</span>}
3302
+ </AddFileBar>
3303
+ {videoError && <span className="vi-error">{videoError}</span>}
3304
+ </div>
3305
+ {libraryVideos.length === 0 ? (
3306
+ <p className="vi-crop-hint">
3307
+ No videos in the session library yet — browse above or ingest one in
3308
+ another station.
3309
+ </p>
3310
+ ) : (
3311
+ <LibraryGroups
3312
+ seed={mode}
3313
+ items={libraryVideos}
3314
+ defaultOpen={libraryDefaultOpen}
3315
+ renderItem={(it) => (
3316
+ <button
3317
+ type="button"
3318
+ key={it.ref.uri}
3319
+ className="vi-gen-pick-cell"
3320
+ onClick={() => onPick({ ref: it.ref, origin: it.origin, label: it.label })}
3321
+ title={it.ref.uri}
3322
+ >
3323
+ <span className="vi-gen-pick-videoicon" aria-hidden>
3324
+ ▶
3325
+ </span>
3326
+ <span className="vi-gen-pick-meta">
3327
+ <code>{it.ref.mime}</code> · {it.origin}
3328
+ </span>
3329
+ </button>
3330
+ )}
3331
+ />
3332
+ )}
3333
+ </>
3334
+ );
3335
+ }
3336
+ /** The whole library (images + videos) — the inline `+ library` APPEND panel. */
3337
+ function renderLibraryPickerBody(onPick: (media: PartMedia) => void) {
3338
+ return library.length === 0 ? (
3339
+ <p className="vi-crop-hint">Nothing in the session library yet.</p>
3340
+ ) : (
3341
+ <LibraryGroups
3342
+ seed={mode}
3343
+ items={library}
3344
+ defaultOpen={libraryDefaultOpen}
3345
+ renderItem={(it) => (
3346
+ <button
3347
+ type="button"
3348
+ key={it.ref.uri}
3349
+ className="vi-gen-pick-cell"
3350
+ onClick={() => onPick({ ref: it.ref, origin: it.origin, label: it.label })}
3351
+ title={it.ref.uri}
3352
+ >
3353
+ {it.ref.kind === "image" ? (
3354
+ <img
3355
+ className="vi-gen-pick-thumb"
3356
+ src={mediaBytesUrl(it.ref.uri)}
3357
+ alt={it.label ?? "library image"}
3358
+ loading="lazy"
3359
+ />
3360
+ ) : (
3361
+ <span className="vi-gen-pick-videoicon" aria-hidden>
3362
+ ▶
3363
+ </span>
3364
+ )}
3365
+ <span className="vi-gen-pick-meta">
3366
+ {it.ref.kind === "image" ? it.origin : <><code>{it.ref.mime}</code> · {it.origin}</>}
3367
+ {it.label ? ` · ${it.label}` : ""}
3368
+ </span>
3369
+ </button>
3370
+ )}
3371
+ />
3372
+ );
3373
+ }
3374
+
2912
3375
  // ---- run validation (no silent defaults; every gate is visible) ----
2913
3376
  const widthNum = toNumberOrNull(width);
2914
3377
  const heightNum = toNumberOrNull(height);
@@ -3061,6 +3524,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
3061
3524
  if (!p.media || p.text.trim() !== "") {
3062
3525
  out.push({ kind: "text", text: p.text });
3063
3526
  }
3527
+ if (p.frame) out.push({ kind: "image", media: p.frame.ref });
3064
3528
  if (p.media) {
3065
3529
  out.push(
3066
3530
  p.media.ref.kind === "video"
@@ -3073,11 +3537,12 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
3073
3537
  }
3074
3538
 
3075
3539
  // The composer's MEDIA parts only (images / videos / bound identity views), with
3076
- // every text segment dropped. Tester rows supply their own text and inherit these,
3540
+ // every text segment dropped. Tester rows supply their own text and reuse these,
3077
3541
  // so the reference material is identical across a sweep.
3078
3542
  function buildMediaOnlyParts(): GenPromptPart[] {
3079
3543
  const out: GenPromptPart[] = [];
3080
3544
  for (const p of parts) {
3545
+ if (p.frame) out.push({ kind: "image", media: p.frame.ref });
3081
3546
  if (p.media) {
3082
3547
  out.push(
3083
3548
  p.media.ref.kind === "video"
@@ -3100,7 +3565,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
3100
3565
  // setting and has no tick — it always carries, because a row with no prompt is
3101
3566
  // not runnable and would just be a blank card.
3102
3567
  //
3103
- // The new row inherits the source's tick pattern too, so a sweep keeps sweeping:
3568
+ // The new row copies the source's tick pattern too, so a sweep keeps sweeping:
3104
3569
  // untick `seed` once and every row spun off the chain keeps re-randomizing.
3105
3570
  function seedTestRow(index: number, from: ImageTestRow | undefined): ImageTestRow {
3106
3571
  const ticks = from ? from.carry : carry;
@@ -3121,13 +3586,13 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
3121
3586
  : guidance
3122
3587
  : DEFAULT_KNOBS.guidance,
3123
3588
  seed: ticks.seed ? (from ? from.seed : seed) : DEFAULT_KNOBS.seed,
3124
- // k89: a row seeded from the BASE composer carries the COMPOSED negative
3125
- // (user text + the standard set when part #1's tick is on) — the same value
3126
- // the scene submit sends, so a sweep row reproduces the base generation.
3589
+ // k93: a row seeded from the BASE composer carries part #1's VISIBLE
3590
+ // negative — the same value the scene submit sends, so a sweep row
3591
+ // reproduces the base generation.
3127
3592
  negative: ticks.negative
3128
3593
  ? from
3129
3594
  ? from.negative
3130
- : composeNegative(negative, sceneStdNegative)
3595
+ : sceneGroupNegative()
3131
3596
  : DEFAULT_NEGATIVE,
3132
3597
  carry: { ...ticks },
3133
3598
  };
@@ -3331,7 +3796,23 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
3331
3796
  void assist.runAssist(assistMode, assembledPrompt, applyAssistedPrompt);
3332
3797
  }
3333
3798
 
3334
- function onRun() {
3799
+ useEffect(() => {
3800
+ // Post-commit re-run after the warn panel names the run — fresh closure,
3801
+ // settled state, so the submit sees the new project name.
3802
+ if (!pendingRun) return;
3803
+ setPendingRun(false);
3804
+ onRun(true);
3805
+ });
3806
+
3807
+ function onRun(skipNameCheck = false) {
3808
+ if (
3809
+ !skipNameCheck && warnPref && projectName.trim() === "" &&
3810
+ (mode === "image" || mode === "scene" || mode === "movie")
3811
+ ) {
3812
+ setNameWarn(true);
3813
+ return;
3814
+ }
3815
+ setNameWarn(false);
3335
3816
  // This guard is for the STATION-level knobs (movie + the image fallback).
3336
3817
  // Scene validates every knob PER-PART in buildSceneRow (station values may be
3337
3818
  // blank/unused when knobs are unshared), so it must not be gated here — else a
@@ -3455,10 +3936,10 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
3455
3936
  steps: stepsNum,
3456
3937
  guidance: guidanceNum,
3457
3938
  seed: seed.trim() === "" ? null : seedNum,
3458
- // Same composed value as the scene path — the retained image-job wiring must
3459
- // not drift from what the live submit sends.
3939
+ // Same explicit value as the scene path (part #1's visible field) — the
3940
+ // retained image-job wiring must not drift from what the live submit sends.
3460
3941
  negative: (() => {
3461
- const eff = composeNegative(negative, sceneStdNegative);
3942
+ const eff = sceneGroupNegative();
3462
3943
  return eff === "" ? null : eff;
3463
3944
  })(),
3464
3945
  project: projectName.trim() || undefined,
@@ -3561,9 +4042,10 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
3561
4042
  to frames server-side.
3562
4043
  </p>
3563
4044
  <p>
3564
- Each prompt has its OWN Negative tab (blank = inherit the shared
3565
- negative from a preset or the toolbar); [standard negative] composes
3566
- the standard exclusion set into that prompt at submit.
4045
+ Each prompt has its OWN Negative tab, sent exactly as written (blank
4046
+ = none; nothing is merged at submit). The std. tick inserts or
4047
+ removes the standard exclusion set in that field; part #1&apos;s
4048
+ Negative is also the group assist&apos;s shared negative.
3567
4049
  </p>
3568
4050
  <p>
3569
4051
  The Tester at the bottom compares prompts, settings and models side
@@ -3613,6 +4095,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
3613
4095
  Choose an existing project or type a new name. Saves this run&apos;s frames +
3614
4096
  clip to assets/&lt;project&gt;. Blank = an auto-named folder.
3615
4097
  </span>
4098
+ <WarnUnnamedToggle checked={warnPref} onChange={setWarnPref} />
3616
4099
  </div>
3617
4100
 
3618
4101
  {/* PRESET — a curated "ideal default load". Selecting one prefills every
@@ -4375,50 +4858,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
4375
4858
  timeline.
4376
4859
  </p>
4377
4860
  )}
4378
- {/* Browse-from-disk — attach was library-only for images. */}
4379
- <div className="vi-gen-video-upload">
4380
- <AddFileBar
4381
- accept="image/*"
4382
- onFile={onPickImage}
4383
- busy={imageBusy}
4384
- buttonLabel="Choose image from disk"
4385
- >
4386
- {imageBusy && (
4387
- <span className="vi-crop-status">Uploading…</span>
4388
- )}
4389
- </AddFileBar>
4390
- {imageError && <span className="vi-error">{imageError}</span>}
4391
- </div>
4392
- {libraryImages.length === 0 ? (
4393
- <p className="vi-crop-hint">
4394
- Nothing in the session library yet — browse above, pick
4395
- frames in the Frames station, or generate an image; results
4396
- land here and become pickable.
4397
- </p>
4398
- ) : (
4399
- <div className="vi-gen-picker-grid">
4400
- {libraryImages.map((it) => (
4401
- <button
4402
- type="button"
4403
- key={it.ref.uri}
4404
- className="vi-gen-pick-cell"
4405
- onClick={() => addImage(it)}
4406
- title={it.ref.uri}
4407
- >
4408
- <img
4409
- className="vi-gen-pick-thumb"
4410
- src={mediaBytesUrl(it.ref.uri)}
4411
- alt={it.label ?? "library image"}
4412
- loading="lazy"
4413
- />
4414
- <span className="vi-gen-pick-meta">
4415
- {it.origin}
4416
- {it.label ? ` · ${it.label}` : ""}
4417
- </span>
4418
- </button>
4419
- ))}
4420
- </div>
4421
- )}
4861
+ {renderImagePickerBody(addImageMedia)}
4422
4862
  </div>
4423
4863
  )}
4424
4864
 
@@ -4436,39 +4876,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
4436
4876
  sampling); for precise control, extract + pick frames in the
4437
4877
  Frames station and add them as images.
4438
4878
  </p>
4439
- <div className="vi-gen-video-upload">
4440
- <AddFileBar
4441
- accept="video/*"
4442
- onFile={onPickVideo}
4443
- busy={videoBusy}
4444
- buttonLabel="Choose video from disk"
4445
- >
4446
- {videoBusy && (
4447
- <span className="vi-crop-status">Uploading…</span>
4448
- )}
4449
- </AddFileBar>
4450
- {videoError && <span className="vi-error">{videoError}</span>}
4451
- </div>
4452
- {libraryVideos.length > 0 && (
4453
- <div className="vi-gen-picker-grid">
4454
- {libraryVideos.map((it) => (
4455
- <button
4456
- type="button"
4457
- key={it.ref.uri}
4458
- className="vi-gen-pick-cell"
4459
- onClick={() => addVideoFromLibrary(it)}
4460
- title={it.ref.uri}
4461
- >
4462
- <span className="vi-gen-pick-videoicon" aria-hidden>
4463
- ▶
4464
- </span>
4465
- <span className="vi-gen-pick-meta">
4466
- <code>{it.ref.mime}</code> · {it.origin}
4467
- </span>
4468
- </button>
4469
- ))}
4470
- </div>
4471
- )}
4879
+ {renderVideoPickerBody(addVideoMedia)}
4472
4880
  </div>
4473
4881
  )}
4474
4882
  </>
@@ -4706,9 +5114,9 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
4706
5114
  label: "Goals",
4707
5115
  value: goals.length,
4708
5116
  min: 1,
4709
- max: MAX_MOVIE_GOALS,
4710
5117
  onChange: setGoalCount,
4711
- title: `How many goals the timeline has (1–${MAX_MOVIE_GOALS}). Lowering it removes rows from the END of the timeline.`,
5118
+ title:
5119
+ "How many goals the timeline has. Lowering it parks rows from the END of the timeline; raising it brings them back before adding blanks.",
4712
5120
  }}
4713
5121
  noun="goal"
4714
5122
  selectedCount={movieSelectedCount}
@@ -4860,9 +5268,32 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
4860
5268
  )
4861
5269
  }
4862
5270
  onGenerate={() =>
4863
- void assist.runAssist("generate", g.prompt, (p) =>
4864
- editGoal(g.id, "prompt", p),
4865
- )
5271
+ void (async () => {
5272
+ let fresh = "";
5273
+ await assist.runAssist("generate", g.prompt, (p) => {
5274
+ fresh = p;
5275
+ editGoal(g.id, "prompt", p);
5276
+ });
5277
+ if (!fresh.trim()) return;
5278
+ // NEGATIVE RIDES ALONG (operator 2026-08-13): the
5279
+ // generated exclusion list APPENDS to the goal's (or
5280
+ // shared) typed negative, never clobbers it.
5281
+ await assist.runNegative({ subject: fresh }, (neg) => {
5282
+ const cur = (movieShare.negative ? negative : g.negative ?? "")
5283
+ .trim().replace(/,\s*$/, "");
5284
+ const merged = cur ? `${cur}, ${neg}` : neg;
5285
+ if (movieShare.negative) setNegative(merged);
5286
+ else patchGoal(g.id, { negative: merged });
5287
+ });
5288
+ const t = projectName.trim();
5289
+ if (t === "" || t === autoProjectRef.current) {
5290
+ const derived = deriveNameFrom(fresh);
5291
+ if (derived) {
5292
+ autoProjectRef.current = derived;
5293
+ setProjectName(derived);
5294
+ }
5295
+ }
5296
+ })()
4866
5297
  }
4867
5298
  canEnhance={g.prompt.trim() !== ""}
4868
5299
  models={assist.assistModels}
@@ -5017,9 +5448,12 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
5017
5448
  {/* The station-wide "whole assembled prompt" Enhance/Generate cluster
5018
5449
  was removed (operator): per-part Enhance/Generate on each card and
5019
5450
  the toolbar's group "Generate prompts (N selected)" below cover it. */}
5020
- {/* k89 — the SHARED prompt-list toolbar (same structure Movie and
5021
- Cinema render): part count, select-all, the one-call spread, the
5022
- negative writer and the standard-negative tick. */}
5451
+ {/* k89/k93 — the SHARED prompt-list toolbar in its GROUP layout:
5452
+ prompt assist model [select]
5453
+ [start] - [end] [ ] select all negatives: [on/off] [ ] std.
5454
+ (✨ generate (N selected)) (✨ enhance (M selected)) [+ video]
5455
+ The selection SET (sceneSelectedIds) stays the source of truth;
5456
+ range / select-all / per-card ticks all write into it. */}
5023
5457
  <PromptListToolbar
5024
5458
  count={{
5025
5459
  id: "vi-gen-part-count",
@@ -5035,52 +5469,117 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
5035
5469
  itemCount={sceneTextParts.length}
5036
5470
  allSelected={sceneAllSelected}
5037
5471
  onToggleSelectAll={toggleSceneSelectAll}
5472
+ modelRow={
5473
+ /* PROMPT-ASSIST MODEL — ONE shared selector for which text
5474
+ generator the group actions (and every card's default) use. */
5475
+ assist.assistModels.length > 0 ? (
5476
+ <div className="vi-prompt-assist vi-gen-assist-model">
5477
+ <span className="vi-prompt-assist-lead" aria-hidden="true">
5478
+ prompt assist model
5479
+ </span>
5480
+ <select
5481
+ className="vi-select vi-select-sm"
5482
+ value={assist.assistModel ?? ""}
5483
+ disabled={assist.assistBusy != null}
5484
+ onChange={(e) => assist.setAssistModel(e.target.value || null)}
5485
+ aria-label="Text generator used to enhance or generate prompts"
5486
+ title="Which text generator writes prompts for the group actions and every prompt's Enhance / Generate (a card can pick its own)."
5487
+ >
5488
+ <option value="">fleet default</option>
5489
+ {assist.assistModels.map((m) => (
5490
+ <option key={m.model} value={m.model}>
5491
+ {m.model}
5492
+ {m.serving ? "" : " · cold"}
5493
+ </option>
5494
+ ))}
5495
+ </select>
5496
+ </div>
5497
+ ) : null
5498
+ }
5499
+ range={{
5500
+ start: sceneRangeStart,
5501
+ end: sceneRangeEnd,
5502
+ onChange: selectSceneRange,
5503
+ }}
5504
+ negativesOn={sceneGroupNegatives}
5505
+ onNegativesOnChange={setSceneGroupNegatives}
5506
+ negativesOnTitle="On: generate / enhance also write each selected part's Negative field (visible; sent as-is). Off: prompts only."
5507
+ stdChecked={sceneStdAllOn}
5508
+ onStdChange={onSceneStdChange}
5509
+ stdTitle="Tick: insert the standard exclusion set into every selected part's Negative field. Untick: remove exactly those terms. What you see in the field is what is sent."
5038
5510
  generateLabel={
5039
5511
  assist.assistBusy === "spread"
5040
- ? "✨ Writing the spread…"
5041
- : `✨ Generate prompts (${sceneSelectedCount} selected)`
5512
+ ? "✨ writing the spread…"
5513
+ : `✨ generate (${sceneSelectedCount} selected)`
5042
5514
  }
5043
- generateTitle="ONE call writes every ticked text part against one shared world; unticked parts and media captions ride along as locked context."
5044
- generateDisabledTitle="Tick the text parts you want written."
5515
+ generateTitle="ONE call writes every selected text part against one shared world; unselected parts and media captions ride along as locked context. Part #1's Negative field is sent as the shared negative."
5516
+ generateDisabledTitle="Select the text parts you want written (range, select all, or the per-card ticks)."
5045
5517
  onGenerate={() => void onSceneSpreadSelected()}
5046
- negativesLabel={
5047
- assist.assistBusy === "negative"
5048
- ? "✨ Writing the negative…"
5049
- : `✨ Generate negative (${sceneSelectedCount} selected)`
5518
+ enhanceLabel={
5519
+ assist.assistBusy === "detail"
5520
+ ? "✨ enhancing…"
5521
+ : `✨ enhance (${sceneEnhanceCount} selected)`
5522
+ }
5523
+ enhanceTitle={
5524
+ sceneEnhanceCount === 0
5525
+ ? "Enhance needs selected parts with a non-blank prompt."
5526
+ : "Enrich each selected non-blank prompt with more descriptive detail (one part at a time)."
5050
5527
  }
5051
- negativesTitle="Write an artifact/quality exclusion list for the scene's ONE negative, from the ticked parts' text."
5052
- onNegatives={() => void onSceneNegativesSelected()}
5528
+ canEnhance={sceneEnhanceCount > 0}
5529
+ onEnhance={() => void onSceneEnhanceSelected()}
5530
+ // Legacy single-line props (unused in the group layout, kept typed).
5531
+ negativesLabel=""
5532
+ onNegatives={() => undefined}
5533
+ onStandardNegatives={() => undefined}
5053
5534
  assistBusy={assist.assistBusy != null}
5054
- onStandardNegatives={onSceneStandardNegatives}
5055
- standardNegativesTitle="Writes a shared negative that every prompt with a blank Negative tab inherits; each prompt can override it on its own Negative tab."
5056
- />
5057
- {/* PROMPT-ASSIST MODEL — ONE shared selector for which text
5058
- generator every prompt's Enhance/Generate uses (the per-card
5059
- buttons all drive this same assist model). Defaults to
5060
- flux-klein-uncensored via the effect above. */}
5061
- {assist.assistModels.length > 0 && (
5062
- <div className="vi-prompt-assist vi-gen-assist-model">
5063
- <span className="vi-prompt-assist-lead" aria-hidden="true">
5064
- prompt assist model
5065
- </span>
5066
- <select
5067
- className="vi-select vi-select-sm"
5068
- value={assist.assistModel ?? ""}
5069
- disabled={assist.assistBusy != null}
5070
- onChange={(e) => assist.setAssistModel(e.target.value || null)}
5071
- aria-label="Text generator used to enhance or generate prompts"
5072
- title="Which text generator writes prompts for every prompt's Enhance / Generate."
5535
+ actionsExtra={
5536
+ <button
5537
+ type="button"
5538
+ className={`vi-btn vi-btn-sm vi-btn-ghost${scenePretext ? " vi-btn-on" : ""}`}
5539
+ onClick={() => setScenePretextOpen((v) => !v)}
5540
+ aria-expanded={scenePretextOpen}
5541
+ title="Attach ONE video as pretext: it is analyzed server-side and its description is fed to generate / enhance as context (not a prompt part)."
5073
5542
  >
5074
- <option value="">fleet default</option>
5075
- {assist.assistModels.map((m) => (
5076
- <option key={m.model} value={m.model}>
5077
- {m.model}
5078
- {m.serving ? "" : " · cold"}
5079
- </option>
5080
- ))}
5081
- </select>
5082
- </div>
5083
- )}
5543
+ {scenePretext ? "▶ video pretext ✓" : "+ video"}
5544
+ </button>
5545
+ }
5546
+ below={
5547
+ (scenePretextOpen || scenePretext) && (
5548
+ <div className="vi-gen-inline-attach vi-gen-pretext">
5549
+ {scenePretext ? (
5550
+ <div className="vi-gen-part-media">
5551
+ <span className="vi-gen-part-videoicon" aria-hidden>
5552
+ ▶
5553
+ </span>
5554
+ <span className="vi-gen-part-meta">
5555
+ <code>{scenePretext.ref.mime}</code>
5556
+ {scenePretext.origin ? ` · ${scenePretext.origin}` : ""}
5557
+ {scenePretext.label ? ` · ${scenePretext.label}` : ""}
5558
+ {" · described server-side as pretext for generate / enhance"}
5559
+ </span>
5560
+ <button
5561
+ type="button"
5562
+ className="vi-btn vi-btn-sm vi-btn-ghost"
5563
+ onClick={() => {
5564
+ setScenePretext(null);
5565
+ setScenePretextOpen(false);
5566
+ }}
5567
+ aria-label="Remove the pretext video"
5568
+ title="Remove the pretext video"
5569
+ >
5570
+ ✕
5571
+ </button>
5572
+ </div>
5573
+ ) : (
5574
+ renderVideoPickerBody((it) => {
5575
+ setScenePretext(it);
5576
+ setScenePretextOpen(false);
5577
+ })
5578
+ )}
5579
+ </div>
5580
+ )
5581
+ }
5582
+ />
5084
5583
  {/* GROUP SETTINGS — the settings counterpart to the group prompt
5085
5584
  assist: whichever prompt components are selected get these
5086
5585
  settings (applied as their own per-part overrides). */}
@@ -5198,14 +5697,16 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
5198
5697
  : "Remove"
5199
5698
  }
5200
5699
  >
5201
- {/* [Prompt][Negative] tabs over ONE GoalComposer pane. Per
5202
- the per-part refactor, EVERY part edits its OWN negative
5203
- (p.negative); a blank one inherits the shared station
5204
- negative (preset / toolbar) at submit, and [standard
5205
- negative] composes the standard set into that part per its
5206
- own tick (default = the shared sceneStdNegative). */}
5700
+ {/* [Prompt][Negative] tabs over ONE GoalComposer pane (labels
5701
+ visually hidden — the tabs say it). k93: EVERY part sends
5702
+ exactly what its own Negative field holds; blank is blank.
5703
+ Part #1's field is also the group assist's shared negative
5704
+ (its tab tooltip says so). The std. tick lives in the
5705
+ assist row below and reads its state from the field. */}
5207
5706
  <PromptFieldTabs
5208
5707
  idPrefix={`vi-gen-part-${p.key}`}
5708
+ hideLabels
5709
+ hideStdTick
5209
5710
  promptLabel={
5210
5711
  p.media
5211
5712
  ? `caption (optional — sent as text just before the ${kind})`
@@ -5219,120 +5720,275 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
5219
5720
  : "Describe what to generate…"
5220
5721
  }
5221
5722
  rows={2}
5723
+ negativeLabel="negative prompt"
5222
5724
  negative={p.negative ?? ""}
5223
5725
  onNegativeChange={(v) => patchPart(p.key, { negative: v })}
5224
- negativePlaceholder="what to avoid — blank = inherit shared"
5225
- negativeHint="Blank = inherit the shared negative (presets / toolbar)."
5226
- stdNegative={p.stdNegative ?? sceneStdNegative}
5227
- onStdNegativeChange={(v) => patchPart(p.key, { stdNegative: v })}
5726
+ negativePlaceholder="what to avoid (sent exactly as written; blank = none)"
5727
+ negativeTabTitle={
5728
+ !p.media && sceneTextParts[0]?.key === p.key
5729
+ ? "This part's negative. Part #1's Negative field is ALSO sent as the shared negative of the group generate / enhance — explicitly, never merged."
5730
+ : "This part's negative — sent exactly as written with this part's request."
5731
+ }
5732
+ stdNegative={hasStandardNegative(p.negative ?? "")}
5733
+ onStdNegativeChange={(v) => onPartStdChange(p, v)}
5228
5734
  negativeDisabledNote={undefined}
5229
5735
  negativeExtras={undefined}
5230
5736
  />
5231
5737
 
5232
- {/* Per-part Enhance/Generate (operator: Scene → per-component,
5233
- reversing the k86/k89 station-wide cluster). Mirrors Movie's
5234
- per-goal wiring: assist targets THIS part's text and writes
5235
- back to it. Text parts only — media parts carry captions and
5236
- ride the group actions as locked context. One in-flight assist
5237
- at a time across all parts. */}
5738
+ {/* k93 §B — the per-card assist row:
5739
+ prompt assist model [select]
5740
+ negatives: [on/off] [ ] std. (✨ generate) (✨ enhance)
5741
+ Text parts only — media parts carry captions and ride the
5742
+ group actions as locked context. One in-flight assist at a
5743
+ time across all parts. */}
5238
5744
  {!p.media && (
5239
- <PromptAssistButtons
5240
- busy={assist.assistBusy}
5241
- error={assist.assistError}
5242
- onDismissError={assist.dismissError}
5243
- onEnhance={() => void runPartAssist(p, "detail")}
5244
- onGenerate={() => void runPartAssist(p, "generate")}
5245
- canEnhance={p.text.trim() !== ""}
5246
- models={assist.assistModels}
5247
- model={p.assistModel ?? assist.assistModel}
5248
- onModelChange={(m) =>
5249
- patchPart(p.key, { assistModel: m })
5250
- }
5251
- />
5745
+ <div className="vi-gen-part-assist-row">
5746
+ <label
5747
+ className="vi-comfy-hint vi-prompt-toolbar-neg"
5748
+ title="On: this card's generate / enhance also write its Negative field. Off: the prompt only."
5749
+ >
5750
+ negatives:
5751
+ <button
5752
+ type="button"
5753
+ className={`vi-btn vi-btn-sm vi-toggle${(p.assistNegatives ?? true) ? " vi-toggle-on" : ""}`}
5754
+ role="switch"
5755
+ aria-checked={p.assistNegatives ?? true}
5756
+ onClick={() =>
5757
+ patchPart(p.key, { assistNegatives: !(p.assistNegatives ?? true) })
5758
+ }
5759
+ >
5760
+ {(p.assistNegatives ?? true) ? "on" : "off"}
5761
+ </button>
5762
+ </label>
5763
+ <label
5764
+ className="vi-comfy-hint vi-prompt-toolbar-std"
5765
+ title="Tick: insert the standard exclusion set into this part's Negative field. Untick: remove exactly those terms. What you see is what is sent."
5766
+ >
5767
+ <input
5768
+ type="checkbox"
5769
+ checked={hasStandardNegative(p.negative ?? "")}
5770
+ onChange={(e) => onPartStdChange(p, e.target.checked)}
5771
+ aria-label={`Insert the standard negative into part ${i + 1}`}
5772
+ />
5773
+ std.
5774
+ </label>
5775
+ <PromptAssistButtons
5776
+ busy={assist.assistBusy}
5777
+ error={assist.assistError}
5778
+ onDismissError={assist.dismissError}
5779
+ onEnhance={() => void runPartAssist(p, "detail")}
5780
+ onGenerate={() => void runPartAssist(p, "generate")}
5781
+ canEnhance={p.text.trim() !== ""}
5782
+ models={assist.assistModels}
5783
+ model={p.assistModel ?? assist.assistModel}
5784
+ onModelChange={(m) =>
5785
+ patchPart(p.key, { assistModel: m })
5786
+ }
5787
+ />
5788
+ </div>
5252
5789
  )}
5253
5790
 
5254
5791
  {p.media ? (
5255
- <div className="vi-gen-part-media">
5256
- {kind === "image" ? (
5257
- <img
5258
- className="vi-gen-part-thumb"
5259
- src={mediaBytesUrl(p.media.ref.uri)}
5260
- alt="Prompt image part"
5261
- loading="lazy"
5262
- />
5263
- ) : (
5264
- <span className="vi-gen-part-videoicon" aria-hidden>
5265
- ▶
5792
+ <div className="vi-gen-part-media-block">
5793
+ <div className="vi-gen-part-media">
5794
+ {kind === "image" ? (
5795
+ <img
5796
+ className="vi-gen-part-thumb"
5797
+ src={mediaBytesUrl(p.media.ref.uri)}
5798
+ alt="Prompt image part"
5799
+ loading="lazy"
5800
+ />
5801
+ ) : (
5802
+ <span className="vi-gen-part-videoicon" aria-hidden>
5803
+ ▶
5804
+ </span>
5805
+ )}
5806
+ <span className="vi-gen-part-meta">
5807
+ <code>{p.media.ref.mime}</code>
5808
+ {p.media.origin ? ` · ${p.media.origin}` : ""}
5809
+ {p.media.label ? ` · ${p.media.label}` : ""}
5810
+ {kind === "video"
5811
+ ? " · resolved to frames server-side"
5812
+ : ""}
5266
5813
  </span>
5814
+ <button
5815
+ type="button"
5816
+ className="vi-btn vi-btn-sm vi-btn-ghost"
5817
+ onClick={() => detachMedia(p.key)}
5818
+ aria-label="Detach media"
5819
+ title="Detach media (keeps the text)"
5820
+ >
5821
+ ✕ media
5822
+ </button>
5823
+ </div>
5824
+ {/* k93 §B.6 — pull ONE frame out of the attached video
5825
+ (frame-extract job → the frame becomes this part's
5826
+ image / start frame AND lands in the library). */}
5827
+ {kind === "video" && (
5828
+ <div className="vi-gen-part-pullframe">
5829
+ <label className="vi-comfy-hint" htmlFor={`vi-gen-pull-${p.key}`}>
5830
+ pull frame @
5831
+ </label>
5832
+ <input
5833
+ id={`vi-gen-pull-${p.key}`}
5834
+ type="number"
5835
+ className="vi-knob-input vi-gen-pull-input"
5836
+ min={0}
5837
+ step={0.1}
5838
+ max={p.media.ref.duration_s ?? undefined}
5839
+ value={pullFrameAt[p.key] ?? "0"}
5840
+ onChange={(e) =>
5841
+ setPullFrameAt((prev) => ({ ...prev, [p.key]: e.target.value }))
5842
+ }
5843
+ aria-label="Time in seconds of the frame to pull"
5844
+ />
5845
+ <span className="vi-comfy-hint">
5846
+ s{p.media.ref.duration_s != null ? ` / ${p.media.ref.duration_s.toFixed(1)}` : ""}
5847
+ </span>
5848
+ <button
5849
+ type="button"
5850
+ className="vi-btn vi-btn-sm"
5851
+ disabled={pullFrameJobs[p.key] != null}
5852
+ onClick={() => pullFrame(p)}
5853
+ title="Extract the frame at this time (frame-extract job). The still becomes this part's start image and is added to the library."
5854
+ >
5855
+ {pullFrameJobs[p.key] != null ? "pulling…" : "pull frame"}
5856
+ </button>
5857
+ {pullFrameError[p.key] && (
5858
+ <span className="vi-error">{pullFrameError[p.key]}</span>
5859
+ )}
5860
+ </div>
5861
+ )}
5862
+ {p.frame && (
5863
+ <div className="vi-gen-part-media">
5864
+ <img
5865
+ className="vi-gen-part-thumb"
5866
+ src={mediaBytesUrl(p.frame.ref.uri)}
5867
+ alt="Frame pulled from the attached video"
5868
+ loading="lazy"
5869
+ />
5870
+ <span className="vi-gen-part-meta">
5871
+ <code>{p.frame.ref.mime}</code>
5872
+ {p.frame.label ? ` · ${p.frame.label}` : ""}
5873
+ {" · sent as this part's start image (before the video)"}
5874
+ </span>
5875
+ <button
5876
+ type="button"
5877
+ className="vi-btn vi-btn-sm vi-btn-ghost"
5878
+ onClick={() => detachFrame(p.key)}
5879
+ aria-label="Drop the pulled frame"
5880
+ title="Drop the pulled frame (keeps the video)"
5881
+ >
5882
+ ✕ frame
5883
+ </button>
5884
+ </div>
5267
5885
  )}
5268
- <span className="vi-gen-part-meta">
5269
- <code>{p.media.ref.mime}</code>
5270
- {p.media.origin ? ` · ${p.media.origin}` : ""}
5271
- {p.media.label ? ` · ${p.media.label}` : ""}
5272
- {kind === "video"
5273
- ? " · resolved to frames server-side"
5274
- : ""}
5275
- </span>
5276
- <button
5277
- type="button"
5278
- className="vi-btn vi-btn-sm vi-btn-ghost"
5279
- onClick={() => detachMedia(p.key)}
5280
- aria-label="Detach media"
5281
- title="Detach media (keeps the text)"
5282
- >
5283
- ✕ media
5284
- </button>
5285
5886
  </div>
5286
5887
  ) : (
5287
- <div className="vi-gen-part-attach">
5288
- <button
5289
- type="button"
5290
- className="vi-btn vi-btn-sm vi-btn-ghost"
5291
- onClick={() => openAttachPicker(p.key, "image")}
5292
- title="Pick an image that pairs with this part's text"
5293
- >
5294
- + attach image
5295
- </button>
5296
- <button
5297
- type="button"
5298
- className="vi-btn vi-btn-sm vi-btn-ghost"
5299
- onClick={() => openAttachPicker(p.key, "video")}
5300
- title="Pick a video that pairs with this part's text"
5301
- >
5302
- + attach video
5303
- </button>
5304
- {/* k89 — the add bar's library-APPEND path, reachable from
5305
- the card (adds a NEW image part rather than pairing). */}
5306
- <button
5307
- type="button"
5308
- className="vi-btn vi-btn-sm vi-btn-ghost"
5309
- onClick={() => {
5310
- setAttachTarget(null); // append mode, not attach
5311
- setShowImagePicker((v) => !v);
5312
- setShowVideoPicker(false);
5313
- }}
5314
- title="Append an image part from the library (a new part, not paired with this one)"
5315
- >
5316
- + from library
5317
- </button>
5318
- </div>
5888
+ <>
5889
+ {/* k93 §B.5 — the attach row; each button EXPANDS its
5890
+ picker inline right under this row. */}
5891
+ <div className="vi-gen-part-attach">
5892
+ <button
5893
+ type="button"
5894
+ className={`vi-btn vi-btn-sm vi-btn-ghost${partAttachOpen?.key === p.key && partAttachOpen.kind === "image" ? " vi-btn-on" : ""}`}
5895
+ onClick={() => openAttachPicker(p.key, "image")}
5896
+ aria-expanded={partAttachOpen?.key === p.key && partAttachOpen.kind === "image"}
5897
+ title="Pick an image that pairs with this part's text"
5898
+ >
5899
+ + image
5900
+ </button>
5901
+ <button
5902
+ type="button"
5903
+ className={`vi-btn vi-btn-sm vi-btn-ghost${partAttachOpen?.key === p.key && partAttachOpen.kind === "video" ? " vi-btn-on" : ""}`}
5904
+ onClick={() => openAttachPicker(p.key, "video")}
5905
+ aria-expanded={partAttachOpen?.key === p.key && partAttachOpen.kind === "video"}
5906
+ title="Pick a video that pairs with this part's text (a frame can then be pulled from it)"
5907
+ >
5908
+ + video
5909
+ </button>
5910
+ <button
5911
+ type="button"
5912
+ className={`vi-btn vi-btn-sm vi-btn-ghost${partAttachOpen?.key === p.key && partAttachOpen.kind === "library" ? " vi-btn-on" : ""}`}
5913
+ onClick={() => openAttachPicker(p.key, "library")}
5914
+ aria-expanded={partAttachOpen?.key === p.key && partAttachOpen.kind === "library"}
5915
+ title="Append a media part from the library (a new part, not paired with this one)"
5916
+ >
5917
+ + library
5918
+ </button>
5919
+ </div>
5920
+ {partAttachOpen?.key === p.key && (
5921
+ <div className="vi-gen-inline-attach">
5922
+ {partAttachOpen.kind === "image" && (
5923
+ <>
5924
+ <p className="vi-crop-hint">
5925
+ The picked image pairs with this part&apos;s text.
5926
+ </p>
5927
+ {renderImagePickerBody(placeMedia)}
5928
+ </>
5929
+ )}
5930
+ {partAttachOpen.kind === "video" && (
5931
+ <>
5932
+ <p className="vi-crop-hint">
5933
+ The picked video pairs with this part&apos;s text; it is
5934
+ resolved into frames server-side, or pull one frame
5935
+ from it afterwards.
5936
+ </p>
5937
+ {renderVideoPickerBody(placeMedia)}
5938
+ </>
5939
+ )}
5940
+ {partAttachOpen.kind === "library" && (
5941
+ <>
5942
+ <p className="vi-crop-hint">
5943
+ Appends a NEW media part after the last one.
5944
+ </p>
5945
+ {renderLibraryPickerBody((m) => {
5946
+ setAttachTarget(null);
5947
+ placeMedia(m);
5948
+ })}
5949
+ </>
5950
+ )}
5951
+ </div>
5952
+ )}
5953
+ </>
5319
5954
  )}
5320
5955
 
5321
5956
  {/* IDENTITY — the generation-wide character, selectable from
5322
- ANY part (alongside that part's "from library"/attach
5323
- controls) because ONE identity is shared by the whole
5324
- generation. Rendered on every part and driven by the SAME
5325
- bound state, so all parts always agree; the profile list
5326
- comes from the station's single useIdentityProfiles(). */}
5327
- <GenIdentityBar
5328
- profiles={identityProfiles}
5329
- loading={identityLoading}
5330
- error={identityError}
5331
- bound={boundIdentity}
5332
- onBind={bindIdentity}
5333
- onClear={clearIdentity}
5334
- disabled={running}
5335
- />
5957
+ ANY part because ONE identity is shared by the whole
5958
+ generation. k93 §B.7: rendered as a single `+ identity`
5959
+ button that expands inline (same row idiom); shows bound
5960
+ when one is set. */}
5961
+ <div className="vi-gen-part-attach">
5962
+ <button
5963
+ type="button"
5964
+ className={`vi-btn vi-btn-sm vi-btn-ghost${identityOpen.has(p.key) || boundIdentity ? " vi-btn-on" : ""}`}
5965
+ aria-expanded={identityOpen.has(p.key)}
5966
+ onClick={() =>
5967
+ setIdentityOpen((prev) => {
5968
+ const next = new Set(prev);
5969
+ if (next.has(p.key)) next.delete(p.key);
5970
+ else next.add(p.key);
5971
+ return next;
5972
+ })
5973
+ }
5974
+ title="Bind an identity profile (one per generation — shared by every part)"
5975
+ >
5976
+ {boundIdentity ? `+ identity ✓ ${boundIdentity.name}` : "+ identity"}
5977
+ </button>
5978
+ </div>
5979
+ {identityOpen.has(p.key) && (
5980
+ <div className="vi-gen-inline-attach">
5981
+ <GenIdentityBar
5982
+ profiles={identityProfiles}
5983
+ loading={identityLoading}
5984
+ error={identityError}
5985
+ bound={boundIdentity}
5986
+ onBind={bindIdentity}
5987
+ onClear={clearIdentity}
5988
+ disabled={running}
5989
+ />
5990
+ </div>
5991
+ )}
5336
5992
 
5337
5993
  {renderPartSettings(p)}
5338
5994
  {renderPartResult(p)}
@@ -5355,7 +6011,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
5355
6011
  contract, and scene's own knobs (frames/motion/fps/assemble/strength/
5356
6012
  chain) stay in the rail because they describe the SCENE, not the frame.
5357
6013
 
5358
- The ticks decide what a component seeded from this one inherits; in
6014
+ The ticks decide what a component seeded from this one receives; in
5359
6015
  scene mode generation itself just reads the values as it always has.
5360
6016
 
5361
6017
  k89: the strip stays STATION-WIDE (it configures the single
@@ -5557,12 +6213,23 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
5557
6213
  </div>
5558
6214
  )}
5559
6215
  </div>
6216
+ {nameWarn && (
6217
+ <UnnamedWarnPanel
6218
+ noun={mode}
6219
+ onProceed={(name) => {
6220
+ setNameWarn(false);
6221
+ if (name) setProjectName(name);
6222
+ setPendingRun(true);
6223
+ }}
6224
+ onDismiss={() => setNameWarn(false)}
6225
+ />
6226
+ )}
5560
6227
  <div className="vi-station-footer-run">
5561
6228
  <button
5562
6229
  type="button"
5563
6230
  className="vi-btn vi-btn-accent"
5564
6231
  disabled={!canRun}
5565
- onClick={onRun}
6232
+ onClick={() => onRun()}
5566
6233
  >
5567
6234
  {running
5568
6235
  ? mode === "movie"