diff --git a/docs/capabilities/gamedev-2d.md b/docs/capabilities/gamedev-2d.md index aa948492..3968183f 100644 --- a/docs/capabilities/gamedev-2d.md +++ b/docs/capabilities/gamedev-2d.md @@ -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`). | diff --git a/python/functions/ml/comfyui_build_emote_workflow.md b/python/functions/ml/comfyui_build_emote_workflow.md new file mode 100644 index 00000000..b9330441 --- /dev/null +++ b/python/functions/ml/comfyui_build_emote_workflow.md @@ -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`. diff --git a/python/functions/ml/comfyui_build_emote_workflow.py b/python/functions/ml/comfyui_build_emote_workflow.py new file mode 100644 index 00000000..283cba94 --- /dev/null +++ b/python/functions/ml/comfyui_build_emote_workflow.py @@ -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, + ) + ) diff --git a/python/functions/ml/comfyui_build_emote_workflow_test.py b/python/functions/ml/comfyui_build_emote_workflow_test.py new file mode 100644 index 00000000..6f37938b --- /dev/null +++ b/python/functions/ml/comfyui_build_emote_workflow_test.py @@ -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