@hugpy/video-intelligence-ui 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +901 -366
- 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 +6 -3
- 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 +10 -17
- package/src/stations/studio/StudioMovieComposer.tsx +296 -59
- 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 +157 -0
- package/src/video/LengthRow.tsx +189 -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 +5 -0
- package/src/video/useToks.ts +193 -0
|
@@ -37,13 +37,16 @@ import {
|
|
|
37
37
|
PromptFieldTabs,
|
|
38
38
|
PromptListToolbar,
|
|
39
39
|
STANDARD_NEGATIVE,
|
|
40
|
+
applyStandardNegative,
|
|
40
41
|
composeNegative,
|
|
42
|
+
hasStandardNegative,
|
|
41
43
|
} from "./studio/promptCard";
|
|
42
44
|
// Movie's group "Generate prompts" rides the SAME one-call spread the Cinema
|
|
43
45
|
// composer uses (usePromptAssist.runSpread + the pure builders/readers).
|
|
44
46
|
import {
|
|
45
47
|
buildSpreadBody,
|
|
46
48
|
spreadNoticeLines,
|
|
49
|
+
type AssistMediaWire,
|
|
47
50
|
type SpreadGoalInput,
|
|
48
51
|
} from "../video/spreadAssist";
|
|
49
52
|
import { StudioGenerateMode } from "./studio/StudioGenerateMode";
|
|
@@ -51,7 +54,7 @@ import { useIdentityProfiles, type IdentityProfile } from "./studio/useIdentityP
|
|
|
51
54
|
import { GenIdentityBar, type BoundIdentity } from "./GenIdentityBar";
|
|
52
55
|
import { useSidebarRegistry } from "./SidebarRegistry";
|
|
53
56
|
import { useProjects } from "../video/useProjects";
|
|
54
|
-
import { cancelJob } from "../video/jobTracker";
|
|
57
|
+
import { cancelJob, trackJob, useJobTracker } from "../video/jobTracker";
|
|
55
58
|
import { STILL_RUNNING_MESSAGE } from "./pollCaps";
|
|
56
59
|
import { AddFileBar } from "./AddFileBar";
|
|
57
60
|
import { useImageModels, useModelsByTask, requiredInputsFor } from "../video/useModels";
|
|
@@ -65,7 +68,6 @@ import { useMoviePresets } from "../video/useMoviePresets";
|
|
|
65
68
|
import {
|
|
66
69
|
addToLibrary,
|
|
67
70
|
useMediaLibrary,
|
|
68
|
-
type LibraryItem,
|
|
69
71
|
} from "../video/mediaLibrary";
|
|
70
72
|
import {
|
|
71
73
|
request,
|
|
@@ -87,7 +89,9 @@ import { registerComposer, type StageRequest } from "../video/composerBridge";
|
|
|
87
89
|
import {
|
|
88
90
|
mediaRefSchema,
|
|
89
91
|
uploadResultSchema,
|
|
92
|
+
enqueueResultSchema,
|
|
90
93
|
isMovieProgress,
|
|
94
|
+
type FrameExtractRequest,
|
|
91
95
|
type GenPromptPart,
|
|
92
96
|
type GenerateImageRequest,
|
|
93
97
|
type GenerateSceneRequest,
|
|
@@ -233,7 +237,7 @@ type GoalRow = {
|
|
|
233
237
|
// k92 — a movie goal IS a prompt component (same architecture as a Scene part):
|
|
234
238
|
// it may carry its OWN generation knobs. Held as the same live strings the station
|
|
235
239
|
// setters use so a goal's override parses/validates exactly like the shared value
|
|
236
|
-
// it defaults from. Empty =
|
|
240
|
+
// it defaults from. Empty = falls back to the movie-level value (see goalKnobValues /
|
|
237
241
|
// goalExtra). `knobs` = the 4 CondensedKnobStrip knobs (size/steps/guidance/seed);
|
|
238
242
|
// `extra` = model + the goal-appropriate scene knobs (strength/chain/motion).
|
|
239
243
|
knobs?: Partial<KnobValues>;
|
|
@@ -337,6 +341,14 @@ type PartEntry = {
|
|
|
337
341
|
// Per-part PROMPT-ASSIST model (which text generator writes THIS prompt's
|
|
338
342
|
// Enhance/Generate). Undefined = use the shared assist model / fleet default.
|
|
339
343
|
assistModel?: string | null;
|
|
344
|
+
// k93 — per-card `negatives: [on/off]`: whether THIS card's Enhance/Generate
|
|
345
|
+
// also writes its Negative field. Undefined = on (today's behavior).
|
|
346
|
+
assistNegatives?: boolean;
|
|
347
|
+
// k93 — a frame PULLED from this part's attached video (the frame-extract
|
|
348
|
+
// job's output). The part keeps its video AND the frame; buildPartPromptParts
|
|
349
|
+
// emits the frame image BEFORE the video so the runner's image_paths[0] (the
|
|
350
|
+
// start frame) is the pulled still.
|
|
351
|
+
frame?: PartMedia;
|
|
340
352
|
};
|
|
341
353
|
// The share-tick keys for EVERY per-part setting. "share" = one value across all
|
|
342
354
|
// prompts; unticked = each prompt keeps its own. (Scene owns its own share state
|
|
@@ -491,8 +503,15 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
491
503
|
// example prompt (matches the sample gens) so the brochure opens with a coherent,
|
|
492
504
|
// runnable story instead of a blank composer. The seed string lives in src/demo/
|
|
493
505
|
// and is injected ONLY behind isCanned() — the live path stays an empty prompt.
|
|
506
|
+
// k93: a new TEXT part opens with the standard negative VISIBLE in its Negative
|
|
507
|
+
// field (operator: standard negatives are the default) — explicit text, sent
|
|
508
|
+
// as-is; the std. tick reads its state from the field and can remove it.
|
|
494
509
|
const [parts, setParts] = useSessionState<PartEntry[]>("gen.parts", () => [
|
|
495
|
-
{
|
|
510
|
+
{
|
|
511
|
+
key: partKey(),
|
|
512
|
+
text: isCanned() ? DEMO_GENERATE_PROMPT : "",
|
|
513
|
+
negative: STANDARD_NEGATIVE,
|
|
514
|
+
},
|
|
496
515
|
]);
|
|
497
516
|
const [showImagePicker, setShowImagePicker] = useState<boolean>(false);
|
|
498
517
|
const [showVideoPicker, setShowVideoPicker] = useState<boolean>(false);
|
|
@@ -563,25 +582,18 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
563
582
|
const [guidance, setGuidance] = useSessionState<string>("gen.guidance", DEFAULT_KNOBS.guidance);
|
|
564
583
|
// blank = null (random)
|
|
565
584
|
const [seed, setSeed] = useSessionState<string>("gen.seed", DEFAULT_KNOBS.seed);
|
|
566
|
-
// k89: no longer SEEDED with the standard-negative TEXT — the standard
|
|
567
|
-
// rides
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
//
|
|
585
|
+
// k89: no longer SEEDED with the standard-negative TEXT — for MOVIE the standard
|
|
586
|
+
// set rides goal #1's stdNegative tick (seeded ON below) and is COMPOSED in at
|
|
587
|
+
// submit via composeNegative. For SCENE (k93) this shared value is NOT read at
|
|
588
|
+
// all any more: each part sends exactly what its own visible Negative field
|
|
589
|
+
// holds (blank = blank, nothing implied); presets WRITE into part #1's field.
|
|
571
590
|
const [negative, setNegative] = useSessionState<string>("gen.negative", "");
|
|
572
|
-
// Scene's [standard negative] tick — station-wide like `negative` itself (ONE
|
|
573
|
-
// negative per scene), rendered on part #1's tabbed Negative pane. Defaults ON to
|
|
574
|
-
// preserve the pre-k89 prefill behavior.
|
|
575
|
-
const [sceneStdNegative, setSceneStdNegative] = useSessionState<boolean>(
|
|
576
|
-
"gen.sceneStdNegative",
|
|
577
|
-
true,
|
|
578
|
-
);
|
|
579
591
|
|
|
580
592
|
// ---- per-setting CARRY ticks on the base composer strip (operator ask
|
|
581
593
|
// 2026-08-04, k63) ----
|
|
582
594
|
// "Bring this setting into the post created component": every setting in the
|
|
583
595
|
// condensed strip carries a tick, and the tick decides what a component SEEDED
|
|
584
|
-
// from this one
|
|
596
|
+
// from this one receives (today: a tester row via "+ Add row" / the per-model /
|
|
585
597
|
// per-preset expanders; the same rule is what any future scene-carry flow reads).
|
|
586
598
|
// Untick a setting and the new component gets the mode default instead — that is
|
|
587
599
|
// how you build a sweep that varies exactly one thing. Purely client-side: no
|
|
@@ -672,7 +684,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
672
684
|
// and produces its OWN job (see useGenerateRowJobs); "Run all rows" fires them
|
|
673
685
|
// together so the results land beside each other.
|
|
674
686
|
//
|
|
675
|
-
// Rows deliberately do NOT get their own media. They
|
|
687
|
+
// Rows deliberately do NOT get their own media. They reuse the base composer's
|
|
676
688
|
// image/video/identity parts, so a sweep holds the reference material fixed and
|
|
677
689
|
// varies only the thing under test — otherwise the comparison proves nothing.
|
|
678
690
|
const [testerOpen, setTesterOpen] = useState<boolean>(false);
|
|
@@ -693,6 +705,8 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
693
705
|
const [goals, setGoals] = useSessionState<GoalRow[]>("gen.movie.goals", () => [
|
|
694
706
|
{ id: goalKey(), start: "0", end: String(DEFAULT_GOAL_LEN), prompt: "", stdNegative: true },
|
|
695
707
|
]);
|
|
708
|
+
// Rows parked by lowering the goal count — revived (keys intact) when it grows.
|
|
709
|
+
const [goalStash, setGoalStash] = useSessionState<GoalRow[]>("gen.movie.goalStash", () => []);
|
|
696
710
|
// When set, the image picker attaches its pick as THIS goal's reference (vs a part).
|
|
697
711
|
const [goalAttachTarget, setGoalAttachTarget] = useState<string | null>(null);
|
|
698
712
|
// ---- Movie prompt-list toolbar state (k88 — mirrors Cinema's group assist) ----
|
|
@@ -710,6 +724,64 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
710
724
|
const [sceneSelectedIds, setSceneSelectedIds] = useState<ReadonlySet<string>>(() => new Set());
|
|
711
725
|
const [sceneSpreadNotice, setSceneSpreadNotice] = useState<string[] | null>(null);
|
|
712
726
|
const [sceneSteeringSeed, setSceneSteeringSeed] = useState<number | null>(null);
|
|
727
|
+
// ---- k93 Scene group toolbar state ----
|
|
728
|
+
// `negatives: [on/off]` — when ON the group generate/enhance also write each
|
|
729
|
+
// selected part's Negative field (the old separate "Generate negative" button
|
|
730
|
+
// folded in here). Session-remembered like the other toolbar prefs.
|
|
731
|
+
const [sceneGroupNegatives, setSceneGroupNegatives] = useSessionState<boolean>(
|
|
732
|
+
"gen.sceneGroupNegatives",
|
|
733
|
+
true,
|
|
734
|
+
);
|
|
735
|
+
// The last EXPLICIT range (1-based, over text parts) — what "select all" goes
|
|
736
|
+
// back to when unticked. Null until the user has touched the range inputs /
|
|
737
|
+
// per-card ticks.
|
|
738
|
+
const sceneRangeMemo = useRef<{ start: number; end: number } | null>(null);
|
|
739
|
+
// The toolbar-level `[+ video]` PRETEXT: ONE video (library pick or upload)
|
|
740
|
+
// sent as context.media on the group generate/enhance so the backend describes
|
|
741
|
+
// it and feeds that description to the generator. Not a prompt part.
|
|
742
|
+
const [scenePretext, setScenePretext] = useState<PartMedia | null>(null);
|
|
743
|
+
const [scenePretextOpen, setScenePretextOpen] = useState<boolean>(false);
|
|
744
|
+
// Which card has its inline attach panel expanded (one at a time), and what.
|
|
745
|
+
const [partAttachOpen, setPartAttachOpen] = useState<
|
|
746
|
+
{ key: string; kind: "image" | "video" | "library" } | null
|
|
747
|
+
>(null);
|
|
748
|
+
// Whether the inline `+ identity` expander is open per card key.
|
|
749
|
+
const [identityOpen, setIdentityOpen] = useState<ReadonlySet<string>>(() => new Set());
|
|
750
|
+
// Pull-frame: per part, the time (seconds, as typed) + the in-flight job id.
|
|
751
|
+
const [pullFrameAt, setPullFrameAt] = useState<Record<string, string>>({});
|
|
752
|
+
const [pullFrameJobs, setPullFrameJobs] = useState<Record<string, string>>({});
|
|
753
|
+
const [pullFrameError, setPullFrameError] = useState<Record<string, string>>({});
|
|
754
|
+
// Watch the tracker for the pull-frame jobs: on `done` the first output becomes
|
|
755
|
+
// this part's `frame` (the tracker has ALREADY pushed it into the library as
|
|
756
|
+
// "frames / frame 1"); on failure the message surfaces on the card.
|
|
757
|
+
const trackedJobs = useJobTracker();
|
|
758
|
+
useEffect(() => {
|
|
759
|
+
const entries = Object.entries(pullFrameJobs);
|
|
760
|
+
if (entries.length === 0) return;
|
|
761
|
+
for (const [key, jobId] of entries) {
|
|
762
|
+
const rec = trackedJobs.find((r) => r.jobId === jobId);
|
|
763
|
+
if (!rec) continue;
|
|
764
|
+
if (rec.status === "done") {
|
|
765
|
+
const out = rec.outputs[0];
|
|
766
|
+
setParts((prev) =>
|
|
767
|
+
prev.map((p) =>
|
|
768
|
+
p.key === key && out
|
|
769
|
+
? { ...p, frame: { ref: out, origin: "frames", label: rec.label.replace(/^pull /, "") } }
|
|
770
|
+
: p,
|
|
771
|
+
),
|
|
772
|
+
);
|
|
773
|
+
if (!out) setPullFrameError((prev) => ({ ...prev, [key]: "The job produced no frame." }));
|
|
774
|
+
} else if (rec.status === "failed" || rec.status === "cancelled" || rec.expired) {
|
|
775
|
+
setPullFrameError((prev) => ({ ...prev, [key]: rec.error ?? "Frame pull failed." }));
|
|
776
|
+
} else {
|
|
777
|
+
continue; // still running
|
|
778
|
+
}
|
|
779
|
+
setPullFrameJobs((prev) => {
|
|
780
|
+
const { [key]: _drop, ...rest } = prev;
|
|
781
|
+
return rest;
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
}, [trackedJobs, pullFrameJobs, setParts]);
|
|
713
785
|
// ---- Director knobs (opt-in vision scoring / re-roll loop) ----
|
|
714
786
|
const [visionEnabled, setVisionEnabled] = useSessionState<boolean>(
|
|
715
787
|
"gen.visionEnabled",
|
|
@@ -823,7 +895,13 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
823
895
|
// the prefilled text) — so the standard-set tick clears too, keeping the
|
|
824
896
|
// effective payload identical to the old override semantics.
|
|
825
897
|
setNegative(d.negative);
|
|
826
|
-
|
|
898
|
+
// k93 (Scene): presets WRITE the negative into part #1's visible field —
|
|
899
|
+
// never a silent merge at submit. Replaces the whole field (override).
|
|
900
|
+
const presetNeg = d.negative;
|
|
901
|
+
setParts((prev) => {
|
|
902
|
+
const i = prev.findIndex((p) => !p.media);
|
|
903
|
+
return i < 0 ? prev : prev.map((p, j) => (j === i ? { ...p, negative: presetNeg } : p));
|
|
904
|
+
});
|
|
827
905
|
setGoals((prev) =>
|
|
828
906
|
prev.map((g, i) => (i === 0 ? { ...g, stdNegative: false } : g)),
|
|
829
907
|
);
|
|
@@ -1201,7 +1279,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
1201
1279
|
|
|
1202
1280
|
// ---- part mutations ----
|
|
1203
1281
|
function addText() {
|
|
1204
|
-
setParts((prev) => [...prev, { key: partKey(), text: "" }]);
|
|
1282
|
+
setParts((prev) => [...prev, { key: partKey(), text: "", negative: STANDARD_NEGATIVE }]);
|
|
1205
1283
|
}
|
|
1206
1284
|
function editText(key: string, text: string) {
|
|
1207
1285
|
setParts((prev) => prev.map((p) => (p.key === key ? { ...p, text } : p)));
|
|
@@ -1350,14 +1428,34 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
1350
1428
|
);
|
|
1351
1429
|
}
|
|
1352
1430
|
|
|
1353
|
-
//
|
|
1354
|
-
//
|
|
1355
|
-
//
|
|
1431
|
+
// k93 §C (client half): the toolbar's `[+ video]` pretext as the typed
|
|
1432
|
+
// `context.media` the assist routes accept — {uri, mime, label}. Undefined
|
|
1433
|
+
// when nothing is attached so the body stays byte-identical to today.
|
|
1434
|
+
function scenePretextWire(): AssistMediaWire | undefined {
|
|
1435
|
+
if (!scenePretext) return undefined;
|
|
1436
|
+
const media: AssistMediaWire = {
|
|
1437
|
+
uri: scenePretext.ref.uri,
|
|
1438
|
+
mime: scenePretext.ref.mime,
|
|
1439
|
+
};
|
|
1440
|
+
if (scenePretext.label) media.label = scenePretext.label;
|
|
1441
|
+
return media;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// Per-part Enhance/Generate — writes the prompt AND (when this card's
|
|
1445
|
+
// `negatives` toggle is on — default) its negative. The negative is derived from
|
|
1446
|
+
// the NEW prompt (captured from runAssist's apply); Enhance refines the existing
|
|
1356
1447
|
// negative, Generate writes a fresh one. Sequential because the assist hook
|
|
1357
|
-
// allows one in-flight call at a time.
|
|
1358
|
-
|
|
1448
|
+
// allows one in-flight call at a time. `opts.negatives` lets the GROUP enhance
|
|
1449
|
+
// override the per-card toggle with the toolbar's; `opts.media` is the group
|
|
1450
|
+
// pretext (k93 §C) — the per-card buttons pass none.
|
|
1451
|
+
async function runPartAssist(
|
|
1452
|
+
p: PartEntry,
|
|
1453
|
+
mode: "detail" | "generate",
|
|
1454
|
+
opts: { negatives?: boolean; media?: AssistMediaWire } = {},
|
|
1455
|
+
) {
|
|
1359
1456
|
// This part's OWN assist model (falls back to the shared pick / fleet default).
|
|
1360
1457
|
const model = p.assistModel ?? assist.assistModel ?? null;
|
|
1458
|
+
const context = opts.media ? { media: opts.media } : undefined;
|
|
1361
1459
|
let newText = p.text;
|
|
1362
1460
|
await assist.runAssist(
|
|
1363
1461
|
mode,
|
|
@@ -1366,32 +1464,28 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
1366
1464
|
newText = t;
|
|
1367
1465
|
editText(p.key, t);
|
|
1368
1466
|
},
|
|
1369
|
-
{ model },
|
|
1467
|
+
{ model, ...(context ? { context } : {}) },
|
|
1370
1468
|
);
|
|
1371
1469
|
if (newText.trim() === "") return;
|
|
1470
|
+
const writeNegative = opts.negatives ?? p.assistNegatives ?? true;
|
|
1471
|
+
if (!writeNegative) return;
|
|
1372
1472
|
await assist.runNegative(
|
|
1373
1473
|
{
|
|
1374
1474
|
subject: newText,
|
|
1375
1475
|
draft: mode === "detail" ? p.negative ?? "" : "",
|
|
1376
|
-
extra: { model },
|
|
1476
|
+
extra: { model, ...(context ? { context } : {}) },
|
|
1377
1477
|
},
|
|
1378
|
-
//
|
|
1379
|
-
//
|
|
1380
|
-
//
|
|
1381
|
-
// tick ON so it stays applied. composeNegative dedupes, so submit-time
|
|
1382
|
-
// composition never doubles a term.
|
|
1478
|
+
// k93: everything the wire will carry is IN the field. If the field held
|
|
1479
|
+
// the standard set before this call, re-insert it (the std. tick stays
|
|
1480
|
+
// honest — it reads the field); nothing is composed at submit.
|
|
1383
1481
|
(neg) => {
|
|
1384
|
-
const std = p.
|
|
1482
|
+
const std = hasStandardNegative(p.negative ?? "");
|
|
1385
1483
|
// APPEND, never clobber (operator 2026-08-13): a GENERATED negative adds
|
|
1386
1484
|
// to whatever the operator already typed (Enhance still refines-in-place —
|
|
1387
1485
|
// its draft was the existing text, so appending would double it).
|
|
1388
|
-
// composeNegative dedupes, so submit-time composition never doubles a term.
|
|
1389
1486
|
const cur = (p.negative ?? "").trim().replace(/,\s*$/, "");
|
|
1390
1487
|
const mergedRaw = mode === "generate" && cur ? `${cur}, ${neg}` : neg;
|
|
1391
|
-
patchPart(p.key, {
|
|
1392
|
-
negative: composeNegative(mergedRaw, std),
|
|
1393
|
-
stdNegative: std,
|
|
1394
|
-
});
|
|
1488
|
+
patchPart(p.key, { negative: composeNegative(mergedRaw, std) });
|
|
1395
1489
|
},
|
|
1396
1490
|
);
|
|
1397
1491
|
// NAME RIDES ALONG (operator 2026-08-13): derive the project name from the
|
|
@@ -1416,6 +1510,11 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
1416
1510
|
const p = parts.find((x) => !x.media && x.text.trim() !== "");
|
|
1417
1511
|
return p ? p.text : assembledPrompt;
|
|
1418
1512
|
}
|
|
1513
|
+
/** The base part's VISIBLE negative — a clone sends what its source shows (k93). */
|
|
1514
|
+
function basePartNegative(): string {
|
|
1515
|
+
const p = parts.find((x) => !x.media && x.text.trim() !== "") ?? parts.find((x) => !x.media);
|
|
1516
|
+
return p?.negative ?? "";
|
|
1517
|
+
}
|
|
1419
1518
|
function addPartPerModel(): void {
|
|
1420
1519
|
const text = basePromptText();
|
|
1421
1520
|
if (text.trim() === "") return;
|
|
@@ -1434,6 +1533,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
1434
1533
|
...targets.map((m) => ({
|
|
1435
1534
|
key: partKey(),
|
|
1436
1535
|
text,
|
|
1536
|
+
negative: basePartNegative(),
|
|
1437
1537
|
extra: { model: m.id } as Partial<PartExtra>,
|
|
1438
1538
|
})),
|
|
1439
1539
|
]);
|
|
@@ -1460,16 +1560,13 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
1460
1560
|
const part: PartEntry = {
|
|
1461
1561
|
key: partKey(),
|
|
1462
1562
|
text,
|
|
1563
|
+
negative: basePartNegative(),
|
|
1463
1564
|
extra: { model: pre.model_key || modelId },
|
|
1464
1565
|
knobs,
|
|
1465
1566
|
};
|
|
1466
|
-
// A preset's negative REPLACES the
|
|
1467
|
-
//
|
|
1468
|
-
|
|
1469
|
-
if (d.negative != null) {
|
|
1470
|
-
part.negative = d.negative;
|
|
1471
|
-
part.stdNegative = false;
|
|
1472
|
-
}
|
|
1567
|
+
// A preset's negative REPLACES the field outright (visible, sent as-is —
|
|
1568
|
+
// k93: nothing is composed at submit).
|
|
1569
|
+
if (d.negative != null) part.negative = d.negative;
|
|
1473
1570
|
return part;
|
|
1474
1571
|
}),
|
|
1475
1572
|
]);
|
|
@@ -1956,6 +2053,9 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
1956
2053
|
function buildPartPromptParts(p: PartEntry): GenPromptPart[] {
|
|
1957
2054
|
const out: GenPromptPart[] = [];
|
|
1958
2055
|
if (!p.media || p.text.trim() !== "") out.push({ kind: "text", text: p.text });
|
|
2056
|
+
// k93: a frame pulled from the attached video rides as an IMAGE part placed
|
|
2057
|
+
// before the video, so it is the start frame (image_paths[0]).
|
|
2058
|
+
if (p.frame) out.push({ kind: "image", media: p.frame.ref });
|
|
1959
2059
|
if (p.media)
|
|
1960
2060
|
out.push(
|
|
1961
2061
|
p.media.ref.kind === "video"
|
|
@@ -1997,10 +2097,8 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
1997
2097
|
|
|
1998
2098
|
// Build ONE scene request from a component, or null when any resolved knob is
|
|
1999
2099
|
// invalid (skip, never submit coerced garbage). `lead` = the shared media that
|
|
2000
|
-
// conditions every prompt. Negative:
|
|
2001
|
-
//
|
|
2002
|
-
// still apply); the standard set composes in per the part's tick (default = the
|
|
2003
|
-
// station's sceneStdNegative, i.e. ON).
|
|
2100
|
+
// conditions every prompt. Negative (k93): EXACTLY what this part's visible
|
|
2101
|
+
// Negative field holds — blank is blank; no shared fallback, nothing composed.
|
|
2004
2102
|
function buildSceneRow(
|
|
2005
2103
|
p: PartEntry,
|
|
2006
2104
|
lead: GenPromptPart[],
|
|
@@ -2039,9 +2137,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
2039
2137
|
const partNeedsImage =
|
|
2040
2138
|
requiredInputsFor(partModel?.tasks ?? []).image === "required";
|
|
2041
2139
|
if (partNeedsImage && !reqParts.some((x) => x.kind === "image")) return null;
|
|
2042
|
-
const
|
|
2043
|
-
const std = p.stdNegative ?? sceneStdNegative;
|
|
2044
|
-
const neg = composeNegative(baseNeg, std).trim();
|
|
2140
|
+
const neg = (p.negative ?? "").trim();
|
|
2045
2141
|
return {
|
|
2046
2142
|
rowId: p.key,
|
|
2047
2143
|
req: {
|
|
@@ -2080,31 +2176,106 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
2080
2176
|
setParts((prev) => prev.filter((p) => p.key !== key));
|
|
2081
2177
|
if (attachTarget === key) setAttachTarget(null);
|
|
2082
2178
|
}
|
|
2083
|
-
/** Unpair a part's media
|
|
2179
|
+
/** Unpair a part's media (and any frame pulled from it), keeping its text. */
|
|
2084
2180
|
function detachMedia(key: string) {
|
|
2085
2181
|
setParts((prev) =>
|
|
2086
|
-
prev.map((p) => (p.key === key ? { ...p, media: undefined } : p)),
|
|
2182
|
+
prev.map((p) => (p.key === key ? { ...p, media: undefined, frame: undefined } : p)),
|
|
2087
2183
|
);
|
|
2088
2184
|
}
|
|
2089
|
-
/**
|
|
2090
|
-
function
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2185
|
+
/** Drop only the pulled frame; the video stays attached. */
|
|
2186
|
+
function detachFrame(key: string) {
|
|
2187
|
+
setParts((prev) => prev.map((p) => (p.key === key ? { ...p, frame: undefined } : p)));
|
|
2188
|
+
}
|
|
2189
|
+
/**
|
|
2190
|
+
* k93 §B.5: open the INLINE attach panel on card `key` (toggles closed when the
|
|
2191
|
+
* same one is clicked again). image/video pair the pick onto this part
|
|
2192
|
+
* (attachTarget routes the shared upload/ingest handlers here); "library" is
|
|
2193
|
+
* the old "+ from library" APPEND path, rendered inline as well.
|
|
2194
|
+
*/
|
|
2195
|
+
function openAttachPicker(key: string, kind: "image" | "video" | "library") {
|
|
2196
|
+
const same = partAttachOpen?.key === key && partAttachOpen.kind === kind;
|
|
2197
|
+
if (same) {
|
|
2198
|
+
setPartAttachOpen(null);
|
|
2199
|
+
setAttachTarget(null);
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
2202
|
+
setPartAttachOpen({ key, kind });
|
|
2203
|
+
setAttachTarget(kind === "library" ? null : key);
|
|
2204
|
+
// The station-level pickers are the APPEND path; the card owns attach now.
|
|
2205
|
+
setShowImagePicker(false);
|
|
2206
|
+
setShowVideoPicker(false);
|
|
2094
2207
|
}
|
|
2095
2208
|
// Route a picked media: pair it onto the attach-target part when one is set,
|
|
2096
2209
|
// else append it as a new part (empty caption, editable in place).
|
|
2097
2210
|
function placeMedia(media: PartMedia) {
|
|
2211
|
+
setPartAttachOpen(null);
|
|
2098
2212
|
if (attachTarget != null) {
|
|
2099
2213
|
const target = attachTarget;
|
|
2100
2214
|
setParts((prev) =>
|
|
2101
|
-
prev.map((p) => (p.key === target ? { ...p, media } : p)),
|
|
2215
|
+
prev.map((p) => (p.key === target ? { ...p, media, frame: undefined } : p)),
|
|
2102
2216
|
);
|
|
2103
2217
|
setAttachTarget(null);
|
|
2104
2218
|
return;
|
|
2105
2219
|
}
|
|
2106
2220
|
setParts((prev) => [...prev, { key: partKey(), text: "", media }]);
|
|
2107
2221
|
}
|
|
2222
|
+
|
|
2223
|
+
// ── k93 §B.6 pull-frame: ONE frame at `t` seconds out of this part's attached
|
|
2224
|
+
// video, through the existing frame-extract job (station "frames", so the
|
|
2225
|
+
// tracker's terminal push lands the frame in the library exactly like the
|
|
2226
|
+
// Frames station does). A 0.25 s window at fps 4 yields exactly one frame, and
|
|
2227
|
+
// max_frames: 1 keeps the backend's loud cap honest.
|
|
2228
|
+
function pullFrame(p: PartEntry) {
|
|
2229
|
+
if (!p.media || p.media.ref.kind !== "video") return;
|
|
2230
|
+
const raw = (pullFrameAt[p.key] ?? "0").trim();
|
|
2231
|
+
const t = toNumberOrNull(raw === "" ? "0" : raw);
|
|
2232
|
+
const dur = p.media.ref.duration_s;
|
|
2233
|
+
if (t == null || t < 0 || (dur != null && t >= dur)) {
|
|
2234
|
+
setPullFrameError((prev) => ({
|
|
2235
|
+
...prev,
|
|
2236
|
+
[p.key]: dur != null ? `Enter a time between 0 and ${dur.toFixed(2)} s.` : "Enter a time ≥ 0 s.",
|
|
2237
|
+
}));
|
|
2238
|
+
return;
|
|
2239
|
+
}
|
|
2240
|
+
setPullFrameError((prev) => {
|
|
2241
|
+
const { [p.key]: _drop, ...rest } = prev;
|
|
2242
|
+
return rest;
|
|
2243
|
+
});
|
|
2244
|
+
const req: FrameExtractRequest = {
|
|
2245
|
+
source: p.media.ref,
|
|
2246
|
+
fps: 4,
|
|
2247
|
+
quality: 90,
|
|
2248
|
+
fmt: "jpg",
|
|
2249
|
+
window: { start_s: t, end_s: t + 0.25 },
|
|
2250
|
+
max_frames: 1,
|
|
2251
|
+
};
|
|
2252
|
+
const enqueuedAt = Date.now();
|
|
2253
|
+
void request<unknown>(hugpyConfig.frameExtractEnqueueUrl, {
|
|
2254
|
+
method: "POST",
|
|
2255
|
+
body: JSON.stringify(req),
|
|
2256
|
+
headers: { "Content-Type": "application/json" },
|
|
2257
|
+
meta: { specKey: "frames", operation: "frame_extract.enqueue" },
|
|
2258
|
+
}).then((r) => {
|
|
2259
|
+
if (!r.ok) {
|
|
2260
|
+
setPullFrameError((prev) => ({ ...prev, [p.key]: describeAppError(errorOf(r)) }));
|
|
2261
|
+
return;
|
|
2262
|
+
}
|
|
2263
|
+
const parsed = enqueueResultSchema.safeParse(okValue(r));
|
|
2264
|
+
if (!parsed.success) {
|
|
2265
|
+
setPullFrameError((prev) => ({ ...prev, [p.key]: "Malformed enqueue response." }));
|
|
2266
|
+
return;
|
|
2267
|
+
}
|
|
2268
|
+
trackJob({
|
|
2269
|
+
jobId: parsed.data.job_id,
|
|
2270
|
+
kind: "frame_extract",
|
|
2271
|
+
station: "frames",
|
|
2272
|
+
rowKey: p.key,
|
|
2273
|
+
label: `pull frame @ ${t}s`,
|
|
2274
|
+
enqueuedAt,
|
|
2275
|
+
});
|
|
2276
|
+
setPullFrameJobs((prev) => ({ ...prev, [p.key]: parsed.data.job_id }));
|
|
2277
|
+
});
|
|
2278
|
+
}
|
|
2108
2279
|
function movePart(key: string, dir: -1 | 1) {
|
|
2109
2280
|
setParts((prev) => {
|
|
2110
2281
|
const i = prev.findIndex((p) => p.key === key);
|
|
@@ -2622,27 +2793,32 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
2622
2793
|
}
|
|
2623
2794
|
|
|
2624
2795
|
// ---- Movie prompt-list toolbar actions (k88 — the shared PromptListToolbar) ----
|
|
2625
|
-
/**
|
|
2626
|
-
|
|
2627
|
-
|
|
2796
|
+
/** Semantics mirror Cinema's Segments stepper (2026-08-27): shrinking PARKS the
|
|
2797
|
+
tail rows in a session stash instead of deleting them; growing revives parked
|
|
2798
|
+
rows (keys intact) before appending default-length windows after the last
|
|
2799
|
+
row. No upper cap. */
|
|
2628
2800
|
function setGoalCount(n: number) {
|
|
2629
|
-
if (!Number.isInteger(n) || n < 1 || n
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2801
|
+
if (!Number.isInteger(n) || n < 1 || n === goals.length) return;
|
|
2802
|
+
if (n < goals.length) {
|
|
2803
|
+
const cut = goals.slice(n);
|
|
2804
|
+
setGoalStash((st) => [...cut, ...st]);
|
|
2805
|
+
setGoals(goals.slice(0, n));
|
|
2806
|
+
return;
|
|
2807
|
+
}
|
|
2808
|
+
const revived = goalStash.slice(0, n - goals.length);
|
|
2809
|
+
if (revived.length) setGoalStash((st) => st.slice(revived.length));
|
|
2810
|
+
const next = [...goals, ...revived];
|
|
2811
|
+
while (next.length < n) {
|
|
2812
|
+
const last = next[next.length - 1];
|
|
2813
|
+
const start = last ? toNumberOrNull(last.end) ?? 0 : 0;
|
|
2814
|
+
next.push({
|
|
2815
|
+
id: goalKey(),
|
|
2816
|
+
start: String(start),
|
|
2817
|
+
end: String(start + DEFAULT_GOAL_LEN),
|
|
2818
|
+
prompt: "",
|
|
2819
|
+
});
|
|
2820
|
+
}
|
|
2821
|
+
setGoals(next);
|
|
2646
2822
|
}
|
|
2647
2823
|
function toggleMovieSelected(id: string) {
|
|
2648
2824
|
setMovieSelectedIds((prev) => {
|
|
@@ -2740,7 +2916,8 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
2740
2916
|
if (n === prev.length) return prev;
|
|
2741
2917
|
if (n > prev.length) {
|
|
2742
2918
|
const next = prev.slice();
|
|
2743
|
-
while (next.length < n)
|
|
2919
|
+
while (next.length < n)
|
|
2920
|
+
next.push({ key: partKey(), text: "", negative: STANDARD_NEGATIVE });
|
|
2744
2921
|
return next;
|
|
2745
2922
|
}
|
|
2746
2923
|
const next = prev.slice();
|
|
@@ -2758,14 +2935,6 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
2758
2935
|
return next;
|
|
2759
2936
|
});
|
|
2760
2937
|
}
|
|
2761
|
-
function toggleSceneSelected(key: string) {
|
|
2762
|
-
setSceneSelectedIds((prev) => {
|
|
2763
|
-
const next = new Set(prev);
|
|
2764
|
-
if (next.has(key)) next.delete(key);
|
|
2765
|
-
else next.add(key);
|
|
2766
|
-
return next;
|
|
2767
|
-
});
|
|
2768
|
-
}
|
|
2769
2938
|
// Selection is over TEXT parts only — the group actions rewrite prompts, and a
|
|
2770
2939
|
// media part's caption rides as locked context (its checkbox renders disabled).
|
|
2771
2940
|
// Live-row math, same as Movie: a removed part can leave a stale key behind.
|
|
@@ -2776,36 +2945,103 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
2776
2945
|
);
|
|
2777
2946
|
const sceneAllSelected =
|
|
2778
2947
|
sceneTextParts.length > 0 && sceneSelectedCount === sceneTextParts.length;
|
|
2948
|
+
// k93 §A.1/2 — the RANGE view of the selection set (1-based over text parts):
|
|
2949
|
+
// min/max of the ticked text parts, or null when nothing is ticked. The SET
|
|
2950
|
+
// stays the source of truth; the range inputs, select-all and the per-card
|
|
2951
|
+
// ticks all write into it.
|
|
2952
|
+
const sceneSelectedIdx = sceneTextParts
|
|
2953
|
+
.map((p, i) => (sceneSelectedIds.has(p.key) ? i + 1 : 0))
|
|
2954
|
+
.filter((n) => n > 0);
|
|
2955
|
+
const sceneRangeStart = sceneSelectedIdx.length ? Math.min(...sceneSelectedIdx) : null;
|
|
2956
|
+
const sceneRangeEnd = sceneSelectedIdx.length ? Math.max(...sceneSelectedIdx) : null;
|
|
2957
|
+
/** Select text parts #start … #end (1-based, inclusive, already clamped by the toolbar). */
|
|
2958
|
+
function selectSceneRange(start: number, end: number) {
|
|
2959
|
+
const s = Math.max(1, Math.min(start, sceneTextParts.length));
|
|
2960
|
+
const e = Math.max(s, Math.min(end, sceneTextParts.length));
|
|
2961
|
+
sceneRangeMemo.current = { start: s, end: e };
|
|
2962
|
+
setSceneSelectedIds(new Set(sceneTextParts.slice(s - 1, e).map((p) => p.key)));
|
|
2963
|
+
}
|
|
2964
|
+
function toggleSceneSelected(key: string) {
|
|
2965
|
+
setSceneSelectedIds((prev) => {
|
|
2966
|
+
const next = new Set(prev);
|
|
2967
|
+
if (next.has(key)) next.delete(key);
|
|
2968
|
+
else next.add(key);
|
|
2969
|
+
// A per-card tick un-checks select-all implicitly (the set is no longer
|
|
2970
|
+
// every text part) and the range inputs show the new min/max; remember
|
|
2971
|
+
// that span as the explicit range select-all returns to.
|
|
2972
|
+
const idx = sceneTextParts
|
|
2973
|
+
.map((p, i) => (next.has(p.key) ? i + 1 : 0))
|
|
2974
|
+
.filter((n) => n > 0);
|
|
2975
|
+
sceneRangeMemo.current = idx.length
|
|
2976
|
+
? { start: Math.min(...idx), end: Math.max(...idx) }
|
|
2977
|
+
: null;
|
|
2978
|
+
return next;
|
|
2979
|
+
});
|
|
2980
|
+
}
|
|
2981
|
+
// select all: checked ⇒ #1 … #last; unchecked ⇒ back to the remembered
|
|
2982
|
+
// explicit [start, end] (or nothing when no range was ever set).
|
|
2779
2983
|
function toggleSceneSelectAll() {
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2984
|
+
if (sceneAllSelected) {
|
|
2985
|
+
const memo = sceneRangeMemo.current;
|
|
2986
|
+
if (memo && !(memo.start === 1 && memo.end === sceneTextParts.length)) {
|
|
2987
|
+
selectSceneRange(memo.start, memo.end);
|
|
2988
|
+
} else {
|
|
2989
|
+
setSceneSelectedIds(new Set());
|
|
2990
|
+
}
|
|
2991
|
+
return;
|
|
2992
|
+
}
|
|
2993
|
+
if (sceneRangeStart != null && sceneRangeEnd != null) {
|
|
2994
|
+
sceneRangeMemo.current = { start: sceneRangeStart, end: sceneRangeEnd };
|
|
2995
|
+
}
|
|
2996
|
+
setSceneSelectedIds(new Set(sceneTextParts.map((p) => p.key)));
|
|
2997
|
+
}
|
|
2998
|
+
// enhance (M selected): the selected text parts with a NON-BLANK prompt.
|
|
2999
|
+
const sceneSelectedParts = sceneTextParts.filter((p) => sceneSelectedIds.has(p.key));
|
|
3000
|
+
const sceneEnhanceCount = sceneSelectedParts.filter((p) => p.text.trim() !== "").length;
|
|
3001
|
+
// The toolbar std. tick reads the FIELDS: checked iff every selected part's
|
|
3002
|
+
// Negative already holds the standard set.
|
|
3003
|
+
const sceneStdAllOn =
|
|
3004
|
+
sceneSelectedParts.length > 0 &&
|
|
3005
|
+
sceneSelectedParts.every((p) => hasStandardNegative(p.negative ?? ""));
|
|
3006
|
+
|
|
3007
|
+
// The scene's group-assist negative (k93): part #1's VISIBLE Negative field,
|
|
3008
|
+
// explicitly — the first text part's field, sent as `global_negative` (its tab
|
|
3009
|
+
// tooltip says so). No std composition, no shared fallback.
|
|
3010
|
+
function sceneGroupNegative(): string {
|
|
3011
|
+
return (sceneTextParts[0]?.negative ?? "").trim();
|
|
2783
3012
|
}
|
|
2784
3013
|
|
|
2785
|
-
// GROUP ACTION (k89): ONE coherent spread across the ticked text parts — the
|
|
2786
|
-
// one-call rule Cinema's onSpreadSelected / Movie's onMovieSpreadSelected
|
|
2787
|
-
// selected parts are targets, everything else (unticked text, media
|
|
2788
|
-
// 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.
|
|
2789
3022
|
async function onSceneSpreadSelected() {
|
|
2790
|
-
const wiredNegative =
|
|
3023
|
+
const wiredNegative = sceneGroupNegative();
|
|
2791
3024
|
const inputs: SpreadGoalInput[] = parts.map((p, i) => ({
|
|
2792
3025
|
key: p.key,
|
|
2793
3026
|
prompt: p.text,
|
|
2794
|
-
negative:
|
|
3027
|
+
negative: p.negative ?? "",
|
|
2795
3028
|
seed: "",
|
|
2796
3029
|
branchFrame: "",
|
|
2797
3030
|
joint: "still",
|
|
2798
3031
|
index: i,
|
|
2799
3032
|
}));
|
|
3033
|
+
const media = scenePretextWire();
|
|
2800
3034
|
const body = buildSpreadBody({
|
|
2801
3035
|
goals: inputs,
|
|
2802
3036
|
selectedKeys: sceneSelectedIds,
|
|
2803
3037
|
movieQuery: "",
|
|
2804
3038
|
globalNegative: wiredNegative,
|
|
2805
3039
|
steeringSeed: sceneSteeringSeed,
|
|
3040
|
+
context: { kind: "scene", ...(media ? { media } : {}) },
|
|
2806
3041
|
});
|
|
2807
3042
|
if (!body) return;
|
|
2808
3043
|
setSceneSpreadNotice(null);
|
|
3044
|
+
const writeNegatives = sceneGroupNegatives;
|
|
2809
3045
|
const res = await assist.runSpread(body);
|
|
2810
3046
|
if (!res) return;
|
|
2811
3047
|
const bySeg = new Map(res.segments.map((s) => [s.segment_id, s]));
|
|
@@ -2818,33 +3054,47 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
2818
3054
|
// may linger in the select set — never overwrite a media caption).
|
|
2819
3055
|
if (!sceneSelectedIds.has(p.key) || p.media) return p;
|
|
2820
3056
|
const hit = bySeg.get(p.key);
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
3057
|
+
if (!hit) return p;
|
|
3058
|
+
if (!writeNegatives || hit.negative.trim() === "") return { ...p, text: hit.prompt };
|
|
3059
|
+
// Keep the standard set if the field held it (the std. tick reads the field).
|
|
3060
|
+
const std = hasStandardNegative(p.negative ?? "");
|
|
3061
|
+
return { ...p, text: hit.prompt, negative: composeNegative(hit.negative, std) };
|
|
2824
3062
|
}),
|
|
2825
3063
|
);
|
|
2826
3064
|
if (res.steering_seed != null) setSceneSteeringSeed(res.steering_seed);
|
|
2827
3065
|
setSceneSpreadNotice(spreadNoticeLines(res, applied));
|
|
2828
3066
|
}
|
|
2829
3067
|
|
|
2830
|
-
// GROUP ACTION (
|
|
2831
|
-
// text
|
|
2832
|
-
//
|
|
2833
|
-
|
|
2834
|
-
|
|
3068
|
+
// GROUP ACTION (k93 §A.5): enhance (M selected) — the "detail" assist over every
|
|
3069
|
+
// selected text part with a non-blank prompt. The spread endpoint carries no
|
|
3070
|
+
// mode, so this runs SEQUENTIALLY per part through runPartAssist (one in-flight
|
|
3071
|
+
// assist at a time — the hook's guard), each call carrying the toolbar's
|
|
3072
|
+
// negatives toggle and the `[+ video]` pretext.
|
|
3073
|
+
async function onSceneEnhanceSelected() {
|
|
3074
|
+
const rows = sceneSelectedParts.filter((p) => p.text.trim() !== "");
|
|
2835
3075
|
if (rows.length === 0) return;
|
|
2836
3076
|
setSceneSpreadNotice(null);
|
|
2837
|
-
const
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
await assist.runNegative({ subject, draft: negative }, setNegative);
|
|
3077
|
+
const media = scenePretextWire();
|
|
3078
|
+
for (const p of rows) {
|
|
3079
|
+
await runPartAssist(p, "detail", { negatives: sceneGroupNegatives, media });
|
|
3080
|
+
}
|
|
2842
3081
|
}
|
|
2843
3082
|
|
|
2844
|
-
// Toolbar [
|
|
2845
|
-
//
|
|
2846
|
-
|
|
2847
|
-
|
|
3083
|
+
// Toolbar `[ ] std.` (k93 §A.4): tick ⇒ INSERT the standard set into every
|
|
3084
|
+
// selected part's Negative field (deduped, comma-joined); untick ⇒ remove
|
|
3085
|
+
// exactly those terms. Visible in the field — nothing composed at submit.
|
|
3086
|
+
function onSceneStdChange(on: boolean) {
|
|
3087
|
+
setParts((prev) =>
|
|
3088
|
+
prev.map((p) =>
|
|
3089
|
+
sceneSelectedIds.has(p.key) && !p.media
|
|
3090
|
+
? { ...p, negative: applyStandardNegative(p.negative ?? "", on) }
|
|
3091
|
+
: p,
|
|
3092
|
+
),
|
|
3093
|
+
);
|
|
3094
|
+
}
|
|
3095
|
+
/** Per-card std. tick (k93 §B.3): same explicit insert/remove on this part only. */
|
|
3096
|
+
function onPartStdChange(p: PartEntry, on: boolean) {
|
|
3097
|
+
patchPart(p.key, { negative: applyStandardNegative(p.negative ?? "", on) });
|
|
2848
3098
|
}
|
|
2849
3099
|
// Route a picked image: onto the attach-target GOAL (Movie mode) when set, else
|
|
2850
3100
|
// through the parts placeMedia path (Image/Scene). Keeps the picker/upload code
|
|
@@ -2859,18 +3109,18 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
2859
3109
|
placeMedia(media);
|
|
2860
3110
|
}
|
|
2861
3111
|
|
|
2862
|
-
function
|
|
2863
|
-
placePickedImage(
|
|
3112
|
+
function addImageMedia(media: PartMedia) {
|
|
3113
|
+
placePickedImage(media);
|
|
2864
3114
|
setShowImagePicker(false);
|
|
2865
3115
|
}
|
|
2866
|
-
function
|
|
2867
|
-
placeMedia(
|
|
3116
|
+
function addVideoMedia(media: PartMedia) {
|
|
3117
|
+
placeMedia(media);
|
|
2868
3118
|
setShowVideoPicker(false);
|
|
2869
3119
|
}
|
|
2870
3120
|
|
|
2871
3121
|
// Upload a raw video, ingest it into a MediaRef, guard kind==="video", then
|
|
2872
3122
|
// append it as a video part (and add it to the library so it is reusable).
|
|
2873
|
-
async function onPickVideo(file: File) {
|
|
3123
|
+
async function onPickVideo(file: File, place: (media: PartMedia) => void = placeMedia) {
|
|
2874
3124
|
setVideoError(null);
|
|
2875
3125
|
setVideoPhase("uploading");
|
|
2876
3126
|
|
|
@@ -2920,7 +3170,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
2920
3170
|
return;
|
|
2921
3171
|
}
|
|
2922
3172
|
addToLibrary(media.data, "upload", media.data.mime);
|
|
2923
|
-
|
|
3173
|
+
place({ ref: media.data, origin: "upload", label: media.data.mime });
|
|
2924
3174
|
setVideoPhase("idle");
|
|
2925
3175
|
setShowVideoPicker(false);
|
|
2926
3176
|
}
|
|
@@ -2929,7 +3179,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
2929
3179
|
// was library-only for images, which forced a detour through another
|
|
2930
3180
|
// station just to use a file that's sitting on disk). Upload → ingest →
|
|
2931
3181
|
// guard kind==="image" → attach as the part + into the library for reuse.
|
|
2932
|
-
async function onPickImage(file: File) {
|
|
3182
|
+
async function onPickImage(file: File, place: (media: PartMedia) => void = placePickedImage) {
|
|
2933
3183
|
setImageError(null);
|
|
2934
3184
|
setImagePhase("uploading");
|
|
2935
3185
|
|
|
@@ -2979,11 +3229,149 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
2979
3229
|
return;
|
|
2980
3230
|
}
|
|
2981
3231
|
addToLibrary(media.data, "upload", media.data.mime);
|
|
2982
|
-
|
|
3232
|
+
place({ ref: media.data, origin: "upload", label: media.data.mime });
|
|
2983
3233
|
setImagePhase("idle");
|
|
2984
3234
|
setShowImagePicker(false);
|
|
2985
3235
|
}
|
|
2986
3236
|
|
|
3237
|
+
// ── k93 §B.5 — the picker BODIES (browse-from-disk + library grid), factored so
|
|
3238
|
+
// the same markup renders inline inside a card, under the toolbar (the `[+ video]`
|
|
3239
|
+
// pretext) and in the station-level append pickers. `onPick` receives the chosen
|
|
3240
|
+
// media as a PartMedia; the caller decides where it lands.
|
|
3241
|
+
function renderImagePickerBody(onPick: (media: PartMedia) => void) {
|
|
3242
|
+
return (
|
|
3243
|
+
<>
|
|
3244
|
+
<div className="vi-gen-video-upload">
|
|
3245
|
+
<AddFileBar
|
|
3246
|
+
accept="image/*"
|
|
3247
|
+
onFile={(f) => onPickImage(f, onPick)}
|
|
3248
|
+
busy={imageBusy}
|
|
3249
|
+
buttonLabel="Choose image from disk"
|
|
3250
|
+
>
|
|
3251
|
+
{imageBusy && <span className="vi-crop-status">Uploading…</span>}
|
|
3252
|
+
</AddFileBar>
|
|
3253
|
+
{imageError && <span className="vi-error">{imageError}</span>}
|
|
3254
|
+
</div>
|
|
3255
|
+
{libraryImages.length === 0 ? (
|
|
3256
|
+
<p className="vi-crop-hint">
|
|
3257
|
+
Nothing in the session library yet — browse above, pick frames in the
|
|
3258
|
+
Frames station, or generate an image; results land here and become
|
|
3259
|
+
pickable.
|
|
3260
|
+
</p>
|
|
3261
|
+
) : (
|
|
3262
|
+
<LibraryGroups
|
|
3263
|
+
seed={mode}
|
|
3264
|
+
items={libraryImages}
|
|
3265
|
+
defaultOpen={libraryDefaultOpen}
|
|
3266
|
+
renderItem={(it) => (
|
|
3267
|
+
<button
|
|
3268
|
+
type="button"
|
|
3269
|
+
key={it.ref.uri}
|
|
3270
|
+
className="vi-gen-pick-cell"
|
|
3271
|
+
onClick={() => onPick({ ref: it.ref, origin: it.origin, label: it.label })}
|
|
3272
|
+
title={it.ref.uri}
|
|
3273
|
+
>
|
|
3274
|
+
<img
|
|
3275
|
+
className="vi-gen-pick-thumb"
|
|
3276
|
+
src={mediaBytesUrl(it.ref.uri)}
|
|
3277
|
+
alt={it.label ?? "library image"}
|
|
3278
|
+
loading="lazy"
|
|
3279
|
+
/>
|
|
3280
|
+
<span className="vi-gen-pick-meta">
|
|
3281
|
+
{it.origin}
|
|
3282
|
+
{it.label ? ` · ${it.label}` : ""}
|
|
3283
|
+
</span>
|
|
3284
|
+
</button>
|
|
3285
|
+
)}
|
|
3286
|
+
/>
|
|
3287
|
+
)}
|
|
3288
|
+
</>
|
|
3289
|
+
);
|
|
3290
|
+
}
|
|
3291
|
+
function renderVideoPickerBody(onPick: (media: PartMedia) => void) {
|
|
3292
|
+
return (
|
|
3293
|
+
<>
|
|
3294
|
+
<div className="vi-gen-video-upload">
|
|
3295
|
+
<AddFileBar
|
|
3296
|
+
accept="video/*"
|
|
3297
|
+
onFile={(f) => onPickVideo(f, onPick)}
|
|
3298
|
+
busy={videoBusy}
|
|
3299
|
+
buttonLabel="Choose video from disk"
|
|
3300
|
+
>
|
|
3301
|
+
{videoBusy && <span className="vi-crop-status">Uploading…</span>}
|
|
3302
|
+
</AddFileBar>
|
|
3303
|
+
{videoError && <span className="vi-error">{videoError}</span>}
|
|
3304
|
+
</div>
|
|
3305
|
+
{libraryVideos.length === 0 ? (
|
|
3306
|
+
<p className="vi-crop-hint">
|
|
3307
|
+
No videos in the session library yet — browse above or ingest one in
|
|
3308
|
+
another station.
|
|
3309
|
+
</p>
|
|
3310
|
+
) : (
|
|
3311
|
+
<LibraryGroups
|
|
3312
|
+
seed={mode}
|
|
3313
|
+
items={libraryVideos}
|
|
3314
|
+
defaultOpen={libraryDefaultOpen}
|
|
3315
|
+
renderItem={(it) => (
|
|
3316
|
+
<button
|
|
3317
|
+
type="button"
|
|
3318
|
+
key={it.ref.uri}
|
|
3319
|
+
className="vi-gen-pick-cell"
|
|
3320
|
+
onClick={() => onPick({ ref: it.ref, origin: it.origin, label: it.label })}
|
|
3321
|
+
title={it.ref.uri}
|
|
3322
|
+
>
|
|
3323
|
+
<span className="vi-gen-pick-videoicon" aria-hidden>
|
|
3324
|
+
▶
|
|
3325
|
+
</span>
|
|
3326
|
+
<span className="vi-gen-pick-meta">
|
|
3327
|
+
<code>{it.ref.mime}</code> · {it.origin}
|
|
3328
|
+
</span>
|
|
3329
|
+
</button>
|
|
3330
|
+
)}
|
|
3331
|
+
/>
|
|
3332
|
+
)}
|
|
3333
|
+
</>
|
|
3334
|
+
);
|
|
3335
|
+
}
|
|
3336
|
+
/** The whole library (images + videos) — the inline `+ library` APPEND panel. */
|
|
3337
|
+
function renderLibraryPickerBody(onPick: (media: PartMedia) => void) {
|
|
3338
|
+
return library.length === 0 ? (
|
|
3339
|
+
<p className="vi-crop-hint">Nothing in the session library yet.</p>
|
|
3340
|
+
) : (
|
|
3341
|
+
<LibraryGroups
|
|
3342
|
+
seed={mode}
|
|
3343
|
+
items={library}
|
|
3344
|
+
defaultOpen={libraryDefaultOpen}
|
|
3345
|
+
renderItem={(it) => (
|
|
3346
|
+
<button
|
|
3347
|
+
type="button"
|
|
3348
|
+
key={it.ref.uri}
|
|
3349
|
+
className="vi-gen-pick-cell"
|
|
3350
|
+
onClick={() => onPick({ ref: it.ref, origin: it.origin, label: it.label })}
|
|
3351
|
+
title={it.ref.uri}
|
|
3352
|
+
>
|
|
3353
|
+
{it.ref.kind === "image" ? (
|
|
3354
|
+
<img
|
|
3355
|
+
className="vi-gen-pick-thumb"
|
|
3356
|
+
src={mediaBytesUrl(it.ref.uri)}
|
|
3357
|
+
alt={it.label ?? "library image"}
|
|
3358
|
+
loading="lazy"
|
|
3359
|
+
/>
|
|
3360
|
+
) : (
|
|
3361
|
+
<span className="vi-gen-pick-videoicon" aria-hidden>
|
|
3362
|
+
▶
|
|
3363
|
+
</span>
|
|
3364
|
+
)}
|
|
3365
|
+
<span className="vi-gen-pick-meta">
|
|
3366
|
+
{it.ref.kind === "image" ? it.origin : <><code>{it.ref.mime}</code> · {it.origin}</>}
|
|
3367
|
+
{it.label ? ` · ${it.label}` : ""}
|
|
3368
|
+
</span>
|
|
3369
|
+
</button>
|
|
3370
|
+
)}
|
|
3371
|
+
/>
|
|
3372
|
+
);
|
|
3373
|
+
}
|
|
3374
|
+
|
|
2987
3375
|
// ---- run validation (no silent defaults; every gate is visible) ----
|
|
2988
3376
|
const widthNum = toNumberOrNull(width);
|
|
2989
3377
|
const heightNum = toNumberOrNull(height);
|
|
@@ -3136,6 +3524,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
3136
3524
|
if (!p.media || p.text.trim() !== "") {
|
|
3137
3525
|
out.push({ kind: "text", text: p.text });
|
|
3138
3526
|
}
|
|
3527
|
+
if (p.frame) out.push({ kind: "image", media: p.frame.ref });
|
|
3139
3528
|
if (p.media) {
|
|
3140
3529
|
out.push(
|
|
3141
3530
|
p.media.ref.kind === "video"
|
|
@@ -3148,11 +3537,12 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
3148
3537
|
}
|
|
3149
3538
|
|
|
3150
3539
|
// The composer's MEDIA parts only (images / videos / bound identity views), with
|
|
3151
|
-
// every text segment dropped. Tester rows supply their own text and
|
|
3540
|
+
// every text segment dropped. Tester rows supply their own text and reuse these,
|
|
3152
3541
|
// so the reference material is identical across a sweep.
|
|
3153
3542
|
function buildMediaOnlyParts(): GenPromptPart[] {
|
|
3154
3543
|
const out: GenPromptPart[] = [];
|
|
3155
3544
|
for (const p of parts) {
|
|
3545
|
+
if (p.frame) out.push({ kind: "image", media: p.frame.ref });
|
|
3156
3546
|
if (p.media) {
|
|
3157
3547
|
out.push(
|
|
3158
3548
|
p.media.ref.kind === "video"
|
|
@@ -3175,7 +3565,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
3175
3565
|
// setting and has no tick — it always carries, because a row with no prompt is
|
|
3176
3566
|
// not runnable and would just be a blank card.
|
|
3177
3567
|
//
|
|
3178
|
-
// The new row
|
|
3568
|
+
// The new row copies the source's tick pattern too, so a sweep keeps sweeping:
|
|
3179
3569
|
// untick `seed` once and every row spun off the chain keeps re-randomizing.
|
|
3180
3570
|
function seedTestRow(index: number, from: ImageTestRow | undefined): ImageTestRow {
|
|
3181
3571
|
const ticks = from ? from.carry : carry;
|
|
@@ -3196,13 +3586,13 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
3196
3586
|
: guidance
|
|
3197
3587
|
: DEFAULT_KNOBS.guidance,
|
|
3198
3588
|
seed: ticks.seed ? (from ? from.seed : seed) : DEFAULT_KNOBS.seed,
|
|
3199
|
-
//
|
|
3200
|
-
//
|
|
3201
|
-
//
|
|
3589
|
+
// k93: a row seeded from the BASE composer carries part #1's VISIBLE
|
|
3590
|
+
// negative — the same value the scene submit sends, so a sweep row
|
|
3591
|
+
// reproduces the base generation.
|
|
3202
3592
|
negative: ticks.negative
|
|
3203
3593
|
? from
|
|
3204
3594
|
? from.negative
|
|
3205
|
-
:
|
|
3595
|
+
: sceneGroupNegative()
|
|
3206
3596
|
: DEFAULT_NEGATIVE,
|
|
3207
3597
|
carry: { ...ticks },
|
|
3208
3598
|
};
|
|
@@ -3546,10 +3936,10 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
3546
3936
|
steps: stepsNum,
|
|
3547
3937
|
guidance: guidanceNum,
|
|
3548
3938
|
seed: seed.trim() === "" ? null : seedNum,
|
|
3549
|
-
// Same
|
|
3550
|
-
// not drift from what the live submit sends.
|
|
3939
|
+
// Same explicit value as the scene path (part #1's visible field) — the
|
|
3940
|
+
// retained image-job wiring must not drift from what the live submit sends.
|
|
3551
3941
|
negative: (() => {
|
|
3552
|
-
const eff =
|
|
3942
|
+
const eff = sceneGroupNegative();
|
|
3553
3943
|
return eff === "" ? null : eff;
|
|
3554
3944
|
})(),
|
|
3555
3945
|
project: projectName.trim() || undefined,
|
|
@@ -3652,9 +4042,10 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
3652
4042
|
to frames server-side.
|
|
3653
4043
|
</p>
|
|
3654
4044
|
<p>
|
|
3655
|
-
Each prompt has its OWN Negative tab
|
|
3656
|
-
|
|
3657
|
-
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.
|
|
3658
4049
|
</p>
|
|
3659
4050
|
<p>
|
|
3660
4051
|
The Tester at the bottom compares prompts, settings and models side
|
|
@@ -4467,53 +4858,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
4467
4858
|
timeline.
|
|
4468
4859
|
</p>
|
|
4469
4860
|
)}
|
|
4470
|
-
{
|
|
4471
|
-
<div className="vi-gen-video-upload">
|
|
4472
|
-
<AddFileBar
|
|
4473
|
-
accept="image/*"
|
|
4474
|
-
onFile={onPickImage}
|
|
4475
|
-
busy={imageBusy}
|
|
4476
|
-
buttonLabel="Choose image from disk"
|
|
4477
|
-
>
|
|
4478
|
-
{imageBusy && (
|
|
4479
|
-
<span className="vi-crop-status">Uploading…</span>
|
|
4480
|
-
)}
|
|
4481
|
-
</AddFileBar>
|
|
4482
|
-
{imageError && <span className="vi-error">{imageError}</span>}
|
|
4483
|
-
</div>
|
|
4484
|
-
{libraryImages.length === 0 ? (
|
|
4485
|
-
<p className="vi-crop-hint">
|
|
4486
|
-
Nothing in the session library yet — browse above, pick
|
|
4487
|
-
frames in the Frames station, or generate an image; results
|
|
4488
|
-
land here and become pickable.
|
|
4489
|
-
</p>
|
|
4490
|
-
) : (
|
|
4491
|
-
<LibraryGroups
|
|
4492
|
-
seed={mode}
|
|
4493
|
-
items={libraryImages}
|
|
4494
|
-
defaultOpen={libraryDefaultOpen}
|
|
4495
|
-
renderItem={(it) => (
|
|
4496
|
-
<button
|
|
4497
|
-
type="button"
|
|
4498
|
-
key={it.ref.uri}
|
|
4499
|
-
className="vi-gen-pick-cell"
|
|
4500
|
-
onClick={() => addImage(it)}
|
|
4501
|
-
title={it.ref.uri}
|
|
4502
|
-
>
|
|
4503
|
-
<img
|
|
4504
|
-
className="vi-gen-pick-thumb"
|
|
4505
|
-
src={mediaBytesUrl(it.ref.uri)}
|
|
4506
|
-
alt={it.label ?? "library image"}
|
|
4507
|
-
loading="lazy"
|
|
4508
|
-
/>
|
|
4509
|
-
<span className="vi-gen-pick-meta">
|
|
4510
|
-
{it.origin}
|
|
4511
|
-
{it.label ? ` · ${it.label}` : ""}
|
|
4512
|
-
</span>
|
|
4513
|
-
</button>
|
|
4514
|
-
)}
|
|
4515
|
-
/>
|
|
4516
|
-
)}
|
|
4861
|
+
{renderImagePickerBody(addImageMedia)}
|
|
4517
4862
|
</div>
|
|
4518
4863
|
)}
|
|
4519
4864
|
|
|
@@ -4531,42 +4876,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
4531
4876
|
sampling); for precise control, extract + pick frames in the
|
|
4532
4877
|
Frames station and add them as images.
|
|
4533
4878
|
</p>
|
|
4534
|
-
|
|
4535
|
-
<AddFileBar
|
|
4536
|
-
accept="video/*"
|
|
4537
|
-
onFile={onPickVideo}
|
|
4538
|
-
busy={videoBusy}
|
|
4539
|
-
buttonLabel="Choose video from disk"
|
|
4540
|
-
>
|
|
4541
|
-
{videoBusy && (
|
|
4542
|
-
<span className="vi-crop-status">Uploading…</span>
|
|
4543
|
-
)}
|
|
4544
|
-
</AddFileBar>
|
|
4545
|
-
{videoError && <span className="vi-error">{videoError}</span>}
|
|
4546
|
-
</div>
|
|
4547
|
-
{libraryVideos.length > 0 && (
|
|
4548
|
-
<LibraryGroups
|
|
4549
|
-
seed={mode}
|
|
4550
|
-
items={libraryVideos}
|
|
4551
|
-
defaultOpen={libraryDefaultOpen}
|
|
4552
|
-
renderItem={(it) => (
|
|
4553
|
-
<button
|
|
4554
|
-
type="button"
|
|
4555
|
-
key={it.ref.uri}
|
|
4556
|
-
className="vi-gen-pick-cell"
|
|
4557
|
-
onClick={() => addVideoFromLibrary(it)}
|
|
4558
|
-
title={it.ref.uri}
|
|
4559
|
-
>
|
|
4560
|
-
<span className="vi-gen-pick-videoicon" aria-hidden>
|
|
4561
|
-
▶
|
|
4562
|
-
</span>
|
|
4563
|
-
<span className="vi-gen-pick-meta">
|
|
4564
|
-
<code>{it.ref.mime}</code> · {it.origin}
|
|
4565
|
-
</span>
|
|
4566
|
-
</button>
|
|
4567
|
-
)}
|
|
4568
|
-
/>
|
|
4569
|
-
)}
|
|
4879
|
+
{renderVideoPickerBody(addVideoMedia)}
|
|
4570
4880
|
</div>
|
|
4571
4881
|
)}
|
|
4572
4882
|
</>
|
|
@@ -4804,9 +5114,9 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
4804
5114
|
label: "Goals",
|
|
4805
5115
|
value: goals.length,
|
|
4806
5116
|
min: 1,
|
|
4807
|
-
max: MAX_MOVIE_GOALS,
|
|
4808
5117
|
onChange: setGoalCount,
|
|
4809
|
-
title:
|
|
5118
|
+
title:
|
|
5119
|
+
"How many goals the timeline has. Lowering it parks rows from the END of the timeline; raising it brings them back before adding blanks.",
|
|
4810
5120
|
}}
|
|
4811
5121
|
noun="goal"
|
|
4812
5122
|
selectedCount={movieSelectedCount}
|
|
@@ -5138,9 +5448,12 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
5138
5448
|
{/* The station-wide "whole assembled prompt" Enhance/Generate cluster
|
|
5139
5449
|
was removed (operator): per-part Enhance/Generate on each card and
|
|
5140
5450
|
the toolbar's group "Generate prompts (N selected)" below cover it. */}
|
|
5141
|
-
{/* k89 — the SHARED prompt-list toolbar
|
|
5142
|
-
|
|
5143
|
-
|
|
5451
|
+
{/* k89/k93 — the SHARED prompt-list toolbar in its GROUP layout:
|
|
5452
|
+
prompt assist model [select]
|
|
5453
|
+
[start] - [end] [ ] select all negatives: [on/off] [ ] std.
|
|
5454
|
+
(✨ generate (N selected)) (✨ enhance (M selected)) [+ video]
|
|
5455
|
+
The selection SET (sceneSelectedIds) stays the source of truth;
|
|
5456
|
+
range / select-all / per-card ticks all write into it. */}
|
|
5144
5457
|
<PromptListToolbar
|
|
5145
5458
|
count={{
|
|
5146
5459
|
id: "vi-gen-part-count",
|
|
@@ -5156,52 +5469,117 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
5156
5469
|
itemCount={sceneTextParts.length}
|
|
5157
5470
|
allSelected={sceneAllSelected}
|
|
5158
5471
|
onToggleSelectAll={toggleSceneSelectAll}
|
|
5472
|
+
modelRow={
|
|
5473
|
+
/* PROMPT-ASSIST MODEL — ONE shared selector for which text
|
|
5474
|
+
generator the group actions (and every card's default) use. */
|
|
5475
|
+
assist.assistModels.length > 0 ? (
|
|
5476
|
+
<div className="vi-prompt-assist vi-gen-assist-model">
|
|
5477
|
+
<span className="vi-prompt-assist-lead" aria-hidden="true">
|
|
5478
|
+
prompt assist model
|
|
5479
|
+
</span>
|
|
5480
|
+
<select
|
|
5481
|
+
className="vi-select vi-select-sm"
|
|
5482
|
+
value={assist.assistModel ?? ""}
|
|
5483
|
+
disabled={assist.assistBusy != null}
|
|
5484
|
+
onChange={(e) => assist.setAssistModel(e.target.value || null)}
|
|
5485
|
+
aria-label="Text generator used to enhance or generate prompts"
|
|
5486
|
+
title="Which text generator writes prompts for the group actions and every prompt's Enhance / Generate (a card can pick its own)."
|
|
5487
|
+
>
|
|
5488
|
+
<option value="">fleet default</option>
|
|
5489
|
+
{assist.assistModels.map((m) => (
|
|
5490
|
+
<option key={m.model} value={m.model}>
|
|
5491
|
+
{m.model}
|
|
5492
|
+
{m.serving ? "" : " · cold"}
|
|
5493
|
+
</option>
|
|
5494
|
+
))}
|
|
5495
|
+
</select>
|
|
5496
|
+
</div>
|
|
5497
|
+
) : null
|
|
5498
|
+
}
|
|
5499
|
+
range={{
|
|
5500
|
+
start: sceneRangeStart,
|
|
5501
|
+
end: sceneRangeEnd,
|
|
5502
|
+
onChange: selectSceneRange,
|
|
5503
|
+
}}
|
|
5504
|
+
negativesOn={sceneGroupNegatives}
|
|
5505
|
+
onNegativesOnChange={setSceneGroupNegatives}
|
|
5506
|
+
negativesOnTitle="On: generate / enhance also write each selected part's Negative field (visible; sent as-is). Off: prompts only."
|
|
5507
|
+
stdChecked={sceneStdAllOn}
|
|
5508
|
+
onStdChange={onSceneStdChange}
|
|
5509
|
+
stdTitle="Tick: insert the standard exclusion set into every selected part's Negative field. Untick: remove exactly those terms. What you see in the field is what is sent."
|
|
5159
5510
|
generateLabel={
|
|
5160
5511
|
assist.assistBusy === "spread"
|
|
5161
|
-
? "✨
|
|
5162
|
-
: `✨
|
|
5512
|
+
? "✨ writing the spread…"
|
|
5513
|
+
: `✨ generate (${sceneSelectedCount} selected)`
|
|
5163
5514
|
}
|
|
5164
|
-
generateTitle="ONE call writes every
|
|
5165
|
-
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)."
|
|
5166
5517
|
onGenerate={() => void onSceneSpreadSelected()}
|
|
5167
|
-
|
|
5168
|
-
assist.assistBusy === "
|
|
5169
|
-
? "✨
|
|
5170
|
-
: `✨
|
|
5518
|
+
enhanceLabel={
|
|
5519
|
+
assist.assistBusy === "detail"
|
|
5520
|
+
? "✨ enhancing…"
|
|
5521
|
+
: `✨ enhance (${sceneEnhanceCount} selected)`
|
|
5171
5522
|
}
|
|
5172
|
-
|
|
5173
|
-
|
|
5523
|
+
enhanceTitle={
|
|
5524
|
+
sceneEnhanceCount === 0
|
|
5525
|
+
? "Enhance needs selected parts with a non-blank prompt."
|
|
5526
|
+
: "Enrich each selected non-blank prompt with more descriptive detail (one part at a time)."
|
|
5527
|
+
}
|
|
5528
|
+
canEnhance={sceneEnhanceCount > 0}
|
|
5529
|
+
onEnhance={() => void onSceneEnhanceSelected()}
|
|
5530
|
+
// Legacy single-line props (unused in the group layout, kept typed).
|
|
5531
|
+
negativesLabel=""
|
|
5532
|
+
onNegatives={() => undefined}
|
|
5533
|
+
onStandardNegatives={() => undefined}
|
|
5174
5534
|
assistBusy={assist.assistBusy != null}
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
{assist.assistModels.length > 0 && (
|
|
5183
|
-
<div className="vi-prompt-assist vi-gen-assist-model">
|
|
5184
|
-
<span className="vi-prompt-assist-lead" aria-hidden="true">
|
|
5185
|
-
prompt assist model
|
|
5186
|
-
</span>
|
|
5187
|
-
<select
|
|
5188
|
-
className="vi-select vi-select-sm"
|
|
5189
|
-
value={assist.assistModel ?? ""}
|
|
5190
|
-
disabled={assist.assistBusy != null}
|
|
5191
|
-
onChange={(e) => assist.setAssistModel(e.target.value || null)}
|
|
5192
|
-
aria-label="Text generator used to enhance or generate prompts"
|
|
5193
|
-
title="Which text generator writes prompts for every prompt's Enhance / Generate."
|
|
5535
|
+
actionsExtra={
|
|
5536
|
+
<button
|
|
5537
|
+
type="button"
|
|
5538
|
+
className={`vi-btn vi-btn-sm vi-btn-ghost${scenePretext ? " vi-btn-on" : ""}`}
|
|
5539
|
+
onClick={() => setScenePretextOpen((v) => !v)}
|
|
5540
|
+
aria-expanded={scenePretextOpen}
|
|
5541
|
+
title="Attach ONE video as pretext: it is analyzed server-side and its description is fed to generate / enhance as context (not a prompt part)."
|
|
5194
5542
|
>
|
|
5195
|
-
|
|
5196
|
-
|
|
5197
|
-
|
|
5198
|
-
|
|
5199
|
-
|
|
5200
|
-
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
|
|
5543
|
+
{scenePretext ? "▶ video pretext ✓" : "+ video"}
|
|
5544
|
+
</button>
|
|
5545
|
+
}
|
|
5546
|
+
below={
|
|
5547
|
+
(scenePretextOpen || scenePretext) && (
|
|
5548
|
+
<div className="vi-gen-inline-attach vi-gen-pretext">
|
|
5549
|
+
{scenePretext ? (
|
|
5550
|
+
<div className="vi-gen-part-media">
|
|
5551
|
+
<span className="vi-gen-part-videoicon" aria-hidden>
|
|
5552
|
+
▶
|
|
5553
|
+
</span>
|
|
5554
|
+
<span className="vi-gen-part-meta">
|
|
5555
|
+
<code>{scenePretext.ref.mime}</code>
|
|
5556
|
+
{scenePretext.origin ? ` · ${scenePretext.origin}` : ""}
|
|
5557
|
+
{scenePretext.label ? ` · ${scenePretext.label}` : ""}
|
|
5558
|
+
{" · described server-side as pretext for generate / enhance"}
|
|
5559
|
+
</span>
|
|
5560
|
+
<button
|
|
5561
|
+
type="button"
|
|
5562
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
5563
|
+
onClick={() => {
|
|
5564
|
+
setScenePretext(null);
|
|
5565
|
+
setScenePretextOpen(false);
|
|
5566
|
+
}}
|
|
5567
|
+
aria-label="Remove the pretext video"
|
|
5568
|
+
title="Remove the pretext video"
|
|
5569
|
+
>
|
|
5570
|
+
✕
|
|
5571
|
+
</button>
|
|
5572
|
+
</div>
|
|
5573
|
+
) : (
|
|
5574
|
+
renderVideoPickerBody((it) => {
|
|
5575
|
+
setScenePretext(it);
|
|
5576
|
+
setScenePretextOpen(false);
|
|
5577
|
+
})
|
|
5578
|
+
)}
|
|
5579
|
+
</div>
|
|
5580
|
+
)
|
|
5581
|
+
}
|
|
5582
|
+
/>
|
|
5205
5583
|
{/* GROUP SETTINGS — the settings counterpart to the group prompt
|
|
5206
5584
|
assist: whichever prompt components are selected get these
|
|
5207
5585
|
settings (applied as their own per-part overrides). */}
|
|
@@ -5319,14 +5697,16 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
5319
5697
|
: "Remove"
|
|
5320
5698
|
}
|
|
5321
5699
|
>
|
|
5322
|
-
{/* [Prompt][Negative] tabs over ONE GoalComposer pane
|
|
5323
|
-
the
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
5327
|
-
|
|
5700
|
+
{/* [Prompt][Negative] tabs over ONE GoalComposer pane (labels
|
|
5701
|
+
visually hidden — the tabs say it). k93: EVERY part sends
|
|
5702
|
+
exactly what its own Negative field holds; blank is blank.
|
|
5703
|
+
Part #1's field is also the group assist's shared negative
|
|
5704
|
+
(its tab tooltip says so). The std. tick lives in the
|
|
5705
|
+
assist row below and reads its state from the field. */}
|
|
5328
5706
|
<PromptFieldTabs
|
|
5329
5707
|
idPrefix={`vi-gen-part-${p.key}`}
|
|
5708
|
+
hideLabels
|
|
5709
|
+
hideStdTick
|
|
5330
5710
|
promptLabel={
|
|
5331
5711
|
p.media
|
|
5332
5712
|
? `caption (optional — sent as text just before the ${kind})`
|
|
@@ -5340,120 +5720,275 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
5340
5720
|
: "Describe what to generate…"
|
|
5341
5721
|
}
|
|
5342
5722
|
rows={2}
|
|
5723
|
+
negativeLabel="negative prompt"
|
|
5343
5724
|
negative={p.negative ?? ""}
|
|
5344
5725
|
onNegativeChange={(v) => patchPart(p.key, { negative: v })}
|
|
5345
|
-
negativePlaceholder="what to avoid
|
|
5346
|
-
|
|
5347
|
-
|
|
5348
|
-
|
|
5726
|
+
negativePlaceholder="what to avoid (sent exactly as written; blank = none)"
|
|
5727
|
+
negativeTabTitle={
|
|
5728
|
+
!p.media && sceneTextParts[0]?.key === p.key
|
|
5729
|
+
? "This part's negative. Part #1's Negative field is ALSO sent as the shared negative of the group generate / enhance — explicitly, never merged."
|
|
5730
|
+
: "This part's negative — sent exactly as written with this part's request."
|
|
5731
|
+
}
|
|
5732
|
+
stdNegative={hasStandardNegative(p.negative ?? "")}
|
|
5733
|
+
onStdNegativeChange={(v) => onPartStdChange(p, v)}
|
|
5349
5734
|
negativeDisabledNote={undefined}
|
|
5350
5735
|
negativeExtras={undefined}
|
|
5351
5736
|
/>
|
|
5352
5737
|
|
|
5353
|
-
{/*
|
|
5354
|
-
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
|
|
5358
|
-
|
|
5738
|
+
{/* k93 §B — the per-card assist row:
|
|
5739
|
+
prompt assist model [select]
|
|
5740
|
+
negatives: [on/off] [ ] std. (✨ generate) (✨ enhance)
|
|
5741
|
+
Text parts only — media parts carry captions and ride the
|
|
5742
|
+
group actions as locked context. One in-flight assist at a
|
|
5743
|
+
time across all parts. */}
|
|
5359
5744
|
{!p.media && (
|
|
5360
|
-
<
|
|
5361
|
-
|
|
5362
|
-
|
|
5363
|
-
|
|
5364
|
-
|
|
5365
|
-
|
|
5366
|
-
|
|
5367
|
-
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
|
|
5372
|
-
|
|
5745
|
+
<div className="vi-gen-part-assist-row">
|
|
5746
|
+
<label
|
|
5747
|
+
className="vi-comfy-hint vi-prompt-toolbar-neg"
|
|
5748
|
+
title="On: this card's generate / enhance also write its Negative field. Off: the prompt only."
|
|
5749
|
+
>
|
|
5750
|
+
negatives:
|
|
5751
|
+
<button
|
|
5752
|
+
type="button"
|
|
5753
|
+
className={`vi-btn vi-btn-sm vi-toggle${(p.assistNegatives ?? true) ? " vi-toggle-on" : ""}`}
|
|
5754
|
+
role="switch"
|
|
5755
|
+
aria-checked={p.assistNegatives ?? true}
|
|
5756
|
+
onClick={() =>
|
|
5757
|
+
patchPart(p.key, { assistNegatives: !(p.assistNegatives ?? true) })
|
|
5758
|
+
}
|
|
5759
|
+
>
|
|
5760
|
+
{(p.assistNegatives ?? true) ? "on" : "off"}
|
|
5761
|
+
</button>
|
|
5762
|
+
</label>
|
|
5763
|
+
<label
|
|
5764
|
+
className="vi-comfy-hint vi-prompt-toolbar-std"
|
|
5765
|
+
title="Tick: insert the standard exclusion set into this part's Negative field. Untick: remove exactly those terms. What you see is what is sent."
|
|
5766
|
+
>
|
|
5767
|
+
<input
|
|
5768
|
+
type="checkbox"
|
|
5769
|
+
checked={hasStandardNegative(p.negative ?? "")}
|
|
5770
|
+
onChange={(e) => onPartStdChange(p, e.target.checked)}
|
|
5771
|
+
aria-label={`Insert the standard negative into part ${i + 1}`}
|
|
5772
|
+
/>
|
|
5773
|
+
std.
|
|
5774
|
+
</label>
|
|
5775
|
+
<PromptAssistButtons
|
|
5776
|
+
busy={assist.assistBusy}
|
|
5777
|
+
error={assist.assistError}
|
|
5778
|
+
onDismissError={assist.dismissError}
|
|
5779
|
+
onEnhance={() => void runPartAssist(p, "detail")}
|
|
5780
|
+
onGenerate={() => void runPartAssist(p, "generate")}
|
|
5781
|
+
canEnhance={p.text.trim() !== ""}
|
|
5782
|
+
models={assist.assistModels}
|
|
5783
|
+
model={p.assistModel ?? assist.assistModel}
|
|
5784
|
+
onModelChange={(m) =>
|
|
5785
|
+
patchPart(p.key, { assistModel: m })
|
|
5786
|
+
}
|
|
5787
|
+
/>
|
|
5788
|
+
</div>
|
|
5373
5789
|
)}
|
|
5374
5790
|
|
|
5375
5791
|
{p.media ? (
|
|
5376
|
-
<div className="vi-gen-part-media">
|
|
5377
|
-
|
|
5378
|
-
|
|
5379
|
-
|
|
5380
|
-
|
|
5381
|
-
|
|
5382
|
-
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
|
|
5792
|
+
<div className="vi-gen-part-media-block">
|
|
5793
|
+
<div className="vi-gen-part-media">
|
|
5794
|
+
{kind === "image" ? (
|
|
5795
|
+
<img
|
|
5796
|
+
className="vi-gen-part-thumb"
|
|
5797
|
+
src={mediaBytesUrl(p.media.ref.uri)}
|
|
5798
|
+
alt="Prompt image part"
|
|
5799
|
+
loading="lazy"
|
|
5800
|
+
/>
|
|
5801
|
+
) : (
|
|
5802
|
+
<span className="vi-gen-part-videoicon" aria-hidden>
|
|
5803
|
+
▶
|
|
5804
|
+
</span>
|
|
5805
|
+
)}
|
|
5806
|
+
<span className="vi-gen-part-meta">
|
|
5807
|
+
<code>{p.media.ref.mime}</code>
|
|
5808
|
+
{p.media.origin ? ` · ${p.media.origin}` : ""}
|
|
5809
|
+
{p.media.label ? ` · ${p.media.label}` : ""}
|
|
5810
|
+
{kind === "video"
|
|
5811
|
+
? " · resolved to frames server-side"
|
|
5812
|
+
: ""}
|
|
5387
5813
|
</span>
|
|
5814
|
+
<button
|
|
5815
|
+
type="button"
|
|
5816
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
5817
|
+
onClick={() => detachMedia(p.key)}
|
|
5818
|
+
aria-label="Detach media"
|
|
5819
|
+
title="Detach media (keeps the text)"
|
|
5820
|
+
>
|
|
5821
|
+
✕ media
|
|
5822
|
+
</button>
|
|
5823
|
+
</div>
|
|
5824
|
+
{/* k93 §B.6 — pull ONE frame out of the attached video
|
|
5825
|
+
(frame-extract job → the frame becomes this part's
|
|
5826
|
+
image / start frame AND lands in the library). */}
|
|
5827
|
+
{kind === "video" && (
|
|
5828
|
+
<div className="vi-gen-part-pullframe">
|
|
5829
|
+
<label className="vi-comfy-hint" htmlFor={`vi-gen-pull-${p.key}`}>
|
|
5830
|
+
pull frame @
|
|
5831
|
+
</label>
|
|
5832
|
+
<input
|
|
5833
|
+
id={`vi-gen-pull-${p.key}`}
|
|
5834
|
+
type="number"
|
|
5835
|
+
className="vi-knob-input vi-gen-pull-input"
|
|
5836
|
+
min={0}
|
|
5837
|
+
step={0.1}
|
|
5838
|
+
max={p.media.ref.duration_s ?? undefined}
|
|
5839
|
+
value={pullFrameAt[p.key] ?? "0"}
|
|
5840
|
+
onChange={(e) =>
|
|
5841
|
+
setPullFrameAt((prev) => ({ ...prev, [p.key]: e.target.value }))
|
|
5842
|
+
}
|
|
5843
|
+
aria-label="Time in seconds of the frame to pull"
|
|
5844
|
+
/>
|
|
5845
|
+
<span className="vi-comfy-hint">
|
|
5846
|
+
s{p.media.ref.duration_s != null ? ` / ${p.media.ref.duration_s.toFixed(1)}` : ""}
|
|
5847
|
+
</span>
|
|
5848
|
+
<button
|
|
5849
|
+
type="button"
|
|
5850
|
+
className="vi-btn vi-btn-sm"
|
|
5851
|
+
disabled={pullFrameJobs[p.key] != null}
|
|
5852
|
+
onClick={() => pullFrame(p)}
|
|
5853
|
+
title="Extract the frame at this time (frame-extract job). The still becomes this part's start image and is added to the library."
|
|
5854
|
+
>
|
|
5855
|
+
{pullFrameJobs[p.key] != null ? "pulling…" : "pull frame"}
|
|
5856
|
+
</button>
|
|
5857
|
+
{pullFrameError[p.key] && (
|
|
5858
|
+
<span className="vi-error">{pullFrameError[p.key]}</span>
|
|
5859
|
+
)}
|
|
5860
|
+
</div>
|
|
5861
|
+
)}
|
|
5862
|
+
{p.frame && (
|
|
5863
|
+
<div className="vi-gen-part-media">
|
|
5864
|
+
<img
|
|
5865
|
+
className="vi-gen-part-thumb"
|
|
5866
|
+
src={mediaBytesUrl(p.frame.ref.uri)}
|
|
5867
|
+
alt="Frame pulled from the attached video"
|
|
5868
|
+
loading="lazy"
|
|
5869
|
+
/>
|
|
5870
|
+
<span className="vi-gen-part-meta">
|
|
5871
|
+
<code>{p.frame.ref.mime}</code>
|
|
5872
|
+
{p.frame.label ? ` · ${p.frame.label}` : ""}
|
|
5873
|
+
{" · sent as this part's start image (before the video)"}
|
|
5874
|
+
</span>
|
|
5875
|
+
<button
|
|
5876
|
+
type="button"
|
|
5877
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
5878
|
+
onClick={() => detachFrame(p.key)}
|
|
5879
|
+
aria-label="Drop the pulled frame"
|
|
5880
|
+
title="Drop the pulled frame (keeps the video)"
|
|
5881
|
+
>
|
|
5882
|
+
✕ frame
|
|
5883
|
+
</button>
|
|
5884
|
+
</div>
|
|
5388
5885
|
)}
|
|
5389
|
-
<span className="vi-gen-part-meta">
|
|
5390
|
-
<code>{p.media.ref.mime}</code>
|
|
5391
|
-
{p.media.origin ? ` · ${p.media.origin}` : ""}
|
|
5392
|
-
{p.media.label ? ` · ${p.media.label}` : ""}
|
|
5393
|
-
{kind === "video"
|
|
5394
|
-
? " · resolved to frames server-side"
|
|
5395
|
-
: ""}
|
|
5396
|
-
</span>
|
|
5397
|
-
<button
|
|
5398
|
-
type="button"
|
|
5399
|
-
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
5400
|
-
onClick={() => detachMedia(p.key)}
|
|
5401
|
-
aria-label="Detach media"
|
|
5402
|
-
title="Detach media (keeps the text)"
|
|
5403
|
-
>
|
|
5404
|
-
✕ media
|
|
5405
|
-
</button>
|
|
5406
5886
|
</div>
|
|
5407
5887
|
) : (
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
5411
|
-
|
|
5412
|
-
|
|
5413
|
-
|
|
5414
|
-
|
|
5415
|
-
|
|
5416
|
-
|
|
5417
|
-
|
|
5418
|
-
|
|
5419
|
-
|
|
5420
|
-
|
|
5421
|
-
|
|
5422
|
-
|
|
5423
|
-
|
|
5424
|
-
|
|
5425
|
-
|
|
5426
|
-
|
|
5427
|
-
|
|
5428
|
-
|
|
5429
|
-
|
|
5430
|
-
|
|
5431
|
-
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
5435
|
-
|
|
5436
|
-
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
|
|
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
|
+
</>
|
|
5440
5954
|
)}
|
|
5441
5955
|
|
|
5442
5956
|
{/* IDENTITY — the generation-wide character, selectable from
|
|
5443
|
-
ANY part
|
|
5444
|
-
|
|
5445
|
-
|
|
5446
|
-
|
|
5447
|
-
|
|
5448
|
-
|
|
5449
|
-
|
|
5450
|
-
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
5454
|
-
|
|
5455
|
-
|
|
5456
|
-
|
|
5957
|
+
ANY part because ONE identity is shared by the whole
|
|
5958
|
+
generation. k93 §B.7: rendered as a single `+ identity`
|
|
5959
|
+
button that expands inline (same row idiom); shows bound
|
|
5960
|
+
when one is set. */}
|
|
5961
|
+
<div className="vi-gen-part-attach">
|
|
5962
|
+
<button
|
|
5963
|
+
type="button"
|
|
5964
|
+
className={`vi-btn vi-btn-sm vi-btn-ghost${identityOpen.has(p.key) || boundIdentity ? " vi-btn-on" : ""}`}
|
|
5965
|
+
aria-expanded={identityOpen.has(p.key)}
|
|
5966
|
+
onClick={() =>
|
|
5967
|
+
setIdentityOpen((prev) => {
|
|
5968
|
+
const next = new Set(prev);
|
|
5969
|
+
if (next.has(p.key)) next.delete(p.key);
|
|
5970
|
+
else next.add(p.key);
|
|
5971
|
+
return next;
|
|
5972
|
+
})
|
|
5973
|
+
}
|
|
5974
|
+
title="Bind an identity profile (one per generation — shared by every part)"
|
|
5975
|
+
>
|
|
5976
|
+
{boundIdentity ? `+ identity ✓ ${boundIdentity.name}` : "+ identity"}
|
|
5977
|
+
</button>
|
|
5978
|
+
</div>
|
|
5979
|
+
{identityOpen.has(p.key) && (
|
|
5980
|
+
<div className="vi-gen-inline-attach">
|
|
5981
|
+
<GenIdentityBar
|
|
5982
|
+
profiles={identityProfiles}
|
|
5983
|
+
loading={identityLoading}
|
|
5984
|
+
error={identityError}
|
|
5985
|
+
bound={boundIdentity}
|
|
5986
|
+
onBind={bindIdentity}
|
|
5987
|
+
onClear={clearIdentity}
|
|
5988
|
+
disabled={running}
|
|
5989
|
+
/>
|
|
5990
|
+
</div>
|
|
5991
|
+
)}
|
|
5457
5992
|
|
|
5458
5993
|
{renderPartSettings(p)}
|
|
5459
5994
|
{renderPartResult(p)}
|
|
@@ -5476,7 +6011,7 @@ export function GenerateStation({ spec }: { spec: StationSpec }) {
|
|
|
5476
6011
|
contract, and scene's own knobs (frames/motion/fps/assemble/strength/
|
|
5477
6012
|
chain) stay in the rail because they describe the SCENE, not the frame.
|
|
5478
6013
|
|
|
5479
|
-
The ticks decide what a component seeded from this one
|
|
6014
|
+
The ticks decide what a component seeded from this one receives; in
|
|
5480
6015
|
scene mode generation itself just reads the values as it always has.
|
|
5481
6016
|
|
|
5482
6017
|
k89: the strip stays STATION-WIDE (it configures the single
|