feat(gamedev): comfyui_generate_asset_pack_oneshot — set 2D coherente one-shot

Pipeline que genera un set de assets 2D de un mismo juego en una sola llamada,
compartiendo checkpoint, LoRA de estilo, estilo comun (inyectado al subject) y
seed derivada (base_seed + indice). Despacha 26 kinds gamedev-2d a sus builders
atomicos, encola/espera/descarga cada PNG y exporta opcionalmente a Godot.

Promocion de composicion a pipeline (issue 0087): el registry no crece inflando
builders, crece promoviendo la secuencia repetida 'N builders con el mismo estilo'
a un one-shot.

- Dispatch declarativo por kind con inyeccion de coherencia via inspect.signature
  (no hardcodea nombres de param; respeta LoRAs funcionales propios).
- Fail-fast si kind desconocido (sin tocar GPU); un OOM aislado no aborta el pack.
- 9 tests offline verdes (golden + edge + error).
- Probado e2e en GPU SD1.5 512: magic sword + goblin warrior, style dark fantasy
  hand-painted, seeds 42/43 -> 2/2 PNG 512x512 RGBA coherentes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-27 03:00:10 +02:00
parent a27dcc028c
commit e1f1be02ce
4 changed files with 734 additions and 3 deletions
+11 -3
View File
@@ -75,6 +75,12 @@ VFX (ver `reports/0143`).
| `godot_map_asset_dir_py_core` | `(kind) -> str` | Mapea `kind` -> subcarpeta de `res://assets/`. Pura. | | `godot_map_asset_dir_py_core` | `(kind) -> str` | Mapea `kind` -> subcarpeta de `res://assets/`. Pura. |
| `godot_clean_asset_name_py_core` | `(filename, *, override=None) -> str` | Normaliza el nombre `<prefijo>_NNNNN_.<ext>` a snake_case seguro para `res://`. Pura. | | `godot_clean_asset_name_py_core` | `(filename, *, override=None) -> str` | Normaliza el nombre `<prefijo>_NNNNN_.<ext>` a snake_case seguro para `res://`. Pura. |
## Pipelines one-shot (`gamedev-2d`, impuros)
| ID | Firma corta | Qué hace |
|---|---|---|
| `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. |
## 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)
Flujo completo pixel-art: construir workflow → generar en ComfyUI → pixel-perfect → Godot. Flujo completo pixel-art: construir workflow → generar en ComfyUI → pixel-perfect → Godot.
@@ -135,9 +141,11 @@ comfyui_export_asset_to_godot(rgba["out_path"], "vfx", PROJ)
no hay función propia todavía — el ejemplo VFX monta con `Image.alpha_composite` no hay función propia todavía — el ejemplo VFX monta con `Image.alpha_composite`
inline. `comfyui_build_grid` NO sirve (aplana el alpha sobre fondo oscuro). Pendiente inline. `comfyui_build_grid` NO sirve (aplana el alpha sobre fondo oscuro). Pendiente
de R4 (plan `reports/0140` F2). de R4 (plan `reports/0140` F2).
- **Pipelines one-shot** (build → submit → wait → fetch → post en una call) para - **Pipelines one-shot** (build → submit → wait → fetch → post en una call): el
pixelart/sprite/VFX: pendientes. Hoy se encadena a mano (ver ejemplos). Candidatos a **set coherente** ya está promovido — `comfyui_generate_asset_pack_oneshot` genera
promoción a pipeline (issue 0087) cuando el patrón se repita. un pack entero compartiendo checkpoint/style/lora/seed (issue 0087, ver tabla de
pipelines arriba). One-shots por-asset individuales (pixelart/sprite/VFX) siguen
encadenándose a mano; candidatos a promoción cuando el patrón se repita.
- **Sprite turnaround multi-vista** (orquestar N poses con identidad fija + juez): - **Sprite turnaround multi-vista** (orquestar N poses con identidad fija + juez):
el builder `comfyui_build_sprite_sheet_workflow` produce UN frame; la orquestación el builder `comfyui_build_sprite_sheet_workflow` produce UN frame; la orquestación
multi-pose es pipeline pendiente (plan `reports/0137` T2). multi-pose es pipeline pendiente (plan `reports/0137` T2).
@@ -0,0 +1,136 @@
---
name: comfyui_generate_asset_pack_oneshot
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def comfyui_generate_asset_pack_oneshot(pack: list, *, checkpoint: str = \"dreamshaper_8.safetensors\", style: str = \"\", lora: str | None = None, lora_strength: float = 1.0, base_seed: int = 0, size: int | None = None, server: str = \"127.0.0.1:8188\", export_godot: str | None = None, out_dir: str | None = None, wait_timeout: float = 600.0, godot_bin: str | None = None) -> dict"
description: "One-shot del grupo gamedev-2d: recibe una spec de pack (lista de assets, cada uno con kind + subject) y genera el set 2D ENTERO de un juego compartiendo el MISMO checkpoint, el MISMO LoRA de estilo, un estilo comun (concatenado al subject de cada asset) y una semilla derivada (seed = base_seed + indice), de modo que sprite + iconos + tiles + UI peguen entre si (misma mano, misma paleta, mismo modelo). Despacha cada kind a su builder atomico del registry (item_icon, enemy_creature, prop_object, seamless_tile, ui_hud, particle_texture, ... 26 kinds), encola con comfyui_submit_workflow, espera con comfyui_wait_result, descarga el PNG con comfyui_fetch_output_image y (si export_godot) exporta cada asset al proyecto Godot con comfyui_export_asset_to_godot. Promueve a UNA llamada la composicion repetida 'llamar N builders con el mismo estilo' (issue 0087): el registry no crece inflando builders, crece promoviendo composiciones a pipelines. Un fallo aislado (p.ej. OOM en un asset) NO aborta el resto. Devuelve {ok, pack_dir, checkpoint, style, lora, base_seed, coherence_note, count, generated, failed, assets, error}. Impuro: HTTP a ComfyUI + disco + (export) subprocess."
tags: [gamedev-2d, pipelines, comfyui, ml, godot]
uses_functions: [comfyui_build_achievement_badge_workflow_py_ml, comfyui_build_card_art_workflow_py_ml, comfyui_build_decal_overlay_workflow_py_ml, comfyui_build_dialogue_box_workflow_py_ml, comfyui_build_emote_workflow_py_ml, comfyui_build_enemy_creature_workflow_py_ml, comfyui_build_foliage_set_workflow_py_ml, comfyui_build_item_icon_workflow_py_ml, comfyui_build_parallax_background_workflow_py_ml, comfyui_build_particle_texture_workflow_py_ml, comfyui_build_portrait_avatar_workflow_py_ml, comfyui_build_projectile_workflow_py_ml, comfyui_build_prop_object_workflow_py_ml, comfyui_build_rune_glyph_workflow_py_ml, comfyui_build_seamless_tile_workflow_py_ml, comfyui_build_skill_tree_node_workflow_py_ml, comfyui_build_splash_art_workflow_py_ml, comfyui_build_status_effect_icon_workflow_py_ml, comfyui_build_structure_workflow_py_ml, comfyui_build_title_lettering_workflow_py_ml, comfyui_build_topdown_sprite_workflow_py_ml, comfyui_build_trap_hazard_workflow_py_ml, comfyui_build_ui_hud_workflow_py_ml, comfyui_build_vehicle_mount_workflow_py_ml, comfyui_build_weather_overlay_workflow_py_ml, comfyui_build_world_map_workflow_py_ml, comfyui_submit_workflow_py_ml, comfyui_wait_result_py_ml, comfyui_fetch_output_image_py_ml, comfyui_export_asset_to_godot_py_pipelines]
uses_types: []
returns: []
returns_optional: false
error_type: "error_py_core"
imports: [comfyui_build_item_icon_workflow_py_ml, comfyui_submit_workflow_py_ml, comfyui_wait_result_py_ml, comfyui_fetch_output_image_py_ml, comfyui_export_asset_to_godot_py_pipelines]
tested: true
test_file_path: "python/functions/pipelines/comfyui_generate_asset_pack_oneshot_test.py"
tests: [test_dispatch_shares_checkpoint_and_seed, test_seed_is_base_plus_index, test_godot_kind_per_category, test_emote_requires_expression, test_per_item_kwargs_passthrough, test_unknown_kind_fails_without_network, test_empty_pack, test_full_flow_mocked, test_one_asset_fails_others_survive]
file_path: "python/functions/pipelines/comfyui_generate_asset_pack_oneshot.py"
params:
- name: pack
desc: "lista de assets. Cada item es un dict con kind (uno de los 26 soportados) + subject (que dibujar). Campos extra se reenvian como kwargs al builder si los admite (p.ej. tier para achievement_badge, expression obligatorio para emote, view/direction/glow/transparent/negative segun el kind)."
- name: checkpoint
desc: "modelo base COMPARTIDO por todos los assets del pack. keyword-only."
- name: style
desc: "estilo comun concatenado al subject de cada asset (la firma visual del pack, p.ej. 'dark fantasy, hand-painted'). El style categorico propio de cada builder se conserva. keyword-only."
- name: lora
desc: "LoRA de estilo COMPARTIDO; solo se aplica a builders con un param 'lora' generico (no pisa LoRAs funcionales como el de isometric/pixelart). keyword-only."
- name: lora_strength
desc: "fuerza del LoRA comun. keyword-only."
- name: base_seed
desc: "semilla base; el asset i usa seed = base_seed + i (reproducible y variado). keyword-only."
- name: size
desc: "resolucion cuadrada comun para builders que aceptan 'size' (icons/sprites); None deja el default de cada builder. Util para bajar VRAM (p.ej. 512 con SD1.5). keyword-only."
- name: server
desc: "host:port del servidor ComfyUI; se acepta con o sin esquema (http://). keyword-only."
- name: export_godot
desc: "ruta raiz de un proyecto Godot 4; si se da, cada asset se exporta a res://assets/... con su .import (reimport headless una sola vez al final). None = no exportar. keyword-only."
- name: out_dir
desc: "directorio local donde descargar los PNG; None = un dir temporal por pack (asset_pack_seed<base_seed> en tempdir). keyword-only."
- name: wait_timeout
desc: "segundos maximos esperando cada asset en ComfyUI. keyword-only."
- name: godot_bin
desc: "binario de Godot para el reimport headless; None autodetecta. keyword-only."
output: "dict con ok (bool, True solo si TODOS los assets se generaron), pack_dir (str), checkpoint/style/lora/base_seed (eco de la coherencia), coherence_note (str), count/generated/failed (int), assets (list, una entrada por item: {index, kind, subject, seed, prompt_id, path, exported, ok, error}), error (str)."
---
Pipeline que genera un set COHERENTE de assets 2D para un mismo juego de un solo
tiro. Cada asset del `pack` se construye con su builder atomico de `gamedev-2d`,
pero todos comparten checkpoint, LoRA de estilo, estilo comun y una semilla
derivada — lo que hace que el set "pegue". Encola, espera y descarga cada uno, y
opcionalmente los exporta a un proyecto Godot.
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from pipelines.comfyui_generate_asset_pack_oneshot import comfyui_generate_asset_pack_oneshot
# Mini-pack coherente de 2 assets (SD1.5, 512px -> poca VRAM):
res = comfyui_generate_asset_pack_oneshot(
[
{"kind": "item_icon", "subject": "magic sword"},
{"kind": "enemy_creature", "subject": "goblin warrior"},
],
checkpoint="dreamshaper_8.safetensors",
style="dark fantasy, hand-painted", # firma visual compartida
base_seed=42, # seeds 42, 43, ...
size=512,
out_dir="/tmp/asset_pack_demo",
)
# res["ok"] -> True
# res["assets"][0] -> {"kind":"item_icon","seed":42,"prompt_id":"...","path":"/tmp/asset_pack_demo/item_icon_00042_.png", ...}
# res["coherence_note"] -> "Set coherente: checkpoint='dreamshaper_8.safetensors', style='dark fantasy, hand-painted', ..."
# Con export directo a un proyecto Godot 4 (cada PNG a res://assets/... + .import):
res = comfyui_generate_asset_pack_oneshot(
[{"kind": "ui_hud", "subject": "health bar"},
{"kind": "seamless_tile", "subject": "mossy stone floor"}],
style="dark fantasy, hand-painted", base_seed=42, size=512,
export_godot=os.path.expanduser("~/gamedev/projects/dungeon"),
)
```
Lanzable tambien por `fn run` (despacha como pipeline Python):
```bash
./fn run comfyui_generate_asset_pack_oneshot # corre el __main__ de demo (2 assets)
```
## Cuando usarla
Usala cuando necesites MAS DE UN asset 2D que tengan que verse del mismo juego:
el sprite del heroe + sus iconos de inventario + los tiles del nivel + la UID del
HUD. En vez de llamar a cada builder a mano repitiendo el mismo `checkpoint`/`lora`/
`style` y acordandote de variar la seed, le pasas la spec del pack una vez y obtienes
el set entero coherente. Para UN solo asset aislado, usa directamente su builder
atomico (`comfyui_build_item_icon_workflow`, etc.) — este pipeline es para el *set*.
Si ademas quieres llevarlos a Godot sin tocar imports, pasa `export_godot`.
## Gotchas
- **Coherencia = mismo modelo + estilo en el subject.** El `style` comun se concatena
al `subject` de cada asset (no pisa el `style` categorico propio del builder, p.ej.
"game icon, clean, centered"). Si un item trae su propio `checkpoint`/`style` en
los kwargs extra, ESE asset rompe la coherencia a proposito (override explicito).
- **VRAM / OOM.** Genera secuencialmente (un asset cada vez), pero cada uno carga el
modelo. Con poca VRAM usa SD1.5 (`dreamshaper_8`) + `size=512` y packs pequenos.
Si un asset peta por OOM, **NO** aborta el pack: ese asset queda `ok=False` con su
`error` y el resto continua. Reduce el pack o `size`, no mates procesos. Conviene
liberar VRAM antes: `POST http://127.0.0.1:8188/free {"unload_models":true,"free_memory":true}`.
- **`lora` comun solo en builders genericos.** Se aplica solo a builders con un param
`lora` (la mayoria de iconos/sprites). Builders con LoRA funcional propio
(isometric `iso_lora`, pixelart `pixel_lora`, seamless `material_lora`,
sprite_sheet `char_lora`) NO se ven afectados — su LoRA es parte de su tecnica, no
estilo de pack.
- **`kind` desconocido = fail-fast sin GPU.** Si algun item trae un `kind` no
soportado, devuelve `ok=False` con la lista de kinds soportados ANTES de encolar
nada (no malgasta GPU). Kinds: ver `supported_kinds()` (26 hoy).
- **Posicionales extra.** Algun builder pide mas que `subject` (p.ej. `emote`
requiere `expression`). Pasalo como campo del item (`{"kind":"emote","subject":
"hero","expression":"angry"}`); si falta, ese asset falla con error claro.
- **`server`** se normaliza (acepta con o sin `http://`). La convencion interna del
registry es `host:port` sin esquema.
- **Export a Godot** hace el reimport headless UNA sola vez (en el ultimo asset
exportado) para no relanzar Godot N veces. Si no encuentra el binario, deja los
`.import` escritos y lo anota; no falla la generacion.
## Capability growth log
v1.0.0 (2026-06-27) — version inicial. Promueve la composicion "N builders gamedev-2d
con estilo/checkpoint/lora/seed compartidos" a un pipeline one-shot (issue 0087).
Despacha 26 kinds; coherencia por checkpoint + style-en-subject + lora generico +
seed = base_seed + indice; export opcional a Godot.
@@ -0,0 +1,399 @@
"""comfyui_generate_asset_pack_oneshot — set COHERENTE de assets 2D de un solo tiro.
One-shot del grupo `gamedev-2d`: recibe una *spec de pack* (lista de assets, cada
uno con su `kind` y `subject`) y genera el set entero compartiendo el MISMO
checkpoint, el MISMO LoRA de estilo, el MISMO estilo común y una semilla derivada
(`seed = base_seed + indice`). Eso es lo que hace que un sprite, sus iconos, sus
tiles y su UI "peguen" entre sí: misma mano, misma paleta, mismo modelo.
Promueve a UNA sola llamada la secuencia que hoy se repite a mano (issue 0087):
elegir N builders atómicos de `gamedev-2d`, pasarles el mismo estilo/checkpoint/LoRA
y encolar/esperar/descargar cada uno. El registry no crece inflando builders —
crece promoviendo esta composición repetida a un pipeline.
Compone funciones del registry:
comfyui_build_<kind>_workflow_py_ml (un builder atómico por `kind`, PUROS)
comfyui_submit_workflow_py_ml (POST /prompt)
comfyui_wait_result_py_ml (poll /history)
comfyui_fetch_output_image_py_ml (GET /view -> disco)
comfyui_export_asset_to_godot_py_pipelines (export opcional a un proyecto Godot)
Coherencia (lo que comparten TODOS los assets del pack):
- checkpoint -> mismo modelo base.
- lora -> mismo LoRA de estilo (solo en builders con un param `lora` genérico).
- style -> texto de estilo común, concatenado al `subject` de cada asset,
de modo que el `style` categórico propio de cada builder
("game icon, clean, centered", etc.) se conserva.
- base_seed -> seed = base_seed + indice (reproducible y a la vez variado).
Pipeline impuro: red (HTTP a ComfyUI) + escritura en disco + (export) subprocess.
"""
from __future__ import annotations
import inspect
import os
import sys
import tempfile
_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)
# --- builders atómicos del grupo gamedev-2d (puros) ---
from ml.comfyui_build_achievement_badge_workflow import comfyui_build_achievement_badge_workflow
from ml.comfyui_build_card_art_workflow import comfyui_build_card_art_workflow
from ml.comfyui_build_decal_overlay_workflow import comfyui_build_decal_overlay_workflow
from ml.comfyui_build_dialogue_box_workflow import comfyui_build_dialogue_box_workflow
from ml.comfyui_build_emote_workflow import comfyui_build_emote_workflow
from ml.comfyui_build_enemy_creature_workflow import comfyui_build_enemy_creature_workflow
from ml.comfyui_build_foliage_set_workflow import comfyui_build_foliage_set_workflow
from ml.comfyui_build_item_icon_workflow import comfyui_build_item_icon_workflow
from ml.comfyui_build_parallax_background_workflow import comfyui_build_parallax_background_workflow
from ml.comfyui_build_particle_texture_workflow import comfyui_build_particle_texture_workflow
from ml.comfyui_build_portrait_avatar_workflow import comfyui_build_portrait_avatar_workflow
from ml.comfyui_build_projectile_workflow import comfyui_build_projectile_workflow
from ml.comfyui_build_prop_object_workflow import comfyui_build_prop_object_workflow
from ml.comfyui_build_rune_glyph_workflow import comfyui_build_rune_glyph_workflow
from ml.comfyui_build_seamless_tile_workflow import comfyui_build_seamless_tile_workflow
from ml.comfyui_build_skill_tree_node_workflow import comfyui_build_skill_tree_node_workflow
from ml.comfyui_build_splash_art_workflow import comfyui_build_splash_art_workflow
from ml.comfyui_build_status_effect_icon_workflow import comfyui_build_status_effect_icon_workflow
from ml.comfyui_build_structure_workflow import comfyui_build_structure_workflow
from ml.comfyui_build_title_lettering_workflow import comfyui_build_title_lettering_workflow
from ml.comfyui_build_topdown_sprite_workflow import comfyui_build_topdown_sprite_workflow
from ml.comfyui_build_trap_hazard_workflow import comfyui_build_trap_hazard_workflow
from ml.comfyui_build_ui_hud_workflow import comfyui_build_ui_hud_workflow
from ml.comfyui_build_vehicle_mount_workflow import comfyui_build_vehicle_mount_workflow
from ml.comfyui_build_weather_overlay_workflow import comfyui_build_weather_overlay_workflow
from ml.comfyui_build_world_map_workflow import comfyui_build_world_map_workflow
# --- 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
# Dispatch declarativo: kind del asset -> (kind Godot para export, builder atómico).
# El "kind Godot" es el bucket de carpeta/import que usa comfyui_export_asset_to_godot
# (sprite -> assets/sprites, tileset -> assets/tilesets, vfx -> assets/vfx).
_SUPPORTED: dict[str, tuple[str, object]] = {
"item_icon": ("sprite", comfyui_build_item_icon_workflow),
"enemy_creature": ("sprite", comfyui_build_enemy_creature_workflow),
"prop_object": ("sprite", comfyui_build_prop_object_workflow),
"ui_hud": ("sprite", comfyui_build_ui_hud_workflow),
"status_effect_icon":("sprite", comfyui_build_status_effect_icon_workflow),
"structure": ("sprite", comfyui_build_structure_workflow),
"topdown_sprite": ("sprite", comfyui_build_topdown_sprite_workflow),
"projectile": ("sprite", comfyui_build_projectile_workflow),
"vehicle_mount": ("sprite", comfyui_build_vehicle_mount_workflow),
"trap_hazard": ("sprite", comfyui_build_trap_hazard_workflow),
"foliage_set": ("sprite", comfyui_build_foliage_set_workflow),
"rune_glyph": ("sprite", comfyui_build_rune_glyph_workflow),
"achievement_badge": ("sprite", comfyui_build_achievement_badge_workflow),
"skill_tree_node": ("sprite", comfyui_build_skill_tree_node_workflow),
"portrait_avatar": ("sprite", comfyui_build_portrait_avatar_workflow),
"emote": ("sprite", comfyui_build_emote_workflow),
"card_art": ("sprite", comfyui_build_card_art_workflow),
"splash_art": ("sprite", comfyui_build_splash_art_workflow),
"world_map": ("sprite", comfyui_build_world_map_workflow),
"title_lettering": ("sprite", comfyui_build_title_lettering_workflow),
"parallax_background":("sprite", comfyui_build_parallax_background_workflow),
"dialogue_box": ("sprite", comfyui_build_dialogue_box_workflow),
"particle_texture": ("vfx", comfyui_build_particle_texture_workflow),
"decal_overlay": ("vfx", comfyui_build_decal_overlay_workflow),
"weather_overlay": ("vfx", comfyui_build_weather_overlay_workflow),
"seamless_tile": ("tileset", comfyui_build_seamless_tile_workflow),
}
# Nombres de parámetro candidatos para el checkpoint, por orden de preferencia.
_CKPT_PARAMS = ("checkpoint", "ckpt_name", "ckpt")
# Claves del item del pack que NO se reenvían como kwargs al builder.
_RESERVED_ITEM_KEYS = {"kind", "subject"}
def supported_kinds() -> list[str]:
"""Lista ordenada de los `kind` de asset que el pack sabe despachar."""
return sorted(_SUPPORTED)
def _inject_style(subject: str, style: str) -> str:
"""Estilo común al final del subject; conserva el style categórico del builder."""
style = (style or "").strip()
subject = (subject or "").strip()
if not style:
return subject
if not subject:
return style
return f"{subject}, {style}"
def _build_item_workflow(
item: dict,
*,
checkpoint: str,
style: str,
lora: str | None,
lora_strength: float,
base_seed: int,
size: int | None,
index: int,
) -> tuple[dict, str, str, int]:
"""Construye el workflow de UN asset del pack inyectando la coherencia compartida.
PURO (no toca la red): solo invoca el builder atómico correspondiente. Útil para
testear el dispatch sin ComfyUI.
Returns:
(workflow, subject_efectivo, kind_godot, seed) del asset.
Raises:
ValueError: si el `kind` no está soportado o falta un campo obligatorio del
builder (p.ej. `expression` en `emote`).
"""
kind = str(item.get("kind", "")).strip()
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
seed = base_seed + index
subject_eff = _inject_style(str(item.get("subject", "")), style)
# Coherencia compartida -> kwargs que el builder admita.
kw: dict = {}
for cand in _CKPT_PARAMS:
if cand in params:
kw[cand] = checkpoint
break
if "seed" in params:
kw["seed"] = seed
if lora and "lora" in params:
kw["lora"] = lora
if "lora_strength" in params:
kw["lora_strength"] = lora_strength
if size is not None and "size" in params:
kw["size"] = size
# Posicionales requeridos extra (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 = [subject_eff]
for name in pos_extra:
if name not in item:
raise ValueError(f"kind {kind!r} requiere el campo {name!r} en el item del pack")
pos_args.append(item[name])
# Passthrough por-asset: cualquier otra clave del item que el builder admita
# (size, width, height, tier, view, direction, glow, transparent, negative, ...).
# Sobreescribe la coherencia común si el usuario lo pide explícitamente.
used = _RESERVED_ITEM_KEYS | set(pos_extra)
for key, val in item.items():
if key in used:
continue
if key in params:
kw[key] = val
workflow = fn(*pos_args, **kw)
return workflow, subject_eff, godot_kind, seed
def _first_image(outputs: dict) -> dict | None:
"""Primer descriptor de imagen {filename, subfolder, type} en los outputs."""
for node_out in outputs.values():
images = node_out.get("images") if isinstance(node_out, dict) else None
if images:
return images[0]
return None
def comfyui_generate_asset_pack_oneshot(
pack: list,
*,
checkpoint: str = "dreamshaper_8.safetensors",
style: str = "",
lora: str | None = None,
lora_strength: float = 1.0,
base_seed: int = 0,
size: int | None = None,
server: str = "127.0.0.1:8188",
export_godot: str | None = None,
out_dir: str | None = None,
wait_timeout: float = 600.0,
godot_bin: str | None = None,
) -> dict:
"""Genera un set COHERENTE de assets 2D para un mismo juego, de un solo tiro.
Args:
pack: lista de assets. Cada item es un dict con al menos `kind` (uno de
`supported_kinds()`) y `subject` (qué dibujar). Campos extra se reenvían
como kwargs al builder si los admite (p.ej. {"kind":"achievement_badge",
"subject":"first kill","tier":"gold"} o {"kind":"emote","subject":"hero",
"expression":"angry"}).
checkpoint: modelo base COMPARTIDO por todos los assets. keyword-only.
style: estilo común concatenado al `subject` de cada asset (la firma visual
del pack, p.ej. "dark fantasy, hand-painted"). keyword-only.
lora: LoRA de estilo COMPARTIDO; se aplica solo a builders con un param
`lora` genérico (no pisa LoRAs funcionales como el de isometric/pixelart).
keyword-only.
lora_strength: fuerza del LoRA común. keyword-only.
base_seed: semilla base; el asset i usa `seed = base_seed + i` (reproducible
y variado). keyword-only.
size: resolución cuadrada común para los builders que aceptan `size`
(icons/sprites); None deja el default de cada builder. Útil para bajar
VRAM (p.ej. 512 con SD1.5). keyword-only.
server: host:port del servidor ComfyUI; se acepta con o sin esquema
(`http://`). keyword-only.
export_godot: ruta raíz de un proyecto Godot 4; si se da, cada asset se
exporta a `res://assets/...` con su `.import`. None = no exportar.
keyword-only.
out_dir: directorio local donde descargar los PNG; None = un dir temporal
por pack. keyword-only.
wait_timeout: segundos máximos esperando cada asset en ComfyUI. keyword-only.
godot_bin: binario de Godot para el reimport headless; None autodetecta.
keyword-only.
Returns:
dict {ok, pack_dir, checkpoint, style, lora, base_seed, coherence_note,
count, generated, failed, assets, error}. `assets` es una lista, una entrada
por item del pack, con {index, kind, subject, seed, prompt_id, path, exported,
ok, error}. `ok` global es True solo si TODOS los assets se generaron. Un
fallo aislado (p.ej. OOM en uno) NO aborta el resto: queda registrado en su
entrada con `ok=False` y `error`.
"""
server = server.replace("http://", "").replace("https://", "").strip("/")
out = {
"ok": False, "pack_dir": "", "checkpoint": checkpoint, "style": style,
"lora": lora, "base_seed": base_seed, "coherence_note": "",
"count": 0, "generated": 0, "failed": 0, "assets": [], "error": "",
}
if not pack or not isinstance(pack, list):
out["error"] = "pack vacío o no es una lista"
return out
out["count"] = len(pack)
# Validación previa: todos los kinds deben estar soportados ANTES de tocar la GPU.
bad = [str(it.get("kind", "")) for it in pack if str(it.get("kind", "")).strip() not in _SUPPORTED]
if bad:
out["error"] = (
f"kind(s) no soportado(s): {', '.join(sorted(set(bad)))}. "
f"Soportados: {', '.join(supported_kinds())}"
)
return out
pack_dir = out_dir or os.path.join(tempfile.gettempdir(), f"asset_pack_seed{base_seed}")
os.makedirs(pack_dir, exist_ok=True)
out["pack_dir"] = pack_dir
out["coherence_note"] = (
f"Set coherente: checkpoint={checkpoint!r}, style={style!r}, lora={lora!r} "
f"compartidos por los {len(pack)} assets; seed = base_seed({base_seed}) + indice."
)
# Índice del último asset a exportar -> único reimport headless de Godot al final.
last_export_idx = -1
if export_godot:
last_export_idx = max(range(len(pack)), default=-1)
for i, item in enumerate(pack):
entry = {
"index": i, "kind": str(item.get("kind", "")), "subject": item.get("subject", ""),
"seed": base_seed + i, "prompt_id": "", "path": "", "exported": None,
"ok": False, "error": "",
}
try:
workflow, subject_eff, godot_kind, seed = _build_item_workflow(
item, checkpoint=checkpoint, style=style, lora=lora,
lora_strength=lora_strength, base_seed=base_seed, size=size, index=i,
)
entry["subject"] = subject_eff
entry["seed"] = seed
except (ValueError, TypeError) as exc:
entry["error"] = f"build fallo: {exc}"
out["assets"].append(entry)
out["failed"] += 1
continue
# Encolar.
try:
sub = comfyui_submit_workflow(workflow, server=server)
entry["prompt_id"] = sub["prompt_id"]
except (RuntimeError, KeyError) as exc:
entry["error"] = f"submit fallo: {exc}"
out["assets"].append(entry)
out["failed"] += 1
continue
# Esperar + localizar el PNG.
try:
outputs = comfyui_wait_result(entry["prompt_id"], server=server, timeout=wait_timeout)
except (TimeoutError, RuntimeError) as exc:
entry["error"] = f"wait fallo: {exc}"
out["assets"].append(entry)
out["failed"] += 1
continue
img = _first_image(outputs)
if img is None:
entry["error"] = f"el workflow no produjo imágenes (outputs={list(outputs)})"
out["assets"].append(entry)
out["failed"] += 1
continue
# Descargar.
fetched = comfyui_fetch_output_image(
img["filename"], subfolder=img.get("subfolder", ""),
type_=img.get("type", "output"), server=server, dest_dir=pack_dir,
)
if not fetched.get("ok"):
entry["error"] = f"fetch fallo: {fetched.get('error')}"
out["assets"].append(entry)
out["failed"] += 1
continue
entry["path"] = fetched["path"]
entry["ok"] = True
out["generated"] += 1
# Export opcional a Godot (reimport solo en el último -> un único scan).
if export_godot:
exp = comfyui_export_asset_to_godot(
entry["path"], godot_kind, export_godot,
reimport=(i == last_export_idx), godot_bin=godot_bin,
)
entry["exported"] = exp
if not exp.get("ok"):
entry["error"] = f"export fallo (asset generado igual): {exp.get('error')}"
out["assets"].append(entry)
out["ok"] = out["failed"] == 0 and out["generated"] == len(pack)
if out["failed"]:
out["error"] = f"{out['failed']}/{len(pack)} assets fallaron (ver assets[].error)"
return out
# Alias con el nombre completo del ID para descubrimiento por convención.
generate_asset_pack_oneshot = comfyui_generate_asset_pack_oneshot
if __name__ == "__main__":
import json
res = comfyui_generate_asset_pack_oneshot(
[
{"kind": "item_icon", "subject": "magic sword"},
{"kind": "enemy_creature", "subject": "goblin warrior"},
],
checkpoint="dreamshaper_8.safetensors",
style="dark fantasy, hand-painted",
base_seed=42,
size=512,
out_dir="/tmp/asset_pack_demo",
)
print(json.dumps(res, indent=2, ensure_ascii=False))
@@ -0,0 +1,188 @@
"""Tests de comfyui_generate_asset_pack_oneshot (offline; sin ComfyUI ni GPU).
Cubre el contrato del pipeline sin tocar la red:
- Golden: dispatch puro inyecta checkpoint/style/seed compartidos en cada workflow.
- Edge: el `kind` Godot por categoría (sprite/vfx/tileset) y seed = base_seed + i.
- Edge: emote exige el campo `expression`.
- Error: kind desconocido -> ok=False con la lista de kinds soportados, sin red.
- Flujo completo con submit/wait/fetch mockeados -> rutas + coherencia.
"""
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_asset_pack_oneshot as packmod # noqa: E402
from pipelines.comfyui_generate_asset_pack_oneshot import ( # noqa: E402
_build_item_workflow,
comfyui_generate_asset_pack_oneshot,
supported_kinds,
)
# --- Golden / Edge: dispatch puro (sin red) ---
def test_dispatch_shares_checkpoint_and_seed():
wf, subject_eff, godot_kind, seed = _build_item_workflow(
{"kind": "item_icon", "subject": "magic sword"},
checkpoint="dreamshaper_8.safetensors", style="dark fantasy, hand-painted",
lora=None, lora_strength=1.0, base_seed=42, size=512, index=0,
)
blob = json.dumps(wf)
assert godot_kind == "sprite"
assert seed == 42 # base_seed(42) + index(0)
assert "dreamshaper_8.safetensors" in blob # checkpoint compartido
assert "dark fantasy, hand-painted" in subject_eff # style inyectado al subject
assert "magic sword" in subject_eff
assert "42" in blob # seed en el sampler
def test_seed_is_base_plus_index():
_, _, _, seed = _build_item_workflow(
{"kind": "enemy_creature", "subject": "goblin"},
checkpoint="dreamshaper_8.safetensors", style="", lora=None,
lora_strength=1.0, base_seed=100, size=None, index=3,
)
assert seed == 103
def test_godot_kind_per_category():
# >=4 tipos distintos cubiertos por el dispatch, con su bucket Godot correcto.
cases = {
"item_icon": "sprite",
"seamless_tile": "tileset",
"particle_texture": "vfx",
"weather_overlay": "vfx",
"ui_hud": "sprite",
}
for kind, expected in cases.items():
_, _, godot_kind, _ = _build_item_workflow(
{"kind": kind, "subject": "x"},
checkpoint="dreamshaper_8.safetensors", style="", lora=None,
lora_strength=1.0, base_seed=0, size=None, index=0,
)
assert godot_kind == expected, kind
def test_emote_requires_expression():
with pytest.raises(ValueError, match="expression"):
_build_item_workflow(
{"kind": "emote", "subject": "hero"}, # falta 'expression'
checkpoint="dreamshaper_8.safetensors", style="", lora=None,
lora_strength=1.0, base_seed=0, size=None, index=0,
)
# con expression sí construye
wf, _, godot_kind, _ = _build_item_workflow(
{"kind": "emote", "subject": "hero", "expression": "angry"},
checkpoint="dreamshaper_8.safetensors", style="", lora=None,
lora_strength=1.0, base_seed=0, size=None, index=0,
)
assert godot_kind == "sprite" and isinstance(wf, dict)
def test_per_item_kwargs_passthrough():
# 'tier' es un kwarg propio de achievement_badge -> debe llegar al builder.
wf, _, _, _ = _build_item_workflow(
{"kind": "achievement_badge", "subject": "first kill", "tier": "platinum"},
checkpoint="dreamshaper_8.safetensors", style="", lora=None,
lora_strength=1.0, base_seed=0, size=None, index=0,
)
assert "platinum" in json.dumps(wf)
# --- Error: kind desconocido no toca la red ---
def test_unknown_kind_fails_without_network():
res = comfyui_generate_asset_pack_oneshot(
[{"kind": "item_icon", "subject": "ok"},
{"kind": "does_not_exist", "subject": "x"}],
checkpoint="dreamshaper_8.safetensors",
)
assert res["ok"] is False
assert "no soportado" in res["error"]
# el error enumera kinds reales soportados
assert any(k in res["error"] for k in supported_kinds())
assert res["generated"] == 0
def test_empty_pack():
res = comfyui_generate_asset_pack_oneshot([])
assert res["ok"] is False and "vacío" in res["error"]
# --- Flujo completo con transporte mockeado ---
def test_full_flow_mocked(monkeypatch, tmp_path):
calls = {"submit": 0}
def fake_submit(workflow, server="127.0.0.1:8188", **kw):
calls["submit"] += 1
# el server debe llegar normalizado (sin esquema)
assert "://" not in server
return {"prompt_id": f"pid-{calls['submit']}", "client_id": "c"}
def fake_wait(prompt_id, server="127.0.0.1:8188", timeout=600.0, **kw):
return {"9": {"images": [{"filename": f"{prompt_id}.png",
"subfolder": "", "type": "output"}]}}
def fake_fetch(filename, *, subfolder="", type_="output",
server="127.0.0.1:8188", dest_dir=".", timeout=60.0):
path = os.path.join(dest_dir, filename)
with open(path, "wb") as fh:
fh.write(b"\x89PNG\r\n")
return {"ok": True, "path": path}
monkeypatch.setattr(packmod, "comfyui_submit_workflow", fake_submit)
monkeypatch.setattr(packmod, "comfyui_wait_result", fake_wait)
monkeypatch.setattr(packmod, "comfyui_fetch_output_image", fake_fetch)
res = comfyui_generate_asset_pack_oneshot(
[{"kind": "item_icon", "subject": "magic sword"},
{"kind": "enemy_creature", "subject": "goblin warrior"}],
checkpoint="dreamshaper_8.safetensors", style="dark fantasy, hand-painted",
base_seed=42, size=512, server="http://127.0.0.1:8188",
out_dir=str(tmp_path),
)
assert res["ok"] is True, res["error"]
assert res["generated"] == 2 and res["failed"] == 0 and res["count"] == 2
assert [a["seed"] for a in res["assets"]] == [42, 43]
assert all(a["prompt_id"].startswith("pid-") for a in res["assets"])
assert all(os.path.isfile(a["path"]) for a in res["assets"])
assert "dreamshaper_8.safetensors" in res["coherence_note"]
assert res["checkpoint"] == "dreamshaper_8.safetensors"
def test_one_asset_fails_others_survive(monkeypatch, tmp_path):
def fake_submit(workflow, server="127.0.0.1:8188", **kw):
# falla el segundo submit (simula OOM/rechazo), el primero pasa
fake_submit.n += 1
if fake_submit.n == 2:
raise RuntimeError("simulated OOM")
return {"prompt_id": "pid-1", "client_id": "c"}
fake_submit.n = 0
def fake_wait(prompt_id, server="127.0.0.1:8188", timeout=600.0, **kw):
return {"9": {"images": [{"filename": "a.png", "subfolder": "", "type": "output"}]}}
def fake_fetch(filename, *, subfolder="", type_="output",
server="127.0.0.1:8188", dest_dir=".", timeout=60.0):
path = os.path.join(dest_dir, filename)
open(path, "wb").write(b"x")
return {"ok": True, "path": path}
monkeypatch.setattr(packmod, "comfyui_submit_workflow", fake_submit)
monkeypatch.setattr(packmod, "comfyui_wait_result", fake_wait)
monkeypatch.setattr(packmod, "comfyui_fetch_output_image", fake_fetch)
res = comfyui_generate_asset_pack_oneshot(
[{"kind": "item_icon", "subject": "sword"},
{"kind": "prop_object", "subject": "barrel"}],
out_dir=str(tmp_path),
)
assert res["ok"] is False # un fallo aislado -> pack no perfecto
assert res["generated"] == 1 and res["failed"] == 1
assert res["assets"][0]["ok"] is True
assert res["assets"][1]["ok"] is False and "OOM" in res["assets"][1]["error"]