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>
This commit is contained in:
2026-06-24 19:02:10 +02:00
parent c36c80dda9
commit 69d9aed46a
12 changed files with 1494 additions and 0 deletions
@@ -0,0 +1,218 @@
"""Inyecta una rama IPAdapter en un workflow ComfyUI ya construido (API format).
Toma un workflow en API format (dict, p.ej. salida de
comfyui_build_txt2img_workflow, ya con LoRAs encadenadas si las hay) y le injerta
la rama IPAdapter del custom node ComfyUI_IPAdapter_plus (cubiq), repuntando el
KSampler para que su MODEL venga condicionado por una imagen de referencia:
- mode='style': IPAdapterUnifiedLoader + IPAdapter. La imagen de referencia
transfiere estilo/composicion al resultado (image prompt clasico).
- mode='faceid': IPAdapterUnifiedLoaderFaceID + IPAdapterFaceID. Usa insightface
para extraer el embedding de la cara de la referencia e imponer un rostro
consistente en el personaje generado.
La fuente del MODEL es la que HOY alimenta el KSampler.model (tras las LoRAs, no
el checkpoint crudo): asi el IPAdapter se aplica sobre el modelo ya modificado
por los LoRAs, en el orden correcto del mixer.
Es la version ENCADENABLE-sobre-dict del builder
comfyui_build_ipadapter_workflow, que construye el grafo entero desde cero y NO
encadena. Reusa sus constantes de preset/weight_type por defecto. Pensada para
componerse con inject_lora / inject_controlnet / inject_hires_fix sobre un mismo
dict base (ver comfyui_compose_capabilities).
Funcion pura: sin red, sin I/O. No muta el dict de entrada (copia profunda). Los
class_type/inputs estan verificados contra /object_info del servidor (IPAdapter
plus), reutilizando exactamente los del builder.
"""
from __future__ import annotations
import copy
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# Reutiliza los defaults de preset/weight_type del builder para no duplicarlos.
from ml.comfyui_build_ipadapter_workflow import ( # noqa: E402
_DEFAULT_PRESET,
_DEFAULT_WEIGHT_TYPE,
)
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 comfyui_inject_ipadapter(
workflow: dict,
ref_image: str,
*,
mode: str = "style",
weight: float = 0.8,
preset: str | None = None,
weight_type: str | None = None,
start_at: float = 0.0,
end_at: float = 1.0,
weight_faceidv2: float = 1.0,
lora_strength: float = 0.6,
combine_embeds: str = "concat",
embeds_scaling: str = "V only",
provider: str = "CPU",
model_node: str | None = None,
) -> dict:
"""Devuelve una copia del workflow con una rama IPAdapter inyectada.
Args:
workflow: dict en API format (ej. salida de
comfyui_build_txt2img_workflow, posiblemente con LoRAs). No se muta;
se devuelve una copia.
ref_image: nombre del archivo de imagen de referencia en el directorio
input/ del servidor ComfyUI (lo carga un nodo LoadImage). En faceid
debe contener una cara nitida; en style es la imagen de estilo. No
puede estar vacio.
mode: 'style' (transfiere estilo/composicion) o 'faceid' (rostro
consistente via insightface + FaceID). keyword-only.
weight: peso de la influencia IPAdapter (0..1+). 0.8 es un buen punto de
partida; sube para mas parecido, baja para mas libertad del prompt.
preset: preset del UnifiedLoader. Si None usa el default del modo
('STANDARD (medium strength)' para style, 'FACEID PLUS V2' para faceid).
weight_type: tipo de ponderacion del nodo IPAdapter/FaceID. Si None usa el
default del modo ('standard' para style, 'linear' para faceid).
start_at: fraccion del sampling donde empieza a aplicar IPAdapter (0..1).
end_at: fraccion del sampling donde deja de aplicar (0..1).
weight_faceidv2: peso del embedding FaceID v2 (solo mode='faceid').
lora_strength: fuerza de la LoRA FaceID que carga el UnifiedLoaderFaceID
(solo mode='faceid').
combine_embeds: como combinar embeddings si hay varias caras
('concat'|'add'|'subtract'|'average'|'norm average'). Solo faceid.
embeds_scaling: escalado de embeddings ('V only'|'K+V'|...). Solo faceid.
provider: backend de insightface ('CPU'|'CUDA'|...). CPU por defecto para
no competir por VRAM con el modelo de difusion. Solo faceid.
model_node: node_id cuya salida MODEL (slot 0) alimentara la rama
IPAdapter. Si None, se detecta la fuente que hoy alimenta el
KSampler.model (con el CheckpointLoader como fallback). keyword-only.
Returns:
copia del workflow con LoadImage + (UnifiedLoader|UnifiedLoaderFaceID) +
(IPAdapter|IPAdapterFaceID) anadidos (node_ids = max id numerico + 1/2/3)
y el KSampler.model repuntado a la salida MODEL de la rama IPAdapter.
Raises:
ValueError: si mode no es 'style' ni 'faceid', si ref_image esta vacio, si
no se encuentra un KSampler, o si no se puede determinar la fuente del
MODEL (y no se pasa model_node explicito).
"""
if mode not in ("style", "faceid"):
raise ValueError(
f"comfyui_inject_ipadapter: mode debe ser 'style' o 'faceid', no {mode!r}"
)
if not ref_image:
raise ValueError("comfyui_inject_ipadapter: ref_image no puede estar vacio")
wf = copy.deepcopy(workflow)
ksampler_id = next(
(nid for nid, n in wf.items() if str(n.get("class_type", "")).endswith("KSampler")),
None,
)
if ksampler_id is None:
raise ValueError(
"comfyui_inject_ipadapter: no se encontro ningun KSampler en el workflow."
)
ks_inputs = wf[ksampler_id].get("inputs", {})
if model_node is not None:
model_src = [model_node, 0]
elif _is_link(ks_inputs.get("model")):
model_src = list(ks_inputs["model"])
else:
ckpt = next(
(nid for nid, n in wf.items()
if str(n.get("class_type", "")).startswith("CheckpointLoader")),
None,
)
if ckpt is None:
raise ValueError(
"comfyui_inject_ipadapter: no se pudo determinar la fuente del "
"MODEL; pasa model_node explicito."
)
model_src = [ckpt, 0]
numeric = [int(k) for k in wf.keys() if str(k).isdigit()]
base = (max(numeric) + 1) if numeric else len(wf) + 1
load_id = str(base)
loader_id = str(base + 1)
apply_id = str(base + 2)
used_preset = preset if preset is not None else _DEFAULT_PRESET[mode]
used_wtype = weight_type if weight_type is not None else _DEFAULT_WEIGHT_TYPE[mode]
wf[load_id] = {
"class_type": "LoadImage",
"inputs": {"image": ref_image},
}
if mode == "style":
wf[loader_id] = {
"class_type": "IPAdapterUnifiedLoader",
"inputs": {"model": list(model_src), "preset": used_preset},
}
wf[apply_id] = {
"class_type": "IPAdapter",
"inputs": {
"model": [loader_id, 0],
"ipadapter": [loader_id, 1],
"image": [load_id, 0],
"weight": weight,
"start_at": start_at,
"end_at": end_at,
"weight_type": used_wtype,
},
}
else: # faceid
wf[loader_id] = {
"class_type": "IPAdapterUnifiedLoaderFaceID",
"inputs": {
"model": list(model_src),
"preset": used_preset,
"lora_strength": lora_strength,
"provider": provider,
},
}
wf[apply_id] = {
"class_type": "IPAdapterFaceID",
"inputs": {
"model": [loader_id, 0],
"ipadapter": [loader_id, 1],
"image": [load_id, 0],
"weight": weight,
"weight_faceidv2": weight_faceidv2,
"weight_type": used_wtype,
"combine_embeds": combine_embeds,
"start_at": start_at,
"end_at": end_at,
"embeds_scaling": embeds_scaling,
},
}
# Repunta el KSampler para que tome el MODEL condicionado por IPAdapter.
wf[ksampler_id]["inputs"]["model"] = [apply_id, 0]
return wf
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 knight, cinematic")
wf_style = comfyui_inject_ipadapter(base, "style_ref.png", mode="style", weight=0.8)
wf_face = comfyui_inject_ipadapter(base, "face_ref.png", mode="faceid", weight=0.9)
print(json.dumps({"style_nodes": list(wf_style), "faceid_nodes": list(wf_face)}, indent=2))