feat(ml): pipeline replicar imagen desde link de Civitai

Nueva capacidad del grupo comfyui: dado el id/URL de una imagen de Civitai,
extrae cómo se generó (prompt, modelo, sampler, LoRAs) vía los endpoints tRPC
image.getGenerationData + image.get (la API v1 da meta=null), reconstruye el
workflow y lo replica en nuestro ComfyUI, sustituyendo el checkpoint ausente por
el más parecido instalado y reportando lo que falta en missing_models sin bajar
nada a ciegas. Respeta SFW.

Funciones nuevas (registry-first, componen 8 funciones existentes):
- comfyui_fetch_civitai_image_meta_py_ml (impura): observa la receta por id/URL.
- comfyui_map_a1111_params_py_ml (pura): traduce meta A1111 -> params ComfyUI,
  familia del modelo y LoRAs.
- comfyui_replicate_civitai_oneshot_py_pipelines: orquesta fetch_meta ->
  map_a1111_params -> build/embebido -> run_foreign_workflow_oneshot -> judge.

Probado en vivo (imagen SFW 23526611): receta extraída + réplica 1024x1024
generada + panel de jueces. 12 tests unitarios verdes. Capability page comfyui.md
actualizada. Report 0127.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 19:25:31 +02:00
parent 69d9aed46a
commit 394221f8c7
10 changed files with 1293 additions and 0 deletions
@@ -0,0 +1,68 @@
---
name: comfyui_fetch_civitai_image_meta
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_fetch_civitai_image_meta(image_ref, *, token: str | None = None, timeout: float = 15.0) -> dict"
description: "Recupera los detalles de generacion de una imagen de Civitai por su id o URL (civitai.com/images/<id>): prompt, prompt negativo, modelo, sampler, steps, cfg, seed, dimensiones, recursos (checkpoint + LoRAs) y nivel NSFW. Es el paso 'entrar al link y observar como lo hicieron'. Usa los endpoints tRPC image.getGenerationData + image.get que consume la propia web de civitai.com, porque la API v1 publica (GET /api/v1/images) hoy devuelve meta=null y un JPEG recomprimido sin workflow embebido. Si la meta trae un workflow ComfyUI embebido (campo comfy) lo devuelve en API format. NO descarga la imagen ni reconstruye workflow: solo lee. Impura: HTTP a civitai.com + subprocess (pass para el token)."
tags: [comfyui, civitai, replicate, ml, metadata, http, stable-diffusion]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json", "os", "re", "subprocess", "urllib.error", "urllib.parse", "urllib.request"]
params:
- name: image_ref
desc: "Id numerico de la imagen (int o str), o su URL 'https://civitai.com/images/<id>' (con o sin query string). Tambien acepta el path '/images/<id>'."
- name: token
desc: "API token de Civitai (header Authorization Bearer). Si None se resuelve de 'pass civitai/api-token'. No hardcodear. keyword-only."
- name: timeout
desc: "Timeout HTTP en segundos por peticion tRPC. keyword-only."
output: "dict {ok, image_id, meta, resources, process, comfy_workflow, width, height, nsfw, nsfw_level, post_id, url_uuid, page_url, error}. meta = dict de generacion estilo A1111/Civitai (prompt, negativePrompt, Model, sampler, steps, cfgScale, seed, Size, ...); {} si la imagen no expone datos. resources = lista de {modelType, modelName, ...}. comfy_workflow = workflow ComfyUI en API format si la meta lo trae embebido, {} si no. nsfw = bool derivado de nsfw_level (politica SFW del caller). ok=False con error si el id no se parsea, la red falla, o la imagen no tiene ni meta ni workflow embebido."
tested: true
tests:
- "test_parse_image_id_acepta_int_str_url"
- "test_parse_image_id_rechaza_invalidos"
- "test_is_nsfw_niveles"
- "test_extract_comfy_workflow_embebido_y_ausente"
test_file_path: "python/functions/ml/comfyui_fetch_civitai_image_meta_test.py"
file_path: "python/functions/ml/comfyui_fetch_civitai_image_meta.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_fetch_civitai_image_meta import comfyui_fetch_civitai_image_meta
# "Entra al link y observa los detalles de cómo lo hicieron":
src = comfyui_fetch_civitai_image_meta("https://civitai.com/images/23526611")
print(src["ok"], src["nsfw"]) # True False
print(src["meta"].get("sampler"), src["meta"].get("steps")) # Euler 20
print([r["modelName"] for r in src["resources"]]) # ['FLUX']
```
## Cuando usarla
Cuando te pasen un link o id de una imagen de Civitai y necesites saber **cómo se generó**
(prompt, modelo, sampler, LoRAs) antes de replicarla, comparar recetas, o alimentar la
librería de skills. Es el primer paso del pipeline `comfyui_replicate_civitai_oneshot`. Para
buscar imágenes por modelo/feed usa `comfyui_search_civitai_images`; esta función resuelve UNA
imagen concreta por id.
## Gotchas
- Usa endpoints tRPC **no documentados** de Civitai (`image.getGenerationData`, `image.get`).
Son los que usa la web y hoy funcionan, pero pueden cambiar sin aviso (frágil por diseño;
no hay alternativa pública que dé la meta por id).
- Civitai responde **HTTP 500** para un id inexistente o privado → la función devuelve
`ok=False` con el mensaje. Imágenes antiguas o subidas sin receta pueden devolver `meta={}`.
- El token sale de `pass civitai/api-token`. Sin token algunas imágenes gated no resuelven.
- `comfy_workflow` (workflow embebido) hoy rara vez viene por esta vía; lo normal es
reconstruir desde `meta` con `comfyui_map_a1111_params`.
- El `meta` está en formato A1111 (claves `Model`, `cfgScale`, `Size`), no ComfyUI. Tradúcelo
con `comfyui_map_a1111_params`.
@@ -0,0 +1,226 @@
"""Recupera los detalles de generación de una imagen de Civitai por su id/URL.
Es el paso "entrar al link y observar cómo lo hicieron": dado el id de una imagen
de Civitai (o su URL `civitai.com/images/<id>`), consulta los endpoints tRPC que
usa la propia web de Civitai para mostrar el panel *Generation data* y devuelve la
receta cruda de generación: prompt, prompt negativo, modelo, sampler, steps, cfg,
seed, dimensiones, recursos (checkpoint + LoRAs) y el nivel NSFW.
Por qué tRPC y no la API v1 pública: el endpoint documentado `GET /api/v1/images`
hoy devuelve `meta: null` para casi todas las imágenes y sirve un JPEG recomprimido
sin el workflow embebido (ver Gotchas de `comfyui_search_civitai_images`). Los
endpoints tRPC `image.getGenerationData` e `image.get` —los mismos que consume el
frontend de civitai.com— sí exponen la metadata de generación completa por id.
Esta función NO descarga la imagen ni reconstruye un workflow: solo lee los datos.
La reconstrucción del workflow y la réplica las hace el pipeline
`comfyui_replicate_civitai_oneshot`, que compone esta función.
Impura: red (HTTP GET a civitai.com) + subprocess (`pass` para el token). Solo
stdlib.
"""
import json
import os
import re
import subprocess
import urllib.error
import urllib.parse
import urllib.request
_TRPC = "https://civitai.com/api/trpc"
_TIMEOUT = 15.0
_UA = "Mozilla/5.0 (fn_registry comfyui_fetch_civitai_image_meta)"
_IMAGE_ID_RE = re.compile(r"/images/(\d+)")
def _resolve_token(token):
"""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 _parse_image_id(image_ref):
"""Extrae el id de imagen de un int, una cadena numérica o una URL de Civitai.
Devuelve (image_id:int, error:str). error no vacío si no se pudo determinar.
"""
if isinstance(image_ref, bool): # bool es subclase de int: rechazar explícito
return 0, f"image_ref no válido: {image_ref!r}"
if isinstance(image_ref, int):
return (image_ref, "") if image_ref > 0 else (0, f"id no válido: {image_ref}")
if isinstance(image_ref, str):
s = image_ref.strip()
if s.isdigit():
return int(s), ""
m = _IMAGE_ID_RE.search(s)
if m:
return int(m.group(1)), ""
return 0, (f"no se pudo extraer un image id de {image_ref!r}; pasa un id "
"numérico o una URL del tipo civitai.com/images/<id>")
return 0, f"image_ref debe ser int o str, no {type(image_ref).__name__}"
def _trpc_get(endpoint, image_id, token, timeout):
"""GET a un endpoint tRPC de Civitai por id. Devuelve (data:dict, error:str)."""
payload = urllib.parse.quote(json.dumps({"json": {"id": image_id}}))
url = f"{_TRPC}/{endpoint}?input={payload}"
headers = {"User-Agent": _UA}
if token:
headers["Authorization"] = f"Bearer {token}"
try:
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = json.loads(resp.read())
except urllib.error.HTTPError as exc:
body = exc.read().decode(errors="replace")[:200]
return {}, f"HTTP {exc.code} en {endpoint}: {body}"
except urllib.error.URLError as exc:
return {}, f"no se pudo conectar a civitai.com: {exc.reason}"
except json.JSONDecodeError as exc:
return {}, f"respuesta de {endpoint} no es JSON válido: {exc}"
return raw.get("result", {}).get("data", {}).get("json", {}) or {}, ""
def _is_nsfw(nsfw_level):
"""Interpreta el nsfwLevel de Civitai (None/'None'/1 = SFW; el resto = NSFW)."""
if nsfw_level in (None, "None", "", 0, 1):
return False
if isinstance(nsfw_level, str):
return nsfw_level not in ("None", "Safe")
if isinstance(nsfw_level, (int, float)) and not isinstance(nsfw_level, bool):
return nsfw_level > 1
return bool(nsfw_level)
def _extract_comfy_workflow(meta):
"""Devuelve el workflow ComfyUI en API format si la meta lo trae embebido.
Civitai guarda en las generaciones onSite hechas con ComfyUI un campo `comfy`
con un JSON serializado `{"prompt": {...}, "workflow": {...}}`. Hoy es raro por
la vía tRPC, pero si aparece se devuelve el chunk `prompt` (API format) para
replicar el workflow EXACTO sin reconstruirlo.
"""
raw = (meta or {}).get("comfy")
if not raw:
return {}
try:
obj = json.loads(raw) if isinstance(raw, str) else raw
except (json.JSONDecodeError, TypeError):
return {}
if isinstance(obj, dict):
prompt = obj.get("prompt")
if isinstance(prompt, dict) and prompt:
return prompt
# A veces el propio dict ya es el API format ({node_id: {class_type,...}}).
if obj and all(isinstance(v, dict) and "class_type" in v for v in obj.values()):
return obj
return {}
def comfyui_fetch_civitai_image_meta(image_ref, *, token=None, timeout=15.0):
"""Recupera los detalles de generación de una imagen de Civitai por id/URL.
Args:
image_ref: id numérico de la imagen (int o str), o su URL
`https://civitai.com/images/<id>` (con o sin query string).
token: API token de Civitai (header Authorization Bearer). Si None se
resuelve de `pass civitai/api-token`. No hardcodear. keyword-only.
timeout: timeout HTTP en segundos por petición. keyword-only.
Returns:
dict {ok, image_id, meta, resources, process, comfy_workflow, width,
height, nsfw, nsfw_level, post_id, url_uuid, page_url, error}:
- meta: dict de generación estilo A1111/Civitai (prompt, negativePrompt,
Model, sampler, steps, cfgScale, seed, Size, ...). {} si la imagen no
expone datos de generación.
- resources: lista de {modelType, modelName, ...} (checkpoint + LoRAs).
- comfy_workflow: workflow ComfyUI en API format si la meta lo trae
embebido (campo `comfy`); {} si no.
- nsfw: bool derivado de nsfw_level (política SFW del caller).
- url_uuid / post_id: identificadores para localizar el archivo original.
ok=False con error si el id no se puede parsear, la red falla, o la imagen
no tiene ni meta de generación ni workflow embebido (nada que replicar).
"""
image_id, perr = _parse_image_id(image_ref)
if perr:
return _err(perr)
tok = _resolve_token(token)
gen, gerr = _trpc_get("image.getGenerationData", image_id, tok, timeout)
if gerr:
return _err(f"getGenerationData falló: {gerr}", image_id=image_id)
info, ierr = _trpc_get("image.get", image_id, tok, timeout)
# image.get es complementario (url/dims/nsfw): si falla no abortamos, seguimos
# con lo que dé getGenerationData.
info = info or {}
meta = gen.get("meta") or {}
resources = gen.get("resources") or []
comfy_workflow = _extract_comfy_workflow(meta)
nsfw_level = info.get("nsfwLevel", gen.get("nsfwLevel"))
has_meta = bool(meta.get("prompt") or meta.get("Model") or resources)
if not has_meta and not comfy_workflow:
detail = "" if not ierr else f" (image.get: {ierr})"
return _err(
f"la imagen {image_id} no expone datos de generación ni workflow "
f"embebido (puede ser privada, sin metadata, o subida sin receta){detail}",
image_id=image_id, nsfw_level=nsfw_level,
)
return {
"ok": True,
"image_id": image_id,
"meta": meta,
"resources": resources,
"process": gen.get("process") or "",
"comfy_workflow": comfy_workflow,
"width": info.get("width"),
"height": info.get("height"),
"nsfw": _is_nsfw(nsfw_level),
"nsfw_level": nsfw_level,
"post_id": info.get("postId"),
"url_uuid": info.get("url") or "",
"page_url": f"https://civitai.com/images/{image_id}",
"error": "",
}
def _err(msg, **extra):
base = {
"ok": False, "image_id": 0, "meta": {}, "resources": [], "process": "",
"comfy_workflow": {}, "width": None, "height": None, "nsfw": False,
"nsfw_level": None, "post_id": None, "url_uuid": "", "page_url": "",
"error": msg,
}
base.update(extra)
return base
if __name__ == "__main__":
import sys
ref = sys.argv[1] if len(sys.argv) > 1 else "https://civitai.com/images/23526611"
out = comfyui_fetch_civitai_image_meta(ref)
m = out.get("meta") or {}
print(json.dumps({
"ok": out["ok"], "image_id": out["image_id"], "process": out["process"],
"nsfw": out["nsfw"], "has_comfy_workflow": bool(out["comfy_workflow"]),
"model": m.get("Model"), "sampler": m.get("sampler"),
"steps": m.get("steps"), "cfg": m.get("cfgScale"),
"prompt_head": (m.get("prompt") or "")[:80],
"n_resources": len(out["resources"]), "error": out["error"],
}, ensure_ascii=False, indent=2))
@@ -0,0 +1,56 @@
"""Tests de los helpers puros de comfyui_fetch_civitai_image_meta.
No tocan la red: validan el parseo de id, la interpretación de NSFW y la
extracción del workflow embebido. La función completa (impura) se valida en vivo
contra Civitai en el report 0127.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from comfyui_fetch_civitai_image_meta import ( # noqa: E402
_extract_comfy_workflow,
_is_nsfw,
_parse_image_id,
)
def test_parse_image_id_acepta_int_str_url():
assert _parse_image_id(23526611) == (23526611, "")
assert _parse_image_id("23526611") == (23526611, "")
assert _parse_image_id("https://civitai.com/images/9078035?x=1") == (9078035, "")
assert _parse_image_id("/images/777") == (777, "")
def test_parse_image_id_rechaza_invalidos():
for bad in ("not_an_id", -5, 0, True, 3.5, None):
iid, err = _parse_image_id(bad)
assert iid == 0 and err, f"deberia rechazar {bad!r}"
def test_is_nsfw_niveles():
for sfw in (None, "None", "", 0, 1, "Safe"):
assert _is_nsfw(sfw) is False, f"{sfw!r} deberia ser SFW"
for nsfw in (2, 4, 16, "Mature", "X", "Soft"):
assert _is_nsfw(nsfw) is True, f"{nsfw!r} deberia ser NSFW"
def test_extract_comfy_workflow_embebido_y_ausente():
# meta con un comfy embebido (string JSON {prompt: API format})
api = {"3": {"class_type": "KSampler", "inputs": {}}}
import json
meta_ok = {"comfy": json.dumps({"prompt": api})}
assert _extract_comfy_workflow(meta_ok) == api
# meta sin comfy -> {}
assert _extract_comfy_workflow({"prompt": "hola"}) == {}
# comfy ilegible -> {} (no crashea)
assert _extract_comfy_workflow({"comfy": "no es json {"}) == {}
if __name__ == "__main__":
test_parse_image_id_acepta_int_str_url()
test_parse_image_id_rechaza_invalidos()
test_is_nsfw_niveles()
test_extract_comfy_workflow_embebido_y_ausente()
print("OK")
@@ -0,0 +1,66 @@
---
name: comfyui_map_a1111_params
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_map_a1111_params(meta: dict, resources: list | None = None) -> dict"
description: "Traduce la metadata de generacion de Civitai/A1111 a parametros de ComfyUI. Mapea el sampler A1111 ('DPM++ 2M Karras') a (sampler_name='dpmpp_2m', scheduler='karras') de ComfyUI, normaliza steps/cfg/dims/seed/positive/negative a las claves que consumen los builders del registry, infiere la familia del modelo (sd15|sdxl|flux|unknown) por nombre/baseModel/recursos/dimensiones, y extrae los LoRAs tanto de los resources de Civitai como de las etiquetas <lora:nombre:peso> del prompt (sintaxis A1111, que ademas limpia del prompt). Funcion pura: sin red ni I/O."
tags: [comfyui, civitai, replicate, ml, a1111, sampler, pure, stable-diffusion]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["re"]
params:
- name: meta
desc: "Dict de generacion estilo A1111/Civitai. Claves reconocidas: prompt, negativePrompt, Model/model, baseModel, sampler, steps, cfgScale, seed, Size ('WxH'), clipSkip."
- name: resources
desc: "Lista de recursos de Civitai ({modelType, modelName, weight, baseModel, ...}) para detectar checkpoint, LoRAs y familia. Opcional (None = lista vacia)."
output: "dict {sampler_name, scheduler, steps, cfg, width, height, seed, positive, negative, family, checkpoint_hint, loras, clip_skip}. Los numericos son None cuando la meta no los aporta (el caller pone defaults por familia). family in {sd15, sdxl, flux, unknown}. loras = [{name, weight, source}]. positive viene SIN las etiquetas <lora:..> (que pasan a loras)."
tested: true
tests:
- "test_map_sampler_karras_y_ancestral"
- "test_infer_family_sdxl_sd15_flux"
- "test_loras_de_resources_y_tags_del_prompt"
- "test_dims_desde_size_y_numericos"
test_file_path: "python/functions/ml/comfyui_map_a1111_params_test.py"
file_path: "python/functions/ml/comfyui_map_a1111_params.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_map_a1111_params import comfyui_map_a1111_params
meta = {"prompt": "knight <lora:detail:0.6>", "negativePrompt": "blurry",
"Model": "juggernautXL_v11", "sampler": "DPM++ 2M Karras",
"steps": 30, "cfgScale": 5.5, "seed": 12345, "Size": "832x1216"}
p = comfyui_map_a1111_params(meta)
print(p["sampler_name"], p["scheduler"]) # dpmpp_2m karras
print(p["family"]) # sdxl
print(p["loras"]) # [{'name': 'detail', 'weight': 0.6, 'source': 'prompt_tag'}]
```
## Cuando usarla
Cuando tengas la `meta` de una imagen de Civitai/A1111 (p.ej. de
`comfyui_fetch_civitai_image_meta`) y necesites construir un workflow ComfyUI que la
reproduzca: traduce sampler/scheduler/dims/loras al vocabulario de ComfyUI y te dice la familia
del modelo para elegir un checkpoint compatible. Es el puente entre observar una receta y
construir el workflow en `comfyui_replicate_civitai_oneshot`.
## Gotchas
- El mapeo de sampler cubre los comunes; un sampler raro o mal escrito cae a
`('euler','normal')` (fallback seguro, no falla).
- `family` por dimensiones es heurística: si no hay pistas en nombres, dimensión mayor >=900
`sdxl`, si no `sd15`. Puede equivocarse con modelos atípicos.
- Los nombres de LoRA salen como los nombra Civitai (modelName o tag del prompt), NO como el
filename `.safetensors` instalado — el caller debe casarlos contra los modelos del servidor.
- Pura: no consulta el servidor ComfyUI ni valida que el checkpoint exista; eso lo hace el
pipeline.
@@ -0,0 +1,226 @@
"""Traduce la metadata de generación de Civitai/A1111 a parámetros de ComfyUI.
La metadata que expone Civitai (y Automatic1111) nombra el sampler y el scheduler
de forma distinta a ComfyUI: "DPM++ 2M Karras" en A1111 es
`sampler_name="dpmpp_2m"` + `scheduler="karras"` en ComfyUI. Esta función pura hace
ese mapeo y normaliza el resto de la receta a las claves que consumen los builders
del registry (`comfyui_build_txt2img_workflow`, etc.): steps, cfg, width, height,
seed, positive, negative.
Además infiere la *familia* del modelo (`sd15` / `sdxl` / `flux` / `unknown`) a
partir del nombre del modelo, el `baseModel`, los recursos y las dimensiones, para
que el pipeline de réplica pueda sustituir el checkpoint original por uno instalado
de la misma familia cuando el exacto no esté disponible. Y extrae los LoRAs tanto
de los `resources` de Civitai como de las etiquetas `<lora:nombre:peso>` embebidas
en el propio prompt (sintaxis A1111).
Función pura: sin red, sin I/O. Solo stdlib (re).
"""
import re
# Mapeo sampler A1111 -> (sampler_name ComfyUI, scheduler por defecto). El scheduler
# real puede venir como sufijo del nombre A1111 ("... Karras") y se detecta aparte.
_SAMPLER_MAP = {
"euler": ("euler", "normal"),
"euler a": ("euler_ancestral", "normal"),
"euler ancestral": ("euler_ancestral", "normal"),
"lms": ("lms", "normal"),
"heun": ("heun", "normal"),
"dpm2": ("dpm_2", "normal"),
"dpm2 a": ("dpm_2_ancestral", "normal"),
"dpm fast": ("dpm_fast", "normal"),
"dpm adaptive": ("dpm_adaptive", "normal"),
"dpm++ 2s a": ("dpmpp_2s_ancestral", "normal"),
"dpm++ 2m": ("dpmpp_2m", "normal"),
"dpm++ sde": ("dpmpp_sde", "normal"),
"dpm++ 2m sde": ("dpmpp_2m_sde", "normal"),
"dpm++ 2m sde heun": ("dpmpp_2m_sde", "normal"),
"dpm++ 3m sde": ("dpmpp_3m_sde", "normal"),
"ddim": ("ddim", "ddim_uniform"),
"ddpm": ("ddpm", "normal"),
"plms": ("euler", "normal"), # PLMS no existe en ComfyUI -> fallback euler
"unipc": ("uni_pc", "normal"),
"lcm": ("lcm", "normal"),
"restart": ("restart", "normal"),
}
# Sufijos de scheduler que A1111 concatena al nombre del sampler.
_SCHEDULER_SUFFIXES = [
("karras", "karras"),
("exponential", "exponential"),
("sgm uniform", "sgm_uniform"),
("simple", "simple"),
("ddim uniform", "ddim_uniform"),
("beta", "beta"),
]
_DEFAULT_SAMPLER = ("euler", "normal")
# Etiqueta A1111 de LoRA embebida en el prompt: <lora:nombre:peso>.
_LORA_TAG_RE = re.compile(r"<lora:([^:>]+)(?::([0-9.]+))?[^>]*>", re.IGNORECASE)
_SDXL_HINTS = ("xl", "pony", "sdxl", "illustrious", "noob", "animagine", "playground")
_SD15_HINTS = ("sd 1.5", "sd1.5", "sd15", "v1-5", "v1.5", "1.5")
_FLUX_HINTS = ("flux",)
def _map_sampler(raw):
"""Traduce un nombre de sampler A1111 a (sampler_name, scheduler) de ComfyUI."""
if not raw or not isinstance(raw, str):
return _DEFAULT_SAMPLER
name = raw.strip().lower()
scheduler = None
for suffix, sched in _SCHEDULER_SUFFIXES:
if name.endswith(" " + suffix):
scheduler = sched
name = name[: -len(suffix)].strip()
break
sampler_name, default_sched = _SAMPLER_MAP.get(name, _DEFAULT_SAMPLER)
return sampler_name, (scheduler or default_sched)
def _num(value, cast):
"""Castea best-effort un valor que puede venir como str/num; None si no se puede."""
if value is None or isinstance(value, bool):
return None
try:
return cast(value)
except (TypeError, ValueError):
try:
return cast(float(value)) if cast is int else cast(value)
except (TypeError, ValueError):
return None
def _dims_from_size(size):
"""Parsea 'WxH' (ej. '832x1216') a (width, height); (None, None) si no procede."""
if not isinstance(size, str) or "x" not in size.lower():
return None, None
try:
w, h = size.lower().split("x", 1)
return int(w.strip()), int(h.strip())
except (ValueError, AttributeError):
return None, None
def _infer_family(meta, resources, width, height):
"""Infiere 'sd15' | 'sdxl' | 'flux' | 'unknown' de la receta."""
blob_parts = [
str(meta.get("Model") or ""),
str(meta.get("model") or ""),
str(meta.get("baseModel") or ""),
]
for res in resources or []:
if isinstance(res, dict):
blob_parts.append(str(res.get("modelName") or ""))
blob_parts.append(str(res.get("baseModel") or ""))
blob = " ".join(blob_parts).lower()
if any(h in blob for h in _FLUX_HINTS):
return "flux"
if any(h in blob for h in _SDXL_HINTS):
return "sdxl"
if any(h in blob for h in _SD15_HINTS):
return "sd15"
# Sin pistas en los nombres: deducir por la dimensión mayor.
longest = max(width or 0, height or 0)
if longest >= 900:
return "sdxl"
if longest > 0:
return "sd15"
return "unknown"
def _checkpoint_hint(meta, resources):
"""Nombre del checkpoint original (Model de la meta o primer resource checkpoint)."""
model = meta.get("Model") or meta.get("model")
if isinstance(model, str) and model.strip():
return model.strip()
for res in resources or []:
if isinstance(res, dict) and str(res.get("modelType", "")).lower() in (
"checkpoint", "model"
):
nm = res.get("modelName") or res.get("name")
if nm:
return str(nm)
return ""
def _loras(meta, resources):
"""Extrae LoRAs de los resources Civitai y de las etiquetas <lora:..> del prompt."""
out = []
seen = set()
for res in resources or []:
if isinstance(res, dict) and str(res.get("modelType", "")).lower() == "lora":
nm = res.get("modelName") or res.get("name")
if nm and str(nm) not in seen:
seen.add(str(nm))
w = res.get("weight")
weight = w if isinstance(w, (int, float)) and not isinstance(w, bool) else 1.0
out.append({"name": str(nm), "weight": float(weight), "source": "resource"})
for m in _LORA_TAG_RE.finditer(str(meta.get("prompt") or "")):
nm = m.group(1).strip()
if nm and nm not in seen:
seen.add(nm)
weight = float(m.group(2)) if m.group(2) else 1.0
out.append({"name": nm, "weight": weight, "source": "prompt_tag"})
return out
def _clean_prompt(prompt):
"""Quita las etiquetas <lora:..> del prompt (ComfyUI las maneja como nodos)."""
return _LORA_TAG_RE.sub("", str(prompt or "")).strip().strip(",").strip()
def comfyui_map_a1111_params(meta, resources=None):
"""Traduce metadata de generación Civitai/A1111 a parámetros de ComfyUI.
Args:
meta: dict de generación estilo A1111/Civitai. Claves reconocidas: `prompt`,
`negativePrompt`, `Model`/`model`, `baseModel`, `sampler`, `steps`,
`cfgScale`, `seed`, `Size` ('WxH'), `clipSkip`.
resources: lista de recursos de Civitai ({modelType, modelName, weight,
baseModel, ...}) para detectar checkpoint, LoRAs y familia. Opcional.
Returns:
dict {sampler_name, scheduler, steps, cfg, width, height, seed, positive,
negative, family, checkpoint_hint, loras, clip_skip}. Los valores numéricos
son None cuando la meta no los aporta (el caller pone defaults por familia).
`family` ∈ {sd15, sdxl, flux, unknown}. `loras` = [{name, weight, source}].
`positive` viene sin las etiquetas <lora:..> (que pasan a `loras`).
"""
meta = meta or {}
resources = resources or []
sampler_name, scheduler = _map_sampler(meta.get("sampler"))
width, height = _dims_from_size(meta.get("Size"))
family = _infer_family(meta, resources, width, height)
return {
"sampler_name": sampler_name,
"scheduler": scheduler,
"steps": _num(meta.get("steps"), int),
"cfg": _num(meta.get("cfgScale"), float),
"width": width,
"height": height,
"seed": _num(meta.get("seed"), int),
"positive": _clean_prompt(meta.get("prompt")),
"negative": str(meta.get("negativePrompt") or "").strip(),
"family": family,
"checkpoint_hint": _checkpoint_hint(meta, resources),
"loras": _loras(meta, resources),
"clip_skip": _num(meta.get("clipSkip"), int),
}
if __name__ == "__main__":
import json
demo_meta = {
"prompt": "cinematic portrait of a knight <lora:detail_tweaker:0.6>, sharp focus",
"negativePrompt": "blurry, lowres",
"Model": "juggernautXL_v11",
"sampler": "DPM++ 2M Karras",
"steps": 30, "cfgScale": 5.5, "seed": 12345, "Size": "832x1216",
}
print(json.dumps(comfyui_map_a1111_params(demo_meta), ensure_ascii=False, indent=2))
@@ -0,0 +1,56 @@
"""Tests de comfyui_map_a1111_params (función pura): mapeo sampler, familia, loras, dims."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from comfyui_map_a1111_params import comfyui_map_a1111_params # noqa: E402
def test_map_sampler_karras_y_ancestral():
p = comfyui_map_a1111_params({"sampler": "DPM++ 2M Karras"})
assert (p["sampler_name"], p["scheduler"]) == ("dpmpp_2m", "karras")
p = comfyui_map_a1111_params({"sampler": "Euler a"})
assert (p["sampler_name"], p["scheduler"]) == ("euler_ancestral", "normal")
p = comfyui_map_a1111_params({"sampler": "DDIM"})
assert (p["sampler_name"], p["scheduler"]) == ("ddim", "ddim_uniform")
# sampler desconocido -> fallback seguro
p = comfyui_map_a1111_params({"sampler": "sampler_inventado"})
assert (p["sampler_name"], p["scheduler"]) == ("euler", "normal")
def test_infer_family_sdxl_sd15_flux():
assert comfyui_map_a1111_params({"Model": "juggernautXL_v11"})["family"] == "sdxl"
assert comfyui_map_a1111_params({"Model": "dreamshaper", "Size": "512x768"})["family"] == "sd15"
assert comfyui_map_a1111_params({}, [{"modelType": "Checkpoint", "modelName": "FLUX"}])["family"] == "flux"
# sin pistas en nombres -> por dimensión
assert comfyui_map_a1111_params({"Size": "1024x1024"})["family"] == "sdxl"
assert comfyui_map_a1111_params({})["family"] == "unknown"
def test_loras_de_resources_y_tags_del_prompt():
meta = {"prompt": "knight <lora:detail_tweaker:0.6>, sharp focus"}
resources = [{"modelType": "LORA", "modelName": "Add Detail XL", "weight": 0.8}]
p = comfyui_map_a1111_params(meta, resources)
names = {lo["name"] for lo in p["loras"]}
assert "Add Detail XL" in names and "detail_tweaker" in names
# el tag <lora:..> se quita del positive
assert "<lora:" not in p["positive"]
assert "knight" in p["positive"]
def test_dims_desde_size_y_numericos():
p = comfyui_map_a1111_params({"Size": "832x1216", "steps": 30, "cfgScale": 5.5, "seed": 7})
assert (p["width"], p["height"]) == (832, 1216)
assert p["steps"] == 30 and p["cfg"] == 5.5 and p["seed"] == 7
# ausentes -> None (el caller pone defaults)
p = comfyui_map_a1111_params({})
assert p["width"] is None and p["steps"] is None and p["cfg"] is None
if __name__ == "__main__":
test_map_sampler_karras_y_ancestral()
test_infer_family_sdxl_sd15_flux()
test_loras_de_resources_y_tags_del_prompt()
test_dims_desde_size_y_numericos()
print("OK")