Three behaviours that are right for SDXL but wrong for the new families. clip_layer() drops the last N layers of a CLIP text encoder. Anima, Krea2 and Z-Image encode with Qwen3 / Qwen3-VL, where it is meaningless and can raise. Presets carry clip_skip: -2 by default regardless of family, so gate on the family flag rather than on the value being set. Distilled models sample at cfg 1 with a zeroed negative, as every shipped template does. Key it off the effective cfg rather than the family: Anima base at cfg 4 keeps its real negative, Anima + turbo LoRA at cfg 1 does not, and no per-family flag can drift from what was actually sampled. Applied in FlakeGenerate, the one point where conditioning and cfg are both final — upstream nodes re-encode on every hop and cfg can still move via a per-instance override (#279). Verified ConditioningZeroOut copies the metadata dict, so a ControlNet's control key survives. Flow-matching families also get their ModelSamplingAuraFlow shift, preset value winning over the family default.
658 lines
26 KiB
Python
658 lines
26 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
import os
|
|
|
|
try:
|
|
from comfy.samplers import KSampler as _KSampler
|
|
_SAMPLER_TYPES = (_KSampler.SAMPLERS, _KSampler.SCHEDULERS)
|
|
except Exception:
|
|
_SAMPLER_TYPES = ("STRING", "STRING")
|
|
|
|
import folder_paths
|
|
from nodes import KSampler, VAEDecode, SaveImage
|
|
|
|
from . import flake_compose
|
|
from .full_flake_node import _build_filename_prefix
|
|
|
|
_ADETAILER_DEFAULT_BBOX = "bbox/face_yolov8m.pt"
|
|
|
|
|
|
def _list_adetailer_bbox() -> list[str]:
|
|
"""ADetailer bbox model choices (#306): Impact Pack's ultralytics_bbox plus a
|
|
direct scan of models/adetailer/. Always includes the default so the COMBO is
|
|
never empty."""
|
|
names: list[str] = []
|
|
try:
|
|
names += folder_paths.get_filename_list("ultralytics_bbox")
|
|
except Exception:
|
|
pass
|
|
base = os.path.join(folder_paths.base_path, "models", "adetailer")
|
|
if os.path.isdir(base):
|
|
for root, _dirs, files in os.walk(base):
|
|
for f in files:
|
|
if f.lower().endswith((".pt", ".pth", ".onnx", ".safetensors")):
|
|
rel = os.path.relpath(os.path.join(root, f), base).replace(os.sep, "/")
|
|
names.append(rel)
|
|
out = sorted(set(names))
|
|
if _ADETAILER_DEFAULT_BBOX not in out:
|
|
out.append(_ADETAILER_DEFAULT_BBOX)
|
|
return out
|
|
|
|
|
|
def _list_upscale_models() -> list[str]:
|
|
"""Upscale model choices (#306); "" = plain rescale to factor."""
|
|
try:
|
|
return [""] + folder_paths.get_filename_list("upscale_models")
|
|
except Exception:
|
|
return [""]
|
|
|
|
_ALL_PINS = [
|
|
("model", "MODEL"),
|
|
("clip", "CLIP"),
|
|
("vae", "VAE"),
|
|
("positive", "CONDITIONING"),
|
|
("negative", "CONDITIONING"),
|
|
("latent", "LATENT"),
|
|
("filename_prefix", "STRING"),
|
|
("width", "INT"),
|
|
("height", "INT"),
|
|
("steps", "INT"),
|
|
("cfg", "FLOAT"),
|
|
# Use STRING for outputs — COMBO lists are input-only in ComfyUI and would
|
|
# prevent node registration when used as RETURN_TYPES.
|
|
("sampler_name", "STRING"),
|
|
("scheduler", "STRING"),
|
|
]
|
|
|
|
# Separate pin definitions for input types (used by IntoFlakeDataSelect/IntoFlakeDataAll)
|
|
_ALL_INPUT_TYPES = {
|
|
"sampler_name": _SAMPLER_TYPES[0],
|
|
"scheduler": _SAMPLER_TYPES[1],
|
|
}
|
|
|
|
|
|
def _split_flake_data(flake_data):
|
|
model_bundle, generation_data, sampling_preset = flake_data
|
|
model, clip, vae = model_bundle
|
|
positive, negative, latent, width, height = generation_data[:5]
|
|
pos_text, neg_text = generation_data[5:7]
|
|
if len(generation_data) > 7 and isinstance(generation_data[7], dict):
|
|
filename_prefix = _build_filename_prefix(
|
|
generation_data[7].get("preset", ""),
|
|
generation_data[7].get("stems", []),
|
|
)
|
|
else:
|
|
filename_prefix = ""
|
|
steps, cfg, sampler_name, scheduler = sampling_preset
|
|
return {
|
|
"model": model,
|
|
"clip": clip,
|
|
"vae": vae,
|
|
"positive": positive,
|
|
"negative": negative,
|
|
"latent": latent,
|
|
"filename_prefix": filename_prefix,
|
|
"width": width,
|
|
"height": height,
|
|
"steps": steps,
|
|
"cfg": cfg,
|
|
"sampler_name": sampler_name,
|
|
"scheduler": scheduler,
|
|
}
|
|
|
|
|
|
class FlakeDataSplitAll:
|
|
"""Splits FLAKE_DATA into all individual outputs (replacing Flake Model Split,
|
|
Flake Generation Data, and Flake Sampling Values)."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"flake_data": ("FLAKE_DATA",),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = tuple(t for _, t in _ALL_PINS)
|
|
RETURN_NAMES = tuple(n for n, _ in _ALL_PINS)
|
|
FUNCTION = "execute"
|
|
CATEGORY = "flakes"
|
|
DESCRIPTION = "Split FLAKE_DATA into all individual model, generation, and sampler outputs."
|
|
|
|
def execute(self, flake_data):
|
|
parts = _split_flake_data(flake_data)
|
|
return tuple(parts[n] for n, _ in _ALL_PINS)
|
|
|
|
|
|
class FlakeDataSplitSelect:
|
|
"""Splits FLAKE_DATA into individually selectable output pins.
|
|
Use the dropdown and [+] button on the node to add output pins."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"flake_data": ("FLAKE_DATA",),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = tuple(t for _, t in _ALL_PINS)
|
|
RETURN_NAMES = tuple(n for n, _ in _ALL_PINS)
|
|
FUNCTION = "execute"
|
|
CATEGORY = "flakes"
|
|
DESCRIPTION = "Split FLAKE_DATA into individually selectable output pins. Use the dropdown and +/- buttons to add or remove pins."
|
|
|
|
def execute(self, flake_data):
|
|
parts = _split_flake_data(flake_data)
|
|
return tuple(parts[n] for n, _ in _ALL_PINS)
|
|
|
|
|
|
class IntoFlakeDataAll:
|
|
"""Combines model, clip, vae, conditioning, latent, and sampler values into FLAKE_DATA.
|
|
All inputs are optional — connect only the ones you need."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {},
|
|
"optional": {
|
|
"model": ("MODEL",),
|
|
"clip": ("CLIP",),
|
|
"vae": ("VAE",),
|
|
"positive": ("CONDITIONING",),
|
|
"negative": ("CONDITIONING",),
|
|
"latent": ("LATENT",),
|
|
"width": ("INT", {"default": 1024}),
|
|
"height": ("INT", {"default": 1024}),
|
|
"steps": ("INT", {"default": 20}),
|
|
"cfg": ("FLOAT", {"default": 7.0}),
|
|
"sampler_name": (_SAMPLER_TYPES[0], {"default": "euler"}),
|
|
"scheduler": (_SAMPLER_TYPES[1], {"default": "normal"}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("FLAKE_DATA",)
|
|
RETURN_NAMES = ("flake_data",)
|
|
FUNCTION = "execute"
|
|
CATEGORY = "flakes"
|
|
DESCRIPTION = "Combine individual model, generation, and sampler values into a single FLAKE_DATA pin. Only connect the inputs you need."
|
|
|
|
def execute(self, model=None, clip=None, vae=None, positive=None, negative=None,
|
|
latent=None, width=1024, height=1024, steps=20, cfg=7.0,
|
|
sampler_name="euler", scheduler="normal"):
|
|
if model is not None and clip is not None and vae is not None:
|
|
model_bundle = (model, clip, vae)
|
|
else:
|
|
model_bundle = None
|
|
|
|
filename_state = {"preset": "", "stems": []}
|
|
pos_text = ""
|
|
neg_text = ""
|
|
if positive is not None and negative is not None:
|
|
generation_data = (positive, negative, latent, width, height, pos_text, neg_text, filename_state)
|
|
else:
|
|
generation_data = None
|
|
|
|
sampling_preset = (steps, cfg, sampler_name, scheduler)
|
|
|
|
return ((model_bundle, generation_data, sampling_preset),)
|
|
|
|
|
|
class IntoFlakeDataSelect:
|
|
"""Selectively override fields of an incoming FLAKE_DATA with individual values.
|
|
Use the dropdown and [+] button to add input pins dynamically.
|
|
Only connected pins override the incoming data; all others pass through unchanged."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"flake_data": ("FLAKE_DATA",),
|
|
},
|
|
"optional": {
|
|
"model": ("MODEL",),
|
|
"clip": ("CLIP",),
|
|
"vae": ("VAE",),
|
|
"positive": ("CONDITIONING",),
|
|
"negative": ("CONDITIONING",),
|
|
"latent": ("LATENT",),
|
|
"filename_prefix": ("STRING",),
|
|
"width": ("INT",),
|
|
"height": ("INT",),
|
|
"steps": ("INT",),
|
|
"cfg": ("FLOAT",),
|
|
"sampler_name": (_SAMPLER_TYPES[0],),
|
|
"scheduler": (_SAMPLER_TYPES[1],),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("FLAKE_DATA",)
|
|
RETURN_NAMES = ("flake_data",)
|
|
FUNCTION = "execute"
|
|
CATEGORY = "flakes"
|
|
DESCRIPTION = "Override individual fields of an incoming FLAKE_DATA. Use the dropdown and [+] button to add input pins. Only connected pins override the original data."
|
|
|
|
def execute(self, flake_data, model=None, clip=None, vae=None,
|
|
positive=None, negative=None, latent=None,
|
|
filename_prefix=None, width=None, height=None,
|
|
steps=None, cfg=None, sampler_name=None, scheduler=None):
|
|
orig_model_bundle, orig_generation_data, orig_sampling_preset = flake_data
|
|
orig_model, orig_clip, orig_vae = orig_model_bundle
|
|
orig_positive, orig_negative, orig_latent, orig_width, orig_height = orig_generation_data[:5]
|
|
orig_pos_text, orig_neg_text = orig_generation_data[5:7]
|
|
orig_filename_state = {}
|
|
if len(orig_generation_data) > 7 and isinstance(orig_generation_data[7], dict):
|
|
orig_filename_state = orig_generation_data[7]
|
|
orig_steps, orig_cfg, orig_sampler, orig_scheduler = orig_sampling_preset
|
|
|
|
new_model_bundle = (
|
|
model if model is not None else orig_model,
|
|
clip if clip is not None else orig_clip,
|
|
vae if vae is not None else orig_vae,
|
|
)
|
|
new_filename_state = dict(orig_filename_state)
|
|
if filename_prefix is not None:
|
|
new_filename_state["preset"] = filename_prefix
|
|
|
|
new_generation_data = (
|
|
positive if positive is not None else orig_positive,
|
|
negative if negative is not None else orig_negative,
|
|
latent if latent is not None else orig_latent,
|
|
width if width is not None else orig_width,
|
|
height if height is not None else orig_height,
|
|
orig_pos_text,
|
|
orig_neg_text,
|
|
new_filename_state,
|
|
)
|
|
new_sampling_preset = (
|
|
steps if steps is not None else orig_steps,
|
|
cfg if cfg is not None else orig_cfg,
|
|
sampler_name if sampler_name is not None else orig_sampler,
|
|
scheduler if scheduler is not None else orig_scheduler,
|
|
)
|
|
|
|
return ((new_model_bundle, new_generation_data, new_sampling_preset),)
|
|
|
|
|
|
class PreviewFlakeData:
|
|
"""Pass-through node that displays a preview of the data in a FLAKE_DATA pin.
|
|
Shows Models, Prompts, Parameters, and Metadata in a grid overlay.
|
|
The FLAKE_DATA passes through unchanged."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"flake_data": ("FLAKE_DATA",),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("FLAKE_DATA",)
|
|
RETURN_NAMES = ("flake_data",)
|
|
FUNCTION = "execute"
|
|
CATEGORY = "flakes"
|
|
DESCRIPTION = "Preview the contents of a FLAKE_DATA pin. Shows Models, Prompts, Parameters, and Metadata. The data passes through unchanged."
|
|
OUTPUT_NODE = True
|
|
|
|
def execute(self, flake_data):
|
|
model_bundle, generation_data, sampling_preset = flake_data
|
|
|
|
models_info = {}
|
|
prompts_info = {}
|
|
params_info = {}
|
|
meta_info = {}
|
|
|
|
meta = {}
|
|
if generation_data is not None and len(generation_data) > 7 and isinstance(generation_data[7], dict):
|
|
meta = generation_data[7]
|
|
|
|
# --- Models ---
|
|
if model_bundle is not None:
|
|
model, clip, vae = model_bundle
|
|
|
|
ckpt_name = meta.get("checkpoint", "")
|
|
if ckpt_name:
|
|
models_info["Checkpoint"] = ckpt_name
|
|
elif model is not None:
|
|
ckpt_attr = getattr(model, "sd_checkpoint_name", None)
|
|
models_info["Checkpoint"] = ckpt_attr if ckpt_attr else "(loaded)"
|
|
|
|
vae_name = meta.get("vae")
|
|
if vae_name is not None:
|
|
models_info["VAE"] = vae_name if vae_name != "baked-in" else "baked-in"
|
|
else:
|
|
models_info["VAE"] = "(loaded)" if vae is not None else "none"
|
|
|
|
te_name = meta.get("text_encoder")
|
|
if te_name is not None:
|
|
models_info["Text Encoder"] = te_name if te_name != "baked-in" else "baked-in"
|
|
else:
|
|
models_info["Text Encoder"] = "(loaded)" if clip is not None else "none"
|
|
|
|
loras = meta.get("loras", [])
|
|
for i, lr in enumerate(loras):
|
|
lr_display = lr.get("name", "") or lr.get("path", "") or f"LoRA #{i + 1}"
|
|
lr_strength = lr.get("strength", 1.0)
|
|
models_info[lr_display] = f"strength: {lr_strength}"
|
|
|
|
# --- Prompts ---
|
|
if generation_data is not None:
|
|
pos_text = generation_data[5] if len(generation_data) > 5 else ""
|
|
neg_text = generation_data[6] if len(generation_data) > 6 else ""
|
|
if pos_text:
|
|
prompts_info["Positive"] = str(pos_text)
|
|
if neg_text:
|
|
prompts_info["Negative"] = str(neg_text)
|
|
|
|
# --- Parameters ---
|
|
if sampling_preset is not None:
|
|
steps, cfg, sampler_name, scheduler = sampling_preset
|
|
params_info["Steps"] = str(steps)
|
|
params_info["CFG"] = str(cfg)
|
|
params_info["Sampler"] = str(sampler_name)
|
|
params_info["Scheduler"] = str(scheduler)
|
|
|
|
# --- Metadata ---
|
|
if generation_data is not None:
|
|
width = generation_data[3] if len(generation_data) > 3 else None
|
|
height = generation_data[4] if len(generation_data) > 4 else None
|
|
if width is not None:
|
|
meta_info["Width"] = str(width)
|
|
if height is not None:
|
|
meta_info["Height"] = str(height)
|
|
|
|
preset = meta.get("preset", "")
|
|
stems = meta.get("stems", [])
|
|
fname_parts = []
|
|
if preset:
|
|
fname_parts.append(preset)
|
|
if stems:
|
|
fname_parts.append("/".join(stems))
|
|
fname = "/".join(fname_parts) if fname_parts else ""
|
|
if fname:
|
|
meta_info["Filename Prefix"] = fname
|
|
|
|
preview_data = {
|
|
"Models": models_info,
|
|
"Prompts": prompts_info,
|
|
"Parameters": params_info,
|
|
"Metadata": meta_info,
|
|
}
|
|
|
|
return {
|
|
"ui": {"preview_data": [preview_data]},
|
|
"result": (flake_data,),
|
|
}
|
|
|
|
|
|
def _strip_unused_flakes(prompt, extra_pnginfo):
|
|
"""Return deep copies of ``prompt`` and ``extra_pnginfo`` in which every
|
|
FlakeStack/FlakeCombo node's ``flakes_json`` is reduced to the *active*
|
|
flakes only — dropping ``bypassed`` and ``inline`` entries (#346).
|
|
|
|
Combo nodes already carry a single flake per generation (queue.js sets that
|
|
at queue time), so the filter is a no-op there; stacks keep only their active
|
|
flakes. The originals are never mutated; the stripped copies are what gets
|
|
embedded in the saved PNG so a drag-drop restore reflects what was used.
|
|
"""
|
|
if not prompt:
|
|
return prompt, extra_pnginfo
|
|
prompt = copy.deepcopy(prompt)
|
|
extra_pnginfo = copy.deepcopy(extra_pnginfo) if extra_pnginfo else extra_pnginfo
|
|
|
|
wf_nodes = {}
|
|
workflow = extra_pnginfo.get("workflow") if isinstance(extra_pnginfo, dict) else None
|
|
if isinstance(workflow, dict):
|
|
for n in (workflow.get("nodes") or []):
|
|
if isinstance(n, dict):
|
|
wf_nodes[str(n.get("id"))] = n
|
|
|
|
items = prompt.items() if isinstance(prompt, dict) else []
|
|
for nid, node in items:
|
|
if not isinstance(node, dict) or node.get("class_type") not in ("FlakeStack", "FlakeCombo"):
|
|
continue
|
|
inputs = node.get("inputs")
|
|
raw = inputs.get("flakes_json") if isinstance(inputs, dict) else None
|
|
if not isinstance(raw, str):
|
|
continue
|
|
try:
|
|
arr = json.loads(raw)
|
|
except Exception:
|
|
continue
|
|
if not isinstance(arr, list):
|
|
continue
|
|
filtered = [f for f in arr if isinstance(f, dict) and not f.get("bypassed") and not f.get("inline")]
|
|
new_raw = json.dumps(filtered)
|
|
if new_raw == raw:
|
|
continue
|
|
inputs["flakes_json"] = new_raw
|
|
# Mirror into the workflow node's positional widgets_values by matching
|
|
# the original flakes_json string (robust to widget ordering).
|
|
wf = wf_nodes.get(str(nid))
|
|
if wf and isinstance(wf.get("widgets_values"), list):
|
|
wf["widgets_values"] = [new_raw if v == raw else v for v in wf["widgets_values"]]
|
|
|
|
return prompt, extra_pnginfo
|
|
|
|
|
|
class FlakeGenerate:
|
|
"""Takes a FLAKE_DATA input and runs KSampler + VAE Decode + Save Image
|
|
internally. Displays a seed widget, a 2x2 preview grid, and the generated
|
|
image. Has no output pins."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"flake_data": ("FLAKE_DATA",),
|
|
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "control_after_generate": True}),
|
|
},
|
|
"optional": {
|
|
# ADetailer post-process (#287, SDXL-first). Off / Face / A/B; A/B
|
|
# generates each image twice — once without, once with ADetailer (#327).
|
|
"adetailer": (["Off", "Face", "A/B"], {"default": "Off"}),
|
|
"adetailer_denoise": ("FLOAT", {"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.05}),
|
|
# ADetailer sampling steps (#301). 0 = reuse the first-pass steps.
|
|
"adetailer_steps": ("INT", {"default": 0, "min": 0, "max": 150, "step": 1, "tooltip": "ADetailer (Face Detailer) sampling steps. 0 = use the same steps as the first pass."}),
|
|
# bbox model dropdown from ultralytics_bbox / models/adetailer (#306).
|
|
"adetailer_bbox": (_list_adetailer_bbox(), {"default": _ADETAILER_DEFAULT_BBOX}),
|
|
# Upscale post-process (#288). upscale_model is a filename under
|
|
# models/upscale_models/ (blank = plain rescale to factor).
|
|
"upscale": ("BOOLEAN", {"default": False, "label_on": "Upscale", "label_off": "Upscale off"}),
|
|
"upscale_model": (_list_upscale_models(), {"default": ""}),
|
|
"upscale_factor": ("FLOAT", {"default": 1.5, "min": 1.0, "max": 8.0, "step": 0.1}),
|
|
},
|
|
# Embed the workflow/prompt into saved PNGs so outputs can be
|
|
# drag-dropped back into ComfyUI to restore the graph (#344).
|
|
"hidden": {
|
|
"prompt": "PROMPT",
|
|
"extra_pnginfo": "EXTRA_PNGINFO",
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ()
|
|
FUNCTION = "execute"
|
|
CATEGORY = "flakes"
|
|
DESCRIPTION = "Generate an image from FLAKE_DATA using KSampler, VAE Decode, and Save Image. Displays the result inline."
|
|
OUTPUT_NODE = True
|
|
|
|
def execute(self, flake_data, seed, adetailer="Off", adetailer_denoise=0.4,
|
|
adetailer_steps=0, adetailer_bbox="bbox/face_yolov8m.pt",
|
|
upscale=False, upscale_model="", upscale_factor=1.5,
|
|
prompt=None, extra_pnginfo=None):
|
|
parts = _split_flake_data(flake_data)
|
|
model = parts["model"]
|
|
clip = parts["clip"]
|
|
vae = parts["vae"]
|
|
positive = parts["positive"]
|
|
negative = parts["negative"]
|
|
latent = parts["latent"]
|
|
filename_prefix = parts["filename_prefix"] or "ComfyUI"
|
|
steps = parts["steps"]
|
|
cfg = parts["cfg"]
|
|
sampler_name = parts["sampler_name"]
|
|
scheduler = parts["scheduler"]
|
|
|
|
# Distilled models sample at cfg 1 with a zeroed negative (#359). Done
|
|
# here rather than at preset-load time because this is the one place the
|
|
# conditioning and the cfg are both final: upstream Flake nodes re-encode
|
|
# the accumulated prompt on every hop, and cfg can still be changed by a
|
|
# per-instance override (#279). ControlNets have already been applied by
|
|
# this point, so the `control` key is carried through the zero-out.
|
|
negative = flake_compose.maybe_zero_out_negative(negative, cfg)
|
|
|
|
ks = KSampler()
|
|
sampled = ks.sample(
|
|
model, seed, steps, cfg, sampler_name, scheduler,
|
|
positive, negative, latent, denoise=1.0,
|
|
)
|
|
sampled_latent = sampled[0]
|
|
|
|
vae_dec = VAEDecode()
|
|
images = vae_dec.decode(vae, sampled_latent)[0]
|
|
|
|
base_images = images
|
|
|
|
def _detail(imgs):
|
|
from .flake_postprocess import run_face_detailer
|
|
# 0 means "match the first pass"; otherwise use the explicit count (#301).
|
|
ad_steps = adetailer_steps if adetailer_steps and adetailer_steps > 0 else steps
|
|
return run_face_detailer(
|
|
imgs, model, clip, vae, positive, negative,
|
|
seed, ad_steps, cfg, sampler_name, scheduler,
|
|
denoise=adetailer_denoise, bbox_model=adetailer_bbox,
|
|
)
|
|
|
|
# Build the output variant(s): Off/Face produce one image set; A/B produces
|
|
# two — once without ADetailer, once with (#327). The ADetailer variant is
|
|
# tagged so its stem gets the _ad_ marker (#326).
|
|
if adetailer == "A/B":
|
|
variants = [(base_images, False), (_detail(base_images), True)]
|
|
elif adetailer == "Face":
|
|
variants = [(_detail(base_images), True)]
|
|
else:
|
|
variants = [(base_images, False)]
|
|
|
|
saver = SaveImage()
|
|
output_dir = folder_paths.get_output_directory()
|
|
|
|
# Embed only the used flakes in the saved metadata (#346).
|
|
meta_prompt, meta_extra = _strip_unused_flakes(prompt, extra_pnginfo)
|
|
|
|
def _save(imgs, is_ad):
|
|
# Stem markers before the increment: _ad_ for ADetailer (#326), _up_ for
|
|
# Upscale (#325). Upscale (#288) applies to every variant when on.
|
|
out_imgs, marker = imgs, ""
|
|
if is_ad:
|
|
marker += "_ad"
|
|
if upscale:
|
|
from .flake_postprocess import upscale_images
|
|
out_imgs = upscale_images(imgs, upscale_model, upscale_factor)
|
|
marker += "_up"
|
|
res = saver.save_images(out_imgs, filename_prefix=filename_prefix + marker,
|
|
prompt=meta_prompt, extra_pnginfo=meta_extra)
|
|
ui_imgs = res.get("ui", {}).get("images", [])
|
|
# Strip the trailing underscore ComfyUI's SaveImage hardcodes
|
|
# (e.g. "..._00001_.png" -> "..._00001.png").
|
|
for img_info in ui_imgs:
|
|
old_name = img_info["filename"]
|
|
if old_name.endswith("_.png"):
|
|
new_name = old_name[:-5] + ".png"
|
|
old_path = os.path.join(output_dir, img_info.get("subfolder", ""), old_name)
|
|
new_path = os.path.join(output_dir, img_info.get("subfolder", ""), new_name)
|
|
try:
|
|
if os.path.exists(old_path):
|
|
os.rename(old_path, new_path)
|
|
img_info["filename"] = new_name
|
|
except OSError:
|
|
pass
|
|
return ui_imgs
|
|
|
|
regular_ui, ad_ui = [], []
|
|
for imgs, is_ad in variants:
|
|
saved = _save(imgs, is_ad)
|
|
if is_ad:
|
|
ad_ui = saved
|
|
else:
|
|
regular_ui = saved
|
|
|
|
model_bundle, generation_data, sampling_preset = flake_data
|
|
meta = {}
|
|
if generation_data is not None and len(generation_data) > 7 and isinstance(generation_data[7], dict):
|
|
meta = generation_data[7]
|
|
|
|
# ── Models overlay: checkpoint, vae, text encoder, loras, resolution, sampling ──
|
|
models_info = {}
|
|
|
|
if model_bundle is not None:
|
|
m, c, v = model_bundle
|
|
ckpt_name = meta.get("checkpoint", "")
|
|
if ckpt_name:
|
|
models_info["Checkpoint"] = ckpt_name
|
|
elif m is not None:
|
|
ckpt_attr = getattr(m, "sd_checkpoint_name", None)
|
|
models_info["Checkpoint"] = ckpt_attr if ckpt_attr else "(loaded)"
|
|
|
|
vae_name = meta.get("vae")
|
|
if vae_name is not None:
|
|
models_info["VAE"] = vae_name if vae_name != "baked-in" else "baked-in"
|
|
else:
|
|
models_info["VAE"] = "(loaded)" if v is not None else "none"
|
|
|
|
te_name = meta.get("text_encoder")
|
|
if te_name is not None and te_name != "baked-in":
|
|
models_info["Text Encoder"] = te_name
|
|
|
|
loras = meta.get("loras", [])
|
|
for i, lr in enumerate(loras):
|
|
lr_display = lr.get("name", "") or lr.get("path", "") or f"LoRA #{i + 1}"
|
|
lr_strength = lr.get("strength", 1.0)
|
|
models_info[f"LoRA: {lr_display}"] = f"strength: {lr_strength}"
|
|
|
|
gen_width = generation_data[3] if generation_data and len(generation_data) > 3 else None
|
|
gen_height = generation_data[4] if generation_data and len(generation_data) > 4 else None
|
|
if gen_width is not None:
|
|
models_info["Width"] = str(gen_width)
|
|
if gen_height is not None:
|
|
models_info["Height"] = str(gen_height)
|
|
|
|
if sampling_preset is not None:
|
|
models_info["Steps"] = str(steps)
|
|
models_info["CFG"] = str(cfg)
|
|
models_info["Sampler"] = str(sampler_name)
|
|
models_info["Scheduler"] = str(scheduler)
|
|
|
|
# ── Inputs overlay: prompts (grouped by BREAK) + controlnet info ──
|
|
inputs_info = {}
|
|
if generation_data is not None:
|
|
pos_text = generation_data[5] if len(generation_data) > 5 else ""
|
|
neg_text = generation_data[6] if len(generation_data) > 6 else ""
|
|
pos_segments = [s.strip() for s in pos_text.split(" BREAK ") if s.strip()] if pos_text else []
|
|
neg_segments = [s.strip() for s in neg_text.split(", ") if s.strip()] if neg_text else []
|
|
if pos_segments:
|
|
for i, seg in enumerate(pos_segments):
|
|
label = f"Positive {i + 1}" if len(pos_segments) > 1 else "Positive"
|
|
inputs_info[label] = seg
|
|
if neg_segments:
|
|
inputs_info["Negative"] = ", ".join(neg_segments)
|
|
|
|
preview_data = {
|
|
"Models": models_info,
|
|
"Inputs": inputs_info,
|
|
}
|
|
|
|
# Custom keys ("flake_images") so ComfyUI's default renderer doesn't show a
|
|
# duplicate; the JS widget reads them. For A/B, flake_images is the regular
|
|
# (non-ADetailer) output and flake_images_ad is the detailed one, with a flag
|
|
# so the Generation Data overlay shows the ADetailer toggle (#327, #328).
|
|
ui_data = {}
|
|
ui_data["flake_images"] = regular_ui if regular_ui else ad_ui
|
|
if adetailer == "A/B":
|
|
ui_data["flake_images_ad"] = ad_ui
|
|
ui_data["adetailer_ab"] = [True]
|
|
ui_data["preview_data"] = [preview_data]
|
|
ui_data["seed"] = [seed]
|
|
|
|
return {"ui": ui_data} |