feat(gamedev): comfyui_build_portrait_avatar_workflow — retratos/avatares de personaje

Builder puro (dict API format) hermano de sprite_sheet/item_icon: retrato/avatar
de personaje (busto centrado, cara al espectador, fondo simple con vinheta) para
dialogo, perfil o seleccion de personaje. Con ref_face encadena IPAdapter-FaceID
(rostro consistente entre retratos); con facedetailer regenera la cara con detalle
(FaceDetailer de Impact-Pack). Compone txt2img/ipadapter + inject_lora + facedetailer.

9 tests offline en verde + 1 generacion real verificada (mujer caballero pelirroja,
cara nitida). Fila en docs/capabilities/gamedev-2d.md. Report 0148.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 22:40:30 +02:00
parent 3f465aceed
commit 404e2e4d0c
4 changed files with 512 additions and 0 deletions
+1
View File
@@ -35,6 +35,7 @@ VFX (ver `reports/0143`).
| `comfyui_build_sprite_sheet_workflow_py_ml` | `(subject, *, ref_image=None, pose_skeleton=None, char_lora=None, transparent=True, …) -> dict` | UN sprite de personaje: IPAdapter-FaceID + LoRA + ControlNet OpenPose (Advanced, end<1) + Rembg. Varias poses → sheet. SD1.5. | | `comfyui_build_sprite_sheet_workflow_py_ml` | `(subject, *, ref_image=None, pose_skeleton=None, char_lora=None, transparent=True, …) -> dict` | UN sprite de personaje: IPAdapter-FaceID + LoRA + ControlNet OpenPose (Advanced, end<1) + Rembg. Varias poses → sheet. SD1.5. |
| `comfyui_build_vfx_spritesheet_workflow_py_ml` | `(prompt, *, motion_model="mm_sd_v15_v2.ckpt", num_frames=16, closed_loop=True, lora=None, …) -> dict` | N frames AnimateDiff loop sobre negro (insumo de luma→alpha). 8GB: 16f@512² revienta, usar ≤8f@512² o bajar resolución. | | `comfyui_build_vfx_spritesheet_workflow_py_ml` | `(prompt, *, motion_model="mm_sd_v15_v2.ckpt", num_frames=16, closed_loop=True, lora=None, …) -> dict` | N frames AnimateDiff loop sobre negro (insumo de luma→alpha). 8GB: 16f@512² revienta, usar ≤8f@512² o bajar resolución. |
| `comfyui_build_item_icon_workflow_py_ml` | `(item, *, style="game icon, clean, centered", checkpoint="dreamshaper_8…", size=512, transparent=True, lora=None, …) -> dict` | UN icono de item de inventario (espada/poción/anillo/libro/escudo): txt2img cuadrado + prompt scaffold de icono + LoRA estilo opcional + Rembg (alpha). Set coherente = mismo style/checkpoint/lora por item. SD1.5. | | `comfyui_build_item_icon_workflow_py_ml` | `(item, *, style="game icon, clean, centered", checkpoint="dreamshaper_8…", size=512, transparent=True, lora=None, …) -> dict` | UN icono de item de inventario (espada/poción/anillo/libro/escudo): txt2img cuadrado + prompt scaffold de icono + LoRA estilo opcional + Rembg (alpha). Set coherente = mismo style/checkpoint/lora por item. SD1.5. |
| `comfyui_build_portrait_avatar_workflow_py_ml` | `(character, *, style="character portrait", ref_face=None, checkpoint="dreamshaper_8…", size=512, facedetailer=True, lora=None, …) -> dict` | UN retrato/avatar de personaje (busto centrado, cara al espectador, fondo simple): txt2img + prompt scaffold de retrato + FaceDetailer (cara nítida) + LoRA estilo opcional; `ref_face` → IPAdapter-FaceID para rostro consistente entre retratos. Diálogo/perfil/selección. SD1.5. |
## Funciones de post-proceso y puente (`gamedev`, CPU) ## Funciones de post-proceso y puente (`gamedev`, CPU)
@@ -0,0 +1,124 @@
---
name: comfyui_build_portrait_avatar_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_portrait_avatar_workflow(character: str, *, style: str = \"character portrait\", ref_face: str | None = None, checkpoint: str = \"dreamshaper_8.safetensors\", size: int = 512, facedetailer: bool = True, seed: int = 0, lora: str | None = None, lora_strength: float = 1.0, weight: float = 0.85, negative: str | None = None, steps: int = 28, cfg: float = 7.0, sampler_name: str = \"dpmpp_2m\", scheduler: str = \"karras\", fd_denoise: float = 0.45, bbox_model: str = \"face_yolov8m.pt\", filename_prefix: str = \"portrait\") -> dict"
description: "Construye el dict (API format) del workflow de UN retrato/avatar de personaje 2D (busto centrado, cara al espectador, fondo simple con vinheta) para dialogo, perfil o seleccion de personaje. Con ref_face encadena IPAdapter-FaceID para rostro consistente entre retratos; con facedetailer regenera la cara con detalle (Impact-Pack). Compone comfyui_build_ipadapter_workflow / comfyui_build_txt2img_workflow + comfyui_inject_lora + comfyui_build_facedetailer_workflow. Hermano de comfyui_build_sprite_sheet/item_icon_workflow. Pura, sin red ni I/O. class_types verificados contra /object_info (8GB lowvram)."
tags: [comfyui, ml, gamedev, gamedev-2d, portrait, avatar, character, faceid, ipadapter, facedetailer, workflow]
uses_functions: [comfyui_build_txt2img_workflow_py_ml, comfyui_build_ipadapter_workflow_py_ml, comfyui_inject_lora_py_ml, comfyui_build_facedetailer_workflow_py_ml]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: character
desc: "Descripcion del personaje (ej. 'a young knight woman with red hair', 'an old wizard with a long white beard'). Se inserta en un prompt scaffold de retrato. No puede estar vacio."
- name: style
desc: "Descriptor de estilo que mantiene consistentes los retratos de un set (ej. 'character portrait', 'anime portrait', 'realistic RPG portrait', 'oil painting portrait'). Pasa el MISMO style + checkpoint + lora a todos los retratos para coherencia visual. keyword-only."
- name: ref_face
desc: "Nombre de una imagen de rostro de referencia en input/ del servidor. Si se pasa, encadena IPAdapter-FaceID para que el retrato tenga el MISMO rostro (identidad consistente entre retratos). None = identidad solo por prompt + seed. keyword-only."
- name: checkpoint
desc: "Checkpoint del servidor. 'dreamshaper_8.safetensors' (SD1.5, holgado en 8GB lowvram) por defecto. FaceID solo instalado para SD1.5: con checkpoint SDXL deja ref_face=None. keyword-only."
- name: size
desc: "Lado del cuadrado en px (width = height = size). 512 SD1.5 por defecto, encuadre tipico de avatar. keyword-only."
- name: facedetailer
desc: "Si True encadena FaceDetailer (Impact-Pack) para regenerar la cara con detalle y descarta el SaveImage base (el unico PNG guardado es el refinado). False deja la imagen tal cual sale del VAEDecode. keyword-only."
- name: seed
desc: "Semilla del KSampler (y del sampler del FaceDetailer). Misma seed + mismo character/ref_face -> mismo retrato. keyword-only."
- name: lora
desc: "LoRA de estilo opcional en models/loras (ej. 'anime_lineart_sd15.safetensors'). None = sin LoRA. keyword-only."
- name: lora_strength
desc: "Fuerza del LoRA sobre model y clip. Se clampa a [0.0, 2.0]. keyword-only."
- name: weight
desc: "Peso del IPAdapter-FaceID (solo si ref_face). 0.85 = parecido alto; baja para mas libertad del prompt, sube para mas parecido. keyword-only."
- name: negative
desc: "Prompt negativo. None usa el negativo por defecto pensado para retratos (una cara bien formada, sin texto/recorte). keyword-only."
- name: steps
desc: "Pasos del KSampler. keyword-only."
- name: cfg
desc: "CFG del KSampler (y del sampler del FaceDetailer). keyword-only."
- name: sampler_name
desc: "Sampler del KSampler. keyword-only."
- name: scheduler
desc: "Scheduler del KSampler. keyword-only."
- name: fd_denoise
desc: "Fuerza de re-difusion de la cara en el FaceDetailer (0.45: refina sin perder la identidad que aporta FaceID). Solo si facedetailer=True. keyword-only."
- name: bbox_model
desc: "Modelo de deteccion de caras de Ultralytics para el FaceDetailer ('face_yolov8m.pt'). Solo si facedetailer=True. keyword-only."
- name: filename_prefix
desc: "Prefijo del PNG en output/. keyword-only."
output: "dict en API format listo para comfyui_submit_workflow: base txt2img (o IPAdapter-FaceID si ref_face) con prompt scaffold de retrato ('portrait of {character}, {style}, bust shot, head and shoulders, looking at viewer, ...') + LoRA de estilo opcional + FaceDetailer (si facedetailer, con SaveImage base descartado). UN retrato; set coherente -> llamar por character con mismo style/checkpoint/(lora) y, para el mismo personaje, el mismo ref_face/seed."
tested: true
tests: ["golden facedetailer: clases CheckpointLoaderSimple/KSampler/VAEDecode/FaceDetailer/UltralyticsDetectorProvider; 'portrait of'+'bust shot'+'looking at viewer' en prompt; un solo SaveImage = fd_save <- FaceDetailer; FaceDetailer.image <- VAEDecode; sin IPAdapterFaceID si no ref_face", "edge facedetailer=False: sin FaceDetailer, SaveImage base <- VAEDecode", "edge ref_face: IPAdapterUnifiedLoaderFaceID/IPAdapterFaceID presentes, LoadImage con ref_face, weight reflejado, KSampler.model <- rama FaceID", "edge style en prompt", "edge size: width==height==768 (cuadrado)", "edge lora: LoraLoader con strength", "edge seed reflejado en KSampler", "error character vacio -> ValueError", "determinismo"]
test_file_path: "python/functions/ml/comfyui_build_portrait_avatar_workflow_test.py"
file_path: "python/functions/ml/comfyui_build_portrait_avatar_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_portrait_avatar_workflow import comfyui_build_portrait_avatar_workflow
# Retrato con cara nitida (FaceDetailer), identidad por prompt + seed.
wf = comfyui_build_portrait_avatar_workflow(
"a young knight woman with red hair",
style="realistic RPG portrait",
facedetailer=True,
seed=7,
)
# -> comfyui_submit_workflow(wf) -> comfyui_wait_result -> comfyui_fetch_output_image
# Rostro CONSISTENTE entre retratos: sube una cara de referencia a input/ del
# servidor y pasala como ref_face (IPAdapter-FaceID). Mismo ref_face -> misma cara.
# wf = comfyui_build_portrait_avatar_workflow(
# "a young knight woman with red hair, smiling",
# ref_face="hero_face.png", style="realistic RPG portrait", seed=7)
# wf = comfyui_build_portrait_avatar_workflow(
# "a young knight woman with red hair, angry",
# ref_face="hero_face.png", style="realistic RPG portrait", seed=8)
# Set coherente de varios personajes: mismo style/checkpoint, varia character.
```
O lanzable directo con: `./fn run comfyui_build_portrait_avatar_workflow` (imprime nodos + class_types del ejemplo).
## Cuando usarla
Cuando necesites retratos/avatares de personaje para un juego: cuadros de dialogo,
pantalla de perfil, selector de personaje, fichas de NPC. Busto centrado, cara al
espectador, fondo simple. Para que el MISMO personaje aparezca en varios retratos
(distintas expresiones/poses) sube una foto de su cara a `input/` y pasala como
`ref_face` -> IPAdapter-FaceID fija el rostro. Para un set de personajes distintos
con look uniforme, fija `style` + `checkpoint` + (`lora`) y varia solo `character`.
`facedetailer=True` (default) recupera el detalle de la cara que el render a 512px
pierde. Es UN retrato; genera cada uno y, si quieres un mosaico, monta los PNG con
`comfyui_build_grid`.
## Gotchas
- **FaceID solo SD1.5**: `ref_face` requiere los modelos IPAdapter-FaceID, que en
este servidor solo estan para SD1.5 (dreamshaper_8). Con un checkpoint SDXL deja
`ref_face=None` (identidad por prompt + seed) o usa un checkpoint SD1.5.
- **El detailer regenera la cara con el modelo base, no con FaceID**: FaceDetailer
reutiliza el `CheckpointLoaderSimple` crudo (no la rama IPAdapter). Por eso
`fd_denoise` es bajo (0.45): refina ojos/piel preservando la identidad que ya
trae la imagen base generada con FaceID. Si notas que la cara refinada se aleja
del parecido, baja `fd_denoise` (0.3-0.4) o sube `weight`.
- **SaveImage unico con facedetailer**: cuando `facedetailer=True` se descarta el
SaveImage base y solo se guarda el PNG del detailer (`fd_save`) — no hay doble
guardado. Con `facedetailer=False` el SaveImage base toma del VAEDecode.
- **Requiere custom nodes instalados**: IPAdapter_plus (cubiq) para FaceID e
Impact-Pack para FaceDetailer/UltralyticsDetectorProvider. Si un nodo falta, el
submit fallara; consulta `/object_info` del servidor para confirmar.
- **Coherencia del set = mismos parametros**: cambiar `style`/`checkpoint`/`lora`
entre retratos rompe el look uniforme. Para el mismo personaje, fija ademas
`ref_face` + `seed`.
- **8GB lowvram**: `size=512` con dreamshaper_8 va holgado; SDXL pide mas VRAM y
resolucion mayor. Si hay OOM, baja `size` o desactiva `facedetailer`.
- Es una funcion **pura**: solo arma el dict. La generacion real (GPU) la hacen
`comfyui_submit_workflow` + `comfyui_wait_result` + `comfyui_fetch_output_image`.
@@ -0,0 +1,244 @@
"""Construye el workflow ComfyUI de UN retrato/avatar de personaje (API format).
Retratos/avatares de personaje para dialogo, pantalla de perfil o seleccion de
personaje: busto centrado (cabeza y hombros), cara mirando al espectador, fondo
simple con vinheta, estilo consistente entre retratos. Es el builder hermano de
comfyui_build_sprite_sheet_workflow / comfyui_build_item_icon_workflow: mismo
patron (PURO, dict API format) que compone funciones existentes del registry, no
reescribe el grafo.
Cableado segun los argumentos:
[IPAdapter-FaceID si ref_face] -> rostro consistente
CheckpointLoaderSimple -> [LoraLoader si lora] -> KSampler
-> CLIPTextEncode (scaffold de retrato) ...
-> VAEDecode -> SaveImage
-> [FaceDetailer si facedetailer] -> SaveImage final
Compone:
- comfyui_build_ipadapter_workflow (mode='faceid') -> identidad de rostro
consistente desde una imagen de referencia (o comfyui_build_txt2img_workflow
si no hay ref_face)
- comfyui_inject_lora -> LoRA de estilo opcional
- comfyui_build_facedetailer_workflow (modo dict) -> cara nitida (regenera la
cara detectada con un sampler de difusion, el 'pain #1' de los retratos)
Por que IPAdapter-FaceID y no solo prompt: para que el MISMO personaje aparezca
en varios retratos (varias expresiones/poses) hace falta fijar el rostro; FaceID
extrae el embedding de la cara de `ref_face` y lo impone (report 0137). Sin
`ref_face`, la identidad la dan solo el prompt + la seed (estable entre llamadas
con la misma seed, pero no anclado a una cara concreta).
Por que FaceDetailer al final: el primer render pierde detalle en la cara a 512px
(ojos, piel); FaceDetailer detecta la cara (YOLO) y la regenera ampliada. Cuando
se activa, se descarta el SaveImage base para que el unico PNG guardado sea el de
la cara ya refinada.
class_types/inputs verificados contra /object_info del servidor (8GB lowvram) a
traves de los builders que compone (IPAdapterUnifiedLoaderFaceID/IPAdapterFaceID,
LoraLoader, UltralyticsDetectorProvider/FaceDetailer de Impact-Pack).
Funcion pura: sin red, sin I/O. No muta dicts de entrada (los builders/inyectores
que compone trabajan sobre copias). Determinista para los mismos argumentos.
"""
from __future__ import annotations
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# Negativo por defecto pensado para retratos: una sola cara bien formada, sin
# texto/marcas ni recortes que ensucien el busto centrado.
_PORTRAIT_NEGATIVE = (
"blurry, lowres, deformed face, disfigured, bad anatomy, extra limbs, "
"extra fingers, mutated hands, ugly, text, watermark, signature, "
"cropped, out of frame, multiple faces, two heads, jpeg artifacts"
)
def comfyui_build_portrait_avatar_workflow(
character: str,
*,
style: str = "character portrait",
ref_face: str | None = None,
checkpoint: str = "dreamshaper_8.safetensors",
size: int = 512,
facedetailer: bool = True,
seed: int = 0,
lora: str | None = None,
lora_strength: float = 1.0,
weight: float = 0.85,
negative: str | None = None,
steps: int = 28,
cfg: float = 7.0,
sampler_name: str = "dpmpp_2m",
scheduler: str = "karras",
fd_denoise: float = 0.45,
bbox_model: str = "face_yolov8m.pt",
filename_prefix: str = "portrait",
) -> dict:
"""Construye el dict (API format) del workflow de un retrato/avatar de personaje.
Args:
character: descripcion del personaje (ej. "a young knight woman with red
hair", "an old wizard with a long white beard"). Se inserta en un
prompt scaffold de retrato. No puede estar vacio.
style: descriptor de estilo que mantiene consistentes los retratos de un
set (ej. "character portrait", "anime portrait", "realistic RPG
portrait", "oil painting portrait"). Pasa el MISMO style + checkpoint
+ (lora) a todos los retratos del set para coherencia visual.
keyword-only.
ref_face: nombre de una imagen de rostro de referencia en el directorio
input/ del servidor ComfyUI. Si se pasa, encadena IPAdapter-FaceID para
que el retrato tenga el MISMO rostro (identidad consistente entre
retratos). None = identidad solo por prompt + seed. keyword-only.
checkpoint: checkpoint del servidor. 'dreamshaper_8.safetensors' (SD1.5,
holgado en 8GB lowvram) por defecto. FaceID solo esta instalado para
SD1.5: si usas un checkpoint SDXL, deja ref_face=None. keyword-only.
size: lado del cuadrado en px (width = height = size). 512 SD1.5 por
defecto, encuadre tipico de avatar. keyword-only.
facedetailer: si True encadena FaceDetailer (Impact-Pack) para regenerar la
cara con detalle y descarta el SaveImage base (el unico PNG guardado es
el refinado). False deja la imagen tal cual sale del VAEDecode.
keyword-only.
seed: semilla del KSampler (y del sampler del FaceDetailer). Misma seed +
mismo character/ref_face -> mismo retrato. keyword-only.
lora: LoRA de estilo opcional en models/loras (ej.
'anime_lineart_sd15.safetensors'). None = sin LoRA. keyword-only.
lora_strength: fuerza del LoRA sobre model y clip. Se clampa a [0.0, 2.0].
keyword-only.
weight: peso del IPAdapter-FaceID (solo si ref_face). 0.85 = parecido alto;
baja para mas libertad del prompt, sube para mas parecido. keyword-only.
negative: prompt negativo. None usa el negativo por defecto pensado para
retratos (una cara, bien formada, sin texto/recorte). keyword-only.
steps: pasos del KSampler. keyword-only.
cfg: CFG del KSampler (y del sampler del FaceDetailer). keyword-only.
sampler_name: sampler del KSampler. keyword-only.
scheduler: scheduler del KSampler. keyword-only.
fd_denoise: fuerza de re-difusion de la cara en el FaceDetailer (0.45 por
defecto: refina sin perder la identidad que aporta FaceID). Solo se usa
si facedetailer=True. keyword-only.
bbox_model: modelo de deteccion de caras de Ultralytics para el
FaceDetailer ('face_yolov8m.pt'). Solo si facedetailer=True.
keyword-only.
filename_prefix: prefijo del PNG generado en output/. keyword-only.
Returns:
dict en API format listo para comfyui_submit_workflow: base txt2img (o
IPAdapter-FaceID si ref_face) con prompt scaffold de retrato + LoRA de
estilo opcional + FaceDetailer (si facedetailer). Es UN retrato; un set
coherente -> llamar por character con el mismo style/checkpoint/(lora) y,
para el mismo personaje, el mismo ref_face/seed.
Raises:
ValueError: si character esta vacio, o si la base/inyectores no encuentran
los nodos donde enganchar (propagado por los builders que compone).
"""
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
if not character or not character.strip():
raise ValueError(
"comfyui_build_portrait_avatar_workflow: 'character' no puede estar vacio"
)
character = character.strip()
lora_strength = max(0.0, min(2.0, float(lora_strength)))
neg = _PORTRAIT_NEGATIVE if negative is None else negative
# Prompt scaffold de retrato: busto centrado, cara al espectador, fondo simple.
positive = (
f"portrait of {character}, {style}, bust shot, head and shoulders, "
"looking at viewer, detailed face, sharp eyes, clean simple background, "
"soft vignette, high detail"
)
if ref_face:
from ml.comfyui_build_ipadapter_workflow import comfyui_build_ipadapter_workflow
wf = comfyui_build_ipadapter_workflow(
positive,
ref_face,
base_checkpoint=checkpoint,
mode="faceid",
weight=weight,
negative=neg,
steps=steps,
cfg=cfg,
width=size,
height=size,
seed=seed,
sampler_name=sampler_name,
scheduler=scheduler,
filename_prefix=filename_prefix,
)
else:
wf = comfyui_build_txt2img_workflow(
checkpoint,
positive,
neg,
steps=steps,
cfg=cfg,
width=size,
height=size,
seed=seed,
sampler_name=sampler_name,
scheduler=scheduler,
filename_prefix=filename_prefix,
)
if lora:
from ml.comfyui_inject_lora import comfyui_inject_lora
wf = comfyui_inject_lora(
wf, lora, strength_model=lora_strength, strength_clip=lora_strength
)
if facedetailer:
from ml.comfyui_build_facedetailer_workflow import (
comfyui_build_facedetailer_workflow,
)
# Descarta el SaveImage base: el FaceDetailer toma la imagen del VAEDecode y
# anhade su propio SaveImage (fd_save). Asi el unico PNG guardado es el de la
# cara ya refinada (sin doble guardado). dict-comprehension = copia, no muta.
wf = {k: v for k, v in wf.items() if v.get("class_type") != "SaveImage"}
# Prompt del detailer enfocado en la cara, conservando personaje + estilo.
fd_positive = f"{character}, {style}, detailed face, sharp eyes, skin texture"
wf = comfyui_build_facedetailer_workflow(
wf,
checkpoint,
fd_positive,
neg,
bbox_model=bbox_model,
denoise=fd_denoise,
steps=steps,
cfg=cfg,
seed=seed,
sampler_name=sampler_name,
scheduler=scheduler,
filename_prefix=filename_prefix,
)
return wf
if __name__ == "__main__":
import json
wf = comfyui_build_portrait_avatar_workflow(
"a young knight woman with red hair",
style="realistic RPG portrait",
facedetailer=True,
seed=7,
)
print(
json.dumps(
{
"nodes": list(wf),
"classes": sorted({n["class_type"] for n in wf.values()}),
},
indent=2,
)
)
@@ -0,0 +1,143 @@
"""Tests offline de comfyui_build_portrait_avatar_workflow (estructura del dict, sin GPU)."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ml.comfyui_build_portrait_avatar_workflow import ( # noqa: E402
comfyui_build_portrait_avatar_workflow,
)
def _classes(wf):
return sorted({n["class_type"] for n in wf.values()})
def _by_class(wf, cls):
return [n for n in wf.values() if n["class_type"] == cls]
def _positive_node(wf, needle):
return next(
n
for n in wf.values()
if n["class_type"] == "CLIPTextEncode" and needle in n["inputs"]["text"]
)
def test_golden_recipe_facedetailer():
wf = comfyui_build_portrait_avatar_workflow(
"a young knight woman with red hair", seed=7
)
cls = _classes(wf)
# Cadena base txt2img + FaceDetailer (default facedetailer=True).
assert "CheckpointLoaderSimple" in cls
assert "KSampler" in cls
assert "VAEDecode" in cls
assert "FaceDetailer" in cls
assert "UltralyticsDetectorProvider" in cls
# Sin ref_face -> no hay rama IPAdapter-FaceID.
assert "IPAdapterFaceID" not in cls
# El personaje aparece en el prompt con el scaffold de retrato.
pos = _positive_node(wf, "a young knight woman with red hair")
assert "portrait of" in pos["inputs"]["text"]
assert "bust shot" in pos["inputs"]["text"]
assert "looking at viewer" in pos["inputs"]["text"]
# FaceDetailer activado -> el unico SaveImage es el del detailer (fd_save).
saves = _by_class(wf, "SaveImage")
assert len(saves) == 1
fd_face_id = next(nid for nid, n in wf.items() if n["class_type"] == "FaceDetailer")
assert saves[0]["inputs"]["images"] == [fd_face_id, 0]
# El FaceDetailer toma la imagen del VAEDecode del base.
vd_id = next(nid for nid, n in wf.items() if n["class_type"] == "VAEDecode")
fd = _by_class(wf, "FaceDetailer")[0]
assert fd["inputs"]["image"] == [vd_id, 0]
def test_edge_no_facedetailer_keeps_base_save():
wf = comfyui_build_portrait_avatar_workflow("an old wizard", facedetailer=False)
cls = _classes(wf)
assert "FaceDetailer" not in cls
# SaveImage base presente y toma del VAEDecode.
saves = _by_class(wf, "SaveImage")
assert len(saves) == 1
vd_id = next(nid for nid, n in wf.items() if n["class_type"] == "VAEDecode")
assert saves[0]["inputs"]["images"] == [vd_id, 0]
def test_edge_ref_face_activates_faceid():
wf = comfyui_build_portrait_avatar_workflow(
"a young knight woman with red hair",
ref_face="hero_face.png",
weight=0.9,
facedetailer=False,
)
cls = _classes(wf)
assert "IPAdapterUnifiedLoaderFaceID" in cls
assert "IPAdapterFaceID" in cls
# La imagen de referencia se carga con LoadImage.
loads = [n for n in _by_class(wf, "LoadImage")]
assert any(n["inputs"]["image"] == "hero_face.png" for n in loads)
# weight reflejado en el nodo FaceID.
faceid = _by_class(wf, "IPAdapterFaceID")[0]
assert faceid["inputs"]["weight"] == 0.9
# El KSampler consume el MODEL condicionado por la rama IPAdapter.
ks = _by_class(wf, "KSampler")[0]
apply_id = next(nid for nid, n in wf.items() if n["class_type"] == "IPAdapterFaceID")
assert ks["inputs"]["model"] == [apply_id, 0]
def test_edge_style_reflected_in_prompt():
wf = comfyui_build_portrait_avatar_workflow(
"a cheerful bard", style="anime portrait, cel shaded", facedetailer=False
)
pos = _positive_node(wf, "a cheerful bard")
assert "anime portrait, cel shaded" in pos["inputs"]["text"]
def test_edge_size_reflected_square():
wf = comfyui_build_portrait_avatar_workflow(
"a stoic paladin", size=768, facedetailer=False
)
latent = _by_class(wf, "EmptyLatentImage")[0]["inputs"]
assert latent["width"] == 768
assert latent["height"] == 768 # cuadrado (avatar)
def test_edge_lora_reflected():
wf = comfyui_build_portrait_avatar_workflow(
"a dark sorceress",
lora="anime_lineart_sd15.safetensors",
lora_strength=0.8,
facedetailer=False,
)
loras = _by_class(wf, "LoraLoader")
assert len(loras) == 1
assert loras[0]["inputs"]["lora_name"] == "anime_lineart_sd15.safetensors"
assert loras[0]["inputs"]["strength_model"] == 0.8
def test_edge_seed_reflected():
wf = comfyui_build_portrait_avatar_workflow(
"a grizzled mercenary", seed=123, facedetailer=False
)
ks = _by_class(wf, "KSampler")[0]
assert ks["inputs"]["seed"] == 123
def test_error_empty_character():
try:
comfyui_build_portrait_avatar_workflow(" ")
assert False
except ValueError as e:
assert "character" in str(e)
def test_determinism():
a = comfyui_build_portrait_avatar_workflow(
"a young knight woman with red hair", lora="anime_lineart_sd15.safetensors", seed=7
)
b = comfyui_build_portrait_avatar_workflow(
"a young knight woman with red hair", lora="anime_lineart_sd15.safetensors", seed=7
)
assert a == b