feat(ml): cosecha Civitai → skills candidatas (search/fetch/extract + harvest oneshot)

Cierra la 3ª pieza del sistema comfyui-skill: cosechar de Civitai imágenes con su
workflow+receta embebidos para clonar su calidad y alimentar la librería de skills.

- comfyui_search_civitai_images: GET /api/v1/images; resuelve query->versión de
  modelo (el endpoint no admite query textual, da HTTP 500); token de pass; reintenta 503.
- comfyui_fetch_civitai_image: descarga el PNG original (conserva workflow embebido),
  SEGREGA NSFW a <dest>/nsfw/, validación no-HTML, nombre único por UUID.
- comfyui_extract_recipe_from_png: import_workflow_png + read_png_metadata + fallback
  flux (CLIPTextEncode/UNETLoader) -> receta candidata (source='civitai', score_n=0).
- comfyui_harvest_civitai_skill_oneshot (pipeline): search->fetch->extract->save_skill;
  itera items, 2º pase al feed global, NO baja modelos a ciegas (missing_models).

Hallazgo: la API de Civitai ya no expone meta (null); la receta sale del workflow
ComfyUI embebido en el PNG. Política: NSFW permitido pero SIEMPRE segregado.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 15:35:12 +02:00
parent bcf731275e
commit 6f4b440762
9 changed files with 1180 additions and 0 deletions
@@ -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/<slug>/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 `<comfyui_dir>/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 `<dest_dir>/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`).
@@ -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 `<comfyui_dir>/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"])