From d3d846f74850051377eda2e199f150a2d6f5ef00 Mon Sep 17 00:00:00 2001 From: agent Date: Sat, 27 Jun 2026 13:50:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(ml):=20grupo=20comfyui-styles=20=E2=80=94?= =?UTF-8?q?=20cat=C3=A1logo=20curado=20+=20merge/dedup=20+=20generador=20L?= =?UTF-8?q?LM=20de=20estilos=20WAS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tres funciones para gestionar y ampliar el repositorio de estilos del selector WAS de ComfyUI (Prompt Styles Selector / Prompt Multiple Styles Selector): - comfyui_curated_styles_catalog (pure): catálogo curado de 190 estilos en 13 categorías (photography, render3d, painting, anime, pixel, illustration, comic, lighting, camera, material, scifi, fantasy, mood), formato WAS exacto. - comfyui_append_styles (impure): merge+dedup no destructivo sobre el styles.json real, con backup atómico, validación de entradas y preservación de existentes. - comfyui_generate_styles_llm (impure): genera estilos de una categoría vía ask_llm (grupo claude-direct); robusta (devuelve {} ante 429/JSON corrupto). Aplicado en vivo: styles.json 269 -> 503 estilos (+190 curados +44 LLM), backup hecho, selector verifica 503 en /object_info. Tests offline verdes. Co-Authored-By: Claude Opus 4.8 (1M context) --- python/functions/ml/comfyui_append_styles.md | 79 ++++ python/functions/ml/comfyui_append_styles.py | 177 +++++++++ .../ml/comfyui_append_styles_test.py | 134 +++++++ .../ml/comfyui_curated_styles_catalog.md | 71 ++++ .../ml/comfyui_curated_styles_catalog.py | 351 ++++++++++++++++++ .../ml/comfyui_curated_styles_catalog_test.py | 64 ++++ .../ml/comfyui_generate_styles_llm.md | 73 ++++ .../ml/comfyui_generate_styles_llm.py | 156 ++++++++ .../ml/comfyui_generate_styles_llm_test.py | 92 +++++ 9 files changed, 1197 insertions(+) create mode 100644 python/functions/ml/comfyui_append_styles.md create mode 100644 python/functions/ml/comfyui_append_styles.py create mode 100644 python/functions/ml/comfyui_append_styles_test.py create mode 100644 python/functions/ml/comfyui_curated_styles_catalog.md create mode 100644 python/functions/ml/comfyui_curated_styles_catalog.py create mode 100644 python/functions/ml/comfyui_curated_styles_catalog_test.py create mode 100644 python/functions/ml/comfyui_generate_styles_llm.md create mode 100644 python/functions/ml/comfyui_generate_styles_llm.py create mode 100644 python/functions/ml/comfyui_generate_styles_llm_test.py diff --git a/python/functions/ml/comfyui_append_styles.md b/python/functions/ml/comfyui_append_styles.md new file mode 100644 index 00000000..59c212c3 --- /dev/null +++ b/python/functions/ml/comfyui_append_styles.md @@ -0,0 +1,79 @@ +--- +name: comfyui_append_styles +kind: function +lang: py +domain: ml +version: "1.0.0" +purity: impure +signature: "def comfyui_append_styles(new_styles: dict, styles_path: str = DEFAULT_STYLES_PATH, overwrite: bool = False, backup: bool = True, dry_run: bool = False) -> dict" +description: "Fusiona (merge+dedup) un dict de estilos nuevos sobre el styles.json del selector WAS de ComfyUI (Prompt Styles Selector / Prompt Multiple Styles Selector) de forma SEGURA y NO destructiva. Preserva TODOS los estilos existentes (dedup por nombre; los existentes ganan salvo overwrite=True), hace backup con timestamp antes de escribir, valida cada entrada nueva (descarta las que no tengan prompt no vacio, rellena negative_prompt por defecto si falta) y escribe de forma atomica (.tmp + os.replace). Devuelve un resumen con conteos antes/despues, anadidos, duplicados saltados e invalidos para verificar el efecto sin releer el archivo. Impura: lee y escribe disco; no usa red, no borra el original." +tags: [comfyui, ml, comfyui-styles, styles, was, merge, dedup] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: error_go_core +imports: [] +params: + - name: new_styles + desc: "dict {nombre: {'prompt': str, 'negative_prompt': str}} de estilos a anadir. Entradas sin prompt no vacio se descartan; las que no traen negative_prompt reciben uno por defecto. Debe ser un dict (si no, ValueError)." + - name: styles_path + desc: "Ruta del styles.json. Default: ~/ComfyUI/custom_nodes/was-node-suite-comfyui/styles.json (la instalacion WAS del usuario). Debe existir (no se crea de cero: FileNotFoundError)." + - name: overwrite + desc: "Si False (default), un nombre que ya existe NO se pisa (se cuenta como skipped_existing). Si True, los nuevos pisan a los existentes (overwritten)." + - name: backup + desc: "Si True (default), copia el archivo a .bak. antes de escribir. Backup hecho ANTES de tocar el original." + - name: dry_run + desc: "Si True, calcula el merge y los conteos pero NO escribe nada (ni backup). Para previsualizar el efecto." +output: "dict resumen: {styles_path, backup_path, total_before, total_after, added:[nombres], overwritten:[nombres], skipped_existing:[nombres], invalid:[nombres], dry_run:bool}." +tested: true +tests: ["golden: merge preserva A y B existentes y anade C; total_before 2 -> total_after 3", "edge dedup: nombre existente no se pisa por defecto (skipped_existing), el original se conserva", "edge overwrite=True pisa el existente", "edge negative por defecto cuando la entrada nueva no lo trae", "edge entradas invalidas (no dict, prompt vacio, sin prompt) se descartan a invalid", "edge backup creado con el estado anterior", "error/edge dry_run no escribe el archivo (intacto) pero calcula conteos"] +test_file_path: "python/functions/ml/comfyui_append_styles_test.py" +file_path: "python/functions/ml/comfyui_append_styles.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions")) +from ml.comfyui_append_styles import comfyui_append_styles +from ml.comfyui_curated_styles_catalog import comfyui_curated_styles_catalog + +# Fusionar el catalogo curado sobre el styles.json real, preservando los existentes. +nuevos = comfyui_curated_styles_catalog() # 190 estilos curados +res = comfyui_append_styles(nuevos) # backup + merge + dedup + escritura atomica +print(res["total_before"], "->", res["total_after"], "anadidos:", len(res["added"])) +# 269 -> 459 anadidos: 190 (los duplicados por nombre quedan en skipped_existing) + +# Previsualizar sin escribir: +print(comfyui_append_styles(nuevos, dry_run=True)["total_after"]) +``` + +O por CLI: `echo '{"x":{"prompt":"neon glow"}}' | python/.venv/bin/python3 python/functions/ml/comfyui_append_styles.py --dry-run` + +## Cuando usarla + +Cuando quieras AMPLIAR el repositorio de estilos del selector WAS de ComfyUI sin perder los que +ya hay. Es el paso de escritura del flujo "generar estilos -> fusionar": genera un dict de estilos +(con `comfyui_curated_styles_catalog` y/o `comfyui_generate_styles_llm`), pasalo aqui y el archivo +queda fusionado con backup. Usala SIEMPRE en vez de editar el JSON a mano (preserva los existentes, +valida formato, hace backup atomico). Tras escribir, reinicia `comfyui.service` para que el +selector recargue el catalogo. + +## Gotchas + +- **Reinicio necesario**: el nodo WAS lee styles.json al arrancar. Despues de fusionar hay que + `systemctl --user restart comfyui.service` (o reiniciar el server) para que el selector liste + los nuevos. Verifica con `GET /object_info` contando los enum del `Prompt Styles Selector`. +- **dedup por NOMBRE, no por contenido**: dos estilos con el mismo nombre se consideran el mismo; + por defecto gana el existente. Si quieres reemplazar deliberadamente, pasa `overwrite=True`. +- **El archivo debe existir**: no se crea de cero (FileNotFoundError) para no enmascarar una + instalacion WAS rota. Si lo necesitas vacio, crea `{}` a mano primero. +- **Backups se acumulan**: cada escritura deja un `styles.json.bak.`. Limpialos a mano si + molestan; son la red de seguridad para restaurar. +- **No versionar**: el styles.json es de ComfyUI, no de fn_registry. No hacer `git add` de el. + +## Capability growth log + +(v1.0.0 — sin cambios todavia.) diff --git a/python/functions/ml/comfyui_append_styles.py b/python/functions/ml/comfyui_append_styles.py new file mode 100644 index 00000000..df5c1b96 --- /dev/null +++ b/python/functions/ml/comfyui_append_styles.py @@ -0,0 +1,177 @@ +"""comfyui_append_styles — merge+dedup de estilos nuevos sobre el styles.json de WAS. + +El selector de estilos de ComfyUI (nodos WAS `Prompt Styles Selector` / +`Prompt Multiple Styles Selector`) lee de +`~/ComfyUI/custom_nodes/was-node-suite-comfyui/styles.json`, un dict cuyo formato exacto es: + + { "NombreEstilo": {"prompt": "modificadores de estilo", "negative_prompt": "..."}, ... } + +El selector múltiple CONCATENA los `prompt` de los estilos elegidos, por lo que cada `prompt` +debe contener MODIFICADORES de estilo (no la descripción del sujeto) y NO el placeholder +`{prompt}`. + +Esta función fusiona un dict de estilos nuevos sobre el archivo existente de forma SEGURA y NO +destructiva: + + - Hace un backup con timestamp del styles.json antes de tocarlo (nunca sobrescribe sin copia). + - Preserva TODOS los estilos existentes (dedup por nombre: los existentes ganan salvo + `overwrite=True`). + - Valida cada entrada nueva: debe ser un dict con `prompt` no vacío. Si falta `negative_prompt` + se rellena con un negativo por defecto razonable; las entradas inválidas se descartan + (reportadas, no abortan el merge). + - Escribe el resultado de forma atómica (a un .tmp y `os.replace`). + +Devuelve un resumen con conteos (antes/después, añadidos, duplicados saltados, inválidos) para +que el caller verifique el efecto sin volver a leer el archivo. + +Impura: lee y escribe disco. No usa red. No mata procesos. No borra el original (sólo backup + +reemplazo atómico). +""" +from __future__ import annotations + +import json +import os +import shutil +import time + +# Negativo por defecto cuando un estilo nuevo no trae `negative_prompt`. Sobrio y SFW, +# alineado con el estilo de los negativos que ya viven en el styles.json de WAS. +DEFAULT_NEGATIVE = ( + "ugly, deformed, noisy, blurry, low quality, distorted, disfigured, " + "bad anatomy, watermark, signature, text, NSFW" +) + +DEFAULT_STYLES_PATH = os.path.join( + os.path.expanduser("~"), + "ComfyUI", + "custom_nodes", + "was-node-suite-comfyui", + "styles.json", +) + + +def _validate_entry(value: object) -> dict | None: + """Normaliza una entrada de estilo. Devuelve el dict válido o None si es inválida. + + Una entrada válida es un dict con `prompt` (str no vacío). `negative_prompt` se rellena + con `DEFAULT_NEGATIVE` si falta o está vacío. Campos extra se descartan (el formato WAS + sólo usa `prompt` y `negative_prompt`). + """ + if not isinstance(value, dict): + return None + prompt = value.get("prompt") + if not isinstance(prompt, str) or not prompt.strip(): + return None + neg = value.get("negative_prompt") + if not isinstance(neg, str) or not neg.strip(): + neg = DEFAULT_NEGATIVE + return {"prompt": prompt.strip(), "negative_prompt": neg.strip()} + + +def comfyui_append_styles( + new_styles: dict, + styles_path: str = DEFAULT_STYLES_PATH, + overwrite: bool = False, + backup: bool = True, + dry_run: bool = False, +) -> dict: + """Fusiona `new_styles` sobre el styles.json de WAS preservando los existentes. + + Args: + new_styles: dict {nombre: {"prompt": str, "negative_prompt": str}} de estilos a añadir. + Las entradas inválidas (sin `prompt`) se descartan; las que no traen + `negative_prompt` reciben uno por defecto. + styles_path: ruta del styles.json. Default: el de la instalación WAS del usuario. + overwrite: si False (default), un nombre que ya existe en el archivo NO se pisa (se + cuenta como duplicado saltado). Si True, los nuevos pisan a los existentes. + backup: si True (default), copia el archivo a `.bak.` antes de escribir. + dry_run: si True, calcula el merge y los conteos pero NO escribe nada (ni backup). + + Returns: + dict resumen: { + "styles_path", "backup_path", "total_before", "total_after", + "added": [nombres añadidos], "overwritten": [nombres pisados], + "skipped_existing": [nombres saltados por existir y overwrite=False], + "invalid": [nombres descartados por inválidos], "dry_run": bool + } + + Raises: + FileNotFoundError: si `styles_path` no existe (no se crea de cero para no enmascarar + una instalación rota; el caller debe asegurar que el archivo está). + ValueError: si `new_styles` no es un dict o el archivo existente no contiene un dict. + """ + if not isinstance(new_styles, dict): + raise ValueError("comfyui_append_styles: new_styles debe ser un dict") + if not os.path.isfile(styles_path): + raise FileNotFoundError(f"comfyui_append_styles: no existe styles.json en {styles_path!r}") + + with open(styles_path, "r", encoding="utf-8") as fh: + existing = json.load(fh) + if not isinstance(existing, dict): + raise ValueError( + f"comfyui_append_styles: el styles.json en {styles_path!r} no es un dict de estilos" + ) + + total_before = len(existing) + merged = dict(existing) # copia: no mutar el cargado hasta validar todo + + added: list[str] = [] + overwritten: list[str] = [] + skipped_existing: list[str] = [] + invalid: list[str] = [] + + for name, value in new_styles.items(): + norm = _validate_entry(value) + if norm is None: + invalid.append(str(name)) + continue + if name in existing: + if overwrite: + merged[name] = norm + overwritten.append(name) + else: + skipped_existing.append(name) + continue + merged[name] = norm + added.append(name) + + backup_path = "" + if not dry_run: + if backup: + backup_path = f"{styles_path}.bak.{int(time.time())}" + shutil.copy2(styles_path, backup_path) + # Escritura atómica: escribir a .tmp en el mismo dir y reemplazar. + tmp_path = f"{styles_path}.tmp.{os.getpid()}" + with open(tmp_path, "w", encoding="utf-8") as fh: + json.dump(merged, fh, ensure_ascii=False, indent=4) + os.replace(tmp_path, styles_path) + + return { + "styles_path": styles_path, + "backup_path": backup_path, + "total_before": total_before, + "total_after": len(merged), + "added": added, + "overwritten": overwritten, + "skipped_existing": skipped_existing, + "invalid": invalid, + "dry_run": dry_run, + } + + +if __name__ == "__main__": + import sys + + # CLI de conveniencia: lee un dict de estilos JSON de stdin (o de un archivo dado como + # primer arg) y lo fusiona. Con --dry-run no escribe. Imprime el resumen como JSON. + args = sys.argv[1:] + dry = "--dry-run" in args + over = "--overwrite" in args + path_args = [a for a in args if not a.startswith("--")] + if path_args: + with open(path_args[0], "r", encoding="utf-8") as fh: + payload = json.load(fh) + else: + payload = json.load(sys.stdin) + res = comfyui_append_styles(payload, overwrite=over, dry_run=dry) + print(json.dumps(res, ensure_ascii=False, indent=2)) diff --git a/python/functions/ml/comfyui_append_styles_test.py b/python/functions/ml/comfyui_append_styles_test.py new file mode 100644 index 00000000..2d041f67 --- /dev/null +++ b/python/functions/ml/comfyui_append_styles_test.py @@ -0,0 +1,134 @@ +"""Tests offline de comfyui_append_styles — no toca la instalación real ni la red. + +Usa un styles.json temporal en /tmp para validar merge, dedup, backup, validación y dry-run. +""" +import json +import os +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(__file__)) + +from comfyui_append_styles import comfyui_append_styles, DEFAULT_NEGATIVE + + +def _write_styles(tmpdir: str, data: dict) -> str: + path = os.path.join(tmpdir, "styles.json") + with open(path, "w", encoding="utf-8") as fh: + json.dump(data, fh, ensure_ascii=False, indent=4) + return path + + +def test_merge_preserva_existentes_y_anade_nuevos(): + with tempfile.TemporaryDirectory() as d: + path = _write_styles(d, { + "A": {"prompt": "a-style", "negative_prompt": "neg-a"}, + "B": {"prompt": "b-style", "negative_prompt": "neg-b"}, + }) + res = comfyui_append_styles( + {"C": {"prompt": "c-style", "negative_prompt": "neg-c"}}, + styles_path=path, + ) + assert res["total_before"] == 2 + assert res["total_after"] == 3 + assert res["added"] == ["C"] + loaded = json.load(open(path, encoding="utf-8")) + # Los existentes intactos. + assert loaded["A"] == {"prompt": "a-style", "negative_prompt": "neg-a"} + assert loaded["B"] == {"prompt": "b-style", "negative_prompt": "neg-b"} + assert loaded["C"] == {"prompt": "c-style", "negative_prompt": "neg-c"} + + +def test_dedup_no_pisa_por_defecto(): + with tempfile.TemporaryDirectory() as d: + path = _write_styles(d, {"A": {"prompt": "orig", "negative_prompt": "n"}}) + res = comfyui_append_styles( + {"A": {"prompt": "NUEVO", "negative_prompt": "n2"}}, + styles_path=path, + ) + assert res["skipped_existing"] == ["A"] + assert res["added"] == [] + assert res["total_after"] == 1 + loaded = json.load(open(path, encoding="utf-8")) + assert loaded["A"]["prompt"] == "orig" # preservado + + +def test_overwrite_si_se_pide(): + with tempfile.TemporaryDirectory() as d: + path = _write_styles(d, {"A": {"prompt": "orig", "negative_prompt": "n"}}) + res = comfyui_append_styles( + {"A": {"prompt": "NUEVO", "negative_prompt": "n2"}}, + styles_path=path, + overwrite=True, + ) + assert res["overwritten"] == ["A"] + loaded = json.load(open(path, encoding="utf-8")) + assert loaded["A"]["prompt"] == "NUEVO" + + +def test_negative_por_defecto_cuando_falta(): + with tempfile.TemporaryDirectory() as d: + path = _write_styles(d, {}) + res = comfyui_append_styles( + {"X": {"prompt": "solo-prompt"}}, # sin negative_prompt + styles_path=path, + ) + assert res["added"] == ["X"] + loaded = json.load(open(path, encoding="utf-8")) + assert loaded["X"]["negative_prompt"] == DEFAULT_NEGATIVE + + +def test_entradas_invalidas_se_descartan(): + with tempfile.TemporaryDirectory() as d: + path = _write_styles(d, {}) + res = comfyui_append_styles( + { + "ok": {"prompt": "valido"}, + "vacio": {"prompt": " "}, # prompt vacío + "no_dict": "string", # no es dict + "sin_prompt": {"negative_prompt": "n"}, + }, + styles_path=path, + ) + assert res["added"] == ["ok"] + assert set(res["invalid"]) == {"vacio", "no_dict", "sin_prompt"} + assert res["total_after"] == 1 + + +def test_backup_creado(): + with tempfile.TemporaryDirectory() as d: + path = _write_styles(d, {"A": {"prompt": "a", "negative_prompt": "n"}}) + res = comfyui_append_styles( + {"B": {"prompt": "b"}}, + styles_path=path, + ) + assert res["backup_path"] + assert os.path.isfile(res["backup_path"]) + # El backup contiene el estado ANTERIOR (sólo A). + bk = json.load(open(res["backup_path"], encoding="utf-8")) + assert list(bk) == ["A"] + + +def test_dry_run_no_escribe(): + with tempfile.TemporaryDirectory() as d: + path = _write_styles(d, {"A": {"prompt": "a", "negative_prompt": "n"}}) + before = open(path, encoding="utf-8").read() + res = comfyui_append_styles( + {"B": {"prompt": "b"}}, + styles_path=path, + dry_run=True, + ) + assert res["dry_run"] is True + assert res["added"] == ["B"] + assert res["total_after"] == 2 # calculado + assert res["backup_path"] == "" + after = open(path, encoding="utf-8").read() + assert before == after # archivo intacto + + +if __name__ == "__main__": + for name, fn in sorted(globals().items()): + if name.startswith("test_") and callable(fn): + fn() + print("PASS", name) + print("OK") diff --git a/python/functions/ml/comfyui_curated_styles_catalog.md b/python/functions/ml/comfyui_curated_styles_catalog.md new file mode 100644 index 00000000..6a45ed24 --- /dev/null +++ b/python/functions/ml/comfyui_curated_styles_catalog.md @@ -0,0 +1,71 @@ +--- +name: comfyui_curated_styles_catalog +kind: function +lang: py +domain: ml +version: "1.0.0" +purity: pure +signature: "def comfyui_curated_styles_catalog(category: str | None = None) -> dict" +description: "Catalogo curado de ~190 estilos para el selector WAS de ComfyUI (Prompt Styles Selector / Prompt Multiple Styles Selector), en el formato exacto {nombre: {prompt, negative_prompt}}. Cada prompt son MODIFICADORES de estilo potentes (camara, lente, iluminacion, render engine, medio artistico, paleta, mood), no descripcion de sujeto y sin el placeholder {prompt} (el selector multiple concatena los prompts). Organizado en 13 categorias (photography, render3d, painting, anime, pixel, illustration, comic, lighting, camera, material, scifi, fantasy, mood) construidas desde estructuras compactas con un negative compartido por familia, garantizando prompt+negative no vacios en toda entrada. Pensado para alimentar comfyui_append_styles (merge+dedup sobre el styles.json real). Pura: solo devuelve datos." +tags: [comfyui, ml, comfyui-styles, styles, was, catalog, presets] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +params: + - name: category + desc: "None (default) devuelve TODOS los estilos como dict plano {nombre: {prompt, negative_prompt}}. Una categoria valida (photography, render3d, painting, anime, pixel, illustration, comic, lighting, camera, material, scifi, fantasy, mood) filtra a esa categoria. El valor especial '__categories__' devuelve {categories: {cat: count}, total} para discovery. Categoria desconocida -> ValueError." +output: "Si category None: dict {nombre: {prompt, negative_prompt}} con los ~190 estilos. Si category valida: mismo formato filtrado. Si '__categories__': dict {categories: {cat: count}, total: int}." +tested: true +tests: ["golden: catalogo completo >=180 estilos", "edge toda entrada tiene prompt y negative_prompt no vacios", "edge ningun prompt contiene el placeholder literal {prompt}", "edge nombres unicos (total plano == suma de categorias)", "edge filtrar por categoria valida devuelve subset no vacio", "error categoria desconocida -> ValueError", "edge discovery __categories__ devuelve conteos coherentes"] +test_file_path: "python/functions/ml/comfyui_curated_styles_catalog_test.py" +file_path: "python/functions/ml/comfyui_curated_styles_catalog.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions")) +from ml.comfyui_curated_styles_catalog import comfyui_curated_styles_catalog + +# Discovery: que categorias hay y cuantos estilos +print(comfyui_curated_styles_catalog("__categories__")) +# {'categories': {'photography': 25, 'render3d': 18, ...}, 'total': 190} + +# Todos los estilos, listos para fusionar +todos = comfyui_curated_styles_catalog() +print(len(todos), "estilos") # 190 + +# Solo una categoria +foto = comfyui_curated_styles_catalog("photography") +print(foto["photo-golden-hour"]["prompt"]) +``` + +O lanzable: `./fn run comfyui_curated_styles_catalog` (imprime conteos por categoria + total). + +## Cuando usarla + +Cuando quieras AMPLIAR el repositorio de estilos del selector WAS de ComfyUI con un set grande, +curado y organizado por categoria, sin depender de un LLM ni inventarlos a mano. Es la fuente de +DATOS del flujo de ampliacion: llamala, pasa el dict a `comfyui_append_styles` (merge+dedup +preservando los existentes) y reinicia comfyui. Tambien util para inspeccionar/elegir estilos por +categoria. Para generar estilos NUEVOS no incluidos aqui, usa `comfyui_generate_styles_llm`. + +## Gotchas + +- Es PURA: solo devuelve el catalogo como datos. La escritura al styles.json real (con backup) la + hace `comfyui_append_styles`; el reinicio del server lo haces tu. +- Los `prompt` son MODIFICADORES de estilo, no sujetos. El selector multiple los CONCATENA, asi que + estan pensados para combinarse entre si y con el sujeto del workflow. +- Los nombres usan prefijos por categoria (`photo-`, `render-`, `paint-`, ...) para agrupar y + reducir colisiones con los estilos que ya viven en el styles.json (que usan otros nombres). El + dedup final por nombre lo garantiza `comfyui_append_styles`. +- Anadir un estilo = una linea `(nombre, modificadores)` en la categoria correspondiente de + `_CATEGORIES`. El negative se hereda de la familia. + +## Capability growth log + +(v1.0.0 — sin cambios todavia.) diff --git a/python/functions/ml/comfyui_curated_styles_catalog.py b/python/functions/ml/comfyui_curated_styles_catalog.py new file mode 100644 index 00000000..76d29502 --- /dev/null +++ b/python/functions/ml/comfyui_curated_styles_catalog.py @@ -0,0 +1,351 @@ +"""comfyui_curated_styles_catalog — catálogo curado de estilos para el selector WAS de ComfyUI. + +Devuelve un dict de estilos en el formato EXACTO que consumen los nodos WAS +`Prompt Styles Selector` / `Prompt Multiple Styles Selector`: + + { "NombreEstilo": {"prompt": "modificadores de estilo", "negative_prompt": "..."}, ... } + +El selector múltiple CONCATENA los `prompt` de los estilos elegidos, así que cada `prompt` +contiene MODIFICADORES de estilo potentes (cámara, lente, iluminación, render engine, medio +artístico, paleta, mood) y NO una descripción de sujeto ni el placeholder `{prompt}`. + +El catálogo se construye de forma programática desde estructuras compactas por categoría +(`_CATEGORIES`): una lista de `(nombre, modificadores)` por categoría más un `negative` +compartido por categoría. Esto garantiza que toda entrada tenga `prompt` y `negative_prompt` +no vacíos, y hace trivial añadir estilos (una línea por estilo). + +Pensado para alimentar `comfyui_append_styles`, que lo fusiona (merge+dedup, preservando los +existentes) sobre el styles.json real de la instalación WAS. + +Función PURA: sólo devuelve datos (copias), sin red ni I/O ni estado. +""" +from __future__ import annotations + +# Negativos compartidos por familia. Mantienen las entradas SFW y evitan los artefactos +# típicos de cada medio (p.ej. fotografía rechaza "cartoon/3d render"; el render 3D rechaza +# "flat/2d"). +_NEG_PHOTO = ( + "illustration, painting, drawing, cartoon, anime, 3d render, cgi, sketch, " + "lowres, blurry, deformed, bad anatomy, watermark, signature, text, NSFW" +) +_NEG_RENDER3D = ( + "flat, 2d, hand-drawn, sketch, painterly, lowres, blurry, noisy, deformed, " + "bad anatomy, watermark, signature, text, NSFW" +) +_NEG_PAINT = ( + "photo, photorealistic, 3d render, cgi, lowres, blurry, noisy, jpeg artifacts, " + "deformed, bad anatomy, watermark, signature, text, NSFW" +) +_NEG_ANIME = ( + "photo, photorealistic, 3d render, realistic, lowres, blurry, extra limbs, " + "bad anatomy, bad hands, deformed, watermark, signature, text, NSFW" +) +_NEG_PIXEL = ( + "blurry, smooth, photorealistic, 3d render, antialiasing, soft, gradient, noise, " + "realistic, deformed, watermark, signature, text, NSFW" +) +_NEG_ILLUS = ( + "photo, photorealistic, lowres, blurry, noisy, jpeg artifacts, deformed, " + "bad anatomy, watermark, signature, text, NSFW" +) +_NEG_COMIC = ( + "photo, photorealistic, 3d render, blurry, lowres, messy lines, deformed, " + "bad anatomy, watermark, signature, NSFW" +) +_NEG_SCIFI = ( + "lowres, blurry, noisy, flat lighting, washed out, deformed, bad anatomy, " + "watermark, signature, text, NSFW" +) +_NEG_FANTASY = ( + "lowres, blurry, noisy, modern, contemporary, deformed, bad anatomy, " + "watermark, signature, text, NSFW" +) +_NEG_LIGHT = ( + "flat lighting, washed out, overexposed, underexposed, lowres, blurry, noisy, " + "deformed, watermark, signature, text, NSFW" +) +_NEG_CAMERA = ( + "lowres, blurry, motion blur, noisy, jpeg artifacts, deformed, bad anatomy, " + "watermark, signature, text, NSFW" +) +_NEG_MATERIAL = ( + "lowres, blurry, noisy, flat, dull, deformed, watermark, signature, text, NSFW" +) +_NEG_MOOD = ( + "lowres, blurry, noisy, flat, dull, deformed, bad anatomy, watermark, " + "signature, text, NSFW" +) + +# Cada entrada de categoría: (negative_compartido, [(nombre, modificadores_de_prompt), ...]). +# El prefijo del nombre (ej. "photo-", "render-") agrupa el estilo en el selector y evita +# colisiones con los 269 estilos ya presentes (que usan otros prefijos / nombres propios). +_CATEGORIES: dict[str, tuple[str, list[tuple[str, str]]]] = { + "photography": (_NEG_PHOTO, [ + ("photo-golden-hour", "professional photograph, golden hour lighting, warm sunlight, long soft shadows, 50mm lens, shallow depth of field, sharp focus, high detail, kodak portra 400"), + ("photo-blue-hour", "professional photograph, blue hour twilight, cool ambient light, city lights, 35mm lens, crisp detail, cinematic color grade"), + ("photo-studio-portrait", "studio portrait photograph, softbox key light, seamless backdrop, 85mm f1.4, creamy bokeh, tack-sharp eyes, skin texture detail"), + ("photo-macro", "extreme macro photograph, 1:1 magnification, focus stacking, razor-thin depth of field, intricate microscopic detail, ring light"), + ("photo-aerial-drone", "aerial drone photograph, top-down bird's eye view, wide landscape, atmospheric perspective, high altitude, crisp detail"), + ("photo-street", "candid street photograph, 35mm reportage, available light, decisive moment, grainy film, high contrast black and white"), + ("photo-documentary", "documentary photograph, photojournalism, natural light, authentic moment, 35mm, muted color, honest realism"), + ("photo-fashion-editorial", "high fashion editorial photograph, dramatic studio lighting, vogue magazine, glossy, bold styling, medium format detail"), + ("photo-product", "commercial product photograph, clean white background, soft gradient light, sharp focus, reflections, advertising quality"), + ("photo-food", "appetizing food photograph, natural side light, shallow depth of field, fresh ingredients, steam, glossy highlights, culinary magazine"), + ("photo-wildlife", "wildlife photograph, telephoto 600mm, natural habitat, frozen action, soft background blur, national geographic"), + ("photo-astrophotography", "astrophotography, milky way, long exposure night sky, star trails, deep space detail, low light, tripod, wide angle"), + ("photo-underwater", "underwater photograph, caustic light rays, suspended particles, blue-green water, diffused sunlight, marine scene"), + ("photo-infrared", "infrared photograph, false color, white foliage, dark sky, surreal tonal inversion, dreamy ethereal"), + ("photo-pinhole", "pinhole camera photograph, soft vignette, long exposure, dreamy blur, vintage analog, light leaks"), + ("photo-medium-format", "medium format photograph, hasselblad, 100 megapixel detail, ultra-high resolution, perfect tonality, fine grain"), + ("photo-cinematic-still", "cinematic film still, anamorphic lens, teal and orange grade, lens flare, 2.39:1 framing, dramatic key light, movie scene"), + ("photo-noir", "film noir photograph, high contrast black and white, hard venetian-blind shadows, low key, dramatic chiaroscuro, 1940s"), + ("photo-polaroid", "vintage polaroid photograph, instant film, faded colors, soft focus, white frame, nostalgic, light leak"), + ("photo-lomography", "lomography photograph, saturated colors, heavy vignette, cross-processed, light leaks, analog film, unpredictable"), + ("photo-tilt-shift-mini", "tilt-shift photograph, miniature faking, selective focus band, toy-like scale, vivid saturation"), + ("photo-double-exposure", "double exposure photograph, blended silhouettes, layered imagery, surreal montage, high contrast"), + ("photo-hdr-landscape", "HDR landscape photograph, ultra wide angle, deep depth of field, vivid dynamic range, dramatic clouds, crisp foreground detail"), + ("photo-overcast-soft", "soft overcast photograph, diffused even light, no harsh shadows, gentle muted tones, natural color"), + ("photo-backlit-rim", "backlit photograph, strong rim light, glowing edges, lens flare, hazy atmosphere, silhouette contrast"), + ]), + "render3d": (_NEG_RENDER3D, [ + ("render-octane", "octane render, physically based, global illumination, subsurface scattering, 8k textures, photorealistic 3d, ray traced reflections"), + ("render-unreal5", "unreal engine 5 render, lumen global illumination, nanite detail, cinematic, volumetric fog, ultra realistic real-time"), + ("render-blender-cycles", "blender cycles render, path traced, soft shadows, denoised, studio hdri lighting, clean 3d"), + ("render-pixar-style", "pixar style 3d render, stylized character, soft global illumination, subsurface skin, expressive, polished animation film"), + ("render-clay", "clay render, matte grey material, ambient occlusion, soft studio light, sculptural form, no textures"), + ("render-isometric-3d", "isometric 3d render, clean diorama, orthographic view, soft shadows, miniature scene, vibrant solid colors"), + ("render-product-cgi", "product cgi render, glossy reflections, studio lighting setup, caustics, ultra clean, advertising 3d"), + ("render-architectural-viz", "architectural visualization, photoreal interior render, natural window light, vray, realistic materials, magazine quality"), + ("render-voxel", "voxel art 3d, cubic blocks, magicavoxel, isometric, vibrant palette, soft ambient occlusion, crisp cubes"), + ("render-toon-3d", "toon shaded 3d render, cel shading, outline pass, flat lit, stylized cartoon 3d, clean colors"), + ("render-zbrush-sculpt", "zbrush high-poly sculpt, detailed surface, matcap red wax, intricate displacement, digital clay"), + ("render-glass-iridescent", "iridescent glass 3d render, refraction, dispersion rainbow, translucent material, studio reflections, dreamy"), + ("render-inflatable", "inflatable balloon 3d render, glossy latex material, soft puffy form, bright studio light, playful"), + ("render-liquid-metal", "liquid chrome 3d render, mirror reflections, flowing metal, abstract, high gloss, studio hdri"), + ("render-papercraft-3d", "papercraft 3d render, folded paper texture, soft shadows, layered cutouts, handmade diorama"), + ("render-low-poly", "low poly 3d render, faceted geometry, flat shaded, minimal polygons, clean solid colors, stylized game asset"), + ("render-vaporwave-3d", "vaporwave 3d render, chrome and pastel, retro grid floor, neon glow, surreal 80s aesthetic, glossy"), + ("render-miniature-diorama", "miniature diorama 3d render, tilt-shift scale, tiny detailed scene, soft top light, charming"), + ]), + "painting": (_NEG_PAINT, [ + ("paint-oil-classical", "classical oil painting, old master technique, rich impasto, chiaroscuro, warm varnish glaze, baroque, museum quality"), + ("paint-impressionist", "impressionist painting, loose visible brushstrokes, dappled light, plein air, vibrant broken color, monet style"), + ("paint-watercolor", "watercolor painting, soft wet-on-wet washes, blooming pigment, paper texture, delicate transparency, loose edges"), + ("paint-gouache", "gouache painting, opaque matte color, flat bold shapes, illustrative, soft texture, mid-century"), + ("paint-acrylic-bold", "acrylic painting, bold saturated color, confident brushwork, thick texture, contemporary, expressive"), + ("paint-renaissance", "renaissance painting, sfumato, balanced composition, soft modeling, religious fresco, da vinci style, tempera"), + ("paint-baroque", "baroque painting, dramatic tenebrism, dynamic diagonal composition, rich shadow, caravaggio, opulent"), + ("paint-romanticism", "romanticism painting, sublime landscape, dramatic sky, emotional grandeur, turner style, atmospheric"), + ("paint-art-nouveau", "art nouveau painting, flowing organic lines, decorative floral border, mucha style, elegant, ornamental gold"), + ("paint-ukiyo-e", "ukiyo-e woodblock print, flat color planes, bold outlines, japanese, hokusai style, delicate gradient sky"), + ("paint-cubism", "cubist painting, fragmented geometric planes, multiple viewpoints, muted earth tones, picasso braque style"), + ("paint-surrealism", "surrealist painting, dreamlike imagery, impossible juxtaposition, dali style, melting forms, uncanny"), + ("paint-fauvism", "fauvist painting, wild non-natural color, bold flat brushwork, matisse style, vivid expressive"), + ("paint-pointillism", "pointillist painting, tiny dots of pure color, optical blending, seurat style, luminous texture"), + ("paint-expressionist", "expressionist painting, raw emotional brushwork, distorted form, intense color, munch style, angst"), + ("paint-tonalism", "tonalist painting, muted misty atmosphere, limited palette, soft diffused light, poetic mood, hazy"), + ("paint-fresco", "fresco mural painting, matte plaster surface, earthy pigment, aged texture, italian renaissance wall"), + ("paint-ink-wash", "sumi-e ink wash painting, minimal brushstrokes, negative space, monochrome black ink, zen, expressive gesture"), + ("paint-palette-knife", "palette knife oil painting, thick textured strokes, bold impasto ridges, sculptural paint, vivid"), + ("paint-gothic-icon", "gothic icon painting, gold leaf background, flat stylized figures, byzantine, ornate halo, tempera"), + ]), + "anime": (_NEG_ANIME, [ + ("anime-modern-tv", "modern anime style, clean cel shading, vibrant colors, detailed eyes, crisp lineart, tv anime key visual"), + ("anime-90s-retro", "90s retro anime style, hand-painted cels, muted film grain, nostalgic, classic anime aesthetic"), + ("anime-shoujo", "shoujo manga style, sparkling eyes, soft pastel tones, delicate screentone, floral background, romantic"), + ("anime-shonen-action", "shonen anime style, dynamic action pose, bold speed lines, intense expression, vibrant, energetic"), + ("anime-ghibli-inspired", "studio ghibli inspired, soft painterly background, gentle colors, whimsical, hand-drawn warmth, detailed nature"), + ("anime-makoto-shinkai", "makoto shinkai style, hyper-detailed sky, lens flare, vivid saturated color, photorealistic background, emotional"), + ("anime-chibi", "chibi style, super deformed, oversized head, tiny body, cute rounded shapes, simple flat colors"), + ("anime-mecha", "mecha anime style, detailed robot panels, hard surface, dramatic perspective, metallic shading, sci-fi"), + ("anime-dark-fantasy", "dark fantasy anime, gritty detailed lineart, muted desaturated palette, ominous mood, berserk style"), + ("anime-pastel-soft", "soft pastel anime, gentle gradient shading, dreamy light, kawaii, delicate, low contrast pastel palette"), + ("anime-manga-bw", "black and white manga, screentone shading, ink hatching, dynamic panels, sharp lineart, no color"), + ("anime-cyberpunk", "cyberpunk anime, neon-lit night city, holographic ui, akira style, gritty detail, glowing accents"), + ("anime-watercolor-illust", "anime watercolor illustration, soft wash background, delicate lineart, gentle bloom, light and airy"), + ("anime-key-visual", "anime key visual, polished promotional art, intricate detail, dramatic lighting, official poster quality"), + ("anime-vtuber-style", "vtuber model style, glossy big eyes, clean gradient shading, bright vibrant, live2d aesthetic, cute"), + ]), + "pixel": (_NEG_PIXEL, [ + ("pixel-8bit-nes", "8-bit pixel art, NES palette, chunky pixels, limited 4 color, dithering, retro game sprite, flat"), + ("pixel-16bit-snes", "16-bit pixel art, SNES JRPG sprite, rich palette, clean outline, detailed dithering, retro game"), + ("pixel-gameboy", "game boy pixel art, 4 shade green monochrome, 1-bit dithering, low res, dmg handheld, retro"), + ("pixel-isometric", "isometric pixel art, 2:1 tile grid, crisp pixels, game asset, clean shading, vibrant palette"), + ("pixel-cyber-neon", "neon pixel art, glowing cyberpunk sprite, dark background, bright accent pixels, synthwave palette"), + ("pixel-demake", "demake pixel art, retro reimagining, limited palette, chunky sprite, nostalgic 16-bit"), + ("pixel-portrait", "pixel art portrait, detailed face sprite, hand-placed pixels, anti-alias dithering, vibrant"), + ("pixel-landscape", "pixel art landscape, parallax background, detailed scenery, soft dithered gradient sky, retro game"), + ("pixel-1bit", "1-bit pixel art, pure black and white, dithered shading, minimal, retro monochrome, high contrast"), + ("pixel-pico8", "pico-8 style pixel art, 16 color palette, chunky low-res, charming fantasy console sprite"), + ("pixel-hd-modern", "modern HD pixel art, detailed sprite, smooth palette, sub-pixel animation feel, indie game polish"), + ("pixel-horror", "horror pixel art, dark muted palette, eerie dithering, unsettling sprite, retro survival horror mood"), + ]), + "illustration": (_NEG_ILLUS, [ + ("illust-childrens-book", "children's book illustration, soft warm colors, friendly rounded shapes, storybook charm, gentle texture, whimsical"), + ("illust-flat-vector", "flat vector illustration, bold geometric shapes, solid colors, minimal, clean, modern graphic design"), + ("illust-editorial", "editorial illustration, conceptual metaphor, limited palette, textured grain, sophisticated, magazine art"), + ("illust-storybook-painterly", "painterly storybook illustration, rich textured brushwork, warm narrative scene, fairy tale, detailed"), + ("illust-line-art", "clean line art illustration, single weight ink lines, minimal, elegant, no shading, contour drawing"), + ("illust-risograph", "risograph print illustration, limited spot colors, grainy texture, slight misregistration, retro indie print"), + ("illust-isometric-infographic", "isometric infographic illustration, clean technical diagram, soft shadows, flat colors, vector"), + ("illust-botanical", "vintage botanical illustration, detailed scientific drawing, watercolor and ink, labeled, antique plate"), + ("illust-fantasy-cover", "fantasy book cover illustration, epic dramatic scene, detailed painterly, rich color, cinematic composition"), + ("illust-sticker", "die-cut sticker illustration, bold outline, glossy, cute kawaii, flat vibrant colors, simple shapes"), + ("illust-vintage-poster", "vintage poster illustration, mid-century travel ad, limited palette, bold typography space, screenprint texture"), + ("illust-pen-and-ink", "pen and ink illustration, fine cross-hatching, stippling, detailed monochrome, etching style, intricate"), + ("illust-art-deco", "art deco illustration, geometric symmetry, gold and black, elegant streamline, 1920s glamour, ornamental"), + ("illust-naive-folk", "naive folk art illustration, flat childlike charm, decorative patterns, bright primary colors, handcrafted"), + ("illust-scratchboard", "scratchboard illustration, white lines on black, fine engraving texture, dramatic high contrast, detailed"), + ("illust-collage-mixed", "mixed media collage illustration, torn paper, layered textures, hand-cut shapes, eclectic, tactile"), + ]), + "comic": (_NEG_COMIC, [ + ("comic-american", "american comic book art, bold ink outlines, dynamic ben-day dots, dramatic shading, superhero, vibrant"), + ("comic-noir-graphic", "noir graphic novel, high contrast black and white, heavy shadows, sin city style, stark ink"), + ("comic-european-bd", "european bande dessinée, clear ligne claire lines, flat color, detailed background, tintin moebius style"), + ("comic-manga-action", "manga action comic, dynamic panels, speed lines, screentone, dramatic ink, expressive"), + ("comic-newspaper-strip", "newspaper comic strip, simple clean cartoon, flat colors, humorous, classic funnies style"), + ("comic-golden-age", "golden age comic art, retro 1940s, halftone print texture, primary colors, pulp hero, vintage"), + ("comic-webtoon", "webtoon style, vertical scroll, soft digital coloring, clean lineart, modern korean comic, gradient"), + ("comic-underground", "underground comix, crosshatched grit, satirical, rough ink, robert crumb style, dense detail"), + ("comic-superhero-modern", "modern superhero comic, painterly digital ink, dynamic anatomy, dramatic lighting, detailed, cinematic"), + ("comic-cartoon-network", "cartoon network style, bold flat color, simple expressive shapes, quirky, clean thick outlines"), + ]), + "lighting": (_NEG_LIGHT, [ + ("light-rembrandt", "rembrandt lighting, soft key light, triangle cheek highlight, deep falloff shadow, classic portrait"), + ("light-rim-backlight", "dramatic rim lighting, glowing edge light, dark background, strong separation, cinematic backlight"), + ("light-volumetric-god-rays", "volumetric god rays, light shafts through haze, atmospheric beams, dust particles, dramatic"), + ("light-neon-glow", "neon glow lighting, vibrant magenta and cyan, reflective wet surfaces, urban night, colorful bloom"), + ("light-candlelit", "warm candlelit scene, soft flickering glow, intimate, deep warm shadows, chiaroscuro, cozy"), + ("light-bioluminescent", "bioluminescent glow, ethereal blue-green light, magical particles, dark surroundings, otherworldly"), + ("light-studio-softbox", "clean studio softbox lighting, even diffused, soft shadows, professional, balanced exposure"), + ("light-harsh-noon", "harsh midday sun, hard sharp shadows, high contrast, blown highlights, intense overhead light"), + ("light-moonlit", "moonlit night, cool blue ambient, soft silver highlights, deep shadows, serene nocturnal mood"), + ("light-firelit-ember", "firelit scene, warm orange ember glow, dancing shadows, dramatic warmth, intimate"), + ("light-split-dramatic", "split lighting, half face lit half shadow, bold dramatic contrast, mysterious, low key"), + ("light-soft-window", "soft window light, gentle directional daylight, natural falloff, painterly, vermeer mood"), + ("light-color-gel", "color gel lighting, dual contrasting hues, magenta and teal, moody, fashion editorial, vibrant"), + ("light-overcast-flat", "soft overcast light, even diffused grey sky, no harsh shadows, gentle muted tonality"), + ]), + "camera": (_NEG_CAMERA, [ + ("cam-fisheye", "fisheye lens, extreme wide angle distortion, curved horizon, bulging perspective, 180 degree view"), + ("cam-wide-angle", "ultra wide angle lens, 14mm, expansive perspective, exaggerated depth, sweeping foreground"), + ("cam-telephoto-compression", "telephoto lens 200mm, compressed perspective, flattened background, isolated subject, creamy bokeh"), + ("cam-bokeh-portrait", "85mm f1.2, dreamy bokeh, razor shallow depth of field, smooth background blur, sharp subject"), + ("cam-dutch-angle", "dutch angle tilt, dynamic diagonal framing, unsettling composition, dramatic tension"), + ("cam-low-angle-hero", "low angle hero shot, looking up, towering imposing perspective, dramatic, powerful"), + ("cam-high-angle-overhead", "high angle overhead shot, looking down, top perspective, contextual, diminutive subject"), + ("cam-anamorphic", "anamorphic widescreen, horizontal blue lens flare, oval bokeh, 2.39:1 cinematic, film look"), + ("cam-motion-pan", "motion blur panning shot, sharp subject streaked background, sense of speed, dynamic"), + ("cam-long-exposure", "long exposure, silky motion trails, light streaks, smooth water, static sharp elements"), + ("cam-shallow-tilt", "selective focus, tilt lens, plane of focus, blurred surroundings, intimate detail isolation"), + ("cam-vintage-lens", "vintage lens character, soft glow, swirly bokeh, gentle vignette, helios rendering, dreamy imperfection"), + ]), + "material": (_NEG_MATERIAL, [ + ("mat-gold-leaf", "gold leaf material, lustrous metallic sheen, ornate gilded surface, warm reflections, opulent"), + ("mat-marble", "polished marble material, veined stone, smooth cool surface, subsurface translucence, classical"), + ("mat-frosted-glass", "frosted glass material, translucent diffusion, soft light scatter, smooth matte transparency"), + ("mat-rusted-metal", "rusted weathered metal, corroded patina, flaking paint, industrial grit, oxidized texture"), + ("mat-velvet", "rich velvet material, soft plush nap, deep light absorption, luxurious sheen, tactile fabric"), + ("mat-holographic", "holographic iridescent material, rainbow shimmer, prismatic reflections, futuristic foil, shifting color"), + ("mat-wood-grain", "natural wood grain material, warm organic texture, visible rings, matte finish, handcrafted"), + ("mat-liquid-chrome", "liquid chrome material, mirror polish, flowing reflective metal, high gloss, abstract"), + ("mat-crystal", "crystal material, faceted transparency, internal refraction, sparkling dispersion, gemstone clarity"), + ("mat-ceramic-glaze", "glazed ceramic material, smooth glossy coat, subtle crackle, soft highlights, handmade pottery"), + ("mat-knitted-wool", "knitted wool material, cozy yarn texture, soft fibers, handmade stitch pattern, warm tactile"), + ("mat-neon-acrylic", "translucent neon acrylic, glowing edge, bright saturated plastic, glossy, vibrant"), + ]), + "scifi": (_NEG_SCIFI, [ + ("scifi-cyberpunk-city", "cyberpunk megacity, towering neon skyscrapers, holographic ads, rain-slicked streets, blade runner, dense"), + ("scifi-solarpunk", "solarpunk utopia, lush green architecture, solar panels, optimistic bright, harmonious nature-tech, airy"), + ("scifi-space-opera", "space opera, vast starship fleet, nebula backdrop, epic galactic scale, dramatic, cinematic sci-fi"), + ("scifi-biopunk", "biopunk, organic biotech, fleshy machinery, glowing veins, unsettling living technology, detailed"), + ("scifi-retro-futurism", "retro futurism, 1950s atomic age vision, chrome rockets, ray guns, optimistic vintage sci-fi"), + ("scifi-dieselpunk", "dieselpunk, industrial diesel-era machinery, art deco tech, grimy chrome, alternate 1940s, mechanical"), + ("scifi-hard-sci-fi", "hard sci-fi, plausible realistic spacecraft, functional engineering, NASA punk, grounded detail"), + ("scifi-alien-world", "alien world, exotic flora, strange atmosphere, twin suns, otherworldly landscape, vivid surreal"), + ("scifi-post-apocalyptic", "post-apocalyptic wasteland, ruined overgrown city, rusted decay, dust haze, desolate survival, gritty"), + ("scifi-holographic-ui", "futuristic holographic interface, glowing data displays, translucent screens, sleek tech, blue glow"), + ("scifi-mecha-hangar", "mecha hangar, giant robots, industrial scaffolding, dramatic spotlights, hard surface detail, scale"), + ("scifi-space-station", "orbital space station interior, sleek panels, viewport to earth, zero-g, clean futuristic, soft glow"), + ]), + "fantasy": (_NEG_FANTASY, [ + ("fantasy-high-epic", "high fantasy epic, grand sweeping vista, mythical scale, dramatic lighting, detailed, lord of the rings"), + ("fantasy-dark-grim", "dark grimdark fantasy, brooding atmosphere, muted desaturated palette, ominous, gritty, foreboding"), + ("fantasy-fairytale", "fairytale fantasy, enchanted forest, glowing magic, whimsical storybook, soft luminous, dreamy"), + ("fantasy-norse-mythic", "norse mythology, rugged frozen landscape, runic carvings, epic gods, cold dramatic, ancient"), + ("fantasy-eldritch-cosmic", "eldritch cosmic horror, lovecraftian, impossible geometry, tentacled dread, otherworldly, eerie green glow"), + ("fantasy-enchanted-forest", "enchanted magical forest, glowing flora, floating spores, dappled mystical light, lush detailed"), + ("fantasy-dragon-lair", "dragon lair, hoarded gold, cavernous dark, glinting treasure, dramatic firelight, epic scale"), + ("fantasy-celestial", "celestial fantasy, divine heavenly light, ethereal clouds, golden radiance, angelic, luminous"), + ("fantasy-underwater-kingdom", "underwater fantasy kingdom, coral spires, bioluminescent, flowing currents, magical aquatic, serene"), + ("fantasy-steampunk", "steampunk fantasy, brass clockwork, victorian machinery, steam pipes, copper gears, ornate retro-tech"), + ("fantasy-witch-gothic", "gothic witch aesthetic, candlelit potion lab, dark academia, mystical herbs, moody candlelight, ornate"), + ("fantasy-desert-mystic", "mystic desert fantasy, ancient ruins, shifting dunes, mirage haze, golden sand, lost civilization"), + ]), + "mood": (_NEG_MOOD, [ + ("mood-cozy-warm", "cozy warm atmosphere, soft amber light, comfortable intimate, gentle, hygge, inviting"), + ("mood-melancholic", "melancholic mood, muted desaturated tones, soft rain, wistful solitude, pensive, quiet"), + ("mood-ethereal-dreamy", "ethereal dreamy atmosphere, soft glowing haze, pastel light, floating, surreal serenity, gentle bloom"), + ("mood-ominous-tense", "ominous tense atmosphere, dark looming shadows, foreboding, cold palette, suspense, heavy"), + ("mood-nostalgic", "nostalgic mood, faded warm film tones, soft grain, vintage memory, golden wistful, hazy"), + ("mood-serene-calm", "serene calm atmosphere, soft balanced light, peaceful stillness, minimal, gentle harmony"), + ("mood-epic-awe", "epic awe-inspiring mood, vast scale, dramatic god rays, sublime grandeur, sweeping, majestic"), + ("mood-eerie-liminal", "eerie liminal space, empty unsettling, fluorescent flat light, uncanny stillness, dreamlike dread"), + ("mood-romantic-soft", "romantic soft mood, warm rosy glow, gentle bokeh, tender intimate, dreamy haze, delicate"), + ("mood-energetic-vibrant", "energetic vibrant mood, saturated bold color, dynamic, lively, punchy contrast, upbeat"), + ("mood-foggy-mysterious", "foggy mysterious atmosphere, thick mist, obscured forms, muted, low visibility, haunting"), + ("mood-festive-joyful", "festive joyful atmosphere, warm bokeh lights, celebratory glow, vibrant, cheerful sparkle"), + ]), +} + + +def _build_catalog() -> dict[str, dict[str, str]]: + """Construye el dict completo {nombre: {prompt, negative_prompt}} desde _CATEGORIES.""" + out: dict[str, dict[str, str]] = {} + for _cat, (neg, items) in _CATEGORIES.items(): + for name, prompt in items: + out[name] = {"prompt": prompt, "negative_prompt": neg} + return out + + +def comfyui_curated_styles_catalog(category: str | None = None) -> dict: + """Devuelve el catálogo curado de estilos WAS, o sólo una categoría. + + Args: + category: si None (default), devuelve TODOS los estilos como dict plano + {nombre: {"prompt", "negative_prompt"}}. Si es una categoría válida + (`photography`, `render3d`, `painting`, `anime`, `pixel`, `illustration`, + `comic`, `lighting`, `camera`, `material`, `scifi`, `fantasy`, `mood`), + devuelve sólo los estilos de esa categoría. El valor especial `"__categories__"` + devuelve la lista de categorías y sus conteos (discovery). + + Returns: + - category None: dict {nombre: {"prompt", "negative_prompt"}} con todos los estilos. + - category válida: mismo formato, filtrado a esa categoría. + - category "__categories__": dict {"categories": {cat: count}, "total": int}. + + Raises: + ValueError: si `category` no es None/"__categories__" ni una categoría conocida. + """ + if category == "__categories__": + return { + "categories": {cat: len(items) for cat, (_neg, items) in _CATEGORIES.items()}, + "total": sum(len(items) for _neg, items in _CATEGORIES.values()), + } + if category is None: + return _build_catalog() + if category not in _CATEGORIES: + raise ValueError( + f"comfyui_curated_styles_catalog: categoría desconocida {category!r}. " + f"Disponibles: {sorted(_CATEGORIES)}" + ) + neg, items = _CATEGORIES[category] + return {name: {"prompt": prompt, "negative_prompt": neg} for name, prompt in items} + + +if __name__ == "__main__": + import json + + print(json.dumps(comfyui_curated_styles_catalog("__categories__"), indent=2)) + full = comfyui_curated_styles_catalog() + print("TOTAL ESTILOS:", len(full)) diff --git a/python/functions/ml/comfyui_curated_styles_catalog_test.py b/python/functions/ml/comfyui_curated_styles_catalog_test.py new file mode 100644 index 00000000..0481dcc9 --- /dev/null +++ b/python/functions/ml/comfyui_curated_styles_catalog_test.py @@ -0,0 +1,64 @@ +"""Tests offline de comfyui_curated_styles_catalog — puro, sin red ni I/O.""" +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) + +from comfyui_curated_styles_catalog import comfyui_curated_styles_catalog + + +def test_catalogo_completo_no_vacio(): + cat = comfyui_curated_styles_catalog() + assert isinstance(cat, dict) + assert len(cat) >= 180, f"esperaba >=180 estilos curados, hay {len(cat)}" + + +def test_toda_entrada_tiene_prompt_y_negative_no_vacios(): + cat = comfyui_curated_styles_catalog() + for name, v in cat.items(): + assert isinstance(v, dict), f"{name} no es dict" + assert v.get("prompt", "").strip(), f"{name} prompt vacío" + assert v.get("negative_prompt", "").strip(), f"{name} negative vacío" + + +def test_sin_placeholder_prompt_literal(): + # El selector múltiple concatena prompts; NO debe llevar el placeholder {prompt}. + cat = comfyui_curated_styles_catalog() + for name, v in cat.items(): + assert "{prompt}" not in v["prompt"], f"{name} contiene placeholder {{prompt}}" + + +def test_nombres_unicos(): + cat = comfyui_curated_styles_catalog() + # dict ya garantiza claves únicas; comprobamos que no hay colisión entre categorías + # (el total plano == suma de categorías) -> ningún nombre se pisó al construir. + meta = comfyui_curated_styles_catalog("__categories__") + assert len(cat) == meta["total"], "colisión de nombres entre categorías" + + +def test_filtrar_por_categoria(): + photo = comfyui_curated_styles_catalog("photography") + assert len(photo) >= 10 + assert all(v["prompt"] for v in photo.values()) + + +def test_categoria_desconocida_lanza(): + try: + comfyui_curated_styles_catalog("noexiste") + except ValueError: + return + raise AssertionError("esperaba ValueError para categoría desconocida") + + +def test_discovery_categorias(): + meta = comfyui_curated_styles_catalog("__categories__") + assert "categories" in meta and "total" in meta + assert meta["total"] == sum(meta["categories"].values()) + + +if __name__ == "__main__": + for name, fn in sorted(globals().items()): + if name.startswith("test_") and callable(fn): + fn() + print("PASS", name) + print("OK") diff --git a/python/functions/ml/comfyui_generate_styles_llm.md b/python/functions/ml/comfyui_generate_styles_llm.md new file mode 100644 index 00000000..1931e8c9 --- /dev/null +++ b/python/functions/ml/comfyui_generate_styles_llm.md @@ -0,0 +1,73 @@ +--- +name: comfyui_generate_styles_llm +kind: function +lang: py +domain: ml +version: "1.0.0" +purity: impure +signature: "def comfyui_generate_styles_llm(category: str, n: int = 8, prefix: str = '', avoid: list[str] | None = None, model: str = 'claude-haiku-4-5-20251001') -> dict" +description: "Genera N estilos para el selector WAS de ComfyUI de una CATEGORIA tematica usando el LLM via ask_llm (grupo claude-direct, API directa de Anthropic, arranque 0). Devuelve el dict en el formato exacto {nombre: {prompt, negative_prompt}}, con prompts que son MODIFICADORES de estilo (camara, lente, iluminacion, render, medio, paleta, mood), sin descripcion de sujeto y sin el placeholder {prompt}. Robusta por diseno: extrae el primer bloque JSON de la respuesta (tolera fences markdown y prosa), valida entrada por entrada (descarta sin prompt, dedup contra avoid, rellena negative por defecto, limpia {prompt} si aparece) y ante CUALQUIER fallo (429 rate-limit, JSON corrupto, respuesta vacia, ask_llm ausente) devuelve {} en vez de lanzar — asi el caller itera categorias y sigue aunque una falle. Impura: llama a la API del LLM (red); no escribe disco." +tags: [comfyui, ml, comfyui-styles, styles, was, llm, claude-direct] +uses_functions: [ask_llm_py_core] +uses_types: [] +returns: [] +returns_optional: false +error_type: error_go_core +imports: [] +params: + - name: category + desc: "Tema de los estilos (texto libre que dirige el contenido), ej. 'vaporwave aesthetics', 'baroque painting', 'macro insect photography'." + - name: n + desc: "Numero de estilos a pedir. El modelo puede devolver menos; se validan todos. Default 8." + - name: prefix + desc: "Prefijo para los nombres generados (ej. 'vapor-') para agrupar y reducir colisiones con estilos existentes. Vacio = sin prefijo." + - name: avoid + desc: "Lista de nombres ya existentes que el modelo NO debe repetir (dedup previo). Tambien se filtran a posteriori en la validacion." + - name: model + desc: "Id del modelo Anthropic. Default claude-haiku-4-5-20251001 (mas cuota, rapido). Otros: claude-opus-4-8, claude-sonnet-4-6." +output: "dict {nombre: {prompt, negative_prompt}} ya validado (prompt no vacio, sin {prompt}, negative rellenado). Vacio {} si el LLM falla, no hay JSON valido o no quedan entradas utilizables. NUNCA lanza: el error-path es devolver {}." +tested: true +tests: ["edge extract json plano", "edge extract json con fence markdown y prosa alrededor", "edge extract json corrupto/vacio -> None", "edge validate descarta no-dict, prompt vacio, duplicado en avoid; rellena negative por defecto", "edge validate limpia el placeholder {prompt}", "error ask_llm lanza (429) -> devuelve {} sin propagar", "error ask_llm ausente (None) -> {}", "golden respuesta JSON valida se parsea a dict de estilos"] +test_file_path: "python/functions/ml/comfyui_generate_styles_llm_test.py" +file_path: "python/functions/ml/comfyui_generate_styles_llm.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions")) +from ml.comfyui_generate_styles_llm import comfyui_generate_styles_llm +from ml.comfyui_append_styles import comfyui_append_styles + +# Generar 8 estilos de una categoria y fusionarlos (best-effort: si el LLM falla, devuelve {}) +nuevos = comfyui_generate_styles_llm("vaporwave and synthwave aesthetics", n=8, prefix="vapor-") +if nuevos: + comfyui_append_styles(nuevos) # merge+dedup+backup sobre el styles.json real +print("generados:", len(nuevos)) +``` + +O por CLI: `python/.venv/bin/python3 python/functions/ml/comfyui_generate_styles_llm.py "art deco poster" 6 deco-` + +## Cuando usarla + +Cuando quieras AMPLIAR el repositorio de estilos WAS con estilos NUEVOS de una categoria concreta +que no estan en `comfyui_curated_styles_catalog`, generandolos con el LLM en el formato correcto. +Encadena con `comfyui_append_styles` para escribirlos (merge+dedup). Por ser best-effort (devuelve +{} ante fallo), puedes llamarla en bucle sobre varias categorias y seguir aunque alguna falle por +rate-limit. Para un set grande y fiable sin depender de la red, prefiere el catalogo curado. + +## Gotchas + +- **Rate limits**: ask_llm puede dar `HTTP 429` en rafagas. Esta funcion lo absorbe devolviendo {}; + espacia las llamadas o usa el modelo haiku (default, mas cuota). No reintenta sola. +- **No determinista**: el LLM puede devolver menos de `n`, nombres distintos cada vez o repetir + modificadores. Pasa `avoid` con los nombres existentes para reducir colisiones; el dedup final + por nombre lo cierra `comfyui_append_styles`. +- **No escribe disco**: solo genera y valida. La escritura (con backup) es de `comfyui_append_styles`. +- **Calidad variable**: revisa una muestra de lo generado antes de fusionar en lote grande; el + catalogo curado es la fuente fiable, esto es el complemento. + +## Capability growth log + +(v1.0.0 — sin cambios todavia.) diff --git a/python/functions/ml/comfyui_generate_styles_llm.py b/python/functions/ml/comfyui_generate_styles_llm.py new file mode 100644 index 00000000..013d5d14 --- /dev/null +++ b/python/functions/ml/comfyui_generate_styles_llm.py @@ -0,0 +1,156 @@ +"""comfyui_generate_styles_llm — genera estilos WAS para ComfyUI con el LLM (grupo claude-direct). + +Pide al modelo (vía `ask_llm`, API directa de Anthropic, arranque 0) que produzca N estilos de +una CATEGORÍA temática en el formato exacto del selector WAS: + + { "NombreEstilo": {"prompt": "modificadores de estilo", "negative_prompt": "..."}, ... } + +Cada `prompt` son MODIFICADORES de estilo (cámara, lente, iluminación, render, medio artístico, +paleta, mood) — NO una descripción de sujeto y SIN el placeholder `{prompt}` (el selector +múltiple concatena los prompts elegidos). + +Robusta por diseño: extrae el primer bloque JSON de la respuesta, lo valida entrada por entrada +(descarta las que no tengan `prompt`, rellena `negative_prompt` por defecto si falta) y, ante +CUALQUIER fallo (rate-limit 429, JSON corrupto, respuesta vacía), devuelve `{}` en vez de lanzar. +Así el caller puede iterar varias categorías y seguir aunque una falle (error-path del DoD). + +Impura: llama a la API del LLM (red). No escribe disco (eso es trabajo de +`comfyui_append_styles`). Pensada para componer: generar -> validar -> append. +""" +from __future__ import annotations + +import json +import os +import re +import sys + +# Importa ask_llm del registry (grupo claude-direct). Path al paquete de funciones Python. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +try: + from core.ask_llm import ask_llm # type: ignore +except Exception: # pragma: no cover - sólo si el registry no está en el path + ask_llm = None # type: ignore + +DEFAULT_NEGATIVE = ( + "ugly, deformed, noisy, blurry, low quality, distorted, disfigured, " + "bad anatomy, watermark, signature, text, NSFW" +) + +_SYSTEM = ( + "You are an expert prompt engineer for Stable Diffusion. You output ONLY valid JSON, " + "no prose, no markdown fences. Styles must be SFW." +) + +_PROMPT_TMPL = """Generate exactly {n} distinct image STYLE presets for the category: "{category}". + +Output a single JSON object mapping a short unique style name to an object with keys +"prompt" and "negative_prompt". Rules: +- "prompt" = a comma-separated list of powerful STYLE modifiers ONLY (camera, lens, lighting, + render engine, art medium, palette, texture, mood). NO subject description. Do NOT include + the literal token {{prompt}}. +- "negative_prompt" = comma-separated terms to avoid for this style. Keep it SFW. +- Style names must be short, kebab-case, prefixed with "{prefix}", and must NOT repeat these + existing names: {avoid}. +- Output ONLY the JSON object, nothing else. + +Example of ONE entry: +"{prefix}example": {{"prompt": "cinematic film still, anamorphic lens, teal and orange grade, dramatic key light", "negative_prompt": "lowres, blurry, deformed, watermark, text"}} +""" + + +def _extract_json_object(text: str) -> dict | None: + """Extrae el primer objeto JSON {...} de un texto, tolerando fences de markdown.""" + if not text: + return None + # Quitar fences ```json ... ``` si los hay. + fenced = re.search(r"```(?:json)?\s*(\{.*\})\s*```", text, re.DOTALL) + candidate = fenced.group(1) if fenced else None + if candidate is None: + # Buscar desde la primera { hasta la última } (greedy) — robusto a prosa alrededor. + start = text.find("{") + end = text.rfind("}") + if start == -1 or end == -1 or end <= start: + return None + candidate = text[start : end + 1] + try: + obj = json.loads(candidate) + except (json.JSONDecodeError, ValueError): + return None + return obj if isinstance(obj, dict) else None + + +def _validate(obj: dict, avoid: set[str]) -> dict: + """Filtra el dict del LLM a entradas válidas WAS, descartando duplicados y vacíos.""" + clean: dict[str, dict[str, str]] = {} + for name, v in obj.items(): + if not isinstance(name, str) or not name.strip(): + continue + if name in avoid: + continue + if not isinstance(v, dict): + continue + prompt = v.get("prompt") + if not isinstance(prompt, str) or not prompt.strip(): + continue + if "{prompt}" in prompt: + prompt = prompt.replace("{prompt}", "").strip().strip(",").strip() + if not prompt: + continue + neg = v.get("negative_prompt") + if not isinstance(neg, str) or not neg.strip(): + neg = DEFAULT_NEGATIVE + clean[name.strip()] = {"prompt": prompt.strip(), "negative_prompt": neg.strip()} + return clean + + +def comfyui_generate_styles_llm( + category: str, + n: int = 8, + prefix: str = "", + avoid: list[str] | None = None, + model: str = "claude-haiku-4-5-20251001", +) -> dict: + """Genera estilos WAS de una categoría con el LLM. Devuelve {} ante cualquier fallo. + + Args: + category: tema de los estilos (ej. "vaporwave aesthetics", "baroque painting", + "macro insect photography"). Texto libre, dirige el contenido. + n: número de estilos a pedir (el modelo puede devolver menos; se validan todos). + prefix: prefijo para los nombres generados (ej. "vapor-"), para agrupar y reducir + colisiones con estilos existentes. Si vacío, los nombres van sin prefijo. + avoid: lista de nombres ya existentes que el modelo NO debe repetir (dedup previo). + model: id del modelo Anthropic. Default haiku (más cuota, rápido). Otros: + claude-opus-4-8, claude-sonnet-4-6. + + Returns: + dict {nombre: {"prompt", "negative_prompt"}} ya validado (prompt no vacío, sin + `{prompt}`, negative rellenado). Vacío `{}` si el LLM falla, la respuesta no trae JSON + válido, o no hay entradas utilizables. NUNCA lanza: el error-path es devolver {}. + """ + if ask_llm is None: + return {} + avoid_set = set(avoid or []) + avoid_str = ", ".join(sorted(avoid_set)[:40]) if avoid_set else "(none)" + prompt = _PROMPT_TMPL.format( + n=int(n), category=category, prefix=prefix, avoid=avoid_str + ) + try: + raw = ask_llm(prompt, model=model, system=_SYSTEM, max_tokens=4096, echo=False) + except Exception: + return {} + obj = _extract_json_object(raw or "") + if obj is None: + return {} + return _validate(obj, avoid_set) + + +if __name__ == "__main__": + import json as _json + + cat = sys.argv[1] if len(sys.argv) > 1 else "synthwave retro aesthetics" + n = int(sys.argv[2]) if len(sys.argv) > 2 else 6 + pref = sys.argv[3] if len(sys.argv) > 3 else "synth-" + out = comfyui_generate_styles_llm(cat, n=n, prefix=pref) + print(_json.dumps(out, ensure_ascii=False, indent=2)) + print("GENERADOS:", len(out), file=sys.stderr) diff --git a/python/functions/ml/comfyui_generate_styles_llm_test.py b/python/functions/ml/comfyui_generate_styles_llm_test.py new file mode 100644 index 00000000..19180d7e --- /dev/null +++ b/python/functions/ml/comfyui_generate_styles_llm_test.py @@ -0,0 +1,92 @@ +"""Tests offline de comfyui_generate_styles_llm — sin red (ask_llm monkeypatcheado).""" +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) + +import comfyui_generate_styles_llm as mod +from comfyui_generate_styles_llm import ( + _extract_json_object, + _validate, + comfyui_generate_styles_llm, + DEFAULT_NEGATIVE, +) + + +def test_extract_json_plano(): + txt = '{"a": {"prompt": "x", "negative_prompt": "y"}}' + obj = _extract_json_object(txt) + assert obj == {"a": {"prompt": "x", "negative_prompt": "y"}} + + +def test_extract_json_con_fence_y_prosa(): + txt = 'Aquí tienes:\n```json\n{"a": {"prompt": "x"}}\n```\nFin.' + obj = _extract_json_object(txt) + assert obj == {"a": {"prompt": "x"}} + + +def test_extract_json_corrupto_devuelve_none(): + assert _extract_json_object("no json aquí") is None + assert _extract_json_object("") is None + assert _extract_json_object("{roto") is None + + +def test_validate_descarta_invalidos_y_duplicados(): + obj = { + "ok": {"prompt": "modificador", "negative_prompt": "n"}, + "sin_neg": {"prompt": "solo prompt"}, + "vacio": {"prompt": " "}, + "dup": {"prompt": "x"}, + "no_dict": "string", + } + clean = _validate(obj, avoid={"dup"}) + assert set(clean) == {"ok", "sin_neg"} + assert clean["sin_neg"]["negative_prompt"] == DEFAULT_NEGATIVE + + +def test_validate_quita_placeholder_prompt(): + obj = {"x": {"prompt": "cinematic, {prompt}, dramatic light"}} + clean = _validate(obj, avoid=set()) + assert "{prompt}" not in clean["x"]["prompt"] + assert clean["x"]["prompt"] + + +def test_error_path_ask_llm_lanza_devuelve_vacio(monkeypatch=None): + # Simula ask_llm que revienta -> la función debe devolver {} sin propagar. + def boom(*a, **k): + raise RuntimeError("429 rate limit") + + orig = mod.ask_llm + mod.ask_llm = boom + try: + out = comfyui_generate_styles_llm("cualquier categoría", n=5, prefix="z-") + assert out == {} + finally: + mod.ask_llm = orig + + +def test_ask_llm_ausente_devuelve_vacio(): + orig = mod.ask_llm + mod.ask_llm = None + try: + assert comfyui_generate_styles_llm("cat", n=3) == {} + finally: + mod.ask_llm = orig + + +def test_respuesta_valida_se_parsea(): + orig = mod.ask_llm + mod.ask_llm = lambda *a, **k: '{"z-one": {"prompt": "neon glow, synthwave", "negative_prompt": "flat"}}' + try: + out = comfyui_generate_styles_llm("synthwave", n=1, prefix="z-") + assert out == {"z-one": {"prompt": "neon glow, synthwave", "negative_prompt": "flat"}} + finally: + mod.ask_llm = orig + + +if __name__ == "__main__": + for name, fn in sorted(globals().items()): + if name.startswith("test_") and callable(fn): + fn() + print("PASS", name) + print("OK")