feat(gamedev): comfyui_build_emote_workflow — emotes/expresiones de personaje

Builder puro (dict API format) del grupo gamedev: genera el workflow de UN
emote/expresion facial del mismo personaje (alegre, triste, enfadado,
sorprendido, neutral) para sistema de dialogo, retratos reactivos o emotes de
chat. La clave es la consistencia del personaje entre expresiones: ref_face
encadena IPAdapter-FaceID para que varie solo la expresion y el rostro sea el
mismo; facedetailer regenera la cara conservando la expresion.

Compone comfyui_build_ipadapter_workflow / comfyui_build_txt2img_workflow +
comfyui_inject_lora + comfyui_build_facedetailer_workflow. Hermano de
comfyui_build_portrait_avatar/sprite_sheet_workflow.

12 tests offline verdes (golden + edge + error) y 1 generacion real verificada
en GPU (8GB lowvram): la expresion happy/smiling se lee claramente. Fila en
docs/capabilities/gamedev-2d.md. Evidencia en reports/0151.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 23:08:49 +02:00
parent 3980fbbffb
commit f5387aa30e
4 changed files with 594 additions and 0 deletions
+1
View File
@@ -37,6 +37,7 @@ VFX (ver `reports/0143`).
| `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_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. |
| `comfyui_build_emote_workflow_py_ml` | `(character, expression, *, ref_face=None, style="character portrait", checkpoint="dreamshaper_8…", size=512, facedetailer=True, lora=None, …) -> dict` | UN emote/expresión facial del MISMO personaje (alegre/triste/enfadado/sorprendido/neutral…) para diálogo, retratos reactivos o emotes de chat: txt2img + prompt scaffold de emote (`portrait of {character}, {expression} expression, emote, clean background`) + FaceDetailer (conserva la expresión); `ref_face` → IPAdapter-FaceID para que varíe SOLO la expresión y el rostro sea el mismo. UNA expresión por llamada; set = mismas claves variando `expression``comfyui_build_grid`. Probado e2e en GPU (`reports/0151`). SD1.5. |
| `comfyui_build_parallax_background_workflow_py_ml` | `(scene, *, style="game background, side-scroller…", layers=3, checkpoint="dreamshaper_8…", depth_node="DepthAnythingV2Preprocessor", width=1024, height=512, …) -> dict` | Fondo en capas para parallax 2.5D: genera el fondo apaisado (txt2img) + su depth map (`DepthAnythingV2Preprocessor` sobre el VAEDecode), dos SaveImage. El split en N bandas por profundidad es post (GAP: `split_parallax_layers`, aún no creada). Probado e2e en GPU (`reports/0149`). SD1.5. |
| `comfyui_build_normal_map_workflow_py_ml` | `(image, *, method="normal", strength=1.0, resolution=512, bg_threshold=0.1, filename_prefix="normal_map") -> dict` | Normal/depth map de un sprite existente para iluminación dinámica 2.5D (Godot CanvasItem `normal_map`, Unity sprite normal). `LoadImage → preprocesador controlnet_aux → SaveImage`. `method`: `normal` (default, `BAE-NormalMapPreprocessor`, normal canónico **azul/violeta** usable directo en motor), `normal_midas` (MiDaS, único con `strength``a`, paleta no canónica), `normal_dsine` (DSINE), `depth` (`DepthAnythingV2`, height en gris). `image` debe estar en `input/` de ComfyUI. Coste VRAM ≈0. Probado e2e en GPU (`reports/0150`). |
@@ -0,0 +1,133 @@
---
name: comfyui_build_emote_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_emote_workflow(character: str, expression: str, *, ref_face: str | None = None, style: str = \"character portrait\", 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 = \"emote\") -> dict"
description: "Construye el dict (API format) del workflow de UN emote/expresion facial de personaje 2D (alegre, triste, enfadado, sorprendido, neutral...) para sistema de dialogo, retratos reactivos o emotes de chat. La clave es la consistencia del personaje entre expresiones: con ref_face encadena IPAdapter-FaceID para que el rostro sea el mismo y varie SOLO la expresion; con facedetailer regenera la cara con detalle conservando la expresion (Impact-Pack). Compone comfyui_build_ipadapter_workflow / comfyui_build_txt2img_workflow + comfyui_inject_lora + comfyui_build_facedetailer_workflow. Hermano de comfyui_build_portrait_avatar/sprite_sheet_workflow. Pura, sin red ni I/O. class_types verificados contra /object_info (8GB lowvram)."
tags: [comfyui, ml, gamedev, gamedev-2d, emote, expression, character, dialogue, 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 knight woman with red hair', 'an old wizard with a long white beard'). Se inserta en un prompt scaffold de emote. No puede estar vacio. Manten el MISMO character en todas las expresiones de un set para coherencia de personaje."
- name: expression
desc: "Expresion facial a representar (ej. 'happy, smiling', 'sad', 'angry', 'surprised', 'neutral'). Es lo unico que deberia cambiar entre los emotes de un mismo personaje. No puede estar vacio."
- name: ref_face
desc: "Nombre de una imagen de rostro de referencia del personaje en input/ del servidor. Si se pasa, encadena IPAdapter-FaceID para que todos los emotes tengan el MISMO rostro (identidad consistente entre expresiones). None = identidad solo por prompt + seed. keyword-only."
- name: style
desc: "Descriptor de estilo que mantiene consistentes los emotes de un set (ej. 'character portrait', 'anime portrait', 'realistic RPG portrait'). Pasa el MISMO style + checkpoint + lora a todas las expresiones para coherencia visual. 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 retrato/emote. keyword-only."
- name: facedetailer
desc: "Si True encadena FaceDetailer (Impact-Pack) para regenerar la cara con detalle (donde se lee la expresion) 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 personaje; variar solo expression mantiene la identidad y cambia la cara. 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 expresiva, sube para mas parecido. keyword-only."
- name: negative
desc: "Prompt negativo. None usa el negativo por defecto pensado para emotes (una cara bien formada, sin texto/recorte; NO filtra ninguna expresion). 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 de FaceID ni la expresion). 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 emote ('portrait of {character}, {expression} expression, emote, {style}, expressive face, ..., clean background') + LoRA de estilo opcional + FaceDetailer (si facedetailer, con SaveImage base descartado y la expresion conservada en el prompt del detailer). UN emote; un set coherente -> llamar por expression con mismo character/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'+'{expression} expression'+'emote'+'clean background' en prompt; un solo SaveImage = fd_save <- FaceDetailer; FaceDetailer.image <- VAEDecode; sin IPAdapterFaceID si no ref_face", "golden detailer conserva expresion: >=2 CLIPTextEncode con la expresion (base + fd_pos), fd_pos enfoca 'detailed 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 (consistencia)", "edge expression en prompt", "edge style en prompt", "edge size: width==height==768 (cuadrado)", "edge lora: LoraLoader con strength", "edge seed reflejado en KSampler", "error character vacio -> ValueError", "error expression vacio -> ValueError", "determinismo"]
test_file_path: "python/functions/ml/comfyui_build_emote_workflow_test.py"
file_path: "python/functions/ml/comfyui_build_emote_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_emote_workflow import comfyui_build_emote_workflow
# UN emote con cara nitida (FaceDetailer), identidad por prompt + seed.
wf = comfyui_build_emote_workflow(
"a knight woman with red hair",
"happy, smiling",
style="realistic RPG portrait",
facedetailer=True,
seed=7,
)
# -> comfyui_submit_workflow(wf) -> comfyui_wait_result -> comfyui_fetch_output_image
# Set de emotes del MISMO personaje: sube una cara de referencia a input/ del
# servidor y pasala como ref_face (IPAdapter-FaceID). Mismo character/ref_face/seed,
# varia SOLO expression -> mismo rostro, distinta expresion.
# for expr in ["happy, smiling", "sad", "angry", "surprised", "neutral"]:
# wf = comfyui_build_emote_workflow(
# "a knight woman with red hair", expr,
# ref_face="hero_face.png", style="realistic RPG portrait", seed=7)
# # submit + wait + fetch cada uno -> luego comfyui_build_grid para una hoja.
```
O lanzable directo con: `./fn run comfyui_build_emote_workflow` (imprime nodos + class_types del ejemplo).
## Cuando usarla
Cuando necesites el set de expresiones/emotes de un personaje para un sistema de
dialogo, retratos reactivos (la cara del NPC cambia segun el tono de la
conversacion) o emotes de chat: alegre, triste, enfadado, sorprendido, neutral...
Es UNA expresion por llamada. Para que sea el MISMO personaje en todas, sube una
foto de su cara a `input/` y pasala como `ref_face` -> IPAdapter-FaceID fija el
rostro y solo cambia la expresion entre llamadas. Mantén `character` + `style` +
`checkpoint` + (`lora`) + `seed` constantes y varia solo `expression`. Un set
completo se monta en una hoja de emotes con `comfyui_build_grid` sobre los PNG.
Para un busto generico (sin expresion concreta) usa el hermano
`comfyui_build_portrait_avatar_workflow`.
## Gotchas
- **La expresion es lo unico que debe variar**: para coherencia de personaje en
un set, cambia SOLO `expression` y deja `character`/`style`/`checkpoint`/`lora`/
`seed`/`ref_face` igual. Cambiar otros parametros rompe la identidad entre
emotes.
- **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 conserva la expresion**: el prompt del FaceDetailer incluye
`{expression} expression` para no perder el emote al refinar la cara. Reutiliza
el `CheckpointLoaderSimple` crudo (no la rama IPAdapter); por eso `fd_denoise`
es bajo (0.45): refina ojos/boca/cejas preservando la identidad de FaceID. Si la
expresion se diluye al refinar, sube `fd_denoise` ligeramente; si se aleja el
parecido, bajalo (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.
- **El negativo por defecto no filtra expresiones**: 'neutral', 'sad', etc. son
expresiones validas, por eso el negativo solo cubre defectos de cara/anatomia,
no estados de animo. Si pasas un `negative` propio, no incluyas terminos que
contradigan la `expression` pedida.
- **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,272 @@
"""Construye el workflow ComfyUI de UN emote/expresion facial de personaje (API format).
Emotes/expresiones del MISMO personaje (alegre, triste, enfadado, sorprendido,
neutral...) para un sistema de dialogo, retratos reactivos o emotes de chat. La
clave es la consistencia del personaje entre expresiones: el rostro debe ser el
mismo y variar SOLO la expresion. Es el builder hermano de
comfyui_build_portrait_avatar_workflow / comfyui_build_sprite_sheet_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] -> mismo rostro entre emotes
CheckpointLoaderSimple -> [LoraLoader si lora] -> KSampler
-> CLIPTextEncode (scaffold de emote) ...
-> VAEDecode -> SaveImage
-> [FaceDetailer si facedetailer] -> SaveImage final
Compone:
- comfyui_build_ipadapter_workflow (mode='faceid') -> identidad de rostro
consistente desde una imagen de referencia del personaje (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 un set de emotes coherente el
MISMO personaje tiene que aparecer en todas las expresiones; FaceID extrae el
embedding de la cara de `ref_face` y lo impone, de modo que cambia la expresion
pero no la identidad (report 0137 / report 0148). Sin `ref_face`, la identidad la
dan solo el prompt + la seed (estable entre llamadas con la misma seed y el mismo
character, pero no anclada a una cara concreta de referencia).
Por que FaceDetailer al final: el primer render pierde detalle en la cara a 512px
(ojos, boca, cejas) y la expresion es precisamente donde se lee el emote;
FaceDetailer detecta la cara (YOLO) y la regenera ampliada conservando la
expresion. Cuando se activa, se descarta el SaveImage base para que el unico PNG
guardado sea el de la cara ya refinada.
Una expresion por llamada. Un set de emotes del mismo personaje se obtiene
llamando N veces (mismo character/ref_face/seed/style/checkpoint, cambiando solo
`expression`) y montando los PNG resultantes con comfyui_build_grid en una hoja.
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 emotes: una sola cara bien formada, sin
# texto/marcas ni recortes que ensucien el busto centrado. No filtra ninguna
# expresion concreta (neutral, triste, etc. son expresiones validas).
_EMOTE_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_emote_workflow(
character: str,
expression: str,
*,
ref_face: str | None = None,
style: str = "character portrait",
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 = "emote",
) -> dict:
"""Construye el dict (API format) del workflow de un emote/expresion de personaje.
Args:
character: descripcion del personaje (ej. "a knight woman with red hair",
"an old wizard with a long white beard"). Se inserta en un prompt
scaffold de emote. No puede estar vacio. Manten el MISMO character en
todas las expresiones de un set para coherencia de personaje.
expression: expresion facial a representar (ej. "happy, smiling", "sad",
"angry", "surprised", "neutral"). Es lo unico que deberia cambiar
entre los emotes de un mismo personaje. No puede estar vacio.
ref_face: nombre de una imagen de rostro de referencia del personaje en el
directorio input/ del servidor ComfyUI. Si se pasa, encadena
IPAdapter-FaceID para que todos los emotes tengan el MISMO rostro
(identidad consistente entre expresiones). None = identidad solo por
prompt + seed. keyword-only.
style: descriptor de estilo que mantiene consistentes los emotes de un set
(ej. "character portrait", "anime portrait", "realistic RPG
portrait"). Pasa el MISMO style + checkpoint + (lora) a todas las
expresiones para coherencia visual. 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 retrato/emote. keyword-only.
facedetailer: si True encadena FaceDetailer (Impact-Pack) para regenerar la
cara con detalle (donde se lee la expresion) 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 personaje; variar solo expression
mantiene la identidad y cambia la cara. 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 expresiva, sube para mas parecido. keyword-only.
negative: prompt negativo. None usa el negativo por defecto pensado para
emotes (una cara, bien formada, sin texto/recorte; no filtra ninguna
expresion). 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 de FaceID ni la expresion).
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 emote + LoRA de estilo
opcional + FaceDetailer (si facedetailer). Es UN emote; un set coherente
-> llamar por expression con el mismo character/style/checkpoint/(lora) y,
para el mismo personaje, el mismo ref_face/seed.
Raises:
ValueError: si character o expression estan vacios, 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_emote_workflow: 'character' no puede estar vacio"
)
if not expression or not expression.strip():
raise ValueError(
"comfyui_build_emote_workflow: 'expression' no puede estar vacio"
)
character = character.strip()
expression = expression.strip()
lora_strength = max(0.0, min(2.0, float(lora_strength)))
neg = _EMOTE_NEGATIVE if negative is None else negative
# Prompt scaffold de emote: retrato del personaje con la expresion marcada,
# encuadre de emote (cara expresiva mirando al espectador) y fondo limpio para
# recorte/uso en UI de dialogo.
positive = (
f"portrait of {character}, {expression} expression, emote, "
f"{style}, expressive face, head and shoulders, looking at viewer, "
"clean background, 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 la expresion: la
# expresion es lo que distingue un emote, no debe perderse al refinar.
fd_positive = (
f"{character}, {expression} expression, {style}, "
"detailed face, sharp eyes, expressive"
)
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_emote_workflow(
"a knight woman with red hair",
"happy, smiling",
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,188 @@
"""Tests offline de comfyui_build_emote_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_emote_workflow import ( # noqa: E402
comfyui_build_emote_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_emote_workflow(
"a knight woman with red hair", "happy, smiling", 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 y la expresion aparecen en el prompt con el scaffold de emote.
pos = _positive_node(wf, "a knight woman with red hair")
assert "portrait of" in pos["inputs"]["text"]
assert "happy, smiling expression" in pos["inputs"]["text"]
assert "emote" in pos["inputs"]["text"]
assert "clean background" 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_golden_facedetailer_keeps_expression():
wf = comfyui_build_emote_workflow(
"a knight woman with red hair", "happy, smiling", seed=7
)
# Hay al menos dos nodos positivos que mencionan la expresion: base + detailer.
matches = [
n
for n in wf.values()
if n["class_type"] == "CLIPTextEncode"
and "happy, smiling expression" in n["inputs"]["text"]
]
assert len(matches) >= 2
# El CLIPTextEncode del detailer (fd_pos) conserva la expresion y enfoca la cara.
fd_pos = _positive_node(wf, "detailed face")
assert "happy, smiling expression" in fd_pos["inputs"]["text"]
def test_edge_no_facedetailer_keeps_base_save():
wf = comfyui_build_emote_workflow("an old wizard", "angry", 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_emote_workflow(
"a knight woman with red hair",
"surprised",
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 del personaje se carga con LoadImage.
loads = _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 (consistencia).
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_expression_reflected_in_prompt():
wf = comfyui_build_emote_workflow(
"a cheerful bard", "sad, crying", facedetailer=False
)
pos = _positive_node(wf, "a cheerful bard")
assert "sad, crying expression" in pos["inputs"]["text"]
def test_edge_style_reflected_in_prompt():
wf = comfyui_build_emote_workflow(
"a cheerful bard",
"neutral",
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_emote_workflow(
"a stoic paladin", "angry", size=768, facedetailer=False
)
latent = _by_class(wf, "EmptyLatentImage")[0]["inputs"]
assert latent["width"] == 768
assert latent["height"] == 768 # cuadrado (retrato/emote)
def test_edge_lora_reflected():
wf = comfyui_build_emote_workflow(
"a dark sorceress",
"smug",
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_emote_workflow(
"a grizzled mercenary", "neutral", seed=123, facedetailer=False
)
ks = _by_class(wf, "KSampler")[0]
assert ks["inputs"]["seed"] == 123
def test_error_empty_character():
try:
comfyui_build_emote_workflow(" ", "happy")
assert False
except ValueError as e:
assert "character" in str(e)
def test_error_empty_expression():
try:
comfyui_build_emote_workflow("a knight", " ")
assert False
except ValueError as e:
assert "expression" in str(e)
def test_determinism():
a = comfyui_build_emote_workflow(
"a knight woman with red hair",
"happy, smiling",
lora="anime_lineart_sd15.safetensors",
seed=7,
)
b = comfyui_build_emote_workflow(
"a knight woman with red hair",
"happy, smiling",
lora="anime_lineart_sd15.safetensors",
seed=7,
)
assert a == b