"""套图生成服务:图像 provider(豆包 Seedream / 通义万相)+ 任务执行器。 Provider 调用方式移植自 ecommerce-image-suite/scripts/generate.py: - doubao:火山方舟 images/generations,同步返回 URL;参考图走 image 字段(data URI) - tongyi:wan* 万象模型走异步任务轮询;qwen* 走同步 multimodal-generation """ from __future__ import annotations import asyncio import base64 import logging import mimetypes import re import httpx from config import get_settings from services import storage from services.prompts import build_prompt, build_context, type_name from services.tasks import Task, TaskImage, TASK_FAILED, TASK_RUNNING, TASK_DONE, TASK_PARTIAL, IMG_FAILED, IMG_OK from services.watermark import apply_watermark 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"]*>(.*?)", re.IGNORECASE | re.DOTALL) def _raise_api_error(resp, provider: str): """HTTP 错误时抛出带 API 错误码/信息的异常(响应体里有真正的失败原因)。""" if resp.is_success: return text = resp.text or "" if " 作摘要,避免整段 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 = text[:200] raise ApiError(f"{provider} API HTTP {resp.status_code} — {detail or '无错误详情'}", resp.status_code) # 参考图选择:material 用第 2 张(背面/细节),其余用第 1 张(正面) TYPE_REF_INDEX = { "material": 1, } DEFAULT_REF_COUNT = 2 # 每次生图最多带的参考图数(正面 1 张 + 背面/细节 1 张) 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" if provider == "rightapi": # 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" _DOUBAO_ANTI_AI = ( "authentic real-world photography, natural imperfections, genuine texture, " "no synthetic look, no CGI quality, no heavy post-processing" ) DEFAULT_NEGATIVE_PROMPT = ( "AI-generated look, artificial, CGI quality, 3D render, synthetic texture, " "plastic skin, mannequin-like, too perfect, oversaturated, HDR, heavy vignette, " "low resolution, blurry, deformed, bad anatomy, overexposed, underexposed, grainy, " "watermark, text distortion, bad typography, overlapping text, cheap look, cartoon" ) # ── 参考图解析 ──────────────────────────────────────────────────────────── def _bytes_to_data_uri(data: bytes, mime: str) -> str: return f"data:{mime};base64,{base64.b64encode(data).decode()}" async def _resolve_ref_bytes(url: str) -> tuple[bytes, str]: """参考图 URL → (bytes, mime)。本地 media 文件直读磁盘;远程 URL 带 Referer 下载。 生图 API 的服务器无法访问 127.0.0.1,代理 URL 也不能直接透传, 所以统一在本地解析成原始字节再进请求体(data URI 或 multipart)。 """ if url.startswith("data:"): head, _, b64 = url.partition(",") mime = head[5:].split(";", 1)[0] or "image/jpeg" return base64.b64decode(b64), mime path = storage.local_path(url) if path is not None: mime = mimetypes.guess_type(path.name)[0] or "image/jpeg" return path.read_bytes(), mime if url.startswith(("http://", "https://")): from api.proxy import guess_referer data, ctype = await storage.download_bytes(url, referer=guess_referer(url)) if not ctype.startswith("image/"): ctype = "image/jpeg" return data, ctype raise FileNotFoundError(f"无法解析参考图: {url}") async def _resolve_ref(url: str) -> str: data, mime = await _resolve_ref_bytes(url) return _bytes_to_data_uri(data, mime) # ── Provider:豆包 Seedream(火山方舟)──────────────────────────────────── async def generate_doubao(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes: s = get_settings() if not s.ark_api_key: raise RuntimeError("未配置 ARK_API_KEY(.env)") body = { "model": model or s.ark_image_model, "prompt": prompt.rstrip(". ") + ". " + _DOUBAO_ANTI_AI, "size": size, "response_format": "url", "watermark": False, "n": 1, } if ref_images: body["image"] = [await _resolve_ref(u) for u in ref_images] async with httpx.AsyncClient(timeout=s.request_timeout, verify=False) as client: resp = await client.post( s.ark_base_url, headers={"Authorization": f"Bearer {s.ark_api_key}", "Content-Type": "application/json"}, json=body, ) _raise_api_error(resp, "豆包") img_url = resp.json()["data"][0]["url"] dl = await client.get(img_url, timeout=s.request_timeout) dl.raise_for_status() return dl.content # ── Provider:通义万相 / 千问(DashScope)──────────────────────────────── 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 while elapsed < max_wait: resp = await client.get(poll_url, headers={"Authorization": f"Bearer {key}"}, timeout=30) resp.raise_for_status() result = resp.json() status = result.get("output", {}).get("task_status", "") if status == "SUCCEEDED": choices = result["output"].get("choices", []) if choices: content = choices[0].get("message", {}).get("content", []) if content: return content[0].get("image", "") results = result["output"].get("results", []) if results: return results[0].get("url") or results[0].get("b64_image", "") raise RuntimeError(f"通义任务成功但无结果: {result}") if status in ("FAILED", "UNKNOWN"): raise RuntimeError(f"通义任务失败: {result}") await asyncio.sleep(interval) elapsed += interval interval = min(interval + 2, 10) raise TimeoutError(f"通义异步任务超时 ({max_wait}s): task_id={task_id}") async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048*2048", model: str | None = None) -> bytes: s = get_settings() if not s.dashscope_api_key: raise RuntimeError("未配置 DASHSCOPE_API_KEY(.env)") model = model or s.dashscope_model is_wan = _is_wan_model(model) url = s.dashscope_base_url or ( "https://dashscope.aliyuncs.com/api/v1/services/aigc/image-generation/generation" if is_wan else "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation" ) # 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} if not is_wan: params["prompt_extend"] = False params["negative_prompt"] = DEFAULT_NEGATIVE_PROMPT[:500] headers = {"Authorization": f"Bearer {s.dashscope_api_key}", "Content-Type": "application/json"} if is_wan: headers["X-DashScope-Async"] = "enable" body = {"model": model, "input": {"messages": [{"role": "user", "content": content}]}, "parameters": params} async with httpx.AsyncClient(timeout=s.request_timeout, verify=False) as client: resp = await client.post(url, headers=headers, json=body) _raise_api_error(resp, "通义") data = resp.json() if is_wan: task_id = data.get("output", {}).get("task_id", "") if not task_id: raise RuntimeError(f"通义万象未返回 task_id: {data}") img_url = await _tongyi_poll_task(client, s.dashscope_api_key, task_id, s.poll_max_wait) if img_url.startswith("data:") or len(img_url) > 500: return base64.b64decode(img_url.split(",", 1)[-1] if "," in img_url else img_url) dl = await client.get(img_url, timeout=s.request_timeout) dl.raise_for_status() return dl.content img_url = data["output"]["choices"][0]["message"]["content"][0]["image"] dl = await client.get(img_url, timeout=s.request_timeout) dl.raise_for_status() return dl.content # ── Provider:RightAPI(gpt-image / nano-banana,OpenAI 兼容中转)────────── # 可重试的状态码:中转限流/网关抖动(该中转限流时返回 Cloudflare 502 而非 429) RETRYABLE_STATUS = {429, 500, 502, 503, 504} async def _rightapi_poll_task(client: httpx.AsyncClient, headers: dict, origin: str, task_id: str, max_wait: int) -> dict: """轮询站点级任务查询接口 GET /v1/tasks/{task_id}(不带 /draw 前缀)。 实测要点(docs/rightapi-调用排查与修复方案.md §2.2): - 完成响应**没有** status:"completed" 字段,完成判定 = 响应里出现 data; - progress 基本不动(0~2),不能当进度条依据; - 失败态 = status 为 failed / error / cancelled。 """ poll_url = f"{origin}/v1/tasks/{task_id}" elapsed, interval = 0, 3 while elapsed < max_wait: resp = await client.get(poll_url, headers=headers, timeout=30) _raise_api_error(resp, "RightAPI") result = resp.json() status = result.get("status", "") if status in ("failed", "error", "cancelled"): err = result.get("error") or {} raise RuntimeError(f"RightAPI 任务失败: {err.get('message') or result}") if "data" in result: return result await asyncio.sleep(interval) elapsed += interval interval = min(interval + 2, 10) raise TimeoutError(f"RightAPI 异步任务超时 ({max_wait}s): task_id={task_id}") def _rightapi_extract_image(result: dict) -> tuple[str | None, str | None]: """从轮询完成结果里取 (kind, payload):kind ∈ url | b64,未取到返回 (None, None)。 完成形状为 Images 协议:{"created":..., "data":[{"url": "..."}]}(实测只见 url)。 """ data = result.get("data") or [] if data: item = data[0] or {} url = item.get("url") or "" if url: return ("url", url) b64 = item.get("b64_json") or "" if b64: return ("b64", b64) return (None, None) async def _rightapi_request(s, prompt: str, ref_images: list[str], size: str, model: str) -> bytes: """RightAPI 各模型:统一走 /v1/images/generations(异步)。 官方协议(docs.rightapi.ai/docs/rc_draw/,2026-07 起统一异步): - POST /draw/v1/images/generations,请求体固定带 async:true,参考图放 image 数组(data-URI); - 返回 task_id 后轮询 GET /v1/tasks/{task_id}(站点级,不带 /draw); - 参数只有 model/prompt/n/size/imageSize/image/async;不传 quality/output_format/input_fidelity。 参考图沿用现选图逻辑(≤2 张,image 数组)。单张 1-5 分钟,轮询上限 poll_max_wait 兜底。 """ base = s.rightapi_base_url.rstrip("/") # 任务查询是站点级接口,不带 /draw:从 base 里拆出 origin(https://rightapi.ai/draw → https://rightapi.ai) origin = base.split("/draw", 1)[0].rstrip("/") or base headers = {"Authorization": f"Bearer {s.rightapi_api_key}"} body = { "model": model, "prompt": prompt, "n": 1, "size": size, "async": True, } if ref_images: body["image"] = [await _resolve_ref(u) for u in ref_images] async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client: resp = await client.post( f"{base}/v1/images/generations", headers={**headers, "Content-Type": "application/json"}, json=body, ) _raise_api_error(resp, "RightAPI") submitted = resp.json() task_id = submitted.get("task_id") or "" if task_id: result = await _rightapi_poll_task(client, headers, origin, task_id, s.poll_max_wait) else: # 极端兜底:个别中转可能同步返回 data(文档不保证,但防御处理) result = submitted kind, payload = _rightapi_extract_image(result) if kind == "b64" and payload: return base64.b64decode(payload.split(",", 1)[-1] if "," in payload else payload) if kind == "url" and payload: dl = await client.get(payload, timeout=s.request_timeout) dl.raise_for_status() return dl.content raise RuntimeError(f"RightAPI 任务完成但没有图片数据: {result}") 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} # ── 任务执行器 ──────────────────────────────────────────────────────────── def _order_refs(refs: list[str], type_id: str) -> list[str]: """参考图槽位选择 + 截断:material 偏好第 2 张,其余用第 1 张。""" preferred = TYPE_REF_INDEX.get(type_id) if preferred is not None and len(refs) > preferred: refs = [refs[preferred]] + [r for i, r in enumerate(refs) if i != preferred] return refs[:DEFAULT_REF_COUNT] def _refs_for_job(images: list[dict], job: dict) -> list[str]: """无状态路径:按方案项选参考图。 优先 variant_name 精确匹配(「主图·粉色」用粉色那张 SKU 图); 匹配不到则回退 main 组第一张(再退到任意第一张)。 """ variant = job.get("variant_name") if variant: matched = [i["url"] for i in images if i.get("variant_name") == variant] if matched: return matched[:DEFAULT_REF_COUNT] mains = [i["url"] for i in images if i.get("group_key") == "main"] others = [i["url"] for i in images if i.get("group_key") != "main"] pool = mains or others or [i["url"] for i in images] if not pool: raise RuntimeError("任务没有参考图") return _order_refs(pool, job.get("kind", "")) # 串行生成队列:所有用户共享同一批 API key,并发生成会触发中转限流 # (rightapi 同 key 分钟级冷却);同一时间只跑一个任务,其余保持 pending 排队。 _GEN_LOCK = asyncio.Lock() async def run_suite(task: Task) -> None: """后台执行套图任务:排队 → 逐张生成 → 落盘 → 更新内存状态;单张失败不中断。""" settings = get_settings() provider_name = task.provider or settings.image_provider generator = GENERATORS.get(provider_name) if generator is None: task.status = TASK_FAILED task.error = f"未知 provider: {provider_name}" return ctx = build_context(task.context or {}, fallback_name="") model = task.model or { "tongyi": settings.dashscope_model, "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, task.ratio, is_wan=is_wan, model=model) jobs = [dict(j) for j in task.plan] async with _GEN_LOCK: task.status = TASK_RUNNING ok, failed = 0, 0 failures: list[str] = [] for job in jobs: type_id = job["kind"] image = TaskImage(type_id=type_id, name=job.get("title") or type_name(type_id)) task.images.append(image) try: # 提示词按模型家族分发:国产主体参考 / gpt edits 保真 / google 主体保持 prompt = build_prompt( provider_name, model, type_id, ctx, task.style_set, task.lang, extra=job, style_prompt=task.style_prompt, requirements=task.requirements, ) refs = _refs_for_job(list(task.ref_images or []), job) data = await generator(prompt, refs, size=size, model=model) # 水印:AI 出图返回后、落盘前的后处理(失败不阻断,内部返回原图) wm = task.watermark or {} if wm.get("enabled"): data = apply_watermark(data, wm) # 部分中转不遵守 output_format(要 jpeg 回 PNG),按魔数定扩展名 ext = ".png" if data[:8] == b"\x89PNG\r\n\x1a\n" else ".jpg" key = storage.write_bytes(data, key_prefix=f"suites/{task.id}", ext=ext) image.url = storage.public_url(key) image.status = IMG_OK ok += 1 except Exception as exc: # noqa: BLE001 log.exception("套图 %s 类型 %s 生成失败", task.id, type_id) image.status = IMG_FAILED # 默认 pending,失败显式置 failed image.error = str(exc)[:500] failures.append(f"{job.get('title') or type_name(type_id)}:{str(exc)[:200]}") failed += 1 task.status = TASK_DONE if failed == 0 else (TASK_PARTIAL if ok > 0 else TASK_FAILED) if failed: uniq = list(dict.fromkeys(failures)) # 去重保序 detail = ";".join(uniq[:6]) if len(uniq) > 6: detail += f";…等共 {failed} 张失败" if ok == 0: task.error = f"全部生成失败。{detail}" else: task.error = f"部分生成失败({failed} 张)。{detail}"