Files
fn_registry/python/functions/pipelines/comfyui_replicate_civitai_oneshot.py
T
egutierrez 10dbc510b7 feat(ml): LoRAs con prefijo de arquitectura (SD15_/SDXL_/FLUX_) + refs actualizadas
Mueve el indicador de arquitectura del SUFIJO al PREFIJO del nombre de cada
LoRA para que el dropdown del LoraLoader muestre de inmediato que LoRA casa con
que checkpoint (evita el shape mismatch SD1.5 vs SDXL que crashea ComfyUI).

- 20 LoRAs renombradas en disco (15 SD15/SDXL en /mnt/2tb, 5 FLUX en ~/ComfyUI),
  mapa de reversion en ~/ComfyUI/models/loras/_rename_map.json.
- Refs actualizadas en builders gamedev-2d, style presets, pipelines, tests y
  docs/capabilities. Defaults hardcodeados (pixel-art, lcm-lora, etc.) apuntan a
  los nombres con prefijo.
- Ejemplos genericos en docstrings normalizados a la convencion de prefijo.
- comfyui_replicate_civitai_oneshot::_norm ignora el token de arquitectura al
  comparar, robusto al reordenado (sufijo civitai vs prefijo instalado).

Refs a repos HuggingFace (nerijs/pixel-art-xl) y checkpoints (juggernaut_xl_v11)
preservados. Verificado: dropdown LoraLoader con prefijos + generacion real
pixel-art OK + tests comfyui verdes (481 ml + 26 pipelines).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:33:03 +02:00

431 lines
18 KiB
Python

"""comfyui_replicate_civitai_oneshot — replica una imagen de Civitai en una llamada.
"Te paso un link de Civitai: entro, observo cómo lo hicieron, y construyo un
workflow que lo replique." Dado el id/URL de una imagen de Civitai (o un
`modelVersionId`, o directamente una URL/dict de workflow ComfyUI), el pipeline:
1. OBSERVA los detalles de generación con `comfyui_fetch_civitai_image_meta`
(prompt, negativo, modelo, sampler, steps, cfg, seed, recursos) vía los
endpoints tRPC que usa la web de Civitai.
2. TRADUCE la receta a parámetros de ComfyUI con `comfyui_map_a1111_params`
(sampler/scheduler, dims, familia del modelo, LoRAs).
3. CONSTRUYE el workflow que la replica:
- si la imagen trae un workflow ComfyUI embebido -> se usa TAL CUAL;
- si no -> se reconstruye con `comfyui_build_txt2img_workflow` +
`comfyui_inject_lora`, sustituyendo el checkpoint original por el más
parecido INSTALADO (misma familia) cuando el exacto no está, y
descartando los LoRAs ausentes.
4. RESUELVE dependencias y GENERA con `comfyui_run_foreign_workflow_oneshot`
(resolve_deps -> submit -> wait -> fetch).
5. JUZGA la réplica con `comfyui_judge_image` contra el prompt extraído.
NO baja modelos a ciegas: lo que la receta pide y no tenemos se reporta en
`missing_models` con la sustitución aplicada (el modelo más parecido instalado),
nunca se descarga. Respeta la política SFW: si la imagen es NSFW, devuelve
`ok=False` sin generar.
El parecido será aproximado cuando falte el checkpoint/LoRA exacto (se reconstruye
con el más parecido) — eso es esperado y queda documentado en `missing_models`.
Pipeline impuro: red (HTTP a Civitai + ComfyUI) + escritura en disco + subprocess
(jueces). Solo stdlib salvo las funciones del registry que compone.
"""
from __future__ import annotations
import os
import re
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_build_txt2img_workflow import comfyui_build_txt2img_workflow
from ml.comfyui_fetch_civitai_image_meta import comfyui_fetch_civitai_image_meta
from ml.comfyui_inject_lora import comfyui_inject_lora
from ml.comfyui_judge_image import comfyui_judge_image
from ml.comfyui_map_a1111_params import comfyui_map_a1111_params
from ml.comfyui_object_info import comfyui_object_info
from ml.comfyui_search_civitai_images import comfyui_search_civitai_images
from pipelines.comfyui_run_foreign_workflow_oneshot import comfyui_run_foreign_workflow_oneshot
# Defaults de generación por familia cuando la meta no los aporta.
_FAMILY_DEFAULTS = {
"sd15": {"width": 512, "height": 768, "steps": 25, "cfg": 7.0},
"sdxl": {"width": 1024, "height": 1024, "steps": 30, "cfg": 7.0},
"flux": {"width": 1024, "height": 1024, "steps": 30, "cfg": 7.0},
"unknown": {"width": 768, "height": 768, "steps": 25, "cfg": 7.0},
}
# Checkpoints que NO sirven para txt2img de imagen (video / 3D / mallas).
_NON_IMAGE_CKPT = ("video", "svd", "zero123", "hunyuan", "ltx", "3d")
_CIVITAI_IMAGES_RE = re.compile(r"civitai\.com/images/(\d+)|^/?images/(\d+)$")
_MODEL_VERSION_RE = re.compile(r"modelVersionId[=/](\d+)|model-versions/(\d+)|modelVersions/(\d+)")
_WORKFLOW_EXTS = (".png", ".json", ".webp")
def _classify_input(url_or_id):
"""Clasifica la entrada -> ('image'|'model_version'|'workflow_source', ref)."""
if isinstance(url_or_id, dict):
return "workflow_source", url_or_id
if isinstance(url_or_id, bool):
return "error", "entrada booleana no válida"
if isinstance(url_or_id, int):
return "image", url_or_id
if not isinstance(url_or_id, str):
return "error", f"entrada no soportada: {type(url_or_id).__name__}"
s = url_or_id.strip()
if s.isdigit():
return "image", int(s)
m = _CIVITAI_IMAGES_RE.search(s)
if m:
return "image", int(next(g for g in m.groups() if g))
mv = _MODEL_VERSION_RE.search(s)
if mv:
return "model_version", int(next(g for g in mv.groups() if g))
# URL/archivo de workflow (no es una página de imagen Civitai).
low = s.lower().split("?")[0]
if low.endswith(_WORKFLOW_EXTS) or os.path.exists(s):
return "workflow_source", s
if "civitai.com/models/" in low:
return "error", ("una URL de página de modelo no apunta a una imagen "
"concreta; pásame un link civitai.com/images/<id> o un "
"modelVersionId")
# Cualquier otra URL: dejar que el ejecutor de workflows foráneos lo intente.
if s.startswith(("http://", "https://")):
return "workflow_source", s
return "error", f"no se reconoce la entrada {url_or_id!r}"
def _server_models(server):
"""(checkpoints, loras) que ve el servidor; ([], []) si no responde."""
ckpts, loras = [], []
try:
ci = comfyui_object_info(server, "CheckpointLoaderSimple")
ckpts = ci["CheckpointLoaderSimple"]["input"]["required"]["ckpt_name"][0]
except Exception: # noqa: BLE001 — server caído / nodo ausente
ckpts = []
try:
li = comfyui_object_info(server, "LoraLoader")
loras = li["LoraLoader"]["input"]["required"]["lora_name"][0]
except Exception: # noqa: BLE001
loras = []
return list(ckpts), list(loras)
def _norm(name):
"""Normaliza un nombre de modelo para comparar (sin ext, sin separadores).
Ignora el token de arquitectura (SD15/SDXL/XL/FLUX) lo lleve como prefijo
(convencion nueva: `SDXL_detail_tweaker`) o como sufijo (recetas civitai:
`Detail-Tweaker XL`), para que el match sea robusto al reordenado del token.
"""
base = os.path.splitext(str(name))[0].lower()
s = re.sub(r"[^a-z0-9]", "", base)
for tok in ("sdxl", "sd15", "flux"):
if s.startswith(tok):
s = s[len(tok):]
if s.endswith(tok):
s = s[: -len(tok)]
if s.endswith("xl"): # 'xl' suelto como sufijo de arquitectura
s = s[:-2]
return s
def _find_installed(name, installed):
"""Devuelve el filename instalado que casa con `name` (exacto/normalizado), o None."""
if not name:
return None
target = _norm(name)
for cand in installed:
if _norm(cand) == target:
return cand
# Match laxo: el nombre normalizado de la receta contenido en el del archivo.
for cand in installed:
nc = _norm(cand)
if target and (target in nc or nc in target):
return cand
return None
def _pick_checkpoint(installed, family, hint):
"""Elige el checkpoint instalado más parecido. Devuelve (filename, exact:bool)."""
candidates = [c for c in installed if not any(k in c.lower() for k in _NON_IMAGE_CKPT)]
if not candidates:
candidates = list(installed)
if not candidates:
return None, False
exact = _find_installed(hint, candidates)
if exact:
return exact, True
if family in ("sdxl", "flux"):
xl = [c for c in candidates if "xl" in c.lower()]
if xl:
return xl[0], False
if family == "sd15":
non_xl = [c for c in candidates if "xl" not in c.lower()]
if non_xl:
return non_xl[0], False
# Familia desconocida o sin candidato de la familia: preferir un SD1.5 versátil.
non_xl = [c for c in candidates if "xl" not in c.lower()]
return (non_xl[0] if non_xl else candidates[0]), False
def _reconstruct_workflow(params, server):
"""Reconstruye un workflow txt2img desde la receta. Devuelve (workflow, missing)."""
family = params["family"]
defaults = _FAMILY_DEFAULTS.get(family, _FAMILY_DEFAULTS["unknown"])
installed_ckpts, installed_loras = _server_models(server)
if not installed_ckpts:
raise _ReplicateError(
"el servidor ComfyUI no devolvió checkpoints (¿vivo?); no se puede "
"elegir un modelo para la réplica")
ckpt, exact = _pick_checkpoint(installed_ckpts, family, params["checkpoint_hint"])
if not ckpt:
raise _ReplicateError("no hay ningún checkpoint de imagen instalado en el servidor")
missing = []
if not exact and params["checkpoint_hint"]:
missing.append({
"kind": "checkpoint",
"name": params["checkpoint_hint"],
"base_model_family": family,
"substituted_with": ckpt,
"note": "checkpoint exacto no instalado; réplica con el más parecido",
})
width = params["width"] or defaults["width"]
height = params["height"] or defaults["height"]
steps = params["steps"] or defaults["steps"]
cfg = params["cfg"] if params["cfg"] is not None else defaults["cfg"]
seed = params["seed"] if params["seed"] is not None else 0
workflow = comfyui_build_txt2img_workflow(
ckpt, params["positive"], params["negative"],
steps=steps, cfg=cfg, width=width, height=height, seed=seed,
sampler_name=params["sampler_name"], scheduler=params["scheduler"],
filename_prefix="civitai_replica",
)
for lora in params["loras"]:
match = _find_installed(lora["name"], installed_loras)
if match:
workflow = comfyui_inject_lora(
workflow, match,
strength_model=lora["weight"], strength_clip=lora["weight"],
)
else:
missing.append({
"kind": "lora", "name": lora["name"],
"substituted_with": None,
"note": "LoRA no instalado; omitido de la réplica (no se baja a ciegas)",
})
return workflow, missing
def _extract_positive_from_workflow(workflow):
"""Saca el texto positivo más largo de los CLIPTextEncode (para juzgar embebidos)."""
texts = []
for node in (workflow or {}).values():
if isinstance(node, dict) and "CLIPTextEncode" in str(node.get("class_type", "")):
t = node.get("inputs", {}).get("text")
if isinstance(t, str) and t.strip():
texts.append(t.strip())
return max(texts, key=len) if texts else ""
def comfyui_replicate_civitai_oneshot(
url_or_id,
*,
server: str = "127.0.0.1:8188",
dest: str | None = None,
judge: bool = True,
token: str | None = None,
wait_timeout: float = 600.0,
):
"""Replica una imagen de Civitai (o un workflow ajeno) en una sola llamada.
Args:
url_or_id: link/URL de una imagen Civitai (`civitai.com/images/<id>`), su id
numérico (int o str), un `modelVersionId` (se replica su primera imagen
SFW), o directamente una URL/ruta/dict de un workflow ComfyUI (PNG con
workflow embebido, .json, o dict en API format).
server: host:port del servidor ComfyUI (sin esquema). keyword-only.
dest: directorio donde guardar la réplica. None = `~/ComfyUI/civitai_replicas`.
keyword-only.
judge: si True, juzga la réplica con el panel `comfyui_judge_image` contra el
prompt extraído. keyword-only.
token: token Civitai (Bearer). None lo resuelve de `pass civitai/api-token`.
keyword-only.
wait_timeout: segundos máximos esperando a que ComfyUI termine. keyword-only.
Returns:
dict {ok, source, replica_image_path, prompt_id, judge, missing_models,
has_workflow, error}:
- source: receta observada {image_id, page_url, prompt, negative, model,
family, sampler_name, scheduler, steps, cfg, width, height, seed, loras,
process, has_workflow_embedded}.
- replica_image_path: ruta local de la imagen réplica generada.
- missing_models: modelos que la receta pedía y no teníamos, con la
sustitución aplicada (NUNCA se descargan a ciegas).
- judge: dict del panel de jueces (None si judge=False o no se generó).
- has_workflow: True si se replicó un workflow embebido tal cual.
ok=False con error claro si: el link es inválido/privado/sin meta, la imagen
es NSFW (se respeta SFW), el server no responde, o la generación falla.
"""
kind, ref = _classify_input(url_or_id)
if kind == "error":
return _err(ref)
# Caso A: la entrada YA es un workflow (PNG embebido / .json / dict / URL).
if kind == "workflow_source":
return _replicate_workflow_source(ref, server, dest, judge, token, wait_timeout)
# Caso B: modelVersionId -> resolver a la primera imagen SFW de esa versión.
if kind == "model_version":
sr = comfyui_search_civitai_images(model_version_id=ref, nsfw="None",
limit=10, token=token)
if not sr.get("ok") or not sr.get("items"):
return _err(f"no se hallaron imágenes SFW para modelVersionId {ref}: "
f"{sr.get('error') or '0 resultados'}")
ref = sr["items"][0]["id"]
# Caso C (principal): id/URL de imagen Civitai.
src = comfyui_fetch_civitai_image_meta(ref, token=token)
if not src.get("ok"):
return _err(f"no se pudieron observar los detalles de la imagen: {src.get('error')}")
if src.get("nsfw"):
return _err(
f"la imagen {src.get('image_id')} es NSFW (nivel {src.get('nsfw_level')!r}); "
"se respeta la política SFW y NO se replica.",
source=_source_from_meta(src, params=None))
params = comfyui_map_a1111_params(src["meta"], src.get("resources"))
source = _source_from_meta(src, params)
# Construcción del workflow: embebido tal cual, o reconstruido desde la receta.
has_workflow = bool(src.get("comfy_workflow"))
try:
if has_workflow:
workflow = src["comfy_workflow"]
missing = []
else:
workflow, missing = _reconstruct_workflow(params, server)
except _ReplicateError as exc:
return _err(str(exc), source=source)
out_dir = os.path.expanduser(dest or "~/ComfyUI/civitai_replicas")
run = comfyui_run_foreign_workflow_oneshot(
workflow, server=server, dest=out_dir, output_kind="image",
wait_timeout=wait_timeout, civitai_token=token,
)
if not run.get("ok"):
# run_foreign puede reportar deps faltantes propias (p.ej. un nodo custom).
extra_missing = run.get("missing") or []
return _err(f"la generación de la réplica falló: {run.get('error')}",
source=source, missing_models=missing + extra_missing,
has_workflow=has_workflow)
replica = run["outputs"][0]
judge_res = None
if judge:
try:
judge_res = comfyui_judge_image(replica, params["positive"], server=server)
except Exception as exc: # noqa: BLE001 — el juez no debe tumbar la réplica
judge_res = {"ok": False, "error": f"juez no disponible: {exc}"}
return {
"ok": True,
"source": source,
"replica_image_path": replica,
"prompt_id": run.get("prompt_id", ""),
"judge": judge_res,
"missing_models": missing,
"has_workflow": has_workflow,
"error": "",
}
def _replicate_workflow_source(source_ref, server, dest, judge, token, wait_timeout):
"""Replica un workflow ya embebido (PNG/.json/dict/URL) ejecutándolo tal cual."""
out_dir = os.path.expanduser(dest or "~/ComfyUI/civitai_replicas")
run = comfyui_run_foreign_workflow_oneshot(
source_ref, server=server, dest=out_dir, output_kind="image",
wait_timeout=wait_timeout, civitai_token=token,
)
if not run.get("ok"):
return _err(f"no se pudo ejecutar el workflow embebido: {run.get('error')}",
missing_models=run.get("missing") or [], has_workflow=True)
replica = run["outputs"][0]
positive = ""
if isinstance(source_ref, dict):
positive = _extract_positive_from_workflow(source_ref)
judge_res = None
if judge:
try:
judge_res = comfyui_judge_image(replica, positive, server=server)
except Exception as exc: # noqa: BLE001
judge_res = {"ok": False, "error": f"juez no disponible: {exc}"}
return {
"ok": True,
"source": {"prompt": positive, "has_workflow_embedded": True,
"source_type": run.get("source_type", "")},
"replica_image_path": replica,
"prompt_id": run.get("prompt_id", ""),
"judge": judge_res,
"missing_models": [],
"has_workflow": True,
"error": "",
}
def _source_from_meta(src, params):
"""Construye el sub-dict `source` legible de la salida."""
meta = src.get("meta") or {}
base = {
"image_id": src.get("image_id"),
"page_url": src.get("page_url", ""),
"process": src.get("process", ""),
"has_workflow_embedded": bool(src.get("comfy_workflow")),
"model": meta.get("Model") or meta.get("model"),
}
if params is not None:
base.update({
"prompt": params["positive"],
"negative": params["negative"],
"family": params["family"],
"sampler_name": params["sampler_name"],
"scheduler": params["scheduler"],
"steps": params["steps"],
"cfg": params["cfg"],
"width": params["width"],
"height": params["height"],
"seed": params["seed"],
"loras": [lo["name"] for lo in params["loras"]],
})
return base
def _err(msg, **extra):
base = {"ok": False, "source": {}, "replica_image_path": "", "prompt_id": "",
"judge": None, "missing_models": [], "has_workflow": False, "error": msg}
base.update(extra)
return base
class _ReplicateError(Exception):
"""Error interno de reconstrucción, traducido a {ok: False, error}."""
if __name__ == "__main__":
import json
ref = sys.argv[1] if len(sys.argv) > 1 else "https://civitai.com/images/23526611"
out = comfyui_replicate_civitai_oneshot(ref, judge=False)
print(json.dumps({k: v for k, v in out.items() if k != "judge"},
ensure_ascii=False, indent=2))