From 1311c7e585aaeb5ae93455e4179d060dfa131817 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Wed, 24 Jun 2026 01:16:37 +0200 Subject: [PATCH] feat(ml): auto-commit con 7 cambios Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/capabilities/comfyui.md | 8 +- .../ml/comfyui_build_view_3d_workflow.md | 82 +++++ .../ml/comfyui_build_view_3d_workflow.py | 86 +++++ .../functions/ml/comfyui_download_workflow.md | 93 +++++ .../functions/ml/comfyui_download_workflow.py | 326 ++++++++++++++++++ .../ml/comfyui_generate_views_from_image.md | 104 ++++++ .../ml/comfyui_generate_views_from_image.py | 269 +++++++++++++++ 7 files changed, 967 insertions(+), 1 deletion(-) create mode 100644 python/functions/ml/comfyui_build_view_3d_workflow.md create mode 100644 python/functions/ml/comfyui_build_view_3d_workflow.py create mode 100644 python/functions/ml/comfyui_download_workflow.md create mode 100644 python/functions/ml/comfyui_download_workflow.py create mode 100644 python/functions/ml/comfyui_generate_views_from_image.md create mode 100644 python/functions/ml/comfyui_generate_views_from_image.py diff --git a/docs/capabilities/comfyui.md b/docs/capabilities/comfyui.md index e274d9eb..4e1c7d3b 100644 --- a/docs/capabilities/comfyui.md +++ b/docs/capabilities/comfyui.md @@ -53,6 +53,7 @@ El **API format** (dict de nodos numerados que produce `build_txt2img_workflow` | [comfyui_validate_workflow_py_ml](../../python/functions/ml/comfyui_validate_workflow.md) | `validate_workflow(workflow, server='127.0.0.1:8188', timeout) -> dict` | Cruza class_type y nombres de modelo contra `/object_info`; devuelve `{valid, missing_nodes, missing_models}` ANTES de encolar. Compone `object_info`. Impura. | | [comfyui_import_workflow_json_py_ml](../../python/functions/ml/comfyui_import_workflow_json.md) | `import_workflow_json(source, *, server, timeout) -> dict` | Lee un workflow JSON de URL o path local; normaliza UI graph → API format (widgets vía `object_info`); passthrough si ya es API. Impura. | | [comfyui_import_workflow_png_py_ml](../../python/functions/ml/comfyui_import_workflow_png.md) | `import_workflow_png(png_path_or_url, *, timeout) -> dict` | Extrae el workflow embebido en los chunks `prompt` (API) / `workflow` (UI) de un PNG de ComfyUI (tEXt/zTXt/iTXt, stdlib). Path o URL. Impura. | +| [comfyui_download_workflow_py_ml](../../python/functions/ml/comfyui_download_workflow.md) | `download_workflow(source, dest=None, *, server, civitai_token, hf_token, timeout) -> dict` | **Dispatcher**: descarga un workflow de CUALQUIER fuente (Google Drive, GitHub, Civitai, HuggingFace, URL directa o path local) y lo normaliza a API format. Detecta el tipo por la URL y delega; tras bajar compone `import_workflow_json`/`import_workflow_png`. Catálogo de fuentes: `reports/0080`. Impura. | | [comfyui_read_png_metadata_py_ml](../../python/functions/ml/comfyui_read_png_metadata.md) | `read_png_metadata(png_path) -> dict` | Lee los parámetros de generación (modelo, seed, steps, cfg, sampler, prompts) de un PNG generado por ComfyUI. Impura (I/O disco). | | [comfyui_fetch_output_image_py_ml](../../python/functions/ml/comfyui_fetch_output_image.md) | `fetch_output_image(filename, *, subfolder='', type_='output', server, dest_dir='.', timeout) -> dict` | Descarga el PNG generado vía GET `/view` a disco local (`wait_result` solo da metadata). Impura. | @@ -75,11 +76,16 @@ reconstruye en una malla 3D GLB con un grafo de 9 nodos (`LoadImage → ImageOnl → CLIPVisionEncode → Hunyuan3Dv2Conditioning → EmptyLatentHunyuan3Dv2 → KSampler → VAEDecodeHunyuan3D → VoxelToMeshBasic → SaveGLB`). El checkpoint es self-contained (DiT de forma + VAE 3D + encoder de imagen en un `.safetensors`). Salida **shape-only** (sin color/textura). Detalle -y benchmark en `reports/0069-2026-06-23-comfyui-img-to-3d.md`. +y benchmark en `reports/0069-2026-06-23-comfyui-img-to-3d.md`. Para mejorar la cara trasera/laterales, +genera vistas novel-view desde 1 imagen (`generate_views_from_image`, reports `0073`); para VER el GLB +resultante interactivo dentro de un nodo de la UI, monta el visor `Load3D` (`build_view_3d_workflow`, +report `0079`). | ID | Firma corta | Qué hace | |---|---|---| | [comfyui_build_image_to_3d_workflow_py_ml](../../python/functions/ml/comfyui_build_image_to_3d_workflow.md) | `build_image_to_3d_workflow(image_name, ckpt_name='hunyuan3d-dit-v2-mini.safetensors', *, resolution, steps, cfg, seed, octree_resolution, num_chunks, threshold, ...) -> dict` | Builder del workflow imagen→3D de 9 nodos (Hunyuan3D-2 nativo) en API format. El SaveGLB produce un `.glb`. **Pura**. | +| [comfyui_generate_views_from_image_py_ml](../../python/functions/ml/comfyui_generate_views_from_image.md) | `generate_views_from_image(image_name, *, method='auto', server, azimuths=(90,180,270), elevation, dest_dir, validate_only=False, ...) -> dict` | Sintetiza vistas novel-view (back/left/right) desde 1 imagen con StableZero123/SV3D nativos, para alimentar el 3D multi-vista. **Honesta**: si el nodo+checkpoint no están, devuelve `ok=False` con la acción y NO encola. `validate_only=True` valida sin tocar GPU. Impura. | +| [comfyui_build_view_3d_workflow_py_ml](../../python/functions/ml/comfyui_build_view_3d_workflow.md) | `build_view_3d_workflow(model_file, *, animation=False, width, height) -> dict` | Monta el visor 3D nativo `Load3D` (o `Load3DAdvanced` con `animation=True`) para VER un GLB/OBJ existente, orbitando con el ratón, sin ejecutar el grafo. `model_file` relativo a `input/3d/`. Cárgalo con `load_workflow_ui`. **Pura**. | | [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. | diff --git a/python/functions/ml/comfyui_build_view_3d_workflow.md b/python/functions/ml/comfyui_build_view_3d_workflow.md new file mode 100644 index 00000000..cec51293 --- /dev/null +++ b/python/functions/ml/comfyui_build_view_3d_workflow.md @@ -0,0 +1,82 @@ +--- +name: comfyui_build_view_3d_workflow +kind: function +lang: py +domain: ml +version: "1.0.0" +purity: pure +signature: "def comfyui_build_view_3d_workflow(model_file: str, *, animation: bool = False, width: int = 1024, height: int = 1024) -> dict" +description: "Construye el dict API-format de un visor 3D minimo de ComfyUI con el nodo nativo Load3D (display 'Load 3D & Animation', comfy_extras.nodes_load_3d, categoria 3d) para VISUALIZAR un GLB/GLTF/OBJ/FBX/STL/PLY existente, orbitando con el raton, sin ejecutar el grafo (no es output node). animation=True usa Load3DAdvanced (input viewport_state, control avanzado de camara); animation=False usa Load3D (input image de estado del visor, el del report 0079). Pura, sin red ni I/O." +tags: [comfyui, ml, view-3d, load3d, mesh, workflow, viewer] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +params: + - name: model_file + desc: "Ruta del modelo RELATIVA al input/ del servidor ComfyUI (ej. '3d/fox_mv_textured.glb'). El archivo debe existir ya bajo ~/ComfyUI/input/3d/ para que el visor lo cargue (Load3D solo lista ese directorio)." + - name: animation + desc: "Si True usa Load3DAdvanced (viewport_state, control avanzado de camara/viewport para inspeccionar modelos animados); si False (default) usa Load3D, el visor estandar. Ambos reproducen animaciones embebidas del modelo en el frontend. keyword-only." + - name: width + desc: "Ancho del viewport del nodo en px. keyword-only." + - name: height + desc: "Alto del viewport del nodo en px. keyword-only." +output: "dict en API format con un unico nodo '1'. Con animation=False: class_type 'Load3D', inputs {model_file, image, width, height}. Con animation=True: class_type 'Load3DAdvanced', inputs {model_file, viewport_state, width, height}. Cargable con comfyui_load_workflow_ui (inyecta en la UI del navegador) o POSTeable a /prompt." +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/ml/comfyui_build_view_3d_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_view_3d_workflow import comfyui_build_view_3d_workflow + +wf = comfyui_build_view_3d_workflow("3d/fox_mv_textured.glb") +# wf == {"1": {"class_type": "Load3D", +# "inputs": {"model_file": "3d/fox_mv_textured.glb", "image": "", +# "width": 1024, "height": 1024}}} + +# Inyectar en la UI abierta (visor interactivo, orbita con el raton): +# from browser.comfyui_load_workflow_ui import comfyui_load_workflow_ui +# comfyui_load_workflow_ui(wf, server_url_substr="8188") + +# Variante avanzada (control de camara/viewport): +wf_adv = comfyui_build_view_3d_workflow("3d/walk_cycle.glb", animation=True) +# wf_adv["1"]["class_type"] == "Load3DAdvanced" +``` + +O lanzable directo con: `./fn run comfyui_build_view_3d_workflow` (imprime los dos workflows de ejemplo). + +## Cuando usarla + +Cuando ya tengas un mesh GLB/OBJ (p.ej. la salida de `comfyui_image_to_3d_oneshot`, +descargada con `comfyui_fetch_output_mesh`) y quieras VERLO con su textura/color dentro +de un nodo de ComfyUI, interactivo. Construye aquí el dict del visor y cárgalo en la UI +con `comfyui_load_workflow_ui`. Es shape+textura: el visor Three.js pinta el material PBR +del GLB (report 0079: el zorro se ve naranja, no gris). Para añadir el nodo SIN reemplazar +el grafo abierto del usuario, el método no-destructivo es inyectarlo vía CDP +(`LiteGraph.createNode('Load3D')` + `app.graph.add`), ver report 0079. + +## Gotchas + +- **`model_file` debe ser ruta RELATIVA a `input/`** (p.ej. `3d/fox.glb`), y el archivo + debe existir bajo `~/ComfyUI/input/3d/`. `Load3D` solo lista/carga ese directorio: si + el GLB vive en `output/3D/`, cópialo a `input/3d/` antes (eso es I/O, fuera de esta + función pura). Sin la copia el combo `model_file` solo ofrece `none`. +- **No es output node**: `Load3D`/`Load3DAdvanced` renderizan en el frontend (Three.js) + SIN ejecutar el grafo (no hace falta Queue). Si quieres mostrar un GLB que produce un + pipeline al ejecutar, usa `Preview3D` (output node, requiere queue) — no es esta función. +- **Requiere ComfyUI >= 0.26.0** (nodos nativos `Load3D`/`Load3DAdvanced`, módulo + `comfy_extras.nodes_load_3d`). En versiones anteriores el server rechaza el workflow. +- El flag `animation` elige la VARIANTE de nodo, no un modo "play": ambos visores ya + reproducen las animaciones embebidas del modelo en el frontend. `Load3DAdvanced` aporta + `viewport_state` (control fino de cámara), útil para inspeccionar la órbita de un modelo + animado; `Load3D` da además un preview `image` del visor. +- Pura: sólo arma el dict, no toca red ni disco ni valida contra el server. Valida con + `comfyui_validate_workflow` si dudas de que el nodo exista en tu versión. diff --git a/python/functions/ml/comfyui_build_view_3d_workflow.py b/python/functions/ml/comfyui_build_view_3d_workflow.py new file mode 100644 index 00000000..f16c9839 --- /dev/null +++ b/python/functions/ml/comfyui_build_view_3d_workflow.py @@ -0,0 +1,86 @@ +"""Construye el workflow minimo de un visor 3D nativo de ComfyUI (Load3D). + +ComfyUI 0.26.0 trae el nodo nativo `Load3D` (display "Load 3D & Animation", +`comfy_extras.nodes_load_3d`, categoria `3d`): un visor Three.js embebido que +renderiza un GLB/GLTF/OBJ/FBX/STL/PLY **en el frontend, sin ejecutar el grafo** +(no es output node). Sirve para VER un mesh ya existente con su textura/color, +orbitando con el raton, dentro de un nodo de la UI. + +Este builder devuelve el dict API-format (un unico nodo) cargable en la UI con +`comfyui_load_workflow_ui`. La variante se elige con `animation`: + + - animation=False -> `Load3D` (visor estandar; input `image` de estado del + visor; el usado en el report 0079). Reproduce animaciones embebidas del + modelo en el frontend. + - animation=True -> `Load3DAdvanced` (display "Load 3D (Advanced)"; input + `viewport_state` en vez de `image`): mismo visor con control avanzado de + camara/viewport, mejor para inspeccionar la orbita de un modelo animado. + +Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos. + +GOTCHA: `Load3D`/`Load3DAdvanced` solo listan/cargan archivos que esten bajo +`~/ComfyUI/input/3d/`. `model_file` debe ser la ruta RELATIVA a `input/` +(p.ej. "3d/fox.glb"). Copiar el GLB ahi es I/O, fuera de esta funcion pura. +""" + + +def comfyui_build_view_3d_workflow( + model_file: str, + *, + animation: bool = False, + width: int = 1024, + height: int = 1024, +) -> dict: + """Monta el API-format de un visor 3D minimo para un GLB/GLTF/OBJ existente. + + Args: + model_file: ruta del modelo RELATIVA al `input/` del servidor ComfyUI + (p.ej. "3d/fox_mv_textured.glb"). El archivo debe existir ya bajo + `~/ComfyUI/input/3d/` para que el visor lo cargue. + animation: si True usa `Load3DAdvanced` (control avanzado de + camara/viewport, apto para inspeccionar modelos animados); si False + (default) usa `Load3D`, el visor estandar del report 0079. Ambos + reproducen animaciones embebidas del modelo en el frontend. + keyword-only. + width: ancho del viewport del nodo en px. keyword-only. + height: alto del viewport del nodo en px. keyword-only. + + Returns: + dict en API format con un unico nodo "1". Con animation=False: + {"1": {"class_type": "Load3D", "inputs": {"model_file", "image", + "width", "height"}}}; con animation=True el class_type es + "Load3DAdvanced" y el segundo input es "viewport_state". Cargable con + comfyui_load_workflow_ui (inyecta en la UI) o POSTeable a /prompt. + """ + if animation: + return { + "1": { + "class_type": "Load3DAdvanced", + "inputs": { + "model_file": model_file, + "viewport_state": "", + "width": width, + "height": height, + }, + } + } + return { + "1": { + "class_type": "Load3D", + "inputs": { + "model_file": model_file, + "image": "", + "width": width, + "height": height, + }, + } + } + + +if __name__ == "__main__": + import json + + wf = comfyui_build_view_3d_workflow("3d/fox_mv_textured.glb") + print(json.dumps(wf, indent=2)) + wf_anim = comfyui_build_view_3d_workflow("3d/walk_cycle.glb", animation=True) + print(json.dumps(wf_anim, indent=2)) diff --git a/python/functions/ml/comfyui_download_workflow.md b/python/functions/ml/comfyui_download_workflow.md new file mode 100644 index 00000000..bf9a7fd7 --- /dev/null +++ b/python/functions/ml/comfyui_download_workflow.md @@ -0,0 +1,93 @@ +--- +name: comfyui_download_workflow +kind: function +lang: py +domain: ml +version: "1.0.0" +purity: impure +signature: "def comfyui_download_workflow(source: str, dest: str | None = None, *, server: str = \"127.0.0.1:8188\", civitai_token: str | None = None, hf_token: str | None = None, timeout: float = 30.0) -> dict" +description: "Descarga un workflow ComfyUI desde CUALQUIER fuente (Google Drive, GitHub, Civitai, HuggingFace, URL directa o path local) y lo normaliza a API format. Dispatcher que detecta el tipo de fuente por la URL y delega: Drive via gdown/uc?export=download, GitHub via raw.githubusercontent.com, Civitai via API REST (resuelve downloadUrl, descomprime zip), HuggingFace via resolve/. Tras bajar: PNG/WebP -> comfyui_import_workflow_png; JSON -> comfyui_import_workflow_json (normaliza UI->API). Compone import_workflow_json + import_workflow_png. Impura: red + descompresion + disco." +tags: [comfyui, ml, workflow, download, dispatcher, import] +uses_functions: [comfyui_import_workflow_json_py_ml, comfyui_import_workflow_png_py_ml] +uses_types: [] +returns: [] +returns_optional: false +error_type: error_go_core +imports: [] +params: + - name: source + desc: "URL (Google Drive con /d/ o ?id=, GitHub blob o raw, Civitai /api/download o /models/, HuggingFace resolve, o URL directa a .json/.png/.webp) o ruta de un archivo local." + - name: dest + desc: "Ruta local donde guardar el archivo descargado. Si None, archivo temporal (se conserva y se reporta en 'path'). Para fuentes locales no copia: path = source. keyword-only por posicion 2 (acepta posicional)." + - name: server + desc: "host:port de ComfyUI, usado SOLO para mapear widgets cuando la fuente viene en formato UI graph (lo pasa a import_workflow_json). keyword-only." + - name: civitai_token + desc: "Token de Civitai (Bearer) para descargas restringidas/gated. keyword-only." + - name: hf_token + desc: "Token de HuggingFace (Bearer) para datasets privados. keyword-only." + - name: timeout + desc: "Timeout HTTP en segundos. keyword-only." +output: "dict {ok, workflow, source_type, path, format_in, error}. workflow = dict API format (vacio si ok=False); source_type = drive|github|civitai|huggingface|direct|local; path = ruta local descargada; format_in = api|ui_graph|png-prompt|png-workflow|zip. Nunca lanza: fallos devuelven ok=False con error." +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/ml/comfyui_download_workflow.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions")) +from ml.comfyui_download_workflow import comfyui_download_workflow + +# GitHub (cubiq, Apache-2.0) — baja el raw .json y lo deja en API format +res = comfyui_download_workflow( + "https://raw.githubusercontent.com/cubiq/ComfyUI_Workflows/main/ComfyUI_Simple/SDXL_simple.json" +) +# res == {"ok": True, "workflow": {...}, "source_type": "github", +# "path": "/tmp/comfy_wf_xxx.json", "format_in": "ui_graph", "error": ""} + +# Google Drive por share-url (extrae el file id; usa gdown si esta, si no descarga directa) +res2 = comfyui_download_workflow("https://drive.google.com/file/d//view", dest="/tmp/wf.json") + +# El workflow resultante esta listo para validar/encolar: +# from ml.comfyui_validate_workflow import comfyui_validate_workflow +# comfyui_validate_workflow(res["workflow"]) +``` + +Lánzalo con el python del venv (import de arriba o heredoc). `./fn run` directo no aplica: la firma usa `*` (keyword-only), no soportado por el generador de runner de `fn run`. El bloque `__main__` baja el ejemplo de cubiq cuando lo ejecutas como script. + +## Cuando usarla + +Cuando tengas la URL de un workflow ajeno (Drive de un creador, repo GitHub, página +de Civitai, dataset de HuggingFace) y quieras un dict en API format sin pensar en el +método de descarga ni en el formato. Es el punto de entrada único antes de +`comfyui_validate_workflow` + `comfyui_resolve_workflow_deps` + `comfyui_submit_workflow`. +Para una fuente que ya sabes que es JSON local/URL directa, `comfyui_import_workflow_json` +basta; para un PNG suelto, `comfyui_import_workflow_png`. Este dispatcher es para +"dame el workflow de esta URL, sea cual sea la fuente". Catálogo de fuentes: report 0080. + +## Gotchas + +- Impura: hace HTTP GET (y gdown/unzip según fuente) + escribe a disco. Cualquier + fallo de red/IO devuelve `{ok: False, error: ...}` (no lanza). +- **Google Drive**: usa `gdown` si está instalado (maneja el aviso de virus-scan de + archivos grandes). Sin gdown cae a `uc?export=download`, que solo sirve para + archivos pequeños (un `.json` de workflow son KB); si Drive devuelve HTML (aviso + de virus-scan o gated) la función pide instalar gdown. `pip install gdown` en el venv. +- **Civitai**: descargas gated/early-access exigen `civitai_token` (Bearer). Sin token + la respuesta puede ser HTML de login → error claro. Una página `/models/` se + resuelve via `/api/v1/models/` tomando el primer file; para precisión pasa el + `downloadUrl` directo (`/api/download/models/`). +- **GitHub**: una URL `github.com/.../blob/...` se reescribe a `raw.githubusercontent.com` + automáticamente; si pasas la URL de la página HTML (no raw ni blob) puede bajar HTML + → error. Mejor pasar el raw o el blob. +- **Formato de salida siempre API**: un PNG con chunk `prompt` (API) se usa directo; si + solo trae el chunk `workflow` (UI graph) se normaliza vía import_workflow_json (necesita + el server vivo para mapear widgets). Un UI graph `.json` se normaliza igual (best-effort: + conexiones siempre; widgets sólo si el server responde). +- **El workflow descargado es un secreto si trae credenciales/cookies** (raro en workflows, + común en HAR): este caso es de workflows públicos; aun así no commitear el `path` temporal. +- Fuentes con anti-bot fuerte (ComfyWorkflows.com, comfy.org/workflows con Cloudflare) + pueden devolver 402/HTML a la descarga directa → requieren navegador (CDP). No cubiertas. diff --git a/python/functions/ml/comfyui_download_workflow.py b/python/functions/ml/comfyui_download_workflow.py new file mode 100644 index 00000000..988cb718 --- /dev/null +++ b/python/functions/ml/comfyui_download_workflow.py @@ -0,0 +1,326 @@ +"""Descarga un workflow ComfyUI desde CUALQUIER fuente y lo normaliza a API format. + +Dispatcher: detecta el tipo de fuente por la URL/patron y delega la descarga, luego +normaliza el resultado a API format reusando las dos funciones de import del registry +(no reescribe la conversion): + + - Google Drive (drive.google.com/.../d/ o uc?id=) -> gdown (si esta) o + descarga directa uc?export=download -> import_workflow_json | import_workflow_png + - GitHub (github.com/.../blob/... o raw.githubusercontent.com) -> raw URL del + .json/.png -> import_workflow_json | import_workflow_png + - Civitai (civitai.com/api/download/... o pagina /models/) -> resuelve el + downloadUrl via API REST, descarga el archivo (zip o json) -> import + - HuggingFace (huggingface.co/datasets/.../resolve/...) -> import_workflow_json + - URL directa .json/.png/.webp o path local -> import segun extension + +El resultado SIEMPRE es API format (dict {node_id: {class_type, inputs}}), listo para +comfyui_validate_workflow + comfyui_submit_workflow. + +Compone comfyui_import_workflow_json + comfyui_import_workflow_png. Impura: red +(HTTP GET / gdown), descompresion de zip y lectura/escritura de disco. Solo stdlib +(urllib, json, zipfile, tempfile, re) + gdown opcional para Drive. +""" +import json +import os +import re +import sys +import tempfile +import urllib.error +import urllib.parse +import urllib.request +import zipfile + +_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +if _THIS_DIR not in sys.path: + sys.path.insert(0, _THIS_DIR) + +from comfyui_import_workflow_json import comfyui_import_workflow_json # noqa: E402 +from comfyui_import_workflow_png import comfyui_import_workflow_png # noqa: E402 + +_UA = "Mozilla/5.0 (fn_registry comfyui_download_workflow)" + + +def comfyui_download_workflow( + source: str, + dest: str | None = None, + *, + server: str = "127.0.0.1:8188", + civitai_token: str | None = None, + hf_token: str | None = None, + timeout: float = 30.0, +) -> dict: + """Descarga un workflow de ComfyUI de cualquier fuente y lo normaliza a API format. + + Args: + source: URL (Google Drive, GitHub, Civitai, HuggingFace, o directa a + .json/.png/.webp) o ruta de un archivo local. + dest: ruta local donde guardar el archivo descargado. Si None, se usa un + archivo temporal (que se conserva para trazabilidad y se reporta en + 'path'). Para fuentes locales no se copia: 'path' = source. + server: host:port de ComfyUI, usado SOLO para mapear widgets cuando la + fuente viene en formato UI graph (lo pasa a import_workflow_json). + keyword-only. + civitai_token: token de Civitai (Bearer) para descargas restringidas/gated. + keyword-only. + hf_token: token de HuggingFace (Bearer) para datasets privados. keyword-only. + timeout: timeout HTTP en segundos. keyword-only. + + Returns: + dict {ok, workflow, source_type, path, format_in, error}: + - workflow: dict en API format (vacio si ok=False). + - source_type: 'drive' | 'github' | 'civitai' | 'huggingface' | + 'direct' | 'local'. + - path: ruta local del archivo descargado (o source si era local). + - format_in: formato de origen detectado ('api', 'ui_graph', + 'png-prompt', 'png-workflow', 'zip'). + Nunca lanza: cualquier fallo de red/IO devuelve ok=False con error. + """ + source_type = _detect_source_type(source) + try: + if source_type == "local": + local_path = source + if not os.path.exists(local_path): + return _err(source_type, f"no existe el archivo local {source!r}") + elif source_type == "drive": + local_path = _download_drive(source, dest, timeout) + elif source_type == "civitai": + local_path = _download_civitai(source, dest, civitai_token, timeout) + else: # github | huggingface | direct + url = _to_raw_url(source) if source_type == "github" else source + token = hf_token if source_type == "huggingface" else None + local_path = _download_url(url, dest, token, timeout) + except _DownloadError as exc: + return _err(source_type, str(exc)) + except (urllib.error.URLError, OSError) as exc: + return _err(source_type, f"fallo de descarga: {exc}") + + # Si bajamos un zip (tipico de Civitai), extraer el primer workflow de dentro. + if local_path.lower().endswith(".zip"): + try: + inner, fmt_hint = _extract_from_zip(local_path) + except _DownloadError as exc: + return _err(source_type, str(exc), path=local_path, fmt="zip") + norm = _normalize(inner, server, timeout) + norm["format_in"] = "zip" + norm["source_type"] = source_type + norm["path"] = local_path + return norm + + norm = _normalize(local_path, server, timeout) + norm["source_type"] = source_type + norm["path"] = local_path + return norm + + +# --------------------------------------------------------------------------- # +# Deteccion + resolucion de URLs +# --------------------------------------------------------------------------- # +def _detect_source_type(source: str) -> str: + if not source.startswith(("http://", "https://")): + return "local" + host = urllib.parse.urlparse(source).netloc.lower() + if "drive.google.com" in host or "docs.google.com" in host: + return "drive" + if "civitai.com" in host: + return "civitai" + if "github.com" in host or "githubusercontent.com" in host: + return "github" + if "huggingface.co" in host: + return "huggingface" + return "direct" + + +def _to_raw_url(github_url: str) -> str: + """Convierte una URL github.com/.../blob// a raw.githubusercontent.com.""" + if "raw.githubusercontent.com" in github_url or "/raw/" in github_url: + return github_url + m = re.match( + r"https://github\.com/([^/]+)/([^/]+)/blob/(.+)$", github_url + ) + if m: + user, repo, rest = m.groups() + return f"https://raw.githubusercontent.com/{user}/{repo}/{rest}" + return github_url # ya es raw o un patron no-blob: usar tal cual + + +def _drive_id(url: str) -> str | None: + m = re.search(r"/d/([A-Za-z0-9_-]+)", url) or re.search(r"[?&]id=([A-Za-z0-9_-]+)", url) + return m.group(1) if m else None + + +# --------------------------------------------------------------------------- # +# Descargas por fuente +# --------------------------------------------------------------------------- # +def _http_bytes(url: str, token: str | None, timeout: float) -> bytes: + req = urllib.request.Request(url, headers={"User-Agent": _UA}) + if token: + req.add_header("Authorization", f"Bearer {token}") + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read() + + +def _ext_from(url_or_name: str, content: bytes) -> str: + low = url_or_name.lower().split("?")[0] + for ext in (".json", ".png", ".webp", ".zip"): + if low.endswith(ext): + return ext + if content[:8] == b"\x89PNG\r\n\x1a\n": + return ".png" + if content[:4] == b"PK\x03\x04": + return ".zip" + if content[:4] == b"RIFF" and content[8:12] == b"WEBP": + return ".webp" + return ".json" + + +def _save(content: bytes, dest: str | None, ext: str) -> str: + if dest: + os.makedirs(os.path.dirname(os.path.abspath(dest)) or ".", exist_ok=True) + path = dest + else: + fd, path = tempfile.mkstemp(prefix="comfy_wf_", suffix=ext) + os.close(fd) + with open(path, "wb") as f: + f.write(content) + return path + + +def _download_url(url: str, dest: str | None, token: str | None, timeout: float) -> str: + content = _http_bytes(url, token, timeout) + if content[:15].lstrip().startswith(b" str: + file_id = _drive_id(source) + if not file_id: + raise _DownloadError(f"no se pudo extraer el file id de Drive de {source!r}") + # Camino 1: gdown (maneja el warning de virus-scan de archivos grandes). + try: + import gdown # type: ignore + + out = dest or tempfile.mkstemp(prefix="comfy_wf_", suffix=".bin")[1] + got = gdown.download(id=file_id, output=out, quiet=True) + if got and os.path.exists(out) and os.path.getsize(out) > 0: + return _retype_by_content(out) + raise _DownloadError("gdown no devolvio archivo") + except ImportError: + pass # sin gdown: fallback urllib + # Camino 2: descarga directa (sirve para archivos pequenos como un .json de workflow). + url = f"https://drive.google.com/uc?export=download&id={file_id}" + content = _http_bytes(url, None, timeout) + if content[:15].lstrip().startswith(b" str: + """Renombra un archivo .bin descargado a su extension real segun cabecera.""" + with open(path, "rb") as f: + head = f.read(16) + ext = _ext_from(path, head) + if path.lower().endswith(ext): + return path + new = os.path.splitext(path)[0] + ext + os.replace(path, new) + return new + + +def _download_civitai(source: str, dest: str | None, token: str | None, timeout: float) -> str: + download_url = source + # Pagina de modelo civitai.com/models/ -> resolver el primer file via API v1. + m = re.search(r"civitai\.com/models/(\d+)", source) + if m and "/api/download/" not in source: + api = f"https://civitai.com/api/v1/models/{m.group(1)}" + meta = json.loads(_http_bytes(api, token, timeout)) + versions = meta.get("modelVersions") or [] + files = (versions[0].get("files") if versions else None) or [] + if not files: + raise _DownloadError(f"el modelo Civitai {m.group(1)} no expone archivos descargables") + download_url = files[0].get("downloadUrl") or "" + if not download_url: + raise _DownloadError("Civitai no devolvio downloadUrl para el modelo") + content = _http_bytes(download_url, token, timeout) + if content[:15].lstrip().startswith(b" tuple[str, str]: + """Extrae el primer .json/.png de un zip a un tmp y devuelve (ruta, hint).""" + with zipfile.ZipFile(zip_path) as zf: + names = [n for n in zf.namelist() if n.lower().endswith((".json", ".png", ".webp"))] + if not names: + raise _DownloadError(f"el zip {zip_path!r} no contiene .json ni .png de workflow") + name = names[0] + data = zf.read(name) + ext = os.path.splitext(name)[1].lower() + fd, out = tempfile.mkstemp(prefix="comfy_wf_zip_", suffix=ext) + os.close(fd) + with open(out, "wb") as f: + f.write(data) + return out, ext + + +# --------------------------------------------------------------------------- # +# Normalizacion a API format (reusa las funciones de import del registry) +# --------------------------------------------------------------------------- # +def _normalize(path: str, server: str, timeout: float) -> dict: + low = path.lower() + if low.endswith((".png", ".webp")): + res = comfyui_import_workflow_png(path, timeout=timeout) + if not res.get("ok"): + return {"ok": False, "workflow": {}, "format_in": "", + "error": res.get("error", "PNG sin workflow embebido")} + # Preferir el chunk 'prompt' (API format). Si solo hay UI graph, normalizarlo. + if res.get("prompt"): + return {"ok": True, "workflow": res["prompt"], "format_in": "png-prompt", "error": ""} + ui = res.get("workflow") or {} + if ui: + tmp = _dump_tmp_json(ui) + j = comfyui_import_workflow_json(tmp, server=server, timeout=timeout) + return {"ok": j.get("ok", False), "workflow": j.get("workflow", {}), + "format_in": "png-workflow", "error": j.get("error", "")} + return {"ok": False, "workflow": {}, "format_in": "", + "error": "PNG sin chunk prompt ni workflow"} + # .json / sin extension -> import_workflow_json (passthrough API o normaliza UI) + res = comfyui_import_workflow_json(path, server=server, timeout=timeout) + fmt = res.get("format_detected", "") + return {"ok": res.get("ok", False), "workflow": res.get("workflow", {}), + "format_in": fmt, "error": res.get("error", "")} + + +def _dump_tmp_json(obj: dict) -> str: + fd, tmp = tempfile.mkstemp(prefix="comfy_wf_ui_", suffix=".json") + with os.fdopen(fd, "w") as f: + json.dump(obj, f) + return tmp + + +def _err(source_type: str, msg: str, *, path: str = "", fmt: str = "") -> dict: + return {"ok": False, "workflow": {}, "source_type": source_type, + "path": path, "format_in": fmt, "error": msg} + + +class _DownloadError(Exception): + """Error de descarga interno, traducido a {ok: False, error} en la salida.""" + + +if __name__ == "__main__": + # Smoke: baja un workflow real de cubiq (Apache-2.0) desde GitHub raw. + url = ( + "https://raw.githubusercontent.com/cubiq/ComfyUI_Workflows/" + "main/ComfyUI_Simple/SDXL_simple.json" + ) + out = comfyui_download_workflow(url) + print(json.dumps({k: v for k, v in out.items() if k != "workflow"}, indent=2)) + print("nodos:", len(out.get("workflow", {}))) diff --git a/python/functions/ml/comfyui_generate_views_from_image.md b/python/functions/ml/comfyui_generate_views_from_image.md new file mode 100644 index 00000000..d3f3fc72 --- /dev/null +++ b/python/functions/ml/comfyui_generate_views_from_image.md @@ -0,0 +1,104 @@ +--- +name: comfyui_generate_views_from_image +kind: function +lang: py +domain: ml +version: "1.0.0" +purity: impure +signature: "def comfyui_generate_views_from_image(image_name: str, *, method: str = \"auto\", server: str = \"127.0.0.1:8188\", azimuths: tuple = (90, 180, 270), elevation: float = 0.0, dest_dir: str | None = None, validate_only: bool = False, wait_timeout: float = 300.0, timeout: float = 30.0) -> dict" +description: "Genera vistas novel-view (back/left/right) desde 1 imagen para alimentar el 3D multi-vista de ComfyUI. Usa los sintetizadores NATIVOS StableZero123 (StableZero123_Conditioning_Batched, control de azimuth) o SV3D (orbita de 21 frames); en 8 GB cabe zero123 para sintesis de vistas. HONESTA: consulta /object_info, comprueba que el nodo Y su checkpoint estan instalados, y SOLO encola si hay camino viable; si no, devuelve {ok: False, reason} con la accion para habilitarlo SIN tocar la GPU. Compone object_info + submit + wait + fetch_output_image. Impura: HTTP + disco." +tags: [comfyui, ml, img-to-3d, novel-view, multiview, stablezero123, sv3d] +uses_functions: [comfyui_object_info_py_ml, comfyui_validate_workflow_py_ml, comfyui_submit_workflow_py_ml, comfyui_wait_result_py_ml, comfyui_fetch_output_image_py_ml] +uses_types: [] +returns: [] +returns_optional: false +error_type: error_go_core +imports: [] +params: + - name: image_name + desc: "Nombre del archivo de imagen (vista frontal) en el input/ del servidor ComfyUI. Debe existir ya (subelo con POST /upload/image)." + - name: method + desc: "'zero123' (StableZero123, control directo de azimuth), 'sv3d' (orbita SVD, mejor consistencia) o 'auto' (elige el primero cuyo nodo+checkpoint esten instalados, prefiriendo zero123). keyword-only." + - name: server + desc: "host:port de ComfyUI. keyword-only." + - name: azimuths + desc: "Angulos (grados) de las vistas a generar; 90=right, 180=back, 270=left (0=front la aporta el caller). Se asumen equiespaciados para el batch. keyword-only." + - name: elevation + desc: "Elevacion de camara en grados para todas las vistas. keyword-only." + - name: dest_dir + desc: "Carpeta local donde descargar las vistas generadas. Si None, no se descargan (solo se devuelven los nombres del output del servidor). keyword-only." + - name: validate_only + desc: "Si True, construye el workflow y lo valida contra /object_info SIN encolar ni tocar la GPU; devuelve el veredicto estructural (valid, missing_nodes, missing_models). Util para comprobar viabilidad antes de comprometer GPU. keyword-only." + - name: wait_timeout + desc: "Timeout de espera de la generacion en segundos. keyword-only." + - name: timeout + desc: "Timeout HTTP por request en segundos. keyword-only." +output: "dict. Viable: {ok: True, method, views: {back, left, right -> ruta/nombre}, prompt_id, available, reason: '', error: ''}. validate_only=True (no encola): {ok: , method, validated: True, valid, missing_nodes, missing_models, available, ...}. Sin nodo+modelo viable (stub honesto, NO encola): {ok: False, method, views: {}, reason: '', available: {nodes, ckpts, ckpt_combo}, error: ''}. Fallos de red/encolado: ok=False con error." +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/ml/comfyui_generate_views_from_image.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions")) +from ml.comfyui_generate_views_from_image import comfyui_generate_views_from_image + +# Comprobar viabilidad SIN encolar (no toca GPU) — recomendado antes de generar +chk = comfyui_generate_views_from_image("front.png", validate_only=True) +# chk == {"ok": True, "method": "zero123", "validated": True, "valid": True, +# "missing_nodes": [], "missing_models": [], ...} (si el ckpt esta instalado) + +# 'front.png' debe estar ya en el input/ del servidor (POST /upload/image) +res = comfyui_generate_views_from_image("front.png", method="auto", dest_dir="/tmp/views") + +if res["ok"]: + # res["views"] == {"right": "/tmp/views/novel_view_00001_.png", + # "back": "/tmp/views/novel_view_00002_.png", + # "left": "/tmp/views/novel_view_00003_.png"} + # -> alimentan comfyui_build_image_to_3d_multiview_workflow junto a la frontal + ... +else: + # Stub honesto: el checkpoint del sintetizador no esta instalado. + print(res["reason"]) # que falta + comando para instalarlo + print(res["available"]) # {nodes: {...}, ckpts: {...}} +``` + +Lánzalo con el python del venv (import de arriba o heredoc). `./fn run` directo no aplica: la firma usa `*` (keyword-only). El bloque `__main__` ejecuta el caso sin modelos instalados y muestra el `ok=False` honesto. + +## Cuando usarla + +Cuando tengas UNA sola imagen de un objeto y quieras reconstruir un 3D multi-vista +mejor (cara trasera y laterales definidos, no alucinados). Genera con esta función las +vistas back/left/right que faltan y pásalas, junto a la frontal, a +`comfyui_build_image_to_3d_multiview_workflow` (Hunyuan3D-2mv). Si tienes fotos reales +del objeto desde varios ángulos, NO la necesitas: úsalas directamente (mejor resultado; +report 0073). Esta función es el camino sintético cuando solo hay 1 vista. + +## Gotchas + +- **No finge resultados**: si el nodo o su checkpoint no están instalados, devuelve + `{ok: False, reason: ...}` con el comando para habilitarlo y **NO encola nada** (no + compite por la GPU). Estado en este equipo (24/06/2026, verificado contra `/object_info`): + `stable_zero123.ckpt` **SÍ está instalado** → `method='zero123'` es viable y genera de + verdad (el report 0073 lo daba por ausente; quedó desfasado). `sv3d_u.safetensors` NO + está instalado y, además, su builder aún no existe (ver gotcha siguiente). +- **`image_name` debe existir en `input/` ANTES de generar**: la función no sube la imagen + (no la inventa). Si pasas un `image_name` que no está en el `input/` del servidor, el + POST /prompt devuelve HTTP 400 (`prompt_outputs_failed_validation` de LoadImage) y la + función lo propaga como `ok=False` con el body — comportamiento correcto, no un bug. + Usa `validate_only=True` para comprobar el grafo sin necesidad de la imagen ni de GPU. +- **`method='sv3d'` aún no tiene builder**: el camino SV3D (órbita de 21 frames) lanza + `NotImplementedError` capturado → `ok=False` con error claro. Implementado: `zero123` + (StableZero123_Conditioning_Batched). Se añadirá SV3D cuando el modelo esté disponible + para probarlo (no especular: KISS). +- **Encola trabajo de GPU** sólo en el camino viable: `comfyui_submit_workflow` dispara + generación real. Respeta el aislamiento del server (coordina si otro agente lo usa). +- **Vistas sintéticas ≠ fotos reales**: no son perfectamente ortogonales ni 100% + consistentes; introducen ruido que el modelo mv puede amplificar. Para máxima fidelidad, + fotos reales > síntesis. MV-Adapter (mejor sintetizador) es custom node, fuera de alcance. +- `azimuths` se asume equiespaciado (el batch usa un incremento fijo). Mapeo de ángulo a + nombre: 0=front, 90=right, 180=back, 270=left. diff --git a/python/functions/ml/comfyui_generate_views_from_image.py b/python/functions/ml/comfyui_generate_views_from_image.py new file mode 100644 index 00000000..25864aa3 --- /dev/null +++ b/python/functions/ml/comfyui_generate_views_from_image.py @@ -0,0 +1,269 @@ +"""Genera vistas novel-view (back/left/right) desde 1 imagen para alimentar el 3D multi-vista. + +El camino imagen->3D de una sola vista deja indeterminada la cara trasera del objeto. +El nodo `Hunyuan3Dv2ConditioningMultiView` reconstruye mucho mejor con varias vistas +ortogonales, pero hace falta producirlas. ComfyUI 0.26.0 trae DOS sintetizadores de +vistas NATIVOS (sin custom node), confirmados en /object_info: + + - StableZero123 (`StableZero123_Conditioning_Batched`): control directo de azimuth + por vista; un batch saca varias vistas en una pasada. Requiere el checkpoint + `stable_zero123.ckpt` (~8.58 GB; cabe en 8 GB solo para SINTESIS de vistas). + - SV3D (`SV3D_Conditioning`): orbita de 21 frames en una pasada, mejor consistencia; + requiere `sv3d_u.safetensors`/`sv3d_p.safetensors` (~2.3 GB; modelo de video, mas + exigente en VRAM). + +Esta funcion es HONESTA sobre la viabilidad: consulta el servidor, comprueba que el +nodo Y su checkpoint estan disponibles, y SOLO encola si hay un camino viable. Si no +hay ningun (nodo + modelo) instalado, devuelve {ok: False, reason: ...} con la accion +concreta para habilitarlo, SIN tocar la GPU (no encola nada). Asi no finge un resultado +ni compite por la GPU cuando el modelo no esta. + +Descartados por aislamiento: MV-Adapter y Zero123++ son custom nodes (no nativos); la +regla prohibe instalarlos aqui. + +Compone comfyui_object_info + comfyui_submit_workflow + comfyui_wait_result + +comfyui_fetch_output_image. Impura: HTTP GET/POST + escritura en disco. Solo stdlib. +""" +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_object_info import comfyui_object_info # noqa: E402 +from comfyui_validate_workflow import comfyui_validate_workflow # noqa: E402 +from comfyui_submit_workflow import comfyui_submit_workflow # noqa: E402 +from comfyui_wait_result import comfyui_wait_result # noqa: E402 +from comfyui_fetch_output_image import comfyui_fetch_output_image # noqa: E402 + +# Checkpoint requerido por cada metodo (lo carga ImageOnlyCheckpointLoader). +_METHOD_CKPT = { + "zero123": "stable_zero123.ckpt", + "sv3d": "sv3d_u.safetensors", +} +# azimuth (grados) -> nombre de vista. 0=front (la que aporta el caller). +_AZIMUTH_NAME = {0: "front", 90: "right", 180: "back", 270: "left"} + + +def comfyui_generate_views_from_image( + image_name: str, + *, + method: str = "auto", + server: str = "127.0.0.1:8188", + azimuths: tuple = (90, 180, 270), + elevation: float = 0.0, + dest_dir: str | None = None, + validate_only: bool = False, + wait_timeout: float = 300.0, + timeout: float = 30.0, +) -> dict: + """Sintetiza vistas novel-view desde una imagen ya subida al input/ de ComfyUI. + + Args: + image_name: nombre del archivo de imagen en el `input/` del servidor + (la vista frontal). Debe existir ya (subelo con POST /upload/image). + method: 'zero123' (StableZero123, control de azimuth), 'sv3d' (orbita + SVD) o 'auto' (elige el primero cuyo nodo+checkpoint esten + instalados, prefiriendo zero123). keyword-only. + server: host:port de ComfyUI. keyword-only. + azimuths: angulos (grados) de las vistas a generar; 90=right, 180=back, + 270=left (0=front la aporta el caller). Se asumen equiespaciados para + el batch. keyword-only. + elevation: elevacion de camara en grados para todas las vistas. + keyword-only. + dest_dir: carpeta local donde descargar las vistas generadas. Si None, no + se descargan (solo se devuelven los nombres del output del servidor). + keyword-only. + validate_only: si True, construye el workflow y lo VALIDA contra + /object_info (comfyui_validate_workflow) SIN encolar ni tocar la GPU, + devolviendo el veredicto estructural. Util para comprobar viabilidad + antes de comprometer GPU (y para smoke sin generar). keyword-only. + wait_timeout: timeout de espera de la generacion en segundos. keyword-only. + timeout: timeout HTTP por request en segundos. keyword-only. + + Returns: + dict. Si hay camino viable y se genera: + {ok: True, method, views: {"back": , "left": ..., "right": ...}, + prompt_id, available: {...}, reason: "", error: ""}. + Con validate_only=True (no encola): + {ok: , method, validated: True, valid, missing_nodes, + missing_models, views: {}, available: {...}, reason: "", error: ""}. + Si NINGUN nodo+modelo viable (stub honesto, no encola): + {ok: False, method, views: {}, reason: "", + available: {nodes: {...}, ckpts: {...}}, error: ""}. + Cualquier fallo de red/encolado tambien devuelve ok=False con error. + """ + # 1. Inventario del servidor (impuro, solo lectura: NO encola, NO toca GPU). + try: + oi = comfyui_object_info(server=server, timeout=timeout) + except Exception as exc: # noqa: BLE001 + return _stub(method, f"no se pudo consultar /object_info de {server}: {exc}", error=str(exc)) + + nodes_present = { + "zero123": "StableZero123_Conditioning_Batched" in oi, + "sv3d": "SV3D_Conditioning" in oi, + } + ckpts = _checkpoint_combo(oi) + ckpts_present = {m: (_METHOD_CKPT[m] in ckpts) for m in _METHOD_CKPT} + available = {"nodes": nodes_present, "ckpts": ckpts_present, "ckpt_combo": ckpts} + + # 2. Elegir metodo viable (nodo + checkpoint presentes). + order = [method] if method in _METHOD_CKPT else ["zero123", "sv3d"] + chosen = next( + (m for m in order if nodes_present.get(m) and ckpts_present.get(m)), + None, + ) + if chosen is None: + return _stub( + method, + _why_unavailable(order, nodes_present, ckpts_present), + available=available, + ) + + # 3. Construir el workflow. + try: + wf = _build_views_workflow(image_name, chosen, ckpts[_method_ckpt_key(chosen, ckpts)], + azimuths, elevation) + except NotImplementedError as exc: + return _stub(chosen, str(exc), available=available, error=str(exc)) + + # 3a. Modo validate_only: valida contra /object_info SIN encolar (no toca GPU). + if validate_only: + val = comfyui_validate_workflow(wf, server=server, timeout=timeout) + return {"ok": bool(val.get("valid")), "method": chosen, "validated": True, + "valid": val.get("valid"), "missing_nodes": val.get("missing_nodes", []), + "missing_models": val.get("missing_models", []), "views": {}, + "available": available, "reason": "", "error": val.get("error", "")} + + # 3b. Encolar y generar (solo si hay camino viable y NO es validate_only). + try: + sub = comfyui_submit_workflow(wf, server=server, timeout=timeout) + prompt_id = sub.get("prompt_id") + if not prompt_id: + return _stub(chosen, f"el servidor no devolvio prompt_id: {sub}", available=available) + comfyui_wait_result(prompt_id, server=server, timeout=wait_timeout) + views = _collect_views(prompt_id, server, azimuths, dest_dir, timeout) + return {"ok": True, "method": chosen, "views": views, "prompt_id": prompt_id, + "available": available, "reason": "", "error": ""} + except Exception as exc: # noqa: BLE001 + return _stub(chosen, f"fallo al generar vistas: {exc}", available=available, error=str(exc)) + + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # +def _checkpoint_combo(oi: dict) -> list: + """Lista de checkpoints que el servidor ofrece a ImageOnlyCheckpointLoader.""" + for node in ("ImageOnlyCheckpointLoader", "CheckpointLoaderSimple"): + spec = (oi.get(node) or {}).get("input", {}).get("required", {}) + decl = spec.get("ckpt_name") + if isinstance(decl, list) and decl and isinstance(decl[0], list): + return list(decl[0]) + return [] + + +def _method_ckpt_key(method: str, ckpts: list) -> int: + return ckpts.index(_METHOD_CKPT[method]) + + +def _why_unavailable(order, nodes_present, ckpts_present) -> str: + parts = [] + for m in order: + if m not in _METHOD_CKPT: + continue + if not nodes_present.get(m): + parts.append(f"{m}: nodo nativo ausente en el servidor") + elif not ckpts_present.get(m): + ck = _METHOD_CKPT[m] + parts.append( + f"{m}: nodo OK pero falta el checkpoint '{ck}'. " + f"Instalalo con comfyui_download_model(, dest_subdir='checkpoints')" + ) + return ("Ningun sintetizador de vistas nativo viable en 8 GB esta listo. " + + " | ".join(parts) + ". Alternativa: aporta fotos reales del objeto " + "(front/left/back/right) y usa comfyui_build_image_to_3d_multiview_workflow directamente.") + + +def _build_views_workflow(image_name, method, ckpt_name, azimuths, elevation) -> dict: + """Workflow batched de sintesis de vistas. Hoy implementado para StableZero123.""" + if method == "sv3d": + raise NotImplementedError( + "el builder SV3D (orbita de 21 frames) no esta implementado todavia; usa method='zero123'" + ) + azs = sorted(azimuths) + start = azs[0] + increment = (azs[1] - azs[0]) if len(azs) > 1 else 90 + batch = len(azs) + return { + "1": {"class_type": "LoadImage", "inputs": {"image": image_name}}, + "2": {"class_type": "ImageOnlyCheckpointLoader", "inputs": {"ckpt_name": ckpt_name}}, + "3": { + "class_type": "StableZero123_Conditioning_Batched", + "inputs": { + "clip_vision": ["2", 1], + "init_image": ["1", 0], + "vae": ["2", 2], + "width": 256, + "height": 256, + "batch_size": batch, + "elevation": elevation, + "azimuth": start, + "elevation_batch_increment": 0.0, + "azimuth_batch_increment": increment, + }, + }, + "4": { + "class_type": "KSampler", + "inputs": { + "seed": 0, "steps": 20, "cfg": 4.0, "sampler_name": "euler", + "scheduler": "normal", "denoise": 1.0, + "model": ["2", 0], "positive": ["3", 0], "negative": ["3", 1], + "latent_image": ["3", 2], + }, + }, + "5": {"class_type": "VAEDecode", "inputs": {"samples": ["4", 0], "vae": ["2", 2]}}, + "6": {"class_type": "SaveImage", "inputs": {"images": ["5", 0], "filename_prefix": "novel_view"}}, + } + + +def _collect_views(prompt_id, server, azimuths, dest_dir, timeout) -> dict: + """Mapea las imagenes del SaveImage (en orden de azimuth) a nombres de vista.""" + import json + import urllib.request + + url = f"http://{server}/history/{prompt_id}" + with urllib.request.urlopen(url, timeout=timeout) as resp: + hist = json.load(resp) + outputs = (hist.get(prompt_id) or {}).get("outputs", {}) + images = [] + for node_out in outputs.values(): + images.extend(node_out.get("images", [])) + azs = sorted(azimuths) + views = {} + for img, az in zip(images, azs): + name = _AZIMUTH_NAME.get(az, f"az{az}") + if dest_dir: + got = comfyui_fetch_output_image( + img["filename"], subfolder=img.get("subfolder", ""), + type_=img.get("type", "output"), server=server, dest_dir=dest_dir, timeout=60.0, + ) + views[name] = got.get("path", img["filename"]) + else: + views[name] = img["filename"] + return views + + +def _stub(method, reason, *, available=None, error="") -> dict: + return {"ok": False, "method": method, "views": {}, "reason": reason, + "available": available or {}, "error": error} + + +if __name__ == "__main__": + import json + + # Smoke ligero: valida el camino sin encolar (no toca GPU). Si el checkpoint + # del sintetizador no esta instalado, devuelve el stub honesto ok=False. + res = comfyui_generate_views_from_image("front.png", validate_only=True) + print(json.dumps({k: v for k, v in res.items() if k != "available"}, indent=2)) + print("available:", json.dumps(res.get("available", {}).get("ckpts", {})))