feat(ml): auto-commit con 6 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||||
Reference in New Issue
Block a user