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:
2026-06-27 09:02:24 +02:00
parent 2bab120d7c
commit 9f0d2e2338
6 changed files with 1059 additions and 0 deletions
@@ -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