diff --git a/ProjectStructure.md b/ProjectStructure.md index cabe861..e67359f 100644 --- a/ProjectStructure.md +++ b/ProjectStructure.md @@ -310,6 +310,7 @@ All routes registered via `PromptServer.instance.routes`. * `prepare_input_jobs()` — final job list preparation * `parse_lora_definition()` — parses `"name:model_str:clip_str"` format * `sanitize_session_name()`, `get_files_from_folder()` + * Job plan + preview helpers (pure Python, unit-tested): `build_job_plan()`, `order_job_plan()` (as_written / minimize_reloads), `count_switches()`, `config_fingerprint()` / `item_fingerprint()`, `preview_dims()`, `build_preview_index()`, `should_skip_for_previews()`, `build_full_fp_set()`, `collect_rejected_preview_fps()` * **`manifest_utils.py`** (120 lines) * `load_existing_manifest()`, `save_manifest()`, `merge_manifest_user_changes()` diff --git a/README.md b/README.md index eb9193c..40b9cd0 100644 --- a/README.md +++ b/README.md @@ -775,6 +775,15 @@ This expands into all combinations (3 × 2 × 2 = 12 prompts): The editor provides a visual chip interface for building and editing these variation groups. Each group is displayed as a clickable chip that can be expanded to add/remove options. Multiple groups can be nested for exponential prompt variation. +### Job Order & Preview Pass (Run Settings) +Both live in the **Run Settings** section of the Config Builder and default to the old behaviour. + +* **Job Order:** `As written (default)` runs one resolution pass after another, exactly as before. `Minimize model/LoRA reloads` re-orders the whole job list so each model, LoRA stack and VAE loads as few times as possible (resolutions interleave, which costs nothing; only a new empty latent). Example: 3 models x 3 LoRA stacks x 3 resolutions loads 9 models and patches 27 LoRA stacks as written, but 3 and 9 with Minimize. The log prints the before/after counts. **Start At Job #** numbers follow the chosen order. Ignored in distributed mode (a warning is logged). +* **Preview Pass:** renders every cell cheaply first (fewer **Preview Steps**, smaller image via **Preview Scale** x0.25/0.33/0.5/0.75 snapped to the model's latent grid, optional **Preview CFG Override**, upscaling skipped) so you see the whole grid in a fraction of the time. Preview images carry a `PREVIEW` badge on the Dashboard. Reject the losers, then **untick Preview Pass and queue again**: only the surviving cells render at full quality, and each full image replaces its preview in the default Dashboard view. Interrupted preview passes resume (previews already done are skipped), and a preview pass skips cells that already have a full image. Not used when a latent input is connected; LTX video configs always render normally; ignored in distributed mode. +* **Skip Rejected Previews** (default on) and **Only Favorited Previews**: apply when Preview Pass is OFF. Cells whose preview you rejected are skipped, including after **Delete Rejected** (the rejection is remembered in `meta.rejected_preview_fps`). With *Only Favorited*, a cell that has a preview is rendered only if that preview is favorited; cells with no preview are still rendered. `Overwrite Existing` does not override a rejection. +* A cell is identified by a **config fingerprint** (model, LoRA stack, VAE, text encoders, sampler, scheduler, CFG, denoise, seed, raw prompts, full resolution, plus advanced-sampling/SLG/deep-shrink settings; *not* steps). Cells whose seed is re-randomised on every run (`random_before`, extra random seeds) cannot be matched to a preview and simply render again. +* ETA: preview and full jobs cost roughly 10x apart and the rolling average lags when slow models are grouped together; treat the ETA as approximate under Minimize. + --- ## 🖥️ Dashboard Interface @@ -1491,6 +1500,8 @@ This is a **hybrid application** with three layers: 12. **ComfyUI Registry security** — No `import requests`, no `subprocess`/`eval`/`exec`, no custom file-serving endpoints. Use `urllib.request` for HTTP and ComfyUI's `/view` endpoint for serving files. See `ProjectStructure.md` for full rules. +13. **Job plan and config fingerprint** — `run_generation_loop` walks a flat `job_plan` (`config_utils.build_job_plan`, one entry per job, binding `conf, conf_idx, job_idx, job, w, h, batch_idx`) instead of nested resolution x config loops; `order_job_plan` reorders it for `job_order: minimize_reloads` (stable sort on first-appearance ranks). `check_if_job_completed(..., preview=False)` keeps its positional signature (the distributed companion calls it positionally); the new `preview` flag is keyword-only and previews/full items never match each other. `config_fingerprint()` is the identity of a grid cell (stored as `config_fp` on every new item; preview items add `preview`, `preview_of`, `full_*`; upscaled items add `base_fp`). Bump `FP_VERSION` (and keep the old reader) if you ever change its payload; a golden-value test pins it. The main-loop body still sits under an `if True:` only to keep its indentation (diff size) after flattening. Preview/promotion/job-order features are ignored by the distributed path. + ### File Dependencies (Import Graph) ``` diff --git a/config_builder_node.py b/config_builder_node.py index 02df6b4..434baef 100644 --- a/config_builder_node.py +++ b/config_builder_node.py @@ -937,6 +937,35 @@ class UltimateConfigBuilder: except (TypeError, ValueError): session_settings["add_random_seeds_to_gens"] = 0 + # Job order + preview pass (coarse-to-fine grids) — always emitted so older workflows get the + # defaults (as_written, preview off) and the Builder UI stays authoritative. + job_order = str(state.get("job_order", "as_written")) + session_settings["job_order"] = job_order if job_order in ("as_written", "minimize_reloads") else "as_written" + pv_state = state.get("preview_pass") + if not isinstance(pv_state, dict): + pv_state = {} + try: + pv_steps = max(1, int(pv_state.get("steps", 8))) + except (TypeError, ValueError): + pv_steps = 8 + try: + pv_scale = min(1.0, max(0.05, float(pv_state.get("scale", 0.5)))) + except (TypeError, ValueError): + pv_scale = 0.5 + pv_cfg = pv_state.get("cfg_override") + try: + pv_cfg = None if pv_cfg is None or str(pv_cfg).strip() == "" else float(pv_cfg) + except (TypeError, ValueError): + pv_cfg = None + session_settings["preview_pass"] = { + "enabled": bool(pv_state.get("enabled", False)), + "steps": pv_steps, + "scale": pv_scale, + "cfg_override": pv_cfg, + } + session_settings["skip_rejected_previews"] = bool(state.get("skip_rejected_previews", True)) + session_settings["only_favorited_previews"] = bool(state.get("only_favorited_previews", False)) + if session_settings: output_obj["_session_settings"] = session_settings diff --git a/tests/test_state_to_configs_json.py b/tests/test_state_to_configs_json.py index eda9faf..8f24509 100644 --- a/tests/test_state_to_configs_json.py +++ b/tests/test_state_to_configs_json.py @@ -200,3 +200,48 @@ def test_legacy_dual_array_preserves_cartesian_form(): expected = "legacyLora:[0.5, 1.0]:[0.7, 1.0]" assert any(l == expected for l in loras), \ f"Expected legacy dual-array form {expected!r}. Got: {loras}" + + +# ---- job order + preview pass run settings ----------------------------------- + +def _ss(**state_extra): + state = make_state() + state.update(state_extra) + return json.loads(UltimateConfigBuilder.state_to_configs_json(state))["_session_settings"] + + +def test_preview_and_job_order_defaults_are_emitted(): + ss = _ss() + assert ss["job_order"] == "as_written" + assert ss["preview_pass"] == {"enabled": False, "steps": 8, "scale": 0.5, "cfg_override": None} + assert ss["skip_rejected_previews"] is True + assert ss["only_favorited_previews"] is False + + +def test_preview_settings_are_coerced(): + ss = _ss(job_order="minimize_reloads", + preview_pass={"enabled": 1, "steps": "12", "scale": "0.33", "cfg_override": "3.5"}, + skip_rejected_previews=0, only_favorited_previews=1) + assert ss["job_order"] == "minimize_reloads" + assert ss["preview_pass"] == {"enabled": True, "steps": 12, "scale": 0.33, "cfg_override": 3.5} + assert ss["skip_rejected_previews"] is False + assert ss["only_favorited_previews"] is True + + +def test_preview_bad_types_fall_back_and_clamp(): + ss = _ss(preview_pass={"enabled": True, "steps": "abc", "scale": "x", "cfg_override": "nope"}) + assert ss["preview_pass"] == {"enabled": True, "steps": 8, "scale": 0.5, "cfg_override": None} + ss = _ss(preview_pass={"steps": 0, "scale": 7}) + assert ss["preview_pass"]["steps"] == 1 and ss["preview_pass"]["scale"] == 1.0 + assert _ss(preview_pass={"scale": 0})["preview_pass"]["scale"] == 0.05 + assert _ss(preview_pass="garbage")["preview_pass"]["enabled"] is False + + +def test_cfg_override_blank_is_null_and_zero_is_kept(): + assert _ss(preview_pass={"cfg_override": ""})["preview_pass"]["cfg_override"] is None + assert _ss(preview_pass={"cfg_override": None})["preview_pass"]["cfg_override"] is None + assert _ss(preview_pass={"cfg_override": 0})["preview_pass"]["cfg_override"] == 0.0 + + +def test_unknown_job_order_becomes_as_written(): + assert _ss(job_order="alphabetical")["job_order"] == "as_written" diff --git a/web/conf_builder/conf-builder-config-management.js b/web/conf_builder/conf-builder-config-management.js index 4cea30d..204cdd8 100644 --- a/web/conf_builder/conf-builder-config-management.js +++ b/web/conf_builder/conf-builder-config-management.js @@ -6958,6 +6958,10 @@ export function renderRunSettingsSection(node, container) { if (node.state.enable_model_cache === undefined) node.state.enable_model_cache = false; if (node.state.vae_batch_size === undefined) node.state.vae_batch_size = 1; if (node.state.add_random_seeds_to_gens === undefined) node.state.add_random_seeds_to_gens = 0; + if (node.state.job_order === undefined) node.state.job_order = "as_written"; + if (node.state.preview_pass === undefined) node.state.preview_pass = { enabled: false, steps: 8, scale: 0.5, cfg_override: null }; + if (node.state.skip_rejected_previews === undefined) node.state.skip_rejected_previews = true; + if (node.state.only_favorited_previews === undefined) node.state.only_favorited_previews = false; const section = document.createElement("div"); section.className = "cb-section full-width"; @@ -7118,6 +7122,101 @@ export function renderRunSettingsSection(node, container) { _addRunSetting(content, { stateKey: "add_random_seeds_to_gens", label: "Additional Random Seeds", kind: "int", min: 0, max: 100, defaultValue: 0, tooltip: "Generate this many extra images per config using additional random seeds. 0 = disabled." }); + // --- Job order + preview pass (coarse-to-fine grids) --------------------------- + // Small local row helper with the same look as _addRunSetting; handles labelled selects and the + // nested preview_pass fields, which _addRunSetting (flat keys only) cannot bind. + function _addRunRow(labelText, tooltip, buildInput) { + const row = document.createElement("div"); + row.style.cssText = "margin-top: 10px; display: flex; align-items: center; gap: 8px;"; + const label = document.createElement("label"); + label.textContent = labelText + ":"; + label.style.cssText = "font-size: 12px; color: #ccc; white-space: nowrap;"; + const help = document.createElement("span"); + help.textContent = "?"; + help.title = tooltip; + help.style.cssText = "display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; background: #2a2a2a; color: #8af; font-size: 10px; font-weight: bold; cursor: help; user-select: none;"; + row.appendChild(label); + row.appendChild(help); + row.appendChild(buildInput()); + content.appendChild(row); + return row; + } + const _inputCss = "background: #1a1a1a; color: #ccc; border: 1px solid #444; border-radius: 4px; padding: 4px 6px; font-size: 12px;"; + const _pv = () => { + if (!node.state.preview_pass || typeof node.state.preview_pass !== "object") { + node.state.preview_pass = { enabled: false, steps: 8, scale: 0.5, cfg_override: null }; + } + return node.state.preview_pass; + }; + + _addRunRow("Job Order", "As written = resolution passes run one after another (the order used before this option existed). Minimize reloads = group jobs so each model, LoRA stack and VAE loads as few times as possible (resolutions interleave, which is free). Note: Start At Job # numbers follow the chosen order. Ignored in distributed mode.", () => { + const sel = document.createElement("select"); + sel.style.cssText = _inputCss; + [["as_written", "As written (default)"], ["minimize_reloads", "Minimize model/LoRA reloads"]].forEach(([v, t]) => { + const o = document.createElement("option"); + o.value = v; o.textContent = t; + if (v === (node.state.job_order || "as_written")) o.selected = true; + sel.appendChild(o); + }); + sel.onchange = () => { node.state.job_order = sel.value; node.saveState(); }; + return sel; + }); + + _addRunRow("Preview Pass", "Render every cell cheaply first (fewer steps, smaller image, no upscales). Reject the losers on the Dashboard, then untick this and queue again: only the survivors render at full quality, and each full image replaces its preview in the Dashboard. Not used when a latent input is connected. Ignored in distributed mode.", () => { + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.checked = !!_pv().enabled; + cb.style.cssText = "transform: scale(1.2); cursor: pointer;"; + cb.onchange = () => { _pv().enabled = cb.checked; node.saveState(); }; + return cb; + }); + _addRunRow(" Preview Steps", "Sampling steps used for preview renders (1-200).", () => { + const inp = document.createElement("input"); + inp.type = "number"; inp.min = 1; inp.max = 200; + inp.value = _pv().steps !== undefined ? _pv().steps : 8; + inp.style.cssText = _inputCss + " width: 70px;"; + inp.onchange = () => { + const v = parseInt(inp.value); + _pv().steps = isNaN(v) ? 8 : Math.min(200, Math.max(1, v)); + inp.value = _pv().steps; + node.saveState(); + }; + return inp; + }); + _addRunRow(" Preview Scale", "Preview resolution as a fraction of each job's resolution (snapped to the model's latent grid).", () => { + const sel = document.createElement("select"); + sel.style.cssText = _inputCss; + [0.25, 0.33, 0.5, 0.75].forEach(v => { + const o = document.createElement("option"); + o.value = String(v); o.textContent = "x" + v; + if (v === Number(_pv().scale)) o.selected = true; + sel.appendChild(o); + }); + sel.onchange = () => { _pv().scale = parseFloat(sel.value); node.saveState(); }; + return sel; + }); + _addRunRow(" Preview CFG Override", "Optional CFG for preview renders. Leave blank to use each config's own CFG.", () => { + const inp = document.createElement("input"); + inp.type = "number"; inp.step = "0.1"; inp.min = 0; + inp.placeholder = "config CFG"; + inp.value = (_pv().cfg_override === null || _pv().cfg_override === undefined) ? "" : _pv().cfg_override; + inp.style.cssText = _inputCss + " width: 90px;"; + inp.onchange = () => { + const v = parseFloat(inp.value); + _pv().cfg_override = (inp.value === "" || isNaN(v)) ? null : v; + node.saveState(); + }; + return inp; + }); + _addRunSetting(content, { stateKey: "skip_rejected_previews", label: "Skip Rejected Previews", kind: "bool", + tooltip: "Applies when Preview pass is OFF: cells whose preview you rejected are skipped (also after Delete Rejected)." }); + _addRunSetting(content, { stateKey: "only_favorited_previews", label: "Only Favorited Previews", kind: "bool", + tooltip: "Applies when Preview pass is OFF: stricter than the option above. Cells that have a preview are rendered only if that preview is favorited. Cells with no preview at all are still rendered." }); + const _pvHint = document.createElement("div"); + _pvHint.style.cssText = "margin-top: 6px; font-size: 11px; color: #888;"; + _pvHint.textContent = "Full pass: untick Preview Pass and queue again (or use the Dashboard's Full pass for survivors button)."; + content.appendChild(_pvHint); + section.appendChild(header); section.appendChild(content); container.appendChild(section); diff --git a/web/conf_builder/conf-builder-main.js b/web/conf_builder/conf-builder-main.js index af1a0eb..085ec81 100644 --- a/web/conf_builder/conf-builder-main.js +++ b/web/conf_builder/conf-builder-main.js @@ -210,7 +210,11 @@ app.registerExtension({ lora_triggerwords_mode: "None", save_conditioning_cache_to_file: false, enable_model_cache: false, - vae_batch_size: 1 + vae_batch_size: 1, + job_order: "as_written", + preview_pass: { enabled: false, steps: 8, scale: 0.5, cfg_override: null }, + skip_rejected_previews: true, + only_favorited_previews: false }; // Create HTML container immediately @@ -321,6 +325,10 @@ 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.job_order === undefined) this.state.job_order = "as_written"; + if (this.state.preview_pass === undefined) this.state.preview_pass = { enabled: false, steps: 8, scale: 0.5, cfg_override: null }; + if (this.state.skip_rejected_previews === undefined) this.state.skip_rejected_previews = true; + if (this.state.only_favorited_previews === undefined) this.state.only_favorited_previews = false; this.saveState(); this.renderUI();