Files
JasonHoku-ComfyUI-Ultimate-…/upscale_runner.py
T
Jason HokuandClaude Fable 5.1 e37e8e861c feat: Dashboard Upscale phase 2 (inline editing, cancel) + embed a ComfyUI nodes workflow in every image (Roadmap 93, 71)
Item 71: comfy_nodes_workflow.py is an exact Python port of the Revise view's "copy as nodes" graph
builder (pinned to a golden file generated from the JS, now in resources/logic_comfynodes.js) plus
lossless PNG tEXt / WebP EXIF writers in the format ComfyUI's frontend reads on drop. The Builder's new
Run Setting "Embed ComfyUI Nodes Workflow" (session_settings.pack_nodes_metadata) stamps every saved
image at every save site (generation, remote VAE, inline/deferred upscales, dashboard upscales, worker
images via a master pass after collection); export_favorites falls back to the Python builder.

Item 93: the upscale modal gets step/pipeline reordering, inline validation (mirrored server-side,
400 on bad configs), a preset bar (Update / Save as / Revert / Delete, read-modify-write), "Run once
(unsaved)", an output summary, separate Cancel and Close, a running lock and reattach after closing.
Cancel now lands within one sampler step through a per-job unet function wrapper on a cloned model
(never the process-wide interrupt flag), keeps completed outputs, and sends the final progress event.
Also fixed: the dashboard received the session's OLDEST items as "new" after an upscale (slice at the
wrong end), the poll targeted the wrong button, jobs were never pruned, two jobs could rewrite one
session's manifest (409 guard).

Known quirk recorded, not changed: a final pipeline step with several ratio/denoise values saves only
the last combination; the summary and a warning now say so.

Tests: 101 python, 59 node, 9 Playwright.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-04 19:01:49 -08:00

979 lines
42 KiB
Python

"""
Dashboard Upscale Runner
Background thread for upscaling images triggered from the dashboard.
Loads models via ComfyUI APIs, runs upscale pipeline chains,
saves results and updates manifests.
Cancellation (Roadmap 93): a job carries a threading.Event. It is checked at every
granularity the runner controls (per image, per pipeline, per expanded step, per combo,
before the SeedVR2 / Florence2 calls) and INSIDE sampling through a cloned model whose
unet-function wrapper raises UpscaleCancelled once the event is set. The process-wide
comfy.model_management interrupt flag is deliberately NOT used: it would also kill a
Generator run in the main queue and a leftover True would abort the next queued prompt.
SeedVR2 runs as an external node call, so it can only be cancelled between steps.
"""
import math
import os
import re
import time
import json
import threading
import uuid
import torch
import numpy as np
from PIL import Image as PILImage
from .model_loader import load_checkpoint, load_vae_by_name
from .batch_encoding import encode_prompt_with_combinators, encode_pair_cached
from .regional_prompt import append_to_base
from .image_generation import upscale_image, decode_latent_with_vae, create_image_metadata
from .manifest_utils import save_manifest, base_dims_of
from . import image_metrics
from . import comfy_nodes_workflow
import comfy.model_management as mm
# Active upscale jobs (module-level state)
_upscale_jobs = {}
_jobs_lock = threading.RLock()
ALREADY_RUNNING_MSG = "An upscale job is already running for this session"
_ACTIVE_STATES = ("running", "cancelling")
_VALID_MODES = ("hires_only", "model_only", "model_then_hires", "seedvr2", "florence2_hires")
class UpscaleCancelled(Exception):
"""Raised inside the upscale thread when the user cancels the job."""
def _check_cancel(job):
ev = job.get("cancel")
if ev is not None and ev.is_set():
raise UpscaleCancelled()
# =============================================================================
# CONFIG VALIDATION (pure-Python mirror of resources/logic_upscale_edit.js)
# =============================================================================
_NUM_RE = re.compile(r"^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$")
def _num(v):
"""Strict number: int/float or a plain decimal string; anything else (including 'x', '', None) -> None."""
if isinstance(v, bool) or v is None:
return None
if isinstance(v, (int, float)):
return float(v) if math.isfinite(v) else None
if isinstance(v, str):
s = v.strip()
return float(s) if _NUM_RE.match(s) else None
return None
def _num_list(v):
"""('1.5, 2' -> ([1.5, 2.0], bad)) ; empty entries are skipped like the runner does."""
parts = [p.strip() for p in str(v).split(",")]
parts = [p for p in parts if p]
vals, bad = [], False
for p in parts:
n = _num(p)
if n is None:
bad = True
else:
vals.append(n)
return parts, vals, bad
def _is_int(n):
return n is not None and n == int(n)
def _falsy(v):
return v is None or v is False or v == 0 or v == ""
def _validate_upscale(cfg):
"""-> (errors, warnings); each entry {"path", "field", "msg"}. Same rules as validateUpscaleConfig()."""
errors, warnings = [], []
def err(path, field, msg):
errors.append({"path": path, "field": field, "msg": msg})
def warn(path, field, msg):
warnings.append({"path": path, "field": field, "msg": msg})
if not isinstance(cfg, dict) or not isinstance(cfg.get("pipelines"), list):
err("", "pipelines", "Config has no pipelines")
return errors, warnings
any_active = False
for pi, pipe in enumerate(cfg["pipelines"]):
if not isinstance(pipe, dict) or pipe.get("active", True) is False:
continue
steps = pipe.get("steps")
steps = steps if isinstance(steps, list) else []
for si, step in enumerate(steps):
if not isinstance(step, dict) or step.get("active", True) is False:
continue
any_active = True
path = f"p{pi}.s{si}"
mode = step.get("mode") or "hires_only"
if mode not in _VALID_MODES:
err(path, "mode", f"Unknown mode '{mode}'")
continue
hires = mode in ("hires_only", "model_then_hires")
f2 = mode == "florence2_hires"
model = mode in ("model_only", "model_then_hires")
rep = step.get("repeat")
if _falsy(rep):
rep = 1
else:
rn = _num(rep)
if rn is not None and rn < 1:
rep = 1
rn = _num(rep)
if not _is_int(rn) or rn < 1 or rn > 20:
err(path, "repeat", "Repeat must be a whole number from 1 to 20")
if hires:
ratios = step.get("upscale_ratios")
ratios = "1.5" if _falsy(ratios) else ratios
parts, vals, bad = _num_list(ratios)
if not parts:
err(path, "upscale_ratios", "Enter at least one ratio, e.g. 1.5, 2")
elif bad:
err(path, "upscale_ratios", "Ratios must be numbers separated by commas")
elif any(v <= 0 or v > 8 for v in vals):
err(path, "upscale_ratios", "Each ratio must be above 0 and at most 8")
if hires or f2:
dn = step.get("hires_denoise")
dn = "0.3" if _falsy(dn) else dn
parts, vals, bad = _num_list(dn)
if not parts:
err(path, "hires_denoise", "Enter at least one denoise value, e.g. 0.3")
elif bad:
err(path, "hires_denoise", "Denoise must be numbers separated by commas")
elif any(v < 0 or v > 1 for v in vals):
err(path, "hires_denoise", "Each denoise value must be between 0 and 1")
elif f2 and len(vals) > 1:
warn(path, "hires_denoise", "Florence2 uses only the first denoise value")
hs = step.get("hires_steps", 0) if "hires_steps" in step else 0
hn = _num(hs)
if not _is_int(hn) or hn < 0 or hn > 150:
err(path, "hires_steps", "HiRes steps must be a whole number from 0 to 150")
if hires and step.get("hires_tiled_sampling"):
for key, label in (("hires_tile_width", "Tile width"), ("hires_tile_height", "Tile height")):
v = step.get(key)
v = 512 if _falsy(v) else v
n = _num(v)
if n is None or n < 64 or n % 8 != 0:
err(path, key, f"{label} must be a multiple of 8 and at least 64")
if hires and step.get("tiled_vae"):
v = step.get("tile_size")
v = 512 if _falsy(v) else v
n = _num(v)
if n is None or n < 128:
err(path, "tile_size", "VAE tile size must be at least 128")
if model:
models = step.get("upscale_models")
if not isinstance(models, list) or len([m for m in models if m]) == 0:
err(path, "upscale_models", "Select at least one upscale model")
if mode == "model_only":
v = step.get("upscale_size")
v = "2.0" if _falsy(v) else v
n = _num(v)
if n is None or n <= 0:
err(path, "upscale_size", "Size multiplier must be a number above 0")
if mode == "seedvr2":
sv = step.get("seedvr2")
if sv is not None and not isinstance(sv, dict):
sv = {}
if sv is not None:
if not str(sv.get("dit_model") or "").strip():
err(path, "dit_model", "Choose a SeedVR2 DiT model")
if sv.get("resolution") is not None:
n = _num(sv.get("resolution"))
if n is None or n < 16:
err(path, "resolution", "SeedVR2 resolution must be at least 16")
if f2:
fl = step.get("florence2")
if fl is not None and not isinstance(fl, dict):
fl = {}
if fl is not None and not str(fl.get("text_input") or "").strip():
err(path, "text_input", "Say what Florence2 should detect (e.g. face)")
if not any_active:
err("", "pipelines", "Turn on at least one pipeline that has an active step")
if cfg.get("hires_prompt_adjust") and not str(cfg.get("hires_prompt_text") or "").strip():
warn("", "hires_prompt_text", "Prompt adjustment is on but the HiRes prompt is empty")
return errors, warnings
def validate_upscale_config(cfg):
"""Server-side guard for POST /config_tester/upscale_images: a list of error strings (empty = ok)."""
errors, _warnings = _validate_upscale(cfg)
return [(f"{e['path']}: " if e["path"] else "") + e["msg"] for e in errors]
# =============================================================================
# JOB BOOKKEEPING
# =============================================================================
def _prune_jobs(max_age=3600, now=None):
"""Forget finished jobs older than max_age seconds (running jobs are never pruned)."""
now = time.time() if now is None else now
with _jobs_lock:
for jid, job in list(_upscale_jobs.items()):
if job.get("status") in _ACTIVE_STATES or job.get("status") == "running":
continue
fin = job.get("finished_at")
if fin is not None and now - fin > max_age:
del _upscale_jobs[jid]
def _is_active(job):
return job.get("status") == "running"
def start_upscale_job(session_name, image_ids, upscale_config, all_favorited=False):
"""
Start an async upscale job. Returns (job_id, error) immediately.
The actual work runs in a background thread. Only one job may run per session
(two jobs would both rewrite the same manifest).
"""
import folder_paths
_prune_jobs()
with _jobs_lock:
for other in _upscale_jobs.values():
if other.get("session_name") == session_name and _is_active(other):
return None, ALREADY_RUNNING_MSG
benchmarks_dir = os.path.realpath(os.path.join(folder_paths.get_output_directory(), "benchmarks"))
session_dir = os.path.join(benchmarks_dir, session_name)
manifest_path = os.path.join(session_dir, "manifest.json")
images_dir = os.path.join(session_dir, "images")
if not os.path.exists(manifest_path):
return None, "Manifest not found"
with open(manifest_path, "r", encoding="utf-8") as f:
manifest_data = json.load(f)
items = manifest_data.get("items", [])
meta = manifest_data.get("meta", {})
# Resolve image list
if all_favorited:
target_items = [item for item in items if item.get("favorited")]
else:
id_set = set(image_ids) if image_ids else set()
target_items = [item for item in items if item.get("id") in id_set]
if not target_items:
return None, "No matching images found"
job_id = f"upscale_{uuid.uuid4().hex[:12]}"
job = {
"id": job_id,
"session_name": session_name,
"status": "running",
"completed": 0,
"total": len(target_items),
"outputs": 0,
"current_image": "",
"current_step": "",
"error": None,
"cancel": threading.Event(),
"started_at": time.time(),
"finished_at": None,
}
with _jobs_lock:
for other in _upscale_jobs.values(): # re-check: the manifest read above is not under the lock
if other.get("session_name") == session_name and _is_active(other):
return None, ALREADY_RUNNING_MSG
_upscale_jobs[job_id] = job
thread = threading.Thread(
target=_run_upscale_thread,
args=(job, target_items, upscale_config, meta, manifest_data, manifest_path, images_dir, session_name),
daemon=True
)
thread.start()
return job_id, None
def _public_status(job):
status = job["status"]
if status == "running" and job["cancel"].is_set():
status = "cancelling"
return status
def get_upscale_status(job_id):
"""Get status of an upscale job."""
job = _upscale_jobs.get(job_id)
if not job:
return {"status": "not_found"}
return {
"status": _public_status(job),
"completed": job["completed"],
"total": job["total"],
"outputs": job.get("outputs", 0),
"current_image": job.get("current_image", ""),
"current_step": job.get("current_step", ""),
"error": job.get("error"),
}
def list_upscale_jobs(session_name=None):
"""Summaries of the known jobs (optionally for one session), newest first."""
with _jobs_lock:
jobs = [j for j in _upscale_jobs.values() if session_name in (None, "", j.get("session_name"))]
jobs.sort(key=lambda j: j.get("started_at") or 0, reverse=True)
return [{"job_id": j["id"], "status": _public_status(j), "completed": j["completed"], "total": j["total"]}
for j in jobs]
def cancel_upscale_job(job_id):
"""Signal an upscale job to stop. -> {"cancelled": bool, "status": str}. True only for a running job
(calling it again while it winds down is harmless)."""
job = _upscale_jobs.get(job_id)
if not job:
return {"cancelled": False, "status": "not_found"}
if job["status"] == "running":
job["cancel"].set()
return {"cancelled": True, "status": "cancelling"}
return {"cancelled": False, "status": job["status"]}
def _cancellable_model(model, cancel_event):
"""A clone of `model` whose unet function wrapper raises UpscaleCancelled once cancel_event is set.
The raise happens between sampler steps (every model call), so a cancel lands within one step.
An existing wrapper is chained, never replaced."""
m = model.clone()
prev = (getattr(m, "model_options", None) or {}).get("model_function_wrapper")
def _wrapper(apply_model, args):
if cancel_event.is_set():
raise UpscaleCancelled()
if prev is not None:
return prev(apply_model, args)
return apply_model(args["input"], args["timestep"], **args["c"])
m.set_model_unet_function_wrapper(_wrapper)
return m
def _clean_vram():
try:
mm.soft_empty_cache()
import gc
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
except Exception:
pass
def _drive_upscale_items(job, target_items, process_item, on_item_done=None, on_progress=None, on_finish=None):
"""Run process_item(item, produced) over target_items with the cancel / progress semantics.
produced is a list the callee appends every manifest entry it creates, so whatever an item
finished before a cancel (or an error) is kept. Cancel is honoured before each item and when
process_item raises UpscaleCancelled. Returns "complete" or "cancelled" (errors propagate after
the partial entries were handed to on_item_done). The driver owns job status / counters."""
total = len(target_items)
cancelled = False
for idx, item in enumerate(target_items):
if job["cancel"].is_set():
cancelled = True
break
produced = []
try:
process_item(item, produced)
except UpscaleCancelled:
cancelled = True
except Exception:
if produced:
job["outputs"] += len(produced)
if on_item_done:
on_item_done(item, produced)
raise
if produced:
job["outputs"] += len(produced)
if on_item_done:
on_item_done(item, produced)
if cancelled:
break
job["completed"] = idx + 1
if on_progress:
on_progress(job["completed"], total)
state = "cancelled" if cancelled else "complete"
if on_finish:
try:
on_finish(state)
except Exception as e:
print(f"[DashboardUpscale] ⚠️ final progress event failed: {e}")
job["finished_at"] = time.time()
job["status"] = state
return state
def _save_and_stamp(pil, path, fmt, meta, pack):
"""Write the image in the session's format and embed the nodes workflow (PNG/WEBP; never raises)."""
if fmt == "png":
pil.save(path, format="PNG")
elif fmt in ("jpg", "jpeg"):
pil.save(path, format="JPEG", quality=95)
else:
pil.save(path, format="WEBP", quality=95)
comfy_nodes_workflow.stamp_file(path, meta, enabled=pack)
def _send(PromptServer, event, payload):
if PromptServer is None:
return
try:
PromptServer.instance.send_sync(event, payload)
except Exception:
pass
def _run_upscale_thread(job, target_items, upscale_config, meta, manifest_data, manifest_path, images_dir, session_name):
"""
Background thread that runs the upscale pipeline on each image.
Loads the session's model/VAE/CLIP, encodes prompts, runs pipeline chains.
"""
try:
pipelines = upscale_config.get("pipelines", [])
if not pipelines:
job["status"] = "error"
job["error"] = "No upscale pipelines configured"
job["finished_at"] = time.time()
return
# Resolve image format from manifest meta (written by orchestrator)
_raw_fmt = meta.get("image_format", "webp")
_fmt = (_raw_fmt or "webp").lower().strip()
if _fmt not in ("webp", "png", "jpg", "jpeg"):
_fmt = "webp"
_ext = "jpg" if _fmt in ("jpg", "jpeg") else _fmt
# Load model, VAE, CLIP for conditioning
model_name = meta.get("model", "") or ""
vae_name = meta.get("vae", "")
# If meta.model is empty/missing (legacy manifests, manifests written before
# meta.model was recorded), fall back to the first non-empty item.model in
# the target set. Items always store their own model name.
if not model_name:
for _it in target_items:
_it_model = (_it.get("model", "") or "").strip()
if _it_model:
model_name = _it_model
print(f"[DashboardUpscale] meta.model is empty; using first item's model: {model_name}")
break
# Validate the checkpoint exists before calling load_checkpoint. Without
# this guard, a missing file ends up at comfy/utils.py:load_torch_file(None,
# ...) and crashes with the cryptic "'NoneType' object has no attribute
# 'lower'". Common cause: manifest copied from another machine where the
# checkpoint isn't installed; or manifest entry has model=None.
try:
import folder_paths as _fp_check
ckpt_path_check = _fp_check.get_full_path("checkpoints", model_name) if model_name else None
except Exception:
ckpt_path_check = None
if not model_name or ckpt_path_check is None:
job["status"] = "error"
job["error"] = (
f"Checkpoint '{model_name or '(empty)'}' not found in "
f"ComfyUI/models/checkpoints/. The session manifest references it but "
f"the file is not installed on this machine. Copy the .safetensors "
f"file over, or regenerate the images on this machine first."
)
job["finished_at"] = time.time()
print(f"[DashboardUpscale] ❌ {job['error']}")
return
# VRAM hygiene before load_checkpoint: the previous workflow's model handles
# may still be Python-referenced and won't be evicted by ComfyUI's LRU on a
# new load_checkpoint call. soft_empty_cache + gc.collect releases those
# before we add the upscale session's checkpoint on top. Critical for
# Florence2 mode on small-VRAM cards (otherwise prior SDXL gen + new SDXL
# session + Florence2 = OOM during beam search).
_clean_vram()
print(f"[DashboardUpscale] \U0001f504 Loading model: {model_name}")
loaded_model, loaded_clip, loaded_vae = load_checkpoint(
target_model_name=model_name,
ckpt_name=model_name, # Same as target since we're loading directly
use_remote_vae=False,
)
if vae_name and vae_name != "None" and vae_name != "Default":
print(f"[DashboardUpscale] \U0001f504 Loading VAE: {vae_name}")
loaded_vae = load_vae_by_name(vae_name)
# One cancellable clone for the whole job: every sampler step (hires, tiled hires) checks the event.
cancellable = _cancellable_model(loaded_model, job["cancel"])
# Get PromptServer for sending events
PromptServer = None
try:
import server as comfy_server
PromptServer = comfy_server.PromptServer
# Ensure last_prompt_id exists — KSampler progress callbacks require it
if PromptServer.instance is not None and not hasattr(PromptServer.instance, 'last_prompt_id'):
PromptServer.instance.last_prompt_id = f"dashboard_upscale_{job['id']}"
except Exception:
pass
ctx = {
"job": job, "upscale_config": upscale_config, "meta": meta, "manifest_data": manifest_data,
"images_dir": images_dir, "session_name": session_name, "fmt": _fmt, "ext": _ext,
"vae": loaded_vae, "model": cancellable, "clip": loaded_clip,
"conditioning_cache": {"positive": {}, "negative": {}},
"pack": bool((meta.get("session_settings") or {}).get("pack_nodes_metadata")),
}
started = time.time()
total = len(target_items)
def on_item_done(item, produced):
save_manifest(manifest_path, manifest_data)
# Send exactly the entries this item produced (they were inserted at index 0)
_send(PromptServer, "ultimate_grid.update_data", {
"session_name": session_name,
"new_items": list(produced),
})
def on_progress(done, total_):
_send(PromptServer, "ultimate_grid.progress", {
"session_name": session_name,
"current_job": done,
"total_jobs": total_,
"progress_pct": int((done / total_) * 100),
"eta_str": "",
"avg_duration": 0,
"last_duration": 0,
"phase": "upscaling",
})
print(f"[DashboardUpscale] \U0001f504 Upscaled {done}/{total_}: {job.get('current_image', '')}")
def on_finish(state):
done = job["completed"]
if state == "cancelled":
_send(PromptServer, "ultimate_grid.progress", {
"session_name": session_name,
"phase": "upscaling",
"state": "cancelled",
"current_job": done,
"total_jobs": total,
"progress_pct": int((done / total) * 100) if total else 0,
"eta_str": "",
"avg_duration": 0,
"last_duration": 0,
})
else:
elapsed = time.time() - started
_send(PromptServer, "ultimate_grid.progress", {
"session_name": session_name,
"phase": "upscaling",
"complete": True,
"total_generated": job.get("outputs", 0),
"total_elapsed": f"{int(elapsed)}s",
"avg_duration": round(elapsed / total, 1) if total else 0,
})
state = _drive_upscale_items(
job, target_items,
lambda item, produced: _upscale_one_item(ctx, item, produced),
on_item_done, on_progress, on_finish)
if state == "cancelled":
print(f"[DashboardUpscale] \U0001f6d1 Cancelled: {job['completed']}/{total} images done, "
f"{job.get('outputs', 0)} new images kept")
else:
print(f"[DashboardUpscale] ✅ Complete: {job['completed']}/{job['total']} images upscaled")
except Exception as e:
import traceback
traceback.print_exc()
job["status"] = "error"
job["error"] = str(e)
job["finished_at"] = time.time()
print(f"[DashboardUpscale] ❌ Error: {e}")
finally:
# Always attempt cleanup on any exit path
_clean_vram()
def _upscale_one_item(ctx, item, produced):
"""Upscale one manifest image through every active pipeline. Every manifest entry created is
inserted at index 0 of manifest_data["items"] AND appended to `produced`. Raises UpscaleCancelled."""
import itertools as up_itertools
import random as up_random
job = ctx["job"]
upscale_config = ctx["upscale_config"]
meta = ctx["meta"]
manifest_data = ctx["manifest_data"]
images_dir = ctx["images_dir"]
session_name = ctx["session_name"]
_fmt, _ext, pack = ctx["fmt"], ctx["ext"], ctx["pack"]
loaded_vae = ctx["vae"]
patched_model = ctx["model"]
patched_clip = ctx["clip"]
conditioning_cache = ctx["conditioning_cache"]
pipelines = upscale_config.get("pipelines", [])
# Never inherit the source's metrics; recompute them only when the source had
# them. This thread must not read generation-run state, so the spec is explicit.
item_metrics_spec = image_metrics.spec_from_item(item)
_check_cancel(job)
# Extract filename from manifest item — may be in "filename" or parsed from "file" URL
filename = item.get("filename", "")
if not filename and item.get("file"):
fname_match = re.search(r'filename=([^&]+)', item["file"])
if fname_match:
filename = fname_match.group(1)
job["current_image"] = filename
image_path = os.path.join(images_dir, filename)
if not os.path.exists(image_path):
print(f"[DashboardUpscale] ⚠️ File not found: {filename}, skipping")
return
# Load image and VAE-encode to latent
try:
pil_image = PILImage.open(image_path).convert("RGB")
img_array = np.array(pil_image).astype(np.float32) / 255.0
img_tensor = torch.from_numpy(img_array).unsqueeze(0)
encoded = loaded_vae.encode(img_tensor[:, :, :, :3])
result_latent = {"samples": encoded}
except Exception as e:
print(f"[DashboardUpscale] ⚠️ Failed to load/encode {filename}: {e}")
return
# Get conditioning
pos_prompt = item.get("positive", meta.get("positive", ""))
neg_prompt = item.get("negative", meta.get("negative", ""))
clip_skip = item.get("clip_skip", 0)
# One encoder load for the pair (ComfyUI re-prepares the TE on every encode otherwise)
final_positive, final_negative = encode_pair_cached(
patched_clip, conditioning_cache, pos_prompt, neg_prompt, clip_skip
)
# HiRes prompt adjustment
hires_positive_cond = final_positive
hires_prompt_active = False
hires_prompt_behavior_rt = ""
hires_prompt_text_rt = ""
if upscale_config.get("hires_prompt_adjust") and upscale_config.get("hires_prompt_text", "").strip():
hires_prompt_behavior_rt = upscale_config.get("hires_prompt_behavior", "append_end")
hires_prompt_text_rt = upscale_config["hires_prompt_text"].strip()
if hires_prompt_behavior_rt == "prepend":
adjusted = hires_prompt_text_rt + " " + pos_prompt
elif hires_prompt_behavior_rt == "append_end":
adjusted = append_to_base(pos_prompt, hires_prompt_text_rt)
elif hires_prompt_behavior_rt == "replace":
adjusted = hires_prompt_text_rt
else:
adjusted = pos_prompt
if adjusted not in conditioning_cache["positive"]:
conditioning_cache["positive"][adjusted] = encode_prompt_with_combinators(patched_clip, adjusted, clip_skip)
hires_positive_cond = conditioning_cache["positive"][adjusted]
hires_prompt_active = True
pipe_w = item.get("width", 512)
pipe_h = item.get("height", 512)
total_upscale_duration = 0
for pipeline_idx, pipeline in enumerate(pipelines):
_check_cancel(job)
if pipeline.get("active", True) is False:
continue
pipeline_name = pipeline.get("name", f"Pipeline {pipeline_idx + 1}")
pipeline_steps = pipeline.get("steps", [])
if not pipeline_steps:
continue
pipe_latent = result_latent
pipe_w_current = pipe_w
pipe_h_current = pipe_h
# Flatten steps with repeat
expanded_steps = []
for step in pipeline_steps:
if step.get("active", True) is False:
continue
repeat = max(1, int(step.get("repeat", 1)))
for _ in range(repeat):
expanded_steps.append(step)
for step_idx, ucfg in enumerate(expanded_steps):
_check_cancel(job)
mode = ucfg.get("mode", "hires_only")
show_hires = mode in ("hires_only", "model_then_hires")
show_model = mode in ("model_only", "model_then_hires")
step_label = f"{pipeline_name} · step {step_idx + 1}/{len(expanded_steps)}"
job["current_step"] = step_label
# --- SeedVR2 upscale mode ---
if mode == "seedvr2":
from .image_generation import seedvr2_upscale
sv_config = ucfg.get("seedvr2", {})
_check_cancel(job) # external node call: no way to interrupt it once started
# Convert source PIL image directly (no latent decoding needed)
pil_input = PILImage.open(os.path.join(images_dir, filename))
result_pil, up_w, up_h, sv_duration = seedvr2_upscale(pil_input, sv_config)
total_upscale_duration += sv_duration
is_last_step = step_idx == len(expanded_steps) - 1
if is_last_step:
# Create manifest entry, then save the upscaled image
upscale_id = int(time.time() * 100000) + up_random.randint(0, 1000)
upscaled_filename = f"img_{upscale_id}_upscaled.{_ext}"
_up_path = os.path.join(images_dir, upscaled_filename)
upscaled_meta = {
k: v for k, v in item.items()
if k not in ("id", "gen_index", "file", "filename", "upscaled", "width", "height", "duration",
"upscale_source", "upscale_pipeline", "upscale_mode",
"upscale_ratio", "upscale_denoise", "upscale_model", "metrics")
}
upscaled_meta.update({
"id": upscale_id,
"gen_index": len(manifest_data["items"]),
"file": f"/view?filename={upscaled_filename}&type=output&subfolder=benchmarks/{session_name}/images",
"filename": upscaled_filename,
"width": up_w, "height": up_h,
"duration": round(sv_duration, 2),
"upscaled": True,
**base_dims_of(item),
"upscale_source": "dashboard",
"upscale_pipeline": pipeline_name,
"upscale_mode": "seedvr2",
"upscale_model": sv_config.get("dit_model", ""),
})
_save_and_stamp(result_pil, _up_path, _fmt, upscaled_meta, pack)
image_metrics.finalize(upscaled_meta, result_pil, spec=item_metrics_spec)
manifest_data["items"].insert(0, upscaled_meta)
produced.append(upscaled_meta)
else:
# For chained steps, the next step gets the PIL result
pil_input = result_pil
pipe_w_current = up_w
pipe_h_current = up_h
continue # Skip the normal combo loop
# --- Florence2 Hi-Res Fix mode ---
if mode == "florence2_hires":
from .florence2_hires import (
run_florence2_step, preflight_florence2,
build_florence2_manifest_entry, build_florence2_no_detection_entry,
)
import numpy as _np
# Preflight once per job (cached)
if not job.get("_florence2_preflighted"):
preflight_florence2()
job["_florence2_preflighted"] = True
# Merge top-level step sampling fields into a unified config dict.
# hires_denoise on the step may be "0.45, 0.65" — Florence2 uses just one
# value per pass; take the first.
_hd = str(ucfg.get("hires_denoise", "0.45")).split(",")[0].strip() or "0.45"
f2_config = {
**(ucfg.get("florence2", {}) or {}),
"hires_denoise": float(_hd),
"hires_steps": int(ucfg.get("hires_steps", 15) or 15),
"cfg": float(ucfg.get("cfg", 1.5) or 1.5),
"sampler": ucfg.get("sampler", "euler"),
"scheduler": ucfg.get("scheduler", "simple"),
}
# Source image: load fresh from disk as tensor (1, H, W, 3)
src_pil = PILImage.open(os.path.join(images_dir, filename)).convert("RGB")
src_arr = _np.array(src_pil).astype(_np.float32) / 255.0
src_tensor = torch.from_numpy(src_arr).unsqueeze(0)
# Per-job checkpoint+lora cache (lazy init)
if "_florence2_ckpt_cache" not in job:
job["_florence2_ckpt_cache"] = {}
_check_cancel(job)
step_result = run_florence2_step(
source_image=src_tensor,
item=item,
step_config=f2_config,
fallback_handles=(patched_model, patched_clip, loaded_vae),
ckpt_cache=job["_florence2_ckpt_cache"],
conditioning_cache=conditioning_cache,
positive_prompt=pos_prompt,
negative_prompt=neg_prompt,
clip_skip=clip_skip,
# Pass the session's already-loaded model name so Florence2 can
# short-circuit duplicate loads when item.model matches it.
session_model_name=meta.get("model", ""),
# Sampling inside the step checks the job's cancel event between steps
model_wrap=lambda m: _cancellable_model(m, job["cancel"]),
)
is_last_step = step_idx == len(expanded_steps) - 1
if step_result.get("status") == "no_detection":
sentinel_id = int(time.time() * 100000) + up_random.randint(0, 1000)
entry = build_florence2_no_detection_entry(
step_result, item,
sentinel_id=sentinel_id,
current_index=len(manifest_data["items"]),
base_dims=base_dims_of(item),
)
manifest_data["items"].insert(0, entry)
produced.append(entry)
total_upscale_duration += float(step_result.get("duration", 0))
continue
total_upscale_duration += float(step_result.get("duration", 0))
if is_last_step:
upscale_id = int(time.time() * 100000) + up_random.randint(0, 1000)
upscaled_filename = f"img_{upscale_id}_upscaled.{_ext}"
_up_path = os.path.join(images_dir, upscaled_filename)
result_pil = step_result["image_pil"]
entry = build_florence2_manifest_entry(
step_result, item,
session_name=session_name,
pipeline_name=pipeline_name,
upscale_id=upscale_id,
upscaled_filename=upscaled_filename,
current_index=len(manifest_data["items"]),
hires_denoise=f2_config["hires_denoise"],
base_dims=base_dims_of(item),
)
_save_and_stamp(result_pil, _up_path, _fmt, entry, pack)
image_metrics.finalize(entry, result_pil, spec=item_metrics_spec)
manifest_data["items"].insert(0, entry)
produced.append(entry)
else:
pipe_w_current = step_result["image_width"]
pipe_h_current = step_result["image_height"]
continue # Skip the normal combo loop
raw_ratios = str(ucfg.get("upscale_ratios", "1.5"))
ratios = [float(r.strip()) for r in raw_ratios.split(",") if r.strip()] or [1.5]
raw_denoise = str(ucfg.get("hires_denoise", "0.3"))
denoises = [float(d.strip()) for d in raw_denoise.split(",") if d.strip()] or [0.3]
models = ucfg.get("upscale_models", []) or [""]
if show_hires and show_model:
combos = list(up_itertools.product(models, ratios, denoises))
elif show_hires:
combos = list(up_itertools.product([""], ratios, denoises))
elif show_model:
combos = list(up_itertools.product(models, [1.0], [0.0]))
else:
combos = []
for combo_idx, combo in enumerate(combos):
_check_cancel(job)
job["current_step"] = f"{step_label} · combo {combo_idx + 1}/{len(combos)}"
up_model, up_ratio, up_denoise = combo
single_config = {
"mode": mode, "upscale_model": up_model,
"upscale_ratio": up_ratio, "hires_denoise": up_denoise,
"hires_steps": ucfg.get("hires_steps", 0),
"tiled_vae": ucfg.get("tiled_vae", False),
"tile_size": ucfg.get("tile_size", 512),
"upscale_size": ucfg.get("upscale_size", "2.0"),
"resize_method": ucfg.get("resize_method", "bilinear"),
"hires_tiled_sampling": ucfg.get("hires_tiled_sampling", False),
"hires_tile_width": ucfg.get("hires_tile_width", 512),
"hires_tile_height": ucfg.get("hires_tile_height", 512),
"hires_mask_blur": ucfg.get("hires_mask_blur", 8),
"hires_tile_padding": ucfg.get("hires_tile_padding", 32),
"hires_force_uniform_tiles": ucfg.get("hires_force_uniform_tiles", False)
}
up_positive = hires_positive_cond if (show_hires and hires_prompt_active) else final_positive
upscale_result, upscale_duration = upscale_image(
pipe_latent, loaded_vae, patched_model, single_config,
item, up_positive, final_negative, pipe_w_current, pipe_h_current
)
is_last_step = step_idx == len(expanded_steps) - 1
is_last_combo = combo == combos[-1]
is_final_output = is_last_step and is_last_combo
up_metrics_sink = {}
up_metrics_img = None
if isinstance(upscale_result, dict) and "samples" in upscale_result:
up_metrics_img = upscaled_pil = decode_latent_with_vae(
loaded_vae, upscale_result["samples"],
metrics_sink=up_metrics_sink if (is_final_output and item_metrics_spec) else None,
metrics_spec=item_metrics_spec)
up_w, up_h = upscaled_pil.size
out_pil = upscaled_pil
elif isinstance(upscale_result, PILImage.Image):
up_metrics_img = upscale_result
up_w, up_h = upscale_result.size
out_pil = upscale_result
else:
continue
if not is_final_output:
if isinstance(upscale_result, dict) and "samples" in upscale_result:
pipe_latent = upscale_result
pipe_w_current = up_w
pipe_h_current = up_h
continue
upscale_id = int(time.time() * 100000) + up_random.randint(0, 1000)
upscaled_filename = f"img_{upscale_id}_upscaled.{_ext}"
_up_path = os.path.join(images_dir, upscaled_filename)
# Create manifest entry — copy source item fields, override with upscale info
upscaled_meta = {
k: v for k, v in item.items()
if k not in ("id", "gen_index", "file", "filename", "upscaled", "width", "height", "duration",
"upscale_source", "upscale_pipeline", "upscale_mode",
"upscale_ratio", "upscale_denoise", "upscale_model", "metrics")
}
# gen_index: place upscaled images at the end (newest) since they were just created
# Use current manifest length so each new upscale gets the next sequential position
upscale_gen_index = len(manifest_data["items"])
upscaled_meta.update({
"id": upscale_id,
"gen_index": upscale_gen_index,
"file": f"/view?filename={upscaled_filename}&type=output&subfolder=benchmarks/{session_name}/images",
"filename": upscaled_filename,
"width": up_w,
"height": up_h,
"duration": round(upscale_duration, 2),
"upscaled": True,
**base_dims_of(item),
"upscale_source": "dashboard",
"upscale_pipeline": pipeline_name,
"upscale_mode": mode,
"upscale_ratio": up_ratio,
"upscale_denoise": up_denoise,
})
if up_model:
upscaled_meta["upscale_model"] = up_model
if hires_prompt_active:
upscaled_meta["hires_prompt_behavior"] = hires_prompt_behavior_rt
upscaled_meta["hires_prompt_text"] = hires_prompt_text_rt
_save_and_stamp(out_pil, _up_path, _fmt, upscaled_meta, pack)
image_metrics.finalize(upscaled_meta, up_metrics_img, up_metrics_sink, spec=item_metrics_spec)
manifest_data["items"].insert(0, upscaled_meta)
produced.append(upscaled_meta)