feat(builder): job order and preview pass run settings

State keys job_order, preview_pass{enabled,steps,scale,cfg_override}, skip_rejected_previews,
only_favorited_previews: new-state defaults + migration, Run Settings controls, emission with
coercion in state_to_configs_json, tests, README/ProjectStructure.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jason Hoku
2026-11-04 00:06:08 -08:00
co-authored by Claude Fable 5.1
parent 602bde44fb
commit ffb352fb92
6 changed files with 194 additions and 1 deletions
+1
View File
@@ -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()`
+11
View File
@@ -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)
```
+29
View File
@@ -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
+45
View File
@@ -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"
@@ -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);
+9 -1
View File
@@ -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();