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:
@@ -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"])
|
||||
Reference in New Issue
Block a user