From 5a791a96edefe604db2ad55fd0347b39c8d41270 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:41:25 +0300 Subject: [PATCH] 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 --- CHANGELOG.md | 4 + movie_editor/backend/settings_card.py | 87 ++++++++++++++++++- movie_editor/server.py | 18 +++- movie_editor/tests/test_settings_card.py | 106 +++++++++++++++++++++++ 4 files changed, 212 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15e62058..9ccde9bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/movie_editor/backend/settings_card.py b/movie_editor/backend/settings_card.py index 052b4fdc..bec58f37 100644 --- a/movie_editor/backend/settings_card.py +++ b/movie_editor/backend/settings_card.py @@ -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"), diff --git a/movie_editor/server.py b/movie_editor/server.py index 6c8e096d..fe05b739 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -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: diff --git a/movie_editor/tests/test_settings_card.py b/movie_editor/tests/test_settings_card.py index 5cde4a19..3bbda393 100644 --- a/movie_editor/tests/test_settings_card.py +++ b/movie_editor/tests/test_settings_card.py @@ -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"