From 3887e59092142e0b6d757485e8d153708696a376 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Wed, 24 Jun 2026 17:47:28 +0200 Subject: [PATCH] feat(ml): auto-commit con 6 cambios Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ml/comfyui_build_ipadapter_workflow.md | 118 +++++++++ .../ml/comfyui_build_ipadapter_workflow.py | 224 ++++++++++++++++++ .../functions/ml/comfyui_inject_multi_lora.md | 63 +++++ .../functions/ml/comfyui_inject_multi_lora.py | 86 +++++++ .../test_comfyui_build_ipadapter_workflow.py | 110 +++++++++ .../tests/test_comfyui_inject_multi_lora.py | 107 +++++++++ 6 files changed, 708 insertions(+) create mode 100644 python/functions/ml/comfyui_build_ipadapter_workflow.md create mode 100644 python/functions/ml/comfyui_build_ipadapter_workflow.py create mode 100644 python/functions/ml/comfyui_inject_multi_lora.md create mode 100644 python/functions/ml/comfyui_inject_multi_lora.py create mode 100644 python/functions/ml/tests/test_comfyui_build_ipadapter_workflow.py create mode 100644 python/functions/ml/tests/test_comfyui_inject_multi_lora.py diff --git a/python/functions/ml/comfyui_build_ipadapter_workflow.md b/python/functions/ml/comfyui_build_ipadapter_workflow.md new file mode 100644 index 00000000..b641707e --- /dev/null +++ b/python/functions/ml/comfyui_build_ipadapter_workflow.md @@ -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. diff --git a/python/functions/ml/comfyui_build_ipadapter_workflow.py b/python/functions/ml/comfyui_build_ipadapter_workflow.py new file mode 100644 index 00000000..19d33bfa --- /dev/null +++ b/python/functions/ml/comfyui_build_ipadapter_workflow.py @@ -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)) diff --git a/python/functions/ml/comfyui_inject_multi_lora.md b/python/functions/ml/comfyui_inject_multi_lora.md new file mode 100644 index 00000000..526d0f11 --- /dev/null +++ b/python/functions/ml/comfyui_inject_multi_lora.md @@ -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`. diff --git a/python/functions/ml/comfyui_inject_multi_lora.py b/python/functions/ml/comfyui_inject_multi_lora.py new file mode 100644 index 00000000..bca790e4 --- /dev/null +++ b/python/functions/ml/comfyui_inject_multi_lora.py @@ -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)) diff --git a/python/functions/ml/tests/test_comfyui_build_ipadapter_workflow.py b/python/functions/ml/tests/test_comfyui_build_ipadapter_workflow.py new file mode 100644 index 00000000..0e552025 --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_build_ipadapter_workflow.py @@ -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 diff --git a/python/functions/ml/tests/test_comfyui_inject_multi_lora.py b/python/functions/ml/tests/test_comfyui_inject_multi_lora.py new file mode 100644 index 00000000..f1b4295b --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_inject_multi_lora.py @@ -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)