Files
olivv-cs-ComfyUI-FunPack/app/shell/prompt.js
T
digital-garbage 2e384c48b2 core, app: shortcuts, autocomplete, and prompt-craft expansion
A trigger->replacement library (core/shortcuts.py, a close port of v4's
apply_prompt_shortcuts/resolve_variables), a Settings section to manage it,
autocomplete on the real prompt textarea (composer.autocomplete.default, so
it outranks the Constructor modal it lives inside), and a stateless
/api/prompt/expand that combines anchor + shortcuts + $variables + postfix
at the moment a run is queued -- scene.text itself stays exactly what was
typed. Project gained anchor/postfix/variables editing UI on its own tab.

Along the way: a real layout bug in the Properties column (every composer
stack can flex-shrink below its own content, and once the Project tab grew
past a couple of rows the column started squeezing its own children's
content into each other and, at the outer level, into the sibling Generation
section below) -- fixed with flex-shrink:0 at each level that needs it,
caught by a Playwright regression test.
2026-09-08 11:49:49 +03:00

155 lines
6.7 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;
// What this input is when nothing has an opinion on it. The PIPELINE's
// own value, not the raw node's widget-schema default -- a module can
// and does bake its own sensible number in (this app's latent slot
// declares 512, the underlying node's own schema default is 768) and
// the pipeline's is the one Generate would actually use.
const atRest = current === undefined ? setting.default : current;
const entry = { slot: slot.id, input: role.input, at: role.at,
value: atRest, default: atRest };
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; },
// What this control shows when nothing has an opinion -- the pipeline's own
// declared default, not whatever a PREVIOUS project last set it to. Needed
// by anyone syncing this control from per-project state (project.video):
// without it, a project that never touched this input has no way to say
// "go back to normal" and the last project's value is left standing.
get default() { return entry.default; },
// The real DOM node, for a caller that needs to attach something to the
// FIELD rather than read/write its value -- autocomplete.js's attach(),
// concretely. Not every control renders one text input (a combo, a
// stepper), so this is undefined rather than a guess for anything but
// the multiline/text controls that have one.
get node() { return entry.control.node; },
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([]); },
};
}