@hugpy/video-intelligence-ui 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/package.json +1 -3
  2. package/src/config.ts +36 -0
  3. package/src/demo/assets/generate/cinema-cavern/movie.mp4 +0 -0
  4. package/src/demo/assets/generate/cinema-cavern/seg_00.mp4 +0 -0
  5. package/src/demo/assets/generate/clips/cat-lounge.mp4 +0 -0
  6. package/src/demo/assets/generate/clips/drone-city.mp4 +0 -0
  7. package/src/demo/assets/generate/clips/paper-sails.mp4 +0 -0
  8. package/src/demo/assets/generate/movie-galaxy/movie.mp4 +0 -0
  9. package/src/demo/assets/generate/movie-galaxy/seg_00.mp4 +0 -0
  10. package/src/demo/assets/generate/movie-galaxy/seg_01.mp4 +0 -0
  11. package/src/demo/assets/generate/movie-galaxy/seg_02.mp4 +0 -0
  12. package/src/demo/assets/generate/movie-galaxy/seg_03.mp4 +0 -0
  13. package/src/demo/assets/generate/movie-medow/movie.mp4 +0 -0
  14. package/src/demo/assets/generate/movie-medow/seg_00.mp4 +0 -0
  15. package/src/demo/assets/generate/movie-medow/seg_01.mp4 +0 -0
  16. package/src/demo/assets/generate/scene-desert/frame_00000.png +0 -0
  17. package/src/demo/assets/generate/scene-desert/frame_00001.png +0 -0
  18. package/src/demo/assets/generate/scene-desert/frame_00002.png +0 -0
  19. package/src/demo/assets/generate/scene-desert/frame_00003.png +0 -0
  20. package/src/demo/assets/generate/scene-desert/frame_00004.png +0 -0
  21. package/src/demo/assets/generate/scene-desert/frame_00005.png +0 -0
  22. package/src/demo/assets/generate/scene-desert/scene.mp4 +0 -0
  23. package/src/demo/assets/generate/scene-vintage/frame_00000.png +0 -0
  24. package/src/demo/assets/generate/scene-vintage/frame_00001.png +0 -0
  25. package/src/demo/assets/generate/scene-vintage/frame_00002.png +0 -0
  26. package/src/demo/assets/generate/scene-vintage/frame_00003.png +0 -0
  27. package/src/demo/assets/generate/scene-vintage/frame_00004.png +0 -0
  28. package/src/demo/assets/generate/scene-vintage/frame_00005.png +0 -0
  29. package/src/demo/assets/generate/scene-vintage/scene.mp4 +0 -0
  30. package/src/demo/assets/generate2/s0.png +0 -0
  31. package/src/demo/assets/generate2/s1.png +0 -0
  32. package/src/demo/assets/generate2/s10.png +0 -0
  33. package/src/demo/assets/generate2/s11.png +0 -0
  34. package/src/demo/assets/generate2/s12.png +0 -0
  35. package/src/demo/assets/generate2/s13.png +0 -0
  36. package/src/demo/assets/generate2/s14.png +0 -0
  37. package/src/demo/assets/generate2/s15.png +0 -0
  38. package/src/demo/assets/generate2/s16.png +0 -0
  39. package/src/demo/assets/generate2/s17.png +0 -0
  40. package/src/demo/assets/generate2/s18.png +0 -0
  41. package/src/demo/assets/generate2/s19.png +0 -0
  42. package/src/demo/assets/generate2/s2.png +0 -0
  43. package/src/demo/assets/generate2/s20.png +0 -0
  44. package/src/demo/assets/generate2/s21.mp4 +0 -0
  45. package/src/demo/assets/generate2/s22.mp4 +0 -0
  46. package/src/demo/assets/generate2/s23.mp4 +0 -0
  47. package/src/demo/assets/generate2/s24.mp4 +0 -0
  48. package/src/demo/assets/generate2/s25.mp4 +0 -0
  49. package/src/demo/assets/generate2/s26.mp4 +0 -0
  50. package/src/demo/assets/generate2/s27.mp4 +0 -0
  51. package/src/demo/assets/generate2/s28.mp4 +0 -0
  52. package/src/demo/assets/generate2/s29.mp4 +0 -0
  53. package/src/demo/assets/generate2/s3.png +0 -0
  54. package/src/demo/assets/generate2/s30.mp4 +0 -0
  55. package/src/demo/assets/generate2/s4.png +0 -0
  56. package/src/demo/assets/generate2/s5.png +0 -0
  57. package/src/demo/assets/generate2/s6.png +0 -0
  58. package/src/demo/assets/generate2/s7.png +0 -0
  59. package/src/demo/assets/generate2/s8.png +0 -0
  60. package/src/demo/assets/generate2/s9.png +0 -0
  61. package/src/demo/demoFetch.ts +126 -3
  62. package/src/demo/fixtures.ts +72 -4
  63. package/src/demo/promptAssist.ts +25 -0
  64. package/src/demo/sampleData.ts +960 -0
  65. package/src/demo/sampleSets.ts +172 -0
  66. package/src/stations/ActiveProcessesStation.tsx +18 -3
  67. package/src/stations/GenerateStation.tsx +901 -366
  68. package/src/stations/GoalComposer.tsx +9 -1
  69. package/src/stations/ScriptFirstStation.tsx +1081 -0
  70. package/src/stations/ToksPanel.tsx +188 -0
  71. package/src/stations/WorkbenchDrawer.tsx +4 -3
  72. package/src/stations/WorkbenchSidebar.tsx +6 -3
  73. package/src/stations/oracle/DagPanel.tsx +391 -0
  74. package/src/stations/oracle/OracleStation.tsx +304 -0
  75. package/src/stations/oracle/SpatialTab.tsx +185 -0
  76. package/src/stations/oracle/StewardPanel.tsx +165 -0
  77. package/src/stations/oracle/oracle.css +467 -0
  78. package/src/stations/oracle/oracleShared.tsx +160 -0
  79. package/src/stations/oracle/oracleTypes.ts +304 -0
  80. package/src/stations/oracle/useOracle.ts +560 -0
  81. package/src/stations/registry.ts +37 -0
  82. package/src/stations/scriptFirst.css +350 -0
  83. package/src/stations/studio/IdentitiesStation.tsx +219 -292
  84. package/src/stations/studio/IdentityCreatePanel.tsx +390 -0
  85. package/src/stations/studio/StudioGenerateTab.tsx +10 -17
  86. package/src/stations/studio/StudioMovieComposer.tsx +296 -59
  87. package/src/stations/studio/StudioPlane.tsx +35 -3
  88. package/src/stations/studio/StudioViewer.tsx +116 -14
  89. package/src/stations/studio/coordinationBadge.tsx +206 -0
  90. package/src/stations/studio/identityUpload.ts +66 -0
  91. package/src/stations/studio/promptCard.tsx +362 -59
  92. package/src/stations/studio/useIdentityCreateJob.ts +184 -0
  93. package/src/stations/studio/useIdentityProfiles.ts +66 -0
  94. package/src/stations/useScriptFirstRun.ts +596 -0
  95. package/src/style/app.css +157 -0
  96. package/src/video/LengthRow.tsx +189 -0
  97. package/src/video/coordinationReport.ts +266 -0
  98. package/src/video/jobClock.ts +104 -0
  99. package/src/video/spreadAssist.ts +26 -0
  100. package/src/video/useMediaJobs.ts +26 -0
  101. package/src/video/useOperatorHistory.ts +5 -0
  102. package/src/video/useToks.ts +193 -0
@@ -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,
@@ -87,7 +89,9 @@ import { registerComposer, type StageRequest } from "../video/composerBridge";
87
89
  import {
88
90
  mediaRefSchema,
89
91
  uploadResultSchema,
92
+ enqueueResultSchema,
90
93
  isMovieProgress,
94
+ type FrameExtractRequest,
91
95
  type GenPromptPart,
92
96
  type GenerateImageRequest,
93
97
  type GenerateSceneRequest,
@@ -233,7 +237,7 @@ type GoalRow = {
233
237
  // k92 — a movie goal IS a prompt component (same architecture as a Scene part):
234
238
  // it may carry its OWN generation knobs. Held as the same live strings the station
235
239
  // setters use so a goal's override parses/validates exactly like the shared value
236
- // 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 /
237
241
  // goalExtra). `knobs` = the 4 CondensedKnobStrip knobs (size/steps/guidance/seed);
238
242
  // `extra` = model + the goal-appropriate scene knobs (strength/chain/motion).
239
243
  knobs?: Partial<KnobValues>;
@@ -337,6 +341,14 @@ type PartEntry = {
337
341
  // Per-part PROMPT-ASSIST model (which text generator writes THIS prompt's
338
342
  // Enhance/Generate). Undefined = use the shared assist model / fleet default.
339
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;
340
352
  };
341
353
  // The share-tick keys for EVERY per-part setting. "share" = one value across all
342
354
  // prompts; unticked = each prompt keeps its own. (Scene owns its own share state
@@ -491,8 +503,15 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
491
503
  // example prompt (matches the sample gens) so the brochure opens with a coherent,
492
504
  // runnable story instead of a blank composer. The seed string lives in src/demo/
493
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.
494
509
  const [parts, setParts] = useSessionState<PartEntry[]>("gen.parts", () => [
495
- { key: partKey(), text: isCanned() ? DEMO_GENERATE_PROMPT : "" },
510
+ {
511
+ key: partKey(),
512
+ text: isCanned() ? DEMO_GENERATE_PROMPT : "",
513
+ negative: STANDARD_NEGATIVE,
514
+ },
496
515
  ]);
497
516
  const [showImagePicker, setShowImagePicker] = useState<boolean>(false);
498
517
  const [showVideoPicker, setShowVideoPicker] = useState<boolean>(false);
@@ -563,25 +582,18 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
563
582
  const [guidance, setGuidance] = useSessionState<string>("gen.guidance", DEFAULT_KNOBS.guidance);
564
583
  // blank = null (random)
565
584
  const [seed, setSeed] = useSessionState<string>("gen.seed", DEFAULT_KNOBS.seed);
566
- // k89: no longer SEEDED with the standard-negative TEXT — the standard set now
567
- // rides the [standard negative] checkbox (sceneStdNegative here for Scene; goal
568
- // #1's stdNegative for Movie, seeded ON below) and is COMPOSED in at submit via
569
- // composeNegative, so the effective default payload is byte-identical to the old
570
- // 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.
571
590
  const [negative, setNegative] = useSessionState<string>("gen.negative", "");
572
- // Scene's [standard negative] tick — station-wide like `negative` itself (ONE
573
- // negative per scene), rendered on part #1's tabbed Negative pane. Defaults ON to
574
- // preserve the pre-k89 prefill behavior.
575
- const [sceneStdNegative, setSceneStdNegative] = useSessionState<boolean>(
576
- "gen.sceneStdNegative",
577
- true,
578
- );
579
591
 
580
592
  // ---- per-setting CARRY ticks on the base composer strip (operator ask
581
593
  // 2026-08-04, k63) ----
582
594
  // "Bring this setting into the post created component": every setting in the
583
595
  // condensed strip carries a tick, and the tick decides what a component SEEDED
584
- // 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 /
585
597
  // per-preset expanders; the same rule is what any future scene-carry flow reads).
586
598
  // Untick a setting and the new component gets the mode default instead — that is
587
599
  // how you build a sweep that varies exactly one thing. Purely client-side: no
@@ -672,7 +684,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
672
684
  // and produces its OWN job (see useGenerateRowJobs); "Run all rows" fires them
673
685
  // together so the results land beside each other.
674
686
  //
675
- // 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
676
688
  // image/video/identity parts, so a sweep holds the reference material fixed and
677
689
  // varies only the thing under test — otherwise the comparison proves nothing.
678
690
  const [testerOpen, setTesterOpen] = useState<boolean>(false);
@@ -693,6 +705,8 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
693
705
  const [goals, setGoals] = useSessionState<GoalRow[]>("gen.movie.goals", () => [
694
706
  { id: goalKey(), start: "0", end: String(DEFAULT_GOAL_LEN), prompt: "", stdNegative: true },
695
707
  ]);
708
+ // Rows parked by lowering the goal count — revived (keys intact) when it grows.
709
+ const [goalStash, setGoalStash] = useSessionState<GoalRow[]>("gen.movie.goalStash", () => []);
696
710
  // When set, the image picker attaches its pick as THIS goal's reference (vs a part).
697
711
  const [goalAttachTarget, setGoalAttachTarget] = useState<string | null>(null);
698
712
  // ---- Movie prompt-list toolbar state (k88 — mirrors Cinema's group assist) ----
@@ -710,6 +724,64 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
710
724
  const [sceneSelectedIds, setSceneSelectedIds] = useState<ReadonlySet<string>>(() => new Set());
711
725
  const [sceneSpreadNotice, setSceneSpreadNotice] = useState<string[] | null>(null);
712
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]);
713
785
  // ---- Director knobs (opt-in vision scoring / re-roll loop) ----
714
786
  const [visionEnabled, setVisionEnabled] = useSessionState<boolean>(
715
787
  "gen.visionEnabled",
@@ -823,7 +895,13 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
823
895
  // the prefilled text) — so the standard-set tick clears too, keeping the
824
896
  // effective payload identical to the old override semantics.
825
897
  setNegative(d.negative);
826
- 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
+ });
827
905
  setGoals((prev) =>
828
906
  prev.map((g, i) => (i === 0 ? { ...g, stdNegative: false } : g)),
829
907
  );
@@ -1201,7 +1279,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
1201
1279
 
1202
1280
  // ---- part mutations ----
1203
1281
  function addText() {
1204
- setParts((prev) => [...prev, { key: partKey(), text: "" }]);
1282
+ setParts((prev) => [...prev, { key: partKey(), text: "", negative: STANDARD_NEGATIVE }]);
1205
1283
  }
1206
1284
  function editText(key: string, text: string) {
1207
1285
  setParts((prev) => prev.map((p) => (p.key === key ? { ...p, text } : p)));
@@ -1350,14 +1428,34 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
1350
1428
  );
1351
1429
  }
1352
1430
 
1353
- // Per-part Enhance/Generate — writes the prompt AND its negative (operator:
1354
- // the negative is part of the assist). The negative is derived from the NEW
1355
- // 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
1356
1447
  // negative, Generate writes a fresh one. Sequential because the assist hook
1357
- // allows one in-flight call at a time.
1358
- 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
+ ) {
1359
1456
  // This part's OWN assist model (falls back to the shared pick / fleet default).
1360
1457
  const model = p.assistModel ?? assist.assistModel ?? null;
1458
+ const context = opts.media ? { media: opts.media } : undefined;
1361
1459
  let newText = p.text;
1362
1460
  await assist.runAssist(
1363
1461
  mode,
@@ -1366,32 +1464,28 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
1366
1464
  newText = t;
1367
1465
  editText(p.key, t);
1368
1466
  },
1369
- { model },
1467
+ { model, ...(context ? { context } : {}) },
1370
1468
  );
1371
1469
  if (newText.trim() === "") return;
1470
+ const writeNegative = opts.negatives ?? p.assistNegatives ?? true;
1471
+ if (!writeNegative) return;
1372
1472
  await assist.runNegative(
1373
1473
  {
1374
1474
  subject: newText,
1375
1475
  draft: mode === "detail" ? p.negative ?? "" : "",
1376
- extra: { model },
1476
+ extra: { model, ...(context ? { context } : {}) },
1377
1477
  },
1378
- // Fold the STANDARD negative into the generated one so the anti-frame terms
1379
- // are visibly IN the field (operator: standard negatives are default and part
1380
- // of Generate), not just appended silently at submit. Keep this part's std
1381
- // tick ON so it stays applied. composeNegative dedupes, so submit-time
1382
- // 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.
1383
1481
  (neg) => {
1384
- const std = p.stdNegative ?? sceneStdNegative;
1482
+ const std = hasStandardNegative(p.negative ?? "");
1385
1483
  // APPEND, never clobber (operator 2026-08-13): a GENERATED negative adds
1386
1484
  // to whatever the operator already typed (Enhance still refines-in-place —
1387
1485
  // its draft was the existing text, so appending would double it).
1388
- // composeNegative dedupes, so submit-time composition never doubles a term.
1389
1486
  const cur = (p.negative ?? "").trim().replace(/,\s*$/, "");
1390
1487
  const mergedRaw = mode === "generate" && cur ? `${cur}, ${neg}` : neg;
1391
- patchPart(p.key, {
1392
- negative: composeNegative(mergedRaw, std),
1393
- stdNegative: std,
1394
- });
1488
+ patchPart(p.key, { negative: composeNegative(mergedRaw, std) });
1395
1489
  },
1396
1490
  );
1397
1491
  // NAME RIDES ALONG (operator 2026-08-13): derive the project name from the
@@ -1416,6 +1510,11 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
1416
1510
  const p = parts.find((x) => !x.media && x.text.trim() !== "");
1417
1511
  return p ? p.text : assembledPrompt;
1418
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
+ }
1419
1518
  function addPartPerModel(): void {
1420
1519
  const text = basePromptText();
1421
1520
  if (text.trim() === "") return;
@@ -1434,6 +1533,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
1434
1533
  ...targets.map((m) => ({
1435
1534
  key: partKey(),
1436
1535
  text,
1536
+ negative: basePartNegative(),
1437
1537
  extra: { model: m.id } as Partial<PartExtra>,
1438
1538
  })),
1439
1539
  ]);
@@ -1460,16 +1560,13 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
1460
1560
  const part: PartEntry = {
1461
1561
  key: partKey(),
1462
1562
  text,
1563
+ negative: basePartNegative(),
1463
1564
  extra: { model: pre.model_key || modelId },
1464
1565
  knobs,
1465
1566
  };
1466
- // A preset's negative REPLACES the standard set (mirrors applyPresetLoad's
1467
- // setSceneStdNegative(false)) — otherwise the standard exclusions get
1468
- // appended and the component sends a different negative than the preset.
1469
- if (d.negative != null) {
1470
- part.negative = d.negative;
1471
- part.stdNegative = false;
1472
- }
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;
1473
1570
  return part;
1474
1571
  }),
1475
1572
  ]);
@@ -1956,6 +2053,9 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
1956
2053
  function buildPartPromptParts(p: PartEntry): GenPromptPart[] {
1957
2054
  const out: GenPromptPart[] = [];
1958
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 });
1959
2059
  if (p.media)
1960
2060
  out.push(
1961
2061
  p.media.ref.kind === "video"
@@ -1997,10 +2097,8 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
1997
2097
 
1998
2098
  // Build ONE scene request from a component, or null when any resolved knob is
1999
2099
  // invalid (skip, never submit coerced garbage). `lead` = the shared media that
2000
- // conditions every prompt. Negative: the part's own value wins, else the shared
2001
- // station negative (so presets / the toolbar negative writer / the standard set
2002
- // still apply); the standard set composes in per the part's tick (default = the
2003
- // 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.
2004
2102
  function buildSceneRow(
2005
2103
  p: PartEntry,
2006
2104
  lead: GenPromptPart[],
@@ -2039,9 +2137,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
2039
2137
  const partNeedsImage =
2040
2138
  requiredInputsFor(partModel?.tasks ?? []).image === "required";
2041
2139
  if (partNeedsImage && !reqParts.some((x) => x.kind === "image")) return null;
2042
- const baseNeg = (p.negative ?? "").trim() || negative.trim();
2043
- const std = p.stdNegative ?? sceneStdNegative;
2044
- const neg = composeNegative(baseNeg, std).trim();
2140
+ const neg = (p.negative ?? "").trim();
2045
2141
  return {
2046
2142
  rowId: p.key,
2047
2143
  req: {
@@ -2080,31 +2176,106 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
2080
2176
  setParts((prev) => prev.filter((p) => p.key !== key));
2081
2177
  if (attachTarget === key) setAttachTarget(null);
2082
2178
  }
2083
- /** 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. */
2084
2180
  function detachMedia(key: string) {
2085
2181
  setParts((prev) =>
2086
- prev.map((p) => (p.key === key ? { ...p, media: undefined } : p)),
2182
+ prev.map((p) => (p.key === key ? { ...p, media: undefined, frame: undefined } : p)),
2087
2183
  );
2088
2184
  }
2089
- /** Open a picker in ATTACH mode: the selection pairs onto part `key`. */
2090
- function openAttachPicker(key: string, kind: "image" | "video") {
2091
- setAttachTarget(key);
2092
- setShowImagePicker(kind === "image");
2093
- 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);
2094
2207
  }
2095
2208
  // Route a picked media: pair it onto the attach-target part when one is set,
2096
2209
  // else append it as a new part (empty caption, editable in place).
2097
2210
  function placeMedia(media: PartMedia) {
2211
+ setPartAttachOpen(null);
2098
2212
  if (attachTarget != null) {
2099
2213
  const target = attachTarget;
2100
2214
  setParts((prev) =>
2101
- prev.map((p) => (p.key === target ? { ...p, media } : p)),
2215
+ prev.map((p) => (p.key === target ? { ...p, media, frame: undefined } : p)),
2102
2216
  );
2103
2217
  setAttachTarget(null);
2104
2218
  return;
2105
2219
  }
2106
2220
  setParts((prev) => [...prev, { key: partKey(), text: "", media }]);
2107
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
+ }
2108
2279
  function movePart(key: string, dir: -1 | 1) {
2109
2280
  setParts((prev) => {
2110
2281
  const i = prev.findIndex((p) => p.key === key);
@@ -2622,27 +2793,32 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
2622
2793
  }
2623
2794
 
2624
2795
  // ---- Movie prompt-list toolbar actions (k88 — the shared PromptListToolbar) ----
2625
- /** Cap + semantics mirror Cinema's Segments stepper: grow by appending default-
2626
- length windows after the last row, TRUNCATE from the tail to shrink. */
2627
- 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. */
2628
2800
  function setGoalCount(n: number) {
2629
- if (!Number.isInteger(n) || n < 1 || n > MAX_MOVIE_GOALS) return;
2630
- setGoals((prev) => {
2631
- if (n === prev.length) return prev;
2632
- if (n < prev.length) return prev.slice(0, n);
2633
- const next = prev.slice();
2634
- while (next.length < n) {
2635
- const last = next[next.length - 1];
2636
- const start = last ? toNumberOrNull(last.end) ?? 0 : 0;
2637
- next.push({
2638
- id: goalKey(),
2639
- start: String(start),
2640
- end: String(start + DEFAULT_GOAL_LEN),
2641
- prompt: "",
2642
- });
2643
- }
2644
- return next;
2645
- });
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);
2646
2822
  }
2647
2823
  function toggleMovieSelected(id: string) {
2648
2824
  setMovieSelectedIds((prev) => {
@@ -2740,7 +2916,8 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
2740
2916
  if (n === prev.length) return prev;
2741
2917
  if (n > prev.length) {
2742
2918
  const next = prev.slice();
2743
- while (next.length < n) next.push({ key: partKey(), text: "" });
2919
+ while (next.length < n)
2920
+ next.push({ key: partKey(), text: "", negative: STANDARD_NEGATIVE });
2744
2921
  return next;
2745
2922
  }
2746
2923
  const next = prev.slice();
@@ -2758,14 +2935,6 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
2758
2935
  return next;
2759
2936
  });
2760
2937
  }
2761
- function toggleSceneSelected(key: string) {
2762
- setSceneSelectedIds((prev) => {
2763
- const next = new Set(prev);
2764
- if (next.has(key)) next.delete(key);
2765
- else next.add(key);
2766
- return next;
2767
- });
2768
- }
2769
2938
  // Selection is over TEXT parts only — the group actions rewrite prompts, and a
2770
2939
  // media part's caption rides as locked context (its checkbox renders disabled).
2771
2940
  // Live-row math, same as Movie: a removed part can leave a stale key behind.
@@ -2776,36 +2945,103 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
2776
2945
  );
2777
2946
  const sceneAllSelected =
2778
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).
2779
2983
  function toggleSceneSelectAll() {
2780
- setSceneSelectedIds(
2781
- sceneAllSelected ? new Set() : new Set(sceneTextParts.map((p) => p.key)),
2782
- );
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();
2783
3012
  }
2784
3013
 
2785
- // GROUP ACTION (k89): ONE coherent spread across the ticked text parts — the same
2786
- // one-call rule Cinema's onSpreadSelected / Movie's onMovieSpreadSelected enforce:
2787
- // selected parts are targets, everything else (unticked text, media captions)
2788
- // 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.
2789
3022
  async function onSceneSpreadSelected() {
2790
- const wiredNegative = composeNegative(negative, sceneStdNegative);
3023
+ const wiredNegative = sceneGroupNegative();
2791
3024
  const inputs: SpreadGoalInput[] = parts.map((p, i) => ({
2792
3025
  key: p.key,
2793
3026
  prompt: p.text,
2794
- negative: i === 0 ? wiredNegative : "",
3027
+ negative: p.negative ?? "",
2795
3028
  seed: "",
2796
3029
  branchFrame: "",
2797
3030
  joint: "still",
2798
3031
  index: i,
2799
3032
  }));
3033
+ const media = scenePretextWire();
2800
3034
  const body = buildSpreadBody({
2801
3035
  goals: inputs,
2802
3036
  selectedKeys: sceneSelectedIds,
2803
3037
  movieQuery: "",
2804
3038
  globalNegative: wiredNegative,
2805
3039
  steeringSeed: sceneSteeringSeed,
3040
+ context: { kind: "scene", ...(media ? { media } : {}) },
2806
3041
  });
2807
3042
  if (!body) return;
2808
3043
  setSceneSpreadNotice(null);
3044
+ const writeNegatives = sceneGroupNegatives;
2809
3045
  const res = await assist.runSpread(body);
2810
3046
  if (!res) return;
2811
3047
  const bySeg = new Map(res.segments.map((s) => [s.segment_id, s]));
@@ -2818,33 +3054,47 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
2818
3054
  // may linger in the select set — never overwrite a media caption).
2819
3055
  if (!sceneSelectedIds.has(p.key) || p.media) return p;
2820
3056
  const hit = bySeg.get(p.key);
2821
- // ONE scene negative on the wire — a returned per-part negative is dropped
2822
- // here rather than written somewhere it could never be sent (Movie's rule).
2823
- 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) };
2824
3062
  }),
2825
3063
  );
2826
3064
  if (res.steering_seed != null) setSceneSteeringSeed(res.steering_seed);
2827
3065
  setSceneSpreadNotice(spreadNoticeLines(res, applied));
2828
3066
  }
2829
3067
 
2830
- // GROUP ACTION (k89): write the scene's ONE wired negative from the ticked parts'
2831
- // text — Movie's single-runNegative approach, not Cinema's per-row loop (the wire
2832
- // carries one negative per scene).
2833
- async function onSceneNegativesSelected() {
2834
- 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() !== "");
2835
3075
  if (rows.length === 0) return;
2836
3076
  setSceneSpreadNotice(null);
2837
- const subject = rows
2838
- .map((p) => p.text.trim())
2839
- .filter((t) => t !== "")
2840
- .join("; ");
2841
- 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
+ }
2842
3081
  }
2843
3082
 
2844
- // Toolbar [standard negatives]: ONE wired negative (part #1's Negative tab), so
2845
- // this ticks that pane's [standard negative] on.
2846
- function onSceneStandardNegatives() {
2847
- 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) });
2848
3098
  }
2849
3099
  // Route a picked image: onto the attach-target GOAL (Movie mode) when set, else
2850
3100
  // through the parts placeMedia path (Image/Scene). Keeps the picker/upload code
@@ -2859,18 +3109,18 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
2859
3109
  placeMedia(media);
2860
3110
  }
2861
3111
 
2862
- function addImage(item: LibraryItem) {
2863
- placePickedImage({ ref: item.ref, origin: item.origin, label: item.label });
3112
+ function addImageMedia(media: PartMedia) {
3113
+ placePickedImage(media);
2864
3114
  setShowImagePicker(false);
2865
3115
  }
2866
- function addVideoFromLibrary(item: LibraryItem) {
2867
- placeMedia({ ref: item.ref, origin: item.origin, label: item.label });
3116
+ function addVideoMedia(media: PartMedia) {
3117
+ placeMedia(media);
2868
3118
  setShowVideoPicker(false);
2869
3119
  }
2870
3120
 
2871
3121
  // Upload a raw video, ingest it into a MediaRef, guard kind==="video", then
2872
3122
  // append it as a video part (and add it to the library so it is reusable).
2873
- async function onPickVideo(file: File) {
3123
+ async function onPickVideo(file: File, place: (media: PartMedia) => void = placeMedia) {
2874
3124
  setVideoError(null);
2875
3125
  setVideoPhase("uploading");
2876
3126
 
@@ -2920,7 +3170,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
2920
3170
  return;
2921
3171
  }
2922
3172
  addToLibrary(media.data, "upload", media.data.mime);
2923
- placeMedia({ ref: media.data, origin: "upload", label: media.data.mime });
3173
+ place({ ref: media.data, origin: "upload", label: media.data.mime });
2924
3174
  setVideoPhase("idle");
2925
3175
  setShowVideoPicker(false);
2926
3176
  }
@@ -2929,7 +3179,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
2929
3179
  // was library-only for images, which forced a detour through another
2930
3180
  // station just to use a file that's sitting on disk). Upload → ingest →
2931
3181
  // guard kind==="image" → attach as the part + into the library for reuse.
2932
- async function onPickImage(file: File) {
3182
+ async function onPickImage(file: File, place: (media: PartMedia) => void = placePickedImage) {
2933
3183
  setImageError(null);
2934
3184
  setImagePhase("uploading");
2935
3185
 
@@ -2979,11 +3229,149 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
2979
3229
  return;
2980
3230
  }
2981
3231
  addToLibrary(media.data, "upload", media.data.mime);
2982
- placePickedImage({ ref: media.data, origin: "upload", label: media.data.mime });
3232
+ place({ ref: media.data, origin: "upload", label: media.data.mime });
2983
3233
  setImagePhase("idle");
2984
3234
  setShowImagePicker(false);
2985
3235
  }
2986
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
+
2987
3375
  // ---- run validation (no silent defaults; every gate is visible) ----
2988
3376
  const widthNum = toNumberOrNull(width);
2989
3377
  const heightNum = toNumberOrNull(height);
@@ -3136,6 +3524,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
3136
3524
  if (!p.media || p.text.trim() !== "") {
3137
3525
  out.push({ kind: "text", text: p.text });
3138
3526
  }
3527
+ if (p.frame) out.push({ kind: "image", media: p.frame.ref });
3139
3528
  if (p.media) {
3140
3529
  out.push(
3141
3530
  p.media.ref.kind === "video"
@@ -3148,11 +3537,12 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
3148
3537
  }
3149
3538
 
3150
3539
  // The composer's MEDIA parts only (images / videos / bound identity views), with
3151
- // 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,
3152
3541
  // so the reference material is identical across a sweep.
3153
3542
  function buildMediaOnlyParts(): GenPromptPart[] {
3154
3543
  const out: GenPromptPart[] = [];
3155
3544
  for (const p of parts) {
3545
+ if (p.frame) out.push({ kind: "image", media: p.frame.ref });
3156
3546
  if (p.media) {
3157
3547
  out.push(
3158
3548
  p.media.ref.kind === "video"
@@ -3175,7 +3565,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
3175
3565
  // setting and has no tick — it always carries, because a row with no prompt is
3176
3566
  // not runnable and would just be a blank card.
3177
3567
  //
3178
- // 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:
3179
3569
  // untick `seed` once and every row spun off the chain keeps re-randomizing.
3180
3570
  function seedTestRow(index: number, from: ImageTestRow | undefined): ImageTestRow {
3181
3571
  const ticks = from ? from.carry : carry;
@@ -3196,13 +3586,13 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
3196
3586
  : guidance
3197
3587
  : DEFAULT_KNOBS.guidance,
3198
3588
  seed: ticks.seed ? (from ? from.seed : seed) : DEFAULT_KNOBS.seed,
3199
- // k89: a row seeded from the BASE composer carries the COMPOSED negative
3200
- // (user text + the standard set when part #1's tick is on) — the same value
3201
- // 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.
3202
3592
  negative: ticks.negative
3203
3593
  ? from
3204
3594
  ? from.negative
3205
- : composeNegative(negative, sceneStdNegative)
3595
+ : sceneGroupNegative()
3206
3596
  : DEFAULT_NEGATIVE,
3207
3597
  carry: { ...ticks },
3208
3598
  };
@@ -3546,10 +3936,10 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
3546
3936
  steps: stepsNum,
3547
3937
  guidance: guidanceNum,
3548
3938
  seed: seed.trim() === "" ? null : seedNum,
3549
- // Same composed value as the scene path — the retained image-job wiring must
3550
- // 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.
3551
3941
  negative: (() => {
3552
- const eff = composeNegative(negative, sceneStdNegative);
3942
+ const eff = sceneGroupNegative();
3553
3943
  return eff === "" ? null : eff;
3554
3944
  })(),
3555
3945
  project: projectName.trim() || undefined,
@@ -3652,9 +4042,10 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
3652
4042
  to frames server-side.
3653
4043
  </p>
3654
4044
  <p>
3655
- Each prompt has its OWN Negative tab (blank = inherit the shared
3656
- negative from a preset or the toolbar); [standard negative] composes
3657
- 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.
3658
4049
  </p>
3659
4050
  <p>
3660
4051
  The Tester at the bottom compares prompts, settings and models side
@@ -4467,53 +4858,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
4467
4858
  timeline.
4468
4859
  </p>
4469
4860
  )}
4470
- {/* Browse-from-disk — attach was library-only for images. */}
4471
- <div className="vi-gen-video-upload">
4472
- <AddFileBar
4473
- accept="image/*"
4474
- onFile={onPickImage}
4475
- busy={imageBusy}
4476
- buttonLabel="Choose image from disk"
4477
- >
4478
- {imageBusy && (
4479
- <span className="vi-crop-status">Uploading…</span>
4480
- )}
4481
- </AddFileBar>
4482
- {imageError && <span className="vi-error">{imageError}</span>}
4483
- </div>
4484
- {libraryImages.length === 0 ? (
4485
- <p className="vi-crop-hint">
4486
- Nothing in the session library yet — browse above, pick
4487
- frames in the Frames station, or generate an image; results
4488
- land here and become pickable.
4489
- </p>
4490
- ) : (
4491
- <LibraryGroups
4492
- seed={mode}
4493
- items={libraryImages}
4494
- defaultOpen={libraryDefaultOpen}
4495
- renderItem={(it) => (
4496
- <button
4497
- type="button"
4498
- key={it.ref.uri}
4499
- className="vi-gen-pick-cell"
4500
- onClick={() => addImage(it)}
4501
- title={it.ref.uri}
4502
- >
4503
- <img
4504
- className="vi-gen-pick-thumb"
4505
- src={mediaBytesUrl(it.ref.uri)}
4506
- alt={it.label ?? "library image"}
4507
- loading="lazy"
4508
- />
4509
- <span className="vi-gen-pick-meta">
4510
- {it.origin}
4511
- {it.label ? ` · ${it.label}` : ""}
4512
- </span>
4513
- </button>
4514
- )}
4515
- />
4516
- )}
4861
+ {renderImagePickerBody(addImageMedia)}
4517
4862
  </div>
4518
4863
  )}
4519
4864
 
@@ -4531,42 +4876,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
4531
4876
  sampling); for precise control, extract + pick frames in the
4532
4877
  Frames station and add them as images.
4533
4878
  </p>
4534
- <div className="vi-gen-video-upload">
4535
- <AddFileBar
4536
- accept="video/*"
4537
- onFile={onPickVideo}
4538
- busy={videoBusy}
4539
- buttonLabel="Choose video from disk"
4540
- >
4541
- {videoBusy && (
4542
- <span className="vi-crop-status">Uploading…</span>
4543
- )}
4544
- </AddFileBar>
4545
- {videoError && <span className="vi-error">{videoError}</span>}
4546
- </div>
4547
- {libraryVideos.length > 0 && (
4548
- <LibraryGroups
4549
- seed={mode}
4550
- items={libraryVideos}
4551
- defaultOpen={libraryDefaultOpen}
4552
- renderItem={(it) => (
4553
- <button
4554
- type="button"
4555
- key={it.ref.uri}
4556
- className="vi-gen-pick-cell"
4557
- onClick={() => addVideoFromLibrary(it)}
4558
- title={it.ref.uri}
4559
- >
4560
- <span className="vi-gen-pick-videoicon" aria-hidden>
4561
- ▶
4562
- </span>
4563
- <span className="vi-gen-pick-meta">
4564
- <code>{it.ref.mime}</code> · {it.origin}
4565
- </span>
4566
- </button>
4567
- )}
4568
- />
4569
- )}
4879
+ {renderVideoPickerBody(addVideoMedia)}
4570
4880
  </div>
4571
4881
  )}
4572
4882
  </>
@@ -4804,9 +5114,9 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
4804
5114
  label: "Goals",
4805
5115
  value: goals.length,
4806
5116
  min: 1,
4807
- max: MAX_MOVIE_GOALS,
4808
5117
  onChange: setGoalCount,
4809
- 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.",
4810
5120
  }}
4811
5121
  noun="goal"
4812
5122
  selectedCount={movieSelectedCount}
@@ -5138,9 +5448,12 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
5138
5448
  {/* The station-wide "whole assembled prompt" Enhance/Generate cluster
5139
5449
  was removed (operator): per-part Enhance/Generate on each card and
5140
5450
  the toolbar's group "Generate prompts (N selected)" below cover it. */}
5141
- {/* k89 — the SHARED prompt-list toolbar (same structure Movie and
5142
- Cinema render): part count, select-all, the one-call spread, the
5143
- 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. */}
5144
5457
  <PromptListToolbar
5145
5458
  count={{
5146
5459
  id: "vi-gen-part-count",
@@ -5156,52 +5469,117 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
5156
5469
  itemCount={sceneTextParts.length}
5157
5470
  allSelected={sceneAllSelected}
5158
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."
5159
5510
  generateLabel={
5160
5511
  assist.assistBusy === "spread"
5161
- ? "✨ Writing the spread…"
5162
- : `✨ Generate prompts (${sceneSelectedCount} selected)`
5512
+ ? "✨ writing the spread…"
5513
+ : `✨ generate (${sceneSelectedCount} selected)`
5163
5514
  }
5164
- generateTitle="ONE call writes every ticked text part against one shared world; unticked parts and media captions ride along as locked context."
5165
- 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)."
5166
5517
  onGenerate={() => void onSceneSpreadSelected()}
5167
- negativesLabel={
5168
- assist.assistBusy === "negative"
5169
- ? "✨ Writing the negative…"
5170
- : `✨ Generate negative (${sceneSelectedCount} selected)`
5518
+ enhanceLabel={
5519
+ assist.assistBusy === "detail"
5520
+ ? "✨ enhancing…"
5521
+ : `✨ enhance (${sceneEnhanceCount} selected)`
5171
5522
  }
5172
- negativesTitle="Write an artifact/quality exclusion list for the scene's ONE negative, from the ticked parts' text."
5173
- onNegatives={() => void onSceneNegativesSelected()}
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)."
5527
+ }
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}
5174
5534
  assistBusy={assist.assistBusy != null}
5175
- onStandardNegatives={onSceneStandardNegatives}
5176
- standardNegativesTitle="Writes a shared negative that every prompt with a blank Negative tab inherits; each prompt can override it on its own Negative tab."
5177
- />
5178
- {/* PROMPT-ASSIST MODEL — ONE shared selector for which text
5179
- generator every prompt's Enhance/Generate uses (the per-card
5180
- buttons all drive this same assist model). Defaults to
5181
- flux-klein-uncensored via the effect above. */}
5182
- {assist.assistModels.length > 0 && (
5183
- <div className="vi-prompt-assist vi-gen-assist-model">
5184
- <span className="vi-prompt-assist-lead" aria-hidden="true">
5185
- prompt assist model
5186
- </span>
5187
- <select
5188
- className="vi-select vi-select-sm"
5189
- value={assist.assistModel ?? ""}
5190
- disabled={assist.assistBusy != null}
5191
- onChange={(e) => assist.setAssistModel(e.target.value || null)}
5192
- aria-label="Text generator used to enhance or generate prompts"
5193
- 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)."
5194
5542
  >
5195
- <option value="">fleet default</option>
5196
- {assist.assistModels.map((m) => (
5197
- <option key={m.model} value={m.model}>
5198
- {m.model}
5199
- {m.serving ? "" : " · cold"}
5200
- </option>
5201
- ))}
5202
- </select>
5203
- </div>
5204
- )}
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
+ />
5205
5583
  {/* GROUP SETTINGS — the settings counterpart to the group prompt
5206
5584
  assist: whichever prompt components are selected get these
5207
5585
  settings (applied as their own per-part overrides). */}
@@ -5319,14 +5697,16 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
5319
5697
  : "Remove"
5320
5698
  }
5321
5699
  >
5322
- {/* [Prompt][Negative] tabs over ONE GoalComposer pane. Per
5323
- the per-part refactor, EVERY part edits its OWN negative
5324
- (p.negative); a blank one inherits the shared station
5325
- negative (preset / toolbar) at submit, and [standard
5326
- negative] composes the standard set into that part per its
5327
- 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. */}
5328
5706
  <PromptFieldTabs
5329
5707
  idPrefix={`vi-gen-part-${p.key}`}
5708
+ hideLabels
5709
+ hideStdTick
5330
5710
  promptLabel={
5331
5711
  p.media
5332
5712
  ? `caption (optional — sent as text just before the ${kind})`
@@ -5340,120 +5720,275 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
5340
5720
  : "Describe what to generate…"
5341
5721
  }
5342
5722
  rows={2}
5723
+ negativeLabel="negative prompt"
5343
5724
  negative={p.negative ?? ""}
5344
5725
  onNegativeChange={(v) => patchPart(p.key, { negative: v })}
5345
- negativePlaceholder="what to avoid — blank = inherit shared"
5346
- negativeHint="Blank = inherit the shared negative (presets / toolbar)."
5347
- stdNegative={p.stdNegative ?? sceneStdNegative}
5348
- 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)}
5349
5734
  negativeDisabledNote={undefined}
5350
5735
  negativeExtras={undefined}
5351
5736
  />
5352
5737
 
5353
- {/* Per-part Enhance/Generate (operator: Scene → per-component,
5354
- reversing the k86/k89 station-wide cluster). Mirrors Movie's
5355
- per-goal wiring: assist targets THIS part's text and writes
5356
- back to it. Text parts only — media parts carry captions and
5357
- ride the group actions as locked context. One in-flight assist
5358
- 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. */}
5359
5744
  {!p.media && (
5360
- <PromptAssistButtons
5361
- busy={assist.assistBusy}
5362
- error={assist.assistError}
5363
- onDismissError={assist.dismissError}
5364
- onEnhance={() => void runPartAssist(p, "detail")}
5365
- onGenerate={() => void runPartAssist(p, "generate")}
5366
- canEnhance={p.text.trim() !== ""}
5367
- models={assist.assistModels}
5368
- model={p.assistModel ?? assist.assistModel}
5369
- onModelChange={(m) =>
5370
- patchPart(p.key, { assistModel: m })
5371
- }
5372
- />
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>
5373
5789
  )}
5374
5790
 
5375
5791
  {p.media ? (
5376
- <div className="vi-gen-part-media">
5377
- {kind === "image" ? (
5378
- <img
5379
- className="vi-gen-part-thumb"
5380
- src={mediaBytesUrl(p.media.ref.uri)}
5381
- alt="Prompt image part"
5382
- loading="lazy"
5383
- />
5384
- ) : (
5385
- <span className="vi-gen-part-videoicon" aria-hidden>
5386
- ▶
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
+ : ""}
5387
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>
5388
5885
  )}
5389
- <span className="vi-gen-part-meta">
5390
- <code>{p.media.ref.mime}</code>
5391
- {p.media.origin ? ` · ${p.media.origin}` : ""}
5392
- {p.media.label ? ` · ${p.media.label}` : ""}
5393
- {kind === "video"
5394
- ? " · resolved to frames server-side"
5395
- : ""}
5396
- </span>
5397
- <button
5398
- type="button"
5399
- className="vi-btn vi-btn-sm vi-btn-ghost"
5400
- onClick={() => detachMedia(p.key)}
5401
- aria-label="Detach media"
5402
- title="Detach media (keeps the text)"
5403
- >
5404
- ✕ media
5405
- </button>
5406
5886
  </div>
5407
5887
  ) : (
5408
- <div className="vi-gen-part-attach">
5409
- <button
5410
- type="button"
5411
- className="vi-btn vi-btn-sm vi-btn-ghost"
5412
- onClick={() => openAttachPicker(p.key, "image")}
5413
- title="Pick an image that pairs with this part's text"
5414
- >
5415
- + attach image
5416
- </button>
5417
- <button
5418
- type="button"
5419
- className="vi-btn vi-btn-sm vi-btn-ghost"
5420
- onClick={() => openAttachPicker(p.key, "video")}
5421
- title="Pick a video that pairs with this part's text"
5422
- >
5423
- + attach video
5424
- </button>
5425
- {/* k89 — the add bar's library-APPEND path, reachable from
5426
- the card (adds a NEW image part rather than pairing). */}
5427
- <button
5428
- type="button"
5429
- className="vi-btn vi-btn-sm vi-btn-ghost"
5430
- onClick={() => {
5431
- setAttachTarget(null); // append mode, not attach
5432
- setShowImagePicker((v) => !v);
5433
- setShowVideoPicker(false);
5434
- }}
5435
- title="Append an image part from the library (a new part, not paired with this one)"
5436
- >
5437
- + from library
5438
- </button>
5439
- </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
+ </>
5440
5954
  )}
5441
5955
 
5442
5956
  {/* IDENTITY — the generation-wide character, selectable from
5443
- ANY part (alongside that part's "from library"/attach
5444
- controls) because ONE identity is shared by the whole
5445
- generation. Rendered on every part and driven by the SAME
5446
- bound state, so all parts always agree; the profile list
5447
- comes from the station's single useIdentityProfiles(). */}
5448
- <GenIdentityBar
5449
- profiles={identityProfiles}
5450
- loading={identityLoading}
5451
- error={identityError}
5452
- bound={boundIdentity}
5453
- onBind={bindIdentity}
5454
- onClear={clearIdentity}
5455
- disabled={running}
5456
- />
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
+ )}
5457
5992
 
5458
5993
  {renderPartSettings(p)}
5459
5994
  {renderPartResult(p)}
@@ -5476,7 +6011,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
5476
6011
  contract, and scene's own knobs (frames/motion/fps/assemble/strength/
5477
6012
  chain) stay in the rail because they describe the SCENE, not the frame.
5478
6013
 
5479
- 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
5480
6015
  scene mode generation itself just reads the values as it always has.
5481
6016
 
5482
6017
  k89: the strip stays STATION-WIDE (it configures the single