10 Commits

Author SHA1 Message Date
egutierrez ca07b25297 feat(comfyui): comfyui_interrupt_queue v1.1.0 — clear_pending + cleared/queue_remaining + tests
Alinea la funcion al contrato de control de cola (punto 3 del roadmap ComfyUI):
- firma keyword-only: clear_pending (vacia pendientes con POST /queue {clear:true}) + timeout
- output {ok, interrupted, cleared, queue_remaining, error}; GET /queue al final
- no lanza en fallo de red: degrada a {ok:False, error}
- test con mock HTTP local (golden + clear + cola vacia + error path), 4/4 verde
- .md autosuficiente con gotchas + capability growth log

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 04:54:14 +02:00
egutierrez fbbff7d5e7 chore: auto-commit (1 archivos)
- logs/ardour_mcp_server.log

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-28 04:48:32 +02:00
egutierrez bdd841d9af merge(comfyui): higiene — 5 funciones de la sesión en capability page + tests list_templates/extract_template 2026-06-28 04:47:48 +02:00
egutierrez 7d33b39859 docs(comfyui): consolidar las 5 funciones nuevas del grupo (tests + capability page)
Higiene del grupo comfyui sobre las 5 funciones de la sesión:
comfyui_build_audio_workflow, comfyui_fetch_output_audio,
comfyui_build_flux_workflow, comfyui_list_templates, comfyui_extract_template.

- Tests nuevos para list_templates y extract_template (lógica pura: localización
  del intérprete, error-path sin el paquete instalado, contrato del dict; golden
  condicional con skip si no hay ComfyUI con comfyui-workflow-templates). 10 tests,
  todos verdes.
- comfyui_list_templates.md / comfyui_extract_template.md: tested true + tests +
  test_file_path.
- Fix drift de test_file_path en comfyui_fetch_output_audio.md (apuntaba a un
  *_test.py inexistente; corregido a tests/test_*.py). Elimina el WARN de fn index.
- docs/capabilities/comfyui.md: subsecciones Audio (ACE-Step) y Templates oficiales.
- docs/capabilities/comfyui-overview.md: sección 05b audio, fetch_output_audio en
  Outputs, Templates oficiales en Workflows I/O. (flux ya estaba documentada.)

fn index limpio (las 5 sin WARN); sin drift nuevo en fn doctor uses-functions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 04:46:47 +02:00
egutierrez a1074d32e7 fix(test): corregir sys.path del test de comfyui_fetch_output_audio 2026-06-27 20:51:09 +02:00
egutierrez fd16453691 feat(ml): generación de audio en ComfyUI (ACE-Step) — comfyui_build_audio_workflow + comfyui_fetch_output_audio 2026-06-27 20:50:34 +02:00
egutierrez 5494507c39 chore: auto-commit (2 archivos)
- .mcp.json
- logs/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-27 20:43:03 +02:00
egutierrez dfb3eda087 merge(ml): comfyui_build_flux_workflow — builder Flux schnell+dev (custom-advanced) 2026-06-27 20:39:04 +02:00
egutierrez 83738d4035 merge(ml): comfyui_list_templates + comfyui_extract_template (extraer grafos de templates oficiales) 2026-06-27 20:37:18 +02:00
egutierrez e178ab8d2d feat(ml): comfyui_list_templates + comfyui_extract_template — extraer grafos de los templates oficiales de ComfyUI
Capitaliza el descubrimiento y extraccion de los workflow templates oficiales que
trae el paquete pip comfyui-workflow-templates 0.10.3 (los del menu Browse
Templates del frontend de ComfyUI). Hasta ahora no habia forma programatica de
listarlos ni extraer su grafo de nodos.

- comfyui_list_templates: lista los 451 templates reales (nombre, bundle/categoria,
  path, n_nodes, node_types). Filtra las ~16 entradas index* no-workflow.
- comfyui_extract_template: extrae el grafo + class_types de un template por nombre;
  to_api convierte a API format reusando comfyui_import_workflow_json.

Desde la 0.10.x el paquete es multi-bundle y ya no expone una carpeta templates/
unica; ambas funciones usan la API oficial comfyui_workflow_templates_core via el
interprete de ComfyUI. node_types aplana subgrafos y descarta los UUID de instancia.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 20:35:46 +02:00
19 changed files with 1926 additions and 45 deletions
+4
View File
@@ -15,6 +15,10 @@
"godot": {
"type": "http",
"url": "http://127.0.0.1:8000/mcp"
},
"ardour": {
"command": "/home/enmanuel/audio-tools/ardour-mcp/target/release/ardour_mcp_server",
"args": []
}
}
}
+6 -1
View File
@@ -72,6 +72,10 @@ sus IDs reales cuando se ejecute `fn index`.
- `comfyui_build_img2vid_workflow_py_ml` (pura) — SVD: condicionamiento por CLIP_VISION (sin prompt de texto).
- `comfyui_build_video_workflow_py_ml` (pura) — txt2video LTX-Video 2B o Wan2.1 1.3B.
### 05b · audio
- `comfyui_build_audio_workflow_py_ml` (pura) — txt2audio ACE-Step: TextEncodeAceStepAudio (tags + lyrics) → EmptyAceStepLatentAudio → KSampler → VAEDecodeAudio → SaveAudio(.flac).
### 06 · upscale / detail
- `comfyui_build_upscale_workflow_py_ml` (pura) — ESRGAN (`model`) o reescalado pixel (`latent`).
@@ -102,9 +106,10 @@ sus IDs reales cuando se ejecute `fn index`.
- Modelos: `comfyui_download_model_py_ml`, `comfyui_list_installed_models_py_ml`, `comfyui_install_custom_node_py_ml`.
- Ejecución: `comfyui_submit_workflow_py_ml`, `comfyui_wait_result_py_ml`, `comfyui_stream_progress_py_ml`, `comfyui_validate_workflow_py_ml`, `comfyui_object_info_py_ml`.
- Cola: `comfyui_queue_manage_py_ml`, `comfyui_interrupt_queue_py_ml`.
- Outputs: `comfyui_fetch_output_image_py_ml`, `comfyui_fetch_output_video_py_ml`, `comfyui_fetch_output_mesh_py_ml`.
- Outputs: `comfyui_fetch_output_image_py_ml`, `comfyui_fetch_output_video_py_ml`, `comfyui_fetch_output_mesh_py_ml`, `comfyui_fetch_output_audio_py_ml`.
- Barridos: `comfyui_batch_generate_py_ml`, `comfyui_build_grid_py_ml`.
- Workflows I/O: `comfyui_import_workflow_json_py_ml`, `comfyui_import_workflow_png_py_ml`, `comfyui_read_png_metadata_py_ml`, `comfyui_download_workflow_py_ml`, `comfyui_run_foreign_workflow_oneshot_py_pipelines`.
- Templates oficiales (paquete `comfyui-workflow-templates`): `comfyui_list_templates_py_ml`, `comfyui_extract_template_py_ml`.
- UI vía CDP: `comfyui_load_workflow_ui_py_browser`, `comfyui_export_workflow_ui_py_browser`, `comfyui_queue_prompt_ui_py_browser`, `comfyui_clear_node_outputs_ui_py_browser`.
## Librería de grafos en disco
+28
View File
@@ -142,6 +142,19 @@ canónica). El resultado es un `.mp4` vía `CreateVideo → SaveVideo`.
| [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**. |
### Audio (txt2audio, ACE-Step) — dominio `ml` (tag `audio-generation`)
ComfyUI ≥ 0.26.0 trae nodos de **audio nativos**. `build_audio_workflow` cubre **ACE-Step v1**
(`AUDIO_ace_step_v1_3.5b.safetensors`, Apache 2.0): música y SFX por texto, con `lyrics` opcional
para voz cantada. El resultado es un `.flac` vía `VAEDecodeAudio → SaveAudio`, que `fetch_output_audio`
localiza y baja a disco (los nodos de audio exponen su salida bajo la clave `"audio"` de `/history`,
no `"images"`).
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_build_audio_workflow_py_ml](../../python/functions/ml/comfyui_build_audio_workflow.md) | `build_audio_workflow(ckpt_name, prompt, *, lyrics='', seconds=10.0, seed=0, steps=50, cfg=5.0, sampler_name='euler', scheduler='simple', shift=5.0, lyrics_strength=1.0, filename_prefix='audio/comfy_audio') -> dict` | Builder **txt2audio (ACE-Step)** en API format: CheckpointLoaderSimple → TextEncodeAceStepAudio (tags=prompt + lyrics) como positive + ConditioningZeroOut como negative + EmptyAceStepLatentAudio(seconds) → ModelSamplingSD3(shift) → KSampler → VAEDecodeAudio → SaveAudio(.flac). La guía va por `cfg`; `lyrics` opcional para voz cantada. **Pura**. |
| [comfyui_fetch_output_audio_py_ml](../../python/functions/ml/comfyui_fetch_output_audio.md) | `fetch_output_audio(prompt_id, *, server='127.0.0.1:8188', dest=None, outputs=None, timeout=120.0) -> dict` | Localiza y descarga el output de **audio** (`.flac`/`.wav`/`.mp3`/`.opus`/`.ogg`/`.m4a`) de `/history` vía GET `/view`. Cubre SaveAudio/SaveAudioMP3/Opus/Advanced (bajo la clave `"audio"`). Hermana de `fetch_output_image`/`video`/`mesh`. Acepta `outputs=` de `wait_result` para no re-consultar `/history`. Impura. |
### Imagen → 3D (Hunyuan3D-2 nativo) — dominio `ml` + `pipelines` (tag `img-to-3d`)
ComfyUI ≥ 0.26.0 trae **soporte nativo de Hunyuan3D-2** (sin custom node): una imagen se
@@ -179,6 +192,21 @@ report `0079`).
| [comfyui_export_workflow_ui_py_browser](../../python/functions/browser/comfyui_export_workflow_ui.md) | `export_workflow_ui(*, port, server_url_substr, api_format=True, save_path, timeout_s) -> dict` | Exporta el grafo actual: API format (`graphToPrompt().output`) o UI graph (`graph.serialize()`); opcional a disco. Impura. |
| [comfyui_refresh_nodes_ui_py_browser](../../python/functions/browser/comfyui_refresh_nodes_ui.md) | `refresh_nodes_ui(*, port, server_url_substr, timeout_s) -> dict` | Refresca los combos (checkpoints/loras/vae) sin recargar la página (`app.refreshComboInNodes`). Impura. |
### Templates oficiales — dominio `ml` (tag `templates`)
Los workflows del menú **"Browse Templates"** del frontend se distribuyen en el paquete pip
`comfyui-workflow-templates` (desde la 0.10.x un meta-paquete multi-bundle con API en
`comfyui_workflow_templates_core`). Estas dos funciones leen ese catálogo localizando el intérprete
de ComfyUI y usando su API oficial vía subprocess (el paquete vive en el venv de ComfyUI, no en el
del registry). Sirven para descubrir grafos oficiales y arrancar un workflow desde una plantilla
probada en vez de construirlo a mano. Si no hay un ComfyUI con el paquete, devuelven `ok=False` con
un error accionable, sin lanzar.
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_list_templates_py_ml](../../python/functions/ml/comfyui_list_templates.md) | `list_templates(comfyui_python=None, bundle=None, name_filter=None, with_nodes=True, workflows_only=True, limit=0) -> dict` | Lista los templates oficiales con su grafo: nombre, bundle/categoría, path en disco, `n_nodes` y `node_types` (class_types reales, aplanando subgrafos y descartando UUID de instancia). Filtra por bundle/nombre; excluye entradas no-workflow por defecto. Impura (lee disco vía el intérprete de ComfyUI). |
| [comfyui_extract_template_py_ml](../../python/functions/ml/comfyui_extract_template.md) | `extract_template(name, comfyui_python=None, to_api=False, server='127.0.0.1:8188') -> dict` | Extrae el grafo completo (formato UI) + `class_types` de un template por su `template_id`. `to_api=True` lo convierte a API format vía `comfyui_import_workflow_json` (requiere servidor ComfyUI vivo). Nombre inexistente → `ok=False` con sugerencias cercanas, sin traceback. Impura. |
## Ejemplo canónico end-to-end (build → load → tune → queue → resultado)
Combina API + UI: construyes el workflow por API, lo cargas en la UI del usuario, ajustas el
File diff suppressed because one or more lines are too long
@@ -0,0 +1,99 @@
---
name: comfyui_build_audio_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_audio_workflow(ckpt_name: str, prompt: str, *, lyrics: str = \"\", seconds: float = 10.0, seed: int = 0, steps: int = 50, cfg: float = 5.0, sampler_name: str = \"euler\", scheduler: str = \"simple\", shift: float = 5.0, lyrics_strength: float = 1.0, filename_prefix: str = \"audio/comfy_audio\") -> dict"
description: "Construye el dict de un workflow ComfyUI texto->audio (ACE-Step) en API format. Cadena con nodos de audio NATIVOS de ComfyUI 0.26.0: CheckpointLoaderSimple(AUDIO_ace_step_v1_3.5b.safetensors -> MODEL, CLIP, VAE) -> TextEncodeAceStepAudio(tags=prompt, lyrics) como positive + ConditioningZeroOut como negative + EmptyAceStepLatentAudio(seconds) -> ModelSamplingSD3(shift) -> KSampler -> VAEDecodeAudio -> SaveAudio(.flac). ACE-Step es abierto (Apache 2.0). Genera musica y SFX por texto; lyrics opcional para voz cantada. Pura, sin red ni I/O. Hermana de audio de comfyui_build_txt2img_workflow."
tags: [comfyui, audio, ace-step, sfx, music, ml, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: ckpt_name
desc: "Nombre del checkpoint ACE-Step tal como lo ve el servidor ComfyUI (ej. 'AUDIO_ace_step_v1_3.5b.safetensors', todo-en-uno: DiT + text encoder + VAE de audio). Debe estar entre los que devuelve comfyui_object_info en CheckpointLoaderSimple."
- name: prompt
desc: "Descripcion del sonido o estilo musical. Va al campo 'tags' de TextEncodeAceStepAudio. Ej. '8-bit coin pickup sound, retro game' o 'lofi hip hop, mellow piano, 90 bpm'."
- name: lyrics
desc: "Letra cantada para musica con voz. Vacio '' para SFX o musica instrumental. keyword-only."
- name: seconds
desc: "Duracion del audio en segundos (min 1.0). Controla el tamano del latente via EmptyAceStepLatentAudio. keyword-only."
- name: seed
desc: "Semilla del KSampler. 0 es determinista; cambiar para variar el resultado. keyword-only."
- name: steps
desc: "Pasos de sampling del KSampler. 50 recomendado para ACE-Step. keyword-only."
- name: cfg
desc: "Classifier-free guidance scale. 5.0 recomendado para ACE-Step. keyword-only."
- name: sampler_name
desc: "Algoritmo del KSampler. Por defecto 'euler'. keyword-only."
- name: scheduler
desc: "Scheduler del KSampler. Por defecto 'simple'. keyword-only."
- name: shift
desc: "Shift del ModelSamplingSD3 aplicado al MODEL antes del sampling. 5.0 recomendado para ACE-Step; mejora la coherencia temporal. keyword-only."
- name: lyrics_strength
desc: "Fuerza del condicionamiento de la letra (1.0 por defecto; sin efecto practico cuando lyrics esta vacio). keyword-only."
- name: filename_prefix
desc: "Prefijo del .flac generado por SaveAudio en output/ del servidor. keyword-only."
output: "dict en API format listo para comfyui_submit_workflow. node_ids string; cada valor con class_type + inputs. Devuelve 8 nodos: CheckpointLoaderSimple, TextEncodeAceStepAudio, ConditioningZeroOut, EmptyAceStepLatentAudio, ModelSamplingSD3, KSampler, VAEDecodeAudio y SaveAudio. El denoise del KSampler se fija a 1.0 (genera desde el latente vacio, no es audio2audio)."
tested: true
tests: ["estructura: 8 nodos ACE-Step presentes + ckpt en CheckpointLoaderSimple + prompt en TextEncodeAceStepAudio.tags", "cableado: clip [4,1], positive [6,0], negative via ConditioningZeroOut [10,0], model post ModelSamplingSD3 [11,0], vae [4,2], denoise 1.0", "params reflejados (lyrics/seconds/seed/steps/cfg/sampler_name/scheduler/shift/lyrics_strength/filename_prefix)", "edge: seconds y seed variables se reflejan en EmptyAceStepLatentAudio y KSampler", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_audio_workflow.py"
file_path: "python/functions/ml/comfyui_build_audio_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_audio_workflow import comfyui_build_audio_workflow
wf = comfyui_build_audio_workflow(
ckpt_name="AUDIO_ace_step_v1_3.5b.safetensors",
prompt="8-bit coin pickup sound, retro game, short",
seconds=4.0, seed=42,
)
# wf["6"]["class_type"] == "TextEncodeAceStepAudio"
# wf["9"]["class_type"] == "SaveAudio"
# -> comfyui_submit_workflow(wf, server="127.0.0.1:8188") para encolar (necesita GPU)
# -> comfyui_wait_result(prompt_id) -> comfyui_fetch_output_audio(prompt_id, dest=...)
```
O lanzable directo con: `./fn run comfyui_build_audio_workflow` (imprime el JSON del workflow ACE-Step de ejemplo).
## Cuando usarla
Antes de enviar una generacion de audio (musica o SFX por texto) a ComfyUI:
construye aqui el dict del workflow ACE-Step y pasalo a `comfyui_submit_workflow`.
Usala cuando quieres un sonido o pieza musical descrita en lenguaje natural
(`prompt`), opcionalmente con letra cantada (`lyrics`). Baja el resultado con
`comfyui_fetch_output_audio`. 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.
- El checkpoint ACE-Step 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.
- Stable Audio Open 1.0 (la otra via nativa, mas ligera) esta GATED en HuggingFace
(resolve da HTTP 403 sin aceptar la licencia): por eso el modelo por defecto es
ACE-Step, que es abierto (Apache 2.0) y no gated.
- VRAM 8GB: `ace_step_v1_3.5b.safetensors` pesa ~7.7GB. Arrancar ComfyUI con
`--lowvram` para que streamee bloques a CPU; aun asi va justo. Antes de generar
audio, liberar VRAM de SD/Flux con POST /free {"unload_models":true,
"free_memory":true}. Si da OOM, bajar `seconds`. El builder es puro: no toca la
GPU, solo arma el dict (un OOM ocurre en el submit posterior, no aqui).
- ACE-Step es modelo de MUSICA: para SFX cortos funciona pero el resultado tiende
a sonar "musical". `seconds` minimo 1.0. Para SFX muy cortos usar 2-4 s.
- SaveAudio guarda `.flac` por defecto (clave "audio" en outputs[node]). Para bajar
el archivo usa `comfyui_fetch_output_audio` (no `comfyui_fetch_output_video`, que
solo busca extensiones de video).
- `lyrics` vacio = instrumental/SFX. Con letra, ACE-Step canta; `lyrics_strength`
ajusta cuanto se ciñe a ella.
@@ -0,0 +1,126 @@
"""Construye un workflow ComfyUI de texto->audio (ACE-Step) en "API format".
API format: cada clave es un node_id (string); cada nodo tiene class_type +
inputs. Las conexiones entre nodos son listas [node_id, output_index]. Este es
el formato que acepta POST /prompt, distinto del formato de la UI (graph con
links explicitos).
El grafo usa los nodos de audio NATIVOS de ComfyUI 0.26.0 para el modelo
ACE-Step (abierto, Apache 2.0): CheckpointLoaderSimple ->
TextEncodeAceStepAudio (tags + lyrics) -> EmptyAceStepLatentAudio ->
ModelSamplingSD3 -> KSampler -> VAEDecodeAudio -> SaveAudio. El negative se
construye con ConditioningZeroOut sobre el positive (patron oficial de ACE-Step).
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
def comfyui_build_audio_workflow(
ckpt_name: str,
prompt: str,
*,
lyrics: str = "",
seconds: float = 10.0,
seed: int = 0,
steps: int = 50,
cfg: float = 5.0,
sampler_name: str = "euler",
scheduler: str = "simple",
shift: float = 5.0,
lyrics_strength: float = 1.0,
filename_prefix: str = "audio/comfy_audio",
) -> dict:
"""Construye el dict del workflow texto->audio para ACE-Step.
Cadena de nodos: CheckpointLoaderSimple -> TextEncodeAceStepAudio (positivo)
+ ConditioningZeroOut (negativo) + EmptyAceStepLatentAudio -> ModelSamplingSD3
-> KSampler -> VAEDecodeAudio -> SaveAudio. SaveAudio escribe un .flac en la
carpeta output/<filename_prefix> del servidor ComfyUI.
Args:
ckpt_name: nombre del checkpoint ACE-Step tal como lo ve el servidor
(ej. "AUDIO_ace_step_v1_3.5b.safetensors"). Debe estar entre los que
devuelve comfyui_object_info en CheckpointLoaderSimple.
prompt: descripcion del sonido o estilo musical (va al campo "tags" de
TextEncodeAceStepAudio). Ej. "8-bit coin pickup sound, retro game".
lyrics: letra cantada para musica con voz. Vacio "" para SFX o musica
instrumental.
seconds: duracion del audio en segundos (min 1.0). Controla el tamano
del latente via EmptyAceStepLatentAudio.
seed: semilla del KSampler (cambia para variar el resultado).
steps: pasos de sampling del KSampler (50 recomendado para ACE-Step).
cfg: classifier-free guidance scale (5.0 recomendado para ACE-Step).
sampler_name: nombre del sampler (ej. "euler").
scheduler: scheduler del sampler (ej. "simple").
shift: shift del ModelSamplingSD3 aplicado al MODEL antes del sampling
(5.0 recomendado para ACE-Step). Mejora la coherencia temporal.
lyrics_strength: fuerza del condicionamiento de la letra (1.0 por
defecto; sin efecto practico cuando lyrics esta vacio).
filename_prefix: prefijo del .flac generado por SaveAudio en output/.
Returns:
dict en API format listo para comfyui_submit_workflow. Las claves son
node_ids ("3".."11") y cada valor tiene class_type + inputs.
"""
return {
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": ckpt_name},
},
"6": {
"class_type": "TextEncodeAceStepAudio",
"inputs": {
"clip": ["4", 1],
"tags": prompt,
"lyrics": lyrics,
"lyrics_strength": lyrics_strength,
},
},
"10": {
"class_type": "ConditioningZeroOut",
"inputs": {"conditioning": ["6", 0]},
},
"5": {
"class_type": "EmptyAceStepLatentAudio",
"inputs": {"seconds": seconds, "batch_size": 1},
},
"11": {
"class_type": "ModelSamplingSD3",
"inputs": {"model": ["4", 0], "shift": shift},
},
"3": {
"class_type": "KSampler",
"inputs": {
"seed": seed,
"steps": steps,
"cfg": cfg,
"sampler_name": sampler_name,
"scheduler": scheduler,
"denoise": 1.0,
"model": ["11", 0],
"positive": ["6", 0],
"negative": ["10", 0],
"latent_image": ["5", 0],
},
},
"8": {
"class_type": "VAEDecodeAudio",
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
},
"9": {
"class_type": "SaveAudio",
"inputs": {"filename_prefix": filename_prefix, "audio": ["8", 0]},
},
}
if __name__ == "__main__":
import json
wf = comfyui_build_audio_workflow(
ckpt_name="AUDIO_ace_step_v1_3.5b.safetensors",
prompt="8-bit coin pickup sound, retro game, short",
seconds=4.0,
seed=42,
)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,83 @@
---
name: comfyui_extract_template
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_extract_template(name: str, comfyui_python: str | None = None, to_api: bool = False, server: str = \"127.0.0.1:8188\") -> dict"
description: "Extrae el grafo de nodos de un workflow template oficial de ComfyUI por su template_id. Devuelve el grafo completo (formato UI: nodes/links), la lista de class_types que usa (aplanando subgrafos y descartando UUID de instancia), el formato, el bundle y los assets en disco. Opcionalmente (to_api=True) convierte el grafo UI a API format reutilizando comfyui_import_workflow_json (requiere un servidor ComfyUI vivo). Nombre inexistente -> error legible con sugerencias, sin traceback. Localiza el interprete de ComfyUI y usa su API oficial via subprocess. Impura: lee disco (+ red opcional si to_api)."
tags: [comfyui, ml, templates, workflow, extract]
uses_functions: ["comfyui_import_workflow_json_py_ml"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: name
desc: "template_id exacto del template (p.ej. 'sdxl_simple_example', 'image_sdxl'). Usa comfyui_list_templates para ver los nombres disponibles."
- name: comfyui_python
desc: "Ruta al interprete python de ComfyUI con el paquete comfyui-workflow-templates. None autodetecta (env COMFYUI_PYTHON, ~/ComfyUI/.venv/bin/python)."
- name: to_api
desc: "True intenta convertir el grafo UI a API format via comfyui_import_workflow_json (requiere servidor ComfyUI vivo en `server`). Si falla, el grafo UI se devuelve igualmente y el motivo va en api_error."
- name: server
desc: "host:port del servidor ComfyUI usado para la conversion to_api (default '127.0.0.1:8188')."
output: "dict {ok, name, format, class_types, has_subgraphs, n_nodes, graph, api_workflow, api_error, bundle, version, assets, error}. graph = dict del template (formato UI o API). class_types = lista ordenada de tipos de nodo reales. api_workflow = dict API si to_api tuvo exito, si no {}. Nunca lanza: nombre inexistente -> ok=False con error + sugerencias."
tested: true
tests:
- "sin el paquete instalado -> ok=False con error que menciona comfyui-workflow-templates"
- "el nombre pedido se preserva y el dict trae todas sus claves aun en fallo"
- "golden (skip si no hay ComfyUI con el paquete): extrae un template real con graph + class_types no vacios"
- "golden (skip si no hay ComfyUI con el paquete): nombre inexistente -> ok=False con error legible"
test_file_path: "python/functions/ml/tests/test_comfyui_extract_template.py"
file_path: "python/functions/ml/comfyui_extract_template.py"
---
## Ejemplo
```bash
# Lanzable directo (grafo slim + class_types de un template concreto):
python/.venv/bin/python3 python/functions/ml/comfyui_extract_template.py sdxl_simple_example
# Con conversion a API format (necesita ComfyUI corriendo en 127.0.0.1:8188):
python/.venv/bin/python3 python/functions/ml/comfyui_extract_template.py sdxl_simple_example --to-api
```
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_extract_template import comfyui_extract_template
res = comfyui_extract_template("sdxl_simple_example")
print(res["format"], res["n_nodes"], "nodos") # ui_graph 25 nodos
print(res["class_types"]) # ['CheckpointLoaderSimple', 'KSamplerAdvanced', ...]
graph = res["graph"] # dict cargable en la UI tal cual
```
## Cuando usarla
Cuando quieras reutilizar la estructura de nodos de un template oficial: cargar su
grafo en tu UI, usarlo de base para un workflow propio, o saber exactamente que
class_types encadena. Segundo paso del flujo listar (`comfyui_list_templates`) ->
extraer. Para encolar el resultado en `/prompt` usa `to_api=True` (o pasa el grafo por
`comfyui_import_workflow_json`).
## Gotchas
- El grafo viene en **formato UI** (nodes/links con posiciones), no en API format. La
UI de ComfyUI lo entiende tal cual (cargalo o copia el dict); para `/prompt` hay que
convertirlo a API format con `to_api=True`.
- `to_api=True` reutiliza `comfyui_import_workflow_json`, que necesita un **servidor
ComfyUI vivo** para mapear los widgets a sus claves de input. Sin servidor, la
extraccion del grafo UI sigue funcionando (ok=True) y el motivo del fallo de
conversion va en `api_error` (no rompe). KISS: no se fuerza la conversion.
- Templates **subgraphed** (con `definitions.subgraphs`, `has_subgraphs=True`): la
conversion a API NO expande el subgraph (limitacion de la normalizacion UI->API
estandar), asi que `api_workflow` puede quedar con solo los nodos top-level. Para
esos, cargar el grafo UI en la UI es lo fiable. `class_types` sí incluye los nodos
reales de dentro del subgraph.
- Nombre inexistente -> `ok=False` con `error` legible y sugerencias por substring (o
difflib). No lanza traceback.
- El paquete vive en el venv de ComfyUI; si no se encuentra el interprete o el paquete,
`ok=False` indicando `pip install comfyui-workflow-templates`.
@@ -0,0 +1,302 @@
"""Extrae el grafo de nodos de un workflow template oficial de ComfyUI por su nombre.
Funcion impura: lee disco (el .json del template instalado) ejecutando la API oficial
del paquete comfyui-workflow-templates dentro del interprete de ComfyUI.
Dado el nombre de un template (su template_id, p.ej. "image_sdxl" o
"api_bfl_flux2_max_sofa_swap"), devuelve:
- graph: el dict completo del .json (formato UI: nodes/links con posiciones).
- class_types: la lista de tipos de nodo (class_type) que usa, aplanando los
subgrafos de `definitions` si los hay.
- format: "ui_graph" (lo normal en los templates) o "api".
- assets: rutas en disco de los ficheros del template (json + previews .webp).
Opcionalmente (to_api=True) intenta convertir el grafo UI a API format reutilizando
comfyui_import_workflow_json del registry. Esa conversion necesita un servidor ComfyUI
vivo para mapear los widgets a sus claves de input; si no lo hay, se devuelve el grafo
UI + class_types igualmente y se reporta el motivo en api_error (KISS: no se fuerza la
conversion de grafos complejos).
El paquete vive en el venv de ComfyUI (no en el del registry), por eso esta funcion no
lo importa: localiza el interprete de ComfyUI y le pasa un script que usa la API oficial.
"""
import json
import os
import subprocess
import sys
import tempfile
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
if _THIS_DIR not in sys.path:
sys.path.insert(0, _THIS_DIR)
# Script que corre DENTRO del python de ComfyUI. Resuelve un template por id, vuelca su
# grafo + metadata como JSON. Si no existe, devuelve sugerencias cercanas.
_EXTRACT_SCRIPT = r"""
import json, sys, difflib, re
try:
import comfyui_workflow_templates_core as core
except Exception as exc:
print(json.dumps({"__err__": "import", "msg": str(exc)}))
sys.exit(0)
_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
TID = json.loads({tid_json!r})
m = core.load_manifest()
if TID not in m.templates:
near = [k for k in m.templates if TID.lower() in k.lower()][:8]
if not near:
near = difflib.get_close_matches(TID, list(m.templates.keys()), n=8, cutoff=0.6)
print(json.dumps({"__err__": "not_found", "suggestions": near}))
sys.exit(0)
entry = m.templates[TID]
json_asset = next((a.filename for a in entry.assets if a.filename.endswith(".json")), None)
if not json_asset:
print(json.dumps({"__err__": "no_json"}))
sys.exit(0)
path = core.get_asset_path(TID, json_asset)
with open(path, encoding="utf-8") as fh:
graph = json.load(fh)
# Detecta formato y extrae class_types.
fmt = "unknown"
class_types = set()
has_subgraphs = False
if isinstance(graph, dict) and isinstance(graph.get("nodes"), list):
fmt = "ui_graph"
for n in graph["nodes"]:
t = n.get("type") if isinstance(n, dict) else None
if t and not _UUID_RE.match(str(t)):
class_types.add(t)
defs = graph.get("definitions")
if isinstance(defs, dict) and isinstance(defs.get("subgraphs"), list):
for sg in defs["subgraphs"]:
for n in (sg.get("nodes") or []) if isinstance(sg, dict) else []:
if isinstance(n, dict) and n.get("type"):
has_subgraphs = True
if not _UUID_RE.match(str(n["type"])):
class_types.add(n["type"])
elif isinstance(graph, dict):
fmt = "api"
for v in graph.values():
if isinstance(v, dict) and v.get("class_type"):
class_types.add(v["class_type"])
print(json.dumps({
"graph": graph,
"class_types": sorted(class_types),
"format": fmt,
"has_subgraphs": has_subgraphs,
"bundle": entry.bundle,
"version": entry.version,
"assets": core.resolve_all_assets(TID),
"json_path": path,
}))
"""
def _find_comfyui_python(explicit: str | None) -> str | None:
"""Localiza un interprete de ComfyUI con el paquete instalado (ver list_templates)."""
candidates = []
if explicit:
candidates.append(os.path.expanduser(explicit))
env = os.environ.get("COMFYUI_PYTHON")
if env:
candidates.append(os.path.expanduser(env))
candidates += [
os.path.expanduser("~/ComfyUI/.venv/bin/python"),
os.path.expanduser("~/ComfyUI/venv/bin/python"),
os.path.expanduser("~/comfyui/.venv/bin/python"),
sys.executable,
]
for c in candidates:
if c and os.path.isfile(c):
return c
return None
def comfyui_extract_template(
name: str,
comfyui_python: str | None = None,
to_api: bool = False,
server: str = "127.0.0.1:8188",
) -> dict:
"""Extrae el grafo y los class_types de un template oficial de ComfyUI por nombre.
Args:
name: template_id exacto del template (p.ej. "image_sdxl"). Usa
comfyui_list_templates para ver los nombres disponibles.
comfyui_python: ruta al interprete python de ComfyUI con el paquete
comfyui-workflow-templates. Si None, se autodetecta.
to_api: si True, intenta convertir el grafo UI a API format reutilizando
comfyui_import_workflow_json (requiere un servidor ComfyUI vivo en
`server`). Si la conversion falla, se devuelve el grafo UI igualmente y
el motivo va en api_error.
server: host:port del servidor ComfyUI para la conversion to_api.
Returns:
dict {ok, name, format, class_types, has_subgraphs, n_nodes, graph,
api_workflow, api_error, bundle, version, assets, error}:
- graph: el dict del template en formato UI (o API si ya lo estaba).
- class_types: lista ordenada de tipos de nodo del grafo (incluye los de
subgrafos de `definitions`).
- api_workflow: dict en API format si to_api tuvo exito, si no {}.
Nunca lanza. Nombre inexistente -> ok=False con error legible + sugerencias.
"""
py = _find_comfyui_python(comfyui_python)
base = {
"ok": False,
"name": name,
"format": "",
"class_types": [],
"has_subgraphs": False,
"n_nodes": 0,
"graph": {},
"api_workflow": {},
"api_error": "",
"bundle": "",
"version": "",
"assets": [],
"error": "",
}
if not py:
base["error"] = (
"no se encontro un interprete de ComfyUI. Pasa comfyui_python=... o "
"define COMFYUI_PYTHON. Instala el paquete con: "
"pip install comfyui-workflow-templates"
)
return base
script = _EXTRACT_SCRIPT.replace("{tid_json!r}", repr(json.dumps(name)))
try:
proc = subprocess.run(
[py, "-c", script],
capture_output=True,
text=True,
timeout=60,
)
except Exception as exc: # noqa: BLE001
base["error"] = f"fallo al ejecutar el interprete de ComfyUI ({py}): {exc}"
return base
if proc.returncode != 0:
base["error"] = f"el interprete de ComfyUI fallo: {proc.stderr.strip()[:500]}"
return base
try:
data = json.loads(proc.stdout.strip().splitlines()[-1])
except Exception as exc: # noqa: BLE001
base["error"] = f"salida no parseable del interprete de ComfyUI: {exc}"
return base
err = data.get("__err__")
if err == "import":
base["error"] = (
f"el paquete comfyui-workflow-templates no esta instalado en {py} "
f"({data.get('msg', '')}). Instalalo con: "
"pip install comfyui-workflow-templates"
)
return base
if err == "not_found":
sug = data.get("suggestions", [])
hint = f" ¿Quizas: {', '.join(sug)}?" if sug else ""
base["error"] = f"template '{name}' no existe en el paquete.{hint}"
return base
if err == "no_json":
base["error"] = f"el template '{name}' no tiene asset .json."
return base
graph = data.get("graph", {})
fmt = data.get("format", "")
nodes = graph.get("nodes") if isinstance(graph, dict) else None
n_nodes = len(nodes) if isinstance(nodes, list) else (
len(graph) if fmt == "api" and isinstance(graph, dict) else 0
)
out = {
"ok": True,
"name": name,
"format": fmt,
"class_types": data.get("class_types", []),
"has_subgraphs": data.get("has_subgraphs", False),
"n_nodes": n_nodes,
"graph": graph,
"api_workflow": {},
"api_error": "",
"bundle": data.get("bundle", ""),
"version": data.get("version", ""),
"assets": data.get("assets", []),
"error": "",
}
if to_api:
if fmt == "api":
out["api_workflow"] = graph
else:
out["api_workflow"], out["api_error"] = _convert_to_api(graph, server)
return out
def _convert_to_api(graph: dict, server: str) -> tuple[dict, str]:
"""Convierte un grafo UI a API format via comfyui_import_workflow_json del registry.
Requiere un servidor ComfyUI vivo para mapear widgets. Devuelve (workflow, "")
si tuvo exito o ({}, motivo) si fallo. No lanza.
"""
try:
from comfyui_import_workflow_json import comfyui_import_workflow_json
except Exception as exc: # noqa: BLE001
return {}, f"no se pudo importar comfyui_import_workflow_json: {exc}"
tmp = None
try:
with tempfile.NamedTemporaryFile(
"w", suffix=".json", delete=False, encoding="utf-8"
) as fh:
json.dump(graph, fh)
tmp = fh.name
res = comfyui_import_workflow_json(tmp, server=server)
if res.get("ok"):
return res.get("workflow", {}), ""
return {}, (
res.get("error", "conversion fallida")
+ f" (requiere un servidor ComfyUI vivo en {server})"
)
except Exception as exc: # noqa: BLE001
return {}, f"conversion to_api fallida: {exc}"
finally:
if tmp and os.path.exists(tmp):
try:
os.unlink(tmp)
except OSError:
pass
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser(description="Extrae el grafo de un template ComfyUI")
ap.add_argument("name", help="template_id (ver comfyui_list_templates)")
ap.add_argument("--comfyui-python", default=None)
ap.add_argument("--to-api", action="store_true")
ap.add_argument("--server", default="127.0.0.1:8188")
ap.add_argument("--full", action="store_true", help="incluye el grafo entero")
args = ap.parse_args()
res = comfyui_extract_template(
args.name,
args.comfyui_python,
to_api=args.to_api,
server=args.server,
)
if args.full or not res["ok"]:
print(json.dumps(res, indent=2, ensure_ascii=False))
else:
slim = {k: v for k, v in res.items() if k != "graph"}
slim["graph_keys"] = list(res["graph"].keys()) if isinstance(res["graph"], dict) else []
print(json.dumps(slim, indent=2, ensure_ascii=False))
@@ -0,0 +1,85 @@
---
name: comfyui_fetch_output_audio
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_fetch_output_audio(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 audio de un workflow ComfyUI a disco local. Hermana de comfyui_fetch_output_video / _image / _mesh pero para los nodos de audio (SaveAudio, SaveAudioMP3, SaveAudioOpus, SaveAudioAdvanced): esos exponen su salida en GET /history bajo la clave 'audio' con items {filename, subfolder, type}. Localiza el primer .flac/.wav/.mp3/.opus/.ogg/.m4a, 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, audio, fetch, ace-step, 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 audio (SaveAudio/SaveAudioMP3/...) 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 audio 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 audio 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 audio guardado, format = extension sin punto (ej. 'flac' o 'mp3'), bytes = bytes descargados. Si falla, ok=False y error explica (sin audio en los outputs, HTTP, conexion o escritura)."
tested: true
tests:
- "test_is_audio_item_por_extension"
- "test_find_saveaudio_flac_bajo_audio"
- "test_find_saveaudiomp3_bajo_audio"
- "test_find_prioriza_clave_audio"
- "test_find_sin_audio_devuelve_none"
test_file_path: "python/functions/ml/tests/test_comfyui_fetch_output_audio.py"
file_path: "python/functions/ml/comfyui_fetch_output_audio.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_audio import comfyui_fetch_output_audio
# Tras comfyui_submit_workflow + comfyui_wait_result de un workflow de audio
# (ACE-Step, Stable Audio), baja el .flac/.mp3 al disco.
res = comfyui_fetch_output_audio("8a278988-8a94-4225-add3-88a406f7101c", dest="/tmp/audios")
# res == {"ok": True, "path": "/tmp/audios/comfy_audio_00001_.flac",
# "format": "flac", "bytes": 882000, "error": ""}
# Si ya tienes los outputs de comfyui_wait_result, pasalos y evita re-consultar /history:
outputs = {"9": {"audio": [{"filename": "comfy_audio_00001_.flac", "subfolder": "audio", "type": "output"}]}}
res2 = comfyui_fetch_output_audio("ignored", dest="/tmp/audios", 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 audio con ComfyUI (música o SFX por texto con ACE-Step, o Stable
Audio), cuando necesites el archivo `.flac`/`.wav`/`.mp3`/`.opus` real en disco (no
solo su nombre): para reproducirlo, subirlo a un vault, o usarlo como asset de un
juego. Es la hermana de `comfyui_fetch_output_video` (vídeo/animación),
`comfyui_fetch_output_image` (imágenes) y `comfyui_fetch_output_mesh` (mallas 3D).
El builder hermano es `comfyui_build_audio_workflow`.
## 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=`).
- Los nodos SaveAudio* exponen el archivo bajo la clave `"audio"` de los outputs
(no `"images"` como los de imagen/vídeo). Por eso `comfyui_fetch_output_video` NO
sirve para audio: busca extensiones de vídeo y claves gifs/videos/images.
- SaveAudio guarda `.flac` por defecto; SaveAudioMP3 `.mp3`, SaveAudioOpus `.opus`.
La función cubre todas por extensión.
- Toma el PRIMER archivo de audio 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,162 @@
"""Localiza y descarga el output de audio de un workflow ComfyUI a disco.
Hermana de comfyui_fetch_output_video / comfyui_fetch_output_image / _mesh, pero
para los nodos de audio (SaveAudio, SaveAudioMP3, SaveAudioOpus, SaveAudioAdvanced).
Esos nodos exponen su salida en GET /history/{prompt_id} bajo la clave "audio"
como lista de items {filename, subfolder, type}. Esta funcion localiza el primer
archivo con extension de audio (.flac/.wav/.mp3/.opus/.ogg/.m4a), 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 audio que producen los nodos SaveAudio* de ComfyUI.
_AUDIO_EXTS = (".flac", ".wav", ".mp3", ".opus", ".ogg", ".m4a")
# Claves de output preferentes para audio (se inspeccionan primero).
_AUDIO_KEYS = ("audio", "audios")
def _is_audio_item(item: dict) -> bool:
"""True si el item de output apunta a un archivo de audio (por extension)."""
fn = (item.get("filename") or "").lower()
return fn.endswith(_AUDIO_EXTS)
def _find_audio_output(outputs: dict) -> dict | None:
"""Busca en los outputs de /history el primer archivo de audio.
Hace dos pasadas: primero en la clave preferente "audio" (la que usan los
nodos SaveAudio*), luego en cualquier clave por si un nodo lo expone bajo
otro nombre. 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 _AUDIO_KEYS:
continue
if not isinstance(items, list):
continue
for item in items:
if isinstance(item, dict) and _is_audio_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_audio(
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 audio de un prompt ComfyUI ya ejecutado a disco local.
Args:
prompt_id: id devuelto por comfyui_submit_workflow, de un workflow cuyo
nodo de audio (SaveAudio/SaveAudioMP3/...) 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 audio 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 audio 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
audio guardado; format = extension sin punto (ej. "flac" o "mp3"); bytes =
tamano descargado. Si falla, ok=False y error explica (sin audio 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", {})
audio = _find_audio_output(outputs or {})
if audio is None:
return {"ok": False, "path": "", "format": "", "bytes": 0,
"error": f"sin archivo de audio en los outputs de {prompt_id}"}
# 2. Descargar el archivo via GET /view.
qs = urllib.parse.urlencode({
"filename": audio["filename"],
"subfolder": audio["subfolder"],
"type": audio["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, audio["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(audio["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_audio(pid, dest="/tmp/comfy_audio")
print(json.dumps(res, indent=2))
+43 -22
View File
@@ -3,10 +3,10 @@ name: comfyui_interrupt_queue
kind: function
lang: py
domain: ml
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "def comfyui_interrupt_queue(server: str = \"127.0.0.1:8188\") -> dict"
description: "Corta la generacion en curso de ComfyUI (POST /interrupt) y devuelve el estado de la cola (GET /queue). Devuelve {ok, interrupted, queue_running, queue_pending, error}. NO lanza excepcion en fallo de red: degrada a {ok: False, error}. /interrupt corta solo el prompt en ejecucion, no vacia los pendientes. Impura: HTTP POST + GET, solo stdlib (urllib, json)."
signature: "def comfyui_interrupt_queue(*, clear_pending: bool = False, server: str = \"127.0.0.1:8188\", timeout: float = 10.0) -> dict"
description: "Corta la generacion en curso de ComfyUI (POST /interrupt) y, si clear_pending=True, vacia ademas la cola de pendientes (POST /queue {\"clear\":true}). Consulta GET /queue al final para reportar queue_remaining. Devuelve {ok, interrupted, cleared, queue_remaining, error}. NO lanza excepcion en fallo de red: degrada a {ok: False, error}. /interrupt corta solo el prompt en ejecucion, no vacia los pendientes salvo clear_pending. Impura: HTTP POST + GET, solo stdlib (urllib, json)."
tags: [comfyui, ml, queue, interrupt, control, http]
uses_functions: []
uses_types: []
@@ -15,12 +15,16 @@ returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: clear_pending
desc: "keyword-only. Si True, ademas de cortar el prompt en ejecucion vacia la cola de pendientes con POST /queue {\"clear\":true}. Default False."
- name: server
desc: "host:port del servidor ComfyUI sin esquema (default '127.0.0.1:8188')."
output: "dict con ok (bool, True si interrupt + lectura de cola OK), interrupted (bool, True si POST /interrupt respondio), queue_running (int, prompts ejecutandose), queue_pending (int, prompts encolados), error (str, vacio si todo OK)."
tested: false
tests: []
test_file_path: ""
desc: "keyword-only. host:port del servidor ComfyUI sin esquema (default '127.0.0.1:8188')."
- name: timeout
desc: "keyword-only. Timeout de cada peticion HTTP en segundos (default 10.0)."
output: "dict con ok (bool, True si interrupt + clear (si se pidio) + lectura de cola OK), interrupted (bool, True si POST /interrupt respondio), cleared (bool, True si clear_pending y POST /queue {clear:true} respondio; False si no se pidio o fallo), queue_remaining (int, queue_running + queue_pending tras la operacion), error (str, vacio si todo OK)."
tested: true
tests: ["test_interrumpe_sin_vaciar", "test_clear_pending_vacia_cola", "test_clear_pending_cola_vacia_no_rompe", "test_servidor_caido_no_lanza"]
test_file_path: "python/functions/ml/tests/test_comfyui_interrupt_queue.py"
file_path: "python/functions/ml/comfyui_interrupt_queue.py"
---
@@ -31,30 +35,47 @@ import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_interrupt_queue import comfyui_interrupt_queue
# Solo cortar el prompt en ejecucion (los pendientes siguen):
res = comfyui_interrupt_queue()
# {'ok': True, 'interrupted': True, 'queue_running': 0, 'queue_pending': 0, 'error': ''}
if res["ok"] and res["interrupted"]:
print(f"cortado; pendientes en cola: {res['queue_pending']}")
# {'ok': True, 'interrupted': True, 'cleared': False, 'queue_remaining': 3, 'error': ''}
# Cortar el actual Y vaciar los pendientes de golpe:
res = comfyui_interrupt_queue(clear_pending=True)
# {'ok': True, 'interrupted': True, 'cleared': True, 'queue_remaining': 0, 'error': ''}
if res["ok"]:
print(f"cortado; quedan {res['queue_remaining']} en cola")
```
O lanzable directo con: `./fn run comfyui_interrupt_queue`.
O lanzable directo: `./fn run comfyui_interrupt_queue` · `./fn run comfyui_interrupt_queue --clear`.
## Cuando usarla
Para abortar una generacion que se esta tomando demasiado, que tira de mas VRAM de
la prevista, o tras encolar por error un workflow pesado. Tambien para inspeccionar
de un vistazo cuanto queda en cola (`queue_running` / `queue_pending`) sin parsear
el JSON de /queue a mano. Es el freno de mano del round-trip build -> submit -> wait.
la prevista, o tras encolar por error un workflow pesado. Con `clear_pending=True`
es el freno de mano completo: corta el actual y borra todo lo encolado en una sola
llamada (sin tener que encadenar `comfyui_queue_manage("clear")` despues). Tras la
operacion `queue_remaining` dice de un vistazo cuanto queda en cola.
## Gotchas
- `/interrupt` corta SOLO el prompt en ejecucion; los pendientes (`queue_pending`)
siguen y el siguiente arranca de inmediato. Para vaciar la cola entera hay que
llamar `POST /queue` con `{"clear": true}` (no lo hace esta funcion — solo corta
+ lee).
- `/interrupt` corta SOLO el prompt en ejecucion; sin `clear_pending` los pendientes
(`queue_pending`) siguen y el siguiente arranca de inmediato. Pasa
`clear_pending=True` para vaciar tambien la cola (POST /queue {"clear": true}).
- No es idempotente en el sentido de "sin efecto": si hay algo ejecutandose, lo
mata. Si la cola esta vacia, el interrupt es inocuo (interrupted=True igual).
mata. Si la cola esta vacia, tanto el interrupt como el clear son inocuos
(`interrupted=True`/`cleared=True` igual, `queue_remaining=0`).
- `queue_remaining` se lee al FINAL (GET /queue tras interrupt+clear): es
`queue_running + queue_pending`. Justo tras un interrupt sin clear puede ser >0
porque el siguiente pendiente ya arranco.
- En fallo de red NO lanza: devuelve `ok=False` con el mensaje en `error`. Comprueba
`ok` antes de fiarte de los conteos.
`ok` antes de fiarte de `queue_remaining`.
- Tras el interrupt conviene liberar VRAM con `POST /free` si vas a encolar otro
trabajo pesado (esta funcion no lo hace).
trabajo pesado (esta funcion no lo hace; ver el round-trip build -> submit -> wait).
- Para operaciones de cola mas finas (borrar UN prompt por id, contar el historial)
usa `comfyui_queue_manage`; esta funcion se centra en el interrupt + clear masivo.
## Capability growth log
- v1.1.0 (2026-06-28) — anade flag `clear_pending` (vacia la cola en la misma
llamada) + param `timeout`; el output pasa a {ok, interrupted, cleared,
queue_remaining, error} y se anaden tests (mock HTTP local).
+58 -22
View File
@@ -1,38 +1,53 @@
"""Interrumpe la generacion en curso de ComfyUI y devuelve el estado de la cola.
"""Interrumpe la generacion en curso de ComfyUI y, opcionalmente, vacia la cola.
Funcion impura: hace red (HTTP POST /interrupt + GET /queue). Solo stdlib.
Funcion impura: hace red (HTTP POST /interrupt, POST /queue, GET /queue). Solo
stdlib (urllib, json).
POST /interrupt corta el prompt que ComfyUI esta ejecutando ahora mismo (no vacia
la cola: los prompts pendientes siguen). GET /queue devuelve queue_running (lo que
se ejecuta) y queue_pending (lo encolado). Esta funcion combina ambos en un dict
honesto que NO lanza excepcion en fallo de red: devuelve {ok: False, error}.
POST /interrupt corta el prompt que ComfyUI esta ejecutando ahora mismo: NO vacia
los pendientes, solo aborta el actual y el siguiente arranca de inmediato. Para
vaciar de golpe los pendientes hay que ademas hacer POST /queue con {"clear": true}
(lo que activa el flag clear_pending). GET /queue se consulta al final para reportar
cuantos trabajos quedan en cola tras la operacion (queue_remaining).
NO lanza excepcion en fallo de red: devuelve un dict de estado {ok: False, error}.
"""
import json
import urllib.error
import urllib.request
def comfyui_interrupt_queue(server: str = "127.0.0.1:8188") -> dict:
"""Interrumpe la generacion en curso y devuelve el estado de la cola.
def comfyui_interrupt_queue(
*,
clear_pending: bool = False,
server: str = "127.0.0.1:8188",
timeout: float = 10.0,
) -> dict:
"""Corta la generacion en curso de ComfyUI y devuelve el estado de la cola.
Args:
clear_pending: si True, ademas de cortar el prompt en ejecucion vacia la
cola de pendientes con POST /queue {"clear": true}. keyword-only.
server: host:port del servidor ComfyUI sin esquema (default
"127.0.0.1:8188").
"127.0.0.1:8188"). keyword-only.
timeout: timeout de cada peticion HTTP en segundos (default 10.0).
keyword-only.
Returns:
dict con:
- ok (bool): True si tanto el interrupt como la lectura de la cola
tuvieron exito.
- ok (bool): True si el interrupt, la lectura de la cola y (si se pidio)
el clear tuvieron exito.
- interrupted (bool): True si el POST /interrupt respondio sin error.
- queue_running (int): numero de prompts ejecutandose ahora mismo.
- queue_pending (int): numero de prompts encolados pendientes.
- cleared (bool): True si clear_pending era True y el POST /queue
{"clear": true} respondio sin error; False si no se pidio o fallo.
- queue_remaining (int): trabajos que quedan en cola tras la operacion
(queue_running + queue_pending segun GET /queue al final).
- error (str): mensaje de error si algo fallo; cadena vacia si todo OK.
"""
out = {
"ok": False,
"interrupted": False,
"queue_running": 0,
"queue_pending": 0,
"cleared": False,
"queue_remaining": 0,
"error": "",
}
base = f"http://{server}"
@@ -40,19 +55,37 @@ def comfyui_interrupt_queue(server: str = "127.0.0.1:8188") -> dict:
# 1. POST /interrupt (cuerpo vacio): corta el prompt en ejecucion.
try:
req = urllib.request.Request(f"{base}/interrupt", data=b"", method="POST")
with urllib.request.urlopen(req, timeout=10.0):
with urllib.request.urlopen(req, timeout=timeout):
out["interrupted"] = True
except urllib.error.URLError as exc:
reason = getattr(exc, "reason", exc)
out["error"] = f"interrupt fallo: no se pudo conectar a {base}/interrupt: {reason}"
return out
# 2. GET /queue: estado actual de la cola tras el interrupt.
# 2. Opcional: POST /queue {"clear": true} para vaciar los pendientes.
if clear_pending:
try:
payload = json.dumps({"clear": True}).encode()
req = urllib.request.Request(
f"{base}/queue",
data=payload,
method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=timeout):
out["cleared"] = True
except urllib.error.URLError as exc:
reason = getattr(exc, "reason", exc)
out["error"] = f"clear fallo: no se pudo conectar a {base}/queue: {reason}"
return out
# 3. GET /queue: cuantos trabajos quedan en cola tras la operacion.
try:
with urllib.request.urlopen(f"{base}/queue", timeout=10.0) as resp:
with urllib.request.urlopen(f"{base}/queue", timeout=timeout) as resp:
data = json.loads(resp.read())
out["queue_running"] = len(data.get("queue_running", []))
out["queue_pending"] = len(data.get("queue_pending", []))
running = len(data.get("queue_running", []))
pending = len(data.get("queue_pending", []))
out["queue_remaining"] = running + pending
out["ok"] = True
except urllib.error.URLError as exc:
reason = getattr(exc, "reason", exc)
@@ -63,9 +96,12 @@ def comfyui_interrupt_queue(server: str = "127.0.0.1:8188") -> dict:
if __name__ == "__main__":
res = comfyui_interrupt_queue()
import sys
clear = "--clear" in sys.argv[1:]
res = comfyui_interrupt_queue(clear_pending=clear)
print(
f"ok={res['ok']} interrupted={res['interrupted']} "
f"running={res['queue_running']} pending={res['queue_pending']} "
f"cleared={res['cleared']} queue_remaining={res['queue_remaining']} "
f"error={res['error']!r}"
)
@@ -0,0 +1,88 @@
---
name: comfyui_list_templates
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_list_templates(comfyui_python: str | None = None, bundle: str | None = None, name_filter: str | None = None, with_nodes: bool = True, workflows_only: bool = True, limit: int = 0) -> dict"
description: "Lista los workflow templates oficiales del paquete pip comfyui-workflow-templates (los del menu 'Browse Templates' del frontend de ComfyUI). Devuelve nombre, bundle/categoria, path en disco, n_nodes y node_types (class_types reales, aplanando subgrafos y descartando los UUID de instancia). Localiza el interprete de ComfyUI y usa su API oficial via subprocess (el paquete vive en el venv de ComfyUI, no en el del registry). Impura: lee disco. Filtra entradas no-workflow (index*/localizacion) por defecto."
tags: [comfyui, ml, templates, workflow, discovery]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: comfyui_python
desc: "Ruta al interprete python de ComfyUI con el paquete comfyui-workflow-templates instalado. None autodetecta (env COMFYUI_PYTHON, ~/ComfyUI/.venv/bin/python, ~/ComfyUI/venv/bin/python)."
- name: bundle
desc: "Filtra por bundle exacto: 'media-api', 'media-image', 'media-video' o 'media-other'. None = todos."
- name: name_filter
desc: "Subcadena (case-insensitive) que debe contener el nombre del template. None = sin filtro."
- name: with_nodes
desc: "True (default) incluye node_types en cada registro; False los omite (registros mas ligeros)."
- name: workflows_only
desc: "True (default) excluye entradas que no son grafos de workflow (ficheros index*/localizacion del paquete)."
- name: limit
desc: "Si > 0, trunca a los primeros N templates tras filtrar y ordenar por nombre."
output: "dict {ok: bool, count: int, package_version: str, templates: list, error: str}. Cada template: {name, category, bundle, version, path, n_nodes, node_types, is_workflow}. Nunca lanza: paquete ausente o interprete no hallado -> ok=False con error legible que indica como instalar (pip install comfyui-workflow-templates)."
tested: true
tests:
- "_find_comfyui_python: interprete existente se devuelve tal cual"
- "_find_comfyui_python: ruta inexistente cae al fallback (sys.executable)"
- "sin el paquete instalado -> ok=False con error que menciona comfyui-workflow-templates"
- "el dict de retorno conserva todas sus claves aun en fallo"
- "golden (skip si no hay ComfyUI con el paquete): catalogo no vacio, cada template con name+bundle"
- "golden (skip si no hay ComfyUI con el paquete): bundle inexistente filtra a lista vacia con ok=True"
test_file_path: "python/functions/ml/tests/test_comfyui_list_templates.py"
file_path: "python/functions/ml/comfyui_list_templates.py"
---
## Ejemplo
```bash
# Lanzable directo (muestra version del paquete + 15 primeros con sus node_types):
./fn run comfyui_list_templates
# Filtrado por bundle de imagen, sin abrir node_types, primeros 20:
python/.venv/bin/python3 python/functions/ml/comfyui_list_templates.py \
--bundle media-image --no-nodes --limit 20
```
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_list_templates import comfyui_list_templates
res = comfyui_list_templates(name_filter="sdxl")
print(res["count"], "templates SDXL") # p.ej. 4
for t in res["templates"]:
print(t["name"], t["n_nodes"], t["node_types"][:3])
```
## Cuando usarla
Para descubrir que workflow templates oficiales trae ComfyUI sin abrir la UI:
explorar el catalogo, filtrar por bundle/nombre, o saber que `node_types` usa cada
template antes de extraerlo con `comfyui_extract_template`. Primer paso del flujo
listar -> extraer -> (cargar en UI / convertir a API).
## Gotchas
- El paquete `comfyui-workflow-templates` vive en el venv de ComfyUI, NO en el del
registry. La funcion no lo importa: localiza el python de ComfyUI y corre su API
oficial en un subprocess. Si no encuentra ese interprete (o el paquete no esta
instalado) devuelve `ok=False` con un error que dice como instalarlo. No lanza.
- Desde la 0.10.x el paquete es multi-bundle y ya NO expone una carpeta `templates/`
unica (la API antigua `get_templates_path()` lanza a proposito). Por eso se usa
`comfyui_workflow_templates_core` (`load_manifest`/`get_asset_path`).
- `node_types` aplana los subgrafos de `definitions` y descarta los `type` que son
UUID (instancias de subgraph), para mostrar class_types reales (KSampler, CLIPLoader,
…) en vez de identificadores opacos. `n_nodes` cuenta solo los nodos top-level.
- `workflows_only=True` (default) excluye ~16 entradas `index*` que son metadata de
localizacion del frontend, no grafos. Pasa `workflows_only=False` (o `--all` en CLI)
para verlas.
- Impura: abre cada `.json` en disco (≈451 ficheros pequeños, ~0.2s). No toca red ni
arranca GPU.
@@ -0,0 +1,284 @@
"""Lista los workflow templates oficiales que trae el paquete comfyui-workflow-templates.
Funcion impura: lee disco (los .json de los templates instalados) ejecutando la
API oficial del paquete dentro del interprete de ComfyUI.
ComfyUI 0.26+ distribuye los templates oficiales (los del menu "Browse Templates"
del frontend) en el paquete pip `comfyui-workflow-templates`, que desde la 0.10.x es
un meta-paquete multi-bundle: ya NO expone una carpeta `templates/` unica, sino una
API en `comfyui_workflow_templates_core` (`load_manifest`, `iter_templates`,
`get_asset_path`). Cada template es un grafo de nodos en formato UI (nodes/links con
posiciones), agrupado en uno de cuatro bundles: media-api, media-image, media-video,
media-other.
Como el paquete vive en el venv de ComfyUI (no en el del registry), esta funcion no
lo importa directamente: localiza el interprete de ComfyUI y le pasa un script que usa
la API oficial y vuelca el catalogo como JSON. Asi es robusta ante cambios de la
estructura interna del paquete.
"""
import json
import os
import subprocess
import sys
# Script que corre DENTRO del python de ComfyUI. Usa la API oficial del paquete y
# vuelca el catalogo (metadata + node_types por template) como una linea JSON.
_DUMP_SCRIPT = r"""
import json, sys, re
try:
import comfyui_workflow_templates_core as core
except Exception as exc:
print(json.dumps({"__err__": "import", "msg": str(exc)}))
sys.exit(0)
_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
def _collect_types(graph):
# Recoge class_types reales: aplana los subgrafos de definitions y descarta los
# type que son UUID (instancias de subgraph, cuyo contenido real ya se incluye).
types = set()
if isinstance(graph, dict) and isinstance(graph.get("nodes"), list):
for n in graph["nodes"]:
if isinstance(n, dict) and n.get("type") and not _UUID_RE.match(str(n["type"])):
types.add(n["type"])
defs = graph.get("definitions")
if isinstance(defs, dict) and isinstance(defs.get("subgraphs"), list):
for sg in defs["subgraphs"]:
for n in (sg.get("nodes") or []) if isinstance(sg, dict) else []:
if isinstance(n, dict) and n.get("type") and not _UUID_RE.match(str(n["type"])):
types.add(n["type"])
return len(graph["nodes"]), sorted(types)
if isinstance(graph, dict): # API format
for v in graph.values():
if isinstance(v, dict) and v.get("class_type"):
types.add(v["class_type"])
if types:
return len(graph), sorted(types)
return 0, []
WITH_NODES = {with_nodes}
m = core.load_manifest()
try:
import importlib.metadata as _md
pkg_version = _md.version("comfyui-workflow-templates")
except Exception:
pkg_version = ""
out = []
for tid, entry in m.templates.items():
json_asset = next(
(a.filename for a in entry.assets if a.filename.endswith(".json")), None
)
path = core.get_asset_path(tid, json_asset) if json_asset else ""
rec = {
"name": tid,
"bundle": entry.bundle,
"category": entry.bundle,
"version": entry.version,
"path": path,
"n_nodes": 0,
"node_types": [],
}
rec["is_workflow"] = False
if path:
try:
with open(path, encoding="utf-8") as fh:
graph = json.load(fh)
n_nodes, node_types = _collect_types(graph)
is_api = isinstance(graph, dict) and any(
isinstance(v, dict) and v.get("class_type") for v in graph.values()
)
rec["is_workflow"] = bool(
(isinstance(graph, dict) and isinstance(graph.get("nodes"), list) and graph["nodes"])
or is_api
)
rec["n_nodes"] = n_nodes
if WITH_NODES:
rec["node_types"] = node_types
except Exception:
pass
out.append(rec)
print(json.dumps({"package_version": pkg_version, "templates": out}))
"""
def _find_comfyui_python(explicit: str | None) -> str | None:
"""Devuelve la ruta a un interprete de ComfyUI que tenga el paquete instalado.
Orden de busqueda: argumento explicito -> env COMFYUI_PYTHON -> candidatos
habituales (~/ComfyUI/.venv, ~/ComfyUI/venv) -> el python actual. Devuelve None
si ninguno existe en disco.
"""
candidates = []
if explicit:
candidates.append(os.path.expanduser(explicit))
env = os.environ.get("COMFYUI_PYTHON")
if env:
candidates.append(os.path.expanduser(env))
candidates += [
os.path.expanduser("~/ComfyUI/.venv/bin/python"),
os.path.expanduser("~/ComfyUI/venv/bin/python"),
os.path.expanduser("~/comfyui/.venv/bin/python"),
sys.executable,
]
for c in candidates:
if c and os.path.isfile(c):
return c
return None
def comfyui_list_templates(
comfyui_python: str | None = None,
bundle: str | None = None,
name_filter: str | None = None,
with_nodes: bool = True,
workflows_only: bool = True,
limit: int = 0,
) -> dict:
"""Lista los templates oficiales de ComfyUI con su grafo de nodos.
Args:
comfyui_python: ruta al interprete python de ComfyUI que tiene instalado
el paquete comfyui-workflow-templates. Si None, se autodetecta (env
COMFYUI_PYTHON o ~/ComfyUI/.venv/bin/python).
bundle: si se da, filtra por bundle exacto ("media-api", "media-image",
"media-video", "media-other").
name_filter: si se da, filtra a templates cuyo nombre contenga esta
subcadena (case-insensitive).
with_nodes: si True (default) incluye node_types en cada registro. Si
False los omite (registros mas ligeros).
workflows_only: si True (default) excluye entradas que no son grafos de
workflow (ficheros index*/localizacion del paquete).
limit: si > 0, trunca la lista a los primeros N tras filtrar.
Returns:
dict {ok, count, package_version, templates, error}:
- templates: lista de {name, category, bundle, version, path, n_nodes,
node_types} ordenada por name.
- count: numero de templates devueltos (tras filtros y limit).
Nunca lanza: cualquier fallo (paquete ausente, interprete no hallado)
devuelve ok=False con un error legible.
"""
py = _find_comfyui_python(comfyui_python)
if not py:
return {
"ok": False,
"count": 0,
"package_version": "",
"templates": [],
"error": (
"no se encontro un interprete de ComfyUI. Pasa comfyui_python=... "
"o define COMFYUI_PYTHON. El paquete se instala con: "
"pip install comfyui-workflow-templates"
),
}
script = _DUMP_SCRIPT.replace("{with_nodes}", "True" if with_nodes else "False")
try:
proc = subprocess.run(
[py, "-c", script],
capture_output=True,
text=True,
timeout=120,
)
except Exception as exc: # noqa: BLE001
return {
"ok": False,
"count": 0,
"package_version": "",
"templates": [],
"error": f"fallo al ejecutar el interprete de ComfyUI ({py}): {exc}",
}
if proc.returncode != 0:
return {
"ok": False,
"count": 0,
"package_version": "",
"templates": [],
"error": f"el interprete de ComfyUI fallo: {proc.stderr.strip()[:500]}",
}
try:
data = json.loads(proc.stdout.strip().splitlines()[-1])
except Exception as exc: # noqa: BLE001
return {
"ok": False,
"count": 0,
"package_version": "",
"templates": [],
"error": f"salida no parseable del interprete de ComfyUI: {exc}",
}
if data.get("__err__") == "import":
return {
"ok": False,
"count": 0,
"package_version": "",
"templates": [],
"error": (
"el paquete comfyui-workflow-templates no esta instalado en "
f"{py} ({data.get('msg', '')}). Instalalo con: "
"pip install comfyui-workflow-templates"
),
}
templates = data.get("templates", [])
if workflows_only:
templates = [t for t in templates if t.get("is_workflow")]
if bundle:
templates = [t for t in templates if t.get("bundle") == bundle]
if name_filter:
nf = name_filter.lower()
templates = [t for t in templates if nf in t.get("name", "").lower()]
templates.sort(key=lambda t: t.get("name", ""))
if limit and limit > 0:
templates = templates[:limit]
return {
"ok": True,
"count": len(templates),
"package_version": data.get("package_version", ""),
"templates": templates,
"error": "",
}
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser(description="Lista templates oficiales de ComfyUI")
ap.add_argument("--comfyui-python", default=None)
ap.add_argument("--bundle", default=None)
ap.add_argument("--name-filter", default=None)
ap.add_argument("--no-nodes", action="store_true", help="omite node_types")
ap.add_argument("--all", action="store_true", help="incluye entradas no-workflow (index*)")
ap.add_argument("--limit", type=int, default=0)
ap.add_argument("--full", action="store_true", help="dump completo (todos los node_types)")
args = ap.parse_args()
res = comfyui_list_templates(
args.comfyui_python,
bundle=args.bundle,
name_filter=args.name_filter,
with_nodes=not args.no_nodes,
workflows_only=not args.all,
limit=args.limit,
)
if args.full or not res["ok"]:
print(json.dumps(res, indent=2, ensure_ascii=False))
else:
print(
json.dumps(
{
"ok": res["ok"],
"count": res["count"],
"package_version": res["package_version"],
"sample": res["templates"][:15],
},
indent=2,
ensure_ascii=False,
)
)
@@ -0,0 +1,90 @@
"""Tests de estructura para comfyui_build_audio_workflow (funcion pura, ACE-Step)."""
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_audio_workflow import comfyui_build_audio_workflow
from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct
def test_estructura_y_nodos_acestep():
wf = comfyui_build_audio_workflow(
"AUDIO_ace_step_v1_3.5b.safetensors", "retro coin sfx"
)
assert_api_format(wf)
cts = class_types(wf)
for ct in (
"CheckpointLoaderSimple",
"TextEncodeAceStepAudio",
"ConditioningZeroOut",
"EmptyAceStepLatentAudio",
"ModelSamplingSD3",
"KSampler",
"VAEDecodeAudio",
"SaveAudio",
):
assert ct in cts, f"falta nodo {ct}"
assert len(wf) == 8
def test_ckpt_y_prompt_reflejados():
wf = comfyui_build_audio_workflow("AUDIO_x.safetensors", "magic spell whoosh")
assert node_by_ct(wf, "CheckpointLoaderSimple")["inputs"]["ckpt_name"] == "AUDIO_x.safetensors"
enc = node_by_ct(wf, "TextEncodeAceStepAudio")
assert enc["inputs"]["tags"] == "magic spell whoosh"
assert enc["inputs"]["lyrics"] == ""
def test_cableado_ksampler():
wf = comfyui_build_audio_workflow("AUDIO_x.safetensors", "p")
ks = node_by_ct(wf, "KSampler")["inputs"]
# model viene de ModelSamplingSD3 ("11"), no del checkpoint directo
assert ks["model"] == ["11", 0]
assert ks["positive"] == ["6", 0]
# negative pasa por ConditioningZeroOut ("10")
assert ks["negative"] == ["10", 0]
assert ks["latent_image"] == ["5", 0]
assert ks["denoise"] == 1.0
# ModelSamplingSD3 toma el MODEL del checkpoint
assert node_by_ct(wf, "ModelSamplingSD3")["inputs"]["model"] == ["4", 0]
# VAEDecodeAudio usa el VAE del checkpoint
assert node_by_ct(wf, "VAEDecodeAudio")["inputs"]["vae"] == ["4", 2]
# ConditioningZeroOut deriva del positive
assert node_by_ct(wf, "ConditioningZeroOut")["inputs"]["conditioning"] == ["6", 0]
def test_edge_seconds_y_seed_variables():
wf_a = comfyui_build_audio_workflow("c", "p", seconds=4.0, seed=42)
wf_b = comfyui_build_audio_workflow("c", "p", seconds=8.0, seed=99)
assert node_by_ct(wf_a, "EmptyAceStepLatentAudio")["inputs"]["seconds"] == 4.0
assert node_by_ct(wf_b, "EmptyAceStepLatentAudio")["inputs"]["seconds"] == 8.0
assert node_by_ct(wf_a, "KSampler")["inputs"]["seed"] == 42
assert node_by_ct(wf_b, "KSampler")["inputs"]["seed"] == 99
def test_params_reflejados():
wf = comfyui_build_audio_workflow(
"c", "p",
lyrics="la la la", steps=30, cfg=4.0, sampler_name="dpmpp_2m",
scheduler="karras", shift=3.5, lyrics_strength=0.7,
filename_prefix="audio/mio",
)
enc = node_by_ct(wf, "TextEncodeAceStepAudio")["inputs"]
assert enc["lyrics"] == "la la la"
assert enc["lyrics_strength"] == 0.7
ks = node_by_ct(wf, "KSampler")["inputs"]
assert ks["steps"] == 30
assert ks["cfg"] == 4.0
assert ks["sampler_name"] == "dpmpp_2m"
assert ks["scheduler"] == "karras"
assert node_by_ct(wf, "ModelSamplingSD3")["inputs"]["shift"] == 3.5
assert node_by_ct(wf, "SaveAudio")["inputs"]["filename_prefix"] == "audio/mio"
def test_determinismo():
a = comfyui_build_audio_workflow("c", "p", seconds=5.0, seed=7)
b = comfyui_build_audio_workflow("c", "p", seconds=5.0, seed=7)
assert a == b
@@ -0,0 +1,86 @@
"""Tests para comfyui_extract_template.
Cubre, sin tocar red ni GPU:
- El camino de error legible cuando el paquete `comfyui-workflow-templates` no
esta instalado: subprocess local contra el python del venv del registry (que no
lo tiene) -> `ok=False` con mensaje accionable, sin lanzar.
- El contrato del dict de retorno (claves presentes, nombre preservado) aun en
fallo.
El golden path (extraer un template real con sus class_types) y el error
'template inexistente -> sugerencias' solo se ejecutan si hay un ComfyUI con el
paquete instalado; si no, se omiten con `pytest.skip`. Nunca dependen de GPU ni
de un servidor ComfyUI vivo (la conversion to_api, que si necesita servidor, no
se ejercita aqui).
"""
import os
import subprocess
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from comfyui_extract_template import _find_comfyui_python, comfyui_extract_template
_PKG = "comfyui_workflow_templates_core"
_RET_KEYS = {
"ok", "name", "format", "class_types", "has_subgraphs", "n_nodes",
"graph", "api_workflow", "api_error", "bundle", "version", "assets", "error",
}
def _python_con_paquete():
"""Devuelve un interprete que importa el paquete, o None (para omitir el golden)."""
py = _find_comfyui_python(None)
if not py:
return None
r = subprocess.run([py, "-c", f"import {_PKG}"], capture_output=True)
return py if r.returncode == 0 else None
def test_extract_sin_paquete_error_legible():
# El venv del registry no tiene el paquete -> ok=False con error que lo menciona.
res = comfyui_extract_template("image_sdxl", comfyui_python=sys.executable)
assert res["ok"] is False
assert res["graph"] == {}
assert res["class_types"] == []
assert "comfyui-workflow-templates" in res["error"]
def test_extract_preserva_nombre_y_claves():
# El nombre pedido se preserva y el dict trae siempre todas sus claves.
res = comfyui_extract_template("cualquier_nombre", comfyui_python=sys.executable)
assert res["name"] == "cualquier_nombre"
assert _RET_KEYS <= set(res)
def test_extract_golden_template_real():
py = _python_con_paquete()
if not py:
pytest.skip("no hay ComfyUI con comfyui-workflow-templates instalado")
# Toma el primer template real del catalogo y extraelo (to_api=False: sin servidor).
from comfyui_list_templates import comfyui_list_templates
cat = comfyui_list_templates(comfyui_python=py, with_nodes=False, limit=1)
assert cat["ok"] and cat["count"] >= 1
name = cat["templates"][0]["name"]
res = comfyui_extract_template(name, comfyui_python=py)
assert res["ok"] is True
assert res["name"] == name
assert isinstance(res["graph"], dict) and res["graph"]
assert len(res["class_types"]) > 0
assert res["format"] in ("ui_graph", "api")
def test_extract_nombre_inexistente_error_con_sugerencias():
py = _python_con_paquete()
if not py:
pytest.skip("no hay ComfyUI con comfyui-workflow-templates instalado")
res = comfyui_extract_template(
"zzz_template_que_no_existe_jamas", comfyui_python=py
)
assert res["ok"] is False
assert "no existe" in res["error"]
@@ -0,0 +1,50 @@
"""Tests de localizacion de output para comfyui_fetch_output_audio.
Solo cubren la logica pura de busqueda (_is_audio_item / _find_audio_output): no
tocan red ni disco. La descarga real via HTTP se prueba en el flujo e2e con el
servidor ComfyUI vivo.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from comfyui_fetch_output_audio import _find_audio_output, _is_audio_item
def test_is_audio_item_por_extension():
assert _is_audio_item({"filename": "comfy_audio_00001_.flac"})
assert _is_audio_item({"filename": "x.mp3"})
assert _is_audio_item({"filename": "x.WAV"})
assert not _is_audio_item({"filename": "x.png"})
assert not _is_audio_item({"filename": ""})
def test_find_saveaudio_flac_bajo_audio():
outputs = {
"9": {"audio": [{"filename": "comfy_audio_00001_.flac",
"subfolder": "audio", "type": "output"}]}
}
got = _find_audio_output(outputs)
assert got == {"filename": "comfy_audio_00001_.flac",
"subfolder": "audio", "type": "output"}
def test_find_saveaudiomp3_bajo_audio():
outputs = {"12": {"audio": [{"filename": "track.mp3", "subfolder": "", "type": "output"}]}}
assert _find_audio_output(outputs)["filename"] == "track.mp3"
def test_find_prioriza_clave_audio():
# Un nodo deja un png bajo "images" y otro un flac bajo "audio": gana el audio.
outputs = {
"9": {"images": [{"filename": "preview.png", "subfolder": "", "type": "output"}]},
"10": {"audio": [{"filename": "out.flac", "subfolder": "", "type": "output"}]},
}
assert _find_audio_output(outputs)["filename"] == "out.flac"
def test_find_sin_audio_devuelve_none():
outputs = {"9": {"images": [{"filename": "preview.png", "subfolder": "", "type": "output"}]}}
assert _find_audio_output(outputs) is None
assert _find_audio_output({}) is None
@@ -0,0 +1,149 @@
"""Tests de comfyui_interrupt_queue contra un servidor ComfyUI simulado.
La funcion es pura I/O (HTTP), asi que levantamos un http.server local que imita
los endpoints relevantes de ComfyUI (/interrupt, /queue) y verificamos:
- Golden: interrupt sin clear corta el actual pero NO vacia los pendientes.
- Edge: clear_pending=True vacia la cola (queue_remaining=0).
- Edge: clear_pending=True con la cola ya vacia no rompe.
- Error: si el servidor no responde, devuelve {ok:False, error} sin lanzar.
"""
import http.server
import json
import os
import socket
import sys
import threading
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from ml.comfyui_interrupt_queue import comfyui_interrupt_queue
class _FakeComfyHandler(http.server.BaseHTTPRequestHandler):
"""Imita ComfyUI: estado de cola mutable compartido via la clase del server."""
def log_message(self, *args): # silenciar el log del servidor en los tests
pass
def _send_json(self, obj, code=200):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
st = self.server.state
if self.path == "/interrupt":
st["running"] = [] # interrupt corta el prompt en ejecucion
self._send_json({})
return
if self.path == "/queue":
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length) if length else b"{}"
body = json.loads(raw or b"{}")
if body.get("clear"):
st["pending"] = [] # clear vacia los pendientes
elif "delete" in body:
st["pending"] = [
p for p in st["pending"] if p not in body["delete"]
]
self._send_json({})
return
self._send_json({"error": "not found"}, code=404)
def do_GET(self):
st = self.server.state
if self.path == "/queue":
self._send_json(
{
"queue_running": st["running"],
"queue_pending": st["pending"],
}
)
return
self._send_json({"error": "not found"}, code=404)
def _start_fake_server(running, pending):
"""Levanta el servidor fake en un puerto efimero. Devuelve (server, addr, thread)."""
server = http.server.HTTPServer(("127.0.0.1", 0), _FakeComfyHandler)
server.state = {"running": list(running), "pending": list(pending)}
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
host, port = server.server_address
return server, f"{host}:{port}", thread
def _free_port():
"""Reserva y libera un puerto para garantizar que NADA escucha ahi (error path)."""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
def test_interrumpe_sin_vaciar():
# Golden: 1 ejecutandose + 2 pendientes; interrupt corta el actual, pendientes siguen.
server, addr, _ = _start_fake_server(running=["r1"], pending=["p1", "p2"])
try:
res = comfyui_interrupt_queue(server=addr)
finally:
server.shutdown()
assert res["ok"] is True
assert res["interrupted"] is True
assert res["cleared"] is False
# running cortado (0) + 2 pendientes que siguen = 2 restantes.
assert res["queue_remaining"] == 2
assert res["error"] == ""
def test_clear_pending_vacia_cola():
# Edge: clear_pending vacia los pendientes -> queue_remaining 0.
server, addr, _ = _start_fake_server(running=["r1"], pending=["p1", "p2", "p3"])
try:
res = comfyui_interrupt_queue(clear_pending=True, server=addr)
finally:
server.shutdown()
assert res["ok"] is True
assert res["interrupted"] is True
assert res["cleared"] is True
assert res["queue_remaining"] == 0
assert res["error"] == ""
def test_clear_pending_cola_vacia_no_rompe():
# Edge: clear_pending con la cola ya vacia es inocuo, no rompe.
server, addr, _ = _start_fake_server(running=[], pending=[])
try:
res = comfyui_interrupt_queue(clear_pending=True, server=addr)
finally:
server.shutdown()
assert res["ok"] is True
assert res["interrupted"] is True
assert res["cleared"] is True
assert res["queue_remaining"] == 0
assert res["error"] == ""
def test_servidor_caido_no_lanza():
# Error: nada escucha en el puerto -> {ok:False, error} sin excepcion cruda.
dead = f"127.0.0.1:{_free_port()}"
res = comfyui_interrupt_queue(server=dead, timeout=1.0)
assert res["ok"] is False
assert res["interrupted"] is False
assert res["error"] != ""
assert "interrupt fallo" in res["error"]
if __name__ == "__main__":
test_interrumpe_sin_vaciar()
test_clear_pending_vacia_cola()
test_clear_pending_cola_vacia_no_rompe()
test_servidor_caido_no_lanza()
print("OK: 4 tests passed")
@@ -0,0 +1,87 @@
"""Tests para comfyui_list_templates.
Cubre dos cosas sin tocar red ni GPU:
- La localizacion del interprete (`_find_comfyui_python`), que solo consulta el
sistema de ficheros.
- El camino de error legible cuando el paquete `comfyui-workflow-templates` no
esta instalado: se ejecuta un subprocess local contra el python indicado (el
del propio venv del registry, que no tiene el paquete) y se comprueba que la
funcion devuelve `ok=False` con un mensaje accionable, sin lanzar.
El golden path (catalogo de templates no vacio) y un edge de filtrado solo se
ejecutan si hay un ComfyUI con el paquete instalado; si no, se omiten con
`pytest.skip`. Nunca dependen de GPU ni de un servidor ComfyUI vivo.
"""
import os
import subprocess
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from comfyui_list_templates import _find_comfyui_python, comfyui_list_templates
_PKG = "comfyui_workflow_templates_core"
_RET_KEYS = {"ok", "count", "package_version", "templates", "error"}
def _python_con_paquete():
"""Devuelve un interprete que importa el paquete, o None (para omitir el golden)."""
py = _find_comfyui_python(None)
if not py:
return None
r = subprocess.run([py, "-c", f"import {_PKG}"], capture_output=True)
return py if r.returncode == 0 else None
def test_find_comfyui_python_explicit_valido():
# Un interprete que existe en disco se devuelve tal cual.
assert _find_comfyui_python(sys.executable) == sys.executable
def test_find_comfyui_python_inexistente_cae_a_fallback():
# Una ruta inexistente no rompe: cae al siguiente candidato (sys.executable existe).
got = _find_comfyui_python("/ruta/que/no/existe/python")
assert got is not None and os.path.isfile(got)
def test_list_sin_paquete_error_legible():
# El venv del registry no tiene el paquete -> ok=False con error que lo menciona.
res = comfyui_list_templates(comfyui_python=sys.executable)
assert res["ok"] is False
assert res["count"] == 0
assert res["templates"] == []
assert "comfyui-workflow-templates" in res["error"]
def test_list_retorno_tiene_todas_las_claves():
# El contrato del dict de retorno se mantiene aun en fallo.
res = comfyui_list_templates(comfyui_python=sys.executable)
assert _RET_KEYS <= set(res)
def test_list_golden_catalogo_no_vacio():
py = _python_con_paquete()
if not py:
pytest.skip("no hay ComfyUI con comfyui-workflow-templates instalado")
res = comfyui_list_templates(comfyui_python=py, with_nodes=False, limit=5)
assert res["ok"] is True
assert res["count"] > 0
assert len(res["templates"]) == res["count"]
# Cada template trae al menos nombre y bundle.
for t in res["templates"]:
assert t.get("name")
assert t.get("bundle")
def test_list_golden_filtro_bundle_inexistente_vacio():
py = _python_con_paquete()
if not py:
pytest.skip("no hay ComfyUI con comfyui-workflow-templates instalado")
# Un bundle que no existe filtra a una lista vacia pero la llamada sigue siendo ok.
res = comfyui_list_templates(comfyui_python=py, bundle="bundle-inexistente-xyz")
assert res["ok"] is True
assert res["count"] == 0
assert res["templates"] == []