Files
olivv-cs-ComfyUI-FunPack/app/shell/prompt.js
T
digital-garbage 33eeff08e3 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.
2026-09-06 12:18:33 +03:00

137 lines
5.5 KiB
JavaScript

// The inputs a pipeline puts on the app's own surface.
//
// 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. 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, { 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 async function createPrompts(slots = [], { describe, onChange } = {}) {
let fields = [];
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 = [];
const asked = wanted(from);
if (!asked.length) return;
const described = describe
? await describe([...new Set(asked.map(({ slot }) => slot.node))])
: {};
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;
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;
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);
}
}
/** 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 set. */
overrides() {
const out = {};
for (const { slot, input, value } of fields) {
out[slot] = out[slot] || {};
out[slot][input] = value;
}
return out;
},
/**
* The pipeline changed underneath: rebuild against it.
*
* 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) { return build(next); },
get fields() { return fields.map((f) => ({ slot: f.slot, input: f.input, at: f.at })); },
/**
* 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 ? handleFor(found) : null;
},
/** Every control at a place, by the input it writes. */
controlsAt(point) {
return fields.filter((f) => f.at === point)
.map((f) => ({ input: f.input, control: handleFor(f) }));
},
destroy() { return build([]); },
};
}