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"])