feat: 插件改为浮窗模式

This commit is contained in:
R524809
2026-08-19 17:05:23 +08:00
parent 06b220ae8d
commit 82cb694837
11 changed files with 381 additions and 22 deletions
+90 -11
View File
@@ -10,6 +10,7 @@ import asyncio
import base64
import logging
import mimetypes
import re
from uuid import UUID
import httpx
@@ -19,21 +20,38 @@ from config import get_settings
from db import get_session_factory
from models import Product, ProductAsset, Suite, SuiteImage, SUITE_RUNNING, SUITE_DONE, SUITE_PARTIAL, SUITE_FAILED, STATUS_OK, STATUS_FAILED
from services import storage
from services.prompt import build_prompt, build_context, type_name
from services.prompt import build_prompt, build_context, type_name, wrap_prompt_for_gpt_edits
log = logging.getLogger("suite.generator")
class ApiError(RuntimeError):
"""带 HTTP 状态码的 API 错误(用于区分可重试的网关/限流错误)。"""
def __init__(self, message: str, status: int = 0):
super().__init__(message)
self.status = status
_HTML_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
def _raise_api_error(resp, provider: str):
"""HTTP 错误时抛出带 API 错误码/信息的异常(响应体里有真正的失败原因)。"""
if resp.is_success:
return
text = resp.text or ""
if "<html" in text[:300].lower() or text.lstrip()[:15].lower().startswith("<!doctype"):
# Cloudflare/网关错误页:取 <title> 作摘要,避免整段 HTML 进错误信息
m = _HTML_TITLE_RE.search(text)
detail = (re.sub(r"\s+", " ", m.group(1)).strip() if m else "") or "网关返回 HTML 错误页(上游/CDN 故障)"
raise ApiError(f"{provider} API HTTP {resp.status_code}{detail}", resp.status_code)
try:
body = resp.json()
detail = f"{body.get('code', '')}: {body.get('message', '')}".strip(': ')
except Exception: # noqa: BLE001
detail = resp.text[:200]
raise RuntimeError(f"{provider} API HTTP {resp.status_code}{detail or '无错误详情'}")
detail = text[:200]
raise ApiError(f"{provider} API HTTP {resp.status_code}{detail or '无错误详情'}", resp.status_code)
# 参考图选择:material 用第 2 张(背面/细节),其余用第 1 张(正面)
TYPE_REF_INDEX = {
@@ -42,7 +60,7 @@ TYPE_REF_INDEX = {
DEFAULT_REF_COUNT = 2 # 每次生图最多带的参考图数(正面 1 张 + 背面/细节 1 张)
def _image_size(provider: str, ratio: str, is_wan: bool = True) -> str:
def _image_size(provider: str, ratio: str, is_wan: bool = True, model: str = "") -> str:
"""平台比例 → provider 尺寸参数。3:4 竖版(Ozon/WB),1:1 方图(国内)。"""
if provider == "doubao":
return "1536x2048" if ratio == "3:4" else "2048x2048"
@@ -50,6 +68,9 @@ def _image_size(provider: str, ratio: str, is_wan: bool = True) -> str:
# gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法)
return "1536x2048" if ratio == "3:4" else "2048x2048"
# tongyi:万象与千问的 size 语法相同(* 分隔),档位不同
# wan2.6 系列总像素限制在 [1280², 1440²]wan2.7 的 1536*2048/2048*2048 会超限
if model.startswith("wan2.6"):
return "1152*1536" if ratio == "3:4" else "1440*1440"
if ratio == "3:4":
return "1536*2048" if is_wan else "768*1024"
return "2048*2048" if is_wan else "1024*1024"
@@ -136,6 +157,11 @@ def _is_wan_model(model: str) -> bool:
return model.lower().startswith("wan")
def _is_t2i_model(model: str) -> bool:
"""纯文生图模型(如 wan2.6-t2i):不接受参考图,商品一致性只能靠文案描述。"""
return "t2i" in model.lower()
async def _tongyi_poll_task(client: httpx.AsyncClient, key: str, task_id: str, max_wait: int) -> str:
poll_url = "https://dashscope.aliyuncs.com/api/v1/tasks/" + task_id
elapsed, interval = 0, 3
@@ -174,7 +200,10 @@ async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048*
else "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
)
content: list[dict] = [{"image": await _resolve_ref(u)} for u in ref_images]
# t2i 模型不接受参考图:content 只有文本,商品一致性依赖 prompt 里的标题/卖点描述
content: list[dict] = []
if not _is_t2i_model(model):
content = [{"image": await _resolve_ref(u)} for u in ref_images]
content.append({"text": prompt})
params = {"size": size, "n": 1, "watermark": False}
@@ -210,18 +239,25 @@ async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048*
# ── ProviderRightAPIgpt-imageOpenAI 兼容中转)──────────────────────
async def generate_rightapi(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes:
# 可重试的状态码:中转限流/网关抖动(该中转限流时返回 Cloudflare 502 而非 429
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
# 中转对 input_fidelity 参数的支持探测:None=未探测,True=支持,False=不支持(已降级)
_rightapi_fidelity_supported: bool | None = None
async def _rightapi_request(s, prompt: str, ref_images: list[str], size: str, model: str) -> bytes:
"""gpt-image 系列:有参考图走 /v1/images/editsmultipart),无参考图走 /v1/images/generations。
OpenAI 兼容协议:响应固定 b64_json(不支持 response_format 参数,传了报 400);
同步调用无任务轮询,高质量档单张 1-5 分钟,超时按文档建议兜底 600s。
input_fidelity=high 强制高保真保留输入图细节(商品一致性关键参数,仅 edits 端点);
中转若不认该参数(400),自动去掉重试并记住,后续请求不再带。
"""
s = get_settings()
if not s.rightapi_api_key:
raise RuntimeError("未配置 RIGHTAPI_API_KEY.env")
model = model or s.rightapi_image_model
global _rightapi_fidelity_supported
base = s.rightapi_base_url.rstrip("/")
headers = {"Authorization": f"Bearer {s.rightapi_api_key}"}
use_fidelity = bool(ref_images) and s.rightapi_input_fidelity and _rightapi_fidelity_supported is not False
async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client:
common = {
@@ -232,12 +268,22 @@ async def generate_rightapi(prompt: str, ref_images: list[str], size: str = "204
"output_format": "jpeg", # 与落盘 .jpg 后缀一致
"n": 1,
}
if use_fidelity:
common["input_fidelity"] = s.rightapi_input_fidelity
if ref_images:
files = []
for i, u in enumerate(ref_images):
data, mime = await _resolve_ref_bytes(u)
files.append(("image[]", (f"ref-{i + 1}.{mime.split('/')[-1]}", data, mime)))
resp = await client.post(f"{base}/v1/images/edits", headers=headers, files=files, data=common)
# 中转不认 input_fidelity:去掉参数重试一次(仅一次探测)
if resp.status_code == 400 and use_fidelity and "input_fidelity" in resp.text:
_rightapi_fidelity_supported = False
log.warning("RightAPI 不支持 input_fidelity 参数,已自动去掉并降级(后续请求不再带)")
common.pop("input_fidelity", None)
resp = await client.post(f"{base}/v1/images/edits", headers=headers, files=files, data=common)
elif resp.is_success and use_fidelity:
_rightapi_fidelity_supported = True
else:
resp = await client.post(
f"{base}/v1/images/generations",
@@ -257,6 +303,36 @@ async def generate_rightapi(prompt: str, ref_images: list[str], size: str = "204
return dl.content
async def generate_rightapi(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes:
"""带重试的 RightAPI 入口:429/5xx/超时按递增间隔重试。
实测该中转对同 key 连续请求有分钟级冷却(成功一张后紧接着的请求会被网关秒拒 502),
60s → 120s → 240s 的退避基本能等到窗口放开。
"""
s = get_settings()
if not s.rightapi_api_key:
raise RuntimeError("未配置 RIGHTAPI_API_KEY.env")
model = model or s.rightapi_image_model
attempts = max(1, s.rightapi_max_retries)
last_exc: Exception | None = None
for i in range(attempts):
try:
return await _rightapi_request(s, prompt, ref_images, size, model)
except ApiError as exc:
last_exc = exc
if exc.status not in RETRYABLE_STATUS:
raise # 参数错误等不可重试,立即失败
except (httpx.TimeoutException, httpx.TransportError) as exc:
last_exc = exc # 网络抖动/超时可重试
if i == attempts - 1:
break
wait = s.rightapi_retry_wait * (2 ** i)
log.warning("RightAPI 第 %d/%d 次请求失败(%s),%ds 后重试", i + 1, attempts, last_exc, wait)
await asyncio.sleep(wait)
raise last_exc # type: ignore[misc]
GENERATORS = {"doubao": generate_doubao, "tongyi": generate_tongyi, "rightapi": generate_rightapi}
@@ -344,7 +420,7 @@ async def run_suite(suite_id: str) -> None:
"rightapi": settings.rightapi_image_model,
}.get(provider_name, settings.ark_image_model)
is_wan = provider_name == "tongyi" and _is_wan_model(model)
size = _image_size(provider_name, suite.ratio, is_wan=is_wan)
size = _image_size(provider_name, suite.ratio, is_wan=is_wan, model=model)
# 任务列表:方案(逐张)优先,旧路径按 types
if suite.plan:
@@ -372,6 +448,9 @@ async def run_suite(suite_id: str) -> None:
type_id, ctx, suite.style_set, suite.lang,
extra=job, style_prompt=suite.style_prompt, requirements=suite.requirements,
)
# gpt-image edits 语义:商品冻结契约前置,防止风格词改商品
if provider_name == "rightapi":
prompt = wrap_prompt_for_gpt_edits(prompt)
if product:
refs = await _select_ref_images(db, product.id, type_id)
else:
+33
View File
@@ -116,6 +116,39 @@ DEFAULT_NEGATIVE_INTENT = (
"no distorted text, no deformed product, no extra limbs, no blurry areas"
)
# ── gpt-image/v1/images/edits 语义)专用包装 ─────────────────────────────
# gpt-image 的 edits 端点把输入图当"被编辑的底图"、prompt 当"编辑指令"(豆包/通义则是
# "主体参考"),风格词会被字面执行到商品上。按 OpenAI 官方提示词指南的编辑模式:
# 按序号说明输入图、PRESERVE/MAY CHANGE 分列、首尾重申不变量、文案逐字渲染。
GPT_EDITS_CONTRACT = (
"INPUT IMAGES: Image 1 (and Image 2 if present) are reference photos of ONE product "
"from different angles. Use them ONLY as the source of the product's true appearance.\n"
"PRESERVE (frozen, never change): the product itself — silhouette, proportions, colors, "
"print/pattern (keep stripes / logos / labels exactly), materials, texture, stitching, "
"hardware and every design detail. The product in the output must be the same physical "
"item as in the input images, merely photographed in a new setting.\n"
"MAY CHANGE: background, scene, props, camera angle, lighting, composition "
"and in-image marketing typography.\n"
"STYLE SCOPE: all style, mood, color-palette and decoration instructions below describe "
"the SCENE AND BACKGROUND ONLY — never apply them to the product itself. "
"Do not restyle, recolor, re-pattern or redecorate the product. "
"You may relight the product so it sits naturally in the new scene "
"(matched shadows and color temperature), but never change its design, colors or pattern."
)
GPT_EDITS_FINAL_CHECK = (
"FINAL CHECK before output: if the product in your result differs from the input product in any "
"design detail (shape, color, pattern, material, logo), the image is rejected. "
"Render any listed marketing copy / headlines exactly as written (verbatim, no extra characters, "
"no paraphrasing)."
)
def wrap_prompt_for_gpt_edits(prompt: str) -> str:
"""gpt-image edits 语义适配:契约放开头(指令权重最高处),终检放结尾。"""
return f"{GPT_EDITS_CONTRACT}\n\n{prompt}\n\n{GPT_EDITS_FINAL_CHECK}"
# ── 商品上下文提炼 ────────────────────────────────────────────────────────
def _shorten(text: str, n: int) -> str: