feat: AI生图功能丰富
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user