diff --git a/docs/capabilities/gamedev-2d.md b/docs/capabilities/gamedev-2d.md index 2c27c303..4bf0e88d 100644 --- a/docs/capabilities/gamedev-2d.md +++ b/docs/capabilities/gamedev-2d.md @@ -41,6 +41,7 @@ VFX (ver `reports/0143`). | `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_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 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, 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. 'juggernaut_xl_v11.safetensors' (SDXL, mejor detalle) por defecto; en 8GB lowvram con hires puede ser pesado: usa '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. 'detail_tweaker_sd15.safetensors', 'anime_style_xl.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 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 (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="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="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 `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="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`. diff --git a/python/functions/ml/comfyui_build_card_art_workflow.py b/python/functions/ml/comfyui_build_card_art_workflow.py new file mode 100644 index 00000000..794cc07c --- /dev/null +++ b/python/functions/ml/comfyui_build_card_art_workflow.py @@ -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 = "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. '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 + '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. + 'detail_tweaker_sd15.safetensors', 'anime_style_xl.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="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, + ) + ) diff --git a/python/functions/ml/comfyui_build_card_art_workflow_test.py b/python/functions/ml/comfyui_build_card_art_workflow_test.py new file mode 100644 index 00000000..24763ddc --- /dev/null +++ b/python/functions/ml/comfyui_build_card_art_workflow_test.py @@ -0,0 +1,123 @@ +"""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="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"] + + +def test_edge_no_hires_plain_txt2img(): + wf = comfyui_build_card_art_workflow( + "an elven archer", checkpoint="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="detail_tweaker_sd15.safetensors", + lora_strength=0.9, + hires=False, + ) + loras = _by_class(wf, "LoraLoader") + assert len(loras) == 1 + assert loras[0]["inputs"]["lora_name"] == "detail_tweaker_sd15.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