From e57da2f6d57d5a2a55d62ae85b61648cb52b683e Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 26 Jun 2026 19:43:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(gamedev):=20ronda=201=20=E2=80=94=20pixeli?= =?UTF-8?q?ze=20+=20luma=E2=86=92alpha=20+=20export-godot=20(grupo=20gamed?= =?UTF-8?q?ev)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tres funciones CPU-only del lote gamedev 2D + 2 helpers puros + grupo de capacidad: - comfyui_pixelize_image_py_ml (impure): Fase 2 pixelart — downscale nearest + cuantizacion a N colores / paleta fija (game-boy/pico-8/nes) + re-upscale nearest. - comfyui_matting_luma_to_alpha_py_ml (impure): frame VFX sobre negro -> RGBA por luminancia ponderada (translucidos con additive blend). - comfyui_export_asset_to_godot_py_pipelines (impure): puente ComfyUI -> Godot 4 — copia a res://assets/ por kind + .import por tipo + filtro Nearest si pixelart + reimport headless best-effort. Compone los 2 helpers puros. - godot_map_asset_dir_py_core, godot_clean_asset_name_py_core (pure): nucleos reutilizables del pipeline. - docs/capabilities/gamedev-2d.md + INDEX: grupo nuevo gamedev. Tests 33/33 verdes (offline PIL/numpy). Golden real verificado: asset de ~/ComfyUI/output -> /tmp/godot_test_proj con .import correcto y reimport headless real de Godot 4.7. Sin GPU, sin red, sin tocar proyectos del usuario. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/capabilities/INDEX.md | 1 + docs/capabilities/gamedev-2d.md | 72 ++++ .../functions/core/godot_clean_asset_name.md | 54 +++ .../functions/core/godot_clean_asset_name.py | 49 +++ .../core/godot_clean_asset_name_test.py | 31 ++ python/functions/core/godot_map_asset_dir.md | 51 +++ python/functions/core/godot_map_asset_dir.py | 46 +++ .../core/godot_map_asset_dir_test.py | 29 ++ .../ml/comfyui_matting_luma_to_alpha.md | 75 +++++ .../ml/comfyui_matting_luma_to_alpha.py | 139 ++++++++ .../ml/comfyui_matting_luma_to_alpha_test.py | 90 +++++ python/functions/ml/comfyui_pixelize_image.md | 82 +++++ python/functions/ml/comfyui_pixelize_image.py | 206 ++++++++++++ .../ml/comfyui_pixelize_image_test.py | 81 +++++ .../comfyui_export_asset_to_godot.md | 83 +++++ .../comfyui_export_asset_to_godot.py | 311 ++++++++++++++++++ .../comfyui_export_asset_to_godot_test.py | 129 ++++++++ 17 files changed, 1529 insertions(+) create mode 100644 docs/capabilities/gamedev-2d.md create mode 100644 python/functions/core/godot_clean_asset_name.md create mode 100644 python/functions/core/godot_clean_asset_name.py create mode 100644 python/functions/core/godot_clean_asset_name_test.py create mode 100644 python/functions/core/godot_map_asset_dir.md create mode 100644 python/functions/core/godot_map_asset_dir.py create mode 100644 python/functions/core/godot_map_asset_dir_test.py create mode 100644 python/functions/ml/comfyui_matting_luma_to_alpha.md create mode 100644 python/functions/ml/comfyui_matting_luma_to_alpha.py create mode 100644 python/functions/ml/comfyui_matting_luma_to_alpha_test.py create mode 100644 python/functions/ml/comfyui_pixelize_image.md create mode 100644 python/functions/ml/comfyui_pixelize_image.py create mode 100644 python/functions/ml/comfyui_pixelize_image_test.py create mode 100644 python/functions/pipelines/comfyui_export_asset_to_godot.md create mode 100644 python/functions/pipelines/comfyui_export_asset_to_godot.py create mode 100644 python/functions/pipelines/comfyui_export_asset_to_godot_test.py diff --git a/docs/capabilities/INDEX.md b/docs/capabilities/INDEX.md index 73f2309f..73a49bb2 100644 --- a/docs/capabilities/INDEX.md +++ b/docs/capabilities/INDEX.md @@ -14,6 +14,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu | Grupo | N | Que cubre | |---|---|---| +| [gamedev](gamedev-2d.md) | 5 | Assets 2D para Godot: post-proceso (pixelize, luma->alpha) + puente de assets a proyectos Godot 4 (carpetas + .import + reimport headless) | | [registry](registry.md) | 17 | Auditoria y monitorizacion del propio registry: copied-code, uses-functions, unused, proposals, telemetria | | [systemd](systemd.md) | 14 | Generar, instalar, restart y status de unit files systemd via SSH (deploys a VPS) | | [ssh](ssh.md) | 19 | Operar hosts remotos via SSH: config, conn, ejecutar comandos, port-forward, deploys con SCP/rsync | diff --git a/docs/capabilities/gamedev-2d.md b/docs/capabilities/gamedev-2d.md new file mode 100644 index 00000000..2f951c3e --- /dev/null +++ b/docs/capabilities/gamedev-2d.md @@ -0,0 +1,72 @@ +# Capability group: `gamedev` — assets 2D para Godot (post-proceso + puente) + +Cluster de funciones para producir y mover assets 2D de juego entre **ComfyUI** +(generación) y **Godot 4** (consumo). Cubre el **post-proceso determinista** de los +crudos generados (pixelizar, recortar a alpha) y el **puente de assets** que los +coloca en un proyecto Godot con sus import settings correctos. Todas son CPU-only: +ninguna toca la GPU ni descarga modelos. + +Tag plano del grupo: `gamedev`. Filtro: `mcp__registry__fn_search query="" tag="gamedev"`. + +Documento hermano del grupo `comfyui` (generación de imágenes/video/3D): este grupo +empieza donde el crudo ya existe en `~/ComfyUI/output/`. Diseño del puente: +`docs/comfyui-godot-integration.md`. Planes origen: `reports/0135` (pixelart), +`reports/0140` (VFX), `reports/0137`/`0138` (puente Godot). + +## Funciones del grupo + +| ID | Firma corta | Qué hace | +|---|---|---| +| `comfyui_pixelize_image_py_ml` | `(src, dst, *, downscale=8, colors=16, palette=None, dither=False, upscale_back=True) -> dict` | Pixel-perfect: downscale nearest + cuantización a N colores o paleta fija (game-boy/pico-8/nes). Fase 2 pixelart. Impura (I/O). | +| `comfyui_matting_luma_to_alpha_py_ml` | `(image_path, *, out_path=None, gamma=1.0, black_point=0.0, premultiply=False, luma_weights=(.299,.587,.114)) -> dict` | Frame VFX sobre negro -> RGBA usando luminancia como alpha (translúcidos con additive blend). Impura (I/O). | +| `comfyui_export_asset_to_godot_py_pipelines` | `(asset_path, kind, godot_project, *, name=None, reimport=True, godot_bin=None) -> dict` | Copia el asset a `res://assets//` por `kind` + escribe `.import` + filtro Nearest si pixelart + reimport headless. Pipeline impuro. | +| `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. | + +## Ejemplo canónico end-to-end + +Flujo: crudo generado en ComfyUI -> pixelizar -> exportar a Godot con Nearest. + +```python +import sys, os +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions")) +from ml.comfyui_pixelize_image import comfyui_pixelize_image +from ml.comfyui_matting_luma_to_alpha import comfyui_matting_luma_to_alpha +from pipelines.comfyui_export_asset_to_godot import comfyui_export_asset_to_godot + +OUT = os.path.expanduser("~/ComfyUI/output") +PROJ = os.path.expanduser("~/gamedev/projects/crossy_road") + +# 1. Pixelizar un sprite crudo (SDXL+pixel-art-xl) a 16 colores +px = comfyui_pixelize_image(f"{OUT}/hero_00001_.png", "/tmp/hero_pixel.png", + downscale=8, colors=16) + +# 2. Exportarlo a Godot como pixelart (carpeta sprites/, filtro Nearest, reimport) +exp = comfyui_export_asset_to_godot("/tmp/hero_pixel.png", "pixelart", PROJ) +print(exp["dest_res_path"], exp["pixelart_filter_set"], exp["reimported"]) + +# Rama VFX: frame de humo sobre negro -> RGBA -> carpeta vfx/ +rgba = comfyui_matting_luma_to_alpha(f"{OUT}/vfx_loop_00007_.png", gamma=1.2, black_point=0.04) +comfyui_export_asset_to_godot(rgba["out_path"], "vfx", PROJ) +``` + +## Fronteras (qué NO cubre) + +- **Generación**: este grupo no genera imágenes. La Fase 1 (SDXL + LoRA + `pixel-art-xl`, AnimateDiff loop, etc.) vive en el grupo `comfyui` y necesita GPU. +- **Montaje de spritesheet** (grid RGBA + JSON sidecar) y **builders de workflow** + (pixelart/VFX-loop): pendientes de la ronda siguiente (planes `reports/0135` F3/F4 + y `reports/0140` F2/F3). Cuando se añadan, van a este mismo grupo. +- **Paletas lospec por red** (`load_lospec_palette`): no incluido. `pixelize` usa + paletas fijas embebidas (game-boy/pico-8/nes) o lista de hex, sin HTTP. +- **TileSet / SpriteFrames `.tres`**: Godot no los deriva solos; `export_asset_to_godot` + copia la textura y avisa, pero no genera el recurso (paso manual o futura función). + +## Prerequisitos / notas + +- **Godot CLI** para el reimport headless: autodetectado en PATH y en + `~/godot/Godot_v4.7-stable_linux.x86_64`. Si falta, `export_asset_to_godot` deja el + `.import` escrito y lo anota (no falla). +- **Filtro Nearest (Godot 4)**: se setea global en `project.godot` + (`default_texture_filter=0`), no por `.import`. La función lo asegura para pixelart. +- CPU-only: Pillow + numpy del venv del registry. Cero VRAM, cero red. diff --git a/python/functions/core/godot_clean_asset_name.md b/python/functions/core/godot_clean_asset_name.md new file mode 100644 index 00000000..6a412d76 --- /dev/null +++ b/python/functions/core/godot_clean_asset_name.md @@ -0,0 +1,54 @@ +--- +name: godot_clean_asset_name +kind: function +lang: py +domain: core +version: "1.0.0" +purity: pure +signature: "def godot_clean_asset_name(filename: str, *, override: str | None = None) -> str" +description: "Normaliza el nombre de archivo de un asset salido de ComfyUI (patron _NNNNN_.) a un nombre limpio y seguro para res://: snake_case, minusculas, sin el sufijo numerico _NNNNN_, sin espacios ni caracteres raros, conservando la extension. Pura: solo manipula el string, no toca disco. Pensada para el puente ComfyUI -> Godot." +tags: [godot, gamedev, core, assets, naming] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +params: + - name: filename + desc: "nombre o ruta del asset de origen (se toma solo el basename)." + - name: override + desc: "nombre base deseado sin extension; si se pasa se usa en lugar del nombre de origen (igual se normaliza a snake_case y se le anade la extension del origen). keyword-only." +output: "nombre de archivo limpio 'snake_case.ext' (ext en minuscula; sin punto si el origen no tenia extension)." +tested: true +tests: [test_strips_comfyui_suffix, test_normalizes_spaces_and_case, test_takes_basename_from_path, test_override_name, test_empty_fallback] +test_file_path: "python/functions/core/godot_clean_asset_name_test.py" +file_path: "python/functions/core/godot_clean_asset_name.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions")) +from core.godot_clean_asset_name import godot_clean_asset_name + +godot_clean_asset_name("svd_motion_hi_00001_.webp") # 'svd_motion_hi.webp' +godot_clean_asset_name("/x/ComfyUI/output/bench_00042_.png") # 'bench.png' +godot_clean_asset_name("x.png", override="explosion loop") # 'explosion_loop.png' +``` + +## Cuando usarla + +Al exportar un asset de ComfyUI a Godot, para renombrar el `_NNNNN_.` +a un nombre semántico y seguro (lo usa `comfyui_export_asset_to_godot`). También +para limpiar cualquier nombre de archivo antes de meterlo en `res://`. + +## Gotchas + +- Solo quita el sufijo numérico `_NNNNN_` del **nombre de origen**; con `override` + se respetan los dígitos finales (p.ej. `hero_v2` no pierde el `2`). +- Reduce cualquier carácter no `[a-z0-9_]` a `_` y colapsa repetidos; un nombre que + quede vacío cae a `"asset"`. +- Conserva la extensión en minúscula; si el origen no tiene extensión, devuelve solo + el nombre sin punto. diff --git a/python/functions/core/godot_clean_asset_name.py b/python/functions/core/godot_clean_asset_name.py new file mode 100644 index 00000000..62c24180 --- /dev/null +++ b/python/functions/core/godot_clean_asset_name.py @@ -0,0 +1,49 @@ +"""godot_clean_asset_name — normaliza el nombre de archivo de un asset para Godot. + +Funcion pura: toma el nombre de un asset salido de ComfyUI (patron +`_NNNNN_.`, p.ej. `svd_motion_hi_00001_.webp`) y devuelve un nombre +limpio, semantico y seguro para `res://`: snake_case, minusculas, sin el sufijo +numerico `_NNNNN_`, sin espacios ni caracteres raros, conservando la extension. +No toca disco. Pensada para el puente ComfyUI -> Godot (renombrar al exportar). +""" + +import os +import re + +_NNNNN_SUFFIX = re.compile(r"_\d{3,}_?$") # sufijo numerico de ComfyUI: _00001_ / _00001 +_NON_SAFE = re.compile(r"[^a-z0-9_]+") # cualquier cosa que no sea snake_case +_MULTI_US = re.compile(r"_{2,}") + + +def godot_clean_asset_name(filename: str, *, override: str | None = None) -> str: + """Limpia el nombre de un asset a snake_case seguro conservando la extension. + + Args: + filename: nombre o ruta del asset de origen (se toma solo el basename). + override: nombre base deseado (sin extension); si se pasa, se usa en lugar + del nombre de origen (igual se normaliza a snake_case y se le anade la + extension del origen). keyword-only. + + Returns: + Nombre de archivo limpio "snake_case.ext" (ext en minuscula, sin punto si + el origen no tenia extension). + """ + base = os.path.basename(filename or "") + stem, ext = os.path.splitext(base) + ext = ext.lower() + + raw = override if override is not None else stem + name = raw.strip().lower().replace(" ", "_").replace("-", "_") + if override is None: + name = _NNNNN_SUFFIX.sub("", name) # quita _00001_ solo del nombre de origen + name = _NON_SAFE.sub("_", name) + name = _MULTI_US.sub("_", name).strip("_") + if not name: + name = "asset" + return f"{name}{ext}" if ext else name + + +if __name__ == "__main__": + import sys + + print(godot_clean_asset_name(sys.argv[1] if len(sys.argv) > 1 else "svd_motion_hi_00001_.webp")) diff --git a/python/functions/core/godot_clean_asset_name_test.py b/python/functions/core/godot_clean_asset_name_test.py new file mode 100644 index 00000000..cd6428e3 --- /dev/null +++ b/python/functions/core/godot_clean_asset_name_test.py @@ -0,0 +1,31 @@ +"""Tests de godot_clean_asset_name (pura, offline).""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from core.godot_clean_asset_name import godot_clean_asset_name # noqa: E402 + + +def test_strips_comfyui_suffix(): + assert godot_clean_asset_name("svd_motion_hi_00001_.webp") == "svd_motion_hi.webp" + assert godot_clean_asset_name("3d_robot_mesh_00001_.glb") == "3d_robot_mesh.glb" + + +def test_normalizes_spaces_and_case(): + assert godot_clean_asset_name("My Hero Sprite.PNG") == "my_hero_sprite.png" + assert godot_clean_asset_name("fire-flare.png") == "fire_flare.png" + + +def test_takes_basename_from_path(): + assert godot_clean_asset_name("/home/x/ComfyUI/output/bench_00042_.png") == "bench.png" + + +def test_override_name(): + assert godot_clean_asset_name("svd_00001_.webp", override="explosion loop") == "explosion_loop.webp" + # override conserva digitos finales (no es sufijo ComfyUI a quitar) + assert godot_clean_asset_name("x.png", override="hero_v2") == "hero_v2.png" + + +def test_empty_fallback(): + assert godot_clean_asset_name("_00001_.png") == "asset.png" diff --git a/python/functions/core/godot_map_asset_dir.md b/python/functions/core/godot_map_asset_dir.md new file mode 100644 index 00000000..3587cd6a --- /dev/null +++ b/python/functions/core/godot_map_asset_dir.md @@ -0,0 +1,51 @@ +--- +name: godot_map_asset_dir +kind: function +lang: py +domain: core +version: "1.0.0" +purity: pure +signature: "def godot_map_asset_dir(kind: str) -> str" +description: "Mapea el tipo de asset (sprite, pixelart, tileset, vfx, sfx, music, model) a su subcarpeta canonica bajo res://assets/ de un proyecto Godot 4, segun la convencion del puente ComfyUI -> Godot. Pura: solo resuelve una ruta relativa POSIX, no toca disco. kind desconocido -> ValueError." +tags: [godot, gamedev, core, assets, mapping] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +params: + - name: kind + desc: "tipo de asset: 'sprite', 'pixelart', 'tileset', 'vfx', 'sfx', 'music' o 'model' (case-insensitive, se ignoran espacios)." +output: "subruta relativa POSIX bajo assets/ (p.ej. 'sprites', 'tilesets', 'audio/music', 'models')." +tested: true +tests: [test_all_kinds, test_case_insensitive, test_unknown_kind_raises] +test_file_path: "python/functions/core/godot_map_asset_dir_test.py" +file_path: "python/functions/core/godot_map_asset_dir.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions")) +from core.godot_map_asset_dir import godot_map_asset_dir + +godot_map_asset_dir("pixelart") # 'sprites' +godot_map_asset_dir("music") # 'audio/music' +godot_map_asset_dir("model") # 'models' +``` + +## Cuando usarla + +Al construir la ruta destino de un asset dentro de un proyecto Godot (lo usa el +pipeline `comfyui_export_asset_to_godot`). Centraliza la convención tipo -> carpeta +para no esparcir el mapeo por varias funciones. + +## Gotchas + +- `pixelart` cae en `sprites/` (el pixelart no es una carpeta, es un atributo: lo + que cambia es el filtro Nearest, no la ubicación). +- `kind` desconocido lanza `ValueError` (no devuelve un default silencioso). +- Devuelve siempre subrutas POSIX (`/`), no rutas absolutas: el llamador antepone + `/assets/`. diff --git a/python/functions/core/godot_map_asset_dir.py b/python/functions/core/godot_map_asset_dir.py new file mode 100644 index 00000000..5dc01523 --- /dev/null +++ b/python/functions/core/godot_map_asset_dir.py @@ -0,0 +1,46 @@ +"""godot_map_asset_dir — mapea el tipo de asset a su subcarpeta res://assets/. + +Funcion pura: dado el `kind` de un asset (sprite, pixelart, tileset, vfx, sfx, +music, model) devuelve el subdirectorio canonico bajo `res://assets/` donde debe +vivir en un proyecto Godot 4, segun la convencion del puente ComfyUI -> Godot +(docs/comfyui-godot-integration.md). No toca disco: solo resuelve una ruta +relativa. `kind` desconocido -> ValueError. +""" + +# kind -> subruta relativa bajo assets/ (convencion del puente ComfyUI->Godot). +_KIND_TO_DIR = { + "sprite": "sprites", + "pixelart": "sprites", + "tileset": "tilesets", + "vfx": "vfx", + "sfx": "audio/sfx", + "music": "audio/music", + "model": "models", +} + + +def godot_map_asset_dir(kind: str) -> str: + """Resuelve la subcarpeta de assets para un tipo de asset Godot. + + Args: + kind: tipo de asset: "sprite", "pixelart", "tileset", "vfx", "sfx", + "music" o "model". + + Returns: + Subruta relativa (POSIX) bajo `assets/`, p.ej. "sprites" o "audio/music". + + Raises: + ValueError: si `kind` no es uno de los tipos soportados. + """ + key = (kind or "").strip().lower() + if key not in _KIND_TO_DIR: + raise ValueError( + f"kind desconocido: {kind!r}. Soportados: {sorted(_KIND_TO_DIR)}" + ) + return _KIND_TO_DIR[key] + + +if __name__ == "__main__": + import sys + + print(godot_map_asset_dir(sys.argv[1] if len(sys.argv) > 1 else "pixelart")) diff --git a/python/functions/core/godot_map_asset_dir_test.py b/python/functions/core/godot_map_asset_dir_test.py new file mode 100644 index 00000000..9b60c2cc --- /dev/null +++ b/python/functions/core/godot_map_asset_dir_test.py @@ -0,0 +1,29 @@ +"""Tests de godot_map_asset_dir (pura, offline).""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from core.godot_map_asset_dir import godot_map_asset_dir # noqa: E402 + + +def test_all_kinds(): + assert godot_map_asset_dir("sprite") == "sprites" + assert godot_map_asset_dir("pixelart") == "sprites" + assert godot_map_asset_dir("tileset") == "tilesets" + assert godot_map_asset_dir("vfx") == "vfx" + assert godot_map_asset_dir("sfx") == "audio/sfx" + assert godot_map_asset_dir("music") == "audio/music" + assert godot_map_asset_dir("model") == "models" + + +def test_case_insensitive(): + assert godot_map_asset_dir("PixelArt") == "sprites" + assert godot_map_asset_dir(" MODEL ") == "models" + + +def test_unknown_kind_raises(): + with pytest.raises(ValueError): + godot_map_asset_dir("hologram") diff --git a/python/functions/ml/comfyui_matting_luma_to_alpha.md b/python/functions/ml/comfyui_matting_luma_to_alpha.md new file mode 100644 index 00000000..3f3609e3 --- /dev/null +++ b/python/functions/ml/comfyui_matting_luma_to_alpha.md @@ -0,0 +1,75 @@ +--- +name: comfyui_matting_luma_to_alpha +kind: function +lang: py +domain: ml +version: "1.0.0" +purity: impure +signature: "def comfyui_matting_luma_to_alpha(image_path: str, *, out_path: str | None = None, gamma: float = 1.0, black_point: float = 0.0, premultiply: bool = False, luma_weights: tuple = (0.299, 0.587, 0.114)) -> dict" +description: "Convierte un frame de VFX (humo, fuego, destello, magia) generado sobre fondo negro en RGBA usando la LUMINANCIA ponderada como canal alpha (brillante -> opaco, negro -> transparente). Tecnica gamedev correcta para translucidos con additive blend: preserva todo el falloff, a diferencia de un matting binario (rembg) que aplana el gradiente. Nucleo numpy puro, CPU-only: sin GPU, sin red. Devuelve {ok, out_path, size, error}. Impura solo por la lectura/escritura de disco." +tags: [comfyui, gamedev, vfx, matting, alpha, luminance, ml, pil, numpy] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_py_core" +imports: [] +params: + - name: image_path + desc: "ruta del frame de entrada (generado sobre fondo negro)." + - name: out_path + desc: "ruta del PNG RGBA de salida; si None se escribe junto al original con sufijo '_rgba.png'. keyword-only." + - name: gamma + desc: "ajuste del falloff del alpha (>1 sube alpha de medios tonos, <1 lo baja). Por defecto 1.0 (lineal). keyword-only." + - name: black_point + desc: "piso [0,1) que limpia gris de fondo residual sin matar el efecto (re-normaliza el rango restante). Por defecto 0.0. keyword-only." + - name: premultiply + desc: "si True exporta RGB*alpha (materiales premultiplied / additive del motor). keyword-only." + - name: luma_weights + desc: "pesos (r,g,b) de la luminancia. Rec.601 (0.299,0.587,0.114) por defecto; Rec.709 = (0.2126,0.7152,0.0722). keyword-only." +output: "dict con ok (bool), out_path (str, ruta del PNG RGBA), size ([w,h]), error (str, vacio si OK)." +tested: true +tests: [test_golden_radial_glow, test_edge_all_black_is_transparent, test_edge_preserves_color, test_edge_input_rgba_recomputes_from_luma, test_edge_gamma_raises_midtone_alpha, test_edge_premultiply, test_error_missing_path] +test_file_path: "python/functions/ml/comfyui_matting_luma_to_alpha_test.py" +file_path: "python/functions/ml/comfyui_matting_luma_to_alpha.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions")) +from ml.comfyui_matting_luma_to_alpha import comfyui_matting_luma_to_alpha + +# Frame de humo generado sobre negro -> RGBA listo para additive blend en Godot/Unity +res = comfyui_matting_luma_to_alpha( + os.path.expanduser("~/ComfyUI/output/vfx_loop_00007_.png"), + out_path="/tmp/smoke_0007_rgba.png", + gamma=1.2, # realza la cola tenue del humo + black_point=0.04, # limpia el gris residual del fondo +) +# {'ok': True, 'out_path': '/tmp/smoke_0007_rgba.png', 'size': [512, 512], 'error': ''} +``` + +## Cuando usarla + +Tras generar frames de un efecto (humo/fuego/magia/explosion) **sobre fondo negro** +(prompt "on pure black background"), antes de montar el spritesheet o exportarlo a +un motor. Es el recorte a alpha correcto para translucidos. NO uses rembg / matting +binario para humo/fuego: rompe el degradado. Para sprites SOLIDOS (personaje, item) +con silueta definida, rembg sigue siendo lo adecuado, no esta funcion. + +## Gotchas + +- **El frame debe estar sobre NEGRO**: el alpha sale de la luminancia, asi que un + fondo no-negro se vuelve parcialmente opaco. Genera con "on pure black background". +- Ignora cualquier alpha de la imagen de entrada: **recomputa** alpha desde la luma + del RGB (un input RGBA con alpha previo se sobreescribe). +- `black_point` limpia el gris de fondo si el modelo no dio negro puro; subirlo + demasiado come las zonas tenues del efecto. +- `premultiply=True` para materiales additive/premultiplied; deja `False` si el + motor espera alpha straight. +- Todo error es **dict `ok=False`** (no excepcion): path inexistente, `gamma<=0`, + `black_point` fuera de [0,1) -> `error` explica. +- CPU-only (numpy): no toca la GPU ni el servidor ComfyUI. El nodo `ImageToMask` de + ComfyUI NO sirve para esto (hace channel-pick, no luminancia ponderada). diff --git a/python/functions/ml/comfyui_matting_luma_to_alpha.py b/python/functions/ml/comfyui_matting_luma_to_alpha.py new file mode 100644 index 00000000..28e62c25 --- /dev/null +++ b/python/functions/ml/comfyui_matting_luma_to_alpha.py @@ -0,0 +1,139 @@ +"""comfyui_matting_luma_to_alpha — frame VFX sobre negro -> RGBA por luminancia. + +Convierte un frame de efecto (humo, fuego, destello, magia) generado sobre fondo +negro en una imagen RGBA donde el canal alpha es la LUMINANCIA del pixel: brillante +-> opaco, negro -> transparente. Es la tecnica gamedev correcta para translucidos: +en el motor se compone con additive blend y el solape brilla de forma natural, +preservando todo el falloff (cosa que un matting binario tipo rembg destruye). + +Por que en Python y no con nodos ComfyUI: el `ImageToMask` del server hace +channel-pick (extrae un canal R/G/B/A), NO la luminancia ponderada perceptual +0.299R + 0.587G + 0.114B. La luma correcta es trivial en numpy. Ademas mantiene el +matting fuera del server: cero VRAM, headless, determinista, testeable. + +El nucleo `_luma_to_rgba` es puro (numpy); la funcion publica es impura solo por la +lectura/escritura de disco. +""" + +import os + + +def _luma_to_rgba(rgb01, *, gamma: float, black_point: float, + premultiply: bool, weights): + """Nucleo puro: HxWx3 float [0,1] (efecto sobre negro) -> HxWx4 float [0,1]. + + Args: + rgb01: numpy array HxWx3 en [0,1]. + gamma: realza (>1) o atenua (<1) el alpha en los medios tonos. + black_point: piso [0,1) que limpia el gris residual de fondo antes de + mapear a alpha (re-normaliza el rango restante a [0,1]). + premultiply: si True multiplica el RGB de salida por el alpha (materiales + premultiplied / additive del motor). + weights: pesos de luminancia (r, g, b). Rec.601 (0.299,0.587,0.114) por + defecto; Rec.709 = (0.2126,0.7152,0.0722). + + Returns: + numpy array HxWx4 en [0,1]. + """ + import numpy as np + + w = np.asarray(weights, dtype=np.float32) + luma = rgb01 @ w # HxW, luminancia ponderada + if black_point > 0.0: + luma = np.clip((luma - black_point) / (1.0 - black_point), 0.0, 1.0) + if gamma != 1.0: + luma = np.power(np.clip(luma, 0.0, 1.0), 1.0 / gamma) + alpha = np.clip(luma, 0.0, 1.0) + rgb_out = rgb01 * alpha[..., None] if premultiply else rgb01 + return np.dstack([rgb_out, alpha]) + + +def comfyui_matting_luma_to_alpha( + image_path: str, + *, + out_path: str | None = None, + gamma: float = 1.0, + black_point: float = 0.0, + premultiply: bool = False, + luma_weights: tuple = (0.299, 0.587, 0.114), +) -> dict: + """Convierte un frame sobre negro a RGBA usando la luminancia como alpha. + + Args: + image_path: ruta del frame de entrada (generado sobre fondo negro). + out_path: ruta del PNG RGBA de salida; si None se escribe junto al + original con sufijo "_rgba.png". keyword-only. + gamma: ajuste del falloff del alpha (>1 sube alpha de medios tonos, + <1 lo baja). Por defecto 1.0 (lineal). keyword-only. + black_point: piso [0,1) para limpiar gris de fondo residual sin matar el + efecto. Por defecto 0.0. keyword-only. + premultiply: exporta RGB*alpha si True (additive/premultiplied del motor). + keyword-only. + luma_weights: pesos (r,g,b) de la luminancia. keyword-only. + + Returns: + dict con: + - ok (bool): True si se convirtio y guardo. + - out_path (str): ruta del PNG RGBA generado. + - size (list[int]): [w, h] de la imagen. + - error (str): mensaje de error; cadena vacia si todo OK. + """ + out = {"ok": False, "out_path": "", "size": [0, 0], "error": ""} + + try: + import numpy as np + from PIL import Image + except ImportError as exc: + out["error"] = f"falta dependencia (PIL/numpy): {exc}" + return out + + if not os.path.isfile(image_path): + out["error"] = f"image_path no existe: {image_path!r}" + return out + if not (0.0 <= float(black_point) < 1.0): + out["error"] = f"black_point debe estar en [0,1), recibido {black_point!r}" + return out + if float(gamma) <= 0.0: + out["error"] = f"gamma debe ser > 0, recibido {gamma!r}" + return out + + try: + with Image.open(image_path) as src: + # Ignora cualquier alpha de entrada: recomputa desde la luma del RGB. + rgb = np.asarray(src.convert("RGB"), dtype=np.float32) / 255.0 + except OSError as exc: + out["error"] = f"no se pudo leer/decodificar {image_path!r}: {exc}" + return out + + rgba01 = _luma_to_rgba( + rgb, gamma=float(gamma), black_point=float(black_point), + premultiply=bool(premultiply), weights=luma_weights, + ) + rgba8 = (np.clip(rgba01, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8) + result = Image.fromarray(rgba8, mode="RGBA") + + if out_path is None: + base, _ = os.path.splitext(image_path) + out_path = base + "_rgba.png" + try: + os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True) + result.save(out_path) + except OSError as exc: + out["error"] = f"no se pudo escribir {out_path!r}: {exc}" + return out + + out.update(ok=True, out_path=out_path, size=list(result.size)) + return out + + +if __name__ == "__main__": + import json + import sys + + if len(sys.argv) < 2: + print("uso: comfyui_matting_luma_to_alpha.py [out]", + file=sys.stderr) + sys.exit(2) + src = sys.argv[1] + dst = sys.argv[2] if len(sys.argv) > 2 else None + print(json.dumps(comfyui_matting_luma_to_alpha(src, out_path=dst), indent=2)) diff --git a/python/functions/ml/comfyui_matting_luma_to_alpha_test.py b/python/functions/ml/comfyui_matting_luma_to_alpha_test.py new file mode 100644 index 00000000..861d9d5a --- /dev/null +++ b/python/functions/ml/comfyui_matting_luma_to_alpha_test.py @@ -0,0 +1,90 @@ +"""Tests de comfyui_matting_luma_to_alpha (offline, sin red ni GPU; PIL/numpy).""" + +import os +import sys + +import numpy as np +from PIL import Image + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from ml.comfyui_matting_luma_to_alpha import comfyui_matting_luma_to_alpha # noqa: E402 + + +def _radial_glow_png(path, size=64): + """Degradado radial blanco (centro brillante) sobre negro.""" + yy, xx = np.mgrid[0:size, 0:size] + cx = cy = (size - 1) / 2.0 + dist = np.sqrt((xx - cx) ** 2 + (yy - cy) ** 2) + val = np.clip(1.0 - dist / (size / 2.0), 0.0, 1.0) + val = val / val.max() # normaliza el pico a 1.0 (centro = blanco puro) + arr = (val[..., None] * 255).astype(np.uint8).repeat(3, axis=2) + Image.fromarray(arr, "RGB").save(path) + return path + + +def test_golden_radial_glow(tmp_path): + src = _radial_glow_png(str(tmp_path / "glow.png")) + res = comfyui_matting_luma_to_alpha(src) + assert res["ok"] is True, res["error"] + img = Image.open(res["out_path"]) + assert img.mode == "RGBA" + a = np.asarray(img)[..., 3] + assert a[32, 32] >= 250 # centro brillante -> opaco + assert a[0, 0] <= 5 # esquina negra -> transparente + + +def test_edge_all_black_is_transparent(tmp_path): + src = str(tmp_path / "black.png") + Image.fromarray(np.zeros((32, 32, 3), np.uint8), "RGB").save(src) + res = comfyui_matting_luma_to_alpha(src) + assert res["ok"] is True # efecto invisible, NO error + a = np.asarray(Image.open(res["out_path"]))[..., 3] + assert int(a.max()) == 0 # alpha todo 0 + + +def test_edge_preserves_color(tmp_path): + """Un frame rojo brillante conserva su RGB y gana alpha por su luma.""" + src = str(tmp_path / "red.png") + Image.fromarray(np.full((16, 16, 3), [255, 0, 0], np.uint8), "RGB").save(src) + res = comfyui_matting_luma_to_alpha(src) + rgba = np.asarray(Image.open(res["out_path"])) + assert tuple(rgba[8, 8, :3]) == (255, 0, 0) # color preservado (sin premultiply) + assert rgba[8, 8, 3] > 0 # alpha desde luma del rojo + + +def test_edge_input_rgba_recomputes_from_luma(tmp_path): + """Input ya RGBA con alpha=0: se ignora y se recomputa alpha desde la luma.""" + src = str(tmp_path / "rgba_in.png") + arr = np.zeros((16, 16, 4), np.uint8) + arr[..., :3] = 255 # blanco + arr[..., 3] = 0 # alpha previo nulo (debe ignorarse) + Image.fromarray(arr, "RGBA").save(src) + res = comfyui_matting_luma_to_alpha(src) + a = np.asarray(Image.open(res["out_path"]))[..., 3] + assert int(a.min()) >= 250 # alpha recomputado desde RGB blanco + + +def test_edge_gamma_raises_midtone_alpha(tmp_path): + """gamma>1 sube el alpha de los medios tonos respecto a gamma=1.""" + src = str(tmp_path / "mid.png") + Image.fromarray(np.full((16, 16, 3), 128, np.uint8), "RGB").save(src) + base = comfyui_matting_luma_to_alpha(src, out_path=str(tmp_path / "g1.png"), gamma=1.0) + hi = comfyui_matting_luma_to_alpha(src, out_path=str(tmp_path / "g2.png"), gamma=2.0) + a1 = int(np.asarray(Image.open(base["out_path"]))[8, 8, 3]) + a2 = int(np.asarray(Image.open(hi["out_path"]))[8, 8, 3]) + assert a2 > a1 + + +def test_edge_premultiply(tmp_path): + """premultiply multiplica el RGB por el alpha (medio tono -> RGB reducido).""" + src = str(tmp_path / "mid.png") + Image.fromarray(np.full((16, 16, 3), 128, np.uint8), "RGB").save(src) + res = comfyui_matting_luma_to_alpha(src, premultiply=True) + rgba = np.asarray(Image.open(res["out_path"]))[8, 8] + assert rgba[0] < 128 # RGB premultiplicado por alpha (<1) + + +def test_error_missing_path(tmp_path): + res = comfyui_matting_luma_to_alpha(str(tmp_path / "nope.png")) + assert res["ok"] is False + assert "no existe" in res["error"] diff --git a/python/functions/ml/comfyui_pixelize_image.md b/python/functions/ml/comfyui_pixelize_image.md new file mode 100644 index 00000000..f3c299e8 --- /dev/null +++ b/python/functions/ml/comfyui_pixelize_image.md @@ -0,0 +1,82 @@ +--- +name: comfyui_pixelize_image +kind: function +lang: py +domain: ml +version: "1.0.0" +purity: impure +signature: "def comfyui_pixelize_image(src_path: str, dst_path: str, *, downscale: int = 8, colors: int = 16, palette=None, dither: bool = False, upscale_back: bool = True) -> dict" +description: "Post-proceso pixel-perfect (Fase 2 pixelart): imagen -> downscale nearest-neighbor por factor (colapsa cada bloque borroso a un pixel duro) -> cuantizacion a N colores (MEDIANCUT) o a una paleta fija embebida (game-boy / pico-8 / nes / lista de hex) -> opcional re-upscale nearest conservando los pixeles duros. Convierte el 'pixelart borroso de IA' en pixelart de verdad. Nucleo PIL puro, CPU-only: sin GPU, sin red. Devuelve {ok, out_path, size, n_colors_final, error}. Impura solo por la lectura/escritura de disco." +tags: [comfyui, gamedev, pixelart, ml, pil, quantize, palette, image] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_py_core" +imports: [] +params: + - name: src_path + desc: "ruta de la imagen de entrada (PNG/JPG/...)." + - name: dst_path + desc: "ruta del PNG de salida (se crea el directorio si falta)." + - name: downscale + desc: "factor entero de reduccion nearest (>=1); cada bloque downscale x downscale px colapsa a 1 pixel. 1 = solo cuantiza sin colapsar el grid. keyword-only." + - name: colors + desc: "numero de colores objetivo (2..256) cuando palette es None; cuantizacion MEDIANCUT determinista. keyword-only." + - name: palette + desc: "None (auto a 'colors'), nombre de paleta fija builtin ('game-boy','pico-8','nes') o lista de hex ('#rrggbb'/'rrggbb'). Una paleta fija ignora 'colors'. keyword-only." + - name: dither + desc: "aplica Floyd-Steinberg al cuantizar (off por defecto = pixelart limpio). keyword-only." + - name: upscale_back + desc: "re-escala nearest al tamano original (preview con pixeles duros). False deja la imagen pequena. keyword-only." +output: "dict con ok (bool), out_path (str), size ([w,h] de la imagen final), n_colors_final (int, colores distintos del resultado), error (str, vacio si OK)." +tested: true +tests: [test_golden_downscale_quantize, test_no_upscale_back_keeps_small, test_edge_fixed_palette_game_boy, test_edge_palette_list_hex, test_edge_downscale_1_only_quantizes, test_error_missing_src, test_error_downscale_zero, test_error_bad_palette] +test_file_path: "python/functions/ml/comfyui_pixelize_image_test.py" +file_path: "python/functions/ml/comfyui_pixelize_image.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions")) +from ml.comfyui_pixelize_image import comfyui_pixelize_image + +# Crudo SDXL+pixel-art-xl 1024x1024 -> pixelart 16 colores, grid de 128 +res = comfyui_pixelize_image( + os.path.expanduser("~/ComfyUI/output/pixelart_00001_.png"), + "/tmp/hero_pixel.png", + downscale=8, colors=16, +) +# {'ok': True, 'out_path': '/tmp/hero_pixel.png', 'size': [1024, 1024], 'n_colors_final': 16, 'error': ''} + +# Forzar la paleta retro Game Boy (4 colores) y dejar la imagen pequena (sin upscale) +comfyui_pixelize_image("/tmp/hero_pixel.png", "/tmp/hero_gb.png", + palette="game-boy", upscale_back=False) +``` + +## Cuando usarla + +Fase 2 del pipeline pixelart: tras generar el crudo (SDXL + LoRA `pixel-art-xl`), +para colapsar el grid borroso a pixeles duros y limitar la paleta. Tambien sirve +para "pixelizar" cualquier imagen (sprite, render, foto) a estetica retro sin +tocar la GPU. Para llevar el resultado a Godot con filtro Nearest: +`comfyui_export_asset_to_godot(out, "pixelart", proj)`. + +## Gotchas + +- **nearest, no lanczos**: el downscale usa NEAREST a proposito; interpolar suave + re-difumina el grid. No lo cambies por "calidad". +- `palette` fija (game-boy/pico-8/nes o lista de hex) **ignora** `colors`. La + paleta se rellena internamente repitiendo su ultimo color para que `quantize` + no introduzca un negro extra por entradas vacias (bug arreglado en v1.0.0). +- `downscale` con `upscale_back=False` deja la imagen de `w//downscale x h//downscale`: + util para spritesheets compactos; con `True` vuelve al tamano original con bordes + duros (preview). +- Todo error es **dict `ok=False`** (no excepcion): `src_path` inexistente, + `downscale<1`, paleta desconocida -> `error` explica. No crashea ni borra nada. +- `n_colors_final` cuenta colores distintos reales del PNG escrito; con paleta fija + puede ser **menor** que el tamano de la paleta si la imagen no usa todos. +- CPU-only: no toca la GPU ni el servidor ComfyUI; corre en cualquier interprete + con Pillow. diff --git a/python/functions/ml/comfyui_pixelize_image.py b/python/functions/ml/comfyui_pixelize_image.py new file mode 100644 index 00000000..1e71f904 --- /dev/null +++ b/python/functions/ml/comfyui_pixelize_image.py @@ -0,0 +1,206 @@ +"""comfyui_pixelize_image — post-proceso pixel-perfect de una imagen (Fase 2 pixelart). + +Convierte una imagen "pixelart borroso de IA" (o cualquier PNG) en pixelart de +verdad: downscale nearest-neighbor por factor (colapsa cada bloque borroso a un +pixel duro) -> cuantizacion a N colores o a una paleta fija (NES / Game Boy / +PICO-8) -> opcional re-upscale nearest conservando los pixeles duros. + +Es la Fase 2 del pipeline pixelart (la Fase 1, generar con SDXL + pixel-art-xl +LoRA, vive en otra funcion). Determinista y CPU-only: el nucleo `_pixelize_pil` +es puro (PIL), no toca la GPU ni la red. La funcion publica es impura solo por la +lectura/escritura de disco (mismo patron que comfyui_build_grid). + +Por que nearest y no lanczos/cubic: el downscale tiene que colapsar cada bloque +borroso a UN pixel; cualquier interpolacion suave re-difumina el grid. La +cuantizacion (Image.quantize) limita la paleta, que es lo que da la identidad +retro y elimina el ruido de cientos de colores de la difusion. +""" + +import os + + +# Paletas retro fijas (hex sin '#'). Embebidas: cero red, deterministas. +# Fuentes: lospec.com (game-boy, pico-8) + paleta NES clasica reducida. +_BUILTIN_PALETTES = { + "game-boy": ["0f380f", "306230", "8bac0f", "9bbc0f"], + "pico-8": [ + "000000", "1d2b53", "7e2553", "008751", "ab5236", "5f574f", + "c2c3c7", "fff1e8", "ff004d", "ffa300", "ffec27", "00e436", + "29adff", "83769c", "ff77a8", "ffccaa", + ], + # NES: subconjunto representativo de 16 colores de la paleta 2C02. + "nes": [ + "000000", "fcfcfc", "f8f8f8", "bcbcbc", "7c7c7c", "0000fc", + "0078f8", "00b800", "b8f818", "f83800", "e40058", "f878f8", + "fca044", "f8b800", "503000", "00a800", + ], +} + + +def _hex_to_rgb(h: str) -> tuple: + """'1a2b3c' o '#1a2b3c' -> (26, 43, 60).""" + h = h.strip().lstrip("#") + if len(h) != 6: + raise ValueError(f"hex de color invalido: {h!r} (esperado rrggbb)") + return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4)) + + +def _normalize_palette(palette): + """palette: None | nombre builtin (str) | lista de hex -> list[(r,g,b)] | None.""" + if palette is None: + return None + if isinstance(palette, str): + key = palette.strip().lower().replace("_", "-") + if key not in _BUILTIN_PALETTES: + raise ValueError( + f"paleta builtin desconocida: {palette!r}. " + f"Disponibles: {sorted(_BUILTIN_PALETTES)} o pasa una lista de hex." + ) + hexes = _BUILTIN_PALETTES[key] + else: + hexes = list(palette) + if not hexes: + raise ValueError("lista de paleta vacia") + return [_hex_to_rgb(h) for h in hexes] + + +def _pixelize_pil(img, downscale, colors, palette_rgb, dither, upscale_back): + """Nucleo puro PIL: imagen RGB -> imagen RGB pixelizada. + + Args: + img: PIL.Image de entrada. + downscale: factor entero de reduccion nearest (>=1). + colors: numero de colores objetivo si no hay paleta fija. + palette_rgb: lista [(r,g,b), ...] o None (cuantizacion automatica). + dither: aplica Floyd-Steinberg al cuantizar si True. + upscale_back: re-escala nearest al tamano original si True. + + Returns: + PIL.Image RGB pixelizada. + """ + from PIL import Image + + img = img.convert("RGB") + w, h = img.size + # 1. downscale nearest -> grid real (colapsa bloques borrosos a 1 pixel). + sw, sh = max(1, w // downscale), max(1, h // downscale) + small = img.resize((sw, sh), Image.NEAREST) + d = Image.Dither.FLOYDSTEINBERG if dither else Image.Dither.NONE + # 2. cuantizar la paleta. + if palette_rgb: + pal_img = Image.new("P", (1, 1)) + flat = [c for rgb in palette_rgb for c in rgb][:768] + # Rellena las 256 entradas repitiendo el ultimo color real (no ceros): asi + # quantize no puede introducir un color extra (negro) por las entradas vacias. + if flat: + last = flat[-3:] + flat += last * ((768 - len(flat)) // 3) + flat += [0] * (768 - len(flat)) + pal_img.putpalette(flat) + small = small.quantize(palette=pal_img, dither=d) + else: + n = max(2, min(256, int(colors))) + small = small.quantize(colors=n, method=Image.Quantize.MEDIANCUT, dither=d) + out = small.convert("RGB") + # 3. opcional: re-upscale nearest para preview/entrega (pixeles duros). + if upscale_back: + out = out.resize((w, h), Image.NEAREST) + return out + + +def comfyui_pixelize_image( + src_path: str, + dst_path: str, + *, + downscale: int = 8, + colors: int = 16, + palette=None, + dither: bool = False, + upscale_back: bool = True, +) -> dict: + """Pixeliza una imagen y la guarda como PNG. + + Args: + src_path: ruta de la imagen de entrada (PNG/JPG/...). + dst_path: ruta del PNG de salida. + downscale: factor entero de reduccion nearest-neighbor; cada bloque de + downscale x downscale px colapsa a 1 pixel. 1 = solo cuantiza, sin + colapsar el grid. keyword-only. + colors: numero de colores objetivo (2..256) cuando palette es None; + cuantizacion MEDIANCUT determinista. keyword-only. + palette: None (cuantizacion automatica a `colors`), nombre de paleta + fija builtin ("game-boy", "pico-8", "nes") o lista de hex + ("#rrggbb"/"rrggbb"). Una paleta fija ignora `colors`. keyword-only. + dither: aplica Floyd-Steinberg al cuantizar (por defecto off, pixelart + limpio). keyword-only. + upscale_back: re-escala nearest al tamano original (preview con pixeles + duros). False deja la imagen pequena (sw x sh). keyword-only. + + Returns: + dict con: + - ok (bool): True si se pixelizo y guardo. + - out_path (str): ruta del PNG generado. + - size (list[int]): [w, h] de la imagen final. + - n_colors_final (int): numero de colores distintos en el resultado. + - error (str): mensaje de error; cadena vacia si todo OK. + """ + out = {"ok": False, "out_path": "", "size": [0, 0], "n_colors_final": 0, "error": ""} + + try: + from PIL import Image + except ImportError: + out["error"] = "PIL (Pillow) no esta instalado en este interprete" + return out + + if not os.path.isfile(src_path): + out["error"] = f"src_path no existe: {src_path!r}" + return out + if int(downscale) < 1: + out["error"] = f"downscale debe ser >= 1, recibido {downscale!r}" + return out + + try: + palette_rgb = _normalize_palette(palette) + except ValueError as exc: + out["error"] = f"paleta invalida: {exc}" + return out + + try: + with Image.open(src_path) as src: + result = _pixelize_pil( + src, int(downscale), colors, palette_rgb, bool(dither), bool(upscale_back) + ) + except OSError as exc: + out["error"] = f"no se pudo leer/decodificar {src_path!r}: {exc}" + return out + + try: + dst_dir = os.path.dirname(os.path.abspath(dst_path)) + os.makedirs(dst_dir, exist_ok=True) + result.save(dst_path) + except OSError as exc: + out["error"] = f"no se pudo escribir {dst_path!r}: {exc}" + return out + + colors_found = result.getcolors(maxcolors=1 << 20) + n_final = len(colors_found) if colors_found is not None else -1 + out.update( + ok=True, out_path=dst_path, size=list(result.size), n_colors_final=n_final + ) + return out + + +if __name__ == "__main__": + import json + import sys + + if len(sys.argv) < 3: + print("uso: comfyui_pixelize_image.py [downscale] [colors] [palette]", + file=sys.stderr) + sys.exit(2) + src, dst = sys.argv[1], sys.argv[2] + ds = int(sys.argv[3]) if len(sys.argv) > 3 else 8 + col = int(sys.argv[4]) if len(sys.argv) > 4 else 16 + pal = sys.argv[5] if len(sys.argv) > 5 else None + print(json.dumps(comfyui_pixelize_image(src, dst, downscale=ds, colors=col, palette=pal), + indent=2)) diff --git a/python/functions/ml/comfyui_pixelize_image_test.py b/python/functions/ml/comfyui_pixelize_image_test.py new file mode 100644 index 00000000..99d0e1ef --- /dev/null +++ b/python/functions/ml/comfyui_pixelize_image_test.py @@ -0,0 +1,81 @@ +"""Tests de comfyui_pixelize_image (offline, sin red ni GPU; PIL/numpy).""" + +import os +import sys + +import numpy as np +from PIL import Image + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from ml.comfyui_pixelize_image import comfyui_pixelize_image # noqa: E402 + + +def _noisy_png(path, w=256, h=256): + """PNG ruidoso con cientos de colores (simula crudo borroso de IA).""" + rng = np.random.default_rng(7) + arr = rng.integers(0, 256, size=(h, w, 3), dtype=np.uint8) + Image.fromarray(arr, "RGB").save(path) + return path + + +def test_golden_downscale_quantize(tmp_path): + src = _noisy_png(str(tmp_path / "raw.png")) + dst = str(tmp_path / "pixel.png") + res = comfyui_pixelize_image(src, dst, downscale=8, colors=16) + assert res["ok"] is True, res["error"] + assert os.path.isfile(dst) + assert res["size"] == [256, 256] # upscale_back=True conserva tamano + assert res["n_colors_final"] <= 16 # cuantizado a <=16 colores + + +def test_no_upscale_back_keeps_small(tmp_path): + src = _noisy_png(str(tmp_path / "raw.png")) + dst = str(tmp_path / "small.png") + res = comfyui_pixelize_image(src, dst, downscale=8, colors=16, upscale_back=False) + assert res["ok"] is True + assert res["size"] == [32, 32] # 256//8 + + +def test_edge_fixed_palette_game_boy(tmp_path): + src = _noisy_png(str(tmp_path / "raw.png")) + dst = str(tmp_path / "gb.png") + res = comfyui_pixelize_image(src, dst, palette="game-boy") + assert res["ok"] is True, res["error"] + assert res["n_colors_final"] <= 4 # paleta Game Boy = 4 colores + + +def test_edge_palette_list_hex(tmp_path): + src = _noisy_png(str(tmp_path / "raw.png")) + dst = str(tmp_path / "pal.png") + res = comfyui_pixelize_image(src, dst, palette=["#000000", "#ffffff", "#ff0000"]) + assert res["ok"] is True + assert res["n_colors_final"] <= 3 + + +def test_edge_downscale_1_only_quantizes(tmp_path): + src = _noisy_png(str(tmp_path / "raw.png")) + dst = str(tmp_path / "q.png") + res = comfyui_pixelize_image(src, dst, downscale=1, colors=8) + assert res["ok"] is True + assert res["size"] == [256, 256] + assert res["n_colors_final"] <= 8 + + +def test_error_missing_src(tmp_path): + res = comfyui_pixelize_image(str(tmp_path / "nope.png"), str(tmp_path / "o.png")) + assert res["ok"] is False + assert "no existe" in res["error"] + + +def test_error_downscale_zero(tmp_path): + src = _noisy_png(str(tmp_path / "raw.png")) + res = comfyui_pixelize_image(src, str(tmp_path / "o.png"), downscale=0) + assert res["ok"] is False + assert "downscale" in res["error"] + + +def test_error_bad_palette(tmp_path): + src = _noisy_png(str(tmp_path / "raw.png")) + res = comfyui_pixelize_image(src, str(tmp_path / "o.png"), palette="not-a-palette") + assert res["ok"] is False + assert "paleta" in res["error"].lower() diff --git a/python/functions/pipelines/comfyui_export_asset_to_godot.md b/python/functions/pipelines/comfyui_export_asset_to_godot.md new file mode 100644 index 00000000..d3cd8f85 --- /dev/null +++ b/python/functions/pipelines/comfyui_export_asset_to_godot.md @@ -0,0 +1,83 @@ +--- +name: comfyui_export_asset_to_godot +kind: pipeline +lang: py +domain: pipelines +version: "1.0.0" +purity: impure +signature: "def comfyui_export_asset_to_godot(asset_path: str, kind: str, godot_project: str, *, name: str | None = None, reimport: bool = True, godot_bin: str | None = None) -> dict" +description: "Puente ComfyUI -> Godot: copia un asset generado a la subcarpeta correcta de un proyecto Godot 4 (res://assets/{sprites,tilesets,vfx,audio/{sfx,music},models}/ segun kind), escribe el .import adecuado al tipo (textura Lossless+mipmaps off / audio loop on-off / escena glTF), asegura el filtro Nearest del proyecto si es pixelart (default_texture_filter=0 en project.godot), y lanza reimport headless si el binario de Godot esta disponible. Compone godot_map_asset_dir + godot_clean_asset_name. NO toca ningun proyecto salvo el que se le pasa; idempotente (preserva uid existente). Devuelve {ok, dest_res_path, dest_abs_path, import_path, import_written, pixelart_filter_set, reimported, warnings, error}. Impuro: disco + subprocess." +tags: [godot, gamedev, comfyui, pipelines, export, import, launcher] +uses_functions: [godot_map_asset_dir_py_core, godot_clean_asset_name_py_core] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_py_core" +imports: [godot_map_asset_dir_py_core, godot_clean_asset_name_py_core] +params: + - name: asset_path + desc: "ruta del asset de origen (p.ej. en ~/ComfyUI/output/)." + - name: kind + desc: "tipo de asset: 'sprite', 'pixelart', 'tileset', 'vfx', 'sfx', 'music' o 'model'." + - name: godot_project + desc: "ruta raiz del proyecto Godot destino (debe contener project.godot)." + - name: name + desc: "nombre base deseado para el archivo destino (sin extension); None lo deriva del origen (snake_case sin _NNNNN_). keyword-only." + - name: reimport + desc: "si True intenta reimport headless con el binario de Godot. keyword-only." + - name: godot_bin + desc: "ruta del binario de Godot; None autodetecta (PATH y rutas conocidas como ~/godot/Godot_v4.7-stable_linux.x86_64). keyword-only." +output: "dict con ok (bool), dest_res_path (res://...), dest_abs_path, import_path, import_written (bool), pixelart_filter_set (bool), reimported (bool), warnings (list[str]), error (str)." +tested: true +tests: [test_golden_pixelart, test_edge_music_loop_on, test_edge_sfx_loop_off, test_edge_model_glb_scene, test_edge_tileset_warns, test_idempotent_preserves_uid, test_error_missing_asset, test_error_bad_kind, test_error_not_a_godot_project, test_godot_cli_absent_leaves_import] +test_file_path: "python/functions/pipelines/comfyui_export_asset_to_godot_test.py" +file_path: "python/functions/pipelines/comfyui_export_asset_to_godot.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions")) +from pipelines.comfyui_export_asset_to_godot import comfyui_export_asset_to_godot + +# Pixelart -> sprites/ con Nearest global + reimport headless automatico +res = comfyui_export_asset_to_godot( + os.path.expanduser("~/ComfyUI/output/hero_00001_.png"), + "pixelart", + os.path.expanduser("~/gamedev/projects/crossy_road"), +) +# {'ok': True, 'dest_res_path': 'res://assets/sprites/hero.png', +# 'import_written': True, 'pixelart_filter_set': True, 'reimported': True, ...} + +# Musica con loop ON +comfyui_export_asset_to_godot("/tmp/theme.ogg", "music", "/tmp/godot_proj") +``` + +## Cuando usarla + +Para llevar un asset recien generado en ComfyUI a un proyecto Godot sin tocar el +import a mano: resuelve carpeta + escribe el `.import` por tipo + arregla el filtro +pixelart + reimporta. Es el ultimo paso del flujo gen -> (pixelize / matting) -> +export. Si solo quieres el mapeo o el nombre limpio sin copiar, usa los helpers +`godot_map_asset_dir` / `godot_clean_asset_name` directamente. + +## Gotchas + +- **Seguridad**: solo escribe en el `godot_project` que se le pasa. Aborta con + `ok=False` si ese directorio no tiene `project.godot`. Nunca toca otros proyectos. +- **Filtro Nearest (Godot 4)**: NO es un campo del `.import` por defecto; se setea + global en `project.godot` (`default_texture_filter=0`). Con `kind="pixelart"` la + función lo asegura (idempotente). Para sprites no-pixelart deja el filtro por + defecto del proyecto. +- **Godot no deriva TileSet ni SpriteFrames**: para `tileset`/`vfx` copia la textura + y **avisa** (en `warnings`) que el `.tres` hay que crearlo en editor o por script. +- **reimport best-effort**: si no encuentra el binario de Godot, deja el `.import` + escrito y lo anota en `warnings` (`reimported=False`) — no falla. Autodetecta en + PATH y en `~/godot/Godot_v4.7-stable_linux.x86_64`; override con `godot_bin`. +- **Idempotente**: re-exportar preserva el `uid://` de un `.import` ya existente (no + rompe las referencias de escenas que ya usan el asset). +- **WEBP animado (SVD)**: para usarlo como animación en Godot, descomponer en frames + PNG antes; este pipeline lo copia como textura tal cual (no lo descompone). +- El `.import` escrito es el mínimo correcto por tipo; Godot completa los campos que + falten en el primer reimport. diff --git a/python/functions/pipelines/comfyui_export_asset_to_godot.py b/python/functions/pipelines/comfyui_export_asset_to_godot.py new file mode 100644 index 00000000..864425a6 --- /dev/null +++ b/python/functions/pipelines/comfyui_export_asset_to_godot.py @@ -0,0 +1,311 @@ +"""comfyui_export_asset_to_godot — lleva un asset generado a un proyecto Godot 4. + +Pipeline del puente ComfyUI -> Godot (docs/comfyui-godot-integration.md): copia un +asset salido de `~/ComfyUI/output/` a la subcarpeta correcta de un proyecto Godot +(`res://assets/{sprites,tilesets,vfx,audio/{sfx,music},models}/` segun `kind`), +escribe el archivo `.import` adecuado al tipo (textura / audio / escena glTF) con +los settings clave, asegura el filtro Nearest del proyecto si el asset es pixelart, +y lanza un reimport headless si el binario de Godot esta disponible. + +Compone las funciones puras del registry: + godot_map_asset_dir_py_core (kind -> subcarpeta) + godot_clean_asset_name_py_core (nombre de origen -> snake_case seguro) + +Y orquesta el I/O especifico del puente (copia, .import, project.godot, reimport). + +Seguridad: NO toca ningun proyecto salvo el `godot_project` que se le pasa +explicitamente. Aborta si ese directorio no es un proyecto Godot (sin +`project.godot`). Idempotente: preserva el `uid://` de un `.import` ya existente +(no rompe referencias de escenas en uso). Impuro: lee/escribe disco + subprocess +(reimport). +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import sys + +_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) + +from core.godot_clean_asset_name import godot_clean_asset_name +from core.godot_map_asset_dir import godot_map_asset_dir + +# Tipos que importan como textura 2D. +_TEXTURE_KINDS = {"sprite", "pixelart", "tileset", "vfx"} +# Candidatos de binario Godot 4 (autodeteccion). +_GODOT_CANDIDATES = [ + "godot", "godot4", + os.path.expanduser("~/godot/Godot_v4.7-stable_linux.x86_64"), +] +_UID_RE = re.compile(r'^uid="(uid://[^"]+)"', re.MULTILINE) + + +def _find_godot_bin(godot_bin: str | None) -> str | None: + """Resuelve el binario de Godot: arg explicito -> PATH -> rutas conocidas.""" + if godot_bin: + return godot_bin if (os.path.isfile(godot_bin) or shutil.which(godot_bin)) else None + for cand in _GODOT_CANDIDATES: + found = cand if os.path.isfile(cand) else shutil.which(cand) + if found: + return found + return None + + +def _existing_uid(import_path: str) -> str | None: + """Lee el uid:// de un .import ya presente (idempotencia), si lo hay.""" + if not os.path.isfile(import_path): + return None + try: + with open(import_path, encoding="utf-8") as fh: + m = _UID_RE.search(fh.read()) + return m.group(1) if m else None + except OSError: + return None + + +def _texture_import(res_path: str, pixelart: bool, uid: str | None) -> str: + """Genera el .import de una textura 2D (Lossless, mipmaps off).""" + uid_line = f'uid="{uid}"\n' if uid else "" + return ( + "[remap]\n\n" + 'importer="texture"\n' + f"{uid_line}" + 'type="CompressedTexture2D"\n\n' + "[deps]\n\n" + f'source_file="{res_path}"\n\n' + "[params]\n\n" + "compress/mode=0\n" # 0 = Lossless (correcto para pixelart/2D) + "mipmaps/generate=false\n" + "process/fix_alpha_border=true\n" + "detect_3d/compress_to=1\n" + ) + + +def _audio_import(res_path: str, kind: str, uid: str | None) -> str: + """Genera el .import de audio: wav (loop segun kind) u ogg (loop segun kind).""" + loop = kind == "music" + ext = os.path.splitext(res_path)[1].lower() + uid_line = f'uid="{uid}"\n' if uid else "" + if ext == ".ogg": + return ( + "[remap]\n\n" + 'importer="oggvorbisstr"\n' + f"{uid_line}" + 'type="AudioStreamOggVorbis"\n\n' + "[deps]\n\n" + f'source_file="{res_path}"\n\n' + "[params]\n\n" + f"loop={'true' if loop else 'false'}\n" + ) + return ( + "[remap]\n\n" + 'importer="wav"\n' + f"{uid_line}" + 'type="AudioStreamWAV"\n\n' + "[deps]\n\n" + f'source_file="{res_path}"\n\n' + "[params]\n\n" + f"edit/loop_mode={1 if loop else 0}\n" # 0=Disabled (sfx), 1=Forward (musica) + "force/mono=false\n" + ) + + +def _scene_import(res_path: str, uid: str | None) -> str: + """Genera el .import de una malla glTF/GLB (escena PackedScene).""" + uid_line = f'uid="{uid}"\n' if uid else "" + return ( + "[remap]\n\n" + 'importer="scene"\n' + f"{uid_line}" + 'importer_version=1\n' + 'type="PackedScene"\n\n' + "[deps]\n\n" + f'source_file="{res_path}"\n\n' + "[params]\n\n" + "nodes/root_type=\"\"\n" + ) + + +def _ensure_pixelart_filter(project_godot: str) -> bool: + """Asegura default_texture_filter=0 (Nearest) en project.godot. Idempotente. + + Returns True si el archivo quedo con el filtro Nearest (ya lo tenia o se anadio). + """ + key = "rendering/textures/canvas_textures/default_texture_filter" + try: + with open(project_godot, encoding="utf-8") as fh: + text = fh.read() + except OSError: + return False + + if re.search(rf"^{re.escape(key)}=0\s*$", text, re.MULTILINE): + return True # ya esta en Nearest + # Reemplaza un valor existente distinto de 0, o anade la clave a [rendering]. + if re.search(rf"^{re.escape(key)}=", text, re.MULTILINE): + text = re.sub(rf"^{re.escape(key)}=.*$", f"{key}=0", text, flags=re.MULTILINE) + elif re.search(r"^\[rendering\]\s*$", text, re.MULTILINE): + text = re.sub(r"^\[rendering\]\s*$", f"[rendering]\n\n{key}=0", text, + count=1, flags=re.MULTILINE) + else: + text = text.rstrip() + f"\n\n[rendering]\n\n{key}=0\n" + try: + with open(project_godot, "w", encoding="utf-8") as fh: + fh.write(text) + return True + except OSError: + return False + + +def comfyui_export_asset_to_godot( + asset_path: str, + kind: str, + godot_project: str, + *, + name: str | None = None, + reimport: bool = True, + godot_bin: str | None = None, +) -> dict: + """Exporta un asset generado a un proyecto Godot con su .import correcto. + + Args: + asset_path: ruta del asset de origen (p.ej. en ~/ComfyUI/output/). + kind: tipo de asset: "sprite", "pixelart", "tileset", "vfx", "sfx", + "music" o "model". + godot_project: ruta raiz del proyecto Godot destino (debe contener + project.godot). + name: nombre base deseado para el archivo destino (sin extension); si + None se deriva del origen (snake_case sin sufijo _NNNNN_). keyword-only. + reimport: si True intenta un reimport headless con el binario de Godot. + keyword-only. + godot_bin: ruta del binario de Godot; None autodetecta (PATH y rutas + conocidas). keyword-only. + + Returns: + dict con: + - ok (bool) + - dest_res_path (str): ruta "res://assets/.../.". + - dest_abs_path (str): ruta absoluta en disco del asset copiado. + - import_path (str): ruta absoluta del .import escrito. + - import_written (bool) + - pixelart_filter_set (bool): True si se aseguro Nearest en project.godot. + - reimported (bool): True si el reimport headless salio con exit 0. + - warnings (list[str]) + - error (str): vacio si OK. + """ + out = { + "ok": False, "dest_res_path": "", "dest_abs_path": "", "import_path": "", + "import_written": False, "pixelart_filter_set": False, "reimported": False, + "warnings": [], "error": "", + } + + if not os.path.isfile(asset_path): + out["error"] = f"asset_path no existe: {asset_path!r}" + return out + + project_godot = os.path.join(godot_project, "project.godot") + if not os.path.isfile(project_godot): + out["error"] = f"no es un proyecto Godot (falta project.godot): {godot_project!r}" + return out + + try: + subdir = godot_map_asset_dir(kind) + except ValueError as exc: + out["error"] = str(exc) + return out + + clean = godot_clean_asset_name(asset_path, override=name) + dest_dir_abs = os.path.join(godot_project, "assets", subdir) + dest_abs = os.path.join(dest_dir_abs, clean) + res_path = f"res://assets/{subdir}/{clean}" + import_abs = dest_abs + ".import" + + # 1. Copiar el asset. + try: + os.makedirs(dest_dir_abs, exist_ok=True) + shutil.copy2(asset_path, dest_abs) + except OSError as exc: + out["error"] = f"no se pudo copiar el asset: {exc}" + return out + out["dest_abs_path"] = dest_abs + out["dest_res_path"] = res_path + + # 2. Escribir el .import segun el tipo (preservando uid existente). + kind_l = kind.strip().lower() + uid = _existing_uid(import_abs) + if kind_l in _TEXTURE_KINDS: + content = _texture_import(res_path, pixelart=(kind_l == "pixelart"), uid=uid) + if kind_l in ("tileset", "vfx"): + out["warnings"].append( + f"{kind_l}: textura copiada; Godot no deriva el TileSet/SpriteFrames " + "solo (crear el .tres en editor o por script)." + ) + elif kind_l in ("sfx", "music"): + content = _audio_import(res_path, kind_l, uid=uid) + elif kind_l == "model": + content = _scene_import(res_path, uid=uid) + else: # no deberia pasar (map_asset_dir ya valido), defensa. + out["error"] = f"kind sin importer: {kind!r}" + return out + + try: + with open(import_abs, "w", encoding="utf-8") as fh: + fh.write(content) + out["import_written"] = True + out["import_path"] = import_abs + except OSError as exc: + out["error"] = f"no se pudo escribir el .import: {exc}" + return out + + # 3. Filtro Nearest del proyecto si es pixelart (mecanismo Godot 4: global). + if kind_l == "pixelart": + if _ensure_pixelart_filter(project_godot): + out["pixelart_filter_set"] = True + else: + out["warnings"].append( + "no se pudo asegurar default_texture_filter=0 en project.godot" + ) + + # 4. Reimport headless (best-effort). Sin binario -> deja el .import y anota. + if reimport: + gbin = _find_godot_bin(godot_bin) + if gbin is None: + out["warnings"].append( + "Godot CLI no encontrado: .import escrito, reimport pendiente " + "(abre el editor o pasa godot_bin)." + ) + else: + try: + proc = subprocess.run( + [gbin, "--headless", "--path", godot_project, "--import"], + capture_output=True, text=True, timeout=180, + ) + out["reimported"] = proc.returncode == 0 + if proc.returncode != 0: + tail = (proc.stderr or proc.stdout or "").strip().splitlines()[-3:] + out["warnings"].append( + f"reimport exit {proc.returncode}: {' / '.join(tail)}" + ) + except subprocess.TimeoutExpired: + out["warnings"].append("reimport headless excedio el timeout (180s)") + except OSError as exc: + out["warnings"].append(f"reimport headless fallo: {exc}") + + out["ok"] = True + return out + + +if __name__ == "__main__": + import json + + if len(sys.argv) < 4: + print("uso: comfyui_export_asset_to_godot.py [name]", + file=sys.stderr) + sys.exit(2) + a, k, p = sys.argv[1], sys.argv[2], sys.argv[3] + nm = sys.argv[4] if len(sys.argv) > 4 else None + print(json.dumps(comfyui_export_asset_to_godot(a, k, p, name=nm), indent=2)) diff --git a/python/functions/pipelines/comfyui_export_asset_to_godot_test.py b/python/functions/pipelines/comfyui_export_asset_to_godot_test.py new file mode 100644 index 00000000..ec8b83cb --- /dev/null +++ b/python/functions/pipelines/comfyui_export_asset_to_godot_test.py @@ -0,0 +1,129 @@ +"""Tests de comfyui_export_asset_to_godot (offline; sin Godot real, reimport mockeado).""" + +import os +import sys + +import numpy as np +from PIL import Image + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from pipelines.comfyui_export_asset_to_godot import comfyui_export_asset_to_godot # noqa: E402 + + +def _godot_proj(tmp_path): + """Crea un proyecto Godot 4 minimo (solo project.godot).""" + proj = tmp_path / "godot_proj" + proj.mkdir() + (proj / "project.godot").write_text( + 'config_version=5\n\n[application]\n\nconfig/name="Test"\n', encoding="utf-8" + ) + return str(proj) + + +def _png(tmp_path, name="hero_00001_.png"): + p = tmp_path / name + Image.fromarray(np.zeros((16, 16, 3), np.uint8), "RGB").save(str(p)) + return str(p) + + +def test_golden_pixelart(tmp_path): + proj = _godot_proj(tmp_path) + asset = _png(tmp_path) + res = comfyui_export_asset_to_godot(asset, "pixelart", proj, reimport=False) + assert res["ok"] is True, res["error"] + # copiado a sprites/ con nombre limpio + assert res["dest_res_path"] == "res://assets/sprites/hero.png" + assert os.path.isfile(os.path.join(proj, "assets", "sprites", "hero.png")) + # .import textura con Lossless + Nearest global del proyecto + imp = open(res["import_path"], encoding="utf-8").read() + assert 'importer="texture"' in imp + assert "compress/mode=0" in imp + assert res["pixelart_filter_set"] is True + pg = open(os.path.join(proj, "project.godot"), encoding="utf-8").read() + assert "rendering/textures/canvas_textures/default_texture_filter=0" in pg + + +def test_edge_music_loop_on(tmp_path): + proj = _godot_proj(tmp_path) + wav = tmp_path / "theme.wav" + wav.write_bytes(b"RIFF....WAVE") # contenido falso: el .import no decodifica aqui + res = comfyui_export_asset_to_godot(str(wav), "music", proj, reimport=False) + assert res["ok"] is True + assert res["dest_res_path"] == "res://assets/audio/music/theme.wav" + imp = open(res["import_path"], encoding="utf-8").read() + assert 'importer="wav"' in imp + assert "edit/loop_mode=1" in imp # musica -> loop ON + + +def test_edge_sfx_loop_off(tmp_path): + proj = _godot_proj(tmp_path) + wav = tmp_path / "step.wav" + wav.write_bytes(b"RIFF....WAVE") + res = comfyui_export_asset_to_godot(str(wav), "sfx", proj, reimport=False) + assert res["ok"] is True + imp = open(res["import_path"], encoding="utf-8").read() + assert "edit/loop_mode=0" in imp # sfx -> loop OFF + + +def test_edge_model_glb_scene(tmp_path): + proj = _godot_proj(tmp_path) + glb = tmp_path / "robot_00001_.glb" + glb.write_bytes(b"glTF....") + res = comfyui_export_asset_to_godot(str(glb), "model", proj, reimport=False) + assert res["ok"] is True + assert res["dest_res_path"] == "res://assets/models/robot.glb" + imp = open(res["import_path"], encoding="utf-8").read() + assert 'importer="scene"' in imp + + +def test_edge_tileset_warns(tmp_path): + proj = _godot_proj(tmp_path) + res = comfyui_export_asset_to_godot(_png(tmp_path, "tiles_00001_.png"), "tileset", proj, + reimport=False) + assert res["ok"] is True + assert any("TileSet" in w for w in res["warnings"]) + + +def test_idempotent_preserves_uid(tmp_path): + proj = _godot_proj(tmp_path) + asset = _png(tmp_path) + r1 = comfyui_export_asset_to_godot(asset, "sprite", proj, reimport=False) + # inyecta un uid en el .import como si Godot ya lo hubiera asignado + imp_path = r1["import_path"] + txt = open(imp_path, encoding="utf-8").read().replace( + 'importer="texture"\n', 'importer="texture"\nuid="uid://abc123xyz"\n' + ) + open(imp_path, "w", encoding="utf-8").write(txt) + r2 = comfyui_export_asset_to_godot(asset, "sprite", proj, reimport=False) + assert 'uid="uid://abc123xyz"' in open(r2["import_path"], encoding="utf-8").read() + + +def test_error_missing_asset(tmp_path): + proj = _godot_proj(tmp_path) + res = comfyui_export_asset_to_godot(str(tmp_path / "nope.png"), "sprite", proj) + assert res["ok"] is False + assert "no existe" in res["error"] + + +def test_error_bad_kind(tmp_path): + proj = _godot_proj(tmp_path) + res = comfyui_export_asset_to_godot(_png(tmp_path), "hologram", proj, reimport=False) + assert res["ok"] is False + + +def test_error_not_a_godot_project(tmp_path): + asset = _png(tmp_path) + res = comfyui_export_asset_to_godot(asset, "sprite", str(tmp_path / "empty")) + assert res["ok"] is False + assert "project.godot" in res["error"] + + +def test_godot_cli_absent_leaves_import(tmp_path): + proj = _godot_proj(tmp_path) + asset = _png(tmp_path) + res = comfyui_export_asset_to_godot(asset, "sprite", proj, reimport=True, + godot_bin="/no/such/godot") + assert res["ok"] is True + assert res["import_written"] is True + assert res["reimported"] is False + assert any("Godot CLI no encontrado" in w for w in res["warnings"])