feat(ml): auto-commit con 7 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 01:16:37 +02:00
parent db4f454f8a
commit 1311c7e585
7 changed files with 967 additions and 1 deletions
+7 -1
View File
@@ -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. |
@@ -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.
@@ -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))
@@ -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/<id> o ?id=, GitHub blob o raw, Civitai /api/download o /models/<id>, 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/<FILE_ID>/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/<id>` se
resuelve via `/api/v1/models/<id>` tomando el primer file; para precisión pasa el
`downloadUrl` directo (`/api/download/models/<version_id>`).
- **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.
@@ -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/<id> 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/<id>) -> 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/<branch>/<path> 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"<!DOCTYPE") or content[:6].lstrip().startswith(b"<html"):
raise _DownloadError(
f"la respuesta de {url!r} es HTML, no un workflow (gated/login o URL de pagina, no raw)"
)
return _save(content, dest, _ext_from(url, content))
def _download_drive(source: str, dest: str | None, timeout: float) -> 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"<!DOCTYPE") or content[:6].lstrip().startswith(b"<html"):
raise _DownloadError(
"Drive devolvio HTML (archivo grande con aviso de virus-scan o gated). "
"Instala gdown (pip install gdown) para este archivo."
)
return _save(content, dest, _ext_from(source, content))
def _retype_by_content(path: str) -> 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/<id> -> 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"<!DOCTYPE") or content[:6].lstrip().startswith(b"<html"):
raise _DownloadError(
"Civitai devolvio HTML (requiere login/token o el workflow es early-access). "
"Pasa civitai_token."
)
return _save(content, dest, _ext_from(download_url, content))
def _extract_from_zip(zip_path: str) -> 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", {})))
@@ -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: <valido>, method, validated: True, valid, missing_nodes, missing_models, available, ...}. Sin nodo+modelo viable (stub honesto, NO encola): {ok: False, method, views: {}, reason: '<que falta y como instalarlo>', 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.
@@ -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": <ruta/nombre>, "left": ..., "right": ...},
prompt_id, available: {...}, reason: "", error: ""}.
Con validate_only=True (no encola):
{ok: <valido>, 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: "<que falta y como instalarlo>",
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(<url_{m}>, 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", {})))