From 11ef8ef6dbe5ea12317f4c1c4b51abbc8d5c4f40 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Wed, 24 Jun 2026 12:02:04 +0200 Subject: [PATCH] feat(ml): comfyui_build_img2vid_workflow builder img2vid SVD (API format) Builder puro que construye el dict de un workflow ComfyUI img2vid (Stable Video Diffusion) en API format a partir de una imagen estatica. Cadena de 7 nodos: ImageOnlyCheckpointLoader(svd.safetensors, todo-en-uno) + LoadImage -> SVD_img2vid_Conditioning -> VideoLinearCFGGuidance -> KSampler(denoise 1.0) -> VAEDecode -> SaveAnimatedWEBP. SVD condiciona por CLIP_VISION de la imagen (sin prompt de texto); movimiento via motion_bucket_id. class_type/inputs verificados contra /object_info del servidor vivo. Validacion estructural con comfyui_validate_workflow: 0 errores. 4 tests verdes. Sin submit de generacion (GPU en uso por otro agente). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/capabilities/comfyui.md | 1 + .../ml/comfyui_build_img2vid_workflow.md | 107 ++++++++++++ .../ml/comfyui_build_img2vid_workflow.py | 152 ++++++++++++++++++ .../test_comfyui_build_img2vid_workflow.py | 87 ++++++++++ 4 files changed, 347 insertions(+) create mode 100644 python/functions/ml/comfyui_build_img2vid_workflow.md create mode 100644 python/functions/ml/comfyui_build_img2vid_workflow.py create mode 100644 python/functions/ml/tests/test_comfyui_build_img2vid_workflow.py diff --git a/docs/capabilities/comfyui.md b/docs/capabilities/comfyui.md index 0362942b..2b4871f7 100644 --- a/docs/capabilities/comfyui.md +++ b/docs/capabilities/comfyui.md @@ -98,6 +98,7 @@ canónica). El resultado es un `.mp4` vía `CreateVideo → SaveVideo`. | ID | Firma corta | Qué hace | |---|---|---| | [comfyui_build_video_workflow_py_ml](../../python/functions/ml/comfyui_build_video_workflow.md) | `build_video_workflow(prompt, *, model='ltx', negative='', width=512, height=320, num_frames=65, steps=20, seed=0, fps=24) -> dict` | Builder txt2video para LTX-Video 2B (`model='ltx'`, 12 nodos LTXV*) o Wan2.1 1.3B (`model='wan'`, UNETLoader+VAELoader+ModelSamplingSD3). Nombres de modelo reales, defaults conservadores 8 GB. **Pura**. | +| [comfyui_build_img2vid_workflow_py_ml](../../python/functions/ml/comfyui_build_img2vid_workflow.md) | `build_img2vid_workflow(image, *, ckpt='svd.safetensors', width=1024, height=576, video_frames=14, motion_bucket_id=127, fps=6, augmentation_level=0.0, steps=20, cfg=2.5, min_cfg=1.0, seed=0, sampler_name='euler', scheduler='karras', filename_prefix='comfy_svd') -> dict` | Builder img2vid (Stable Video Diffusion): anima una imagen estática a clip corto. ImageOnlyCheckpointLoader(`svd.safetensors`, todo-en-uno) + LoadImage → SVD_img2vid_Conditioning → VideoLinearCFGGuidance → KSampler (denoise 1.0) → VAEDecode → SaveAnimatedWEBP. SVD no usa prompt de texto: condiciona por CLIP_VISION de la imagen; movimiento vía `motion_bucket_id`. **Pura**. | ### Imagen → 3D (Hunyuan3D-2 nativo) — dominio `ml` + `pipelines` (tag `img-to-3d`) diff --git a/python/functions/ml/comfyui_build_img2vid_workflow.md b/python/functions/ml/comfyui_build_img2vid_workflow.md new file mode 100644 index 00000000..c584ba53 --- /dev/null +++ b/python/functions/ml/comfyui_build_img2vid_workflow.md @@ -0,0 +1,107 @@ +--- +name: comfyui_build_img2vid_workflow +kind: function +lang: py +domain: ml +version: "1.0.0" +purity: pure +signature: "def comfyui_build_img2vid_workflow(image: str, *, ckpt: str = \"svd.safetensors\", width: int = 1024, height: int = 576, video_frames: int = 14, motion_bucket_id: int = 127, fps: int = 6, augmentation_level: float = 0.0, steps: int = 20, cfg: float = 2.5, min_cfg: float = 1.0, seed: int = 0, sampler_name: str = \"euler\", scheduler: str = \"karras\", filename_prefix: str = \"comfy_svd\") -> dict" +description: "Construye el dict de un workflow ComfyUI img2vid (Stable Video Diffusion) en API format a partir de una imagen estatica. Cadena: ImageOnlyCheckpointLoader(svd.safetensors -> MODEL, CLIP_VISION, VAE) + LoadImage -> SVD_img2vid_Conditioning(positive, negative, latent) -> VideoLinearCFGGuidance -> KSampler(denoise 1.0) -> VAEDecode -> SaveAnimatedWEBP. SVD no usa prompt de texto: el condicionamiento sale de la imagen via CLIP_VISION del checkpoint todo-en-uno. Movimiento via motion_bucket_id y fps. Pura, sin red ni I/O. Hermana de comfyui_build_video_workflow (txt2video LTX/Wan)." +tags: [comfyui, svd, img2vid, video, ml, workflow] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +params: + - name: image + desc: "Nombre del archivo de imagen base en la carpeta input/ del servidor ComfyUI (lo que carga LoadImage). Es el frame inicial del que SVD deriva el clip." + - name: ckpt + desc: "Nombre del checkpoint SVD tal como lo ve el servidor. Por defecto 'svd.safetensors' (todo-en-uno: UNet + VAE + CLIP image encoder). keyword-only." + - name: width + desc: "Ancho del video en px (multiplo de 8; SVD base entrena a 1024). keyword-only." + - name: height + desc: "Alto del video en px (multiplo de 8; SVD base entrena a 576). keyword-only." + - name: video_frames + desc: "Numero de frames del clip. svd.safetensors es el modelo de 14 frames; la variante xt llega a 25. keyword-only." + - name: motion_bucket_id + desc: "Intensidad de movimiento (1-255 util; 127 por defecto). Mas alto = mas movimiento. keyword-only." + - name: fps + desc: "Frames por segundo con que se condiciona (SVD_img2vid_Conditioning) y se guarda el clip (SaveAnimatedWEBP, alli como float). keyword-only." + - name: augmentation_level + desc: "Ruido anadido a la imagen base (0.0 = fiel; subirlo da mas libertad/movimiento a costa de fidelidad). keyword-only." + - name: steps + desc: "Pasos de sampling del KSampler. keyword-only." + - name: cfg + desc: "Guidance scale del ultimo frame (VideoLinearCFGGuidance interpola de min_cfg al primero hasta cfg al ultimo). SVD usa cfg baja (~2.5). keyword-only." + - name: min_cfg + desc: "Guidance scale del primer frame para VideoLinearCFGGuidance. keyword-only." + - name: seed + desc: "Semilla del sampler. 0 es determinista; cambiar para variar el clip. keyword-only." + - name: sampler_name + desc: "Algoritmo del KSampler. Por defecto 'euler'. keyword-only." + - name: scheduler + desc: "Scheduler del KSampler. Por defecto 'karras'. keyword-only." + - name: filename_prefix + desc: "Prefijo del archivo de salida (.webp animado de SaveAnimatedWEBP). keyword-only." +output: "dict en API format listo para comfyui_submit_workflow. node_ids string; cada valor con class_type + inputs. Devuelve 7 nodos: ImageOnlyCheckpointLoader, LoadImage, SVD_img2vid_Conditioning, VideoLinearCFGGuidance, KSampler, VAEDecode y SaveAnimatedWEBP. El denoise del KSampler se fija a 1.0 (genera desde el latente condicionado, no es img2img)." +tested: true +tests: ["estructura: 7 nodos SVD presentes + ckpt svd.safetensors + image en LoadImage", "cableado: clip_vision/vae [15,1]/[15,2], cond->KSampler 0/1/2, model post VideoLinearCFGGuidance, denoise 1.0", "params reflejados (width/height/video_frames/motion_bucket_id/fps/augmentation_level/steps/cfg/min_cfg/seed/filename_prefix) + fps float en SaveAnimatedWEBP", "determinismo: misma entrada -> mismo dict (builder puro)"] +test_file_path: "python/functions/ml/tests/test_comfyui_build_img2vid_workflow.py" +file_path: "python/functions/ml/comfyui_build_img2vid_workflow.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions")) +from ml.comfyui_build_img2vid_workflow import comfyui_build_img2vid_workflow + +wf = comfyui_build_img2vid_workflow( + "example.png", + width=1024, height=576, video_frames=14, + motion_bucket_id=127, fps=6, steps=20, seed=42, +) +# wf["12"]["class_type"] == "SVD_img2vid_Conditioning" +# wf["30"]["class_type"] == "SaveAnimatedWEBP" +# -> comfyui_submit_workflow(wf) para encolar el clip (necesita GPU) +``` + +O lanzable directo con: `./fn run comfyui_build_img2vid_workflow` (imprime el JSON del workflow SVD de ejemplo). + +## Cuando usarla + +Antes de enviar una generacion de video img2vid (animar una imagen estatica) a +ComfyUI: construye aqui el dict del workflow SVD y pasalo a +`comfyui_submit_workflow`. Usala cuando partes de UNA imagen y quieres un clip +corto derivado de ella (SVD no toma prompt de texto). Para texto -> video usa la +hermana `comfyui_build_video_workflow` (LTX/Wan). Verifica el workflow contra el +servidor con `comfyui_validate_workflow` antes de encolar. + +## Gotchas + +- Es API format (nodos numerados), NO el formato de la UI de ComfyUI. Es lo que + acepta POST /prompt. +- SVD NO usa prompts de texto. El condicionamiento sale de la imagen base via el + CLIP_VISION del checkpoint todo-en-uno; por eso no hay nodos CLIPTextEncode. +- El checkpoint `svd.safetensors` debe existir y ser visible para el servidor + (carpeta de checkpoints o extra_model_paths) o ComfyUI rechaza el workflow con + HTTP 400 al enviarlo. Esta funcion es pura y no valida contra el servidor. +- La imagen `image` debe estar en la carpeta input/ del servidor (subela antes con + el endpoint de upload o el nodo LoadImage de la UI). El validador estructural NO + comprueba la existencia de la imagen (image no es un input de modelo). +- VRAM 8GB: SVD es pesado. Con los defaults (1024x576, 14 frames) el modelo base + puede acercarse al techo de 8GB. Si da OOM, bajar resolucion (768x448) o + video_frames. La generacion real (submit) es un paso posterior con GPU; este + builder solo arma el dict y se valida de forma estructural (offline). +- `svd.safetensors` es el modelo de 14 frames. La variante `svd_xt` admite 25; + con el base, video_frames > 14 puede degradar el clip. +- motion_bucket_id alto = mas movimiento (y mas artefactos). 127 es el centro + recomendado por Stability. +- cfg se mantiene baja (~2.5) y se interpola con VideoLinearCFGGuidance (min_cfg en + el primer frame -> cfg en el ultimo). Subir cfg degrada el video. +- SaveAnimatedWEBP declara `fps` como FLOAT en /object_info: el builder pasa + `float(fps)` para no provocar HTTP 400. El nodo VHS_VideoCombine NO esta instalado + en este servidor; por eso el guardado usa el SaveAnimatedWEBP nativo. diff --git a/python/functions/ml/comfyui_build_img2vid_workflow.py b/python/functions/ml/comfyui_build_img2vid_workflow.py new file mode 100644 index 00000000..6286f4c3 --- /dev/null +++ b/python/functions/ml/comfyui_build_img2vid_workflow.py @@ -0,0 +1,152 @@ +"""Construye un workflow ComfyUI img2vid (SVD) en "API format" (dict de nodos numerados). + +Implementa la plantilla canonica de Stable Video Diffusion de ComfyUI: a partir de +una imagen estatica genera un clip corto de video. El checkpoint `svd.safetensors` +es todo-en-uno (UNet + VAE + CLIP image encoder), cargado con +ImageOnlyCheckpointLoader (da MODEL, CLIP_VISION y VAE de una sola pieza). + +Cadena de nodos: + ImageOnlyCheckpointLoader (MODEL, CLIP_VISION, VAE) + LoadImage (imagen base) -> + SVD_img2vid_Conditioning (positive, negative, latent) -> + VideoLinearCFGGuidance (interpola cfg de min_cfg a cfg a lo largo del clip) -> + KSampler (denoise 1.0) -> VAEDecode (secuencia de frames) -> SaveAnimatedWEBP. + +A diferencia de los modelos txt2video (LTX/Wan), SVD no usa prompts de texto: el +condicionamiento sale de la imagen via el CLIP_VISION del propio checkpoint. El +movimiento se controla con motion_bucket_id (mas alto = mas movimiento) y fps. + +Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos. +""" + + +def comfyui_build_img2vid_workflow( + image: str, + *, + ckpt: str = "svd.safetensors", + width: int = 1024, + height: int = 576, + video_frames: int = 14, + motion_bucket_id: int = 127, + fps: int = 6, + augmentation_level: float = 0.0, + steps: int = 20, + cfg: float = 2.5, + min_cfg: float = 1.0, + seed: int = 0, + sampler_name: str = "euler", + scheduler: str = "karras", + filename_prefix: str = "comfy_svd", +) -> dict: + """Construye el dict del workflow img2vid (SVD) para svd.safetensors. + + Args: + image: nombre del archivo de imagen base dentro de la carpeta input/ del + servidor ComfyUI (lo que carga el nodo LoadImage). Es el frame inicial + del que SVD deriva el clip. + ckpt: nombre del checkpoint SVD tal como lo ve el servidor. Por defecto + "svd.safetensors" (todo-en-uno: UNet + VAE + CLIP image encoder). + keyword-only. + width: ancho del video en px (multiplo de 8; SVD base entrena a 1024). + keyword-only. + height: alto del video en px (multiplo de 8; SVD base entrena a 576). + keyword-only. + video_frames: numero de frames del clip. svd.safetensors es el modelo de + 14 frames; el variante xt llega a 25. keyword-only. + motion_bucket_id: intensidad de movimiento (1-255 util; 127 por defecto). + Mas alto = mas movimiento. keyword-only. + fps: frames por segundo con que se condiciona y se guarda el clip. + keyword-only. + augmentation_level: ruido anadido a la imagen base (0.0 = fiel a la base; + subirlo da mas libertad/movimiento a costa de fidelidad). keyword-only. + steps: pasos de sampling del KSampler. keyword-only. + cfg: guidance scale del ultimo frame (VideoLinearCFGGuidance interpola de + min_cfg al primer frame hasta cfg al ultimo). SVD usa cfg baja (~2.5). + keyword-only. + min_cfg: guidance scale del primer frame para VideoLinearCFGGuidance. + keyword-only. + seed: semilla del sampler (0 = determinista; cambiar para variar el clip). + keyword-only. + sampler_name: algoritmo del KSampler. Por defecto "euler". keyword-only. + scheduler: scheduler del KSampler. Por defecto "karras". keyword-only. + filename_prefix: prefijo del archivo de salida (.webp animado). + keyword-only. + + Returns: + dict en API format listo para comfyui_submit_workflow. Las claves son + node_ids (string) y cada valor tiene class_type + inputs. Devuelve 7 nodos: + ImageOnlyCheckpointLoader, LoadImage, SVD_img2vid_Conditioning, + VideoLinearCFGGuidance, KSampler, VAEDecode y SaveAnimatedWEBP. El denoise + del KSampler se fija a 1.0 (img2vid genera desde latente vacio condicionado, + no es img2img). + """ + return { + "15": { + "class_type": "ImageOnlyCheckpointLoader", + "inputs": {"ckpt_name": ckpt}, + }, + "23": { + "class_type": "LoadImage", + "inputs": {"image": image}, + }, + "12": { + "class_type": "SVD_img2vid_Conditioning", + "inputs": { + "clip_vision": ["15", 1], + "init_image": ["23", 0], + "vae": ["15", 2], + "width": width, + "height": height, + "video_frames": video_frames, + "motion_bucket_id": motion_bucket_id, + "fps": fps, + "augmentation_level": augmentation_level, + }, + }, + "14": { + "class_type": "VideoLinearCFGGuidance", + "inputs": {"model": ["15", 0], "min_cfg": min_cfg}, + }, + "3": { + "class_type": "KSampler", + "inputs": { + "seed": seed, + "steps": steps, + "cfg": cfg, + "sampler_name": sampler_name, + "scheduler": scheduler, + "denoise": 1.0, + "model": ["14", 0], + "positive": ["12", 0], + "negative": ["12", 1], + "latent_image": ["12", 2], + }, + }, + "8": { + "class_type": "VAEDecode", + "inputs": {"samples": ["3", 0], "vae": ["15", 2]}, + }, + "30": { + "class_type": "SaveAnimatedWEBP", + "inputs": { + "images": ["8", 0], + "filename_prefix": filename_prefix, + "fps": float(fps), + "lossless": False, + "quality": 90, + "method": "default", + }, + }, + } + + +if __name__ == "__main__": + import json + + wf = comfyui_build_img2vid_workflow( + "example.png", + motion_bucket_id=127, + fps=6, + video_frames=14, + seed=42, + ) + print(json.dumps(wf, indent=2)) diff --git a/python/functions/ml/tests/test_comfyui_build_img2vid_workflow.py b/python/functions/ml/tests/test_comfyui_build_img2vid_workflow.py new file mode 100644 index 00000000..98e9a9cf --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_build_img2vid_workflow.py @@ -0,0 +1,87 @@ +"""Tests de estructura para comfyui_build_img2vid_workflow (funcion pura, SVD).""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from ml.comfyui_build_img2vid_workflow import comfyui_build_img2vid_workflow +from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct + + +def test_estructura_y_nodos_svd(): + wf = comfyui_build_img2vid_workflow("example.png") + assert_api_format(wf) + cts = class_types(wf) + for ct in ( + "ImageOnlyCheckpointLoader", + "LoadImage", + "SVD_img2vid_Conditioning", + "VideoLinearCFGGuidance", + "KSampler", + "VAEDecode", + "SaveAnimatedWEBP", + ): + assert ct in cts, f"falta {ct} en SVD img2vid" + # El checkpoint SVD es todo-en-uno cargado con el loader image-only. + assert node_by_ct(wf, "ImageOnlyCheckpointLoader")["inputs"]["ckpt_name"] == ( + "svd.safetensors" + ) + # La imagen base entra por LoadImage. + assert node_by_ct(wf, "LoadImage")["inputs"]["image"] == "example.png" + + +def test_cableado_de_nodos(): + wf = comfyui_build_img2vid_workflow("example.png") + cond = node_by_ct(wf, "SVD_img2vid_Conditioning")["inputs"] + # clip_vision y vae salen del checkpoint image-only (outputs 1 y 2). + assert cond["clip_vision"] == ["15", 1] + assert cond["vae"] == ["15", 2] + assert cond["init_image"] == ["23", 0] + ks = node_by_ct(wf, "KSampler")["inputs"] + # KSampler usa el MODEL post VideoLinearCFGGuidance y los 3 outputs del cond. + assert ks["model"] == ["14", 0] + assert ks["positive"] == ["12", 0] + assert ks["negative"] == ["12", 1] + assert ks["latent_image"] == ["12", 2] + assert ks["denoise"] == 1.0 + # VideoLinearCFGGuidance toma el MODEL crudo del checkpoint. + assert node_by_ct(wf, "VideoLinearCFGGuidance")["inputs"]["model"] == ["15", 0] + + +def test_params_se_reflejan(): + wf = comfyui_build_img2vid_workflow( + "fox_front.png", + width=768, + height=448, + video_frames=25, + motion_bucket_id=200, + fps=10, + augmentation_level=0.2, + steps=25, + cfg=3.0, + min_cfg=1.5, + seed=7, + filename_prefix="myclip", + ) + cond = node_by_ct(wf, "SVD_img2vid_Conditioning")["inputs"] + assert cond["width"] == 768 and cond["height"] == 448 + assert cond["video_frames"] == 25 + assert cond["motion_bucket_id"] == 200 + assert cond["fps"] == 10 + assert cond["augmentation_level"] == 0.2 + ks = node_by_ct(wf, "KSampler")["inputs"] + assert ks["steps"] == 25 and ks["cfg"] == 3.0 and ks["seed"] == 7 + assert node_by_ct(wf, "VideoLinearCFGGuidance")["inputs"]["min_cfg"] == 1.5 + save = node_by_ct(wf, "SaveAnimatedWEBP")["inputs"] + assert save["filename_prefix"] == "myclip" + # SaveAnimatedWEBP declara fps como FLOAT en /object_info. + assert save["fps"] == 10.0 + assert isinstance(save["fps"], float) + + +def test_determinista(): + a = comfyui_build_img2vid_workflow("example.png", seed=7) + b = comfyui_build_img2vid_workflow("example.png", seed=7) + assert a == b