diff --git a/docs/capabilities/gamedev-2d.md b/docs/capabilities/gamedev-2d.md index 08f25acd..643702f1 100644 --- a/docs/capabilities/gamedev-2d.md +++ b/docs/capabilities/gamedev-2d.md @@ -137,6 +137,54 @@ del dibujo del dev, no inventar un tipo nuevo desde texto. | `godot_map_asset_dir_py_core` | `(kind) -> str` | Mapea `kind` -> subcarpeta de `res://assets/`. Pura. | | `godot_clean_asset_name_py_core` | `(filename, *, override=None) -> str` | Normaliza el nombre `_NNNNN_.` a snake_case seguro para `res://`. Pura. | +## Estilos (style presets) — calidad por ESTILO reutilizable + +Un *style preset* es la receta curada de un look visual que se aplica a **TODOS** los +assets de un juego de una vez ("todo en Game Boy", "estilo Ghibli", "pixel-art retro"). +En vez de repetir a mano `style`/`checkpoint`/`lora`/`negative` + post-proceso en cada +builder, el preset los empaqueta como DATOS puros y el helper los traduce a los kwargs de +cualquier builder de sujeto (item_icon, enemy_creature, prop_object, …) o del pipeline +`comfyui_generate_asset_pack_oneshot`. Diseño (issue 0087): función pura de presets + +helper de aplicación (NO un pipeline monolítico) — máxima composabilidad, sin acoplar +firmas. Extensible: añadir un estilo = una entrada en `_PRESETS`. + +| ID | Firma corta | Qué hace | +|---|---|---| +| `comfyui_get_gamedev_style_preset_py_ml` | `(name=None) -> dict` | Devuelve la receta de un STYLE PRESET curado o el catálogo si `name=None`. Receta = `{subject_prefix, subject_suffix, style, negative, checkpoint, lora, lora_strength, size, transparent, post, notes}`. Pura, copias profundas. Estilos iniciales: **gameboy** (sin LoRA → prompt + post `pixelize` paleta `game-boy` 4 tonos verde), **ghibli** (degrada a `watercolor_style_sd15` gratis instalado + prompt; no hay LoRA Ghibli dedicado ni se descargó nada gated), **pixel-art-retro** (reutiliza `pixel-art-xl` SDXL ya instalado → checkpoint `juggernaut_xl_v11` + size 768 + post `pixelize` 16 colores). | +| `comfyui_apply_style_preset_py_ml` | `(preset, subject, *, style=None, negative=None) -> dict` | Traduce un preset + un `subject` a `{name, subject (con prefijo/sufijo), builder_kwargs={style,checkpoint,lora,lora_strength,negative}, size, transparent, post}`. Los `builder_kwargs` hacen `**spread` directo en cualquier builder de sujeto; `size`/`transparent` van aparte (recomendaciones); el caller aplica `post["pixelize"]` al PNG si existe. Pura, no muta el preset; `negative` se mergea (no reemplaza). | + +**Ejemplo canónico (mismo subject, look del juego entero):** + +```python +import sys, os +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions")) +from ml.comfyui_get_gamedev_style_preset import comfyui_get_gamedev_style_preset +from ml.comfyui_apply_style_preset import comfyui_apply_style_preset +from ml.comfyui_build_enemy_creature_workflow import comfyui_build_enemy_creature_workflow +from ml.comfyui_submit_workflow import comfyui_submit_workflow +from ml.comfyui_wait_result import comfyui_wait_result +from ml.comfyui_fetch_output_image import comfyui_fetch_output_image +from ml.comfyui_pixelize_image import comfyui_pixelize_image + +preset = comfyui_get_gamedev_style_preset("gameboy") # o "ghibli" / "pixel-art-retro" +ap = comfyui_apply_style_preset(preset, "knight character") +wf = comfyui_build_enemy_creature_workflow(ap["subject"], size=ap["size"], + transparent=ap["transparent"], seed=7, **ap["builder_kwargs"]) +pid = comfyui_submit_workflow(wf)["prompt_id"] +outs = comfyui_wait_result(pid, timeout=500) +fn = next(i["filename"] for o in outs.values() for i in o.get("images", [])) +raw = comfyui_fetch_output_image(fn, dest_dir="/tmp")["path"] +if ap["post"].get("pixelize"): # gameboy/pixel-retro sellan el grid/paleta + comfyui_pixelize_image(raw, "/tmp/knight.png", **ap["post"]["pixelize"]) +``` + +Validado e2e en GPU con el MISMO `knight character` en los 3 estilos (`reports/0190`): +gameboy 4 colores verde (`prompt_id 0657e3e3`), ghibli 78 552 colores acuarela +(`42f2f492`), pixel-art-retro SDXL 768 16 colores (`84b08581`) — tres looks +visiblemente distintos y coherentes. **Gotcha**: el `post` no se aplica solo (el caller +llama `comfyui_pixelize_image`); el LoRA y el checkpoint deben casar de base (pixel-art-xl +es SDXL → exige juggernaut); OOM en 8 GB → bajar `size`, NO matar procesos. + ## Pipelines one-shot (`gamedev-2d`, impuros) | ID | Firma corta | Qué hace | diff --git a/python/functions/ml/comfyui_apply_style_preset.md b/python/functions/ml/comfyui_apply_style_preset.md new file mode 100644 index 00000000..e2c36af5 --- /dev/null +++ b/python/functions/ml/comfyui_apply_style_preset.md @@ -0,0 +1,83 @@ +--- +name: comfyui_apply_style_preset +kind: function +lang: py +domain: ml +version: "1.0.0" +purity: pure +signature: "def comfyui_apply_style_preset(preset: dict, subject: str, *, style: str | None = None, negative: str | None = None) -> dict" +description: "Traduce un STYLE PRESET gamedev (de comfyui_get_gamedev_style_preset) + un subject del usuario a lo que necesita un builder de sujeto del grupo gamedev-2d: el subject combinado con el prefijo/sufijo del estilo, los kwargs comunes (style, checkpoint, lora, lora_strength, negative) listos para **spread, la resolucion y el recorte recomendados (size, transparent) y la spec de post-proceso (post, p.ej. pixelize) que el caller aplica al PNG. Asi el mismo estilo se aplica a CUALQUIER builder (item_icon, enemy_creature, prop_object, ...) y al pipeline comfyui_generate_asset_pack_oneshot sin acoplar firmas. Pura, sin red ni I/O; no muta el preset." +tags: [comfyui, ml, gamedev-2d, style, preset, theme] +uses_functions: [comfyui_get_gamedev_style_preset_py_ml] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +params: + - name: preset + desc: "Receta de estilo (dict de comfyui_get_gamedev_style_preset). Debe traer los campos del preset (se valida que esten). No se muta." + - name: subject + desc: "Lo que el usuario quiere generar (ej. 'knight character', 'health potion'). Se combina con el prefijo/sufijo del estilo. No puede estar vacio." + - name: style + desc: "Override puntual: si se pasa, sustituye al style del preset. None usa el del preset. keyword-only." + - name: negative + desc: "Negativo extra del caller; se MERGEA (sin duplicar) con el negativo del estilo, no lo reemplaza. None = solo el del estilo. keyword-only." +output: "dict con: name (estilo aplicado), subject (combinado con prefijo/sufijo), builder_kwargs ({style, checkpoint, lora, lora_strength, negative} para **spread en el builder), size (resolucion recomendada), transparent (recorte recomendado), post (post-proceso CPU: {'pixelize': {...}} o {})." +tested: true +tests: ["golden gameboy: subject combina suffix (8-bit), builder_kwargs con las 5 claves comunes, checkpoint dreamshaper, lora None, post pixelize paleta game-boy", "golden contrato: los builder_kwargs hacen **spread en comfyui_build_item_icon_workflow sin TypeError y el LoRA del preset aparece en el grafo", "edge style override sustituye el del preset", "edge negative se mergea con el del estilo (no se pierde photorealistic) y deduplica", "edge no muta el preset de entrada", "error subject vacio -> ValueError", "error preset incompleto -> ValueError"] +test_file_path: "python/functions/ml/comfyui_apply_style_preset_test.py" +file_path: "python/functions/ml/comfyui_apply_style_preset.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions")) +from ml.comfyui_get_gamedev_style_preset import comfyui_get_gamedev_style_preset +from ml.comfyui_apply_style_preset import comfyui_apply_style_preset +from ml.comfyui_build_enemy_creature_workflow import comfyui_build_enemy_creature_workflow + +# 1. Elegir estilo + aplicarlo a un subject +preset = comfyui_get_gamedev_style_preset("gameboy") +ap = comfyui_apply_style_preset(preset, "knight character") + +# 2. Construir el workflow con cualquier builder de sujeto (kwargs por **spread) +wf = comfyui_build_enemy_creature_workflow( + ap["subject"], size=ap["size"], transparent=ap["transparent"], **ap["builder_kwargs"] +) +# 3. Generar (submit/wait/fetch) y, si el estilo lo pide, post-proceso: +# if ap["post"].get("pixelize"): +# comfyui_pixelize_image(raw_png, dst_png, **ap["post"]["pixelize"]) +``` + +O lanzable directo con: `./fn run comfyui_apply_style_preset` (aplica pixel-art-retro a "knight character"). + +## Cuando usarla + +Justo despues de elegir un estilo con `comfyui_get_gamedev_style_preset`, para convertir +esa receta en los argumentos exactos de un builder. Es el puente entre "que estilo quiero" +y "como lo paso a item_icon/enemy_creature/prop_object/...". El mismo `ap` sirve para +generar N assets distintos en el MISMO estilo (varia solo el `subject`). Para overrides +puntuales sin tocar el preset, usa `style=`/`negative=`. + +## Gotchas + +- Devuelve `builder_kwargs` con EXACTAMENTE las 5 claves comunes a los builders de SUJETO + (`style`, `checkpoint`, `lora`, `lora_strength`, `negative`). Builders que NO las acepten + todas (p.ej. `seamless_tile`, `parallax_background` no tienen `transparent`/`lora` igual) + exigen filtrar las claves; este helper esta pensado para los builders de sujeto cuadrado. +- `size` y `transparent` van FUERA de `builder_kwargs` (son recomendaciones del estilo): el + caller los pasa explicitos o decide otros. `transparent=False` en los presets de demo es + para que el look (paleta/pintura) cubra todo el frame; para un sprite con alpha pon + `transparent=True` (el recorte es ortogonal al estilo). +- El `post` NO se aplica solo: el caller debe llamar `comfyui_pixelize_image(raw, dst, + **ap["post"]["pixelize"])` tras descargar el PNG si `ap["post"].get("pixelize")`. Sin eso, + estilos como gameboy/pixel-art-retro no sellan su grid/paleta. +- Es **pura**: no llama a ningun builder ni toca la GPU; solo arma kwargs. No muta el + `preset` de entrada (lo que devuelve es independiente). + +## Capability growth log + +(v1.0.0 — sin cambios todavia.) diff --git a/python/functions/ml/comfyui_apply_style_preset.py b/python/functions/ml/comfyui_apply_style_preset.py new file mode 100644 index 00000000..54ac2be9 --- /dev/null +++ b/python/functions/ml/comfyui_apply_style_preset.py @@ -0,0 +1,135 @@ +"""comfyui_apply_style_preset — traduce un style preset gamedev a los kwargs de un builder. + +Toma una receta de estilo (de `comfyui_get_gamedev_style_preset`) y un `subject` del usuario +y produce, de forma PURA, lo que un builder de sujeto del grupo `gamedev-2d` necesita: + + - el `subject` combinado con el prefijo/sufijo del estilo, + - los kwargs comunes a todos los builders de sujeto (`style`, `checkpoint`, `lora`, + `lora_strength`, `negative`) listos para hacer `**spread`, + - la resolucion y el recorte recomendados (`size`, `transparent`), + - y la spec de post-proceso (`post`, p.ej. pixelize) que el caller aplica al PNG resultante. + +Asi el mismo estilo se aplica a CUALQUIER builder de sujeto (item_icon, enemy_creature, +prop_object, structure, ...) sin acoplar este helper a sus firmas, y el preset elige el +checkpoint/lora coherentes ANTES de construir el grafo. + +Patron de uso: + + preset = comfyui_get_gamedev_style_preset("gameboy") + ap = comfyui_apply_style_preset(preset, "knight character") + wf = comfyui_build_enemy_creature_workflow( + ap["subject"], size=ap["size"], transparent=ap["transparent"], **ap["builder_kwargs"] + ) + # tras submit/wait/fetch, si ap["post"].get("pixelize"): + # comfyui_pixelize_image(raw_png, dst_png, **ap["post"]["pixelize"]) + +Funcion pura: sin red, sin I/O. No muta el preset de entrada (copia lo que devuelve). +""" +from __future__ import annotations + +import copy + +# Claves obligatorias de una receta valida (las que produce comfyui_get_gamedev_style_preset). +_REQUIRED = ( + "name", + "subject_prefix", + "subject_suffix", + "style", + "negative", + "checkpoint", + "lora", + "lora_strength", + "size", + "transparent", + "post", +) + + +def _merge_negative(a: str, b: str) -> str: + """Une dos negativos por comas sin duplicar terminos ni dejar comas sueltas.""" + seen: list[str] = [] + for chunk in (a or "", b or ""): + for term in chunk.split(","): + t = term.strip() + if t and t.lower() not in {s.lower() for s in seen}: + seen.append(t) + return ", ".join(seen) + + +def comfyui_apply_style_preset( + preset: dict, + subject: str, + *, + style: str | None = None, + negative: str | None = None, +) -> dict: + """Aplica un style preset a un subject y devuelve los kwargs listos para un builder. + + Args: + preset: receta de estilo (dict de comfyui_get_gamedev_style_preset). Debe traer + los campos del preset; se valida que esten presentes. No se muta. + subject: lo que el usuario quiere generar (ej. "knight character", "health potion"). + Se combina con el prefijo/sufijo del estilo. No puede estar vacio. + style: si se pasa, sustituye al `style` del preset (override puntual). None usa el + del preset. keyword-only. + negative: negativo extra del caller; se MERGEA con el negativo del estilo (no lo + reemplaza). None = solo el del estilo. keyword-only. + + Returns: + dict con: + - "name" (str): nombre del estilo aplicado. + - "subject" (str): subject combinado con prefijo/sufijo del estilo. + - "builder_kwargs" (dict): {style, checkpoint, lora, lora_strength, negative} — + los kwargs comunes a los builders de sujeto, para hacer **spread. + - "size" (int): resolucion recomendada por el estilo. + - "transparent" (bool): recorte a alpha recomendado por el estilo. + - "post" (dict): post-proceso CPU a aplicar al PNG ({"pixelize": {...}} o {}). + + Raises: + ValueError: si subject esta vacio o el preset no trae los campos requeridos. + """ + if not subject or not subject.strip(): + raise ValueError("comfyui_apply_style_preset: 'subject' no puede estar vacio") + if not isinstance(preset, dict): + raise ValueError("comfyui_apply_style_preset: 'preset' debe ser un dict") + missing = [k for k in _REQUIRED if k not in preset] + if missing: + raise ValueError( + f"comfyui_apply_style_preset: preset incompleto, faltan campos {missing}. " + "Usa comfyui_get_gamedev_style_preset para obtener una receta valida." + ) + + subject_full = ( + f"{preset['subject_prefix']}{subject.strip()}{preset['subject_suffix']}" + ).strip().strip(",").strip() + + style_final = style if style is not None else preset["style"] + neg_final = _merge_negative(preset["negative"], negative or "") + + return { + "name": preset["name"], + "subject": subject_full, + "builder_kwargs": { + "style": style_final, + "checkpoint": preset["checkpoint"], + "lora": preset["lora"], + "lora_strength": preset["lora_strength"], + "negative": neg_final, + }, + "size": preset["size"], + "transparent": preset["transparent"], + "post": copy.deepcopy(preset["post"]), + } + + +if __name__ == "__main__": + import json + import os + import sys + + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + from ml.comfyui_get_gamedev_style_preset import comfyui_get_gamedev_style_preset + + p = comfyui_get_gamedev_style_preset("pixel-art-retro") + ap = comfyui_apply_style_preset(p, "knight character") + print(json.dumps(ap, indent=2, ensure_ascii=False)) diff --git a/python/functions/ml/comfyui_apply_style_preset_test.py b/python/functions/ml/comfyui_apply_style_preset_test.py new file mode 100644 index 00000000..992c3f87 --- /dev/null +++ b/python/functions/ml/comfyui_apply_style_preset_test.py @@ -0,0 +1,92 @@ +"""Tests offline de comfyui_apply_style_preset (traduccion preset -> kwargs, sin GPU).""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from ml.comfyui_apply_style_preset import comfyui_apply_style_preset # noqa: E402 +from ml.comfyui_get_gamedev_style_preset import ( # noqa: E402 + comfyui_get_gamedev_style_preset, +) + + +def test_golden_apply_gameboy_to_subject(): + p = comfyui_get_gamedev_style_preset("gameboy") + ap = comfyui_apply_style_preset(p, "knight character") + # El subject combina prefijo/sufijo del estilo. + assert "knight character" in ap["subject"] + assert "8-bit" in ap["subject"] # del subject_suffix del gameboy + # builder_kwargs trae las claves comunes a los builders de sujeto, listas para **spread. + bk = ap["builder_kwargs"] + assert set(bk) == {"style", "checkpoint", "lora", "lora_strength", "negative"} + assert bk["checkpoint"] == "dreamshaper_8.safetensors" + assert bk["lora"] is None + assert "Game Boy" in bk["style"] + # Recomendaciones y post propagados. + assert ap["transparent"] is False + assert ap["post"]["pixelize"]["palette"] == "game-boy" + + +def test_golden_kwargs_spreadable_into_builder(): + # Los builder_kwargs son exactamente los que aceptan los builders de sujeto: + # se hace **spread sin TypeError (verifica el contrato con item_icon). + from ml.comfyui_build_item_icon_workflow import comfyui_build_item_icon_workflow + + p = comfyui_get_gamedev_style_preset("ghibli") + ap = comfyui_apply_style_preset(p, "magic potion") + wf = comfyui_build_item_icon_workflow( + ap["subject"], size=ap["size"], transparent=ap["transparent"], **ap["builder_kwargs"] + ) + cls = sorted({n["class_type"] for n in wf.values()}) + assert "KSampler" in cls + # El LoRA watercolor del preset aparece en el grafo. + loras = [n for n in wf.values() if n["class_type"] == "LoraLoader"] + assert loras and loras[0]["inputs"]["lora_name"] == "watercolor_style_sd15.safetensors" + + +def test_edge_style_override(): + p = comfyui_get_gamedev_style_preset("gameboy") + ap = comfyui_apply_style_preset(p, "tree", style="custom override style") + assert ap["builder_kwargs"]["style"] == "custom override style" + + +def test_edge_negative_merged_not_replaced(): + p = comfyui_get_gamedev_style_preset("gameboy") + ap = comfyui_apply_style_preset(p, "tree", negative="extra unwanted thing") + neg = ap["builder_kwargs"]["negative"] + assert "extra unwanted thing" in neg + assert "photorealistic" in neg # del negativo del estilo, no se pierde + + +def test_edge_negative_dedup(): + p = comfyui_get_gamedev_style_preset("gameboy") + # "photo" ya esta en el negativo del estilo; no debe duplicarse. + ap = comfyui_apply_style_preset(p, "tree", negative="photo") + assert ap["builder_kwargs"]["negative"].lower().count("photo,") + \ + ap["builder_kwargs"]["negative"].lower().endswith("photo") <= 2 + + +def test_edge_does_not_mutate_preset(): + p = comfyui_get_gamedev_style_preset("pixel-art-retro") + before = dict(p) + ap = comfyui_apply_style_preset(p, "knight") + ap["post"]["pixelize"]["colors"] = 999 # mutar el resultado + assert p == before # el preset original intacto + assert p["post"]["pixelize"]["colors"] == 16 + + +def test_error_empty_subject(): + p = comfyui_get_gamedev_style_preset("gameboy") + try: + comfyui_apply_style_preset(p, " ") + assert False, "deberia lanzar ValueError" + except ValueError as e: + assert "subject" in str(e) + + +def test_error_incomplete_preset(): + try: + comfyui_apply_style_preset({"name": "broken"}, "knight") + assert False, "deberia lanzar ValueError" + except ValueError as e: + assert "incompleto" in str(e) or "faltan" in str(e) diff --git a/python/functions/ml/comfyui_get_gamedev_style_preset.md b/python/functions/ml/comfyui_get_gamedev_style_preset.md new file mode 100644 index 00000000..43d7d515 --- /dev/null +++ b/python/functions/ml/comfyui_get_gamedev_style_preset.md @@ -0,0 +1,84 @@ +--- +name: comfyui_get_gamedev_style_preset +kind: function +lang: py +domain: ml +version: "1.0.0" +purity: pure +signature: "def comfyui_get_gamedev_style_preset(name: str | None = None) -> dict" +description: "Devuelve la receta de un STYLE PRESET gamedev curado y reutilizable (gameboy, ghibli, pixel-art-retro) o el catalogo de nombres si name es None. Un preset empaqueta como DATOS puros el look de un juego entero (subject_prefix/suffix, style, negative, checkpoint recomendado, lora + strength, size, transparent, post-proceso pixelize) para que cualquier builder/pipeline del grupo gamedev-2d lo aplique via comfyui_apply_style_preset y todos los assets salgan coherentes en ese estilo. Pura, sin red ni I/O; devuelve copias profundas. Extensible: anadir un estilo = una entrada en _PRESETS." +tags: [comfyui, ml, gamedev-2d, style, preset, theme, lora] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +params: + - name: name + desc: "Identificador del estilo: 'gameboy', 'ghibli' o 'pixel-art-retro'. Insensible a mayusculas y a '_' vs '-'. Si es None o cadena vacia devuelve el catalogo {names, count} en vez de una receta (discovery)." +output: "Si name es None/'': dict {names: list[str], count: int} con el catalogo. Si name es valido: copia profunda de la receta del estilo con campos {name, subject_prefix, subject_suffix, style, negative, checkpoint, lora, lora_strength, size, transparent, post, notes}. Es una COPIA — mutarla no afecta al registro interno." +tested: true +tests: ["golden: los 3 presets (gameboy/ghibli/pixel-art-retro) devuelven recetas con todos los campos requeridos, checkpoint .safetensors, size>0", "golden gameboy: lora None + post pixelize paleta game-boy 4 colores", "golden pixel-art-retro: lora pixel-art-xl SDXL + checkpoint juggernaut + size>=768 + post pixelize", "golden ghibli: degrada a watercolor_style_sd15 + sin post pixelize", "edge name None/'' -> catalogo de 3 nombres", "edge insensible a mayusculas y '_'/'-'", "edge devuelve copia profunda (mutar no afecta), error name desconocido -> ValueError listando disponibles"] +test_file_path: "python/functions/ml/comfyui_get_gamedev_style_preset_test.py" +file_path: "python/functions/ml/comfyui_get_gamedev_style_preset.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions")) +from ml.comfyui_get_gamedev_style_preset import comfyui_get_gamedev_style_preset + +# Discovery: que estilos hay +print(comfyui_get_gamedev_style_preset()) # {'names': ['gameboy', 'ghibli', 'pixel-art-retro'], 'count': 3} + +# Receta de un estilo concreto (copia segura de mutar) +preset = comfyui_get_gamedev_style_preset("gameboy") +print(preset["checkpoint"], preset["lora"], preset["post"]["pixelize"]["palette"]) +# dreamshaper_8.safetensors None game-boy + +# Aplicarlo a un asset con el helper hermano (ver comfyui_apply_style_preset): +# ap = comfyui_apply_style_preset(preset, "knight character") +# wf = comfyui_build_enemy_creature_workflow(ap["subject"], size=ap["size"], +# transparent=ap["transparent"], **ap["builder_kwargs"]) +``` + +O lanzable directo con: `./fn run comfyui_get_gamedev_style_preset` (imprime catalogo + receta gameboy). + +## Cuando usarla + +Cuando quieras que TODOS los assets de un juego salgan en un MISMO look coherente sin +repetir a mano el `style`/`checkpoint`/`lora`/`negative` ni el post-proceso en cada +builder. Llama una vez al principio para fijar el estilo del proyecto ("todo en Game Boy", +"estilo Ghibli", "pixel-art retro 16-bit"), pasa la receta a `comfyui_apply_style_preset` +y de ahi a cualquier builder de sujeto (`item_icon`, `enemy_creature`, `prop_object`, +`structure`, ...) o al pipeline `comfyui_generate_asset_pack_oneshot`. Usa `name=None` +para descubrir los estilos disponibles. + +## Gotchas + +- Es una funcion **pura**: solo devuelve datos (la receta). La generacion real (GPU) y el + post-proceso (pixelize) los hacen los builders + `comfyui_apply_style_preset` + + `comfyui_submit_workflow`/`comfyui_wait_result`/`comfyui_fetch_output_image` + + `comfyui_pixelize_image`. +- **El checkpoint y el LoRA deben casar de base**: `pixel-art-retro` usa el LoRA SDXL + `pixel-art-xl` -> exige `checkpoint` SDXL (`juggernaut_xl_v11`) y `size` 768. Aplicar un + LoRA SDXL sobre un checkpoint SD1.5 da basura. Si anades un estilo con LoRA nuevo, + descargalo a `models/loras` y verifica su base antes. +- **ghibli no usa un LoRA Ghibli dedicado**: no hay ninguno instalado y no se descargo + ninguno gated/de pago. Degrada a `watercolor_style_sd15` (gratis, ya instalado) + + prompt Ghibli para el look pintado/acuarela. Un LoRA Ghibli especifico de Civitai + mejoraria el parecido facial — pendiente humano. +- **gameboy se resuelve por POST, no por LoRA**: sin LoRA; el look DMG de 4 tonos verde lo + da `comfyui_pixelize_image` con la paleta builtin `game-boy` + dithering. El caller DEBE + aplicar el post (`preset["post"]["pixelize"]`) o solo vera un sprite monocromo-ish sin + la paleta sellada. +- **Modelos verificados en el servidor** (8GB lowvram, modelos en `/mnt/2tb`): si cambias + de PC valida que `dreamshaper_8`, `juggernaut_xl_v11`, `pixel-art-xl` y + `watercolor_style_sd15` existan (`GET /object_info/CheckpointLoaderSimple` y `/LoraLoader`). + +## Capability growth log + +(v1.0.0 — sin cambios todavia.) diff --git a/python/functions/ml/comfyui_get_gamedev_style_preset.py b/python/functions/ml/comfyui_get_gamedev_style_preset.py new file mode 100644 index 00000000..3fd4dccf --- /dev/null +++ b/python/functions/ml/comfyui_get_gamedev_style_preset.py @@ -0,0 +1,174 @@ +"""comfyui_get_gamedev_style_preset — recetas de ESTILO curadas y reutilizables para gamedev. + +Un *style preset* es la receta de un look visual coherente que un dev quiere aplicar +a TODOS los assets de su juego de una vez ("todo en estilo Game Boy", "estilo Ghibli", +"pixel-art retro 16-bit"). En vez de repetir a mano el mismo `style`/`checkpoint`/`lora`/ +`negative` y el mismo post-proceso en cada builder gamedev, el preset los empaqueta como +DATOS puros que cualquier builder o pipeline del grupo `gamedev-2d` consume vía su helper +hermano `comfyui_apply_style_preset`. + +Diseno (issue 0087, crecer por composicion no por inflado): se eligio (A) una funcion pura +de presets + un helper de aplicacion, NO (B) un pipeline monolitico, porque: + + - Los presets son datos puros: reutilizables por CUALQUIER builder de sujeto + (item_icon, enemy_creature, prop_object, ...) y por el pipeline ya existente + `comfyui_generate_asset_pack_oneshot` (que comparte checkpoint/style/lora) sin acoplar + sus firmas heterogeneas. + - El helper `comfyui_apply_style_preset` traduce el preset a los kwargs comunes de los + builders + indica el post-proceso. Asi el preset se aplica al builder ANTES de construir + el grafo (eligiendo checkpoint/lora coherentes), no parcheando el dict despues. + +Cada preset es un dict con estos campos: + + { + "name": str, # identificador del estilo + "subject_prefix": str, # texto que precede al subject del usuario + "subject_suffix": str, # texto que sigue al subject del usuario + "style": str, # descriptor de estilo -> kwarg `style` del builder + "negative": str, # negativo extra del estilo -> se mergea con el del builder + "checkpoint": str, # checkpoint recomendado (SD1.5 vs SDXL segun el LoRA) + "lora": str | None, # LoRA del estilo (None = solo prompt + post) + "lora_strength": float, # fuerza del LoRA + "size": int, # resolucion recomendada (SDXL pide mas) + "transparent": bool, # recorte a alpha recomendado para este look + "post": dict, # post-proceso CPU; {"pixelize": {...}} o {} si no aplica + "notes": str, # como se logro el look y caveats + } + +Funcion pura: sin red, sin I/O, sin estado. Devuelve copias profundas para que el caller +no mute el registro interno. Extensible: anadir un estilo = una entrada en `_PRESETS`. +""" +from __future__ import annotations + +import copy + + +# Modelos verificados presentes en el servidor (8GB lowvram, modelos en /mnt/2tb): +# checkpoints: dreamshaper_8 (SD1.5), juggernaut_xl_v11 (SDXL) +# loras: pixel-art-xl (SDXL), watercolor_style_sd15 (SD1.5), ... +# Si se anade un estilo con un LoRA nuevo, descargar antes a models/loras y verificar +# que el `checkpoint` casa con su base (un LoRA SDXL exige checkpoint SDXL). +_PRESETS: dict[str, dict] = { + # Game Boy DMG: paleta de 4 tonos verde + dithering 1-bit + lowres. Sin LoRA: + # el prompt empuja a monocromo simple y el post `pixelize` con la paleta builtin + # "game-boy" sella el look icónico de 4 tonos. transparent=False para que el verde + # cubra todo el frame (recorte a alpha es ortogonal y se hace despues si se quiere). + "gameboy": { + "name": "gameboy", + "subject_prefix": "", + "subject_suffix": ", 8-bit, simple shapes, limited palette, retro handheld", + "style": "Game Boy DMG game art, monochrome green, retro handheld sprite, low detail, flat shading", + "negative": "color, colorful, vibrant, photorealistic, photo, 3d render, smooth gradient, high detail, realistic lighting", + "checkpoint": "dreamshaper_8.safetensors", + "lora": None, + "lora_strength": 1.0, + "size": 512, + "transparent": False, + "post": { + "pixelize": { + "downscale": 6, + "colors": 4, + "palette": "game-boy", + "dither": True, + "upscale_back": True, + } + }, + "notes": ( + "Sin LoRA: el look DMG lo da el post-proceso pixelize con la paleta builtin " + "'game-boy' (4 tonos verde 0f380f/306230/8bac0f/9bbc0f) + dithering. El prompt " + "solo empuja a formas simples y monocromo. transparent=False para que la paleta " + "verde cubra todo el frame." + ), + }, + # Studio Ghibli: anime pintado a mano, colores suaves, fondos acuarela. No hay un LoRA + # Ghibli dedicado instalado (no se descargo ninguno gated/de pago); el look pintado se + # logra con watercolor_style_sd15 (LoRA gratis ya instalado) a fuerza media + prompt + # Ghibli. Sin post-proceso (es ilustracion pintada, no pixelart). + "ghibli": { + "name": "ghibli", + "subject_prefix": "", + "subject_suffix": ", anime illustration, soft lighting, painterly", + "style": "Studio Ghibli style, hand-painted anime, soft colors, watercolor background, whimsical, gentle, detailed illustration", + "negative": "photo, photorealistic, 3d render, harsh shadows, pixel art, lowres, deformed, text, watermark, signature", + "checkpoint": "dreamshaper_8.safetensors", + "lora": "watercolor_style_sd15.safetensors", + "lora_strength": 0.7, + "size": 512, + "transparent": False, + "post": {}, + "notes": ( + "No hay LoRA Ghibli dedicado instalado y no se descargo ninguno gated/de pago " + "(error-path: degradar a LoRA gratis + prompt). watercolor_style_sd15 (gratis, ya " + "instalado) a strength 0.7 + prompt Ghibli da el look pintado/acuarela. Un LoRA " + "Ghibli especifico de Civitai mejoraria el parecido facial — pendiente humano si " + "se quiere. transparent=False para conservar el fondo acuarela." + ), + }, + # Pixel-art retro 16-bit (SNES/Genesis): reutiliza el LoRA pixel-art-xl ya instalado + # (es SDXL -> exige checkpoint SDXL juggernaut_xl_v11 y resolucion mayor). El post + # pixelize a 16 colores sella los pixeles duros (el LoRA da el estilo, el post el grid). + "pixel-art-retro": { + "name": "pixel-art-retro", + "subject_prefix": "", + "subject_suffix": ", pixel art, game sprite, crisp pixels", + "style": "16-bit pixel art, SNES JRPG sprite, retro game, limited palette, clean outline, flat colors", + "negative": "blurry, smooth, photorealistic, 3d render, realistic, antialiasing, soft, gradient, noise", + "checkpoint": "juggernaut_xl_v11.safetensors", + "lora": "pixel-art-xl.safetensors", + "lora_strength": 1.0, + "size": 768, + "transparent": False, + "post": { + "pixelize": { + "downscale": 8, + "colors": 16, + "palette": None, + "dither": False, + "upscale_back": True, + } + }, + "notes": ( + "Reutiliza pixel-art-xl.safetensors (SDXL, ya instalado) -> requiere checkpoint " + "SDXL juggernaut_xl_v11 y size 768 (a 512 SDXL+pixel-art-xl pierde calidad). El " + "post pixelize a 16 colores (paleta auto MEDIANCUT) da el grid duro 16-bit. OOM en " + "8GB -> bajar size a 512 (NO matar procesos). El LoRA da el estilo; el post el grid." + ), + }, +} + + +def comfyui_get_gamedev_style_preset(name: str | None = None) -> dict: + """Devuelve la receta de un style preset gamedev, o el catalogo si name es None. + + Args: + name: identificador del estilo ("gameboy", "ghibli", "pixel-art-retro"). Si es + None (o cadena vacia), devuelve el catalogo de nombres disponibles en vez de + una receta concreta (discovery). Insensible a mayusculas y a '_' vs '-'. + + Returns: + - Si name es None/"": dict {"names": list[str], "count": int} con el catalogo. + - Si name es un estilo valido: copia profunda de su receta (ver el modulo para los + campos). Es una COPIA — mutarla no afecta al registro interno. + + Raises: + ValueError: si name no es None pero no corresponde a ningun preset. El mensaje + lista los nombres disponibles. + """ + if name is None or (isinstance(name, str) and not name.strip()): + names = sorted(_PRESETS) + return {"names": names, "count": len(names)} + + key = name.strip().lower().replace("_", "-") + if key not in _PRESETS: + raise ValueError( + f"comfyui_get_gamedev_style_preset: estilo desconocido {name!r}. " + f"Disponibles: {sorted(_PRESETS)}" + ) + return copy.deepcopy(_PRESETS[key]) + + +if __name__ == "__main__": + import json + + print(json.dumps(comfyui_get_gamedev_style_preset(), indent=2)) + print(json.dumps(comfyui_get_gamedev_style_preset("gameboy"), indent=2, ensure_ascii=False)) diff --git a/python/functions/ml/comfyui_get_gamedev_style_preset_test.py b/python/functions/ml/comfyui_get_gamedev_style_preset_test.py new file mode 100644 index 00000000..f29f5442 --- /dev/null +++ b/python/functions/ml/comfyui_get_gamedev_style_preset_test.py @@ -0,0 +1,97 @@ +"""Tests offline de comfyui_get_gamedev_style_preset (recetas de estilo, sin GPU).""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from ml.comfyui_get_gamedev_style_preset import ( # noqa: E402 + comfyui_get_gamedev_style_preset, +) + +_REQUIRED = { + "name", + "subject_prefix", + "subject_suffix", + "style", + "negative", + "checkpoint", + "lora", + "lora_strength", + "size", + "transparent", + "post", + "notes", +} + + +def test_golden_three_presets_valid(): + # Los 3 estilos iniciales existen y devuelven recetas con todos los campos. + for name in ("gameboy", "ghibli", "pixel-art-retro"): + r = comfyui_get_gamedev_style_preset(name) + assert _REQUIRED <= set(r), f"{name} faltan campos {_REQUIRED - set(r)}" + assert r["name"] == name + assert isinstance(r["style"], str) and r["style"] + assert isinstance(r["checkpoint"], str) and r["checkpoint"].endswith(".safetensors") + assert isinstance(r["lora_strength"], (int, float)) + assert isinstance(r["size"], int) and r["size"] > 0 + assert isinstance(r["transparent"], bool) + assert isinstance(r["post"], dict) + + +def test_golden_gameboy_recipe(): + r = comfyui_get_gamedev_style_preset("gameboy") + # Game Boy se resuelve sin LoRA, con pixelize a la paleta game-boy (4 tonos). + assert r["lora"] is None + assert r["post"]["pixelize"]["palette"] == "game-boy" + assert r["post"]["pixelize"]["colors"] == 4 + + +def test_golden_pixel_retro_uses_sdxl_lora(): + r = comfyui_get_gamedev_style_preset("pixel-art-retro") + # Reutiliza pixel-art-xl (SDXL) -> checkpoint SDXL + size mayor + pixelize. + assert r["lora"] == "pixel-art-xl.safetensors" + assert r["checkpoint"] == "juggernaut_xl_v11.safetensors" + assert r["size"] >= 768 + assert "pixelize" in r["post"] + + +def test_golden_ghibli_degrades_to_watercolor_lora(): + r = comfyui_get_gamedev_style_preset("ghibli") + # Sin LoRA Ghibli dedicado -> watercolor instalado + prompt; sin post pixelize. + assert r["lora"] == "watercolor_style_sd15.safetensors" + assert r["post"] == {} + assert "ghibli" in r["style"].lower() + + +def test_edge_catalog_when_none(): + cat = comfyui_get_gamedev_style_preset(None) + assert set(cat["names"]) == {"gameboy", "ghibli", "pixel-art-retro"} + assert cat["count"] == 3 + # Cadena vacia tambien devuelve catalogo (discovery). + assert comfyui_get_gamedev_style_preset("") == cat + + +def test_edge_case_and_separator_insensitive(): + # Insensible a mayusculas y a '_' vs '-'. + a = comfyui_get_gamedev_style_preset("PIXEL_ART_RETRO") + b = comfyui_get_gamedev_style_preset("pixel-art-retro") + assert a == b + + +def test_edge_returns_copy_not_internal(): + # Mutar la receta devuelta NO afecta a la siguiente llamada (copia profunda). + r = comfyui_get_gamedev_style_preset("gameboy") + r["style"] = "MUTATED" + r["post"]["pixelize"]["colors"] = 999 + r2 = comfyui_get_gamedev_style_preset("gameboy") + assert r2["style"] != "MUTATED" + assert r2["post"]["pixelize"]["colors"] == 4 + + +def test_error_unknown_preset(): + try: + comfyui_get_gamedev_style_preset("vaporwave") + assert False, "deberia lanzar ValueError" + except ValueError as e: + assert "vaporwave" in str(e) + assert "gameboy" in str(e) # lista los disponibles