fix(ai): 平台套图改走商品主图参考 image_edit · 模特上身图锁商品+模特双图

- 平台套图(cover)以前纯文生图,不参考真实商品主图 → 出图与商品对不上;
  现以商品主图为参考图1走 image_edit,锁包装一致性,有模特则参考图2=模特
- 新增 build_platform_cover_prompt_refs;model_url 解析对 cover 模式同样生效
- 补回归测试:cover 传商品主图、model 传商品主图+模特图、无主图回落文生图

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-17 21:58:39 +08:00
co-authored by Claude Opus 4.8
parent 7c6d4477da
commit 0695c36f4c
12 changed files with 455 additions and 126 deletions
+98 -13
View File
@@ -576,6 +576,17 @@ def _find_entity_group(project, kind: str, label: str, group_id: str | None):
return None
def _ratio_to_image_size(ratio: str) -> str:
"""前端比例(1:1 / 3:4 / 9:16)→ gpt-image 支持的尺寸。竖屏统一 1024x1536,横屏 1536x1024。"""
return {
"1:1": "1024x1024",
"3:4": "1024x1536",
"9:16": "1024x1536",
"4:3": "1536x1024",
"16:9": "1536x1024",
}.get((ratio or "").strip(), "1024x1024")
def _product_cover_url(product) -> str:
"""商品主图 URL:优先 cover_asset,其次标记为主图的商品图,再次首张商品图。无图返回 ''"""
if product is None:
@@ -604,6 +615,50 @@ def build_product_triview_prompt_refs(product, base_prompt: str = "") -> str:
return " ".join(lines)
def build_model_tryon_prompt_refs(product, has_model: bool, base_prompt: str = "") -> str:
"""模特上身图 image_edit 提示词(refs 版):
参考图1=商品真实主图(锁商品外形/品牌/配色),参考图2=选中模特(锁人脸/身形/气质)。
生成「该模特自然展示/使用该商品」的电商效果图。"""
name = (getattr(product, "title", "") or "商品").strip()
lines = [f"参考图1是「{name}」的真实商品。"]
if has_model:
lines += [
"参考图2是出镜模特。请生成参考图2中的这位模特自然地展示/佩戴/使用参考图1中商品的电商效果图。",
"模特的五官、发型、肤色、身形与气质必须与参考图2高度一致,不要换人;",
"商品的外形、品牌文字、配色、Logo 必须与参考图1高度一致,不要改动或重新设计。",
]
else:
lines += [
"请生成一位真人模特自然地展示/佩戴/使用参考图1中商品的电商效果图。",
"商品的外形、品牌文字、配色、Logo 必须与参考图1高度一致,不要改动或重新设计。",
]
lines.append("自然光、真实质感、干净背景、电商主图构图,人物与商品比例真实协调。")
if base_prompt and base_prompt.strip():
lines.append(base_prompt.strip())
return " ".join(lines)
def build_platform_cover_prompt_refs(product, has_model: bool, base_prompt: str = "") -> str:
"""平台套图 image_edit 提示词(refs 版):参考图1=商品真实主图(锁外形/品牌/配色/Logo),
有模特时参考图2=出镜模特(锁人脸/身形)。生成电商平台主图 / 封面套图,商品须还原真实包装。"""
name = (getattr(product, "title", "") or "商品").strip()
lines = [f"参考图1是「{name}」的真实商品主图。"]
if has_model:
lines += [
"参考图2是出镜模特。请生成参考图2中的这位模特展示参考图1中商品的电商平台套图(主图 / 封面 / 详情);",
"模特的五官、发型、肤色、身形必须与参考图2高度一致,不要换人;",
]
else:
lines.append("请基于该商品生成电商平台套图(主图 / 封面 / 详情排版),统一视觉风格;")
lines.append(
"商品的外形、品牌文字、配色、Logo、材质必须与参考图1高度一致,严禁改动或重新设计包装;"
"干净背景、电商主图构图、真实质感。"
)
if base_prompt and base_prompt.strip():
lines.append(base_prompt.strip())
return " ".join(lines)
def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "", group_id: str | None = None) -> AITask:
"""提交基础资产生成(**异步**):Web 请求只建 RESERVED 任务 + 预留额度(秒级),
慢出图(文生图 / 商品 image_edit)交给 Celery worker(run_base_asset_task)跑。
@@ -1349,7 +1404,7 @@ def _reap_stale_standalone_image_tasks(*, team) -> None:
continue
def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False) -> list[AITask]:
def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, ratio: str | None = None) -> list[AITask]:
"""独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 +
预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。
@@ -1375,7 +1430,7 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
status=AITask.Status.CREATED,
model_config=model_config,
idempotency_key=f"standalone-image:{team.id}:{uuid.uuid4()}",
request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product)},
request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product), "model_id": str(model_id) if model_id else None, "ratio": str(ratio) if ratio else None},
estimated_cost=cost,
)
# 预留额度若余额不足会抛 ValueError,在同步的 Web 请求里立刻反馈给前端(不会先建半套任务)
@@ -1402,25 +1457,54 @@ def run_standalone_image_task(*, task_id: str) -> None:
mode = str(payload.get("mode") or "image")
index = int(payload.get("index") or 0)
product_id = payload.get("product_id") or None
category = _STANDALONE_CATEGORY.get(mode, Asset.Category.UNCATEGORIZED)
# 模特上身图(mode=model 且绑了商品)= 该商品的商品图,归到对应商品的 AI 资产,不进人物库;
# 「生成演员」同样走 mode=model 但无 product_id,仍归人物库(PERSON)。
if mode == "model" and product_id:
category = Asset.Category.PRODUCT_IMAGE
else:
category = _STANDALONE_CATEGORY.get(mode, Asset.Category.UNCATEGORIZED)
model_config = task.model_config
provider = get_image_provider(model_config)
reservation = task.credit_reservation
# 商品三视图(商品详情页按 reference_product 提交):有真实商品主图 + 模型支持 image_edit →
# 以主图为参考锁包装一致性,否则回落纯文生图(仅凭商品名脑补,不保证还原真实包装)。
ref_url = ""
# 出图策略(都优先 image_edit 锁真实素材,模型不支持/无素材才回落纯文生图):
# · 模特上身图(mode=model):参考图1=商品真实主图 + 参考图2=选中模特 → 生成「该模特用该商品」效果图;
# · 平台套图(mode=cover):参考图1=商品真实主图(+ 有模特则参考图2=模特)→ 锁包装一致性出套图;
# · 商品三视图(reference_product):参考图1=商品真实主图 → 锁包装一致性;
# · 其余(图片创作):纯文生图。
product = None
if bool(payload.get("reference_product")) and product_id:
if product_id:
from apps.products.models import Product
product = Product.objects.filter(id=product_id).first()
if product is not None:
ref_url = _product_cover_url(product)
use_edit = bool(ref_url) and hasattr(provider, "image_edit")
can_edit = hasattr(provider, "image_edit")
model_url = ""
if payload.get("model_id"):
model_asset = Asset.objects.filter(id=payload.get("model_id")).first()
if model_asset is not None:
model_url = _asset_preview_url(model_asset)
product_url = _product_cover_url(product) if product is not None else ""
edit_images: list[str] = []
edit_prompt = ""
if mode == "model" and can_edit and product_url:
# 模特上身图:商品图必有,模特图可缺(缺则让模型自取真人模特)
edit_images = [product_url] + ([model_url] if model_url else [])
edit_prompt = build_model_tryon_prompt_refs(product, has_model=bool(model_url), base_prompt=prompt)
elif mode == "cover" and can_edit and product_url:
# 平台套图:参考图1=商品真实主图(锁包装一致性),有模特则参考图2=模特(锁人脸/身形)
edit_images = [product_url] + ([model_url] if model_url else [])
edit_prompt = build_platform_cover_prompt_refs(product, has_model=bool(model_url), base_prompt=prompt)
elif bool(payload.get("reference_product")) and can_edit and product_url:
edit_images = [product_url]
edit_prompt = build_product_triview_prompt_refs(product, "")
use_edit = bool(edit_images)
try:
if use_edit:
edit_prompt = build_product_triview_prompt_refs(product, "")
response = provider.image_edit(model=model_config.name, prompt=edit_prompt, images=[ref_url], size="1536x1024")
if payload.get("reference_product"):
size = "1536x1024" # 三视图固定横向
else:
size = _ratio_to_image_size(str(payload.get("ratio") or "")) # 模特图按选中比例
response = provider.image_edit(model=model_config.name, prompt=edit_prompt, images=edit_images, size=size)
else:
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt)
media = provider.extract_first_media_url(response)
@@ -1436,8 +1520,9 @@ def run_standalone_image_task(*, task_id: str) -> None:
asset_id = uuid.uuid4()
object_key = f"teams/{team.id}/standalone/{asset_id}{suffix}"
stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type)
asset_label = {"model": "模特上身图", "cover": "平台套图", "image": "图片创作"}.get(mode, mode)
asset = Asset.objects.create(
id=asset_id, team=team, created_by=user, name=f"AI 生成 · {mode} · {index + 1}",
id=asset_id, team=team, created_by=user, name=f"AI 生成 · {asset_label} · {index + 1}",
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=category, origin_task=task,
# 记下生图时选中的商品,商品详情页据此只展示「该商品」的 AI 素材(而非全团队)
metadata={"product_id": str(product_id)} if product_id else {},