diff --git a/README.md b/README.md index 48f0338..1292869 100644 --- a/README.md +++ b/README.md @@ -795,7 +795,7 @@ The editor provides a visual chip interface for building and editing these varia ### 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). +* **Job Order:** `As written (default)` runs one resolution pass after another, exactly as before. `Minimize model/LoRA reloads` re-groups the job list by model, LoRA stack, VAE and resolution. `As written` is already grouped by model (and Builder per-config resolutions stay inside their model group), so Minimize only changes anything when LoRA folders or random LoRA picks expand into different stacks (e.g. a model with 2 LoRA folders alternating A1 B1 A2 B2 becomes A1 A2 B1 B2), or when a batched latent is connected (one pass per latent). LoRA stacks are grouped before VAEs, so a model with several VAEs and several LoRA stacks can switch VAE more often than as written. The log prints the model/LoRA/VAE switch counts before and after and says when nothing changed. **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. diff --git a/config_builder_node.py b/config_builder_node.py index e3beb17..fa3f051 100644 --- a/config_builder_node.py +++ b/config_builder_node.py @@ -897,9 +897,10 @@ class UltimateConfigBuilder: # later by the orchestrator (seed, denoise) or only set for # specific model types (clip_type, text_encoders, gguf_options, # ltx_*) are intentionally excluded so they don't fire false alarms. + # "vae" is omitted when no VAE is picked (see 'Process VAEs' above), so it is excluded too. _EXPECTED_CONFIG_KEYS = { "sampler", "scheduler", "steps", "cfg", "seed_behavior", - "model", "model_type", "lora", "vae", + "model", "model_type", "lora", "model_sampling_override", "use_advanced_sampling", "use_flux_guidance", "model_prompt_prefix", "model_prompt_suffix", "attention_mode", "use_deep_shrink", "use_slg", diff --git a/config_utils.py b/config_utils.py index 176d902..9078b7c 100644 --- a/config_utils.py +++ b/config_utils.py @@ -839,6 +839,23 @@ import hashlib JOB_ORDERS = ("as_written", "minimize_reloads") +def run_meta_extras(session_settings, has_optional_inputs): + """Manifest-meta keys that let the Dashboard re-queue this session headless (POST /config_tester/run). + meta.configs_json only holds the configs list (sampler_node strips _session_settings).""" + ss = session_settings if isinstance(session_settings, dict) else {} + return {"session_settings": json.loads(json.dumps(ss, default=str)), + "has_optional_inputs": bool(has_optional_inputs)} + + +def job_order_note(before, after): + """None when minimize_reloads changed the switch counts, else a note saying it changed nothing.""" + if before != after: + return None + return ("minimize_reloads changed nothing: this grid is already grouped by model (it only helps when LoRA " + "folders or random LoRA picks expand into different stacks, or a batched latent makes each model " + "run once per latent)") + + def build_job_plan(expanded, input_jobs): """Flatten the nested ``for job in input_jobs: for conf in expanded`` loop. diff --git a/examples/README.md b/examples/README.md index 426a25a..f330f4a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -67,7 +67,8 @@ Times are approximate on an RTX 5060 Ti 16 GB, not counting the first model load * **What the grid tests:** euler at 4 and 6 steps, 1024x1024, each with the Advanced Sampling pipeline (CFG guider + Flux2 scheduler) and without it (plain KSampler, simple scheduler): the Builder always grids Advanced Sampling on vs off. **4 images** * **Time:** TBD -* **Notes:** Klein is distilled, so cfg is 1; the official template uses the CFG guider plus the Flux2 scheduler, which is the Advanced Sampling half of the grid. CLIP type `flux2`. +* **Notes:** Klein is distilled, so cfg is 1; the official (distilled) template uses the CFG guider plus the Flux2 scheduler, which is the Advanced Sampling half of the grid. CLIP type `flux2`. +* **Base variant:** this example uses the distilled checkpoint. For the base model use `flux-2-klein-base-9b-fp8.safetensors` (same folder) with ~20 steps and cfg 5 (CFG guider + Flux2 scheduler), as in Comfy's `image_flux2_klein_image_edit_9b_base` template. ## MOSS-Image (`uscg-moss-image.json`) diff --git a/generation_orchestrator.py b/generation_orchestrator.py index ffcc7b3..2a90d9f 100644 --- a/generation_orchestrator.py +++ b/generation_orchestrator.py @@ -840,7 +840,8 @@ def run_generation_loop( ) from .config_utils import ( JOB_ORDERS, build_job_plan, order_job_plan, count_switches, config_fingerprint, - preview_dims, build_preview_index, should_skip_for_previews, build_full_fp_set + preview_dims, build_preview_index, should_skip_for_previews, build_full_fp_set, + run_meta_extras, job_order_note ) # ==== JOB ORDER / PREVIEW PASS SETTINGS (defaults reproduce the old behaviour) ==== @@ -963,7 +964,11 @@ def run_generation_loop( _sw_before = count_switches(_plan_as_written, get_model_cache_key) _sw_after = count_switches(job_plan, get_model_cache_key) print(f"[GridTester] 🧭 Job order: minimize_reloads | model loads {_sw_before['model_loads']} -> {_sw_after['model_loads']}, " - f"LoRA switches {_sw_before['lora_switches']} -> {_sw_after['lora_switches']}") + f"LoRA switches {_sw_before['lora_switches']} -> {_sw_after['lora_switches']}, " + f"VAE switches {_sw_before['vae_switches']} -> {_sw_after['vae_switches']}") + _note = job_order_note(_sw_before, _sw_after) + if _note: + print(f"[GridTester] â„šī¸ Job order: {_note}") print(f"{'='*80}") print(f"[GridTester] 🚀 GENERATION START") @@ -986,6 +991,7 @@ def run_generation_loop( optional_negative is not None, optional_latent is not None ]) + _meta_run = run_meta_extras(session_settings, has_optional_inputs) if has_optional_inputs and not overwrite_existing: print(f"[GridTester] âš ī¸ Optional inputs connected — job skip/resume DISABLED.") @@ -1421,7 +1427,8 @@ def run_generation_loop( "image_format": image_format, "job_order": job_order, "preview_pass": {"enabled": preview_on, "steps": pv_steps, "scale": pv_scale, "cfg_override": pv_cfg}, - "rejected_preview_fps": list((existing_data.get("meta") or {}).get("rejected_preview_fps", [])) + "rejected_preview_fps": list((existing_data.get("meta") or {}).get("rejected_preview_fps", [])), + **_meta_run } save_manifest(paths["manifest"], existing_data) @@ -1768,7 +1775,8 @@ def run_generation_loop( "image_format": image_format, "job_order": job_order, "preview_pass": {"enabled": preview_on, "steps": pv_steps, "scale": pv_scale, "cfg_override": pv_cfg}, - "rejected_preview_fps": list((existing_data.get("meta") or {}).get("rejected_preview_fps", [])) + "rejected_preview_fps": list((existing_data.get("meta") or {}).get("rejected_preview_fps", [])), + **_meta_run } save_manifest(paths["manifest"], existing_data) @@ -2600,7 +2608,8 @@ def run_generation_loop( "image_format": image_format, "job_order": job_order, "preview_pass": {"enabled": preview_on, "steps": pv_steps, "scale": pv_scale, "cfg_override": pv_cfg}, - "rejected_preview_fps": list((existing_data.get("meta") or {}).get("rejected_preview_fps", [])) + "rejected_preview_fps": list((existing_data.get("meta") or {}).get("rejected_preview_fps", [])), + **_meta_run } save_manifest(paths["manifest"], existing_data) @@ -2709,7 +2718,8 @@ def run_generation_loop( "image_format": image_format, "job_order": job_order, "preview_pass": {"enabled": preview_on, "steps": pv_steps, "scale": pv_scale, "cfg_override": pv_cfg}, - "rejected_preview_fps": list((existing_data.get("meta") or {}).get("rejected_preview_fps", [])) + "rejected_preview_fps": list((existing_data.get("meta") or {}).get("rejected_preview_fps", [])), + **_meta_run } save_manifest(paths["manifest"], existing_data) diff --git a/resources/logic_utils.js b/resources/logic_utils.js index 04ffefb..63489d6 100644 --- a/resources/logic_utils.js +++ b/resources/logic_utils.js @@ -1008,7 +1008,7 @@ setupKeyReloadFullscreen() // FULL PASS FOR SURVIVORS (preview-pass workflow) // Strategy (a): ask the parent ComfyUI page (web/dashboard.js) to flip the matching Config Builder to // "Preview pass off", then queue the prompt. Needs a parent that understands 'uscg_run_full_pass'. -// Strategy (b): STUB for a future run API (see _uscgTryRunApi). Fallback: instructions toast. +// Strategy (b): queue it through the run API (POST /config_tester/run) from the manifest meta. Fallback: instructions toast. // --------------------------------------------------------------------------- function _uscgToast(msg, ok) { var el = document.createElement('div'); @@ -1020,25 +1020,49 @@ function _uscgToast(msg, ok) { setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 9000); } -// Strategy (b) stub: wire the run API here once it exists. Detection: window.USCG_RUN_API (set by the host -// page) or a '/config_tester/run' route. Resolves to {ok, message} when it handled the request, else null. +// Strategy (b): queue the full pass through POST /config_tester/run, re-using the configs and Run Settings the +// orchestrator recorded in this session's manifest meta (configs_json + session_settings, see run_meta_extras). +function uscgFullPassBody(manifest, session) { + var m = (manifest && manifest.meta) || {}; + if (!m.configs_json) return { error: 'This session has no recorded configs (meta.configs_json).' }; + if (!m.session_settings || typeof m.session_settings !== 'object') + return { error: 'This session was rendered before the hub recorded its Run Settings in the manifest.' }; + if (m.has_optional_inputs) + return { error: "This session used the Generator's optional inputs, which a headless run cannot reproduce." }; + var configs; + try { configs = typeof m.configs_json === 'string' ? JSON.parse(m.configs_json) : m.configs_json; } + catch (e) { return { error: 'meta.configs_json is not valid JSON.' }; } + if (configs && !Array.isArray(configs) && Array.isArray(configs.configs)) configs = configs.configs; + if (!Array.isArray(configs) || !configs.length) return { error: 'meta.configs_json holds no configs.' }; + var ss = JSON.parse(JSON.stringify(m.session_settings)); + ss.preview_pass = Object.assign({ steps: 8, scale: 0.5, cfg_override: null }, ss.preview_pass || {}, { enabled: false }); + ss.skip_rejected_previews = true; + ss.start_at_job = 0; + ss.overwrite_existing = false; + ss.session_name = session; + return { body: { configs_json: { configs: configs, _session_settings: ss }, + session_name: session, overwrite_existing: false, force: true } }; +} + +// Resolves to {ok, message} when the run API answered (or the session cannot be re-queued), or null when the +// server has no run API (older USCG), so the caller shows the manual instructions. async function _uscgTryRunApi(session) { + var built = uscgFullPassBody(typeof fullManifest !== 'undefined' ? fullManifest : null, session); + if (built.error) return { ok: false, message: built.error }; + var r; try { - if (window.USCG_RUN_API && typeof window.USCG_RUN_API.run === 'function') { - // TODO(merge with the run-API task): confirm the real signature; overrides below are the contract. - var res = await window.USCG_RUN_API.run({ - session_name: session, - settings_overrides: { preview_pass: { enabled: false }, skip_rejected_previews: true } - }); - return { ok: true, message: (res && res.message) || 'Full pass queued.' }; - } - var probe = await fetch('/config_tester/run', { method: 'GET' }).catch(function () { return null; }); - if (probe && probe.status !== 404) { - // TODO(merge with the run-API task): POST /config_tester/run with the same overrides, then return - // {ok, message}. Intentionally not implemented here (this task does not depend on it). - } - } catch (e) { /* fall through to the instructions toast */ } - return null; + r = await fetch('/config_tester/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(built.body) }); + } catch (e) { return null; } + if (r.status === 404 || r.status === 405) return null; + var j = null; + try { j = await r.json(); } catch (e) { /* non-JSON error body */ } + if (!r.ok) { + var err = j && j.error ? (typeof j.error === 'string' ? j.error : JSON.stringify(j.error)) : ('HTTP ' + r.status); + return { ok: false, message: 'Run API: ' + String(err).slice(0, 200) + '.' }; + } + return { ok: true, message: 'Full pass queued (prompt ' + String((j && j.prompt_id) || '?').slice(0, 8) + + '). Rejected previews are skipped; full images replace their previews as they finish.' }; } function _uscgAskParentFullPass(session) { @@ -1049,7 +1073,7 @@ function _uscgAskParentFullPass(session) { if (!ev.data || ev.data.type !== 'uscg_run_full_pass_result') return; done = true; window.removeEventListener('message', onMsg); - resolve({ ok: !!ev.data.ok, message: ev.data.message || '' }); + resolve({ ok: !!ev.data.ok, message: ev.data.message || '', reason: ev.data.reason || null }); }; window.addEventListener('message', onMsg); window.parent.postMessage({ type: 'uscg_run_full_pass', session_name: session }, '*'); @@ -1062,12 +1086,13 @@ function _uscgAskParentFullPass(session) { } async function runFullPassForSurvivors() { - var session = (document.getElementById('session-input') && document.getElementById('session-input').value) || + var session = window._uscgLoadedSession || + (document.getElementById('session-input') && document.getElementById('session-input').value) || (fullManifest && fullManifest.session_name) || ''; var manual = 'In the Config Builder open Run Settings, untick "Preview Pass" and queue the workflow again. ' + 'Only cells whose preview was not rejected will render at full quality.'; var res = await _uscgAskParentFullPass(session); - if (!res) res = await _uscgTryRunApi(session); + if (!res || (!res.ok && (res.reason === 'no_builder' || res.reason === 'no_queue'))) res = await _uscgTryRunApi(session); if (res && res.ok) { _uscgToast(res.message || 'Full pass queued.', true); return; } _uscgToast((res && res.message ? res.message + ' ' : '') + manual, false); } diff --git a/resources/template.html b/resources/template.html index 879f5b7..18c01f0 100644 --- a/resources/template.html +++ b/resources/template.html @@ -566,7 +566,7 @@ PREVIEW PASS -- diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 936d451..bf768e5 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -57,8 +57,9 @@ def make_item(i, model, sampler, scheduler, cfg, steps, lora): } -def make_manifest(): - """24 items, every config tuple unique. Newest first, like the real manifest.""" +def make_manifest(meta=None): + """24 items, every config tuple unique. Newest first, like the real manifest. + `meta` (optional) is merged over the default meta dict.""" built = [] i = 0 for model in ("sdxl_base.safetensors", "juggernautXL_v9.safetensors"): @@ -71,7 +72,7 @@ def make_manifest(): i += 1 return { "session_name": SESSION, - "meta": {"model": "", "positive": "", "negative": ""}, + "meta": {"model": "", "positive": "", "negative": "", **(meta or {})}, "items": list(reversed(built)), } @@ -140,9 +141,15 @@ class Dashboard: @pytest.fixture -def dashboard(page): +def manifest_meta(request): + """Extra manifest meta for the `dashboard` fixture; override via indirect parametrize.""" + return getattr(request, "param", None) or {} + + +@pytest.fixture +def dashboard(page, manifest_meta): from playwright.sync_api import expect - d = Dashboard(page, make_manifest()) + d = Dashboard(page, make_manifest(manifest_meta)) page.set_viewport_size({"width": 1600, "height": 1200}) page.on("pageerror", lambda e: d.pageerrors.append(str(e))) page.route("**/*", d.handle) diff --git a/tests/e2e/test_full_pass.py b/tests/e2e/test_full_pass.py new file mode 100644 index 0000000..6889115 --- /dev/null +++ b/tests/e2e/test_full_pass.py @@ -0,0 +1,61 @@ +"""Dashboard 'Full pass for survivors' via the run API (POST /config_tester/run) on the fake origin.""" +import json + +import pytest + +SESSION = "smoke" # matches tests/e2e/conftest.py + +_CONFIGS = [{"model": "sdxl_base.safetensors", "sampler": "euler", "scheduler": "normal", + "steps": 20, "cfg": 7.0, "lora": "None"}] +_META = { + "configs_json": json.dumps(_CONFIGS), + "session_settings": {"preview_pass": {"enabled": True, "steps": 8, "scale": 0.5}, + "add_random_seeds_to_gens": 2, "session_name": SESSION}, + "has_optional_inputs": False, +} + + +def _run_posts(d): + return [b for (m, p, b) in d.request_log if m == "POST" and p == "/config_tester/run"] + + +def _toast_text(page): + page.wait_for_function("[...document.querySelectorAll('div')].some(e => e.style.zIndex === '99999')") + return page.evaluate("[...document.querySelectorAll('div')].filter(e => e.style.zIndex === '99999')" + ".map(e => e.textContent).join('|')") + + +@pytest.mark.parametrize("manifest_meta", [_META], indirect=True) +def test_full_pass_uses_run_api(dashboard): + page = dashboard.page + page.route("**/config_tester/run", lambda route: ( + dashboard.request_log.append(("POST", "/config_tester/run", json.loads(route.request.post_data))) + or route.fulfill(status=200, content_type="application/json", body='{"prompt_id":"abcd1234-0000"}'))) + page.evaluate("runFullPassForSurvivors()") + toast = _toast_text(page) + assert "Full pass queued" in toast + posts = _run_posts(dashboard) + assert len(posts) == 1 + body = posts[0] + ss = body["configs_json"]["_session_settings"] + assert ss["preview_pass"]["enabled"] is False + assert ss["skip_rejected_previews"] is True + assert ss["add_random_seeds_to_gens"] == 2 + assert body["overwrite_existing"] is False + assert body["force"] is True + assert body["session_name"] == SESSION + assert body["configs_json"]["configs"] == _CONFIGS + + +def test_full_pass_without_recorded_settings_posts_nothing(dashboard): + dashboard.page.evaluate("runFullPassForSurvivors()") + toast = _toast_text(dashboard.page) + assert "before the hub recorded" in toast or "no recorded configs" in toast + assert _run_posts(dashboard) == [] + + +@pytest.mark.parametrize("manifest_meta", [{"configs_json": json.dumps(_CONFIGS)}], indirect=True) +def test_full_pass_old_session_without_settings(dashboard): + dashboard.page.evaluate("runFullPassForSurvivors()") + assert "before the hub recorded" in _toast_text(dashboard.page) + assert _run_posts(dashboard) == [] diff --git a/tests/test_examples.py b/tests/test_examples.py index 9186b81..5f3798c 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -127,10 +127,8 @@ def test_emits_and_counts(path, capsys): name = os.path.basename(path) state = _state(_load(path)) out = json.loads(UltimateConfigBuilder.state_to_configs_json(state)) - # state_to_configs_json lists "vae" in _EXPECTED_CONFIG_KEYS but omits it on purpose when no VAE is - # picked (the SDXL checkpoint has its own), so that one key may be reported; anything else is real drift. drift = [l for l in capsys.readouterr().out.splitlines() if "Config schema drift" in l] - assert all("missing keys {'vae'}" in l for l in drift), drift + assert not drift, drift assert out["_session_settings"]["add_random_seeds_to_gens"] == 0 count = len(expand_configs(out["configs"], [""], [""], [1.0], 0, [], None)) assert count == EXPECTED_IMAGES[name] diff --git a/tests/test_job_order.py b/tests/test_job_order.py index 012ea95..1406a08 100644 --- a/tests/test_job_order.py +++ b/tests/test_job_order.py @@ -157,3 +157,28 @@ def test_list_valued_model_keys_are_hashable(): plan = build_job_plan(expanded, _jobs((64, 64))) after = order_job_plan(plan, "minimize_reloads", lambda c: [c["model"], ["te"]]) assert [p["conf"]["model"] for p in after] == ["A", "A", "B"] + + +def test_job_order_note(): + from config_utils import job_order_note + assert job_order_note({"model_loads": 1}, {"model_loads": 0}) is None + note = job_order_note({"model_loads": 1}, {"model_loads": 1}) + assert isinstance(note, str) and "changed nothing" in note + + +def test_per_config_resolutions_in_model_order_gain_nothing(): + expanded = [_conf(model=m, resolution=r) for m in ("A", "B") for r in ((512, 512), (768, 768))] + plan = build_job_plan(expanded, _jobs((1024, 1024))) + before = count_switches(plan, _key) + after = count_switches(order_job_plan(plan, "minimize_reloads", _key), _key) + assert before == after + + +def test_minimize_reloads_can_trade_vae_switches_for_lora_switches(): + # As written, VAE is the slower-changing axis (vae outer, lora inner), but the sort ranks LoRA first. + expanded = [_conf(lora=l, vae=v) for v in ("V1", "V2") for l in ("L1", "L2")] + plan = build_job_plan(expanded, _jobs((1024, 1024))) + before = count_switches(plan, _key) + after = count_switches(order_job_plan(plan, "minimize_reloads", _key), _key) + assert (before["lora_switches"], after["lora_switches"]) == (4, 2) + assert (before["vae_switches"], after["vae_switches"]) == (2, 4) diff --git a/tests/test_run_meta_extras.py b/tests/test_run_meta_extras.py new file mode 100644 index 0000000..c378091 --- /dev/null +++ b/tests/test_run_meta_extras.py @@ -0,0 +1,47 @@ +"""run_meta_extras (manifest meta that lets the Dashboard re-queue a session) + the orchestrator wiring.""" +import json +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from config_utils import run_meta_extras + +_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def test_deep_copy_and_json_safe(): + ss = {"add_random_seeds_to_gens": 2, "upscaling": {"enabled": True, "steps": [1, 2]}} + out = run_meta_extras(ss, False) + ss["upscaling"]["steps"].append(3) + ss["add_random_seeds_to_gens"] = 9 + assert out["session_settings"]["upscaling"]["steps"] == [1, 2] + assert out["session_settings"]["add_random_seeds_to_gens"] == 2 + json.dumps(out) + + +def test_non_dict_gives_empty_settings(): + assert run_meta_extras(None, False)["session_settings"] == {} + assert run_meta_extras("x", False)["session_settings"] == {} + + +def test_has_optional_inputs_is_bool(): + assert run_meta_extras({}, True)["has_optional_inputs"] is True + assert run_meta_extras({}, 0)["has_optional_inputs"] is False + assert run_meta_extras({}, None)["has_optional_inputs"] is False + + +def test_unserialisable_values_are_stringified(): + out = run_meta_extras({"p": object()}, False) + json.dumps(out) + + +def test_every_run_generation_loop_meta_block_records_extras(): + with open(os.path.join(_ROOT, "generation_orchestrator.py"), encoding="utf-8") as f: + src = f.read() + body = src[src.index("def run_generation_loop"):src.index("def _conditioning_to_serializable")] + blocks = re.findall(r'existing_data\["meta"\] = \{(.*?)\n\s*\}\n', body, flags=re.S) + assert len(blocks) == 4 + for b in blocks: + assert "**_meta_run" in b diff --git a/tests/test_state_to_configs_json.py b/tests/test_state_to_configs_json.py index 83bfafd..d81a194 100644 --- a/tests/test_state_to_configs_json.py +++ b/tests/test_state_to_configs_json.py @@ -261,3 +261,17 @@ def test_cfg_override_blank_is_null_and_zero_is_kept(): def test_unknown_job_order_becomes_as_written(): assert _ss(job_order="alphabetical")["job_order"] == "as_written" + + +def test_checkpoint_without_vae_omits_key_and_reports_no_drift(capsys): + state = make_state(models=["m.safetensors"]) + out = json.loads(UltimateConfigBuilder.state_to_configs_json(state)) + assert all("vae" not in c for c in out["configs"]) + assert "schema drift" not in capsys.readouterr().out.lower() + + +def test_picked_vae_is_still_emitted(capsys): + state = make_state(models=["m.safetensors"], extra_array_fields={"vaes": ["my.vae.safetensors"]}) + out = json.loads(UltimateConfigBuilder.state_to_configs_json(state)) + assert any(c.get("vae") for c in out["configs"]) + assert "schema drift" not in capsys.readouterr().out.lower() diff --git a/web/conf_builder/conf-builder-config-management.js b/web/conf_builder/conf-builder-config-management.js index 52db864..f9594a7 100644 --- a/web/conf_builder/conf-builder-config-management.js +++ b/web/conf_builder/conf-builder-config-management.js @@ -4714,9 +4714,8 @@ function renderLTXSection(node, configArray, arrayIdx, modelLists) { section.appendChild(header); - // Sub-sections — defined in subsequent tasks (A13-A16). For now, stub them - // as no-op functions so this skeleton renders without errors. The real - // implementations will replace these stubs. + // LTX sub-sections (model files, video shape, stage). The typeof guards keep this section rendering + // if one of the render functions is ever missing. if (typeof renderLTXModelFiles === "function") section.appendChild(renderLTXModelFiles(node, configArray, modelLists)); if (typeof renderLTXVideoShape === "function") section.appendChild(renderLTXVideoShape(node, configArray)); if (typeof renderLTXStage === "function") { @@ -7167,7 +7166,7 @@ export function renderRunSettingsSection(node, container) { 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.", () => { + _addRunRow("Job Order", "As written = the order the configs expand in, already grouped by model, then text encoders, VAE, LoRA and prompt; Builder per-config resolutions stay inside their model group. Minimize reloads = re-group the job list by model, LoRA stack, VAE and resolution. It only changes anything when LoRA folders or random LoRA picks expand into different stacks, or when a batched latent is connected (one pass per latent). LoRA stacks are grouped before VAEs, so a model with several VAEs and LoRAs may switch VAE more often. The log prints the before/after counts and says when nothing changed. 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]) => { diff --git a/web/dashboard.js b/web/dashboard.js index aa124d5..0445902 100644 --- a/web/dashboard.js +++ b/web/dashboard.js @@ -162,8 +162,8 @@ app.registerExtension({ // "Preview pass off" and queue the prompt. Feature-detected; the dashboard falls back to an // instructions toast when this does not answer (or app.queuePrompt is unavailable). if (event.data?.type === 'uscg_run_full_pass') { - const reply = (ok, message) => { - try { event.source?.postMessage({ type: 'uscg_run_full_pass_result', ok, message }, '*'); } catch (e) { /* ignore */ } + const reply = (ok, message, reason) => { + try { event.source?.postMessage({ type: 'uscg_run_full_pass_result', ok, message, reason }, '*'); } catch (e) { /* ignore */ } }; try { const san = (s) => String(s || '').replace(/[^\w\-]/g, ''); @@ -173,11 +173,12 @@ app.registerExtension({ if (builders.length !== 1) { reply(false, builders.length === 0 ? `No Config Builder in this workflow uses session "${wanted}".` - : `${builders.length} Config Builders use session "${wanted}".`); + : `${builders.length} Config Builders use session "${wanted}".`, + builders.length === 0 ? 'no_builder' : undefined); return; } if (typeof app.queuePrompt !== 'function') { - reply(false, 'This ComfyUI frontend does not expose app.queuePrompt.'); + reply(false, 'This ComfyUI frontend does not expose app.queuePrompt.', 'no_queue'); return; } const b = builders[0];