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 | | 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). | | `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):** **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`): 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 gameboy 4 colores verde (`prompt_id 0657e3e3`), ghibli 78 552 colores acuarela
(`42f2f492`), pixel-art-retro SDXL 768 16 colores (`84b08581`) — tres looks (`42f2f492`), pixel-art-retro SDXL 768 16 colores (`84b08581`) — tres looks
visiblemente distintos y coherentes. **Gotcha**: el `post` no se aplica solo (el caller visiblemente distintos y coherentes. **Gotcha**: en el flujo manual de arriba el `post` no
llama `comfyui_pixelize_image`); el LoRA y el checkpoint deben casar de base (pixel-art-xl se aplica solo (el caller llama `comfyui_pixelize_image`) — para evitarlo usa el pipeline
es SDXL → exige juggernaut); OOM en 8 GB → bajar `size`, NO matar procesos. 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) ## 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_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_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) ## 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." "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. """Devuelve la receta de un style preset gamedev, o el catalogo si name es None.
Args: 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 None (o cadena vacia), devuelve el catalogo de nombres disponibles en vez de
una receta concreta (discovery). Insensible a mayusculas y a '_' vs '-'. 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(): def test_edge_catalog_when_none():
cat = comfyui_get_gamedev_style_preset(None) cat = comfyui_get_gamedev_style_preset(None)
assert set(cat["names"]) == {"gameboy", "ghibli", "pixel-art-retro"} # Los 3 originales + los 3 ampliados (2026-06-27); el catalogo crece, asi que se
assert cat["count"] == 3 # 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). # Cadena vacia tambien devuelve catalogo (discovery).
assert comfyui_get_gamedev_style_preset("") == cat 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