From 3e75d1bf79b14b8fbcbc753abe97442a7ca83342 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Wed, 24 Jun 2026 11:55:09 +0200 Subject: [PATCH] feat(ml): comfyui_build_flux_workflow builder txt2img Flux (API format) Builder puro hermano de comfyui_build_txt2img_workflow para modelos Flux (schnell/dev): UNETLoader + DualCLIPLoader (clip_l + t5xxl, type flux) + VAELoader -> CLIPTextEncode -> FluxGuidance + EmptySD3LatentImage -> KSampler (cfg fijo 1.0) -> VAEDecode -> SaveImage. La guia va por FluxGuidance, no por el cfg del sampler. fp8 + ~4 pasos para GPU de 8GB. class_type/inputs verificados contra /object_info del server vivo. Validado end-to-end: genera imagen real (prompt_id 909b8876, flux_builder_test_00001_.png, status success). 6 tests unitarios verde. Pagina madre docs/capabilities/comfyui.md actualizada con la fila del builder. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/capabilities/comfyui.md | 1 + .../ml/comfyui_build_flux_workflow.md | 104 ++++++++++++++ .../ml/comfyui_build_flux_workflow.py | 136 ++++++++++++++++++ .../tests/test_comfyui_build_flux_workflow.py | 79 ++++++++++ 4 files changed, 320 insertions(+) create mode 100644 python/functions/ml/comfyui_build_flux_workflow.md create mode 100644 python/functions/ml/comfyui_build_flux_workflow.py create mode 100644 python/functions/ml/tests/test_comfyui_build_flux_workflow.py diff --git a/docs/capabilities/comfyui.md b/docs/capabilities/comfyui.md index aa86dc7f..0362942b 100644 --- a/docs/capabilities/comfyui.md +++ b/docs/capabilities/comfyui.md @@ -38,6 +38,7 @@ El **API format** (dict de nodos numerados que produce `build_txt2img_workflow` | ID | Firma corta | Qué hace | |---|---|---| | [comfyui_build_txt2img_workflow_py_ml](../../python/functions/ml/comfyui_build_txt2img_workflow.md) | `build_txt2img_workflow(ckpt_name, positive, negative='', *, steps, cfg, width, height, seed, ...) -> dict` | Construye el dict del workflow txt2img básico (Checkpoint → CLIPTextEncode×2 + EmptyLatent → KSampler → VAEDecode → SaveImage) en API format. **Pura**. | +| [comfyui_build_flux_workflow_py_ml](../../python/functions/ml/comfyui_build_flux_workflow.md) | `build_flux_workflow(prompt, *, unet='flux1-schnell-fp8-e4m3fn.safetensors', clip_l, t5xxl, vae='ae.safetensors', width=1024, height=1024, steps=4, guidance=3.5, seed, weight_dtype='fp8_e4m3fn', ...) -> dict` | Builder txt2img para **Flux** (schnell/dev): UNETLoader + DualCLIPLoader (clip_l + t5xxl, type flux) + VAELoader → CLIPTextEncode → FluxGuidance + EmptySD3LatentImage → KSampler (cfg fijo 1.0) → VAEDecode → SaveImage. La guía va por FluxGuidance, no por el cfg. fp8 + ~4 pasos para 8 GB. **Pura**. | | [comfyui_object_info_py_ml](../../python/functions/ml/comfyui_object_info.md) | `object_info(server='127.0.0.1:8188', node_class=None, timeout) -> dict` | Catálogo de nodos del server: inputs, tipos y enums (lista de checkpoints/samplers visibles). Para validar antes de enviar. Impura. | | [comfyui_submit_workflow_py_ml](../../python/functions/ml/comfyui_submit_workflow.md) | `submit_workflow(workflow, server, client_id, timeout) -> dict` | Encola un workflow API format vía POST /prompt; devuelve `prompt_id` + posición en cola. HTTP 400 propaga la validación por nodo. Impura. | | [comfyui_wait_result_py_ml](../../python/functions/ml/comfyui_wait_result.md) | `wait_result(prompt_id, server, timeout, poll_interval) -> dict` | Sondea GET /history/{prompt_id} hasta que termina; devuelve los outputs (PNGs con filename/subfolder/type). Impura. | diff --git a/python/functions/ml/comfyui_build_flux_workflow.md b/python/functions/ml/comfyui_build_flux_workflow.md new file mode 100644 index 00000000..cc61a5ff --- /dev/null +++ b/python/functions/ml/comfyui_build_flux_workflow.md @@ -0,0 +1,104 @@ +--- +name: comfyui_build_flux_workflow +kind: function +lang: py +domain: ml +version: "1.0.0" +purity: pure +signature: "def comfyui_build_flux_workflow(prompt: str, *, unet: str = \"flux1-schnell-fp8-e4m3fn.safetensors\", clip_l: str = \"clip_l.safetensors\", t5xxl: str = \"t5xxl_fp8_e4m3fn_scaled.safetensors\", vae: str = \"ae.safetensors\", width: int = 1024, height: int = 1024, steps: int = 4, guidance: float = 3.5, seed: int = 0, weight_dtype: str = \"fp8_e4m3fn\", sampler_name: str = \"euler\", scheduler: str = \"simple\", filename_prefix: str = \"comfy_flux\") -> dict" +description: "Construye el dict de un workflow ComfyUI txt2img con Flux en API format (nodos numerados con class_type + inputs, conexiones como [node_id, output_index]). A diferencia de SD1.5/SDXL, Flux carga por separado UNETLoader + DualCLIPLoader (clip_l + t5xxl, type flux) + VAELoader; la guia va por FluxGuidance (no por el cfg del KSampler, que se fija a 1.0). Cadena: UNETLoader+DualCLIPLoader+VAELoader -> CLIPTextEncode -> FluxGuidance + EmptySD3LatentImage -> KSampler -> VAEDecode -> SaveImage. Pura, sin red ni I/O. Hermana de comfyui_build_txt2img_workflow." +tags: [comfyui, flux, ml, txt2img, workflow] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +params: + - name: prompt + desc: "Prompt positivo: lo que se quiere ver en la imagen." + - name: unet + desc: "Nombre del modelo de difusion en models/diffusion_models/ tal como lo lista comfyui_object_info para UNETLoader (unet_name). Por defecto el Flux schnell fp8. keyword-only." + - name: clip_l + desc: "Nombre del encoder CLIP-L en models/text_encoders/ (clip_name2 del DualCLIPLoader). Por defecto 'clip_l.safetensors'. keyword-only." + - name: t5xxl + desc: "Nombre del encoder T5-XXL en models/text_encoders/ (clip_name1 del DualCLIPLoader). Por defecto 't5xxl_fp8_e4m3fn_scaled.safetensors'. keyword-only." + - name: vae + desc: "Nombre del VAE en models/vae/ (vae_name del VAELoader). Por defecto 'ae.safetensors', el autoencoder de Flux. keyword-only." + - name: width + desc: "Ancho del latente/imagen en px, multiplo de 16 para SD3/Flux. keyword-only." + - name: height + desc: "Alto del latente/imagen en px, multiplo de 16 para SD3/Flux. keyword-only." + - name: steps + desc: "Pasos de sampling del KSampler. Flux schnell rinde con ~4; Flux dev necesita ~20. keyword-only." + - name: guidance + desc: "Valor del nodo FluxGuidance (no es el cfg clasico). Schnell es poco sensible; dev responde a 3.0-4.0. keyword-only." + - name: seed + desc: "Semilla del KSampler. 0 es determinista; cambiar para variar la imagen. keyword-only." + - name: weight_dtype + desc: "dtype de carga del UNET (uno de 'default', 'fp8_e4m3fn', 'fp8_e4m3fn_fast', 'fp8_e5m2'). fp8 reduce VRAM, clave en GPU de 8GB. keyword-only." + - name: sampler_name + desc: "Nombre del sampler (Flux usa 'euler'). keyword-only." + - name: scheduler + desc: "Scheduler del sampler (Flux usa 'simple'). keyword-only." + - name: filename_prefix + desc: "Prefijo del PNG que SaveImage escribe en output/. keyword-only." +output: "dict en API format con node_ids como claves (UNETLoader '10', DualCLIPLoader '11', VAELoader '12', CLIPTextEncode positivo '6', FluxGuidance '13', CLIPTextEncode negativo vacio '7', EmptySD3LatentImage '5', KSampler '3', VAEDecode '8', SaveImage '9'). Listo para comfyui_submit_workflow." +tested: true +tests: ["class_types esperados (9 nodos de Flux)", "loaders separados UNET+DualCLIP(flux)+VAE", "guidance via FluxGuidance y cfg del KSampler fijado a 1.0", "params width/height/steps/seed reflejados", "filename_prefix en SaveImage", "determinismo: misma entrada -> mismo dict (builder puro)"] +test_file_path: "python/functions/ml/tests/test_comfyui_build_flux_workflow.py" +file_path: "python/functions/ml/comfyui_build_flux_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_flux_workflow import comfyui_build_flux_workflow + +wf = comfyui_build_flux_workflow( + prompt="a red apple on a wooden table, sharp focus, studio lighting", + width=1024, + height=1024, + steps=4, # Flux schnell: ~4 pasos basta + seed=42, +) +# wf["10"]["class_type"] == "UNETLoader" # modelo de difusion suelto +# wf["11"]["inputs"]["type"] == "flux" # DualCLIPLoader en modo flux +# wf["3"]["inputs"]["positive"] == ["13", 0] # KSampler consume FluxGuidance +# wf["3"]["inputs"]["cfg"] == 1.0 # la guia va por FluxGuidance +# wf["9"]["class_type"] == "SaveImage" +``` + +O lanzable directo con: `./fn run comfyui_build_flux_workflow` (imprime el JSON del workflow de ejemplo). + +## Cuando usarla + +Cuando vayas a generar txt2img con un modelo Flux (schnell o dev) y necesites el +dict del workflow para `comfyui_submit_workflow`. Usala en lugar de +`comfyui_build_txt2img_workflow` siempre que el modelo NO sea un checkpoint +todo-en-uno SD1.5/SDXL sino Flux con UNET + text encoders + VAE por separado. +Flux schnell es ideal en GPU de poca VRAM (8GB) por el fp8 y los ~4 pasos. + +## Gotchas + +- Es API format (nodos numerados), NO el formato de la UI de ComfyUI (graph con + links). No se puede pegar en la UI tal cual; es el formato que acepta POST + /prompt. +- Flux NO usa el cfg del KSampler para guiar: este builder lo fija a 1.0 y la + guia va por el nodo FluxGuidance. Subir el cfg del KSampler con Flux degrada o + rompe la imagen. +- El negativo es un CLIPTextEncode vacio cableado al KSampler (igual que el + template oficial de Flux). Flux schnell es destilado y practicamente ignora el + negativo; no esperes que un prompt negativo tenga el efecto de SD1.5/SDXL. +- `unet`, `clip_l`, `t5xxl` y `vae` deben existir en los directorios respectivos + visibles para el servidor (models/diffusion_models/, models/text_encoders/, + models/vae/). Si no, ComfyUI rechaza el workflow con HTTP 400 al enviarlo (no + aqui — esta funcion es pura y no valida contra el servidor). Valida antes con + `comfyui_validate_workflow`. +- `width`/`height` deben ser multiplos de 16 para EmptySD3LatentImage (Flux), no + de 8 como en SD1.5/SDXL. +- `weight_dtype` debe ser uno de los que admite UNETLoader ('default', + 'fp8_e4m3fn', 'fp8_e4m3fn_fast', 'fp8_e5m2'). En 8GB usa fp8 o el modelo no + cabe en VRAM. diff --git a/python/functions/ml/comfyui_build_flux_workflow.py b/python/functions/ml/comfyui_build_flux_workflow.py new file mode 100644 index 00000000..5824cdb7 --- /dev/null +++ b/python/functions/ml/comfyui_build_flux_workflow.py @@ -0,0 +1,136 @@ +"""Construye un workflow ComfyUI txt2img con Flux en "API format" (dict de nodos numerados). + +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). + +A diferencia del builder SD1.5/SDXL (comfyui_build_txt2img_workflow), Flux NO usa +un checkpoint todo-en-uno: carga por separado el modelo de difusion (UNETLoader), +los dos text encoders (DualCLIPLoader con clip_l + t5xxl, type="flux") y el VAE +(VAELoader). La guia no va por el cfg del KSampler (que se fija a 1.0) sino por el +nodo FluxGuidance aplicado al condicionamiento positivo. El negativo se deja como +un CLIPTextEncode vacio, igual que el template oficial de Flux en ComfyUI. + +Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos. +""" + + +def comfyui_build_flux_workflow( + prompt: str, + *, + unet: str = "flux1-schnell-fp8-e4m3fn.safetensors", + clip_l: str = "clip_l.safetensors", + t5xxl: str = "t5xxl_fp8_e4m3fn_scaled.safetensors", + vae: str = "ae.safetensors", + width: int = 1024, + height: int = 1024, + steps: int = 4, + guidance: float = 3.5, + seed: int = 0, + weight_dtype: str = "fp8_e4m3fn", + sampler_name: str = "euler", + scheduler: str = "simple", + filename_prefix: str = "comfy_flux", +) -> dict: + """Construye el dict del workflow txt2img de Flux (schnell/dev). + + Cadena de nodos: UNETLoader + DualCLIPLoader + VAELoader -> CLIPTextEncode + (positivo) -> FluxGuidance, mas un CLIPTextEncode vacio para el negativo y + EmptySD3LatentImage -> KSampler -> VAEDecode -> SaveImage. + + Args: + prompt: prompt positivo (lo que se quiere ver en la imagen). + unet: nombre del modelo de difusion en models/diffusion_models/ tal como + lo lista comfyui_object_info para UNETLoader (unet_name). Por defecto + el Flux schnell fp8 ("flux1-schnell-fp8-e4m3fn.safetensors"). + clip_l: nombre del encoder CLIP-L en models/text_encoders/ (clip_name2 del + DualCLIPLoader). Por defecto "clip_l.safetensors". + t5xxl: nombre del encoder T5-XXL en models/text_encoders/ (clip_name1 del + DualCLIPLoader). Por defecto "t5xxl_fp8_e4m3fn_scaled.safetensors". + vae: nombre del VAE en models/vae/ (vae_name del VAELoader). Por defecto + "ae.safetensors" (el autoencoder de Flux). + width: ancho del latente/imagen en px (multiplo de 16 para SD3/Flux). keyword-only. + height: alto del latente/imagen en px (multiplo de 16 para SD3/Flux). keyword-only. + steps: pasos de sampling del KSampler. Flux schnell rinde bien con ~4; + Flux dev necesita ~20. keyword-only. + guidance: valor del nodo FluxGuidance (no es el cfg clasico). Schnell es + poco sensible a este valor; dev responde a 3.0-4.0. keyword-only. + seed: semilla del KSampler (0 = determinista; cambia para variar). keyword-only. + weight_dtype: dtype de carga del UNET (uno de "default", "fp8_e4m3fn", + "fp8_e4m3fn_fast", "fp8_e5m2"). fp8 reduce VRAM (clave en 8GB). keyword-only. + sampler_name: nombre del sampler (Flux usa "euler"). keyword-only. + scheduler: scheduler del sampler (Flux usa "simple"). keyword-only. + filename_prefix: prefijo del PNG que SaveImage escribe en output/. keyword-only. + + Returns: + dict en API format listo para comfyui_submit_workflow. Las claves son + node_ids (string) y cada valor tiene class_type + inputs. + """ + return { + "10": { + "class_type": "UNETLoader", + "inputs": {"unet_name": unet, "weight_dtype": weight_dtype}, + }, + "11": { + "class_type": "DualCLIPLoader", + "inputs": { + "clip_name1": t5xxl, + "clip_name2": clip_l, + "type": "flux", + }, + }, + "12": { + "class_type": "VAELoader", + "inputs": {"vae_name": vae}, + }, + "6": { + "class_type": "CLIPTextEncode", + "inputs": {"text": prompt, "clip": ["11", 0]}, + }, + "13": { + "class_type": "FluxGuidance", + "inputs": {"conditioning": ["6", 0], "guidance": guidance}, + }, + "7": { + "class_type": "CLIPTextEncode", + "inputs": {"text": "", "clip": ["11", 0]}, + }, + "5": { + "class_type": "EmptySD3LatentImage", + "inputs": {"width": width, "height": height, "batch_size": 1}, + }, + "3": { + "class_type": "KSampler", + "inputs": { + "seed": seed, + "steps": steps, + "cfg": 1.0, + "sampler_name": sampler_name, + "scheduler": scheduler, + "denoise": 1.0, + "model": ["10", 0], + "positive": ["13", 0], + "negative": ["7", 0], + "latent_image": ["5", 0], + }, + }, + "8": { + "class_type": "VAEDecode", + "inputs": {"samples": ["3", 0], "vae": ["12", 0]}, + }, + "9": { + "class_type": "SaveImage", + "inputs": {"filename_prefix": filename_prefix, "images": ["8", 0]}, + }, + } + + +if __name__ == "__main__": + import json + + wf = comfyui_build_flux_workflow( + prompt="a red apple on a wooden table, sharp focus, studio lighting", + seed=42, + ) + print(json.dumps(wf, indent=2)) diff --git a/python/functions/ml/tests/test_comfyui_build_flux_workflow.py b/python/functions/ml/tests/test_comfyui_build_flux_workflow.py new file mode 100644 index 00000000..eb84ffc0 --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_build_flux_workflow.py @@ -0,0 +1,79 @@ +"""Tests de estructura para comfyui_build_flux_workflow (funcion pura).""" + +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_flux_workflow import comfyui_build_flux_workflow +from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct + + +def test_estructura_y_class_types(): + wf = comfyui_build_flux_workflow("POS") + assert_api_format(wf) + assert class_types(wf) == { + "UNETLoader", + "DualCLIPLoader", + "VAELoader", + "CLIPTextEncode", + "FluxGuidance", + "EmptySD3LatentImage", + "KSampler", + "VAEDecode", + "SaveImage", + } + + +def test_loaders_separados_de_flux(): + # Flux carga UNET + dos text encoders + VAE por separado (no checkpoint unico). + wf = comfyui_build_flux_workflow( + "POS", + unet="flux1-schnell-fp8-e4m3fn.safetensors", + clip_l="clip_l.safetensors", + t5xxl="t5xxl_fp8_e4m3fn_scaled.safetensors", + vae="ae.safetensors", + weight_dtype="fp8_e4m3fn", + ) + unet = node_by_ct(wf, "UNETLoader")["inputs"] + assert unet["unet_name"] == "flux1-schnell-fp8-e4m3fn.safetensors" + assert unet["weight_dtype"] == "fp8_e4m3fn" + dual = node_by_ct(wf, "DualCLIPLoader")["inputs"] + assert dual["type"] == "flux" + assert dual["clip_name1"] == "t5xxl_fp8_e4m3fn_scaled.safetensors" + assert dual["clip_name2"] == "clip_l.safetensors" + assert node_by_ct(wf, "VAELoader")["inputs"]["vae_name"] == "ae.safetensors" + + +def test_guidance_y_cfg_de_flux(): + # La guia va por FluxGuidance; el cfg del KSampler se fija a 1.0 (schnell). + wf = comfyui_build_flux_workflow("POS", guidance=2.5) + assert node_by_ct(wf, "FluxGuidance")["inputs"]["guidance"] == 2.5 + ks = node_by_ct(wf, "KSampler")["inputs"] + assert ks["cfg"] == 1.0 + # KSampler positive consume la salida de FluxGuidance, no la del CLIPTextEncode directo. + assert ks["positive"] == ["13", 0] + + +def test_params_se_reflejan_en_los_nodos(): + wf = comfyui_build_flux_workflow("POS", width=768, height=512, steps=8, seed=123) + ks = node_by_ct(wf, "KSampler")["inputs"] + assert ks["seed"] == 123 + assert ks["steps"] == 8 + lat = node_by_ct(wf, "EmptySD3LatentImage")["inputs"] + assert lat["width"] == 768 and lat["height"] == 512 + pos = node_by_ct(wf, "FluxGuidance")["inputs"]["conditioning"] + assert pos == ["6", 0] # FluxGuidance aplica sobre el CLIPTextEncode positivo + + +def test_filename_prefix_en_saveimage(): + wf = comfyui_build_flux_workflow("POS", filename_prefix="demo_flux") + assert node_by_ct(wf, "SaveImage")["inputs"]["filename_prefix"] == "demo_flux" + + +def test_determinista(): + # Builder puro: misma entrada -> mismo dict (sin red, seed fijo, sin estado). + a = comfyui_build_flux_workflow("POS", seed=123) + b = comfyui_build_flux_workflow("POS", seed=123) + assert a == b