feat(editor): put the sampling settings on the export card

Render geometry, sampler + schedule, second pass when running, Studio
and Chain Sampler overrides. Only the selected algorithm's block is
printed, and pass 2 only when it is actually on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-08-23 03:41:25 +03:00
co-authored by Claude Opus 5
parent 7a2526a8b7
commit 5a791a96ed
4 changed files with 212 additions and 3 deletions
+4
View File
@@ -38,6 +38,10 @@
Numbered per kind, so marking an audio file never shifts the image slots. The picker
offers one past the highest number that node already uses, and an unmarked slot leaves its
socket unconnected in silence — never auto-wired to some other image, never reported.
- **Export settings… now covers the sampling too** — render geometry, the sampler and its
schedule, the second pass when it is running, and the Studio / Chain Sampler overrides.
Only the selected algorithm's settings are printed: a pass config always carries the
hybrid / distilled / normalizing blocks and just one of them is live.
- **Models & Pipeline ▸ Export settings…** renders the loaded pipeline as a PNG: every
loader with its full filename, every LoRA with its weight, the typed-in values of any
custom node, and the host's PyTorch / CUDA / attention / GPU. Only inputs that were typed
+85 -2
View File
@@ -116,8 +116,86 @@ def _host_rows(host: dict) -> list:
return rows
# The algorithm sub-blocks a pass config always carries. Only the one matching `type` is
# live; printing the other two would be the same lie as printing a wired input's stale widget.
_PASS_BLOCKS = {"Hybrid Euler 2S": "hybrid", "Distilled Flow": "distilled",
"Normalizing": "normalizing"}
# Widget names that say nothing about the render, or that are stored elsewhere on the card.
_SKIP_STUDIO_KEYS = {"studio_settings"}
def _pass_rows(cfg: dict) -> list:
"""One sampling pass: its schedule, then only the settings its own algorithm reads."""
cfg = cfg or {}
kind = str(cfg.get("type") or "Hybrid Euler 2S")
rows = [("algorithm", kind)]
scheduler = cfg.get("scheduler", cfg.get("ksampler_scheduler"))
if scheduler and scheduler != "use_user_sigmas":
rows.append(("schedule", _short(scheduler)))
rows.append(("steps", _short(cfg.get("steps", cfg.get("ksampler_steps")))))
else:
rows.append(("sigmas", _short(cfg.get("sigmas"))))
if kind == "KSampler":
rows.append(("sampler_name", _short(cfg.get("ksampler_name", "euler"))))
if float(cfg.get("ksampler_sharpness") or 0) > 0:
rows.append(("quality_sharpness", _short(cfg.get("ksampler_sharpness"))))
rows.append(("sharpen_last_pct", _short(cfg.get("ksampler_sharpen_start_pct", 0.35))))
return rows
block = cfg.get(_PASS_BLOCKS.get(kind, "")) or {}
for k, v in block.items():
rows.append((f" {k}", _short(v)))
return rows
def _sampling_sections(studio_inputs: dict, sampler_inputs: dict) -> list:
"""Everything that decided HOW it was sampled, as card sections.
The two dictionaries are different in kind: `studio_settings` holds a full config, while
`sampler_inputs` holds only the widgets the Editor overrode — so the first is filtered
down to what is live and the second is printed whole.
"""
out = []
studio_inputs = studio_inputs or {}
sampler_inputs = sampler_inputs or {}
settings = {}
raw = studio_inputs.get("studio_settings")
if isinstance(raw, str):
try:
settings = json.loads(raw)
except Exception:
settings = {}
elif isinstance(raw, dict):
settings = raw
samplers = settings.get("samplers") if isinstance(settings.get("samplers"), dict) else {}
if samplers.get("high"):
out.append({"title": "Sampler", "node_class": "", "rows": _pass_rows(samplers["high"])})
# Pass 2 only when it is actually running — an off feature's settings on the card read
# as if they were in effect.
if sampler_inputs.get("second_pass") and samplers.get("low"):
low = samplers["low"]
rows = ([("own sampler", "yes")] + _pass_rows(low)) if low.get("own_sampler") else \
[("own sampler", "no — reuses pass 1")] + _pass_rows(low)[1:2]
out.append({"title": "Second pass", "node_class": "", "rows": rows})
refiner = settings.get("refiner") if isinstance(settings.get("refiner"), dict) else {}
if refiner:
out.append({"title": "Studio", "node_class": "",
"rows": [(k, _short(v)) for k, v in refiner.items()]})
extras = {k: v for k, v in studio_inputs.items() if k not in _SKIP_STUDIO_KEYS}
if extras:
out.append({"title": "Studio inputs", "node_class": "",
"rows": [(k, _short(v)) for k, v in extras.items()]})
if sampler_inputs:
out.append({"title": "Chain Sampler", "node_class": "",
"rows": [(k, _short(v)) for k, v in sampler_inputs.items()]})
return out
def collect(models: dict, host: dict, *, project_name=None, version=None,
codename=None) -> dict:
codename=None, render=None, studio_inputs=None, sampler_inputs=None) -> dict:
"""The card's content, as data. Rendering is a separate step so this is testable."""
slots = (models or {}).get("slots") or []
sections = []
@@ -146,11 +224,16 @@ def collect(models: dict, host: dict, *, project_name=None, version=None,
"node_class": str(slot.get("node_class") or ""),
"rows": rows,
})
head = []
if render:
head.append({"title": "Render", "node_class": "",
"rows": [(k, _short(v)) for k, v in render.items()]})
head.extend(_sampling_sections(studio_inputs, sampler_inputs))
return {
"project": project_name or None,
"family": (models or {}).get("model_family") or None,
"host": _host_rows(host),
"sections": sections,
"sections": head + sections,
"version": version or "",
"codename": codename or "",
"generated": datetime.now().strftime("%Y-%m-%d %H:%M"),
+17 -1
View File
@@ -2504,11 +2504,27 @@ if web is not None and PromptServer is not None:
models_cfg = _project_models(p) if pid else nodes.load_models()
name = getattr(p, "name", None) if p else None
# The sampling settings matter at least as much as the model files: two runs on the
# same checkpoint with different schedules are different renders. Read off the project
# so the card describes THIS project, not the global defaults.
render = studio_in = sampler_in = None
if p is not None:
render = {
"size": f"{getattr(p, 'width', '?')}x{getattr(p, 'height', '?')}",
"frames per scene": getattr(p, "num_frames_per_scene", None),
"frame rate": getattr(p, "frame_rate", None),
"max scenes": getattr(p, "max_scenes", None),
"mode": getattr(p, "generation_mode", None),
}
studio_in = dict(getattr(p, "studio_inputs", None) or {})
sampler_in = dict(getattr(p, "sampler_inputs", None) or {})
def _build():
st = git_update.status()
report = settings_card.collect(
models_cfg, sysinfo.collect(), project_name=name,
version=st.get("version"), codename=st.get("codename"))
version=st.get("version"), codename=st.get("codename"),
render=render, studio_inputs=studio_in, sampler_inputs=sampler_in)
return settings_card.render_png(report, theme)
try:
+106
View File
@@ -205,3 +205,109 @@ def test_long_values_wrap_instead_of_running_off_the_card():
lines = sc._wrap("a" * 200, 40)
assert all(len(x) <= 40 for x in lines)
assert "".join(lines) == "a" * 200
# ── sampling settings ─────────────────────────────────────────────────────────
# Two runs on the same checkpoint with different schedules are different renders, so the
# model files alone do not answer "what produced this".
STUDIO = {"studio_settings": json.dumps({
"samplers": {
"high": {"type": "Hybrid Euler 2S", "sigmas": "1.0, 0.5, 0.0",
"hybrid": {"eta": 0.7, "quality_sharpness": 0.3},
"distilled": {"order": 2}, "normalizing": {"normalize_strength": 0.5}},
"low": {"type": "KSampler", "sigmas": "0.4, 0.0", "ksampler_name": "res_multistep"},
},
"refiner": {"mode": "Refine", "negative_erase": True, "negative_erase_strength": 0.5},
})}
def _card(**kw):
return sc.collect(_models(), HOST, **kw)
def _titles(rep):
return [s["title"] for s in rep["sections"]]
def test_the_sampler_and_its_schedule_are_on_the_card():
rows = dict(next(s for s in _card(studio_inputs=STUDIO)["sections"]
if s["title"] == "Sampler")["rows"])
assert rows["algorithm"] == "Hybrid Euler 2S"
assert rows["sigmas"] == "1.0, 0.5, 0.0"
def test_only_the_selected_algorithms_settings_are_shown():
"""A pass config always carries hybrid/distilled/normalizing blocks; only one is live.
Printing all three is the same lie as printing a wired input's stale widget."""
rows = dict(next(s for s in _card(studio_inputs=STUDIO)["sections"]
if s["title"] == "Sampler")["rows"])
assert rows[" eta"] == "0.7"
assert not any("order" in k for k in rows) # Distilled Flow is not selected
assert not any("normalize_strength" in k for k in rows)
def test_a_computed_schedule_shows_steps_instead_of_sigmas():
studio = {"studio_settings": json.dumps({"samplers": {"high": {
"type": "KSampler", "scheduler": "karras", "steps": 12, "ksampler_name": "euler"}}})}
rows = dict(next(s for s in _card(studio_inputs=studio)["sections"]
if s["title"] == "Sampler")["rows"])
assert rows["schedule"] == "karras" and rows["steps"] == "12"
assert "sigmas" not in rows
def test_ksampler_sharpness_is_only_listed_when_it_is_on():
on = {"studio_settings": json.dumps({"samplers": {"high": {
"type": "KSampler", "ksampler_sharpness": 0.3}}})}
off = {"studio_settings": json.dumps({"samplers": {"high": {"type": "KSampler"}}})}
assert any("quality_sharpness" in k for k, _ in
next(s for s in _card(studio_inputs=on)["sections"]
if s["title"] == "Sampler")["rows"])
assert not any("quality_sharpness" in k for k, _ in
next(s for s in _card(studio_inputs=off)["sections"]
if s["title"] == "Sampler")["rows"])
def test_the_second_pass_appears_only_when_it_is_running():
"""An off feature's settings on the card read as if they were in effect."""
assert "Second pass" not in _titles(_card(studio_inputs=STUDIO))
on = _card(studio_inputs=STUDIO, sampler_inputs={"second_pass": True})
assert "Second pass" in _titles(on)
rows = dict(next(s for s in on["sections"] if s["title"] == "Second pass")["rows"])
assert rows["own sampler"].startswith("no") # own_sampler not set
assert rows["sigmas"] == "0.4, 0.0"
def test_a_second_pass_with_its_own_sampler_says_which():
studio = {"studio_settings": json.dumps({"samplers": {
"high": {"type": "Hybrid Euler 2S"},
"low": {"type": "KSampler", "own_sampler": True, "ksampler_name": "res_multistep"}}})}
rows = dict(next(s for s in _card(studio_inputs=studio,
sampler_inputs={"second_pass": True})["sections"]
if s["title"] == "Second pass")["rows"])
assert rows["own sampler"] == "yes"
assert rows["sampler_name"] == "res_multistep"
def test_studio_and_chain_sampler_overrides_are_listed():
rep = _card(studio_inputs=STUDIO, sampler_inputs={"alg_anchor": True, "second_pass": False})
assert dict(next(s for s in rep["sections"]
if s["title"] == "Studio")["rows"])["negative_erase"] == "on"
assert dict(next(s for s in rep["sections"]
if s["title"] == "Chain Sampler")["rows"])["alg_anchor"] == "on"
def test_the_render_geometry_leads_the_card():
rep = _card(render={"size": "768x512", "frame rate": 25})
assert rep["sections"][0]["title"] == "Render"
assert dict(rep["sections"][0]["rows"])["size"] == "768x512"
def test_a_project_with_no_sampling_settings_adds_no_empty_sections():
assert _titles(_card()) == _titles(sc.collect(_models(), HOST))
def test_unparseable_studio_settings_do_not_break_the_card():
rep = _card(studio_inputs={"studio_settings": "{not json"})
assert sc.render_png(rep, "dark")[:8] == b"\x89PNG\r\n\x1a\n"