diff --git a/docs/capabilities/gamedev-2d.md b/docs/capabilities/gamedev-2d.md index 535b645a..74d4d7c9 100644 --- a/docs/capabilities/gamedev-2d.md +++ b/docs/capabilities/gamedev-2d.md @@ -17,7 +17,8 @@ Filtro: `mcp__registry__fn_search query="" tag="gamedev-2d"`. Documento hermano del grupo `comfyui` (generación genérica de imágenes/video/3D). Diseño del puente: `docs/comfyui-godot-integration.md`. Planes origen: `reports/0135` (pixelart), `reports/0139` (entornos/tiles/iso), `reports/0137` (personajes/sprites), -`reports/0140` (VFX), `reports/0143` (ronda 2b: builders), `reports/0147` (item icons). +`reports/0140` (VFX), `reports/0143` (ronda 2b: builders), `reports/0147` (item icons), +`reports/0149` (parallax background). ## Builders de workflow 2D (`gamedev-2d`, puros — generación) @@ -36,6 +37,7 @@ VFX (ver `reports/0143`). | `comfyui_build_vfx_spritesheet_workflow_py_ml` | `(prompt, *, motion_model="mm_sd_v15_v2.ckpt", num_frames=16, closed_loop=True, lora=None, …) -> dict` | N frames AnimateDiff loop sobre negro (insumo de luma→alpha). 8GB: 16f@512² revienta, usar ≤8f@512² o bajar resolución. | | `comfyui_build_item_icon_workflow_py_ml` | `(item, *, style="game icon, clean, centered", checkpoint="dreamshaper_8…", size=512, transparent=True, lora=None, …) -> dict` | UN icono de item de inventario (espada/poción/anillo/libro/escudo): txt2img cuadrado + prompt scaffold de icono + LoRA estilo opcional + Rembg (alpha). Set coherente = mismo style/checkpoint/lora por item. SD1.5. | | `comfyui_build_portrait_avatar_workflow_py_ml` | `(character, *, style="character portrait", ref_face=None, checkpoint="dreamshaper_8…", size=512, facedetailer=True, lora=None, …) -> dict` | UN retrato/avatar de personaje (busto centrado, cara al espectador, fondo simple): txt2img + prompt scaffold de retrato + FaceDetailer (cara nítida) + LoRA estilo opcional; `ref_face` → IPAdapter-FaceID para rostro consistente entre retratos. Diálogo/perfil/selección. SD1.5. | +| `comfyui_build_parallax_background_workflow_py_ml` | `(scene, *, style="game background, side-scroller…", layers=3, checkpoint="dreamshaper_8…", depth_node="DepthAnythingV2Preprocessor", width=1024, height=512, …) -> dict` | Fondo en capas para parallax 2.5D: genera el fondo apaisado (txt2img) + su depth map (`DepthAnythingV2Preprocessor` sobre el VAEDecode), dos SaveImage. El split en N bandas por profundidad es post (GAP: `split_parallax_layers`, aún no creada). Probado e2e en GPU (`reports/0149`). SD1.5. | ## Funciones de post-proceso y puente (`gamedev`, CPU) diff --git a/python/functions/ml/comfyui_build_parallax_background_workflow.md b/python/functions/ml/comfyui_build_parallax_background_workflow.md new file mode 100644 index 00000000..82f9b1da --- /dev/null +++ b/python/functions/ml/comfyui_build_parallax_background_workflow.md @@ -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 '_depth_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 + (`_depth_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) diff --git a/python/functions/ml/comfyui_build_parallax_background_workflow.py b/python/functions/ml/comfyui_build_parallax_background_workflow.py new file mode 100644 index 00000000..ac2d9cc2 --- /dev/null +++ b/python/functions/ml/comfyui_build_parallax_background_workflow.py @@ -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 (`_depth_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 + '_depth_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)) diff --git a/python/functions/ml/comfyui_build_parallax_background_workflow_test.py b/python/functions/ml/comfyui_build_parallax_background_workflow_test.py new file mode 100644 index 00000000..87945f5d --- /dev/null +++ b/python/functions/ml/comfyui_build_parallax_background_workflow_test.py @@ -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