feat(ml): auto-commit con 20 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 01:26:38 +02:00
parent 1311c7e585
commit d7245efa59
20 changed files with 383 additions and 27 deletions
+5
View File
@@ -183,6 +183,11 @@ Para tunear nodo a nodo en vez del oneshot: `build_image_to_3d_workflow(image_na
`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 validan con `validate_workflow` antes de encolar.
- **Los 9 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.
- **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`).
@@ -38,9 +38,9 @@ params:
- 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: false
tests: []
test_file_path: ""
tested: true
tests: ["usa ControlNetLoader+ControlNetApply", "control_image, modelo cn y strength reflejados"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_controlnet_workflow.py"
file_path: "python/functions/ml/comfyui_build_controlnet_workflow.py"
---
@@ -40,9 +40,9 @@ params:
- name: filename_prefix
desc: "Prefijo del archivo de malla que SaveGLB escribe en output/ (ej. '3d_mesh' -> '3d_mesh_00001_.glb'). keyword-only."
output: "dict en API format con node_ids '1'..'9' como claves; cada valor tiene class_type + inputs. Listo para comfyui_submit_workflow. El nodo '9' (SaveGLB) produce el archivo .glb en el output del servidor."
tested: false
tests: []
test_file_path: ""
tested: true
tests: ["cadena de 9 nodos Hunyuan3D-2 nativos", "imagen, checkpoint, seed reflejados y SaveGLB presente"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_image_to_3d_workflow.py"
file_path: "python/functions/ml/comfyui_build_image_to_3d_workflow.py"
---
@@ -36,9 +36,9 @@ params:
- name: scheduler
desc: "Scheduler del sampler (ej. 'normal', 'karras'). keyword-only."
output: "dict en API format con node_ids como claves (CheckpointLoaderSimple '4', LoadImage '10', VAEEncode '11', CLIPTextEncode '6'/'7', KSampler '3', VAEDecode '8', SaveImage '9'). Listo para comfyui_submit_workflow."
tested: false
tests: []
test_file_path: ""
tested: true
tests: ["usa VAEEncode/LoadImage y no EmptyLatentImage", "denoise e init_image reflejados"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_img2img_workflow.py"
file_path: "python/functions/ml/comfyui_build_img2img_workflow.py"
---
@@ -38,9 +38,9 @@ params:
- name: scheduler
desc: "Scheduler del sampler (ej. 'normal', 'karras'). keyword-only."
output: "dict en API format con node_ids como claves (CheckpointLoaderSimple '4', LoadImage '10', LoadImageMask '12', VAEEncodeForInpaint '11', CLIPTextEncode '6'/'7', KSampler '3', VAEDecode '8', SaveImage '9'). Listo para comfyui_submit_workflow."
tested: false
tests: []
test_file_path: ""
tested: true
tests: ["usa LoadImageMask+VAEEncodeForInpaint", "imagen base, mascara, seed y denoise reflejados"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_inpaint_workflow.py"
file_path: "python/functions/ml/comfyui_build_inpaint_workflow.py"
---
@@ -36,9 +36,9 @@ params:
- name: height
desc: "Alto del latente/imagen en px (SDXL nativo 1024). keyword-only."
output: "dict en API format con node_ids como claves (CheckpointLoaderSimple base '4' y refiner '14', EmptyLatentImage '5', CLIPTextEncode base '6'/'7' y refiner '16'/'17', KSamplerAdvanced base '3' y refiner '15', VAEDecode '8', SaveImage '9'). Listo para comfyui_submit_workflow."
tested: false
tests: []
test_file_path: ""
tested: true
tests: ["dos KSamplerAdvanced encadenados", "base emite ruido sobrante y refiner lo recoge (start/end_at_step compartidos)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_sdxl_refiner_workflow.py"
file_path: "python/functions/ml/comfyui_build_sdxl_refiner_workflow.py"
---
@@ -38,9 +38,9 @@ params:
- name: filename_prefix
desc: "Prefijo del PNG que SaveImage escribe en output/. keyword-only."
output: "dict en API format con node_ids '3'..'9' como claves; cada valor tiene class_type + inputs. Listo para comfyui_submit_workflow."
tested: false
tests: []
test_file_path: ""
tested: true
tests: ["class_types esperados (6 nodos)", "params seed/steps/cfg/width/height reflejados", "filename_prefix en SaveImage"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_txt2img_workflow.py"
file_path: "python/functions/ml/comfyui_build_txt2img_workflow.py"
---
@@ -22,9 +22,9 @@ params:
- name: method
desc: "'model' (ESRGAN via UpscaleModelLoader + ImageUpscaleWithModel) o 'latent' (reescalado de pixel x2 con ImageScaleBy, sin modelo). keyword-only."
output: "dict en API format. Con method='model': LoadImage '10' + UpscaleModelLoader '12' + ImageUpscaleWithModel '13' + SaveImage '9'. Con method='latent': LoadImage '10' + ImageScaleBy '13' + SaveImage '9'. Listo para comfyui_submit_workflow."
tested: false
tests: []
test_file_path: ""
tested: true
tests: ["method='model' usa UpscaleModelLoader+ImageUpscaleWithModel", "method='latent' usa ImageScaleBy sin modelo"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_upscale_workflow.py"
file_path: "python/functions/ml/comfyui_build_upscale_workflow.py"
---
@@ -24,9 +24,9 @@ params:
- name: height
desc: "Alto del viewport del nodo en px. keyword-only."
output: "dict en API format con un unico nodo '1'. Con animation=False: class_type 'Load3D', inputs {model_file, image, width, height}. Con animation=True: class_type 'Load3DAdvanced', inputs {model_file, viewport_state, width, height}. Cargable con comfyui_load_workflow_ui (inyecta en la UI del navegador) o POSTeable a /prompt."
tested: false
tests: []
test_file_path: ""
tested: true
tests: ["Load3D simple con model_file/width/height", "animation=True usa Load3DAdvanced"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_view_3d_workflow.py"
file_path: "python/functions/ml/comfyui_build_view_3d_workflow.py"
---
+3 -3
View File
@@ -28,9 +28,9 @@ params:
- name: clip_node
desc: "node_id cuya salida CLIP (slot 1) alimentara el LoRA. Si None, se detecta la fuente que hoy alimenta los CLIPTextEncode.clip. keyword-only."
output: "copia del workflow con un nodo LoraLoader insertado (node_id = max id numerico + 1) y reconectado entre la fuente model/clip y sus consumidores."
tested: false
tests: []
test_file_path: ""
tested: true
tests: ["no muta el dict de entrada (pureza)", "inserta LoraLoader con strength correcto", "reconecta KSampler.model al LoRA"]
test_file_path: "python/functions/ml/tests/test_comfyui_inject_lora.py"
file_path: "python/functions/ml/comfyui_inject_lora.py"
---
@@ -0,0 +1,43 @@
"""Helper compartido por los tests de los builders de workflow ComfyUI.
Valida la invariante del "API format": un dict de nodos numerados, cada uno con
`class_type` + `inputs`, donde las conexiones entre nodos son listas
`[node_id, output_index]` que referencian un node_id existente.
"""
def assert_api_format(wf):
"""Comprueba que `wf` es un workflow ComfyUI en API format bien formado.
- dict no vacio
- cada nodo tiene `class_type` (str) e `inputs` (dict)
- toda conexion `[node_id, output_index]` apunta a un nodo existente
"""
assert isinstance(wf, dict) and wf, "workflow debe ser un dict no vacio"
ids = set(wf)
for nid, node in wf.items():
assert isinstance(node, dict), f"nodo {nid} no es dict"
assert isinstance(node.get("class_type"), str) and node["class_type"], (
f"nodo {nid} sin class_type"
)
assert isinstance(node.get("inputs"), dict), f"nodo {nid} sin inputs dict"
for key, val in node["inputs"].items():
if (
isinstance(val, list)
and len(val) == 2
and isinstance(val[0], str)
and isinstance(val[1], int)
):
assert val[0] in ids, (
f"nodo {nid}.{key} referencia node_id inexistente {val[0]!r}"
)
def class_types(wf):
"""Conjunto de los `class_type` presentes en el workflow."""
return {n["class_type"] for n in wf.values()}
def node_by_ct(wf, ct):
"""Primer nodo cuyo `class_type` es `ct` (lanza StopIteration si no hay)."""
return next(n for n in wf.values() if n["class_type"] == ct)
@@ -0,0 +1,30 @@
"""Tests de estructura para comfyui_build_controlnet_workflow (funcion pura)."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from ml.comfyui_build_controlnet_workflow import comfyui_build_controlnet_workflow
from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct
def test_estructura_controlnet():
wf = comfyui_build_controlnet_workflow("ck.safetensors", "ctrl.png", "cn.pth", "POS", "NEG")
assert_api_format(wf)
cts = class_types(wf)
assert "ControlNetLoader" in cts
assert "ControlNetApply" in cts
def test_control_image_modelo_y_strength():
wf = comfyui_build_controlnet_workflow(
"ck.safetensors", "pose.png", "control_openpose.pth", "POS", "NEG", strength=0.65, seed=5
)
assert node_by_ct(wf, "ControlNetLoader")["inputs"]["control_net_name"] == "control_openpose.pth"
# La imagen de control se carga via LoadImage.
assert node_by_ct(wf, "LoadImage")["inputs"]["image"] == "pose.png"
apply_in = node_by_ct(wf, "ControlNetApply")["inputs"]
assert apply_in["strength"] == 0.65
assert node_by_ct(wf, "KSampler")["inputs"]["seed"] == 5
@@ -0,0 +1,42 @@
"""Tests de estructura para comfyui_build_image_to_3d_workflow (funcion pura)."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from ml.comfyui_build_image_to_3d_workflow import comfyui_build_image_to_3d_workflow
from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct
def test_cadena_hunyuan3d_nativa():
wf = comfyui_build_image_to_3d_workflow("obj.png")
assert_api_format(wf)
# Los 9 nodos nativos de Hunyuan3D-2 (sin custom node).
esperado = {
"LoadImage",
"ImageOnlyCheckpointLoader",
"CLIPVisionEncode",
"Hunyuan3Dv2Conditioning",
"EmptyLatentHunyuan3Dv2",
"KSampler",
"VAEDecodeHunyuan3D",
"VoxelToMeshBasic",
"SaveGLB",
}
assert class_types(wf) == esperado
def test_imagen_checkpoint_y_salida_glb():
wf = comfyui_build_image_to_3d_workflow(
"robot.png", ckpt_name="hunyuan3d-dit-v2-mini.safetensors", seed=42
)
assert node_by_ct(wf, "LoadImage")["inputs"]["image"] == "robot.png"
assert (
node_by_ct(wf, "ImageOnlyCheckpointLoader")["inputs"]["ckpt_name"]
== "hunyuan3d-dit-v2-mini.safetensors"
)
assert node_by_ct(wf, "KSampler")["inputs"]["seed"] == 42
# SaveGLB es el nodo de salida: produce la malla .glb.
assert "SaveGLB" in class_types(wf)
@@ -0,0 +1,30 @@
"""Tests de estructura para comfyui_build_img2img_workflow (funcion pura)."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from ml.comfyui_build_img2img_workflow import comfyui_build_img2img_workflow
from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct
def test_estructura_y_class_types():
wf = comfyui_build_img2img_workflow("ck.safetensors", "init.png", "POS", "NEG")
assert_api_format(wf)
# img2img usa VAEEncode (no EmptyLatentImage) para partir de la imagen base.
cts = class_types(wf)
assert "VAEEncode" in cts
assert "LoadImage" in cts
assert "EmptyLatentImage" not in cts
def test_denoise_y_init_image():
wf = comfyui_build_img2img_workflow(
"ck.safetensors", "init.png", "POS", "NEG", denoise=0.45, seed=7
)
ks = node_by_ct(wf, "KSampler")["inputs"]
assert ks["denoise"] == 0.45
assert ks["seed"] == 7
assert node_by_ct(wf, "LoadImage")["inputs"]["image"] == "init.png"
@@ -0,0 +1,31 @@
"""Tests de estructura para comfyui_build_inpaint_workflow (funcion pura)."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from ml.comfyui_build_inpaint_workflow import comfyui_build_inpaint_workflow
from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct
def test_estructura_inpaint():
wf = comfyui_build_inpaint_workflow("ck.safetensors", "img.png", "mask.png", "POS", "NEG")
assert_api_format(wf)
cts = class_types(wf)
# Inpaint necesita la mascara y la codificacion para inpaint.
assert "LoadImageMask" in cts
assert "VAEEncodeForInpaint" in cts
def test_base_y_mascara_se_cargan():
wf = comfyui_build_inpaint_workflow(
"ck.safetensors", "base.png", "m.png", "POS", "NEG", seed=33, denoise=0.9
)
img = node_by_ct(wf, "LoadImage")["inputs"]["image"]
mask = node_by_ct(wf, "LoadImageMask")["inputs"]["image"]
assert img == "base.png"
assert mask == "m.png"
ks = node_by_ct(wf, "KSampler")["inputs"]
assert ks["seed"] == 33 and ks["denoise"] == 0.9
@@ -0,0 +1,33 @@
"""Tests de estructura para comfyui_build_sdxl_refiner_workflow (funcion pura)."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from ml.comfyui_build_sdxl_refiner_workflow import comfyui_build_sdxl_refiner_workflow
from _comfyui_wf_assert import assert_api_format, class_types
def test_dos_ksampler_advanced():
wf = comfyui_build_sdxl_refiner_workflow("base.st", "ref.st", "POS", "NEG")
assert_api_format(wf)
assert "KSamplerAdvanced" in class_types(wf)
ks = [n for n in wf.values() if n["class_type"] == "KSamplerAdvanced"]
assert len(ks) == 2
def test_base_emite_ruido_sobrante_y_refiner_lo_recoge():
wf = comfyui_build_sdxl_refiner_workflow(
"base.st", "ref.st", "POS", "NEG", base_steps=20, refiner_steps=5, seed=9
)
ks = [n["inputs"] for n in wf.values() if n["class_type"] == "KSamplerAdvanced"]
base = next(k for k in ks if k["add_noise"] == "enable")
refiner = next(k for k in ks if k["add_noise"] == "disable")
# El base arranca el ruido y deja el sobrante; el refiner lo termina sin add_noise.
assert base["return_with_leftover_noise"] == "enable"
assert refiner["return_with_leftover_noise"] == "disable"
# Comparten el corte de pasos: el base termina donde declara base_steps.
assert base["end_at_step"] == 20
assert refiner["start_at_step"] == 20
@@ -0,0 +1,43 @@
"""Tests de estructura para comfyui_build_txt2img_workflow (funcion pura)."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct
def test_estructura_y_class_types():
wf = comfyui_build_txt2img_workflow("ck.safetensors", "POS", "NEG")
assert_api_format(wf)
assert class_types(wf) == {
"CheckpointLoaderSimple",
"CLIPTextEncode",
"EmptyLatentImage",
"KSampler",
"VAEDecode",
"SaveImage",
}
def test_params_se_reflejan_en_los_nodos():
wf = comfyui_build_txt2img_workflow(
"ck.safetensors", "POS", "NEG", steps=8, cfg=6.0, width=640, height=480, seed=123
)
ks = node_by_ct(wf, "KSampler")["inputs"]
assert ks["seed"] == 123
assert ks["steps"] == 8
assert ks["cfg"] == 6.0
lat = node_by_ct(wf, "EmptyLatentImage")["inputs"]
assert lat["width"] == 640 and lat["height"] == 480
assert node_by_ct(wf, "CheckpointLoaderSimple")["inputs"]["ckpt_name"] == "ck.safetensors"
textos = sorted(n["inputs"]["text"] for n in wf.values() if n["class_type"] == "CLIPTextEncode")
assert textos == ["NEG", "POS"]
def test_filename_prefix_en_saveimage():
wf = comfyui_build_txt2img_workflow("ck.safetensors", "POS", filename_prefix="demo_run")
assert node_by_ct(wf, "SaveImage")["inputs"]["filename_prefix"] == "demo_run"
@@ -0,0 +1,28 @@
"""Tests de estructura para comfyui_build_upscale_workflow (funcion pura)."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from ml.comfyui_build_upscale_workflow import comfyui_build_upscale_workflow
from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct
def test_method_model_usa_esrgan():
wf = comfyui_build_upscale_workflow("img.png", model_name="4x-UltraSharp.pth", method="model")
assert_api_format(wf)
cts = class_types(wf)
assert "UpscaleModelLoader" in cts
assert "ImageUpscaleWithModel" in cts
assert node_by_ct(wf, "UpscaleModelLoader")["inputs"]["model_name"] == "4x-UltraSharp.pth"
def test_method_latent_no_usa_modelo():
wf = comfyui_build_upscale_workflow("img.png", method="latent")
assert_api_format(wf)
cts = class_types(wf)
assert "ImageScaleBy" in cts
assert "UpscaleModelLoader" not in cts
assert node_by_ct(wf, "LoadImage")["inputs"]["image"] == "img.png"
@@ -0,0 +1,26 @@
"""Tests de estructura para comfyui_build_view_3d_workflow (funcion pura)."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from ml.comfyui_build_view_3d_workflow import comfyui_build_view_3d_workflow
from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct
def test_load3d_simple():
wf = comfyui_build_view_3d_workflow("mesh.glb", width=800, height=600)
assert_api_format(wf)
# Visor minimo: un unico nodo Load3D.
assert class_types(wf) == {"Load3D"}
n = node_by_ct(wf, "Load3D")["inputs"]
assert n["model_file"] == "mesh.glb"
assert n["width"] == 800 and n["height"] == 600
def test_animation_usa_load3d_advanced():
wf = comfyui_build_view_3d_workflow("mesh.glb", animation=True)
assert class_types(wf) == {"Load3DAdvanced"}
assert node_by_ct(wf, "Load3DAdvanced")["inputs"]["model_file"] == "mesh.glb"
@@ -0,0 +1,45 @@
"""Tests de estructura y pureza para comfyui_inject_lora (funcion pura)."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
from ml.comfyui_inject_lora import comfyui_inject_lora
from _comfyui_wf_assert import assert_api_format, class_types
def _lora_node_id(wf):
return next(nid for nid, n in wf.items() if n["class_type"] == "LoraLoader")
def test_no_muta_la_entrada():
base = comfyui_build_txt2img_workflow("ck.safetensors", "POS", "NEG")
base_antes = {k: dict(v) for k, v in base.items()}
_ = comfyui_inject_lora(base, "style.safetensors")
# La copia profunda garantiza que el dict original queda intacto (pureza).
assert "LoraLoader" not in class_types(base)
assert set(base) == set(base_antes)
def test_inserta_loraloader_y_respeta_strength():
base = comfyui_build_txt2img_workflow("ck.safetensors", "POS", "NEG")
inj = comfyui_inject_lora(base, "style.safetensors", strength_model=0.7, strength_clip=0.4)
assert_api_format(inj)
assert "LoraLoader" in class_types(inj)
lid = _lora_node_id(inj)
lora_in = inj[lid]["inputs"]
assert lora_in["lora_name"] == "style.safetensors"
assert lora_in["strength_model"] == 0.7
assert lora_in["strength_clip"] == 0.4
def test_reconecta_el_ksampler_al_lora():
base = comfyui_build_txt2img_workflow("ck.safetensors", "POS", "NEG")
inj = comfyui_inject_lora(base, "style.safetensors")
lid = _lora_node_id(inj)
ks = next(n for n in inj.values() if n["class_type"] == "KSampler")
# El KSampler debe tomar el modelo del LoraLoader, no ya del checkpoint.
assert ks["inputs"]["model"][0] == lid