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,146 @@
"""Tests del mixer comfyui_compose_capabilities (funcion pura).
Cubre: base intacto sin capacidades, combinaciones (solo loras; loras+facedetailer;
ipadapter+lora; hires), error paths (controlnet/ipadapter incompatibles), pureza,
conexiones validas en todas, y que activar una capacidad cambia el grafo.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
import pytest
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
from ml.comfyui_compose_capabilities import comfyui_compose_capabilities
from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct
def _base():
return comfyui_build_txt2img_workflow("dreamshaper_8.safetensors", "POS", "NEG")
def _count_ct(wf, ct):
return sum(1 for n in wf.values() if n["class_type"] == ct)
def test_sin_capacidades_base_intacto():
base = _base()
out = comfyui_compose_capabilities(base)
assert_api_format(out)
# Mismos class_types y mismo numero de nodos que el base.
assert class_types(out) == class_types(base)
assert set(out) == set(base)
def test_solo_loras_encadena():
base = _base()
out = comfyui_compose_capabilities(
base,
loras=[
{"name": "3d_render_redmond_sd15.safetensors", "strength_model": 0.9},
{"name": "detail_tweaker_sd15.safetensors", "strength_model": 0.5},
],
)
assert_api_format(out)
assert _count_ct(out, "LoraLoader") == 2
# El KSampler cuelga del ultimo LoraLoader.
ks = node_by_ct(out, "KSampler")
assert out[ks["inputs"]["model"][0]]["class_type"] == "LoraLoader"
def test_loras_mas_facedetailer():
out = comfyui_compose_capabilities(
_base(),
loras=[{"name": "detail_tweaker_sd15.safetensors", "strength_model": 0.5}],
facedetailer={"denoise": 0.45},
)
assert_api_format(out)
assert _count_ct(out, "LoraLoader") == 1
assert _count_ct(out, "FaceDetailer") == 1
# Tras facedetailer queda un unico SaveImage (el del detailer).
assert _count_ct(out, "SaveImage") == 1
save = node_by_ct(out, "SaveImage")
assert out[save["inputs"]["images"][0]]["class_type"] == "FaceDetailer"
def test_ipadapter_mas_lora_toma_model_del_lora():
out = comfyui_compose_capabilities(
_base(),
loras=[{"name": "a.safetensors"}],
ipadapter={"ref_image": "ref.png", "mode": "style", "weight": 0.8},
)
assert_api_format(out)
lora_id = next(nid for nid, n in out.items() if n["class_type"] == "LoraLoader")
loader = node_by_ct(out, "IPAdapterUnifiedLoader")
assert loader["inputs"]["model"] == [lora_id, 0]
# KSampler.model cuelga del IPAdapter.
ks = node_by_ct(out, "KSampler")
assert out[ks["inputs"]["model"][0]]["class_type"] == "IPAdapter"
def test_hires_anade_upscale():
out = comfyui_compose_capabilities(_base(), hires={})
assert_api_format(out)
assert "UltimateSDUpscale" in class_types(out)
assert "UpscaleModelLoader" in class_types(out)
def test_facedetailer_detecta_checkpoint_y_prompt():
# Sin pasar ckpt_name ni positive, se detectan del workflow.
out = comfyui_compose_capabilities(_base(), facedetailer={})
fd = node_by_ct(out, "FaceDetailer")
# El FaceDetailer usa el checkpoint del base (reutilizado).
assert "FaceDetailer" in class_types(out)
# El CLIPTextEncode positivo del detailer lleva el texto del base.
pos_text = out[fd["inputs"]["positive"][0]]["inputs"]["text"]
assert pos_text == "POS"
def test_controlnet_sin_imagen_propaga_valueerror():
with pytest.raises(ValueError):
comfyui_compose_capabilities(_base(), controlnet={"cn_name": "m.pth"})
def test_ipadapter_sin_ref_propaga_valueerror():
with pytest.raises(ValueError):
comfyui_compose_capabilities(_base(), ipadapter={"mode": "style"})
def test_no_muta_base():
base = _base()
snapshot = set(base)
comfyui_compose_capabilities(
base,
loras=[{"name": "a.safetensors"}],
facedetailer={},
)
assert set(base) == snapshot
def test_activar_capacidad_cambia_grafo():
base = _base()
plain = comfyui_compose_capabilities(base)
with_lora = comfyui_compose_capabilities(base, loras=[{"name": "a.safetensors"}])
with_fd = comfyui_compose_capabilities(base, facedetailer={})
# Cada activacion introduce class_types nuevos respecto al base.
assert "LoraLoader" not in class_types(plain)
assert "LoraLoader" in class_types(with_lora)
assert "FaceDetailer" not in class_types(plain)
assert "FaceDetailer" in class_types(with_fd)
assert len(set(with_lora)) > len(set(plain))
def test_combinacion_controlnet_ipadapter_lora_valida():
# ControlNet + IPAdapter + LoRA juntos producen api format valido.
out = comfyui_compose_capabilities(
_base(),
loras=[{"name": "a.safetensors"}],
controlnet={"control_image": "ctrl.png", "cn_name": "cn.pth", "strength": 0.6},
ipadapter={"ref_image": "ref.png", "mode": "style"},
)
assert_api_format(out)
cts = class_types(out)
assert {"LoraLoader", "ControlNetApply", "IPAdapter"} <= cts
@@ -0,0 +1,70 @@
"""Tests de estructura, repunte y pureza para comfyui_inject_controlnet (funcion pura)."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
import pytest
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
from ml.comfyui_inject_controlnet import comfyui_inject_controlnet
from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct
def _base():
return comfyui_build_txt2img_workflow("ck.safetensors", "POS", "NEG")
def test_inyecta_tres_nodos_y_repunta_positive():
base = _base()
inj = comfyui_inject_controlnet(base, "ctrl.png", "cn_canny.pth", strength=0.7)
assert_api_format(inj)
cts = class_types(inj)
assert {"LoadImage", "ControlNetLoader", "ControlNetApply"} <= cts
# El KSampler.positive ahora viene de un ControlNetApply.
ks = node_by_ct(inj, "KSampler")
pos_link = ks["inputs"]["positive"]
assert inj[pos_link[0]]["class_type"] == "ControlNetApply"
def test_controlnetapply_toma_el_positivo_original():
base = _base()
# En el base, KSampler.positive apunta al CLIPTextEncode positivo (nodo "6").
orig_pos = base["3"]["inputs"]["positive"]
inj = comfyui_inject_controlnet(base, "ctrl.png", "cn_canny.pth")
apply_node = node_by_ct(inj, "ControlNetApply")
assert apply_node["inputs"]["conditioning"] == orig_pos
# control_net e image apuntan al loader y al LoadImage.
assert inj[apply_node["inputs"]["control_net"][0]]["class_type"] == "ControlNetLoader"
assert inj[apply_node["inputs"]["image"][0]]["class_type"] == "LoadImage"
def test_respeta_strength_y_cn_name():
inj = comfyui_inject_controlnet(_base(), "c.png", "mymodel.pth", strength=0.42)
apply_node = node_by_ct(inj, "ControlNetApply")
loader = node_by_ct(inj, "ControlNetLoader")
load = node_by_ct(inj, "LoadImage")
assert apply_node["inputs"]["strength"] == 0.42
assert loader["inputs"]["control_net_name"] == "mymodel.pth"
assert load["inputs"]["image"] == "c.png"
def test_no_muta_entrada():
base = _base()
snapshot = {k: dict(v) for k, v in base.items()}
comfyui_inject_controlnet(base, "c.png", "m.pth")
assert set(base) == set(snapshot)
assert base["3"]["inputs"]["positive"] == snapshot["3"]["inputs"]["positive"]
def test_control_image_vacio_lanza():
with pytest.raises(ValueError):
comfyui_inject_controlnet(_base(), "", "m.pth")
def test_sin_ksampler_lanza():
wf = {"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "x"}}}
with pytest.raises(ValueError):
comfyui_inject_controlnet(wf, "c.png", "m.pth")
@@ -0,0 +1,79 @@
"""Tests de estructura, repunte y pureza para comfyui_inject_ipadapter (funcion pura)."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
import pytest
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
from ml.comfyui_inject_multi_lora import comfyui_inject_multi_lora
from ml.comfyui_inject_ipadapter import comfyui_inject_ipadapter
from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct
def _base():
return comfyui_build_txt2img_workflow("ck.safetensors", "POS", "NEG")
def test_style_inyecta_unified_loader_y_repunta_model():
inj = comfyui_inject_ipadapter(_base(), "ref.png", mode="style", weight=0.8)
assert_api_format(inj)
cts = class_types(inj)
assert "IPAdapterUnifiedLoader" in cts
assert "IPAdapter" in cts
ks = node_by_ct(inj, "KSampler")
assert inj[ks["inputs"]["model"][0]]["class_type"] == "IPAdapter"
def test_faceid_inyecta_faceid_nodes():
inj = comfyui_inject_ipadapter(_base(), "face.png", mode="faceid", weight=0.9)
assert_api_format(inj)
cts = class_types(inj)
assert "IPAdapterUnifiedLoaderFaceID" in cts
assert "IPAdapterFaceID" in cts
ks = node_by_ct(inj, "KSampler")
assert inj[ks["inputs"]["model"][0]]["class_type"] == "IPAdapterFaceID"
def test_toma_model_actual_tras_loras():
# Con LoRAs encadenados, el IPAdapter debe colgar del ultimo LoraLoader,
# no del checkpoint crudo.
base = _base()
with_lora = comfyui_inject_multi_lora(base, [{"name": "a.safetensors"}])
lora_id = next(nid for nid, n in with_lora.items() if n["class_type"] == "LoraLoader")
inj = comfyui_inject_ipadapter(with_lora, "ref.png", mode="style")
loader = node_by_ct(inj, "IPAdapterUnifiedLoader")
assert loader["inputs"]["model"] == [lora_id, 0]
def test_respeta_weight_y_preset():
inj = comfyui_inject_ipadapter(
_base(), "ref.png", mode="style", weight=0.55, preset="PLUS (high strength)"
)
ip = node_by_ct(inj, "IPAdapter")
loader = node_by_ct(inj, "IPAdapterUnifiedLoader")
load = node_by_ct(inj, "LoadImage")
assert ip["inputs"]["weight"] == 0.55
assert loader["inputs"]["preset"] == "PLUS (high strength)"
assert load["inputs"]["image"] == "ref.png"
def test_no_muta_entrada():
base = _base()
snapshot = set(base)
comfyui_inject_ipadapter(base, "ref.png")
assert set(base) == snapshot
assert base["3"]["inputs"]["model"] == ["4", 0]
def test_mode_invalido_lanza():
with pytest.raises(ValueError):
comfyui_inject_ipadapter(_base(), "ref.png", mode="bogus")
def test_ref_image_vacio_lanza():
with pytest.raises(ValueError):
comfyui_inject_ipadapter(_base(), "", mode="style")