From 19ad2b3e5de88cdf41a126fef1e41840ed5f8fbe Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 27 Jun 2026 00:41:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(gamedev):=20comfyui=5Fbuild=5Fprojectile?= =?UTF-8?q?=5Fworkflow=20=E2=80=94=20proyectiles/balas/hechizos=20orientad?= =?UTF-8?q?os=20(glow=E2=86=92luma-alpha,=20s=C3=B3lido=E2=86=92rembg)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/capabilities/gamedev-2d.md | 1 + .../ml/comfyui_build_projectile_workflow.md | 125 +++++++++ .../ml/comfyui_build_projectile_workflow.py | 248 ++++++++++++++++++ .../comfyui_build_projectile_workflow_test.py | 156 +++++++++++ 4 files changed, 530 insertions(+) create mode 100644 python/functions/ml/comfyui_build_projectile_workflow.md create mode 100644 python/functions/ml/comfyui_build_projectile_workflow.py create mode 100644 python/functions/ml/comfyui_build_projectile_workflow_test.py diff --git a/docs/capabilities/gamedev-2d.md b/docs/capabilities/gamedev-2d.md index e4a9a157..98287c3c 100644 --- a/docs/capabilities/gamedev-2d.md +++ b/docs/capabilities/gamedev-2d.md @@ -47,6 +47,7 @@ VFX (ver `reports/0143`). | `comfyui_build_topdown_sprite_workflow_py_ml` | `(subject, *, direction="south", style="top-down game sprite, RPG", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UN sprite en **vista CENITAL (top-down)** estilo RPG clásico/roguelike (Zelda, juegos cenitales): personaje/objeto visto **desde arriba**, centrado, fondo limpio recortable a alpha (`{subject}, top-down view, overhead view, {direction} facing, {style}, centered, plain background, game asset…`) → txt2img cuadrado + LoRA estilo opcional + Rembg (alpha). `direction` (south/north/east/west) para el sprite de movimiento: las 4 vistas del MISMO personaje = misma `subject`/`style`/`seed`, varía solo `direction` → montar con `comfyui_build_grid`. **DISTINTO de `sprite_sheet` (vista lateral/frontal de plataformas)**: el negativo por defecto rechaza side/front/3-4/isometric/perspective para forzar la cenital. Con SD1.5 sin LoRA sale picado alto; cenital estricto pide LoRA top-down + cfg alto. Probado e2e en GPU con SD1.5 (`reports/0156`). SD1.5. | | `comfyui_build_splash_art_workflow_py_ml` | `(scene, *, mood="epic, cinematic", checkpoint="juggernaut_xl_v11…", width=1024, height=576, hires=True, seed=0, lora=None, …) -> dict` | LA ilustración grande de UN splash / pantalla de carga / key art en formato **pantalla apaisado 16:9** (`width>height`, ~1024×576), composición cinematográfica (`{scene}, {mood}, key art, game splash screen, dramatic lighting, cinematic composition, wide shot, epic scale, atmospheric…`). `hires=True` → 2ª pasada de detalle (`comfyui_build_hires_fix_workflow`) para verse a pantalla completa; si no, txt2img + LoRA estilo opcional. Genera SOLO la ilustración — el título/logo/barra de carga los pone el motor/post (negativo rechaza `text/title/logo/UI/frame/watermark`), dejando aire para superponer el título. Set coherente = mismo `mood`/`checkpoint`/`lora`, varía solo `scene`. Probado e2e en GPU con SD1.5 + hires (1024×576 → 1536×864, 54s, `reports/0159`). SD1.5/SDXL. | | `comfyui_build_decal_overlay_workflow_py_ml` | `(decal, *, on_black=True, style="grunge decal, high detail", checkpoint="dreamshaper_8…", size=512, seed=0, lora=None, …) -> dict` | UN decal/overlay con alpha para superponer sobre superficies/paredes/sprites con blend mode del motor (sangre, grietas, suciedad, óxido, quemaduras, salpicaduras, arañazos, musgo): textura **aislada sobre fondo PLANO** (`{decal}, {style}, single isolated decal, centered, on a solid pure black background, flat backdrop, sticker, no scenery, texture overlay, game asset…`) → txt2img cuadrado + LoRA estilo opcional. `on_black=True` (defecto) pensado para extraer alpha con **`comfyui_matting_luma_to_alpha`** (luma=alpha, conserva el falloff de translúcidos — la técnica gamedev correcta, ≠ recorte binario). **NO inyecta Rembg** (el matting es luma→alpha de disco, no un nodo): el SaveImage sale directo del VAEDecode. Set coherente = mismo `style`/`checkpoint`/`lora`, varía solo `decal`/`seed`. ⚠️ "grunge" en `style` arrastra fondo gris en SD1.5 → para fondo negro plano usar un `style` sin connotación de fondo + reroll de `seed`; luma Rec601 penaliza el rojo → para sangre roja pasar `luma_weights` con más peso al rojo. Probado e2e en GPU con SD1.5 (`reports/0160`). SD1.5. | +| `comfyui_build_projectile_workflow_py_ml` | `(projectile, *, direction="right", glow=False, style="game projectile, side view", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UN proyectil orientado (flecha, bala, bola de fuego, rayo, misil, hechizo): sprite pequeño con **orientación** (apunta a la derecha por defecto, ángulo 0 — el motor rota el sprite), aislado, listo para instanciar. **`glow` elige el camino a alpha**: `glow=False` (defecto) = proyectil SÓLIDO con silueta → `plain background` + **Rembg** (alpha por recorte, como `item_icon`/`topdown_sprite`); `glow=True` = brillante/mágico → `glowing, on black background` **sin Rembg** (recortaría el halo), insumo de **`comfyui_matting_luma_to_alpha`** que el caller aplica luego (como `vfx_spritesheet`/`decal_overlay`). `glow=True` ignora `transparent`/`rembg_model`; el negativo por defecto NO rechaza "black background". `direction` se inserta como `pointing {direction}` (`""`/None = sin orientación). Set coherente = mismo `style`/`checkpoint`/`lora`, varía solo `projectile`/`seed`. Probado e2e en GPU con SD1.5 — fireball glow sobre negro + luma→alpha RGBA (`reports/0161`). SD1.5. | ## Funciones de post-proceso y puente (`gamedev`, CPU) diff --git a/python/functions/ml/comfyui_build_projectile_workflow.md b/python/functions/ml/comfyui_build_projectile_workflow.md new file mode 100644 index 00000000..a7437679 --- /dev/null +++ b/python/functions/ml/comfyui_build_projectile_workflow.md @@ -0,0 +1,125 @@ +--- +name: comfyui_build_projectile_workflow +kind: function +lang: py +domain: ml +version: "1.0.0" +purity: pure +signature: "def comfyui_build_projectile_workflow(projectile: str, *, direction: str = \"right\", glow: bool = False, style: str = \"game projectile, side view\", checkpoint: str = \"dreamshaper_8.safetensors\", size: int = 512, transparent: bool = True, seed: int = 0, lora: str | None = None, lora_strength: float = 1.0, rembg_model: str = \"u2net\", negative: str | None = None, steps: int = 28, cfg: float = 7.0, sampler_name: str = \"dpmpp_2m\", scheduler: str = \"karras\", filename_prefix: str = \"projectile\") -> dict" +description: "Construye el dict (API format) del workflow de UN proyectil orientado 2D: flecha, bala, bola de fuego, rayo, misil o hechizo — sprite pequeno con orientacion (horizontal por defecto, apuntando a la derecha), aislado sobre fondo recortable a alpha, listo para instanciar como proyectil en el motor (rotar segun el angulo de disparo). Opcion `direction` (right/left/up/down) y `glow`: glow=False (defecto) -> proyectil SOLIDO con Image Rembg para alpha; glow=True -> proyectil BRILLANTE/magico sobre fondo NEGRO sin Rembg, como insumo de comfyui_matting_luma_to_alpha (luminancia-como-alpha) que el caller aplica luego. Compone comfyui_build_txt2img_workflow + comfyui_inject_lora (estilo opcional) + Image Rembg (si solido y transparent). Hermano de comfyui_build_item_icon/topdown_sprite/vfx_spritesheet_workflow. Pura, sin red ni I/O. class_types verificados contra /object_info." +tags: [comfyui, ml, gamedev, gamedev-2d, projectile, bullet, fireball, spell, vfx, rembg, luma-to-alpha, workflow] +uses_functions: [comfyui_build_txt2img_workflow_py_ml, comfyui_inject_lora_py_ml] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +params: + - name: projectile + desc: "Nombre del proyectil (ej. 'arrow', 'bullet', 'fireball', 'lightning bolt', 'missile', 'magic spell'). Se inserta en un prompt scaffold de proyectil. No puede estar vacio." + - name: direction + desc: "Orientacion hacia la que apunta el proyectil. 'right' por defecto (apuntando a la derecha, angulo 0 — el convenio del motor que luego rota el sprite), 'left', 'up', 'down' (tambien diagonales como 'up-right'). None/'' = sin orientacion explicita. keyword-only." + - name: glow + desc: "Si True el proyectil es brillante/magico (bola de fuego, rayo, hechizo): scaffold 'glowing, on black background' y NO se inyecta Rembg (recortaria el halo translucido). La imagen queda sobre fondo negro como INSUMO de comfyui_matting_luma_to_alpha. glow=True ignora transparent/rembg_model. False (defecto) = proyectil solido por Rembg. keyword-only." + - name: style + desc: "Descriptor de estilo que mantiene consistentes los proyectiles del set (ej. 'game projectile, side view', 'hand-painted RPG projectile', 'pixel art bullet'). Pasa el MISMO style + checkpoint + lora a todos los proyectiles para coherencia visual. keyword-only." + - name: checkpoint + desc: "Checkpoint del servidor. 'dreamshaper_8.safetensors' (SD1.5, holgado en 8GB lowvram) por defecto; 'juggernaut_xl_v11.safetensors' para SDXL (mas VRAM, subir size). keyword-only." + - name: size + desc: "Lado del cuadrado en px (width = height = size). 512 SD1.5 por defecto. keyword-only." + - name: transparent + desc: "Si True (y glow=False) inyecta Image Rembg y el PNG sale con alpha (silueta recortada). False = proyectil opaco sobre fondo plano, recortable luego por el caller. Ignorado cuando glow=True. keyword-only." + - name: seed + desc: "Semilla del KSampler. keyword-only." + - name: lora + desc: "LoRA de estilo opcional en models/loras (ej. 'detail_tweaker_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: rembg_model + desc: "Modelo Rembg ('u2net' general, 'isnet-anime' para anime). Solo se usa si transparent=True y glow=False. keyword-only." + - name: negative + desc: "Prompt negativo. None usa el negativo por defecto pensado para proyectiles (un objeto aislado, sin escenario/personaje, sin texto/foto). No menciona 'black background' para no rechazar el fondo negro que glow necesita. keyword-only." + - name: steps + desc: "Pasos del KSampler. keyword-only." + - name: cfg + desc: "CFG del KSampler. keyword-only." + - name: sampler_name + desc: "Sampler del KSampler. keyword-only." + - name: scheduler + desc: "Scheduler del KSampler. 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 cuadrada con prompt scaffold de proyectil ('a {projectile}, {style}, pointing {direction}, isolated, single object, {plain background | glowing, on black background}, game asset, ...') + LoRA de estilo opcional. Si glow=False y transparent, ademas Image Rembg para alpha. Si glow=True, la imagen sale sobre fondo negro (insumo de comfyui_matting_luma_to_alpha). UN proyectil; un set -> llamar por proyectil con el mismo style/checkpoint/lora." +tested: true +tests: ["golden solido transparent: clases CheckpointLoaderSimple/KSampler/VAEDecode/SaveImage/Image Rembg; projectile + 'game projectile, side view' + 'pointing right' + 'isolated' + 'plain background' + 'game asset' en prompt; SaveImage <- Rembg; transparency True", "golden glow sobre negro: 'glowing, on black background' en prompt, sin 'plain background', sin Rembg, SaveImage <- VAEDecode", "glow ignora transparent: glow=True + transparent=True no inyecta Rembg", "edge direction reflejada: left/up/down/up-right aparecen como 'pointing {d}'", "edge direction opcional: sin direction no hay 'pointing'", "edge solido opaco: glow=False + transparent=False sin Rembg, SaveImage <- VAEDecode", "edge size: width==height==768 (cuadrado)", "edge style en prompt", "edge lora: LoraLoader presente con strength", "negativo por defecto permite black background (glow lo necesita)", "error projectile vacio -> ValueError", "determinismo"] +test_file_path: "python/functions/ml/comfyui_build_projectile_workflow_test.py" +file_path: "python/functions/ml/comfyui_build_projectile_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_projectile_workflow import comfyui_build_projectile_workflow + +# Proyectil brillante (bola de fuego) apuntando a la derecha, sobre fondo negro +# como insumo de luma->alpha. Listo para submit. +wf = comfyui_build_projectile_workflow( + "fireball", + direction="right", + glow=True, + style="game projectile, side view", + seed=42, +) +# comfyui_submit_workflow(wf) -> comfyui_wait_result -> comfyui_fetch_output_image +# -> comfyui_matting_luma_to_alpha (brillante=opaco, negro=transparente) + +# Proyectil solido (flecha) con alpha directo via Rembg: +wf2 = comfyui_build_projectile_workflow("arrow", direction="right", glow=False, transparent=True, seed=5) +``` + +O lanzable directo con: `./fn run comfyui_build_projectile_workflow` (imprime nodos + class_types del ejemplo). + +## Cuando usarla + +Cuando necesites el sprite de un PROYECTIL de juego — flecha, bala, bola de fuego, +rayo, misil, hechizo, plasma — orientado (apuntando a la derecha por defecto, angulo +0) y aislado para instanciar en el motor y rotar segun el angulo de disparo. Elige el +camino a alpha segun la naturaleza del proyectil: + +- **`glow=False` (proyectil solido: flecha, bala, misil, espada lanzada)**: la silueta + esta definida; con `transparent=True` se recorta limpio con Image Rembg dejando alpha. +- **`glow=True` (proyectil brillante/magico: bola de fuego, rayo, hechizo)**: el halo es + translucido; se genera sobre fondo NEGRO y NO se recorta con Rembg. Despues aplica + `comfyui_matting_luma_to_alpha` (luminancia-como-alpha) en un paso aparte para sacar + el halo con transparencia gradual. + +Usa `direction` para fijar hacia donde apunta el sprite si el motor no rota desde +"right". Pasa el MISMO `style` + `checkpoint` + (`lora`) a todos los proyectiles del +juego para que combinen. + +## Gotchas + +- **glow elige el camino a alpha**: `glow=True` NO inyecta Rembg (recortaria el halo) y + pone el proyectil sobre fondo negro como insumo de `comfyui_matting_luma_to_alpha`. + `glow=False` + `transparent=True` recorta la silueta solida con Rembg. No mezcles: un + proyectil brillante recortado por silueta pierde el halo; uno solido por luma-to-alpha + pierde las zonas oscuras del objeto. +- **glow=True ignora `transparent`/`rembg_model`**: el camino de un brillante es siempre + luma-to-alpha, no Rembg. Esos dos parametros solo aplican a `glow=False`. +- **El negativo por defecto NO rechaza "black background"**: es deliberado, para que el + fondo negro de `glow=True` salga. Si pasas un `negative` propio para un proyectil glow, + NO incluyas "black background" o anularas el insumo de luma-to-alpha. +- **`direction` se inserta como "pointing {direction}"**: `direction="up"` -> "pointing up" + en el prompt. Deja `direction=""`/None para un proyectil sin orientacion fija. El + convenio tipico del motor es apuntar a la derecha (angulo 0) y rotar el sprite. +- **El matting/recorte es un paso aparte**: esta funcion solo arma el dict. La generacion + real (GPU) la hacen `comfyui_submit_workflow` + `comfyui_wait_result` + + `comfyui_fetch_output_image`; el luma-to-alpha de un proyectil glow es + `comfyui_matting_luma_to_alpha` en un pipeline posterior. +- **SDXL pide mas VRAM y resolucion**: con `checkpoint="juggernaut_xl_v11.safetensors"` + sube `size` a 768/1024; con dreamshaper_8 (SD1.5) deja 512 (holgado en 8GB lowvram). Si + hay OOM, baja `size` o usa SD1.5. +- Es una funcion **pura**: solo arma el dict, sin red ni I/O. Determinista para los mismos + argumentos. diff --git a/python/functions/ml/comfyui_build_projectile_workflow.py b/python/functions/ml/comfyui_build_projectile_workflow.py new file mode 100644 index 00000000..d4cc720e --- /dev/null +++ b/python/functions/ml/comfyui_build_projectile_workflow.py @@ -0,0 +1,248 @@ +"""Construye el workflow ComfyUI de UN proyectil orientado (API format). + +Proyectiles de juego (flecha, bala, bola de fuego, rayo, misil, hechizo): sprite +pequeno con ORIENTACION (horizontal por defecto, apuntando a la derecha), aislado +sobre fondo recortable a alpha, listo para instanciar como proyectil en el motor +(rotar segun el angulo de disparo). Es el builder hermano de +comfyui_build_item_icon_workflow / comfyui_build_vfx_spritesheet_workflow / +comfyui_build_topdown_sprite_workflow: mismo patron (PURO, dict API format) que +compone funciones existentes del registry, no reescribe el grafo. + +Cableado: + + CheckpointLoaderSimple -> [LoraLoader opcional de estilo] -> KSampler + -> CLIPTextEncode (prompt scaffold de proyectil) ... + -> VAEDecode -> [Image Rembg opcional] -> SaveImage + +Compone: + - comfyui_build_txt2img_workflow -> base txt2img cuadrada + - comfyui_inject_lora -> LoRA de estilo opcional (consistencia) + - 'Image Rembg (Remove Background)' (helper local) -> fondo transparente (alpha) + +Dos caminos a alpha segun la naturaleza del proyectil: + + - glow=False (DEFECTO): proyectil SOLIDO (flecha, bala, misil, espada lanzada) + con silueta definida. El scaffold pide "plain background" y, si transparent, + se inyecta Rembg que recorta la silueta dejando alpha limpio. Es el camino + analogo a item_icon / topdown_sprite. + + - glow=True: proyectil BRILLANTE/MAGICO (bola de fuego, rayo, hechizo, plasma) + cuyo halo es translucido. El scaffold pide "glowing, on black background" y NO + se inyecta Rembg (recortaria el halo). La imagen queda sobre fondo negro, + pensada como INSUMO de comfyui_matting_luma_to_alpha (luminancia-como-alpha: + brillante=opaco, negro=transparente), que el caller aplica luego en un + pipeline. Mismo motivo por el que comfyui_build_vfx_spritesheet_workflow genera + sobre fondo negro. Por eso glow=True ignora `transparent`/`rembg_model`. + +Por que `direction` y no meterlo en `style`: separar la orientacion del estilo deja +fijar hacia donde "apunta" el proyectil (right/left/up/down) manteniendo el resto. +El convenio del motor suele ser apuntar a la derecha (angulo 0) y rotar el sprite; +por eso direction="right" por defecto. direction vacio = sin orientacion explicita. + +class_types/inputs verificados contra /object_info del servidor (8GB lowvram): +CheckpointLoaderSimple, CLIPTextEncode, EmptyLatentImage, KSampler, VAEDecode, +SaveImage, LoraLoader, 'Image Rembg (Remove Background)' (transparency BOOLEAN). + +Funcion pura: sin red, sin I/O. No muta dicts de entrada (copia profunda en el +helper de rembg). Determinista para los mismos argumentos. +""" +from __future__ import annotations + +import copy +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +# Negativo por defecto pensado para proyectiles: UN objeto aislado, sin escenario ni +# personaje que lo sostenga, sin texto/marcas ni recortes. No menciona "black +# background" para no rechazar el fondo negro que glow=True necesita. +_PROJECTILE_NEGATIVE = ( + "blurry, lowres, multiple objects, cluttered background, busy background, " + "scenery, landscape, character, person, hand holding, text, watermark, " + "signature, photo, photorealistic, jpeg artifacts, cropped, out of frame, " + "deformed" +) + + +def _inject_rembg(workflow: dict, model: str) -> dict: + """Inserta 'Image Rembg (Remove Background)' (transparency=True) entre VAEDecode y SaveImage. + + Mismo helper que usan comfyui_build_item_icon_workflow / topdown_sprite: el nodo + recorta la silueta del proyectil solido dejando alpha. Repunta SaveImage.images + a la salida del Rembg. + """ + wf = copy.deepcopy(workflow) + vaedecode_id = next( + (nid for nid, n in wf.items() if n.get("class_type") == "VAEDecode"), None + ) + save_id = next((nid for nid, n in wf.items() if n.get("class_type") == "SaveImage"), None) + if vaedecode_id is None or save_id is None: + raise ValueError( + "comfyui_build_projectile_workflow: no se encontro VAEDecode/SaveImage para Rembg" + ) + numeric = [int(k) for k in wf.keys() if str(k).isdigit()] + rembg_id = str((max(numeric) + 1) if numeric else len(wf) + 1) + wf[rembg_id] = { + "class_type": "Image Rembg (Remove Background)", + "inputs": { + "images": [vaedecode_id, 0], + "transparency": True, + "model": model, + "post_processing": False, + "only_mask": False, + "alpha_matting": False, + "alpha_matting_foreground_threshold": 240, + "alpha_matting_background_threshold": 10, + "alpha_matting_erode_size": 10, + "background_color": "none", + }, + } + wf[save_id]["inputs"]["images"] = [rembg_id, 0] + return wf + + +def comfyui_build_projectile_workflow( + projectile: str, + *, + direction: str = "right", + glow: bool = False, + style: str = "game projectile, side view", + checkpoint: str = "dreamshaper_8.safetensors", + size: int = 512, + transparent: bool = True, + seed: int = 0, + lora: str | None = None, + lora_strength: float = 1.0, + rembg_model: str = "u2net", + negative: str | None = None, + steps: int = 28, + cfg: float = 7.0, + sampler_name: str = "dpmpp_2m", + scheduler: str = "karras", + filename_prefix: str = "projectile", +) -> dict: + """Construye el dict (API format) del workflow de UN proyectil orientado. + + Args: + projectile: nombre del proyectil (ej. "arrow", "bullet", "fireball", + "lightning bolt", "missile", "magic spell"). Se inserta en un prompt + scaffold de proyectil. No puede estar vacio. + direction: orientacion hacia la que apunta el proyectil. Tipico "right" + (apuntando a la derecha, angulo 0 — el convenio del motor que luego + rota el sprite), "left", "up", "down" (tambien diagonales como + "up-right" si el motor las usa). None/"" = sin orientacion explicita. + keyword-only. + glow: si True el proyectil es brillante/magico (bola de fuego, rayo, + hechizo): el scaffold pide "glowing, on black background" y NO se + inyecta Rembg (recortaria el halo translucido). La imagen queda sobre + fondo negro como INSUMO de comfyui_matting_luma_to_alpha + (luminancia-como-alpha), que el caller aplica luego en un pipeline. + glow=True ignora `transparent`/`rembg_model`. Si False (defecto) el + proyectil es solido y va por Rembg. keyword-only. + style: descriptor de estilo que mantiene consistentes los proyectiles de un + set (ej. "game projectile, side view", "hand-painted RPG projectile", + "pixel art bullet"). Pasa el MISMO style + checkpoint + (lora) a todos + los proyectiles del set para coherencia visual. keyword-only. + checkpoint: checkpoint del servidor. 'dreamshaper_8.safetensors' (SD1.5, + holgado en 8GB lowvram) por defecto; 'juggernaut_xl_v11.safetensors' + para SDXL (mas VRAM, subir size a 768/1024). keyword-only. + size: lado del cuadrado en px (width = height = size). 512 SD1.5 por + defecto. keyword-only. + transparent: si True (y glow=False) inyecta Rembg y el PNG sale con alpha + (silueta recortada). Si False deja el proyectil opaco sobre fondo plano, + recortable luego por el caller/pipeline. Ignorado cuando glow=True. + keyword-only. + seed: semilla del KSampler. keyword-only. + lora: LoRA de estilo opcional en models/loras (ej. + 'detail_tweaker_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. + rembg_model: modelo Rembg ('u2net' general, 'isnet-anime' para anime). Solo + se usa si transparent=True y glow=False. keyword-only. + negative: prompt negativo. None usa el negativo por defecto pensado para + proyectiles (un objeto aislado, sin escenario/personaje, sin texto/foto). + keyword-only. + steps, cfg, sampler_name, scheduler, filename_prefix: parametros de + generacion. keyword-only. + + Returns: + dict en API format listo para comfyui_submit_workflow: txt2img base cuadrada + con prompt scaffold de proyectil ('a {projectile}, {style}, pointing + {direction}, isolated, ...') + LoRA de estilo opcional. Si glow=False y + transparent, ademas Image Rembg para alpha. Si glow=True, la imagen sale + sobre fondo negro (insumo de comfyui_matting_luma_to_alpha). Es UN proyectil; + un set -> llamar por proyectil con el mismo style/checkpoint/lora. + + Raises: + ValueError: si projectile esta vacio, o si la base no tiene VAEDecode/SaveImage + donde inyectar el Rembg (propagado por el helper). + """ + from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow + + if not projectile or not projectile.strip(): + raise ValueError( + "comfyui_build_projectile_workflow: 'projectile' no puede estar vacio" + ) + + projectile = projectile.strip() + direction = (direction or "").strip() + lora_strength = max(0.0, min(2.0, float(lora_strength))) + neg = _PROJECTILE_NEGATIVE if negative is None else negative + + # Fondo segun la naturaleza: brillante sobre negro (luma->alpha) vs solido sobre + # fondo plano (recorte por silueta). + background = "glowing, on black background" if glow else "plain background" + pointing = f"pointing {direction}, " if direction else "" + positive = ( + f"a {projectile}, {style}, {pointing}isolated, single object, " + f"{background}, game asset, dynamic, high detail" + ) + + 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 + ) + + # Solo el proyectil solido va por Rembg. El brillante va por luma->alpha (caller). + if transparent and not glow: + wf = _inject_rembg(wf, rembg_model) + + return wf + + +if __name__ == "__main__": + import json + + wf = comfyui_build_projectile_workflow( + "fireball", + direction="right", + glow=True, + style="game projectile, side view", + seed=42, + ) + 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_projectile_workflow_test.py b/python/functions/ml/comfyui_build_projectile_workflow_test.py new file mode 100644 index 00000000..7f7d711a --- /dev/null +++ b/python/functions/ml/comfyui_build_projectile_workflow_test.py @@ -0,0 +1,156 @@ +"""Tests offline de comfyui_build_projectile_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_projectile_workflow import ( # noqa: E402 + comfyui_build_projectile_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 _id_of(wf, cls): + return next(nid for nid, n in wf.items() if n["class_type"] == cls) + + +def _pos_with(wf, needle): + return next( + n for n in wf.values() + if n["class_type"] == "CLIPTextEncode" and needle in n["inputs"]["text"] + ) + + +def test_golden_solid_transparent_recipe(): + # Proyectil solido (glow=False) + transparent -> cadena base txt2img + Rembg. + wf = comfyui_build_projectile_workflow( + "arrow", direction="right", glow=False, transparent=True, seed=5 + ) + cls = _classes(wf) + assert "CheckpointLoaderSimple" in cls + assert "KSampler" in cls + assert "VAEDecode" in cls + assert "SaveImage" in cls + assert "Image Rembg (Remove Background)" in cls + # El proyectil + estilo + orientacion aparecen en el prompt positivo. + pos = _pos_with(wf, "arrow") + txt = pos["inputs"]["text"] + assert "game projectile, side view" in txt + assert "pointing right" in txt + assert "isolated" in txt + assert "plain background" in txt + assert "game asset" in txt + # SaveImage toma la imagen del Rembg (no del VAEDecode). + rembg_id = _id_of(wf, "Image Rembg (Remove Background)") + save = next(n for n in wf.values() if n["class_type"] == "SaveImage") + assert save["inputs"]["images"] == [rembg_id, 0] + assert _by_class(wf, "Image Rembg (Remove Background)")[0]["inputs"]["transparency"] is True + + +def test_golden_glow_on_black_no_rembg(): + # glow=True -> "glowing, on black background" + SIN Rembg (insumo luma->alpha). + wf = comfyui_build_projectile_workflow( + "fireball", direction="right", glow=True, seed=42 + ) + cls = _classes(wf) + assert "Image Rembg (Remove Background)" not in cls + pos = _pos_with(wf, "fireball") + txt = pos["inputs"]["text"] + assert "glowing, on black background" in txt + assert "plain background" not in txt + # SaveImage toma del VAEDecode directamente (no hay Rembg). + vd_id = _id_of(wf, "VAEDecode") + save = next(n for n in wf.values() if n["class_type"] == "SaveImage") + assert save["inputs"]["images"] == [vd_id, 0] + + +def test_glow_ignores_transparent(): + # Aunque transparent=True, glow=True NO inyecta Rembg. + wf = comfyui_build_projectile_workflow( + "lightning bolt", glow=True, transparent=True + ) + assert "Image Rembg (Remove Background)" not in _classes(wf) + + +def test_edge_direction_reflected(): + for d in ["left", "up", "down", "up-right"]: + wf = comfyui_build_projectile_workflow("bullet", direction=d, glow=False) + pos = _pos_with(wf, "bullet") + assert f"pointing {d}" in pos["inputs"]["text"] + + +def test_edge_direction_optional(): + # Sin orientacion, el prompt no inserta "pointing". + wf = comfyui_build_projectile_workflow("missile", direction="", glow=False, transparent=False) + pos = _pos_with(wf, "missile") + assert "pointing" not in pos["inputs"]["text"] + + +def test_edge_solid_opaque_no_rembg(): + # glow=False + transparent=False -> sin Rembg, SaveImage del VAEDecode. + wf = comfyui_build_projectile_workflow("bullet", glow=False, transparent=False) + assert "Image Rembg (Remove Background)" not in _classes(wf) + vd_id = _id_of(wf, "VAEDecode") + save = next(n for n in wf.values() if n["class_type"] == "SaveImage") + assert save["inputs"]["images"] == [vd_id, 0] + + +def test_edge_size_reflected(): + wf = comfyui_build_projectile_workflow("arrow", size=768) + latent = _by_class(wf, "EmptyLatentImage")[0]["inputs"] + assert latent["width"] == 768 + assert latent["height"] == 768 # cuadrado + + +def test_edge_style_reflected(): + wf = comfyui_build_projectile_workflow( + "fireball", style="pixel art bullet", glow=True + ) + pos = _pos_with(wf, "fireball") + assert "pixel art bullet" in pos["inputs"]["text"] + + +def test_edge_lora_reflected(): + wf = comfyui_build_projectile_workflow( + "arrow", lora="detail_tweaker_sd15.safetensors", lora_strength=0.9 + ) + loras = _by_class(wf, "LoraLoader") + assert len(loras) == 1 + assert loras[0]["inputs"]["lora_name"] == "detail_tweaker_sd15.safetensors" + assert loras[0]["inputs"]["strength_model"] == 0.9 + + +def test_default_negative_allows_black_background(): + # El negativo por defecto NO debe rechazar "black background" (glow lo necesita). + wf = comfyui_build_projectile_workflow("fireball", glow=True) + neg = next( + n["inputs"]["text"] + for n in wf.values() + if n["class_type"] == "CLIPTextEncode" and "watermark" in n["inputs"]["text"] + ) + assert "black background" not in neg + + +def test_error_empty_projectile(): + try: + comfyui_build_projectile_workflow(" ") + assert False + except ValueError as e: + assert "projectile" in str(e) + + +def test_determinism(): + a = comfyui_build_projectile_workflow( + "fireball", direction="right", glow=True, lora="detail_tweaker_sd15.safetensors", seed=7 + ) + b = comfyui_build_projectile_workflow( + "fireball", direction="right", glow=True, lora="detail_tweaker_sd15.safetensors", seed=7 + ) + assert a == b