feat(ml): auto-commit con 6 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 17:47:28 +02:00
parent d5660aa13f
commit 3887e59092
6 changed files with 708 additions and 0 deletions
@@ -0,0 +1,118 @@
---
name: comfyui_build_ipadapter_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_ipadapter_workflow(prompt: str, ref_image: str, *, base_checkpoint: str, mode: str = 'style', weight: float = 0.8, negative: str = '', 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', steps: int = 20, cfg: float = 7.0, width: int = 512, height: int = 512, seed: int = 0, sampler_name: str = 'euler', scheduler: str = 'normal', filename_prefix: str = 'ipadapter') -> dict"
description: "Construye un workflow ComfyUI txt2img + IPAdapter (custom node cubiq/IPAdapter_plus) en API format. mode='style' usa IPAdapterUnifiedLoader+IPAdapter para transferir estilo/composicion de una imagen de referencia; mode='faceid' usa IPAdapterUnifiedLoaderFaceID+IPAdapterFaceID (insightface + .bin FaceID + su LoRA) para imponer un rostro consistente. Repunta el KSampler a la salida MODEL de la rama IPAdapter. Pura: sin red ni I/O."
tags: [comfyui, comfyui-skill, ipadapter, faceid, ml, stable-diffusion, workflow]
uses_functions: [comfyui_build_txt2img_workflow_py_ml]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: prompt
desc: "Prompt positivo (texto del resultado deseado)."
- name: ref_image
desc: "Nombre del archivo de imagen de referencia en input/ del servidor ComfyUI (lo carga LoadImage). En faceid debe contener una cara nitida; en style es la imagen de estilo."
- name: base_checkpoint
desc: "Checkpoint SD1.5/SDXL. Debe casar con los modelos IPAdapter (modelos SD1.5 con checkpoints SD1.5). keyword-only."
- name: mode
desc: "'style' (transfiere estilo/composicion) o 'faceid' (rostro consistente). keyword-only."
- name: weight
desc: "Peso de la influencia IPAdapter (0..1+). 0.8 buen punto de partida; sube para mas parecido, baja para mas libertad del prompt."
- name: negative
desc: "Prompt negativo."
- name: preset
desc: "Preset del UnifiedLoader. None => default por modo ('STANDARD (medium strength)' style, 'FACEID PLUS V2' faceid)."
- name: weight_type
desc: "Tipo de ponderacion del nodo IPAdapter/FaceID. None => default por modo ('standard' style, 'linear' faceid)."
- name: start_at
desc: "Fraccion del sampling donde empieza a aplicar IPAdapter (0..1)."
- name: end_at
desc: "Fraccion del sampling donde deja de aplicar (0..1)."
- name: weight_faceidv2
desc: "Peso del embedding FaceID v2 (solo mode='faceid')."
- name: lora_strength
desc: "Fuerza de la LoRA FaceID que carga el UnifiedLoaderFaceID (solo mode='faceid')."
- name: combine_embeds
desc: "Combinacion de embeddings si hay varias caras ('concat'|'add'|'subtract'|'average'|'norm average'). Solo faceid."
- name: embeds_scaling
desc: "Escalado de embeddings ('V only'|'K+V'|...). Solo faceid."
- name: provider
desc: "Backend de insightface ('CPU'|'CUDA'|...). CPU por defecto para no competir por VRAM. Solo faceid."
- name: steps
desc: "Pasos de sampling (pasa a la base txt2img)."
- name: cfg
desc: "Classifier-free guidance scale (pasa a la base)."
- name: width
desc: "Ancho en px, multiplo de 8 (pasa a la base)."
- name: height
desc: "Alto en px, multiplo de 8 (pasa a la base)."
- name: seed
desc: "Semilla del KSampler (pasa a la base)."
- name: sampler_name
desc: "Nombre del sampler (pasa a la base)."
- name: scheduler
desc: "Scheduler del sampler (pasa a la base)."
- name: filename_prefix
desc: "Prefijo del PNG generado por SaveImage."
output: "dict en API format listo para comfyui_submit_workflow: base txt2img + LoadImage + rama IPAdapter del modo elegido, con el KSampler repuntado a la salida MODEL de esa rama."
tested: true
tests: ["mode='style': nodos LoadImage/IPAdapterUnifiedLoader/IPAdapter + conexiones + KSampler repuntado + defaults", "mode='faceid': nodos UnifiedLoaderFaceID/IPAdapterFaceID + conexiones + provider CPU + defaults", "mode invalido lanza ValueError", "ref_image vacia lanza ValueError", "override de preset y weight_type", "determinismo"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_ipadapter_workflow.py"
file_path: "python/functions/ml/comfyui_build_ipadapter_workflow.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_ipadapter_workflow import comfyui_build_ipadapter_workflow
from ml.comfyui_submit_workflow import comfyui_submit_workflow
# Estilo: transfiere el look de una imagen de referencia
wf = comfyui_build_ipadapter_workflow(
"a fantasy castle on a hill", "example.png",
base_checkpoint="dreamshaper_8.safetensors", mode="style", weight=0.8)
resp = comfyui_submit_workflow(wf)
# FaceID: rostro consistente a partir de una cara de referencia
wf = comfyui_build_ipadapter_workflow(
"portrait of a knight in armor, cinematic", "showcase_char.png",
base_checkpoint="dreamshaper_8.safetensors", mode="faceid", weight=0.9)
resp = comfyui_submit_workflow(wf)
print(resp["prompt_id"])
```
## Cuando usarla
Cuando quieras condicionar una generacion por una **imagen de referencia**, no solo
texto. Dos casos: `mode='style'` para clonar el estilo/composicion de una imagen
(image prompt), y `mode='faceid'` para generar un personaje con un **rostro
concreto y consistente** (el modelo extrae el embedding facial con insightface).
La referencia se sube primero a `input/` del servidor (LoadImage la lee por nombre).
## Gotchas
- **Modelos SD1.5 ↔ checkpoints SD1.5.** Los modelos descargados son SD1.5
(`ip-adapter*_sd15`, `ip-adapter-faceid-plusv2_sd15`); usalos con un checkpoint
SD1.5 (dreamshaper_8). Mezclar con SDXL hace fallar el UnifiedLoader.
- **La clave `ipadapter` debe estar en `extra_model_paths.yaml`.** El custom node
registra la carpeta `models/ipadapter`; si los modelos viven en otra ruta (ej.
`/mnt/2tb`), esa clave los mapea. Sin ella `ipadapter_file` sale vacio.
- **faceid usa insightface (`buffalo_l`) + la LoRA FaceID.** El UnifiedLoaderFaceID
carga la LoRA `ip-adapter-faceid-plusv2_sd15_lora.safetensors` (debe estar en
`models/loras/`). `provider='CPU'` por defecto: insightface en CPU no compite por
los 8GB de VRAM; pon `'CUDA'` solo si tienes onnxruntime-gpu instalado.
- **La referencia debe existir en `input/`.** Es un nombre de archivo, no una ruta:
sube la imagen antes (POST /upload/image o copiala a `~/ComfyUI/input/`).
- Pura: construye el dict, no valida que los modelos existan ni hace red. Valida con
`comfyui_validate_workflow` y envia con `comfyui_submit_workflow`.
- En 8GB usa resolucion modesta (512x512) en SD1.5; faceid + LoRA + insightface
caben con `--lowvram`, pero sube la VRAM si combinas con multi-LoRA pesado.
@@ -0,0 +1,224 @@
"""Construye un workflow ComfyUI txt2img + IPAdapter en API format (dict de nodos).
Parte de comfyui_build_txt2img_workflow y le injerta la rama IPAdapter del custom
node ComfyUI_IPAdapter_plus (cubiq):
- 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 y el .bin FaceID + su
LoRA para imponer un **rostro consistente** en el personaje generado.
En ambos casos la salida MODEL de la rama IPAdapter se repunta al KSampler, de
modo que el sampler genera ya condicionado por la imagen de referencia.
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos. Los
class_type/inputs estan verificados contra /object_info del servidor (IPAdapter
plus): IPAdapterUnifiedLoader(model,preset)->[MODEL,IPADAPTER],
IPAdapter(model,ipadapter,image,weight,start_at,end_at,weight_type)->[MODEL],
IPAdapterUnifiedLoaderFaceID(model,preset,lora_strength,provider)->[MODEL,IPADAPTER],
IPAdapterFaceID(model,ipadapter,image,weight,weight_faceidv2,weight_type,
combine_embeds,start_at,end_at,embeds_scaling)->[MODEL,IMAGE].
"""
from __future__ import annotations
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# Presets por defecto del IPAdapterUnifiedLoader(FaceID) segun el modo.
_DEFAULT_PRESET = {
"style": "STANDARD (medium strength)",
"faceid": "FACEID PLUS V2",
}
# weight_type por defecto: el nodo IPAdapter usa 'standard', el FaceID usa 'linear'.
_DEFAULT_WEIGHT_TYPE = {
"style": "standard",
"faceid": "linear",
}
def comfyui_build_ipadapter_workflow(
prompt: str,
ref_image: str,
*,
base_checkpoint: str,
mode: str = "style",
weight: float = 0.8,
negative: str = "",
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",
steps: int = 20,
cfg: float = 7.0,
width: int = 512,
height: int = 512,
seed: int = 0,
sampler_name: str = "euler",
scheduler: str = "normal",
filename_prefix: str = "ipadapter",
) -> dict:
"""Construye un workflow txt2img condicionado por una imagen de referencia.
Args:
prompt: prompt positivo (texto del resultado deseado).
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.
base_checkpoint: checkpoint SD1.5/SDXL (debe casar con los modelos
IPAdapter: usa modelos SD1.5 con checkpoints SD1.5). keyword-only.
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.
negative: prompt negativo.
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.
steps, cfg, width, height, seed, sampler_name, scheduler, filename_prefix:
parametros de generacion que se pasan a comfyui_build_txt2img_workflow.
Returns:
dict en API format listo para comfyui_submit_workflow, con la base
txt2img + LoadImage + la rama IPAdapter del modo elegido, y el KSampler
repuntado a la salida MODEL de esa rama.
Raises:
ValueError: si mode no es 'style' ni 'faceid', si ref_image esta vacio, o
si no se puede localizar el checkpoint/KSampler en la base.
"""
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
if mode not in ("style", "faceid"):
raise ValueError(
f"comfyui_build_ipadapter_workflow: mode debe ser 'style' o 'faceid', no {mode!r}"
)
if not ref_image:
raise ValueError("comfyui_build_ipadapter_workflow: ref_image no puede estar vacio")
wf = comfyui_build_txt2img_workflow(
base_checkpoint,
prompt,
negative,
steps=steps,
cfg=cfg,
width=width,
height=height,
seed=seed,
sampler_name=sampler_name,
scheduler=scheduler,
filename_prefix=filename_prefix,
)
ckpt = next(
(nid for nid, n in wf.items() if str(n.get("class_type", "")).startswith("CheckpointLoader")),
None,
)
ksampler = next(
(nid for nid, n in wf.items() if str(n.get("class_type", "")).endswith("KSampler")),
None,
)
if ckpt is None or ksampler is None:
raise ValueError(
"comfyui_build_ipadapter_workflow: no se encontro CheckpointLoader/KSampler en la base"
)
numeric = [int(k) for k in wf.keys() if str(k).isdigit()]
base_id = (max(numeric) + 1) if numeric else len(wf) + 1
load_id = str(base_id)
loader_id = str(base_id + 1)
apply_id = str(base_id + 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]
# Carga la imagen de referencia (slot 0 = IMAGE).
wf[load_id] = {
"class_type": "LoadImage",
"inputs": {"image": ref_image},
}
if mode == "style":
wf[loader_id] = {
"class_type": "IPAdapterUnifiedLoader",
"inputs": {"model": [ckpt, 0], "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": [ckpt, 0],
"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]["inputs"]["model"] = [apply_id, 0]
return wf
if __name__ == "__main__":
import json
wf_style = comfyui_build_ipadapter_workflow(
"a fantasy castle on a hill, oil painting",
"example.png",
base_checkpoint="dreamshaper_8.safetensors",
mode="style",
weight=0.8,
)
wf_face = comfyui_build_ipadapter_workflow(
"portrait of a knight in armor, cinematic",
"showcase_char.png",
base_checkpoint="dreamshaper_8.safetensors",
mode="faceid",
weight=0.9,
)
print(json.dumps({"style_nodes": list(wf_style), "faceid_nodes": list(wf_face)}, indent=2))
@@ -0,0 +1,63 @@
---
name: comfyui_inject_multi_lora
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_inject_multi_lora(workflow: dict, loras: list[dict]) -> dict"
description: "Encadena N nodos LoraLoader en un workflow ComfyUI ya construido (API format) reusando comfyui_inject_lora una vez por LoRA. Cada lora = {name, strength_model, strength_clip}. La salida MODEL/CLIP de cada LoraLoader alimenta al siguiente: el primer elemento queda cerca del checkpoint y el ultimo cerca del KSampler. Respeta el orden de la lista. Pura: no muta el dict de entrada."
tags: [comfyui, comfyui-skill, ml, lora, stable-diffusion, workflow]
uses_functions: [comfyui_inject_lora_py_ml]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: workflow
desc: "dict en API format (ej. salida de comfyui_build_txt2img_workflow). No se muta; se devuelve una copia."
- name: loras
desc: "Lista de dicts aplicados en orden. Cada dict: name (str, obligatorio, archivo .safetensors en models/loras/), strength_model (float, default 1.0, fuerza sobre el UNet), strength_clip (float, default 1.0, fuerza sobre el CLIP). Lista vacia => copia sin cambios."
output: "copia del workflow con N LoraLoader insertados y encadenados (checkpoint -> loras[0] -> loras[1] -> ... -> KSampler/CLIPTextEncode). Los node_id crecen en el orden de la lista."
tested: true
tests: ["encadena N LoRAs (cuenta correcta de LoraLoader)", "orden y cadena validos (loras[0] toma model del checkpoint, KSampler toma model del ultimo)", "respeta pesos por posicion", "no muta el dict de entrada (pureza)", "lista vacia devuelve copia sin loras", "lora sin name o no-dict lanza ValueError", "determinismo"]
test_file_path: "python/functions/ml/tests/test_comfyui_inject_multi_lora.py"
file_path: "python/functions/ml/comfyui_inject_multi_lora.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
from ml.comfyui_inject_multi_lora import comfyui_inject_multi_lora
base = comfyui_build_txt2img_workflow("dreamshaper_8.safetensors", "a robot, 3D Render Style")
wf = comfyui_inject_multi_lora(base, [
{"name": "3d_render_redmond_sd15.safetensors", "strength_model": 0.9},
{"name": "detail_tweaker_sd15.safetensors", "strength_model": 0.5, "strength_clip": 0.5},
])
# Cadena: checkpoint -> 3d_render (0.9) -> detail_tweaker (0.5/0.5) -> KSampler/CLIPTextEncode
```
## Cuando usarla
Cuando quieras apilar **varios LoRAs de estilo/detalle** sobre un workflow
txt2img/img2img sin llamar `comfyui_inject_lora` a mano N veces. Pasa la lista y
la funcion encadena en orden. Util para combinar estilo + ajuste fino (ej. un
LoRA de estilo 3D + un detail_tweaker) en una sola llamada.
## Gotchas
- Pura: no muta el `workflow` de entrada y NO valida que cada `name` exista en el
servidor. Valida con `comfyui_validate_workflow` antes de submit.
- **El orden importa**: `[estilo, detalle]` produce una cadena distinta a
`[detalle, estilo]`. El primer elemento queda mas cerca del checkpoint y el
ultimo mas cerca del KSampler (es el que el sampler "ve" mas directo).
- Cada LoRA acumula coste de VRAM. En 8GB con SD1.5 caben varios; con SDXL vigila
la memoria y reduce resolucion si hay OOM.
- Hereda los gotchas de `comfyui_inject_lora`: asume slots MODEL=0/CLIP=1 y detecta
la fuente por el KSampler.model. Si el workflow no tiene KSampler, propaga el
ValueError de `comfyui_inject_lora`.
@@ -0,0 +1,86 @@
"""Encadena N nodos LoraLoader en un workflow ComfyUI ya construido (API format).
Reusa comfyui_inject_lora una vez por LoRA: la salida MODEL/CLIP de cada
LoraLoader alimenta al siguiente, formando una cadena
checkpoint -> lora[0] -> lora[1] -> ... -> lora[n-1] -> KSampler/CLIPTextEncode.
El primer elemento de `loras` queda mas cerca del checkpoint y el ultimo mas
cerca de los consumidores (KSampler.model, CLIPTextEncode.clip). El orden se
respeta: apilar [estilo, detalle] no es lo mismo que [detalle, estilo].
Funcion pura: no muta el dict de entrada (comfyui_inject_lora trabaja sobre
copias profundas, asi que la cadena tampoco toca el workflow original).
"""
from __future__ import annotations
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
def comfyui_inject_multi_lora(workflow: dict, loras: list[dict]) -> dict:
"""Devuelve una copia del workflow con N LoraLoader encadenados.
Args:
workflow: dict en API format (ej. salida de
comfyui_build_txt2img_workflow). No se muta.
loras: lista de dicts, uno por LoRA, aplicados en orden. Cada dict:
- name (str, obligatorio): archivo .safetensors en models/loras/.
- strength_model (float, opcional, default 1.0): fuerza sobre el UNet.
- strength_clip (float, opcional, default 1.0): fuerza sobre el CLIP.
Una lista vacia devuelve una copia del workflow sin cambios.
Returns:
copia del workflow con los LoraLoader insertados y reconectados. Cada
LoRA recibe el node_id `max(ids numericos) + 1` en el momento de
insertarse, asi que los ids crecen en el orden de la lista.
Raises:
ValueError: si algun elemento de `loras` no es un dict, le falta 'name',
o el name esta vacio. Tambien propaga el ValueError de
comfyui_inject_lora si no puede determinar la fuente model/clip.
"""
from ml.comfyui_inject_lora import comfyui_inject_lora
if not isinstance(loras, list):
raise ValueError(
f"comfyui_inject_multi_lora: 'loras' debe ser una lista, no {type(loras).__name__}"
)
wf = workflow
for i, lora in enumerate(loras):
if not isinstance(lora, dict):
raise ValueError(
f"comfyui_inject_multi_lora: loras[{i}] debe ser un dict "
f"{{name, strength_model, strength_clip}}, no {type(lora).__name__}"
)
name = lora.get("name")
if not name:
raise ValueError(
f"comfyui_inject_multi_lora: loras[{i}] necesita la clave 'name' "
"con el archivo del LoRA"
)
wf = comfyui_inject_lora(
wf,
name,
strength_model=float(lora.get("strength_model", 1.0)),
strength_clip=float(lora.get("strength_clip", 1.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 robot, 3d render")
wf = comfyui_inject_multi_lora(
base,
[
{"name": "3d_render_redmond_sd15.safetensors", "strength_model": 0.9},
{"name": "detail_tweaker_sd15.safetensors", "strength_model": 0.5, "strength_clip": 0.5},
],
)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,110 @@
"""Tests de estructura, conexiones y validacion para comfyui_build_ipadapter_workflow (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_ipadapter_workflow import comfyui_build_ipadapter_workflow
from _comfyui_wf_assert import assert_api_format, class_types
def _node(wf, class_type):
return next((n for n in wf.values() if n["class_type"] == class_type), None)
def _node_id(wf, class_type):
return next((nid for nid, n in wf.items() if n["class_type"] == class_type), None)
def test_style_mode_nodos_y_conexiones():
wf = comfyui_build_ipadapter_workflow(
"a castle, oil painting", "ref.png",
base_checkpoint="dreamshaper_8.safetensors", mode="style", weight=0.75,
)
assert_api_format(wf)
cts = class_types(wf)
assert "LoadImage" in cts
assert "IPAdapterUnifiedLoader" in cts
assert "IPAdapter" in cts
ckpt = _node_id(wf, "CheckpointLoaderSimple")
load_id = _node_id(wf, "LoadImage")
loader_id = _node_id(wf, "IPAdapterUnifiedLoader")
apply_node = _node(wf, "IPAdapter")
apply_id = _node_id(wf, "IPAdapter")
# loader toma el MODEL del checkpoint
assert _node(wf, "IPAdapterUnifiedLoader")["inputs"]["model"] == [ckpt, 0]
# el nodo IPAdapter cablea model/ipadapter del loader y la imagen del LoadImage
assert apply_node["inputs"]["model"] == [loader_id, 0]
assert apply_node["inputs"]["ipadapter"] == [loader_id, 1]
assert apply_node["inputs"]["image"] == [load_id, 0]
assert apply_node["inputs"]["weight"] == 0.75
# KSampler repuntado a la salida MODEL del IPAdapter
assert _node(wf, "KSampler")["inputs"]["model"] == [apply_id, 0]
# defaults de modo style
assert _node(wf, "IPAdapterUnifiedLoader")["inputs"]["preset"] == "STANDARD (medium strength)"
assert apply_node["inputs"]["weight_type"] == "standard"
def test_faceid_mode_nodos_y_conexiones():
wf = comfyui_build_ipadapter_workflow(
"a knight portrait", "face.png",
base_checkpoint="dreamshaper_8.safetensors", mode="faceid",
weight=0.9, lora_strength=0.7,
)
assert_api_format(wf)
cts = class_types(wf)
assert "IPAdapterUnifiedLoaderFaceID" in cts
assert "IPAdapterFaceID" in cts
# no debe haber rama style
assert "IPAdapterUnifiedLoader" not in cts
assert "IPAdapter" not in cts
loader = _node(wf, "IPAdapterUnifiedLoaderFaceID")
apply_node = _node(wf, "IPAdapterFaceID")
loader_id = _node_id(wf, "IPAdapterUnifiedLoaderFaceID")
load_id = _node_id(wf, "LoadImage")
apply_id = _node_id(wf, "IPAdapterFaceID")
assert loader["inputs"]["preset"] == "FACEID PLUS V2"
assert loader["inputs"]["lora_strength"] == 0.7
assert loader["inputs"]["provider"] == "CPU"
assert apply_node["inputs"]["model"] == [loader_id, 0]
assert apply_node["inputs"]["ipadapter"] == [loader_id, 1]
assert apply_node["inputs"]["image"] == [load_id, 0]
assert apply_node["inputs"]["weight"] == 0.9
assert apply_node["inputs"]["weight_type"] == "linear"
assert _node(wf, "KSampler")["inputs"]["model"] == [apply_id, 0]
def test_mode_invalido_lanza_valueerror():
with pytest.raises(ValueError):
comfyui_build_ipadapter_workflow(
"x", "ref.png", base_checkpoint="ck.safetensors", mode="bogus")
def test_ref_image_vacia_lanza_valueerror():
with pytest.raises(ValueError):
comfyui_build_ipadapter_workflow(
"x", "", base_checkpoint="ck.safetensors", mode="style")
def test_preset_y_weight_type_override():
wf = comfyui_build_ipadapter_workflow(
"x", "ref.png", base_checkpoint="ck.safetensors", mode="style",
preset="PLUS (high strength)", weight_type="style transfer",
)
assert _node(wf, "IPAdapterUnifiedLoader")["inputs"]["preset"] == "PLUS (high strength)"
assert _node(wf, "IPAdapter")["inputs"]["weight_type"] == "style transfer"
def test_determinista():
kw = dict(base_checkpoint="ck.safetensors", mode="faceid", seed=42)
a = comfyui_build_ipadapter_workflow("x", "ref.png", **kw)
b = comfyui_build_ipadapter_workflow("x", "ref.png", **kw)
assert a == b
@@ -0,0 +1,107 @@
"""Tests de estructura, orden y pureza para comfyui_inject_multi_lora (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 _comfyui_wf_assert import assert_api_format, class_types
def _lora_nodes(wf):
"""Mapa lora_name -> (node_id, inputs) de los LoraLoader del workflow."""
return {
n["inputs"]["lora_name"]: (nid, n["inputs"])
for nid, n in wf.items()
if n["class_type"] == "LoraLoader"
}
def test_encadena_n_loras():
base = comfyui_build_txt2img_workflow("ck.safetensors", "POS", "NEG")
inj = comfyui_inject_multi_lora(
base,
[
{"name": "a.safetensors", "strength_model": 0.9},
{"name": "b.safetensors", "strength_model": 0.5},
{"name": "c.safetensors", "strength_model": 0.3},
],
)
assert_api_format(inj)
loras = _lora_nodes(inj)
assert set(loras) == {"a.safetensors", "b.safetensors", "c.safetensors"}
def test_orden_y_cadena_validos():
# Cadena esperada: checkpoint -> a -> b -> KSampler/CLIPTextEncode.
base = comfyui_build_txt2img_workflow("ck.safetensors", "POS", "NEG")
inj = comfyui_inject_multi_lora(
base,
[{"name": "a.safetensors"}, {"name": "b.safetensors"}],
)
loras = _lora_nodes(inj)
a_id, a_in = loras["a.safetensors"]
b_id, b_in = loras["b.safetensors"]
ckpt = next(nid for nid, n in inj.items() if n["class_type"] == "CheckpointLoaderSimple")
# 'a' (primer elemento) toma el MODEL del checkpoint.
assert a_in["model"] == [ckpt, 0]
# 'b' (segundo elemento) toma el MODEL de 'a' (salida slot 0).
assert b_in["model"] == [a_id, 0]
# El KSampler queda al final de la cadena: toma el MODEL de 'b'.
ks = next(n for n in inj.values() if n["class_type"] == "KSampler")
assert ks["inputs"]["model"] == [b_id, 0]
# El CLIP tambien se encadena hasta 'b' (salida slot 1).
cte = next(n for n in inj.values() if n["class_type"] == "CLIPTextEncode")
assert cte["inputs"]["clip"] == [b_id, 1]
def test_respeta_pesos_por_posicion():
base = comfyui_build_txt2img_workflow("ck.safetensors", "POS", "NEG")
inj = comfyui_inject_multi_lora(
base,
[
{"name": "a.safetensors", "strength_model": 0.9, "strength_clip": 0.8},
{"name": "b.safetensors", "strength_model": 0.4},
],
)
loras = _lora_nodes(inj)
assert loras["a.safetensors"][1]["strength_model"] == 0.9
assert loras["a.safetensors"][1]["strength_clip"] == 0.8
assert loras["b.safetensors"][1]["strength_model"] == 0.4
# default strength_clip = 1.0 cuando no se especifica
assert loras["b.safetensors"][1]["strength_clip"] == 1.0
def test_no_muta_la_entrada():
base = comfyui_build_txt2img_workflow("ck.safetensors", "POS", "NEG")
claves_antes = set(base)
_ = comfyui_inject_multi_lora(base, [{"name": "a.safetensors"}, {"name": "b.safetensors"}])
assert "LoraLoader" not in class_types(base)
assert set(base) == claves_antes
def test_lista_vacia_devuelve_copia_sin_loras():
base = comfyui_build_txt2img_workflow("ck.safetensors", "POS", "NEG")
inj = comfyui_inject_multi_lora(base, [])
assert "LoraLoader" not in class_types(inj)
assert class_types(inj) == class_types(base)
def test_lora_sin_name_lanza_valueerror():
base = comfyui_build_txt2img_workflow("ck.safetensors", "POS", "NEG")
with pytest.raises(ValueError):
comfyui_inject_multi_lora(base, [{"strength_model": 0.5}])
with pytest.raises(ValueError):
comfyui_inject_multi_lora(base, ["a.safetensors"]) # no es dict
def test_determinista():
base = comfyui_build_txt2img_workflow("ck.safetensors", "POS", "NEG")
spec = [{"name": "a.safetensors"}, {"name": "b.safetensors"}]
assert comfyui_inject_multi_lora(base, spec) == comfyui_inject_multi_lora(base, spec)