Merge branch 'feat/orch-fixes' into int/wave3

This commit is contained in:
Jason Hoku
2026-11-04 03:07:53 -08:00
7 changed files with 481 additions and 41 deletions
+2 -2
View File
@@ -28,12 +28,12 @@ Read `ProjectStructure.md` in this directory for the full reference (file struct
| `__init__.py` | 1597 | API endpoints (config CRUD, presets, dashboard save/export/delete, scan, async upscale, pairwise votes, metrics jobs, standalone dashboard page, run API, workflow sweep), node mappings, path security |
| `sampler_node.py` | 401 | Main node class. Unwraps configs, extracts `_distribution` + `_session_settings`, delegates to orchestrator |
| `config_builder_node.py` | 1578 | Config builder node. `state_to_configs_json(state)` is the ONLY state-to-configs implementation (`generate_config()` and `POST /configbuilder/preview` both call it). API endpoints for model lists, trigger lookups |
| `generation_orchestrator.py` | 3693 | **Largest file.** Main generation loop over a flat job plan (`config_utils.build_job_plan`), smart skip, preview pass, upscaling pipelines, deferred upscales (`run_deferred_upscales()`), GPU cooldown, distribution, ETA tracking, Start At Job # |
| `generation_orchestrator.py` | 3892 | **Largest file.** Main generation loop over a flat job plan (`config_utils.build_job_plan`), smart skip, preview pass, upscaling pipelines, deferred upscales (`run_deferred_upscales()`), GPU cooldown, distribution, ETA tracking, Start At Job # |
| `image_generation.py` | 1321 | KSampler wrapper, VAE decode, `flush_batch_with_vae()` (saves images + updates manifest), `upscale_image()`, `create_image_metadata()`, rolling ETA |
| `batch_encoding.py` | 878 | Prompt pre-encode with combinators, `pinned_clip()` (one text-encoder load per pass), `te_batch_size` text-encoder batching |
| `civitai.py`, `distribution.py` | 40, 107 | Thin facades for the companion plugins (ComfyUI-USCG-CivitAI, ComfyUI-USCG-Distributed). No outbound network code in this package. |
| `upscale_runner.py` | 642 | Dashboard async upscaling. Background thread loads models, runs pipeline chains, updates manifests, sends progress events. |
| `config_utils.py` | 1108 | Config expansion (Cartesian products), nested prompt parsing, job preparation, job plan / ordering, `config_fingerprint()` |
| `config_utils.py` | 1141 | Config expansion (Cartesian products), nested prompt parsing, job preparation, job plan / ordering, `config_fingerprint()` |
| `model_loader.py` | 907 | Checkpoint/LoRA/VAE/GGUF loading, latent geometry (`get_latent_downscale_ratio`), CLIP type resolution |
| `model_cache.py` | 935 | 3-tier cache: LoRA files → incremental states → patched models. Async preloading |
| `manifest_utils.py` | 316 | Load/save/merge manifest. `save_manifest()` reloads from disk first to preserve concurrent user edits (favorites, votes, metrics) |
+2 -2
View File
@@ -38,7 +38,7 @@ ComfyUI-Ultimate-Sampler-Grid/
├── config_builder_node.py # "Config Builder" Node + API endpoints + state_to_configs_json (1578 lines)
├── json_text_node.py # "Smart JSON" Node (60 lines)
│
├── generation_orchestrator.py # The "Conductor" (Loop Logic, job plan, preview pass, 3693 lines)
├── generation_orchestrator.py # The "Conductor" (Loop Logic, job plan, preview pass, 3892 lines)
├── image_generation.py # KSampler & VAE Wrappers (1321 lines)
├── image_metrics.py # Optional per-image metrics (sharpness/entropy/...), run context, scorer-pack adapters (ADAPTERS registry: node ids/signatures pinned to pack shas, loader defaults + `prefer`, `scale`, `max_batch`, `vram_mb`; `adapter_status()` reports `problem`) (668 lines)
├── metrics_runner.py # Background job: compute metrics for an existing session (CPU, manifest lock) (167 lines)
@@ -52,7 +52,7 @@ ComfyUI-Ultimate-Sampler-Grid/
├── conditioning_cache.py # Disk-based Conditioning Cache (372 lines)
├── lora_utils.py # LoRA Searching & Validation (468 lines)
├── trigger_words.py # Trigger Word Prompt Assembly (377 lines)
├── config_utils.py # Config Parsing, Expansion, Cartesian Products, job plan + fingerprints (1108 lines)
├── config_utils.py # Config Parsing, Expansion, Cartesian Products, job plan + fingerprints (1141 lines)
├── directory_scanner.py # External Directory Session Scanner (609 lines)
├── metadata_packer.py # PNG/WebP Metadata Embedding for Exports (561 lines)
├── manifest_utils.py # Manifest Read/Write/Merge Helpers, comparisons (316 lines)
+7 -1
View File
@@ -528,7 +528,7 @@ The Generator node features built-in widgets for Model Selection and Prompts, bu
- Connect `optional_positive` and `optional_negative` for pre-encoded conditioning
- The node automatically detects latent channel count (4 for SD1.5/SDXL, 16 for SD3/Flux/Z-Image)
- Skip detection uses conditioning tensor hashing to properly detect prompt changes
* **Latent Input:** Connect a `LATENT` to use img2img or upscaling workflows.
* **Latent Input:** Connect a `LATENT` to use img2img or upscaling workflows. The image size is the latent size times the model's latent ratio (8 px per cell for SD/SDXL/Flux.1, 16 for Flux.2/MOSS-Image/Krea 2), so the manifest and upscale target sizes are right for every family.
- For SD3/Flux: Use `EmptySD3LatentImage` instead of `EmptyLatentImage`
- Latent dimensions are automatically preserved
@@ -1634,6 +1634,8 @@ Master (your machine):
6. Master saves everything to the session manifest
```
**Upscales:** with upscaling enabled, the master upscales after all workers have finished, only the images made by this run, one model/LoRA/VAE group at a time (each group loads its own model; worker images are matched back to their job config). **Latent input:** remote workers never receive the latent and render an empty latent at the placeholder size; only the master honours it.
### Master Text Encoding
When enabled, the master pre-encodes ALL unique positive/negative prompts for every (model, LoRA) combination before distributing jobs. Workers receive the encoded conditionings in their job claim response and skip CLIP encoding entirely.
@@ -1775,6 +1777,10 @@ After modifying backend Python files, restart ComfyUI. After modifying `web/` JS
## 📝 Changelog
### Update 11/4/26 (fixes): Distributed upscales, latent-input size
* 🌐 **Distributed upscales work:** the post-run upscale phase on the master used to crash on every distributed run with upscaling on. It now upscales this run's images (master and worker made), per model/LoRA/VAE group, with HiRes-prompt adjustment encoded per model.
* 📐 **Latent input size:** a connected latent's image size was always cells x 8; it is now cells x the loaded model's latent ratio (16 for Flux.2/MOSS-Image/Krea 2).
### Update 11/4/26: Standalone dashboard, pairwise review, image metrics, preview pass, workflow sweeps
* 📱 **Standalone Dashboard + Run API:** open the dashboard as a normal web page at `/config_tester/dashboard?session=<name>` (phone, second monitor) with live updates over its own websocket. `POST /config_tester/run` and `GET /config_tester/run_status` start and track headless runs (Discord bot, curl) without any workflow JSON.
* ⚖️ **Pairwise Review:** blind A/B compare mode (`C` key) with matched, same-settings, one-axis and random pairing, Bradley-Terry ratings (Sort by Rating) and a per-setting scoreboard with 95% intervals. Votes are stored in the session manifest under `comparisons`.
+19 -3
View File
@@ -785,13 +785,18 @@ def expand_configs(raw_configs, pos_prompts, neg_prompts, denoise_values, seed,
return expanded
def prepare_input_jobs(optional_latent, resolutions):
def prepare_input_jobs(optional_latent, resolutions, downscale_ratio=8):
"""
Prepare input jobs from either optional latent or resolution list.
For a latent input, width/height are a placeholder (cells x downscale_ratio) until the
model is known; "latent_cells" carries (cells_w, cells_h) and the orchestrator resolves
the real pixel size per job with latent_job_pixel_size() once that job's model is loaded.
Args:
optional_latent: Optional latent tensor input
resolutions: List of [width, height] pairs
downscale_ratio: placeholder pixels per latent cell (default 8)
Returns:
List of job dictionaries with label, width, height, latent, batch_idx
@@ -802,10 +807,12 @@ def prepare_input_jobs(optional_latent, resolutions):
batch_count = optional_latent["samples"].shape[0]
for i in range(batch_count):
single_sample = optional_latent["samples"][i].unsqueeze(0)
cells_w, cells_h = int(single_sample.shape[3]), int(single_sample.shape[2])
input_jobs.append({
"label": f"Input {i+1}",
"width": single_sample.shape[3] * 8,
"height": single_sample.shape[2] * 8,
"width": cells_w * downscale_ratio,
"height": cells_h * downscale_ratio,
"latent_cells": (cells_w, cells_h),
"latent": {"samples": single_sample},
"batch_idx": i
})
@@ -822,6 +829,15 @@ def prepare_input_jobs(optional_latent, resolutions):
return input_jobs
def latent_job_pixel_size(job, downscale_ratio):
"""(w, h) in pixels of an input job. Latent-input jobs: cells x the model's latent ratio (8 SD/SDXL/Flux.1, 16 Flux.2/MOSS/Krea 2); resolution jobs: unchanged."""
cells = job.get("latent_cells") if isinstance(job, dict) else None
if not cells:
return job["width"], job["height"]
r = int(downscale_ratio) if downscale_ratio else 8
return int(cells[0]) * r, int(cells[1]) * r
def sanitize_session_name(session_name):
"""Sanitize session name to be filesystem-safe"""
session_name = re.sub(r'[^\w\-]', '', session_name)
+209 -33
View File
@@ -393,6 +393,11 @@ def run_deferred_upscales(
Process deferred upscale jobs after generation completes.
Runs all pipeline chains for each queued image.
Called BEFORE finalization cleanup so VAE/model/CLIP are still loaded.
Queue entries: config, width, height, seed, batch_idx, actual_positive_prompt,
actual_negative_prompt, gen_index, config_fp and optionally base_item (the exact
manifest item to upscale; skips the identity search). The caller supplies a
model/VAE/CLIP/conditioning_cache that matches every job in the queue.
"""
if not deferred_queue:
return
@@ -427,16 +432,18 @@ def run_deferred_upscales(
actual_positive_prompt = job["actual_positive_prompt"]
actual_negative_prompt = job["actual_negative_prompt"]
# Find the base image in the manifest by matching identity
base_item = None
for item in existing_data.get("items", []):
if (item.get("seed") == current_seed and
item.get("width") == pipe_w and
item.get("height") == pipe_h and
not item.get("upscaled") and
item.get("positive") == actual_positive_prompt):
base_item = item
break
# Distributed callers hand over the exact manifest item; local entries search by identity
base_item = job.get("base_item")
if base_item is None:
# Find the base image in the manifest by matching identity
for item in existing_data.get("items", []):
if (item.get("seed") == current_seed and
item.get("width") == pipe_w and
item.get("height") == pipe_h and
not item.get("upscaled") and
item.get("positive") == actual_positive_prompt):
base_item = item
break
if not base_item:
print(f"[GridTester] ⚠️ Deferred upscale #{job_idx+1}: base image not found in manifest, skipping")
@@ -513,6 +520,15 @@ def run_deferred_upscales(
else:
adjusted_prompt = actual_positive_prompt
hires_cond = conditioning_cache["positive"].get(adjusted_prompt)
if hires_cond is None:
# not cached (fresh per-model cache): encode it, keep the base conditioning on failure
try:
with pinned_clip(patched_clip):
hires_cond = encode_prompt_with_combinators(patched_clip, adjusted_prompt, conf.get("clip_skip", 0))
conditioning_cache["positive"][adjusted_prompt] = hires_cond
except Exception as e:
print(f"[GridTester] ⚠️ Deferred upscale #{job_idx+1}: HiRes prompt encode failed: {e}, using base prompt")
hires_cond = None
if hires_cond is not None:
hires_positive_cond = hires_cond
hires_prompt_active = True
@@ -695,6 +711,150 @@ def run_deferred_upscales(
print(f"[GridTester] ✅ Deferred upscale phase complete: {len(upscale_durations)}/{total_jobs} images upscaled")
# Item fields that describe THIS image rather than its config (stripped when a config is rebuilt from an item).
_ITEM_ONLY_KEYS = (
"id", "file", "filename", "favorited", "rejected", "note", "duration", "gen_index",
"config_fp", "base_fp", "metrics", "upscaled", "base_width", "base_height",
)
def _conf_from_item(item):
"""Best-effort job config rebuilt from a manifest item (fallback when no distributed job matches)."""
conf = {k: v for k, v in item.items() if k not in _ITEM_ONLY_KEYS}
conf["positive"] = item.get("config_positive", item.get("positive", ""))
conf["negative"] = item.get("config_negative", item.get("negative", ""))
conf.setdefault("lora_expanded", item.get("lora", "None"))
return conf
def _build_distributed_upscale_queue(items, pre_run_ids, jobs):
"""
Build the deferred-upscale queue for a distributed run.
items: existing_data["items"] (newest first); pre_run_ids: ids present before the run
(only this run's images are upscaled); jobs: [(config, input_job)] of COMPLETED jobs.
Worker-inserted items carry only a /view URL (no filename, no config_fp), so each item is
matched back to its job config with the same rule resume uses (check_if_job_completed).
Returns entries shaped like the local deferred queue plus ``base_item``.
"""
from .config_utils import config_fingerprint
by_key = {}
for cfg, ij in jobs:
by_key.setdefault((cfg.get("model"), cfg.get("sampler"), cfg.get("scheduler")), []).append((cfg, ij))
queue = []
for item in reversed(items): # oldest first = generation order
if item.get("id") in pre_run_ids:
continue
if item.get("upscaled") or item.get("preview") or item.get("media_type") == "video":
continue
if item.get("model_type") == "ltx_video":
continue
if not item_filename(item):
print(f"[Distribution] ⚠️ Upscale: item {item.get('id')} has no filename, skipping")
continue
w, h = item.get("width"), item.get("height")
seed = item.get("seed")
bidx = item.get("batch_idx", 0)
pos, neg = item.get("positive", ""), item.get("negative", "")
matches = []
for cfg, _ij in by_key.get((item.get("model"), item.get("sampler"), item.get("scheduler")), []):
try:
if check_if_job_completed([item], cfg, seed, w, h, bidx, pos, neg) == 0:
matches.append(cfg)
except Exception:
continue
if matches:
cfg = next((c for c in matches if c.get("seed") == seed), matches[0])
else:
cfg = _conf_from_item(item)
queue.append({
"config": cfg, "width": w, "height": h, "seed": seed,
"batch_idx": bidx, "actual_positive_prompt": pos,
"actual_negative_prompt": neg, "gen_index": item.get("gen_index", 0),
"config_fp": config_fingerprint(cfg, w, h, cfg.get("seed")),
"base_item": item,
})
return queue
def _run_distributed_upscales(
queue, upscale_settings, session_settings, paths, existing_data, session_name, unique_id,
ckpt_name, use_remote_vae, optional_model, optional_clip, optional_vae,
optional_positive, optional_negative, model_cache, incompatible_loras
):
"""
Run a distributed run's upscales on the master, one (model, LoRA, VAE) group at a time.
Each group loads its own model and gets a fresh conditioning cache (conditioning from another
model's CLIP is invalid), then everything is released before the next group.
"""
import comfy.model_management as mm
def _norm_vae(c):
v = c.get("vae", "Default")
return "Default" if (not v or str(v) in ("None", "none")) else v
groups = {}
for entry in queue:
c = entry["config"]
key = (get_model_cache_key(c), c.get("lora_expanded", c.get("lora", "None")), _norm_vae(c))
groups.setdefault(key, []).append(entry)
if optional_positive is not None or optional_negative is not None:
print("[Distribution] ℹ️ Positive/negative inputs are connected: upscale conditioning comes from the prompts, not those inputs")
PromptServer = None
try:
from server import PromptServer as _PS
PromptServer = _PS
except Exception:
pass
for (model_key, lora_str, vae_name), group in groups.items():
if mm.processing_interrupted():
print("[Distribution] 🛑 INTERRUPTED during distributed upscales")
break
cfg0 = group[0]["config"]
loaded_model = loaded_clip = loaded_vae = patched_model = patched_clip = None
cache = {"positive": {}, "negative": {}}
try:
loaded_model, loaded_clip, loaded_vae = load_model_by_type(
cfg0, ckpt_name, use_remote_vae, optional_model, optional_clip, optional_vae,
optional_positive, optional_negative, None, None, model_cache=model_cache
)
if loaded_vae is None and optional_vae is not None:
loaded_vae = optional_vae
if vae_name != "Default":
if is_remote_vae(vae_name):
print(f"[Distribution] ℹ️ Remote VAE '{vae_name}': upscale encode/decode stays local with the model's VAE")
else:
loaded_vae = load_vae_by_name(vae_name)
patched_model, patched_clip, should_skip = load_loras(
loaded_model, loaded_clip, lora_str, model_key, incompatible_loras, model_cache=model_cache
)
if should_skip:
print(f"[Distribution] ⚠️ LoRA incompatible for {model_key}; skipping {len(group)} upscales")
continue
if loaded_vae is None:
print(f"[Distribution] ⚠️ No local VAE for {model_key}; skipping {len(group)} upscales")
continue
run_deferred_upscales(
group, upscale_settings, session_settings, loaded_vae, patched_model, patched_clip,
cache, paths, existing_data, session_name, unique_id, PromptServer
)
except Exception as e:
if isinstance(e, InterruptProcessingException):
raise
print(f"[Distribution] ⚠️ Upscale group {model_key} failed: {e}")
finally:
patched_model, patched_clip = cleanup_model_references(patched_model, patched_clip, cache)
loaded_model = loaded_clip = loaded_vae = None
gc.collect()
def _expand_lora_weight_arrays(expanded_configs):
"""
Expand LoRA weight array bracket notation into Cartesian product of configs.
@@ -838,7 +998,7 @@ def run_generation_loop(
from .config_utils import (
parse_json_with_error, parse_float_input, parse_prompt_input_nested, derive_extra_seeds,
expand_configs, prepare_input_jobs
expand_configs, prepare_input_jobs, latent_job_pixel_size
)
from .config_utils import (
JOB_ORDERS, build_job_plan, order_job_plan, count_switches, config_fingerprint,
@@ -1627,6 +1787,12 @@ def run_generation_loop(
else:
model_switched = False
# A connected latent's pixel size = latent cells x this model's ratio, known only now.
if job.get("latent_cells"):
w, h = latent_job_pixel_size(job, latent_ratio)
if job_fp is not None:
job_fp = config_fingerprint(conf, w, h, conf["seed"])
# ==== PER-CONFIG VAE SWITCHING ====
# Config Builder VAE settings take priority over sampler node's remote_vae_endpoint
target_vae = conf.get("vae", "Default")
@@ -3073,6 +3239,11 @@ def _run_distributed_generation(
if image_format != "webp":
print(f"[GridTester] 🖼️ Image save format (distributed master): {image_format}")
# Ids present before this run: only images made by this run get upscaled in the post-phase
_pre_run_ids = {it.get("id") for it in existing_data.get("items", [])}
if optional_latent is not None:
print("[Distribution] ⚠️ Latent input: remote workers render empty latents at the placeholder size")
manager.populate_jobs(
expanded, input_jobs, existing_data,
overwrite_existing, has_optional_inputs, lora_triggerwords_mode
@@ -3339,6 +3510,9 @@ def _run_distributed_generation(
latent_channels = get_latent_channels(loaded_model, optional_latent)
latent_ratio = get_latent_downscale_ratio(loaded_model)
if optional_latent is not None:
# pixel size of a connected latent = cells x this model's ratio
w = int(optional_latent["samples"].shape[3]) * latent_ratio
h = int(optional_latent["samples"].shape[2]) * latent_ratio
latent_in = {"samples": optional_latent["samples"].clone()}
else:
latent_in = make_empty_latent(latent_channels, w, h, latent_ratio)
@@ -3616,29 +3790,31 @@ def _run_distributed_generation(
if session_settings and session_settings.get("upscaling", {}).get("enabled", False):
upscale_settings = session_settings.get("upscaling", {})
print(f"[Distribution] 🔍 Running upscales on master for distributed results...")
# Build deferred upscale queue from all generated items
deferred_upscale_queue = []
for item_meta in existing_data.get("items", []):
if not item_meta.get("upscaled"): # Skip items already upscaled
deferred_upscale_queue.append({
"meta": item_meta,
"config": {
"model": item_meta.get("model", ckpt_name),
"steps": item_meta.get("steps", 20),
"sampler": item_meta.get("sampler", "euler"),
"scheduler": item_meta.get("scheduler", "normal"),
"cfg": item_meta.get("cfg", 7.0),
}
})
if deferred_upscale_queue:
run_deferred_upscales(
deferred_upscale_queue, upscale_settings, existing_data,
session_name, paths, unique_id, ckpt_name,
conditioning_cache, model_cache,
use_remote_vae, remote_vae_endpoint
# Release the master's model first so the per-group loads below have the VRAM
patched_model, patched_clip = cleanup_model_references(patched_model, patched_clip, conditioning_cache)
conditioning_cache = {"positive": {}, "negative": {}}
loaded_model, loaded_clip, loaded_vae = None, None, None
gc.collect()
try:
with manager._lock:
_done_jobs = [(j.config, j.input_job) for j in manager._jobs.values() if j.state == JobState.COMPLETED]
deferred_upscale_queue = _build_distributed_upscale_queue(
existing_data.get("items", []), _pre_run_ids, _done_jobs
)
else:
print(f"[Distribution] ℹ️ No items to upscale")
if deferred_upscale_queue:
print(f"[Distribution] 🔍 {len(deferred_upscale_queue)} images from this run to upscale")
_run_distributed_upscales(
deferred_upscale_queue, upscale_settings, session_settings, paths, existing_data,
session_name, unique_id, ckpt_name, use_remote_vae,
optional_model, optional_clip, optional_vae, optional_positive, optional_negative,
model_cache, incompatible_loras
)
else:
print(f"[Distribution] ℹ️ No items to upscale")
except InterruptProcessingException:
raise
except Exception as e:
print(f"[Distribution] Upscale phase failed: {e}")
existing_data["meta"] = {
"positive": positive_text, "negative": negative_text,
+184
View File
@@ -0,0 +1,184 @@
"""Distributed runs: the master's post-phase upscales this run's images, grouped per model/LoRA/VAE.
REGRESSION (2026-11): the post-phase called run_deferred_upscales() with 11 positional arguments against a
12+ signature and entries without width/height, so it raised on every distributed run with upscaling on.
"""
import importlib.util
import inspect
import os
import re
import sys
import pytest
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _ROOT)
import generation_orchestrator as go # noqa: E402 (conftest preloads the real module)
from config_utils import config_fingerprint # noqa: E402
_spec = importlib.util.spec_from_file_location("_mu_real_du", os.path.join(_ROOT, "manifest_utils.py"))
_mu = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mu)
_REAL_RDU = go.run_deferred_upscales
LOCAL_KEYS = {"config", "width", "height", "seed", "batch_idx", "actual_positive_prompt",
"actual_negative_prompt", "gen_index", "config_fp"}
def _conf(model="a.safetensors", seed=1, lora="None", vae="Default", **kw):
c = {"model": model, "sampler": "euler", "scheduler": "normal", "steps": 8, "cfg": 5.0,
"denoise": 1.0, "seed": seed, "lora": lora, "lora_expanded": lora, "vae": vae,
"positive": "p", "negative": "n", "gguf_options": object()}
c.update(kw)
return c
def _item(i, conf, **kw):
it = {"id": i, "file": f"/view?filename=img_{i}.webp&type=output", "seed": conf["seed"],
"width": 768, "height": 768, "batch_idx": 0, "positive": "p", "negative": "n",
"model": conf["model"], "sampler": conf["sampler"], "scheduler": conf["scheduler"],
"steps": conf["steps"], "cfg": conf["cfg"], "denoise": conf["denoise"], "lora": conf["lora"],
"vae": conf.get("vae", "Default"), "config_positive": "cp", "config_negative": "cn"}
it.update(kw)
return it
@pytest.fixture(autouse=True)
def _real_item_filename(monkeypatch):
monkeypatch.setattr(go, "item_filename", _mu.item_filename)
def test_queue_only_this_runs_plain_items_oldest_first():
c1, c2 = _conf(seed=1), _conf(seed=2)
jobs = [(c1, {}), (c2, {})]
items = [ # newest first
_item(5, c2),
_item(4, c1),
_item(3, c1, filename="x_upscaled.webp", upscaled=True),
_item(2, c1, file=""), # no filename
_item(1, c1), # pre-run
]
q = go._build_distributed_upscale_queue(items, {1, 3}, jobs)
assert [e["base_item"]["id"] for e in q] == [4, 5]
for e in q:
assert set(e) == LOCAL_KEYS | {"base_item"}
assert q[0]["config"] is c1 and q[1]["config"] is c2 # job config by identity (gguf_options sentinel survives)
assert q[0]["config_fp"] == config_fingerprint(c1, 768, 768, 1)
def test_queue_skips_preview_video_ltx():
c = _conf()
items = [_item(9, c, preview=True), _item(8, c, media_type="video"), _item(7, c, model_type="ltx_video")]
assert go._build_distributed_upscale_queue(items, set(), [(c, {})]) == []
def test_queue_prefers_seed_sibling_and_handles_randomized_seed():
a, b = _conf(seed=10), _conf(seed=20)
q = go._build_distributed_upscale_queue([_item(2, b), _item(1, a)], set(), [(a, {}), (b, {})])
assert [e["config"] for e in q] == [a, b]
# randomized: the item carries a seed that is not the conf's; it still matches its job
r = _conf(seed=10, seed_behavior="randomize")
it = _item(3, r, seed=987654)
q = go._build_distributed_upscale_queue([it], set(), [(r, {})])
assert q[0]["config"] is r and q[0]["seed"] == 987654
def test_queue_fallback_conf_strips_item_fields():
c = _conf()
it = _item(4, _conf(model="other.safetensors"), favorited=True, note="n", gen_index=3, duration=1.5)
q = go._build_distributed_upscale_queue([it], set(), [(c, {})])
cfg = q[0]["config"]
for k in ("id", "file", "favorited", "note", "gen_index", "duration"):
assert k not in cfg
assert cfg["positive"] == "cp" and cfg["negative"] == "cn"
assert cfg["lora_expanded"] == "None" and cfg["model"] == "other.safetensors"
# ---- driver -------------------------------------------------------------------------------------------------
class _Rec:
def __init__(self, mp, vae_result="vae", skip_lora=()):
self.calls, self.loads = [], []
self.skip_lora, self.vae_result = set(skip_lora), vae_result
mp.setattr(go, "load_model_by_type", self._load)
mp.setattr(go, "load_loras", self._loras)
mp.setattr(go, "load_vae_by_name", lambda n: f"vae:{n}")
mp.setattr(go, "cleanup_model_references", lambda m, c, cache: (None, None))
mp.setattr(go, "run_deferred_upscales", lambda *a, **kw: self.calls.append(a))
mp.setattr(go, "is_remote_vae", lambda v: str(v).startswith("remote"))
self.mm = sys.modules["comfy.model_management"]
mp.setattr(self.mm, "processing_interrupted", lambda: False, raising=False)
def _load(self, conf, *a, **kw):
self.loads.append(conf["model"])
return "m", "c", (self.vae_result if self.vae_result != "none" else None)
def _loras(self, m, c, lora, key, inc, model_cache=None):
return "pm", "pc", lora in self.skip_lora
def _entry(conf):
return {"config": conf, "width": 8, "height": 8, "seed": 1, "batch_idx": 0, "actual_positive_prompt": "p",
"actual_negative_prompt": "n", "gen_index": 0, "config_fp": "x", "base_item": {}}
def _drive(queue):
return go._run_distributed_upscales(queue, {"pipelines": [1]}, {}, {"images": "i"}, {"items": []}, "s", "u",
"ck", False, None, None, None, None, None, {}, {})
def test_driver_groups_per_model_lora_vae(monkeypatch):
rec = _Rec(monkeypatch)
q = [_entry(_conf("a")), _entry(_conf("b")), _entry(_conf("a")), _entry(_conf("a", lora="L"))]
_drive(q)
assert len(rec.calls) == 3
assert [len(c[0]) for c in rec.calls] == [2, 1, 1] # first-appearance order
assert len({id(c[6]) for c in rec.calls}) == 3 # a fresh conditioning cache per group
n = len(inspect.signature(_REAL_RDU).parameters)
assert len(rec.calls[0]) == n - 1 # config_overrides_vae stays defaulted
assert rec.calls[0][3] == "vae" and rec.calls[0][4] == "pm" and rec.calls[0][5] == "pc"
def test_driver_skips_missing_vae_and_incompatible_lora(monkeypatch):
rec = _Rec(monkeypatch, vae_result="none")
_drive([_entry(_conf("a"))])
assert rec.calls == []
rec = _Rec(monkeypatch, skip_lora={"L"})
_drive([_entry(_conf("a", lora="L")), _entry(_conf("b"))])
assert len(rec.calls) == 1 and rec.calls[0][0][0]["config"]["model"] == "b"
def test_driver_named_vae_is_loaded(monkeypatch):
rec = _Rec(monkeypatch)
_drive([_entry(_conf("a", vae="my.vae"))])
assert rec.calls[0][3] == "vae:my.vae"
def test_driver_stops_on_interrupt(monkeypatch):
rec = _Rec(monkeypatch)
monkeypatch.setattr(rec.mm, "processing_interrupted", lambda: True, raising=False)
_drive([_entry(_conf("a")), _entry(_conf("b"))])
assert rec.calls == []
# ---- source guards ------------------------------------------------------------------------------------------
def _src(name):
with open(os.path.join(_ROOT, "generation_orchestrator.py"), encoding="utf-8") as f:
text = f.read()
m = re.search(rf"\ndef {name}\(.*?(?=\n(?:def |class )|\Z)", text, re.S)
assert m, name
return m.group(0)
def test_post_phase_source():
s = _src("_run_distributed_generation")
assert '"meta": item_meta' not in s
assert "_run_distributed_upscales(" in s and "_build_distributed_upscale_queue(" in s
assert s.index("_pre_run_ids =") < s.index("manager.populate_jobs(")
assert "run_deferred_upscales(" not in s
def test_deferred_uses_base_item():
assert 'job.get("base_item")' in _src("run_deferred_upscales")
+58
View File
@@ -0,0 +1,58 @@
"""A connected latent's pixel size is cells x the model's latent ratio, not cells * 8."""
import os
import re
import sys
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _ROOT)
from config_utils import prepare_input_jobs, latent_job_pixel_size # noqa: E402
class T:
def __init__(self, shape):
self.shape = tuple(shape)
def __getitem__(self, i):
return T(self.shape[1:])
def unsqueeze(self, d):
return T((1,) + self.shape)
def test_prepare_input_jobs_carries_cells():
jobs = prepare_input_jobs({"samples": T((2, 128, 76, 52))}, [])
assert len(jobs) == 2
assert [j["batch_idx"] for j in jobs] == [0, 1]
assert all(j["latent_cells"] == (52, 76) for j in jobs)
assert (jobs[0]["width"], jobs[0]["height"]) == (416, 608)
assert prepare_input_jobs({"samples": T((1, 4, 8, 8))}, [], 16)[0]["width"] == 128
def test_latent_job_pixel_size():
j = prepare_input_jobs({"samples": T((1, 128, 76, 52))}, [])[0]
assert latent_job_pixel_size(j, 16) == (832, 1216)
assert latent_job_pixel_size(j, 8) == (416, 608)
assert latent_job_pixel_size(j, None) == (416, 608)
r = prepare_input_jobs(None, [[512, 768]])[0]
assert latent_job_pixel_size(r, 16) == (512, 768)
def _read(n):
with open(os.path.join(_ROOT, n), encoding="utf-8") as f:
return f.read()
def test_no_hardcoded_8_in_prepare_input_jobs():
body = re.search(r"def prepare_input_jobs\(.*?\ndef ", _read("config_utils.py"), re.S).group(0)
assert "* 8" not in body
def test_orchestrator_resolves_size_after_ratio():
s = _read("generation_orchestrator.py")
a = s.index("latent_ratio = get_latent_downscale_ratio(loaded_model)")
b = s.index("latent_job_pixel_size(job, latent_ratio)")
c = s.index("make_empty_latent(latent_channels, gen_w")
assert a < b < c
# distributed master too
assert 'optional_latent["samples"].shape[3]) * latent_ratio' in s