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
+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)