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:
co-authored by
Claude Opus 5
parent
7a2526a8b7
commit
5a791a96ed
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user