feat(gamedev): comfyui_generate_character_set_oneshot — set completo de un personaje coherente (2D + direccional 8-way + 3D)
Promueve a un pipeline one-shot la secuencia que hoy exige 4 llamadas a mano: generar el set COMPLETO de un personaje de juego (imagen base 2D recortada, sprite direccional N-way SV3D/Zero123 y malla 3D Hunyuan3D .glb), todos del MISMO personaje. La coherencia cross-frontera se garantiza por construccion: el direccional y el 3D parten de la MISMA base 2D aplanada (base_flat), no de tres generaciones independientes. Es la culminacion de las 5 fronteras del grupo gamedev-2d (issue 0087). Compone builders del registry (enemy_creature/portrait_avatar/topdown_sprite por introspeccion) + comfyui_flatten_alpha_on_color (nueva, aplana el sprite recortado sobre fondo solido que SV3D/Hunyuan exigen) + comfyui_image_to_3d_oneshot + comfyui_build_directional_sprite_workflow + submit/wait/fetch + export Godot. Secuencial liberando VRAM entre pasos pesados (3D antes que SV3D) para caber en 8 GB; fallo aislado deja set PARCIAL sin abortar. Probado e2e en GPU (RTX 3070 8 GB) con 'armored paladin': base 2D RGBA 512 recortada + malla glTF 395600 triangulos + 8 vistas direccionales SV3D 576, todos del mismo personaje. 9 tests offline verdes (incluye coherencia mockeada). Ver reports/0189. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
---
|
||||
name: comfyui_flatten_alpha_on_color
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_flatten_alpha_on_color(image_path: str, *, out_path: str | None = None, color: tuple = (255, 255, 255), size: int | None = None, resample: str = 'lanczos') -> dict"
|
||||
description: "Aplana (compone) un PNG con canal alpha sobre un fondo de color SOLIDO y opaco, produciendo un PNG RGB sin transparencia. Sirve para preparar un sprite ya recortado (fondo transparente) como entrada de modelos 3D / multivista (SV3D, Stable Zero123, Hunyuan3D), que esperan el sujeto sobre fondo limpio y opaco (tipicamente blanco), no sobre alpha 0. Evita el bug del LoadImage de ComfyUI, que hace convert('RGB') y descarta el alpha mostrando los RGB crudos bajo la transparencia (basura -> mala reconstruccion 3D). Opcionalmente reescala a size x size para encajar el tamano nativo del modelo (SV3D 576, Zero123 256). Nucleo PIL puro, CPU-only: sin GPU, sin red. Devuelve {ok, out_path, size, error}. Impura solo por la lectura/escritura de disco."
|
||||
tags: [comfyui, gamedev-2d, ml, matting, alpha, flatten, composite, 3d, pil, image]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: image_path
|
||||
desc: "ruta del PNG de entrada (idealmente con alpha; un sprite ya recortado). Se expande '~'. Si no existe -> ok=False sin lanzar excepcion."
|
||||
- name: out_path
|
||||
desc: "ruta del PNG RGB de salida; si None se escribe junto al original con sufijo '_flat' antes de la extension. Se crea el directorio destino si falta. keyword-only."
|
||||
- name: color
|
||||
desc: "color de fondo (r,g,b) en 0..255. Si llega (r,g,b,a) se ignora el alpha entrante (el fondo siempre es opaco). Por defecto blanco (255,255,255). keyword-only."
|
||||
- name: size
|
||||
desc: "si no es None, reescala el resultado a size x size (cuadrado), para encajar el tamano nativo del modelo 3D (SV3D 576, Zero123 256). keyword-only."
|
||||
- name: resample
|
||||
desc: "filtro de reescalado: 'lanczos' (por defecto), 'nearest', 'bilinear', 'bicubic', 'area'. String desconocido -> LANCZOS. keyword-only."
|
||||
output: "dict con ok (bool), out_path (str, ruta del PNG RGB; vacio si error), size ([w,h] final), error (str, vacio si OK)."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/ml/comfyui_flatten_alpha_on_color.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_flatten_alpha_on_color import comfyui_flatten_alpha_on_color
|
||||
|
||||
# Sprite recortado (fondo transparente) -> base limpia sobre BLANCO a 576x576,
|
||||
# lista como entrada de SV3D / Stable Zero123 / Hunyuan3D.
|
||||
res = comfyui_flatten_alpha_on_color(
|
||||
os.path.expanduser("~/ComfyUI/output/goblin_cutout.png"),
|
||||
out_path="/tmp/goblin_flat.png",
|
||||
color=(255, 255, 255),
|
||||
size=576,
|
||||
)
|
||||
# {'ok': True, 'out_path': '/tmp/goblin_flat.png', 'size': [576, 576], 'error': ''}
|
||||
|
||||
# Sin out_path: escribe junto al original con sufijo _flat (goblin_cutout_flat.png)
|
||||
comfyui_flatten_alpha_on_color("~/ComfyUI/output/goblin_cutout.png", size=256)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Justo ANTES de meter un sprite recortado (PNG con alpha, p.ej. salida de rembg o de
|
||||
`comfyui_matting_luma_to_alpha`) en un workflow image-to-3D / multivista (SV3D
|
||||
turntable, Stable Zero123, Hunyuan3D). Esos modelos asumen el sujeto sobre un fondo
|
||||
SOLIDO y opaco, no sobre alpha 0; si les pasas el PNG con transparencia tal cual, el
|
||||
`LoadImage` de ComfyUI tira el alpha y reconstruye sobre los RGB basura que habia
|
||||
debajo. Tambien para "poner fondo blanco" a cualquier asset recortado antes de un
|
||||
catalogo o un thumbnail. Usa `size` para dejar la base ya al tamano nativo del modelo.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: lee y escribe disco. Crea el directorio destino si no existe. No borra
|
||||
el original.
|
||||
- **El `LoadImage` de ComfyUI hace `convert("RGB")`** y descarta el alpha: por eso hay
|
||||
que aplanar ANTES, no confiar en que el nodo respete la transparencia.
|
||||
- Todo error es **dict `ok=False`** (no excepcion): path inexistente, fallo de I/O o
|
||||
de decodificado de Pillow -> `error` lo explica. No crashea.
|
||||
- **Fondo blanco por defecto** `(255,255,255)`: es lo que esperan SV3D/Zero123. Cambia
|
||||
`color` solo si el modelo concreto pide otro fondo (algunos preferen gris medio).
|
||||
- Si la imagen ya es RGB (sin alpha) se compone igual (alpha implicito 255): el
|
||||
resultado es la misma imagen sobre el color, idempotente respecto al fondo.
|
||||
- Reescalado con **LANCZOS por defecto** (suave); usa `resample="nearest"` si la base
|
||||
es pixelart y no quieres interpolar el grid.
|
||||
- `color` con 4 componentes `(r,g,b,a)`: el `a` se ignora, el fondo es siempre opaco.
|
||||
@@ -0,0 +1,124 @@
|
||||
"""comfyui_flatten_alpha_on_color — aplana un PNG con alpha sobre un fondo solido.
|
||||
|
||||
Compone una imagen con canal alpha (tipicamente un sprite ya recortado, fondo
|
||||
transparente) sobre un fondo de color SOLIDO y opaco, produciendo un PNG RGB sin
|
||||
transparencia. Sirve para preparar la entrada de modelos 3D / multivista (SV3D,
|
||||
Stable Zero123, Hunyuan3D), que esperan el sujeto sobre un fondo limpio y opaco
|
||||
(normalmente blanco), no sobre alpha 0.
|
||||
|
||||
Por que importa: el `LoadImage` de ComfyUI hace `convert("RGB")` y DESCARTA el
|
||||
canal alpha, mostrando los RGB crudos que habia debajo de la transparencia. Esos
|
||||
RGB suelen ser basura (negro residual, halos, restos del fondo original) -> la
|
||||
reconstruccion 3D sale mala. Aplanar antes garantiza un fondo controlado.
|
||||
|
||||
La funcion es impura solo por la lectura/escritura de disco. Sin red, sin GPU,
|
||||
sin servidor ComfyUI. Solo Pillow.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
_RESAMPLE = {
|
||||
"lanczos": "LANCZOS",
|
||||
"nearest": "NEAREST",
|
||||
"bilinear": "BILINEAR",
|
||||
"bicubic": "BICUBIC",
|
||||
"area": "BOX",
|
||||
}
|
||||
|
||||
|
||||
def comfyui_flatten_alpha_on_color(
|
||||
image_path: str,
|
||||
*,
|
||||
out_path: str | None = None,
|
||||
color: tuple = (255, 255, 255),
|
||||
size: int | None = None,
|
||||
resample: str = "lanczos",
|
||||
) -> dict:
|
||||
"""Aplana un PNG con alpha sobre un fondo de color solido -> PNG RGB.
|
||||
|
||||
Args:
|
||||
image_path: ruta del PNG de entrada (idealmente con alpha; un sprite ya
|
||||
recortado). Se expande `~`. Si no existe devuelve ok=False sin lanzar.
|
||||
out_path: ruta del PNG RGB de salida; si None se escribe junto al original
|
||||
con sufijo "_flat" antes de la extension. Se crea el directorio
|
||||
destino si falta. keyword-only.
|
||||
color: color de fondo (r, g, b) en 0..255. Si llega (r, g, b, a) se ignora
|
||||
el alpha entrante (el fondo siempre es opaco). Por defecto blanco.
|
||||
keyword-only.
|
||||
size: si no es None, redimensiona el resultado a size x size (cuadrado),
|
||||
util para encajar la base al tamano nativo del modelo 3D (SV3D 576,
|
||||
Zero123 256). keyword-only.
|
||||
resample: filtro de redimension ("lanczos" por defecto, "nearest",
|
||||
"bilinear", "bicubic", "area"). String desconocido -> LANCZOS.
|
||||
keyword-only.
|
||||
|
||||
Returns:
|
||||
dict con:
|
||||
- ok (bool): True si se compuso y guardo.
|
||||
- out_path (str): ruta del PNG RGB generado (vacio si error).
|
||||
- size (list[int]): [w, h] final del resultado (ausente si error temprano).
|
||||
- error (str): mensaje de error; cadena vacia si todo OK.
|
||||
"""
|
||||
out = {"ok": False, "out_path": "", "size": [0, 0], "error": ""}
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError as exc:
|
||||
out["error"] = f"falta dependencia (PIL): {exc}"
|
||||
return out
|
||||
|
||||
src_path = os.path.expanduser(image_path)
|
||||
if not os.path.isfile(src_path):
|
||||
out["error"] = f"imagen no existe: {src_path!r}"
|
||||
return out
|
||||
|
||||
# Color de fondo opaco: usa solo (r, g, b), descarta cualquier alpha entrante.
|
||||
rgb = tuple(int(c) for c in color[:3])
|
||||
if len(rgb) != 3:
|
||||
out["error"] = f"color debe tener al menos 3 componentes (r,g,b), recibido {color!r}"
|
||||
return out
|
||||
bg_rgba = rgb + (255,)
|
||||
|
||||
resample_const = getattr(Image, _RESAMPLE.get(str(resample).lower(), "LANCZOS"))
|
||||
|
||||
try:
|
||||
with Image.open(src_path) as src:
|
||||
img = src.convert("RGBA")
|
||||
canvas = Image.new("RGBA", img.size, bg_rgba)
|
||||
flat = Image.alpha_composite(canvas, img).convert("RGB")
|
||||
|
||||
if size is not None:
|
||||
flat = flat.resize((int(size), int(size)), resample_const)
|
||||
|
||||
if out_path is None:
|
||||
base, ext = os.path.splitext(src_path)
|
||||
dst_path = base + "_flat" + (ext if ext.lower() == ".png" else ".png")
|
||||
else:
|
||||
dst_path = os.path.expanduser(out_path)
|
||||
|
||||
dst_dir = os.path.dirname(os.path.abspath(dst_path))
|
||||
os.makedirs(dst_dir, exist_ok=True)
|
||||
flat.save(dst_path, "PNG")
|
||||
except (OSError, ValueError) as exc:
|
||||
out["error"] = str(exc)
|
||||
return out
|
||||
except Exception as exc: # pragma: no cover - Pillow puede lanzar tipos varios
|
||||
out["error"] = str(exc)
|
||||
return out
|
||||
|
||||
out.update(ok=True, out_path=dst_path, size=list(flat.size), error="")
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("uso: comfyui_flatten_alpha_on_color.py <sprite_con_alpha> [out] [size]",
|
||||
file=sys.stderr)
|
||||
sys.exit(2)
|
||||
src = sys.argv[1]
|
||||
dst = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
sz = int(sys.argv[3]) if len(sys.argv) > 3 else None
|
||||
print(json.dumps(comfyui_flatten_alpha_on_color(src, out_path=dst, size=sz), indent=2))
|
||||
@@ -0,0 +1,176 @@
|
||||
---
|
||||
name: comfyui_generate_character_set_oneshot
|
||||
kind: pipeline
|
||||
lang: py
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_generate_character_set_oneshot(character: str, *, style: str = \"game character, full body, clean background\", checkpoint: str = \"dreamshaper_8.safetensors\", base_kind: str = \"enemy_creature\", directions: int = 8, make_directional: bool = True, make_3d: bool = True, directional_model: str = \"sv3d\", elevation: float = 15.0, seed: int = 0, size: int = 512, directional_size: int | None = None, flatten_color: tuple = (255, 255, 255), variant_3d: str = \"mini\", lora: str | None = None, lora_strength: float = 1.0, server: str = \"http://127.0.0.1:8188\", export_godot: str | None = None, out_dir: str | None = None, wait_timeout: float = 600.0, free_vram: bool = True, godot_bin: str | None = None) -> dict"
|
||||
description: "Culminacion del grupo gamedev-2d: genera el set COMPLETO y coherente de UN personaje de juego de un solo tiro — (1) imagen base 2D recortada a alpha, (2) sprite direccional N-way (vistas 3D consistentes SV3D/Zero123) y (3) malla 3D .glb (Hunyuan3D-2). La CLAVE es la coherencia cross-frontera: la vista direccional y la malla 3D parten de la MISMA imagen base 2D aplanada, no de tres generaciones independientes, asi que las tres representaciones son del MISMO personaje con el mismo estilo (no tres personajes distintos). Promueve a UNA llamada la secuencia que hoy exige 4 funciones a mano (issue 0087). Compone: un builder de personaje (enemy_creature/portrait_avatar/topdown_sprite) + comfyui_flatten_alpha_on_color + comfyui_image_to_3d_oneshot + comfyui_build_directional_sprite_workflow + submit/wait/fetch + comfyui_export_asset_to_godot. Secuencial liberando VRAM (POST /free) entre los pasos pesados para caber en 8 GB; el 3D va antes que el direccional (SV3D es el de mayor pico). Un fallo aislado (p.ej. OOM en el 3D) NO aborta el resto: deja el set PARCIAL. Devuelve {ok, character, style, checkpoint, base_kind, seed, coherence_note, base_image, base_flat, base_prompt_id, directional, mesh, exported, steps, error}. Impuro: HTTP a ComfyUI + disco + (export) subprocess."
|
||||
tags: [gamedev-2d, pipelines, comfyui, ml, godot]
|
||||
uses_functions: [comfyui_build_enemy_creature_workflow_py_ml, comfyui_build_portrait_avatar_workflow_py_ml, comfyui_build_topdown_sprite_workflow_py_ml, comfyui_build_directional_sprite_workflow_py_ml, comfyui_flatten_alpha_on_color_py_ml, comfyui_submit_workflow_py_ml, comfyui_wait_result_py_ml, comfyui_fetch_output_image_py_ml, comfyui_image_to_3d_oneshot_py_pipelines, comfyui_export_asset_to_godot_py_pipelines]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [comfyui_build_enemy_creature_workflow_py_ml, comfyui_build_directional_sprite_workflow_py_ml, comfyui_flatten_alpha_on_color_py_ml, comfyui_image_to_3d_oneshot_py_pipelines, comfyui_submit_workflow_py_ml, comfyui_wait_result_py_ml, comfyui_fetch_output_image_py_ml, comfyui_export_asset_to_godot_py_pipelines]
|
||||
tested: true
|
||||
test_file_path: "python/functions/pipelines/comfyui_generate_character_set_oneshot_test.py"
|
||||
tests: [test_empty_character_fails, test_unknown_base_kind_fails_without_network, test_nothing_to_generate_fails, test_base_builder_introspection_injects_coherence, test_view_order_labels_8way, test_coherence_same_base_feeds_3d_and_directional, test_directional_downloads_all_frames, test_isolated_3d_failure_leaves_partial_set, test_export_godot_called_for_base_and_mesh]
|
||||
file_path: "python/functions/pipelines/comfyui_generate_character_set_oneshot.py"
|
||||
params:
|
||||
- name: character
|
||||
desc: "descripcion del personaje ('armored paladin', 'goblin warrior', 'fire mage'). Se pasa como primer posicional al builder de la base 2D. No puede estar vacio."
|
||||
- name: style
|
||||
desc: "estilo comun de TODO el set (la firma visual compartida); el builder base lo usa/concatena segun su firma. keyword-only."
|
||||
- name: checkpoint
|
||||
desc: "modelo base de la generacion 2D, compartido por base y direccional. keyword-only."
|
||||
- name: base_kind
|
||||
desc: "builder de personaje para la base 2D — uno de supported_base_kinds(): 'enemy_creature' (default, cuerpo entero recortable, ideal para turntable 3D), 'portrait_avatar' (busto), 'topdown_sprite' (cenital). keyword-only."
|
||||
- name: directions
|
||||
desc: "numero de direcciones del sprite direccional. 8 = 8-way N/NE/E/SE/S/SW/W/NW; 4 = N/E/S/W (RPG clasico). keyword-only."
|
||||
- name: make_directional
|
||||
desc: "si True genera el sprite direccional N-way. keyword-only."
|
||||
- name: make_3d
|
||||
desc: "si True genera la malla 3D .glb. keyword-only."
|
||||
- name: directional_model
|
||||
desc: "'sv3d' (orbit turntable, mejor consistencia rotacional) o 'zero123' (batch por azimuth, menor VRAM). keyword-only."
|
||||
- name: elevation
|
||||
desc: "picado de camara del orbit direccional en grados (~15-30 para top-down/iso; 0 = ecuador lateral puro). keyword-only."
|
||||
- name: seed
|
||||
desc: "semilla compartida de la generacion (base 2D + KSampler direccional) para reproducibilidad. keyword-only."
|
||||
- name: size
|
||||
desc: "lado en px de la base 2D cuadrada (512 con SD1.5 = poca VRAM). keyword-only."
|
||||
- name: directional_size
|
||||
desc: "lado de cada vista direccional; None = nativo del modelo (576 sv3d / 256 zero123). La base se aplana a este tamano para alimentar el modelo 3D direccional. keyword-only."
|
||||
- name: flatten_color
|
||||
desc: "color RGB de fondo sobre el que se aplana la base recortada antes de los pasos 3D/direccional (blanco (255,255,255) por defecto; los modelos 3D esperan fondo opaco). keyword-only."
|
||||
- name: variant_3d
|
||||
desc: "variante Hunyuan3D-2 para la malla: 'mini' (default, ~4.9 GB), 'standard', 'mv'. keyword-only."
|
||||
- name: lora
|
||||
desc: "LoRA de estilo compartido; solo se aplica si el builder base tiene un param 'lora' generico. keyword-only."
|
||||
- name: lora_strength
|
||||
desc: "fuerza del LoRA comun. keyword-only."
|
||||
- name: server
|
||||
desc: "host:port del servidor ComfyUI; se acepta con o sin esquema (http://). keyword-only."
|
||||
- name: export_godot
|
||||
desc: "ruta de un proyecto Godot 4; si se da, la base 2D (sprite) y la malla (model) se exportan a res://assets/... con su .import. None = no exportar. keyword-only."
|
||||
- name: out_dir
|
||||
desc: "directorio local donde descargar los artefactos; None = un dir temporal por personaje (character_set_<nombre>_seed<seed> en tempdir). keyword-only."
|
||||
- name: wait_timeout
|
||||
desc: "segundos maximos esperando cada trabajo en ComfyUI. keyword-only."
|
||||
- name: free_vram
|
||||
desc: "si True hace POST /free entre el paso 3D y el direccional para liberar VRAM y caber en 8 GB. keyword-only."
|
||||
- name: godot_bin
|
||||
desc: "binario de Godot para el reimport headless; None autodetecta. keyword-only."
|
||||
output: "dict con ok (bool, True si la base salio y todos los pasos solicitados tuvieron exito), character/style/checkpoint/base_kind/seed (eco), coherence_note (str), base_image (PNG RGBA recortado = deliverable 2D), base_flat (PNG aplanado = fuente comun de 3D+direccional), base_prompt_id (str), directional ({ok, model, directions, view_order, views:[{direction, path}], prompt_id, error} o None), mesh ({ok, mesh_glb, faces, prompt_id, error} o None), exported (list de resultados de export a Godot), steps (list de {step, ok, detail} del log secuencial), error (str). Un paso solicitado que falla deja el set PARCIAL sin abortar los demas."
|
||||
---
|
||||
|
||||
Pipeline que genera el set COMPLETO de un personaje de juego de un solo tiro y, sobre
|
||||
todo, COHERENTE: la imagen base 2D, el sprite direccional 8-way y la malla 3D son del
|
||||
MISMO personaje porque las dos ultimas derivan de la primera. Es la culminacion del
|
||||
grupo `gamedev-2d` — junta sus fronteras de generacion 2D, 2.5D direccional (SV3D) y
|
||||
3D-mesh (Hunyuan3D) en una capa de orquestacion que garantiza la identidad compartida.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from pipelines.comfyui_generate_character_set_oneshot import comfyui_generate_character_set_oneshot
|
||||
|
||||
# Set completo de UN personaje (SD1.5 512 base, SV3D 576 direccional, Hunyuan3D mini):
|
||||
res = comfyui_generate_character_set_oneshot(
|
||||
"armored paladin",
|
||||
style="dark fantasy, hand-painted, full body, clean background",
|
||||
base_kind="enemy_creature", # cuerpo entero recortable -> mejor turntable 3D
|
||||
directions=8, # sprite 8-way
|
||||
make_3d=True,
|
||||
make_directional=True,
|
||||
seed=7,
|
||||
size=512,
|
||||
out_dir="/tmp/character_set_paladin",
|
||||
)
|
||||
# res["base_image"] -> PNG RGBA recortado (el sprite 2D)
|
||||
# res["base_flat"] -> PNG aplanado sobre blanco (la FUENTE comun)
|
||||
# res["mesh"]["mesh_glb"] -> .glb de la malla 3D
|
||||
# res["directional"]["views"] -> [{"direction":"S","path":...}, {"direction":"SE",...}, ...]
|
||||
# res["coherence_note"] -> explica que 3D y direccional parten de la MISMA base
|
||||
# res["ok"] -> True si base + 3D + direccional salieron todos
|
||||
|
||||
# Solo el sprite direccional, sin malla 3D (mas rapido):
|
||||
res = comfyui_generate_character_set_oneshot(
|
||||
"goblin warrior", make_3d=False, directions=8, seed=7, size=512,
|
||||
)
|
||||
|
||||
# Con export directo a un proyecto Godot 4 (sprite -> assets/sprites, malla -> assets/models):
|
||||
res = comfyui_generate_character_set_oneshot(
|
||||
"fire mage", seed=7, size=512,
|
||||
export_godot=os.path.expanduser("~/gamedev/projects/dungeon"),
|
||||
)
|
||||
```
|
||||
|
||||
Lanzable tambien por `fn run` (despacha como pipeline Python):
|
||||
|
||||
```bash
|
||||
./fn run comfyui_generate_character_set_oneshot # corre el __main__ de demo (armored paladin)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando necesites TODAS las representaciones de UN personaje para un juego y
|
||||
tengan que ser el mismo personaje: el sprite 2D para el inventario/retrato, el sprite
|
||||
direccional para moverlo en un top-down/shooter 8-way, y la malla 3D para un prototipo
|
||||
3D o un asset hibrido. En vez de llamar `enemy_creature` -> recortar -> aplanar ->
|
||||
`directional_sprite` -> `image_to_3d` a mano (4 llamadas + el riesgo de que el 2D, el
|
||||
direccional y el 3D acaben siendo personajes ligeramente distintos), esta funcion lo
|
||||
hace de un tiro garantizando que el direccional y el 3D salen de la MISMA base. Para
|
||||
UNA sola representacion aislada, usa su builder/pipeline directo
|
||||
(`comfyui_build_enemy_creature_workflow`, `comfyui_image_to_3d_oneshot`,
|
||||
`comfyui_build_directional_sprite_workflow`) — esta es para el *set entero*. Para un
|
||||
set de assets VARIADOS de un juego (iconos + tiles + UI + enemigos) que no son un solo
|
||||
personaje, usa `comfyui_generate_asset_pack_oneshot`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Coherencia = una sola base.** El sprite direccional y la malla 3D NO se generan por
|
||||
separado: ambos parten de `base_flat`, que es `base_image` aplanada sobre fondo solido.
|
||||
Eso es lo que garantiza que sean el mismo personaje. Si quisieras tres personajes
|
||||
distintos, llamarias a los builders por separado — no es lo que hace este pipeline.
|
||||
- **Por que se aplana la base.** `base_image` sale recortada a alpha (transparente). Los
|
||||
modelos 3D (SV3D, Hunyuan3D) y el `LoadImage` de ComfyUI hacen `convert("RGB")`, que
|
||||
descarta el alpha y deja los RGB crudos bajo la transparencia (basura) -> mala
|
||||
reconstruccion. Por eso se aplana sobre `flatten_color` (blanco) antes de los pasos 3D.
|
||||
- **VRAM / OOM (RTX 3070 8 GB).** SV3D pica ~7145 MiB y Hunyuan3D mini ~4.9 GB. Caben
|
||||
secuencialmente pero NO a la vez: el pipeline hace `POST /free` entre el 3D y el
|
||||
direccional (`free_vram=True`) y corre el 3D ANTES (SV3D es el de mayor pico, va con la
|
||||
GPU recien liberada). Limpia la VRAM tu mismo antes de empezar
|
||||
(`POST http://127.0.0.1:8188/free {"unload_models":true,"free_memory":true}`) y cierra
|
||||
cualquier juego — el video/3D no convive con un juego en VRAM. Si un paso pesado peta
|
||||
por OOM NO se matan procesos: ese sub-dict queda `ok=False` con su `error` y el resto
|
||||
del set sobrevive (set PARCIAL).
|
||||
- **Fallo aislado = set parcial, no aborto.** Si la base 2D falla, se aborta (sin base no
|
||||
hay set). Pero si falla SOLO el 3D o SOLO el direccional, el otro y la base 2D se
|
||||
conservan; `ok` global es False y `error` describe el paso parcial.
|
||||
- **`base_kind` para turntable.** El default `enemy_creature` da cuerpo entero centrado y
|
||||
recortable, lo ideal para que SV3D rote la figura. `portrait_avatar` (busto) y
|
||||
`topdown_sprite` (cenital) son validos pero el turntable 3D de un busto/cenital es
|
||||
menos util. El builder se elige por introspeccion: solo se le pasan los kwargs que su
|
||||
firma admita (checkpoint/size/seed/transparent/lora/style).
|
||||
- **`directional` produce N imagenes en un prompt.** SV3D emite los N frames del orbit en
|
||||
un unico SaveImage; el pipeline los descarga TODOS y los etiqueta por direccion con
|
||||
`directional_sprite_view_order(directions)` (frame i = direccion i). `comfyui_wait_result`
|
||||
lanza `TimeoutError` al expirar pero el job suele completar en GPU — subir `wait_timeout`
|
||||
si SV3D tarda en lowvram.
|
||||
- **`server`** se normaliza (acepta con o sin `http://`); internamente es `host:port`.
|
||||
- **Export a Godot** lleva la base 2D como `sprite` y la malla como `model`; reimport
|
||||
headless una sola vez. Si no encuentra el binario de Godot, deja los `.import` y lo
|
||||
anota, no falla la generacion.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.0.0 (2026-06-27) — version inicial. Promueve a un pipeline one-shot la secuencia
|
||||
"base 2D de personaje -> aplanar -> malla 3D + sprite direccional, todos del mismo
|
||||
personaje" (issue 0087). Resuelve la coherencia cross-frontera que ninguna pieza suelta
|
||||
del grupo gamedev-2d garantizaba: el direccional y el 3D derivan de la MISMA base 2D
|
||||
aplanada. Secuencial liberando VRAM entre pasos pesados para caber en 8 GB.
|
||||
@@ -0,0 +1,448 @@
|
||||
"""comfyui_generate_character_set_oneshot — set COMPLETO y coherente de UN personaje.
|
||||
|
||||
Culminación del grupo `gamedev-2d`: las 5 fronteras de generación (crear 2D,
|
||||
transformar, animar, 2.5D direccional SV3D, 3D-mesh Hunyuan3D) ya existen como
|
||||
builders/pipelines sueltos, pero ninguna pieza resolvía la *coherencia
|
||||
cross-frontera*: generar el set ENTERO de un personaje de juego (sprite 2D + sprite
|
||||
direccional 8-way + malla 3D) exigía llamar 4 funciones a mano y NO garantizaba que
|
||||
las tres representaciones fueran del MISMO personaje con el mismo estilo.
|
||||
|
||||
Este pipeline lo promueve a UNA sola llamada (doctrina issue 0087: el registry crece
|
||||
promoviendo composiciones repetidas a pipelines one-shot, no inflando funciones). La
|
||||
clave de la coherencia: la vista direccional y la malla 3D parten de la MISMA imagen
|
||||
base 2D, no de tres generaciones independientes → es el mismo personaje fotografiado
|
||||
de tres maneras, no tres personajes distintos.
|
||||
|
||||
Flujo (secuencial, liberando VRAM entre los pasos pesados para caber en 8 GB):
|
||||
|
||||
1. base 2D (txt2img de un builder de personaje del catálogo, recortado a alpha)
|
||||
└─ comfyui_build_enemy_creature_workflow (o portrait_avatar / topdown_sprite)
|
||||
+ comfyui_submit_workflow + comfyui_wait_result + comfyui_fetch_output_image
|
||||
2. aplanar la base sobre fondo sólido (los modelos 3D/SV3D quieren fondo opaco)
|
||||
└─ comfyui_flatten_alpha_on_color
|
||||
3. malla 3D desde la base aplanada
|
||||
└─ comfyui_image_to_3d_oneshot (sube la imagen + Hunyuan3D-2 + descarga .glb)
|
||||
4. sprite direccional N-way desde la MISMA base aplanada
|
||||
└─ comfyui_build_directional_sprite_workflow (SV3D/Zero123)
|
||||
+ _upload_image + submit + wait + fetch (N frames del orbit)
|
||||
5. export opcional a un proyecto Godot
|
||||
└─ comfyui_export_asset_to_godot
|
||||
|
||||
Compone funciones del registry — no reescribe ninguna. Un fallo aislado (p.ej. OOM
|
||||
en el 3D) NO aborta el resto del set: devuelve lo que sí salió + el error parcial.
|
||||
|
||||
Pipeline impuro: red (HTTP a ComfyUI) + escritura en disco + (export) subprocess.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
_FUNCTIONS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _FUNCTIONS_ROOT not in sys.path:
|
||||
sys.path.insert(0, _FUNCTIONS_ROOT)
|
||||
|
||||
# --- builders de personaje (puros) — base 2D del set ---
|
||||
from ml.comfyui_build_enemy_creature_workflow import comfyui_build_enemy_creature_workflow
|
||||
from ml.comfyui_build_portrait_avatar_workflow import comfyui_build_portrait_avatar_workflow
|
||||
from ml.comfyui_build_topdown_sprite_workflow import comfyui_build_topdown_sprite_workflow
|
||||
|
||||
# --- builder direccional 2.5D (puro) + helper de orden de vistas ---
|
||||
from ml.comfyui_build_directional_sprite_workflow import (
|
||||
comfyui_build_directional_sprite_workflow,
|
||||
directional_sprite_view_order,
|
||||
)
|
||||
|
||||
# --- post-proceso CPU: aplanar alpha sobre color sólido (entrada de SV3D/Hunyuan) ---
|
||||
from ml.comfyui_flatten_alpha_on_color import comfyui_flatten_alpha_on_color
|
||||
|
||||
# --- transporte ComfyUI (impuro) ---
|
||||
from ml.comfyui_fetch_output_image import comfyui_fetch_output_image
|
||||
from ml.comfyui_submit_workflow import comfyui_submit_workflow
|
||||
from ml.comfyui_wait_result import comfyui_wait_result
|
||||
|
||||
# --- pipeline 3D reutilizado entero (sube imagen + Hunyuan3D-2 + descarga .glb) ---
|
||||
# y su helper de upload al input/ del servidor (lógica de transporte ya existente).
|
||||
from pipelines.comfyui_image_to_3d_oneshot import (
|
||||
_upload_image,
|
||||
comfyui_image_to_3d_oneshot,
|
||||
)
|
||||
|
||||
# --- export opcional a Godot ---
|
||||
from pipelines.comfyui_export_asset_to_godot import comfyui_export_asset_to_godot
|
||||
|
||||
# base_kind -> builder de personaje. El primer posicional de cada builder es el
|
||||
# nombre del personaje (creature/character/subject); el resto de coherencia
|
||||
# (checkpoint/size/seed/transparent/lora/style) se pasa por introspección.
|
||||
_BASE_BUILDERS: dict[str, object] = {
|
||||
"enemy_creature": comfyui_build_enemy_creature_workflow,
|
||||
"portrait_avatar": comfyui_build_portrait_avatar_workflow,
|
||||
"topdown_sprite": comfyui_build_topdown_sprite_workflow,
|
||||
}
|
||||
|
||||
# Nombres de parámetro candidatos para el checkpoint, por orden de preferencia.
|
||||
_CKPT_PARAMS = ("checkpoint", "ckpt_name", "ckpt")
|
||||
|
||||
|
||||
def supported_base_kinds() -> list[str]:
|
||||
"""Lista ordenada de los `base_kind` válidos para la imagen base 2D del set."""
|
||||
return sorted(_BASE_BUILDERS)
|
||||
|
||||
|
||||
def _free_vram(server: str) -> bool:
|
||||
"""Pide a ComfyUI que descargue modelos y libere VRAM (POST /free).
|
||||
|
||||
Best-effort: entre el paso 3D (Hunyuan) y el direccional (SV3D), ambos con pico
|
||||
~7 GB en una RTX 3070 de 8 GB, liberar la VRAM del paso anterior evita el OOM al
|
||||
encadenarlos. No lanza: si el endpoint falla, devuelve False y el caller sigue.
|
||||
"""
|
||||
try:
|
||||
body = json.dumps({"unload_models": True, "free_memory": True}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"http://{server}/free", data=body,
|
||||
headers={"Content-Type": "application/json"}, method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
resp.read()
|
||||
return True
|
||||
except (urllib.error.URLError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def _build_base_workflow(
|
||||
base_kind: str,
|
||||
character: str,
|
||||
*,
|
||||
checkpoint: str,
|
||||
style: str,
|
||||
size: int | None,
|
||||
seed: int,
|
||||
lora: str | None,
|
||||
lora_strength: float,
|
||||
) -> dict:
|
||||
"""Construye el workflow de la base 2D inyectando solo los kwargs que el builder admita.
|
||||
|
||||
PURO. Pasa `transparent=True` si el builder lo soporta (la base recortada a alpha
|
||||
es el deliverable 2D y la fuente de los demás pasos). Introspección de firma para
|
||||
no pasar kwargs que el builder no acepte.
|
||||
"""
|
||||
fn = _BASE_BUILDERS[base_kind]
|
||||
params = inspect.signature(fn).parameters
|
||||
kw: dict = {}
|
||||
for cand in _CKPT_PARAMS:
|
||||
if cand in params:
|
||||
kw[cand] = checkpoint
|
||||
break
|
||||
if "style" in params and style:
|
||||
kw["style"] = style
|
||||
if "size" in params and size is not None:
|
||||
kw["size"] = size
|
||||
if "seed" in params:
|
||||
kw["seed"] = seed
|
||||
if "transparent" in params:
|
||||
kw["transparent"] = True
|
||||
if lora and "lora" in params:
|
||||
kw["lora"] = lora
|
||||
if "lora_strength" in params:
|
||||
kw["lora_strength"] = lora_strength
|
||||
return fn(character, **kw)
|
||||
|
||||
|
||||
def _first_image(outputs: dict) -> dict | None:
|
||||
"""Primer descriptor de imagen {filename, subfolder, type} en los outputs."""
|
||||
for node_out in outputs.values():
|
||||
images = node_out.get("images") if isinstance(node_out, dict) else None
|
||||
if images:
|
||||
return images[0]
|
||||
return None
|
||||
|
||||
|
||||
def _all_images(outputs: dict) -> list:
|
||||
"""Todos los descriptores de imagen de los outputs, en orden de nodo y de batch.
|
||||
|
||||
El SaveImage de SV3D emite los N frames del orbit como una lista en un único
|
||||
nodo; este helper los aplana para descargarlos todos (uno por dirección).
|
||||
"""
|
||||
out: list = []
|
||||
for node_out in outputs.values():
|
||||
images = node_out.get("images") if isinstance(node_out, dict) else None
|
||||
if images:
|
||||
out.extend(images)
|
||||
return out
|
||||
|
||||
|
||||
def comfyui_generate_character_set_oneshot(
|
||||
character: str,
|
||||
*,
|
||||
style: str = "game character, full body, clean background",
|
||||
checkpoint: str = "dreamshaper_8.safetensors",
|
||||
base_kind: str = "enemy_creature",
|
||||
directions: int = 8,
|
||||
make_directional: bool = True,
|
||||
make_3d: bool = True,
|
||||
directional_model: str = "sv3d",
|
||||
elevation: float = 15.0,
|
||||
seed: int = 0,
|
||||
size: int = 512,
|
||||
directional_size: int | None = None,
|
||||
flatten_color: tuple = (255, 255, 255),
|
||||
variant_3d: str = "mini",
|
||||
lora: str | None = None,
|
||||
lora_strength: float = 1.0,
|
||||
server: str = "http://127.0.0.1:8188",
|
||||
export_godot: str | None = None,
|
||||
out_dir: str | None = None,
|
||||
wait_timeout: float = 600.0,
|
||||
free_vram: bool = True,
|
||||
godot_bin: str | None = None,
|
||||
) -> dict:
|
||||
"""Genera el set COMPLETO y coherente de UN personaje de juego, de un solo tiro.
|
||||
|
||||
Produce, del MISMO personaje y con el MISMO estilo: (1) una imagen base 2D
|
||||
recortada a alpha, (2) un sprite direccional N-way (vistas 3D consistentes) y
|
||||
(3) una malla 3D `.glb`. La (2) y la (3) parten de la MISMA base 2D aplanada, así
|
||||
que las tres representaciones son coherentes (mismo personaje, no tres distintos).
|
||||
|
||||
Args:
|
||||
character: descripción del personaje ("armored paladin", "goblin warrior",
|
||||
"fire mage"). Se pasa al builder de la base 2D.
|
||||
style: estilo común de todo el set; el builder base lo concatena/usa según su
|
||||
firma. Es la firma visual compartida. keyword-only.
|
||||
checkpoint: modelo base de la generación 2D, compartido. keyword-only.
|
||||
base_kind: builder de personaje para la base 2D — uno de
|
||||
`supported_base_kinds()` ("enemy_creature" default, "portrait_avatar",
|
||||
"topdown_sprite"). keyword-only.
|
||||
directions: nº de direcciones del sprite direccional (8 = 8-way, 4 = RPG
|
||||
clásico). keyword-only.
|
||||
make_directional: si True genera el sprite direccional. keyword-only.
|
||||
make_3d: si True genera la malla 3D. keyword-only.
|
||||
directional_model: "sv3d" (orbit turntable, mejor consistencia) o "zero123"
|
||||
(batch, menor VRAM). keyword-only.
|
||||
elevation: picado de cámara del orbit direccional en grados (~15-30 para
|
||||
top-down/iso). keyword-only.
|
||||
seed: semilla compartida de la generación (base 2D + direccional). keyword-only.
|
||||
size: lado en px de la base 2D cuadrada. keyword-only.
|
||||
directional_size: lado de cada vista direccional; None = nativo del modelo
|
||||
(576 sv3d / 256 zero123). La base se aplana a este tamaño para alimentar
|
||||
el modelo 3D direccional. keyword-only.
|
||||
flatten_color: color RGB de fondo sobre el que se aplana la base recortada
|
||||
antes de los pasos 3D/direccional (blanco por defecto). keyword-only.
|
||||
variant_3d: variante Hunyuan3D-2 para la malla ("mini" default, "standard",
|
||||
"mv"). keyword-only.
|
||||
lora: LoRA de estilo compartido (solo si el builder base lo admite). keyword-only.
|
||||
lora_strength: fuerza del LoRA común. keyword-only.
|
||||
server: host:port del ComfyUI (acepta con o sin esquema http://). keyword-only.
|
||||
export_godot: ruta de un proyecto Godot 4; si se da, la base 2D (sprite) y la
|
||||
malla (model) se exportan a `res://assets/...`. None = no exportar. keyword-only.
|
||||
out_dir: directorio local de descarga; None = un dir temporal por personaje.
|
||||
keyword-only.
|
||||
wait_timeout: segundos máximos esperando cada trabajo en ComfyUI. keyword-only.
|
||||
free_vram: si True hace POST /free entre los pasos pesados (3D y direccional)
|
||||
para caber en 8 GB. keyword-only.
|
||||
godot_bin: binario de Godot para el reimport headless; None autodetecta. keyword-only.
|
||||
|
||||
Returns:
|
||||
dict {ok, character, style, checkpoint, base_kind, seed, coherence_note,
|
||||
base_image, base_flat, base_prompt_id, directional, mesh, exported, steps,
|
||||
error}. `base_image` = PNG RGBA recortado (deliverable 2D); `base_flat` = PNG
|
||||
aplanado que alimenta los pasos 3D/direccional (la fuente común que garantiza
|
||||
la coherencia); `directional` = {ok, model, directions, view_order, views:
|
||||
[{direction, path}], prompt_id, error}; `mesh` = {ok, mesh_glb, faces,
|
||||
prompt_id, error}; `exported` = lista de resultados de export a Godot. `ok`
|
||||
global es True si la base salió y todos los pasos solicitados
|
||||
(make_3d/make_directional) tuvieron éxito. Un paso solicitado que falla deja
|
||||
el set PARCIAL (su sub-dict con ok=False y error), pero NO aborta los demás.
|
||||
"""
|
||||
server = server.replace("http://", "").replace("https://", "").strip("/")
|
||||
out: dict = {
|
||||
"ok": False, "character": character, "style": style, "checkpoint": checkpoint,
|
||||
"base_kind": base_kind, "seed": seed, "coherence_note": "",
|
||||
"base_image": "", "base_flat": "", "base_prompt_id": "",
|
||||
"directional": None, "mesh": None, "exported": [], "steps": [], "error": "",
|
||||
}
|
||||
|
||||
def _step(name: str, ok: bool, detail: str = "") -> None:
|
||||
out["steps"].append({"step": name, "ok": ok, "detail": detail})
|
||||
|
||||
# --- validación temprana (antes de tocar la GPU) ---
|
||||
if not character or not character.strip():
|
||||
out["error"] = "character vacío"
|
||||
return out
|
||||
if base_kind not in _BASE_BUILDERS:
|
||||
out["error"] = (
|
||||
f"base_kind {base_kind!r} no soportado. "
|
||||
f"Soportados: {', '.join(supported_base_kinds())}"
|
||||
)
|
||||
return out
|
||||
if not make_3d and not make_directional:
|
||||
out["error"] = "nada que generar: make_3d y make_directional ambos False"
|
||||
return out
|
||||
|
||||
char_dir = out_dir or os.path.join(
|
||||
tempfile.gettempdir(),
|
||||
f"character_set_{character.strip().replace(' ', '_')}_seed{seed}",
|
||||
)
|
||||
os.makedirs(char_dir, exist_ok=True)
|
||||
out["coherence_note"] = (
|
||||
f"Set coherente de {character!r}: la base 2D (base_kind={base_kind!r}, "
|
||||
f"checkpoint={checkpoint!r}, style={style!r}, seed={seed}) se aplana una vez "
|
||||
f"y de esa MISMA base derivan el sprite direccional y la malla 3D — mismo "
|
||||
f"personaje en las tres representaciones, no tres generaciones independientes."
|
||||
)
|
||||
|
||||
# --- 1. base 2D ---
|
||||
try:
|
||||
base_wf = _build_base_workflow(
|
||||
base_kind, character.strip(), checkpoint=checkpoint, style=style,
|
||||
size=size, seed=seed, lora=lora, lora_strength=lora_strength,
|
||||
)
|
||||
sub = comfyui_submit_workflow(base_wf, server=server)
|
||||
out["base_prompt_id"] = sub["prompt_id"]
|
||||
outputs = comfyui_wait_result(sub["prompt_id"], server=server, timeout=wait_timeout)
|
||||
img = _first_image(outputs)
|
||||
if img is None:
|
||||
out["error"] = "la base 2D no produjo imágenes"
|
||||
_step("base_2d", False, out["error"])
|
||||
return out
|
||||
fetched = comfyui_fetch_output_image(
|
||||
img["filename"], subfolder=img.get("subfolder", ""),
|
||||
type_=img.get("type", "output"), server=server, dest_dir=char_dir,
|
||||
)
|
||||
if not fetched.get("ok"):
|
||||
out["error"] = f"fetch de la base falló: {fetched.get('error')}"
|
||||
_step("base_2d", False, out["error"])
|
||||
return out
|
||||
out["base_image"] = fetched["path"]
|
||||
_step("base_2d", True, out["base_image"])
|
||||
except (ValueError, TypeError, RuntimeError, KeyError, TimeoutError) as exc:
|
||||
out["error"] = f"base 2D falló: {exc}"
|
||||
_step("base_2d", False, out["error"])
|
||||
return out
|
||||
|
||||
# --- 2. aplanar la base sobre fondo sólido (entrada de los modelos 3D) ---
|
||||
flat_size = directional_size # None => el flatten conserva tamaño; se reescala abajo
|
||||
flat = comfyui_flatten_alpha_on_color(
|
||||
out["base_image"],
|
||||
out_path=os.path.join(char_dir, "base_flat.png"),
|
||||
color=tuple(flatten_color),
|
||||
size=flat_size,
|
||||
)
|
||||
if not flat.get("ok"):
|
||||
out["error"] = f"flatten de la base falló: {flat.get('error')}"
|
||||
_step("flatten", False, out["error"])
|
||||
return out
|
||||
out["base_flat"] = flat["out_path"]
|
||||
_step("flatten", True, out["base_flat"])
|
||||
|
||||
# --- 3. malla 3D desde la base aplanada (paso pesado #1) ---
|
||||
if make_3d:
|
||||
mesh_dir = os.path.join(char_dir, "mesh")
|
||||
os.makedirs(mesh_dir, exist_ok=True) # dir existente => fetch nombra el .glb dentro
|
||||
mesh = comfyui_image_to_3d_oneshot(
|
||||
out["base_flat"], server=server, variant=variant_3d,
|
||||
dest=mesh_dir, wait_timeout=wait_timeout, seed=seed,
|
||||
)
|
||||
out["mesh"] = {
|
||||
"ok": mesh.get("ok", False), "mesh_glb": mesh.get("mesh_path", ""),
|
||||
"faces": mesh.get("faces"), "prompt_id": mesh.get("prompt_id", ""),
|
||||
"error": mesh.get("error", ""),
|
||||
}
|
||||
_step("mesh_3d", out["mesh"]["ok"], out["mesh"]["mesh_glb"] or out["mesh"]["error"])
|
||||
if free_vram and make_directional:
|
||||
_step("free_vram", _free_vram(server), "tras 3D, antes del direccional")
|
||||
|
||||
# --- 4. sprite direccional desde la MISMA base aplanada (paso pesado #2) ---
|
||||
if make_directional:
|
||||
directional: dict = {
|
||||
"ok": False, "model": directional_model, "directions": directions,
|
||||
"view_order": directional_sprite_view_order(directions), "views": [],
|
||||
"prompt_id": "", "error": "",
|
||||
}
|
||||
try:
|
||||
server_name = _upload_image(out["base_flat"], server)
|
||||
dir_wf = comfyui_build_directional_sprite_workflow(
|
||||
server_name, directions=directions, model=directional_model,
|
||||
elevation=elevation, size=directional_size, seed=seed,
|
||||
)
|
||||
sub = comfyui_submit_workflow(dir_wf, server=server)
|
||||
directional["prompt_id"] = sub["prompt_id"]
|
||||
outputs = comfyui_wait_result(sub["prompt_id"], server=server, timeout=wait_timeout)
|
||||
imgs = _all_images(outputs)
|
||||
if not imgs:
|
||||
directional["error"] = "el direccional no produjo imágenes"
|
||||
else:
|
||||
names = directional["view_order"]
|
||||
for i, im in enumerate(imgs):
|
||||
label = names[i] if i < len(names) else f"frame{i}"
|
||||
got = comfyui_fetch_output_image(
|
||||
im["filename"], subfolder=im.get("subfolder", ""),
|
||||
type_=im.get("type", "output"), server=server,
|
||||
dest_dir=os.path.join(char_dir, "directional"),
|
||||
)
|
||||
if got.get("ok"):
|
||||
directional["views"].append({"direction": label, "path": got["path"]})
|
||||
directional["ok"] = len(directional["views"]) > 0
|
||||
if not directional["ok"]:
|
||||
directional["error"] = "no se pudo descargar ninguna vista direccional"
|
||||
except (ValueError, TypeError, RuntimeError, KeyError, TimeoutError) as exc:
|
||||
directional["error"] = f"direccional falló: {exc}"
|
||||
out["directional"] = directional
|
||||
_step("directional", directional["ok"],
|
||||
f"{len(directional['views'])}/{directions} vistas" if directional["ok"]
|
||||
else directional["error"])
|
||||
|
||||
# --- 5. export opcional a Godot (base sprite + malla) ---
|
||||
if export_godot:
|
||||
if out["base_image"]:
|
||||
exp = comfyui_export_asset_to_godot(
|
||||
out["base_image"], "sprite", export_godot,
|
||||
reimport=not (make_3d and out.get("mesh", {}).get("ok")),
|
||||
godot_bin=godot_bin,
|
||||
)
|
||||
out["exported"].append({"asset": "base_image", "result": exp})
|
||||
if make_3d and out.get("mesh") and out["mesh"]["ok"] and out["mesh"]["mesh_glb"]:
|
||||
exp = comfyui_export_asset_to_godot(
|
||||
out["mesh"]["mesh_glb"], "model", export_godot,
|
||||
reimport=True, godot_bin=godot_bin,
|
||||
)
|
||||
out["exported"].append({"asset": "mesh", "result": exp})
|
||||
|
||||
# --- veredicto global: base OK + todos los pasos solicitados OK ---
|
||||
ok = bool(out["base_image"])
|
||||
if make_3d:
|
||||
ok = ok and bool(out.get("mesh") and out["mesh"]["ok"])
|
||||
if make_directional:
|
||||
ok = ok and bool(out.get("directional") and out["directional"]["ok"])
|
||||
out["ok"] = ok
|
||||
if not ok and not out["error"]:
|
||||
parts = []
|
||||
if make_3d and out.get("mesh") and not out["mesh"]["ok"]:
|
||||
parts.append(f"3D: {out['mesh']['error']}")
|
||||
if make_directional and out.get("directional") and not out["directional"]["ok"]:
|
||||
parts.append(f"direccional: {out['directional']['error']}")
|
||||
out["error"] = "set PARCIAL — " + "; ".join(parts) if parts else "set parcial"
|
||||
return out
|
||||
|
||||
|
||||
# Alias con el nombre completo del ID para descubrimiento por convención.
|
||||
generate_character_set_oneshot = comfyui_generate_character_set_oneshot
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
res = comfyui_generate_character_set_oneshot(
|
||||
"armored paladin",
|
||||
style="dark fantasy, hand-painted, full body, clean background",
|
||||
base_kind="enemy_creature",
|
||||
directions=8,
|
||||
make_3d=True,
|
||||
make_directional=True,
|
||||
seed=7,
|
||||
size=512,
|
||||
out_dir="/tmp/character_set_demo",
|
||||
)
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Tests de comfyui_generate_character_set_oneshot (offline; sin ComfyUI ni GPU).
|
||||
|
||||
Cubre el contrato del pipeline sin tocar la red ni la GPU:
|
||||
- Error: character vacío / base_kind desconocido / nada que generar -> ok=False sin red.
|
||||
- Golden dispatch: el builder base recibe checkpoint/seed/transparent por introspección.
|
||||
- Golden coherencia (el corazón del pipeline): el sprite direccional y la malla 3D
|
||||
parten de la MISMA base aplanada (no de tres generaciones independientes).
|
||||
- Edge: el direccional descarga TODOS los frames del orbit, etiquetados por dirección.
|
||||
- Error: un fallo aislado (3D) deja el set PARCIAL sin abortar el direccional ni la base.
|
||||
- Edge: export a Godot lleva la base 2D (sprite) y la malla (model).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
import pipelines.comfyui_generate_character_set_oneshot as csmod # noqa: E402
|
||||
from pipelines.comfyui_generate_character_set_oneshot import ( # noqa: E402
|
||||
_build_base_workflow,
|
||||
comfyui_generate_character_set_oneshot,
|
||||
supported_base_kinds,
|
||||
)
|
||||
from ml.comfyui_build_directional_sprite_workflow import ( # noqa: E402
|
||||
directional_sprite_view_order,
|
||||
)
|
||||
|
||||
|
||||
# --- Error paths que NO tocan la red ---
|
||||
|
||||
def test_empty_character_fails():
|
||||
res = comfyui_generate_character_set_oneshot(" ")
|
||||
assert res["ok"] is False and "character" in res["error"]
|
||||
|
||||
|
||||
def test_unknown_base_kind_fails_without_network():
|
||||
res = comfyui_generate_character_set_oneshot("paladin", base_kind="does_not_exist")
|
||||
assert res["ok"] is False
|
||||
assert "no soportado" in res["error"]
|
||||
assert any(k in res["error"] for k in supported_base_kinds())
|
||||
|
||||
|
||||
def test_nothing_to_generate_fails():
|
||||
res = comfyui_generate_character_set_oneshot(
|
||||
"paladin", make_3d=False, make_directional=False
|
||||
)
|
||||
assert res["ok"] is False and "nada que generar" in res["error"]
|
||||
|
||||
|
||||
# --- Golden: dispatch puro del builder base por introspección ---
|
||||
|
||||
def test_base_builder_introspection_injects_coherence():
|
||||
wf = _build_base_workflow(
|
||||
"enemy_creature", "armored paladin",
|
||||
checkpoint="dreamshaper_8.safetensors", style="dark fantasy",
|
||||
size=512, seed=77, lora=None, lora_strength=1.0,
|
||||
)
|
||||
blob = json.dumps(wf)
|
||||
assert "dreamshaper_8.safetensors" in blob # checkpoint compartido
|
||||
assert "armored paladin" in blob # personaje
|
||||
assert "77" in blob # seed en el sampler
|
||||
# transparent=True -> el builder inyecta el nodo Rembg (base recortada a alpha)
|
||||
assert any(n.get("class_type", "").startswith("Image Rembg") for n in wf.values())
|
||||
|
||||
|
||||
def test_view_order_labels_8way():
|
||||
assert directional_sprite_view_order(8) == ["S", "SE", "E", "NE", "N", "NW", "W", "SW"]
|
||||
assert directional_sprite_view_order(4) == ["S", "E", "N", "W"]
|
||||
|
||||
|
||||
# --- Helpers de mock compartidos ---
|
||||
|
||||
def _mock_transport(monkeypatch, captured, directions, *, base_ok=True):
|
||||
"""Parchea submit/wait/fetch del módulo. submit 1=base, submit 2=direccional."""
|
||||
state = {"submit_n": 0}
|
||||
|
||||
def fake_submit(workflow, server="127.0.0.1:8188", **kw):
|
||||
state["submit_n"] += 1
|
||||
assert "://" not in server # server normalizado
|
||||
return {"prompt_id": f"pid-{state['submit_n']}", "client_id": "c"}
|
||||
|
||||
def fake_wait(prompt_id, server="127.0.0.1:8188", timeout=600.0, **kw):
|
||||
if prompt_id == "pid-1": # base 2D: una imagen
|
||||
return {"7": {"images": [{"filename": "base.png", "subfolder": "",
|
||||
"type": "output"}]}}
|
||||
# direccional: N frames del orbit en un solo SaveImage
|
||||
return {"7": {"images": [{"filename": f"dir_{i}.png", "subfolder": "",
|
||||
"type": "output"} for i in range(directions)]}}
|
||||
|
||||
def fake_fetch(filename, *, subfolder="", type_="output",
|
||||
server="127.0.0.1:8188", dest_dir=".", timeout=60.0):
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
path = os.path.join(dest_dir, filename)
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(b"\x89PNG\r\n")
|
||||
return {"ok": base_ok, "path": path if base_ok else "",
|
||||
"error": "" if base_ok else "fetch falló"}
|
||||
|
||||
def fake_flatten(image_path, *, out_path=None, color=(255, 255, 255),
|
||||
size=None, resample="lanczos"):
|
||||
captured["flatten_in"] = image_path
|
||||
captured["flatten_size"] = size
|
||||
out = out_path or (os.path.splitext(image_path)[0] + "_flat.png")
|
||||
return {"ok": True, "out_path": out, "size": [size or 0, size or 0], "error": ""}
|
||||
|
||||
def fake_upload(image_path, server, timeout=60.0):
|
||||
captured["upload_in"] = image_path
|
||||
return "base_flat.png"
|
||||
|
||||
monkeypatch.setattr(csmod, "comfyui_submit_workflow", fake_submit)
|
||||
monkeypatch.setattr(csmod, "comfyui_wait_result", fake_wait)
|
||||
monkeypatch.setattr(csmod, "comfyui_fetch_output_image", fake_fetch)
|
||||
monkeypatch.setattr(csmod, "comfyui_flatten_alpha_on_color", fake_flatten)
|
||||
monkeypatch.setattr(csmod, "_upload_image", fake_upload)
|
||||
monkeypatch.setattr(csmod, "_free_vram", lambda server: True)
|
||||
|
||||
|
||||
# --- Golden coherencia: 3D y direccional parten de la MISMA base aplanada ---
|
||||
|
||||
def test_coherence_same_base_feeds_3d_and_directional(monkeypatch, tmp_path):
|
||||
captured = {}
|
||||
_mock_transport(monkeypatch, captured, directions=8)
|
||||
|
||||
def fake_3d(image_path, *, server="127.0.0.1:8188", variant="mini",
|
||||
dest=None, wait_timeout=600.0, **gen):
|
||||
captured["mesh_in"] = image_path
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
glb = os.path.join(dest, "mesh.glb")
|
||||
open(glb, "wb").write(b"glTF")
|
||||
return {"ok": True, "mesh_path": glb, "faces": 1234, "prompt_id": "pid-3d",
|
||||
"error": ""}
|
||||
|
||||
monkeypatch.setattr(csmod, "comfyui_image_to_3d_oneshot", fake_3d)
|
||||
|
||||
res = comfyui_generate_character_set_oneshot(
|
||||
"armored paladin", style="dark fantasy", base_kind="enemy_creature",
|
||||
directions=8, make_3d=True, make_directional=True, seed=7, size=512,
|
||||
directional_size=576, server="http://127.0.0.1:8188",
|
||||
out_dir=str(tmp_path), free_vram=False,
|
||||
)
|
||||
assert res["ok"] is True, res["error"]
|
||||
# la base recortada es la entrada del flatten
|
||||
assert captured["flatten_in"] == res["base_image"]
|
||||
# 3D y direccional parten de la MISMA base aplanada (coherencia)
|
||||
assert captured["mesh_in"] == res["base_flat"]
|
||||
assert captured["upload_in"] == res["base_flat"]
|
||||
assert captured["mesh_in"] == captured["upload_in"]
|
||||
# la base se aplanó al tamaño del modelo direccional
|
||||
assert captured["flatten_size"] == 576
|
||||
assert res["mesh"]["ok"] and res["mesh"]["faces"] == 1234
|
||||
assert res["directional"]["ok"] and len(res["directional"]["views"]) == 8
|
||||
assert "MISMA base" in res["coherence_note"]
|
||||
|
||||
|
||||
# --- Edge: el direccional descarga TODOS los frames, etiquetados por dirección ---
|
||||
|
||||
def test_directional_downloads_all_frames(monkeypatch, tmp_path):
|
||||
captured = {}
|
||||
_mock_transport(monkeypatch, captured, directions=8)
|
||||
|
||||
res = comfyui_generate_character_set_oneshot(
|
||||
"goblin warrior", make_3d=False, make_directional=True, directions=8,
|
||||
seed=7, size=512, out_dir=str(tmp_path), free_vram=False,
|
||||
)
|
||||
assert res["ok"] is True, res["error"]
|
||||
views = res["directional"]["views"]
|
||||
assert len(views) == 8
|
||||
assert [v["direction"] for v in views] == ["S", "SE", "E", "NE", "N", "NW", "W", "SW"]
|
||||
assert all(os.path.isfile(v["path"]) for v in views)
|
||||
assert res["mesh"] is None # no se pidió 3D
|
||||
|
||||
|
||||
# --- Error: fallo aislado del 3D -> set PARCIAL sin abortar el resto ---
|
||||
|
||||
def test_isolated_3d_failure_leaves_partial_set(monkeypatch, tmp_path):
|
||||
captured = {}
|
||||
_mock_transport(monkeypatch, captured, directions=4)
|
||||
|
||||
def fake_3d_oom(image_path, *, server="127.0.0.1:8188", variant="mini",
|
||||
dest=None, wait_timeout=600.0, **gen):
|
||||
return {"ok": False, "mesh_path": "", "faces": None, "prompt_id": "",
|
||||
"error": "simulated OOM en Hunyuan3D"}
|
||||
|
||||
monkeypatch.setattr(csmod, "comfyui_image_to_3d_oneshot", fake_3d_oom)
|
||||
|
||||
res = comfyui_generate_character_set_oneshot(
|
||||
"fire mage", make_3d=True, make_directional=True, directions=4,
|
||||
seed=7, size=512, out_dir=str(tmp_path), free_vram=False,
|
||||
)
|
||||
assert res["ok"] is False # set no perfecto
|
||||
assert res["base_image"] # la base SÍ se generó
|
||||
assert res["mesh"]["ok"] is False and "OOM" in res["mesh"]["error"]
|
||||
assert res["directional"]["ok"] is True # el direccional sobrevivió al fallo del 3D
|
||||
assert len(res["directional"]["views"]) == 4
|
||||
assert "PARCIAL" in res["error"]
|
||||
|
||||
|
||||
# --- Edge: export a Godot para base (sprite) y malla (model) ---
|
||||
|
||||
def test_export_godot_called_for_base_and_mesh(monkeypatch, tmp_path):
|
||||
captured = {}
|
||||
_mock_transport(monkeypatch, captured, directions=4)
|
||||
exports = []
|
||||
|
||||
def fake_3d(image_path, *, server="127.0.0.1:8188", variant="mini",
|
||||
dest=None, wait_timeout=600.0, **gen):
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
glb = os.path.join(dest, "mesh.glb")
|
||||
open(glb, "wb").write(b"glTF")
|
||||
return {"ok": True, "mesh_path": glb, "faces": 10, "prompt_id": "p", "error": ""}
|
||||
|
||||
def fake_export(asset_path, kind, godot_project, *, reimport=True,
|
||||
name=None, godot_bin=None):
|
||||
exports.append((kind, asset_path))
|
||||
return {"ok": True, "kind": kind}
|
||||
|
||||
monkeypatch.setattr(csmod, "comfyui_image_to_3d_oneshot", fake_3d)
|
||||
monkeypatch.setattr(csmod, "comfyui_export_asset_to_godot", fake_export)
|
||||
|
||||
res = comfyui_generate_character_set_oneshot(
|
||||
"paladin", make_3d=True, make_directional=False, seed=7, size=512,
|
||||
out_dir=str(tmp_path), export_godot=str(tmp_path / "godot_proj"),
|
||||
free_vram=False,
|
||||
)
|
||||
kinds = [k for k, _ in exports]
|
||||
assert "sprite" in kinds # base 2D
|
||||
assert "model" in kinds # malla 3D
|
||||
assert len(res["exported"]) == 2
|
||||
Reference in New Issue
Block a user