feat: 试用过程中的细节优化

This commit is contained in:
2026-09-13 22:19:16 +08:00
parent dbb2bb80bd
commit 897e5585c0
40 changed files with 1156 additions and 257 deletions
+5 -4
View File
@@ -2,8 +2,8 @@
平移自 image-suite-studio;路径按 docs/v2.1/api.md §7 定为 POST /api/export/images
(前端 services/suite.ts exportImages 同款契约,注意与 ISS 的 /export-images 不同)。
ZIP 内部按分组名建子文件夹(主图 / SKU图片 / 详情图 / 手动上传),
文件名沿用采集 key(main-001 等)+ SKU 规格名;顶层文件夹用商品标题(清洗后)。
采集图片默认按分组名建子文件夹(主图 / SKU图片 / 详情图 / 手动上传)。生成结果可
请求扁平导出,所有图片直接放在商品根目录;文件名沿用采集 key(main-001 等)+ SKU 规格名。
"""
from __future__ import annotations
@@ -51,6 +51,7 @@ class ExportImageItem(BaseModel):
class ExportImagesRequest(BaseModel):
title: str | None = Field(default=None, description="商品标题,用作 ZIP 顶层文件夹名")
images: list[ExportImageItem] = Field(min_length=1)
flat: bool = Field(default=False, description="是否忽略图片分组,直接写入 ZIP 顶层商品目录")
def _clean(name: str) -> str:
@@ -159,8 +160,8 @@ async def export_images(req: ExportImagesRequest):
filename = f"{stem}-{n}{ext}"
used.add(filename)
group = _clean(img.groupName) or "图片"
zf.writestr(f"{root}/{group}/{filename}", data)
archive_path = f"{root}/{filename}" if req.flat else f"{root}/{_clean(img.groupName) or '图片'}/{filename}"
zf.writestr(archive_path, data)
if archive.tell() > settings.export_zip_max_bytes:
raise HTTPException(
status_code=413,
+46 -15
View File
@@ -9,6 +9,7 @@ watermark + services/prompts),任务持久化到数据库,由独立 Worker
from __future__ import annotations
import io
import re
import tempfile
import uuid
import zipfile
@@ -49,25 +50,39 @@ from services.suite_service import texts_to_raw, validate_model
router = APIRouter(prefix="/api", tags=["suite"])
_DIMENSION_PARAM_RE = re.compile(
r"尺寸|长宽高|长度|宽度|高度|厚度|深度|直径|dimension|length|width|height|depth|thickness|diameter",
re.IGNORECASE,
)
def _with_dimensions(raw: dict, dimensions, *, required: bool = False) -> dict:
"""将表单长宽高放入提示词上下文,不依赖采集参数是否已有尺寸字段。"""
"""将已填写的尺寸方向放入提示词上下文;未填写方向绝不传给绘制提示词。"""
if dimensions is None:
if required:
raise HTTPException(status_code=400, detail="生成尺寸标注图需要完整填写商品长、宽、高")
raise HTTPException(status_code=400, detail="生成尺寸标注图至少需要填写一项商品尺寸")
return raw
values = (dimensions.length, dimensions.width, dimensions.height)
if not all(value is not None and value > 0 for value in values):
known_dimensions = {
key: value for key, value in {
"length": dimensions.length,
"width": dimensions.width,
"height": dimensions.height,
}.items() if value is not None and value > 0
}
if not known_dimensions:
if required:
raise HTTPException(status_code=400, detail="生成尺寸标注图需要完整填写商品长、宽、高")
raise HTTPException(status_code=400, detail="生成尺寸标注图至少需要填写一项商品尺寸")
return raw
enriched = dict(raw)
enriched["dimensions"] = {
"length": dimensions.length,
"width": dimensions.width,
"height": dimensions.height,
"unit": dimensions.unit,
}
# 表单尺寸是尺寸标注图的唯一事实来源。原始商品参数常含完整 L×W×H,
# 若继续带进 prompt,模型会把未填写的方向也画出来。
params = raw.get("params") or []
if isinstance(params, list):
enriched["params"] = [
pair for pair in params
if isinstance(pair, dict) and not _DIMENSION_PARAM_RE.search(str(pair.get("key") or ""))
]
enriched["dimensions"] = {**known_dimensions, "unit": dimensions.unit}
return enriched
@@ -315,7 +330,19 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
except ValueError as exc:
raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc
# 单图编辑优先级高于套图;prompt_extra 清空展示文案,避免中文 UI 文案进入模型 prompt。
# 单图编辑优先级高于套图;支持多张参考底图,保持传入顺序并去重。
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":
if provider_name != "pcore":
raise HTTPException(status_code=400, detail="仅扩图当前仅支持 Pcore gpt-image-2")
if len(ref_urls) != 1:
raise HTTPException(status_code=400, detail="仅扩图只能选择一张参考底图")
if req.generate_size_chart:
raise HTTPException(status_code=400, detail="仅扩图不能同时生成尺寸标注图")
# prompt_extra 清空展示文案,避免中文 UI 文案进入模型 prompt。
is_size_chart = bool(req.generate_size_chart)
task = await create_image_job(
product_id=product_id,
@@ -326,20 +353,24 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
platform="ozon",
lang=spec["lang"],
ratio=spec["ratio"],
style_set=5,
# 仅扩图与用户明确填写的 AI 编辑要求都不得继承默认「纯净棚拍」风格。
style_set=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,
"requirements": req.prompt,
"operation": req.mode,
# 用户写了要求时,这一轮只执行其要求,不再拼营销图、默认风格或默认俄文文案。
"requirements_exclusive": bool(req.mode == "edit" and not is_size_chart and req.prompt.strip()),
"watermark": req.watermark.model_dump() if req.watermark else None,
"append": bool(req.append and product_id),
"after_asset_id": req.after_asset_id,
"ref_images": [{"url": req.image_url, "group_key": "main", "variant_name": None}],
"ref_images": [{"url": url, "group_key": "main", "variant_name": None} for url in ref_urls],
},
item_payloads=[{
"position": 0,
"kind": "size_chart" if is_size_chart else "custom",
"title": "尺寸标注图" if is_size_chart else "AI生图",
"title": "尺寸标注图" if is_size_chart else ("仅扩图" if req.mode == "outpaint" else "AI生图"),
"detail": "",
"prompt_hint": "",
"variant_name": None,
View File
+8 -3
View File
@@ -77,7 +77,7 @@ class WatermarkOptions(BaseModel):
class ProductDimensions(BaseModel):
"""商品长宽高;尺寸图只会使用完整的三项数据。"""
"""商品长宽高;尺寸图仅标注实际提供的方向。"""
length: float | None = Field(default=None, gt=0, description="长度(按 unit 单位)")
width: float | None = Field(default=None, gt=0, description="宽度(按 unit 单位)")
@@ -177,10 +177,15 @@ class SuiteOut(BaseModel):
class ImageEditSingleRequest(BaseModel):
"""POST /api/suite/image-edit:单张 AI 生图(试算页每张图的「AI 生图」入口)。"""
"""POST /api/suite/image-edit:单张 AI 生图,可携带多张参考底图。"""
product_id: str | None = Field(default=None, description="append=true 时回写到该商品的 generated 组")
image_url: str = Field(..., description="原图(stored_url 或 source_url,远程由服务端带 Referer 代下)")
image_url: str | None = Field(default=None, description="兼容旧客户端的单张原图 URL")
image_urls: list[str] = Field(default_factory=list, description="选中的参考底图 URL,可多张")
prompt: str = Field(default="", description="用户要求(可选,作为额外生图指令)")
mode: Literal["edit", "outpaint"] = Field(
default="edit",
description="edit 为 AI 编辑;outpaint 只扩展到目标画布,不允许模型改动原图区域",
)
model: str = Field(default="nano-banana-2")
append: bool = Field(default=True, description="结果是否追加为商品素材(generated 组)")
after_asset_id: str | None = Field(
+80 -2
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import asyncio
import base64
import io
import logging
import mimetypes
import re
@@ -17,6 +18,7 @@ from dataclasses import dataclass
from typing import Awaitable, Callable
import httpx
from PIL import Image, ImageOps
from config import get_settings
from services.prompts import build_context, build_prompt, type_name
@@ -84,8 +86,8 @@ def _image_size(provider: str, ratio: str, is_wan: bool = True, model: str = "")
# gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法)
return "1536x2048" if ratio == "3:4" else "2048x2048"
if provider == "pcore":
# pcore 的 size 是枚举(默认 1024x1024),不接受自定义尺寸;用 gpt-image 枚举:竖版 1024x1536(近似 3:4)
return "1024x1536" if ratio == "3:4" else "1024x1024"
# 竖图必须与 Ozon 的 3:4 规格一致。1024x1536 实际是 2:3,会使成图明显偏瘦高。
return "1152x1536" if ratio == "3:4" else "1024x1024"
# tongyi:万象与千问的 size 语法相同(* 分隔),档位不同
# wan2.6 系列总像素限制在 [1280², 1440²],wan2.7 的 1536*2048/2048*2048 会超限
if model.startswith("wan2.6"):
@@ -141,6 +143,75 @@ async def _resolve_ref(url: str) -> str:
return _bytes_to_data_uri(data, mime)
@dataclass(frozen=True)
class OutpaintCanvas:
"""用于受限扩图的预处理结果。
``image_data_url`` 与 ``mask_data_url`` 尺寸严格一致:蒙版不透明的区域是原图,
透明区域才允许供应商生成。最终仍会把 ``original`` 覆盖回结果,防止供应商在
蒙版区域有轻微重绘。
"""
image_data_url: str
mask_data_url: str
original: Image.Image
box: tuple[int, int, int, int]
size: tuple[int, int]
def restore_original(self, generated: bytes) -> bytes:
"""恢复原图区域,确保输出中原图像素不受模型影响。"""
with Image.open(io.BytesIO(generated)) as result:
result = ImageOps.exif_transpose(result).convert("RGBA")
if result.size != self.size:
# 供应商偶发不遵从 size 时仍输出固定 3:4;随后会精确覆盖原图区域。
result = ImageOps.fit(result, self.size, method=Image.Resampling.LANCZOS)
result.alpha_composite(self.original, self.box[:2])
out = io.BytesIO()
result.save(out, format="PNG")
return out.getvalue()
def _png_data_url(image: Image.Image) -> str:
out = io.BytesIO()
image.save(out, format="PNG")
return _bytes_to_data_uri(out.getvalue(), "image/png")
async def prepare_outpaint_canvas(url: str, size: str) -> OutpaintCanvas:
"""把单张参考图置于目标画布,并生成仅开放新增边缘的 PNG alpha 蒙版。"""
try:
target_width, target_height = (int(value) for value in size.lower().split("x", 1))
except (TypeError, ValueError) as exc:
raise ValueError(f"仅扩图需要合法的宽高尺寸,收到: {size}") from exc
if target_width <= 0 or target_height <= 0:
raise ValueError(f"仅扩图需要正数宽高尺寸,收到: {size}")
data, _ = await _resolve_ref_bytes(url)
with Image.open(io.BytesIO(data)) as opened:
source = ImageOps.exif_transpose(opened).convert("RGBA")
if source.width <= 0 or source.height <= 0:
raise ValueError("参考底图尺寸无效")
scale = min(target_width / source.width, target_height / source.height)
source_width = max(1, round(source.width * scale))
source_height = max(1, round(source.height * scale))
original = source.resize((source_width, source_height), Image.Resampling.LANCZOS)
x = (target_width - original.width) // 2
y = (target_height - original.height) // 2
canvas = Image.new("RGBA", (target_width, target_height), (0, 0, 0, 0))
canvas.alpha_composite(original, (x, y))
# OpenAI/GPT Image 兼容蒙版约定:透明像素是可编辑区域;原图区域不透明。
mask = Image.new("RGBA", (target_width, target_height), (0, 0, 0, 0))
mask.paste((255, 255, 255, 255), (x, y, x + original.width, y + original.height))
return OutpaintCanvas(
image_data_url=_png_data_url(canvas),
mask_data_url=_png_data_url(mask),
original=original,
box=(x, y, x + original.width, y + original.height),
size=(target_width, target_height),
)
# ── Provider:豆包 Seedream(火山方舟)────────────────────────────────────
async def generate_doubao(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes:
@@ -562,6 +633,7 @@ async def advance_generation(
model: str,
provider_task_id: str | None = None,
provider_task_kind: str | None = None,
edit_mask: str | None = None,
) -> GenerationAdvance:
"""推进一次图片生成,不在本函数内无限轮询。
@@ -569,6 +641,8 @@ async def advance_generation(
能继续外部任务,且在等待期间可以领取其他任务。豆包和同步千问仍在一次调用内完成。
"""
settings = get_settings()
if edit_mask and provider != "pcore":
raise RuntimeError("带蒙版的仅扩图当前仅支持 Pcore gpt-image-2")
if provider == "doubao":
return GenerationAdvance(data=await generate_doubao(prompt, ref_images, size=size, model=model))
@@ -650,6 +724,10 @@ async def advance_generation(
body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True}
if ref_images:
body["images"] = [{"image_url": await _resolve_ref(item)} for item in ref_images]
if edit_mask:
# Pcore 的图片参数使用 JSON {image_url};蒙版沿用相同的 OpenAI 兼容对象形式。
# 透明区域允许编辑,不透明区域保留。若供应商轻微偏移,Worker 会二次覆盖原图。
body["mask"] = {"image_url": edit_mask}
response = await client.post(
f"{base}/v1/images/{kind}", headers={**headers, "Content-Type": "application/json"}, json=body,
)
+46 -8
View File
@@ -10,7 +10,7 @@ from config import get_settings
from db import get_session_factory
from models import ProductAsset
from services.image_assets import get_image_asset_service
from services.generator import _image_size, _refs_for_job, advance_generation
from services.generator import OutpaintCanvas, _image_size, _refs_for_job, advance_generation, prepare_outpaint_canvas
from services.jobs import (
ClaimedItem,
claim_next_item,
@@ -22,6 +22,7 @@ from services.jobs import (
recover_expired_leases,
)
from services.prompts import build_context, build_prompt
from services.prompts.common import requirements_block
from services.storage import get_storage
from services.suite_service import append_generated_asset
from services.tasks import TaskImage
@@ -152,6 +153,22 @@ async def _process_slideshow_video_item(claim: ClaimedItem) -> None:
await mark_item_failed(claim.item_id, str(exc))
def _outpaint_prompt(requirements: str | None) -> str:
"""仅扩图不复用套图提示词,避免默认风格、营销构图或文字污染边缘生成。"""
requested = (requirements or "").strip()
if not requested:
requested = "Continue the existing surroundings naturally and seamlessly."
return "\n\n".join([
requirements_block(requested),
"OUTPAINT ONLY: Image 1 contains an original photo placed on a 3:4 canvas. Generate pixels only "
"in the transparent masked margins outside the original photo. Do not crop, move, resize, redraw, "
"retouch, recolor, relight, or otherwise alter any pixel of the original photo.",
"Do not add marketing layouts, product captions, overlays, props, or visible text unless the STRICT "
"REQUIREMENTS explicitly ask for them. Make the new margins blend naturally with the original edges.",
"FINAL CHECK: output exactly 3:4 and change only the newly expanded margins.",
])
async def process_item(claim: ClaimedItem) -> None:
"""推进一次子任务。外部模型未完成时只写 provider id 并退出,让出 Worker。"""
if claim.job_kind == "watermark_batch":
@@ -174,18 +191,34 @@ async def process_item(claim: ClaimedItem) -> None:
"pcore": "gpt-image-2",
}.get(provider, settings.ark_image_model)
try:
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"),
)
refs = _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
outpaint: OutpaintCanvas | None = None
if operation == "outpaint":
if len(refs) != 1:
raise RuntimeError("仅扩图必须且只能使用一张参考底图")
prompt = _outpaint_prompt(payload.get("requirements"))
# 首次提交时才构造图片和蒙版;之后轮询不再向供应商重复上传。
if not claim.provider_task_id:
outpaint = await prepare_outpaint_canvas(refs[0], target_size)
refs = [outpaint.image_data_url]
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")),
)
is_wan = provider == "tongyi" and model.lower().startswith("wan")
result = await advance_generation(
provider, prompt, refs,
size=_image_size(provider, claim.ratio, is_wan=is_wan, model=model), model=model,
size=target_size, model=model,
provider_task_id=claim.provider_task_id, provider_task_kind=claim.provider_task_kind,
edit_mask=edit_mask,
)
if result.data is None:
if not result.provider_task_id or not result.provider_task_kind:
@@ -203,6 +236,11 @@ async def process_item(claim: ClaimedItem) -> None:
await mark_item_cancelled(claim.item_id)
return
if operation == "outpaint":
# 异步任务完成时本轮未必创建过画布,按同一源图与尺寸确定性重建后覆盖原图区域。
outpaint = outpaint or await prepare_outpaint_canvas(refs[0], target_size)
result = type(result)(data=outpaint.restore_original(result.data))
watermark = payload.get("watermark") or {}
position = int(job_item.get("position", 0))
if watermark.get("enabled"):
+4 -4
View File
@@ -56,11 +56,11 @@ SYSTEM_PROMPT = """你是一名资深电商视觉策划。根据商品信息规
5. variant_name 只能从「SKU规格」列表原样照抄;没有绑定就输出 null。
## 规划规则
1. 套图必须均衡覆盖:白底主图、场景图、细节图;参数含可靠的长宽高/尺寸时还必须有尺寸标注图。按这个顺序先预留张数,再安排其他类型。
1. 套图必须均衡覆盖:白底主图、场景图、细节图;参数含可靠的任一方向尺寸时还必须有尺寸标注图。按这个顺序先预留张数,再安排其他类型。
2. 白底主图(kind=white_bg)通常只出 1 张;有多个视觉差异明显的 SKU 时最多选 2 个代表款各出 1 张,variant_name 填对应规格名。**禁止因为 SKU 多而为每个 SKU 都规划主图**。仅当「生图要求」明确要求每个/全部 SKU 分别出图时才可例外。
3. 场景图(kind=lifestyle):至少 1 张,按核心使用场景出 1-2 张,每张聚焦一个场景。
4. 细节图(kind=material / key_features / selling_pt / ecommerce_detail):至少 1 张,展示关键细节、材质或结构;通常 1-2 张,每张聚焦一个卖点。
5. 尺寸标注图(kind=size_chart):参数含长宽高/尺寸时必须出 1 张;无可靠尺寸参数时不要虚构尺寸。
5. 尺寸标注图(kind=size_chart):参数含任一可靠尺寸时必须出 1 张;只标注已知方向,无可靠尺寸参数时不要虚构尺寸。
6. SKU 合集图(kind=sku_collection):仅在 SKU >1 且确有必要展示差异时出 1 张,优先级低于上述四类。
7. kind 枚举:white_bg / key_features / selling_pt / material / lifestyle / multi_scene / ecommerce_detail / size_chart / sku_collection / custom。
8. title 用中文短语(如「主图·粉色」「浴室壁挂」);detail 中文说明这张图展示什么;prompt_hint 用英文描述构图要点。
@@ -124,9 +124,9 @@ def _normalize_items(raw_items: list, sku_variants: list[str]) -> list[dict]:
def _has_dimensions(product_info: dict) -> bool:
"""仅在已采集到尺寸信息时规划尺寸图,避免模型编造数值。"""
"""有任一可靠尺寸即可规划尺寸图;未给出的方向不允许模型编造。"""
dimensions = product_info.get("dimensions") or {}
if isinstance(dimensions, dict) and all(dimensions.get(key) is not None for key in ("length", "width", "height")):
if isinstance(dimensions, dict) and any(dimensions.get(key) is not None for key in ("length", "width", "height")):
return True
for param in product_info.get("params") or []:
if not isinstance(param, dict):
+2 -1
View File
@@ -36,10 +36,11 @@ def prompt_family(provider: str, model: str | None) -> str:
def build_prompt(provider: str, model: str | None, type_id: str, ctx: dict, style_set: int,
lang: str, extra: dict | None = None, style_prompt: str | None = None,
requirements: str | None = None) -> str:
requirements: str | None = None, requirements_exclusive: bool = False) -> str:
"""按模型家族构造指定图类型的完整生图 prompt。参数含义见各家族 build_prompt。"""
module = _MODULE_BY_FAMILY[prompt_family(provider, model)]
return module.build_prompt(
type_id, ctx, style_set, lang,
extra=extra, style_prompt=style_prompt, requirements=requirements,
requirements_exclusive=requirements_exclusive,
)
+14 -3
View File
@@ -96,12 +96,13 @@ def _prompt_ecommerce_detail(ctx: dict, style: dict, lang: str) -> str:
def _prompt_size_chart(ctx: dict, style: dict, lang: str) -> str:
dims = params_line_for_output(ctx, lang)
annotation = ctx.get("dimension_annotation_instruction") or "Do not invent measurement directions or values."
return (
f"Product size chart infographic for \"{ctx['title']}\": product shown in clean front and side views "
f"on light background, with thin measurement annotation lines (arrows) marking length, width and height, "
f"on light background, with thin measurement annotation lines (arrows) only for supplied dimensions, "
f"measurement values rendered next to each line"
+ (f" (known specs: {dims})" if dims else "")
+ f", small caption row, precise technical drawing aesthetic. {style['tone']}. "
+ f". {annotation} Small caption row, precise technical drawing aesthetic. {style['tone']}. "
f"{TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
)
@@ -136,7 +137,8 @@ _PROMPT_BUILDERS = {
def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict | None = None,
style_prompt: str | None = None, requirements: str | None = None) -> str:
style_prompt: str | None = None, requirements: str | None = None,
requirements_exclusive: bool = False) -> str:
"""构造指定图类型的完整生图 prompt(主体参考语义)。
extra: 方案项信息 {title, detail, prompt_hint}——custom 类型必需,
@@ -145,6 +147,15 @@ def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict
requirements: 生图要求(最高优先级,强制约束),置于 prompt 最前面,
声明覆盖一切冲突指令,用户可在此输入强制要求。
"""
if requirements_exclusive:
return " ".join([
requirements_block(requirements),
"Execute only the strict requirements above. Do not add marketing layouts, captions, overlays, "
"props, default studio styling, or visible text unless explicitly requested.",
"Preserve the reference image exactly unless the strict requirements explicitly request a "
"specific change. When they do, make only that requested change and preserve everything else.",
"Do not make any change that was not explicitly required.",
])
style = resolve_style(style_set, style_prompt)
extra = extra or {}
if type_id == "custom":
+23 -5
View File
@@ -134,11 +134,28 @@ def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") -
break
dimensions = raw.get("dimensions") or {}
dimension_values = (dimensions.get("length"), dimensions.get("width"), dimensions.get("height")) if isinstance(dimensions, dict) else ()
dimensions_line = ""
if len(dimension_values) == 3 and all(value is not None for value in dimension_values):
unit = dimensions.get("unit") or "mm"
dimensions_line = "L × W × H: " + " × ".join(str(value) for value in dimension_values) + f" {unit}"
known_dimensions: list[tuple[str, object]] = []
if isinstance(dimensions, dict):
known_dimensions = [
(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"
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]
dimension_annotation_instruction = (
"STRICT MEASUREMENT OUTPUT RULE: render exactly "
+ str(len(known_labels))
+ " measurement arrows and labels, only for: "
+ ", ".join(known_labels)
+ ". The only permitted dimension values are: "
+ dimensions_line
+ ". Do not infer product measurements. Do not draw, label, or show an arrow for: "
+ ", ".join(missing_labels or ["any additional direction"])
+ ", depth, thickness, diameter, or any other unlisted dimension."
if known_dimensions else ""
)
params_line = "; ".join(
f"{p.get('key')}: {p.get('value')}" for p in (raw.get("params") or [])[:8]
)
@@ -158,6 +175,7 @@ def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") -
dimensions_line,
))),
"price": raw.get("price") or "",
"dimension_annotation_instruction": dimension_annotation_instruction,
}
def selling_point_lines(ctx: dict, lang: str, max_n: int = 3) -> str:
+15 -3
View File
@@ -98,12 +98,13 @@ def _task_ecommerce_detail(ctx: dict, lang: str) -> str:
def _task_size_chart(ctx: dict, lang: str) -> str:
dims = params_line_for_output(ctx, lang)
annotation = ctx.get("dimension_annotation_instruction") or "Do not invent measurement directions or values."
return (
"Create a size chart: the unchanged product in clean front and side views on a light background, "
"thin measurement annotation lines (arrows) marking length, width and height with values beside "
"thin measurement annotation lines (arrows) only for supplied dimensions with values beside "
"each line"
+ (f" (known specs: {dims})" if dims else "")
+ ", small caption row, precise technical-drawing aesthetic."
+ f". {annotation} Small caption row, precise technical-drawing aesthetic."
)
def _task_sku_collection(ctx: dict, lang: str) -> str:
@@ -128,8 +129,19 @@ _TASK_BUILDERS = {
def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict | None = None,
style_prompt: str | None = None, requirements: str | None = None) -> str:
style_prompt: str | None = None, requirements: str | None = None,
requirements_exclusive: bool = False) -> str:
"""构造指定图类型的 prompt:要求块 → 指令 → 主体锁 → 锚定 → 风格 → 文案 → 画质 → 提醒。"""
if requirements_exclusive:
return "\n\n".join([
requirements_block(requirements),
"Edit the first image only as required above. Do not add marketing layouts, captions, overlays, "
"props, default studio styling, or visible text unless explicitly requested above.",
"PROTECTION: preserve the first image exactly unless the strict requirements explicitly request "
"a specific change. When they do, make only that requested change and preserve everything else.",
"Final check: follow the strict requirements over every default behavior; do not make changes "
"that were not explicitly required.",
])
style = resolve_style(style_set, style_prompt)
extra = extra or {}
hint = (extra.get("prompt_hint") or "").strip()
+18 -4
View File
@@ -129,12 +129,13 @@ def _task_ecommerce_detail(ctx: dict, lang: str) -> str:
def _task_size_chart(ctx: dict, lang: str) -> str:
dims = params_line_for_output(ctx, lang)
annotation = ctx.get("dimension_annotation_instruction") or "Do not invent measurement directions or values."
return (
"TASK: Measurement chart. Show the unchanged product in clean front and side views on a light "
"background; overlay thin technical annotation lines (arrows) marking length, width and height "
"with measurement values rendered beside each line"
"background; overlay thin technical annotation lines (arrows) only for the supplied dimensions, "
"with each measurement value rendered beside its line"
+ (f" (known specs: {dims})" if dims else "")
+ "; precise technical-drawing aesthetic, small caption row."
+ f". {annotation}; precise technical-drawing aesthetic, small caption row."
)
def _task_sku_collection(ctx: dict, lang: str) -> str:
@@ -160,8 +161,21 @@ _TASK_BUILDERS = {
def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict | None = None,
style_prompt: str | None = None, requirements: str | None = None) -> str:
style_prompt: str | None = None, requirements: str | None = None,
requirements_exclusive: bool = False) -> str:
"""构造指定图类型的 edits 语义 prompt:要求块 → 编辑指令 → 保真锁 → 锚定 → 风格 → 文案 → 画质 → 终检。"""
if requirements_exclusive:
# 单张 AI 编辑:用户要求是唯一的画面意图,绝不能被套图的营销图/棚拍/俄文文案覆盖。
return "\n\n".join([
requirements_block(requirements),
"TASK: Edit Image 1 only according to the STRICT REQUIREMENTS above. Do not add marketing "
"layouts, captions, overlays, props, default studio styling, or any visible text unless the "
"STRICT REQUIREMENTS explicitly ask for them.",
"PROTECTION: preserve Image 1 exactly unless the STRICT REQUIREMENTS explicitly request a "
"specific change. When they do, make only that requested change and preserve everything else.",
"FINAL CHECK: follow the STRICT REQUIREMENTS over every default behavior. Preserve the product "
"and do not make any change that is not explicitly required.",
])
style = resolve_style(style_set, style_prompt)
extra = extra or {}
hint = (extra.get("prompt_hint") or "").strip()
+22 -12
View File
@@ -58,11 +58,16 @@ async def _run_process(
should_cancel: Callable[[], Awaitable[bool]],
timeout_seconds: int,
) -> None:
process = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
try:
process = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError as exc:
raise VideoRenderError(
f"未找到视频合成工具「{args[0]}」。请安装 FFmpeg,并配置 VIDEO_FFMPEG_PATH 为其可执行文件的绝对路径。"
) from exc
try:
waited = 0.0
while process.returncode is None:
@@ -91,13 +96,18 @@ async def _run_process(
async def _probe(path: Path) -> dict:
settings = get_settings()
process = await asyncio.create_subprocess_exec(
settings.video_ffprobe_path,
"-v", "error", "-show_entries", "format=duration,size:stream=codec_name,width,height,pix_fmt",
"-of", "json", str(path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
process = await asyncio.create_subprocess_exec(
settings.video_ffprobe_path,
"-v", "error", "-show_entries", "format=duration,size:stream=codec_name,width,height,pix_fmt",
"-of", "json", str(path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError as exc:
raise VideoRenderError(
f"未找到视频校验工具「{settings.video_ffprobe_path}」。请安装 FFmpeg,并配置 VIDEO_FFPROBE_PATH 为其可执行文件的绝对路径。"
) from exc
stdout, stderr = await process.communicate()
if process.returncode != 0:
raise VideoRenderError(f"无法校验视频输出:{stderr.decode('utf-8', errors='replace')[-400:]}")
+21 -1
View File
@@ -1,8 +1,10 @@
"""单图导出响应头回归测试。"""
from __future__ import annotations
import io
import sys
import unittest
import zipfile
from pathlib import Path
from unittest.mock import AsyncMock, patch
@@ -10,7 +12,7 @@ SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from api.export import ExportImageItem, export_image
from api.export import ExportImageItem, ExportImagesRequest, export_image, export_images
class ExportImageTests(unittest.IsolatedAsyncioTestCase):
@@ -27,6 +29,24 @@ class ExportImageTests(unittest.IsolatedAsyncioTestCase):
self.assertIn('filename="_____-__.png"', disposition)
self.assertIn("filename*=UTF-8''%E5%B0%BA%E5%AF%B8%E6%A0%87%E6%B3%A8%E5%9B%BE-%E8%93%9D%E8%89%B2.png", disposition)
async def test_flat_export_places_generated_images_in_product_root(self) -> None:
request = ExportImagesRequest(
title="商品",
flat=True,
images=[
ExportImageItem(url="https://example.com/single.png", groupName="单张生图", key="single"),
ExportImageItem(url="https://example.com/suite.png", groupName="套图结果", key="suite"),
],
)
with patch("api.export._download", new=AsyncMock(return_value=(b"image", "image/png"))):
response = await export_images(request)
payload = b"".join([chunk async for chunk in response.body_iterator])
self.assertEqual(
sorted(zipfile.ZipFile(io.BytesIO(payload)).namelist()),
["商品/single.png", "商品/suite.png"],
)
if __name__ == "__main__":
unittest.main()
+22
View File
@@ -0,0 +1,22 @@
"""平台目标比例到供应商尺寸参数的映射。"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from services.generator import _image_size
class ImageSizeTests(unittest.TestCase):
def test_pcore_ozon_size_is_exactly_three_to_four(self) -> None:
width, height = map(int, _image_size("pcore", "3:4").split("x"))
self.assertEqual(width * 4, height * 3)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,70 @@
"""仅扩图的像素保护与单张 AI 编辑提示词回归测试。"""
from __future__ import annotations
import asyncio
import base64
import io
import sys
import unittest
from pathlib import Path
from PIL import Image
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from services.generator import prepare_outpaint_canvas
from services.prompts import build_prompt
def _data_url(image: Image.Image) -> str:
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode()
class OutpaintTests(unittest.TestCase):
def test_outpaint_restores_original_region_after_provider_response(self) -> None:
original = Image.new("RGB", (400, 400), (220, 20, 60))
canvas = asyncio.run(prepare_outpaint_canvas(_data_url(original), "1152x1536"))
self.assertEqual(canvas.size, (1152, 1536))
self.assertEqual(canvas.box, (0, 192, 1152, 1344))
# 模拟供应商把整张图都重绘成蓝色;服务端合成后中心原图必须仍为红色。
generated = Image.new("RGB", canvas.size, (0, 0, 255))
restored = Image.open(io.BytesIO(canvas.restore_original(_png_bytes(generated)))).convert("RGB")
self.assertEqual(restored.getpixel((576, 768)), (220, 20, 60))
self.assertEqual(restored.getpixel((576, 80)), (0, 0, 255))
def test_expanded_margin_is_transparent_in_mask(self) -> None:
original = Image.new("RGB", (400, 400), (1, 2, 3))
canvas = asyncio.run(prepare_outpaint_canvas(_data_url(original), "1152x1536"))
mask_bytes = base64.b64decode(canvas.mask_data_url.split(",", 1)[1])
mask = Image.open(io.BytesIO(mask_bytes)).convert("RGBA")
self.assertEqual(mask.getpixel((576, 80))[3], 0)
self.assertEqual(mask.getpixel((576, 768))[3], 255)
def _png_bytes(image: Image.Image) -> bytes:
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return buffer.getvalue()
class ExclusiveEditPromptTests(unittest.TestCase):
def test_user_edit_requirements_do_not_inherit_marketing_or_light_studio_prompt(self) -> None:
prompt = build_prompt(
"pcore", "gpt-image-2", "custom", {"title": "product"}, 5, "ru",
requirements="背景必须是纯黑色,其他不要改变",
requirements_exclusive=True,
)
self.assertIn("背景必须是纯黑色", prompt)
self.assertNotIn("e-commerce marketing image", prompt)
self.assertNotIn("plain light background", prompt)
self.assertNotIn("Render concise Russian marketing text", prompt)
if __name__ == "__main__":
unittest.main()
+44
View File
@@ -0,0 +1,44 @@
"""部分尺寸的尺寸标注图:仅传入已知方向,绝不补造缺失方向。"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
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 _with_dimensions
from schemas.suite import ProductDimensions
from services.prompts.common import build_context
from services.prompts.gpt import _task_size_chart
class PartialDimensionsTests(unittest.TestCase):
def test_only_known_direction_is_passed_to_prompt_context(self) -> None:
raw = _with_dimensions(
{"title": "测试商品", "params": [
{"key": "产品尺寸", "value": "12.5×8×4 cm"},
{"key": "材质", "value": "棉"},
]},
ProductDimensions(width=8, height=4, unit="cm"),
required=True,
)
self.assertEqual(raw["dimensions"], {"width": 8, "height": 4, "unit": "cm"})
self.assertEqual(raw["params"], [{"key": "材质", "value": "棉"}])
context = build_context(raw)
prompt = _task_size_chart(context, "en")
self.assertIn("width: 8.0 cm; height: 4.0 cm", prompt)
self.assertIn("render exactly 2 measurement arrows and labels, only for: width, height", prompt)
self.assertIn("Do not draw, label, or show an arrow for: length", prompt)
self.assertNotIn("12.5×8×4", prompt)
def test_size_chart_still_requires_at_least_one_dimension(self) -> None:
with self.assertRaisesRegex(Exception, "至少需要填写一项"):
_with_dimensions({"title": "测试商品"}, ProductDimensions(unit="mm"), required=True)
if __name__ == "__main__":
unittest.main()