feat(gamedev-2d): pipeline walk_cycle_oneshot — personaje andando en pixel-art animado

Promueve el caso 1 del report 0217 (animacion de sprites de personaje) a un
pipeline one-shot: de un prompt de personaje a un sprite sheet + GIF/WEBP en loop,
frame-by-frame dirigido por pose (ControlNet OpenPose + seed fija + Rembg) con cada
frame pixelizado a NxN RGBA.

Nuevas funciones reutilizables (issue 0087, crecimiento por composicion):
- comfyui_walk_cycle_oneshot (pipeline): orquesta poses -> generacion -> pixelizado
  -> ensamblado. No-throw, salta frames que fallan. Modo openpose (esqueletos reales)
  con fallback prompt-pose.
- render_openpose_walk_skeletons: dibuja N esqueletos OpenPose COCO-18 del walk cycle
  (el insumo que el report 0217 marco como faltante).
- comfyui_pixelize_sprite_png: PNG existente -> NxN RGBA pixel-art real (compone
  crop_to_content + pixeloe_downscale + comfyui_pixelize_image).
- assemble_animated_sprite: frames RGBA -> sprite sheet horizontal + WEBP/GIF loop.
- comfyui_build_walk_cycle_workflow (pura): grafo API del workflow animado para la UI
  (ControlNet OpenPose -> KSampler xN seed fija -> ImageBatch -> Rembg -> SaveAnimatedWEBP).

Verificado en GPU: GIF/WEBP de caballero andando, 4 frames 32x32 (y 64x64) RGBA con
fondo transparente y 16 colores, identidad de silueta consistente, piernas que cambian.
Metodo de poses usado: OpenPose real (sin fallback). Evidencia en report 0221.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 18:14:46 +02:00
parent 36a725ba10
commit 6cc90558d4
10 changed files with 1960 additions and 0 deletions
@@ -0,0 +1,110 @@
---
name: comfyui_walk_cycle_oneshot
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def comfyui_walk_cycle_oneshot(character: str, *, frames: int = 4, size: int = 32, colors: int = 16, fps: int = 8, checkpoint: str = 'IMG_dreamshaper_8.safetensors', ref_image: str | None = None, server: str = '127.0.0.1:8188', dest_dir: str = '~/ComfyUI/output', seed: int = 0, pose_method: str = 'auto', controlnet_strength: float = 0.7, engine: str = 'pixeloe', palette=None, fmt: str = 'webp', **gen_kwargs) -> dict"
description: "Pipeline one-shot: de un prompt de personaje a una animacion de walk cycle en pixel-art (sprite sheet + GIF/WEBP en loop). Genera N frames frame-by-frame dirigidos por pose (ControlNet OpenPose con esqueletos del walk cycle, o fase del paso por prompt como fallback), con seed fija para identidad consistente y Rembg para alpha, y pixeliza cada frame a un grid duro size x size RGBA. Materializa el caso 1 de la investigacion de animacion de sprites (report 0217): personaje = frame-by-frame pose-driven, NUNCA modelos de video. Compone render_openpose_walk_skeletons + comfyui_build_sprite_sheet_workflow + submit/wait/fetch + comfyui_pixelize_sprite_png + assemble_animated_sprite. Impuro: red + GPU + disco. No-throw, salta frames que fallan."
tags: [gamedev-2d, comfyui, pixelart, sprite, animation, walk-cycle, controlnet, openpose, launcher]
uses_functions: ["render_openpose_walk_skeletons_py_ml", "comfyui_build_sprite_sheet_workflow_py_ml", "comfyui_submit_workflow_py_ml", "comfyui_wait_result_py_ml", "comfyui_fetch_output_image_py_ml", "comfyui_pixelize_sprite_png_py_ml", "assemble_animated_sprite_py_ml"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["os", "sys"]
params:
- name: character
desc: "Prompt del personaje (ej. 'pixel art knight, full body, side view'). No vacio. La identidad se mantiene entre frames con seed fija."
- name: frames
desc: "Numero de frames del ciclo (>=2, recomendado 4-8). 4 = las 4 fases canonicas contact-L / passing / contact-R / passing."
- name: size
desc: "Lado del grid pixel-art final por frame en pixeles (32 sprites pequenos, 64 personajes con mas detalle)."
- name: colors
desc: "Numero de colores de la paleta libre por frame (cuantizacion MEDIANCUT) cuando palette es None."
- name: fps
desc: "Cadencia de la animacion en frames por segundo (duration = 1000/fps ms por frame)."
- name: checkpoint
desc: "Checkpoint SD1.5 (ControlNet OpenPose + IPAdapter-FaceID solo instalados en SD1.5; default 'IMG_dreamshaper_8.safetensors')."
- name: ref_image
desc: "Imagen de cara de referencia en el input/ del servidor para IPAdapter-FaceID (segunda ancla de identidad). None = solo seed + prompt."
- name: server
desc: "host:port del servidor ComfyUI (sin esquema). Default 127.0.0.1:8188."
- name: dest_dir
desc: "Directorio donde guardar frames + sprite sheet + animacion (se expande ~)."
- name: seed
desc: "Semilla FIJA del KSampler para TODOS los frames (identidad estable entre poses)."
- name: pose_method
desc: "'openpose' (esqueletos OpenPose -> ControlNet, control exacto), 'prompt' (fase del paso descrita en el prompt, sin esqueletos) o 'auto' (intenta openpose, cae a prompt si el render falla)."
- name: controlnet_strength
desc: "Fuerza del ControlNet OpenPose (0.7 da buen control sin aplastar el estilo). Solo aplica en modo openpose."
- name: engine
desc: "Motor de downscale del pixelizado: 'pixeloe' (contrast-aware, conserva silueta) o 'nearest'."
- name: palette
desc: "None (paleta libre a colors), nombre builtin ('pico-8','nes','game-boy') o lista de hex. Fija ignora colors."
- name: fmt
desc: "Formato de la animacion: 'webp' (recomendado, alpha real) o 'gif' (alpha binario)."
output: "dict {ok, frames:[paths], spritesheet_path, animation_path, size, n_frames, seed, pose_method_used, skipped:[idx], error}. ok=True si se produjo la animacion con >=1 frame. n_frames puede ser < frames si alguno fallo (se salta y se sigue)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/comfyui_walk_cycle_oneshot.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from pipelines.comfyui_walk_cycle_oneshot import comfyui_walk_cycle_oneshot
# Requiere el servidor ComfyUI vivo en 127.0.0.1:8188 (GPU).
res = comfyui_walk_cycle_oneshot(
"pixel art knight, full body, side view",
frames=4, size=32, colors=16, fps=8, seed=42,
dest_dir="/tmp/comfy_walk_cycle",
)
print(res["ok"], res["n_frames"], res["animation_path"], res["pose_method_used"])
# -> True 4 /tmp/comfy_walk_cycle/walk_cycle.webp openpose
```
## Cuando usarla
Cuando quieras una animacion de un personaje en pixel-art (caminar, correr) lista
para un juego 2D, en un solo paso: das el prompt del personaje y recibes el sprite
sheet + el GIF/WEBP en loop. Es la promocion a pipeline (issue 0087) de la receta
del caso 1 del report 0217 — el camino correcto para sprites limpios de personaje
con alpha, frente a AnimateDiff o modelos de video (que ensucian el alpha y no
clavan la pose). Para una sola pose estatica usa `comfyui_pixelart_real_oneshot`;
para varias vistas direccionales (8-way) usa
`comfyui_build_directional_sprite_workflow`.
## Gotchas
- **Server vivo + GPU**: requiere ComfyUI en `server` con la GPU libre. El report
recomienda `POST /free` antes de cargas pesadas de modelo. Cada frame reusa el
mismo checkpoint, asi que el modelo solo se carga una vez.
- **Poses OpenPose**: en modo `openpose` los esqueletos se escriben en el `input/`
del servidor (asume server local; para un server remoto haria falta subirlos con
`POST /upload/image`). Si el ControlNet no produce variacion de piernas
reconocible, usa `pose_method="prompt"`: a 32x32 el detalle de pose se simplifica
y la fase del paso por prompt + seed fija da un walk reconocible.
- **Identidad**: la `seed` es FIJA para todos los frames — esa es la ancla de
identidad. Cambiar la seed entre frames rompe la consistencia del personaje.
`ref_image` (IPAdapter-FaceID) es una segunda ancla opcional; sobre un sprite de
cuerpo entero pequeno aporta sobre todo paleta/ropa (ver report 0217).
- **No-throw, salta frames**: si un frame falla (red, GPU, build) se anade a
`skipped` y la animacion se monta con los que queden. ok=False solo si NINGUN
frame sale.
- **Loop suave**: con `frames=4` el ciclo (contact-L, passing, contact-R, passing)
ya cierra el bucle — el frame siguiente al ultimo vuelve a la primera fase.
- **WEBP vs GIF**: `fmt="webp"` conserva alpha real (lossless); `fmt="gif"` solo
tiene alpha binario (1 bit). Para sprites con transparencia, usa WEBP.
## Capability growth log
- v1.0.0 (2026-06-28) — version inicial. Caso 1 del report 0217 promovido a
pipeline one-shot: walk cycle pixel-art con poses OpenPose (o fallback prompt),
seed fija para identidad, Rembg para alpha, pixelizado a NxN RGBA, sprite sheet +
WEBP/GIF en loop.
@@ -0,0 +1,362 @@
"""comfyui_walk_cycle_oneshot — personaje andando -> sprite pixel-art animado (GIF/WEBP).
Pipeline one-shot (issue 0087) que materializa el caso 1 de la investigacion de
animacion de sprites (report 0217): un ciclo de caminar de personaje se anima
**frame-by-frame dirigido por pose**, NO con modelos de video. Por cada fase del
paso se genera el MISMO personaje cambiando solo la pose (ControlNet OpenPose +
seed fija + IPAdapter opcional), se recorta el fondo a alpha (Rembg) y se pixeliza
a un grid duro de `size` x `size` RGBA. Los N frames se ensamblan en un sprite
sheet horizontal + una animacion en loop (WEBP/GIF).
Dos metodos para las poses, seleccionables con `pose_method`:
- "openpose" (preferido): se dibujan N esqueletos OpenPose del walk cycle
(render_openpose_walk_skeletons) y se alimentan al ControlNet OpenPose, un
frame por pose. El timing del paso es exactamente el dibujado.
- "prompt" (fallback): sin esqueletos, la fase del paso se describe en el prompt
("left leg forward", "passing pose", ...) con seed fija. A 32x32 el detalle de
pose se simplifica, asi que un walk de 4 frames reconocible basta. Se usa
cuando el ControlNet no da poses utilizables o el caller lo pide.
- "auto" (default): intenta "openpose"; si el render de esqueletos falla, cae a
"prompt" y lo refleja en pose_method_used.
La identidad consistente entre frames se ancla con **seed fija** (mismo personaje,
misma semilla) + prompt base fijo, y opcionalmente con `ref_image` (IPAdapter-FaceID
en comfyui_build_sprite_sheet_workflow).
Compone funciones del registry, no reescribe su logica:
render_openpose_walk_skeletons_py_ml (esqueletos OpenPose del walk cycle)
comfyui_build_sprite_sheet_workflow_py_ml(1 frame: identidad + pose + alpha)
comfyui_submit_workflow_py_ml (POST /prompt)
comfyui_wait_result_py_ml (poll /history)
comfyui_fetch_output_image_py_ml (GET /view -> disco)
comfyui_pixelize_sprite_png_py_ml (PNG alta-res -> NxN RGBA pixel-art)
assemble_animated_sprite_py_ml (frames -> sprite sheet + WEBP/GIF loop)
Pipeline impuro: red (HTTP a ComfyUI), GPU (generacion), escritura en disco.
No-throw: cualquier fallo se captura y viaja en el dict de estado (campo error).
Si un frame concreto falla se salta y la animacion se monta con los que haya.
"""
from __future__ import annotations
import os
import sys
# Importa las funciones del registry (mismo arbol python/functions).
_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)
from ml.assemble_animated_sprite import assemble_animated_sprite
from ml.comfyui_build_sprite_sheet_workflow import comfyui_build_sprite_sheet_workflow
from ml.comfyui_fetch_output_image import comfyui_fetch_output_image
from ml.comfyui_pixelize_sprite_png import comfyui_pixelize_sprite_png
from ml.comfyui_submit_workflow import comfyui_submit_workflow
from ml.comfyui_wait_result import comfyui_wait_result
from ml.render_openpose_walk_skeletons import render_openpose_walk_skeletons
# Descriptores de fase del paso para el modo "prompt" (y como apoyo). El ciclo
# canonico de 4 fases de un walk lateral: contacto pierna izquierda, paso (piernas
# juntas, cuerpo arriba), contacto pierna derecha, paso. Para N != 4 se reparten
# ciclicamente sobre estas cuatro.
_WALK_PHASES = [
"walking, left leg forward, mid stride, dynamic walk pose",
"walking, legs together passing position, standing tall",
"walking, right leg forward, mid stride, dynamic walk pose",
"walking, legs together passing position, standing tall",
]
def _phase_for(index: int, total: int) -> str:
"""Devuelve el descriptor de fase del paso para el frame `index` de `total`.
Mapea el frame al ciclo de 4 fases base de forma uniforme, de modo que el
primer y el ultimo frame cierren un loop continuo (el frame `total` volveria a
la fase del frame 0).
"""
pos = (index / max(1, total)) * len(_WALK_PHASES)
return _WALK_PHASES[int(pos) % len(_WALK_PHASES)]
def comfyui_walk_cycle_oneshot(
character: str,
*,
frames: int = 4,
size: int = 32,
colors: int = 16,
fps: int = 8,
checkpoint: str = "IMG_dreamshaper_8.safetensors",
ref_image: str | None = None,
server: str = "127.0.0.1:8188",
dest_dir: str = "~/ComfyUI/output",
seed: int = 0,
pose_method: str = "auto",
comfy_input_dir: str = "~/ComfyUI/input",
controlnet_strength: float = 0.7,
controlnet_end: float = 0.8,
facing: str = "right",
engine: str = "pixeloe",
palette=None,
fmt: str = "webp",
negative: str = "blurry, lowres, extra limbs, deformed, multiple characters",
width: int = 512,
height: int = 768,
steps: int = 24,
cfg: float = 7.0,
comfy_python: str | None = None,
wait_timeout: float = 300.0,
filename_prefix: str = "walk_cycle",
keep_frames: bool = True,
**gen_kwargs,
) -> dict:
"""Genera una animacion de personaje andando en pixel-art, end-to-end.
Args:
character: prompt del personaje ("pixel art knight, full body, side view").
No puede estar vacio. La identidad se mantiene entre frames con seed fija.
frames: numero de frames del ciclo (>=2, recomendado 4-8). keyword-only.
size: lado del grid pixel-art final por frame en pixeles (32 sprites
pequenos, 64 personajes con mas detalle). keyword-only.
colors: numero de colores de la paleta libre por frame (cuantizacion
MEDIANCUT) cuando palette es None. keyword-only.
fps: cadencia de la animacion en frames por segundo. keyword-only.
checkpoint: checkpoint SD1.5 (ControlNet OpenPose + IPAdapter-FaceID solo
instalados en SD1.5; default 'IMG_dreamshaper_8.safetensors'). keyword-only.
ref_image: imagen de cara de referencia en el input/ del servidor para
IPAdapter-FaceID (segunda ancla de identidad). None usa solo seed +
prompt. keyword-only.
server: host:port del servidor ComfyUI (sin esquema). keyword-only.
dest_dir: directorio donde guardar frames + sprite sheet + animacion.
keyword-only.
seed: semilla FIJA del KSampler para todos los frames (identidad estable).
keyword-only.
pose_method: "openpose" (esqueletos OpenPose -> ControlNet, control exacto),
"prompt" (fase del paso descrita en el prompt, sin esqueletos), o "auto"
(intenta openpose, cae a prompt si el render falla). keyword-only.
comfy_input_dir: directorio input/ del servidor ComfyUI donde se escriben
los esqueletos para que el ControlNet los lea (server local).
keyword-only.
controlnet_strength: fuerza del ControlNet OpenPose (0.7 da buen control de
la pose sin aplastar el estilo). keyword-only.
controlnet_end: fraccion de pasos en que el OpenPose deja de aplicarse
(end<1.0 libera los ultimos pasos para pelo/ropa). keyword-only.
facing: direccion a la que mira el personaje en los esqueletos ("right" o
"left"). keyword-only.
engine: motor de downscale del pixelizado ("pixeloe" contrast-aware o
"nearest"). keyword-only.
palette: None (paleta libre a `colors`), nombre builtin ("pico-8", "nes",
"game-boy") o lista de hex. keyword-only.
fmt: formato de la animacion ("webp" recomendado para alpha, o "gif").
keyword-only.
negative: prompt negativo de generacion. keyword-only.
width, height: resolucion de generacion por frame (512x768 vertical encuadra
cuerpo entero). keyword-only.
steps, cfg: parametros del KSampler. keyword-only.
comfy_python: interprete con pixeloe para el pixelizado (None autodetecta
~/ComfyUI/.venv). keyword-only.
wait_timeout: segundos maximos esperando cada frame al server. keyword-only.
filename_prefix: prefijo de los archivos de salida. keyword-only.
keep_frames: si True conserva los PNG de cada frame pixelizado; si False los
borra tras montar la animacion. keyword-only.
**gen_kwargs: params extra para comfyui_build_sprite_sheet_workflow
(sampler_name, scheduler, char_lora, lora_strength, weight, ...).
Returns:
dict con:
- ok (bool): True si se produjo la animacion con >=1 frame.
- frames (list[str]): rutas de los PNG pixelizados (size x size RGBA).
- spritesheet_path (str): ruta del sprite sheet horizontal.
- animation_path (str): ruta de la animacion WEBP/GIF en loop.
- size (int): lado real de cada frame pixelizado.
- n_frames (int): numero de frames producidos (puede ser < `frames` si
alguno fallo y se salto).
- seed (int): semilla usada.
- pose_method_used (str): "openpose" o "prompt" (refleja el fallback).
- skipped (list[int]): indices de frames que fallaron y se saltaron.
- error (str): mensaje de error; vacio si todo OK.
"""
out = {
"ok": False, "frames": [], "spritesheet_path": "", "animation_path": "",
"size": int(size), "n_frames": 0, "seed": int(seed),
"pose_method_used": "", "skipped": [], "error": "",
}
if not character or not character.strip():
out["error"] = "character vacio"
return out
if int(frames) < 2:
out["error"] = f"frames debe ser >= 2, recibido {frames!r}"
return out
if int(size) < 1:
out["error"] = f"size debe ser >= 1, recibido {size!r}"
return out
if pose_method not in ("auto", "openpose", "prompt"):
out["error"] = f"pose_method invalido: {pose_method!r}"
return out
n = int(frames)
dest = os.path.expanduser(dest_dir)
frames_dir = os.path.join(dest, f"{filename_prefix}_frames")
try:
os.makedirs(frames_dir, exist_ok=True)
except OSError as exc:
out["error"] = f"no se pudo crear dest_dir {frames_dir!r}: {exc}"
return out
# --- Fase 0: resolver el metodo de poses + esqueletos OpenPose si aplica. ---
skeleton_names: list[str | None] = [None] * n
method = "prompt" if pose_method == "prompt" else "openpose"
if method == "openpose":
input_dir = os.path.expanduser(comfy_input_dir)
sk = render_openpose_walk_skeletons(
input_dir, frames=n, width=int(width), height=int(height),
facing=facing, filename_prefix=f"{filename_prefix}_pose",
)
if sk.get("ok") and sk.get("skeleton_paths"):
paths = sk["skeleton_paths"]
# El builder espera el basename relativo al input/ del servidor.
skeleton_names = [os.path.basename(p) for p in paths][:n]
# Si se generaron menos esqueletos que frames, rellena con None.
while len(skeleton_names) < n:
skeleton_names.append(None)
else:
# Fallback limpio: no hay esqueletos -> modo prompt.
method = "prompt"
out["error"] = (
f"render de esqueletos OpenPose fallo ({sk.get('error')}); "
f"fallback a pose por prompt"
)
if pose_method == "openpose":
# El caller forzo openpose y no hay esqueletos: aun asi seguimos en
# prompt para no abortar, pero queda anotado en el error.
pass
out["pose_method_used"] = method
# --- Fase 1..N: generar + pixelizar cada frame del ciclo. ---
pixel_frames: list[str] = []
for i in range(n):
pose_skeleton = skeleton_names[i] if method == "openpose" else None
# En modo openpose la pose la fija el esqueleto: no se mete la fase en el
# prompt para no competir con el ControlNet. En modo prompt, la fase guia.
if method == "prompt":
subject_i = f"{character}, {_phase_for(i, n)}"
else:
subject_i = f"{character}, walking"
try:
wf = comfyui_build_sprite_sheet_workflow(
subject_i,
ref_image=ref_image,
pose_skeleton=pose_skeleton,
ckpt_name=checkpoint,
controlnet_strength=controlnet_strength,
controlnet_end=controlnet_end,
transparent=True,
negative=negative,
width=int(width),
height=int(height),
steps=int(steps),
cfg=float(cfg),
seed=int(seed), # FIJA: identidad consistente entre frames.
filename_prefix=f"{filename_prefix}_f{i}",
**gen_kwargs,
)
except (ValueError, TypeError) as exc:
out["skipped"].append(i)
if not out["error"]:
out["error"] = f"build workflow frame {i} fallo: {exc}"
continue
# submit -> wait -> fetch (alta resolucion RGBA). Cualquier fallo de red/
# GPU salta este frame y sigue (error path del DoD).
try:
sub = comfyui_submit_workflow(wf, server=server)
prompt_id = sub["prompt_id"]
outputs = comfyui_wait_result(prompt_id, server=server, timeout=wait_timeout)
except (RuntimeError, KeyError, OSError, TimeoutError) as exc:
out["skipped"].append(i)
if not out["error"]:
out["error"] = f"frame {i} fallo en submit/wait: {exc}"
continue
img = None
for node_out in outputs.values():
images = node_out.get("images") if isinstance(node_out, dict) else None
if images:
img = images[0]
break
if img is None:
out["skipped"].append(i)
continue
fetched = comfyui_fetch_output_image(
img["filename"], subfolder=img.get("subfolder", ""),
type_=img.get("type", "output"), server=server, dest_dir=frames_dir,
)
if not fetched.get("ok"):
out["skipped"].append(i)
if not out["error"]:
out["error"] = f"frame {i} fetch fallo: {fetched.get('error')}"
continue
# Pixelizar el PNG alta-res a un grid duro size x size RGBA.
frame_px = os.path.join(frames_dir, f"{filename_prefix}_px_{i:02d}.png")
px = comfyui_pixelize_sprite_png(
fetched["path"], frame_px, size=int(size), colors=int(colors),
engine=engine, palette=palette, transparent=True, autocrop=True,
comfy_python=comfy_python,
)
if not px.get("ok"):
out["skipped"].append(i)
if not out["error"]:
out["error"] = f"frame {i} pixelizado fallo: {px.get('error')}"
continue
pixel_frames.append(frame_px)
if not pixel_frames:
if not out["error"]:
out["error"] = "ningun frame se genero correctamente"
return out
# --- Ensamblado: sprite sheet horizontal + animacion en loop. ---
asm = assemble_animated_sprite(
pixel_frames, dest, name=filename_prefix, fps=int(fps), fmt=fmt,
loop=True, spritesheet=True,
)
if not asm.get("ok"):
out["frames"] = pixel_frames
out["n_frames"] = len(pixel_frames)
if not out["error"]:
out["error"] = f"ensamblado fallo: {asm.get('error')}"
return out
out["frames"] = pixel_frames
out["spritesheet_path"] = asm.get("spritesheet_path", "")
out["animation_path"] = asm.get("animation_path", "")
out["n_frames"] = len(pixel_frames)
# El size real lo confirma el primer frame ensamblado.
fs = asm.get("frame_size") or [int(size), int(size)]
out["size"] = int(fs[0])
if not keep_frames:
for fp in pixel_frames:
try:
os.remove(fp)
except OSError:
pass
out["ok"] = True
# El error de fallback de poses (si lo hubo) es informativo, no invalida el ok.
return out
if __name__ == "__main__":
import json
res = comfyui_walk_cycle_oneshot(
"pixel art knight, full body, side view",
frames=4, size=32, colors=16, fps=8, seed=42,
dest_dir="/tmp/comfy_walk_cycle",
)
print(json.dumps(res, indent=2))