diff --git a/python/functions/ml/comfyui_build_asset_variant_workflow.md b/python/functions/ml/comfyui_build_asset_variant_workflow.md index 8fd54d6f..502cc894 100644 --- a/python/functions/ml/comfyui_build_asset_variant_workflow.md +++ b/python/functions/ml/comfyui_build_asset_variant_workflow.md @@ -49,7 +49,9 @@ params: - 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 +tested: true +tests: ["estructura img2img (LoadImage+VAEEncode, sin EmptyLatentImage)", "input_image/prompt reflejados en LoadImage y CLIPTextEncode positivo", "size por defecto inserta ImageScale a 512; size=None lo omite", "denoise se clampa a [0,1]", "filename_prefix/seed/lora opcional reflejados", "input_image o variant vacios -> ValueError", "determinismo: misma entrada -> mismo dict"] +test_file_path: "python/functions/ml/tests/test_comfyui_build_asset_variant_workflow.py" file_path: python/functions/ml/comfyui_build_asset_variant_workflow.py --- diff --git a/python/functions/ml/comfyui_build_directional_sprite_workflow.md b/python/functions/ml/comfyui_build_directional_sprite_workflow.md index 367d4052..d6a9182e 100644 --- a/python/functions/ml/comfyui_build_directional_sprite_workflow.md +++ b/python/functions/ml/comfyui_build_directional_sprite_workflow.md @@ -44,7 +44,9 @@ params: - name: filename_prefix desc: "Prefijo del archivo de salida del SaveImage. keyword-only." output: "dict en API format listo para comfyui_submit_workflow (claves = node_ids string, valores = class_type + inputs). SV3D: ImageOnlyCheckpointLoader + LoadImage + SV3D_Conditioning + VideoLinearCFGGuidance + KSampler + VAEDecode + SaveImage (los N frames del orbit). Zero123: ImageOnlyCheckpointLoader + LoadImage + StableZero123_Conditioning_Batched + KSampler + VAEDecode + SaveImage (un batch de directions vistas). El frame i (i-esima imagen del SaveImage, azimuth creciente desde la frontal) = direccion i de directional_sprite_view_order(directions). El modulo expone ademas directional_sprite_view_order(directions) -> lista de nombres de direccion alineada por indice con los frames." -tested: false +tested: true +tests: ["sv3d: estructura + orbit (video_frames=directions, size nativa 576)", "orbit_frames override", "zero123: StableZero123_Conditioning_Batched, azimuth equiespaciado, size 256", "cfg/ckpt por defecto segun modelo", "elevation/seed reflejados", "directional_sprite_view_order para 4/8/N", "errores: input vacio, model invalido, directions<1", "determinismo"] +test_file_path: "python/functions/ml/tests/test_comfyui_build_directional_sprite_workflow.py" file_path: "python/functions/ml/comfyui_build_directional_sprite_workflow.py" --- diff --git a/python/functions/ml/comfyui_build_grid.md b/python/functions/ml/comfyui_build_grid.md index f6e7ef78..ea1ba970 100644 --- a/python/functions/ml/comfyui_build_grid.md +++ b/python/functions/ml/comfyui_build_grid.md @@ -26,9 +26,9 @@ params: - name: labels desc: "rotulos opcionales, uno por imagen (mismo orden); reservan una franja bajo cada celda." output: "dict con ok (bool), out_path (str, ruta del PNG generado), rows (int, filas), cols (int, columnas), error (str, vacio si OK)." -tested: false -tests: [] -test_file_path: "" +tested: true +tests: ["grid basico: ok + out_path + cols/rows (ceil(sqrt(N)))", "cols explicito define filas", "cell define dimension del canvas", "labels reservan franja bajo cada celda", "error: lista vacia", "error: ruta inexistente", "determinismo del dict de salida"] +test_file_path: "python/functions/ml/tests/test_comfyui_build_grid.py" file_path: "python/functions/ml/comfyui_build_grid.py" --- diff --git a/python/functions/ml/comfyui_build_inpaint_asset_workflow.md b/python/functions/ml/comfyui_build_inpaint_asset_workflow.md index 8f717ae5..2b3e9d67 100644 --- a/python/functions/ml/comfyui_build_inpaint_asset_workflow.md +++ b/python/functions/ml/comfyui_build_inpaint_asset_workflow.md @@ -57,7 +57,9 @@ params: - name: filename_prefix desc: "Prefijo del archivo de salida en SaveImage. keyword-only." output: "dict en API format listo para comfyui_submit_workflow: inpaint que repinta SOLO la region marcada en blanco por la mascara, conservando el resto del asset, con grow_mask para difuminar la costura, escalado consistente opcional (img+mask) y LoRA de estilo opcional. Nodos modo vae_encode: CheckpointLoaderSimple '4', LoadImage '10', LoadImageMask '12', VAEEncodeForInpaint '11', CLIPTextEncode '6'/'7', KSampler '3', VAEDecode '8', SaveImage '9' (+ ImageScale/ImageToMask si size, + LoraLoader si lora). Modo noise_mask sustituye VAEEncodeForInpaint por VAEEncode + SetLatentNoiseMask (+ GrowMask)." -tested: false +tested: true +tests: ["estructura vae_encode (LoadImage+LoadImageMask+VAEEncodeForInpaint)", "prompt de region + grow_mask reflejados", "grow_mask se clampa a [0,64]", "mode noise_mask degrada a VAEEncode+SetLatentNoiseMask+GrowMask", "size inserta ImageScale a imagen y mascara + ImageToMask", "lora opcional + filename_prefix", "errores: input/mask/prompt vacios, mode invalido", "determinismo"] +test_file_path: "python/functions/ml/tests/test_comfyui_build_inpaint_asset_workflow.py" file_path: python/functions/ml/comfyui_build_inpaint_asset_workflow.py --- diff --git a/python/functions/ml/comfyui_build_outpaint_asset_workflow.md b/python/functions/ml/comfyui_build_outpaint_asset_workflow.md index df4a4a75..abb14792 100644 --- a/python/functions/ml/comfyui_build_outpaint_asset_workflow.md +++ b/python/functions/ml/comfyui_build_outpaint_asset_workflow.md @@ -55,7 +55,9 @@ params: - name: filename_prefix desc: "Prefijo del archivo de salida en SaveImage. keyword-only." output: "dict en API format listo para comfyui_submit_workflow: outpaint que extiende el lienzo por los lados pedidos y genera lo nuevo con '{prompt}, {style}, seamless extension...', conservando el asset original. Nodos: CheckpointLoaderSimple '4', LoadImage '10', ImagePadForOutpaint (id nuevo, reusa el '12' que libera el LoadImageMask eliminado), VAEEncodeForInpaint '11' (pixels <- pad IMAGE, mask <- pad MASK), CLIPTextEncode '6'/'7', KSampler '3', VAEDecode '8', SaveImage '9' (+ LoraLoader si lora). El LoadImageMask de la base inpaint se elimina: la mascara la GENERA el pad." -tested: false +tested: true +tests: ["estructura outpaint (ImagePadForOutpaint, sin LoadImageMask)", "pad cableado a VAEEncodeForInpaint (pixels<-IMAGE, mask<-MASK)", "extensiones redondeadas a multiplo de 8", "sin extension (todo 0 tras redondear) -> ValueError", "feather y prompt reflejados", "lora opcional + filename_prefix", "errores: input/prompt vacios", "determinismo"] +test_file_path: "python/functions/ml/tests/test_comfyui_build_outpaint_asset_workflow.py" file_path: python/functions/ml/comfyui_build_outpaint_asset_workflow.py --- diff --git a/python/functions/ml/comfyui_build_sprite_from_sketch_workflow.md b/python/functions/ml/comfyui_build_sprite_from_sketch_workflow.md index 0bddde13..2c8b0c7b 100644 --- a/python/functions/ml/comfyui_build_sprite_from_sketch_workflow.md +++ b/python/functions/ml/comfyui_build_sprite_from_sketch_workflow.md @@ -51,7 +51,9 @@ params: - name: filename_prefix desc: "Prefijo del archivo de salida en SaveImage. keyword-only." output: "dict en API format listo para comfyui_submit_workflow: txt2img base (CheckpointLoaderSimple '4', EmptyLatentImage '5', CLIPTextEncode '6'/'7', KSampler '3' denoise 1.0, VAEDecode '8', SaveImage '9') + rama ControlNet (LoadImage del boceto -> [Preprocessor del control_type si preprocess] -> ControlNetApply -> KSampler.positive, con ControlNetLoader del modelo CN) + LoraLoader si lora. Es UN sprite; varios objetos del mismo set -> llamar por subject/sketch_image con el mismo style/checkpoint/(lora)." -tested: false +tested: true +tests: ["estructura txt2img + ControlNet (EmptyLatentImage, ControlNetLoader/Apply)", "lineart: preprocesador + modelo por defecto, ControlNetApply consume el mapa de lineas", "canny: preprocesador + modelo", "preprocess=False pasa el boceto directo al ControlNet", "controlnet_name override + strength reflejado", "strength se clampa a [0,2]", "lora opcional", "errores: sketch/subject vacios, control_type invalido", "determinismo"] +test_file_path: "python/functions/ml/tests/test_comfyui_build_sprite_from_sketch_workflow.py" file_path: python/functions/ml/comfyui_build_sprite_from_sketch_workflow.py --- diff --git a/python/functions/ml/comfyui_critique_image_llm.md b/python/functions/ml/comfyui_critique_image_llm.md index 1f06cf03..48372417 100644 --- a/python/functions/ml/comfyui_critique_image_llm.md +++ b/python/functions/ml/comfyui_critique_image_llm.md @@ -26,9 +26,9 @@ params: - name: token desc: "Token OAuth; si vacio lo carga ask_llm_vision automaticamente. keyword-only." output: "dict {ok, verdict, score_0_10, reasons, error}. En exito ok=True, verdict 'good'|'bad', score_0_10 el score del modelo y reasons la lista de razones. En error (imagen invalida, API caida, 429, JSON no parseable) ok=False con error. Nunca lanza excepcion." -tested: false -tests: [] -test_file_path: "" +tested: true +tests: ["_extract_json: fence json", "_extract_json: brace plano", "_extract_json: sin objeto -> ValueError", "flujo: veredicto estructurado good", "verdict ambiguo -> bad conservador", "API caida -> ok=False", "respuesta no parseable -> ok=False"] +test_file_path: "python/functions/ml/tests/test_comfyui_critique_image_llm.py" file_path: "python/functions/ml/comfyui_critique_image_llm.py" --- diff --git a/python/functions/ml/comfyui_extract_recipe_from_png.md b/python/functions/ml/comfyui_extract_recipe_from_png.md index 98ce53b2..63ae3fd2 100644 --- a/python/functions/ml/comfyui_extract_recipe_from_png.md +++ b/python/functions/ml/comfyui_extract_recipe_from_png.md @@ -26,9 +26,9 @@ params: - name: nsfw desc: "Marca provenance.nsfw. keyword-only." output: "dict {ok, recipe, slug, has_workflow, error}. recipe sigue el schema minimo de comfyui_save_skill con provenance.source='civitai' y score_n=0. ok=False solo si no hay ni workflow embebido ni civitai_meta utilizable." -tested: false -tests: [] -test_file_path: "" +tested: true +tests: ["_slugify (normaliza y acota a 6 tokens)", "_loras_from_prompt", "_dims_from_prompt + _checkpoint_from_prompt", "_detect_base_workflow (flux/txt2img)", "_from_civitai_meta (mapea steps/cfg/size/modelo/prompts)", "flujo fallback a civitai_meta sin workflow embebido", "slug derivado del prompt", "error: sin workflow ni meta"] +test_file_path: "python/functions/ml/tests/test_comfyui_extract_recipe_from_png.py" file_path: "python/functions/ml/comfyui_extract_recipe_from_png.py" --- diff --git a/python/functions/ml/comfyui_flatten_alpha_on_color.md b/python/functions/ml/comfyui_flatten_alpha_on_color.md index 072085c4..2aae83be 100644 --- a/python/functions/ml/comfyui_flatten_alpha_on_color.md +++ b/python/functions/ml/comfyui_flatten_alpha_on_color.md @@ -26,9 +26,9 @@ params: - name: resample desc: "filtro de reescalado: 'lanczos' (por defecto), 'nearest', 'bilinear', 'bicubic', 'area'. String desconocido -> LANCZOS. keyword-only." output: "dict con ok (bool), out_path (str, ruta del PNG RGB; vacio si error), size ([w,h] final), error (str, vacio si OK)." -tested: false -tests: [] -test_file_path: "" +tested: true +tests: ["aplana transparente sobre blanco -> RGB sin alpha", "color de fondo personalizado", "size redimensiona a cuadrado", "out_path por defecto con sufijo _flat", "error: imagen inexistente", "determinismo (mismos bytes de salida)"] +test_file_path: "python/functions/ml/tests/test_comfyui_flatten_alpha_on_color.py" file_path: "python/functions/ml/comfyui_flatten_alpha_on_color.py" --- diff --git a/python/functions/ml/comfyui_import_workflow_json.md b/python/functions/ml/comfyui_import_workflow_json.md index cef3f55e..f8494bdd 100644 --- a/python/functions/ml/comfyui_import_workflow_json.md +++ b/python/functions/ml/comfyui_import_workflow_json.md @@ -22,9 +22,9 @@ params: - name: timeout desc: "Timeout HTTP en segundos. keyword-only." output: "dict {ok, workflow, format_detected, error}. workflow = dict en API format; format_detected = 'api' (passthrough) o 'ui_graph' (convertido) o ''. Si falla la lectura/parse, ok=False y error explica." -tested: false -tests: [] -test_file_path: "" +tested: true +tests: ["API format se devuelve tal cual (format=api)", "UI graph se normaliza a API (descarta Note, resuelve conexiones)", "JSON invalido -> error", "formato no reconocido -> error", "JSON no es objeto -> error", "archivo inexistente -> error", "determinismo"] +test_file_path: "python/functions/ml/tests/test_comfyui_import_workflow_json.py" file_path: "python/functions/ml/comfyui_import_workflow_json.py" --- diff --git a/python/functions/ml/comfyui_judge_image.md b/python/functions/ml/comfyui_judge_image.md index 7f7f8373..9bf28798 100644 --- a/python/functions/ml/comfyui_judge_image.md +++ b/python/functions/ml/comfyui_judge_image.md @@ -32,9 +32,9 @@ params: - name: venv_python desc: "Python del venv ComfyUI para los jueces estetico/fidelidad. keyword-only." output: "dict {ok, verdict, score, votes, reasons, error, details}. verdict 'good'|'bad' por mayoria; score media ponderada 0-10 de los jueces vivos; votes = {clip, aesthetic, llm} cada uno 'good'|'bad'|'failed'; reasons agrega razones del critico + notas de jueces caidos; details lleva el dict crudo de cada juez. ok=False solo si los tres fallan." -tested: false -tests: [] -test_file_path: "" +tested: true +tests: ["tres votos good -> verdict good + score medio", "mayoria bad", "empate -> bad conservador", "juez caido se excluye sin crashear", "los tres jueces fallan -> ok=False", "weights afectan score pero no el voto"] +test_file_path: "python/functions/ml/tests/test_comfyui_judge_image.py" file_path: "python/functions/ml/comfyui_judge_image.py" --- diff --git a/python/functions/ml/comfyui_read_png_metadata.md b/python/functions/ml/comfyui_read_png_metadata.md index 42c7a19a..d85135a2 100644 --- a/python/functions/ml/comfyui_read_png_metadata.md +++ b/python/functions/ml/comfyui_read_png_metadata.md @@ -18,9 +18,9 @@ params: - name: png_path desc: "Ruta local del PNG generado por ComfyUI." output: "dict {ok, prompt, parameters, error}. prompt = workflow API format embebido (dict); parameters = {model, seed, steps, cfg, sampler_name, scheduler, denoise, positive, negative} extraido del KSampler y nodos conectados; error = motivo si ok=False." -tested: false -tests: [] -test_file_path: "" +tested: true +tests: ["extrae prompt embebido + parametros del KSampler (seed/steps/cfg/sampler/scheduler/denoise/positive/negative/model)", "error: archivo inexistente", "error: PNG sin chunk prompt", "error: chunk prompt no es JSON", "error: no es un PNG valido", "determinismo"] +test_file_path: "python/functions/ml/tests/test_comfyui_read_png_metadata.py" file_path: "python/functions/ml/comfyui_read_png_metadata.py" --- diff --git a/python/functions/ml/comfyui_resolve_workflow_deps.md b/python/functions/ml/comfyui_resolve_workflow_deps.md index 1bb0b0a4..689dcaf6 100644 --- a/python/functions/ml/comfyui_resolve_workflow_deps.md +++ b/python/functions/ml/comfyui_resolve_workflow_deps.md @@ -20,9 +20,9 @@ params: - name: server desc: "host:port del servidor ComfyUI sin esquema. Debe estar vivo para consultar /object_info." output: "dict {ok, missing_nodes, missing_models, suggestions, error}. ok = se pudo consultar el servidor; missing_nodes = class_type ausentes (nodos custom); missing_models = lista de {node, input, value}; suggestions = lista de {kind, name, action, hint, ...} (una por nodo/modelo faltante) con la funcion a usar; error = motivo si ok=False." -tested: false -tests: [] -test_file_path: "" +tested: true +tests: ["traduce nodos y modelos faltantes en suggestions (install_custom_node / search_and_download)", "sin faltantes -> suggestions vacio", "servidor caido -> ok=False con error propagado"] +test_file_path: "python/functions/ml/tests/test_comfyui_resolve_workflow_deps.py" file_path: "python/functions/ml/comfyui_resolve_workflow_deps.py" --- diff --git a/python/functions/ml/comfyui_score_aesthetic.md b/python/functions/ml/comfyui_score_aesthetic.md index ec05c4a6..c933db8d 100644 --- a/python/functions/ml/comfyui_score_aesthetic.md +++ b/python/functions/ml/comfyui_score_aesthetic.md @@ -26,9 +26,9 @@ params: - name: timeout desc: "Timeout del subproceso en segundos (la primera vez puede descargar CLIP). keyword-only." output: "dict {ok, score_0_10, error}. En exito ok=True y score_0_10 es el score continuo (~1-10, mayor = mejor). En error ok=False, score_0_10=0.0 y error describe la causa. Nunca lanza excepcion." -tested: false -tests: [] -test_file_path: "" +tested: true +tests: ["error: imagen inexistente (guard previo al subproceso)", "error: python del venv ComfyUI ausente", "error: .pth del modelo ausente", "nunca lanza excepcion + determinismo del error"] +test_file_path: "python/functions/ml/tests/test_comfyui_score_aesthetic.py" file_path: "python/functions/ml/comfyui_score_aesthetic.py" --- diff --git a/python/functions/ml/comfyui_score_clip_alignment.md b/python/functions/ml/comfyui_score_clip_alignment.md index 397b8737..a7e57b5e 100644 --- a/python/functions/ml/comfyui_score_clip_alignment.md +++ b/python/functions/ml/comfyui_score_clip_alignment.md @@ -28,9 +28,9 @@ params: - name: timeout desc: "Timeout del subproceso en segundos. keyword-only." output: "dict {ok, score_0_1, error}. En exito ok=True y score_0_1 es la similitud coseno clamp a [0,1] (mayor = mas fiel al prompt; tipico 0.28-0.35 buen match, 0.10-0.18 distinto). En error ok=False, score_0_1=0.0 y error describe la causa. Nunca lanza excepcion." -tested: false -tests: [] -test_file_path: "" +tested: true +tests: ["error: imagen inexistente", "error: prompt vacio", "error: python del venv ComfyUI ausente", "nunca lanza excepcion + determinismo del error"] +test_file_path: "python/functions/ml/tests/test_comfyui_score_clip_alignment.py" file_path: "python/functions/ml/comfyui_score_clip_alignment.py" --- diff --git a/python/functions/ml/tests/test_comfyui_build_asset_variant_workflow.py b/python/functions/ml/tests/test_comfyui_build_asset_variant_workflow.py new file mode 100644 index 00000000..e4cbca0c --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_build_asset_variant_workflow.py @@ -0,0 +1,86 @@ +"""Tests de estructura/determinismo para comfyui_build_asset_variant_workflow (func pura, img2img).""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from ml.comfyui_build_asset_variant_workflow import comfyui_build_asset_variant_workflow +from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct + + +def _texts(wf): + return [n["inputs"].get("text", "") for n in wf.values() if n["class_type"] == "CLIPTextEncode"] + + +def test_estructura_img2img(): + # img2img: parte de una imagen (LoadImage + VAEEncode), NO de EmptyLatentImage. + wf = comfyui_build_asset_variant_workflow("enemy.png", "ice element") + assert_api_format(wf) + cts = class_types(wf) + for ct in ("CheckpointLoaderSimple", "LoadImage", "VAEEncode", "CLIPTextEncode", + "KSampler", "VAEDecode", "SaveImage"): + assert ct in cts, f"falta nodo {ct}" + assert "EmptyLatentImage" not in cts # img2img no genera desde ruido + + +def test_load_image_y_prompt_reflejados(): + wf = comfyui_build_asset_variant_workflow(" enemy_creature_00001_.png ", "fire element") + # input_image se strippea y llega al LoadImage. + assert node_by_ct(wf, "LoadImage")["inputs"]["image"] == "enemy_creature_00001_.png" + # el positivo contiene la variante + el refuerzo de composicion. + pos = [t for t in _texts(wf) if "same composition" in t] + assert pos and "fire element" in pos[0] + + +def test_size_default_inserta_imagescale(): + # size=512 por defecto -> normaliza la base con un ImageScale a 512x512. + wf = comfyui_build_asset_variant_workflow("enemy.png", "golden tier 2") + scale = node_by_ct(wf, "ImageScale")["inputs"] + assert scale["width"] == 512 and scale["height"] == 512 + + +def test_size_none_sin_imagescale(): + wf = comfyui_build_asset_variant_workflow("enemy.png", "frozen", size=None) + assert "ImageScale" not in class_types(wf) + + +def test_denoise_se_clampa(): + assert node_by_ct(comfyui_build_asset_variant_workflow("e.png", "v", denoise=2.0), + "KSampler")["inputs"]["denoise"] == 1.0 + assert node_by_ct(comfyui_build_asset_variant_workflow("e.png", "v", denoise=-1.0), + "KSampler")["inputs"]["denoise"] == 0.0 + assert node_by_ct(comfyui_build_asset_variant_workflow("e.png", "v", denoise=0.5), + "KSampler")["inputs"]["denoise"] == 0.5 + + +def test_filename_prefix_y_seed(): + wf = comfyui_build_asset_variant_workflow("e.png", "v", seed=123, filename_prefix="mio") + assert node_by_ct(wf, "SaveImage")["inputs"]["filename_prefix"] == "mio" + assert node_by_ct(wf, "KSampler")["inputs"]["seed"] == 123 + + +def test_lora_inyecta_loraloader(): + sin = comfyui_build_asset_variant_workflow("e.png", "v") + con = comfyui_build_asset_variant_workflow("e.png", "v", lora="SD15_dark.safetensors") + assert "LoraLoader" not in class_types(sin) + assert "LoraLoader" in class_types(con) + + +def test_input_image_vacio_lanza(): + with pytest.raises(ValueError): + comfyui_build_asset_variant_workflow(" ", "v") + + +def test_variant_vacio_lanza(): + with pytest.raises(ValueError): + comfyui_build_asset_variant_workflow("e.png", "") + + +def test_determinista(): + a = comfyui_build_asset_variant_workflow("e.png", "ice", seed=7, denoise=0.5) + b = comfyui_build_asset_variant_workflow("e.png", "ice", seed=7, denoise=0.5) + assert a == b diff --git a/python/functions/ml/tests/test_comfyui_build_directional_sprite_workflow.py b/python/functions/ml/tests/test_comfyui_build_directional_sprite_workflow.py new file mode 100644 index 00000000..b289a7f3 --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_build_directional_sprite_workflow.py @@ -0,0 +1,83 @@ +"""Tests de estructura/determinismo para comfyui_build_directional_sprite_workflow (func pura, 2.5D).""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from ml.comfyui_build_directional_sprite_workflow import ( + comfyui_build_directional_sprite_workflow, + directional_sprite_view_order, +) +from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct + + +def test_sv3d_estructura_y_orbit_default(): + wf = comfyui_build_directional_sprite_workflow("goblin.png", directions=8, model="sv3d") + assert_api_format(wf) + cts = class_types(wf) + for ct in ("LoadImage", "ImageOnlyCheckpointLoader", "SV3D_Conditioning", + "VideoLinearCFGGuidance", "KSampler", "VAEDecode", "SaveImage"): + assert ct in cts, f"falta nodo {ct}" + cond = node_by_ct(wf, "SV3D_Conditioning")["inputs"] + # video_frames default = directions; size nativa sv3d = 576. + assert cond["video_frames"] == 8 + assert cond["width"] == 576 and cond["height"] == 576 + + +def test_sv3d_orbit_frames_override(): + wf = comfyui_build_directional_sprite_workflow("g.png", directions=8, orbit_frames=21) + assert node_by_ct(wf, "SV3D_Conditioning")["inputs"]["video_frames"] == 21 + + +def test_zero123_estructura_y_azimuth(): + wf = comfyui_build_directional_sprite_workflow("g.png", directions=4, model="zero123") + assert_api_format(wf) + cts = class_types(wf) + assert "StableZero123_Conditioning_Batched" in cts + assert "SV3D_Conditioning" not in cts # camino distinto al sv3d + cond = node_by_ct(wf, "StableZero123_Conditioning_Batched")["inputs"] + # batch = directions; size nativa zero123 = 256; azimuth equiespaciado 360/N. + assert cond["batch_size"] == 4 + assert cond["width"] == 256 and cond["height"] == 256 + assert cond["azimuth_batch_increment"] == 90.0 + + +def test_cfg_y_ckpt_default_por_modelo(): + sv3d = comfyui_build_directional_sprite_workflow("g.png", model="sv3d") + z123 = comfyui_build_directional_sprite_workflow("g.png", model="zero123") + assert node_by_ct(sv3d, "KSampler")["inputs"]["cfg"] == 2.5 + assert node_by_ct(z123, "KSampler")["inputs"]["cfg"] == 4.0 + assert node_by_ct(sv3d, "ImageOnlyCheckpointLoader")["inputs"]["ckpt_name"] == "3D_sv3d_p.safetensors" + assert node_by_ct(z123, "ImageOnlyCheckpointLoader")["inputs"]["ckpt_name"] == "3D_stable_zero123.ckpt" + + +def test_elevation_y_seed_reflejados(): + wf = comfyui_build_directional_sprite_workflow("g.png", model="sv3d", elevation=15.0, seed=42) + assert node_by_ct(wf, "SV3D_Conditioning")["inputs"]["elevation"] == 15.0 + assert node_by_ct(wf, "KSampler")["inputs"]["seed"] == 42 + + +def test_view_order_helper(): + assert directional_sprite_view_order(8) == ["S", "SE", "E", "NE", "N", "NW", "W", "SW"] + assert directional_sprite_view_order(4) == ["S", "E", "N", "W"] + # N no canonico -> etiquetas por azimuth. + assert directional_sprite_view_order(6) == ["az0", "az60", "az120", "az180", "az240", "az300"] + + +def test_errores(): + with pytest.raises(ValueError): + comfyui_build_directional_sprite_workflow("") + with pytest.raises(ValueError): + comfyui_build_directional_sprite_workflow("g.png", model="turbo") + with pytest.raises(ValueError): + comfyui_build_directional_sprite_workflow("g.png", directions=0) + + +def test_determinista(): + a = comfyui_build_directional_sprite_workflow("g.png", directions=8, seed=7, elevation=15.0) + b = comfyui_build_directional_sprite_workflow("g.png", directions=8, seed=7, elevation=15.0) + assert a == b diff --git a/python/functions/ml/tests/test_comfyui_build_grid.py b/python/functions/ml/tests/test_comfyui_build_grid.py new file mode 100644 index 00000000..b77f0733 --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_build_grid.py @@ -0,0 +1,74 @@ +"""Tests offline para comfyui_build_grid (impura PIL: lee N imagenes -> PNG grid). + +Sin red, sin GPU, sin servidor: crea PNGs reales en un tmp_path y monta el grid. +""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from ml.comfyui_build_grid import comfyui_build_grid + +PIL = pytest.importorskip("PIL") +from PIL import Image # noqa: E402 + + +def _png(path, size=(64, 64), color=(120, 30, 30)): + Image.new("RGB", size, color).save(path) + return str(path) + + +def test_grid_basico(tmp_path): + paths = [_png(tmp_path / f"i{i}.png") for i in range(4)] + out = tmp_path / "grid.png" + res = comfyui_build_grid(paths, out_path=str(out)) + assert res["ok"] is True + assert res["error"] == "" + assert os.path.isfile(res["out_path"]) and res["out_path"] == str(out) + # 4 imagenes -> ceil(sqrt(4)) = 2 columnas, 2 filas. + assert res["cols"] == 2 and res["rows"] == 2 + + +def test_cols_explicito_y_filas(tmp_path): + paths = [_png(tmp_path / f"i{i}.png") for i in range(5)] + res = comfyui_build_grid(paths, cols=5, out_path=str(tmp_path / "g.png")) + assert res["cols"] == 5 and res["rows"] == 1 + + +def test_cell_define_dimension_del_canvas(tmp_path): + paths = [_png(tmp_path / f"i{i}.png") for i in range(2)] + res = comfyui_build_grid(paths, cols=2, cell=128, out_path=str(tmp_path / "g.png")) + with Image.open(res["out_path"]) as im: + # 2 columnas x 128 cell = 256 ancho; 1 fila x 128 = 128 alto. + assert im.size == (256, 128) + + +def test_labels_reservan_franja(tmp_path): + paths = [_png(tmp_path / f"i{i}.png") for i in range(2)] + res = comfyui_build_grid(paths, cols=2, cell=64, labels=["a", "b"], + out_path=str(tmp_path / "g.png")) + with Image.open(res["out_path"]) as im: + # con labels se reservan 22px bajo cada celda: alto = 64 + 22. + assert im.size == (128, 86) + + +def test_error_lista_vacia(): + res = comfyui_build_grid([]) + assert res["ok"] is False and "vacio" in res["error"] + + +def test_error_ruta_inexistente(tmp_path): + res = comfyui_build_grid([str(tmp_path / "no_existe.png")]) + assert res["ok"] is False and "no existen" in res["error"] + + +def test_determinista_mismo_dict(tmp_path): + paths = [_png(tmp_path / f"i{i}.png") for i in range(3)] + a = comfyui_build_grid(paths, out_path=str(tmp_path / "a.png")) + b = comfyui_build_grid(paths, out_path=str(tmp_path / "b.png")) + # rows/cols/ok son determableinistas para las mismas entradas. + assert (a["ok"], a["rows"], a["cols"]) == (b["ok"], b["rows"], b["cols"]) diff --git a/python/functions/ml/tests/test_comfyui_build_inpaint_asset_workflow.py b/python/functions/ml/tests/test_comfyui_build_inpaint_asset_workflow.py new file mode 100644 index 00000000..71233550 --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_build_inpaint_asset_workflow.py @@ -0,0 +1,78 @@ +"""Tests de estructura/determinismo para comfyui_build_inpaint_asset_workflow (func pura, inpaint).""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from ml.comfyui_build_inpaint_asset_workflow import comfyui_build_inpaint_asset_workflow +from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct + + +def _texts(wf): + return [n["inputs"].get("text", "") for n in wf.values() if n["class_type"] == "CLIPTextEncode"] + + +def test_estructura_vae_encode(): + wf = comfyui_build_inpaint_asset_workflow("asset.png", "mask.png", "a golden sword") + assert_api_format(wf) + cts = class_types(wf) + for ct in ("CheckpointLoaderSimple", "LoadImage", "LoadImageMask", + "VAEEncodeForInpaint", "CLIPTextEncode", "KSampler", "VAEDecode", "SaveImage"): + assert ct in cts, f"falta nodo {ct}" + + +def test_prompt_region_y_grow_mask(): + wf = comfyui_build_inpaint_asset_workflow("a.png", "m.png", "blue shield", grow_mask=8) + pos = [t for t in _texts(wf) if "seamless blend" in t] + assert pos and "blue shield" in pos[0] + assert node_by_ct(wf, "VAEEncodeForInpaint")["inputs"]["grow_mask_by"] == 8 + + +def test_grow_mask_se_clampa(): + wf = comfyui_build_inpaint_asset_workflow("a.png", "m.png", "p", grow_mask=999) + assert node_by_ct(wf, "VAEEncodeForInpaint")["inputs"]["grow_mask_by"] == 64 + + +def test_modo_noise_mask_degrada(): + # noise_mask reemplaza VAEEncodeForInpaint por VAEEncode + SetLatentNoiseMask (+ GrowMask). + wf = comfyui_build_inpaint_asset_workflow("a.png", "m.png", "p", mode="noise_mask", grow_mask=6) + cts = class_types(wf) + assert "VAEEncodeForInpaint" not in cts + assert "VAEEncode" in cts and "SetLatentNoiseMask" in cts and "GrowMask" in cts + + +def test_size_inserta_imagescale_a_imagen_y_mascara(): + # size en modo vae_encode escala imagen Y mascara de forma consistente. + wf = comfyui_build_inpaint_asset_workflow("a.png", "m.png", "p", size=768) + scales = [n for n in wf.values() if n["class_type"] == "ImageScale"] + assert len(scales) == 2 # una para la imagen, otra para la mascara + assert all(s["inputs"]["width"] == 768 and s["inputs"]["height"] == 768 for s in scales) + assert "ImageToMask" in class_types(wf) + + +def test_lora_y_filename(): + wf = comfyui_build_inpaint_asset_workflow("a.png", "m.png", "p", lora="x.safetensors", + filename_prefix="mio") + assert "LoraLoader" in class_types(wf) + assert node_by_ct(wf, "SaveImage")["inputs"]["filename_prefix"] == "mio" + + +def test_errores(): + with pytest.raises(ValueError): + comfyui_build_inpaint_asset_workflow("", "m.png", "p") + with pytest.raises(ValueError): + comfyui_build_inpaint_asset_workflow("a.png", "", "p") + with pytest.raises(ValueError): + comfyui_build_inpaint_asset_workflow("a.png", "m.png", "") + with pytest.raises(ValueError): + comfyui_build_inpaint_asset_workflow("a.png", "m.png", "p", mode="otro") + + +def test_determinista(): + a = comfyui_build_inpaint_asset_workflow("a.png", "m.png", "orb", seed=7, grow_mask=6) + b = comfyui_build_inpaint_asset_workflow("a.png", "m.png", "orb", seed=7, grow_mask=6) + assert a == b diff --git a/python/functions/ml/tests/test_comfyui_build_outpaint_asset_workflow.py b/python/functions/ml/tests/test_comfyui_build_outpaint_asset_workflow.py new file mode 100644 index 00000000..187718d5 --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_build_outpaint_asset_workflow.py @@ -0,0 +1,73 @@ +"""Tests de estructura/determinismo para comfyui_build_outpaint_asset_workflow (func pura, outpaint).""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from ml.comfyui_build_outpaint_asset_workflow import comfyui_build_outpaint_asset_workflow +from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct + + +def test_estructura_outpaint(): + wf = comfyui_build_outpaint_asset_workflow("bg.png", "more forest", right=256) + assert_api_format(wf) + cts = class_types(wf) + for ct in ("CheckpointLoaderSimple", "LoadImage", "ImagePadForOutpaint", + "VAEEncodeForInpaint", "CLIPTextEncode", "KSampler", "VAEDecode", "SaveImage"): + assert ct in cts, f"falta nodo {ct}" + # outpaint genera su mascara con el pad: NO usa LoadImageMask. + assert "LoadImageMask" not in cts + + +def test_pad_cableado_a_vaeencode(): + # VAEEncodeForInpaint toma pixels de la IMAGE del pad y mask de la MASK del pad. + wf = comfyui_build_outpaint_asset_workflow("bg.png", "sky", top=128) + pad_id = next(nid for nid, n in wf.items() if n["class_type"] == "ImagePadForOutpaint") + enc = node_by_ct(wf, "VAEEncodeForInpaint")["inputs"] + assert enc["pixels"] == [pad_id, 0] + assert enc["mask"] == [pad_id, 1] + + +def test_extensiones_redondeadas_a_8(): + # _round8 normaliza al multiplo de 8 mas cercano. + wf = comfyui_build_outpaint_asset_workflow("bg.png", "p", right=10) + pad = node_by_ct(wf, "ImagePadForOutpaint")["inputs"] + assert pad["right"] == 8 and pad["left"] == 0 and pad["top"] == 0 and pad["bottom"] == 0 + + +def test_sin_extension_lanza(): + # las cuatro extensiones a 0 (tras redondear) -> no hay nada que extender. + with pytest.raises(ValueError): + comfyui_build_outpaint_asset_workflow("bg.png", "p", left=3, right=2) + + +def test_feather_y_prompt(): + wf = comfyui_build_outpaint_asset_workflow("bg.png", "open sky", top=64, feather=30) + assert node_by_ct(wf, "ImagePadForOutpaint")["inputs"]["feathering"] == 30 + pos = [n["inputs"]["text"] for n in wf.values() + if n["class_type"] == "CLIPTextEncode" and "seamless extension" in n["inputs"].get("text", "")] + assert pos and "open sky" in pos[0] + + +def test_lora_y_filename(): + wf = comfyui_build_outpaint_asset_workflow("bg.png", "p", right=64, lora="x.safetensors", + filename_prefix="mio") + assert "LoraLoader" in class_types(wf) + assert node_by_ct(wf, "SaveImage")["inputs"]["filename_prefix"] == "mio" + + +def test_errores_vacios(): + with pytest.raises(ValueError): + comfyui_build_outpaint_asset_workflow("", "p", right=64) + with pytest.raises(ValueError): + comfyui_build_outpaint_asset_workflow("bg.png", "", right=64) + + +def test_determinista(): + a = comfyui_build_outpaint_asset_workflow("bg.png", "forest", right=256, seed=7) + b = comfyui_build_outpaint_asset_workflow("bg.png", "forest", right=256, seed=7) + assert a == b diff --git a/python/functions/ml/tests/test_comfyui_build_sprite_from_sketch_workflow.py b/python/functions/ml/tests/test_comfyui_build_sprite_from_sketch_workflow.py new file mode 100644 index 00000000..30907a82 --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_build_sprite_from_sketch_workflow.py @@ -0,0 +1,80 @@ +"""Tests de estructura/determinismo para comfyui_build_sprite_from_sketch_workflow (func pura, ControlNet).""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from ml.comfyui_build_sprite_from_sketch_workflow import comfyui_build_sprite_from_sketch_workflow +from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct + + +def test_estructura_txt2img_mas_controlnet(): + # txt2img (EmptyLatentImage, denoise alto) guiado por ControlNet atado al boceto. + wf = comfyui_build_sprite_from_sketch_workflow("sketch.png", "armored knight") + assert_api_format(wf) + cts = class_types(wf) + for ct in ("CheckpointLoaderSimple", "EmptyLatentImage", "CLIPTextEncode", "KSampler", + "VAEDecode", "SaveImage", "LoadImage", "ControlNetLoader", "ControlNetApply"): + assert ct in cts, f"falta nodo {ct}" + + +def test_lineart_default_preprocesador_y_modelo(): + wf = comfyui_build_sprite_from_sketch_workflow("s.png", "knight", control_type="lineart") + assert "LineArtPreprocessor" in class_types(wf) + assert node_by_ct(wf, "ControlNetLoader")["inputs"]["control_net_name"] == \ + "control_v11p_sd15_lineart_fp16.safetensors" + # el ControlNetApply consume el mapa de lineas del preprocesador, no el LoadImage directo. + pre_id = next(nid for nid, n in wf.items() if n["class_type"].endswith("Preprocessor")) + assert node_by_ct(wf, "ControlNetApply")["inputs"]["image"] == [pre_id, 0] + + +def test_canny_preprocesador_y_modelo(): + wf = comfyui_build_sprite_from_sketch_workflow("s.png", "chest", control_type="canny") + assert "CannyEdgePreprocessor" in class_types(wf) + assert node_by_ct(wf, "ControlNetLoader")["inputs"]["control_net_name"] == \ + "control_v11p_sd15_canny_fp16.safetensors" + + +def test_preprocess_false_pasa_boceto_directo(): + wf = comfyui_build_sprite_from_sketch_workflow("s.png", "k", preprocess=False) + assert not any(n["class_type"].endswith("Preprocessor") for n in wf.values()) + load_id = next(nid for nid, n in wf.items() if n["class_type"] == "LoadImage") + assert node_by_ct(wf, "ControlNetApply")["inputs"]["image"] == [load_id, 0] + + +def test_controlnet_name_override_y_strength(): + wf = comfyui_build_sprite_from_sketch_workflow( + "s.png", "k", control_type="lineart", + controlnet_name="control_v11p_sd15_canny_fp16.safetensors", strength=0.65) + assert node_by_ct(wf, "ControlNetLoader")["inputs"]["control_net_name"] == \ + "control_v11p_sd15_canny_fp16.safetensors" + assert node_by_ct(wf, "ControlNetApply")["inputs"]["strength"] == 0.65 + + +def test_strength_se_clampa(): + wf = comfyui_build_sprite_from_sketch_workflow("s.png", "k", strength=5.0) + assert node_by_ct(wf, "ControlNetApply")["inputs"]["strength"] == 2.0 + + +def test_lora_inyecta(): + assert "LoraLoader" in class_types( + comfyui_build_sprite_from_sketch_workflow("s.png", "k", lora="x.safetensors")) + + +def test_errores(): + with pytest.raises(ValueError): + comfyui_build_sprite_from_sketch_workflow("", "k") + with pytest.raises(ValueError): + comfyui_build_sprite_from_sketch_workflow("s.png", "") + with pytest.raises(ValueError): + comfyui_build_sprite_from_sketch_workflow("s.png", "k", control_type="depth") + + +def test_determinista(): + a = comfyui_build_sprite_from_sketch_workflow("s.png", "knight", seed=7, strength=0.8) + b = comfyui_build_sprite_from_sketch_workflow("s.png", "knight", seed=7, strength=0.8) + assert a == b diff --git a/python/functions/ml/tests/test_comfyui_critique_image_llm.py b/python/functions/ml/tests/test_comfyui_critique_image_llm.py new file mode 100644 index 00000000..080eed12 --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_critique_image_llm.py @@ -0,0 +1,62 @@ +"""Tests offline para comfyui_critique_image_llm (impura: critica LLM-vision via ask_llm_vision). + +Sin red, sin API: prueba el parser de JSON puro (_extract_json) y el flujo con ask_llm_vision +monkeypatcheado (veredicto estructurado, ambiguo->bad conservador, API caida, texto no parseable). +""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +import ml.comfyui_critique_image_llm as mod +from ml.comfyui_critique_image_llm import comfyui_critique_image_llm, _extract_json + + +def test_extract_json_fenced(): + txt = 'blah\n```json\n{"verdict": "good", "score": 8}\n```\nfin' + assert _extract_json(txt) == {"verdict": "good", "score": 8} + + +def test_extract_json_brace_plano(): + assert _extract_json(' {"verdict": "bad", "score": 2} ') == {"verdict": "bad", "score": 2} + + +def test_extract_json_sin_objeto_lanza(): + with pytest.raises(ValueError): + _extract_json("no hay json aqui") + + +def _fake_vision(text, ok=True): + return lambda user_prompt, image_path, **kw: {"ok": ok, "text": text, "error": "" if ok else "429"} + + +def test_flujo_veredicto_estructurado(monkeypatch): + monkeypatch.setattr(mod, "ask_llm_vision", + _fake_vision('{"verdict": "good", "score": 8.5, "reasons": ["nitida"]}')) + res = comfyui_critique_image_llm("i.png", "a cat") + assert res["ok"] is True + assert res["verdict"] == "good" and res["score_0_10"] == 8.5 + assert res["reasons"] == ["nitida"] + + +def test_verdict_ambiguo_cae_a_bad(monkeypatch): + monkeypatch.setattr(mod, "ask_llm_vision", + _fake_vision('{"verdict": "maybe", "score": 5}')) + res = comfyui_critique_image_llm("i.png", "p") + assert res["ok"] is True and res["verdict"] == "bad" # conservador ante ambiguo + + +def test_api_caida_ok_false(monkeypatch): + monkeypatch.setattr(mod, "ask_llm_vision", _fake_vision("", ok=False)) + res = comfyui_critique_image_llm("i.png", "p") + assert res["ok"] is False and res["error"] + + +def test_respuesta_no_parseable_ok_false(monkeypatch): + monkeypatch.setattr(mod, "ask_llm_vision", _fake_vision("lo siento, no puedo")) + res = comfyui_critique_image_llm("i.png", "p") + assert res["ok"] is False and "no parseable" in res["error"] diff --git a/python/functions/ml/tests/test_comfyui_extract_recipe_from_png.py b/python/functions/ml/tests/test_comfyui_extract_recipe_from_png.py new file mode 100644 index 00000000..1296fa1c --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_extract_recipe_from_png.py @@ -0,0 +1,86 @@ +"""Tests offline para comfyui_extract_recipe_from_png (impura: destila PNG -> receta de skill). + +Sin red, sin servidor: prueba los helpers puros de extraccion y el flujo de degradacion a la +`meta` de Civitai cuando el PNG no trae workflow embebido (PNG inexistente -> sin workflow). +""" + +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_extract_recipe_from_png import ( + comfyui_extract_recipe_from_png, + _slugify, + _loras_from_prompt, + _dims_from_prompt, + _checkpoint_from_prompt, + _detect_base_workflow, + _from_civitai_meta, +) + + +def test_slugify(): + assert _slugify("A Red Apple!", "fb") == "a_red_apple" + assert _slugify("", "fallback") == "fallback" + # acota a 6 tokens. + assert _slugify("one two three four five six seven eight", "fb").count("_") == 5 + + +def test_loras_from_prompt(): + prompt = {"7": {"class_type": "LoraLoader", + "inputs": {"lora_name": "style.safetensors", + "strength_model": 0.8, "strength_clip": 0.7}}} + loras = _loras_from_prompt(prompt) + assert loras == [{"name": "style.safetensors", "strength_model": 0.8, "strength_clip": 0.7}] + assert _loras_from_prompt({}) == [] + + +def test_dims_y_checkpoint_from_prompt(): + prompt = { + "1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "dream.safetensors"}}, + "5": {"class_type": "EmptyLatentImage", "inputs": {"width": 832, "height": 1216}}, + } + assert _dims_from_prompt(prompt) == {"width": 832, "height": 1216} + assert _checkpoint_from_prompt(prompt) == "dream.safetensors" + + +def test_detect_base_workflow(): + assert _detect_base_workflow({"1": {"class_type": "UNETLoader", "inputs": {}}}) == "flux" + assert _detect_base_workflow({"1": {"class_type": "CheckpointLoaderSimple", "inputs": {}}}) == "txt2img" + + +def test_from_civitai_meta(): + meta = {"steps": 25, "sampler": "Euler a", "Size": "832x1216", "seed": 7, + "cfgScale": 6.5, "Model": "mymodel", "prompt": "a cat", "negativePrompt": "blurry"} + out = _from_civitai_meta(meta) + assert out["checkpoint"] == "mymodel" + assert out["positive"] == "a cat" and out["negative"] == "blurry" + assert out["params"]["steps"] == 25 and out["params"]["cfg"] == 6.5 + assert out["params"]["width"] == 832 and out["params"]["height"] == 1216 + + +def test_flujo_fallback_civitai_meta(tmp_path): + # PNG inexistente -> sin workflow embebido; cae a la meta de Civitai (utilizable). + res = comfyui_extract_recipe_from_png( + str(tmp_path / "no.png"), + civitai_meta={"prompt": "a knight", "Model": "dream.safetensors", "steps": 20}) + assert res["ok"] is True + assert res["has_workflow"] is False + recipe = res["recipe"] + assert recipe["checkpoint"] == "dream.safetensors" + assert recipe["prompt_scaffold"]["positive"] == "a knight" + assert recipe["provenance"]["source"] == "civitai" and recipe["score_n"] == 0 + + +def test_slug_derivado_del_prompt(tmp_path): + res = comfyui_extract_recipe_from_png( + str(tmp_path / "no.png"), civitai_meta={"prompt": "Fire Goblin Warrior"}) + assert res["ok"] is True and res["slug"] == "fire_goblin_warrior" + + +def test_error_sin_workflow_ni_meta(tmp_path): + res = comfyui_extract_recipe_from_png(str(tmp_path / "no.png")) + assert res["ok"] is False and res["recipe"] == {} + assert "no trae workflow" in res["error"] diff --git a/python/functions/ml/tests/test_comfyui_flatten_alpha_on_color.py b/python/functions/ml/tests/test_comfyui_flatten_alpha_on_color.py new file mode 100644 index 00000000..8bfea82c --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_flatten_alpha_on_color.py @@ -0,0 +1,68 @@ +"""Tests offline para comfyui_flatten_alpha_on_color (impura PIL: aplana RGBA sobre fondo solido). + +Sin red, sin GPU, sin servidor: crea un PNG RGBA real y verifica el RGB resultante. +""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from ml.comfyui_flatten_alpha_on_color import comfyui_flatten_alpha_on_color + +PIL = pytest.importorskip("PIL") +from PIL import Image # noqa: E402 + + +def _rgba(path, size=(32, 32), color=(0, 0, 0, 0)): + Image.new("RGBA", size, color).save(path) + return str(path) + + +def test_aplana_transparente_sobre_blanco(tmp_path): + src = _rgba(tmp_path / "sprite.png", color=(0, 0, 0, 0)) # totalmente transparente + out = tmp_path / "flat.png" + res = comfyui_flatten_alpha_on_color(src, out_path=str(out), color=(255, 255, 255)) + assert res["ok"] is True and res["error"] == "" + with Image.open(res["out_path"]) as im: + assert im.mode == "RGB" # sin alpha + # sobre alpha 0 queda el fondo solido: blanco. + assert im.getpixel((0, 0)) == (255, 255, 255) + + +def test_color_de_fondo_personalizado(tmp_path): + src = _rgba(tmp_path / "s.png", color=(0, 0, 0, 0)) + res = comfyui_flatten_alpha_on_color(src, out_path=str(tmp_path / "o.png"), color=(10, 20, 30)) + with Image.open(res["out_path"]) as im: + assert im.getpixel((0, 0)) == (10, 20, 30) + + +def test_size_redimensiona_cuadrado(tmp_path): + src = _rgba(tmp_path / "s.png", size=(32, 16)) + res = comfyui_flatten_alpha_on_color(src, out_path=str(tmp_path / "o.png"), size=64) + assert res["size"] == [64, 64] + with Image.open(res["out_path"]) as im: + assert im.size == (64, 64) + + +def test_out_path_default_sufijo_flat(tmp_path): + src = _rgba(tmp_path / "sprite.png") + res = comfyui_flatten_alpha_on_color(src) # out_path None -> _flat.png + assert res["ok"] is True + assert res["out_path"].endswith("sprite_flat.png") + + +def test_error_imagen_inexistente(tmp_path): + res = comfyui_flatten_alpha_on_color(str(tmp_path / "no.png")) + assert res["ok"] is False and "no existe" in res["error"] + + +def test_determinista(tmp_path): + src = _rgba(tmp_path / "s.png", color=(5, 5, 5, 128)) + a = comfyui_flatten_alpha_on_color(src, out_path=str(tmp_path / "a.png"), color=(200, 0, 0)) + b = comfyui_flatten_alpha_on_color(src, out_path=str(tmp_path / "b.png"), color=(200, 0, 0)) + with Image.open(a["out_path"]) as ia, Image.open(b["out_path"]) as ib: + assert ia.tobytes() == ib.tobytes() diff --git a/python/functions/ml/tests/test_comfyui_import_workflow_json.py b/python/functions/ml/tests/test_comfyui_import_workflow_json.py new file mode 100644 index 00000000..0cbc92cb --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_import_workflow_json.py @@ -0,0 +1,88 @@ +"""Tests offline para comfyui_import_workflow_json (impura: lee disco/URL + normaliza a API format). + +Sin red, sin servidor: lee workflows desde archivos locales. Para el caso UI graph monkeypatchea +comfyui_object_info (devuelve None) para no consultar el servidor; se valida la resolucion de +conexiones y el descarte de nodos virtuales (Note). +""" + +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +import ml.comfyui_import_workflow_json as mod +from ml.comfyui_import_workflow_json import comfyui_import_workflow_json +from _comfyui_wf_assert import assert_api_format, class_types + + +_API = { + "1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m.safetensors"}}, + "2": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0], "vae": ["1", 2]}}, +} + +_UI_GRAPH = { + "nodes": [ + {"id": 1, "type": "CheckpointLoaderSimple", "inputs": [], "widgets_values": ["m.safetensors"]}, + {"id": 2, "type": "Note", "inputs": []}, + {"id": 3, "type": "VAEDecode", + "inputs": [{"name": "samples", "link": 10}, {"name": "vae", "link": 11}]}, + ], + "links": [ + [10, 1, 0, 3, 0, "LATENT"], + [11, 1, 2, 3, 1, "VAE"], + ], +} + + +def _write(tmp_path, name, obj): + p = tmp_path / name + p.write_text(json.dumps(obj)) + return str(p) + + +def test_api_format_se_devuelve_tal_cual(tmp_path): + res = comfyui_import_workflow_json(_write(tmp_path, "api.json", _API)) + assert res["ok"] is True and res["format_detected"] == "api" + assert res["workflow"] == _API + + +def test_ui_graph_se_normaliza(tmp_path, monkeypatch): + monkeypatch.setattr(mod, "comfyui_object_info", lambda server="", timeout=5.0: None) + res = comfyui_import_workflow_json(_write(tmp_path, "ui.json", _UI_GRAPH)) + assert res["ok"] is True and res["format_detected"] == "ui_graph" + api = res["workflow"] + assert_api_format(api) + # el nodo virtual Note se descarta; las conexiones del VAEDecode se resuelven al CheckpointLoader. + assert "Note" not in class_types(api) + assert "2" not in api + assert api["3"]["inputs"]["samples"] == ["1", 0] + assert api["3"]["inputs"]["vae"] == ["1", 2] + + +def test_json_invalido_error(tmp_path): + p = tmp_path / "bad.json" + p.write_text("no soy json {") + res = comfyui_import_workflow_json(str(p)) + assert res["ok"] is False and "JSON invalido" in res["error"] + + +def test_formato_no_reconocido(tmp_path): + res = comfyui_import_workflow_json(_write(tmp_path, "x.json", {"foo": "bar"})) + assert res["ok"] is False and "no reconocido" in res["error"] + + +def test_json_no_es_objeto(tmp_path): + res = comfyui_import_workflow_json(_write(tmp_path, "lst.json", [1, 2, 3])) + assert res["ok"] is False and "no es un objeto de workflow" in res["error"] + + +def test_archivo_inexistente(tmp_path): + res = comfyui_import_workflow_json(str(tmp_path / "no.json")) + assert res["ok"] is False and "no se pudo leer" in res["error"] + + +def test_determinista(tmp_path): + path = _write(tmp_path, "api.json", _API) + assert comfyui_import_workflow_json(path) == comfyui_import_workflow_json(path) diff --git a/python/functions/ml/tests/test_comfyui_judge_image.py b/python/functions/ml/tests/test_comfyui_judge_image.py new file mode 100644 index 00000000..cd3a2421 --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_judge_image.py @@ -0,0 +1,82 @@ +"""Tests offline para comfyui_judge_image (impura: panel multi-juez por mayoria). + +Sin GPU, sin red, sin servidor: monkeypatchea los tres jueces (estetico, fidelidad CLIP, +critico LLM) con stubs para probar la LOGICA de voto, agregacion y exclusion de jueces caidos. +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +import ml.comfyui_judge_image as mod +from ml.comfyui_judge_image import comfyui_judge_image + + +def _aes(score, ok=True): + return lambda image_path, **kw: {"ok": ok, "score_0_10": score, "error": "" if ok else "boom"} + + +def _clip(score, ok=True): + return lambda image_path, prompt, **kw: {"ok": ok, "score_0_1": score, "error": "" if ok else "boom"} + + +def _llm(verdict, score=7.0, ok=True): + return lambda image_path, prompt, **kw: { + "ok": ok, "verdict": verdict, "score_0_10": score, + "reasons": ["motivo"], "error": "" if ok else "boom"} + + +def _patch(monkeypatch, aes, clip, llm): + monkeypatch.setattr(mod, "comfyui_score_aesthetic", aes) + monkeypatch.setattr(mod, "comfyui_score_clip_alignment", clip) + monkeypatch.setattr(mod, "comfyui_critique_image_llm", llm) + + +def test_tres_good_verdict_good(monkeypatch): + _patch(monkeypatch, _aes(8.0), _clip(0.30), _llm("good")) + res = comfyui_judge_image("i.png", "a cat") + assert res["ok"] is True and res["verdict"] == "good" + assert res["votes"] == {"aesthetic": "good", "clip": "good", "llm": "good"} + # score = media de 8, 3.0(=0.30*10), 7 = 6.0 + assert abs(res["score"] - 6.0) < 1e-9 + + +def test_mayoria_bad(monkeypatch): + # estetico bajo (bad) + clip bajo (bad) + llm good -> 2 bad, 1 good -> bad. + _patch(monkeypatch, _aes(2.0), _clip(0.05), _llm("good")) + res = comfyui_judge_image("i.png", "p") + assert res["verdict"] == "bad" + + +def test_empate_es_bad_conservador(monkeypatch): + # 1 good (estetico) + 1 bad (clip) + 1 failed (llm) -> empate -> bad. + _patch(monkeypatch, _aes(8.0), _clip(0.05), _llm("good", ok=False)) + res = comfyui_judge_image("i.png", "p") + assert res["votes"]["llm"] == "failed" + assert res["verdict"] == "bad" + + +def test_juez_caido_se_excluye_no_crashea(monkeypatch): + # estetico falla pero el panel sigue votando con los otros dos. + _patch(monkeypatch, _aes(0.0, ok=False), _clip(0.30), _llm("good")) + res = comfyui_judge_image("i.png", "p") + assert res["ok"] is True + assert res["votes"]["aesthetic"] == "failed" + assert res["verdict"] == "good" + + +def test_tres_fallan_ok_false(monkeypatch): + _patch(monkeypatch, _aes(0.0, ok=False), _clip(0.0, ok=False), _llm("", ok=False)) + res = comfyui_judge_image("i.png", "p") + assert res["ok"] is False and "los tres jueces fallaron" in res["error"] + + +def test_weights_afectan_score_no_voto(monkeypatch): + _patch(monkeypatch, _aes(10.0), _clip(0.30), _llm("good", score=0.0)) + base = comfyui_judge_image("i.png", "p") + # subir el peso del estetico (10) y anular el del llm (0) sube el score agregado. + weighted = comfyui_judge_image("i.png", "p", weights={"aesthetic": 5.0, "llm": 0.0}) + assert weighted["score"] > base["score"] + assert weighted["verdict"] == base["verdict"] == "good" diff --git a/python/functions/ml/tests/test_comfyui_read_png_metadata.py b/python/functions/ml/tests/test_comfyui_read_png_metadata.py new file mode 100644 index 00000000..b628fc58 --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_read_png_metadata.py @@ -0,0 +1,80 @@ +"""Tests offline para comfyui_read_png_metadata (impura stdlib: parsea metadata de un PNG ComfyUI). + +Sin red, sin GPU, sin servidor: fabrica PNGs con chunk de texto 'prompt' y verifica el parsing. +""" + +import json +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from ml.comfyui_read_png_metadata import comfyui_read_png_metadata + +PIL = pytest.importorskip("PIL") +from PIL import Image # noqa: E402 +from PIL.PngImagePlugin import PngInfo # noqa: E402 + + +_PROMPT = { + "1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "model.safetensors"}}, + "2": {"class_type": "CLIPTextEncode", "inputs": {"text": "a cat on a table"}}, + "3": {"class_type": "CLIPTextEncode", "inputs": {"text": "blurry, lowres"}}, + "4": {"class_type": "KSampler", "inputs": { + "seed": 42, "steps": 20, "cfg": 7.0, "sampler_name": "euler", + "scheduler": "normal", "denoise": 1.0, + "positive": ["2", 0], "negative": ["3", 0], "model": ["1", 0], "latent_image": ["5", 0]}}, +} + + +def _png_with_prompt(path, prompt_obj=_PROMPT, text=None): + info = PngInfo() + info.add_text("prompt", text if text is not None else json.dumps(prompt_obj)) + Image.new("RGB", (8, 8), (0, 0, 0)).save(path, pnginfo=info) + return str(path) + + +def _png_plain(path): + Image.new("RGB", (8, 8), (0, 0, 0)).save(path) + return str(path) + + +def test_extrae_prompt_y_parametros(tmp_path): + res = comfyui_read_png_metadata(_png_with_prompt(tmp_path / "g.png")) + assert res["ok"] is True and res["error"] == "" + assert res["prompt"] == _PROMPT + p = res["parameters"] + assert p["seed"] == 42 and p["steps"] == 20 and p["cfg"] == 7.0 + assert p["sampler_name"] == "euler" and p["scheduler"] == "normal" and p["denoise"] == 1.0 + assert p["positive"] == "a cat on a table" and p["negative"] == "blurry, lowres" + assert p["model"] == "model.safetensors" + + +def test_error_archivo_inexistente(tmp_path): + res = comfyui_read_png_metadata(str(tmp_path / "no.png")) + assert res["ok"] is False and "no se pudo leer" in res["error"] + + +def test_error_png_sin_chunk_prompt(tmp_path): + res = comfyui_read_png_metadata(_png_plain(tmp_path / "plain.png")) + assert res["ok"] is False and "no contiene chunk 'prompt'" in res["error"] + + +def test_error_prompt_no_json(tmp_path): + res = comfyui_read_png_metadata(_png_with_prompt(tmp_path / "bad.png", text="no soy json {")) + assert res["ok"] is False and "no es JSON valido" in res["error"] + + +def test_error_no_es_png(tmp_path): + bad = tmp_path / "fake.png" + bad.write_bytes(b"esto no es un PNG") + res = comfyui_read_png_metadata(str(bad)) + assert res["ok"] is False and res["error"] + + +def test_determinista(tmp_path): + path = _png_with_prompt(tmp_path / "g.png") + assert comfyui_read_png_metadata(path) == comfyui_read_png_metadata(path) diff --git a/python/functions/ml/tests/test_comfyui_resolve_workflow_deps.py b/python/functions/ml/tests/test_comfyui_resolve_workflow_deps.py new file mode 100644 index 00000000..ceafe363 --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_resolve_workflow_deps.py @@ -0,0 +1,49 @@ +"""Tests offline para comfyui_resolve_workflow_deps (impura: compone comfyui_validate_workflow). + +Sin red, sin servidor: monkeypatchea comfyui_validate_workflow para probar la traduccion de +nodos/modelos faltantes en sugerencias accionables y el error path cuando el servidor no responde. +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +import ml.comfyui_resolve_workflow_deps as mod +from ml.comfyui_resolve_workflow_deps import comfyui_resolve_workflow_deps + +_WF = {"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "x.safetensors"}}} + + +def test_traduce_nodos_y_modelos_faltantes(monkeypatch): + monkeypatch.setattr(mod, "comfyui_validate_workflow", lambda wf, server="": { + "ok": True, + "missing_nodes": ["FooNode"], + "missing_models": [{"node": "1", "input": "ckpt_name", "value": "x.safetensors"}], + }) + res = comfyui_resolve_workflow_deps(_WF) + assert res["ok"] is True and res["error"] == "" + assert res["missing_nodes"] == ["FooNode"] + kinds = {s["kind"] for s in res["suggestions"]} + assert kinds == {"node", "model"} + node_sug = next(s for s in res["suggestions"] if s["kind"] == "node") + assert node_sug["action"] == "install_custom_node" and node_sug["name"] == "FooNode" + model_sug = next(s for s in res["suggestions"] if s["kind"] == "model") + assert model_sug["action"] == "search_and_download" and model_sug["name"] == "x.safetensors" + + +def test_sin_faltantes_suggestions_vacio(monkeypatch): + monkeypatch.setattr(mod, "comfyui_validate_workflow", lambda wf, server="": { + "ok": True, "missing_nodes": [], "missing_models": []}) + res = comfyui_resolve_workflow_deps(_WF) + assert res["ok"] is True and res["suggestions"] == [] + + +def test_servidor_caido_propaga_error(monkeypatch): + monkeypatch.setattr(mod, "comfyui_validate_workflow", lambda wf, server="": { + "ok": False, "error": "no se pudo conectar al servidor"}) + res = comfyui_resolve_workflow_deps(_WF) + assert res["ok"] is False + assert "no se pudo conectar" in res["error"] + assert res["suggestions"] == [] diff --git a/python/functions/ml/tests/test_comfyui_score_aesthetic.py b/python/functions/ml/tests/test_comfyui_score_aesthetic.py new file mode 100644 index 00000000..7b769a70 --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_score_aesthetic.py @@ -0,0 +1,51 @@ +"""Tests offline para comfyui_score_aesthetic (impura: scoring LAION-V2 via subproceso torch). + +Sin GPU, sin torch, sin servidor: ejercita SOLO los guards previos al subproceso (imagen, +python del venv ComfyUI y .pth del modelo ausentes), que cortan antes de tocar la GPU. +""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from ml.comfyui_score_aesthetic import comfyui_score_aesthetic + +PIL = pytest.importorskip("PIL") +from PIL import Image # noqa: E402 + + +def _png(path): + Image.new("RGB", (8, 8), (0, 0, 0)).save(path) + return str(path) + + +def test_error_imagen_inexistente(tmp_path): + res = comfyui_score_aesthetic(str(tmp_path / "no.png")) + assert res["ok"] is False and res["score_0_10"] == 0.0 + assert "imagen no encontrada" in res["error"] + + +def test_error_venv_python_inexistente(tmp_path): + # imagen valida pero venv_python ausente -> corta antes del subproceso. + res = comfyui_score_aesthetic(_png(tmp_path / "i.png"), + venv_python=str(tmp_path / "no_python")) + assert res["ok"] is False and "python del venv ComfyUI no encontrado" in res["error"] + + +def test_error_modelo_inexistente(tmp_path): + # imagen + python validos, .pth ausente -> error de modelo, sin lanzar el subproceso. + res = comfyui_score_aesthetic(_png(tmp_path / "i.png"), + venv_python=sys.executable, + model_path=str(tmp_path / "no.pth")) + assert res["ok"] is False and "modelo estetico no encontrado" in res["error"] + + +def test_nunca_lanza_y_es_determinista(tmp_path): + img = _png(tmp_path / "i.png") + a = comfyui_score_aesthetic(img, venv_python=str(tmp_path / "x")) + b = comfyui_score_aesthetic(img, venv_python=str(tmp_path / "x")) + assert a == b and a["ok"] is False diff --git a/python/functions/ml/tests/test_comfyui_score_clip_alignment.py b/python/functions/ml/tests/test_comfyui_score_clip_alignment.py new file mode 100644 index 00000000..d5eefe77 --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_score_clip_alignment.py @@ -0,0 +1,47 @@ +"""Tests offline para comfyui_score_clip_alignment (impura: similitud CLIP via subproceso torch). + +Sin GPU, sin torch, sin servidor: ejercita SOLO los guards previos al subproceso (imagen +ausente, prompt vacio, python del venv ComfyUI ausente). +""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from ml.comfyui_score_clip_alignment import comfyui_score_clip_alignment + +PIL = pytest.importorskip("PIL") +from PIL import Image # noqa: E402 + + +def _png(path): + Image.new("RGB", (8, 8), (0, 0, 0)).save(path) + return str(path) + + +def test_error_imagen_inexistente(tmp_path): + res = comfyui_score_clip_alignment(str(tmp_path / "no.png"), "a cat") + assert res["ok"] is False and res["score_0_1"] == 0.0 + assert "imagen no encontrada" in res["error"] + + +def test_error_prompt_vacio(tmp_path): + res = comfyui_score_clip_alignment(_png(tmp_path / "i.png"), " ") + assert res["ok"] is False and "prompt vacio" in res["error"] + + +def test_error_venv_python_inexistente(tmp_path): + res = comfyui_score_clip_alignment(_png(tmp_path / "i.png"), "a cat", + venv_python=str(tmp_path / "no_python")) + assert res["ok"] is False and "python del venv ComfyUI no encontrado" in res["error"] + + +def test_nunca_lanza_y_es_determinista(tmp_path): + img = _png(tmp_path / "i.png") + a = comfyui_score_clip_alignment(img, "a cat", venv_python=str(tmp_path / "x")) + b = comfyui_score_clip_alignment(img, "a cat", venv_python=str(tmp_path / "x")) + assert a == b and a["ok"] is False