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:
@@ -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")
|
||||
Reference in New Issue
Block a user