Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e49841a60a |
@@ -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,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/comfyui_fetch_output_audio_test.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))
|
||||
@@ -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(__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,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
|
||||
Reference in New Issue
Block a user