diff --git a/docs/capabilities/gamedev-2d.md b/docs/capabilities/gamedev-2d.md index 58494f7d..7f8e6944 100644 --- a/docs/capabilities/gamedev-2d.md +++ b/docs/capabilities/gamedev-2d.md @@ -75,6 +75,12 @@ VFX (ver `reports/0143`). | `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 `_NNNNN_.` 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) 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` inline. `comfyui_build_grid` NO sirve (aplana el alpha sobre fondo oscuro). Pendiente de R4 (plan `reports/0140` F2). -- **Pipelines one-shot** (build → submit → wait → fetch → post en una call) para - pixelart/sprite/VFX: pendientes. Hoy se encadena a mano (ver ejemplos). Candidatos a - promoción a pipeline (issue 0087) cuando el patrón se repita. +- **Pipelines one-shot** (build → submit → wait → fetch → post en una call): el + **set coherente** ya está promovido — `comfyui_generate_asset_pack_oneshot` genera + 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): el builder `comfyui_build_sprite_sheet_workflow` produce UN frame; la orquestación multi-pose es pipeline pendiente (plan `reports/0137` T2). diff --git a/python/functions/pipelines/comfyui_generate_asset_pack_oneshot.md b/python/functions/pipelines/comfyui_generate_asset_pack_oneshot.md new file mode 100644 index 00000000..9ec7cd9a --- /dev/null +++ b/python/functions/pipelines/comfyui_generate_asset_pack_oneshot.md @@ -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 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. diff --git a/python/functions/pipelines/comfyui_generate_asset_pack_oneshot.py b/python/functions/pipelines/comfyui_generate_asset_pack_oneshot.py new file mode 100644 index 00000000..53de3ce1 --- /dev/null +++ b/python/functions/pipelines/comfyui_generate_asset_pack_oneshot.py @@ -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__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)) diff --git a/python/functions/pipelines/comfyui_generate_asset_pack_oneshot_test.py b/python/functions/pipelines/comfyui_generate_asset_pack_oneshot_test.py new file mode 100644 index 00000000..1c10cd50 --- /dev/null +++ b/python/functions/pipelines/comfyui_generate_asset_pack_oneshot_test.py @@ -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"]