diff --git a/docs/capabilities/comfyui.md b/docs/capabilities/comfyui.md index bd915f1c..fe07dd99 100644 --- a/docs/capabilities/comfyui.md +++ b/docs/capabilities/comfyui.md @@ -42,6 +42,8 @@ El **API format** (dict de nodos numerados que produce `build_txt2img_workflow` | [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. | | [comfyui_download_model_py_ml](../../python/functions/ml/comfyui_download_model.md) | `download_model(url, dest_subdir='checkpoints', *, comfyui_dir, filename, token, overwrite, timeout_s) -> dict` | Descarga un checkpoint/LoRA/VAE a `models//`. Soporta Civitai (token) y HuggingFace. Valida que no sea HTML de error ni `.safetensors` corrupto. Impura. | +| [comfyui_interrupt_queue_py_ml](../../python/functions/ml/comfyui_interrupt_queue.md) | `interrupt_queue(server='127.0.0.1:8188') -> dict` | Corta la generación en curso (POST `/interrupt`) y lee la cola (GET `/queue`) → `{ok, interrupted, queue_running, queue_pending, error}`. Freno de mano; degrada limpio en fallo de red. Impura. | +| [comfyui_batch_generate_py_ml](../../python/functions/ml/comfyui_batch_generate.md) | `batch_generate(workflow, *, seeds=None, server='127.0.0.1:8188') -> dict` | Encola N variantes (una por seed), parcheando el campo de semilla de los nodos sampler sin mutar el original → `{ok, prompt_ids, count, error}`. Re-roll en una llamada. Compone `submit_workflow`. Impura. | ### Builders, validación e import — dominio `ml` (P0, issue 0064) @@ -69,6 +71,18 @@ El **API format** (dict de nodos numerados que produce `build_txt2img_workflow` | [comfyui_resolve_workflow_deps_py_ml](../../python/functions/ml/comfyui_resolve_workflow_deps.md) | `resolve_workflow_deps(workflow, server='127.0.0.1:8188') -> dict` | Para un workflow ajeno: valida y traduce lo que falta en acciones (`{missing_nodes, missing_models, suggestions}`). Compone `validate_workflow`. Impura. | | [comfyui_list_installed_models_py_ml](../../python/functions/ml/comfyui_list_installed_models.md) | `list_installed_models(folder=None, comfyui_dir='~/ComfyUI') -> dict` | Lista modelos por carpeta resolviendo la ruta real de `extra_model_paths.yaml` (`/mnt/2tb/comfyui_models/`) + la nativa. Escaneo de FS, no depende del server. Impura. | +### Vídeo (txt2video) — dominio `ml` (tag `video-generation`) + +ComfyUI ≥ 0.26.0 trae soporte nativo para **vídeo por difusión**. `build_video_workflow` cubre +los dos modelos que caben en 8 GB: **LTX-Video 2B v0.9.5** (`model='ltx'`, checkpoint todo-en-uno + +VAE temporal + scheduler propio — validado end-to-end en `reports/0084`, clip real de 65 frames, +pico ~7.7 GB) y **Wan2.1 T2V 1.3B** (`model='wan'`, diffusion + umt5 + vae aparte — plantilla nativa +canónica). El resultado es un `.mp4` vía `CreateVideo → SaveVideo`. + +| ID | Firma corta | Qué hace | +|---|---|---| +| [comfyui_build_video_workflow_py_ml](../../python/functions/ml/comfyui_build_video_workflow.md) | `build_video_workflow(prompt, *, model='ltx', negative='', width=512, height=320, num_frames=65, steps=20, seed=0, fps=24) -> dict` | Builder txt2video para LTX-Video 2B (`model='ltx'`, 12 nodos LTXV*) o Wan2.1 1.3B (`model='wan'`, UNETLoader+VAELoader+ModelSamplingSD3). Nombres de modelo reales, defaults conservadores 8 GB. **Pura**. | + ### 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 @@ -89,6 +103,7 @@ report `0079`). | [comfyui_fetch_output_mesh_py_ml](../../python/functions/ml/comfyui_fetch_output_mesh.md) | `fetch_output_mesh(prompt_id, *, server, dest=None, timeout) -> dict` | Localiza la malla en `/history/{prompt_id}` (el SaveGLB la expone bajo la clave `"3d"`, no `"images"`) y la baja via GET `/view` a disco. Hermana de `fetch_output_image`. Impura. | | [comfyui_install_3d_model_py_ml](../../python/functions/ml/comfyui_install_3d_model.md) | `install_3d_model(variant='mini', *, hf_token=None, comfyui_dir) -> dict` | Instala el checkpoint Hunyuan3D-2 (mini/standard/mv) en `checkpoints/`. Cascada: ya-instalado → cache de HF → descarga. Resuelve la ruta real via `extra_model_paths.yaml`. Impura. | | [comfyui_image_to_3d_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_image_to_3d_oneshot.md) | `image_to_3d_oneshot(image_path, *, server, variant='mini', dest=None, wait_timeout, **gen) -> dict` | **Pipeline** imagen en disco → malla GLB en una llamada: upload + build + submit + wait + fetch. Promoción de la secuencia (issue 0087). Impuro. | +| [comfyui_build_textured_3d_multiview_workflow_py_ml](../../python/functions/ml/comfyui_build_textured_3d_multiview_workflow.md) | `build_textured_3d_multiview_workflow(image_name, *, ckpt='hunyuan3d-dit-v2-mv.safetensors', views=6, octree=384, max_faces=50000, upscale_model='4x_foolhardy_Remacri.pth') -> dict` | Builder imagen→malla 3D **con textura PBR** vía el wrapper Hunyuan3DWrapper (kijai): 4/6 vistas + delight + sample multi-vista + upscale Remacri + bake sobre UV (19 nodos). Cobertura de atlas 32.93% (report 0082). **Pura**. En 8 GB ejecutar en 2 fases (shape→`/free`→paint). | ### Por la UI web (CDP) — dominio `browser` @@ -175,30 +190,34 @@ Para tunear nodo a nodo en vez del oneshot: `build_image_to_3d_workflow(image_na - **No es un grupo de generación genérica de imágenes**: cubre ComfyUI concretamente (su API y su frontend litegraph). Para otros backends (Automatic1111, diffusers) harían falta otras funciones. -- **Los builders cubren txt2img, img2img, upscale, LoRA stacks, inpaint, ControlNet y SDXL - refiner** (`build_txt2img_workflow`, `build_img2img_workflow`, `build_upscale_workflow`, - `inject_lora`, `build_inpaint_workflow`, `build_controlnet_workflow`, `build_sdxl_refiner_workflow`). - Workflows aún más complejos (multi-ControlNet avanzado, IPAdapter, vídeo) se montan en la UI a mano - y se capturan con `export_workflow_ui`, o se importan de internet con - `import_workflow_json`/`import_workflow_png`, se resuelven sus dependencias con +- **Los builders cubren txt2img, img2img, upscale, LoRA stacks, inpaint, ControlNet, SDXL + refiner, vídeo (LTX/Wan) y 3D texturizado multi-vista** (`build_txt2img_workflow`, + `build_img2img_workflow`, `build_upscale_workflow`, `inject_lora`, `build_inpaint_workflow`, + `build_controlnet_workflow`, `build_sdxl_refiner_workflow`, `build_video_workflow`, + `build_textured_3d_multiview_workflow`). Workflows aún más complejos (multi-ControlNet avanzado, + IPAdapter) se montan en la UI a mano y se capturan con `export_workflow_ui`, o se importan de + internet con `import_workflow_json`/`import_workflow_png`, se resuelven sus dependencias con `resolve_workflow_deps` (instala nodos con `install_custom_node`, descubre modelos con `search_civitai_models`) y se validan con `validate_workflow` antes de encolar. -- **Los 9 builders puros tienen tests de estructura** (`python/functions/ml/tests/test_comfyui_build_*.py` +- **Los 11 builders puros tienen tests de estructura** (`python/functions/ml/tests/test_comfyui_build_*.py` + `test_comfyui_inject_lora.py`): verifican los `class_type` esperados, que los parámetros se reflejan en los nodos, la validez de las conexiones `[node_id, output_index]` y la pureza de `inject_lora`. Son tests offline (no tocan GPU ni server); las funciones impuras del grupo (todo lo que habla con el server, el navegador o Civitai/HuggingFace) no se cubren con unit tests por diseño — se validan con el server vivo. +- **Control de cola**: `interrupt_queue` corta la generación en curso + lee `/queue`; `batch_generate` + encola N variantes por seed (re-roll). No vacían la cola entera (eso es `POST /queue {"clear": true}`). - **Las funciones `*_ui` requieren la pestaña abierta y el navegador con CDP** (puerto 9222 por defecto). Sin target que matchee `server_url_substr`, devuelven `ok=False`. Para automatización desatendida sin navegador, usa el camino API (`submit_workflow` + `wait_result`). - **`download_model` no gestiona el catálogo del server**: tras bajar un modelo, llama `refresh_nodes_ui` (o recarga la página) para que ComfyUI lo vea en los combos. -- **El camino imagen→3D es shape-only**: los nodos nativos de Hunyuan3D-2 +- **El camino imagen→3D nativo es shape-only**: los nodos nativos de Hunyuan3D-2 (`build_image_to_3d_workflow`, `fetch_output_mesh`, `install_3d_model`, `image_to_3d_oneshot`) - reconstruyen la FORMA, sin color ni textura horneada. Para color/textura haría falta el wrapper - de kijai (compila `custom_rasterizer`) — fuera del grupo. Tampoco hay decimación: las mallas son - densas (decenas de MB de GLB). Decisión y comparación vs la app local en - `reports/0069-2026-06-23-comfyui-img-to-3d.md`. + reconstruyen la FORMA, sin color ni textura horneada. Para **textura PBR** está + `build_textured_3d_multiview_workflow`, que usa el wrapper de kijai (requiere `custom_rasterizer` + CUDA + `ComfyUI_essentials` + el upscaler Remacri) y debe ejecutarse en 2 fases en 8 GB + (shape→`/free`→paint). Detalle y cobertura medida en `reports/0082`; shape-only y comparación vs la + app local en `reports/0069-2026-06-23-comfyui-img-to-3d.md`. - La primitiva de transport CDP es [`cdp_eval`](../../python/functions/browser/cdp_eval.md) (grupo navegador): si necesitas leer/escribir algo del grafo que estas funciones no cubren, compón `cdp_eval` directamente antes de inventar nada. diff --git a/python/functions/ml/comfyui_batch_generate.md b/python/functions/ml/comfyui_batch_generate.md new file mode 100644 index 00000000..51d319ae --- /dev/null +++ b/python/functions/ml/comfyui_batch_generate.md @@ -0,0 +1,75 @@ +--- +name: comfyui_batch_generate +kind: function +lang: py +domain: ml +version: "1.0.0" +purity: impure +signature: "def comfyui_batch_generate(workflow: dict, *, seeds: list | None = None, server: str = \"127.0.0.1:8188\") -> dict" +description: "Encola N variantes de un workflow ComfyUI, una por seed de la lista, parcheando el campo de semilla de los nodos sampler (KSampler.seed, KSamplerAdvanced/SamplerCustom.noise_seed) sin mutar el original (deepcopy), y recoge cada prompt_id. Compone comfyui_submit_workflow. Util para barridos de re-roll: misma escena, varias semillas, una sola llamada. Devuelve {ok, prompt_ids, count, error}. Impura: HTTP POST por variante, solo stdlib." +tags: [comfyui, ml, batch, seeds, queue, http] +uses_functions: ["comfyui_submit_workflow_py_ml"] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: workflow + desc: "dict en API format (resultado de un builder). No se muta: cada variante es una copia profunda con la semilla parcheada." + - name: seeds + desc: "Lista de semillas (int); cada una produce una variante encolada. None o vacia encola el workflow tal cual una sola vez. keyword-only." + - name: server + desc: "host:port del servidor ComfyUI sin esquema (default '127.0.0.1:8188'). keyword-only." +output: "dict con ok (bool, True si TODAS las variantes se encolaron), prompt_ids (list[str] en orden de seeds, para comfyui_wait_result), count (int, variantes encoladas con exito), error (str, primer error; vacio si OK). Si una variante falla, detiene el barrido y devuelve los prompt_ids ya encolados." +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/ml/comfyui_batch_generate.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions")) +from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow +from ml.comfyui_batch_generate import comfyui_batch_generate + +wf = comfyui_build_txt2img_workflow( + ckpt_name="v1-5-pruned-emaonly-fp16.safetensors", + positive="a red apple on a wooden table, sharp focus", + negative="blurry, low quality", +) +res = comfyui_batch_generate(wf, seeds=[1, 2, 3]) +# {'ok': True, 'prompt_ids': ['', '', ''], 'count': 3, 'error': ''} +for pid in res["prompt_ids"]: + pass # comfyui_wait_result(pid) para recoger cada resultado +``` + +O lanzable directo (build txt2img + encolar 2 seeds) con: `./fn run comfyui_batch_generate`. + +## Cuando usarla + +Para generar varias variantes de la misma escena cambiando solo la semilla +(re-roll de calidad) en una sola llamada, en vez de editar el seed y reenviar a +mano N veces. Aplica a cualquier workflow con nodo sampler: txt2img, img2img, +video (parchea `noise_seed` del SamplerCustom de LTX), etc. Tras encolar, sigue +cada `prompt_id` con `comfyui_wait_result`. + +## Gotchas + +- Parchea TODO input llamado `seed` o `noise_seed` en cualquier nodo. Si un + workflow tiene varios samplers, todos reciben la misma semilla de la variante + (normalmente lo deseado). Si necesitas semillas independientes por sampler, + parchea a mano. +- Encolar tiene efecto secundario: arranca trabajo de GPU. N seeds = N prompts en + cola = N corridas de GPU en serie. En 8GB, no encoles 20 videos a la vez sin + vigilar VRAM/tiempo. +- `seeds=None` encola el workflow tal cual UNA vez (sin tocar la semilla): util + como "submit con la firma de batch". +- Fail-fast: si una variante es rechazada (HTTP 400), detiene el barrido, + devuelve `ok=False` + `error` y los `prompt_ids` ya encolados (no hace rollback + de los anteriores — ya estan en la cola del servidor). +- Si necesitas cortar un barrido a medias, usa `comfyui_interrupt_queue` (corta el + que se ejecuta) o `POST /queue {"clear": true}` para vaciar los pendientes. diff --git a/python/functions/ml/comfyui_batch_generate.py b/python/functions/ml/comfyui_batch_generate.py new file mode 100644 index 00000000..fef7b8d4 --- /dev/null +++ b/python/functions/ml/comfyui_batch_generate.py @@ -0,0 +1,91 @@ +"""Encola N variantes de un workflow ComfyUI, una por seed, y recoge los prompt_ids. + +Funcion impura: hace red (POST /prompt por variante, via comfyui_submit_workflow). +Compone comfyui_submit_workflow. + +Para cada seed de la lista, copia el workflow (deepcopy, no muta el original), +parchea el campo de semilla de los nodos sampler (KSampler.seed, KSamplerAdvanced. +noise_seed, SamplerCustom.noise_seed — en general cualquier input "seed"/"noise_seed") +y lo encola. Util para barridos de re-roll: misma escena, varias semillas, una sola +llamada. Devuelve los prompt_ids en el mismo orden que la lista de seeds; cada uno +se sigue con comfyui_wait_result. +""" +import copy +import os +import sys + +_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +if _THIS_DIR not in sys.path: + sys.path.insert(0, _THIS_DIR) + +from comfyui_submit_workflow import comfyui_submit_workflow # noqa: E402 + +# Campos de semilla conocidos en los nodos sampler de ComfyUI. +_SEED_KEYS = ("seed", "noise_seed") + + +def _patch_seed(workflow: dict, seed: int) -> dict: + """Copia el workflow y fija `seed` en todos los inputs de semilla (no muta el original).""" + wf = copy.deepcopy(workflow) + for node in wf.values(): + inputs = node.get("inputs") + if not isinstance(inputs, dict): + continue + for key in _SEED_KEYS: + if key in inputs: + inputs[key] = seed + return wf + + +def comfyui_batch_generate( + workflow: dict, + *, + seeds: list | None = None, + server: str = "127.0.0.1:8188", +) -> dict: + """Encola una variante del workflow por cada seed y devuelve los prompt_ids. + + Args: + workflow: dict en API format (resultado de un builder). No se muta: cada + variante es una copia profunda con la semilla parcheada. + seeds: lista de semillas (int). Cada una produce una variante encolada. Si + es None o vacia, se encola el workflow tal cual una sola vez (sin + parchear semilla). keyword-only. + server: host:port del servidor ComfyUI sin esquema. keyword-only. + + Returns: + dict con: + - ok (bool): True si TODAS las variantes se encolaron sin error. + - prompt_ids (list[str]): prompt_id de cada variante encolada, en orden. + - count (int): numero de variantes encoladas con exito. + - error (str): primer error encontrado; cadena vacia si todo OK. Si una + variante falla, se detiene el barrido y se devuelven los prompt_ids ya + encolados. + """ + out = {"ok": False, "prompt_ids": [], "count": 0, "error": ""} + variants = [(s, _patch_seed(workflow, s)) for s in seeds] if seeds else [(None, workflow)] + + for seed, wf in variants: + try: + resp = comfyui_submit_workflow(wf, server=server) + except RuntimeError as exc: + label = "tal cual" if seed is None else f"seed={seed}" + out["error"] = f"variante {label} fallo al encolar: {exc}" + return out + out["prompt_ids"].append(resp["prompt_id"]) + + out["count"] = len(out["prompt_ids"]) + out["ok"] = True + return out + + +if __name__ == "__main__": + from comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow + + wf = comfyui_build_txt2img_workflow( + ckpt_name="v1-5-pruned-emaonly-fp16.safetensors", + positive="a red apple on a wooden table, sharp focus", + negative="blurry, low quality", + ) + res = comfyui_batch_generate(wf, seeds=[1, 2]) + print(f"ok={res['ok']} count={res['count']} ids={res['prompt_ids']} error={res['error']!r}") diff --git a/python/functions/ml/comfyui_build_textured_3d_multiview_workflow.md b/python/functions/ml/comfyui_build_textured_3d_multiview_workflow.md new file mode 100644 index 00000000..a18ae42c --- /dev/null +++ b/python/functions/ml/comfyui_build_textured_3d_multiview_workflow.md @@ -0,0 +1,86 @@ +--- +name: comfyui_build_textured_3d_multiview_workflow +kind: function +lang: py +domain: ml +version: "1.0.0" +purity: pure +signature: "def comfyui_build_textured_3d_multiview_workflow(image_name: str, *, ckpt: str = \"hunyuan3d-dit-v2-mv.safetensors\", views: int = 6, octree: int = 384, max_faces: int = 50000, upscale_model: str = \"4x_foolhardy_Remacri.pth\") -> dict" +description: "Construye el dict (API format) del pipeline imagen->malla 3D texturizada PBR multi-vista de ComfyUI via el wrapper Hunyuan3DWrapper (kijai). Cadena: LoadImage -> Hy3DModelLoader -> Hy3DGenerateMesh -> Hy3DVAEDecode(octree) -> Hy3DPostprocessMesh(max_faces) -> Hy3DMeshUVWrap -> Hy3DCameraConfig(4 o 6 vistas) + Hy3DRenderMultiView + Hy3DDelightImage -> Hy3DSampleMultiView -> [UpscaleModelLoader+ImageUpscaleWithModel(Remacri)+ImageResize+] -> Hy3DBakeFromMultiview -> Hy3DMeshVerticeInpaintTexture -> Hy3DApplyTexture -> Hy3DExportMesh(glb). Portado del report 0082 (cobertura de atlas 32.93% con 6 vistas + Remacri + octree 384). Pura, sin red ni I/O." +tags: [comfyui, ml, img-to-3d, texture, multiview, hunyuan3d, workflow] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +params: + - name: image_name + desc: "Nombre del archivo de imagen de referencia tal como lo ve el servidor ComfyUI en su carpeta input/ (subido con POST /upload/image)." + - name: ckpt + desc: "Checkpoint del modelo de forma Hunyuan3D para Hy3DModelLoader. Por defecto el variante multi-vista hunyuan3d-dit-v2-mv. keyword-only." + - name: views + desc: "Numero de vistas de camara: 4 (front/left/back/right) o 6 (anade top/bottom, rellena concavidades). Otro valor lanza ValueError. keyword-only." + - name: octree + desc: "octree_resolution del Hy3DVAEDecode (mas alto = malla mas fina, mas VRAM; 384 en el report 0082). keyword-only." + - name: max_faces + desc: "max_facenum del Hy3DPostprocessMesh (decimacion; 50000 en el report 0082). keyword-only." + - name: upscale_model + desc: "Modelo de upscale ESRGAN en upscale_models/ para mejorar las vistas antes del bake (factor dominante de cobertura). Cadena vacia desactiva el upscale. keyword-only." +output: "dict en API format listo para comfyui_submit_workflow. node_ids '1'..'19'; los nodos de upscale ('13'..'15') solo presentes si upscale_model esta activo. El SaveGLB-equivalente Hy3DExportMesh produce un .glb texturizado en output/3D/." +tested: true +tests: ["estructura completa shape+paint+upscale (18 class_types)", "params imagen/ckpt/octree/max_faces reflejados", "6 vistas configuran 6 azimuths/elevations", "4 vistas configuran 4 azimuths", "sin upscale omite nodos Remacri y el bake toma del sample", "views invalido lanza ValueError"] +test_file_path: "python/functions/ml/tests/test_comfyui_build_textured_3d_multiview_workflow.py" +file_path: "python/functions/ml/comfyui_build_textured_3d_multiview_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_textured_3d_multiview_workflow import ( + comfyui_build_textured_3d_multiview_workflow, +) + +wf = comfyui_build_textured_3d_multiview_workflow( + "tex_src_character.png", views=6, octree=384, max_faces=50000, + upscale_model="4x_foolhardy_Remacri.pth", +) +# wf["9"]["class_type"] == "Hy3DCameraConfig" (6 vistas) +# wf["19"]["class_type"] == "Hy3DExportMesh" (.glb texturizado) +# OJO: en 8GB ejecutar en 2 fases (ver Gotchas), no de una pasada +``` + +O lanzable directo con: `./fn run comfyui_build_textured_3d_multiview_workflow` (imprime el JSON del workflow de ejemplo). + +## Cuando usarla + +Cuando quieras una malla 3D **con textura** desde una sola imagen, con mejor +cobertura de atlas que el image-to-3D nativo (que da geometria sin pintar). Es el +builder del pipeline de texturizado multi-vista del report 0082: 6 vistas de +camara + delight + sample multi-vista + upscale Remacri de las vistas + bake sobre +el UV. Para geometria sin textura usa `comfyui_build_image_to_3d_workflow` +(nodos nativos, mas ligero). + +## Gotchas + +- **Ejecutar en 2 fases en 8GB**: el grafo es monolitico (shape + paint en un + dict) por claridad, pero el grafo entero da OOM en 8GB (confirmado reports + 0075/0081/0082). El camino valido es: ejecutar la fase shape (nodos 1-5 -> + Hy3DExportMesh del shape), liberar VRAM con `POST /free`, y luego la fase paint + arrancando desde `Hy3DLoadMesh` del .glb del shape. La separacion + el /free los + orquesta el pipeline impuro que consuma este builder; este dict es la referencia + de cableado completo. +- Requiere el custom node **ComfyUI-Hunyuan3DWrapper** (kijai) + `custom_rasterizer` + CUDA compilado, **ComfyUI_essentials** (para `ImageResize+`) y el modelo + `4x_foolhardy_Remacri.pth` en `upscale_models/`. Si falta algo, ComfyUI rechaza + el workflow con HTTP 400 (esta funcion es pura y no valida contra el servidor). +- `ckpt` por defecto es el variante multi-vista (`-mv`). El report 0082 uso + `hy3dgen/hunyuan3d-dit-v2-0-fp16.safetensors`; ajusta `ckpt` al nombre real que + el servidor enumere en Hy3DModelLoader. +- `upscale_model=""` desactiva el upscale: el bake toma las vistas directas del + Hy3DSampleMultiView. Pierde la mejora dominante de cobertura (el report midio + 20.81% -> 32.93% al cablear Remacri en serie). +- Render bonito del GLB no disponible headless; verificar con `Load3D`/`Preview3D` + en la UI de ComfyUI o el visor de `apps/img_to_3d_webapp`. diff --git a/python/functions/ml/comfyui_build_textured_3d_multiview_workflow.py b/python/functions/ml/comfyui_build_textured_3d_multiview_workflow.py new file mode 100644 index 00000000..a1d3ddf3 --- /dev/null +++ b/python/functions/ml/comfyui_build_textured_3d_multiview_workflow.py @@ -0,0 +1,241 @@ +"""Construye un workflow ComfyUI imagen->malla 3D texturizada multi-vista (API format). + +Usa el wrapper ComfyUI-Hunyuan3DWrapper (kijai): genera la geometria con +Hy3DGenerateMesh/Hy3DVAEDecode, la limpia y le hace UV unwrap, renderiza N vistas +de camara, sintetiza la textura multi-vista (Hy3DSampleMultiView) opcionalmente +mejorada con un upscaler ESRGAN (Remacri), la hornea sobre el atlas UV +(Hy3DBakeFromMultiview), rellena los huecos por vertices y exporta el GLB con +material PBR. Portado del pipeline validado en el report 0082 (cobertura de atlas +32.93 % con 6 vistas + Remacri + octree 384). + +Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos. + +IMPORTANTE: el grafo es monolitico (shape + paint en un solo dict) por claridad, +pero en 8 GB de VRAM debe ejecutarse en 2 fases (shape -> /free -> paint), no de +una pasada. La separacion en fases y el /free los orquesta el pipeline impuro que +consuma este builder. Ver la seccion Gotchas del .md. +""" + +# Vistas de camara soportadas: tabla (azimuths, elevations, weights) por numero de vistas. +# 4 = front/left/back/right; 6 anade top/bottom (rellena concavidades que 4 camaras no ven). +_CAMERA_PRESETS = { + 4: { + "camera_azimuths": "0, 90, 180, 270", + "camera_elevations": "0, 0, 0, 0", + "view_weights": "1, 0.1, 0.5, 0.1", + }, + 6: { + "camera_azimuths": "0, 90, 180, 270, 0, 180", + "camera_elevations": "0, 0, 0, 0, 90, -90", + "view_weights": "1, 0.1, 0.5, 0.1, 0.05, 0.05", + }, +} + + +def comfyui_build_textured_3d_multiview_workflow( + image_name: str, + *, + ckpt: str = "hunyuan3d-dit-v2-mv.safetensors", + views: int = 6, + octree: int = 384, + max_faces: int = 50000, + upscale_model: str = "4x_foolhardy_Remacri.pth", +) -> dict: + """Construye el dict del workflow imagen->3D texturizado multi-vista. + + Args: + image_name: nombre del archivo de imagen de referencia tal como lo ve el + servidor ComfyUI en su carpeta input/ (subido con POST /upload/image). + ckpt: checkpoint del modelo de forma Hunyuan3D para Hy3DModelLoader (por + defecto el variante multi-vista hunyuan3d-dit-v2-mv). keyword-only. + views: numero de vistas de camara: 4 (front/left/back/right) o 6 (anade + top/bottom). Cualquier otro valor lanza ValueError. keyword-only. + octree: octree_resolution del Hy3DVAEDecode (mas alto = malla mas fina, + mas VRAM). keyword-only. + max_faces: max_facenum del Hy3DPostprocessMesh (decimacion de la malla). + keyword-only. + upscale_model: nombre del modelo de upscale ESRGAN en upscale_models/ para + mejorar las vistas antes del bake. Cadena vacia o None desactiva el + upscale (el bake toma las vistas directas del sample). keyword-only. + + Returns: + dict en API format listo para comfyui_submit_workflow. node_ids "1".."19" + (los de upscale "13".."15" solo presentes si upscale_model esta activo). + + Raises: + ValueError: si views no es 4 ni 6. + """ + if views not in _CAMERA_PRESETS: + raise ValueError( + f"comfyui_build_textured_3d_multiview_workflow: views debe ser 4 o 6, " + f"no {views!r}" + ) + cam = _CAMERA_PRESETS[views] + + wf = { + # --- Fase shape: imagen -> malla limpia con UV --- + "1": { + "class_type": "LoadImage", + "inputs": {"image": image_name}, + }, + "2": { + "class_type": "Hy3DModelLoader", + "inputs": {"model": ckpt, "attention_mode": "sdpa", "cublas_ops": False}, + }, + "3": { + "class_type": "Hy3DGenerateMesh", + "inputs": { + "pipeline": ["2", 0], + "image": ["1", 0], + "guidance_scale": 5.5, + "steps": 30, + "seed": 42, + "force_offload": True, + }, + }, + "4": { + "class_type": "Hy3DVAEDecode", + "inputs": { + "vae": ["2", 1], + "latents": ["3", 0], + "box_v": 1.01, + "octree_resolution": octree, + "num_chunks": 8000, + "mc_level": 0, + "mc_algo": "mc", + "enable_flash_vdm": True, + "force_offload": True, + }, + }, + "5": { + "class_type": "Hy3DPostprocessMesh", + "inputs": { + "trimesh": ["4", 0], + "remove_floaters": True, + "remove_degenerate_faces": True, + "reduce_faces": True, + "max_facenum": max_faces, + "smooth_normals": False, + }, + }, + "6": { + "class_type": "Hy3DMeshUVWrap", + "inputs": {"trimesh": ["5", 0]}, + }, + # --- Fase paint: render multi-vista + delight + sample + bake + textura --- + "7": { + "class_type": "DownloadAndLoadHy3DPaintModel", + "inputs": {"model": "hunyuan3d-paint-v2-0"}, + }, + "8": { + "class_type": "DownloadAndLoadHy3DDelightModel", + "inputs": {"model": "hunyuan3d-delight-v2-0"}, + }, + "9": { + "class_type": "Hy3DCameraConfig", + "inputs": { + "camera_azimuths": cam["camera_azimuths"], + "camera_elevations": cam["camera_elevations"], + "view_weights": cam["view_weights"], + "camera_distance": 1.45, + "ortho_scale": 1.2, + }, + }, + "10": { + "class_type": "Hy3DRenderMultiView", + "inputs": { + "trimesh": ["6", 0], + "render_size": 1024, + "texture_size": 1024, + "camera_config": ["9", 0], + "normal_space": "world", + }, + }, + "11": { + "class_type": "Hy3DDelightImage", + "inputs": { + "delight_pipe": ["8", 0], + "image": ["1", 0], + "steps": 50, + "width": 512, + "height": 512, + "cfg_image": 1.0, + "seed": 42, + }, + }, + "12": { + "class_type": "Hy3DSampleMultiView", + "inputs": { + "pipeline": ["7", 0], + "ref_image": ["11", 0], + "normal_maps": ["10", 0], + "position_maps": ["10", 1], + "view_size": 512, + "steps": 25, + "seed": 0, + "camera_config": ["9", 0], + }, + }, + } + + # Upscale opcional de los multiviews antes del bake (factor dominante de cobertura). + if upscale_model: + wf["13"] = { + "class_type": "UpscaleModelLoader", + "inputs": {"model_name": upscale_model}, + } + wf["14"] = { + "class_type": "ImageUpscaleWithModel", + "inputs": {"upscale_model": ["13", 0], "image": ["12", 0]}, + } + wf["15"] = { + "class_type": "ImageResize+", + "inputs": { + "image": ["14", 0], + "width": 1024, + "height": 1024, + "interpolation": "lanczos", + "method": "stretch", + "condition": "always", + "multiple_of": 0, + }, + } + bake_images = ["15", 0] + else: + bake_images = ["12", 0] + + wf["16"] = { + "class_type": "Hy3DBakeFromMultiview", + "inputs": { + "images": bake_images, + "renderer": ["10", 2], + "camera_config": ["9", 0], + }, + } + wf["17"] = { + "class_type": "Hy3DMeshVerticeInpaintTexture", + "inputs": {"texture": ["16", 0], "mask": ["16", 1], "renderer": ["16", 2]}, + } + wf["18"] = { + "class_type": "Hy3DApplyTexture", + "inputs": {"texture": ["17", 0], "renderer": ["17", 2]}, + } + wf["19"] = { + "class_type": "Hy3DExportMesh", + "inputs": { + "trimesh": ["18", 0], + "filename_prefix": "3D/textured_multiview", + "file_format": "glb", + "save_file": True, + }, + } + return wf + + +if __name__ == "__main__": + import json + + wf = comfyui_build_textured_3d_multiview_workflow( + "tex_src_character.png", views=6, octree=384, max_faces=50000 + ) + print(json.dumps(wf, indent=2)) diff --git a/python/functions/ml/comfyui_build_video_workflow.md b/python/functions/ml/comfyui_build_video_workflow.md new file mode 100644 index 00000000..0cd86612 --- /dev/null +++ b/python/functions/ml/comfyui_build_video_workflow.md @@ -0,0 +1,90 @@ +--- +name: comfyui_build_video_workflow +kind: function +lang: py +domain: ml +version: "1.0.0" +purity: pure +signature: "def comfyui_build_video_workflow(prompt: str, *, model: str = \"ltx\", negative: str = \"\", width: int = 512, height: int = 320, num_frames: int = 65, steps: int = 20, seed: int = 0, fps: int = 24) -> dict" +description: "Construye el dict de un workflow ComfyUI txt2video en API format para LTX-Video 2B v0.9.5 (model='ltx') o Wan2.1 T2V 1.3B (model='wan'), con los nombres de modelo reales. LTX: CLIPLoader(ltxv)+CheckpointLoaderSimple -> CLIPTextEncode x2 -> LTXVConditioning+EmptyLTXVLatentVideo+LTXVScheduler+KSamplerSelect -> SamplerCustom -> VAEDecode -> CreateVideo -> SaveVideo. Wan: UNETLoader+CLIPLoader(wan)+VAELoader+ModelSamplingSD3 -> CLIPTextEncode x2+EmptyHunyuanLatentVideo -> KSampler(uni_pc/simple) -> VAEDecode -> CreateVideo -> SaveVideo. Defaults conservadores para 8GB. Pura, sin red ni I/O. Hermana de comfyui_build_txt2img_workflow." +tags: [comfyui, ml, video-generation, txt2video, ltx-video, wan, workflow] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +params: + - name: prompt + desc: "Prompt positivo: lo que se quiere ver en el clip de video." + - name: model + desc: "'ltx' (LTX-Video 2B v0.9.5, todo-en-uno) o 'wan' (Wan2.1 T2V 1.3B, diffusion+vae aparte). Cualquier otro valor lanza ValueError. keyword-only." + - name: negative + desc: "Prompt negativo: lo que se quiere evitar. Por defecto cadena vacia. keyword-only." + - name: width + desc: "Ancho del video en px (multiplo de 32 recomendado). keyword-only." + - name: height + desc: "Alto del video en px (multiplo de 32 recomendado). keyword-only." + - name: num_frames + desc: "Numero de frames del clip (longitud temporal del latente de video). keyword-only." + - name: steps + desc: "Pasos de sampling: LTXVScheduler para ltx, KSampler para wan. keyword-only." + - name: seed + desc: "Semilla del sampler. 0 es determinista; cambiar para variar el clip. keyword-only." + - name: fps + desc: "Frames por segundo del video (CreateVideo). En LTX se usa tambien como frame_rate del LTXVConditioning. keyword-only." +output: "dict en API format listo para comfyui_submit_workflow. node_ids string; cada valor con class_type + inputs. LTX devuelve 12 nodos; Wan 11. La cfg/sampler/scheduler se fijan internamente segun el modelo (LTX: cfg 3.0, euler; Wan: cfg 6.0, uni_pc/simple, shift 8.0)." +tested: true +tests: ["LTX: nodos LTXV* presentes + t5xxl fp8 + ckpt real", "Wan: UNETLoader/VAELoader/ModelSamplingSD3 + umt5 + wan_2.1_vae", "params reflejados (width/height/num_frames/steps/seed/fps)", "model invalido lanza ValueError"] +test_file_path: "python/functions/ml/tests/test_comfyui_build_video_workflow.py" +file_path: "python/functions/ml/comfyui_build_video_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_video_workflow import comfyui_build_video_workflow + +wf = comfyui_build_video_workflow( + "A red fox runs through a sunlit autumn forest, cinematic, shallow depth of field", + model="ltx", + negative="low quality, worst quality, deformed, motion smear", + width=512, height=320, num_frames=65, steps=25, seed=42, fps=24, +) +# wf["72"]["class_type"] == "SamplerCustom" (camino LTX) +# wf["79"]["class_type"] == "SaveVideo" +# -> comfyui_submit_workflow(wf) para encolar el clip +``` + +O lanzable directo con: `./fn run comfyui_build_video_workflow` (imprime el JSON del workflow LTX de ejemplo). + +## Cuando usarla + +Antes de enviar una generacion de video txt2video a ComfyUI: construye aqui el +dict del workflow y pasalo a `comfyui_submit_workflow`. Usa `model="ltx"` por +defecto (cupo en 8GB confirmado, scheduler y VAE temporales propios); `model="wan"` +si quieres el camino Wan2.1 1.3B (umt5 + vae aparte). Hermana de +`comfyui_build_txt2img_workflow` para imagen estatica. + +## Gotchas + +- Es API format (nodos numerados), NO el formato de la UI de ComfyUI. Es lo que + acepta POST /prompt. +- Los nombres de modelo estan fijados a los reales del equipo + (`ltx-video-2b-v0.9.5.safetensors` + `t5xxl_fp8_e4m3fn_scaled.safetensors`; + `wan2.1_t2v_1.3B_fp16.safetensors` + `umt5_xxl_fp8_e4m3fn_scaled.safetensors` + + `wan_2.1_vae.safetensors`). Deben existir y ser visibles para el servidor o + ComfyUI rechaza el workflow con HTTP 400 al enviarlo (esta funcion es pura y no + valida contra el servidor). +- Cupo 8GB: con los defaults (512x320, 65 frames) LTX pico ~7.7 GB en el report + 0084 sin OOM. Subir resolucion o num_frames acerca el techo. Si da OOM, bajar a + 512x288 / 49 frames. +- El camino LTX esta validado de extremo a extremo (report 0084: clip real de 65 + frames). El camino Wan modela la plantilla nativa canonica de ComfyUI pero NO se + ejecuto en esa sesion; verificar nombres de modelo antes de tirar de el. +- LTX usa cfg baja (3.0). Subirla degrada el video. Por eso la cfg no es parametro: + se fija segun el modelo. +- `SaveVideo` necesita `format`/`codec` (aqui "auto"/"auto"); sin ellos ComfyUI + responde HTTP 400 (gotcha del importador, report 0084). Este builder ya los pone. diff --git a/python/functions/ml/comfyui_build_video_workflow.py b/python/functions/ml/comfyui_build_video_workflow.py new file mode 100644 index 00000000..3fbc533c --- /dev/null +++ b/python/functions/ml/comfyui_build_video_workflow.py @@ -0,0 +1,232 @@ +"""Construye un workflow ComfyUI txt2video en "API format" (dict de nodos numerados). + +Soporta dos modelos de difusion de video nativos de ComfyUI 0.26, ambos pensados +para caber en 8 GB de VRAM con parametros conservadores: + +- model="ltx": LTX-Video 2B v0.9.5. Checkpoint todo-en-uno (UNet + VAE temporal) + + text encoder t5xxl en fp8. Cadena CLIPLoader(ltxv) + CheckpointLoaderSimple -> + CLIPTextEncode x2 -> LTXVConditioning + EmptyLTXVLatentVideo + LTXVScheduler + + KSamplerSelect -> SamplerCustom -> VAEDecode -> CreateVideo -> SaveVideo. + Validado de extremo a extremo en el report 0084 (clip real de 65 frames). + +- model="wan": Wan2.1 T2V 1.3B. Diffusion model (UNETLoader) + text encoder umt5 + fp8 (CLIPLoader type=wan) + wan_2.1_vae aparte (VAELoader) + ModelSamplingSD3 -> + CLIPTextEncode x2 + EmptyHunyuanLatentVideo -> KSampler(uni_pc/simple) -> + VAEDecode -> CreateVideo -> SaveVideo. Plantilla nativa canonica de ComfyUI. + +Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos. +""" + +# Nombres reales de los modelos tal como los ve el servidor ComfyUI. +_LTX_CKPT = "ltx-video-2b-v0.9.5.safetensors" +_LTX_CLIP = "t5xxl_fp8_e4m3fn_scaled.safetensors" +_WAN_UNET = "wan2.1_t2v_1.3B_fp16.safetensors" +_WAN_CLIP = "umt5_xxl_fp8_e4m3fn_scaled.safetensors" +_WAN_VAE = "wan_2.1_vae.safetensors" + + +def comfyui_build_video_workflow( + prompt: str, + *, + model: str = "ltx", + negative: str = "", + width: int = 512, + height: int = 320, + num_frames: int = 65, + steps: int = 20, + seed: int = 0, + fps: int = 24, +) -> dict: + """Construye el dict del workflow txt2video para LTX-Video 2B o Wan2.1 1.3B. + + Args: + prompt: prompt positivo (lo que se quiere ver en el clip). + model: "ltx" (LTX-Video 2B v0.9.5) o "wan" (Wan2.1 T2V 1.3B). keyword-only. + negative: prompt negativo. keyword-only. + width: ancho del video en px (multiplo de 32 recomendado). keyword-only. + height: alto del video en px (multiplo de 32 recomendado). keyword-only. + num_frames: numero de frames del clip (longitud temporal del latente). + keyword-only. + steps: pasos de sampling (LTXVScheduler para ltx, KSampler para wan). + keyword-only. + seed: semilla del sampler (0 = determinista; cambiar para variar). + keyword-only. + fps: frames por segundo del video resultante (CreateVideo). En LTX se usa + ademas como frame_rate del condicionamiento LTXVConditioning. + 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. La cfg, el + sampler y el scheduler se fijan internamente segun el modelo (LTX: cfg 3.0, + euler; Wan: cfg 6.0, uni_pc/simple, shift 8.0). + + Raises: + ValueError: si model no es "ltx" ni "wan". + """ + m = model.lower() + if m == "ltx": + return { + "38": { + "class_type": "CLIPLoader", + "inputs": {"clip_name": _LTX_CLIP, "type": "ltxv", "device": "default"}, + }, + "44": { + "class_type": "CheckpointLoaderSimple", + "inputs": {"ckpt_name": _LTX_CKPT}, + }, + "6": { + "class_type": "CLIPTextEncode", + "inputs": {"text": prompt, "clip": ["38", 0]}, + }, + "7": { + "class_type": "CLIPTextEncode", + "inputs": {"text": negative, "clip": ["38", 0]}, + }, + "70": { + "class_type": "EmptyLTXVLatentVideo", + "inputs": { + "width": width, + "height": height, + "length": num_frames, + "batch_size": 1, + }, + }, + "71": { + "class_type": "LTXVScheduler", + "inputs": { + "steps": steps, + "max_shift": 2.05, + "base_shift": 0.95, + "stretch": True, + "terminal": 0.1, + "latent": ["70", 0], + }, + }, + "73": { + "class_type": "KSamplerSelect", + "inputs": {"sampler_name": "euler"}, + }, + "69": { + "class_type": "LTXVConditioning", + "inputs": { + "positive": ["6", 0], + "negative": ["7", 0], + "frame_rate": fps, + }, + }, + "72": { + "class_type": "SamplerCustom", + "inputs": { + "model": ["44", 0], + "positive": ["69", 0], + "negative": ["69", 1], + "sampler": ["73", 0], + "sigmas": ["71", 0], + "latent_image": ["70", 0], + "add_noise": True, + "noise_seed": seed, + "cfg": 3.0, + }, + }, + "8": { + "class_type": "VAEDecode", + "inputs": {"samples": ["72", 0], "vae": ["44", 2]}, + }, + "78": { + "class_type": "CreateVideo", + "inputs": {"images": ["8", 0], "fps": fps}, + }, + "79": { + "class_type": "SaveVideo", + "inputs": { + "video": ["78", 0], + "filename_prefix": "video", + "format": "auto", + "codec": "auto", + }, + }, + } + if m == "wan": + return { + "37": { + "class_type": "UNETLoader", + "inputs": {"unet_name": _WAN_UNET, "weight_dtype": "default"}, + }, + "38": { + "class_type": "CLIPLoader", + "inputs": {"clip_name": _WAN_CLIP, "type": "wan", "device": "default"}, + }, + "39": { + "class_type": "VAELoader", + "inputs": {"vae_name": _WAN_VAE}, + }, + "48": { + "class_type": "ModelSamplingSD3", + "inputs": {"shift": 8.0, "model": ["37", 0]}, + }, + "6": { + "class_type": "CLIPTextEncode", + "inputs": {"text": prompt, "clip": ["38", 0]}, + }, + "7": { + "class_type": "CLIPTextEncode", + "inputs": {"text": negative, "clip": ["38", 0]}, + }, + "40": { + "class_type": "EmptyHunyuanLatentVideo", + "inputs": { + "width": width, + "height": height, + "length": num_frames, + "batch_size": 1, + }, + }, + "3": { + "class_type": "KSampler", + "inputs": { + "seed": seed, + "steps": steps, + "cfg": 6.0, + "sampler_name": "uni_pc", + "scheduler": "simple", + "denoise": 1.0, + "model": ["48", 0], + "positive": ["6", 0], + "negative": ["7", 0], + "latent_image": ["40", 0], + }, + }, + "8": { + "class_type": "VAEDecode", + "inputs": {"samples": ["3", 0], "vae": ["39", 0]}, + }, + "78": { + "class_type": "CreateVideo", + "inputs": {"images": ["8", 0], "fps": fps}, + }, + "79": { + "class_type": "SaveVideo", + "inputs": { + "video": ["78", 0], + "filename_prefix": "video", + "format": "auto", + "codec": "auto", + }, + }, + } + raise ValueError( + f"comfyui_build_video_workflow: model debe ser 'ltx' o 'wan', no {model!r}" + ) + + +if __name__ == "__main__": + import json + + wf = comfyui_build_video_workflow( + "A red fox runs through a sunlit autumn forest, cinematic, shallow depth of field", + model="ltx", + negative="low quality, worst quality, deformed, motion smear", + seed=42, + ) + print(json.dumps(wf, indent=2)) diff --git a/python/functions/ml/comfyui_interrupt_queue.md b/python/functions/ml/comfyui_interrupt_queue.md new file mode 100644 index 00000000..834217c4 --- /dev/null +++ b/python/functions/ml/comfyui_interrupt_queue.md @@ -0,0 +1,60 @@ +--- +name: comfyui_interrupt_queue +kind: function +lang: py +domain: ml +version: "1.0.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)." +tags: [comfyui, ml, queue, interrupt, control, http] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - 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: "" +file_path: "python/functions/ml/comfyui_interrupt_queue.py" +--- + +## Ejemplo + +```python +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 + +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']}") +``` + +O lanzable directo con: `./fn run comfyui_interrupt_queue`. + +## 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. + +## 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). +- 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). +- En fallo de red NO lanza: devuelve `ok=False` con el mensaje en `error`. Comprueba + `ok` antes de fiarte de los conteos. +- Tras el interrupt conviene liberar VRAM con `POST /free` si vas a encolar otro + trabajo pesado (esta funcion no lo hace). diff --git a/python/functions/ml/comfyui_interrupt_queue.py b/python/functions/ml/comfyui_interrupt_queue.py new file mode 100644 index 00000000..d56c81e9 --- /dev/null +++ b/python/functions/ml/comfyui_interrupt_queue.py @@ -0,0 +1,71 @@ +"""Interrumpe la generacion en curso de ComfyUI y devuelve el estado de la cola. + +Funcion impura: hace red (HTTP POST /interrupt + GET /queue). Solo stdlib. + +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}. +""" +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. + + Args: + server: host:port del servidor ComfyUI sin esquema (default + "127.0.0.1:8188"). + + Returns: + dict con: + - ok (bool): True si tanto el interrupt como la lectura de la cola + 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. + - error (str): mensaje de error si algo fallo; cadena vacia si todo OK. + """ + out = { + "ok": False, + "interrupted": False, + "queue_running": 0, + "queue_pending": 0, + "error": "", + } + base = f"http://{server}" + + # 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): + 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. + try: + with urllib.request.urlopen(f"{base}/queue", timeout=10.0) as resp: + data = json.loads(resp.read()) + out["queue_running"] = len(data.get("queue_running", [])) + out["queue_pending"] = len(data.get("queue_pending", [])) + out["ok"] = True + except urllib.error.URLError as exc: + reason = getattr(exc, "reason", exc) + out["error"] = f"queue fallo: no se pudo conectar a {base}/queue: {reason}" + except json.JSONDecodeError as exc: + out["error"] = f"queue fallo: respuesta no es JSON valido: {exc}" + return out + + +if __name__ == "__main__": + res = comfyui_interrupt_queue() + print( + f"ok={res['ok']} interrupted={res['interrupted']} " + f"running={res['queue_running']} pending={res['queue_pending']} " + f"error={res['error']!r}" + ) diff --git a/python/functions/ml/tests/test_comfyui_build_textured_3d_multiview_workflow.py b/python/functions/ml/tests/test_comfyui_build_textured_3d_multiview_workflow.py new file mode 100644 index 00000000..26086f74 --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_build_textured_3d_multiview_workflow.py @@ -0,0 +1,80 @@ +"""Tests de estructura para comfyui_build_textured_3d_multiview_workflow (pura).""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from ml.comfyui_build_textured_3d_multiview_workflow import ( + comfyui_build_textured_3d_multiview_workflow, +) +from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct + + +def test_estructura_shape_paint_y_upscale(): + wf = comfyui_build_textured_3d_multiview_workflow("ref.png", views=6) + assert_api_format(wf) + cts = class_types(wf) + # Fase shape (geometria) + fase paint (textura multi-vista) + upscale Remacri. + for ct in ( + "LoadImage", + "Hy3DModelLoader", + "Hy3DGenerateMesh", + "Hy3DVAEDecode", + "Hy3DPostprocessMesh", + "Hy3DMeshUVWrap", + "DownloadAndLoadHy3DPaintModel", + "DownloadAndLoadHy3DDelightModel", + "Hy3DCameraConfig", + "Hy3DRenderMultiView", + "Hy3DDelightImage", + "Hy3DSampleMultiView", + "UpscaleModelLoader", + "ImageUpscaleWithModel", + "ImageResize+", + "Hy3DBakeFromMultiview", + "Hy3DApplyTexture", + "Hy3DExportMesh", + ): + assert ct in cts, f"falta {ct}" + + +def test_params_imagen_ckpt_octree_faces(): + wf = comfyui_build_textured_3d_multiview_workflow( + "robot.png", ckpt="custom-mv.safetensors", octree=256, max_faces=40000 + ) + assert node_by_ct(wf, "LoadImage")["inputs"]["image"] == "robot.png" + assert node_by_ct(wf, "Hy3DModelLoader")["inputs"]["model"] == "custom-mv.safetensors" + assert node_by_ct(wf, "Hy3DVAEDecode")["inputs"]["octree_resolution"] == 256 + assert node_by_ct(wf, "Hy3DPostprocessMesh")["inputs"]["max_facenum"] == 40000 + + +def test_6_vistas_configura_camara(): + wf = comfyui_build_textured_3d_multiview_workflow("ref.png", views=6) + cam = node_by_ct(wf, "Hy3DCameraConfig")["inputs"] + # 6 azimuths/elevations (front/left/back/right + top/bottom). + assert len(cam["camera_azimuths"].split(",")) == 6 + assert len(cam["camera_elevations"].split(",")) == 6 + + +def test_4_vistas_configura_camara(): + wf = comfyui_build_textured_3d_multiview_workflow("ref.png", views=4) + cam = node_by_ct(wf, "Hy3DCameraConfig")["inputs"] + assert len(cam["camera_azimuths"].split(",")) == 4 + + +def test_sin_upscale_omite_nodos_remacri(): + wf = comfyui_build_textured_3d_multiview_workflow("ref.png", upscale_model="") + cts = class_types(wf) + assert "UpscaleModelLoader" not in cts + assert "ImageUpscaleWithModel" not in cts + # El bake toma las vistas directas del sample multi-vista (nodo 12). + assert node_by_ct(wf, "Hy3DBakeFromMultiview")["inputs"]["images"] == ["12", 0] + + +def test_views_invalido_lanza_valueerror(): + with pytest.raises(ValueError): + comfyui_build_textured_3d_multiview_workflow("ref.png", views=3) diff --git a/python/functions/ml/tests/test_comfyui_build_video_workflow.py b/python/functions/ml/tests/test_comfyui_build_video_workflow.py new file mode 100644 index 00000000..ce47abb8 --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_build_video_workflow.py @@ -0,0 +1,91 @@ +"""Tests de estructura para comfyui_build_video_workflow (funcion pura).""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from ml.comfyui_build_video_workflow import comfyui_build_video_workflow +from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct + + +def test_ltx_estructura_y_nodos(): + wf = comfyui_build_video_workflow("a fox running", model="ltx") + assert_api_format(wf) + cts = class_types(wf) + # Nodos clave del camino LTX presentes. + for ct in ( + "CLIPLoader", + "CheckpointLoaderSimple", + "EmptyLTXVLatentVideo", + "LTXVScheduler", + "KSamplerSelect", + "LTXVConditioning", + "SamplerCustom", + "CreateVideo", + "SaveVideo", + ): + assert ct in cts, f"falta {ct} en LTX" + # El CLIPLoader de LTX usa el text encoder t5xxl fp8 con type=ltxv. + clip = node_by_ct(wf, "CLIPLoader")["inputs"] + assert clip["type"] == "ltxv" + assert clip["clip_name"] == "t5xxl_fp8_e4m3fn_scaled.safetensors" + assert node_by_ct(wf, "CheckpointLoaderSimple")["inputs"]["ckpt_name"] == ( + "ltx-video-2b-v0.9.5.safetensors" + ) + + +def test_wan_estructura_y_nodos(): + wf = comfyui_build_video_workflow("a fox running", model="wan") + assert_api_format(wf) + cts = class_types(wf) + # Wan usa UNETLoader + VAELoader aparte + ModelSamplingSD3 + KSampler nativo. + for ct in ( + "UNETLoader", + "CLIPLoader", + "VAELoader", + "ModelSamplingSD3", + "EmptyHunyuanLatentVideo", + "KSampler", + "CreateVideo", + "SaveVideo", + ): + assert ct in cts, f"falta {ct} en Wan" + assert node_by_ct(wf, "UNETLoader")["inputs"]["unet_name"] == ( + "wan2.1_t2v_1.3B_fp16.safetensors" + ) + assert node_by_ct(wf, "CLIPLoader")["inputs"]["type"] == "wan" + assert node_by_ct(wf, "VAELoader")["inputs"]["vae_name"] == "wan_2.1_vae.safetensors" + + +def test_params_se_reflejan_ltx(): + wf = comfyui_build_video_workflow( + "POS", model="ltx", negative="NEG", width=640, height=384, + num_frames=49, steps=18, seed=7, fps=30, + ) + lat = node_by_ct(wf, "EmptyLTXVLatentVideo")["inputs"] + assert lat["width"] == 640 and lat["height"] == 384 and lat["length"] == 49 + assert node_by_ct(wf, "LTXVScheduler")["inputs"]["steps"] == 18 + assert node_by_ct(wf, "SamplerCustom")["inputs"]["noise_seed"] == 7 + assert node_by_ct(wf, "CreateVideo")["inputs"]["fps"] == 30 + textos = sorted( + n["inputs"]["text"] for n in wf.values() if n["class_type"] == "CLIPTextEncode" + ) + assert textos == ["NEG", "POS"] + + +def test_params_se_reflejan_wan(): + wf = comfyui_build_video_workflow( + "POS", model="wan", num_frames=33, steps=15, seed=99, + ) + assert node_by_ct(wf, "EmptyHunyuanLatentVideo")["inputs"]["length"] == 33 + ks = node_by_ct(wf, "KSampler")["inputs"] + assert ks["steps"] == 15 and ks["seed"] == 99 + + +def test_model_invalido_lanza_valueerror(): + with pytest.raises(ValueError): + comfyui_build_video_workflow("x", model="sora")