Files

574 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""图像生成服务 — 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,
)