diff --git a/README.md b/README.md index eb9193c..b0f301c 100644 --- a/README.md +++ b/README.md @@ -380,7 +380,7 @@ This suite consists of three nodes found under the `sampling/testing` category: * **`add_random_seeds_to_gens`**: Generate X extra random variations per config. - `0` (default): Only use base seed - `3`: Generate 3 additional random seed variations per config - - Random seeds are deterministic per base seed — changing base seed generates new random variations + - Extra seeds are deterministic per base seed — the same base seed always gives the same extras, changing the base seed gives new ones * **`lookup_and_append_lora_triggerwords`**: Automatically fetch and append LoRA trigger words. - `False` (default): Use prompts as-is @@ -502,6 +502,12 @@ The Config Builder node replaces manual JSON editing with an intuitive visual in All configuration data is automatically synchronized with the Ultimate Sampler Grid node. +### Run Settings + +The Run Settings section of the Builder holds options that apply to the whole run rather than to one config array (overwrite/resume, flush batch size, VAE batch size, model cache, trigger-word mode, ...). One of them: + +* **Additional Random Seeds** (`add_random_seeds_to_gens`, 0-100): render this many extra images per config using extra seeds on top of the base seed. Handy for telling "the base model did that" from "the LoRA did that" by checking whether a quirk survives across seeds. The extra seeds are derived from the base seed, so the same base seed always gives the same extras (and resuming an interrupted run works). Contributed by DRuggeri (PR #21). + ### Basic Setup 1. **Add the Node:** Right-click on the canvas → `Add Node` → `sampling/testing` → `Ultimate Config Builder` @@ -1126,7 +1132,7 @@ Please check the [GitHub Issues list](https://github.com/YOUR_USERNAME/ComfyUI-U * **"Session not found":** Ensure the `session_name` matches a folder inside `ComfyUI/output/benchmarks/`. * **OOM Errors:** Lower the `vae_batch_size` to 1 or 2. * **Images not resuming:** Make sure `overwrite_existing: False`. Check console for skip messages. -* **Random seeds different each run:** Intentional — random seeds are tied to the base seed. +* **Extra random seeds changed:** Additional Random Seeds are derived from the base seed: the same base seed always gives the same extra seeds (so re-runs and resumed runs skip finished images), and a different base seed gives different ones. * **"mat1 and mat2 shapes cannot be multiplied":** Model architecture mismatch. For SD3/Flux/Z-Image, ensure you connect ALL optional inputs. Check LoRA compatibility. * **Dashboard not auto-loading:** Ensure the dashboard node is connected to the sampler's `dashboard_html` output. Try the "RELOAD / SHOW SESSION" button. @@ -1537,6 +1543,11 @@ After modifying backend Python files, restart ComfyUI. After modifying `web/` JS ## 📝 Changelog +### Update 11/3/26 — Additional Random Seeds finished, LTX prompt arrays +* 🌱 **Additional Random Seeds:** Run Settings control (0-100) now has full defaults/migration and tests; extra seeds are deterministic per base seed. Thanks to DRuggeri (PR #21). +* 🎬 **LTX Builder prompts:** Builder prompt arrays (`[["p1","p2"]]`) now expand into separate LTX runs instead of being passed as a list. +* 🔧 **Builder fixes:** empty text-encoder list falls back to `None`; reloading a saved Builder config keeps each model's type. + ### Update 3/9/26 — Upscaling, Workflow Packing & Config Builder Enhancements * 🔍 **Array-Based Upscaling System:** Define multiple upscale configs with Cartesian expansion across models, ratios, and denoise values. Five modes: hires_only, model_only, model_then_hires, seedvr2, and florence2_hires (targeted region inpaint via Florence2 segmentation — needs ComfyUI-Florence2). * 🖼️ **Upscale Model Discovery:** Auto-detects installed upscale models (RealESRGAN, etc.) from ComfyUI's folder system with searchable dropdown. diff --git a/config_utils.py b/config_utils.py index a6d1f58..4de7416 100644 --- a/config_utils.py +++ b/config_utils.py @@ -450,6 +450,27 @@ def _expand_ltx_entry(entry): return expanded +_EXTRA_SEEDS_SALT = "uscg-extra-seeds-v1" + + +def derive_extra_seeds(base_seed, count): + """Deterministic extra seeds for "Additional Random Seeds". + + The same (base_seed, count prefix) always yields the same seeds, so a + re-run or an interrupted run resumed later (skip matching is by seed) + renders the same images; a different base seed yields different extras. + """ + import random + try: + count = int(count) + except (TypeError, ValueError): + return [] + if count <= 0: + return [] + rng = random.Random(f"{_EXTRA_SEEDS_SALT}:{base_seed}") + return [rng.randint(0, 2**32 - 1) for _ in range(count)] + + def expand_configs(raw_configs, pos_prompts, neg_prompts, denoise_values, seed, extra_seeds, ckpt_name=None): """ Expand raw config entries into full configuration combinations. diff --git a/generation_orchestrator.py b/generation_orchestrator.py index 12f7a82..27c163d 100644 --- a/generation_orchestrator.py +++ b/generation_orchestrator.py @@ -810,7 +810,7 @@ def run_generation_loop( existing_data["session_name"] = session_name from .config_utils import ( - parse_json_with_error, parse_float_input, parse_prompt_input_nested, + parse_json_with_error, parse_float_input, parse_prompt_input_nested, derive_extra_seeds, expand_configs, prepare_input_jobs ) @@ -823,8 +823,9 @@ def run_generation_loop( extra_seeds = [] if add_random_seeds_to_gens > 0: - import random - extra_seeds = [random.randint(0, 2**32 - 1) for _ in range(add_random_seeds_to_gens)] + # Deterministic per base seed (see config_utils.derive_extra_seeds) so a + # re-run / resumed run matches already-rendered images by seed. + extra_seeds = derive_extra_seeds(seed, add_random_seeds_to_gens) expanded = expand_configs(raw_configs, pos_prompts, neg_prompts, denoise_values, seed, extra_seeds, ckpt_name) expanded.sort(key=lambda x: ( diff --git a/tests/test_random_seeds_setting.py b/tests/test_random_seeds_setting.py new file mode 100644 index 0000000..126a1ec --- /dev/null +++ b/tests/test_random_seeds_setting.py @@ -0,0 +1,111 @@ +"""Additional Random Seeds run setting: Builder emit -> sampler_node unwrap -> expand_configs, +plus the deterministic per-base-seed derivation (config_utils.derive_extra_seeds).""" +import importlib.util +import json +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from config_builder_node import UltimateConfigBuilder +from config_utils import derive_extra_seeds, expand_configs +from tests.test_state_to_configs_json import make_state # noqa: E402 + +_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_PKG = os.path.basename(_ROOT) + + +def _settings(**kw): + state = make_state() + state.update(kw) + return json.loads(UltimateConfigBuilder.state_to_configs_json(state))["_session_settings"] + + +def test_emit_value(): + assert _settings(add_random_seeds_to_gens=3)["add_random_seeds_to_gens"] == 3 + + +def test_emit_missing_defaults_zero(): + assert _settings()["add_random_seeds_to_gens"] == 0 + + +def test_emit_garbage_defaults_zero(): + assert _settings(add_random_seeds_to_gens="abc")["add_random_seeds_to_gens"] == 0 + + +def test_emit_numeric_string(): + assert _settings(add_random_seeds_to_gens="2")["add_random_seeds_to_gens"] == 2 + + +def test_derive_same_base_seed_same_list(): + assert derive_extra_seeds(123, 5) == derive_extra_seeds(123, 5) + assert len(derive_extra_seeds(123, 5)) == 5 + + +def test_derive_different_base_seed_differs(): + assert derive_extra_seeds(123, 5) != derive_extra_seeds(124, 5) + + +def test_derive_prefix_stable_and_zero_empty(): + assert derive_extra_seeds(7, 3) == derive_extra_seeds(7, 6)[:3] + assert derive_extra_seeds(7, 0) == [] + assert derive_extra_seeds(7, "x") == [] + + +def test_expand_configs_extra_seeds_multiply_count(): + raw = [{"model": "m.safetensors", "sampler": "euler", "scheduler": "normal", "steps": 5, "cfg": 1}] + base = expand_configs(raw, [""], [""], [1.0], 0, []) + more = expand_configs(raw, [""], [""], [1.0], 0, [11, 22]) + assert len(base) >= 1 + assert len(more) == 3 * len(base) + + +_REAL = {} + + +def _load_sampler_node(): + # conftest registers a STUB `.sampler_node`; load the real file under a private name. + if "mod" not in _REAL: + # the conftest stubs for the sibling modules lack some names sampler_node imports; + # add inert placeholders for the missing ones only (never overrides a real attribute). + for modname, names in { + "lora_utils": ["load_and_save_tags"], + "config_utils": ["parse_json_with_error", "parse_float_input", "parse_string_input", + "expand_configs", "prepare_input_jobs", "sanitize_session_name", + "normalize_str", "get_files_from_folder", "parse_lora_definition"], + }.items(): + stub = sys.modules.get(f"{_PKG}.{modname}") + for n in names: + if stub is not None and not hasattr(stub, n): + setattr(stub, n, lambda *a, **k: None) + spec = importlib.util.spec_from_file_location(f"{_PKG}.sampler_node_real", os.path.join(_ROOT, "sampler_node.py")) + mod = importlib.util.module_from_spec(spec) + mod.__package__ = _PKG + spec.loader.exec_module(mod) + _REAL["mod"] = mod + return _REAL["mod"] + + +def _run_tests_arg(monkeypatch, configs_json): + try: + sn = _load_sampler_node() + except Exception as exc: # stubs may be too thin for this module + pytest.skip(f"sampler_node not importable under stubs: {exc}") + captured = {} + monkeypatch.setattr(sys.modules["folder_paths"], "get_filename_list", lambda *_: [], raising=False) + orch = sys.modules[f"{_PKG}.generation_orchestrator"] + monkeypatch.setattr(orch, "run_generation_loop", lambda *a, **k: captured.setdefault("args", a) or ("",)) + sn.SamplerGridTester().run_tests(configs_json) + return captured["args"] + + +def test_sampler_node_unwraps_session_setting(monkeypatch): + args = _run_tests_arg(monkeypatch, json.dumps({"configs": [{}], "_session_settings": {"add_random_seeds_to_gens": 2}})) + assert args[13] == 2 + + +def test_sampler_node_default_zero(monkeypatch): + args = _run_tests_arg(monkeypatch, json.dumps({"configs": [{}]})) + assert args[13] == 0 diff --git a/web/conf_builder/conf-builder-main.js b/web/conf_builder/conf-builder-main.js index af1a0eb..ac93969 100644 --- a/web/conf_builder/conf-builder-main.js +++ b/web/conf_builder/conf-builder-main.js @@ -210,7 +210,8 @@ app.registerExtension({ lora_triggerwords_mode: "None", save_conditioning_cache_to_file: false, enable_model_cache: false, - vae_batch_size: 1 + vae_batch_size: 1, + add_random_seeds_to_gens: 0 }; // Create HTML container immediately @@ -321,6 +322,7 @@ app.registerExtension({ if (this.state.save_conditioning_cache_to_file === undefined) this.state.save_conditioning_cache_to_file = false; if (this.state.enable_model_cache === undefined) this.state.enable_model_cache = false; if (this.state.vae_batch_size === undefined) this.state.vae_batch_size = 1; + if (this.state.add_random_seeds_to_gens === undefined) this.state.add_random_seeds_to_gens = 0; this.saveState(); this.renderUI();