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.
320 lines
13 KiB
JavaScript
320 lines
13 KiB
JavaScript
// The parts added to make v5 an app rather than a generate button: the action
|
|
// registry and the wheel that shows it, the inspector, and the way in.
|
|
//
|
|
// One file, because each is small and they are all the same shape: something
|
|
// announces, something else draws whatever announced.
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { setupDom, teardownDom, fire } from "../../composer/tests/_dom.js";
|
|
|
|
let actions, createWheel, createInspector, openWizard;
|
|
test.before(async () => {
|
|
setupDom();
|
|
await import("../../composer/composer.js");
|
|
actions = await import("../actions.js");
|
|
({ createWheel } = await import("../wheel.js"));
|
|
({ createInspector } = await import("../inspector.js"));
|
|
({ open: openWizard } = await import("../wizard.js"));
|
|
});
|
|
test.after(() => teardownDom());
|
|
|
|
// --- actions ----------------------------------------------------------------
|
|
|
|
test("an action is offered by whoever owns it, and taken away with it", () => {
|
|
actions._reset();
|
|
const ran = [];
|
|
const drop = actions.offerAction({ id: "go", label: "Go", run: () => ran.push("go") });
|
|
|
|
assert.deepEqual(actions.offered().map((a) => a.id), ["go"]);
|
|
assert.equal(actions.run("go"), true);
|
|
assert.deepEqual(ran, ["go"]);
|
|
|
|
drop();
|
|
assert.deepEqual(actions.offered(), []);
|
|
// Not an error: whatever offered it may simply be gone.
|
|
assert.equal(actions.run("go"), false);
|
|
});
|
|
|
|
test("one broken action does not take down the list that showed it", () => {
|
|
actions._reset();
|
|
actions.offerAction({ id: "bad", run: () => { throw new Error("nope"); } });
|
|
actions.offerAction({ id: "good", run: () => {} });
|
|
|
|
assert.equal(actions.run("bad"), false);
|
|
assert.equal(actions.run("good"), true, "a neighbour's failure stopped this one");
|
|
});
|
|
|
|
test("offering the same id twice replaces it", () => {
|
|
// Two entries that look identical and do different things is worse than one.
|
|
actions._reset();
|
|
const ran = [];
|
|
actions.offerAction({ id: "go", label: "First", run: () => ran.push(1) });
|
|
actions.offerAction({ id: "go", label: "Second", run: () => ran.push(2) });
|
|
|
|
assert.equal(actions.offered().length, 1);
|
|
assert.equal(actions.offered()[0].label, "Second");
|
|
actions.run("go");
|
|
assert.deepEqual(ran, [2]);
|
|
});
|
|
|
|
// --- the wheel --------------------------------------------------------------
|
|
|
|
const middle = (node, init = {}) => node.dispatchEvent(
|
|
new window.MouseEvent("mousedown", { button: 1, bubbles: true, cancelable: true, ...init }));
|
|
|
|
test("the middle button opens the wheel on whatever is offered", () => {
|
|
actions._reset();
|
|
actions.offerAction({ id: "generate", label: "Generate", run: () => {} });
|
|
actions.offerAction({ id: "cancel", label: "Cancel", run: () => {} });
|
|
const wheel = createWheel();
|
|
|
|
middle(document.body);
|
|
assert.equal(wheel.isOpen, true);
|
|
assert.match(document.body.textContent, /Generate/);
|
|
wheel.close();
|
|
assert.equal(wheel.isOpen, false);
|
|
wheel.destroy();
|
|
});
|
|
|
|
test("a wheel of one is not a wheel", () => {
|
|
// Every action comes from a part of the app that loaded, so "only one" is a
|
|
// real state -- and there is nothing to aim between. The kit refuses it; no
|
|
// wheel is a better answer than an error thrown under the pointer.
|
|
actions._reset();
|
|
const wheel = createWheel();
|
|
middle(document.body);
|
|
assert.equal(wheel.isOpen, false, "a wheel opened with nothing in it");
|
|
|
|
actions.offerAction({ id: "a", run: () => {} });
|
|
middle(document.body);
|
|
assert.equal(wheel.isOpen, false, "a wheel opened with one item in it");
|
|
wheel.destroy();
|
|
});
|
|
|
|
test("the middle button's own behaviour is prevented", () => {
|
|
// Otherwise Chrome starts autoscroll under the wheel.
|
|
actions._reset();
|
|
actions.offerAction({ id: "a", run: () => {} });
|
|
actions.offerAction({ id: "b", run: () => {} });
|
|
const wheel = createWheel();
|
|
const event = new window.MouseEvent("mousedown", { button: 1, bubbles: true, cancelable: true });
|
|
document.body.dispatchEvent(event);
|
|
assert.equal(event.defaultPrevented, true);
|
|
wheel.destroy();
|
|
});
|
|
|
|
test("a wheel that is already open is not opened again", () => {
|
|
actions._reset();
|
|
actions.offerAction({ id: "a", run: () => {} });
|
|
actions.offerAction({ id: "b", run: () => {} });
|
|
const wheel = createWheel();
|
|
middle(document.body);
|
|
const first = document.querySelectorAll(".cx-wheel").length;
|
|
middle(document.body);
|
|
assert.equal(document.querySelectorAll(".cx-wheel").length, first);
|
|
wheel.destroy();
|
|
});
|
|
|
|
// --- the inspector ----------------------------------------------------------
|
|
|
|
function fakeProject(over = {}) {
|
|
const scene = { id: "aaaaaaaaaaaa", text: "", result: null, length: null, rating: null,
|
|
source_image: null, references: [] };
|
|
const written = [];
|
|
return {
|
|
written,
|
|
project: { id: "p", name: "Untitled" },
|
|
scenes: [scene],
|
|
selectedId: scene.id,
|
|
selected: scene,
|
|
video: { length: 97 },
|
|
anchor: "", postfix: "", postfixEnabled: true, variables: [],
|
|
setScene: (id, key, value) => { written.push([id, key, value]); scene[key] = value; },
|
|
rename: (name) => written.push(["project", "name", name]),
|
|
setAnchor: (v) => written.push(["project", "anchor", v]),
|
|
setPostfix: (v) => written.push(["project", "postfix", v]),
|
|
setPostfixEnabled: (v) => written.push(["project", "postfix_enabled", v]),
|
|
setVariables: (v) => written.push(["project", "variables", v]),
|
|
setSourceImage: (id, mediaId) => { written.push([id, "source_image", mediaId]); scene.source_image = mediaId; },
|
|
setReferences: (id, refs) => { written.push([id, "references", refs]); scene.references = refs; },
|
|
...over,
|
|
};
|
|
}
|
|
|
|
test("the scene tab edits the scene, and says the crop is not what regenerates", () => {
|
|
const project = fakeProject();
|
|
const inspector = createInspector({ project });
|
|
document.body.replaceChildren(inspector.node);
|
|
|
|
assert.match(inspector.node.textContent, /Regenerating uses the project's length/);
|
|
const length = inspector.node.querySelector('input[type="number"]');
|
|
assert.equal(length.value, "97", "a scene with no crop of its own shows the project's length");
|
|
|
|
length.value = "48";
|
|
fire(length, "blur");
|
|
assert.deepEqual(project.written.at(-1), [project.selected.id, "length", 48]);
|
|
});
|
|
|
|
test("a rating is kept with the scene, and can be taken back", () => {
|
|
// Binary, because the sign is all anything downstream reads. And reversible:
|
|
// without a way back a mis-click is a permanent opinion, and "no opinion" is
|
|
// a real answer nothing should have to guess at.
|
|
const project = fakeProject();
|
|
const inspector = createInspector({ project });
|
|
document.body.replaceChildren(inspector.node);
|
|
|
|
const press = (label) => fire([...inspector.node.querySelectorAll("button")]
|
|
.find((b) => b.textContent === label), "click");
|
|
|
|
press("Liked");
|
|
assert.deepEqual(project.written.at(-1), [project.selected.id, "rating", "liked"]);
|
|
press("Disliked");
|
|
assert.deepEqual(project.written.at(-1), [project.selected.id, "rating", "disliked"]);
|
|
press("Disliked");
|
|
assert.deepEqual(project.written.at(-1), [project.selected.id, "rating", null]);
|
|
});
|
|
|
|
test("the project tab renames the project", () => {
|
|
const project = fakeProject();
|
|
const renamed = [];
|
|
const inspector = createInspector({ project, onRename: (n) => renamed.push(n) });
|
|
document.body.replaceChildren(inspector.node);
|
|
|
|
inspector.show("project");
|
|
const name = inspector.node.querySelector('input[type="text"]');
|
|
assert.equal(name.value, "Untitled");
|
|
name.value = "Rooftops";
|
|
fire(name, "blur");
|
|
assert.deepEqual(renamed, ["Rooftops"]);
|
|
});
|
|
|
|
test("whatever mounted at project.negative shows only on the Project tab", () => {
|
|
// The host has to survive a redraw (a scene selection, a rating) without
|
|
// losing whatever a module appended into it -- it lives OUTSIDE the part
|
|
// of the tree draw() tears down and rebuilds.
|
|
const inspector = createInspector({ project: fakeProject() });
|
|
document.body.replaceChildren(inspector.node);
|
|
const marker = document.createElement("div");
|
|
marker.textContent = "Negative prompt field";
|
|
inspector.negativeHost.node.appendChild(marker);
|
|
|
|
assert.equal(inspector.negativeHost.node.hidden, true, "showing on the Scene tab");
|
|
inspector.show("project");
|
|
assert.equal(inspector.negativeHost.node.hidden, false);
|
|
assert.ok(inspector.node.contains(marker), "the field did not survive the tab switch");
|
|
|
|
inspector.show("scene");
|
|
assert.equal(inspector.negativeHost.node.hidden, true);
|
|
assert.ok(inspector.node.contains(marker), "the field did not survive switching away and back");
|
|
});
|
|
|
|
test("editing a variable never mutates the array a caller already holds a reference to", () => {
|
|
// The real projects.js store's setVariables() calls remember() -- which
|
|
// deep-clones `project` for the undo stack -- BEFORE applying the new
|
|
// list. If a caller mutated the array project.variables already points at
|
|
// and handed that SAME array back, remember()'s clone would capture the
|
|
// ALREADY-EDITED values as the "before" state, and Undo would restore
|
|
// exactly what is already on screen: a silent no-op. This fixture stands
|
|
// in for that hazard by keeping its own reference to the array from
|
|
// before the edit and checking it is untouched afterward.
|
|
const before = [{ name: "a", value: "1" }];
|
|
let current = before;
|
|
const project = fakeProject({
|
|
variables: before,
|
|
setVariables: (list) => { current = list; },
|
|
});
|
|
Object.defineProperty(project, "variables", { get: () => current });
|
|
const inspector = createInspector({ project });
|
|
document.body.replaceChildren(inspector.node);
|
|
inspector.show("project");
|
|
|
|
const nameInput = inspector.node.querySelector('input[placeholder="name"]');
|
|
nameInput.value = "b";
|
|
fire(nameInput, "change");
|
|
|
|
assert.notEqual(current, before, "the same array was handed back to setVariables");
|
|
assert.deepEqual(before, [{ name: "a", value: "1" }],
|
|
"a reference taken before the edit changed value out from under it");
|
|
});
|
|
|
|
test("picking a resolution source calls back with the scene it is for", () => {
|
|
const project = fakeProject();
|
|
const picked = [];
|
|
const inspector = createInspector({ project, onPickSourceImage: (id) => picked.push(id) });
|
|
document.body.replaceChildren(inspector.node);
|
|
|
|
const pick = [...inspector.node.querySelectorAll("button")].find((b) => b.textContent === "Pick…");
|
|
fire(pick, "click");
|
|
assert.deepEqual(picked, [project.selected.id]);
|
|
});
|
|
|
|
test("a picked resolution source shows Change and can be cleared", () => {
|
|
const project = fakeProject({ scenes: [{ id: "aaaaaaaaaaaa", text: "", result: null,
|
|
length: null, rating: null, source_image: "abcdef012345", references: [] }] });
|
|
project.selected = project.scenes[0];
|
|
const inspector = createInspector({ project });
|
|
document.body.replaceChildren(inspector.node);
|
|
|
|
assert.match(inspector.node.textContent, /Change/);
|
|
const clear = [...inspector.node.querySelectorAll("button")].find((b) => b.textContent === "✕");
|
|
fire(clear, "click");
|
|
assert.deepEqual(project.written.at(-1), [project.selected.id, "source_image", null]);
|
|
});
|
|
|
|
test("adding a reference calls back, and each one can be removed on its own", () => {
|
|
const project = fakeProject({ scenes: [{ id: "aaaaaaaaaaaa", text: "", result: null,
|
|
length: null, rating: null, source_image: null, references: ["111111111111", "222222222222"] }] });
|
|
project.selected = project.scenes[0];
|
|
const picked = [];
|
|
const inspector = createInspector({ project, onPickReference: (id) => picked.push(id) });
|
|
document.body.replaceChildren(inspector.node);
|
|
|
|
assert.match(inspector.node.textContent, /111111111111/);
|
|
assert.match(inspector.node.textContent, /222222222222/);
|
|
|
|
const add = [...inspector.node.querySelectorAll("button")].find((b) => b.textContent === "+ Add reference");
|
|
fire(add, "click");
|
|
assert.deepEqual(picked, [project.selected.id]);
|
|
|
|
// The FIRST reference's own ✕ -- removing it must not touch the second,
|
|
// even though both are plain strings and a value-based removal could not
|
|
// tell two identical references apart.
|
|
const removes = [...inspector.node.querySelectorAll("button")].filter((b) => b.textContent === "✕");
|
|
fire(removes[0], "click");
|
|
assert.deepEqual(project.written.at(-1), [project.selected.id, "references", ["222222222222"]]);
|
|
});
|
|
|
|
test("with no scene selected the inspector says so instead of drawing nothing", () => {
|
|
const inspector = createInspector({ project: fakeProject({ selected: null, scenes: [] }) });
|
|
document.body.replaceChildren(inspector.node);
|
|
assert.match(inspector.node.textContent, /No scene/);
|
|
});
|
|
|
|
// --- the way in -------------------------------------------------------------
|
|
|
|
test("the wizard can be left, and leaving is an answer", () => {
|
|
// v4's could not be dismissed: a page refresh was the only way out, which
|
|
// makes it a trap rather than a wizard.
|
|
const picked = [];
|
|
const window_ = openWizard({ onPick: (id) => picked.push(id) });
|
|
assert.ok(document.querySelector(".cx-modal"));
|
|
|
|
window_.close("dismissed");
|
|
assert.deepEqual(picked, [null], "dismissing said nothing at all");
|
|
assert.equal(document.querySelector(".cx-modal"), null);
|
|
});
|
|
|
|
test("a choice is answered once, however it was closed", () => {
|
|
const picked = [];
|
|
const window_ = openWizard({ onPick: (id) => picked.push(id) });
|
|
const card = [...document.querySelectorAll(".cx-card")]
|
|
.find((c) => /A few scenes/.test(c.textContent));
|
|
fire(card, "click");
|
|
|
|
assert.deepEqual(picked, ["scenes"]);
|
|
window_.close("again");
|
|
assert.deepEqual(picked, ["scenes"], "closing after a choice answered a second time");
|
|
});
|