++
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
"""Agnes Image 2.1 Flash 文生图 / 图生图 / 多图合成的核心逻辑。"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
API_BASE_URL = os.getenv("AGNES_API_BASE_URL", "https://apihub.agnes-ai.com/v1")
|
||||
OUTPUT_DIR = Path("output")
|
||||
MAX_RETRIES = 3
|
||||
RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}
|
||||
DEFAULT_MODEL = "agnes-image-2.1-flash"
|
||||
VALID_RATIOS = {"1:1", "3:4", "4:3", "16:9", "9:16", "2:3", "3:2", "21:9"}
|
||||
RATIO_DIMENSIONS = {
|
||||
"1:1": {"1K": "1024x1024", "2K": "2048x2048", "3K": "3072x3072", "4K": "4096x4096"},
|
||||
"3:4": {"1K": "864x1152", "2K": "1728x2304", "3K": "2592x3456", "4K": "3456x4608"},
|
||||
"4:3": {"1K": "1152x864", "2K": "2304x1728", "3K": "3456x2592", "4K": "4608x3456"},
|
||||
"16:9": {"1K": "1312x736", "2K": "2624x1472", "3K": "3936x2208", "4K": "5248x2944"},
|
||||
"9:16": {"1K": "736x1312", "2K": "1472x2624", "3K": "2208x3936", "4K": "2944x5248"},
|
||||
"2:3": {"1K": "832x1248", "2K": "1664x2496", "3K": "2496x3744", "4K": "3328x4992"},
|
||||
"3:2": {"1K": "1248x832", "2K": "2496x1664", "3K": "3744x2496", "4K": "4992x3328"},
|
||||
"21:9": {"1K": "1568x672", "2K": "3136x1344", "3K": "4704x2016", "4K": "6272x2688"},
|
||||
}
|
||||
|
||||
|
||||
def _resolve_size(size: str, ratio: str | None) -> str:
|
||||
"""把 size 档位 (1K/2K/3K/4K) 配合 ratio 解析成实际像素尺寸,方便保存文件。"""
|
||||
if ratio and ratio not in VALID_RATIOS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的宽高比: {ratio}")
|
||||
if size in {"1K", "2K", "3K", "4K"}:
|
||||
if not ratio:
|
||||
ratio = "1:1"
|
||||
return RATIO_DIMENSIONS[ratio][size]
|
||||
return size
|
||||
|
||||
|
||||
def _build_payload(*, prompt: str, size: str, ratio: str | None, images: list[str] | None) -> dict:
|
||||
"""组装请求体。注意 response_format 必须放在 extra_body 内。"""
|
||||
payload: dict = {
|
||||
"model": DEFAULT_MODEL,
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
}
|
||||
extra_body: dict = {}
|
||||
if ratio:
|
||||
payload["ratio"] = ratio
|
||||
if images:
|
||||
extra_body["image"] = images
|
||||
if extra_body:
|
||||
payload["extra_body"] = extra_body
|
||||
return payload
|
||||
|
||||
|
||||
def _post_with_retry(url: str, headers: dict, payload: dict) -> dict:
|
||||
"""调用 Agnes Image 接口,并对可恢复的上游错误进行指数退避重试。"""
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
for attempt in range(1, MAX_RETRIES + 1):
|
||||
request = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=360) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code not in RETRYABLE_STATUS_CODES or attempt == MAX_RETRIES:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
raise HTTPException(status_code=502, detail=f"Agnes 图片服务错误: HTTP {e.code} {body}") from e
|
||||
except (urllib.error.URLError, TimeoutError) as e:
|
||||
if attempt == MAX_RETRIES:
|
||||
raise HTTPException(status_code=504, detail="Agnes 图片服务连接或请求超时") from e
|
||||
time.sleep(2 ** (attempt - 1))
|
||||
|
||||
|
||||
def _download_image(url: str) -> bytes:
|
||||
"""从 URL 或 data URI 下载图片。"""
|
||||
if url.startswith("data:image/"):
|
||||
try:
|
||||
_, encoded = url.split(",", 1)
|
||||
return base64.b64decode(encoded, validate=True)
|
||||
except (ValueError, TypeError) as e:
|
||||
raise HTTPException(status_code=502, detail="图片 data URL 格式无效") from e
|
||||
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
for attempt in range(1, MAX_RETRIES + 1):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
return response.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code not in RETRYABLE_STATUS_CODES or attempt == MAX_RETRIES:
|
||||
raise HTTPException(status_code=502, detail=f"图片 URL 下载失败: HTTP {e.code}") from e
|
||||
except (urllib.error.URLError, TimeoutError) as e:
|
||||
if attempt == MAX_RETRIES:
|
||||
raise HTTPException(status_code=504, detail="图片 URL 下载超时或网络连接失败") from e
|
||||
time.sleep(2 ** (attempt - 1))
|
||||
|
||||
|
||||
def generate_images(
|
||||
*,
|
||||
prompt: str,
|
||||
size: str,
|
||||
ratio: str | None,
|
||||
images: list[str] | None,
|
||||
profile_name: str,
|
||||
):
|
||||
"""生成图片并保存到本地,返回结果字典。支持文生图 / 图生图 / 多图合成。"""
|
||||
api_key = os.getenv("AGNES_API_KEY")
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=500, detail="未配置环境变量 AGNES_API_KEY")
|
||||
|
||||
resolved_size = _resolve_size(size, ratio)
|
||||
payload = _build_payload(prompt=prompt, size=size, ratio=ratio, images=images)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
response = _post_with_retry(f"{API_BASE_URL}/images/generations", headers, payload)
|
||||
|
||||
profile_dir = OUTPUT_DIR / profile_name
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
saved = []
|
||||
for item in response.get("data") or []:
|
||||
try:
|
||||
if item.get("b64_json"):
|
||||
raw = base64.b64decode(item["b64_json"], validate=True)
|
||||
elif item.get("url"):
|
||||
raw = _download_image(item["url"])
|
||||
else:
|
||||
raise HTTPException(status_code=502, detail="Agnes 图片服务未返回 b64_json 或 url")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=502, detail="Agnes Base64 图片数据格式无效") from e
|
||||
|
||||
filename = f"{DEFAULT_MODEL}-{uuid4().hex}.png"
|
||||
(profile_dir / filename).write_bytes(raw)
|
||||
saved.append({"filename": filename, "url": f"/output/{profile_name}/{filename}", "bytes": len(raw)})
|
||||
|
||||
return {"model": DEFAULT_MODEL, "size": resolved_size, "ratio": ratio or "1:1", "images": saved}
|
||||
@@ -0,0 +1,98 @@
|
||||
"""GPT Image 和 Grok Image 文生图的核心逻辑。"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from openai import APIConnectionError, APIStatusError, APITimeoutError, OpenAI
|
||||
|
||||
API_BASE_URL = os.getenv("IMAGE_API_BASE_URL", "https://api.slomerex.xyz/v1")
|
||||
OUTPUT_DIR = Path("output")
|
||||
MAX_RETRIES = 3
|
||||
RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}
|
||||
|
||||
PROVIDERS = {
|
||||
"grok": {
|
||||
"api_key_env": "GROK_API_KEY",
|
||||
"model": "grok-imagine-image-lite",
|
||||
},
|
||||
"gpt": {
|
||||
"api_key_env": "GPT_API_KEY",
|
||||
"model": "gpt-image-2-1K",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _generate_with_retry(client: OpenAI, *, model: str, prompt: str, size: str, n: int):
|
||||
"""调用图片接口,并对可恢复的上游错误进行指数退避重试。"""
|
||||
for attempt in range(1, MAX_RETRIES + 1):
|
||||
try:
|
||||
return client.images.generate(model=model, prompt=prompt, size=size, n=n)
|
||||
except APIStatusError as e:
|
||||
if e.status_code not in RETRYABLE_STATUS_CODES or attempt == MAX_RETRIES:
|
||||
raise HTTPException(status_code=502, detail=f"上游图片服务错误: HTTP {e.status_code}") from e
|
||||
time.sleep(2 ** (attempt - 1))
|
||||
except (APIConnectionError, APITimeoutError) as e:
|
||||
if attempt == MAX_RETRIES:
|
||||
raise HTTPException(status_code=504, detail="上游图片服务连接或请求超时") from e
|
||||
time.sleep(2 ** (attempt - 1))
|
||||
|
||||
|
||||
def _download_image(url: str) -> bytes:
|
||||
"""从 URL 或 data URI 下载图片。"""
|
||||
if url.startswith("data:image/"):
|
||||
try:
|
||||
_, encoded = url.split(",", 1)
|
||||
return base64.b64decode(encoded, validate=True)
|
||||
except (ValueError, TypeError) as e:
|
||||
raise HTTPException(status_code=502, detail="图片 data URL 格式无效") from e
|
||||
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
for attempt in range(1, MAX_RETRIES + 1):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
return response.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code not in RETRYABLE_STATUS_CODES or attempt == MAX_RETRIES:
|
||||
raise HTTPException(status_code=502, detail=f"图片 URL 下载失败: HTTP {e.code}") from e
|
||||
except (urllib.error.URLError, TimeoutError) as e:
|
||||
if attempt == MAX_RETRIES:
|
||||
raise HTTPException(status_code=504, detail="图片 URL 下载超时或网络连接失败") from e
|
||||
time.sleep(2 ** (attempt - 1))
|
||||
|
||||
|
||||
def generate_images(*, provider: str, model: str | None, prompt: str, size: str, n: int, profile_name: str):
|
||||
"""生成图片并保存到本地,返回结果字典。"""
|
||||
config = PROVIDERS[provider]
|
||||
api_key = os.getenv(config["api_key_env"])
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=500, detail=f"未配置环境变量 {config['api_key_env']}")
|
||||
|
||||
resolved_model = model or config["model"]
|
||||
profile_dir = OUTPUT_DIR / profile_name
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
client = OpenAI(base_url=API_BASE_URL, api_key=api_key, timeout=120.0, max_retries=0)
|
||||
response = _generate_with_retry(client, model=resolved_model, prompt=prompt, size=size, n=n)
|
||||
|
||||
images = []
|
||||
for item in response.data:
|
||||
try:
|
||||
if item.b64_json:
|
||||
raw = base64.b64decode(item.b64_json, validate=True)
|
||||
elif item.url:
|
||||
raw = _download_image(item.url)
|
||||
else:
|
||||
raise HTTPException(status_code=502, detail="上游图片服务未返回 b64_json 或 url")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=502, detail="上游 Base64 图片数据格式无效") from e
|
||||
|
||||
filename = f"{resolved_model}-{uuid4().hex}.png"
|
||||
(profile_dir / filename).write_bytes(raw)
|
||||
images.append({"filename": filename, "url": f"/output/{profile_name}/{filename}", "bytes": len(raw)})
|
||||
|
||||
return {"provider": provider, "model": resolved_model, "images": images}
|
||||
Reference in New Issue
Block a user