Files
fn_registry/python/functions/ml/comfyui_compose_capabilities.py
T
egutierrez 69d9aed46a feat(ml): mixer de capacidades comfyui (compose + generate_mixed_oneshot + inject controlnet/ipadapter)
Mezclador del grupo comfyui-skill que promueve a una sola llamada la secuencia
base -> compose -> submit -> wait -> fetch -> judge (issue 0087):

- comfyui_compose_capabilities_py_ml (PURA): aplica en orden las capacidades
  activadas (loras, controlnet, ipadapter, facedetailer, hires) sobre un
  workflow base, sin mutar la entrada.
- comfyui_generate_mixed_oneshot_py_pipelines: one-shot que resuelve el base
  (skill/txt2img/dict), compone, encola, espera, descarga el PNG y lo puntua
  con el panel comfyui-judge.
- comfyui_inject_controlnet_py_ml, comfyui_inject_ipadapter_py_ml: inyectores
  encadenables que consume el compose.
- Tests (24 passed) + pagina madre docs/capabilities/comfyui-skill.md.

Prueba real en GPU: txt2img dreamshaper_8 + 2 LoRAs (3d_render_redmond +
detail_tweaker) + FaceDetailer -> imagen 512x512 en ~24s, juez verdict 'good'
(score 4.69, votos aesthetic+clip good; voto llm degradado por rate-limit 429).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:02:10 +02:00

204 lines
8.3 KiB
Python

"""comfyui_compose_capabilities — mezclador de capacidades sobre un workflow base.
Toma un workflow ComfyUI en API format (la base: salida de
comfyui_build_skill_workflow o comfyui_build_txt2img_workflow) y aplica EN ORDEN
las capacidades que se activen, componiendo los inyectores/builders ENCADENABLES
del registry. Cada capacidad es un argumento keyword opcional: None (default) =
desactivada. Asi el mismo dict base se mezcla a la carta y se puede ir mejorando
(activar/desactivar una capacidad cambia el grafo resultante).
Orden de aplicacion (de mas cerca del checkpoint a la salida):
1. loras -> comfyui_inject_multi_lora (cadena MODEL/CLIP)
2. controlnet -> comfyui_inject_controlnet (re-condiciona KSampler.positive)
3. ipadapter -> comfyui_inject_ipadapter (re-condiciona KSampler.model, tras loras)
4. facedetailer -> comfyui_build_facedetailer_workflow (regenera caras del VAEDecode)
5. hires -> comfyui_inject_hires_fix (UltimateSDUpscale tras el VAEDecode)
Cada capacidad es independiente: se puede activar cualquier subconjunto. Sin
ninguna activada devuelve una copia del base intacta.
Funcion PURA: sin red, sin I/O. No muta el dict de entrada (copia profunda). Solo
compone funciones puras del registry.
Limitacion conocida (piezas actuales): hires y facedetailer NO encadenan entre
si. Ambos toman su imagen del VAEDecode original del render; combinarlos deja a
uno de los dos sin efecto sobre la salida final. Usa uno U otro por workflow, o
encadenalos manualmente fuera del mixer. Ver el .md (## Gotchas).
"""
from __future__ import annotations
import copy
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from ml.comfyui_build_facedetailer_workflow import comfyui_build_facedetailer_workflow # noqa: E402
from ml.comfyui_inject_controlnet import comfyui_inject_controlnet # noqa: E402
from ml.comfyui_inject_hires_fix import comfyui_inject_hires_fix # noqa: E402
from ml.comfyui_inject_ipadapter import comfyui_inject_ipadapter # noqa: E402
from ml.comfyui_inject_multi_lora import comfyui_inject_multi_lora # noqa: E402
def _is_link(v) -> bool:
"""True si v es una conexion ComfyUI [node_id(str), output_index(int)]."""
return (
isinstance(v, list)
and len(v) == 2
and isinstance(v[0], str)
and isinstance(v[1], int)
)
def _detect_checkpoint(wf: dict) -> str:
"""Nombre del checkpoint del primer CheckpointLoaderSimple, o '' si no hay."""
for node in wf.values():
if node.get("class_type") == "CheckpointLoaderSimple":
return str(node.get("inputs", {}).get("ckpt_name", "")) or ""
return ""
def _detect_prompts(wf: dict) -> tuple[str, str]:
"""Texto (positivo, negativo) de los dos primeros CLIPTextEncode del workflow.
En los builders del registry el positivo se inserta antes que el negativo, asi
que el primer CLIPTextEncode es el positivo y el segundo el negativo.
"""
texts = [
str(n.get("inputs", {}).get("text", ""))
for n in wf.values()
if n.get("class_type") == "CLIPTextEncode"
]
positive = texts[0] if texts else ""
negative = texts[1] if len(texts) > 1 else ""
return positive, negative
def _prune_redundant_saveimages(wf: dict, keep_source_class: str) -> None:
"""Deja un unico SaveImage: el alimentado por un nodo `keep_source_class`.
Tras encadenar facedetailer queda el SaveImage del render base (que ya no es
la salida final) ademas del SaveImage del detailer. Se borra el primero para
que el workflow tenga una sola imagen de salida (la procesada). Muta `wf` in
situ (el caller ya trabaja sobre una copia). No-op si hay <=1 SaveImage o si
no se encuentra el SaveImage alimentado por `keep_source_class`.
"""
saves = [
(nid, n) for nid, n in wf.items() if n.get("class_type") == "SaveImage"
]
if len(saves) <= 1:
return
keep = None
for nid, node in saves:
src = node.get("inputs", {}).get("images")
if _is_link(src) and wf.get(src[0], {}).get("class_type") == keep_source_class:
keep = nid
break
if keep is None:
return
for nid, _ in saves:
if nid != keep:
del wf[nid]
def comfyui_compose_capabilities(
base_workflow: dict,
*,
loras: list[dict] | None = None,
controlnet: dict | None = None,
ipadapter: dict | None = None,
hires: dict | None = None,
facedetailer: dict | None = None,
) -> dict:
"""Aplica en orden las capacidades activadas sobre un workflow base.
Args:
base_workflow: dict en API format (salida de
comfyui_build_skill_workflow o comfyui_build_txt2img_workflow). No se
muta; se devuelve una copia.
loras: lista de dicts {name, strength_model?, strength_clip?} para
comfyui_inject_multi_lora. None o lista vacia = sin LoRAs. keyword-only.
controlnet: dict para comfyui_inject_controlnet. Claves: control_image
(str, obligatoria), cn_name (str, obligatoria), strength (float),
positive_node (str). None = sin ControlNet. keyword-only.
ipadapter: dict para comfyui_inject_ipadapter. Claves: ref_image (str,
obligatoria), mode ('style'|'faceid'), weight (float) y demas
keyword-only del inyector. None = sin IPAdapter. keyword-only.
hires: dict de kwargs para comfyui_inject_hires_fix (upscale_by, denoise,
steps, cfg, seed, upscale_model, ...). {} = hires con defaults. None =
sin hires. keyword-only.
facedetailer: dict de overrides para comfyui_build_facedetailer_workflow.
Claves opcionales: ckpt_name (str; si falta se detecta del workflow),
positive / negative (str; si faltan se detectan de los CLIPTextEncode),
y demas params del builder (denoise, steps, cfg, seed, bbox_model, ...).
{} = facedetailer con detect + defaults. None = sin facedetailer.
keyword-only.
Returns:
copia del base con las capacidades activadas encadenadas en orden. Si no
se activa ninguna, una copia del base intacta.
Raises:
ValueError: si una capacidad activada es incompatible (p.ej. controlnet
sin control_image, ipadapter sin ref_image): se propaga el ValueError
del inyector correspondiente con el contexto del fallo.
"""
wf = copy.deepcopy(base_workflow)
if loras:
wf = comfyui_inject_multi_lora(wf, loras)
if controlnet is not None:
cn = dict(controlnet)
control_image = cn.pop("control_image", "")
cn_name = cn.pop("cn_name", "")
wf = comfyui_inject_controlnet(wf, control_image, cn_name, **cn)
if ipadapter is not None:
ip = dict(ipadapter)
ref_image = ip.pop("ref_image", "")
wf = comfyui_inject_ipadapter(wf, ref_image, **ip)
if facedetailer is not None:
fd = dict(facedetailer)
ckpt_name = fd.pop("ckpt_name", None) or _detect_checkpoint(wf)
det_pos, det_neg = _detect_prompts(wf)
positive = fd.pop("positive", None)
if positive is None:
positive = det_pos
negative = fd.pop("negative", None)
if negative is None:
negative = det_neg
wf = comfyui_build_facedetailer_workflow(wf, ckpt_name, positive, negative, **fd)
# facedetailer anade su propio SaveImage; el del render base ya no es la
# salida final -> dejar solo el del detailer.
_prune_redundant_saveimages(wf, "FaceDetailer")
if hires is not None:
h = dict(hires) if isinstance(hires, dict) else {}
wf = comfyui_inject_hires_fix(wf, **h)
return wf
# Alias con el nombre completo del ID para descubrimiento por convencion.
compose_capabilities = comfyui_compose_capabilities
if __name__ == "__main__":
import json
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
base = comfyui_build_txt2img_workflow("dreamshaper_8.safetensors", "a hero, 3d render")
mixed = comfyui_compose_capabilities(
base,
loras=[
{"name": "3d_render_redmond_sd15.safetensors", "strength_model": 0.9},
{"name": "detail_tweaker_sd15.safetensors", "strength_model": 0.5},
],
facedetailer={"denoise": 0.45},
)
print(json.dumps({"base_nodes": list(base), "mixed_nodes": list(mixed)}, indent=2))