feat(ml): implementa camino sv3d en comfyui_generate_views_from_image

Completa la rama method='sv3d' (antes NotImplementedError) componiendo el
workflow SV3D nativo de ComfyUI (SV3D_Conditioning + VideoLinearCFGGuidance +
KSampler + VAEDecode + SaveImage): una imagen produce un orbit de N frames
equiespaciados en 360 grados en una pasada.

- _METHOD_CKPT['sv3d'] acepta sv3d_p (preferido) o sv3d_u; nuevo helper
  _resolve_ckpt sustituye a _method_ckpt_key.
- nuevos params keyword-only video_frames=21, sv3d_width=576, sv3d_height=576
  (configurables para densidad de orbit y control de VRAM).
- salida sv3d extendida con frames (orbit completo) + frame_count; views mapea
  cada azimuth al frame del orbit mas cercano (cardinales para multi-vista).
- _collect_views_sv3d + helpers compartidos _history_images/_fetch_or_name;
  _collect_views (zero123) refactorizado para reusarlos.

Probado en GPU (8 GB lowvram): sv3d_p.safetensors descargado a checkpoints/,
21 frames 576x576 en ~75 s, peak ~5.7 GB, sin OOM
(prompt_id 0caeedf4-baa0-4c8f-844a-867490ac4f85). Detalle en report 0128.

Bumpa version 1.0.0 -> 1.1.0 + Capability growth log. Pagina madre comfyui.md
marca ambos caminos (zero123/sv3d) operativos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 19:57:10 +02:00
parent 394221f8c7
commit 4302212b34
3 changed files with 181 additions and 39 deletions
+3 -2
View File
@@ -150,14 +150,15 @@ reconstruye en una malla 3D GLB con un grafo de 9 nodos (`LoadImage → ImageOnl
VAEDecodeHunyuan3D → VoxelToMeshBasic → SaveGLB`). El checkpoint es self-contained (DiT de forma + 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 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`. Para mejorar la cara trasera/laterales, 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 genera vistas novel-view desde 1 imagen (`generate_views_from_image`: `zero123` azimuth o
`sv3d` orbit de 21 frames, ambos operativos en 8 GB — reports `0073`, `0128`); para VER el GLB
resultante interactivo dentro de un nodo de la UI, monta el visor `Load3D` (`build_view_3d_workflow`, resultante interactivo dentro de un nodo de la UI, monta el visor `Load3D` (`build_view_3d_workflow`,
report `0079`). report `0079`).
| ID | Firma corta | Qué hace | | 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, ..., watertight=False) -> dict` | Builder del workflow imagen→3D de 9 nodos (Hunyuan3D-2 nativo) en API format. El SaveGLB produce un `.glb`. `watertight=True` usa `VoxelToMesh` (`algorithm='surface net'`) en vez de `VoxelToMeshBasic` → malla estanca de raíz (default conserva el comportamiento histórico). **Pura**. | | [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, ..., watertight=False) -> dict` | Builder del workflow imagen→3D de 9 nodos (Hunyuan3D-2 nativo) en API format. El SaveGLB produce un `.glb`. `watertight=True` usa `VoxelToMesh` (`algorithm='surface net'`) en vez de `VoxelToMeshBasic` → malla estanca de raíz (default conserva el comportamiento histórico). **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_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, video_frames=21, sv3d_width=576, sv3d_height=576, dest_dir, validate_only=False, ...) -> dict` | Sintetiza vistas novel-view desde 1 imagen con StableZero123/SV3D nativos, para alimentar el 3D multi-vista. **Ambos caminos operativos**: `method='zero123'` (azimuth → back/left/right) y `method='sv3d'` (`sv3d_p.safetensors`, orbit de N frames 360° → `frames` + cardinales mapeados; probado en 8 GB lowvram, 21f@576 ~75 s, peak ~5.7 GB, report 0128). **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_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_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_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. |
@@ -3,10 +3,10 @@ name: comfyui_generate_views_from_image
kind: function kind: function
lang: py lang: py
domain: ml domain: ml
version: "1.0.0" version: "1.1.0"
purity: impure 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" 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, video_frames: int = 21, sv3d_width: int = 576, sv3d_height: int = 576, 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." description: "Genera vistas novel-view desde 1 imagen para alimentar el 3D multi-vista de ComfyUI. Usa los sintetizadores NATIVOS StableZero123 (StableZero123_Conditioning_Batched, control de azimuth; devuelve back/left/right) o SV3D (SV3D_Conditioning, orbita de N frames equiespaciados en 360 grados; devuelve el orbit completo + cardinales mapeados). Ambos caminos operativos y probados en 8 GB lowvram. 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 + validate + submit + wait + fetch_output_image. Impura: HTTP + disco."
tags: [comfyui, ml, img-to-3d, novel-view, multiview, stablezero123, sv3d] 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_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: [] uses_types: []
@@ -25,6 +25,12 @@ params:
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." 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 - name: elevation
desc: "Elevacion de camara en grados para todas las vistas. keyword-only." desc: "Elevacion de camara en grados para todas las vistas. keyword-only."
- name: video_frames
desc: "SOLO sv3d. Numero de frames del orbit (vistas equiespaciadas en 360 grados que el modelo sintetiza en una pasada). Default 21 (el nativo de SV3D). Bajalo (7-12) para reducir VRAM en 8 GB lowvram a costa de menos densidad angular. keyword-only."
- name: sv3d_width
desc: "SOLO sv3d. Ancho en px de cada frame del orbit. Default 576 (nativo). Bajalo para reducir VRAM. keyword-only."
- name: sv3d_height
desc: "SOLO sv3d. Alto en px de cada frame del orbit. Default 576 (nativo). keyword-only."
- name: dest_dir - 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." 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 - name: validate_only
@@ -33,7 +39,7 @@ params:
desc: "Timeout de espera de la generacion en segundos. keyword-only." desc: "Timeout de espera de la generacion en segundos. keyword-only."
- name: timeout - name: timeout
desc: "Timeout HTTP por request en segundos. keyword-only." 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." output: "dict. Viable zero123: {ok: True, method, views: {back, left, right -> ruta/nombre}, prompt_id, available, reason: '', error: ''}. Viable sv3d: ademas trae el orbit completo: {..., views: {cardinales mapeados al frame mas cercano}, frames: [ruta/nombre de cada uno de los N frames del orbit], frame_count: N}. 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 tested: false
tests: [] tests: []
test_file_path: "" test_file_path: ""
@@ -67,6 +73,24 @@ else:
print(res["available"]) # {nodes: {...}, ckpts: {...}} print(res["available"]) # {nodes: {...}, ckpts: {...}}
``` ```
### Camino SV3D (orbit de N frames)
```python
# SV3D: 1 imagen -> orbit de 21 vistas equiespaciadas en 360 grados, en una pasada.
# La imagen debe estar ya en input/ del servidor. Cabe en 8 GB lowvram (~75 s, peak ~5.7 GB).
res = comfyui_generate_views_from_image(
"sv3d_test_robot.png", method="sv3d",
video_frames=21, sv3d_width=576, sv3d_height=576, # nativo; baja para menos VRAM
dest_dir="/tmp/sv3d_views", wait_timeout=900.0)
if res["ok"]:
res["frame_count"] # 21 — orbit completo
res["frames"] # [".../sv3d_view_00001_.png", ... x21] (orbit entero)
res["views"] # {"right": frame06, "back": frame11, "left": frame17}
# cardinales mapeados al frame mas cercano del orbit,
# para alimentar comfyui_build_image_to_3d_multiview_workflow
```
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. 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 usarla
@@ -83,18 +107,25 @@ report 0073). Esta función es el camino sintético cuando solo hay 1 vista.
- **No finge resultados**: si el nodo o su checkpoint no están instalados, devuelve - **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 `{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`): 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 `stable_zero123.ckpt` **SÍ** (zero123 viable) y `sv3d_p.safetensors` **SÍ** (sv3d viable
verdad (el report 0073 lo daba por ausente; quedó desfasado). `sv3d_u.safetensors` NO y probado en GPU, ver Capability growth log). Para sv3d acepta `sv3d_p.safetensors`
está instalado y, además, su builder aún no existe (ver gotcha siguiente). (preferido, orbita con elevación variable) o `sv3d_u.safetensors` (orbita uniforme).
- **`image_name` debe existir en `input/` ANTES de generar**: la función no sube la imagen - **`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 (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 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. 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. 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 - **SV3D y la VRAM (8 GB lowvram)**: el orbit nativo de 21 frames @576×576 **cabe** en
`NotImplementedError` capturado → `ok=False` con error claro. Implementado: `zero123` 8 GB lowvram (medido: ~75 s, peak ~5.7 GB de 8 GB con el escritorio usando ~2.9 GB). El
(StableZero123_Conditioning_Batched). Se añadirá SV3D cuando el modelo esté disponible checkpoint `sv3d_p.safetensors` pesa ~9.4 GB en disco (incluye el backbone SVD completo +
para probarlo (no especular: KISS). CLIP-vision + VAE), NO ~2 GB; ComfyUI lo carga por capas en lowvram. Si diera OOM en un
equipo más justo, baja `video_frames` (p. ej. 12) y/o `sv3d_width`/`sv3d_height` (p. ej.
320); reducir frames degrada la densidad del orbit (el modelo se entrenó para 21).
- **SV3D orbit vs zero123 azimuths**: SV3D produce N frames equiespaciados en 360° empezando
por la vista frontal (frame 0 = az 0); el azimuth por-frame **no es controlable**
individualmente como en zero123. Para encajar con el consumidor multi-vista, los `azimuths`
pedidos se mapean al frame del orbit más cercano (con 21 frames: right≈frame 6, back≈frame
11, left≈frame 17) en `views`, y el orbit completo va en `frames`.
- **Encola trabajo de GPU** sólo en el camino viable: `comfyui_submit_workflow` dispara - **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). 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% - **Vistas sintéticas ≠ fotos reales**: no son perfectamente ortogonales ni 100%
@@ -102,3 +133,12 @@ report 0073). Esta función es el camino sintético cuando solo hay 1 vista.
fotos reales > síntesis. MV-Adapter (mejor sintetizador) es custom node, fuera de alcance. 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 - `azimuths` se asume equiespaciado (el batch usa un incremento fijo). Mapeo de ángulo a
nombre: 0=front, 90=right, 180=back, 270=left. nombre: 0=front, 90=right, 180=back, 270=left.
## Capability growth log
- v1.1.0 (24/06/2026) — implementado el camino `sv3d` (antes `NotImplementedError`):
builder `SV3D_Conditioning` + `VideoLinearCFGGuidance` + `KSampler` + `VAEDecode` que
produce el orbit de N frames; nuevos params `video_frames`/`sv3d_width`/`sv3d_height`
configurables; salida extendida con `frames`/`frame_count`; soporta `sv3d_p`/`sv3d_u`.
Probado en GPU (8 GB lowvram): 21 frames 576×576 en ~75 s, peak ~5.7 GB
(prompt_id `0caeedf4-…`). Modelo `sv3d_p.safetensors` descargado a `checkpoints/`.
@@ -37,10 +37,13 @@ from comfyui_submit_workflow import comfyui_submit_workflow # noqa: E402
from comfyui_wait_result import comfyui_wait_result # noqa: E402 from comfyui_wait_result import comfyui_wait_result # noqa: E402
from comfyui_fetch_output_image import comfyui_fetch_output_image # noqa: E402 from comfyui_fetch_output_image import comfyui_fetch_output_image # noqa: E402
# Checkpoint requerido por cada metodo (lo carga ImageOnlyCheckpointLoader). # Checkpoint(s) requerido(s) por cada metodo (los carga ImageOnlyCheckpointLoader).
# Cada metodo declara una tupla de candidatos en orden de preferencia: se usa el
# primero que el servidor ofrezca. SV3D acepta sv3d_p (orbita con elevacion
# variable, preferido) o sv3d_u (orbita uniforme en el ecuador).
_METHOD_CKPT = { _METHOD_CKPT = {
"zero123": "stable_zero123.ckpt", "zero123": ("stable_zero123.ckpt",),
"sv3d": "sv3d_u.safetensors", "sv3d": ("sv3d_p.safetensors", "sv3d_u.safetensors"),
} }
# azimuth (grados) -> nombre de vista. 0=front (la que aporta el caller). # azimuth (grados) -> nombre de vista. 0=front (la que aporta el caller).
_AZIMUTH_NAME = {0: "front", 90: "right", 180: "back", 270: "left"} _AZIMUTH_NAME = {0: "front", 90: "right", 180: "back", 270: "left"}
@@ -53,6 +56,9 @@ def comfyui_generate_views_from_image(
server: str = "127.0.0.1:8188", server: str = "127.0.0.1:8188",
azimuths: tuple = (90, 180, 270), azimuths: tuple = (90, 180, 270),
elevation: float = 0.0, elevation: float = 0.0,
video_frames: int = 21,
sv3d_width: int = 576,
sv3d_height: int = 576,
dest_dir: str | None = None, dest_dir: str | None = None,
validate_only: bool = False, validate_only: bool = False,
wait_timeout: float = 300.0, wait_timeout: float = 300.0,
@@ -72,6 +78,14 @@ def comfyui_generate_views_from_image(
el batch. keyword-only. el batch. keyword-only.
elevation: elevacion de camara en grados para todas las vistas. elevation: elevacion de camara en grados para todas las vistas.
keyword-only. keyword-only.
video_frames: SOLO sv3d. Numero de frames del orbit (vistas equiespaciadas
en 360 grados que el modelo sintetiza en una pasada). Default 21 (el
nativo de SV3D). Bajalo (p. ej. 7-12) para reducir VRAM en 8 GB
lowvram a costa de menos densidad angular. keyword-only.
sv3d_width: SOLO sv3d. Ancho de cada frame del orbit. Default 576 (nativo).
Bajalo (p. ej. 320) para reducir VRAM. keyword-only.
sv3d_height: SOLO sv3d. Alto de cada frame del orbit. Default 576 (nativo).
keyword-only.
dest_dir: carpeta local donde descargar las vistas generadas. Si None, no dest_dir: carpeta local donde descargar las vistas generadas. Si None, no
se descargan (solo se devuelven los nombres del output del servidor). se descargan (solo se devuelven los nombres del output del servidor).
keyword-only. keyword-only.
@@ -86,6 +100,10 @@ def comfyui_generate_views_from_image(
dict. Si hay camino viable y se genera: dict. Si hay camino viable y se genera:
{ok: True, method, views: {"back": <ruta/nombre>, "left": ..., "right": ...}, {ok: True, method, views: {"back": <ruta/nombre>, "left": ..., "right": ...},
prompt_id, available: {...}, reason: "", error: ""}. prompt_id, available: {...}, reason: "", error: ""}.
Con method='sv3d' ademas trae el orbit completo:
{..., "views": {<cardinales mapeados al frame mas cercano>},
"frames": [<ruta/nombre de cada uno de los N frames del orbit>],
"frame_count": N}.
Con validate_only=True (no encola): Con validate_only=True (no encola):
{ok: <valido>, method, validated: True, valid, missing_nodes, {ok: <valido>, method, validated: True, valid, missing_nodes,
missing_models, views: {}, available: {...}, reason: "", error: ""}. missing_models, views: {}, available: {...}, reason: "", error: ""}.
@@ -105,7 +123,7 @@ def comfyui_generate_views_from_image(
"sv3d": "SV3D_Conditioning" in oi, "sv3d": "SV3D_Conditioning" in oi,
} }
ckpts = _checkpoint_combo(oi) ckpts = _checkpoint_combo(oi)
ckpts_present = {m: (_METHOD_CKPT[m] in ckpts) for m in _METHOD_CKPT} ckpts_present = {m: (_resolve_ckpt(m, ckpts) is not None) for m in _METHOD_CKPT}
available = {"nodes": nodes_present, "ckpts": ckpts_present, "ckpt_combo": ckpts} available = {"nodes": nodes_present, "ckpts": ckpts_present, "ckpt_combo": ckpts}
# 2. Elegir metodo viable (nodo + checkpoint presentes). # 2. Elegir metodo viable (nodo + checkpoint presentes).
@@ -122,9 +140,11 @@ def comfyui_generate_views_from_image(
) )
# 3. Construir el workflow. # 3. Construir el workflow.
ckpt_name = _resolve_ckpt(chosen, ckpts)
try: try:
wf = _build_views_workflow(image_name, chosen, ckpts[_method_ckpt_key(chosen, ckpts)], wf = _build_views_workflow(image_name, chosen, ckpt_name, azimuths, elevation,
azimuths, elevation) video_frames=video_frames,
sv3d_width=sv3d_width, sv3d_height=sv3d_height)
except NotImplementedError as exc: except NotImplementedError as exc:
return _stub(chosen, str(exc), available=available, error=str(exc)) return _stub(chosen, str(exc), available=available, error=str(exc))
@@ -143,6 +163,11 @@ def comfyui_generate_views_from_image(
if not prompt_id: if not prompt_id:
return _stub(chosen, f"el servidor no devolvio prompt_id: {sub}", available=available) return _stub(chosen, f"el servidor no devolvio prompt_id: {sub}", available=available)
comfyui_wait_result(prompt_id, server=server, timeout=wait_timeout) comfyui_wait_result(prompt_id, server=server, timeout=wait_timeout)
if chosen == "sv3d":
views, frames = _collect_views_sv3d(prompt_id, server, azimuths, dest_dir, timeout)
return {"ok": True, "method": chosen, "views": views, "frames": frames,
"frame_count": len(frames), "prompt_id": prompt_id,
"available": available, "reason": "", "error": ""}
views = _collect_views(prompt_id, server, azimuths, dest_dir, timeout) views = _collect_views(prompt_id, server, azimuths, dest_dir, timeout)
return {"ok": True, "method": chosen, "views": views, "prompt_id": prompt_id, return {"ok": True, "method": chosen, "views": views, "prompt_id": prompt_id,
"available": available, "reason": "", "error": ""} "available": available, "reason": "", "error": ""}
@@ -163,8 +188,12 @@ def _checkpoint_combo(oi: dict) -> list:
return [] return []
def _method_ckpt_key(method: str, ckpts: list) -> int: def _resolve_ckpt(method: str, ckpts: list) -> str | None:
return ckpts.index(_METHOD_CKPT[method]) """Primer checkpoint candidato del metodo que el servidor ofrece, o None."""
for cand in _METHOD_CKPT.get(method, ()):
if cand in ckpts:
return cand
return None
def _why_unavailable(order, nodes_present, ckpts_present) -> str: def _why_unavailable(order, nodes_present, ckpts_present) -> str:
@@ -175,7 +204,7 @@ def _why_unavailable(order, nodes_present, ckpts_present) -> str:
if not nodes_present.get(m): if not nodes_present.get(m):
parts.append(f"{m}: nodo nativo ausente en el servidor") parts.append(f"{m}: nodo nativo ausente en el servidor")
elif not ckpts_present.get(m): elif not ckpts_present.get(m):
ck = _METHOD_CKPT[m] ck = " o ".join(_METHOD_CKPT[m])
parts.append( parts.append(
f"{m}: nodo OK pero falta el checkpoint '{ck}'. " f"{m}: nodo OK pero falta el checkpoint '{ck}'. "
f"Instalalo con comfyui_download_model(<url_{m}>, dest_subdir='checkpoints')" f"Instalalo con comfyui_download_model(<url_{m}>, dest_subdir='checkpoints')"
@@ -185,12 +214,12 @@ def _why_unavailable(order, nodes_present, ckpts_present) -> str:
"(front/left/back/right) y usa comfyui_build_image_to_3d_multiview_workflow directamente.") "(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: def _build_views_workflow(image_name, method, ckpt_name, azimuths, elevation, *,
"""Workflow batched de sintesis de vistas. Hoy implementado para StableZero123.""" video_frames=21, sv3d_width=576, sv3d_height=576) -> dict:
"""Workflow batched de sintesis de vistas. zero123 (azimuth) o sv3d (orbit)."""
if method == "sv3d": if method == "sv3d":
raise NotImplementedError( return _build_sv3d_workflow(image_name, ckpt_name, elevation,
"el builder SV3D (orbita de 21 frames) no esta implementado todavia; usa method='zero123'" video_frames, sv3d_width, sv3d_height)
)
azs = sorted(azimuths) azs = sorted(azimuths)
start = azs[0] start = azs[0]
increment = (azs[1] - azs[0]) if len(azs) > 1 else 90 increment = (azs[1] - azs[0]) if len(azs) > 1 else 90
@@ -227,8 +256,50 @@ def _build_views_workflow(image_name, method, ckpt_name, azimuths, elevation) ->
} }
def _collect_views(prompt_id, server, azimuths, dest_dir, timeout) -> dict: def _build_sv3d_workflow(image_name, ckpt_name, elevation, video_frames,
"""Mapea las imagenes del SaveImage (en orden de azimuth) a nombres de vista.""" width, height) -> dict:
"""Workflow SV3D: 1 imagen -> orbit de `video_frames` vistas en una pasada.
SV3D reutiliza la maquinaria img2vid de Stable Video Diffusion: el checkpoint
(sv3d_p / sv3d_u) se carga con ImageOnlyCheckpointLoader (MODEL+CLIP_VISION+VAE),
SV3D_Conditioning produce el conditioning del orbit y la latente, y se muestrea
como un video con guia CFG lineal. El SaveImage emite los N frames del orbit.
"""
return {
"1": {"class_type": "LoadImage", "inputs": {"image": image_name}},
"2": {"class_type": "ImageOnlyCheckpointLoader", "inputs": {"ckpt_name": ckpt_name}},
"3": {
"class_type": "SV3D_Conditioning",
"inputs": {
"clip_vision": ["2", 1],
"init_image": ["1", 0],
"vae": ["2", 2],
"width": width,
"height": height,
"video_frames": video_frames,
"elevation": elevation,
},
},
"4": {
"class_type": "VideoLinearCFGGuidance",
"inputs": {"model": ["2", 0], "min_cfg": 1.0},
},
"5": {
"class_type": "KSampler",
"inputs": {
"seed": 0, "steps": 20, "cfg": 2.5, "sampler_name": "euler",
"scheduler": "karras", "denoise": 1.0,
"model": ["4", 0], "positive": ["3", 0], "negative": ["3", 1],
"latent_image": ["3", 2],
},
},
"6": {"class_type": "VAEDecode", "inputs": {"samples": ["5", 0], "vae": ["2", 2]}},
"7": {"class_type": "SaveImage", "inputs": {"images": ["6", 0], "filename_prefix": "sv3d_view"}},
}
def _history_images(prompt_id, server, timeout) -> list:
"""Lista ordenada de descriptores de imagen del history de un prompt."""
import json import json
import urllib.request import urllib.request
@@ -239,18 +310,48 @@ def _collect_views(prompt_id, server, azimuths, dest_dir, timeout) -> dict:
images = [] images = []
for node_out in outputs.values(): for node_out in outputs.values():
images.extend(node_out.get("images", [])) images.extend(node_out.get("images", []))
azs = sorted(azimuths) return images
def _fetch_or_name(img, dest_dir, server) -> str:
"""Descarga la imagen a dest_dir y devuelve su ruta, o el nombre si dest_dir es None."""
if not dest_dir:
return img["filename"]
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,
)
return got.get("path", img["filename"])
def _collect_views_sv3d(prompt_id, server, azimuths, dest_dir, timeout) -> tuple:
"""Mapea el orbit SV3D: devuelve (cardinales, lista completa de frames).
SV3D produce N frames equiespaciados en 360 grados empezando por la vista
frontal (frame 0 = azimuth 0). Para cada azimuth solicitado se localiza el
frame del orbit mas cercano y se etiqueta con su nombre de vista cardinal,
de modo que el resultado encaja con el consumidor multi-vista. `frames`
conserva el orbit completo para reconstruccion 3D densa.
"""
images = _history_images(prompt_id, server, timeout)
frames = [_fetch_or_name(img, dest_dir, server) for img in images]
n = len(frames)
views = {} views = {}
for img, az in zip(images, azs): if n:
for az in sorted(azimuths):
idx = round((az % 360) / 360.0 * n) % n
name = _AZIMUTH_NAME.get(az % 360, f"az{az % 360}")
views[name] = frames[idx]
return views, frames
def _collect_views(prompt_id, server, azimuths, dest_dir, timeout) -> dict:
"""Mapea las imagenes del SaveImage (en orden de azimuth) a nombres de vista."""
images = _history_images(prompt_id, server, timeout)
views = {}
for img, az in zip(images, sorted(azimuths)):
name = _AZIMUTH_NAME.get(az, f"az{az}") name = _AZIMUTH_NAME.get(az, f"az{az}")
if dest_dir: views[name] = _fetch_or_name(img, dest_dir, server)
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 return views