feat(gamedev): ronda 1 — pixelize + luma→alpha + export-godot (grupo gamedev)

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/<dir> 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 19:43:47 +02:00
parent 9508fff282
commit e57da2f6d5
17 changed files with 1529 additions and 0 deletions
@@ -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 <prefijo>_NNNNN_.<ext>) 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 `<prefijo>_NNNNN_.<ext>`
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.
@@ -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
`<prefijo>_NNNNN_.<ext>`, 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"))
@@ -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"
@@ -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
`<proyecto>/assets/`.
@@ -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"))
@@ -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")
@@ -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).
@@ -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 <frame_sobre_negro> [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))
@@ -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"]
@@ -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.
@@ -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 <src> <dst> [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))
@@ -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()
@@ -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.
@@ -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/.../<n>.<ext>".
- 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 <asset> <kind> <godot_project> [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))
@@ -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"])