@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.
- package/package.json +1 -3
- package/src/config.ts +36 -0
- package/src/demo/assets/generate/cinema-cavern/movie.mp4 +0 -0
- package/src/demo/assets/generate/cinema-cavern/seg_00.mp4 +0 -0
- package/src/demo/assets/generate/clips/cat-lounge.mp4 +0 -0
- package/src/demo/assets/generate/clips/drone-city.mp4 +0 -0
- package/src/demo/assets/generate/clips/paper-sails.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/movie.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/seg_00.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/seg_01.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/seg_02.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/seg_03.mp4 +0 -0
- package/src/demo/assets/generate/movie-medow/movie.mp4 +0 -0
- package/src/demo/assets/generate/movie-medow/seg_00.mp4 +0 -0
- package/src/demo/assets/generate/movie-medow/seg_01.mp4 +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00000.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00001.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00002.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00003.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00004.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00005.png +0 -0
- package/src/demo/assets/generate/scene-desert/scene.mp4 +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00000.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00001.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00002.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00003.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00004.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00005.png +0 -0
- package/src/demo/assets/generate/scene-vintage/scene.mp4 +0 -0
- package/src/demo/assets/generate2/s0.png +0 -0
- package/src/demo/assets/generate2/s1.png +0 -0
- package/src/demo/assets/generate2/s10.png +0 -0
- package/src/demo/assets/generate2/s11.png +0 -0
- package/src/demo/assets/generate2/s12.png +0 -0
- package/src/demo/assets/generate2/s13.png +0 -0
- package/src/demo/assets/generate2/s14.png +0 -0
- package/src/demo/assets/generate2/s15.png +0 -0
- package/src/demo/assets/generate2/s16.png +0 -0
- package/src/demo/assets/generate2/s17.png +0 -0
- package/src/demo/assets/generate2/s18.png +0 -0
- package/src/demo/assets/generate2/s19.png +0 -0
- package/src/demo/assets/generate2/s2.png +0 -0
- package/src/demo/assets/generate2/s20.png +0 -0
- package/src/demo/assets/generate2/s21.mp4 +0 -0
- package/src/demo/assets/generate2/s22.mp4 +0 -0
- package/src/demo/assets/generate2/s23.mp4 +0 -0
- package/src/demo/assets/generate2/s24.mp4 +0 -0
- package/src/demo/assets/generate2/s25.mp4 +0 -0
- package/src/demo/assets/generate2/s26.mp4 +0 -0
- package/src/demo/assets/generate2/s27.mp4 +0 -0
- package/src/demo/assets/generate2/s28.mp4 +0 -0
- package/src/demo/assets/generate2/s29.mp4 +0 -0
- package/src/demo/assets/generate2/s3.png +0 -0
- package/src/demo/assets/generate2/s30.mp4 +0 -0
- package/src/demo/assets/generate2/s4.png +0 -0
- package/src/demo/assets/generate2/s5.png +0 -0
- package/src/demo/assets/generate2/s6.png +0 -0
- package/src/demo/assets/generate2/s7.png +0 -0
- package/src/demo/assets/generate2/s8.png +0 -0
- package/src/demo/assets/generate2/s9.png +0 -0
- package/src/demo/demoFetch.ts +126 -3
- package/src/demo/fixtures.ts +72 -4
- package/src/demo/promptAssist.ts +25 -0
- package/src/demo/sampleData.ts +960 -0
- package/src/demo/sampleSets.ts +172 -0
- package/src/stations/ActiveProcessesStation.tsx +18 -3
- package/src/stations/GenerateStation.tsx +1031 -364
- package/src/stations/GoalComposer.tsx +9 -1
- package/src/stations/ScriptFirstStation.tsx +1081 -0
- package/src/stations/ToksPanel.tsx +188 -0
- package/src/stations/WorkbenchDrawer.tsx +4 -3
- package/src/stations/WorkbenchSidebar.tsx +60 -12
- package/src/stations/oracle/DagPanel.tsx +391 -0
- package/src/stations/oracle/OracleStation.tsx +304 -0
- package/src/stations/oracle/SpatialTab.tsx +185 -0
- package/src/stations/oracle/StewardPanel.tsx +165 -0
- package/src/stations/oracle/oracle.css +467 -0
- package/src/stations/oracle/oracleShared.tsx +160 -0
- package/src/stations/oracle/oracleTypes.ts +304 -0
- package/src/stations/oracle/useOracle.ts +560 -0
- package/src/stations/registry.ts +37 -0
- package/src/stations/scriptFirst.css +350 -0
- package/src/stations/studio/IdentitiesStation.tsx +219 -292
- package/src/stations/studio/IdentityCreatePanel.tsx +390 -0
- package/src/stations/studio/StudioGenerateTab.tsx +162 -6
- package/src/stations/studio/StudioMovieComposer.tsx +763 -318
- package/src/stations/studio/{StudioMovieComposer.tsx.bak-sessions-20260812 → StudioMovieComposer.tsx.bak-declutter-20260813} +350 -48
- package/src/stations/studio/StudioPlane.tsx +35 -3
- package/src/stations/studio/StudioViewer.tsx +116 -14
- package/src/stations/studio/coordinationBadge.tsx +206 -0
- package/src/stations/studio/identityUpload.ts +66 -0
- package/src/stations/studio/promptCard.tsx +362 -59
- package/src/stations/studio/useIdentityCreateJob.ts +184 -0
- package/src/stations/studio/useIdentityProfiles.ts +66 -0
- package/src/stations/useScriptFirstRun.ts +596 -0
- package/src/style/app.css +176 -0
- package/src/video/LengthRow.tsx +189 -0
- package/src/video/LibraryGroups.tsx +81 -0
- package/src/video/UnnamedWarn.tsx +146 -0
- package/src/video/coordinationReport.ts +266 -0
- package/src/video/jobClock.ts +104 -0
- package/src/video/spreadAssist.ts +26 -0
- package/src/video/useMediaJobs.ts +26 -0
- package/src/video/useOperatorHistory.ts +105 -0
- package/src/video/useToks.ts +193 -0
- package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +0 -228
- package/src/stations/GenerateStation.tsx.bak-tester-removal +0 -5170
- 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 =
|
|
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
|
-
{
|
|
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
|
|
564
|
-
// rides
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
1325
|
-
//
|
|
1326
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
1350
|
-
//
|
|
1351
|
-
//
|
|
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.
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
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
|
|
1420
|
-
//
|
|
1421
|
-
|
|
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:
|
|
1954
|
-
//
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
2043
|
-
function
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
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
|
-
/**
|
|
2551
|
-
|
|
2552
|
-
|
|
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
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
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)
|
|
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
|
-
|
|
2706
|
-
|
|
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
|
|
2711
|
-
// one-call rule Cinema's onSpreadSelected / Movie's onMovieSpreadSelected
|
|
2712
|
-
// selected parts are targets, everything else (unticked text, media
|
|
2713
|
-
// rides as LOCKED context, and the reply may only land on parts whose
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
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 (
|
|
2756
|
-
// text
|
|
2757
|
-
//
|
|
2758
|
-
|
|
2759
|
-
|
|
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
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
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 [
|
|
2770
|
-
//
|
|
2771
|
-
|
|
2772
|
-
|
|
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
|
|
2788
|
-
placePickedImage(
|
|
3112
|
+
function addImageMedia(media: PartMedia) {
|
|
3113
|
+
placePickedImage(media);
|
|
2789
3114
|
setShowImagePicker(false);
|
|
2790
3115
|
}
|
|
2791
|
-
function
|
|
2792
|
-
placeMedia(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
3125
|
-
//
|
|
3126
|
-
//
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
3565
|
-
|
|
3566
|
-
the standard exclusion set
|
|
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's
|
|
4048
|
+
Negative is also the group assist'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's frames +
|
|
3614
4096
|
clip to assets/<project>. 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
|
-
{
|
|
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
|
-
|
|
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:
|
|
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
|
|
4864
|
-
|
|
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
|
|
5021
|
-
|
|
5022
|
-
|
|
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
|
-
? "✨
|
|
5041
|
-
: `✨
|
|
5512
|
+
? "✨ writing the spread…"
|
|
5513
|
+
: `✨ generate (${sceneSelectedCount} selected)`
|
|
5042
5514
|
}
|
|
5043
|
-
generateTitle="ONE call writes every
|
|
5044
|
-
generateDisabledTitle="
|
|
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
|
-
|
|
5047
|
-
assist.assistBusy === "
|
|
5048
|
-
? "✨
|
|
5049
|
-
: `✨
|
|
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
|
-
|
|
5052
|
-
|
|
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
|
-
|
|
5055
|
-
|
|
5056
|
-
|
|
5057
|
-
|
|
5058
|
-
|
|
5059
|
-
|
|
5060
|
-
|
|
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
|
-
|
|
5075
|
-
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
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
|
|
5202
|
-
the
|
|
5203
|
-
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
|
|
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
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
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
|
-
{/*
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
|
|
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
|
-
<
|
|
5240
|
-
|
|
5241
|
-
|
|
5242
|
-
|
|
5243
|
-
|
|
5244
|
-
|
|
5245
|
-
|
|
5246
|
-
|
|
5247
|
-
|
|
5248
|
-
|
|
5249
|
-
|
|
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
|
-
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
|
|
5263
|
-
|
|
5264
|
-
|
|
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
|
-
|
|
5288
|
-
|
|
5289
|
-
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
|
|
5304
|
-
|
|
5305
|
-
|
|
5306
|
-
|
|
5307
|
-
|
|
5308
|
-
|
|
5309
|
-
|
|
5310
|
-
|
|
5311
|
-
|
|
5312
|
-
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
|
|
5318
|
-
|
|
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'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'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
|
|
5323
|
-
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
|
|
5329
|
-
|
|
5330
|
-
|
|
5331
|
-
|
|
5332
|
-
|
|
5333
|
-
|
|
5334
|
-
|
|
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
|
|
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"
|