feat(gamedev): comfyui_generate_styled_asset_oneshot — aplica estilo a un asset con auto-post + amplía catálogo a 6 estilos
Pipeline one-shot que aplica un style preset curado a un asset en una llamada (kind, subject, style_preset) y auto-ejecuta el post-proceso que el estilo declara: los estilos pixelart (gameboy, pixel-art-retro) salen ya pixelizados del pipeline, cerrando el hueco #1 del sistema de style presets (report 0190) donde el caller tenía que llamar comfyui_pixelize_image a mano. Reutiliza el dispatch _SUPPORTED (kind->builder) de comfyui_generate_asset_pack_oneshot en vez de redefinir el mapa. Parte pura aislada en styled_asset_build_only para validar kind/estilo desconocido sin tocar la GPU. Export a Godot consciente del post (pixelart si hubo pixelize, para fijar el filtro Nearest). Catálogo de estilos ampliado de 3 a 6: cyberpunk-neon (prompt puro SD1.5), low-poly-flat (prompt puro SD1.5), cartoon-cel-shaded (LoRA anime_style_box_sd15 0.7). Verificación: 11 tests offline del pipeline + suite de presets verde (27 passed). Prueba real en GPU: mismo "treasure chest" en cyberpunk-neon, low-poly-flat y gameboy one-shot; gameboy pasa de 17374 colores (crudo) a 4 (paleta Game Boy) auto-pixelizado directo del pipeline. Detalle en reports/0191. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
---
|
||||
name: comfyui_generate_styled_asset_oneshot
|
||||
kind: pipeline
|
||||
lang: py
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_generate_styled_asset_oneshot(kind: str, subject: str, style_preset: str, *, seed: int = 0, server: str = \"127.0.0.1:8188\", out_dir: str | None = None, export_godot: str | None = None, style_override: str | None = None, negative_extra: str | None = None, wait_timeout: float = 600.0, free_vram: bool = False, godot_bin: str | None = None, **builder_extra) -> dict"
|
||||
description: "One-shot del grupo gamedev-2d que aplica un ESTILO curado a un asset en UNA llamada y AUTO-APLICA su post-proceso. Cierra el ultimo hueco manual del sistema de style presets (report 0190): antes habia que encadenar a mano get_gamedev_style_preset -> apply_style_preset -> despachar al builder del kind -> generar -> y llamar a mano comfyui_pixelize_image. Aqui se hace de un tiro y, para los estilos que declaran post (gameboy, pixel-art-retro), el PNG sale YA pixelizado sin paso manual. Flujo: comfyui_get_gamedev_style_preset(style_preset) -> comfyui_apply_style_preset(preset, subject) -> despacho kind->builder REUTILIZANDO el dispatch _SUPPORTED de comfyui_generate_asset_pack_oneshot -> submit/wait/fetch -> auto-post (comfyui_pixelize_image si el preset lo pide) -> export opcional a Godot (como pixelart si hubo pixelize, para fijar el filtro Nearest). Doctrina issue 0087: el registry crece promoviendo a un pipeline one-shot la composicion repetida 'elegir estilo -> aplicarlo a un kind -> generar -> post-procesar', no inflando el helper de presets ni los builders. Un kind/estilo desconocido devuelve ok=False con error claro SIN tocar la GPU. Devuelve {ok, kind, subject, style_preset, seed, prompt_id, raw_path, path, post, post_applied, size, transparent, godot_kind, exported, coherence_note, error}. Impuro: HTTP a ComfyUI + disco + (export) subprocess."
|
||||
tags: [gamedev-2d, pipelines, comfyui, ml]
|
||||
uses_functions: [comfyui_get_gamedev_style_preset_py_ml, comfyui_apply_style_preset_py_ml, comfyui_pixelize_image_py_ml, comfyui_submit_workflow_py_ml, comfyui_wait_result_py_ml, comfyui_fetch_output_image_py_ml, comfyui_generate_asset_pack_oneshot_py_pipelines, comfyui_export_asset_to_godot_py_pipelines]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [comfyui_get_gamedev_style_preset_py_ml, comfyui_apply_style_preset_py_ml, comfyui_pixelize_image_py_ml, comfyui_submit_workflow_py_ml, comfyui_wait_result_py_ml, comfyui_fetch_output_image_py_ml, comfyui_generate_asset_pack_oneshot_py_pipelines, comfyui_export_asset_to_godot_py_pipelines]
|
||||
tested: true
|
||||
test_file_path: "python/functions/pipelines/comfyui_generate_styled_asset_oneshot_test.py"
|
||||
tests: [test_unknown_kind_fails_without_network, test_unknown_style_fails_without_network, test_empty_subject_fails_without_network, test_build_only_preset_drives_workflow, test_build_only_lora_preset_injects_lora, test_catalog_has_at_least_5_styles, test_every_style_builds_a_valid_workflow, test_post_auto_applied_for_gameboy, test_no_post_keeps_raw_for_cyberpunk, test_post_failure_keeps_raw_and_flags_error, test_export_godot_uses_pixelart_when_post]
|
||||
file_path: "python/functions/pipelines/comfyui_generate_styled_asset_oneshot.py"
|
||||
params:
|
||||
- name: kind
|
||||
desc: "tipo de asset a generar; uno de supported_kinds() del grupo gamedev-2d (item_icon, enemy_creature, prop_object, seamless_tile, ui_hud, particle_texture, ... 26 kinds). Un kind desconocido devuelve ok=False con error claro SIN tocar la GPU."
|
||||
- name: subject
|
||||
desc: "que dibujar ('knight character', 'health potion', 'stone wall tile'). Se combina con el prefijo/sufijo del estilo. Vacio -> error sin red. Es el primer posicional."
|
||||
- name: style_preset
|
||||
desc: "nombre del estilo curado a aplicar, del catalogo de comfyui_get_gamedev_style_preset (gameboy, ghibli, pixel-art-retro, cyberpunk-neon, low-poly-flat, cartoon-cel-shaded). Insensible a mayusculas y a '_' vs '-'. Un estilo desconocido devuelve ok=False con error claro (+ lista de disponibles) SIN tocar la GPU."
|
||||
- name: seed
|
||||
desc: "semilla del KSampler para reproducibilidad. keyword-only."
|
||||
- name: server
|
||||
desc: "host:port del servidor ComfyUI; se acepta con o sin esquema (http://). keyword-only."
|
||||
- name: out_dir
|
||||
desc: "directorio local donde descargar el PNG crudo y el post-procesado; None = un dir temporal por (estilo, seed) en tempdir. keyword-only."
|
||||
- name: export_godot
|
||||
desc: "ruta de un proyecto Godot 4; si se da, el asset FINAL (ya post-procesado) se exporta a res://assets/...; si hubo pixelize se exporta como 'pixelart' (fija el filtro Nearest global). None = no exportar. keyword-only."
|
||||
- name: style_override
|
||||
desc: "sustituye el descriptor 'style' del preset (override puntual); None usa el del preset. keyword-only."
|
||||
- name: negative_extra
|
||||
desc: "negativo extra del caller; se MERGEA con el negativo del estilo (no lo reemplaza). keyword-only."
|
||||
- name: wait_timeout
|
||||
desc: "segundos maximos esperando el trabajo en ComfyUI. keyword-only."
|
||||
- name: free_vram
|
||||
desc: "si True hace POST /free ANTES de generar para liberar VRAM de tareas previas (util en 8 GB con modelos ya cargados). keyword-only."
|
||||
- name: godot_bin
|
||||
desc: "binario de Godot para el reimport headless; None autodetecta. keyword-only."
|
||||
- name: builder_extra
|
||||
desc: "kwargs extra reenviados al builder del kind: posicionales requeridos por algunos builders (p.ej. expression='angry' en emote) o passthrough que el builder admita (steps, cfg, width, height, ...). Sobreescriben la coherencia del estilo si se pasan."
|
||||
output: "dict con ok (bool, True si el asset se genero y, si el estilo pedia post, el post se aplico), kind/subject/style_preset/seed (eco; subject es el combinado con el estilo), prompt_id (str, id del trabajo en ComfyUI), raw_path (PNG descargado SIN post), path (PNG FINAL entregable: post-procesado si el estilo lo pedia, si no igual a raw_path), post (spec de post del estilo, {} si no pedia), post_applied ({kind, out_path, n_colors_final, size} en exito | {kind, ok:False, error} si fallo | None si el estilo no pedia post), size (int) y transparent (bool) recomendados por el estilo, godot_kind (bucket Godot usado), exported (resultado de export a Godot o None), coherence_note (str), error (str). Un fallo de post o export se anota en error pero el crudo queda en raw_path."
|
||||
---
|
||||
|
||||
Pipeline que aplica un estilo CURADO (gameboy, ghibli, pixel-art-retro, cyberpunk-neon,
|
||||
low-poly-flat, cartoon-cel-shaded, ...) a un asset de juego de un solo tiro y, lo
|
||||
importante, **auto-ejecuta el post-proceso que el estilo declara**: los estilos pixelart
|
||||
(gameboy, pixel-art-retro) salen ya pixelizados del pipeline, sin tener que llamar a
|
||||
`comfyui_pixelize_image` a mano. Es la pieza que cerraba el sistema de style presets del
|
||||
grupo `gamedev-2d` (report 0190).
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from pipelines.comfyui_generate_styled_asset_oneshot import comfyui_generate_styled_asset_oneshot
|
||||
|
||||
# Estilo CON post: el icono sale YA pixelizado (paleta Game Boy de 4 tonos), sin paso manual
|
||||
res = comfyui_generate_styled_asset_oneshot(
|
||||
"item_icon", "health potion", "gameboy", seed=7, out_dir="/tmp/styled_gameboy",
|
||||
)
|
||||
# res["raw_path"] -> PNG crudo de la difusion
|
||||
# res["path"] -> PNG FINAL ya pixelizado (auto-post) — distinto del crudo
|
||||
# res["post_applied"] -> {"kind":"pixelize","n_colors_final":4, ...}
|
||||
|
||||
# Estilo SIN post: el mismo subject en estilo neón (path == raw_path, no se pixeliza)
|
||||
res = comfyui_generate_styled_asset_oneshot(
|
||||
"enemy_creature", "street samurai", "cyberpunk-neon", seed=5, out_dir="/tmp/styled_cyber",
|
||||
)
|
||||
|
||||
# Con export directo a un proyecto Godot 4 (pixelart -> Nearest filter global automatico)
|
||||
res = comfyui_generate_styled_asset_oneshot(
|
||||
"prop_object", "treasure chest", "pixel-art-retro", seed=3,
|
||||
export_godot=os.path.expanduser("~/gamedev/projects/dungeon"),
|
||||
)
|
||||
|
||||
# Kind que exige un posicional extra (emote.expression) -> pasarlo como kwarg
|
||||
res = comfyui_generate_styled_asset_oneshot(
|
||||
"emote", "hero", "cartoon-cel-shaded", expression="angry", seed=1,
|
||||
)
|
||||
```
|
||||
|
||||
Lanzable tambien por `fn run` (despacha como pipeline Python; corre el `__main__` de demo):
|
||||
|
||||
```bash
|
||||
./fn run comfyui_generate_styled_asset_oneshot # demo: item_icon "health potion" estilo gameboy
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando quieras UN asset concreto con un look consistente de un catalogo de estilos
|
||||
ya curados, sin acordarte del checkpoint/LoRA/negativo correctos ni del post-proceso que
|
||||
ese estilo necesita. Es el reemplazo one-shot de "buscar el preset -> aplicarlo al builder
|
||||
-> generar -> pixelizar a mano": una sola llamada con `(kind, subject, style_preset)`. Para
|
||||
un SET de assets variados que comparten un estilo libre (no de catalogo), usa
|
||||
`comfyui_generate_asset_pack_oneshot`. Para las tres representaciones (2D + direccional +
|
||||
3D) de UN personaje, usa `comfyui_generate_character_set_oneshot`. Para añadir un estilo
|
||||
nuevo al catalogo, edita `_PRESETS` en `comfyui_get_gamedev_style_preset` (datos puros) —
|
||||
este pipeline lo recoge sin tocarse.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Auto-post = la razon de existir.** Si el estilo declara `post` (gameboy, pixel-art-retro
|
||||
traen `{"pixelize": {...}}`), el pipeline lo ejecuta sobre el PNG descargado y devuelve la
|
||||
ruta post-procesada en `path` (el crudo queda en `raw_path`). Para estilos sin post
|
||||
(ghibli, cyberpunk-neon, low-poly-flat, cartoon-cel-shaded) `path == raw_path`.
|
||||
- **post + transparent son incompatibles (el post gana).** `comfyui_pixelize_image` hace
|
||||
`convert("RGB")`, que descarta el alpha. Por eso los presets con post traen
|
||||
`transparent=False` (asset opaco). No pidas un estilo con post sobre un kind donde
|
||||
necesites alpha: el pixelize aplanaria el recorte. Los estilos sin post respetan el
|
||||
`transparent` que recomienden (cyberpunk-neon False, cartoon-cel-shaded/low-poly-flat True).
|
||||
- **El post elige el bucket Godot.** Si hubo pixelize, el export va como `pixelart`
|
||||
(asegura `default_texture_filter=0` Nearest en project.godot); si no, usa el bucket
|
||||
natural del builder (sprite/vfx/tileset).
|
||||
- **Reutiliza el dispatch del pack.** El mapa `kind -> (godot_kind, builder)` NO se redefine
|
||||
aqui: se importa `_SUPPORTED` de `comfyui_generate_asset_pack_oneshot`, asi que los 26
|
||||
kinds soportados son exactamente los del pack (una sola fuente de verdad).
|
||||
- **Un kind o estilo desconocido NO toca la GPU.** La resolucion del preset y la
|
||||
construccion del workflow (pasos 1-3) son puras y se hacen antes de encolar; un kind/estilo
|
||||
invalido devuelve `ok=False` con error claro y `prompt_id == ""`. La parte pura es
|
||||
invocable sola con `styled_asset_build_only(...)` para inspeccionar el grafo y el post sin
|
||||
generar.
|
||||
- **VRAM / OOM (RTX 3070 8 GB).** Los presets nuevos usan SD1.5 (dreamshaper_8) a 512 y caben
|
||||
holgados; pixel-art-retro usa SDXL (juggernaut_xl_v11) a 768 y pica mas. Con modelos ya
|
||||
cargados de otra tarea, pasa `free_vram=True` (hace `POST /free` antes de generar) o limpia
|
||||
tu mismo (`POST http://127.0.0.1:8188/free {"unload_models":true,"free_memory":true}`). Si
|
||||
hay OOM, baja la resolucion del preset o el `size` del builder via `builder_extra`; NO se
|
||||
matan procesos.
|
||||
- **Fallo aislado de post/export no borra el crudo.** Si el pixelize o el export fallan, el
|
||||
PNG crudo sigue en `raw_path` y el error se anota; `ok=False` solo si el post pedido fallo.
|
||||
- **`server`** se normaliza (acepta con o sin `http://`); internamente es `host:port`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.0.0 (2026-06-27) — version inicial. Promueve a un pipeline one-shot la secuencia
|
||||
"elegir un style preset curado -> aplicarlo a un kind de asset -> generar -> auto-aplicar
|
||||
su post-proceso" (issue 0087). Cierra el hueco #1 del sistema de style presets (report
|
||||
0190): el post-proceso (pixelize) ya no se llama a mano — los estilos pixelart salen
|
||||
pixelizados directos del pipeline. Reutiliza el dispatch `_SUPPORTED` del pack one-shot
|
||||
para no duplicar el mapa kind->builder.
|
||||
@@ -0,0 +1,412 @@
|
||||
"""comfyui_generate_styled_asset_oneshot — aplica un ESTILO a un asset de un solo tiro.
|
||||
|
||||
One-shot del grupo `gamedev-2d` que cierra el último hueco manual del sistema de
|
||||
*style presets* (report 0190): antes, aplicar un estilo curado (gameboy / ghibli /
|
||||
pixel-art-retro / ...) a un asset exigía encadenar A MANO cuatro pasos —
|
||||
`comfyui_get_gamedev_style_preset` → `comfyui_apply_style_preset` → despachar al
|
||||
builder del `kind` y generar → y, lo que más se olvidaba, llamar a mano al
|
||||
post-proceso (`comfyui_pixelize_image`) que el preset declaraba. Este pipeline lo
|
||||
promueve a UNA sola llamada y, sobre todo, **auto-aplica el post-proceso del preset**:
|
||||
si el estilo pide pixelizar (gameboy, pixel-art-retro), el PNG sale ya pixelizado del
|
||||
pipeline, sin paso manual. Es la doctrina del issue 0087 aplicada al estilo: el
|
||||
registry no crece inflando builders ni el helper de presets, crece promoviendo a un
|
||||
pipeline one-shot la composición repetida "elegir estilo → aplicarlo a un kind →
|
||||
generar → post-procesar".
|
||||
|
||||
Flujo:
|
||||
|
||||
1. comfyui_get_gamedev_style_preset(style_preset) -> receta de estilo (datos puros)
|
||||
2. comfyui_apply_style_preset(preset, subject) -> subject + builder_kwargs + size
|
||||
+ transparent + spec de post
|
||||
3. despacho kind -> builder atómico del registry (REUTILIZA el dispatch _SUPPORTED
|
||||
de comfyui_generate_asset_pack_oneshot, no se redefine el mapa kind->builder)
|
||||
4. comfyui_submit_workflow + comfyui_wait_result + comfyui_fetch_output_image
|
||||
5. AUTO-POST: si el preset declara post (p.ej. {"pixelize": {...}}), se aplica al
|
||||
PNG descargado con comfyui_pixelize_image -> la ruta FINAL ya es la post-procesada
|
||||
6. export opcional a un proyecto Godot (comfyui_export_asset_to_godot); si hubo
|
||||
pixelize, el asset se exporta como `pixelart` (filtro Nearest global en Godot)
|
||||
|
||||
A diferencia de `comfyui_generate_asset_pack_oneshot` (que comparte UN estilo libre
|
||||
concatenado al subject de N assets variados) y de `comfyui_generate_character_set_oneshot`
|
||||
(que produce las tres representaciones de UN personaje), este pipeline aplica UN preset
|
||||
de estilo CURADO (con su checkpoint/LoRA/negativo/post coherentes) a UN asset concreto.
|
||||
Reutiliza el mismo catálogo de `kind`s del pack, así que sirve para cualquiera de los 26
|
||||
builders de sujeto del grupo.
|
||||
|
||||
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)
|
||||
|
||||
# --- presets de estilo (puros) ---
|
||||
from ml.comfyui_apply_style_preset import comfyui_apply_style_preset
|
||||
from ml.comfyui_get_gamedev_style_preset import comfyui_get_gamedev_style_preset
|
||||
|
||||
# --- post-proceso CPU (impuro: disco) ---
|
||||
from ml.comfyui_pixelize_image import comfyui_pixelize_image
|
||||
|
||||
# --- 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
|
||||
|
||||
# --- export opcional a Godot ---
|
||||
from pipelines.comfyui_export_asset_to_godot import comfyui_export_asset_to_godot
|
||||
|
||||
# REUTILIZA el dispatch kind -> (godot_kind, builder) del pack one-shot en vez de
|
||||
# redefinir el mapa: una sola fuente de verdad de los `kind`s soportados y de a qué
|
||||
# builder atómico va cada uno. _CKPT_PARAMS y _first_image también se comparten.
|
||||
from pipelines.comfyui_generate_asset_pack_oneshot import (
|
||||
_CKPT_PARAMS,
|
||||
_SUPPORTED,
|
||||
_first_image,
|
||||
supported_kinds,
|
||||
)
|
||||
|
||||
|
||||
def _free_vram(server: str) -> bool:
|
||||
"""Pide a ComfyUI que descargue modelos y libere VRAM (POST /free).
|
||||
|
||||
Best-effort: en una RTX 3070 de 8 GB con modelos ya cargados de otra tarea, liberar
|
||||
antes de generar evita el OOM. 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_styled_workflow(
|
||||
kind: str,
|
||||
applied: dict,
|
||||
*,
|
||||
seed: int,
|
||||
builder_extra: dict,
|
||||
) -> tuple[dict, str]:
|
||||
"""Construye el workflow de UN asset con el estilo del preset ya aplicado.
|
||||
|
||||
PURO (no toca la red): localiza el builder del `kind` en el dispatch compartido
|
||||
`_SUPPORTED`, introspecciona su firma y le pasa SOLO los kwargs que admita, tomados
|
||||
del resultado de `comfyui_apply_style_preset` (`applied`): el `subject` combinado, el
|
||||
`style`/`checkpoint`/`lora`/`lora_strength`/`negative` del estilo, y la `size`/
|
||||
`transparent` recomendadas. A diferencia del pack (que concatena un `style` libre al
|
||||
subject), aquí el `style` del preset SUSTITUYE el `style` categórico del builder, que
|
||||
es lo que da el look del estilo curado.
|
||||
|
||||
Args:
|
||||
kind: tipo de asset (uno de `supported_kinds()`).
|
||||
applied: dict de `comfyui_apply_style_preset` ({subject, builder_kwargs, size,
|
||||
transparent, post, name}).
|
||||
seed: semilla del KSampler. keyword-only.
|
||||
builder_extra: kwargs extra del caller para posicionales requeridos por algunos
|
||||
builders (p.ej. `expression` en `emote`) o passthrough que el builder admita.
|
||||
keyword-only.
|
||||
|
||||
Returns:
|
||||
(workflow, godot_kind): el dict API-format y el bucket Godot del builder
|
||||
(sprite/vfx/tileset) para el export.
|
||||
|
||||
Raises:
|
||||
ValueError: si el `kind` no está soportado o falta un posicional requerido del
|
||||
builder (p.ej. `expression` en `emote`).
|
||||
"""
|
||||
if kind not in _SUPPORTED:
|
||||
raise ValueError(
|
||||
f"kind {kind!r} no soportado. Soportados: {', '.join(supported_kinds())}"
|
||||
)
|
||||
godot_kind, fn = _SUPPORTED[kind]
|
||||
params = inspect.signature(fn).parameters
|
||||
bk = applied["builder_kwargs"] # {style, checkpoint, lora, lora_strength, negative}
|
||||
|
||||
kw: dict = {}
|
||||
# checkpoint -> el nombre de parámetro que use el builder (checkpoint/ckpt_name/ckpt).
|
||||
for cand in _CKPT_PARAMS:
|
||||
if cand in params:
|
||||
kw[cand] = bk["checkpoint"]
|
||||
break
|
||||
if "style" in params:
|
||||
kw["style"] = bk["style"]
|
||||
if "negative" in params:
|
||||
kw["negative"] = bk["negative"]
|
||||
if bk.get("lora") and "lora" in params:
|
||||
kw["lora"] = bk["lora"]
|
||||
if "lora_strength" in params:
|
||||
kw["lora_strength"] = bk["lora_strength"]
|
||||
if "size" in params and applied.get("size") is not None:
|
||||
kw["size"] = applied["size"]
|
||||
if "transparent" in params:
|
||||
kw["transparent"] = applied["transparent"]
|
||||
if "seed" in params:
|
||||
kw["seed"] = seed
|
||||
|
||||
# Posicionales requeridos más allá del primero (= subject). Ej: emote.expression.
|
||||
pos_extra = [
|
||||
p.name
|
||||
for p in params.values()
|
||||
if p.default is inspect.Parameter.empty
|
||||
and p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
||||
][1:]
|
||||
pos_args = [applied["subject"]]
|
||||
for name in pos_extra:
|
||||
if name not in builder_extra:
|
||||
raise ValueError(
|
||||
f"kind {kind!r} requiere el campo {name!r}; pásalo como kwarg "
|
||||
f"(p.ej. {name}=...)"
|
||||
)
|
||||
pos_args.append(builder_extra[name])
|
||||
|
||||
# Passthrough: cualquier otro kwarg extra del caller que el builder admita
|
||||
# (steps, cfg, rembg_model, width, height, glow, ...). Sobreescribe la coherencia.
|
||||
used = set(pos_extra)
|
||||
for key, val in builder_extra.items():
|
||||
if key in used:
|
||||
continue
|
||||
if key in params:
|
||||
kw[key] = val
|
||||
|
||||
return fn(*pos_args, **kw), godot_kind
|
||||
|
||||
|
||||
def styled_asset_build_only(
|
||||
kind: str,
|
||||
subject: str,
|
||||
style_preset: str,
|
||||
*,
|
||||
seed: int = 0,
|
||||
style_override: str | None = None,
|
||||
negative_extra: str | None = None,
|
||||
**builder_extra,
|
||||
) -> dict:
|
||||
"""Parte PURA del pipeline: resuelve el preset y construye el workflow, sin tocar la red.
|
||||
|
||||
Útil para inspeccionar/testear el grafo y el post-proceso que el pipeline va a aplicar
|
||||
sin generar nada. Es lo que el pipeline hace en sus pasos 1-3.
|
||||
|
||||
Returns:
|
||||
dict {kind, godot_kind, applied, workflow} donde `applied` es el resultado de
|
||||
`comfyui_apply_style_preset` (incluye `post`, la spec de post-proceso) y `workflow`
|
||||
es el dict API-format listo para `comfyui_submit_workflow`.
|
||||
|
||||
Raises:
|
||||
ValueError: si el `kind` o el `style_preset` no existen, o si subject está vacío,
|
||||
o si falta un posicional requerido por el builder.
|
||||
"""
|
||||
preset = comfyui_get_gamedev_style_preset(style_preset) # ValueError si no existe
|
||||
applied = comfyui_apply_style_preset(
|
||||
preset, subject, style=style_override, negative=negative_extra
|
||||
)
|
||||
workflow, godot_kind = _build_styled_workflow(
|
||||
kind, applied, seed=seed, builder_extra=builder_extra
|
||||
)
|
||||
return {
|
||||
"kind": kind, "godot_kind": godot_kind, "applied": applied, "workflow": workflow,
|
||||
}
|
||||
|
||||
|
||||
def comfyui_generate_styled_asset_oneshot(
|
||||
kind: str,
|
||||
subject: str,
|
||||
style_preset: str,
|
||||
*,
|
||||
seed: int = 0,
|
||||
server: str = "127.0.0.1:8188",
|
||||
out_dir: str | None = None,
|
||||
export_godot: str | None = None,
|
||||
style_override: str | None = None,
|
||||
negative_extra: str | None = None,
|
||||
wait_timeout: float = 600.0,
|
||||
free_vram: bool = False,
|
||||
godot_bin: str | None = None,
|
||||
**builder_extra,
|
||||
) -> dict:
|
||||
"""Genera UN asset aplicándole un estilo CURADO de un solo tiro, con auto-post-proceso.
|
||||
|
||||
Toma un `kind` de asset (uno de `supported_kinds()`), un `subject` (qué dibujar) y el
|
||||
nombre de un `style_preset` del catálogo de `comfyui_get_gamedev_style_preset`
|
||||
("gameboy", "ghibli", "pixel-art-retro", "cyberpunk-neon", "low-poly-flat",
|
||||
"cartoon-cel-shaded", ...), y produce el PNG final ya con el estilo aplicado Y su
|
||||
post-proceso ejecutado. Para los estilos que declaran post (gameboy, pixel-art-retro)
|
||||
el resultado sale ya pixelizado del pipeline — sin llamar a `comfyui_pixelize_image` a
|
||||
mano, que es el hueco que este one-shot cierra.
|
||||
|
||||
Args:
|
||||
kind: tipo de asset; uno de `supported_kinds()` (item_icon, enemy_creature,
|
||||
prop_object, seamless_tile, ui_hud, ...). Un kind desconocido devuelve
|
||||
ok=False con error claro SIN tocar la GPU.
|
||||
subject: qué dibujar ("knight character", "health potion", "stone wall tile").
|
||||
Se combina con el prefijo/sufijo del estilo. Vacío -> error sin red.
|
||||
style_preset: nombre del estilo curado a aplicar. Insensible a mayúsculas y a
|
||||
'_' vs '-'. Un estilo desconocido devuelve ok=False con error claro (y la
|
||||
lista de estilos disponibles) SIN tocar la GPU.
|
||||
seed: semilla del KSampler para reproducibilidad. keyword-only.
|
||||
server: host:port del ComfyUI; se acepta con o sin esquema (http://). keyword-only.
|
||||
out_dir: directorio local donde descargar el PNG (crudo y post-procesado); None =
|
||||
un dir temporal por (estilo, seed). keyword-only.
|
||||
export_godot: ruta de un proyecto Godot 4; si se da, el asset FINAL (ya
|
||||
post-procesado) se exporta a res://assets/...; si hubo pixelize se exporta como
|
||||
`pixelart` (fija el filtro Nearest global). None = no exportar. keyword-only.
|
||||
style_override: sustituye el `style` del preset (override puntual); None usa el del
|
||||
preset. keyword-only.
|
||||
negative_extra: negativo extra del caller; se MERGEA con el del estilo (no lo
|
||||
reemplaza). keyword-only.
|
||||
wait_timeout: segundos máximos esperando el trabajo en ComfyUI. keyword-only.
|
||||
free_vram: si True hace POST /free ANTES de generar para liberar VRAM de tareas
|
||||
previas (útil en 8 GB con modelos ya cargados). keyword-only.
|
||||
godot_bin: binario de Godot para el reimport headless; None autodetecta. keyword-only.
|
||||
**builder_extra: kwargs extra para el builder del `kind` (posicionales requeridos
|
||||
como `expression` en `emote`, o passthrough como `steps`/`cfg`/`width`/`height`).
|
||||
|
||||
Returns:
|
||||
dict con:
|
||||
- ok (bool): True si el asset se generó (y, si el estilo pedía post, el post se
|
||||
aplicó). Un fallo de post o de export se anota en `error` pero el crudo queda
|
||||
en `raw_path`.
|
||||
- kind, subject, style_preset, seed (eco de la entrada; `subject` es el combinado).
|
||||
- prompt_id (str): id del trabajo en ComfyUI.
|
||||
- raw_path (str): PNG descargado SIN post-procesar.
|
||||
- path (str): PNG FINAL entregable (post-procesado si el estilo lo pedía; si no,
|
||||
igual a raw_path).
|
||||
- post (dict): la spec de post del estilo ({} si no pedía).
|
||||
- post_applied (dict|None): resultado del post-proceso si se aplicó
|
||||
({kind, out_path, n_colors_final, size} en éxito; {kind, ok:False, error} si
|
||||
falló); None si el estilo no pedía post.
|
||||
- size (int), transparent (bool): los que el estilo recomendó.
|
||||
- godot_kind (str): bucket Godot usado (sprite/vfx/tileset, o pixelart si hubo post).
|
||||
- exported (dict|None): resultado de export a Godot, o None.
|
||||
- coherence_note (str), error (str).
|
||||
"""
|
||||
server = server.replace("http://", "").replace("https://", "").strip("/")
|
||||
out: dict = {
|
||||
"ok": False, "kind": kind, "subject": subject, "style_preset": style_preset,
|
||||
"seed": seed, "prompt_id": "", "raw_path": "", "path": "", "post": {},
|
||||
"post_applied": None, "size": None, "transparent": None, "godot_kind": "",
|
||||
"exported": None, "coherence_note": "", "error": "",
|
||||
}
|
||||
|
||||
# --- 1-3. resolver estilo + construir workflow (PURO; valida kind/estilo sin red) ---
|
||||
try:
|
||||
built = styled_asset_build_only(
|
||||
kind, subject, style_preset, seed=seed,
|
||||
style_override=style_override, negative_extra=negative_extra,
|
||||
**builder_extra,
|
||||
)
|
||||
except ValueError as exc:
|
||||
out["error"] = str(exc)
|
||||
return out
|
||||
|
||||
applied = built["applied"]
|
||||
workflow = built["workflow"]
|
||||
out["subject"] = applied["subject"]
|
||||
out["size"] = applied.get("size")
|
||||
out["transparent"] = applied.get("transparent")
|
||||
out["post"] = applied.get("post", {})
|
||||
out["godot_kind"] = built["godot_kind"]
|
||||
out["coherence_note"] = (
|
||||
f"Estilo {applied['name']!r} aplicado a un {kind!r}: "
|
||||
f"checkpoint={applied['builder_kwargs']['checkpoint']!r}, "
|
||||
f"lora={applied['builder_kwargs']['lora']!r}, "
|
||||
f"post={'sí' if out['post'] else 'no'} (auto-aplicado por el pipeline)."
|
||||
)
|
||||
|
||||
asset_dir = out_dir or os.path.join(
|
||||
tempfile.gettempdir(), f"styled_asset_{applied['name']}_seed{seed}"
|
||||
)
|
||||
os.makedirs(asset_dir, exist_ok=True)
|
||||
|
||||
if free_vram:
|
||||
_free_vram(server)
|
||||
|
||||
# --- 4. encolar + esperar + descargar ---
|
||||
try:
|
||||
sub = comfyui_submit_workflow(workflow, server=server)
|
||||
out["prompt_id"] = sub["prompt_id"]
|
||||
except (RuntimeError, KeyError) as exc:
|
||||
out["error"] = f"submit falló: {exc}"
|
||||
return out
|
||||
|
||||
try:
|
||||
outputs = comfyui_wait_result(out["prompt_id"], server=server, timeout=wait_timeout)
|
||||
except (TimeoutError, RuntimeError) as exc:
|
||||
out["error"] = f"wait falló: {exc}"
|
||||
return out
|
||||
|
||||
img = _first_image(outputs)
|
||||
if img is None:
|
||||
out["error"] = f"el workflow no produjo imágenes (outputs={list(outputs)})"
|
||||
return out
|
||||
|
||||
fetched = comfyui_fetch_output_image(
|
||||
img["filename"], subfolder=img.get("subfolder", ""),
|
||||
type_=img.get("type", "output"), server=server, dest_dir=asset_dir,
|
||||
)
|
||||
if not fetched.get("ok"):
|
||||
out["error"] = f"fetch falló: {fetched.get('error')}"
|
||||
return out
|
||||
out["raw_path"] = fetched["path"]
|
||||
out["path"] = fetched["path"] # por defecto el crudo es el final (estilos sin post)
|
||||
|
||||
# --- 5. AUTO-POST: aplica el post-proceso que el estilo declaró ---
|
||||
# Hoy el único post soportado es `pixelize`; el dispatch es extensible (un estilo
|
||||
# nuevo con otro post añade su rama aquí). Si el estilo no pide post, raw == final.
|
||||
post_ok = True
|
||||
pixelize_spec = out["post"].get("pixelize") if isinstance(out["post"], dict) else None
|
||||
if pixelize_spec:
|
||||
base, ext = os.path.splitext(os.path.basename(out["raw_path"]))
|
||||
dst = os.path.join(asset_dir, f"{base}_{applied['name']}{ext or '.png'}")
|
||||
pr = comfyui_pixelize_image(out["raw_path"], dst, **pixelize_spec)
|
||||
if pr.get("ok"):
|
||||
out["path"] = pr["out_path"]
|
||||
out["post_applied"] = {
|
||||
"kind": "pixelize", "out_path": pr["out_path"],
|
||||
"n_colors_final": pr["n_colors_final"], "size": pr["size"],
|
||||
}
|
||||
else:
|
||||
post_ok = False
|
||||
out["post_applied"] = {"kind": "pixelize", "ok": False, "error": pr.get("error")}
|
||||
out["error"] = f"post pixelize falló (asset crudo OK en raw_path): {pr.get('error')}"
|
||||
|
||||
# --- 6. export opcional a Godot (asset FINAL ya post-procesado) ---
|
||||
if export_godot and out["path"]:
|
||||
# Si se pixelizó, exportar como `pixelart` asegura el filtro Nearest global en Godot.
|
||||
godot_kind = "pixelart" if out["post_applied"] and out["post_applied"].get("out_path") \
|
||||
else out["godot_kind"]
|
||||
exp = comfyui_export_asset_to_godot(
|
||||
out["path"], godot_kind, export_godot, reimport=True, godot_bin=godot_bin,
|
||||
)
|
||||
out["exported"] = exp
|
||||
if not exp.get("ok"):
|
||||
out["error"] = (out["error"] + "; " if out["error"] else "") + \
|
||||
f"export falló (asset generado igual): {exp.get('error')}"
|
||||
|
||||
out["ok"] = bool(out["path"]) and post_ok
|
||||
return out
|
||||
|
||||
|
||||
# Alias con el nombre completo del ID para descubrimiento por convención.
|
||||
generate_styled_asset_oneshot = comfyui_generate_styled_asset_oneshot
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
res = comfyui_generate_styled_asset_oneshot(
|
||||
"item_icon", "health potion", "gameboy", seed=7, out_dir="/tmp/styled_asset_demo",
|
||||
)
|
||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Tests de comfyui_generate_styled_asset_oneshot (offline; sin ComfyUI ni GPU).
|
||||
|
||||
Cubre el contrato del pipeline sin tocar la red ni la GPU:
|
||||
- Error: kind desconocido / estilo desconocido / subject vacío -> ok=False SIN red.
|
||||
- Golden build: el preset elige checkpoint/lora/negativo y el workflow los lleva; el
|
||||
`style` del preset SUSTITUYE el del builder; transparent/size del preset se aplican.
|
||||
- Golden auto-post: un estilo con post (gameboy) auto-pixeliza el PNG -> `path` final
|
||||
!= `raw_path` y sale ya pixelizado, sin paso manual.
|
||||
- Edge: un estilo SIN post (cyberpunk-neon) deja `path` == `raw_path` (no toca el crudo).
|
||||
- Edge: catálogo ampliado a >=5 estilos; cada estilo nuevo construye un workflow válido.
|
||||
- Error: un fallo de post deja el crudo en raw_path y ok=False con error claro.
|
||||
- Edge: export a Godot usa el asset FINAL y lo manda como `pixelart` si hubo pixelize.
|
||||
"""
|
||||
|
||||
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_styled_asset_oneshot as smod # noqa: E402
|
||||
from pipelines.comfyui_generate_styled_asset_oneshot import ( # noqa: E402
|
||||
comfyui_generate_styled_asset_oneshot,
|
||||
styled_asset_build_only,
|
||||
)
|
||||
from ml.comfyui_get_gamedev_style_preset import comfyui_get_gamedev_style_preset # noqa: E402
|
||||
|
||||
|
||||
# --- Error paths que NO tocan la red ---
|
||||
|
||||
def test_unknown_kind_fails_without_network():
|
||||
res = comfyui_generate_styled_asset_oneshot("does_not_exist", "knight", "gameboy")
|
||||
assert res["ok"] is False
|
||||
assert "no soportado" in res["error"]
|
||||
assert res["prompt_id"] == "" # no llegó a encolar
|
||||
|
||||
|
||||
def test_unknown_style_fails_without_network():
|
||||
res = comfyui_generate_styled_asset_oneshot("item_icon", "potion", "does_not_exist")
|
||||
assert res["ok"] is False
|
||||
assert "desconocido" in res["error"]
|
||||
assert res["prompt_id"] == ""
|
||||
|
||||
|
||||
def test_empty_subject_fails_without_network():
|
||||
res = comfyui_generate_styled_asset_oneshot("item_icon", " ", "gameboy")
|
||||
assert res["ok"] is False
|
||||
assert res["prompt_id"] == ""
|
||||
|
||||
|
||||
# --- Golden build puro: el preset dirige el workflow ---
|
||||
|
||||
def test_build_only_preset_drives_workflow():
|
||||
built = styled_asset_build_only("item_icon", "knight character", "gameboy", seed=7)
|
||||
blob = json.dumps(built["workflow"])
|
||||
# checkpoint del preset gameboy (SD1.5)
|
||||
assert "dreamshaper_8.safetensors" in blob
|
||||
# el subject combinó el sufijo del estilo gameboy
|
||||
assert "8-bit" in built["applied"]["subject"]
|
||||
# el style del preset SUSTITUYE el style categórico del builder
|
||||
assert "Game Boy" in blob
|
||||
# gameboy declara post pixelize (lo que el pipeline auto-aplicará)
|
||||
assert built["applied"]["post"].get("pixelize")
|
||||
assert built["godot_kind"] == "sprite"
|
||||
|
||||
|
||||
def test_build_only_lora_preset_injects_lora():
|
||||
# cartoon-cel-shaded usa anime_style_box_sd15 -> el workflow debe inyectar el LoRA
|
||||
built = styled_asset_build_only("enemy_creature", "goblin", "cartoon-cel-shaded", seed=1)
|
||||
blob = json.dumps(built["workflow"])
|
||||
assert "anime_style_box_sd15.safetensors" in blob
|
||||
assert any(n.get("class_type") == "LoraLoader" for n in built["workflow"].values())
|
||||
|
||||
|
||||
# --- Catálogo ampliado: >=5 estilos, cada uno construible ---
|
||||
|
||||
def test_catalog_has_at_least_5_styles():
|
||||
cat = comfyui_get_gamedev_style_preset()
|
||||
assert cat["count"] >= 5
|
||||
for nuevo in ("cyberpunk-neon", "low-poly-flat", "cartoon-cel-shaded"):
|
||||
assert nuevo in cat["names"]
|
||||
|
||||
|
||||
def test_every_style_builds_a_valid_workflow():
|
||||
cat = comfyui_get_gamedev_style_preset()
|
||||
for style in cat["names"]:
|
||||
built = styled_asset_build_only("item_icon", "magic ring", style, seed=3)
|
||||
wf = built["workflow"]
|
||||
classes = {n.get("class_type") for n in wf.values()}
|
||||
assert "KSampler" in classes and "SaveImage" in classes, style
|
||||
|
||||
|
||||
# --- Mock de transporte: submit/wait/fetch sin red ---
|
||||
|
||||
def _mock_transport(monkeypatch, *, raw_name="gen.png"):
|
||||
def fake_submit(workflow, server="127.0.0.1:8188", **kw):
|
||||
assert "://" not in server # server normalizado
|
||||
return {"prompt_id": "pid-1", "client_id": "c"}
|
||||
|
||||
def fake_wait(prompt_id, server="127.0.0.1:8188", timeout=600.0, **kw):
|
||||
return {"9": {"images": [{"filename": raw_name, "subfolder": "", "type": "output"}]}}
|
||||
|
||||
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)
|
||||
# PNG real mínimo (8x8 RGB) para que el pixelize de verdad pueda abrirlo.
|
||||
from PIL import Image
|
||||
Image.new("RGB", (64, 64), (123, 80, 200)).save(path)
|
||||
return {"ok": True, "path": path, "size_bytes": 100, "error": ""}
|
||||
|
||||
monkeypatch.setattr(smod, "comfyui_submit_workflow", fake_submit)
|
||||
monkeypatch.setattr(smod, "comfyui_wait_result", fake_wait)
|
||||
monkeypatch.setattr(smod, "comfyui_fetch_output_image", fake_fetch)
|
||||
|
||||
|
||||
# --- Golden auto-post: gameboy pixeliza automáticamente ---
|
||||
|
||||
def test_post_auto_applied_for_gameboy(monkeypatch, tmp_path):
|
||||
_mock_transport(monkeypatch)
|
||||
res = comfyui_generate_styled_asset_oneshot(
|
||||
"item_icon", "health potion", "gameboy", seed=7,
|
||||
server="http://127.0.0.1:8188", out_dir=str(tmp_path),
|
||||
)
|
||||
assert res["ok"] is True, res["error"]
|
||||
assert res["raw_path"] and os.path.isfile(res["raw_path"])
|
||||
# el path FINAL es el post-procesado, distinto del crudo
|
||||
assert res["path"] != res["raw_path"]
|
||||
assert os.path.isfile(res["path"])
|
||||
assert res["post_applied"]["kind"] == "pixelize"
|
||||
# paleta game-boy -> a lo sumo 4 colores en el resultado
|
||||
assert 0 < res["post_applied"]["n_colors_final"] <= 4
|
||||
|
||||
|
||||
# --- Edge: estilo SIN post no toca el crudo ---
|
||||
|
||||
def test_no_post_keeps_raw_for_cyberpunk(monkeypatch, tmp_path):
|
||||
_mock_transport(monkeypatch)
|
||||
res = comfyui_generate_styled_asset_oneshot(
|
||||
"enemy_creature", "street samurai", "cyberpunk-neon", seed=5,
|
||||
out_dir=str(tmp_path),
|
||||
)
|
||||
assert res["ok"] is True, res["error"]
|
||||
assert res["path"] == res["raw_path"] # sin post -> final == crudo
|
||||
assert res["post_applied"] is None
|
||||
assert res["post"] == {}
|
||||
|
||||
|
||||
# --- Error: fallo de post deja el crudo y ok=False ---
|
||||
|
||||
def test_post_failure_keeps_raw_and_flags_error(monkeypatch, tmp_path):
|
||||
_mock_transport(monkeypatch)
|
||||
|
||||
def fake_pixelize(src, dst, **kw):
|
||||
return {"ok": False, "out_path": "", "error": "simulated PIL failure"}
|
||||
|
||||
monkeypatch.setattr(smod, "comfyui_pixelize_image", fake_pixelize)
|
||||
res = comfyui_generate_styled_asset_oneshot(
|
||||
"item_icon", "sword", "gameboy", seed=1, out_dir=str(tmp_path),
|
||||
)
|
||||
assert res["ok"] is False
|
||||
assert res["raw_path"] and os.path.isfile(res["raw_path"]) # crudo preservado
|
||||
assert "post pixelize falló" in res["error"]
|
||||
assert res["post_applied"]["ok"] is False
|
||||
|
||||
|
||||
# --- Edge: export a Godot usa el asset final como pixelart si hubo post ---
|
||||
|
||||
def test_export_godot_uses_pixelart_when_post(monkeypatch, tmp_path):
|
||||
_mock_transport(monkeypatch)
|
||||
captured = {}
|
||||
|
||||
def fake_export(asset_path, kind, godot_project, *, reimport=True,
|
||||
name=None, godot_bin=None):
|
||||
captured["kind"] = kind
|
||||
captured["asset"] = asset_path
|
||||
return {"ok": True, "kind": kind}
|
||||
|
||||
monkeypatch.setattr(smod, "comfyui_export_asset_to_godot", fake_export)
|
||||
res = comfyui_generate_styled_asset_oneshot(
|
||||
"item_icon", "potion", "gameboy", seed=2, out_dir=str(tmp_path),
|
||||
export_godot=str(tmp_path / "godot_proj"),
|
||||
)
|
||||
assert res["ok"] is True, res["error"]
|
||||
assert captured["kind"] == "pixelart" # post -> Nearest filter en Godot
|
||||
assert captured["asset"] == res["path"] # exporta el FINAL post-procesado
|
||||
assert res["exported"]["ok"] is True
|
||||
Reference in New Issue
Block a user