574 lines
21 KiB
Python
574 lines
21 KiB
Python
"""图像生成服务 — Provider 抽象层。"""
|
||
|
||
import asyncio
|
||
import base64
|
||
import mimetypes
|
||
import os
|
||
import uuid
|
||
from abc import ABC, abstractmethod
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import httpx
|
||
from openai import AsyncOpenAI
|
||
from replicate import Client as ReplicateClient
|
||
|
||
from app.config import get_image_aspect_ratio, get_image_model_config, get_image_output_format
|
||
from app.services.image_prompt_strategy import prepare_image_prompt_for_model
|
||
|
||
BACKEND_ROOT = Path(__file__).parent.parent.parent
|
||
GENERATED_DIR = BACKEND_ROOT / "generated"
|
||
|
||
|
||
def _make_replicate_client() -> ReplicateClient:
|
||
"""
|
||
创建 Replicate 客户端。
|
||
Replicate SDK 内部创建 httpx transport 时不读取代理环境变量,
|
||
需要我们手动把代理配置注入到 transport 中。
|
||
"""
|
||
timeout = httpx.Timeout(5.0, read=300.0, write=30.0, connect=30.0, pool=10.0)
|
||
proxy_url = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
|
||
|
||
transport_kwargs: dict[str, Any] = {}
|
||
if proxy_url:
|
||
transport_kwargs["proxy"] = proxy_url
|
||
|
||
our_transport = httpx.AsyncHTTPTransport(**transport_kwargs)
|
||
|
||
client = ReplicateClient(
|
||
timeout=timeout,
|
||
transport=our_transport,
|
||
)
|
||
|
||
return client
|
||
|
||
|
||
_replicate_client = _make_replicate_client()
|
||
|
||
|
||
# ─── 公共工具 ──────────────────────────────────────────
|
||
|
||
def to_data_uri(image_path: str) -> str:
|
||
"""将本地路径或已有 URL 转为 Replicate 可接受的格式(data URI 或原始 URL)。"""
|
||
if image_path.startswith("data:") or image_path.startswith("http"):
|
||
return image_path
|
||
local = BACKEND_ROOT / image_path.lstrip("/")
|
||
if local.exists():
|
||
mime = mimetypes.guess_type(str(local))[0] or "image/png"
|
||
b64 = base64.b64encode(local.read_bytes()).decode()
|
||
return f"data:{mime};base64,{b64}"
|
||
return image_path
|
||
|
||
|
||
def _load_image_bytes(image_path: str) -> bytes:
|
||
"""将本地路径或 data URI 转为原始字节,供 OpenAI images.edit 使用。"""
|
||
if image_path.startswith("data:"):
|
||
# data:image/png;base64,xxxx
|
||
_, b64_part = image_path.split(",", 1)
|
||
return base64.b64decode(b64_part)
|
||
if image_path.startswith("http"):
|
||
raise ValueError("_load_image_bytes 不支持远程 URL,请先下载到本地")
|
||
local = BACKEND_ROOT / image_path.lstrip("/")
|
||
if local.exists():
|
||
return local.read_bytes()
|
||
raise FileNotFoundError(f"参考图文件未找到: {local}")
|
||
|
||
|
||
def _resolve_image_base64(image_path: str) -> tuple[str, str]:
|
||
"""将图片路径/data URI 解析为 (mime_type, base64_string)。
|
||
|
||
统一处理三种输入形式:
|
||
- data URI (data:image/png;base64,xxxx) → 直接提取 mime 和 base64
|
||
- 本地路径 (/uploads/xxx.png) → 读取文件并编码
|
||
- 其他 → 尝试作为本地路径处理
|
||
"""
|
||
if image_path.startswith("data:"):
|
||
header, b64_part = image_path.split(",", 1)
|
||
# header 格式: data:image/png;base64
|
||
mime = header.split(";")[0].replace("data:", "")
|
||
return mime, b64_part
|
||
|
||
local = BACKEND_ROOT / image_path.lstrip("/")
|
||
if local.exists():
|
||
mime = mimetypes.guess_type(str(local))[0] or "image/png"
|
||
b64 = base64.b64encode(local.read_bytes()).decode()
|
||
return mime, b64
|
||
|
||
raise FileNotFoundError(f"参考图文件未找到: {local}")
|
||
|
||
|
||
async def _download_image(url: str) -> str:
|
||
"""下载远程图片到本地 generated/ 目录,返回本地 URL 路径。"""
|
||
filename = f"{uuid.uuid4().hex}.png"
|
||
filepath = GENERATED_DIR / filename
|
||
proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
|
||
async with httpx.AsyncClient(proxy=proxy, timeout=httpx.Timeout(60.0)) as client:
|
||
resp = await client.get(url, follow_redirects=True)
|
||
resp.raise_for_status()
|
||
filepath.write_bytes(resp.content)
|
||
return f"/generated/{filename}"
|
||
|
||
|
||
# ─── Provider 抽象基类 ─────────────────────────────────
|
||
|
||
class ImageProvider(ABC):
|
||
"""所有图像生成 provider 的基类。"""
|
||
|
||
@abstractmethod
|
||
async def generate(
|
||
self,
|
||
model_config: dict[str, Any],
|
||
prompt: str,
|
||
num_images: int = 1,
|
||
ref_image_urls: list[str] | None = None,
|
||
negative_prompt: str | None = None,
|
||
) -> list[str]:
|
||
"""生成图片并返回本地 URL 列表。negative_prompt 仅部分后端使用(如 Replicate SDXL)。"""
|
||
...
|
||
|
||
|
||
class ReplicateProvider(ImageProvider):
|
||
"""通过 Replicate API 调用模型。"""
|
||
|
||
async def generate(
|
||
self,
|
||
model_config: dict[str, Any],
|
||
prompt: str,
|
||
num_images: int = 1,
|
||
ref_image_urls: list[str] | None = None,
|
||
negative_prompt: str | None = None,
|
||
) -> list[str]:
|
||
replicate_model_id = model_config["model_id"]
|
||
default_params: dict[str, Any] = model_config.get("default_params", {})
|
||
|
||
local_urls: list[str] = []
|
||
|
||
try:
|
||
# Replicate 模型(InstantStyle/Kolors)只支持单张参考图,取第一张
|
||
single_ref = ref_image_urls[0] if ref_image_urls else None
|
||
input_params = self._build_input(
|
||
model_config,
|
||
default_params,
|
||
prompt,
|
||
num_images,
|
||
single_ref,
|
||
negative_prompt=negative_prompt,
|
||
)
|
||
|
||
output = await _replicate_client.async_run(
|
||
replicate_model_id, input=input_params, wait=False
|
||
)
|
||
|
||
items = output if isinstance(output, list) else [output]
|
||
for item in items:
|
||
url = str(item)
|
||
if url.startswith("https://") or url.startswith("http://") or url.startswith("data:"):
|
||
local_urls.append(await _download_image(url))
|
||
else:
|
||
local_urls.append(f"[生成失败: 模型返回非图片内容: {url[:200]}]")
|
||
|
||
except Exception as e:
|
||
detail = str(e) or f"{type(e).__name__}: {repr(e)}"
|
||
local_urls.append(f"[生成失败: {detail}]")
|
||
|
||
return local_urls
|
||
|
||
@staticmethod
|
||
def _build_input(
|
||
model_config: dict[str, Any],
|
||
default_params: dict[str, Any],
|
||
prompt: str,
|
||
num_images: int = 1,
|
||
ref_image_url: str | None = None,
|
||
negative_prompt: str | None = None,
|
||
) -> dict[str, Any]:
|
||
"""
|
||
根据模型配置构建 Replicate input 参数。
|
||
|
||
Replicate 上的 IP-Adapter 模型只支持单张参考图。
|
||
"""
|
||
model_id = model_config["model_id"]
|
||
supports_ref = model_config.get("supports_ref_image", False)
|
||
ref_param_name = model_config.get("ref_image_param", "image")
|
||
num_images_param = model_config.get("num_images_param", "num_outputs")
|
||
|
||
if supports_ref:
|
||
params: dict[str, Any] = {"prompt": prompt}
|
||
for k, v in default_params.items():
|
||
params[k] = v
|
||
params[num_images_param] = num_images
|
||
if ref_image_url:
|
||
params[ref_param_name] = to_data_uri(ref_image_url)
|
||
return params
|
||
|
||
is_flux = "flux" in model_id.lower()
|
||
params = {"prompt": prompt, num_images_param: num_images}
|
||
|
||
if is_flux:
|
||
params["aspect_ratio"] = default_params.get(
|
||
"aspect_ratio", get_image_aspect_ratio()
|
||
)
|
||
params["output_format"] = default_params.get(
|
||
"output_format", get_image_output_format()
|
||
)
|
||
else:
|
||
params["width"] = default_params.get("width", 1024)
|
||
params["height"] = default_params.get("height", 1024)
|
||
if "num_inference_steps" in default_params:
|
||
params["num_inference_steps"] = default_params["num_inference_steps"]
|
||
if "guidance_scale" in default_params:
|
||
params["guidance_scale"] = default_params["guidance_scale"]
|
||
if negative_prompt is not None:
|
||
params["negative_prompt"] = negative_prompt
|
||
|
||
return params
|
||
|
||
|
||
class OpenAIImageProvider(ImageProvider):
|
||
"""通过 OpenAI 兼容 API(向量引擎中转)调用 GPT Image 系列模型。
|
||
|
||
有参考图时使用 images.edit(支持最多 16 张参考图),
|
||
无参考图时使用 images.generate(纯文生图)。
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
self._client: AsyncOpenAI | None = None
|
||
|
||
def _get_client(self) -> AsyncOpenAI:
|
||
if self._client is None:
|
||
self._client = AsyncOpenAI(
|
||
api_key=os.getenv("VECTORENGINE_API_KEY"),
|
||
base_url=os.getenv("VECTORENGINE_BASE_URL", "https://api.vectorengine.ai/v1"),
|
||
timeout=httpx.Timeout(5.0, read=180.0, write=60.0, connect=30.0),
|
||
)
|
||
return self._client
|
||
|
||
async def generate(
|
||
self,
|
||
model_config: dict[str, Any],
|
||
prompt: str,
|
||
num_images: int = 1,
|
||
ref_image_urls: list[str] | None = None,
|
||
negative_prompt: str | None = None,
|
||
) -> list[str]:
|
||
_ = negative_prompt
|
||
client = self._get_client()
|
||
default_params = model_config.get("default_params", {})
|
||
model_id = model_config["model_id"]
|
||
|
||
local_urls: list[str] = []
|
||
try:
|
||
if ref_image_urls:
|
||
resp = await self._edit_with_refs(
|
||
client, model_id, prompt, ref_image_urls, num_images, default_params
|
||
)
|
||
else:
|
||
resp = await client.images.generate(
|
||
model=model_id,
|
||
prompt=prompt,
|
||
n=num_images,
|
||
size=default_params.get("size", "1024x1024"),
|
||
quality=default_params.get("quality", "high"),
|
||
)
|
||
|
||
for img_data in resp.data:
|
||
if img_data.url:
|
||
local_urls.append(await _download_image(img_data.url))
|
||
elif img_data.b64_json:
|
||
filename = f"{uuid.uuid4().hex}.png"
|
||
filepath = GENERATED_DIR / filename
|
||
filepath.write_bytes(base64.b64decode(img_data.b64_json))
|
||
local_urls.append(f"/generated/{filename}")
|
||
else:
|
||
local_urls.append("[生成失败: 模型未返回图片数据]")
|
||
|
||
except Exception as e:
|
||
detail = str(e) or f"{type(e).__name__}: {repr(e)}"
|
||
local_urls.append(f"[生成失败: {detail}]")
|
||
|
||
return local_urls
|
||
|
||
@staticmethod
|
||
async def _edit_with_refs(
|
||
client: AsyncOpenAI,
|
||
model_id: str,
|
||
prompt: str,
|
||
ref_image_urls: list[str],
|
||
num_images: int,
|
||
default_params: dict[str, Any],
|
||
):
|
||
"""使用 images.edit 端点传入参考图(GPT Image 系列最多 16 张)。"""
|
||
image_files: list[Any] = []
|
||
for url in ref_image_urls:
|
||
image_files.append(_load_image_bytes(url))
|
||
|
||
image_arg: Any = image_files[0] if len(image_files) == 1 else image_files
|
||
|
||
return await client.images.edit(
|
||
model=model_id,
|
||
image=image_arg,
|
||
prompt=prompt,
|
||
n=num_images,
|
||
size=default_params.get("size", "1024x1024"),
|
||
)
|
||
|
||
|
||
class GeminiNativeImageProvider(ImageProvider):
|
||
"""通过向量引擎中转调用 Gemini 原生 generateContent 接口。
|
||
|
||
Gemini 原生接口支持文字 + 图片混合输入(最多 14 张参考图),
|
||
在一次 generateContent 调用中同时理解参考图并生成新图片。
|
||
这是 OpenAI 兼容的 images/generate 和 images/edit 都无法覆盖的能力。
|
||
|
||
API 格式:
|
||
POST /v1beta/models/{model}:generateContent?key={API_KEY}
|
||
Body: { contents: [{ parts: [...] }], generationConfig: { responseModalities: ["TEXT","IMAGE"] } }
|
||
Response: candidates[0].content.parts[] → text 或 inline_data (base64)
|
||
|
||
多图 + 生图耗时较长,上游或代理可能提前断开(httpx: Server disconnected without sending a response)。
|
||
使用较长超时 + 对可恢复网络错误自动重试。
|
||
"""
|
||
|
||
_RETRYABLE: tuple[type[BaseException], ...] = (
|
||
httpx.RemoteProtocolError,
|
||
httpx.ConnectError,
|
||
httpx.ReadTimeout,
|
||
httpx.WriteTimeout,
|
||
httpx.ConnectTimeout,
|
||
httpx.PoolTimeout,
|
||
)
|
||
|
||
@classmethod
|
||
def _gemini_timeout(cls) -> httpx.Timeout:
|
||
"""可通过环境变量调大,多参考图时请求体大、响应慢。"""
|
||
read_s = float(os.getenv("VECTORENGINE_GEMINI_READ_TIMEOUT", "600"))
|
||
write_s = float(os.getenv("VECTORENGINE_GEMINI_WRITE_TIMEOUT", "180"))
|
||
connect_s = float(os.getenv("VECTORENGINE_GEMINI_CONNECT_TIMEOUT", "60"))
|
||
pool_s = float(os.getenv("VECTORENGINE_GEMINI_POOL_TIMEOUT", "60"))
|
||
return httpx.Timeout(
|
||
connect=connect_s,
|
||
read=read_s,
|
||
write=write_s,
|
||
pool=pool_s,
|
||
)
|
||
|
||
async def generate(
|
||
self,
|
||
model_config: dict[str, Any],
|
||
prompt: str,
|
||
num_images: int = 1,
|
||
ref_image_urls: list[str] | None = None,
|
||
negative_prompt: str | None = None,
|
||
) -> list[str]:
|
||
_ = negative_prompt
|
||
api_key = os.getenv("VECTORENGINE_API_KEY", "")
|
||
base_url = os.getenv("VECTORENGINE_BASE_URL", "https://api.vectorengine.ai/v1")
|
||
# 从 /v1 回退到根 URL,拼接 /v1beta/models/... 端点
|
||
api_root = base_url.rstrip("/").removesuffix("/v1")
|
||
model_id = model_config["model_id"]
|
||
|
||
url = f"{api_root}/v1beta/models/{model_id}:generateContent?key={api_key}"
|
||
|
||
parts = self._build_parts(prompt, ref_image_urls)
|
||
body = {
|
||
"contents": [{"parts": parts}],
|
||
"generationConfig": {
|
||
"responseModalities": ["TEXT", "IMAGE"],
|
||
},
|
||
}
|
||
|
||
local_urls: list[str] = []
|
||
proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
|
||
max_retries = max(1, int(os.getenv("VECTORENGINE_GEMINI_MAX_RETRIES", "3")))
|
||
timeout = self._gemini_timeout()
|
||
|
||
try:
|
||
data: dict[str, Any] | None = None
|
||
for attempt in range(max_retries):
|
||
try:
|
||
async with httpx.AsyncClient(
|
||
proxy=proxy,
|
||
timeout=timeout,
|
||
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10),
|
||
) as client:
|
||
resp = await client.post(
|
||
url,
|
||
json=body,
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
break
|
||
except self._RETRYABLE as e:
|
||
if attempt < max_retries - 1:
|
||
await asyncio.sleep(2 ** attempt)
|
||
continue
|
||
raise
|
||
|
||
if data is None:
|
||
local_urls.append("[生成失败: 未收到上游响应]")
|
||
return local_urls
|
||
|
||
local_urls = self._extract_images(data)
|
||
|
||
if not local_urls:
|
||
# Gemini 可能只返回了文字(拒绝生图或纯文字回复)
|
||
text_parts = self._extract_text(data)
|
||
hint = text_parts[:200] if text_parts else "模型未返回图片"
|
||
local_urls.append(f"[生成失败: {hint}]")
|
||
|
||
except httpx.HTTPStatusError as e:
|
||
detail = e.response.text[:500] if e.response else str(e)
|
||
local_urls.append(f"[生成失败: HTTP {e.response.status_code} - {detail}]")
|
||
except self._RETRYABLE as e:
|
||
hint = (
|
||
"连接被上游或代理提前关闭,常见于多图参考或生图较慢。"
|
||
"可稍后重试,或在 .env 中增大 VECTORENGINE_GEMINI_READ_TIMEOUT / 检查代理稳定性。"
|
||
)
|
||
detail = str(e) or f"{type(e).__name__}: {repr(e)}"
|
||
local_urls.append(f"[生成失败: {detail}。{hint}]")
|
||
except Exception as e:
|
||
detail = str(e) or f"{type(e).__name__}: {repr(e)}"
|
||
local_urls.append(f"[生成失败: {detail}]")
|
||
|
||
return local_urls
|
||
|
||
@staticmethod
|
||
def _build_parts(
|
||
prompt: str, ref_image_urls: list[str] | None
|
||
) -> list[dict[str, Any]]:
|
||
"""构建 Gemini generateContent 的 parts 数组:文字 + 内联图片。"""
|
||
parts: list[dict[str, Any]] = [{"text": prompt}]
|
||
if ref_image_urls:
|
||
for url in ref_image_urls:
|
||
mime, b64 = _resolve_image_base64(url)
|
||
parts.append({
|
||
"inline_data": {
|
||
"mime_type": mime,
|
||
"data": b64,
|
||
}
|
||
})
|
||
return parts
|
||
|
||
@staticmethod
|
||
def _extract_images(response_data: dict) -> list[str]:
|
||
"""从 Gemini 响应中提取所有图片并保存到本地。"""
|
||
local_urls: list[str] = []
|
||
candidates = response_data.get("candidates", [])
|
||
for candidate in candidates:
|
||
parts = candidate.get("content", {}).get("parts", [])
|
||
for part in parts:
|
||
inline = part.get("inlineData") or part.get("inline_data")
|
||
if inline and inline.get("data"):
|
||
mime = inline.get("mimeType") or inline.get("mime_type", "image/png")
|
||
ext = ".png" if "png" in mime else ".jpg" if "jpeg" in mime or "jpg" in mime else ".webp" if "webp" in mime else ".png"
|
||
filename = f"{uuid.uuid4().hex}{ext}"
|
||
filepath = GENERATED_DIR / filename
|
||
filepath.write_bytes(base64.b64decode(inline["data"]))
|
||
local_urls.append(f"/generated/{filename}")
|
||
return local_urls
|
||
|
||
@staticmethod
|
||
def _extract_text(response_data: dict) -> str:
|
||
"""从 Gemini 响应中提取文字内容(用于调试或错误提示)。"""
|
||
texts: list[str] = []
|
||
candidates = response_data.get("candidates", [])
|
||
for candidate in candidates:
|
||
parts = candidate.get("content", {}).get("parts", [])
|
||
for part in parts:
|
||
if part.get("text"):
|
||
texts.append(part["text"])
|
||
return "\n".join(texts)
|
||
|
||
|
||
# ─── Provider 注册 ─────────────────────────────────────
|
||
|
||
_PROVIDERS: dict[str, ImageProvider] = {
|
||
"replicate": ReplicateProvider(),
|
||
"openai": OpenAIImageProvider(),
|
||
"gemini_native": GeminiNativeImageProvider(),
|
||
}
|
||
|
||
|
||
# ─── 公开 API ──────────────────────────────────────────
|
||
|
||
class GenerateResult:
|
||
"""图片生成结果,包含生成的 URL 列表和实际使用的模型信息。"""
|
||
|
||
def __init__(
|
||
self,
|
||
urls: list[str],
|
||
model_name: str,
|
||
model_id: str,
|
||
*,
|
||
effective_prompt: str | None = None,
|
||
negative_prompt: str | None = None,
|
||
):
|
||
self.urls = urls
|
||
self.model_name = model_name
|
||
self.model_id = model_id
|
||
self.effective_prompt = effective_prompt
|
||
self.negative_prompt = negative_prompt
|
||
|
||
|
||
async def generate_images(
|
||
prompt: str,
|
||
num_images: int = 1,
|
||
ref_image_urls: list[str] | None = None,
|
||
model_id: str | None = None,
|
||
) -> GenerateResult:
|
||
"""
|
||
统一入口:根据 model_id 查注册表,分发到对应 provider。
|
||
|
||
ref_image_urls: 参考图路径列表(可为 None 或空列表)。
|
||
model_id 为空时使用 .env 中配置的默认模型。
|
||
"""
|
||
config = get_image_model_config(model_id)
|
||
provider_name = config.get("provider", "replicate")
|
||
provider = _PROVIDERS.get(provider_name)
|
||
|
||
model_name = config.get("name", "未知模型")
|
||
resolved_id = config.get("id", model_id or "unknown")
|
||
|
||
if not provider:
|
||
return GenerateResult([f"[未知 provider: {provider_name}]"], model_name, resolved_id)
|
||
|
||
effective_refs = ref_image_urls or []
|
||
|
||
# Replicate IP-Adapter 系列模型必须有参考图才能工作
|
||
if config.get("supports_ref_image") and not effective_refs and provider_name == "replicate":
|
||
return GenerateResult(
|
||
[f"[生成失败: {model_name} 是风格迁移模型,需要上传参考图才能使用]"],
|
||
model_name,
|
||
resolved_id,
|
||
)
|
||
|
||
prepared = prepare_image_prompt_for_model(
|
||
config,
|
||
prompt,
|
||
has_reference_images=bool(effective_refs),
|
||
)
|
||
if not prepared.prompt.strip():
|
||
return GenerateResult(
|
||
[f"[生成失败: 经模型策略处理后的 prompt 为空]"],
|
||
model_name,
|
||
resolved_id,
|
||
effective_prompt=prepared.prompt,
|
||
negative_prompt=prepared.negative_prompt,
|
||
)
|
||
|
||
urls = await provider.generate(
|
||
config,
|
||
prepared.prompt,
|
||
num_images,
|
||
effective_refs or None,
|
||
negative_prompt=prepared.negative_prompt,
|
||
)
|
||
return GenerateResult(
|
||
urls,
|
||
model_name,
|
||
resolved_id,
|
||
effective_prompt=prepared.prompt,
|
||
negative_prompt=prepared.negative_prompt,
|
||
)
|