模特上身图提示词重构 + 图片创作页 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
+470
View File
@@ -0,0 +1,470 @@
#!/usr/bin/env python
"""出格式能力探针 · 三家模型 × 三种策略 → 真·normalize_draft 验收。
测什么:在"按现在的代码"的前提下,豆包 / GPT-5.x / Gemini 各自能不能稳定吐出一份
符合 ScriptDraft 契约的结构,以及三种约束策略各自的成功率与"原始输出干净度"
三家(都走 OpenAI 兼容 chat/completions,凭证读 settings.PROVIDER_KEYS / PROVIDER_BASE_URLS):
- 豆包 doubao → provider "volcengine" 直连
- GPT gpt → provider "tokenssr" 中转
- Gemini gemini → provider "tokenssr" 中转(一把 key 通吃,见 settings.base:198)
三种策略:
A. freeform —— 现状:skill 提示词 + 自由文本,完全靠后端 normalize_draft 兜底抽取
B. structured —— response_format=json_schema(strict),解码期约束输出为契约 JSON
C. tool —— function-calling,tool_choice 强制调用 emit_script_draft(参数=契约)
每发产出都做两层判定:
raw_clean : 原始输出本身就是合法且含 segments 的 JSON(= 约束真的生效,没靠 normalize 抢救)
normalized : 喂进真·apps.ai.script_agent.normalize_draft 后能产出非空分镜(= 最终下游能用)
用法:
cd core/backend && python probe_format_support.py
# 改模型名(默认值见 DEFAULT_MODELS):
PROBE_GPT_MODEL=gpt-5.5 PROBE_GEMINI_MODEL=gemini-2.5-flash python probe_format_support.py
# 只测某几家 / 某几策略:
python probe_format_support.py --only doubao,gpt --strategies freeform,tool
不写库、不计费、不依赖 DB:只调 normalize_draft / load_ecommerce_skill(纯函数 + 读文件)。
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from pathlib import Path
import django
import requests
BASE_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(BASE_DIR))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development")
django.setup()
from django.conf import settings # noqa: E402
from types import SimpleNamespace # noqa: E402
from apps.ai.script_agent import ( # noqa: E402
_SEGMENT_ARRAY_KEYS,
_extract_json,
_resolve_segments,
build_agent_messages,
normalize_draft,
)
# --------------------------------------------------------------------------- #
# 目标模型:label → (provider.name, 默认模型名, env 覆盖键)
# --------------------------------------------------------------------------- #
DEFAULT_MODELS = {
"doubao": ("volcengine", "doubao-seed-2-0-pro-260215", "PROBE_DOUBAO_MODEL"),
"gpt": ("tokenssr", "gpt-5.5", "PROBE_GPT_MODEL"),
"gemini": ("tokenssr", "gemini-3.1-pro-preview", "PROBE_GEMINI_MODEL"),
}
ASPECT_RATIO = "9:16"
TOTAL_DURATION = 60 # → 4 镜
# --------------------------------------------------------------------------- #
# 模拟数据:一款保温杯,塑成 Django 模型同形对象,喂进真·build_agent_messages
# (复现"全自动①"真实场景:系统提示词=skill+_OUTPUT_PROTOCOL,user=真 _product_context)
# --------------------------------------------------------------------------- #
class _FakeManager:
"""仿 product.selling_points:支持 .all() / .filter(id__in=...),够 _product_context 用。"""
def __init__(self, items):
self._items = list(items)
def all(self):
return self._items
def filter(self, id__in=None, **_):
if id__in is None:
return self._items
return [s for s in self._items if s.id in set(id__in)]
def _make_fake_project():
selling = [
SimpleNamespace(id="sp1", title="长效保温", detail="晚上灌开水,第二天早上还温热"),
SimpleNamespace(id="sp2", title="单手弹盖", detail="开会/带娃腾不出第二只手也能喝"),
SimpleNamespace(id="sp3", title="防漏密封", detail="塞进通勤包横放也不洒"),
]
product = SimpleNamespace(
title="暖岚 316 不锈钢保温杯 500ml",
brand="暖岚",
category="水具/保温杯",
target_audience="久坐办公室的年轻白领、熬夜党",
description="24 小时长效保温,一键弹盖单手开,食品级 316 内胆,防漏防烫。",
selling_points=_FakeManager(selling),
)
return SimpleNamespace(product=product, metadata={})
# --------------------------------------------------------------------------- #
# ScriptDraft JSON Schema(契约的可机读版本,供 structured / tool 两策略约束解码)
# 与 script_agent.normalize_draft 期望的字段对齐;故意保留 strict 友好形态。
# --------------------------------------------------------------------------- #
SEGMENT_SCHEMA = {
"type": "object",
"properties": {
"index": {"type": "integer"},
"duration": {"type": "integer"},
"role": {"type": "string", "enum": ["钩子", "痛点", "卖点", "CTA"]},
"narration": {"type": "string", "description": "这一镜口播/旁白,≤55字"},
"visual": {"type": "string", "description": "画面:主体+动作+景别/运镜+变化,40-70字"},
"product_exposure": {"type": "string"},
"entity_refs": {"type": "array", "items": {"type": "string"}},
},
"required": ["index", "duration", "role", "narration", "visual", "product_exposure", "entity_refs"],
"additionalProperties": False,
}
ENTITY_SCHEMA = {
"type": "object",
"properties": {
"id": {"type": "string"},
"type": {"type": "string", "enum": ["character", "scene", "product"]},
"name": {"type": "string"},
"visual_prompt": {"type": "string"},
"ref_index": {"type": "integer"},
},
"required": ["id", "type", "name", "visual_prompt", "ref_index"],
"additionalProperties": False,
}
DRAFT_SCHEMA = {
"type": "object",
"properties": {
"hook": {"type": "string"},
"tone": {"type": "string", "enum": ["种草", "测评", "剧情", "痛点"]},
"aspect_ratio": {"type": "string"},
"total_duration": {"type": "integer", "description": "总时长,取 15/30/60/90 之一"},
"segment_count": {"type": "integer"},
"entities": {"type": "array", "items": ENTITY_SCHEMA},
"segments": {"type": "array", "items": SEGMENT_SCHEMA},
},
"required": ["hook", "tone", "aspect_ratio", "total_duration", "segment_count", "entities", "segments"],
"additionalProperties": False,
}
TOOL_DEF = {
"type": "function",
"function": {
"name": "emit_script_draft",
"description": "提交一份符合 AirShelf 契约的带货短视频脚本草稿",
"parameters": DRAFT_SCHEMA,
},
}
def _creds(provider_name: str) -> tuple[str | None, str | None]:
base = settings.PROVIDER_BASE_URLS.get(provider_name)
key = settings.PROVIDER_KEYS.get(provider_name)
return (base or None), (key or None)
def _messages() -> list[dict]:
# 走真·build_agent_messages(全自动①):与线上发给模型的 system+user 一字不差
# (system = skill + _OUTPUT_PROTOCOL;user = 真 _product_context 拼的商品上下文)
return build_agent_messages(
project=_make_fake_project(),
mode="auto",
user_prompt="",
selling_point_ids=None,
base_draft=None,
aspect_ratio=ASPECT_RATIO,
total_duration=TOTAL_DURATION,
)
def _post(base_url: str, api_key: str, body: dict) -> dict:
resp = requests.post(
f"{base_url.rstrip('/')}/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json=body,
timeout=240,
)
if not resp.ok:
# 抽真实报错(不支持 response_format / tool 等会在这里暴露)
detail = ""
try:
detail = json.dumps(resp.json().get("error") or resp.json(), ensure_ascii=False)[:300]
except Exception:
detail = (resp.text or "")[:300]
raise RuntimeError(f"HTTP {resp.status_code}: {detail}")
return resp.json()
def _build_body(model: str, strategy: str) -> dict:
body: dict = {"model": model, "messages": _messages(), "temperature": 0.85, "stream": False}
if strategy == "structured":
body["response_format"] = {
"type": "json_schema",
"json_schema": {"name": "script_draft", "strict": True, "schema": DRAFT_SCHEMA},
}
elif strategy == "tool":
body["tools"] = [TOOL_DEF]
body["tool_choice"] = {"type": "function", "function": {"name": "emit_script_draft"}}
return body
def _extract_output(data: dict, strategy: str) -> str:
"""取这一发的"待判定文本":tool 取 arguments,其余取 content。"""
choice = (data.get("choices") or [{}])[0]
msg = choice.get("message") or {}
if strategy == "tool":
calls = msg.get("tool_calls") or []
if not calls:
return "" # 没吐 tool_call = 约束没生效
return calls[0].get("function", {}).get("arguments") or ""
content = msg.get("content")
if isinstance(content, list): # 个别网关把 content 拆成块
return "".join(c.get("text", "") for c in content if isinstance(c, dict))
return content or ""
def _raw_is_clean(text: str) -> bool:
"""原始输出本身就是合法且含 segments 的 JSON(= 约束真生效,无需 normalize 抢救)。"""
try:
obj = json.loads(text)
except (ValueError, TypeError):
return False
return isinstance(obj, dict) and isinstance(obj.get("segments"), list) and len(obj["segments"]) > 0
def _raw_structure(text: str) -> dict:
"""用生产同款 _extract_json/_resolve_segments 解析模型**原始**输出,提取它真实用的
segments 数组键名 + 每镜键集 + 每实体键集 + 顶层键集。用于跨模型逐键比对(normalize 之前)。"""
blob = _extract_json(text)
if not blob:
return {"parse": False}
try:
obj = json.loads(blob)
except (ValueError, TypeError):
return {"parse": False}
if not isinstance(obj, dict):
return {"parse": False}
seg_array_key = next(
(k for k in _SEGMENT_ARRAY_KEYS if isinstance(obj.get(k), list) and obj.get(k)), None
)
segs = _resolve_segments(obj)
seg_keys = sorted({k for s in segs if isinstance(s, dict) for k in s})
ents = obj.get("entities") if isinstance(obj.get("entities"), list) else []
ent_keys = sorted({k for e in ents if isinstance(e, dict) for k in e})
return {
"parse": True,
"seg_array_key": seg_array_key,
"top_keys": sorted(obj.keys()),
"seg_keys": seg_keys,
"ent_keys": ent_keys,
"n_seg": len(segs),
}
def _seg_quality(draft: dict) -> tuple[int, int]:
"""(总镜数, 旁白+画面都非空的镜数)——衡量 normalize 后是否真有内容,不是空骨架。"""
segs = draft.get("segments") or []
full = sum(1 for s in segs if (s.get("narration") or "").strip() and (s.get("visual") or "").strip())
return len(segs), full
def run_cell(label: str, model: str, base_url: str, api_key: str, strategy: str) -> dict:
t0 = time.time()
cell = {"label": label, "model": model, "strategy": strategy, "ok": False, "raw_clean": False,
"segs": 0, "full": 0, "secs": 0.0, "note": "", "raw_preview": "", "draft": None,
"raw_struct": {"parse": False}}
try:
data = _post(base_url, api_key, _build_body(model, strategy))
text = _extract_output(data, strategy)
if not text.strip():
cell["note"] = "空输出(tool 未触发 / content 为空)"
return cell
cell["raw_preview"] = text.strip()[:600] # 原始输出头部(看模型有没有裹散文/围栏)
cell["raw_struct"] = _raw_structure(text) # 原始 JSON 结构签名(segments/entity 键集),供逐键 diff
cell["raw_clean"] = _raw_is_clean(text)
# 真·生产 normalizer 验收
draft = normalize_draft(text, aspect_ratio=ASPECT_RATIO, total_duration=TOTAL_DURATION)
cell["draft"] = draft # 存下规范化后的成稿,供 dump 渲染
segs, full = _seg_quality(draft)
cell["segs"], cell["full"] = segs, full
cell["ok"] = segs == (TOTAL_DURATION // 15) and full == segs # 镜数对 + 每镜都有词有画面
if not cell["ok"]:
cell["note"] = f"normalize 后 {full}/{segs} 镜有完整内容(期望 {TOTAL_DURATION // 15} 镜全满)"
except Exception as exc: # noqa: BLE001
cell["note"] = str(exc)[:200]
finally:
cell["secs"] = round(time.time() - t0, 1)
return cell
def _render_draft_md(draft: dict) -> str:
"""把规范化后的 ScriptDraft 渲染成可读 markdown(hook/tone/实体/逐镜旁白+画面)。"""
if not draft:
return "_(无成稿)_"
lines = [
f"- **hook**:{draft.get('hook', '')}",
f"- **tone**:{draft.get('tone', '')} · **时长**:{draft.get('total_duration')}s · **画幅**:{draft.get('aspect_ratio')}",
]
ents = draft.get("entities") or []
if ents:
ent_str = "".join(f"{e.get('name')}({e.get('type')})" for e in ents)
lines.append(f"- **实体**({len(ents)}):{ent_str}")
lines.append("")
lines.append("| 镜 | role | narration(口播) | visual(画面) |")
lines.append("| -- | ---- | ------------- | ----------- |")
for s in draft.get("segments") or []:
nar = (s.get("narration") or "").replace("|", "\\|").replace("\n", " ")
vis = (s.get("visual") or "").replace("|", "\\|").replace("\n", " ")
lines.append(f"| {s.get('index')} | {s.get('role', '')} | {nar} | {vis} |")
return "\n".join(lines)
def write_dump(path: str, results: list[dict], strategies: list[str]) -> None:
import datetime
out = [
"# 出格式能力探针 · 模型实际产出汇总",
"",
f"> 生成时间:{datetime.datetime.now():%Y-%m-%d %H:%M} · 模拟商品:暖岚 316 保温杯 · 期望 {TOTAL_DURATION // 15} 镜 / 画幅 {ASPECT_RATIO}",
"",
"判定:`✅`=normalize 后镜数对且每镜有词有画面 `raw_clean`=模型原始输出本身就是合规 JSON(约束真生效,未靠后端抢救)",
"",
"## 速览矩阵",
"",
"| 模型 | " + " | ".join(strategies) + " |",
"| ---- | " + " | ".join(["----"] * len(strategies)) + " |",
]
labels = []
for r in results:
if r["label"] not in labels:
labels.append(r["label"])
for label in labels:
cells = []
for s in strategies:
c = next((r for r in results if r["label"] == label and r["strategy"] == s), None)
if not c:
cells.append("-"); continue
mark = ("" if c["ok"] else "") + ("·raw✓" if c["raw_clean"] else "·raw✗") + f" {c['secs']}s"
cells.append(mark)
model = next((r["model"] for r in results if r["label"] == label), "")
out.append(f"| **{label}**<br>`{model}` | " + " | ".join(cells) + " |")
out.append("")
# 原始结构一致性(normalize 之前):逐策略比三家模型原始 JSON 的键集是否同构
out.append("## 原始结构一致性(normalize 之前 · 逐键 diff)\n")
out.append("> 看的是模型**原始吐出**的 JSON 用什么键,不是 normalize 抹平后的。`seg_array_key`=模型装分镜用的数组键名;"
"`seg_keys`=每镜的键集;`ent_keys`=每实体的键集。三家在同一策略下若键集相同即「同构」。\n")
for s in strategies:
out.append(f"### 策略:{s}\n")
out.append("| 模型 | parse | seg_array_key | segments 每镜键集 | entities 每实体键集 |")
out.append("| ---- | ----- | ------------- | ---------------- | ------------------ |")
sig_segs, sig_ents = [], []
for label in labels:
c = next((r for r in results if r["label"] == label and r["strategy"] == s), None)
st = (c or {}).get("raw_struct") or {"parse": False}
if not st.get("parse"):
out.append(f"| {label} | ❌ 解析失败 | - | - | - |")
continue
sk = ",".join(st["seg_keys"]); ek = ",".join(st["ent_keys"])
sig_segs.append(sk); sig_ents.append(ek)
out.append(f"| {label} | ✓ | `{st['seg_array_key']}` | `{sk}` | `{ek}` |")
same_seg = len(set(sig_segs)) <= 1 and len(sig_segs) == len([l for l in labels])
same_ent = len(set(sig_ents)) <= 1 and len(sig_ents) == len([l for l in labels])
verdict = []
verdict.append("segments 键集" + ("**完全同构**✅" if same_seg else "**有差异**⚠️"))
verdict.append("entities 键集" + ("**完全同构**✅" if same_ent else "**有差异**⚠️"))
out.append(f"\n{' · '.join(verdict)}\n")
out.append("")
# 逐发明细
for label in labels:
model = next((r["model"] for r in results if r["label"] == label), "")
out.append(f"\n---\n\n## {label} · `{model}`\n")
for s in strategies:
c = next((r for r in results if r["label"] == label and r["strategy"] == s), None)
if not c:
continue
flag = "" if c["ok"] else ""
# 模型名写进每个子标题:成稿表格很长,滚到中间时 ## 大标题已滚出屏,子标题须自带身份
out.append(f"### {flag}{label} · {model}】策略:{s} · {c['secs']}s · raw_clean={'' if c['raw_clean'] else ''}")
if c["note"]:
out.append(f"\n> ⚠️ {c['note']}\n")
out.append("\n**规范化成稿:**\n")
out.append(_render_draft_md(c["draft"]))
if c["raw_preview"]:
out.append("\n<details><summary>原始输出预览(前 600 字)</summary>\n")
out.append("\n```\n" + c["raw_preview"] + "\n```\n")
out.append("</details>")
out.append("")
Path(path).write_text("\n".join(out), encoding="utf-8")
print(f"\n📄 已写出:{path}")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--only", default="", help="逗号分隔:doubao,gpt,gemini")
ap.add_argument("--strategies", default="freeform,structured,tool")
ap.add_argument("--dump", default="", help="把每发真实产出写成 markdown 报告到此路径")
args = ap.parse_args()
only = {x.strip() for x in args.only.split(",") if x.strip()} or set(DEFAULT_MODELS)
strategies = [s.strip() for s in args.strategies.split(",") if s.strip()]
print(f"\n出格式能力探针 · 模拟商品=暖岚保温杯 · 期望 {TOTAL_DURATION // 15} 镜 · 画幅 {ASPECT_RATIO}\n")
targets = []
for label, (provider_name, default_model, env_key) in DEFAULT_MODELS.items():
if label not in only:
continue
base_url, api_key = _creds(provider_name)
model = os.getenv(env_key, default_model)
if not base_url or not api_key:
print(f"{label:8s} 跳过:provider '{provider_name}' 凭证缺失(检查 .env)")
continue
targets.append((label, provider_name, model, base_url, api_key))
if not targets:
print("没有可测目标(凭证全缺)。"); return
results: list[dict] = []
for label, provider_name, model, base_url, api_key in targets:
print(f"\n{label} ({provider_name} · {model})")
for strategy in strategies:
cell = run_cell(label, model, base_url, api_key, strategy)
results.append(cell)
flag = "" if cell["ok"] else ""
raw = "raw_clean✓" if cell["raw_clean"] else "raw_clean✗"
line = f" {flag} {strategy:10s} {raw} segs {cell['full']}/{cell['segs']} {cell['secs']}s"
if cell["note"]:
line += f" · {cell['note']}"
print(line)
# 汇总矩阵
print("\n" + "=" * 72)
print("汇总(✅=normalize 后镜数对且每镜有词有画面 / raw=原始输出本身就合规)")
print("=" * 72)
header = f"{'model':24s} " + " ".join(f"{s:>12s}" for s in strategies)
print(header)
for label, (_, _, _) in DEFAULT_MODELS.items():
if label not in only:
continue
row = next((r for r in results if r["label"] == label), None)
if row is None:
continue
cells = []
for s in strategies:
c = next((r for r in results if r["label"] == label and r["strategy"] == s), None)
if c is None:
cells.append(f"{'-':>12s}"); continue
mark = ("" if c["ok"] else "") + ("R" if c["raw_clean"] else " ")
cells.append(f"{mark:>12s}")
print(f"{label:24s} " + " ".join(cells))
print()
if args.dump:
write_dump(args.dump, results, strategies)
if __name__ == "__main__":
main()
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python
"""故事板「画风锚点」对比 demo。
目的:直观演示"锁画风"——同一组分镜,各生成两版:
A. 无锚点(no_anchor) —— 模拟现状故事板提示词,只说"导演故事板…画面清晰",不规定画风
B. 有锚点(with_anchor)—— 同样内容 + 注入一段「统一画风锚点」(写实电商摄影棚风/统一色调光线构图)
每版各出 4 帧(钩子/痛点/卖点/CTA),存盘后肉眼对比:
- no_anchor 组:帧与帧画风易漂(这次写实、那次插画、景别色调各异)
- with_anchor 组:4 帧像同一套片子(统一风格)
走真·图像模型(yunqi/gpt-image-2,纯文生图,隔离出"锚点"这一个变量,不掺参考图)。
输出:../docs/storyboard-style-demo/{no_anchor,with_anchor}/frame{N}.png + prompts.md
用法:cd backend && python storyboard_style_demo.py
"""
from __future__ import annotations
import os
import sys
import time
from pathlib import Path
import django
BASE_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(BASE_DIR))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development")
django.setup()
from apps.ai.models import ModelConfig # noqa: E402
from apps.ai.providers import VolcanoArkProvider # noqa: E402 — media_to_bytes 复用
from apps.ai.services import build_provider, get_default_model # noqa: E402
OUT = BASE_DIR.parent / "docs" / "storyboard-style-demo"
SIZE = "1024x1536" # 9:16 竖屏,与故事板一致
# --------------------------------------------------------------------------- #
# 模拟数据:保温杯 4 分镜(钩子→痛点→卖点→CTA),每帧一句画面描述
# --------------------------------------------------------------------------- #
FRAMES = [
("钩子", "年轻女白领坐在办公室工位,皱眉摸了摸桌上凉掉的水杯,抬头看镜头一脸无奈"),
("痛点", "女白领从通勤包里拿出漏水的旧保温杯,纸巾擦被打湿的笔记本,表情懊恼"),
("卖点", "女白领单手按下保温杯一键弹盖,杯口冒出热气,桌面横放杯子滴水不漏"),
("CTA", "女白领手持保温杯对镜头微笑展示,画面右下角出现购物车引导点击"),
]
# --------------------------------------------------------------------------- #
# A. 无锚点:模拟现状故事板提示词(无任何画风约束)
# --------------------------------------------------------------------------- #
def prompt_no_anchor(role: str, scene: str) -> str:
return (
f"根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。\n"
f"【镜头功能】{role}\n【画面】{scene}\n"
f"电商竖屏 9:16 导演故事板,画面清晰。"
)
# --------------------------------------------------------------------------- #
# B. 有锚点:同内容 + 一段「统一画风锚点」(每帧逐字相同,把风格焊死)
# --------------------------------------------------------------------------- #
STYLE_ANCHOR = (
"【统一画风 · 所有分镜必须严格一致,不可逐帧漂移】\n"
"· 风格:写实电商摄影棚实拍质感(photorealistic),禁止插画/线稿/漫画/3D 卡通;\n"
"· 布光:统一柔和影棚顺光,同一色温(暖白);\n"
"· 色彩:统一明亮干净的电商色彩分级,低饱和高级灰背景;\n"
"· 景别构图:统一中近景、人物居中、相同画面留白比例与镜头高度;\n"
"· 人物:全片同一位年轻女白领(同一张脸、同一发型妆容着装);\n"
"· 商品:全片同一只白色保温杯(同一外形/配色/logo)。"
)
def prompt_with_anchor(role: str, scene: str) -> str:
return (
f"根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。\n"
f"【镜头功能】{role}\n【画面】{scene}\n"
f"{STYLE_ANCHOR}\n"
f"电商竖屏 9:16 导演故事板,画面清晰。"
)
def gen_one(provider, model, prompt: str, save_path: Path) -> str:
resp = provider.image_generation(model=model.name, endpoint=model.endpoint, prompt=prompt, size=SIZE)
media = provider.extract_first_media_url(resp)
fileobj, _ct = VolcanoArkProvider.media_to_bytes(media)
save_path.write_bytes(fileobj.getvalue())
return str(save_path)
def main() -> None:
model = get_default_model(ModelConfig.Capability.IMAGE)
provider = build_provider(model)
print(f"图像模型:{model.provider.name}/{model.name} · 尺寸 {SIZE}")
(OUT / "no_anchor").mkdir(parents=True, exist_ok=True)
(OUT / "with_anchor").mkdir(parents=True, exist_ok=True)
prompts_md = ["# 故事板画风锚点 demo · 用到的提示词\n",
f"> 图像模型:{model.provider.name}/{model.name} · 模拟商品:保温杯 · 4 分镜\n"]
for variant, builder in (("no_anchor", prompt_no_anchor), ("with_anchor", prompt_with_anchor)):
label = "无锚点(现状)" if variant == "no_anchor" else "有锚点(锁画风)"
print(f"\n===== {label} =====")
prompts_md.append(f"\n## {label}\n")
for i, (role, scene) in enumerate(FRAMES, 1):
prompt = builder(role, scene)
path = OUT / variant / f"frame{i}_{role}.png"
t0 = time.time()
try:
gen_one(provider, model, prompt, path)
print(f" ✅ 第{i}{role} {round(time.time()-t0,1)}s → {path.name}")
except Exception as exc: # noqa: BLE001
print(f" ❌ 第{i}{role} {str(exc)[:160]}")
prompts_md.append(f"\n### 第{i}镜 · {role}\n\n```\n{prompt}\n```\n")
(OUT / "prompts.md").write_text("\n".join(prompts_md), encoding="utf-8")
print(f"\n📁 图片+提示词已存:{OUT}")
print(" 对比:no_anchor/ 各帧画风易漂 vs with_anchor/ 4 帧同一套风格")
if __name__ == "__main__":
main()
+171
View File
@@ -0,0 +1,171 @@
"""模特上身图 · 新提示词效果测试脚本(一次性,可删)。
设计:**提示词模板固定**(下面 TEMPLATE/变化表/负面词都是常量),
**商品参数是变量**(按 PRODUCT_ID 从库里查出来填进模板)。
跑法:
.venv/bin/python tryon_prompt_test.py
产物:把生成图写到 OUT_DIR,顺带打印每张实际发出去的提示词。
"""
import os
import sys
import uuid
import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development")
django.setup()
from apps.products.models import Product # noqa: E402
from apps.assets.models import Asset # noqa: E402
from apps.ai.models import ModelConfig # noqa: E402
from apps.ai.services import ( # noqa: E402
get_default_model,
get_image_provider,
_product_cover_url,
_asset_preview_url,
_ratio_to_image_size,
)
from apps.ai.providers.volcano import VolcanoArkProvider # noqa: E402
# ───────────────────────── 可调输入(变量) ─────────────────────────
PRODUCT_ID = "045ad8d6-8b15-486b-a4a9-f6968eb1555f" # 南卡 Lite Pro 蓝牙耳机
# 商品参考图(可多张):图1=真实上传(干净外形) + 图2=NANK 多角度(带品牌)。两张一起喂,锁外形+锁品牌。
PRODUCT_IMAGE_IDS = [
"c8c47a2e-6f07-43b2-b776-1be2a401538a", # 真实上传 · 图1
"463a826c-2adc-43c8-b776-2eea973f0449", # NANK 多角度(带 Logo)
]
MODEL_ID = "ad362810-cf3e-49c0-9426-8142a74b75b2" # 一位真人模特
RATIO = "3:4"
COUNT = 3 # 生成张数(每张动作/场景/镜头不同)
OUT_DIR = "/Users/maidong/Desktop/zyc/tryon_prompt_test2"
# ═════════════════════════ 固定提示词模板 ═════════════════════════
# 占位符全部由商品/模特变量填入;模板本身不随商品变。
TEMPLATE = (
"{product_intro}"
"请生成参考图{model_idx}中这位模特{relation}的电商详情页效果图。"
"模特要求:五官、发型、肤色、身形、年龄与气质必须与参考图{model_idx}(模特图)高度一致,不要换人,不要自行生成另一位模特。"
"商品要求:外形、配色、材质、品牌文字与 Logo、图案必须与{product_ref_label}(商品图)严格一致,不要重新设计、不要改样、不要生成相似款。"
"本张画面:{action};场景:{scene};镜头:{shot}"
"画面风格:真实电商摄影,自然光,干净背景,主体突出,商品细节清晰,模特姿态自然,单人,高分辨率,{ratio} 构图。"
"(商品类目:{category};卖点:{selling_points}。以上文字仅供理解商品,不要据此凭空改变商品外观。)"
" 请规避:{negative}"
)
# 穿戴 / 非穿戴 两套「商品-模特关系」从句(固定)
RELATION_WEARABLE = (
"真实穿着参考图1中的这件商品,替换掉模特原本的衣服,让商品自然合身地穿在身上,"
"而不是把商品放在一旁展示;保持商品的版型、领口、袖型、长度、纹样不变"
)
RELATION_NON_WEARABLE = (
"自然地手持 / 在合适位置佩戴 / 正在使用参考图1中的这件商品(如为耳机则佩戴在耳朵上),"
"不要改动模特原本的服装,不要把商品强行穿到身上,商品以真实合理的方式出现在模特手中或身上"
)
# 每张按序号变化(固定的变化表;非穿戴默认)
VARIATIONS = [
{"action": "模特正面自然展示该商品", "scene": "干净的室内空间", "shot": "半身近景,商品清晰可见"},
{"action": "模特正在使用该商品", "scene": "生活化的真实居家场景", "shot": "侧面角度,突出使用方式"},
{"action": "模特手持并展示商品细节", "scene": "明亮的时尚生活场景", "shot": "中近景,商品占比较高"},
{"action": "模特与商品自然互动", "scene": "温暖时尚的生活场景", "shot": "半身,强调使用情境"},
]
NEGATIVE = (
"不要换人,不要改商品设计,不要改商品颜色与结构,不要生成错误或乱码的 Logo 与文字,"
"不要把商品改成相似款,不要多余的商品堆叠,不要多余文字,不要水印,不要边框,"
"不要低清模糊,不要过度磨皮,不要畸变,不要扭曲身体,不要夸张滤镜"
)
# 穿戴类类目关键词(命中即按穿戴从句)
WEARABLE_HINTS = ("", "", "", "", "", "", "", "围巾", "外套", "T恤", "卫衣", "内衣", "")
def is_wearable(category: str, title: str) -> bool:
blob = f"{category} {title}"
return any(h in blob for h in WEARABLE_HINTS)
def build_product_intro(name: str, n_product: int) -> tuple[str, int, str]:
"""据商品参考图数量自适应序号:N 张商品 → 参考图1~N=商品, 参考图N+1=模特。
返回 (intro, 模特图序号, 商品图序号标签)。"""
if n_product <= 1:
intro = f"参考图1是「{name}」的真实商品图,是该商品外观的唯一依据。参考图2是出镜模特。"
return intro, 2, "参考图1"
rng = f"1-{n_product}" if n_product > 2 else "1、2"
intro = (
f"参考图{rng}是「{name}」同一件真实商品的不同角度图,是该商品外观的唯一依据,"
f"请综合这些角度还原商品。参考图{n_product + 1}是出镜模特。"
)
return intro, n_product + 1, f"参考图{rng}"
def build_prompt(product, index: int, ratio: str, n_product: int) -> str:
name = (product.title or "商品").strip()
category = (product.category or "").strip()
points = list(product.selling_points.all().values_list("title", flat=True))
selling = "".join(points) if points else "(无)"
relation = RELATION_WEARABLE if is_wearable(category, name) else RELATION_NON_WEARABLE
var = VARIATIONS[index % len(VARIATIONS)]
product_intro, model_idx, product_ref_label = build_product_intro(name, n_product)
return TEMPLATE.format(
product_intro=product_intro,
model_idx=model_idx,
product_ref_label=product_ref_label,
relation=relation,
action=var["action"],
scene=var["scene"],
shot=var["shot"],
ratio=ratio,
category=category or "(未填)",
selling_points=selling,
negative=NEGATIVE,
)
def main():
os.makedirs(OUT_DIR, exist_ok=True)
product = Product.objects.get(id=PRODUCT_ID)
model_asset = Asset.objects.get(id=MODEL_ID)
product_urls = []
for aid in PRODUCT_IMAGE_IDS:
a = Asset.objects.get(id=aid)
u = _asset_preview_url(a)
if u:
product_urls.append(u)
print(f"商品参考图: {a.name} | {a.source} | {u[:80]}")
model_url = _asset_preview_url(model_asset)
if not product_urls:
print("❌ 没有可用的商品参考图。")
sys.exit(1)
n_product = len(product_urls)
print(f"商品: {product.title} | 类目: {product.category} | 穿戴类: {is_wearable(product.category or '', product.title)} | 商品参考图数: {n_product}")
print(f"模特: {model_asset.name} | {model_url[:80]}")
model_config = get_default_model(ModelConfig.Capability.IMAGE)
provider = get_image_provider(model_config)
print(f"模型: {model_config.provider.name}:{model_config.name}\n")
images = product_urls + [model_url] # 参考图1~N=商品多角度, 参考图N+1=模特
size = _ratio_to_image_size(RATIO)
for i in range(COUNT):
prompt = build_prompt(product, i, RATIO, n_product)
print(f"━━━ 第 {i + 1} 张 ━━━\n{prompt}\n")
try:
resp = provider.image_edit(model=model_config.name, prompt=prompt, images=images, size=size)
media = provider.extract_first_media_url(resp)
fileobj, content_type = VolcanoArkProvider.media_to_bytes(media)
ext = ".jpg" if "jpeg" in content_type else (".webp" if "webp" in content_type else ".png")
path = os.path.join(OUT_DIR, f"tryon_{i + 1}{ext}")
with open(path, "wb") as f:
f.write(fileobj.getvalue())
print(f"✅ 第 {i + 1} 张 → {path}\n")
except Exception as exc: # noqa: BLE001
print(f"❌ 第 {i + 1} 张失败: {exc}\n")
print(f"完成。产物目录: {OUT_DIR}")
if __name__ == "__main__":
main()
+86
View File
@@ -0,0 +1,86 @@
"""内衣品类(文胸)测试:对比两条路线能不能出图。
路线A · 真人上身(穿戴类) —— 验证 gpt-image-2 内容审核会不会拦(safety=[sexual])
路线B · 隐形人台/平铺(无真人) —— 验证绕开审核的安全做法
跑法: .venv/bin/python tryon_underwear_test.py
产物: /Users/maidong/Desktop/zyc/underwear_test
"""
import os
import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development")
django.setup()
from apps.products.models import Product # noqa: E402
from apps.assets.models import Asset # noqa: E402
from apps.ai.models import ModelConfig # noqa: E402
from apps.ai.services import ( # noqa: E402
get_default_model, get_image_provider, _asset_preview_url, _ratio_to_image_size,
)
from apps.ai.providers.volcano import VolcanoArkProvider # noqa: E402
PRODUCT_ID = "42078cdc-8714-4fc7-be5c-6aa42b7fc995" # 棉居莫代尔无痕内衣(真实主图)
MODEL_ID = "ad362810-cf3e-49c0-9426-8142a74b75b2" # 一位真人模特
RATIO = "3:4"
OUT_DIR = "/Users/maidong/Desktop/zyc/underwear_test"
# 路线A · 真人上身(穿戴类从句)
PROMPT_ON_BODY = (
"参考图1是「{name}」的真实商品图,是商品外观的唯一依据。参考图2是出镜模特。"
"请生成参考图2中这位模特真实穿着参考图1中这件内衣的电商详情页效果图,得体、健康、非裸露,"
"覆盖充分,运动内衣风格,商品的颜色、版型、领口、肩带、纹样、Logo 必须与参考图1严格一致。"
"模特五官/发型/肤色/身形与参考图2一致,不要换人。真实电商摄影,自然光,干净背景,单人,{ratio} 构图。"
" 请规避:不要裸露,不要性暗示,不要换人,不要改商品设计与颜色,不要乱码文字,不要水印。"
)
# 路线B · 隐形人台 / 平铺(无真人,绕开审核)
PROMPT_GHOST = (
"参考图1是「{name}」的真实商品图。请生成该内衣的电商主图:采用隐形人台(ghost mannequin)立体悬浮效果,"
"衣服呈现被穿着时的自然立体版型与轮廓,但画面中没有真人、没有人体、不出现任何皮肤或身体部位。"
"商品的颜色、版型、肩带、领口、纹理、Logo 必须与参考图1严格一致,不要重新设计。"
"柔和自然光,干净纯净背景,商品居中,细节清晰,高分辨率,{ratio} 构图。"
" 请规避:不要出现人、不要皮肤、不要模特、不要改商品颜色与版型、不要乱码文字、不要水印。"
)
def run(provider, model_name, label, prompt, images, size):
print(f"\n━━━ {label} ━━━\n{prompt}\n图数={len(images)}")
try:
resp = provider.image_edit(model=model_name, prompt=prompt, images=images, size=size)
media = provider.extract_first_media_url(resp)
fileobj, ct = VolcanoArkProvider.media_to_bytes(media)
ext = ".jpg" if "jpeg" in ct else (".webp" if "webp" in ct else ".png")
path = os.path.join(OUT_DIR, f"{label}{ext}")
with open(path, "wb") as f:
f.write(fileobj.getvalue())
print(f"✅ 出图成功 → {path}")
except Exception as exc: # noqa: BLE001
msg = str(exc)
flagged = any(k in msg.lower() for k in ("moderation", "safety", "sexual", "blocked", "content_policy", "rejected"))
print(f"❌ 失败{' · 命中内容审核' if flagged else ''}: {msg[:600]}")
def main():
os.makedirs(OUT_DIR, exist_ok=True)
p = Product.objects.get(id=PRODUCT_ID)
product_url = _asset_preview_url(p.cover_asset)
model_url = _asset_preview_url(Asset.objects.get(id=MODEL_ID))
mc = get_default_model(ModelConfig.Capability.IMAGE)
provider = get_image_provider(mc)
size = _ratio_to_image_size(RATIO)
print(f"商品: {p.title} | 类目: {p.category}")
print(f"模型: {mc.provider.name}:{mc.name}")
print(f"商品图: {product_url[:80]}")
# 路线A:真人上身(商品图 + 模特图)
run(provider, mc.name, "A_真人上身", PROMPT_ON_BODY.format(name=p.title, ratio=RATIO),
[product_url, model_url], size)
# 路线B:隐形人台(仅商品图)
run(provider, mc.name, "B_隐形人台", PROMPT_GHOST.format(name=p.title, ratio=RATIO),
[product_url], size)
print(f"\n完成。产物: {OUT_DIR}")
if __name__ == "__main__":
main()