diff --git a/docs/capabilities/gamedev-2d.md b/docs/capabilities/gamedev-2d.md index d34831a1..ad822611 100644 --- a/docs/capabilities/gamedev-2d.md +++ b/docs/capabilities/gamedev-2d.md @@ -68,15 +68,19 @@ VFX (ver `reports/0143`). ## Builders de transformación (`gamedev-2d`, puros — parten de una imagen/dibujo de entrada) A diferencia de los builders de **generación** de arriba (parten de TEXTO, txt2img desde -ruido), estos parten de una **imagen de entrada** y la transforman. Dos sub-ejes: +ruido), estos parten de una **imagen de entrada** y la transforman. Tres sub-ejes: - **img2img** (`asset_variant`): parte de un asset **ya pintado**; el KSampler arranca del latente de la imagen base (LoadImage → VAEEncode), no de ruido, así que con `denoise` medio - conserva la estructura mientras el prompt reescribe material/paleta/tier. Conserva forma **y** - color del original. + conserva la estructura mientras el prompt reescribe material/paleta/tier. Reescribe **todo** el + asset conservando forma **y** color del original. - **sketch→ControlNet** (`sprite_from_sketch`): parte del **dibujo tosco** del dev (boceto, lineart, garabato); es `txt2img` (arranca de ruido) pero condicionado por un ControlNet atado al mapa de líneas del dibujo. Conserva solo la **forma**; la IA pone material/color/acabado. +- **inpaint** (`inpaint_asset`): parte de un asset **ya pintado** + una **máscara** que marca qué + región editar (blanco) y cuál conservar (negro); el sampler regenera **solo** la zona enmascarada + dejando el resto del pixel intacto. Cambia **una parte** (arma, casco, escudo, reparación), no el + asset entero. Cubren el eje que el critic de generación (`reports/0178`) no exploró: derivar de un asset o del dibujo del dev, no inventar un tipo nuevo desde texto. @@ -85,6 +89,7 @@ del dibujo del dev, no inventar un tipo nuevo desde texto. |---|---|---| | `comfyui_build_asset_variant_workflow_py_ml` | `(input_image, variant, *, checkpoint="dreamshaper_8…", denoise=0.5, style="game asset", size=512, seed=0, lora=None, …) -> dict` | UNA **variante coherente de un asset 2D ya generado** (img2img): parte del sprite/icono que existe en `input_image` y produce su versión de **otro material/paleta/tier/estado** (`ice element`, `fire element`, `battle-damaged`, `golden tier 2`, `corrupted`) manteniendo **silueta, pose y composición** del original. Compone `comfyui_build_img2img_workflow` (LoadImage → VAEEncode → KSampler con `denoise`) + `comfyui_inject_lora` (estilo opcional) + `ImageScale` opcional (`size` normaliza la base a size×size; `size=None` preserva las dimensiones exactas sin deformar). El prompt es `{variant}, {style}, same composition, same pose, same silhouette, …`. **`denoise` es la palanca**: ~0.3 invisible, **0.45-0.6 recomendado** (cambia material/paleta, conserva forma), ~0.8 deriva la pose y se acerca a txt2img. Set de variantes del MISMO asset = mismo `input_image`/`style`/`seed`, varía solo `variant`. **DISTINTO de los builders txt2img** (`enemy_creature`, `item_icon`…): esos generan un tipo desde cero; éste transforma uno concreto. **NO inyecta Rembg** (img2img preserva el fondo/alpha del original según la base). ⚠️ la imagen base debe existir en `input/` del server (subir con `POST /upload/image`); pura, no valida (usar `comfyui_validate_workflow` antes de enviar); asset NO cuadrado + `size` fijo + `crop="disabled"` deforma → `size=None` o `crop="center"`. Probado e2e en GPU con SD1.5 — variante `ice element, frozen` del goblin `enemy_creature_00001_.png` denoise 0.5 seed 7 512×512 (`prompt_id 5e4a5d3d`): silueta conservada (luminance corr 0.63) + paleta a frío (blueness B−R −1.6→+1.9), `reports/0181`. SD1.5. | | `comfyui_build_sprite_from_sketch_workflow_py_ml` | `(sketch_image, subject, *, control_type="lineart", checkpoint="dreamshaper_8…", style="game asset, clean, centered", strength=0.8, size=512, seed=0, lora=None, preprocess=True, controlnet_name=None, …) -> dict` | UN **sprite pintado a partir del BOCETO del dev**, guiado por **ControlNet** (sub-eje sketch→ControlNet, **NO img2img**). Recibe el dibujo tosco que existe en `sketch_image` (boceto/lineart/garabato) + `subject` (qué es), y genera un sprite en estilo de juego que **conserva la forma dibujada**: el dev marca la silueta, la IA pone material/color/acabado. Mecanismo: `txt2img` base (ruido, `EmptyLatentImage`, `denoise 1.0`) cuyo positivo pasa por `ControlNetApply` atado al mapa de líneas del boceto. `control_type` elige el **preprocesador** (`LineArtPreprocessor` / `ScribblePreprocessor` / `CannyEdgePreprocessor`, interpuesto entre el boceto y el ControlNet por un helper) y, por defecto, el **modelo CN emparejado**. Compone `comfyui_build_txt2img_workflow` + `comfyui_inject_controlnet` + `comfyui_inject_lora` (estilo opcional). **`strength` es la palanca**: 0 = ignora el dibujo (txt2img puro), ~0.8 recomendado (respeta forma dejando limpiar a la IA), 1.0 = se ciñe estricto. **DISTINTO de `asset_variant`** (img2img conserva forma+color de una imagen ya pintada) y de los txt2img (`enemy_creature`…, inventan la forma desde texto): éste conserva **solo la forma** del dibujo. ⚠️ el boceto debe existir en `input/` (subir con `POST /upload/image`); pura, no valida (usar `comfyui_validate_workflow` antes de enviar); `preprocess=False` solo si el sketch ya es un lineart limpio. **GOTCHA del server 8GB: solo `canny`/`depth`/`openpose` SD1.5 instalados** — para `lineart`/`scribble` pasa `controlnet_name="control_v11p_sd15_canny_fp16.safetensors"` u usa `control_type="canny"` (out-of-the-box); pendiente humano descargar `control_v11p_sd15_lineart_fp16`/`scribble`. Probado e2e en GPU con SD1.5 — boceto del goblin `enemy_creature_00001_.png` → `CannyEdgePreprocessor` → ControlNet canny, `subject="dark fantasy goblin warrior"` strength 0.85 seed 123 512×512 (`prompt_id ea6fc372`): pose/orejas/hombrera/lanza dentada/espada del dibujo conservadas, repintado en estilo de juego, `reports/0182`. SD1.5. | +| `comfyui_build_inpaint_asset_workflow_py_ml` | `(input_image, mask_image, prompt, *, checkpoint="dreamshaper_8…", denoise=1.0, style="game asset", grow_mask=6, size=None, seed=0, lora=None, mode="vae_encode", …) -> dict` | EDITA **solo una región** de un asset 2D ya pintado (**inpaint**, sub-eje propio). Recibe el asset en `input_image` + una **máscara** `mask_image` (BLANCO = editar, NEGRO = conservar) + `prompt` de qué poner ahí, y repinta **únicamente** la zona enmascarada dejando el resto del sprite intacto (cambiar/añadir un arma, quitar un casco, poner un escudo, reparar una zona dañada). Mecanismo (`mode="vae_encode"`): `VAEEncodeForInpaint` codifica el latente respetando la máscara y dilata su borde `grow_mask` px para difuminar la costura; `KSampler` (`denoise` alto) regenera solo esa región con `{prompt}, {style}, seamless blend…`. Compone `comfyui_build_inpaint_workflow` (base) + `comfyui_inject_lora` (estilo opcional); `size` escala imagen **Y** máscara de forma consistente (escalar solo una las desalinea). **`grow_mask` es la palanca de costura** (6-10 px difumina el borde lo/nuevo); `denoise` 1.0 reescribe entero, ~0.5-0.7 repara suave. **DISTINTO de `asset_variant`** (img2img reescribe TODO el asset) y de `sprite_from_sketch` (ControlNet parte de un dibujo de líneas para un sprite nuevo): éste edita **un trozo** delimitado por la máscara. **ERROR-PATH**: si el server no expone `VAEEncodeForInpaint`, pasar `mode="noise_mask"` → degrada a `VAEEncode` + `SetLatentNoiseMask` (+ `GrowMask`); `mask_image` vacío lanza `ValueError`. ⚠️ asset y máscara deben existir en `input/` (subir con `POST /upload/image`) y compartir resolución (o usar `size`); `ImageScale` aquí NO ofrece `lanczos` (válidos `bilinear`/`nearest-exact`/`area`/`bicubic`); pura, no valida. Probado e2e en GPU con SD1.5 — máscara circular (R70) sobre la mano del goblin `enemy_creature_00001_.png`, `prompt="a glowing blue magic orb"` grow_mask 8 denoise 1.0 seed 7 (`prompt_id 88b52c66`): orbe azul en la región, **resto idéntico** (diff medio dentro 40.3 vs fuera 1.97 → ratio 20.4×; 44.6% px cambiados dentro vs 1.7% fuera), `reports/0183`. SD1.5. | ## Funciones de post-proceso y puente (`gamedev-2d`, CPU) diff --git a/python/functions/ml/comfyui_build_inpaint_asset_workflow.md b/python/functions/ml/comfyui_build_inpaint_asset_workflow.md new file mode 100644 index 00000000..b7384e8d --- /dev/null +++ b/python/functions/ml/comfyui_build_inpaint_asset_workflow.md @@ -0,0 +1,131 @@ +--- +name: comfyui_build_inpaint_asset_workflow +kind: function +lang: py +domain: ml +purity: pure +version: 1.0.0 +signature: "def comfyui_build_inpaint_asset_workflow(input_image: str, mask_image: str, prompt: str, *, checkpoint: str = \"dreamshaper_8.safetensors\", denoise: float = 1.0, style: str = \"game asset\", grow_mask: int = 6, size: int | None = None, seed: int = 0, lora: str | None = None, lora_strength: float = 1.0, mode: str = \"vae_encode\", negative: str | None = None, steps: int = 28, cfg: float = 7.0, sampler_name: str = \"dpmpp_2m\", scheduler: str = \"karras\", img_upscale_method: str = \"bilinear\", mask_upscale_method: str = \"nearest-exact\", crop: str = \"disabled\", filename_prefix: str = \"inpaint_asset\") -> dict" +description: "Construye el dict (API format) del workflow que EDITA SOLO UNA REGION de un asset 2D ya pintado (inpaint): recibe el asset + una mascara (blanco = editar, negro = conservar) + un prompt de que poner en esa zona, y repinta UNICAMENTE la region enmascarada dejando el resto del sprite intacto. Es el cuarto vertice del eje transform de gamedev-2d: distinto de txt2img (enemy_creature/item_icon, inventan la forma desde texto), de img2img (asset_variant, reescribe TODO el asset), y de ControlNet (sprite_from_sketch, pinta un sprite nuevo desde un boceto). Mecanismo (modo vae_encode): VAEEncodeForInpaint codifica el latente respetando la mascara y dilata su borde grow_mask px para difuminar la costura; el KSampler (denoise alto) regenera solo esa zona con '{prompt}, {style}, seamless blend'. Modo noise_mask degrada a VAEEncode + SetLatentNoiseMask (+ GrowMask) para servidores sin VAEEncodeForInpaint. size escala imagen Y mascara de forma consistente (escalar solo una las desalinearia). Compone comfyui_build_inpaint_workflow + comfyui_inject_lora (estilo opcional). Pura, sin red ni I/O. class_types verificados contra /object_info (8GB lowvram)." +tags: [comfyui, ml, gamedev-2d, inpaint, asset-transform, mask-edit, stable-diffusion, workflow] +uses_functions: [comfyui_build_inpaint_workflow_py_ml, comfyui_inject_lora_py_ml] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +params: + - name: input_image + desc: "Nombre del archivo del asset a editar dentro de la carpeta input/ del servidor ComfyUI (un sprite/icono YA pintado). Lo carga el nodo LoadImage. Subelo antes con POST /upload/image o copialo a ~/ComfyUI/input/. No puede estar vacio." + - name: mask_image + desc: "Nombre del archivo de la mascara dentro de input/ del servidor. BLANCO = region a editar/repintar, NEGRO = region a conservar. Debe tener la MISMA resolucion que input_image (salvo que pases size, que reescala ambas consistentemente). No puede estar vacio: sin mascara no es inpaint (para reescribir el asset entero usa comfyui_build_asset_variant_workflow)." + - name: prompt + desc: "Que poner en la region enmascarada ('a golden sword', 'a blue shield', 'empty background', 'intact armor'). Es lo unico que se regenera; el resto del asset se conserva. No puede estar vacio." + - name: checkpoint + desc: "Checkpoint del servidor. 'dreamshaper_8.safetensors' (SD1.5, holgado en 8GB lowvram) por defecto. keyword-only." + - name: denoise + desc: "Fuerza de denoising del KSampler dentro de la mascara. En inpaint suele ser alto: 1.0 (por defecto) reescribe la region por completo; <1.0 conserva parte de los pixeles originales (reparacion suave). Se clampa a [0.0, 1.0]. keyword-only." + - name: style + desc: "Descriptor de estilo que mantiene la region coherente con el asset y el set ('game asset', 'dark fantasy creature', 'pixel art'). Mismo style + checkpoint + (lora) que el resto del set. keyword-only." + - name: grow_mask + desc: "Pixeles que se dilata el borde de la mascara para difuminar la costura entre lo viejo y lo nuevo (evita bordes duros). Se clampa a [0, 64] (limite del nodo VAEEncodeForInpaint). En modo noise_mask se aplica via GrowMask. keyword-only." + - name: size + desc: "Lado en px al que se NORMALIZAN imagen Y mascara antes de inpaint. None (por defecto) = no escala: conserva la resolucion nativa del asset y exige que mascara e imagen ya coincidan en tamano (lo recomendado; los assets del set ya salen a 512). Un int reescala AMBAS a size x size de forma consistente. Solo aplica al modo vae_encode. keyword-only." + - name: seed + desc: "Semilla del KSampler. keyword-only." + - name: lora + desc: "LoRA de estilo opcional en models/loras (ej. 'dark_fantasy_sd15.safetensors'). None = sin LoRA. keyword-only." + - name: lora_strength + desc: "Fuerza del LoRA sobre model y clip. Se clampa a [0.0, 2.0]. keyword-only." + - name: mode + desc: "'vae_encode' (por defecto, VAEEncodeForInpaint con grow_mask nativo) o 'noise_mask' (degrada a VAEEncode + SetLatentNoiseMask + GrowMask para servidores sin VAEEncodeForInpaint). El caller decide tras consultar /object_info. keyword-only." + - name: negative + desc: "Prompt negativo. None usa el negativo por defecto pensado para edicion local (mezcla limpia, sin costuras ni bordes de mascara, una figura). keyword-only." + - name: steps + desc: "Pasos de sampling del KSampler. keyword-only." + - name: cfg + desc: "Classifier-free guidance scale. keyword-only." + - name: sampler_name + desc: "Nombre del sampler (ej. 'dpmpp_2m', 'euler'). keyword-only." + - name: scheduler + desc: "Scheduler del sampler (ej. 'karras', 'normal'). keyword-only." + - name: img_upscale_method + desc: "Metodo de ImageScale para la imagen cuando size no es None ('bilinear' por defecto; 'lanczos' NO esta disponible en este servidor). keyword-only." + - name: mask_upscale_method + desc: "Metodo de ImageScale para la mascara cuando size no es None ('nearest-exact' por defecto, preserva bordes nitidos blanco/negro de la mascara). keyword-only." + - name: crop + desc: "Modo de recorte de ImageScale ('disabled' o 'center'). Solo si size no es None. keyword-only." + - name: filename_prefix + desc: "Prefijo del archivo de salida en SaveImage. keyword-only." +output: "dict en API format listo para comfyui_submit_workflow: 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 +file_path: python/functions/ml/comfyui_build_inpaint_asset_workflow.py +--- + +Construye el dict (API format) del workflow que **edita SOLO una región** de un asset +2D ya pintado (inpaint). Cuarto vértice del eje **transform** de `gamedev-2d`, junto a +sus hermanos `comfyui_build_asset_variant_workflow` (img2img: reescribe todo) y +`comfyui_build_sprite_from_sketch_workflow` (ControlNet: pinta desde un boceto). Aquí +el dev tiene un sprite terminado y quiere cambiar **una parte** —ponerle otra arma, +quitarle el casco, añadir un escudo, reparar una zona dañada— dejando el resto del +pixel intacto. Una máscara delimita qué se repinta (blanco = editar, negro = conservar) +y el sampler solo regenera ahí. + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions")) +from ml.comfyui_build_inpaint_asset_workflow import comfyui_build_inpaint_asset_workflow +from ml.comfyui_submit_workflow import comfyui_submit_workflow +from ml.comfyui_wait_result import comfyui_wait_result + +# Editar SOLO la mano del goblin: poner un orbe mágico, conservar el resto del sprite. +# (input_image y mask_image deben estar ya subidos al input/ del server, misma resolución) +wf = comfyui_build_inpaint_asset_workflow( + "enemy_creature_00001_.png", # el goblin (512x512, ya en ~/ComfyUI/input/) + "goblin_hand_mask.png", # máscara: blanco sobre la mano, negro el resto + "a glowing blue magic orb", # qué poner en la región enmascarada + style="dark fantasy creature, game asset", + grow_mask=8, # difumina la costura 8 px + denoise=1.0, # reescribe la región por completo + seed=7, +) +resp = comfyui_submit_workflow(wf, server="127.0.0.1:8188") +out = comfyui_wait_result(resp["prompt_id"], server="127.0.0.1:8188") +# La imagen sale en ~/ComfyUI/output/inpaint_asset_*.png: SOLO la mano cambió. +``` + +## Cuando usarla + +Úsala cuando tengas un asset 2D **ya pintado** y quieras cambiar **una zona concreta** +sin regenerar el resto: cambiar/añadir un objeto que sostiene un personaje, quitar una +pieza de equipo, reparar una región dañada o limpiar un fondo detrás del sujeto. Elige +entre los hermanos del eje transform así: + +- **una región, el resto intacto** → este builder (inpaint con máscara). +- **todo el asset, mismo diseño/pose, otro material/tier** → `asset_variant` (img2img). +- **un sprite nuevo cuya silueta marca un boceto** → `sprite_from_sketch` (ControlNet). +- **un asset de cero desde texto** → `enemy_creature` / `item_icon` (txt2img). + +## Gotchas + +- **Necesita una máscara real.** `mask_image` es obligatorio (blanco = editar, negro = + conservar). Sin máscara no es inpaint: lanza `ValueError`. Para reescribir el asset + entero usa `asset_variant`. La máscara y el asset deben tener la **misma resolución** + (o pasa `size`, que reescala ambos de forma consistente; escalar solo uno los + desalinea y la edición cae en el sitio equivocado). +- **`grow_mask` evita costuras.** Sin dilatar el borde de la máscara aparece una línea + dura entre lo viejo y lo nuevo. 6–10 px suele bastar; se clampa a `[0, 64]`. +- **`denoise` alto por defecto (1.0).** La región se reescribe por completo. Baja a + ~0.5–0.7 para reparaciones suaves que conserven parte de los píxeles originales bajo + la máscara. +- **`mode="noise_mask"` es el plan B.** Si el servidor no expone `VAEEncodeForInpaint` + (compruébalo con `/object_info`), pásalo: arma el equivalente con `VAEEncode` + + `SetLatentNoiseMask` (+ `GrowMask`). El camino por defecto `vae_encode` da mejores + bordes y es el recomendado. +- **`ImageScale` no ofrece 'lanczos' en este servidor** (8GB lowvram): métodos válidos + `nearest-exact`, `bilinear`, `area`, `bicubic`. La máscara se escala con + `nearest-exact` por defecto para no difuminar sus bordes blanco/negro. +- **Función pura.** No sube ni valida que `input_image`/`mask_image`/`checkpoint`/`lora` + existan en el servidor: súbelos antes (`POST /upload/image`) y valida con + `comfyui_validate_workflow` si quieres atrapar nombres inexistentes antes de enviar. diff --git a/python/functions/ml/comfyui_build_inpaint_asset_workflow.py b/python/functions/ml/comfyui_build_inpaint_asset_workflow.py new file mode 100644 index 00000000..fcbf490c --- /dev/null +++ b/python/functions/ml/comfyui_build_inpaint_asset_workflow.py @@ -0,0 +1,422 @@ +"""Construye el workflow ComfyUI que EDITA SOLO UNA REGION de un asset ya pintado (inpaint). + +Es el cuarto vertice del eje `transform` del catalogo gamedev-2d. Los otros tres +parten de: + + - txt2img (enemy_creature, item_icon): inventan la forma desde texto en blanco. + - img2img (asset_variant): reescriben TODO el asset conservando silueta/pose. + - ControlNet (sprite_from_sketch): pintan un sprite desde la silueta de un boceto. + +Este builder cubre el dolor que ninguno resuelve: tienes un asset terminado y quieres +cambiar SOLO una parte -- ponerle otra arma, quitarle el casco, anadirle un escudo, +reparar una zona danada -- dejando el RESTO del pixel intacto. Eso es inpaint: una +mascara marca que region se repinta (blanco = editar, negro = conservar) y el sampler +solo regenera ahi. + +Diferencia clave con los hermanos: + + - vs asset_variant (img2img): variant reescribe el asset ENTERO con denoise medio; + aqui solo cambia la region enmascarada y el resto se preserva bit a bit fuera del + crecimiento de la mascara. + - vs sprite_from_sketch (ControlNet): sketch parte de un dibujo de lineas que guia + la forma de un sprite NUEVO; aqui se parte de un asset YA pintado y de una mascara + que delimita la zona a editar. + +Mecanismo (modo por defecto `vae_encode`): + + CheckpointLoaderSimple -> [LoraLoader opcional de estilo] -> KSampler + LoadImage(asset) -> [ImageScale opcional] ->\ + LoadImageMask(mask) ----------------------- > VAEEncodeForInpaint(grow_mask_by) -> KSampler.latent + CLIPTextEncode(prompt de la region + estilo + "seamless blend") -> KSampler.positive + KSampler(denoise alto) -> VAEDecode -> SaveImage + +`VAEEncodeForInpaint` codifica el latente respetando la mascara y dilata sus bordes +`grow_mask_by` pixeles para que la costura entre lo viejo y lo nuevo quede difuminada +(sin bordes duros visibles). En inpaint el `denoise` suele ser alto (1.0): la region +se reescribe por completo a partir del prompt; un denoise menor conserva parte de los +pixeles originales bajo la mascara (util para reparaciones suaves). + +Modo de degradacion `noise_mask` (para el error path): si el servidor NO expone +`VAEEncodeForInpaint`, el caller -- que SI puede consultar /object_info -- pide +`mode="noise_mask"` y el builder arma el camino equivalente con nodos basicos: + + LoadImage -> VAEEncode -> SetLatentNoiseMask(mask) -> KSampler.latent + +opcionalmente dilatando la mascara con `GrowMask(expand=grow_mask)` antes de aplicarla, +ya que `SetLatentNoiseMask` no crece la mascara por si mismo. + +Compone: + - comfyui_build_inpaint_workflow -> base inpaint (Checkpoint/LoadImage/LoadImageMask/ + VAEEncodeForInpaint/KSampler/VAEDecode/SaveImage). + - comfyui_inject_lora -> LoRA de estilo opcional (coherencia con el set). + +El unico codigo propio es: el prompt gamedev (region + estilo + mezcla limpia), el +repunte de `grow_mask_by` y `filename_prefix`, el escalado opcional CONSISTENTE de +imagen y mascara (escalar solo una las desalinearia), y la construccion del camino +`noise_mask` de degradacion. + +class_types/inputs verificados contra /object_info del servidor (8GB lowvram): +CheckpointLoaderSimple, LoadImage, LoadImageMask, ImageScale, ImageToMask, +VAEEncode, VAEEncodeForInpaint, SetLatentNoiseMask, GrowMask, CLIPTextEncode, +KSampler, VAEDecode, SaveImage, LoraLoader. Nota: ImageScale en este servidor NO +ofrece 'lanczos'; los metodos validos son 'nearest-exact', 'bilinear', 'area', +'bicubic'. + +Funcion pura: sin red, sin I/O. No muta dicts de entrada (copia profunda al insertar +nodos). NO valida que input_image/mask_image/checkpoint/lora existan en el servidor +(eso es responsabilidad del caller / comfyui_validate_workflow antes de enviar). +Determinista para los mismos argumentos. +""" +from __future__ import annotations + +import copy +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +# Negativo por defecto pensado para edicion local de un asset: la region nueva debe +# fundirse con el resto (sin costuras ni bordes de mascara visibles), sin duplicar el +# sujeto ni meter texto/marcas. NO restringe material/color (el prompt manda en la zona). +_INPAINT_ASSET_NEGATIVE = ( + "visible seam, visible mask edge, hard border, halo, blurry, lowres, " + "deformed, bad anatomy, extra limbs, duplicate, multiple subjects, " + "extra objects, text, watermark, signature, logo, jpeg artifacts" +) + +_VALID_MODES = ("vae_encode", "noise_mask") + + +def _new_id(wf: dict) -> str: + """Devuelve un node_id numerico libre (max id existente + 1).""" + numeric = [int(k) for k in wf.keys() if str(k).isdigit()] + return str((max(numeric) + 1) if numeric else len(wf) + 1) + + +def _find_class(wf: dict, class_type: str) -> str | None: + """Primer node_id cuyo class_type coincide exactamente, o None.""" + return next( + (nid for nid, n in wf.items() if n.get("class_type") == class_type), None + ) + + +def _scale_inpaint_to_size( + wf: dict, *, size: int, img_method: str, mask_method: str, crop: str +) -> dict: + """Escala imagen base Y mascara al mismo size para no desalinearlas. + + Inserta un ImageScale entre el LoadImage del asset y el VAEEncodeForInpaint, y + reemplaza el LoadImageMask por LoadImage(mask) -> ImageScale -> ImageToMask(red) + de modo que ambos lleguen al nodo de inpaint a size x size con el mismo recorte. + Escalar solo la imagen (dejando la mascara a su tamano original) produciria una + mascara que ya no cubre la region correcta: por eso aqui se escalan las DOS o + ninguna. Pura: trabaja sobre una copia profunda. + """ + wf = copy.deepcopy(wf) + enc_id = _find_class(wf, "VAEEncodeForInpaint") + if enc_id is None: + raise ValueError( + "comfyui_build_inpaint_asset_workflow: no se encontro VAEEncodeForInpaint " + "para escalar (size solo aplica al modo vae_encode)" + ) + enc = wf[enc_id]["inputs"] + img_src = list(enc.get("pixels", [])) + mask_link = list(enc.get("mask", [])) + if not img_src or not mask_link: + raise ValueError( + "comfyui_build_inpaint_asset_workflow: VAEEncodeForInpaint sin pixels/mask" + ) + + # Imagen base: LoadImage[0] -> ImageScale -> VAEEncodeForInpaint.pixels + scale_img = _new_id(wf) + wf[scale_img] = { + "class_type": "ImageScale", + "inputs": { + "image": img_src, + "upscale_method": img_method, + "width": int(size), + "height": int(size), + "crop": crop, + }, + } + enc["pixels"] = [scale_img, 0] + + # Mascara: el nodo fuente (LoadImageMask) se convierte en LoadImage para poder + # escalarla como imagen y volver a MASK con ImageToMask al mismo size. + mask_node_id = mask_link[0] + mask_node = wf[mask_node_id] + mask_file = mask_node["inputs"].get("image", "") + mask_node["class_type"] = "LoadImage" + mask_node["inputs"] = {"image": mask_file} + + scale_mask = _new_id(wf) + wf[scale_mask] = { + "class_type": "ImageScale", + "inputs": { + "image": [mask_node_id, 0], + "upscale_method": mask_method, + "width": int(size), + "height": int(size), + "crop": crop, + }, + } + to_mask = _new_id(wf) + wf[to_mask] = { + "class_type": "ImageToMask", + "inputs": {"image": [scale_mask, 0], "channel": "red"}, + } + enc["mask"] = [to_mask, 0] + return wf + + +def _to_noise_mask(wf: dict, *, grow_mask: int) -> dict: + """Reemplaza el camino VAEEncodeForInpaint por VAEEncode + SetLatentNoiseMask. + + Degradacion para servidores sin VAEEncodeForInpaint: codifica la imagen con un + VAEEncode normal y aplica la mascara al latente con SetLatentNoiseMask, opcional- + mente dilatandola antes con GrowMask(expand=grow_mask) porque SetLatentNoiseMask + no crece la mascara. Repunta KSampler.latent_image al nuevo SetLatentNoiseMask. + Pura: copia profunda. + """ + wf = copy.deepcopy(wf) + enc_id = _find_class(wf, "VAEEncodeForInpaint") + if enc_id is None: + raise ValueError( + "comfyui_build_inpaint_asset_workflow: base sin VAEEncodeForInpaint; " + "no se puede degradar a noise_mask" + ) + enc = wf[enc_id]["inputs"] + pixels = list(enc["pixels"]) + vae = list(enc["vae"]) + mask_link = list(enc["mask"]) + + # VAEEncode normal en el mismo node_id (reutiliza el id; cae el grow_mask_by). + wf[enc_id] = { + "class_type": "VAEEncode", + "inputs": {"pixels": pixels, "vae": vae}, + } + + mask_out = mask_link + if grow_mask and int(grow_mask) != 0: + grow_id = _new_id(wf) + wf[grow_id] = { + "class_type": "GrowMask", + "inputs": { + "mask": mask_link, + "expand": int(grow_mask), + "tapered_corners": True, + }, + } + mask_out = [grow_id, 0] + + setmask = _new_id(wf) + wf[setmask] = { + "class_type": "SetLatentNoiseMask", + "inputs": {"samples": [enc_id, 0], "mask": mask_out}, + } + + ks_id = _find_class(wf, "KSampler") + if ks_id is not None: + wf[ks_id]["inputs"]["latent_image"] = [setmask, 0] + return wf + + +def comfyui_build_inpaint_asset_workflow( + input_image: str, + mask_image: str, + prompt: str, + *, + checkpoint: str = "dreamshaper_8.safetensors", + denoise: float = 1.0, + style: str = "game asset", + grow_mask: int = 6, + size: int | None = None, + seed: int = 0, + lora: str | None = None, + lora_strength: float = 1.0, + mode: str = "vae_encode", + negative: str | None = None, + steps: int = 28, + cfg: float = 7.0, + sampler_name: str = "dpmpp_2m", + scheduler: str = "karras", + img_upscale_method: str = "bilinear", + mask_upscale_method: str = "nearest-exact", + crop: str = "disabled", + filename_prefix: str = "inpaint_asset", +) -> dict: + """Construye el dict (API format) de un inpaint que edita SOLO la region enmascarada. + + Args: + input_image: nombre del archivo del asset a editar dentro de la carpeta input/ + del servidor ComfyUI (un sprite/icono YA pintado). Lo carga LoadImage. + Subelo antes con POST /upload/image o copialo a ~/ComfyUI/input/. No puede + estar vacio. + mask_image: nombre del archivo de la mascara dentro de input/ del servidor. + BLANCO = region a editar/repintar, NEGRO = region a conservar. Debe tener + la MISMA resolucion que input_image (salvo que pases `size`, que reescala + ambas de forma consistente). No puede estar vacio: una edicion sin mascara + no es inpaint -- para reescribir el asset entero usa asset_variant. + prompt: que poner en la region enmascarada ("a golden sword", "a blue shield", + "empty background", "intact armor"). Es lo unico que se regenera; el resto + del asset se conserva. No puede estar vacio. + checkpoint: checkpoint del servidor. 'dreamshaper_8.safetensors' (SD1.5, holgado + en 8GB lowvram) por defecto. keyword-only. + denoise: fuerza de denoising del KSampler dentro de la mascara. En inpaint suele + ser alto: 1.0 (por defecto) reescribe la region por completo; <1.0 conserva + parte de los pixeles originales (reparacion suave). Se clampa a [0.0, 1.0]. + keyword-only. + style: descriptor de estilo que mantiene la region coherente con el asset y el + set ("game asset", "dark fantasy creature", "pixel art"). Pasa el MISMO + style + checkpoint + (lora) que el resto del set. keyword-only. + grow_mask: pixeles que se dilata el borde de la mascara para difuminar la + costura entre lo viejo y lo nuevo (evita bordes duros). Se clampa a [0, 64] + (limite del nodo VAEEncodeForInpaint). En modo noise_mask se aplica via + GrowMask. keyword-only. + size: lado en px al que se NORMALIZAN imagen Y mascara antes de inpaint. None + (por defecto) = no escala: el inpaint conserva la resolucion nativa del + asset y exige que mascara e imagen ya coincidan en tamano (lo recomendado; + los assets del set ya salen a 512). Un int reescala AMBAS a size x size de + forma consistente. Solo aplica al modo vae_encode. keyword-only. + seed: semilla del KSampler. keyword-only. + lora: LoRA de estilo opcional en models/loras (ej. 'dark_fantasy_sd15.safetensors'). + None = sin LoRA. keyword-only. + lora_strength: fuerza del LoRA sobre model y clip. Se clampa a [0.0, 2.0]. + keyword-only. + mode: 'vae_encode' (por defecto, usa VAEEncodeForInpaint con grow_mask nativo) + o 'noise_mask' (degrada a VAEEncode + SetLatentNoiseMask para servidores sin + VAEEncodeForInpaint). El caller decide el modo tras consultar /object_info. + keyword-only. + negative: prompt negativo. None usa el negativo por defecto pensado para edicion + local (mezcla limpia, sin costuras, una figura). keyword-only. + steps, cfg, sampler_name, scheduler, filename_prefix: parametros de generacion. + keyword-only. + img_upscale_method: metodo de ImageScale para la imagen cuando size no es None + ('bilinear' por defecto; 'lanczos' NO esta disponible en este servidor). + keyword-only. + mask_upscale_method: metodo de ImageScale para la mascara cuando size no es None + ('nearest-exact' por defecto, preserva bordes nitidos blanco/negro de la + mascara). keyword-only. + crop: modo de recorte de ImageScale ('disabled' o 'center'). keyword-only. + + Returns: + dict en API format listo para comfyui_submit_workflow: inpaint que repinta SOLO + la region marcada en blanco por la mascara con '{prompt}, {style}, seamless + blend...', conservando el resto del asset. Con grow_mask para difuminar la + costura, escalado consistente opcional y LoRA de estilo opcional. + + Raises: + ValueError: si input_image, mask_image o prompt estan vacios; si mode no es + 'vae_encode' ni 'noise_mask'; o si la base no tiene los nodos esperados + (propagado por los helpers). + """ + from ml.comfyui_build_inpaint_workflow import comfyui_build_inpaint_workflow + + if not input_image or not input_image.strip(): + raise ValueError( + "comfyui_build_inpaint_asset_workflow: 'input_image' no puede estar vacio" + ) + if not mask_image or not mask_image.strip(): + raise ValueError( + "comfyui_build_inpaint_asset_workflow: 'mask_image' no puede estar vacio " + "(sin mascara no es inpaint; para reescribir el asset entero usa " + "comfyui_build_asset_variant_workflow)" + ) + if not prompt or not prompt.strip(): + raise ValueError( + "comfyui_build_inpaint_asset_workflow: 'prompt' no puede estar vacio" + ) + if mode not in _VALID_MODES: + raise ValueError( + f"comfyui_build_inpaint_asset_workflow: 'mode' debe ser uno de {_VALID_MODES}, " + f"recibido {mode!r}" + ) + + input_image = input_image.strip() + mask_image = mask_image.strip() + prompt = prompt.strip() + denoise = max(0.0, min(1.0, float(denoise))) + grow_mask = max(0, min(64, int(grow_mask))) + lora_strength = max(0.0, min(2.0, float(lora_strength))) + neg = _INPAINT_ASSET_NEGATIVE if negative is None else negative + + # Prompt de la region: describe SOLO lo que va en la zona enmascarada y empuja a + # que se funda con el resto (mezcla limpia, iluminacion consistente). A diferencia + # de asset_variant NO se pide "same pose/silhouette": aqui solo cambia un trozo. + positive = ( + f"{prompt}, {style}, seamless blend with surroundings, " + "consistent lighting, matching art style, high detail" + ) + + wf = comfyui_build_inpaint_workflow( + checkpoint, + input_image, + mask_image, + positive, + neg, + denoise=denoise, + steps=steps, + cfg=cfg, + seed=seed, + sampler_name=sampler_name, + scheduler=scheduler, + ) + + # Repuntar grow_mask_by (el base lo deja en 6) y el filename_prefix (el base lo + # deja en 'comfy_inpaint'). + enc_id = _find_class(wf, "VAEEncodeForInpaint") + if enc_id is not None: + wf[enc_id]["inputs"]["grow_mask_by"] = grow_mask + save_id = _find_class(wf, "SaveImage") + if save_id is not None: + wf[save_id]["inputs"]["filename_prefix"] = filename_prefix + + if mode == "vae_encode" and size is not None: + wf = _scale_inpaint_to_size( + wf, + size=int(size), + img_method=img_upscale_method, + mask_method=mask_upscale_method, + crop=crop, + ) + + if mode == "noise_mask": + wf = _to_noise_mask(wf, grow_mask=grow_mask) + + 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_inpaint_asset_workflow( + "enemy_creature_00001_.png", + "goblin_hand_mask.png", + "a glowing blue magic orb", + style="dark fantasy creature, game asset", + grow_mask=8, + denoise=1.0, + seed=7, + ) + print( + json.dumps( + { + "nodes": list(wf), + "classes": sorted({n["class_type"] for n in wf.values()}), + "denoise": wf["3"]["inputs"]["denoise"], + "grow_mask_by": wf["11"]["inputs"]["grow_mask_by"], + "positive": wf["6"]["inputs"]["text"], + "input_image": wf["10"]["inputs"]["image"], + "mask": wf["12"]["inputs"]["image"], + "filename_prefix": wf["9"]["inputs"]["filename_prefix"], + }, + indent=2, + ) + )