feat(orchestrator): flat job plan, job order, preview pass and preview promotion

- run_generation_loop walks a flat job plan (config_utils.build_job_plan) instead of the
  nested resolution x config loops; job_order=minimize_reloads reorders it to cut model
  and LoRA reloads. as_written reproduces the old order exactly.
- Preview pass (fewer steps, smaller snapped latent, no upscales); items carry config_fp,
  preview_of, full_* keys. A full run skips cells whose preview was rejected (tombstoned
  in meta.rejected_preview_fps by delete_rejected); a preview run skips cells that already
  have a full image.
- check_if_job_completed gets a keyword-only preview flag (positional API unchanged).
- Distributed mode is untouched apart from an 'ignored' warning.

fix: a per-config resolution no longer leaks its w/h/batch_idx into later configs of the
same pass (the old loop set them once per input job; the pre-flight scan already did this
correctly, so pre-flight and the main loop disagreed).
fix: the async look-ahead picked the next config via job_idx*len(expanded)+conf_idx, which
was wrong whenever per-config-resolution configs were skipped after the first pass; it now
reads the next plan entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jason Hoku
2026-11-04 00:04:13 -08:00
co-authored by Claude Fable 5.1
parent ca20cee664
commit 602bde44fb
4 changed files with 345 additions and 113 deletions
+6
View File
@@ -919,6 +919,12 @@ async def delete_rejected(request):
except Exception as e:
print(f"[Delete] Error deleting {filename}: {e}")
# Tombstone rejected PREVIEW cells: a later full pass must still know the user rejected them
# even though the preview item (the only record of the rejection) is gone after this delete.
from .config_utils import collect_rejected_preview_fps
manifest.setdefault("meta", {})["rejected_preview_fps"] = collect_rejected_preview_fps(
rejected, manifest.get("meta", {}).get("rejected_preview_fps", []))
# Update manifest to remove rejected items
manifest["items"] = kept
with open(manifest_path, "w", encoding="utf-8") as f:
+13
View File
@@ -1049,3 +1049,16 @@ def build_full_fp_set(items):
if fp:
out.add(fp)
return out
def collect_rejected_preview_fps(rejected_items, existing=()):
"""Union of ``existing`` tombstones and the ``preview_of`` of rejected PREVIEW items (deduped, order kept)."""
out = list(existing or [])
seen = set(out)
for it in rejected_items:
if it.get("preview") is True:
fp = it.get("preview_of") or item_fingerprint(it)
if fp and fp not in seen:
seen.add(fp)
out.append(fp)
return out
+251 -113
View File
@@ -170,12 +170,20 @@ def calculate_clip_hash(clip_model):
return "unknown"
def check_if_job_completed(existing_items, conf, seed, width, height, batch_idx, positive_prompt, negative_prompt, has_optional_inputs=False):
def check_if_job_completed(existing_items, conf, seed, width, height, batch_idx, positive_prompt, negative_prompt, has_optional_inputs=False, *, preview=False):
"""Independent check to see if a specific generation job already exists.
When has_optional_inputs is True, we cannot reliably detect changes from
upstream nodes (models, LoRAs, conditionings, latents), so we disable
skip detection entirely and return -1 (no match) to force re-generation.
``preview`` (keyword-only): preview-pass items (``item["preview"] is True``) and
full items never match each other. A preview job matches on the FULL width/height
stored in ``full_width``/``full_height`` (the preview's own latent size depends on
the model's latent ratio, which is unknown before the model loads); callers pass a
conf whose steps/cfg are the preview values. Legacy items have no ``preview`` key
and count as full. The distributed companion calls this positionally, so full
behaviour is the default.
"""
# When optional inputs are connected, we can't reliably match on model,
# lora, prompts, or latents because those may come from upstream nodes
@@ -186,9 +194,14 @@ def check_if_job_completed(existing_items, conf, seed, width, height, batch_idx,
FLOAT_TOLERANCE = 0.0001
for idx, item in enumerate(existing_items):
if bool(item.get("preview", False)) != preview: continue
if item.get("seed") != seed: continue
if item.get("width") != width: continue
if item.get("height") != height: continue
if preview:
if item.get("full_width", item.get("width")) != width: continue
if item.get("full_height", item.get("height")) != height: continue
else:
if item.get("width") != width: continue
if item.get("height") != height: continue
if item.get("batch_idx", 0) != batch_idx: continue
if item.get("sampler") != conf["sampler"]: continue
if item.get("scheduler") != conf["scheduler"]: continue
@@ -614,6 +627,10 @@ def run_deferred_upscales(
upscaled_meta["filename"] = upscaled_filename
upscaled_meta["upscaled"] = True
upscaled_meta["upscale_pipeline"] = pipeline_name
if job.get("config_fp"):
from .config_utils import config_fingerprint as _cfp
upscaled_meta["base_fp"] = job["config_fp"]
upscaled_meta["config_fp"] = _cfp(conf, pipe_w, pipe_h, conf.get("seed"), pipeline_name)
upscaled_meta["upscale_mode"] = mode
upscaled_meta["upscale_ratio"] = up_ratio
upscaled_meta["upscale_denoise"] = up_denoise
@@ -813,7 +830,36 @@ def run_generation_loop(
parse_json_with_error, parse_float_input, parse_prompt_input_nested,
expand_configs, prepare_input_jobs
)
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
)
# ==== JOB ORDER / PREVIEW PASS SETTINGS (defaults reproduce the old behaviour) ====
_ss = session_settings or {}
job_order = _ss.get("job_order", "as_written")
if job_order not in JOB_ORDERS:
job_order = "as_written"
pv = _ss.get("preview_pass") or {}
preview_on = bool(pv.get("enabled"))
if preview_on and optional_latent is not None:
print(f"[GridTester] ⚠️ Preview pass ignored: a latent input is connected (its size is fixed)")
preview_on = False
try:
pv_steps = max(1, int(pv.get("steps", 8)))
except (TypeError, ValueError):
pv_steps = 8
try:
pv_scale = min(1.0, max(0.05, float(pv.get("scale", 0.5))))
except (TypeError, ValueError):
pv_scale = 0.5
try:
pv_cfg = None if pv.get("cfg_override") in (None, "") else float(pv.get("cfg_override"))
except (TypeError, ValueError):
pv_cfg = None
skip_rejected_previews = bool(_ss.get("skip_rejected_previews", True))
only_favorited_previews = bool(_ss.get("only_favorited_previews", False))
raw_configs = parse_json_with_error(configs_json, "configs")
denoise_values = parse_float_input(denoise)
resolutions = parse_json_with_error(resolutions_json, "resolutions")
@@ -897,8 +943,23 @@ def run_generation_loop(
configs_without_res = len(expanded) - configs_with_res
total_jobs = configs_with_res + (configs_without_res * len(input_jobs))
# ==== FLAT JOB PLAN (+ optional reload-minimising order) ====
# One entry per job, in exactly the order the old nested loop visited them
# (resolution outer, configs inner) unless job_order says otherwise.
_plan_as_written = build_job_plan(expanded, input_jobs)
job_plan = order_job_plan(_plan_as_written, job_order, get_model_cache_key)
if len(job_plan) != total_jobs:
print(f"[GridTester] ⚠️ Job plan has {len(job_plan)} entries but total_jobs is {total_jobs}")
if job_order == "minimize_reloads":
_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']}")
print(f"{'='*80}")
print(f"[GridTester] 🚀 GENERATION START")
if job_order != "as_written":
print(f"[GridTester] 🧭 Job order: {job_order} (Start At Job numbers follow this order)")
if configs_with_res > 0:
print(f"[GridTester] 📋 {configs_with_res} configs with per-config resolutions + "
f"{configs_without_res} configs × {len(input_jobs)} resolutions = {total_jobs} total jobs")
@@ -943,6 +1004,9 @@ def run_generation_loop(
if dist_enabled:
print(f"[GridTester] 🌐 ENTERING DISTRIBUTED MODE with {len(distribution_config.get('worker_urls', []))} worker(s)")
if (job_order != "as_written" or preview_on
or any(it.get("preview") is True for it in existing_data.get("items", []))):
print(f"[GridTester] ⚠️ job_order / preview pass / preview promotion are not supported in distributed mode yet - ignored")
# Upscaling in distributed mode: workers generate base images, master runs upscales after collection
if session_settings and session_settings.get("upscaling", {}).get("enabled", False):
print(f"[GridTester] ℹ️ Upscaling enabled — workers will generate base images, master will run upscales after all generation completes.")
@@ -958,6 +1022,19 @@ def run_generation_loop(
session_settings=session_settings
)
# The schedule above used `expanded`; the main loop walks job_plan, so the smart cache needs
# plan positions (identical to the old behaviour when there is one resolution and as_written).
if model_cache:
model_cache.register_schedule([pj["conf"] for pj in job_plan])
# ==== PREVIEW PASS: no upscaling at all (one change point covers inline, deferred, HiRes pre-encode) ====
if preview_on:
session_settings = {**(session_settings or {}), "upscaling": {"enabled": False}}
print(f"[GridTester] 🔎 Preview pass: {pv_steps} steps, x{pv_scale} scale"
f"{'' if pv_cfg is None else f', cfg {pv_cfg}'}, upscales skipped")
if any(c.get("model_type") == "ltx_video" for c in expanded):
print(f"[GridTester] ℹ️ Preview pass does not apply to LTX video configs - they render normally")
# ==== OPTIONAL CONDITIONING SETUP ====
if optional_positive or optional_negative:
pos_hash = hashlib.md5(str(optional_positive).encode()).hexdigest()[:16] if optional_positive else None
@@ -1020,6 +1097,51 @@ def run_generation_loop(
current_remote_vae_url = None
config_overrides_vae = False # True when config explicitly sets a non-Default VAE (overrides sampler node's remote_vae_endpoint)
# ==== SKIP PREDICATE (single source of truth for "does this plan entry still need work?") ====
# `_pstate` holds the preview index (rebuilt lazily whenever the item list changes, i.e. after
# every flush/save, so dashboard rejects/favourites merged in by save_manifest are seen mid-run).
_pstate = {"sig": None, "pindex": {}, "full_fps": set()}
def _have_history():
return bool(existing_data.get("items")) or bool((existing_data.get("meta") or {}).get("rejected_preview_fps"))
def _refresh_pstate():
items = existing_data.get("items", [])
sig = (len(items), id(items[0]) if items else None)
if sig == _pstate["sig"]:
return
_pstate["sig"] = sig
_pstate["pindex"] = build_preview_index(items, (existing_data.get("meta") or {}).get("rejected_preview_fps", []))
_pstate["full_fps"] = build_full_fp_set(items) if preview_on else set()
def _effective_conf(c):
"""The conf a PREVIEW job actually samples with (fewer steps, optional cfg override)."""
return {**c, "steps": pv_steps, "cfg": c["cfg"] if pv_cfg is None else pv_cfg}
def _promotion_status(c, fp):
"""'rejected_preview' / 'no_favorited_preview' (full run) or 'full_exists' (preview run) or None."""
if has_optional_inputs or c.get("model_type") == "ltx_video" or fp is None:
return None
_refresh_pstate()
if preview_on:
return "full_exists" if fp in _pstate["full_fps"] else None
return should_skip_for_previews(fp, _pstate["pindex"], skip_rejected_previews, only_favorited_previews)
def _job_status(pj):
"""'exists' | 'rejected_preview' | 'no_favorited_preview' | 'full_exists' | 'todo'."""
c = pj["conf"]
is_pv = preview_on and c.get("model_type") != "ltx_video"
pos_, _ = build_prompt_with_triggers(c, lora_triggerwords_mode)
if check_if_job_completed(
existing_data["items"], _effective_conf(c) if is_pv else c, c["seed"],
pj["w"], pj["h"], pj["batch_idx"], pos_, c["negative"],
has_optional_inputs=has_optional_inputs, preview=is_pv
) != -1:
return "exists"
if has_optional_inputs or c.get("model_type") == "ltx_video":
return "todo"
return _promotion_status(c, config_fingerprint(c, pj["w"], pj["h"], c["seed"])) or "todo"
# ==== PRE-ENCODING STAGE ====
unique_model_keys = set(get_model_cache_key(conf) for conf in expanded)
@@ -1028,7 +1150,7 @@ def run_generation_loop(
all_ltx = expanded and all(c.get("model_type") == "ltx_video" for c in expanded)
if not all_ltx and not (optional_positive and optional_negative) and expanded and len(unique_model_keys) == 1:
first_conf = expanded[0]
first_conf = job_plan[0]["conf"] if job_plan else expanded[0]
target_model_name = first_conf["model"]
print(f"[GridTester] ✅ Single model detected ({target_model_name}) - enabling pre-encoding")
@@ -1091,25 +1213,16 @@ def run_generation_loop(
# Filter configs to only collect prompts from jobs that actually need to run
# This avoids wasting time encoding prompts for jobs that will be skipped in resume mode
print(f"[GridTester] 🧠 Collecting unique prompts...")
if not overwrite_existing and existing_data.get("items"):
if not overwrite_existing and _have_history():
configs_needing_work = []
for conf in expanded:
conf_positive, _ = build_prompt_with_triggers(conf, lora_triggerwords_mode)
conf_negative = conf["negative"]
conf_seed = conf["seed"]
# Check if ANY resolution/batch needs this config
needs_work = False
for job in input_jobs:
if check_if_job_completed(
existing_data["items"], conf, conf_seed,
job["width"], job["height"], job["batch_idx"],
conf_positive, conf_negative,
has_optional_inputs=has_optional_inputs
) == -1:
needs_work = True
break
if needs_work:
configs_needing_work.append(conf)
_seen_need = set()
# A config needs work if ANY of its plan entries (resolution/batch) is still todo
for _npj in job_plan:
if id(_npj["conf"]) in _seen_need:
continue
if _job_status(_npj) == "todo":
_seen_need.add(id(_npj["conf"]))
configs_needing_work.append(_npj["conf"])
skippable = len(expanded) - len(configs_needing_work)
if skippable > 0:
@@ -1199,49 +1312,30 @@ def run_generation_loop(
# in the running ETA line so the user knows how much real work is left.
# Only useful when overwrite_existing is False (otherwise nothing is skipped).
existing_count = 0
if not overwrite_existing and not has_optional_inputs and existing_data.get("items"):
_pf_ran = False
if not overwrite_existing and not has_optional_inputs and _have_history():
_pf_ran = True
_pf_total = total_jobs
print(f"[GridTester] 🔍 Pre-flight scan: checking which of {_pf_total} configs already exist...")
_pf_checked = 0
for _pf_job in input_jobs:
_pf_w, _pf_h = _pf_job["width"], _pf_job["height"]
_pf_batch_idx = _pf_job["batch_idx"]
for _pf_job_idx_outer, _pf_job2 in enumerate(input_jobs):
if _pf_job2 is not _pf_job:
continue
_pf_outer_idx = _pf_job_idx_outer
break
else:
_pf_outer_idx = 0
for _pf_conf in expanded:
# Mirror the per-config resolution override from the main loop
_pf_use_w, _pf_use_h, _pf_use_batch_idx = _pf_w, _pf_h, _pf_batch_idx
if _pf_conf.get("resolution") is not None:
if _pf_outer_idx > 0:
continue # Processed only at job_idx == 0 in the main loop
_pf_use_w, _pf_use_h = _pf_conf["resolution"]
_pf_use_batch_idx = 0
# Use conf seed (randomize-behavior seeds won't match existing items — correct)
_pf_seed = _pf_conf["seed"]
_pf_pos, _ = build_prompt_with_triggers(_pf_conf, lora_triggerwords_mode)
_pf_neg = _pf_conf["negative"]
_pf_match = check_if_job_completed(
existing_data["items"],
_pf_conf,
_pf_seed,
_pf_use_w, _pf_use_h,
_pf_use_batch_idx,
_pf_pos,
_pf_neg,
has_optional_inputs=False
)
if _pf_match != -1:
existing_count += 1
_pf_checked += 1
if _pf_checked % 1000 == 0:
print(f"[GridTester] 🔍 ...scanned {_pf_checked}/{_pf_total} ({existing_count} exist so far)")
_pf_reasons = {}
# Same job_plan the main loop walks (per-config resolutions already applied by build_job_plan)
for _pf_pj in job_plan:
_pf_st = _job_status(_pf_pj)
if _pf_st != "todo":
existing_count += 1
_pf_reasons[_pf_st] = _pf_reasons.get(_pf_st, 0) + 1
_pf_checked += 1
if _pf_checked % 1000 == 0:
print(f"[GridTester] 🔍 ...scanned {_pf_checked}/{_pf_total} ({existing_count} exist so far)")
_pf_new = _pf_total - existing_count
print(f"[GridTester] ✅ Pre-flight: {existing_count}/{_pf_total} already exist; {_pf_new} new to generate")
if _pf_reasons.get("rejected_preview"):
print(f"[GridTester] ⏭️ {_pf_reasons['rejected_preview']} cells skipped because their preview was rejected")
if _pf_reasons.get("no_favorited_preview"):
print(f"[GridTester] ⏭️ {_pf_reasons['no_favorited_preview']} cells skipped because their preview is not favorited")
if _pf_reasons.get("full_exists"):
print(f"[GridTester] ⏭️ {_pf_reasons['full_exists']} cells skipped in the preview pass because a full image already exists")
# Send initial dashboard progress so the user sees "X/N already done"
# even before the first new gen completes. Clears any stale values from a prior run.
@@ -1267,24 +1361,18 @@ def run_generation_loop(
_ltx_preflight_done = [False] # Mutable cell so inner branch can set it
for job_idx, job in enumerate(input_jobs):
w, h = job["width"], job["height"]
batch_idx = job["batch_idx"]
for conf_idx, conf in enumerate(expanded):
# Per-config resolution override: if this config has its own resolution,
# use it instead of the global input_job's resolution. Only process once
# (at job_idx == 0) to avoid duplicating work across input_jobs.
if conf.get("resolution") is not None:
if job_idx > 0:
continue # Already processed at job_idx=0
w, h = conf["resolution"]
batch_idx = 0
_promo_skips = 0
for plan_idx, pj in enumerate(job_plan):
# The per-config resolution override (and its batch_idx reset) now lives in build_job_plan,
# so w/h/batch_idx are fresh for every entry and can no longer leak into later configs.
conf, conf_idx, job_idx, job = pj["conf"], pj["conf_idx"], pj["job_idx"], pj["job"]
w, h, batch_idx = pj["w"], pj["h"], pj["batch_idx"]
if True: # body below keeps its original indentation (it used to sit inside the nested loops)
# ==== CHECK FOR INTERRUPT ====
# ==== UPDATE CURRENT STEP ====
if model_cache:
model_cache.set_current_step(conf_idx)
model_cache.set_current_step(plan_idx)
try:
import comfy.model_management as mm
@@ -1311,7 +1399,10 @@ def run_generation_loop(
"vae_batch_size": vae_batch_size,
"configs_json": configs_json,
"resolutions_json": resolutions_json,
"image_format": image_format
"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", []))
}
save_manifest(paths["manifest"], existing_data)
@@ -1333,6 +1424,15 @@ def run_generation_loop(
if conf.get("seed_behavior") == "randomize":
import random
current_seed = random.randint(0, 2**63 - 1)
# Fingerprint of this cell (configured seed, FULL resolution); LTX items are not fingerprinted.
job_fp = None
if conf.get("model_type") != "ltx_video":
job_fp = config_fingerprint(conf, w, h, conf["seed"])
if not preview_on and not has_optional_inputs and conf.get("seed_behavior") == "randomize":
_refresh_pstate()
_pe = _pstate["pindex"].get(job_fp)
if _pe and _pe.get("seed") is not None:
current_seed = _pe["seed"] # re-render the survivor with the seed the user approved
current_job += 1
if pbar:
@@ -1401,8 +1501,14 @@ def run_generation_loop(
continue
progress_pct = int((current_job / total_jobs) * 100)
print(f"[GridTester] 📊 {current_job}/{total_jobs} ({progress_pct}%) | "
f"{conf['sampler']} @ {conf['steps']} steps | {w}x{h}")
_is_pv = preview_on # LTX configs never reach this point (dispatched above)
eff_conf = _effective_conf(conf) if _is_pv else conf
if _is_pv:
print(f"[GridTester] 📊 {current_job}/{total_jobs} ({progress_pct}%) | "
f"{conf['sampler']} @ PREVIEW {pv_steps} steps | {w}x{h} x{pv_scale}")
else:
print(f"[GridTester] 📊 {current_job}/{total_jobs} ({progress_pct}%) | "
f"{conf['sampler']} @ {conf['steps']} steps | {w}x{h}")
actual_positive_prompt, lora_triggers = build_prompt_with_triggers(
conf, lora_triggerwords_mode
@@ -1414,16 +1520,30 @@ def run_generation_loop(
skipped_count += 1
continue
# ==== PREVIEW PROMOTION SKIP ====
# Full run: skip cells whose preview was rejected (or not favorited). Preview run: skip cells
# that already have a full image. A rejection is an explicit user signal, so overwrite_existing
# does not override it; it still overrides the "full image exists" skip.
_promo_why = _promotion_status(conf, job_fp)
if _promo_why and not (_promo_why == "full_exists" and overwrite_existing):
skipped_count += 1
if _promo_why != "full_exists":
_promo_skips += 1
if _promo_skips % 50 == 0:
print(f"[GridTester] ⏭️ skipped {_promo_skips} jobs whose preview was rejected")
continue
# ==== CHECK EXISTING MATCH ====
match_index = check_if_job_completed(
existing_data["items"],
conf,
eff_conf,
current_seed,
w, h,
batch_idx,
actual_positive_prompt,
actual_negative_prompt,
has_optional_inputs=has_optional_inputs
has_optional_inputs=has_optional_inputs,
preview=_is_pv
)
if match_index != -1:
@@ -1555,26 +1675,19 @@ def run_generation_loop(
_seen_positives = set()
_seen_negatives = set()
for future_idx in range(conf_idx, len(expanded)):
future_conf = expanded[future_idx]
for future_idx in range(plan_idx, len(job_plan)):
future_conf = job_plan[future_idx]["conf"]
if get_model_cache_key(future_conf) != target_model_key:
break # the contiguous run of this model in the plan
if get_model_cache_key(future_conf) == target_model_key:
future_positive, _ = build_prompt_with_triggers(
future_conf, lora_triggerwords_mode
)
# Skip encoding prompts for configs that are already completed
# (avoids wasting time on prompts only used by skipped jobs)
if not overwrite_existing and existing_data.get("items"):
future_seed = future_conf["seed"]
all_done = True
for fj in input_jobs:
if check_if_job_completed(
existing_data["items"], future_conf, future_seed,
fj["width"], fj["height"], fj["batch_idx"],
future_positive, future_conf["negative"],
has_optional_inputs=has_optional_inputs
) == -1:
all_done = False
break
if not overwrite_existing and _have_history():
# this plan entry (conf @ resolution) is already done or skipped
all_done = _job_status(job_plan[future_idx]) != "todo"
if all_done:
continue
if future_positive not in _seen_positives:
@@ -1633,7 +1746,10 @@ def run_generation_loop(
"vae_batch_size": vae_batch_size,
"configs_json": configs_json,
"resolutions_json": resolutions_json,
"image_format": image_format
"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", []))
}
save_manifest(paths["manifest"], existing_data)
@@ -1687,16 +1803,12 @@ def run_generation_loop(
# ==== 🚀 ASYNC LOOK-AHEAD PRE-FETCHING (CORRECTED) ====
# =========================================================
if model_cache is not None and model_cache.async_preload:
current_overall_index = (job_idx * len(expanded)) + conf_idx
# Check if next job exists
if current_overall_index + 1 < total_jobs:
next_idx = current_overall_index + 1
next_job_idx = next_idx // len(expanded)
next_conf_idx = next_idx % len(expanded)
if next_job_idx < len(input_jobs):
next_conf = expanded[next_conf_idx]
# Check if next job exists (the plan carries it directly, no index arithmetic)
if plan_idx + 1 < len(job_plan):
next_idx = plan_idx + 1
if next_idx < len(job_plan):
next_conf = job_plan[next_idx]["conf"]
# RESOLVE cache keys for next conf
next_model_key = get_model_cache_key(next_conf)
@@ -1757,6 +1869,9 @@ def run_generation_loop(
model_cache.preload_base_model(next_model_key, _preload_diff_worker)
# =========================================================
# Preview pass renders a snapped, scaled-down latent; full jobs use w x h unchanged.
gen_w, gen_h = preview_dims(w, h, pv_scale, latent_ratio) if _is_pv else (w, h)
# Generate image
if job["latent"] is not None:
latent_in = {"samples": job["latent"]["samples"].clone()}
@@ -1764,7 +1879,7 @@ def run_generation_loop(
# Pixels-per-cell comes from the model's latent format (8 for SD/SDXL/Flux.1,
# 16 for the Flux.2 family incl. MOSS-Image). Hardcoding 8 here made 16x
# models render at 2x size and ~10x slower.
latent_in = make_empty_latent(latent_channels, w, h, latent_ratio)
latent_in = make_empty_latent(latent_channels, gen_w, gen_h, latent_ratio)
result_latent = None
try:
@@ -1784,7 +1899,7 @@ def run_generation_loop(
print(f"[GridTester] ⚙️ Extra options: {', '.join(extra_opts)}")
result_latent, duration = generate_image(
patched_model, current_seed, conf["steps"], conf["cfg"],
patched_model, current_seed, eff_conf["steps"], eff_conf["cfg"],
conf["sampler"], conf["scheduler"], final_positive, final_negative,
latent_in, conf["denoise"], attention_mode=attention_mode,
model_sampling_override=conf.get("model_sampling_override", "none"),
@@ -1808,8 +1923,8 @@ def run_generation_loop(
slg_single_layers=conf.get("slg_single_layers", "22"),
slg_start_percent=conf.get("slg_start_percent", 0.0),
slg_end_percent=conf.get("slg_end_percent", 0.5),
width=w,
height=h
width=gen_w,
height=gen_h
)
# ==== UPSCALING (pipeline-based with sequential steps) ====
@@ -1838,6 +1953,7 @@ def run_generation_loop(
"actual_positive_prompt": actual_positive_prompt,
"actual_negative_prompt": actual_negative_prompt,
"gen_index": gen_index_offset + total_generated,
"config_fp": job_fp,
})
# Clear pipelines so the inline loop below becomes a no-op
upscale_pipelines = []
@@ -1977,6 +2093,8 @@ def run_generation_loop(
)
sentinel_meta.update({
"id": sentinel_id,
"base_fp": job_fp,
"config_fp": config_fingerprint(conf, w, h, conf["seed"], pipeline_name),
"upscaled": False,
"florence2_no_detection": True,
"florence2_text_input": step_result.get("florence2_text_input", ""),
@@ -2013,6 +2131,8 @@ def run_generation_loop(
)
upscaled_meta.update({
"id": upscale_id,
"base_fp": job_fp,
"config_fp": config_fingerprint(conf, w, h, conf["seed"], pipeline_name),
"file": f"/view?filename={upscaled_filename}&type=output&subfolder=benchmarks/{session_name}/images",
"filename": upscaled_filename,
"rejected": False,
@@ -2076,6 +2196,8 @@ def run_generation_loop(
upscaled_meta = dict(meta)
upscaled_meta.update({
"id": upscale_id,
"base_fp": job_fp,
"config_fp": config_fingerprint(conf, w, h, conf["seed"], pipeline_name),
"gen_index": len(existing_data["items"]),
"file": f"/view?filename={upscaled_filename}&type=output&subfolder=benchmarks/{session_name}/images",
"filename": upscaled_filename,
@@ -2240,6 +2362,8 @@ def run_generation_loop(
upscaled_meta["id"] = upscale_id
upscaled_meta["upscaled"] = True
upscaled_meta["upscale_pipeline"] = pipeline_name
upscaled_meta["base_fp"] = job_fp
upscaled_meta["config_fp"] = config_fingerprint(conf, w, h, conf["seed"], pipeline_name)
# Include HiRes prompt adjustment info in manifest
if hires_prompt_active:
@@ -2326,7 +2450,8 @@ def run_generation_loop(
total_duration = duration + total_upscale_duration
new_completed += 1
job_durations.append(total_duration)
eta_info = calculate_eta(job_durations, current_job, total_jobs)
# With the pre-flight scan, jobs that will be skipped later must not count as remaining work
eta_info = calculate_eta(job_durations, (existing_count + new_completed) if _pf_ran else current_job, total_jobs)
if eta_info:
print_generation_progress(current_job, total_jobs, conf, w, h, total_duration, eta_info)
# Compact single-line ETA summary — uses effective progress (existing + newly completed)
@@ -2373,10 +2498,17 @@ def run_generation_loop(
# Unless save_pre_upscale is enabled, in which case save both
if not upscale_produced or save_pre_upscale:
meta = create_image_metadata(
conf, w, h, total_duration, current_seed, batch_idx,
eff_conf, gen_w, gen_h, total_duration, current_seed, batch_idx,
actual_positive_prompt, actual_negative_prompt,
gen_index=gen_index_offset + total_generated
)
meta["config_fp"] = job_fp
if _is_pv:
meta.update({
"preview": True, "preview_of": job_fp,
"full_steps": conf["steps"], "full_cfg": conf["cfg"],
"full_width": w, "full_height": h, "preview_scale": pv_scale,
})
if pos_hash or neg_hash:
meta["conditioning_pos_hash"] = pos_hash
meta["conditioning_neg_hash"] = neg_hash
@@ -2430,7 +2562,10 @@ def run_generation_loop(
"vae_batch_size": vae_batch_size,
"configs_json": configs_json,
"resolutions_json": resolutions_json,
"image_format": image_format
"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", []))
}
save_manifest(paths["manifest"], existing_data)
@@ -2536,7 +2671,10 @@ def run_generation_loop(
"vae_batch_size": vae_batch_size,
"configs_json": configs_json,
"resolutions_json": resolutions_json,
"image_format": image_format
"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", []))
}
save_manifest(paths["manifest"], existing_data)
+75
View File
@@ -0,0 +1,75 @@
"""check_if_job_completed(preview=...) and tombstone collection."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import generation_orchestrator as go # preloaded by tests/conftest.py
from config_utils import collect_rejected_preview_fps
def _conf(**kw):
c = {"model": "m", "lora_expanded": "None", "sampler": "euler", "scheduler": "normal",
"steps": 30, "cfg": 7.0, "denoise": 1.0, "seed": 5}
c.update(kw)
return c
def _item(**kw):
i = {"seed": 5, "width": 1024, "height": 1024, "batch_idx": 0, "sampler": "euler",
"scheduler": "normal", "steps": 30, "cfg": 7.0, "denoise": 1.0, "model": "m",
"positive": "p", "negative": "n", "lora": "None"}
i.update(kw)
return i
def _preview_item(**kw):
return _item(preview=True, steps=8, width=512, height=512, full_width=1024, full_height=1024,
full_steps=30, full_cfg=7.0, **kw)
def _call(items, conf, w=1024, h=1024, **kw):
return go.check_if_job_completed(items, conf, conf["seed"], w, h, 0, "p", "n", **kw)
def test_full_job_matches_full_item_not_preview():
assert _call([_item()], _conf()) == 0
assert _call([_preview_item()], _conf()) == -1
def test_preview_job_does_not_match_full_item():
assert _call([_item()], _conf(steps=8), preview=True) == -1
def test_preview_job_matches_preview_item_on_full_dims_and_preview_steps():
items = [_preview_item()]
assert _call(items, _conf(steps=8), preview=True) == 0
assert _call(items, _conf(steps=12), preview=True) == -1 # different preview steps
assert _call(items, _conf(steps=8), w=832, preview=True) == -1 # different FULL width
assert _call(items, _conf(steps=8), w=512, h=512, preview=True) == -1 # snapped dims are not the key
def test_legacy_items_without_preview_key_count_as_full():
legacy = _item()
assert "preview" not in legacy
assert _call([legacy], _conf()) == 0
def test_positional_distributed_style_call_still_works():
conf = _conf()
assert go.check_if_job_completed([_item()], conf, 5, 1024, 1024, 0, "p", "n", False) == 0
assert go.check_if_job_completed([_item()], conf, 5, 1024, 1024, 0, "p", "n") == 0
assert go.check_if_job_completed([_item()], conf, 5, 1024, 1024, 0, "p", "n", True) == -1 # optional inputs
def test_preview_is_keyword_only():
import pytest
with pytest.raises(TypeError):
go.check_if_job_completed([_item()], _conf(), 5, 1024, 1024, 0, "p", "n", False, True)
def test_collect_rejected_preview_fps():
rej = [{"preview": True, "preview_of": "a"}, {"preview": True, "preview_of": "b"},
{"rejected": True, "config_fp": "full"}, {"preview": True, "preview_of": "a"}]
assert collect_rejected_preview_fps(rej, ["z", "b"]) == ["z", "b", "a"]
assert collect_rejected_preview_fps([], None) == []