feat: AI生图功能丰富
This commit is contained in:
@@ -202,7 +202,8 @@ async def create_materials(
|
||||
await db.commit()
|
||||
await db.refresh(product)
|
||||
|
||||
if queued:
|
||||
# 多批上报的最后一批可能全被去重;仍要扫描此前批次留下的 pending 素材。
|
||||
if req.process_assets:
|
||||
background.add_task(process_product_assets, str(product.id))
|
||||
return MaterialsResponse(
|
||||
product_id=str(product.id),
|
||||
|
||||
+23
-5
@@ -316,12 +316,15 @@ async def download_suite_zip(suite_id: str):
|
||||
async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse:
|
||||
"""提交单张图生图任务;立即返回任务 ID,实际生成在统一队列异步执行。"""
|
||||
settings = get_settings()
|
||||
provider_name = resolve_provider(req.model, None, settings.image_provider)
|
||||
structured_edit = bool(req.main_image_url and req.main_image_url.strip())
|
||||
if structured_edit and req.model not in {"gpt-image-2", "gpt-image-2@pcore"}:
|
||||
raise HTTPException(status_code=400, detail="单张 AI 编辑仅支持 Pcore gpt-image-2")
|
||||
provider_name = "pcore" if structured_edit else resolve_provider(req.model, None, settings.image_provider)
|
||||
if provider_name not in GENERATORS:
|
||||
raise HTTPException(status_code=400, detail=f"未知 provider: {provider_name}")
|
||||
validate_model(provider_name, req.model)
|
||||
|
||||
spec = {"lang": "ru", "ratio": "3:4"} # 试算页固定 Ozon 规格(俄文图内文案 · 3:4)
|
||||
spec = {"lang": req.target_language if structured_edit else "ru", "ratio": req.ratio if structured_edit else "3:4"}
|
||||
model = api_model(req.model) # 剥 @provider 后缀,供应商侧只认裸模型名
|
||||
product_id = (req.product_id or "").strip() or None
|
||||
if product_id:
|
||||
@@ -331,7 +334,12 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
|
||||
raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc
|
||||
|
||||
# 单图编辑优先级高于套图;支持多张参考底图,保持传入顺序并去重。
|
||||
ref_urls = list(dict.fromkeys(url for url in [*req.image_urls, req.image_url] if url))
|
||||
if structured_edit:
|
||||
ref_urls = list(dict.fromkeys(url.strip() for url in [req.main_image_url, *req.reference_image_urls] if url and url.strip()))
|
||||
if len(ref_urls) > 3:
|
||||
raise HTTPException(status_code=400, detail="辅助参考图最多两张")
|
||||
else:
|
||||
ref_urls = list(dict.fromkeys(url for url in [*req.image_urls, req.image_url] if url))
|
||||
if not ref_urls:
|
||||
raise HTTPException(status_code=400, detail="请至少选择一张参考底图")
|
||||
if req.mode == "outpaint":
|
||||
@@ -341,6 +349,10 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
|
||||
raise HTTPException(status_code=400, detail="仅扩图只能选择一张参考底图")
|
||||
if req.generate_size_chart:
|
||||
raise HTTPException(status_code=400, detail="仅扩图不能同时生成尺寸标注图")
|
||||
if structured_edit and req.ratio != "3:4":
|
||||
raise HTTPException(status_code=400, detail="仅扩图只支持 3:4")
|
||||
if structured_edit and req.mode == "edit" and req.background == "reference" and len(ref_urls) < 2:
|
||||
raise HTTPException(status_code=400, detail="参考图背景需要选择一张辅助参考图")
|
||||
|
||||
# prompt_extra 清空展示文案,避免中文 UI 文案进入模型 prompt。
|
||||
is_size_chart = bool(req.generate_size_chart)
|
||||
@@ -354,10 +366,16 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
|
||||
lang=spec["lang"],
|
||||
ratio=spec["ratio"],
|
||||
# 仅扩图与用户明确填写的 AI 编辑要求都不得继承默认「纯净棚拍」风格。
|
||||
style_set=0 if req.mode == "outpaint" or bool(req.prompt.strip()) else 5,
|
||||
style_set=req.style_set if structured_edit and req.mode == "edit" else (0 if req.mode == "outpaint" or bool(req.prompt.strip()) else 5),
|
||||
request_payload={
|
||||
"context": _with_dimensions({"title": "AI 精修"}, req.dimensions, required=is_size_chart),
|
||||
"style_prompt": None,
|
||||
"style_prompt": req.style_prompt if structured_edit else None,
|
||||
"single_edit": {
|
||||
"translate_text": req.translate_text,
|
||||
"target_language": req.target_language,
|
||||
"background": req.background,
|
||||
"background_description": req.background_description,
|
||||
} if structured_edit and req.mode == "edit" else None,
|
||||
"requirements": req.prompt,
|
||||
"operation": req.mode,
|
||||
# 用户写了要求时,这一轮只执行其要求,不再拼营销图、默认风格或默认俄文文案。
|
||||
|
||||
@@ -33,6 +33,7 @@ class MaterialsRequest(BaseModel):
|
||||
texts: list[TextMaterial] = Field(default_factory=list)
|
||||
images: list[ImageMaterial] = Field(default_factory=list)
|
||||
refererOrigin: str | None = None # 下载源图时需带的 Referer
|
||||
process_assets: bool = True # 插件多批上报时,仅最后一批触发下载队列
|
||||
|
||||
|
||||
class MaterialsResponse(BaseModel):
|
||||
|
||||
+11
-2
@@ -82,7 +82,7 @@ class ProductDimensions(BaseModel):
|
||||
length: float | None = Field(default=None, gt=0, description="长度(按 unit 单位)")
|
||||
width: float | None = Field(default=None, gt=0, description="宽度(按 unit 单位)")
|
||||
height: float | None = Field(default=None, gt=0, description="高度(按 unit 单位)")
|
||||
unit: Literal["mm", "cm"] = Field(default="mm", description="尺寸单位")
|
||||
unit: Literal["mm", "cm"] = Field(default="cm", description="尺寸单位")
|
||||
|
||||
|
||||
class PlanItem(BaseModel):
|
||||
@@ -181,6 +181,8 @@ class SuiteOut(BaseModel):
|
||||
class ImageEditSingleRequest(BaseModel):
|
||||
"""POST /api/suite/image-edit:单张 AI 生图,可携带多张参考底图。"""
|
||||
product_id: str | None = Field(default=None, description="append=true 时回写到该商品的 generated 组")
|
||||
main_image_url: str | None = Field(default=None, description="本次编辑的主图;新界面必须显式传入")
|
||||
reference_image_urls: list[str] = Field(default_factory=list, max_length=2, description="最多两张辅助参考图,按选择顺序传给模型")
|
||||
image_url: str | None = Field(default=None, description="兼容旧客户端的单张原图 URL")
|
||||
image_urls: list[str] = Field(default_factory=list, description="选中的参考底图 URL,可多张")
|
||||
prompt: str = Field(default="", description="用户要求(可选,作为额外生图指令)")
|
||||
@@ -188,7 +190,14 @@ class ImageEditSingleRequest(BaseModel):
|
||||
default="edit",
|
||||
description="edit 为 AI 编辑;outpaint 只扩展到目标画布,不允许模型改动原图区域",
|
||||
)
|
||||
model: str = Field(default="nano-banana-2")
|
||||
model: str = Field(default="gpt-image-2@pcore")
|
||||
ratio: Literal["3:4", "1:1", "4:5"] = Field(default="3:4", description="输出画幅")
|
||||
target_language: Literal["ru", "ar", "en"] = Field(default="ru", description="图内文字的目标语言")
|
||||
translate_text: bool = Field(default=False, description="在本次生图调用中识别、翻译并重新排版图中文字")
|
||||
style_set: int = Field(default=2, ge=1, le=5)
|
||||
style_prompt: str | None = Field(default=None, description="用户修改后的风格提示词")
|
||||
background: Literal["auto", "white", "solid", "scene", "reference"] = Field(default="auto")
|
||||
background_description: str | None = Field(default=None, description="纯色或场景背景的具体要求")
|
||||
append: bool = Field(default=True, description="结果是否追加为商品素材(generated 组)")
|
||||
after_asset_id: str | None = Field(
|
||||
default=None,
|
||||
|
||||
@@ -81,19 +81,21 @@ DEFAULT_REF_COUNT = 2 # 每次生图最多带的参考图数(正面 1 张 +
|
||||
def _image_size(provider: str, ratio: str, is_wan: bool = True, model: str = "") -> str:
|
||||
"""平台比例 → provider 尺寸参数。3:4 竖版(Ozon),1:1 方图(国内)。"""
|
||||
if provider == "doubao":
|
||||
return "1536x2048" if ratio == "3:4" else "2048x2048"
|
||||
return {"3:4": "1536x2048", "4:5": "1536x1920"}.get(ratio, "2048x2048")
|
||||
if provider == "rightapi":
|
||||
# gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法)
|
||||
return "1536x2048" if ratio == "3:4" else "2048x2048"
|
||||
return {"3:4": "1536x2048", "4:5": "1536x1920"}.get(ratio, "2048x2048")
|
||||
if provider == "pcore":
|
||||
# 竖图必须与 Ozon 的 3:4 规格一致。1024x1536 实际是 2:3,会使成图明显偏瘦高。
|
||||
return "1152x1536" if ratio == "3:4" else "1024x1024"
|
||||
return {"3:4": "1152x1536", "4:5": "1280x1600"}.get(ratio, "1024x1024")
|
||||
# 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"
|
||||
return {"3:4": "1152*1536", "4:5": "1280*1600"}.get(ratio, "1440*1440")
|
||||
if ratio == "3:4":
|
||||
return "1536*2048" if is_wan else "768*1024"
|
||||
if ratio == "4:5":
|
||||
return "1536*1920" if is_wan else "960*1200"
|
||||
return "2048*2048" if is_wan else "1024*1024"
|
||||
|
||||
_DOUBAO_ANTI_AI = (
|
||||
@@ -520,7 +522,8 @@ async def _pcore_request(s, prompt: str, ref_images: list[str], size: str, model
|
||||
kind = "edits" if ref_images else "generations"
|
||||
body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True}
|
||||
if ref_images:
|
||||
# pcore 的 edits 要求 images 为 [{image_url: ...}] 对象数组(实测纯字符串报 images[].image_url is required)
|
||||
# Pcore 的 gpt-image-2 通道实际要求 OpenAI 兼容对象数组。
|
||||
# 虽然通用文档描述字符串数组也可用,该通道会在异步任务中校验 images[].image_url。
|
||||
body["images"] = [{"image_url": await _resolve_ref(u)} for u in ref_images]
|
||||
|
||||
async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client:
|
||||
@@ -723,6 +726,7 @@ async def advance_generation(
|
||||
kind = "edits" if ref_images else "generations"
|
||||
body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True}
|
||||
if ref_images:
|
||||
# 与 _pcore_request 保持同一经 gpt-image-2 实际验证的形状,避免 Worker 路径漂移。
|
||||
body["images"] = [{"image_url": await _resolve_ref(item)} for item in ref_images]
|
||||
if edit_mask:
|
||||
# Pcore 的图片参数使用 JSON {image_url};蒙版沿用相同的 OpenAI 兼容对象形式。
|
||||
|
||||
@@ -6,6 +6,8 @@ import logging
|
||||
import socket
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
|
||||
from config import get_settings
|
||||
from db import get_session_factory
|
||||
from models import ProductAsset
|
||||
@@ -23,6 +25,7 @@ from services.jobs import (
|
||||
)
|
||||
from services.prompts import build_context, build_prompt
|
||||
from services.prompts.common import requirements_block
|
||||
from services.prompts.single_edit import build_single_edit_prompt
|
||||
from services.storage import get_storage
|
||||
from services.suite_service import append_generated_asset
|
||||
from services.tasks import TaskImage
|
||||
@@ -31,6 +34,10 @@ from services.video_renderer import VideoRenderCancelled, render_slideshow
|
||||
|
||||
log = logging.getLogger("background.worker")
|
||||
|
||||
# 异步供应商任务已经持久化了 task id 后,轮询网络失败不能把任务判为失败。
|
||||
# 下一次轮询复用同一 task id,不会再次提交/计费。
|
||||
PROVIDER_POLL_RETRY_DELAY_SECONDS = 30
|
||||
|
||||
|
||||
def _worker_owner() -> str:
|
||||
return f"{socket.gethostname()}-{uuid.uuid4().hex[:10]}"
|
||||
@@ -191,7 +198,11 @@ async def process_item(claim: ClaimedItem) -> None:
|
||||
"pcore": "gpt-image-2",
|
||||
}.get(provider, settings.ark_image_model)
|
||||
try:
|
||||
refs = _refs_for_job(list(payload.get("ref_images") or []), job_item)
|
||||
single_edit = payload.get("single_edit")
|
||||
refs = (
|
||||
[image["url"] for image in payload.get("ref_images") or []]
|
||||
if single_edit else _refs_for_job(list(payload.get("ref_images") or []), job_item)
|
||||
)
|
||||
operation = payload.get("operation", "edit")
|
||||
target_size = _image_size(provider, claim.ratio, is_wan=provider == "tongyi" and model.lower().startswith("wan"), model=model)
|
||||
edit_mask: str | None = None
|
||||
@@ -207,12 +218,21 @@ async def process_item(claim: ClaimedItem) -> None:
|
||||
edit_mask = outpaint.mask_data_url
|
||||
else:
|
||||
context = build_context(payload.get("context") or {}, fallback_name="")
|
||||
prompt_extra = job_item.get("prompt_extra") or job_item
|
||||
prompt = build_prompt(
|
||||
provider, model, job_item.get("kind", "custom"), context, claim.style_set, claim.lang,
|
||||
extra=prompt_extra, style_prompt=payload.get("style_prompt"), requirements=payload.get("requirements"),
|
||||
requirements_exclusive=bool(payload.get("requirements_exclusive")),
|
||||
)
|
||||
if single_edit:
|
||||
prompt = build_single_edit_prompt(
|
||||
requirements=payload.get("requirements") or "", config=single_edit,
|
||||
context=context, style_set=claim.style_set,
|
||||
style_prompt=payload.get("style_prompt"), ratio=claim.ratio,
|
||||
is_size_chart=job_item.get("kind") == "size_chart",
|
||||
reference_count=len(refs) - 1,
|
||||
)
|
||||
else:
|
||||
prompt_extra = job_item.get("prompt_extra") or job_item
|
||||
prompt = build_prompt(
|
||||
provider, model, job_item.get("kind", "custom"), context, claim.style_set, claim.lang,
|
||||
extra=prompt_extra, style_prompt=payload.get("style_prompt"), requirements=payload.get("requirements"),
|
||||
requirements_exclusive=bool(payload.get("requirements_exclusive")),
|
||||
)
|
||||
is_wan = provider == "tongyi" and model.lower().startswith("wan")
|
||||
result = await advance_generation(
|
||||
provider, prompt, refs,
|
||||
@@ -274,6 +294,21 @@ async def process_item(claim: ClaimedItem) -> None:
|
||||
claim.item_id, result_url=result_url, source_url=source_url or None,
|
||||
watermark_config=watermark_config, generated_asset_id=asset_id,
|
||||
)
|
||||
except (httpx.TimeoutException, httpx.TransportError) as exc:
|
||||
if claim.provider_task_id and claim.provider_task_kind:
|
||||
log.warning(
|
||||
"供应商轮询网络暂时失败,%ds 后重试同一任务 %s:%s",
|
||||
PROVIDER_POLL_RETRY_DELAY_SECONDS, claim.provider_task_id, exc.__class__.__name__,
|
||||
)
|
||||
await mark_waiting_provider(
|
||||
claim.item_id,
|
||||
claim.provider_task_id,
|
||||
claim.provider_task_kind,
|
||||
delay_seconds=PROVIDER_POLL_RETRY_DELAY_SECONDS,
|
||||
)
|
||||
return
|
||||
log.exception("后台图片任务 %s/%s 网络请求失败", claim.job_id, claim.item_id)
|
||||
await mark_item_failed(claim.item_id, str(exc) or exc.__class__.__name__)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.exception("后台图片任务 %s/%s 执行失败", claim.job_id, claim.item_id)
|
||||
await mark_item_failed(claim.item_id, str(exc))
|
||||
|
||||
@@ -140,7 +140,7 @@ def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") -
|
||||
(label, dimensions[key]) for key, label in (("length", "length"), ("width", "width"), ("height", "height"))
|
||||
if dimensions.get(key) is not None
|
||||
]
|
||||
unit = (dimensions.get("unit") or "mm") if isinstance(dimensions, dict) else "mm"
|
||||
unit = (dimensions.get("unit") or "cm") if isinstance(dimensions, dict) else "cm"
|
||||
dimensions_line = "; ".join(f"{label}: {value} {unit}" for label, value in known_dimensions)
|
||||
known_labels = [label for label, _ in known_dimensions]
|
||||
missing_labels = [label for label in ("length", "width", "height") if label not in known_labels]
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""单张 AI 编辑提示词:一次图像模型调用完成编辑、翻译和排版。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from services.prompts.common import requirements_block, resolve_style
|
||||
|
||||
|
||||
LANGUAGES = {"ru": "Russian", "ar": "Arabic", "en": "English"}
|
||||
|
||||
BACKGROUNDS = {
|
||||
"auto": "Keep the original background unless the user's requirements ask to change it.",
|
||||
"white": "Replace only the background with clean pure white; retain natural contact shadows.",
|
||||
"solid": "Replace only the background with a clean solid color as described below.",
|
||||
"scene": "Replace only the background with the described realistic scene. Keep the product as the hero.",
|
||||
"reference": "Use Image 2 for background and lighting direction. Do not copy its product.",
|
||||
}
|
||||
|
||||
|
||||
def build_single_edit_prompt(
|
||||
*, requirements: str, config: dict, context: dict, style_set: int,
|
||||
style_prompt: str | None, ratio: str, is_size_chart: bool, reference_count: int,
|
||||
) -> str:
|
||||
"""Image 1 always supplies the product; following images are supporting references only."""
|
||||
language = LANGUAGES.get(config.get("target_language"), "Russian")
|
||||
background = config.get("background") or "auto"
|
||||
sections = [
|
||||
requirements_block(requirements),
|
||||
"Create exactly ONE finished e-commerce image. Image 1 is the fixed main product image. "
|
||||
"Preserve the product's shape, proportions, color, material, logo and distinguishing details. "
|
||||
"Do not replace it with products from any other image.",
|
||||
]
|
||||
if reference_count:
|
||||
sections.append(
|
||||
f"Images 2 through {reference_count + 1} are auxiliary references only. "
|
||||
"Use them for product detail, material, scene or lighting as appropriate; "
|
||||
"Image 1 remains the source of truth for the product."
|
||||
)
|
||||
sections.append("BACKGROUND: " + BACKGROUNDS.get(background, BACKGROUNDS["auto"]))
|
||||
if config.get("background_description"):
|
||||
sections.append("Background details: " + str(config["background_description"]).strip())
|
||||
if config.get("translate_text"):
|
||||
sections.append(
|
||||
f"In this same image-generation pass, read the visible marketing text in Image 1, "
|
||||
f"translate its meaning into {language}, replace the source-language marketing copy, "
|
||||
"and redesign the text layout for a polished, legible composition. "
|
||||
"For Arabic, use correct right-to-left typesetting. Keep factual values and units exact. "
|
||||
"Preserve text printed on the physical product and its branding unless the user explicitly asks to edit them. "
|
||||
"Do not add unsupported claims."
|
||||
)
|
||||
else:
|
||||
sections.append(
|
||||
"Do not invent marketing copy or translate existing image text. "
|
||||
"Preserve text printed on the physical product unless explicitly requested otherwise."
|
||||
)
|
||||
if is_size_chart:
|
||||
annotation = context.get("dimension_annotation_instruction")
|
||||
if annotation:
|
||||
sections.append(
|
||||
"Create a clean product measurement diagram with legible dimension arrows. "
|
||||
+ str(annotation)
|
||||
)
|
||||
style = resolve_style(style_set, style_prompt)
|
||||
sections.append("VISUAL STYLE: " + str(style["tone"]))
|
||||
sections.append(
|
||||
f"OUTPUT: exactly {ratio} portrait or square composition as specified. "
|
||||
"Apply the user's explicit background and text instructions before style preferences. "
|
||||
"Do not add a watermark or extra panels."
|
||||
)
|
||||
return "\n\n".join(part for part in sections if part)
|
||||
@@ -7,6 +7,8 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(SERVER_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SERVER_DIR))
|
||||
@@ -58,6 +60,46 @@ class WorkerCancellationTests(unittest.IsolatedAsyncioTestCase):
|
||||
cancelled.assert_awaited_once_with(claim.item_id)
|
||||
waiting.assert_not_awaited()
|
||||
|
||||
async def test_provider_poll_timeout_keeps_existing_task_for_retry(self) -> None:
|
||||
claim = ClaimedItem(
|
||||
job_id="00000000-0000-0000-0000-000000000001",
|
||||
item_id="00000000-0000-0000-0000-000000000002",
|
||||
job_kind="image_edit",
|
||||
product_id=None,
|
||||
provider="pcore",
|
||||
model="gpt-image-2",
|
||||
platform="ozon",
|
||||
lang="ru",
|
||||
ratio="3:4",
|
||||
style_set=5,
|
||||
request_payload={"context": {"title": "test"}, "ref_images": []},
|
||||
item_payload={"kind": "custom", "title": "AI生图"},
|
||||
provider_task_id="provider-task",
|
||||
provider_task_kind="edits",
|
||||
)
|
||||
settings = SimpleNamespace(
|
||||
image_provider="pcore",
|
||||
dashscope_model="wan2.7-image-pro",
|
||||
rightapi_image_model="gpt-image-2",
|
||||
ark_image_model="doubao-seedream-4-5-251128",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("services.job_worker.get_settings", return_value=settings),
|
||||
patch("services.job_worker.build_context", return_value={}),
|
||||
patch("services.job_worker.build_prompt", return_value="prompt"),
|
||||
patch("services.job_worker._refs_for_job", return_value=[]),
|
||||
patch("services.job_worker.advance_generation", new=AsyncMock(side_effect=httpx.ConnectTimeout("timeout"))),
|
||||
patch("services.job_worker.mark_waiting_provider", new=AsyncMock()) as waiting,
|
||||
patch("services.job_worker.mark_item_failed", new=AsyncMock()) as failed,
|
||||
):
|
||||
await process_item(claim)
|
||||
|
||||
waiting.assert_awaited_once_with(
|
||||
claim.item_id, "provider-task", "edits", delay_seconds=30,
|
||||
)
|
||||
failed.assert_not_awaited()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""单张编辑的新参数必须进入同一次生图任务,且主图顺序与尺寸准确。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from fastapi import HTTPException
|
||||
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(SERVER_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SERVER_DIR))
|
||||
|
||||
from api.suite import suite_image_edit, _with_dimensions
|
||||
from schemas.suite import ImageEditSingleRequest, ProductDimensions
|
||||
from services.generator import _image_size
|
||||
from services.prompts.common import build_context
|
||||
from services.prompts.single_edit import build_single_edit_prompt
|
||||
|
||||
|
||||
class SingleEditRequestTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_main_and_two_auxiliary_images_keep_order_in_one_item(self) -> None:
|
||||
request = ImageEditSingleRequest(
|
||||
main_image_url="main", reference_image_urls=["detail", "scene"],
|
||||
prompt="替换背景", model="gpt-image-2@pcore", translate_text=True,
|
||||
target_language="ar", background="reference", ratio="4:5",
|
||||
)
|
||||
job = AsyncMock(return_value=SimpleNamespace(id="task-id"))
|
||||
with patch("api.suite.create_image_job", job):
|
||||
response = await suite_image_edit(request)
|
||||
self.assertEqual(response.suite_id, "task-id")
|
||||
payload = job.await_args.kwargs
|
||||
self.assertEqual(payload["ratio"], "4:5")
|
||||
self.assertEqual(payload["lang"], "ar")
|
||||
self.assertEqual(payload["provider"], "pcore")
|
||||
self.assertEqual(payload["model"], "gpt-image-2")
|
||||
self.assertEqual(len(payload["item_payloads"]), 1)
|
||||
self.assertEqual([image["url"] for image in payload["request_payload"]["ref_images"]], ["main", "detail", "scene"])
|
||||
self.assertTrue(payload["request_payload"]["single_edit"]["translate_text"])
|
||||
|
||||
async def test_structured_edit_rejects_other_provider(self) -> None:
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
await suite_image_edit(ImageEditSingleRequest(
|
||||
main_image_url="main", model="nano-banana-2",
|
||||
))
|
||||
self.assertEqual(raised.exception.status_code, 400)
|
||||
|
||||
|
||||
class SingleEditPromptTests(unittest.TestCase):
|
||||
def test_translation_background_and_partial_dimensions_share_one_prompt(self) -> None:
|
||||
context = build_context(_with_dimensions(
|
||||
{"title": "AI 精修"}, ProductDimensions(width=8, unit="cm"), required=True,
|
||||
))
|
||||
prompt = build_single_edit_prompt(
|
||||
requirements="保留商品颜色", config={"translate_text": True, "target_language": "ar", "background": "scene", "background_description": "warm kitchen"},
|
||||
context=context, style_set=2, style_prompt=None, ratio="3:4", is_size_chart=True, reference_count=2,
|
||||
)
|
||||
self.assertIn("Image 1 is the fixed main product image", prompt)
|
||||
self.assertIn("Images 2 through 3 are auxiliary", prompt)
|
||||
self.assertIn("Arabic", prompt)
|
||||
self.assertIn("right-to-left", prompt)
|
||||
self.assertIn("warm kitchen", prompt)
|
||||
self.assertIn("width: 8.0 cm", prompt)
|
||||
self.assertIn("Do not draw, label, or show an arrow for: length, height", prompt)
|
||||
|
||||
def test_four_to_five_size_does_not_fall_back_to_square(self) -> None:
|
||||
for provider, is_wan, model in (
|
||||
("pcore", True, "gpt-image-2"),
|
||||
("rightapi", True, "gpt-image-2"),
|
||||
("doubao", True, "seedream"),
|
||||
("tongyi", True, "wan2.6-image"),
|
||||
("tongyi", False, "qwen-image-3.0-pro"),
|
||||
):
|
||||
with self.subTest(provider=provider, model=model):
|
||||
width, height = map(int, _image_size(provider, "4:5", is_wan, model).replace("*", "x").split("x"))
|
||||
self.assertEqual(width * 5, height * 4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user