app: the dials on the main window, and a way to have two projects

Steps, Sampler and Scheduler are on the main window -- the three v4 buried
several clicks deep in Engine Settings. No new UI: the sampler slot declares
roles the way the prompt and the size already do.

What made that nearly free is that the controls now come from the NODE's own
description, through the settingFor/rendererFor pair the pipeline window has
always used. So a combo is a dropdown of the real choices and a number obeys
the real bounds, and prompt.js lost both of its hand-rolled branches: it no
longer decides what a control looks like, it asks.

File: New project, and every project listed with the open one ticked. The
store has been able to do this since the day it arrived and there was no way
in -- 20 lines of menu.

Three faults found on the way, all in the shared input helper:

* ENTER COULD NOT BE TYPED IN A TEXTAREA. `wire()` bound Enter to commit for
  every text control, textareas included, so a multi-line value was
  impossible -- the prompt above all, which is the longest multi-line thing
  in the app. It has been that way in the pipeline window since it was
  written; nobody had typed a second line into one.
* A control committed on blur alone, so autofill, a password manager and any
  script setting a value went unheard. It listens to `change` as well now,
  and commit() only acts on a real difference so the pair costs nothing.
* A control commits on blur, which means text still being typed is not in
  the project yet -- and a tab closing is exactly when that is lost. boot
  blurs before it saves.
This commit is contained in:
digital-garbage
2026-09-06 12:18:33 +03:00
parent 77f72bbaee
commit 33eeff08e3
12 changed files with 296 additions and 125 deletions
+28 -2
View File
@@ -71,6 +71,24 @@ async function start() {
onGenerate: () => { ranFor = project.selectedId; session.generate(); },
onCancel: () => run.cancel(),
onConstructor: () => page.constructor.open(),
projects: () => project.recent,
currentProject: () => (project.project ? project.project.id : null),
onProject: async (id) => {
if (id === "new") {
const asked = composer.modal.prompt({
title: "New project", label: "Name", value: "Untitled", confirmLabel: "Create",
});
const name = await asked.result;
if (name === null) return; // cancelled
await project.newProject(name);
} else if (id !== (project.project || {}).id) {
await project.open(id);
} else {
return; // already open
}
// The prompt box follows the project, the same way it follows a scene.
showScene(project.selected);
},
onPipeline: () => openPipeline({
load, describe, check, search,
onApply: (next) => {
@@ -97,7 +115,12 @@ async function start() {
// Saved on the way out. A debounce that has not fired yet is work the user
// did and cannot see anywhere, and a reload is exactly when it is lost.
window.addEventListener("pagehide", () => { project.flush(); });
window.addEventListener("pagehide", () => {
// Blur first: a control commits on blur, so text still being typed is not
// in the project yet -- and this is exactly the moment it would be lost.
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
project.flush();
});
let manifest;
try {
@@ -118,7 +141,10 @@ async function start() {
// After the modules, because a region has to exist before anything can be put
// in it, and a module may be sharing the region a role names.
try {
prompts = createPrompts((await load()).slots, {
prompts = await createPrompts((await load()).slots, {
// Controls come from the node's own description: a combo gets the node's
// real choices, a number its real bounds.
describe,
// Typing in the box writes to the scene it belongs to. Without this the
// prompt is a value the run uses and the project never hears about, so a
// reload shows a timeline whose scenes are all empty.
+10 -1
View File
@@ -14,7 +14,16 @@ function wire(node, { onInput, onCommit }) {
let last = node.value;
const commit = () => { if (node.value !== last) { last = node.value; onCommit(node.value); } };
node.addEventListener("blur", commit);
node.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); commit(); } });
// And `change`, which is what autofill, a password manager and a script
// setting the value all fire without ever taking focus. commit() only acts
// on a real difference, so the pair costs nothing when both arrive.
node.addEventListener("change", commit);
// Enter commits a one-line field. In a TEXTAREA it is a newline, and
// stealing it there means a multi-line value cannot be typed at all -- the
// prompt included, which is the longest multi-line thing in the app.
if (node.tagName !== "TEXTAREA") {
node.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); commit(); } });
}
}
}
@@ -468,3 +468,20 @@ test("a bin's thumbnails are lazy", () => {
g.destroy();
}
});
test("Enter is a newline in a textarea, not a commit", () => {
// A one-line field commits on Enter. A textarea holds the longest thing
// anyone types here -- the prompt -- and stealing Enter there means a
// multi-line value cannot be typed at all.
const committed = [];
const box = mount(composer.textarea.md({ label: "Prompt", onCommit: (v) => committed.push(v) }));
box.node.value = "first line";
const event = new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
box.node.dispatchEvent(event);
assert.equal(event.defaultPrevented, false, "Enter was swallowed, so no second line can be typed");
assert.deepEqual(committed, []);
fire(box.node, "blur");
assert.deepEqual(committed, ["first line"], "blur is what commits a textarea");
});
+7 -1
View File
@@ -176,7 +176,13 @@ export function build(root, handlers = {}) {
// The menu bar: who this is, what is not a zone, and whether ComfyUI is
// still on the other end. A zone head holds what acts on THAT zone, so
// anything that acts on the app has nowhere else to live.
const menubar = createMenubar({ workspace, onPipeline: handlers.onPipeline });
const menubar = createMenubar({
workspace,
onPipeline: handlers.onPipeline,
onProject: handlers.onProject,
projects: handlers.projects,
current: handlers.currentProject,
});
const bar = menubar;
const page = composer.frame.app({ header: bar, main: workspace });
+12 -3
View File
@@ -69,9 +69,18 @@ function menu(label, itemsOf, onPick) {
return button;
}
export function createMenubar({ workspace, onPipeline, theme = services.theme } = {}) {
export function createMenubar({ workspace, onPipeline, onProject, projects = () => [],
current = () => null, theme = services.theme } = {}) {
const connection = createConnection();
const file = menu("File", () => [
{ id: "new", label: "New project…" },
...(projects().length ? [{ separator: true }] : []),
// A tick rather than leaving the current one out: which project is open is
// a fact about the app, and a menu that hides it makes you press one to find out.
...projects().map((p) => ({ id: p.id, label: p.name, icon: p.id === current() ? "✓" : " " })),
], (id) => { if (onProject) onProject(id); });
const view = menu("View", () => [
{ id: "left", label: `${workspace && workspace.isOpen("left") ? "Hide" : "Show"} Assets` },
{ id: "right", label: `${workspace && workspace.isOpen("right") ? "Hide" : "Show"} Properties` },
@@ -93,9 +102,9 @@ export function createMenubar({ workspace, onPipeline, theme = services.theme }
const bar = composer.toolbar.default({
label: "FunPack",
items: [composer.brand.default({ name: "FunPack" }), view, settings],
items: [composer.brand.default({ name: "FunPack" }), file, view, settings],
trailing: connection.items,
});
return { node: bar.node, bar, connection, view, settings };
return { node: bar.node, bar, connection, file, view, settings };
}
+9
View File
@@ -56,6 +56,10 @@ const put = (project) => json("PUT", `${BASE}/${encodeURIComponent(project.id)}`
export function createProject({ onChange, onError } = {}) {
let project = null;
let selected = null;
// Every project this browser knows about, for the File menu. Read once at
// startup and kept in step here rather than re-fetched each time the menu
// opens: a listing is not a thing that changes behind the user's back.
let recent = [];
let timer = null;
let saving = null; // the PUT in flight, so a queued one waits for it
let dirty = false;
@@ -97,9 +101,13 @@ export function createProject({ onChange, onError } = {}) {
get selected() { return sceneAt(selected); },
/** The most recent project, or a new one when there are none. */
get recent() { return recent; },
async start() {
const found = await list();
recent = found;
project = found.length ? await read(found[0].id) : await create("Untitled");
if (!found.length) recent = [{ id: project.id, name: project.name }];
selected = (project.scenes || [])[0]?.id ?? null;
changed();
return project;
@@ -116,6 +124,7 @@ export function createProject({ onChange, onError } = {}) {
async newProject(name) {
await flush();
project = await create(name);
recent = [{ id: project.id, name: project.name }, ...recent];
selected = (project.scenes || [])[0]?.id ?? null;
changed();
return project;
+73 -59
View File
@@ -3,87 +3,102 @@
// The prompt is a slot input like any other -- `positive.text` on a
// CLIPTextEncode -- and until this existed the only way to type one was to open
// the pipeline window, find the node and edit it there. That is a graph editor,
// not an app.
// not an app. The same mechanism now carries the size a project generates at
// and the sampler dials, because they are slot inputs too.
//
// Nothing here knows what a prompt IS. A slot says "this input of mine belongs
// at generation.prompt, call it Prompt", the shell either offers a region with
// that name or does not, and a role naming a place nobody offers is simply not
// shown -- the same rule a module's panel lives by.
//
// The CONTROL comes from the node's own description, through the same
// settingFor/rendererFor pair the pipeline window uses. That is what makes a
// combo a dropdown of the node's real choices and a number obey the node's real
// bounds, without this file knowing that samplers or schedulers exist.
import { composer } from "../composer/composer.js";
import { rendererFor, rendererNameFor, SELF_LABELLING } from "../composer/panel.js";
import { settingFor } from "./widgets.js";
import { hostFor } from "./mounts.js";
const isLink = (value) => Array.isArray(value);
/**
* createPrompts(slots) -> { overrides, sync, fields, destroy }
* createPrompts(slots, { describe }) -> { overrides, sync, fields, at, destroy }
*
* `overrides` is what the app is holding, addressed by slot and input, ready to
* be sent with a run. It is not a copy of the pipeline: the pipeline window
* owns the structure, and these own one value each inside it.
*/
export function createPrompts(slots = [], { onChange } = {}) {
export async function createPrompts(slots = [], { describe, onChange } = {}) {
let fields = [];
function build(from) {
const wanted = (from) => (from || []).flatMap((slot) =>
(slot.roles || []).map((role) => ({ slot, role })).filter(({ role }) => hostFor(role.at)));
async function build(from) {
// The FIELD, not the control inside it: destroying the control leaves the
// label and the box it sat in behind, which is a labelled empty space.
for (const field of fields) field.field.destroy();
fields = [];
for (const slot of from || []) {
for (const role of slot.roles || []) {
const host = hostFor(role.at);
if (!host) continue; // nobody offers that place
const asked = wanted(from);
if (!asked.length) return;
const described = describe
? await describe([...new Set(asked.map(({ slot }) => slot.node))])
: {};
const current = (slot.inputs || {})[role.input];
// A wired input is fed by another node, and a box over one would offer
// to write a value the server refuses. Nothing is said: an input that
// cannot be typed into has no control, which is the same answer the
// pipeline window gives for a socket.
if (isLink(current)) continue;
const kind = current === undefined ? "string" : typeof current;
if (kind !== "string" && kind !== "number") continue;
for (const { slot, role } of asked) {
const current = (slot.inputs || {})[role.input];
// A wired input is fed by another node, and a box over one would offer to
// write a value the server refuses. Nothing is said: an input that cannot
// be typed into has no control, which is the same answer the pipeline
// window gives for a socket.
if (isLink(current)) continue;
// The field exists before its control so the control can report which
// field changed. Two places send values now -- a scene's text and the
// project's own settings -- and "something changed, go and read it all"
// does not say which one.
const entry = { slot: slot.id, input: role.input, at: role.at };
const told = () => { if (onChange) onChange(entry); };
const node = described[slot.node];
const widget = ((node && node.widgets) || []).find((w) => w.name === role.input);
// No description, or nothing sane to draw for it: absent, not a guess.
const setting = widget && settingFor(widget);
if (!setting) continue;
entry.control = kind === "number"
// No min or max: what they are is the NODE's business, and it already
// refuses a value outside them with a reason, beside Generate.
? composer.number.md({ value: current, onChange: told })
: composer.textarea.md({
// No label here: the field around it draws one and points at it,
// and an aria-label as well would win over the visible words.
value: current || "",
rows: 2,
autoGrow: true,
placeholder: role.label || role.input,
onInput: told,
});
entry.field = composer.field.default({
label: role.label || role.input,
control: entry.control,
});
host.appendChild(entry.field.node);
fields.push(entry);
}
const entry = { slot: slot.id, input: role.input, at: role.at,
value: current === undefined ? setting.default : current };
const told = (next) => {
entry.value = next;
if (onChange) onChange(entry);
};
entry.control = rendererFor(setting)({ ...setting, label: role.label || setting.label },
entry.value, told);
// A checkbox row draws its own label and hint; wrapping it in a field
// would print both of them twice, one above the other.
entry.field = SELF_LABELLING.has(rendererNameFor(setting))
? entry.control
: composer.field.default({ label: role.label || setting.label,
hint: setting.hint, control: entry.control });
hostFor(role.at).appendChild(entry.field.node);
fields.push(entry);
}
}
build(slots);
/** What a caller holds: the value, and a way to set it that both halves see. */
const handleFor = (entry) => ({
get value() { return entry.value; },
setValue(next) {
entry.value = next;
if (entry.control.setValue) entry.control.setValue(next);
},
});
await build(slots);
return {
/** {slotId: {input: value}} -- only what a person can actually have typed. */
/** {slotId: {input: value}} -- only what a person can actually have set. */
overrides() {
const out = {};
for (const { slot, input, control } of fields) {
for (const { slot, input, value } of fields) {
out[slot] = out[slot] || {};
out[slot][input] = control.value;
out[slot][input] = value;
}
return out;
},
@@ -91,32 +106,31 @@ export function createPrompts(slots = [], { onChange } = {}) {
/**
* The pipeline changed underneath: rebuild against it.
*
* A slot the pipeline window removed takes its box with it, and a value
* saved in that window is what the box then shows. Keeping the old value
* would make the two windows disagree about one input, and the last one
* sent would win silently.
* A slot the pipeline window removed takes its control with it, and a value
* saved in that window is what the control then shows. Keeping the old value
* would make the two windows disagree about one input, and the last one sent
* would win silently.
*/
sync(next) { build(next); },
sync(next) { return build(next); },
get fields() { return fields.map((f) => ({ slot: f.slot, input: f.input, at: f.at })); },
/**
* The control mounted at a place, or null.
*
* What makes a scene's text editable: the timeline owns the text and this is
* the box it is typed in, so the two are bound rather than each holding
* their own copy of a prompt.
* The control at a place, or null. What makes a scene's text editable: the
* timeline owns the text and this is where it is typed, so the two are bound
* rather than each holding their own copy of a prompt.
*/
at(point) {
const found = fields.find((f) => f.at === point);
return found ? found.control : null;
return found ? handleFor(found) : null;
},
/** Every control at a place, by the input it writes. */
controlsAt(point) {
return fields.filter((f) => f.at === point)
.map(({ input, control }) => ({ input, control }));
.map((f) => ({ input: f.input, control: handleFor(f) }));
},
destroy() { build([]); },
destroy() { return build([]); },
};
}
+96 -56
View File
@@ -14,6 +14,22 @@ test.before(async () => {
});
test.after(() => teardownDom());
// What the server says a node's inputs look like. The control a role gets comes
// from HERE, not from a guess about the value, which is what makes a combo a
// dropdown of the node's own choices.
const NODES = {
CLIPTextEncode: { widgets: [{ name: "text", type: "STRING", multiline: true, default: "" }] },
EmptyLatent: { widgets: [
{ name: "width", type: "INT", default: 512, min: 16, max: 4096, step: 8 },
{ name: "length", type: "INT", default: 1, min: 1, max: 512 },
] },
Sampler: { widgets: [
{ name: "sampler_name", type: "COMBO", choices: ["euler", "dpmpp_2m"], default: "euler" },
] },
};
const describe = async (classes) =>
Object.fromEntries(classes.map((c) => [c, NODES[c] || null]));
const SLOTS = [
{ id: "positive", node: "CLIPTextEncode",
roles: [{ at: "generation.prompt", input: "text", label: "Prompt" }],
@@ -32,25 +48,28 @@ function host(mount = "generation.prompt") {
return node;
}
const make = (slots, opts = {}) => createPrompts(slots, { describe, ...opts });
const boxes = (node) => [...node.querySelectorAll("textarea")];
test("a slot that names a place gets a box there", () => {
test("a slot that names a place gets a control there", async () => {
const node = host();
const p = createPrompts(SLOTS);
const p = await make(SLOTS);
assert.equal(boxes(node).length, 2);
assert.deepEqual([...node.querySelectorAll(".cx-label")].map((l) => l.textContent),
["Prompt", "Negative"]);
assert.equal(boxes(node)[0].value, "a cat", "the box did not show what the slot holds");
assert.equal(boxes(node)[0].value, "a cat", "the control did not show what the slot holds");
assert.deepEqual(p.fields.map((f) => f.slot), ["positive", "negative"]);
});
test("what is typed comes back addressed by slot and input", () => {
test("what is typed comes back addressed by slot and input", async () => {
const node = host();
const p = createPrompts(SLOTS);
const p = await make(SLOTS);
// Committed, not per keystroke: the control is the node's own, and the node's
// own control commits on blur. boot blurs before it saves for the same reason.
boxes(node)[0].value = "a dog on a roof";
fire(boxes(node)[0], "input");
fire(boxes(node)[0], "blur");
assert.deepEqual(p.overrides(), {
positive: { text: "a dog on a roof" },
@@ -58,29 +77,43 @@ test("what is typed comes back addressed by slot and input", () => {
});
});
test("a numeric input gets a number control, not a prompt box", () => {
// The same mechanism carries the prompt and the size the project generates
// at. What kind of control appears is decided by what is IN the input, so a
// pipeline that puts a number on the surface does not get a textarea for it.
test("the control is the node's, so a combo is its own list of choices", async () => {
// The whole reason this asks the server what a node looks like: a dropdown of
// the real samplers, and a number that obeys the real bounds, without this
// file knowing that samplers exist.
const node = host("generation.sampling");
const p = await make([{ id: "sampler", node: "Sampler",
roles: [{ at: "generation.sampling", input: "sampler_name", label: "Sampler" }],
inputs: { sampler_name: "dpmpp_2m" } }]);
const select = node.querySelector("select");
assert.ok(select, "a combo did not get a dropdown");
assert.deepEqual([...select.options].map((o) => o.value), ["euler", "dpmpp_2m"]);
assert.equal(select.value, "dpmpp_2m", "the dropdown did not show the slot's value");
select.value = "euler";
fire(select, "change");
assert.deepEqual(p.overrides(), { sampler: { sampler_name: "euler" } });
});
test("a numeric input gets a number control, not a prompt box", async () => {
const node = host("project.video");
const p = createPrompts([{ id: "latent", node: "EmptyLatent",
const p = await make([{ id: "latent", node: "EmptyLatent",
roles: [{ at: "project.video", input: "width", label: "Width" },
{ at: "project.video", input: "length", label: "Length" }],
inputs: { width: 832, length: 97 } }]);
assert.equal(node.querySelectorAll("textarea").length, 0);
const numbers = [...node.querySelectorAll('input[type="number"]')];
assert.equal(numbers.length, 2);
assert.equal(numbers[0].value, "832");
assert.equal(boxes(node).length, 0);
assert.equal(node.querySelectorAll('input[type="number"]').length, 2);
assert.deepEqual(p.overrides(), { latent: { width: 832, length: 97 } });
});
test("a changed control says which input it was", () => {
// Two places take a value now -- a scene's text and the project's settings --
// and "something changed, go and read it all" does not say which one.
test("a changed control says which input it was", async () => {
// Two places take a value -- a scene's text and the project's settings -- and
// "something changed, go and read it all" does not say which one.
const node = host("project.video");
const told = [];
createPrompts([{ id: "latent", node: "EmptyLatent",
await make([{ id: "latent", node: "EmptyLatent",
roles: [{ at: "project.video", input: "width", label: "Width" }],
inputs: { width: 512 } }], { onChange: (field) => told.push(field) });
@@ -92,86 +125,93 @@ test("a changed control says which input it was", () => {
assert.equal(told[0].at, "project.video");
assert.equal(told[0].input, "width");
assert.equal(told[0].slot, "latent");
assert.equal(told[0].control.value, 832);
assert.equal(told[0].value, 832);
});
test("the controls at a place come back with the input each one writes", () => {
test("setting a control's value is what the run then sends", async () => {
// The box and the value behind it are one thing. Seeded from the project on
// open and from the scene on selection, and either would be a lie if the
// control moved and the value did not.
const node = host("project.video");
const p = createPrompts([{ id: "latent", node: "EmptyLatent",
const p = await make([{ id: "latent", node: "EmptyLatent",
roles: [{ at: "project.video", input: "width", label: "Width" },
{ at: "project.video", input: "height", label: "Height" }],
inputs: { width: 512, height: 512 } }]);
void node;
{ at: "project.video", input: "length", label: "Length" }],
inputs: { width: 512, length: 1 } }]);
const found = p.controlsAt("project.video");
assert.deepEqual(found.map((f) => f.input), ["width", "height"]);
found[1].control.setValue(480);
assert.deepEqual(p.overrides(), { latent: { width: 512, height: 480 } });
assert.deepEqual(found.map((f) => f.input), ["width", "length"]);
found[0].control.setValue(640);
assert.equal(found[0].control.value, 640);
assert.equal(node.querySelector('input[type="number"]').value, "640");
assert.deepEqual(p.overrides(), { latent: { width: 640, length: 1 } });
assert.deepEqual(p.controlsAt("nowhere.at.all"), []);
});
test("a role naming a place nobody offers is not shown", () => {
test("a role naming a place nobody offers is not shown", async () => {
// The same rule a module's panel lives by: absent, not broken, and not a
// warning either -- this shell may simply not have that region yet.
const node = host("somewhere.else");
const p = createPrompts(SLOTS);
const p = await make(SLOTS);
assert.equal(boxes(node).length, 0);
assert.deepEqual(p.overrides(), {});
});
test("an input fed by another node gets no box", () => {
test("an input fed by another node gets no control", async () => {
// A value written over a link unwires a node, and the server refuses it. A
// box offering to do that is a box whose every use is an error.
// control offering to do that is one whose every use is an error.
const node = host();
createPrompts([{ id: "positive", node: "CLIPTextEncode",
await make([{ id: "positive", node: "CLIPTextEncode",
roles: [{ at: "generation.prompt", input: "clip", label: "CLIP" }],
inputs: { clip: ["clip", 0] } }]);
assert.equal(boxes(node).length, 0);
assert.equal(node.children.length, 0);
});
test("an input holding something other than text gets no box", () => {
test("an input the node does not describe gets no control", async () => {
// A pipeline can name an input a node has not got -- after a node swap, or a
// hand-edited pipeline. Nothing sane to draw means nothing drawn.
const node = host();
createPrompts([{ id: "latent", node: "EmptyLatent",
roles: [{ at: "generation.prompt", input: "width", label: "Width" }],
inputs: { width: 512 } }]);
assert.equal(boxes(node).length, 0, "a number was offered a prompt box");
await make([{ id: "positive", node: "CLIPTextEncode",
roles: [{ at: "generation.prompt", input: "gone", label: "Gone" }],
inputs: {} }]);
assert.equal(node.children.length, 0);
});
test("a slot with no roles puts nothing anywhere", () => {
test("a slot with no roles puts nothing anywhere", async () => {
const node = host();
createPrompts([{ id: "latent", node: "EmptyLatent", inputs: { width: 512 } }]);
assert.equal(boxes(node).length, 0);
await make([{ id: "latent", node: "EmptyLatent", inputs: { width: 512 } }]);
assert.equal(node.children.length, 0);
});
test("the pipeline changing underneath takes the boxes with it", () => {
// The pipeline window can remove the slot a box belongs to. A box left behind
// sends a value for a slot that is gone, and the server refuses the whole run
// over a control the user has no way to find.
test("the pipeline changing underneath takes the controls with it", async () => {
// The pipeline window can remove the slot a control belongs to. One left
// behind sends a value for a slot that is gone, and the server refuses the
// whole run over a control the user has no way to find.
const node = host();
const p = createPrompts(SLOTS);
const p = await make(SLOTS);
boxes(node)[0].value = "a dog";
fire(boxes(node)[0], "input");
fire(boxes(node)[0], "blur");
p.sync(SLOTS.filter((s) => s.id !== "positive"));
await p.sync(SLOTS.filter((s) => s.id !== "positive"));
assert.equal(boxes(node).length, 1);
assert.deepEqual(p.overrides(), { negative: { text: "" } });
});
test("a value saved in the pipeline window is what the box then shows", () => {
test("a value saved in the pipeline window is what the control then shows", async () => {
const node = host();
const p = createPrompts(SLOTS);
const p = await make(SLOTS);
boxes(node)[0].value = "typed here";
fire(boxes(node)[0], "input");
fire(boxes(node)[0], "blur");
p.sync([{ ...SLOTS[0], inputs: { clip: ["clip", 0], text: "saved there" } }]);
await p.sync([{ ...SLOTS[0], inputs: { clip: ["clip", 0], text: "saved there" } }]);
assert.equal(boxes(node)[0].value, "saved there",
"two windows held different text for one input");
assert.deepEqual(p.overrides(), { positive: { text: "saved there" } });
});
test("rebuilding leaves nothing behind", () => {
test("rebuilding leaves nothing behind", async () => {
const node = host();
const p = createPrompts(SLOTS);
p.sync([]);
const p = await make(SLOTS);
await p.sync([]);
assert.equal(node.children.length, 0, "a labelled empty space was left behind");
});
+33 -1
View File
@@ -43,10 +43,16 @@ test("it is still what the project generates at on the next visit", async ({ pag
await openConstructor(page);
await widthBox(page).fill("640");
await widthBox(page).blur();
await page.evaluate(() => window.FunPack.project.flush());
const id = await page.evaluate(async () => {
await window.FunPack.project.flush();
return window.FunPack.project.project.id;
});
await page.reload();
await page.waitForFunction(() => window.FunPack !== undefined);
// Opened by id: another test in this file makes projects, and which one a
// fresh page picks is "the most recent", not "the one this test used".
await page.evaluate((pid) => window.FunPack.project.open(pid), id);
await expect.poll(() => page.evaluate(() => window.FunPack.project.video.width)).toBe(640);
await openConstructor(page);
@@ -81,3 +87,29 @@ test("what a run produces lands on the scene it was started for", async ({ page
// 404 for every /view, so the strip shows its glyph -- which is the fallback
// doing its job, not a missing result.
});
test("a project can be made and switched to from the File menu", async ({ page }) => {
// The store could do all of this from the day it arrived; there was no way in.
await page.goto("/funpack/");
await page.waitForFunction(() => window.FunPack !== undefined);
// A name of its own: the dev server keeps real project files, so what is
// already in the store is whatever earlier runs left there.
const name = `Made ${Date.now()}`;
await page.getByRole("button", { name: "File" }).click();
await page.getByRole("menuitem", { name: /New project/ }).click();
await page.locator(".cx-modal input").fill(name);
await page.locator(".cx-modal").getByRole("button", { name: "Create" }).click();
await expect.poll(() => page.evaluate(() => window.FunPack.project.project.name)).toBe(name);
const made = await page.evaluate(() => window.FunPack.project.project.id);
// And away again. By position, not by name: every other project in the store
// may well be called Untitled.
await page.getByRole("button", { name: "File" }).click();
const items = page.getByRole("menuitem");
await expect(items.nth(1)).toHaveText(new RegExp(name)); // the one just made, ticked
await items.nth(2).click();
await expect.poll(() => page.evaluate(() => window.FunPack.project.project.id)).not.toBe(made);
});
+2
View File
@@ -21,6 +21,7 @@ test("the prompt is written in the Constructor, and typing in it is what runs",
const boxes = page.locator(".cx-modal textarea");
await expect(boxes).toHaveCount(2);
await boxes.first().fill("a cat on a rooftop");
await boxes.first().blur(); // a control commits on blur
const sent = await page.evaluate(async () => {
const body = JSON.stringify({ inputs: window.FunPack.prompts() });
@@ -40,6 +41,7 @@ test("what was typed is still there the next time the window opens", async ({ pa
await openConstructor(page);
await page.locator(".cx-modal textarea").first().fill("a cat on a rooftop");
await page.locator(".cx-modal textarea").first().blur();
await page.locator(".cx-modal").getByRole("button", { name: "Done" }).click();
await expect(page.locator(".cx-modal")).toHaveCount(0);
+1 -1
View File
@@ -437,5 +437,5 @@ def test_the_default_pipeline_says_where_its_prompt_belongs(server):
roles = [(s["id"], r) for s in body["slots"] for r in (s.get("roles") or [])]
assert roles, "no slot offers anything to the main window"
places = {r["at"] for _id, r in roles}
assert places == {"generation.prompt", "project.video"}
assert places == {"generation.prompt", "project.video", "generation.sampling"}
assert all(r["input"] and r["label"] for _id, r in roles)
+8 -1
View File
@@ -57,7 +57,14 @@ DEFAULT = [
{"id": "modifiers", "group": "Preparation", "node": "FunPackLoadModifiers", "inputs": {
"model": ["model", 0], "settings": ["settings", 0]}},
{"id": "sampler", "group": "Sampling", "node": "FunPackSampler", "inputs": {
{"id": "sampler", "group": "Sampling", "node": "FunPackSampler",
# The three dials that get reached for constantly. v4 buried them several
# clicks deep in Engine Settings; here they are on the main window because
# the slot says they belong there.
"roles": [{"at": "generation.sampling", "input": "steps", "label": "Steps"},
{"at": "generation.sampling", "input": "sampler_name", "label": "Sampler"},
{"at": "generation.sampling", "input": "scheduler", "label": "Scheduler"}],
"inputs": {
"model": ["modifiers", 0], "positive": ["positive", 0], "negative": ["negative", 0],
"latent": ["latent", 0], "settings": ["settings", 0],
"seed": 0, "steps": 20, "cfg": 7.0,