模特上身图提示词重构 + 图片创作页 UI 调整

后端(模特上身图提示词):
- build_model_tryon_prompt_refs 重写:穿戴/非穿戴分流(穿戴=真实穿身替换原衣,
  非穿戴=手持/佩戴/使用不动原衣)、每张按 index 变化动作/场景/镜头、负面词尾接、
  多图参考序号自适应(参考图1~N=商品,参考图N+1=模特)
- 新增 _product_reference_urls:商品参考图真实上传图优先、排除 AI 生成图、可多张(≤3),
  无真实图回落 cover
- worker run_standalone_image_task 模特分支改用多图取图 + 传 index/n_product

前端(图片创作/工作室):
- 生成数量改 1/2/4;图片比例新增「手动输入」(宽:高 两输入框)
- 临时隐藏「商品库」按钮
- 模特卡:去掉 // 真人模特,标题改图片底部白字遮罩层 + 单行省略
- 工作室壳负边距对齐 .content padding,修复上下被遮挡/裁切

其他:并入此前未提交的商品页改动、脚本 Agent/格式实测文档与 demo
This commit is contained in:
zyc
2026-06-27 09:31:44 +08:00
parent 4c8b2d1902
commit de4b20cc7b
24 changed files with 2350 additions and 127 deletions
+95 -18
View File
@@ -752,6 +752,35 @@ def _product_cover_url(product) -> str:
return ""
def _product_reference_urls(product, limit: int = 3) -> list[str]:
"""模特上身图的商品参考图(可多张):**真实上传图优先,排除 AI 生成图**——避免拿生成图当真相
再喂回模型造成误差累积。按主图/排序取前 limit 张真实上传图;一张都没有时回落 cover(即便是 AI 图,
至少保证能走 image_edit 而不是纯文生图)。"""
if product is None:
return []
from apps.assets.models import Asset
urls: list[str] = []
seen: set[str] = set()
rels = list(product.images.select_related("asset").all())
rels.sort(key=lambda im: (not im.is_primary, im.sort_order))
for im in rels:
a = im.asset
if a is None or getattr(a, "source", "") == Asset.Source.AI_GENERATED:
continue
u = _asset_preview_url(a)
if u and u not in seen:
seen.add(u)
urls.append(u)
if len(urls) >= limit:
return urls
if not urls: # 无任何真实上传图 → 回落 cover(可能是 AI 图,但好过纯文生图)
cover = _product_cover_url(product)
if cover:
urls.append(cover)
return urls
def quality_words(stage: str, slot: str = "quality") -> list[str]:
"""平台单层质量词配置(QualityWord)。无配置/表不存在 → 返回 [],调用方回落写死值,保证零回归。"""
try:
@@ -815,26 +844,69 @@ def build_product_triview_prompt_refs(product, base_prompt: str = "") -> str:
return render_prompt("product_triview", default, 商品=name, 补充=(base_prompt or "").strip())
def build_model_tryon_prompt_refs(product, has_model: bool, base_prompt: str = "") -> str:
# 模特上身图:按品类分「穿戴 / 非穿戴」注入不同的商品-模特关系;每张按序号变化动作/场景/镜头。
_TRYON_WEARABLE_HINTS = ("", "", "", "", "", "", "", "围巾", "外套", "卫衣", "内衣", "文胸", "胸罩", "", "bra")
_TRYON_VARIATIONS = [
{"action": "模特正面自然展示该商品", "scene": "干净的室内空间", "shot": "半身近景,商品清晰可见"},
{"action": "模特正在穿着 / 使用该商品", "scene": "生活化的真实居家场景", "shot": "侧面角度,突出穿着或使用方式"},
{"action": "模特手持或局部展示商品细节", "scene": "明亮的时尚生活场景", "shot": "中近景,商品占比较高"},
{"action": "模特与商品自然互动", "scene": "温暖时尚的生活场景", "shot": "半身,强调使用情境"},
]
_TRYON_NEGATIVE = (
"不要换人,不要改商品设计,不要改商品颜色与结构,不要生成错误或乱码的 Logo 与文字,"
"不要把商品改成相似款,不要多余的商品堆叠,不要多余文字,不要水印,不要边框,"
"不要低清模糊,不要过度磨皮,不要畸变,不要扭曲身体,不要夸张滤镜"
)
def _is_wearable_product(product) -> bool:
"""据类目/标题判断是否「穿戴类」(服饰鞋帽内衣) → 真实穿到身上;否则非穿戴 → 手持/佩戴/使用。"""
blob = f"{getattr(product, 'category', '') or ''} {getattr(product, 'title', '') or ''}".lower()
return any(h in blob for h in _TRYON_WEARABLE_HINTS)
def build_model_tryon_prompt_refs(product, has_model: bool, base_prompt: str = "", index: int = 0, n_product: int = 1) -> str:
"""模特上身图 image_edit 提示词(refs 版):
参考图1=商品真实图(锁商品外形/品牌/配色),参考图2=选中模特(锁人脸/身形/气质)。
生成「该模特自然展示/使用该商品」的电商效果图。"""
参考图1~N=商品真实图(多角度,锁外形/品牌/配色),参考图N+1=选中模特(锁人脸/身形/气质)。
按品类分穿戴/非穿戴(穿戴=真实穿身上、替换原衣;非穿戴=手持/佩戴/使用,不动原衣);
`index` 让每张图动作/场景/镜头不同;`n_product` 让参考图序号自适应。"""
name = (getattr(product, "title", "") or "商品").strip()
lines = [f"参考图1是「{name}」的真实商品。"]
if has_model:
lines += [
"参考图2是出镜模特。请生成参考图2中的这位模特自然地展示/佩戴/使用参考图1中商品的电商效果图",
"模特的五官、发型、肤色、身形与气质必须与参考图2高度一致,不要换人;",
"商品的外形、品牌文字、配色、Logo 必须与参考图1高度一致,不要改动或重新设计。",
]
n_product = max(1, int(n_product or 1))
# 参考图序号自适应:N 张商品图 → 参考图1~N=商品, 参考图N+1=模特
if n_product <= 1:
intro = f"参考图1是「{name}」的真实商品图,是该商品外观的唯一依据"
prod_ref = "参考图1"
model_idx = 2
else:
lines += [
"请生成一位真人模特自然地展示/佩戴/使用参考图1中商品的电商效果图",
"商品的外形、品牌文字、配色、Logo 必须与参考图1高度一致,不要改动或重新设计。",
]
rng = f"1-{n_product}" if n_product > 2 else "1、2"
intro = f"参考图{rng}是「{name}」同一件真实商品的不同角度图,是该商品外观的唯一依据,请综合这些角度还原商品"
prod_ref = f"参考图{rng}"
model_idx = n_product + 1
if _is_wearable_product(product):
relation = (
f"真实穿着{prod_ref}中的这件商品,替换掉模特原本的衣服,让商品自然合身地穿在身上,"
"而不是放在一旁展示,保持商品的版型、领口、袖型、长度、纹样不变"
)
else:
relation = (
f"自然地手持 / 在合适位置佩戴 / 正在使用{prod_ref}中的这件商品(如为耳机则佩戴在耳朵上),"
"不要改动模特原本的服装,不要把商品强行穿到身上"
)
var = _TRYON_VARIATIONS[index % len(_TRYON_VARIATIONS)]
lines = [intro]
if has_model:
lines.append(f"参考图{model_idx}是出镜模特。请生成参考图{model_idx}中这位模特{relation}的电商详情页效果图。")
lines.append(f"模特的五官、发型、肤色、身形、年龄与气质必须与参考图{model_idx}(模特图)高度一致,不要换人,不要自行生成另一位模特。")
else:
lines.append(f"请生成一位真人模特{relation}的电商详情页效果图。")
lines.append(f"商品的外形、配色、材质、品牌文字与 Logo、图案必须与{prod_ref}(商品图)严格一致,不要重新设计、不要改样、不要生成相似款。")
lines.append(f"本张画面:{var['action']};场景:{var['scene']};镜头:{var['shot']}")
lines.append(quality_suffix("model_tryon", "自然光、真实质感、干净背景、电商主图构图,人物与商品比例真实协调。"))
if base_prompt and base_prompt.strip():
lines.append(base_prompt.strip())
lines.append("请规避:" + _TRYON_NEGATIVE)
return " ".join(lines)
@@ -1985,13 +2057,18 @@ def run_standalone_image_task(*, task_id: str) -> None:
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 ""
# 模特上身图:真实上传图优先、排除 AI 生成图,可多张(多角度更易锁外形/品牌)
product_urls = _product_reference_urls(product, limit=3) 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)
if mode == "model" and can_edit and product_urls:
# 模特上身图:参考图1~N=商品真实图(多角度),参考图N+1=模特(模特图可缺则让模型自取真人模特)
edit_images = product_urls + ([model_url] if model_url else [])
edit_prompt = build_model_tryon_prompt_refs(
product, has_model=bool(model_url), base_prompt=prompt,
index=index, n_product=len(product_urls),
)
elif mode == "cover" and can_edit and product_url:
# 平台套图:参考图1=商品真实主图(锁包装一致性),有模特则参考图2=模特(锁人脸/身形)
edit_images = [product_url] + ([model_url] if model_url else [])
+14 -7
View File
@@ -57,17 +57,24 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet):
product = self.get_object()
team = product.team
assets = list(
# 真因不是 OR,而是 select_related("origin_task__project") 把每个资产关联的 AITask 整行拉过来,
# 含 request_payload / response_payload 两个巨型 JSON 列(实测 114 行就要 ~66s 纯传输)。
# 视图与序列化器只用到 origin_task.project(取项目名/id),从不读 payload → .defer() 掉这两列。
base = (
Asset.objects.filter(team=team, is_deleted=False)
.filter(
Q(metadata__product_id=str(product.id))
| Q(origin_task__project__product_id=product.id)
| Q(product_images__product_id=product.id)
)
.select_related("origin_task__project")
.defer("origin_task__request_payload", "origin_task__response_payload")
.prefetch_related("files")
.distinct()
)
by_id = {}
for qs in (
base.filter(metadata__product_id=str(product.id)),
base.filter(origin_task__project__product_id=product.id),
base.filter(product_images__product_id=product.id),
):
for a in qs:
by_id[a.id] = a
assets = sorted(by_id.values(), key=lambda a: (a.created_at is not None, a.created_at), reverse=True)
def ser(a):
return AssetSerializer(a).data