feat(ml): auto-commit con 7 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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", {})))
|
||||
Reference in New Issue
Block a user