2026-03-18 17:18:23 +08:00
|
|
|
|
"""
|
|
|
|
|
|
Liblib 图生 SDK 封装,位于 adapters 层;实现细节不暴露给 feature。
|
|
|
|
|
|
Feature 通过 port ImageGenerator 使用,本模块仅被 app.adapters.image_gen.liblib 使用。
|
|
|
|
|
|
"""
|
2026-03-19 14:36:14 +08:00
|
|
|
|
|
2026-03-10 17:02:50 +08:00
|
|
|
|
import base64
|
|
|
|
|
|
import hmac
|
|
|
|
|
|
import logging
|
2026-03-11 11:26:42 +08:00
|
|
|
|
import re
|
2026-03-10 16:00:59 +08:00
|
|
|
|
import time
|
2026-03-10 17:02:50 +08:00
|
|
|
|
import uuid
|
|
|
|
|
|
from hashlib import sha1
|
2026-03-11 11:26:42 +08:00
|
|
|
|
from urllib.parse import urlparse
|
2026-03-10 16:00:59 +08:00
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
from app.core.config import settings
|
2026-04-08 15:37:09 +08:00
|
|
|
|
from app.core.logging import get_logger
|
2026-03-18 17:18:23 +08:00
|
|
|
|
|
|
|
|
|
|
logger = get_logger(__name__)
|
2026-03-11 11:26:42 +08:00
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
DEFAULT_LIBLIB_TEMPLATE_UUID = "5d7e67009b344550bc1aa6ccbfa1d7f4"
|
2026-03-10 17:02:50 +08:00
|
|
|
|
|
2026-03-11 11:26:42 +08:00
|
|
|
|
_SENSITIVE_QUERY_PARAMS = ("AccessKey", "Signature", "Timestamp", "SignatureNonce")
|
|
|
|
|
|
_SENSITIVE_QUERY_RE = re.compile(
|
|
|
|
|
|
r"([?&])(" + "|".join(_SENSITIVE_QUERY_PARAMS) + r")=([^&\s]+)"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-10 17:02:50 +08:00
|
|
|
|
_SIZE_TO_ASPECT_RATIO = {
|
|
|
|
|
|
"1024x1024": "square",
|
|
|
|
|
|
"768x1024": "portrait",
|
|
|
|
|
|
"1280x720": "landscape",
|
|
|
|
|
|
}
|
2026-03-11 15:36:58 +08:00
|
|
|
|
_DEFAULT_WIDTH, _DEFAULT_HEIGHT = 1024, 1024
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_size(size: str) -> tuple[int, int]:
|
|
|
|
|
|
try:
|
|
|
|
|
|
w_str, h_str = size.lower().split("x", 1)
|
|
|
|
|
|
w = max(512, min(2048, int(w_str)))
|
|
|
|
|
|
h = max(512, min(2048, int(h_str)))
|
|
|
|
|
|
return w, h
|
|
|
|
|
|
except (ValueError, AttributeError):
|
|
|
|
|
|
return _DEFAULT_WIDTH, _DEFAULT_HEIGHT
|
|
|
|
|
|
|
2026-03-10 16:00:59 +08:00
|
|
|
|
|
|
|
|
|
|
class LiblibImageProvider:
|
2026-03-18 17:18:23 +08:00
|
|
|
|
"""Liblib (https://openapi.liblibai.cloud) image generation — adapter 层实现。"""
|
2026-03-10 17:02:50 +08:00
|
|
|
|
|
2026-03-10 16:00:59 +08:00
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
2026-03-10 17:02:50 +08:00
|
|
|
|
http_client: httpx.Client | None = None,
|
|
|
|
|
|
access_key: str | None = None,
|
|
|
|
|
|
secret_key: str | None = None,
|
2026-03-10 16:00:59 +08:00
|
|
|
|
base_url: str | None = None,
|
2026-03-10 17:02:50 +08:00
|
|
|
|
template_uuid: str | None = None,
|
2026-03-11 11:26:42 +08:00
|
|
|
|
allowed_download_hosts: tuple[str, ...] | None = None,
|
2026-03-10 16:00:59 +08:00
|
|
|
|
):
|
2026-03-11 11:26:42 +08:00
|
|
|
|
_install_http_log_redaction()
|
|
|
|
|
|
self._owns_http_client = http_client is None
|
2026-03-10 17:02:50 +08:00
|
|
|
|
self.http_client = http_client or httpx.Client(timeout=120)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
self.access_key = access_key or (settings.liblib_access_key or "")
|
|
|
|
|
|
self.secret_key = secret_key or (settings.liblib_secret_key or "")
|
2026-03-19 14:36:14 +08:00
|
|
|
|
self.base_url = (
|
|
|
|
|
|
base_url or settings.liblib_base_url or "https://openapi.liblibai.cloud"
|
|
|
|
|
|
).rstrip("/")
|
|
|
|
|
|
self.template_uuid = template_uuid or (
|
|
|
|
|
|
settings.liblib_template_uuid or DEFAULT_LIBLIB_TEMPLATE_UUID
|
|
|
|
|
|
)
|
2026-03-11 11:26:42 +08:00
|
|
|
|
self.allowed_download_hosts = _build_allowed_download_hosts(
|
|
|
|
|
|
self.base_url,
|
|
|
|
|
|
allowed_download_hosts=allowed_download_hosts,
|
2026-03-10 17:02:50 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-11 11:26:42 +08:00
|
|
|
|
def _build_url(self, uri: str) -> str:
|
|
|
|
|
|
return f"{self.base_url}{uri}"
|
|
|
|
|
|
|
|
|
|
|
|
def _sign(self, uri: str) -> dict[str, str]:
|
2026-03-10 17:02:50 +08:00
|
|
|
|
timestamp = str(int(time.time() * 1000))
|
|
|
|
|
|
nonce = str(uuid.uuid4())
|
|
|
|
|
|
content = "&".join((uri, timestamp, nonce))
|
|
|
|
|
|
digest = hmac.new(self.secret_key.encode(), content.encode(), sha1).digest()
|
|
|
|
|
|
signature = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
|
2026-03-11 11:26:42 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"AccessKey": self.access_key,
|
|
|
|
|
|
"Signature": signature,
|
|
|
|
|
|
"Timestamp": timestamp,
|
|
|
|
|
|
"SignatureNonce": nonce,
|
|
|
|
|
|
}
|
2026-03-10 17:02:50 +08:00
|
|
|
|
|
2026-03-10 16:00:59 +08:00
|
|
|
|
def submit_generation(self, prompt: str, size: str, style: str) -> dict:
|
2026-03-10 17:02:50 +08:00
|
|
|
|
uri = "/api/generate/webui/text2img/ultra"
|
2026-03-11 11:26:42 +08:00
|
|
|
|
url = self._build_url(uri)
|
|
|
|
|
|
params = self._sign(uri)
|
|
|
|
|
|
styled_prompt = _apply_style_to_prompt(prompt, style)
|
2026-03-11 15:36:58 +08:00
|
|
|
|
aspect_ratio = _SIZE_TO_ASPECT_RATIO.get(size)
|
|
|
|
|
|
generate_params: dict = {
|
|
|
|
|
|
"prompt": styled_prompt,
|
|
|
|
|
|
"imgCount": 1,
|
|
|
|
|
|
"steps": 30,
|
|
|
|
|
|
}
|
|
|
|
|
|
if aspect_ratio:
|
|
|
|
|
|
generate_params["aspectRatio"] = aspect_ratio
|
|
|
|
|
|
else:
|
|
|
|
|
|
w, h = _parse_size(size)
|
|
|
|
|
|
generate_params["imageSize"] = {"width": w, "height": h}
|
2026-03-10 17:02:50 +08:00
|
|
|
|
body = {
|
|
|
|
|
|
"templateUuid": self.template_uuid,
|
2026-03-11 15:36:58 +08:00
|
|
|
|
"generateParams": generate_params,
|
2026-03-10 17:02:50 +08:00
|
|
|
|
}
|
2026-03-10 16:00:59 +08:00
|
|
|
|
response = self.http_client.post(
|
2026-03-10 17:02:50 +08:00
|
|
|
|
url,
|
2026-03-11 11:26:42 +08:00
|
|
|
|
params=params,
|
2026-03-10 17:02:50 +08:00
|
|
|
|
headers={"Content-Type": "application/json"},
|
|
|
|
|
|
json=body,
|
2026-03-10 16:00:59 +08:00
|
|
|
|
)
|
2026-03-10 17:02:50 +08:00
|
|
|
|
response.raise_for_status()
|
2026-03-10 16:00:59 +08:00
|
|
|
|
data = response.json()
|
2026-03-10 17:02:50 +08:00
|
|
|
|
if data.get("code") != 0:
|
|
|
|
|
|
raise RuntimeError(f"Liblib submit failed: {data.get('msg', data)}")
|
|
|
|
|
|
generate_uuid = data["data"]["generateUuid"]
|
|
|
|
|
|
return {"status": "processing", "job_id": generate_uuid, "image_url": None}
|
|
|
|
|
|
|
|
|
|
|
|
def poll_until_complete(
|
|
|
|
|
|
self, job: dict, poll_interval_seconds: int, max_attempts: int
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
uri = "/api/generate/webui/status"
|
|
|
|
|
|
for attempt in range(max_attempts):
|
2026-03-11 11:26:42 +08:00
|
|
|
|
url = self._build_url(uri)
|
|
|
|
|
|
params = self._sign(uri)
|
2026-03-10 17:02:50 +08:00
|
|
|
|
response = self.http_client.post(
|
|
|
|
|
|
url,
|
2026-03-11 11:26:42 +08:00
|
|
|
|
params=params,
|
2026-03-10 17:02:50 +08:00
|
|
|
|
headers={"Content-Type": "application/json"},
|
|
|
|
|
|
json={"generateUuid": job["job_id"]},
|
2026-03-10 16:00:59 +08:00
|
|
|
|
)
|
2026-03-10 17:02:50 +08:00
|
|
|
|
response.raise_for_status()
|
2026-03-10 16:00:59 +08:00
|
|
|
|
data = response.json()
|
2026-03-10 17:02:50 +08:00
|
|
|
|
if data.get("code") != 0:
|
2026-03-19 14:36:14 +08:00
|
|
|
|
raise RuntimeError(
|
|
|
|
|
|
f"Liblib status query failed: {data.get('msg', data)}"
|
|
|
|
|
|
)
|
2026-03-10 17:02:50 +08:00
|
|
|
|
result = data.get("data", {})
|
|
|
|
|
|
status = result.get("generateStatus")
|
2026-03-18 17:18:23 +08:00
|
|
|
|
if status == 5:
|
2026-03-10 17:02:50 +08:00
|
|
|
|
images = result.get("images") or []
|
|
|
|
|
|
if images:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"image_url": images[0]["imageUrl"],
|
|
|
|
|
|
"job_id": job["job_id"],
|
|
|
|
|
|
}
|
2026-03-19 14:36:14 +08:00
|
|
|
|
raise RuntimeError(
|
|
|
|
|
|
f"Liblib returned success but no images for {job['job_id']}"
|
|
|
|
|
|
)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
if status == 6:
|
2026-03-19 14:36:14 +08:00
|
|
|
|
raise RuntimeError(
|
|
|
|
|
|
f"Liblib generation failed: {result.get('generateMsg', 'unknown')}"
|
|
|
|
|
|
)
|
2026-03-11 15:36:58 +08:00
|
|
|
|
if status == 7:
|
2026-03-19 14:36:14 +08:00
|
|
|
|
raise TimeoutError(
|
|
|
|
|
|
f"Liblib returned undocumented status 7 for {job['job_id']}"
|
|
|
|
|
|
)
|
2026-03-10 17:02:50 +08:00
|
|
|
|
logger.debug(
|
2026-03-26 12:13:36 +08:00
|
|
|
|
"Liblib poll attempt {}/{}, status={}, job={}",
|
2026-03-19 14:36:14 +08:00
|
|
|
|
attempt + 1,
|
|
|
|
|
|
max_attempts,
|
|
|
|
|
|
status,
|
|
|
|
|
|
job["job_id"],
|
2026-03-10 17:02:50 +08:00
|
|
|
|
)
|
2026-03-10 16:00:59 +08:00
|
|
|
|
time.sleep(poll_interval_seconds)
|
2026-03-19 14:36:14 +08:00
|
|
|
|
raise TimeoutError(
|
|
|
|
|
|
f"Liblib image generation timed out after {max_attempts} attempts for {job['job_id']}"
|
|
|
|
|
|
)
|
2026-03-10 16:00:59 +08:00
|
|
|
|
|
|
|
|
|
|
def download_image(self, job: dict) -> bytes:
|
2026-03-11 11:26:42 +08:00
|
|
|
|
image_url = job["image_url"]
|
|
|
|
|
|
_validate_download_url(image_url, self.allowed_download_hosts)
|
|
|
|
|
|
response = self.http_client.get(image_url)
|
2026-03-10 17:02:50 +08:00
|
|
|
|
response.raise_for_status()
|
2026-03-10 16:00:59 +08:00
|
|
|
|
return response.content
|
2026-03-11 11:26:42 +08:00
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
def download_image_from_url(self, image_url: str) -> bytes:
|
|
|
|
|
|
"""按 URL 下载图片(用于 port 的 download_image)。"""
|
|
|
|
|
|
return self.download_image({"image_url": image_url})
|
|
|
|
|
|
|
2026-03-11 11:26:42 +08:00
|
|
|
|
def close(self) -> None:
|
|
|
|
|
|
if self._owns_http_client:
|
|
|
|
|
|
self.http_client.close()
|
|
|
|
|
|
|
|
|
|
|
|
def __enter__(self) -> "LiblibImageProvider":
|
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> None:
|
|
|
|
|
|
self.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _LiblibAuthRedactionFilter(logging.Filter):
|
|
|
|
|
|
def filter(self, record: logging.LogRecord) -> bool:
|
|
|
|
|
|
record.msg = _redact_sensitive_query_values(record.msg)
|
|
|
|
|
|
if record.args:
|
|
|
|
|
|
if isinstance(record.args, dict):
|
2026-03-19 14:36:14 +08:00
|
|
|
|
record.args = {
|
|
|
|
|
|
k: _redact_sensitive_query_values(v) for k, v in record.args.items()
|
|
|
|
|
|
}
|
2026-03-11 11:26:42 +08:00
|
|
|
|
else:
|
2026-03-19 14:36:14 +08:00
|
|
|
|
record.args = tuple(
|
|
|
|
|
|
_redact_sensitive_query_values(v) for v in record.args
|
|
|
|
|
|
)
|
2026-03-11 11:26:42 +08:00
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _redact_sensitive_query_values(value):
|
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
|
return _SENSITIVE_QUERY_RE.sub(r"\1\2=[REDACTED]", value)
|
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _install_http_log_redaction() -> None:
|
2026-03-26 12:13:36 +08:00
|
|
|
|
"""对 httpx/httpcore 的标准库 Logger 挂 Filter(非 loguru get_logger)。"""
|
2026-03-19 14:36:14 +08:00
|
|
|
|
for logger_name in (
|
|
|
|
|
|
"httpx",
|
|
|
|
|
|
"httpcore",
|
|
|
|
|
|
"httpcore.connection",
|
|
|
|
|
|
"httpcore.http11",
|
|
|
|
|
|
"httpcore.proxy",
|
|
|
|
|
|
):
|
2026-03-26 12:13:36 +08:00
|
|
|
|
target_logger = logging.getLogger(logger_name)
|
2026-03-11 11:26:42 +08:00
|
|
|
|
if getattr(target_logger, "_liblib_auth_redaction_installed", False):
|
|
|
|
|
|
continue
|
|
|
|
|
|
target_logger.addFilter(_LiblibAuthRedactionFilter())
|
|
|
|
|
|
target_logger._liblib_auth_redaction_installed = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_allowed_download_hosts(
|
|
|
|
|
|
base_url: str,
|
|
|
|
|
|
allowed_download_hosts: tuple[str, ...] | None = None,
|
|
|
|
|
|
) -> tuple[str, ...]:
|
|
|
|
|
|
configured_hosts = allowed_download_hosts
|
|
|
|
|
|
if configured_hosts is None:
|
|
|
|
|
|
configured_hosts = tuple(
|
|
|
|
|
|
host.strip().lower()
|
2026-03-18 17:18:23 +08:00
|
|
|
|
for host in (settings.memoir_image_download_hosts or "").split(",")
|
2026-03-11 11:26:42 +08:00
|
|
|
|
if host.strip()
|
|
|
|
|
|
)
|
|
|
|
|
|
base_hostname = (urlparse(base_url).hostname or "").lower()
|
|
|
|
|
|
default_hosts: set[str] = set()
|
|
|
|
|
|
if base_hostname:
|
|
|
|
|
|
default_hosts.add(base_hostname)
|
2026-03-19 14:36:14 +08:00
|
|
|
|
if (
|
|
|
|
|
|
base_hostname.endswith(".liblibai.cloud")
|
|
|
|
|
|
or base_hostname == "liblibai.cloud"
|
|
|
|
|
|
):
|
2026-03-11 11:26:42 +08:00
|
|
|
|
default_hosts.add("liblibai.cloud")
|
2026-03-11 13:18:20 +08:00
|
|
|
|
default_hosts.add("liblib.cloud")
|
2026-03-11 11:26:42 +08:00
|
|
|
|
return tuple(sorted(default_hosts.union(configured_hosts)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_download_url(image_url: str, allowed_hosts: tuple[str, ...]) -> None:
|
|
|
|
|
|
parsed = urlparse(image_url)
|
|
|
|
|
|
hostname = (parsed.hostname or "").lower()
|
|
|
|
|
|
if parsed.scheme != "https" or not hostname:
|
|
|
|
|
|
raise ValueError(f"Unsupported image download URL: {image_url}")
|
2026-03-19 14:36:14 +08:00
|
|
|
|
if not any(
|
|
|
|
|
|
_hostname_matches(hostname, allowed_host) for allowed_host in allowed_hosts
|
|
|
|
|
|
):
|
2026-03-11 11:26:42 +08:00
|
|
|
|
raise ValueError(f"Image download host is not allowed: {hostname}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _hostname_matches(hostname: str, allowed_host: str) -> bool:
|
|
|
|
|
|
normalized_allowed = allowed_host.strip().lower()
|
|
|
|
|
|
if not normalized_allowed:
|
|
|
|
|
|
return False
|
|
|
|
|
|
return hostname == normalized_allowed or hostname.endswith(f".{normalized_allowed}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _apply_style_to_prompt(prompt: str, style: str) -> str:
|
|
|
|
|
|
cleaned_prompt = (prompt or "").strip()
|
|
|
|
|
|
cleaned_style = (style or "").strip()
|
|
|
|
|
|
if not cleaned_style:
|
|
|
|
|
|
return cleaned_prompt
|
|
|
|
|
|
if cleaned_style.lower() in cleaned_prompt.lower():
|
|
|
|
|
|
return cleaned_prompt
|
|
|
|
|
|
if not cleaned_prompt:
|
|
|
|
|
|
return cleaned_style
|
|
|
|
|
|
return f"{cleaned_style}, {cleaned_prompt}"
|