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