208 lines
6.7 KiB
Python
208 lines
6.7 KiB
Python
"""出图服务:调用适配器、落盘、记录 generations。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import re
|
|
import time
|
|
import uuid
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from pydantic import ValidationError
|
|
|
|
from .. import config, db
|
|
from ..providers import ProviderError, get_adapter
|
|
from ..providers.agnes import size_to_tier_and_ratio
|
|
from ..schemas import GenerateIn
|
|
from . import providers as provider_service
|
|
|
|
WEB_PROFILE = "user"
|
|
_SAFE = re.compile(r"[^A-Za-z0-9._-]+")
|
|
|
|
|
|
def _safe_model(model: str) -> str:
|
|
return _SAFE.sub("-", model).strip("-") or "image"
|
|
|
|
|
|
def record_generation(
|
|
*,
|
|
provider_id: str,
|
|
provider_name: str,
|
|
model: str,
|
|
profile_name: str,
|
|
prompt: str,
|
|
size: str,
|
|
ratio: str,
|
|
n: int,
|
|
status: str,
|
|
error: str,
|
|
files: list[str],
|
|
duration_ms: int,
|
|
) -> str:
|
|
gid = "gen_" + uuid.uuid4().hex[:12]
|
|
with db.session() as conn:
|
|
conn.execute(
|
|
"INSERT INTO generations (id,provider_id,provider_name,model,profile_name,prompt,size,"
|
|
"ratio,n,status,error,files,duration_ms,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
(
|
|
gid,
|
|
provider_id,
|
|
provider_name,
|
|
model,
|
|
profile_name,
|
|
prompt,
|
|
size,
|
|
ratio,
|
|
n,
|
|
status,
|
|
error,
|
|
json.dumps(files, ensure_ascii=False),
|
|
duration_ms,
|
|
datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
),
|
|
)
|
|
return gid
|
|
|
|
|
|
def list_generations() -> list[dict[str, Any]]:
|
|
with db.session() as conn:
|
|
rows = conn.execute("SELECT * FROM generations ORDER BY created_at DESC, rowid DESC").fetchall()
|
|
out: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
try:
|
|
files = json.loads(row["files"] or "[]")
|
|
except ValueError:
|
|
files = []
|
|
out.append(
|
|
{
|
|
"id": row["id"],
|
|
"provider_id": row["provider_id"],
|
|
"provider_name": row["provider_name"],
|
|
"model": row["model"],
|
|
"profile_name": row["profile_name"],
|
|
"prompt": row["prompt"],
|
|
"size": row["size"],
|
|
"ratio": row["ratio"],
|
|
"n": row["n"],
|
|
"status": row["status"],
|
|
"error": row["error"],
|
|
"files": files,
|
|
"duration_ms": row["duration_ms"],
|
|
"created_at": row["created_at"],
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def generate(
|
|
*,
|
|
provider_id: str,
|
|
model: str,
|
|
prompt: str,
|
|
size: str = "1024x1024",
|
|
ratio: str = "",
|
|
n: int = 1,
|
|
images: list[str] | None = None,
|
|
profile_name: str | None = None,
|
|
) -> dict[str, Any]:
|
|
try:
|
|
payload = GenerateIn(provider_id=provider_id, model=model, prompt=prompt, size=size,
|
|
ratio=ratio, n=n, images=images or [], profile_name=profile_name)
|
|
except ValidationError as exc:
|
|
raise ProviderError(str(exc), status=400) from exc
|
|
prompt = payload.prompt
|
|
size = payload.size
|
|
profile = payload.profile_name or WEB_PROFILE
|
|
cfg = provider_service.provider_config(provider_id, require_enabled=True)
|
|
selected = provider_service.get_model(f"{provider_id}:{model}")
|
|
if not selected["enabled"]:
|
|
raise ProviderError("模型已停用", status=400)
|
|
provider_name = cfg.name
|
|
adapter = get_adapter(cfg)
|
|
if images and not adapter.capabilities.supports_image_input:
|
|
raise ProviderError("该供应商不支持参考图输入", status=400)
|
|
if ratio and cfg.adapter != "agnes":
|
|
raise ProviderError("该供应商不支持 ratio,请使用 WxH 尺寸", status=400)
|
|
if cfg.adapter == "agnes":
|
|
_, ratio = size_to_tier_and_ratio(size, ratio)
|
|
elif "x" in size:
|
|
width, height = map(int, size.split("x"))
|
|
divisor = math.gcd(width, height)
|
|
ratio = f"{width // divisor}:{height // divisor}"
|
|
|
|
started = time.monotonic()
|
|
files: list[str] = []
|
|
folder = config.OUTPUT_DIR / profile
|
|
if not folder.resolve().is_relative_to(config.OUTPUT_DIR.resolve()):
|
|
raise ProviderError("画廊路径超出输出目录", status=400)
|
|
images_out: list[dict[str, Any]] = []
|
|
try:
|
|
results = adapter.generate(model, prompt, size, ratio, n, images or [])
|
|
if not results:
|
|
raise ProviderError("上游未返回图片")
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
stamp = uuid.uuid4().hex[:8]
|
|
for idx, item in enumerate(results):
|
|
if item.url and not item.data:
|
|
item = adapter.download(item.url)
|
|
if not item.data:
|
|
raise ProviderError("上游返回空图片")
|
|
suffix = item.filename if item.filename in config.IMAGE_SUFFIXES else ".png"
|
|
filename = f"{_safe_model(model)}-{stamp}{'' if len(results) == 1 else f'-{idx + 1}'}{suffix}"
|
|
path = folder / filename
|
|
files.append(filename)
|
|
path.write_bytes(item.data)
|
|
images_out.append({"filename": filename, "url": f"/output/{profile}/{filename}",
|
|
"bytes": path.stat().st_size})
|
|
except (ProviderError, OSError) as exc:
|
|
for filename in files:
|
|
(folder / filename).unlink(missing_ok=True)
|
|
message = exc.message if isinstance(exc, ProviderError) else "保存图片失败"
|
|
duration = int((time.monotonic() - started) * 1000)
|
|
record_generation(
|
|
provider_id=provider_id,
|
|
provider_name=provider_name,
|
|
model=model,
|
|
profile_name=profile,
|
|
prompt=prompt,
|
|
size=size,
|
|
ratio=ratio,
|
|
n=n,
|
|
status="failed",
|
|
error=message,
|
|
files=[],
|
|
duration_ms=duration,
|
|
)
|
|
raise ProviderError(message, status=exc.status if isinstance(exc, ProviderError) else 500) from exc
|
|
|
|
duration = int((time.monotonic() - started) * 1000)
|
|
gid = record_generation(
|
|
provider_id=provider_id,
|
|
provider_name=provider_name,
|
|
model=model,
|
|
profile_name=profile,
|
|
prompt=prompt,
|
|
size=size,
|
|
ratio=ratio or "1:1",
|
|
n=n,
|
|
status="success",
|
|
error="",
|
|
files=files,
|
|
duration_ms=duration,
|
|
)
|
|
|
|
return {
|
|
"id": gid,
|
|
"provider": provider_id,
|
|
"provider_name": provider_name,
|
|
"model": model,
|
|
"profile_name": profile,
|
|
"size": size,
|
|
"ratio": ratio or "1:1",
|
|
"images": images_out,
|
|
"output_dir": f"output/{profile}/",
|
|
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
|
}
|