diff --git a/docs/capabilities/gamedev-2d.md b/docs/capabilities/gamedev-2d.md index 643702f1..eff1e83d 100644 --- a/docs/capabilities/gamedev-2d.md +++ b/docs/capabilities/gamedev-2d.md @@ -150,7 +150,7 @@ firmas. Extensible: añadir un estilo = una entrada en `_PRESETS`. | ID | Firma corta | Qué hace | |---|---|---| -| `comfyui_get_gamedev_style_preset_py_ml` | `(name=None) -> dict` | Devuelve la receta de un STYLE PRESET curado o el catálogo si `name=None`. Receta = `{subject_prefix, subject_suffix, style, negative, checkpoint, lora, lora_strength, size, transparent, post, notes}`. Pura, copias profundas. Estilos iniciales: **gameboy** (sin LoRA → prompt + post `pixelize` paleta `game-boy` 4 tonos verde), **ghibli** (degrada a `watercolor_style_sd15` gratis instalado + prompt; no hay LoRA Ghibli dedicado ni se descargó nada gated), **pixel-art-retro** (reutiliza `pixel-art-xl` SDXL ya instalado → checkpoint `juggernaut_xl_v11` + size 768 + post `pixelize` 16 colores). | +| `comfyui_get_gamedev_style_preset_py_ml` | `(name=None) -> dict` | Devuelve la receta de un STYLE PRESET curado o el catálogo si `name=None`. Receta = `{subject_prefix, subject_suffix, style, negative, checkpoint, lora, lora_strength, size, transparent, post, notes}`. Pura, copias profundas. **6 estilos**: **gameboy** (sin LoRA → prompt + post `pixelize` paleta `game-boy` 4 tonos verde), **ghibli** (degrada a `watercolor_style_sd15` gratis instalado + prompt; no hay LoRA Ghibli dedicado ni se descargó nada gated), **pixel-art-retro** (reutiliza `pixel-art-xl` SDXL ya instalado → checkpoint `juggernaut_xl_v11` + size 768 + post `pixelize` 16 colores), **cyberpunk-neon** (prompt puro SD1.5, glow magenta/cyan, sin post), **low-poly-flat** (prompt puro SD1.5, facetas/flat shading PS1, sin post, transparent), **cartoon-cel-shaded** (LoRA `anime_style_box_sd15` 0.7 + prompt cel-shaded, sin post, transparent). Extensible: añadir un estilo = una entrada en `_PRESETS`. | | `comfyui_apply_style_preset_py_ml` | `(preset, subject, *, style=None, negative=None) -> dict` | Traduce un preset + un `subject` a `{name, subject (con prefijo/sufijo), builder_kwargs={style,checkpoint,lora,lora_strength,negative}, size, transparent, post}`. Los `builder_kwargs` hacen `**spread` directo en cualquier builder de sujeto; `size`/`transparent` van aparte (recomendaciones); el caller aplica `post["pixelize"]` al PNG si existe. Pura, no muta el preset; `negative` se mergea (no reemplaza). | **Ejemplo canónico (mismo subject, look del juego entero):** @@ -181,9 +181,11 @@ if ap["post"].get("pixelize"): # gameboy/pixel-re Validado e2e en GPU con el MISMO `knight character` en los 3 estilos (`reports/0190`): gameboy 4 colores verde (`prompt_id 0657e3e3`), ghibli 78 552 colores acuarela (`42f2f492`), pixel-art-retro SDXL 768 16 colores (`84b08581`) — tres looks -visiblemente distintos y coherentes. **Gotcha**: el `post` no se aplica solo (el caller -llama `comfyui_pixelize_image`); el LoRA y el checkpoint deben casar de base (pixel-art-xl -es SDXL → exige juggernaut); OOM en 8 GB → bajar `size`, NO matar procesos. +visiblemente distintos y coherentes. **Gotcha**: en el flujo manual de arriba el `post` no +se aplica solo (el caller llama `comfyui_pixelize_image`) — para evitarlo usa el pipeline +one-shot `comfyui_generate_styled_asset_oneshot` (abajo), que auto-aplica el post. El LoRA y +el checkpoint deben casar de base (pixel-art-xl es SDXL → exige juggernaut); OOM en 8 GB → +bajar `size`, NO matar procesos. ## Pipelines one-shot (`gamedev-2d`, impuros) @@ -191,6 +193,7 @@ es SDXL → exige juggernaut); OOM en 8 GB → bajar `size`, NO matar procesos. |---|---|---| | `comfyui_generate_asset_pack_oneshot_py_pipelines` | `(pack, *, checkpoint="dreamshaper_8…", style="", lora=None, base_seed=0, size=None, server="127.0.0.1:8188", export_godot=None, out_dir=None, …) -> dict` | **Set COHERENTE de assets 2D de un mismo juego de un solo tiro**: `pack=[{"kind","subject"}, …]` → despacha cada `kind` a su builder atómico (26 kinds: item_icon, enemy_creature, prop_object, seamless_tile, ui_hud, particle_texture, …) compartiendo el MISMO `checkpoint`/`lora` + `style` común inyectado al `subject` + `seed = base_seed + i`, encola (`submit`) + espera (`wait`) + descarga (`fetch`) cada uno, y (si `export_godot`) los exporta a Godot. Promoción a pipeline del patrón "N builders con el mismo estilo" (issue 0087). Fail-fast si `kind` desconocido; un OOM aislado no aborta el resto. Probado e2e en GPU SD1.5 512: `magic sword`(item_icon, seed 42) + `goblin warrior`(enemy_creature, seed 43), `style="dark fantasy, hand-painted"` → 2/2 PNG 512×512 RGBA coherentes (`prompt_id f7cfda43` + `11d1d031`, `reports/0179`). Impuro: HTTP + disco + (export) subprocess. | | `comfyui_generate_character_set_oneshot_py_pipelines` | `(character, *, style="game character, full body, clean background", checkpoint="dreamshaper_8…", base_kind="enemy_creature", directions=8, make_directional=True, make_3d=True, directional_model="sv3d", elevation=15.0, seed=0, size=512, directional_size=None, flatten_color=(255,255,255), variant_3d="mini", lora=None, server="127.0.0.1:8188", export_godot=None, out_dir=None, free_vram=True, …) -> dict` | **Set COMPLETO y COHERENTE de UN personaje de un solo tiro** (culminación cross-frontera del grupo): genera del MISMO personaje (1) imagen **base 2D** recortada a alpha, (2) **sprite direccional N-way** (vistas 3D consistentes SV3D/Zero123) y (3) **malla 3D `.glb`** (Hunyuan3D-2). La CLAVE es la coherencia: el direccional y el 3D parten de la **MISMA base 2D aplanada** (`base_flat`), no de tres generaciones independientes → mismo personaje en las tres representaciones, no tres personajes distintos. Compone un builder de personaje (`enemy_creature`/`portrait_avatar`/`topdown_sprite`, elegido por introspección) + `comfyui_flatten_alpha_on_color` (aplana la base recortada sobre blanco — los modelos 3D y `LoadImage` hacen `convert("RGB")` y tiran el alpha) + `comfyui_image_to_3d_oneshot` + `comfyui_build_directional_sprite_workflow` + `submit`/`wait`/`fetch` + `comfyui_export_asset_to_godot`. **Secuencial liberando VRAM** (`POST /free`) entre los pasos pesados, el 3D ANTES del direccional (SV3D es el de mayor pico, ~7.1 GB), para caber en 8 GB. Un fallo aislado (p.ej. OOM en el 3D) NO aborta el resto: deja el set PARCIAL. Promoción a pipeline (issue 0087) de la secuencia que hoy exige 4 llamadas a mano. Probado e2e en GPU — ver `reports/0188`. Impuro: HTTP + disco + (export) subprocess. | +| `comfyui_generate_styled_asset_oneshot_py_pipelines` | `(kind, subject, style_preset, *, seed=0, server="127.0.0.1:8188", out_dir=None, export_godot=None, style_override=None, negative_extra=None, free_vram=False, **builder_extra) -> dict` | **Aplica un ESTILO curado a UN asset de un solo tiro, con AUTO-POST**: `comfyui_get_gamedev_style_preset(style_preset)` → `comfyui_apply_style_preset` → despacha `kind` a su builder (REUTILIZA el dispatch `_SUPPORTED` del pack, mismos 26 kinds) → `submit`/`wait`/`fetch` → **auto-aplica el `post` del preset** (`comfyui_pixelize_image` si el estilo lo pide) → export opcional a Godot (como `pixelart` si hubo pixelize → fija el filtro Nearest). Cierra el hueco #1 de los style presets (report 0190): los estilos pixelart (gameboy, pixel-art-retro) salen ya pixelizados del pipeline, **sin llamar a `comfyui_pixelize_image` a mano**. Devuelve `path` (FINAL post-procesado) y `raw_path` (crudo); `path==raw_path` si el estilo no pide post. Kind/estilo desconocido → `ok=False` sin tocar la GPU (validación pura; parte pura aislada en `styled_asset_build_only`). Probado e2e en GPU: mismo `treasure chest`(prop_object) en cyberpunk-neon (`prompt_id 02473baa`), low-poly-flat (`7a186053`) y gameboy (`46b396e2`, crudo 17374 colores → final **4 colores** Game Boy, auto-pixelizado) — ver `reports/0191`. Impuro: HTTP + disco + (export) subprocess. | ## Ejemplo end-to-end con builder (Fase 1 GPU → Fase 2 CPU → Godot) diff --git a/python/functions/ml/comfyui_get_gamedev_style_preset.py b/python/functions/ml/comfyui_get_gamedev_style_preset.py index 3fd4dccf..c3e87417 100644 --- a/python/functions/ml/comfyui_get_gamedev_style_preset.py +++ b/python/functions/ml/comfyui_get_gamedev_style_preset.py @@ -134,6 +134,79 @@ _PRESETS: dict[str, dict] = { "8GB -> bajar size a 512 (NO matar procesos). El LoRA da el estilo; el post el grid." ), }, + # Cyberpunk neón: ciudad nocturna, luces de neón, glow, alto contraste, atmósfera + # Blade Runner. No hay un LoRA cyberpunk gratis instalado en el servidor; el look se + # logra con PROMPT puro sobre SD1.5 (dreamshaper_8 lo cubre bien) — es un estilo muy + # dirigible por prompt. Sin post (es ilustración con gradientes/glow, NO pixelart; + # pixelizar mataría el brillo). transparent=False para conservar el fondo neón. + "cyberpunk-neon": { + "name": "cyberpunk-neon", + "subject_prefix": "", + "subject_suffix": ", neon-lit, glowing edges, high contrast, futuristic", + "style": "cyberpunk neon art, blade runner aesthetic, glowing neon lights, dark city night, vibrant magenta and cyan glow, rim lighting, reflective surfaces, atmospheric haze, detailed digital painting", + "negative": "daylight, pastel, flat lighting, washed out, low contrast, pixel art, lowres, blurry, deformed, text, watermark, signature", + "checkpoint": "dreamshaper_8.safetensors", + "lora": None, + "lora_strength": 1.0, + "size": 512, + "transparent": False, + "post": {}, + "notes": ( + "Sin LoRA: no hay un LoRA cyberpunk gratis instalado y no se descargo ninguno " + "gated/de pago. El neón lo da el prompt (glow magenta/cyan, rim lighting, ciudad " + "nocturna) sobre dreamshaper_8 (SD1.5), que rinde bien en este registro. Sin post: " + "el brillo y los gradientes son la identidad del estilo; pixelizar los destruiria. " + "transparent=False para conservar el ambiente neón del fondo." + ), + }, + # Low-poly flat: estética PS1/PSX y arte 3D minimalista — facetas geométricas, flat + # shading sin gradientes suaves, pocos polígonos, colores planos. PROMPT puro sobre + # SD1.5: el LoRA 3d_render_redmond empuja a render fotorrealista (lo contrario de + # low-poly), asi que se evita a propósito. Sin post (es flat shading limpio, no grid). + "low-poly-flat": { + "name": "low-poly-flat", + "subject_prefix": "", + "subject_suffix": ", low poly, faceted, flat shaded, geometric", + "style": "low poly 3d art, flat shading, faceted geometry, minimal polygons, PS1 PSX aesthetic, clean solid colors, isometric game asset, no gradients, crisp facets", + "negative": "photorealistic, smooth shading, soft gradient, high detail, realistic texture, blurry, noisy, pixel art, deformed, text, watermark, signature", + "checkpoint": "dreamshaper_8.safetensors", + "lora": None, + "lora_strength": 1.0, + "size": 512, + "transparent": True, + "post": {}, + "notes": ( + "Sin LoRA a propósito: 3d_render_redmond_sd15 (instalado) empuja a render " + "fotorrealista, lo OPUESTO a low-poly. El look faceteado/flat lo da el prompt " + "(low poly, faceted, flat shading, PS1) sobre dreamshaper_8 (SD1.5). Sin post: el " + "flat shading es limpio de por sí, no necesita pixelize. transparent=True porque un " + "asset low-poly suele ir recortado sobre el juego (silueta sólida bien definida)." + ), + }, + # Cartoon cel-shaded: dibujos animados / anime con sombreado plano por celdas, líneas + # negras gruesas, colores saturados y planos (look toon/Borderlands/Zelda Wind Waker). + # Usa anime_style_box_sd15 (LoRA gratis ya instalado) a fuerza media + prompt cel-shaded. + # Sin post (es ilustración vectorial limpia, no pixelart). + "cartoon-cel-shaded": { + "name": "cartoon-cel-shaded", + "subject_prefix": "", + "subject_suffix": ", cel shaded, bold outlines, flat colors, cartoon", + "style": "cartoon cel-shaded art, bold black outlines, flat color fills, hard cel shadows, vibrant saturated colors, clean vector look, anime toon shading, comic style", + "negative": "photorealistic, soft shading, gradient, realistic texture, painterly, blurry, noisy, pixel art, lowres, grainy, deformed, text, watermark, signature", + "checkpoint": "dreamshaper_8.safetensors", + "lora": "anime_style_box_sd15.safetensors", + "lora_strength": 0.7, + "size": 512, + "transparent": True, + "post": {}, + "notes": ( + "anime_style_box_sd15.safetensors (gratis, ya instalado en /mnt/2tb) a strength 0.7 " + "empuja el toon/anime; el prompt sella el cel-shading (outlines negros gruesos, " + "sombras duras por celdas, colores planos saturados). SD1.5 (dreamshaper_8). Sin " + "post: el look vectorial limpio no necesita pixelize. transparent=True para recortar " + "la silueta del personaje/objeto cartoon sobre el juego." + ), + }, } @@ -141,7 +214,8 @@ def comfyui_get_gamedev_style_preset(name: str | None = None) -> dict: """Devuelve la receta de un style preset gamedev, o el catalogo si name es None. Args: - name: identificador del estilo ("gameboy", "ghibli", "pixel-art-retro"). Si es + name: identificador del estilo ("gameboy", "ghibli", "pixel-art-retro", + "cyberpunk-neon", "low-poly-flat", "cartoon-cel-shaded"). Si es None (o cadena vacia), devuelve el catalogo de nombres disponibles en vez de una receta concreta (discovery). Insensible a mayusculas y a '_' vs '-'. diff --git a/python/functions/ml/comfyui_get_gamedev_style_preset_test.py b/python/functions/ml/comfyui_get_gamedev_style_preset_test.py index f29f5442..1499b3b4 100644 --- a/python/functions/ml/comfyui_get_gamedev_style_preset_test.py +++ b/python/functions/ml/comfyui_get_gamedev_style_preset_test.py @@ -65,8 +65,12 @@ def test_golden_ghibli_degrades_to_watercolor_lora(): def test_edge_catalog_when_none(): cat = comfyui_get_gamedev_style_preset(None) - assert set(cat["names"]) == {"gameboy", "ghibli", "pixel-art-retro"} - assert cat["count"] == 3 + # Los 3 originales + los 3 ampliados (2026-06-27); el catalogo crece, asi que se + # comprueba inclusion y conteo minimo, no igualdad exacta (evita romper al ampliar). + assert {"gameboy", "ghibli", "pixel-art-retro"} <= set(cat["names"]) + assert {"cyberpunk-neon", "low-poly-flat", "cartoon-cel-shaded"} <= set(cat["names"]) + assert cat["count"] >= 6 + assert cat["count"] == len(cat["names"]) # Cadena vacia tambien devuelve catalogo (discovery). assert comfyui_get_gamedev_style_preset("") == cat diff --git a/python/functions/pipelines/comfyui_generate_styled_asset_oneshot.md b/python/functions/pipelines/comfyui_generate_styled_asset_oneshot.md new file mode 100644 index 00000000..3b0b578b --- /dev/null +++ b/python/functions/pipelines/comfyui_generate_styled_asset_oneshot.md @@ -0,0 +1,147 @@ +--- +name: comfyui_generate_styled_asset_oneshot +kind: pipeline +lang: py +domain: pipelines +version: "1.0.0" +purity: impure +signature: "def comfyui_generate_styled_asset_oneshot(kind: str, subject: str, style_preset: str, *, seed: int = 0, server: str = \"127.0.0.1:8188\", out_dir: str | None = None, export_godot: str | None = None, style_override: str | None = None, negative_extra: str | None = None, wait_timeout: float = 600.0, free_vram: bool = False, godot_bin: str | None = None, **builder_extra) -> dict" +description: "One-shot del grupo gamedev-2d que aplica un ESTILO curado a un asset en UNA llamada y AUTO-APLICA su post-proceso. Cierra el ultimo hueco manual del sistema de style presets (report 0190): antes habia que encadenar a mano get_gamedev_style_preset -> apply_style_preset -> despachar al builder del kind -> generar -> y llamar a mano comfyui_pixelize_image. Aqui se hace de un tiro y, para los estilos que declaran post (gameboy, pixel-art-retro), el PNG sale YA pixelizado sin paso manual. Flujo: comfyui_get_gamedev_style_preset(style_preset) -> comfyui_apply_style_preset(preset, subject) -> despacho kind->builder REUTILIZANDO el dispatch _SUPPORTED de comfyui_generate_asset_pack_oneshot -> submit/wait/fetch -> auto-post (comfyui_pixelize_image si el preset lo pide) -> export opcional a Godot (como pixelart si hubo pixelize, para fijar el filtro Nearest). Doctrina issue 0087: el registry crece promoviendo a un pipeline one-shot la composicion repetida 'elegir estilo -> aplicarlo a un kind -> generar -> post-procesar', no inflando el helper de presets ni los builders. Un kind/estilo desconocido devuelve ok=False con error claro SIN tocar la GPU. Devuelve {ok, kind, subject, style_preset, seed, prompt_id, raw_path, path, post, post_applied, size, transparent, godot_kind, exported, coherence_note, error}. Impuro: HTTP a ComfyUI + disco + (export) subprocess." +tags: [gamedev-2d, pipelines, comfyui, ml] +uses_functions: [comfyui_get_gamedev_style_preset_py_ml, comfyui_apply_style_preset_py_ml, comfyui_pixelize_image_py_ml, comfyui_submit_workflow_py_ml, comfyui_wait_result_py_ml, comfyui_fetch_output_image_py_ml, comfyui_generate_asset_pack_oneshot_py_pipelines, comfyui_export_asset_to_godot_py_pipelines] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_py_core" +imports: [comfyui_get_gamedev_style_preset_py_ml, comfyui_apply_style_preset_py_ml, comfyui_pixelize_image_py_ml, comfyui_submit_workflow_py_ml, comfyui_wait_result_py_ml, comfyui_fetch_output_image_py_ml, comfyui_generate_asset_pack_oneshot_py_pipelines, comfyui_export_asset_to_godot_py_pipelines] +tested: true +test_file_path: "python/functions/pipelines/comfyui_generate_styled_asset_oneshot_test.py" +tests: [test_unknown_kind_fails_without_network, test_unknown_style_fails_without_network, test_empty_subject_fails_without_network, test_build_only_preset_drives_workflow, test_build_only_lora_preset_injects_lora, test_catalog_has_at_least_5_styles, test_every_style_builds_a_valid_workflow, test_post_auto_applied_for_gameboy, test_no_post_keeps_raw_for_cyberpunk, test_post_failure_keeps_raw_and_flags_error, test_export_godot_uses_pixelart_when_post] +file_path: "python/functions/pipelines/comfyui_generate_styled_asset_oneshot.py" +params: + - name: kind + desc: "tipo de asset a generar; uno de supported_kinds() del grupo gamedev-2d (item_icon, enemy_creature, prop_object, seamless_tile, ui_hud, particle_texture, ... 26 kinds). Un kind desconocido devuelve ok=False con error claro SIN tocar la GPU." + - name: subject + desc: "que dibujar ('knight character', 'health potion', 'stone wall tile'). Se combina con el prefijo/sufijo del estilo. Vacio -> error sin red. Es el primer posicional." + - name: style_preset + desc: "nombre del estilo curado a aplicar, del catalogo de comfyui_get_gamedev_style_preset (gameboy, ghibli, pixel-art-retro, cyberpunk-neon, low-poly-flat, cartoon-cel-shaded). Insensible a mayusculas y a '_' vs '-'. Un estilo desconocido devuelve ok=False con error claro (+ lista de disponibles) SIN tocar la GPU." + - name: seed + desc: "semilla del KSampler para reproducibilidad. keyword-only." + - name: server + desc: "host:port del servidor ComfyUI; se acepta con o sin esquema (http://). keyword-only." + - name: out_dir + desc: "directorio local donde descargar el PNG crudo y el post-procesado; None = un dir temporal por (estilo, seed) en tempdir. keyword-only." + - name: export_godot + desc: "ruta de un proyecto Godot 4; si se da, el asset FINAL (ya post-procesado) se exporta a res://assets/...; si hubo pixelize se exporta como 'pixelart' (fija el filtro Nearest global). None = no exportar. keyword-only." + - name: style_override + desc: "sustituye el descriptor 'style' del preset (override puntual); None usa el del preset. keyword-only." + - name: negative_extra + desc: "negativo extra del caller; se MERGEA con el negativo del estilo (no lo reemplaza). keyword-only." + - name: wait_timeout + desc: "segundos maximos esperando el trabajo en ComfyUI. keyword-only." + - name: free_vram + desc: "si True hace POST /free ANTES de generar para liberar VRAM de tareas previas (util en 8 GB con modelos ya cargados). keyword-only." + - name: godot_bin + desc: "binario de Godot para el reimport headless; None autodetecta. keyword-only." + - name: builder_extra + desc: "kwargs extra reenviados al builder del kind: posicionales requeridos por algunos builders (p.ej. expression='angry' en emote) o passthrough que el builder admita (steps, cfg, width, height, ...). Sobreescriben la coherencia del estilo si se pasan." +output: "dict con ok (bool, True si el asset se genero y, si el estilo pedia post, el post se aplico), kind/subject/style_preset/seed (eco; subject es el combinado con el estilo), prompt_id (str, id del trabajo en ComfyUI), raw_path (PNG descargado SIN post), path (PNG FINAL entregable: post-procesado si el estilo lo pedia, si no igual a raw_path), post (spec de post del estilo, {} si no pedia), post_applied ({kind, out_path, n_colors_final, size} en exito | {kind, ok:False, error} si fallo | None si el estilo no pedia post), size (int) y transparent (bool) recomendados por el estilo, godot_kind (bucket Godot usado), exported (resultado de export a Godot o None), coherence_note (str), error (str). Un fallo de post o export se anota en error pero el crudo queda en raw_path." +--- + +Pipeline que aplica un estilo CURADO (gameboy, ghibli, pixel-art-retro, cyberpunk-neon, +low-poly-flat, cartoon-cel-shaded, ...) a un asset de juego de un solo tiro y, lo +importante, **auto-ejecuta el post-proceso que el estilo declara**: los estilos pixelart +(gameboy, pixel-art-retro) salen ya pixelizados del pipeline, sin tener que llamar a +`comfyui_pixelize_image` a mano. Es la pieza que cerraba el sistema de style presets del +grupo `gamedev-2d` (report 0190). + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions")) +from pipelines.comfyui_generate_styled_asset_oneshot import comfyui_generate_styled_asset_oneshot + +# Estilo CON post: el icono sale YA pixelizado (paleta Game Boy de 4 tonos), sin paso manual +res = comfyui_generate_styled_asset_oneshot( + "item_icon", "health potion", "gameboy", seed=7, out_dir="/tmp/styled_gameboy", +) +# res["raw_path"] -> PNG crudo de la difusion +# res["path"] -> PNG FINAL ya pixelizado (auto-post) — distinto del crudo +# res["post_applied"] -> {"kind":"pixelize","n_colors_final":4, ...} + +# Estilo SIN post: el mismo subject en estilo neón (path == raw_path, no se pixeliza) +res = comfyui_generate_styled_asset_oneshot( + "enemy_creature", "street samurai", "cyberpunk-neon", seed=5, out_dir="/tmp/styled_cyber", +) + +# Con export directo a un proyecto Godot 4 (pixelart -> Nearest filter global automatico) +res = comfyui_generate_styled_asset_oneshot( + "prop_object", "treasure chest", "pixel-art-retro", seed=3, + export_godot=os.path.expanduser("~/gamedev/projects/dungeon"), +) + +# Kind que exige un posicional extra (emote.expression) -> pasarlo como kwarg +res = comfyui_generate_styled_asset_oneshot( + "emote", "hero", "cartoon-cel-shaded", expression="angry", seed=1, +) +``` + +Lanzable tambien por `fn run` (despacha como pipeline Python; corre el `__main__` de demo): + +```bash +./fn run comfyui_generate_styled_asset_oneshot # demo: item_icon "health potion" estilo gameboy +``` + +## Cuando usarla + +Usala cuando quieras UN asset concreto con un look consistente de un catalogo de estilos +ya curados, sin acordarte del checkpoint/LoRA/negativo correctos ni del post-proceso que +ese estilo necesita. Es el reemplazo one-shot de "buscar el preset -> aplicarlo al builder +-> generar -> pixelizar a mano": una sola llamada con `(kind, subject, style_preset)`. Para +un SET de assets variados que comparten un estilo libre (no de catalogo), usa +`comfyui_generate_asset_pack_oneshot`. Para las tres representaciones (2D + direccional + +3D) de UN personaje, usa `comfyui_generate_character_set_oneshot`. Para añadir un estilo +nuevo al catalogo, edita `_PRESETS` en `comfyui_get_gamedev_style_preset` (datos puros) — +este pipeline lo recoge sin tocarse. + +## Gotchas + +- **Auto-post = la razon de existir.** Si el estilo declara `post` (gameboy, pixel-art-retro + traen `{"pixelize": {...}}`), el pipeline lo ejecuta sobre el PNG descargado y devuelve la + ruta post-procesada en `path` (el crudo queda en `raw_path`). Para estilos sin post + (ghibli, cyberpunk-neon, low-poly-flat, cartoon-cel-shaded) `path == raw_path`. +- **post + transparent son incompatibles (el post gana).** `comfyui_pixelize_image` hace + `convert("RGB")`, que descarta el alpha. Por eso los presets con post traen + `transparent=False` (asset opaco). No pidas un estilo con post sobre un kind donde + necesites alpha: el pixelize aplanaria el recorte. Los estilos sin post respetan el + `transparent` que recomienden (cyberpunk-neon False, cartoon-cel-shaded/low-poly-flat True). +- **El post elige el bucket Godot.** Si hubo pixelize, el export va como `pixelart` + (asegura `default_texture_filter=0` Nearest en project.godot); si no, usa el bucket + natural del builder (sprite/vfx/tileset). +- **Reutiliza el dispatch del pack.** El mapa `kind -> (godot_kind, builder)` NO se redefine + aqui: se importa `_SUPPORTED` de `comfyui_generate_asset_pack_oneshot`, asi que los 26 + kinds soportados son exactamente los del pack (una sola fuente de verdad). +- **Un kind o estilo desconocido NO toca la GPU.** La resolucion del preset y la + construccion del workflow (pasos 1-3) son puras y se hacen antes de encolar; un kind/estilo + invalido devuelve `ok=False` con error claro y `prompt_id == ""`. La parte pura es + invocable sola con `styled_asset_build_only(...)` para inspeccionar el grafo y el post sin + generar. +- **VRAM / OOM (RTX 3070 8 GB).** Los presets nuevos usan SD1.5 (dreamshaper_8) a 512 y caben + holgados; pixel-art-retro usa SDXL (juggernaut_xl_v11) a 768 y pica mas. Con modelos ya + cargados de otra tarea, pasa `free_vram=True` (hace `POST /free` antes de generar) o limpia + tu mismo (`POST http://127.0.0.1:8188/free {"unload_models":true,"free_memory":true}`). Si + hay OOM, baja la resolucion del preset o el `size` del builder via `builder_extra`; NO se + matan procesos. +- **Fallo aislado de post/export no borra el crudo.** Si el pixelize o el export fallan, el + PNG crudo sigue en `raw_path` y el error se anota; `ok=False` solo si el post pedido fallo. +- **`server`** se normaliza (acepta con o sin `http://`); internamente es `host:port`. + +## Capability growth log + +v1.0.0 (2026-06-27) — version inicial. Promueve a un pipeline one-shot la secuencia +"elegir un style preset curado -> aplicarlo a un kind de asset -> generar -> auto-aplicar +su post-proceso" (issue 0087). Cierra el hueco #1 del sistema de style presets (report +0190): el post-proceso (pixelize) ya no se llama a mano — los estilos pixelart salen +pixelizados directos del pipeline. Reutiliza el dispatch `_SUPPORTED` del pack one-shot +para no duplicar el mapa kind->builder. diff --git a/python/functions/pipelines/comfyui_generate_styled_asset_oneshot.py b/python/functions/pipelines/comfyui_generate_styled_asset_oneshot.py new file mode 100644 index 00000000..5f0aeb6e --- /dev/null +++ b/python/functions/pipelines/comfyui_generate_styled_asset_oneshot.py @@ -0,0 +1,412 @@ +"""comfyui_generate_styled_asset_oneshot — aplica un ESTILO a un asset de un solo tiro. + +One-shot del grupo `gamedev-2d` que cierra el último hueco manual del sistema de +*style presets* (report 0190): antes, aplicar un estilo curado (gameboy / ghibli / +pixel-art-retro / ...) a un asset exigía encadenar A MANO cuatro pasos — +`comfyui_get_gamedev_style_preset` → `comfyui_apply_style_preset` → despachar al +builder del `kind` y generar → y, lo que más se olvidaba, llamar a mano al +post-proceso (`comfyui_pixelize_image`) que el preset declaraba. Este pipeline lo +promueve a UNA sola llamada y, sobre todo, **auto-aplica el post-proceso del preset**: +si el estilo pide pixelizar (gameboy, pixel-art-retro), el PNG sale ya pixelizado del +pipeline, sin paso manual. Es la doctrina del issue 0087 aplicada al estilo: el +registry no crece inflando builders ni el helper de presets, crece promoviendo a un +pipeline one-shot la composición repetida "elegir estilo → aplicarlo a un kind → +generar → post-procesar". + +Flujo: + + 1. comfyui_get_gamedev_style_preset(style_preset) -> receta de estilo (datos puros) + 2. comfyui_apply_style_preset(preset, subject) -> subject + builder_kwargs + size + + transparent + spec de post + 3. despacho kind -> builder atómico del registry (REUTILIZA el dispatch _SUPPORTED + de comfyui_generate_asset_pack_oneshot, no se redefine el mapa kind->builder) + 4. comfyui_submit_workflow + comfyui_wait_result + comfyui_fetch_output_image + 5. AUTO-POST: si el preset declara post (p.ej. {"pixelize": {...}}), se aplica al + PNG descargado con comfyui_pixelize_image -> la ruta FINAL ya es la post-procesada + 6. export opcional a un proyecto Godot (comfyui_export_asset_to_godot); si hubo + pixelize, el asset se exporta como `pixelart` (filtro Nearest global en Godot) + +A diferencia de `comfyui_generate_asset_pack_oneshot` (que comparte UN estilo libre +concatenado al subject de N assets variados) y de `comfyui_generate_character_set_oneshot` +(que produce las tres representaciones de UN personaje), este pipeline aplica UN preset +de estilo CURADO (con su checkpoint/LoRA/negativo/post coherentes) a UN asset concreto. +Reutiliza el mismo catálogo de `kind`s del pack, así que sirve para cualquiera de los 26 +builders de sujeto del grupo. + +Pipeline impuro: red (HTTP a ComfyUI) + escritura en disco + (export) subprocess. +""" +from __future__ import annotations + +import inspect +import json +import os +import sys +import tempfile +import urllib.error +import urllib.request + +_FUNCTIONS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _FUNCTIONS_ROOT not in sys.path: + sys.path.insert(0, _FUNCTIONS_ROOT) + +# --- presets de estilo (puros) --- +from ml.comfyui_apply_style_preset import comfyui_apply_style_preset +from ml.comfyui_get_gamedev_style_preset import comfyui_get_gamedev_style_preset + +# --- post-proceso CPU (impuro: disco) --- +from ml.comfyui_pixelize_image import comfyui_pixelize_image + +# --- transporte ComfyUI (impuro) --- +from ml.comfyui_fetch_output_image import comfyui_fetch_output_image +from ml.comfyui_submit_workflow import comfyui_submit_workflow +from ml.comfyui_wait_result import comfyui_wait_result + +# --- export opcional a Godot --- +from pipelines.comfyui_export_asset_to_godot import comfyui_export_asset_to_godot + +# REUTILIZA el dispatch kind -> (godot_kind, builder) del pack one-shot en vez de +# redefinir el mapa: una sola fuente de verdad de los `kind`s soportados y de a qué +# builder atómico va cada uno. _CKPT_PARAMS y _first_image también se comparten. +from pipelines.comfyui_generate_asset_pack_oneshot import ( + _CKPT_PARAMS, + _SUPPORTED, + _first_image, + supported_kinds, +) + + +def _free_vram(server: str) -> bool: + """Pide a ComfyUI que descargue modelos y libere VRAM (POST /free). + + Best-effort: en una RTX 3070 de 8 GB con modelos ya cargados de otra tarea, liberar + antes de generar evita el OOM. No lanza: si el endpoint falla, devuelve False y el + caller sigue. + """ + try: + body = json.dumps({"unload_models": True, "free_memory": True}).encode() + req = urllib.request.Request( + f"http://{server}/free", data=body, + headers={"Content-Type": "application/json"}, method="POST", + ) + with urllib.request.urlopen(req, timeout=30) as resp: + resp.read() + return True + except (urllib.error.URLError, OSError): + return False + + +def _build_styled_workflow( + kind: str, + applied: dict, + *, + seed: int, + builder_extra: dict, +) -> tuple[dict, str]: + """Construye el workflow de UN asset con el estilo del preset ya aplicado. + + PURO (no toca la red): localiza el builder del `kind` en el dispatch compartido + `_SUPPORTED`, introspecciona su firma y le pasa SOLO los kwargs que admita, tomados + del resultado de `comfyui_apply_style_preset` (`applied`): el `subject` combinado, el + `style`/`checkpoint`/`lora`/`lora_strength`/`negative` del estilo, y la `size`/ + `transparent` recomendadas. A diferencia del pack (que concatena un `style` libre al + subject), aquí el `style` del preset SUSTITUYE el `style` categórico del builder, que + es lo que da el look del estilo curado. + + Args: + kind: tipo de asset (uno de `supported_kinds()`). + applied: dict de `comfyui_apply_style_preset` ({subject, builder_kwargs, size, + transparent, post, name}). + seed: semilla del KSampler. keyword-only. + builder_extra: kwargs extra del caller para posicionales requeridos por algunos + builders (p.ej. `expression` en `emote`) o passthrough que el builder admita. + keyword-only. + + Returns: + (workflow, godot_kind): el dict API-format y el bucket Godot del builder + (sprite/vfx/tileset) para el export. + + Raises: + ValueError: si el `kind` no está soportado o falta un posicional requerido del + builder (p.ej. `expression` en `emote`). + """ + if kind not in _SUPPORTED: + raise ValueError( + f"kind {kind!r} no soportado. Soportados: {', '.join(supported_kinds())}" + ) + godot_kind, fn = _SUPPORTED[kind] + params = inspect.signature(fn).parameters + bk = applied["builder_kwargs"] # {style, checkpoint, lora, lora_strength, negative} + + kw: dict = {} + # checkpoint -> el nombre de parámetro que use el builder (checkpoint/ckpt_name/ckpt). + for cand in _CKPT_PARAMS: + if cand in params: + kw[cand] = bk["checkpoint"] + break + if "style" in params: + kw["style"] = bk["style"] + if "negative" in params: + kw["negative"] = bk["negative"] + if bk.get("lora") and "lora" in params: + kw["lora"] = bk["lora"] + if "lora_strength" in params: + kw["lora_strength"] = bk["lora_strength"] + if "size" in params and applied.get("size") is not None: + kw["size"] = applied["size"] + if "transparent" in params: + kw["transparent"] = applied["transparent"] + if "seed" in params: + kw["seed"] = seed + + # Posicionales requeridos más allá del primero (= subject). Ej: emote.expression. + pos_extra = [ + p.name + for p in params.values() + if p.default is inspect.Parameter.empty + and p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) + ][1:] + pos_args = [applied["subject"]] + for name in pos_extra: + if name not in builder_extra: + raise ValueError( + f"kind {kind!r} requiere el campo {name!r}; pásalo como kwarg " + f"(p.ej. {name}=...)" + ) + pos_args.append(builder_extra[name]) + + # Passthrough: cualquier otro kwarg extra del caller que el builder admita + # (steps, cfg, rembg_model, width, height, glow, ...). Sobreescribe la coherencia. + used = set(pos_extra) + for key, val in builder_extra.items(): + if key in used: + continue + if key in params: + kw[key] = val + + return fn(*pos_args, **kw), godot_kind + + +def styled_asset_build_only( + kind: str, + subject: str, + style_preset: str, + *, + seed: int = 0, + style_override: str | None = None, + negative_extra: str | None = None, + **builder_extra, +) -> dict: + """Parte PURA del pipeline: resuelve el preset y construye el workflow, sin tocar la red. + + Útil para inspeccionar/testear el grafo y el post-proceso que el pipeline va a aplicar + sin generar nada. Es lo que el pipeline hace en sus pasos 1-3. + + Returns: + dict {kind, godot_kind, applied, workflow} donde `applied` es el resultado de + `comfyui_apply_style_preset` (incluye `post`, la spec de post-proceso) y `workflow` + es el dict API-format listo para `comfyui_submit_workflow`. + + Raises: + ValueError: si el `kind` o el `style_preset` no existen, o si subject está vacío, + o si falta un posicional requerido por el builder. + """ + preset = comfyui_get_gamedev_style_preset(style_preset) # ValueError si no existe + applied = comfyui_apply_style_preset( + preset, subject, style=style_override, negative=negative_extra + ) + workflow, godot_kind = _build_styled_workflow( + kind, applied, seed=seed, builder_extra=builder_extra + ) + return { + "kind": kind, "godot_kind": godot_kind, "applied": applied, "workflow": workflow, + } + + +def comfyui_generate_styled_asset_oneshot( + kind: str, + subject: str, + style_preset: str, + *, + seed: int = 0, + server: str = "127.0.0.1:8188", + out_dir: str | None = None, + export_godot: str | None = None, + style_override: str | None = None, + negative_extra: str | None = None, + wait_timeout: float = 600.0, + free_vram: bool = False, + godot_bin: str | None = None, + **builder_extra, +) -> dict: + """Genera UN asset aplicándole un estilo CURADO de un solo tiro, con auto-post-proceso. + + Toma un `kind` de asset (uno de `supported_kinds()`), un `subject` (qué dibujar) y el + nombre de un `style_preset` del catálogo de `comfyui_get_gamedev_style_preset` + ("gameboy", "ghibli", "pixel-art-retro", "cyberpunk-neon", "low-poly-flat", + "cartoon-cel-shaded", ...), y produce el PNG final ya con el estilo aplicado Y su + post-proceso ejecutado. Para los estilos que declaran post (gameboy, pixel-art-retro) + el resultado sale ya pixelizado del pipeline — sin llamar a `comfyui_pixelize_image` a + mano, que es el hueco que este one-shot cierra. + + Args: + kind: tipo de asset; uno de `supported_kinds()` (item_icon, enemy_creature, + prop_object, seamless_tile, ui_hud, ...). Un kind desconocido devuelve + ok=False con error claro SIN tocar la GPU. + subject: qué dibujar ("knight character", "health potion", "stone wall tile"). + Se combina con el prefijo/sufijo del estilo. Vacío -> error sin red. + style_preset: nombre del estilo curado a aplicar. Insensible a mayúsculas y a + '_' vs '-'. Un estilo desconocido devuelve ok=False con error claro (y la + lista de estilos disponibles) SIN tocar la GPU. + seed: semilla del KSampler para reproducibilidad. keyword-only. + server: host:port del ComfyUI; se acepta con o sin esquema (http://). keyword-only. + out_dir: directorio local donde descargar el PNG (crudo y post-procesado); None = + un dir temporal por (estilo, seed). keyword-only. + export_godot: ruta de un proyecto Godot 4; si se da, el asset FINAL (ya + post-procesado) se exporta a res://assets/...; si hubo pixelize se exporta como + `pixelart` (fija el filtro Nearest global). None = no exportar. keyword-only. + style_override: sustituye el `style` del preset (override puntual); None usa el del + preset. keyword-only. + negative_extra: negativo extra del caller; se MERGEA con el del estilo (no lo + reemplaza). keyword-only. + wait_timeout: segundos máximos esperando el trabajo en ComfyUI. keyword-only. + free_vram: si True hace POST /free ANTES de generar para liberar VRAM de tareas + previas (útil en 8 GB con modelos ya cargados). keyword-only. + godot_bin: binario de Godot para el reimport headless; None autodetecta. keyword-only. + **builder_extra: kwargs extra para el builder del `kind` (posicionales requeridos + como `expression` en `emote`, o passthrough como `steps`/`cfg`/`width`/`height`). + + Returns: + dict con: + - ok (bool): True si el asset se generó (y, si el estilo pedía post, el post se + aplicó). Un fallo de post o de export se anota en `error` pero el crudo queda + en `raw_path`. + - kind, subject, style_preset, seed (eco de la entrada; `subject` es el combinado). + - prompt_id (str): id del trabajo en ComfyUI. + - raw_path (str): PNG descargado SIN post-procesar. + - path (str): PNG FINAL entregable (post-procesado si el estilo lo pedía; si no, + igual a raw_path). + - post (dict): la spec de post del estilo ({} si no pedía). + - post_applied (dict|None): resultado del post-proceso si se aplicó + ({kind, out_path, n_colors_final, size} en éxito; {kind, ok:False, error} si + falló); None si el estilo no pedía post. + - size (int), transparent (bool): los que el estilo recomendó. + - godot_kind (str): bucket Godot usado (sprite/vfx/tileset, o pixelart si hubo post). + - exported (dict|None): resultado de export a Godot, o None. + - coherence_note (str), error (str). + """ + server = server.replace("http://", "").replace("https://", "").strip("/") + out: dict = { + "ok": False, "kind": kind, "subject": subject, "style_preset": style_preset, + "seed": seed, "prompt_id": "", "raw_path": "", "path": "", "post": {}, + "post_applied": None, "size": None, "transparent": None, "godot_kind": "", + "exported": None, "coherence_note": "", "error": "", + } + + # --- 1-3. resolver estilo + construir workflow (PURO; valida kind/estilo sin red) --- + try: + built = styled_asset_build_only( + kind, subject, style_preset, seed=seed, + style_override=style_override, negative_extra=negative_extra, + **builder_extra, + ) + except ValueError as exc: + out["error"] = str(exc) + return out + + applied = built["applied"] + workflow = built["workflow"] + out["subject"] = applied["subject"] + out["size"] = applied.get("size") + out["transparent"] = applied.get("transparent") + out["post"] = applied.get("post", {}) + out["godot_kind"] = built["godot_kind"] + out["coherence_note"] = ( + f"Estilo {applied['name']!r} aplicado a un {kind!r}: " + f"checkpoint={applied['builder_kwargs']['checkpoint']!r}, " + f"lora={applied['builder_kwargs']['lora']!r}, " + f"post={'sí' if out['post'] else 'no'} (auto-aplicado por el pipeline)." + ) + + asset_dir = out_dir or os.path.join( + tempfile.gettempdir(), f"styled_asset_{applied['name']}_seed{seed}" + ) + os.makedirs(asset_dir, exist_ok=True) + + if free_vram: + _free_vram(server) + + # --- 4. encolar + esperar + descargar --- + try: + sub = comfyui_submit_workflow(workflow, server=server) + out["prompt_id"] = sub["prompt_id"] + except (RuntimeError, KeyError) as exc: + out["error"] = f"submit falló: {exc}" + return out + + try: + outputs = comfyui_wait_result(out["prompt_id"], server=server, timeout=wait_timeout) + except (TimeoutError, RuntimeError) as exc: + out["error"] = f"wait falló: {exc}" + return out + + img = _first_image(outputs) + if img is None: + out["error"] = f"el workflow no produjo imágenes (outputs={list(outputs)})" + return out + + fetched = comfyui_fetch_output_image( + img["filename"], subfolder=img.get("subfolder", ""), + type_=img.get("type", "output"), server=server, dest_dir=asset_dir, + ) + if not fetched.get("ok"): + out["error"] = f"fetch falló: {fetched.get('error')}" + return out + out["raw_path"] = fetched["path"] + out["path"] = fetched["path"] # por defecto el crudo es el final (estilos sin post) + + # --- 5. AUTO-POST: aplica el post-proceso que el estilo declaró --- + # Hoy el único post soportado es `pixelize`; el dispatch es extensible (un estilo + # nuevo con otro post añade su rama aquí). Si el estilo no pide post, raw == final. + post_ok = True + pixelize_spec = out["post"].get("pixelize") if isinstance(out["post"], dict) else None + if pixelize_spec: + base, ext = os.path.splitext(os.path.basename(out["raw_path"])) + dst = os.path.join(asset_dir, f"{base}_{applied['name']}{ext or '.png'}") + pr = comfyui_pixelize_image(out["raw_path"], dst, **pixelize_spec) + if pr.get("ok"): + out["path"] = pr["out_path"] + out["post_applied"] = { + "kind": "pixelize", "out_path": pr["out_path"], + "n_colors_final": pr["n_colors_final"], "size": pr["size"], + } + else: + post_ok = False + out["post_applied"] = {"kind": "pixelize", "ok": False, "error": pr.get("error")} + out["error"] = f"post pixelize falló (asset crudo OK en raw_path): {pr.get('error')}" + + # --- 6. export opcional a Godot (asset FINAL ya post-procesado) --- + if export_godot and out["path"]: + # Si se pixelizó, exportar como `pixelart` asegura el filtro Nearest global en Godot. + godot_kind = "pixelart" if out["post_applied"] and out["post_applied"].get("out_path") \ + else out["godot_kind"] + exp = comfyui_export_asset_to_godot( + out["path"], godot_kind, export_godot, reimport=True, godot_bin=godot_bin, + ) + out["exported"] = exp + if not exp.get("ok"): + out["error"] = (out["error"] + "; " if out["error"] else "") + \ + f"export falló (asset generado igual): {exp.get('error')}" + + out["ok"] = bool(out["path"]) and post_ok + return out + + +# Alias con el nombre completo del ID para descubrimiento por convención. +generate_styled_asset_oneshot = comfyui_generate_styled_asset_oneshot + + +if __name__ == "__main__": + res = comfyui_generate_styled_asset_oneshot( + "item_icon", "health potion", "gameboy", seed=7, out_dir="/tmp/styled_asset_demo", + ) + print(json.dumps(res, indent=2, ensure_ascii=False)) diff --git a/python/functions/pipelines/comfyui_generate_styled_asset_oneshot_test.py b/python/functions/pipelines/comfyui_generate_styled_asset_oneshot_test.py new file mode 100644 index 00000000..18ad63db --- /dev/null +++ b/python/functions/pipelines/comfyui_generate_styled_asset_oneshot_test.py @@ -0,0 +1,188 @@ +"""Tests de comfyui_generate_styled_asset_oneshot (offline; sin ComfyUI ni GPU). + +Cubre el contrato del pipeline sin tocar la red ni la GPU: +- Error: kind desconocido / estilo desconocido / subject vacío -> ok=False SIN red. +- Golden build: el preset elige checkpoint/lora/negativo y el workflow los lleva; el + `style` del preset SUSTITUYE el del builder; transparent/size del preset se aplican. +- Golden auto-post: un estilo con post (gameboy) auto-pixeliza el PNG -> `path` final + != `raw_path` y sale ya pixelizado, sin paso manual. +- Edge: un estilo SIN post (cyberpunk-neon) deja `path` == `raw_path` (no toca el crudo). +- Edge: catálogo ampliado a >=5 estilos; cada estilo nuevo construye un workflow válido. +- Error: un fallo de post deja el crudo en raw_path y ok=False con error claro. +- Edge: export a Godot usa el asset FINAL y lo manda como `pixelart` si hubo pixelize. +""" + +import json +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import pipelines.comfyui_generate_styled_asset_oneshot as smod # noqa: E402 +from pipelines.comfyui_generate_styled_asset_oneshot import ( # noqa: E402 + comfyui_generate_styled_asset_oneshot, + styled_asset_build_only, +) +from ml.comfyui_get_gamedev_style_preset import comfyui_get_gamedev_style_preset # noqa: E402 + + +# --- Error paths que NO tocan la red --- + +def test_unknown_kind_fails_without_network(): + res = comfyui_generate_styled_asset_oneshot("does_not_exist", "knight", "gameboy") + assert res["ok"] is False + assert "no soportado" in res["error"] + assert res["prompt_id"] == "" # no llegó a encolar + + +def test_unknown_style_fails_without_network(): + res = comfyui_generate_styled_asset_oneshot("item_icon", "potion", "does_not_exist") + assert res["ok"] is False + assert "desconocido" in res["error"] + assert res["prompt_id"] == "" + + +def test_empty_subject_fails_without_network(): + res = comfyui_generate_styled_asset_oneshot("item_icon", " ", "gameboy") + assert res["ok"] is False + assert res["prompt_id"] == "" + + +# --- Golden build puro: el preset dirige el workflow --- + +def test_build_only_preset_drives_workflow(): + built = styled_asset_build_only("item_icon", "knight character", "gameboy", seed=7) + blob = json.dumps(built["workflow"]) + # checkpoint del preset gameboy (SD1.5) + assert "dreamshaper_8.safetensors" in blob + # el subject combinó el sufijo del estilo gameboy + assert "8-bit" in built["applied"]["subject"] + # el style del preset SUSTITUYE el style categórico del builder + assert "Game Boy" in blob + # gameboy declara post pixelize (lo que el pipeline auto-aplicará) + assert built["applied"]["post"].get("pixelize") + assert built["godot_kind"] == "sprite" + + +def test_build_only_lora_preset_injects_lora(): + # cartoon-cel-shaded usa anime_style_box_sd15 -> el workflow debe inyectar el LoRA + built = styled_asset_build_only("enemy_creature", "goblin", "cartoon-cel-shaded", seed=1) + blob = json.dumps(built["workflow"]) + assert "anime_style_box_sd15.safetensors" in blob + assert any(n.get("class_type") == "LoraLoader" for n in built["workflow"].values()) + + +# --- Catálogo ampliado: >=5 estilos, cada uno construible --- + +def test_catalog_has_at_least_5_styles(): + cat = comfyui_get_gamedev_style_preset() + assert cat["count"] >= 5 + for nuevo in ("cyberpunk-neon", "low-poly-flat", "cartoon-cel-shaded"): + assert nuevo in cat["names"] + + +def test_every_style_builds_a_valid_workflow(): + cat = comfyui_get_gamedev_style_preset() + for style in cat["names"]: + built = styled_asset_build_only("item_icon", "magic ring", style, seed=3) + wf = built["workflow"] + classes = {n.get("class_type") for n in wf.values()} + assert "KSampler" in classes and "SaveImage" in classes, style + + +# --- Mock de transporte: submit/wait/fetch sin red --- + +def _mock_transport(monkeypatch, *, raw_name="gen.png"): + def fake_submit(workflow, server="127.0.0.1:8188", **kw): + assert "://" not in server # server normalizado + return {"prompt_id": "pid-1", "client_id": "c"} + + def fake_wait(prompt_id, server="127.0.0.1:8188", timeout=600.0, **kw): + return {"9": {"images": [{"filename": raw_name, "subfolder": "", "type": "output"}]}} + + def fake_fetch(filename, *, subfolder="", type_="output", + server="127.0.0.1:8188", dest_dir=".", timeout=60.0): + os.makedirs(dest_dir, exist_ok=True) + path = os.path.join(dest_dir, filename) + # PNG real mínimo (8x8 RGB) para que el pixelize de verdad pueda abrirlo. + from PIL import Image + Image.new("RGB", (64, 64), (123, 80, 200)).save(path) + return {"ok": True, "path": path, "size_bytes": 100, "error": ""} + + monkeypatch.setattr(smod, "comfyui_submit_workflow", fake_submit) + monkeypatch.setattr(smod, "comfyui_wait_result", fake_wait) + monkeypatch.setattr(smod, "comfyui_fetch_output_image", fake_fetch) + + +# --- Golden auto-post: gameboy pixeliza automáticamente --- + +def test_post_auto_applied_for_gameboy(monkeypatch, tmp_path): + _mock_transport(monkeypatch) + res = comfyui_generate_styled_asset_oneshot( + "item_icon", "health potion", "gameboy", seed=7, + server="http://127.0.0.1:8188", out_dir=str(tmp_path), + ) + assert res["ok"] is True, res["error"] + assert res["raw_path"] and os.path.isfile(res["raw_path"]) + # el path FINAL es el post-procesado, distinto del crudo + assert res["path"] != res["raw_path"] + assert os.path.isfile(res["path"]) + assert res["post_applied"]["kind"] == "pixelize" + # paleta game-boy -> a lo sumo 4 colores en el resultado + assert 0 < res["post_applied"]["n_colors_final"] <= 4 + + +# --- Edge: estilo SIN post no toca el crudo --- + +def test_no_post_keeps_raw_for_cyberpunk(monkeypatch, tmp_path): + _mock_transport(monkeypatch) + res = comfyui_generate_styled_asset_oneshot( + "enemy_creature", "street samurai", "cyberpunk-neon", seed=5, + out_dir=str(tmp_path), + ) + assert res["ok"] is True, res["error"] + assert res["path"] == res["raw_path"] # sin post -> final == crudo + assert res["post_applied"] is None + assert res["post"] == {} + + +# --- Error: fallo de post deja el crudo y ok=False --- + +def test_post_failure_keeps_raw_and_flags_error(monkeypatch, tmp_path): + _mock_transport(monkeypatch) + + def fake_pixelize(src, dst, **kw): + return {"ok": False, "out_path": "", "error": "simulated PIL failure"} + + monkeypatch.setattr(smod, "comfyui_pixelize_image", fake_pixelize) + res = comfyui_generate_styled_asset_oneshot( + "item_icon", "sword", "gameboy", seed=1, out_dir=str(tmp_path), + ) + assert res["ok"] is False + assert res["raw_path"] and os.path.isfile(res["raw_path"]) # crudo preservado + assert "post pixelize falló" in res["error"] + assert res["post_applied"]["ok"] is False + + +# --- Edge: export a Godot usa el asset final como pixelart si hubo post --- + +def test_export_godot_uses_pixelart_when_post(monkeypatch, tmp_path): + _mock_transport(monkeypatch) + captured = {} + + def fake_export(asset_path, kind, godot_project, *, reimport=True, + name=None, godot_bin=None): + captured["kind"] = kind + captured["asset"] = asset_path + return {"ok": True, "kind": kind} + + monkeypatch.setattr(smod, "comfyui_export_asset_to_godot", fake_export) + res = comfyui_generate_styled_asset_oneshot( + "item_icon", "potion", "gameboy", seed=2, out_dir=str(tmp_path), + export_godot=str(tmp_path / "godot_proj"), + ) + assert res["ok"] is True, res["error"] + assert captured["kind"] == "pixelart" # post -> Nearest filter en Godot + assert captured["asset"] == res["path"] # exporta el FINAL post-procesado + assert res["exported"]["ok"] is True