723 lines
32 KiB
Python
723 lines
32 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
from datetime import datetime
|
|
|
|
import folder_paths
|
|
import comfy.ldm.modules.attention
|
|
import comfy.sd
|
|
import comfy.utils
|
|
from nodes import CLIPTextEncode, EmptyLatentImage, ControlNetApplyAdvanced, ControlNetLoader
|
|
|
|
from . import flake_families, flake_io
|
|
from .flake_io import _resolve_model_name
|
|
|
|
|
|
def _split_stem_for_filename(stem: str) -> tuple[str, str, str, str]:
|
|
"""Parse one filename-prefix stem into placement buckets, honoring the
|
|
``**`` suffix marker (#290).
|
|
|
|
The last path segment is the filename contribution; preceding segments are
|
|
folders. A ``**`` before the folder portion makes that folder a *suffix*
|
|
(placed after other flakes' folders); a ``**`` before the file portion
|
|
makes that name a *suffix* (placed after other flakes' names).
|
|
|
|
Returns ``(folder_prefix, folder_suffix, file_prefix, file_suffix)`` with
|
|
each element possibly empty. Folder values keep their trailing ``/``.
|
|
"""
|
|
folder_prefix = folder_suffix = ""
|
|
file_prefix = file_suffix = ""
|
|
|
|
slash = stem.rfind("/")
|
|
if slash >= 0:
|
|
folder_region = stem[:slash + 1]
|
|
file_region = stem[slash + 1:]
|
|
else:
|
|
folder_region = ""
|
|
file_region = stem
|
|
|
|
if folder_region:
|
|
if folder_region.startswith("**"):
|
|
folder_suffix = folder_region[2:].replace("**", "")
|
|
else:
|
|
folder_prefix = folder_region.replace("**", "")
|
|
|
|
if file_region:
|
|
before, sep, after = file_region.partition("**")
|
|
file_prefix = before.replace("**", "")
|
|
if sep:
|
|
file_suffix = after.replace("**", "")
|
|
|
|
return folder_prefix, folder_suffix, file_prefix, file_suffix
|
|
|
|
|
|
def _build_filename_prefix(preset_name: str, stems: list[str]) -> str:
|
|
"""Build a filename prefix from preset name and flake stems.
|
|
|
|
Folders accumulate as ``<folder_prefixes><folder_suffixes>`` and the
|
|
filename as ``<time>_<file_prefixes>_<file_suffixes>``, where prefixes come
|
|
from each stem in stack order and suffixes (marked with ``**``) follow all
|
|
prefixes — see :func:`_split_stem_for_filename` (#290).
|
|
"""
|
|
folder_prefixes: list[str] = []
|
|
folder_suffixes: list[str] = []
|
|
file_prefixes: list[str] = []
|
|
file_suffixes: list[str] = []
|
|
|
|
for stem in stems:
|
|
if not stem:
|
|
continue
|
|
fpre, fsuf, npre, nsuf = _split_stem_for_filename(stem)
|
|
if fpre:
|
|
folder_prefixes.append(fpre)
|
|
if fsuf:
|
|
folder_suffixes.append(fsuf)
|
|
if npre:
|
|
file_prefixes.append(npre)
|
|
if nsuf:
|
|
file_suffixes.append(nsuf)
|
|
|
|
path = (preset_name + "/") if preset_name else ""
|
|
path += "".join(folder_prefixes) + "".join(folder_suffixes)
|
|
now = datetime.now()
|
|
path += now.strftime("%y%m%d") + "/"
|
|
|
|
filename = now.strftime("%H%M%S")
|
|
name_parts = file_prefixes + file_suffixes
|
|
if name_parts:
|
|
filename += "_" + "_".join(name_parts)
|
|
return path + filename
|
|
|
|
|
|
# Attention backends core registers per model when installed, by combo label (#455).
|
|
_ATTENTION_BACKENDS = {"PyTorch": "pytorch", "Comfy Kitchen INT8": "comfy_kitchen_int8", "Sage": "sage", "Sage 3": "sage3", "Flash": "flash"}
|
|
|
|
|
|
def _attention_options() -> list[str]:
|
|
registered = comfy.ldm.modules.attention.REGISTERED_ATTENTION_FUNCTIONS
|
|
return ["default"] + [label for label, name in _ATTENTION_BACKENDS.items() if name in registered]
|
|
|
|
|
|
def _apply_attention(model, attention: str):
|
|
"""``model`` set to the ``attention`` backend like core Model Attention
|
|
Backend; "default" keeps the one ComfyUI was launched with."""
|
|
if attention == "default":
|
|
return model
|
|
m = model.clone()
|
|
m.set_model_optimized_attention(comfy.ldm.modules.attention.get_attention_function(_ATTENTION_BACKENDS[attention]))
|
|
return m
|
|
|
|
|
|
def _carry_controlnets(src_cond, dst_cond):
|
|
"""Copy the controlnet chain from upstream conditioning onto freshly
|
|
re-encoded conditioning (#257).
|
|
|
|
Flake nodes re-encode the accumulated prompt text every time, producing
|
|
fresh conditioning that loses the ``control`` key — which is how ComfyUI
|
|
chains ControlNets (see ControlNetApplyAdvanced). Without this, a
|
|
controlnet applied by an upstream Flake node is silently dropped and only
|
|
the last node's controlnets survive. Carrying ``control`` forward lets this
|
|
node's own controlnets chain on top via ``set_previous_controlnet``.
|
|
"""
|
|
if not src_cond or not dst_cond:
|
|
return dst_cond
|
|
src_meta = src_cond[0][1] if len(src_cond[0]) > 1 else {}
|
|
control = src_meta.get("control")
|
|
if control is None:
|
|
return dst_cond
|
|
out = []
|
|
for t in dst_cond:
|
|
d = t[1].copy()
|
|
d["control"] = control
|
|
if "control_apply_to_uncond" in src_meta:
|
|
d["control_apply_to_uncond"] = src_meta["control_apply_to_uncond"]
|
|
out.append([t[0], d])
|
|
return out
|
|
|
|
|
|
def _require_model_file(category: str, ref: str, field: str) -> str:
|
|
"""Resolve a preset's model reference to a real path or fail loudly (#358).
|
|
|
|
Silently skipping a missing file (as the old VAE/text-encoder overrides did)
|
|
means the preset quietly generates with the wrong components.
|
|
"""
|
|
resolved = _resolve_model_name(category, ref)
|
|
path = folder_paths.get_full_path(category, resolved)
|
|
if not path or not os.path.isfile(path):
|
|
raise FileNotFoundError(
|
|
f"Preset field '{field}' points at '{ref}', which was not found in "
|
|
f"models/{category}/."
|
|
)
|
|
return path
|
|
|
|
|
|
def _resolve_clip_type(name: str | None, family=None):
|
|
"""Look up a ``comfy.sd.CLIPType`` member by name (#358).
|
|
|
|
By name, never by value: the enum is renumbered upstream whenever a new
|
|
architecture lands, so a hardcoded integer silently selects the wrong
|
|
encoder after a ComfyUI update.
|
|
"""
|
|
key = (name or (family.clip_type if family else None) or "STABLE_DIFFUSION").strip().upper()
|
|
try:
|
|
return comfy.sd.CLIPType[key]
|
|
except KeyError:
|
|
available = ", ".join(sorted(m.name for m in comfy.sd.CLIPType))
|
|
raise ValueError(
|
|
f"clip_type '{key}' is not supported by this ComfyUI build. "
|
|
f"Available types: {available}"
|
|
) from None
|
|
|
|
|
|
def _apply_sampling_shift(model, shift: float):
|
|
"""Apply a ModelSamplingAuraFlow sigma shift to the model (#359).
|
|
|
|
Imported lazily: comfy_extras is not importable at module scope during node
|
|
registration. Degrades to a warning rather than failing the run, since the
|
|
detected model config already carries a sensible default shift.
|
|
"""
|
|
try:
|
|
from comfy_extras.nodes_model_advanced import ModelSamplingAuraFlow
|
|
except ImportError:
|
|
logging.warning("[flakes] ModelSamplingAuraFlow unavailable; skipping shift=%s", shift)
|
|
return model
|
|
return ModelSamplingAuraFlow().patch_aura(model, shift)[0]
|
|
|
|
|
|
def _load_preset_bundle(preset_name: str, model_family: str | None = None):
|
|
"""Load a model preset and return (model_bundle, generation_data, sampling_preset).
|
|
|
|
``model_family`` is used to derive the ``img/<family>/`` output folder
|
|
prefix (#240). It's optional for backwards compatibility but should be
|
|
passed by the node so the structural prefix is present.
|
|
"""
|
|
preset_data = flake_io.load_preset(preset_name)
|
|
family = flake_families.get(model_family)
|
|
|
|
embedding_dir = folder_paths.get_folder_paths("embeddings")
|
|
category, model_ref = preset_data.model_source
|
|
|
|
# --- Load the model -----------------------------------------------------
|
|
if category == "diffusion_models":
|
|
model = comfy.sd.load_diffusion_model(
|
|
_require_model_file("diffusion_models", model_ref, "diffusion_model")
|
|
)
|
|
clip = vae = None
|
|
else:
|
|
ckpt_path = _require_model_file("checkpoints", model_ref, "checkpoint")
|
|
model, clip, vae, _ = comfy.sd.load_checkpoint_guess_config(
|
|
ckpt_path,
|
|
output_vae=True,
|
|
output_clip=True,
|
|
embedding_directory=embedding_dir,
|
|
)
|
|
if clip is None or vae is None:
|
|
# A UNET-only file living under models/checkpoints/ — how Anima,
|
|
# Krea2 and Z-Image are usually distributed (#358). The call still
|
|
# built `model` correctly from the model.diffusion_model.* keys; it
|
|
# just had no text encoder or VAE to give us, so those come from the
|
|
# preset. Only reached when the call *succeeded*: a genuine load
|
|
# failure raises and is left to propagate.
|
|
logging.info(
|
|
"[flakes] '%s' has no bundled %s — loading them from the preset",
|
|
model_ref,
|
|
" or ".join(n for n, v in (("text encoder", clip), ("VAE", vae)) if v is None),
|
|
)
|
|
|
|
# --- Text encoder -------------------------------------------------------
|
|
if preset_data.text_encoder:
|
|
te_path = _require_model_file("text_encoders", preset_data.text_encoder, "text_encoder")
|
|
clip = comfy.sd.load_clip(
|
|
ckpt_paths=[te_path],
|
|
embedding_directory=embedding_dir,
|
|
clip_type=_resolve_clip_type(preset_data.clip_type, family),
|
|
)
|
|
if clip is None:
|
|
raise ValueError(
|
|
f"Preset '{preset_name}' loads a model with no built-in text encoder, "
|
|
f"so it needs a 'text_encoder' set in the preset editor."
|
|
)
|
|
|
|
# --- VAE ----------------------------------------------------------------
|
|
if preset_data.vae:
|
|
vae_path = _require_model_file("vae", preset_data.vae, "vae")
|
|
vae = comfy.sd.VAE(sd=comfy.utils.load_torch_file(vae_path))
|
|
if vae is None:
|
|
raise ValueError(
|
|
f"Preset '{preset_name}' loads a model with no built-in VAE, "
|
|
f"so it needs a 'vae' set in the preset editor."
|
|
)
|
|
|
|
# --- Clip skip ----------------------------------------------------------
|
|
# clip_layer() is a CLIP-ism: it drops the last N transformer layers of a
|
|
# CLIP text encoder. Anima, Krea2 and Z-Image encode with Qwen3 / Qwen3-VL,
|
|
# where it is meaningless and can raise, so families declare whether they
|
|
# support it (#359). Presets carry clip_skip: -2 by default whether or not
|
|
# the family can use it, so this has to be gated here rather than on the
|
|
# value being set.
|
|
if preset_data.clip_skip and (family is None or family.supports_clip_skip):
|
|
clip = clip.clone()
|
|
clip.clip_layer(preset_data.clip_skip)
|
|
|
|
# --- Sampling shift -----------------------------------------------------
|
|
# Flow-matching families need a sigma shift; the templates apply it with an
|
|
# explicit ModelSamplingAuraFlow node. The preset value wins over the
|
|
# family default so a preset can be tuned without touching the registry.
|
|
shift = preset_data.shift if preset_data.shift is not None else (
|
|
family.default_shift if family else None
|
|
)
|
|
if shift is not None:
|
|
model = _apply_sampling_shift(model, shift)
|
|
|
|
# --- LoRAs and flake links ---------------------------------------------
|
|
# The preset acts as a flake of its own, so its links resolve (and
|
|
# schedule) exactly like a flake's. Hooks attached here are kept by
|
|
# carry_schedules in downstream Flake nodes.
|
|
from .flake_compose import apply_loras, carry_schedules, encode_scheduled_prompts
|
|
|
|
preset_flake = flake_io.Flake(
|
|
name="__preset__",
|
|
positive=preset_data.positive.strip(),
|
|
negative=preset_data.negative.strip(),
|
|
loras=list(preset_data.loras),
|
|
flake_links=list(preset_data.flake_links),
|
|
)
|
|
flake_io._apply_flake_link(preset_flake, {})
|
|
model, clip, lora_entries, hook_groups = apply_loras(model, clip, [preset_flake])
|
|
|
|
# --- Encode prompts -----------------------------------------------------
|
|
encoder = CLIPTextEncode()
|
|
pos_text, neg_text, prompt_parts = _join_prompts("", "", [preset_flake], None)
|
|
positive = encoder.encode(clip, pos_text)[0] if pos_text else encoder.encode(clip, "")[0]
|
|
negative = encoder.encode(clip, neg_text)[0] if neg_text else encoder.encode(clip, "")[0]
|
|
pos_ranged, neg_ranged = encode_scheduled_prompts(clip, [preset_flake])
|
|
positive = carry_schedules(positive, positive, pos_ranged, hook_groups)
|
|
negative = carry_schedules(negative, negative, neg_ranged, hook_groups)
|
|
|
|
# --- Latent -------------------------------------------------------------
|
|
width, height = preset_data.width, preset_data.height
|
|
latent = EmptyLatentImage().generate(width, height, 1)[0]
|
|
|
|
model_bundle = (model, clip, vae)
|
|
# Build the structural prefix `img/<family>/` (#240) plus the explicit
|
|
# filename_prefix field. We do NOT inherit from the preset's yaml location
|
|
# on disk (#217). The img/<family>/ segment is the project's output folder
|
|
# convention; without it, outputs land at output/<rest>/... instead of
|
|
# output/img/<family>/<rest>/...
|
|
family_folder = flake_io._family_folder(model_family) if model_family else None
|
|
structural = f"img/{family_folder}/" if family_folder else ""
|
|
prefix_stem = (preset_data.filename_prefix or "").strip()
|
|
stems = []
|
|
if structural:
|
|
# stems entries containing "/" land in folder_parts (see _build_filename_prefix).
|
|
stems.append(structural)
|
|
if prefix_stem:
|
|
stems.append(prefix_stem)
|
|
filename_state = {
|
|
"preset": "",
|
|
"stems": stems,
|
|
"checkpoint": preset_data.checkpoint,
|
|
"vae": preset_data.vae or "baked-in",
|
|
"text_encoder": preset_data.text_encoder or "baked-in",
|
|
"loras": lora_entries,
|
|
"model_family": model_family,
|
|
"prompt_parts": prompt_parts,
|
|
}
|
|
generation_data = (positive, negative, latent, width, height, pos_text, neg_text, filename_state)
|
|
sampling_preset = (preset_data.steps, preset_data.cfg, preset_data.sampler, preset_data.scheduler, 1.0)
|
|
|
|
return model_bundle, generation_data, sampling_preset
|
|
|
|
|
|
def _apply_preset_overrides(overrides_json, model_bundle, generation_data, sampling_preset):
|
|
"""Overlay per-instance overrides (steps/cfg/sampler/scheduler/
|
|
filename_prefix) onto a loaded preset bundle (#279).
|
|
|
|
Empty/blank fields are ignored so the preset's own values pass through.
|
|
``filename_prefix`` is appended to the stem chain rather than replacing it.
|
|
"""
|
|
if not overrides_json:
|
|
return model_bundle, generation_data, sampling_preset
|
|
try:
|
|
ovr = json.loads(overrides_json) or {}
|
|
except (ValueError, TypeError):
|
|
return model_bundle, generation_data, sampling_preset
|
|
if not isinstance(ovr, dict) or not ovr:
|
|
return model_bundle, generation_data, sampling_preset
|
|
|
|
steps, cfg, sampler, scheduler, denoise = sampling_preset
|
|
|
|
def _conv(key, conv, cur):
|
|
v = ovr.get(key)
|
|
if v is None or v == "":
|
|
return cur
|
|
try:
|
|
return conv(v)
|
|
except (ValueError, TypeError):
|
|
return cur
|
|
|
|
steps = _conv("steps", int, steps)
|
|
cfg = _conv("cfg", float, cfg)
|
|
if ovr.get("sampler"):
|
|
sampler = str(ovr["sampler"])
|
|
if ovr.get("scheduler"):
|
|
scheduler = str(ovr["scheduler"])
|
|
sampling_preset = (steps, cfg, sampler, scheduler, denoise)
|
|
|
|
fp = (ovr.get("filename_prefix") or "").strip()
|
|
if fp and isinstance(generation_data, tuple) and len(generation_data) > 7 and isinstance(generation_data[7], dict):
|
|
fs = dict(generation_data[7])
|
|
fs["stems"] = list(fs.get("stems", [])) + [fp]
|
|
generation_data = generation_data[:7] + (fs,)
|
|
|
|
return model_bundle, generation_data, sampling_preset
|
|
|
|
|
|
# Free text (#470): a registered label or folder in any case, else a new family.
|
|
_FAMILY_TOOLTIP = "Model family, e.g. SDXL/Illustrious or anima. An unknown name is a new family with default settings."
|
|
|
|
|
|
class FlakeModelPreset:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
try:
|
|
preset_names = flake_io.list_presets(local=True)
|
|
except Exception:
|
|
preset_names = []
|
|
if not preset_names:
|
|
preset_names = ["No model preset is selected"]
|
|
return {
|
|
"required": {
|
|
"model_family": ("STRING", {"default": "SDXL/Base", "tooltip": _FAMILY_TOOLTIP}),
|
|
"preset": (["Select a preset..."] + preset_names,),
|
|
},
|
|
"optional": {
|
|
# Per-instance overrides (steps/cfg/sampler/scheduler/
|
|
# filename_prefix) as JSON. Managed by the node's widget; lets a
|
|
# preset be A/B-tested without editing its file (#279).
|
|
"overrides_json": ("STRING", {
|
|
"default": "",
|
|
"multiline": True,
|
|
"tooltip": "JSON override of steps/cfg/sampler/scheduler/filename_prefix. Managed by the Flake widget.",
|
|
}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("FLAKE_DATA",)
|
|
RETURN_NAMES = ("flake_data",)
|
|
FUNCTION = "execute"
|
|
CATEGORY = "flakes"
|
|
DESCRIPTION = (
|
|
"Load a model preset (checkpoint, VAE, prompts, resolution, sampler settings). "
|
|
"Outputs a single bundled flake_data pin for wiring into FlakeStack / FlakeCombo nodes."
|
|
)
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, model_family: str, preset: str, overrides_json: str = ""):
|
|
# Re-run whenever the preset file's contents change on disk, even if the
|
|
# selected preset name (the node input) stays the same. Without this,
|
|
# ComfyUI caches the output and edits to Steps/CFG/etc. are ignored.
|
|
# The override JSON is part of the signature so override edits re-run too.
|
|
# Edits to a flake the preset links to are not tracked.
|
|
preset_name = preset.strip() if preset else ""
|
|
if not preset_name or preset_name in ("Select a preset...", "No model preset is selected"):
|
|
return f"{preset_name}:{overrides_json}"
|
|
try:
|
|
path = flake_io._resolve_preset_file(preset_name)
|
|
st = os.stat(path)
|
|
return f"{path}:{st.st_mtime_ns}:{st.st_size}:{overrides_json}"
|
|
except Exception:
|
|
return float("nan")
|
|
|
|
def execute(self, model_family: str, preset: str, overrides_json: str = ""):
|
|
preset_name = preset.strip() if preset else ""
|
|
if not preset_name or preset_name in ("Select a preset...", "No model preset is selected"):
|
|
raise ValueError("No model preset is selected — pick one from the dropdown.")
|
|
|
|
model_bundle, generation_data, sampling_preset = _load_preset_bundle(preset_name, model_family)
|
|
model_bundle, generation_data, sampling_preset = _apply_preset_overrides(
|
|
overrides_json, model_bundle, generation_data, sampling_preset
|
|
)
|
|
return ((model_bundle, generation_data, sampling_preset),)
|
|
|
|
|
|
def _resolve_flakes(flakes_json: str) -> list[flake_io.Flake]:
|
|
"""Resolve the active (non-bypassed) entries of a Flake Stack / Combo
|
|
``flakes_json`` list, skipping malformed ones."""
|
|
try:
|
|
entries = json.loads(flakes_json) if flakes_json else []
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(f"flakes_json is not valid JSON: {exc}") from exc
|
|
|
|
if not isinstance(entries, list):
|
|
raise ValueError("flakes_json must be a JSON list")
|
|
|
|
normalized: list[dict] = []
|
|
for i, entry in enumerate(entries):
|
|
if not isinstance(entry, dict):
|
|
logging.warning("[FlakeStack] skipping non-object entry %d: %r", i, entry)
|
|
continue
|
|
if entry.get("inline") or entry.get("name"):
|
|
normalized.append(entry)
|
|
else:
|
|
logging.warning("[FlakeStack] skipping entry %d (needs 'name' or 'inline')", i)
|
|
|
|
return [flake_io.resolve(e) for e in normalized if not e.get("bypassed")]
|
|
|
|
|
|
def _render_parts(parts: list) -> tuple[str, str]:
|
|
"""(positive, negative) of the prompt parts: each part's text with its
|
|
active options; positives joined with " BREAK ", negatives with ", "."""
|
|
pos = (", ".join(t for t in [p, *(o[0] for o in options.values() if o[2])] if t) for p, _, options in parts)
|
|
neg = (t for _, n, options in parts for t in [n, *(o[1] for o in options.values() if o[2])] if t)
|
|
return " BREAK ".join(t for t in pos if t), ", ".join(neg)
|
|
|
|
|
|
def _join_prompts(pos_text: str, neg_text: str, flakes: list[flake_io.Flake], parts: list | None) -> tuple[str, str, list]:
|
|
"""Incoming prompt text followed by each flake's: positives joined with
|
|
" BREAK ", negatives with ", ". A flake's active options join its prompts.
|
|
|
|
``parts`` (``[[positive, negative, {name: [positive, negative, on]}], ...]``,
|
|
from the chain state) keep upstream options apart so each flake's toggler
|
|
can switch them before its own are added (#462, #467). They are reused
|
|
only while they still render the incoming texts; returns the new parts
|
|
for the chain state."""
|
|
if not parts or _render_parts(parts) != (pos_text.strip(), neg_text.strip()):
|
|
parts = [[pos_text.strip(), neg_text.strip(), {}]]
|
|
parts = [[p, n, {k: list(o) for k, o in options.items()}] for p, n, options in parts]
|
|
for f in flakes:
|
|
for name, on in f.toggler:
|
|
for *_, options in parts:
|
|
if name in options:
|
|
options[name][2] = on
|
|
parts.append([f.positive.strip(), f.negative.strip(),
|
|
{k: [o.positive, o.negative, o.on] for k, o in f.options.items()}])
|
|
return *_render_parts(parts), parts
|
|
|
|
|
|
class FlakeStack:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"model_family": ("STRING", {"default": "SDXL/Base", "tooltip": _FAMILY_TOOLTIP}),
|
|
"flake_data": ("FLAKE_DATA",),
|
|
"flakes_json": ("STRING", {
|
|
"multiline": True,
|
|
"default": "[]",
|
|
"tooltip": "JSON-encoded list of flake entries. Managed by the Full Flakes widget.",
|
|
}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("FLAKE_DATA",)
|
|
RETURN_NAMES = ("flake_data",)
|
|
FUNCTION = "execute"
|
|
CATEGORY = "flakes"
|
|
DESCRIPTION = (
|
|
"Compose flakes on top of an incoming flake_data bundle. "
|
|
"Applies LoRAs, merges prompts, resolution overrides and ControlNets. "
|
|
"Outputs updated flake_data for chaining into downstream nodes."
|
|
)
|
|
|
|
def execute(self, model_family: str, flake_data, flakes_json: str):
|
|
model_bundle, generation_data, sampling_preset = flake_data
|
|
model, clip, vae = model_bundle
|
|
positive_cond, negative_cond, latent, width, height, pos_text, neg_text = generation_data[:7]
|
|
steps, cfg, sampler, scheduler, denoise = sampling_preset
|
|
|
|
# --- Filename prefix state ----------------------------------------------
|
|
# Copy the incoming filename_state so we don't mutate the original tuple.
|
|
# Preserve existing stems from upstream FlakeStack nodes so that stems
|
|
# accumulate correctly across a chain of stacked nodes.
|
|
if len(generation_data) > 7 and isinstance(generation_data[7], dict):
|
|
filename_state = dict(generation_data[7])
|
|
filename_state["stems"] = list(filename_state.get("stems", []))
|
|
else:
|
|
filename_state = {"preset": "", "stems": []}
|
|
|
|
# The widget is only a frontend copy of the upstream node's family and
|
|
# goes stale behind reroutes/subgraphs; the preset loader records the
|
|
# family it actually loaded (#382).
|
|
model_family = filename_state.get("model_family") or model_family
|
|
|
|
flakes = _resolve_flakes(flakes_json)
|
|
|
|
from .flake_compose import (
|
|
_load_cn_image, _fit_cn_image, anima_cn_choice, apply_anima_vace, apply_loras, apply_model_patch_cn,
|
|
carry_schedules, encode_scheduled_prompts, load_cn_patch,
|
|
)
|
|
|
|
# --- Apply LoRAs --------------------------------------------------------
|
|
model, clip, lora_entries, hook_groups = apply_loras(model, clip, flakes)
|
|
|
|
new_pos_text, new_neg_text, filename_state["prompt_parts"] = _join_prompts(
|
|
pos_text, neg_text, flakes, filename_state.get("prompt_parts"))
|
|
|
|
# --- Re-encode prompts --------------------------------------------------
|
|
# Re-encoding produces fresh conditioning that drops the ControlNet
|
|
# chain attached by upstream Flake nodes, so snapshot the incoming
|
|
# conditioning and carry its `control` forward afterwards (#257).
|
|
prev_positive_cond, prev_negative_cond = positive_cond, negative_cond
|
|
|
|
encoder = CLIPTextEncode()
|
|
positive_cond = encoder.encode(clip, new_pos_text)[0] if new_pos_text else encoder.encode(clip, "")[0]
|
|
negative_cond = encoder.encode(clip, new_neg_text)[0] if new_neg_text else encoder.encode(clip, "")[0]
|
|
|
|
# Scheduled prompts become time-ranged entries after the base cond (#370).
|
|
pos_ranged, neg_ranged = encode_scheduled_prompts(clip, flakes)
|
|
positive_cond = carry_schedules(prev_positive_cond, positive_cond, pos_ranged, hook_groups)
|
|
negative_cond = carry_schedules(prev_negative_cond, negative_cond, neg_ranged, hook_groups)
|
|
|
|
positive_cond = _carry_controlnets(prev_positive_cond, positive_cond)
|
|
negative_cond = _carry_controlnets(prev_negative_cond, negative_cond)
|
|
|
|
# --- Target resolution (skip bypassed) ----------------------------------
|
|
# Computed before ControlNets so hint images can be fitted to the frame.
|
|
new_width, new_height = width, height
|
|
for f in flakes:
|
|
if f.resolution is not None:
|
|
new_width, new_height = f.resolution
|
|
break
|
|
|
|
# --- Apply ControlNets (from flakes) ------------------------------------
|
|
# ControlNet "use image dimensions as resolution" override (#260): the
|
|
# first active CN entry flagged resolution_from_image drives the
|
|
# generation size, taking precedence over the flake resolution above.
|
|
for f in flakes:
|
|
hit = False
|
|
for cn in f.controlnets:
|
|
if cn.strength == 0 or not cn.resolution_from_image or not cn.image_name.strip():
|
|
continue
|
|
try:
|
|
ov = _load_cn_image(cn.image_name)
|
|
except Exception as exc:
|
|
logging.warning("[flakes] resolution_from_image failed for %s: %s", cn.image_name, exc)
|
|
continue
|
|
new_width, new_height = int(ov.shape[2]), int(ov.shape[1])
|
|
logging.info("[flakes] resolution override from CN image %s -> %dx%d", cn.image_name, new_width, new_height)
|
|
hit = True
|
|
break
|
|
if hit:
|
|
break
|
|
|
|
family_folder = flake_io._family_folder(model_family) or "sdxl"
|
|
# Anima and Z-Image ControlNets are model patches that hook the MODEL
|
|
# instead of the conditioning (#365); the patched model flows out in
|
|
# the bundle, so downstream Flake nodes chain on top of it.
|
|
family = flake_families.get(model_family)
|
|
cn_kind = family.cn_kind if family else "controlnet"
|
|
cn_category = family.cn_category if family else "controlnet"
|
|
cn_model_cache = {}
|
|
cn_loader = ControlNetLoader()
|
|
cn_apply = ControlNetApplyAdvanced()
|
|
for f in flakes:
|
|
for cn in f.controlnets:
|
|
if cn.strength == 0:
|
|
continue
|
|
# An Anima entry whose model resolves to an LLLite patch or a
|
|
# VACE file (fork route, #378) uses that file; otherwise the
|
|
# type picks the LLLite file (#381).
|
|
picked = anima_cn_choice(cn.model_name) if cn_kind == "anima_lllite" else None
|
|
vace_name = picked[0] if picked and picked[1] == "vace" else None
|
|
model_name = cn.model_name
|
|
if cn.type and not picked:
|
|
inferred = flake_io.infer_cn_model(cn.type, family_folder)
|
|
if inferred:
|
|
model_name = inferred
|
|
if not model_name.strip():
|
|
print(f"[flakes] skipping controlnet entry with empty model_name (type={cn.type})")
|
|
continue
|
|
if not cn.image_name.strip():
|
|
print(f"[flakes] skipping controlnet entry with empty image_name (type={cn.type})")
|
|
continue
|
|
cn_resolved = picked[0] if picked else flake_io.resolve_cn_model_name(model_name, family_folder, cn_category)
|
|
image = _load_cn_image(cn.image_name)
|
|
image = _fit_cn_image(image, new_width, new_height)
|
|
if vace_name:
|
|
positive_cond, negative_cond = apply_anima_vace(
|
|
positive_cond, negative_cond, vace_name, vae, image,
|
|
cn.strength, cn.start_percent, cn.end_percent,
|
|
)
|
|
elif cn_kind == "controlnet":
|
|
if cn_resolved not in cn_model_cache:
|
|
cn_model_cache[cn_resolved] = cn_loader.load_controlnet(cn_resolved)[0]
|
|
positive_cond, negative_cond = cn_apply.apply_controlnet(
|
|
positive_cond, negative_cond, cn_model_cache[cn_resolved], image,
|
|
cn.strength, cn.start_percent, cn.end_percent,
|
|
)
|
|
else:
|
|
model = apply_model_patch_cn(
|
|
cn_kind, model, vae, load_cn_patch(cn_resolved), image,
|
|
cn.strength, cn.start_percent, cn.end_percent,
|
|
)
|
|
# The sigma window shows which steps an Anima ControlNet can
|
|
# act on; FlakeGenerate logs whether the patch reached the sampler (#382).
|
|
if cn_kind == "anima_lllite":
|
|
model_sampling = model.get_model_object("model_sampling")
|
|
logging.info(
|
|
"[flakes] Anima %s %s image=%s fit=%dx%d strength=%.2f start=%.2f end=%.2f sigma=%.3f..%.3f",
|
|
picked[1] if picked else "lllite", cn_resolved, cn.image_name, new_width, new_height,
|
|
cn.strength, cn.start_percent, cn.end_percent,
|
|
float(model_sampling.percent_to_sigma(cn.start_percent)),
|
|
float(model_sampling.percent_to_sigma(cn.end_percent)),
|
|
)
|
|
else:
|
|
logging.info(
|
|
"[flakes] applied CN model=%s image=%s fit=%dx%d strength=%.2f start=%.2f end=%.2f",
|
|
cn_resolved, cn.image_name, new_width, new_height,
|
|
cn.strength, cn.start_percent, cn.end_percent,
|
|
)
|
|
|
|
# --- Latent (resolution resolved above, before ControlNets) -------------
|
|
if (new_width, new_height) != (width, height):
|
|
latent = EmptyLatentImage().generate(new_width, new_height, 1)[0]
|
|
|
|
# --- Lora metadata for Preview Flake Data ---------------------------------
|
|
filename_state["loras"] = list(filename_state.get("loras", [])) + lora_entries
|
|
|
|
# --- Filename prefix (skip bypassed) ------------------------------------
|
|
for f in flakes:
|
|
if f.output_stem:
|
|
filename_state["stems"].append(f.output_stem)
|
|
|
|
logging.info(
|
|
"[FlakeStack] %dx%s steps=%s cfg=%s flakes=%d",
|
|
new_width, new_height, steps, cfg, len(flakes),
|
|
)
|
|
|
|
out_model_bundle = (model, clip, vae)
|
|
out_generation_data = (positive_cond, negative_cond, latent, new_width, new_height, new_pos_text, new_neg_text, filename_state)
|
|
out_sampling_preset = (steps, cfg, sampler, scheduler, denoise)
|
|
|
|
return (
|
|
(out_model_bundle, out_generation_data, out_sampling_preset),
|
|
)
|
|
|
|
|
|
class FlakeCombo(FlakeStack):
|
|
"""Mutually-exclusive flake stack.
|
|
|
|
The backend behavior is identical to FlakeStack — it receives a
|
|
``flakes_json`` list and applies whatever entries are inside.
|
|
The frontend widget ensures that ``flakes_json`` contains exactly
|
|
one active flake at a time, and the JavaScript extension hooks
|
|
``app.queuePrompt`` to queue every combination across all
|
|
FlakeCombo / FlakeModelCombo nodes in the graph.
|
|
"""
|
|
pass
|
|
|
|
|
|
class FlakeModelCombo(FlakeModelPreset):
|
|
"""Multiple model preset selector.
|
|
|
|
The backend behavior is identical to FlakeModelPreset — it loads
|
|
a single preset from the ``preset`` dropdown. The frontend widget
|
|
manages a list of selected presets in ``node.properties`` and the
|
|
JavaScript extension cycles the ``preset`` widget value for each
|
|
combination when queueing.
|
|
"""
|
|
pass
|