diff --git a/src/lembas/__init__.py b/src/lembas/__init__.py index 9daafa2..9c40899 100644 --- a/src/lembas/__init__.py +++ b/src/lembas/__init__.py @@ -1,3 +1,3 @@ """LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints.""" -__version__ = "0.9.1" +__version__ = "0.9.2" diff --git a/src/lembas/api/admin_images.py b/src/lembas/api/admin_images.py index 92b6446..6ff9bca 100644 --- a/src/lembas/api/admin_images.py +++ b/src/lembas/api/admin_images.py @@ -18,6 +18,7 @@ import json import logging import re from datetime import UTC, datetime +from typing import Any from fastapi import APIRouter, Form, HTTPException, Request, Response, status from fastapi.responses import RedirectResponse @@ -54,6 +55,27 @@ def _lines(text: str) -> list[str]: return seen +def _number(raw: str, name: str, *, whole: bool = True) -> Any: + """A filled box as a clamped number, an empty one as "". + + The empty string is load-bearing and is not a missing value: it is how an + administrator says "no opinion about this one", which `workflow.resolve` + reads as "fall through to the built-in floor". Turning it into a zero here + would silently set every instance to zero steps. + """ + text = (raw or "").strip() + if not text: + return "" + try: + value = float(text) + except ValueError: + return "" + low, high = workflow_service.LIMITS.get(name, (None, None)) + if low is not None: + value = min(max(value, low), high) + return int(value) if whole else value + + def _config(db: Db) -> comfy.Config: values = settings_store.images(db) return comfy.Config( @@ -114,6 +136,21 @@ async def save_images( max_tries: int = Form(4), preserve_vram: bool = Form(False), instructions: str = Form(""), + # The generation defaults. Every one is a *string* even where it is a + # number, because "" is how an administrator says "no opinion" and an + # `int = Form(0)` cannot express that -- zero steps is a value, and one + # somebody could mean. `_number` below turns a filled box into a clamped + # number and an empty one back into "". + default_checkpoint: str = Form(""), + default_steps: str = Form(""), + default_cfg: str = Form(""), + default_width: str = Form(""), + default_height: str = Form(""), + default_sampler: str = Form(""), + default_scheduler: str = Form(""), + default_denoise: str = Form(""), + default_negative: str = Form(""), + default_batch: str = Form(""), ) -> Response: """Save the settings. @@ -142,6 +179,20 @@ async def save_images( "max_tries": min(max(max_tries, 1), 10), "preserve_vram": preserve_vram, "instructions": instructions.strip()[:4000], + # Clamped here to the same bounds `workflow.LIMITS` uses on the way + # out. Twice, deliberately: a number stored by an earlier version, + # or written straight into the settings row, still has to be safe + # when a generation reads it. + "default_checkpoint": default_checkpoint.strip(), + "default_steps": _number(default_steps, "steps"), + "default_cfg": _number(default_cfg, "cfg", whole=False), + "default_width": _number(default_width, "width"), + "default_height": _number(default_height, "height"), + "default_sampler": default_sampler.strip(), + "default_scheduler": default_scheduler.strip(), + "default_denoise": _number(default_denoise, "denoise", whole=False), + "default_negative": default_negative.strip()[:500], + "default_batch": _number(default_batch, "batch"), }, key=settings_store.IMAGES, ) @@ -212,7 +263,34 @@ def _workflow(db: Db, workflow_id: str) -> ImageWorkflow: return row -def _detail(request: Request, row: ImageWorkflow, *, is_new: bool, error: str = "", **extra): +def _placeholder_help(db: Db) -> list[tuple[str, str, str, str]]: + """Every placeholder, what it fills, and what it resolves to *today*. + + The last column is the point. A legend listing names answers "what may I + write"; the question somebody actually has, standing in front of a workflow + that came out wrong, is "what happens if I leave this out" -- and the answer + moved the day instance defaults arrived. Resolved through the same call a + generation makes, so the two cannot disagree. + """ + resolved = workflow_service.resolve({}, settings=settings_store.images(db)) + out: list[tuple[str, str, str, str]] = [] + for name in workflow_service.PLACEHOLDERS: + kind, what = workflow_service.DESCRIPTIONS.get(name, ("text", "")) + if name == "prompt": + shown = "whatever is asked for" + elif name == "seed": + shown = "a fresh random one" + elif name == "model": + shown = str(resolved.get("model") or "") or "the first checkpoint listed" + else: + shown = str(resolved.get(name, "")) + out.append((name, kind, what, shown)) + return out + + +def _detail( + request: Request, db: Db, row: ImageWorkflow, *, is_new: bool, error: str = "", **extra +): return render( request, "admin/workflow_detail.html", @@ -221,6 +299,7 @@ def _detail(request: Request, row: ImageWorkflow, *, is_new: bool, error: str = "is_new": is_new, "error": error, "placeholders": workflow_service.PLACEHOLDERS, + "placeholder_help": _placeholder_help(db), "workflow_text": extra.pop( "workflow_text", json.dumps(row.workflow_json or {}, indent=2) ), @@ -304,7 +383,7 @@ async def new_workflow(request: Request, db: Db, user: AdminUser) -> Response: workflow_json=json.loads(base.read_text(encoding="utf-8")), enabled=True, ) - return _detail(request, draft, is_new=True) + return _detail(request, db, draft, is_new=True) @router.post("/workflows") @@ -316,6 +395,7 @@ async def create_workflow(request: Request, db: Db, user: AdminUser) -> Response if problem: return _detail( request, + db, row, is_new=True, error=problem, @@ -334,7 +414,7 @@ async def create_workflow(request: Request, db: Db, user: AdminUser) -> Response @router.get("/workflows/{workflow_id}/edit") async def edit_workflow(request: Request, db: Db, user: AdminUser, workflow_id: str) -> Response: - return _detail(request, _workflow(db, workflow_id), is_new=False) + return _detail(request, db, _workflow(db, workflow_id), is_new=False) @router.post("/workflows/{workflow_id}/delete") @@ -363,6 +443,7 @@ async def update_workflow(request: Request, db: Db, user: AdminUser, workflow_id draft.id = row.id return _detail( request, + db, draft, is_new=False, error=problem, diff --git a/src/lembas/services/images/base_workflow.json b/src/lembas/services/images/base_workflow.json index 5a12e6e..8f2df6b 100644 --- a/src/lembas/services/images/base_workflow.json +++ b/src/lembas/services/images/base_workflow.json @@ -24,7 +24,7 @@ "inputs": { "width": "{{width}}", "height": "{{height}}", - "batch_size": 1 + "batch_size": "{{batch}}" }, "class_type": "EmptyLatentImage", "_meta": { "title": "Empty Latent Image" } diff --git a/src/lembas/services/images/tool.py b/src/lembas/services/images/tool.py index 2449815..a6feb00 100644 --- a/src/lembas/services/images/tool.py +++ b/src/lembas/services/images/tool.py @@ -27,6 +27,7 @@ from __future__ import annotations import json import logging +import re from dataclasses import dataclass from typing import Any @@ -196,6 +197,43 @@ def _choices(db, values: dict[str, Any]) -> tuple[list[Any], list[str]]: return rows, [str(name) for name in (values.get("checkpoints") or [])] +_DEFAULT_SENTENCE = re.compile(r"Default ([^.,]+)([.,])") + + +def _restate_defaults(schema: dict[str, Any], values: dict[str, Any]) -> None: + """Rewrite each "Default 20." to say what this instance actually uses. + + Every one of those descriptions was written when there was one set of + defaults in the world. Now an administrator can move them, and a schema + still saying "Default 512" beside an instance that draws at 1024 is worse + than saying nothing: the model reasons from it, decides 512 is fine for the + SDXL checkpoint it was handed, and omits the parameter — arriving at the + right behaviour for the wrong reason, or the wrong one silently. + + A rewrite rather than a `{default}` placeholder in the prose, because the + sentence around it differs per parameter and half of them go on to say what + to do *instead* of the default. The regex keeps the punctuation it found, + since `denoise` says "Default 1, which is…" and the rest use a full stop. + """ + resolved = workflow.resolve({}, settings=values) + for name, spec in schema.get("properties", {}).items(): + if name not in resolved or name in ("prompt", "seed", "model", "template"): + continue + shown = resolved[name] + # A float that is whole reads better as "8" than "8.0", and this is the + # text a model reasons about. + if isinstance(shown, float) and shown.is_integer(): + shown = int(shown) + spec["description"] = _DEFAULT_SENTENCE.sub( + # Bound now rather than closed over: `shown` is a loop variable, and + # a lambda reading it later would restate every description with the + # last parameter's value. + lambda match, shown=shown: f"Default {shown}{match.group(2)}", + spec["description"], + count=1, + ) + + def schema_for(db, values: dict[str, Any]) -> dict[str, Any]: """The parameter schema, with this instance's own choices in it. @@ -207,6 +245,7 @@ def schema_for(db, values: dict[str, Any]) -> dict[str, Any]: """ rows, checkpoints = _choices(db, values) schema = json.loads(json.dumps(SCHEMA)) + _restate_defaults(schema, values) if rows: schema["properties"]["template"]["enum"] = [row.slug for row in rows] schema["properties"]["template"]["description"] = "Which workflow to use. " + "; ".join( @@ -421,7 +460,12 @@ async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome: {**event, "status": "error", "error": str(exc)}, ) - checkpoint = _checkpoint(str(args.get("model") or ""), context.image_checkpoint, checkpoints) + checkpoint = _checkpoint( + str(args.get("model") or ""), + context.image_checkpoint, + checkpoints, + instance_default=str(values.get("default_checkpoint") or ""), + ) if checkpoint is None: return ToolOutcome( "No checkpoint is available. An administrator has to list them on the " @@ -429,7 +473,7 @@ async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome: {**event, "status": "error", "error": "No checkpoint."}, ) - given = {name: args.get(name) for name in workflow.PLACEHOLDERS if name in args} + given = {name: args.get(name) for name in workflow.MODEL_SETTABLE if name in args} given["model"] = checkpoint given["prompt"] = prompt @@ -441,14 +485,16 @@ async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome: kept: tuple[bytes, dict[str, Any]] | None = None # What the last attempt actually asked for, so a failure can name concrete # numbers back at the model rather than saying "try something smaller". - params_used: dict[str, Any] = workflow.resolve(given) + params_used: dict[str, Any] = workflow.resolve(given, settings=values) try: for number in range(1, tries + 1): if preserve: await _unload_llm(context) - params = workflow.resolve({**given, "seed": args.get("seed") if number == 1 else None}) + params = workflow.resolve( + {**given, "seed": args.get("seed") if number == 1 else None}, settings=values + ) params_used = params refs = await comfy.await_images( config, await comfy.submit(config, workflow.fill(template, params)) @@ -571,17 +617,26 @@ def _pick(rows: list[Any], wanted: str, chat_default: str, values: dict[str, Any return rows[0] if rows else None -def _checkpoint(wanted: str, chat_default: str, available: list[str]) -> str | None: +def _checkpoint( + wanted: str, chat_default: str, available: list[str], *, instance_default: str = "" +) -> str | None: """The checkpoint to draw with, on the same ladder. - A name the instance does not have is ignored rather than passed through: it - would reach ComfyUI, be refused, and cost a round to discover -- and the - model was shown the list it may choose from. + Most specific first: what the model named, then this chat's own, then the + instance default, then whatever is first in the list. The instance rung is + the new one -- without it, "the default" was position zero in a textarea an + administrator had typed in some order, which is a default by accident. + + A name the instance does not have is ignored at every rung rather than + passed through: it would reach ComfyUI, be refused, and cost a round to + discover -- and the model was shown the list it may choose from. """ if wanted and wanted in available: return wanted if chat_default and chat_default in available: return chat_default + if instance_default and instance_default in available: + return instance_default return available[0] if available else None diff --git a/src/lembas/services/images/workflow.py b/src/lembas/services/images/workflow.py index 3b47ecc..e86e61a 100644 --- a/src/lembas/services/images/workflow.py +++ b/src/lembas/services/images/workflow.py @@ -40,12 +40,25 @@ PLACEHOLDERS = ( "sampler", "scheduler", "denoise", + # How many pictures one run produces. Late to the list, and the reason is + # worth stating: `batch_size` was a literal `1` in the base template, so an + # administrator whose card can comfortably make four at a time had no way of + # saying so short of editing the JSON. Not a tool parameter -- a model asking + # for six images because it is unsure is exactly the cost this should not + # invite -- so it fills from the instance default and nowhere else. + "batch", ) -# The defaults, taken from the base template. `seed` is deliberately absent: it -# has no fixed default, because one would make every generation that did not -# name a seed identical -- and would make the retry loop produce the same -# rejected image four times over. +# What a model may name. Everything else in `PLACEHOLDERS` fills from a default. +MODEL_SETTABLE = tuple(name for name in PLACEHOLDERS if name != "batch") + +# The floor, taken from the base template. An instance's own defaults sit above +# this (see `resolve`), and this stays as the last resort so a fresh install +# behaves exactly as it always did. +# +# `seed` is deliberately absent: it has no fixed default, because one would make +# every generation that did not name a seed identical -- and would make the +# retry loop produce the same rejected image four times over. DEFAULTS: dict[str, Any] = { "negative": "text, watermark", "steps": 20, @@ -55,6 +68,26 @@ DEFAULTS: dict[str, Any] = { "sampler": "euler", "scheduler": "normal", "denoise": 1.0, + "batch": 1, +} + +# What each hole is for, and what it lands as. Read by the workflow editor, so +# somebody writing a template is told what `{{sampler}}` fills without reading +# this file -- and in particular is told the two names that do not match +# ComfyUI's own, which is the mistake that costs an afternoon. +DESCRIPTIONS: dict[str, tuple[str, str]] = { + "model": ("text", "The checkpoint. Fills ComfyUI's `ckpt_name`, not `model`."), + "prompt": ("text", "What to draw. The only value a model must supply."), + "negative": ("text", "What to keep out of the picture."), + "seed": ("number", "The noise seed. Absent or negative means a fresh random one."), + "steps": ("number", "How many denoising steps. More is slower, not always better."), + "cfg": ("number", "How closely to follow the prompt. A decimal."), + "width": ("number", "Pixels across. A multiple of 64."), + "height": ("number", "Pixels down. A multiple of 64."), + "sampler": ("text", "The sampling method. Fills ComfyUI's `sampler_name`, not `sampler`."), + "scheduler": ("text", "The noise schedule."), + "denoise": ("number", "How much of the latent to redraw. 1.0 for text-to-image."), + "batch": ("number", "How many images one run makes. Fills `batch_size`."), } # ComfyUI's own ranges, read off `/object_info`. Clamped rather than refused: a @@ -66,6 +99,10 @@ LIMITS: dict[str, tuple[float, float]] = { "width": (64, 2048), "height": (64, 2048), "denoise": (0.0, 1.0), + # Not ComfyUI's ceiling, which is 4096, but a sane one: this multiplies + # every generation's time and VRAM, and an administrator who wants more than + # eight at once wants a different workflow rather than a bigger number here. + "batch": (1, 8), } # ComfyUI's seed is a uint64. Generated here rather than left to the far side @@ -80,12 +117,43 @@ def random_seed() -> int: return secrets.randbelow(MAX_SEED) -def resolve(given: dict[str, Any]) -> dict[str, Any]: - """The full parameter set: what was asked for, over the defaults. +def instance_defaults(values: dict[str, Any] | None) -> dict[str, Any]: + """The `default_*` keys out of the image settings, as placeholder names. - Absent and null are both "no opinion". A model that emits `"seed": null` - rather than omitting the key is common enough that treating it as a request - for seed zero would be a bug nobody could see. + Only the ones actually set: an absent or empty key means "no opinion", and + must fall through to `DEFAULTS` rather than land as an empty string in a + workflow. That is the same reading `resolve` gives a model's own arguments, + and it is why an administrator can set two of these and leave the rest. + """ + out: dict[str, Any] = {} + for name in PLACEHOLDERS: + if name in ("prompt", "seed", "model"): + # A default prompt is not a thing; a default seed would make every + # picture identical; the checkpoint has its own setting and its own + # per-chat override, resolved before this is reached. + continue + value = (values or {}).get(f"default_{name}") + if value is None or value == "": + continue + out[name] = value + return out + + +def resolve(given: dict[str, Any], *, settings: dict[str, Any] | None = None) -> dict[str, Any]: + """The full parameter set: what was asked for, over what this instance + prefers, over the built-in floor. + + Three rungs, most specific winning, and the middle one is the new part. For + the whole life of this feature there were only two -- so 512x512, euler and + twenty steps were the values every instance got, whatever card it was + running on, and the only ways to move them were to bake literals into a + template instead of placeholders or to write prose in the instructions box + and hope. `DEFAULTS` stays underneath so an instance that sets nothing + behaves exactly as it did. + + Absent and null are both "no opinion", at both levels. A model that emits + `"seed": null` rather than omitting the key is common enough that treating + it as a request for seed zero would be a bug nobody could see. **A negative seed means random**, which is what `-1` means in ComfyUI's own interface, in A1111, and in every other thing that has ever asked somebody @@ -94,14 +162,17 @@ def resolve(given: dict[str, Any]) -> dict[str, Any]: a perfectly valid *fixed* seed, so "give me something new" produced the same picture every time. Exactly the wrong answer, arrived at silently. """ - values: dict[str, Any] = {**DEFAULTS} + values: dict[str, Any] = {**DEFAULTS, **instance_defaults(settings)} for name, value in (given or {}).items(): - if name in PLACEHOLDERS and value is not None and value != "": + # `batch` is absent from `MODEL_SETTABLE`, so a model naming it is + # ignored here rather than refused -- the tool schema never offered it, + # and one that invents the key has guessed rather than misbehaved. + if name in MODEL_SETTABLE and value is not None and value != "": values[name] = value seed = _whole(values.get("seed"), default=-1) values["seed"] = random_seed() if seed < 0 else seed % (MAX_SEED + 1) - for name in ("steps", "width", "height"): + for name in ("steps", "width", "height", "batch"): values[name] = _clamp(_whole(values.get(name), DEFAULTS[name]), name) for name in ("cfg", "denoise"): values[name] = _clamp(_decimal(values.get(name), DEFAULTS[name]), name) diff --git a/src/lembas/services/settings_store.py b/src/lembas/services/settings_store.py index bf7b91a..a3c4b73 100644 --- a/src/lembas/services/settings_store.py +++ b/src/lembas/services/settings_store.py @@ -260,6 +260,31 @@ def _images_defaults() -> dict[str, Any]: "samplers": [], "schedulers": [], "default_workflow_id": "", + # What a generation uses when nothing names otherwise. Empty means "no + # opinion" for every one of them, falling through to + # `workflow.DEFAULTS` -- which is why they are empty here rather than + # holding a copy of that dict. A copy would freeze an instance on + # whatever this file said the day it was installed, and would make + # improving a floor in code reach nobody. + # + # These exist because for the whole life of this feature there were + # none: 512x512, euler and twenty steps were what every instance got + # whatever card it was running on, and the only ways to move them were + # to bake literals into a template instead of placeholders, or to write + # prose in the instructions box and hope. + "default_checkpoint": "", + "default_steps": "", + "default_cfg": "", + "default_width": "", + "default_height": "", + "default_sampler": "", + "default_scheduler": "", + "default_denoise": "", + "default_negative": "", + # How many pictures one run makes. Not offered to the model at all -- + # see workflow.MODEL_SETTABLE -- because a model asking for six because + # it is unsure is exactly the cost this must not invite. + "default_batch": "", # Whether a vision model looks at what came back and says whether to # keep it. Deliberately independent of `preserve_vram` below: on a # machine that can hold both models this costs nothing, and on one that diff --git a/src/lembas/web/static/css/admin.css b/src/lembas/web/static/css/admin.css index 2634636..b49f83e 100644 --- a/src/lembas/web/static/css/admin.css +++ b/src/lembas/web/static/css/admin.css @@ -203,6 +203,24 @@ a.tabs__tab { text-decoration: none; } flex-wrap: wrap; } +/* + Two or three short fields on one line. + + Numbers with their own units — width and height, steps and cfg and denoise — + read as a set and take a fraction of the width each, so stacking them makes a + card that is mostly whitespace and a form that is mostly scrolling. Auto-fit + rather than a fixed count: the same markup gives three columns on a wide + screen and one on a narrow one with no breakpoint, which matters because + `chat.css` has a test refusing media queries and this file should not drift + from that habit without a reason. +*/ +.field-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); + gap: var(--sp-3); +} +.field-row > .field { margin-bottom: var(--sp-4); } + /* Legacy aliases so existing admin templates keep their spacing. */ .form-grid { display: block; } .connection__head { display: flex; align-items: center; justify-content: space-between; diff --git a/src/lembas/web/static/js/ui.js b/src/lembas/web/static/js/ui.js index 350ad10..a30a528 100644 --- a/src/lembas/web/static/js/ui.js +++ b/src/lembas/web/static/js/ui.js @@ -759,6 +759,32 @@ document.addEventListener("htmx:afterSettle", scan); })(); +/* + Two number fields set together. + + Image sizes come in pairs and nobody types 1024 and then 1536; they pick + "portrait". A button rather than a select of pairs, because a select would + have to enumerate every combination somebody might want and these are only + the common ones — the two boxes are still there and still authoritative. + + Delegated and keyed on the attributes rather than on the image page's ids, so + the next screen with a paired number wants no new code. +*/ +document.addEventListener("click", function (event) { + var preset = event.target.closest("[data-width][data-height]"); + if (!preset) return; + var row = preset.closest("[data-size-presets]"); + if (!row) return; + event.preventDefault(); + + var form = preset.closest("form"); + if (!form) return; + var width = form.querySelector('[name="default_width"]'); + var height = form.querySelector('[name="default_height"]'); + if (width) width.value = preset.dataset.width; + if (height) height.value = preset.dataset.height; +}); + /* A toast asked for by the server. diff --git a/src/lembas/web/templates/admin/images.html b/src/lembas/web/templates/admin/images.html index c37f007..99584f9 100644 --- a/src/lembas/web/templates/admin/images.html +++ b/src/lembas/web/templates/admin/images.html @@ -100,6 +100,143 @@
+
+

What a generation uses by default

+

+ What every picture is drawn with unless the model names otherwise. Leave a + box empty to use the built-in value shown beside it — the built-ins are + SD1.5-era, and 512×512 on an SDXL checkpoint is what produces the + duplicated limbs. +

+ +
+ + {# A select rather than a box, from the list above. Typing a name that is + not there produced a picture drawn with whatever happened to be first, + with nothing anywhere saying so. #} + +
+ +
+
+ + +
+
+ + +
+
+ {# The five sizes anybody actually picks. Buttons rather than a select + because they set two fields at once, and a select of pairs would have to + name every combination somebody might want. #} +
+ {% for label, w, h in [ + ("512²", 512, 512), ("768²", 768, 768), ("1024²", 1024, 1024), + ("1024×1536", 1024, 1536), ("1536×1024", 1536, 1024) + ] %} + + {% endfor %} +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + {# From what the Test button read off ComfyUI. There are forty-odd and + spelling one wrong is a refused workflow, so it is picked rather than + typed — and the current value is kept as an option even when the list + has not been read, or saving this page would quietly clear it. #} + +
+
+ + +
+
+ + +
+
+ {% if not values.samplers %} +

+ Press Test & read what it has above to fill the sampler + and scheduler lists from your ComfyUI. +

+ {% endif %} + +
+ + +

+ Used when the model does not write one of its own. It writes one often, + so this is a floor rather than something always applied — “always add + these words” belongs in the instructions below, where the model is told + to include them. +

+
+
+

Checking the result

diff --git a/src/lembas/web/templates/admin/workflow_detail.html b/src/lembas/web/templates/admin/workflow_detail.html index 5b440da..7c1b3bd 100644 --- a/src/lembas/web/templates/admin/workflow_detail.html +++ b/src/lembas/web/templates/admin/workflow_detail.html @@ -75,15 +75,46 @@ "{{ '{{prompt}}' }}, masterpiece" works. Anything you leave out takes its default.

-

- Available: - {% for name in placeholders %}{{ '{{' ~ name ~ '}}' }}{{ ", " if not loop.last }}{% endfor %}. - {{ '{{prompt}}' }} is required — without it every image would - be the same. -

+
+

The placeholders

+

+ Every hole a template may carry, what it fills, and what it resolves to + right now. {{ '{{prompt}}' }} is required — without it every + image would be the same one, whatever anybody typed. +

+ {# The current value beside each name, the way /admin/prompts shows a + variable's. A legend that lists names and not values answers "what may I + write" and not "what will happen", and the second is the question + somebody has while looking at a workflow that came out wrong. #} +
+ {% for name, kind, what, current in placeholder_help %} +
+ {{ '{{' ~ name ~ '}}' }} + + {{ what }} + {% if name == "prompt" %}Required.{% endif %} + + {{ current }} +
+ {% endfor %} +
+

+ Two of these are not spelled the way ComfyUI spells them, which is the + mistake that costs an afternoon: {{ '{{model}}' }} fills + ckpt_name and {{ '{{sampler}}' }} fills + sampler_name. +

+

+ The right-hand column is what an omitted placeholder resolves to today — + the instance defaults from the image settings page, falling back to the + built-in floor. A model may override any of them except + {{ '{{batch}}' }}, which is yours alone. +

+
+