The Angelo — Overrides node now carries an optional second model stack (gen_model / gen_positive / gen_negative / gen_vae connection inputs plus gen_steps / gen_cfg / gen_sampler_name / gen_scheduler settings). When wired, Sampler Mode generates the base with the gen stack, decodes with the gen VAE, and re-encodes the pixels with the main edit VAE so the whole editing session runs on the edit model — generate with FLUX Krea / SDXL for the look, click-to-edit with Klein / Qwen-Image-Edit. Behaviour: - Edit Mode never reads the gen bundle; unwired = byte-identical to 2.5 - Seed + Smpl Denoise stay on the toolbar (seed controls keep working; denoise < 1.0 = img2img with the gen model, incl. off a loaded image) - gen_negative optional: falls back to a zeroed gen_positive (ConditioningZeroOut maths) - Preview/history pixels are the gen VAE decode (not the edit-VAE round-trip); base pixels cropped to /16 for any edit VAE ratio - Cached-base / loaded-image Sampler runs derive the gen latent by encoding the base pixels with the gen VAE - guider/sampler/sigmas trio deliberately not applied to the gen model (a wired GUIDER wraps the edit model's conds) Hardening from the bug-check pass (2 inline + 3 agent-review findings): - gen widgets live in optional inputs, not required: ComfyUI validates required inputs server-side BEFORE Python defaults apply, which would have rejected every pre-2.6 API-format export of the Overrides node - explicit None checks in overrides parsing (gen_cfg 0.0 is falsy and an `or` fallback silently sampled at the default) - partial gen-bundle wiring (any subset incl. gen_negative-only) raises a clear ValueError in Sampler Mode instead of silently falling back - wired-latent guard: wrong-channel latents raise a clear not-in-the- gen-model's-latent-space error; 5D video latents into a 4D gen model get their singleton frame axis squeezed instead of crashing deep in the UNet - console prints the gen resolution each run (an empty latent authored for a 16x edit VAE silently means half-size through an 8x gen VAE — undetectable from the latent alone, so make it visible) Fingerprint stays edit-model-fixed so Edit Mode never spuriously resets; _STATE untouched until the whole gen chain succeeds (cancel/OOM safe). Also: example_workflows/Krea_plus_Klein-workflow.json (FLUX Krea 2 + turbo LoRA at 1504x1504 through the Wan 2.1 VAE, Klein 9B editing) and full README coverage (Quick start section, Overrides wiring rules, Krea 2 + Wan 2.1 VAE pairing note).
3686 lines
196 KiB
Python
3686 lines
196 KiB
Python
"""AngeloRefine — single-node click-to-refine sampler.
|
||
|
||
How it works:
|
||
- On first run (no clicks yet), the node decodes the incoming latent
|
||
via VAE and shows the image in its own preview area. The latent is
|
||
cached server-side keyed by node id.
|
||
- The JS frontend attaches click handling to that preview. When the
|
||
user clicks a region they want improved, the JS updates this node's
|
||
click_x / click_y / click_seq widgets and re-queues the workflow.
|
||
- On re-run, the node detects the new click_seq, builds a feathered
|
||
circular mask centred on (click_x, click_y) with radius click_radius,
|
||
re-noises the cached latent partially (`denoise`), and re-samples
|
||
`steps` steps with the mask applied. ComfyUI's noise_mask handling
|
||
re-stitches the original outside the mask at each step (the noise-
|
||
injection inpaint pattern), so the unclicked region stays preserved.
|
||
- The refined latent is cached for the next click, so successive clicks
|
||
keep refining the SAME working latent rather than starting over.
|
||
- Toggle the `reset` widget (or press the Reset button the JS adds) to
|
||
throw away the cache and start fresh from the incoming latent.
|
||
|
||
The cache is in-process state only — restart of ComfyUI clears it.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import hashlib
|
||
import json
|
||
import math
|
||
import os
|
||
import torch
|
||
|
||
import comfy.model_management
|
||
import comfy.sample
|
||
import comfy.samplers
|
||
import comfy.utils
|
||
import latent_preview
|
||
import folder_paths
|
||
import node_helpers
|
||
|
||
# Reuse PreviewImage's save machinery for the preview output.
|
||
import nodes as comfy_nodes
|
||
|
||
|
||
# Per-node state cache:
|
||
# unique_id -> {
|
||
# "history": list[tuple[Tensor, Tensor | None]], # stack of (latent, pixels) (oldest first, current = [-1])
|
||
# "click_seq": int, # last processed click_seq from JS
|
||
# "undo_seq": int, # last processed undo_seq from JS
|
||
# "redo_seq": int, # last processed redo_seq from JS
|
||
# "redo_stack": list, # entries Undo popped, awaiting Redo (cleared on new edit)
|
||
# "source_latent": Tensor, # session base latent, for the source_image output
|
||
# "source_pixels": Tensor|None,# decoded source base (lazy, cached)
|
||
# "fingerprint": str, # hash of incoming latent; mismatch = upstream changed
|
||
# }
|
||
_STATE: dict[str, dict] = {}
|
||
|
||
# ---- Custom-sampler helpers (#8) -----------------------------------------
|
||
# These three helpers + the _do_sample wrapper below let users wire a
|
||
# GUIDER + SAMPLER + SIGMAS bundle through Angelo — Overrides into Angelo
|
||
# in place of the toolbar's steps / cfg / sampler_name / scheduler. The
|
||
# helpers themselves are taken verbatim from @KursatAs's customSampler
|
||
# branch (https://github.com/KursatAs/ComfyUI-Angelo/tree/customSampler);
|
||
# the integration shape (optional via Overrides rather than required on
|
||
# the main node) is the Angelo adaptation. Full credit to him for the
|
||
# NAG-Extended fix encoded in _guider_sample.
|
||
|
||
def _guider_sample(
|
||
temp_g,
|
||
noise: torch.Tensor,
|
||
latent: torch.Tensor,
|
||
sampler,
|
||
sigmas: torch.Tensor,
|
||
*,
|
||
denoise_mask: torch.Tensor | None = None,
|
||
callback=None,
|
||
disable_pbar: bool = False,
|
||
seed: int | None = None,
|
||
) -> torch.Tensor:
|
||
"""Device-safe wrapper around guider.sample(). (From @KursatAs.)
|
||
|
||
ComfyUI's built-in CFGGuider.sample() moves noise, latent, and
|
||
denoise_mask to the model's load device before sampling. Some
|
||
third-party extensions (e.g. ComfyUI-NAG-Extended) override
|
||
inner_sample() without repeating that movement, so CPU tensors
|
||
survive into the k-sampler's inpaint path where they collide with
|
||
GPU tensors and raise a device-mismatch RuntimeError. Moving
|
||
everything to load_device here is a safe no-op when the built-in
|
||
already does it, and fixes the crash for extensions that don't."""
|
||
device = temp_g.model_patcher.load_device
|
||
noise = noise.to(device)
|
||
latent = latent.to(device)
|
||
if denoise_mask is not None:
|
||
denoise_mask = denoise_mask.to(device)
|
||
samples = temp_g.sample(
|
||
noise, latent, sampler, sigmas,
|
||
denoise_mask=denoise_mask,
|
||
callback=callback,
|
||
disable_pbar=disable_pbar,
|
||
seed=seed,
|
||
)
|
||
# Mirror comfy.sample.sample()'s exit move so the rest of Angelo's
|
||
# pipeline (VAE decode, pixel composite, latent blend in Fine Upscale)
|
||
# sees the same intermediate device + dtype it does on the default
|
||
# path. Without this, the guider path returns the sampled latent on
|
||
# the model's load_device (cuda) while cached_pixels / mask are on
|
||
# intermediate_device (typically CPU), causing a device-mismatch at
|
||
# the composite step in _refine_with_fine_upscaling.
|
||
return samples.to(
|
||
device=comfy.model_management.intermediate_device(),
|
||
dtype=comfy.model_management.intermediate_dtype(),
|
||
)
|
||
|
||
|
||
def _guider_with_conds(guider, positive, negative):
|
||
"""Copy a wired GUIDER and apply Angelo's per-call positive/negative
|
||
conds to it. Handles both CFGGuider (takes both conds) and
|
||
BasicGuider (positive only). (From @KursatAs.) Lets the user wire
|
||
a generic guider once and Angelo keeps using its dynamic conds
|
||
(Refine vs Area Prompt vs Smart Inpaint reference_latents etc.)
|
||
for each sample call."""
|
||
g = copy.copy(guider)
|
||
try:
|
||
g.set_conds(positive, negative) # comfy.samplers.CFGGuider
|
||
except TypeError:
|
||
g.set_conds(positive) # BasicGuider / BaseGuider
|
||
return g
|
||
|
||
|
||
def _truncate_sigmas_for_denoise(sigmas: torch.Tensor, denoise: float) -> torch.Tensor:
|
||
"""Tail-slice the wired SIGMAS tensor by Angelo's per-call denoise,
|
||
matching ComfyUI's SplitSigmasDenoise convention. (From @KursatAs.)
|
||
A wired sigmas tensor comes pre-baked from a scheduler node at
|
||
denoise=1.0; Angelo applies its own refine denoise here so the
|
||
refine slider keeps meaning what it did before."""
|
||
if denoise >= 1.0:
|
||
return sigmas
|
||
if denoise <= 0.0:
|
||
return sigmas[-1:].new_zeros(2)
|
||
n_total = len(sigmas) - 1
|
||
n_refine = max(1, round(n_total * denoise))
|
||
return sigmas[-(n_refine + 1):]
|
||
|
||
|
||
def _do_sample(
|
||
*,
|
||
guider,
|
||
sampler,
|
||
sigmas,
|
||
model,
|
||
noise: torch.Tensor,
|
||
steps: int,
|
||
cfg: float,
|
||
sampler_name: str,
|
||
scheduler: str,
|
||
positive,
|
||
negative,
|
||
source_latent: torch.Tensor,
|
||
denoise: float,
|
||
callback,
|
||
disable_pbar: bool,
|
||
seed: int,
|
||
noise_mask: torch.Tensor | None = None,
|
||
) -> torch.Tensor:
|
||
"""Single sample dispatch for every Angelo sample call. If a custom
|
||
guider + sampler + sigmas trio is wired through Overrides, take the
|
||
custom path (via _guider_sample); otherwise fall through to the
|
||
standard comfy.sample.sample(...) path that's existed since v1.0.
|
||
|
||
All-or-nothing on the trio: partial wiring (e.g. sampler without
|
||
sigmas) silently falls through to the default path. Users see the
|
||
custom-sampler kwargs only by wiring the full bundle from a proper
|
||
GUIDER + SAMPLER + SIGMAS chain in their workflow."""
|
||
if guider is not None and sampler is not None and sigmas is not None:
|
||
g = _guider_with_conds(guider, positive, negative)
|
||
s = _truncate_sigmas_for_denoise(sigmas, denoise)
|
||
return _guider_sample(
|
||
g, noise, source_latent, sampler, s,
|
||
denoise_mask=noise_mask,
|
||
callback=callback,
|
||
disable_pbar=disable_pbar,
|
||
seed=seed,
|
||
)
|
||
return comfy.sample.sample(
|
||
model, noise, steps, cfg, sampler_name, scheduler,
|
||
positive, negative, source_latent,
|
||
denoise=denoise,
|
||
noise_mask=noise_mask,
|
||
callback=callback,
|
||
disable_pbar=disable_pbar,
|
||
seed=seed,
|
||
)
|
||
|
||
|
||
def _zero_out_conditioning(cond):
|
||
"""Zeroed-out copy of a conditioning list (same maths as ComfyUI's
|
||
ConditioningZeroOut node). Used as the gen bundle's negative when
|
||
gen_negative isn't wired — the main `negative` input can't stand in
|
||
because it's encoded with the EDIT model's CLIP and conditioning is
|
||
not portable across model families."""
|
||
out = []
|
||
for t, d in cond:
|
||
d = d.copy()
|
||
pooled = d.get("pooled_output")
|
||
if pooled is not None:
|
||
d["pooled_output"] = torch.zeros_like(pooled)
|
||
out.append([torch.zeros_like(t), d])
|
||
return out
|
||
|
||
|
||
# Max number of latents to keep in the undo stack per node. Each FLUX 2
|
||
# latent at 832x1776 is ~180 KB (bf16); 10 = ~1.8 MB per node. Cheap.
|
||
_HISTORY_CAP: int = 10
|
||
|
||
# Valid resize methods for the Fine Upscaling crop. All routed through
|
||
# comfy.utils.common_upscale which accepts both 4D image tensors and 4D
|
||
# latent tensors. lanczos is image-quality; bislerp is latent-aware.
|
||
# Default nearest-exact preserves exact sample values (good for
|
||
# latents); for the pixel-space path lanczos / bicubic typically look
|
||
# better. The user picks one for both ops in the Fine Upscale flow.
|
||
_FINE_UPSCALE_RESIZE_METHODS = ["nearest-exact", "bilinear", "area", "bicubic", "bislerp", "lanczos"]
|
||
|
||
# Smart Guided Inpaint: maps a location dropdown LABEL (set by the JS
|
||
# location selector) to a natural-language prefix that's prepended to
|
||
# the Area Prompt text before CLIP encoding. The labels here are the
|
||
# single source of truth — the JS dropdown lists exactly these keys.
|
||
# Order is preserved (py3.7+ dicts) so the JS can build its dropdown
|
||
# from the same list via the widget's tooltip / a mirrored array.
|
||
_GUIDED_LOCATION_PREFIXES = {
|
||
"(none)": "",
|
||
"Whole image": "Across the whole image, ",
|
||
"Top left": "In the top left of the image, ",
|
||
"Top middle": "In the top middle of the image, ",
|
||
"Top right": "In the top right of the image, ",
|
||
"Middle left": "On the left middle of the image, ",
|
||
"Center": "In the center of the image, ",
|
||
"Middle right": "On the right middle of the image, ",
|
||
"Bottom left": "In the bottom left of the image, ",
|
||
"Bottom middle": "In the bottom middle of the image, ",
|
||
"Bottom right": "In the bottom right of the image, ",
|
||
"Left edge": "Along the left edge of the image, ",
|
||
"Right edge": "Along the right edge of the image, ",
|
||
"Top edge": "Along the top edge of the image, ",
|
||
"Bottom edge": "Along the bottom edge of the image, ",
|
||
"Top half": "In the top half of the image, ",
|
||
"Bottom half": "In the bottom half of the image, ",
|
||
"Left half": "In the left half of the image, ",
|
||
"Right half": "In the right half of the image, ",
|
||
"Top of the image": "At the top of the image, ",
|
||
"Bottom of the image": "At the bottom of the image, ",
|
||
}
|
||
|
||
|
||
def _latent_fingerprint(latent: torch.Tensor) -> str:
|
||
"""Quick non-cryptographic fingerprint of an incoming latent.
|
||
|
||
Used to detect when the upstream KSampler has produced a fresh
|
||
latent (e.g. user changed the prompt + re-queued), so we can
|
||
automatically reset the cache instead of layering refinements
|
||
onto a now-irrelevant base.
|
||
"""
|
||
flat = latent.detach().to(torch.float32).flatten()
|
||
n = min(flat.numel(), 1024)
|
||
sample = flat[::max(1, flat.numel() // n)][:n]
|
||
h = hashlib.sha1()
|
||
h.update(str(tuple(latent.shape)).encode())
|
||
h.update(sample.cpu().numpy().tobytes())
|
||
return h.hexdigest()
|
||
|
||
|
||
def _parse_stroke_points(raw: str) -> list[tuple[float, float]]:
|
||
"""Parse the JS-set stroke_points widget. Empty / malformed → []."""
|
||
raw = (raw or "").strip()
|
||
if not raw:
|
||
return []
|
||
try:
|
||
data = json.loads(raw)
|
||
except Exception:
|
||
return []
|
||
out = []
|
||
if not isinstance(data, list):
|
||
return []
|
||
for item in data:
|
||
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
||
try:
|
||
out.append((float(item[0]), float(item[1])))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
return out
|
||
|
||
|
||
def _stroke_mask_latent(
|
||
latent_h: int,
|
||
latent_w: int,
|
||
stroke_points_pixel: list[tuple[float, float]],
|
||
r_latent: float,
|
||
scale_x: float,
|
||
scale_y: float,
|
||
device: torch.device,
|
||
) -> torch.Tensor:
|
||
"""Vectorised union of circles in latent space, one circle per
|
||
point in stroke_points_pixel. Points come in image-pixel coords;
|
||
we scale them to latent space here using the per-axis ratios.
|
||
|
||
Memory: one [N_points, H, W] float tensor briefly. For N=200, a
|
||
typical FLUX 2 latent (~52x111), that's about 4 MB peak — fine.
|
||
"""
|
||
if not stroke_points_pixel:
|
||
return torch.zeros((1, latent_h, latent_w), device=device, dtype=torch.float32)
|
||
|
||
pts = torch.tensor(stroke_points_pixel, device=device, dtype=torch.float32)
|
||
cx = pts[:, 0] * scale_x # [N]
|
||
cy = pts[:, 1] * scale_y # [N]
|
||
|
||
ys = torch.arange(latent_h, device=device, dtype=torch.float32).view(1, -1, 1)
|
||
xs = torch.arange(latent_w, device=device, dtype=torch.float32).view(1, 1, -1)
|
||
cxv = cx.view(-1, 1, 1)
|
||
cyv = cy.view(-1, 1, 1)
|
||
dist2 = (xs - cxv) ** 2 + (ys - cyv) ** 2
|
||
|
||
circles = (dist2 <= r_latent * r_latent).to(torch.float32) # [N, H, W]
|
||
mask = circles.amax(dim=0) # union via max
|
||
return mask.unsqueeze(0) # [1, H, W]
|
||
|
||
|
||
def _parse_rect_points(raw: str) -> tuple[float, float, float, float] | None:
|
||
"""Parse the JS-set rect_points widget — JSON list of one or more
|
||
[x1, y1, x2, y2] entries in image-pixel coords. We use only the
|
||
LAST rectangle (the most recent drag); earlier entries are kept
|
||
in the widget history for the undo stack to consume but don't
|
||
affect mask building. Returns None for empty / malformed input.
|
||
"""
|
||
raw = (raw or "").strip()
|
||
if not raw:
|
||
return None
|
||
try:
|
||
data = json.loads(raw)
|
||
except Exception:
|
||
return None
|
||
if not isinstance(data, list) or not data:
|
||
return None
|
||
last = data[-1]
|
||
if not isinstance(last, (list, tuple)) or len(last) < 4:
|
||
return None
|
||
try:
|
||
return (float(last[0]), float(last[1]), float(last[2]), float(last[3]))
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _rect_mask_latent(
|
||
latent_h: int,
|
||
latent_w: int,
|
||
rect_pixel: tuple[float, float, float, float],
|
||
scale_x: float,
|
||
scale_y: float,
|
||
device: torch.device,
|
||
) -> torch.Tensor:
|
||
"""Build a [1, H, W] filled-rectangle mask in latent space.
|
||
|
||
rect_pixel is (x1, y1, x2, y2) in image-pixel coords; corners
|
||
aren't required to be ordered (the user may drag in any
|
||
direction). Output is clamped to the latent bounds.
|
||
"""
|
||
x1, y1, x2, y2 = rect_pixel
|
||
x_lo_p, x_hi_p = min(x1, x2), max(x1, x2)
|
||
y_lo_p, y_hi_p = min(y1, y2), max(y1, y2)
|
||
|
||
xlat_lo = max(0, min(latent_w, int(round(x_lo_p * scale_x))))
|
||
xlat_hi = max(0, min(latent_w, int(round(x_hi_p * scale_x))))
|
||
ylat_lo = max(0, min(latent_h, int(round(y_lo_p * scale_y))))
|
||
ylat_hi = max(0, min(latent_h, int(round(y_hi_p * scale_y))))
|
||
|
||
mask = torch.zeros((1, latent_h, latent_w), device=device, dtype=torch.float32)
|
||
if xlat_hi > xlat_lo and ylat_hi > ylat_lo:
|
||
mask[0, ylat_lo:ylat_hi, xlat_lo:xlat_hi] = 1.0
|
||
return mask
|
||
|
||
|
||
def _parse_seg_polygons(raw: str):
|
||
"""Parse the seg_polygon widget — a JSON list of polygons, each a flat
|
||
[x0,y0,x1,y1,...] coord list in image-pixel space (a SAM 3 / YOLO
|
||
detection's silhouette). Returns the list, or None if empty/invalid."""
|
||
if not raw or not str(raw).strip():
|
||
return None
|
||
try:
|
||
polys = json.loads(raw)
|
||
except (ValueError, TypeError):
|
||
return None
|
||
if not isinstance(polys, list) or not polys:
|
||
return None
|
||
return polys
|
||
|
||
|
||
def _polygons_mask_latent(
|
||
latent_h: int,
|
||
latent_w: int,
|
||
polygons_pixel,
|
||
scale_x: float,
|
||
scale_y: float,
|
||
device: torch.device,
|
||
) -> torch.Tensor:
|
||
"""Rasterise one or more silhouette polygons (image-pixel coords) into
|
||
a filled [1, H, W] latent-space mask (their union)."""
|
||
import numpy as np
|
||
from PIL import Image, ImageDraw
|
||
|
||
img = Image.new("L", (latent_w, latent_h), 0)
|
||
draw = ImageDraw.Draw(img)
|
||
for poly in (polygons_pixel or []):
|
||
if not poly or len(poly) < 6:
|
||
continue
|
||
pts = [
|
||
(float(poly[i]) * scale_x, float(poly[i + 1]) * scale_y)
|
||
for i in range(0, len(poly) - 1, 2)
|
||
]
|
||
draw.polygon(pts, fill=255)
|
||
arr = np.array(img, dtype=np.float32) / 255.0
|
||
return torch.from_numpy(arr)[None, ...].to(device)
|
||
|
||
|
||
def _raster_mask_latent(latent_h, latent_w, png_b64, device):
|
||
"""Decode a base64-PNG touch-up mask (image-pixel resolution, white =
|
||
masked) into a [1, H, W] latent-space mask. The Detect Shift/Alt brush
|
||
produces this — a raster handles brushed holes / unions that a polygon
|
||
silhouette can't. Resized straight to the latent grid (no scale args)."""
|
||
import base64
|
||
import io
|
||
import numpy as np
|
||
from PIL import Image
|
||
|
||
raw = base64.b64decode(png_b64)
|
||
img = Image.open(io.BytesIO(raw)).convert("L")
|
||
img = img.resize((latent_w, latent_h), Image.BILINEAR)
|
||
arr = np.array(img, dtype=np.float32) / 255.0
|
||
return torch.from_numpy(arr)[None, ...].to(device)
|
||
|
||
|
||
def _mask_bbox_latent(mask: torch.Tensor) -> tuple[int, int, int, int] | None:
|
||
"""Tight latent-space bbox of non-zero mask values. Returns
|
||
(y_min, y_max, x_min, x_max) or None if the mask is empty.
|
||
|
||
Threshold of 0.01 includes the feathered edge — bbox covers the
|
||
full soft-edge region not just the binary interior.
|
||
"""
|
||
m = mask[0] if mask.dim() == 3 else mask
|
||
nz = m > 0.01
|
||
if not nz.any():
|
||
return None
|
||
rows = nz.any(dim=1)
|
||
cols = nz.any(dim=0)
|
||
ridx = rows.nonzero(as_tuple=False).squeeze(-1)
|
||
cidx = cols.nonzero(as_tuple=False).squeeze(-1)
|
||
return (
|
||
int(ridx[0].item()),
|
||
int(ridx[-1].item()) + 1,
|
||
int(cidx[0].item()),
|
||
int(cidx[-1].item()) + 1,
|
||
)
|
||
|
||
|
||
def _fine_upscale_factor(
|
||
bbox_w_latent: int,
|
||
bbox_h_latent: int,
|
||
scale_x: float,
|
||
scale_y: float,
|
||
target_mp: float,
|
||
max_linear: float,
|
||
) -> float:
|
||
"""Linear scale factor to apply to the cropped latent so that the
|
||
crop is processed at ≥ target_mp (in image-pixel-equivalent terms),
|
||
clamped to max_linear. Returns 1.0 when the crop already meets
|
||
target — no upscale needed."""
|
||
if scale_x <= 0 or scale_y <= 0:
|
||
return 1.0
|
||
bbox_w_pix = bbox_w_latent / scale_x
|
||
bbox_h_pix = bbox_h_latent / scale_y
|
||
current_mp = bbox_w_pix * bbox_h_pix / 1_000_000.0
|
||
if current_mp <= 0 or current_mp >= target_mp:
|
||
return 1.0
|
||
needed = math.sqrt(target_mp / current_mp)
|
||
return min(needed, max_linear)
|
||
|
||
|
||
def _resize_latent(t: torch.Tensor, target_h: int, target_w: int, method: str) -> torch.Tensor:
|
||
"""Resize the spatial dims of a latent or mask tensor using one of
|
||
ComfyUI's standard latent-resize methods. Accepts [C,H,W], [B,C,H,W],
|
||
or [1,H,W] (mask). Returns the same rank as input.
|
||
|
||
`method` is one of _FINE_UPSCALE_RESIZE_METHODS. Routes through
|
||
comfy.utils.common_upscale so bislerp + lanczos custom paths work."""
|
||
method = method if method in _FINE_UPSCALE_RESIZE_METHODS else "nearest-exact"
|
||
if t.dim() == 3:
|
||
t4 = t.unsqueeze(0)
|
||
out = comfy.utils.common_upscale(t4, target_w, target_h, method, "disabled")
|
||
return out.squeeze(0)
|
||
if t.dim() == 4:
|
||
return comfy.utils.common_upscale(t, target_w, target_h, method, "disabled")
|
||
raise ValueError(f"_resize_latent: unexpected ndim {t.dim()}")
|
||
|
||
|
||
# ----- VAE boundary -----
|
||
# Every latent->pixel and pixel->latent conversion in the node routes
|
||
# through these two functions. Centralising them means model-family
|
||
# quirks live in ONE place instead of at ~7 scattered call sites. The
|
||
# motivating case is Qwen Image Edit / Wan-derived VAEs, whose latents
|
||
# carry an extra temporal axis ([B, C, T, H, W] with T=1) that the rest
|
||
# of the node — and PIL's previewer — expect to be absent ([B, C, H, W]).
|
||
# Both helpers are thin pass-throughs today; this is the seam where that
|
||
# 5D normalisation will land without disturbing any caller.
|
||
# Above this output megapixel count, route VAE encode/decode through the
|
||
# TILED path. A single whole-canvas VAE pass at very large sizes (e.g. a
|
||
# 7744x2176 = 16.8MP canvas) can OOM or numerically overflow to NaN — which
|
||
# is silent (no exception), poisons everything downstream, and shows up as a
|
||
# black image. Tiling keeps each VAE pass small. Below the threshold we use
|
||
# the plain (exact, faster) path; ~1.4k tiles and normal canvases stay plain.
|
||
_VAE_TILE_MP = 4.0
|
||
|
||
|
||
def _vae_decode(vae, latent: torch.Tensor) -> torch.Tensor:
|
||
"""Decode a latent to pixels. Single decode chokepoint — see the
|
||
VAE-boundary note above. Always returns a 4D image batch
|
||
(B, H, W, C) float in [0, 1].
|
||
|
||
Large canvases decode TILED (see _VAE_TILE_MP) so a single huge VAE pass
|
||
can't OOM/overflow to NaN and blacken the output.
|
||
|
||
Temporal/video VAEs (Qwen Image Edit, Wan) keep a frame axis: their
|
||
latents are 5D ([B, C, T, H, W]) and `vae.decode` accordingly returns
|
||
a 5D frame stack ([B, T, H, W, C] — ComfyUI moves channels last). The
|
||
rest of the node, and ComfyUI's PreviewImage/PIL path, only understand
|
||
4D image batches, so fold the frame axis into the batch dim. For image
|
||
editing T is 1, so this is just dropping the singleton frame axis; if a
|
||
future model ever produces T>1 the frames surface as extra batch items
|
||
rather than crashing. The latent is passed through to `vae.decode`
|
||
untouched — the video VAE wants its native 5D input — we only normalise
|
||
the *pixels* it returns."""
|
||
try:
|
||
# Ask the VAE for its true spatial ratio — hardcoding 8 under-
|
||
# estimated the output 4× on 16×-per-axis VAEs (FLUX 2, the node's
|
||
# main target), so tiling only engaged ~4× past the danger point.
|
||
try:
|
||
ratio = int(vae.spacial_compression_decode())
|
||
except Exception:
|
||
ratio = int(getattr(vae, "downscale_ratio", 8) or 8)
|
||
out_mp = (latent.shape[-2] * ratio) * (latent.shape[-1] * ratio) / 1e6
|
||
except Exception:
|
||
out_mp = 0.0
|
||
image = vae.decode_tiled(latent) if out_mp > _VAE_TILE_MP else vae.decode(latent)
|
||
if image.ndim == 5:
|
||
b, t, h, w, c = image.shape
|
||
image = image.reshape(b * t, h, w, c)
|
||
return image
|
||
|
||
|
||
def _vae_encode(vae, pixels: torch.Tensor) -> torch.Tensor:
|
||
"""Encode pixels to latent samples. Single encode chokepoint —
|
||
counterpart to _vae_decode. See the VAE-boundary note above.
|
||
|
||
Deliberately returns the VAE's native latent shape WITHOUT collapsing
|
||
it: a temporal/video VAE (Qwen, Wan) returns a 5D latent
|
||
([B, C, T, H, W]) and the sampler + model require that 5D shape to flow
|
||
through unchanged (comfy.sample.sample is ndim-agnostic and prepare_noise
|
||
matches the latent's shape exactly). Squeezing the frame axis here would
|
||
break Qwen sampling — do not add a squeeze.
|
||
|
||
Large canvases encode TILED (see _VAE_TILE_MP) so a single huge VAE pass
|
||
can't OOM/overflow to NaN (which would silently poison the latent)."""
|
||
try:
|
||
in_mp = pixels.shape[1] * pixels.shape[2] / 1e6
|
||
except Exception:
|
||
in_mp = 0.0
|
||
if in_mp > _VAE_TILE_MP:
|
||
return vae.encode_tiled(pixels)
|
||
return vae.encode(pixels)
|
||
|
||
|
||
def _grow_mask_latent(mask: torch.Tensor, frac: float) -> torch.Tensor:
|
||
"""Dilate a latent-space mask outward by `frac` of its bbox extent per side
|
||
(a maxpool dilation). Used by the Remove brush so the erase fully covers the
|
||
object plus its immediate halo. `mask`: [1, H, W] in [0, 1]. Returns the
|
||
same shape."""
|
||
import torch.nn.functional as F
|
||
bbox = _mask_bbox_latent(mask)
|
||
if bbox is None or frac <= 0:
|
||
return mask
|
||
y0, y1, x0, x1 = bbox
|
||
mean_ext = 0.5 * ((y1 - y0) + (x1 - x0))
|
||
grow = int(round(frac * mean_ext))
|
||
if grow < 1:
|
||
return mask
|
||
k = 2 * grow + 1
|
||
m = mask
|
||
while m.dim() < 4:
|
||
m = m.unsqueeze(0) # [1,H,W] -> [1,1,H,W]
|
||
grown = F.max_pool2d(m, k, stride=1, padding=grow)
|
||
return grown.view(mask.shape).clamp(0.0, 1.0)
|
||
|
||
|
||
def _refine_with_fine_upscaling(
|
||
*,
|
||
model,
|
||
vae,
|
||
current: torch.Tensor, # [B, C, H_lat, W_lat] cached full-res latent
|
||
current_pixels: torch.Tensor | None, # [B, H_pix, W_pix, C] cached full-res pixels to avoid redundant VAE decode
|
||
mask: torch.Tensor, # [1, H_lat, W_lat] feathered mask, latent res
|
||
scale_x: float,
|
||
scale_y: float,
|
||
target_mp: float,
|
||
max_linear: float,
|
||
resize_method: str,
|
||
context_pad_pixel: int,
|
||
inpainting_mode: str,
|
||
reference_strength: float = 0.0, # Refine + Ref value: anchor on the
|
||
# (pre-refine) crop for this fraction
|
||
# of the schedule (_apply_reference) —
|
||
# WITHOUT Smart Inpaint's latent-zeroing
|
||
remove_mode: bool = False, # Remove brush via Xtra-Fine: reference the
|
||
# crop with its masked region ZEROED (a real
|
||
# hole) so the edit model rebuilds the
|
||
# background; regenerate at full denoise
|
||
seed: int = 0,
|
||
steps: int,
|
||
cfg: float,
|
||
sampler_name: str,
|
||
scheduler: str,
|
||
positive,
|
||
negative,
|
||
denoise: float,
|
||
callback,
|
||
disable_pbar: bool,
|
||
# #8 custom-sampler trio — None = use the default comfy.sample.sample
|
||
# path; all three set = dispatch via _do_sample to the guider path.
|
||
ov_guider=None,
|
||
ov_sampler=None,
|
||
ov_sigmas=None,
|
||
) -> tuple[torch.Tensor, torch.Tensor | None]:
|
||
"""Pixel-space crop + upscale + VAE encode + refine + VAE decode +
|
||
downscale + composite + VAE encode. The latent-space crop+upscale
|
||
approach smears bilinearly-interpolated latents into a low-freq
|
||
starting state that the model can't recover detail from. Going
|
||
through pixel space (where there's an image-upscale toolkit that's
|
||
been tuned for natural images) and re-encoding gives the model a
|
||
"natural" latent at the higher resolution to denoise from.
|
||
|
||
Returns a tuple of (new_latent, new_pixels). Returns (current, current_pixels)
|
||
if the mask bbox is empty / degenerate.
|
||
"""
|
||
bbox = _mask_bbox_latent(mask)
|
||
if bbox is None:
|
||
return current, current_pixels
|
||
y0_tight, y1_tight, x0_tight, x1_tight = bbox
|
||
|
||
# Apply context padding: grow the bbox outward by context_pad_pixel
|
||
# in every direction (clamped to the latent boundaries). This is
|
||
# the area the model SEES during refine. The painted-shape mask
|
||
# stays unchanged — areas inside the padded bbox but outside the
|
||
# painted shape have mask=0 in the cropped tensor, so the noise-
|
||
# injection inpaint preserves them as context (the model uses them
|
||
# to inform what to draw inside the mask, but doesn't overwrite
|
||
# them). All downstream code uses the PADDED bbox.
|
||
H_lat = current.shape[-2]
|
||
W_lat = current.shape[-1]
|
||
pad_lat_y = max(0, round(context_pad_pixel * scale_y))
|
||
pad_lat_x = max(0, round(context_pad_pixel * scale_x))
|
||
y0 = max(0, y0_tight - pad_lat_y)
|
||
y1 = min(H_lat, y1_tight + pad_lat_y)
|
||
x0 = max(0, x0_tight - pad_lat_x)
|
||
x1 = min(W_lat, x1_tight + pad_lat_x)
|
||
|
||
bbox_h_lat = y1 - y0
|
||
bbox_w_lat = x1 - x0
|
||
if bbox_h_lat <= 0 or bbox_w_lat <= 0:
|
||
return current, current_pixels
|
||
|
||
scale = _fine_upscale_factor(bbox_w_lat, bbox_h_lat, scale_x, scale_y, target_mp, max_linear)
|
||
if scale <= 1.0 and inpainting_mode != "Smart Inpaint" and not remove_mode:
|
||
# Refine with no upscale needed — fall back to the standard latent-space
|
||
# noise-injection inpaint. Avoids unnecessary VAE round-trips when the
|
||
# painted region already meets the MP target.
|
||
#
|
||
# Smart Inpaint must NOT take this shortcut. It needs the crop +
|
||
# reference_latents + masked-zero treatment below regardless of rect
|
||
# size; skipping it made a LARGE rectangle (already at/above the MP
|
||
# target, so scale<=1.0 — roughly >1024px on FLUX 2) degrade to a
|
||
# whole-latent edit with NO crop reference, so the model worked on the
|
||
# whole image instead of the selected rect.
|
||
print(f"[Angelo fine-upscale] scale=1.0 — using latent-space path (no VAE round-trip)")
|
||
if reference_strength > 0.0:
|
||
# Refine + Reference on the no-upscale path: anchor on the whole
|
||
# current image (there's no crop on this path).
|
||
positive = _apply_reference(positive, current.clone(), reference_strength)
|
||
noise = comfy.sample.prepare_noise(current, seed, None)
|
||
new_latent = _do_sample(
|
||
guider=ov_guider, sampler=ov_sampler, sigmas=ov_sigmas,
|
||
model=model, noise=noise,
|
||
steps=steps, cfg=cfg, sampler_name=sampler_name, scheduler=scheduler,
|
||
positive=positive, negative=negative,
|
||
source_latent=current,
|
||
denoise=denoise,
|
||
noise_mask=mask,
|
||
callback=callback,
|
||
disable_pbar=disable_pbar,
|
||
seed=seed,
|
||
)
|
||
# Return None for pixels because the latent was modified directly;
|
||
# this forces a fresh VAE decode for the preview in the main run() method.
|
||
return new_latent, None
|
||
|
||
# Smart Inpaint with a large rectangle still crops + references the selected
|
||
# region; it just doesn't upscale (and must never downscale) — clamp the
|
||
# factor to identity so the crop is taken at native resolution. Remove takes
|
||
# the same crop+reference path regardless of size, so clamp it too.
|
||
if inpainting_mode == "Smart Inpaint" or remove_mode:
|
||
scale = max(1.0, scale)
|
||
|
||
# ----- VAE decode the full cached latent → cached pixels -----
|
||
# Optimization: Reuse cached pixels if available to prevent VAE degradation
|
||
# (loss of high-frequency details) across multiple consecutive edits.
|
||
if current_pixels is not None:
|
||
cached_pixels = current_pixels
|
||
else:
|
||
cached_pixels = _vae_decode(vae, current) # (B, H_pix, W_pix, C) float [0,1]
|
||
|
||
H_pix = cached_pixels.shape[1]
|
||
W_pix = cached_pixels.shape[2]
|
||
# Pixel-per-latent ratio per axis (16 for FLUX 2, 8 for SDXL/SD1.5).
|
||
# round(), not floor-divide: a non-integer true ratio (exotic VAEs)
|
||
# floor-divided gives e.g. 15 for 15.8, drifting the pixel-space bbox
|
||
# ~1px against the latent bbox and leaving a seam in the composite.
|
||
# (#28, from @KursatAs.)
|
||
px_per_lat_y = max(1, round(H_pix / current.shape[-2]))
|
||
px_per_lat_x = max(1, round(W_pix / current.shape[-1]))
|
||
|
||
# Pixel-space bbox derived from the latent-space bbox.
|
||
y0_p = y0 * px_per_lat_y
|
||
y1_p = y1 * px_per_lat_y
|
||
x0_p = x0 * px_per_lat_x
|
||
x1_p = x1 * px_per_lat_x
|
||
bbox_h_p = y1_p - y0_p
|
||
bbox_w_p = x1_p - x0_p
|
||
|
||
# Upscaled target dims in pixel space. Snap to multiples of the
|
||
# VAE downscale (16 for FLUX 2) so the subsequent VAE encode
|
||
# produces a clean integer-dim latent.
|
||
vae_snap = max(px_per_lat_y, px_per_lat_x)
|
||
target_h_p = max(vae_snap, math.ceil(bbox_h_p * scale / vae_snap) * vae_snap)
|
||
target_w_p = max(vae_snap, math.ceil(bbox_w_p * scale / vae_snap) * vae_snap)
|
||
|
||
print(f"[Angelo fine-upscale] bbox_lat=(h={bbox_h_lat}, w={bbox_w_lat}) "
|
||
f"bbox_px=(h={bbox_h_p}, w={bbox_w_p}) scale={scale:.2f} "
|
||
f"target_px=(h={target_h_p}, w={target_w_p}) "
|
||
f"resize={resize_method} max_linear={max_linear} "
|
||
f"vae_ratio=(x={px_per_lat_x}, y={px_per_lat_y})")
|
||
|
||
# ----- Crop pixel image + upscale in pixel space -----
|
||
pixel_crop = cached_pixels[:, y0_p:y1_p, x0_p:x1_p, :] # (B, h, w, C)
|
||
# common_upscale expects (B, C, H, W) — permute, upscale, permute back.
|
||
pixel_crop_chw = pixel_crop.movedim(-1, 1)
|
||
pixel_crop_up_chw = comfy.utils.common_upscale(
|
||
pixel_crop_chw, target_w_p, target_h_p, resize_method, "disabled",
|
||
)
|
||
pixel_crop_up = pixel_crop_up_chw.movedim(1, -1) # back to (B, H, W, C)
|
||
|
||
# ----- VAE encode the upscaled pixel crop → latent at high res -----
|
||
latent_up = _vae_encode(vae, pixel_crop_up)
|
||
target_h_lat = latent_up.shape[-2]
|
||
target_w_lat = latent_up.shape[-1]
|
||
|
||
# ----- Build mask at the upscaled latent resolution -----
|
||
# Mask resizing always uses bilinear regardless of the user's choice.
|
||
# The user's resize_method is for the IMAGE content upscale (where
|
||
# lanczos / bicubic / etc. have real quality differences). The mask
|
||
# is a 1-channel feathered alpha where we just want smooth values;
|
||
# lanczos's grayscale-branch returns a transposed 3D tensor (PIL
|
||
# quirk) and bislerp's spherical-vector math is semantically wrong
|
||
# on a single channel.
|
||
mask_crop = mask[..., y0:y1, x0:x1].contiguous()
|
||
mask_crop_up = _resize_latent(mask_crop, target_h_lat, target_w_lat, "bilinear").clamp(0.0, 1.0)
|
||
|
||
# ===== Smart Inpaint pre-processing on the upscaled patch =====
|
||
# Klein 9B's edit branch only activates when reference_latents is
|
||
# present on the conditioning. We then zero the masked area so the
|
||
# sampler regenerates that region from full noise at sigma_max
|
||
# (the denoise=1.0 lock makes this clean: every pixel in the
|
||
# painted rect is brand-new content, with the surrounding context
|
||
# band restored each step by the noise_mask compositing). The
|
||
# reference uses the PRE-ZERO upscaled patch so Klein still sees
|
||
# what was there before we blanked it.
|
||
# POSITIVE ONLY — putting reference_latents on negative would tell
|
||
# CFG>1 samplers to steer AWAY from the reference scene. Non-edit
|
||
# models ignore the field, so this is harmless on any checkpoint.
|
||
#
|
||
# append=False (REPLACE, not append): the reference must be ONLY this
|
||
# upscaled crop. When the Area Prompt is empty, refine_positive falls back
|
||
# to the node's `positive` input, which in a Klein edit workflow already
|
||
# carries reference_latents = the WHOLE source image (from an upstream
|
||
# ReferenceLatent node). append=True stacked the crop onto that whole-image
|
||
# reference, and the whole-image one dominated — so the patch reproduced
|
||
# the entire original scene instead of editing the selected region.
|
||
# Replacing guarantees Klein sees the crop and nothing else.
|
||
# Mask used for the SAMPLING step (both the masked-zero and the noise_mask).
|
||
# Defaults to the feathered mask; hardened to binary for Smart Inpaint on
|
||
# 5D temporal latents — see the note in the Smart Inpaint block below.
|
||
sample_mask = mask_crop_up
|
||
if remove_mode:
|
||
# Remove via Xtra-Fine: reference the crop with its masked region ZEROED
|
||
# (a real hole), so the edit model rebuilds the background from the
|
||
# surrounding crop rather than anchoring to the object. The source latent
|
||
# is NOT zeroed — at denoise 1.0 the masked region is noised anyway, and
|
||
# outside the mask the crop is kept. 5D uses a hard sampling mask for the
|
||
# same reason as Smart Inpaint below.
|
||
if latent_up.ndim == 5:
|
||
sample_mask = (mask_crop_up >= 0.5).to(mask_crop_up.dtype)
|
||
ref_hole = latent_up.clone() * (1.0 - sample_mask.unsqueeze(0))
|
||
positive = node_helpers.conditioning_set_values(
|
||
positive, {"reference_latents": [ref_hole]}, append=False,
|
||
)
|
||
elif inpainting_mode == "Smart Inpaint":
|
||
reference_latent = latent_up.clone()
|
||
positive = node_helpers.conditioning_set_values(
|
||
positive, {"reference_latents": [reference_latent]}, append=False,
|
||
)
|
||
# Feather goes in the COMPOSITE, not the denoise mask — for Qwen/Wan.
|
||
#
|
||
# FLUX/Klein (4D, ~zero-mean latents): a soft mask works directly as
|
||
# both the masked-zero and the noise_mask; their inpaint blend handles a
|
||
# soft boundary cleanly, so keep the feathered mask.
|
||
#
|
||
# Qwen Image Edit / Wan (5D temporal latents): sample with a HARD
|
||
# (binary) mask. These models have no clean soft-mask-during-denoise
|
||
# behaviour — the community-standard Qwen-edit inpaint recipe is "blank
|
||
# the region, regenerate, then composite back with a feathered blend",
|
||
# NOT feathering the denoise mask. A soft noise_mask at denoise=1.0 on a
|
||
# non-zero-mean latent space distorts exactly the feather band (the
|
||
# symptom: artifacts only where the feathering happens). The smooth
|
||
# visible edge is still produced downstream by the feathered PIXEL
|
||
# composite + final latent blend (both use the full-res feathered
|
||
# `mask`), so the feather is preserved without the sampling artifacts.
|
||
if latent_up.ndim == 5:
|
||
sample_mask = (mask_crop_up >= 0.5).to(mask_crop_up.dtype)
|
||
latent_up = (1.0 - sample_mask.unsqueeze(0)) * latent_up
|
||
elif reference_strength > 0.0:
|
||
# Refine + Reference value: the upscaled (pre-refine) crop anchors
|
||
# identity/content through the edit branch for the first
|
||
# reference_strength fraction of the schedule, so a high denoise
|
||
# can fully re-render texture without losing the subject — the
|
||
# photo-restoration recipe. Unlike Smart Inpaint, the latent is NOT
|
||
# zeroed: the existing content remains the starting state.
|
||
positive = _apply_reference(positive, latent_up.clone(), reference_strength)
|
||
|
||
# ----- Refine via noise-injection inpaint on the upscaled latent -----
|
||
noise = comfy.sample.prepare_noise(latent_up, seed, None)
|
||
refined_latent_up = _do_sample(
|
||
guider=ov_guider, sampler=ov_sampler, sigmas=ov_sigmas,
|
||
model=model, noise=noise,
|
||
steps=steps, cfg=cfg, sampler_name=sampler_name, scheduler=scheduler,
|
||
positive=positive, negative=negative,
|
||
source_latent=latent_up,
|
||
denoise=denoise,
|
||
noise_mask=sample_mask,
|
||
callback=callback,
|
||
disable_pbar=disable_pbar,
|
||
seed=seed,
|
||
)
|
||
|
||
# ----- VAE decode refined latent → high-res pixel patch -----
|
||
refined_pixel_up = _vae_decode(vae, refined_latent_up) # (B, target_h_p, target_w_p, C)
|
||
|
||
# ----- Downscale refined patch back to original bbox pixel size -----
|
||
refined_pixel_up_chw = refined_pixel_up.movedim(-1, 1)
|
||
refined_pixel_chw = comfy.utils.common_upscale(
|
||
refined_pixel_up_chw, bbox_w_p, bbox_h_p, resize_method, "disabled",
|
||
)
|
||
refined_pixel = refined_pixel_chw.movedim(1, -1) # (B, bbox_h_p, bbox_w_p, C)
|
||
|
||
# ----- Composite refined patch into the cached pixel image -----
|
||
# Build a pixel-space alpha by resizing the latent feathered mask to
|
||
# full pixel resolution, cropping to the bbox. Always bilinear for
|
||
# the same reasons as the mask upscale above — lanczos's grayscale
|
||
# path is broken, bislerp doesn't apply to 1-channel.
|
||
mask_4d = mask.unsqueeze(0) # [1, 1, H_lat, W_lat]
|
||
pixel_mask = comfy.utils.common_upscale(
|
||
mask_4d, W_pix, H_pix, "bilinear", "disabled",
|
||
).clamp(0.0, 1.0) # [1, 1, H_pix, W_pix]
|
||
pixel_alpha_crop = pixel_mask[0, 0, y0_p:y1_p, x0_p:x1_p] # [bbox_h_p, bbox_w_p]
|
||
pixel_alpha_crop = pixel_alpha_crop.unsqueeze(0).unsqueeze(-1) # [1, h, w, 1]
|
||
|
||
new_pixels = cached_pixels.clone()
|
||
pixel_orig_crop = cached_pixels[:, y0_p:y1_p, x0_p:x1_p, :]
|
||
composited = refined_pixel * pixel_alpha_crop + pixel_orig_crop * (1.0 - pixel_alpha_crop)
|
||
new_pixels[:, y0_p:y1_p, x0_p:x1_p, :] = composited
|
||
|
||
# ----- VAE encode the composited full image → encoded latent -----
|
||
encoded_latent = _vae_encode(vae, new_pixels)
|
||
|
||
# ----- Blend in LATENT space using the feathered mask as alpha -----
|
||
# The VAE encode is lossy, so naively returning encoded_latent would
|
||
# mean the *unaltered* regions of the image drift slightly with every
|
||
# Fine Upscale click. Avoidable: keep the original cached latent
|
||
# outside the mask, take the encoded latent inside the mask. Mask is
|
||
# already feathered, so the transition is smooth. Now unaltered
|
||
# regions stay bit-exact across successive clicks; only the masked
|
||
# area accumulates any VAE-roundtrip cost (and it gets a fresh
|
||
# refine each click anyway, so any drift there is overwritten).
|
||
alpha_lat = mask.unsqueeze(0) # [1, 1, H_lat, W_lat]
|
||
new_latent = encoded_latent * alpha_lat + current * (1.0 - alpha_lat)
|
||
|
||
return new_latent, new_pixels
|
||
|
||
|
||
# Quick Photo Refine: the one-click restoration recipe — a MAGIC BUTTON
|
||
# with its own fixed settings, deliberately independent of every toolbar
|
||
# box. The whole canvas is re-rendered with this prompt, anchored to the
|
||
# current image via reference_latents — identity from the reference,
|
||
# texture from the re-render. Tested values; tuned here in ONE place.
|
||
# The winning prompt is an INSTRUCTION — edit models are instruction-
|
||
# trained, so speaking their vocabulary beat every descriptive/
|
||
# constraint phrasing tried ("high quality photo", keep-the-colours
|
||
# variants — see git history). At ref 1.0 the blend collapses to a
|
||
# single cond, so this recipe also costs nothing extra.
|
||
#
|
||
# Per-model phrasing: Qwen-Image-Edit responds better to a fuller,
|
||
# gentler instruction than Klein does. Selected at run time by latent
|
||
# dimensionality — Qwen/Wan-family latents are 5D [B,C,T,H,W], the
|
||
# same load-bearing check the inpaint hard-mask logic uses.
|
||
_QUICK_REFINE_PROMPT = "Keep the identity from image 1. make the image high quality."
|
||
_QUICK_REFINE_PROMPT_QWEN = ("lightly restore this old photo, remove dust and scratches, "
|
||
"improve sharpness and contrast, preserve original feel")
|
||
_QUICK_REFINE_DENOISE = 1.0
|
||
# Lite mode (toggle beside the prompt selector): identical recipe, gentler
|
||
# denoise — a lighter restore that re-renders less and stays closer to the
|
||
# input. Everything else (ref, target, prompt, tiling, re-roll) is unchanged.
|
||
_QUICK_REFINE_LITE_DENOISE = 0.8
|
||
_QUICK_REFINE_REF = 1.0
|
||
# ✨ v2 runs the whole image through the XTRA-FINE pipeline: whole-canvas
|
||
# mask, target 1.3MP (small images get internally supersampled to 1.3MP,
|
||
# refined there, composited back), ctx pad 128 (no-op at full coverage),
|
||
# feather 0.
|
||
_QUICK_REFINE_TARGET_MP = 1.3
|
||
_QUICK_REFINE_CTX_PAD = 128
|
||
|
||
# ✨ prompt presets (the selector beside the button). All instruction-
|
||
# register with the image-1 identity clause — the phrasing family testing
|
||
# proved out. "Use Area Prompt" hands the text box over instead (falls
|
||
# back to the default preset when the box is empty).
|
||
_QUICK_REFINE_PROMPTS = {
|
||
"Identity + Quality": _QUICK_REFINE_PROMPT,
|
||
"Restore Photo": "Keep the identity from image 1. restore the photo.",
|
||
"Identity + Colours": ("Keep the identity and colours from image 1. "
|
||
"make the image high quality."),
|
||
}
|
||
_QUICK_REFINE_PROMPT_MODES = list(_QUICK_REFINE_PROMPTS.keys()) + ["Use Area Prompt"]
|
||
|
||
# 🩹 Remove brush — toggle beside Restore (Refine mode, edit models only).
|
||
# Erases the painted region to background in a SINGLE denoise-1.0 sampling pass
|
||
# over the grown, hard-edged mask (no pixel fill): regenerate the masked region
|
||
# with a background-fill instruction (_REMOVE_PROMPT) and a reference latent whose masked
|
||
# region is ZEROED — a real hole in the reference, so the edit model has nothing
|
||
# to anchor to there and rebuilds the background from the surrounding scene. Full
|
||
# denoise removes the object from the starting state; the zeroed reference stops
|
||
# it being reconstructed (the ghost). This is the direct analog of outpaint's
|
||
# protect brush, which excludes a region so it isn't repeated.
|
||
#
|
||
# feather 0 (hard mask, no soft-edge bleed) + grow the mask ~10% (cover the
|
||
# object's halo — a tight mask leaves a rim of the original behind). The Denoise
|
||
# box is unused — the pass is always full denoise.
|
||
#
|
||
# ALWAYS runs on the Xtra-Fine crop path (Remove forces the toggle on and won't
|
||
# run without it): crop the region at the toolbar ctx-pad / MP / method and do
|
||
# the removal on a high-res crop, with the zeroed reference built from that crop.
|
||
# This is what rebuilds fine background detail behind the removed object.
|
||
# FILL-style, not "remove"-style: the object is already gone from what the model
|
||
# sees (the reference has its masked region zeroed), so the prompt's only job is
|
||
# to fill the hole with plausible surrounding background — telling it to "remove
|
||
# the object" is redundant/confusing when it's looking at a hole.
|
||
_REMOVE_PROMPT = ("Seamlessly continue the surrounding background across the masked area. "
|
||
"A natural, coherent extension of the scene behind it, matching the "
|
||
"surrounding lighting, texture, colour and perspective. No objects, no new "
|
||
"content — background only.")
|
||
_REMOVE_PROMPT_QWEN = ("seamlessly continue the surrounding background across the masked area, "
|
||
"a natural coherent extension of the scene behind it, matching the "
|
||
"surrounding lighting, texture, colour and perspective; background only, "
|
||
"no objects and no new content")
|
||
_REMOVE_MASK_GROW_FRAC = 0.05 # ~10% larger area
|
||
|
||
# Tiled restore engine (2× Restore Upscale + big-canvas Quick Refine).
|
||
# Working tile size + overlap in PIXELS: tiles are sampled at ~1MP no
|
||
# matter how large the canvas is, so the latent fed to the model never
|
||
# outgrows the resolution it renders well at. Quick Photo Refine
|
||
# auto-routes through the tiled engine above the MP threshold for the
|
||
# same reason.
|
||
_TILE_PX = 1400
|
||
_TILE_OVERLAP_PX = 128
|
||
|
||
|
||
def _tile_min_overlap_px(canvas_long_edge: int) -> int:
|
||
"""Canvas-proportional MINIMUM tile overlap. Bigger canvases mean each
|
||
tile sees a smaller slice of the scene, so neighbours need more shared
|
||
context to agree. Linear in the long edge, calibrated on tested
|
||
points: 3872px -> 172, 7744px -> 256; clamps to the 128px floor below
|
||
~1840px."""
|
||
return max(_TILE_OVERLAP_PX, 88 + round(canvas_long_edge / 46.0))
|
||
_QUICK_REFINE_TILE_THRESHOLD_MP = 1.6
|
||
|
||
|
||
|
||
|
||
def _apply_reference(positive, ref_latent: torch.Tensor, strength: float):
|
||
"""Attach ref_latent as reference_latents at a TRUE fractional strength
|
||
via dual-conditioning prediction blending.
|
||
|
||
There is no native scalar weight on reference_latents — the reference
|
||
becomes extra image tokens, take-it-or-leave-it. But ComfyUI's cond
|
||
`strength` field is a RELATIVE weight: each cond's prediction is
|
||
multiplied by its strength, accumulated, then normalised by the total.
|
||
So two full-coverage conds — [with-reference, strength s] + [plain,
|
||
strength 1-s] — yield exactly s·pred_ref + (1-s)·pred_plain at every
|
||
step: a genuine prediction-space lerp between anchored and free.
|
||
|
||
1.0 → one cond, fully anchored (no extra cost)
|
||
0 < s <1 → the dual-cond blend above. COSTS A SECOND positive-side
|
||
model evaluation per step (like CFG's negative does) —
|
||
the price of a real interpolation.
|
||
0.0 → untouched (no reference, no extra cost)
|
||
|
||
(A timestep-range variant — strength as DURATION of anchoring — was
|
||
tried first and rejected by testing; see git history.)
|
||
"""
|
||
s = max(0.0, min(1.0, float(strength)))
|
||
if s <= 0.0:
|
||
return positive
|
||
with_ref = node_helpers.conditioning_set_values(
|
||
positive, {"reference_latents": [ref_latent]}, append=False,
|
||
)
|
||
if s >= 1.0:
|
||
return with_ref
|
||
head = node_helpers.conditioning_set_values(with_ref, {"strength": s})
|
||
tail = node_helpers.conditioning_set_values(positive, {"strength": 1.0 - s})
|
||
return head + tail
|
||
|
||
|
||
def _tile_positions(total: int, tile: int, overlap: int) -> list[int]:
|
||
"""Start offsets covering [0, total) with `tile`-sized windows,
|
||
UNIFORMLY distributed so every seam gets the same overlap (>= the
|
||
requested minimum). The old flush-to-end layout produced wildly
|
||
inconsistent seams (128px here, 900px there) — uniform spacing means
|
||
every boundary shares at least the minimum context, evenly."""
|
||
if total <= tile:
|
||
return [0]
|
||
stride = max(1, tile - max(0, overlap))
|
||
n = max(2, math.ceil((total - overlap) / stride))
|
||
step = (total - tile) / (n - 1)
|
||
return [round(i * step) for i in range(n)]
|
||
|
||
|
||
def _tiled_restore_pass(
|
||
*,
|
||
model,
|
||
vae,
|
||
current: torch.Tensor,
|
||
current_pixels: torch.Tensor | None,
|
||
scale: float,
|
||
positive_base,
|
||
negative,
|
||
seed: int,
|
||
steps: int,
|
||
cfg: float,
|
||
sampler_name: str,
|
||
scheduler: str,
|
||
callback,
|
||
disable_pbar: bool,
|
||
ov_guider=None,
|
||
ov_sampler=None,
|
||
ov_sigmas=None,
|
||
tile_ref: float = _QUICK_REFINE_REF,
|
||
tile_denoise: float = _QUICK_REFINE_DENOISE,
|
||
dual_grid: bool = False,
|
||
) -> tuple[torch.Tensor, torch.Tensor, int]:
|
||
"""The tiled restore engine: the Quick Photo Refine recipe run over
|
||
overlapping ~1MP tiles, so the model never samples a latent bigger
|
||
than its happy place — regardless of canvas size.
|
||
|
||
scale=2.0 → 2× Restore Upscale: decode, lanczos-upscale the PIXELS
|
||
(latent upscaling smears; pixel space is the Xtra-Fine lesson),
|
||
re-encode, then restore tile by tile. scale=1.0 → in-place tiled
|
||
restore (how Quick Photo Refine handles big canvases).
|
||
|
||
Each tile is sampled at denoise 1.0 anchored to ITS OWN pre-refine
|
||
latent via reference_latents. The anchor is what makes tiled
|
||
full-denoise viable: tiles can't hallucinate new content (the
|
||
classic tiled-upscaler failure), and adjacent tiles agree because
|
||
they're anchored to the same underlying image — the reference does
|
||
the job tile-ControlNets do in other pipelines.
|
||
|
||
Tiles composite in pixel space under a separable ramp weight
|
||
(linear fade over the overlap), normalised by the accumulated
|
||
weight, so seams blend and un-overlapped edges keep weight 1.
|
||
|
||
Returns (final_latent, final_pixels, n_tiles).
|
||
"""
|
||
pixels = current_pixels if current_pixels is not None else _vae_decode(vae, current)
|
||
B, H, W, C = pixels.shape
|
||
|
||
if scale != 1.0:
|
||
new_W = max(16, int(round(W * scale / 16.0)) * 16)
|
||
new_H = max(16, int(round(H * scale / 16.0)) * 16)
|
||
chw = pixels.movedim(-1, 1)
|
||
chw = comfy.utils.common_upscale(chw, new_W, new_H, "lanczos", "disabled")
|
||
big_pixels = chw.movedim(1, -1).contiguous()
|
||
else:
|
||
big_pixels = pixels
|
||
new_H, new_W = big_pixels.shape[1], big_pixels.shape[2]
|
||
|
||
big_latent = _vae_encode(vae, big_pixels)
|
||
nlh, nlw = big_latent.shape[-2], big_latent.shape[-1]
|
||
ppl_x = max(1, round(new_W / nlw))
|
||
ppl_y = max(1, round(new_H / nlh))
|
||
|
||
tile_lx = max(8, _TILE_PX // ppl_x)
|
||
tile_ly = max(8, _TILE_PX // ppl_y)
|
||
min_ov_px = _tile_min_overlap_px(max(new_W, new_H))
|
||
ov_lx = max(1, min_ov_px // ppl_x)
|
||
ov_ly = max(1, min_ov_px // ppl_y)
|
||
xs = _tile_positions(nlw, tile_lx, ov_lx)
|
||
ys = _tile_positions(nlh, tile_ly, ov_ly)
|
||
n_tiles = len(xs) * len(ys)
|
||
print(f"[Angelo tiled-restore] scale={scale} canvas={new_W}x{new_H} "
|
||
f"grid={len(xs)}x{len(ys)} ({n_tiles} tiles of ~{_TILE_PX}px, "
|
||
f"min overlap {min_ov_px}px, uniform spacing)")
|
||
|
||
out = torch.zeros_like(big_pixels)
|
||
wsum = torch.zeros((1, new_H, new_W, 1), dtype=big_pixels.dtype, device=big_pixels.device)
|
||
|
||
# ONE global noise field, cropped per tile. This is the anti-ghosting
|
||
# measure: neighbouring tiles see IDENTICAL init noise (and identical
|
||
# reference content) in their shared overlap, so at denoise 1.0 their
|
||
# trajectories converge on essentially the same fine detail there —
|
||
# the blend averages two near-identical renderings instead of two
|
||
# independently-imagined ones (decorrelated per-tile seeds produced
|
||
# "double eyelash" ghosting at seams). Deterministic samplers (euler,
|
||
# the default) get the full benefit; ancestral samplers re-noise per
|
||
# step in tile-local coordinates, so some residual seam softness is
|
||
# expected there.
|
||
global_noise = comfy.sample.prepare_noise(big_latent, seed, None)
|
||
|
||
def _ramp(length_px: int, ov_px: int, device):
|
||
r = torch.ones(length_px, dtype=torch.float32, device=device)
|
||
n = min(ov_px, length_px)
|
||
fade = (torch.arange(n, dtype=torch.float32, device=device) + 1.0) / (n + 1.0)
|
||
fade = fade * fade * (3.0 - 2.0 * fade) # smoothstep — softer crossover
|
||
r[:n] = torch.minimum(r[:n], fade)
|
||
r[-n:] = torch.minimum(r[-n:], fade.flip(0))
|
||
return r
|
||
|
||
def _accumulate(xs_grid, ys_grid):
|
||
for y0 in ys_grid:
|
||
for x0 in xs_grid:
|
||
tw = min(tile_lx, nlw - x0)
|
||
th = min(tile_ly, nlh - y0)
|
||
tile_lat = big_latent[..., y0:y0 + th, x0:x0 + tw].clone().contiguous()
|
||
tile_pos = _apply_reference(positive_base, tile_lat.clone(), tile_ref)
|
||
noise = global_noise[..., y0:y0 + th, x0:x0 + tw].clone().contiguous()
|
||
refined = _do_sample(
|
||
guider=ov_guider, sampler=ov_sampler, sigmas=ov_sigmas,
|
||
model=model, noise=noise,
|
||
steps=steps, cfg=cfg, sampler_name=sampler_name, scheduler=scheduler,
|
||
positive=tile_pos, negative=negative,
|
||
source_latent=tile_lat,
|
||
denoise=tile_denoise,
|
||
noise_mask=None,
|
||
callback=callback,
|
||
disable_pbar=disable_pbar,
|
||
seed=int(seed),
|
||
)
|
||
tile_px = _vae_decode(vae, refined).to(big_pixels.device, big_pixels.dtype)
|
||
|
||
py0, px0 = y0 * ppl_y, x0 * ppl_x
|
||
pth, ptw = tile_px.shape[1], tile_px.shape[2]
|
||
wy = _ramp(pth, min_ov_px, big_pixels.device)
|
||
wx = _ramp(ptw, min_ov_px, big_pixels.device)
|
||
w = (wy.view(1, -1, 1, 1) * wx.view(1, 1, -1, 1)).to(big_pixels.dtype)
|
||
out[:, py0:py0 + pth, px0:px0 + ptw, :] += tile_px * w
|
||
wsum[:, py0:py0 + pth, px0:px0 + ptw, :] += w
|
||
|
||
_accumulate(xs, ys)
|
||
|
||
# Seam-erase pass: a SECOND tile grid offset by ~half a tile, so its
|
||
# tiles are CENTRED on the first grid's seams (a tile start at the
|
||
# midpoint of two grid-A starts puts the new tile's centre on the seam
|
||
# between them). Both grids refine the same source and accumulate into
|
||
# the same weighted-blend buffers, so where grid A is weakest — its seam
|
||
# lines, where its tiles taper to low ramp weight — the offset tiles sit
|
||
# at full weight and dominate, and vice versa. Any residual seam from one
|
||
# grid is overwritten by the other grid's continuous tile interior.
|
||
# Costs a second set of tiles (~2× sampling). Skipped when a tiny canvas
|
||
# produced a single tile on either axis (nothing to offset).
|
||
offset_tiles = 0
|
||
if dual_grid and len(xs) > 1 and len(ys) > 1:
|
||
xs_b = [round((xs[i] + xs[i + 1]) / 2) for i in range(len(xs) - 1)]
|
||
ys_b = [round((ys[i] + ys[i + 1]) / 2) for i in range(len(ys) - 1)]
|
||
offset_tiles = len(xs_b) * len(ys_b)
|
||
print(f"[Angelo tiled-restore] seam-erase pass: offset grid "
|
||
f"{len(xs_b)}x{len(ys_b)} ({offset_tiles} tiles)")
|
||
_accumulate(xs_b, ys_b)
|
||
|
||
final_pixels = out / wsum.clamp(min=1e-6)
|
||
final_latent = _vae_encode(vae, final_pixels)
|
||
return final_latent, final_pixels, n_tiles + offset_tiles
|
||
|
||
|
||
# Direction-aware instruction prepended to the outpaint conditioning when a
|
||
# CLIP is wired (same pattern as Smart Guided's location prefixes). Edit
|
||
# models (Klein / Qwen) follow explicit continue-don't-repeat instructions
|
||
# well — this is the main lever against the classic outpainting failure of
|
||
# duplicating the scene's subject into the new strip.
|
||
_OUTPAINT_INSTRUCTIONS = {
|
||
"left": "Extend the image to the left, continuing the scene and background naturally. Do not repeat or add new subjects. ",
|
||
"right": "Extend the image to the right, continuing the scene and background naturally. Do not repeat or add new subjects. ",
|
||
"up": "Extend the image upward, continuing the scene and background naturally. Do not repeat or add new subjects. ",
|
||
"down": "Extend the image downward, continuing the scene and background naturally. Do not repeat or add new subjects. ",
|
||
"all": "Extend the image outward on all sides, continuing the scene and background naturally. Do not repeat or add new subjects. ",
|
||
}
|
||
|
||
|
||
def _outpaint_prepare(
|
||
*,
|
||
vae,
|
||
current: torch.Tensor,
|
||
current_pixels: torch.Tensor | None,
|
||
direction: str,
|
||
amount_px: int,
|
||
overlap_px: int,
|
||
protect_json: str = "",
|
||
) -> tuple[torch.Tensor, torch.Tensor, int, torch.Tensor]:
|
||
"""Build the init latent + mask for one outpaint pass.
|
||
|
||
Pads the decoded canvas in `direction` by `amount_px` (snapped to a
|
||
/16 multiple so both 8x and 16x VAEs land on integer latent cells),
|
||
fills the new strip with edge-replicated pixels (a colour-continuity
|
||
hint that costs nothing — at denoise 1.0 the masked region starts from
|
||
pure noise on flow models anyway), VAE-encodes the padded canvas, then
|
||
PASTES the original latent back over the old region so the existing
|
||
image stays bit-exact in latent terms (no VAE round-trip drift — the
|
||
same trick as Xtra-Fine's final blend).
|
||
|
||
The mask covers the new strip at 1.0 plus a feathered band reaching
|
||
`overlap_px` into the old image, so the seam is redrawn rather than
|
||
butted. The pure-new region is re-hardened to 1.0 after the blur.
|
||
|
||
Every extension spans the FULL current edge, which is what makes
|
||
corners a non-problem: extend right then down, and the bottom strip
|
||
spans the new wider canvas — the corner is generated by whichever
|
||
direction runs second, with both neighbours as context.
|
||
|
||
`protect_json` is the protect-brush payload — a JSON list of
|
||
[x, y, r] circles in OLD-image pixel coords. Protected pixels are
|
||
subtracted from the overlap band (clamped so the pure-new strip can
|
||
never be protected), keeping e.g. a car at the frame edge frozen
|
||
while the rest of the band still blends generously.
|
||
|
||
Also returns the REFERENCE latent for edit models: the edge-adjacent
|
||
band (~512px deep) for directional extensions — showing the model
|
||
the texture/lighting to continue WITHOUT re-showing it the scene's
|
||
subject (whole-image references invite the edit branch to reproduce
|
||
the subject into the strip — the Smart Inpaint lesson again). "all"
|
||
keeps the whole image (everything borders the new space).
|
||
|
||
Returns (latent_init, mask, snapped_amount_px, reference_latent).
|
||
"""
|
||
pixels = current_pixels if current_pixels is not None else _vae_decode(vae, current)
|
||
B, H, W, C = pixels.shape
|
||
amt = max(16, int(round(amount_px / 16.0)) * 16)
|
||
pl, pr, pt, pb = {
|
||
"left": (amt, 0, 0, 0),
|
||
"right": (0, amt, 0, 0),
|
||
"up": (0, 0, amt, 0),
|
||
"down": (0, 0, 0, amt),
|
||
"all": (amt, amt, amt, amt),
|
||
}.get(str(direction), (0, amt, 0, 0))
|
||
|
||
chw = pixels.movedim(-1, 1)
|
||
chw = torch.nn.functional.pad(chw, (pl, pr, pt, pb), mode="replicate")
|
||
new_pixels = chw.movedim(1, -1).contiguous()
|
||
new_H, new_W = new_pixels.shape[1], new_pixels.shape[2]
|
||
|
||
latent_init = _vae_encode(vae, new_pixels)
|
||
nlh, nlw = latent_init.shape[-2], latent_init.shape[-1]
|
||
px_per_lat_x = max(1, round(new_W / nlw))
|
||
px_per_lat_y = max(1, round(new_H / nlh))
|
||
lat_l = pl // px_per_lat_x
|
||
lat_t = pt // px_per_lat_y
|
||
lh, lw = current.shape[-2], current.shape[-1]
|
||
latent_init[..., lat_t:lat_t + lh, lat_l:lat_l + lw] = current.to(
|
||
device=latent_init.device, dtype=latent_init.dtype,
|
||
)
|
||
|
||
ov_lx = max(0, round(int(overlap_px) / px_per_lat_x))
|
||
ov_ly = max(0, round(int(overlap_px) / px_per_lat_y))
|
||
mask = torch.ones((1, nlh, nlw), dtype=torch.float32, device=latent_init.device)
|
||
y0 = lat_t + (ov_ly if pt > 0 else 0)
|
||
y1 = lat_t + lh - (ov_ly if pb > 0 else 0)
|
||
x0 = lat_l + (ov_lx if pl > 0 else 0)
|
||
x1 = lat_l + lw - (ov_lx if pr > 0 else 0)
|
||
if y1 > y0 and x1 > x0:
|
||
mask[..., y0:y1, x0:x1] = 0.0
|
||
# strip_only = 1.0 over ONLY the genuinely-new pixels. It's the floor
|
||
# the blur and the protect brush are both clamped against: nothing may
|
||
# reduce the strip below full regeneration.
|
||
strip_only = torch.ones_like(mask)
|
||
strip_only[..., lat_t:lat_t + lh, lat_l:lat_l + lw] = 0.0
|
||
if int(overlap_px) > 0:
|
||
sigma = max(0.5, (ov_lx + ov_ly) / 4.0)
|
||
mask = torch.maximum(_gaussian_blur_2d(mask, sigma), strip_only).clamp(0.0, 1.0)
|
||
|
||
# Protect brush: subtract painted circles from the overlap band so
|
||
# e.g. a car at the frame edge stays bit-exact frozen while the rest
|
||
# of the band still blends. Circles arrive in OLD-image pixel coords;
|
||
# offset by the pad, scale to latent cells, union, soften 1 cell.
|
||
circles = []
|
||
try:
|
||
for item in json.loads(protect_json or "[]"):
|
||
if isinstance(item, (list, tuple)) and len(item) >= 3:
|
||
circles.append((float(item[0]), float(item[1]), float(item[2])))
|
||
except (ValueError, TypeError):
|
||
circles = []
|
||
if circles:
|
||
pts = torch.tensor(circles, dtype=torch.float32, device=mask.device)
|
||
cx = (pts[:, 0] + pl) / px_per_lat_x
|
||
cy = (pts[:, 1] + pt) / px_per_lat_y
|
||
rl = pts[:, 2] / math.sqrt(px_per_lat_x * px_per_lat_y)
|
||
ys = torch.arange(nlh, device=mask.device, dtype=torch.float32).view(1, -1, 1)
|
||
xs = torch.arange(nlw, device=mask.device, dtype=torch.float32).view(1, 1, -1)
|
||
dist2 = (xs - cx.view(-1, 1, 1)) ** 2 + (ys - cy.view(-1, 1, 1)) ** 2
|
||
protect = (dist2 <= (rl.view(-1, 1, 1) ** 2)).to(torch.float32).amax(dim=0).unsqueeze(0)
|
||
protect = _gaussian_blur_2d(protect, 1.0).clamp(0.0, 1.0)
|
||
mask = torch.maximum(mask * (1.0 - protect), strip_only).clamp(0.0, 1.0)
|
||
|
||
# Reference for edit models: the edge-adjacent band (~512px deep) for
|
||
# a directional extension; the whole image for "all" (see docstring).
|
||
ref_latent = current
|
||
if direction in ("left", "right", "up", "down"):
|
||
d_x = max(8, round(512.0 / px_per_lat_x))
|
||
d_y = max(8, round(512.0 / px_per_lat_y))
|
||
if direction == "right":
|
||
ref_latent = current[..., :, max(0, lw - d_x):]
|
||
elif direction == "left":
|
||
ref_latent = current[..., :, :min(lw, d_x)]
|
||
elif direction == "down":
|
||
ref_latent = current[..., max(0, lh - d_y):, :]
|
||
else: # up
|
||
ref_latent = current[..., :min(lh, d_y), :]
|
||
ref_latent = ref_latent.clone().contiguous()
|
||
|
||
return latent_init, mask, amt, ref_latent
|
||
|
||
|
||
def _circle_mask_latent_direct(
|
||
latent_h: int,
|
||
latent_w: int,
|
||
cx_latent: float,
|
||
cy_latent: float,
|
||
r_latent: float,
|
||
device: torch.device,
|
||
) -> torch.Tensor:
|
||
"""Build a binary [1, latent_h, latent_w] mask with a filled circle
|
||
centred on (cx_latent, cy_latent) of radius `r_latent`, all in
|
||
latent-space coordinates. The caller is responsible for converting
|
||
pixel-space click coords to latent space using the correct per-axis
|
||
scale (image_dim_pixel → latent_dim) so this function stays VAE-
|
||
agnostic.
|
||
"""
|
||
ys = torch.arange(latent_h, device=device, dtype=torch.float32).view(-1, 1)
|
||
xs = torch.arange(latent_w, device=device, dtype=torch.float32).view(1, -1)
|
||
dist2 = (xs - cx_latent) ** 2 + (ys - cy_latent) ** 2
|
||
mask = (dist2 <= r_latent * r_latent).to(torch.float32)
|
||
return mask.unsqueeze(0) # [1, H, W]
|
||
|
||
|
||
def _gaussian_blur_2d(mask: torch.Tensor, sigma_latent: float) -> torch.Tensor:
|
||
"""Separable gaussian blur on a [B, H, W] or [H, W] mask tensor.
|
||
|
||
sigma_latent is in latent-space units.
|
||
"""
|
||
if sigma_latent <= 0:
|
||
return mask
|
||
# Kernel covers ~±3σ. Force odd size.
|
||
ksize = int(2 * math.ceil(3 * sigma_latent) + 1)
|
||
half = ksize // 2
|
||
x = torch.arange(ksize, device=mask.device, dtype=torch.float32) - half
|
||
k1d = torch.exp(-0.5 * (x / sigma_latent) ** 2)
|
||
k1d = k1d / k1d.sum()
|
||
|
||
# Reshape mask to [N, 1, H, W]
|
||
orig_ndim = mask.dim()
|
||
if orig_ndim == 2:
|
||
m = mask.unsqueeze(0).unsqueeze(0)
|
||
elif orig_ndim == 3:
|
||
m = mask.unsqueeze(1)
|
||
elif orig_ndim == 4:
|
||
m = mask
|
||
else:
|
||
raise ValueError(f"gaussian_blur_2d: unexpected mask ndim {orig_ndim}")
|
||
|
||
kh = k1d.view(1, 1, 1, ksize)
|
||
kv = k1d.view(1, 1, ksize, 1)
|
||
|
||
m = torch.nn.functional.pad(m, (half, half, 0, 0), mode="replicate")
|
||
m = torch.nn.functional.conv2d(m, kh)
|
||
m = torch.nn.functional.pad(m, (0, 0, half, half), mode="replicate")
|
||
m = torch.nn.functional.conv2d(m, kv)
|
||
|
||
if orig_ndim == 2:
|
||
return m.squeeze(0).squeeze(0)
|
||
if orig_ndim == 3:
|
||
return m.squeeze(1)
|
||
return m
|
||
|
||
|
||
def _encode_loaded_image(vae, ref_json: str, resize_mode: str, target_mp: float):
|
||
"""Load an image the user picked via the Load Image button and
|
||
VAE-encode it into a base latent.
|
||
|
||
`ref_json` is a JSON {name, subfolder, type} ref returned by
|
||
ComfyUI's /upload/image (falls back to treating the string as a bare
|
||
input-dir filename). resize_mode is "keep" (native res) or "mp"
|
||
(scaled to ~target_mp megapixels). In both cases dimensions are
|
||
rounded to a multiple of 16 so any supported VAE (8x or 16x) is
|
||
happy. Returns (latent samples, original pixels) — the pixels so a
|
||
freshly loaded image previews as ITSELF, not its VAE round-trip.
|
||
"""
|
||
import numpy as np
|
||
from PIL import Image, ImageOps
|
||
|
||
# Resolve the image reference to a path.
|
||
name, subfolder, type_ = ref_json, "", "input"
|
||
try:
|
||
ref = json.loads(ref_json)
|
||
name = ref.get("name") or ref.get("filename") or ""
|
||
subfolder = ref.get("subfolder", "") or ""
|
||
type_ = ref.get("type", "input") or "input"
|
||
except (ValueError, TypeError):
|
||
pass
|
||
|
||
if type_ == "output":
|
||
base_dir = folder_paths.get_output_directory()
|
||
elif type_ == "temp":
|
||
base_dir = folder_paths.get_temp_directory()
|
||
else:
|
||
base_dir = folder_paths.get_input_directory()
|
||
path = os.path.normpath(os.path.join(base_dir, subfolder, name))
|
||
if not path.startswith(os.path.normpath(base_dir)):
|
||
raise ValueError("Angelo: invalid loaded-image path")
|
||
if not os.path.exists(path):
|
||
raise ValueError(f"Angelo: loaded image not found: {name}")
|
||
|
||
img = Image.open(path)
|
||
img = ImageOps.exif_transpose(img).convert("RGB")
|
||
w, h = img.size
|
||
|
||
if resize_mode == "mp" and target_mp > 0:
|
||
cur_mp = (w * h) / 1.0e6
|
||
if cur_mp > 0:
|
||
s = math.sqrt(target_mp / cur_mp)
|
||
w = int(round(w * s))
|
||
h = int(round(h * s))
|
||
|
||
# Round down to a multiple of 16 (divisible by both 8x and 16x VAEs).
|
||
w = max(16, (w // 16) * 16)
|
||
h = max(16, (h // 16) * 16)
|
||
if (w, h) != img.size:
|
||
img = img.resize((w, h), Image.LANCZOS)
|
||
|
||
arr = np.array(img).astype(np.float32) / 255.0 # (H, W, 3)
|
||
pixels = torch.from_numpy(arr)[None, ...] # (1, H, W, 3)
|
||
samples = _vae_encode(vae, pixels[:, :, :, :3])
|
||
# Hand back the ORIGINAL pixels (matched to the latent's device) so the
|
||
# load previews the real file. The VAE round-trip is near-lossless on
|
||
# small images — looks like a plain load — but visibly lossy on large
|
||
# ones, which reads as "it edited my image on load".
|
||
return samples, pixels[:, :, :, :3].to(samples.device)
|
||
|
||
|
||
def _pixels_to_preview(image: torch.Tensor):
|
||
"""Save an already-decoded (B, H, W, C) float image batch to the temp
|
||
directory in the same format PreviewImage uses. Returns
|
||
(image_tensor, list_of_image_refs). Split out of _decode_to_preview
|
||
for paths that already hold pixels (the gen-bundle base generation
|
||
previews the GEN VAE's decode, not its edit-VAE round-trip)."""
|
||
previewer = comfy_nodes.PreviewImage()
|
||
ui = previewer.save_images(image, filename_prefix="Angelo_preview")
|
||
return image, ui["ui"]["images"]
|
||
|
||
|
||
def _decode_to_preview(vae, latent_samples: torch.Tensor):
|
||
"""Decode the latent and save to the temp directory in the same
|
||
format PreviewImage uses, so we can return the same ui dict shape.
|
||
|
||
Returns (image_tensor, list_of_image_refs). Each image ref is a
|
||
{filename, subfolder, type} dict.
|
||
"""
|
||
image = _vae_decode(vae, latent_samples) # (B, H, W, C) float in [0, 1]
|
||
return _pixels_to_preview(image)
|
||
|
||
|
||
class AngeloRefine:
|
||
"""Click-to-refine sampler. See module docstring."""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"model": ("MODEL",),
|
||
"positive": ("CONDITIONING",),
|
||
"negative": ("CONDITIONING",),
|
||
"vae": ("VAE",),
|
||
|
||
# ===== Sampler Mode controls (top of native widget area) =====
|
||
# Visible in the node body. When `mode == "Sampler Mode"`, the
|
||
# toolbar is greyed and canvas clicks do nothing — this widget
|
||
# group is the active one, producing the base latent from the
|
||
# incoming latent via comfy.sample.sample. When mode flips to
|
||
# Refinement, sampler_seed_control is auto-forced to "fixed"
|
||
# so subsequent Queue presses don't regenerate the base.
|
||
"mode": (["Sampler Mode", "Edit Mode"], {"default": "Sampler Mode",
|
||
"tooltip": "Sampler Mode: AngeloRefine acts "
|
||
"like a KSampler — generates the "
|
||
"base latent from the incoming "
|
||
"(usually empty) latent. Toolbar "
|
||
"and canvas clicks are inert. "
|
||
"Edit Mode: click / paint / drag "
|
||
"to refine or inpaint the cached "
|
||
"base. Switching to Edit Mode auto-"
|
||
"locks sampler_seed_control to "
|
||
"'fixed' so the base stays stable."}),
|
||
"sampler_denoise": ("FLOAT", {"default": 1.0, "min": 0.05, "max": 1.0, "step": 0.05,
|
||
"tooltip": "[Sampler Mode] Denoise level for the base "
|
||
"generation. 1.0 = generate fully from noise "
|
||
"(KSampler default). Lower = img2img-style from "
|
||
"the incoming latent."}),
|
||
"sampler_seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF,
|
||
"control_after_generate": False,
|
||
"tooltip": "[Sampler Mode] Seed for the base generation. "
|
||
"Controlled by sampler_seed_control after each run."}),
|
||
"sampler_seed_control": (["fixed", "increment", "decrement", "randomize"],
|
||
{"default": "randomize",
|
||
"tooltip": "[Sampler Mode] After-generation seed behaviour. "
|
||
"Defaults to randomize so each base generation gets "
|
||
"a fresh seed. Auto-forced to 'fixed' (locked to the "
|
||
"seed that produced the base) when you switch to Edit "
|
||
"Mode so refine clicks stay stable."}),
|
||
|
||
# ===== Edit Mode controls (driven from toolbar) =====
|
||
# Hidden from the native widget area; the toolbar above the
|
||
# preview canvas drives these. seed_control follows the same
|
||
# fixed/random/increment/decrement pattern as sampler_seed.
|
||
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF,
|
||
"control_after_generate": False,
|
||
"tooltip": "[Edit Mode] Seed for the refine pass. Hidden — "
|
||
"controlled by the Seed input on the toolbar."}),
|
||
"seed_control": (["fixed", "increment", "decrement", "randomize"],
|
||
{"default": "randomize",
|
||
"tooltip": "[Edit Mode] After-click seed behaviour. Hidden — "
|
||
"controlled by the Seed Ctrl dropdown on the toolbar. "
|
||
"Defaults to randomize so each refine click produces a "
|
||
"fresh variation rather than repeating the same result."}),
|
||
"steps": ("INT", {"default": 4, "min": 1, "max": 100,
|
||
"tooltip": "Match the model's expected step count. "
|
||
"FLUX 2 Klein distilled = 4."}),
|
||
"cfg": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 30.0, "step": 0.1,
|
||
"tooltip": "FLUX 2 Klein distilled uses CFG=1 (no negative)."}),
|
||
"sampler_name": (comfy.samplers.KSampler.SAMPLERS, {"default": "euler"}),
|
||
"scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"default": "simple"}),
|
||
"denoise": ("FLOAT", {"default": 0.5, "min": 0.05, "max": 1.0, "step": 0.05,
|
||
"tooltip": "How much noise to add back for the refinement. "
|
||
"0.3 = subtle touch-up, 0.6 = real redo, "
|
||
"0.9+ = essentially regenerate that region."}),
|
||
|
||
"click_radius": ("INT", {"default": 96, "min": 8, "max": 1024, "step": 4,
|
||
"tooltip": "Pixel-space radius of the refinement region. "
|
||
"Updated automatically by the JS click widget."}),
|
||
"feather_radius": ("INT", {"default": 24, "min": 0, "max": 256, "step": 4,
|
||
"tooltip": "Pixel-space gaussian blur applied to the mask "
|
||
"before sampling. Smooths the seam between the "
|
||
"refined region and the preserved surroundings. "
|
||
"Roughly half of click_radius is a good default."}),
|
||
|
||
# JS-driven widgets. click_x/y in pixel space; -1 = no click yet.
|
||
# click_seq increments per click to force ComfyUI to detect a change
|
||
# and re-execute this node (otherwise identical inputs would skip).
|
||
# image_w/h are the actual decoded image dimensions in pixels —
|
||
# set by the JS whenever a fresh preview is loaded into the canvas,
|
||
# so we can compute the correct pixel→latent scale without
|
||
# hardcoding the VAE downscale factor (FLUX 2 is 16, FLUX 1 / SDXL
|
||
# are 8, exotic VAEs vary).
|
||
"click_x": ("INT", {"default": -1, "min": -1, "max": 16384}),
|
||
"click_y": ("INT", {"default": -1, "min": -1, "max": 16384}),
|
||
"click_seq": ("INT", {"default": 0, "min": 0, "max": 0x7FFFFFFF}),
|
||
"image_w": ("INT", {"default": 0, "min": 0, "max": 16384}),
|
||
"image_h": ("INT", {"default": 0, "min": 0, "max": 16384}),
|
||
# Undo bookkeeping: undo_seq increments when the user clicks
|
||
# the Undo button. Python pops the last refined latent off
|
||
# the history stack on each new undo_seq value.
|
||
"undo_seq": ("INT", {"default": 0, "min": 0, "max": 0x7FFFFFFF}),
|
||
|
||
"reset": ("BOOLEAN", {"default": False,
|
||
"tooltip": "Tick + re-queue to discard the cached refined "
|
||
"latent and start over from the incoming latent."}),
|
||
# DEPRECATED as a control — always-on now. Kept declared
|
||
# (not removed) so its slot in widgets_values stays put;
|
||
# deleting it would shift every later widget's position
|
||
# and drift old saved workflows on load. Hidden in the UI;
|
||
# run() ignores the value and always decodes the preview.
|
||
"auto_decode": ("BOOLEAN", {"default": True,
|
||
"tooltip": "(Deprecated — preview always decodes now.)"}),
|
||
|
||
# STEP 2 of the Fine Upscaling re-introduction: JS toggle
|
||
# bar drives these. Both widgets are hidden from the node
|
||
# UI; the green "Fine Upscale" toggle and the small "MP"
|
||
# numeric input on the bar above the preview canvas are
|
||
# the user-facing surface. Python's run() still prints the
|
||
# received values so we can verify the JS bar correctly
|
||
# drives them. SAMPLING IS STILL UNCHANGED — neither value
|
||
# is read by any code path below the print. Step 3 wires
|
||
# the actual crop+upscale+refine.
|
||
"fine_upscaling": ("BOOLEAN", {"default": False,
|
||
"tooltip": "When ON, the painted region is cropped, "
|
||
"bilinear-upscaled in latent space to hit "
|
||
"min_megapixels, refined, downscaled, and "
|
||
"composited back. Gives the model more "
|
||
"effective resolution on small painted "
|
||
"regions. Upscale capped at 8× linear "
|
||
"internally. Set via the Fine Upscale "
|
||
"toggle above the preview."}),
|
||
"min_megapixels": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 4.0, "step": 0.1,
|
||
"tooltip": "Target megapixels for the refine pass when "
|
||
"Fine Upscaling is on. Only used in that mode. "
|
||
"Set via the MP input above the preview."}),
|
||
"max_upscale": ("FLOAT", {"default": 8.0, "min": 1.0, "max": 16.0, "step": 0.5,
|
||
"tooltip": "Hard cap on the linear upscale factor in Fine "
|
||
"Upscaling. 8.0 was the original internal default. "
|
||
"Lower values reduce smoothing artifacts from "
|
||
"extreme upscales at the cost of less effective "
|
||
"resolution gain. Set via the Max input above "
|
||
"the preview."}),
|
||
"resize_method": (_FINE_UPSCALE_RESIZE_METHODS, {"default": "lanczos",
|
||
"tooltip": "Pixel-space upscale method for "
|
||
"Fine Upscale. lanczos is the "
|
||
"default — sharpest detail recovery "
|
||
"for natural images. bilinear is "
|
||
"smooth (good for skin/faces); "
|
||
"bicubic middle ground; nearest-"
|
||
"exact preserves exact sample values; "
|
||
"area/bislerp niche."}),
|
||
"inpainting_mode": (["Refine", "Smart Inpaint", "Smart Guided Inpaint", "Outpaint"], {"default": "Refine",
|
||
"tooltip": "How the painted region is treated.\n\n"
|
||
"Refine — the painted region is partially "
|
||
"denoised from the existing content. Best "
|
||
"for refining what's already there (faces, "
|
||
"hands, textures). Paint/click as normal.\n\n"
|
||
"Smart Inpaint — adds NEW content where you "
|
||
"drag a rectangle. Click+hold one corner, "
|
||
"release at the opposite corner. Locks "
|
||
"denoise=1.0, Fine Upscale=ON, Ctx Pad=0 "
|
||
"(the right defaults for adding new subjects "
|
||
"with an edit model — Klein 9B etc.). "
|
||
"Reference_latents are auto-injected so the "
|
||
"model's edit branch activates.\n\n"
|
||
"Smart Guided Inpaint — no painting or boxes. "
|
||
"Pick a location from the dropdown above the "
|
||
"Area Prompt; it's prepended to your prompt "
|
||
"(e.g. 'In the top left of the image, ...') "
|
||
"and the edit model places the content there "
|
||
"across the whole image. Locks denoise=1.0, "
|
||
"Fine Upscale=OFF, Area Prompt=ON; press "
|
||
"'Generate Guided Edit' to run.\n\n"
|
||
"Outpaint — extend the canvas. Pick a direction "
|
||
"(arrows, or click near an edge of the preview), "
|
||
"review the result, Accept to commit. Accepting "
|
||
"installs the new canvas as a fresh session base "
|
||
"— history resets."}),
|
||
|
||
# Hidden — DOM location dropdown (Smart Guided Inpaint)
|
||
# drives this. Holds a LABEL key from
|
||
# _GUIDED_LOCATION_PREFIXES; run() maps it to the prompt
|
||
# prefix at encode time.
|
||
"guided_location": ("STRING", {"default": "(none)", "multiline": False}),
|
||
|
||
"fine_context_pad": ("INT", {"default": 64, "min": 0, "max": 512, "step": 8,
|
||
"tooltip": "[Fine Upscale] Pixel-space padding around the "
|
||
"painted-shape bbox before cropping. Gives the "
|
||
"model surrounding context (skin, hair, "
|
||
"background) so a tight face mask at high denoise "
|
||
"still produces a coherent face that matches its "
|
||
"surroundings. The painted shape is unchanged — "
|
||
"only the area the model SEES grows. Outside the "
|
||
"painted shape, the surrounding pixels are "
|
||
"preserved (not refined). Larger pad = more "
|
||
"context + less effective resolution on the "
|
||
"painted area (bump MP to compensate)."}),
|
||
|
||
"persistent_mask": ("BOOLEAN", {"default": False,
|
||
"tooltip": "When ON, the last mask used (click point or paint "
|
||
"stroke) is held in the node. Pressing the standard "
|
||
"ComfyUI Queue button re-runs that same mask + a fresh "
|
||
"seed on the LATEST result, so each press builds further "
|
||
"on that region — use it to gradually morph an area over "
|
||
"several presses while the rest of the image stays "
|
||
"unchanged. (To re-roll the same edit on the ORIGINAL "
|
||
"image instead of building on it, use the Re-roll "
|
||
"button.) Toggled via the Persistent Mask button above "
|
||
"the preview."}),
|
||
|
||
"paint_mode": ("BOOLEAN", {"default": False,
|
||
"tooltip": "When ON, hold + drag on the preview paints a "
|
||
"freeform brush stroke (each dragged point is the "
|
||
"centre of a circle of click_radius; the union is "
|
||
"the refine mask). Release to submit. Single clicks "
|
||
"in paint mode work as one-point strokes. When OFF, "
|
||
"clicks behave as before (single-circle refine)."}),
|
||
|
||
# Hidden — JS-set JSON list of [x_pixel, y_pixel] points
|
||
# captured during a paint drag. Empty when no paint stroke
|
||
# is pending (e.g. the user just single-clicked instead).
|
||
"stroke_points": ("STRING", {"default": "", "multiline": False}),
|
||
|
||
# Hidden — JS-set JSON list of [x1, y1, x2, y2] rectangles
|
||
# in image-pixel coords, captured during Smart Inpaint
|
||
# rectangle drags. The backend uses only the most recent
|
||
# entry as the active mask.
|
||
"rect_points": ("STRING", {"default": "", "multiline": False}),
|
||
|
||
"area_prompt": ("BOOLEAN", {"default": False,
|
||
"tooltip": "When ON, refinements encode the Area Prompt text "
|
||
"(typed in the box below the canvas) with the "
|
||
"connected CLIP and use it instead of the main "
|
||
"positive/negative — useful for reshaping a region "
|
||
"with a different prompt (e.g. main prompt = wide "
|
||
"shot, area prompt = \"detailed face, photorealistic "
|
||
"eyes\"). The click-and-mask behaviour is unchanged; "
|
||
"only the prompt differs. If CLIP isn't connected or "
|
||
"the area text is empty, the main positive/negative "
|
||
"are used. Forced ON in Smart Inpaint."}),
|
||
|
||
# Hidden — DOM text box below the canvas drives these.
|
||
# The Area Prompt input has a Pos/Neg toggle that decides
|
||
# which of these two the textarea is editing. Encoded
|
||
# with the connected CLIP at run time when area_prompt is
|
||
# on. Negative is usually unused (Klein / CFG=1) but kept
|
||
# for non-distilled models.
|
||
"area_text_positive": ("STRING", {"default": "", "multiline": True}),
|
||
"area_text_negative": ("STRING", {"default": "", "multiline": True}),
|
||
|
||
# Hidden — the Load Image button drives these. loaded_image
|
||
# is a JSON ref {name,subfolder,type} from /upload/image;
|
||
# loaded_image_seq bumps on each load so run() knows a new
|
||
# image arrived; resize_mode/target_mp come from the popup.
|
||
"loaded_image": ("STRING", {"default": "", "multiline": False}),
|
||
"loaded_image_seq": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFF}),
|
||
"loaded_resize_mode": (["keep", "mp"], {"default": "keep"}),
|
||
"loaded_target_mp": ("FLOAT", {"default": 1.5, "min": 0.1, "max": 8.0, "step": 0.1}),
|
||
|
||
# Hidden — the Detect (SAM 3 / YOLO) flow drives this. A JSON
|
||
# list of silhouette polygons (image-pixel coords) for the
|
||
# confirmed detection; in Refine mode it becomes the mask.
|
||
# Cleared by the JS after the confirm run.
|
||
"seg_polygon": ("STRING", {"default": "", "multiline": False}),
|
||
|
||
# Hidden — the Re-roll button drives this. Bumps on each
|
||
# press; run() pops the most recent edit to expose its
|
||
# pre-edit base, rebuilds the SAME mask from the widgets
|
||
# above, and re-samples with a fresh seed — swapping the
|
||
# new variation in place of the last attempt. Declared LAST
|
||
# so older saved workflows (which lack it) don't shift their
|
||
# positional widgets_values; it just defaults to 0.
|
||
"reroll_seq": ("INT", {"default": 0, "min": 0, "max": 0x7FFFFFFF}),
|
||
|
||
# Hidden — the Detect Shift/Alt touch-up brush drives this. A
|
||
# base64-PNG mask at image resolution (white = masked) that, in
|
||
# Refine, takes priority over seg_polygon. Lets the user add to
|
||
# or subtract from (incl. holes) a SAM mask before committing.
|
||
# Declared LAST so older saved workflows don't shift their
|
||
# positional widgets_values; defaults to "".
|
||
"seg_mask_png": ("STRING", {"default": "", "multiline": False}),
|
||
|
||
# Redo (#6): the Redo button / Ctrl-Y bumps this. Python pushes
|
||
# back onto the history stack the entry that Undo most recently
|
||
# popped off (held on a per-node redo stack). Declared LAST so
|
||
# older saved workflows don't shift their positional
|
||
# widgets_values; defaults to 0.
|
||
"redo_seq": ("INT", {"default": 0, "min": 0, "max": 0x7FFFFFFF}),
|
||
|
||
# Restore brush (#12): when ON, clicks / paint strokes / detect
|
||
# masks RESTORE the painted region back to the session's base
|
||
# image via a feathered latent blend — no sampling at all, so
|
||
# it's effectively instant. The Lightroom "erase part of an
|
||
# edit" gesture: refine a region, then brush back the bits of
|
||
# the original you wanted kept (the face inside the helmet).
|
||
# Honoured in Refine mode only; the Smart modes ignore it.
|
||
# Declared LAST so older saved workflows don't shift their
|
||
# positional widgets_values; defaults to False.
|
||
"restore_mode": ("BOOLEAN", {"default": False,
|
||
"tooltip": "When ON, clicks and paint strokes restore "
|
||
"the painted region back to the session base "
|
||
"image (a feathered latent blend — no "
|
||
"sampling, instant). Refine mode only. "
|
||
"Toggled via the Restore button on the "
|
||
"toolbar."}),
|
||
|
||
# Prompt slots (#12): JSON persistence for the Area Prompt
|
||
# slot strip ({active, slots: [{pos, neg} × 6]}). Pure UI
|
||
# state — Python never reads it; the JS mirrors the active
|
||
# slot's text into area_text_positive/negative, which remain
|
||
# the encode source of truth. Declared LAST (see above).
|
||
"area_prompt_slots": ("STRING", {"default": "", "multiline": False}),
|
||
|
||
# Vary ×4: the Vary button bumps vary_seq. run() re-runs the
|
||
# most recent edit's mask from its PRE-edit base four times
|
||
# with different seeds, stashes the candidates in state
|
||
# WITHOUT touching history, and ships one preview ref per
|
||
# candidate (Angelo_vary_previews) to the JS chooser overlay.
|
||
# Clicking a candidate sets vary_pick + bumps vary_pick_seq;
|
||
# run() then swaps the chosen candidate in for the last
|
||
# attempt — a pure restore, no sampling, mirroring Re-roll's
|
||
# replace semantics. Cancelling the chooser needs no Python
|
||
# round-trip (the stash is invalidated by the next edit).
|
||
# Declared LAST (see redo_seq note).
|
||
"vary_seq": ("INT", {"default": 0, "min": 0, "max": 0x7FFFFFFF}),
|
||
"vary_pick": ("INT", {"default": -1, "min": -1, "max": 15}),
|
||
"vary_pick_seq": ("INT", {"default": 0, "min": 0, "max": 0x7FFFFFFF}),
|
||
|
||
# Outpaint: the JS arrows / edge-click bump outpaint_seq with
|
||
# a direction + amount + overlap. run() pads the canvas, fills
|
||
# the new strip at denoise 1.0, and STASHES the result for
|
||
# review (Angelo_outpaint_preview → the JS Accept overlay) —
|
||
# history is untouched. Accepting bumps outpaint_accept_seq;
|
||
# run() then installs the new canvas as a FRESH session base
|
||
# (Load-Image semantics: history resets — a documented fact of
|
||
# outpainting, which is why nothing commits without review).
|
||
# Cancel needs no Python round-trip. Declared LAST.
|
||
"outpaint_seq": ("INT", {"default": 0, "min": 0, "max": 0x7FFFFFFF}),
|
||
"outpaint_dir": ("STRING", {"default": "right", "multiline": False}),
|
||
"outpaint_amount": ("INT", {"default": 256, "min": 16, "max": 2048, "step": 16,
|
||
"tooltip": "Outpaint: how many pixels to extend the "
|
||
"canvas by (snapped to /16). Set via the "
|
||
"Amount input on the Outpaint row."}),
|
||
"outpaint_overlap": ("INT", {"default": 64, "min": 0, "max": 512, "step": 8,
|
||
"tooltip": "Outpaint: feathered band reaching this many "
|
||
"pixels INTO the existing image, redrawn so "
|
||
"the seam blends instead of butting. Set via "
|
||
"the Overlap input on the Outpaint row."}),
|
||
"outpaint_accept_seq": ("INT", {"default": 0, "min": 0, "max": 0x7FFFFFFF}),
|
||
|
||
# Outpaint protect brush: JSON list of [x, y, r] circles
|
||
# (old-image pixel coords) painted on the canvas in Outpaint
|
||
# mode. Subtracted from the overlap band so marked content
|
||
# (a car at the frame edge) stays frozen while the rest of
|
||
# the band blends. Declared LAST.
|
||
"outpaint_protect": ("STRING", {"default": "", "multiline": False}),
|
||
|
||
# Where the outpaint instruction sits relative to the user's
|
||
# Area Prompt text: "prepend" = instruction first (default),
|
||
# "append" = the user's text first, instruction after. Some
|
||
# models weight the head of the prompt more heavily, so the
|
||
# order can change reliability — the JS shows a live preview
|
||
# of the combined prompt so there's no guessing. The exact
|
||
# composition here MUST stay in lockstep with the JS preview
|
||
# (syncOutpaintPromptPreview). Declared LAST.
|
||
"outpaint_instruction_pos": (["prepend", "append"], {"default": "prepend"}),
|
||
|
||
# Reference toggle: ON/OFF for the anchored-refine feature;
|
||
# reference_strength (declared further down) is the 0–1
|
||
# blend it applies when ON. Manual refines only — Quick
|
||
# Photo Refine runs its own fixed recipe and ignores both.
|
||
"refine_reference": ("BOOLEAN", {"default": False,
|
||
"tooltip": "Anchor refines to the current image "
|
||
"(edit models). The strength box "
|
||
"beside the toolbar button sets the "
|
||
"blend. Toggled via the Reference "
|
||
"button."}),
|
||
|
||
# Quick Photo Refine: the ✨ button bumps this. One-shot
|
||
# restoration pass — whole canvas, with the button's OWN
|
||
# fixed settings (_QUICK_REFINE_PROMPT / _DENOISE / _REF).
|
||
# A magic button: no toolbar box affects it except the
|
||
# seed. Pushes a normal history entry so Undo covers it.
|
||
# Declared LAST.
|
||
"quick_refine_seq": ("INT", {"default": 0, "min": 0, "max": 0x7FFFFFFF}),
|
||
|
||
# Reference strength (Refine + Quick Photo Refine): a TRUE
|
||
# 0–1 blend between the reference-anchored prediction and
|
||
# the plain one — see _apply_reference. 0 = no reference
|
||
# (classic refine), 1 = fully anchored. Declared LAST.
|
||
"reference_strength": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.05,
|
||
"tooltip": "How strongly the current image anchors "
|
||
"a refine — a true blend between the "
|
||
"anchored and free predictions. 0 = "
|
||
"off, 1 = fully anchored. Set via the "
|
||
"Ref box on the toolbar."}),
|
||
|
||
# ⬆ 2× Pixel: the button bumps this. A PURE pixel-space
|
||
# lanczos 2× + re-encode — no AI, deterministic — committed
|
||
# directly as a fresh session base (dimension change =
|
||
# Load-Image semantics, history resets). The AI step is the
|
||
# user's next move (e.g. ✨, which auto-tiles when large).
|
||
"upscale_seq": ("INT", {"default": 0, "min": 0, "max": 0x7FFFFFFF}),
|
||
|
||
# ✨ prompt selector — which preset (or the Area Prompt box)
|
||
# drives Quick Photo Refine. Declared LAST.
|
||
"quick_prompt_mode": (_QUICK_REFINE_PROMPT_MODES,
|
||
{"default": "Identity + Quality"}),
|
||
|
||
# ⬇ Shrink Image: the button bumps shrink_seq with the chosen
|
||
# shrink_scale (0–1). A PURE pixel-space AREA-resample
|
||
# downscale + re-encode — no AI, deterministic — committed
|
||
# directly as a fresh session base (dimension change =
|
||
# Load-Image semantics, history resets). Declared LAST.
|
||
"shrink_seq": ("INT", {"default": 0, "min": 0, "max": 0x7FFFFFFF}),
|
||
"shrink_scale": ("FLOAT", {"default": 0.5, "min": 0.05, "max": 0.95, "step": 0.05}),
|
||
|
||
# ✨ Lite mode — the toggle beside the prompt selector. ON =
|
||
# the regular Quick Photo Refine recipe at a gentler denoise
|
||
# (_QUICK_REFINE_LITE_DENOISE); everything else identical.
|
||
# Declared LAST.
|
||
"quick_lite": ("BOOLEAN", {"default": False}),
|
||
|
||
# 🩹 Remove brush — the toggle beside Restore. ON = clicks /
|
||
# paint strokes / Detect masks ERASE the painted object and
|
||
# fill the hole with background, using the edit branch (blank
|
||
# masked region + reference, fixed removal instruction, denoise
|
||
# 1.0). Edit models only; Refine mode only. Declared LAST so
|
||
# older saved workflows don't shift their positional
|
||
# widgets_values; defaults to False.
|
||
"remove_mode": ("BOOLEAN", {"default": False,
|
||
"tooltip": "When ON, clicks / paint / Detect masks "
|
||
"REMOVE the painted object: the hole is "
|
||
"filled with surrounding background, a "
|
||
"removal pass reconstructs it, then a "
|
||
"neutral refine cleans it up (edit models "
|
||
"only). Refine mode only; toggled via the "
|
||
"Remove button on the toolbar."}),
|
||
|
||
},
|
||
"optional": {
|
||
# CLIP / text encoder for the Area Prompt. Optional —
|
||
# without it the area text is ignored and the main
|
||
# positive/negative are used.
|
||
"clip": ("CLIP",),
|
||
# Base latent. OPTIONAL now: if the Load Image button is
|
||
# used, the loaded photo becomes the base and no latent
|
||
# input is needed. Sampler Mode still needs one (it defines
|
||
# the output dimensions for a fresh generation).
|
||
"latent": ("LATENT",),
|
||
|
||
# Optional sampler-quintet overrides for users who want a
|
||
# single source of truth for these values across the
|
||
# workflow (issue #25). Pipe-pattern: a single ANGELO_OVERRIDES
|
||
# slot, fed by the companion `Angelo — Overrides` node which
|
||
# bundles steps / cfg / sampler / scheduler into one dict.
|
||
# If unwired, the toolbar widget values flow through as
|
||
# before. Picked over per-param forceInput slots because
|
||
# forceInput on optional inputs proved unreliable across
|
||
# ComfyUI versions for delivering wired primitive values.
|
||
"overrides": ("ANGELO_OVERRIDES", {"tooltip": "Optional. Wire from an "
|
||
"'Angelo — Overrides' node to "
|
||
"drive steps / cfg / sampler / "
|
||
"scheduler from your workflow "
|
||
"instead of the toolbar — and/or "
|
||
"to supply a dedicated GENERATION "
|
||
"model (gen bundle): Sampler Mode "
|
||
"generates the base with it, then "
|
||
"the main model handles all edits."}),
|
||
},
|
||
"hidden": {
|
||
"unique_id": "UNIQUE_ID",
|
||
},
|
||
}
|
||
|
||
RETURN_TYPES = ("IMAGE", "LATENT", "IMAGE")
|
||
RETURN_NAMES = ("image", "latent", "source_image")
|
||
FUNCTION = "run"
|
||
OUTPUT_NODE = True
|
||
CATEGORY = "sampling/Angelo"
|
||
DESCRIPTION = (
|
||
"Click-to-refine sampler. Click a region in the preview to do "
|
||
"extra denoising on just that area while the rest of the latent "
|
||
"is preserved. Repeated clicks layer refinements on top of each other."
|
||
)
|
||
|
||
def run(
|
||
self,
|
||
model,
|
||
positive,
|
||
negative,
|
||
vae,
|
||
mode,
|
||
sampler_denoise,
|
||
sampler_seed,
|
||
sampler_seed_control,
|
||
seed,
|
||
seed_control,
|
||
steps,
|
||
cfg,
|
||
sampler_name,
|
||
scheduler,
|
||
denoise,
|
||
click_radius,
|
||
feather_radius,
|
||
click_x,
|
||
click_y,
|
||
click_seq,
|
||
image_w,
|
||
image_h,
|
||
undo_seq,
|
||
reset,
|
||
auto_decode,
|
||
fine_upscaling,
|
||
min_megapixels,
|
||
max_upscale,
|
||
resize_method,
|
||
inpainting_mode,
|
||
fine_context_pad,
|
||
persistent_mask,
|
||
paint_mode,
|
||
stroke_points,
|
||
rect_points,
|
||
area_prompt,
|
||
guided_location="(none)",
|
||
area_text_positive="",
|
||
area_text_negative="",
|
||
loaded_image="",
|
||
loaded_image_seq=0,
|
||
loaded_resize_mode="keep",
|
||
loaded_target_mp=1.5,
|
||
seg_polygon="",
|
||
reroll_seq=0,
|
||
seg_mask_png="",
|
||
redo_seq=0,
|
||
restore_mode=False,
|
||
area_prompt_slots="",
|
||
vary_seq=0,
|
||
vary_pick=-1,
|
||
vary_pick_seq=0,
|
||
outpaint_seq=0,
|
||
outpaint_dir="right",
|
||
outpaint_amount=256,
|
||
outpaint_overlap=64,
|
||
outpaint_accept_seq=0,
|
||
outpaint_protect="",
|
||
outpaint_instruction_pos="prepend",
|
||
refine_reference=False,
|
||
quick_refine_seq=0,
|
||
reference_strength=0.0,
|
||
upscale_seq=0,
|
||
shrink_seq=0,
|
||
shrink_scale=0.5,
|
||
quick_lite=False,
|
||
quick_prompt_mode="Identity + Quality",
|
||
remove_mode=False,
|
||
latent=None,
|
||
clip=None,
|
||
overrides=None,
|
||
unique_id=None,
|
||
):
|
||
# Optional upstream overrides: if an ANGELO_OVERRIDES bundle is
|
||
# wired in, its non-None entries beat the toolbar widget values.
|
||
# Kept here at the top so every downstream code path sees the
|
||
# effective value without each one having to know about the override.
|
||
# Carries sampler-config (#25), display flags (#21
|
||
# disable_live_preview), and the custom-sampler trio (#8
|
||
# guider/sampler/sigmas — all-or-nothing, applied inside _do_sample).
|
||
disable_live_preview = False
|
||
ov_guider = None
|
||
ov_sampler = None
|
||
ov_sigmas = None
|
||
ov_gen_model = None
|
||
ov_gen_positive = None
|
||
ov_gen_negative = None
|
||
ov_gen_vae = None
|
||
ov_gen_steps = 25
|
||
ov_gen_cfg = 5.0
|
||
ov_gen_sampler_name = "euler"
|
||
ov_gen_scheduler = "normal"
|
||
if isinstance(overrides, dict):
|
||
if overrides.get("steps") is not None:
|
||
steps = overrides["steps"]
|
||
if overrides.get("cfg") is not None:
|
||
cfg = overrides["cfg"]
|
||
if overrides.get("sampler_name") is not None:
|
||
sampler_name = overrides["sampler_name"]
|
||
if overrides.get("scheduler") is not None:
|
||
scheduler = overrides["scheduler"]
|
||
disable_live_preview = bool(overrides.get("disable_live_preview"))
|
||
ov_guider = overrides.get("guider")
|
||
ov_sampler = overrides.get("sampler")
|
||
ov_sigmas = overrides.get("sigmas")
|
||
# Gen bundle: a second model stack used ONLY by Sampler Mode's
|
||
# base generation (see the Sampler Mode branch). Edit passes
|
||
# always run on the main edit model regardless of this bundle.
|
||
ov_gen_model = overrides.get("gen_model")
|
||
ov_gen_positive = overrides.get("gen_positive")
|
||
ov_gen_negative = overrides.get("gen_negative")
|
||
ov_gen_vae = overrides.get("gen_vae")
|
||
# Explicit None checks, NOT `or` fallbacks: gen_cfg 0.0 is a
|
||
# legal widget value and falsy — `or` would silently sample at
|
||
# the default instead.
|
||
if overrides.get("gen_steps") is not None:
|
||
ov_gen_steps = int(overrides["gen_steps"])
|
||
if overrides.get("gen_cfg") is not None:
|
||
ov_gen_cfg = float(overrides["gen_cfg"])
|
||
if overrides.get("gen_sampler_name") is not None:
|
||
ov_gen_sampler_name = overrides["gen_sampler_name"]
|
||
if overrides.get("gen_scheduler") is not None:
|
||
ov_gen_scheduler = overrides["gen_scheduler"]
|
||
|
||
node_id = str(unique_id)
|
||
state = _STATE.get(node_id)
|
||
|
||
# ===== Base latent selection =====
|
||
# While an image is LOADED (loaded_image non-empty), it owns the
|
||
# base and the wired `latent` input is ignored — until the user
|
||
# hits Unload (which clears loaded_image). This stops the wired
|
||
# latent from re-winning after a load (which made undo snap back
|
||
# to the latent image). Priority:
|
||
# 1. a freshly loaded image (encode it, force reset)
|
||
# 2. an already-loaded image still active → keep the cached base
|
||
# 3. the wired `latent` input
|
||
# 4. the cached base (no latent, no load)
|
||
loaded_ref = str(loaded_image).strip()
|
||
loaded_active = bool(loaded_ref)
|
||
loaded_seq = int(loaded_image_seq)
|
||
new_loaded = loaded_active and (
|
||
state is None or state.get("loaded_seq") != loaded_seq
|
||
)
|
||
forced_base = None
|
||
forced_pixels = None
|
||
if new_loaded:
|
||
forced_base, forced_pixels = _encode_loaded_image(
|
||
vae, loaded_ref, str(loaded_resize_mode), float(loaded_target_mp)
|
||
)
|
||
|
||
# base_from_wired_latent: only the wired-`latent` path carries a
|
||
# meaningful fingerprint for upstream-change detection. The cache /
|
||
# loaded-image paths take incoming from history[0], which MUTATES as
|
||
# the undo stack is capped (the original base is evicted after
|
||
# _HISTORY_CAP refines), so a fingerprint comparison there is bogus
|
||
# and would spuriously reset mid-session. See the need_reset note.
|
||
# Cached-base fallbacks prefer source_latent over history[0]:
|
||
# once _HISTORY_CAP evicts the true base, history[0] is a mid-
|
||
# session edit — a Reset would land on it AND overwrite the
|
||
# session base (Restore anchor, compare image, source_image
|
||
# output) with it, unrecoverably.
|
||
def _cached_base(st):
|
||
src = st.get("source_latent")
|
||
if src is not None:
|
||
return src
|
||
h0 = st["history"][0]
|
||
return h0[0] if isinstance(h0, tuple) else h0
|
||
|
||
base_from_wired_latent = False
|
||
if forced_base is not None:
|
||
incoming = forced_base
|
||
elif loaded_active and state is not None and state.get("history"):
|
||
# Loaded image still active — keep its base, ignore `latent`.
|
||
incoming = _cached_base(state)
|
||
elif latent is not None:
|
||
incoming = latent["samples"]
|
||
base_from_wired_latent = True
|
||
elif state is not None and state.get("history"):
|
||
incoming = _cached_base(state)
|
||
else:
|
||
raise ValueError(
|
||
"Angelo: no base image — connect a `latent` input or use the "
|
||
"Load Image button."
|
||
)
|
||
|
||
# Normalise the base latent to the dimensionality the MODEL expects.
|
||
# ComfyUI's stock KSampler runs the latent through
|
||
# fix_empty_latent_channels before sampling; Angelo calls
|
||
# comfy.sample.sample directly, so it must do this itself. The
|
||
# load-bearing case is video/temporal VAEs (Qwen Image Edit, Wan):
|
||
# their diffusion model wants a 5D latent [B, C, T, H, W], and a 4D
|
||
# [B, C, H, W] base (e.g. from a plain EmptyLatentImage, or any path
|
||
# that didn't go through a Qwen-aware latent node) makes process_img
|
||
# fail with "not enough values to unpack (expected 5, got 4)". This
|
||
# unsqueezes the temporal axis (and channel-pads an empty latent) for
|
||
# those models; for ordinary 4D models (FLUX/SDXL/SD) it is a no-op,
|
||
# and an already-5D latent is returned unchanged. Done once here so
|
||
# every downstream path — Sampler Mode, the Edit-Mode history seed,
|
||
# the fingerprint, and the refine round-trips — sees the right shape.
|
||
# incoming_pre_fix: kept for the gen-bundle path, which must run
|
||
# fix_empty_latent_channels against the GEN model instead (its
|
||
# latent channel count / dimensionality can differ from the edit
|
||
# model's). The fingerprint below stays EDIT-model-fixed so Edit
|
||
# Mode's next-run comparison sees the same value.
|
||
incoming_pre_fix = incoming
|
||
incoming = comfy.sample.fix_empty_latent_channels(model, incoming)
|
||
incoming_fp = _latent_fingerprint(incoming)
|
||
|
||
# ===== Smart Inpaint locks =====
|
||
# The whole point of Smart Inpaint as a mode is to opinionate
|
||
# the params that matter for "add new content in a rectangle"
|
||
# workflows. Override the widget values up front so every code
|
||
# path downstream sees the locked values regardless of what
|
||
# the user set the toolbar to.
|
||
# area_prompt ON makes Smart Inpaint encode the Area Prompt text
|
||
# (typed below the canvas) with the connected CLIP and use it — a
|
||
# separate "what to insert" prompt, kept independent of the main scene
|
||
# prompt. While area_prompt is on the refine uses the Area text ONLY
|
||
# (empty Area text → empty conditioning), never the main positive — see
|
||
# the area-conditioning block further down in run().
|
||
if inpainting_mode == "Smart Inpaint":
|
||
denoise = 1.0
|
||
fine_context_pad = 0
|
||
fine_upscaling = True
|
||
area_prompt = True
|
||
# feather_radius is left under user control — a soft edge can
|
||
# help blend the inserted content into the surroundings.
|
||
elif inpainting_mode == "Smart Guided Inpaint":
|
||
# No painting / boxes — the whole image is edited and the
|
||
# location dropdown supplies a prompt prefix that tells the
|
||
# edit model where to place the content. No region to crop
|
||
# (Fine Upscale OFF), no mask edge to feather, and no mask to
|
||
# persist (Persistent Mask is meaningless here).
|
||
denoise = 1.0
|
||
fine_context_pad = 0
|
||
fine_upscaling = False
|
||
area_prompt = True
|
||
feather_radius = 0
|
||
persistent_mask = False
|
||
elif inpainting_mode == "Outpaint":
|
||
# Canvas extension, not a masked edit — there's no held mask to
|
||
# re-run, so the Persistent Mask queue hook must not fire. The
|
||
# other edit params aren't read on the outpaint path (denoise is
|
||
# fixed at 1.0 inside it; the JS dims their controls).
|
||
persistent_mask = False
|
||
|
||
# ===== Sampler Mode branch =====
|
||
# Acts like a KSampler: take the incoming latent (typically empty),
|
||
# run a fresh denoise pass with sampler_seed + sampler_denoise, cache
|
||
# the result as the new base. All toolbar / canvas / refine logic is
|
||
# skipped — those are Edit Mode concerns.
|
||
if mode == "Sampler Mode":
|
||
disable_pbar = not comfy.utils.PROGRESS_BAR_ENABLED
|
||
new_pixels = None
|
||
gen_wired = (ov_gen_model is not None or ov_gen_positive is not None
|
||
or ov_gen_vae is not None or ov_gen_negative is not None)
|
||
if gen_wired:
|
||
# ===== Gen-bundle path =====
|
||
# Generate the base with a DEDICATED generation model, then
|
||
# cross the VAE boundary into the edit model's latent space:
|
||
# gen sample → gen-VAE decode → edit-VAE encode. Everything
|
||
# after this (history, refines, outpaint) runs on the edit
|
||
# model exactly as if the pixels had come from Load Image.
|
||
# Raise on partial wiring rather than silently falling back
|
||
# — a user who wired gen_model clearly intended this path.
|
||
if ov_gen_model is None or ov_gen_positive is None or ov_gen_vae is None:
|
||
raise ValueError(
|
||
"Angelo: the Overrides gen bundle needs gen_model + "
|
||
"gen_positive + gen_vae all wired (gen_negative is "
|
||
"optional). Wire the missing input(s) or disconnect "
|
||
"the bundle."
|
||
)
|
||
if base_from_wired_latent:
|
||
# The wired latent defines the output dimensions. It must
|
||
# be in the GEN model's latent space (typically an
|
||
# EmptyLatentImage sized for the GEN VAE's ratio) — fix
|
||
# channels for the GEN model, then sanity-check the shape
|
||
# so a wrong-space latent fails HERE with a clear message
|
||
# instead of deep inside the gen UNet with a conv error.
|
||
gen_in = comfy.sample.fix_empty_latent_channels(
|
||
ov_gen_model, incoming_pre_fix
|
||
)
|
||
try:
|
||
gen_lf = ov_gen_model.get_model_object("latent_format")
|
||
gen_lf_channels = int(gen_lf.latent_channels)
|
||
gen_lf_5d = getattr(gen_lf, "latent_dimensions", 2) == 3
|
||
except Exception:
|
||
gen_lf_channels = None
|
||
gen_lf_5d = None
|
||
if gen_lf_5d is False and gen_in.ndim == 5:
|
||
# 5D latent (Qwen/Wan-native empty latent for the EDIT
|
||
# model) into a 4D-latent gen model: drop a singleton
|
||
# frame axis; anything else is a real mismatch.
|
||
if gen_in.shape[2] == 1:
|
||
gen_in = gen_in.squeeze(2)
|
||
else:
|
||
raise ValueError(
|
||
"Angelo: the wired latent is a multi-frame video "
|
||
"latent — gen_model is an image model and can't "
|
||
"generate from it. Wire an empty latent sized "
|
||
"for the GEN model instead."
|
||
)
|
||
if gen_lf_channels is not None and gen_in.shape[1] != gen_lf_channels:
|
||
raise ValueError(
|
||
f"Angelo: the wired latent has {gen_in.shape[1]} "
|
||
f"channels but gen_model expects {gen_lf_channels} — "
|
||
"it isn't in the gen model's latent space. Wire an "
|
||
"empty latent made for the GEN model (a plain "
|
||
"EmptyLatentImage for SD-class models). Note the "
|
||
"latent also sets the output size via the GEN "
|
||
"VAE's ratio, not the edit VAE's."
|
||
)
|
||
else:
|
||
# No wired latent (cached base / loaded image): derive the
|
||
# gen-space latent by encoding the base PIXELS with the
|
||
# gen VAE. At denoise 1.0 only the shape matters; below
|
||
# 1.0 this is a true img2img on the current base.
|
||
base_px = forced_pixels
|
||
if base_px is None and state is not None:
|
||
base_px = state.get("source_pixels")
|
||
if base_px is None:
|
||
base_px = _vae_decode(vae, incoming)
|
||
gen_in = _vae_encode(ov_gen_vae, base_px[:, :, :, :3])
|
||
# Announce the generation resolution: an empty latent authored
|
||
# for the EDIT VAE's ratio silently generates at the wrong
|
||
# size with a different-ratio gen VAE (64x64 latent = 1024px
|
||
# at 16x but 512px at 8x) — undetectable from the latent
|
||
# alone, so at least make it visible in the console.
|
||
try:
|
||
_gr = int(ov_gen_vae.spacial_compression_decode())
|
||
except Exception:
|
||
_gr = int(getattr(ov_gen_vae, "downscale_ratio", 8) or 8)
|
||
print(f"[Angelo] gen bundle: generating "
|
||
f"{gen_in.shape[-1] * _gr}x{gen_in.shape[-2] * _gr} "
|
||
f"with the gen model")
|
||
# #21: same live-preview opt-out as the standard path.
|
||
callback = None if disable_live_preview else latent_preview.prepare_callback(
|
||
ov_gen_model, ov_gen_steps
|
||
)
|
||
noise = comfy.sample.prepare_noise(gen_in, sampler_seed, None)
|
||
gen_negative_eff = (ov_gen_negative if ov_gen_negative is not None
|
||
else _zero_out_conditioning(ov_gen_positive))
|
||
# Deliberately NOT passing the guider trio: a wired GUIDER
|
||
# wraps the EDIT model's conds; it can't drive the gen model.
|
||
gen_latent = _do_sample(
|
||
guider=None, sampler=None, sigmas=None,
|
||
model=ov_gen_model, noise=noise,
|
||
steps=ov_gen_steps, cfg=ov_gen_cfg,
|
||
sampler_name=ov_gen_sampler_name, scheduler=ov_gen_scheduler,
|
||
positive=ov_gen_positive, negative=gen_negative_eff,
|
||
source_latent=gen_in,
|
||
denoise=sampler_denoise,
|
||
callback=callback,
|
||
disable_pbar=disable_pbar,
|
||
seed=sampler_seed,
|
||
)
|
||
gen_pixels = _vae_decode(ov_gen_vae, gen_latent)[:, :, :, :3]
|
||
# Crop to a multiple of 16 so any edit VAE (8x or 16x) is
|
||
# happy — same rule as _encode_loaded_image. An 8x gen VAE
|
||
# can emit dims the 16x edit VAE can't take.
|
||
h16 = max(16, (gen_pixels.shape[1] // 16) * 16)
|
||
w16 = max(16, (gen_pixels.shape[2] // 16) * 16)
|
||
if (h16, w16) != (gen_pixels.shape[1], gen_pixels.shape[2]):
|
||
gen_pixels = gen_pixels[:, :h16, :w16, :]
|
||
new_latent = _vae_encode(vae, gen_pixels)
|
||
# Preview + history pixels are the GEN VAE's decode — what
|
||
# the generation model actually produced — not the edit-VAE
|
||
# round-trip of it.
|
||
new_pixels = gen_pixels.to(new_latent.device)
|
||
else:
|
||
# #21: skip the live-preview callback when the user has
|
||
# disabled it via Overrides — keeps ComfyUI's global preview
|
||
# on for other samplers but stops it squashing Angelo's editor.
|
||
callback = None if disable_live_preview else latent_preview.prepare_callback(model, steps)
|
||
noise = comfy.sample.prepare_noise(incoming, sampler_seed, None)
|
||
# #8: _do_sample dispatches to the custom guider path when
|
||
# ov_guider/ov_sampler/ov_sigmas are all wired, otherwise
|
||
# falls back to the standard comfy.sample.sample(...) call.
|
||
new_latent = _do_sample(
|
||
guider=ov_guider, sampler=ov_sampler, sigmas=ov_sigmas,
|
||
model=model, noise=noise,
|
||
steps=steps, cfg=cfg, sampler_name=sampler_name, scheduler=scheduler,
|
||
positive=positive, negative=negative,
|
||
source_latent=incoming,
|
||
denoise=sampler_denoise,
|
||
callback=callback,
|
||
disable_pbar=disable_pbar,
|
||
seed=sampler_seed,
|
||
)
|
||
# Replace the cache with the freshly-sampled base. Drops the
|
||
# undo history (it's irrelevant — we have a brand-new image).
|
||
#
|
||
# CRITICAL: store the fingerprint of the INCOMING latent, not
|
||
# the freshly-generated new_latent. The fingerprint is the
|
||
# "did upstream change?" signal used by Edit Mode's
|
||
# auto-reset check. If we stored new_latent's fingerprint,
|
||
# Edit Mode would always see (cached_fp != incoming_fp)
|
||
# → think upstream changed → wipe cache → refine the empty
|
||
# latent. Storing incoming_fp means Refinement only resets
|
||
# when the user actually rewires upstream.
|
||
#
|
||
# sampler_seed_at_run = the seed Python used. Sent back to JS
|
||
# via the ui message so JS can (a) apply after-gen control
|
||
# next, and (b) restore this value if the user later switches
|
||
# the control to "fixed".
|
||
_STATE[node_id] = {
|
||
"history": [(new_latent, new_pixels)],
|
||
"click_seq": click_seq,
|
||
"undo_seq": undo_seq,
|
||
# Anchor every action-seq to its current widget value so a
|
||
# stale bump can't refire its gate against the new base.
|
||
"redo_seq": redo_seq,
|
||
"reroll_seq": reroll_seq,
|
||
"vary_seq": vary_seq,
|
||
"vary_pick_seq": vary_pick_seq,
|
||
"outpaint_seq": outpaint_seq,
|
||
"outpaint_accept_seq": outpaint_accept_seq,
|
||
"quick_refine_seq": quick_refine_seq,
|
||
"upscale_seq": upscale_seq,
|
||
"shrink_seq": shrink_seq,
|
||
"fingerprint": incoming_fp,
|
||
"sampler_seed_at_run": int(sampler_seed),
|
||
"loaded_seq": loaded_seq,
|
||
# Source image (#3/#9): the session base, captured once so the
|
||
# source_image output survives _HISTORY_CAP eviction of history[0].
|
||
"source_latent": new_latent,
|
||
"source_pixels": None,
|
||
}
|
||
out_latent = {"samples": new_latent}
|
||
ui_msg = {
|
||
"Angelo_preview": [],
|
||
"Angelo_mode": ["Sampler Mode"],
|
||
"Angelo_sampler_seed_at_run": [int(sampler_seed)],
|
||
}
|
||
# Preview always decodes now (auto_decode deprecated). Gen-bundle
|
||
# path already holds the gen VAE's pixels — preview those rather
|
||
# than the edit-VAE round-trip of them.
|
||
if new_pixels is not None:
|
||
image, image_refs = _pixels_to_preview(new_pixels)
|
||
else:
|
||
image, image_refs = _decode_to_preview(vae, new_latent)
|
||
ui_msg["Angelo_preview"] = image_refs
|
||
# Freshly-generated base IS the source image — cache + emit it.
|
||
# The preview refs double as the source refs for the JS
|
||
# hold-to-compare key (the base and the preview are the same
|
||
# image until the first edit).
|
||
_STATE[node_id]["source_pixels"] = image
|
||
_STATE[node_id]["source_preview_refs"] = image_refs
|
||
ui_msg["Angelo_source_preview"] = image_refs
|
||
return {"ui": ui_msg, "result": (image, out_latent, image)}
|
||
|
||
# ===== Edit Mode branch (existing behaviour) =====
|
||
|
||
# Decide whether to (re)seed the cache from the incoming latent.
|
||
# Reset on explicit toggle, first run, or fingerprint change —
|
||
# but NOT on fingerprint change while persistent_mask is on, because
|
||
# the whole point of persistent_mask is to keep refining the cached
|
||
# latent across upstream re-rolls (the user's pressing Queue
|
||
# specifically because they want a variation of the held region,
|
||
# not a fresh image).
|
||
#
|
||
# The fingerprint check ONLY applies when the base is the wired
|
||
# `latent` input (where a change really does mean "upstream produced
|
||
# a fresh latent"). When the base comes from the cache / loaded
|
||
# image, `incoming` is history[0] — which mutates as the undo stack
|
||
# is capped (_HISTORY_CAP). Comparing against it there would
|
||
# spuriously reset to a mid-stage latent after enough refines (the
|
||
# "suddenly reverts to an earlier stage while painting" bug). Those
|
||
# bases only change via explicit Load / Unload / Reset, all handled
|
||
# by `reset` / `new_loaded` above — so the fingerprint isn't needed.
|
||
state = _STATE.get(node_id)
|
||
fingerprint_changed = (
|
||
base_from_wired_latent
|
||
and state is not None
|
||
and state.get("fingerprint") != incoming_fp
|
||
)
|
||
need_reset = (
|
||
reset
|
||
or state is None
|
||
or new_loaded
|
||
or (fingerprint_changed and not persistent_mask)
|
||
)
|
||
|
||
if need_reset:
|
||
# Any reset means the base just changed (load, unload, upstream
|
||
# rewire). Anchor click_seq/undo_seq to the CURRENT widget
|
||
# values so a click that was meant for the OLD base can't trip
|
||
# the new-click gate and replay a stale inpaint onto the new
|
||
# base. The user's next genuine click bumps click_seq and fires
|
||
# normally.
|
||
_STATE[node_id] = {
|
||
# On a fresh LOAD, pair the base latent with the real loaded
|
||
# pixels (forced_pixels) so the preview is the file itself,
|
||
# not its VAE round-trip. Other resets have no pixels → None,
|
||
# and the preview decodes the latent as before.
|
||
"history": [(incoming.clone(), forced_pixels)],
|
||
"click_seq": click_seq,
|
||
"undo_seq": undo_seq,
|
||
# Anchor every action-seq (see the Sampler Mode dict note).
|
||
"redo_seq": redo_seq,
|
||
"reroll_seq": reroll_seq,
|
||
"vary_seq": vary_seq,
|
||
"vary_pick_seq": vary_pick_seq,
|
||
"outpaint_seq": outpaint_seq,
|
||
"outpaint_accept_seq": outpaint_accept_seq,
|
||
"quick_refine_seq": quick_refine_seq,
|
||
"upscale_seq": upscale_seq,
|
||
"shrink_seq": shrink_seq,
|
||
"fingerprint": incoming_fp,
|
||
"loaded_seq": loaded_seq,
|
||
# Source image (#3/#9): capture the base once, independent of
|
||
# history[0] (which mutates under _HISTORY_CAP eviction). On a
|
||
# load this is the real loaded image, so hold-to-compare and
|
||
# the source_image output show the file, not a round-trip.
|
||
"source_latent": incoming.clone(),
|
||
"source_pixels": forced_pixels,
|
||
}
|
||
state = _STATE[node_id]
|
||
|
||
# Undo: if undo_seq advanced and we have history to pop, pop it.
|
||
# We always keep at least one latent (the base / earliest refine)
|
||
# so the preview stays valid.
|
||
new_undo = undo_seq > 0 and undo_seq != state.get("undo_seq", -1)
|
||
if new_undo:
|
||
if len(state["history"]) > 1:
|
||
# Move the popped entry onto the redo stack so Redo (#6) can
|
||
# restore it. Bounded like the history stack.
|
||
popped = state["history"].pop()
|
||
redo = state.setdefault("redo_stack", [])
|
||
redo.append(popped)
|
||
if len(redo) > _HISTORY_CAP:
|
||
state["redo_stack"] = redo[-_HISTORY_CAP:]
|
||
state["undo_seq"] = undo_seq
|
||
state["quick_last"] = False
|
||
# history[-1] is no longer the edit the mask widgets describe —
|
||
# block Re-roll / Vary until a fresh edit re-arms them, and drop
|
||
# any stashed Vary candidates (picking one after an Undo would
|
||
# overwrite the wrong history entry).
|
||
state["last_edit_kind"] = None
|
||
state["vary_candidates"] = None
|
||
# Undo is a PURE restore — pop the cached latent and decode it.
|
||
# It must NEVER re-sample, or it would produce a different image
|
||
# than the one being restored. Absorb the current click_seq so
|
||
# the new-click gate below stays False on this run. This is
|
||
# load-bearing because the Persistent Mask queue hook bumps
|
||
# click_seq on EVERY queue, including the Undo button's — without
|
||
# this, an undo while Persistent Mask is on would look like a new
|
||
# click and re-run the last mask with the (since-randomized) seed,
|
||
# restoring the WRONG result. (Harmless no-op when the hook didn't
|
||
# bump it.)
|
||
state["click_seq"] = click_seq
|
||
|
||
# ===== Redo (#6): restore an entry Undo moved to the redo stack =====
|
||
# A PURE restore — never re-samples — and runs BEFORE `current` is read
|
||
# below, so the restored entry becomes history[-1]. Absorbs click_seq
|
||
# for the same reason Undo does (the Persistent Mask queue hook bumps
|
||
# it on every queue, including the Redo button's). No-op if the redo
|
||
# stack is empty. A genuine new edit clears the redo stack (below).
|
||
new_redo = redo_seq > 0 and redo_seq != state.get("redo_seq", -1)
|
||
if new_redo:
|
||
redo = state.get("redo_stack") or []
|
||
if redo:
|
||
state["history"].append(redo.pop())
|
||
if len(state["history"]) > _HISTORY_CAP:
|
||
state["history"] = state["history"][-_HISTORY_CAP:]
|
||
state["redo_seq"] = redo_seq
|
||
state["quick_last"] = False
|
||
# Same invalidation as Undo — the restored entry may not be the
|
||
# edit the mask widgets currently describe.
|
||
state["last_edit_kind"] = None
|
||
state["vary_candidates"] = None
|
||
state["click_seq"] = click_seq
|
||
|
||
# ===== Vary pick: commit a chosen variation (pure restore) =====
|
||
# The JS chooser sets vary_pick + bumps vary_pick_seq after a Vary
|
||
# run stashed candidates. Swap the chosen candidate in for the last
|
||
# attempt — replace, not stack, exactly like Re-roll — and clear the
|
||
# stash. Never samples. Absorbs click_seq for the same queue-hook
|
||
# reason as Undo/Redo.
|
||
new_vary_pick = vary_pick_seq > 0 and vary_pick_seq != state.get("vary_pick_seq", -1)
|
||
if new_vary_pick:
|
||
state["vary_pick_seq"] = vary_pick_seq
|
||
state["quick_last"] = False
|
||
cands = state.get("vary_candidates")
|
||
if cands and 0 <= int(vary_pick) < len(cands):
|
||
state["history"][-1] = cands[int(vary_pick)]
|
||
# Picking a variation is a real edit decision — the redo
|
||
# branch (which predates it) no longer applies.
|
||
state["redo_stack"] = []
|
||
state["vary_candidates"] = None
|
||
state["click_seq"] = click_seq
|
||
|
||
# ===== Outpaint accept: commit the reviewed canvas as a NEW session =====
|
||
# The JS Accept button bumps outpaint_accept_seq after the review
|
||
# overlay. The stashed canvas becomes a fresh session base with
|
||
# Load-Image semantics — history resets, the Restore anchor and the
|
||
# hold-to-compare base move to the new canvas. This wholesale state
|
||
# replacement is what keeps every same-shape assumption in the rest
|
||
# of the node true across a dimension change.
|
||
new_op_accept = (
|
||
outpaint_accept_seq > 0
|
||
and outpaint_accept_seq != state.get("outpaint_accept_seq", -1)
|
||
)
|
||
if new_op_accept:
|
||
pend = state.get("outpaint_pending")
|
||
if pend is not None:
|
||
op_lat, op_px = pend
|
||
_STATE[node_id] = {
|
||
"history": [(op_lat, op_px)],
|
||
"click_seq": click_seq,
|
||
"undo_seq": undo_seq,
|
||
"redo_seq": redo_seq,
|
||
"reroll_seq": reroll_seq,
|
||
"vary_seq": vary_seq,
|
||
"vary_pick_seq": vary_pick_seq,
|
||
"outpaint_seq": outpaint_seq,
|
||
"outpaint_accept_seq": outpaint_accept_seq,
|
||
"quick_refine_seq": quick_refine_seq,
|
||
"upscale_seq": upscale_seq,
|
||
"shrink_seq": shrink_seq,
|
||
# Preserve the wired-latent fingerprint + load marker so
|
||
# the next run doesn't read the unchanged upstream as a
|
||
# fresh latent and blow away the canvas we just committed.
|
||
"fingerprint": state.get("fingerprint"),
|
||
"loaded_seq": state.get("loaded_seq"),
|
||
"sampler_seed_at_run": state.get("sampler_seed_at_run", int(sampler_seed)),
|
||
"refine_seed_at_run": (state.get("refine_seed_at_run")
|
||
if state.get("refine_seed_at_run") is not None
|
||
else int(seed)),
|
||
"source_latent": op_lat,
|
||
"source_pixels": op_px,
|
||
}
|
||
state = _STATE[node_id]
|
||
else:
|
||
state["outpaint_accept_seq"] = outpaint_accept_seq
|
||
state["click_seq"] = click_seq
|
||
|
||
# ===== Re-roll: redo the most recent edit with a fresh seed =====
|
||
# The Re-roll button bumps reroll_seq (and sets a new seed) without
|
||
# touching the mask widgets. Re-run the SAME mask on the SAME pre-
|
||
# edit base and swap the result in place of the last attempt — so
|
||
# the user can cycle seeds on one edit without reset → re-mask →
|
||
# rerun. History is NOT popped up front (a Cancel/OOM mid-sample
|
||
# would permanently lose the attempt): like Vary, the edit block
|
||
# samples from history[-2] and the commit REPLACES history[-1]
|
||
# only on success. Gated on last_edit_kind == "mask" — after an
|
||
# Undo/Redo or a ✨ pass the mask widgets no longer describe
|
||
# history[-1], so re-running them would corrupt the session
|
||
# (e.g. a click_x=-1 fresh session fires a bogus corner edit).
|
||
new_reroll = reroll_seq > 0 and reroll_seq != state.get("reroll_seq", -1)
|
||
reroll_now = (
|
||
new_reroll
|
||
and len(state["history"]) > 1
|
||
and inpainting_mode != "Outpaint"
|
||
and state.get("last_edit_kind") == "mask"
|
||
)
|
||
state["reroll_seq"] = reroll_seq
|
||
|
||
# Re-roll after ✨: the last entry is a ✨ pass, so there are no mask
|
||
# widgets to re-run — route the press into a FRESH ✨ pass instead
|
||
# ("re-roll the last thing, whatever it was"). Picked up by the quick
|
||
# block below; quick_last's replace semantics make it swap the
|
||
# previous ✨ result, exactly like pressing ✨ again. The JS already
|
||
# forced a new random seed for the press, so it's a real variation.
|
||
reroll_quick = (
|
||
new_reroll
|
||
and inpainting_mode == "Refine"
|
||
and state.get("last_edit_kind") == "quick"
|
||
)
|
||
|
||
# ===== Vary ×4 gate =====
|
||
# Re-run the most recent edit's mask from its PRE-edit base, four
|
||
# times, WITHOUT touching history — candidates are stashed and the
|
||
# user commits one via the chooser (vary_pick above). Needs a prior
|
||
# edit (history > 1) whose mask widgets are still valid (same
|
||
# last_edit_kind gate as Re-roll). Excluded when the Restore brush
|
||
# is active (restores are deterministic — four identical candidates
|
||
# would be noise); the JS blocks that combination too.
|
||
new_vary = vary_seq > 0 and vary_seq != state.get("vary_seq", -1)
|
||
vary_now = (
|
||
new_vary
|
||
and len(state["history"]) > 1
|
||
and inpainting_mode != "Outpaint"
|
||
and not (bool(restore_mode) and inpainting_mode == "Refine")
|
||
and state.get("last_edit_kind") == "mask"
|
||
)
|
||
state["vary_seq"] = vary_seq
|
||
|
||
hist_last = state["history"][-1]
|
||
if isinstance(hist_last, tuple):
|
||
current, current_pixels = hist_last
|
||
else:
|
||
current = hist_last
|
||
current_pixels = None
|
||
|
||
# ===== Outpaint (generate): pad + fill, stash for review =====
|
||
# Driven by outpaint_seq from the JS arrows / edge-click, gated on
|
||
# Outpaint mode. The result is NOT committed — it's stashed with a
|
||
# preview ref so the JS can show the Accept / Try again / Cancel
|
||
# overlay. The node keeps outputting the current canvas underneath.
|
||
new_outpaint = (
|
||
inpainting_mode == "Outpaint"
|
||
and outpaint_seq > 0
|
||
and outpaint_seq != state.get("outpaint_seq", -1)
|
||
)
|
||
state["outpaint_seq"] = outpaint_seq
|
||
if new_outpaint:
|
||
latent_init, op_mask, op_amt, op_ref = _outpaint_prepare(
|
||
vae=vae, current=current, current_pixels=current_pixels,
|
||
direction=str(outpaint_dir), amount_px=int(outpaint_amount),
|
||
overlap_px=int(outpaint_overlap),
|
||
protect_json=str(outpaint_protect),
|
||
)
|
||
# Conditioning. With a CLIP wired, the outpaint encodes its OWN
|
||
# prompt: a direction-aware continue-don't-repeat instruction +
|
||
# the Area Prompt text (if armed) describing the new space. The
|
||
# main scene prompt is deliberately NOT used — conditioning the
|
||
# strip on "a red car on a road" is precisely what paints a
|
||
# second car into it. Without a CLIP we can't encode, so the
|
||
# main conditioning flows through (with its known duplication
|
||
# risk — wiring CLIP is the documented recommendation).
|
||
if clip is not None:
|
||
instr = _OUTPAINT_INSTRUCTIONS.get(
|
||
str(outpaint_dir), _OUTPAINT_INSTRUCTIONS["right"])
|
||
# Instruction order: "prepend" (default) puts the instruction
|
||
# first; "append" puts the user's Area text first. MUST stay
|
||
# in lockstep with the JS preview (syncOutpaintPromptPreview).
|
||
user_txt = str(area_text_positive).strip() if area_prompt else ""
|
||
if user_txt and str(outpaint_instruction_pos) == "append":
|
||
op_text = user_txt.rstrip(" .,") + ". " + instr.strip()
|
||
else:
|
||
op_text = instr + user_txt
|
||
tokens_p = clip.tokenize(op_text)
|
||
op_positive = clip.encode_from_tokens_scheduled(tokens_p)
|
||
if area_prompt and str(area_text_negative).strip():
|
||
tokens_n = clip.tokenize(str(area_text_negative))
|
||
op_negative = clip.encode_from_tokens_scheduled(tokens_n)
|
||
else:
|
||
op_negative = negative
|
||
else:
|
||
op_positive = positive
|
||
op_negative = negative
|
||
# Edge-band reference for edit models (Klein / Qwen): texture +
|
||
# lighting to continue, without re-showing the scene's subject
|
||
# (a whole-image reference invites the edit branch to reproduce
|
||
# it into the strip). REPLACE (append=False) per the Smart
|
||
# Inpaint lesson — non-edit models ignore the field entirely.
|
||
op_positive = node_helpers.conditioning_set_values(
|
||
op_positive, {"reference_latents": [op_ref]}, append=False,
|
||
)
|
||
# 5D temporal latents (Qwen/Wan) sample with a HARD mask — same
|
||
# reasoning as Smart Inpaint (no clean soft-mask-during-denoise
|
||
# behaviour on non-zero-mean latent spaces).
|
||
op_sample_mask = op_mask
|
||
if latent_init.ndim == 5:
|
||
op_sample_mask = (op_mask >= 0.5).to(op_mask.dtype)
|
||
op_seed = int(seed)
|
||
callback = None if disable_live_preview else latent_preview.prepare_callback(model, steps)
|
||
disable_pbar = not comfy.utils.PROGRESS_BAR_ENABLED
|
||
print(f"[Angelo outpaint] dir={outpaint_dir} amount={op_amt}px "
|
||
f"overlap={int(outpaint_overlap)}px "
|
||
f"new_latent=({latent_init.shape[-2]}x{latent_init.shape[-1]})")
|
||
noise = comfy.sample.prepare_noise(latent_init, op_seed, None)
|
||
op_refined = _do_sample(
|
||
guider=ov_guider, sampler=ov_sampler, sigmas=ov_sigmas,
|
||
model=model, noise=noise,
|
||
steps=steps, cfg=cfg, sampler_name=sampler_name, scheduler=scheduler,
|
||
positive=op_positive, negative=op_negative,
|
||
source_latent=latent_init,
|
||
denoise=1.0,
|
||
noise_mask=op_sample_mask,
|
||
callback=callback,
|
||
disable_pbar=disable_pbar,
|
||
seed=op_seed,
|
||
)
|
||
op_img, op_refs = _decode_to_preview(vae, op_refined)
|
||
state["outpaint_pending"] = (op_refined, op_img)
|
||
state["outpaint_preview_refs"] = op_refs
|
||
state["click_seq"] = click_seq
|
||
state["refine_seed_at_run"] = op_seed
|
||
|
||
# ===== Quick Photo Refine: the one-click restoration recipe =====
|
||
# Whole canvas, the internal restoration prompt, and the Reference
|
||
# anchor — identity reconstructs from the reference while the
|
||
# texture re-renders. The toolbar DENOISE applies (1.0 = full
|
||
# re-render, the strongest restoration; lower = gentler clean-up);
|
||
# area prompt / toggles are ignored. The seed also applies, so
|
||
# seed_control = randomize lets you mash the button for variations.
|
||
new_quick = (
|
||
inpainting_mode == "Refine"
|
||
and quick_refine_seq > 0
|
||
and quick_refine_seq != state.get("quick_refine_seq", -1)
|
||
)
|
||
state["quick_refine_seq"] = quick_refine_seq
|
||
# Re-roll pressed while the last entry is a ✨ pass → run a fresh ✨
|
||
# (see the reroll_quick note above).
|
||
if reroll_quick:
|
||
new_quick = True
|
||
if new_quick:
|
||
# Re-roll semantics: if the latest entry is itself a ✨ result,
|
||
# this press re-draws from the same source the last press used,
|
||
# REPLACING it, instead of compounding on its output (anchor-
|
||
# chaining converged to a fixed point and made mashing for
|
||
# variations useless). Manual edits in between clear the flag,
|
||
# so they're never discarded. History is NOT popped up front —
|
||
# a Cancel/OOM mid-sample must not lose the previous ✨ result;
|
||
# the source is read from history[-2] and history[-1] is
|
||
# replaced only on success (commit below).
|
||
qr_replace = bool(state.get("quick_last")) and len(state["history"]) > 1
|
||
if qr_replace:
|
||
_hl = state["history"][-2]
|
||
if isinstance(_hl, tuple):
|
||
current, current_pixels = _hl
|
||
else:
|
||
current, current_pixels = _hl, None
|
||
# Prompt selection: preset from the ✨ selector, or the Area
|
||
# Prompt text. The default preset keeps the model-tuned Qwen
|
||
# variant on 5D latents; explicit presets are used verbatim.
|
||
qp_mode = str(quick_prompt_mode)
|
||
if qp_mode == "Use Area Prompt" and str(area_text_positive).strip():
|
||
qr_prompt = str(area_text_positive)
|
||
elif qp_mode == "Use Area Prompt":
|
||
print("[Angelo quick-refine] Area Prompt empty — using the default preset")
|
||
qr_prompt = (_QUICK_REFINE_PROMPT_QWEN if current.dim() == 5
|
||
else _QUICK_REFINE_PROMPT)
|
||
elif qp_mode == "Identity + Quality":
|
||
qr_prompt = (_QUICK_REFINE_PROMPT_QWEN if current.dim() == 5
|
||
else _QUICK_REFINE_PROMPT)
|
||
else:
|
||
qr_prompt = _QUICK_REFINE_PROMPTS.get(qp_mode, _QUICK_REFINE_PROMPT)
|
||
print(f"[Angelo quick-refine] prompt mode: {qp_mode}")
|
||
if clip is not None:
|
||
tokens_q = clip.tokenize(qr_prompt)
|
||
qr_base = clip.encode_from_tokens_scheduled(tokens_q)
|
||
else:
|
||
# No CLIP → can't encode the restoration prompt; the main
|
||
# positive flows through. Still works, just less targeted.
|
||
qr_base = positive
|
||
qr_seed = int(seed)
|
||
# Lite mode (toggle) just swaps the denoise — everything else is
|
||
# the regular Quick Photo Refine recipe.
|
||
qr_denoise = _QUICK_REFINE_LITE_DENOISE if bool(quick_lite) else _QUICK_REFINE_DENOISE
|
||
callback = None if disable_live_preview else latent_preview.prepare_callback(model, steps)
|
||
disable_pbar = not comfy.utils.PROGRESS_BAR_ENABLED
|
||
# Big canvases (e.g. after a 2× Restore Upscale) auto-route
|
||
# through the tiled engine — same recipe per ~1MP tile, each
|
||
# anchored to its own content, seams feathered — so the model
|
||
# never samples a latent beyond the size it renders well at.
|
||
canvas_mp = (image_w * image_h) / 1e6 if (image_w > 0 and image_h > 0) else 0.0
|
||
# Tiling is for RESTORATION (every tile's job is local). An Area
|
||
# Prompt is a SEMANTIC edit — changing a person can't be decided
|
||
# per-tile (each tile would change them differently: the broken
|
||
# half-swapped result). Area-Prompt mode therefore always runs
|
||
# the single global pass, like the manual brush does.
|
||
if (canvas_mp > _QUICK_REFINE_TILE_THRESHOLD_MP
|
||
and qp_mode != "Use Area Prompt"):
|
||
print(f"[Angelo quick-refine] {canvas_mp:.1f}MP canvas — tiled restore pass")
|
||
qr_refined, qr_pixels, _n = _tiled_restore_pass(
|
||
model=model, vae=vae,
|
||
current=current, current_pixels=current_pixels,
|
||
scale=1.0,
|
||
positive_base=qr_base, negative=negative,
|
||
seed=qr_seed, steps=steps, cfg=cfg,
|
||
sampler_name=sampler_name, scheduler=scheduler,
|
||
callback=callback, disable_pbar=disable_pbar,
|
||
ov_guider=ov_guider, ov_sampler=ov_sampler, ov_sigmas=ov_sigmas,
|
||
tile_denoise=qr_denoise,
|
||
dual_grid=True, # seam-erase offset grid (option 4)
|
||
)
|
||
else:
|
||
# ✨ v2: the whole image through the XTRA-FINE pipeline —
|
||
# whole-canvas mask, full reference anchor, full denoise,
|
||
# 1.3MP working target (sub-1.3MP canvases get internally
|
||
# supersampled to 1.3MP, refined there, composited back),
|
||
# feather 0. Magic button: NO toolbar box affects it (only
|
||
# the seed, for variation mashing).
|
||
qr_px_in = current_pixels if current_pixels is not None else _vae_decode(vae, current)
|
||
Hq, Wq = qr_px_in.shape[1], qr_px_in.shape[2]
|
||
qr_mask = torch.ones((1, current.shape[-2], current.shape[-1]),
|
||
device=current.device, dtype=torch.float32)
|
||
print(f"[Angelo quick-refine] whole-image Xtra-Fine pass "
|
||
f"({Wq}x{Hq}, target {_QUICK_REFINE_TARGET_MP}MP), "
|
||
f"denoise={qr_denoise}, ref={_QUICK_REFINE_REF}, seed={qr_seed}"
|
||
f"{' (Lite)' if bool(quick_lite) else ''}")
|
||
qr_refined, qr_pixels = _refine_with_fine_upscaling(
|
||
model=model, vae=vae, current=current, current_pixels=qr_px_in,
|
||
mask=qr_mask,
|
||
scale_x=current.shape[-1] / Wq, scale_y=current.shape[-2] / Hq,
|
||
target_mp=_QUICK_REFINE_TARGET_MP,
|
||
max_linear=8.0, resize_method="lanczos",
|
||
context_pad_pixel=_QUICK_REFINE_CTX_PAD,
|
||
inpainting_mode="Refine",
|
||
reference_strength=_QUICK_REFINE_REF,
|
||
seed=qr_seed, steps=steps, cfg=cfg,
|
||
sampler_name=sampler_name, scheduler=scheduler,
|
||
positive=qr_base, negative=negative,
|
||
denoise=qr_denoise,
|
||
callback=callback, disable_pbar=disable_pbar,
|
||
ov_guider=ov_guider, ov_sampler=ov_sampler, ov_sigmas=ov_sigmas,
|
||
)
|
||
if qr_replace:
|
||
# Replace the previous ✨ result (cancel-safe: it stayed in
|
||
# history while this pass sampled).
|
||
state["history"][-1] = (qr_refined, qr_pixels)
|
||
else:
|
||
state["history"].append((qr_refined, qr_pixels))
|
||
if len(state["history"]) > _HISTORY_CAP:
|
||
state["history"] = state["history"][-_HISTORY_CAP:]
|
||
state["quick_last"] = True
|
||
state["redo_stack"] = []
|
||
state["vary_candidates"] = None
|
||
state["outpaint_pending"] = None
|
||
state["click_seq"] = click_seq
|
||
state["refine_seed_at_run"] = qr_seed
|
||
# ✨'s whole-canvas mask has nothing to do with the click/paint
|
||
# widgets — Re-roll / Vary must not re-run them over this result
|
||
# (press ✨ again to re-roll it instead).
|
||
state["last_edit_kind"] = "quick"
|
||
current = qr_refined
|
||
current_pixels = qr_pixels
|
||
|
||
# ===== ⬆ 2× Pixel: pure pixel-space upscale (no AI) =====
|
||
# Lanczos 2× of the decoded canvas, re-encoded, committed DIRECTLY
|
||
# as a fresh session base — dimension change = Load-Image semantics
|
||
# (history resets). Deterministic, so no review step. The AI
|
||
# enhancement is the user's next, separate move — e.g. ✨ Quick
|
||
# Photo Refine, which auto-tiles on the now-large canvas.
|
||
new_upscale = (
|
||
inpainting_mode == "Refine"
|
||
and upscale_seq > 0
|
||
and upscale_seq != state.get("upscale_seq", -1)
|
||
)
|
||
state["upscale_seq"] = upscale_seq
|
||
if new_upscale:
|
||
up_px_in = current_pixels if current_pixels is not None else _vae_decode(vae, current)
|
||
in_H, in_W = up_px_in.shape[1], up_px_in.shape[2]
|
||
out_W = max(16, int(round(in_W * 2.0 / 16.0)) * 16)
|
||
out_H = max(16, int(round(in_H * 2.0 / 16.0)) * 16)
|
||
up_chw = up_px_in.movedim(-1, 1)
|
||
up_chw = comfy.utils.common_upscale(up_chw, out_W, out_H, "lanczos", "disabled")
|
||
up_pixels = up_chw.movedim(1, -1).contiguous()
|
||
up_latent = _vae_encode(vae, up_pixels)
|
||
print(f"[Angelo 2x-pixel] {in_W}x{in_H} -> {out_W}x{out_H} (lanczos, no AI)")
|
||
_STATE[node_id] = {
|
||
"history": [(up_latent, up_pixels)],
|
||
"click_seq": click_seq,
|
||
"undo_seq": undo_seq,
|
||
"redo_seq": redo_seq,
|
||
"reroll_seq": reroll_seq,
|
||
"vary_seq": vary_seq,
|
||
"vary_pick_seq": vary_pick_seq,
|
||
"outpaint_seq": outpaint_seq,
|
||
"outpaint_accept_seq": outpaint_accept_seq,
|
||
"quick_refine_seq": quick_refine_seq,
|
||
"upscale_seq": upscale_seq,
|
||
"shrink_seq": shrink_seq,
|
||
"fingerprint": state.get("fingerprint"),
|
||
"loaded_seq": state.get("loaded_seq"),
|
||
"sampler_seed_at_run": state.get("sampler_seed_at_run", int(sampler_seed)),
|
||
"refine_seed_at_run": (state.get("refine_seed_at_run")
|
||
if state.get("refine_seed_at_run") is not None
|
||
else int(seed)),
|
||
"source_latent": up_latent,
|
||
"source_pixels": up_pixels,
|
||
}
|
||
state = _STATE[node_id]
|
||
current, current_pixels = up_latent, up_pixels
|
||
|
||
# ===== ⬇ Shrink Image: pure pixel-space downscale =====
|
||
# AREA resampling (box averaging) is the right tool for shrinking —
|
||
# it averages the pixels being discarded, so it anti-aliases; lanczos
|
||
# (great for UPscaling) can ring/alias on heavy reduction. No AI;
|
||
# committed straight as a fresh session base (dimension change =
|
||
# Load-Image semantics, history resets). shrink_scale is the chosen
|
||
# factor; out dims snap to a multiple of 16 (latent alignment).
|
||
new_shrink = (
|
||
inpainting_mode == "Refine"
|
||
and shrink_seq > 0
|
||
and shrink_seq != state.get("shrink_seq", -1)
|
||
)
|
||
state["shrink_seq"] = shrink_seq
|
||
if new_shrink:
|
||
sk_px_in = current_pixels if current_pixels is not None else _vae_decode(vae, current)
|
||
in_H, in_W = sk_px_in.shape[1], sk_px_in.shape[2]
|
||
sc = max(0.05, min(0.95, float(shrink_scale)))
|
||
out_W = max(16, int(round(in_W * sc / 16.0)) * 16)
|
||
out_H = max(16, int(round(in_H * sc / 16.0)) * 16)
|
||
sk_chw = sk_px_in.movedim(-1, 1)
|
||
sk_chw = comfy.utils.common_upscale(sk_chw, out_W, out_H, "area", "disabled")
|
||
sk_pixels = sk_chw.movedim(1, -1).contiguous()
|
||
sk_latent = _vae_encode(vae, sk_pixels)
|
||
print(f"[Angelo shrink] {in_W}x{in_H} -> {out_W}x{out_H} "
|
||
f"(scale {sc:.2f}, area, no AI)")
|
||
_STATE[node_id] = {
|
||
"history": [(sk_latent, sk_pixels)],
|
||
"click_seq": click_seq,
|
||
"undo_seq": undo_seq,
|
||
"redo_seq": redo_seq,
|
||
"reroll_seq": reroll_seq,
|
||
"vary_seq": vary_seq,
|
||
"vary_pick_seq": vary_pick_seq,
|
||
"outpaint_seq": outpaint_seq,
|
||
"outpaint_accept_seq": outpaint_accept_seq,
|
||
"quick_refine_seq": quick_refine_seq,
|
||
"upscale_seq": upscale_seq,
|
||
"shrink_seq": shrink_seq,
|
||
"fingerprint": state.get("fingerprint"),
|
||
"loaded_seq": state.get("loaded_seq"),
|
||
"sampler_seed_at_run": state.get("sampler_seed_at_run", int(sampler_seed)),
|
||
"refine_seed_at_run": (state.get("refine_seed_at_run")
|
||
if state.get("refine_seed_at_run") is not None
|
||
else int(seed)),
|
||
"source_latent": sk_latent,
|
||
"source_pixels": sk_pixels,
|
||
}
|
||
state = _STATE[node_id]
|
||
current, current_pixels = sk_latent, sk_pixels
|
||
|
||
# Has the user clicked since our last execution for this node?
|
||
# Never treat a queue-hook click_seq bump as an edit in Outpaint
|
||
# mode — the canvas there is a direction picker, not a mask tool.
|
||
new_click = (
|
||
click_x >= 0
|
||
and click_y >= 0
|
||
and click_seq != state["click_seq"]
|
||
and inpainting_mode != "Outpaint"
|
||
)
|
||
|
||
# Source latent for every edit is the current cached latent, so all
|
||
# paths build ON TOP of the previous result:
|
||
# - normal clicks / paints iterate on the latest image
|
||
# - Persistent Mask Queue presses re-run the held mask on the
|
||
# latest result with a fresh seed, so the region gradually
|
||
# morphs further with each press (change something into
|
||
# something else)
|
||
# Re-roll and Vary are the exceptions: they re-run the last edit's
|
||
# mask from its PRE-edit base, so their sampling source is
|
||
# redirected to history[-2] below. History itself stays untouched
|
||
# until the commit (cancel-safety — a Cancel/OOM mid-sample must
|
||
# never lose the existing attempt).
|
||
|
||
if new_click or reroll_now or vary_now:
|
||
# Re-roll and Vary ×4 both re-run the same mask widgets from
|
||
# the UN-popped pre-edit base: history stays intact (the last
|
||
# attempt remains history[-1]) and only the sampling source is
|
||
# redirected to history[-2]. Re-roll REPLACES history[-1] on
|
||
# success (commit below); Vary stashes candidates and commits
|
||
# nothing until the user picks one in the chooser.
|
||
if vary_now or reroll_now:
|
||
hist_prev = state["history"][-2]
|
||
if isinstance(hist_prev, tuple):
|
||
current, current_pixels = hist_prev
|
||
else:
|
||
current, current_pixels = hist_prev, None
|
||
# Pixel → latent conversion. The JS sends us the actual image
|
||
# dimensions (image_w, image_h) so we can derive the per-axis
|
||
# scale dynamically rather than hardcoding a VAE downscale
|
||
# factor — that breaks for FLUX 2 (16×) vs FLUX 1 / SDXL (8×).
|
||
# Fall back to 8× only if image dims weren't provided (shouldn't
|
||
# happen in normal use).
|
||
latent_h = current.shape[-2]
|
||
latent_w = current.shape[-1]
|
||
if image_w > 0 and image_h > 0:
|
||
scale_x = latent_w / image_w
|
||
scale_y = latent_h / image_h
|
||
else:
|
||
# Fallback for headless tests / direct node use without the
|
||
# JS widget populating image_w/h. 8× is the most common VAE
|
||
# downscale (FLUX 1, SDXL, SD1.5) but breaks for FLUX 2 (16×).
|
||
scale_x = scale_y = 1.0 / 8.0
|
||
print("[Angelo] warning: image_w/h not set by JS; "
|
||
"falling back to 8x VAE assumption — may be wrong for FLUX 2")
|
||
|
||
# Geometric mean of the two axis scales (#28, from @KursatAs):
|
||
# using scale_x alone maps the on-screen circle to an ellipse in
|
||
# latent space whenever the axes scale differently (non-/16
|
||
# image dims), refining a taller/wider region than was painted.
|
||
# The geometric mean keeps the latent circle area-true on both
|
||
# portrait and landscape images.
|
||
scale_geom = math.sqrt(scale_x * scale_y)
|
||
r_latent = max(1.0, click_radius * scale_geom)
|
||
|
||
# Remove brush: Refine-only, edit models only, mutually exclusive
|
||
# with Restore. Computed HERE (before the mask is built) because it
|
||
# forces feather 0 and grows the mask below.
|
||
remove_now = (bool(remove_mode) and not bool(restore_mode)
|
||
and inpainting_mode == "Refine")
|
||
|
||
# Feather is forced to 0 for Remove regardless of the box — a hard
|
||
# mask stops the object bleeding back through a soft edge.
|
||
sigma_latent = 0.0 if remove_now else (
|
||
(feather_radius * scale_geom) if feather_radius > 0 else 0.0)
|
||
|
||
# Build the mask. Sources of mask shape, in priority:
|
||
# 1. Smart Guided Inpaint: full-image (no region — the whole
|
||
# image is edited, location comes from the prompt prefix).
|
||
# 2. Smart Inpaint: a single rectangle from rect_points.
|
||
# 3. Refine + paint_mode + stroke points: union of brush
|
||
# circles along the drag path.
|
||
# 4. Refine single-click: one circle at (click_x, click_y).
|
||
if inpainting_mode == "Smart Guided Inpaint":
|
||
mask = torch.ones((1, latent_h, latent_w),
|
||
device=current.device, dtype=torch.float32)
|
||
elif inpainting_mode == "Smart Inpaint":
|
||
rect = _parse_rect_points(rect_points)
|
||
if rect is not None:
|
||
mask = _rect_mask_latent(
|
||
latent_h, latent_w, rect,
|
||
scale_x, scale_y, current.device,
|
||
)
|
||
else:
|
||
# No rectangle drawn yet — nothing to do. Caller
|
||
# already gates on click_seq change so this is the
|
||
# "user switched into Smart Inpaint without dragging
|
||
# a rect" case; fall through with an empty mask and
|
||
# let downstream noise_mask handling preserve the
|
||
# cached latent.
|
||
mask = torch.zeros((1, latent_h, latent_w),
|
||
device=current.device, dtype=torch.float32)
|
||
else:
|
||
# Refine mask sources, in priority:
|
||
# 1. a brushed touch-up raster mask (Detect Shift/Alt brush)
|
||
# 2. a confirmed segmentation silhouette (SAM 3 / YOLO)
|
||
# 3. a paint stroke (union of brush circles)
|
||
# 4. a single click circle
|
||
raster_png = (seg_mask_png or "").strip()
|
||
seg_polys = _parse_seg_polygons(seg_polygon)
|
||
stroke_pts = _parse_stroke_points(stroke_points) if paint_mode else []
|
||
if raster_png:
|
||
mask = _raster_mask_latent(
|
||
latent_h, latent_w, raster_png, current.device,
|
||
)
|
||
elif seg_polys:
|
||
mask = _polygons_mask_latent(
|
||
latent_h, latent_w, seg_polys,
|
||
scale_x, scale_y, current.device,
|
||
)
|
||
elif stroke_pts:
|
||
mask = _stroke_mask_latent(
|
||
latent_h, latent_w,
|
||
stroke_pts, r_latent,
|
||
scale_x, scale_y, current.device,
|
||
)
|
||
elif click_x >= 0 and click_y >= 0:
|
||
cx_latent = click_x * scale_x
|
||
cy_latent = click_y * scale_y
|
||
mask = _circle_mask_latent_direct(
|
||
latent_h, latent_w,
|
||
cx_latent, cy_latent, r_latent,
|
||
current.device,
|
||
)
|
||
else:
|
||
# No click recorded (click_x = -1, e.g. right after a
|
||
# Reset). Nothing to mask — an empty mask makes the
|
||
# edit a no-op instead of a bogus corner-circle edit.
|
||
mask = torch.zeros((1, latent_h, latent_w),
|
||
device=current.device, dtype=torch.float32)
|
||
if sigma_latent > 0:
|
||
mask = _gaussian_blur_2d(mask, max(0.5, sigma_latent))
|
||
mask = mask.clamp(0.0, 1.0)
|
||
|
||
# Remove: grow the mask outward so it fully covers the object plus
|
||
# its immediate halo — a tight mask leaves a rim of the original
|
||
# object behind.
|
||
if remove_now:
|
||
mask = _grow_mask_latent(mask, _REMOVE_MASK_GROW_FRAC)
|
||
|
||
# Restore brush (#12): Refine-only — the Smart modes regenerate
|
||
# content, so "restore to base" has no meaning there and the JS
|
||
# dims the toggle. Decided before the conditioning block so a
|
||
# restore never pays for a CLIP encode it won't use.
|
||
restore_now = bool(restore_mode) and inpainting_mode == "Refine"
|
||
|
||
# Remove: a single denoise-1.0 pass regenerates the masked region as
|
||
# background using a removal instruction + a ZEROED reference (a real
|
||
# hole in the reference) — see the pass loop. It ALWAYS runs on the
|
||
# Xtra-Fine crop path (force it on): the removal happens on a high-res
|
||
# crop with a crop-built zeroed reference, respecting the toolbar
|
||
# ctx-pad / MP / method. The JS mirrors this (forces the Xtra-Fine
|
||
# toggle on while Remove is active). Denoise box unused; pass is 1.0.
|
||
if remove_now:
|
||
fine_upscaling = True
|
||
|
||
# Area-prompt conditioning selection. When area_prompt is on AND a
|
||
# CLIP is connected, the refine uses the AREA text ONLY and NEVER
|
||
# the main prompt — even when the Area text is empty, in which case
|
||
# we encode the empty string (→ an empty conditioning) rather than
|
||
# falling back to `positive`. This is load-bearing for the edit
|
||
# modes: the main positive can carry whole-image reference_latents
|
||
# (a Klein edit workflow's ReferenceLatent), and letting it leak in
|
||
# made an empty-Area-Prompt Smart Inpaint reproduce the whole scene.
|
||
# Negative area text is optional — falls back to the main negative
|
||
# when empty (fine for CFG=1 / distilled models that ignore it).
|
||
#
|
||
# Smart Guided Inpaint prepends a location prefix to the positive
|
||
# text (e.g. "In the top left of the image, ") so the edit model
|
||
# places the content at the chosen spot.
|
||
#
|
||
# Without a CLIP we can't encode anything, so we must use the
|
||
# already-encoded main conditioning (an unavoidable degenerate case
|
||
# — area prompts need a CLIP connected).
|
||
area_pos_text = str(area_text_positive)
|
||
if inpainting_mode == "Smart Guided Inpaint":
|
||
prefix = _GUIDED_LOCATION_PREFIXES.get(str(guided_location), "")
|
||
area_pos_text = prefix + area_pos_text
|
||
if area_prompt and clip is not None and not restore_now:
|
||
tokens_p = clip.tokenize(area_pos_text)
|
||
refine_positive = clip.encode_from_tokens_scheduled(tokens_p)
|
||
if str(area_text_negative).strip():
|
||
tokens_n = clip.tokenize(str(area_text_negative))
|
||
refine_negative = clip.encode_from_tokens_scheduled(tokens_n)
|
||
else:
|
||
refine_negative = negative
|
||
else:
|
||
refine_positive = positive
|
||
refine_negative = negative
|
||
|
||
# NOTE: the Remove brush ignores this selection — it drives its
|
||
# single pass with a fixed background-fill instruction (never the
|
||
# main/Area prompt, which would draw content back into the hole). See
|
||
# the Remove pre-processing block.
|
||
|
||
# Sample with the mask. Use the seed widget value as-is —
|
||
# NO click_seq offset. Per-Queue variation (when persistent_mask
|
||
# is on) and per-click variation (when user wants different
|
||
# attempts on the same spot) are now controlled by seed_control:
|
||
# fixed → same seed each run, repeatable result
|
||
# randomize → seed changes after each run (via JS after-gen),
|
||
# so each Queue / click produces a different result
|
||
# increment/decrement → +1/-1 each run
|
||
# An older version of this code did `(seed + click_seq) & mask`
|
||
# to fake per-click variation in the absence of after-gen
|
||
# control. That broke "fixed means fixed" — even with the seed
|
||
# widget locked, click_seq's increment still moved the effective
|
||
# sampling seed. Now the user has explicit control.
|
||
this_seed = int(seed)
|
||
# #21: skip the live-preview callback when the user has
|
||
# disabled it via Overrides — see Sampler Mode branch above.
|
||
callback = None if disable_live_preview else latent_preview.prepare_callback(model, steps)
|
||
disable_pbar = not comfy.utils.PROGRESS_BAR_ENABLED
|
||
|
||
# Source latent = the current cached latent for every path (see
|
||
# the note above). Re-roll already exposed the pre-edit base as
|
||
# `current` via its pop, so it needs no special-casing here.
|
||
refine_source = current
|
||
|
||
# ===== Inpainting Mode pre-processing =====
|
||
# Refine: no pre-processing — partial denoise from the
|
||
# existing latent does the work.
|
||
# Smart Inpaint: forces fine_upscaling=True up top, so its
|
||
# pre-processing (latent zero + reference_latents) lives
|
||
# inside _refine_with_fine_upscaling.
|
||
# Smart Guided Inpaint: whole-image edit through the non-
|
||
# fine-upscale path below — inject the scene as
|
||
# reference_latents so Klein's edit branch keeps the rest
|
||
# of the image faithful while applying the location-guided
|
||
# change. POSITIVE ONLY (negative reference would steer
|
||
# CFG>1 samplers away from the scene).
|
||
if inpainting_mode == "Smart Guided Inpaint":
|
||
reference_latent = refine_source.clone()
|
||
refine_positive = node_helpers.conditioning_set_values(
|
||
refine_positive, {"reference_latents": [reference_latent]}, append=True,
|
||
)
|
||
|
||
# Reference strength (Refine only): anchor identity/content from
|
||
# the current image for the first reference_strength fraction of
|
||
# the schedule (timestep-range construction — _apply_reference),
|
||
# so high-denoise refines keep the subject. Plain path anchors
|
||
# on the WHOLE current image here; the Xtra-Fine path anchors on
|
||
# its upscaled crop instead (reference_strength passed below).
|
||
# Restore never samples, so it's excluded.
|
||
ref_strength = 0.0
|
||
if (inpainting_mode == "Refine" and not restore_now and not remove_now
|
||
and bool(refine_reference)):
|
||
ref_strength = max(0.0, min(1.0, float(reference_strength)))
|
||
if ref_strength > 0.0 and not fine_upscaling:
|
||
refine_positive = _apply_reference(
|
||
refine_positive, refine_source.clone(), ref_strength)
|
||
|
||
# ===== Remove: build the remove-pass conditioning =====
|
||
# There is NO pixel fill — the erase is done by the pass's full
|
||
# denoise plus a ZEROED reference (a real hole in the reference
|
||
# latent), so the edit model rebuilds the background from the
|
||
# surroundings rather than anchoring to the object. refine_source
|
||
# stays `current` (the true latent); at denoise 1.0 the masked region
|
||
# is noised out anyway and outside it the original is kept. The mask
|
||
# was already grown ~10% and hardened (feather 0) higher up.
|
||
remove_pass_positive = None
|
||
if remove_now and clip is not None:
|
||
# Just the background-fill instruction — the ZEROED reference is built
|
||
# from the CROP inside _refine_with_fine_upscaling (remove_mode).
|
||
rm_txt = (_REMOVE_PROMPT_QWEN if current.dim() == 5 else _REMOVE_PROMPT)
|
||
remove_pass_positive = clip.encode_from_tokens_scheduled(clip.tokenize(rm_txt))
|
||
|
||
# One pass normally; four for Vary ×4. The conditioning above is
|
||
# shared across passes — only the noise seed differs per pass.
|
||
n_passes = 4 if vary_now else 1
|
||
pass_results = []
|
||
for _k in range(n_passes):
|
||
pass_seed = this_seed if _k == 0 else (
|
||
(this_seed + _k * 1000003) & 0xFFFFFFFFFFFFFFFF
|
||
)
|
||
if restore_now:
|
||
# ===== Restore brush: heal back to the base, no sampling =====
|
||
# current = mask * base + (1-mask) * current, in latent space.
|
||
# Outside the (feathered) mask the current latent is kept
|
||
# bit-exact; inside it the session base is brought back. The
|
||
# result is pushed as a normal history entry so Undo / Redo /
|
||
# Re-roll keep working unchanged. (vary_now excludes
|
||
# restore_now, so n_passes is always 1 here.)
|
||
src = state.get("source_latent")
|
||
if src is None:
|
||
h0 = state["history"][0]
|
||
src = h0[0] if isinstance(h0, tuple) else h0
|
||
if tuple(src.shape) != tuple(current.shape):
|
||
# Shouldn't happen (source_latent resets with every base
|
||
# change), but never crash an edit session over it.
|
||
print("[Angelo restore] base/current latent shape mismatch "
|
||
f"({tuple(src.shape)} vs {tuple(current.shape)}) — restore skipped")
|
||
refined = current
|
||
else:
|
||
src = src.to(device=current.device, dtype=current.dtype)
|
||
alpha = mask
|
||
while alpha.dim() < current.dim():
|
||
alpha = alpha.unsqueeze(0) # [1,H,W] → [1,1,H,W] (→ [1,1,1,H,W] for 5D)
|
||
refined = src * alpha + current * (1.0 - alpha)
|
||
refined_pixels = None
|
||
elif remove_now:
|
||
# ===== Remove: denoise-1.0 removal on the Xtra-Fine crop ====
|
||
# Crop the region (toolbar ctx-pad / MP / method), regenerate
|
||
# the masked area as background from the removal instruction +
|
||
# a crop-built ZEROED reference (remove_mode), so the edit
|
||
# model rebuilds from the surrounding crop rather than
|
||
# redrawing the object. Always the crop path (forced above).
|
||
if remove_pass_positive is None:
|
||
# No CLIP → can't build the removal conditioning; leave
|
||
# the image untouched rather than erroring.
|
||
refined, refined_pixels = refine_source, None
|
||
else:
|
||
refined, refined_pixels = _refine_with_fine_upscaling(
|
||
model=model, vae=vae, current=refine_source, current_pixels=current_pixels, mask=mask,
|
||
scale_x=scale_x, scale_y=scale_y,
|
||
target_mp=float(min_megapixels),
|
||
max_linear=float(max_upscale),
|
||
resize_method=str(resize_method),
|
||
context_pad_pixel=int(fine_context_pad),
|
||
inpainting_mode="Refine",
|
||
remove_mode=True,
|
||
seed=pass_seed, steps=steps, cfg=cfg,
|
||
sampler_name=sampler_name, scheduler=scheduler,
|
||
positive=remove_pass_positive, negative=refine_negative,
|
||
denoise=1.0, callback=callback, disable_pbar=disable_pbar,
|
||
ov_guider=ov_guider, ov_sampler=ov_sampler, ov_sigmas=ov_sigmas,
|
||
)
|
||
elif fine_upscaling:
|
||
refined, refined_pixels = _refine_with_fine_upscaling(
|
||
model=model, vae=vae, current=refine_source, current_pixels=current_pixels, mask=mask,
|
||
scale_x=scale_x, scale_y=scale_y,
|
||
target_mp=float(min_megapixels),
|
||
max_linear=float(max_upscale),
|
||
resize_method=str(resize_method),
|
||
context_pad_pixel=int(fine_context_pad),
|
||
inpainting_mode=str(inpainting_mode),
|
||
reference_strength=ref_strength,
|
||
seed=pass_seed, steps=steps, cfg=cfg,
|
||
sampler_name=sampler_name, scheduler=scheduler,
|
||
positive=refine_positive, negative=refine_negative,
|
||
denoise=denoise, callback=callback, disable_pbar=disable_pbar,
|
||
# #8: pass the custom-sampler trio through so the
|
||
# internal sample call inside Fine Upscale uses the
|
||
# same dispatch logic.
|
||
ov_guider=ov_guider, ov_sampler=ov_sampler, ov_sigmas=ov_sigmas,
|
||
)
|
||
else:
|
||
noise = comfy.sample.prepare_noise(refine_source, pass_seed, None)
|
||
# #8: dispatched via _do_sample, see Sampler Mode note.
|
||
refined = _do_sample(
|
||
guider=ov_guider, sampler=ov_sampler, sigmas=ov_sigmas,
|
||
model=model, noise=noise,
|
||
steps=steps, cfg=cfg, sampler_name=sampler_name, scheduler=scheduler,
|
||
positive=refine_positive, negative=refine_negative,
|
||
source_latent=refine_source,
|
||
denoise=denoise,
|
||
noise_mask=mask,
|
||
callback=callback,
|
||
disable_pbar=disable_pbar,
|
||
seed=pass_seed,
|
||
)
|
||
refined_pixels = None
|
||
pass_results.append((refined, refined_pixels))
|
||
|
||
if vary_now:
|
||
# Stash candidates + one preview ref each for the JS chooser.
|
||
# History is untouched — the node keeps outputting the
|
||
# existing last attempt until the user picks (vary_pick).
|
||
state["vary_candidates"] = pass_results
|
||
vary_refs = []
|
||
for (lat_k, px_k) in pass_results:
|
||
if px_k is not None:
|
||
previewer = comfy_nodes.PreviewImage()
|
||
ui_k = previewer.save_images(px_k, filename_prefix="Angelo_vary")
|
||
vary_refs.append(ui_k["ui"]["images"][0])
|
||
else:
|
||
_img_k, refs_k = _decode_to_preview(vae, lat_k)
|
||
vary_refs.append(refs_k[0])
|
||
state["vary_preview_refs"] = vary_refs
|
||
state["click_seq"] = click_seq
|
||
state["refine_seed_at_run"] = int(seed)
|
||
# Reload the real last attempt for the node's outputs —
|
||
# `current` was redirected to history[-2] for sampling.
|
||
hist_last = state["history"][-1]
|
||
if isinstance(hist_last, tuple):
|
||
current, current_pixels = hist_last
|
||
else:
|
||
current, current_pixels = hist_last, None
|
||
else:
|
||
refined, refined_pixels = pass_results[0]
|
||
if refined is refine_source:
|
||
# The pass bailed and returned the source unchanged
|
||
# (empty mask bbox, or Remove without a CLIP). Don't
|
||
# push a duplicate history entry or wipe the redo /
|
||
# vary stashes over a no-op — that killed Redo and made
|
||
# the next Undo appear dead. Reload history[-1] for the
|
||
# outputs (a re-roll redirected `current` to the pre-
|
||
# edit base for sampling).
|
||
print("[Angelo] edit was a no-op (empty mask or missing "
|
||
"CLIP) — history unchanged")
|
||
state["click_seq"] = click_seq
|
||
hist_last = state["history"][-1]
|
||
if isinstance(hist_last, tuple):
|
||
current, current_pixels = hist_last
|
||
else:
|
||
current, current_pixels = hist_last, None
|
||
else:
|
||
if reroll_now:
|
||
# Cancel-safe replace: the attempt being re-rolled
|
||
# stayed in history while sampling ran; only now,
|
||
# on success, is it swapped for the fresh take.
|
||
state["history"][-1] = (refined, refined_pixels)
|
||
else:
|
||
state["history"].append((refined, refined_pixels))
|
||
if len(state["history"]) > _HISTORY_CAP:
|
||
state["history"] = state["history"][-_HISTORY_CAP:]
|
||
# A genuine new edit (click or re-roll) invalidates the
|
||
# redo branch and any stale Vary / Outpaint stash.
|
||
state["redo_stack"] = []
|
||
state["vary_candidates"] = None
|
||
state["outpaint_pending"] = None
|
||
state["click_seq"] = click_seq
|
||
state["refine_seed_at_run"] = int(seed)
|
||
state["quick_last"] = False
|
||
# Provenance: history[-1] is a mask-widget edit, so
|
||
# Re-roll / Vary may re-run those widgets. Cleared by
|
||
# Undo/Redo and the ✨ pass, whose masks don't match.
|
||
state["last_edit_kind"] = "mask"
|
||
current = refined
|
||
current_pixels = refined_pixels
|
||
|
||
out_latent = {"samples": current}
|
||
ui_msg = {
|
||
"Angelo_preview": [],
|
||
"Angelo_mode": ["Edit Mode"],
|
||
"Angelo_refine_seed_at_run": [
|
||
int(state["refine_seed_at_run"])
|
||
if state.get("refine_seed_at_run") is not None else int(seed)
|
||
],
|
||
}
|
||
|
||
if current_pixels is not None:
|
||
image = current_pixels
|
||
previewer = comfy_nodes.PreviewImage()
|
||
ui = previewer.save_images(image, filename_prefix="Angelo_preview")
|
||
ui_msg["Angelo_preview"] = ui["ui"]["images"]
|
||
else:
|
||
image, image_refs = _decode_to_preview(vae, current)
|
||
ui_msg["Angelo_preview"] = image_refs
|
||
|
||
# Source image (#3/#9): the session base, decoded once and cached so
|
||
# repeated edits don't re-decode it (and so it survives history[0]
|
||
# eviction under _HISTORY_CAP). source_latent is set at every base
|
||
# (re)establishment; the history[0] fallback only covers pre-existing
|
||
# in-memory state from before this feature.
|
||
source_image = state.get("source_pixels")
|
||
if source_image is None:
|
||
src_latent = state.get("source_latent")
|
||
if src_latent is None:
|
||
h0 = state["history"][0]
|
||
src_latent = h0[0] if isinstance(h0, tuple) else h0
|
||
source_image = _vae_decode(vae, src_latent)
|
||
state["source_pixels"] = source_image
|
||
|
||
# Hold-to-compare: ship a viewable ref to the BASE image so the JS
|
||
# can flash it under the '\' key. Saved to temp once per session
|
||
# (per base) and the refs cached — repeat edits reuse them.
|
||
src_refs = state.get("source_preview_refs")
|
||
if src_refs is None:
|
||
previewer = comfy_nodes.PreviewImage()
|
||
ui_src = previewer.save_images(source_image, filename_prefix="Angelo_source")
|
||
src_refs = ui_src["ui"]["images"]
|
||
state["source_preview_refs"] = src_refs
|
||
ui_msg["Angelo_source_preview"] = src_refs
|
||
|
||
# Vary ×4: ship the candidate previews exactly once (popped, not
|
||
# read) — only the run that generated them opens the JS chooser;
|
||
# later runs must not re-open it.
|
||
vary_refs_out = state.pop("vary_preview_refs", None)
|
||
if vary_refs_out:
|
||
ui_msg["Angelo_vary_previews"] = vary_refs_out
|
||
|
||
# Outpaint: same pop-once contract for the review overlay.
|
||
op_refs_out = state.pop("outpaint_preview_refs", None)
|
||
if op_refs_out:
|
||
ui_msg["Angelo_outpaint_preview"] = op_refs_out
|
||
|
||
return {"ui": ui_msg, "result": (image, out_latent, source_image)}
|
||
|
||
|
||
class AngeloOverrides:
|
||
"""Companion node: bundle non-default Angelo settings into a single
|
||
ANGELO_OVERRIDES dict, wire it into Angelo's `overrides` slot, and
|
||
Angelo applies any non-sentinel field for that run. Originally added
|
||
for #25 (sampler-config overrides). Also carries display flags like
|
||
`disable_live_preview` (#21) so a single companion node covers every
|
||
"drive this from the workflow instead of the toolbar" use case.
|
||
|
||
Sampler-config fields use sentinel defaults ("(toolbar)" for the
|
||
combo dropdowns, -1 for INT / FLOAT) meaning "don't override this
|
||
one — use Angelo's toolbar value". Display flags default to the
|
||
same behaviour Angelo has without an Overrides node connected, so
|
||
leaving them alone is a no-op.
|
||
|
||
Gen bundle (generation model): wire gen_model + gen_positive +
|
||
gen_vae (gen_negative optional) to make Sampler Mode generate the
|
||
base with a DIFFERENT model than the edit model — e.g. generate
|
||
with FLUX dev / SDXL for look, then click-to-edit with FLUX 2 Klein
|
||
or Qwen-Image-Edit. Angelo samples with the gen stack using the
|
||
gen_* settings below, decodes with gen_vae, and re-encodes the
|
||
pixels with the main edit VAE so the whole edit session stays in
|
||
the edit model's latent space. Edit Mode never uses the gen bundle.
|
||
Seed and denoise for the base generation stay on Angelo's toolbar
|
||
(sampler_seed / Smpl Denoise) so the seed controls keep working.
|
||
|
||
WIDGET ORDER IS APPEND-ONLY: this node lives in saved workflows
|
||
with positional widgets_values — new widgets go after gen_scheduler,
|
||
never between existing ones."""
|
||
|
||
_SENTINEL = "(toolbar)"
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"steps": ("INT", {"default": -1, "min": -1, "max": 100, "step": 1,
|
||
"tooltip": "-1 = use Angelo's toolbar value."}),
|
||
"cfg": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 30.0, "step": 0.1,
|
||
"tooltip": "-1 = use Angelo's toolbar value."}),
|
||
"sampler_name": ([cls._SENTINEL] + list(comfy.samplers.KSampler.SAMPLERS),
|
||
{"default": cls._SENTINEL,
|
||
"tooltip": "(toolbar) = use Angelo's toolbar value."}),
|
||
"scheduler": ([cls._SENTINEL] + list(comfy.samplers.KSampler.SCHEDULERS),
|
||
{"default": cls._SENTINEL,
|
||
"tooltip": "(toolbar) = use Angelo's toolbar value."}),
|
||
"disable_live_preview": ("BOOLEAN", {"default": False,
|
||
"tooltip": "When ON, suppress ComfyUI's "
|
||
"live latent preview for this "
|
||
"Angelo node only (#21). "
|
||
"Useful if the global preview "
|
||
"is squashing Angelo's editor "
|
||
"area but you still want previews "
|
||
"on your other samplers."}),
|
||
},
|
||
"optional": {
|
||
# Custom-sampler trio (#8). Wire ALL THREE from a proper
|
||
# SamplerCustomAdvanced-style chain (a GUIDER node like
|
||
# CFGGuider/BasicGuider + a KSamplerSelect SAMPLER + a
|
||
# BasicScheduler/Karras/etc. SIGMAS at denoise=1.0) to
|
||
# replace Angelo's toolbar sampler/scheduler entirely.
|
||
# When all three are wired, Angelo uses the guider path
|
||
# and the toolbar's steps/cfg/sampler_name/scheduler are
|
||
# ignored (sigmas encode the schedule; guider encodes
|
||
# cfg + the sampling math). Partial wiring (e.g. sampler
|
||
# without sigmas) silently falls through to the default.
|
||
# Angelo's per-call denoise still applies — the sigmas
|
||
# are tail-sliced to honour it (same as
|
||
# SplitSigmasDenoise). Conds (Refine vs Area Prompt vs
|
||
# Smart Inpaint reference_latents) are re-set on the
|
||
# guider per call so the wired guider is generic.
|
||
"guider": ("GUIDER",),
|
||
"sampler": ("SAMPLER",),
|
||
"sigmas": ("SIGMAS",),
|
||
# Gen bundle: a SECOND model stack used ONLY for Sampler
|
||
# Mode base generation. gen_positive/gen_negative must be
|
||
# encoded with the GEN model's own CLIP — conditioning is
|
||
# not portable across model families. gen_negative is
|
||
# optional (falls back to a zeroed-out copy of
|
||
# gen_positive, same as ConditioningZeroOut).
|
||
"gen_model": ("MODEL", {"tooltip": "Generation model for Sampler Mode. "
|
||
"When wired (with gen_positive + "
|
||
"gen_vae), Angelo generates the base "
|
||
"with THIS model, then hands the "
|
||
"pixels to the edit model for the "
|
||
"editing session. Size Angelo's wired "
|
||
"latent for THIS model's VAE ratio "
|
||
"(a plain EmptyLatentImage for "
|
||
"SD-class models)."}),
|
||
"gen_positive": ("CONDITIONING", {"tooltip": "Positive conditioning for "
|
||
"gen_model — encode with the "
|
||
"GEN model's CLIP, not the "
|
||
"edit model's."}),
|
||
"gen_negative": ("CONDITIONING", {"tooltip": "Optional negative for "
|
||
"gen_model. Unwired = zeroed "
|
||
"gen_positive (like "
|
||
"ConditioningZeroOut)."}),
|
||
"gen_vae": ("VAE", {"tooltip": "VAE matching gen_model — used to decode "
|
||
"the generated base before it is re-"
|
||
"encoded with the edit VAE."}),
|
||
# ===== Gen-bundle settings (append-only widget tail) =====
|
||
# Only read when gen_model is wired above. Real defaults, no
|
||
# sentinels — there is no toolbar value to fall back to
|
||
# because these describe the SECOND model. OPTIONAL, not
|
||
# required: pre-gen-bundle API-format exports don't carry
|
||
# these keys, and ComfyUI's prompt validation rejects a
|
||
# missing REQUIRED input before build()'s signature defaults
|
||
# can apply. Optional widgets look identical in the UI but
|
||
# let old API prompts keep validating.
|
||
"gen_steps": ("INT", {"default": 25, "min": 1, "max": 100, "step": 1,
|
||
"tooltip": "[Gen bundle] Steps for the base "
|
||
"generation with gen_model."}),
|
||
"gen_cfg": ("FLOAT", {"default": 5.0, "min": 0.0, "max": 30.0, "step": 0.1,
|
||
"tooltip": "[Gen bundle] CFG for the base "
|
||
"generation with gen_model."}),
|
||
"gen_sampler_name": (list(comfy.samplers.KSampler.SAMPLERS),
|
||
{"default": "euler",
|
||
"tooltip": "[Gen bundle] Sampler for the base "
|
||
"generation with gen_model."}),
|
||
"gen_scheduler": (list(comfy.samplers.KSampler.SCHEDULERS),
|
||
{"default": "normal",
|
||
"tooltip": "[Gen bundle] Scheduler for the base "
|
||
"generation with gen_model."}),
|
||
},
|
||
}
|
||
|
||
RETURN_TYPES = ("ANGELO_OVERRIDES",)
|
||
RETURN_NAMES = ("overrides",)
|
||
FUNCTION = "build"
|
||
CATEGORY = "sampling/Angelo"
|
||
DESCRIPTION = (
|
||
"Bundle Angelo settings into one wire that drives them from "
|
||
"your workflow instead of the toolbar: steps / cfg / sampler / "
|
||
"scheduler (per-field opt-in via sentinel defaults), plus the "
|
||
"disable_live_preview flag for #21. Also carries an optional "
|
||
"gen bundle (gen_model / gen_positive / gen_negative / gen_vae "
|
||
"+ gen_* settings): generate the base in Sampler Mode with a "
|
||
"dedicated generation model, then edit with the main edit model."
|
||
)
|
||
|
||
def build(self, steps, cfg, sampler_name, scheduler, disable_live_preview,
|
||
gen_steps=25, gen_cfg=5.0, gen_sampler_name="euler", gen_scheduler="normal",
|
||
guider=None, sampler=None, sigmas=None,
|
||
gen_model=None, gen_positive=None, gen_negative=None, gen_vae=None):
|
||
bundle = {
|
||
"steps": steps if isinstance(steps, int) and steps >= 1 else None,
|
||
"cfg": float(cfg) if isinstance(cfg, (int, float)) and cfg >= 0 else None,
|
||
"sampler_name": sampler_name if sampler_name != self._SENTINEL else None,
|
||
"scheduler": scheduler if scheduler != self._SENTINEL else None,
|
||
"disable_live_preview": bool(disable_live_preview),
|
||
# Custom-sampler trio (#8) — None if not wired, all-or-nothing
|
||
# at use time inside _do_sample.
|
||
"guider": guider,
|
||
"sampler": sampler,
|
||
"sigmas": sigmas,
|
||
# Gen bundle — None if not wired. Angelo validates the
|
||
# model+positive+vae triple at use time (Sampler Mode only).
|
||
"gen_model": gen_model,
|
||
"gen_positive": gen_positive,
|
||
"gen_negative": gen_negative,
|
||
"gen_vae": gen_vae,
|
||
"gen_steps": int(gen_steps),
|
||
"gen_cfg": float(gen_cfg),
|
||
"gen_sampler_name": gen_sampler_name,
|
||
"gen_scheduler": gen_scheduler,
|
||
}
|
||
return (bundle,)
|
||
|
||
|
||
NODE_CLASS_MAPPINGS = {
|
||
"AngeloRefine": AngeloRefine,
|
||
"AngeloOverrides": AngeloOverrides,
|
||
}
|
||
|
||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||
"AngeloRefine": "Angelo — click to refine",
|
||
"AngeloOverrides": "Angelo — Overrides",
|
||
}
|