diff --git a/docs/capabilities/comfyui.md b/docs/capabilities/comfyui.md index ec0489dd..0cba92dd 100644 --- a/docs/capabilities/comfyui.md +++ b/docs/capabilities/comfyui.md @@ -82,6 +82,23 @@ El **API format** (dict de nodos numerados que produce `build_txt2img_workflow` | [comfyui_run_foreign_workflow_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_run_foreign_workflow_oneshot.md) | `run_foreign_workflow_oneshot(source, *, server, dest=None, output_kind='auto', install_nodes=False, node_repos=None, wait_timeout, civitai_token, hf_token) -> dict` | **Pipeline** para ejecutar un workflow ComfyUI **ajeno** end-to-end en una llamada: import (cualquier fuente) → resolve deps → (instala solo nodos confiables opt-in) → validate → submit → wait → fetch (imagen/vídeo/malla). **Gate de seguridad**: si faltan deps NO encola y las reporta en `missing`; nunca descarga modelos a ciegas. Compone `download_workflow` + `resolve_workflow_deps` + `install_custom_node` + `submit`/`wait` + `fetch_output_image/video/mesh`. Promoción del roadmap 0064/0087. Impuro. | | [comfyui_list_installed_models_py_ml](../../python/functions/ml/comfyui_list_installed_models.md) | `list_installed_models(folder=None, comfyui_dir='~/ComfyUI') -> dict` | Lista modelos por carpeta resolviendo la ruta real de `extra_model_paths.yaml` (`/mnt/2tb/comfyui_models/`) + la nativa. Escaneo de FS, no depende del server. Impura. | +### Cosecha de Civitai → skills candidatas — dominio `ml` + `pipelines` (issue 0087) + +Cosechar de Civitai imágenes con su workflow+receta embebidos para clonar su calidad y alimentar +la librería de skills (grupo [`comfyui-skill`](comfyui-skill.md)). En vez de reconstruir a mano una +receta que ya existe en una imagen pública, se cosecha y se guarda como **candidata** (`score_n=0`, +`provenance.source='civitai'`) para que el bucle de juicio/bump la valide. Política: **NSFW +permitido pero SIEMPRE segregado** en carpeta marcada. **Gotcha clave**: la API de Civitai ya no +expone `meta` (viene `null`) — la receta real sale del **workflow ComfyUI embebido en el PNG**, no +de la meta inline. + +| ID | Firma corta | Qué hace | +|---|---|---| +| [comfyui_search_civitai_images_py_ml](../../python/functions/ml/comfyui_search_civitai_images.md) | `search_civitai_images(*, query=None, model_version_id=None, nsfw='None', sort='Most Reactions', limit=20, token=None) -> dict` | Busca imágenes en Civitai (GET /api/v1/images) → items con `url` (PNG con workflow embebido). El endpoint no admite query textual (HTTP 500): resuelve `query`→versión de modelo via `search_civitai_models`. Token de `pass civitai/api-token`. Reintenta 503. Impura. | +| [comfyui_fetch_civitai_image_py_ml](../../python/functions/ml/comfyui_fetch_civitai_image.md) | `fetch_civitai_image(image_url, *, dest_dir, nsfw=False, nsfw_subdir='nsfw', token=None, prefer_original=True, timeout_s=120) -> dict` | Descarga el PNG original (reescribe `/width=N/`→`/original=true/` para conservar el workflow), **segregando NSFW** a `/nsfw/`. Misma validación no-HTML que `download_model`; nombra por UUID. Impura. | +| [comfyui_extract_recipe_from_png_py_ml](../../python/functions/ml/comfyui_extract_recipe_from_png.md) | `extract_recipe_from_png(png_path, *, slug=None, civitai_meta=None, image_url='', nsfw=False) -> dict` | Destila un PNG cosechado en receta de skill candidata (schema `comfyui-skill`, `source='civitai'`, `score_n=0`). Compone `import_workflow_png` + `read_png_metadata` + fallback de prompts/ckpt para flux. Sin workflow → usa `civitai_meta` (degradación honesta). Impura. | +| [comfyui_harvest_civitai_skill_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_harvest_civitai_skill_oneshot.md) | `harvest_civitai_skill_oneshot(*, query=None, model_version_id=None, nsfw='None', dest_dir, library_dir='~/ComfyUI/skills_library', comfyui_dir='~/ComfyUI', token=None, ...) -> dict` | **Pipeline** Civitai→skill candidata: search → fetch (segrega NSFW) → extract → save_skill. Itera items hasta uno con receta destilable (2º pase al feed global si filtró por modelo). **NO baja modelos a ciegas**: checkpoint/LoRA ausente → `missing_models`. Impuro. | + ### Retoque pro y oneshot — dominio `ml` + `pipelines` (P0, lote report 0093) Builders que envuelven custom-nodes "pro" ya instalados (Impact-Pack, UltimateSDUpscale) y la diff --git a/python/functions/ml/comfyui_extract_recipe_from_png.md b/python/functions/ml/comfyui_extract_recipe_from_png.md new file mode 100644 index 00000000..98ce53b2 --- /dev/null +++ b/python/functions/ml/comfyui_extract_recipe_from_png.md @@ -0,0 +1,84 @@ +--- +name: comfyui_extract_recipe_from_png +kind: function +lang: py +domain: ml +version: "1.0.0" +purity: impure +signature: "def comfyui_extract_recipe_from_png(png_path: str, *, slug: str | None = None, civitai_meta: dict | None = None, image_url: str = \"\", nsfw: bool = False) -> dict" +description: "Destila un PNG cosechado de Civitai en una receta de skill CANDIDATA (schema comfyui-skill, score_n=0, provenance.source='civitai'). Compone comfyui_import_workflow_png (workflow API format embebido) + comfyui_read_png_metadata (params del KSampler) y, como fallback para samplers no-KSampler (flux/SamplerCustomAdvanced), extrae checkpoint del UNETLoader y prompts de los nodos CLIPTextEncode. Degradacion honesta: si el PNG no trae workflow embebido, usa la meta de Civitai (civitai_meta); si tampoco hay nada utilizable, ok=False sin inventar. Impura: lectura de disco." +tags: [comfyui, civitai, ml, recipe, png, metadata, comfyui-skill] +uses_functions: ["comfyui_import_workflow_png_py_ml", "comfyui_read_png_metadata_py_ml"] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["os", "re", "sys"] +params: + - name: png_path + desc: "Ruta del PNG descargado de Civitai." + - name: slug + desc: "Slug de la skill candidata. Si None se deriva del prompt positivo (fallback 'civitai_import'). keyword-only." + - name: civitai_meta + desc: "dict meta del item de comfyui_search_civitai_images. Fallback si el PNG no trae workflow embebido, y para rellenar campos ausentes. keyword-only." + - name: image_url + desc: "URL de origen (se guarda en provenance). keyword-only." + - name: nsfw + desc: "Marca provenance.nsfw. keyword-only." +output: "dict {ok, recipe, slug, has_workflow, error}. recipe sigue el schema minimo de comfyui_save_skill con provenance.source='civitai' y score_n=0. ok=False solo si no hay ni workflow embebido ni civitai_meta utilizable." +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/ml/comfyui_extract_recipe_from_png.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions")) +from ml.comfyui_extract_recipe_from_png import comfyui_extract_recipe_from_png +from ml.comfyui_save_skill import comfyui_save_skill + +# PNG cosechado de Civitai (con comfyui_fetch_civitai_image). +res = comfyui_extract_recipe_from_png("/home/me/ComfyUI/civitai_harvest/abc.png", + image_url="https://civitai.com/...", nsfw=False) +print(res["ok"], res["has_workflow"], res["recipe"]["provenance"]["source"]) # True True civitai + +# Round-trip: la receta candidata la acepta save_skill tal cual. +if res["ok"]: + comfyui_save_skill(res["recipe"], library_dir="/tmp/skills_test") + +# Sin workflow embebido pero con meta de Civitai (degradacion honesta): +# comfyui_extract_recipe_from_png(png, civitai_meta={"prompt": "...", "Model": "x.safetensors", "steps": 25}) +``` + +## Cuando usarla + +Tras descargar un PNG de Civitai, para convertirlo en una receta de skill +candidata lista para `comfyui_save_skill` (score_n=0) sin reconstruirla a mano. El +pipeline `comfyui_harvest_civitai_skill_oneshot` la encadena con el search y el +fetch. + +## Gotchas + +- **El prompt cosechado es CONCRETO, no un scaffold con `{subject}`**: se guarda + verbatim en `prompt_scaffold.positive` como punto de partida. Sustituye partes + por `{subject}` a mano si quieres reutilizar la skill para otros sujetos. +- **Degradacion honesta**: si el PNG no trae workflow ComfyUI (chunks tEXt) y no + pasas `civitai_meta` con al menos prompt o modelo, devuelve `ok=False`. NO + inventa una receta. +- **Civitai casi siempre da `meta` null hoy**: el camino principal es el workflow + embebido en el PNG. La `meta` es fallback que en la practica rara vez ayuda. +- **Heuristica de prompt en samplers no-KSampler** (flux/SamplerCustomAdvanced): + el positivo se asume el CLIPTextEncode mas largo y el negativo el que contenga + terminos tipicos (blurry, bad anatomy, ...). Puede no acertar en workflows con + muchos encoders — es best-effort, por eso la skill nace como CANDIDATA a juzgar. +- **El checkpoint puede salir vacio** si el workflow usa un loader exotico; la + receta se guarda igual (slug/base_workflow/version bastan para save_skill) pero + conviene completarla antes de generar. +- **base_workflow se detecta** (txt2img/flux) por los class_type; revisalo si vas a + compilar con `comfyui_build_skill_workflow`. +- El PNG puede traer prompts NSFW: marca `nsfw=True` para que quede en + `provenance.nsfw`. +- Impura: lee disco (via las dos funciones que compone). diff --git a/python/functions/ml/comfyui_extract_recipe_from_png.py b/python/functions/ml/comfyui_extract_recipe_from_png.py new file mode 100644 index 00000000..124291a3 --- /dev/null +++ b/python/functions/ml/comfyui_extract_recipe_from_png.py @@ -0,0 +1,257 @@ +"""Destila un PNG cosechado de Civitai en una receta de skill candidata. + +Compone `comfyui_import_workflow_png` (workflow API format embebido en los chunks +de texto) + `comfyui_read_png_metadata` (parámetros del KSampler) para producir +una receta del grupo `comfyui-skill` lista para `comfyui_save_skill`: checkpoint, +loras, params, prompt_scaffold, `provenance.source='civitai'` y `score_n=0` (es +una candidata, no validada). + +**Degradación honesta**: si el PNG NO trae chunk de workflow ComfyUI (Civitai sirve +muchas imágenes recomprimidas a JPEG sin metadata), cae a la `meta` de generación +que Civitai entrega aparte (pásala con `civitai_meta`). Si tampoco hay nada +utilizable, devuelve `ok=False` sin inventar. + +Impura: lectura de disco (vía las dos funciones que compone). Solo stdlib. +""" +import os +import re +import sys + +_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +if _THIS_DIR not in sys.path: + sys.path.insert(0, _THIS_DIR) + +from comfyui_import_workflow_png import comfyui_import_workflow_png # noqa: E402 +from comfyui_read_png_metadata import comfyui_read_png_metadata # noqa: E402 + + +def _slugify(text: str, fallback: str) -> str: + """Convierte texto libre a un slug válido [a-z0-9_] no vacío.""" + s = re.sub(r"[^a-z0-9]+", "_", (text or "").lower()).strip("_") + s = "_".join(s.split("_")[:6]) # acota a 6 tokens + return s or fallback + + +def _loras_from_prompt(prompt: dict) -> list: + """Extrae los LoRAs de los nodos LoraLoader del workflow API format.""" + loras = [] + for node in (prompt or {}).values(): + if not isinstance(node, dict): + continue + if str(node.get("class_type", "")).startswith("LoraLoader"): + ins = node.get("inputs", {}) + name = ins.get("lora_name") + if isinstance(name, str) and name: + loras.append({ + "name": name, + "strength_model": ins.get("strength_model", 1.0) + if not isinstance(ins.get("strength_model"), list) else 1.0, + "strength_clip": ins.get("strength_clip", 1.0) + if not isinstance(ins.get("strength_clip"), list) else 1.0, + }) + return loras + + +def _dims_from_prompt(prompt: dict) -> dict: + """Saca width/height del primer EmptyLatentImage del workflow.""" + for node in (prompt or {}).values(): + if isinstance(node, dict) and "EmptyLatentImage" in str(node.get("class_type", "")): + ins = node.get("inputs", {}) + w, h = ins.get("width"), ins.get("height") + out = {} + if isinstance(w, (int, float)) and not isinstance(w, bool): + out["width"] = int(w) + if isinstance(h, (int, float)) and not isinstance(h, bool): + out["height"] = int(h) + return out + return {} + + +_NEG_HINTS = ("deformed", "bad anatomy", "bad hands", "blurry", "lowres", "low res", + "worst quality", "extra dots", "noise", "watermark", "jpeg artifacts", + "ugly", "disfigured", "mutated", "(worst quality") + + +def _checkpoint_from_prompt(prompt: dict) -> str: + """ckpt_name de un CheckpointLoader o unet_name de un UNETLoader (flux).""" + for node in (prompt or {}).values(): + if isinstance(node, dict) and str(node.get("class_type", "")).startswith("CheckpointLoader"): + ck = node.get("inputs", {}).get("ckpt_name") + if isinstance(ck, str) and ck: + return ck + for node in (prompt or {}).values(): + if isinstance(node, dict) and "UNETLoader" in str(node.get("class_type", "")): + un = node.get("inputs", {}).get("unet_name") + if isinstance(un, str) and un: + return un + return "" + + +def _prompts_from_clip_nodes(prompt: dict) -> tuple: + """Positive/negative heurístico desde los CLIPTextEncode (samplers no-KSampler). + + El positivo se asume el texto literal más largo; el negativo el que contenga + términos típicos de prompt negativo. Best-effort: workflows con muchos + encoders pueden no acertar, por eso es solo fallback de read_png_metadata. + """ + texts = [] + for node in (prompt or {}).values(): + if not isinstance(node, dict): + continue + if "CLIPTextEncode" in str(node.get("class_type", "")) or "TextEncode" in str(node.get("class_type", "")): + t = node.get("inputs", {}).get("text") + if isinstance(t, str) and t.strip(): + texts.append(t.strip()) + if not texts: + return "", "" + negatives = [t for t in texts if any(h in t.lower() for h in _NEG_HINTS)] + positives = [t for t in texts if t not in negatives] + positive = max(positives, key=len) if positives else max(texts, key=len) + negative = max(negatives, key=len) if negatives else "" + return positive, negative + + +def _detect_base_workflow(prompt: dict) -> str: + """txt2img / flux según los class_type presentes (best-effort).""" + for node in (prompt or {}).values(): + if isinstance(node, dict): + ct = str(node.get("class_type", "")) + if "Flux" in ct or ct in ("UNETLoader", "DualCLIPLoader"): + return "flux" + return "txt2img" + + +def _from_civitai_meta(meta: dict) -> dict: + """Mapea la `meta` de generación de Civitai a campos de receta (fallback).""" + meta = meta or {} + params = {} + for src, dst in (("steps", "steps"), ("sampler", "sampler_name"), + ("Size", "_size"), ("seed", "seed")): + if src in meta: + params[dst] = meta[src] + if "cfgScale" in meta: + params["cfg"] = meta["cfgScale"] + # Size viene como "832x1216". + size = params.pop("_size", None) + if isinstance(size, str) and "x" in size: + try: + w, h = size.lower().split("x", 1) + params["width"], params["height"] = int(w), int(h) + except ValueError: + pass + loras = [] + for res in (meta.get("resources") or meta.get("civitaiResources") or []): + if isinstance(res, dict) and str(res.get("type", "")).lower() == "lora": + nm = res.get("name") or res.get("modelName") + if nm: + loras.append({"name": nm, + "strength_model": res.get("weight", 1.0) or 1.0, + "strength_clip": res.get("weight", 1.0) or 1.0}) + return { + "checkpoint": meta.get("Model") or meta.get("model") or "", + "loras": loras, + "params": params, + "positive": meta.get("prompt", "") or "", + "negative": meta.get("negativePrompt", "") or "", + } + + +def comfyui_extract_recipe_from_png( + png_path: str, + *, + slug: str | None = None, + civitai_meta: dict | None = None, + image_url: str = "", + nsfw: bool = False, +) -> dict: + """Destila un PNG cosechado en una receta de skill candidata (schema `comfyui-skill`). + + Args: + png_path: ruta del PNG descargado de Civitai. + slug: slug de la skill candidata. Si None se deriva del prompt positivo + (fallback `civitai_import`). keyword-only. + civitai_meta: dict `meta` del item de `comfyui_search_civitai_images`. Se + usa como fallback si el PNG no trae workflow embebido, y para rellenar + campos ausentes. keyword-only. + image_url: URL de origen (se guarda en `provenance`). keyword-only. + nsfw: marca `provenance.nsfw`. keyword-only. + + Returns: + dict {ok, recipe, slug, has_workflow, error}. recipe sigue el schema mínimo + de `comfyui_save_skill` con `provenance.source='civitai'` y `score_n=0`. + ok=False solo si no hay ni workflow embebido ni `civitai_meta` utilizable. + """ + imp = comfyui_import_workflow_png(png_path) + meta_res = comfyui_read_png_metadata(png_path) + + has_workflow = bool(imp.get("ok")) + prompt = imp.get("prompt") or {} + png_params = meta_res.get("parameters") or {} + fallback = _from_civitai_meta(civitai_meta or {}) + + if not has_workflow and not (fallback["positive"] or fallback["checkpoint"]): + return {"ok": False, "recipe": {}, "slug": slug or "", "has_workflow": False, + "error": ("el PNG no trae workflow ComfyUI embebido y no se aportó " + "civitai_meta utilizable (sin prompt ni modelo).")} + + # Prompts genéricos del workflow (cubre samplers no-KSampler como flux). + clip_pos, clip_neg = _prompts_from_clip_nodes(prompt) + + # Checkpoint: KSampler/PNG → CheckpointLoader/UNETLoader del grafo → meta Civitai. + checkpoint = png_params.get("model") or _checkpoint_from_prompt(prompt) or fallback["checkpoint"] or "" + + # Params: combinar los del KSampler del PNG con dims del workflow / meta. + params = {} + for k in ("steps", "cfg", "sampler_name", "scheduler", "denoise", "seed"): + if k in png_params: + params[k] = png_params[k] + params.update(_dims_from_prompt(prompt)) + for k, v in fallback["params"].items(): + params.setdefault(k, v) + + positive = png_params.get("positive") or clip_pos or fallback["positive"] or "" + negative = png_params.get("negative") or clip_neg or fallback["negative"] or "" + loras = _loras_from_prompt(prompt) or fallback["loras"] + + derived_slug = slug or _slugify(positive, "civitai_import") + + recipe = { + "schema_version": 1, + "slug": derived_slug, + "version": "1.0.0", + "title": positive[:60] if positive else f"Civitai import {derived_slug}", + "base_workflow": _detect_base_workflow(prompt), + "checkpoint": checkpoint, + "loras": loras, + "params": params, + "prompt_scaffold": { + # El prompt cosechado es concreto; el caller puede sustituir partes por + # {subject} para reutilizarlo. Se conserva verbatim como punto de partida. + "positive": positive, + "negative": negative, + "trigger_words": [], + }, + "blocks": [], + "score_mean": 0.0, + "score_n": 0, + "provenance": { + "source": "civitai", + "nsfw": bool(nsfw), + "image_url": image_url, + "png_path": png_path, + "workflow_embedded": has_workflow, + }, + } + return {"ok": True, "recipe": recipe, "slug": derived_slug, + "has_workflow": has_workflow, "error": ""} + + +if __name__ == "__main__": + import json + import sys + + path = sys.argv[1] if len(sys.argv) > 1 else "/tmp/missing.png" + res = comfyui_extract_recipe_from_png(path, slug="smoke_civitai") + print(json.dumps({"ok": res["ok"], "slug": res["slug"], + "has_workflow": res["has_workflow"], "error": res["error"]}, + ensure_ascii=False, indent=2)) diff --git a/python/functions/ml/comfyui_fetch_civitai_image.md b/python/functions/ml/comfyui_fetch_civitai_image.md new file mode 100644 index 00000000..421e815f --- /dev/null +++ b/python/functions/ml/comfyui_fetch_civitai_image.md @@ -0,0 +1,80 @@ +--- +name: comfyui_fetch_civitai_image +kind: function +lang: py +domain: ml +version: "1.0.0" +purity: impure +signature: "def comfyui_fetch_civitai_image(image_url: str, *, dest_dir: str, nsfw: bool = False, nsfw_subdir: str = \"nsfw\", token: str | None = None, prefer_original: bool = True, timeout_s: float = 120.0) -> dict" +description: "Descarga el PNG de una imagen de Civitai a disco, SEGREGANDO el NSFW a una subcarpeta marcada (//). Reescribe la URL redimensionada (/width=N/) a la original (/original=true/) para conservar el workflow ComfyUI embebido. Aplica la misma validacion no-HTML que comfyui_download_model (rechaza paginas de error/login de Cloudflare disfrazadas de imagen) y nombra el archivo por el UUID de la imagen para evitar colisiones. Impura: HTTP GET + escritura en disco." +tags: [comfyui, civitai, ml, download, images, nsfw, http] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["os", "re", "urllib.error", "urllib.parse", "urllib.request"] +params: + - name: image_url + desc: "URL de la imagen (campo url de comfyui_search_civitai_images)." + - name: dest_dir + desc: "Carpeta destino (se expande ~). Se crea si no existe. keyword-only, obligatorio." + - name: nsfw + desc: "Si True, la imagen se guarda en // en vez de directamente en dest_dir (segregacion obligatoria de NSFW). keyword-only." + - name: nsfw_subdir + desc: "Nombre de la subcarpeta para NSFW. Default 'nsfw'. keyword-only." + - name: token + desc: "Token Civitai (header Authorization Bearer). Algunas imagenes lo exigen para el original. None lo omite. No hardcodear. keyword-only." + - name: prefer_original + desc: "Si True (default) reescribe /width=N/ a /original=true/ para conservar el workflow embebido. keyword-only." + - name: timeout_s + desc: "Timeout HTTP en segundos. keyword-only." +output: "dict {ok, path, size_bytes, nsfw, error}. ok=False si la respuesta era HTML de error, demasiado pequena, o fallo la red/escritura (sin dejar basura en disco). nsfw refleja la carpeta usada." +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/ml/comfyui_fetch_civitai_image.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions")) +from ml.comfyui_search_civitai_images import comfyui_search_civitai_images +from ml.comfyui_fetch_civitai_image import comfyui_fetch_civitai_image + +sr = comfyui_search_civitai_images(nsfw="None", sort="Most Reactions", limit=5) +item = sr["items"][0] +# Imagen SFW -> directamente en dest_dir. +res = comfyui_fetch_civitai_image(item["url"], dest_dir=os.path.expanduser("~/ComfyUI/civitai_harvest"), + nsfw=item["nsfw"]) +print(res["ok"], res["path"]) # ~/ComfyUI/civitai_harvest/.png + +# Imagen NSFW -> segregada a la subcarpeta nsfw/. +# comfyui_fetch_civitai_image(url, dest_dir="...", nsfw=True) -> .../nsfw/.png +``` + +## Cuando usarla + +Tras `comfyui_search_civitai_images`, para bajar el PNG original de una imagen (con +su workflow ComfyUI embebido) y luego destilarlo con +`comfyui_extract_recipe_from_png`. Pasa `nsfw=True` (del item del search) para que +el contenido adulto quede SIEMPRE en su carpeta separada. + +## Gotchas + +- **Segregacion NSFW**: la politica del sistema permite NSFW pero SIEMPRE separado. + Pasa `nsfw=True` y la imagen va a `/nsfw/`; nunca mezcles adulto y SFW + en la misma carpeta. +- **El PNG es un secreto potencial**: el workflow embebido puede traer prompts + NSFW y, ocasionalmente, rutas locales del autor. Trata el archivo como dato (no + lo commitees; vive fuera del repo, en `~/ComfyUI/`). +- **No todas las imagenes traen workflow ComfyUI**: Civitai recomprime muchas a + JPEG (sin chunks tEXt) o son de A1111. La descarga funciona igual; la extraccion + posterior degradara. Por eso el pipeline itera varios items. +- `prefer_original=True` reescribe `/width=N/` a `/original=true/` para maximizar + conservar la metadata; si la URL ya es original, la deja igual. +- Valida que la respuesta no sea HTML (Cloudflare/login) ni < 1 KB: en esos casos + devuelve `ok=False` y NO deja basura en disco. +- Impura: HTTP GET + escritura en disco. Requiere internet. diff --git a/python/functions/ml/comfyui_fetch_civitai_image.py b/python/functions/ml/comfyui_fetch_civitai_image.py new file mode 100644 index 00000000..2834a5f2 --- /dev/null +++ b/python/functions/ml/comfyui_fetch_civitai_image.py @@ -0,0 +1,164 @@ +"""Descarga el PNG de una imagen de Civitai, segregando el NSFW a una subcarpeta. + +Baja el binario de la imagen a `/` (o, si `nsfw=True`, a +`//`), aplicando la misma validación no-HTML que +`comfyui_download_model` para no dejar páginas de error de Cloudflare/login +disfrazadas de imagen. Las URLs de Civitai suelen apuntar a una variante +redimensionada (`/width=N/`) que pierde los chunks de texto; por defecto se +reescribe a la original (`/original=true/`) para conservar el workflow ComfyUI +embebido que luego destila `comfyui_extract_recipe_from_png`. + +**Segregación NSFW**: la política del sistema permite NSFW pero SIEMPRE separado en +su propia carpeta marcada. El caller pasa `nsfw=True` (tomado del item de +`comfyui_search_civitai_images`) y la función lo enruta a `nsfw_subdir`. + +Impura: red (HTTP GET) + escritura en disco. Solo stdlib. +""" +import os +import re +import urllib.error +import urllib.parse +import urllib.request + +_HTML_SNIFF = (b" str: + """Reescribe una URL de Civitai redimensionada a su original (best-effort).""" + if _WIDTH_RE.search(url): + return _WIDTH_RE.sub("/original=true/", url) + return url + + +def _derive_filename(url: str) -> str: + """Nombre único: ., o el último segmento con extensión. + + Las URLs de Civitai llevan el UUID de la imagen como segmento de ruta; usarlo + como nombre garantiza unicidad y evita que dos cosechas colisionen en un + genérico tipo "original.png". + """ + path = urllib.parse.urlparse(url).path + segs = [s for s in path.split("/") if s and "=" not in s] + ext = ".png" + for seg in reversed(segs): + if "." in seg and not seg.endswith("."): + cand_ext = os.path.splitext(seg)[1].lower() + if cand_ext in (".png", ".jpeg", ".jpg", ".webp"): + ext = cand_ext + break + uuid = _UUID_RE.search(path) + if uuid: + return uuid.group(0) + ext + for seg in reversed(segs): + if "." in seg and not seg.endswith("."): + return seg + return (segs[-1] if segs else "civitai_image") + ext + + +def comfyui_fetch_civitai_image( + image_url: str, + *, + dest_dir: str, + nsfw: bool = False, + nsfw_subdir: str = "nsfw", + token: str | None = None, + prefer_original: bool = True, + timeout_s: float = 120.0, +) -> dict: + """Descarga el PNG de una imagen de Civitai a disco, segregando el NSFW. + + Args: + image_url: URL de la imagen (campo `url` de `comfyui_search_civitai_images`). + dest_dir: carpeta destino (se expande ~). Se crea si no existe. keyword-only. + nsfw: si True, la imagen se guarda en `//` en vez de + directamente en `dest_dir`. keyword-only. + nsfw_subdir: nombre de la subcarpeta para NSFW. Default "nsfw". keyword-only. + token: token Civitai (header Authorization Bearer). Algunas imágenes lo + exigen para servir el original. None lo omite. No hardcodear. keyword-only. + prefer_original: si True (default) reescribe la URL `/width=N/` a + `/original=true/` para conservar el workflow embebido. keyword-only. + timeout_s: timeout HTTP en segundos. keyword-only. + + Returns: + dict {ok, path, size_bytes, nsfw, error}. ok=False si la respuesta era HTML + de error, demasiado pequeña, o falló la red/escritura (sin dejar basura en + disco). `nsfw` refleja la carpeta usada. + """ + base = os.path.expanduser(dest_dir) + target_dir = os.path.join(base, nsfw_subdir) if nsfw else base + + req_url = _to_original_url(image_url) if prefer_original else image_url + headers = {"User-Agent": "fn-registry/comfyui_fetch_civitai_image"} + if token: + headers["Authorization"] = f"Bearer {token}" + + tmp_path = None + try: + req = urllib.request.Request(req_url, headers=headers) + with urllib.request.urlopen(req, timeout=timeout_s) as resp: + content_type = resp.headers.get("Content-Type", "") + name = _derive_filename(resp.geturl()) or _derive_filename(image_url) + + if "text/html" in content_type.lower(): + return {"ok": False, "path": "", "size_bytes": 0, "nsfw": nsfw, + "error": (f"la respuesta es HTML (Content-Type: {content_type}), " + "no una imagen. Revisa la URL/token.")} + + os.makedirs(target_dir, exist_ok=True) + final_path = os.path.join(target_dir, name) + tmp_path = final_path + ".part" + + first = resp.read(512) + low = first.lower().lstrip() + if any(low.startswith(sig) for sig in _HTML_SNIFF): + return {"ok": False, "path": "", "size_bytes": 0, "nsfw": nsfw, + "error": "la respuesta empieza con HTML (página de error/login), no una imagen."} + + size = 0 + with open(tmp_path, "wb") as fh: + fh.write(first) + size += len(first) + while True: + chunk = resp.read(1024 * 256) + if not chunk: + break + fh.write(chunk) + size += len(chunk) + except urllib.error.HTTPError as exc: + body = exc.read().decode(errors="replace")[:300] + _cleanup(tmp_path) + return {"ok": False, "path": "", "size_bytes": 0, "nsfw": nsfw, + "error": f"HTTP {exc.code} en {image_url}: {body}"} + except Exception as exc: # noqa: BLE001 — red/DNS/escritura + _cleanup(tmp_path) + return {"ok": False, "path": "", "size_bytes": 0, "nsfw": nsfw, + "error": f"fallo descargando {image_url}: {exc}"} + + if size < 1024: + _cleanup(tmp_path) + return {"ok": False, "path": "", "size_bytes": size, "nsfw": nsfw, + "error": f"descarga sospechosamente pequeña ({size} bytes); probable error, no una imagen."} + + os.replace(tmp_path, final_path) + return {"ok": True, "path": final_path, "size_bytes": size, "nsfw": nsfw, "error": ""} + + +def _cleanup(path: str | None) -> None: + if path and os.path.exists(path): + try: + os.remove(path) + except OSError: + pass + + +if __name__ == "__main__": + import json + import sys + + out = comfyui_fetch_civitai_image( + sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8188/", + dest_dir="/tmp/civitai_harvest_smoke", + ) + print(json.dumps(out, ensure_ascii=False, indent=2)) diff --git a/python/functions/ml/comfyui_search_civitai_images.md b/python/functions/ml/comfyui_search_civitai_images.md new file mode 100644 index 00000000..edda184d --- /dev/null +++ b/python/functions/ml/comfyui_search_civitai_images.md @@ -0,0 +1,88 @@ +--- +name: comfyui_search_civitai_images +kind: function +lang: py +domain: ml +version: "1.0.0" +purity: impure +signature: "def comfyui_search_civitai_images(*, query: str | None = None, model_version_id: int | None = None, nsfw: str = \"None\", sort: str = \"Most Reactions\", limit: int = 20, token: str | None = None) -> dict" +description: "Busca imagenes en Civitai via GET /api/v1/images y normaliza cada item a {id, url, width, height, nsfw, nsfw_level, base_model, model_version_ids, meta}. El endpoint NO admite busqueda textual (un query crudo da HTTP 500): cuando se pasa query sin model_version_id, resuelve el query a una version de modelo con comfyui_search_civitai_models y consulta las imagenes de esa version (filtro fiable); si no casa modelo, cae al feed global. El token se resuelve de pass civitai/api-token si no se pasa. Reintenta 503 transitorios. Impura: HTTP + subprocess (pass)." +tags: [comfyui, civitai, ml, search, images, stable-diffusion, http] +uses_functions: ["comfyui_search_civitai_models_py_ml"] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["json", "os", "subprocess", "sys", "time", "urllib.error", "urllib.parse", "urllib.request"] +params: + - name: query + desc: "Texto de busqueda libre. El endpoint de imagenes no tiene busqueda textual; se resuelve a una version de modelo via comfyui_search_civitai_models (matchea NOMBRES de modelo, no temas). keyword-only." + - name: model_version_id + desc: "Filtra por una version de modelo concreta (el version_id de comfyui_search_civitai_models). Es el filtro fiable. None no filtra. keyword-only." + - name: nsfw + desc: "Nivel NSFW Civitai: 'None' (solo SFW, default), 'Soft', 'Mature', 'X', o 'true'/'false'. keyword-only." + - name: sort + desc: "Orden Civitai: 'Most Reactions' (default), 'Most Comments', 'Newest'. keyword-only." + - name: limit + desc: "Numero maximo de resultados (1-200). keyword-only." + - name: token + desc: "API token de Civitai (header Authorization Bearer). Si None se lee de pass civitai/api-token. No hardcodear. keyword-only." +output: "dict {ok, items, count, resolved_model_version_id, error}. items = lista de {id, url, width, height, nsfw, nsfw_level, base_model, model_version_ids, meta}. ok=False con error si la peticion falla; 0 resultados devuelve ok=True con items=[] (no es error)." +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/ml/comfyui_search_civitai_images.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions")) +from ml.comfyui_search_civitai_images import comfyui_search_civitai_images + +# Feed global SFW ordenado por reacciones (donde abundan los workflows ComfyUI): +out = comfyui_search_civitai_images(nsfw="None", sort="Most Reactions", limit=10) +for it in out["items"]: + print(it["id"], it["nsfw"], it["base_model"], "->", it["url"]) + +# Con query: se resuelve a una version de modelo (matchea nombres de modelo). +out = comfyui_search_civitai_images(query="dreamshaper", nsfw="None", limit=5) +print("resuelto a version:", out["resolved_model_version_id"]) + +# Imagenes de una version concreta (filtro fiable): +out = comfyui_search_civitai_images(model_version_id=128713, nsfw="None", limit=5) +``` + +Cada `url` se descarga con `comfyui_fetch_civitai_image` y se destila con +`comfyui_extract_recipe_from_png` para obtener la receta. + +## Cuando usarla + +Cuando quieras cosechar de Civitai imagenes con su workflow ComfyUI embebido para +clonar su calidad y alimentar la libreria de skills (grupo `comfyui-skill`). +Empieza por el feed global `sort="Most Reactions"` si buscas workflows ComfyUI +completos; usa `model_version_id` si quieres ceñirte a un modelo concreto. El +pipeline `comfyui_harvest_civitai_skill_oneshot` la encadena entera. + +## Gotchas + +- **El endpoint de imagenes NO admite `query` textual**: enviarlo crudo devuelve + HTTP 500 ("Service Unavailable"). Por eso esta funcion lo resuelve a una version + de modelo. El query matchea NOMBRES de modelo, no temas ("portrait"/"cinematic" + devuelven 0 modelos → se usa el feed global). +- **La API ya NO expone `meta`**: hoy `meta` viene casi siempre `null` (cambio de + politica/privacidad de Civitai). La receta real NO sale de aqui — sale del + workflow embebido en el PNG (chunks tEXt), que destila + `comfyui_extract_recipe_from_png`. El campo `meta` se conserva por si algun item + lo trae. +- **El feed por version de modelo (SD1.5) rara vez trae workflow ComfyUI**: esas + imagenes suelen ser de A1111 o recomprimidas. Los workflows ComfyUI completos + abundan en el feed global de usuarios flux. El pipeline tiene un 2o pase global + por esto. +- Impura: HTTP GET a `civitai.com` + `subprocess` a `pass`. Requiere internet. + Reintenta 502/503/429 con backoff (la API es intermitente bajo carga). +- El PNG/URL cosechado puede traer prompts NSFW: clasifica con el campo `nsfw` y + segrega al descargar (`comfyui_fetch_civitai_image(nsfw=True)`). +- El token es secreto: NO lo pongas en claro; viene de `pass civitai/api-token`. +- 0 resultados NO es error: devuelve `ok=True, items=[], count=0`. diff --git a/python/functions/ml/comfyui_search_civitai_images.py b/python/functions/ml/comfyui_search_civitai_images.py new file mode 100644 index 00000000..6ea2aeda --- /dev/null +++ b/python/functions/ml/comfyui_search_civitai_images.py @@ -0,0 +1,177 @@ +"""Busca imágenes en Civitai via su API pública GET /api/v1/images. + +Hermana de `comfyui_search_civitai_models` pero para imágenes: devuelve cada +resultado con la `url` de su PNG (que suele traer el workflow ComfyUI embebido en +los chunks de texto). Permite filtrar NSFW y ordenar. El token se resuelve desde +`pass civitai/api-token` si no se pasa explícitamente. + +Sirve para cosechar de Civitai imágenes con su receta para clonar su calidad y +alimentar la librería de skills (grupo `comfyui-skill`): cada `url` se descarga +con `comfyui_fetch_civitai_image` y se destila con `comfyui_extract_recipe_from_png` +(la receta sale del workflow embebido en el PNG, ver Gotchas). + +El endpoint de imágenes de Civitai NO admite búsqueda textual (un `query` crudo +devuelve HTTP 500). Por eso, cuando se pasa `query` sin `model_version_id`, esta +función primero resuelve el query a una versión de modelo con +`comfyui_search_civitai_models` y consulta las imágenes de esa versión (filtro +fiable). Si el query no casa ningún modelo, cae al feed global ordenado +(best-effort, ver Gotchas). + +Impura: red (HTTP GET a civitai.com) + subprocess (`pass`). Solo stdlib salvo la +función hermana del registry que compone. +""" +import json +import os +import subprocess +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +if _THIS_DIR not in sys.path: + sys.path.insert(0, _THIS_DIR) + +from comfyui_search_civitai_models import comfyui_search_civitai_models # noqa: E402 + +_API = "https://civitai.com/api/v1/images" +_TIMEOUT = 30.0 +_RETRIES = 3 # la API de imágenes devuelve 503 transitorios bajo carga + + +def _resolve_token(token: str | None) -> str | None: + """Devuelve el token explícito o lo lee de `pass civitai/api-token` (best-effort).""" + if token: + return token + try: + out = subprocess.run( + ["pass", "civitai/api-token"], + capture_output=True, text=True, timeout=10, + ) + if out.returncode == 0: + lines = out.stdout.strip().splitlines() + return lines[0].strip() if lines and lines[0].strip() else None + except Exception: # noqa: BLE001 — pass no instalado / entrada inexistente + pass + return None + + +def comfyui_search_civitai_images( + *, + query: str | None = None, + model_version_id: int | None = None, + nsfw: str = "None", + sort: str = "Most Reactions", + limit: int = 20, + token: str | None = None, +) -> dict: + """Busca imágenes en Civitai y devuelve resultados normalizados. + + Args: + query: texto de búsqueda libre. La API de imágenes de Civitai NO tiene un + parámetro de búsqueda textual fuerte (a diferencia de la de modelos): + se envía best-effort y puede ser ignorado por el servidor. Para + filtrar de verdad por un modelo concreto usa `model_version_id`. + keyword-only. + model_version_id: filtra las imágenes generadas con una versión de modelo + concreta (el `version_id` que devuelve `comfyui_search_civitai_models`). + Es el filtro fiable. None no filtra. keyword-only. + nsfw: nivel NSFW Civitai: "None" (solo SFW, default), "Soft", "Mature", + "X", o "true"/"false". keyword-only. + sort: orden Civitai: "Most Reactions" (default), "Most Comments", "Newest". + keyword-only. + limit: número máximo de resultados (1-200). keyword-only. + token: API token de Civitai (header Authorization Bearer). Si None se + resuelve de `pass civitai/api-token`. No hardcodear. keyword-only. + + Returns: + dict {ok, items, count, resolved_model_version_id, error}. items = lista de + {id, url, width, height, nsfw, nsfw_level, base_model, model_version_ids, + meta}. `meta` es el dict de generación de Civitai SI lo expone (hoy suele + venir null, ver Gotchas); la receta real se destila del PNG embebido. + `resolved_model_version_id` indica a qué versión se resolvió un `query`. + ok=False con error si la petición falla; 0 resultados devuelve ok=True con + items=[] (no es error). + """ + tok = _resolve_token(token) + + # El endpoint /images no admite query textual: resolver query → versión de modelo. + resolved_vid = model_version_id + if resolved_vid is None and query: + mr = comfyui_search_civitai_models(query, types="Checkpoint", limit=1, token=tok) + if mr.get("ok") and mr.get("items"): + resolved_vid = mr["items"][0].get("version_id") + + params = [ + ("limit", str(max(1, min(int(limit), 200)))), + ("sort", sort), + ("nsfw", str(nsfw)), + ] + if resolved_vid is not None: + params.append(("modelVersionId", str(resolved_vid))) + + url = f"{_API}?{urllib.parse.urlencode(params)}" + headers = {"User-Agent": "fn-registry/comfyui_search_civitai_images"} + if tok: + headers["Authorization"] = f"Bearer {tok}" + + data = None + last_err = "" + for attempt in range(_RETRIES): + try: + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp: + data = json.loads(resp.read()) + break + except urllib.error.HTTPError as exc: + body = exc.read().decode(errors="replace")[:300] + last_err = f"HTTP {exc.code} en /api/v1/images: {body}" + if exc.code in (502, 503, 429) and attempt < _RETRIES - 1: + time.sleep(1.5 * (attempt + 1)) + continue + return {"ok": False, "items": [], "count": 0, + "resolved_model_version_id": resolved_vid, "error": last_err} + except urllib.error.URLError as exc: + return {"ok": False, "items": [], "count": 0, + "resolved_model_version_id": resolved_vid, + "error": f"no se pudo conectar a civitai.com: {exc.reason}"} + except json.JSONDecodeError as exc: + return {"ok": False, "items": [], "count": 0, + "resolved_model_version_id": resolved_vid, + "error": f"respuesta no es JSON válido: {exc}"} + if data is None: + return {"ok": False, "items": [], "count": 0, + "resolved_model_version_id": resolved_vid, + "error": last_err or "sin respuesta tras reintentos"} + + items = [] + for img in data.get("items", []) or []: + lvl = img.get("nsfwLevel") + is_nsfw = bool(img.get("nsfw", False)) + if isinstance(lvl, str) and lvl not in ("", "None"): + is_nsfw = True + elif isinstance(lvl, (int, float)) and not isinstance(lvl, bool) and lvl > 1: + is_nsfw = True + items.append({ + "id": img.get("id"), + "url": img.get("url"), + "width": img.get("width"), + "height": img.get("height"), + "nsfw": is_nsfw, + "nsfw_level": lvl, + "base_model": img.get("baseModel"), + "model_version_ids": img.get("modelVersionIds") or [], + "meta": img.get("meta") or {}, + }) + return {"ok": True, "items": items, "count": len(items), + "resolved_model_version_id": resolved_vid, "error": ""} + + +if __name__ == "__main__": + out = comfyui_search_civitai_images(query="cinematic portrait", nsfw="None", limit=5) + print(out["ok"], out["count"]) + for it in out["items"]: + meta = it["meta"] + prompt = (meta.get("prompt") or "")[:60] + print(f" img {it['id']} nsfw={it['nsfw']} model={meta.get('Model')!r} prompt={prompt!r}") diff --git a/python/functions/pipelines/comfyui_harvest_civitai_skill_oneshot.md b/python/functions/pipelines/comfyui_harvest_civitai_skill_oneshot.md new file mode 100644 index 00000000..632b11ff --- /dev/null +++ b/python/functions/pipelines/comfyui_harvest_civitai_skill_oneshot.md @@ -0,0 +1,94 @@ +--- +name: comfyui_harvest_civitai_skill_oneshot +kind: pipeline +lang: py +domain: pipelines +version: "1.0.0" +purity: impure +signature: "def comfyui_harvest_civitai_skill_oneshot(*, query: str | None = None, model_version_id: int | None = None, nsfw: str = \"None\", dest_dir: str, library_dir: str = \"~/ComfyUI/skills_library\", comfyui_dir: str = \"~/ComfyUI\", token: str | None = None, slug: str | None = None, pick_index: int = 0, search_limit: int = 20) -> dict" +description: "One-shot Civitai -> skill candidata: search_images -> fetch (segrega NSFW) -> extract_recipe -> save_skill (candidata, score_n=0, provenance.source='civitai'). Itera los items hasta encontrar uno con receta destilable (preferentemente workflow ComfyUI embebido), descartando los PNG intermedios sin receta. Si se filtro por modelo y ninguno trae workflow, hace un 2o pase al feed global 'Most Reactions'. NO baja modelos a ciegas: si la receta referencia un checkpoint/LoRA ausente lo reporta en missing_models. Doctrina issue 0087 aplicada a cosechar assets de internet. Pipeline impuro: HTTP + escritura en disco." +tags: [comfyui, civitai, pipelines, ml, skill, harvest, comfyui-skill] +uses_functions: ["comfyui_search_civitai_images_py_ml", "comfyui_fetch_civitai_image_py_ml", "comfyui_extract_recipe_from_png_py_ml", "comfyui_save_skill_py_ml"] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["os", "sys"] +params: + - name: query + desc: "Texto de busqueda (best-effort: se resuelve a una version de modelo via search). keyword-only." + - name: model_version_id + desc: "Filtra por una version de modelo concreta (filtro fiable). keyword-only." + - name: nsfw + desc: "Nivel NSFW del search Civitai ('None'=SFW por defecto, 'Soft'/'Mature'/'X'). keyword-only." + - name: dest_dir + desc: "Carpeta donde se descarga el PNG (NSFW se segrega a nsfw/). keyword-only, obligatorio." + - name: library_dir + desc: "Libreria de skills. Default ~/ComfyUI/skills_library. keyword-only." + - name: comfyui_dir + desc: "Raiz de ComfyUI para chequear modelos instalados (missing_models). keyword-only." + - name: token + desc: "Token Civitai. None lo resuelve de pass civitai/api-token. No hardcodear. keyword-only." + - name: slug + desc: "Slug de la skill. Si None se deriva del prompt cosechado. keyword-only." + - name: pick_index + desc: "Indice del item del search por el que empezar a iterar (default 0). keyword-only." + - name: search_limit + desc: "Cuantas imagenes pedir al search. keyword-only." +output: "dict {ok, slug, skill_path, png_path, recipe, nsfw, missing_models, image_id, has_workflow, attempts, error}. ok=False con error claro en fallo de red/guardado o si ningun item produjo receta; 0 resultados -> ok=False." +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/pipelines/comfyui_harvest_civitai_skill_oneshot.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions")) +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions", "pipelines")) +from comfyui_harvest_civitai_skill_oneshot import comfyui_harvest_civitai_skill_oneshot + +res = comfyui_harvest_civitai_skill_oneshot( + query="cinematic portrait", nsfw="None", + dest_dir=os.path.expanduser("~/ComfyUI/civitai_harvest"), +) +print(res["ok"], res["slug"], "workflow_embebido=", res["has_workflow"]) +print("guardada en:", res["skill_path"]) # ~/ComfyUI/skills_library//recipe.json +print("modelos ausentes:", res["missing_models"]) # checkpoints/LoRAs a bajar (NO bajados) +# La receta queda como CANDIDATA (score_n=0). El bucle del grupo comfyui-skill +# (generate -> judge -> bump) la valida y promueve si funciona. +``` + +## Cuando usarla + +Cuando quieras cosechar de Civitai una imagen con su workflow+receta embebidos y +guardarla como skill candidata de un tiron, sin reconstruir la receta a mano. Es +la 3a pieza del sistema `comfyui-skill`: alimenta la libreria con recetas reales +de internet para clonar su calidad. Luego juzga/promueve con +`comfyui_generate_with_skill_oneshot` + `comfyui_bump_skill_version`. + +## Gotchas + +- **NO baja modelos a ciegas**: si la receta cosechada referencia un checkpoint o + LoRA no instalado en `/models/`, lo lista en `missing_models` y NO + descarga nada. Bajarlos (con `comfyui_search_civitai_models` + + `comfyui_download_model`) es una decision aparte del caller. +- **NSFW segregado**: el PNG cosechado se descarga a `/nsfw/` si el item + es NSFW (politica del sistema: permitido pero SIEMPRE separado). El `dest_dir` + vive fuera del repo (`~/ComfyUI/`) y NO se commitea: trata las imagenes como + datos. +- **Itera y descarta**: como muchas imagenes de Civitai no traen workflow ComfyUI + (JPEG recomprimido / A1111), el pipeline prueba varios items y borra los PNG sin + receta. `attempts` indica cuantos probo; `has_workflow` si la elegida traia el + grafo embebido. +- **2o pase global**: si filtras por un modelo (query resuelto o model_version_id) + y ninguna imagen de esa version trae workflow, reintenta en el feed global "Most + Reactions" (donde abundan los workflows ComfyUI de usuarios flux). Por eso un + query tematico acaba dando candidata aunque no filtre fuerte. +- **La skill nace CANDIDATA** (`score_n=0`, `provenance.source='civitai'`): no esta + validada. El prompt es concreto, no un scaffold con `{subject}`. +- El token es secreto: viene de `pass civitai/api-token`, nunca en claro. +- Pipeline impuro: HTTP + escritura en disco. Requiere internet y la libreria de + skills es estado local (no se indexa, vive bajo `~/ComfyUI/skills_library`). diff --git a/python/functions/pipelines/comfyui_harvest_civitai_skill_oneshot.py b/python/functions/pipelines/comfyui_harvest_civitai_skill_oneshot.py new file mode 100644 index 00000000..352a06d9 --- /dev/null +++ b/python/functions/pipelines/comfyui_harvest_civitai_skill_oneshot.py @@ -0,0 +1,219 @@ +"""comfyui_harvest_civitai_skill_oneshot — Civitai → skill candidata en una llamada. + +Cosecha de Civitai una imagen con su workflow+receta embebidos y la promueve a una +skill candidata de la librería (grupo `comfyui-skill`), en un solo paso: + + search_images → fetch (segrega NSFW) → extract_recipe → save_skill (candidata) + +Es la doctrina del issue 0087 aplicada a la captación de assets de internet: en vez +de reconstruir a mano una receta que ya existe en una imagen pública, se cosecha y +se guarda como candidata (`score_n=0`, `provenance.source='civitai'`) para que el +bucle de juicio/bump del grupo `comfyui-skill` la valide y la promueva si funciona. + +Compone funciones del registry: + + comfyui_search_civitai_images_py_ml (GET /api/v1/images) + comfyui_fetch_civitai_image_py_ml (descarga PNG, segrega NSFW) + comfyui_extract_recipe_from_png_py_ml (destila receta candidata) + comfyui_save_skill_py_ml (persiste en la librería) + +Pipeline impuro: red (HTTP) + escritura en disco. + +**No baja modelos a ciegas**: si la receta cosechada referencia un checkpoint o LoRA +que NO está instalado en `/models/`, lo reporta en `missing_models` y NO +descarga nada. Bajar esos modelos (con `comfyui_search_civitai_models` + +`comfyui_download_model`) es una decisión aparte del caller. +""" +from __future__ import annotations + +import os +import sys + +_FUNCTIONS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _FUNCTIONS_ROOT not in sys.path: + sys.path.insert(0, _FUNCTIONS_ROOT) + +from ml.comfyui_extract_recipe_from_png import comfyui_extract_recipe_from_png +from ml.comfyui_fetch_civitai_image import comfyui_fetch_civitai_image +from ml.comfyui_save_skill import comfyui_save_skill +from ml.comfyui_search_civitai_images import comfyui_search_civitai_images + + +def _installed_models(comfyui_dir: str) -> set: + """Conjunto de nombres de archivo de checkpoints + loras instalados.""" + base = os.path.expanduser(comfyui_dir) + found = set() + for sub in ("checkpoints", "loras"): + d = os.path.join(base, "models", sub) + if os.path.isdir(d): + try: + found.update(os.listdir(d)) + except OSError: + pass + return found + + +def _missing_models(recipe: dict, comfyui_dir: str) -> list: + """Modelos referenciados por la receta que no están en disco (NO los descarga).""" + installed = _installed_models(comfyui_dir) + missing = [] + ckpt = recipe.get("checkpoint") + if ckpt and os.path.basename(ckpt) not in installed: + missing.append({"kind": "checkpoint", "name": ckpt}) + for lora in recipe.get("loras") or []: + nm = lora.get("name") if isinstance(lora, dict) else None + if nm and os.path.basename(nm) not in installed: + missing.append({"kind": "lora", "name": nm}) + return missing + + +def comfyui_harvest_civitai_skill_oneshot( + *, + query: str | None = None, + model_version_id: int | None = None, + nsfw: str = "None", + dest_dir: str, + library_dir: str = "~/ComfyUI/skills_library", + comfyui_dir: str = "~/ComfyUI", + token: str | None = None, + slug: str | None = None, + pick_index: int = 0, + search_limit: int = 20, +) -> dict: + """Cosecha una imagen de Civitai y la guarda como skill candidata. + + Args: + query: texto de búsqueda libre (best-effort, ver search_civitai_images). + keyword-only. + model_version_id: filtra por versión de modelo (filtro fiable). keyword-only. + nsfw: nivel NSFW del search Civitai ("None"=SFW por defecto). keyword-only. + dest_dir: carpeta donde se descarga el PNG (NSFW se segrega a `nsfw/`). + keyword-only, obligatorio. + library_dir: librería de skills. Default `~/ComfyUI/skills_library`. keyword-only. + comfyui_dir: raíz de ComfyUI para chequear modelos instalados. keyword-only. + token: token Civitai. None lo resuelve de `pass civitai/api-token`. keyword-only. + slug: slug de la skill. Si None se deriva del prompt cosechado. keyword-only. + pick_index: índice del item del search a cosechar (default 0, el primero). + keyword-only. + search_limit: cuántas imágenes pedir al search. keyword-only. + + Returns: + dict {ok, slug, skill_path, png_path, recipe, nsfw, missing_models, + image_id, has_workflow, attempts, error}. ok=False con error en cualquier + fallo de red/guardado; 0 resultados del search → ok=False con error claro. + + Como Civitai sirve muchas imágenes recomprimidas sin workflow ComfyUI embebido + (y su API ya no expone `meta`), el pipeline ITERA los items desde `pick_index` + hasta encontrar el primero del que se puede destilar una receta, descartando + (borrando de disco) los PNGs intermedios que no sirven. Prefiere los que traen + workflow embebido. + """ + sr = comfyui_search_civitai_images( + query=query, model_version_id=model_version_id, + nsfw=nsfw, limit=search_limit, token=token, + ) + if not sr.get("ok"): + return _err(f"search falló: {sr.get('error')}") + items = sr.get("items") or [] + if not items: + return _err("el search no devolvió imágenes (0 resultados)") + if pick_index >= len(items): + return _err(f"pick_index {pick_index} fuera de rango ({len(items)} items)") + + ctx = {"dest_dir": dest_dir, "library_dir": library_dir, + "comfyui_dir": comfyui_dir, "token": token, "slug": slug} + res = _harvest_from_items(items[pick_index:], ctx) + if res.get("ok"): + return res + + # 2º pase: si se filtró por un modelo (query resuelto / model_version_id) y + # ninguna imagen traía workflow ComfyUI embebido, prueba el feed global + # "Most Reactions" — donde abundan los workflows ComfyUI completos. + used_model_filter = (model_version_id is not None) or bool(sr.get("resolved_model_version_id")) + if used_model_filter: + sr2 = comfyui_search_civitai_images(nsfw=nsfw, sort="Most Reactions", + limit=search_limit, token=token) + if sr2.get("ok") and sr2.get("items"): + res2 = _harvest_from_items(sr2["items"], ctx, prev_attempts=res.get("attempts", 0)) + if res2.get("ok"): + return res2 + res = res2 + return res + + +def _harvest_from_items(items: list, ctx: dict, *, prev_attempts: int = 0) -> dict: + """Itera items: fetch → extract → save. Devuelve la primera candidata o _err.""" + attempts = prev_attempts + last_err = "ningún item produjo una receta destilable" + for item in items: + attempts += 1 + img_url = item.get("url") + is_nsfw = bool(item.get("nsfw")) + if not img_url: + continue + fr = comfyui_fetch_civitai_image(img_url, dest_dir=ctx["dest_dir"], + nsfw=is_nsfw, token=ctx["token"]) + if not fr.get("ok"): + last_err = f"fetch falló: {fr.get('error')}" + continue + png_path = fr["path"] + er = comfyui_extract_recipe_from_png( + png_path, slug=ctx["slug"], civitai_meta=item.get("meta"), + image_url=img_url, nsfw=is_nsfw, + ) + if not er.get("ok"): + last_err = f"extracción falló: {er.get('error')}" + _cleanup(png_path) # PNG sin receta útil → no dejar basura + continue + + recipe = er["recipe"] + missing = _missing_models(recipe, ctx["comfyui_dir"]) + sv = comfyui_save_skill(recipe, library_dir=ctx["library_dir"]) + if not sv.get("ok"): + return _err(f"save_skill falló: {sv.get('error')}", + png_path=png_path, recipe=recipe, nsfw=is_nsfw, + missing_models=missing, image_id=item.get("id"), + has_workflow=er.get("has_workflow"), attempts=attempts) + return { + "ok": True, + "slug": sv["slug"], + "skill_path": sv["path"], + "png_path": png_path, + "recipe": recipe, + "nsfw": is_nsfw, + "missing_models": missing, + "image_id": item.get("id"), + "has_workflow": er.get("has_workflow"), + "attempts": attempts, + "error": "", + } + return _err(f"tras {attempts} intentos, {last_err}", attempts=attempts) + + +def _cleanup(path: str | None) -> None: + if path and os.path.exists(path): + try: + os.remove(path) + except OSError: + pass + + +def _err(msg: str, **extra) -> dict: + base = {"ok": False, "slug": "", "skill_path": "", "png_path": "", "recipe": {}, + "nsfw": False, "missing_models": [], "image_id": None, + "has_workflow": False, "attempts": 0, "error": msg} + base.update(extra) + return base + + +if __name__ == "__main__": + import json + + out = comfyui_harvest_civitai_skill_oneshot( + query="cinematic portrait", nsfw="None", + dest_dir=os.path.expanduser("~/ComfyUI/civitai_harvest"), + ) + print(json.dumps({k: v for k, v in out.items() if k != "recipe"}, + ensure_ascii=False, indent=2)) + if out.get("ok"): + print("missing_models:", out["missing_models"])