feat(ml): comfyui_run_foreign_workflow_oneshot + helper fetch_output_video

Pipeline one-shot para ejecutar workflows ComfyUI ajenos end-to-end
(import desde cualquier fuente -> resolve deps -> validate -> submit ->
wait -> fetch del output imagen/video/malla) componiendo 9 funciones
existentes del grupo comfyui. Gate de seguridad: si faltan nodos/modelos
NO encola y los reporta en `missing`; nunca descarga modelos a ciegas y
solo instala nodos custom confiables opt-in (install_nodes + node_repos).

Helper comfyui_fetch_output_video: hermana de fetch_output_image y
fetch_output_mesh para los nodos de video/animacion (SaveAnimatedWEBP,
SaveVideo nativo, VHS_VideoCombine). Localiza el output bajo images/gifs/
videos en /history y lo baja via /view a disco; acepta outputs= de
wait_result para evitar re-consultar /history.

Cierra la pieza marcada por el completeness critic (report 0107) del
roadmap 0064/0087. 13 tests unitarios de las partes puras en verde;
validacion de integracion contra server vivo sin generacion pesada
(report 0110).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 12:53:40 +02:00
parent 898502a321
commit e8a66f0dad
7 changed files with 794 additions and 0 deletions
@@ -0,0 +1,91 @@
---
name: comfyui_fetch_output_video
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_fetch_output_video(prompt_id: str, *, server: str = \"127.0.0.1:8188\", dest: str | None = None, outputs: dict | None = None, timeout: float = 120.0) -> dict"
description: "Localiza y descarga el output de video/animacion de un workflow ComfyUI a disco local. Hermana de comfyui_fetch_output_image y comfyui_fetch_output_mesh pero para los nodos de video (SaveAnimatedWEBP, SaveVideo nativo, VHS_VideoCombine): esos exponen su salida en GET /history bajo 'images', 'gifs' o 'videos' con items {filename, subfolder, type}. Localiza el primer .mp4/.webm/.webp/.gif/.mkv/.mov/.avif, lo baja via GET /view y opcionalmente lo escribe en dest. Acepta outputs= ya obtenido de comfyui_wait_result para evitar re-consultar /history. Impura: HTTP GET + escritura en disco, solo stdlib."
tags: [comfyui, video, fetch, animation, ml, download, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
params:
- name: prompt_id
desc: "id devuelto por comfyui_submit_workflow, de un workflow cuyo nodo de video (SaveAnimatedWEBP/SaveVideo/VHS_VideoCombine) ya termino (usa comfyui_wait_result antes si dudas). Se ignora si se pasa outputs."
- name: server
desc: "host:port del servidor ComfyUI sin esquema. keyword-only."
- name: dest
desc: "Ruta destino. Si None, escribe el basename del video en el cwd. Si es un directorio existente (o termina en separador), escribe el basename dentro. Si es una ruta de archivo, escribe ahi. keyword-only."
- name: outputs
desc: "dict de outputs ya obtenido (el que devuelve comfyui_wait_result). Si se pasa, se busca el video ahi y NO se consulta /history (evita una peticion de red extra). keyword-only."
- name: timeout
desc: "Timeout de cada peticion HTTP en segundos. keyword-only."
output: "dict {ok, path, format, bytes, error}. path = ruta local del archivo de video guardado, format = extension sin punto (ej. 'mp4' o 'webp'), bytes = bytes descargados. Si falla, ok=False y error explica (sin video en los outputs, HTTP, conexion o escritura)."
tested: true
tests:
- "test_is_video_item_por_extension"
- "test_is_video_item_fallback_format"
- "test_find_saveanimatedwebp_bajo_images"
- "test_find_savevideo_mp4_bajo_images"
- "test_find_vhs_bajo_gifs"
- "test_find_prioriza_clave_video_sobre_images"
- "test_find_sin_video_devuelve_none"
test_file_path: "python/functions/ml/comfyui_fetch_output_video_test.py"
file_path: "python/functions/ml/comfyui_fetch_output_video.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_fetch_output_video import comfyui_fetch_output_video
# Tras comfyui_submit_workflow + comfyui_wait_result de un workflow de video
# (SVD img2vid, LTX, AnimateDiff...), baja el .mp4/.webp al disco.
res = comfyui_fetch_output_video("8a278988-8a94-4225-add3-88a406f7101c", dest="/tmp/videos")
# res == {"ok": True, "path": "/tmp/videos/video_00003_.mp4",
# "format": "mp4", "bytes": 199413, "error": ""}
# Si ya tienes los outputs de comfyui_wait_result, pasalos y evita re-consultar /history:
outputs = {"79": {"images": [{"filename": "video_00003_.mp4", "subfolder": "", "type": "output"}]}}
res2 = comfyui_fetch_output_video("ignored", dest="/tmp/videos", outputs=outputs)
```
Lánzalo con el python del venv (import de arriba o heredoc). Nota: `./fn run` directo no aplica porque la firma usa `*` (keyword-only), no soportado por el generador de runner de `fn run`.
## Cuando usarla
Después de generar un vídeo o animación con ComfyUI (img2vid SVD, LTX, AnimateDiff,
Wan, cualquier workflow con SaveAnimatedWEBP / SaveVideo / VHS_VideoCombine), cuando
necesites el archivo `.mp4`/`.webp`/`.webm`/`.gif` real en disco (no solo su nombre):
para reproducirlo, subirlo a un vault, o post-procesarlo. Es la hermana de
`comfyui_fetch_output_image` (imágenes estáticas) y `comfyui_fetch_output_mesh`
(mallas 3D). Para el flujo completo de un workflow ajeno usa el pipeline
`comfyui_run_foreign_workflow_oneshot`, que ya elige este fetch según el tipo de output.
## Gotchas
- Impura: hace HTTP GET a /history y /view y escribe en disco. Requiere el server
vivo y que el prompt YA haya terminado (usa `comfyui_wait_result` antes, o pásale
`outputs=`).
- Distintos nodos exponen el vídeo bajo distintas claves: SVD y SaveVideo nativo lo
ponen bajo `"images"`, VHS_VideoCombine bajo `"gifs"`, otros bajo `"videos"`. La
función inspecciona primero las claves preferentes (gifs/videos/animated) y luego
cualquier clave, así que cubre los tres casos.
- Un `.webp` se trata como animación/vídeo (los nodos de animación de ComfyUI lo
usan). Si tu workflow guarda una imagen `.webp` ESTÁTICA, esta función la bajaría
igual; para garantizar imagen estática usa `comfyui_fetch_output_image` con el
filename concreto.
- Toma el PRIMER archivo de vídeo que encuentra. Si un workflow exporta varios,
baja solo uno; para los demás llama otra vez o usa GET /view con el filename concreto.
- El history se purga al reiniciar el server: si el prompt ya no está, devuelve
`ok=False`. Pasar `outputs=` evita esa consulta y el problema.
- `dest` se interpreta: None -> cwd; directorio EXISTENTE -> dentro; ruta de archivo
-> esa ruta. Un directorio que aún no existe se trata como ruta de archivo: créalo
antes (o termina la ruta en separador).
@@ -0,0 +1,171 @@
"""Localiza y descarga el output de video/animacion de un workflow ComfyUI a disco.
Hermana de comfyui_fetch_output_image y comfyui_fetch_output_mesh, pero para los
nodos de video/animacion (SaveAnimatedWEBP, SaveVideo nativo, VHS_VideoCombine).
Esos nodos exponen su salida en GET /history/{prompt_id} bajo una lista de items
{filename, subfolder, type}; segun el nodo la clave puede ser "images" (SVD y
SaveVideo nativo lo ponen ahi), "gifs" (VHS_VideoCombine) o "videos". Esta funcion
localiza el primer archivo con extension de video/animacion (.mp4/.webm/.webp/.gif/
.mkv/.mov/.avif), lo baja via GET /view a disco y, opcionalmente, lo escribe en `dest`.
Impura: red (HTTP GET a /history y /view) + escritura en disco. Solo stdlib.
"""
import json
import os
import urllib.error
import urllib.parse
import urllib.request
# Extensiones de video/animacion que producen los nodos de ComfyUI.
_VIDEO_EXTS = (".mp4", ".webm", ".webp", ".gif", ".mkv", ".mov", ".avif")
# Claves de output preferentes para video/animacion (se inspeccionan primero).
_VIDEO_KEYS = ("gifs", "videos", "animated")
def _is_video_item(item: dict) -> bool:
"""True si el item de output apunta a un archivo de video/animacion.
Decide por la extension del filename; como fallback, por un campo "format"
que contenga "video" (algunos nodos VHS/SaveVideo lo anotan).
"""
fn = (item.get("filename") or "").lower()
if fn.endswith(_VIDEO_EXTS):
return True
fmt = (item.get("format") or "").lower()
return "video" in fmt
def _find_video_output(outputs: dict) -> dict | None:
"""Busca en los outputs de /history el primer archivo de video/animacion.
Hace dos pasadas: primero en las claves preferentes de video (gifs/videos/
animated), luego en cualquier clave (cubre SVD/SaveVideo nativo, que exponen
el archivo bajo "images"). Devuelve {filename, subfolder, type} o None.
"""
for prefer in (True, False):
for node_out in outputs.values():
if not isinstance(node_out, dict):
continue
for key, items in node_out.items():
if prefer and key not in _VIDEO_KEYS:
continue
if not isinstance(items, list):
continue
for item in items:
if isinstance(item, dict) and _is_video_item(item):
return {
"filename": item.get("filename", ""),
"subfolder": item.get("subfolder", ""),
"type": item.get("type", "output"),
}
return None
def _resolve_dest(dest: str | None, filename: str) -> str:
"""Resuelve la ruta local destino a partir de `dest` y el basename remoto."""
base = os.path.basename(filename)
if dest is None:
return os.path.join(os.getcwd(), base)
expanded = os.path.expanduser(dest)
if os.path.isdir(expanded) or expanded.endswith(os.sep):
return os.path.join(expanded, base)
return expanded
def comfyui_fetch_output_video(
prompt_id: str,
*,
server: str = "127.0.0.1:8188",
dest: str | None = None,
outputs: dict | None = None,
timeout: float = 120.0,
) -> dict:
"""Descarga el video/animacion de un prompt ComfyUI ya ejecutado a disco local.
Args:
prompt_id: id devuelto por comfyui_submit_workflow, de un workflow cuyo
nodo de video (SaveAnimatedWEBP/SaveVideo/VHS_VideoCombine) ya
termino (usa comfyui_wait_result antes si dudas). Se ignora si se
pasa `outputs`.
server: host:port del servidor ComfyUI (sin esquema). keyword-only.
dest: ruta destino. Si None, escribe el basename del video en el cwd.
Si es un directorio (o termina en separador), escribe el basename
dentro. Si es una ruta de archivo, escribe ahi. keyword-only.
outputs: dict de outputs ya obtenido (el que devuelve comfyui_wait_result).
Si se pasa, se busca el video ahi y NO se consulta /history (evita una
peticion de red extra justo despues de esperar). keyword-only.
timeout: timeout de cada peticion HTTP en segundos. keyword-only.
Returns:
dict {ok, path, format, bytes, error}. path = ruta local del archivo de
video guardado; format = extension sin punto (ej. "mp4" o "webp"); bytes =
tamano descargado. Si falla, ok=False y error explica (sin video en los
outputs, HTTP, conexion o escritura).
"""
# 1. Obtener los outputs: del parametro (sin red) o consultando /history.
if outputs is None:
hist_url = f"http://{server}/history/{prompt_id}"
try:
with urllib.request.urlopen(hist_url, timeout=timeout) as resp:
hist = json.loads(resp.read())
except urllib.error.HTTPError as exc:
body = exc.read().decode(errors="replace")[:200]
return {"ok": False, "path": "", "format": "", "bytes": 0,
"error": f"HTTP {exc.code} en {hist_url}: {body}"}
except urllib.error.URLError as exc:
return {"ok": False, "path": "", "format": "", "bytes": 0,
"error": f"no se pudo conectar a {hist_url}: {exc.reason}"}
except json.JSONDecodeError as exc:
return {"ok": False, "path": "", "format": "", "bytes": 0,
"error": f"respuesta no es JSON valido desde {hist_url}: {exc}"}
entry = hist.get(prompt_id)
if not entry:
return {"ok": False, "path": "", "format": "", "bytes": 0,
"error": f"prompt_id {prompt_id} no esta en /history (¿no termino o se purgo?)"}
outputs = entry.get("outputs", {})
video = _find_video_output(outputs or {})
if video is None:
return {"ok": False, "path": "", "format": "", "bytes": 0,
"error": f"sin archivo de video/animacion en los outputs de {prompt_id}"}
# 2. Descargar el archivo via GET /view.
qs = urllib.parse.urlencode({
"filename": video["filename"],
"subfolder": video["subfolder"],
"type": video["type"],
})
view_url = f"http://{server}/view?{qs}"
try:
with urllib.request.urlopen(view_url, timeout=timeout) as resp:
blob = resp.read()
except urllib.error.HTTPError as exc:
body = exc.read().decode(errors="replace")[:200]
return {"ok": False, "path": "", "format": "", "bytes": 0,
"error": f"HTTP {exc.code} en {view_url}: {body}"}
except urllib.error.URLError as exc:
return {"ok": False, "path": "", "format": "", "bytes": 0,
"error": f"no se pudo conectar a {view_url}: {exc.reason}"}
# 3. Escribir a disco.
out_path = _resolve_dest(dest, video["filename"])
try:
parent = os.path.dirname(out_path)
if parent:
os.makedirs(parent, exist_ok=True)
with open(out_path, "wb") as f:
f.write(blob)
except OSError as exc:
return {"ok": False, "path": "", "format": "", "bytes": 0,
"error": f"no se pudo escribir en {out_path!r}: {exc}"}
fmt = os.path.splitext(video["filename"])[1].lstrip(".").lower()
return {"ok": True, "path": out_path, "format": fmt, "bytes": len(blob), "error": ""}
if __name__ == "__main__":
import sys
pid = sys.argv[1] if len(sys.argv) > 1 else "00000000-0000-0000-0000-000000000000"
res = comfyui_fetch_output_video(pid, dest="/tmp/comfy_video")
print(json.dumps(res, indent=2))
@@ -0,0 +1,68 @@
"""Tests de las partes puras de comfyui_fetch_output_video.
Cubren la localizacion del output de video/animacion (la logica con sustancia)
sin tocar red ni GPU: clasificacion por extension, fallback por campo "format",
prioridad de claves de video, y ausencia de video. La descarga real via /view se
valida por integracion contra el server (ver report 0110).
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from comfyui_fetch_output_video import _find_video_output, _is_video_item
def test_is_video_item_por_extension():
assert _is_video_item({"filename": "a.mp4"})
assert _is_video_item({"filename": "a.webp"})
assert _is_video_item({"filename": "a.webm"})
assert _is_video_item({"filename": "a.gif"})
assert not _is_video_item({"filename": "a.png"})
assert not _is_video_item({"filename": "a.glb"})
def test_is_video_item_fallback_format():
# VHS_VideoCombine / SaveVideo a veces no dan extension clara pero anotan format.
assert _is_video_item({"filename": "clip", "format": "video/h264-mp4"})
assert not _is_video_item({"filename": "clip", "format": "image/png"})
def test_find_saveanimatedwebp_bajo_images():
# SVD / SaveAnimatedWEBP exponen el .webp bajo la clave "images".
outs = {"30": {"images": [{"filename": "svd_00001_.webp", "subfolder": "", "type": "output"}]}}
got = _find_video_output(outs)
assert got is not None and got["filename"] == "svd_00001_.webp"
def test_find_savevideo_mp4_bajo_images():
outs = {"79": {"images": [{"filename": "video_00003_.mp4", "subfolder": "", "type": "output"}]}}
assert _find_video_output(outs)["filename"] == "video_00003_.mp4"
def test_find_vhs_bajo_gifs():
# VHS_VideoCombine expone el resultado bajo "gifs".
outs = {"9": {"gifs": [{"filename": "out.webm", "subfolder": "sub", "type": "output"}]}}
got = _find_video_output(outs)
assert got["filename"] == "out.webm" and got["subfolder"] == "sub"
def test_find_prioriza_clave_video_sobre_images():
# Si hay una clave preferente de video y tambien una imagen suelta, gana el video.
outs = {
"1": {"images": [{"filename": "preview.png", "subfolder": "", "type": "output"}]},
"2": {"videos": [{"filename": "final.mp4", "subfolder": "", "type": "output"}]},
}
assert _find_video_output(outs)["filename"] == "final.mp4"
def test_find_sin_video_devuelve_none():
outs = {"1": {"images": [{"filename": "foo.png", "subfolder": "", "type": "output"}]}}
assert _find_video_output(outs) is None
assert _find_video_output({}) is None
if __name__ == "__main__":
import pytest
raise SystemExit(pytest.main([__file__, "-v"]))