feat(gamedev): comfyui_generate_styled_asset_oneshot — aplica estilo a un asset con auto-post + amplía catálogo a 6 estilos

Pipeline one-shot que aplica un style preset curado a un asset en una llamada
(kind, subject, style_preset) y auto-ejecuta el post-proceso que el estilo declara:
los estilos pixelart (gameboy, pixel-art-retro) salen ya pixelizados del pipeline,
cerrando el hueco #1 del sistema de style presets (report 0190) donde el caller
tenía que llamar comfyui_pixelize_image a mano.

Reutiliza el dispatch _SUPPORTED (kind->builder) de comfyui_generate_asset_pack_oneshot
en vez de redefinir el mapa. Parte pura aislada en styled_asset_build_only para validar
kind/estilo desconocido sin tocar la GPU. Export a Godot consciente del post (pixelart
si hubo pixelize, para fijar el filtro Nearest).

Catálogo de estilos ampliado de 3 a 6: cyberpunk-neon (prompt puro SD1.5),
low-poly-flat (prompt puro SD1.5), cartoon-cel-shaded (LoRA anime_style_box_sd15 0.7).

Verificación: 11 tests offline del pipeline + suite de presets verde (27 passed).
Prueba real en GPU: mismo "treasure chest" en cyberpunk-neon, low-poly-flat y gameboy
one-shot; gameboy pasa de 17374 colores (crudo) a 4 (paleta Game Boy) auto-pixelizado
directo del pipeline. Detalle en reports/0191.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-27 12:50:30 +02:00
parent 0eefb7cfcd
commit a5748cb147
6 changed files with 835 additions and 7 deletions
+7 -4
View File
@@ -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)
@@ -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 '-'.
@@ -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
@@ -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.
@@ -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={'' 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))
@@ -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