Pieces B and C: Scene gains source_image (aspect-ratio reference only -- resolution itself stays a project.video setting, ImageTransformKJ's "total_pixels" keep_proportion fits the picture's aspect into that budget) and references (media ids, in order). Inspector's Scene tab gets pick/clear/ remove controls; picking routes through the existing Media bin via a small pending-pick handshake in boot.js (one bin, one onPick, so "which field asked" has to be tracked between the click that asked and the click that answered). References wire into MiniMaxH3ReferenceToVideo's ref_images autogrow input dynamically, per run, only for what a scene actually has -- an unwired slot, not an empty image, is what "not in use" means to that node. The target is data on the role itself (wireTo), not a slot id boot.js would otherwise have to know, keeping the "core never names an implementation" contract intact one layer up into the app. Verifying this exposed a second real core gap (after the MatchType one): core/graph.py had no idea what an Autogrow (V3's io.Autogrow) input even is -- "ref_images" reported as one opaque COMFY_AUTOGROW_V3 slot, its real per-instance sockets (ref_image_0, ref_image_1, ...) invisible to declared()/build(), so a wire naming one was refused as an input the node does not have. Fixed generically in comfy_types.py (autogrow_instances()) and graph.py, confirmed against ComfyUI's own _io.py (finalize_prefix's dotted join, build_nested_inputs's runtime un-nesting) -- not guessed, and proven end to end with a real override()+build() test using the exact edit boot.js makes.
277 lines
13 KiB
JavaScript
277 lines
13 KiB
JavaScript
// The inspector: what the PROJECT is, and what THIS SCENE is.
|
|
//
|
|
// Two tabs because they are two different things and v4 proved that mixing them
|
|
// is how a per-scene value quietly becomes the project's. The rule this file
|
|
// exists to make visible: a regenerate reads the PROJECT, and a crop is the
|
|
// scene's own -- so Length appears in both places and means something different
|
|
// in each, and both say so.
|
|
|
|
import { composer } from "../composer/composer.js";
|
|
|
|
// Two, because two is all anything downstream reads: what learns from these
|
|
// takes the sign of a rating and never its size. A finer scale would be the UI
|
|
// claiming a precision nothing uses.
|
|
const RATINGS = [
|
|
{ value: "liked", label: "Liked" },
|
|
{ value: "disliked", label: "Disliked" },
|
|
];
|
|
|
|
/**
|
|
* A "✕" that removes something the moment it is pressed, not the moment a
|
|
* browser would normally call it clicked -- for a MOUSE press. Between a
|
|
* text field's blur-commit and its own click, a browser blurs the field
|
|
* FIRST -- and a commit here triggers a full, non-keyed redraw of this whole
|
|
* list (draw() -> node.replaceChildren()). Wired only to `onClick`, pressing
|
|
* ✕ right after typing in ANOTHER row's field destroys this exact button
|
|
* mid-gesture: the field's blur/commit/redraw runs before the click the
|
|
* mousedown started ever reaches it, so the press does nothing and says
|
|
* nothing. `mousedown` fires first, and calling `onRemove` there -- after
|
|
* `preventDefault()` stops the browser's own focus-move, and so the blur it
|
|
* would otherwise cause -- removes the row before a redraw gets the chance
|
|
* to pull the rug out from under this button itself.
|
|
*
|
|
* `onClick` stays wired too, and is what a KEYBOARD Enter/Space reaches --
|
|
* no mousedown precedes those, only a synthesised `click`. A real mouse
|
|
* click still fires both: `onRemove` must tolerate being called twice for
|
|
* the one press (its own reference-lookup guard makes the second call, on
|
|
* an already-removed row, a safe no-op -- see the callers below).
|
|
*/
|
|
function removeButton(onRemove) {
|
|
const btn = composer.button.sm({ label: "✕", tone: "danger", onClick: onRemove });
|
|
btn.node.addEventListener("mousedown", (e) => { e.preventDefault(); onRemove(); });
|
|
return btn;
|
|
}
|
|
|
|
export function createInspector({ project, onRename, onPickSourceImage, onPickReference } = {}) {
|
|
const host = composer.region.stack({ gap: "sm", fill: true });
|
|
let tab = "scene";
|
|
|
|
const tabs = composer.tabs.underline({
|
|
value: tab, label: "Inspector",
|
|
tabs: [{ value: "scene", label: "Scene" }, { value: "project", label: "Project" }],
|
|
onChange: (next) => { tab = next; draw(); },
|
|
});
|
|
|
|
const rows = composer.region.stack({ gap: "sm", label: "Inspector rows" });
|
|
// A STABLE host for whatever the pipeline declares at "project.negative" --
|
|
// stable because offer() is wired to it once, at startup, while `rows` is
|
|
// torn down and rebuilt by every draw() below. A field appended into a node
|
|
// that gets replaced on the next scene selection would not survive it.
|
|
const negative = composer.region.stack({ gap: "sm", label: "Negative prompt" });
|
|
host.set([tabs, rows, negative]);
|
|
// Every `.cx-stack` (composer/elements/layout.css) allows itself to shrink
|
|
// below its own content's height -- `host` is also a FILL stack (flex:1 1
|
|
// auto), so it competes for room with its OWN siblings in layout.js's outer
|
|
// Properties column (the Generation section, the Settings collapsible) the
|
|
// same way `rows`/`negative` compete with each other inside it. Without
|
|
// flex-shrink:0 at every one of these levels, once the Project tab grew
|
|
// past a couple of settings rows (Prompt craft, Variables), the column
|
|
// squeezed whichever stack ran out of room below its own content instead
|
|
// of growing past it -- and the spilled-over content landed on top of
|
|
// whatever sits next in the flow, up to and including swallowing that
|
|
// sibling's own clicks. flex-grow stays (fill:true still means "take any
|
|
// LEFTOVER room" when this tab's content is short); only shrinking below
|
|
// content is refused.
|
|
host.node.style.flexShrink = "0";
|
|
rows.node.style.flexShrink = "0";
|
|
negative.node.style.flexShrink = "0";
|
|
|
|
function sceneRows() {
|
|
const scene = project.selected;
|
|
if (!scene) {
|
|
return [composer.emptyState.default({
|
|
icon: "▭", title: "No scene", hint: "Add one on the timeline." })];
|
|
}
|
|
const at = project.scenes.indexOf(scene) + 1;
|
|
return [
|
|
composer.label.section({ text: `Scene ${at}` }),
|
|
composer.settingsRow.default({
|
|
label: "Length on the timeline",
|
|
// The rule, said where it applies: this is a crop, and a regenerate
|
|
// does not read it.
|
|
hint: "Frames this clip plays for. Regenerating uses the project's length.",
|
|
control: composer.number.md({
|
|
value: scene.length ?? project.video.length ?? 1, min: 1, label: "Length",
|
|
onChange: (v) => project.setScene(scene.id, "length", v),
|
|
}),
|
|
}),
|
|
composer.settingsRow.default({
|
|
label: "Rating",
|
|
hint: "Liked or not. Press it again to take it back.",
|
|
control: composer.buttonGroup.md({
|
|
label: "Rating", value: scene.rating || undefined, items: RATINGS,
|
|
// Pressing the one that is on clears it. Without a way back, a
|
|
// mis-click is a permanent opinion -- and "no opinion" is a real
|
|
// answer that nothing downstream should be given a guess about.
|
|
onChange: (v) => {
|
|
const next = v === scene.rating ? null : v;
|
|
project.setScene(scene.id, "rating", next);
|
|
draw();
|
|
},
|
|
}),
|
|
}),
|
|
composer.settingsRow.default({
|
|
label: "Result",
|
|
hint: scene.result ? "This scene has been generated." : "Not generated yet.",
|
|
}),
|
|
composer.label.section({ text: "Reference media" }),
|
|
composer.settingsRow.default({
|
|
label: "Resolution source",
|
|
hint: scene.source_image
|
|
? "Sets this scene's aspect ratio. The project's own Width/Height set the actual resolution; its pixels are not used."
|
|
: "Pick an image in the Media bin to set this scene's aspect ratio.",
|
|
control: composer.toolbar.default({ label: "Resolution source", items: [
|
|
composer.button.sm({ label: scene.source_image ? "Change" : "Pick…",
|
|
onClick: () => onPickSourceImage && onPickSourceImage(scene.id) }),
|
|
...(scene.source_image ? [removeButton(
|
|
() => project.setSourceImage(scene.id, null))] : []),
|
|
] }),
|
|
}),
|
|
composer.label.section({ text: "References" }),
|
|
...scene.references.map((mediaId, i) => composer.settingsRow.default({
|
|
label: `Reference ${i + 1}`,
|
|
control: composer.toolbar.default({ label: `Reference ${i + 1}`, items: [
|
|
composer.hint.default({ text: mediaId }),
|
|
removeButton(() => project.setReferences(
|
|
scene.id, scene.references.filter((_, j) => j !== i))),
|
|
] }),
|
|
})),
|
|
composer.button.md({ label: "+ Add reference",
|
|
onClick: () => onPickReference && onPickReference(scene.id) }),
|
|
];
|
|
}
|
|
|
|
function projectRows() {
|
|
const open = project.project;
|
|
if (!open) return [composer.hint.default({ text: "No project is open." })];
|
|
const vars = project.variables;
|
|
return [
|
|
composer.label.section({ text: "Project" }),
|
|
composer.settingsRow.default({
|
|
label: "Name",
|
|
control: composer.input.md({
|
|
value: open.name, label: "Project name",
|
|
onCommit: (v) => { if (onRename) onRename(v); },
|
|
}),
|
|
}),
|
|
composer.settingsRow.default({
|
|
label: "Scenes", hint: `${project.scenes.length} on the timeline`,
|
|
}),
|
|
composer.hint.default({
|
|
text: "Size and length live in the Constructor: they are what every scene "
|
|
+ "is generated at.",
|
|
}),
|
|
composer.label.section({ text: "Prompt craft" }),
|
|
composer.field.default({
|
|
label: "Anchor",
|
|
hint: "Prepended to every scene's prompt at generation.",
|
|
control: composer.textarea.md({
|
|
value: project.anchor, rows: 2, placeholder: "cinematic, shared subject/setting",
|
|
onCommit: (v) => project.setAnchor(v),
|
|
}),
|
|
}),
|
|
composer.field.default({
|
|
label: "Postfix",
|
|
hint: "Appended to every scene's prompt, while the toggle below is on.",
|
|
control: composer.textarea.md({
|
|
value: project.postfix, rows: 2, placeholder: "4k, high detail",
|
|
onCommit: (v) => project.setPostfix(v),
|
|
}),
|
|
}),
|
|
composer.toggle.default({
|
|
label: "Postfix enabled", checked: project.postfixEnabled,
|
|
onChange: (v) => project.setPostfixEnabled(v),
|
|
}),
|
|
composer.label.section({ text: "Variables" }),
|
|
composer.hint.default({ text: "$name in the anchor, a scene, or the postfix is replaced with its value." }),
|
|
// Every write below reads `project.variables` FRESH at commit time
|
|
// (never the `vars` snapshot this render closed over) and hands
|
|
// setVariables a brand new array -- necessary for two reasons:
|
|
//
|
|
// 1. setVariables's own remember() clones `project` for undo BEFORE
|
|
// applying the new list, so that clone must still show the OLD
|
|
// values. An earlier version mutated the shared row object in
|
|
// place and passed the SAME array back -- by the time remember()
|
|
// cloned it, the "before" snapshot already held the new value, and
|
|
// Undo after any variable edit silently did nothing.
|
|
// 2. setVariables's changed() triggers boot.js's `inspector.draw()`,
|
|
// rebuilding every field here (including ones not being edited)
|
|
// before a second, still-pending commit on the same row can fire.
|
|
// Reading `project.variables` fresh (rather than the `vars` this
|
|
// closure captured at render time) means that second commit builds
|
|
// its own edit on top of whatever the first commit already saved.
|
|
//
|
|
// Rows are addressed by the ROW OBJECT ITSELF (found by reference in
|
|
// the current array), never by the index this render closed over.
|
|
// Removing row0 shrinks the array, so a commit still in flight for a
|
|
// LATER row would write to the wrong slot under its old index -- and
|
|
// that is not just two clicks in quick succession: removeButton's own
|
|
// mousedown handler triggers a synchronous redraw that detaches
|
|
// whatever field still has an uncommitted edit, and a browser fires
|
|
// `blur` on a detached-while-focused element even with the mousedown's
|
|
// default prevented. That blur commits REENTRANTLY, inside the very
|
|
// setVariables() call that is still removing a row, indexed against an
|
|
// array that has already changed shape underneath it -- including when
|
|
// the row being removed IS the one with the uncommitted edit, where the
|
|
// reentrant commit would otherwise silently overwrite whatever row now
|
|
// sits at that same numeric slot. Looking the row up by reference makes
|
|
// a commit for a row no longer in the array a no-op instead.
|
|
...vars.map((row) => {
|
|
const commit = (patch) => {
|
|
const current = project.variables;
|
|
const at = current.indexOf(row);
|
|
if (at === -1) return; // this row is already gone
|
|
project.setVariables(current.map((x, j) => (j === at ? { ...x, ...patch } : x)));
|
|
};
|
|
const remove = () => {
|
|
const current = project.variables;
|
|
if (!current.includes(row)) return; // already removed (see removeButton)
|
|
project.setVariables(current.filter((x) => x !== row));
|
|
};
|
|
return composer.settingsRow.default({
|
|
label: `$${row.name || "…"}`,
|
|
control: composer.toolbar.default({ label: `$${row.name}`, items: [
|
|
composer.input.md({
|
|
value: row.name, placeholder: "name",
|
|
onCommit: (name) => commit({ name: name.replace(/^\$+/, "") }),
|
|
}),
|
|
composer.input.md({
|
|
value: row.value, placeholder: "value",
|
|
onCommit: (value) => commit({ value }),
|
|
}),
|
|
removeButton(remove),
|
|
] }),
|
|
});
|
|
}),
|
|
composer.button.md({ label: "+ Add variable",
|
|
onClick: () => project.setVariables([...project.variables, { name: "", value: "" }]) }),
|
|
];
|
|
}
|
|
|
|
function draw() {
|
|
tabs.setValue(tab);
|
|
// Whatever mounted at "project.negative" belongs to the project, not the
|
|
// scene -- shown only on the tab that means "the project".
|
|
negative.node.hidden = tab !== "project";
|
|
// Wrapped in a group, which is what draws the card AND is the container the
|
|
// stacking rule measures: settings rows outside one keep their 180px control
|
|
// column whatever the width, and a four-button group is then clipped.
|
|
const [head, ...body] = tab === "scene" ? sceneRows() : projectRows();
|
|
rows.set([head, composer.group.default({ rows: body })]);
|
|
}
|
|
|
|
draw();
|
|
|
|
return {
|
|
node: host.node,
|
|
draw,
|
|
/** Which tab is showing. Tests and the wheel both ask. */
|
|
get tab() { return tab; },
|
|
show(next) { tab = next; draw(); },
|
|
destroy() { host.destroy(); },
|
|
/** Where layout.js offers "project.negative" -- a project-level role,
|
|
* not a per-run one, so it lives beside Name/Scenes, not the prompt. */
|
|
negativeHost: negative,
|
|
};
|
|
}
|