51 Commits

Author SHA1 Message Date
egutierrez 8121e4b04e chore: auto-commit (1 archivos)
- .mcp.json

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-25 00:26:34 +02:00
egutierrez 4302212b34 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>
2026-06-24 19:57:10 +02:00
egutierrez 394221f8c7 feat(ml): pipeline replicar imagen desde link de Civitai
Nueva capacidad del grupo comfyui: dado el id/URL de una imagen de Civitai,
extrae cómo se generó (prompt, modelo, sampler, LoRAs) vía los endpoints tRPC
image.getGenerationData + image.get (la API v1 da meta=null), reconstruye el
workflow y lo replica en nuestro ComfyUI, sustituyendo el checkpoint ausente por
el más parecido instalado y reportando lo que falta en missing_models sin bajar
nada a ciegas. Respeta SFW.

Funciones nuevas (registry-first, componen 8 funciones existentes):
- comfyui_fetch_civitai_image_meta_py_ml (impura): observa la receta por id/URL.
- comfyui_map_a1111_params_py_ml (pura): traduce meta A1111 -> params ComfyUI,
  familia del modelo y LoRAs.
- comfyui_replicate_civitai_oneshot_py_pipelines: orquesta fetch_meta ->
  map_a1111_params -> build/embebido -> run_foreign_workflow_oneshot -> judge.

Probado en vivo (imagen SFW 23526611): receta extraída + réplica 1024x1024
generada + panel de jueces. 12 tests unitarios verdes. Capability page comfyui.md
actualizada. Report 0127.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:25:31 +02:00
egutierrez 69d9aed46a feat(ml): mixer de capacidades comfyui (compose + generate_mixed_oneshot + inject controlnet/ipadapter)
Mezclador del grupo comfyui-skill que promueve a una sola llamada la secuencia
base -> compose -> submit -> wait -> fetch -> judge (issue 0087):

- comfyui_compose_capabilities_py_ml (PURA): aplica en orden las capacidades
  activadas (loras, controlnet, ipadapter, facedetailer, hires) sobre un
  workflow base, sin mutar la entrada.
- comfyui_generate_mixed_oneshot_py_pipelines: one-shot que resuelve el base
  (skill/txt2img/dict), compone, encola, espera, descarga el PNG y lo puntua
  con el panel comfyui-judge.
- comfyui_inject_controlnet_py_ml, comfyui_inject_ipadapter_py_ml: inyectores
  encadenables que consume el compose.
- Tests (24 passed) + pagina madre docs/capabilities/comfyui-skill.md.

Prueba real en GPU: txt2img dreamshaper_8 + 2 LoRAs (3d_render_redmond +
detail_tweaker) + FaceDetailer -> imagen 512x512 en ~24s, juez verdict 'good'
(score 4.69, votos aesthetic+clip good; voto llm degradado por rate-limit 429).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:02:10 +02:00
egutierrez c36c80dda9 docs(comfyui-skill): añade comfyui_inject_multi_lora + comfyui_build_ipadapter_workflow a la página madre 2026-06-24 17:51:03 +02:00
egutierrez 3887e59092 feat(ml): auto-commit con 6 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 17:47:28 +02:00
agent d5660aa13f docs(comfyui): añade capacidad 10 ipadapter/referencia (en construcción) al overview
El flujo de funciones+server está creando comfyui_build_ipadapter_workflow e
comfyui_inject_multi_lora (vistos sin indexar en python/functions/ml/ el
24/06/2026). Se documentan como capacidad emergente para que el mapa esté
completo; sus IDs reales se rellenarán cuando se ejecute fn index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:46:45 +02:00
agent a56b6e36ea docs(comfyui): comfyui-overview — mapa cross-grupo de capacidades de generación
Indexa las 58 funciones del stack ComfyUI (grupos comfyui + comfyui-skill +
comfyui-judge) por capacidad: txt2img, img2img/inpaint, controlnet,
skills/multiestilo-LoRA, video, upscale/detail, 3D, juez/calidad y
operación/infra. Cada capacidad mapea a sus builders/pipelines del registry,
grafos UI y skills. Añade fila en docs/capabilities/INDEX.md.

El catálogo navegable con los grafos en disco (reorganizados en subcarpetas
por capacidad bajo ~/ComfyUI/user/default/workflows/) vive fuera del repo en
~/ComfyUI/CAPABILITIES.md (no versionado).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:45:59 +02:00
egutierrez 5f0df32728 fix(browser): limpiar previews/outputs residuales al cargar workflow ComfyUI
app.loadApiJson (lo que usa comfyui_load_workflow_ui) reconstruye el grafo pero
no llama a app.clean(), por lo que no resetea el store app.nodeOutputs ni los
previews de los nodos. Cuando un workflow nuevo reusa un node_id existente en el
store, el preview cacheado del workflow anterior se re-pinta sobre el nodo nuevo
(visto: imagen 3D pegada bajo un CheckpointLoaderSimple/SaveGLB).

- Nueva funcion comfyui_clear_node_outputs_ui: limpieza no destructiva del store
  app.nodeOutputs + node.imgs/images, sin tocar la topologia del grafo.
- comfyui_load_workflow_ui v1.1.0: anade clear_outputs=True (default) que invoca
  la limpieza antes de loadApiJson, replicando la garantia de loadGraphData.

Reproducido y verificado en la UI real (CDP 9222) con evidencia antes/despues.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:37:07 +02:00
egutierrez 6d1b66167d docs(comfyui): cerrar doc_orphan comfyui-skill + cobertura Civitai + allowlist naming
Aplica los 3 arreglos de orden de la auditoria 0120:
- INDEX.md: anade la fila del grupo comfyui-skill (11 fns), cerrando el unico FAIL
  doc_orphan de fn doctor capabilities.
- comfyui-skill.md: documenta las 2 funciones de cosecha Civitai
  (comfyui_extract_recipe_from_png, comfyui_harvest_civitai_skill_oneshot) en la
  tabla + seccion 'Cosecha Civitai -> skill candidata'. Cobertura 9/11 -> 11/11.
- ids_naming.md: anade save/bump/harvest/judge/critique a la allowlist documentada
  (espejo del cambio en apps/registry_mcp/naming.go, en su sub-repo).

No fn index (solo docs + rule). No renames.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:10:47 +02:00
egutierrez 04ecf9f394 feat(ml): comfyui_export_skill_template — skills (recetas) como grafos cargables en el navegador
Cierra el gap receta->grafo del grupo comfyui-skill. La función impura
comfyui_export_skill_template compila una skill a template API format
(exports/<slug>.template.json) y, con ui_graph=True, genera el UI graph
posicionado vía CDP (load_workflow_ui + export_workflow_ui) en la carpeta
nativa de la UI (~/ComfyUI/user/default/workflows/<slug>.json), de modo que la
skill aparece en el menú Workflows del navegador y se abre como grafo visual.
Sin navegador, deja el template API y reporta el fallback (no falla).

- 4 tests offline (golden + edge + 2 error paths).
- Página madre comfyui-skill.md: fila en la tabla del grupo + sección
  "Skills como grafos en el navegador".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:58:11 +02:00
egutierrez 46954d8584 feat(infra): auto-commit con 8 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 15:35:59 +02:00
egutierrez 6f4b440762 feat(ml): cosecha Civitai → skills candidatas (search/fetch/extract + harvest oneshot)
Cierra la 3ª pieza del sistema comfyui-skill: cosechar de Civitai imágenes con su
workflow+receta embebidos para clonar su calidad y alimentar la librería de skills.

- comfyui_search_civitai_images: GET /api/v1/images; resuelve query->versión de
  modelo (el endpoint no admite query textual, da HTTP 500); token de pass; reintenta 503.
- comfyui_fetch_civitai_image: descarga el PNG original (conserva workflow embebido),
  SEGREGA NSFW a <dest>/nsfw/, validación no-HTML, nombre único por UUID.
- comfyui_extract_recipe_from_png: import_workflow_png + read_png_metadata + fallback
  flux (CLIPTextEncode/UNETLoader) -> receta candidata (source='civitai', score_n=0).
- comfyui_harvest_civitai_skill_oneshot (pipeline): search->fetch->extract->save_skill;
  itera items, 2º pase al feed global, NO baja modelos a ciegas (missing_models).

Hallazgo: la API de Civitai ya no expone meta (null); la receta sale del workflow
ComfyUI embebido en el PNG. Política: NSFW permitido pero SIEMPRE segregado.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:35:12 +02:00
egutierrez bcf731275e feat(ml): cierre del bucle de mejora comfyui-skill (genera→juzga→bump)
Tres funciones nuevas que cierran el lazo skill→generación→juicio→promoción
del grupo comfyui-skill (issue 0087):

- comfyui_bump_skill_version (impura): promueve una versión nueva SOLO si el
  score del panel-juez sube (gate objetivo). Snapshot versions/vN.json
  pre-mutación, deep-merge de recipe_patch, semver↑, línea en growth_log.jsonl.
  force=True salta el gate. No usa datetime.now().
- comfyui_update_skill_score (impura): media incremental de score_mean/score_n
  reescribiendo recipe.json in-place (sin snapshot ni growth_log).
- comfyui_generate_with_skill_oneshot (pipeline): one-shot load→build→submit→
  wait→fetch→judge→score_mean. recipe_patch prueba variantes sin guardar score.
  Compone 7 funciones del registry.

Tests offline: 11 passed (gate, semver, deep-merge, media incremental, errores).
Página madre docs/capabilities/comfyui-skill.md: +3 funciones, sección "Bucle de
mejora" con diagrama, fronteras de scoring actualizadas.

Demo real verificada: skill seed portrait_cinematic_sd15 (SD1.5) generó imagen
SFW real, el panel la juzgó, una variante puntuó más alto (4.787 > 4.7276) y el
gate promovió v1.0.0→v1.1.0 con el judge_run_id como evidencia.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:09:33 +02:00
egutierrez 974cc06bc7 feat(ml): panel multi-juez comfyui-judge (estetico + CLIP + LLM-vision)
Cuatro funciones impuras + pagina madre del grupo comfyui-judge, el gate
objetivo de calidad de imagen para tests/DoD y el bucle de mejora de skills:

- comfyui_score_aesthetic: estetico LAION-V2 (head MLP sobre CLIP ViT-L/14),
  subproceso al venv ComfyUI (torch+open_clip).
- comfyui_score_clip_alignment: fidelidad prompt-imagen via similitud coseno CLIP.
- comfyui_critique_image_llm: critica LLM-vision (compone ask_llm_vision), JSON
  verdict+score+reasons.
- comfyui_judge_image: agregadora, vota mayoria good/bad; degrada si un juez cae.

QuickGELU (ViT-L-14-quickgelu/openai) obligatorio: sin el, los embeddings se
degradan y el ranking de fidelidad se invierte en silencio.

Validado e2e sobre imagenes reales: golden 3 votos coherentes, asserts relativos
(nitida>ruido, alineado>desalineado), split 2-1 respeta mayoria en ambos sentidos,
degradacion ante 429/model invalido/path invalido sin crash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:54:32 +02:00
agent 70d541fca9 feat(ml): núcleo subsistema comfyui-skill + ask_llm_vision
Grupo nuevo comfyui-skill: recetas versionadas de generación ComfyUI que
compilan a un workflow cambiando solo el subject.

- comfyui_build_skill_workflow (pura): receta -> workflow API format,
  despacha base (txt2img/flux/sdxl_refiner), sustituye {subject}+triggers,
  encadena loras e inject blocks (facedetailer, hires_fix). SkillWorkflowError tipada.
- comfyui_inject_hires_fix (pura): inyecta 2ª pasada UltimateSDUpscale sobre dict.
- comfyui_save/load/list_skill (impuras): CRUD de la librería en disco con
  versionado por snapshots, round-trip idéntico, filtro NSFW.
- ask_llm_vision (core, claude-direct): pregunta multimodal imagen+texto via
  API directa Anthropic, para puntuar generaciones.
- Página madre docs/capabilities/comfyui-skill.md con schema canónico de recipe.json.

Tests offline: 11 verdes (6 builder + 5 inject_hires_fix). Sin GPU.
2026-06-24 14:35:46 +02:00
egutierrez e8a66f0dad feat(ml): comfyui_run_foreign_workflow_oneshot + helper fetch_output_video
Pipeline one-shot para ejecutar workflows ComfyUI ajenos end-to-end
(import desde cualquier fuente -> resolve deps -> validate -> submit ->
wait -> fetch del output imagen/video/malla) componiendo 9 funciones
existentes del grupo comfyui. Gate de seguridad: si faltan nodos/modelos
NO encola y los reporta en `missing`; nunca descarga modelos a ciegas y
solo instala nodos custom confiables opt-in (install_nodes + node_repos).

Helper comfyui_fetch_output_video: hermana de fetch_output_image y
fetch_output_mesh para los nodos de video/animacion (SaveAnimatedWEBP,
SaveVideo nativo, VHS_VideoCombine). Localiza el output bajo images/gifs/
videos en /history y lo baja via /view a disco; acepta outputs= de
wait_result para evitar re-consultar /history.

Cierra la pieza marcada por el completeness critic (report 0107) del
roadmap 0064/0087. 13 tests unitarios de las partes puras en verde;
validacion de integracion contra server vivo sin generacion pesada
(report 0110).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:53:40 +02:00
egutierrez 898502a321 fix(ml): comfyui_wait_result no sale prematuro en jobs de video/3D
Exige outputs no vacios (no solo status terminal) para dar por completado
un prompt: en jobs pesados ComfyUI marca la entry de /history como
terminada antes de poblar outputs, lo que devolvia un dict vacio mientras
el job seguia en GPU. Ahora sigue sondeando hasta que los outputs aparecen
o hasta agotar el timeout. Timeout default 180s -> 600s (cubre video/3D) y
timeout HTTP por-request acotado a 30s. Firma y contrato de retorno intactos.

Tests nuevos (mock urllib CI-safe + live opcional contra /history real):
golden, regresion del bug, edge imagen corta, timeout y error. v1.0.0 -> 1.1.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:39:58 +02:00
egutierrez 2fe36e314e docs(ml): fix doc gap controlnet (.pth → _fp16.safetensors) + capability page comfyui completa
- comfyui_build_controlnet_workflow.md: el ejemplo usaba cn_name=control_v11p_sd15_canny.pth
  pero el modelo instalado es control_v11p_sd15_canny_fp16.safetensors. Corregido para que
  copia+pega funcione. Firma intacta.
- docs/capabilities/comfyui.md: añadida subsección "Lifecycle del server — dominio infra"
  con comfyui_ensure_server_py_infra (faltaba: página 48 vs registry 49). Ahora 49 == 49.

Higiene del grupo comfyui (report local 0104): tests de los builders puros flux/img2vid
verificados (10/10 pasan, suite del grupo 65/65), fn doctor uses-functions sin drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:09:04 +02:00
egutierrez 11ef8ef6db feat(ml): comfyui_build_img2vid_workflow builder img2vid SVD (API format)
Builder puro que construye el dict de un workflow ComfyUI img2vid (Stable Video
Diffusion) en API format a partir de una imagen estatica. Cadena de 7 nodos:
ImageOnlyCheckpointLoader(svd.safetensors, todo-en-uno) + LoadImage ->
SVD_img2vid_Conditioning -> VideoLinearCFGGuidance -> KSampler(denoise 1.0) ->
VAEDecode -> SaveAnimatedWEBP. SVD condiciona por CLIP_VISION de la imagen (sin
prompt de texto); movimiento via motion_bucket_id.

class_type/inputs verificados contra /object_info del servidor vivo. Validacion
estructural con comfyui_validate_workflow: 0 errores. 4 tests verdes. Sin submit
de generacion (GPU en uso por otro agente).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:02:04 +02:00
egutierrez 3e75d1bf79 feat(ml): comfyui_build_flux_workflow builder txt2img Flux (API format)
Builder puro hermano de comfyui_build_txt2img_workflow para modelos Flux
(schnell/dev): UNETLoader + DualCLIPLoader (clip_l + t5xxl, type flux) +
VAELoader -> CLIPTextEncode -> FluxGuidance + EmptySD3LatentImage ->
KSampler (cfg fijo 1.0) -> VAEDecode -> SaveImage. La guia va por FluxGuidance,
no por el cfg del sampler. fp8 + ~4 pasos para GPU de 8GB.

class_type/inputs verificados contra /object_info del server vivo. Validado
end-to-end: genera imagen real (prompt_id 909b8876, flux_builder_test_00001_.png,
status success). 6 tests unitarios verde. Pagina madre docs/capabilities/comfyui.md
actualizada con la fila del builder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:55:09 +02:00
egutierrez 68f0ce0dae feat(infra): auto-commit con 3 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 11:45:31 +02:00
egutierrez c0b2dce3b0 feat(ml): auto-commit con 26 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 04:02:54 +02:00
egutierrez ff41f4f053 feat(ml): auto-commit con 7 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 02:52:51 +02:00
egutierrez f686b338d6 chore: auto-commit (14 archivos)
- docs/capabilities/comfyui.md
- python/functions/ml/comfyui_build_image_to_3d_workflow.md
- python/functions/ml/comfyui_build_image_to_3d_workflow.py
- python/functions/ml/tests/test_comfyui_build_image_to_3d_workflow.py
- python/functions/ml/comfyui_build_facedetailer_workflow.md
- python/functions/ml/comfyui_build_facedetailer_workflow.py
- python/functions/ml/comfyui_build_hires_fix_workflow.md
- python/functions/ml/comfyui_build_hires_fix_workflow.py
- python/functions/ml/tests/test_comfyui_build_facedetailer_workflow.py
- python/functions/ml/tests/test_comfyui_build_hires_fix_workflow.py
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 02:34:10 +02:00
egutierrez 3823a28d1c feat(ml): auto-commit con 7 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 02:05:43 +02:00
egutierrez 337f75b527 chore: auto-commit (5 archivos)
- docs/capabilities/comfyui.md
- python/functions/ml/comfyui_import_workflow_json.md
- python/functions/ml/comfyui_import_workflow_json.py
- python/functions/pipelines/comfyui_text_to_3d_oneshot.md
- python/functions/pipelines/comfyui_text_to_3d_oneshot.py

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 01:52:46 +02:00
egutierrez d3f05a19a5 feat(ml): auto-commit con 11 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 01:39:30 +02:00
egutierrez d7245efa59 feat(ml): auto-commit con 20 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 01:26:38 +02:00
egutierrez 1311c7e585 feat(ml): auto-commit con 7 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 01:16:37 +02:00
egutierrez db4f454f8a chore: auto-commit (1 archivos)
- .claude/commands/ausente.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 00:59:54 +02:00
egutierrez f12272d002 chore: auto-commit (61 archivos)
- docs/capabilities/INDEX.md
- docs/capabilities/comfyui.md
- python/functions/browser/comfyui_export_workflow_ui.md
- python/functions/browser/comfyui_export_workflow_ui.py
- python/functions/browser/comfyui_load_workflow_ui.md
- python/functions/browser/comfyui_load_workflow_ui.py
- python/functions/browser/comfyui_queue_prompt_ui.md
- python/functions/browser/comfyui_queue_prompt_ui.py
- python/functions/browser/comfyui_refresh_nodes_ui.md
- python/functions/browser/comfyui_refresh_nodes_ui.py
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 00:30:30 +02:00
egutierrez 495f545ec1 chore: untrack gitlinks fantasma cpp/apps/{chart_demo,shaders_lab}
Eran gitlinks (160000) en HEAD del padre sin entrada en .gitmodules,
restos del layout legacy cpp/apps/ (deprecado tras issue 0096, las apps
C++ viven ahora en apps/). Hacian fallar 'git submodule update' en cada
/full-git-pull. El sub-repo real shaders_lab vive sano en apps/shaders_lab;
chart_demo no existe en disco. Anadido cpp/apps/*/ al .gitignore para que
no recurra (regla apps_subrepo.md: el padre nunca trackea contenido de
artefactos hijos).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:24:17 +02:00
egutierrez f34badb500 Merge remote-tracking branch 'origin/master' 2026-06-23 17:49:49 +02:00
egutierrez 3289c67986 chore: auto-commit (2 archivos)
- .claude/settings.local.json
- cpp/framework/app_base.cpp

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 17:49:47 +02:00
egutierrez bcc1fe1738 feat(captacion_clientes): scraping freelance en perfil headless dedicado, no chromium-personal
El monitor de captación scrapeaba Workana sobre el navegador personal del
usuario (chromium-personal, CDP 9222), interfiriendo con su navegación. El
scraping CDP debe correr siempre en un perfil headless dedicado.

- Nuevo pipeline monitor_freelance_projects_headless: levanta un Chromium
  headless aislado con perfil dedicado (~/.config/fn_scrape_chrome, CDP 9334)
  vía systemd-run, ejecuta monitor_freelance_projects contra ese puerto y
  cierra la instancia al terminar (finally). Reutiliza el patrón de lifecycle
  de ingest_market_trends_headless. Reutiliza un CDP vivo si el puerto ya
  responde (no cierra lo ajeno).
- scrape_workana_projects y monitor_freelance_projects: default de `port`
  cambiado de 9222 (chromium-personal) a 9334 (perfil dedicado). Default seguro:
  correr a pelo sin Chrome en 9334 falla limpio, no contamina el 9222 personal.

Verificado: el wrapper arranca headless en 9334, scrapea 8 proyectos reales de
Workana, cierra la instancia (9334 muerto, sin proceso colgado) y deja el 9222
personal intacto.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:11:26 +02:00
egutierrez 7619347be8 Merge orq/mcp-crud-ids: doc orchestration fleet_list identifica por pane_id (report 0008) 2026-06-22 12:07:54 +02:00
egutierrez f55e41cf74 docs(orchestration): fleet_list identifies agents by pane_id, not tmux_window
Reflects the orchestrator_mcp change: the MCP fleet_list payload now surfaces
pane_id ("%N", the stable per-pane id) and omits tmux_window ("@N"), which
migrates with the focus swap. Documents that focus/send-keys(nudge)/kill
resolve the live window on demand against tmux, and that the nudge reads
tmux_window from the fleetview binary (which keeps it as an internal field),
never from the MCP payload. The binary's list --json field list now mentions
pane_id as the identifier alongside the internal tmux_window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:03:50 +02:00
egutierrez e2e8669edf Merge orq/sql-connect: mssql_connect + mssql_query + run_mssql_query pipeline, grupo sql-connect (report 0007) 2026-06-22 11:32:47 +02:00
egutierrez 86d68dc9f0 feat(infra): conexion y consulta directa a SQL Server (Navision) via pymssql
Grupo de capacidad nuevo 'sql-connect' (3 funciones) para conectar a un
Microsoft SQL Server (donde corre Navision) y consultar directamente, en
lugar del ida y vuelta manual de pegar CSVs.

- mssql_connect_py_infra: abre conexion pymssql (login_timeout acotado,
  credenciales por argumento, RuntimeError claro si falla).
- mssql_query_py_infra: SELECT parametrizada con binding seguro (sin
  inyeccion) sobre conexion abierta; devuelve {columns, rows, row_count};
  0 filas -> lista vacia; max_rows con fetchmany; read-only.
- run_mssql_query_py_pipelines: one-shot que compone connect+query y cierra
  siempre; CLI imprime JSON o CSV; contrasena desde env var (pass).

Pagina madre docs/capabilities/sql-connect.md + fila en INDEX.md.
Dependencia pymssql>=2.3.13 anadida a python/pyproject.toml + uv.lock.
Tests mock-based (11) verdes; error path verificado end-to-end contra el
driver real (host inalcanzable -> RuntimeError, acotado por login_timeout).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:29:49 +02:00
egutierrez b18759823d Merge orq/equal-skill: /equal espejo de requisitos (report 0005) 2026-06-22 11:21:31 +02:00
egutierrez a59d50238d feat(commands): añadir /equal — espejo de requisitos para confirmar alineación
Reformula la última tarea pedida de forma detallada y estructurada
(objetivo, alcance, entregables, supuestos, criterios de aceptación,
fuera de alcance, dudas) para que usuario y Claude confirmen alineación
antes de ejecutar. No ejecuta la tarea: solo refleja y pregunta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:19:30 +02:00
egutierrez f17d957a8f docs(orquestador): nombrar cada secundario (--title + goal del sidebar) para distinguirlos
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:19:21 +02:00
egutierrez c1f355ffa5 Merge orq/fleet-detect: detect_fleet_context ($TMUX) + spawn auto-detecta socket + hook CONTEXTO FLEET + doctrina (report 0041) 2026-06-21 21:55:11 +02:00
egutierrez 237f763c19 Merge orq/img3d-registry-funcs: promover remove_background al registry + mask en depth_to_relief_glb (grupo img-to-3d, report 0040) 2026-06-21 21:51:13 +02:00
agent bf67ff3180 docs(orquestador): deteccion de flota por $TMUX, kitty solo fuera de tmux
orquestador.md + orchestration.md: la deteccion de 'estoy en una flota' se hace
por $TMUX (via detect_fleet_context), NO por $FLEET_SOCKET (fragil). kitty es
fallback SOLO cuando in_tmux=false. spawn_fleet_agent auto-detecta el socket
(ya no hace falta pasar --socket/--session). Documenta la linea CONTEXTO FLEET
del hook y anade detect_fleet_context al catalogo del grupo orchestration.
2026-06-21 21:50:32 +02:00
agent 03fc0461fa feat(hook): inyectar CONTEXTO FLEET con socket/session al orquestador
hook_fleet_state_inject.sh ahora, ademas de MODO ORQUESTADOR, llama a
detect_fleet_context (por $TMUX) e inyecta una linea CONTEXTO FLEET con
socket/session + recordatorio de usar spawn_fleet_agent (nunca kitty) cuando
in_fleet=true. No depende del venv (solo bash+tmux) y se emite antes del bloque
FLEET-STATE. Degrada limpio: si el detector falta o $TMUX esta vacia, no emite
la linea y el turno sigue intacto.
2026-06-21 21:50:32 +02:00
agent a1105dc4c5 feat(infra): spawn_fleet_agent auto-detecta socket/session de $TMUX
--socket/--session ahora opcionales: si no se pasan, se auto-detectan del
contexto tmux ($TMUX) via detect_fleet_context. Los explicitos siguen
primando. Aborta (exit 2) solo si tras auto-detectar siguen vacios (no hay
tmux). Elimina el bug de caer a kitty cuando $FLEET_SOCKET viene vacia pese a
estar en la flota. Bump v1.2.0 + growth log.
2026-06-21 21:50:32 +02:00
agent 3c9e909eda feat(infra): detect_fleet_context — contexto de flota por $TMUX (no $FLEET_SOCKET)
Funcion nueva detect_fleet_context_bash_infra (tag orchestration). Deriva
socket/session de $TMUX (senal fiable que todo proceso dentro de tmux tiene
siempre), con fallback a $FLEET_SOCKET/$FLEET_SESSION. Devuelve JSON
{in_fleet,in_tmux,socket,session,source}. Causa raiz del bug: $FLEET_SOCKET
(exportada con tmux set-environment -g por launch_fleetclaude) a veces viene
vacia en un claude resumido/relanzado pese a vivir en la flota, y el modo
orquestador caia al fallback kitty. .md self-doc (Ejemplo + Cuando usarla +
Gotchas).
2026-06-21 21:50:32 +02:00
egutierrez 3cf8b21fea feat(datascience): promover remove_background al registry + mask en depth_to_relief_glb (grupo img-to-3d)
Completa la promoción del flujo imagen->3D al registry (grupo de capacidad
img-to-3d), extraído de la app img_to_3d_webapp.

- remove_background_py_datascience (nueva): elimina el fondo con cascada
  rembg/U2Net -> OpenCV GrabCut -> umbral NumPy, compone el objeto sobre gris
  neutro y devuelve image + mask + engine. Impura, nunca lanza. Adaptada de
  backend/bg_removal.py con firma de ruta (image_path) y salida dict, demo CLI
  JSON-serializable.
- depth_to_relief_glb_py_datascience (v1.1.0): añade el parámetro opcional mask
  para recortar la malla de relieve al objeto (descarta las caras del fondo),
  cerrando la cadena con remove_background. Aditivo (mask=None = comportamiento
  previo), fiel al original de backend/depth.py.
- docs/capabilities/img-to-3d.md: incorpora remove_background como paso 0
  (pre-proceso), actualiza el flujo a 3 pasos encadenados, la tabla de funciones
  (4), el ejemplo end-to-end con mask y las deps (rembg/opencv).
- docs/capabilities/INDEX.md: conteo del grupo 3 -> 4.

Las dos funciones ya presentes (estimate_image_depth, depth_to_relief_glb) y el
pipeline build_relief_glb_from_image fueron promovidas en una ronda previa.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:43:08 +02:00
egutierrez cbefc82c02 Merge orq/pane-id-json: campo ClaudeFleet.PaneID + resolve_pane_ids + poblar en list_claude_fleet (report 0039) 2026-06-21 21:30:40 +02:00
255 changed files with 25899 additions and 103 deletions
+112
View File
@@ -0,0 +1,112 @@
---
description: Modo ausente — el orquestador itera solo (lanza agentes, verifica cierres, genera tareas del roadmap, push periódico) sin supervisión, hasta que el humano vuelva. Auto-continúa con ScheduleWakeup.
---
# /ausente — orquestador autónomo desatendido
Activa un **loop autónomo del modo orquestador**: el humano se va y tú sigues trabajando solo
—lanzando agentes, verificando sus cierres, cerrando los que cumplen su DoD, generando tareas
nuevas cuando la flota se vacía, y sincronizando— **hasta que el humano vuelva**. Es el modo
orquestador (`.claude/commands/orquestador.md` + `.claude/rules/orchestration.md`) corriendo sin
prompts humanos, con un mecanismo de auto-continuación.
Requisito: estar ya en modo orquestador (`role=orchestrator`). `/ausente` NO sustituye al
orquestador, lo deja en piloto automático.
## Configuración de esta sesión (elegida por el humano)
- **Al vaciarse la flota**: seguir el **roadmap ComfyUI** — generar tareas nuevas sin parar.
- **Git**: **push periódico**`/full-git-push` tras cada bloque de tareas cerrado.
- **Límite**: **hasta que el humano vuelva** — heartbeat ~25 min + el watcher; tope DURO de 6
ejecutores a la vez; parar en cuanto el humano escriba.
(Si se reinvoca `/ausente` en otra sesión, re-confirmar estas 3 con el humano vía AskUserQuestion.)
## El bucle (cada vez que te re-invocan: por FLEET-DONE del watcher o por el heartbeat)
1. **Drena la flota**: `./fn run drain_fleet_events`. Para cada ejecutor `DICE_TERMINADO`:
**verifica de primera mano** (lee su report + comprueba en disco/CDP que el golden existe — no
te fíes del autodeclarado). Si cumple el DoD → `set_dod_contract <sid> "<c>" met` y **ciérralo
con `kill <PID>` directo** (NUNCA `kill_fleet_agent`/`kill-window`: cierra windows ajenas y se
llevó la console de fleetview — incidente real). Si falla → nudge con el gap concreto.
2. **Nudge** a los `ESTANCADO` (idle > 10 min con DoD sin cerrar). NUNCA a `waiting`.
3. **¿Flota con hueco?** (< 6 ejecutores y hay backlog) → **genera la siguiente tarea del roadmap**
(lista abajo), escribe su prompt autocontenido con aislamiento + DoD-contrato, lánzala con
`spawn_fleet_agent --parent <tu-sid>`, fíjale nombre (`fleet_set_name`) + DoD. Respeta el tope
de 6 y la disjunción de recursos (server/venv/GPU vs functions+fn_index vs disco — ver
`orchestration.md`): solo UN agente dueño del server/venv a la vez; solo UNO toca
`functions/`+`fn index` a la vez; los descargadores de modelos van a carpetas distintas.
4. **Push periódico**: cuando cierres un bloque (>=1 tarea met e integrada), corre
`./fn run full_git_push_bash_pipelines ""` y verifica que el padre queda alineado con
`origin/master`. Diagnostica y reintenta si falla (regla de `/full-git-push`).
5. **Bitácora**: añade una línea al report de bitácora `reports/NNNN-ausente-bitacora.md` (créalo
la primera vez): timestamp + qué cerraste + qué lanzaste + push. Es lo que el humano lee al
volver.
6. **Reprograma el heartbeat**: `ScheduleWakeup(delaySeconds≈1500, prompt="/ausente",
reason="loop ausente: vigilar flota + roadmap ComfyUI")`. Si hay agentes en vuelo, el watcher
te empujará sus FLEET-DONE antes (no hace falta wakeup corto); el heartbeat es el fallback para
cuando la flota está vacía y hay que generar tareas nuevas.
## Supervivencia a la compactación de contexto
El loop es de larga duración → el contexto se llenará. **Cuando te quedes sin contexto, deja que
el harness compacte la conversación y CONTINÚA el modo ausente** — no lo trates como una parada.
El modo sobrevive porque su estado es **durable fuera del contexto**:
- El `ScheduleWakeup(prompt="/ausente")` re-inyecta el modo en cada heartbeat (y el FLEET-DONE del
watcher también te re-entra).
- La **bitácora** `reports/ausente-bitacora-2026-06-24.md` es la memoria persistente: qué se cerró,
qué se lanzó, qué falta del backlog, último push. **Tras una compactación, lo PRIMERO es releer
la bitácora** (y `fleet_list`) para reconstruir el estado y seguir donde lo dejaste.
- Mantén la bitácora al día en CADA turno (no solo al cerrar bloques) para que la compactación
nunca pierda progreso. El comando `/ausente` + `orchestration.md` reconstruyen la doctrina.
Una compactación NO es el humano volviendo — sigue iterando con normalidad.
## Parada
- **El humano vuelve** = recibes un prompt que NO es un FLEET-DONE ni el `/ausente` del heartbeat
(es texto del humano). Entonces: **no reprogrames el wakeup**, resume todo lo hecho durante la
ausencia (lee la bitácora) y vuelve al modo orquestador interactivo normal.
- Si el backlog del roadmap se agota del todo (raro): haz un último push, deja la flota cerrada,
escribe el resumen en la bitácora, programa un heartbeat largo y queda a la espera.
## Reglas duras (más estrictas sin supervisión)
- **Nada destructivo ni irreversible sin el humano**: no borrar datos/modelos/repos, no `git push
--force`, no tocar producción/VPS, no mandar nada hacia afuera (correos, mensajes, APIs con
efecto), no pagar/descargar gated de pago. Ante la duda, NO lo hagas: déjalo anotado en la
bitácora como "pendiente de revisión humana".
- **Aislamiento git por agente** SIEMPRE (sub-repo / worktree / scope disjunto). Ningún agente
commitea el padre salvo el push periódico que corres tú.
- **Tope 6 ejecutores**. Encola el resto.
- **Cierre por `kill <PID>`**, jamás `pkill`/`killall`/`kill_fleet_agent` (protege la TUI/console
de fleetview y a ti mismo).
- **Verificación adversarial**: el golden de cada cierre se comprueba en disco/CDP/ejecución, no
por lo que el agente diga. Honestidad en la bitácora (gaps incluidos).
- Cada agente full-capaz sigue registry-first y delega a `fn-constructor`; tú no escribes lógica
reutilizable inline.
## Backlog del roadmap ComfyUI (fuente de tareas a generar; prioriza arriba→abajo)
Base: `reports/0064-comfyui-roadmap-plan.md` + propuestas de los reports 0069/0073/0075/0079.
1. **Funciones 3D propuestas pendientes**: `comfyui_build_view_3d_workflow`,
`comfyui_generate_views_from_image` (Zero123/SV3D), `comfyui_text_to_3d_oneshot` (pipeline),
`comfyui_build_multiview_textured_3d_workflow`. (Dueño de functions/+fn index, uno a la vez.)
2. **`comfyui_download_workflow`** (detecta Drive/GitHub/Civitai/PNG → API format) — del catálogo
de fuentes (report `comfyui-wf-sources`).
3. **P2 del roadmap**: `comfyui_batch_generate`, `comfyui_interrupt_queue`,
`comfyui_ensure_server` (systemd-user con --lowvram + health).
4. **Vídeo end-to-end**: montar workflow LTX-Video y Wan2.1 (modelos ya en /mnt/2tb), generar un
clip corto SFW de prueba, validar VRAM 8GB; capitalizar `comfyui_build_video_workflow`.
5. **Calidad 3D**: decimación de mesh (`fast_simplification`, gap del 0069) + watertight
(`VoxelToMesh`); función `comfyui_simplify_mesh`.
6. **Librería de workflows**: bajar+validar los ejemplos recomendados por `comfyui-wf-sources`,
dejarlos en una librería local validada contra nuestro server.
7. **Higiene**: `fn doctor` sobre las funciones nuevas (uses-functions/unused), capability page
`docs/capabilities/comfyui.md` al día, tests de las funciones sin cobertura.
8. Cuando ideas concretas se agoten: un agente "completeness critic" que audite el grupo `comfyui`
y proponga el siguiente lote.
Cada tarea generada respeta el patrón del orquestador: prompt autocontenido (objetivo, dir,
aislamiento, qué entrega, DoD-contrato golden+edge+error), `--parent`, nombre + DoD fijados al
lanzar, verificación de primera mano al cerrar.
## Relación
- `.claude/commands/orquestador.md` — el modo base; `/ausente` es su versión desatendida.
- `.claude/rules/orchestration.md` — maquinaria (drain, clasificación, verificador, nudge, tope).
- `.claude/rules/autonomous_loop.md` — `fn-orquestador` (Agent tool, sandbox). `/ausente` NO es
eso: aquí TÚ (el orquestador interactivo) sigues conduciendo la flota de Claudes interactivos.
+81
View File
@@ -0,0 +1,81 @@
---
description: "Espejo de requisitos: Claude reformula con detalle la última tarea pedida (objetivo, alcance, entregables, supuestos, criterios de aceptación, fuera de alcance y dudas) para confirmar alineación antes de ejecutar. No ejecuta nada."
argument-hint: "[opcional: matiz o foco a tener en cuenta al reformular]"
---
# /equal — confirmar alineación reformulando la tarea pedida
Mecanismo de **espejo de requisitos**. Cuando el usuario invoca `/equal`, NO ejecutas la tarea: devuelves tu interpretación detallada y estructurada del encargo más reciente, para que el usuario confirme o corrija antes de que empieces a trabajar.
El objetivo es eliminar el malentendido silencioso: prefieres gastar un turno reflejando lo que crees que se te pide que arrancar en la dirección equivocada.
## Qué hacer al invocarse
1. **Identifica la tarea más reciente que el usuario te ha pedido** en la conversación actual: la última petición de trabajo real, no el `/equal` en sí ni un comando de utilidad anterior. Si hay `$ARGUMENTS`, úsalos como matiz o foco adicional al reformular (p. ej. "céntrate en el alcance" o "asume que es solo el backend"), no como la tarea nueva.
2. **Reformula esa tarea de forma detallada y estructurada**, con estas secciones (omite una sección solo si es genuinamente no aplicable, no para abreviar):
- **Objetivo** — qué se quiere conseguir, en una o dos frases claras. El "para qué", no solo el "qué".
- **Alcance / qué incluye** — los trozos concretos de trabajo que entiendes incluidos. Lista, no párrafo.
- **Entregables** — qué archivos, cambios, salidas o artefactos concretos vas a producir.
- **Supuestos** — lo que estás asumiendo por defecto al no estar dicho explícitamente (stack, ubicación, convenciones, datos, alcance temporal). Hazlos visibles para que el usuario los pueda tumbar.
- **Criterios de aceptación** — cómo sabremos que está bien hecho. Condiciones verificables, no deseos vagos. Cuando aplique, golden + edge + caso de error (alineado con `dod_quality.md`).
- **Fuera de alcance** — lo que NO vas a hacer, para acotar expectativas y evitar scope creep.
- **Dudas / ambigüedades a confirmar** — preguntas concretas sobre lo que no está claro. Numéralas para que el usuario pueda responder por número. Si no hay dudas reales, dilo explícitamente ("sin dudas bloqueantes").
3. **Cierra pidiendo validación**: una línea clara del tipo "¿Alineado? Corrige lo que no cuadre y arranco." No empieces a trabajar hasta que el usuario confirme.
## Caso sin tarea previa
Si **no hay** una petición de trabajo clara que reformular (la conversación está vacía de encargos, o lo último fueron solo comandos de utilidad / charla), NO inventes una tarea. Dilo directamente y pide al usuario que describa qué quiere hacer, ofreciéndole la misma estructura como guía de qué información es útil (objetivo, entregables, criterios).
## Reglas duras
- **No ejecutas la tarea.** `/equal` solo refleja y pregunta. Nada de editar archivos, lanzar comandos de trabajo ni delegar a subagentes para "ir avanzando".
- **No inventes requisitos.** Lo que no se ha pedido va a *Supuestos* (marcado como asunción) o a *Dudas* (como pregunta), nunca colado como hecho.
- **Refleja lo que se pidió, no lo que crees que debería pedirse.** Si detectas una mejora obvia, propónla como una duda al final, no la incorpores al objetivo.
- **Sé concreto.** Evita reformulaciones genéricas que valdrían para cualquier tarea. Cita los detalles reales del encargo (nombres de archivos, rutas, tecnologías, cifras que el usuario mencionó).
- Mantén el comando **simple**: es un reformulador de requisitos, no un planificador de implementación ni un generador de tareas.
## Ejemplo de salida
Para una petición previa como *"añade un endpoint `/health` al servicio de deploy que devuelva 200 y la versión"*:
```
## Objetivo
Añadir un health check HTTP al deploy_server para poder verificar de forma
programática que el servicio está vivo y saber qué versión corre.
## Alcance / qué incluye
- Nuevo handler GET `/health` en el servidor HTTP de deploy_server.
- Respuesta 200 con un cuerpo JSON que incluya la versión del binario.
- Registro de la ruta en el router existente.
## Entregables
- Cambio en el código del servidor de `apps/deploy_server/` (handler + ruta).
- Sin migración de BD (no toca schema).
## Supuestos
- La versión a devolver es la que ya expone el binario (no hay que crear un
sistema de versionado nuevo).
- Formato de respuesta JSON `{"status":"ok","version":"x.y.z"}`.
- Sin autenticación en `/health` (endpoint público de liveness).
## Criterios de aceptación
- Golden: `GET /health` con el servicio arriba → 200 + JSON con `version`.
- Edge: la versión se lee correctamente aunque el binario se compile sin tag.
- Error: si un subsistema crítico no está listo, devuelve 503, no 200 falso.
## Fuera de alcance
- Readiness check de dependencias remotas (VPS, SSH).
- Métricas / observabilidad más allá del 200.
## Dudas / ambigüedades a confirmar
1. ¿`/health` debe comprobar algo real (DB, disco) o basta con responder vivo?
2. ¿La versión sale de un ldflag de build, de un fichero, o de constante?
3. ¿Puerto y router son los que ya usa `deploy_server serve`?
¿Alineado? Corrige lo que no cuadre y arranco.
```
El ejemplo es ilustrativo del **formato y el nivel de detalle**; el contenido real sale siempre de la tarea concreta que el usuario haya pedido en la conversación.
+43 -19
View File
@@ -75,36 +75,59 @@ siendo grande para un agente, pásala por el **splitter** (ver `.claude/rules/or
### 2. Lanzar cada secundario
**Regla dura: cada secundario se lanza SIEMPRE como terminal visible — window de la flota tmux si
hay perfil fleet (`$FLEET_SOCKET`, lo normal), o kitty fuera de él. NUNCA como sub-agente del Agent
tool (ver paso 8).** Empieza por el bloque de flota tmux cuando estás en un perfil fleet; kitty es
el fallback para secundarios que deban vivir fuera de la flota.
estás dentro de tmux/una flota, o kitty SOLO cuando de verdad no hay tmux. NUNCA como sub-agente del
Agent tool (ver paso 8).** La detección de "estoy en una flota" se hace por **`$TMUX`** (señal
fiable, vía `detect_fleet_context`), **NO por `$FLEET_SOCKET`** (a veces viene vacía en un claude
resumido/relanzado pese a vivir en la flota → te haría caer a kitty por error). El hook
`hook_fleet_state_inject.sh` te inyecta cada turno una línea `CONTEXTO FLEET: … socket=<X>` cuando
estás dentro de la flota; úsala. Empieza por el bloque de flota tmux; kitty es el fallback solo fuera
de tmux.
Siempre con `--dangerously-skip-permissions` (memoria `lanzar-agentes-skip-permissions`): los
secundarios trabajan autónomos y desatendidos; los prompts de permiso en cada Bash los atascarían.
#### En la flota tmux (PREFERIDO en perfil fleet)
**Nombra cada secundario para diferenciarlo de un vistazo (regla dura).** Cuando lances varios a la
vez, el humano tiene que poder distinguirlos rápido en el sidebar de fleetview. Dos cosas:
Si estás dentro de un perfil FleetView (`$FLEET_SOCKET` seteada), **NO lances kitties sueltas**:
lanza cada ejecutor como una **window de la flota tmux** con `spawn_fleet_agent`, para que viva en
la flota, se vea en la TUI `fleetview` y sea conmutable con `/fleet focus`:
1. **`--title` descriptivo y prefijado** en cada `spawn_fleet_agent`: un slug corto y único que diga
QUÉ hace ese agente, idealmente con una letra/índice para ordenarlos (`A·mcp-rename`,
`B·sql-navision`, `C·kanban`, `D·equal-skill`). Esto nombra la window tmux.
2. **El nombre del sidebar fleetview = el campo `goal`** del `~/.claude/goals/<sid>.json`. En cuanto
resuelvas el `sessionId` del secundario, fíjale un nombre claro con la tool
`mcp__orchestrator__fleet_set_name` (o `./fn run set_fleet_name` cuando exista el fallback CLI) —
mismo slug descriptivo que el `--title`. Si esa capacidad aún no está disponible en la sesión,
apóyate solo en `--title` y en que el `goal` autogenerado del prompt sea descriptivo, pero el
objetivo es que el sidebar liste nombres legibles, no objetivos genéricos repetidos.
#### En la flota tmux (PREFERIDO siempre que estés en tmux)
Si estás dentro de tmux/una flota (`$TMUX` seteada — compruébalo con `detect_fleet_context`, **no**
con `$FLEET_SOCKET`), **NO lances kitties sueltas**: lanza cada ejecutor como una **window de la
flota tmux** con `spawn_fleet_agent`, para que viva en la flota, se vea en la TUI `fleetview` y sea
conmutable con `/fleet focus`:
```bash
./fn run spawn_fleet_agent --socket "$FLEET_SOCKET" --session "$FLEET_SESSION" \
# spawn_fleet_agent auto-detecta el socket/session de $TMUX — NO hace falta pasar --socket/--session:
./fn run spawn_fleet_agent \
--cwd <dir-aislado> --prompt-file /tmp/orq_<slug>.md --title "<subtarea>" \
--parent "$MI_SESSION_ID"
# devuelve el window_id; despues escribe el DoD-contrato del ejecutor:
./fn run set_dod_contract <sessionId-del-ejecutor> "<DoD golden+edge+error>" pending
```
- `spawn_fleet_agent_bash_infra` crea la window tmux + arranca claude con el prompt autocontenido
(o `--skill <name>`), y con `--role executor|orchestrator` marca su `goal.json`. El aislamiento
git (sub-repo / worktree / scope) sigue imponiéndose en el prompt.
- `spawn_fleet_agent_bash_infra` **auto-detecta** socket/session del contexto tmux (`$TMUX`) vía
`detect_fleet_context`; pásalos explícitos solo si quieres otra flota (los explícitos priman).
Crea la window tmux + arranca claude con el prompt autocontenido (o `--skill <name>`), y con
`--role executor|orchestrator` marca su `goal.json`. El aislamiento git (sub-repo / worktree /
scope) sigue imponiéndose en el prompt.
- **`--parent <mi-sessionId>` (recomendado):** escribe `parent_orchestrator` en el `goal.json` del
ejecutor atribuyéndotelo a ti. Es lo que habilita el **push activo** del watcher (te avisa en TU
pane cuando ese ejecutor termina). Sin `--parent` el aviso no se rutea. Opcional y
retro-compatible. Ver `.claude/rules/orchestration.md`.
#### Fuera de la flota (kitty fallback)
#### Fuera de tmux (kitty fallback)
Solo cuando `detect_fleet_context` reporta `in_tmux=false` (de verdad no hay tmux):
```bash
./fn run launch_claude_agent_kitty "<PROYECTO> · <subtarea>" <dir-aislado> /tmp/orq_<slug>.md
@@ -113,7 +136,8 @@ la flota, se vea en la TUI `fleetview` y sea conmutable con `/fleet focus`:
- `launch_claude_agent_kitty_bash_infra(title, directory, prompt_file)` lanza el secundario con el
comando canónico (`setsid nohup kitty … zsh -ic 'claude --dangerously-skip-permissions … ; exec
zsh'`) que sobrevive al cierre de la terminal padre y deja una shell viva al terminar el claude;
devuelve el log de arranque (`/tmp/orq_<slug>_kitty.log`). Usa kitty solo fuera de un perfil fleet.
devuelve el log de arranque (`/tmp/orq_<slug>_kitty.log`). Usa kitty solo cuando NO estás en tmux
(`$TMUX` vacía); estando en una flota, kitty fragmenta la flota — usa `spawn_fleet_agent`.
### 3. Aislamiento git obligatorio por secundario (regla de oro)
@@ -204,8 +228,8 @@ Cuando un secundario termina (rama pusheada + report verde):
**Todo agente de trabajo va como terminal visible del fleet, NUNCA como sub-agente headless del Agent tool.** Un sub-agente headless corre invisible: no sale en `fleetview`, no es conmutable con `/fleet focus` ni se puede retomar. Jerarquía al lanzar un agente:
1. **En perfil fleet** (`$FLEET_SOCKET`, lo normal) → `spawn_fleet_agent` (window de la flota tmux).
2. **Fuera de un perfil fleet** → kitty con `launch_claude_agent_kitty`.
1. **Dentro de tmux/flota** (`$TMUX` seteada — comprueba con `detect_fleet_context`, NO con `$FLEET_SOCKET`) → `spawn_fleet_agent` (auto-detecta el socket; window de la flota tmux).
2. **Fuera de tmux** (`in_tmux=false`) → kitty con `launch_claude_agent_kitty`.
3. **Agent tool (sub-agente headless)** → **PROHIBIDO para lanzar un agente de trabajo.** SOLO para
utilidades internas read-only tuyas que devuelven un resultado y mueren: el **verificador**
adversarial de un cierre, el **splitter** (`Plan`), o una búsqueda puntual (`Explore`).
@@ -268,10 +292,10 @@ git -C ~/fn_registry worktree add /tmp/orq_capdoc -b orq/cap-deploy master
# /tmp/orq_health.md → trabaja en apps/kanban (sub-repo propio), rama issue/health, push, report.
# /tmp/orq_capdoc.md → trabaja SOLO en /tmp/orq_capdoc (worktree), rama orq/cap-deploy, push, report.
# 4. Lanzar ambos (window de la flota si hay $FLEET_SOCKET; aquí kitty fallback). Tras conocer su
# sessionId, escribe su DoD-contrato con set_dod_contract.
./fn run launch_claude_agent_kitty "kanban · health endpoint" ~/fn_registry/apps/kanban /tmp/orq_health.md
./fn run launch_claude_agent_kitty "fn_registry · doc deploy" /tmp/orq_capdoc /tmp/orq_capdoc.md
# 4. Lanzar ambos como windows de la flota (estás en tmux → spawn_fleet_agent auto-detecta el socket
# de $TMUX; kitty SOLO si in_tmux=false). Tras conocer su sessionId, escribe su DoD-contrato.
./fn run spawn_fleet_agent --cwd ~/fn_registry/apps/kanban --prompt-file /tmp/orq_health.md --title "kanban · health endpoint" --parent "$MI_SESSION_ID"
./fn run spawn_fleet_agent --cwd /tmp/orq_capdoc --prompt-file /tmp/orq_capdoc.md --title "fn_registry · doc deploy" --parent "$MI_SESSION_ID"
# 5. Seguir cada turno: drena FLEET-STATE, verifica DICE_TERMINADO, nudge a ESTANCADO, lee reports/ (maquinaria en orchestration.md).
+1 -1
View File
@@ -13,7 +13,7 @@ IDs: `{name}_{lang}_{domain}` (ej: `filter_slice_go_core`). Predictibilidad alta
Lista no exhaustiva pero cubre la mayoria. Anadir aqui (y al validator en `apps/registry_mcp/naming.go`) cuando se introduzca un verbo nuevo recurrente.
`get, set, list, find, search, show, read, load, fetch, scan, query, lookup, parse, format, encode, decode, marshal, unmarshal, serialize, deserialize, validate, check, ensure, verify, audit, diagnose, test, match, filter, map, reduce, sort, group, count, sum, aggregate, compute, calculate, score, rank, cluster, classify, detect, init, create, make, build, generate, scaffold, install, setup, configure, register, add, insert, append, prepend, update, upsert, modify, edit, patch, replace, delete, remove, clear, drop, prune, clean, copy, move, rename, sync, clone, extract, inject, import, export, send, post, put, call, dispatch, exec, run, launch, relaunch, start, stop, kill, restart, reboot, redeploy, deploy, open, close, connect, disconnect, login, logout, authenticate, enable, disable, toggle, lock, unlock, propose, promote, deprecate, approve, reject, emit, render, draw, paint, serve, host, pull, push, checkout, commit, tag, merge, rebase, watch, monitor, observe, log, trace, profile, benchmark, snapshot, backup, restore, archive, compress, decompress, hash, encrypt, decrypt, sign, taskkill, recopile, vault, propose, apply, gather, collect, fold, head, tail, take, drop, slice, chunk, batch, debounce, throttle, retry, await, sleep, ping, kill, prime, warm, refresh, invalidate, reload, reset, rollback, fork, spawn, daemon, observe, plot, draw, capture, replay, recopilate`
`get, set, list, find, search, show, read, load, fetch, scan, query, lookup, parse, format, encode, decode, marshal, unmarshal, serialize, deserialize, validate, check, ensure, verify, audit, diagnose, test, match, filter, map, reduce, sort, group, count, sum, aggregate, compute, calculate, score, rank, cluster, classify, detect, init, create, make, build, generate, scaffold, install, setup, configure, register, add, insert, append, prepend, update, upsert, modify, edit, patch, replace, delete, remove, clear, drop, prune, clean, copy, move, rename, sync, clone, extract, inject, import, export, send, post, put, call, dispatch, exec, run, launch, relaunch, start, stop, kill, restart, reboot, redeploy, deploy, open, close, connect, disconnect, login, logout, authenticate, enable, disable, toggle, lock, unlock, propose, promote, deprecate, approve, reject, emit, render, draw, paint, serve, host, pull, push, checkout, commit, tag, merge, rebase, watch, monitor, observe, log, trace, profile, benchmark, snapshot, backup, restore, archive, compress, decompress, hash, encrypt, decrypt, sign, taskkill, recopile, vault, propose, apply, gather, collect, fold, head, tail, take, drop, slice, chunk, batch, debounce, throttle, retry, await, sleep, ping, kill, prime, warm, refresh, invalidate, reload, reset, rollback, fork, spawn, daemon, observe, plot, draw, capture, replay, recopilate, save, bump, harvest, judge, critique`
### Excepciones
+29 -4
View File
@@ -27,7 +27,7 @@ La fuente de verdad del mapeo PID→sessionId→cwd son los archivos `~/.claude/
`goal`, `phase`, `status`, `tmux_window` y `age`/`idle_seconds` la da el CLI de la app fleetview:
```bash
apps/fleetview/fleetview list --json # flota tipada: session_id, goal, phase, status, tmux_window, age, idle_seconds
apps/fleetview/fleetview list --json # flota tipada: session_id, goal, phase, status, pane_id ("%N", el id estable), tmux_window ("@N", interno para focus/send-keys), age, idle_seconds
apps/fleetview/fleetview list # tabla legible (incluye columna AGE)
```
@@ -58,7 +58,7 @@ devuelven salida estructurada y se registran en la telemetría como cualquier MC
| Operación de la flota | Tool MCP (preferido) | Fallback `./fn run` / binario |
|---|---|---|
| Listar la flota tipada (session_id, goal, phase, status, **role, dod_contract, dod_status**, tmux_window, age, idle_seconds) | `mcp__orchestrator__fleet_list` | `apps/fleetview/fleetview list --json` (NO `./fn run list_claude_fleet`) |
| Listar la flota tipada (session_id, goal, phase, status, **role, dod_contract, dod_status**, **pane_id** (el id estable), age, idle_seconds) | `mcp__orchestrator__fleet_list` | `apps/fleetview/fleetview list --json` (NO `./fn run list_claude_fleet`) |
| Drenar la cola de transiciones del watcher (agrupada por clasificación + urgentes) | `mcp__orchestrator__fleet_drain` (`advance` true consume, false hace peek) | `./fn run drain_fleet_events` |
| Clasificar el estado de terminación de UN agente (RECLAMA/MAL_LANZADO/DICE_TERMINADO/ESTANCADO/TRABAJANDO) | `mcp__orchestrator__fleet_classify` | (Go con tests; lo consume el watcher, no se invoca a mano) |
| Escribir el DoD-contrato fijo (`dod_contract`/`dod_status`) en el `goal.json` de un agente | `mcp__orchestrator__fleet_set_dod` | `./fn run set_dod_contract` |
@@ -69,6 +69,15 @@ Ventaja extra de `fleet_list`: expone `role`/`dod_contract`/`dod_status` directa
vacíos desde el sidecar `goal.json`), así que la regla "No te vigiles a ti mismo" se resuelve sin leer
el sidecar a mano — filtra por el `role` que ya trae cada fila.
**Identifica a cada agente por su `pane_id` ("%N").** Es el id ESTABLE de por vida del pane: el
`fleet_list` del MCP lo expone como el único identificador y **omite a propósito el `tmux_window`
("@N")**, que migra cuando el focus-swap mueve el pane entre windows y por eso nunca debe usarse ni
mostrarse como id (la persona no tiene referencia mental de "@4"). Las operaciones internas que sí
necesitan la window viva — `focus`, `send-keys`/nudge y `kill` — la resuelven BAJO DEMANDA contra
tmux a partir del session_id/PID (`kill_fleet_agent` y `fleetview focus` la recalculan por llamada);
para el nudge, lee `tmux_window` del binario `fleetview list --json` (que sí lo conserva como campo
interno), nunca del payload del MCP.
Mantén una **tabla de seguimiento**, una fila por secundario, y actualízala en cada turno:
| slug | título kitty | PID | cwd / dir aislado | rama | log | report | estado |
@@ -123,6 +132,21 @@ existe, degrada limpio sin romper el turno (la línea de rol se sigue emitiendo)
clasificación sigues drenando (abajo). El resumen lo produce `summarize_fleet_transitions_py_infra`
sobre el feed del watcher.
Además, el mismo hook inyecta una línea **`CONTEXTO FLEET`** cuando detecta (vía
`detect_fleet_context_bash_infra`, leyendo **`$TMUX`**, no `$FLEET_SOCKET`) que el orquestador vive
dentro de una flota tmux:
```
CONTEXTO FLEET: estás dentro de la fleet tmux socket=<X> session=<Y>. Lanza ejecutores con spawn_fleet_agent (auto-detecta el socket) — NUNCA kitty/launch_claude_agent_kitty estando aquí.
```
Es el recordatorio que evita el bug de caer a kitty cuando `$FLEET_SOCKET` viene vacía pese a estar
en la flota: la detección de contexto se hace por `$TMUX` (señal fiable que todo proceso dentro de
tmux tiene siempre), no por `$FLEET_SOCKET` (a veces ausente en un claude resumido/relanzado). Esta
parte del hook no necesita venv ni python (solo bash + tmux) y se emite antes del bloque
`FLEET-STATE`; si el detector falta o `$TMUX` está vacía, simplemente no se emite la línea (turno
intacto).
Gotcha conocido: el bloque `FLEET-STATE` (peek pasivo) lista transiciones de TODA la flota, incluidas
las de otros orquestadores y sus ejecutores. Si hay más de un orquestador activo, filtra por tu propia
familia de agentes (los que tú lanzaste) — igual que en "No te vigiles a ti mismo" más abajo. El **push
@@ -302,7 +326,8 @@ en lote.
| `summarize_fleet_transitions_py_infra` | Resumir las transiciones del feed en una línea (`terminados/reclaman/estancados`); alimenta el bloque `FLEET-STATE` que el hook `UserPromptSubmit` inyecta cada turno |
| `classify_fleet_termination_go_infra` | Clasificar el estado de terminación de un agente (RECLAMA/MAL_LANZADO/DICE_TERMINADO/ESTANCADO/TRABAJANDO) — lo usa el watcher |
| `list_claude_fleet_go_infra` | Fleet tipado con goal/phase/`role` + `dod_contract`/`dod_status` + `tmux_window` (alimenta `/fleet`, el watcher y el tool `fleet_list`). **Invócala por el tool `mcp__orchestrator__fleet_list` (preferido) o el binario `apps/fleetview/fleetview list --json`**, NUNCA por `./fn run` (la despacha como `go test`). El JSON del CLI **ya expone** `role`/`dod_contract`/`dod_status` (`""` si el `goal.json` no los declara); el tool MCP además rellena los vacíos desde `~/.claude/goals/<session_id>.json` |
| `spawn_fleet_agent_bash_infra` | Lanzar un ejecutor (o el orquestador) como window de la flota tmux — preferido sobre kitty cuando hay perfil fleet. `--parent <tu-sessionId>` atribuye el ejecutor a ti y habilita el push activo del watcher |
| `detect_fleet_context_bash_infra` | Detectar si estás en una flota tmux derivando socket/session de `$TMUX` (señal fiable), con fallback a `$FLEET_SOCKET`. Devuelve JSON `{in_fleet,in_tmux,socket,session,source}`. Lo usan `spawn_fleet_agent` (auto-detección de socket) y el hook (línea `CONTEXTO FLEET`) para no caer a kitty estando en la flota |
| `spawn_fleet_agent_bash_infra` | Lanzar un ejecutor (o el orquestador) como window de la flota tmux — preferido sobre kitty siempre que estés en tmux. **Auto-detecta socket/session de `$TMUX`** (vía `detect_fleet_context`) si no se pasan `--socket`/`--session` (los explícitos priman). `--parent <tu-sessionId>` atribuye el ejecutor a ti y habilita el push activo del watcher |
| `mark_claude_role_py_infra` | Marcar `role` (orchestrator/executor) en el goal.json de un Claude resolviendo PID→sessionId |
| `mark_claude_parent_py_infra` | Marcar `parent_orchestrator` (sessionId del orquestador que lo lanzó) en el goal.json de un ejecutor resolviendo PID→sessionId. Lo invoca `spawn_fleet_agent --parent`; habilita el routing del watcher al pane del orquestador padre |
| `kill_fleet_agent_bash_infra` | Cierre dirigido de UN ejecutor: SIGTERM al claude + kill-window de su window tmux. Guards anti-orquestador y anti-self. Lo usa el orquestador para liberar el slot idle tras verificar `met` (auto-kill) |
@@ -311,7 +336,7 @@ en lote.
**Cómo invocarlas.** Las Bash y Python del grupo se lanzan con `./fn run <id> [args]` (verificado:
`list_claude_agents`, `drain_fleet_events`, `reboot_all_claudes`, `set_dod_contract`,
`mark_claude_role`, `mark_claude_parent`, `kill_fleet_agent`, `launch_claude_agent_kitty`,
`spawn_fleet_agent`). Las **Go con tests** NO: `./fn run` las despacha como `go test`. Por eso
`spawn_fleet_agent`, `detect_fleet_context`). Las **Go con tests** NO: `./fn run` las despacha como `go test`. Por eso
`list_claude_fleet_go_infra` se usa por el binario `apps/fleetview/fleetview list --json`, y
`classify_fleet_termination_go_infra` la consume el watcher embebido en fleetview (no se invoca a
mano).
@@ -46,6 +46,24 @@ ROLE=""
printf '%s\n' "MODO ORQUESTADOR activo (role=orchestrator)."
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$HOME/fn_registry}"
# Contexto de flota: recordarle al orquestador en que socket/sesion tmux vive,
# para que lance ejecutores con spawn_fleet_agent (auto-detecta el socket) y
# NUNCA caiga a kitty estando dentro de la flota. La deteccion va por $TMUX
# (senal fiable), no por $FLEET_SOCKET (a veces vacia en un claude resumido/
# relanzado). No necesita venv ni python: solo bash + tmux. Degrada limpio: si
# el detector falta o falla, simplemente no se emite la linea (turno intacto).
DETECTOR="$PROJECT_DIR/bash/functions/infra/detect_fleet_context.sh"
if [ -f "$DETECTOR" ]; then
CTX=$(bash "$DETECTOR" 2>/dev/null || true)
IN_FLEET=$(printf '%s' "$CTX" | sed -n 's/.*"in_fleet":\(true\|false\).*/\1/p')
F_SOCKET=$(printf '%s' "$CTX" | sed -n 's/.*"socket":"\([^"]*\)".*/\1/p')
F_SESSION=$(printf '%s' "$CTX" | sed -n 's/.*"session":"\([^"]*\)".*/\1/p')
if [ "$IN_FLEET" = "true" ]; then
printf 'CONTEXTO FLEET: estas dentro de la fleet tmux socket=%s session=%s. Lanza ejecutores con spawn_fleet_agent (auto-detecta el socket) — NUNCA kitty/launch_claude_agent_kitty estando aqui.\n' "$F_SOCKET" "$F_SESSION"
fi
fi
PY="$PROJECT_DIR/python/.venv/bin/python3"
{ [ -x "$PY" ] && [ -d "$PROJECT_DIR/python/functions" ]; } || exit 0
+2 -1
View File
@@ -8,7 +8,8 @@
},
"enabledMcpjsonServers": [
"registry",
"jupyter"
"jupyter",
"orchestrator"
],
"hooks": {
"PreToolUse": [
+1
View File
@@ -37,6 +37,7 @@ python/.venv/
# Externalized apps and analysis (each is its own Gitea repo)
apps/*/
cpp/apps/*/
analysis/*/
# Projects (each is its own git repo, only project.md templates are versioned)
+4
View File
@@ -11,6 +11,10 @@
"jupyter": {
"command": "bash",
"args": ["-c", "exec bash \"$(git rev-parse --show-toplevel)/bash/functions/infra/jupyter_mcp_serve.sh\""]
},
"godot": {
"type": "http",
"url": "http://127.0.0.1:8000/mcp"
}
}
}
@@ -0,0 +1,98 @@
---
name: detect_fleet_context
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: "detect_fleet_context() -> JSON {in_fleet,in_tmux,socket,session,source}"
description: "Detecta de forma robusta si el proceso corre dentro de una flota tmux FleetView, derivando socket y sesion de $TMUX (senal fiable) en vez de $FLEET_SOCKET (fragil, a veces vacia en un claude resumido/relanzado). Salida JSON con in_fleet/in_tmux/socket/session/source."
tags: [orchestration, fleet, tmux, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
tested: false
file_path: "bash/functions/infra/detect_fleet_context.sh"
params:
- name: "(ninguno)"
desc: "No recibe argumentos. Lee el entorno ($TMUX, con fallback a $FLEET_SOCKET/$FLEET_SESSION) y consulta el servidor tmux."
output: "JSON en stdout: {\"in_fleet\":bool, \"in_tmux\":bool, \"socket\":str, \"session\":str, \"source\":\"tmux|fleet_socket|none\"}. in_tmux=true basta para lanzar una window; in_fleet es la senal semantica de 'estoy en una flota'."
---
# detect_fleet_context
Detecta el contexto de flota del proceso actual sin depender de `$FLEET_SOCKET`.
## Por que existe
La deteccion de "estoy en una flota FleetView" dependia de la variable de
entorno `$FLEET_SOCKET`, que `launch_fleetclaude` exporta con
`tmux set-environment -g`. Esa variable solo llega a los procesos que tmux
arranca **despues** de setearla: un `claude` relanzado o resumido a mano puede
no heredarla y `$FLEET_SOCKET` queda vacia, aunque ese claude SI viva en una
window de la flota. Cuando eso pasa, el modo orquestador cae al fallback kitty
(`launch_claude_agent_kitty`) y lanza ejecutores en terminales sueltas en vez de
como windows de la flota.
La senal **fiable** es `$TMUX`: todo proceso dentro de tmux la tiene SIEMPRE, con
el formato `/tmp/tmux-<uid>/<socket>,<server_pid>,<client_id>`. De ahi se extrae
el socket (basename del path antes de la primera coma) y, con
`tmux -L <socket> display-message -p '#{session_name}'`, la sesion actual.
## Salida
```json
{"in_fleet":true,"in_tmux":true,"socket":"fleet3","session":"fleet3","source":"tmux"}
```
| Campo | Significado |
|---|---|
| `in_fleet` | Heuristica de "estoy en una flota". `true` si en tmux Y (socket/sesion casan `fleet`, O hay window `fleetview`, O la sesion tiene >= 2 windows). |
| `in_tmux` | `true` si el proceso esta dentro de tmux. Basta para lanzar una window (mejor que caer a kitty). |
| `socket` | Socket tmux derivado de `$TMUX` (o de `$FLEET_SOCKET` en fallback). |
| `session` | Sesion tmux actual resuelta con `display-message` (fallback a `$FLEET_SESSION` o al socket). |
| `source` | `tmux` (derivado de `$TMUX`), `fleet_socket` (fallback), o `none`. |
## Ejemplo
```bash
# Dentro de una window de la flota fleet3:
bash bash/functions/infra/detect_fleet_context.sh
# {"in_fleet":true,"in_tmux":true,"socket":"fleet3","session":"fleet3","source":"tmux"}
# Fuera de tmux, sin FLEET_SOCKET:
env -u TMUX -u FLEET_SOCKET bash bash/functions/infra/detect_fleet_context.sh
# {"in_fleet":false,"in_tmux":false,"socket":"","session":"","source":"none"}
# Parsear el socket con jq para pasarlo a spawn_fleet_agent:
ctx=$(bash bash/functions/infra/detect_fleet_context.sh)
sock=$(printf '%s' "$ctx" | jq -r .socket)
```
## Cuando usarla
Antes de lanzar un ejecutor de la flota: llama a esta funcion para saber si
estas dentro de una flota tmux. Si `in_tmux=true`, lanza con `spawn_fleet_agent`
(que ya la usa para auto-detectar el socket); NUNCA caigas a kitty. Tambien la
usa el hook `hook_fleet_state_inject.sh` para recordarle al orquestador el socket
de su flota cada turno.
## Gotchas
- Es **impura**: consulta el servidor tmux (`display-message`, `list-windows`).
No modifica estado.
- `in_fleet` es **heuristico** a proposito. Para LANZAR basta `in_tmux=true`
(lanzar una window en cualquier tmux supera a una kitty suelta). `in_fleet` es
solo la senal semantica que consume el hook y la doctrina.
- Fallback `source=fleet_socket`: si `$TMUX` no esta pero `$FLEET_SOCKET` si,
devuelve `socket`/`session` de esas vars con `in_tmux=false`. Un
`tmux -L <socket> new-window` puede seguir funcionando si el servidor existe,
aunque el caller no este attached.
- No requiere `jq` ni python: emite el JSON con `printf`, para poder ser el
detector base que invocan hooks y otras funciones bash.
- Si `tmux` no esta instalado y `$TMUX` esta seteada (raro), `socket` se deriva
igual de `$TMUX` pero `session` cae al fallback y `in_fleet` no se puede afinar
por windows.
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# detect_fleet_context — detecta de forma robusta si el proceso actual corre
# dentro de una sesion tmux de una flota FleetView, derivando el socket y la
# sesion de la variable de entorno $TMUX (senal fiable) en vez de depender de
# $FLEET_SOCKET (que a veces viene vacia en el entorno de un claude resumido o
# relanzado, aunque ese claude SI viva en una window de la flota).
#
# Por que $TMUX y no $FLEET_SOCKET:
# launch_fleetclaude exporta FLEET_SOCKET/FLEET_SESSION con `tmux
# set-environment -g`. Esa variable solo llega a los procesos que tmux arranca
# DESPUES de setearla; un claude relanzado o resumido a mano puede no heredarla
# y entonces $FLEET_SOCKET queda vacia. En cambio, todo proceso que corre
# dentro de tmux tiene SIEMPRE $TMUX seteada, con el formato:
# /tmp/tmux-<uid>/<socket>,<server_pid>,<client_id>
# De ahi se extrae el socket (basename del path antes de la primera coma) y,
# con `tmux -L <socket> display-message -p '#{session_name}'`, la sesion
# actual. Eso identifica el contexto fleet sin depender de $FLEET_SOCKET.
#
# Salida: JSON en stdout con los campos:
# in_fleet : true|false — heuristica de "estoy en una flota" (ver criterio).
# in_tmux : true|false — estoy dentro de tmux (basta para lanzar una window).
# socket : nombre del socket tmux derivado ("" si no hay).
# session : nombre de la sesion tmux actual ("" si no se resuelve).
# source : "tmux" | "fleet_socket" | "none" — de donde se derivo el contexto.
#
# Criterio de "flota reconocible" (in_fleet): estar en tmux (in_tmux) Y que se
# cumpla al menos uno, de mas fiable a menos:
# 1. el socket o la sesion casan el patron de flota (contienen "fleet"), o
# 2. existe una window llamada "fleetview" (la TUI de la flota), o
# 3. la sesion tiene >= 2 windows (una flota agrupa varios agentes en windows).
# Es heuristico a proposito: para LANZAR un ejecutor basta con in_tmux (lanzar
# una window en cualquier tmux es mejor que caer a una kitty suelta); in_fleet es
# la senal semantica que consume el hook del orquestador y la doctrina.
#
# Funcion IMPURA: lee el entorno y consulta el servidor tmux (display-message,
# list-windows). No modifica estado. Degrada limpio: si tmux no esta o falla
# cualquier consulta, devuelve los campos que pueda y nunca aborta con error.
set -euo pipefail
IFS=$' \t\n'
detect_fleet_context() {
local socket="" session="" source="none"
local in_tmux="false" in_fleet="false"
if [[ -n "${TMUX:-}" ]]; then
in_tmux="true"
source="tmux"
# $TMUX = /tmp/tmux-<uid>/<socket>,<server_pid>,<client_id>
# Socket = basename del path antes de la primera coma.
local tmux_path="${TMUX%%,*}"
socket="$(basename "$tmux_path" 2>/dev/null || true)"
# Sesion actual: tmux resuelve el cliente via $TMUX. -L fija el socket.
if command -v tmux >/dev/null 2>&1 && [[ -n "$socket" ]]; then
session="$(tmux -L "$socket" display-message -p '#{session_name}' 2>/dev/null || true)"
fi
# Fallback de sesion si display-message no resolvio nada.
[[ -z "$session" ]] && session="${FLEET_SESSION:-$socket}"
elif [[ -n "${FLEET_SOCKET:-}" ]]; then
# No estamos en tmux pero hay FLEET_SOCKET exportada: usarla como ultimo
# recurso (un claude que perdio $TMUX pero conserva la env del perfil).
in_tmux="false"
source="fleet_socket"
socket="${FLEET_SOCKET}"
session="${FLEET_SESSION:-$socket}"
fi
# Heuristica in_fleet: solo tiene sentido si estamos en tmux.
if [[ "$in_tmux" == "true" && -n "$socket" ]]; then
local sl="${socket,,}" sesl="${session,,}"
if [[ "$sl" == *fleet* || "$sesl" == *fleet* ]]; then
in_fleet="true"
elif command -v tmux >/dev/null 2>&1; then
# Construir el target de sesion sin trucos de expansion fragiles.
local -a tgt=()
[[ -n "$session" ]] && tgt=(-t "$session")
# window "fleetview" presente => flota.
if tmux -L "$socket" list-windows "${tgt[@]}" \
-F '#{window_name}' 2>/dev/null | grep -qx 'fleetview'; then
in_fleet="true"
else
# >= 2 windows => agrupacion tipo flota.
local nwin
nwin="$(tmux -L "$socket" list-windows "${tgt[@]}" \
-F x 2>/dev/null | wc -l | tr -d ' ')"
[[ "${nwin:-0}" -ge 2 ]] && in_fleet="true"
fi
fi
fi
# JSON sin dependencias (jq/python no requeridos: este es el detector base).
printf '{"in_fleet":%s,"in_tmux":%s,"socket":"%s","session":"%s","source":"%s"}\n' \
"$in_fleet" "$in_tmux" "$socket" "$session" "$source"
return 0
}
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
detect_fleet_context "$@"
fi
+7 -4
View File
@@ -3,10 +3,10 @@ name: kill_fleet_agent
kind: function
lang: bash
domain: infra
version: 1.0.0
version: 1.1.0
purity: impure
signature: "kill_fleet_agent <sessionId|PID> [--socket <s>] [--dry-run]"
description: "Cierre limpio y dirigido de UN ejecutor de la flota tmux. Dado un sessionId (exacto o prefijo) o un PID, manda SIGTERM al proceso claude del ejecutor (cierre limpio, recuperable con claude --resume) y cierra su window tmux (kill-window) en el socket del perfil FleetView. Lo usa el orquestador para liberar el slot idle de cada ejecutor en cuanto verifica que su DoD-contrato esta met. Guards de seguridad: NUNCA mata a un agente con role=orchestrator (leido de su goal.json) ni a la sesion que invoca la funcion (resuelve su propio PID de claude por los ancestros de /proc). Por defecto EJECUTA; --dry-run imprime el plan sin tocar nada. Es el cierre dirigido a UN agente, frente a reboot_all_claudes que opera sobre toda la flota."
description: "Cierre limpio y dirigido de UN ejecutor de la flota tmux. Dado un sessionId (exacto o prefijo) o un PID, manda SIGTERM al proceso claude del ejecutor (cierre limpio, recuperable con claude --resume) y cierra su window tmux en el socket del perfil FleetView. Lo usa el orquestador para liberar el slot idle de cada ejecutor en cuanto verifica que su DoD-contrato esta met. Tres guards de seguridad: NUNCA mata a un agente con role=orchestrator (leido de su goal.json); NUNCA a la sesion que invoca la funcion (resuelve su propio PID de claude por los ancestros de /proc); y NUNCA cierra la window que aloja la TUI fleetview o la window 'console' con kill-window (eso se llevaria el panel de control por delante) — en ese caso cierra SOLO el pane del target con kill-pane y preserva la TUI. Por defecto EJECUTA; --dry-run imprime el plan (incluida la accion kill-pane vs kill-window) sin tocar nada. Es el cierre dirigido a UN agente, frente a reboot_all_claudes que opera sobre toda la flota."
tags: [fleet, claude-fleet, orchestration, tmux, kill, infra]
uses_functions: []
uses_types: []
@@ -17,6 +17,7 @@ tests:
- "golden: ejecutor por sessionId, PID y prefijo se resuelve y dry-run imprime el plan"
- "guard: matar un role=orchestrator devuelve rc=3 y se niega"
- "guard: matar la sesion actual (self) devuelve rc=3 y se niega"
- "guard3: predicado _fleet_window_hosts_tui detecta window 'console' o pane fleetview"
- "error: target no resuelto rc=2; sin target rc=2"
test_file_path: "bash/functions/infra/kill_fleet_agent_test.sh"
params:
@@ -55,11 +56,13 @@ Cierra de forma dirigida UN ejecutor de la flota tmux: SIGTERM al proceso `claud
- **Impura y destructiva**: manda SIGTERM y cierra una window tmux. Por defecto EJECUTA (es el caso de uso del bot: cerrar un ejecutor ya verificado `met`); usa `--dry-run` para inspeccionar antes.
- **Guard anti-orquestador**: si el goal.json del target tiene `role=orchestrator`, rehúsa con exit 3. Evita decapitar la flota por error. El `role` se lee de `~/.claude/goals/<sessionId>.json` (lo escribe `mark_claude_role`).
- **Guard anti-self**: resuelve el PID de `claude` de la sesión actual subiendo por los ancestros de `/proc`; si el target coincide, rehúsa con exit 3 ("No me suicido"). Es el equivalente dirigido de la regla "nunca `pkill claude`".
- **Resolución de la window**: usa `tmux -L <socket> list-panes -a` y casa `pane_pid == PID`. Funciona porque `spawn_fleet_agent` arranca el ejecutor con `exec claude`, así el `pane_pid` ES el PID de claude. Si no hay socket/tmux, la window queda "(no resuelta)" y solo se manda el SIGTERM (best-effort, no falla).
- **Guard 3 — anti-TUI/console (no decapitar el panel)**: antes de cerrar nada, comprueba si la window del target **aloja la TUI fleetview** (algún pane corre el binario `fleetview`) o se llama **`console`**. El layout FleetView mete la TUI y un Claude en la misma window `console`, y los focus-swaps (`join-pane`) pueden meter al ejecutor target en esa window; un `kill-window` ahí se llevaría la TUI por delante (causa del fallo descrito en `fleetview` v0.4.3). En ese caso la función NO usa `kill-window`: manda el SIGTERM al claude y cierra **solo su pane** con `kill-pane`, preservando el pane de la TUI. El plan (y el `--dry-run`) lo refleja como `accion: kill-pane … (aloja la TUI/console)` vs `accion: kill-window …`. El predicado es la función interna `_fleet_window_hosts_tui` (testeada). Se mantiene inline (no función propia del registry) por estar acoplada a este flujo y para no dejar una capacidad huérfana (KISS).
- **Resolución de la window y el pane**: usa `tmux -L <socket> list-panes -a` y casa `pane_pid == PID`, capturando `window_id`, `pane_id` y `window_name`. Funciona porque `spawn_fleet_agent` arranca el ejecutor con `exec claude`, así el `pane_pid` ES el PID de claude. Si no hay socket/tmux, la window queda "(no resuelta)" y solo se manda el SIGTERM (best-effort, no falla).
- **SIGTERM, no SIGKILL**: cierre limpio para que Claude Code persista su sesión; el trabajo se puede retomar con `claude --resume <sessionId>`.
- **Requiere `jq`** para leer los JSON de sessions/goals.
- **Overrides de entorno solo para tests**: `FN_FLEET_SESSIONS_DIR`, `FN_FLEET_GOALS_DIR` y `FN_FLEET_SELF_PID` redirigen los directorios y fuerzan el PID propio; no usarlos en operación normal.
## Capability growth log
(v1.0.0 — sin cambios todavía.)
- v1.1.0 (2026-06-24) — **Guard 3 anti-TUI/console** (elimina un gotcha conocido). Antes, si un focus-swap metía al ejecutor target en la window `console` (la que aloja la TUI fleetview), `kill-window` cerraba la TUI por error. Ahora, cuando la window del target aloja la TUI (pane `fleetview`) o se llama `console`, se cierra solo el pane del target con `kill-pane` y la TUI sobrevive; el resto de windows siguen cerrándose con `kill-window`. Predicado interno `_fleet_window_hosts_tui` con tests. Es la causa raíz que complementa el auto-respawn de la TUI (`supervise_fleetview_tui`).
- v1.0.0 — versión inicial.
+74 -8
View File
@@ -26,6 +26,25 @@
set -euo pipefail
IFS=$' \t\n'
# Predicado (puro respecto a tmux): dada una window — su nombre y el texto de sus
# panes en formato "<pane_pid> <pane_current_command>" (una linea por pane) —
# decide si esa window ALOJA la TUI fleetview o es la window 'console' del perfil.
# Si es asi, cerrar la window entera con kill-window se llevaria la TUI por
# delante; el caller debe cerrar solo el pane del target con kill-pane.
# - Nombre de window 'console' = la window del panel FleetView por convencion
# del launcher (y a donde el focus-swap ancla la TUI, ver fleetview v0.4.3).
# - Algun pane corre el binario 'fleetview' (pane_current_command) = la TUI
# vive ahi aunque la window se haya renombrado.
# Devuelve 0 si aloja la TUI/console, 1 si no.
_fleet_window_hosts_tui() {
local window_name="${1:-}" panes_text="${2:-}"
[[ "$window_name" == "console" ]] && return 0
if printf '%s\n' "$panes_text" | awk '{print $2}' | grep -qx 'fleetview'; then
return 0
fi
return 1
}
kill_fleet_agent() {
local target="" socket="" dry=0
@@ -155,27 +174,65 @@ USAGE
fi
# -----------------------------------------------------------------------
# Resolver la window tmux del PID en el socket (pane_pid == claude por el
# `exec claude` de spawn_fleet_agent). Best-effort: vacio si no hay socket.
# Resolver la window tmux Y el pane del PID en el socket (pane_pid == claude
# por el `exec claude` de spawn_fleet_agent). Capturamos window_id, pane_id y
# window_name juntos. Best-effort: vacio si no hay socket.
# -----------------------------------------------------------------------
local window=""
local window="" pane="" wname=""
if command -v tmux >/dev/null 2>&1; then
window="$(tmux -L "$socket" list-panes -a -F '#{pane_pid} #{window_id}' 2>/dev/null \
| awk -v p="$pid" '$1==p {print $2; exit}' || true)"
local line
line="$(tmux -L "$socket" list-panes -a -F '#{pane_pid} #{window_id} #{pane_id} #{window_name}' 2>/dev/null \
| awk -v p="$pid" '$1==p {print $2, $3, $4; exit}' || true)"
if [[ -n "$line" ]]; then
window="$(awk '{print $1}' <<<"$line")"
pane="$(awk '{print $2}' <<<"$line")"
wname="$(awk '{print $3}' <<<"$line")"
fi
fi
# -----------------------------------------------------------------------
# Guard 3 — anti-TUI/console: si la window del target aloja la TUI fleetview
# o es la window 'console' del perfil, NO cerramos la window entera (eso se
# llevaria la TUI), sino solo el pane del target con kill-pane. El layout
# FleetView mete la TUI y un Claude en la misma window 'console', y los
# focus-swaps (join-pane) pueden meter al ejecutor target en esa window.
# -----------------------------------------------------------------------
local hosts_tui=0
if [[ -n "$window" ]] && command -v tmux >/dev/null 2>&1; then
local panes_text
panes_text="$(tmux -L "$socket" list-panes -t "$window" -F '#{pane_pid} #{pane_current_command}' 2>/dev/null || true)"
if _fleet_window_hosts_tui "$wname" "$panes_text"; then
hosts_tui=1
fi
fi
# Accion sobre la window/pane segun lo resuelto y el Guard 3.
local action
if [[ -z "$window" ]]; then
action="solo SIGTERM (window no resuelta)"
elif [[ "$hosts_tui" -eq 1 ]]; then
if [[ -n "$pane" ]]; then
action="kill-pane $pane (window '${wname:-$window}' aloja la TUI/console; se preserva la TUI)"
else
action="solo SIGTERM (window '${wname:-$window}' aloja la TUI y no se resolvio el pane; window preservada)"
fi
else
action="kill-window $window"
fi
# -----------------------------------------------------------------------
# Plan (se imprime siempre).
# -----------------------------------------------------------------------
echo "kill_fleet_agent — target: $target PID: $pid sessionId: ${sid:-?} role: ${role:-executor} socket: $socket window: ${window:-(no resuelta)}"
echo "kill_fleet_agent — target: $target PID: $pid sessionId: ${sid:-?} role: ${role:-executor} socket: $socket window: ${window:-(no resuelta)} pane: ${pane:-?} accion: $action"
if [[ "$dry" -eq 1 ]]; then
echo "DRY-RUN: no se ha matado el proceso ni cerrado la window."
echo "DRY-RUN: no se ha matado el proceso ni cerrado nada."
return 0
fi
# -----------------------------------------------------------------------
# Ejecutar: SIGTERM al claude (cierre limpio) + kill-window (idempotente).
# Ejecutar: SIGTERM al claude (cierre limpio) + cierre de pane/window segun
# el Guard 3 (idempotente).
# -----------------------------------------------------------------------
if kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
@@ -185,9 +242,18 @@ USAGE
fi
if [[ -n "$window" ]] && command -v tmux >/dev/null 2>&1; then
if [[ "$hosts_tui" -eq 1 ]]; then
if [[ -n "$pane" ]]; then
tmux -L "$socket" kill-pane -t "$pane" 2>/dev/null || true
echo "kill_fleet_agent: pane $pane cerrado (window '${wname:-$window}' aloja la TUI; window preservada)."
else
echo "kill_fleet_agent: window '${wname:-$window}' aloja la TUI pero no se resolvio el pane; solo SIGTERM (window preservada)."
fi
else
tmux -L "$socket" kill-window -t "$window" 2>/dev/null || true
echo "kill_fleet_agent: window $window cerrada en el socket $socket."
fi
fi
return 0
}
@@ -104,6 +104,24 @@ set -e
assert_rc "error: sin target devuelve rc=2" 2 "$rc"
assert_contains "error: mensaje falta target" "falta el target" "$out"
# --- Test 7 (Guard 3 predicado): _fleet_window_hosts_tui ---
# La window 'console' SIEMPRE se considera que aloja la TUI (no se cierra entera).
assert_predicate() {
local test_name="$1" expected="$2"; shift 2
set +e
_fleet_window_hosts_tui "$@"; local rc=$?
set -e
assert_rc "$test_name" "$expected" "$rc"
}
# Nombre de window 'console' -> aloja TUI (rc 0), aunque ningun pane sea fleetview.
assert_predicate "guard3: window 'console' aloja la TUI" 0 "console" $'1234 claude\n5678 bash'
# Algun pane corre 'fleetview' -> aloja TUI (rc 0), aunque la window no sea console.
assert_predicate "guard3: pane fleetview aloja la TUI" 0 "claude" $'1111 bash\n2222 fleetview'
# Ni console ni fleetview -> NO aloja la TUI (rc 1): kill-window normal.
assert_predicate "guard3: window normal no aloja la TUI" 1 "claude" $'3333 claude\n4444 bash'
# Substring que contiene 'fleetview' pero no es el comando exacto -> NO matchea (grep -qx).
assert_predicate "guard3: comando 'fleetviewer' no falsea positivo" 1 "work" $'7777 fleetviewer'
echo "---"
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] || exit 1
+25 -7
View File
@@ -3,10 +3,10 @@ name: launch_fleetclaude
kind: function
lang: bash
domain: infra
version: "1.4.0"
version: "1.5.0"
purity: impure
signature: "launch_fleetclaude [--cwd <dir>] [--bin <path>] [--session <name>] [--reuse] [--cols <n>]"
description: "Entrypoint de FleetView: abre una ventana kitty con una sesion tmux (socket aislado por perfil) de dos panes (TUI fleetview a la izquierda, claude --dangerously-skip-permissions a la derecha) para centralizar la flota de Claudes. Soporta PERFILES multiples: sin --session/--reuse cada invocacion abre un perfil nuevo (fleet, fleet2, fleet3, ...) con su propia flota; inyecta FLEET_SOCKET/FLEET_SESSION a la TUI para que cada panel vea solo sus Claudes. Instala atajos alt+flechas/alt+enter/alt+n que controlan la TUI desde cualquier pane, y fija el ancho del sidebar con hooks."
description: "Entrypoint de FleetView: abre una ventana kitty con una sesion tmux (socket aislado por perfil) de dos panes (TUI fleetview a la izquierda, claude --dangerously-skip-permissions a la derecha) para centralizar la flota de Claudes. El pane de la TUI corre dentro del bucle supervisor supervise_fleetview_tui, que la relanza si muere (crash/panic/kill), asi el panel de control NUNCA se pierde. Soporta PERFILES multiples: sin --session/--reuse cada invocacion abre un perfil nuevo (fleet, fleet2, fleet3, ...) con su propia flota; inyecta FLEET_SOCKET/FLEET_SESSION a la TUI para que cada panel vea solo sus Claudes. Instala atajos alt+flechas/alt+enter/alt+n que controlan la TUI desde cualquier pane, y fija el ancho del sidebar con hooks."
tags: [claude-fleet, infra, kitty, tmux, claude, fleetview, launcher]
params:
- name: --cwd
@@ -20,7 +20,8 @@ params:
- name: --cols
desc: "Ancho en columnas del pane izquierdo (la TUI). Opcional. Default: 40."
output: "Crea/reutiliza una sesion tmux detached con dos panes y lanza una ventana kitty 'FleetView' adjunta a ella, desacoplada del shell padre (setsid). Imprime el estado por stdout. Sin valor de retorno; exit 0 en exito."
uses_functions: []
uses_functions:
- supervise_fleetview_tui_bash_infra
uses_types: []
returns: []
returns_optional: false
@@ -83,10 +84,20 @@ al retomar el trabajo en el repo `fn_registry`.
TTY, reutiliza la terminal actual con `exec tmux attach`.
- **kitty detached (setsid)**: la ventana se lanza con `setsid ... &` para
sobrevivir al cierre de la terminal que la invoco. No bloquea al shell padre.
- **`exec` en los panes**: tanto la TUI como `claude` se lanzan con `exec`, asi
que al terminar el proceso el pane se cierra en vez de dejar una shell zombie
colgando. Excepcion: el fallback cuando `fleetview` no esta compilado deja una
shell interactiva a proposito (para que veas el mensaje y puedas compilar).
- **TUI bajo supervisor (auto-respawn)**: el pane izquierdo NO corre un
`exec fleetview` de una sola vida, sino `supervise_fleetview_tui` (bucle que
relanza la TUI si muere por crash/panic/kill). Asi el panel de control nunca se
pierde por un fallo puntual. El supervisor para limpio con su sentinel
(`touch ~/.claude/fleet/tui_stop_<perfil>` y deja salir la TUI) o se rinde si la
TUI entra en crash-loop; en ambos casos el pane cae a una shell viva (no se
cierra solo) para inspeccionar. Es la mitad "auto-recuperacion" del par de
fixes que blindan FleetView; la otra es el Guard 3 anti-TUI/console de
`kill_fleet_agent` (la causa raiz del cierre accidental). Si el script del
supervisor no estuviera en disco, cae al `exec fleetview` clasico.
- **`exec` en los demas panes**: `claude` (orquestador e idle) se lanza con
`exec`, asi que al terminar el proceso el pane se cierra en vez de dejar una
shell zombie. Excepcion: el fallback cuando `fleetview` no esta compilado deja
una shell interactiva a proposito (para que veas el mensaje y puedas compilar).
- **Requiere fleetview compilado**: el default `--bin` apunta a
`<repo>/apps/fleetview/fleetview`. Si ese binario no existe, el pane izquierdo
muestra `cd apps/fleetview && go build -o fleetview .` en lugar de fallar en
@@ -113,6 +124,13 @@ al retomar el trabajo en el repo `fn_registry`.
## Capability growth log
- v1.5.0 (2026-06-24) — **auto-respawn de la TUI**. El pane izquierdo ya no corre
`exec fleetview` (una sola vida), sino el bucle supervisor
`supervise_fleetview_tui`, que relanza la TUI si muere (crash/panic/kill de su
proceso o pane). Asi el panel de control NUNCA se pierde por un fallo puntual.
Parada voluntaria via sentinel; crash-loop guard para no relanzar en bucle
cerrado. Complementa el Guard 3 anti-TUI/console de `kill_fleet_agent` (causa
raiz del cierre accidental). Nueva dependencia: `supervise_fleetview_tui_bash_infra`.
- v1.4.0 (2026-06-18) — **perfiles multiples**. Socket+sesion tmux ya no son el
fijo `fleet`: cada perfil tiene los suyos (mismo nombre). Sin `--session`/
`--reuse`, cada invocacion abre el primer perfil libre (`fleet`, `fleet2`, ...),
@@ -170,7 +170,22 @@ USAGE
envpfx="FLEET_SOCKET=$(printf '%q' "$session") FLEET_SESSION=$(printf '%q' "$session")"
local left_cmd
if [[ -x "$bin" ]]; then
# NO un `exec fleetview` de una sola vida: lo envolvemos en el bucle
# supervisor supervise_fleetview_tui, que relanza la TUI si muere (crash,
# panic, kill de su proceso o de su pane). Asi el panel de control de la
# flota NUNCA se pierde por un fallo puntual. El supervisor para limpio
# con su sentinel (touch ~/.claude/fleet/tui_stop_<perfil>) o se rinde si
# la TUI entra en crash-loop; en ambos casos cae a una shell viva.
local sup="$repo_root/bash/functions/infra/supervise_fleetview_tui.sh"
if [[ -f "$sup" ]]; then
# bash <sup> (no exec): al volver el supervisor (sentinel o crash-loop)
# caemos a una shell viva para que el mensaje siga visible y se pueda
# inspeccionar/relanzar. El env aplica al supervisor y a su hijo TUI.
left_cmd="$envpfx bash $(printf '%q' "$sup") --bin $(printf '%q' "$bin") --socket $(printf '%q' "$session"); exec \"\$SHELL\""
else
# Fallback si falta el supervisor en disco: comportamiento clasico.
left_cmd="$envpfx exec $(printf '%q' "$bin")"
fi
else
# Fallback claro: instruye como compilar la TUI y deja una shell viva.
left_cmd="echo 'fleetview no compilado: cd apps/fleetview && go build -o fleetview .'; exec \"\$SHELL\""
+16 -5
View File
@@ -3,23 +3,24 @@ name: spawn_fleet_agent
kind: function
lang: bash
domain: infra
version: 1.1.0
version: 1.2.0
purity: impure
signature: "spawn_fleet_agent --socket <s> --session <s> --cwd <dir> [--prompt-file <f> | --skill <name>] [--role orchestrator|executor] [--parent <sid>] [--title <t>]"
description: "Lanza un Claude como window nueva dentro de la sesion tmux de un perfil FleetView (socket aislado), opcionalmente en modo orquestador (skill embebida como primer prompt), marcado con un role en su goal.json y atribuido a su orquestador padre. Es la forma de que un ejecutor o el propio orquestador VIVAN en la flota tmux (visibles en la TUI fleetview, conmutables con /fleet focus) en vez de en kitties sueltas. Reemplaza a launch_claude_agent_kitty cuando se opera dentro de un perfil fleet ya montado. Con --parent <sid> escribe parent_orchestrator en el goal.json del nuevo Claude (via mark_claude_parent) para que el watcher de fleetview rutee sus avisos al orquestador que lo lanzo. Imprime el window_id creado."
signature: "spawn_fleet_agent [--socket <s>] [--session <s>] [--cwd <dir>] [--prompt-file <f> | --skill <name>] [--role orchestrator|executor] [--parent <sid>] [--title <t>]"
description: "Lanza un Claude como window nueva dentro de la sesion tmux de un perfil FleetView (socket aislado), opcionalmente en modo orquestador (skill embebida como primer prompt), marcado con un role en su goal.json y atribuido a su orquestador padre. --socket/--session son opcionales: si no se pasan se auto-detectan del contexto tmux ($TMUX) via detect_fleet_context (los explicitos tienen prioridad), evitando caer a kitty cuando $FLEET_SOCKET viene vacia. Es la forma de que un ejecutor o el propio orquestador VIVAN en la flota tmux (visibles en la TUI fleetview, conmutables con /fleet focus) en vez de en kitties sueltas. Reemplaza a launch_claude_agent_kitty cuando se opera dentro de un perfil fleet ya montado. Con --parent <sid> escribe parent_orchestrator en el goal.json del nuevo Claude (via mark_claude_parent) para que el watcher de fleetview rutee sus avisos al orquestador que lo lanzo. Imprime el window_id creado."
tags: [fleet, claude-fleet, orchestration, tmux, infra]
uses_functions:
- mark_claude_role_py_infra
- mark_claude_parent_py_infra
- detect_fleet_context_bash_infra
uses_types: []
error_type: error_go_core
file_path: "bash/functions/infra/spawn_fleet_agent.sh"
tested: false
params:
- name: --socket
desc: "Socket tmux del perfil FleetView (ej. fleet, fleet2). El perfil debe estar ya montado (sesion viva)."
desc: "Socket tmux del perfil FleetView (ej. fleet, fleet2). Opcional: se auto-detecta de $TMUX via detect_fleet_context si no se pasa. El perfil debe estar ya montado (sesion viva)."
- name: --session
desc: "Nombre de la sesion tmux dentro del socket (normalmente igual al socket)."
desc: "Nombre de la sesion tmux dentro del socket (normalmente igual al socket). Opcional: se auto-detecta de $TMUX si no se pasa."
- name: --cwd
desc: "Directorio de trabajo del nuevo Claude. Default: PWD."
- name: --prompt-file
@@ -54,6 +55,11 @@ Lanza un Claude dentro de un perfil FleetView (sesion tmux de un socket aislado)
./fn run spawn_fleet_agent --socket fleet2 --session fleet2 --cwd "$HOME/fn_registry" \
--prompt-file /tmp/orq_health.md --title "kanban-health" \
--parent 32945650-a4e1-472b-90c9-5b38ef60a463
# Sin --socket/--session: auto-detecta el socket de la flota actual ($TMUX).
# Forma preferida desde dentro de la flota — no hace falta saber el socket:
./fn run spawn_fleet_agent --cwd "$HOME/fn_registry" \
--prompt-file /tmp/orq_health.md --title "kanban-health"
```
## Cuando usarla
@@ -62,9 +68,14 @@ Cuando el orquestador (o el launcher) necesita arrancar un Claude que debe vivir
## Gotchas
- **Auto-deteccion de socket/session**: si no pasas `--socket`/`--session`, se derivan de `$TMUX` via `detect_fleet_context`. Los explicitos tienen prioridad. Solo aborta (exit 2) si tras auto-detectar siguen vacios (de verdad no hay tmux). No dependas de `$FLEET_SOCKET`: a veces viene vacia en un claude resumido/relanzado aunque viva en la flota — `$TMUX` es la senal fiable.
- El perfil (socket+session) debe estar **ya montado** (`launch_fleetclaude` primero); si la sesion no existe, falla con exit 1.
- El `--role` se aplica en **background**: el `sessionId` del nuevo Claude no existe hasta que Claude escribe `~/.claude/sessions/<PID>.json` (unos segundos). `mark_claude_role` espera ese archivo. Si el arranque es muy lento, el role puede tardar en aparecer; es no-fatal (el agente simplemente no se pinea/identifica hasta entonces).
- El `--parent` se aplica igual en **background** via `mark_claude_parent` (misma espera del `sessions/<PID>.json`). Cuando se pasan `--role` y `--parent` juntos se encadenan **secuencialmente** en el mismo subshell (primero role, luego parent) para que la segunda escritura lea el goal ya con la primera clave puesta — sin carrera de lectura-modificacion-escritura. Es no-fatal: si el sessions JSON no aparece a tiempo, el `parent_orchestrator` simplemente no se escribe.
- `--skill` envia `/<name>` como primer prompt: depende de que Claude Code interprete el primer argumento como invocacion de slash command (verificado con `/orquestador`).
- El nuevo Claude hereda `FLEET_SOCKET`/`FLEET_SESSION` del entorno del server tmux (que `launch_fleetclaude` fija con `set-environment`), asi apunta al perfil correcto.
- `--dangerously-skip-permissions` siempre (los agentes de la flota trabajan desatendidos); riesgo asumido como en el resto del modo orquestador.
## Capability growth log
- v1.2.0 (2026-06-21) — `--socket`/`--session` ahora son opcionales: se auto-detectan del contexto tmux (`$TMUX`) via `detect_fleet_context` cuando no se pasan. Elimina el gotcha de caer a kitty cuando `$FLEET_SOCKET` viene vacia pese a estar en la flota. Los valores explicitos siguen primando.
+23 -2
View File
@@ -29,11 +29,15 @@ spawn_fleet_agent() {
--title) shift; title="${1:-claude}" ;;
-h|--help)
cat <<'USAGE'
Uso: spawn_fleet_agent --socket <s> --session <s> --cwd <dir> [opciones]
Uso: spawn_fleet_agent [--socket <s>] [--session <s>] [--cwd <dir>] [opciones]
Lanza un Claude como window nueva en la sesion tmux <session> del socket <socket>
(un perfil FleetView ya montado). Imprime el window_id creado.
--socket/--session son OPCIONALES: si no se pasan, se auto-detectan del contexto
tmux actual ($TMUX) via detect_fleet_context. Los valores explicitos tienen
prioridad. Aborta solo si tras auto-detectar siguen vacios (no hay tmux).
Opciones:
--prompt-file <f> Primer prompt del Claude = contenido del archivo (prompt
autocontenido del ejecutor). El cat lo hace el shell del
@@ -66,8 +70,25 @@ USAGE
shift
done
# Auto-detectar socket/session del contexto tmux ($TMUX) cuando no se pasan
# explicitos. Los --socket/--session explicitos SIEMPRE tienen prioridad.
# Esto evita el bug de caer a kitty cuando $FLEET_SOCKET viene vacia pese a
# estar dentro de una window de la flota (ver detect_fleet_context).
if [[ -z "$socket" || -z "$session" ]]; then
local _detector ctx det_socket="" det_session=""
_detector="$(dirname "${BASH_SOURCE[0]}")/detect_fleet_context.sh"
if [[ -f "$_detector" ]]; then
ctx="$(bash "$_detector" 2>/dev/null || true)"
# Parseo minimo sin depender de jq: extraer "socket":"..." / "session":"...".
det_socket="$(printf '%s' "$ctx" | sed -n 's/.*"socket":"\([^"]*\)".*/\1/p')"
det_session="$(printf '%s' "$ctx" | sed -n 's/.*"session":"\([^"]*\)".*/\1/p')"
[[ -z "$socket" ]] && socket="$det_socket"
[[ -z "$session" ]] && session="$det_session"
fi
fi
[[ -z "$socket" || -z "$session" ]] && {
echo "spawn_fleet_agent: --socket y --session son obligatorios" >&2
echo "spawn_fleet_agent: no se detecto contexto tmux (\$TMUX vacia) y no se pasaron --socket/--session. Lanza desde dentro de la flota o pasa el socket/session explicito." >&2
return 2
}
[[ -z "$cwd" ]] && cwd="$PWD"
@@ -0,0 +1,67 @@
---
name: supervise_fleetview_tui
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "supervise_fleetview_tui --bin <path> [--socket <s>] [--sentinel <path>] [--backoff <s>] [--min-uptime <s>] [--max-fast-exits <n>]"
description: "Bucle supervisor que mantiene viva la TUI fleetview: lanza el binario y, si sale (crash, panic, kill de su proceso o pane), lo relanza tras un backoff, para que el panel de control de la flota NUNCA se pierda por un fallo puntual. Es la pieza que hace resiliente al pane izquierdo de la sesion tmux FleetView (lo invoca launch_fleetclaude). Dos valvulas de escape evitan el respawn infinito: un fichero centinela (touch <sentinel> => parada voluntaria al siguiente ciclo) y un crash-loop guard (si la TUI sale demasiado rapido muchas veces seguidas, el supervisor se rinde con rc=3 en vez de quemar CPU relanzando un binario roto)."
tags: [fleet, claude-fleet, orchestration, fleetview, tui, supervisor, resilience, infra]
uses_functions: []
uses_types: []
error_type: error_go_core
file_path: "bash/functions/infra/supervise_fleetview_tui.sh"
tested: true
tests:
- "golden: tras salir el binario, el supervisor lo relanza (respawn observable)"
- "sentinel: tocar el fichero centinela para el bucle limpio (rc=0) y lo consume"
- "crash-loop: salidas rapidas seguidas >= max_fast_exits hacen que se rinda (rc=3)"
- "error: sin --bin rc=1; binario no ejecutable rc=1"
test_file_path: "bash/functions/infra/supervise_fleetview_tui_test.sh"
params:
- name: --bin
desc: "Ruta al binario fleetview a supervisar. Obligatorio. Si no es ejecutable, sale con rc=1 con instruccion de compilado."
- name: --socket
desc: "Socket del perfil FleetView. Solo fija el nombre del sentinel por defecto. Default: $FLEET_SOCKET, o 'fleet' si no esta seteada."
- name: --sentinel
desc: "Ruta del fichero centinela de parada voluntaria. Si existe tras una salida de la TUI, se borra y el bucle termina. Default: $HOME/.claude/fleet/tui_stop_<socket>."
- name: --backoff
desc: "Segundos de espera antes de relanzar la TUI tras una salida. Default: 1."
- name: --min-uptime
desc: "Umbral en segundos para considerar una salida 'rapida' (sospecha de crash-loop). Un arranque que dura >= este valor resetea el contador. Default: 2."
- name: --max-fast-exits
desc: "Numero de salidas rapidas seguidas tras las que el supervisor se rinde (crash-loop guard) en vez de seguir relanzando. Default: 5."
output: "No retorna valor; corre indefinidamente relanzando la TUI. Sale 0 ante parada voluntaria (sentinel), 1 ante uso incorrecto / binario no ejecutable, 3 cuando el crash-loop guard se rinde. Imprime una linea por cada relanzamiento o parada."
---
# supervise_fleetview_tui
Bucle supervisor de la TUI `fleetview`. Corre el binario y, cada vez que sale (crash, panic, `kill` de su proceso, cierre de su pane), lo **relanza** tras un pequeño backoff. Hace que el panel de control de la flota — el pane izquierdo de la sesión tmux FleetView — **nunca se pierda** por un fallo puntual. `launch_fleetclaude` lo usa como comando del pane izquierdo en vez de un `exec fleetview` de una sola vida.
## Ejemplo
```bash
# Como lo invoca el launcher en el pane izquierdo (relanza la TUI si muere):
FLEET_SOCKET=fleet bash bash/functions/infra/supervise_fleetview_tui.sh \
--bin apps/fleetview/fleetview --socket fleet
# Pararlo voluntariamente desde otra terminal: tocar el sentinel y dejar salir la TUI.
touch ~/.claude/fleet/tui_stop_fleet
```
## Cuando usarla
Úsala como wrapper del binario `fleetview` siempre que quieras que la TUI sobreviva a un crash o a un `kill` accidental de su proceso/pane (p. ej. un `kill_fleet_agent` que cierre la window que la aloja). Es la mitad "auto-recuperación" del par de fixes que blindan FleetView; la otra mitad es el Guard 3 anti-TUI/console de `kill_fleet_agent` (la causa raíz). No la uses para supervisar Claudes (esos se relanzan con `claude --resume`, no en bucle ciego).
## Gotchas
- **Impura y de larga duración**: corre indefinidamente. Está pensada para vivir en un pane tmux con TTY, no como systemd service (la TUI necesita PTY; el watcher de fleetview sí es systemd `Restart=always`).
- **Crash-loop guard**: si la TUI sale en menos de `--min-uptime` segundos, `--max-fast-exits` veces seguidas, el supervisor se **rinde** (rc=3) en vez de relanzar para siempre un binario roto. Ajusta los umbrales si tu arranque es legítimamente lento.
- **Sentinel = única parada voluntaria limpia**: `touch <sentinel>` y deja que la TUI salga; al siguiente ciclo el supervisor ve el fichero, lo borra y termina. Sin sentinel, **relanza siempre** (es el objetivo: que no se pierda). Un sentinel huérfano de una sesión previa se limpia al arrancar para no parar de inmediato.
- **El sentinel por defecto depende del socket**: `~/.claude/fleet/tui_stop_<socket>`. Dos perfiles (`fleet`, `fleet2`) tienen sentinels distintos, así parar uno no para el otro.
- **No supervisa Claudes**: su contrato es solo la TUI. Relanzar un Claude en bucle ciego perdería su sesión; los Claudes se recuperan con `claude --resume`.
## Capability growth log
(v1.0.0 — sin cambios todavía.)
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# supervise_fleetview_tui — bucle supervisor que mantiene viva la TUI fleetview.
#
# Lanza el binario fleetview y, si sale (crash, panic, kill de su proceso o de su
# pane), lo relanza tras un pequeno backoff. Asi el panel de control de la flota
# NUNCA se pierde por un fallo puntual: es la pieza que hace resiliente al pane
# izquierdo de la sesion tmux FleetView (lo invoca launch_fleetclaude).
#
# Dos valvulas de escape para no hacer respawn infinito:
# - Sentinel file: si tras una salida existe el fichero centinela, se borra y
# el bucle termina (parada voluntaria solicitada por el usuario). El default
# es $HOME/.claude/fleet/tui_stop_<socket>; pararla a mano: `touch <sentinel>`
# y dejar que la TUI salga (o matar su proceso).
# - Crash-loop guard: si la TUI sale demasiado rapido (uptime < min_uptime
# segundos) muchas veces seguidas (>= max_fast_exits), el supervisor se rinde
# y devuelve != 0, para no quemar CPU relanzando un binario roto en caliente.
# Un arranque que dura >= min_uptime resetea el contador.
#
# Funcion IMPURA: lanza un proceso en bucle y lee/escribe un fichero centinela.
#
# Overrides de entorno (testabilidad, no para uso normal):
# FLEET_SOCKET socket del perfil; fija el nombre del sentinel por defecto.
set -euo pipefail
IFS=$' \t\n'
supervise_fleetview_tui() {
local bin="" socket="" sentinel="" backoff=1 min_uptime=2 max_fast_exits=5
while [[ $# -gt 0 ]]; do
case "$1" in
--bin) shift; bin="${1:-}" ;;
--socket) shift; socket="${1:-}" ;;
--sentinel) shift; sentinel="${1:-}" ;;
--backoff) shift; backoff="${1:-1}" ;;
--min-uptime) shift; min_uptime="${1:-2}" ;;
--max-fast-exits) shift; max_fast_exits="${1:-5}" ;;
-h|--help)
cat <<'USAGE'
Uso: supervise_fleetview_tui --bin <path> [opciones]
Bucle supervisor: corre el binario fleetview y lo relanza si sale, para que el
panel de la flota nunca se pierda por un crash/kill puntual.
Opciones:
--bin <path> Ruta al binario fleetview (obligatorio).
--socket <s> Socket del perfil FleetView. Default: $FLEET_SOCKET o "fleet".
--sentinel <path> Fichero centinela de parada voluntaria.
Default: $HOME/.claude/fleet/tui_stop_<socket>.
--backoff <s> Segundos de espera antes de relanzar. Default: 1.
--min-uptime <s> Umbral (s) para considerar una salida "rapida". Default: 2.
--max-fast-exits <n> Salidas rapidas seguidas tras las que el supervisor se
rinde (crash-loop guard). Default: 5.
-h, --help Esta ayuda.
Parar el bucle a mano: `touch <sentinel>` y dejar que la TUI salga (o matar su
proceso); en el siguiente ciclo el supervisor ve el sentinel, lo borra y termina.
Salida: 0 parada voluntaria (sentinel); 1 binario no ejecutable / uso incorrecto;
3 el supervisor se rindio por crash-loop (demasiadas salidas rapidas seguidas).
USAGE
return 0 ;;
--*)
echo "supervise_fleetview_tui: opcion desconocida '$1' (usa -h)" >&2
return 1 ;;
*)
if [[ -z "$bin" ]]; then
bin="$1"
else
echo "supervise_fleetview_tui: argumento extra '$1' (bin ya es '$bin')" >&2
return 1
fi ;;
esac
shift
done
[[ -z "$bin" ]] && {
echo "supervise_fleetview_tui: falta --bin <path> al binario fleetview. Usa -h." >&2
return 1
}
[[ -z "$socket" ]] && socket="${FLEET_SOCKET:-fleet}"
[[ -z "$sentinel" ]] && sentinel="$HOME/.claude/fleet/tui_stop_${socket}"
mkdir -p "$(dirname "$sentinel")" 2>/dev/null || true
if [[ ! -x "$bin" ]]; then
echo "supervise_fleetview_tui: binario '$bin' no es ejecutable. Compila la TUI: cd apps/fleetview && go build -o fleetview ." >&2
return 1
fi
# Limpiar un sentinel huerfano de una sesion anterior, para no parar al arrancar.
[[ -f "$sentinel" ]] && rm -f "$sentinel" 2>/dev/null || true
local fast_exits=0
while true; do
local start end uptime code
start=$(date +%s)
set +e
"$bin"
code=$?
set -e
end=$(date +%s)
uptime=$(( end - start ))
# Valvula 1 — parada voluntaria por sentinel.
if [[ -f "$sentinel" ]]; then
rm -f "$sentinel" 2>/dev/null || true
echo "[fleetview: parada solicitada via sentinel ($sentinel) — fin del supervisor]"
return 0
fi
# Valvula 2 — crash-loop guard.
if [[ "$uptime" -lt "$min_uptime" ]]; then
fast_exits=$(( fast_exits + 1 ))
else
fast_exits=0
fi
if [[ "$fast_exits" -ge "$max_fast_exits" ]]; then
echo "[fleetview: $fast_exits salidas rapidas seguidas (ultimo code=$code) — el supervisor se rinde para no hacer respawn infinito. Inspecciona el binario y relanza.]" >&2
return 3
fi
echo "[fleetview salio (code=$code, uptime=${uptime}s) — relanzando en ${backoff}s. Para parar: touch $sentinel, o Ctrl-C.]"
sleep "$backoff"
done
}
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
supervise_fleetview_tui "$@"
fi
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Tests para supervise_fleetview_tui. Usa un binario falso (un script) que cuenta
# sus invocaciones, para verificar respawn, crash-loop guard y sentinel sin correr
# la TUI real.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/supervise_fleetview_tui.sh"
PASS=0
FAIL=0
assert_contains() {
local test_name="$1" needle="$2" haystack="$3"
if echo "$haystack" | grep -qF "$needle"; then
echo "PASS: $test_name"
PASS=$((PASS+1))
else
echo "FAIL: $test_name — expected to contain '$needle'"
echo " got: $haystack"
FAIL=$((FAIL+1))
fi
}
assert_eq() {
local test_name="$1" expected="$2" actual="$3"
if [[ "$actual" == "$expected" ]]; then
echo "PASS: $test_name ($actual)"
PASS=$((PASS+1))
else
echo "FAIL: $test_name — expected '$expected', got '$actual'"
FAIL=$((FAIL+1))
fi
}
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
COUNTER="$TMP/runs"
SENTINEL="$TMP/sentinel"
# --- Test 1 (crash-loop guard): binario que sale rapido siempre se rinde a las N ---
# Fake bin: registra una linea por invocacion y sale 1 inmediato.
FAKE_FAST="$TMP/fake_fast.sh"
cat > "$FAKE_FAST" <<EOF
#!/usr/bin/env bash
echo run >> "$COUNTER"
exit 1
EOF
chmod +x "$FAKE_FAST"
: > "$COUNTER"
set +e
out=$(supervise_fleetview_tui --bin "$FAKE_FAST" --backoff 0 --min-uptime 100 \
--max-fast-exits 3 --sentinel "$SENTINEL" 2>&1); rc=$?
set -e
runs=$(wc -l < "$COUNTER" | tr -d ' ')
assert_eq "crash-loop: se rinde con rc=3" 3 "$rc"
assert_eq "crash-loop: corrio exactamente 3 veces" 3 "$runs"
assert_contains "crash-loop: mensaje de rendicion" "el supervisor se rinde" "$out"
# --- Test 2 (golden respawn + sentinel): relanza tras salir, para via sentinel ---
# Fake bin: en la 2a invocacion crea el sentinel, luego sale. Prueba que:
# (a) tras la 1a salida RELANZA (respawn) -> hay 2a invocacion (golden).
# (b) al ver el sentinel, PARA (no hay 3a invocacion).
FAKE_SENT="$TMP/fake_sent.sh"
cat > "$FAKE_SENT" <<EOF
#!/usr/bin/env bash
echo run >> "$COUNTER"
n=\$(wc -l < "$COUNTER" | tr -d ' ')
if [[ "\$n" -ge 2 ]]; then
touch "$SENTINEL"
fi
exit 1
EOF
chmod +x "$FAKE_SENT"
: > "$COUNTER"
rm -f "$SENTINEL"
set +e
out=$(supervise_fleetview_tui --bin "$FAKE_SENT" --backoff 0 --min-uptime 0 \
--max-fast-exits 99 --sentinel "$SENTINEL" 2>&1); rc=$?
set -e
runs=$(wc -l < "$COUNTER" | tr -d ' ')
assert_eq "golden: relanzo tras morir (2 invocaciones)" 2 "$runs"
assert_eq "sentinel: para limpio con rc=0" 0 "$rc"
assert_contains "sentinel: mensaje de parada voluntaria" "parada solicitada via sentinel" "$out"
assert_eq "sentinel: el fichero se consume (borrado)" "no" "$([[ -f "$SENTINEL" ]] && echo si || echo no)"
assert_contains "golden: registra el respawn" "relanzando" "$out"
# --- Test 3 (error): falta --bin ---
set +e
out=$(supervise_fleetview_tui --backoff 0 2>&1); rc=$?
set -e
assert_eq "error: sin --bin devuelve rc=1" 1 "$rc"
assert_contains "error: mensaje falta --bin" "falta --bin" "$out"
# --- Test 4 (error): binario no ejecutable ---
set +e
out=$(supervise_fleetview_tui --bin "$TMP/no_existe" 2>&1); rc=$?
set -e
assert_eq "error: binario no ejecutable rc=1" 1 "$rc"
assert_contains "error: mensaje no ejecutable" "no es ejecutable" "$out"
echo "---"
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] || exit 1
Submodule cpp/apps/chart_demo deleted from 026f514bb7
Submodule cpp/apps/shaders_lab deleted from ab38127ac0
+18
View File
@@ -114,6 +114,24 @@ static LRESULT CALLBACK fn_subclass_wndproc(HWND hwnd, UINT msg, WPARAM wp, LPAR
case WM_EXITSIZEMOVE:
g_in_sizemove.store(false, std::memory_order_release);
break;
case WM_SYSKEYDOWN:
// Alt+Enter would otherwise toggle a borderless-fullscreen mode
// (driven by some GPU drivers' OpenGL/Vulkan hotkey, or by
// DefWindowProc on certain window styles). We never want that:
// these are docked tool windows, not games. Consume the keystroke
// so the window stays in its normal decorated state. Every other
// Alt+key combo chains through to GLFW/DefWindowProc untouched.
if (wp == VK_RETURN) {
return 0;
}
break;
case WM_SYSCHAR:
// Swallow the system "ding" beep that the suppressed Alt+Enter
// above would otherwise trigger via the default char handler.
if (wp == VK_RETURN) {
return 0;
}
break;
case WM_LBUTTONDOWN:
// Alt + LMB anywhere on the window initiates a native modal MOVE
// via WM_SYSCOMMAND, SC_MOVE | HTCAPTION. Same pattern as our
+6 -1
View File
@@ -42,7 +42,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [sink](sink.md) | 11 | Funciones que escriben datos a destino externo (BD, dashboard, alerta, email). Nodos output |
| [validator](validator.md) | 6 | Funciones que verifican datos/config contra reglas. Pre-flight de sinks y gates en DAGs |
| [navegator](navegator.md) | 4 | Automatización de browser via CDP + AX tree + LLM: obtener, limpiar, chunkear AX tree y llamar a Claude CLI |
| [img-to-3d](img-to-3d.md) | 3 | Imagen 2D -> modelo 3D: profundidad monocular (Depth-Anything-V2) + malla de relieve texturizada exportada a .glb, con pipeline one-shot. Produce el glb que mesh-3d consume/renderiza |
| [img-to-3d](img-to-3d.md) | 4 | Imagen 2D -> modelo 3D: recorte de fondo (rembg/GrabCut/umbral) + profundidad monocular (Depth-Anything-V2) + malla de relieve texturizada exportada a .glb, con pipeline one-shot. Produce el glb que mesh-3d consume/renderiza |
| [whatsapp](whatsapp.md) | 3 | Operar WhatsApp Web por CDP sobre la pestaña existente (sin ventana ni foco): buscar/abrir chat, leer conversacion, enviar texto. Compone 4 primitivas CDP-Python (cdp_eval/type_chars/press_key/click_xy). No HTTP: WhatsApp usa WebSocket + cifrado E2E |
| [cpp-dashboard-viz](cpp-dashboard-viz.md) | 10 | Primitivas C++ ImGui para dashboards: kpi_card, sparkline, line/bar/scatter/pie/heatmap/histogram, panel containers |
| [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit |
@@ -57,6 +57,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [duckdb](duckdb.md) | 10 | Operar bases DuckDB: open (Go), query/execute/upsert, introspeccion (list_tables, table_schema), CSV->Parquet, dedup, OHLCV, e ingesta desde Excel (excel_to_duckdb) + salida a Postgres (duckdb_to_postgres). Motor analitico del stack de datos Excel->DuckDB->Postgres->viz |
| [excel](excel.md) | 6 | CRUD de hojas Excel (.xlsx) con openpyxl: escribir multi-hoja, upsert no destructivo (preserva columnas manuales), leer a memoria, leer a markdown, graficos nativos (bar/line/pie/scatter), e ingesta a DuckDB. Round-trip de datos con humanos |
| [postgres](postgres.md) | 7 | CRUD de PostgreSQL via psycopg2 (dsn): connect (Go), query read-only, insert append-only, upsert idempotente, crear tabla inferida, introspeccion, aplicar .sql. Capa que sirve datos a Metabase/Grafana (que no hablan DuckDB nativo) |
| [sql-connect](sql-connect.md) | 3 | Conexion directa y consulta a Microsoft SQL Server (Navision) via pymssql: abrir conexion (login_timeout), SELECT parametrizada con binding seguro -> {columns, rows, row_count}, y pipeline one-shot run_mssql_query (CLI JSON/CSV). Elimina el copia-pega manual de CSV de Navision. Credenciales desde pass, host = IP LAN de Windows desde WSL2 |
| [recon](recon.md) | 8 | Reconocimiento de red OSINT: whois, rdap, dns (dig), ping, traceroute, nmap por perfiles. Cada scan se archiva en OSINT (nota vault + tabla DuckDB network_scans) via el sink save_scan_to_osint o el pipeline one-shot recon_osint. Perfiles nmap pesados (full-tcp/vuln/udp-top) en segundo plano. No es framework de explotacion; solo hosts autorizados |
| [osint-passive](osint-passive.md) | 8 | Recoleccion OSINT pasiva (fuentes publicas, no intrusiva): EXIF/PDF metadata, whois RDAP, DNS, subdominios crt.sh, guess emails, username enumeration, search dorks |
| [osint-enrich](osint-enrich.md) | 3 | Orquestadores de enriquecimiento OSINT: componen osint-passive para aumentar datapoints de personas (emails/usernames/dorks), orgs (whois+dns+subdominios) y metadatos de attachments |
@@ -68,6 +69,10 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [eda](eda.md) | 27 | Exploratory Data Analysis por tabla y base con motor DuckDB + PostgreSQL push-down: perfil base SQL (SUMMARIZE + distinct exacto), estadística numérica/categórica, tipo semántico regex, calidad, correlación/asociación (Pearson/Spearman/Cramér's V/Theil's U/η/MI), relaciones inter-tabla (FK containment + join graph mermaid), modelos baratos (PCA/KMeans/IsolationForest/normalidad/tendencia), capa LLM (dictionary/PII/limpieza/análisis) y generación de notebook. Orquestadores `profile_table` (backend duckdb/postgres, flags run_models/run_llm) y `profile_database` |
| [seo](seo.md) | 3 | SEO orientado a datos sobre Google Search Console: autenticar con service account (`gsc_auth`), extraer Search Analytics paginado (`pull_gsc_search_analytics`) y el pipeline de ingesta a DuckDB + espejo Postgres para Metabase (`ingest_gsc_search_analytics`). Cadena de ingesta del proyecto `seo_analytics`; alimenta dashboards de striking distance, CTR opportunities y content decay |
| [local-hub](local-hub.md) | 4 | Exponer los procesos locales como subdominios `*.localhost` (via Caddy, sin DNS) y reunirlos en una pantalla principal Glance con estado en vivo, refrescada a diario por dag_engine. Descubre servicios (manifiesto + registry), renderiza Caddyfile + config Glance (puras), y el pipeline `refresh_local_hub` regenera+recarga. Fuente de verdad: `apps/local_hub/local_services.yaml` |
| [comfyui-judge](comfyui-judge.md) | 4 | Panel multi-juez de calidad de imagen: estético LAION-V2 (`comfyui_score_aesthetic`, 0-10) + fidelidad CLIP prompt↔imagen (`comfyui_score_clip_alignment`, 0-1) + crítica LLM-vision (`comfyui_critique_image_llm`, good/bad). Agregados por voto mayoría en `comfyui_judge_image`. Gate objetivo para tests/DoD y el bucle de mejora de skills ComfyUI; degrada con gracia si un juez cae. Jueces estético/fidelidad por subproceso al venv ComfyUI (torch+open_clip), crítico via claude-direct |
| [comfyui](comfyui.md) | 29 | Controlar ComfyUI (Stable Diffusion por grafos) de dos formas: por API HTTP (build_txt2img_workflow puro → submit → wait → object_info; download_model con validación Civitai/HF) y por la UI web vía CDP sobre la pestaña abierta (load_workflow_ui, set_node_widget_ui para tunear prompt/steps/seed en vivo, queue_prompt_ui = botón Queue Prompt, export_workflow_ui, refresh_nodes_ui). El API format es el puente entre ambos caminos. Las funciones de UI componen `cdp_eval`. Incluye imagen→3D nativo (Hunyuan3D-2, tag `img-to-3d`): build_image_to_3d_workflow + fetch_output_mesh + install_3d_model + pipeline image_to_3d_oneshot |
| [comfyui-skill](comfyui-skill.md) | 11 | Tratar una configuración de generación ComfyUI como una skill: receta versionada en disco (checkpoint + LoRAs + params + scaffold de prompt + post-proceso) que se compila a un workflow cambiando solo el subject. Save/load/list de recetas, bucle de mejora genera→juzga→bump con gate objetivo (el score del juez decide qué se promueve), export de la skill a grafo cargable en el navegador, y cosecha de Civitai (extract_recipe_from_png + harvest oneshot) que destila el workflow embebido de una imagen pública en una skill candidata |
| [comfyui-overview](comfyui-overview.md) | — | Mapa cross-grupo de las capacidades de generación ComfyUI (txt2img, img2img/inpaint, controlnet, skills/multiestilo-LoRA, video, upscale/detail, 3D, juez, operación): cada capacidad → builders/pipelines del registry + grafos UI + skills que la cubren. Índice de entrada al stack ComfyUI; las firmas y gotchas viven en `comfyui.md`/`comfyui-skill.md`/`comfyui-judge.md`. Catálogo navegable de los grafos en disco (subcarpetas por capacidad) en `~/ComfyUI/CAPABILITIES.md` |
## Como anadir grupo
+88
View File
@@ -0,0 +1,88 @@
# comfyui-judge — panel multi-juez de calidad de imagen
El "modelo adversario" del pipeline ComfyUI: el sistema que distingue **producto bueno de
malo** de forma objetiva. Tres jueces independientes puntúan/critican una imagen y un
agregador vota por mayoría. Es el **gate objetivo** que consumen los tests, los contratos
DoD y el bucle de mejora de skills (grupo `comfyui-skill`): un skill no está "hecho" hasta
que la imagen que produce pasa el panel (`verdict == 'good'`).
## Funciones del grupo
| ID | Firma corta | Qué hace |
|---|---|---|
| `comfyui_score_aesthetic_py_ml` | `score_aesthetic(image_path) -> {ok, score_0_10}` | Calidad estética LAION-V2 (head MLP sobre CLIP ViT-L/14). Subproceso al venv ComfyUI. Barato, determinista, sin API. |
| `comfyui_score_clip_alignment_py_ml` | `score_clip_alignment(image_path, prompt) -> {ok, score_0_1}` | Fidelidad prompt↔imagen (similitud coseno CLIP). Subproceso al venv ComfyUI. |
| `comfyui_critique_image_llm_py_ml` | `critique_image_llm(image_path, prompt) -> {ok, verdict, score_0_10, reasons}` | Crítica de un LLM-vision (artefactos, anatomía, watermarks). Compone `ask_llm_vision` (claude-direct). Cuesta API. |
| `comfyui_judge_image_py_ml` | `judge_image(image_path, prompt) -> {ok, verdict, score, votes, reasons}` | **Agregadora.** Llama a los 3, vota good/bad por mayoría, agrega razones. Degrada si un juez cae. |
Los tres jueces son ortogonales a propósito: el estético mide *belleza*, el de fidelidad mide
*que sea lo pedido*, y el crítico LLM ve *defectos finos* que un score global no penaliza. Un
único score se engaña fácil; tres votos independientes, no.
## Ejemplo canónico (end-to-end)
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from ml.comfyui_judge_image import comfyui_judge_image
img = os.path.expanduser("~/ComfyUI/output/comfy_sdxl_00001_.png")
prompt = "a majestic lion standing on rocks at sunset, photorealistic"
res = comfyui_judge_image(img, prompt)
print(res["verdict"], round(res["score"], 2), res["votes"])
# good 7.1 {'aesthetic': 'good', 'clip': 'good', 'llm': 'good'}
if res["verdict"] != "good":
for r in res["reasons"]:
print(" -", r) # feedback accionable para mejorar el skill
```
Jueces sueltos (cuando solo quieres una dimensión):
```bash
# estético (sin API, rápido)
python/.venv/bin/python3 python/functions/ml/comfyui_score_aesthetic.py ~/ComfyUI/output/comfy_sdxl_00001_.png
# fidelidad
python/.venv/bin/python3 python/functions/ml/comfyui_score_clip_alignment.py ~/ComfyUI/output/comfy_sdxl_00001_.png "a lion at sunset"
```
## Cómo se enchufa a tests / DoD como gate objetivo
- **e2e_check de un skill ComfyUI**: declarar en el `app.md` (o en el contrato del skill) un
check que genere la imagen y exija `comfyui_judge_image(img, prompt)["verdict"] == "good"`.
Es la cláusula golden: producto = imagen que el panel aprueba, no "el workflow no petó".
- **Bucle de mejora** (grupo `comfyui-skill`): tras generar, juzgar; si `verdict == 'bad'`,
las `reasons` del juez crítico indican qué corregir (más steps, otro sampler, fix de prompt)
y se re-genera. Convergencia = el panel aprueba.
- **Selección de la mejor de N**: ejecutar el panel sobre cada candidata y rankear por `score`
(o filtrar por `verdict == 'good'`).
Umbrales por defecto (ajustables): estético `>= 6.0` → good; fidelidad `>= 0.24` → good; el
crítico da su propio verdict. Veredicto final = **mayoría** de los votos vivos; empate → `bad`.
## Fronteras (qué NO cubre)
- **No genera imágenes.** Eso es el grupo `comfyui` (build_*_workflow + submit + wait + fetch).
Este grupo solo *juzga* una imagen ya producida.
- **No es un detector forense de IA** ni un clasificador NSFW/seguridad: juzga *calidad de
producto*, no procedencia ni políticas de contenido.
- **No corre en el venv del registry.** Los jueces estético/fidelidad necesitan torch +
open_clip, que viven en `~/ComfyUI/.venv`; se invocan por subproceso. El crítico necesita la
API Anthropic (claude-direct).
- **No persiste resultados.** Devuelve dicts en memoria; persistir veredictos (operations.db,
e2e_runs) es responsabilidad del consumidor.
## Prerequisitos
- venv de ComfyUI con torch + open_clip 3.x: `~/ComfyUI/.venv/bin/python3`.
- Modelo estético LAION en `/mnt/2tb/comfyui_models/aesthetic/sac+logos+ava1-l14-linearMSE.pth`.
- CLIP ViT-L-14-quickgelu (pretrained openai) cacheado (se descarga la 1ª vez, ~900 MB).
- Token OAuth de Claude (claude-direct) para el juez crítico — lo resuelve `ask_llm_vision`.
## Notas
- **QuickGELU** es obligatorio en CLIP (`ViT-L-14-quickgelu`/`openai`): sin él los embeddings
se degradan en silencio y tanto el score estético como el ranking de fidelidad se desvirtúan.
- El panel **degrada con gracia**: si un juez cae (p.ej. el LLM en HTTP 429), vota con los
demás y lo anota; solo falla si caen los tres.
+125
View File
@@ -0,0 +1,125 @@
# ComfyUI — mapa de capacidades de generación
Vista de pájaro de **qué sabemos generar con ComfyUI**, organizada por capacidad. Es el índice de
entrada al stack ComfyUI: cruza cada capacidad con las funciones del registry que la implementan,
los grafos UI cargables y las skills (recetas) que la materializan.
Las tres páginas madre detalladas siguen siendo la fuente de verdad por grupo:
- [comfyui.md](comfyui.md) — grupo `comfyui`: builders de workflow, ejecución HTTP, UI vía CDP, I/O.
- [comfyui-skill.md](comfyui-skill.md) — grupo `comfyui-skill`: recetas de estilo versionadas.
- [comfyui-judge.md](comfyui-judge.md) — grupo `comfyui-judge`: panel multi-juez de calidad.
El catálogo navegable con los grafos concretos en disco (subcarpetas por capacidad, cómo cargar
cada uno) vive **fuera del repo**, junto a la instalación: `~/ComfyUI/CAPABILITIES.md`. Este doc es
su contraparte versionable: el mapa capacidad → grupo de funciones que sí pertenece a `fn_registry`.
Filtros MCP: `mcp__registry__fn_search query="" tag="comfyui"` (y `tag="comfyui-skill"`,
`tag="comfyui-judge"`).
## Las capacidades de un vistazo
| # | Capacidad | Qué resuelve | Builders / pipelines clave | Grafo UI | Skills |
|---|---|---|---|---|---|
| 01 | **txt2img** | prompt → imagen (SD1.5/SDXL/Flux) | `build_txt2img`, `build_flux`, `build_sdxl_refiner`, `txt2img_oneshot` | ✅ ×2 | — |
| 02 | **img2img / inpaint** | imagen → imagen, regenerar zona enmascarada | `build_img2img`, `build_inpaint` | ✅ | — |
| 03 | **controlnet** | generación guiada por mapa (depth/pose/canny) | `build_controlnet` | ✅ | — |
| 04 | **skills (multiestilo/LoRA)** | recetas de estilo reproducibles con `{subject}` | `build_skill_workflow`, `inject_lora`, `generate_with_skill_oneshot`, `harvest_civitai_skill_oneshot` | ✅ ×2 | ✅ ×2 |
| 05 | **video** | imagen/texto → vídeo (SVD, LTX, Wan) | `build_img2vid`, `build_video` | ✅ | — |
| 06 | **upscale / detail** | ampliar y recuperar detalle (ESRGAN, hires-fix, FaceDetailer) | `build_upscale`, `build_hires_fix`, `inject_hires_fix`, `build_facedetailer` | — | — |
| 07 | **3D** | imagen/texto → malla 3D (Hunyuan3D) + limpieza | `build_image_to_3d`, `build_textured_3d_multiview`, `image_to_3d_oneshot`, `text_to_3d_oneshot`, `mesh_cleanup_oneshot` | — | — |
| 08 | **juez / calidad** | puntuar lo generado (gate de DoD y bucle de mejora) | `judge_image`, `score_aesthetic`, `score_clip_alignment`, `critique_image_llm` | — | — |
| 09 | **operación / infra** | server, modelos, cola, encolar/esperar, I/O de workflows | `ensure_server`, `submit_workflow`, `wait_result`, `validate_workflow`, `download_model`, `run_foreign_workflow_oneshot` | — | — |
| 10 | **ipadapter / referencia** _(en construcción)_ | guiar por imagen de referencia (estilo/sujeto) sin LoRA + encadenar varios LoRAs en una llamada | `build_ipadapter_workflow`, `inject_multi_lora` _(añadiéndose ahora; no indexadas todavía)_ | — | — |
Las capacidades 01-05 ya tienen grafo UI cargable; 06-08 están cubiertas por funciones del registry
pero sin grafo UI todavía (se añade su subcarpeta cuando aparezca el primero). 09 es la maquinaria
transversal que habilita el resto, no una capacidad de generación. **10 está en construcción** por
el flujo de funciones+server (al 24/06/2026 vi `comfyui_build_ipadapter_workflow` e
`comfyui_inject_multi_lora` en `python/functions/ml/` sin indexar aún): se completará este mapa con
sus IDs reales cuando se ejecute `fn index`.
## Mapa capacidad → funciones del registry
### 01 · txt2img
- `comfyui_build_txt2img_workflow_py_ml` (pura) — SD1.5/SDXL: CheckpointLoader → CLIPTextEncode×2 → KSampler → VAEDecode → SaveImage.
- `comfyui_build_flux_workflow_py_ml` (pura) — Flux: UNETLoader + DualCLIPLoader + VAELoader, guía por FluxGuidance.
- `comfyui_build_sdxl_refiner_workflow_py_ml` (pura) — SDXL base+refiner (2 KSamplerAdvanced encadenados).
- `comfyui_txt2img_oneshot_py_pipelines` — prompt → PNG en disco (build + submit + wait + fetch).
### 02 · img2img / inpaint
- `comfyui_build_img2img_workflow_py_ml` (pura) — LoadImage → VAEEncode → KSampler (denoise<1).
- `comfyui_build_inpaint_workflow_py_ml` (pura) — LoadImage + LoadImageMask → VAEEncodeForInpaint.
### 03 · controlnet
- `comfyui_build_controlnet_workflow_py_ml` (pura) — ControlNetLoader → ControlNetApply sobre el condicionamiento positivo.
### 04 · skills (multiestilo / LoRA)
- `comfyui_build_skill_workflow_py_ml` (pura) — compila una receta (`recipe.json`) a workflow, sustituye `{subject}`, encadena LoRAs + post-proceso.
- `comfyui_inject_lora_py_ml` (pura) — inserta `LoraLoader` en un workflow ya construido (encadenable).
- `comfyui_generate_with_skill_oneshot_py_pipelines` — skill + subject → PNG juzgado.
- `comfyui_harvest_civitai_skill_oneshot_py_pipelines` — Civitai → skill candidata.
- `comfyui_export_skill_template_py_ml` — skill → template API + grafo UI cargable.
- `comfyui_extract_recipe_from_png_py_ml` — destila un PNG de Civitai en receta candidata.
- CRUD + telemetría: `comfyui_list_skills_py_ml`, `comfyui_load_skill_py_ml`, `comfyui_save_skill_py_ml`, `comfyui_update_skill_score_py_ml`, `comfyui_bump_skill_version_py_ml`.
### 05 · video
- `comfyui_build_img2vid_workflow_py_ml` (pura) — SVD: condicionamiento por CLIP_VISION (sin prompt de texto).
- `comfyui_build_video_workflow_py_ml` (pura) — txt2video LTX-Video 2B o Wan2.1 1.3B.
### 06 · upscale / detail
- `comfyui_build_upscale_workflow_py_ml` (pura) — ESRGAN (`model`) o reescalado pixel (`latent`).
- `comfyui_build_hires_fix_workflow_py_ml` (pura) — hires-fix 2 pasadas (UltimateSDUpscale por tiles + Remacri).
- `comfyui_inject_hires_fix_py_ml` (pura) — inyecta la 2ª pasada en un workflow ya construido.
- `comfyui_build_facedetailer_workflow_py_ml` (pura) — FaceDetailer (detecta caras YOLO y las regenera).
### 07 · 3D
- `comfyui_build_image_to_3d_workflow_py_ml` (pura) — imagen → GLB (Hunyuan3D-2 nativo, 9 nodos).
- `comfyui_build_textured_3d_multiview_workflow_py_ml` (pura) — malla texturizada PBR multi-vista (Hunyuan3DWrapper).
- `comfyui_build_view_3d_workflow_py_ml` (pura) — visor 3D nativo (Load3D) para orbitar un GLB existente.
- `comfyui_generate_views_from_image_py_ml` — sintetiza vistas novel-view (StableZero123/SV3D).
- Pipelines: `comfyui_image_to_3d_oneshot_py_pipelines`, `comfyui_text_to_3d_oneshot_py_pipelines`, `comfyui_mesh_cleanup_oneshot_py_pipelines`.
- Mallas: `comfyui_simplify_mesh_py_ml`, `comfyui_make_watertight_py_ml`, `comfyui_install_3d_model_py_ml`.
- Relación: el grupo [img-to-3d](img-to-3d.md) es la vía ligera (relieve por profundidad) que produce el GLB que [mesh-3d](mesh-3d.md) renderiza; ComfyUI/Hunyuan3D es la vía pesada de malla volumétrica real.
### 08 · juez / calidad
- `comfyui_judge_image_py_ml` — panel agregador (estético + CLIP + LLM-vision), veredicto por mayoría.
- `comfyui_score_aesthetic_py_ml` — score estético LAION-V2 (0-10).
- `comfyui_score_clip_alignment_py_ml` — fidelidad prompt↔imagen vía CLIP (0-1).
- `comfyui_critique_image_llm_py_ml` — crítica LLM-vision (artefactos, anatomía, texto, watermarks).
### 09 · operación / infra (transversal)
- Server: `comfyui_ensure_server_py_infra`.
- Modelos: `comfyui_download_model_py_ml`, `comfyui_list_installed_models_py_ml`, `comfyui_install_custom_node_py_ml`.
- Ejecución: `comfyui_submit_workflow_py_ml`, `comfyui_wait_result_py_ml`, `comfyui_stream_progress_py_ml`, `comfyui_validate_workflow_py_ml`, `comfyui_object_info_py_ml`.
- Cola: `comfyui_queue_manage_py_ml`, `comfyui_interrupt_queue_py_ml`.
- Outputs: `comfyui_fetch_output_image_py_ml`, `comfyui_fetch_output_video_py_ml`, `comfyui_fetch_output_mesh_py_ml`.
- Barridos: `comfyui_batch_generate_py_ml`, `comfyui_build_grid_py_ml`.
- Workflows I/O: `comfyui_import_workflow_json_py_ml`, `comfyui_import_workflow_png_py_ml`, `comfyui_read_png_metadata_py_ml`, `comfyui_download_workflow_py_ml`, `comfyui_run_foreign_workflow_oneshot_py_pipelines`.
- UI vía CDP: `comfyui_load_workflow_ui_py_browser`, `comfyui_export_workflow_ui_py_browser`, `comfyui_queue_prompt_ui_py_browser`, `comfyui_clear_node_outputs_ui_py_browser`.
## Librería de grafos en disco
Los grafos UI cargables viven en `~/ComfyUI/user/default/workflows/`, agrupados en subcarpetas
numeradas por capacidad (`01_txt2img/`, `02_img2img/`, `03_controlnet/`, `04_skills/`, `05_video/`).
ComfyUI las lista recursivamente (`GET /api/userdata?dir=workflows&recurse=true`) y el menú
**Workflows** del navegador las muestra como árbol anidado, así que las capacidades quedan visibles
en la propia UI. La regla de clasificación de un grafo nuevo (por su nodo terminal) y el detalle de
cada grafo concreto están en `~/ComfyUI/CAPABILITIES.md`.
## Fronteras
- Este doc es un **índice cross-grupo**: no documenta firmas completas ni gotchas por función — eso
vive en las páginas madre (`comfyui.md`, `comfyui-skill.md`, `comfyui-judge.md`) y en cada `.md`
de función. Aquí solo está el mapa capacidad → función.
- No cubre la **generación** en sí (ejecutar workflows contra la GPU); cubre el catálogo de qué
capacidades existen y con qué piezas se componen.
+350
View File
@@ -0,0 +1,350 @@
# ComfyUI Skill — Recetas versionadas de generación reutilizables
Tag: `comfyui-skill`. Grupo para tratar una configuración de generación de ComfyUI como una
**skill**: una receta versionada en disco (checkpoint + LoRAs + params + scaffold de prompt +
bloques de post-proceso) que se guarda una vez y se reproduce a un workflow concreto cambiando
solo el *subject*. Es la doctrina del issue 0087 aplicada a la generación de imágenes: el registry
crece **promoviendo configuraciones que funcionan a recetas reutilizables**, no reescribiendo el
grafo de nodos cada vez.
Construye sobre el grupo [`comfyui`](comfyui.md) (los builders puros de workflow y el ciclo
submit/wait). Una skill no es un workflow: es la *receta* que compila a uno.
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui-skill"`.
## Qué es una skill
Una receta vive en `~/ComfyUI/skills_library/<slug>/` y la manipulan las funciones de este grupo:
```
~/ComfyUI/skills_library/
INDEX.md # índice regenerado de todas las skills
<slug>/
recipe.json # la receta actual
versions/vN.json # snapshot inmutable de cada save (N incremental)
growth_log.jsonl # bitácora append-only de cada save
exports/ # plantillas de workflow exportadas
samples/ # imágenes de muestra
```
### Schema de `recipe.json` (canónico)
```json
{
"schema_version": 1,
"slug": "portrait_cinematic_sdxl",
"version": "1.0.0",
"title": "Retrato cinematográfico SDXL",
"base_workflow": "txt2img",
"checkpoint": "juggernaut_xl_v11.safetensors",
"loras": [{"name": "add_detail.safetensors", "strength_model": 0.6, "strength_clip": 0.6}],
"params": {"steps": 30, "cfg": 5.5, "sampler_name": "dpmpp_2m",
"scheduler": "karras", "width": 832, "height": 1216, "denoise": 1.0},
"prompt_scaffold": {"positive": "cinematic portrait of {subject}, sharp focus",
"negative": "blurry, lowres", "trigger_words": []},
"blocks": [{"type": "facedetailer", "params": {"denoise": 0.45}},
{"type": "hires_fix", "params": {"upscale_by": 1.5, "denoise": 0.4}}],
"score_mean": 0.0, "score_n": 0,
"provenance": {"source": "manual", "nsfw": false},
"export_template_path": "exports/portrait_cinematic_sdxl.template.json"
}
```
`base_workflow` ∈ {`txt2img`, `flux`, `sdxl_refiner`} (las bases que se generan desde un *subject*
de texto). `blocks[].type` ∈ {`facedetailer`, `hires_fix`}.
## Funciones del grupo
| ID | Firma corta | Qué hace | Purity |
|---|---|---|---|
| [comfyui_build_skill_workflow_py_ml](../../python/functions/ml/comfyui_build_skill_workflow.md) | `build_skill_workflow(recipe, subject, *, seed=0) -> dict` | Compila una receta a un workflow en API format: despacha al builder base, sustituye `{subject}` + trigger_words, encadena LoRAs y aplica los blocks en orden. `SkillWorkflowError` si la base es desconocida o requiere imagen. | **pura** |
| [comfyui_export_skill_template_py_ml](../../python/functions/ml/comfyui_export_skill_template.md) | `export_skill_template(slug, *, ui_graph=False, port=9222, ...) -> dict` | Exporta una skill a artefactos cargables como GRAFO: template API en `exports/<slug>.template.json` y, con `ui_graph=True`, el UI graph posicionado (vía `load_workflow_ui`+`export_workflow_ui` por CDP) en la carpeta nativa `~/ComfyUI/user/default/workflows/<slug>.json` (menú Workflows del navegador). Sin navegador, deja el template API y reporta el fallback. | impura |
| [comfyui_inject_hires_fix_py_ml](../../python/functions/ml/comfyui_inject_hires_fix.md) | `comfyui_inject_hires_fix(workflow, *, upscale_by=1.5, denoise=0.4, steps=20, ...) -> dict` | Inyecta una 2ª pasada hires-fix (UpscaleModelLoader + UltimateSDUpscale) sobre un workflow ya construido, repuntando el SaveImage. Versión encadenable-sobre-dict del builder hermano. | **pura** |
| [comfyui_inject_multi_lora_py_ml](../../python/functions/ml/comfyui_inject_multi_lora.md) | `comfyui_inject_multi_lora(workflow, loras) -> dict` | Encadena N `LoraLoader` sobre un workflow ya construido reusando `comfyui_inject_lora` por LoRA. Cada lora = `{name, strength_model, strength_clip}`; respeta el orden (primero cerca del checkpoint, último cerca del KSampler). Apila estilo + detalle en una sola llamada. | **pura** |
| [comfyui_build_ipadapter_workflow_py_ml](../../python/functions/ml/comfyui_build_ipadapter_workflow.md) | `comfyui_build_ipadapter_workflow(prompt, ref_image, *, base_checkpoint, mode='style'\|'faceid', weight=0.8, ...) -> dict` | txt2img + IPAdapter (custom node cubiq). `mode='style'` transfiere estilo/composición de una imagen de referencia (IPAdapterUnifiedLoader+IPAdapter); `mode='faceid'` impone un rostro consistente vía insightface + .bin FaceID + su LoRA (IPAdapterUnifiedLoaderFaceID+IPAdapterFaceID). Repunta el KSampler a la rama IPAdapter. | **pura** |
| [comfyui_save_skill_py_ml](../../python/functions/ml/comfyui_save_skill.md) | `comfyui_save_skill(recipe, *, library_dir=None) -> dict` | Valida el schema mínimo y escribe `recipe.json` + snapshot `versions/vN.json` + growth_log + INDEX.md. No muta la receta (round-trip con load). | impura |
| [comfyui_load_skill_py_ml](../../python/functions/ml/comfyui_load_skill.md) | `comfyui_load_skill(slug, *, version=None, library_dir=None) -> dict` | Lee `recipe.json` (actual) o un snapshot `versions/vN.json`. Slug/versión inexistente → `{ok:False}` sin lanzar. | impura |
| [comfyui_list_skills_py_ml](../../python/functions/ml/comfyui_list_skills.md) | `comfyui_list_skills(*, library_dir=None, include_nsfw=False) -> dict` | Lista las skills con slug/title/base_workflow/version/score/nsfw/n_versions. Oculta NSFW por defecto. | impura |
| [ask_llm_vision_py_core](../../python/functions/core/ask_llm_vision.md) | `ask_llm_vision(prompt, image_path='', *, image_b64='', media_type='', model='claude-opus-4-8', ...) -> dict` | Pregunta multimodal (imagen + texto) al modelo via API directa de Anthropic (grupo `claude-direct`). Útil para **puntuar** el PNG de una skill y alimentar `score_mean`. | impura |
| [comfyui_generate_with_skill_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_generate_with_skill_oneshot.md) | `generate_with_skill_oneshot(slug, subject, *, server='127.0.0.1:8188', dest=None, seed=0, judge=True, recipe_patch=None, ...) -> dict` | One-shot del bucle: carga la skill, la compila para el `subject`, encola, espera, descarga el PNG y (si `judge`) lo puntúa con el panel `comfyui-judge`, acumulando el score en la media. `recipe_patch` prueba una variante en memoria sin guardar. | pipeline (impura) |
| [comfyui_update_skill_score_py_ml](../../python/functions/ml/comfyui_update_skill_score.md) | `comfyui_update_skill_score(slug, new_score, *, library_dir=None) -> dict` | Acumula el score de un juicio en `score_mean`/`score_n` por media incremental, reescribiendo `recipe.json` en sitio (sin snapshot ni growth_log). | impura |
| [comfyui_bump_skill_version_py_ml](../../python/functions/ml/comfyui_bump_skill_version.md) | `comfyui_bump_skill_version(slug, change, *, score_before, score_after, judge_run_id=None, recipe_patch=None, force=False, ...) -> dict` | Promueve una versión nueva **solo si el score sube** (gate objetivo): snapshot `versions/vN.json` + aplica `recipe_patch` + sube el semver + línea en `growth_log`. Gate bloquea si no mejora. | impura |
| [comfyui_extract_recipe_from_png_py_ml](../../python/functions/ml/comfyui_extract_recipe_from_png.md) | `comfyui_extract_recipe_from_png(png_path, *, slug=None, civitai_meta=None, image_url='', nsfw=False) -> dict` | Destila un PNG cosechado de Civitai en una receta de skill **candidata** (`score_n=0`, `provenance.source='civitai'`). Compone `comfyui_import_workflow_png` (workflow API embebido) + `comfyui_read_png_metadata` (params del KSampler); fallback a la `meta` de Civitai. Degradación honesta: `ok=False` sin inventar si no hay ni workflow embebido ni meta utilizable. | impura |
| [comfyui_harvest_civitai_skill_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_harvest_civitai_skill_oneshot.md) | `comfyui_harvest_civitai_skill_oneshot(*, query=None, model_version_id=None, nsfw='None', dest_dir, library_dir='~/ComfyUI/skills_library', ...) -> dict` | One-shot Civitai → skill candidata: `search``fetch` (segrega NSFW) → `extract_recipe``save_skill`. Itera los items hasta hallar uno con receta destilable (preferentemente workflow embebido), descartando los PNG sin receta; 2º pase al feed global si filtró por modelo. **No baja modelos a ciegas**: los ausentes van a `missing_models`. | pipeline (impura) |
`build_skill_workflow` compone los builders del grupo [`comfyui`](comfyui.md):
`comfyui_build_txt2img_workflow`, `comfyui_build_flux_workflow`,
`comfyui_build_sdxl_refiner_workflow`, `comfyui_inject_lora`,
`comfyui_build_facedetailer_workflow` y `comfyui_inject_hires_fix`.
## Ejemplo canónico end-to-end (receta → workflow → PNG → score)
Guardar una skill, cargarla, compilarla a un workflow para un sujeto, encolarla y puntuar el
resultado con visión. Requiere el server ComfyUI en `127.0.0.1:8188` y los modelos de la receta
instalados.
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_save_skill import comfyui_save_skill
from ml.comfyui_load_skill import comfyui_load_skill
from ml.comfyui_build_skill_workflow import build_skill_workflow
from ml.comfyui_submit_workflow import comfyui_submit_workflow
from ml.comfyui_wait_result import comfyui_wait_result
from ml.comfyui_fetch_output_image import comfyui_fetch_output_image
from core.ask_llm_vision import ask_llm_vision
# 1. Definir y guardar la skill (una vez).
recipe = {
"schema_version": 1, "slug": "portrait_cinematic_sdxl", "version": "1.0.0",
"title": "Retrato cinematográfico SDXL", "base_workflow": "txt2img",
"checkpoint": "dreamshaper_8.safetensors",
"loras": [{"name": "add_detail.safetensors", "strength_model": 0.6, "strength_clip": 0.6}],
"params": {"steps": 28, "cfg": 6.0, "sampler_name": "dpmpp_2m", "scheduler": "karras"},
"prompt_scaffold": {"positive": "cinematic portrait of {subject}, sharp focus",
"negative": "blurry, lowres", "trigger_words": []},
"blocks": [{"type": "facedetailer", "params": {"denoise": 0.45}}],
"score_mean": 0.0, "score_n": 0, "provenance": {"source": "manual", "nsfw": False},
}
comfyui_save_skill(recipe) # ~/ComfyUI/skills_library/portrait_cinematic_sdxl/
# 2. Cargar + compilar a un workflow para un sujeto concreto.
recipe = comfyui_load_skill("portrait_cinematic_sdxl")["recipe"]
wf = build_skill_workflow(recipe, "a woman with red hair", seed=42)
# 3. Encolar y esperar el PNG (camino headless del grupo comfyui).
pid = comfyui_submit_workflow(wf)["prompt_id"]
outputs = comfyui_wait_result(pid)["outputs"]
img = comfyui_fetch_output_image(outputs[0]["filename"], dest_dir="/tmp")["path"]
# 4. Puntuar el resultado con visión (alimenta el bucle de scoring de la skill).
verdict = ask_llm_vision(
"Puntúa de 0 a 10 el realismo de este retrato. Responde solo el número.",
image_path=img, model="claude-opus-4-8",
)
print(verdict["text"])
```
El paso "guardar la receta" se hace una sola vez; a partir de ahí cada generación es
`load → build → submit`, cambiando solo el `subject` y la `seed`.
## Bucle de mejora (skill → genera → juzga → bump)
La doctrina del issue 0087 cerrada en un lazo: una skill **no crece inflando la receta a ciegas,
crece registrando mejoras medibles**. El juez (no el humano) decide qué se promueve.
```
┌─────────────────────────────────────────────────────────────────┐
│ generate_with_skill_oneshot(slug, subject, judge=True) │
│ load → build → submit → wait → fetch → judge → score_mean │ ← canónica
└─────────────────────────────────────────────────────────────────┘
│ score_before = score de la receta vigente
┌─────────────────────────────────────────────────────────────────┐
│ generate_with_skill_oneshot(..., recipe_patch={params:{...}}) │ ← variante (no guarda score)
│ misma seed, un cambio plausible → judge.score = score_after │
└─────────────────────────────────────────────────────────────────┘
▼ GATE objetivo
comfyui_bump_skill_version(slug, change, score_before, score_after, judge_run_id=...)
score_after > score_before ?
├── sí → promueve: versions/vN.json (snapshot) + recipe_patch + semver↑ + growth_log
└── no → {ok:False} — NO se promueve (la variante se descarta)
```
Pasos concretos:
1. **Genera la canónica** con `judge=True`. El panel `comfyui-judge` emite un `score` y el pipeline
lo acumula en `score_mean`/`score_n` de la skill (vía `comfyui_update_skill_score`). Ese score es
el `score_before`.
2. **Genera una variante** con `recipe_patch` (p.ej. `{"params": {"steps": 32}}`) y la **misma seed**.
El patch se aplica en memoria, NO se guarda, y su score NO contamina la media. Su `judge.score` es
el `score_after`, y su `judge_run_id` es la evidencia.
3. **Promueve con el gate**: `comfyui_bump_skill_version` aplica el patch a `recipe.json`, sube el
semver y deja una línea en `growth_log.jsonl` **solo si `score_after > score_before`**. Si no
mejora, devuelve `{ok:False}` y la receta se queda como estaba. El gate es objetivo: lo decide el
número del juez, no quien lanza la generación.
Así `versions/` y `growth_log` reflejan **versiones de receta con mejora demostrada**, mientras
`score_mean` es la telemetría de calidad media de la versión vigente.
## Skills como grafos en el navegador
Una skill no vive solo como receta JSON: se exporta a un **grafo de ComfyUI cargable como tal en el
navegador**. `comfyui_export_skill_template` cierra ese hueco (receta → grafo):
```python
from ml.comfyui_export_skill_template import export_skill_template
# Headless (sin navegador): congela el template API junto a la skill.
export_skill_template("portrait_cinematic_sd15")
# -> exports/portrait_cinematic_sd15.template.json (API format, node-template reproducible)
# Con navegador (pestaña ComfyUI abierta en CDP 9222): además el grafo visual posicionado.
out = export_skill_template("portrait_cinematic_sd15", ui_graph=True, port=9222)
# -> ~/ComfyUI/user/default/workflows/portrait_cinematic_sd15.json (aparece en el menú Workflows)
```
Dos formatos, dos usos:
- **API format** (`exports/<slug>.template.json`) — el dict `{node_id:{class_type,inputs}}`. Se
carga con `comfyui_load_workflow_ui` (`app.loadApiJson`, litegraph lo auto-posiciona) o va directo
a `comfyui_submit_workflow`. Es el node-template versionable de la skill.
- **UI graph** (`~/ComfyUI/user/default/workflows/<slug>.json` + copia en `exports/<slug>.ui.json`)
`nodes`/`links`/`pos` (`app.graph.serialize()`). La carpeta nativa de la UI **solo** acepta este
formato; por eso solo se escribe con `ui_graph=True` (se genera vía CDP cargando el API en la UI y
serializando el grafo posicionado). Es el que se abre como grafo visual desde el menú Workflows.
**Fotos ↔ grafo.** Cada PNG de ComfyUI lleva su workflow embebido (chunk `prompt`, API format).
`comfyui_import_workflow_png` lo recupera, de modo que toda muestra de una skill queda asociada a su
grafo reproducible 1:1 (ver `INDEX.md` de la librería: `samples/<base>.png` + `samples/<base>.graph.json`).
**No destructivo en el navegador**: `ui_graph=True` reemplaza el grafo in-memory de la pestaña. Si
hay trabajo sin guardar (título con `*`), respalda antes con
`comfyui_export_workflow_ui(api_format=True, save_path=...)` y restáuralo después con
`comfyui_load_workflow_ui`.
## Cosecha Civitai → skill candidata
El registry crece también captando recetas que **ya existen en internet**, no solo escribiéndolas a
mano: doctrina del issue 0087 aplicada a la captación de assets. Cada imagen publicada en Civitai
suele llevar su workflow de ComfyUI embebido en el PNG (chunk `prompt`, API format); cosecharla
destila la receta entera (checkpoint + LoRAs + params + prompt) en una **skill candidata** lista para
juzgar.
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions", "pipelines"))
from comfyui_harvest_civitai_skill_oneshot import comfyui_harvest_civitai_skill_oneshot
res = comfyui_harvest_civitai_skill_oneshot(
query="cinematic portrait", nsfw="None",
dest_dir=os.path.expanduser("~/ComfyUI/civitai_harvest"),
)
print(res["ok"], res["slug"], "workflow_embebido=", res["has_workflow"])
print("guardada en:", res["skill_path"]) # ~/ComfyUI/skills_library/<slug>/recipe.json
print("modelos ausentes:", res["missing_models"]) # checkpoints/LoRAs a bajar (NO bajados)
```
Dos piezas, una composición:
- **`comfyui_extract_recipe_from_png`** (paso puro de destilación, impura solo por leer disco) —
toma un PNG ya descargado y produce la receta candidata. Compone
`comfyui_import_workflow_png` (workflow embebido) + `comfyui_read_png_metadata` (params del
KSampler), con fallback a la `meta` de generación de Civitai y, para samplers no-KSampler
(flux/`SamplerCustomAdvanced`), heurística sobre los nodos `CLIPTextEncode`. **Degradación honesta**:
si no hay ni workflow embebido ni meta utilizable devuelve `ok=False` sin inventar la receta.
- **`comfyui_harvest_civitai_skill_oneshot`** (pipeline) — encadena
`search_civitai_images → fetch_civitai_image → extract_recipe_from_png → save_skill` en una sola
llamada. Itera los resultados del search hasta encontrar el primero con receta destilable
(descartando los PNG sin workflow), y si filtró por un modelo concreto y ninguno trae grafo, hace
un 2º pase al feed global "Most Reactions" (donde abundan los workflows ComfyUI de usuarios flux).
Notas de uso:
- **La skill nace CANDIDATA** (`score_n=0`, `provenance.source='civitai'`): no está validada. El
prompt cosechado es **concreto**, no un scaffold con `{subject}` — sustitúyelo a mano si quieres
reutilizar la skill para otros sujetos. La validación la da el bucle
`generate_with_skill_oneshot` (juzga) + `comfyui_bump_skill_version` (promueve si mejora).
- **No baja modelos a ciegas**: si la receta referencia un checkpoint o LoRA que no está en
`<comfyui_dir>/models/`, lo lista en `missing_models` y no descarga nada. Bajarlos
(`comfyui_search_civitai_models` + `comfyui_download_model`) es una decisión aparte del caller.
- **NSFW segregado**: el PNG se descarga a `<dest_dir>/nsfw/` si el item es NSFW (permitido pero
siempre separado). El `dest_dir` vive fuera del repo (`~/ComfyUI/`) y se trata como datos: no se
commitea ni se indexa.
- El token Civitai es secreto: viene de `pass civitai/api-token`, nunca hardcodeado.
## Mezclar capacidades (mixer)
Una skill fija *una* receta. El **mixer** resuelve el otro eje: combinar **a la carta** todas las
capacidades de generación sobre un mismo workflow base y activar/desactivar cada una para iterar.
Misma doctrina del issue 0087 (componer piezas probadas, no reescribir el grafo), pero aplicada a
mezclar capacidades en vez de a guardar una receta.
Dos funciones:
| ID | firma corta | qué hace |
|---|---|---|
| `comfyui_compose_capabilities_py_ml` | `compose_capabilities(base, *, loras, controlnet, ipadapter, hires, facedetailer) -> dict` | **PURA.** Aplica EN ORDEN las capacidades activadas (cada arg `None` = desactivada) sobre un dict base, componiendo los inyectores/builders encadenables. Reconecta MODEL/CLIP/positive/IMAGE. Sin ninguna = base intacto. |
| `comfyui_generate_mixed_oneshot_py_pipelines` | `generate_mixed_oneshot(base, subject, *, capabilities, server, judge, ...) -> dict` | **Pipeline.** base (skill slug / `'txt2img'` / dict) → compose → submit → wait → fetch → (si `judge`) juzga. Devuelve `{ok, prompt_id, image_path, capabilities_active, judge, error}`. |
El mixer se apoya en los **inyectores encadenables-sobre-dict** (cada uno la versión componible de
su builder-desde-cero hermano):
| Capacidad | Inyector | Reconecta |
|---|---|---|
| LoRAs (N) | `comfyui_inject_multi_lora_py_ml` | cadena MODEL/CLIP tras el checkpoint |
| ControlNet | `comfyui_inject_controlnet_py_ml` | `KSampler.positive``ControlNetApply` |
| IPAdapter (style/faceid) | `comfyui_inject_ipadapter_py_ml` | `KSampler.model` ← IPAdapter (tras las LoRAs) |
| hires/upscale | `comfyui_inject_hires_fix_py_ml` | `UltimateSDUpscale` tras el `VAEDecode` |
| FaceDetailer | `comfyui_build_facedetailer_workflow_py_ml` | regenera caras del `VAEDecode` |
Orden fijo: `loras → controlnet → ipadapter → facedetailer → hires`. El IPAdapter se aplica sobre
el MODEL ya modificado por los LoRAs (orden correcto). Tras FaceDetailer el mixer deja un único
`SaveImage` (el del detailer).
### Ejemplo canónico (≥3 capacidades, juzgado)
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from pipelines.comfyui_generate_mixed_oneshot import comfyui_generate_mixed_oneshot
# txt2img dreamshaper + 2 LoRAs + FaceDetailer (3 capacidades). Activar/desactivar = cambiar args.
res = comfyui_generate_mixed_oneshot(
"txt2img",
"a heroic knight portrait, 3d render style, dramatic lighting, detailed face",
checkpoint="dreamshaper_8.safetensors",
capabilities={
"loras": [
{"name": "3d_render_redmond_sd15.safetensors", "strength_model": 0.9},
{"name": "detail_tweaker_sd15.safetensors", "strength_model": 0.5, "strength_clip": 0.5},
],
"facedetailer": {"denoise": 0.45},
# "ipadapter": {"ref_image": "face.png", "mode": "faceid"}, # se activa con solo añadirla
# "hires": {"upscale_by": 1.5},
},
dest="/tmp/comfy_mixed", seed=42, judge=True,
)
print(res["ok"], res["prompt_id"], res["capabilities_active"], res["judge"])
```
### Límite conocido (8GB / piezas actuales)
- **hires + facedetailer no encadenan**: ambos toman su imagen del `VAEDecode` del render base, así
que combinarlos deja a uno sin efecto sobre la salida final (con los dos activos, hires "gana" y
facedetailer queda sin consumidor). Usa uno U otro por workflow. El resto de combinaciones
(LoRAs + ControlNet + IPAdapter + uno de los dos post-procesos) encadenan limpio.
- **VRAM**: en 8GB lowvram con SD1.5 entran ~2-3 capacidades modestas (p.ej. 2 LoRAs + FaceDetailer
a 512px). Apilar IPAdapter FaceID + ControlNet + hires + facedetailer a la vez puede dar OOM —
baja resolución o reduce capacidades. `mixer` no valida VRAM; el OOM aflora en `wait`.
- **Incompatibilidad explícita, no silenciosa**: ControlNet sin `control_image` o IPAdapter sin
`ref_image` lanzan `ValueError` del inyector (no petan a medias). Las imágenes de control/referencia
deben estar en el `input/` del servidor antes de encolar.
## Fronteras
- **No genera ni descarga modelos**: una skill referencia checkpoints/LoRAs por nombre; deben
estar ya instalados en ComfyUI (`comfyui_download_model`, otro flujo). `build_skill_workflow` es
puro y no valida contra el servidor — usa `comfyui_validate_workflow` antes de encolar si dudas.
- **`base_workflow` solo de texto**: `txt2img`, `flux`, `sdxl_refiner`. Las bases que parten de una
imagen (`img2img`, `inpaint`, `controlnet`) lanzan `SkillWorkflowError`; para esas, monta el
workflow con los builders del grupo `comfyui` directamente.
- **`blocks` soportados**: `facedetailer` y `hires_fix`. Otros post-procesos (IPAdapter,
multi-ControlNet) se añaden creando su función-inyector hermana y registrándola en el dispatcher
de `build_skill_workflow`.
- **El juicio (`comfyui-judge`) vive en su grupo**: este grupo lo *consume* (vía
`generate_with_skill_oneshot` con `judge=True`), pero el panel multi-juez —estético + CLIP +
LLM-vision— se documenta en [`comfyui-judge`](comfyui-judge.md). Aquí solo se acumula su `score`
en `score_mean` (`comfyui_update_skill_score`) y se usa como gate del bump.
- **El bump solo sube versiones, no genera ni juzga**: `comfyui_bump_skill_version` aplica el patch
y registra la mejora; generar la imagen y puntuarla es trabajo del pipeline + el panel-juez. Una
variante que no supera a la vigente se descarta sola (el gate la rechaza).
- **La librería es metadata local**: vive bajo `~/ComfyUI/skills_library` (no toca el venv ni los
modelos en disco). No tiene repo propio ni se indexa — es estado vivo, como un `operations.db`.
- **Las funciones impuras del grupo** (save/load/list, ask_llm_vision) no llevan unit tests por
diseño (I/O de disco / red); `build_skill_workflow` e `inject_hires_fix` son puras y sí tienen
tests de estructura offline (`python/functions/ml/tests/test_comfyui_build_skill_workflow.py`,
`test_comfyui_inject_hires_fix.py`).
+292
View File
@@ -0,0 +1,292 @@
# ComfyUI — Generación de imágenes por API HTTP y por la UI (CDP)
Tag: `comfyui`. Grupo de funciones para controlar [ComfyUI](https://github.com/comfyanonymous/ComfyUI)
(motor de Stable Diffusion basado en grafos de nodos) de dos formas complementarias:
- **Por su API HTTP** (`/prompt`, `/history`, `/object_info`): construir un workflow en
"API format", encolarlo, esperar el resultado. Headless, scriptable, sin navegador.
- **Por su UI web vía CDP**: operar la pestaña de ComfyUI ya abierta en el navegador diario
(cargar un workflow en el grafo visual, editar widgets en vivo, encolar como si pulsaras
"Queue Prompt", exportar el grafo, refrescar combos). Lo que el usuario ve, el agente lo
toca. Todas las funciones de UI componen la primitiva de transport
[`cdp_eval_py_browser`](../../python/functions/browser/cdp_eval.md) — no reinventan CDP.
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui"`.
## Dos caminos, mismo motor
```
API HTTP (dominio ml) UI web vía CDP (dominio browser)
────────────────────── ───────────────────────────────
build_txt2img_workflow (dict API format) load_workflow_ui (dict -> grafo visual)
│ set_node_widget_ui (tuning en vivo)
▼ queue_prompt_ui (= botón Queue Prompt)
submit_workflow (POST /prompt -> id) export_workflow_ui (grafo -> dict API format)
▼ refresh_nodes_ui (recarga combos)
wait_result (poll /history -> PNG)
object_info (catálogo de nodos) download_model (dominio ml) -> baja checkpoints
```
El **API format** (dict de nodos numerados que produce `build_txt2img_workflow` y consume
`submit_workflow`) es el puente entre ambos mundos: `load_workflow_ui` lo carga en la UI y
`export_workflow_ui` lo recupera de la UI, así que puedes mezclar libremente API y navegador.
## Funciones del grupo
### Lifecycle del server — dominio `infra`
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_ensure_server_py_infra](../../python/functions/infra/comfyui_ensure_server.md) | `ensure_server(*, port=8188, lowvram=None, health_timeout=60, comfyui_dir='~/ComfyUI', unit_name='comfyui', runner=None) -> dict` | Garantiza que ComfyUI corre como servicio **systemd-user resiliente y sano**: genera/instala el unit (`Restart=always`, `--lowvram` autodetectado en GPUs ≤ 8 GB), daemon-reload + enable + start, y verifica salud por GET `/system_stats`. Idempotente; migra limpio un ComfyUI lanzado a mano (SIGTERM, nunca SIGKILL). Solo stdlib, no lanza excepciones → dict de estado. Prerequisito de todas las funciones HTTP. Impura. |
### Por API HTTP — dominio `ml`
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_build_txt2img_workflow_py_ml](../../python/functions/ml/comfyui_build_txt2img_workflow.md) | `build_txt2img_workflow(ckpt_name, positive, negative='', *, steps, cfg, width, height, seed, ...) -> dict` | Construye el dict del workflow txt2img básico (Checkpoint → CLIPTextEncode×2 + EmptyLatent → KSampler → VAEDecode → SaveImage) en API format. **Pura**. |
| [comfyui_build_flux_workflow_py_ml](../../python/functions/ml/comfyui_build_flux_workflow.md) | `build_flux_workflow(prompt, *, unet='flux1-schnell-fp8-e4m3fn.safetensors', clip_l, t5xxl, vae='ae.safetensors', width=1024, height=1024, steps=4, guidance=3.5, seed, weight_dtype='fp8_e4m3fn', ...) -> dict` | Builder txt2img para **Flux** (schnell/dev): UNETLoader + DualCLIPLoader (clip_l + t5xxl, type flux) + VAELoader → CLIPTextEncode → FluxGuidance + EmptySD3LatentImage → KSampler (cfg fijo 1.0) → VAEDecode → SaveImage. La guía va por FluxGuidance, no por el cfg. fp8 + ~4 pasos para 8 GB. **Pura**. |
| [comfyui_object_info_py_ml](../../python/functions/ml/comfyui_object_info.md) | `object_info(server='127.0.0.1:8188', node_class=None, timeout) -> dict` | Catálogo de nodos del server: inputs, tipos y enums (lista de checkpoints/samplers visibles). Para validar antes de enviar. Impura. |
| [comfyui_submit_workflow_py_ml](../../python/functions/ml/comfyui_submit_workflow.md) | `submit_workflow(workflow, server, client_id, timeout) -> dict` | Encola un workflow API format vía POST /prompt; devuelve `prompt_id` + posición en cola. HTTP 400 propaga la validación por nodo. Impura. |
| [comfyui_wait_result_py_ml](../../python/functions/ml/comfyui_wait_result.md) | `wait_result(prompt_id, server, timeout, poll_interval) -> dict` | Sondea GET /history/{prompt_id} hasta que termina; devuelve los outputs (PNGs con filename/subfolder/type). Impura. |
| [comfyui_download_model_py_ml](../../python/functions/ml/comfyui_download_model.md) | `download_model(url, dest_subdir='checkpoints', *, comfyui_dir, filename, token, overwrite, timeout_s) -> dict` | Descarga un checkpoint/LoRA/VAE a `models/<dest_subdir>/`. Soporta Civitai (token) y HuggingFace. Valida que no sea HTML de error ni `.safetensors` corrupto. Impura. |
| [comfyui_interrupt_queue_py_ml](../../python/functions/ml/comfyui_interrupt_queue.md) | `interrupt_queue(server='127.0.0.1:8188') -> dict` | Corta la generación en curso (POST `/interrupt`) y lee la cola (GET `/queue`) → `{ok, interrupted, queue_running, queue_pending, error}`. Freno de mano; degrada limpio en fallo de red. Impura. |
| [comfyui_batch_generate_py_ml](../../python/functions/ml/comfyui_batch_generate.md) | `batch_generate(workflow, *, seeds=None, server='127.0.0.1:8188') -> dict` | Encola N variantes (una por seed), parcheando el campo de semilla de los nodos sampler sin mutar el original → `{ok, prompt_ids, count, error}`. Re-roll en una llamada. Compone `submit_workflow`. Impura. |
| [comfyui_queue_manage_py_ml](../../python/functions/ml/comfyui_queue_manage.md) | `queue_manage(action, *, server='127.0.0.1:8188', prompt_id=None) -> dict` | API de cola completa que complementa a `interrupt_queue`: `action='status'` (GET `/queue`), `'clear'` (vacía pendientes), `'delete'` (borra un prompt, requiere `prompt_id`), `'history'` (cuenta `/history`) → `{ok, action, queue_running, queue_pending, history_count, error}`. Degrada limpio en fallo de red. Impura. |
| [comfyui_stream_progress_py_ml](../../python/functions/ml/comfyui_stream_progress.md) | `stream_progress(prompt_id, *, server='127.0.0.1:8188', client_id=None, timeout=300) -> dict` | Progreso en vivo por WebSocket `/ws` (alternativa a `wait_result`): cuenta pasos del sampler (`steps_seen`), último nodo, y detecta el fin → `{ok, completed, steps_seen, last_node, method, error}`. Para ver progreso comparte el `client_id` con el submit. Cae a polling si falta `websocket-client`. Impura. |
### Builders, validación e import — dominio `ml` (P0, issue 0064)
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_build_img2img_workflow_py_ml](../../python/functions/ml/comfyui_build_img2img_workflow.md) | `build_img2img_workflow(ckpt_name, init_image, positive, negative='', *, denoise=0.6, steps, cfg, seed, ...) -> dict` | Builder img2img (Checkpoint + LoadImage → VAEEncode → KSampler con `denoise` → VAEDecode → SaveImage). **Pura**. |
| [comfyui_build_upscale_workflow_py_ml](../../python/functions/ml/comfyui_build_upscale_workflow.md) | `build_upscale_workflow(image, *, model_name='4x-UltraSharp.pth', method='model') -> dict` | Builder upscale: `method='model'` (ESRGAN: UpscaleModelLoader + ImageUpscaleWithModel) o `method='latent'` (ImageScaleBy x2 sin modelo). **Pura**. |
| [comfyui_inject_lora_py_ml](../../python/functions/ml/comfyui_inject_lora.md) | `inject_lora(workflow, lora_name, *, strength_model=1.0, strength_clip=1.0, model_node=None, clip_node=None) -> dict` | Inserta un LoraLoader en un workflow ya construido, reconectando model/clip de la fuente a sus consumidores. Encadenable. **Pura** (no muta la entrada). |
| [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. |
| [comfyui_fetch_output_video_py_ml](../../python/functions/ml/comfyui_fetch_output_video.md) | `fetch_output_video(prompt_id, *, server, dest=None, outputs=None, timeout) -> dict` | Localiza y descarga el output de **vídeo/animación** (`.mp4`/`.webp`/`.webm`/`.gif`) de `/history` vía GET `/view`. Cubre SaveAnimatedWEBP/SaveVideo (bajo `"images"`) y VHS_VideoCombine (bajo `"gifs"`). Hermana de `fetch_output_image`/`fetch_output_mesh`. Acepta `outputs=` de `wait_result` para evitar re-consultar `/history`. Impura. |
### Potencia y assets de internet — dominio `ml` (P1, issue 0064)
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_build_inpaint_workflow_py_ml](../../python/functions/ml/comfyui_build_inpaint_workflow.md) | `build_inpaint_workflow(ckpt_name, image, mask, positive, negative='', *, denoise=1.0, steps, cfg, seed, ...) -> dict` | Builder inpaint: CheckpointLoaderSimple + LoadImage + LoadImageMask → VAEEncodeForInpaint → KSampler → VAEDecode → SaveImage. Regenera solo la zona enmascarada. **Pura**. |
| [comfyui_build_controlnet_workflow_py_ml](../../python/functions/ml/comfyui_build_controlnet_workflow.md) | `build_controlnet_workflow(ckpt_name, control_image, cn_name, positive, negative='', *, strength=1.0, steps, cfg, seed, width, height) -> dict` | Builder ControlNet: ControlNetLoader + ControlNetApply inyectan el mapa de control sobre el condicionamiento positivo. **Pura**. |
| [comfyui_build_sdxl_refiner_workflow_py_ml](../../python/functions/ml/comfyui_build_sdxl_refiner_workflow.md) | `build_sdxl_refiner_workflow(base_ckpt, refiner_ckpt, positive, negative='', *, base_steps=20, refiner_steps=5, cfg, seed, width=1024, height=1024) -> dict` | SDXL base+refiner: dos KSamplerAdvanced encadenados (base con `return_with_leftover_noise`, refiner termina). **Pura**. |
| [comfyui_search_civitai_models_py_ml](../../python/functions/ml/comfyui_search_civitai_models.md) | `search_civitai_models(query, *, types='Checkpoint', base_model=None, sort, limit=20, token=None) -> dict` | Busca modelos/LoRAs en la API pública de Civitai → `{ok, items:[{name, type, base_model, version_id, download_url, nsfw}], count, error}`. Sin token funciona. Impura. |
| [comfyui_install_custom_node_py_ml](../../python/functions/ml/comfyui_install_custom_node.md) | `install_custom_node(repo_url, *, comfyui_dir, pip_install=True, restart=False) -> dict` | git clone en `custom_nodes/` + pip/uv install de requirements en el venv de ComfyUI. NO reinicia el server (restart=False). Impura. |
| [comfyui_resolve_workflow_deps_py_ml](../../python/functions/ml/comfyui_resolve_workflow_deps.md) | `resolve_workflow_deps(workflow, server='127.0.0.1:8188') -> dict` | Para un workflow ajeno: valida y traduce lo que falta en acciones (`{missing_nodes, missing_models, suggestions}`). Compone `validate_workflow`. Impura. |
| [comfyui_run_foreign_workflow_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_run_foreign_workflow_oneshot.md) | `run_foreign_workflow_oneshot(source, *, server, dest=None, output_kind='auto', install_nodes=False, node_repos=None, wait_timeout, civitai_token, hf_token) -> dict` | **Pipeline** para ejecutar un workflow ComfyUI **ajeno** end-to-end en una llamada: import (cualquier fuente) → resolve deps → (instala solo nodos confiables opt-in) → validate → submit → wait → fetch (imagen/vídeo/malla). **Gate de seguridad**: si faltan deps NO encola y las reporta en `missing`; nunca descarga modelos a ciegas. Compone `download_workflow` + `resolve_workflow_deps` + `install_custom_node` + `submit`/`wait` + `fetch_output_image/video/mesh`. Promoción del roadmap 0064/0087. Impuro. |
| [comfyui_list_installed_models_py_ml](../../python/functions/ml/comfyui_list_installed_models.md) | `list_installed_models(folder=None, comfyui_dir='~/ComfyUI') -> dict` | Lista modelos por carpeta resolviendo la ruta real de `extra_model_paths.yaml` (`/mnt/2tb/comfyui_models/`) + la nativa. Escaneo de FS, no depende del server. Impura. |
### Cosecha de Civitai → skills candidatas — dominio `ml` + `pipelines` (issue 0087)
Cosechar de Civitai imágenes con su workflow+receta embebidos para clonar su calidad y alimentar
la librería de skills (grupo [`comfyui-skill`](comfyui-skill.md)). En vez de reconstruir a mano una
receta que ya existe en una imagen pública, se cosecha y se guarda como **candidata** (`score_n=0`,
`provenance.source='civitai'`) para que el bucle de juicio/bump la valide. Política: **NSFW
permitido pero SIEMPRE segregado** en carpeta marcada. **Gotcha clave**: la API de Civitai ya no
expone `meta` (viene `null`) — la receta real sale del **workflow ComfyUI embebido en el PNG**, no
de la meta inline.
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_search_civitai_images_py_ml](../../python/functions/ml/comfyui_search_civitai_images.md) | `search_civitai_images(*, query=None, model_version_id=None, nsfw='None', sort='Most Reactions', limit=20, token=None) -> dict` | Busca imágenes en Civitai (GET /api/v1/images) → items con `url` (PNG con workflow embebido). El endpoint no admite query textual (HTTP 500): resuelve `query`→versión de modelo via `search_civitai_models`. Token de `pass civitai/api-token`. Reintenta 503. Impura. |
| [comfyui_fetch_civitai_image_py_ml](../../python/functions/ml/comfyui_fetch_civitai_image.md) | `fetch_civitai_image(image_url, *, dest_dir, nsfw=False, nsfw_subdir='nsfw', token=None, prefer_original=True, timeout_s=120) -> dict` | Descarga el PNG original (reescribe `/width=N/``/original=true/` para conservar el workflow), **segregando NSFW** a `<dest_dir>/nsfw/`. Misma validación no-HTML que `download_model`; nombra por UUID. Impura. |
| [comfyui_extract_recipe_from_png_py_ml](../../python/functions/ml/comfyui_extract_recipe_from_png.md) | `extract_recipe_from_png(png_path, *, slug=None, civitai_meta=None, image_url='', nsfw=False) -> dict` | Destila un PNG cosechado en receta de skill candidata (schema `comfyui-skill`, `source='civitai'`, `score_n=0`). Compone `import_workflow_png` + `read_png_metadata` + fallback de prompts/ckpt para flux. Sin workflow → usa `civitai_meta` (degradación honesta). Impura. |
| [comfyui_harvest_civitai_skill_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_harvest_civitai_skill_oneshot.md) | `harvest_civitai_skill_oneshot(*, query=None, model_version_id=None, nsfw='None', dest_dir, library_dir='~/ComfyUI/skills_library', comfyui_dir='~/ComfyUI', token=None, ...) -> dict` | **Pipeline** Civitai→skill candidata: search → fetch (segrega NSFW) → extract → save_skill. Itera items hasta uno con receta destilable (2º pase al feed global si filtró por modelo). **NO baja modelos a ciegas**: checkpoint/LoRA ausente → `missing_models`. Impuro. |
### Replicación desde un link de Civitai — dominio `ml` + `pipelines` (issue C5, report 0127)
"Te paso un link de Civitai: entra, observa cómo lo hicieron, y construye un workflow que lo
replique." Dado el id/URL de una imagen de Civitai → extrae la receta (prompt, modelo, sampler,
LoRAs) → reconstruye el workflow → lo genera y lo juzga. **Gotcha clave**: la API v1 `/images`
devuelve `meta=null`; la receta por id sale de los endpoints **tRPC** `image.getGenerationData` +
`image.get` (los que usa la web). Como casi nunca tendrás el checkpoint/LoRA exacto, se sustituye
por el más parecido **instalado** (misma familia) y lo ausente se reporta en `missing_models` (NUNCA
se descarga a ciegas). El parecido es aproximado cuando falta el modelo exacto — esperado. SFW
estricto: una imagen NSFW devuelve `ok=False` sin generar.
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_fetch_civitai_image_meta_py_ml](../../python/functions/ml/comfyui_fetch_civitai_image_meta.md) | `fetch_civitai_image_meta(image_ref, *, token=None, timeout=15.0) -> dict` | "Entra al link y observa": resuelve UNA imagen Civitai por id/URL vía tRPC `image.getGenerationData` + `image.get``{meta, resources, comfy_workflow, nsfw, ...}`. Donde `search_civitai_images` da `meta=null`, esta sí trae prompt/modelo/sampler. Impura. |
| [comfyui_map_a1111_params_py_ml](../../python/functions/ml/comfyui_map_a1111_params.md) | `map_a1111_params(meta, resources=None) -> dict` | **Pura**: traduce meta A1111/Civitai a params ComfyUI (sampler `DPM++ 2M Karras``dpmpp_2m`/`karras`, dims, seed), infiere familia (`sd15`/`sdxl`/`flux`) y extrae LoRAs (de resources y tags `<lora:..>` del prompt). |
| [comfyui_replicate_civitai_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_replicate_civitai_oneshot.md) | `replicate_civitai_oneshot(url_or_id, *, server, dest=None, judge=True, token=None, wait_timeout=600) -> dict` | **Pipeline** link Civitai→réplica: fetch_meta → map_a1111_params → workflow embebido tal cual O reconstruido (build_txt2img + inject_lora, **sustituye checkpoint ausente por el más parecido instalado**, omite LoRAs ausentes) → run_foreign_workflow_oneshot → judge_image. Acepta también `modelVersionId` o un workflow ajeno (PNG/.json/dict). Impuro. |
### Retoque pro y oneshot — dominio `ml` + `pipelines` (P0, lote report 0093)
Builders que envuelven custom-nodes "pro" ya instalados (Impact-Pack, UltimateSDUpscale) y la
promoción del flujo txt2img a una sola llamada. Los class_types se verificaron contra el
`/object_info` del server vivo (FaceDetailer, UltralyticsDetectorProvider, UltimateSDUpscale).
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_build_facedetailer_workflow_py_ml](../../python/functions/ml/comfyui_build_facedetailer_workflow.md) | `build_facedetailer_workflow(base_workflow_or_image, ckpt_name, positive, negative='', *, bbox_model='face_yolov8m.pt', denoise=0.5, ...) -> dict` | Builder **FaceDetailer** (Impact-Pack): detecta caras con `UltralyticsDetectorProvider` (YOLO bbox) y las regenera para recuperar detalle (el pain #1 de retratos). Acepta el nombre de una imagen en `input/` (str) o un workflow base (dict): toma la imagen del `VAEDecode` y reutiliza el `CheckpointLoaderSimple`. No usa SAM (no instalado). **Pura**. |
| [comfyui_build_hires_fix_workflow_py_ml](../../python/functions/ml/comfyui_build_hires_fix_workflow.md) | `build_hires_fix_workflow(ckpt_name, positive, negative='', *, first_pass=(768,768), upscale_by=1.5, denoise=0.4, steps=20, ...) -> dict` | Builder **hires fix** de 2 pasadas: genera base (KSampler) y la amplía re-difundiéndola por tiles con `UltimateSDUpscale` + Remacri (`denoise<1` = añade detalle real). Distinto de `build_upscale_workflow` (ESRGAN puro, sin re-difusión). **Pura**. |
| [comfyui_txt2img_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_txt2img_oneshot.md) | `txt2img_oneshot(prompt, *, ckpt='dreamshaper_8.safetensors', negative='', server, dest=None, wait_timeout, **gen) -> dict` | **Pipeline** texto → PNG en disco en una llamada: build_txt2img + submit + wait + fetch_output_image → `{ok, image_path, prompt_id, error}`. Promoción de la secuencia (issue 0087). Impuro. |
| [comfyui_build_grid_py_ml](../../python/functions/ml/comfyui_build_grid.md) | `build_grid(image_paths, *, cols=None, cell=512, out_path=None, labels=None) -> dict` | Monta un **grid / contact-sheet** PIL de N imágenes para comparar de un vistazo (p.ej. el output de `batch_generate` con varios seeds). Celdas que conservan aspect ratio, rejilla casi cuadrada por defecto, rótulos opcionales → `{ok, out_path, rows, cols, error}`. Post-proceso local de imagen (no toca el server). Impura (I/O disco, PIL). |
### Vídeo (txt2video) — dominio `ml` (tag `video-generation`)
ComfyUI ≥ 0.26.0 trae soporte nativo para **vídeo por difusión**. `build_video_workflow` cubre
los dos modelos que caben en 8 GB: **LTX-Video 2B v0.9.5** (`model='ltx'`, checkpoint todo-en-uno +
VAE temporal + scheduler propio — validado end-to-end en `reports/0084`, clip real de 65 frames,
pico ~7.7 GB) y **Wan2.1 T2V 1.3B** (`model='wan'`, diffusion + umt5 + vae aparte — plantilla nativa
canónica). El resultado es un `.mp4` vía `CreateVideo → SaveVideo`.
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_build_video_workflow_py_ml](../../python/functions/ml/comfyui_build_video_workflow.md) | `build_video_workflow(prompt, *, model='ltx', negative='', width=512, height=320, num_frames=65, steps=20, seed=0, fps=24) -> dict` | Builder txt2video para LTX-Video 2B (`model='ltx'`, 12 nodos LTXV*) o Wan2.1 1.3B (`model='wan'`, UNETLoader+VAELoader+ModelSamplingSD3). Nombres de modelo reales, defaults conservadores 8 GB. **Pura**. |
| [comfyui_build_img2vid_workflow_py_ml](../../python/functions/ml/comfyui_build_img2vid_workflow.md) | `build_img2vid_workflow(image, *, ckpt='svd.safetensors', width=1024, height=576, video_frames=14, motion_bucket_id=127, fps=6, augmentation_level=0.0, steps=20, cfg=2.5, min_cfg=1.0, seed=0, sampler_name='euler', scheduler='karras', filename_prefix='comfy_svd') -> dict` | Builder img2vid (Stable Video Diffusion): anima una imagen estática a clip corto. ImageOnlyCheckpointLoader(`svd.safetensors`, todo-en-uno) + LoadImage → SVD_img2vid_Conditioning → VideoLinearCFGGuidance → KSampler (denoise 1.0) → VAEDecode → SaveAnimatedWEBP. SVD no usa prompt de texto: condiciona por CLIP_VISION de la imagen; movimiento vía `motion_bucket_id`. **Pura**. |
### Imagen → 3D (Hunyuan3D-2 nativo) — dominio `ml` + `pipelines` (tag `img-to-3d`)
ComfyUI ≥ 0.26.0 trae **soporte nativo de Hunyuan3D-2** (sin custom node): una imagen se
reconstruye en una malla 3D GLB con un grafo de 9 nodos (`LoadImage → ImageOnlyCheckpointLoader
→ 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`. Para mejorar la cara trasera/laterales,
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`,
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, ..., 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, 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_fetch_output_mesh_py_ml](../../python/functions/ml/comfyui_fetch_output_mesh.md) | `fetch_output_mesh(prompt_id, *, server, dest=None, timeout) -> dict` | Localiza la malla en `/history/{prompt_id}` (el SaveGLB la expone bajo la clave `"3d"`, no `"images"`) y la baja via GET `/view` a disco. Hermana de `fetch_output_image`. Impura. |
| [comfyui_install_3d_model_py_ml](../../python/functions/ml/comfyui_install_3d_model.md) | `install_3d_model(variant='mini', *, hf_token=None, comfyui_dir) -> dict` | Instala el checkpoint Hunyuan3D-2 (mini/standard/mv) en `checkpoints/`. Cascada: ya-instalado → cache de HF → descarga. Resuelve la ruta real via `extra_model_paths.yaml`. Impura. |
| [comfyui_image_to_3d_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_image_to_3d_oneshot.md) | `image_to_3d_oneshot(image_path, *, server, variant='mini', dest=None, wait_timeout, **gen) -> dict` | **Pipeline** imagen en disco → malla GLB en una llamada: upload + build + submit + wait + fetch. Promoción de la secuencia (issue 0087). Impuro. |
| [comfyui_text_to_3d_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_text_to_3d_oneshot.md) | `text_to_3d_oneshot(prompt, *, server, ckpt_name='v1-5-pruned-emaonly.safetensors', negative='', textured=False, variant='mini', dest=None, ...) -> dict` | **Pipeline** prompt de texto → malla 3D GLB en una llamada: txt2img (SD) + fetch + upload + build 3D (nativo o `textured=True` multi-vista PBR) + submit + wait + fetch_mesh. Promoción de la secuencia texto→imagen→3D (issue 0087). Impuro. |
| [comfyui_build_textured_3d_multiview_workflow_py_ml](../../python/functions/ml/comfyui_build_textured_3d_multiview_workflow.md) | `build_textured_3d_multiview_workflow(image_name, *, ckpt='hunyuan3d-dit-v2-mv.safetensors', views=6, octree=384, max_faces=50000, upscale_model='4x_foolhardy_Remacri.pth') -> dict` | Builder imagen→malla 3D **con textura PBR** vía el wrapper Hunyuan3DWrapper (kijai): 4/6 vistas + delight + sample multi-vista + upscale Remacri + bake sobre UV (19 nodos). Cobertura de atlas 32.93% (report 0082). **Pura**. En 8 GB ejecutar en 2 fases (shape→`/free`→paint). |
| [comfyui_simplify_mesh_py_ml](../../python/functions/ml/comfyui_simplify_mesh.md) | `simplify_mesh(in_path, *, target_faces=80000, weld=True, out_path=None) -> dict` | **Post-proceso**: decima un GLB/OBJ/PLY denso (suelda cube-soup + quadric edge collapse de pymeshlab), conservando vertex colors o textura+UV. 964k→80k caras, 34.7→1.43 MB medido (report 0090). `weld=True` es clave: sin él la cube-soup de `VoxelToMeshBasic` no decima. Impura (trimesh+pymeshlab+scipy). |
| [comfyui_make_watertight_py_ml](../../python/functions/ml/comfyui_make_watertight.md) | `make_watertight(in_path, *, method='voxel', pitch=None, out_path=None) -> dict` | **Post-proceso**: hace estanca una malla. `method='voxel'` (voxeliza+fill+marching cubes) garantiza `is_watertight=True` a costa de más caras y de descartar la apariencia; `method='repair'` (fill_holes+fix_normals) conserva detalle pero no garantiza estanqueidad. La vía de raíz es `VoxelToMesh surface net` (report 0088). Impura. |
| [comfyui_mesh_cleanup_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_mesh_cleanup_oneshot.md) | `mesh_cleanup_oneshot(in_path, *, target_faces=80000, watertight=True, method='repair', out_path=None) -> dict` | **Pipeline** de limpieza en una llamada: `simplify_mesh` → (si `watertight`) `make_watertight`. Capitaliza el "80k caras + estanco" del report 0088. `method='voxel'` garantiza estanqueidad; `method='repair'` conserva caras. Reporta `{in_faces, simplified_faces, final_faces, is_watertight}`. Impuro. |
### Por la UI web (CDP) — dominio `browser`
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_load_workflow_ui_py_browser](../../python/functions/browser/comfyui_load_workflow_ui.md) | `load_workflow_ui(workflow, *, port=9222, server_url_substr='8188', filename, timeout_s) -> dict` | Carga un workflow API format en el grafo visual (`app.loadApiJson`). Impura (CDP + muta UI). |
| [comfyui_set_node_widget_ui_py_browser](../../python/functions/browser/comfyui_set_node_widget_ui.md) | `set_node_widget_ui(node, widget_name, value, *, match='type', port, server_url_substr, timeout_s) -> dict` | Edita en vivo un widget de un nodo (texto del CLIPTextEncode, steps/seed/cfg del KSampler). Localiza por type/id/title. Impura. |
| [comfyui_queue_prompt_ui_py_browser](../../python/functions/browser/comfyui_queue_prompt_ui.md) | `queue_prompt_ui(*, port, server_url_substr, timeout_s) -> dict` | Encola el grafo actual (`app.queuePrompt(0)`), = botón "Queue Prompt". Impura (dispara GPU). |
| [comfyui_export_workflow_ui_py_browser](../../python/functions/browser/comfyui_export_workflow_ui.md) | `export_workflow_ui(*, port, server_url_substr, api_format=True, save_path, timeout_s) -> dict` | Exporta el grafo actual: API format (`graphToPrompt().output`) o UI graph (`graph.serialize()`); opcional a disco. Impura. |
| [comfyui_refresh_nodes_ui_py_browser](../../python/functions/browser/comfyui_refresh_nodes_ui.md) | `refresh_nodes_ui(*, port, server_url_substr, timeout_s) -> dict` | Refresca los combos (checkpoints/loras/vae) sin recargar la página (`app.refreshComboInNodes`). Impura. |
## Ejemplo canónico end-to-end (build → load → tune → queue → resultado)
Combina API + UI: construyes el workflow por API, lo cargas en la UI del usuario, ajustas el
prompt y los pasos en vivo, encolas y esperas el PNG. Requiere el server en `127.0.0.1:8188`
y la pestaña de ComfyUI abierta en un Chrome con `--remote-debugging-port=9222`.
```python
import sys, os, time, glob
sys.path.insert(0, os.path.join("python", "functions"))
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
from browser.comfyui_load_workflow_ui import comfyui_load_workflow_ui
from browser.comfyui_set_node_widget_ui import comfyui_set_node_widget_ui
from browser.comfyui_queue_prompt_ui import comfyui_queue_prompt_ui
# 1. Construir (API format, función pura) con un prefijo de salida localizable.
prefix = f"demo_{int(time.time())}"
wf = comfyui_build_txt2img_workflow(
ckpt_name="dreamshaper_8.safetensors",
positive="placeholder",
steps=8, seed=111, filename_prefix=prefix,
)
# 2. Cargar el grafo en la UI del navegador del usuario.
comfyui_load_workflow_ui(wf) # {'ok': True, 'loaded': True}
# 3. Tuning en vivo: prompt (widget de texto) + pasos (widget numérico).
comfyui_set_node_widget_ui("CLIPTextEncode", "text",
"a green glass bottle on a marble shelf", match="type")
comfyui_set_node_widget_ui("KSampler", "steps", 12, match="type")
# 4. Encolar (= pulsar "Queue Prompt") y localizar el PNG nuevo en output/.
comfyui_queue_prompt_ui() # {'ok': True, 'queued': True}
before = set(glob.glob(os.path.expanduser("~/ComfyUI/output/*.png")))
while True:
new = [p for p in set(glob.glob(os.path.expanduser("~/ComfyUI/output/*.png"))) - before
if prefix in os.path.basename(p)]
if new:
print("PNG generado:", new[0]); break
time.sleep(1.5)
```
Variante 100% headless (sin navegador): cambia los pasos 2-4 por
`comfyui_submit_workflow(wf)``comfyui_wait_result(prompt_id)`. Misma capacidad, sin UI.
## Ejemplo canónico imagen → 3D (Hunyuan3D-2 nativo)
Una imagen de un objeto → su malla GLB, en una sola llamada. Requiere el server en
`127.0.0.1:8188` y el checkpoint mini instalado (lo hace `install_3d_model` la primera vez,
reutilizando la cache de HF; ~60 s de GPU por reconstrucción en una RTX 3070).
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from ml.comfyui_install_3d_model import comfyui_install_3d_model
from pipelines.comfyui_image_to_3d_oneshot import comfyui_image_to_3d_oneshot
# 1. Asegurar el checkpoint (instantáneo si ya está; reused_cache=True).
comfyui_install_3d_model("mini")
# 2. Imagen en disco -> malla GLB en /tmp/meshes.
res = comfyui_image_to_3d_oneshot(
os.path.expanduser("~/ComfyUI/input/3d_src_robot_00001_.png"),
dest="/tmp/meshes", variant="mini", seed=42,
)
print(res["mesh_path"], res["faces"]) # /tmp/meshes/3d_mesh_00001_.glb 1668040
```
Para tunear nodo a nodo en vez del oneshot: `build_image_to_3d_workflow(image_name)`
`submit_workflow``wait_result``fetch_output_mesh(prompt_id, dest=...)`.
## Fronteras
- **No es un grupo de generación genérica de imágenes**: cubre ComfyUI concretamente (su API
y su frontend litegraph). Para otros backends (Automatic1111, diffusers) harían falta otras
funciones.
- **Los builders cubren txt2img, img2img, upscale (ESRGAN y hires-fix con re-difusión), LoRA
stacks, inpaint, ControlNet, SDXL refiner, FaceDetailer, vídeo (LTX/Wan) y 3D texturizado
multi-vista** (`build_txt2img_workflow`, `build_img2img_workflow`, `build_upscale_workflow`,
`build_hires_fix_workflow`, `inject_lora`, `build_inpaint_workflow`, `build_controlnet_workflow`,
`build_sdxl_refiner_workflow`, `build_facedetailer_workflow`, `build_video_workflow`,
`build_textured_3d_multiview_workflow`). Lo que aún NO tiene builder propio (IPAdapter,
multi-ControlNet avanzado) se monta en la UI a mano y se captura con `export_workflow_ui`, o se
importa de internet con `import_workflow_json`/`import_workflow_png`, se resuelven sus dependencias
con `resolve_workflow_deps` (instala nodos con `install_custom_node`, descubre modelos con
`search_civitai_models`) y se valida con `validate_workflow` antes de encolar.
- **Los 13 builders puros tienen tests de estructura** (`python/functions/ml/tests/test_comfyui_build_*.py`
+ `test_comfyui_inject_lora.py`): verifican los `class_type` esperados, que los parámetros se reflejan
en los nodos, la validez de las conexiones `[node_id, output_index]` y la pureza de `inject_lora`. Son
tests offline (no tocan GPU ni server); las funciones impuras del grupo (todo lo que habla con el server,
el navegador o Civitai/HuggingFace) no se cubren con unit tests por diseño — se validan con el server vivo.
- **Control de cola**: `interrupt_queue` corta la generación en curso + lee `/queue`; `batch_generate`
encola N variantes por seed (re-roll). No vacían la cola entera (eso es `POST /queue {"clear": true}`).
- **Las funciones `*_ui` requieren la pestaña abierta y el navegador con CDP** (puerto 9222 por
defecto). Sin target que matchee `server_url_substr`, devuelven `ok=False`. Para automatización
desatendida sin navegador, usa el camino API (`submit_workflow` + `wait_result`).
- **`download_model` no gestiona el catálogo del server**: tras bajar un modelo, llama
`refresh_nodes_ui` (o recarga la página) para que ComfyUI lo vea en los combos.
- **El camino imagen→3D nativo es shape-only**: los nodos nativos de Hunyuan3D-2
(`build_image_to_3d_workflow`, `fetch_output_mesh`, `install_3d_model`, `image_to_3d_oneshot`)
reconstruyen la FORMA, sin color ni textura horneada. Para **textura PBR** está
`build_textured_3d_multiview_workflow`, que usa el wrapper de kijai (requiere `custom_rasterizer`
CUDA + `ComfyUI_essentials` + el upscaler Remacri) y debe ejecutarse en 2 fases en 8 GB
(shape→`/free`→paint). Detalle y cobertura medida en `reports/0082`; shape-only y comparación vs la
app local en `reports/0069-2026-06-23-comfyui-img-to-3d.md`.
- **Estanqueidad de la malla**: el default de `build_image_to_3d_workflow` (`VoxelToMeshBasic`) da
malla NO estanca; con `watertight=True` (`VoxelToMesh surface-net`) sale estanca de raíz. Si ya
tienes el GLB en disco, `mesh_cleanup_oneshot` decima + cierra en una llamada (`method='voxel'`
garantiza `is_watertight=True`; `method='repair'` conserva caras sin garantía). Ver `reports/0088`.
- La primitiva de transport CDP es [`cdp_eval`](../../python/functions/browser/cdp_eval.md) (grupo
navegador): si necesitas leer/escribir algo del grafo que estas funciones no cubren, compón
`cdp_eval` directamente antes de inventar nada.
+30 -16
View File
@@ -10,24 +10,27 @@ partir de una sola foto se estima un mapa de profundidad monocular con un modelo
reconstruye una malla de relieve (heightmap) texturizada con la imagen original, exportada como
`.glb` cargable por cualquier visor glTF (three.js `useGLTF`/`GLTFLoader`, Babylon, model-viewer).
Promovido desde la app `img_to_3d_webapp` (su backend incrustaba estas dos funciones; ver su
`backend/depth.py`). El flujo canonico es de **dos pasos encadenados**:
Promovido desde la app `img_to_3d_webapp` (su backend incrustaba estas funciones; ver
`backend/depth.py` y `backend/bg_removal.py`). El flujo canonico encadena un pre-proceso opcional
de fondo con los dos pasos de reconstruccion:
```
estimate_image_depth (imagen -> depth+image) -> depth_to_relief_glb (depth+image -> .glb)
[remove_background (imagen -> rgb+mask)] -> estimate_image_depth (imagen -> depth+image) -> depth_to_relief_glb (depth+image[+mask] -> .glb)
```
## Funciones
| ID | Firma corta | Que hace |
|---|---|---|
| `remove_background_py_datascience` | `remove_background(image_path, engine?) -> dict` | **Pre-proceso (paso 0).** Elimina el fondo en cascada rembg -> GrabCut -> umbral y compone el objeto sobre gris neutro. Devuelve `image` PIL + `mask` ndarray. La `mask` se pasa a `depth_to_relief_glb` para recortar la malla al objeto. |
| `estimate_image_depth_py_datascience` | `estimate_image_depth(image_path, model_name?, device?, use_cache?) -> dict` | Estima profundidad monocular con Depth-Anything-V2 (GPU/CPU). Devuelve `depth` ndarray [0,1] + `image` PIL. Paso 1. |
| `depth_to_relief_glb_py_datascience` | `depth_to_relief_glb(image, depth, out_glb_path, z_scale?, max_dim?) -> dict` | Convierte `depth`+`image` en una malla de relieve texturizada y la exporta a `.glb`. Paso 2. |
| `build_relief_glb_from_image_py_pipelines` | `build_relief_glb_from_image(image_path, out_glb_path, model_name?, device?, z_scale?, max_dim?) -> dict` | **Pipeline one-shot**: compone los dos pasos en una sola llamada (imagen -> .glb). Salida JSON-serializable, apta para `fn run`. |
| `depth_to_relief_glb_py_datascience` | `depth_to_relief_glb(image, depth, out_glb_path, z_scale?, max_dim?, mask?) -> dict` | Convierte `depth`+`image` en una malla de relieve texturizada y la exporta a `.glb`. Con `mask` opcional recorta las caras del fondo. Paso 2. |
| `build_relief_glb_from_image_py_pipelines` | `build_relief_glb_from_image(image_path, out_glb_path, model_name?, device?, z_scale?, max_dim?) -> dict` | **Pipeline one-shot**: compone estimacion + relieve en una sola llamada (imagen -> .glb). Salida JSON-serializable, apta para `fn run`. |
Las tres son **impuras** (cargan modelo / GPU / escriben archivo), devuelven `dict` con `status`
Las cuatro son **impuras** (cargan modelo / GPU / escriben archivo), devuelven `dict` con `status`
(`ok`/`error`) y **nunca lanzan**: los fallos vuelven como `{status:'error', error:str}`. El
pipeline ademas marca `stage` (`estimate`/`relief`) en el error.
pipeline ademas marca `stage` (`estimate`/`relief`) en el error. `remove_background` en
`engine="auto"` nunca falla (cae al umbral NumPy puro sin deps externas).
## Ejemplo canonico (end-to-end imagen → glb)
@@ -37,17 +40,24 @@ pipeline ademas marca `stage` (`estimate`/`relief`) en el error.
# ausentes en el venv de vision. Ver "Fronteras / gotchas".
import sys
sys.path.insert(0, "python/functions/datascience")
from remove_background import remove_background
from estimate_image_depth import estimate_image_depth
from depth_to_relief_glb import depth_to_relief_glb
IMG = "apps/img_to_3d_webapp/samples/cats.jpg"
OUT = "/tmp/cats_relief.glb"
# Paso 0 (opcional pero recomendado): aislar el objeto del fondo. La mask recorta la malla.
cut = remove_background(IMG) # engine='auto' -> rembg -> grabcut -> umbral
assert cut["status"] == "ok"
print(cut["engine"], cut["fg_fraction"]) # p.ej. rembg:u2net 0.42
est = estimate_image_depth(IMG) # device='auto' -> GPU si hay
assert est["status"] == "ok"
# est["depth"]: ndarray HxW float32 [0,1] (1=mas cerca) | est["image"]: PIL.Image RGB
res = depth_to_relief_glb(est["image"], est["depth"], OUT, z_scale=0.35, max_dim=220)
# Pasando la mask del paso 0, las caras del fondo se descartan: malla solo del objeto.
res = depth_to_relief_glb(est["image"], est["depth"], OUT, z_scale=0.35, max_dim=220, mask=cut["mask"])
assert res["status"] == "ok"
print(res["glb_path"], res["vertices"], res["faces"]) # /tmp/cats_relief.glb 36300 71832
# OUT es un glTF binario valido: trimesh.load(OUT) devuelve una Scene texturizada.
@@ -70,15 +80,19 @@ O en una sola llamada con el pipeline (recomendado para fn run / Launcher TUI):
- **No cubre el render/visualizacion.** Producir el `.glb` es el limite del grupo. Cargarlo y
subirlo a GPU (OpenGL) en una app C++/ImGui es el grupo **`mesh-3d`** (`gltf_load_mesh_cpp_gfx`
carga justamente este tipo de `.glb`). img-to-3d **produce**; mesh-3d **consume/renderiza**.
- **Deps pesadas y de dos mundos.** Requiere `torch`+`transformers` (vision) y `trimesh` (mesh),
que hoy viven en el venv de `img_to_3d_webapp`, NO en el venv del registry. Ademas el
`datascience.__init__` arrastra deps de scrapers (`bs4`...) que no estan en el venv de vision,
por eso el import es **plano** (al modulo) y no via el paquete. `fn run` de estas funciones
exige un venv que combine ambos mundos (torch + transformers + trimesh + las deps del dominio
datascience). Ver gotchas en cada `.md`.
- **Deps pesadas y de dos mundos.** Requiere `torch`+`transformers` (vision), `trimesh` (mesh) y,
para `remove_background`, `rembg`+`onnxruntime` (segmentacion) y `opencv-python` (GrabCut) —
todas opcionales: el umbral de `remove_background` es NumPy puro. Hoy viven en el venv de
`img_to_3d_webapp`, NO en el venv del registry. Ademas el `datascience.__init__` arrastra deps
de scrapers (`bs4`...) que no estan en el venv de vision, por eso el import es **plano** (al
modulo) y no via el paquete. `fn run` de estas funciones exige un venv que combine ambos mundos
(torch + transformers + trimesh + rembg/opencv + las deps del dominio datascience). Ver gotchas
en cada `.md`.
## Prerequisitos
- GPU NVIDIA + CUDA recomendada (corre en CPU pero lento). Primera ejecucion descarga los pesos
del modelo a `~/.cache/huggingface/` (cientos de MB segun la variante).
- Paquetes: `torch`, `transformers`, `trimesh`, `pillow`, `numpy`.
del modelo de profundidad a `~/.cache/huggingface/` y el de `rembg` (U2Net ~170 MB) a su cache.
- Paquetes: `torch`, `transformers`, `trimesh`, `pillow`, `numpy`. Para el recorte de fondo de
mayor calidad: `rembg` (+`onnxruntime`) y `opencv-python` (ambos opcionales; sin ellos
`remove_background` cae al umbral NumPy).
+70
View File
@@ -0,0 +1,70 @@
# Capability: sql-connect
Conexión directa y consulta a un **Microsoft SQL Server** desde el registry, con el caso prioritario de **Navision** (el ERP corre sobre SQL Server). Las funciones Python usan el driver **pymssql** (más simple en Linux/WSL que pyodbc: trae FreeTDS embebido, no necesita ODBC driver manager).
Existe para **eliminar el ida y vuelta manual** con Navision: en vez de escribir una query, que el usuario la ejecute en su SGBD y pegue el CSV, estas funciones se conectan al servidor y devuelven las filas — iteración rápida sobre una query en un solo comando.
## Funciones
| ID | Firma | Que hace |
|---|---|---|
| `mssql_connect_py_infra` | `mssql_connect(host, database, user, password, port=1433, login_timeout=15, query_timeout=30) -> pymssql.Connection` | Abre una conexión a SQL Server vía pymssql. Credenciales por argumento (nunca hardcodeadas). `login_timeout` acota la fase de login para que un host inalcanzable no cuelgue. Devuelve la conexión abierta; el caller la cierra con `.close()`. Lanza `RuntimeError` claro (host:port/db) si falla. |
| `mssql_query_py_infra` | `mssql_query(conn, sql, params=None, max_rows=None) -> dict` | Ejecuta una SELECT parametrizada sobre una conexión abierta y mapea las filas a dicts. Binding seguro del driver (placeholders `%s`/`%(nombre)s`, sin inyección). Devuelve `{columns, rows:[{col:val}], row_count}`. 0 filas → lista vacía sin error. `max_rows` limita con `fetchmany`. Read-only (no commit), no cierra la conexión. |
| `run_mssql_query_py_pipelines` | `run_mssql_query(host, database, user, password, sql, params=None, port=1433, max_rows=None, login_timeout=15, query_timeout=30) -> dict` | **Pipeline one-shot**: compone `mssql_connect` + `mssql_query` y cierra siempre la conexión (try/finally). CLI imprime JSON o CSV. Para iterar sobre una query de Navision en un solo `fn run`. |
## Ejemplo canónico
One-shot para iterar sobre Navision (la contraseña se lee de una env var, nunca se pasa por la línea de comandos):
```bash
cd /home/egutierrez/fn_registry
MSSQL_PASSWORD=$(pass navision/password) \
./fn run run_mssql_query \
--host 10.0.0.5 --database navdb --user sa \
--sql "SELECT TOP 5 [No_], [Amount] FROM [dbo].[Cartera] WHERE [Customer No_] = %s" \
--param CLI-0001 \
--format csv
```
Conexión persistente para muchas queries seguidas (abrir una vez, consultar N veces):
```python
import os, sys
sys.path.insert(0, "python/functions")
from infra.mssql_connect import mssql_connect
from infra.mssql_query import mssql_query
conn = mssql_connect("10.0.0.5", "navdb", "sa", os.environ["MSSQL_PASSWORD"])
try:
abiertos = mssql_query(
conn,
"SELECT [No_], [Amount] FROM [dbo].[Cartera] WHERE [Open] = 1 AND [Customer No_] = %s",
params=("CLI-0001",),
)
print(abiertos["row_count"], abiertos["columns"])
posted = mssql_query(conn, "SELECT TOP 10 [Document No_], [Amount] FROM [dbo].[Posted Cartera]")
print(posted["rows"])
finally:
conn.close()
```
## Gotchas del grupo
- **Conectividad WSL2 → Windows**: el `host` debe ser la **IP LAN del Windows** que corre SQL Server, NO `localhost` (desde WSL2 localhost no alcanza al host Windows). Ver memoria `wsl2-localhost-forwarding`. Probablemente el servidor real de Navision no sea alcanzable desde un entorno aislado sin red a la oficina + credenciales.
- **Credenciales desde `pass`, nunca hardcodeadas.** Patrón: `MSSQL_PASSWORD=$(pass navision/password) ./fn run run_mssql_query ...`. La función recibe la contraseña como argumento; el caller la resuelve. `--password` literal existe pero queda visible en la lista de procesos — usa `--password-env`.
- **Placeholders pymssql** son `%s` (posicional) y `%(nombre)s` (nombrado), NO `?` (eso es pyodbc). Pasa los valores como `params`, jamás concatenados en el SQL (inyección).
- **`mssql_query` no abre ni cierra la conexión** — la toma prestada. Para ráfagas de queries, abre con `mssql_connect` una vez y reúsala; el pipeline `run_mssql_query` abre y cierra por llamada (cómodo, no eficiente en ráfaga).
- **Read-only por uso**: pensado para SELECT (Navision: cartera, posted cartera, movimientos). No hace commit.
- **Requiere `pymssql`** instalado en el venv (`uv add pymssql`). Import perezoso: el módulo carga sin la dependencia, pero la llamada falla con `RuntimeError` claro si falta.
- **Datos sintéticos en ejemplos** [POL-MMNSEG-001-1.0]: los `No_`/`Customer No_` de los ejemplos son ficticios. Sobre datos reales de Navision aplica la política de protección de datos.
## Fronteras
- **Solo SQL Server (Navision)**. No es una capa SQL genérica: para PostgreSQL usa el grupo `postgres`; para DuckDB el grupo `duckdb`. Generalizar a MySQL/otros engines sería especulativo (KISS) hasta que haya un caso real.
- **No es ETL ni BI**: solo conecta y devuelve filas. Para llevar datos de Navision a un destino analítico, compón con los grupos `duckdb`/`postgres` (cargar las filas) o léelas en un notebook.
- **No gestiona el servidor** (no crea bases, no administra logins). Solo cliente de lectura.
## Relación con otros grupos
- `postgres` / `duckdb` — capas CRUD para otros engines; mismo espíritu (conectar + consultar), distinto motor. SQL Server (Navision) es la fuente; esos son destinos analíticos/BI.
- `metabase` / `bigquery` — el trabajo Aurgi consume datos ya en BigQuery/Metabase; este grupo abre la puerta a leer Navision en origen para iterar queries antes de modelarlas.
@@ -0,0 +1,67 @@
---
name: comfyui_clear_node_outputs_ui
kind: function
lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def comfyui_clear_node_outputs_ui(*, port: int = 9222, server_url_substr: str = '8188', timeout_s: float = 15.0) -> dict"
description: "Limpia outputs/previews residuales de TODOS los nodos del grafo de ComfyUI en la UI via CDP: vacia app.nodeOutputs (store de previews keyed by node_id) y borra imgs/images de cada nodo vivo, sin tocar la topologia del grafo (no borra nodos ni links). Arregla el bug de imagenes pegadas a nodos que no corresponden tras cargar un workflow nuevo con app.loadApiJson. Compone cdp_eval. Impura: red (CDP) + muta la UI."
tags: [comfyui, browser, cdp, ml, ui-automation, image-generation]
uses_functions: ["cdp_eval_py_browser"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: port
desc: "Puerto de remote debugging del Chrome diario. Default 9222."
- name: server_url_substr
desc: "Substring de la URL de la pestana de ComfyUI (default '8188', el puerto del server). Identifica la pestana entre las abiertas."
- name: timeout_s
desc: "Timeout de la conexion CDP en segundos. Default 15.0."
output: "dict {ok, cleared, error, store_cleared, nodes_touched, nodes}. ok/cleared True si la limpieza termino sin excepcion. store_cleared = entradas borradas de app.nodeOutputs; nodes_touched = nodos a los que se les quito un preview; nodes = total de nodos del grafo."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/browser/comfyui_clear_node_outputs_ui.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from browser.comfyui_clear_node_outputs_ui import comfyui_clear_node_outputs_ui
# Requiere la UI de ComfyUI abierta en el Chrome con CDP en el puerto 9222.
print(comfyui_clear_node_outputs_ui())
# -> {'ok': True, 'cleared': True, 'error': '', 'store_cleared': 6, 'nodes_touched': 2, 'nodes': 12}
```
## Cuando usarla
Cuando ves previews/outputs de imagenes pegados a nodos que no los produjeron
(una imagen bajo un `CheckpointLoaderSimple`, un `SaveGLB`, etc.) tras haber
cargado varios workflows seguidos en la misma pestana. Es la limpieza no
destructiva: borra los previews residuales del grafo actual SIN recargarlo ni
perder la topologia. `comfyui_load_workflow_ui(..., clear_outputs=True)` la
invoca automaticamente antes de cargar, asi que normalmente no hace falta
llamarla a mano; usala solo para limpiar un grafo ya cargado sin recargarlo.
## Gotchas
- Requiere la pestana de ComfyUI abierta en un Chrome con
`--remote-debugging-port=9222`. Si no hay target que matchee
`server_url_substr`, `cdp_eval` devuelve error y aqui `ok=False`.
- Borra TODOS los previews del grafo, incluidos los legitimos de la ultima
ejecucion. Si quieres conservar un preview concreto, no la llames; el residuo
cross-workflow se evita de raiz cargando con
`comfyui_load_workflow_ui(..., clear_outputs=True)`.
- No es `app.clean()`: a proposito NO hace `rootGraph.clear()`, por eso es
segura sobre el grafo vivo del usuario (no borra nodos ni conexiones).
- El store que vacia es `app.nodeOutputs`; el nombre interno puede variar entre
versiones de ComfyUI. Si una version renombra el store, el borrado del store
no aplica pero el barrido de `node.imgs`/`node.images` sigue limpiando los
previews visibles.
@@ -0,0 +1,105 @@
"""Limpia los outputs/previews residuales de los nodos de ComfyUI en la UI via CDP.
ComfyUI cachea los outputs de cada ejecucion en `app.nodeOutputs`, un store
indexado por node_id. La ruta de carga `app.loadApiJson` (la que usa
comfyui_load_workflow_ui) reconstruye el grafo pero NO resetea ese store ni los
previews de los nodos. Cuando un workflow nuevo reusa un node_id que ya existia
en el store, el preview cacheado del workflow anterior se vuelve a pintar sobre
el nodo nuevo, que muchas veces es de otro tipo (ej. una imagen pegada bajo un
`CheckpointLoaderSimple` o un `SaveGLB`).
Esta funcion vacia `app.nodeOutputs` y borra `imgs`/`images` de todos los nodos
vivos del grafo, sin tocar la topologia del grafo (no borra nodos ni links), y
marca el canvas dirty para repintar. Es la version no destructiva de
`app.clean()` (que ademas haria `rootGraph.clear()`).
Funcion impura: hace red (CDP WebSocket) y muta el estado de la UI.
"""
try: # ejecucion directa del archivo / fn run (browser/ en sys.path[0])
from cdp_eval import cdp_eval
except ImportError: # importado como paquete (sys.path = python/functions)
from browser.cdp_eval import cdp_eval
def comfyui_clear_node_outputs_ui(
*,
port: int = 9222,
server_url_substr: str = "8188",
timeout_s: float = 15.0,
) -> dict:
"""Limpia previews/outputs residuales de todos los nodos del grafo de ComfyUI.
Args:
port: puerto de remote debugging del Chrome diario. Default 9222.
server_url_substr: substring de la URL de la pestana de ComfyUI (default
"8188", el puerto del server). Identifica la pestana entre las
abiertas.
timeout_s: timeout de la conexion CDP en segundos.
Returns:
dict {ok: bool, cleared: bool, error: str, store_cleared: int,
nodes_touched: int, nodes: int}. ok/cleared True si la limpieza termino
sin excepcion en la pagina. `store_cleared` es el numero de entradas
eliminadas de `app.nodeOutputs`; `nodes_touched` los nodos a los que se
les quito un preview; `nodes` el total de nodos del grafo.
"""
expr = (
"(function(){"
" if(!window.app){ return {ok:false, cleared:false, error:'window.app no disponible en la pestana'}; }"
" try{"
" var store=0;"
" if(app.nodeOutputs){ for(var k in app.nodeOutputs){ if(Object.prototype.hasOwnProperty.call(app.nodeOutputs,k)){ delete app.nodeOutputs[k]; store++; } } }"
" var nodes=(app.graph && app.graph._nodes)? app.graph._nodes : [];"
" var touched=0;"
" for(var i=0;i<nodes.length;i++){"
" var nd=nodes[i];"
" if(nd.imgs!==undefined || nd.images!==undefined){ touched++; }"
" nd.imgs=undefined;"
" nd.images=undefined;"
" nd.imageIndex=null;"
" nd.overIndex=null;"
" if('animatedImages' in nd){ nd.animatedImages=undefined; }"
" }"
" if(app.graph && app.graph.setDirtyCanvas){ app.graph.setDirtyCanvas(true,true); }"
" return {ok:true, cleared:true, error:'', store_cleared:store, nodes_touched:touched, nodes:nodes.length};"
" }catch(e){ return {ok:false, cleared:false, error:String(e)}; }"
"})()"
)
r = cdp_eval(
expr,
port=port,
target_url_substr=server_url_substr,
await_promise=False,
timeout_s=timeout_s,
)
if not r["ok"]:
return {
"ok": False,
"cleared": False,
"error": r["error"],
"store_cleared": 0,
"nodes_touched": 0,
"nodes": 0,
}
val = r["value"] or {}
return {
"ok": bool(val.get("cleared")),
"cleared": bool(val.get("cleared")),
"error": val.get("error", ""),
"store_cleared": int(val.get("store_cleared", 0)),
"nodes_touched": int(val.get("nodes_touched", 0)),
"nodes": int(val.get("nodes", 0)),
}
if __name__ == "__main__":
import json
print(
json.dumps(
comfyui_clear_node_outputs_ui(),
ensure_ascii=False,
indent=2,
)
)
@@ -0,0 +1,66 @@
---
name: comfyui_export_workflow_ui
kind: function
lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def comfyui_export_workflow_ui(*, port: int = 9222, server_url_substr: str = '8188', api_format: bool = True, save_path: str | None = None, timeout_s: float = 15.0) -> dict"
description: "Exporta el workflow actual del grafo de ComfyUI desde la UI via CDP. Con api_format=True devuelve el API format ((await app.graphToPrompt()).output, listo para POST /prompt); con False el UI graph serializado (app.graph.serialize(), recargable en la UI). Opcionalmente escribe el JSON a disco. Compone cdp_eval. Impura: red (CDP) + escritura opcional."
tags: [comfyui, browser, cdp, ml, image-generation, stable-diffusion, ui-automation]
uses_functions: ["cdp_eval_py_browser"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json", "os"]
params:
- name: port
desc: "Puerto de remote debugging del Chrome diario. Default 9222."
- name: server_url_substr
desc: "Substring de la URL de la pestana de ComfyUI. Default '8188'."
- name: api_format
desc: "True devuelve el API format (POST /prompt); False el UI graph serializado (recargable con la UI). Default True."
- name: save_path
desc: "Si se pasa, ruta donde escribir el JSON (se expande ~ y se crean los padres). None no escribe a disco."
- name: timeout_s
desc: "Timeout de la conexion CDP en segundos. Default 15.0."
output: "dict {ok: bool, workflow: dict, saved_to: str|None, error: str}. workflow es el API format o el UI graph segun api_format; saved_to es la ruta escrita o None."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/browser/comfyui_export_workflow_ui.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from browser.comfyui_export_workflow_ui import comfyui_export_workflow_ui
# Captura el API format del grafo actual y guardalo a disco.
out = comfyui_export_workflow_ui(api_format=True, save_path="/tmp/wf_actual.json")
print(out["ok"], len(out["workflow"]), "nodos ->", out["saved_to"])
# El API format devuelto es re-enviable por API:
from ml.comfyui_submit_workflow import comfyui_submit_workflow
resp = comfyui_submit_workflow(out["workflow"])
```
## Cuando usarla
Para capturar lo que el usuario tiene montado en la UI y (a) re-enviarlo por API
con `comfyui_submit_workflow`, (b) persistirlo como plantilla, o (c) verificar
que un cambio hecho con `comfyui_set_node_widget_ui` quedo reflejado en el grafo.
Es el reverso de `comfyui_load_workflow_ui`.
## Gotchas
- `api_format=True` da el formato de POST /prompt (sin links visuales ni
posiciones); `api_format=False` da el grafo de UI (con todo lo necesario para
`app.loadGraphData`). Elige segun si vas a re-enviar por API o a recargar en UI.
- `graphToPrompt()` es asincrono: se espera la Promise (`await_promise=True`). Si
la pestana no tiene `window.app`, devuelve `ok=False` con error claro.
- El export refleja el estado EN VIVO del grafo, incluidos los cambios de
`comfyui_set_node_widget_ui` aplicados antes.
@@ -0,0 +1,96 @@
"""Exporta el workflow actual del grafo de ComfyUI desde la UI via CDP.
Con api_format=True devuelve el API format (el dict que acepta POST /prompt,
extraido de `(await app.graphToPrompt()).output`); con False devuelve el UI graph
serializado (`app.graph.serialize()`, con links y posiciones para volver a
cargar en la UI). Opcionalmente escribe el JSON a disco. Compone cdp_eval.
Funcion impura: hace red (CDP WebSocket) y, si save_path, escribe en disco.
"""
import json
import os
try: # ejecucion directa del archivo / fn run (browser/ en sys.path[0])
from cdp_eval import cdp_eval
except ImportError: # importado como paquete (sys.path = python/functions)
from browser.cdp_eval import cdp_eval
def comfyui_export_workflow_ui(
*,
port: int = 9222,
server_url_substr: str = "8188",
api_format: bool = True,
save_path: str | None = None,
timeout_s: float = 15.0,
) -> dict:
"""Exporta el workflow actual del grafo de la UI de ComfyUI.
Args:
port: puerto de remote debugging del Chrome diario. Default 9222.
server_url_substr: substring de la URL de la pestana de ComfyUI.
api_format: True devuelve el API format (POST /prompt); False devuelve el
UI graph serializado (recargable con la UI). Default True.
save_path: si se pasa, ruta donde escribir el JSON exportado. Se expande
~ y se crean los directorios padre. None no escribe a disco.
timeout_s: timeout de la conexion CDP en segundos.
Returns:
dict {ok: bool, workflow: dict, saved_to: str|None, error: str}.
"""
if api_format:
expr = (
"(async function(){"
" if(!window.app || typeof app.graphToPrompt!=='function'){"
" return {error:'window.app.graphToPrompt no disponible en la pestana'};"
" }"
" try{ var p = await app.graphToPrompt(); return {workflow: p.output, error:''}; }"
" catch(e){ return {error:String(e)}; }"
"})()"
)
await_p = True
else:
expr = (
"(function(){"
" if(!window.app || !app.graph || typeof app.graph.serialize!=='function'){"
" return {error:'window.app.graph.serialize no disponible en la pestana'};"
" }"
" try{ return {workflow: app.graph.serialize(), error:''}; }"
" catch(e){ return {error:String(e)}; }"
"})()"
)
await_p = False
r = cdp_eval(
expr,
port=port,
target_url_substr=server_url_substr,
await_promise=await_p,
timeout_s=timeout_s,
)
if not r["ok"]:
return {"ok": False, "workflow": {}, "saved_to": None, "error": r["error"]}
val = r["value"] or {}
if val.get("error"):
return {"ok": False, "workflow": {}, "saved_to": None, "error": val["error"]}
workflow = val.get("workflow") or {}
saved_to = None
if save_path:
path = os.path.expanduser(save_path)
parent = os.path.dirname(path)
if parent:
os.makedirs(parent, exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
json.dump(workflow, fh, ensure_ascii=False, indent=2)
saved_to = path
return {"ok": True, "workflow": workflow, "saved_to": saved_to, "error": ""}
if __name__ == "__main__":
out = comfyui_export_workflow_ui(api_format=True)
print(json.dumps(
{"ok": out["ok"], "nodes": len(out["workflow"]), "error": out["error"]},
ensure_ascii=False, indent=2,
))
@@ -0,0 +1,84 @@
---
name: comfyui_load_workflow_ui
kind: function
lang: py
domain: browser
version: "1.1.0"
purity: impure
signature: "def comfyui_load_workflow_ui(workflow: dict, *, port: int = 9222, server_url_substr: str = '8188', filename: str = 'workflow.json', clear_outputs: bool = True, timeout_s: float = 20.0) -> dict"
description: "Carga un workflow ComfyUI (API format) en la UI del navegador via CDP: inyecta app.loadApiJson(<workflow>, filename) en la pestana de ComfyUI abierta y reconstruye el grafo visual. Por defecto (clear_outputs=True) limpia antes los previews/outputs residuales para que un preview cacheado del workflow anterior no se pegue a un nodo nuevo que reusa el mismo node_id. Compone cdp_eval + comfyui_clear_node_outputs_ui. Impura: red (CDP WebSocket) + muta el grafo de la UI."
tags: [comfyui, browser, cdp, ml, image-generation, stable-diffusion, ui-automation]
uses_functions: ["cdp_eval_py_browser", "comfyui_clear_node_outputs_ui_py_browser"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json"]
params:
- name: workflow
desc: "dict en API format (claves = node_ids, valores con class_type + inputs); tipicamente el resultado de comfyui_build_txt2img_workflow."
- name: port
desc: "Puerto de remote debugging del Chrome diario. Default 9222."
- name: server_url_substr
desc: "Substring de la URL de la pestana de ComfyUI (default '8188', el puerto del server). Identifica la pestana entre las abiertas."
- name: filename
desc: "Nombre que ComfyUI asocia al workflow cargado. Default 'workflow.json'."
- name: clear_outputs
desc: "Si True (default) limpia previews/outputs residuales (app.nodeOutputs + node.imgs) antes de cargar, evitando que un preview cacheado de un workflow anterior se pegue a un nodo nuevo que reusa el mismo node_id. False conserva los previews previos a proposito."
- name: timeout_s
desc: "Timeout de la conexion CDP en segundos. Default 20.0."
output: "dict {ok: bool, loaded: bool, error: str}. ok/loaded True si app.loadApiJson termino sin excepcion en la pagina."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/browser/comfyui_load_workflow_ui.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
from browser.comfyui_load_workflow_ui import comfyui_load_workflow_ui
wf = comfyui_build_txt2img_workflow(
ckpt_name="dreamshaper_8.safetensors",
positive="a red apple on a wooden table, sharp focus",
)
# Requiere la UI de ComfyUI abierta en el Chrome con CDP en el puerto 9222.
print(comfyui_load_workflow_ui(wf)) # -> {'ok': True, 'loaded': True, 'error': ''}
```
## Cuando usarla
Cuando tienes un workflow en API format (lo construyo con
`comfyui_build_txt2img_workflow` o lo exporto de otro lado) y quieres verlo y
editarlo en la UI del navegador del usuario antes de encolarlo. Es el puente
"API format -> grafo visual": cargas, luego ajustas widgets con
`comfyui_set_node_widget_ui` y encolas con `comfyui_queue_prompt_ui`.
## Gotchas
- Requiere que la pestana de ComfyUI ya este abierta en un Chrome con
`--remote-debugging-port=9222`. Si no hay target que matchee `server_url_substr`,
`cdp_eval` devuelve error y aqui `ok=False`.
- `app.loadApiJson` REEMPLAZA el grafo actual de la UI por el del workflow; pierde
los cambios no exportados. Exporta antes con `comfyui_export_workflow_ui` si los
necesitas.
- Espera la Promise de carga (`await_promise=True`). El conteo de nodos cargados
se puede verificar con `cdp_eval("app.graph._nodes.length", target_url_substr="8188")`.
- `app.loadApiJson` (a diferencia de la ruta del menu `app.loadGraphData`) NO
llama a `app.clean()`, asi que NO resetea el store `app.nodeOutputs` ni los
previews de los nodos. Sin `clear_outputs=True`, un preview cacheado de un
workflow anterior se re-pinta sobre el nodo nuevo que reuse el mismo node_id
(visto: imagen 3D pegada bajo un `CheckpointLoaderSimple`/`SaveGLB`). El
default `clear_outputs=True` lo evita delegando en
`comfyui_clear_node_outputs_ui`.
## Capability growth log
- v1.1.0 (2026-06-24) — anade `clear_outputs=True` (default): limpia los
previews/outputs residuales (`app.nodeOutputs` + `node.imgs`) antes de cargar,
delegando en `comfyui_clear_node_outputs_ui`. Fija el bug de imagenes
residuales pegadas a nodos que reusan node_id entre workflows.
@@ -0,0 +1,96 @@
"""Carga un workflow ComfyUI (API format) en la UI del navegador via CDP.
Inyecta `app.loadApiJson(<workflow>, filename)` en la pestana de ComfyUI ya
abierta en el navegador diario, reconstruyendo el grafo visual a partir del API
format (el mismo dict que produce comfyui_build_txt2img_workflow). Compone la
primitiva de transport cdp_eval; no abre ventana nueva ni reinventa CDP.
Funcion impura: hace red (CDP WebSocket) y muta el grafo de la UI.
"""
import json
try: # ejecucion directa del archivo / fn run (browser/ en sys.path[0])
from cdp_eval import cdp_eval
from comfyui_clear_node_outputs_ui import comfyui_clear_node_outputs_ui
except ImportError: # importado como paquete (sys.path = python/functions)
from browser.cdp_eval import cdp_eval
from browser.comfyui_clear_node_outputs_ui import comfyui_clear_node_outputs_ui
def comfyui_load_workflow_ui(
workflow: dict,
*,
port: int = 9222,
server_url_substr: str = "8188",
filename: str = "workflow.json",
clear_outputs: bool = True,
timeout_s: float = 20.0,
) -> dict:
"""Carga un workflow API format en el grafo de la UI de ComfyUI.
Args:
workflow: dict en API format (claves = node_ids, valores con class_type +
inputs). Tipicamente el resultado de comfyui_build_txt2img_workflow.
port: puerto de remote debugging del Chrome diario. Default 9222.
server_url_substr: substring de la URL de la pestana de ComfyUI (default
"8188", el puerto del server). Identifica la pestana entre todas las
abiertas.
filename: nombre que ComfyUI asocia al workflow cargado.
clear_outputs: si True (default), limpia los previews/outputs residuales
(app.nodeOutputs + node.imgs) ANTES de cargar, replicando lo que hace
la ruta del menu (app.clean()). Evita que un preview cacheado de un
workflow anterior se pegue a un nodo nuevo que reusa el mismo node_id
(bug de imagenes residuales). Ponlo en False solo si quieres conservar
a proposito los previews del grafo previo.
timeout_s: timeout de la conexion CDP en segundos.
Returns:
dict {ok: bool, loaded: bool, error: str}. ok/loaded True si
app.loadApiJson termino sin excepcion en la pagina.
"""
if clear_outputs:
comfyui_clear_node_outputs_ui(
port=port,
server_url_substr=server_url_substr,
timeout_s=timeout_s,
)
expr = (
"(async function(){"
" if(!window.app || typeof app.loadApiJson!=='function'){"
" return {loaded:false, error:'window.app.loadApiJson no disponible en la pestana'};"
" }"
" try{"
f" await app.loadApiJson({json.dumps(workflow)}, {json.dumps(filename)});"
" return {loaded:true, error:'', nodes: app.graph? app.graph._nodes.length : -1};"
" }catch(e){ return {loaded:false, error:String(e)}; }"
"})()"
)
r = cdp_eval(
expr,
port=port,
target_url_substr=server_url_substr,
await_promise=True,
timeout_s=timeout_s,
)
if not r["ok"]:
return {"ok": False, "loaded": False, "error": r["error"]}
val = r["value"] or {}
return {
"ok": bool(val.get("loaded")),
"loaded": bool(val.get("loaded")),
"error": val.get("error", ""),
}
if __name__ == "__main__":
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
wf = comfyui_build_txt2img_workflow(
ckpt_name="v1-5-pruned-emaonly-fp16.safetensors",
positive="a red apple on a wooden table, sharp focus",
)
print(json.dumps(comfyui_load_workflow_ui(wf), ensure_ascii=False, indent=2))
@@ -0,0 +1,62 @@
---
name: comfyui_queue_prompt_ui
kind: function
lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def comfyui_queue_prompt_ui(*, port: int = 9222, server_url_substr: str = '8188', timeout_s: float = 20.0) -> dict"
description: "Encola el grafo actual de ComfyUI desde la UI (equivale a pulsar 'Queue Prompt'): llama app.queuePrompt(0) en la pestana, que serializa el grafo al API format y hace POST /prompt al server. Compone cdp_eval. Impura: red (CDP) + dispara trabajo de GPU."
tags: [comfyui, browser, cdp, ml, image-generation, stable-diffusion, ui-automation]
uses_functions: ["cdp_eval_py_browser"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json"]
params:
- name: port
desc: "Puerto de remote debugging del Chrome diario. Default 9222."
- name: server_url_substr
desc: "Substring de la URL de la pestana de ComfyUI. Default '8188'."
- name: timeout_s
desc: "Timeout de la conexion CDP en segundos. Default 20.0."
output: "dict {ok: bool, queued: bool, error: str}. queued True si app.queuePrompt resolvio sin excepcion."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/browser/comfyui_queue_prompt_ui.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from browser.comfyui_queue_prompt_ui import comfyui_queue_prompt_ui
from ml.comfyui_wait_result import comfyui_wait_result
print(comfyui_queue_prompt_ui()) # -> {'ok': True, 'queued': True, 'error': ''}
# El PNG aparece en ~/ComfyUI/output/. Para esperar el resultado por API se usa
# el prompt_id; si solo encolas desde la UI, sondea la carpeta output/ o usa el
# historial (GET /history) para localizar el archivo nuevo.
```
## Cuando usarla
Como ultimo paso del flujo por UI: tras cargar (`comfyui_load_workflow_ui`) y
ajustar widgets (`comfyui_set_node_widget_ui`), dispara la generacion sin que el
usuario pulse el boton. Reproduce exactamente "Queue Prompt" del frontend.
## Gotchas
- Tiene efecto secundario real: arranca trabajo de GPU en el server. No es
idempotente — cada llamada encola un prompt nuevo.
- `app.queuePrompt(0)` encola el grafo TAL CUAL esta en la UI en ese momento, no
un workflow que le pases. Para encolar uno concreto, cargalo antes con
`comfyui_load_workflow_ui`.
- No devuelve el `prompt_id` (la UI lo gestiona internamente). Para correlar el
resultado por API mejor usa `comfyui_submit_workflow` (devuelve prompt_id) +
`comfyui_wait_result`; esta funcion es para el caso "como si pulsara el boton".
- Si el grafo tiene errores de validacion, ComfyUI los muestra en la UI y la
Promise puede rechazar: aqui se refleja como `ok=False` con el error.
@@ -0,0 +1,61 @@
"""Encola el grafo actual de ComfyUI desde la UI (equivale a pulsar "Queue Prompt").
Llama `app.queuePrompt(0)` en la pestana de ComfyUI abierta en el navegador, que
serializa el grafo visual al API format y hace POST /prompt al server. Compone
cdp_eval.
Funcion impura: hace red (CDP WebSocket) y dispara trabajo de GPU en el server.
"""
import json
try: # ejecucion directa del archivo / fn run (browser/ en sys.path[0])
from cdp_eval import cdp_eval
except ImportError: # importado como paquete (sys.path = python/functions)
from browser.cdp_eval import cdp_eval
def comfyui_queue_prompt_ui(
*,
port: int = 9222,
server_url_substr: str = "8188",
timeout_s: float = 20.0,
) -> dict:
"""Encola el grafo actual de la UI de ComfyUI.
Args:
port: puerto de remote debugging del Chrome diario. Default 9222.
server_url_substr: substring de la URL de la pestana de ComfyUI.
timeout_s: timeout de la conexion CDP en segundos.
Returns:
dict {ok: bool, queued: bool, error: str}. queued True si
app.queuePrompt resolvio sin excepcion.
"""
expr = (
"(async function(){"
" if(!window.app || typeof app.queuePrompt!=='function'){"
" return {queued:false, error:'window.app.queuePrompt no disponible en la pestana'};"
" }"
" try{ await app.queuePrompt(0); return {queued:true, error:''}; }"
" catch(e){ return {queued:false, error:String(e)}; }"
"})()"
)
r = cdp_eval(
expr,
port=port,
target_url_substr=server_url_substr,
await_promise=True,
timeout_s=timeout_s,
)
if not r["ok"]:
return {"ok": False, "queued": False, "error": r["error"]}
val = r["value"] or {}
return {
"ok": bool(val.get("queued")),
"queued": bool(val.get("queued")),
"error": val.get("error", ""),
}
if __name__ == "__main__":
print(json.dumps(comfyui_queue_prompt_ui(), ensure_ascii=False, indent=2))
@@ -0,0 +1,60 @@
---
name: comfyui_refresh_nodes_ui
kind: function
lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def comfyui_refresh_nodes_ui(*, port: int = 9222, server_url_substr: str = '8188', timeout_s: float = 15.0) -> dict"
description: "Refresca los combos del grafo de ComfyUI desde la UI via CDP: llama app.refreshComboInNodes(), que vuelve a pedir GET /object_info y actualiza los combos de todos los nodos (checkpoints, loras, vae, samplers) sin recargar la pagina. Util tras descargar modelos nuevos. Compone cdp_eval. Impura: red (CDP) + refresca estado de la UI."
tags: [comfyui, browser, cdp, ml, image-generation, stable-diffusion, ui-automation]
uses_functions: ["cdp_eval_py_browser"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json"]
params:
- name: port
desc: "Puerto de remote debugging del Chrome diario. Default 9222."
- name: server_url_substr
desc: "Substring de la URL de la pestana de ComfyUI. Default '8188'."
- name: timeout_s
desc: "Timeout de la conexion CDP en segundos. Default 15.0."
output: "dict {ok: bool, refreshed: bool, error: str}. refreshed True si app.refreshComboInNodes resolvio sin excepcion."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/browser/comfyui_refresh_nodes_ui.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from ml.comfyui_download_model import comfyui_download_model
from browser.comfyui_refresh_nodes_ui import comfyui_refresh_nodes_ui
# Tras bajar un checkpoint nuevo, refresca los combos para que aparezca en los
# CheckpointLoaderSimple sin recargar la pagina.
comfyui_download_model("https://.../nuevo.safetensors", "checkpoints")
print(comfyui_refresh_nodes_ui()) # -> {'ok': True, 'refreshed': True, 'error': ''}
```
## Cuando usarla
Justo despues de añadir modelos a `~/ComfyUI/models/` (con
`comfyui_download_model` o a mano) para que los nodos de la UI vean los archivos
nuevos en sus combos sin un F5 que perderia el grafo no guardado.
## Gotchas
- Solo refresca combos (listas que vienen de /object_info): checkpoints, loras,
vae, samplers, schedulers. NO recarga el grafo ni cambia los valores ya
seleccionados.
- Si el server no ve aun el archivo nuevo (lo copiaste a la carpeta equivocada o
ComfyUI no reescanea), el combo seguira sin mostrarlo aunque `refreshed=True`:
el refresh fue exitoso pero el catalogo del server no lo incluye.
- Requiere la pestana de ComfyUI abierta en el Chrome con CDP; sin target,
`ok=False`.
@@ -0,0 +1,63 @@
"""Refresca los combos del grafo de ComfyUI desde la UI via CDP.
Llama `app.refreshComboInNodes()`, que vuelve a pedir GET /object_info al server
y actualiza los combos de todos los nodos (lista de checkpoints, loras, vaes,
samplers) sin recargar la pagina. Util tras descargar modelos nuevos con
comfyui_download_model para que aparezcan en los CheckpointLoaderSimple sin un
F5. Compone cdp_eval.
Funcion impura: hace red (CDP WebSocket) y refresca estado de la UI.
"""
import json
try: # ejecucion directa del archivo / fn run (browser/ en sys.path[0])
from cdp_eval import cdp_eval
except ImportError: # importado como paquete (sys.path = python/functions)
from browser.cdp_eval import cdp_eval
def comfyui_refresh_nodes_ui(
*,
port: int = 9222,
server_url_substr: str = "8188",
timeout_s: float = 15.0,
) -> dict:
"""Refresca los combos (checkpoints/loras/vae) de los nodos del grafo.
Args:
port: puerto de remote debugging del Chrome diario. Default 9222.
server_url_substr: substring de la URL de la pestana de ComfyUI.
timeout_s: timeout de la conexion CDP en segundos.
Returns:
dict {ok: bool, refreshed: bool, error: str}. refreshed True si
app.refreshComboInNodes resolvio sin excepcion.
"""
expr = (
"(async function(){"
" if(!window.app || typeof app.refreshComboInNodes!=='function'){"
" return {refreshed:false, error:'window.app.refreshComboInNodes no disponible en la pestana'};"
" }"
" try{ await app.refreshComboInNodes(); return {refreshed:true, error:''}; }"
" catch(e){ return {refreshed:false, error:String(e)}; }"
"})()"
)
r = cdp_eval(
expr,
port=port,
target_url_substr=server_url_substr,
await_promise=True,
timeout_s=timeout_s,
)
if not r["ok"]:
return {"ok": False, "refreshed": False, "error": r["error"]}
val = r["value"] or {}
return {
"ok": bool(val.get("refreshed")),
"refreshed": bool(val.get("refreshed")),
"error": val.get("error", ""),
}
if __name__ == "__main__":
print(json.dumps(comfyui_refresh_nodes_ui(), ensure_ascii=False, indent=2))
@@ -0,0 +1,72 @@
---
name: comfyui_set_node_widget_ui
kind: function
lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def comfyui_set_node_widget_ui(node: str, widget_name: str, value, *, match: str = 'type', port: int = 9222, server_url_substr: str = '8188', timeout_s: float = 15.0) -> dict"
description: "Edita en vivo el valor de un widget de un nodo del grafo de ComfyUI via CDP. Localiza el nodo en app.graph._nodes por type (comfyClass), id o title; asigna widget.value, invoca widget.callback si existe y marca el canvas dirty. Cubre widgets numericos (steps/cfg/seed) y de texto (CLIPTextEncode.text). Compone cdp_eval. Impura: red (CDP) + muta el grafo."
tags: [comfyui, browser, cdp, ml, image-generation, stable-diffusion, ui-automation]
uses_functions: ["cdp_eval_py_browser"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json"]
params:
- name: node
desc: "Identificador del nodo a localizar, interpretado segun `match`."
- name: widget_name
desc: "Nombre del widget a editar (ej. 'text', 'steps', 'seed', 'cfg', 'sampler_name')."
- name: value
desc: "Nuevo valor (str, int, float o bool). Se serializa a JSON para inyectarlo."
- name: match
desc: "Criterio de busqueda: 'type' (por comfyClass/type, ej. 'CLIPTextEncode'/'KSampler'), 'id' (por n.id) o 'title' (por titulo visible). Default 'type'."
- name: port
desc: "Puerto de remote debugging del Chrome diario. Default 9222."
- name: server_url_substr
desc: "Substring de la URL de la pestana de ComfyUI. Default '8188'."
- name: timeout_s
desc: "Timeout de la conexion CDP en segundos. Default 15.0."
output: "dict {ok, matched_nodes (int), set (bool), old_value, new_value, error}. Con match='type' y varios matches, actua sobre el primero y reporta cuantos coincidieron."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/browser/comfyui_set_node_widget_ui.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from browser.comfyui_set_node_widget_ui import comfyui_set_node_widget_ui
# Cambiar el prompt positivo (widget de texto del CLIPTextEncode) ...
print(comfyui_set_node_widget_ui(
"CLIPTextEncode", "text", "a blue ceramic mug, studio light", match="type"))
# ... y los pasos del sampler (widget numerico).
print(comfyui_set_node_widget_ui("KSampler", "steps", 25, match="type"))
# -> {'ok': True, 'matched_nodes': 2, 'set': True, 'old_value': 20, 'new_value': 25, 'error': ''}
```
## Cuando usarla
Para ajustar parametros de un workflow ya cargado en la UI sin reconstruirlo:
cambiar el prompt, los steps, la seed, el cfg o el sampler en vivo antes de
encolar con `comfyui_queue_prompt_ui`. Es el paso de "tuning" entre
`comfyui_load_workflow_ui` y la cola.
## Gotchas
- Con `match="type"` y un workflow txt2img hay DOS `CLIPTextEncode` (positivo y
negativo): `matched_nodes=2` y solo se edita el primero (el positivo en el grafo
por defecto). Para apuntar al negativo usa `match="id"` o `match="title"`.
- Nodo o widget inexistente NO lanza: devuelve `ok=False`, `set=False` y un
`error` claro ("sin nodo que matchee ..." / "el nodo no tiene widget ...").
- `widget.callback` se invoca con el nuevo valor para propagar el cambio (combos,
derivados); si el callback de un widget concreto espera mas argumentos, el fallo
se traga (try/catch) y el `value` ya queda asignado igualmente.
- El cambio vive en el grafo de la UI; para persistirlo a un archivo exportalo con
`comfyui_export_workflow_ui` o encolalo.
@@ -0,0 +1,103 @@
"""Edita en vivo el valor de un widget de un nodo del grafo de ComfyUI via CDP.
Localiza un nodo en `app.graph._nodes` por su tipo (comfyClass), su id o su
titulo, y asigna el valor del widget cuyo `name` coincide. Cubre tanto widgets
numericos (steps, cfg, seed del KSampler) como de texto (el `text` de un
CLIPTextEncode). Tras asignar `widget.value` invoca `widget.callback` si existe
para propagar el cambio y marca el canvas dirty. Compone cdp_eval.
Funcion impura: hace red (CDP WebSocket) y muta el grafo de la UI.
"""
import json
try: # ejecucion directa del archivo / fn run (browser/ en sys.path[0])
from cdp_eval import cdp_eval
except ImportError: # importado como paquete (sys.path = python/functions)
from browser.cdp_eval import cdp_eval
def comfyui_set_node_widget_ui(
node: str,
widget_name: str,
value,
*,
match: str = "type",
port: int = 9222,
server_url_substr: str = "8188",
timeout_s: float = 15.0,
) -> dict:
"""Asigna el valor de un widget de un nodo del grafo en vivo.
Args:
node: identificador del nodo a localizar, interpretado segun `match`.
widget_name: nombre del widget a editar (ej. "text", "steps", "seed",
"cfg", "sampler_name").
value: nuevo valor (str, int, float o bool). Se serializa a JSON.
match: criterio de busqueda del nodo. "type" (por comfyClass/type, ej.
"CLIPTextEncode" o "KSampler"), "id" (por n.id) o "title" (por el
titulo visible del nodo). Default "type".
port: puerto de remote debugging del Chrome diario. Default 9222.
server_url_substr: substring de la URL de la pestana de ComfyUI.
timeout_s: timeout de la conexion CDP en segundos.
Returns:
dict {ok, matched_nodes (int), set (bool), old_value, new_value, error}.
Si `match="type"` produce varios nodos, actua sobre el primero y reporta
cuantos coincidieron en matched_nodes.
"""
expr = (
"(function(){"
" if(!window.app || !app.graph) return {matched_nodes:0, set:false, error:'window.app.graph no disponible'};"
" var nodes = app.graph._nodes || [];"
f" var key = {json.dumps(match)};"
f" var target = {json.dumps(node)};"
f" var wname = {json.dumps(widget_name)};"
f" var nval = {json.dumps(value)};"
" var matches = nodes.filter(function(n){"
" if(key==='id') return String(n.id)===String(target);"
" if(key==='title') return n.title===target;"
" return (n.comfyClass||n.type)===target;"
" });"
" if(matches.length===0) return {matched_nodes:0, set:false, error:'sin nodo que matchee '+key+'='+target};"
" var n = matches[0];"
" var w = (n.widgets||[]).find(function(x){return x.name===wname;});"
" if(!w) return {matched_nodes:matches.length, set:false, error:'el nodo no tiene widget \"'+wname+'\"'};"
" var old = w.value;"
" w.value = nval;"
" if(typeof w.callback==='function'){ try{ w.callback(nval); }catch(e){} }"
" if(typeof app.graph.setDirtyCanvas==='function') app.graph.setDirtyCanvas(true,true);"
" return {matched_nodes:matches.length, set:true, old_value:old, new_value:w.value, error:''};"
"})()"
)
r = cdp_eval(
expr,
port=port,
target_url_substr=server_url_substr,
await_promise=False,
timeout_s=timeout_s,
)
if not r["ok"]:
return {
"ok": False,
"matched_nodes": 0,
"set": False,
"old_value": None,
"new_value": None,
"error": r["error"],
}
val = r["value"] or {}
return {
"ok": bool(val.get("set")),
"matched_nodes": val.get("matched_nodes", 0),
"set": bool(val.get("set")),
"old_value": val.get("old_value"),
"new_value": val.get("new_value"),
"error": val.get("error", ""),
}
if __name__ == "__main__":
out = comfyui_set_node_widget_ui(
"KSampler", "steps", 25, match="type"
)
print(json.dumps(out, ensure_ascii=False, indent=2))
@@ -5,7 +5,7 @@ lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def scrape_workana_projects(category: str = 'it-programming', language: str = 'es', extra_query: str = '', pages: int = 1, port: int = 9222, timeout_s: float = 20.0) -> dict"
signature: "def scrape_workana_projects(category: str = 'it-programming', language: str = 'es', extra_query: str = '', pages: int = 1, port: int = 9334, timeout_s: float = 20.0) -> dict"
description: "Scraper de proyectos freelance de Workana (https://www.workana.com/jobs) via Chrome DevTools Protocol (CDP). Workana es una SPA Vue: el GET HTTP NO trae los proyectos (0 cards en el HTML inicial), hay que renderizar con JS. Navega con un Chrome remoto, espera a que los cards monten async y extrae cada proyecto con un evaluador JS validado. Pieza 1 de un monitor de captacion de clientes: detecta proyectos freelance nuevos sin abrir el navegador a mano. Shape unificado con el scraper hermano de Upwork. Devuelve un dict con count + lista de proyectos; nunca lanza ni inventa datos."
tags: [market-intel, recon, flow-replay, browser, cdp, workana, scraper, freelance, spa, vue, captacion]
uses_functions: ["cdp_open_url_and_wait_py_pipelines", "cdp_eval_py_browser"]
@@ -24,7 +24,7 @@ params:
- name: pages
desc: "Numero de paginas de listado a recorrer. Default 1. Cada pagina adicional se navega con &page=N."
- name: port
desc: "Puerto de remote debugging del Chrome a usar. Default 9222 (chromium-personal de produccion). Para un Chrome aislado (smoke / recon sin mezclar sesion personal) apuntar a 9333 (el del browser_mcp)."
desc: "Puerto de remote debugging del Chrome a usar. Default 9334 (perfil headless dedicado del scraping, ~/.config/fn_scrape_chrome, que levanta y cierra el wrapper monitor_freelance_projects_headless). NUNCA 9222 por defecto: ese es el chromium-personal del usuario y el scraping no debe abrir pestanas ahi. Para un Chrome aislado interactivo (smoke/recon) tambien sirve 9333 (browser_mcp)."
- name: timeout_s
desc: "Timeout (segundos) por pagina, tanto para la navegacion como para el polling de aparicion de cards. Default 20.0."
output: "dict siempre (nunca lanza). En exito: {status:'ok', source:'workana', count:N, projects:[{...}]}. Cada project_dict con claves EXACTAS: source ('workana'), job_id (slug), url (absoluta), title, budget (str|None), posted (str ej 'Hace 4 horas'), bids (str|None nº propuestas), skills (list[str]), snippet (str), country (str|None), scraped_at (ISO8601 UTC). En error (sin cards tras timeout, Chrome muerto, DOM cambiado): {status:'error', error:<mensaje claro>, source:'workana', projects:[]}. NUNCA devuelve filas falsas."
@@ -40,17 +40,17 @@ file_path: "python/functions/browser/scrape_workana_projects.py"
# fn run mapea args POSICIONALMENTE a la firma (category language extra_query pages port timeout_s).
# NO uses flags --category/--language con fn run: el runner los toma como valores posicionales.
# Smoke contra el Chrome aislado del browser_mcp (port 9333, sin login):
fn run scrape_workana_projects it-programming es "" 1 9333 25
# Perfil headless dedicado (port 9334, lo levanta el wrapper monitor_freelance_projects_headless):
fn run scrape_workana_projects it-programming es "" 1 9334 25
# Produccion (chromium-personal, port 9222 por defecto):
fn run scrape_workana_projects it-programming es "" 1 9222 20
# Smoke contra el Chrome aislado interactivo del browser_mcp (port 9333, sin login):
fn run scrape_workana_projects it-programming es "" 1 9333 25
```
```bash
# Ejecucion directa del modulo SI acepta flags --... (argparse del __main__):
python/.venv/bin/python3 python/functions/browser/scrape_workana_projects.py \
--category it-programming --language es --port 9222
--category it-programming --language es --port 9334
```
```python
@@ -78,9 +78,12 @@ porque la pagina es una SPA Vue que monta los cards en runtime.
## Gotchas
- **Requiere un Chrome con remote debugging vivo en `port`**: 9222 (chromium-personal
de produccion, ya activado global) o 9333 (Chrome aislado del browser_mcp). Sin
Chrome escuchando devuelve `{status:'error', error:'no hay Chrome en el puerto N...'}` — no lanza.
- **Requiere un Chrome con remote debugging vivo en `port`**: por defecto 9334 (el
perfil headless dedicado del scraping, que levanta/cierra el wrapper
`monitor_freelance_projects_headless`). NO usa 9222 (chromium-personal del usuario)
por defecto: el scraping no abre pestanas en el navegador diario. 9333 (browser_mcp)
sirve para smoke interactivo. Sin Chrome escuchando devuelve
`{status:'error', error:'no hay Chrome en el puerto N...'}` — no lanza.
- **Workana es una SPA Vue: los cards montan ASYNC** tras la hidratacion. El load
event NO garantiza que esten en el DOM, por eso la funcion hace polling de
`document.querySelectorAll('div.project-item.js-project').length` hasta >0 o timeout.
@@ -198,7 +198,7 @@ def scrape_workana_projects(
language: str = "es",
extra_query: str = "",
pages: int = 1,
port: int = 9222,
port: int = 9334,
timeout_s: float = 20.0,
) -> dict:
"""Scrapea proyectos freelance de Workana renderizando la SPA via CDP.
@@ -217,9 +217,12 @@ def scrape_workana_projects(
filtrar por palabra clave (ej. "python", "scraping").
pages: Numero de paginas de listado a recorrer (1 por defecto). Cada pagina
adicional se navega con &page=N.
port: Puerto de remote debugging del Chrome a usar. Default 9222 (el
chromium-personal de produccion). Para un Chrome aislado (smoke / recon
sin mezclar sesion personal) apunta a 9333 (el del browser_mcp).
port: Puerto de remote debugging del Chrome a usar. Default 9334 (el
perfil headless dedicado del scraping, ~/.config/fn_scrape_chrome, que
levanta y cierra el wrapper monitor_freelance_projects_headless). NUNCA
9222 por defecto: ese es el chromium-personal del usuario y el scraping
no debe abrir pestanas ahi. Para un Chrome aislado interactivo (smoke /
recon) tambien sirve 9333 (el del browser_mcp).
timeout_s: Timeout (segundos) por pagina, tanto para la navegacion como para
el polling de aparicion de cards. Default 20.0.
@@ -293,7 +296,7 @@ if __name__ == "__main__":
parser.add_argument("--language", default="es")
parser.add_argument("--extra-query", default="")
parser.add_argument("--pages", type=int, default=1)
parser.add_argument("--port", type=int, default=9222)
parser.add_argument("--port", type=int, default=9334)
parser.add_argument("--timeout-s", type=float, default=20.0)
args = parser.parse_args()
+92
View File
@@ -0,0 +1,92 @@
---
name: ask_llm_vision
kind: function
lang: py
domain: core
version: "1.0.0"
purity: impure
signature: "def ask_llm_vision(prompt: str, image_path: str = '', *, image_b64: str = '', media_type: str = '', model: str = 'claude-opus-4-8', system: str = '', max_tokens: int = 4096, echo: bool = False, token: str = '') -> dict"
description: "Pregunta multimodal (imagen + texto) al modelo via la API directa de Anthropic con el token OAuth de Claude Max. Construye un content block [imagen base64, texto] y devuelve dict {ok, text, model, error}. Wrapper sobre stream_anthropic_messages (grupo claude-direct, arranque 0, sin proceso claude). Util para describir/puntuar/clasificar imagenes (p.ej. evaluar una generacion ComfyUI)."
error_type: error_go_core
tags: ["claude-direct", "llm", "anthropic", "vision", "multimodal", "image", "oauth"]
uses_functions:
- stream_anthropic_messages_py_core
uses_types: []
params:
- name: prompt
desc: "Pregunta o instruccion de texto sobre la imagen."
- name: image_path
desc: "Ruta a la imagen en disco; se lee y codifica a base64. El media_type se deduce de la extension si no se pasa. Ignorado si se pasa image_b64."
- name: image_b64
desc: "Alternativa a image_path: la imagen ya en base64 (sin prefijo data:). Requiere media_type explicito. keyword-only."
- name: media_type
desc: "Tipo MIME de la imagen (image/png, image/jpeg, image/webp, image/gif). Obligatorio con image_b64; deducido del path si se omite. keyword-only."
- name: model
desc: "Id del modelo Anthropic con vision. Default claude-opus-4-8. keyword-only."
- name: system
desc: "System prompt opcional (string vacio = ninguno). keyword-only."
- name: max_tokens
desc: "Maximo de tokens de salida. Default 4096. keyword-only."
- name: echo
desc: "Si True, vuelca el texto a stdout segun llega (streaming). keyword-only."
- name: token
desc: "Token OAuth; si vacio lo carga stream_anthropic_messages automaticamente. keyword-only."
output: "dict {ok, text, model, error}. En exito ok=True y text lleva la respuesta completa; en error ok=False, text vacio y error describe la causa. Nunca lanza excepcion."
file_path: python/functions/core/ask_llm_vision.py
---
# ask_llm_vision
Versión multimodal de [`ask_llm`](ask_llm.md): adjunta una imagen al prompt y devuelve la
respuesta del modelo. Usa la API directa de Anthropic con el token OAuth de Claude Max (sin
proceso `claude`, arranque 0), reutilizando [`stream_anthropic_messages`](stream_anthropic_messages.md),
que ya acepta content blocks multimodales. Cumple la regla `llm_invocation`: SIEMPRE claude-direct,
NUNCA `claude -p`.
## Ejemplo
```bash
# CLI (fn run): describe una imagen
fn run ask_llm_vision "describe esta imagen en una frase" --image ~/ComfyUI/output/demo_00001_.png
# Directo con el venv
python/.venv/bin/python3 python/functions/core/ask_llm_vision.py \
"que defectos ves en esta cara?" --image /tmp/render.png --model claude-opus-4-8
```
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from core.ask_llm_vision import ask_llm_vision
res = ask_llm_vision(
"Puntua de 0 a 10 el realismo de este retrato y justifica en una frase.",
image_path="/tmp/portrait.png",
model="claude-opus-4-8",
)
if res["ok"]:
print(res["text"])
else:
print("error:", res["error"])
```
## Cuando usarla
- Cuando necesites el juicio del modelo **sobre una imagen** (describir, puntuar, clasificar,
comparar, detectar defectos). Caso típico: cerrar el bucle de scoring de una skill ComfyUI —
generas un PNG y `ask_llm_vision` lo evalúa para alimentar `score_mean`.
- Si solo mandas texto, usa `ask_llm`. Si necesitas tools / loop agéntico, `run_claude_tool_loop_py_core`.
Si necesitas los eventos crudos (deltas, tool_use), `stream_anthropic_messages_py_core`.
## Gotchas
- **Es impura y hace red**: una request HTTP a `api.anthropic.com` por llamada. Sujeta a los
rate limits del plan (`HTTP 429` en ráfagas → espacia o reporta el error del dict).
- **media_type obligatorio con `image_b64`**: sin extensión de archivo no se puede deducir; pásalo
explícito (`image/png`, `image/jpeg`, `image/webp`, `image/gif`).
- **No lanza excepción**: errores (imagen inexistente, fallo HTTP) salen como `{ok: False, error: ...}`,
no como `raise`. Comprueba siempre `res["ok"]` antes de usar `res["text"]`.
- **Tamaño de la imagen**: imágenes muy grandes inflan los tokens de entrada (la API escala/limita
internamente). Para puntuar en lote conviene reducir la resolución antes.
- **Modelo con visión**: usa un modelo multimodal (`claude-opus-4-8` por defecto). Un id inexistente
da `404 not_found_error` propagado en `error`.
+167
View File
@@ -0,0 +1,167 @@
"""ask_llm_vision — pregunta multimodal (imagen + texto) al modelo via la API directa de Anthropic.
Wrapper sobre `stream_anthropic_messages` (grupo claude-direct) que construye un mensaje
multimodal `[bloque de imagen base64, bloque de texto]` y devuelve la respuesta del modelo.
Respeta la regla `llm_invocation`: SIEMPRE API directa (claude-direct, arranque 0 con el token
OAuth de Claude Max), NUNCA `claude -p`.
A diferencia de `ask_llm`, que solo manda texto, esta funcion adjunta una imagen — util para
describir, puntuar o clasificar imagenes (p.ej. evaluar el resultado de una generacion ComfyUI).
Impura: lee la imagen de disco y hace una request HTTP a api.anthropic.com.
"""
import base64
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from stream_anthropic_messages import stream_anthropic_messages # noqa: E402
DEFAULT_MODEL = "claude-opus-4-8"
_MEDIA_TYPES = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".jpe": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
}
def _media_type_for(path: str) -> str:
"""Deduce el media_type MIME a partir de la extension del archivo (o "" si no se reconoce)."""
ext = os.path.splitext(path)[1].lower()
return _MEDIA_TYPES.get(ext, "")
def ask_llm_vision(
prompt: str,
image_path: str = "",
*,
image_b64: str = "",
media_type: str = "",
model: str = DEFAULT_MODEL,
system: str = "",
max_tokens: int = 4096,
echo: bool = False,
token: str = "",
) -> dict:
"""Pregunta al modelo sobre una imagen y devuelve su respuesta de texto.
Construye un mensaje multimodal con un bloque de imagen (base64) seguido del bloque de
texto del prompt, y lo envia a la API Messages de Anthropic via
`stream_anthropic_messages` (token OAuth de Claude Max, sin proceso `claude`).
Args:
prompt: pregunta/instruccion de texto sobre la imagen.
image_path: ruta a la imagen en disco. Se lee y codifica a base64; el media_type se
deduce de la extension si no se pasa. Ignorado si se pasa image_b64.
image_b64: alternativa a image_path: la imagen ya en base64 (sin prefijo `data:`).
Requiere `media_type` explicito. keyword-only.
media_type: tipo MIME de la imagen (image/png, image/jpeg, image/webp, image/gif).
Obligatorio con image_b64; deducido del path si se omite. keyword-only.
model: id del modelo Anthropic con vision. Default "claude-opus-4-8". keyword-only.
system: system prompt opcional. keyword-only.
max_tokens: maximo de tokens de salida. keyword-only.
echo: si True, vuelca el texto a stdout segun llega (streaming). keyword-only.
token: token OAuth; si vacio, lo carga `stream_anthropic_messages` automaticamente.
keyword-only.
Returns:
dict ``{ok, text, model, error}``. En exito ``ok=True`` y ``text`` lleva la respuesta
completa del modelo. En error ``ok=False``, ``text=""`` y ``error`` describe la causa
(imagen inexistente, falta media_type, error HTTP/API). Nunca lanza excepcion.
"""
if not image_path and not image_b64:
return {"ok": False, "text": "", "model": model,
"error": "falta la imagen: pasa image_path o image_b64"}
if image_b64:
b64 = image_b64
mt = media_type
if not mt:
return {"ok": False, "text": "", "model": model,
"error": "image_b64 requiere media_type explicito (ej. image/png)"}
else:
path = os.path.expanduser(image_path)
if not os.path.isfile(path):
return {"ok": False, "text": "", "model": model,
"error": f"imagen no encontrada: {path}"}
mt = media_type or _media_type_for(path)
if not mt:
return {"ok": False, "text": "", "model": model,
"error": f"no se pudo deducir media_type de {path!r}; pasa media_type explicito"}
try:
with open(path, "rb") as fh:
b64 = base64.standard_b64encode(fh.read()).decode("ascii")
except OSError as exc:
return {"ok": False, "text": "", "model": model,
"error": f"no se pudo leer la imagen: {exc}"}
content = [
{"type": "image", "source": {"type": "base64", "media_type": mt, "data": b64}},
{"type": "text", "text": prompt},
]
messages = [{"role": "user", "content": content}]
parts = []
for ev in stream_anthropic_messages(
messages=messages,
model=model,
system=system,
max_tokens=max_tokens,
token=token,
):
t = ev.get("type")
if t == "text":
parts.append(ev["text"])
if echo:
sys.stdout.write(ev["text"])
sys.stdout.flush()
elif t == "error":
return {"ok": False, "text": "", "model": model,
"error": ev.get("message", "error desconocido")}
if echo:
sys.stdout.write("\n")
sys.stdout.flush()
return {"ok": True, "text": "".join(parts), "model": model, "error": ""}
def _main(argv):
image_path = ""
model = DEFAULT_MODEL
system = ""
prompt_parts = []
i = 0
while i < len(argv):
a = argv[i]
if a in ("--image", "-i") and i + 1 < len(argv):
image_path = argv[i + 1]
i += 2
elif a in ("--model", "-m") and i + 1 < len(argv):
model = argv[i + 1]
i += 2
elif a in ("--system", "-s") and i + 1 < len(argv):
system = argv[i + 1]
i += 2
else:
prompt_parts.append(a)
i += 1
prompt = " ".join(prompt_parts).strip()
if not image_path:
sys.stderr.write('uso: ask_llm_vision "prompt" --image RUTA [--model M] [--system S]\n')
return 2
if not prompt:
prompt = "Describe esta imagen."
res = ask_llm_vision(prompt, image_path, model=model, system=system, echo=True)
if not res["ok"]:
sys.stderr.write("ask_llm_vision error: " + str(res["error"]) + "\n")
return 1
return 0
if __name__ == "__main__":
sys.exit(_main(sys.argv[1:]))
@@ -3,10 +3,10 @@ name: depth_to_relief_glb
kind: function
lang: py
domain: datascience
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "def depth_to_relief_glb(image: Image.Image, depth: np.ndarray, out_glb_path: str, z_scale: float = 0.35, max_dim: int = 220) -> dict"
description: "Construye una malla de relieve (heightmap) texturizada a partir de un mapa de profundidad + la imagen original y la exporta como glTF binario (.glb). El depth se vuelve el eje Z de un grid regular de vertices y la imagen se mapea como textura UV. Paso 2 del flujo img->3D (grupo img-to-3d): consume la salida de estimate_image_depth."
signature: "def depth_to_relief_glb(image: Image.Image, depth: np.ndarray, out_glb_path: str, z_scale: float = 0.35, max_dim: int = 220, mask: np.ndarray | None = None) -> dict"
description: "Construye una malla de relieve (heightmap) texturizada a partir de un mapa de profundidad + la imagen original y la exporta como glTF binario (.glb). El depth se vuelve el eje Z de un grid regular de vertices y la imagen se mapea como textura UV. Con mask opcional recorta la malla al objeto (descarta las caras del fondo). Paso 2 del flujo img->3D (grupo img-to-3d): consume la salida de estimate_image_depth y, opcionalmente, la mask de remove_background."
tags: [img-to-3d, datascience, mesh, glb, gltf, relief, heightmap, trimesh, 3d, texture]
uses_functions: []
uses_types: []
@@ -25,7 +25,9 @@ params:
desc: "Amplitud del relieve como fraccion del lado de la malla (default 0.35). Mayor = relieve mas pronunciado/exagerado."
- name: max_dim
desc: "Lado maximo del grid tras downsample bilineal (default 220, ~48k vertices / ~96k caras). Controla resolucion de la malla vs tamano del .glb. Imagenes mayores se reducen; menores se dejan igual."
output: "dict. Exito: {status:'ok', glb_path:str, vertices:int, faces:int, height:int, width:int}. Error: {status:'error', error:str} (depth con forma invalida, directorio de salida inexistente, fallo de trimesh.export). No lanza."
- name: mask
desc: "Mascara opcional HxW (0..255, 255=objeto), tipicamente la 'mask' de remove_background. Si se pasa, se reescala al grid (NEAREST), el fondo se aplana a Z=0 y las caras cuyos tres vertices caen en el fondo se descartan: la malla queda recortada al objeto. None (default) = malla del frame completo (relieve incluido el fondo)."
output: "dict. Exito: {status:'ok', glb_path:str, vertices:int, faces:int, height:int, width:int}. Con mask, 'faces' es menor (solo caras del objeto); 'vertices' no cambia (el grid completo se conserva). Error: {status:'error', error:str} (depth con forma invalida, directorio de salida inexistente, fallo de trimesh.export). No lanza."
tested: false
tests: []
test_file_path: ""
@@ -81,3 +83,14 @@ suavizar el relieve.
- **Import plano**: importa el modulo directo, NO `from datascience import ...` (el `__init__` del
paquete arrastra deps de otros dominios ausentes en el venv de vision). Ver misma gotcha en
`estimate_image_depth`.
- **mask opcional (v1.1.0)**: pasa la `mask` de `remove_background` para recortar la malla al
objeto. Se reescala con NEAREST (sin interpolar, preserva el borde binario), el fondo se aplana
a Z=0 y sus caras se eliminan. El nº de `vertices` no baja (el grid completo se conserva para no
romper el mapeo UV 1:1); solo baja `faces`. Una mask degenerada (todo objeto) deja la malla
intacta; una mask vacia (todo fondo) deja la malla sin caras (glb valido pero vacio).
## Capability growth log
- v1.1.0 (2026-06-21) — anade parametro opcional `mask` para recortar la malla al objeto
(descarta las caras del fondo), cerrando la cadena con `remove_background` del grupo img-to-3d.
Aditivo: `mask=None` mantiene el comportamiento previo. Fiel al original de `backend/depth.py`.
@@ -22,6 +22,7 @@ def depth_to_relief_glb(
out_glb_path: str,
z_scale: float = 0.35,
max_dim: int = 220,
mask: "np.ndarray | None" = None,
) -> dict:
"""
Construye una malla de relieve texturizada y la exporta como .glb.
@@ -33,6 +34,9 @@ def depth_to_relief_glb(
z_scale: amplitud del relieve (fracción del lado de la malla). Default 0.35.
max_dim: lado máximo del grid tras downsample (controla de vértices/caras).
Default 220 (~48k vértices, ~96k caras).
mask: máscara opcional HxW (0..255, 255 = objeto), típicamente la "mask" devuelta por
remove_background. Si se pasa, el fondo se aplana y las caras cuyos vértices caigan
en el fondo se descartan: la malla contiene solo el objeto, sin el plano de fondo.
Devuelve (dict, nunca lanza):
Éxito: {"status": "ok", "glb_path": out_glb_path, "vertices": int, "faces": int,
@@ -58,6 +62,14 @@ def depth_to_relief_glb(
depth = np.asarray(depth_img, dtype=np.float32) / 255.0
H, W = depth.shape
# Si se pasó máscara (objeto vs fondo), reescalarla al grid ya downsampleado: el fondo
# no aporta relieve (se aplana a 0) y luego sus caras se descartan, dejando solo el objeto.
fg = None
if mask is not None:
mask_img = Image.fromarray(np.asarray(mask).astype(np.uint8)).resize((W, H), Image.NEAREST)
fg = np.asarray(mask_img) >= 128
depth = np.where(fg, depth, 0.0).astype(np.float32)
# Coordenadas del grid: X corrige aspect ratio, Y hacia abajo, Z = profundidad.
aspect = W / float(H)
xs = np.linspace(-aspect / 2.0, aspect / 2.0, W, dtype=np.float32)
@@ -79,6 +91,12 @@ def depth_to_relief_glb(
]
)
# Con máscara: conservar solo las caras cuyos tres vértices son objeto. La malla queda
# recortada al objeto, sin el plano de fondo que deformaría el relieve.
if fg is not None:
keep = fg.ravel()[faces].all(axis=1)
faces = faces[keep]
# UV mapeando cada vértice al pixel de la imagen (V invertido para convención glTF).
u = np.linspace(0.0, 1.0, W, dtype=np.float32)
v = np.linspace(0.0, 1.0, H, dtype=np.float32)
@@ -0,0 +1,89 @@
---
name: remove_background
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def remove_background(image_path: str, engine: str = 'auto') -> dict"
description: "Elimina el fondo de una imagen con cascada de motores (rembg/U2Net -> OpenCV GrabCut -> umbral NumPy), compone el objeto sobre fondo gris neutro y devuelve image+mask+engine. Paso de pre-proceso del flujo img->3D (grupo img-to-3d): su mask alimenta depth_to_relief_glb para recortar la malla de relieve al objeto."
tags: [img-to-3d, datascience, background-removal, segmentation, rembg, grabcut, opencv, computer-vision, mask]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: image_path
desc: "Ruta a la imagen de entrada. Cualquier formato que PIL.Image.open abra (jpg, png, webp, RGBA...). Si no existe o no es imagen valida, se devuelve status error. Un PNG RGBA ya recortado se reaprovecha en modo auto (passthrough:alpha)."
- name: engine
desc: "Motor de segmentacion. 'auto' (default) prueba en cascada rembg:u2net -> opencv:grabcut -> threshold:border y NUNCA falla (cae al umbral NumPy puro sin deps externas). Forzar uno: 'rembg' (red neuronal U2Net, mejor calidad, deps pesadas), 'grabcut' (OpenCV, rectangulo central), 'threshold' (distancia al color medio de los bordes, NumPy puro, objeto centrado). Si se fuerza un motor y no esta disponible/falla o produce mascara degenerada -> status error."
output: "dict. Exito: {status:'ok', image: PIL.Image RGB del objeto compuesto sobre fondo gris neutro (127,127,127), mask: ndarray HxW uint8 (0..255, 255=objeto), engine: str del motor usado ('rembg:u2net' | 'opencv:grabcut' | 'threshold:border' | 'passthrough:alpha'), height:int, width:int, fg_fraction: float (fraccion de pixeles objeto, redondeada a 4 decimales)}. Error: {status:'error', error:str} (ruta invalida, motor desconocido, motor forzado no disponible/fallido, o ningun motor produjo una mascara valida). No lanza nunca. El demo CLI (__main__) imprime un resumen JSON sin el ndarray ni la imagen y, si se pasa out_dir, guarda rgb.png + mask.png."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/datascience/remove_background.py"
source_file: "apps/img_to_3d_webapp/backend/bg_removal.py"
---
## Ejemplo
```python
# Requiere un venv con pillow + numpy (rembg/opencv solo si fuerzas esos motores; el umbral es NumPy puro).
# Import PLANO al modulo: el paquete datascience.__init__ arrastra deps de otros dominios
# (bs4, duckdb...) que no estan en ese venv. Ver Gotchas.
import sys
sys.path.insert(0, "python/functions/datascience")
from remove_background import remove_background
res = remove_background("apps/img_to_3d_webapp/samples/cats.jpg", engine="auto")
assert res["status"] == "ok"
print(res["engine"]) # p.ej. "rembg:u2net" (o "opencv:grabcut" / "threshold:border")
print(res["height"], res["width"]) # p.ej. 1024 768
print(res["mask"].shape, res["mask"].dtype) # (1024, 768) uint8 (255=objeto)
assert 0.0 < res["fg_fraction"] < 1.0
# res["mask"] (ndarray HxW uint8) alimenta depth_to_relief_glb para recortar la malla al objeto.
# res["image"] es el objeto compuesto sobre gris neutro, listo para estimar profundidad.
```
Lanzable como demo (imprime resumen JSON, sin serializar el ndarray; guarda PNGs si das out_dir):
```bash
./fn run remove_background_py_datascience apps/img_to_3d_webapp/samples/cats.jpg auto /tmp/cut
# {"status": "ok", "engine": "rembg:u2net", "height": 1024, "width": 768,
# "fg_fraction": 0.4123, "rgb_path": "/tmp/cut/rgb.png", "mask_path": "/tmp/cut/mask.png"}
```
## Cuando usarla
Como pre-proceso ANTES de estimar profundidad en el flujo img->3D: aislar el objeto evita que el
modelo de profundidad estire el fondo plano, y la `mask` permite recortar la malla de relieve al
objeto (se pasa a `depth_to_relief_glb`). Tambien para segmentacion de primer plano generica
cuando necesitas separar un objeto de su fondo y componerlo sobre un color neutro (recortes para
catalogos, datasets, miniaturas).
## Gotchas
- **Impura**: segun el motor carga modelos neuronales y lee disco. `rembg`/`onnxruntime` (~170MB)
DESCARGA el modelo U2Net la primera vez a su cache (`~/.u2net/`), requiere red en esa primera
carga; `opencv-python` para GrabCut; el umbral (`threshold:border`) es NumPy puro sin deps externas.
- **Estado de proceso**: `_REMBG_SESSION` cachea la sesion rembg a nivel de modulo para no recargar
los pesos en cada llamada. Es estado mutable compartido del proceso y ocupa RAM hasta que el
interprete muere.
- **engine='auto' nunca lanza**: prueba rembg -> grabcut -> threshold y siempre cae al umbral NumPy
puro si los anteriores no estan disponibles o fallan. Forzar un motor concreto SI puede devolver
status error (motor no instalado, fallo, o mascara degenerada).
- **Mascara degenerada**: si la fraccion de objeto resulta `< 0.01` o `> 0.995` la mascara se
descarta (casi todo fondo o casi todo objeto) y en modo auto se prueba el siguiente motor.
- **threshold:border es de baja calidad**: asume objeto centrado con los bordes de la imagen siendo
fondo (calcula la distancia al color medio de los bordes). Es el fallback de ultimo recurso.
- **passthrough:alpha**: si la imagen ya viene recortada (PNG RGBA con alfa por debajo de 128) se
reutiliza su canal alfa como mascara, SOLO en modo auto. Si fuerzas un motor concreto se respeta
esa eleccion e ignora el alfa existente.
- **Import plano**: importa el modulo directo (`sys.path` a `python/functions/datascience` +
`from remove_background import remove_background`), NO `from datascience import ...`. El
`datascience.__init__` carga todo el dominio (scrapers con bs4, duckdb...) con deps ajenas a esta
funcion que romperian el import del paquete en el venv de vision.
- Nunca lanza: errores (ruta invalida, motor forzado no disponible, OOM) vuelven como
`{status:'error', error:str}`.
@@ -0,0 +1,213 @@
"""
Eliminación de fondo de una imagen con cascada de motores (rembg -> GrabCut -> umbral).
Función del registry (grupo de capacidad `img-to-3d`, dominio `datascience`). Promovida desde
la app `img_to_3d_webapp` (backend/bg_removal.py) para que cualquier artefacto pueda aislar el
objeto de primer plano sin reimplementar la cascada de segmentación ni la composición sobre fondo
neutro.
Impura: carga modelos neuronales (rembg/U2Net), usa GPU/CPU vía onnxruntime, lee disco y mantiene
una caché de sesión rembg a nivel de proceso para no recargar los pesos en cada llamada. Las deps
pesadas (rembg, opencv) se importan dentro de los helpers (lazy) para que el módulo se pueda
importar sin ellas; el motor de umbral es NumPy puro sin deps externas.
"""
from __future__ import annotations
import numpy as np
from PIL import Image
# Fondo gris neutro sobre el que se compone el objeto recortado.
NEUTRAL_BG = (127, 127, 127)
# Umbral de alfa para considerar un PNG RGBA "ya recortado" (passthrough).
_ALPHA_THRESH = 128
# Sesión rembg cacheada a nivel de proceso (estado mutable: ver .md "Gotchas").
_REMBG_SESSION = None
def _existing_alpha_mask(image):
"""Devuelve el canal alfa como máscara HxW uint8 si la imagen ya viene recortada, si no None."""
if image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info):
alpha = np.asarray(image.convert("RGBA"))[:, :, 3]
if alpha.min() < _ALPHA_THRESH:
return alpha
return None
def _composite_over_neutral(image_rgb, mask):
"""Compone la imagen RGB sobre el fondo gris neutro usando la máscara como alfa."""
rgb = np.asarray(image_rgb.convert("RGB"), dtype=np.float32)
alpha = (mask.astype(np.float32) / 255.0)[:, :, None]
bg = np.empty_like(rgb)
bg[:] = NEUTRAL_BG
out = rgb * alpha + bg * (1.0 - alpha)
return Image.fromarray(out.clip(0, 255).astype(np.uint8), mode="RGB")
def _remove_with_rembg(image):
"""Segmenta con rembg (modelo U2Net). Devuelve (mask HxW uint8, engine_str)."""
global _REMBG_SESSION
from rembg import new_session, remove
if _REMBG_SESSION is None:
_REMBG_SESSION = new_session("u2net")
cut = remove(image.convert("RGB"), session=_REMBG_SESSION)
mask = np.asarray(cut.convert("RGBA"))[:, :, 3]
return mask, "rembg:u2net"
def _remove_with_grabcut(image):
"""Segmenta con OpenCV GrabCut (rectángulo central). Devuelve (mask HxW uint8, engine_str)."""
import cv2
rgb = np.asarray(image.convert("RGB"))
h, w = rgb.shape[:2]
bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
gc_mask = np.zeros((h, w), np.uint8)
bgd_model = np.zeros((1, 65), np.float64)
fgd_model = np.zeros((1, 65), np.float64)
margin_x, margin_y = int(0.08 * w), int(0.08 * h)
rect = (margin_x, margin_y, max(1, w - 2 * margin_x), max(1, h - 2 * margin_y))
cv2.grabCut(bgr, gc_mask, rect, bgd_model, fgd_model, 5, cv2.GC_INIT_WITH_RECT)
fg = np.where((gc_mask == cv2.GC_FGD) | (gc_mask == cv2.GC_PR_FGD), 255, 0).astype(np.uint8)
return fg, "opencv:grabcut"
def _remove_with_threshold(image):
"""Segmenta por distancia al color medio de los bordes (NumPy puro). Devuelve (mask, engine_str)."""
rgb = np.asarray(image.convert("RGB"), dtype=np.float32)
h, w = rgb.shape[:2]
border = np.concatenate([rgb[0, :, :], rgb[-1, :, :], rgb[:, 0, :], rgb[:, -1, :]], axis=0)
bg_color = border.mean(axis=0)
dist = np.linalg.norm(rgb - bg_color, axis=2)
thresh = max(30.0, float(dist.mean()))
fg = (dist > thresh).astype(np.uint8) * 255
return fg, "threshold:border"
def remove_background(image_path: str, engine: str = "auto") -> dict:
"""
Elimina el fondo de una imagen y compone el objeto sobre un fondo gris neutro.
Parámetros:
image_path: ruta a la imagen de entrada (cualquier formato que PIL abra).
engine: "auto" (default) prueba rembg -> GrabCut -> umbral en cascada y NUNCA falla
(cae al umbral NumPy puro sin deps externas); también admite forzar un motor concreto:
"rembg", "grabcut" o "threshold". Si se fuerza un motor y no está disponible/falla,
o la máscara resulta degenerada, se devuelve status error.
Devuelve (dict, nunca lanza):
Éxito: {"status": "ok", "image": PIL.Image RGB del objeto compuesto sobre gris neutro,
"mask": ndarray HxW uint8 (0..255, 255=objeto), "engine": str del motor usado
("rembg:u2net" | "opencv:grabcut" | "threshold:border" | "passthrough:alpha"),
"height": int, "width": int, "fg_fraction": float (fracción de píxeles objeto,
redondeada a 4 decimales)}.
Error: {"status": "error", "error": str} (ruta inválida, motor desconocido, motor forzado
no disponible/fallido, o ningún motor produjo una máscara válida).
"""
try:
image = Image.open(image_path)
# Passthrough: si la imagen ya viene recortada (PNG RGBA con alfa), reutiliza su alfa.
# Solo en modo auto; si se fuerza un motor concreto se respeta esa elección.
if engine == "auto":
existing = _existing_alpha_mask(image)
if existing is not None:
composed = _composite_over_neutral(image, existing)
frac = float((existing >= 128).mean())
h, w = existing.shape[:2]
return {
"status": "ok",
"image": composed,
"mask": existing,
"engine": "passthrough:alpha",
"height": int(h),
"width": int(w),
"fg_fraction": round(frac, 4),
}
# Construir la lista de motores a probar según el engine pedido.
if engine == "auto":
attempts = [_remove_with_rembg, _remove_with_grabcut, _remove_with_threshold]
elif engine == "rembg":
attempts = [_remove_with_rembg]
elif engine == "grabcut":
attempts = [_remove_with_grabcut]
elif engine == "threshold":
attempts = [_remove_with_threshold]
else:
attempts = []
if not attempts:
return {"status": "error", "error": f"Motor desconocido: {engine!r}"}
last_exc = None
for attempt in attempts:
try:
mask, used = attempt(image)
except Exception as e: # noqa: BLE001
last_exc = e
continue
# Rechazar máscaras degeneradas (casi todo fondo o casi todo objeto).
frac = float((mask >= 128).mean())
if frac < 0.01 or frac > 0.995:
last_exc = f"mascara degenerada (fg_fraction={round(frac, 4)}) con {used}"
continue
composed = _composite_over_neutral(image, mask)
h, w = mask.shape[:2]
return {
"status": "ok",
"image": composed,
"mask": mask,
"engine": used,
"height": int(h),
"width": int(w),
"fg_fraction": round(frac, 4),
}
return {
"status": "error",
"error": f"No se pudo eliminar el fondo con engine={engine!r}: {last_exc}",
}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}
if __name__ == "__main__":
# Demo runner para `fn run remove_background_py_datascience <image_path> [engine] [out_dir]`.
# Imprime un resumen JSON-serializable (el ndarray y la PIL.Image no se serializan).
import json
import os
import sys
if len(sys.argv) < 2:
print(json.dumps({"status": "error", "error": "uso: <image_path> [engine] [out_dir]"}))
sys.exit(1)
path = sys.argv[1]
eng = sys.argv[2] if len(sys.argv) > 2 else "auto"
out_dir = sys.argv[3] if len(sys.argv) > 3 else None
res = remove_background(path, engine=eng)
if res["status"] == "ok":
summary = {
"status": "ok",
"engine": res["engine"],
"height": res["height"],
"width": res["width"],
"fg_fraction": res["fg_fraction"],
}
if out_dir:
os.makedirs(out_dir, exist_ok=True)
rgb_path = os.path.join(out_dir, "rgb.png")
mask_path = os.path.join(out_dir, "mask.png")
res["image"].save(rgb_path)
Image.fromarray(res["mask"]).save(mask_path)
summary["rgb_path"] = rgb_path
summary["mask_path"] = mask_path
print(json.dumps(summary))
else:
print(json.dumps(res))
sys.exit(1)
@@ -0,0 +1,90 @@
---
name: comfyui_ensure_server
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def comfyui_ensure_server(*, port: int = 8188, lowvram: bool | None = None, health_timeout: int = 60, comfyui_dir: str = '~/ComfyUI', unit_name: str = 'comfyui', runner=None) -> dict"
description: "Garantiza que ComfyUI corre como servicio systemd-user resiliente y sano. Genera/instala el unit systemd-user comfyui.service (ExecStart con el venv de ComfyUI + main.py --port, anadiendo --lowvram si lowvram=True o autodetectando GPUs <= 8 GB; Restart=always — NO on-failure; WantedBy=default.target), hace daemon-reload + enable + start, y comprueba la salud via GET /system_stats (2xx) con timeout. Idempotente: si el servicio ya esta gestionado por systemd, activo y respondiendo, no toca nada. Migracion limpia: si ComfyUI ya corre a mano (puerto ocupado por un proceso main.py que systemd NO gestiona), lo para con SIGTERM (nunca SIGKILL) y lo levanta via systemd. Solo stdlib (subprocess, urllib, os, signal, time, re). No lanza excepciones: devuelve un dict de estado."
tags: [comfyui, systemd, service, server, resilient, ml, healthcheck, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["os", "re", "signal", "subprocess", "time", "urllib.request"]
params:
- name: port
desc: "puerto HTTP del backend ComfyUI; tambien el que escribe en el unit (--port) y el que sondea el health check (default 8188)"
- name: lowvram
desc: "True/False fuerza/omite el flag --lowvram en ExecStart; None autodetecta por VRAM (GPUs con <= 8200 MiB -> True). Recomendado True en GPUs de 8 GB para modelos grandes (Flux, video)"
- name: health_timeout
desc: "segundos maximos sondeando GET /system_stats tras arrancar el servicio antes de declararlo no-sano (default 60)"
- name: comfyui_dir
desc: "raiz de la instalacion de ComfyUI; debe contener .venv/bin/python y main.py (default ~/ComfyUI, se expande y normaliza a absoluto)"
- name: unit_name
desc: "nombre del unit systemd-user (sin .service); el archivo va a ~/.config/systemd/user/<unit_name>.service (default 'comfyui')"
- name: runner
desc: "callable(cmd: list) -> CompletedProcess inyectable para tests; default ejecuta subprocess.run capturando salida"
output: "dict con ok (bool: servicio activo y sano), active (ActiveState del unit: active|inactive|failed), port, health (bool: /system_stats respondio 2xx), error (str|None), lowvram (bool aplicado), unit_path (ruta del .service escrito), migrated (bool: paro un ComfyUI a mano para migrar a systemd), reloaded (bool: hubo daemon-reload), idempotent (bool: ya estaba activo+sano y no se toco nada)"
tested: true
tests:
- "_detect_lowvram aplica el umbral de 8 GB (8192/8200 -> True, 8201/24564/None -> False)"
- "_render_unit incluye Restart=always, WantedBy=default.target y nunca on-failure; anade --lowvram solo cuando corresponde"
- "error claro si falta el venv python en comfyui_dir"
- "idempotente: si is-active=active y /system_stats sano, no llama a start"
- "arranque fresco: escribe el unit, daemon-reload + enable + start y espera salud"
- "lowvram=False omite el flag --lowvram en el unit escrito"
test_file_path: "python/functions/infra/comfyui_ensure_server_test.py"
file_path: "python/functions/infra/comfyui_ensure_server.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from infra.comfyui_ensure_server import comfyui_ensure_server
# Deja ComfyUI corriendo como servicio systemd-user, sano y con --lowvram
# autodetectado en GPUs de 8 GB. Idempotente: relanzarla no rompe nada.
res = comfyui_ensure_server(port=8188, lowvram=True)
print(res)
# {'ok': True, 'active': 'active', 'port': 8188, 'health': True, 'error': None,
# 'lowvram': True, 'unit_path': '/home/enmanuel/.config/systemd/user/comfyui.service',
# 'migrated': True, 'reloaded': True, 'idempotent': False}
```
CLI directa (despacha por el venv del registry):
```bash
python/.venv/bin/python3 python/functions/infra/comfyui_ensure_server.py --port=8188 --lowvram
```
El usuario lo gestiona despues con systemd-user normal:
```bash
systemctl --user status comfyui # estado + ultimos logs
systemctl --user restart comfyui # reiniciar (la salud vuelve verde sola)
systemctl --user stop comfyui # parar
systemctl --user disable --now comfyui # revertir: para y deshabilita el arranque automatico
journalctl --user -u comfyui -n 50 # diagnosticar fallos de arranque
```
## Cuando usarla
Usala cuando necesites que ComfyUI este garantizado arriba y sano antes de
encolar workflows (txt2img, video, 3D), o para convertir el ComfyUI que hoy se
relanza a mano en un servicio que arranca solo al boot y se reinicia si cae
(gap del roadmap 0064). Es el primer paso del grupo `comfyui`: dejar el backend
disponible; despues vienen `comfyui_build_*_workflow` + `comfyui_submit_workflow`.
## Gotchas
- **systemd-user requiere linger** para sobrevivir al cierre de sesion / arrancar al boot: `loginctl enable-linger $USER`. Sin linger el unit solo vive mientras hay sesion activa. Si `enable` falla por esto, el dict lo dice en `error`.
- **Migracion limpia con SIGTERM, nunca SIGKILL**: si ComfyUI ya corre a mano ocupando el puerto, la funcion lo para con SIGTERM y espera a que libere el bind (hasta ~25 s) antes de arrancar el servicio. Si el puerto lo ocupa un proceso que NO es ComfyUI (cmdline sin `main.py`), NO lo toca y devuelve `error` — no arranca para no duplicar el bind.
- **Cambiar los flags del unit (p.ej. lowvram) NO reinicia un servicio ya sano**: la funcion reescribe el `.service` y hace daemon-reload, pero si el servicio ya esta active+healthy no lo reinicia para no interrumpir. Para aplicar flags nuevos: `systemctl --user restart comfyui`.
- **Carga la GPU al arrancar**: levantar ComfyUI reserva VRAM. En una GPU de 8 GB compartida, evita lanzarlo mientras otra tarea pesada usa la GPU.
- **Restart=always (no on-failure)**: un `systemctl --user stop` limpio es exit success; con `on-failure` el servicio reviviria solo tras crash. Para pararlo de verdad usa `stop` (no `restart`) o `disable --now`.
- El health check es `GET http://127.0.0.1:<port>/system_stats` y espera 2xx; solo loopback.
@@ -0,0 +1,326 @@
"""Garantiza que ComfyUI corre como servicio systemd-user resiliente y sano.
Funcion impura: instala/actualiza el unit systemd-user `comfyui.service`, lo
habilita y arranca, y comprueba la salud del backend HTTP. Idempotente: si el
servicio ya esta gestionado por systemd, activo y respondiendo, no toca nada.
Migracion limpia: si ComfyUI ya corre a mano (puerto ocupado por un proceso
`main.py` que systemd NO gestiona), lo para con SIGTERM y lo levanta via
systemd, para que a partir de ese momento se reinicie solo (Restart=always).
Solo depende de la stdlib (subprocess, urllib, os, signal, time, re). No lanza
excepciones: siempre devuelve un dict de estado.
"""
import os
import re
import signal
import subprocess
import time
import urllib.request
def _default_runner(cmd):
"""Ejecuta un comando capturando salida. Inyectable para tests."""
return subprocess.run(cmd, capture_output=True, text=True, timeout=30)
def _detect_lowvram(vram_mib):
"""Decide si conviene --lowvram segun la VRAM total en MiB.
GPUs con <= 8200 MiB (tarjetas de 8 GB) ganan estabilidad con --lowvram para
modelos grandes (Flux, video). Si no hay dato de VRAM (None), NO asume
lowvram: devuelve False para no penalizar GPUs grandes sin necesidad.
"""
return vram_mib is not None and vram_mib <= 8200
def _query_vram_mib(runner):
"""Lee la VRAM total (MiB) de la primera GPU via nvidia-smi. None si falla."""
try:
r = runner(
[
"nvidia-smi",
"--query-gpu=memory.total",
"--format=csv,noheader,nounits",
]
)
if r.returncode == 0 and r.stdout.strip():
return int(r.stdout.strip().splitlines()[0].strip())
except Exception:
pass
return None
def _render_unit(python_bin, main_py, working_dir, port, lowvram, description):
"""Construye el texto del unit systemd-user. Pura (sin I/O)."""
exec_start = f"{python_bin} {main_py} --port {port}"
if lowvram:
exec_start += " --lowvram"
return (
"[Unit]\n"
f"Description={description}\n"
"After=network-online.target\n"
"Wants=network-online.target\n"
"\n"
"[Service]\n"
"Type=simple\n"
f"WorkingDirectory={working_dir}\n"
f"ExecStart={exec_start}\n"
# Restart=always (NO on-failure): un SIGTERM limpio es exit success y
# con on-failure el servicio no reviviria. Ver .claude/rules/function_tags.md.
"Restart=always\n"
"RestartSec=5\n"
"\n"
"[Install]\n"
"WantedBy=default.target\n"
)
def _health(port, path="/system_stats", timeout=3):
"""True si GET http://127.0.0.1:<port><path> responde 2xx."""
url = f"http://127.0.0.1:{port}{path}"
try:
with urllib.request.urlopen(url, timeout=timeout) as resp:
return 200 <= resp.status < 300
except Exception:
return False
def _wait_health(port, timeout, interval=2.0):
"""Sondea la salud hasta que responda 2xx o se agote el timeout."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if _health(port):
return True
time.sleep(interval)
return _health(port)
def _systemctl(runner, *args):
return runner(["systemctl", "--user", *args])
def _unit_active_state(runner, unit_name):
"""Devuelve el ActiveState del unit: active|inactive|failed|... o '' si no existe."""
r = _systemctl(runner, "is-active", unit_name)
return (r.stdout or r.stderr or "").strip()
def _pid_listening_on_port(port, runner):
"""PID del proceso que escucha en 127.0.0.1:<port>, o None. Via `ss`."""
try:
r = runner(["ss", "-ltnpH", f"sport = :{port}"])
if r.returncode == 0:
m = re.search(r"pid=(\d+)", r.stdout or "")
if m:
return int(m.group(1))
except Exception:
pass
return None
def _is_comfy_process(pid):
"""True si la cmdline del PID contiene 'main.py' (proceso ComfyUI a mano)."""
try:
with open(f"/proc/{pid}/cmdline", "rb") as f:
cmd = f.read().replace(b"\0", b" ").decode(errors="replace")
return "main.py" in cmd
except Exception:
return False
def _terminate_manual(pid, port, runner, wait_s=25.0):
"""SIGTERM al proceso a mano y espera a que libere el puerto. No usa SIGKILL."""
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
return True
except Exception:
return False
deadline = time.monotonic() + wait_s
while time.monotonic() < deadline:
if _pid_listening_on_port(port, runner) is None:
return True
time.sleep(1.0)
# Reintento suave de SIGTERM antes de rendirse (nunca SIGKILL: no destructivo).
try:
os.kill(pid, signal.SIGTERM)
except Exception:
pass
time.sleep(3.0)
return _pid_listening_on_port(port, runner) is None
def comfyui_ensure_server(
*,
port=8188,
lowvram=None,
health_timeout=60,
comfyui_dir="~/ComfyUI",
unit_name="comfyui",
runner=None,
):
"""Garantiza ComfyUI corriendo y sano como servicio systemd-user.
Args:
port: puerto HTTP del backend ComfyUI (default 8188).
lowvram: True/False fuerza el flag --lowvram; None autodetecta por VRAM
(GPUs <= 8 GB -> True).
health_timeout: segundos maximos esperando a que /system_stats responda
tras arrancar el servicio.
comfyui_dir: raiz de la instalacion de ComfyUI (con .venv/ y main.py).
unit_name: nombre del unit systemd-user (sin .service).
runner: callable(cmd:list)->CompletedProcess inyectable para tests.
Returns:
dict con: ok, active (ActiveState), port, health (bool), error (str|None),
lowvram (bool), unit_path, migrated (bool), reloaded (bool),
idempotent (bool).
"""
runner = runner or _default_runner
result = {
"ok": False,
"active": None,
"port": port,
"health": False,
"error": None,
"lowvram": None,
"unit_path": None,
"migrated": False,
"reloaded": False,
"idempotent": False,
}
comfyui_dir = os.path.abspath(os.path.expanduser(comfyui_dir))
python_bin = os.path.join(comfyui_dir, ".venv", "bin", "python")
main_py = os.path.join(comfyui_dir, "main.py")
if not os.path.exists(python_bin):
result["error"] = f"venv python no encontrado: {python_bin}"
return result
if not os.path.exists(main_py):
result["error"] = f"main.py no encontrado: {main_py}"
return result
# 1. Resolver lowvram (autodetect por VRAM si es None).
lv = lowvram if lowvram is not None else _detect_lowvram(_query_vram_mib(runner))
result["lowvram"] = bool(lv)
# 2. Renderizar e instalar el unit (solo reescribe si cambio el contenido).
content = _render_unit(
python_bin, main_py, comfyui_dir, port, lv,
"ComfyUI (Stable Diffusion / Flux backend) gestionado por el registry",
)
unit_dir = os.path.expanduser("~/.config/systemd/user")
try:
os.makedirs(unit_dir, exist_ok=True)
except Exception as e:
result["error"] = f"no se pudo crear {unit_dir}: {e}"
return result
unit_path = os.path.join(unit_dir, f"{unit_name}.service")
result["unit_path"] = unit_path
existing = None
if os.path.exists(unit_path):
try:
with open(unit_path, "r") as f:
existing = f.read()
except Exception:
existing = None
changed = existing != content
if changed:
tmp = unit_path + ".tmp"
try:
with open(tmp, "w") as f:
f.write(content)
os.replace(tmp, unit_path)
except Exception as e:
result["error"] = f"no se pudo escribir el unit: {e}"
return result
rl = _systemctl(runner, "daemon-reload")
result["reloaded"] = rl.returncode == 0
if rl.returncode != 0:
result["error"] = f"daemon-reload fallo: {(rl.stderr or '').strip()}"
return result
# 3. Habilitar (idempotente; el linger del usuario ya debe estar activo).
en = _systemctl(runner, "enable", unit_name)
if en.returncode != 0:
result["error"] = (
f"systemctl --user enable {unit_name} fallo: "
f"{(en.stderr or '').strip()}. "
"Si es por falta de linger: `loginctl enable-linger $USER`."
)
return result
# 4. Estado actual: salud HTTP + si systemd ya lo gestiona.
active_state = _unit_active_state(runner, unit_name)
health_now = _health(port)
if health_now and active_state == "active":
# Ya gestionado por systemd y sano -> idempotente, no tocar.
result["ok"] = True
result["health"] = True
result["active"] = "active"
result["idempotent"] = not changed
return result
if health_now and active_state != "active":
# Proceso a mano ocupa el puerto y systemd NO lo gestiona -> migrar limpio.
pid = _pid_listening_on_port(port, runner)
if pid and _is_comfy_process(pid):
if not _terminate_manual(pid, port, runner):
result["error"] = (
f"no se pudo liberar el puerto {port} (PID {pid}) con SIGTERM; "
"no arranco el servicio para no duplicar el bind."
)
return result
result["migrated"] = True
elif pid:
result["error"] = (
f"puerto {port} ocupado por PID {pid} que no parece ComfyUI; "
"no lo toco ni arranco el servicio."
)
return result
# Si pid es None pero health_now True: race raro; seguimos a start.
# 5. Arrancar via systemd y esperar salud.
st = _systemctl(runner, "start", unit_name)
if st.returncode != 0:
result["active"] = _unit_active_state(runner, unit_name)
result["error"] = (
f"systemctl --user start {unit_name} fallo: "
f"{(st.stderr or '').strip()}. Diagnostica con "
f"`journalctl --user -u {unit_name} -n 50`."
)
return result
healthy = _wait_health(port, health_timeout)
result["active"] = _unit_active_state(runner, unit_name)
result["health"] = healthy
result["ok"] = healthy
if not healthy:
result["error"] = (
f"el unit arranco pero /system_stats no respondio 2xx en "
f"{health_timeout}s. Revisa `journalctl --user -u {unit_name} -n 50`."
)
return result
if __name__ == "__main__":
import json
import sys
kwargs = {}
for arg in sys.argv[1:]:
if arg.startswith("--port="):
kwargs["port"] = int(arg.split("=", 1)[1])
elif arg == "--lowvram":
kwargs["lowvram"] = True
elif arg == "--no-lowvram":
kwargs["lowvram"] = False
elif arg.startswith("--health-timeout="):
kwargs["health_timeout"] = int(arg.split("=", 1)[1])
elif arg.startswith("--comfyui-dir="):
kwargs["comfyui_dir"] = arg.split("=", 1)[1]
print(json.dumps(comfyui_ensure_server(**kwargs), indent=2))
@@ -0,0 +1,156 @@
"""Tests para comfyui_ensure_server.
Los tests no tocan systemd ni la red reales: inyectan un runner falso que
registra los comandos systemctl y se mockea el health check.
"""
import os
import subprocess
from . import comfyui_ensure_server as mod
from .comfyui_ensure_server import (
_detect_lowvram,
_render_unit,
comfyui_ensure_server,
)
class FakeRunner:
"""Runner inyectable: respuestas programables por prefijo de comando."""
def __init__(self, active_state="inactive"):
self.calls = []
self.active_state = active_state
def __call__(self, cmd):
self.calls.append(list(cmd))
# nvidia-smi VRAM
if cmd[:1] == ["nvidia-smi"]:
return subprocess.CompletedProcess(cmd, 0, stdout="8192\n", stderr="")
if cmd[:2] == ["systemctl", "--user"]:
sub = cmd[2] if len(cmd) > 2 else ""
if sub == "is-active":
return subprocess.CompletedProcess(
cmd, 0, stdout=self.active_state + "\n", stderr=""
)
# daemon-reload, enable, start -> exito
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
if cmd[:1] == ["ss"]:
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
def ran(self, *needle):
return any(call[: len(needle)] == list(needle) for call in self.calls)
def _fake_comfy_dir(tmp_path):
"""Crea un comfyui_dir falso con .venv/bin/python y main.py."""
d = tmp_path / "ComfyUI"
(d / ".venv" / "bin").mkdir(parents=True)
(d / ".venv" / "bin" / "python").write_text("#!/bin/sh\n")
(d / "main.py").write_text("# fake\n")
return d
# --- helpers puros ---
def test_detect_lowvram_umbral_8gb():
assert _detect_lowvram(8192) is True
assert _detect_lowvram(8200) is True
assert _detect_lowvram(8201) is False
assert _detect_lowvram(24564) is False
assert _detect_lowvram(None) is False
def test_render_unit_restart_always_y_wantedby():
unit = _render_unit(
"/x/.venv/bin/python", "/x/main.py", "/x", 8188, True, "ComfyUI test"
)
assert "Restart=always" in unit
assert "on-failure" not in unit # regla function_tags
assert "WantedBy=default.target" in unit
assert "ExecStart=/x/.venv/bin/python /x/main.py --port 8188 --lowvram" in unit
assert "WorkingDirectory=/x" in unit
def test_render_unit_sin_lowvram():
unit = _render_unit(
"/x/.venv/bin/python", "/x/main.py", "/x", 9000, False, "ComfyUI test"
)
assert "--lowvram" not in unit
assert "--port 9000" in unit
# --- orquestacion (runner falso + health mockeado) ---
def test_error_si_falta_venv(tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
res = comfyui_ensure_server(
comfyui_dir=str(tmp_path / "no_existe"), runner=FakeRunner()
)
assert res["ok"] is False
assert "venv python no encontrado" in res["error"]
def test_idempotente_si_ya_activo_y_sano(tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setattr(mod, "_health", lambda *a, **k: True)
d = _fake_comfy_dir(tmp_path)
runner = FakeRunner(active_state="active")
# 1a llamada: instala el unit por primera vez (changed -> idempotent False),
# pero como ya esta active+sano NO debe arrancar nada.
res1 = comfyui_ensure_server(comfyui_dir=str(d), runner=runner)
assert res1["ok"] is True
assert res1["health"] is True
assert res1["active"] == "active"
assert res1["idempotent"] is False # escribio el unit por primera vez
assert not runner.ran("systemctl", "--user", "start", "comfyui")
# 2a llamada: el unit ya existe identico -> no toca nada -> idempotent True.
runner2 = FakeRunner(active_state="active")
res2 = comfyui_ensure_server(comfyui_dir=str(d), runner=runner2)
assert res2["ok"] is True
assert res2["idempotent"] is True
assert res2["reloaded"] is False # no reescribio el unit
assert not runner2.ran("systemctl", "--user", "start", "comfyui")
def test_arranque_fresco_escribe_unit_y_arranca(tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
# health: False antes de arrancar, True despues
estados = iter([False, True, True, True])
monkeypatch.setattr(mod, "_health", lambda *a, **k: next(estados, True))
d = _fake_comfy_dir(tmp_path)
runner = FakeRunner(active_state="inactive")
res = comfyui_ensure_server(comfyui_dir=str(d), runner=runner, health_timeout=5)
assert res["ok"] is True
assert res["health"] is True
assert res["lowvram"] is True # nvidia-smi falso devuelve 8192
assert runner.ran("systemctl", "--user", "daemon-reload")
assert runner.ran("systemctl", "--user", "enable", "comfyui")
assert runner.ran("systemctl", "--user", "start", "comfyui")
# el unit quedo escrito
unit_path = os.path.join(
str(tmp_path), ".config", "systemd", "user", "comfyui.service"
)
assert os.path.exists(unit_path)
with open(unit_path) as f:
assert "Restart=always" in f.read()
def test_lowvram_forzado_false_omite_flag(tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
estados = iter([False, True])
monkeypatch.setattr(mod, "_health", lambda *a, **k: next(estados, True))
d = _fake_comfy_dir(tmp_path)
runner = FakeRunner(active_state="inactive")
res = comfyui_ensure_server(
comfyui_dir=str(d), runner=runner, lowvram=False, health_timeout=5
)
assert res["lowvram"] is False
unit_path = os.path.join(
str(tmp_path), ".config", "systemd", "user", "comfyui.service"
)
with open(unit_path) as f:
assert "--lowvram" not in f.read()
+81
View File
@@ -0,0 +1,81 @@
---
name: mssql_connect
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def mssql_connect(host: str, database: str, user: str, password: str, port: int = 1433, login_timeout: int = 15, query_timeout: int = 30) -> pymssql.Connection"
description: "Abre una conexion pymssql a un Microsoft SQL Server (donde corre Navision). Las credenciales llegan siempre por argumento (el caller las saca de pass/env), nunca hardcodeadas. login_timeout acota la fase de conexion/login para evitar cuelgues con un host inalcanzable. Devuelve el objeto conexion pymssql para iterar queries despues."
tags: [mssql, sqlserver, navision, sql-connect, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [pymssql]
params:
- name: host
desc: "Host o IP del servidor SQL Server. Desde WSL2 debe ser la IP LAN de Windows (ej. 10.0.0.5), no localhost."
- name: database
desc: "Nombre de la base de datos a la que conectar (ej. navdb)."
- name: user
desc: "Usuario de login de SQL Server (ej. sa)."
- name: password
desc: "Contrasena del usuario de login. Se pasa desde pass/env, nunca como literal."
- name: port
desc: "Puerto TCP del SQL Server. Por defecto 1433. La funcion lo convierte a string porque pymssql lo exige asi."
- name: login_timeout
desc: "Segundos permitidos para la fase de conexion/login antes de fallar. Por defecto 15. Evita que un host inalcanzable cuelgue indefinidamente."
- name: query_timeout
desc: "Segundos permitidos para cada query ejecutada sobre la conexion devuelta antes de hacer timeout. Por defecto 30."
output: "Un objeto pymssql.Connection abierto. El caller es responsable de cerrarlo con .close() al terminar."
tested: true
tests: ["test_golden_connect_passes_string_port_and_kwargs", "test_error_path_wraps_failure_with_host"]
test_file_path: "python/functions/infra/mssql_connect_test.py"
file_path: "python/functions/infra/mssql_connect.py"
---
## Ejemplo
```python
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))
from infra.mssql_connect import mssql_connect
# La IP debe ser la IP LAN del servidor Windows: desde WSL2 "localhost" NO
# llega al host Windows. La contrasena llega del entorno, nunca literal.
conn = mssql_connect(
host="10.0.0.5",
database="navdb",
user="sa",
password=os.environ["MSSQL_PASSWORD"],
port=1433,
login_timeout=15,
)
try:
with conn.cursor() as cur:
cur.execute("SELECT TOP 1 name FROM sys.databases")
print(cur.fetchone())
finally:
conn.close()
```
## Cuando usarla
Usala cuando necesites abrir una conexion a un Microsoft SQL Server (donde
corre Navision) antes de iterar queries con `mssql_query`. Es el primer paso
de cualquier pipeline que lea datos de Navision: abre la conexion una vez,
reutilizala para varias queries, y cierrala al final. Triggers: "conecta a
Navision", "lee de SQL Server", "abre conexion mssql".
## Gotchas
- WSL2 -> Windows: usa la IP LAN del servidor Windows, NUNCA `localhost`. Desde dentro de WSL2 `localhost` no alcanza el host Windows (el reenvio de localhost solo funciona Windows -> WSL, no al reves).
- pymssql necesita el puerto como string. La funcion ya convierte `port` a `str(port)` internamente, asi que tu pasas un int normal.
- `login_timeout` esta acotado (15s por defecto) precisamente para que un host inalcanzable o mal configurado falle con un RuntimeError claro en vez de colgarse indefinidamente. Ajustalo si la red es lenta, pero no lo dejes sin limite.
- Credenciales NUNCA hardcodeadas: `user`/`password` llegan por argumento desde `pass`/env. No las escribas literales en el codigo del caller.
- Cierra la conexion con `.close()` al terminar (idealmente en un `finally`). La funcion devuelve un handle abierto y no gestiona su ciclo de vida.
- Requiere `pymssql` instalado en el venv (import perezoso: el modulo importa sin la dependencia, pero la llamada falla con RuntimeError claro si falta).
+65
View File
@@ -0,0 +1,65 @@
"""Open a connection to a Microsoft SQL Server (Navision) via pymssql."""
from __future__ import annotations
def mssql_connect(host: str, database: str, user: str, password: str,
port: int = 1433, login_timeout: int = 15,
query_timeout: int = 30):
"""Open a connection to a Microsoft SQL Server instance (e.g. Navision).
Uses the pymssql driver. Credentials are always supplied by the caller
(typically read from `pass`/env) and never hardcoded. The connection is
impure I/O: it touches the network and the database server.
pymssql expects the TCP port as a string, so `port` is converted before
being passed through. `login_timeout` bounds the connect/login phase, which
is what keeps an invalid host from hanging indefinitely; `query_timeout`
bounds individual queries run on the resulting connection.
Args:
host: SQL Server host or IP. From WSL2 this must be the Windows LAN IP
(e.g. "10.0.0.5"), not "localhost" localhost does not reach the
Windows host from inside WSL2.
database: Name of the database to connect to (e.g. "navdb").
user: SQL Server login user (e.g. "sa").
password: Password for the login user. Pass it from `pass`/env, never
as a string literal.
port: TCP port of the SQL Server instance. Defaults to 1433. Converted
to a string internally because pymssql requires a string port.
login_timeout: Seconds allowed for the connect/login phase before it
fails. Defaults to 15. Keeps an unreachable host from hanging.
query_timeout: Seconds allowed for each query executed on the returned
connection before it times out. Defaults to 30.
Returns:
An open pymssql.Connection. The caller is responsible for closing it
with `.close()` when done.
Raises:
RuntimeError: If pymssql is not installed, or if the connection/login
fails. The message includes host:port and database for context and
the original exception is chained for debugging.
"""
# Lazy import so the module loads even without pymssql installed.
try:
import pymssql
except ImportError as exc: # pragma: no cover - exercised only without dep
raise RuntimeError(
"pymssql is required for mssql_connect; install pymssql"
) from exc
try:
return pymssql.connect(
server=host,
user=user,
password=password,
database=database,
port=str(port),
login_timeout=login_timeout,
timeout=query_timeout,
)
except Exception as exc:
raise RuntimeError(
f"mssql_connect failed connecting to {host}:{port}/{database}: {exc}"
) from exc
@@ -0,0 +1,59 @@
"""Tests for mssql_connect (mock-based, no real SQL Server)."""
from __future__ import annotations
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(__file__))
from mssql_connect import mssql_connect
def test_golden_connect_passes_string_port_and_kwargs(monkeypatch):
"""Golden path: returns the driver connection and forwards the right kwargs.
The TCP port must reach pymssql as a STRING, and login_timeout must default
to 15 when not supplied.
"""
captured: dict = {}
sentinel = object()
def fake_connect(**kwargs):
captured.update(kwargs)
return sentinel
monkeypatch.setattr("pymssql.connect", fake_connect)
result = mssql_connect("10.0.0.5", "navdb", "sa", "pw", port=1433)
assert result is sentinel
assert captured["server"] == "10.0.0.5"
assert captured["database"] == "navdb"
assert captured["user"] == "sa"
assert captured["password"] == "pw"
assert captured["port"] == "1433"
assert isinstance(captured["port"], str)
assert captured["login_timeout"] == 15
assert captured["timeout"] == 30
def test_error_path_wraps_failure_with_host(monkeypatch):
"""Error path: a driver failure becomes a clear RuntimeError, not a hang.
The wrapped message must include the host and the phrase 'failed connecting'
so callers can diagnose connectivity problems.
"""
def fake_connect(**kwargs):
raise Exception("login timeout")
monkeypatch.setattr("pymssql.connect", fake_connect)
with pytest.raises(RuntimeError) as excinfo:
mssql_connect("10.0.0.5", "navdb", "sa", "pw", port=1433)
message = str(excinfo.value)
assert "10.0.0.5" in message
assert "failed connecting" in message
+78
View File
@@ -0,0 +1,78 @@
---
name: mssql_query
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def mssql_query(conn, sql: str, params=None, max_rows: int | None = None) -> dict"
description: "Ejecuta una SELECT parametrizada (binding seguro de pymssql, sin inyeccion) sobre una conexion SQL Server/Navision ya abierta y devuelve {columns, rows como lista de dicts, row_count}. Opcion max_rows para limitar las filas."
tags: [mssql, sqlserver, navision, sql-connect, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["test_golden_maps_rows_to_dicts", "test_binding_passes_params_to_driver", "test_zero_rows_no_error", "test_max_rows_uses_fetchmany", "test_description_none_empty_columns", "test_execution_error_raises_runtimeerror"]
test_file_path: "python/functions/infra/mssql_query_test.py"
params:
- name: conn
desc: "Conexion abierta (la que devuelve mssql_connect). No se abre ni cierra aqui; se reutiliza por duck typing via conn.cursor()."
- name: sql
desc: "Sentencia SELECT con placeholders pymssql %s (posicional) o %(nombre)s (nombrado) para los valores a vincular."
- name: params
desc: "Tuple/list para placeholders posicionales, dict para nombrados, o None. Se pasa a cursor.execute(sql, params) para binding seguro del driver (nunca interpolacion)."
- name: max_rows
desc: "Si es int>0, limita a las primeras max_rows filas (fetchmany). Si None, devuelve todas (fetchall)."
output: "Dict con tres claves: 'columns' (lista de nombres de columna en orden, vacia si no hubo result set), 'rows' (lista de dicts columna->valor, una por fila), 'row_count' (int len(rows))."
file_path: "python/functions/infra/mssql_query.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from infra.mssql_connect import mssql_connect
from infra.mssql_query import mssql_query
conn = mssql_connect(
host="10.0.0.5", database="navdb", user="readonly", password="<desde pass>"
)
try:
res = mssql_query(
conn,
"SELECT TOP 10 No_, Amount FROM [dbo].[Cartera] WHERE [Customer No_] = %s",
("CLI-0001",),
)
print(res["columns"]) # ['No_', 'Amount']
print(res["row_count"]) # numero de filas devueltas
for fila in res["rows"]:
print(fila["No_"], fila["Amount"])
finally:
conn.close()
```
## Cuando usarla
Cuando ya tienes una conexion abierta con `mssql_connect` y quieres iterar
consultas SELECT sobre Navision / SQL Server sin reabrir la conexion en cada
una. Pasa los valores variables como `params` para que el driver los vincule de
forma segura (sin inyeccion) en lugar de construir el SQL con f-strings.
## Gotchas
- Los placeholders de pymssql son `%s` (posicional) y `%(nombre)s` (nombrado),
NO el `?` de pyodbc. Si usas el placeholder equivocado, el binding falla.
- Pasa los valores SIEMPRE por el argumento `params`, jamas con f-string o `%`
dentro del SQL: interpolar abre la puerta a inyeccion SQL.
- No hace commit: es read-only, pensada para SELECT.
- No cierra la conexion — la gestiona el caller (abrir una vez, consultar
muchas, cerrar al final).
- `max_rows` usa `cursor.fetchmany(max_rows)`; con None usa `fetchall()`.
- Si la sentencia no produce result set (`cursor.description is None`),
`columns` y `rows` vuelven como listas vacias en lugar de fallar.
- El mensaje de error es generico a proposito: no incluye el SQL ni los params
para no filtrar datos sensibles.
+77
View File
@@ -0,0 +1,77 @@
"""Run a parameterized SELECT over an open pymssql (SQL Server / Navision) connection."""
from __future__ import annotations
def mssql_query(conn, sql: str, params=None, max_rows: int | None = None) -> dict:
"""Execute a SELECT on an already-open connection and map rows to dicts.
The connection is supplied by the caller (typically from `mssql_connect`),
so a single connection can be opened once and reused for many queries. This
function never opens or closes the connection it only borrows it. It is
impure I/O: it touches the database over an existing connection.
Parameter binding is delegated to the driver: `params` is passed straight to
`cursor.execute(sql, params)`. NEVER interpolate values into `sql` with
f-strings or `%` formatting that opens the door to SQL injection. Use the
pymssql placeholders `%s` (positional) or `%(name)s` (named) in `sql` and
let the driver bind safely. When `params is None`, the SQL is executed with
no bound parameters.
The query runs read-only: no commit is issued. The cursor opened here is
always closed before returning (try/finally), even on error.
Args:
conn: An open connection object (e.g. the one returned by
`mssql_connect`). Used by duck typing via `conn.cursor()`, so the
concrete driver does not matter and the function stays testable.
sql: The SELECT statement, using pymssql placeholders `%s` (positional)
or `%(name)s` (named) for any bound values.
params: A tuple/list for positional placeholders, a dict for named
placeholders, or None for a query with no parameters. Passed to
`cursor.execute(sql, params)` for safe driver-side binding.
max_rows: If a positive int, only the first `max_rows` rows are fetched
(via `cursor.fetchmany(max_rows)`). If None, all rows are fetched
(via `cursor.fetchall()`).
Returns:
A dict with three keys:
- "columns": list of column names in result order (empty list if the
statement produced no result set, i.e. `cursor.description is None`).
- "rows": list of dicts, one per row, mapping each column name to its
value. Empty list when the query returned no rows.
- "row_count": int, equal to `len(rows)`.
Raises:
RuntimeError: If executing or fetching the query fails. The message is
deliberately generic (it does not include the SQL or the params,
which may carry sensitive data) and the original exception is
chained for debugging.
"""
cur = conn.cursor()
try:
try:
if params is None:
cur.execute(sql)
else:
cur.execute(sql, params)
description = cur.description
if description is None:
columns: list = []
raw_rows: list = []
else:
columns = [d[0] for d in description]
if max_rows is not None and max_rows > 0:
raw_rows = cur.fetchmany(max_rows)
else:
raw_rows = cur.fetchall()
except Exception as exc:
raise RuntimeError(
f"mssql_query failed executing query: {exc}"
) from exc
finally:
cur.close()
rows = [dict(zip(columns, row)) for row in raw_rows]
return {"columns": columns, "rows": rows, "row_count": len(rows)}
+133
View File
@@ -0,0 +1,133 @@
"""Tests para mssql_query usando un doble de prueba (sin servidor real)."""
from __future__ import annotations
import os
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from functions.infra.mssql_query import mssql_query
def _desc(*names):
"""Construye una description estilo DB-API: una tupla 7-elem por columna."""
return [(name, None, None, None, None, None, None) for name in names]
class FakeCursor:
"""Doble de prueba de un cursor DB-API (pymssql-like)."""
def __init__(self, description=None, rows=None):
self.description = description
self._rows = list(rows or [])
self.executed = None # (sql, params) de la ultima execute
self.fetchmany_calls = [] # tamaños pedidos a fetchmany
self.closed = False
def execute(self, sql, params=None):
self.executed = (sql, params)
def fetchall(self):
return list(self._rows)
def fetchmany(self, size):
self.fetchmany_calls.append(size)
return list(self._rows[:size])
def close(self):
self.closed = True
class FakeConn:
"""Doble de prueba de una conexion: devuelve un FakeCursor fijo."""
def __init__(self, cursor):
self._cursor = cursor
def cursor(self):
return self._cursor
def test_golden_maps_rows_to_dicts():
cur = FakeCursor(
description=_desc("No_", "Amount"),
rows=[("CLI-1", 100), ("CLI-2", 200)],
)
conn = FakeConn(cur)
result = mssql_query(conn, "SELECT No_, Amount FROM Cartera")
assert result == {
"columns": ["No_", "Amount"],
"rows": [
{"No_": "CLI-1", "Amount": 100},
{"No_": "CLI-2", "Amount": 200},
],
"row_count": 2,
}
assert cur.closed is True
def test_binding_passes_params_to_driver():
cur = FakeCursor(description=_desc("No_"), rows=[("CLI-0001",)])
conn = FakeConn(cur)
sql = "SELECT No_ FROM Cartera WHERE [Customer No_] = %s"
mssql_query(conn, sql, params=("CLI-0001",))
# El SQL y los params llegan al driver tal cual: binding, no interpolacion.
assert cur.executed == (sql, ("CLI-0001",))
def test_zero_rows_no_error():
cur = FakeCursor(description=_desc("No_", "Amount"), rows=[])
conn = FakeConn(cur)
result = mssql_query(conn, "SELECT No_, Amount FROM Cartera WHERE 1 = 0")
assert result["rows"] == []
assert result["row_count"] == 0
assert result["columns"] == ["No_", "Amount"]
def test_max_rows_uses_fetchmany():
cur = FakeCursor(
description=_desc("No_"),
rows=[("CLI-1",), ("CLI-2",), ("CLI-3",)],
)
conn = FakeConn(cur)
result = mssql_query(conn, "SELECT No_ FROM Cartera", max_rows=1)
assert cur.fetchmany_calls == [1]
assert result["row_count"] == 1
assert result["rows"] == [{"No_": "CLI-1"}]
def test_description_none_empty_columns():
cur = FakeCursor(description=None, rows=[])
conn = FakeConn(cur)
result = mssql_query(conn, "SET NOCOUNT ON")
assert result["columns"] == []
assert result["rows"] == []
assert result["row_count"] == 0
def test_execution_error_raises_runtimeerror():
class BoomCursor(FakeCursor):
def execute(self, sql, params=None):
raise ValueError("boom")
cur = BoomCursor()
conn = FakeConn(cur)
with pytest.raises(RuntimeError, match="mssql_query failed executing query"):
mssql_query(conn, "SELECT 1")
# El cursor se cierra incluso en error (try/finally).
assert cur.closed is True
@@ -0,0 +1,75 @@
---
name: comfyui_batch_generate
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_batch_generate(workflow: dict, *, seeds: list | None = None, server: str = \"127.0.0.1:8188\") -> dict"
description: "Encola N variantes de un workflow ComfyUI, una por seed de la lista, parcheando el campo de semilla de los nodos sampler (KSampler.seed, KSamplerAdvanced/SamplerCustom.noise_seed) sin mutar el original (deepcopy), y recoge cada prompt_id. Compone comfyui_submit_workflow. Util para barridos de re-roll: misma escena, varias semillas, una sola llamada. Devuelve {ok, prompt_ids, count, error}. Impura: HTTP POST por variante, solo stdlib."
tags: [comfyui, ml, batch, seeds, queue, http]
uses_functions: ["comfyui_submit_workflow_py_ml"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: workflow
desc: "dict en API format (resultado de un builder). No se muta: cada variante es una copia profunda con la semilla parcheada."
- name: seeds
desc: "Lista de semillas (int); cada una produce una variante encolada. None o vacia encola el workflow tal cual una sola vez. keyword-only."
- name: server
desc: "host:port del servidor ComfyUI sin esquema (default '127.0.0.1:8188'). keyword-only."
output: "dict con ok (bool, True si TODAS las variantes se encolaron), prompt_ids (list[str] en orden de seeds, para comfyui_wait_result), count (int, variantes encoladas con exito), error (str, primer error; vacio si OK). Si una variante falla, detiene el barrido y devuelve los prompt_ids ya encolados."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_batch_generate.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
from ml.comfyui_batch_generate import comfyui_batch_generate
wf = comfyui_build_txt2img_workflow(
ckpt_name="v1-5-pruned-emaonly-fp16.safetensors",
positive="a red apple on a wooden table, sharp focus",
negative="blurry, low quality",
)
res = comfyui_batch_generate(wf, seeds=[1, 2, 3])
# {'ok': True, 'prompt_ids': ['<id1>', '<id2>', '<id3>'], 'count': 3, 'error': ''}
for pid in res["prompt_ids"]:
pass # comfyui_wait_result(pid) para recoger cada resultado
```
O lanzable directo (build txt2img + encolar 2 seeds) con: `./fn run comfyui_batch_generate`.
## Cuando usarla
Para generar varias variantes de la misma escena cambiando solo la semilla
(re-roll de calidad) en una sola llamada, en vez de editar el seed y reenviar a
mano N veces. Aplica a cualquier workflow con nodo sampler: txt2img, img2img,
video (parchea `noise_seed` del SamplerCustom de LTX), etc. Tras encolar, sigue
cada `prompt_id` con `comfyui_wait_result`.
## Gotchas
- Parchea TODO input llamado `seed` o `noise_seed` en cualquier nodo. Si un
workflow tiene varios samplers, todos reciben la misma semilla de la variante
(normalmente lo deseado). Si necesitas semillas independientes por sampler,
parchea a mano.
- Encolar tiene efecto secundario: arranca trabajo de GPU. N seeds = N prompts en
cola = N corridas de GPU en serie. En 8GB, no encoles 20 videos a la vez sin
vigilar VRAM/tiempo.
- `seeds=None` encola el workflow tal cual UNA vez (sin tocar la semilla): util
como "submit con la firma de batch".
- Fail-fast: si una variante es rechazada (HTTP 400), detiene el barrido,
devuelve `ok=False` + `error` y los `prompt_ids` ya encolados (no hace rollback
de los anteriores — ya estan en la cola del servidor).
- Si necesitas cortar un barrido a medias, usa `comfyui_interrupt_queue` (corta el
que se ejecuta) o `POST /queue {"clear": true}` para vaciar los pendientes.
@@ -0,0 +1,91 @@
"""Encola N variantes de un workflow ComfyUI, una por seed, y recoge los prompt_ids.
Funcion impura: hace red (POST /prompt por variante, via comfyui_submit_workflow).
Compone comfyui_submit_workflow.
Para cada seed de la lista, copia el workflow (deepcopy, no muta el original),
parchea el campo de semilla de los nodos sampler (KSampler.seed, KSamplerAdvanced.
noise_seed, SamplerCustom.noise_seed en general cualquier input "seed"/"noise_seed")
y lo encola. Util para barridos de re-roll: misma escena, varias semillas, una sola
llamada. Devuelve los prompt_ids en el mismo orden que la lista de seeds; cada uno
se sigue con comfyui_wait_result.
"""
import copy
import os
import sys
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
if _THIS_DIR not in sys.path:
sys.path.insert(0, _THIS_DIR)
from comfyui_submit_workflow import comfyui_submit_workflow # noqa: E402
# Campos de semilla conocidos en los nodos sampler de ComfyUI.
_SEED_KEYS = ("seed", "noise_seed")
def _patch_seed(workflow: dict, seed: int) -> dict:
"""Copia el workflow y fija `seed` en todos los inputs de semilla (no muta el original)."""
wf = copy.deepcopy(workflow)
for node in wf.values():
inputs = node.get("inputs")
if not isinstance(inputs, dict):
continue
for key in _SEED_KEYS:
if key in inputs:
inputs[key] = seed
return wf
def comfyui_batch_generate(
workflow: dict,
*,
seeds: list | None = None,
server: str = "127.0.0.1:8188",
) -> dict:
"""Encola una variante del workflow por cada seed y devuelve los prompt_ids.
Args:
workflow: dict en API format (resultado de un builder). No se muta: cada
variante es una copia profunda con la semilla parcheada.
seeds: lista de semillas (int). Cada una produce una variante encolada. Si
es None o vacia, se encola el workflow tal cual una sola vez (sin
parchear semilla). keyword-only.
server: host:port del servidor ComfyUI sin esquema. keyword-only.
Returns:
dict con:
- ok (bool): True si TODAS las variantes se encolaron sin error.
- prompt_ids (list[str]): prompt_id de cada variante encolada, en orden.
- count (int): numero de variantes encoladas con exito.
- error (str): primer error encontrado; cadena vacia si todo OK. Si una
variante falla, se detiene el barrido y se devuelven los prompt_ids ya
encolados.
"""
out = {"ok": False, "prompt_ids": [], "count": 0, "error": ""}
variants = [(s, _patch_seed(workflow, s)) for s in seeds] if seeds else [(None, workflow)]
for seed, wf in variants:
try:
resp = comfyui_submit_workflow(wf, server=server)
except RuntimeError as exc:
label = "tal cual" if seed is None else f"seed={seed}"
out["error"] = f"variante {label} fallo al encolar: {exc}"
return out
out["prompt_ids"].append(resp["prompt_id"])
out["count"] = len(out["prompt_ids"])
out["ok"] = True
return out
if __name__ == "__main__":
from comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
wf = comfyui_build_txt2img_workflow(
ckpt_name="v1-5-pruned-emaonly-fp16.safetensors",
positive="a red apple on a wooden table, sharp focus",
negative="blurry, low quality",
)
res = comfyui_batch_generate(wf, seeds=[1, 2])
print(f"ok={res['ok']} count={res['count']} ids={res['prompt_ids']} error={res['error']!r}")
@@ -0,0 +1,89 @@
---
name: comfyui_build_controlnet_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_controlnet_workflow(ckpt_name: str, control_image: str, cn_name: str, positive: str, negative: str = \"\", *, strength: float = 1.0, steps: int = 20, cfg: float = 7.0, seed: int = 0, width: int = 512, height: int = 512) -> dict"
description: "Construye el dict de un workflow ComfyUI txt2img guiado por ControlNet en API format: CheckpointLoaderSimple + EmptyLatentImage + LoadImage (mapa de control) + ControlNetLoader -> ControlNetApply (inyecta el control sobre el condicionamiento positivo) -> KSampler -> VAEDecode -> SaveImage. Pura, sin red ni I/O. Hermana de comfyui_build_txt2img_workflow."
tags: [comfyui, ml, image-generation, controlnet, stable-diffusion, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: ckpt_name
desc: "Nombre del checkpoint tal como lo ve el servidor ComfyUI (ej. 'dreamshaper_8.safetensors'). Debe estar en la lista de CheckpointLoaderSimple de comfyui_object_info."
- name: control_image
desc: "Nombre del archivo de la imagen de control dentro de input/ del servidor (mapa canny/depth/openpose preprocesado); lo carga el nodo LoadImage."
- name: cn_name
desc: "Nombre del modelo ControlNet en models/controlnet/ tal como lo lista comfyui_object_info para ControlNetLoader (control_net_name)."
- name: positive
desc: "Prompt positivo: lo que se quiere ver en la imagen."
- name: negative
desc: "Prompt negativo: lo que se quiere evitar. Por defecto cadena vacia."
- name: strength
desc: "Fuerza con la que el ControlNet condiciona la generacion (0.0 = nula, 1.0 = plena). keyword-only."
- name: steps
desc: "Pasos de sampling del KSampler. keyword-only."
- name: cfg
desc: "Classifier-free guidance scale. keyword-only."
- name: seed
desc: "Semilla del KSampler. 0 es determinista; cambiar para variar. keyword-only."
- name: width
desc: "Ancho del latente/imagen en px (multiplo de 8). keyword-only."
- name: height
desc: "Alto del latente/imagen en px (multiplo de 8). keyword-only."
output: "dict en API format con node_ids como claves (CheckpointLoaderSimple '4', EmptyLatentImage '5', LoadImage '10', ControlNetLoader '12', CLIPTextEncode '6'/'7', ControlNetApply '13', KSampler '3', VAEDecode '8', SaveImage '9'). Listo para comfyui_submit_workflow."
tested: true
tests: ["usa ControlNetLoader+ControlNetApply", "control_image, modelo cn y strength reflejados", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_controlnet_workflow.py"
file_path: "python/functions/ml/comfyui_build_controlnet_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_controlnet_workflow import comfyui_build_controlnet_workflow
wf = comfyui_build_controlnet_workflow(
ckpt_name="dreamshaper_8.safetensors",
control_image="pose_canny.png", # mapa de control en input/
cn_name="control_v11p_sd15_canny_fp16.safetensors", # modelo en models/controlnet/
positive="a knight in shining armor, dramatic lighting",
negative="blurry, low quality",
strength=0.8,
seed=42,
)
# wf["13"]["class_type"] == "ControlNetApply"
# wf["13"]["inputs"]["conditioning"] == ["6", 0] # aplica sobre el positivo
# wf["3"]["inputs"]["positive"] == ["13", 0] # KSampler usa el cond condicionado
```
El bloque se lanza con el python del venv. `./fn run` directo no aplica (firma con
`*` keyword-only); usa el import o un heredoc.
## Cuando usarla
Cuando quieras controlar la composicion de la imagen con una guia estructural
(bordes canny, profundidad depth, pose openpose, scribble) en lugar de dejar la
composicion al azar del prompt. Necesitas el mapa de control ya preprocesado en
`input/` y el modelo ControlNet adecuado descargado en `models/controlnet/`.
## Gotchas
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI.
- `control_image` debe ser el mapa de control YA preprocesado (ej. salida de un
preprocesador canny/depth). Este builder NO incluye el nodo preprocesador; si
pasas una foto normal, el ControlNet la usara tal cual.
- Usa el nodo clasico `ControlNetApply` (un solo `strength`). Para ControlNet
avanzado con `start_percent`/`end_percent` necesitas `ControlNetApplyAdvanced`
(no cubierto aqui): montalo en la UI y captura con `comfyui_export_workflow_ui`.
- `cn_name` debe corresponder a la version del checkpoint (un ControlNet de SD1.5
no sirve con un checkpoint SDXL). Valida antes con `comfyui_validate_workflow`.
- Es pura: NO valida que los modelos existan en el servidor. Valida antes.
@@ -0,0 +1,129 @@
"""Construye un workflow ComfyUI con ControlNet en API format (nodos numerados).
ControlNet condiciona la generacion con una imagen de control (canny, depth,
pose, scribble, ...). Cadena de nodos: CheckpointLoaderSimple + EmptyLatentImage
+ LoadImage (imagen de control) + ControlNetLoader -> ControlNetApply (inyecta
el control sobre el condicionamiento positivo) -> KSampler -> VAEDecode ->
SaveImage. Los CLIPTextEncode codifican el prompt positivo y el negativo.
API format: cada clave es un node_id (string); cada nodo tiene class_type +
inputs. Las conexiones entre nodos son listas [node_id, output_index]. Es el
formato que acepta POST /prompt, distinto del formato de la UI (graph con links).
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
def comfyui_build_controlnet_workflow(
ckpt_name: str,
control_image: str,
cn_name: str,
positive: str,
negative: str = "",
*,
strength: float = 1.0,
steps: int = 20,
cfg: float = 7.0,
seed: int = 0,
width: int = 512,
height: int = 512,
) -> dict:
"""Construye el dict de un workflow txt2img guiado por ControlNet.
Args:
ckpt_name: nombre del checkpoint tal como lo ve el servidor ComfyUI
(ej. "dreamshaper_8.safetensors"). Debe estar entre los que devuelve
comfyui_object_info para CheckpointLoaderSimple.
control_image: nombre del archivo de la imagen de control dentro de la
carpeta input/ del servidor ComfyUI (lo carga el nodo LoadImage).
Suele ser un mapa preprocesado (canny/depth/openpose).
cn_name: nombre del modelo ControlNet en models/controlnet/ tal como lo
lista comfyui_object_info para ControlNetLoader (control_net_name).
positive: prompt positivo (lo que se quiere ver en la imagen).
negative: prompt negativo (lo que se quiere evitar). Por defecto "".
strength: fuerza con la que el ControlNet condiciona la generacion
(0.0 = nula, 1.0 = plena). keyword-only.
steps: pasos de sampling del KSampler. keyword-only.
cfg: classifier-free guidance scale. keyword-only.
seed: semilla del KSampler. 0 es determinista; cambiar para variar.
keyword-only.
width: ancho del latente/imagen en px (multiplo de 8). keyword-only.
height: alto del latente/imagen en px (multiplo de 8). keyword-only.
Returns:
dict en API format listo para comfyui_submit_workflow. Las claves son
node_ids y cada valor tiene class_type + inputs.
"""
return {
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": ckpt_name},
},
"5": {
"class_type": "EmptyLatentImage",
"inputs": {"width": width, "height": height, "batch_size": 1},
},
"10": {
"class_type": "LoadImage",
"inputs": {"image": control_image},
},
"12": {
"class_type": "ControlNetLoader",
"inputs": {"control_net_name": cn_name},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": positive, "clip": ["4", 1]},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["4", 1]},
},
"13": {
"class_type": "ControlNetApply",
"inputs": {
"conditioning": ["6", 0],
"control_net": ["12", 0],
"image": ["10", 0],
"strength": strength,
},
},
"3": {
"class_type": "KSampler",
"inputs": {
"seed": seed,
"steps": steps,
"cfg": cfg,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1.0,
"model": ["4", 0],
"positive": ["13", 0],
"negative": ["7", 0],
"latent_image": ["5", 0],
},
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "comfy_controlnet", "images": ["8", 0]},
},
}
if __name__ == "__main__":
import json
wf = comfyui_build_controlnet_workflow(
ckpt_name="dreamshaper_8.safetensors",
control_image="pose_canny.png",
cn_name="control_v11p_sd15_canny.pth",
positive="a knight in shining armor, dramatic lighting",
negative="blurry, low quality",
strength=0.8,
seed=42,
)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,115 @@
---
name: comfyui_build_facedetailer_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_facedetailer_workflow(base_workflow_or_image, ckpt_name: str, positive: str, negative: str = \"\", *, bbox_model: str = \"face_yolov8m.pt\", denoise: float = 0.5, steps: int = 20, cfg: float = 8.0, seed: int = 0, guide_size: float = 512.0, bbox_threshold: float = 0.5, feather: int = 5, sampler_name: str = \"euler\", scheduler: str = \"normal\", filename_prefix: str = \"facedetail\") -> dict"
description: "Construye un workflow ComfyUI con FaceDetailer (Impact-Pack) en API format: detecta caras con UltralyticsDetectorProvider (YOLO bbox) y las regenera con un sampler de difusion para recuperar detalle (el pain #1 de retratos). Acepta el nombre de una imagen ya en input/ (modo str) o un workflow base como dict (modo workflow, p.ej. el de comfyui_build_txt2img_workflow): en este caso toma la imagen del VAEDecode y reutiliza el CheckpointLoaderSimple. Class_types reales verificados en /object_info. Pura, sin red ni I/O."
tags: [comfyui, ml, facedetailer, impact-pack, portrait, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: base_workflow_or_image
desc: "Nombre (str) de una imagen ya en el input/ del servidor, o un workflow base (dict en API format). Con str monta LoadImage + CheckpointLoaderSimple nuevos; con dict toma la imagen del primer VAEDecode y reutiliza su CheckpointLoaderSimple."
- name: ckpt_name
desc: "Checkpoint para el sampler del detailer (y para el loader nuevo en modo imagen). Debe existir en el servidor (CheckpointLoaderSimple)."
- name: positive
desc: "Prompt positivo para regenerar las caras (ej. 'detailed face, sharp eyes, skin texture'). Se codifica con el CLIP del checkpoint."
- name: negative
desc: "Prompt negativo. Por defecto ''."
- name: bbox_model
desc: "Modelo de deteccion Ultralytics. Acepta nombre corto ('face_yolov8m.pt') o prefijado ('bbox/face_yolov8m.pt'); si no trae prefijo se asume 'bbox/'. keyword-only."
- name: denoise
desc: "Fuerza de re-difusion de cada cara (0.5 por defecto; mas alto = mas cambio, mas riesgo de perder identidad). keyword-only."
- name: steps
desc: "Pasos de sampling del detailer. keyword-only."
- name: cfg
desc: "Classifier-free guidance del detailer. keyword-only."
- name: seed
desc: "Semilla del sampler del detailer. keyword-only."
- name: guide_size
desc: "Tamano (px) al que se reescala cada cara recortada antes de re-difundirla (FaceDetailer.guide_size). keyword-only."
- name: bbox_threshold
desc: "Umbral de confianza del detector de caras (0..1). Mas alto = menos falsos positivos, riesgo de no detectar caras pequenas. keyword-only."
- name: feather
desc: "Pixeles de difuminado del borde de la mascara al recomponer la cara sobre la imagen. keyword-only."
- name: sampler_name
desc: "Sampler del detailer (ej. 'euler'). keyword-only."
- name: scheduler
desc: "Scheduler del detailer (ej. 'normal'). keyword-only."
- name: filename_prefix
desc: "Prefijo del PNG final que escribe SaveImage. keyword-only."
output: "dict en API format listo para comfyui_submit_workflow. En modo dict contiene los nodos del workflow base mas los del detailer (node_ids prefijados 'fd_' para no colisionar); el SaveImage 'fd_save' produce la imagen con las caras regeneradas."
tested: true
tests: ["modo imagen monta UltralyticsDetectorProvider + FaceDetailer + SaveImage", "modo workflow reutiliza VAEDecode y CheckpointLoaderSimple del base y conserva sus nodos", "normaliza bbox_model corto a prefijo bbox/", "dict sin VAEDecode lanza ValueError", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_facedetailer_workflow.py"
file_path: "python/functions/ml/comfyui_build_facedetailer_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_facedetailer_workflow import comfyui_build_facedetailer_workflow
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
# Modo workflow: genera un retrato y le aplica FaceDetailer en el mismo grafo.
base = comfyui_build_txt2img_workflow(
ckpt_name="dreamshaper_8.safetensors",
positive="portrait of a woman, soft light",
width=512, height=768, seed=7,
)
wf = comfyui_build_facedetailer_workflow(
base,
ckpt_name="dreamshaper_8.safetensors",
positive="detailed face, sharp eyes, skin texture",
negative="blurry, deformed",
denoise=0.45,
)
# wf["fd_det"]["class_type"] == "UltralyticsDetectorProvider"
# wf["fd_det"]["inputs"]["model_name"] == "bbox/face_yolov8m.pt"
# wf["fd_face"]["class_type"] == "FaceDetailer"
# wf["fd_face"]["inputs"]["image"] == ["8", 0] # VAEDecode del base
# wf["4"]["class_type"] == "CheckpointLoaderSimple" # nodos del base conservados
```
O lanzable directo con: `./fn run comfyui_build_facedetailer_workflow` (imprime el JSON del workflow de ejemplo en modo imagen).
## Cuando usarla
Cuando una imagen generada tiene caras mediocres (ojos borrosos, piel plana,
rasgos deformados) y quieres regenerarlas con detalle sin rehacer toda la imagen.
Es el ADetailer/FaceDetailer "pro" del flujo de retratos. Encadénala tras
`comfyui_build_txt2img_workflow` (pásale el dict) para detail en una sola cola, o
pásale el nombre de una imagen ya en `input/` para mejorar una imagen existente.
Después: `comfyui_submit_workflow``comfyui_wait_result``comfyui_fetch_output_image`.
## Gotchas
- Es API format (nodos numerados / con prefijo `fd_`), NO el formato de la UI.
- Requiere **ComfyUI-Impact-Pack** instalado (provee `FaceDetailer` y
`UltralyticsDetectorProvider`). Si el server responde HTTP 400 "node type not
found: FaceDetailer", el custom node no está cargado: revísalo en el Manager.
- El modelo de detección debe estar en `models/ultralytics/bbox/` (aquí
`face_yolov8m.pt`). El nodo lo referencia con prefijo de subcarpeta
(`bbox/face_yolov8m.pt`); la función normaliza el nombre corto automáticamente.
- **No usa SAM** (segment-anything): `sam_model_opt` es opcional y aquí no hay
modelo SAM instalado (`SAMLoader` reporta lista vacía). FaceDetailer funciona
solo con el detector de bounding box, que basta para caras. Si instalas un SAM
y quieres máscaras más finas, habría que añadir el `SAMLoader` aparte.
- En **modo workflow** (dict) se reutiliza el primer `CheckpointLoaderSimple` y el
primer `VAEDecode` del base. Si el base usa otro loader (p.ej. un flujo SDXL con
loaders distintos), se monta un `CheckpointLoaderSimple` propio con `ckpt_name`
asegúrate de que el checkpoint case con el espacio latente del base.
- El SaveImage del workflow base (si lo tenía) se conserva: el grafo produce tanto
la imagen base como la "detailed" (`fd_save`). Si solo quieres la final, ignora
la otra salida.
- `denoise` alto (>0.6) puede cambiar la identidad de la cara; 0.40.5 conserva
rasgos y añade detalle.
@@ -0,0 +1,230 @@
"""Construye un workflow ComfyUI con FaceDetailer (Impact-Pack) en API format.
FaceDetailer es el nodo estrella de ComfyUI-Impact-Pack para el "pain #1" de los
retratos: detecta las caras de una imagen (con un detector YOLO via
UltralyticsDetectorProvider) y regenera cada una por separado con un sampler de
difusion, recuperando detalle (ojos, piel, dientes) que el primer render pierde.
Esta funcion monta el sub-grafo del detailer y lo conecta a una fuente de imagen,
que puede ser:
- una imagen ya subida al `input/` del servidor (pasa su nombre como str), o
- un workflow base ya construido (pasa el dict, p.ej. el de
`comfyui_build_txt2img_workflow`): el detailer toma la imagen del `VAEDecode`
del workflow y reutiliza su `CheckpointLoaderSimple` (model/clip/vae).
Cadena del sub-grafo (sobre los class_types REALES de Impact-Pack, verificados en
`/object_info`):
UltralyticsDetectorProvider -> BBOX_DETECTOR
CheckpointLoaderSimple (nuevo o reutilizado) -> MODEL, CLIP, VAE
CLIPTextEncode (positive, negative) -> CONDITIONING
FaceDetailer(image, model, clip, vae, positive, negative, bbox_detector, ...) -> IMAGE
SaveImage
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
from __future__ import annotations
def _normalize_bbox_model(name: str) -> str:
"""Normaliza el nombre del modelo de deteccion al formato del nodo.
UltralyticsDetectorProvider expone los modelos con prefijo de subcarpeta
(`bbox/face_yolov8m.pt`, `segm/person_yolov8m-seg.pt`). Acepta tanto el
nombre corto (`face_yolov8m.pt`) como el ya prefijado; si no trae prefijo
`bbox/` ni `segm/`, asume `bbox/` (caras/manos son detectores de bounding box).
"""
if name.startswith(("bbox/", "segm/")):
return name
return f"bbox/{name}"
def _find_first(workflow: dict, class_type: str) -> str | None:
"""Devuelve el node_id del primer nodo con ese class_type, o None."""
for node_id, node in workflow.items():
if isinstance(node, dict) and node.get("class_type") == class_type:
return node_id
return None
def comfyui_build_facedetailer_workflow(
base_workflow_or_image,
ckpt_name: str,
positive: str,
negative: str = "",
*,
bbox_model: str = "face_yolov8m.pt",
denoise: float = 0.5,
steps: int = 20,
cfg: float = 8.0,
seed: int = 0,
guide_size: float = 512.0,
bbox_threshold: float = 0.5,
feather: int = 5,
sampler_name: str = "euler",
scheduler: str = "normal",
filename_prefix: str = "facedetail",
) -> dict:
"""Construye un workflow ComfyUI que aplica FaceDetailer a una imagen.
Args:
base_workflow_or_image: o bien el nombre (str) de una imagen ya presente
en el `input/` del servidor, o bien un workflow base (dict en API
format, p.ej. el de `comfyui_build_txt2img_workflow`). Con str se monta
un `LoadImage` y un `CheckpointLoaderSimple` nuevos; con dict se toma la
imagen del primer `VAEDecode` y se reutiliza su `CheckpointLoaderSimple`.
ckpt_name: checkpoint para el sampler del detailer (y para el loader nuevo
en el modo imagen). Debe existir en el servidor (CheckpointLoaderSimple).
positive: prompt positivo para regenerar las caras (p.ej. "detailed face,
sharp eyes, skin texture"). Se codifica con el CLIP del checkpoint.
negative: prompt negativo. Por defecto "".
bbox_model: modelo de deteccion de Ultralytics. Acepta nombre corto
("face_yolov8m.pt") o prefijado ("bbox/face_yolov8m.pt"). keyword-only.
denoise: fuerza de re-difusion de cada cara (0.5 por defecto; mas alto =
mas cambio, mas riesgo de perder identidad). keyword-only.
steps: pasos de sampling del detailer. keyword-only.
cfg: classifier-free guidance del detailer. keyword-only.
seed: semilla del sampler del detailer. keyword-only.
guide_size: tamano (px) al que se reescala cada cara recortada antes de
re-difundirla (FaceDetailer.guide_size). keyword-only.
bbox_threshold: umbral de confianza del detector de caras (0..1). Mas alto
= menos falsos positivos, riesgo de no detectar caras pequenas.
keyword-only.
feather: pixeles de difuminado del borde de la mascara al recomponer la
cara sobre la imagen. keyword-only.
sampler_name: sampler del detailer (ej. "euler"). keyword-only.
scheduler: scheduler del detailer (ej. "normal"). keyword-only.
filename_prefix: prefijo del PNG final que escribe SaveImage. keyword-only.
Returns:
dict en API format listo para `comfyui_submit_workflow`. En el modo dict
contiene los nodos del workflow base mas los del detailer (con node_ids
prefijados `fd_` para no colisionar); el SaveImage `fd_save` produce la
imagen con las caras regeneradas.
Raises:
ValueError: si se pasa un dict sin `VAEDecode` (no hay fuente de imagen)
o un tipo que no es str ni dict.
"""
bbox_norm = _normalize_bbox_model(bbox_model)
if isinstance(base_workflow_or_image, str):
# Modo imagen: cargar una imagen del input/ y montar un checkpoint nuevo.
base: dict = {}
nodes = {
"fd_load": {
"class_type": "LoadImage",
"inputs": {"image": base_workflow_or_image},
},
"fd_ckpt": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": ckpt_name},
},
}
image_in = ["fd_load", 0]
ckpt_id = "fd_ckpt"
elif isinstance(base_workflow_or_image, dict):
# Modo workflow: tomar la imagen del VAEDecode y reutilizar el checkpoint.
base = dict(base_workflow_or_image)
vae_decode_id = _find_first(base, "VAEDecode")
if vae_decode_id is None:
raise ValueError(
"comfyui_build_facedetailer_workflow: el workflow base no tiene "
"VAEDecode; no hay fuente de imagen para el detailer. Pasa el nombre "
"de una imagen (str) o un workflow que decodifique a imagen."
)
image_in = [vae_decode_id, 0]
ckpt_id = _find_first(base, "CheckpointLoaderSimple")
nodes = {}
if ckpt_id is None:
# El base no usa CheckpointLoaderSimple (p.ej. SDXL con otro loader):
# montamos uno propio para el sampler del detailer.
nodes["fd_ckpt"] = {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": ckpt_name},
}
ckpt_id = "fd_ckpt"
else:
raise ValueError(
"comfyui_build_facedetailer_workflow: base_workflow_or_image debe ser "
f"str (nombre de imagen) o dict (workflow), no {type(base_workflow_or_image).__name__}."
)
model = [ckpt_id, 0]
clip = [ckpt_id, 1]
vae = [ckpt_id, 2]
nodes.update(
{
"fd_pos": {
"class_type": "CLIPTextEncode",
"inputs": {"text": positive, "clip": clip},
},
"fd_neg": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": clip},
},
"fd_det": {
"class_type": "UltralyticsDetectorProvider",
"inputs": {"model_name": bbox_norm},
},
"fd_face": {
"class_type": "FaceDetailer",
"inputs": {
"image": image_in,
"model": model,
"clip": clip,
"vae": vae,
"positive": ["fd_pos", 0],
"negative": ["fd_neg", 0],
"bbox_detector": ["fd_det", 0],
"guide_size": guide_size,
"guide_size_for": True,
"max_size": 1024.0,
"seed": seed,
"steps": steps,
"cfg": cfg,
"sampler_name": sampler_name,
"scheduler": scheduler,
"denoise": denoise,
"feather": feather,
"noise_mask": True,
"force_inpaint": True,
"bbox_threshold": bbox_threshold,
"bbox_dilation": 10,
"bbox_crop_factor": 3.0,
"sam_detection_hint": "center-1",
"sam_dilation": 0,
"sam_threshold": 0.93,
"sam_bbox_expansion": 0,
"sam_mask_hint_threshold": 0.7,
"sam_mask_hint_use_negative": "False",
"drop_size": 10,
"wildcard": "",
"cycle": 1,
},
},
"fd_save": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": filename_prefix, "images": ["fd_face", 0]},
},
}
)
return {**base, **nodes}
if __name__ == "__main__":
import json
# Modo imagen: regenerar caras de una imagen ya en el input/ del servidor.
wf = comfyui_build_facedetailer_workflow(
"portrait_00001_.png",
ckpt_name="dreamshaper_8.safetensors",
positive="detailed face, sharp eyes, skin texture",
negative="blurry, deformed",
seed=42,
)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,104 @@
---
name: comfyui_build_flux_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_flux_workflow(prompt: str, *, unet: str = \"flux1-schnell-fp8-e4m3fn.safetensors\", clip_l: str = \"clip_l.safetensors\", t5xxl: str = \"t5xxl_fp8_e4m3fn_scaled.safetensors\", vae: str = \"ae.safetensors\", width: int = 1024, height: int = 1024, steps: int = 4, guidance: float = 3.5, seed: int = 0, weight_dtype: str = \"fp8_e4m3fn\", sampler_name: str = \"euler\", scheduler: str = \"simple\", filename_prefix: str = \"comfy_flux\") -> dict"
description: "Construye el dict de un workflow ComfyUI txt2img con Flux en API format (nodos numerados con class_type + inputs, conexiones como [node_id, output_index]). A diferencia de SD1.5/SDXL, Flux carga por separado UNETLoader + DualCLIPLoader (clip_l + t5xxl, type flux) + VAELoader; la guia va por FluxGuidance (no por el cfg del KSampler, que se fija a 1.0). Cadena: UNETLoader+DualCLIPLoader+VAELoader -> CLIPTextEncode -> FluxGuidance + EmptySD3LatentImage -> KSampler -> VAEDecode -> SaveImage. Pura, sin red ni I/O. Hermana de comfyui_build_txt2img_workflow."
tags: [comfyui, flux, ml, txt2img, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: prompt
desc: "Prompt positivo: lo que se quiere ver en la imagen."
- name: unet
desc: "Nombre del modelo de difusion en models/diffusion_models/ tal como lo lista comfyui_object_info para UNETLoader (unet_name). Por defecto el Flux schnell fp8. keyword-only."
- name: clip_l
desc: "Nombre del encoder CLIP-L en models/text_encoders/ (clip_name2 del DualCLIPLoader). Por defecto 'clip_l.safetensors'. keyword-only."
- name: t5xxl
desc: "Nombre del encoder T5-XXL en models/text_encoders/ (clip_name1 del DualCLIPLoader). Por defecto 't5xxl_fp8_e4m3fn_scaled.safetensors'. keyword-only."
- name: vae
desc: "Nombre del VAE en models/vae/ (vae_name del VAELoader). Por defecto 'ae.safetensors', el autoencoder de Flux. keyword-only."
- name: width
desc: "Ancho del latente/imagen en px, multiplo de 16 para SD3/Flux. keyword-only."
- name: height
desc: "Alto del latente/imagen en px, multiplo de 16 para SD3/Flux. keyword-only."
- name: steps
desc: "Pasos de sampling del KSampler. Flux schnell rinde con ~4; Flux dev necesita ~20. keyword-only."
- name: guidance
desc: "Valor del nodo FluxGuidance (no es el cfg clasico). Schnell es poco sensible; dev responde a 3.0-4.0. keyword-only."
- name: seed
desc: "Semilla del KSampler. 0 es determinista; cambiar para variar la imagen. keyword-only."
- name: weight_dtype
desc: "dtype de carga del UNET (uno de 'default', 'fp8_e4m3fn', 'fp8_e4m3fn_fast', 'fp8_e5m2'). fp8 reduce VRAM, clave en GPU de 8GB. keyword-only."
- name: sampler_name
desc: "Nombre del sampler (Flux usa 'euler'). keyword-only."
- name: scheduler
desc: "Scheduler del sampler (Flux usa 'simple'). keyword-only."
- name: filename_prefix
desc: "Prefijo del PNG que SaveImage escribe en output/. keyword-only."
output: "dict en API format con node_ids como claves (UNETLoader '10', DualCLIPLoader '11', VAELoader '12', CLIPTextEncode positivo '6', FluxGuidance '13', CLIPTextEncode negativo vacio '7', EmptySD3LatentImage '5', KSampler '3', VAEDecode '8', SaveImage '9'). Listo para comfyui_submit_workflow."
tested: true
tests: ["class_types esperados (9 nodos de Flux)", "loaders separados UNET+DualCLIP(flux)+VAE", "guidance via FluxGuidance y cfg del KSampler fijado a 1.0", "params width/height/steps/seed reflejados", "filename_prefix en SaveImage", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_flux_workflow.py"
file_path: "python/functions/ml/comfyui_build_flux_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_flux_workflow import comfyui_build_flux_workflow
wf = comfyui_build_flux_workflow(
prompt="a red apple on a wooden table, sharp focus, studio lighting",
width=1024,
height=1024,
steps=4, # Flux schnell: ~4 pasos basta
seed=42,
)
# wf["10"]["class_type"] == "UNETLoader" # modelo de difusion suelto
# wf["11"]["inputs"]["type"] == "flux" # DualCLIPLoader en modo flux
# wf["3"]["inputs"]["positive"] == ["13", 0] # KSampler consume FluxGuidance
# wf["3"]["inputs"]["cfg"] == 1.0 # la guia va por FluxGuidance
# wf["9"]["class_type"] == "SaveImage"
```
O lanzable directo con: `./fn run comfyui_build_flux_workflow` (imprime el JSON del workflow de ejemplo).
## Cuando usarla
Cuando vayas a generar txt2img con un modelo Flux (schnell o dev) y necesites el
dict del workflow para `comfyui_submit_workflow`. Usala en lugar de
`comfyui_build_txt2img_workflow` siempre que el modelo NO sea un checkpoint
todo-en-uno SD1.5/SDXL sino Flux con UNET + text encoders + VAE por separado.
Flux schnell es ideal en GPU de poca VRAM (8GB) por el fp8 y los ~4 pasos.
## Gotchas
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI (graph con
links). No se puede pegar en la UI tal cual; es el formato que acepta POST
/prompt.
- Flux NO usa el cfg del KSampler para guiar: este builder lo fija a 1.0 y la
guia va por el nodo FluxGuidance. Subir el cfg del KSampler con Flux degrada o
rompe la imagen.
- El negativo es un CLIPTextEncode vacio cableado al KSampler (igual que el
template oficial de Flux). Flux schnell es destilado y practicamente ignora el
negativo; no esperes que un prompt negativo tenga el efecto de SD1.5/SDXL.
- `unet`, `clip_l`, `t5xxl` y `vae` deben existir en los directorios respectivos
visibles para el servidor (models/diffusion_models/, models/text_encoders/,
models/vae/). Si no, ComfyUI rechaza el workflow con HTTP 400 al enviarlo (no
aqui — esta funcion es pura y no valida contra el servidor). Valida antes con
`comfyui_validate_workflow`.
- `width`/`height` deben ser multiplos de 16 para EmptySD3LatentImage (Flux), no
de 8 como en SD1.5/SDXL.
- `weight_dtype` debe ser uno de los que admite UNETLoader ('default',
'fp8_e4m3fn', 'fp8_e4m3fn_fast', 'fp8_e5m2'). En 8GB usa fp8 o el modelo no
cabe en VRAM.
@@ -0,0 +1,136 @@
"""Construye un workflow ComfyUI txt2img con Flux en "API format" (dict de nodos numerados).
API format: cada clave es un node_id (string); cada nodo tiene class_type +
inputs. Las conexiones entre nodos son listas [node_id, output_index]. Este es
el formato que acepta POST /prompt, distinto del formato de la UI (graph con
links explicitos).
A diferencia del builder SD1.5/SDXL (comfyui_build_txt2img_workflow), Flux NO usa
un checkpoint todo-en-uno: carga por separado el modelo de difusion (UNETLoader),
los dos text encoders (DualCLIPLoader con clip_l + t5xxl, type="flux") y el VAE
(VAELoader). La guia no va por el cfg del KSampler (que se fija a 1.0) sino por el
nodo FluxGuidance aplicado al condicionamiento positivo. El negativo se deja como
un CLIPTextEncode vacio, igual que el template oficial de Flux en ComfyUI.
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
def comfyui_build_flux_workflow(
prompt: str,
*,
unet: str = "flux1-schnell-fp8-e4m3fn.safetensors",
clip_l: str = "clip_l.safetensors",
t5xxl: str = "t5xxl_fp8_e4m3fn_scaled.safetensors",
vae: str = "ae.safetensors",
width: int = 1024,
height: int = 1024,
steps: int = 4,
guidance: float = 3.5,
seed: int = 0,
weight_dtype: str = "fp8_e4m3fn",
sampler_name: str = "euler",
scheduler: str = "simple",
filename_prefix: str = "comfy_flux",
) -> dict:
"""Construye el dict del workflow txt2img de Flux (schnell/dev).
Cadena de nodos: UNETLoader + DualCLIPLoader + VAELoader -> CLIPTextEncode
(positivo) -> FluxGuidance, mas un CLIPTextEncode vacio para el negativo y
EmptySD3LatentImage -> KSampler -> VAEDecode -> SaveImage.
Args:
prompt: prompt positivo (lo que se quiere ver en la imagen).
unet: nombre del modelo de difusion en models/diffusion_models/ tal como
lo lista comfyui_object_info para UNETLoader (unet_name). Por defecto
el Flux schnell fp8 ("flux1-schnell-fp8-e4m3fn.safetensors").
clip_l: nombre del encoder CLIP-L en models/text_encoders/ (clip_name2 del
DualCLIPLoader). Por defecto "clip_l.safetensors".
t5xxl: nombre del encoder T5-XXL en models/text_encoders/ (clip_name1 del
DualCLIPLoader). Por defecto "t5xxl_fp8_e4m3fn_scaled.safetensors".
vae: nombre del VAE en models/vae/ (vae_name del VAELoader). Por defecto
"ae.safetensors" (el autoencoder de Flux).
width: ancho del latente/imagen en px (multiplo de 16 para SD3/Flux). keyword-only.
height: alto del latente/imagen en px (multiplo de 16 para SD3/Flux). keyword-only.
steps: pasos de sampling del KSampler. Flux schnell rinde bien con ~4;
Flux dev necesita ~20. keyword-only.
guidance: valor del nodo FluxGuidance (no es el cfg clasico). Schnell es
poco sensible a este valor; dev responde a 3.0-4.0. keyword-only.
seed: semilla del KSampler (0 = determinista; cambia para variar). keyword-only.
weight_dtype: dtype de carga del UNET (uno de "default", "fp8_e4m3fn",
"fp8_e4m3fn_fast", "fp8_e5m2"). fp8 reduce VRAM (clave en 8GB). keyword-only.
sampler_name: nombre del sampler (Flux usa "euler"). keyword-only.
scheduler: scheduler del sampler (Flux usa "simple"). keyword-only.
filename_prefix: prefijo del PNG que SaveImage escribe en output/. keyword-only.
Returns:
dict en API format listo para comfyui_submit_workflow. Las claves son
node_ids (string) y cada valor tiene class_type + inputs.
"""
return {
"10": {
"class_type": "UNETLoader",
"inputs": {"unet_name": unet, "weight_dtype": weight_dtype},
},
"11": {
"class_type": "DualCLIPLoader",
"inputs": {
"clip_name1": t5xxl,
"clip_name2": clip_l,
"type": "flux",
},
},
"12": {
"class_type": "VAELoader",
"inputs": {"vae_name": vae},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": prompt, "clip": ["11", 0]},
},
"13": {
"class_type": "FluxGuidance",
"inputs": {"conditioning": ["6", 0], "guidance": guidance},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "", "clip": ["11", 0]},
},
"5": {
"class_type": "EmptySD3LatentImage",
"inputs": {"width": width, "height": height, "batch_size": 1},
},
"3": {
"class_type": "KSampler",
"inputs": {
"seed": seed,
"steps": steps,
"cfg": 1.0,
"sampler_name": sampler_name,
"scheduler": scheduler,
"denoise": 1.0,
"model": ["10", 0],
"positive": ["13", 0],
"negative": ["7", 0],
"latent_image": ["5", 0],
},
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["3", 0], "vae": ["12", 0]},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": filename_prefix, "images": ["8", 0]},
},
}
if __name__ == "__main__":
import json
wf = comfyui_build_flux_workflow(
prompt="a red apple on a wooden table, sharp focus, studio lighting",
seed=42,
)
print(json.dumps(wf, indent=2))
+73
View File
@@ -0,0 +1,73 @@
---
name: comfyui_build_grid
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_build_grid(image_paths: list, *, cols: int | None = None, cell: int = 512, out_path: str | None = None, labels: list | None = None) -> dict"
description: "Monta un grid / contact-sheet PIL de N imagenes para comparacion visual (p.ej. el output de comfyui_batch_generate con varios seeds). Cada celda conserva el aspect ratio (thumbnail centrado sobre fondo oscuro); rejilla casi cuadrada por defecto (cols=ceil(sqrt(N))). Rotulos opcionales por celda. Usa PIL (Pillow) del venv del registry. Devuelve {ok, out_path, rows, cols, error}. Impura: lee N imagenes y escribe un PNG."
tags: [comfyui, ml, grid, montage, pil, image]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: image_paths
desc: "lista de rutas a las imagenes a montar, en orden de lectura (izq->der, arriba->abajo)."
- name: cols
desc: "numero de columnas; si None usa ceil(sqrt(N)) para una rejilla casi cuadrada."
- name: cell
desc: "lado en pixeles de cada celda cuadrada; la imagen se reduce para caber conservando proporcion (default 512)."
- name: out_path
desc: "ruta del PNG de salida; si None escribe 'comfy_grid.png' en el dir de la primera imagen."
- name: labels
desc: "rotulos opcionales, uno por imagen (mismo orden); reservan una franja bajo cada celda."
output: "dict con ok (bool), out_path (str, ruta del PNG generado), rows (int, filas), cols (int, columnas), error (str, vacio si OK)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_build_grid.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_grid import comfyui_build_grid
imgs = [
os.path.expanduser("~/ComfyUI/output/comfy_00001_.png"),
os.path.expanduser("~/ComfyUI/output/comfy_00002_.png"),
os.path.expanduser("~/ComfyUI/output/comfy_00003_.png"),
os.path.expanduser("~/ComfyUI/output/comfy_00004_.png"),
]
res = comfyui_build_grid(imgs, cols=2, cell=512, out_path="/tmp/seeds_grid.png",
labels=["seed 1", "seed 2", "seed 3", "seed 4"])
# {'ok': True, 'out_path': '/tmp/seeds_grid.png', 'rows': 2, 'cols': 2, 'error': ''}
```
## Cuando usarla
Tras un barrido de seeds con `comfyui_batch_generate` + `comfyui_fetch_output_image`:
en vez de abrir N PNGs uno a uno, montas un unico contact-sheet para elegir de un
vistazo la mejor variante (o comparar steps/cfg/sampler distintos). Tambien sirve
para documentar un report con una rejilla de resultados. Es post-proceso local
puro de imagen: no toca el servidor ComfyUI.
## Gotchas
- Si alguna ruta de `image_paths` no existe, devuelve `ok=False` con la lista de
faltantes (estricto): no monta una rejilla parcial silenciosamente. Filtra las
rutas validas antes si quieres tolerar ausencias.
- Cada imagen se reduce a `cell` px conservando proporcion (thumbnail); imagenes de
distinto tamano quedan centradas en su celda con relleno, no estiradas.
- `labels` se dibuja con la fuente por defecto de PIL (pequeña, sin TTF externo);
para rotulos grandes habria que pasar una fuente — no soportado hoy (KISS).
- Escribe el PNG en disco: si `out_path` apunta a un directorio inexistente lo crea;
si no tiene permiso devuelve `ok=False` con el error.
- N grande con `cell` alto produce un canvas enorme (rows*cols*cell^2 px): para
decenas de imagenes baja `cell` (p.ej. 256) para no agotar memoria.
+114
View File
@@ -0,0 +1,114 @@
"""Monta un grid / contact-sheet PIL de N imagenes para comparacion visual.
Funcion impura: lee N imagenes de disco y escribe un PNG de salida. Usa PIL
(Pillow), presente en el venv del registry.
El compañero natural de comfyui_batch_generate: ese encola N variantes de un
workflow (una por seed) pero no junta los resultados. Esta funcion toma las N
imagenes ya descargadas (p.ej. con comfyui_fetch_output_image) y las dispone en
una rejilla regular para compararlas de un vistazo. Cada celda conserva el aspect
ratio (thumbnail centrado sobre fondo oscuro). Opcionalmente rotula cada celda.
"""
import math
import os
def comfyui_build_grid(
image_paths: list,
*,
cols: int | None = None,
cell: int = 512,
out_path: str | None = None,
labels: list | None = None,
) -> dict:
"""Compone una rejilla de imagenes y la guarda como PNG.
Args:
image_paths: lista de rutas a las imagenes (PNG/JPG/...) a montar, en
orden de lectura (izquierda->derecha, arriba->abajo).
cols: numero de columnas; si None se usa ceil(sqrt(N)) para una rejilla
casi cuadrada. keyword-only.
cell: lado en pixeles de cada celda cuadrada; cada imagen se reduce para
caber dentro conservando su proporcion. keyword-only.
out_path: ruta del PNG de salida; si None se escribe "comfy_grid.png" en
el directorio de la primera imagen. keyword-only.
labels: rotulos opcionales, uno por imagen (mismo orden); si se pasan, se
reserva una franja bajo cada celda y se dibuja el texto. keyword-only.
Returns:
dict con:
- ok (bool): True si el grid se monto y guardo.
- out_path (str): ruta del PNG generado.
- rows (int): filas de la rejilla.
- cols (int): columnas de la rejilla.
- error (str): mensaje de error; cadena vacia si todo OK.
"""
out = {"ok": False, "out_path": "", "rows": 0, "cols": 0, "error": ""}
if not image_paths:
out["error"] = "image_paths vacio: nada que montar"
return out
try:
from PIL import Image, ImageDraw
except ImportError:
out["error"] = "PIL (Pillow) no esta instalado en este interprete"
return out
missing = [p for p in image_paths if not os.path.isfile(p)]
if missing:
out["error"] = f"no existen {len(missing)} rutas: {missing[:5]}"
return out
n = len(image_paths)
cols = int(cols) if cols and cols > 0 else max(1, math.ceil(math.sqrt(n)))
rows = math.ceil(n / cols)
cell = max(16, int(cell))
label_h = 22 if labels else 0
bg = (24, 24, 28)
fg = (232, 232, 236)
canvas = Image.new("RGB", (cols * cell, rows * (cell + label_h)), bg)
draw = ImageDraw.Draw(canvas) if labels else None
try:
for i, path in enumerate(image_paths):
with Image.open(path) as src:
im = src.convert("RGB")
im.thumbnail((cell, cell))
r, c = divmod(i, cols)
x = c * cell + (cell - im.width) // 2
y = r * (cell + label_h) + (cell - im.height) // 2
canvas.paste(im, (x, y))
if draw is not None and i < len(labels):
tx = c * cell + 4
ty = r * (cell + label_h) + cell + 3
draw.text((tx, ty), str(labels[i]), fill=fg)
except OSError as exc:
out["error"] = f"no se pudo leer/decodificar una imagen: {exc}"
return out
if out_path is None:
out_path = os.path.join(os.path.dirname(os.path.abspath(image_paths[0])),
"comfy_grid.png")
try:
os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
canvas.save(out_path)
except OSError as exc:
out["error"] = f"no se pudo escribir {out_path!r}: {exc}"
return out
out.update(ok=True, out_path=out_path, rows=rows, cols=cols)
return out
if __name__ == "__main__":
import json
import sys
paths = sys.argv[1:]
if not paths:
print("uso: comfyui_build_grid.py <img1> <img2> ...", file=sys.stderr)
sys.exit(2)
res = comfyui_build_grid(paths, out_path="/tmp/comfy_grid.png")
print(json.dumps(res, indent=2))
@@ -0,0 +1,104 @@
---
name: comfyui_build_hires_fix_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_hires_fix_workflow(ckpt_name: str, positive: str, negative: str = \"\", *, first_pass: tuple[int, int] = (768, 768), upscale_by: float = 1.5, denoise: float = 0.4, steps: int = 20, cfg: float = 7.0, seed: int = 0, upscale_model: str = \"4x_foolhardy_Remacri.pth\", sampler_name: str = \"euler\", scheduler: str = \"normal\", tile_width: int = 512, tile_height: int = 512, filename_prefix: str = \"hires\") -> dict"
description: "Construye un workflow ComfyUI de hires-fix de 2 pasadas en API format: genera una imagen base pequena (KSampler) y la amplia re-difundiendola por tiles con UltimateSDUpscale + un modelo de upscale (Remacri), anadiendo detalle real a alta resolucion. UltimateSDUpscale es la segunda pasada de muestreo (recibe model/positive/negative/vae). Distinto de comfyui_build_upscale_workflow, que es ESRGAN puro sin re-difusion. Class_types verificados en /object_info. Pura, sin red ni I/O."
tags: [comfyui, ml, hires-fix, ultimatesdupscale, upscale, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: ckpt_name
desc: "Checkpoint tal como lo ve el servidor (CheckpointLoaderSimple)."
- name: positive
desc: "Prompt positivo (se usa en la base y en la re-difusion tiled)."
- name: negative
desc: "Prompt negativo. Por defecto ''."
- name: first_pass
desc: "(ancho, alto) en px de la pasada base (latente pequeno y rapido). Por defecto (768, 768). keyword-only."
- name: upscale_by
desc: "Factor de ampliacion de UltimateSDUpscale sobre la imagen base (1.5 -> 768 pasa a 1152). keyword-only."
- name: denoise
desc: "Fuerza de re-difusion de la segunda pasada (0.4 por defecto). <1 conserva la composicion base y solo anade detalle; 1.0 la re-generaria entera. keyword-only."
- name: steps
desc: "Pasos de sampling (ambas pasadas). keyword-only."
- name: cfg
desc: "Classifier-free guidance (ambas pasadas). keyword-only."
- name: seed
desc: "Semilla de la pasada base (UltimateSDUpscale usa la misma). keyword-only."
- name: upscale_model
desc: "Modelo de upscale en models/upscale_models/ que usa UltimateSDUpscale para escalar antes de re-difundir (ej. '4x_foolhardy_Remacri.pth'). keyword-only."
- name: sampler_name
desc: "Sampler (ambas pasadas). keyword-only."
- name: scheduler
desc: "Scheduler (ambas pasadas). keyword-only."
- name: tile_width
desc: "Ancho de tile de UltimateSDUpscale (px). Tiles mas pequenos = menos VRAM, mas costuras. keyword-only."
- name: tile_height
desc: "Alto de tile de UltimateSDUpscale (px). keyword-only."
- name: filename_prefix
desc: "Prefijo del PNG final que escribe SaveImage. keyword-only."
output: "dict en API format listo para comfyui_submit_workflow. node_ids: '4' CheckpointLoaderSimple, '5' EmptyLatentImage, '6'/'7' CLIPTextEncode, '3' KSampler (base), '8' VAEDecode, '11' UpscaleModelLoader, '12' UltimateSDUpscale, '9' SaveImage."
tested: true
tests: ["cadena base (KSampler) + UltimateSDUpscale + SaveImage", "denoise de la 2a pasada <1 (re-difusion parcial)", "first_pass refleja width/height en EmptyLatentImage", "upscale_model llega a UpscaleModelLoader", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_hires_fix_workflow.py"
file_path: "python/functions/ml/comfyui_build_hires_fix_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_hires_fix_workflow import comfyui_build_hires_fix_workflow
wf = comfyui_build_hires_fix_workflow(
ckpt_name="dreamshaper_8.safetensors",
positive="a fox in a forest, intricate detail, sharp focus",
negative="blurry, low quality",
first_pass=(768, 768),
upscale_by=1.5,
denoise=0.4,
seed=42,
)
# wf["3"]["class_type"] == "KSampler" # pasada base
# wf["12"]["class_type"] == "UltimateSDUpscale" # pasada de detalle (re-difusion)
# wf["12"]["inputs"]["denoise"] == 0.4 # <1 = solo anade detalle
# wf["11"]["inputs"]["model_name"] == "4x_foolhardy_Remacri.pth"
```
O lanzable directo con: `./fn run comfyui_build_hires_fix_workflow` (imprime el JSON del workflow de ejemplo).
## Cuando usarla
Cuando una imagen a baja resolución se ve plana o sin detalle y quieres una
versión grande y nítida que el modelo "redibuja" en alta (no un simple escalado).
Es el "hires fix" idiomático: genera la base pequeña y rápida, luego añade detalle
real al ampliar. Úsala cuando `comfyui_build_upscale_workflow` (ESRGAN puro) se
queda corto porque no inventa detalle nuevo. Después: `comfyui_submit_workflow`
`comfyui_wait_result``comfyui_fetch_output_image`.
## Gotchas
- Es API format (nodos numerados), NO el formato de la UI.
- Requiere el custom node **UltimateSDUpscale** (`comfyui_ultimatesdupscale`). Si
el server responde HTTP 400 "node type not found: UltimateSDUpscale", el custom
node no está cargado.
- El `upscale_model` debe existir en `models/upscale_models/` (aquí
`4x_foolhardy_Remacri.pth`). Sin él, el server rechaza el workflow al encolar.
- **2 etapas de muestreo, 1 KSampler explícito**: UltimateSDUpscale re-samplea
cada tile internamente (por eso recibe `model`/`positive`/`negative`/`vae`), así
que el grafo tiene el KSampler base + el UltimateSDUpscale, no dos KSampler.
- `denoise` de la 2ª pasada controla cuánto cambia: 0.30.45 añade detalle sin
alterar la composición; >0.6 puede deformar caras o introducir artefactos.
- `upscale_by` alto + `tile_width/height` grandes = más VRAM. En 8 GB conviene
tiles de 512 y `upscale_by` 1.52.0.
- Coste real: la 2ª pasada re-difunde N tiles, es bastante más lenta que un upscale
ESRGAN puro. Para solo agrandar sin re-difusión usa `comfyui_build_upscale_workflow`.
@@ -0,0 +1,167 @@
"""Construye un workflow ComfyUI de "hires fix" de 2 pasadas en API format.
El hires fix clasico genera una imagen pequena nitida y luego la amplia
*re-difundiendola* (no solo escalando pixeles), de modo que el modelo anade
detalle coherente a la resolucion alta. Este builder lo implementa con
UltimateSDUpscale (custom node), que hace la segunda pasada por TILES con un
modelo de upscale (ESRGAN/Remacri) + un sampler con `denoise` parcial:
Pasada 1 (base): CheckpointLoaderSimple -> CLIPTextEncode(+/-) +
EmptyLatentImage(first_pass) -> KSampler -> VAEDecode
Pasada 2 (detalle): UpscaleModelLoader(Remacri) +
UltimateSDUpscale(image, model, +/-, vae, upscale_model,
upscale_by, denoise<1, tiled) -> SaveImage
UltimateSDUpscale ES la segunda pasada de muestreo: re-samplea cada tile con el
checkpoint (de ahi que reciba `model`, `positive`, `negative`, `vae`), por eso el
grafo tiene UN KSampler explicito (la base) + el UltimateSDUpscale (el detalle).
Distinto de `comfyui_build_upscale_workflow`, que es ESRGAN puro SIN re-difusion.
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
from __future__ import annotations
def comfyui_build_hires_fix_workflow(
ckpt_name: str,
positive: str,
negative: str = "",
*,
first_pass: tuple[int, int] = (768, 768),
upscale_by: float = 1.5,
denoise: float = 0.4,
steps: int = 20,
cfg: float = 7.0,
seed: int = 0,
upscale_model: str = "4x_foolhardy_Remacri.pth",
sampler_name: str = "euler",
scheduler: str = "normal",
tile_width: int = 512,
tile_height: int = 512,
filename_prefix: str = "hires",
) -> dict:
"""Construye el dict de un workflow hires-fix (base + UltimateSDUpscale).
Args:
ckpt_name: checkpoint tal como lo ve el servidor (CheckpointLoaderSimple).
positive: prompt positivo (se usa en la base y en la re-difusion tiled).
negative: prompt negativo. Por defecto "".
first_pass: (ancho, alto) en px de la pasada base (latente pequeno y
rapido). Por defecto (768, 768). keyword-only.
upscale_by: factor de ampliacion de UltimateSDUpscale sobre la imagen base
(1.5 -> 768 pasa a 1152). keyword-only.
denoise: fuerza de re-difusion de la segunda pasada (0.4 por defecto).
<1 para conservar la composicion base y solo anadir detalle; 1.0 la
re-generaria entera. keyword-only.
steps: pasos de sampling (ambas pasadas). keyword-only.
cfg: classifier-free guidance (ambas pasadas). keyword-only.
seed: semilla de la pasada base (UltimateSDUpscale usa la misma).
keyword-only.
upscale_model: modelo de upscale en models/upscale_models/ que usa
UltimateSDUpscale para escalar antes de re-difundir (ej.
"4x_foolhardy_Remacri.pth"). keyword-only.
sampler_name: sampler (ambas pasadas). keyword-only.
scheduler: scheduler (ambas pasadas). keyword-only.
tile_width: ancho de tile de UltimateSDUpscale (px). Tiles mas pequenos =
menos VRAM, mas costuras. keyword-only.
tile_height: alto de tile de UltimateSDUpscale (px). keyword-only.
filename_prefix: prefijo del PNG final que escribe SaveImage. keyword-only.
Returns:
dict en API format listo para `comfyui_submit_workflow`. node_ids:
"4" CheckpointLoaderSimple, "5" EmptyLatentImage, "6"/"7" CLIPTextEncode,
"3" KSampler (base), "8" VAEDecode, "11" UpscaleModelLoader,
"12" UltimateSDUpscale, "9" SaveImage.
"""
w, h = first_pass
return {
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": ckpt_name},
},
"5": {
"class_type": "EmptyLatentImage",
"inputs": {"width": w, "height": h, "batch_size": 1},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": positive, "clip": ["4", 1]},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["4", 1]},
},
"3": {
"class_type": "KSampler",
"inputs": {
"seed": seed,
"steps": steps,
"cfg": cfg,
"sampler_name": sampler_name,
"scheduler": scheduler,
"denoise": 1.0,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["5", 0],
},
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
},
"11": {
"class_type": "UpscaleModelLoader",
"inputs": {"model_name": upscale_model},
},
"12": {
"class_type": "UltimateSDUpscale",
"inputs": {
"image": ["8", 0],
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"vae": ["4", 2],
"upscale_model": ["11", 0],
"upscale_by": upscale_by,
"seed": seed,
"steps": steps,
"cfg": cfg,
"sampler_name": sampler_name,
"scheduler": scheduler,
"denoise": denoise,
"mode_type": "Linear",
"tile_width": tile_width,
"tile_height": tile_height,
"mask_blur": 8,
"tile_padding": 32,
"seam_fix_mode": "None",
"seam_fix_denoise": 1.0,
"seam_fix_width": 64,
"seam_fix_mask_blur": 8,
"seam_fix_padding": 16,
"force_uniform_tiles": True,
"tiled_decode": False,
},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": filename_prefix, "images": ["12", 0]},
},
}
if __name__ == "__main__":
import json
wf = comfyui_build_hires_fix_workflow(
ckpt_name="dreamshaper_8.safetensors",
positive="a fox in a forest, intricate detail, sharp focus",
negative="blurry, low quality",
first_pass=(768, 768),
upscale_by=1.5,
denoise=0.4,
seed=42,
)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,112 @@
---
name: comfyui_build_image_to_3d_workflow
kind: function
lang: py
domain: ml
version: "1.1.0"
purity: pure
signature: "def comfyui_build_image_to_3d_workflow(image_name: str, ckpt_name: str = \"hunyuan3d-dit-v2-mini.safetensors\", *, resolution: int = 3072, steps: int = 30, cfg: float = 5.5, seed: int = 0, octree_resolution: int = 256, num_chunks: int = 8000, threshold: float = 0.6, sampler_name: str = \"euler\", scheduler: str = \"normal\", filename_prefix: str = \"3d_mesh\", watertight: bool = False) -> dict"
description: "Construye el dict de un workflow ComfyUI imagen->malla 3D en API format usando los nodos NATIVOS de Hunyuan3D-2 de ComfyUI 0.26.0 (sin custom node). Cadena de 9 nodos: LoadImage -> ImageOnlyCheckpointLoader -> CLIPVisionEncode -> Hunyuan3Dv2Conditioning -> EmptyLatentHunyuan3Dv2 -> KSampler -> VAEDecodeHunyuan3D -> (VoxelToMeshBasic | VoxelToMesh surface-net si watertight=True) -> SaveGLB. El SaveGLB produce un .glb. Pura, sin red ni I/O."
tags: [comfyui, ml, img-to-3d, hunyuan3d, mesh, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: image_name
desc: "Nombre del archivo de imagen en el input/ del servidor ComfyUI (ej. '3d_src_robot_00001_.png'). Lo carga el nodo LoadImage; debe existir ya en input/ (subelo antes o usa el pipeline oneshot)."
- name: ckpt_name
desc: "Nombre del checkpoint Hunyuan3D-2 tal como lo ve el servidor (ej. 'hunyuan3d-dit-v2-mini.safetensors'). Debe estar en la lista de comfyui_object_info para ImageOnlyCheckpointLoader."
- name: resolution
desc: "Resolucion del latente 3D (EmptyLatentHunyuan3Dv2). Mayor = mas detalle de forma y mas VRAM. keyword-only."
- name: steps
desc: "Pasos de sampling del KSampler de difusion 3D. keyword-only."
- name: cfg
desc: "Classifier-free guidance scale del KSampler. keyword-only."
- name: seed
desc: "Semilla del KSampler. 0 es determinista; cambiar para variar la malla. keyword-only."
- name: octree_resolution
desc: "Resolucion del grid de voxels en VAEDecodeHunyuan3D. Mayor = malla mas densa (mas caras) y mas memoria. keyword-only."
- name: num_chunks
desc: "Numero de chunks de decode del VAE 3D; controla el troceado del grid para caber en memoria. keyword-only."
- name: threshold
desc: "Umbral de iso-superficie del nodo voxel->malla (marching cubes / surface net sobre el grid de voxels). keyword-only."
- name: sampler_name
desc: "Nombre del sampler del KSampler (ej. 'euler'). keyword-only."
- name: scheduler
desc: "Scheduler del sampler (ej. 'normal'). keyword-only."
- name: filename_prefix
desc: "Prefijo del archivo de malla que SaveGLB escribe en output/ (ej. '3d_mesh' -> '3d_mesh_00001_.glb'). keyword-only."
- name: watertight
desc: "Si False (default, retro-compatible) el nodo '8' es VoxelToMeshBasic (malla NO estanca). Si True usa VoxelToMesh con algorithm='surface net', que produce una malla estanca/manifold de raiz sin post-proceso. keyword-only."
output: "dict en API format con node_ids '1'..'9' como claves; cada valor tiene class_type + inputs. Listo para comfyui_submit_workflow. El nodo '9' (SaveGLB) produce el archivo .glb en el output del servidor. El nodo '8' es VoxelToMeshBasic (watertight=False) o VoxelToMesh surface-net (watertight=True)."
tested: true
tests: ["cadena de 9 nodos Hunyuan3D-2 nativos", "imagen, checkpoint, seed reflejados y SaveGLB presente", "watertight=True usa VoxelToMesh surface-net; default conserva VoxelToMeshBasic", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_image_to_3d_workflow.py"
file_path: "python/functions/ml/comfyui_build_image_to_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_image_to_3d_workflow import comfyui_build_image_to_3d_workflow
wf = comfyui_build_image_to_3d_workflow(
image_name="3d_src_robot_00001_.png",
ckpt_name="hunyuan3d-dit-v2-mini.safetensors",
seed=42,
)
# wf["2"]["class_type"] == "ImageOnlyCheckpointLoader"
# wf["3"]["inputs"]["clip_vision"] == ["2", 1] # CLIP_VISION del loader
# wf["7"]["class_type"] == "VAEDecodeHunyuan3D"
# wf["9"]["class_type"] == "SaveGLB"
```
O lanzable directo con: `./fn run comfyui_build_image_to_3d_workflow` (imprime el JSON del workflow de ejemplo).
## Cuando usarla
Antes de enviar una reconstruccion imagen->3D a ComfyUI: construye aqui el dict
del workflow y pasalo a `comfyui_submit_workflow`. Usala siempre que tengas una
imagen ya en el `input/` del servidor y quieras una malla GLB sin escribir el
grafo de 9 nodos a mano. Para hacerlo end-to-end desde una imagen en disco (subir
+ build + submit + wait + fetch en una llamada), usa el pipeline
`comfyui_image_to_3d_oneshot`.
## Gotchas
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI (graph con
links). No se pega en la UI tal cual; es el formato que acepta POST /prompt.
- Usa nodos NATIVOS de Hunyuan3D-2 de ComfyUI >= 0.26.0. En versiones anteriores
(sin `ImageOnlyCheckpointLoader`/`VAEDecodeHunyuan3D`/`SaveGLB`) el server
rechaza el workflow al enviarlo. Esta funcion es pura y no valida contra el
server: valida con `comfyui_validate_workflow` antes de encolar si dudas.
- `image_name` debe existir en el `input/` del servidor ANTES de enviar. Esta
funcion solo referencia el nombre; no sube nada (es pura). El pipeline oneshot
hace el upload.
- `ckpt_name` debe coincidir EXACTAMENTE con un checkpoint visible para el
servidor (instalalo con `comfyui_install_3d_model`).
- El camino nativo es **shape-only**: la malla sale SIN color/textura. Para color
por vertice o textura horneada haria falta el wrapper de kijai (compila
custom_rasterizer) — fuera de alcance.
- Con el default (`watertight=False`) el nodo `VoxelToMeshBasic` produce malla NO
estanca ("cube-soup"), lo esperable; se arregla a posteriori con
`comfyui_make_watertight` o el pipeline `comfyui_mesh_cleanup_oneshot`. Para
malla estanca DE RAÍZ pasa `watertight=True`: usa `VoxelToMesh` con
`algorithm="surface net"` (manifold cerrado sin reparar, ver report 0088). El
nodo `VoxelToMesh` es nativo de ComfyUI >= 0.26.0 (`nodes_hunyuan3d.py`); en
versiones sin él, usar el default + post-proceso.
- `octree_resolution` alto (256) produce mallas muy densas (decenas de MB de GLB,
>1M caras) sin decimacion. Para web conviene un paso de simplificacion posterior
(`comfyui_simplify_mesh` / `comfyui_mesh_cleanup_oneshot`).
## Capability growth log
- v1.1.0 (2026-06-24) — añade `watertight=False` (keyword-only, retro-compatible):
con `True` el nodo voxel→malla usa `VoxelToMesh` (`algorithm="surface net"`) en
vez de `VoxelToMeshBasic`, para mallas estancas de raíz sin post-proceso. El
default conserva el comportamiento histórico exacto.
@@ -0,0 +1,163 @@
"""Construye un workflow ComfyUI imagen -> malla 3D en "API format" (Hunyuan3D-2 nativo).
API format: cada clave es un node_id (string); cada nodo tiene class_type +
inputs. Las conexiones entre nodos son listas [node_id, output_index]. Es el
formato que acepta POST /prompt, distinto del formato de la UI (graph con links).
El workflow usa los nodos NATIVOS de Hunyuan3D-2 que trae ComfyUI 0.26.0 (sin
custom node de terceros): una imagen de entrada se reconstruye en una malla 3D
GLB. Cadena de 9 nodos:
LoadImage -> ImageOnlyCheckpointLoader -> CLIPVisionEncode ->
Hunyuan3Dv2Conditioning -> EmptyLatentHunyuan3Dv2 -> KSampler ->
VAEDecodeHunyuan3D -> (VoxelToMeshBasic | VoxelToMesh) -> SaveGLB
El paso voxel->malla depende del parametro `watertight`:
- watertight=False (default): VoxelToMeshBasic, el comportamiento historico
(marching cubes simple; malla NO estanca, "cube-soup", que luego se arregla con
comfyui_make_watertight).
- watertight=True: VoxelToMesh con algorithm="surface net" (verificado en
/object_info, nodes_hunyuan3d.py), que produce una malla manifold/estanca de
raiz, sin post-proceso (ver report 0088).
El checkpoint Hunyuan3D-2 (mini/standard) es self-contained: ImageOnlyCheckpointLoader
devuelve MODEL, CLIP_VISION y VAE de un solo .safetensors.
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
def comfyui_build_image_to_3d_workflow(
image_name: str,
ckpt_name: str = "hunyuan3d-dit-v2-mini.safetensors",
*,
resolution: int = 3072,
steps: int = 30,
cfg: float = 5.5,
seed: int = 0,
octree_resolution: int = 256,
num_chunks: int = 8000,
threshold: float = 0.6,
sampler_name: str = "euler",
scheduler: str = "normal",
filename_prefix: str = "3d_mesh",
watertight: bool = False,
) -> dict:
"""Construye el dict del workflow imagen->3D nativo (Hunyuan3D-2).
Args:
image_name: nombre del archivo de imagen en el `input/` del servidor
ComfyUI (ej. "3d_src_robot_00001_.png"). Lo carga el nodo LoadImage;
debe existir ya en input/ (subelo antes, o usa el pipeline oneshot).
ckpt_name: nombre del checkpoint Hunyuan3D-2 tal como lo ve el servidor
(ej. "hunyuan3d-dit-v2-mini.safetensors"). Debe estar entre los que
devuelve comfyui_object_info para ImageOnlyCheckpointLoader.
resolution: resolucion del latente 3D (EmptyLatentHunyuan3Dv2). Mayor =
mas detalle de forma y mas VRAM. keyword-only.
steps: pasos de sampling del KSampler de difusion 3D. keyword-only.
cfg: classifier-free guidance scale del KSampler. keyword-only.
seed: semilla del KSampler (0 = determinista; cambia para variar la
malla). keyword-only.
octree_resolution: resolucion del grid de voxels en VAEDecodeHunyuan3D.
Mayor = malla mas densa (mas caras) y mas memoria. keyword-only.
num_chunks: numero de chunks de decode del VAE 3D; controla el troceado
del grid para caber en memoria. keyword-only.
threshold: umbral de iso-superficie del nodo voxel->malla (marching cubes
/ surface net sobre el grid de voxels). keyword-only.
sampler_name: nombre del sampler del KSampler (ej. "euler"). keyword-only.
scheduler: scheduler del sampler (ej. "normal"). keyword-only.
filename_prefix: prefijo del archivo de malla que SaveGLB escribe en
output/ (ej. "3d_mesh" -> "3d_mesh_00001_.glb"). keyword-only.
watertight: si False (default, retro-compatible) el nodo "8" es
VoxelToMeshBasic (malla NO estanca). Si True usa VoxelToMesh con
algorithm="surface net", que produce una malla estanca/manifold de
raiz sin post-proceso. keyword-only.
Returns:
dict en API format con node_ids "1".."9" como claves; cada valor tiene
class_type + inputs. Listo para comfyui_submit_workflow. El nodo "9"
(SaveGLB) produce el archivo .glb en el output del servidor. El nodo "8"
es VoxelToMeshBasic (watertight=False) o VoxelToMesh surface-net
(watertight=True).
"""
voxel_node = (
{
"class_type": "VoxelToMesh",
"inputs": {
"voxel": ["7", 0],
"algorithm": "surface net",
"threshold": threshold,
},
}
if watertight
else {
"class_type": "VoxelToMeshBasic",
"inputs": {"voxel": ["7", 0], "threshold": threshold},
}
)
return {
"1": {
"class_type": "LoadImage",
"inputs": {"image": image_name},
},
"2": {
"class_type": "ImageOnlyCheckpointLoader",
"inputs": {"ckpt_name": ckpt_name},
},
"3": {
"class_type": "CLIPVisionEncode",
"inputs": {
"clip_vision": ["2", 1],
"image": ["1", 0],
"crop": "center",
},
},
"4": {
"class_type": "Hunyuan3Dv2Conditioning",
"inputs": {"clip_vision_output": ["3", 0]},
},
"5": {
"class_type": "EmptyLatentHunyuan3Dv2",
"inputs": {"resolution": resolution, "batch_size": 1},
},
"6": {
"class_type": "KSampler",
"inputs": {
"seed": seed,
"steps": steps,
"cfg": cfg,
"sampler_name": sampler_name,
"scheduler": scheduler,
"denoise": 1.0,
"model": ["2", 0],
"positive": ["4", 0],
"negative": ["4", 1],
"latent_image": ["5", 0],
},
},
"7": {
"class_type": "VAEDecodeHunyuan3D",
"inputs": {
"samples": ["6", 0],
"vae": ["2", 2],
"num_chunks": num_chunks,
"octree_resolution": octree_resolution,
},
},
"8": voxel_node,
"9": {
"class_type": "SaveGLB",
"inputs": {"mesh": ["8", 0], "filename_prefix": filename_prefix},
},
}
if __name__ == "__main__":
import json
wf = comfyui_build_image_to_3d_workflow(
image_name="3d_src_robot_00001_.png",
ckpt_name="hunyuan3d-dit-v2-mini.safetensors",
seed=42,
)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,87 @@
---
name: comfyui_build_img2img_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_img2img_workflow(ckpt_name: str, init_image: str, positive: str, negative: str = \"\", *, denoise: float = 0.6, steps: int = 20, cfg: float = 7.0, seed: int = 0, sampler_name: str = \"euler\", scheduler: str = \"normal\") -> dict"
description: "Construye el dict de un workflow ComfyUI img2img en API format para SD1.5/SDXL: CheckpointLoaderSimple + LoadImage -> VAEEncode -> KSampler (con denoise < 1.0 para conservar la imagen base) -> VAEDecode -> SaveImage. Pura, sin red ni I/O. Hermana de comfyui_build_txt2img_workflow."
tags: [comfyui, ml, image-generation, img2img, stable-diffusion, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: ckpt_name
desc: "Nombre del checkpoint tal como lo ve el servidor ComfyUI (ej. 'dreamshaper_8.safetensors'). Debe estar en la lista de CheckpointLoaderSimple de comfyui_object_info."
- name: init_image
desc: "Nombre del archivo de imagen base dentro de la carpeta input/ del servidor ComfyUI; lo carga el nodo LoadImage."
- name: positive
desc: "Prompt positivo: lo que se quiere ver en la imagen."
- name: negative
desc: "Prompt negativo: lo que se quiere evitar. Por defecto cadena vacia."
- name: denoise
desc: "Fuerza de denoising del KSampler (0.0 = identica a la base, 1.0 = ignora la base). Tipico 0.4-0.7 para img2img. keyword-only."
- name: steps
desc: "Pasos de sampling del KSampler. keyword-only."
- name: cfg
desc: "Classifier-free guidance scale. keyword-only."
- name: seed
desc: "Semilla del KSampler. 0 es determinista; cambiar para variar. keyword-only."
- name: sampler_name
desc: "Nombre del sampler (ej. 'euler', 'dpmpp_2m'). keyword-only."
- name: scheduler
desc: "Scheduler del sampler (ej. 'normal', 'karras'). keyword-only."
output: "dict en API format con node_ids como claves (CheckpointLoaderSimple '4', LoadImage '10', VAEEncode '11', CLIPTextEncode '6'/'7', KSampler '3', VAEDecode '8', SaveImage '9'). Listo para comfyui_submit_workflow."
tested: true
tests: ["usa VAEEncode/LoadImage y no EmptyLatentImage", "denoise e init_image reflejados", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_img2img_workflow.py"
file_path: "python/functions/ml/comfyui_build_img2img_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_img2img_workflow import comfyui_build_img2img_workflow
wf = comfyui_build_img2img_workflow(
ckpt_name="dreamshaper_8.safetensors",
init_image="cabin.png", # archivo en el input/ de ComfyUI
positive="a cozy cabin in the woods, golden hour",
negative="blurry, low quality",
denoise=0.55, # conserva ~la mitad de la imagen base
seed=42,
)
# wf["11"]["class_type"] == "VAEEncode"
# wf["3"]["inputs"]["latent_image"] == ["11", 0] # KSampler parte del latente de la imagen
# wf["3"]["inputs"]["denoise"] == 0.55
```
El bloque de arriba se lanza con el python del venv (`python/.venv/bin/python3`). Nota: `./fn run` directo no aplica a este builder porque su firma usa `*` (keyword-only) y el generador de runner de `fn run` no lo soporta — igual que en `comfyui_build_txt2img_workflow`. Usa el import de arriba o un heredoc.
## Cuando usarla
Cuando quieras transformar una imagen existente con un prompt (variaciones,
restyling, refine) en lugar de generar desde ruido. Sube primero la imagen base
al `input/` del servidor (o cargala por la UI) y pasa su nombre en `init_image`.
Para generar desde cero usa `comfyui_build_txt2img_workflow`; para ampliar una
imagen usa `comfyui_build_upscale_workflow`.
## Gotchas
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI. Es lo que
acepta POST /prompt.
- `init_image` debe existir en la carpeta `input/` del servidor (no es un path
local arbitrario). Subela antes con la UI o copiala a `~/ComfyUI/input/`.
- `denoise` controla cuanto se conserva de la base: cerca de 1.0 ignora la
imagen (casi txt2img); cerca de 0.0 apenas la cambia. 0.4-0.7 es el rango util.
- Asume que el checkpoint trae VAE embebido (VAEEncode/VAEDecode usan `["4", 2]`).
Para un VAE externo cambia esas conexiones.
- Es pura: NO valida que `ckpt_name`/`init_image` existan en el servidor. Si no
existen, ComfyUI rechaza el workflow con HTTP 400 al enviarlo. Valida antes con
`comfyui_validate_workflow`.
@@ -0,0 +1,108 @@
"""Construye un workflow ComfyUI img2img en API format (dict de nodos numerados).
API format: cada clave es un node_id (string); cada nodo tiene class_type +
inputs. Las conexiones entre nodos son listas [node_id, output_index]. Es el
formato que acepta POST /prompt, distinto del formato de la UI (graph con links).
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
def comfyui_build_img2img_workflow(
ckpt_name: str,
init_image: str,
positive: str,
negative: str = "",
*,
denoise: float = 0.6,
steps: int = 20,
cfg: float = 7.0,
seed: int = 0,
sampler_name: str = "euler",
scheduler: str = "normal",
) -> dict:
"""Construye el dict de un workflow img2img para SD1.5 / SDXL.
Cadena de nodos: CheckpointLoaderSimple + LoadImage -> VAEEncode ->
KSampler (con denoise < 1.0 para conservar la imagen base) -> VAEDecode ->
SaveImage. CLIPTextEncode codifica el prompt positivo y el negativo.
Args:
ckpt_name: nombre del checkpoint tal como lo ve el servidor ComfyUI
(ej. "dreamshaper_8.safetensors"). Debe estar entre los que devuelve
comfyui_object_info para CheckpointLoaderSimple.
init_image: nombre del archivo de imagen base dentro de la carpeta
input/ del servidor ComfyUI (lo que carga el nodo LoadImage).
positive: prompt positivo (lo que se quiere ver en la imagen).
negative: prompt negativo (lo que se quiere evitar). Por defecto "".
denoise: fuerza de denoising del KSampler (0.0 = identica a la base,
1.0 = ignora la base). Tipico 0.4-0.7 para img2img. keyword-only.
steps: pasos de sampling del KSampler. keyword-only.
cfg: classifier-free guidance scale. keyword-only.
seed: semilla del KSampler. keyword-only.
sampler_name: nombre del sampler (ej. "euler", "dpmpp_2m"). keyword-only.
scheduler: scheduler del sampler (ej. "normal", "karras"). keyword-only.
Returns:
dict en API format listo para comfyui_submit_workflow. Las claves son
node_ids y cada valor tiene class_type + inputs.
"""
return {
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": ckpt_name},
},
"10": {
"class_type": "LoadImage",
"inputs": {"image": init_image},
},
"11": {
"class_type": "VAEEncode",
"inputs": {"pixels": ["10", 0], "vae": ["4", 2]},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": positive, "clip": ["4", 1]},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["4", 1]},
},
"3": {
"class_type": "KSampler",
"inputs": {
"seed": seed,
"steps": steps,
"cfg": cfg,
"sampler_name": sampler_name,
"scheduler": scheduler,
"denoise": denoise,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["11", 0],
},
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "comfy_img2img", "images": ["8", 0]},
},
}
if __name__ == "__main__":
import json
wf = comfyui_build_img2img_workflow(
ckpt_name="dreamshaper_8.safetensors",
init_image="example.png",
positive="a cozy cabin in the woods, golden hour, sharp focus",
negative="blurry, low quality",
denoise=0.6,
seed=42,
)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,107 @@
---
name: comfyui_build_img2vid_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_img2vid_workflow(image: str, *, ckpt: str = \"svd.safetensors\", width: int = 1024, height: int = 576, video_frames: int = 14, motion_bucket_id: int = 127, fps: int = 6, augmentation_level: float = 0.0, steps: int = 20, cfg: float = 2.5, min_cfg: float = 1.0, seed: int = 0, sampler_name: str = \"euler\", scheduler: str = \"karras\", filename_prefix: str = \"comfy_svd\") -> dict"
description: "Construye el dict de un workflow ComfyUI img2vid (Stable Video Diffusion) en API format a partir de una imagen estatica. Cadena: ImageOnlyCheckpointLoader(svd.safetensors -> MODEL, CLIP_VISION, VAE) + LoadImage -> SVD_img2vid_Conditioning(positive, negative, latent) -> VideoLinearCFGGuidance -> KSampler(denoise 1.0) -> VAEDecode -> SaveAnimatedWEBP. SVD no usa prompt de texto: el condicionamiento sale de la imagen via CLIP_VISION del checkpoint todo-en-uno. Movimiento via motion_bucket_id y fps. Pura, sin red ni I/O. Hermana de comfyui_build_video_workflow (txt2video LTX/Wan)."
tags: [comfyui, svd, img2vid, video, ml, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: image
desc: "Nombre del archivo de imagen base en la carpeta input/ del servidor ComfyUI (lo que carga LoadImage). Es el frame inicial del que SVD deriva el clip."
- name: ckpt
desc: "Nombre del checkpoint SVD tal como lo ve el servidor. Por defecto 'svd.safetensors' (todo-en-uno: UNet + VAE + CLIP image encoder). keyword-only."
- name: width
desc: "Ancho del video en px (multiplo de 8; SVD base entrena a 1024). keyword-only."
- name: height
desc: "Alto del video en px (multiplo de 8; SVD base entrena a 576). keyword-only."
- name: video_frames
desc: "Numero de frames del clip. svd.safetensors es el modelo de 14 frames; la variante xt llega a 25. keyword-only."
- name: motion_bucket_id
desc: "Intensidad de movimiento (1-255 util; 127 por defecto). Mas alto = mas movimiento. keyword-only."
- name: fps
desc: "Frames por segundo con que se condiciona (SVD_img2vid_Conditioning) y se guarda el clip (SaveAnimatedWEBP, alli como float). keyword-only."
- name: augmentation_level
desc: "Ruido anadido a la imagen base (0.0 = fiel; subirlo da mas libertad/movimiento a costa de fidelidad). keyword-only."
- name: steps
desc: "Pasos de sampling del KSampler. keyword-only."
- name: cfg
desc: "Guidance scale del ultimo frame (VideoLinearCFGGuidance interpola de min_cfg al primero hasta cfg al ultimo). SVD usa cfg baja (~2.5). keyword-only."
- name: min_cfg
desc: "Guidance scale del primer frame para VideoLinearCFGGuidance. keyword-only."
- name: seed
desc: "Semilla del sampler. 0 es determinista; cambiar para variar el clip. keyword-only."
- name: sampler_name
desc: "Algoritmo del KSampler. Por defecto 'euler'. keyword-only."
- name: scheduler
desc: "Scheduler del KSampler. Por defecto 'karras'. keyword-only."
- name: filename_prefix
desc: "Prefijo del archivo de salida (.webp animado de SaveAnimatedWEBP). keyword-only."
output: "dict en API format listo para comfyui_submit_workflow. node_ids string; cada valor con class_type + inputs. Devuelve 7 nodos: ImageOnlyCheckpointLoader, LoadImage, SVD_img2vid_Conditioning, VideoLinearCFGGuidance, KSampler, VAEDecode y SaveAnimatedWEBP. El denoise del KSampler se fija a 1.0 (genera desde el latente condicionado, no es img2img)."
tested: true
tests: ["estructura: 7 nodos SVD presentes + ckpt svd.safetensors + image en LoadImage", "cableado: clip_vision/vae [15,1]/[15,2], cond->KSampler 0/1/2, model post VideoLinearCFGGuidance, denoise 1.0", "params reflejados (width/height/video_frames/motion_bucket_id/fps/augmentation_level/steps/cfg/min_cfg/seed/filename_prefix) + fps float en SaveAnimatedWEBP", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_img2vid_workflow.py"
file_path: "python/functions/ml/comfyui_build_img2vid_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_img2vid_workflow import comfyui_build_img2vid_workflow
wf = comfyui_build_img2vid_workflow(
"example.png",
width=1024, height=576, video_frames=14,
motion_bucket_id=127, fps=6, steps=20, seed=42,
)
# wf["12"]["class_type"] == "SVD_img2vid_Conditioning"
# wf["30"]["class_type"] == "SaveAnimatedWEBP"
# -> comfyui_submit_workflow(wf) para encolar el clip (necesita GPU)
```
O lanzable directo con: `./fn run comfyui_build_img2vid_workflow` (imprime el JSON del workflow SVD de ejemplo).
## Cuando usarla
Antes de enviar una generacion de video img2vid (animar una imagen estatica) a
ComfyUI: construye aqui el dict del workflow SVD y pasalo a
`comfyui_submit_workflow`. Usala cuando partes de UNA imagen y quieres un clip
corto derivado de ella (SVD no toma prompt de texto). Para texto -> video usa la
hermana `comfyui_build_video_workflow` (LTX/Wan). Verifica el workflow contra el
servidor con `comfyui_validate_workflow` antes de encolar.
## Gotchas
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI. Es lo que
acepta POST /prompt.
- SVD NO usa prompts de texto. El condicionamiento sale de la imagen base via el
CLIP_VISION del checkpoint todo-en-uno; por eso no hay nodos CLIPTextEncode.
- El checkpoint `svd.safetensors` debe existir y ser visible para el servidor
(carpeta de checkpoints o extra_model_paths) o ComfyUI rechaza el workflow con
HTTP 400 al enviarlo. Esta funcion es pura y no valida contra el servidor.
- La imagen `image` debe estar en la carpeta input/ del servidor (subela antes con
el endpoint de upload o el nodo LoadImage de la UI). El validador estructural NO
comprueba la existencia de la imagen (image no es un input de modelo).
- VRAM 8GB: SVD es pesado. Con los defaults (1024x576, 14 frames) el modelo base
puede acercarse al techo de 8GB. Si da OOM, bajar resolucion (768x448) o
video_frames. La generacion real (submit) es un paso posterior con GPU; este
builder solo arma el dict y se valida de forma estructural (offline).
- `svd.safetensors` es el modelo de 14 frames. La variante `svd_xt` admite 25;
con el base, video_frames > 14 puede degradar el clip.
- motion_bucket_id alto = mas movimiento (y mas artefactos). 127 es el centro
recomendado por Stability.
- cfg se mantiene baja (~2.5) y se interpola con VideoLinearCFGGuidance (min_cfg en
el primer frame -> cfg en el ultimo). Subir cfg degrada el video.
- SaveAnimatedWEBP declara `fps` como FLOAT en /object_info: el builder pasa
`float(fps)` para no provocar HTTP 400. El nodo VHS_VideoCombine NO esta instalado
en este servidor; por eso el guardado usa el SaveAnimatedWEBP nativo.
@@ -0,0 +1,152 @@
"""Construye un workflow ComfyUI img2vid (SVD) en "API format" (dict de nodos numerados).
Implementa la plantilla canonica de Stable Video Diffusion de ComfyUI: a partir de
una imagen estatica genera un clip corto de video. El checkpoint `svd.safetensors`
es todo-en-uno (UNet + VAE + CLIP image encoder), cargado con
ImageOnlyCheckpointLoader (da MODEL, CLIP_VISION y VAE de una sola pieza).
Cadena de nodos:
ImageOnlyCheckpointLoader (MODEL, CLIP_VISION, VAE) + LoadImage (imagen base) ->
SVD_img2vid_Conditioning (positive, negative, latent) ->
VideoLinearCFGGuidance (interpola cfg de min_cfg a cfg a lo largo del clip) ->
KSampler (denoise 1.0) -> VAEDecode (secuencia de frames) -> SaveAnimatedWEBP.
A diferencia de los modelos txt2video (LTX/Wan), SVD no usa prompts de texto: el
condicionamiento sale de la imagen via el CLIP_VISION del propio checkpoint. El
movimiento se controla con motion_bucket_id (mas alto = mas movimiento) y fps.
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
def comfyui_build_img2vid_workflow(
image: str,
*,
ckpt: str = "svd.safetensors",
width: int = 1024,
height: int = 576,
video_frames: int = 14,
motion_bucket_id: int = 127,
fps: int = 6,
augmentation_level: float = 0.0,
steps: int = 20,
cfg: float = 2.5,
min_cfg: float = 1.0,
seed: int = 0,
sampler_name: str = "euler",
scheduler: str = "karras",
filename_prefix: str = "comfy_svd",
) -> dict:
"""Construye el dict del workflow img2vid (SVD) para svd.safetensors.
Args:
image: nombre del archivo de imagen base dentro de la carpeta input/ del
servidor ComfyUI (lo que carga el nodo LoadImage). Es el frame inicial
del que SVD deriva el clip.
ckpt: nombre del checkpoint SVD tal como lo ve el servidor. Por defecto
"svd.safetensors" (todo-en-uno: UNet + VAE + CLIP image encoder).
keyword-only.
width: ancho del video en px (multiplo de 8; SVD base entrena a 1024).
keyword-only.
height: alto del video en px (multiplo de 8; SVD base entrena a 576).
keyword-only.
video_frames: numero de frames del clip. svd.safetensors es el modelo de
14 frames; el variante xt llega a 25. keyword-only.
motion_bucket_id: intensidad de movimiento (1-255 util; 127 por defecto).
Mas alto = mas movimiento. keyword-only.
fps: frames por segundo con que se condiciona y se guarda el clip.
keyword-only.
augmentation_level: ruido anadido a la imagen base (0.0 = fiel a la base;
subirlo da mas libertad/movimiento a costa de fidelidad). keyword-only.
steps: pasos de sampling del KSampler. keyword-only.
cfg: guidance scale del ultimo frame (VideoLinearCFGGuidance interpola de
min_cfg al primer frame hasta cfg al ultimo). SVD usa cfg baja (~2.5).
keyword-only.
min_cfg: guidance scale del primer frame para VideoLinearCFGGuidance.
keyword-only.
seed: semilla del sampler (0 = determinista; cambiar para variar el clip).
keyword-only.
sampler_name: algoritmo del KSampler. Por defecto "euler". keyword-only.
scheduler: scheduler del KSampler. Por defecto "karras". keyword-only.
filename_prefix: prefijo del archivo de salida (.webp animado).
keyword-only.
Returns:
dict en API format listo para comfyui_submit_workflow. Las claves son
node_ids (string) y cada valor tiene class_type + inputs. Devuelve 7 nodos:
ImageOnlyCheckpointLoader, LoadImage, SVD_img2vid_Conditioning,
VideoLinearCFGGuidance, KSampler, VAEDecode y SaveAnimatedWEBP. El denoise
del KSampler se fija a 1.0 (img2vid genera desde latente vacio condicionado,
no es img2img).
"""
return {
"15": {
"class_type": "ImageOnlyCheckpointLoader",
"inputs": {"ckpt_name": ckpt},
},
"23": {
"class_type": "LoadImage",
"inputs": {"image": image},
},
"12": {
"class_type": "SVD_img2vid_Conditioning",
"inputs": {
"clip_vision": ["15", 1],
"init_image": ["23", 0],
"vae": ["15", 2],
"width": width,
"height": height,
"video_frames": video_frames,
"motion_bucket_id": motion_bucket_id,
"fps": fps,
"augmentation_level": augmentation_level,
},
},
"14": {
"class_type": "VideoLinearCFGGuidance",
"inputs": {"model": ["15", 0], "min_cfg": min_cfg},
},
"3": {
"class_type": "KSampler",
"inputs": {
"seed": seed,
"steps": steps,
"cfg": cfg,
"sampler_name": sampler_name,
"scheduler": scheduler,
"denoise": 1.0,
"model": ["14", 0],
"positive": ["12", 0],
"negative": ["12", 1],
"latent_image": ["12", 2],
},
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["3", 0], "vae": ["15", 2]},
},
"30": {
"class_type": "SaveAnimatedWEBP",
"inputs": {
"images": ["8", 0],
"filename_prefix": filename_prefix,
"fps": float(fps),
"lossless": False,
"quality": 90,
"method": "default",
},
},
}
if __name__ == "__main__":
import json
wf = comfyui_build_img2vid_workflow(
"example.png",
motion_bucket_id=127,
fps=6,
video_frames=14,
seed=42,
)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,94 @@
---
name: comfyui_build_inpaint_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_inpaint_workflow(ckpt_name: str, image: str, mask: str, positive: str, negative: str = \"\", *, denoise: float = 1.0, steps: int = 20, cfg: float = 7.0, seed: int = 0, sampler_name: str = \"euler\", scheduler: str = \"normal\") -> dict"
description: "Construye el dict de un workflow ComfyUI inpaint en API format para SD1.5/SDXL: CheckpointLoaderSimple + LoadImage (base) + LoadImageMask (mascara) -> VAEEncodeForInpaint -> KSampler -> VAEDecode -> SaveImage. Regenera solo la zona enmascarada conservando el resto. Pura, sin red ni I/O. Hermana de comfyui_build_txt2img_workflow."
tags: [comfyui, ml, image-generation, inpaint, stable-diffusion, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: ckpt_name
desc: "Nombre del checkpoint tal como lo ve el servidor ComfyUI (ej. 'dreamshaper_8.safetensors'). Debe estar en la lista de CheckpointLoaderSimple de comfyui_object_info."
- name: image
desc: "Nombre del archivo de la imagen base dentro de la carpeta input/ del servidor ComfyUI; lo carga el nodo LoadImage."
- name: mask
desc: "Nombre del archivo de la mascara dentro de input/ del servidor; lo carga LoadImageMask. Las zonas blancas se regeneran."
- name: positive
desc: "Prompt positivo: lo que se quiere ver en la zona enmascarada."
- name: negative
desc: "Prompt negativo: lo que se quiere evitar. Por defecto cadena vacia."
- name: denoise
desc: "Fuerza de denoising del KSampler (1.0 regenera por completo la zona enmascarada; <1.0 conserva parte de la base). keyword-only."
- name: steps
desc: "Pasos de sampling del KSampler. keyword-only."
- name: cfg
desc: "Classifier-free guidance scale. keyword-only."
- name: seed
desc: "Semilla del KSampler. 0 es determinista; cambiar para variar. keyword-only."
- name: sampler_name
desc: "Nombre del sampler (ej. 'euler', 'dpmpp_2m'). keyword-only."
- name: scheduler
desc: "Scheduler del sampler (ej. 'normal', 'karras'). keyword-only."
output: "dict en API format con node_ids como claves (CheckpointLoaderSimple '4', LoadImage '10', LoadImageMask '12', VAEEncodeForInpaint '11', CLIPTextEncode '6'/'7', KSampler '3', VAEDecode '8', SaveImage '9'). Listo para comfyui_submit_workflow."
tested: true
tests: ["usa LoadImageMask+VAEEncodeForInpaint", "imagen base, mascara, seed y denoise reflejados", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_inpaint_workflow.py"
file_path: "python/functions/ml/comfyui_build_inpaint_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_inpaint_workflow import comfyui_build_inpaint_workflow
wf = comfyui_build_inpaint_workflow(
ckpt_name="dreamshaper_8.safetensors",
image="room.png", # imagen base en el input/ de ComfyUI
mask="room_mask.png", # mascara: blanco = zona a regenerar
positive="a vase of red flowers on the table, sharp focus",
negative="blurry, low quality",
denoise=1.0,
seed=42,
)
# wf["11"]["class_type"] == "VAEEncodeForInpaint"
# wf["11"]["inputs"]["mask"] == ["12", 0] # mascara desde LoadImageMask
# wf["3"]["inputs"]["latent_image"] == ["11", 0] # KSampler parte del latente inpaint
```
El bloque se lanza con el python del venv (`python/.venv/bin/python3`). `./fn run`
directo no aplica a este builder porque su firma usa `*` (keyword-only); usa el
import de arriba o un heredoc.
## Cuando usarla
Cuando quieras reemplazar solo una parte de una imagen (quitar un objeto, cambiar
un detalle, rellenar una zona) conservando el resto intacto. Sube la imagen base
y la mascara al `input/` del servidor y pasa sus nombres. Para transformar la
imagen entera usa `comfyui_build_img2img_workflow`; para generar desde cero
`comfyui_build_txt2img_workflow`.
## Gotchas
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI. Es lo que
acepta POST /prompt.
- `image` y `mask` deben existir en la carpeta `input/` del servidor (no son
paths locales arbitrarios). Subelos antes con la UI o copialos a `~/ComfyUI/input/`.
- `LoadImageMask` lee el canal `red` por defecto: la mascara debe tener la zona a
regenerar en blanco. Si tu mascara usa el canal alpha, cambia `channel` en el
nodo '12' tras construir.
- `VAEEncodeForInpaint` usa `grow_mask_by: 6` (suaviza el borde de la mascara).
Ajustalo en el nodo '11' si necesitas un borde mas duro o mas difuso.
- Asume que el checkpoint trae VAE embebido (VAEEncodeForInpaint/VAEDecode usan
`["4", 2]`). Para un VAE externo cambia esas conexiones.
- Es pura: NO valida que `ckpt_name`/`image`/`mask` existan en el servidor.
Valida antes con `comfyui_validate_workflow`.
@@ -0,0 +1,123 @@
"""Construye un workflow ComfyUI inpaint en API format (dict de nodos numerados).
Inpaint: se reemplaza la zona enmascarada de una imagen conservando el resto.
Cadena de nodos: CheckpointLoaderSimple + LoadImage (imagen base) +
LoadImageMask (mascara) -> VAEEncodeForInpaint (codifica el latente respetando
la mascara) -> KSampler -> VAEDecode -> SaveImage. Los CLIPTextEncode codifican
el prompt positivo y el negativo.
API format: cada clave es un node_id (string); cada nodo tiene class_type +
inputs. Las conexiones entre nodos son listas [node_id, output_index]. Es el
formato que acepta POST /prompt, distinto del formato de la UI (graph con links).
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
def comfyui_build_inpaint_workflow(
ckpt_name: str,
image: str,
mask: str,
positive: str,
negative: str = "",
*,
denoise: float = 1.0,
steps: int = 20,
cfg: float = 7.0,
seed: int = 0,
sampler_name: str = "euler",
scheduler: str = "normal",
) -> dict:
"""Construye el dict de un workflow inpaint para SD1.5 / SDXL.
Args:
ckpt_name: nombre del checkpoint tal como lo ve el servidor ComfyUI
(ej. "dreamshaper_8.safetensors"). Debe estar entre los que devuelve
comfyui_object_info para CheckpointLoaderSimple.
image: nombre del archivo de la imagen base dentro de la carpeta input/
del servidor ComfyUI (lo carga el nodo LoadImage).
mask: nombre del archivo de la mascara dentro de input/ del servidor
(lo carga LoadImageMask; las zonas blancas se regeneran).
positive: prompt positivo (lo que se quiere ver en la zona enmascarada).
negative: prompt negativo (lo que se quiere evitar). Por defecto "".
denoise: fuerza de denoising del KSampler (1.0 regenera por completo la
zona enmascarada; <1.0 conserva parte de la base). keyword-only.
steps: pasos de sampling del KSampler. keyword-only.
cfg: classifier-free guidance scale. keyword-only.
seed: semilla del KSampler. 0 es determinista; cambiar para variar.
keyword-only.
sampler_name: nombre del sampler (ej. "euler", "dpmpp_2m"). keyword-only.
scheduler: scheduler del sampler (ej. "normal", "karras"). keyword-only.
Returns:
dict en API format listo para comfyui_submit_workflow. Las claves son
node_ids y cada valor tiene class_type + inputs.
"""
return {
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": ckpt_name},
},
"10": {
"class_type": "LoadImage",
"inputs": {"image": image},
},
"12": {
"class_type": "LoadImageMask",
"inputs": {"image": mask, "channel": "red"},
},
"11": {
"class_type": "VAEEncodeForInpaint",
"inputs": {
"pixels": ["10", 0],
"vae": ["4", 2],
"mask": ["12", 0],
"grow_mask_by": 6,
},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": positive, "clip": ["4", 1]},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["4", 1]},
},
"3": {
"class_type": "KSampler",
"inputs": {
"seed": seed,
"steps": steps,
"cfg": cfg,
"sampler_name": sampler_name,
"scheduler": scheduler,
"denoise": denoise,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["11", 0],
},
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "comfy_inpaint", "images": ["8", 0]},
},
}
if __name__ == "__main__":
import json
wf = comfyui_build_inpaint_workflow(
ckpt_name="dreamshaper_8.safetensors",
image="room.png",
mask="room_mask.png",
positive="a vase of red flowers on the table, sharp focus",
negative="blurry, low quality",
seed=42,
)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,118 @@
---
name: comfyui_build_ipadapter_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_ipadapter_workflow(prompt: str, ref_image: str, *, base_checkpoint: str, mode: str = 'style', weight: float = 0.8, negative: str = '', preset: str | None = None, weight_type: str | None = None, start_at: float = 0.0, end_at: float = 1.0, weight_faceidv2: float = 1.0, lora_strength: float = 0.6, combine_embeds: str = 'concat', embeds_scaling: str = 'V only', provider: str = 'CPU', steps: int = 20, cfg: float = 7.0, width: int = 512, height: int = 512, seed: int = 0, sampler_name: str = 'euler', scheduler: str = 'normal', filename_prefix: str = 'ipadapter') -> dict"
description: "Construye un workflow ComfyUI txt2img + IPAdapter (custom node cubiq/IPAdapter_plus) en API format. mode='style' usa IPAdapterUnifiedLoader+IPAdapter para transferir estilo/composicion de una imagen de referencia; mode='faceid' usa IPAdapterUnifiedLoaderFaceID+IPAdapterFaceID (insightface + .bin FaceID + su LoRA) para imponer un rostro consistente. Repunta el KSampler a la salida MODEL de la rama IPAdapter. Pura: sin red ni I/O."
tags: [comfyui, comfyui-skill, ipadapter, faceid, ml, stable-diffusion, workflow]
uses_functions: [comfyui_build_txt2img_workflow_py_ml]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: prompt
desc: "Prompt positivo (texto del resultado deseado)."
- name: ref_image
desc: "Nombre del archivo de imagen de referencia en input/ del servidor ComfyUI (lo carga LoadImage). En faceid debe contener una cara nitida; en style es la imagen de estilo."
- name: base_checkpoint
desc: "Checkpoint SD1.5/SDXL. Debe casar con los modelos IPAdapter (modelos SD1.5 con checkpoints SD1.5). keyword-only."
- name: mode
desc: "'style' (transfiere estilo/composicion) o 'faceid' (rostro consistente). keyword-only."
- name: weight
desc: "Peso de la influencia IPAdapter (0..1+). 0.8 buen punto de partida; sube para mas parecido, baja para mas libertad del prompt."
- name: negative
desc: "Prompt negativo."
- name: preset
desc: "Preset del UnifiedLoader. None => default por modo ('STANDARD (medium strength)' style, 'FACEID PLUS V2' faceid)."
- name: weight_type
desc: "Tipo de ponderacion del nodo IPAdapter/FaceID. None => default por modo ('standard' style, 'linear' faceid)."
- name: start_at
desc: "Fraccion del sampling donde empieza a aplicar IPAdapter (0..1)."
- name: end_at
desc: "Fraccion del sampling donde deja de aplicar (0..1)."
- name: weight_faceidv2
desc: "Peso del embedding FaceID v2 (solo mode='faceid')."
- name: lora_strength
desc: "Fuerza de la LoRA FaceID que carga el UnifiedLoaderFaceID (solo mode='faceid')."
- name: combine_embeds
desc: "Combinacion de embeddings si hay varias caras ('concat'|'add'|'subtract'|'average'|'norm average'). Solo faceid."
- name: embeds_scaling
desc: "Escalado de embeddings ('V only'|'K+V'|...). Solo faceid."
- name: provider
desc: "Backend de insightface ('CPU'|'CUDA'|...). CPU por defecto para no competir por VRAM. Solo faceid."
- name: steps
desc: "Pasos de sampling (pasa a la base txt2img)."
- name: cfg
desc: "Classifier-free guidance scale (pasa a la base)."
- name: width
desc: "Ancho en px, multiplo de 8 (pasa a la base)."
- name: height
desc: "Alto en px, multiplo de 8 (pasa a la base)."
- name: seed
desc: "Semilla del KSampler (pasa a la base)."
- name: sampler_name
desc: "Nombre del sampler (pasa a la base)."
- name: scheduler
desc: "Scheduler del sampler (pasa a la base)."
- name: filename_prefix
desc: "Prefijo del PNG generado por SaveImage."
output: "dict en API format listo para comfyui_submit_workflow: base txt2img + LoadImage + rama IPAdapter del modo elegido, con el KSampler repuntado a la salida MODEL de esa rama."
tested: true
tests: ["mode='style': nodos LoadImage/IPAdapterUnifiedLoader/IPAdapter + conexiones + KSampler repuntado + defaults", "mode='faceid': nodos UnifiedLoaderFaceID/IPAdapterFaceID + conexiones + provider CPU + defaults", "mode invalido lanza ValueError", "ref_image vacia lanza ValueError", "override de preset y weight_type", "determinismo"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_ipadapter_workflow.py"
file_path: "python/functions/ml/comfyui_build_ipadapter_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_ipadapter_workflow import comfyui_build_ipadapter_workflow
from ml.comfyui_submit_workflow import comfyui_submit_workflow
# Estilo: transfiere el look de una imagen de referencia
wf = comfyui_build_ipadapter_workflow(
"a fantasy castle on a hill", "example.png",
base_checkpoint="dreamshaper_8.safetensors", mode="style", weight=0.8)
resp = comfyui_submit_workflow(wf)
# FaceID: rostro consistente a partir de una cara de referencia
wf = comfyui_build_ipadapter_workflow(
"portrait of a knight in armor, cinematic", "showcase_char.png",
base_checkpoint="dreamshaper_8.safetensors", mode="faceid", weight=0.9)
resp = comfyui_submit_workflow(wf)
print(resp["prompt_id"])
```
## Cuando usarla
Cuando quieras condicionar una generacion por una **imagen de referencia**, no solo
texto. Dos casos: `mode='style'` para clonar el estilo/composicion de una imagen
(image prompt), y `mode='faceid'` para generar un personaje con un **rostro
concreto y consistente** (el modelo extrae el embedding facial con insightface).
La referencia se sube primero a `input/` del servidor (LoadImage la lee por nombre).
## Gotchas
- **Modelos SD1.5 ↔ checkpoints SD1.5.** Los modelos descargados son SD1.5
(`ip-adapter*_sd15`, `ip-adapter-faceid-plusv2_sd15`); usalos con un checkpoint
SD1.5 (dreamshaper_8). Mezclar con SDXL hace fallar el UnifiedLoader.
- **La clave `ipadapter` debe estar en `extra_model_paths.yaml`.** El custom node
registra la carpeta `models/ipadapter`; si los modelos viven en otra ruta (ej.
`/mnt/2tb`), esa clave los mapea. Sin ella `ipadapter_file` sale vacio.
- **faceid usa insightface (`buffalo_l`) + la LoRA FaceID.** El UnifiedLoaderFaceID
carga la LoRA `ip-adapter-faceid-plusv2_sd15_lora.safetensors` (debe estar en
`models/loras/`). `provider='CPU'` por defecto: insightface en CPU no compite por
los 8GB de VRAM; pon `'CUDA'` solo si tienes onnxruntime-gpu instalado.
- **La referencia debe existir en `input/`.** Es un nombre de archivo, no una ruta:
sube la imagen antes (POST /upload/image o copiala a `~/ComfyUI/input/`).
- Pura: construye el dict, no valida que los modelos existan ni hace red. Valida con
`comfyui_validate_workflow` y envia con `comfyui_submit_workflow`.
- En 8GB usa resolucion modesta (512x512) en SD1.5; faceid + LoRA + insightface
caben con `--lowvram`, pero sube la VRAM si combinas con multi-LoRA pesado.
@@ -0,0 +1,224 @@
"""Construye un workflow ComfyUI txt2img + IPAdapter en API format (dict de nodos).
Parte de comfyui_build_txt2img_workflow y le injerta la rama IPAdapter del custom
node ComfyUI_IPAdapter_plus (cubiq):
- mode='style': IPAdapterUnifiedLoader + IPAdapter. La imagen de referencia
transfiere estilo/composicion al resultado (image prompt clasico).
- mode='faceid': IPAdapterUnifiedLoaderFaceID + IPAdapterFaceID. Usa insightface
para extraer el embedding de la cara de la referencia y el .bin FaceID + su
LoRA para imponer un **rostro consistente** en el personaje generado.
En ambos casos la salida MODEL de la rama IPAdapter se repunta al KSampler, de
modo que el sampler genera ya condicionado por la imagen de referencia.
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos. Los
class_type/inputs estan verificados contra /object_info del servidor (IPAdapter
plus): IPAdapterUnifiedLoader(model,preset)->[MODEL,IPADAPTER],
IPAdapter(model,ipadapter,image,weight,start_at,end_at,weight_type)->[MODEL],
IPAdapterUnifiedLoaderFaceID(model,preset,lora_strength,provider)->[MODEL,IPADAPTER],
IPAdapterFaceID(model,ipadapter,image,weight,weight_faceidv2,weight_type,
combine_embeds,start_at,end_at,embeds_scaling)->[MODEL,IMAGE].
"""
from __future__ import annotations
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# Presets por defecto del IPAdapterUnifiedLoader(FaceID) segun el modo.
_DEFAULT_PRESET = {
"style": "STANDARD (medium strength)",
"faceid": "FACEID PLUS V2",
}
# weight_type por defecto: el nodo IPAdapter usa 'standard', el FaceID usa 'linear'.
_DEFAULT_WEIGHT_TYPE = {
"style": "standard",
"faceid": "linear",
}
def comfyui_build_ipadapter_workflow(
prompt: str,
ref_image: str,
*,
base_checkpoint: str,
mode: str = "style",
weight: float = 0.8,
negative: str = "",
preset: str | None = None,
weight_type: str | None = None,
start_at: float = 0.0,
end_at: float = 1.0,
weight_faceidv2: float = 1.0,
lora_strength: float = 0.6,
combine_embeds: str = "concat",
embeds_scaling: str = "V only",
provider: str = "CPU",
steps: int = 20,
cfg: float = 7.0,
width: int = 512,
height: int = 512,
seed: int = 0,
sampler_name: str = "euler",
scheduler: str = "normal",
filename_prefix: str = "ipadapter",
) -> dict:
"""Construye un workflow txt2img condicionado por una imagen de referencia.
Args:
prompt: prompt positivo (texto del resultado deseado).
ref_image: nombre del archivo de imagen de referencia en el directorio
input/ del servidor ComfyUI (lo carga un nodo LoadImage). En faceid
debe contener una cara nitida; en style es la imagen de estilo.
base_checkpoint: checkpoint SD1.5/SDXL (debe casar con los modelos
IPAdapter: usa modelos SD1.5 con checkpoints SD1.5). keyword-only.
mode: 'style' (transfiere estilo/composicion) o 'faceid' (rostro
consistente via insightface + FaceID). keyword-only.
weight: peso de la influencia IPAdapter (0..1+). 0.8 es un buen punto de
partida; sube para mas parecido, baja para mas libertad del prompt.
negative: prompt negativo.
preset: preset del UnifiedLoader. Si None usa el default del modo
('STANDARD (medium strength)' para style, 'FACEID PLUS V2' para faceid).
weight_type: tipo de ponderacion del nodo IPAdapter/FaceID. Si None usa el
default del modo ('standard' para style, 'linear' para faceid).
start_at: fraccion del sampling donde empieza a aplicar IPAdapter (0..1).
end_at: fraccion del sampling donde deja de aplicar (0..1).
weight_faceidv2: peso del embedding FaceID v2 (solo mode='faceid').
lora_strength: fuerza de la LoRA FaceID que carga el UnifiedLoaderFaceID
(solo mode='faceid').
combine_embeds: como combinar embeddings si hay varias caras
('concat'|'add'|'subtract'|'average'|'norm average'). Solo faceid.
embeds_scaling: escalado de embeddings ('V only'|'K+V'|...). Solo faceid.
provider: backend de insightface ('CPU'|'CUDA'|...). CPU por defecto para
no competir por VRAM con el modelo de difusion. Solo faceid.
steps, cfg, width, height, seed, sampler_name, scheduler, filename_prefix:
parametros de generacion que se pasan a comfyui_build_txt2img_workflow.
Returns:
dict en API format listo para comfyui_submit_workflow, con la base
txt2img + LoadImage + la rama IPAdapter del modo elegido, y el KSampler
repuntado a la salida MODEL de esa rama.
Raises:
ValueError: si mode no es 'style' ni 'faceid', si ref_image esta vacio, o
si no se puede localizar el checkpoint/KSampler en la base.
"""
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
if mode not in ("style", "faceid"):
raise ValueError(
f"comfyui_build_ipadapter_workflow: mode debe ser 'style' o 'faceid', no {mode!r}"
)
if not ref_image:
raise ValueError("comfyui_build_ipadapter_workflow: ref_image no puede estar vacio")
wf = comfyui_build_txt2img_workflow(
base_checkpoint,
prompt,
negative,
steps=steps,
cfg=cfg,
width=width,
height=height,
seed=seed,
sampler_name=sampler_name,
scheduler=scheduler,
filename_prefix=filename_prefix,
)
ckpt = next(
(nid for nid, n in wf.items() if str(n.get("class_type", "")).startswith("CheckpointLoader")),
None,
)
ksampler = next(
(nid for nid, n in wf.items() if str(n.get("class_type", "")).endswith("KSampler")),
None,
)
if ckpt is None or ksampler is None:
raise ValueError(
"comfyui_build_ipadapter_workflow: no se encontro CheckpointLoader/KSampler en la base"
)
numeric = [int(k) for k in wf.keys() if str(k).isdigit()]
base_id = (max(numeric) + 1) if numeric else len(wf) + 1
load_id = str(base_id)
loader_id = str(base_id + 1)
apply_id = str(base_id + 2)
used_preset = preset if preset is not None else _DEFAULT_PRESET[mode]
used_wtype = weight_type if weight_type is not None else _DEFAULT_WEIGHT_TYPE[mode]
# Carga la imagen de referencia (slot 0 = IMAGE).
wf[load_id] = {
"class_type": "LoadImage",
"inputs": {"image": ref_image},
}
if mode == "style":
wf[loader_id] = {
"class_type": "IPAdapterUnifiedLoader",
"inputs": {"model": [ckpt, 0], "preset": used_preset},
}
wf[apply_id] = {
"class_type": "IPAdapter",
"inputs": {
"model": [loader_id, 0],
"ipadapter": [loader_id, 1],
"image": [load_id, 0],
"weight": weight,
"start_at": start_at,
"end_at": end_at,
"weight_type": used_wtype,
},
}
else: # faceid
wf[loader_id] = {
"class_type": "IPAdapterUnifiedLoaderFaceID",
"inputs": {
"model": [ckpt, 0],
"preset": used_preset,
"lora_strength": lora_strength,
"provider": provider,
},
}
wf[apply_id] = {
"class_type": "IPAdapterFaceID",
"inputs": {
"model": [loader_id, 0],
"ipadapter": [loader_id, 1],
"image": [load_id, 0],
"weight": weight,
"weight_faceidv2": weight_faceidv2,
"weight_type": used_wtype,
"combine_embeds": combine_embeds,
"start_at": start_at,
"end_at": end_at,
"embeds_scaling": embeds_scaling,
},
}
# Repunta el KSampler para que tome el MODEL condicionado por IPAdapter.
wf[ksampler]["inputs"]["model"] = [apply_id, 0]
return wf
if __name__ == "__main__":
import json
wf_style = comfyui_build_ipadapter_workflow(
"a fantasy castle on a hill, oil painting",
"example.png",
base_checkpoint="dreamshaper_8.safetensors",
mode="style",
weight=0.8,
)
wf_face = comfyui_build_ipadapter_workflow(
"portrait of a knight in armor, cinematic",
"showcase_char.png",
base_checkpoint="dreamshaper_8.safetensors",
mode="faceid",
weight=0.9,
)
print(json.dumps({"style_nodes": list(wf_style), "faceid_nodes": list(wf_face)}, indent=2))
@@ -0,0 +1,87 @@
---
name: comfyui_build_sdxl_refiner_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_sdxl_refiner_workflow(base_ckpt: str, refiner_ckpt: str, positive: str, negative: str = \"\", *, base_steps: int = 20, refiner_steps: int = 5, cfg: float = 7.0, seed: int = 0, width: int = 1024, height: int = 1024) -> dict"
description: "Construye el dict de un workflow ComfyUI SDXL base+refiner en API format: dos KSamplerAdvanced encadenados que comparten el total de pasos. El base arranca el ruido y devuelve el latente con ruido sobrante (return_with_leftover_noise=enable), el refiner lo recoge (add_noise=disable) y lo termina. Pura, sin red ni I/O. Hermana de comfyui_build_txt2img_workflow."
tags: [comfyui, ml, image-generation, sdxl, refiner, stable-diffusion, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: base_ckpt
desc: "Nombre del checkpoint base SDXL tal como lo ve el servidor (ej. 'sd_xl_base_1.0.safetensors'). En CheckpointLoaderSimple."
- name: refiner_ckpt
desc: "Nombre del checkpoint refiner SDXL (ej. 'sd_xl_refiner_1.0.safetensors')."
- name: positive
desc: "Prompt positivo: lo que se quiere ver. Se usa para el CLIP del base y el del refiner."
- name: negative
desc: "Prompt negativo: lo que se quiere evitar. Por defecto cadena vacia."
- name: base_steps
desc: "Pasos que ejecuta el sampler base (del 0 a base_steps). keyword-only."
- name: refiner_steps
desc: "Pasos que ejecuta el refiner (de base_steps al total). El total es base_steps + refiner_steps. keyword-only."
- name: cfg
desc: "Classifier-free guidance scale (compartido por ambos samplers). keyword-only."
- name: seed
desc: "Semilla de ruido (compartida por ambos samplers). keyword-only."
- name: width
desc: "Ancho del latente/imagen en px (SDXL nativo 1024). keyword-only."
- name: height
desc: "Alto del latente/imagen en px (SDXL nativo 1024). keyword-only."
output: "dict en API format con node_ids como claves (CheckpointLoaderSimple base '4' y refiner '14', EmptyLatentImage '5', CLIPTextEncode base '6'/'7' y refiner '16'/'17', KSamplerAdvanced base '3' y refiner '15', VAEDecode '8', SaveImage '9'). Listo para comfyui_submit_workflow."
tested: true
tests: ["dos KSamplerAdvanced encadenados", "base emite ruido sobrante y refiner lo recoge (start/end_at_step compartidos)", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_sdxl_refiner_workflow.py"
file_path: "python/functions/ml/comfyui_build_sdxl_refiner_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_sdxl_refiner_workflow import comfyui_build_sdxl_refiner_workflow
wf = comfyui_build_sdxl_refiner_workflow(
base_ckpt="sd_xl_base_1.0.safetensors",
refiner_ckpt="sd_xl_refiner_1.0.safetensors",
positive="a majestic lion on a cliff at sunset, ultra detailed",
negative="blurry, low quality",
base_steps=20, refiner_steps=5,
seed=42,
)
# wf["3"]["inputs"]["steps"] == 25 # total = base + refiner
# wf["3"]["inputs"]["end_at_step"] == 20 # base corta en base_steps
# wf["15"]["inputs"]["start_at_step"] == 20 # refiner arranca ahi
# wf["15"]["inputs"]["latent_image"] == ["3", 0] # encadenado del base
```
El bloque se lanza con el python del venv. `./fn run` directo no aplica (firma con
`*` keyword-only); usa el import o un heredoc.
## Cuando usarla
Cuando uses el pipeline oficial SDXL de dos etapas (checkpoint base + checkpoint
refiner) para pulir el detalle final. Si solo tienes un checkpoint SDXL completo
(sin refiner separado) usa `comfyui_build_txt2img_workflow` con width/height 1024
— el refiner separado solo merece la pena con `sd_xl_refiner_*`.
## Gotchas
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI.
- Los dos KSamplerAdvanced comparten `steps` = base_steps + refiner_steps. El
base va de 0 a base_steps con `return_with_leftover_noise=enable` (no decodifica);
el refiner va de base_steps a 10000 (= "hasta el final") con `add_noise=disable`.
- El VAE de salida es el del refiner (`["14", 2]`). Ambos checkpoints SDXL traen
el mismo VAE, asi que el resultado no cambia; para un VAE externo cambia esa
conexion en el nodo '8'.
- SDXL es nativo a 1024x1024: bajar mucho la resolucion degrada el resultado.
- Es pura: NO valida que los checkpoints existan en el servidor. Valida antes con
`comfyui_validate_workflow` (necesitas ambos: base y refiner descargados).
@@ -0,0 +1,147 @@
"""Construye un workflow ComfyUI SDXL base+refiner en API format.
SDXL genera en dos etapas: un checkpoint base produce el latente con la mayor
parte de los pasos y un checkpoint refiner termina los ultimos pasos para pulir
el detalle. Se encadenan dos KSamplerAdvanced compartiendo el numero total de
pasos: el base arranca el ruido y devuelve el latente con ruido sobrante
(return_with_leftover_noise=enable, no decodifica), y el refiner lo recoge
(add_noise=disable) y lo lleva al final.
Cadena de nodos: CheckpointLoaderSimple base + CheckpointLoaderSimple refiner +
EmptyLatentImage + 4 CLIPTextEncode (positivo/negativo por cada CLIP) ->
KSamplerAdvanced base -> KSamplerAdvanced refiner -> VAEDecode -> SaveImage.
API format: cada clave es un node_id (string); cada nodo tiene class_type +
inputs. Las conexiones entre nodos son listas [node_id, output_index]. Es el
formato que acepta POST /prompt, distinto del formato de la UI (graph con links).
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
def comfyui_build_sdxl_refiner_workflow(
base_ckpt: str,
refiner_ckpt: str,
positive: str,
negative: str = "",
*,
base_steps: int = 20,
refiner_steps: int = 5,
cfg: float = 7.0,
seed: int = 0,
width: int = 1024,
height: int = 1024,
) -> dict:
"""Construye el dict de un workflow SDXL base+refiner (dos KSamplerAdvanced).
Args:
base_ckpt: nombre del checkpoint base SDXL tal como lo ve el servidor
ComfyUI (ej. "sd_xl_base_1.0.safetensors"). En CheckpointLoaderSimple.
refiner_ckpt: nombre del checkpoint refiner SDXL
(ej. "sd_xl_refiner_1.0.safetensors").
positive: prompt positivo (lo que se quiere ver en la imagen). Se usa
tanto para el CLIP del base como para el del refiner.
negative: prompt negativo (lo que se quiere evitar). Por defecto "".
base_steps: pasos que ejecuta el sampler base (del 0 a base_steps).
keyword-only.
refiner_steps: pasos que ejecuta el refiner (de base_steps al total).
El total de pasos es base_steps + refiner_steps. keyword-only.
cfg: classifier-free guidance scale (compartido). keyword-only.
seed: semilla de ruido (compartida por ambos samplers). keyword-only.
width: ancho del latente/imagen en px (SDXL nativo 1024). keyword-only.
height: alto del latente/imagen en px (SDXL nativo 1024). keyword-only.
Returns:
dict en API format listo para comfyui_submit_workflow. Las claves son
node_ids y cada valor tiene class_type + inputs.
"""
total_steps = base_steps + refiner_steps
return {
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": base_ckpt},
},
"14": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": refiner_ckpt},
},
"5": {
"class_type": "EmptyLatentImage",
"inputs": {"width": width, "height": height, "batch_size": 1},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": positive, "clip": ["4", 1]},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["4", 1]},
},
"16": {
"class_type": "CLIPTextEncode",
"inputs": {"text": positive, "clip": ["14", 1]},
},
"17": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["14", 1]},
},
"3": {
"class_type": "KSamplerAdvanced",
"inputs": {
"add_noise": "enable",
"noise_seed": seed,
"steps": total_steps,
"cfg": cfg,
"sampler_name": "euler",
"scheduler": "normal",
"start_at_step": 0,
"end_at_step": base_steps,
"return_with_leftover_noise": "enable",
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["5", 0],
},
},
"15": {
"class_type": "KSamplerAdvanced",
"inputs": {
"add_noise": "disable",
"noise_seed": seed,
"steps": total_steps,
"cfg": cfg,
"sampler_name": "euler",
"scheduler": "normal",
"start_at_step": base_steps,
"end_at_step": 10000,
"return_with_leftover_noise": "disable",
"model": ["14", 0],
"positive": ["16", 0],
"negative": ["17", 0],
"latent_image": ["3", 0],
},
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["15", 0], "vae": ["14", 2]},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "comfy_sdxl", "images": ["8", 0]},
},
}
if __name__ == "__main__":
import json
wf = comfyui_build_sdxl_refiner_workflow(
base_ckpt="sd_xl_base_1.0.safetensors",
refiner_ckpt="sd_xl_refiner_1.0.safetensors",
positive="a majestic lion on a cliff at sunset, ultra detailed",
negative="blurry, low quality",
base_steps=20,
refiner_steps=5,
seed=42,
)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,89 @@
---
name: comfyui_build_skill_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def build_skill_workflow(recipe: dict, subject: str, *, seed: int = 0) -> dict"
description: "Compila una receta de skill ComfyUI (dict recipe.json del grupo comfyui-skill) a un workflow en API format listo para comfyui_submit_workflow. Despacha al builder base segun recipe['base_workflow'] (txt2img|flux|sdxl_refiner), sustituye {subject} y los trigger_words en el prompt_scaffold, encadena los loras (inject_lora) y aplica los blocks de post-proceso (facedetailer, hires_fix) en orden. Pura: solo compone builders puros del registry, sin red ni I/O. base_workflow desconocido o que requiere imagen -> SkillWorkflowError."
tags: [comfyui, comfyui-skill, ml, workflow, stable-diffusion, skill]
uses_functions:
- comfyui_build_txt2img_workflow_py_ml
- comfyui_build_flux_workflow_py_ml
- comfyui_build_sdxl_refiner_workflow_py_ml
- comfyui_inject_lora_py_ml
- comfyui_build_facedetailer_workflow_py_ml
- comfyui_inject_hires_fix_py_ml
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: recipe
desc: "Dict de la receta (schema comfyui-skill). Campos usados: base_workflow (txt2img|flux|sdxl_refiner), checkpoint, loras [{name, strength_model, strength_clip}], params (steps/cfg/width/height/sampler_name/scheduler/...), prompt_scaffold (positive/negative con {subject} + trigger_words), blocks [{type, params}]."
- name: subject
desc: "El sujeto concreto que sustituye {subject} en el scaffold del prompt (ej. 'a woman with red hair')."
- name: seed
desc: "Semilla de generacion; se pasa al builder base y por defecto a cada bloque que la acepte. keyword-only."
output: "dict en API format (nodos numerados con class_type + inputs) listo para comfyui_submit_workflow."
tested: true
tests: ["golden: txt2img + 1 lora + facedetailer -> API format valido con LoraLoader + FaceDetailer y subject sustituido", "edge: sin loras ni blocks -> workflow base minimo (6 class_types)", "params seed/steps/cfg/width + trigger_words reflejados", "error: base_workflow desconocido -> SkillWorkflowError", "error: base que requiere imagen (img2img) -> SkillWorkflowError", "error: recipe no dict -> SkillWorkflowError"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_skill_workflow.py"
file_path: "python/functions/ml/comfyui_build_skill_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_skill_workflow import build_skill_workflow
recipe = {
"schema_version": 1,
"slug": "portrait_cinematic_sdxl",
"version": "1.0.0",
"base_workflow": "txt2img",
"checkpoint": "juggernaut_xl_v11.safetensors",
"loras": [{"name": "add_detail.safetensors", "strength_model": 0.6, "strength_clip": 0.6}],
"params": {"steps": 30, "cfg": 5.5, "sampler_name": "dpmpp_2m",
"scheduler": "karras", "width": 832, "height": 1216},
"prompt_scaffold": {"positive": "cinematic portrait of {subject}, sharp focus",
"negative": "blurry, lowres", "trigger_words": []},
"blocks": [{"type": "facedetailer", "params": {"denoise": 0.45}}],
}
wf = build_skill_workflow(recipe, "a woman with red hair", seed=42)
# wf tiene CheckpointLoaderSimple + KSampler + LoraLoader + FaceDetailer + SaveImage.
# Pasalo a comfyui_submit_workflow para encolarlo.
```
O lanzable directo con `./fn run comfyui_build_skill_workflow` (imprime el JSON del workflow de ejemplo).
## Cuando usarla
- Cuando tengas una **receta de skill** (de `comfyui_load_skill`) y quieras convertirla en un
workflow concreto para un `subject` dado, sin montar el grafo a mano. Es el paso "receta →
workflow" del flujo del grupo `comfyui-skill`: `load_skill``build_skill_workflow`
`submit_workflow``wait_result`.
- Cuando reutilices una configuración probada (checkpoint + LoRAs + params + post-proceso)
cambiando solo el sujeto y la semilla.
## Gotchas
- **Pura, NO valida contra el servidor**: igual que los builders hermanos, no comprueba que el
checkpoint/LoRA/modelo de upscale existan en ComfyUI. Si faltan, el servidor rechaza el
workflow con HTTP 400 al enviarlo. Valida antes con `comfyui_validate_workflow` si hace falta.
- **Solo bases de texto**: soporta `base_workflow` ∈ {`txt2img`, `flux`, `sdxl_refiner`}. Las
bases que requieren una imagen de entrada (`img2img`, `inpaint`, `controlnet`) lanzan
`SkillWorkflowError``build_skill_workflow` arranca de un `subject` de texto, no de una imagen.
- **`sdxl_refiner` requiere `params['refiner_ckpt']`**; sin él lanza `SkillWorkflowError`.
- **Flux ignora el negativo**: el builder de Flux usa un negativo vacío fijo y la guía va por
`FluxGuidance`; en Flux el campo `checkpoint` de la receta se mapea a `unet`.
- **El orden de los `blocks` importa**: se aplican secuencialmente sobre el dict. `facedetailer`
toma la imagen del `VAEDecode`; `hires_fix` re-difunde y repunta el `SaveImage`. Encadénalos en
el orden lógico (p.ej. hires_fix tras facedetailer).
- **Excepción tipada**: todos los errores de compilación son `SkillWorkflowError` (subclase de
`ValueError`), exportada por el módulo.
@@ -0,0 +1,213 @@
"""comfyui_build_skill_workflow — compila una receta de *skill* a un workflow ComfyUI.
Una **skill** es una receta versionada (el dict de `recipe.json` del grupo `comfyui-skill`) que
fija checkpoint, LoRAs, parametros de sampling, scaffold de prompt y bloques de post-proceso.
Esta funcion PURA la compila a un dict de workflow en "API format" listo para
`comfyui_submit_workflow`, componiendo los builders del registry segun `recipe['base_workflow']`.
Despacho de `base_workflow` (los que se construyen solo a partir de un `subject` de texto):
txt2img -> comfyui_build_txt2img_workflow
flux -> comfyui_build_flux_workflow
sdxl_refiner -> comfyui_build_sdxl_refiner_workflow
Bloques de post-proceso (`recipe['blocks']`, aplicados en orden sobre el dict resultante):
facedetailer -> comfyui_build_facedetailer_workflow (encadena sobre el dict)
hires_fix -> comfyui_inject_hires_fix (encadena sobre el dict)
Pasos: scaffold de prompt ({subject} + trigger_words) -> builder base -> LoRAs (inject_lora) ->
bloques (en orden). Los `base_workflow` que necesitan una imagen de entrada (img2img, inpaint,
controlnet) NO se soportan aqui `build_skill_workflow` arranca de texto.
Funcion pura: sin red, sin I/O. Solo compone builders puros del registry.
"""
from __future__ import annotations
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
class SkillWorkflowError(ValueError):
"""Error tipado al compilar una receta de skill (base_workflow desconocido,
recipe invalida, bloque no soportado, dependencia ausente)."""
_IMAGE_INPUT_BASES = {"img2img", "inpaint", "controlnet"}
def _pick(params: dict, keys) -> dict:
"""Subconjunto de `params` con solo las claves presentes en `keys`."""
return {k: params[k] for k in keys if k in params}
def _scaffold_prompts(recipe: dict, subject: str) -> tuple[str, str]:
"""Sustituye `{subject}` y antepone los trigger_words en el scaffold de prompt.
Devuelve (positive, negative). Si no hay `prompt_scaffold`, usa el subject como
positivo y "" como negativo.
"""
scaffold = recipe.get("prompt_scaffold") or {}
positive = str(scaffold.get("positive", "") or "")
negative = str(scaffold.get("negative", "") or "")
if "{subject}" in positive:
positive = positive.replace("{subject}", subject)
elif not positive:
positive = subject
else:
# scaffold sin placeholder: el subject se antepone para no perderlo.
positive = f"{subject}, {positive}"
negative = negative.replace("{subject}", subject)
triggers = scaffold.get("trigger_words") or []
if triggers:
positive = ", ".join(list(triggers) + [positive]) if positive else ", ".join(triggers)
return positive, negative
def _build_base(recipe: dict, positive: str, negative: str, seed: int) -> dict:
"""Despacha al builder base del registry segun `recipe['base_workflow']`."""
base = recipe.get("base_workflow")
params = dict(recipe.get("params") or {})
ckpt = recipe.get("checkpoint", "")
if base == "txt2img":
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
if not ckpt:
raise SkillWorkflowError("base_workflow txt2img requiere recipe['checkpoint']")
kw = _pick(params, ("steps", "cfg", "width", "height", "sampler_name", "scheduler"))
return comfyui_build_txt2img_workflow(ckpt, positive, negative, seed=seed, **kw)
if base == "flux":
from ml.comfyui_build_flux_workflow import comfyui_build_flux_workflow
kw = _pick(params, ("clip_l", "t5xxl", "vae", "width", "height", "steps",
"guidance", "weight_dtype", "sampler_name", "scheduler"))
# En Flux el "checkpoint" de la receta es el modelo de difusion (unet).
if ckpt:
kw.setdefault("unet", ckpt)
return comfyui_build_flux_workflow(positive, seed=seed, **kw)
if base == "sdxl_refiner":
from ml.comfyui_build_sdxl_refiner_workflow import comfyui_build_sdxl_refiner_workflow
refiner = params.get("refiner_ckpt")
if not ckpt or not refiner:
raise SkillWorkflowError(
"base_workflow sdxl_refiner requiere recipe['checkpoint'] (base) y "
"recipe['params']['refiner_ckpt']")
kw = _pick(params, ("base_steps", "refiner_steps", "cfg", "width", "height"))
return comfyui_build_sdxl_refiner_workflow(ckpt, refiner, positive, negative, seed=seed, **kw)
if base in _IMAGE_INPUT_BASES:
raise SkillWorkflowError(
f"base_workflow {base!r} requiere una imagen de entrada; build_skill_workflow "
"compila a partir de un subject de texto. Construye ese workflow aparte.")
raise SkillWorkflowError(
f"base_workflow desconocido: {base!r}. Soportados: txt2img, flux, sdxl_refiner.")
def _apply_loras(workflow: dict, loras) -> dict:
"""Encadena los LoRAs de la receta via comfyui_inject_lora (en orden)."""
if not loras:
return workflow
from ml.comfyui_inject_lora import comfyui_inject_lora
wf = workflow
for lora in loras:
name = lora.get("name")
if not name:
raise SkillWorkflowError("cada lora de la receta necesita 'name'")
wf = comfyui_inject_lora(
wf,
name,
strength_model=lora.get("strength_model", 1.0),
strength_clip=lora.get("strength_clip", 1.0),
)
return wf
def _apply_block(workflow: dict, block: dict, recipe: dict, positive: str,
negative: str, seed: int) -> dict:
"""Aplica un bloque de post-proceso sobre el workflow (facedetailer | hires_fix)."""
btype = block.get("type")
bparams = dict(block.get("params") or {})
if btype == "facedetailer":
from ml.comfyui_build_facedetailer_workflow import comfyui_build_facedetailer_workflow
bparams.setdefault("seed", seed)
return comfyui_build_facedetailer_workflow(
workflow, recipe.get("checkpoint", ""), positive, negative, **bparams)
if btype == "hires_fix":
try:
from ml.comfyui_inject_hires_fix import comfyui_inject_hires_fix
except ImportError as exc:
raise SkillWorkflowError(
"bloque hires_fix requiere comfyui_inject_hires_fix_py_ml (no disponible)") from exc
bparams.setdefault("seed", seed)
return comfyui_inject_hires_fix(workflow, **bparams)
raise SkillWorkflowError(
f"bloque de tipo desconocido: {btype!r}. Soportados: facedetailer, hires_fix.")
def build_skill_workflow(recipe: dict, subject: str, *, seed: int = 0) -> dict:
"""Compila una receta de skill a un workflow ComfyUI en API format.
Args:
recipe: dict de la receta (schema `comfyui-skill`). Campos usados:
`base_workflow` (txt2img|flux|sdxl_refiner), `checkpoint`, `loras`,
`params`, `prompt_scaffold` (`positive`/`negative` con `{subject}` +
`trigger_words`), `blocks` (lista de `{type, params}`).
subject: el sujeto concreto que sustituye `{subject}` en el scaffold del
prompt (p.ej. "a woman with red hair").
seed: semilla de generacion; se pasa al builder base y por defecto a cada
bloque que la acepte. keyword-only.
Returns:
dict en API format listo para `comfyui_submit_workflow`.
Raises:
SkillWorkflowError: si `base_workflow` es desconocido o necesita imagen, si
la receta no es un dict valido, si falta un checkpoint requerido, o si un
bloque es de tipo no soportado / su dependencia no esta disponible.
"""
if not isinstance(recipe, dict):
raise SkillWorkflowError(f"recipe debe ser dict, no {type(recipe).__name__}")
positive, negative = _scaffold_prompts(recipe, subject)
workflow = _build_base(recipe, positive, negative, seed)
workflow = _apply_loras(workflow, recipe.get("loras"))
for block in (recipe.get("blocks") or []):
if not isinstance(block, dict):
raise SkillWorkflowError(f"cada block debe ser dict, no {type(block).__name__}")
workflow = _apply_block(workflow, block, recipe, positive, negative, seed)
return workflow
# Alias con el nombre completo del ID para descubrimiento por convencion.
comfyui_build_skill_workflow = build_skill_workflow
if __name__ == "__main__":
import json
demo = {
"schema_version": 1,
"slug": "portrait_demo",
"version": "1.0.0",
"base_workflow": "txt2img",
"checkpoint": "juggernaut_xl_v11.safetensors",
"loras": [{"name": "add_detail.safetensors", "strength_model": 0.6, "strength_clip": 0.6}],
"params": {"steps": 30, "cfg": 5.5, "sampler_name": "dpmpp_2m",
"scheduler": "karras", "width": 832, "height": 1216},
"prompt_scaffold": {"positive": "cinematic portrait of {subject}, sharp focus",
"negative": "blurry, lowres", "trigger_words": []},
"blocks": [{"type": "facedetailer", "params": {"denoise": 0.45}}],
}
wf = build_skill_workflow(demo, "a woman with red hair", seed=42)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,86 @@
---
name: comfyui_build_textured_3d_multiview_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_textured_3d_multiview_workflow(image_name: str, *, ckpt: str = \"hunyuan3d-dit-v2-mv.safetensors\", views: int = 6, octree: int = 384, max_faces: int = 50000, upscale_model: str = \"4x_foolhardy_Remacri.pth\") -> dict"
description: "Construye el dict (API format) del pipeline imagen->malla 3D texturizada PBR multi-vista de ComfyUI via el wrapper Hunyuan3DWrapper (kijai). Cadena: LoadImage -> Hy3DModelLoader -> Hy3DGenerateMesh -> Hy3DVAEDecode(octree) -> Hy3DPostprocessMesh(max_faces) -> Hy3DMeshUVWrap -> Hy3DCameraConfig(4 o 6 vistas) + Hy3DRenderMultiView + Hy3DDelightImage -> Hy3DSampleMultiView -> [UpscaleModelLoader+ImageUpscaleWithModel(Remacri)+ImageResize+] -> Hy3DBakeFromMultiview -> Hy3DMeshVerticeInpaintTexture -> Hy3DApplyTexture -> Hy3DExportMesh(glb). Portado del report 0082 (cobertura de atlas 32.93% con 6 vistas + Remacri + octree 384). Pura, sin red ni I/O."
tags: [comfyui, ml, img-to-3d, texture, multiview, hunyuan3d, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: image_name
desc: "Nombre del archivo de imagen de referencia tal como lo ve el servidor ComfyUI en su carpeta input/ (subido con POST /upload/image)."
- name: ckpt
desc: "Checkpoint del modelo de forma Hunyuan3D para Hy3DModelLoader. Por defecto el variante multi-vista hunyuan3d-dit-v2-mv. keyword-only."
- name: views
desc: "Numero de vistas de camara: 4 (front/left/back/right) o 6 (anade top/bottom, rellena concavidades). Otro valor lanza ValueError. keyword-only."
- name: octree
desc: "octree_resolution del Hy3DVAEDecode (mas alto = malla mas fina, mas VRAM; 384 en el report 0082). keyword-only."
- name: max_faces
desc: "max_facenum del Hy3DPostprocessMesh (decimacion; 50000 en el report 0082). keyword-only."
- name: upscale_model
desc: "Modelo de upscale ESRGAN en upscale_models/ para mejorar las vistas antes del bake (factor dominante de cobertura). Cadena vacia desactiva el upscale. keyword-only."
output: "dict en API format listo para comfyui_submit_workflow. node_ids '1'..'19'; los nodos de upscale ('13'..'15') solo presentes si upscale_model esta activo. El SaveGLB-equivalente Hy3DExportMesh produce un .glb texturizado en output/3D/."
tested: true
tests: ["estructura completa shape+paint+upscale (18 class_types)", "params imagen/ckpt/octree/max_faces reflejados", "6 vistas configuran 6 azimuths/elevations", "4 vistas configuran 4 azimuths", "sin upscale omite nodos Remacri y el bake toma del sample", "views invalido lanza ValueError", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_textured_3d_multiview_workflow.py"
file_path: "python/functions/ml/comfyui_build_textured_3d_multiview_workflow.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_textured_3d_multiview_workflow import (
comfyui_build_textured_3d_multiview_workflow,
)
wf = comfyui_build_textured_3d_multiview_workflow(
"tex_src_character.png", views=6, octree=384, max_faces=50000,
upscale_model="4x_foolhardy_Remacri.pth",
)
# wf["9"]["class_type"] == "Hy3DCameraConfig" (6 vistas)
# wf["19"]["class_type"] == "Hy3DExportMesh" (.glb texturizado)
# OJO: en 8GB ejecutar en 2 fases (ver Gotchas), no de una pasada
```
O lanzable directo con: `./fn run comfyui_build_textured_3d_multiview_workflow` (imprime el JSON del workflow de ejemplo).
## Cuando usarla
Cuando quieras una malla 3D **con textura** desde una sola imagen, con mejor
cobertura de atlas que el image-to-3D nativo (que da geometria sin pintar). Es el
builder del pipeline de texturizado multi-vista del report 0082: 6 vistas de
camara + delight + sample multi-vista + upscale Remacri de las vistas + bake sobre
el UV. Para geometria sin textura usa `comfyui_build_image_to_3d_workflow`
(nodos nativos, mas ligero).
## Gotchas
- **Ejecutar en 2 fases en 8GB**: el grafo es monolitico (shape + paint en un
dict) por claridad, pero el grafo entero da OOM en 8GB (confirmado reports
0075/0081/0082). El camino valido es: ejecutar la fase shape (nodos 1-5 ->
Hy3DExportMesh del shape), liberar VRAM con `POST /free`, y luego la fase paint
arrancando desde `Hy3DLoadMesh` del .glb del shape. La separacion + el /free los
orquesta el pipeline impuro que consuma este builder; este dict es la referencia
de cableado completo.
- Requiere el custom node **ComfyUI-Hunyuan3DWrapper** (kijai) + `custom_rasterizer`
CUDA compilado, **ComfyUI_essentials** (para `ImageResize+`) y el modelo
`4x_foolhardy_Remacri.pth` en `upscale_models/`. Si falta algo, ComfyUI rechaza
el workflow con HTTP 400 (esta funcion es pura y no valida contra el servidor).
- `ckpt` por defecto es el variante multi-vista (`-mv`). El report 0082 uso
`hy3dgen/hunyuan3d-dit-v2-0-fp16.safetensors`; ajusta `ckpt` al nombre real que
el servidor enumere en Hy3DModelLoader.
- `upscale_model=""` desactiva el upscale: el bake toma las vistas directas del
Hy3DSampleMultiView. Pierde la mejora dominante de cobertura (el report midio
20.81% -> 32.93% al cablear Remacri en serie).
- Render bonito del GLB no disponible headless; verificar con `Load3D`/`Preview3D`
en la UI de ComfyUI o el visor de `apps/img_to_3d_webapp`.
@@ -0,0 +1,241 @@
"""Construye un workflow ComfyUI imagen->malla 3D texturizada multi-vista (API format).
Usa el wrapper ComfyUI-Hunyuan3DWrapper (kijai): genera la geometria con
Hy3DGenerateMesh/Hy3DVAEDecode, la limpia y le hace UV unwrap, renderiza N vistas
de camara, sintetiza la textura multi-vista (Hy3DSampleMultiView) opcionalmente
mejorada con un upscaler ESRGAN (Remacri), la hornea sobre el atlas UV
(Hy3DBakeFromMultiview), rellena los huecos por vertices y exporta el GLB con
material PBR. Portado del pipeline validado en el report 0082 (cobertura de atlas
32.93 % con 6 vistas + Remacri + octree 384).
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
IMPORTANTE: el grafo es monolitico (shape + paint en un solo dict) por claridad,
pero en 8 GB de VRAM debe ejecutarse en 2 fases (shape -> /free -> paint), no de
una pasada. La separacion en fases y el /free los orquesta el pipeline impuro que
consuma este builder. Ver la seccion Gotchas del .md.
"""
# Vistas de camara soportadas: tabla (azimuths, elevations, weights) por numero de vistas.
# 4 = front/left/back/right; 6 anade top/bottom (rellena concavidades que 4 camaras no ven).
_CAMERA_PRESETS = {
4: {
"camera_azimuths": "0, 90, 180, 270",
"camera_elevations": "0, 0, 0, 0",
"view_weights": "1, 0.1, 0.5, 0.1",
},
6: {
"camera_azimuths": "0, 90, 180, 270, 0, 180",
"camera_elevations": "0, 0, 0, 0, 90, -90",
"view_weights": "1, 0.1, 0.5, 0.1, 0.05, 0.05",
},
}
def comfyui_build_textured_3d_multiview_workflow(
image_name: str,
*,
ckpt: str = "hunyuan3d-dit-v2-mv.safetensors",
views: int = 6,
octree: int = 384,
max_faces: int = 50000,
upscale_model: str = "4x_foolhardy_Remacri.pth",
) -> dict:
"""Construye el dict del workflow imagen->3D texturizado multi-vista.
Args:
image_name: nombre del archivo de imagen de referencia tal como lo ve el
servidor ComfyUI en su carpeta input/ (subido con POST /upload/image).
ckpt: checkpoint del modelo de forma Hunyuan3D para Hy3DModelLoader (por
defecto el variante multi-vista hunyuan3d-dit-v2-mv). keyword-only.
views: numero de vistas de camara: 4 (front/left/back/right) o 6 (anade
top/bottom). Cualquier otro valor lanza ValueError. keyword-only.
octree: octree_resolution del Hy3DVAEDecode (mas alto = malla mas fina,
mas VRAM). keyword-only.
max_faces: max_facenum del Hy3DPostprocessMesh (decimacion de la malla).
keyword-only.
upscale_model: nombre del modelo de upscale ESRGAN en upscale_models/ para
mejorar las vistas antes del bake. Cadena vacia o None desactiva el
upscale (el bake toma las vistas directas del sample). keyword-only.
Returns:
dict en API format listo para comfyui_submit_workflow. node_ids "1".."19"
(los de upscale "13".."15" solo presentes si upscale_model esta activo).
Raises:
ValueError: si views no es 4 ni 6.
"""
if views not in _CAMERA_PRESETS:
raise ValueError(
f"comfyui_build_textured_3d_multiview_workflow: views debe ser 4 o 6, "
f"no {views!r}"
)
cam = _CAMERA_PRESETS[views]
wf = {
# --- Fase shape: imagen -> malla limpia con UV ---
"1": {
"class_type": "LoadImage",
"inputs": {"image": image_name},
},
"2": {
"class_type": "Hy3DModelLoader",
"inputs": {"model": ckpt, "attention_mode": "sdpa", "cublas_ops": False},
},
"3": {
"class_type": "Hy3DGenerateMesh",
"inputs": {
"pipeline": ["2", 0],
"image": ["1", 0],
"guidance_scale": 5.5,
"steps": 30,
"seed": 42,
"force_offload": True,
},
},
"4": {
"class_type": "Hy3DVAEDecode",
"inputs": {
"vae": ["2", 1],
"latents": ["3", 0],
"box_v": 1.01,
"octree_resolution": octree,
"num_chunks": 8000,
"mc_level": 0,
"mc_algo": "mc",
"enable_flash_vdm": True,
"force_offload": True,
},
},
"5": {
"class_type": "Hy3DPostprocessMesh",
"inputs": {
"trimesh": ["4", 0],
"remove_floaters": True,
"remove_degenerate_faces": True,
"reduce_faces": True,
"max_facenum": max_faces,
"smooth_normals": False,
},
},
"6": {
"class_type": "Hy3DMeshUVWrap",
"inputs": {"trimesh": ["5", 0]},
},
# --- Fase paint: render multi-vista + delight + sample + bake + textura ---
"7": {
"class_type": "DownloadAndLoadHy3DPaintModel",
"inputs": {"model": "hunyuan3d-paint-v2-0"},
},
"8": {
"class_type": "DownloadAndLoadHy3DDelightModel",
"inputs": {"model": "hunyuan3d-delight-v2-0"},
},
"9": {
"class_type": "Hy3DCameraConfig",
"inputs": {
"camera_azimuths": cam["camera_azimuths"],
"camera_elevations": cam["camera_elevations"],
"view_weights": cam["view_weights"],
"camera_distance": 1.45,
"ortho_scale": 1.2,
},
},
"10": {
"class_type": "Hy3DRenderMultiView",
"inputs": {
"trimesh": ["6", 0],
"render_size": 1024,
"texture_size": 1024,
"camera_config": ["9", 0],
"normal_space": "world",
},
},
"11": {
"class_type": "Hy3DDelightImage",
"inputs": {
"delight_pipe": ["8", 0],
"image": ["1", 0],
"steps": 50,
"width": 512,
"height": 512,
"cfg_image": 1.0,
"seed": 42,
},
},
"12": {
"class_type": "Hy3DSampleMultiView",
"inputs": {
"pipeline": ["7", 0],
"ref_image": ["11", 0],
"normal_maps": ["10", 0],
"position_maps": ["10", 1],
"view_size": 512,
"steps": 25,
"seed": 0,
"camera_config": ["9", 0],
},
},
}
# Upscale opcional de los multiviews antes del bake (factor dominante de cobertura).
if upscale_model:
wf["13"] = {
"class_type": "UpscaleModelLoader",
"inputs": {"model_name": upscale_model},
}
wf["14"] = {
"class_type": "ImageUpscaleWithModel",
"inputs": {"upscale_model": ["13", 0], "image": ["12", 0]},
}
wf["15"] = {
"class_type": "ImageResize+",
"inputs": {
"image": ["14", 0],
"width": 1024,
"height": 1024,
"interpolation": "lanczos",
"method": "stretch",
"condition": "always",
"multiple_of": 0,
},
}
bake_images = ["15", 0]
else:
bake_images = ["12", 0]
wf["16"] = {
"class_type": "Hy3DBakeFromMultiview",
"inputs": {
"images": bake_images,
"renderer": ["10", 2],
"camera_config": ["9", 0],
},
}
wf["17"] = {
"class_type": "Hy3DMeshVerticeInpaintTexture",
"inputs": {"texture": ["16", 0], "mask": ["16", 1], "renderer": ["16", 2]},
}
wf["18"] = {
"class_type": "Hy3DApplyTexture",
"inputs": {"texture": ["17", 0], "renderer": ["17", 2]},
}
wf["19"] = {
"class_type": "Hy3DExportMesh",
"inputs": {
"trimesh": ["18", 0],
"filename_prefix": "3D/textured_multiview",
"file_format": "glb",
"save_file": True,
},
}
return wf
if __name__ == "__main__":
import json
wf = comfyui_build_textured_3d_multiview_workflow(
"tex_src_character.png", views=6, octree=384, max_faces=50000
)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,87 @@
---
name: comfyui_build_txt2img_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_txt2img_workflow(ckpt_name: str, positive: str, negative: str = \"\", *, steps: int = 20, cfg: float = 7.0, width: int = 512, height: int = 512, seed: int = 0, sampler_name: str = \"euler\", scheduler: str = \"normal\", filename_prefix: str = \"comfy\") -> dict"
description: "Construye el dict de un workflow ComfyUI txt2img en API format (nodos numerados con class_type + inputs, conexiones como [node_id, output_index]) para SD1.5/SDXL: CheckpointLoaderSimple -> CLIPTextEncode x2 + EmptyLatentImage -> KSampler -> VAEDecode -> SaveImage. Pura, sin red ni I/O."
tags: [comfyui, ml, image-generation, txt2img, stable-diffusion, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: ckpt_name
desc: "Nombre del checkpoint tal como lo ve el servidor ComfyUI (ej. 'v1-5-pruned-emaonly-fp16.safetensors'). Debe estar en la lista que devuelve comfyui_object_info para CheckpointLoaderSimple."
- name: positive
desc: "Prompt positivo: lo que se quiere ver en la imagen."
- name: negative
desc: "Prompt negativo: lo que se quiere evitar. Por defecto cadena vacia."
- name: steps
desc: "Pasos de sampling del KSampler. keyword-only."
- name: cfg
desc: "Classifier-free guidance scale. keyword-only."
- name: width
desc: "Ancho del latente/imagen en px, multiplo de 8. keyword-only."
- name: height
desc: "Alto del latente/imagen en px, multiplo de 8. keyword-only."
- name: seed
desc: "Semilla del KSampler. 0 es determinista; cambiar para variar la imagen. keyword-only."
- name: sampler_name
desc: "Nombre del sampler (ej. 'euler', 'dpmpp_2m'). keyword-only."
- name: scheduler
desc: "Scheduler del sampler (ej. 'normal', 'karras'). keyword-only."
- name: filename_prefix
desc: "Prefijo del PNG que SaveImage escribe en output/. keyword-only."
output: "dict en API format con node_ids '3'..'9' como claves; cada valor tiene class_type + inputs. Listo para comfyui_submit_workflow."
tested: true
tests: ["class_types esperados (6 nodos)", "params seed/steps/cfg/width/height reflejados", "filename_prefix en SaveImage", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_txt2img_workflow.py"
file_path: "python/functions/ml/comfyui_build_txt2img_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_txt2img_workflow import comfyui_build_txt2img_workflow
wf = comfyui_build_txt2img_workflow(
ckpt_name="v1-5-pruned-emaonly-fp16.safetensors",
positive="a red apple on a wooden table, sharp focus",
negative="blurry, low quality",
steps=20,
seed=42,
)
# wf["3"]["class_type"] == "KSampler"
# wf["3"]["inputs"]["model"] == ["4", 0] # conexion al CheckpointLoader
# wf["9"]["class_type"] == "SaveImage"
```
O lanzable directo con: `./fn run comfyui_build_txt2img_workflow` (imprime el JSON del workflow de ejemplo).
## Cuando usarla
Antes de enviar una generacion txt2img a ComfyUI: construye aqui el dict del
workflow y pasalo a `comfyui_submit_workflow`. Usala siempre que necesites un
txt2img basico sin tener que escribir el grafo de nodos a mano. Para workflows
mas complejos (img2img, ControlNet, upscalers) construye el dict tu mismo o
extiende esta funcion con un builder hermano.
## Gotchas
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI (graph con
links). No se puede pegar en la UI tal cual; es el formato que acepta POST
/prompt.
- `ckpt_name` debe coincidir EXACTAMENTE con un checkpoint visible para el
servidor. Si no existe, ComfyUI rechaza el workflow con HTTP 400 al enviarlo
(no aqui — esta funcion es pura y no valida contra el servidor).
- `width`/`height` deben ser multiplos de 8 o el KSampler fallara en el
servidor.
- Asume que el checkpoint trae VAE embebido (VAEDecode usa `["4", 2]`, la salida
VAE del CheckpointLoaderSimple). Para un VAE externo cambia esa conexion.
@@ -0,0 +1,103 @@
"""Construye un workflow ComfyUI txt2img en "API format" (dict de nodos numerados).
API format: cada clave es un node_id (string); cada nodo tiene class_type +
inputs. Las conexiones entre nodos son listas [node_id, output_index]. Este es
el formato que acepta POST /prompt, distinto del formato de la UI (graph con
links explicitos).
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
def comfyui_build_txt2img_workflow(
ckpt_name: str,
positive: str,
negative: str = "",
*,
steps: int = 20,
cfg: float = 7.0,
width: int = 512,
height: int = 512,
seed: int = 0,
sampler_name: str = "euler",
scheduler: str = "normal",
filename_prefix: str = "comfy",
) -> dict:
"""Construye el dict del workflow txt2img basico para SD1.5 / SDXL.
Cadena de nodos: CheckpointLoaderSimple -> CLIPTextEncode (positivo y
negativo) + EmptyLatentImage -> KSampler -> VAEDecode -> SaveImage.
Args:
ckpt_name: nombre del checkpoint tal como lo ve el servidor ComfyUI
(ej. "v1-5-pruned-emaonly-fp16.safetensors"). Debe estar entre los
que devuelve comfyui_object_info en CheckpointLoaderSimple.
positive: prompt positivo (lo que se quiere ver en la imagen).
negative: prompt negativo (lo que se quiere evitar). Por defecto "".
steps: pasos de sampling del KSampler.
cfg: classifier-free guidance scale.
width: ancho del latente/imagen en px (multiplo de 8).
height: alto del latente/imagen en px (multiplo de 8).
seed: semilla del KSampler (0 = determinista; cambia para variar).
sampler_name: nombre del sampler (ej. "euler", "dpmpp_2m").
scheduler: scheduler del sampler (ej. "normal", "karras").
filename_prefix: prefijo del PNG generado por SaveImage en output/.
Returns:
dict en API format listo para comfyui_submit_workflow. Las claves son
node_ids ("3".."9") y cada valor tiene class_type + inputs.
"""
return {
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": ckpt_name},
},
"5": {
"class_type": "EmptyLatentImage",
"inputs": {"width": width, "height": height, "batch_size": 1},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": positive, "clip": ["4", 1]},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["4", 1]},
},
"3": {
"class_type": "KSampler",
"inputs": {
"seed": seed,
"steps": steps,
"cfg": cfg,
"sampler_name": sampler_name,
"scheduler": scheduler,
"denoise": 1.0,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["5", 0],
},
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": filename_prefix, "images": ["8", 0]},
},
}
if __name__ == "__main__":
import json
wf = comfyui_build_txt2img_workflow(
ckpt_name="v1-5-pruned-emaonly-fp16.safetensors",
positive="a red apple on a wooden table, sharp focus",
negative="blurry, low quality",
steps=20,
seed=42,
)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,68 @@
---
name: comfyui_build_upscale_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_upscale_workflow(image: str, *, model_name: str = \"4x-UltraSharp.pth\", method: str = \"model\") -> dict"
description: "Construye el dict de un workflow ComfyUI de upscale en API format. method='model' usa UpscaleModelLoader + ImageUpscaleWithModel (ESRGAN, alta calidad); method='latent' usa ImageScaleBy (reescalado de pixel x2 sin modelo). Pura, sin red ni I/O."
tags: [comfyui, ml, upscale, esrgan, stable-diffusion, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: image
desc: "Nombre del archivo de imagen dentro de la carpeta input/ del servidor ComfyUI; lo carga el nodo LoadImage."
- name: model_name
desc: "Nombre del modelo de upscale en models/upscale_models/ (ej. '4x-UltraSharp.pth'). Solo se usa con method='model'. keyword-only."
- name: method
desc: "'model' (ESRGAN via UpscaleModelLoader + ImageUpscaleWithModel) o 'latent' (reescalado de pixel x2 con ImageScaleBy, sin modelo). keyword-only."
output: "dict en API format. Con method='model': LoadImage '10' + UpscaleModelLoader '12' + ImageUpscaleWithModel '13' + SaveImage '9'. Con method='latent': LoadImage '10' + ImageScaleBy '13' + SaveImage '9'. Listo para comfyui_submit_workflow."
tested: true
tests: ["method='model' usa UpscaleModelLoader+ImageUpscaleWithModel", "method='latent' usa ImageScaleBy sin modelo", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_upscale_workflow.py"
file_path: "python/functions/ml/comfyui_build_upscale_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_upscale_workflow import comfyui_build_upscale_workflow
# Upscale con modelo ESRGAN (necesita el .pth en models/upscale_models/)
wf = comfyui_build_upscale_workflow("render.png", model_name="4x-UltraSharp.pth")
# wf["12"]["class_type"] == "UpscaleModelLoader"
# wf["13"]["inputs"]["upscale_model"] == ["12", 0]
# Upscale rapido sin modelo (reescalado de pixel x2)
wf_latent = comfyui_build_upscale_workflow("render.png", method="latent")
# wf_latent["13"]["class_type"] == "ImageScaleBy"
```
Lánzalo con el python del venv (import de arriba o heredoc). Nota: `./fn run` directo no aplica porque la firma usa `*` (keyword-only), no soportado por el generador de runner de `fn run`.
## Cuando usarla
Cuando quieras ampliar una imagen ya generada. Usa `method="model"` (ESRGAN) para
mejor calidad si tienes un upscaler en `models/upscale_models/` (ej. 4x-UltraSharp);
usa `method="latent"` para un reescalado rapido sin descargar nada. Pega la salida
de un txt2img/img2img como `image` en el input/ del servidor.
## Gotchas
- `method="latent"` NO es un upscale en espacio latente real (eso requiere un
checkpoint+VAE para encode/decode, que esta firma no recibe). Usa `ImageScaleBy`
= reescalado de pixel con lanczos x2. Es honesto: barato y sin modelo, pero no
recupera detalle como un ESRGAN. Para latent-upscale real construye un workflow
con checkpoint + VAEEncode + LatentUpscale + VAEDecode.
- Con `method="model"`, `model_name` debe existir en `models/upscale_models/`. Si
no, ComfyUI rechaza el workflow al enviarlo (HTTP 400). Valida antes con
`comfyui_validate_workflow`.
- `image` debe existir en la carpeta `input/` del servidor.
- Es pura: no valida contra el servidor.
@@ -0,0 +1,87 @@
"""Construye un workflow ComfyUI de upscale en API format (dict de nodos numerados).
Dos modos:
- method="model": upscale con modelo ESRGAN (UpscaleModelLoader +
ImageUpscaleWithModel). Calidad alta; necesita un modelo en
models/upscale_models/ (ej. "4x-UltraSharp.pth").
- method="latent": reescalado en espacio de pixel con ImageScaleBy (x2, sin
modelo ni checkpoint). Upscale rapido y barato.
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
def comfyui_build_upscale_workflow(
image: str,
*,
model_name: str = "4x-UltraSharp.pth",
method: str = "model",
) -> dict:
"""Construye el dict de un workflow de upscale para una imagen cargada.
Args:
image: nombre del archivo de imagen dentro de la carpeta input/ del
servidor ComfyUI (lo que carga el nodo LoadImage).
model_name: nombre del modelo de upscale en models/upscale_models/
(ej. "4x-UltraSharp.pth"). Solo se usa con method="model".
keyword-only.
method: "model" (ESRGAN via UpscaleModelLoader + ImageUpscaleWithModel)
o "latent" (reescalado de pixel x2 con ImageScaleBy, sin modelo).
keyword-only.
Returns:
dict en API format listo para comfyui_submit_workflow.
Raises:
ValueError: si method no es "model" ni "latent".
"""
if method not in ("model", "latent"):
raise ValueError(
f"comfyui_build_upscale_workflow: method invalido {method!r}; "
"usa 'model' o 'latent'."
)
load = {
"10": {"class_type": "LoadImage", "inputs": {"image": image}},
}
if method == "model":
return {
**load,
"12": {
"class_type": "UpscaleModelLoader",
"inputs": {"model_name": model_name},
},
"13": {
"class_type": "ImageUpscaleWithModel",
"inputs": {"upscale_model": ["12", 0], "image": ["10", 0]},
},
"9": {
"class_type": "SaveImage",
"inputs": {
"filename_prefix": "comfy_upscale",
"images": ["13", 0],
},
},
}
# method == "latent": reescalado de pixel x2 sin modelo
return {
**load,
"13": {
"class_type": "ImageScaleBy",
"inputs": {
"upscale_method": "lanczos",
"scale_by": 2.0,
"image": ["10", 0],
},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "comfy_upscale", "images": ["13", 0]},
},
}
if __name__ == "__main__":
import json
print(json.dumps(comfyui_build_upscale_workflow("example.png"), indent=2))
print(json.dumps(comfyui_build_upscale_workflow("example.png", method="latent"), indent=2))
@@ -0,0 +1,90 @@
---
name: comfyui_build_video_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_video_workflow(prompt: str, *, model: str = \"ltx\", negative: str = \"\", width: int = 512, height: int = 320, num_frames: int = 65, steps: int = 20, seed: int = 0, fps: int = 24) -> dict"
description: "Construye el dict de un workflow ComfyUI txt2video en API format para LTX-Video 2B v0.9.5 (model='ltx') o Wan2.1 T2V 1.3B (model='wan'), con los nombres de modelo reales. LTX: CLIPLoader(ltxv)+CheckpointLoaderSimple -> CLIPTextEncode x2 -> LTXVConditioning+EmptyLTXVLatentVideo+LTXVScheduler+KSamplerSelect -> SamplerCustom -> VAEDecode -> CreateVideo -> SaveVideo. Wan: UNETLoader+CLIPLoader(wan)+VAELoader+ModelSamplingSD3 -> CLIPTextEncode x2+EmptyHunyuanLatentVideo -> KSampler(uni_pc/simple) -> VAEDecode -> CreateVideo -> SaveVideo. Defaults conservadores para 8GB. Pura, sin red ni I/O. Hermana de comfyui_build_txt2img_workflow."
tags: [comfyui, ml, video-generation, txt2video, ltx-video, wan, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: prompt
desc: "Prompt positivo: lo que se quiere ver en el clip de video."
- name: model
desc: "'ltx' (LTX-Video 2B v0.9.5, todo-en-uno) o 'wan' (Wan2.1 T2V 1.3B, diffusion+vae aparte). Cualquier otro valor lanza ValueError. keyword-only."
- name: negative
desc: "Prompt negativo: lo que se quiere evitar. Por defecto cadena vacia. keyword-only."
- name: width
desc: "Ancho del video en px (multiplo de 32 recomendado). keyword-only."
- name: height
desc: "Alto del video en px (multiplo de 32 recomendado). keyword-only."
- name: num_frames
desc: "Numero de frames del clip (longitud temporal del latente de video). keyword-only."
- name: steps
desc: "Pasos de sampling: LTXVScheduler para ltx, KSampler para wan. keyword-only."
- name: seed
desc: "Semilla del sampler. 0 es determinista; cambiar para variar el clip. keyword-only."
- name: fps
desc: "Frames por segundo del video (CreateVideo). En LTX se usa tambien como frame_rate del LTXVConditioning. keyword-only."
output: "dict en API format listo para comfyui_submit_workflow. node_ids string; cada valor con class_type + inputs. LTX devuelve 12 nodos; Wan 11. La cfg/sampler/scheduler se fijan internamente segun el modelo (LTX: cfg 3.0, euler; Wan: cfg 6.0, uni_pc/simple, shift 8.0)."
tested: true
tests: ["LTX: nodos LTXV* presentes + t5xxl fp8 + ckpt real", "Wan: UNETLoader/VAELoader/ModelSamplingSD3 + umt5 + wan_2.1_vae", "params reflejados (width/height/num_frames/steps/seed/fps)", "model invalido lanza ValueError", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_video_workflow.py"
file_path: "python/functions/ml/comfyui_build_video_workflow.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_video_workflow import comfyui_build_video_workflow
wf = comfyui_build_video_workflow(
"A red fox runs through a sunlit autumn forest, cinematic, shallow depth of field",
model="ltx",
negative="low quality, worst quality, deformed, motion smear",
width=512, height=320, num_frames=65, steps=25, seed=42, fps=24,
)
# wf["72"]["class_type"] == "SamplerCustom" (camino LTX)
# wf["79"]["class_type"] == "SaveVideo"
# -> comfyui_submit_workflow(wf) para encolar el clip
```
O lanzable directo con: `./fn run comfyui_build_video_workflow` (imprime el JSON del workflow LTX de ejemplo).
## Cuando usarla
Antes de enviar una generacion de video txt2video a ComfyUI: construye aqui el
dict del workflow y pasalo a `comfyui_submit_workflow`. Usa `model="ltx"` por
defecto (cupo en 8GB confirmado, scheduler y VAE temporales propios); `model="wan"`
si quieres el camino Wan2.1 1.3B (umt5 + vae aparte). Hermana de
`comfyui_build_txt2img_workflow` para imagen estatica.
## Gotchas
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI. Es lo que
acepta POST /prompt.
- Los nombres de modelo estan fijados a los reales del equipo
(`ltx-video-2b-v0.9.5.safetensors` + `t5xxl_fp8_e4m3fn_scaled.safetensors`;
`wan2.1_t2v_1.3B_fp16.safetensors` + `umt5_xxl_fp8_e4m3fn_scaled.safetensors` +
`wan_2.1_vae.safetensors`). Deben existir y ser visibles para el servidor o
ComfyUI rechaza el workflow con HTTP 400 al enviarlo (esta funcion es pura y no
valida contra el servidor).
- Cupo 8GB: con los defaults (512x320, 65 frames) LTX pico ~7.7 GB en el report
0084 sin OOM. Subir resolucion o num_frames acerca el techo. Si da OOM, bajar a
512x288 / 49 frames.
- El camino LTX esta validado de extremo a extremo (report 0084: clip real de 65
frames). El camino Wan modela la plantilla nativa canonica de ComfyUI pero NO se
ejecuto en esa sesion; verificar nombres de modelo antes de tirar de el.
- LTX usa cfg baja (3.0). Subirla degrada el video. Por eso la cfg no es parametro:
se fija segun el modelo.
- `SaveVideo` necesita `format`/`codec` (aqui "auto"/"auto"); sin ellos ComfyUI
responde HTTP 400 (gotcha del importador, report 0084). Este builder ya los pone.
@@ -0,0 +1,232 @@
"""Construye un workflow ComfyUI txt2video en "API format" (dict de nodos numerados).
Soporta dos modelos de difusion de video nativos de ComfyUI 0.26, ambos pensados
para caber en 8 GB de VRAM con parametros conservadores:
- model="ltx": LTX-Video 2B v0.9.5. Checkpoint todo-en-uno (UNet + VAE temporal) +
text encoder t5xxl en fp8. Cadena CLIPLoader(ltxv) + CheckpointLoaderSimple ->
CLIPTextEncode x2 -> LTXVConditioning + EmptyLTXVLatentVideo + LTXVScheduler +
KSamplerSelect -> SamplerCustom -> VAEDecode -> CreateVideo -> SaveVideo.
Validado de extremo a extremo en el report 0084 (clip real de 65 frames).
- model="wan": Wan2.1 T2V 1.3B. Diffusion model (UNETLoader) + text encoder umt5
fp8 (CLIPLoader type=wan) + wan_2.1_vae aparte (VAELoader) + ModelSamplingSD3 ->
CLIPTextEncode x2 + EmptyHunyuanLatentVideo -> KSampler(uni_pc/simple) ->
VAEDecode -> CreateVideo -> SaveVideo. Plantilla nativa canonica de ComfyUI.
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
# Nombres reales de los modelos tal como los ve el servidor ComfyUI.
_LTX_CKPT = "ltx-video-2b-v0.9.5.safetensors"
_LTX_CLIP = "t5xxl_fp8_e4m3fn_scaled.safetensors"
_WAN_UNET = "wan2.1_t2v_1.3B_fp16.safetensors"
_WAN_CLIP = "umt5_xxl_fp8_e4m3fn_scaled.safetensors"
_WAN_VAE = "wan_2.1_vae.safetensors"
def comfyui_build_video_workflow(
prompt: str,
*,
model: str = "ltx",
negative: str = "",
width: int = 512,
height: int = 320,
num_frames: int = 65,
steps: int = 20,
seed: int = 0,
fps: int = 24,
) -> dict:
"""Construye el dict del workflow txt2video para LTX-Video 2B o Wan2.1 1.3B.
Args:
prompt: prompt positivo (lo que se quiere ver en el clip).
model: "ltx" (LTX-Video 2B v0.9.5) o "wan" (Wan2.1 T2V 1.3B). keyword-only.
negative: prompt negativo. keyword-only.
width: ancho del video en px (multiplo de 32 recomendado). keyword-only.
height: alto del video en px (multiplo de 32 recomendado). keyword-only.
num_frames: numero de frames del clip (longitud temporal del latente).
keyword-only.
steps: pasos de sampling (LTXVScheduler para ltx, KSampler para wan).
keyword-only.
seed: semilla del sampler (0 = determinista; cambiar para variar).
keyword-only.
fps: frames por segundo del video resultante (CreateVideo). En LTX se usa
ademas como frame_rate del condicionamiento LTXVConditioning.
keyword-only.
Returns:
dict en API format listo para comfyui_submit_workflow. Las claves son
node_ids (string) y cada valor tiene class_type + inputs. La cfg, el
sampler y el scheduler se fijan internamente segun el modelo (LTX: cfg 3.0,
euler; Wan: cfg 6.0, uni_pc/simple, shift 8.0).
Raises:
ValueError: si model no es "ltx" ni "wan".
"""
m = model.lower()
if m == "ltx":
return {
"38": {
"class_type": "CLIPLoader",
"inputs": {"clip_name": _LTX_CLIP, "type": "ltxv", "device": "default"},
},
"44": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": _LTX_CKPT},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": prompt, "clip": ["38", 0]},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["38", 0]},
},
"70": {
"class_type": "EmptyLTXVLatentVideo",
"inputs": {
"width": width,
"height": height,
"length": num_frames,
"batch_size": 1,
},
},
"71": {
"class_type": "LTXVScheduler",
"inputs": {
"steps": steps,
"max_shift": 2.05,
"base_shift": 0.95,
"stretch": True,
"terminal": 0.1,
"latent": ["70", 0],
},
},
"73": {
"class_type": "KSamplerSelect",
"inputs": {"sampler_name": "euler"},
},
"69": {
"class_type": "LTXVConditioning",
"inputs": {
"positive": ["6", 0],
"negative": ["7", 0],
"frame_rate": fps,
},
},
"72": {
"class_type": "SamplerCustom",
"inputs": {
"model": ["44", 0],
"positive": ["69", 0],
"negative": ["69", 1],
"sampler": ["73", 0],
"sigmas": ["71", 0],
"latent_image": ["70", 0],
"add_noise": True,
"noise_seed": seed,
"cfg": 3.0,
},
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["72", 0], "vae": ["44", 2]},
},
"78": {
"class_type": "CreateVideo",
"inputs": {"images": ["8", 0], "fps": fps},
},
"79": {
"class_type": "SaveVideo",
"inputs": {
"video": ["78", 0],
"filename_prefix": "video",
"format": "auto",
"codec": "auto",
},
},
}
if m == "wan":
return {
"37": {
"class_type": "UNETLoader",
"inputs": {"unet_name": _WAN_UNET, "weight_dtype": "default"},
},
"38": {
"class_type": "CLIPLoader",
"inputs": {"clip_name": _WAN_CLIP, "type": "wan", "device": "default"},
},
"39": {
"class_type": "VAELoader",
"inputs": {"vae_name": _WAN_VAE},
},
"48": {
"class_type": "ModelSamplingSD3",
"inputs": {"shift": 8.0, "model": ["37", 0]},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": prompt, "clip": ["38", 0]},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["38", 0]},
},
"40": {
"class_type": "EmptyHunyuanLatentVideo",
"inputs": {
"width": width,
"height": height,
"length": num_frames,
"batch_size": 1,
},
},
"3": {
"class_type": "KSampler",
"inputs": {
"seed": seed,
"steps": steps,
"cfg": 6.0,
"sampler_name": "uni_pc",
"scheduler": "simple",
"denoise": 1.0,
"model": ["48", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["40", 0],
},
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["3", 0], "vae": ["39", 0]},
},
"78": {
"class_type": "CreateVideo",
"inputs": {"images": ["8", 0], "fps": fps},
},
"79": {
"class_type": "SaveVideo",
"inputs": {
"video": ["78", 0],
"filename_prefix": "video",
"format": "auto",
"codec": "auto",
},
},
}
raise ValueError(
f"comfyui_build_video_workflow: model debe ser 'ltx' o 'wan', no {model!r}"
)
if __name__ == "__main__":
import json
wf = comfyui_build_video_workflow(
"A red fox runs through a sunlit autumn forest, cinematic, shallow depth of field",
model="ltx",
negative="low quality, worst quality, deformed, motion smear",
seed=42,
)
print(json.dumps(wf, indent=2))
@@ -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: true
tests: ["Load3D simple con model_file/width/height", "animation=True usa Load3DAdvanced", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_view_3d_workflow.py"
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,90 @@
---
name: comfyui_bump_skill_version
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_bump_skill_version(slug: str, change: str, *, score_before: float, score_after: float, judge_run_id: str = None, recipe_patch: dict = None, force: bool = False, bump: str = \"minor\", library_dir: str = None, timestamp: float = None) -> dict"
description: "Promueve una version nueva de una skill ComfyUI SOLO si el score sube (gate objetivo del bucle de mejora, grupo comfyui-skill). Si score_after <= score_before y no force, rechaza con ok=False sin tocar nada. Si pasa: snapshot pre-mutacion versions/vN.json, aplica recipe_patch (deep-merge) a recipe.json, sube el semver (minor default), y appende a growth_log.jsonl una linea {version,date,change,score_before,score_after,judge_run_id,diff}. library_dir default ~/ComfyUI/skills_library. Slug inexistente -> ok=False. No usa datetime.now (deriva la fecha del timestamp recibido o time.time). Impura: disco. Nunca lanza."
error_type: error_py_core
tags: [comfyui, comfyui-skill, ml, skill, versioning, growth]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
imports: []
params:
- name: slug
desc: "Slug de la skill (su carpeta en la libreria)."
- name: change
desc: "Descripcion de una linea del cambio que motiva el bump (va al growth_log)."
- name: score_before
desc: "Score 0-10 de la version actual (del panel comfyui-judge). keyword-only."
- name: score_after
desc: "Score 0-10 de la variante candidata. Debe ser > score_before salvo force=True. keyword-only."
- name: judge_run_id
desc: "Identificador de la corrida del juez que justifica el bump (evidencia trazable). keyword-only."
- name: recipe_patch
desc: "Dict con los cambios a aplicar sobre la receta (deep-merge). Ej. {'params': {'steps': 32}}. keyword-only."
- name: force
desc: "Si True, salta el gate y promueve aunque el score no mejore. keyword-only."
- name: bump
desc: "Parte del semver a subir: 'minor' (default), 'major' o 'patch'. keyword-only."
- name: library_dir
desc: "Raiz de la libreria. Default ~/ComfyUI/skills_library. keyword-only."
- name: timestamp
desc: "Epoch en segundos para la fecha del growth_log; None = time.time(). keyword-only."
output: "dict {ok, slug, old_version, new_version, snapshot_file, growth_entry, recipe_path, error}. ok=False con error si el gate rechaza el bump, si la skill no existe, o si falla la escritura; nunca lanza."
tested: true
tests: [test_semver_helper, test_deep_merge_no_pisa_otras_claves, test_golden_promueve_cuando_score_sube, test_edge_major_y_patch, test_error_gate_bloquea_si_no_mejora, test_error_force_salta_gate, test_error_skill_inexistente]
test_file_path: "python/functions/ml/tests/test_comfyui_bump_skill_version.py"
file_path: "python/functions/ml/comfyui_bump_skill_version.py"
---
# comfyui_bump_skill_version
Pieza de cierre del bucle de mejora del grupo [`comfyui-skill`](../../../docs/capabilities/comfyui-skill.md):
una skill genera, el panel [`comfyui-judge`](../../../docs/capabilities/comfyui-judge.md) la
puntúa, y esta función promueve una versión nueva **solo si el score sube**. El juez decide, no
el humano. Es el "crecimiento por composición" del issue 0087 aplicado a la generación de imágenes.
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_bump_skill_version import comfyui_bump_skill_version
# La variante con steps=32 puntuó 7.4 vs 6.5 de la versión vigente → se promueve.
res = comfyui_bump_skill_version(
"portrait_cinematic_sd15", "subir steps 28→32 (mejor detalle facial)",
score_before=6.5, score_after=7.4,
judge_run_id="judge_abc123", recipe_patch={"params": {"steps": 32}},
)
print(res["old_version"], "→", res["new_version"]) # 1.0.0 → 1.1.0
# recipe.json ahora en 1.1.0 con steps=32; versions/vN.json conserva la 1.0.0;
# growth_log.jsonl tiene una línea con score_before/after + judge_run_id.
```
## Cuando usarla
Tras juzgar una variante de una skill: si el `score` del panel supera al de la versión vigente,
llama a esta función para **promover** la mejora (snapshot + semver + growth_log). Si el score no
sube, NO la llames (o se rechaza por el gate) — esa es justo la garantía del bucle. Pásale el
`judge_run_id` de `comfyui_generate_with_skill_oneshot` como evidencia trazable.
## Gotchas
- **Gate duro**: `score_after <= score_before` sin `force=True` devuelve `{ok:False}` y NO toca
nada (ni snapshot, ni growth_log, ni versión). Es el comportamiento deseado, no un error.
- **`force=True`** salta el gate (promueve aunque empeore) y marca `growth_entry.forced=True`.
Úsalo solo para correcciones manuales, no en el bucle automático.
- **`recipe_patch` es deep-merge**: dicts anidados (`params`) se fusionan; listas (`loras`,
`blocks`) se reemplazan enteras (no se concatenan).
- **Snapshot pre-mutación**: `versions/vN.json` guarda la receta ANTES del patch; el `recipe.json`
queda con la versión nueva. Recupera la vieja con `comfyui_load_skill(slug, version=N)`.
- **Fecha sin `datetime.now()`**: se deriva de `timestamp` o `time.time()` vía `time.strftime`
(compatible con entornos que prohíben `datetime.now()`).
- **No genera ni juzga**: solo promueve la receta. Generar + puntuar es trabajo de
`comfyui_generate_with_skill_oneshot` + `comfyui_judge_image`.
@@ -0,0 +1,224 @@
"""comfyui_bump_skill_version — promueve una nueva versión de una *skill* ComfyUI.
Cierra el bucle de mejora del grupo `comfyui-skill`: una skill genera, el panel
`comfyui-judge` la puntúa y, **solo si el score sube**, esta función promociona una
versión nueva de la receta. Es la pieza de "crecimiento por composición" del issue 0087
aplicada a la generación de imágenes el catálogo de recetas crece registrando mejoras
medibles, no inflando recetas a ciegas.
Qué hace, en orden:
1. **Gate objetivo**: si ``score_after <= score_before`` y no se pasa ``force=True``,
rechaza con ``{ok: False}`` sin tocar nada. El juez (no el humano) decide.
2. **Snapshot pre-mutación**: escribe ``versions/vN.json`` con la receta ACTUAL antes de
cambiarla (backup recuperable con ``comfyui_load_skill(slug, version=N)``).
3. **Aplica ``recipe_patch``** (deep-merge) sobre ``recipe.json``.
4. **Sube el semver** de la receta (``minor`` por defecto: 1.0.0 1.1.0).
5. **Append a ``growth_log.jsonl``**: una línea
``{version, date, change, score_before, score_after, judge_run_id, diff}``.
`library_dir` por defecto ``~/ComfyUI/skills_library``. Slug inexistente ``{ok: False}``.
Impura: lee y escribe archivos en disco. Sin red. No usa ``datetime.now()`` (prohibido en
algunos entornos) la fecha se deriva del ``timestamp`` recibido o de ``time.time()``.
"""
import json
import os
import time
DEFAULT_LIBRARY = "~/ComfyUI/skills_library"
def _lib_dir(library_dir):
return os.path.expanduser(library_dir or DEFAULT_LIBRARY)
def _bump_semver(version: str, part: str) -> str:
"""Sube un semver ``X.Y.Z`` por ``major``/``minor``/``patch``.
Tolerante a versiones mal formadas: si ``version`` no parsea como tres enteros,
parte de ``0.0.0`` antes de aplicar el incremento.
"""
try:
nums = (list(map(int, str(version).split("."))) + [0, 0, 0])[:3]
major, minor, patch = nums
except (ValueError, TypeError):
major, minor, patch = 0, 0, 0
if part == "major":
return f"{major + 1}.0.0"
if part == "patch":
return f"{major}.{minor}.{patch + 1}"
# minor (default)
return f"{major}.{minor + 1}.0"
def _deep_merge(base: dict, patch: dict) -> dict:
"""Merge recursivo de ``patch`` sobre ``base`` sin mutar los originales.
Las claves cuyo valor es dict en ambos se fusionan en profundidad; cualquier otro
tipo (incluida una lista, p.ej. ``loras``/``blocks``) se reemplaza entero.
"""
out = dict(base)
for key, val in (patch or {}).items():
if isinstance(val, dict) and isinstance(out.get(key), dict):
out[key] = _deep_merge(out[key], val)
else:
out[key] = val
return out
def _next_version_index(versions_dir: str) -> int:
"""Siguiente N para ``versions/vN.json`` (1 + cuántos snapshots ya hay)."""
try:
existing = [f for f in os.listdir(versions_dir)
if f.startswith("v") and f.endswith(".json")]
except OSError:
existing = []
return len(existing) + 1
def comfyui_bump_skill_version(
slug: str,
change: str,
*,
score_before: float,
score_after: float,
judge_run_id: str = None,
recipe_patch: dict = None,
force: bool = False,
bump: str = "minor",
library_dir: str = None,
timestamp: float = None,
) -> dict:
"""Promueve una versión nueva de una skill si el score mejora (gate objetivo).
Args:
slug: slug de la skill (su carpeta en la librería).
change: descripción de una línea del cambio que motiva el bump (va al growth_log).
score_before: score de la versión actual (del panel-juez). keyword-only.
score_after: score de la variante candidata. Debe ser ``> score_before`` salvo
``force=True``. keyword-only.
judge_run_id: identificador de la corrida del juez que justifica el bump
(evidencia trazable). keyword-only.
recipe_patch: dict con los cambios a aplicar sobre la receta (deep-merge). Por
ejemplo ``{"params": {"steps": 32}}``. keyword-only.
force: si True, salta el gate y promueve aunque el score no mejore. keyword-only.
bump: parte del semver a subir: ``minor`` (default), ``major`` o ``patch``.
keyword-only.
library_dir: raíz de la librería. Default ``~/ComfyUI/skills_library``. keyword-only.
timestamp: epoch en segundos para la fecha del growth_log; None = ``time.time()``.
keyword-only.
Returns:
dict ``{ok, slug, old_version, new_version, snapshot_file, growth_entry,
recipe_path, error}``. ``ok=False`` con ``error`` si el gate rechaza el bump, si
la skill no existe, o si falla la escritura; nunca lanza.
"""
if not slug or not isinstance(slug, str):
return {"ok": False, "slug": slug, "old_version": "", "new_version": "",
"snapshot_file": "", "growth_entry": None, "recipe_path": "",
"error": "slug requerido (string no vacío)"}
# 1. Gate objetivo: el juez decide, no el humano.
try:
sb = float(score_before)
sa = float(score_after)
except (TypeError, ValueError):
return {"ok": False, "slug": slug, "old_version": "", "new_version": "",
"snapshot_file": "", "growth_entry": None, "recipe_path": "",
"error": f"score_before/score_after deben ser numéricos "
f"(recibido {score_before!r}, {score_after!r})"}
if sa <= sb and not force:
return {"ok": False, "slug": slug, "old_version": "", "new_version": "",
"snapshot_file": "", "growth_entry": None, "recipe_path": "",
"error": f"gate: score_after ({sa}) no supera score_before ({sb}); "
f"no se promueve (usa force=True para forzar)"}
lib = _lib_dir(library_dir)
skill_dir = os.path.join(lib, slug)
recipe_path = os.path.join(skill_dir, "recipe.json")
versions_dir = os.path.join(skill_dir, "versions")
if not os.path.isfile(recipe_path):
return {"ok": False, "slug": slug, "old_version": "", "new_version": "",
"snapshot_file": "", "growth_entry": None, "recipe_path": recipe_path,
"error": f"skill no encontrada: {slug!r} (sin recipe.json en {skill_dir})"}
try:
with open(recipe_path, encoding="utf-8") as fh:
recipe = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
return {"ok": False, "slug": slug, "old_version": "", "new_version": "",
"snapshot_file": "", "growth_entry": None, "recipe_path": recipe_path,
"error": f"no se pudo leer la receta: {exc}"}
old_version = recipe.get("version", "0.0.0")
new_version = _bump_semver(old_version, bump)
ts = float(timestamp) if timestamp is not None else time.time()
date = time.strftime("%Y-%m-%d", time.gmtime(ts))
try:
os.makedirs(versions_dir, exist_ok=True)
# 2. Snapshot pre-mutación: preserva la receta actual antes de cambiarla.
n = _next_version_index(versions_dir)
snapshot_file = os.path.join(versions_dir, f"v{n}.json")
with open(snapshot_file, "w", encoding="utf-8") as fh:
json.dump(recipe, fh, indent=2, ensure_ascii=False)
# 3. Aplicar el patch (deep-merge) + 4. subir el semver.
new_recipe = _deep_merge(recipe, recipe_patch or {})
new_recipe["version"] = new_version
with open(recipe_path, "w", encoding="utf-8") as fh:
json.dump(new_recipe, fh, indent=2, ensure_ascii=False)
# 5. Bitácora append-only del crecimiento.
growth_entry = {
"version": new_version,
"date": date,
"ts": int(ts),
"change": change,
"score_before": sb,
"score_after": sa,
"judge_run_id": judge_run_id or "",
"diff": recipe_patch or {},
"forced": bool(force and sa <= sb),
}
with open(os.path.join(skill_dir, "growth_log.jsonl"), "a", encoding="utf-8") as fh:
fh.write(json.dumps(growth_entry, ensure_ascii=False) + "\n")
except OSError as exc:
return {"ok": False, "slug": slug, "old_version": old_version, "new_version": "",
"snapshot_file": "", "growth_entry": None, "recipe_path": recipe_path,
"error": f"fallo de escritura: {exc}"}
return {"ok": True, "slug": slug, "old_version": old_version,
"new_version": new_version, "snapshot_file": snapshot_file,
"growth_entry": growth_entry, "recipe_path": recipe_path, "error": ""}
# Alias con el nombre completo del ID para descubrimiento por convención.
bump_skill_version = comfyui_bump_skill_version
if __name__ == "__main__":
import sys
# Demo offline contra una librería temporal.
demo_lib = "/tmp/skills_bump_demo"
sdir = os.path.join(demo_lib, "demo_skill")
os.makedirs(os.path.join(sdir, "versions"), exist_ok=True)
with open(os.path.join(sdir, "recipe.json"), "w", encoding="utf-8") as f:
json.dump({"schema_version": 1, "slug": "demo_skill", "version": "1.0.0",
"base_workflow": "txt2img", "params": {"steps": 28}}, f, indent=2)
# Gate bloquea cuando no mejora.
blocked = comfyui_bump_skill_version("demo_skill", "subir steps", score_before=7.0,
score_after=6.5, library_dir=demo_lib)
print("gate bloquea:", blocked["ok"], "->", blocked["error"], file=sys.stderr)
# Promueve cuando mejora.
ok = comfyui_bump_skill_version("demo_skill", "subir steps a 32", score_before=6.5,
score_after=7.4, judge_run_id="judge_abc",
recipe_patch={"params": {"steps": 32}},
library_dir=demo_lib)
print(json.dumps(ok, indent=2, ensure_ascii=False))
@@ -0,0 +1,87 @@
---
name: comfyui_compose_capabilities
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_compose_capabilities(base_workflow: dict, *, loras: list[dict] | None = None, controlnet: dict | None = None, ipadapter: dict | None = None, hires: dict | None = None, facedetailer: dict | None = None) -> dict"
description: "Mezclador de capacidades ComfyUI: toma un workflow base en API format (skill o txt2img) y aplica EN ORDEN las capacidades activadas (cada arg None = desactivada), componiendo los inyectores/builders encadenables del registry: loras (inject_multi_lora) -> controlnet (inject_controlnet) -> ipadapter (inject_ipadapter) -> facedetailer (build_facedetailer_workflow) -> hires (inject_hires_fix), reconectando MODEL/CLIP/positive/IMAGE. Cada capacidad es opcional e independiente; sin ninguna devuelve el base intacto. Pura: no muta el dict de entrada."
tags: [comfyui, comfyui-skill, ml, mixer, lora, controlnet, ipadapter, facedetailer, hires, workflow]
uses_functions: [comfyui_inject_multi_lora_py_ml, comfyui_inject_controlnet_py_ml, comfyui_inject_ipadapter_py_ml, comfyui_build_facedetailer_workflow_py_ml, comfyui_inject_hires_fix_py_ml]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: base_workflow
desc: "dict en API format (salida de comfyui_build_skill_workflow o comfyui_build_txt2img_workflow). No se muta; se devuelve una copia."
- name: loras
desc: "Lista de dicts {name, strength_model?, strength_clip?} para inject_multi_lora. None o vacia = sin LoRAs. keyword-only."
- name: controlnet
desc: "Dict para inject_controlnet: {control_image (obligatoria), cn_name (obligatoria), strength?, positive_node?}. None = sin ControlNet. keyword-only."
- name: ipadapter
desc: "Dict para inject_ipadapter: {ref_image (obligatoria), mode ('style'|'faceid'), weight?, ...}. None = sin IPAdapter. keyword-only."
- name: hires
desc: "Dict de kwargs para inject_hires_fix (upscale_by, denoise, steps, cfg, seed, upscale_model, ...). {} = hires con defaults. None = sin hires. keyword-only."
- name: facedetailer
desc: "Dict de overrides para build_facedetailer_workflow. ckpt_name/positive/negative se detectan del workflow si faltan; resto = params del builder (denoise, steps, bbox_model, ...). {} = detect + defaults. None = sin facedetailer. keyword-only."
output: "copia del base con las capacidades activadas encadenadas en orden (loras -> controlnet -> ipadapter -> facedetailer -> hires). Sin ninguna activada, copia del base intacta. Tras facedetailer deja un unico SaveImage (el del detailer)."
tested: true
tests: ["sin capacidades devuelve el base intacto (mismos nodos)", "solo loras encadena los LoraLoader", "loras + facedetailer: cadena de loras + FaceDetailer + un solo SaveImage", "ipadapter + lora: IPAdapter toma el MODEL del ultimo LoraLoader", "hires anade UltimateSDUpscale", "controlnet sin control_image propaga ValueError", "ipadapter sin ref_image propaga ValueError", "no muta el dict de entrada (pureza)", "api format valido en todas las combinaciones", "activar una capacidad cambia el conjunto de class_types"]
test_file_path: "python/functions/ml/tests/test_comfyui_compose_capabilities.py"
file_path: "python/functions/ml/comfyui_compose_capabilities.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
from ml.comfyui_compose_capabilities import comfyui_compose_capabilities
base = comfyui_build_txt2img_workflow("dreamshaper_8.safetensors", "a hero, 3d render style")
# 3 capacidades a la vez: 2 LoRAs + FaceDetailer (activar/desactivar = cambiar args)
mixed = comfyui_compose_capabilities(
base,
loras=[
{"name": "3d_render_redmond_sd15.safetensors", "strength_model": 0.9},
{"name": "detail_tweaker_sd15.safetensors", "strength_model": 0.5},
],
facedetailer={"denoise": 0.45},
# controlnet=..., ipadapter=..., hires=... -> None = desactivadas
)
```
## Cuando usarla
Cuando quieras **mezclar varias capacidades de generacion** (LoRAs + ControlNet +
IPAdapter + FaceDetailer + hires) sobre un mismo workflow base y poder
activar/desactivar cada una para iterar y mejorar. Es el "mixer" del grupo
`comfyui-skill`: una sola funcion en vez de encadenar los inyectores a mano. La
salida va directa a `comfyui_submit_workflow` (o usa el one-shot
`comfyui_generate_mixed_oneshot` para submit + juicio).
## Gotchas
- Pura: no muta el `base_workflow` y NO valida que checkpoints/loras/modelos
existan en el servidor. Las imagenes de control/referencia (ControlNet,
IPAdapter) deben estar en el `input/` del servidor antes de submit.
- **Orden fijo**: loras -> controlnet -> ipadapter -> facedetailer -> hires. El
IPAdapter se aplica sobre el MODEL ya modificado por los LoRAs (orden correcto).
- **hires + facedetailer NO encadenan** con las piezas actuales: ambos toman su
imagen del VAEDecode del render base, asi que combinarlos deja a uno sin efecto
sobre la salida final (con los dos activos, hires "gana" y facedetailer queda
sin consumidor). Usa uno U otro por workflow. Es la limitacion documentada del
mixer; el resto de combinaciones (loras+controlnet+ipadapter+uno de los dos
post-procesos) encadenan limpio.
- Cada capacidad apila coste de VRAM. En 8GB lowvram con SD1.5 entran ~2-3
capacidades modestas (p.ej. 2 LoRAs + FaceDetailer a 512px). Apilar IPAdapter
FaceID + ControlNet + hires + facedetailer a la vez puede dar OOM: baja
resolucion o desactiva capacidades.
- Errores de incompatibilidad (controlnet sin `control_image`, ipadapter sin
`ref_image`, mode invalido) se propagan como `ValueError` del inyector, no
petan en silencio.
@@ -0,0 +1,203 @@
"""comfyui_compose_capabilities — mezclador de capacidades sobre un workflow base.
Toma un workflow ComfyUI en API format (la base: salida de
comfyui_build_skill_workflow o comfyui_build_txt2img_workflow) y aplica EN ORDEN
las capacidades que se activen, componiendo los inyectores/builders ENCADENABLES
del registry. Cada capacidad es un argumento keyword opcional: None (default) =
desactivada. Asi el mismo dict base se mezcla a la carta y se puede ir mejorando
(activar/desactivar una capacidad cambia el grafo resultante).
Orden de aplicacion (de mas cerca del checkpoint a la salida):
1. loras -> comfyui_inject_multi_lora (cadena MODEL/CLIP)
2. controlnet -> comfyui_inject_controlnet (re-condiciona KSampler.positive)
3. ipadapter -> comfyui_inject_ipadapter (re-condiciona KSampler.model, tras loras)
4. facedetailer -> comfyui_build_facedetailer_workflow (regenera caras del VAEDecode)
5. hires -> comfyui_inject_hires_fix (UltimateSDUpscale tras el VAEDecode)
Cada capacidad es independiente: se puede activar cualquier subconjunto. Sin
ninguna activada devuelve una copia del base intacta.
Funcion PURA: sin red, sin I/O. No muta el dict de entrada (copia profunda). Solo
compone funciones puras del registry.
Limitacion conocida (piezas actuales): hires y facedetailer NO encadenan entre
si. Ambos toman su imagen del VAEDecode original del render; combinarlos deja a
uno de los dos sin efecto sobre la salida final. Usa uno U otro por workflow, o
encadenalos manualmente fuera del mixer. Ver el .md (## Gotchas).
"""
from __future__ import annotations
import copy
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from ml.comfyui_build_facedetailer_workflow import comfyui_build_facedetailer_workflow # noqa: E402
from ml.comfyui_inject_controlnet import comfyui_inject_controlnet # noqa: E402
from ml.comfyui_inject_hires_fix import comfyui_inject_hires_fix # noqa: E402
from ml.comfyui_inject_ipadapter import comfyui_inject_ipadapter # noqa: E402
from ml.comfyui_inject_multi_lora import comfyui_inject_multi_lora # noqa: E402
def _is_link(v) -> bool:
"""True si v es una conexion ComfyUI [node_id(str), output_index(int)]."""
return (
isinstance(v, list)
and len(v) == 2
and isinstance(v[0], str)
and isinstance(v[1], int)
)
def _detect_checkpoint(wf: dict) -> str:
"""Nombre del checkpoint del primer CheckpointLoaderSimple, o '' si no hay."""
for node in wf.values():
if node.get("class_type") == "CheckpointLoaderSimple":
return str(node.get("inputs", {}).get("ckpt_name", "")) or ""
return ""
def _detect_prompts(wf: dict) -> tuple[str, str]:
"""Texto (positivo, negativo) de los dos primeros CLIPTextEncode del workflow.
En los builders del registry el positivo se inserta antes que el negativo, asi
que el primer CLIPTextEncode es el positivo y el segundo el negativo.
"""
texts = [
str(n.get("inputs", {}).get("text", ""))
for n in wf.values()
if n.get("class_type") == "CLIPTextEncode"
]
positive = texts[0] if texts else ""
negative = texts[1] if len(texts) > 1 else ""
return positive, negative
def _prune_redundant_saveimages(wf: dict, keep_source_class: str) -> None:
"""Deja un unico SaveImage: el alimentado por un nodo `keep_source_class`.
Tras encadenar facedetailer queda el SaveImage del render base (que ya no es
la salida final) ademas del SaveImage del detailer. Se borra el primero para
que el workflow tenga una sola imagen de salida (la procesada). Muta `wf` in
situ (el caller ya trabaja sobre una copia). No-op si hay <=1 SaveImage o si
no se encuentra el SaveImage alimentado por `keep_source_class`.
"""
saves = [
(nid, n) for nid, n in wf.items() if n.get("class_type") == "SaveImage"
]
if len(saves) <= 1:
return
keep = None
for nid, node in saves:
src = node.get("inputs", {}).get("images")
if _is_link(src) and wf.get(src[0], {}).get("class_type") == keep_source_class:
keep = nid
break
if keep is None:
return
for nid, _ in saves:
if nid != keep:
del wf[nid]
def comfyui_compose_capabilities(
base_workflow: dict,
*,
loras: list[dict] | None = None,
controlnet: dict | None = None,
ipadapter: dict | None = None,
hires: dict | None = None,
facedetailer: dict | None = None,
) -> dict:
"""Aplica en orden las capacidades activadas sobre un workflow base.
Args:
base_workflow: dict en API format (salida de
comfyui_build_skill_workflow o comfyui_build_txt2img_workflow). No se
muta; se devuelve una copia.
loras: lista de dicts {name, strength_model?, strength_clip?} para
comfyui_inject_multi_lora. None o lista vacia = sin LoRAs. keyword-only.
controlnet: dict para comfyui_inject_controlnet. Claves: control_image
(str, obligatoria), cn_name (str, obligatoria), strength (float),
positive_node (str). None = sin ControlNet. keyword-only.
ipadapter: dict para comfyui_inject_ipadapter. Claves: ref_image (str,
obligatoria), mode ('style'|'faceid'), weight (float) y demas
keyword-only del inyector. None = sin IPAdapter. keyword-only.
hires: dict de kwargs para comfyui_inject_hires_fix (upscale_by, denoise,
steps, cfg, seed, upscale_model, ...). {} = hires con defaults. None =
sin hires. keyword-only.
facedetailer: dict de overrides para comfyui_build_facedetailer_workflow.
Claves opcionales: ckpt_name (str; si falta se detecta del workflow),
positive / negative (str; si faltan se detectan de los CLIPTextEncode),
y demas params del builder (denoise, steps, cfg, seed, bbox_model, ...).
{} = facedetailer con detect + defaults. None = sin facedetailer.
keyword-only.
Returns:
copia del base con las capacidades activadas encadenadas en orden. Si no
se activa ninguna, una copia del base intacta.
Raises:
ValueError: si una capacidad activada es incompatible (p.ej. controlnet
sin control_image, ipadapter sin ref_image): se propaga el ValueError
del inyector correspondiente con el contexto del fallo.
"""
wf = copy.deepcopy(base_workflow)
if loras:
wf = comfyui_inject_multi_lora(wf, loras)
if controlnet is not None:
cn = dict(controlnet)
control_image = cn.pop("control_image", "")
cn_name = cn.pop("cn_name", "")
wf = comfyui_inject_controlnet(wf, control_image, cn_name, **cn)
if ipadapter is not None:
ip = dict(ipadapter)
ref_image = ip.pop("ref_image", "")
wf = comfyui_inject_ipadapter(wf, ref_image, **ip)
if facedetailer is not None:
fd = dict(facedetailer)
ckpt_name = fd.pop("ckpt_name", None) or _detect_checkpoint(wf)
det_pos, det_neg = _detect_prompts(wf)
positive = fd.pop("positive", None)
if positive is None:
positive = det_pos
negative = fd.pop("negative", None)
if negative is None:
negative = det_neg
wf = comfyui_build_facedetailer_workflow(wf, ckpt_name, positive, negative, **fd)
# facedetailer anade su propio SaveImage; el del render base ya no es la
# salida final -> dejar solo el del detailer.
_prune_redundant_saveimages(wf, "FaceDetailer")
if hires is not None:
h = dict(hires) if isinstance(hires, dict) else {}
wf = comfyui_inject_hires_fix(wf, **h)
return wf
# Alias con el nombre completo del ID para descubrimiento por convencion.
compose_capabilities = comfyui_compose_capabilities
if __name__ == "__main__":
import json
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
base = comfyui_build_txt2img_workflow("dreamshaper_8.safetensors", "a hero, 3d render")
mixed = comfyui_compose_capabilities(
base,
loras=[
{"name": "3d_render_redmond_sd15.safetensors", "strength_model": 0.9},
{"name": "detail_tweaker_sd15.safetensors", "strength_model": 0.5},
],
facedetailer={"denoise": 0.45},
)
print(json.dumps({"base_nodes": list(base), "mixed_nodes": list(mixed)}, indent=2))

Some files were not shown because too many files have changed in this diff Show More