feat: AI生图功能丰富

This commit is contained in:
2026-09-27 22:41:33 +08:00
parent 1c4fe1a836
commit 7f1503294f
20 changed files with 627 additions and 336 deletions
+2 -1
View File
@@ -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
View File
@@ -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,
# 用户写了要求时,这一轮只执行其要求,不再拼营销图、默认风格或默认俄文文案。
+1
View File
@@ -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
View File
@@ -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,
+9 -5
View File
@@ -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 兼容对象形式。
+42 -7
View File
@@ -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))
+1 -1
View File
@@ -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]
+68
View File
@@ -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)
+42
View File
@@ -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()
+81
View File
@@ -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()