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,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).
|
||||
@@ -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))
|
||||
@@ -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 (<dest_dir>/<nsfw_subdir>/). 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 <dest_dir>/<nsfw_subdir>/ 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/<uuid>.png
|
||||
|
||||
# Imagen NSFW -> segregada a la subcarpeta nsfw/.
|
||||
# comfyui_fetch_civitai_image(url, dest_dir="...", nsfw=True) -> .../nsfw/<uuid>.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 `<dest_dir>/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.
|
||||
@@ -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 `<dest_dir>/<filename>` (o, si `nsfw=True`, a
|
||||
`<dest_dir>/<nsfw_subdir>/<filename>`), 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"<!doctype", b"<html", b"<head", b"<?xml")
|
||||
_WIDTH_RE = re.compile(r"/(?:width|height)=\d+/")
|
||||
_UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.I)
|
||||
|
||||
|
||||
def _to_original_url(url: str) -> 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: <uuid-de-la-imagen>.<ext>, 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 `<dest_dir>/<nsfw_subdir>/` 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))
|
||||
@@ -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`.
|
||||
@@ -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}")
|
||||
@@ -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