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,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