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.
155 lines
6.7 KiB
JavaScript
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([]); },
|
|
};
|
|
}
|