95 Commits

Author SHA1 Message Date
egutierrez e49841a60a feat(ml): generación de audio en ComfyUI (ACE-Step) — builder comfyui_build_audio_workflow + fetch_output_audio
Soporte nativo de audio texto->música/SFX en ComfyUI 0.26.0 capitalizado como
funciones del registry:

- comfyui_build_audio_workflow (pura): builder ACE-Step en API format. Cadena
  CheckpointLoaderSimple -> TextEncodeAceStepAudio + ConditioningZeroOut +
  EmptyAceStepLatentAudio -> ModelSamplingSD3 -> KSampler -> VAEDecodeAudio ->
  SaveAudio. Params seconds/seed/steps/cfg/shift/lyrics. Tags comfyui,audio,ace-step.
- comfyui_fetch_output_audio (impura): baja el .flac/.wav/.mp3 del output (clave
  'audio'). Hermana de comfyui_fetch_output_video, que no sirve para audio.

Modelo ACE-Step v1 3.5B (Apache 2.0, abierto). Stable Audio Open 1.0 descartado
por estar gated (HTTP 403) en HuggingFace. Cabe en 8GB con --lowvram.

Verificado e2e: 2 .flac reales generados desde texto (4.0s y 8.0s, seeds
distintos), duración exacta confirmada con ffprobe. Tests 6+5 verdes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 20:49:05 +02:00
egutierrez cda36408d0 feat(ml): modelos con prefijo de categoría (IMG_/VIDEO_/3D_) + refs actualizadas
Renombra los 13 checkpoints/diffusion models de ComfyUI prefijando la
categoría al inicio del nombre, para que en el dropdown de carga el usuario
distinga de inmediato imagen/vídeo/3D y no cargue un modelo en el nodo
equivocado. Misma operación que se hizo con los LoRAs (report 0197) pero
sobre los modelos.

Clasificación:
- IMG_: dreamshaper_8, juggernaut_xl_v11, v1-5-pruned-emaonly-fp16,
  flux1-dev-fp8-e4m3fn, flux1-schnell-fp8-e4m3fn
- VIDEO_: svd, ltx-video-2b-v0.9.5, wan2.1_t2v_1.3B_fp16
- 3D_: stable_zero123, sv3d_p, hunyuan3d-dit-v2-mini, hunyuan3d-dit-v2-mv,
  hy3dgen/hunyuan3d-dit-v2-0-fp16 (mantiene subcarpeta)

A diferencia de los LoRAs aquí solo se PREFIJA la categoría conservando el
nombre completo (versión/arquitectura). Archivos físicos renombrados en
~/ComfyUI/models/checkpoints, /mnt/2tb/comfyui_models/{checkpoints,
diffusion_models} y la subcarpeta hy3dgen/. Mapa de reversión en
~/ComfyUI/models/checkpoints/_ckpt_rename_map.json.

Actualiza todas las refs (ckpt_name/unet_name + defaults + prosa) en los
builders gamedev/vídeo/3D, style presets, pipelines, tests y los workflows
de ComfyUI. Arregla de paso el default roto de comfyui_text_to_3d_oneshot
(apuntaba a v1-5-pruned-emaonly.safetensors inexistente; ahora al real
IMG_v1-5-pruned-emaonly-fp16.safetensors).

No tocados (justificado): repo-paths de HuggingFace en comfyui_install_3d_model
(<repo>/model.fp16.safetensors son rutas de descarga, no nombres de dropdown)
y el mock de stable-diffusion.cpp en test_genconfig_to_sdcpp_args.

Verificado: dropdowns CheckpointLoaderSimple + UNETLoader listan los nombres
con prefijo; 1 generación real con IMG_juggernaut_xl_v11 (node_errors vacío,
pixelart_00003_.png); 327 tests comfyui verdes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:24:52 +02:00
egutierrez 10dbc510b7 feat(ml): LoRAs con prefijo de arquitectura (SD15_/SDXL_/FLUX_) + refs actualizadas
Mueve el indicador de arquitectura del SUFIJO al PREFIJO del nombre de cada
LoRA para que el dropdown del LoraLoader muestre de inmediato que LoRA casa con
que checkpoint (evita el shape mismatch SD1.5 vs SDXL que crashea ComfyUI).

- 20 LoRAs renombradas en disco (15 SD15/SDXL en /mnt/2tb, 5 FLUX en ~/ComfyUI),
  mapa de reversion en ~/ComfyUI/models/loras/_rename_map.json.
- Refs actualizadas en builders gamedev-2d, style presets, pipelines, tests y
  docs/capabilities. Defaults hardcodeados (pixel-art, lcm-lora, etc.) apuntan a
  los nombres con prefijo.
- Ejemplos genericos en docstrings normalizados a la convencion de prefijo.
- comfyui_replicate_civitai_oneshot::_norm ignora el token de arquitectura al
  comparar, robusto al reordenado (sufijo civitai vs prefijo instalado).

Refs a repos HuggingFace (nerijs/pixel-art-xl) y checkpoints (juggernaut_xl_v11)
preservados. Verificado: dropdown LoraLoader con prefijos + generacion real
pixel-art OK + tests comfyui verdes (481 ml + 26 pipelines).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:33:03 +02:00
agent d3d846f748 feat(ml): grupo comfyui-styles — catálogo curado + merge/dedup + generador LLM de estilos WAS
Tres funciones para gestionar y ampliar el repositorio de estilos del selector
WAS de ComfyUI (Prompt Styles Selector / Prompt Multiple Styles Selector):

- comfyui_curated_styles_catalog (pure): catálogo curado de 190 estilos en 13
  categorías (photography, render3d, painting, anime, pixel, illustration,
  comic, lighting, camera, material, scifi, fantasy, mood), formato WAS exacto.
- comfyui_append_styles (impure): merge+dedup no destructivo sobre el styles.json
  real, con backup atómico, validación de entradas y preservación de existentes.
- comfyui_generate_styles_llm (impure): genera estilos de una categoría vía
  ask_llm (grupo claude-direct); robusta (devuelve {} ante 429/JSON corrupto).

Aplicado en vivo: styles.json 269 -> 503 estilos (+190 curados +44 LLM),
backup hecho, selector verifica 503 en /object_info. Tests offline verdes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 13:50:25 +02:00
egutierrez a5748cb147 feat(gamedev): comfyui_generate_styled_asset_oneshot — aplica estilo a un asset con auto-post + amplía catálogo a 6 estilos
Pipeline one-shot que aplica un style preset curado a un asset en una llamada
(kind, subject, style_preset) y auto-ejecuta el post-proceso que el estilo declara:
los estilos pixelart (gameboy, pixel-art-retro) salen ya pixelizados del pipeline,
cerrando el hueco #1 del sistema de style presets (report 0190) donde el caller
tenía que llamar comfyui_pixelize_image a mano.

Reutiliza el dispatch _SUPPORTED (kind->builder) de comfyui_generate_asset_pack_oneshot
en vez de redefinir el mapa. Parte pura aislada en styled_asset_build_only para validar
kind/estilo desconocido sin tocar la GPU. Export a Godot consciente del post (pixelart
si hubo pixelize, para fijar el filtro Nearest).

Catálogo de estilos ampliado de 3 a 6: cyberpunk-neon (prompt puro SD1.5),
low-poly-flat (prompt puro SD1.5), cartoon-cel-shaded (LoRA anime_style_box_sd15 0.7).

Verificación: 11 tests offline del pipeline + suite de presets verde (27 passed).
Prueba real en GPU: mismo "treasure chest" en cyberpunk-neon, low-poly-flat y gameboy
one-shot; gameboy pasa de 17374 colores (crudo) a 4 (paleta Game Boy) auto-pixelizado
directo del pipeline. Detalle en reports/0191.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 12:50:30 +02:00
egutierrez 0eefb7cfcd feat(gamedev): sistema de style presets reutilizable (gameboy/ghibli/pixel-art-retro)
Calidad por ESTILO en vez de por tipo: un dev fija el look del juego una vez
y todos los assets salen coherentes. Diseño (A) datos puros + helper, no
pipeline monolítico (issue 0087, crecer por composición).

- comfyui_get_gamedev_style_preset(name=None): recetas curadas o catálogo.
  gameboy (sin LoRA, post pixelize paleta game-boy 4 tonos), ghibli (degrada
  a watercolor_style_sd15 gratis instalado, sin LoRA Ghibli gated), pixel-art-retro
  (reutiliza pixel-art-xl SDXL + juggernaut + post pixelize 16 colores). Extensible.
- comfyui_apply_style_preset(preset, subject): traduce a kwargs **spread-ables
  para cualquier builder de sujeto + size/transparent/post. Pura, no muta.
- 16 tests offline verdes. Validado e2e GPU: mismo 'knight character' en 3
  estilos visiblemente distintos (4 vs 78552 vs 16 colores).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 12:36:18 +02:00
egutierrez 9f0d2e2338 feat(gamedev): comfyui_generate_character_set_oneshot — set completo de un personaje coherente (2D + direccional 8-way + 3D)
Promueve a un pipeline one-shot la secuencia que hoy exige 4 llamadas a mano:
generar el set COMPLETO de un personaje de juego (imagen base 2D recortada,
sprite direccional N-way SV3D/Zero123 y malla 3D Hunyuan3D .glb), todos del
MISMO personaje. La coherencia cross-frontera se garantiza por construccion: el
direccional y el 3D parten de la MISMA base 2D aplanada (base_flat), no de tres
generaciones independientes. Es la culminacion de las 5 fronteras del grupo
gamedev-2d (issue 0087).

Compone builders del registry (enemy_creature/portrait_avatar/topdown_sprite
por introspeccion) + comfyui_flatten_alpha_on_color (nueva, aplana el sprite
recortado sobre fondo solido que SV3D/Hunyuan exigen) + comfyui_image_to_3d_oneshot
+ comfyui_build_directional_sprite_workflow + submit/wait/fetch + export Godot.
Secuencial liberando VRAM entre pasos pesados (3D antes que SV3D) para caber en
8 GB; fallo aislado deja set PARCIAL sin abortar.

Probado e2e en GPU (RTX 3070 8 GB) con 'armored paladin': base 2D RGBA 512
recortada + malla glTF 395600 triangulos + 8 vistas direccionales SV3D 576,
todos del mismo personaje. 9 tests offline verdes (incluye coherencia mockeada).
Ver reports/0189.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 09:02:24 +02:00
egutierrez 2bab120d7c feat(gamedev): comfyui_build_directional_sprite_workflow — sprite multi-direccional 2.5D (SV3D turntable / Zero123)
Builder puro (dict API format) que a partir del sprite frontal de un personaje
construye el workflow ComfyUI de N vistas direccionales consistentes (8-way
N/NE/E/SE/S/SW/W/NW o 4-way) rotando la figura en 3D. SV3D (orbit turntable) por
defecto, Stable Zero123 (batch por azimuth) como fallback de menor VRAM. Es el
puente 2.5D del catalogo gamedev-2d: consistencia rotacional real (el mismo
modelo rotado) frente a sprite_sheet (OpenPose 2D re-poza, identidad inconsistente).

Helper directional_sprite_view_order(directions) mapea frame i -> direccion i.
Funcion pura: solo construye el grafo; coste GPU al enviar con comfyui_submit_workflow.

Probado e2e en GPU: goblin enemy_creature_00001_ -> SV3D 8 direcciones elevation 15,
8 frames 576x576 en 75s, pico 7145/8192 MiB (prompt_id 8b9f75de). Consistencia
rotacional medida: MAE adyacentes 27 < frente-espalda 29.6, spread de paleta 3.83.

Report: reports/0187.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 07:36:38 +02:00
egutierrez d08667df9b docs(gamedev-2d): documentar animación de assets (vídeo) validada e2e
Añade la sub-sección "Animación de assets (vídeo) — caminos validados e2e" a la
capability page gamedev-2d con las tres vías para animar un asset 2D, todas
cabiendo en 8GB con la GPU vacía:

- txt2video LTX (comfyui_build_video_workflow model='ltx'): loop de elemento
  desde texto. Validado e2e: portal mágico 512×320, 25 frames, VRAM pico
  7717/8192 MiB (prompt_id 54eda033).
- img2vid SVD (comfyui_build_img2vid_workflow): animar un sprite/fondo ya
  generado. Validado e2e: enemy_creature del pack → 512×512 RGBA 14 frames
  animado, VRAM pico 7463/8192 MiB (prompt_id 5b501d03).
- txt2video Wan (enlazado + visible en /object_info, clip no generado aún).
- spritesheet AnimateDiff (ya validado en rondas previas).

Documenta el gate VRAM (el vídeo no convive con un juego AAA abierto), el gotcha
de comfyui_wait_result (lanza TimeoutError) y que SVD completa en GPU aunque el
script de orquestación expire (recuperar output sondeando /history). Los modelos
de vídeo de /mnt/2tb/comfyui_models ya estaban enlazados vía extra_model_paths.yaml
(verificado en /object_info, sin copiar, reversible). Evidencia: reports/0186.

No se creó builder nuevo: los builders del registry ya cubren el caso gamedev
(registry-first/KISS).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:23:14 +02:00
egutierrez 9f1d643013 feat(gamedev): comfyui_build_outpaint_asset_workflow — extender el lienzo de un asset (outpaint)
Quinto vertice del eje transform de gamedev-2d. Funcion pura (dict API format)
que extiende el lienzo de un asset ya pintado por uno o varios lados y genera
contenido coherente mas alla de sus bordes via el nodo nativo ImagePadForOutpaint,
que ademas de ampliar el canvas EMITE la mascara feathered de la franja nueva (la
genera el grafo, no la recibe el caller — esa es la diferencia con inpaint_asset).

Compone comfyui_build_inpaint_workflow (base; su LoadImageMask se elimina y
VAEEncodeForInpaint se reconecta a las dos salidas del pad) + comfyui_inject_lora.

Probado e2e en GPU con SD1.5: seamless_00004 512x512 extendido right=256 -> 768x512
(prompt_id aa33de05), original conservado (diff 7.2) + franja nueva coherente.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 04:59:50 +02:00
egutierrez 914def9e5c feat(gamedev): comfyui_build_inpaint_asset_workflow — editar solo una región de un asset (inpaint)
Cuarto vértice del eje transform de gamedev-2d: editar SOLO una región de un
asset 2D ya pintado vía inpaint, conservando el resto del sprite. Completa el
eje junto a txt2img (crear de cero), asset_variant (img2img: reescribe todo) y
sprite_from_sketch (ControlNet: sprite nuevo desde boceto).

Función pura (API format dict) que compone comfyui_build_inpaint_workflow (base)
+ comfyui_inject_lora (estilo opcional). Recibe asset + máscara (blanco=editar,
negro=conservar) + prompt de qué poner; VAEEncodeForInpaint codifica respetando
la máscara y dilata el borde grow_mask px para difuminar la costura; el KSampler
regenera solo esa zona. mode="noise_mask" degrada a VAEEncode+SetLatentNoiseMask
para servidores sin VAEEncodeForInpaint (error path). size escala imagen Y máscara
de forma consistente. class_types verificados contra /object_info (8GB lowvram).

Probado e2e en GPU con SD1.5: máscara circular sobre la mano del goblin
enemy_creature_00001_.png, prompt "a glowing blue magic orb" (prompt_id 88b52c66).
Solo la región enmascarada cambió: diff medio dentro 40.3 vs fuera 1.97 (ratio
20.4x), 44.6% px cambiados dentro vs 1.7% fuera. Confirmación visual: orbe azul
en la región, resto del goblin idéntico.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 04:45:50 +02:00
egutierrez 1012355998 feat(gamedev): comfyui_build_sprite_from_sketch_workflow — boceto→sprite vía ControlNet
Tercer eje del catálogo gamedev-2d: partir del DIBUJO del dev. Recibe un
boceto/lineart + un prompt de qué es y construye un workflow txt2img guiado por
ControlNet (lineart/scribble/canny) que pinta el sprite conservando la forma
dibujada. Distinto de los builders txt2img (inventan la forma desde texto) y de
asset_variant img2img (reescribe una imagen ya pintada conservando forma+color):
aquí el dev marca la silueta y la IA pone material/color/acabado, conservando
solo la forma.

Función pura (API format). Compone comfyui_build_txt2img_workflow +
comfyui_inject_controlnet + comfyui_inject_lora; el único código propio es el
helper que interpone el preprocesador (LineArt/Scribble/Canny) entre el boceto y
el ControlNet, análogo a _inject_image_scale del hermano asset_variant.

control_type selecciona preprocesador y modelo CN emparejado; controlnet_name y
preprocess dan override para degradar al modelo disponible. Gotcha documentado:
el server 8GB solo tiene modelos CN SD1.5 canny/depth/openpose — para
lineart/scribble usar override a canny o control_type=canny (pendiente humano
descargar los modelos lineart/scribble dedicados).

Verificación: tests offline verdes (cableado txt2img guiado, 3 control_types,
clamps, errores). E2E real GPU SD1.5: boceto del goblin → CannyEdgePreprocessor →
ControlNet canny → sprite que respeta pose/orejas/hombrera/lanza/espada del
dibujo (prompt_id ea6fc372, edge corr 0.545, luminance corr -0.19 confirmando
repintado). Report en reports/0182.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 04:31:41 +02:00
egutierrez 1585e986c1 feat(gamedev): comfyui_build_asset_variant_workflow — variantes img2img de un asset existente
Primer builder gamedev-2d de transformacion (img2img) en vez de generacion
(txt2img): parte de un asset ya generado y produce una variante coherente
(ice/fire/damaged/golden tier) cambiando material/paleta/estado y conservando
silueta, pose y composicion via denoise medio (~0.5). Compone
comfyui_build_img2img_workflow + comfyui_inject_lora + ImageScale opcional.

Probado e2e en GPU SD1.5: variante ice del goblin del demo pack
(prompt_id 5e4a5d3d) — silueta conservada (luminance corr 0.63) + paleta a
frio (blueness B-R -1.6 -> +1.9). Subseccion nueva en docs/capabilities y
report 0181.
2026-06-27 04:20:49 +02:00
egutierrez e1f1be02ce feat(gamedev): comfyui_generate_asset_pack_oneshot — set 2D coherente one-shot
Pipeline que genera un set de assets 2D de un mismo juego en una sola llamada,
compartiendo checkpoint, LoRA de estilo, estilo comun (inyectado al subject) y
seed derivada (base_seed + indice). Despacha 26 kinds gamedev-2d a sus builders
atomicos, encola/espera/descarga cada PNG y exporta opcionalmente a Godot.

Promocion de composicion a pipeline (issue 0087): el registry no crece inflando
builders, crece promoviendo la secuencia repetida 'N builders con el mismo estilo'
a un one-shot.

- Dispatch declarativo por kind con inyeccion de coherencia via inspect.signature
  (no hardcodea nombres de param; respeta LoRAs funcionales propios).
- Fail-fast si kind desconocido (sin tocar GPU); un OOM aislado no aborta el pack.
- 9 tests offline verdes (golden + edge + error).
- Probado e2e en GPU SD1.5 512: magic sword + goblin warrior, style dark fantasy
  hand-painted, seeds 42/43 -> 2/2 PNG 512x512 RGBA coherentes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 03:00:10 +02:00
egutierrez a27dcc028c docs(capabilities): unifica tag gamedev en gamedev-2d + separa gamedev-engine
El doctor reportaba el dominio gamedev en doble FAIL: el tag plano `gamedev`
(44 funciones) como `ungrouped_candidate` y la pagina `gamedev-2d.md` como
`doc_orphan`. Causa raiz: el INDEX declaraba `[gamedev](gamedev-2d.md)` y el
auditor solo registra el slug cuando label==target, asi que ni casaba la
pagina ni declaraba el tag.

Al revisar las 44 funciones habia dos clusters reales bajo el mismo tag, asi
que se separan en dos grupos honestos:

- gamedev-2d (tag canonico): 31 builders de workflow ComfyUI + 5 de apoyo
  (post-proceso + puente a Godot) = 36. Se elimina el tag plano `gamedev` de
  los builders (ya tenian `gamedev-2d`) y se reemplaza por `gamedev-2d` en las
  de apoyo.
- gamedev-engine (grupo nuevo, pagina madre nueva): runtime de juego C++
  multiplataforma (SDL3 + sokol_gfx + miniaudio, Issue 0072b) = 8. Game loop,
  camara 2D, input unificado, sprite batch, setup render/audio, build wasm.

El tag plano `gamedev` queda eliminado (count 0). INDEX corregido: fila
gamedev-2d con label==target y conteo 36 + fila nueva gamedev-engine (8).

Verificacion: `fn index` + `fn doctor capabilities` -> ambos grupos OK
(declared_in_index=yes, doc_exists=yes, sin issues); `gamedev` plano = 0.
Solo se modifico el campo `tags` de los .md, ningun archivo de codigo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 02:40:50 +02:00
egutierrez 8a4cc323a3 feat(gamedev): comfyui_build_weather_overlay_workflow — overlays de clima/atmósfera
Builder puro (dict API format) de capas de clima a pantalla completa: lluvia,
niebla, nieve, god rays, polvo, viñeta de tormenta. Capa de cobertura total
apaisada (16:9, 1024x576) que se superpone sobre la escena con blend del motor.

Dos modos via on_black: True (defecto) genera el clima brillante sobre negro
puro como insumo de comfyui_matting_luma_to_alpha (blend aditivo/screen);
False genera una pelicula translucida semi-transparente (multiply/overlay).
NO inyecta Rembg: el matting de una capa es luma->alpha de disco. Compone
comfyui_build_txt2img_workflow + comfyui_inject_lora.

Diferenciado de decal_overlay (mancha localizada) y vfx_spritesheet (secuencia
animada de un efecto puntual): aqui es una pelicula estatica de cobertura
full-screen que el motor anima por scroll/loop/shader.

10 tests offline verdes. Probado e2e en GPU SD1.5 8GB lowvram: heavy rain
on_black seed 11 1024x576 (16:9 exacto), estrias brillantes sobre negro plano
(esquinas luma 0.00, dark 89.6%) apto luma->alpha (prompt_id 5d2300d1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 02:26:39 +02:00
egutierrez 2a7c77cb56 feat(gamedev): comfyui_build_achievement_badge_workflow — insignias/medallas/logros
Nuevo builder del grupo gamedev-2d para insignias de logro / medallas / trofeos de
sistemas de achievements/recompensas, con tier metálico (bronce/plata/oro/platino/
diamante) y fondo recortable a alpha. Función pura (dict API format) que compone
comfyui_build_txt2img_workflow + comfyui_inject_lora (estilo opcional) + Image Rembg.

Hermano de item_icon (objeto de inventario suelto) y skill_tree_node (nodo enmarcado
de la rejilla de talentos): aquí el asset es la insignia de logro/recompensa = medalla
con cinta + tier. El tier metálico y la forma de medalla/trofeo son la firma.

12 tests offline verdes. Probado e2e en GPU con SD1.5: badge="dragon slayer" tier gold
seed 77 256x256 RGBA, medalla circular dorada con emblema centrado y fondo recortado a
alpha (esquina α=0, centro α=254; prompt_id 8b8b7ede).
2026-06-27 02:18:12 +02:00
egutierrez fa94f7a235 feat(gamedev): comfyui_build_trap_hazard_workflow — trampas/peligros de escenario
Builder ComfyUI puro (dict API format) para UNA trampa/peligro JUGABLE de nivel
(pinchos, sierra giratoria, foso de lava, placa de presion, llamas, trampa de
flechas, charco acido, descarga electrica): objeto de peligro aislado y centrado
a perspectiva de juego (side/top-down/iso via view), fondo limpio recortable a
alpha, estilo consistente para poblar niveles.

Hermano de comfyui_build_prop_object/structure/foliage_set_workflow: mismo patron
que compone comfyui_build_txt2img_workflow + comfyui_inject_lora (estilo opcional)
+ Image Rembg (alpha si transparent). Diferenciado de prop_object (peligro con
hitbox de dano vs decoracion inerte) y enemy_creature (trampa vs enemigo vivo);
el negativo rechaza character/person/creature/multiple objects.

Gotcha documentado: para hazards puramente etereos (llamas/electricidad/gas) usar
transparent=False + comfyui_matting_luma_to_alpha (conserva el falloff), no Rembg.

12 tests offline en verde. Probado e2e en GPU con SD1.5 — spiked floor trap side
512x512 RGBA, mecanismo de peligro centrado recortado a alpha real (alpha extrema
0-255, prompt_id ab1b1560).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 02:10:26 +02:00
egutierrez 0ce1c31fb9 feat(gamedev): comfyui_build_skill_tree_node_workflow — nodos de arbol de habilidades/talentos
Builder puro del grupo gamedev-2d: nodo de skill tree (icono de habilidad dentro de
un marco circular/hexagonal/rombo/escudo) con variante de estado visual (unlocked
brillante / locked gris), centrado, recortable a alpha. Diferenciado de item_icon
(objeto suelto sin marco), status_effect_icon (simbolo superpuesto sin marco) y
ui_hud (chrome grande): el marco y el estado son la firma del asset. Compone
comfyui_build_txt2img_workflow + comfyui_inject_lora + Image Rembg, sin reescribir el
grafo. 11 tests offline en verde. Probado e2e SD1.5 8GB lowvram: fireball hexagonal
unlocked 256x256 RGBA, prompt_id cf36b2ea, nodo enmarcado brillante centrado
(reports/0173).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 02:03:07 +02:00
egutierrez 5a0818ee9c feat(gamedev): comfyui_build_rune_glyph_workflow — runas/glifos/sigilos mágicos (símbolo arcano aislado sobre negro; glow=True -> luma->alpha conserva resplandor para blend aditivo, sin Rembg; glow=False runa mate; hermano de status_effect_icon/decal_overlay; probado e2e SD1.5 prompt_id 701d149a)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 01:55:27 +02:00
egutierrez 1a8093a7be feat(gamedev): comfyui_build_dialogue_box_workflow — caja de diálogo/bocadillo/panel de texto
Builder del contenedor de diálogo de juego (RPG, visual novel, aventura): marco
apaisado (768x256) con borde decorativo e interior plano reservado para el texto
del motor. Distinto de ui_hud (elementos sueltos): es el panel-contenedor completo.
Compone txt2img + inject_lora + Image Rembg (alpha). Pura, dict API format.
7 tests offline verdes; 1 generación real en GPU (medieval wood+gold, 768x256 RGBA).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 01:48:00 +02:00
egutierrez ba302dd793 feat(gamedev): comfyui_build_foliage_set_workflow — vegetación/foliage de escenario (árbol, arbusto, hierba, flores, helecho, hongo, cactus, juncos; elemento de naturaleza aislado vista side/top, fondo limpio recortable a alpha; diferenciado de prop_object/structure)
Builder puro hermano de prop_object/structure: compone txt2img + inject_lora (estilo opcional) + Image Rembg (alpha). Scaffold '{plant}, {view} view, {style}, single plant element, centered, plain background, game nature asset'. Negativo rechaza manufacturado/edificio/persona/bosque/maceta para mantener UN elemento vegetal orgánico aislado.

13 tests offline verdes + generación real e2e (golden 'a glowing mushroom' seed 7, prompt_id 8fb65a51, RGBA recortable centrado). Dos gotchas reales SD1.5+Rembg documentados (planta grande->paisaje; follaje claro->Rembg come hojas) con evidencia en reports/0170.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 01:41:34 +02:00
egutierrez 0421bc6d4f feat(gamedev): comfyui_build_vehicle_mount_workflow — vehículos/monturas que el personaje usa o conduce (caballo, dragón-montura, nave, coche, barco, carro, grifo; vehículo completo vista side/iso, SIN jinete, alpha recortable)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 01:32:42 +02:00
egutierrez 5662a54fa7 feat(gamedev): comfyui_build_world_map_workflow — mapa de mundo/nivel ilustrado (lámina cartográfica cenital fantasy, regiones rotuladas, borde ornamental, cuadrado por defecto, hires opcional)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 01:19:17 +02:00
Egutierrez b45165dbc5 feat(gamedev): comfyui_build_title_lettering_workflow — texto/logo de título de juego (lettering estilizado, apaisado, alpha)
Builder puro (dict API format) hermano de ui_hud/splash_art: compone
comfyui_build_txt2img_workflow + comfyui_inject_lora + Image Rembg. Renderiza
el nombre del juego/una palabra con un tratamiento de lettering (metálico,
tallado, neón, fuego...), formato apaisado 1024x512, fondo recortable a alpha.

El negativo NO rechaza texto (el lettering es el sujeto). Documentada la
limitación clave: la difusión no garantiza la ortografía exacta del texto
(letras de más/deformadas; una palabra-objeto como DRAGON se ilustra en vez de
escribirse). Mitigaciones: palabras cortas en mayúscula, re-roll de seeds,
SDXL > SD1.5, o pintar el texto real en el motor.

Tests 9/9 verde (offline). Verificado e2e en GPU (8GB lowvram): DRAGON/fire
engraved (SD1.5, prompt_id 6f3920b7) y AETHER/epic fantasy metallic (SDXL,
prompt_id 2a7fe8ba, logo metálico dorado + alpha). Fila en
docs/capabilities/gamedev-2d.md. Report en reports/0165.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 01:10:55 +02:00
egutierrez dbb040aa12 feat(gamedev): comfyui_build_structure_workflow — edificios/estructuras de escenario (building completo, view iso/lateral, alpha)
Builder gamedev-2d nuevo: edificacion grande y completa (casa, torre, castillo, tienda,
posada, ruina, muralla, puente, templo, faro) para poblar mapas/escenarios. Diferenciado
de comfyui_build_prop_object (edificio completo vs objeto pequeno suelto): el negativo
rechaza small object/single item/prop/furniture y el scaffold empuja full building/
complete structure/single building. view (iso por defecto) fija la perspectiva del mapa.

Pura (dict API format): compone comfyui_build_txt2img_workflow + comfyui_inject_lora
(estilo/iso opcional) + Image Rembg (alpha si transparent). 12 tests offline verdes.
Probado e2e en GPU (8GB lowvram): medieval blacksmith shop iso 512x512 RGBA, edificio
centrado (centroide 0.54/0.53). Fila en docs/capabilities/gamedev-2d.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 01:01:15 +02:00
egutierrez 91cf683289 feat(gamedev): comfyui_build_particle_texture_workflow — texturas de partícula individuales (chispa/humo/polvo/destello, sobre negro, luma→alpha, size 256)
Builder PURO (dict API format) del grupo gamedev-2d/gamedev-vfx: UNA textura de
partícula reutilizable que el sistema de partículas del motor (Godot GPUParticles2D,
Unity VFX Graph) instancia a miles. Aislada sobre fondo negro puro, pensada para
luma→alpha (comfyui_matting_luma_to_alpha, additive blend); soft controla el borde
(glow difuso vs nítido); NO inyecta Rembg (rompería el falloff); size 256 por defecto.
Diferenciada de vfx_spritesheet (secuencia animada) y decal_overlay (mancha estática).
Compone comfyui_build_txt2img_workflow + comfyui_inject_lora. 9 tests offline verdes.
Generación real verificada e2e en GPU (spark sobre negro plano + luma→alpha RGBA).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:54:58 +02:00
egutierrez 696148d56b feat(gamedev): comfyui_build_status_effect_icon_workflow — iconos de estado/buff-debuff (símbolo compacto legible a tamaño reducido, size 256, Rembg alpha)
Builder hermano de item_icon/ui_hud, diferenciado por rol: símbolo de estado
compacto (veneno/escudo/velocidad...) optimizado para 16-32 px, no objeto de
inventario ni chrome de interfaz. Pura (dict API format), 8 tests offline,
1 generación real verificada (poison 256x256 RGBA).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:48:24 +02:00
egutierrez 19ad2b3e5d feat(gamedev): comfyui_build_projectile_workflow — proyectiles/balas/hechizos orientados (glow→luma-alpha, sólido→rembg)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:41:52 +02:00
egutierrez b88730b7cb feat(gamedev): comfyui_build_decal_overlay_workflow — decals/overlays con alpha (luma→alpha sobre negro)
Builder puro (dict API format) para texturas de superposicion (sangre, grietas,
suciedad, grunge, oxido, quemaduras, salpicaduras): genera el decal aislado sobre
fondo plano (negro por defecto), pensado para extraer alpha con
comfyui_matting_luma_to_alpha (luminancia=alpha, conserva el falloff de translucidos).
NO inyecta Rembg (el matting es luma->alpha de disco, no un nodo). Compone
comfyui_build_txt2img_workflow + comfyui_inject_lora. 9 tests offline verdes;
generacion real verificada e2e en GPU (8GB lowvram, SD1.5, prompt_id 109907a4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:34:53 +02:00
egutierrez 6add50311b feat(gamedev): comfyui_build_splash_art_workflow — splash/loading screen key art (apaisado 16:9, cinematográfico, espacio para título)
Builder PURO (dict API format) del grupo gamedev/gamedev-2d, hermano de
comfyui_build_card_art_workflow y comfyui_build_parallax_background_workflow.

Genera la ilustración grande de una pantalla de portada / loading screen / key
art en formato pantalla apaisado 16:9 (~1024x576), composición cinematográfica
(wide shot) con aire para superponer el título del juego. Compone
comfyui_build_hires_fix_workflow (si hires) o comfyui_build_txt2img_workflow +
comfyui_inject_lora (estilo opcional). Genera SOLO la ilustración: el negativo
por defecto rechaza text/title/logo/UI/frame para que el motor componga el
título encima.

- 9 tests offline verde (golden hires, apaisado width>height, batch_size, sin
  hires, dims/mood/lora reflejados, error scene vacío, determinismo).
- .md autosuficiente (Ejemplo + Cuando usarla + Gotchas) + fila en
  docs/capabilities/gamedev-2d.md.
- Probado e2e en GPU 8GB lowvram: 1 splash real (héroe ante castillo oscuro en
  tormenta), 1024x576 -> 1536x864 (16:9 exacto) tras hires, 54s, SD1.5
  dreamshaper_8. Evidencia en reports/0159.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:23:16 +02:00
Egutierrez ab27c253c5 fix(gamedev): card_art hires roto (UltimateSDUpscale exige batch_size) + INDEX conteo gamedev 10->20
El nodo UltimateSDUpscale declara batch_size como input requerido en /object_info;
comfyui_build_hires_fix_workflow y comfyui_inject_hires_fix no lo proveian, por lo
que card_art con hires=True fallaba en runtime. Se anade batch_size: 1 a ambos
constructores + guards de regresion en los tests (card_art golden hires, builder e
inject). Verificado con una generacion real en ComfyUI (carta 768x1152, sin
node_errors, prompt_id 4033fb0b). Bump de version 1.0.0->1.0.1 en ambos .md con
growth log y gotcha.

INDEX.md: la fila gamedev decia count=10; el cluster de assets 2D documentado en
gamedev-2d.md tiene 20 funciones (15 builders tag gamedev-2d + 5 de apoyo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:00:38 +02:00
egutierrez 8fb10fdf8a feat(gamedev): comfyui_build_topdown_sprite_workflow — sprite vista cenital (top-down RPG, direccion, alpha)
Builder puro (dict API format) del grupo gamedev: sprite de personaje/objeto en
vista cenital (top-down) estilo RPG clasico/roguelike, visto desde arriba,
centrado, fondo limpio recortable a alpha. Argumento direction (south/north/east/
west) para el set de sprites de movimiento. Compone comfyui_build_txt2img_workflow
+ comfyui_inject_lora (estilo opcional) + Image Rembg (alpha). Diferenciado de
comfyui_build_sprite_sheet_workflow (vista lateral/frontal): el negativo por
defecto rechaza side/front/isometric/perspective para forzar la cenital.

Probado e2e en GPU con SD1.5 (8GB lowvram): caballero cenital, fondo transparente
(reports/0157). 10 tests offline verdes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 23:49:24 +02:00
egutierrez 0c1d2aa4fc feat(gamedev): comfyui_build_prop_object_workflow — props/objetos de escenario (objeto de mundo, alpha, perspectiva de juego)
Builder PURO del grupo gamedev: dict API format de un prop/objeto de escenario
(barril, cofre, antorcha, planta, mueble, roca, fuente, estatua). Compone
comfyui_build_txt2img_workflow + comfyui_inject_lora opcional + Image Rembg.
Diferenciado de item_icon: objeto de MUNDO (escala de escena, perspectiva
iso/lateral) vs icono plano de inventario. 10 tests offline verdes; 1 generacion
real en GPU (cofre del tesoro, RGBA 512x512, fondo recortado). reports/0155.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 23:36:21 +02:00
egutierrez 2ff111bae4 feat(browser): auto-commit con 3 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-26 23:30:35 +02:00
egutierrez d7387d9d2c feat(gamedev): comfyui_build_enemy_creature_workflow — enemigos/criaturas (cuerpo entero, alpha, variantes)
Nuevo builder del grupo gamedev/gamedev-2d para enemigos/criaturas de juego
(goblin, esqueleto, slime, dragon, boss, elemental): figura de cuerpo entero,
centrada, fondo limpio recortable a alpha, estilo consistente entre criaturas
del bestiario. Variantes por nivel/elemento (ice, fire, elite, corrupted) via
el argumento variant, que se antepone a la criatura base.

Funcion pura (dict API format) que compone funciones existentes del registry:
comfyui_build_txt2img_workflow + comfyui_inject_lora (estilo opcional) +
Image Rembg (fondo transparente). Hermano de comfyui_build_item_icon /
ui_hud / sprite_sheet_workflow.

- 8 tests offline verdes (golden/edge/error/determinismo)
- .md autosuficiente (Ejemplo + Cuando usarla + Gotchas)
- fila en docs/capabilities/gamedev-2d.md
- probado e2e en GPU (8GB lowvram, SD1.5): goblin warrior ice cuerpo entero,
  512x512 RGBA con alpha, prompt_id e770d050 (report 0154)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 23:29:54 +02:00
egutierrez 03df14df97 feat(gamedev): comfyui_build_card_art_workflow — arte de carta TCG (ilustración vertical)
Nuevo builder PURO (dict API format) del grupo gamedev-2d para la ilustración
central de una carta coleccionable (criatura/personaje/hechizo) en formato
vertical de carta (~512x768), composición centrada y dramática. Compone
comfyui_build_txt2img_workflow / comfyui_build_hires_fix_workflow (hires opcional)
+ comfyui_inject_lora (estilo opcional). Genera solo la ilustración; marco/título/
stats son composición del motor/post (el negativo por defecto los rechaza).

- 8 tests offline verdes (golden hires + edges dims/style/lora + error + determinismo).
- Generación real en GPU verificada: dragón de fuego 512x768 vertical, SD1.5, 5s
  (prompt_id 010dcdae-..., reports/0153).
- Fila en docs/capabilities/gamedev-2d.md.

Gap: el path hires=True falla hoy por bug del builder hermano
comfyui_build_hires_fix_workflow (nodo UltimateSDUpscale exige batch_size que el
builder no emite); abierta proposal improve_function. Usar hires=False hasta el fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 23:22:36 +02:00
egutierrez d0960bed70 feat(browser): auto-commit con 3 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-26 23:15:30 +02:00
egutierrez 0dd2718c95 feat(gamedev): comfyui_build_ui_hud_workflow — elementos de UI/HUD (botones/marcos/barras)
Builder puro hermano de comfyui_build_item_icon_workflow: construye el dict (API
format) del workflow de UN elemento de interfaz/HUD de juego (botón, marco/panel,
barra de vida/maná/XP, icono de UI, cursor, viñeta de menú). Pieza única centrada,
fondo limpio recortable a alpha. Compone comfyui_build_txt2img_workflow +
comfyui_inject_lora + Image Rembg.

Tests offline 7/7 verdes (golden + 4 edge + error + determinismo). Generación real
verificada en GPU (8GB lowvram): ornate health bar frame -> PNG 512x512 RGBA con
alpha recortado (reports/0152). Fila añadida en docs/capabilities/gamedev-2d.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 23:14:48 +02:00
egutierrez 4c4eec4b1d feat(browser): auto-commit con 6 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-26 23:09:32 +02:00
egutierrez f5387aa30e feat(gamedev): comfyui_build_emote_workflow — emotes/expresiones de personaje
Builder puro (dict API format) del grupo gamedev: genera el workflow de UN
emote/expresion facial del mismo personaje (alegre, triste, enfadado,
sorprendido, neutral) para sistema de dialogo, retratos reactivos o emotes de
chat. La clave es la consistencia del personaje entre expresiones: ref_face
encadena IPAdapter-FaceID para que varie solo la expresion y el rostro sea el
mismo; facedetailer regenera la cara conservando la expresion.

Compone comfyui_build_ipadapter_workflow / comfyui_build_txt2img_workflow +
comfyui_inject_lora + comfyui_build_facedetailer_workflow. Hermano de
comfyui_build_portrait_avatar/sprite_sheet_workflow.

12 tests offline verdes (golden + edge + error) y 1 generacion real verificada
en GPU (8GB lowvram): la expresion happy/smiling se lee claramente. Fila en
docs/capabilities/gamedev-2d.md. Evidencia en reports/0151.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 23:08:49 +02:00
egutierrez 3980fbbffb feat(gamedev): comfyui_build_normal_map_workflow — normal/depth map de sprite para 2.5D
Builder puro (dict API format) que genera el normal map (o depth/height) de un
sprite existente para iluminacion dinamica 2.5D (Godot normal_map, Unity sprite
normal). Pipeline LoadImage -> preprocesador controlnet_aux -> SaveImage, ~0 VRAM.

method selecciona el nodo (verificados contra /object_info):
- normal (default): BAE-NormalMapPreprocessor, normal canonico azul/violeta usable
  directo en motor.
- normal_midas: MiDaS, unico con control de intensidad (strength -> a).
- normal_dsine: DSINE. depth: DepthAnythingV2 (height en gris).

Nodos de normal NATIVOS, sin necesidad de depth->Sobel post (gap innecesario).
11 tests offline verdes. Probado e2e en GPU (8GB): normal map de un icono de
pocion, prompt_id d47f9943, tono azul/violeta verificado. Report 0150.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 23:01:08 +02:00
egutierrez 4886305d49 feat(gamedev): comfyui_build_parallax_background_workflow — fondo en capas para parallax 2.5D
Builder puro (dict API format) del grupo gamedev-2d: genera el fondo apaisado
(txt2img) y su mapa de profundidad (DepthAnythingV2Preprocessor sobre el VAEDecode),
guardando ambos como PNG. El corte en N bandas por rango de profundidad queda como
post-proceso documentado (gap split_parallax_layers). Compone
comfyui_build_txt2img_workflow. 8 tests offline verdes; probado e2e en GPU
(RTX 3070 8GB lowvram): fondo de bosque + depth map, prompt_id
11763613-33cf-4f63-8405-34f75c1c89be. class_types verificados contra /object_info.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:50:07 +02:00
egutierrez 404e2e4d0c feat(gamedev): comfyui_build_portrait_avatar_workflow — retratos/avatares de personaje
Builder puro (dict API format) hermano de sprite_sheet/item_icon: retrato/avatar
de personaje (busto centrado, cara al espectador, fondo simple con vinheta) para
dialogo, perfil o seleccion de personaje. Con ref_face encadena IPAdapter-FaceID
(rostro consistente entre retratos); con facedetailer regenera la cara con detalle
(FaceDetailer de Impact-Pack). Compone txt2img/ipadapter + inject_lora + facedetailer.

9 tests offline en verde + 1 generacion real verificada (mujer caballero pelirroja,
cara nitida). Fila en docs/capabilities/gamedev-2d.md. Report 0148.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:40:30 +02:00
egutierrez 3f465aceed feat(gamedev): comfyui_build_item_icon_workflow — iconos de inventario
Builder puro hermano de pixelart/sprite_sheet/seamless_tile: arma el dict
(API format) para iconos de items (espada/pocion/anillo/libro/escudo).
txt2img cuadrado + prompt scaffold de icono + LoRA estilo opcional + Rembg
(alpha). Compone comfyui_build_txt2img_workflow + comfyui_inject_lora.

Test offline 7/7 verde. Generacion real verificada (icono de pocion de
salud centrado, RGBA fondo recortado, prompt_id 70b7a52a, 512x512 SD1.5).
Fila en docs/capabilities/gamedev-2d.md. Detalle en report 0147.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:31:59 +02:00
egutierrez 3be8b28a8f feat(orchestration): fleet_send_text — nudge fiable por pane_id estable
El nudge del orquestador apuntaba al window_id (@N) de tmux, que migra cuando
el focus-swap de FleetView recrea windows (break-pane/join-pane): el texto
acababa en el window equivocado o en otro agente (a veces no llega). Ademas,
texto y Enter en la misma invocacion hacian que el TUI no interpretara el submit.

Nueva funcion fleet_send_text_bash_infra (grupo orchestration) que:
- resuelve el pane_id (%N) estable fresco justo antes de enviar (sessionId/PID
  a pane via tmux list-panes -a + walk de ancestros /proc), no el @N volatil;
- manda texto literal y Enter en invocaciones separadas;
- verifica con capture-pane que el texto llego antes del submit, con reintento;
- guards anti-self y error claro si el target no resuelve a un pane vivo.

Test (19/19) sobre socket tmux propio: confirma que tras break-pane el pane_id
no migra y el reenvio sigue llegando. orchestration.md (seccion Nudge + catalogo)
actualizado para usar la funcion en lugar del send-keys -t <@N> manual.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 21:08:47 +02:00
egutierrez aeefd09f19 feat(gamedev): ronda 2b — 5 builders de workflow 2D (pixelart/seamless/iso/sprite/vfx)
Cinco builders puros que devuelven dict API format, cada uno componiendo funciones
existentes del registry (comfyui_build_txt2img_workflow, comfyui_inject_*,
comfyui_build_ipadapter_workflow). class_types verificados contra /object_info.
Probados e2e en GPU (8GB lowvram): pixelart (pixel-perfect), seamless (sin costura),
vfx (AnimateDiff loop -> luma-alpha -> spritesheet RGBA). 30 tests offline verdes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:16:16 +02:00
egutierrez e57da2f6d5 feat(gamedev): ronda 1 — pixelize + luma→alpha + export-godot (grupo gamedev)
Tres funciones CPU-only del lote gamedev 2D + 2 helpers puros + grupo de capacidad:

- comfyui_pixelize_image_py_ml (impure): Fase 2 pixelart — downscale nearest +
  cuantizacion a N colores / paleta fija (game-boy/pico-8/nes) + re-upscale nearest.
- comfyui_matting_luma_to_alpha_py_ml (impure): frame VFX sobre negro -> RGBA por
  luminancia ponderada (translucidos con additive blend).
- comfyui_export_asset_to_godot_py_pipelines (impure): puente ComfyUI -> Godot 4 —
  copia a res://assets/<dir> por kind + .import por tipo + filtro Nearest si pixelart
  + reimport headless best-effort. Compone los 2 helpers puros.
- godot_map_asset_dir_py_core, godot_clean_asset_name_py_core (pure): nucleos
  reutilizables del pipeline.
- docs/capabilities/gamedev-2d.md + INDEX: grupo nuevo gamedev.

Tests 33/33 verdes (offline PIL/numpy). Golden real verificado: asset de
~/ComfyUI/output -> /tmp/godot_test_proj con .import correcto y reimport headless
real de Godot 4.7. Sin GPU, sin red, sin tocar proyectos del usuario.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 19:43:47 +02:00
egutierrez 9508fff282 docs: añade diseño de integración ComfyUI → Godot (puente de assets)
Mapa de ambas estructuras (output/ de ComfyUI + res://assets/ de Godot 4),
tabla tipo-de-asset → carpeta destino → import settings clave, y propuesta
de pipeline export_asset_to_godot que compone helpers atómicos + reimport
headless (gap confirmado: 0 funciones godot en el registry).

Documenta el gotcha de Godot 4: el filtro Nearest del pixelart se setea
global (default_texture_filter=0) o por override, no por .import por defecto.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 19:25:29 +02:00
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
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
420 changed files with 51190 additions and 90 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.
+13
View File
@@ -86,6 +86,19 @@ 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.
**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:
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**
+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
+26 -9
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,16 @@ 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/pane 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** NO leas ni caches el `@N`: usa `fleet_send_text` (grupo `orchestration`), que resuelve
el `pane_id` (`%N`) ESTABLE fresco a partir del `sessionId`/PID en el momento del envío — el `@N` migra
con el focus-swap y mandaría el texto al agente equivocado (ver sección Nudge).
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 |
@@ -253,18 +263,24 @@ verificas → `kill_fleet_agent` libera el slot. No uses `pkill`/`killall` ni `k
### Nudge — `ESTANCADO`
Agente idle con `dod_contract` sin cumplir y sin actividad > umbral (10 min). Empújalo a cerrar SU DoD
inyectando en su pane tmux:
inyectando texto en su pane con la función `fleet_send_text` (grupo `orchestration`):
```bash
tmux -L "${FLEET_SOCKET:-fleet}" send-keys -t <window_id> \
"Sigues idle con tu DoD-contrato sin cerrar. Falta: <gap>. Cierra el golden+edge+error con evidencia, o reporta el bloqueo concreto." Enter
./fn run fleet_send_text <sessionId> \
"Sigues idle con tu DoD-contrato sin cerrar. Falta: <gap>. Cierra el golden+edge+error con evidencia, o reporta el bloqueo concreto." \
--socket "$FLEET_SOCKET"
```
El `window_id` es el campo `tmux_window` (p.ej. `@20`) de `apps/fleetview/fleetview list --json`:
`fleet_send_text` resuelve el **`pane_id` (`%N`) ESTABLE FRESCO** del agente justo antes de enviar (a
partir del `sessionId` → PID → pane, leyendo `tmux list-panes -a` en el momento), y manda el texto
literal y el `Enter` en invocaciones **separadas**, verificando con `capture-pane` que el texto llegó
antes de hacer submit (reintenta si no). Acepta el target por `sessionId` (exacto o prefijo) o por PID.
```bash
apps/fleetview/fleetview list --json | jq -r '.[] | select(.session_id|startswith("<sid>")) | .tmux_window'
```
**NO uses `tmux send-keys -t <window_id @N>` a mano para esto.** El `window_id` (`@N`, p.ej. `@20`) que
expone `fleetview list --json` MIGRA cuando el focus-swap recrea windows (`break-pane`+`join-pane`):
`@32` → `@34`. Enviar al `@N` viejo (cacheado por el bloque `FLEET-STATE` o leído un instante antes)
manda el texto al window equivocado o a otro agente — esa era la causa de "el nudge a veces no llega al
agente correcto". `fleet_send_text` nunca usa `@N`; usa el `pane_id` (`%N`), que no migra.
**Solo a idle/ESTANCADO. JAMÁS a un agente en `waiting`/`preguntando`** — esos te reclaman a TI, no un
empujón del bot.
@@ -322,6 +338,7 @@ en lote.
| `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) |
| `fleet_send_text_bash_infra` | Empujar texto al input de UN agente (nudge) resolviendo su `pane_id` (`%N`) ESTABLE FRESCO justo antes de enviar — NO el `window_id` (`@N`), que migra con el focus-swap y manda el texto al agente equivocado. Texto literal + `Enter` en invocaciones separadas, verificado con `capture-pane` + reintento. Guard anti-self. Reemplaza el `tmux send-keys -t <@N>` manual del nudge |
| `notify_desktop_go_infra` | Notificación de escritorio del fleet (`notify-send --app-name=fleetview`, degradación silenciosa si no hay `notify-send`). La usa el orquestador/watcher para avisar a la persona de un `RECLAMA` u otro evento urgente cuando no está mirando la terminal |
**Cómo invocarlas.** Las Bash y Python del grupo se lanzan con `./fn run <id> [args]` (verificado:
+3 -2
View File
@@ -8,7 +8,8 @@
},
"enabledMcpjsonServers": [
"registry",
"jupyter"
"jupyter",
"orchestrator"
],
"hooks": {
"PreToolUse": [
@@ -65,4 +66,4 @@
}
]
}
}
}
+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"
}
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ version: "0.1.0"
purity: impure
signature: "build_wasm_cpp_app(app_name: string, [--no-budget-check]) -> void"
description: "Compila una app C++ del registry (cpp/apps/<name>) a WASM via emscripten. Sale build/wasm/<name>/<name>.{html,js,wasm,wasm.gz}. Falla si gzip > 2 MB."
tags: [wasm, emscripten, cpp, build, gamedev, pendiente-usar]
tags: [wasm, emscripten, cpp, build, gamedev-engine, pendiente-usar]
uses_functions: []
uses_types: []
returns: []
+72
View File
@@ -0,0 +1,72 @@
---
name: fleet_send_text
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: "fleet_send_text <sessionId|PID> \"<texto>\" [--socket <s>] [--no-enter] [--retries N] [--dry-run]"
description: "Empuja texto a UN agente de la flota tmux de forma fiable, resolviendo su pane_id (%N) ESTABLE FRESCO justo antes de enviar. Es el reemplazo del nudge antiguo del orquestador, que apuntaba al window_id (@N) leido del JSON de la flota: ese @N MIGRA cuando el focus-swap de FleetView (break-pane + join-pane) recrea windows, asi que enviar al @N viejo (cacheado por el bloque FLEET-STATE o leido un instante antes) mandaba el texto al window equivocado o a otro agente. fleet_send_text resuelve sessionId -> PID (sessions/<PID>.json) -> el pane cuyo proceso (o un ancestro suyo en /proc) es pane_pid, leyendo tmux list-panes -a en el momento del envio, y usa el pane_id (%N) que NO migra. Ademas manda el texto literal (send-keys -l) y el Enter en invocaciones SEPARADAS, verificando con capture-pane que el texto aparecio en el input antes de pulsar Enter; reintenta si no aparece. Guards: NO envia a tu propio pane; error claro si el target no resuelve a un pane vivo. Por defecto EJECUTA; --dry-run imprime el plan sin enviar."
tags: [fleet, claude-fleet, orchestration, tmux, nudge, send-keys, infra]
uses_functions: []
uses_types: []
error_type: error_go_core
file_path: "bash/functions/infra/fleet_send_text.sh"
tested: true
tests:
- "golden: envio por PID resuelve el pane_id estable, inyecta el texto y se verifica via capture-pane"
- "edge: tras break-pane (focus-swap) el pane_id NO migra y el reenvio sigue llegando"
- "edge: resolucion por prefijo de sessionId (sessions/<pid>.json) entrega el texto"
- "edge: --dry-run no inyecta nada y reporta status=dry-run"
- "error: sessionId no resuelto rc=2; falta texto rc=2; PID sin pane vivo rc=4"
- "guard: enviar a la sesion actual (self) rc=3"
test_file_path: "bash/functions/infra/fleet_send_text_test.sh"
params:
- name: target
desc: "Primer arg posicional: sessionId del agente (exacto o prefijo) o su PID (todo digitos). Por sessionId se busca en sessions/*.json el que case y su archivo (<pid>.json) da el PID; por PID se usa directo."
- name: texto
desc: "Segundo arg posicional: el texto a inyectar en el input del agente (entre comillas)."
- name: --socket
desc: "Socket tmux del perfil FleetView donde vive el pane. Default: $FLEET_SOCKET, o 'fleet' si no esta seteada."
- name: --no-enter
desc: "Deja el texto en el input sin pulsar Enter (no hace submit). Por defecto envia el Enter en una invocacion separada tras el texto."
- name: --retries
desc: "Numero de reintentos si el texto no aparece en el pane tras el send (default 2). Cada reintento limpia el input con C-u antes de reenviar."
- name: --dry-run
desc: "Imprime el plan (PID, sessionId, pane, socket) y NO envia nada. Sin esto, ejecuta."
output: "Imprime una linea de plan (target, PID, sessionId, socket, pane resuelto, modo de envio) y una linea final parseable 'pane=%N intento=N status=ok|dry-run'. Exit 0 ok/dry-run; 2 uso incorrecto o target no resuelto a PID; 3 guard (target es la sesion actual); 4 no se encontro pane vivo para el target; 5 enviado pero no verificado tras los reintentos."
---
# fleet_send_text
Empuja texto al input de **un** agente de la flota tmux de forma fiable. Resuelve el `pane_id` (`%N`) **estable** del agente **fresco** justo antes de enviar (nunca cachea el `window_id` `@N`, que migra con el focus-swap), manda el texto literal y el `Enter` en invocaciones **separadas**, y verifica con `capture-pane` que el texto llegó antes de hacer submit. Es el reemplazo del patrón de nudge antiguo (`tmux send-keys -t <window_id @N>`), que fallaba "a veces" porque enviaba al window equivocado tras un focus-swap.
## Ejemplo
```bash
# Nudge a un ejecutor estancado por sessionId (el orquestador lo llama tras detectar ESTANCADO):
./fn run fleet_send_text 32945650-a4e1-472b-90c9-5b38ef60a463 \
"Sigues idle con tu DoD-contrato sin cerrar. Falta: el error path con evidencia. Cierralo o reporta el bloqueo." \
--socket "$FLEET_SOCKET"
# Por prefijo de sessionId, en el socket por defecto ($FLEET_SOCKET o "fleet"):
./fn run fleet_send_text 32945650 "Recuerda pushear la rama antes de cerrar."
# Dejar texto en el input sin hacer submit (--no-enter), o solo ver el plan (--dry-run):
./fn run fleet_send_text 48213 "borrador..." --no-enter
./fn run fleet_send_text 48213 "texto" --dry-run
```
## Cuando usarla
Úsala desde el modo orquestador siempre que necesites **inyectar texto en el input de un agente** de la flota: el **nudge** a un `ESTANCADO`, el aviso de un gap concreto a un ejecutor cuyo cierre falló la verificación, o cualquier mensaje dirigido. Sustituye al `tmux send-keys -t <window_id>` manual. Resuelve el target por sessionId (exacto o prefijo) o por PID. **Solo a idle/ESTANCADO; jamás a un agente en `waiting`/`preguntando`** (esos te reclaman a ti, no un empujón del bot). Para *cerrar* un ejecutor verificado `met` no es esto: usa `kill_fleet_agent`.
## Gotchas
- **El bug que arregla — el `window_id` (`@N`) MIGRA**: el focus-swap de FleetView (`tmux_swap_window_into_console.go`) trae el claude objetivo a la console con `break-pane` + `join-pane`, lo que **recrea windows** y cambia el `@N` del agente (`@32``@34`). El bloque `FLEET-STATE` y el JSON de la flota pueden traer un `@N` ya viejo. Enviar a ese `@N` manda el texto al window equivocado o a otro agente. Esta función NUNCA usa `@N`: resuelve el `pane_id` (`%N`), que se **preserva** durante toda la vida del pane aunque el pane se mueva de window. Verificado en test: tras `break-pane` el `window_id` pasa de `@0` a `@1` pero el `pane_id` sigue `%0` y el envío sigue llegando.
- **Resolución fresca**: el mapa `pane_pid → pane_id` se lee con `tmux -L <socket> list-panes -a` **en el momento del envío**, no se cachea. La resolución sube por los ancestros de `/proc` desde el PID del agente hasta casar un `pane_pid`: cubre tanto `exec claude` (pane_pid == claude pid, match directo, como hace `spawn_fleet_agent`) como un claude lanzado bajo un shell (pane_pid == shell ancestro).
- **Texto y Enter separados**: el texto va con `send-keys -l` (literal, sin interpretar nombres de tecla), luego `sleep 0.3`, y el `Enter` en una **invocación aparte**. Mandar texto+Enter juntos hace que el TUI de Claude Code a veces no interprete el Enter como submit. La verificación con `capture-pane` se hace **antes** del Enter (tras el submit el TUI vacía el input y no se podría comprobar). Si el texto no aparece, limpia el input con `C-u` y reintenta (`--retries`, default 2).
- **Impura**: inyecta teclas en un pane ajeno. Por defecto EJECUTA; usa `--dry-run` para inspeccionar el plan antes.
- **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 autoenvio").
- **Verificación por fragmento ancla**: comprueba que aparezcan los primeros 24 caracteres del texto (no el texto completo) para no dar falso negativo cuando el input del TUI wrapea un mensaje largo en varias líneas.
- **Socket**: si no pasas `--socket`, usa `$FLEET_SOCKET` o `"fleet"`. Si el agente no está en ese socket, no se encontrará el pane (exit 4).
+266
View File
@@ -0,0 +1,266 @@
#!/usr/bin/env bash
# fleet_send_text — empuja texto a UN agente de la flota tmux de forma fiable.
#
# El problema que resuelve: el orquestador "nudgea" a los ejecutores con
# `tmux send-keys`. El patron antiguo apuntaba al `window_id` (`@N`) leido del
# JSON de la flota. Pero el focus-swap de FleetView (`break-pane` + `join-pane`)
# RECREA windows, asi que el `@N` de un agente MIGRA (p.ej. `@32` -> `@34`) cada
# vez que se entra/sale de su window. Enviar al `@N` viejo (cacheado por el bloque
# FLEET-STATE o leido un instante antes) manda el texto al window equivocado o a
# otro agente -> "a veces no llega al agente correcto". Ademas, mandar el texto y
# el `Enter` en la MISMA invocacion hace que el TUI de Claude Code a veces no
# interprete el Enter como submit.
#
# Esta funcion arregla las dos cosas:
# 1. Resuelve el `pane_id` ESTABLE (`%N`) FRESCO justo antes de enviar. El
# `pane_id` se preserva durante toda la vida del pane aunque el pane se mueva
# de window con break/join — NO migra como el `window_id`. La resolucion va
# sessionId -> PID (sessions/<PID>.json) -> el pane cuyo proceso (o un
# ancestro suyo en /proc) es `pane_pid`, leyendo `tmux list-panes -a` en el
# momento del envio.
# 2. Manda el texto literal (`send-keys -l`), espera un poco, y el `Enter` en
# una invocacion SEPARADA. Verifica con `capture-pane` que el texto aparecio
# en el pane antes de pulsar Enter; si no, reintenta.
#
# Guards: NO envia a tu propio pane (la sesion que invoca la funcion). Error claro
# si el sessionId/PID no resuelve a un pane vivo.
#
# Funcion IMPURA: inyecta teclas en un pane tmux ajeno. Por defecto EJECUTA (es el
# caso de uso del bot: nudgear a un ejecutor). Usa --dry-run para ver el plan sin
# enviar nada.
#
# Overrides de entorno (testabilidad, no para uso normal):
# FN_FLEET_SESSIONS_DIR directorio de los sessions JSON. Default ~/.claude/sessions
# FN_FLEET_SELF_PID fuerza el PID propio (salta la deteccion por /proc)
set -euo pipefail
IFS=$' \t\n'
# Resuelve el pane_id (%N) ESTABLE de un PID dado, leyendo el mapa fresco de panes
# del socket. Sube por la cadena de ancestros del PID en /proc hasta encontrar un
# `pane_pid` del mapa: cubre tanto el caso `exec claude` (pane_pid == claude pid,
# match directo) como el de un claude lanzado bajo un shell (pane_pid == shell
# ancestro). Imprime el pane_id y devuelve 0 si lo encuentra; 1 si no.
# $1 = PID objetivo
# $2 = texto del mapa "pane_pid pane_id" (una linea por pane)
_fleet_resolve_pane_for_pid() {
local p="${1:-}" panes_map="${2:-}" guard=0 pane_id
while [[ -n "$p" && "$p" != "0" && "$p" != "1" ]]; do
pane_id="$(awk -v pp="$p" '$1==pp {print $2; exit}' <<<"$panes_map")"
if [[ -n "$pane_id" ]]; then
printf '%s\n' "$pane_id"
return 0
fi
p="$(awk '{print $4}' "/proc/$p/stat" 2>/dev/null || true)"
guard=$((guard + 1))
[[ "$guard" -gt 64 ]] && break
done
return 1
}
fleet_send_text() {
local target="" txt="" socket="" do_enter=1 dry=0 retries=2
local got_target=0 got_text=0
while [[ $# -gt 0 ]]; do
case "$1" in
--socket) shift; socket="${1:-}" ;;
--no-enter) do_enter=0 ;;
--retries) shift; retries="${1:-2}" ;;
--dry-run) dry=1 ;;
-h|--help)
cat <<'USAGE'
Uso: fleet_send_text <sessionId|PID> "<texto>" [--socket <s>] [--no-enter] [--retries N] [--dry-run]
Empuja <texto> a UN agente de la flota tmux resolviendo su pane_id (%N) ESTABLE
FRESCO justo antes de enviar (no cachea el window_id @N, que migra con el
focus-swap). Manda el texto literal y el Enter en invocaciones separadas, y
verifica con capture-pane que el texto aparecio antes de pulsar Enter;
reintenta si no.
Argumentos:
<sessionId|PID> Primer posicional: sessionId del agente (exacto o prefijo) o
su PID (todo digitos). Por sessionId se busca en
sessions/*.json el que case; su archivo (<pid>.json) da el PID.
"<texto>" Segundo posicional: el texto a inyectar en el input del agente.
Opciones:
--socket <s> Socket tmux del perfil FleetView. Default: $FLEET_SOCKET, o "fleet".
--no-enter Deja el texto en el input sin pulsar Enter (no hace submit).
--retries N Reintentos si el texto no aparece tras el send (default 2).
--dry-run Imprime el plan (PID, sessionId, pane, socket) y NO envia nada.
-h, --help Esta ayuda.
Salida: linea de resultado con `pane=%N` usado e `intento=N`. Exit 0 ok/dry-run;
2 uso incorrecto o target no resuelto; 3 guard (target es la sesion actual);
4 no se encontro pane vivo para el target; 5 enviado pero no verificado tras los
reintentos.
Ejemplos:
fleet_send_text 32945650-a4e1-472b-90c9-5b38ef60a463 "Cierra tu DoD o reporta el bloqueo." --socket "$FLEET_SOCKET"
fleet_send_text 32945650 "Falta el error path con evidencia." # por prefijo de sessionId
fleet_send_text 48213 "texto" --no-enter --dry-run # por PID, solo ver el plan
USAGE
return 0 ;;
--*)
echo "fleet_send_text: opcion desconocida '$1' (usa -h)" >&2
return 2 ;;
*)
if [[ "$got_target" -eq 0 ]]; then
target="$1"; got_target=1
elif [[ "$got_text" -eq 0 ]]; then
txt="$1"; got_text=1
else
echo "fleet_send_text: argumento extra '$1' (target y texto ya fijados)" >&2
return 2
fi ;;
esac
shift
done
[[ "$got_target" -eq 0 ]] && {
echo "fleet_send_text: falta el target (sessionId o PID). Usa -h." >&2
return 2
}
[[ "$got_text" -eq 0 ]] && {
echo "fleet_send_text: falta el texto a enviar. Usa -h." >&2
return 2
}
[[ "$retries" =~ ^[0-9]+$ ]] || {
echo "fleet_send_text: --retries debe ser un entero (recibido '$retries')" >&2
return 2
}
local sessions_dir="${FN_FLEET_SESSIONS_DIR:-$HOME/.claude/sessions}"
[[ -z "$socket" ]] && socket="${FLEET_SOCKET:-fleet}"
command -v tmux >/dev/null 2>&1 || {
echo "fleet_send_text: tmux no esta instalado" >&2
return 1
}
# -----------------------------------------------------------------------
# Resolver (PID, sessionId) a partir del target. Mismo patron que
# kill_fleet_agent: por PID directo, o por sessionId (exacto/prefijo)
# buscando en sessions/*.json.
# -----------------------------------------------------------------------
local pid="" sid=""
if [[ "$target" =~ ^[0-9]+$ ]]; then
pid="$target"
local sfile="$sessions_dir/$pid.json"
if [[ -f "$sfile" ]] && command -v jq >/dev/null 2>&1; then
sid="$(jq -r '.sessionId // ""' "$sfile" 2>/dev/null || true)"
fi
else
command -v jq >/dev/null 2>&1 || {
echo "fleet_send_text: jq no esta instalado (necesario para resolver el sessionId)" >&2
return 1
}
local f base candidate_sid
for f in "$sessions_dir"/*.json; do
[[ -f "$f" ]] || continue
candidate_sid="$(jq -r '.sessionId // ""' "$f" 2>/dev/null || true)"
[[ -z "$candidate_sid" ]] && continue
if [[ "$candidate_sid" == "$target" || "$candidate_sid" == "$target"* ]]; then
base="$(basename "$f" .json)"
pid="$base"
sid="$candidate_sid"
break
fi
done
fi
[[ -z "$pid" ]] && {
echo "fleet_send_text: no se pudo resolver el target '$target' a un PID (sessions en $sessions_dir)" >&2
return 2
}
# -----------------------------------------------------------------------
# Guard — anti-self: no enviar a la sesion que invoca la funcion.
# -----------------------------------------------------------------------
local self_pid="${FN_FLEET_SELF_PID:-}"
if [[ -z "$self_pid" ]]; then
local walk="$$" guard=0 comm
while [[ -n "$walk" && "$walk" != "0" && "$walk" != "1" ]]; do
comm="$(cat "/proc/$walk/comm" 2>/dev/null || true)"
if [[ "$comm" == "claude" ]]; then
self_pid="$walk"
break
fi
walk="$(awk '{print $4}' "/proc/$walk/stat" 2>/dev/null || true)"
guard=$((guard + 1))
[[ "$guard" -gt 64 ]] && break
done
fi
if [[ -n "$self_pid" && "$pid" == "$self_pid" ]]; then
echo "fleet_send_text: REHUSADO — el target (PID $pid) es la sesion actual. No me autoenvio." >&2
return 3
fi
# -----------------------------------------------------------------------
# Resolver el pane_id (%N) ESTABLE FRESCO. Mapa pane_pid->pane_id del socket
# leido AHORA; subir por ancestros del PID hasta casar un pane_pid.
# -----------------------------------------------------------------------
local panes_map pane=""
panes_map="$(tmux -L "$socket" list-panes -a -F '#{pane_pid} #{pane_id}' 2>/dev/null || true)"
if [[ -n "$panes_map" ]]; then
pane="$(_fleet_resolve_pane_for_pid "$pid" "$panes_map" || true)"
fi
[[ -z "$pane" ]] && {
echo "fleet_send_text: no se encontro un pane vivo para el target '$target' (PID $pid) en el socket '$socket'." >&2
return 4
}
# -----------------------------------------------------------------------
# Plan.
# -----------------------------------------------------------------------
local enter_desc; [[ "$do_enter" -eq 1 ]] && enter_desc="texto + Enter separado" || enter_desc="solo texto (--no-enter)"
echo "fleet_send_text — target: $target PID: $pid sessionId: ${sid:-?} socket: $socket pane: $pane envio: $enter_desc retries: $retries"
if [[ "$dry" -eq 1 ]]; then
echo "DRY-RUN: no se ha enviado nada."
echo "pane=$pane intento=0 status=dry-run"
return 0
fi
# -----------------------------------------------------------------------
# Enviar + verificar. El texto se manda literal (-l); el Enter va en una
# invocacion separada tras un sleep. Verificamos ANTES del Enter (el texto
# esta en el input; tras Enter el TUI vacia el input y no se podria verificar).
# Si el texto no aparece, limpiamos el input (C-u) y reintentamos.
# -----------------------------------------------------------------------
local anchor="${txt:0:24}" # fragmento ancla (evita falsos negativos por wrapping)
local i cap ok=0 used_try=0
for (( i=1; i<=retries+1; i++ )); do
tmux -L "$socket" send-keys -t "$pane" -l -- "$txt" 2>/dev/null || true
sleep 0.3
cap="$(tmux -L "$socket" capture-pane -p -t "$pane" 2>/dev/null || true)"
if grep -qF -- "$anchor" <<<"$cap"; then
ok=1; used_try="$i"
break
fi
# No aparecio: limpiar el input antes de reintentar.
tmux -L "$socket" send-keys -t "$pane" C-u 2>/dev/null || true
sleep 0.2
done
if [[ "$ok" -ne 1 ]]; then
echo "fleet_send_text: texto enviado pero NO verificado en el pane $pane tras $((retries+1)) intentos." >&2
echo "pane=$pane intento=$((retries+1)) status=unverified" >&2
return 5
fi
# Texto presente en el input. Ahora el Enter (separado) para hacer submit.
if [[ "$do_enter" -eq 1 ]]; then
tmux -L "$socket" send-keys -t "$pane" Enter 2>/dev/null || true
fi
echo "fleet_send_text: OK — texto inyectado en el pane $pane (intento $used_try)$([[ "$do_enter" -eq 1 ]] && echo " + Enter")."
echo "pane=$pane intento=$used_try status=ok"
return 0
}
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
fleet_send_text "$@"
fi
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
# Tests para fleet_send_text. Levanta un socket tmux PROPIO de test
# (fleet_test_<pid>, nunca el socket "fleet" real) con un pane `cat` vivo, y
# verifica: envio + verificacion via capture-pane (golden), supervivencia al
# focus-swap (break-pane preserva el pane_id), resolucion por sessionId fake,
# y los paths de error/guard. No toca la flota real ni ningun agente.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/fleet_send_text.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_not_contains() {
local test_name="$1" needle="$2" haystack="$3"
if echo "$haystack" | grep -qF "$needle"; then
echo "FAIL: $test_name — should NOT contain '$needle'"
echo " got: $haystack"
FAIL=$((FAIL+1))
else
echo "PASS: $test_name"
PASS=$((PASS+1))
fi
}
assert_rc() {
local test_name="$1" expected="$2" actual="$3"
if [[ "$actual" == "$expected" ]]; then
echo "PASS: $test_name (rc=$actual)"
PASS=$((PASS+1))
else
echo "FAIL: $test_name — expected rc=$expected, got rc=$actual"
FAIL=$((FAIL+1))
fi
}
command -v tmux >/dev/null 2>&1 || { echo "SKIP: tmux no instalado"; exit 0; }
# --- Socket de test PROPIO + pane `cat` vivo (con echo de tty) ---
SOCK="fleet_test_$$"
TMP="$(mktemp -d)"
SESS="$TMP/sessions"
mkdir -p "$SESS"
cleanup() {
tmux -L "$SOCK" kill-server 2>/dev/null || true
rm -rf "$TMP"
}
trap cleanup EXIT
tmux -L "$SOCK" new-session -d -s t -x 120 -y 30 'cat'
sleep 0.4
PANE_PID="$(tmux -L "$SOCK" list-panes -a -F '#{pane_pid}' | head -n1)"
PANE_ID0="$(tmux -L "$SOCK" list-panes -a -F '#{pane_pid} #{pane_id}' | awk -v p="$PANE_PID" '$1==p{print $2}')"
WIN_ID0="$(tmux -L "$SOCK" list-panes -a -F '#{pane_pid} #{window_id}' | awk -v p="$PANE_PID" '$1==p{print $2}')"
echo "INFO: socket=$SOCK pane_pid=$PANE_PID pane_id=$PANE_ID0 window_id=$WIN_ID0"
# self_pid forzado a un PID que nunca sera target en los tests golden.
export FN_FLEET_SELF_PID=1
export FN_FLEET_SESSIONS_DIR="$SESS"
# --- Test 1 (golden): enviar por PID, verificar via capture-pane ---
set +e
out=$(fleet_send_text "$PANE_PID" "HOLA_FLEET_123" --socket "$SOCK" --no-enter --retries 1 2>&1); rc=$?
set -e
assert_rc "golden: envio por PID sale 0" 0 "$rc"
assert_contains "golden: reporta status=ok" "status=ok" "$out"
assert_contains "golden: reporta el pane_id estable" "pane=$PANE_ID0" "$out"
cap="$(tmux -L "$SOCK" capture-pane -p -t "$PANE_ID0")"
assert_contains "golden: el texto llego al pane (capture-pane)" "HOLA_FLEET_123" "$cap"
# limpiar input del cat
tmux -L "$SOCK" send-keys -t "$PANE_ID0" C-u; sleep 0.2
tmux -L "$SOCK" send-keys -t "$PANE_ID0" C-l 2>/dev/null || true; sleep 0.2
# --- Test 2 (edge focus-swap): mover el pane a otra window, pane_id NO migra ---
# Anadimos un segundo pane para poder break-pane el nuestro a una window nueva.
tmux -L "$SOCK" split-window -t "$WIN_ID0" -d 'cat'; sleep 0.3
tmux -L "$SOCK" break-pane -d -s "$PANE_ID0"; sleep 0.3
WIN_ID1="$(tmux -L "$SOCK" list-panes -a -F '#{pane_pid} #{window_id}' | awk -v p="$PANE_PID" '$1==p{print $2}')"
PANE_ID1="$(tmux -L "$SOCK" list-panes -a -F '#{pane_pid} #{pane_id}' | awk -v p="$PANE_PID" '$1==p{print $2}')"
echo "INFO: tras break-pane: pane_id=$PANE_ID1 (era $PANE_ID0) window_id=$WIN_ID1 (era $WIN_ID0)"
assert_contains "edge: pane_id NO cambia tras mover de window" "$PANE_ID0" "$PANE_ID1"
set +e
out=$(fleet_send_text "$PANE_PID" "TRAS_MOVER_456" --socket "$SOCK" --no-enter --retries 1 2>&1); rc=$?
set -e
assert_rc "edge: reenvio tras focus-swap sale 0" 0 "$rc"
cap="$(tmux -L "$SOCK" capture-pane -p -t "$PANE_ID1")"
assert_contains "edge: el texto sigue llegando tras mover de window" "TRAS_MOVER_456" "$cap"
tmux -L "$SOCK" send-keys -t "$PANE_ID1" C-u; sleep 0.2
# --- Test 3 (edge): resolver por sessionId (sessions/<pid>.json fake) ---
echo "{\"sessionId\":\"test-sid-aaa-111\",\"cwd\":\"/tmp/x\"}" > "$SESS/$PANE_PID.json"
set +e
out=$(fleet_send_text "test-sid-aaa" "VIA_SID_789" --socket "$SOCK" --no-enter --retries 1 2>&1); rc=$?
set -e
assert_rc "edge: resolucion por prefijo de sessionId sale 0" 0 "$rc"
cap="$(tmux -L "$SOCK" capture-pane -p -t "$PANE_ID1")"
assert_contains "edge: texto llego resolviendo por sessionId" "VIA_SID_789" "$cap"
tmux -L "$SOCK" send-keys -t "$PANE_ID1" C-u; sleep 0.2
# --- Test 4 (edge): --dry-run no envia nada ---
set +e
out=$(fleet_send_text "$PANE_PID" "NO_DEBE_APARECER_000" --socket "$SOCK" --no-enter --dry-run 2>&1); rc=$?
set -e
assert_rc "edge: dry-run sale 0" 0 "$rc"
assert_contains "edge: dry-run reporta status=dry-run" "status=dry-run" "$out"
cap="$(tmux -L "$SOCK" capture-pane -p -t "$PANE_ID1")"
assert_not_contains "edge: dry-run NO inyecto texto" "NO_DEBE_APARECER_000" "$cap"
# --- Test 5 (error): sessionId que no resuelve a PID -> rc 2 ---
set +e
out=$(fleet_send_text "sid-inexistente-zzz" "x" --socket "$SOCK" 2>&1); rc=$?
set -e
assert_rc "error: sessionId no resuelto sale 2" 2 "$rc"
assert_contains "error: mensaje de target no resuelto" "no se pudo resolver" "$out"
# --- Test 6 (error): falta el texto -> rc 2 ---
set +e
out=$(fleet_send_text "$PANE_PID" --socket "$SOCK" 2>&1); rc=$?
set -e
assert_rc "error: falta texto sale 2" 2 "$rc"
# --- Test 7 (guard anti-self): target == self_pid -> rc 3 ---
set +e
out=$(FN_FLEET_SELF_PID="$PANE_PID" fleet_send_text "$PANE_PID" "x" --socket "$SOCK" 2>&1); rc=$?
set -e
assert_rc "guard: enviar a la sesion actual sale 3" 3 "$rc"
assert_contains "guard: mensaje anti-self" "No me autoenvio" "$out"
# --- Test 8 (error): PID sin pane vivo -> rc 4 ---
set +e
out=$(fleet_send_text 999999 "x" --socket "$SOCK" 2>&1); rc=$?
set -e
assert_rc "error: PID sin pane vivo sale 4" 4 "$rc"
assert_contains "error: mensaje no pane vivo" "no se encontro un pane vivo" "$out"
# --- Resumen ---
echo ""
echo "================================"
echo "PASS: $PASS FAIL: $FAIL"
echo "================================"
[[ "$FAIL" -eq 0 ]]
+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.
+76 -10
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,8 +242,17 @@ USAGE
fi
if [[ -n "$window" ]] && command -v tmux >/dev/null 2>&1; then
tmux -L "$socket" kill-window -t "$window" 2>/dev/null || true
echo "kill_fleet_agent: window $window cerrada en el socket $socket."
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`, ...),
+16 -1
View File
@@ -170,7 +170,22 @@ USAGE
envpfx="FLEET_SOCKET=$(printf '%q' "$session") FLEET_SESSION=$(printf '%q' "$session")"
local left_cmd
if [[ -x "$bin" ]]; then
left_cmd="$envpfx exec $(printf '%q' "$bin")"
# 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\""
@@ -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
+1 -1
View File
@@ -7,7 +7,7 @@ version: "0.1.0"
purity: impure
signature: "engine_init() -> Engine; engine_shutdown(Engine&); engine_set_volume(Engine&, float)"
description: "Lifecycle del engine de audio basado en miniaudio (single-header, public domain). Inicializa device default, expone master volume, y libera recursos. Cross-platform: Windows/Linux/macOS via WASAPI/ALSA/CoreAudio y WebAudio bajo emscripten. Issue 0072b — runtime gamedev nucleo. Esta TU es la unica del proyecto que define MINIAUDIO_IMPLEMENTATION."
tags: [gamedev, audio, miniaudio]
tags: [gamedev-engine, audio, miniaudio]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "0.1.0"
purity: impure
signature: "sound_load(Engine&, const char*) -> Sound; sound_play/stop/set_volume/destroy(Sound&); play_sound_oneshot(Engine&, const char*, float)"
description: "Reproduccion de audio sobre fn::audio::Engine: carga sonidos con streaming desde disco (wav/mp3/flac/ogg), play/stop/volumen por sonido, y helper fire-and-forget para one-shots sin handle. Cross-platform via miniaudio. Issue 0072b — runtime gamedev nucleo."
tags: [gamedev, audio, miniaudio]
tags: [gamedev-engine, audio, miniaudio]
uses_functions: ["audio_engine_cpp_gamedev"]
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "0.1.0"
purity: pure
signature: "world_to_screen(Camera2D, Vec2) -> Vec2; screen_to_world(Camera2D, Vec2) -> Vec2; visible_world_rect(Camera2D) -> Rect; view_proj_matrix(Camera2D, float[16])"
description: "Camara ortografica 2D pura: pos (centro), zoom, rotacion (rad) y viewport en pixeles. Conversiones world<->screen, AABB visible y matriz view-projection 4x4 column-major lista para cualquier renderer (sokol_gfx, OpenGL, WebGPU). Fast-path sin trig si rotation==0. Issue 0072b."
tags: [gamedev, camera, 2d, math, pure]
tags: [gamedev-engine, camera, 2d, math, pure]
uses_functions: []
uses_types: ["Vec2_cpp_core", "Rect_cpp_core"]
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "0.1.0"
purity: impure
signature: "loop_run(SDL_Window*, const LoopCfg&) -> void"
description: "Game loop fixed-timestep estilo Glenn Fiedler ('Fix Your Timestep'). Desacopla simulacion (on_fixed_update con dt fijo) de renderizado (on_render con factor de interpolacion). Acumulador con cap anti spiral-of-death. Branch automatico desktop (while loop bloqueante) vs __EMSCRIPTEN__ (emscripten_set_main_loop). Issue 0072b."
tags: [gamedev, game-loop, sdl3, wasm, fixed-timestep]
tags: [gamedev-engine, game-loop, sdl3, wasm, fixed-timestep]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "0.1.0"
purity: impure
signature: "input_begin_frame(InputState&); input_process_event(InputState&, const SDL_Event*)"
description: "Snapshot unificado de input por frame para SDL3. Mapea keyboard (WASD+arrows), mouse, gamepad (SDL_Gamepad) y touch a botones logicos (left/right/up/down/action_a..y/start/back) y ejes analogicos. Expone flags *_pressed con rising edge limpio cada frame. Issue 0072b — runtime gamedev PC + WASM."
tags: [gamedev, input, sdl3, touch, gamepad]
tags: [gamedev-engine, input, sdl3, touch, gamepad]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "0.1.0"
purity: pure
signature: "make_environment() -> sg_environment; make_swapchain(int w, int h) -> sg_swapchain"
description: "Builders puros para inicializar sokol_gfx encima de un GL context creado por SDL3 (no por sokol_app). Construye sg_environment con defaults RGBA8 + depth/stencil y sg_swapchain con el default framebuffer del contexto activo. Issue 0072b — base del runtime gamedev en PC + WASM."
tags: [gamedev, sokol, gfx, sdl3, wasm]
tags: [gamedev-engine, sokol, gfx, sdl3, wasm]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "0.1.0"
purity: impure
signature: "sprite_batch_create(int cap=4096) -> SpriteBatch; sprite_batch_begin/draw/end"
description: "Batched textured quad renderer sobre sokol_gfx. Begin/draw/end con auto-flush por atlas change o capacity full. Vertex layout pos+uv+color, alpha blending estandar, GLSL 330 / GLES 300. Issue 0072b runtime gamedev — base de plataformeros, top-down, UI sprites."
tags: [gamedev, gfx, sokol, sprite, batch, 2d]
tags: [gamedev-engine, gfx, sokol, sprite, batch, 2d]
uses_functions:
- sokol_setup_cpp_gfx
uses_types:
+8 -1
View File
@@ -14,6 +14,8 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| Grupo | N | Que cubre |
|---|---|---|
| [gamedev-2d](gamedev-2d.md) | 36 | Assets 2D para Godot via ComfyUI: 31 builders de workflow (pixelart/seamless/iso/sprite/topdown/card/enemy/prop/structure/foliage/trap/projectile/decal/particle/rune/weather/badge/skill-tree/dialogue/icon/portrait/VFX...) + 5 de apoyo: post-proceso (pixelize, luma->alpha) + puente de assets a Godot 4 (.import + reimport headless). Tag canonico `gamedev-2d` (antes `gamedev`, ya unificado) |
| [gamedev-engine](gamedev-engine.md) | 8 | Runtime de juego C++ multiplataforma (PC + WebAssembly): SDL3 + sokol_gfx + miniaudio. Game loop fixed-timestep, camara 2D, input unificado (teclado/gamepad/touch), sprite batch, setup de render/audio y build a wasm. Grupo hermano de `gamedev-2d` (este ejecuta el juego, aquel genera los assets) |
| [registry](registry.md) | 17 | Auditoria y monitorizacion del propio registry: copied-code, uses-functions, unused, proposals, telemetria |
| [systemd](systemd.md) | 14 | Generar, instalar, restart y status de unit files systemd via SSH (deploys a VPS) |
| [ssh](ssh.md) | 19 | Operar hosts remotos via SSH: config, conn, ejecutar comandos, port-forward, deploys con SCP/rsync |
@@ -42,7 +44,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 +59,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 +71,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": "SD15_3d_render_redmond.safetensors", "strength_model": 0.9},
{"name": "SD15_detail_tweaker.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.
+281
View File
@@ -0,0 +1,281 @@
# Capability group: `gamedev-2d` — assets 2D para Godot (generación + post-proceso + puente)
Cluster de funciones para producir y mover assets 2D de juego entre **ComfyUI**
(generación) y **Godot 4** (consumo). Tres capas:
1. **Builders de workflow 2D** (`gamedev-2d`, GPU): construyen el dict (API format)
de los workflows ComfyUI para pixel-art, tiles seamless, isométrico, sprites de
personaje y VFX en bucle. Son **puros** (no tocan GPU al construir); el coste GPU
está al enviar con `comfyui_submit_workflow`.
2. **Post-proceso determinista** (CPU): pixelizar, recortar a alpha.
3. **Puente de assets** (CPU): coloca el resultado en un proyecto Godot
con sus import settings.
Tag único del grupo: `gamedev-2d` (los 31 builders de workflow + las 5 funciones de
apoyo de post-proceso y puente). El tag plano `gamedev` quedó deprecado y unificado a
`gamedev-2d`. El **runtime de juego C++** (el motor que ejecuta el juego: game loop,
cámara, input, render por lotes, audio) vive en el grupo hermano `gamedev-engine`.
Filtro: `mcp__registry__fn_search query="" tag="gamedev-2d"`.
Documento hermano del grupo `comfyui` (generación genérica de imágenes/video/3D).
Diseño del puente: `docs/comfyui-godot-integration.md`. Planes origen: `reports/0135`
(pixelart), `reports/0139` (entornos/tiles/iso), `reports/0137` (personajes/sprites),
`reports/0140` (VFX), `reports/0143` (ronda 2b: builders), `reports/0147` (item icons),
`reports/0149` (parallax background).
## Builders de workflow 2D (`gamedev-2d`, puros — generación)
Construyen el dict API format listo para `comfyui_submit_workflow`. Cada uno compone
funciones existentes del registry (`comfyui_build_txt2img_workflow`, `comfyui_inject_*`,
`comfyui_build_ipadapter_workflow`) — no reinventan el grafo. class_types verificados
contra `/object_info` del server (8GB lowvram). Probados e2e en GPU: pixelart, seamless,
VFX (ver `reports/0143`).
| ID | Firma corta | Qué hace |
|---|---|---|
| `comfyui_build_pixelart_workflow_py_ml` | `(positive, negative=…, *, ckpt_name="juggernaut_xl_v11…", pixel_lora="SDXL_pixel-art…", use_lcm=True, …) -> dict` | Fase 1 pixel-art: SDXL + LoRA SDXL_pixel-art (+ LCM 8 steps). El pixel-perfect es post (`comfyui_pixelize_image`). |
| `comfyui_build_seamless_tile_workflow_py_ml` | `(positive, negative="", *, tiling="enable", copy_model="Make a copy", circular_vae=True, material_lora=None, …) -> dict` | Textura tileable: `SeamlessTile` (Conv2d circular) + `CircularVAEDecode`. Coste VRAM ≈0. |
| `comfyui_build_isometric_workflow_py_ml` | `(positive, negative=…, *, iso_lora="SD15_isometric_game_assets…", grid_image=None, …) -> dict` | Asset iso 2:1: LoRA iso + ControlNet grid opcional. |
| `comfyui_build_sprite_sheet_workflow_py_ml` | `(subject, *, ref_image=None, pose_skeleton=None, char_lora=None, transparent=True, …) -> dict` | UN sprite de personaje: IPAdapter-FaceID + LoRA + ControlNet OpenPose (Advanced, end<1) + Rembg. Varias poses → sheet. SD1.5. |
| `comfyui_build_vfx_spritesheet_workflow_py_ml` | `(prompt, *, motion_model="mm_sd_v15_v2.ckpt", num_frames=16, closed_loop=True, lora=None, …) -> dict` | N frames AnimateDiff loop sobre negro (insumo de luma→alpha). 8GB: 16f@512² revienta, usar ≤8f@512² o bajar resolución. |
| `comfyui_build_item_icon_workflow_py_ml` | `(item, *, style="game icon, clean, centered", checkpoint="dreamshaper_8…", size=512, transparent=True, lora=None, …) -> dict` | UN icono de item de inventario (espada/poción/anillo/libro/escudo): txt2img cuadrado + prompt scaffold de icono + LoRA estilo opcional + Rembg (alpha). Set coherente = mismo style/checkpoint/lora por item. SD1.5. |
| `comfyui_build_portrait_avatar_workflow_py_ml` | `(character, *, style="character portrait", ref_face=None, checkpoint="dreamshaper_8…", size=512, facedetailer=True, lora=None, …) -> dict` | UN retrato/avatar de personaje (busto centrado, cara al espectador, fondo simple): txt2img + prompt scaffold de retrato + FaceDetailer (cara nítida) + LoRA estilo opcional; `ref_face` → IPAdapter-FaceID para rostro consistente entre retratos. Diálogo/perfil/selección. SD1.5. |
| `comfyui_build_emote_workflow_py_ml` | `(character, expression, *, ref_face=None, style="character portrait", checkpoint="dreamshaper_8…", size=512, facedetailer=True, lora=None, …) -> dict` | UN emote/expresión facial del MISMO personaje (alegre/triste/enfadado/sorprendido/neutral…) para diálogo, retratos reactivos o emotes de chat: txt2img + prompt scaffold de emote (`portrait of {character}, {expression} expression, emote, clean background`) + FaceDetailer (conserva la expresión); `ref_face` → IPAdapter-FaceID para que varíe SOLO la expresión y el rostro sea el mismo. UNA expresión por llamada; set = mismas claves variando `expression``comfyui_build_grid`. Probado e2e en GPU (`reports/0151`). SD1.5. |
| `comfyui_build_parallax_background_workflow_py_ml` | `(scene, *, style="game background, side-scroller…", layers=3, checkpoint="dreamshaper_8…", depth_node="DepthAnythingV2Preprocessor", width=1024, height=512, …) -> dict` | Fondo en capas para parallax 2.5D: genera el fondo apaisado (txt2img) + su depth map (`DepthAnythingV2Preprocessor` sobre el VAEDecode), dos SaveImage. El split en N bandas por profundidad es post (GAP: `split_parallax_layers`, aún no creada). Probado e2e en GPU (`reports/0149`). SD1.5. |
| `comfyui_build_normal_map_workflow_py_ml` | `(image, *, method="normal", strength=1.0, resolution=512, bg_threshold=0.1, filename_prefix="normal_map") -> dict` | Normal/depth map de un sprite existente para iluminación dinámica 2.5D (Godot CanvasItem `normal_map`, Unity sprite normal). `LoadImage → preprocesador controlnet_aux → SaveImage`. `method`: `normal` (default, `BAE-NormalMapPreprocessor`, normal canónico **azul/violeta** usable directo en motor), `normal_midas` (MiDaS, único con `strength``a`, paleta no canónica), `normal_dsine` (DSINE), `depth` (`DepthAnythingV2`, height en gris). `image` debe estar en `input/` de ComfyUI. Coste VRAM ≈0. Probado e2e en GPU (`reports/0150`). |
| `comfyui_build_ui_hud_workflow_py_ml` | `(element, *, ui_style="fantasy game UI", checkpoint="dreamshaper_8…", size=512, transparent=True, lora=None, …) -> dict` | UN elemento de interfaz/HUD de juego (botón, marco/panel, barra de vida/maná/XP, icono de UI, cursor, viñeta de menú): txt2img cuadrado + prompt scaffold de UI (`{element}, {ui_style}, game UI element, centered, clean, plain background…`) + LoRA estilo opcional + Rembg (alpha). HUD coherente = mismo `ui_style`/`checkpoint`/`lora` por pieza, varía solo `element`. El texto/label lo pone el motor (negativo empuja a `no text`). Probado e2e en GPU (`reports/0152`). SD1.5. |
| `comfyui_build_dialogue_box_workflow_py_ml` | `(box_style="fantasy RPG dialogue box", *, shape="rounded panel", checkpoint="dreamshaper_8…", width=768, height=256, transparent=True, seed=0, lora=None, …) -> dict` | EL contenedor de diálogo / bocadillo / panel de texto de juego (RPG, visual novel, aventura): marco **apaisado** (`width>height`, 768×256) con borde decorativo y un **interior plano/vacío** reservado para que el motor renderice el texto de la conversación encima → `{box_style}, {shape}, game UI dialogue box frame, ornate border, empty flat interior for text, plain background` + LoRA estilo opcional + Rembg (alpha). **DISTINTO de `ui_hud` (elementos sueltos: botón/barra/icono)**: esto es el panel-contenedor completo. `shape` (rounded panel/scroll parchment/stone tablet/speech bubble…) + set coherente = mismo `box_style`/`shape`/`checkpoint`/`lora`. El interior se mantiene liso (negativo rechaza `busy/decorated interior`); el texto lo pone el motor (negativo empuja a `no text`). Probado e2e en GPU con SD1.5 — `medieval fantasy dialogue box, wood and gold` 768×256 RGBA, panel madera+oro con interior plano y alpha (`reports/0171`). SD1.5. |
| `comfyui_build_status_effect_icon_workflow_py_ml` | `(effect, *, ui_style="game status icon, bold symbol, flat", checkpoint="dreamshaper_8…", size=256, transparent=True, seed=0, lora=None, …) -> dict` | UN icono de estado / buff-debuff (veneno, quemadura, congelación, escudo, regeneración, aturdimiento, velocidad, sangrado, maldición): **símbolo compacto** que se superpone al HUD para indicar un efecto activo, optimizado para **legibilidad a tamaño reducido** (16-32 px) → `{effect} status effect icon, {ui_style}, simple bold symbol, centered, readable at small size, plain background…` + LoRA estilo opcional + Rembg (alpha). **`size` por defecto menor (256, no 512)** porque se muestra pequeño; el negativo rechaza `intricate details/complex/cluttered` para no perder legibilidad. **DISTINTO de `item_icon` (objeto de inventario) y `ui_hud` (chrome grande de interfaz)**: aquí es un símbolo de estado. Barra coherente = mismo `ui_style`/`checkpoint`/`lora`, varía solo `effect` (color habla del tipo). El texto/contador lo pone el motor (negativo empuja a `no text`). Probado e2e en GPU con SD1.5 — `poison` 256×256 RGBA, símbolo verde flat centrado (`reports/0162`). SD1.5. |
| `comfyui_build_skill_tree_node_workflow_py_ml` | `(skill, *, frame="hexagonal", state="unlocked", ui_style="fantasy skill tree node", checkpoint="dreamshaper_8…", size=256, transparent=True, seed=0, lora=None, …) -> dict` | UN nodo de **árbol de habilidades / talentos** (RPG, ARPG, MOBA, roguelike): el icono de una `skill` **DENTRO de un marco** (`frame`: hexagonal/circular/diamond/shield) que la UI de progresión pinta en la rejilla, con variante de **estado** visual (`state`: `unlocked`=brillante/saturado, `locked`=gris/desaturado) → `{skill} skill icon inside a {frame} {ui_style} frame, {state} (…hint…), centered, plain background, game UI, skill tree talent node…` + LoRA estilo opcional + Rembg (alpha). El **marco** y el **estado** son la firma del asset. **DISTINTO de `item_icon` (objeto suelto sin marco), `status_effect_icon` (símbolo superpuesto sin marco) y `ui_hud` (chrome grande)**: aquí es el nodo enmarcado completo de la pantalla de talentos. Par de un mismo talento = mismo `skill`/`frame`/`ui_style`/`seed`, varía solo `state` (las dos caras de la rejilla). Árbol coherente = mismo `frame`/`ui_style`/`checkpoint`/`lora`, varía `skill`. El texto/coste lo pone el motor (negativo empuja a `no text`). Probado e2e en GPU con SD1.5 — `fireball` hexagonal unlocked 256×256 RGBA, nodo enmarcado brillante centrado (`reports/0173`). SD1.5. |
| `comfyui_build_achievement_badge_workflow_py_ml` | `(badge, *, tier="gold", style="game achievement badge, ornate", checkpoint="dreamshaper_8…", size=256, transparent=True, seed=0, lora=None, …) -> dict` | UNA **insignia / medalla / logro** (achievement, recompensa, rango): un trofeo, una medalla con cinta, un escudo de logro o un badge de rango que el panel de logros pinta al desbloquear un hito, con **`tier` metálico** (`bronze`/`silver`/`gold`/`platinum`/`diamond`) que distingue el grado → `{badge} achievement badge, {tier} tier (…hint metálico…), {style}, medal with ribbon, centered, plain background, game UI reward, trophy emblem…` + LoRA estilo opcional + Rembg (alpha). El **tier metálico** y la forma de **medalla/trofeo con cinta** son la firma del asset. **DISTINTO de `item_icon` (objeto de inventario suelto, sin tier ni cinta), `status_effect_icon` (símbolo de estado superpuesto sin marco) y `skill_tree_node` (nodo enmarcado de la rejilla de talentos con estado unlocked/locked)**: aquí es la insignia de logro/recompensa del panel de achievements. Familia de un mismo logro = mismo `badge`/`style`/`seed`, varía solo `tier` (los grados); set coherente = mismo `style`/`checkpoint`/`lora`, varía `badge`. El nombre/descripción/fecha lo pone el motor (negativo empuja a `no text`). Probado e2e en GPU con SD1.5 — `dragon slayer` tier gold seed 77 256×256 RGBA, medalla circular dorada con emblema centrado y fondo recortado a alpha (esquina α=0, centro α=254; `prompt_id 8b8b7ede`, `reports/0175`). SD1.5. |
| `comfyui_build_card_art_workflow_py_ml` | `(subject, *, card_style="fantasy trading card art", checkpoint="juggernaut_xl_v11…", width=512, height=768, hires=True, seed=0, lora=None, …) -> dict` | LA ilustración central de UNA carta coleccionable (TCG): criatura/personaje/hechizo en formato **vertical** de carta (`width<height`, ~512×768), composición centrada + iluminación dramática (`{subject}, {card_style}, dramatic lighting, detailed illustration, centered composition, full art…`). `hires=True` → 2ª pasada de detalle (`comfyui_build_hires_fix_workflow`); si no, txt2img + LoRA estilo opcional. Genera SOLO la ilustración — el marco/título/stats los pone el motor/post (negativo rechaza `card frame/border/text/stats/UI`). Set coherente = mismo `card_style`/`checkpoint`/`lora`, varía solo `subject`. Probado e2e en GPU con SD1.5 (`reports/0153`); ⚠️ el path `hires=True` falla hoy por bug del builder `comfyui_build_hires_fix_workflow` (nodo `UltimateSDUpscale` pide `batch_size`) — usar `hires=False` hasta el fix. SD1.5/SDXL. |
| `comfyui_build_enemy_creature_workflow_py_ml` | `(creature, *, variant=None, style="game creature, full body", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UN enemigo/criatura de juego (goblin, esqueleto, slime, dragón, boss, elemental): figura de **cuerpo entero** centrada, fondo limpio recortable a alpha (`{variant} {creature}, {style}, full body, centered, plain background, game asset…`) → txt2img cuadrado + LoRA estilo opcional + Rembg (alpha). `variant` (ice/fire/elite/corrupted…) se antepone a la criatura para generar la familia del MISMO enemigo (misma `creature`/`seed`/`style`, varía solo `variant`); bestiario coherente = mismo `style`/`checkpoint`/`lora`, varía solo `creature`. El negativo empuja a UNA criatura entera sin recorte. Probado e2e en GPU con SD1.5 (`reports/0154`). SD1.5. |
| `comfyui_build_prop_object_workflow_py_ml` | `(prop, *, style="game prop, isometric or side view", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UN prop/objeto de escenario (barril, cofre, antorcha, planta, mueble, roca, fuente, estatua): objeto inanimado aislado a **escala de escena y perspectiva de juego** (iso/lateral), centrado, fondo limpio recortable a alpha (`{prop}, {style}, game asset, single object, centered, plain background, scene prop, world object…`) → txt2img cuadrado + LoRA estilo opcional + Rembg (alpha). **Objeto de MUNDO**, no icono plano de inventario (≠ `item_icon`, que es para una casilla de UI); este puebla el nivel. Atrezzo coherente = mismo `style`/`checkpoint`/`lora`, varía solo `prop`. El negativo excluye personas/criaturas (objeto inanimado). Probado e2e en GPU con SD1.5 (`reports/0155`). SD1.5. |
| `comfyui_build_vehicle_mount_workflow_py_ml` | `(vehicle, *, view="side", style="game vehicle", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UN vehículo/montura que el personaje **USA o CONDUCE** (caballo, dragón-montura, nave espacial, coche, barco, carro, grifo, mecha): el vehículo **COMPLETO** en vista lateral o isométrica, centrado, fondo limpio recortable a alpha, **SIN jinete/conductor** (`{vehicle}, {view} view, {style}, full vehicle, centered, plain background, game asset, no rider, empty…`) → txt2img cuadrado + LoRA estilo opcional + Rembg (alpha). Se genera VACÍO (el negativo rechaza `person/rider/driver/passenger`) para que el motor componga al personaje encima. **DISTINTO de `enemy_creature` (sujeto a COMBATIR) y `prop_object` (atrezzo inanimado que decora)**: aquí el objeto se MONTA/USA; una montura viva que se cabalga (caballo, dragón) entra aquí, no en `enemy_creature`. `view` (side/iso) fija la geometría del parque móvil; set coherente = mismo `view`/`style`/`checkpoint`/`lora`, varía solo `vehicle`. Probado e2e en GPU con SD1.5 — `armored war horse with saddle` side 512×512 RGBA, vehículo centrado recortado a alpha (centroide 0.55/0.54, 4 esquinas transparentes, `reports/0169`). SD1.5. |
| `comfyui_build_topdown_sprite_workflow_py_ml` | `(subject, *, direction="south", style="top-down game sprite, RPG", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UN sprite en **vista CENITAL (top-down)** estilo RPG clásico/roguelike (Zelda, juegos cenitales): personaje/objeto visto **desde arriba**, centrado, fondo limpio recortable a alpha (`{subject}, top-down view, overhead view, {direction} facing, {style}, centered, plain background, game asset…`) → txt2img cuadrado + LoRA estilo opcional + Rembg (alpha). `direction` (south/north/east/west) para el sprite de movimiento: las 4 vistas del MISMO personaje = misma `subject`/`style`/`seed`, varía solo `direction` → montar con `comfyui_build_grid`. **DISTINTO de `sprite_sheet` (vista lateral/frontal de plataformas)**: el negativo por defecto rechaza side/front/3-4/isometric/perspective para forzar la cenital. Con SD1.5 sin LoRA sale picado alto; cenital estricto pide LoRA top-down + cfg alto. Probado e2e en GPU con SD1.5 (`reports/0156`). SD1.5. |
| `comfyui_build_splash_art_workflow_py_ml` | `(scene, *, mood="epic, cinematic", checkpoint="juggernaut_xl_v11…", width=1024, height=576, hires=True, seed=0, lora=None, …) -> dict` | LA ilustración grande de UN splash / pantalla de carga / key art en formato **pantalla apaisado 16:9** (`width>height`, ~1024×576), composición cinematográfica (`{scene}, {mood}, key art, game splash screen, dramatic lighting, cinematic composition, wide shot, epic scale, atmospheric…`). `hires=True` → 2ª pasada de detalle (`comfyui_build_hires_fix_workflow`) para verse a pantalla completa; si no, txt2img + LoRA estilo opcional. Genera SOLO la ilustración — el título/logo/barra de carga los pone el motor/post (negativo rechaza `text/title/logo/UI/frame/watermark`), dejando aire para superponer el título. Set coherente = mismo `mood`/`checkpoint`/`lora`, varía solo `scene`. Probado e2e en GPU con SD1.5 + hires (1024×576 → 1536×864, 54s, `reports/0159`). SD1.5/SDXL. |
| `comfyui_build_world_map_workflow_py_ml` | `(region, *, map_style="fantasy cartography, aged parchment", checkpoint="juggernaut_xl_v11…", width=768, height=768, hires=False, seed=0, lora=None, …) -> dict` | LA ilustración de la **pantalla de mapa** del juego: una lámina cartográfica en **vista cenital** de un continente/región/reino/mazmorra con aspecto de atlas fantasy (`map of {region}, {map_style}, top-down cartographic view, illustrated game world map, labeled regions, decorative border, compass rose, fantasy atlas, no people…`). **Cuadrado por defecto** (768×768; sube `width` para mundo apaisado, `height` para mazmorra en columna), `hires=False` por defecto (ponlo `True` para detalle fino de costas/relieve). Genera SOLO la ilustración — las marcas interactivas, los iconos pinchables, las rutas y el "estás aquí" los pone el motor SOBRE la lámina; la difusión dibuja labels/ornamentos **DECORATIVOS** pero NO garantiza ortografía ni posiciones usables como datos (el negativo rechaza `photo/3d render/perspective/character/person` para mantener la vista cenital plana). Atlas coherente = mismo `map_style`/`checkpoint`/`lora`, varía solo `region`. Probado e2e en GPU con SD1.5 — reino fantasy 768×768, lámina de pergamino con costas/montañas/regiones + borde ornamental + rosa de los vientos (`prompt_id bf4861fc`, `reports/0167`). SD1.5/SDXL. |
| `comfyui_build_decal_overlay_workflow_py_ml` | `(decal, *, on_black=True, style="grunge decal, high detail", checkpoint="dreamshaper_8…", size=512, seed=0, lora=None, …) -> dict` | UN decal/overlay con alpha para superponer sobre superficies/paredes/sprites con blend mode del motor (sangre, grietas, suciedad, óxido, quemaduras, salpicaduras, arañazos, musgo): textura **aislada sobre fondo PLANO** (`{decal}, {style}, single isolated decal, centered, on a solid pure black background, flat backdrop, sticker, no scenery, texture overlay, game asset…`) → txt2img cuadrado + LoRA estilo opcional. `on_black=True` (defecto) pensado para extraer alpha con **`comfyui_matting_luma_to_alpha`** (luma=alpha, conserva el falloff de translúcidos — la técnica gamedev correcta, ≠ recorte binario). **NO inyecta Rembg** (el matting es luma→alpha de disco, no un nodo): el SaveImage sale directo del VAEDecode. Set coherente = mismo `style`/`checkpoint`/`lora`, varía solo `decal`/`seed`. ⚠️ "grunge" en `style` arrastra fondo gris en SD1.5 → para fondo negro plano usar un `style` sin connotación de fondo + reroll de `seed`; luma Rec601 penaliza el rojo → para sangre roja pasar `luma_weights` con más peso al rojo. Probado e2e en GPU con SD1.5 (`reports/0160`). SD1.5. |
| `comfyui_build_projectile_workflow_py_ml` | `(projectile, *, direction="right", glow=False, style="game projectile, side view", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UN proyectil orientado (flecha, bala, bola de fuego, rayo, misil, hechizo): sprite pequeño con **orientación** (apunta a la derecha por defecto, ángulo 0 — el motor rota el sprite), aislado, listo para instanciar. **`glow` elige el camino a alpha**: `glow=False` (defecto) = proyectil SÓLIDO con silueta → `plain background` + **Rembg** (alpha por recorte, como `item_icon`/`topdown_sprite`); `glow=True` = brillante/mágico → `glowing, on black background` **sin Rembg** (recortaría el halo), insumo de **`comfyui_matting_luma_to_alpha`** que el caller aplica luego (como `vfx_spritesheet`/`decal_overlay`). `glow=True` ignora `transparent`/`rembg_model`; el negativo por defecto NO rechaza "black background". `direction` se inserta como `pointing {direction}` (`""`/None = sin orientación). Set coherente = mismo `style`/`checkpoint`/`lora`, varía solo `projectile`/`seed`. Probado e2e en GPU con SD1.5 — fireball glow sobre negro + luma→alpha RGBA (`reports/0161`). SD1.5. |
| `comfyui_build_structure_workflow_py_ml` | `(structure, *, view="isometric", style="game building", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UN edificio/estructura de escenario (casa, torre, castillo, tienda, posada, ruina, muralla, puente, templo, faro): UN **building COMPLETO** y centrado a perspectiva de juego (`{view} view`, iso por defecto), fondo limpio recortable a alpha (`{structure}, {view} view, {style}, full building, complete structure, single building, centered, plain background, game asset, architecture…`) → txt2img cuadrado + LoRA estilo/iso opcional + Rembg (alpha). **EDIFICACIÓN grande que ocupa varios tiles y define el escenario**, no un objeto pequeño suelto (≠ `prop_object`, que es atrezzo que se deja sobre un tile); el negativo rechaza `small object / single item / prop / furniture`. `view` fija la perspectiva del mapa (iso/side/front/top-down/¾); LoRA iso fija mejor el ángulo 2:1. Set coherente = mismo `view`/`style`/`checkpoint`/`lora`, varía solo `structure`. Probado e2e en GPU con SD1.5 — `medieval blacksmith shop` iso 512×512 RGBA, edificio centrado recortado a alpha (centroide 0.54/0.53, `reports/0164`). SD1.5. |
| `comfyui_build_foliage_set_workflow_py_ml` | `(plant, *, view="side", style="game foliage, stylized", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UN elemento de **vegetación/foliage** de escenario (árbol, arbusto, hierba alta, flores, helecho, hongo, cactus, tronco caído, juncos, hiedra): UN elemento de **naturaleza ORGÁNICA AISLADO** y centrado a perspectiva de juego (`{view} view`, `side` por defecto), fondo limpio recortable a alpha (`{plant}, {view} view, {style}, single plant element, centered, plain background, game nature asset, natural vegetation, organic, isolated plant…`) → txt2img cuadrado + LoRA estilo opcional + Rembg (alpha). **Vegetación que viste el terreno**, distinta del **objeto MANUFACTURADO** suelto (≠ `prop_object`: barril/cofre/mueble) y del **EDIFICIO** (≠ `structure`: casa/torre); el negativo rechaza `building / manmade object / barrel / furniture / person` y `multiple plants / dense forest / jungle / landscape` (UN elemento, no un bosque) + `pot / planter / vase` (planta en maceta = `prop_object`). Recorte por **Rembg** (planta opaca de silueta definida), no luma→alpha. Set coherente = mismo `view`/`style`/`checkpoint`/`lora`, varía solo `plant`. ⚠️ **dos gotchas reales SD1.5+Rembg**: (1) **plantas grandes (árbol) tienden a PAISAJE** (cielo+campo) en lugar de fondo plano → re-roll de seeds buscando fondo uniforme (`comfyui_batch_generate`); (2) **follaje verde claro sobre fondo claro → Rembg se come las hojas** y deja solo tronco/ramas → preferir elementos de **silueta compacta y color saturado** (hongo, arbusto denso) o `transparent=False` + matting manual. Probado e2e en GPU con SD1.5 — golden `a glowing mushroom` seed 7 512×512 RGBA, hongo centrado recortado a alpha limpio (centroide 0.51/0.58, opaco 19%, `prompt_id 8fb65a51`); evidencia del gotcha del roble en `reports/0170`. SD1.5. |
| `comfyui_build_trap_hazard_workflow_py_ml` | `(hazard, *, view="side", style="game hazard trap", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UNA **trampa/peligro JUGABLE** de nivel (pinchos del suelo, sierra giratoria, foso de lava, placa de presión, columna de llamas, trampa de flechas, charco ácido, descarga eléctrica, prensa, estaca cayendo): UN objeto de **peligro AISLADO** y centrado a perspectiva de juego (`{view} view`, `side` por defecto), fondo limpio recortable a alpha (`{hazard}, {view} view, {style}, single hazard object, trap, dangerous, centered, plain background, game asset, high detail`) → txt2img cuadrado + LoRA estilo opcional + Rembg (alpha). **Peligro al que el motor asigna hitbox de daño + estado activo/inactivo**, distinto del **objeto INERTE de decoración** (≠ `prop_object`: barril/cofre que solo ambienta) y del **enemigo VIVO** (≠ `enemy_creature`); el negativo rechaza `character / person / creature / multiple objects` para que salga el mecanismo, no un enemigo ni una escena. Recorte por **Rembg** (trampa sólida de silueta definida: pinchos/sierra/placa); ⚠️ para hazards **puramente etéreos** (columna de llamas, arco eléctrico, gas) usar `transparent=False` + `comfyui_matting_luma_to_alpha` (conserva el falloff translúcido para blend aditivo), no Rembg. `view` fija la perspectiva del nivel (side/top-down/iso); set coherente = mismo `view`/`style`/`checkpoint`/`lora`, varía solo `hazard`. Probado e2e en GPU con SD1.5 — `spiked floor trap` side seed 7 512×512 RGBA, mecanismo de peligro centrado recortado a alpha (alpha extrema 0255, fondo transparente real, `prompt_id ab1b1560`, `reports/0174`). SD1.5. |
| `comfyui_build_particle_texture_workflow_py_ml` | `(particle, *, soft=True, style="particle texture, soft glow", checkpoint="dreamshaper_8…", size=256, seed=0, lora=None, …) -> dict` | UNA textura de **partícula individual** reutilizable (chispa, humo, polvo, destello/flare, gota, copo, hoja, círculo de energía) — el "ladrillo" que el sistema de partículas del motor (Godot `GPUParticles2D`, Unity VFX Graph) instancia a **miles** y anima (spawn/fade/color over lifetime). Aislada y centrada **sobre fondo NEGRO** (`{particle} particle, {style}, isolated on pure black background, <soft|sharp> edges, single element, for game particle system…`) → txt2img cuadrado + LoRA estilo opcional. **`soft` controla el borde**: `soft=True` (defecto) → `soft glow, feathered edges` (humo/destello/gota); `soft=False``crisp sharp edges, high contrast` (chispa/copo/hoja). **NO inyecta Rembg** (rompería el falloff translúcido): insumo de **`comfyui_matting_luma_to_alpha`** (luma=alpha, additive blend en el motor). **`size` por defecto pequeño (256)** porque se replica a miles. **DISTINTO de `vfx_spritesheet`** (ese es la SECUENCIA animada de un efecto; esto es UNA textura estática reutilizable) **y de `decal_overlay`** (ése es una mancha de desgaste estática para superponer; éste es un emisor de partículas). ⚠️ el `style` por defecto trae "soft glow" → si pides `soft=False` para algo nítido, usa un `style` sin connotación suave. Probado e2e en GPU con SD1.5 — `spark` 256×256 sobre negro plano (dark 85%) + luma→alpha RGBA con falloff preservado (`reports/0163`). SD1.5. |
| `comfyui_build_weather_overlay_workflow_py_ml` | `(weather, *, on_black=True, style="weather overlay, atmospheric", checkpoint="dreamshaper_8…", width=1024, height=576, seed=0, lora=None, …) -> dict` | UNA **capa de clima/atmósfera a PANTALLA COMPLETA** que cubre toda la vista del jugador y se superpone sobre la escena con blend del motor (lluvia, niebla, nieve, rayos de sol/god rays, polvo, viñeta de tormenta): cobertura **uniforme de borde a borde**, generada **APAISADA a resolución de pantalla** (16:9, 1024×576 por defecto — `width>height`, NO cuadrado) (`{weather} overlay, {style}, full screen atmospheric layer, <particles/streaks on pure black background \| translucent layer>, seamless full screen coverage, edge to edge, game VFX…`) → txt2img apaisado + LoRA estilo opcional. **`on_black` elige el modo de blend**: `on_black=True` (defecto) = clima BRILLANTE sobre **NEGRO puro** (estrías de lluvia, copos, haces de luz, motas), **sin Rembg**, insumo de **`comfyui_matting_luma_to_alpha`** (luma=alpha, **blend aditivo/screen** — el negro desaparece, el clima brilla sobre la escena); `on_black=False` = **película TRANSLÚCIDA** semi-transparente (niebla densa, tinte de tormenta) para blend multiply/overlay o alpha global. El negativo rechaza `solid object/single subject/character/building/landscape scene/horizon line/frame` (cobertura total, NO un sujeto centrado) + (si `on_black`) `blue sky/gray/white background` para forzar negro plano. **DISTINTO de `decal_overlay`** (ése es una mancha LOCALIZADA que se pega en un punto de una superficie) **y de `vfx_spritesheet`** (ése es la SECUENCIA animada de UN efecto puntual): la capa de clima es UNA película estática de cobertura full-screen que el motor anima por scroll/loop/shader. Set coherente = mismo `style`/`checkpoint`/`lora`, varía `weather`/`seed`. ⚠️ algunos climas (lluvia/niebla) pintan cielo azul de fondo en SD1.5 aunque pidas negro → subir `cfg` + re-roll de `seed`; climas brillantes-sobre-negro (god rays, snow, sparks) salen más limpios que los difusos (fog). luma Rec601 penaliza el azul → para lluvia azulada ajustar `luma_weights`/`gamma` en el matting. Probado e2e en GPU con SD1.5 — `heavy rain` on_black seed 11 **1024×576** (16:9 exacto), estrías de lluvia brillantes sobre **negro plano** (esquinas luma 0.00, dark 89.6%, lluvia 1.4% brillante) apto luma→alpha aditivo (`prompt_id 5d2300d1`, `reports/0176`). SD1.5/SDXL. |
| `comfyui_build_rune_glyph_workflow_py_ml` | `(glyph, *, glow=True, style="arcane glowing rune", checkpoint="dreamshaper_8…", size=512, seed=0, lora=None, …) -> dict` | UNA **runa / glifo / sigilo mágico** (glifos rúnicos, círculos mágicos, sigilos de invocación, inscripciones brillantes) para hechizos, portales, marcas de conjuro y efectos de magia: símbolo arcano **aislado** sobre fondo uniforme (`{glyph}, {style}, magic symbol, single isolated glyph, centered, glowing on a solid pure black background, occult sigil, arcane inscription, no scenery, game asset…`) → txt2img cuadrado + LoRA estilo opcional. **`glow` elige el camino a alpha**: `glow=True` (defecto) = runa BRILLANTE sobre **NEGRO puro**, **sin Rembg** (recortaría el halo del resplandor), insumo de **`comfyui_matting_luma_to_alpha`** (luma=alpha, **blend aditivo** en el motor — conserva el glow); `glow=False` = runa MATE/grabada sobre fondo plano (el negativo rechaza `glow/neon/bloom`), recorte/inversión por el caller. El negativo rechaza `realistic text/readable words/latin alphabet` (un glifo arcano, **no letras reales**) + fondo texturizado/niebla. **DISTINTO de `status_effect_icon`** (símbolo SÓLIDO de UI, recorte Rembg, legible a 16-32 px en el HUD): la runa es una marca translúcida que **emite luz** e se inscribe en el mundo. Grimorio coherente = mismo `style`/`checkpoint`/`lora`, varía `glyph`/`seed`. ⚠️ luma Rec601 penaliza el rojo → para runas rojas (sigilo demoníaco) pasar `luma_weights` con más peso al rojo + subir `gamma`; runas blancas/azules/doradas van con pesos por defecto. Probado e2e en GPU con SD1.5 — `circular summoning rune` glow seed 11 512×512, círculo de invocación brillante sobre **negro puro** (esquinas luma 0.00, dark 83%, runa 3.4% brillante, max 255) apto luma→alpha (`prompt_id 701d149a`, `reports/0172`). SD1.5. |
| `comfyui_build_title_lettering_workflow_py_ml` | `(text, *, letter_style="epic fantasy metallic", checkpoint="juggernaut_xl_v11…", width=1024, height=512, transparent=True, seed=0, lora=None, …) -> dict` | EL texto/logo de **título** de un juego (el nombre del juego o una palabra) renderizado con un **tratamiento de lettering** (metálico, tallado en fuego/piedra/madera, neón, cristal, oro), formato **apaisado** (`width>height`, 1024×512 por defecto), fondo plano recortable a alpha (`the word "{text}" as a game logo, {letter_style} lettering, stylized typography, centered, plain background…`) → txt2img apaisado + LoRA estilo opcional + Rembg (alpha). El **negativo NO rechaza texto** (el lettering es el sujeto) y empuja contra el ruido textual (`extra letters/jumbled text/deformed letters`). El VALOR es el ESTILO del lettering, **NO** la fidelidad tipográfica: ⚠️ la difusión renderiza texto de forma imperfecta — letras de más, deformadas o mal escritas; mitigar con palabras CORTAS en MAYÚSCULA, **re-roll de seeds** (`comfyui_batch_generate`), SDXL > SD1.5 para texto, o pintar el texto real con una fuente en el motor. **Una palabra que es un objeto concreto (DRAGON) → el modelo dibuja el objeto, no las letras** — usar palabras abstractas o reforzar `letter_style`. Marca coherente = mismo `letter_style`/`checkpoint`/`lora`, varía solo `text`. Recorte por **Rembg** (logo sólido), no luma→alpha. Probado e2e en GPU: `DRAGON`/`fire engraved` SD1.5 1024×512 → ilustró dragones rojos (alpha OK, confirma el gotcha de palabra-objeto, `prompt_id 6f3920b7`); `AETHER`/`epic fantasy metallic` SDXL 768×384 → **logo de texto metálico dorado** legible con ortografía imperfecta + alpha (`prompt_id 2a7fe8ba`, `reports/0165`). SD1.5/SDXL. |
## Animación de assets (vídeo) — caminos validados e2e
Tres vías para que un asset 2D se **mueva** (loop de VFX, sprite animado, fondo con
movimiento), todas cabiendo en 8GB **con la GPU vacía** (cierra el juego antes — el
vídeo NO convive con un juego AAA en VRAM). Los builders son del grupo hermano `comfyui`
(dominio `ml`); aquí se documenta su **uso gamedev**. Reutilizan el round-trip canónico
`build → comfyui_submit_workflow → (sondear /history) → comfyui_fetch_output_video`.
| Vía | Builder | Para qué (gamedev) | Validado |
|---|---|---|---|
| **txt2video (LTX)** | `comfyui_build_video_workflow(prompt, model='ltx', width=512, height=320, num_frames=25, fps=12)` | **Loop de elemento desde texto**: portal, antorcha, agua, humo, magia. Sale `.mp4`. Modelo LTX-Video 2B v0.9.5 (`ltx-video-2b-v0.9.5.safetensors` + text encoder `t5xxl_fp8`). | e2e GPU: portal mágico 512×320, **25 frames**, 2.08s, pico **7717 MiB / 8192**, `prompt_id 54eda033`, `reports/0186`. |
| **txt2video (Wan)** | `comfyui_build_video_workflow(prompt, model='wan', …)` | Igual que LTX pero con Wan2.1 T2V 1.3B (`wan2.1_t2v_1.3B_fp16` + `umt5_xxl_fp8` + `wan_2.1_vae`). Enlazado y visible en `/object_info`. | Enlace verificado en `reports/0186`; clip no generado aún (LTX cubrió el golden). |
| **img2vid (SVD)** | `comfyui_build_img2vid_workflow('sprite.png', width=512, height=512, video_frames=14, motion_bucket_id=127)` | **Animar un sprite/fondo YA generado**: copia la imagen a `~/ComfyUI/input/`, SVD la condiciona por CLIP_VISION (no usa prompt de texto) y la pone en movimiento. Sale `.webp` animado. | e2e GPU: `enemy_creature` (del pack) → 512×512 RGBA **14 frames** animado, pico **7463 MiB / 8192**, `prompt_id 5b501d03`, `reports/0186`. |
| **spritesheet (AnimateDiff)** | `comfyui_build_vfx_spritesheet_workflow(prompt, num_frames=8, closed_loop=True)` | N frames de un VFX 2D en bucle seamless sobre negro (insumo de luma→alpha + montaje de spritesheet). | e2e GPU previos (`reports/0140`/`0143`); 8GB: usar ≤8f@512² o bajar resolución (16f@512² revienta). |
**Límites VRAM (RTX 3070 8GB, GPU vacía):** LTX 512×320@25f → 7717 MiB; SVD 512×512@14f
7463 MiB. Margen estrecho (~0.5 GB): con un juego AAA abierto (~2.7 GB) **ningún** camino
de vídeo cabe → cerrar el juego o ir a frames/res mínimos. La generación de **imagen**
estática sí convive con el juego. `comfyui_wait_result` **lanza** `TimeoutError` al
expirar (envolver en try/except); SVD es lento (>10 min para 14f en lowvram), pero el job
completa en GPU aunque el script de orquestación expire — recuperar el output sondeando
`/history` por `prompt_id`. Para transparencia, post-procesar los frames a alpha
(luma→alpha o rembg por frame).
## Builders de transformación (`gamedev-2d`, puros — parten de una imagen/dibujo de entrada)
A diferencia de los builders de **generación** de arriba (parten de TEXTO, txt2img desde
ruido), estos parten de una **imagen de entrada** y la transforman. Cuatro sub-ejes:
- **img2img** (`asset_variant`): parte de un asset **ya pintado**; el KSampler arranca del
latente de la imagen base (LoadImage → VAEEncode), no de ruido, así que con `denoise` medio
conserva la estructura mientras el prompt reescribe material/paleta/tier. Reescribe **todo** el
asset conservando forma **y** color del original.
- **sketch→ControlNet** (`sprite_from_sketch`): parte del **dibujo tosco** del dev (boceto,
lineart, garabato); es `txt2img` (arranca de ruido) pero condicionado por un ControlNet atado
al mapa de líneas del dibujo. Conserva solo la **forma**; la IA pone material/color/acabado.
- **inpaint** (`inpaint_asset`): parte de un asset **ya pintado** + una **máscara** que marca qué
región editar (blanco) y cuál conservar (negro); el sampler regenera **solo** la zona enmascarada
dejando el resto del pixel intacto. Cambia **una parte** (arma, casco, escudo, reparación), no el
asset entero.
- **outpaint** (`outpaint_asset`): parte de un asset **ya pintado** y **agranda el lienzo** por uno o
varios lados; el nodo `ImagePadForOutpaint` extiende el canvas **y genera** la máscara feathered de
la franja nueva (no la recibe el caller), y el sampler genera ahí contenido coherente. Cambia el
**tamaño** del asset (recortar/extender un fondo o parallax a otra resolución/aspect), no lo de dentro.
- **multi-vista 3D / 2.5D** (`directional_sprite`): parte del sprite **frontal** de un personaje y lo
**rota en 3D** (SV3D turntable u Stable Zero123 órbita) para producir N vistas direccionales del MISMO
personaje (8-way N/NE/E/SE/S/SW/W/NW o 4-way). A diferencia de `sprite_sheet` (re-poza con OpenPose 2D,
re-dibuja la silueta → identidad inconsistente entre ángulos), aquí la difusión 3D gira la figura sobre
su eje, así casco/arma/paleta son los mismos en cada dirección (**consistencia rotacional**). Cambia el
**ángulo de cámara**, no la pose ni el material.
Cubren el eje que el critic de generación (`reports/0178`) no exploró: derivar de un asset o
del dibujo del dev, no inventar un tipo nuevo desde texto.
| ID | Firma corta | Qué hace |
|---|---|---|
| `comfyui_build_asset_variant_workflow_py_ml` | `(input_image, variant, *, checkpoint="dreamshaper_8…", denoise=0.5, style="game asset", size=512, seed=0, lora=None, …) -> dict` | UNA **variante coherente de un asset 2D ya generado** (img2img): parte del sprite/icono que existe en `input_image` y produce su versión de **otro material/paleta/tier/estado** (`ice element`, `fire element`, `battle-damaged`, `golden tier 2`, `corrupted`) manteniendo **silueta, pose y composición** del original. Compone `comfyui_build_img2img_workflow` (LoadImage → VAEEncode → KSampler con `denoise`) + `comfyui_inject_lora` (estilo opcional) + `ImageScale` opcional (`size` normaliza la base a size×size; `size=None` preserva las dimensiones exactas sin deformar). El prompt es `{variant}, {style}, same composition, same pose, same silhouette, …`. **`denoise` es la palanca**: ~0.3 invisible, **0.45-0.6 recomendado** (cambia material/paleta, conserva forma), ~0.8 deriva la pose y se acerca a txt2img. Set de variantes del MISMO asset = mismo `input_image`/`style`/`seed`, varía solo `variant`. **DISTINTO de los builders txt2img** (`enemy_creature`, `item_icon`…): esos generan un tipo desde cero; éste transforma uno concreto. **NO inyecta Rembg** (img2img preserva el fondo/alpha del original según la base). ⚠️ la imagen base debe existir en `input/` del server (subir con `POST /upload/image`); pura, no valida (usar `comfyui_validate_workflow` antes de enviar); asset NO cuadrado + `size` fijo + `crop="disabled"` deforma → `size=None` o `crop="center"`. Probado e2e en GPU con SD1.5 — variante `ice element, frozen` del goblin `enemy_creature_00001_.png` denoise 0.5 seed 7 512×512 (`prompt_id 5e4a5d3d`): silueta conservada (luminance corr 0.63) + paleta a frío (blueness BR 1.6→+1.9), `reports/0181`. SD1.5. |
| `comfyui_build_sprite_from_sketch_workflow_py_ml` | `(sketch_image, subject, *, control_type="lineart", checkpoint="dreamshaper_8…", style="game asset, clean, centered", strength=0.8, size=512, seed=0, lora=None, preprocess=True, controlnet_name=None, …) -> dict` | UN **sprite pintado a partir del BOCETO del dev**, guiado por **ControlNet** (sub-eje sketch→ControlNet, **NO img2img**). Recibe el dibujo tosco que existe en `sketch_image` (boceto/lineart/garabato) + `subject` (qué es), y genera un sprite en estilo de juego que **conserva la forma dibujada**: el dev marca la silueta, la IA pone material/color/acabado. Mecanismo: `txt2img` base (ruido, `EmptyLatentImage`, `denoise 1.0`) cuyo positivo pasa por `ControlNetApply` atado al mapa de líneas del boceto. `control_type` elige el **preprocesador** (`LineArtPreprocessor` / `ScribblePreprocessor` / `CannyEdgePreprocessor`, interpuesto entre el boceto y el ControlNet por un helper) y, por defecto, el **modelo CN emparejado**. Compone `comfyui_build_txt2img_workflow` + `comfyui_inject_controlnet` + `comfyui_inject_lora` (estilo opcional). **`strength` es la palanca**: 0 = ignora el dibujo (txt2img puro), ~0.8 recomendado (respeta forma dejando limpiar a la IA), 1.0 = se ciñe estricto. **DISTINTO de `asset_variant`** (img2img conserva forma+color de una imagen ya pintada) y de los txt2img (`enemy_creature`…, inventan la forma desde texto): éste conserva **solo la forma** del dibujo. ⚠️ el boceto debe existir en `input/` (subir con `POST /upload/image`); pura, no valida (usar `comfyui_validate_workflow` antes de enviar); `preprocess=False` solo si el sketch ya es un lineart limpio. **GOTCHA del server 8GB: solo `canny`/`depth`/`openpose` SD1.5 instalados** — para `lineart`/`scribble` pasa `controlnet_name="control_v11p_sd15_canny_fp16.safetensors"` u usa `control_type="canny"` (out-of-the-box); pendiente humano descargar `control_v11p_sd15_lineart_fp16`/`scribble`. Probado e2e en GPU con SD1.5 — boceto del goblin `enemy_creature_00001_.png``CannyEdgePreprocessor` → ControlNet canny, `subject="dark fantasy goblin warrior"` strength 0.85 seed 123 512×512 (`prompt_id ea6fc372`): pose/orejas/hombrera/lanza dentada/espada del dibujo conservadas, repintado en estilo de juego, `reports/0182`. SD1.5. |
| `comfyui_build_inpaint_asset_workflow_py_ml` | `(input_image, mask_image, prompt, *, checkpoint="dreamshaper_8…", denoise=1.0, style="game asset", grow_mask=6, size=None, seed=0, lora=None, mode="vae_encode", …) -> dict` | EDITA **solo una región** de un asset 2D ya pintado (**inpaint**, sub-eje propio). Recibe el asset en `input_image` + una **máscara** `mask_image` (BLANCO = editar, NEGRO = conservar) + `prompt` de qué poner ahí, y repinta **únicamente** la zona enmascarada dejando el resto del sprite intacto (cambiar/añadir un arma, quitar un casco, poner un escudo, reparar una zona dañada). Mecanismo (`mode="vae_encode"`): `VAEEncodeForInpaint` codifica el latente respetando la máscara y dilata su borde `grow_mask` px para difuminar la costura; `KSampler` (`denoise` alto) regenera solo esa región con `{prompt}, {style}, seamless blend…`. Compone `comfyui_build_inpaint_workflow` (base) + `comfyui_inject_lora` (estilo opcional); `size` escala imagen **Y** máscara de forma consistente (escalar solo una las desalinea). **`grow_mask` es la palanca de costura** (6-10 px difumina el borde lo/nuevo); `denoise` 1.0 reescribe entero, ~0.5-0.7 repara suave. **DISTINTO de `asset_variant`** (img2img reescribe TODO el asset) y de `sprite_from_sketch` (ControlNet parte de un dibujo de líneas para un sprite nuevo): éste edita **un trozo** delimitado por la máscara. **ERROR-PATH**: si el server no expone `VAEEncodeForInpaint`, pasar `mode="noise_mask"` → degrada a `VAEEncode` + `SetLatentNoiseMask` (+ `GrowMask`); `mask_image` vacío lanza `ValueError`. ⚠️ asset y máscara deben existir en `input/` (subir con `POST /upload/image`) y compartir resolución (o usar `size`); `ImageScale` aquí NO ofrece `lanczos` (válidos `bilinear`/`nearest-exact`/`area`/`bicubic`); pura, no valida. Probado e2e en GPU con SD1.5 — máscara circular (R70) sobre la mano del goblin `enemy_creature_00001_.png`, `prompt="a glowing blue magic orb"` grow_mask 8 denoise 1.0 seed 7 (`prompt_id 88b52c66`): orbe azul en la región, **resto idéntico** (diff medio dentro 40.3 vs fuera 1.97 → ratio 20.4×; 44.6% px cambiados dentro vs 1.7% fuera), `reports/0183`. SD1.5. |
| `comfyui_build_directional_sprite_workflow_py_ml` | `(input_image, *, directions=8, model="sv3d", elevation=0.0, size=None, orbit_frames=None, seed=0, ckpt=None, …) -> dict` | UN **sprite MULTI-DIRECCIONAL** del MISMO personaje rotado en 3D (**multi-vista 2.5D**, sub-eje propio): parte de la imagen **frontal** del personaje (fondo limpio, en `input/`) y construye el workflow que genera N vistas direccionales CONSISTENTES (8-way N/NE/E/SE/S/SW/W/NW o 4-way) para top-down/iso/shooter 8-way. `model="sv3d"` (default) = `SV3D_Conditioning` produce un **orbit turntable** de N frames equiespaciados en 360° en una pasada (mejor consistencia, `sv3d_p.safetensors`, nativo 576²); `model="zero123"` = `StableZero123_Conditioning_Batched` da un **batch** de N vistas por azimuth (fallback menor VRAM, `stable_zero123.ckpt`, nativo 256²). `elevation` (~15-30) da picado para cámara cenital; `orbit_frames` (SOLO sv3d) densifica el orbit (21 nativo) para submuestrear; el módulo expone `directional_sprite_view_order(directions)` (frame i = dirección i). **DISTINTO de `sprite_sheet`** (OpenPose 2D re-poza la silueta → identidad inconsistente): aquí la difusión 3D ROTA la figura sobre su eje → casco/arma/paleta idénticos en cada dirección (rotación 3D real, no re-dibujo). Construye, NO genera (el coste GPU es el `submit`); **pura, no valida** (la imagen frontal debe existir en `input/`). Hermana **pura** de `comfyui_generate_views_from_image` (orquestador impuro para recon 3D, 4 cardinales). ⚠️ VRAM RTX 3070 8GB: SV3D es modelo de vídeo, pesa — 8 frames@576² → pico **7145 MiB**; limpiar GPU antes (`POST /free`); OOM → baja `size`/`directions` o cae a zero123, NO matar procesos; `comfyui_wait_result` lanza `TimeoutError` pero el job completa (sondear `/history`). Probado e2e en GPU con SV3D — goblin `enemy_creature_00001_.png` (compuesto sobre blanco 576²) → 8 direcciones elevation 15 seed 7, **8 frames** 576² en 75 s, consistencia rotacional medida (MAE adyacentes 27 < frente↔espalda 29.6, spread de paleta 3.83 = mismo personaje en las 8 vistas; `prompt_id 8b9f75de`, `reports/0187`). SV3D/Zero123. |
| `comfyui_build_outpaint_asset_workflow_py_ml` | `(input_image, prompt, *, left=0, right=0, top=0, bottom=0, feather=40, checkpoint="dreamshaper_8…", denoise=1.0, style="game background", grow_mask=0, seed=0, lora=None, …) -> dict` | EXTIENDE **el lienzo** de un asset 2D ya pintado (**outpaint**, sub-eje propio). Recibe el asset en `input_image` + cuánto extender por cada lado (`left`/`right`/`top`/`bottom` px) + `prompt` de qué generar fuera de los bordes, y **agranda el canvas** generando contenido coherente con el original más allá de sus bordes (recortar/extender un fondo, parallax, card_art o splash a otra resolución/aspect ratio). Mecanismo: el nodo nativo `ImagePadForOutpaint` amplía el lienzo y **EMITE** a la vez la imagen extendida **y** la máscara feathered de la franja nueva (la genera el grafo, **NO** la recibe el caller); `VAEEncodeForInpaint` codifica respetando esa máscara y `KSampler` (`denoise` alto) genera lo nuevo con `{prompt}, {style}, seamless extension…`. Compone `comfyui_build_inpaint_workflow` (base; su `LoadImageMask` se elimina y `VAEEncodeForInpaint` se reconecta a las dos salidas del pad) + `comfyui_inject_lora` (estilo opcional). **`feather` difumina la costura** (40 px por defecto, no debe pasarse de la extensión); `grow_mask` (0 por defecto) dilata adicionalmente el borde si aparece costura dura. **DISTINTO de `inpaint_asset`**: éste **no recibe máscara** (la genera el pad) y cambia el **tamaño** del asset extendiendo hacia fuera, mientras inpaint edita una región **interior** con máscara externa del mismo tamaño. **ERROR-PATH**: `input_image`/`prompt` vacíos o las cuatro extensiones en 0 tras redondear (`left=3`→0) lanzan `ValueError`; si el server no expone `ImagePadForOutpaint`, consultar `/object_info`. ⚠️ el asset debe existir en `input/` (subir con `POST /upload/image`); las extensiones se redondean a múltiplo de 8 (`250→248`); pura, no valida. Probado e2e en GPU con SD1.5 — fondo `seamless_00004_.png` 512×512 extendido `right=256` feather 40 denoise 1.0 seed 7 (`prompt_id aa33de05`): canvas **512→768×512** (+256), original conservado (diff medio 7.2 lejos del borde) + franja nueva con contenido coherente (std 28.9, dist de paleta 28.6), `reports/0185`. SD1.5. |
## Funciones de post-proceso y puente (`gamedev-2d`, CPU)
| ID | Firma corta | Qué hace |
|---|---|---|
| `comfyui_pixelize_image_py_ml` | `(src, dst, *, downscale=8, colors=16, palette=None, dither=False, upscale_back=True) -> dict` | Pixel-perfect: downscale nearest + cuantización a N colores o paleta fija (game-boy/pico-8/nes). Fase 2 pixelart. Impura (I/O). |
| `comfyui_matting_luma_to_alpha_py_ml` | `(image_path, *, out_path=None, gamma=1.0, black_point=0.0, premultiply=False, luma_weights=(.299,.587,.114)) -> dict` | Frame VFX sobre negro -> RGBA usando luminancia como alpha (translúcidos con additive blend). Impura (I/O). |
| `comfyui_export_asset_to_godot_py_pipelines` | `(asset_path, kind, godot_project, *, name=None, reimport=True, godot_bin=None) -> dict` | Copia el asset a `res://assets/<dir>/` por `kind` + escribe `.import` + filtro Nearest si pixelart + reimport headless. Pipeline impuro. |
| `godot_map_asset_dir_py_core` | `(kind) -> str` | Mapea `kind` -> subcarpeta de `res://assets/`. Pura. |
| `godot_clean_asset_name_py_core` | `(filename, *, override=None) -> str` | Normaliza el nombre `<prefijo>_NNNNN_.<ext>` a snake_case seguro para `res://`. Pura. |
## Estilos (style presets) — calidad por ESTILO reutilizable
Un *style preset* es la receta curada de un look visual que se aplica a **TODOS** los
assets de un juego de una vez ("todo en Game Boy", "estilo Ghibli", "pixel-art retro").
En vez de repetir a mano `style`/`checkpoint`/`lora`/`negative` + post-proceso en cada
builder, el preset los empaqueta como DATOS puros y el helper los traduce a los kwargs de
cualquier builder de sujeto (item_icon, enemy_creature, prop_object, …) o del pipeline
`comfyui_generate_asset_pack_oneshot`. Diseño (issue 0087): función pura de presets +
helper de aplicación (NO un pipeline monolítico) — máxima composabilidad, sin acoplar
firmas. Extensible: añadir un estilo = una entrada en `_PRESETS`.
| ID | Firma corta | Qué hace |
|---|---|---|
| `comfyui_get_gamedev_style_preset_py_ml` | `(name=None) -> dict` | Devuelve la receta de un STYLE PRESET curado o el catálogo si `name=None`. Receta = `{subject_prefix, subject_suffix, style, negative, checkpoint, lora, lora_strength, size, transparent, post, notes}`. Pura, copias profundas. **6 estilos**: **gameboy** (sin LoRA → prompt + post `pixelize` paleta `game-boy` 4 tonos verde), **ghibli** (degrada a `SD15_watercolor_style` gratis instalado + prompt; no hay LoRA Ghibli dedicado ni se descargó nada gated), **pixel-art-retro** (reutiliza `SDXL_pixel-art` SDXL ya instalado → checkpoint `juggernaut_xl_v11` + size 768 + post `pixelize` 16 colores), **cyberpunk-neon** (prompt puro SD1.5, glow magenta/cyan, sin post), **low-poly-flat** (prompt puro SD1.5, facetas/flat shading PS1, sin post, transparent), **cartoon-cel-shaded** (LoRA `SD15_anime_style_box` 0.7 + prompt cel-shaded, sin post, transparent). Extensible: añadir un estilo = una entrada en `_PRESETS`. |
| `comfyui_apply_style_preset_py_ml` | `(preset, subject, *, style=None, negative=None) -> dict` | Traduce un preset + un `subject` a `{name, subject (con prefijo/sufijo), builder_kwargs={style,checkpoint,lora,lora_strength,negative}, size, transparent, post}`. Los `builder_kwargs` hacen `**spread` directo en cualquier builder de sujeto; `size`/`transparent` van aparte (recomendaciones); el caller aplica `post["pixelize"]` al PNG si existe. Pura, no muta el preset; `negative` se mergea (no reemplaza). |
**Ejemplo canónico (mismo subject, look del juego entero):**
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_get_gamedev_style_preset import comfyui_get_gamedev_style_preset
from ml.comfyui_apply_style_preset import comfyui_apply_style_preset
from ml.comfyui_build_enemy_creature_workflow import comfyui_build_enemy_creature_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 ml.comfyui_pixelize_image import comfyui_pixelize_image
preset = comfyui_get_gamedev_style_preset("gameboy") # o "ghibli" / "pixel-art-retro"
ap = comfyui_apply_style_preset(preset, "knight character")
wf = comfyui_build_enemy_creature_workflow(ap["subject"], size=ap["size"],
transparent=ap["transparent"], seed=7, **ap["builder_kwargs"])
pid = comfyui_submit_workflow(wf)["prompt_id"]
outs = comfyui_wait_result(pid, timeout=500)
fn = next(i["filename"] for o in outs.values() for i in o.get("images", []))
raw = comfyui_fetch_output_image(fn, dest_dir="/tmp")["path"]
if ap["post"].get("pixelize"): # gameboy/pixel-retro sellan el grid/paleta
comfyui_pixelize_image(raw, "/tmp/knight.png", **ap["post"]["pixelize"])
```
Validado e2e en GPU con el MISMO `knight character` en los 3 estilos (`reports/0190`):
gameboy 4 colores verde (`prompt_id 0657e3e3`), ghibli 78 552 colores acuarela
(`42f2f492`), pixel-art-retro SDXL 768 16 colores (`84b08581`) — tres looks
visiblemente distintos y coherentes. **Gotcha**: en el flujo manual de arriba el `post` no
se aplica solo (el caller llama `comfyui_pixelize_image`) — para evitarlo usa el pipeline
one-shot `comfyui_generate_styled_asset_oneshot` (abajo), que auto-aplica el post. El LoRA y
el checkpoint deben casar de base (SDXL_pixel-art es SDXL → exige juggernaut); OOM en 8 GB →
bajar `size`, NO matar procesos.
## Pipelines one-shot (`gamedev-2d`, impuros)
| ID | Firma corta | Qué hace |
|---|---|---|
| `comfyui_generate_asset_pack_oneshot_py_pipelines` | `(pack, *, checkpoint="dreamshaper_8…", style="", lora=None, base_seed=0, size=None, server="127.0.0.1:8188", export_godot=None, out_dir=None, …) -> dict` | **Set COHERENTE de assets 2D de un mismo juego de un solo tiro**: `pack=[{"kind","subject"}, …]` → despacha cada `kind` a su builder atómico (26 kinds: item_icon, enemy_creature, prop_object, seamless_tile, ui_hud, particle_texture, …) compartiendo el MISMO `checkpoint`/`lora` + `style` común inyectado al `subject` + `seed = base_seed + i`, encola (`submit`) + espera (`wait`) + descarga (`fetch`) cada uno, y (si `export_godot`) los exporta a Godot. Promoción a pipeline del patrón "N builders con el mismo estilo" (issue 0087). Fail-fast si `kind` desconocido; un OOM aislado no aborta el resto. Probado e2e en GPU SD1.5 512: `magic sword`(item_icon, seed 42) + `goblin warrior`(enemy_creature, seed 43), `style="dark fantasy, hand-painted"` → 2/2 PNG 512×512 RGBA coherentes (`prompt_id f7cfda43` + `11d1d031`, `reports/0179`). Impuro: HTTP + disco + (export) subprocess. |
| `comfyui_generate_character_set_oneshot_py_pipelines` | `(character, *, style="game character, full body, clean background", checkpoint="dreamshaper_8…", base_kind="enemy_creature", directions=8, make_directional=True, make_3d=True, directional_model="sv3d", elevation=15.0, seed=0, size=512, directional_size=None, flatten_color=(255,255,255), variant_3d="mini", lora=None, server="127.0.0.1:8188", export_godot=None, out_dir=None, free_vram=True, …) -> dict` | **Set COMPLETO y COHERENTE de UN personaje de un solo tiro** (culminación cross-frontera del grupo): genera del MISMO personaje (1) imagen **base 2D** recortada a alpha, (2) **sprite direccional N-way** (vistas 3D consistentes SV3D/Zero123) y (3) **malla 3D `.glb`** (Hunyuan3D-2). La CLAVE es la coherencia: el direccional y el 3D parten de la **MISMA base 2D aplanada** (`base_flat`), no de tres generaciones independientes → mismo personaje en las tres representaciones, no tres personajes distintos. Compone un builder de personaje (`enemy_creature`/`portrait_avatar`/`topdown_sprite`, elegido por introspección) + `comfyui_flatten_alpha_on_color` (aplana la base recortada sobre blanco — los modelos 3D y `LoadImage` hacen `convert("RGB")` y tiran el alpha) + `comfyui_image_to_3d_oneshot` + `comfyui_build_directional_sprite_workflow` + `submit`/`wait`/`fetch` + `comfyui_export_asset_to_godot`. **Secuencial liberando VRAM** (`POST /free`) entre los pasos pesados, el 3D ANTES del direccional (SV3D es el de mayor pico, ~7.1 GB), para caber en 8 GB. Un fallo aislado (p.ej. OOM en el 3D) NO aborta el resto: deja el set PARCIAL. Promoción a pipeline (issue 0087) de la secuencia que hoy exige 4 llamadas a mano. Probado e2e en GPU — ver `reports/0188`. Impuro: HTTP + disco + (export) subprocess. |
| `comfyui_generate_styled_asset_oneshot_py_pipelines` | `(kind, subject, style_preset, *, seed=0, server="127.0.0.1:8188", out_dir=None, export_godot=None, style_override=None, negative_extra=None, free_vram=False, **builder_extra) -> dict` | **Aplica un ESTILO curado a UN asset de un solo tiro, con AUTO-POST**: `comfyui_get_gamedev_style_preset(style_preset)``comfyui_apply_style_preset` → despacha `kind` a su builder (REUTILIZA el dispatch `_SUPPORTED` del pack, mismos 26 kinds) → `submit`/`wait`/`fetch`**auto-aplica el `post` del preset** (`comfyui_pixelize_image` si el estilo lo pide) → export opcional a Godot (como `pixelart` si hubo pixelize → fija el filtro Nearest). Cierra el hueco #1 de los style presets (report 0190): los estilos pixelart (gameboy, pixel-art-retro) salen ya pixelizados del pipeline, **sin llamar a `comfyui_pixelize_image` a mano**. Devuelve `path` (FINAL post-procesado) y `raw_path` (crudo); `path==raw_path` si el estilo no pide post. Kind/estilo desconocido → `ok=False` sin tocar la GPU (validación pura; parte pura aislada en `styled_asset_build_only`). Probado e2e en GPU: mismo `treasure chest`(prop_object) en cyberpunk-neon (`prompt_id 02473baa`), low-poly-flat (`7a186053`) y gameboy (`46b396e2`, crudo 17374 colores → final **4 colores** Game Boy, auto-pixelizado) — ver `reports/0191`. Impuro: HTTP + disco + (export) subprocess. |
## Ejemplo end-to-end con builder (Fase 1 GPU → Fase 2 CPU → Godot)
Flujo completo pixel-art: construir workflow → generar en ComfyUI → pixel-perfect → Godot.
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_pixelart_workflow import comfyui_build_pixelart_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 ml.comfyui_pixelize_image import comfyui_pixelize_image
# 1. Construir (puro) + 2. generar (GPU)
wf = comfyui_build_pixelart_workflow("isometric tiny house, pixel, 32x32 style", use_lcm=True, seed=42)
pid = comfyui_submit_workflow(wf)["prompt_id"]
outs = comfyui_wait_result(pid, timeout=300)
fn = next(img["filename"] for o in outs.values() for img in o.get("images", []))
raw = comfyui_fetch_output_image(fn, dest_dir="/tmp")["out_path"]
# 3. pixel-perfect (CPU) -> 4. export Godot (ver ejemplo de abajo)
px = comfyui_pixelize_image(raw, "/tmp/house_pixel.png", downscale=8, colors=16)
```
VFX: `comfyui_build_vfx_spritesheet_workflow(prompt, num_frames=8)` → submit → fetch N frames
`comfyui_matting_luma_to_alpha` por frame → montar sheet RGBA con `Image.alpha_composite`
(NO `comfyui_build_grid`, que aplana el alpha).
## Ejemplo canónico de post-proceso
Flujo: crudo generado en ComfyUI -> pixelizar -> exportar a Godot con Nearest.
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_pixelize_image import comfyui_pixelize_image
from ml.comfyui_matting_luma_to_alpha import comfyui_matting_luma_to_alpha
from pipelines.comfyui_export_asset_to_godot import comfyui_export_asset_to_godot
OUT = os.path.expanduser("~/ComfyUI/output")
PROJ = os.path.expanduser("~/gamedev/projects/crossy_road")
# 1. Pixelizar un sprite crudo (SDXL+SDXL_pixel-art) a 16 colores
px = comfyui_pixelize_image(f"{OUT}/hero_00001_.png", "/tmp/hero_pixel.png",
downscale=8, colors=16)
# 2. Exportarlo a Godot como pixelart (carpeta sprites/, filtro Nearest, reimport)
exp = comfyui_export_asset_to_godot("/tmp/hero_pixel.png", "pixelart", PROJ)
print(exp["dest_res_path"], exp["pixelart_filter_set"], exp["reimported"])
# Rama VFX: frame de humo sobre negro -> RGBA -> carpeta vfx/
rgba = comfyui_matting_luma_to_alpha(f"{OUT}/vfx_loop_00007_.png", gamma=1.2, black_point=0.04)
comfyui_export_asset_to_godot(rgba["out_path"], "vfx", PROJ)
```
## Fronteras (qué NO cubre)
- **Montaje de spritesheet dedicado** (grid RGBA + JSON sidecar para Godot/Unity):
no hay función propia todavía — el ejemplo VFX monta con `Image.alpha_composite`
inline. `comfyui_build_grid` NO sirve (aplana el alpha sobre fondo oscuro). Pendiente
de R4 (plan `reports/0140` F2).
- **Pipelines one-shot** (build → submit → wait → fetch → post en una call): el
**set coherente** ya está promovido — `comfyui_generate_asset_pack_oneshot` genera
un pack entero compartiendo checkpoint/style/lora/seed (issue 0087, ver tabla de
pipelines arriba). One-shots por-asset individuales (pixelart/sprite/VFX) siguen
encadenándose a mano; candidatos a promoción cuando el patrón se repita.
- **Sprite turnaround multi-vista** (N direcciones del mismo personaje con identidad fija):
cubierto por `comfyui_build_directional_sprite_workflow` (rotación 3D SV3D/Zero123,
consistencia rotacional medida — `reports/0187`). Lo que sigue **pendiente** es la
orquestación multi-POSE 2D con juez (re-pozar un personaje en N acciones manteniendo
identidad, distinto de rotarlo): `comfyui_build_sprite_sheet_workflow` produce UN frame;
el pipeline multi-pose con juez sigue pendiente (plan `reports/0137` T2).
- **Paletas lospec por red** (`load_lospec_palette`): no incluido. `pixelize` usa
paletas fijas embebidas (game-boy/pico-8/nes) o lista de hex, sin HTTP.
- **TileSet / SpriteFrames `.tres`**: Godot no los deriva solos; `export_asset_to_godot`
copia la textura y avisa, pero no genera el recurso (paso manual o futura función).
## Prerequisitos / notas
- **Godot CLI** para el reimport headless: autodetectado en PATH y en
`~/godot/Godot_v4.7-stable_linux.x86_64`. Si falta, `export_asset_to_godot` deja el
`.import` escrito y lo anota (no falla).
- **Filtro Nearest (Godot 4)**: se setea global en `project.godot`
(`default_texture_filter=0`), no por `.import`. La función lo asegura para pixelart.
- CPU-only: Pillow + numpy del venv del registry. Cero VRAM, cero red.
+82
View File
@@ -0,0 +1,82 @@
# Capability group: `gamedev-engine` — runtime de juego C++ multiplataforma (PC + WebAssembly)
Cluster de primitivas C++ que forman el núcleo de un runtime de juego 2D portable a
escritorio (Windows/Linux/macOS) y navegador (WebAssembly via emscripten). Stack:
**SDL3** (ventana + input + GL context) + **sokol_gfx** (render) + **miniaudio**
(audio). Nacido del Issue 0072b.
A diferencia del grupo hermano `gamedev-2d` (generación de *assets* 2D con ComfyUI y
puente a Godot), este grupo es el **motor que ejecuta el juego**: el bucle de
simulación, la cámara, el input, el render por lotes y el audio. No genera arte; lo
consume en tiempo de ejecución.
Tag: `gamedev-engine`. Filtro: `mcp__registry__fn_search query="" tag="gamedev-engine"`.
## Funciones del grupo
| ID | Firma corta | Qué hace | Pureza |
|---|---|---|---|
| `game_loop_cpp_gamedev` | `loop_run(SDL_Window*, const LoopCfg&) -> void` | Game loop fixed-timestep estilo Glenn Fiedler ("Fix Your Timestep"): desacopla `on_fixed_update` (dt fijo) de `on_render` (factor de interpolación), acumulador con cap anti spiral-of-death. Branch automático desktop (while loop) vs `__EMSCRIPTEN__` (`emscripten_set_main_loop`). | impure |
| `input_unified_cpp_gamedev` | `input_begin_frame(InputState&); input_process_event(InputState&, const SDL_Event*)` | Snapshot unificado de input por frame para SDL3: mapea teclado (WASD+flechas), ratón, gamepad y touch a botones lógicos (left/right/up/down/action_a..y/start/back) y ejes analógicos, con flags `*_pressed` de rising edge limpio. | impure |
| `camera_2d_cpp_gamedev` | `world_to_screen / screen_to_world(Camera2D, Vec2) -> Vec2; visible_world_rect(Camera2D) -> Rect; view_proj_matrix(Camera2D, float[16])` | Cámara ortográfica 2D pura (pos centro, zoom, rotación, viewport): conversiones world↔screen, AABB visible y matriz view-projection 4×4 column-major lista para cualquier renderer. Fast-path sin trig si `rotation==0`. | pure |
| `sokol_setup_cpp_gfx` | `make_environment() -> sg_environment; make_swapchain(int w, int h) -> sg_swapchain` | Builders puros para inicializar sokol_gfx sobre un GL context creado por SDL3 (no por sokol_app): `sg_environment` con defaults RGBA8 + depth/stencil y `sg_swapchain` del default framebuffer del contexto activo. | pure |
| `sprite_batch_cpp_gfx` | `sprite_batch_create(int cap=4096) -> SpriteBatch; sprite_batch_begin/draw/end` | Batched textured quad renderer sobre sokol_gfx: begin/draw/end con auto-flush por cambio de atlas o capacidad llena. Vertex layout pos+uv+color, alpha blending estándar, GLSL 330 / GLES 300. Base de plataformeros, top-down y UI sprites. | impure |
| `audio_engine_cpp_gamedev` | `engine_init() -> Engine; engine_shutdown(Engine&); engine_set_volume(Engine&, float)` | Lifecycle del engine de audio basado en miniaudio (single-header, public domain): inicializa device default, master volume, libera recursos. Cross-platform (WASAPI/ALSA/CoreAudio y WebAudio bajo emscripten). Única TU que define `MINIAUDIO_IMPLEMENTATION`. | impure |
| `audio_play_cpp_gamedev` | `sound_load(Engine&, const char*) -> Sound; sound_play/stop/set_volume/destroy(Sound&); play_sound_oneshot(Engine&, const char*, float)` | Reproducción de audio sobre `fn::audio::Engine`: carga con streaming desde disco (wav/mp3/flac/ogg), play/stop/volumen por sonido, y helper fire-and-forget para one-shots sin handle. | impure |
| `build_wasm_cpp_app_bash_infra` | `build_wasm_cpp_app(app_name, [--no-budget-check]) -> void` | Compila una app C++ del registry (`cpp/apps/<name>`) a WebAssembly via emscripten. Produce `build/wasm/<name>/<name>.{html,js,wasm,wasm.gz}`. Falla si el gzip supera 2 MB (budget). | impure |
## Ejemplo canónico (esqueleto de un juego 2D)
Las primitivas se componen así dentro del `main()` de una app C++ del registry. Cada
identificador (`fn::game_loop`, `fn::input`, `fn::camera2d`, `fn::gfx`, `fn::audio`)
proviene de la función homónima del registry; la app solo aporta la lógica de juego.
```cpp
// 1. Ventana + GL context con SDL3, sokol_gfx encima (sokol_setup)
sg_setup(...); // usa make_environment() del registry
auto swap = fn::gfx::make_swapchain(W, H);
auto batch = fn::gfx::sprite_batch_create(4096);
auto audio = fn::audio::engine_init();
fn::audio::play_sound_oneshot(audio, "assets/music.ogg", 0.6f);
// 2. Estado de cámara e input
fn::game::Camera2D cam{ .pos = {0,0}, .zoom = 1.0f, .viewport = {W, H} };
fn::input::InputState in{};
// 3. Game loop fixed-timestep: simulación e interpolación desacopladas
fn::game::LoopCfg cfg{
.on_event = [&](const SDL_Event* e){ fn::input::input_process_event(in, e); },
.on_fixed_update = [&](float dt){ /* mover entidades usando in.left_pressed... */ },
.on_render = [&](float alpha){
fn::gfx::sprite_batch_begin(batch);
// draw sprites con cam.view_proj_matrix(...) como transform
fn::gfx::sprite_batch_end(batch);
},
};
fn::game::loop_run(window, cfg);
// 4. Distribuir a navegador: build_wasm_cpp_app "<app>" -> build/wasm/<app>/
```
## Fronteras (qué NO cubre)
- **Generación de assets** (sprites, tiles, VFX): es el grupo hermano `gamedev-2d`
(ComfyUI → post-proceso → Godot). Este grupo solo los *renderiza* en runtime.
- **Física / colisiones / ECS**: no incluidos. El `on_fixed_update` recibe el dt fijo;
la simulación la pone la app.
- **TileSet / mapas / escenas**: no hay sistema de niveles; `sprite_batch` dibuja quads
sueltos.
- **App end-to-end consumidora**: a fecha de hoy estas primitivas son el núcleo del
runtime (Issue 0072b) pero **no hay todavía una app C++ que las componga end-to-end**
(varias están marcadas `pendiente-usar`). El ejemplo de arriba es el esqueleto
previsto, no un binario validado. Cuando exista la primera app, su `app.md`
declarará estas IDs en `uses_functions` y servirá de validación e2e.
## Prerequisitos / notas
- **SDL3** para ventana + input + GL context; **sokol_gfx** (vendored en `cpp/vendor/`)
para render; **miniaudio** (single-header) para audio.
- Target nativo: GLSL 330. Target WebAssembly (emscripten): GLES 300 — `sprite_batch`
emite ambos shaders.
- `build_wasm_cpp_app` exige el SDK de emscripten en el entorno y respeta un budget de
2 MB gzip por defecto (`--no-budget-check` para saltarlo).
+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.
+311
View File
@@ -0,0 +1,311 @@
# Integración ComfyUI → Godot: puente de assets ordenado y gestionado
Diseño del puente entre la generación de assets en **ComfyUI** (`~/ComfyUI/`) y su consumo en
proyectos **Godot 4** (`~/gamedev/projects/`). El objetivo es que un asset generado (sprite,
tileset, pixelart, spritesheet VFX, audio, malla 3D GLB) viaje a un proyecto Godot a la carpeta
correcta, con los *import settings* adecuados a su tipo, sin romper los archivos `.import`
existentes ni desordenar el proyecto.
- **Fecha:** 26/06/2026
- **Alcance:** mapa de ambas estructuras + convención de carpetas destino + tabla
tipo-de-asset → carpeta Godot → import settings + propuesta de función(es) del registry para
automatizar el traslado. Es documento de diseño; la implementación se delega a `fn-constructor`.
- **Fuera de alcance:** generación (GPU, otro agente), implementación de las funciones, descarga
de modelos.
Documento hermano del catálogo de capacidades de generación: `~/ComfyUI/CAPABILITIES.md` (fuera
del repo) y `docs/capabilities/comfyui-overview.md` (versionable). Este documento añade la pata
que faltaba: **qué pasa con el asset una vez generado**.
---
## 1. Mapa de ComfyUI (origen de los assets)
ComfyUI vive en `~/ComfyUI/` (clon del repo, no versionado en `fn_registry`). Las carpetas
relevantes para el puente son:
```
~/ComfyUI/
├── output/ # ★ AQUÍ caen TODOS los assets generados
│ ├── *.png # imágenes (sprites, pixelart, tiles) — ~150 hoy
│ ├── *.webp # spritesheets animados / vídeo corto (SaveAnimatedWEBP)
│ ├── *.mp4 # vídeo (SaveVideo)
│ ├── *.glb / *.obj / *.ply # mallas 3D (SaveGLB)
│ └── 3D/ # subcarpeta de salidas 3D (texturizadas, multi-vista)
├── input/ # imágenes de entrada (img2img, image-to-3d)
├── models/ + /mnt/2tb/comfyui_models/ # checkpoints/loras/vae/... (vía extra_model_paths.yaml)
├── user/default/workflows/ # grafos UI, agrupados por capacidad (01_txt2img, 02_img2img, …)
├── skills_library/<slug>/ # recetas de estilo reproducibles (recipe.json + samples)
└── custom_nodes/ # nodos extra (PixelArt-Detector, IPAdapter, Hunyuan3D, …)
```
- **`output/` es el único punto de origen del puente.** Cada nodo `Save*` escribe ahí con un
patrón de nombre `<prefijo>_NNNNN_.<ext>` (sufijo numérico de 5 dígitos). Ejemplos reales en
disco hoy: `bench160_3000_00001_.png`, `svd_motion_hi_00001_.webp`, `3d_robot_mesh_00001_.glb`,
`output/3D/character_clean_textured_00001_.glb`.
- **Modelos centralizados** en `/mnt/2tb/comfyui_models/` vía `extra_model_paths.yaml`
(`is_default: true`). No intervienen en el puente (son insumo de generación, no asset de salida).
- **Tipos que generamos hoy** (recuento real del `output/`): `png` (mayoría), `glb` (15), `mp4`
(6), `webp` (2), `obj`/`ply` (formatos 3D crudos). **Audio aún no se genera en ComfyUI** (no hay
`wav`/`ogg`/`mp3` en `output/`); el plan de audio existe como report aparte. El puente lo
contempla igualmente porque Godot lo consume y porque el audio puede llegar de otra fuente.
- Catálogo navegable de qué sabemos generar y con qué función/grafo: `~/ComfyUI/CAPABILITIES.md`.
---
## 2. Mapa de Godot (destino de los assets)
Los proyectos viven en `~/gamedev/projects/` (fuera de `fn_registry`, igual que ComfyUI). Hay una
**biblioteca maestra** de assets en `~/gamedev/assets/` y, por proyecto, una copia local de los
assets que usa. Proyectos reales localizados:
- `~/gamedev/projects/crossy_road/` (juego "LizardRoad", Godot 4.7, móvil portrait 640×1280)
- `~/gamedev/projects/risk/` (pilotaje previo)
Estructura canónica de un proyecto Godot 4 (tomada de `crossy_road`, que es el patrón real):
```
~/gamedev/projects/crossy_road/
├── project.godot # config del proyecto (nombre, autoloads, rendering, display)
├── .godot/ # ★ caché de import (regenerable) — NUNCA se versiona ni se toca a mano
│ └── imported/ # binarios .ctex/.sample/... generados desde los .import
├── assets/ # assets del proyecto (copia local de la biblioteca)
│ ├── biomas/ agua.png + agua.png.import (par obligatorio por cada asset)
│ ├── kenney/ packs CC0
│ ├── external/ otros CC0
│ └── audio/sfx/ step.wav + step.wav.import
├── scenes/ *.tscn (escenas)
├── scripts/ *.gd + *.gd.uid
├── addons/godot_ai/ addon del MCP (control del editor desde Claude)
└── export_presets.cfg / android/ (build móvil)
```
### Cómo importa Godot 4 cada asset — el archivo `.import`
**Regla de oro:** en Godot, **cada asset es un par `<archivo>` + `<archivo>.import`**. El
`.import` es un INI que declara el `importer`, el `type` de recurso resultante, un `uid://`
estable y los parámetros de importación. Godot genera el binario importado en `.godot/imported/`
y lo regenera al reimportar. **Romper o desincronizar el `.import` = el asset no carga o se
reimporta con settings por defecto.** Por eso el puente debe respetar este par.
Ejemplos reales de `.import` por tipo (de `crossy_road`):
**Textura** (`importer="texture"`, `type="CompressedTexture2D"`):
```ini
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cedkstexk3ciw"
path="res://.godot/imported/agua.png-<hash>.ctex"
[params]
compress/mode=0 ; 0=Lossless (correcto para pixelart), 2=VRAM
mipmaps/generate=false ; off para 2D/pixelart
process/fix_alpha_border=true
detect_3d/compress_to=1
```
**Audio WAV** (`importer="wav"`, `type="AudioStreamWAV"`):
```ini
[remap]
importer="wav"
type="AudioStreamWAV"
[params]
edit/loop_mode=0 ; 0=Disabled (sfx), 1=Forward (música en bucle)
compress/mode=2
force/mono=false
```
**Malla 3D GLB** (`importer="scene"`, produce `PackedScene`): el GLB se importa como escena glTF;
sus opciones (escala raíz, generación de colisión, manejo de materiales/texturas) viven en el
`.import` de tipo `scene`. No había ninguno en los proyectos actuales (son 2D), pero es el
importer canónico de Godot para `.glb`/`.gltf`.
### El gotcha del filtro de textura (Godot 4 ≠ Godot 3)
En **Godot 4** el filtro Nearest/Linear **no es un campo del `.import` por defecto** (en Godot 3
sí lo era). El filtro se controla de dos formas:
1. **Global del proyecto (recomendado, KISS):** Project Settings → Rendering → Textures →
Canvas Textures → Default Texture Filter → **Nearest**. En `project.godot` es la clave
`rendering/textures/canvas_textures/default_texture_filter=0` (0 = Nearest). Tras cambiarlo hay
que **reimportar** las texturas.
2. **Override por asset:** en el panel Import de una textura concreta, Texture > Filter →
`Nearest` (override del default). Esto sí escribe la opción en su `.import`.
> **Hallazgo (read-only) en `crossy_road`:** su `project.godot` **no** declara
> `default_texture_filter`, así que usa el default **Linear** → cualquier asset pixelart se ve
> **borroso** (sus `biomas/*.png` tienen `mipmaps/generate=false` y `compress/mode=0`, correcto,
> pero les falta el Nearest). Para un proyecto pixelart, setear el global una vez es el primer
> paso del puente. (No se modificó nada; queda anotado para el constructor.)
---
## 3. El puente: convención de carpetas destino en Godot
Convención propuesta para la copia local de assets dentro de cada proyecto, alineada con lo que
ya existe (`assets/audio/sfx/`, `assets/kenney/`) y extendida a todos los tipos que generamos:
```
res://assets/
├── sprites/ # PNG individuales: personajes, props, objetos sueltos
├── tilesets/ # PNG de tiles + el recurso TileSet .tres derivado
├── vfx/ # spritesheets / animaciones (WEBP, o PNG en grid) → SpriteFrames/AtlasTexture
├── audio/
│ ├── sfx/ # efectos cortos (WAV) — loop OFF
│ └── music/ # música (OGG/WAV) — loop ON
├── models/ # mallas 3D GLB (+ sus texturas/materiales)
└── _generated/ # opcional: zona de aterrizaje de lo recién traído de ComfyUI, antes de clasificar
```
- **`pixelart` no es una carpeta**, es un *atributo transversal*: un sprite, tile o VFX puede ser
pixelart. Lo que cambia es el import setting (Nearest + Lossless), no la ubicación. El pixelart
va a `sprites/` / `tilesets/` / `vfx/` según su rol, y el proyecto entero se marca Nearest a
nivel global cuando es un juego pixelart.
- **Biblioteca maestra vs copia local:** `~/gamedev/assets/` es la fuente ordenada; cada proyecto
guarda dentro su copia (`<proyecto>/assets/...`) porque Godot referencia por rutas `res://`
relativas a la raíz del proyecto. El puente copia de `~/ComfyUI/output/` → bien a la biblioteca
maestra, bien directo a un proyecto.
### Naming y versionado
- ComfyUI nombra `<prefijo>_NNNNN_.<ext>` (p. ej. `svd_motion_hi_00001_.webp`). Al exportar,
**renombrar a snake_case limpio y semántico** quitando el sufijo `_NNNNN_` y los guiones bajos
de cola: `svd_motion_hi_00001_.webp``explosion_loop.webp`.
- Sin espacios ni mayúsculas en nombres de archivo (consistencia con `res://` y multiplataforma).
- Versionado: sufijo opcional `_vN` cuando se itera un asset que ya está en uso
(`hero_idle.png``hero_idle_v2.png`), para no pisar el `uid://` del que ya referencian escenas.
**Nunca** sobrescribir un asset en uso sin querer: cambia su contenido pero conserva su `uid`,
lo que puede ser deseable (hot-swap) o no (regresión visual). Decisión explícita por asset.
---
## 4. Tabla: tipo de asset ComfyUI → carpeta Godot → import settings clave
| Tipo (ComfyUI) | Ext salida | Carpeta Godot destino | Importer Godot (`type`) | Import settings clave |
|---|---|---|---|---|
| **Sprite individual** | `.png` | `res://assets/sprites/` | `texture``CompressedTexture2D` | `compress/mode=0` (Lossless); `mipmaps/generate=false`; repeat off; filtro = default del proyecto (Linear si arte vectorial, Nearest si pixelart) |
| **Pixelart** (sprite/tile) | `.png` | `sprites/` o `tilesets/` (según rol) | `texture``CompressedTexture2D` | **CRÍTICO: Nearest** (global `default_texture_filter=0` o override por asset); `compress/mode=0` Lossless; `mipmaps/generate=false`; repeat off |
| **Tileset** | `.png` | `res://assets/tilesets/` | `texture` + recurso **`TileSet` (.tres)** manual/script | Nearest (suele ser pixelart); además crear `TileSet` con tamaño de celda (region grid) — Godot **no** deriva el TileSet automáticamente del PNG |
| **VFX spritesheet** | `.webp` / `.png` (grid) | `res://assets/vfx/` | `texture`**`SpriteFrames`** (AnimatedSprite2D) o **`AtlasTexture`** por frame | Nearest si pixelart; definir `hframes`/`vframes` o regiones por frame; Godot **no** convierte un sheet a `SpriteFrames` solo: se hace en editor o por script de import |
| **Audio SFX** | `.wav` | `res://assets/audio/sfx/` | `wav``AudioStreamWAV` | `edit/loop_mode=0` (Disabled); `compress/mode` según peso |
| **Audio música** | `.ogg` / `.wav` | `res://assets/audio/music/` | `oggvorbisstr` (OGG) / `wav` | **loop ON** (`edit/loop_mode=1` en WAV; `loop=true` en OGG) |
| **Malla 3D** | `.glb` (preferido) | `res://assets/models/` | `scene``PackedScene` (glTF) | escala raíz coherente; generar colisión si se necesita; si la textura es pixelart, poner el material en Nearest; preferir `.glb` sobre `.obj`/`.ply` (los lleva embebidos) |
Notas que la tabla condensa:
- **Godot no automatiza dos conversiones clave:** (1) PNG de tiles → recurso `TileSet`, y (2)
spritesheet → `SpriteFrames`. Ambas requieren un paso manual en editor o un script de import
(o un addon, p. ej. el TexturePacker importer / aseprite-importers). El puente puede generar el
`.tres` por script a partir de la geometría conocida del grid.
- **WEBP animado (SVD):** nuestros `svd_*.webp` son clips de Stable Video Diffusion. Para usarlos
como animación 2D en Godot, lo robusto es **descomponer en frames** (PNG en grid) y construir
`SpriteFrames`, no cargar el WEBP animado tal cual.
- **3D pixelart/low-poly:** el GLB importa como escena; cuidar que el material no aplique filtro
Linear a una textura pixelart (se setea en el material/import de la malla).
---
## 5. Propuesta de función(es) del registry (diseño, NO implementación)
Búsqueda en el registry: `mcp__registry__fn_search query="godot export asset import"` → **0
resultados**. Gap limpio. Hoy llevar un asset de ComfyUI a Godot es manual (copiar + abrir editor
+ tocar import a mano). Alineado con la doctrina del registry (registry-first + crecer por
composición de helpers atómicos, issue 0087), la propuesta es **un pipeline one-shot que compone
helpers pequeños**:
### Pipeline principal
```
export_asset_to_godot_py_pipelines (impura, kind=pipeline, tag de grupo: godot, comfyui)
Firma:
export_asset_to_godot(
asset_path: str, # ruta en ~/ComfyUI/output/ (o cualquier archivo)
kind: str, # "sprite" | "pixelart" | "tileset" | "vfx" | "sfx" | "music" | "model"
godot_project: str, # ruta raíz del proyecto Godot destino
dest_name: str = "", # nombre limpio destino (default: snake_case del origen sin _NNNNN_)
pixelart: bool = False, # fuerza Nearest + Lossless en la textura
loop: bool = False, # audio en bucle (música)
reimport: bool = True, # lanza reimport headless al final
) -> dict # {dest_res_path, import_written, reimported, warnings[]}
```
Comportamiento:
1. Resolver la carpeta destino por `kind` (tabla §4) dentro de `res://assets/`.
2. Copiar el archivo con nombre limpio (snake_case, sin sufijo `_NNNNN_`).
3. Escribir/asegurar el `.import` adecuado al tipo (texture/wav/scene) con los settings clave.
4. Si `pixelart=True`, además asegurar el global del proyecto
(`default_texture_filter=0` en `project.godot`) o el override por asset.
5. Si `reimport=True`, lanzar reimport headless para que Godot regenere `.godot/imported/`.
6. Devolver el `res://` final + avisos (p. ej. "tileset copiado pero falta crear el TileSet .tres",
"WEBP animado: descomponer en frames antes de SpriteFrames").
### Helpers atómicos que compone (delegar a `fn-constructor` en paralelo)
| Helper (id tentativo) | Pureza | Qué hace |
|---|---|---|
| `godot_asset_dest_dir_py_core` | pura | mapea `kind` → subdir de `res://assets/` (tabla §4) |
| `godot_clean_asset_name_py_core` | pura | quita sufijo `_NNNNN_`, normaliza a snake_case, sin espacios |
| `godot_write_texture_import_py_infra` | impura | escribe `.import` de textura (compress/mipmaps/filter) preservando `uid` si ya existe |
| `godot_write_audio_import_py_infra` | impura | escribe `.import` de audio (loop_mode) |
| `godot_ensure_pixelart_project_py_infra` | impura | setea `default_texture_filter=0` en `project.godot` (idempotente) |
| `godot_reimport_headless_bash_infra` | impura | `godot --headless --path <proj> --import` para regenerar la caché |
| `godot_build_spriteframes_tres_py_infra` | impura | genera `.tres` de `SpriteFrames`/`AtlasTexture` a partir de un sheet + geometría de grid |
| `godot_build_tileset_tres_py_infra` | impura | genera `.tres` de `TileSet` a partir de un PNG + tamaño de celda |
### DoD esbozado (según `dod_quality.md`)
- **Golden:** `export_asset_to_godot("~/ComfyUI/output/hero.png", "pixelart",
"~/gamedev/projects/crossy_road")` deja `res://assets/sprites/hero.png` + su `.import` con
Nearest + Lossless, el `project.godot` con `default_texture_filter=0`, y la reimport headless
sale con exit 0; assert: el `.import` contiene el override Nearest y el binario aparece en
`.godot/imported/`.
- **Edge 1 (audio música):** `kind="music", loop=True``.import` con `edit/loop_mode=1`.
- **Edge 2 (GLB 3D):** `kind="model"` copia a `res://assets/models/` y la escena glTF carga
(reimport sin error).
- **Edge 3 (tileset):** copia el PNG y **avisa** que falta el `.tres` del TileSet (o lo genera con
`godot_build_tileset_tres`), sin romper nada.
- **Error path:** `kind` desconocido → error claro sin copiar nada; `godot_project` sin
`project.godot` → aborta y no escribe; nunca sobrescribe un asset en uso sin `dest_name`
explícito.
- **Idempotencia:** re-exportar el mismo asset preserva el `uid://` existente (no rompe las
referencias de las escenas que ya lo usan).
### ¿Automatizar el import vía MCP godot-ai?
El addon `godot_ai` (MCP, server `127.0.0.1:8000/mcp`) está presente en `crossy_road` y `risk`.
Con el editor abierto, `filesystem_manage` op `reimport` (recibe `paths`) puede forzar reimport
desde el editor vivo. **Pero** la convención (`CONVENTIONS.md` de gamedev) ya observa que el
reimport por MCP suele ser **innecesario**: `project_run` recompila desde disco al arrancar, y un
`godot --headless --import` regenera la caché sin editor abierto. **Recomendación:** el puente usa
**reimport headless por CLI** (`godot_reimport_headless_bash_infra`) como mecanismo por defecto
(no requiere editor abierto ni MCP), y deja el MCP como opción cuando el editor ya está vivo y se
quiere refrescar en caliente.
---
## 6. Resumen operativo (TL;DR)
1. **Origen:** todo asset generado cae en `~/ComfyUI/output/` (PNG/WEBP/MP4/GLB).
2. **Destino:** `res://assets/{sprites,tilesets,vfx,audio/{sfx,music},models}/` dentro del
proyecto Godot; cada asset es un par `archivo` + `archivo.import`.
3. **Import por tipo:** textura (Lossless, mipmaps off, **Nearest si pixelart**), audio (loop
off=sfx / on=música), GLB (escena glTF). Tabla §4.
4. **Gotcha Godot 4:** el Nearest pixelart se setea **global** (`default_texture_filter=0`) o por
override de asset — **no** es un flag por defecto del `.import`. `crossy_road` hoy está en
Linear (pixelart borroso): anotado.
5. **Godot no automatiza** PNG→TileSet ni sheet→SpriteFrames: paso manual o script de import.
6. **Automatización:** pipeline `export_asset_to_godot` (gap confirmado, 0 funciones hoy) que
compone helpers atómicos + reimport headless. Diseño en §5; implementación se delega a
`fn-constructor`.
## Fuentes
- ComfyUI: `~/ComfyUI/CAPABILITIES.md`, `~/ComfyUI/extra_model_paths.yaml`, listado de
`~/ComfyUI/output/` (read-only).
- Godot: `~/gamedev/CONVENTIONS.md`, `~/gamedev/README.md`,
`~/gamedev/projects/crossy_road/project.godot` y sus `*.import` (read-only).
- Godot 4 pixel art texture filter: [GDQuest — Setting up pixel art graphics in Godot 4](https://www.gdquest.com/library/pixel_art_setup_godot4/),
[Godot Forum — How to import pixel art in Godot 4](https://forum.godotengine.org/t/how-to-import-pixel-art-in-godot-4/7105).
- Godot 4 sprite sheets: [Godot docs — 2D sprite animation](https://docs.godotengine.org/en/stable/tutorials/2d/2d_sprite_animation.html),
[godot-4-aseprite-importers](https://github.com/nklbdev/godot-4-aseprite-importers).
@@ -0,0 +1,82 @@
---
name: cdp_set_file_input
kind: function
lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def cdp_set_file_input(selector: str, file_paths, *, port: int = 9222, target_url_substr: str = '', timeout_s: float = 10.0) -> dict"
description: "Asigna uno o varios archivos a un <input type=file> de una pestana de un Chrome con remote debugging, via CDP, SIN abrir el dialogo nativo del sistema operativo. Localiza el target por substring de URL, abre el WebSocket y ejecuta DOM.enable -> DOM.getDocument -> DOM.querySelector(selector) -> DOM.setFileInputFiles con las rutas ABSOLUTAS. Es el unico metodo robusto para subir archivos por CDP: el navegador no permite escribir el value de un file input desde JS (seguridad) y simular drag&drop es fragil; setFileInputFiles inyecta los File y dispara el evento change que la SPA escucha. Base de whatsapp_send_image y de cualquier flujo de subida de archivos sobre el navegador diario sin robar el foco al usuario."
tags: [cdp, browser, automation, upload, file-input, python, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json", "os", "urllib.request", "websocket"]
params_schema:
params:
- name: selector
desc: "Selector CSS del <input type=file> destino. Debe resolver a UN elemento (se usa el primer match). El input puede estar oculto (display:none); CDP lo localiza igual."
- name: file_paths
desc: "Ruta (str) o lista de rutas a asignar. Se expanden (~) y se convierten a rutas ABSOLUTAS; cada una debe existir en disco o se aborta con ok=False antes de tocar la red."
- name: port
desc: "Puerto de remote debugging de Chrome. Default 9222."
- name: target_url_substr
desc: "Substring que debe contener la URL del target (pestana). Si vacio, usa el primer target de tipo 'page'."
- name: timeout_s
desc: "Timeout en segundos para la conexion WebSocket. Default 10.0."
output: "dict {ok: bool, error: str, node_id: int (nodeId CDP del input localizado, 0 si no se encontro), selector: str (eco), files: list[str] (rutas absolutas asignadas)}. ok=True solo si el input se localizo y setFileInputFiles no devolvio error. Nunca lanza: errores de archivo/red/conexion/transport se devuelven en 'error' con ok=False."
tested: true
tests: ["test_golden_asigna_archivos_al_input", "test_edge_archivo_inexistente_ok_false_sin_red", "test_edge_selector_no_encontrado_ok_false", "test_error_create_connection_lanza_ok_false"]
test_file_path: "python/functions/browser/cdp_set_file_input_test.py"
file_path: "python/functions/browser/cdp_set_file_input.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from browser.cdp_set_file_input import cdp_set_file_input
# Requiere un Chrome lanzado con --remote-debugging-port=9222.
# Adjuntar una imagen al input de subida de una pestana (WhatsApp Web, un formulario, etc).
# El input "vivo" suele exponerse tras pulsar el boton "Adjuntar"/"Subir" (haz el click antes).
res = cdp_set_file_input(
'input[type="file"][multiple]',
"/home/enmanuel/ComfyUI/output/item_icon_potion_00001_.png",
target_url_substr="whatsapp",
)
print(res["ok"], res["node_id"], res["error"])
# -> True 120
```
O directo por CLI: `python3 python/functions/browser/cdp_set_file_input.py 'input[type="file"]' /ruta/abs.png whatsapp`.
## Cuando usarla
Cuando necesites **subir/adjuntar un archivo** a una pestana abierta sin que aparezca el
dialogo nativo de archivos del sistema operativo (que CDP no puede operar). Es la primitiva
de subida sobre la que se construye `whatsapp_send_image_py_browser` y cualquier
automatizacion de formularios con `<input type=file>`. El patron tipico: primero un click
real (`cdp_click_xy_py_browser`) en el boton que expone el input vivo, luego esta funcion
con el selector del input y la ruta absoluta del archivo.
## Gotchas
- **El input debe existir en el DOM al llamar.** Muchas SPAs (WhatsApp Web) solo crean/activan
el `<input type=file>` "vivo" DESPUES de pulsar el boton de adjuntar. Haz el click real
primero; si asignas sobre un input persistente/decoy, `setFileInputFiles` puede devolver ok
pero la SPA no reacciona (no aparece el preview).
- **Rutas ABSOLUTAS obligatorias.** `setFileInputFiles` exige rutas absolutas; la funcion ya
convierte (`os.path.abspath` + expanduser), pero el archivo debe existir o aborta con ok=False
antes de abrir la conexion.
- **El selector debe resolver al input correcto.** Si hay varios `<input type=file>` (uno por
tipo: fotos, documento...), afina el selector (`[multiple]`, `[accept*="image"]`). Si no
matchea ninguno, devuelve `ok=False` con "no element matches selector".
- **Dispara `change`, no `input`.** `DOM.setFileInputFiles` emite el evento `change` nativo
(que la mayoria de uploads escuchan). Si una SPA solo escucha otro evento, no bastara.
- Requiere un Chrome lanzado con `--remote-debugging-port=9222` (o el puerto que pases). Sin
remote debugging, `GET /json` falla y devuelve `ok=False`.
- Nunca lanza: errores de archivo/red/WS/transport se reportan en `error` con `ok=False`.
@@ -0,0 +1,163 @@
"""Asigna archivos a un <input type=file> de una pestana de Chrome via Chrome DevTools Protocol.
Primitiva de input CDP para subir archivos SIN abrir el dialogo nativo del sistema
operativo: localiza un target (pestana) por substring de su URL, abre el WebSocket de
depuracion y ejecuta la secuencia `DOM.enable` -> `DOM.getDocument` -> `DOM.querySelector`
(localiza el input por selector CSS) -> `DOM.setFileInputFiles` (asigna las rutas
absolutas al input).
Es el unico metodo robusto para adjuntar archivos por CDP: el navegador no permite
escribir el `value` de un `<input type=file>` desde JavaScript (seguridad), y simular
drag&drop es fragil. `DOM.setFileInputFiles` inyecta los `File` directamente y dispara el
evento `change` que la SPA escucha. Base de `whatsapp_send_image` y de cualquier flujo de
subida de archivos sobre el navegador diario sin robar el foco al usuario.
"""
import json
import os
import urllib.request
import websocket
def _call(ws, msg_id: int, method: str, params: dict) -> dict:
"""Envia un comando CDP y drena eventos hasta la respuesta con el mismo id."""
ws.send(json.dumps({"id": msg_id, "method": method, "params": params}))
while True:
raw = ws.recv()
if not raw:
return {}
try:
parsed = json.loads(raw)
except Exception: # noqa: BLE001 — frame no-JSON, ignorar
continue
if parsed.get("id") == msg_id:
return parsed
def cdp_set_file_input(
selector: str,
file_paths,
*,
port: int = 9222,
target_url_substr: str = "",
timeout_s: float = 10.0,
) -> dict:
"""Asigna uno o varios archivos a un `<input type=file>` de una pestana de Chrome.
Localiza el target `page` por substring de URL, abre el WebSocket CDP y ejecuta
`DOM.enable` -> `DOM.getDocument` -> `DOM.querySelector(selector)` ->
`DOM.setFileInputFiles`. Equivale a que el usuario elija esos archivos en el dialogo
nativo, pero sin abrirlo: ideal para automatizar subidas (adjuntar imagen en WhatsApp,
subir un fichero a un formulario) sobre una pestana ya abierta.
Args:
selector: Selector CSS del `<input type=file>` destino. Debe resolver a UN
elemento (se usa el primer match). El input puede estar oculto.
file_paths: Ruta (str) o lista de rutas a asignar. Se expanden (`~`) y se
convierten a rutas ABSOLUTAS; cada una debe existir en disco.
port: Puerto de remote debugging de Chrome. Default 9222.
target_url_substr: Substring que debe contener la URL del target (pestana). Si
"", usa el primer target de tipo "page".
timeout_s: Timeout (segundos) para la conexion WebSocket. Default 10.0.
Returns:
dict con claves:
ok: bool True si el input se localizo y los archivos se asignaron sin error.
error: str mensaje de error (vacio si ok).
node_id: int nodeId CDP del input localizado (0 si no se encontro).
selector: str eco del selector usado.
files: list[str] rutas absolutas que se intentaron asignar.
Nunca lanza: errores de archivo, red, conexion o transport se devuelven en
"error" con ok=False.
"""
# 1. Normalizar y validar las rutas (antes de tocar la red).
if isinstance(file_paths, str):
raw_paths = [file_paths]
else:
raw_paths = list(file_paths)
abs_paths = [os.path.abspath(os.path.expanduser(p)) for p in raw_paths]
if not abs_paths:
return {"ok": False, "error": "no file paths provided",
"node_id": 0, "selector": selector, "files": []}
missing = [p for p in abs_paths if not os.path.isfile(p)]
if missing:
return {"ok": False, "error": f"file(s) not found: {missing}",
"node_id": 0, "selector": selector, "files": abs_paths}
# 2. Listar targets via HTTP y elegir el primer page que matchee.
try:
with urllib.request.urlopen(
f"http://127.0.0.1:{port}/json", timeout=5
) as resp:
targets = json.loads(resp.read().decode())
except Exception as e: # noqa: BLE001 — red/HTTP/JSON, no relanzar
return {"ok": False, "error": str(e),
"node_id": 0, "selector": selector, "files": abs_paths}
chosen = None
for t in targets:
if t.get("type") != "page":
continue
url = t.get("url", "")
if target_url_substr == "" or target_url_substr in url:
chosen = t
break
if chosen is None:
return {"ok": False, "error": f"no target matching {target_url_substr}",
"node_id": 0, "selector": selector, "files": abs_paths}
ws_url = chosen.get("webSocketDebuggerUrl", "")
# 3. Abrir WS y correr la secuencia DOM.
try:
ws = websocket.create_connection(ws_url, timeout=timeout_s)
except Exception as e: # noqa: BLE001 — conexion WS
return {"ok": False, "error": str(e),
"node_id": 0, "selector": selector, "files": abs_paths}
try:
_call(ws, 1, "DOM.enable", {})
doc = _call(ws, 2, "DOM.getDocument", {"depth": 0})
root_id = doc.get("result", {}).get("root", {}).get("nodeId", 0)
if not root_id:
return {"ok": False, "error": "DOM.getDocument did not return a root nodeId",
"node_id": 0, "selector": selector, "files": abs_paths}
qs = _call(ws, 3, "DOM.querySelector",
{"nodeId": root_id, "selector": selector})
node_id = qs.get("result", {}).get("nodeId", 0)
if not node_id:
return {"ok": False, "error": f"no element matches selector: {selector}",
"node_id": 0, "selector": selector, "files": abs_paths}
sf = _call(ws, 4, "DOM.setFileInputFiles",
{"files": abs_paths, "nodeId": node_id})
err = sf.get("error")
if err:
return {"ok": False, "error": json.dumps(err),
"node_id": node_id, "selector": selector, "files": abs_paths}
except Exception as e: # noqa: BLE001 — fallo de transport durante send/recv
return {"ok": False, "error": str(e),
"node_id": 0, "selector": selector, "files": abs_paths}
finally:
try:
ws.close()
except Exception: # noqa: BLE001 — cierre best-effort
pass
return {"ok": True, "error": "", "node_id": node_id,
"selector": selector, "files": abs_paths}
if __name__ == "__main__":
import sys
sel = sys.argv[1] if len(sys.argv) > 1 else 'input[type="file"]'
path = sys.argv[2] if len(sys.argv) > 2 else ""
substr = sys.argv[3] if len(sys.argv) > 3 else ""
out = cdp_set_file_input(sel, path, port=9222, target_url_substr=substr)
print(json.dumps(out, ensure_ascii=False, indent=2))
@@ -0,0 +1,144 @@
"""Tests para cdp_set_file_input — mockean urlopen + create_connection.
Mockean la capa de red de CDP: urllib.request.urlopen (lista de targets) y
websocket.create_connection (un fake que responde a cada comando DOM por su id, con un
nodeId de raiz, un nodeId de input y una respuesta vacia para setFileInputFiles). Asi NO
hace falta Chrome. Para la validacion de existencia de archivo se usan rutas reales
(__file__) y rutas inexistentes.
"""
import json
import os
import sys
from contextlib import contextmanager
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from browser import cdp_set_file_input as mod # noqa: E402
from browser.cdp_set_file_input import cdp_set_file_input # noqa: E402
_THIS = os.path.abspath(__file__)
class _FakeResp:
def __init__(self, payload):
self._payload = payload
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def read(self):
return json.dumps(self._payload).encode()
class _FakeWS:
"""WebSocket falso que responde a cada comando DOM por method."""
def __init__(self, *, query_node_id=4242, set_error=None):
self.sent = []
self._inbox = []
self.closed = False
self.query_node_id = query_node_id
self.set_error = set_error
def send(self, raw):
msg = json.loads(raw)
self.sent.append(msg)
mid = msg["id"]
method = msg["method"]
if method == "DOM.getDocument":
resp = {"id": mid, "result": {"root": {"nodeId": 1}}}
elif method == "DOM.querySelector":
resp = {"id": mid, "result": {"nodeId": self.query_node_id}}
elif method == "DOM.setFileInputFiles":
if self.set_error is not None:
resp = {"id": mid, "error": self.set_error}
else:
resp = {"id": mid, "result": {}}
else: # DOM.enable y demas
resp = {"id": mid, "result": {}}
self._inbox.append(json.dumps(resp))
def recv(self):
if self._inbox:
return self._inbox.pop(0)
return ""
def close(self):
self.closed = True
@contextmanager
def _patch(targets, ws_obj=None, create_conn_exc=None):
orig_urlopen = mod.urllib.request.urlopen
orig_create = mod.websocket.create_connection
def fake_urlopen(url, timeout=5):
return _FakeResp(targets)
def fake_create(ws_url, timeout=10):
if create_conn_exc is not None:
raise create_conn_exc
return ws_obj
mod.urllib.request.urlopen = fake_urlopen
mod.websocket.create_connection = fake_create
try:
yield
finally:
mod.urllib.request.urlopen = orig_urlopen
mod.websocket.create_connection = orig_create
_TARGETS = [
{"type": "page", "url": "https://web.whatsapp.com/", "webSocketDebuggerUrl": "ws://x/1"},
]
def test_golden_asigna_archivos_al_input():
"""setFileInputFiles recibe la ruta ABSOLUTA y el nodeId del input localizado."""
ws = _FakeWS(query_node_id=777)
with _patch(_TARGETS, ws_obj=ws):
res = cdp_set_file_input('input[type="file"]', _THIS, target_url_substr="whatsapp")
assert res["ok"] is True
assert res["error"] == ""
assert res["node_id"] == 777
assert res["files"] == [_THIS]
# El ultimo comando es setFileInputFiles con la ruta absoluta y el nodeId del input.
setcmd = [m for m in ws.sent if m["method"] == "DOM.setFileInputFiles"][0]
assert setcmd["params"]["files"] == [_THIS]
assert setcmd["params"]["nodeId"] == 777
assert ws.closed is True
def test_edge_archivo_inexistente_ok_false_sin_red():
"""Una ruta inexistente devuelve ok=False antes de abrir cualquier conexion."""
res = cdp_set_file_input('input[type="file"]', "/no/existe/imagen.png",
target_url_substr="whatsapp")
assert res["ok"] is False
assert "not found" in res["error"]
assert res["node_id"] == 0
def test_edge_selector_no_encontrado_ok_false():
"""Si querySelector devuelve nodeId 0, no se asignan archivos y ok=False."""
ws = _FakeWS(query_node_id=0)
with _patch(_TARGETS, ws_obj=ws):
res = cdp_set_file_input('input[type="file"]', _THIS, target_url_substr="whatsapp")
assert res["ok"] is False
assert "no element matches selector" in res["error"]
# NO se llamo a setFileInputFiles.
assert all(m["method"] != "DOM.setFileInputFiles" for m in ws.sent)
def test_error_create_connection_lanza_ok_false():
"""Si create_connection lanza, se captura y devuelve ok=False sin relanzar."""
with _patch(_TARGETS, create_conn_exc=ConnectionRefusedError("ws down")):
res = cdp_set_file_input('input[type="file"]', _THIS, target_url_substr="whatsapp")
assert res["ok"] is False
assert "ws down" in res["error"]
assert res["files"] == [_THIS]
@@ -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="IMG_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="IMG_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()
@@ -0,0 +1,105 @@
---
name: whatsapp_send_image
kind: function
lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def whatsapp_send_image(name: str, image_path: str, *, caption: str = '', port: int = 9222, target_url_substr: str = 'whatsapp', open_first: bool = True) -> dict"
description: "Envia una imagen (con caption opcional) a un chat de WhatsApp Web en una pestana ya logueada del navegador diario via CDP, sin abrir ventana nueva ni darle foco. Abre el chat por nombre exacto (whatsapp_open_chat) y verifica el destinatario (salvaguarda anti-envio-equivocado), hace click real en 'Adjuntar' para exponer el <input type=file> vivo, asigna la imagen con cdp_set_file_input (DOM.setFileInputFiles), espera la bandeja inline y hace click en el boton enviar (icono wds-ic-send-filled) verificando que la bandeja se cerro. Si hay caption, lo envia como mensaje de texto de seguimiento via whatsapp_send_message (en la WhatsApp Web compacta actual el caption embebido en la imagen no es automatizable de forma fiable, asi que viaja como segunda burbuja [imagen][caption]). Accion con efecto: envia la imagen DE VERDAD, no reversible."
tags: [whatsapp, cdp, browser, automation, image, upload, python, navegator]
uses_functions: [whatsapp_open_chat_py_browser, cdp_eval_py_browser, cdp_click_xy_py_browser, cdp_set_file_input_py_browser, whatsapp_send_message_py_browser]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["os", "sys", "time", "json"]
params_schema:
params:
- name: name
desc: "Nombre EXACTO del chat o grupo destinatario tal y como aparece en la lista lateral. Se usa para abrir el chat y como salvaguarda de que el composer apunta al destinatario correcto antes de adjuntar."
- name: image_path
desc: "Ruta de la imagen a enviar. Se expande (~) y se convierte a ruta ABSOLUTA; debe existir en disco o aborta con error sin abrir el chat."
- name: caption
desc: "Texto opcional descriptivo. Se envia como un MENSAJE DE TEXTO de seguimiento (segunda burbuja [imagen][caption]) via whatsapp_send_message; '' (default) envia solo la imagen. La WhatsApp Web compacta actual no permite automatizar el caption embebido en la imagen de forma fiable."
- name: port
desc: "Puerto de remote debugging de Chrome. Default 9222."
- name: target_url_substr
desc: "Substring que debe contener la URL del target (pestana). Default 'whatsapp'."
- name: open_first
desc: "Si True (default), abre el chat por su nombre antes de adjuntar. Si False, asume el chat ya abierto pero verifica el aria-label del composer contra name (aborta si no coincide)."
output: "dict {ok: bool (imagen + caption enviados), sent: bool (imagen enviada), caption_sent: bool (caption de seguimiento enviado, False si no habia o fallo), recipient: str, image: str (ruta absoluta), caption: str, error: str (motivo del fallo, vacio si todo ok)}. sent=True solo si la imagen se adjunto y se envio dejando la bandeja vacia. Nunca lanza: los fallos se reportan en 'sent'/'ok' + 'error'."
tested: true
tests: ["test_golden_envia_imagen_y_caption_de_seguimiento", "test_envia_sin_caption_no_manda_texto", "test_edge_imagen_no_existe_error_sin_abrir", "test_edge_open_fallido_error_sin_adjuntar", "test_seguridad_open_first_false_label_no_coincide_aborta", "test_error_set_file_input_falla_no_envia"]
test_file_path: "python/functions/browser/whatsapp_send_image_test.py"
file_path: "python/functions/browser/whatsapp_send_image.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from browser.whatsapp_send_image import whatsapp_send_image
# Requiere WhatsApp Web abierto y logueado (y DESBLOQUEADO si tiene app-lock) en un
# Chrome lanzado con --remote-debugging-port=9222.
res = whatsapp_send_image(
"NOTAS WASAP",
"/home/enmanuel/ComfyUI/output/item_icon_potion_00001_.png",
caption="item icon: potion",
)
print(res)
# -> {"ok": True, "sent": True, "recipient": "NOTAS WASAP",
# "image": ".../item_icon_potion_00001_.png", "caption": "item icon: potion", "error": ""}
```
O directo por CLI: `python3 python/functions/browser/whatsapp_send_image.py "NOTAS WASAP" /ruta/abs.png "mi caption"`.
## Cuando usarla
Cuando necesites **enviar una imagen (foto, captura, asset generado) a un contacto o grupo
por su nombre exacto** en WhatsApp Web, sin abrir ventana nueva ni robar el foco al usuario.
Es la version "imagen" de `whatsapp_send_message_py_browser`: usala cuando ya tienes el
nombre exacto del destinatario y la ruta de un archivo de imagen en disco. Para texto plano,
usa `whatsapp_send_message`; para leer/confirmar lo enviado, `whatsapp_read_chat`.
## Gotchas
- **Accion con efecto: envia la imagen DE VERDAD.** No es reversible. Verifica que `name` es
EXACTO antes de llamar (la salvaguarda abre el chat y comprueba el composer, pero el nombre
debe coincidir con el `title` de la lista lateral).
- **App-lock de WhatsApp Web.** Si la cuenta tiene el "bloqueo de la aplicacion" activo, el DOM
de chats no se renderiza (solo la pantalla de password) y la funcion fallara al abrir el chat.
Hay que desbloquearlo primero (teclear el password en `input[type=password]` + boton
"Desbloquear"). Sintoma: `whatsapp_open_chat` devuelve `opened: False` y la lista lateral sale
vacia aunque la sesion siga logueada.
- **El menu "Adjuntar" es un TOGGLE (`aria-expanded`).** El `<input type=file>` solo queda "vivo"
mientras el menu esta ABIERTO; asignar al input con el menu cerrado es un decoy (no abre preview).
Clickar "Adjuntar" cuando YA esta abierto lo CIERRA. Por eso la funcion clicka solo si
`aria-expanded != "true"` y reintenta hasta verlo abierto (no un click ciego). La WhatsApp Web
actual usa una **bandeja de medios INLINE compacta** sobre el composer (no un drawer a pantalla
completa).
- **El envio se verifica por la ultima fila de `#main`, NO por contar filas.** Las filas de `#main`
se VIRTUALIZAN (las antiguas se desmontan al llegar nuevas), asi que el total se mantiene casi
constante. La funcion confirma el envio comprobando que la bandeja se vacio (adjuntos=0) Y que la
ultima fila renderizada es ya una imagen (`img[src^="blob:"]`).
- **El caption NO se embebe en la imagen: viaja como mensaje de texto de seguimiento.** En esta
WhatsApp Web compacta hay dos botones de envio cuando hay media: "Enviar N seleccionados" (envia
la bandeja, IGNORA el texto del composer) y "Enviar"/Enter (envia el texto como burbuja aparte,
descartando la media en cola). No hay un campo de caption por-imagen automatizable de forma
fiable. Por eso la funcion envia primero la imagen (boton de la bandeja) y, si hay `caption`, lo
manda despues como mensaje de texto via `whatsapp_send_message` (`open_first=False`): el resultado
es [imagen][caption] como dos burbujas. `caption_sent` indica si esa segunda burbuja salio.
- **Selector de aria-label en espanol.** El preview se detecta por `[aria-label="Quitar archivo
adjunto"]` y el boton de adjuntar por `[aria-label="Adjuntar"]`: dependen del idioma de la UI
(espanol). En otro locale habria que ajustar los aria-labels.
- **Las imagenes se ACUMULAN en la bandeja.** Cada `setFileInputFiles` anade una miniatura; si un
envio queda a medias, la siguiente llamada podria sumar a las pendientes. La funcion verifica que
la bandeja queda vacia tras enviar (adjuntos=0) para confirmar; si no se cierra, devuelve
`sent=False` con "envio incierto".
- **Salvaguarda anti-destinatario-equivocado**: con `open_first=True` abre y verifica el chat; con
`open_first=False` lee el aria-label del composer y aborta si no contiene `name`.
- **Funciona con la ventana minimizada o sin foco**: CDP opera la pestana sin traerla a primer plano.
- **Viola los ToS de WhatsApp**: automatizar la web tiene riesgo de ban del numero personal. Usar
con cautela y bajo tu responsabilidad.
@@ -0,0 +1,236 @@
"""Envia una imagen (con caption opcional) a un chat de WhatsApp Web via Chrome DevTools Protocol.
Compone `whatsapp_open_chat` (abrir + verificar destinatario) con primitivas CDP del
registry (`cdp_eval`, `cdp_click_xy`, `cdp_set_file_input`) y `whatsapp_send_message` para
adjuntar y enviar una imagen a un contacto/grupo SIN abrir ventana nueva ni darle foco al
sistema.
Flujo (modelo de bandeja de medios INLINE de la WhatsApp Web actual), con salvaguarda
anti-envio-al-contacto-equivocado:
1. Abre el chat por su nombre exacto (`open_first=True`). Si no abre, aborta. Con
`open_first=False`, asume el chat abierto pero VERIFICA que el aria-label del composer
contiene el nombre; si no, aborta por seguridad.
2. ASEGURA que el menu "Adjuntar" esta ABIERTO (es un toggle `aria-expanded`: clickar
cuando ya esta abierto lo cierra). Solo entonces el `<input type=file>` queda "vivo".
3. Asigna la imagen al input via `cdp_set_file_input` (`DOM.setFileInputFiles`): la
imagen aparece como miniatura en la bandeja inline.
4. Espera a que la bandeja aparezca (boton "Quitar archivo adjunto" presente) y hace click
real en el boton de enviar la bandeja (icono `wds-ic-send-filled`).
5. Verifica el envio comprobando que la bandeja se cerro Y que la ultima fila de `#main`
es ahora una imagen (las filas se virtualizan, asi que NO sirve contar filas).
6. Si `caption` no esta vacio, lo envia como un MENSAJE DE TEXTO de seguimiento via
`whatsapp_send_message` (con `open_first=False`, el chat ya esta abierto). En la
WhatsApp Web compacta actual el caption embebido en la imagen no es automatizable de
forma fiable, asi que la descripcion viaja como una segunda burbuja: [imagen][caption].
Validado contra WhatsApp Web real. Accion CON EFECTO REAL E IRREVERSIBLE: envia la imagen
(y el caption) de verdad.
"""
import json
import os
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from browser.cdp_eval import cdp_eval
from browser.cdp_click_xy import cdp_click_xy
from browser.cdp_set_file_input import cdp_set_file_input
from browser.whatsapp_open_chat import whatsapp_open_chat
from browser.whatsapp_send_message import whatsapp_send_message
def _center(expr: str, port: int, substr: str):
"""Evalua una expresion que devuelve JSON {x,y} (o null) y la parsea a dict/None."""
r = cdp_eval(expr, port=port, target_url_substr=substr)
val = r.get("value")
if not val:
return None
try:
return json.loads(val)
except Exception: # noqa: BLE001 — value no-JSON
return None
def _adj_expanded(port: int, substr: str) -> str:
"""Estado aria-expanded del boton 'Adjuntar' ('true'/'false'/'no-btn')."""
r = cdp_eval(
'/*ADJEXP*/var e=document.querySelector(\'button[aria-label="Adjuntar"]\'); '
"e?e.getAttribute('aria-expanded'):'no-btn'",
port=port, target_url_substr=substr,
)
return r.get("value") or "no-btn"
def _attachment_count(port: int, substr: str) -> int:
"""Numero de adjuntos en la bandeja inline (botones 'Quitar archivo adjunto')."""
r = cdp_eval(
'/*PREVIEW*/document.querySelectorAll(\'[aria-label="Quitar archivo adjunto"]\').length',
port=port, target_url_substr=substr,
)
v = r.get("value")
return v if isinstance(v, int) else 0
def _last_row_is_image(port: int, substr: str) -> bool:
"""True si la ultima fila renderizada de #main contiene una imagen (blob)."""
r = cdp_eval(
'/*LASTIMG*/(() => {const r=[...document.querySelectorAll(\'#main [role="row"]\')]'
".slice(-1)[0]; return r?!!r.querySelector('img[src^=\"blob:\"]'):false;})()",
port=port, target_url_substr=substr,
)
return bool(r.get("value"))
def whatsapp_send_image(
name: str,
image_path: str,
*,
caption: str = "",
port: int = 9222,
target_url_substr: str = "whatsapp",
open_first: bool = True,
) -> dict:
"""Envia una imagen (con caption opcional de seguimiento) a un chat de WhatsApp Web.
Accion CON EFECTO: envia la imagen DE VERDAD (no reversible). Verifica `name`.
Args:
name: Nombre EXACTO del chat/grupo destinatario, tal y como aparece en la lista
lateral. Se usa para abrir el chat y como salvaguarda de que el composer apunta
al destinatario correcto antes de adjuntar.
image_path: Ruta de la imagen a enviar. Se expande (`~`) y se convierte a ruta
ABSOLUTA; debe existir en disco.
caption: Texto opcional descriptivo. Se envia como un MENSAJE DE TEXTO de seguimiento
(segunda burbuja [imagen][caption]) via `whatsapp_send_message`; "" (default)
envia solo la imagen.
port: Puerto de remote debugging de Chrome. Default 9222.
target_url_substr: Substring que debe contener la URL del target (pestana). Default
"whatsapp".
open_first: Si True (default), abre el chat por su nombre antes de adjuntar. Si
False, asume el chat ya abierto pero verifica el aria-label del composer contra
`name` (aborta si no coincide).
Returns:
dict con claves:
ok: bool True si la imagen se envio y (si habia caption) el caption tambien.
sent: bool True si la IMAGEN se envio.
caption_sent: bool True si el caption de seguimiento se envio (False si no
habia caption o si fallo).
recipient: str el nombre solicitado.
image: str ruta absoluta de la imagen.
caption: str caption solicitado.
error: str motivo del fallo (vacio si todo ok).
Nunca lanza: los fallos se reportan en "sent"/"ok" + "error".
"""
S = target_url_substr
abs_img = os.path.abspath(os.path.expanduser(image_path))
def fail(error: str) -> dict:
return {"ok": False, "sent": False, "caption_sent": False, "recipient": name,
"image": abs_img, "caption": caption, "error": error}
# 0. La imagen debe existir.
if not os.path.isfile(abs_img):
return fail(f"imagen no encontrada: {abs_img}")
# 1. Abrir + verificar destinatario correcto (salvaguarda anti-equivocacion).
if open_first:
o = whatsapp_open_chat(name, port=port, target_url_substr=S)
if not o.get("opened"):
return fail(o.get("reason", "no se pudo abrir el chat"))
else:
chk = cdp_eval(
'/*LABEL*/var b=document.querySelector(\'footer div[contenteditable="true"]\'); '
"b?b.getAttribute('aria-label'):null",
port=port, target_url_substr=S,
)
if name not in (chk.get("value") or ""):
return fail("el chat abierto no coincide con el destinatario; abortado por seguridad")
# 2. Asegurar el menu "Adjuntar" ABIERTO. Es un TOGGLE (aria-expanded): clickar cuando
# ya esta abierto lo cierra y el input vivo desaparece. Por eso clickamos SOLO si no
# esta expandido y reintentamos hasta verlo abierto.
adj = _center(
'/*ADJUNTAR*/(() => {const e=document.querySelector(\'button[aria-label="Adjuntar"]\');'
"if(!e)return null;const b=e.getBoundingClientRect();"
"return JSON.stringify({x:Math.round(b.x+b.width/2),y:Math.round(b.y+b.height/2)});})()",
port, S,
)
if not adj:
return fail("boton 'Adjuntar' no encontrado en el footer")
for _ in range(3):
if _adj_expanded(port, S) == "true":
break
cdp_click_xy(adj["x"], adj["y"], port=port, target_url_substr=S)
time.sleep(0.6)
if _adj_expanded(port, S) != "true":
return fail("no se pudo abrir el menu 'Adjuntar' (aria-expanded sigue en false)")
# 3. Asignar la imagen al primer <input type=file> (el "vivo" mientras el menu esta
# abierto). El composer queda VACIO, asi que luego el unico wds-ic-send-filled es el
# de enviar la bandeja.
r = cdp_set_file_input('input[type="file"]', abs_img,
port=port, target_url_substr=S)
if not r.get("ok"):
return fail("no se pudo adjuntar la imagen: " + r.get("error", ""))
# 4. Esperar a que la bandeja aparezca (adjunto presente).
attached = False
for _ in range(15):
time.sleep(0.2)
if _attachment_count(port, S) > 0:
attached = True
break
if not attached:
return fail("el preview no aparecio tras adjuntar la imagen")
time.sleep(0.3)
# 5. Click real en el boton de enviar la bandeja (icono wds-ic-send-filled).
snd = _center(
'/*SEND*/(() => {const e=document.querySelector(\'span[data-icon="wds-ic-send-filled"]\');'
"if(!e)return null;const b=e.getBoundingClientRect();"
"if(b.width===0)return null;"
"return JSON.stringify({x:Math.round(b.x+b.width/2),y:Math.round(b.y+b.height/2)});})()",
port, S,
)
if not snd:
return fail("boton de enviar (wds-ic-send-filled) no encontrado")
cdp_click_xy(snd["x"], snd["y"], port=port, target_url_substr=S)
# 6. Confirmar envio: la bandeja se cierra (adjuntos=0) Y la ultima fila de #main es ya
# una imagen. Las filas de #main se VIRTUALIZAN, asi que contar filas no sirve.
image_sent = False
for _ in range(20):
time.sleep(0.2)
if _attachment_count(port, S) == 0 and _last_row_is_image(port, S):
image_sent = True
break
if not image_sent:
return fail("no se confirmo la imagen en el chat tras pulsar enviar; envio incierto")
# 7. Caption opcional como mensaje de texto de seguimiento (segunda burbuja).
caption_sent = False
if caption:
m = whatsapp_send_message(name, caption, port=port, target_url_substr=S,
open_first=False)
caption_sent = bool(m.get("sent"))
if not caption_sent:
return {"ok": False, "sent": True, "caption_sent": False, "recipient": name,
"image": abs_img, "caption": caption,
"error": "imagen enviada pero el caption fallo: " + m.get("reason", "")}
return {"ok": True, "sent": True, "caption_sent": caption_sent, "recipient": name,
"image": abs_img, "caption": caption, "error": ""}
if __name__ == "__main__":
chat = sys.argv[1] if len(sys.argv) > 1 else "NOTAS WASAP"
img = sys.argv[2] if len(sys.argv) > 2 else ""
cap = sys.argv[3] if len(sys.argv) > 3 else ""
out = whatsapp_send_image(chat, img, caption=cap,
port=9222, target_url_substr="whatsapp")
print(json.dumps(out, ensure_ascii=False, indent=2))
@@ -0,0 +1,181 @@
"""Tests para whatsapp_send_image.
whatsapp_send_image compone whatsapp_open_chat con tres primitivas CDP (cdp_eval,
cdp_click_xy, cdp_set_file_input) y `whatsapp_send_message` (para el caption de
seguimiento), y requiere un Chrome vivo. Aqui se mockean todas con monkeypatch sobre el
modulo `browser.whatsapp_send_image` (donde quedan ligados los nombres por el
`from browser.X import Y`), de modo que NO hace falta Chrome.
El fake de cdp_eval distingue cada expresion por un marcador de comentario JS embebido en
ella (`/*ADJUNTAR*/`, `/*ADJEXP*/`, `/*PREVIEW*/`, `/*SEND*/`, `/*LASTIMG*/`, `/*LABEL*/`).
El estado simula el ciclo real: el menu Adjuntar arranca cerrado y se abre tras un click;
tras pulsar enviar (/*SEND*/) la bandeja se vacia (/*PREVIEW*/ -> 0) y la ultima fila pasa a
ser una imagen (/*LASTIMG*/ -> True).
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import browser.whatsapp_send_image as wsi # noqa: E402
from browser.whatsapp_send_image import whatsapp_send_image # noqa: E402
# Imagen real para pasar la validacion de existencia (este propio archivo de test).
_IMG = os.path.abspath(__file__)
class _Spy:
def __init__(self, ret=None):
self.calls = []
self.ret = ret if ret is not None else {"ok": True}
def __call__(self, *args, **kwargs):
self.calls.append((args, kwargs))
return self.ret
def _make_eval(label="Escribir un mensaje para el grupo NOTAS WASAP", state=None):
state = state if state is not None else {}
def fake(expr, *, port=9222, target_url_substr=""):
if "/*ADJUNTAR*/" in expr:
return {"ok": True, "value": json.dumps({"x": 575, "y": 589}), "error": ""}
if "/*ADJEXP*/" in expr:
n = state.get("adjexp_n", 0)
state["adjexp_n"] = n + 1
return {"ok": True, "value": ("false" if n == 0 else "true"), "error": ""}
if "/*SEND*/" in expr:
state["after_send"] = True
return {"ok": True, "value": json.dumps({"x": 1136, "y": 578}), "error": ""}
if "/*PREVIEW*/" in expr:
return {"ok": True, "value": 0 if state.get("after_send") else 1, "error": ""}
if "/*LASTIMG*/" in expr:
return {"ok": True, "value": bool(state.get("after_send")), "error": ""}
if "/*LABEL*/" in expr:
return {"ok": True, "value": label, "error": ""}
return {"ok": True, "value": None, "error": ""}
return fake
def _patch_common(monkeypatch, *, eval_fn, set_ret={"ok": True}, open_ret=None,
send_msg_ret={"sent": True}):
open_spy = _Spy(ret=open_ret if open_ret is not None else {"opened": True, "name": "x"})
click_spy = _Spy(ret={"ok": True})
set_spy = _Spy(ret=set_ret)
sendmsg_spy = _Spy(ret=send_msg_ret)
monkeypatch.setattr(wsi, "whatsapp_open_chat", open_spy)
monkeypatch.setattr(wsi, "cdp_eval", eval_fn)
monkeypatch.setattr(wsi, "cdp_click_xy", click_spy)
monkeypatch.setattr(wsi, "cdp_set_file_input", set_spy)
monkeypatch.setattr(wsi, "whatsapp_send_message", sendmsg_spy)
monkeypatch.setattr(wsi.time, "sleep", lambda *a, **k: None)
return open_spy, click_spy, set_spy, sendmsg_spy
def test_golden_envia_imagen_y_caption_de_seguimiento(monkeypatch):
cap = "item icon: potion"
state = {}
open_spy, click_spy, set_spy, sendmsg_spy = _patch_common(
monkeypatch, eval_fn=_make_eval(state=state))
res = whatsapp_send_image("NOTAS WASAP", _IMG, caption=cap,
port=9222, target_url_substr="whatsapp")
assert res["sent"] is True and res["ok"] is True
assert res["caption_sent"] is True
assert res["recipient"] == "NOTAS WASAP"
assert res["image"] == _IMG
assert res["caption"] == cap
assert res["error"] == ""
# Se adjunto la imagen (ruta absoluta) por setFileInputFiles, una sola vez.
assert len(set_spy.calls) == 1
assert set_spy.calls[0][0][0] == 'input[type="file"]'
assert set_spy.calls[0][0][1] == _IMG
# Dos clicks reales: abrir Adjuntar (menu cerrado al inicio) y Enviar.
assert len(click_spy.calls) == 2
# El caption viaja como mensaje de texto de seguimiento, open_first=False.
assert len(sendmsg_spy.calls) == 1
assert sendmsg_spy.calls[0][0][0] == "NOTAS WASAP"
assert sendmsg_spy.calls[0][0][1] == cap
assert sendmsg_spy.calls[0][1].get("open_first") is False
def test_envia_sin_caption_no_manda_texto(monkeypatch):
state = {}
_, click_spy, set_spy, sendmsg_spy = _patch_common(
monkeypatch, eval_fn=_make_eval(state=state))
res = whatsapp_send_image("NOTAS WASAP", _IMG, caption="",
port=9222, target_url_substr="whatsapp")
assert res["sent"] is True
assert res["caption_sent"] is False
# Sin caption: NO se manda mensaje de texto de seguimiento.
assert len(sendmsg_spy.calls) == 0
assert len(set_spy.calls) == 1
def test_edge_imagen_no_existe_error_sin_abrir(monkeypatch):
open_spy, click_spy, set_spy, sendmsg_spy = _patch_common(
monkeypatch, eval_fn=_make_eval())
res = whatsapp_send_image("NOTAS WASAP", "/no/existe/foo.png",
port=9222, target_url_substr="whatsapp")
assert res["sent"] is False and res["ok"] is False
assert "no encontrada" in res["error"]
# Ni siquiera se intento abrir el chat ni adjuntar.
assert len(open_spy.calls) == 0
assert len(set_spy.calls) == 0
def test_edge_open_fallido_error_sin_adjuntar(monkeypatch):
open_spy, click_spy, set_spy, sendmsg_spy = _patch_common(
monkeypatch, eval_fn=_make_eval(),
open_ret={"opened": False, "name": "x",
"reason": "chat no encontrado en la lista (no cargado o nombre inexacto)"})
res = whatsapp_send_image("Inexistente", _IMG,
port=9222, target_url_substr="whatsapp")
assert res["sent"] is False
assert "no encontrado" in res["error"]
# No se adjunto ni se hizo click cuando el chat no abrio.
assert len(set_spy.calls) == 0
assert len(click_spy.calls) == 0
def test_seguridad_open_first_false_label_no_coincide_aborta(monkeypatch):
open_spy, click_spy, set_spy, sendmsg_spy = _patch_common(
monkeypatch,
eval_fn=_make_eval(label="Escribir un mensaje para el grupo OTRO CHAT"))
res = whatsapp_send_image("NOTAS WASAP", _IMG,
port=9222, target_url_substr="whatsapp",
open_first=False)
assert res["sent"] is False
assert "abortado por seguridad" in res["error"]
# SEGURIDAD: no se adjunto ni se clicko nada.
assert len(set_spy.calls) == 0
assert len(click_spy.calls) == 0
def test_error_set_file_input_falla_no_envia(monkeypatch):
state = {}
open_spy, click_spy, set_spy, sendmsg_spy = _patch_common(
monkeypatch, eval_fn=_make_eval(state=state),
set_ret={"ok": False, "error": "no element matches selector"})
res = whatsapp_send_image("NOTAS WASAP", _IMG,
port=9222, target_url_substr="whatsapp")
assert res["sent"] is False
assert "no se pudo adjuntar" in res["error"]
# Se abrio Adjuntar (1 click) e intento adjuntar (1 set), pero NO se envio nada.
assert len(set_spy.calls) == 1
assert len(click_spy.calls) == 1
assert len(sendmsg_spy.calls) == 0
+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:]))
@@ -0,0 +1,54 @@
---
name: godot_clean_asset_name
kind: function
lang: py
domain: core
version: "1.0.0"
purity: pure
signature: "def godot_clean_asset_name(filename: str, *, override: str | None = None) -> str"
description: "Normaliza el nombre de archivo de un asset salido de ComfyUI (patron <prefijo>_NNNNN_.<ext>) a un nombre limpio y seguro para res://: snake_case, minusculas, sin el sufijo numerico _NNNNN_, sin espacios ni caracteres raros, conservando la extension. Pura: solo manipula el string, no toca disco. Pensada para el puente ComfyUI -> Godot."
tags: [godot, gamedev-2d, core, assets, naming]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: filename
desc: "nombre o ruta del asset de origen (se toma solo el basename)."
- name: override
desc: "nombre base deseado sin extension; si se pasa se usa en lugar del nombre de origen (igual se normaliza a snake_case y se le anade la extension del origen). keyword-only."
output: "nombre de archivo limpio 'snake_case.ext' (ext en minuscula; sin punto si el origen no tenia extension)."
tested: true
tests: [test_strips_comfyui_suffix, test_normalizes_spaces_and_case, test_takes_basename_from_path, test_override_name, test_empty_fallback]
test_file_path: "python/functions/core/godot_clean_asset_name_test.py"
file_path: "python/functions/core/godot_clean_asset_name.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from core.godot_clean_asset_name import godot_clean_asset_name
godot_clean_asset_name("svd_motion_hi_00001_.webp") # 'svd_motion_hi.webp'
godot_clean_asset_name("/x/ComfyUI/output/bench_00042_.png") # 'bench.png'
godot_clean_asset_name("x.png", override="explosion loop") # 'explosion_loop.png'
```
## Cuando usarla
Al exportar un asset de ComfyUI a Godot, para renombrar el `<prefijo>_NNNNN_.<ext>`
a un nombre semántico y seguro (lo usa `comfyui_export_asset_to_godot`). También
para limpiar cualquier nombre de archivo antes de meterlo en `res://`.
## Gotchas
- Solo quita el sufijo numérico `_NNNNN_` del **nombre de origen**; con `override`
se respetan los dígitos finales (p.ej. `hero_v2` no pierde el `2`).
- Reduce cualquier carácter no `[a-z0-9_]` a `_` y colapsa repetidos; un nombre que
quede vacío cae a `"asset"`.
- Conserva la extensión en minúscula; si el origen no tiene extensión, devuelve solo
el nombre sin punto.
@@ -0,0 +1,49 @@
"""godot_clean_asset_name — normaliza el nombre de archivo de un asset para Godot.
Funcion pura: toma el nombre de un asset salido de ComfyUI (patron
`<prefijo>_NNNNN_.<ext>`, p.ej. `svd_motion_hi_00001_.webp`) y devuelve un nombre
limpio, semantico y seguro para `res://`: snake_case, minusculas, sin el sufijo
numerico `_NNNNN_`, sin espacios ni caracteres raros, conservando la extension.
No toca disco. Pensada para el puente ComfyUI -> Godot (renombrar al exportar).
"""
import os
import re
_NNNNN_SUFFIX = re.compile(r"_\d{3,}_?$") # sufijo numerico de ComfyUI: _00001_ / _00001
_NON_SAFE = re.compile(r"[^a-z0-9_]+") # cualquier cosa que no sea snake_case
_MULTI_US = re.compile(r"_{2,}")
def godot_clean_asset_name(filename: str, *, override: str | None = None) -> str:
"""Limpia el nombre de un asset a snake_case seguro conservando la extension.
Args:
filename: nombre o ruta del asset de origen (se toma solo el basename).
override: nombre base deseado (sin extension); si se pasa, se usa en lugar
del nombre de origen (igual se normaliza a snake_case y se le anade la
extension del origen). keyword-only.
Returns:
Nombre de archivo limpio "snake_case.ext" (ext en minuscula, sin punto si
el origen no tenia extension).
"""
base = os.path.basename(filename or "")
stem, ext = os.path.splitext(base)
ext = ext.lower()
raw = override if override is not None else stem
name = raw.strip().lower().replace(" ", "_").replace("-", "_")
if override is None:
name = _NNNNN_SUFFIX.sub("", name) # quita _00001_ solo del nombre de origen
name = _NON_SAFE.sub("_", name)
name = _MULTI_US.sub("_", name).strip("_")
if not name:
name = "asset"
return f"{name}{ext}" if ext else name
if __name__ == "__main__":
import sys
print(godot_clean_asset_name(sys.argv[1] if len(sys.argv) > 1 else "svd_motion_hi_00001_.webp"))
@@ -0,0 +1,31 @@
"""Tests de godot_clean_asset_name (pura, offline)."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.godot_clean_asset_name import godot_clean_asset_name # noqa: E402
def test_strips_comfyui_suffix():
assert godot_clean_asset_name("svd_motion_hi_00001_.webp") == "svd_motion_hi.webp"
assert godot_clean_asset_name("3d_robot_mesh_00001_.glb") == "3d_robot_mesh.glb"
def test_normalizes_spaces_and_case():
assert godot_clean_asset_name("My Hero Sprite.PNG") == "my_hero_sprite.png"
assert godot_clean_asset_name("fire-flare.png") == "fire_flare.png"
def test_takes_basename_from_path():
assert godot_clean_asset_name("/home/x/ComfyUI/output/bench_00042_.png") == "bench.png"
def test_override_name():
assert godot_clean_asset_name("svd_00001_.webp", override="explosion loop") == "explosion_loop.webp"
# override conserva digitos finales (no es sufijo ComfyUI a quitar)
assert godot_clean_asset_name("x.png", override="hero_v2") == "hero_v2.png"
def test_empty_fallback():
assert godot_clean_asset_name("_00001_.png") == "asset.png"
@@ -0,0 +1,51 @@
---
name: godot_map_asset_dir
kind: function
lang: py
domain: core
version: "1.0.0"
purity: pure
signature: "def godot_map_asset_dir(kind: str) -> str"
description: "Mapea el tipo de asset (sprite, pixelart, tileset, vfx, sfx, music, model) a su subcarpeta canonica bajo res://assets/ de un proyecto Godot 4, segun la convencion del puente ComfyUI -> Godot. Pura: solo resuelve una ruta relativa POSIX, no toca disco. kind desconocido -> ValueError."
tags: [godot, gamedev-2d, core, assets, mapping]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: kind
desc: "tipo de asset: 'sprite', 'pixelart', 'tileset', 'vfx', 'sfx', 'music' o 'model' (case-insensitive, se ignoran espacios)."
output: "subruta relativa POSIX bajo assets/ (p.ej. 'sprites', 'tilesets', 'audio/music', 'models')."
tested: true
tests: [test_all_kinds, test_case_insensitive, test_unknown_kind_raises]
test_file_path: "python/functions/core/godot_map_asset_dir_test.py"
file_path: "python/functions/core/godot_map_asset_dir.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from core.godot_map_asset_dir import godot_map_asset_dir
godot_map_asset_dir("pixelart") # 'sprites'
godot_map_asset_dir("music") # 'audio/music'
godot_map_asset_dir("model") # 'models'
```
## Cuando usarla
Al construir la ruta destino de un asset dentro de un proyecto Godot (lo usa el
pipeline `comfyui_export_asset_to_godot`). Centraliza la convención tipo -> carpeta
para no esparcir el mapeo por varias funciones.
## Gotchas
- `pixelart` cae en `sprites/` (el pixelart no es una carpeta, es un atributo: lo
que cambia es el filtro Nearest, no la ubicación).
- `kind` desconocido lanza `ValueError` (no devuelve un default silencioso).
- Devuelve siempre subrutas POSIX (`/`), no rutas absolutas: el llamador antepone
`<proyecto>/assets/`.
@@ -0,0 +1,46 @@
"""godot_map_asset_dir — mapea el tipo de asset a su subcarpeta res://assets/.
Funcion pura: dado el `kind` de un asset (sprite, pixelart, tileset, vfx, sfx,
music, model) devuelve el subdirectorio canonico bajo `res://assets/` donde debe
vivir en un proyecto Godot 4, segun la convencion del puente ComfyUI -> Godot
(docs/comfyui-godot-integration.md). No toca disco: solo resuelve una ruta
relativa. `kind` desconocido -> ValueError.
"""
# kind -> subruta relativa bajo assets/ (convencion del puente ComfyUI->Godot).
_KIND_TO_DIR = {
"sprite": "sprites",
"pixelart": "sprites",
"tileset": "tilesets",
"vfx": "vfx",
"sfx": "audio/sfx",
"music": "audio/music",
"model": "models",
}
def godot_map_asset_dir(kind: str) -> str:
"""Resuelve la subcarpeta de assets para un tipo de asset Godot.
Args:
kind: tipo de asset: "sprite", "pixelart", "tileset", "vfx", "sfx",
"music" o "model".
Returns:
Subruta relativa (POSIX) bajo `assets/`, p.ej. "sprites" o "audio/music".
Raises:
ValueError: si `kind` no es uno de los tipos soportados.
"""
key = (kind or "").strip().lower()
if key not in _KIND_TO_DIR:
raise ValueError(
f"kind desconocido: {kind!r}. Soportados: {sorted(_KIND_TO_DIR)}"
)
return _KIND_TO_DIR[key]
if __name__ == "__main__":
import sys
print(godot_map_asset_dir(sys.argv[1] if len(sys.argv) > 1 else "pixelart"))
@@ -0,0 +1,29 @@
"""Tests de godot_map_asset_dir (pura, offline)."""
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.godot_map_asset_dir import godot_map_asset_dir # noqa: E402
def test_all_kinds():
assert godot_map_asset_dir("sprite") == "sprites"
assert godot_map_asset_dir("pixelart") == "sprites"
assert godot_map_asset_dir("tileset") == "tilesets"
assert godot_map_asset_dir("vfx") == "vfx"
assert godot_map_asset_dir("sfx") == "audio/sfx"
assert godot_map_asset_dir("music") == "audio/music"
assert godot_map_asset_dir("model") == "models"
def test_case_insensitive():
assert godot_map_asset_dir("PixelArt") == "sprites"
assert godot_map_asset_dir(" MODEL ") == "models"
def test_unknown_kind_raises():
with pytest.raises(ValueError):
godot_map_asset_dir("hologram")
@@ -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,79 @@
---
name: comfyui_append_styles
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_append_styles(new_styles: dict, styles_path: str = DEFAULT_STYLES_PATH, overwrite: bool = False, backup: bool = True, dry_run: bool = False) -> dict"
description: "Fusiona (merge+dedup) un dict de estilos nuevos sobre el styles.json del selector WAS de ComfyUI (Prompt Styles Selector / Prompt Multiple Styles Selector) de forma SEGURA y NO destructiva. Preserva TODOS los estilos existentes (dedup por nombre; los existentes ganan salvo overwrite=True), hace backup con timestamp antes de escribir, valida cada entrada nueva (descarta las que no tengan prompt no vacio, rellena negative_prompt por defecto si falta) y escribe de forma atomica (.tmp + os.replace). Devuelve un resumen con conteos antes/despues, anadidos, duplicados saltados e invalidos para verificar el efecto sin releer el archivo. Impura: lee y escribe disco; no usa red, no borra el original."
tags: [comfyui, ml, comfyui-styles, styles, was, merge, dedup]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
params:
- name: new_styles
desc: "dict {nombre: {'prompt': str, 'negative_prompt': str}} de estilos a anadir. Entradas sin prompt no vacio se descartan; las que no traen negative_prompt reciben uno por defecto. Debe ser un dict (si no, ValueError)."
- name: styles_path
desc: "Ruta del styles.json. Default: ~/ComfyUI/custom_nodes/was-node-suite-comfyui/styles.json (la instalacion WAS del usuario). Debe existir (no se crea de cero: FileNotFoundError)."
- name: overwrite
desc: "Si False (default), un nombre que ya existe NO se pisa (se cuenta como skipped_existing). Si True, los nuevos pisan a los existentes (overwritten)."
- name: backup
desc: "Si True (default), copia el archivo a <path>.bak.<epoch> antes de escribir. Backup hecho ANTES de tocar el original."
- name: dry_run
desc: "Si True, calcula el merge y los conteos pero NO escribe nada (ni backup). Para previsualizar el efecto."
output: "dict resumen: {styles_path, backup_path, total_before, total_after, added:[nombres], overwritten:[nombres], skipped_existing:[nombres], invalid:[nombres], dry_run:bool}."
tested: true
tests: ["golden: merge preserva A y B existentes y anade C; total_before 2 -> total_after 3", "edge dedup: nombre existente no se pisa por defecto (skipped_existing), el original se conserva", "edge overwrite=True pisa el existente", "edge negative por defecto cuando la entrada nueva no lo trae", "edge entradas invalidas (no dict, prompt vacio, sin prompt) se descartan a invalid", "edge backup creado con el estado anterior", "error/edge dry_run no escribe el archivo (intacto) pero calcula conteos"]
test_file_path: "python/functions/ml/comfyui_append_styles_test.py"
file_path: "python/functions/ml/comfyui_append_styles.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_append_styles import comfyui_append_styles
from ml.comfyui_curated_styles_catalog import comfyui_curated_styles_catalog
# Fusionar el catalogo curado sobre el styles.json real, preservando los existentes.
nuevos = comfyui_curated_styles_catalog() # 190 estilos curados
res = comfyui_append_styles(nuevos) # backup + merge + dedup + escritura atomica
print(res["total_before"], "->", res["total_after"], "anadidos:", len(res["added"]))
# 269 -> 459 anadidos: 190 (los duplicados por nombre quedan en skipped_existing)
# Previsualizar sin escribir:
print(comfyui_append_styles(nuevos, dry_run=True)["total_after"])
```
O por CLI: `echo '{"x":{"prompt":"neon glow"}}' | python/.venv/bin/python3 python/functions/ml/comfyui_append_styles.py --dry-run`
## Cuando usarla
Cuando quieras AMPLIAR el repositorio de estilos del selector WAS de ComfyUI sin perder los que
ya hay. Es el paso de escritura del flujo "generar estilos -> fusionar": genera un dict de estilos
(con `comfyui_curated_styles_catalog` y/o `comfyui_generate_styles_llm`), pasalo aqui y el archivo
queda fusionado con backup. Usala SIEMPRE en vez de editar el JSON a mano (preserva los existentes,
valida formato, hace backup atomico). Tras escribir, reinicia `comfyui.service` para que el
selector recargue el catalogo.
## Gotchas
- **Reinicio necesario**: el nodo WAS lee styles.json al arrancar. Despues de fusionar hay que
`systemctl --user restart comfyui.service` (o reiniciar el server) para que el selector liste
los nuevos. Verifica con `GET /object_info` contando los enum del `Prompt Styles Selector`.
- **dedup por NOMBRE, no por contenido**: dos estilos con el mismo nombre se consideran el mismo;
por defecto gana el existente. Si quieres reemplazar deliberadamente, pasa `overwrite=True`.
- **El archivo debe existir**: no se crea de cero (FileNotFoundError) para no enmascarar una
instalacion WAS rota. Si lo necesitas vacio, crea `{}` a mano primero.
- **Backups se acumulan**: cada escritura deja un `styles.json.bak.<epoch>`. Limpialos a mano si
molestan; son la red de seguridad para restaurar.
- **No versionar**: el styles.json es de ComfyUI, no de fn_registry. No hacer `git add` de el.
## Capability growth log
(v1.0.0 — sin cambios todavia.)
@@ -0,0 +1,177 @@
"""comfyui_append_styles — merge+dedup de estilos nuevos sobre el styles.json de WAS.
El selector de estilos de ComfyUI (nodos WAS `Prompt Styles Selector` /
`Prompt Multiple Styles Selector`) lee de
`~/ComfyUI/custom_nodes/was-node-suite-comfyui/styles.json`, un dict cuyo formato exacto es:
{ "NombreEstilo": {"prompt": "modificadores de estilo", "negative_prompt": "..."}, ... }
El selector múltiple CONCATENA los `prompt` de los estilos elegidos, por lo que cada `prompt`
debe contener MODIFICADORES de estilo (no la descripción del sujeto) y NO el placeholder
`{prompt}`.
Esta función fusiona un dict de estilos nuevos sobre el archivo existente de forma SEGURA y NO
destructiva:
- Hace un backup con timestamp del styles.json antes de tocarlo (nunca sobrescribe sin copia).
- Preserva TODOS los estilos existentes (dedup por nombre: los existentes ganan salvo
`overwrite=True`).
- Valida cada entrada nueva: debe ser un dict con `prompt` no vacío. Si falta `negative_prompt`
se rellena con un negativo por defecto razonable; las entradas inválidas se descartan
(reportadas, no abortan el merge).
- Escribe el resultado de forma atómica (a un .tmp y `os.replace`).
Devuelve un resumen con conteos (antes/después, añadidos, duplicados saltados, inválidos) para
que el caller verifique el efecto sin volver a leer el archivo.
Impura: lee y escribe disco. No usa red. No mata procesos. No borra el original (sólo backup +
reemplazo atómico).
"""
from __future__ import annotations
import json
import os
import shutil
import time
# Negativo por defecto cuando un estilo nuevo no trae `negative_prompt`. Sobrio y SFW,
# alineado con el estilo de los negativos que ya viven en el styles.json de WAS.
DEFAULT_NEGATIVE = (
"ugly, deformed, noisy, blurry, low quality, distorted, disfigured, "
"bad anatomy, watermark, signature, text, NSFW"
)
DEFAULT_STYLES_PATH = os.path.join(
os.path.expanduser("~"),
"ComfyUI",
"custom_nodes",
"was-node-suite-comfyui",
"styles.json",
)
def _validate_entry(value: object) -> dict | None:
"""Normaliza una entrada de estilo. Devuelve el dict válido o None si es inválida.
Una entrada válida es un dict con `prompt` (str no vacío). `negative_prompt` se rellena
con `DEFAULT_NEGATIVE` si falta o está vacío. Campos extra se descartan (el formato WAS
sólo usa `prompt` y `negative_prompt`).
"""
if not isinstance(value, dict):
return None
prompt = value.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
return None
neg = value.get("negative_prompt")
if not isinstance(neg, str) or not neg.strip():
neg = DEFAULT_NEGATIVE
return {"prompt": prompt.strip(), "negative_prompt": neg.strip()}
def comfyui_append_styles(
new_styles: dict,
styles_path: str = DEFAULT_STYLES_PATH,
overwrite: bool = False,
backup: bool = True,
dry_run: bool = False,
) -> dict:
"""Fusiona `new_styles` sobre el styles.json de WAS preservando los existentes.
Args:
new_styles: dict {nombre: {"prompt": str, "negative_prompt": str}} de estilos a añadir.
Las entradas inválidas (sin `prompt`) se descartan; las que no traen
`negative_prompt` reciben uno por defecto.
styles_path: ruta del styles.json. Default: el de la instalación WAS del usuario.
overwrite: si False (default), un nombre que ya existe en el archivo NO se pisa (se
cuenta como duplicado saltado). Si True, los nuevos pisan a los existentes.
backup: si True (default), copia el archivo a `<path>.bak.<epoch>` antes de escribir.
dry_run: si True, calcula el merge y los conteos pero NO escribe nada (ni backup).
Returns:
dict resumen: {
"styles_path", "backup_path", "total_before", "total_after",
"added": [nombres añadidos], "overwritten": [nombres pisados],
"skipped_existing": [nombres saltados por existir y overwrite=False],
"invalid": [nombres descartados por inválidos], "dry_run": bool
}
Raises:
FileNotFoundError: si `styles_path` no existe (no se crea de cero para no enmascarar
una instalación rota; el caller debe asegurar que el archivo está).
ValueError: si `new_styles` no es un dict o el archivo existente no contiene un dict.
"""
if not isinstance(new_styles, dict):
raise ValueError("comfyui_append_styles: new_styles debe ser un dict")
if not os.path.isfile(styles_path):
raise FileNotFoundError(f"comfyui_append_styles: no existe styles.json en {styles_path!r}")
with open(styles_path, "r", encoding="utf-8") as fh:
existing = json.load(fh)
if not isinstance(existing, dict):
raise ValueError(
f"comfyui_append_styles: el styles.json en {styles_path!r} no es un dict de estilos"
)
total_before = len(existing)
merged = dict(existing) # copia: no mutar el cargado hasta validar todo
added: list[str] = []
overwritten: list[str] = []
skipped_existing: list[str] = []
invalid: list[str] = []
for name, value in new_styles.items():
norm = _validate_entry(value)
if norm is None:
invalid.append(str(name))
continue
if name in existing:
if overwrite:
merged[name] = norm
overwritten.append(name)
else:
skipped_existing.append(name)
continue
merged[name] = norm
added.append(name)
backup_path = ""
if not dry_run:
if backup:
backup_path = f"{styles_path}.bak.{int(time.time())}"
shutil.copy2(styles_path, backup_path)
# Escritura atómica: escribir a .tmp en el mismo dir y reemplazar.
tmp_path = f"{styles_path}.tmp.{os.getpid()}"
with open(tmp_path, "w", encoding="utf-8") as fh:
json.dump(merged, fh, ensure_ascii=False, indent=4)
os.replace(tmp_path, styles_path)
return {
"styles_path": styles_path,
"backup_path": backup_path,
"total_before": total_before,
"total_after": len(merged),
"added": added,
"overwritten": overwritten,
"skipped_existing": skipped_existing,
"invalid": invalid,
"dry_run": dry_run,
}
if __name__ == "__main__":
import sys
# CLI de conveniencia: lee un dict de estilos JSON de stdin (o de un archivo dado como
# primer arg) y lo fusiona. Con --dry-run no escribe. Imprime el resumen como JSON.
args = sys.argv[1:]
dry = "--dry-run" in args
over = "--overwrite" in args
path_args = [a for a in args if not a.startswith("--")]
if path_args:
with open(path_args[0], "r", encoding="utf-8") as fh:
payload = json.load(fh)
else:
payload = json.load(sys.stdin)
res = comfyui_append_styles(payload, overwrite=over, dry_run=dry)
print(json.dumps(res, ensure_ascii=False, indent=2))
@@ -0,0 +1,134 @@
"""Tests offline de comfyui_append_styles — no toca la instalación real ni la red.
Usa un styles.json temporal en /tmp para validar merge, dedup, backup, validación y dry-run.
"""
import json
import os
import sys
import tempfile
sys.path.insert(0, os.path.dirname(__file__))
from comfyui_append_styles import comfyui_append_styles, DEFAULT_NEGATIVE
def _write_styles(tmpdir: str, data: dict) -> str:
path = os.path.join(tmpdir, "styles.json")
with open(path, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=4)
return path
def test_merge_preserva_existentes_y_anade_nuevos():
with tempfile.TemporaryDirectory() as d:
path = _write_styles(d, {
"A": {"prompt": "a-style", "negative_prompt": "neg-a"},
"B": {"prompt": "b-style", "negative_prompt": "neg-b"},
})
res = comfyui_append_styles(
{"C": {"prompt": "c-style", "negative_prompt": "neg-c"}},
styles_path=path,
)
assert res["total_before"] == 2
assert res["total_after"] == 3
assert res["added"] == ["C"]
loaded = json.load(open(path, encoding="utf-8"))
# Los existentes intactos.
assert loaded["A"] == {"prompt": "a-style", "negative_prompt": "neg-a"}
assert loaded["B"] == {"prompt": "b-style", "negative_prompt": "neg-b"}
assert loaded["C"] == {"prompt": "c-style", "negative_prompt": "neg-c"}
def test_dedup_no_pisa_por_defecto():
with tempfile.TemporaryDirectory() as d:
path = _write_styles(d, {"A": {"prompt": "orig", "negative_prompt": "n"}})
res = comfyui_append_styles(
{"A": {"prompt": "NUEVO", "negative_prompt": "n2"}},
styles_path=path,
)
assert res["skipped_existing"] == ["A"]
assert res["added"] == []
assert res["total_after"] == 1
loaded = json.load(open(path, encoding="utf-8"))
assert loaded["A"]["prompt"] == "orig" # preservado
def test_overwrite_si_se_pide():
with tempfile.TemporaryDirectory() as d:
path = _write_styles(d, {"A": {"prompt": "orig", "negative_prompt": "n"}})
res = comfyui_append_styles(
{"A": {"prompt": "NUEVO", "negative_prompt": "n2"}},
styles_path=path,
overwrite=True,
)
assert res["overwritten"] == ["A"]
loaded = json.load(open(path, encoding="utf-8"))
assert loaded["A"]["prompt"] == "NUEVO"
def test_negative_por_defecto_cuando_falta():
with tempfile.TemporaryDirectory() as d:
path = _write_styles(d, {})
res = comfyui_append_styles(
{"X": {"prompt": "solo-prompt"}}, # sin negative_prompt
styles_path=path,
)
assert res["added"] == ["X"]
loaded = json.load(open(path, encoding="utf-8"))
assert loaded["X"]["negative_prompt"] == DEFAULT_NEGATIVE
def test_entradas_invalidas_se_descartan():
with tempfile.TemporaryDirectory() as d:
path = _write_styles(d, {})
res = comfyui_append_styles(
{
"ok": {"prompt": "valido"},
"vacio": {"prompt": " "}, # prompt vacío
"no_dict": "string", # no es dict
"sin_prompt": {"negative_prompt": "n"},
},
styles_path=path,
)
assert res["added"] == ["ok"]
assert set(res["invalid"]) == {"vacio", "no_dict", "sin_prompt"}
assert res["total_after"] == 1
def test_backup_creado():
with tempfile.TemporaryDirectory() as d:
path = _write_styles(d, {"A": {"prompt": "a", "negative_prompt": "n"}})
res = comfyui_append_styles(
{"B": {"prompt": "b"}},
styles_path=path,
)
assert res["backup_path"]
assert os.path.isfile(res["backup_path"])
# El backup contiene el estado ANTERIOR (sólo A).
bk = json.load(open(res["backup_path"], encoding="utf-8"))
assert list(bk) == ["A"]
def test_dry_run_no_escribe():
with tempfile.TemporaryDirectory() as d:
path = _write_styles(d, {"A": {"prompt": "a", "negative_prompt": "n"}})
before = open(path, encoding="utf-8").read()
res = comfyui_append_styles(
{"B": {"prompt": "b"}},
styles_path=path,
dry_run=True,
)
assert res["dry_run"] is True
assert res["added"] == ["B"]
assert res["total_after"] == 2 # calculado
assert res["backup_path"] == ""
after = open(path, encoding="utf-8").read()
assert before == after # archivo intacto
if __name__ == "__main__":
for name, fn in sorted(globals().items()):
if name.startswith("test_") and callable(fn):
fn()
print("PASS", name)
print("OK")
@@ -0,0 +1,83 @@
---
name: comfyui_apply_style_preset
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_apply_style_preset(preset: dict, subject: str, *, style: str | None = None, negative: str | None = None) -> dict"
description: "Traduce un STYLE PRESET gamedev (de comfyui_get_gamedev_style_preset) + un subject del usuario a lo que necesita un builder de sujeto del grupo gamedev-2d: el subject combinado con el prefijo/sufijo del estilo, los kwargs comunes (style, checkpoint, lora, lora_strength, negative) listos para **spread, la resolucion y el recorte recomendados (size, transparent) y la spec de post-proceso (post, p.ej. pixelize) que el caller aplica al PNG. Asi el mismo estilo se aplica a CUALQUIER builder (item_icon, enemy_creature, prop_object, ...) y al pipeline comfyui_generate_asset_pack_oneshot sin acoplar firmas. Pura, sin red ni I/O; no muta el preset."
tags: [comfyui, ml, gamedev-2d, style, preset, theme]
uses_functions: [comfyui_get_gamedev_style_preset_py_ml]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: preset
desc: "Receta de estilo (dict de comfyui_get_gamedev_style_preset). Debe traer los campos del preset (se valida que esten). No se muta."
- name: subject
desc: "Lo que el usuario quiere generar (ej. 'knight character', 'health potion'). Se combina con el prefijo/sufijo del estilo. No puede estar vacio."
- name: style
desc: "Override puntual: si se pasa, sustituye al style del preset. None usa el del preset. keyword-only."
- name: negative
desc: "Negativo extra del caller; se MERGEA (sin duplicar) con el negativo del estilo, no lo reemplaza. None = solo el del estilo. keyword-only."
output: "dict con: name (estilo aplicado), subject (combinado con prefijo/sufijo), builder_kwargs ({style, checkpoint, lora, lora_strength, negative} para **spread en el builder), size (resolucion recomendada), transparent (recorte recomendado), post (post-proceso CPU: {'pixelize': {...}} o {})."
tested: true
tests: ["golden gameboy: subject combina suffix (8-bit), builder_kwargs con las 5 claves comunes, checkpoint dreamshaper, lora None, post pixelize paleta game-boy", "golden contrato: los builder_kwargs hacen **spread en comfyui_build_item_icon_workflow sin TypeError y el LoRA del preset aparece en el grafo", "edge style override sustituye el del preset", "edge negative se mergea con el del estilo (no se pierde photorealistic) y deduplica", "edge no muta el preset de entrada", "error subject vacio -> ValueError", "error preset incompleto -> ValueError"]
test_file_path: "python/functions/ml/comfyui_apply_style_preset_test.py"
file_path: "python/functions/ml/comfyui_apply_style_preset.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_get_gamedev_style_preset import comfyui_get_gamedev_style_preset
from ml.comfyui_apply_style_preset import comfyui_apply_style_preset
from ml.comfyui_build_enemy_creature_workflow import comfyui_build_enemy_creature_workflow
# 1. Elegir estilo + aplicarlo a un subject
preset = comfyui_get_gamedev_style_preset("gameboy")
ap = comfyui_apply_style_preset(preset, "knight character")
# 2. Construir el workflow con cualquier builder de sujeto (kwargs por **spread)
wf = comfyui_build_enemy_creature_workflow(
ap["subject"], size=ap["size"], transparent=ap["transparent"], **ap["builder_kwargs"]
)
# 3. Generar (submit/wait/fetch) y, si el estilo lo pide, post-proceso:
# if ap["post"].get("pixelize"):
# comfyui_pixelize_image(raw_png, dst_png, **ap["post"]["pixelize"])
```
O lanzable directo con: `./fn run comfyui_apply_style_preset` (aplica pixel-art-retro a "knight character").
## Cuando usarla
Justo despues de elegir un estilo con `comfyui_get_gamedev_style_preset`, para convertir
esa receta en los argumentos exactos de un builder. Es el puente entre "que estilo quiero"
y "como lo paso a item_icon/enemy_creature/prop_object/...". El mismo `ap` sirve para
generar N assets distintos en el MISMO estilo (varia solo el `subject`). Para overrides
puntuales sin tocar el preset, usa `style=`/`negative=`.
## Gotchas
- Devuelve `builder_kwargs` con EXACTAMENTE las 5 claves comunes a los builders de SUJETO
(`style`, `checkpoint`, `lora`, `lora_strength`, `negative`). Builders que NO las acepten
todas (p.ej. `seamless_tile`, `parallax_background` no tienen `transparent`/`lora` igual)
exigen filtrar las claves; este helper esta pensado para los builders de sujeto cuadrado.
- `size` y `transparent` van FUERA de `builder_kwargs` (son recomendaciones del estilo): el
caller los pasa explicitos o decide otros. `transparent=False` en los presets de demo es
para que el look (paleta/pintura) cubra todo el frame; para un sprite con alpha pon
`transparent=True` (el recorte es ortogonal al estilo).
- El `post` NO se aplica solo: el caller debe llamar `comfyui_pixelize_image(raw, dst,
**ap["post"]["pixelize"])` tras descargar el PNG si `ap["post"].get("pixelize")`. Sin eso,
estilos como gameboy/pixel-art-retro no sellan su grid/paleta.
- Es **pura**: no llama a ningun builder ni toca la GPU; solo arma kwargs. No muta el
`preset` de entrada (lo que devuelve es independiente).
## Capability growth log
(v1.0.0 — sin cambios todavia.)
@@ -0,0 +1,135 @@
"""comfyui_apply_style_preset — traduce un style preset gamedev a los kwargs de un builder.
Toma una receta de estilo (de `comfyui_get_gamedev_style_preset`) y un `subject` del usuario
y produce, de forma PURA, lo que un builder de sujeto del grupo `gamedev-2d` necesita:
- el `subject` combinado con el prefijo/sufijo del estilo,
- los kwargs comunes a todos los builders de sujeto (`style`, `checkpoint`, `lora`,
`lora_strength`, `negative`) listos para hacer `**spread`,
- la resolucion y el recorte recomendados (`size`, `transparent`),
- y la spec de post-proceso (`post`, p.ej. pixelize) que el caller aplica al PNG resultante.
Asi el mismo estilo se aplica a CUALQUIER builder de sujeto (item_icon, enemy_creature,
prop_object, structure, ...) sin acoplar este helper a sus firmas, y el preset elige el
checkpoint/lora coherentes ANTES de construir el grafo.
Patron de uso:
preset = comfyui_get_gamedev_style_preset("gameboy")
ap = comfyui_apply_style_preset(preset, "knight character")
wf = comfyui_build_enemy_creature_workflow(
ap["subject"], size=ap["size"], transparent=ap["transparent"], **ap["builder_kwargs"]
)
# tras submit/wait/fetch, si ap["post"].get("pixelize"):
# comfyui_pixelize_image(raw_png, dst_png, **ap["post"]["pixelize"])
Funcion pura: sin red, sin I/O. No muta el preset de entrada (copia lo que devuelve).
"""
from __future__ import annotations
import copy
# Claves obligatorias de una receta valida (las que produce comfyui_get_gamedev_style_preset).
_REQUIRED = (
"name",
"subject_prefix",
"subject_suffix",
"style",
"negative",
"checkpoint",
"lora",
"lora_strength",
"size",
"transparent",
"post",
)
def _merge_negative(a: str, b: str) -> str:
"""Une dos negativos por comas sin duplicar terminos ni dejar comas sueltas."""
seen: list[str] = []
for chunk in (a or "", b or ""):
for term in chunk.split(","):
t = term.strip()
if t and t.lower() not in {s.lower() for s in seen}:
seen.append(t)
return ", ".join(seen)
def comfyui_apply_style_preset(
preset: dict,
subject: str,
*,
style: str | None = None,
negative: str | None = None,
) -> dict:
"""Aplica un style preset a un subject y devuelve los kwargs listos para un builder.
Args:
preset: receta de estilo (dict de comfyui_get_gamedev_style_preset). Debe traer
los campos del preset; se valida que esten presentes. No se muta.
subject: lo que el usuario quiere generar (ej. "knight character", "health potion").
Se combina con el prefijo/sufijo del estilo. No puede estar vacio.
style: si se pasa, sustituye al `style` del preset (override puntual). None usa el
del preset. keyword-only.
negative: negativo extra del caller; se MERGEA con el negativo del estilo (no lo
reemplaza). None = solo el del estilo. keyword-only.
Returns:
dict con:
- "name" (str): nombre del estilo aplicado.
- "subject" (str): subject combinado con prefijo/sufijo del estilo.
- "builder_kwargs" (dict): {style, checkpoint, lora, lora_strength, negative}
los kwargs comunes a los builders de sujeto, para hacer **spread.
- "size" (int): resolucion recomendada por el estilo.
- "transparent" (bool): recorte a alpha recomendado por el estilo.
- "post" (dict): post-proceso CPU a aplicar al PNG ({"pixelize": {...}} o {}).
Raises:
ValueError: si subject esta vacio o el preset no trae los campos requeridos.
"""
if not subject or not subject.strip():
raise ValueError("comfyui_apply_style_preset: 'subject' no puede estar vacio")
if not isinstance(preset, dict):
raise ValueError("comfyui_apply_style_preset: 'preset' debe ser un dict")
missing = [k for k in _REQUIRED if k not in preset]
if missing:
raise ValueError(
f"comfyui_apply_style_preset: preset incompleto, faltan campos {missing}. "
"Usa comfyui_get_gamedev_style_preset para obtener una receta valida."
)
subject_full = (
f"{preset['subject_prefix']}{subject.strip()}{preset['subject_suffix']}"
).strip().strip(",").strip()
style_final = style if style is not None else preset["style"]
neg_final = _merge_negative(preset["negative"], negative or "")
return {
"name": preset["name"],
"subject": subject_full,
"builder_kwargs": {
"style": style_final,
"checkpoint": preset["checkpoint"],
"lora": preset["lora"],
"lora_strength": preset["lora_strength"],
"negative": neg_final,
},
"size": preset["size"],
"transparent": preset["transparent"],
"post": copy.deepcopy(preset["post"]),
}
if __name__ == "__main__":
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ml.comfyui_get_gamedev_style_preset import comfyui_get_gamedev_style_preset
p = comfyui_get_gamedev_style_preset("pixel-art-retro")
ap = comfyui_apply_style_preset(p, "knight character")
print(json.dumps(ap, indent=2, ensure_ascii=False))
@@ -0,0 +1,92 @@
"""Tests offline de comfyui_apply_style_preset (traduccion preset -> kwargs, sin GPU)."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ml.comfyui_apply_style_preset import comfyui_apply_style_preset # noqa: E402
from ml.comfyui_get_gamedev_style_preset import ( # noqa: E402
comfyui_get_gamedev_style_preset,
)
def test_golden_apply_gameboy_to_subject():
p = comfyui_get_gamedev_style_preset("gameboy")
ap = comfyui_apply_style_preset(p, "knight character")
# El subject combina prefijo/sufijo del estilo.
assert "knight character" in ap["subject"]
assert "8-bit" in ap["subject"] # del subject_suffix del gameboy
# builder_kwargs trae las claves comunes a los builders de sujeto, listas para **spread.
bk = ap["builder_kwargs"]
assert set(bk) == {"style", "checkpoint", "lora", "lora_strength", "negative"}
assert bk["checkpoint"] == "IMG_dreamshaper_8.safetensors"
assert bk["lora"] is None
assert "Game Boy" in bk["style"]
# Recomendaciones y post propagados.
assert ap["transparent"] is False
assert ap["post"]["pixelize"]["palette"] == "game-boy"
def test_golden_kwargs_spreadable_into_builder():
# Los builder_kwargs son exactamente los que aceptan los builders de sujeto:
# se hace **spread sin TypeError (verifica el contrato con item_icon).
from ml.comfyui_build_item_icon_workflow import comfyui_build_item_icon_workflow
p = comfyui_get_gamedev_style_preset("ghibli")
ap = comfyui_apply_style_preset(p, "magic potion")
wf = comfyui_build_item_icon_workflow(
ap["subject"], size=ap["size"], transparent=ap["transparent"], **ap["builder_kwargs"]
)
cls = sorted({n["class_type"] for n in wf.values()})
assert "KSampler" in cls
# El LoRA watercolor del preset aparece en el grafo.
loras = [n for n in wf.values() if n["class_type"] == "LoraLoader"]
assert loras and loras[0]["inputs"]["lora_name"] == "SD15_watercolor_style.safetensors"
def test_edge_style_override():
p = comfyui_get_gamedev_style_preset("gameboy")
ap = comfyui_apply_style_preset(p, "tree", style="custom override style")
assert ap["builder_kwargs"]["style"] == "custom override style"
def test_edge_negative_merged_not_replaced():
p = comfyui_get_gamedev_style_preset("gameboy")
ap = comfyui_apply_style_preset(p, "tree", negative="extra unwanted thing")
neg = ap["builder_kwargs"]["negative"]
assert "extra unwanted thing" in neg
assert "photorealistic" in neg # del negativo del estilo, no se pierde
def test_edge_negative_dedup():
p = comfyui_get_gamedev_style_preset("gameboy")
# "photo" ya esta en el negativo del estilo; no debe duplicarse.
ap = comfyui_apply_style_preset(p, "tree", negative="photo")
assert ap["builder_kwargs"]["negative"].lower().count("photo,") + \
ap["builder_kwargs"]["negative"].lower().endswith("photo") <= 2
def test_edge_does_not_mutate_preset():
p = comfyui_get_gamedev_style_preset("pixel-art-retro")
before = dict(p)
ap = comfyui_apply_style_preset(p, "knight")
ap["post"]["pixelize"]["colors"] = 999 # mutar el resultado
assert p == before # el preset original intacto
assert p["post"]["pixelize"]["colors"] == 16
def test_error_empty_subject():
p = comfyui_get_gamedev_style_preset("gameboy")
try:
comfyui_apply_style_preset(p, " ")
assert False, "deberia lanzar ValueError"
except ValueError as e:
assert "subject" in str(e)
def test_error_incomplete_preset():
try:
comfyui_apply_style_preset({"name": "broken"}, "knight")
assert False, "deberia lanzar ValueError"
except ValueError as e:
assert "incompleto" in str(e) or "faltan" in str(e)
@@ -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="IMG_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="IMG_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,140 @@
---
name: comfyui_build_achievement_badge_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_achievement_badge_workflow(badge: str, *, tier: str = \"gold\", style: str = \"game achievement badge, ornate\", checkpoint: str = \"IMG_dreamshaper_8.safetensors\", size: int = 256, transparent: bool = True, seed: int = 0, lora: str | None = None, lora_strength: float = 1.0, rembg_model: str = \"u2net\", negative: str | None = None, steps: int = 28, cfg: float = 7.0, sampler_name: str = \"dpmpp_2m\", scheduler: str = \"karras\", filename_prefix: str = \"achievement_badge\") -> dict"
description: "Construye el dict (API format) del workflow de UNA insignia / medalla / logro 2D (achievement, recompensa, rango): un trofeo, una medalla con cinta, un escudo de logro, una estrella o un badge de rango que la UI de achievements pinta cuando el jugador desbloquea un hito, con TIER metalico (bronce / plata / oro / platino / diamante) que distingue el grado. Centrado, fondo limpio uniforme, recortable a alpha, estilo consistente entre insignias del set. DISTINTO de item_icon (objeto de inventario suelto, sin tier ni cinta), status_effect_icon (simbolo de estado superpuesto sin marco) y skill_tree_node (nodo enmarcado de la rejilla de talentos con estado unlocked/locked): esto es la INSIGNIA DE LOGRO/RECOMPENSA = trofeo/medalla con cinta + tier. El tier metalico y la forma de medalla/trofeo son la firma del asset. Compone comfyui_build_txt2img_workflow + comfyui_inject_lora (estilo opcional) + Image Rembg (fondo transparente si transparent). Hermano de comfyui_build_item_icon/skill_tree_node_workflow. Pura, sin red ni I/O. class_types verificados contra /object_info."
tags: [comfyui, ml, gamedev-2d, ui, achievement, badge, medal, trophy, reward, tier, ribbon, rembg, workflow]
uses_functions: [comfyui_build_txt2img_workflow_py_ml, comfyui_inject_lora_py_ml]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: badge
desc: "Nombre / tema del logro que representa la insignia (ej. 'dragon slayer', 'first blood', '100 wins', 'explorer', 'marathon runner', 'boss killer'). Se inserta en un prompt scaffold de insignia. No puede estar vacio."
- name: tier
desc: "Grado del logro: 'bronze', 'silver', 'gold' (por defecto), 'platinum' o 'diamond'. Define el aspecto metalico de la medalla. Genera la familia con el mismo badge/style/seed variando solo el tier para tener las caras coherentes del mismo logro. Cualquier otro valor se inserta literal y la pista metalica cae a la de 'gold'. keyword-only."
- name: style
desc: "Descriptor de estilo que mantiene consistentes las insignias de un set (ej. 'game achievement badge, ornate', 'flat minimal medal', 'pixel art trophy'). Pasa el MISMO style + checkpoint + lora a todas las insignias del set para coherencia visual. keyword-only."
- name: checkpoint
desc: "Checkpoint del servidor. 'IMG_dreamshaper_8.safetensors' (SD1.5, holgado en 8GB lowvram) por defecto; 'IMG_juggernaut_xl_v11.safetensors' para SDXL (mas VRAM, subir size). keyword-only."
- name: size
desc: "Lado del cuadrado en px (width = height = size). 256 por defecto: las insignias de logro se muestran a tamano reducido en el panel. keyword-only."
- name: transparent
desc: "Si True inyecta Image Rembg y el PNG sale con alpha (fondo recortado, la silueta de la medalla + cinta). False = insignia opaca sobre fondo plano, recortable luego por el caller. keyword-only."
- name: seed
desc: "Semilla del KSampler. Fija el mismo seed en la familia de tiers del mismo logro para que coincidan en composicion. keyword-only."
- name: lora
desc: "LoRA de estilo opcional en models/loras (ej. 'SD15_detail_tweaker.safetensors'). None = sin LoRA. keyword-only."
- name: lora_strength
desc: "Fuerza del LoRA sobre model y clip. Se clampa a [0.0, 2.0]. keyword-only."
- name: rembg_model
desc: "Modelo Rembg ('u2net' general, 'isnet-anime' para anime). Solo se usa si transparent=True. keyword-only."
- name: negative
desc: "Prompt negativo. None usa el negativo por defecto pensado para insignias (una medalla limpia, fondo limpio, sin escena/personaje/texto). keyword-only."
- name: steps
desc: "Pasos del KSampler. keyword-only."
- name: cfg
desc: "CFG del KSampler. keyword-only."
- name: sampler_name
desc: "Sampler del KSampler. keyword-only."
- name: scheduler
desc: "Scheduler del KSampler. keyword-only."
- name: filename_prefix
desc: "Prefijo del PNG en output/. keyword-only."
output: "dict en API format listo para comfyui_submit_workflow: base txt2img cuadrada con prompt scaffold de insignia ('{badge} achievement badge, {tier} tier ({tier hint}), {style}, medal with ribbon, centered, plain background, game UI reward, ...') + LoRA de estilo opcional + Image Rembg (si transparent). UNA insignia; un set de logros -> llamar por badge (y por tier) con mismo style/checkpoint/lora y montar el panel en el motor."
tested: true
test_file_path: python/functions/ml/comfyui_build_achievement_badge_workflow_test.py
tests: [test_golden_transparent, test_edge_transparent_false_no_rembg, test_edge_size_reflected_square, test_edge_tier_gold_default_hint, test_edge_tier_bronze_reflected, test_edge_tier_silver_reflected, test_edge_tier_unknown_falls_back_to_gold_hint, test_edge_style_reflected, test_edge_badge_reflected, test_edge_lora_injected, test_error_empty_badge, test_determinism]
file_path: python/functions/ml/comfyui_build_achievement_badge_workflow.py
---
Construye el dict (API format) del workflow de UNA insignia / medalla / logro 2D
(achievement, recompensa, rango): un trofeo, una medalla con cinta, un escudo de logro,
una estrella o un badge de rango que la UI de achievements pinta cuando el jugador
desbloquea un hito, con TIER metalico (bronce / plata / oro / platino / diamante) que
distingue el grado. Centrado, fondo limpio uniforme, recortable a alpha, estilo
consistente entre insignias del mismo set. Compone `comfyui_build_txt2img_workflow` +
`comfyui_inject_lora` (estilo opcional) + `Image Rembg` (fondo transparente si
`transparent`). Hermano de `comfyui_build_item_icon_workflow` /
`comfyui_build_skill_tree_node_workflow`. Pura, sin red ni I/O. class_types verificados
contra `/object_info`.
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_achievement_badge_workflow import comfyui_build_achievement_badge_workflow
# Insignia de logro tier oro, medalla con cinta, fondo transparente (alpha).
wf = comfyui_build_achievement_badge_workflow(
"dragon slayer",
tier="gold",
style="game achievement badge, ornate",
transparent=True,
seed=12345,
)
# La familia de un mismo logro (los grados del panel): mismo badge/style/seed,
# solo cambia tier. El motor intercambia el sprite segun el grado conseguido.
# for t in ["bronze", "silver", "gold"]:
# wf = comfyui_build_achievement_badge_workflow("dragon slayer", tier=t, seed=12345)
# comfyui_submit_workflow(wf) # -> comfyui_wait_result -> comfyui_fetch_output_image
# Set completo coherente: misma firma de style/checkpoint por insignia, varia badge.
```
O lanzable directo con: `./fn run comfyui_build_achievement_badge_workflow` (imprime nodos + class_types del ejemplo).
## Cuando usarla
Cuando necesites las insignias del sistema de logros / recompensas de un juego: cada
achievement desbloqueado se muestra como una medalla, trofeo o escudo con cinta y un
grado metalico (bronce / plata / oro). Genera la **familia** `tier="bronze"` /
`"silver"` / `"gold"` con el mismo `badge`/`style`/`seed` para tener las caras
coherentes del mismo logro; el motor cambia el sprite segun el grado que el jugador
haya conseguido. Pasa el MISMO `style` + `checkpoint` + (`lora`) a todas las insignias
del set para que el panel de logros combine. `transparent` recorta la silueta de la
medalla + cinta (alpha) lista para el motor.
Eligela frente a sus hermanos por el ROL del asset:
- **item_icon** -> objeto de inventario (espada, pocion): ilustracion de un objeto
suelto que el jugador usa/equipa, SIN tier ni cinta.
- **status_effect_icon** -> simbolo de estado compacto que se superpone al retrato/barra
del personaje (envenenado, aturdido), SIN marco; optimizado para 16-32 px.
- **skill_tree_node** -> nodo ENMARCADO de la pantalla de talentos con estado
unlocked/locked; vive en una rejilla de progresion.
- **achievement_badge (esta)** -> la INSIGNIA DE LOGRO/RECOMPENSA = trofeo/medalla con
cinta + tier (bronce/plata/oro). Se muestra en el panel de logros.
## Gotchas
- **El tier y la cinta son la firma**: lo que distingue este builder de
item_icon/skill_tree_node es que el asset es una medalla/trofeo con `tier` metalico y
cinta. El negativo por defecto NO rechaza "ribbon/medal/trophy" (son parte del asset),
pero si rechaza escenas recargadas, multiples badges y fondos sucios. Si el modelo
ignora el tier metalico, refuerza `style` con "{tier} medal, metallic sheen".
- **La familia de tiers debe compartir parametros**: para que los grados del mismo logro
coincidan en composicion, fija el mismo `badge`/`style`/`seed` y varia SOLO `tier`. La
pista metalica del tier (`bronze` -> bronze/copper; `silver` -> chrome/grey; `gold` ->
golden/yellow; `platinum`/`diamond` -> top tier) se anade automaticamente en el prompt.
Un tier desconocido se inserta literal pero la pista cae a la de `gold`.
- **Coherencia del set = mismos parametros**: si cambias `style`/`checkpoint`/`lora`
entre insignias, el panel deja de combinar. Fija esos y varia solo `badge` (y `tier`).
- **El recorte usa Rembg, NO luma-to-alpha**: una insignia es una pieza SOLIDA con
silueta definida (la medalla perfila el borde, la cinta cuelga), rembg la recorta
limpio. `comfyui_matting_luma_to_alpha` es para translucidos sobre negro (humo/fuego/
runas brillantes) y aplanaria la medalla — no la uses para estas insignias.
- **El texto/nombre lo pone el motor, no la imagen**: el negativo por defecto empuja a
"no text/no letters/no numbers" para que la insignia quede limpia; el nombre del logro,
la descripcion y la fecha los renderiza el juego sobre el badge.
- **SDXL pide mas VRAM y resolucion**: con `checkpoint="IMG_juggernaut_xl_v11.safetensors"`
sube `size` a 512; con IMG_dreamshaper_8 (SD1.5) deja 256 (holgado en 8GB lowvram).
- `transparent=False` deja la insignia opaca sobre fondo plano: util si prefieres
recortar fuera del workflow o el motor compone sobre un slot solido.
- Es una funcion **pura**: solo arma el dict. La generacion real (GPU) la hacen
`comfyui_submit_workflow` + `comfyui_wait_result` + `comfyui_fetch_output_image`.
@@ -0,0 +1,264 @@
"""Construye el workflow ComfyUI de UNA insignia / medalla / logro (API format).
Insignia de logro de juego (achievement, recompensa, rango): un trofeo, una
medalla con cinta, un escudo de logro, una estrella o un badge de rango que la UI
de achievements/recompensas pinta cuando el jugador desbloquea un hito. Tiene
TIER (bronce / plata / oro u otro grado) que distingue el nivel del logro,
centrado, fondo limpio uniforme, recortable a alpha, estilo consistente entre
insignias del mismo set. Es el builder hermano de comfyui_build_item_icon /
comfyui_build_skill_tree_node: mismo patron (PURO, dict API format) que compone
funciones existentes del registry, no reescribe el grafo.
DISTINTO de item_icon, status_effect_icon y skill_tree_node:
- item_icon -> objeto de inventario (espada, pocion): ilustracion de un
objeto suelto que el jugador usa/equipa, SIN tier, SIN cinta.
- status_effect_icon -> simbolo de estado compacto que se superpone al retrato/barra
del personaje (envenenado, aturdido); SIN marco, 16-32 px.
- skill_tree_node -> nodo ENMARCADO de la pantalla de talentos con estado
unlocked/locked; vive en una rejilla de progresion.
- achievement_badge -> ESTO: la INSIGNIA DE LOGRO/RECOMPENSA = trofeo/medalla con
cinta + tier (bronce/plata/oro). El tier metalico y la forma
de medalla/trofeo son la firma del asset; se muestra en el
panel de logros, no en inventario ni en arbol de talentos.
Por que importa el tier: un sistema de achievements muestra el MISMO logro en varios
grados (bronce / plata / oro) segun el progreso o la dificultad alcanzada. Generar la
familia (tier="bronze" / "silver" / "gold") con el mismo badge/style/seed da las
caras coherentes del mismo logro; el motor intercambia el sprite segun el tier que el
jugador haya conseguido.
Cableado:
CheckpointLoaderSimple -> [LoraLoader opcional de estilo] -> KSampler
-> CLIPTextEncode (prompt scaffold de insignia de logro) ...
-> VAEDecode -> [Image Rembg opcional] -> SaveImage
Compone:
- comfyui_build_txt2img_workflow -> base txt2img cuadrada
- comfyui_inject_lora -> LoRA de estilo opcional (consistencia del set)
- 'Image Rembg (Remove Background)' (helper local) -> fondo transparente
Por que Rembg y NO comfyui_matting_luma_to_alpha: una insignia es una pieza SOLIDA
con silueta definida (la medalla/trofeo perfila el borde, la cinta cuelga); rembg
recorta limpio la silueta dejando alpha. La luma-to-alpha es para translucidos sobre
negro (humo/fuego/runas brillantes) y aplanaria la medalla. Si el caller prefiere
recortar fuera del workflow (transparent=False) deja la imagen opaca sobre fondo
plano, recortable luego por el pipeline o el caller.
El mismo style + checkpoint + (lora) en todas las insignias del set hace que el panel
de logros combine visualmente: es la clave de un set coherente, igual que en los
iconos de inventario y los nodos del arbol de talentos.
class_types/inputs verificados contra /object_info del servidor (8GB lowvram):
CheckpointLoaderSimple, CLIPTextEncode, EmptyLatentImage, KSampler, VAEDecode,
SaveImage, LoraLoader, 'Image Rembg (Remove Background)' (transparency BOOLEAN).
Funcion pura: sin red, sin I/O. No muta dicts de entrada (copia profunda en el
helper de rembg). Determinista para los mismos argumentos.
"""
from __future__ import annotations
import copy
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# Negativo por defecto pensado para insignias de logro: una sola medalla/trofeo
# centrado y recortable, sin escena ni personaje ni texto/marcas que ensucien la
# silueta. NO rechaza "ribbon/medal/trophy" (son parte del asset), pero si empuja
# contra escenas recargadas, multiples badges y fondos sucios.
_BADGE_NEGATIVE = (
"blurry, lowres, busy background, cluttered, multiple badges, multiple medals, "
"detailed scene, landscape, full body character, person, face, text, letters, "
"words, numbers, watermark, signature, photo, photorealistic, realistic, "
"jpeg artifacts, cropped, out of frame, deformed"
)
# Pistas visuales por tier: refuerzan en el prompt el aspecto metalico de la medalla
# segun su grado. El nombre del tier tambien se inserta literal en el prompt para que
# sea explicito. Un tier desconocido cae a la pista de "gold".
_TIER_HINTS = {
"bronze": "bronze metal, copper tones, dark brown sheen, lowest tier",
"silver": "silver metal, polished chrome, cool grey sheen, mid tier",
"gold": "gold metal, shiny golden, warm yellow sheen, highest tier",
"platinum": "platinum metal, bright white-silver, prestige sheen, top tier",
"diamond": "diamond crystal, brilliant facets, prismatic shine, elite tier",
}
def _inject_rembg(workflow: dict, model: str) -> dict:
"""Inserta 'Image Rembg (Remove Background)' (transparency=True) entre VAEDecode y SaveImage.
Mismo helper que usan comfyui_build_item_icon_workflow / comfyui_build_skill_tree_node_workflow:
el nodo recorta la silueta de la insignia (medalla + cinta) dejando alpha. Repunta
SaveImage.images a la salida del Rembg.
"""
wf = copy.deepcopy(workflow)
vaedecode_id = next(
(nid for nid, n in wf.items() if n.get("class_type") == "VAEDecode"), None
)
save_id = next((nid for nid, n in wf.items() if n.get("class_type") == "SaveImage"), None)
if vaedecode_id is None or save_id is None:
raise ValueError(
"comfyui_build_achievement_badge_workflow: no se encontro VAEDecode/SaveImage para Rembg"
)
numeric = [int(k) for k in wf.keys() if str(k).isdigit()]
rembg_id = str((max(numeric) + 1) if numeric else len(wf) + 1)
wf[rembg_id] = {
"class_type": "Image Rembg (Remove Background)",
"inputs": {
"images": [vaedecode_id, 0],
"transparency": True,
"model": model,
"post_processing": False,
"only_mask": False,
"alpha_matting": False,
"alpha_matting_foreground_threshold": 240,
"alpha_matting_background_threshold": 10,
"alpha_matting_erode_size": 10,
"background_color": "none",
},
}
wf[save_id]["inputs"]["images"] = [rembg_id, 0]
return wf
def comfyui_build_achievement_badge_workflow(
badge: str,
*,
tier: str = "gold",
style: str = "game achievement badge, ornate",
checkpoint: str = "IMG_dreamshaper_8.safetensors",
size: int = 256,
transparent: bool = True,
seed: int = 0,
lora: str | None = None,
lora_strength: float = 1.0,
rembg_model: str = "u2net",
negative: str | None = None,
steps: int = 28,
cfg: float = 7.0,
sampler_name: str = "dpmpp_2m",
scheduler: str = "karras",
filename_prefix: str = "achievement_badge",
) -> dict:
"""Construye el dict (API format) del workflow de una insignia / medalla / logro.
Args:
badge: nombre / tema del logro que representa la insignia (ej. "dragon slayer",
"first blood", "100 wins", "explorer", "marathon runner", "boss killer").
Se inserta en un prompt scaffold de insignia. No puede estar vacio.
tier: grado del logro: "bronze", "silver", "gold" (por defecto), "platinum" o
"diamond". Define el aspecto metalico de la medalla. Genera la familia con
el mismo badge/style/seed variando solo el tier para tener las caras
coherentes del mismo logro. Cualquier otro valor se inserta literal (el
tier hint cae al de "gold"). keyword-only.
style: descriptor de estilo que mantiene consistentes las insignias de un set
(ej. "game achievement badge, ornate", "flat minimal medal", "pixel art
trophy"). Pasa el MISMO style + checkpoint + (lora) a todas las insignias
del set para coherencia visual. keyword-only.
checkpoint: checkpoint del servidor. 'IMG_dreamshaper_8.safetensors' (SD1.5,
holgado en 8GB lowvram) por defecto; 'IMG_juggernaut_xl_v11.safetensors' para
SDXL (mas VRAM, subir size). keyword-only.
size: lado del cuadrado en px (width = height = size). 256 por defecto: las
insignias de logro se muestran a tamano reducido en el panel. keyword-only.
transparent: si True inyecta Rembg y el PNG sale con alpha (fondo recortado,
la silueta de la medalla + cinta). Si False deja la insignia opaca sobre
fondo plano, recortable luego por el caller/pipeline. keyword-only.
seed: semilla del KSampler. Fija el mismo seed en la familia de tiers del mismo
logro para que coincidan en composicion. keyword-only.
lora: LoRA de estilo opcional en models/loras (ej.
'SD15_detail_tweaker.safetensors'). None = sin LoRA. keyword-only.
lora_strength: fuerza del LoRA sobre model y clip. Se clampa a [0.0, 2.0].
keyword-only.
rembg_model: modelo Rembg ('u2net' general, 'isnet-anime' para anime). Solo se
usa si transparent=True. keyword-only.
negative: prompt negativo. None usa el negativo por defecto pensado para
insignias (una medalla limpia, fondo limpio, sin escena/personaje/texto).
keyword-only.
steps, cfg, sampler_name, scheduler, filename_prefix: parametros de
generacion. keyword-only.
Returns:
dict en API format listo para comfyui_submit_workflow: txt2img base cuadrada
con prompt scaffold de insignia ('{badge} achievement badge, {tier} tier
({tier hint}), {style}, medal with ribbon, centered, plain background, game UI
reward, ...') + LoRA de estilo opcional + Rembg (si transparent). Es UNA
insignia; un set de logros -> llamar por badge (y por tier) con el mismo
style/checkpoint/lora y montar el panel en el motor.
Raises:
ValueError: si badge esta vacio, o si la base no tiene VAEDecode/SaveImage
donde inyectar el Rembg (propagado por el helper).
"""
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
if not badge or not badge.strip():
raise ValueError(
"comfyui_build_achievement_badge_workflow: 'badge' no puede estar vacio"
)
lora_strength = max(0.0, min(2.0, float(lora_strength)))
neg = _BADGE_NEGATIVE if negative is None else negative
badge_s = badge.strip()
tier_s = (tier or "gold").strip()
style_s = (style or "game achievement badge, ornate").strip()
# Pista de aspecto metalico segun tier; tier desconocido -> aspecto de gold.
tier_hint = _TIER_HINTS.get(tier_s.lower(), _TIER_HINTS["gold"])
# Prompt scaffold de insignia de logro: medalla/trofeo con cinta, tier metalico,
# centrado, fondo plano, recortable. badge/tier/style quedan reflejados literalmente.
positive = (
f"{badge_s} achievement badge, {tier_s} tier ({tier_hint}), {style_s}, "
"medal with ribbon, centered, plain background, game UI reward, "
"trophy emblem, clean, high detail"
)
wf = comfyui_build_txt2img_workflow(
checkpoint,
positive,
neg,
steps=steps,
cfg=cfg,
width=size,
height=size,
seed=seed,
sampler_name=sampler_name,
scheduler=scheduler,
filename_prefix=filename_prefix,
)
if lora:
from ml.comfyui_inject_lora import comfyui_inject_lora
wf = comfyui_inject_lora(
wf, lora, strength_model=lora_strength, strength_clip=lora_strength
)
if transparent:
wf = _inject_rembg(wf, rembg_model)
return wf
if __name__ == "__main__":
import json
wf = comfyui_build_achievement_badge_workflow(
"dragon slayer",
tier="gold",
style="game achievement badge, ornate",
transparent=True,
seed=42,
)
print(
json.dumps(
{
"nodes": list(wf),
"classes": sorted({n["class_type"] for n in wf.values()}),
},
indent=2,
)
)
@@ -0,0 +1,152 @@
"""Tests offline (sin red, sin GPU) de comfyui_build_achievement_badge_workflow.
Verifican que el dict en API format se construye correctamente: clases presentes,
cableado del Rembg, prompt scaffold de insignia de logro, y reflejo de los argumentos
(badge, tier, style, size, transparent, lora). No tocan el servidor ComfyUI.
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from ml.comfyui_build_achievement_badge_workflow import ( # noqa: E402
comfyui_build_achievement_badge_workflow,
)
def _classes(wf):
return {n["class_type"] for n in wf.values()}
def _positive_prompt(wf):
"""Texto positivo: el CLIPTextEncode al que apunta KSampler.positive."""
ks = next(n for n in wf.values() if n["class_type"] == "KSampler")
pos_id = ks["inputs"]["positive"][0]
return wf[pos_id]["inputs"]["text"]
def test_golden_transparent():
"""Caso feliz: insignia transparente -> Rembg cableado, prompt de logro, clases base."""
wf = comfyui_build_achievement_badge_workflow(
"dragon slayer", tier="gold", transparent=True, seed=42
)
cls = _classes(wf)
for expected in {
"CheckpointLoaderSimple",
"KSampler",
"VAEDecode",
"SaveImage",
"Image Rembg (Remove Background)",
}:
assert expected in cls, f"falta clase {expected}"
prompt = _positive_prompt(wf)
assert "dragon slayer" in prompt
assert "achievement badge" in prompt
assert "gold tier" in prompt
assert "medal with ribbon" in prompt
assert "centered" in prompt
assert "game UI reward" in prompt
# SaveImage debe tomar la imagen del Rembg, no del VAEDecode.
save = next(n for n in wf.values() if n["class_type"] == "SaveImage")
rembg_id = next(
nid for nid, n in wf.items() if n["class_type"] == "Image Rembg (Remove Background)"
)
assert save["inputs"]["images"][0] == rembg_id
rembg = wf[rembg_id]
assert rembg["inputs"]["transparency"] is True
def test_edge_transparent_false_no_rembg():
"""transparent=False -> sin nodo Rembg; SaveImage cuelga del VAEDecode."""
wf = comfyui_build_achievement_badge_workflow("first blood", transparent=False)
assert "Image Rembg (Remove Background)" not in _classes(wf)
save = next(n for n in wf.values() if n["class_type"] == "SaveImage")
vae_id = next(nid for nid, n in wf.items() if n["class_type"] == "VAEDecode")
assert save["inputs"]["images"][0] == vae_id
def test_edge_size_reflected_square():
"""size se refleja como width == height (cuadrado). Default 256 (insignia compacta)."""
wf = comfyui_build_achievement_badge_workflow("explorer", size=128)
latent = next(n for n in wf.values() if n["class_type"] == "EmptyLatentImage")
assert latent["inputs"]["width"] == 128
assert latent["inputs"]["height"] == 128
wf_default = comfyui_build_achievement_badge_workflow("explorer")
latent_d = next(n for n in wf_default.values() if n["class_type"] == "EmptyLatentImage")
assert latent_d["inputs"]["width"] == 256
assert latent_d["inputs"]["height"] == 256
def test_edge_tier_gold_default_hint():
"""tier='gold' (default) se refleja literal y arrastra la pista metalica de oro."""
wf = comfyui_build_achievement_badge_workflow("boss killer")
prompt = _positive_prompt(wf)
assert "gold tier" in prompt
assert "gold metal" in prompt
def test_edge_tier_bronze_reflected():
"""tier='bronze' se refleja literal y arrastra la pista metalica de bronce."""
wf = comfyui_build_achievement_badge_workflow("rookie", tier="bronze")
prompt = _positive_prompt(wf)
assert "bronze tier" in prompt
assert "bronze metal" in prompt
def test_edge_tier_silver_reflected():
"""tier='silver' se refleja literal y arrastra la pista metalica de plata."""
wf = comfyui_build_achievement_badge_workflow("veteran", tier="silver")
prompt = _positive_prompt(wf)
assert "silver tier" in prompt
assert "silver metal" in prompt
def test_edge_tier_unknown_falls_back_to_gold_hint():
"""tier desconocido se inserta literal pero la pista cae a la de gold."""
wf = comfyui_build_achievement_badge_workflow("mythic", tier="legendary")
prompt = _positive_prompt(wf)
assert "legendary tier" in prompt
assert "gold metal" in prompt # hint fallback
def test_edge_style_reflected():
"""style se inserta en el prompt positivo."""
wf = comfyui_build_achievement_badge_workflow(
"speedrun", style="pixel art trophy"
)
assert "pixel art trophy" in _positive_prompt(wf)
def test_edge_badge_reflected():
"""badge se inserta literal en el prompt positivo."""
wf = comfyui_build_achievement_badge_workflow("marathon runner")
assert "marathon runner" in _positive_prompt(wf)
def test_edge_lora_injected():
"""lora -> LoraLoader presente con la fuerza dada."""
wf = comfyui_build_achievement_badge_workflow(
"collector", lora="SD15_detail_tweaker.safetensors", lora_strength=0.8
)
loras = [n for n in wf.values() if n["class_type"] == "LoraLoader"]
assert len(loras) == 1
assert loras[0]["inputs"]["lora_name"] == "SD15_detail_tweaker.safetensors"
assert loras[0]["inputs"]["strength_model"] == pytest.approx(0.8)
def test_error_empty_badge():
"""badge vacio -> ValueError."""
with pytest.raises(ValueError):
comfyui_build_achievement_badge_workflow(" ")
def test_determinism():
"""Mismos argumentos -> mismo dict (funcion pura)."""
a = comfyui_build_achievement_badge_workflow("undefeated", seed=7)
b = comfyui_build_achievement_badge_workflow("undefeated", seed=7)
assert a == b
@@ -0,0 +1,139 @@
---
name: comfyui_build_asset_variant_workflow
kind: function
lang: py
domain: ml
purity: pure
version: 1.0.0
signature: "def comfyui_build_asset_variant_workflow(input_image: str, variant: str, *, checkpoint: str = \"IMG_dreamshaper_8.safetensors\", denoise: float = 0.5, style: str = \"game asset\", size: int | None = 512, seed: int = 0, lora: str | None = None, lora_strength: float = 1.0, upscale_method: str = \"lanczos\", crop: str = \"disabled\", negative: str | None = None, steps: int = 28, cfg: float = 7.0, sampler_name: str = \"dpmpp_2m\", scheduler: str = \"karras\", filename_prefix: str = \"asset_variant\") -> dict"
description: "Construye el dict (API format) del workflow de una VARIANTE img2img de un asset 2D ya generado: parte de una IMAGEN existente (un sprite de enemigo, un icono...) y produce una version coherente que cambia material/paleta/tier/estado (ice element, fire element, battle-damaged, golden tier 2, corrupted) manteniendo la composicion, la pose y la silueta del original. A diferencia de los builders gamedev hermanos (enemy_creature, item_icon...), que parten de TEXTO (txt2img desde ruido), este parte de una imagen via img2img con denoise MEDIO (~0.45-0.6): el KSampler arranca del latente de la imagen base, no de ruido. Normaliza el tamano con un ImageScale opcional (size) o preserva las dimensiones del original (size=None). Compone comfyui_build_img2img_workflow + comfyui_inject_lora (estilo opcional). Pura, sin red ni I/O. class_types verificados contra /object_info (8GB lowvram)."
tags: [comfyui, ml, gamedev-2d, img2img, variant, asset-transform, stable-diffusion, workflow]
uses_functions: [comfyui_build_img2img_workflow_py_ml, comfyui_inject_lora_py_ml]
uses_types: []
returns: []
returns_optional: false
error_type: ""
params:
- name: input_image
desc: "Nombre del archivo de la imagen base dentro de la carpeta input/ del servidor ComfyUI (un asset YA generado). Lo carga el nodo LoadImage. Subelo antes con POST /upload/image o copialo a ~/ComfyUI/input/. No puede estar vacio."
- name: variant
desc: "Descripcion de la variante a producir (ej. 'ice element, frozen', 'fire element, molten', 'battle-damaged, cracked', 'golden tier 2', 'corrupted shadow'). Reescribe material/paleta/estado del asset manteniendo su composicion. No describe el sujeto desde cero: transforma el que ya existe en input_image. No puede estar vacio."
- name: checkpoint
desc: "Checkpoint del servidor. 'IMG_dreamshaper_8.safetensors' (SD1.5, holgado en 8GB lowvram) por defecto. keyword-only."
- name: denoise
desc: "Fuerza de denoising del KSampler (cuanto se aparta del original). ~0.3 apenas cambia; 0.45-0.6 (recomendado) cambia material/paleta conservando silueta/pose; ~0.8 se aleja y empieza a ser casi txt2img. Se clampa a [0.0, 1.0]. keyword-only."
- name: style
desc: "Descriptor de estilo que mantiene coherentes las variantes de un set (ej. 'game asset', 'dark fantasy creature', 'pixel art'). Mismo style + checkpoint + (lora) en todas las variantes del mismo asset. keyword-only."
- name: size
desc: "Lado en px al que se NORMALIZA la imagen base antes de encodearla (inserta un ImageScale a size x size). None = no escala; la variante hereda las dimensiones EXACTAS del original (preserva proporcion sin deformar). 512 por defecto (SD1.5). keyword-only."
- name: seed
desc: "Semilla del KSampler. keyword-only."
- name: lora
desc: "LoRA de estilo opcional en models/loras (ej. 'SD15_dark_fantasy.safetensors'). None = sin LoRA. keyword-only."
- name: lora_strength
desc: "Fuerza del LoRA sobre model y clip. Se clampa a [0.0, 2.0]. keyword-only."
- name: upscale_method
desc: "Metodo del ImageScale ('lanczos', 'bilinear', 'bicubic', 'area', 'nearest-exact'). Solo se usa si size no es None. keyword-only."
- name: crop
desc: "Modo de recorte del ImageScale ('disabled' conserva todo el contenido, 'center' recorta al centro para encajar el ratio). Solo si size no es None. keyword-only."
- name: negative
desc: "Prompt negativo. None usa el negativo por defecto pensado para variantes (conservar pose/composicion, una figura, fondo limpio). keyword-only."
- name: steps
desc: "Pasos de sampling del KSampler. keyword-only."
- name: cfg
desc: "Classifier-free guidance scale. keyword-only."
- name: sampler_name
desc: "Nombre del sampler (ej. 'dpmpp_2m', 'euler'). keyword-only."
- name: scheduler
desc: "Scheduler del sampler (ej. 'karras', 'normal'). keyword-only."
- name: filename_prefix
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
output: "dict en API format listo para comfyui_submit_workflow: img2img base (parte de input_image) con prompt de variante + ImageScale opcional (normaliza a size) + LoRA opcional. Nodos: CheckpointLoaderSimple '4', LoadImage '10', VAEEncode '11', CLIPTextEncode '6'/'7', KSampler '3' (denoise medio), VAEDecode '8', SaveImage '9', + ImageScale y LoraLoader si aplican."
tested: false
file_path: python/functions/ml/comfyui_build_asset_variant_workflow.py
---
Construye el dict (API format) del workflow de una **variante de un asset 2D que ya
existe** (img2img). Builder gamedev hermano de `comfyui_build_enemy_creature_workflow`
e `comfyui_build_item_icon_workflow`, pero con un eje distinto: en vez de generar un
TIPO de asset desde texto, **transforma** una imagen concreta (un sprite ya generado)
en una variante coherente — la version "de hielo", "de fuego", "dañada" o "tier 2
dorada" — conservando silueta, pose y composición del original.
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_asset_variant_workflow import comfyui_build_asset_variant_workflow
# Variante "de hielo" de un sprite de goblin ya generado (subido al input/ del server)
wf = comfyui_build_asset_variant_workflow(
"enemy_creature_00001_.png", # asset existente en el input/ de ComfyUI
"ice element, frozen", # la variante a producir
style="dark fantasy creature, game asset",
denoise=0.5, # medio: cambia material/paleta, conserva silueta
seed=7,
)
# wf parte de una imagen (img2img), NO de ruido:
# "VAEEncode" in {n["class_type"] for n in wf.values()} # True
# "EmptyLatentImage" not in {n["class_type"] for n in wf.values()} # True (no es txt2img)
# wf["10"]["inputs"]["image"] == "enemy_creature_00001_.png"
# wf["3"]["inputs"]["denoise"] == 0.5
# "ice element, frozen" in wf["6"]["inputs"]["text"]
```
El bloque 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 `comfyui_build_img2img_workflow`.
Usa el import de arriba o un heredoc.
Set de variantes del MISMO asset (mismo `input_image`/`style`/`seed`, distinto `variant`):
```python
for v in ["ice element, frozen", "fire element, molten", "battle-damaged, cracked", "golden tier 2"]:
wf = comfyui_build_asset_variant_workflow("enemy_creature_00001_.png", v,
style="dark fantasy creature, game asset",
denoise=0.5, seed=7)
# enviar con comfyui_submit_workflow -> familia coherente de variantes
```
Para enviar a la GPU: subir la base con `POST /upload/image`, luego
`comfyui_submit_workflow(wf)` + `comfyui_wait_result(prompt_id)` +
`comfyui_fetch_output_image(filename)`.
## Cuando usarla
Cuando ya tienes un asset 2D generado y quieres **derivar variantes coherentes** de
él (elemento/material/tier/estado) sin redibujar desde cero: el sprite de hielo del
mismo enemigo, la armadura dorada del mismo personaje, la versión dañada del mismo
prop. Es img2img con denoise medio que conserva la composición original. Para generar
un asset NUEVO desde texto usa los builders txt2img hermanos
(`comfyui_build_enemy_creature_workflow`, `comfyui_build_item_icon_workflow`...); para
ampliar/refinar resolución usa `comfyui_build_upscale_workflow`; para img2img genérico
sin scaffolding de variante usa `comfyui_build_img2img_workflow` directo.
## Gotchas
- Es **img2img**, no txt2img: SIEMPRE parte de una imagen (`input_image`), no de ruido
en blanco. Esa imagen debe existir en la carpeta `input/` del servidor ComfyUI
(subir con `POST /upload/image` o copiar a `~/ComfyUI/input/`). Es pura: NO valida
que exista; si no está, ComfyUI rechaza el workflow con HTTP 400 al enviarlo. Valida
antes con `comfyui_validate_workflow`.
- `denoise` es la palanca clave: cerca de 0.0 apenas cambia (variante invisible);
0.45-0.6 es el rango útil (cambia material/paleta manteniendo silueta); cerca de 0.8
se aleja del original y deriva la pose/composición (deja de ser variante coherente y
se acerca a un txt2img). Default 0.5.
- `size` reescala la imagen base a `size x size` con un ImageScale ANTES de encodear.
Con `size=512` y un asset cuadrado 512 es no-op de tamaño; con un asset NO cuadrado y
`crop="disabled"` el ImageScale fuerza el ratio cuadrado y puede deformar — pasa
`size=None` para preservar las dimensiones/proporción exactas del original, o
`crop="center"` para recortar al centro en vez de deformar.
- El prompt refuerza "same composition, same pose, same silhouette" además del denoise
medio; aun así, denoise alto o un `variant` que implique cambio de forma (ej. "giant
version") puede alterar la silueta. Para variantes solo de paleta/material, mantén
denoise ≤0.55.
- Asume checkpoint con VAE embebido (VAEEncode/VAEDecode usan el VAE del checkpoint).
Para un VAE externo hay que reconectar esas entradas a mano.
- 8GB lowvram: SD1.5 a 512² va holgado. Si OOM, baja `size` (384) o `denoise`; NO subas
a SDXL en 8GB para esto.
@@ -0,0 +1,262 @@
"""Construye el workflow ComfyUI de una VARIANTE de un asset ya generado (img2img).
A diferencia de los builders gamedev hermanos (enemy_creature, item_icon,
ui_hud...), que parten de TEXTO (txt2img desde ruido), este builder parte de una
IMAGEN que ya existe y produce una variante COHERENTE: cambia paleta, material,
tier o estado del asset manteniendo la composicion, la pose y la silueta del
original. Es el caso real de gamedev: tienes el sprite de un enemigo y quieres su
version "de hielo", "de fuego", "danada en combate" o "tier 2 dorada" sin redibujar
desde cero.
El mecanismo es img2img con denoise MEDIO: el KSampler parte del latente de la
imagen base (LoadImage -> [ImageScale opcional] -> VAEEncode), no de ruido, asi que
con denoise ~0.45-0.6 conserva la estructura global (silueta/pose) mientras el
prompt de la variante reescribe material y color. Denoise bajo (~0.3) apenas cambia;
alto (~0.8) se aleja del original y empieza a ser casi txt2img.
Cableado:
CheckpointLoaderSimple -> [LoraLoader opcional de estilo] -> KSampler
LoadImage -> [ImageScale opcional a size x size] -> VAEEncode -> KSampler.latent
CLIPTextEncode (prompt de variante + "same composition/pose/silhouette")
KSampler (denoise medio) -> VAEDecode -> SaveImage
Compone:
- comfyui_build_img2img_workflow -> base img2img (LoadImage/VAEEncode/KSampler con denoise)
- comfyui_inject_lora -> LoRA de estilo opcional (consistencia con el set)
Por que ImageScale opcional y no EmptyLatentImage: en img2img el tamano de salida lo
fija la imagen base (no hay EmptyLatentImage). Para poder NORMALIZAR todos los assets
del set a una resolucion comun (`size`), se inserta un ImageScale entre LoadImage y
VAEEncode que reescala la base antes de encodear. Si size=None, no se escala y la
variante hereda las dimensiones exactas del original (preserva proporcion sin
deformar). Es la diferencia clave con un txt2img: aqui SIEMPRE hay una imagen de
entrada de la que se parte; el prompt no genera en blanco, transforma.
Por que el prompt empuja "same composition, same pose, same silhouette": el denoise
medio ya conserva la estructura, pero reforzarlo en el texto reduce la deriva de
pose/encuadre y mantiene la variante alineada con el original (lo que se quiere para
un set coherente: misma figura, distinto material/tier).
class_types/inputs verificados contra /object_info del servidor (8GB lowvram):
CheckpointLoaderSimple, LoadImage, ImageScale, VAEEncode, CLIPTextEncode, KSampler,
VAEDecode, SaveImage, LoraLoader.
Funcion pura: sin red, sin I/O. No muta dicts de entrada (copia profunda al insertar
ImageScale). NO valida que input_image/checkpoint/lora existan en el servidor (eso
es responsabilidad de comfyui_validate_workflow antes de enviar). Determinista para
los mismos argumentos.
"""
from __future__ import annotations
import copy
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# Negativo por defecto pensado para variantes de asset: conservar UNA figura entera,
# bien formada, fondo limpio, SIN cambiar la composicion/pose y sin texto/marcas ni
# objetos extra. No filtra material ni paleta (ice/fire/golden/damaged son validos).
_VARIANT_NEGATIVE = (
"blurry, lowres, deformed, disfigured, bad anatomy, extra limbs, "
"different pose, different composition, different framing, extra objects, "
"duplicate, multiple subjects, text, watermark, signature, logo, "
"cropped, cut off, out of frame, jpeg artifacts"
)
def _inject_image_scale(
workflow: dict, *, size: int, upscale_method: str, crop: str
) -> dict:
"""Inserta un nodo ImageScale entre LoadImage y VAEEncode para normalizar el tamano.
Reescala la imagen base a size x size ANTES de encodearla al latente, de modo que
la variante salga a la resolucion deseada en lugar de heredar la del original.
Repunta VAEEncode.pixels a la salida del ImageScale. Pura: trabaja sobre copia.
"""
wf = copy.deepcopy(workflow)
load_id = next(
(nid for nid, n in wf.items() if n.get("class_type") == "LoadImage"), None
)
vaeencode_id = next(
(nid for nid, n in wf.items() if n.get("class_type") == "VAEEncode"), None
)
if load_id is None or vaeencode_id is None:
raise ValueError(
"comfyui_build_asset_variant_workflow: no se encontro LoadImage/VAEEncode "
"para insertar ImageScale"
)
numeric = [int(k) for k in wf.keys() if str(k).isdigit()]
scale_id = str((max(numeric) + 1) if numeric else len(wf) + 1)
# La fuente de pixeles que hoy alimenta el VAEEncode (normalmente LoadImage[0]).
src = wf[vaeencode_id]["inputs"].get("pixels", [load_id, 0])
wf[scale_id] = {
"class_type": "ImageScale",
"inputs": {
"image": list(src),
"upscale_method": upscale_method,
"width": int(size),
"height": int(size),
"crop": crop,
},
}
wf[vaeencode_id]["inputs"]["pixels"] = [scale_id, 0]
return wf
def comfyui_build_asset_variant_workflow(
input_image: str,
variant: str,
*,
checkpoint: str = "IMG_dreamshaper_8.safetensors",
denoise: float = 0.5,
style: str = "game asset",
size: int | None = 512,
seed: int = 0,
lora: str | None = None,
lora_strength: float = 1.0,
upscale_method: str = "lanczos",
crop: str = "disabled",
negative: str | None = None,
steps: int = 28,
cfg: float = 7.0,
sampler_name: str = "dpmpp_2m",
scheduler: str = "karras",
filename_prefix: str = "asset_variant",
) -> dict:
"""Construye el dict (API format) de una variante img2img de un asset existente.
Args:
input_image: nombre del archivo de la imagen base dentro de la carpeta
input/ del servidor ComfyUI (un asset YA generado: un sprite de enemigo,
un icono de objeto...). Lo carga el nodo LoadImage. Subelo antes con
POST /upload/image o copialo a ~/ComfyUI/input/. No puede estar vacio.
variant: descripcion de la variante a producir (ej. "ice element, frozen",
"fire element, molten", "battle-damaged, cracked", "golden tier 2",
"corrupted shadow"). Es lo que reescribe material/paleta/estado del asset
manteniendo su composicion. No puede estar vacio. Es lo que diferencia
este builder de un txt2img: NO describe el sujeto desde cero, transforma
uno que ya existe en la imagen base.
checkpoint: checkpoint del servidor. 'IMG_dreamshaper_8.safetensors' (SD1.5,
holgado en 8GB lowvram) por defecto. keyword-only.
denoise: fuerza de denoising del KSampler (cuanto se aparta del original).
~0.3 apenas cambia; 0.45-0.6 (recomendado) cambia material/paleta
conservando silueta/pose; ~0.8 se aleja y empieza a ser casi txt2img. Se
clampa a [0.0, 1.0]. keyword-only.
style: descriptor de estilo que mantiene coherentes las variantes de un set
(ej. "game asset", "dark fantasy creature", "pixel art"). Pasa el MISMO
style + checkpoint + (lora) a todas las variantes del mismo asset.
keyword-only.
size: lado en px al que se NORMALIZA la imagen base antes de encodearla
(inserta un ImageScale a size x size). None = no escala, la variante
hereda las dimensiones EXACTAS del original (preserva proporcion sin
deformar). 512 por defecto (SD1.5). keyword-only.
seed: semilla del KSampler. keyword-only.
lora: LoRA de estilo opcional en models/loras (ej.
'SD15_dark_fantasy.safetensors'). None = sin LoRA. keyword-only.
lora_strength: fuerza del LoRA sobre model y clip. Se clampa a [0.0, 2.0].
keyword-only.
upscale_method: metodo del ImageScale ('lanczos', 'bilinear', 'bicubic',
'area', 'nearest-exact'). Solo se usa si size no es None. keyword-only.
crop: modo de recorte del ImageScale ('disabled' conserva todo el contenido,
'center' recorta al centro para encajar el ratio). Solo si size no es
None. keyword-only.
negative: prompt negativo. None usa el negativo por defecto pensado para
variantes (conservar pose/composicion, una figura, fondo limpio).
keyword-only.
steps, cfg, sampler_name, scheduler, filename_prefix: parametros de
generacion. keyword-only.
Returns:
dict en API format listo para comfyui_submit_workflow: img2img base (parte de
input_image) con prompt de variante ('{variant}, {style}, same composition,
same pose, same silhouette, ...') + ImageScale opcional (normaliza a size) +
LoRA de estilo opcional. Es UNA variante; un set de variantes del MISMO asset
-> llamar por `variant` con el mismo input_image/style/checkpoint/seed.
Raises:
ValueError: si input_image o variant estan vacios, o si la base no tiene
LoadImage/VAEEncode donde insertar el ImageScale (propagado por el helper).
"""
from ml.comfyui_build_img2img_workflow import comfyui_build_img2img_workflow
if not input_image or not input_image.strip():
raise ValueError(
"comfyui_build_asset_variant_workflow: 'input_image' no puede estar vacio"
)
if not variant or not variant.strip():
raise ValueError(
"comfyui_build_asset_variant_workflow: 'variant' no puede estar vacio"
)
input_image = input_image.strip()
variant = variant.strip()
denoise = max(0.0, min(1.0, float(denoise)))
lora_strength = max(0.0, min(2.0, float(lora_strength)))
neg = _VARIANT_NEGATIVE if negative is None else negative
# Prompt de variante: reescribe material/paleta/estado pero refuerza que la
# composicion, pose y silueta del original se conservan (img2img coherente).
positive = (
f"{variant}, {style}, same composition, same pose, same silhouette, "
"consistent design, high detail"
)
wf = comfyui_build_img2img_workflow(
checkpoint,
input_image,
positive,
neg,
denoise=denoise,
steps=steps,
cfg=cfg,
seed=seed,
sampler_name=sampler_name,
scheduler=scheduler,
)
# El builder base hardcodea filename_prefix="comfy_img2img"; lo repuntamos.
save_id = next(
(nid for nid, n in wf.items() if n.get("class_type") == "SaveImage"), None
)
if save_id is not None:
wf[save_id]["inputs"]["filename_prefix"] = filename_prefix
if size is not None:
wf = _inject_image_scale(
wf, size=size, upscale_method=upscale_method, crop=crop
)
if lora:
from ml.comfyui_inject_lora import comfyui_inject_lora
wf = comfyui_inject_lora(
wf, lora, strength_model=lora_strength, strength_clip=lora_strength
)
return wf
if __name__ == "__main__":
import json
wf = comfyui_build_asset_variant_workflow(
"enemy_creature_00001_.png",
"ice element, frozen",
style="dark fantasy creature, game asset",
denoise=0.5,
seed=7,
)
print(
json.dumps(
{
"nodes": list(wf),
"classes": sorted({n["class_type"] for n in wf.values()}),
"denoise": wf["3"]["inputs"]["denoise"],
"positive": wf["6"]["inputs"]["text"],
"input_image": wf["10"]["inputs"]["image"],
},
indent=2,
)
)
@@ -0,0 +1,99 @@
---
name: comfyui_build_audio_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_audio_workflow(ckpt_name: str, prompt: str, *, lyrics: str = \"\", seconds: float = 10.0, seed: int = 0, steps: int = 50, cfg: float = 5.0, sampler_name: str = \"euler\", scheduler: str = \"simple\", shift: float = 5.0, lyrics_strength: float = 1.0, filename_prefix: str = \"audio/comfy_audio\") -> dict"
description: "Construye el dict de un workflow ComfyUI texto->audio (ACE-Step) en API format. Cadena con nodos de audio NATIVOS de ComfyUI 0.26.0: CheckpointLoaderSimple(AUDIO_ace_step_v1_3.5b.safetensors -> MODEL, CLIP, VAE) -> TextEncodeAceStepAudio(tags=prompt, lyrics) como positive + ConditioningZeroOut como negative + EmptyAceStepLatentAudio(seconds) -> ModelSamplingSD3(shift) -> KSampler -> VAEDecodeAudio -> SaveAudio(.flac). ACE-Step es abierto (Apache 2.0). Genera musica y SFX por texto; lyrics opcional para voz cantada. Pura, sin red ni I/O. Hermana de audio de comfyui_build_txt2img_workflow."
tags: [comfyui, audio, ace-step, sfx, music, ml, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: ckpt_name
desc: "Nombre del checkpoint ACE-Step tal como lo ve el servidor ComfyUI (ej. 'AUDIO_ace_step_v1_3.5b.safetensors', todo-en-uno: DiT + text encoder + VAE de audio). Debe estar entre los que devuelve comfyui_object_info en CheckpointLoaderSimple."
- name: prompt
desc: "Descripcion del sonido o estilo musical. Va al campo 'tags' de TextEncodeAceStepAudio. Ej. '8-bit coin pickup sound, retro game' o 'lofi hip hop, mellow piano, 90 bpm'."
- name: lyrics
desc: "Letra cantada para musica con voz. Vacio '' para SFX o musica instrumental. keyword-only."
- name: seconds
desc: "Duracion del audio en segundos (min 1.0). Controla el tamano del latente via EmptyAceStepLatentAudio. keyword-only."
- name: seed
desc: "Semilla del KSampler. 0 es determinista; cambiar para variar el resultado. keyword-only."
- name: steps
desc: "Pasos de sampling del KSampler. 50 recomendado para ACE-Step. keyword-only."
- name: cfg
desc: "Classifier-free guidance scale. 5.0 recomendado para ACE-Step. keyword-only."
- name: sampler_name
desc: "Algoritmo del KSampler. Por defecto 'euler'. keyword-only."
- name: scheduler
desc: "Scheduler del KSampler. Por defecto 'simple'. keyword-only."
- name: shift
desc: "Shift del ModelSamplingSD3 aplicado al MODEL antes del sampling. 5.0 recomendado para ACE-Step; mejora la coherencia temporal. keyword-only."
- name: lyrics_strength
desc: "Fuerza del condicionamiento de la letra (1.0 por defecto; sin efecto practico cuando lyrics esta vacio). keyword-only."
- name: filename_prefix
desc: "Prefijo del .flac generado por SaveAudio en output/ del servidor. keyword-only."
output: "dict en API format listo para comfyui_submit_workflow. node_ids string; cada valor con class_type + inputs. Devuelve 8 nodos: CheckpointLoaderSimple, TextEncodeAceStepAudio, ConditioningZeroOut, EmptyAceStepLatentAudio, ModelSamplingSD3, KSampler, VAEDecodeAudio y SaveAudio. El denoise del KSampler se fija a 1.0 (genera desde el latente vacio, no es audio2audio)."
tested: true
tests: ["estructura: 8 nodos ACE-Step presentes + ckpt en CheckpointLoaderSimple + prompt en TextEncodeAceStepAudio.tags", "cableado: clip [4,1], positive [6,0], negative via ConditioningZeroOut [10,0], model post ModelSamplingSD3 [11,0], vae [4,2], denoise 1.0", "params reflejados (lyrics/seconds/seed/steps/cfg/sampler_name/scheduler/shift/lyrics_strength/filename_prefix)", "edge: seconds y seed variables se reflejan en EmptyAceStepLatentAudio y KSampler", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_audio_workflow.py"
file_path: "python/functions/ml/comfyui_build_audio_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_audio_workflow import comfyui_build_audio_workflow
wf = comfyui_build_audio_workflow(
ckpt_name="AUDIO_ace_step_v1_3.5b.safetensors",
prompt="8-bit coin pickup sound, retro game, short",
seconds=4.0, seed=42,
)
# wf["6"]["class_type"] == "TextEncodeAceStepAudio"
# wf["9"]["class_type"] == "SaveAudio"
# -> comfyui_submit_workflow(wf, server="127.0.0.1:8188") para encolar (necesita GPU)
# -> comfyui_wait_result(prompt_id) -> comfyui_fetch_output_audio(prompt_id, dest=...)
```
O lanzable directo con: `./fn run comfyui_build_audio_workflow` (imprime el JSON del workflow ACE-Step de ejemplo).
## Cuando usarla
Antes de enviar una generacion de audio (musica o SFX por texto) a ComfyUI:
construye aqui el dict del workflow ACE-Step y pasalo a `comfyui_submit_workflow`.
Usala cuando quieres un sonido o pieza musical descrita en lenguaje natural
(`prompt`), opcionalmente con letra cantada (`lyrics`). Baja el resultado con
`comfyui_fetch_output_audio`. 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.
- El checkpoint ACE-Step 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.
- Stable Audio Open 1.0 (la otra via nativa, mas ligera) esta GATED en HuggingFace
(resolve da HTTP 403 sin aceptar la licencia): por eso el modelo por defecto es
ACE-Step, que es abierto (Apache 2.0) y no gated.
- VRAM 8GB: `ace_step_v1_3.5b.safetensors` pesa ~7.7GB. Arrancar ComfyUI con
`--lowvram` para que streamee bloques a CPU; aun asi va justo. Antes de generar
audio, liberar VRAM de SD/Flux con POST /free {"unload_models":true,
"free_memory":true}. Si da OOM, bajar `seconds`. El builder es puro: no toca la
GPU, solo arma el dict (un OOM ocurre en el submit posterior, no aqui).
- ACE-Step es modelo de MUSICA: para SFX cortos funciona pero el resultado tiende
a sonar "musical". `seconds` minimo 1.0. Para SFX muy cortos usar 2-4 s.
- SaveAudio guarda `.flac` por defecto (clave "audio" en outputs[node]). Para bajar
el archivo usa `comfyui_fetch_output_audio` (no `comfyui_fetch_output_video`, que
solo busca extensiones de video).
- `lyrics` vacio = instrumental/SFX. Con letra, ACE-Step canta; `lyrics_strength`
ajusta cuanto se ciñe a ella.
@@ -0,0 +1,126 @@
"""Construye un workflow ComfyUI de texto->audio (ACE-Step) en "API format".
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).
El grafo usa los nodos de audio NATIVOS de ComfyUI 0.26.0 para el modelo
ACE-Step (abierto, Apache 2.0): CheckpointLoaderSimple ->
TextEncodeAceStepAudio (tags + lyrics) -> EmptyAceStepLatentAudio ->
ModelSamplingSD3 -> KSampler -> VAEDecodeAudio -> SaveAudio. El negative se
construye con ConditioningZeroOut sobre el positive (patron oficial de ACE-Step).
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
def comfyui_build_audio_workflow(
ckpt_name: str,
prompt: str,
*,
lyrics: str = "",
seconds: float = 10.0,
seed: int = 0,
steps: int = 50,
cfg: float = 5.0,
sampler_name: str = "euler",
scheduler: str = "simple",
shift: float = 5.0,
lyrics_strength: float = 1.0,
filename_prefix: str = "audio/comfy_audio",
) -> dict:
"""Construye el dict del workflow texto->audio para ACE-Step.
Cadena de nodos: CheckpointLoaderSimple -> TextEncodeAceStepAudio (positivo)
+ ConditioningZeroOut (negativo) + EmptyAceStepLatentAudio -> ModelSamplingSD3
-> KSampler -> VAEDecodeAudio -> SaveAudio. SaveAudio escribe un .flac en la
carpeta output/<filename_prefix> del servidor ComfyUI.
Args:
ckpt_name: nombre del checkpoint ACE-Step tal como lo ve el servidor
(ej. "AUDIO_ace_step_v1_3.5b.safetensors"). Debe estar entre los que
devuelve comfyui_object_info en CheckpointLoaderSimple.
prompt: descripcion del sonido o estilo musical (va al campo "tags" de
TextEncodeAceStepAudio). Ej. "8-bit coin pickup sound, retro game".
lyrics: letra cantada para musica con voz. Vacio "" para SFX o musica
instrumental.
seconds: duracion del audio en segundos (min 1.0). Controla el tamano
del latente via EmptyAceStepLatentAudio.
seed: semilla del KSampler (cambia para variar el resultado).
steps: pasos de sampling del KSampler (50 recomendado para ACE-Step).
cfg: classifier-free guidance scale (5.0 recomendado para ACE-Step).
sampler_name: nombre del sampler (ej. "euler").
scheduler: scheduler del sampler (ej. "simple").
shift: shift del ModelSamplingSD3 aplicado al MODEL antes del sampling
(5.0 recomendado para ACE-Step). Mejora la coherencia temporal.
lyrics_strength: fuerza del condicionamiento de la letra (1.0 por
defecto; sin efecto practico cuando lyrics esta vacio).
filename_prefix: prefijo del .flac generado por SaveAudio en output/.
Returns:
dict en API format listo para comfyui_submit_workflow. Las claves son
node_ids ("3".."11") y cada valor tiene class_type + inputs.
"""
return {
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": ckpt_name},
},
"6": {
"class_type": "TextEncodeAceStepAudio",
"inputs": {
"clip": ["4", 1],
"tags": prompt,
"lyrics": lyrics,
"lyrics_strength": lyrics_strength,
},
},
"10": {
"class_type": "ConditioningZeroOut",
"inputs": {"conditioning": ["6", 0]},
},
"5": {
"class_type": "EmptyAceStepLatentAudio",
"inputs": {"seconds": seconds, "batch_size": 1},
},
"11": {
"class_type": "ModelSamplingSD3",
"inputs": {"model": ["4", 0], "shift": shift},
},
"3": {
"class_type": "KSampler",
"inputs": {
"seed": seed,
"steps": steps,
"cfg": cfg,
"sampler_name": sampler_name,
"scheduler": scheduler,
"denoise": 1.0,
"model": ["11", 0],
"positive": ["6", 0],
"negative": ["10", 0],
"latent_image": ["5", 0],
},
},
"8": {
"class_type": "VAEDecodeAudio",
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
},
"9": {
"class_type": "SaveAudio",
"inputs": {"filename_prefix": filename_prefix, "audio": ["8", 0]},
},
}
if __name__ == "__main__":
import json
wf = comfyui_build_audio_workflow(
ckpt_name="AUDIO_ace_step_v1_3.5b.safetensors",
prompt="8-bit coin pickup sound, retro game, short",
seconds=4.0,
seed=42,
)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,119 @@
---
name: comfyui_build_card_art_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_card_art_workflow(subject: str, *, card_style: str = \"fantasy trading card art\", checkpoint: str = \"IMG_juggernaut_xl_v11.safetensors\", width: int = 512, height: int = 768, hires: bool = True, seed: int = 0, lora: str | None = None, lora_strength: float = 1.0, negative: str | None = None, steps: int = 28, cfg: float = 7.0, sampler_name: str = \"dpmpp_2m\", scheduler: str = \"karras\", upscale_by: float = 1.5, hires_denoise: float = 0.4, upscale_model: str = \"4x_foolhardy_Remacri.pth\", filename_prefix: str = \"card_art\") -> dict"
description: "Construye el dict (API format) del workflow de UN arte de carta coleccionable (TCG) 2D: ilustracion central de una criatura/personaje/hechizo en formato vertical de carta (~512x768), composicion centrada y dramatica, dejando aire para que el motor/post anada marco/titulo/stats. Genera SOLO la ilustracion (el chrome de la carta NO). Compone comfyui_build_hires_fix_workflow (si hires) o comfyui_build_txt2img_workflow + comfyui_inject_lora (estilo opcional). Hermano de comfyui_build_portrait_avatar/item_icon/ui_hud_workflow. Pura, sin red ni I/O. class_types verificados contra /object_info (8GB lowvram)."
tags: [comfyui, ml, gamedev-2d, card, tcg, trading-card, illustration, workflow]
uses_functions: [comfyui_build_txt2img_workflow_py_ml, comfyui_build_hires_fix_workflow_py_ml, comfyui_inject_lora_py_ml]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: subject
desc: "Descripcion de la figura central de la carta (ej. 'a fire dragon breathing flames', 'an elven archer drawing a glowing bow', 'a frost elemental made of ice'). Se inserta en un prompt scaffold de carta. No puede estar vacio."
- name: card_style
desc: "Descriptor de estilo de la ilustracion que mantiene coherentes las cartas de un set (ej. 'fantasy trading card art', 'anime trading card art', 'realistic painted card art', 'dark gothic card art'). Pasa el MISMO card_style + checkpoint + lora a todas las cartas del set para coherencia visual. keyword-only."
- name: checkpoint
desc: "Checkpoint del servidor. 'IMG_juggernaut_xl_v11.safetensors' (SDXL, mejor detalle) por defecto; en 8GB lowvram con hires puede ser pesado: usa 'IMG_dreamshaper_8.safetensors' (SD1.5) y/o hires=False si la GPU se queda corta. keyword-only."
- name: width
desc: "Ancho del lienzo en px. Vertical de carta -> width < height. 512 por defecto. keyword-only."
- name: height
desc: "Alto del lienzo en px. 768 por defecto (retrato de carta). Para SDXL nativo, 768x1152 luce mejor. keyword-only."
- name: hires
desc: "Si True encadena la 2a pasada de detalle (UltimateSDUpscale + Remacri, re-difusion por tiles) sobre la base vertical (full art mas detallado). False deja la imagen tal cual sale del VAEDecode. keyword-only."
- name: seed
desc: "Semilla del KSampler (y de la pasada hires). Misma seed + mismo subject -> misma ilustracion. keyword-only."
- name: lora
desc: "LoRA de estilo opcional en models/loras (ej. 'SD15_detail_tweaker.safetensors', 'SDXL_anime_style.safetensors'). None = sin LoRA. Encadena estilo coherente entre cartas. keyword-only."
- name: lora_strength
desc: "Fuerza del LoRA sobre model y clip. Se clampa a [0.0, 2.0]. keyword-only."
- name: negative
desc: "Prompt negativo. None usa el negativo por defecto pensado para arte de carta (figura limpia, sin marco/borde/titulo/stats/texto/UI). keyword-only."
- name: steps
desc: "Pasos del KSampler (y de la pasada hires). keyword-only."
- name: cfg
desc: "CFG del KSampler (y de la pasada hires). keyword-only."
- name: sampler_name
desc: "Sampler del KSampler. keyword-only."
- name: scheduler
desc: "Scheduler del KSampler. keyword-only."
- name: upscale_by
desc: "Factor de ampliacion de la pasada hires sobre la base (1.5 -> 512x768 pasa a 768x1152). Solo si hires=True. keyword-only."
- name: hires_denoise
desc: "Fuerza de re-difusion de la pasada hires (0.4 por defecto: anade detalle sin alterar la composicion). Solo si hires=True. keyword-only."
- name: upscale_model
desc: "Modelo de upscale en models/upscale_models/ que usa la pasada hires ('4x_foolhardy_Remacri.pth'). Solo si hires=True. keyword-only."
- name: filename_prefix
desc: "Prefijo del PNG en output/. keyword-only."
output: "dict en API format listo para comfyui_submit_workflow: base vertical (hires-fix si hires, txt2img si no) con prompt scaffold de carta ('{subject}, {card_style}, dramatic lighting, detailed illustration, centered composition, full art, vertical card illustration, ...') + LoRA de estilo opcional. UNA carta; set coherente -> llamar por subject con mismo card_style/checkpoint/lora. El marco/titulo/stats los pone el motor/post, no este workflow."
tested: true
tests: ["golden hires: clases CheckpointLoaderSimple/KSampler/VAEDecode/UltimateSDUpscale/UpscaleModelLoader/SaveImage; subject + 'fantasy trading card art' + 'centered composition' + 'full art' + 'dramatic lighting' en prompt; base vertical 512x768 (width<height)", "edge hires=False: sin UltimateSDUpscale/UpscaleModelLoader, SaveImage <- VAEDecode", "edge dims: width=768/height=1152 reflejados, sigue vertical", "edge card_style en prompt", "edge lora: LoraLoader con strength, KSampler.model <- LoraLoader", "edge lora_strength clamp a [0,2]", "error subject vacio -> ValueError", "determinismo"]
test_file_path: "python/functions/ml/comfyui_build_card_art_workflow_test.py"
file_path: "python/functions/ml/comfyui_build_card_art_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_card_art_workflow import comfyui_build_card_art_workflow
# Arte de carta de un dragon de fuego, formato vertical, con detalle hires, listo para submit.
# En 8GB lowvram va holgado con SD1.5 (IMG_dreamshaper_8); SDXL+hires es mas pesado.
wf = comfyui_build_card_art_workflow(
"a fire dragon breathing flames",
card_style="fantasy trading card art",
checkpoint="IMG_dreamshaper_8.safetensors",
hires=True,
seed=7,
)
# Set coherente: misma firma de estilo para cada carta, varia solo subject.
# for s in ["a fire dragon breathing flames", "an elven archer", "a frost elemental"]:
# wf = comfyui_build_card_art_workflow(s, card_style="fantasy trading card art",
# checkpoint="IMG_dreamshaper_8.safetensors", seed=7)
# comfyui_submit_workflow(wf) # -> comfyui_wait_result -> comfyui_fetch_output_image
# El marco/titulo/stats los compone el motor de juego sobre la ilustracion resultante.
```
O lanzable directo con: `./fn run comfyui_build_card_art_workflow` (imprime nodos + class_types del ejemplo).
## Cuando usarla
Cuando necesites la ILUSTRACION central de una carta coleccionable (TCG/CCG estilo
Magic/Hearthstone/Yu-Gi-Oh): una criatura, personaje o hechizo en formato vertical
de carta, composicion centrada y dramatica. Pasa el MISMO `card_style` +
`checkpoint` + (`lora`) a todas las cartas del set para que combinen visualmente;
varia solo `subject`. Usa `hires=True` para "full art" detallado; `hires=False` para
iterar rapido. El marco, el titulo y los stats los pone el motor de juego o un paso
de post sobre la ilustracion — este builder NO los pinta.
## Gotchas
- **Genera SOLO la ilustracion, no el chrome de la carta**: el marco decorativo, la
barra de titulo, el cuadro de texto y los stats (ataque/defensa/coste) son
composicion del motor/post. El prompt scaffold empuja a "full art illustration" y
el negativo por defecto rechaza "card frame / border / text / stats / UI". Si
quieres el marco horneado en la imagen, pasa un `negative` propio sin esos terminos
y describe el marco en `subject`/`card_style` (no recomendado: el motor compone mejor).
- **Formato vertical = `width < height`**: una carta es mas alta que ancha. 512x768
(SD1.5) o 768x1152 (SDXL nativo). Si pones width>=height pierdes el encuadre de carta.
- **SDXL + hires es pesado en 8GB lowvram**: el default `IMG_juggernaut_xl_v11` con
`hires=True` re-difunde por tiles y puede dar OOM o ir muy lento. Si la GPU se queda
corta: baja a `checkpoint="IMG_dreamshaper_8.safetensors"` (SD1.5), pon `hires=False`, o
reduce `width/height`. Probado e2e en GPU con SD1.5 + hires (ver report 0153).
- **hires requiere UltimateSDUpscale + Remacri**: si el server responde HTTP 400
"node type not found: UltimateSDUpscale", falta el custom node; usa `hires=False`.
El `upscale_model` ('4x_foolhardy_Remacri.pth') debe existir en `models/upscale_models/`.
- **Coherencia del set = mismos parametros**: si cambias `card_style`/`checkpoint`/
`lora`/`seed` entre cartas, el set deja de combinar. Fija esos y varia solo `subject`.
- `hires_denoise` alto (>0.6) en la 2a pasada puede deformar la figura; 0.3-0.45 anade
detalle sin alterar la composicion.
- Es una funcion **pura**: solo arma el dict. La generacion real (GPU) la hacen
`comfyui_submit_workflow` + `comfyui_wait_result` + `comfyui_fetch_output_image`.
@@ -0,0 +1,230 @@
"""Construye el workflow ComfyUI de UN arte de carta coleccionable (TCG) en API format.
Ilustracion central de una carta de juego coleccionable (criatura, personaje o
hechizo): formato vertical de carta (~512x768) con la figura en composicion
centrada y dramatica, dejando aire alrededor para que el motor/post anada el
marco, el titulo y los stats. Es el builder hermano de
comfyui_build_portrait_avatar_workflow / comfyui_build_item_icon_workflow /
comfyui_build_ui_hud_workflow: mismo patron (PURO, dict API format) que compone
funciones existentes del registry, no reescribe el grafo.
IMPORTANTE alcance del builder: genera SOLO la ILUSTRACION de la carta. El marco
decorativo, la barra de titulo, el cuadro de texto y los stats (ataque/defensa/
coste) NO los pinta este workflow: son composicion del motor de juego o de un paso
de post sobre la ilustracion. El prompt scaffold empuja a "full art illustration"
y el negativo por defecto rechaza "card frame / border / text / stats / UI" para
mantener la ilustracion limpia y recortable.
Cableado segun los argumentos:
[hires] CheckpointLoaderSimple -> [LoraLoader si lora] -> KSampler (base) ->
VAEDecode -> UpscaleModelLoader + UltimateSDUpscale -> SaveImage
[plano] CheckpointLoaderSimple -> [LoraLoader si lora] -> KSampler ->
VAEDecode -> SaveImage
Compone:
- comfyui_build_hires_fix_workflow (si hires) -> base vertical + 2a pasada de
detalle por tiles (UltimateSDUpscale + Remacri); el arte de carta luce el
detalle fino de la ilustracion.
- comfyui_build_txt2img_workflow (si no hires) -> base txt2img vertical simple.
- comfyui_inject_lora -> LoRA de estilo opcional (fantasy / anime / realista)
para coherencia de estilo entre cartas de un mismo set.
Por que vertical (width < height): una carta TCG es mas alta que ancha; 512x768
(SD1.5) o 768x1152 (SDXL) da el encuadre de retrato de carta. La figura va centrada
con margen para el chrome de la carta.
Por que hires opcional: el arte de carta se mira de cerca (ilustracion "full art"),
asi que el detalle importa; hires re-difunde la imagen por tiles y anade detalle
real. En 8GB lowvram con SDXL puede ser pesado: bajar a SD1.5 o poner hires=False
si la GPU se queda corta.
class_types/inputs verificados contra /object_info del servidor (8GB lowvram) a
traves de los builders que compone (CheckpointLoaderSimple, CLIPTextEncode,
EmptyLatentImage, KSampler, VAEDecode, SaveImage, LoraLoader, UpscaleModelLoader,
UltimateSDUpscale).
Funcion pura: sin red, sin I/O. No muta dicts de entrada (los builders/inyectores
que compone trabajan sobre copias). Determinista para los mismos argumentos.
"""
from __future__ import annotations
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# Negativo por defecto pensado para arte de carta: una figura central bien formada,
# ilustracion limpia, SIN el chrome de la carta (marco/borde/titulo/stats/UI) ni
# texto/marcas, que son trabajo del motor/post, no de la ilustracion.
_CARD_NEGATIVE = (
"blurry, lowres, deformed, disfigured, bad anatomy, extra limbs, "
"extra fingers, mutated hands, ugly, card frame, card border, border, "
"title bar, text box, text, label, stats, numbers, UI, watermark, "
"signature, cropped, out of frame, jpeg artifacts"
)
def comfyui_build_card_art_workflow(
subject: str,
*,
card_style: str = "fantasy trading card art",
checkpoint: str = "IMG_juggernaut_xl_v11.safetensors",
width: int = 512,
height: int = 768,
hires: bool = True,
seed: int = 0,
lora: str | None = None,
lora_strength: float = 1.0,
negative: str | None = None,
steps: int = 28,
cfg: float = 7.0,
sampler_name: str = "dpmpp_2m",
scheduler: str = "karras",
upscale_by: float = 1.5,
hires_denoise: float = 0.4,
upscale_model: str = "4x_foolhardy_Remacri.pth",
filename_prefix: str = "card_art",
) -> dict:
"""Construye el dict (API format) del workflow de un arte de carta coleccionable.
Args:
subject: descripcion de la figura central de la carta (ej. "a fire dragon
breathing flames", "an elven archer drawing a glowing bow", "a frost
elemental made of ice"). Se inserta en un prompt scaffold de carta. No
puede estar vacio.
card_style: descriptor de estilo de la ilustracion que mantiene coherentes
las cartas de un set (ej. "fantasy trading card art", "anime trading
card art", "realistic painted card art", "dark gothic card art"). Pasa
el MISMO card_style + checkpoint + (lora) a todas las cartas del set
para coherencia visual. keyword-only.
checkpoint: checkpoint del servidor. 'IMG_juggernaut_xl_v11.safetensors' (SDXL,
mejor detalle a alta resolucion) por defecto; en 8GB lowvram puede ser
pesado con hires: si la GPU se queda corta, usa
'IMG_dreamshaper_8.safetensors' (SD1.5) y/o hires=False. keyword-only.
width: ancho del lienzo en px. Vertical de carta -> width < height. 512 por
defecto. keyword-only.
height: alto del lienzo en px. 768 por defecto (retrato de carta). Para
SDXL nativo, 768x1152 luce mejor (subir ambos). keyword-only.
hires: si True encadena la 2a pasada de detalle (UltimateSDUpscale + Remacri,
re-difusion por tiles) sobre la base vertical para una ilustracion mas
detallada (full art). False deja la imagen tal cual sale del VAEDecode.
keyword-only.
seed: semilla del KSampler (y de la pasada hires). Misma seed + mismo
subject -> misma ilustracion. keyword-only.
lora: LoRA de estilo opcional en models/loras (ej.
'SD15_detail_tweaker.safetensors', 'SDXL_anime_style.safetensors'). None =
sin LoRA. Encadena estilo coherente entre cartas. keyword-only.
lora_strength: fuerza del LoRA sobre model y clip. Se clampa a [0.0, 2.0].
keyword-only.
negative: prompt negativo. None usa el negativo por defecto pensado para
arte de carta (figura limpia, sin marco/titulo/stats/texto). keyword-only.
steps: pasos del KSampler (y de la pasada hires). keyword-only.
cfg: CFG del KSampler (y de la pasada hires). keyword-only.
sampler_name: sampler del KSampler. keyword-only.
scheduler: scheduler del KSampler. keyword-only.
upscale_by: factor de ampliacion de la pasada hires sobre la base (1.5 ->
512x768 pasa a 768x1152). Solo se usa si hires=True. keyword-only.
hires_denoise: fuerza de re-difusion de la pasada hires (0.4 por defecto:
anade detalle sin alterar la composicion). Solo si hires=True.
keyword-only.
upscale_model: modelo de upscale en models/upscale_models/ que usa la pasada
hires ('4x_foolhardy_Remacri.pth'). Solo si hires=True. keyword-only.
filename_prefix: prefijo del PNG generado en output/. keyword-only.
Returns:
dict en API format listo para comfyui_submit_workflow: base vertical
(hires-fix si hires, txt2img si no) con prompt scaffold de carta
('{subject}, {card_style}, dramatic lighting, detailed illustration,
centered composition, full art, ...') + LoRA de estilo opcional. Es UNA
carta; un set coherente -> llamar por subject con el mismo
card_style/checkpoint/(lora). El marco/titulo/stats los pone el motor/post,
no este workflow.
Raises:
ValueError: si subject esta vacio, o si los builders/inyectores que compone
no encuentran los nodos donde enganchar (propagado).
"""
if not subject or not subject.strip():
raise ValueError(
"comfyui_build_card_art_workflow: 'subject' no puede estar vacio"
)
subject = subject.strip()
lora_strength = max(0.0, min(2.0, float(lora_strength)))
neg = _CARD_NEGATIVE if negative is None else negative
# Prompt scaffold de carta: figura central, iluminacion dramatica, full art
# vertical, dejando aire para el chrome de la carta (que pone el motor/post).
positive = (
f"{subject}, {card_style}, dramatic lighting, detailed illustration, "
"centered composition, full art, vertical card illustration, "
"intricate detail, high quality"
)
if hires:
from ml.comfyui_build_hires_fix_workflow import (
comfyui_build_hires_fix_workflow,
)
wf = comfyui_build_hires_fix_workflow(
checkpoint,
positive,
neg,
first_pass=(width, height),
upscale_by=upscale_by,
denoise=hires_denoise,
steps=steps,
cfg=cfg,
seed=seed,
upscale_model=upscale_model,
sampler_name=sampler_name,
scheduler=scheduler,
filename_prefix=filename_prefix,
)
else:
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
wf = comfyui_build_txt2img_workflow(
checkpoint,
positive,
neg,
steps=steps,
cfg=cfg,
width=width,
height=height,
seed=seed,
sampler_name=sampler_name,
scheduler=scheduler,
filename_prefix=filename_prefix,
)
if lora:
from ml.comfyui_inject_lora import comfyui_inject_lora
wf = comfyui_inject_lora(
wf, lora, strength_model=lora_strength, strength_clip=lora_strength
)
return wf
if __name__ == "__main__":
import json
wf = comfyui_build_card_art_workflow(
"a fire dragon breathing flames",
card_style="fantasy trading card art",
checkpoint="IMG_dreamshaper_8.safetensors",
hires=True,
seed=7,
)
print(
json.dumps(
{
"nodes": list(wf),
"classes": sorted({n["class_type"] for n in wf.values()}),
},
indent=2,
)
)
@@ -0,0 +1,127 @@
"""Tests offline de comfyui_build_card_art_workflow (estructura del dict, sin GPU)."""
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ml.comfyui_build_card_art_workflow import ( # noqa: E402
comfyui_build_card_art_workflow,
)
def _classes(wf):
return sorted({n["class_type"] for n in wf.values()})
def _by_class(wf, cls):
return [n for n in wf.values() if n["class_type"] == cls]
def _positive(wf, needle):
return next(
n
for n in wf.values()
if n["class_type"] == "CLIPTextEncode" and needle in n["inputs"]["text"]
)
def test_golden_hires_recipe():
wf = comfyui_build_card_art_workflow(
"a fire dragon breathing flames",
card_style="fantasy trading card art",
checkpoint="IMG_dreamshaper_8.safetensors",
hires=True,
seed=7,
)
cls = _classes(wf)
# Cadena base + 2a pasada de detalle (hires).
assert "CheckpointLoaderSimple" in cls
assert "KSampler" in cls
assert "VAEDecode" in cls
assert "UltimateSDUpscale" in cls
assert "UpscaleModelLoader" in cls
assert "SaveImage" in cls
# El subject aparece en el prompt positivo con el scaffold de carta.
pos = _positive(wf, "a fire dragon breathing flames")
assert "fantasy trading card art" in pos["inputs"]["text"]
assert "centered composition" in pos["inputs"]["text"]
assert "full art" in pos["inputs"]["text"]
assert "dramatic lighting" in pos["inputs"]["text"]
# Formato vertical de carta: la base es width < height.
latent = _by_class(wf, "EmptyLatentImage")[0]["inputs"]
assert latent["width"] == 512
assert latent["height"] == 768
assert latent["width"] < latent["height"]
# Regresion: UltimateSDUpscale exige batch_size (input requerido segun
# /object_info). Sin el, el submit con hires=True fallaba con node_errors.
usd = _by_class(wf, "UltimateSDUpscale")[0]["inputs"]
assert usd["batch_size"] == 1
def test_edge_no_hires_plain_txt2img():
wf = comfyui_build_card_art_workflow(
"an elven archer", checkpoint="IMG_dreamshaper_8.safetensors", hires=False
)
cls = _classes(wf)
assert "UltimateSDUpscale" not in cls
assert "UpscaleModelLoader" not in cls
# SaveImage toma del VAEDecode directamente.
vd_id = next(nid for nid, n in wf.items() if n["class_type"] == "VAEDecode")
save = next(n for n in wf.values() if n["class_type"] == "SaveImage")
assert save["inputs"]["images"] == [vd_id, 0]
def test_edge_dims_reflected():
wf = comfyui_build_card_art_workflow(
"a frost elemental", width=768, height=1152, hires=False
)
latent = _by_class(wf, "EmptyLatentImage")[0]["inputs"]
assert latent["width"] == 768
assert latent["height"] == 1152
assert latent["width"] < latent["height"] # sigue siendo vertical
def test_edge_card_style_in_prompt():
wf = comfyui_build_card_art_workflow(
"a lich king", card_style="dark gothic card art", hires=False
)
pos = _positive(wf, "a lich king")
assert "dark gothic card art" in pos["inputs"]["text"]
def test_edge_lora_reflected():
wf = comfyui_build_card_art_workflow(
"a phoenix",
lora="SD15_detail_tweaker.safetensors",
lora_strength=0.9,
hires=False,
)
loras = _by_class(wf, "LoraLoader")
assert len(loras) == 1
assert loras[0]["inputs"]["lora_name"] == "SD15_detail_tweaker.safetensors"
assert loras[0]["inputs"]["strength_model"] == 0.9
# Con lora el KSampler.model ya NO viene del checkpoint directo.
ksampler = _by_class(wf, "KSampler")[0]
lora_id = next(nid for nid, n in wf.items() if n["class_type"] == "LoraLoader")
assert ksampler["inputs"]["model"] == [lora_id, 0]
def test_edge_lora_strength_clamped():
wf = comfyui_build_card_art_workflow(
"a golem", lora="x.safetensors", lora_strength=5.0, hires=False
)
loras = _by_class(wf, "LoraLoader")
assert loras[0]["inputs"]["strength_model"] == 2.0 # clamp a [0.0, 2.0]
def test_error_empty_subject():
with pytest.raises(ValueError):
comfyui_build_card_art_workflow(" ", hires=False)
def test_determinism():
a = comfyui_build_card_art_workflow("a dragon", hires=False, seed=3)
b = comfyui_build_card_art_workflow("a dragon", hires=False, seed=3)
assert a == b
@@ -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. 'IMG_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="IMG_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.

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