feat(gamedev): comfyui_build_parallax_background_workflow — fondo en capas para parallax 2.5D

Builder puro (dict API format) del grupo gamedev-2d: genera el fondo apaisado
(txt2img) y su mapa de profundidad (DepthAnythingV2Preprocessor sobre el VAEDecode),
guardando ambos como PNG. El corte en N bandas por rango de profundidad queda como
post-proceso documentado (gap split_parallax_layers). Compone
comfyui_build_txt2img_workflow. 8 tests offline verdes; probado e2e en GPU
(RTX 3070 8GB lowvram): fondo de bosque + depth map, prompt_id
11763613-33cf-4f63-8405-34f75c1c89be. class_types verificados contra /object_info.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 22:50:07 +02:00
parent 404e2e4d0c
commit 4886305d49
4 changed files with 396 additions and 1 deletions
@@ -0,0 +1,113 @@
---
name: comfyui_build_parallax_background_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_parallax_background_workflow(scene: str, *, style: str = \"game background, side-scroller, parallax layers, detailed, no characters\", layers: int = 3, checkpoint: str = \"dreamshaper_8.safetensors\", negative: str = \"character, person, foreground object, text, watermark, blurry, low quality\", depth_node: str = \"DepthAnythingV2Preprocessor\", depth_model: str = \"depth_anything_v2_vitl.pth\", depth_resolution: int = 512, width: int = 1024, height: int = 512, seed: int = 0, steps: int = 28, cfg: float = 7.0, sampler_name: str = \"dpmpp_2m\", scheduler: str = \"karras\", filename_prefix: str = \"parallax_bg\") -> dict"
description: "Construye el dict (API format) de un workflow ComfyUI para un FONDO DE JUEGO EN CAPAS de parallax 2.5D (side-scroller/plataformas): genera un fondo apaisado con txt2img y estima su MAPA DE PROFUNDIDAD con DepthAnythingV2Preprocessor, guardando ambos PNG (fondo + depth). La separacion en N bandas por rango de profundidad es post-proceso (gap: split_parallax_layers). Compone comfyui_build_txt2img_workflow. Pura, sin red ni I/O. class_types verificados contra /object_info."
tags: [comfyui, ml, gamedev, gamedev-2d, parallax, background, depth, side-scroller, workflow, stable-diffusion]
uses_functions: [comfyui_build_txt2img_workflow_py_ml]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: scene
desc: "Descripcion de la escena del fondo (ej. 'forest at dusk, fantasy', 'ruined city skyline'). No puede estar vacio."
- name: style
desc: "Estilo/encuadre que se concatena a la escena. Por defecto fuerza look de fondo apaisado side-scroller sin personajes. keyword-only."
- name: layers
desc: "Numero de capas de profundidad a derivar del depth map en post-proceso (cielo/lejano/medio/frente). Debe ser >= 2. NO genera nodos: se refleja en el filename del depth map para trazabilidad. keyword-only."
- name: checkpoint
desc: "Checkpoint base. Default 'dreamshaper_8.safetensors' (SD1.5 holgado en 8GB). keyword-only."
- name: negative
desc: "Prompt negativo. Por defecto evita personajes/props en primer plano que rompen un fondo limpio por capas. keyword-only."
- name: depth_node
desc: "class_type del preprocessor de profundidad. Default 'DepthAnythingV2Preprocessor'. Alternativa con misma firma: 'DepthAnythingPreprocessor'. Para MiDaS/Zoe (sin ckpt_name) el builder omite ckpt_name. keyword-only."
- name: depth_model
desc: "Peso del depth_node (enum depth_anything_v2_*). Default vitl (buena calidad, cabe en 8GB). Solo aplica a DepthAnything*. keyword-only."
- name: depth_resolution
desc: "Resolucion interna del preprocessor de profundidad. keyword-only."
- name: width
desc: "Ancho en px (multiplo de 8). Default 1024 (formato apaisado de parallax). keyword-only."
- name: height
desc: "Alto en px (multiplo de 8). Default 512. keyword-only."
- name: seed
desc: "Semilla del KSampler. keyword-only."
- name: steps
desc: "Pasos del KSampler. keyword-only."
- name: cfg
desc: "CFG del KSampler. keyword-only."
- name: sampler_name
desc: "Sampler del KSampler. keyword-only."
- name: scheduler
desc: "Scheduler del KSampler. keyword-only."
- name: filename_prefix
desc: "Prefijo del PNG del fondo en output/. El depth map usa '<filename_prefix>_depth_<layers>L'. keyword-only."
output: "dict en API format listo para comfyui_submit_workflow: txt2img base (fondo apaisado) + un SaveImage para el fondo, mas un nodo de profundidad (depth_node) que toma el IMAGE del VAEDecode y un segundo SaveImage para el depth map. Dos imagenes de salida: el fondo y su mapa de profundidad."
example: "wf = comfyui_build_parallax_background_workflow('forest at dusk, fantasy', layers=4, seed=7)"
file_path: python/functions/ml/comfyui_build_parallax_background_workflow.py
tested: true
tests: ["test_golden_background_plus_depth", "test_edge_scene_style_reflected_in_prompt", "test_edge_layers_reflected_in_depth_filename", "test_edge_dims_reflected_in_latent", "test_edge_alt_depth_node_no_ckpt", "test_error_empty_scene", "test_error_layers_below_two", "test_purity_deterministic_and_no_global_state"]
test_file_path: python/functions/ml/comfyui_build_parallax_background_workflow_test.py
---
Construye el dict (API format) de un workflow ComfyUI para un **fondo de juego en
capas** orientado a parallax 2.5D (side-scroller / plataformas). En lugar de
intentar separar las capas dentro del grafo (caro y fragil), produce las dos
piezas que el motor necesita para el parallax:
1. **El fondo apaisado** (txt2img) — la escena completa, formato ancho típico de un
nivel de plataformas.
2. **Su mapa de profundidad** (`DepthAnythingV2Preprocessor` sobre el IMAGE del
`VAEDecode`) — escala de grises donde el brillo codifica la distancia.
Con el fondo + el depth map, derivar N capas por rango de profundidad
(cielo / fondo lejano / plano medio / frente) es un post-proceso de numpy: umbralar
el depth en `layers` bandas y recortar el fondo con cada máscara a un PNG RGBA. Ese
split es un **gap documentado** (`split_parallax_layers`, aún no creada), no este
builder.
Cableado (verificado contra `/object_info` de ComfyUI 0.26.0):
```
CheckpointLoaderSimple -> ... -> KSampler -> VAEDecode --IMAGE--+-> SaveImage (fondo)
`-> DepthAnythingV2Preprocessor -> SaveImage (depth)
```
## Cuando usarla
Cuando necesites el fondo de un nivel 2D con scroll parallax y quieras las capas
por profundidad. Genera el fondo + el depth en una sola pasada, y deja que el
post-proceso corte las bandas. Sube `layers` para más planos de profundidad
(3-5 es lo habitual). Para texturas tileables del propio fondo (suelos, muros que
se repiten) usa `comfyui_build_seamless_tile_workflow`; para props/edificios iso,
`comfyui_build_isometric_workflow`.
## Gotchas
- **Función pura: no valida contra el server.** Si `depth_node` o su peso no están
instalados, ComfyUI devuelve HTTP 400 al enviar. Verifica con
`comfyui_object_info` (o `curl /object_info`) que `DepthAnythingV2Preprocessor`
existe (lo está). Alternativa con misma firma: `DepthAnythingPreprocessor`. Para
`MiDaS-DepthMapPreprocessor` / `Zoe-DepthMapPreprocessor` (sin `ckpt_name`) pásalo
como `depth_node` y el builder omite `ckpt_name` automáticamente.
- **El peso de DepthAnything se descarga on-demand** al primer uso (controlnet_aux
baja `depth_anything_v2_vitl.pth` ~335 MB desde HF). Requiere red la primera vez;
luego queda cacheado. Para arranque más ligero usa `depth_model="depth_anything_v2_vits.pth"`.
- **`layers` NO genera nodos.** Solo se refleja en el filename del depth map
(`<prefix>_depth_<N>L`). El número real de capas se materializa en el post-proceso
del split, no en este grafo.
- **SD1.5 a 1024x512** (apaisado) puede repetir elementos; si aparecen, baja a
768x384 o usa un checkpoint SDXL. Para 8GB lowvram, 1024x512 SD1.5 + DepthAnything
vitl caben sin OOM (se ejecutan secuencialmente, no a la vez).
- **GAP: `split_parallax_layers`** (numpy) aún no existe — segmentaría el depth en
`layers` bandas y recortaría el fondo a N PNG RGBA. Hasta entonces el split es
manual o se hace en el motor.
## Capability growth log
(sin cambios — v1.0.0)
@@ -0,0 +1,178 @@
"""Construye un workflow ComfyUI de FONDO DE JUEGO EN CAPAS para parallax 2.5D.
Un fondo de parallax (side-scroller / plataformas) se compone de varias capas a
distinta profundidad (cielo, fondo lejano, plano medio, frente) que se desplazan a
velocidades distintas para dar sensacion de profundidad. Generar esas capas en un
solo grafo ComfyUI nativo no es trivial: el camino robusto y barato es
1. generar UN fondo apaisado con txt2img, y
2. estimar su MAPA DE PROFUNDIDAD con un preprocessor (DepthAnything V2),
y dejar la separacion en N bandas por rango de profundidad como POST-proceso
(numpy) fuera del grafo. Este builder produce exactamente eso: el fondo + su depth
map, ambos guardados como PNG. El split en capas es una funcion aparte (gap
documentado: `split_parallax_layers`, aun no creada).
Cableado (verificado contra /object_info de ComfyUI 0.26.0):
CheckpointLoaderSimple ─► ... ─► KSampler ─► VAEDecode ─IMAGE─┬─► SaveImage (fondo)
└─► DepthAnythingV2Preprocessor ─► SaveImage (depth)
class_types reales:
- DepthAnythingV2Preprocessor: inputs image(IMAGE) + ckpt_name(enum de pesos
depth_anything_v2_*) + resolution(INT). RETURN (IMAGE,). Es el preprocessor de
controlnet_aux; descarga el peso elegido on-demand al primer uso.
- SaveImage estandar para fondo y para depth (dos nodos, prefijos distintos).
El parametro `layers` NO genera nodos (el split es post-proceso): se refleja en el
filename_prefix del SaveImage del depth map (`<prefix>_depth_<N>L`) para que, al
bajar los archivos, el nombre conserve cuantas bandas se queria derivar.
Compone comfyui_build_txt2img_workflow. Funcion pura: sin red, sin I/O. No muta el
dict de entrada (copia profunda).
"""
from __future__ import annotations
import copy
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
def _is_link(v) -> bool:
return isinstance(v, list) and len(v) == 2 and isinstance(v[0], str) and isinstance(v[1], int)
def comfyui_build_parallax_background_workflow(
scene: str,
*,
style: str = "game background, side-scroller, parallax layers, detailed, no characters",
layers: int = 3,
checkpoint: str = "dreamshaper_8.safetensors",
negative: str = "character, person, foreground object, text, watermark, blurry, low quality",
depth_node: str = "DepthAnythingV2Preprocessor",
depth_model: str = "depth_anything_v2_vitl.pth",
depth_resolution: int = 512,
width: int = 1024,
height: int = 512,
seed: int = 0,
steps: int = 28,
cfg: float = 7.0,
sampler_name: str = "dpmpp_2m",
scheduler: str = "karras",
filename_prefix: str = "parallax_bg",
) -> dict:
"""Construye el dict (API format) del workflow de un fondo de parallax + su depth map.
Args:
scene: descripcion de la escena del fondo (ej. "forest at dusk, fantasy",
"ruined city skyline", "underwater cavern"). No puede estar vacio.
style: estilo/encuadre que se concatena a la escena. Por defecto fuerza el
look de fondo apaisado de side-scroller sin personajes. keyword-only.
layers: numero de capas de profundidad que se derivaran del depth map en
post-proceso (cielo/lejano/medio/frente...). Debe ser >= 2 (un parallax
necesita al menos dos planos). NO genera nodos en este grafo: se refleja
en el filename del depth map para trazabilidad. keyword-only.
checkpoint: checkpoint base. Default 'dreamshaper_8.safetensors' (SD1.5,
holgado en 8GB). keyword-only.
negative: prompt negativo. Por defecto evita personajes/props en primer
plano, que rompen un fondo limpio por capas. keyword-only.
depth_node: class_type del preprocessor de profundidad. Default
'DepthAnythingV2Preprocessor'. Alternativas instaladas con la misma
firma (image+ckpt_name+resolution): 'DepthAnythingPreprocessor'. Para
MiDaS/Zoe (que NO tienen ckpt_name) edita el nodo a mano. keyword-only.
depth_model: peso del depth_node (enum depth_anything_v2_*). Default vitl
(buena calidad, cabe en 8GB). Solo aplica a los nodos DepthAnything*.
keyword-only.
depth_resolution: resolucion interna del preprocessor de profundidad.
keyword-only.
width: ancho en px (multiplo de 8). Default 1024 (formato apaisado tipico de
parallax). keyword-only.
height: alto en px (multiplo de 8). Default 512. keyword-only.
seed, steps, cfg, sampler_name, scheduler: parametros del KSampler.
keyword-only.
filename_prefix: prefijo del PNG del fondo en output/. El depth map usa
'<filename_prefix>_depth_<layers>L'. keyword-only.
Returns:
dict en API format listo para comfyui_submit_workflow: txt2img base (fondo
apaisado) + un SaveImage para el fondo, mas un nodo de profundidad
(depth_node) que toma el IMAGE del VAEDecode y un segundo SaveImage para el
depth map. Dos imagenes de salida: el fondo y su mapa de profundidad.
Raises:
ValueError: si scene esta vacio, layers < 2, o la base no tiene VAEDecode.
"""
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
if not scene or not scene.strip():
raise ValueError(
"comfyui_build_parallax_background_workflow: 'scene' no puede estar vacio"
)
if not isinstance(layers, int) or layers < 2:
raise ValueError(
"comfyui_build_parallax_background_workflow: 'layers' debe ser un int >= 2 "
f"(un parallax necesita >=2 planos), no {layers!r}"
)
positive = f"{scene}, {style}".strip().rstrip(",") if style and style.strip() else scene
wf = comfyui_build_txt2img_workflow(
checkpoint,
positive,
negative,
steps=steps,
cfg=cfg,
width=width,
height=height,
seed=seed,
sampler_name=sampler_name,
scheduler=scheduler,
filename_prefix=filename_prefix,
)
wf = copy.deepcopy(wf)
vaedecode_id = next(
(nid for nid, n in wf.items() if n.get("class_type") == "VAEDecode"), None
)
if vaedecode_id is None:
raise ValueError(
"comfyui_build_parallax_background_workflow: no se encontro VAEDecode en la base"
)
if not _is_link(wf[vaedecode_id]["inputs"].get("samples")):
raise ValueError(
"comfyui_build_parallax_background_workflow: el VAEDecode no tiene un IMAGE valido"
)
numeric = [int(k) for k in wf.keys() if str(k).isdigit()]
next_id = (max(numeric) + 1) if numeric else len(wf) + 1
depth_id = str(next_id)
save_depth_id = str(next_id + 1)
# Nodo de profundidad sobre el IMAGE del fondo (VAEDecode output 0).
depth_inputs = {"image": [vaedecode_id, 0], "resolution": depth_resolution}
# ckpt_name solo lo aceptan los DepthAnything*; para MiDaS/Zoe no se incluye.
if depth_node.startswith("DepthAnything"):
depth_inputs["ckpt_name"] = depth_model
wf[depth_id] = {"class_type": depth_node, "inputs": depth_inputs}
# SaveImage del depth map: filename refleja el numero de capas a derivar.
wf[save_depth_id] = {
"class_type": "SaveImage",
"inputs": {
"filename_prefix": f"{filename_prefix}_depth_{layers}L",
"images": [depth_id, 0],
},
}
return wf
if __name__ == "__main__":
import json
wf = comfyui_build_parallax_background_workflow(
"forest at dusk, fantasy, atmospheric",
layers=4,
seed=7,
)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,102 @@
"""Tests offline de comfyui_build_parallax_background_workflow (estructura del dict, sin GPU)."""
import copy
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ml.comfyui_build_parallax_background_workflow import ( # noqa: E402
comfyui_build_parallax_background_workflow,
)
def _by_class(wf, cls):
return [n for n in wf.values() if n["class_type"] == cls]
def _id_of(wf, cls):
return next(nid for nid, n in wf.items() if n["class_type"] == cls)
def test_golden_background_plus_depth():
wf = comfyui_build_parallax_background_workflow("forest at dusk, fantasy")
# Base txt2img presente.
assert len(_by_class(wf, "CheckpointLoaderSimple")) == 1
assert len(_by_class(wf, "KSampler")) == 1
assert len(_by_class(wf, "VAEDecode")) == 1
# Nodo de profundidad sobre el IMAGE del VAEDecode.
depths = _by_class(wf, "DepthAnythingV2Preprocessor")
assert len(depths) == 1
vae_id = _id_of(wf, "VAEDecode")
assert depths[0]["inputs"]["image"] == [vae_id, 0]
assert depths[0]["inputs"]["ckpt_name"] == "depth_anything_v2_vitl.pth"
# Dos SaveImage: uno para el fondo, otro para el depth map.
saves = _by_class(wf, "SaveImage")
assert len(saves) == 2
depth_id = _id_of(wf, "DepthAnythingV2Preprocessor")
save_depth = next(s for s in saves if s["inputs"]["images"] == [depth_id, 0])
assert "depth" in save_depth["inputs"]["filename_prefix"]
def test_edge_scene_style_reflected_in_prompt():
wf = comfyui_build_parallax_background_workflow(
"underwater cavern", style="painterly background, no characters"
)
texts = [n["inputs"]["text"] for n in _by_class(wf, "CLIPTextEncode")]
positive = next(t for t in texts if "underwater cavern" in t)
assert "underwater cavern" in positive
assert "painterly background" in positive
def test_edge_layers_reflected_in_depth_filename():
wf = comfyui_build_parallax_background_workflow("desert canyon", layers=5)
saves = _by_class(wf, "SaveImage")
depth_save = next(s for s in saves if "depth" in s["inputs"]["filename_prefix"])
assert "5L" in depth_save["inputs"]["filename_prefix"]
def test_edge_dims_reflected_in_latent():
wf = comfyui_build_parallax_background_workflow(
"city skyline", width=1280, height=512
)
lat = _by_class(wf, "EmptyLatentImage")[0]
assert lat["inputs"]["width"] == 1280
assert lat["inputs"]["height"] == 512
def test_edge_alt_depth_node_no_ckpt():
# MiDaS no tiene ckpt_name: el builder no debe inyectarlo.
wf = comfyui_build_parallax_background_workflow(
"snowy mountains", depth_node="MiDaS-DepthMapPreprocessor"
)
midas = _by_class(wf, "MiDaS-DepthMapPreprocessor")
assert len(midas) == 1
assert "ckpt_name" not in midas[0]["inputs"]
assert "resolution" in midas[0]["inputs"]
def test_error_empty_scene():
try:
comfyui_build_parallax_background_workflow("")
assert False
except ValueError as e:
assert "scene" in str(e)
def test_error_layers_below_two():
try:
comfyui_build_parallax_background_workflow("forest", layers=1)
assert False
except ValueError as e:
assert "layers" in str(e)
def test_purity_deterministic_and_no_global_state():
a = comfyui_build_parallax_background_workflow("forest", seed=1)
b = comfyui_build_parallax_background_workflow("forest", seed=1)
assert a == b
# Modificar el resultado no afecta a llamadas posteriores.
snapshot = copy.deepcopy(a)
a["3"]["inputs"]["seed"] = 999
c = comfyui_build_parallax_background_workflow("forest", seed=1)
assert c == snapshot