399 lines
18 KiB
Python
399 lines
18 KiB
Python
"""套图规划 / 一键生成 / 任务查询 / 结果导出 / 单张 AI 生图。
|
||
|
||
按 docs/v2.1/api.md §2-6 实现:引擎平移自 image-suite-studio(services/planner|generator|
|
||
watermark + services/prompts),任务持久化到数据库,由独立 Worker 执行。
|
||
生成图回调回写 product_assets(group_key='generated');product_id 缺省时只落盘不回写。
|
||
业务逻辑(texts_to_raw / 模型校验 / generated 回写)在 services/suite_service.py。
|
||
注:V2.1 阶段本组接口暂不接鉴权(现有鉴权后续可能重做)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
import re
|
||
import tempfile
|
||
import uuid
|
||
import zipfile
|
||
from collections.abc import Iterator
|
||
from fastapi import APIRouter, HTTPException
|
||
from fastapi.responses import StreamingResponse
|
||
|
||
from config import get_settings
|
||
from schemas.suite import (
|
||
PLATFORM_SPECS,
|
||
SUPPORTED_TYPES,
|
||
ImageEditSingleRequest,
|
||
ImageEditTaskResponse,
|
||
PlanItemOut,
|
||
PlanResponse,
|
||
SuiteCreateResponse,
|
||
SuiteGenerateRequest,
|
||
SuiteOut,
|
||
SuitePlanRequest,
|
||
api_model,
|
||
resolve_provider,
|
||
)
|
||
from services.generator import GENERATORS
|
||
from services.jobs import (
|
||
ITEM_DONE,
|
||
cancel_job,
|
||
create_image_job,
|
||
get_job,
|
||
list_product_jobs,
|
||
retry_failed_items,
|
||
retry_failed_item,
|
||
suite_view,
|
||
)
|
||
from services.planner import generate_plan
|
||
from services.prompts import type_name
|
||
from services.storage import ResourceSizeLimitError, download_bytes, local_path, read_local_bytes
|
||
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="生成尺寸标注图至少需要填写一项商品尺寸")
|
||
return raw
|
||
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="生成尺寸标注图至少需要填写一项商品尺寸")
|
||
return raw
|
||
enriched = dict(raw)
|
||
# 表单尺寸是尺寸标注图的唯一事实来源。原始商品参数常含完整 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
|
||
|
||
|
||
def _stream_file(handle: io.BufferedRandom, chunk_size: int = 64 * 1024) -> Iterator[bytes]:
|
||
"""以固定块返回临时 ZIP,释放响应完成后的临时文件。"""
|
||
try:
|
||
while chunk := handle.read(chunk_size):
|
||
yield chunk
|
||
finally:
|
||
handle.close()
|
||
|
||
|
||
# ── 出图方案规划 ──────────────────────────────────────────────────────────
|
||
|
||
@router.post("/suite/plan", response_model=PlanResponse)
|
||
async def plan_suite(req: SuitePlanRequest) -> PlanResponse:
|
||
"""DeepSeek 根据商品信息生成出图方案(同步接口,数秒级)。"""
|
||
product_info = _with_dimensions(texts_to_raw(req.texts), req.dimensions)
|
||
if not product_info.get("title"):
|
||
raise HTTPException(status_code=400, detail="缺少商品标题,无法规划")
|
||
try:
|
||
result = await generate_plan(
|
||
product_info, req.sku_variants, req.image_stats, req.platform,
|
||
requirements=req.requirements,
|
||
)
|
||
except RuntimeError as exc:
|
||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||
except Exception as exc: # noqa: BLE001
|
||
raise HTTPException(status_code=502, detail=f"规划失败: {exc}") from exc
|
||
return PlanResponse(summary=result["summary"], items=[PlanItemOut(**i) for i in result["items"]])
|
||
|
||
|
||
# ── 一键生成 ─────────────────────────────────────────────────────────────
|
||
|
||
@router.post("/suite/generate", response_model=SuiteCreateResponse)
|
||
async def generate_suite(req: SuiteGenerateRequest) -> SuiteCreateResponse:
|
||
if not req.images:
|
||
raise HTTPException(status_code=400, detail="未勾选任何图片,无法生成")
|
||
|
||
# 生成任务列表:方案优先;无方案时按 types(空则默认四种)
|
||
if req.plan:
|
||
jobs: list[dict] = []
|
||
for item in req.plan:
|
||
if item.count <= 0:
|
||
continue
|
||
if item.kind not in SUPPORTED_TYPES:
|
||
raise HTTPException(status_code=400, detail=f"方案项「{item.title}」的图类型不支持: {item.kind}")
|
||
# 同一项多张 → 展开为多任务,第二张起在标题上加序号
|
||
for n in range(item.count):
|
||
jobs.append({
|
||
"kind": item.kind,
|
||
"title": item.title if item.count == 1 else f"{item.title}{n + 1}",
|
||
"detail": item.detail,
|
||
"prompt_hint": item.prompt_hint,
|
||
"variant_name": item.variant_name,
|
||
})
|
||
if not jobs:
|
||
raise HTTPException(status_code=400, detail="方案中所有项的数量都是 0")
|
||
else:
|
||
types = req.types or ["white_bg", "key_features", "lifestyle", "multi_scene"]
|
||
bad = [t for t in types if t not in SUPPORTED_TYPES]
|
||
if bad:
|
||
raise HTTPException(status_code=400, detail=f"不支持的图类型: {bad}")
|
||
jobs = [{"kind": t, "title": type_name(t), "detail": "", "prompt_hint": "", "variant_name": None} for t in types]
|
||
|
||
if req.platform not in PLATFORM_SPECS:
|
||
raise HTTPException(status_code=400, detail=f"不支持的目标平台: {req.platform}(ozon | wb | cn)")
|
||
spec = PLATFORM_SPECS[req.platform]
|
||
|
||
settings = get_settings()
|
||
# 前端只传模型名:已知模型直接路由到对应 provider(如 gpt-image-2-vip → rightapi、gpt-image-2@pcore → pcore)
|
||
provider_name = resolve_provider(req.model, None, settings.image_provider)
|
||
validate_model(provider_name, req.model)
|
||
model = api_model(req.model) # 剥 @provider 后缀,供应商侧只认裸模型名
|
||
|
||
product_id = (req.product_id or "").strip() or None
|
||
if product_id:
|
||
try:
|
||
uuid.UUID(product_id)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc
|
||
|
||
task = await create_image_job(
|
||
product_id=product_id,
|
||
kind="image_suite",
|
||
priority=0,
|
||
provider=provider_name,
|
||
model=model,
|
||
platform=req.platform,
|
||
lang=spec["lang"],
|
||
ratio=spec["ratio"],
|
||
style_set=req.style_set,
|
||
request_payload={
|
||
"context": _with_dimensions(texts_to_raw(req.texts), req.dimensions),
|
||
"style_prompt": req.style_prompt,
|
||
"requirements": req.requirements,
|
||
"watermark": req.watermark.model_dump() if req.watermark else None,
|
||
"append": bool(product_id),
|
||
# main 组优先;variant_name 精确匹配由 Worker 复用原有选图规则。
|
||
"ref_images": [
|
||
{"url": image.url, "group_key": image.group_key, "variant_name": image.variant_name}
|
||
for image in sorted(req.images, key=lambda image: 0 if image.group_key == "main" else 1)
|
||
],
|
||
},
|
||
item_payloads=[{**job, "position": index} for index, job in enumerate(jobs)],
|
||
)
|
||
return SuiteCreateResponse(suite_id=str(task.id))
|
||
|
||
|
||
# ── 任务查询 / 结果导出 ──────────────────────────────────────────────────
|
||
|
||
@router.get("/suites/{suite_id}", response_model=SuiteOut)
|
||
async def get_suite(suite_id: str):
|
||
result = await get_job(suite_id)
|
||
if result is None:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
return SuiteOut(**suite_view(*result))
|
||
|
||
|
||
@router.get("/products/{product_id}/suites", response_model=list[SuiteOut])
|
||
async def list_suites(product_id: str, active_only: bool = False):
|
||
"""试算页恢复商品的未完成任务;后续任务中心可复用。"""
|
||
jobs = await list_product_jobs(product_id, active_only=active_only, kinds=("image_suite", "image_edit"))
|
||
views: list[SuiteOut] = []
|
||
for job in jobs:
|
||
result = await get_job(str(job.id))
|
||
if result is not None:
|
||
views.append(SuiteOut(**suite_view(*result)))
|
||
return views
|
||
|
||
|
||
@router.post("/suites/{suite_id}/cancel", response_model=SuiteOut)
|
||
async def cancel_suite(suite_id: str):
|
||
task = await cancel_job(suite_id)
|
||
if task is None:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
result = await get_job(str(task.id))
|
||
if result is None:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
return SuiteOut(**suite_view(*result))
|
||
|
||
|
||
@router.post("/suites/{suite_id}/retry", response_model=SuiteOut)
|
||
async def retry_suite(suite_id: str):
|
||
task = await retry_failed_items(suite_id)
|
||
if task is None:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
result = await get_job(str(task.id))
|
||
if result is None:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
return SuiteOut(**suite_view(*result))
|
||
|
||
|
||
@router.post("/suites/{suite_id}/items/{item_id}/retry", response_model=SuiteOut)
|
||
async def retry_suite_item(suite_id: str, item_id: str):
|
||
task = await retry_failed_item(suite_id, item_id)
|
||
if task is None:
|
||
raise HTTPException(status_code=404, detail="任务或子项不存在")
|
||
result = await get_job(str(task.id))
|
||
if result is None:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
return SuiteOut(**suite_view(*result))
|
||
|
||
|
||
@router.get("/suites/{suite_id}/zip")
|
||
async def download_suite_zip(suite_id: str):
|
||
"""把任务内所有成功图打包成 ZIP(中文文件名)。"""
|
||
result = await get_job(suite_id)
|
||
if result is None:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
task, task_items = result
|
||
|
||
settings = get_settings()
|
||
images = [image for image in task_items if image.status == ITEM_DONE and image.result_url]
|
||
if len(images) > settings.export_max_images:
|
||
raise HTTPException(status_code=413, detail=f"该任务有 {len(images)} 张图片,单次最多导出 {settings.export_max_images} 张")
|
||
|
||
archive = tempfile.SpooledTemporaryFile(
|
||
max_size=settings.export_zip_memory_threshold_bytes,
|
||
mode="w+b",
|
||
)
|
||
total_source_bytes = 0
|
||
try:
|
||
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
|
||
seen: set[str] = set()
|
||
for i, img in enumerate(images):
|
||
path = local_path(img.result_url or "")
|
||
try:
|
||
if path is not None:
|
||
data, suffix = read_local_bytes(path), path.suffix or ".jpg"
|
||
else: # 七牛等远程存储:直接拉取公开 URL
|
||
data, _ = await download_bytes(img.result_url or "")
|
||
suffix = ".png" if data[:8] == b"\x89PNG\r\n\x1a\n" else ".jpg"
|
||
except ResourceSizeLimitError as exc:
|
||
raise HTTPException(status_code=413, detail=f"无法导出「{img.name}」:{exc}") from exc
|
||
except Exception: # noqa: BLE001
|
||
continue
|
||
|
||
total_source_bytes += len(data)
|
||
if total_source_bytes > settings.export_zip_max_bytes:
|
||
raise HTTPException(
|
||
status_code=413,
|
||
detail=f"导出图片总大小超过 {settings.export_zip_max_bytes // 1024 // 1024} MB,请减少图片后重试",
|
||
)
|
||
payload = img.payload or {}
|
||
filename = payload.get("title") or payload.get("kind") or "AI 生图"
|
||
if filename in seen: # 同类型多张时加序号防覆盖
|
||
filename = f"{filename}-{i + 1}"
|
||
seen.add(filename)
|
||
zf.writestr(f"{filename}{suffix}", data)
|
||
if archive.tell() > settings.export_zip_max_bytes:
|
||
raise HTTPException(
|
||
status_code=413,
|
||
detail=f"导出 ZIP 超过 {settings.export_zip_max_bytes // 1024 // 1024} MB,请减少图片后重试",
|
||
)
|
||
except Exception:
|
||
archive.close()
|
||
raise
|
||
|
||
archive.seek(0)
|
||
return StreamingResponse(
|
||
_stream_file(archive),
|
||
media_type="application/zip",
|
||
headers={"Content-Disposition": f"attachment; filename=\"suite-{suite_id}.zip\""},
|
||
)
|
||
|
||
|
||
# ── 单张 AI 生图(V2.1 新增,ISS 无对应实现)──────────────────────────────
|
||
|
||
@router.post("/suite/image-edit", response_model=ImageEditTaskResponse)
|
||
async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse:
|
||
"""提交单张图生图任务;立即返回任务 ID,实际生成在统一队列异步执行。"""
|
||
settings = get_settings()
|
||
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": 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:
|
||
try:
|
||
uuid.UUID(product_id)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc
|
||
|
||
# 单图编辑优先级高于套图;支持多张参考底图,保持传入顺序并去重。
|
||
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":
|
||
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="仅扩图不能同时生成尺寸标注图")
|
||
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)
|
||
task = await create_image_job(
|
||
product_id=product_id,
|
||
kind="image_edit",
|
||
priority=10,
|
||
provider=provider_name,
|
||
model=model,
|
||
platform="ozon",
|
||
lang=spec["lang"],
|
||
ratio=spec["ratio"],
|
||
# 仅扩图与用户明确填写的 AI 编辑要求都不得继承默认「纯净棚拍」风格。
|
||
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": 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,
|
||
# 用户写了要求时,这一轮只执行其要求,不再拼营销图、默认风格或默认俄文文案。
|
||
"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": 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 ("仅扩图" if req.mode == "outpaint" else "AI生图"),
|
||
"detail": "",
|
||
"prompt_hint": "",
|
||
"variant_name": None,
|
||
"prompt_extra": {"title": "", "detail": "", "prompt_hint": ""},
|
||
}],
|
||
)
|
||
return ImageEditTaskResponse(suite_id=str(task.id))
|