diff --git a/movie_editor/backend/settings_card.py b/movie_editor/backend/settings_card.py index bec58f37..70293c9b 100644 --- a/movie_editor/backend/settings_card.py +++ b/movie_editor/backend/settings_card.py @@ -148,6 +148,127 @@ def _pass_rows(cfg: dict) -> list: return rows +# ── what is worth printing ─────────────────────────────────────────────────── +# +# The card answers "what produced this video". A setting left at its default produced +# nothing, and a setting belonging to a switched-off feature produced nothing either — a +# region-sharpening prompt printed under a disabled sharpener reads as if it ran. Both are +# noise, and enough of it buries the handful of values that DID decide the render. +# +# Two rules, both the user's: skip a value that equals the widget's own default, and skip a +# value whose feature is off. + +#: knob -> (the toggle that owns it, the value of that toggle which switches it on). +#: None means "any truthy value". Mirrored from the Editor's own dependsOn wiring, and a +#: test regenerates this from engine_settings.js so the two cannot drift apart. +_OWNED_BY = { + "absolute_strength": ("steer_mode", None), + "alg_anchor_sigma_threshold": ("alg_anchor", None), + "alg_anchor_strength": ("alg_anchor", None), + "alg_guide_blur_sigma_threshold": ("alg_blur_guides", None), + "alg_guide_blur_strength": ("alg_blur_guides", None), + "arcface_mode": ("identity_transfer_enabled", None), + "context_window_freenoise": ("context_windows", None), + "context_window_fuse": ("context_windows", None), + "context_window_length": ("context_windows", None), + "context_window_overlap": ("context_windows", None), + "context_window_retain_first": ("context_windows", None), + "context_window_schedule": ("context_windows", None), + "debug_log": ("identity_transfer_enabled", None), + "detail_max_area": ("segmented_detailing", None), + "detail_mode": ("segmented_detailing", None), + "detail_strength": ("segmented_detailing", None), + "detail_targets": ("segmented_detailing", None), + "detail_threshold": ("segmented_detailing", None), + "dynashift_strength": ("dynashift", None), + "dynashift_threshold": ("dynashift", None), + "embed_guidance_source": ("embed_guidance", None), + "embed_guidance_strength": ("embed_guidance", None), + "h3_gain_audio": ("h3_gain_mode", "manual"), + "h3_gain_prompt": ("h3_gain_mode", "manual"), + "h3_gain_video": ("h3_gain_mode", "manual"), + "h3_prompt_scale": ("h3_gain_mode", "manual"), + "h3_taste_bias": ("h3_gain_mode", "manual"), + "h3_video_detail": ("h3_gain_mode", "manual"), + "id_strength": ("identity_transfer_enabled", None), + "joyai_audio_memory": ("joyai_memory", None), + "joyai_fix_frames": ("joyai_memory", None), + "joyai_frame_select": ("joyai_memory", None), + "joyai_memory_size": ("joyai_memory", None), + "joyai_memory_strength": ("joyai_memory", None), + "mid_scene_guide_strength": ("mid_scene_guide", None), + "negative_erase_mode": ("negative_erase", None), + "negative_erase_renorm": ("negative_erase", None), + "negative_erase_strength": ("negative_erase", None), + "output_guidance_strength": ("output_guidance", None), + "phase_scale": ("identity_transfer_enabled", None), + "plateau_cache_threshold": ("plateau_cache", None), + "prompt_enhance_max_length": ("prompt_enhance", None), + "prompt_enhance_system": ("prompt_enhance", None), + "prompt_enhance_temperature": ("prompt_enhance", None), + "prompt_enhance_thinking": ("prompt_enhance", None), + "prompt_enhance_top_p": ("prompt_enhance", None), + "score_slider_strength": ("score_slider", None), + "source_id": ("identity_transfer_enabled", None), + "v2a_grad_scale": ("joyai_audio_memory", None), +} + + +def _node_defaults(node_class: str) -> dict: + """Declared defaults for one node's widgets, read off the node itself. + + Off the NODE rather than a table here: the node is the thing whose value is being + printed, so a default copied to this file would be one more thing to keep in step. + """ + try: + from . import bridge + spec = bridge._funpack_attr("samplers" if "Sampler" in node_class else "conditioning", + node_class).INPUT_TYPES() + except Exception: + return {} + out = {} + for group in ("required", "optional"): + for name, decl in (spec.get(group) or {}).items(): + if not isinstance(decl, (list, tuple)) or not decl: + continue + if len(decl) >= 2 and isinstance(decl[1], dict) and "default" in decl[1]: + out[name] = decl[1]["default"] + elif isinstance(decl[0], (list, tuple)) and decl[0]: + out[name] = decl[0][0] # a combo's first entry is its default + return out + + +def _switched_on(name: str, values: dict, defaults: dict, _seen=None) -> bool: + """Is the feature this knob belongs to actually running? + + Walks the chain: v2a_grad_scale belongs to joyai_audio_memory, which belongs to + joyai_memory. An owner that is itself off takes everything under it with it. + """ + owner, wanted = _OWNED_BY.get(name, (None, None)) + if owner is None: + return True + seen = _seen or set() + if owner in seen: # a cycle in the table is a bug, not a hang + return True + seen.add(owner) + current = values.get(owner, defaults.get(owner)) + live = bool(current) if wanted is None else str(current) == str(wanted) + return live and _switched_on(owner, values, defaults, seen) + + +def _live_rows(values: dict, node_class: str) -> list: + """`values` filtered down to what actually decided this render.""" + defaults = _node_defaults(node_class) + rows = [] + for name, value in (values or {}).items(): + if name in defaults and value == defaults[name]: + continue + if not _switched_on(name, values, defaults): + continue + rows.append((name, _short(value))) + return rows + + def _sampling_sections(studio_inputs: dict, sampler_inputs: dict) -> list: """Everything that decided HOW it was sampled, as card sections. @@ -184,13 +305,13 @@ def _sampling_sections(studio_inputs: dict, sampler_inputs: dict) -> list: 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} + extras = _live_rows({k: v for k, v in studio_inputs.items() + if k not in _SKIP_STUDIO_KEYS}, "FunPackStudio") 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()]}) + out.append({"title": "Studio inputs", "node_class": "", "rows": extras}) + chain = _live_rows(sampler_inputs, "FunPackLTXAVSceneChainSampler") + if chain: + out.append({"title": "Chain Sampler", "node_class": "", "rows": chain}) return out diff --git a/tests/test_settings_card_noise.py b/tests/test_settings_card_noise.py new file mode 100644 index 00000000..a4842b5c --- /dev/null +++ b/tests/test_settings_card_noise.py @@ -0,0 +1,131 @@ +"""The settings card prints what produced the video, not every widget that exists. + +Reported from a rental: too much on the card to read, including a region-sharpening prompt +printed under a sharpener that was switched off. A value left at its default produced +nothing, and a value belonging to a disabled feature produced nothing either — but printed +side by side with the handful that DID decide the render, they read as if they had. + +Two rules: skip a value equal to the widget's own default, and skip a value whose feature +is off. +""" +import re +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from movie_editor.backend import settings_card as sc + +FRONTEND = Path(__file__).resolve().parents[1] / "movie_editor" / "frontend" + + +@pytest.fixture +def defaults(monkeypatch): + """Stand in for the node's declared defaults, so these tests describe the RULE.""" + table = { + "segmented_detailing": False, "detail_targets": "hands, face", + "context_windows": False, "context_window_overlap": 40, + "joyai_memory": False, "joyai_audio_memory": False, "v2a_grad_scale": 1.0, + "h3_gain_mode": "learned", "h3_video_detail": 1.0, + "cfg": 1.0, "frame_overlap": 8, + } + monkeypatch.setattr(sc, "_node_defaults", lambda _cls: table) + return table + + +def rows(values, defaults_unused=None): + return dict(sc._live_rows(values, "FunPackLTXAVSceneChainSampler")) + + +# ── rule 1: a default decided nothing ─────────────────────────────────────── + +def test_a_value_left_at_its_default_is_not_printed(defaults): + assert rows({"cfg": 1.0}) == {} + + +def test_a_changed_value_is_printed(defaults): + assert "cfg" in rows({"cfg": 3.5}) + + +def test_a_value_the_node_does_not_declare_is_kept(defaults): + """Not every key on the card comes from a widget. Unknown means unjudgeable, and + dropping it would hide something the card was asked to show.""" + assert "mystery" in rows({"mystery": 7}) + + +# ── rule 2: an off feature decided nothing ────────────────────────────────── + +def test_a_setting_under_a_disabled_toggle_is_not_printed(defaults): + """The one that was reported: a region-sharpening prompt under a disabled sharpener.""" + assert rows({"detail_targets": "the hands, the face"}) == {} + + +def test_the_same_setting_is_printed_once_its_feature_is_on(defaults): + got = rows({"segmented_detailing": True, "detail_targets": "the hands, the face"}) + assert got["detail_targets"] == "the hands, the face" + assert "segmented_detailing" in got + + +def test_a_default_value_under_an_enabled_toggle_is_still_dropped(defaults): + """The two rules are independent: on does not make a default worth printing.""" + got = rows({"context_windows": True, "context_window_overlap": 40}) + assert "context_window_overlap" not in got + + +def test_a_chain_of_owners_is_walked_to_the_end(defaults): + """v2a_grad_scale belongs to joyai_audio_memory, which belongs to joyai_memory. An + owner that is itself off takes everything under it with it.""" + assert rows({"joyai_audio_memory": True, "v2a_grad_scale": 0.5}) == {} + got = rows({"joyai_memory": True, "joyai_audio_memory": True, "v2a_grad_scale": 0.5}) + assert "v2a_grad_scale" in got + + +def test_an_owner_matched_by_value_not_by_truthiness(defaults): + """h3_gain_mode is a combo: 'manual' switches its dials on, 'learned' does not — and + 'learned' is a perfectly truthy string.""" + assert rows({"h3_video_detail": 1.4}) == {} + assert "h3_video_detail" in rows({"h3_gain_mode": "manual", "h3_video_detail": 1.4}) + + +def test_a_cycle_in_the_table_does_not_hang(monkeypatch, defaults): + monkeypatch.setitem(sc._OWNED_BY, "a", ("b", None)) + monkeypatch.setitem(sc._OWNED_BY, "b", ("a", None)) + assert sc._live_rows({"a": 1, "b": 1}, "X") is not None + + +# ── the table matches the Editor it was copied from ───────────────────────── + +def test_the_ownership_table_is_the_editors_own(): + """The Editor hides a knob whose feature is off; the card must hide the same ones. Two + copies of one rule in two languages, so the test regenerates this one from that one.""" + src = (FRONTEND / "engine_settings.js").read_text(encoding="utf-8") + found = {} + for name, body in re.findall(r'\{\s*name:\s*"([a-z0-9_]+)"(.*?)\n\s*(?=\{ name:|\];)', + src, re.S): + m = re.search(r'dependsOn:\s*"([a-z0-9_]+)"(?:,\s*dependsValue:\s*"([^"]*)")?', body) + if m: + found[name] = (m.group(1), m.group(2)) + assert found, "no dependsOn rows parsed — the parser, not the table, is what broke" + assert found == sc._OWNED_BY + + +# ── the sections it feeds ─────────────────────────────────────────────────── + +def test_an_empty_section_is_dropped_rather_than_printed_bare(defaults): + out = sc._sampling_sections({}, {"cfg": 1.0}) + assert not [s for s in out if s["title"] == "Chain Sampler"] + + +def test_a_section_with_something_to_say_survives(defaults): + out = sc._sampling_sections({}, {"cfg": 3.5}) + chain = next(s for s in out if s["title"] == "Chain Sampler") + assert chain["rows"] == [("cfg", "3.5")] + + +def test_defaults_are_read_off_the_node_not_copied_here(): + """A default copied into this file is one more thing to keep in step with the node.""" + import inspect + src = inspect.getsource(sc._node_defaults) + assert "INPUT_TYPES()" in src