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