模特上身图提示词重构 + 图片创作页 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
+3
View File
@@ -19,3 +19,6 @@ core/qa/visual-parity/output/
*.tsbuildinfo
next-env.d.ts.bak
_design_src
# 本地工具/venv,不入库
core/.tools/
+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()
+87
View File
@@ -0,0 +1,87 @@
# 故事板「画风锚点」对比 demo
> 探针:[`backend/storyboard_style_demo.py`](../../backend/storyboard_style_demo.py) · 图像模型:yunqi/gpt-image-2 · 纯文生图(隔离"锚点"单一变量)
> 同一组保温杯 4 分镜,各出两版:**无锚点(现状)** vs **有锚点(锁画风)**。
---
## 一、什么是「画风锚点」
就是把一段**固定的、逐帧逐字相同的风格规格**注进每一帧的提示词,把模型本来自由发挥的画风焊死。本 demo 用的锚点:
```
【统一画风 · 所有分镜必须严格一致,不可逐帧漂移】
· 风格:写实电商摄影棚实拍质感(photorealistic),禁止插画/线稿/漫画/3D 卡通;
· 布光:统一柔和影棚顺光,同一色温(暖白);
· 色彩:统一明亮干净的电商色彩分级,低饱和高级灰背景;
· 景别构图:统一中近景、人物居中、相同画面留白比例与镜头高度;
· 人物:全片同一位年轻女白领(同一张脸、同一发型妆容着装);
· 商品:全片同一只白色保温杯(同一外形/配色/logo)。
```
关键:**这段每一帧都一模一样地拼进去**,模型就被反复约束到同一套风格上,不会这帧写实、那帧插画。
---
## 二、对比结果
### 无锚点(现状)—— 连"产出物形态"都在漂
| 帧 | 实际产出 |
| -- | -------- |
| 第1镜 钩子 | 单张「分镜卡」:顶部元数据表格(镜号/景别 MCU/机位)+ 一张照片,蓝调办公室,白衬衫女主 |
| 第2镜 痛点 | 又一张「分镜卡」,但**布局变了**(元数据挪到左栏)+ 灰西装女主 + 暖调 |
| 第3镜 卖点 | **直接画成一整张 4 行分镜缩略表(contact sheet)**——完全不同的产出物 |
| 第4镜 CTA | (同样各画各的) |
→ 不只是画风漂,**连"画一张图还是画一张分镜表"都每次不一样**;元数据布局、色调、人物也各不相同。
| ![](no_anchor/frame1_钩子.png) | ![](no_anchor/frame2_痛点.png) | ![](no_anchor/frame3_卖点.png) |
| --- | --- | --- |
| 第1镜:单张卡(表格在顶) | 第2镜:单张卡(表格在左)| 第3镜:4行分镜表 |
### 有锚点(锁画风)—— 4 帧像同一套片子
| ![](with_anchor/frame1_钩子.png) | ![](with_anchor/frame3_卖点.png) | ![](with_anchor/frame4_CTA.png) |
| --- | --- | --- |
| 第1镜 | 第3镜 | 第4镜 |
**统一写实摄影质感、统一米灰色调、统一柔光、同一位女白领、同一只白色保温杯**——明显是一套连贯的片子。
画风一致性被锁住了(对比无锚点那组的杂乱一目了然)。
---
## 三、demo 意外暴露的第二个问题(比画风更值得修)
**两组都把图画成了"分镜表/分镜卡"(多格 + 元数据列 + 文字标注),而不是干净的单帧画面。**
根因:提示词里的 **「导演故事板 / 分镜图」** 这几个字,会让图像模型去画一张"分镜文档"(storyboard sheet),
而不是"这一镜的画面"。锚点锁住了**画风**,但没锁住**产出物类型**。
雪上加霜:线上 DB 模板把代码默认里的 **「一镜一图」** 删掉了(见下),而"一镜一图"正是约束"出单帧、别出多格表"的那句。
- 代码默认:`…电商竖屏 9:16 导演故事板,**一镜一图**,画面清晰…`
- 线上 DB :`…电商竖屏 9:16 导演故事板,画面清晰…`(少了「一镜一图」)
> 注:线上真实路径走 `image_edit` + 人物参考图,参考图会把结果往"这个人的实拍照"拽,可能比纯文生图更不易出表;
> 但"导演故事板"这个词的风险真实存在,demo 已证。
---
## 四、改进建议(锚点要同时锁「画风」+「产出物」)
`storyboard_frame` 后台模板的尾段改成**画风锚点 + 产出物锚点**合体,并恢复「一镜一图」:
```
…请严格保持各参考图中角色的同一张脸、同一商品的外观与配色;
【统一画风】写实电商摄影棚实拍质感,统一柔和暖白影棚光、统一明亮干净色彩分级、统一中近景居中构图;禁止插画/线稿/漫画/3D卡通。
【产出物】只输出单张写实画面(一镜一图);禁止多格分镜表、禁止文字标注/字幕、禁止表格/边框/元数据栏。
电商竖屏 9:16,画面清晰,可直接指导视频生成。
```
要点:
1. **画风锚点**——锁写实/光线/色调/构图(本 demo 已验证有效)。
2. **产出物锚点**——明确"单张画面、禁止分镜表/文字/表格"(治本次暴露的"画成分镜表")。
3. **恢复「一镜一图」**——线上被删了,加回。
4. **人物/服装跨帧锁**——纯文生图下服装仍会微漂(白上衣 vs 西装);彻底锁要靠线上的 `image_edit` 参考图路径(用同一张人物立绘当参考)。
5. **可选固定 seed**——进一步压跨次随机漂移(需确认 yunqi/gpt-image 支持)。
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+117
View File
@@ -0,0 +1,117 @@
# 故事板画风锚点 demo · 用到的提示词
> 图像模型:yunqi/gpt-image-2 · 模拟商品:保温杯 · 4 分镜
## 无锚点(现状)
### 第1镜 · 钩子
```
根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。
【镜头功能】钩子
【画面】年轻女白领坐在办公室工位,皱眉摸了摸桌上凉掉的水杯,抬头看镜头一脸无奈
电商竖屏 9:16 导演故事板,画面清晰。
```
### 第2镜 · 痛点
```
根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。
【镜头功能】痛点
【画面】女白领从通勤包里拿出漏水的旧保温杯,纸巾擦被打湿的笔记本,表情懊恼
电商竖屏 9:16 导演故事板,画面清晰。
```
### 第3镜 · 卖点
```
根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。
【镜头功能】卖点
【画面】女白领单手按下保温杯一键弹盖,杯口冒出热气,桌面横放杯子滴水不漏
电商竖屏 9:16 导演故事板,画面清晰。
```
### 第4镜 · CTA
```
根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。
【镜头功能】CTA
【画面】女白领手持保温杯对镜头微笑展示,画面右下角出现购物车引导点击
电商竖屏 9:16 导演故事板,画面清晰。
```
## 有锚点(锁画风)
### 第1镜 · 钩子
```
根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。
【镜头功能】钩子
【画面】年轻女白领坐在办公室工位,皱眉摸了摸桌上凉掉的水杯,抬头看镜头一脸无奈
【统一画风 · 所有分镜必须严格一致,不可逐帧漂移】
· 风格:写实电商摄影棚实拍质感(photorealistic),禁止插画/线稿/漫画/3D 卡通;
· 布光:统一柔和影棚顺光,同一色温(暖白);
· 色彩:统一明亮干净的电商色彩分级,低饱和高级灰背景;
· 景别构图:统一中近景、人物居中、相同画面留白比例与镜头高度;
· 人物:全片同一位年轻女白领(同一张脸、同一发型妆容着装);
· 商品:全片同一只白色保温杯(同一外形/配色/logo)。
电商竖屏 9:16 导演故事板,画面清晰。
```
### 第2镜 · 痛点
```
根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。
【镜头功能】痛点
【画面】女白领从通勤包里拿出漏水的旧保温杯,纸巾擦被打湿的笔记本,表情懊恼
【统一画风 · 所有分镜必须严格一致,不可逐帧漂移】
· 风格:写实电商摄影棚实拍质感(photorealistic),禁止插画/线稿/漫画/3D 卡通;
· 布光:统一柔和影棚顺光,同一色温(暖白);
· 色彩:统一明亮干净的电商色彩分级,低饱和高级灰背景;
· 景别构图:统一中近景、人物居中、相同画面留白比例与镜头高度;
· 人物:全片同一位年轻女白领(同一张脸、同一发型妆容着装);
· 商品:全片同一只白色保温杯(同一外形/配色/logo)。
电商竖屏 9:16 导演故事板,画面清晰。
```
### 第3镜 · 卖点
```
根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。
【镜头功能】卖点
【画面】女白领单手按下保温杯一键弹盖,杯口冒出热气,桌面横放杯子滴水不漏
【统一画风 · 所有分镜必须严格一致,不可逐帧漂移】
· 风格:写实电商摄影棚实拍质感(photorealistic),禁止插画/线稿/漫画/3D 卡通;
· 布光:统一柔和影棚顺光,同一色温(暖白);
· 色彩:统一明亮干净的电商色彩分级,低饱和高级灰背景;
· 景别构图:统一中近景、人物居中、相同画面留白比例与镜头高度;
· 人物:全片同一位年轻女白领(同一张脸、同一发型妆容着装);
· 商品:全片同一只白色保温杯(同一外形/配色/logo)。
电商竖屏 9:16 导演故事板,画面清晰。
```
### 第4镜 · CTA
```
根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。
【镜头功能】CTA
【画面】女白领手持保温杯对镜头微笑展示,画面右下角出现购物车引导点击
【统一画风 · 所有分镜必须严格一致,不可逐帧漂移】
· 风格:写实电商摄影棚实拍质感(photorealistic),禁止插画/线稿/漫画/3D 卡通;
· 布光:统一柔和影棚顺光,同一色温(暖白);
· 色彩:统一明亮干净的电商色彩分级,低饱和高级灰背景;
· 景别构图:统一中近景、人物居中、相同画面留白比例与镜头高度;
· 人物:全片同一位年轻女白领(同一张脸、同一发型妆容着装);
· 商品:全片同一只白色保温杯(同一外形/配色/logo)。
电商竖屏 9:16 导演故事板,画面清晰。
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 976 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -0,0 +1,409 @@
# 出格式能力探针 · 模型实际产出汇总
> 生成时间:2026-06-24 12:01 · 模拟商品:暖岚 316 保温杯 · 期望 4 镜 / 画幅 9:16
判定:`✅`=normalize 后镜数对且每镜有词有画面 `raw_clean`=模型原始输出本身就是合规 JSON(约束真生效,未靠后端抢救)
## 速览矩阵
| 模型 | freeform | structured | tool |
| ---- | ---- | ---- | ---- |
| **doubao**<br>`doubao-seed-2-0-pro-260215` | ✅·raw✗ 55.7s | ✅·raw✓ 46.0s | ✅·raw✓ 86.7s |
| **gpt**<br>`gpt-5.5` | ✅·raw✗ 56.7s | ✅·raw✗ 96.4s | ✅·raw✓ 33.1s |
| **gemini**<br>`gemini-3.1-pro-preview` | ✅·raw✗ 29.0s | ✅·raw✓ 33.9s | ✅·raw✓ 20.9s |
## 原始结构一致性(normalize 之前 · 逐键 diff)
> 看的是模型**原始吐出**的 JSON 用什么键,不是 normalize 抹平后的。`seg_array_key`=模型装分镜用的数组键名;`seg_keys`=每镜的键集;`ent_keys`=每实体的键集。三家在同一策略下若键集相同即「同构」。
### 策略:freeform
| 模型 | parse | seg_array_key | segments 每镜键集 | entities 每实体键集 |
| ---- | ----- | ------------- | ---------------- | ------------------ |
| doubao | ✓ | `segments` | `dialogue,duration,entity_refs,index,narration,product_exposure,role,speaker,visual` | `id,name,ref_index,type,visual_prompt,voice_ref` |
| gpt | ✓ | `segments` | `dialogue,duration,entity_refs,index,narration,product_exposure,role,speaker,visual` | `id,name,ref_index,type,visual_prompt,voice_ref` |
| gemini | ✓ | `segments` | `dialogue,duration,entity_refs,index,narration,product_exposure,role,speaker,visual` | `id,name,ref_index,type,visual_prompt,voice_ref` |
→ segments 键集**完全同构**✅ · entities 键集**完全同构**✅
### 策略:structured
| 模型 | parse | seg_array_key | segments 每镜键集 | entities 每实体键集 |
| ---- | ----- | ------------- | ---------------- | ------------------ |
| doubao | ✓ | `segments` | `duration,entity_refs,index,narration,product_exposure,role,visual` | `id,name,ref_index,type,visual_prompt` |
| gpt | ✓ | `segments` | `dialogue,duration,entity_refs,index,narration,product_exposure,role,speaker,visual` | `id,name,ref_index,type,visual_prompt,voice_ref` |
| gemini | ✓ | `segments` | `duration,entity_refs,index,narration,product_exposure,role,visual` | `id,name,ref_index,type,visual_prompt` |
→ segments 键集**有差异**⚠️ · entities 键集**有差异**⚠️
### 策略:tool
| 模型 | parse | seg_array_key | segments 每镜键集 | entities 每实体键集 |
| ---- | ----- | ------------- | ---------------- | ------------------ |
| doubao | ✓ | `segments` | `duration,entity_refs,index,narration,product_exposure,role,visual` | `id,name,ref_index,type,visual_prompt` |
| gpt | ✓ | `segments` | `duration,entity_refs,index,narration,product_exposure,role,visual` | `id,name,ref_index,type,visual_prompt` |
| gemini | ✓ | `segments` | `duration,entity_refs,index,narration,product_exposure,role,visual` | `id,name,ref_index,type,visual_prompt` |
→ segments 键集**完全同构**✅ · entities 键集**完全同构**✅
---
## doubao · `doubao-seed-2-0-pro-260215`
### ✅ 【doubao · doubao-seed-2-0-pro-260215】策略:freeform · 55.7s · raw_clean=否
**规范化成稿:**
- **hook**:办公室久坐总喝凉水?这款保温杯24小时锁温超省心!
- **tone**:种草 · **时长**:60s · **画幅**:9:16
- **实体**(3):女白领(character)、办公室工位(scene)、暖岚保温杯(product)
| 镜 | role | narration(口播) | visual(画面) |
| -- | ---- | ------------- | ----------- |
| 0 | 钩子 | 办公室久坐总喝凉水?冬天接的开水没半小时就凉透了? | 近景对准工位上凉掉的玻璃杯,镜头快速拉到女主皱眉摸凉杯子的脸,运镜带轻微晃动感强化痛点,最后扫过桌面空水杯位 |
| 1 | 痛点 | 每次想喝口热的还要反复去接水,通勤带的水到公司早就凉透,太麻烦了 | 中景拍女主抱着凉杯子起身走向茶水间,切特写拍她擦旧保温杯漏湿的包的烦躁表情,运镜平缓写实强化共鸣 |
| 2 | 卖点 | 这款暖岚316保温杯24小时长效保温,单手弹盖就能开,放包里横放也不洒 | 特写对准保温杯一键弹开的动作,慢放拍316内胆细节,切女主单手接水直接塞包的动作,运镜顺滑突出便捷性 |
| 3 | CTA | 现在点下方小黄车就能入手,久坐上班族随时都能喝到温热水啦 | 女主手持保温杯对着镜头微笑展示正面,镜头慢慢推近到保温杯logo和下单弹窗提示,停留3秒强化记忆点 |
<details><summary>原始输出预览(前 600 字)</summary>
```
正在为暖岚316不锈钢保温杯生成4镜种草向竖屏带货脚本~
```json
{
"hook": "办公室久坐总喝凉水?这款保温杯24小时锁温超省心!",
"tone": "种草",
"aspect_ratio": "9:16",
"total_duration": 60,
"segment_count": 4,
"entities": [
{
"id": "c1",
"type": "character",
"name": "女白领",
"visual_prompt": "25岁左右年轻女白领,穿简约通勤衬衫,长相亲切有亲和力,办公淡妆自然",
"ref_index": 1,
"voice_ref": null
},
{
"id": "s1",
"type": "scene",
"name": "办公室工位",
"visual_prompt": "明亮现代的互联网公司工位,桌面放着电脑、笔记本,自然光充足,9:16竖屏构图",
"ref_index": 2,
"voice_ref": null
},
{
"id": "p1",
"type": "produ
```
</details>
### ✅ 【doubao · doubao-seed-2-0-pro-260215】策略:structured · 46.0s · raw_clean=是
**规范化成稿:**
- **hook**:你是不是也忙起来连喝口热的都顾不上,到嘴的水早就凉透了?
- **tone**:种草 · **时长**:60s · **画幅**:9:16
- **实体**(3):职场女主(character)、办公室工位(scene)、暖岚保温杯(product)
| 镜 | role | narration(口播) | visual(画面) |
| -- | ---- | ------------- | ----------- |
| 0 | 钩子 | 你是不是也忙起来连喝口热的都顾不上,到嘴的水早就凉透了? | 近景对准女主皱着眉摸了摸手边凉掉的普通水杯,镜头慢慢拉远扫过堆满文件的工位,最后定格在女主失落的表情上。 |
| 1 | 痛点 | 早上带的热水中午就凉,灌包里还总洒得到处都是,真的太闹心了。 | 特写女主翻出被水打湿的通勤包,拿着湿纸巾擦包内的文件,镜头给到旁边没扣紧的普通水杯,女主无奈叹气摇头。 |
| 2 | 卖点 | 这款暖岚保温杯24小时保温,单手就能弹开,放包里横放也不洒,太适合上班族了。 | 特写女主拿起暖岚保温杯,手指一按就弹开盖子喝了一口,镜头扫过316内胆细节,再给到保温杯塞进包的动作。 |
| 3 | CTA | 现在点下方小黄车就能入手,冬天上班随时能喝上热乎的,太舒服啦。 | 女主手持保温杯正对镜头,包装正面朝向观众,镜头慢慢推近到杯身暖岚logo,下方弹出小黄车弹窗动效。 |
<details><summary>原始输出预览(前 600 字)</summary>
```
{"hook":"你是不是也忙起来连喝口热的都顾不上,到嘴的水早就凉透了?","tone":"种草","aspect_ratio":"9:16","total_duration":60,"segment_count":4,"entities":[{"id":"c1","type":"character","name":"职场女主","visual_prompt":"25-30岁年轻女白领,穿浅灰色通勤西装,扎低马尾,气质清爽干练,面带亲和力","ref_index":1},{"id":"s1","type":"scene","name":"办公室工位","visual_prompt":"现代化开放式办公室工位,明亮冷白光,桌面摆放笔记本电脑、便签纸、绿植,整洁有氛围感","ref_index":2},{"id":"p1","type":"product","name":"暖岚保温杯","visual_prompt":"奶白色暖岚316不锈钢保温杯,500ml容量,顶部有黑色一键弹盖按钮,杯身印有暖岚极简logo","ref_index":3}],"segments":[{"index":0,"duration":15,"role":"钩子","narration":"你是不是也忙起来连喝口热的都顾不上,到嘴的水早就凉透了?","visual":"近景对准女主皱着眉摸了摸手边凉掉的普
```
</details>
### ✅ 【doubao · doubao-seed-2-0-pro-260215】策略:tool · 86.7s · raw_clean=是
**规范化成稿:**
- **hook**:办公室经常喝凉水的朋友快看过来!
- **tone**:种草 · **时长**:60s · **画幅**:9:16
- **实体**(3):办公室女白领(character)、办公室工位(scene)、暖岚316不锈钢保温杯(product)
| 镜 | role | narration(口播) | visual(画面) |
| -- | ---- | ------------- | ----------- |
| 0 | 钩子 | 办公室经常喝凉水的朋友快看过来!是不是早上接的水下午就冰了? | 近景对准女生工位上放着的半杯凉水,镜头上摇到女生皱着眉摸水杯的表情,手持镜头轻微晃动带真实感 |
| 1 | 痛点 | 冬天想喝口热的太费劲,开会腾不出手拧杯盖,装包里还总洒漏弄湿文件。 | 中景拍女生开会时想喝水拧不开杯盖的窘迫,再切到她翻包看到被水打湿的笔记本的无奈表情 |
| 2 | 卖点 | 这款暖岚保温杯24小时保温,单手就能弹开,316内胆用着放心,放包里横放也不洒。 | 特写对准保温杯按动弹盖的动作,再切到女生单手开杯喝水的轻松状态,最后给内胆细节特写 |
| 3 | CTA | 现在点下方小黄车就能入手,办公室久坐的打工人真的可以备一个! | 女生手持保温杯正对镜头展示,镜头拉远给到工位全景,下方弹出小黄车弹窗提示 |
<details><summary>原始输出预览(前 600 字)</summary>
```
{"hook": "办公室经常喝凉水的朋友快看过来!", "tone": "种草", "aspect_ratio": "9:16", "total_duration": 60, "segment_count": 4, "entities": [{"id":"c1","type":"character","name":"办公室女白领","visual_prompt":"25岁左右年轻女生,穿休闲通勤西装,扎低马尾,气质清爽,坐在工位前","ref_index":1},{"id":"s1","type":"scene","name":"办公室工位","visual_prompt":"明亮现代办公区,浅木色书桌,放着电脑、笔记本,暖黄台灯光线柔和","ref_index":2},{"id":"p1","type":"product","name":"暖岚316不锈钢保温杯","visual_prompt":"奶白色500ml保温杯,圆润杯身,黑色一键弹盖按钮,杯身印有暖岚简约logo","ref_index":3}], "segments": [{"index":0,"duration":15,"role":"钩子","narration":"办公室经常喝凉水的朋友快看过来!是不是早上接的水下午就冰了?","visual":"近景对准女生工位上放着的半杯凉水,镜头上摇到女生皱着眉摸水杯的
```
</details>
---
## gpt · `gpt-5.5`
### ✅ 【gpt · gpt-5.5】策略:freeform · 56.7s · raw_clean=否
**规范化成稿:**
- **hook**:熬夜加班的人,早上还想喝口温热水吗?
- **tone**:痛点 · **时长**:60s · **画幅**:9:16
- **实体**(5):年轻白领女主(character)、暖岚316不锈钢保温杯500ml(product)、深夜办公室工位(scene)、早晨通勤包旁(scene)、会议室桌面(scene)
| 镜 | role | narration(口播) | visual(画面) |
| -- | ---- | ------------- | ----------- |
| 0 | 钩子 | 熬夜加班的人,早上还想喝口温热水吗?这杯我真的离不开。 | 竖屏近景从女主趴在深夜工位抬头开始,镜头缓慢推近她疲惫的脸,再落到桌上的暖岚保温杯,情绪从困倦转到被热水安慰。 |
| 1 | 痛点 | 以前普通杯子放一晚就凉,早上赶路还怕漏,包里纸巾都遭殃。 | 镜头切到清晨通勤包旁的中景,女主翻包找东西,先露出被打湿纸巾的尴尬,再手持旧杯皱眉,画面从慌乱转为想解决问题。 |
| 2 | 卖点 | 暖岚316内胆很安心,晚上灌水,早上喝还是温的,横放也不洒。 | 竖屏特写展示杯盖弹开和内胆细节,镜头跟随女主把杯子横放进包里再拿出,水面稳定不外漏,画面从细节检查过渡到放心收包。 |
| 3 | CTA | 开会单手一按就能喝。想少踩杯子坑,点下方小黄车看看。 | 会议室中景里女主一手看电脑一手按开杯盖喝水,镜头轻微跟拍到杯身正面,再定格包装和杯盖按钮,状态从忙乱变得从容。 |
<details><summary>原始输出预览(前 600 字)</summary>
```
在为暖岚保温杯生成 4 镜办公室痛点脚本…
```json
{
"hook": "熬夜加班的人,早上还想喝口温热水吗?",
"tone": "痛点",
"aspect_ratio": "9:16",
"total_duration": 60,
"segment_count": 4,
"entities": [
{
"id": "c1",
"type": "character",
"name": "年轻白领女主",
"visual_prompt": "25岁左右年轻白领女性,黑色中长发,浅色针织衫配西装外套,干净通勤妆容,气质疲惫但利落,适合办公室短视频口播",
"ref_index": 1,
"voice_ref": null
},
{
"id": "p1",
"type": "product",
"name": "暖岚316不锈钢保温杯500ml",
"visual_prompt": "暖岚品牌500ml保温杯,简约磨砂浅米色杯身,杯盖带一键弹盖按钮,杯身有小巧品牌标识,316不锈钢内胆质感,适合通勤办公场景",
"ref_index": 2,
"voice_ref": null
```
</details>
### ✅ 【gpt · gpt-5.5】策略:structured · 96.4s · raw_clean=否
**规范化成稿:**
- **hook**:你是不是也忙到水凉了才想起来喝?
- **tone**:痛点 · **时长**:60s · **画幅**:9:16
- **实体**(3):年轻白领女生(character)、现代办公室工位(scene)、暖岚 316 不锈钢保温杯 500ml(product)
| 镜 | role | narration(口播) | visual(画面) |
| -- | ---- | ------------- | ----------- |
| 0 | 钩子 | 你是不是也忙到水凉了才想起来喝?办公室真的需要这种保温杯。 | 竖屏中景拍白领女生盯着电脑加班,伸手摸到普通杯子皱眉,镜头慢慢推近她失落的表情,最后桌角暖岚保温杯虚焦入画。 |
| 1 | 痛点 | 早会、改方案、回消息,一上午过去,想喝口热的都像抽盲盒。 | 手持跟拍她在工位和会议桌之间来回切换,普通杯子被遗忘在桌边,画面从匆忙敲键盘转到她拿起冷水杯的无奈停顿。 |
| 2 | 卖点 | 暖岚这只316内胆,晚上灌开水,第二天早上喝还是温热的。 | 特写暖岚保温杯旋开杯盖,热气轻轻冒出,镜头从杯口推到316内胆和杯身标识,再切到女生双手捧杯喝水,表情从疲惫变放松。 |
| 3 | CTA | 一键弹盖单手喝,横放包里也不怕洒。想省心喝热水,点下方小黄车。 | 近景拍她一手拿文件一手按开弹盖喝水,随后把杯子横放进通勤包再取出,镜头拉回正面手持包装展示,眼神轻松对镜点头。 |
<details><summary>原始输出预览(前 600 字)</summary>
```
在为这款保温杯生成 4 镜办公室痛点脚本…
```json
{
"hook": "你是不是也忙到水凉了才想起来喝?",
"tone": "痛点",
"aspect_ratio": "9:16",
"total_duration": 60,
"segment_count": 4,
"entities": [
{
"id": "c1",
"type": "character",
"name": "年轻白领女生",
"visual_prompt": "25岁左右年轻白领女生,齐肩深棕色头发,浅色针织衫配西装外套,妆容干净自然,气质亲和,有轻微疲惫但真实的办公室状态",
"ref_index": 1,
"voice_ref": null
},
{
"id": "s1",
"type": "scene",
"name": "现代办公室工位",
"visual_prompt": "现代城市办公室工位,桌面有电脑、文件、便签和台灯,冷暖混合光,背景有模糊同事与会议室玻璃,9:16竖屏构图,生活化带货质感",
"ref_index": 2,
"voice_ref": null
},
{
```
</details>
### ✅ 【gpt · gpt-5.5】策略:tool · 33.1s · raw_clean=是
**规范化成稿:**
- **hook**:你是不是忙到一上午都喝不上热水?
- **tone**:痛点 · **时长**:60s · **画幅**:9:16
- **实体**(3):办公室女白领(character)、现代办公室工位(scene)、暖岚 316 不锈钢保温杯 500ml(product)
| 镜 | role | narration(口播) | visual(画面) |
| -- | ---- | ------------- | ----------- |
| 0 | 钩子 | 你是不是忙到一上午都喝不上热水?这杯子我最近天天带。 | 女白领坐在工位前快速回消息,桌角保温杯若隐若现;镜头从电脑屏幕推到她疲惫表情,再落到杯身,情绪从忙乱转向被吸引。 |
| 1 | 痛点 | 早上倒的水,中午想喝却凉了,开会还腾不出手拧杯盖。 | 女白领一手拿资料一手翻电脑,想喝水却被普通杯盖卡住;手持跟拍她来回忙碌,桌面纸杯冒气到变冷,画面从急促变无奈。 |
| 2 | 卖点 | 暖岚这只316内胆,晚上灌热水,第二天早上喝着还温温的。 | 镜头切到保温杯内胆和倒水特写,女白领按下一键弹盖喝水;由杯口热气细节推到她放松的表情,画面从冷清变得温暖。 |
| 3 | CTA | 单手开盖还防漏,通勤包横放也安心。需要就点下方小黄车。 | 女白领把保温杯横放进通勤包再拿起对镜展示;镜头从包内防漏细节拉到杯身正面,最后她微笑点向屏幕下方,动作干脆。 |
<details><summary>原始输出预览(前 600 字)</summary>
```
{"aspect_ratio":"9:16","entities":[{"id":"c1","name":"办公室女白领","ref_index":1,"type":"character","visual_prompt":"年轻女性白领,25-30岁,黑色中长发,浅色针织衫配西装外套,干净自然妆容,气质亲和,有轻微疲惫感但精神利落"},{"id":"s1","name":"现代办公室工位","ref_index":2,"type":"scene","visual_prompt":"现代办公室工位场景,桌面有电脑、文件、台灯和通勤包,暖色自然光,干净通透,竖屏9:16构图,生活化带货风格"},{"id":"p1","name":"暖岚 316 不锈钢保温杯 500ml","ref_index":3,"type":"product","visual_prompt":"暖岚品牌500ml保温杯,简约磨砂杯身,细长便携杯型,一键弹盖结构,杯盖密封圈细节清晰,316不锈钢内胆,适合办公通勤场景"}],"hook":"你是不是忙到一上午都喝不上热水?","segment_count":4,"segments":[{"duration":15,"entity_refs":["c1","s1","p1"],"index":0,"narration":"你是不是忙到一上午都喝不上热水?这杯子我最近天
```
</details>
---
## gemini · `gemini-3.1-pro-preview`
### ✅ 【gemini · gemini-3.1-pro-preview】策略:freeform · 29.0s · raw_clean=否
**规范化成稿:**
- **hook**:每天带水杯通勤,不是漏水弄脏包,就是开会忙起来腾不出手?
- **tone**:痛点 · **时长**:60s · **画幅**:9:16
- **实体**(3):女主(character)、办公室工位(scene)、暖岚保温杯(product)
| 镜 | role | narration(口播) | visual(画面) |
| -- | ---- | ------------- | ----------- |
| 0 | 钩子 | 每天带水杯通勤,不是漏水弄脏包,就是开会忙起来连喝水都腾不出手? | 全景到手持跟拍,女主在工位上急忙翻找通勤包,发现水杯漏水弄湿了文件,表情从焦急到崩溃,镜头推近包里的水渍。 |
| 1 | 痛点 | 尤其是天冷了,想喝口热的,还得双手去拧盖子,键盘敲一半真不想动。 | 中景转特写,女主一边单手敲击电脑键盘,另一只手试图去拧传统水杯盖子,怎么也拧不开,无奈地放弃并叹气。 |
| 2 | 卖点 | 换上暖岚316保温杯,一键弹盖单手就能喝。晚上灌的热水第二天还烫嘴,横着扔包里也滴水不漏。 | 特写加推镜头,女主单手轻松按键弹开暖岚保温杯喝水,随后画面切到杯子倒置用力摇晃,一滴水也没漏,表情转为惊喜。 |
| 3 | CTA | 办公室熬夜党真的少不了它,几十块钱能用好几年,左下角趁有活动赶紧给自己安排上! | 近景固定镜头,女主微笑着把保温杯放在电脑旁,手持杯身向镜头展示质感,手指指向画面左下方,情绪满是推荐感。 |
<details><summary>原始输出预览(前 600 字)</summary>
```
正在为您生成暖岚保温杯的 60 秒 4 镜带货脚本,主打办公通勤痛点……
```json
{
"hook": "每天带水杯通勤,不是漏水弄脏包,就是开会忙起来腾不出手?",
"tone": "痛点",
"aspect_ratio": "9:16",
"total_duration": 60,
"segment_count": 4,
"entities": [
{
"id": "c1",
"type": "character",
"name": "女主",
"visual_prompt": "20多岁的年轻都市白领,穿着干练的通勤装,妆容精致,略显忙碌",
"ref_index": 1,
"voice_ref": null
},
{
"id": "s1",
"type": "scene",
"name": "办公室工位",
"visual_prompt": "明亮的现代办公室工位,桌上有电脑、文件和键盘,光线自然,充满职场氛围",
"ref_index": 2,
"voice_ref": null
},
{
"id": "p1",
"type": "pr
```
</details>
### ✅ 【gemini · gemini-3.1-pro-preview】策略:structured · 33.9s · raw_clean=是
**规范化成稿:**
- **hook**:你是不是也一忙起来就忘了喝水,等想起来水早凉透了?
- **tone**:痛点 · **时长**:60s · **画幅**:9:16
- **实体**(3):女主(character)、办公桌旁(scene)、暖岚保温杯(product)
| 镜 | role | narration(口播) | visual(画面) |
| -- | ---- | ------------- | ----------- |
| 0 | 钩子 | 你是不是也一忙起来就忘了喝水,等想起来去喝,水早就凉透了? | 近景特写:女主坐在办公桌前敲键盘,眉头微皱,拿起普通水杯喝了一口却被冷水冰到,无奈放下。镜头缓慢推近放大烦躁情绪。 |
| 1 | 痛点 | 而且随手塞进包里还容易漏水,开会时一手拿文件一手根本拧不开杯盖,太崩溃了。 | 中景手持跟拍:女主从通勤包里拿出一沓被水阴湿的文件叹气。画面一切,她单手抱着文件,另一只手焦急拧旧水杯盖却拧不开。 |
| 2 | 卖点 | 换了暖岚保温杯,一键弹盖单手就能喝。晚上倒的开水第二天还是热的,横放也不漏水。 | 特写转全景:特写暖岚保温杯一键弹盖冒出热气。镜头拉开,女主单手轻松饮水,并倒置摇晃杯身展示滴水不漏,表情转为轻松满意。 |
| 3 | CTA | 316食品级内胆用着更安心。打工人别在喝水上凑合,趁现在有活动,左下角给自己安排上! | 近景固定镜头:女主微笑着将暖岚保温杯放在办公桌显眼处,手指轻敲杯身,随后指了指屏幕左下方。画面给出杯子内胆的清晰特写。 |
<details><summary>原始输出预览(前 600 字)</summary>
```
{
"hook": "你是不是也一忙起来就忘了喝水,等想起来水早凉透了?",
"tone": "痛点",
"aspect_ratio": "9:16",
"total_duration": 60,
"segment_count": 4,
"entities": [
{
"id": "c1",
"type": "character",
"name": "女主",
"visual_prompt": "25岁左右职场年轻女性,穿着简约干练的浅色通勤衬衫,化着淡妆,略显疲惫但依旧专注",
"ref_index": 1
},
{
"id": "s1",
"type": "scene",
"name": "办公桌旁",
"visual_prompt": "明亮现代的办公室桌面,有电脑显示器、整齐的文件和绿色盆栽,自然光从侧面打入,职场通勤风格",
"ref_index": 2
},
{
"id": "p1",
"type": "product",
"name": "暖岚保温杯",
"visual_prompt": "简约高颜值的500ml不锈钢保温杯,磨砂质感杯身,
```
</details>
### ✅ 【gemini · gemini-3.1-pro-preview】策略:tool · 20.9s · raw_clean=是
**规范化成稿:**
- **hook**:你是不是也一到下午,杯子里的水就凉透了,开完会连口热茶都喝不上?
- **tone**:痛点 · **时长**:60s · **画幅**:9:16
- **实体**(3):女主(character)、现代办公室(scene)、暖岚保温杯(product)
| 镜 | role | narration(口播) | visual(画面) |
| -- | ---- | ------------- | ----------- |
| 0 | 钩子 | 你是不是也一到下午,杯子里的水就凉透了,开完会连口热茶都喝不上? | 女主坐在办公桌前,拿起一个普通水杯喝水,皱眉发现水已经凉透了,镜头从全景推近到特写,展现她略显疲惫无奈的表情变化。 |
| 1 | 痛点 | 早上挤地铁不敢把水杯放包里怕漏,工作忙起来一手敲键盘,根本腾不出手去拧旧杯盖,太难了。 | 女主一手疯狂敲击键盘,另一只手试图去拧开桌上的旧水杯盖子,结果滑了一下没拧开,手持跟拍展现她急躁焦虑的动作情绪变化。 |
| 2 | 卖点 | 直到换了这款暖岚保温杯,一键弹盖单手就能喝。316内胆昨晚的热水今天还是温的,放包里横着也不漏。 | 镜头特写保温杯,女主单手大拇指轻轻一按,“啪”地弹开杯盖,紧接着镜头摇到内胆特写,最后展示杯子在包里横放无漏水的状态变化。 |
| 3 | CTA | 对咱们久坐熬夜的打工人来说,随时喝口热水太重要了。几十块钱提升幸福感,点左下角小黄车给自己安排上。 | 女主单手拿着暖岚保温杯微笑着喝了一口热水,神情放松,镜头微微拉远给出全身景别,画面从忙碌过渡到惬意享受的状态。 |
<details><summary>原始输出预览(前 600 字)</summary>
```
{"aspect_ratio":"9:16","entities":[{"id":"c1","name":"女主","ref_index":1,"type":"character","visual_prompt":"年轻白领女性,穿着干练通勤装,略显疲惫但妆容精致,适合职场打工人设定"},{"id":"s1","name":"现代办公室","ref_index":2,"type":"scene","visual_prompt":"现代明亮的办公室,光线充足,办公桌上放着电脑显示器、文件和凌乱的文具,充满工作氛围"},{"id":"p1","name":"暖岚保温杯","ref_index":3,"type":"product","visual_prompt":"暖岚500ml不锈钢保温杯,简约纯色设计,带有按键弹盖结构,外观高级有质感"}],"hook":"你是不是也一到下午,杯子里的水就凉透了,开完会连口热茶都喝不上?","segment_count":4,"segments":[{"duration":15,"entity_refs":["c1","s1"],"index":0,"narration":"你是不是也一到下午,杯子里的水就凉透了,开完会连口热茶都喝不上?","product_exposure":"反面对照(拿旧杯子)","role":"钩子","visual":"
```
</details>
@@ -0,0 +1,318 @@
# 脚本生成 Agent · 流式 SSE 编排技术文档
> 对象代码:[`core/backend/apps/ai/script_agent.py`](../backend/apps/ai/script_agent.py) 的 `stream_script_agent()`
> 端点:`POST /api/projects/{id}/script-agent-stream/` → `text/event-stream`
> 一句话定位:把一次「调大模型出脚本」的过程,包装成一条**可见的、可计费的、可断点回滚的** SSE 事件流,给前端真 agent 体感,同时由后端而非模型保证结构化结果的可靠性。
---
## 0. 全局视角
```
前端 fetch(POST script-agent-stream) ──SSE──▶ 浏览器逐帧消费
│ ▲
▼ │ data: {json}\n\n
Django StreamingHttpResponse │
│ 包裹 │
▼ │
stream_script_agent() ← 同步生成器,每 yield 一帧
┌────────────┼─────────────────────────────────────────┐
│ │ │
加载skill 建AITask+预扣额度 调豆包流式SSE
(tool) (reserve_credit) (reasoning/delta)
│ │ │
└──▶ 抽取JSON+规范化 ──▶ 落库ScriptVersion ──▶ charge额度 ──▶ saved/summary/done
```
核心思想三条:
1. **进度即事件**:内部每一步(加载技能 / 分析商品 / 生成分镜 / 提取实体 / 自检)都吐一张「工具卡」,让用户看到 agent 在干活,而不是对着一个转圈等几十秒。
2. **结构化结果由后端兜底**:模型只管「生成」,JSON 的抽取、字段对齐、镜数补齐全在后端做,不信任模型的排版纪律。
3. **计费与流式生命周期绑定**:额度预扣(reserve)→ 成功结算(charge/ 失败或断连释放(release),用 `try/finally` 覆盖包括客户端断连在内的所有退出路径。
---
## 1. SSE 帧格式与事件协议
### 1.1 帧编码
每一帧都是标准 SSE
```python
def _sse(obj: dict) -> str:
return f"data: {json.dumps(obj, ensure_ascii=False)}\n\n"
```
- `ensure_ascii=False`:中文不转义,前端直接拿到可读文本。
- 每帧一个 JSON 对象,必带 `type` 字段,前端按 `type` 分派渲染。
- 结尾 `\n\n` 是 SSE 规范的事件分隔符。
### 1.2 事件类型清单
| type | 载荷 | 语义 | 是否进入「答案」 |
| ---- | ---- | ---- | ---- |
| `tool` | `{id, label?, status: running\|done\|error}` | 工具卡:内部步骤可视化 | 否(纯进度) |
| `reasoning` | `{text}` | 推理模型思考流,逐字 | 否(纯展示) |
| `delta` | `{text}` | 模型自然语言前言 | 否(前言,JSON 不外露) |
| `draft` | `{draft}` | 规范化后的 ScriptDraft | 是(结构化渲染) |
| `saved` | `{script_version_id, version}` | 已落库的 ScriptVersion | 是 |
| `summary` | `{text}` | 模型写的收尾交付语 | 是(当 AI 回复气泡) |
| `done` | `{}` | 正常结束 | — |
| `error` | `{detail}` | 失败(额度已回滚) | — |
---
## 2. 逐帧时序详解
下面按生成器实际 `yield` 顺序拆解,标注每一步的技术意图。
### 阶段 A · 加载技能(同步、毫秒级)
```python
yield _sse({"type": "tool", "id": "skill", "label": "加载电商脚本技能", "status": "running"})
skill_loaded = bool(load_ecommerce_skill())
yield _sse({"type": "tool", "id": "skill", "status": "done" if skill_loaded else "error"})
```
- `load_ecommerce_skill()``@lru_cache(maxsize=1)`:把 `SKILL.md + references/*.md` 拼成系统提示词,进程内只读一次磁盘。
- 同一个 `id: "skill"` 先发 `running` 再发 `done`,前端据 `id` 原地更新同一张卡的状态,而不是堆两张卡。
- 缺文件不致命:`load_ecommerce_skill` 有兜底字符串,`skill_loaded` 仍为 True。
### 阶段 B · 分析商品 + 构建消息(同步)
```python
yield _sse({"type": "tool", "id": "analyze", "label": f"分析商品:{project.product.title}", "status": "running"})
# ... 加载基准稿(改稿)、校验镜号、build_agent_messages ...
yield _sse({"type": "tool", "id": "analyze", "status": "done"})
```
这一阶段做了几件关键的前置判断:
1. **改稿才加载基准稿**`mode == "revise" and base_version_id``_load_base_draft` 读出历史稿。基准稿拿不到则 `target_index = None`,单镜改无从谈起,退回整版生成。
2. **改稿用基准稿的真实时长**`effective_duration = base_draft.total_duration or total_duration`。这是修「90s/6镜稿被请求侧默认 60 挤掉尾镜」的关键——前端可能硬编码 60,但改稿必须尊重原稿镜数。
3. **镜号越界先于建任务**`target_index` 非空时校验 `0 <= target_index < seg_n`,越界直接 `yield error``return`,**绝不建任务/扣费**,避免计费空转的静默 no-op。
> 注意所有 `target_index` 判断一律用 `is None`**不能用真值判断**——`0` 是合法镜号(第 1 镜),`if target_index:` 会把第 1 镜误当未指定。
### 阶段 C · 建任务 + 预扣额度
```python
task_type = AITask.Type.SCRIPT_OPTIMIZATION if mode == "revise" else AITask.Type.SCRIPT_GENERATION
try:
task = create_ai_task(project=..., task_type=task_type, model_config=..., request_payload={...})
except Exception as exc:
yield _sse({"type": "error", "detail": f"任务创建失败(可能额度不足):{exc}"})
return
reservation = task.credit_reservation
```
- `create_ai_task` 内部 `@transaction.atomic`:建 `AITask`CREATED)→ `reserve_credit` 预扣 → 置 RESERVED。预扣失败(余额不足)抛异常,这里转成 `error` 事件优雅返回。
- `reservation` 句柄留到后面 charge/release 用。
### 阶段 D · 调模型流式生成(核心,耗时几十秒)
这是整个流程最重的一段,包在 `try/finally`(计费兜底)+ 内层 `try/except`(生成失败处理)里。
```python
settled = False # 额度是否已结算
try:
yield _sse({"type": "tool", "id": "generate", "label": "按黄金结构生成分镜", "status": "running"})
full: list[str] = [] # 累积模型正文
shown = 0 # 已外露给前端的可见字符数
forwarding = True # 是否仍在转发前言(遇到 JSON 起点后置 False)
try:
task.status = AITask.Status.SUBMITTED; task.save(...)
provider = build_provider(model_config)
for ev in provider.chat_completion_stream(model=..., messages=messages, temperature=0.85):
et = ev.get("type")
if et == "reasoning":
rpiece = ev.get("text") or ""
if rpiece:
yield _sse({"type": "reasoning", "text": rpiece})
continue
if et == "delta":
full.append(ev["text"])
if forwarding:
text = "".join(full)
cut = _visible_cut(text)
if cut < len(text):
forwarding = False
visible = text[:cut]
if len(visible) > shown:
piece = visible[shown:]
shown = len(visible)
if piece.strip():
yield _sse({"type": "delta", "text": piece})
elif et == "done":
break
raw = "".join(full)
draft = normalize_draft(raw, aspect_ratio=..., total_duration=effective_duration)
if target_index is not None and base_draft:
draft = _merge_single_segment(base_draft, draft, target_index, ...)
except Exception as exc:
_fail_task(task, reservation, str(exc)); settled = True
yield _sse({"type": "tool", "id": "generate", "status": "error"})
yield _sse({"type": "error", "detail": f"脚本生成失败:{exc}"})
return
```
#### D.1 两种 delta 的区分(reasoning vs content
底层 [`chat_completion_stream`](../backend/apps/ai/providers/volcano.py) 把 OpenAI 兼容 SSE 的 `delta` 拆成两路:
- `delta.reasoning_content``{type: "reasoning"}`
- `delta.content``{type: "delta"}`
豆包 seed-pro 这类**推理模型**在出 JSON 前会先思考几十秒,思考期**只发 `reasoning_content`、不发 `content`**。如果不单独转发 reasoning,整个思考期前端零输出 = 假死(用户看到「按黄金结构生成分镜」卡了几十秒以为崩了)。所以 reasoning 逐字下发、纯展示、`continue` 掉不进 `full`(它不是答案正文)。
#### D.2 前言可见区裁剪(`_visible_cut`
模型按运行时输出协议会先说一句口语前言("在为这款保温杯生成 4 镜痛点脚本…"),紧接着吐 ` ```json ` 代码块。前端只该看到前言,不该看到刷屏的 JSON。
```python
def _visible_cut(text: str) -> int:
"""可见区终点 = JSON 起点(``` 或第一个 {)。"""
cands = []
for marker in ("```", "{"):
i = text.find(marker)
if i != -1:
cands.append(i)
return min(cands) if cands else len(text)
```
转发逻辑用三个游标协作:
- `full`:累积**全部**模型正文(含 JSON),用于最后解析。
- `shown`:已经 `delta` 出去的可见字符数,保证只增量发新字符、不重发。
- `forwarding`:一旦 `cut < len(text)`(即出现了 ``` 或 `{`),说明前言结束、JSON 开始,置 False,此后不再转发任何 `delta`JSON 不外露)。
只发 `piece.strip()` 非空的片段,避免把纯空白也当帧发出去。
#### D.3 抽取与规范化
`raw = "".join(full)` 是模型完整正文。`normalize_draft(raw, ...)` 负责「不信任模型排版」的全部兜底(抽 JSON、配平括号、挑内容最丰富的 segments 数组、字段模糊匹配、镜数对齐补齐),详见 [`script_agent.py`](../backend/apps/ai/script_agent.py) 的 `_extract_json` / `_resolve_segments` / `_pick_field`
#### D.4 精准改一镜的合并
`target_index is not None and base_draft` 时,模型虽被要求只改第 N 镜并输出完整稿,但后端不信它会乖乖保留其余镜——`_merge_single_segment` 以基准稿深拷贝为底,**只用新稿的第 N 镜替换**,其余镜逐字保持,再整体规范化。模型没产出目标镜则抛错(不静默返回 base 空转计费)。
### 阶段 E · 自检卡 + draft 事件
```python
yield _sse({"type": "tool", "id": "generate", "status": "done"})
yield _sse({"type": "tool", "id": "extract", "label": f"提取实体 {len(draft['entities'])} 个 · {len(draft['segments'])}", "status": "done"})
yield _sse({"type": "tool", "id": "check", "label": "自检:镜数 / ≤55字 / 违规词", "status": "done"})
yield _sse({"type": "draft", "draft": draft})
```
注意 `extract` / `check` 卡直接发 `done`——它们是**对已完成结果的事后陈述**(实体数、镜数都已知),不是真有独立的耗时步骤,目的是补齐 agent 工作流的叙事完整性。`draft` 事件把结构化稿交给前端做卡片化渲染。
### 阶段 F · 落库 + 结算额度
```python
try:
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.response_payload = {"raw": raw[:8000]}
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now(); task.save(...)
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
source = "revise" if mode == "revise" else ("theme" if mode == "theme" else "ai")
script = persist_script_draft(project=..., task=task, draft=draft, source=source)
settled = True # charge 已提交
except Exception as exc:
_fail_task(task, reservation, f"保存脚本失败:{exc}"); settled = True
yield _sse({"type": "error", "detail": f"保存脚本失败:{exc}"})
return
```
- **charge 与落库同一事务**`charge_reserved_credit``persist_script_draft` 在同一个 `transaction.atomic()` 里。落库失败则 atomic 回滚 charge`_fail_task` 补释放预留——钱和数据强一致。
- `settled = True` 标记额度已结算,给最外层 finally 看(见第 3 节)。
- `persist_script_draft``ScriptVersion + ScriptSegment`,并把 entities 回填 `project.metadata`cast/scenes/script_entities),把 SCRIPT 阶段标 `NEEDS_REVIEW`
### 阶段 G · saved / summary / done
```python
yield _sse({"type": "saved", "script_version_id": str(script.id),
"version": ScriptVersionSerializer(script).data})
summary = _closing_summary(raw)
if summary:
yield _sse({"type": "summary", "text": summary})
yield _sse({"type": "done"})
```
`_closing_summary` 取「最后一个 JSON 对象之后的文字」当 AI 回复气泡——这是模型在协议第 3 步写的口语交付语("这版主打熬夜痛点,钩子用了反差,你可以再让我调 CTA")。去掉收尾的 ``` 围栏,若残留 `{` 或太短(<4 字)则返回空串,由前端兜底默认句。
---
## 3. 计费生命周期与断连兜底(最易踩坑处)
整段生成包在:
```python
settled = False
try:
... # 阶段 D~G
finally:
if not settled:
_fail_task(task, reservation, "stream aborted (client disconnected)")
```
为什么必须用 `finally` 而不是普通 `except`
> 客户端中途断连时,Django 会对生成器调用 `.close()`,在当前 `yield` 处抛 **`GeneratorExit`**。它继承自 `BaseException` 而非 `Exception`,普通 `except Exception` 抓不到。若不处理,预扣的额度会永久冻结(既没 charge 也没 release)。
`settled` 标志覆盖所有路径:
| 退出路径 | settled | finally 动作 |
| ---- | ---- | ---- |
| 正常完成(charge 成功) | True | 不动 |
| 生成异常(D.4 except | True(已 `_fail_task` | 不动 |
| 落库失败(F except | True(已 `_fail_task` | 不动 |
| 客户端断连(GeneratorExit | False | `_fail_task` 释放预扣 |
`_fail_task` 自身也防御性 `try/finally`:先置任务 FAILED,再 `release_credit`,release 失败也吞掉(不让兜底逻辑自身抛异常)。
---
## 4. 关键技术细节备忘
| 细节 | 说明 |
| ---- | ---- |
| **同步生成器 + StreamingHttpResponse** | `stream_script_agent` 是普通同步 `def + yield`,不是 async。Django 的 `StreamingHttpResponse` 直接迭代它,每 `yield` 一帧立即下发。 |
| **关 nginx 缓冲** | 端点设 `X-Accel-Buffering: no` + `Cache-Control: no-cache`,否则 nginx 会攒够 buffer 才下发,破坏逐帧体感。 |
| **DRF 必须挂 SSE renderer** | `@action(..., renderer_classes=[ServerSentEventRenderer])`,否则 DRF 内容协商返回 406。 |
| **UTF-8 强制** | 底层 `chat_completion_stream` 设 `response.encoding = "utf-8"`SSE 不带 charset 时 requests 默认 latin-1 会让中文乱码。 |
| **temperature 0.85** | 脚本生成要发散有创意;对比实体提取那条用 0.3(结构化抽取要稳,降 JSON 漂移)。 |
| **同 id 工具卡原地更新** | `running → done/error` 复用同一 `id`,前端据 id 更新而非新增卡片。 |
| **reasoning 不进 full** | 思考流纯展示,`continue` 跳过累积,避免污染待解析正文。 |
| **raw 截断存档** | `task.response_payload = {"raw": raw[:8000]}`,存证据但限长,失败时可回看模型到底吐了啥。 |
---
## 5. 前端消费契约(给前端对接者)
按 `type` 分派即可:
- `tool`:维护一个 `Map<id, {label, status}>`,渲染成进度卡列表;同 id 更新状态。
- `reasoning`:追加到「思考过程」可折叠区(灰字、逐字滚动)。
- `delta`:追加到 AI 前言气泡。
- `draft`:用结构化数据渲染分镜卡片(hook/tone/segments),可直接编辑。
- `saved`:拿 `script_version_id` 标记当前稿,`version` 是完整序列化对象可直接入列表。
- `summary`:作为 AI 的收尾回复气泡(没有则用默认句兜底)。
- `done`:关闭 loading。
- `error`:弹 `detail`,此时后端已回滚额度,前端无需补偿。
---
## 6. 涉及文件索引
| 文件 | 角色 |
| ---- | ---- |
| [`apps/ai/script_agent.py`](../backend/apps/ai/script_agent.py) | 本文主体:`stream_script_agent` 编排 + normalize/merge/persist |
| [`apps/projects/views.py`](../backend/apps/projects/views.py) | `script_agent_stream` 端点 + `ServerSentEventRenderer` |
| [`apps/ai/providers/volcano.py`](../backend/apps/ai/providers/volcano.py) | `chat_completion_stream` 底层 SSEreasoning/delta 分流 |
| [`apps/ai/services.py`](../backend/apps/ai/services.py) | `build_provider` 可插拔分流、`create_ai_task` 预扣 |
| [`apps/billing/services/ledger.py`](../backend/apps/billing/services/ledger.py) | `reserve/charge/release_credit` |
| [`skills/ecommerce-video-script/SKILL.md`](../backend/skills/ecommerce-video-script/SKILL.md) | 领域知识(系统提示词) |
@@ -0,0 +1,311 @@
# 脚本 Agent 编排架构方案 · 动态知识装配(流式管道)
> 状态:设计方案(未落地代码) · 作者:架构评审 · 日期:2026-06-24
> 关联代码:[`apps/ai/script_agent.py`](../backend/apps/ai/script_agent.py) · [`apps/ai/services.py`](../backend/apps/ai/services.py) · [`skills/ecommerce-video-script/`](../backend/skills/ecommerce-video-script/)
> 关联文档:[脚本Agent流式SSE技术文档.md](脚本Agent流式SSE技术文档.md)(现状) · [出格式实测-模型产出汇总.md](出格式实测-模型产出汇总.md)(实测数据)
---
## 1. 背景与要解决的问题
### 1.1 现状
当前脚本生成把领域知识一次性全量灌入上下文:`load_ecommerce_skill()`(见 [script_agent.py:54](../backend/apps/ai/script_agent.py#L54))
`glob("*.md")` **无条件遍历全部 references**,整篇拼成系统提示词,每次对话(不论全自动/一句话/改稿/改一镜)
都把这 ~28K 字符全量发给模型。
SKILL.md 里虽写了一张「输入模式路由 / 参考资料索引」表(指明哪个品类该读哪几篇),但**该路由仅作为提示词
发给模型,后端并未按它选择性加载**——检索发生在模型的注意力里,不在后端。
### 1.2 随业务增长的问题
> **核心痛点:上下文随电商品类数量线性膨胀。**
现在 5 个 references = 28K 字符。未来叠加更多品类话术(美妆/食品/3C/服饰/家居/母婴/宠物/家电…)
与平台调性后,全量灌入会:
- **上下文臃肿**:单次请求 system prompt 可能涨到数十万字符,逼近/超出上下文窗口;
- **成本线性上涨**:每次都付全量知识的 token,即便这单商品只用得上其中一个品类包;
- **注意力稀释**:无关品类的话术挤占模型注意力,可能拉低相关品类的发挥;
- **缓存难命中**:动态拼接的大 prompt 难以稳定复用 prefix cache。
### 1.3 目标
把「静态全量灌入」改为「**按需动态装配**」:拿到商品需求后,**只加载与该商品相关的知识模块**,
打包给模型,流式生成,再经过滤/提取输出前端。**单次上下文只随"命中的 1-2 个品类包"走,不随品类总量膨胀。**
---
## 2. 设计目标(验收标准)
| # | 目标 | 可度量标准 |
| - | ---- | ---------- |
| G1 | 上下文不随品类总数膨胀 | 单次 system prompt 字数 ≈ 内核 + 命中模块,与品类总数解耦 |
| G2 | 输出格式永不因知识缺失而塌 | 任意路由结果下,输出契约恒在 prompt 中 |
| G3 | 全程流式 | 路由/装配/生成/提取每阶段都有 SSE 进度事件 |
| G4 | 运营可扩品类不改代码 | 加一个品类 = 加一个知识模块(文件/DB),无需改 Python |
| G5 | 不引入与任务不匹配的重型框架 | 沿用生成器流式编排,不上状态图运行时 |
| G6 | 平滑迁移 | 分阶段落地,每阶段可独立上线、可回滚 |
---
## 3. 总体架构:六段流式管道
```
商品需求(product + 前置条件)
▼ ① 路由 Router ────────── 识别品类/平台 → 决定加载哪些知识模块
│ SSE: tool router "识别品类:美妆洗护 · 平台:抖音"
▼ ② 装配 Assembler ─────── 取「内核 + 命中品类包 + 命中平台调性」
│ SSE: tool assemble "装配知识:内核+2模块 共 9.2K 字" ← 可见地证明未膨胀
▼ ③ 打包 Packager ──────── 内核(恒在) + 动态知识 + 商品上下文 + 输出契约
▼ ④ 生成 Generator ─────── 约束解码(tool/structured)流式出结构
│ SSE: reasoning(思考) / delta(口语前言)
▼ ⑤ 过滤提取 Extractor ─── 校验/归一/抽实体/扫违规词(强不变量,后端兜底)
│ SSE: tool extract "提取实体4个 · 自检通过"
▼ ⑥ 前端 Sink ──────────── draft / saved / summary / done
```
**与现状的本质差异**:②③ 从"glob 全部"变为"只装命中"。①②⑤ 是真实工作步骤,不再是装样子的工具卡。
---
## 4. 核心设计:知识分两层
把现有单块 28K 知识拆成**内核(恒在)+ 模块(动态)**两层。
### 4.1 内核 Kernel(每次必带,小而稳)
| 内容 | 来源(现状) |
| ---- | ---------- |
| 黄金结构(钩子→痛点→卖点→CTA)、档位×结构映射 | methodology.md 的结构部分 |
| **输出契约(铁律1):字段名锚定、JSON 形状、镜数规则** | SKILL.md 铁律1 + `_OUTPUT_PROTOCOL` |
| 写作红线:≤55字、违规词清单、口语化 | methodology.md 红线 + SKILL.md 铁律3 |
| 字段纪律:tone/role 枚举、entity 引用合法性 | SKILL.md 铁律1 |
> ⚠️ **输出契约必须在内核里,永远在。** 这是你们踩过的坑(skills 没进镜像→契约丢失→模型吐散文解析失败)
> 的正式解。无论路由加载了哪些品类包,格式硬底线都不会塌。现有 `_EXTRACT_OUTPUT_CONTRACT`
> (见 [services.py:385](../backend/apps/ai/services.py#L385))写死兜底,就是这一思想的雏形——把它正式化为"内核"。
### 4.2 品类模块 Module(按商品命中才加载,多而长)
| 模块类型 | 例 | frontmatter 选择维度 |
| ------- | -- | ------------------- |
| 品类话术 | 美妆/食品/3C/服饰/家居… | `applies_to: [category...]` |
| 平台调性 | 抖音/快手/小红书/视频号 | `platforms: [...]` |
| 钩子库分册 | 痛点提问/反差/数字冲击… | `tone: [...]` 或 always |
每个模块是一个独立的、带元数据索引的知识单元(不再是一坨大 concat)。
---
## 5. 路由机制:怎么选模块
三种机制,按本场景适配度排序。**推荐 rule-first 混合**。
### 5.1 元数据路由(主力 · 先落地这个)
把 SKILL.md 的路由表**从提示词搬进代码**:每个模块 frontmatter 声明它服务的品类/平台,
`select_knowledge(product)``product.category` / 平台前置条件命中。
- **优点**:零额外调用、确定性、可解释、可单测。电商商品基本都有 category 字段,**80% 情况足够**。
- **缺点**:新品类要维护映射——但加品类 = 加一个 `.md` 模块 + 写 frontmatter,**不改 Python**(满足 G4)。
### 5.2 向量检索 RAG(扩容兜底 · 品类破百再上)
把知识块 embedding,用商品上下文检索 top-K 相关片段。
- **优点**:处理模糊/新品类(novel category 自动匹配近邻),可无限扩。
- **缺点**:引入检索失败模式(检错块→知识缺失)、需 embedding 基建与运维。**别过早引入。**
### 5.3 LLM 路由(灵活但加跳)
用便宜快模型(如 doubao-lite)先分类"该商品属哪类、用哪套话术"。
- **优点**:最灵活,能理解复杂商品描述。**缺点**:多一次调用 + 延迟。
### 5.4 推荐:rule-first 混合
```
select_knowledge(product):
modules = [Kernel] # 恒在
hit = rule_match(product.category, platform) # 5.1 规则命中
if hit:
modules += hit
else:
modules += fallback() # 命中不到:回落(全量核心包 or 5.2 检索)
return modules
```
**先只做 5.1 规则版,留好 `fallback()` 接口**;品类规模或模糊度上来时,把 `fallback` 换成检索/LLM 路由。
---
## 6. 接口设计
### 6.1 知识模块结构(frontmatter 规范)
每个 reference 模块在文件头加 YAML frontmatter(或等价 DB 字段):
```markdown
---
id: playbook-beauty
type: category # core | category | platform | hook
applies_to: [美妆, 护肤, 洗护, 彩妆] # 命中这些 category 时加载
platforms: [] # 限定平台(空=不限)
keywords: [精华, 面膜, 口红, 防晒] # 检索/模糊命中用
priority: 10
enabled: true
---
(正文:该品类的话术、语气、卖点侧重…)
```
`core` 类型 = 内核,恒加载;其余按 `applies_to`/`platforms`/`keywords` 命中。
### 6.2 选择函数(替换 `load_ecommerce_skill`)
```python
# apps/ai/knowledge.py(新增)
@dataclass
class KnowledgeModule:
id: str
type: str # core|category|platform|hook
applies_to: list[str]
platforms: list[str]
keywords: list[str]
body: str
def load_registry() -> list[KnowledgeModule]:
"""扫 skills/ 下模块(含 frontmatter),或读 DB。缓存。"""
def select_knowledge(*, product, platform: str | None, mode: str) -> list[KnowledgeModule]:
"""rule-first:内核恒在 + 按 category/platform 命中品类包/平台调性;
命中不到走 fallback(全量核心 or 检索)。改稿模式可少带选题类模块。"""
def assemble_system_prompt(modules: list[KnowledgeModule]) -> tuple[str, int]:
"""拼 system prompt = 内核(置顶稳定,利于 prefix cache) + 动态模块。
返回 (prompt, 字数) —— 字数用于 SSE 上报,可见证明未膨胀。"""
```
`build_agent_messages`(见 [script_agent.py:111](../backend/apps/ai/script_agent.py#L111))
`system = load_ecommerce_skill() + _OUTPUT_PROTOCOL` 改为
`system, n = assemble_system_prompt(select_knowledge(...))`,其中输出契约并入内核。
### 6.3 SSE 事件扩展
在现有 `tool/reasoning/delta/draft/saved/summary/done` 基础上,让 ①②⑤ 成为**真实**工具卡:
| 事件 | 新增/变化 | 载荷 |
| ---- | -------- | ---- |
| `tool: router` | 新增 | `{label:"识别品类:美妆·抖音", status, meta:{category, platform}}` |
| `tool: assemble` | 新增 | `{label:"装配知识 内核+2模块 9.2K字", status, meta:{module_ids, chars}}` |
| `tool: generate` | 不变 | 约束解码流式 |
| `tool: extract` | 强化 | 真实体提取 + 违规词自检结果 |
> `assemble` 卡把"这次只装了 9.2K 而非 28K"**可观测地**展示给用户/运维,是 G1 的活体证明。
---
## 7. 生成与过滤提取(④⑤)
### 7.1 生成:约束解码,让 normalize 退居安全网
结合 [出格式实测-模型产出汇总.md](出格式实测-模型产出汇总.md) 的实测结论:
- 三模型(豆包/GPT-5.5/Gemini-3.1-pro)的 structured/tool 均可产出 `raw_clean✓` 的契约 JSON;
- **freeform 下三家原始输出全 `raw_clean✗`**(靠 `normalize_draft` fuzzy 抢救);
- **tool 策略跨模型 segments 键集完全同构**(最稳)。
→ 生成阶段改用 **tool/structured 约束解码**(schema 须补全 `dialogue/speaker/voice_ref` 等契约字段),
内核保留输出契约文字作双保险。`normalize_draft` 从"主力解析器"降为"安全网"。
> ⚠️ 约束解码与"先写口语前言"的 `_OUTPUT_PROTOCOL` 有张力(实测 GPT structured 被前言污染)。
> 落地时**对话气泡(前言/收尾)与结构稿分离**:结构走纯约束,气泡另起轻量一跳或用支持混合流的部件协议。
### 7.2 过滤提取:强不变量后端兜底
沿用并简化现有逻辑(约束解码后原始已干净,兜底压力骤降):
- 镜数对齐(=时长/15)、role/tone 枚举归一、entity_refs 合法性 —— `normalize_draft` 现有能力;
- 实体抽取(角色/场景)—— 复用 [services.py](../backend/apps/ai/services.py) 的 `run_extract_entities_task`;
- 违规词自检 —— 可前置为真校验节点(发现即标记/可触发重生成)。
---
## 8. 为什么不用 LangGraph
本管道是**线性流水线**(router→assemble→generate→extract→sink):**无环、无 reflect-retry、无多 agent 对话**。
线性 + 流式正是现有生成器范式的最佳 altitude。LangGraph 的状态图是为"有环/有分支/要回退/human-in-loop"
设计的,**此处上图属过度设计**。真正的编排升级点是"选择性装配"这一层抽象,而非更换运行时。
> 若未来产品要做「生成→自检违规词→自动修正→再检」的真闭环,或「编剧/审查/提取」多 agent 协作,
> 那时再评估 LangGraph / PydanticAI(类型契约+重试)/ 轻量自写 retry。当前不需要。
---
## 9. 分阶段迁移清单
每阶段可独立上线、独立回滚。
### Phase 1 · 知识分层 + 规则路由(止血膨胀,优先级最高)
- [ ] references 加 frontmatter(`type/applies_to/platforms/keywords`);抽出 `core` 内核。
- [ ] 新增 `apps/ai/knowledge.py`:`load_registry` / `select_knowledge`(规则版)/ `assemble_system_prompt`
- [ ] `build_agent_messages` 改用 `assemble_system_prompt(select_knowledge(...))`;**输出契约并入内核**。
- [ ] SSE 增 `router`/`assemble` 真实工具卡(含字数)。
- [ ] 单测:命中/未命中/改稿模式各自加载了哪些模块;内核必含契约。
- **验收**:单次 system prompt 字数与品类总数解耦(G1);格式零回归。
### Phase 2 · 约束解码(让 normalize 退居安全网)
- [ ] 定义完整 `ScriptDraft` JSON Schema(含 dialogue/speaker/voice_ref)。
- [ ] 生成阶段切 tool/structured(按 provider 能力分流,实测已验证三家可行)。
- [ ] 对话气泡与结构稿分离,解决 `_OUTPUT_PROTOCOL` 与约束的张力。
- [ ] `normalize_draft` 降级为兜底;保留以防个别模型/中转站不合规。
- **验收**:三模型原始输出 `raw_clean✓`;normalize 命中率(需抢救比例)大幅下降。
### Phase 3 · 检索扩容(品类规模化后才做)
- [ ] 知识块 embedding + 向量库;`fallback()` 接入检索。
- [ ] 模糊/新品类召回评估。
- **验收**:新增品类无需改路由规则即可被正确召回。
### Phase 4(可选)· 自检闭环
- [ ] 违规词/字数校验做成真节点,不过则带错误自动重生成(轻量 retry,非全图)。
---
## 10. 风险与对策
| 风险 | 对策 |
| ---- | ---- |
| **R1 内核漏放契约 → 某品类下格式塌** | 内核**必含**完整输出契约;单测断言"任意路由结果都含契约";保留写死兜底。 |
| **R2 路由漏召(该加载却没加载)→ 模型瞎编** | 漏召比误召危险。规则命中不到**必须回落**(全量核心包 or 检索),严禁裸奔。 |
| **R3 约束解码与对话气泡冲突** | 结构稿走纯约束、气泡分离(见 7.1);或用支持混合流的部件协议。 |
| **R4 prefix cache 未命中,内核成本没摊薄** | 内核置顶且稳定;实测豆包/中转是否支持 prefix cache 再定。 |
| **R5 检索引入新失败模式** | Phase 3 才上;上之前用规则兜底;检索结果可解释、可回退规则。 |
---
## 11. 与现有代码映射(速查)
| 设计组件 | 现状 | 落点 |
| ------- | ---- | ---- |
| 内核 + 模块拆分 | `load_ecommerce_skill()` glob 全部 | 新 `apps/ai/knowledge.py` |
| 路由 `select_knowledge` | SKILL.md 路由表(给模型看) | 新 `knowledge.py`,规则实现 |
| 装配 `assemble_system_prompt` | 字符串拼全部 | 新 `knowledge.py`,内核+命中 |
| 打包 | `build_agent_messages` system 拼接 | 改 [script_agent.py:122](../backend/apps/ai/script_agent.py#L122) |
| 生成(约束解码) | freeform + `_OUTPUT_PROTOCOL` | 改 provider 调用,见 [providers/](../backend/apps/ai/providers/) |
| 过滤提取 | `normalize_draft` 当主力 | 降为安全网 [script_agent.py:307](../backend/apps/ai/script_agent.py#L307) |
| 流式编排 | `stream_script_agent` 生成器 | 沿用,增 router/assemble 事件 [script_agent.py:561](../backend/apps/ai/script_agent.py#L561) |
| 模块运营管理 | `references/*.md` 文件 | frontmatter 文件,或复用 `PromptTemplate` admin DB 模式 |
---
## 12. 一句话总结
把「静态全量灌」改成「**内核恒在 + 品类模块按需装配**」的**线性流式管道**:路由先用规则(SKILL 路由表搬进代码)、
扩容再上检索;生成换约束解码让 `normalize_draft` 退居安全网;流式编排沿用现有生成器,**不需要 LangGraph**。
如此品类再叠,单次上下文也只随"命中的一两个包"走,**不随品类总量膨胀**。
+28 -7
View File
@@ -170,14 +170,20 @@
在 .content 内铺满可用高度(shell 由 App.tsx 渲染,这里只占正文)。
============================================================ */
.image-workbench {
/* 抵消 .content 的 48/28/72 padding,让工作室壳贴边铺满(同旧 .tool-shell 思路) */
margin: -48px -28px -72px;
/* 抵消 .content 的 24/28/60 padding,让工作室壳贴边铺满(同旧 .tool-shell 思路)
必须与 design-restraint.css .content 的 padding 严格一致:多减则顶部钻进 sticky
topbar(被遮)、底部溢出视口(被裁)。 */
margin: -24px -28px -60px;
height: calc(100vh - 64px);
display: flex;
flex-direction: column;
background: var(--background-base);
overflow: hidden;
}
/* <1100px 时 .content 改 28/24/48,负边距需同步,否则窄屏又会上下被遮/裁 */
@media (max-width: 1100px) {
.image-workbench { margin: -28px -24px -48px; }
}
/* ════════════════════════════════════════════════
通用:返回 pill(图片创作侧栏头 / 模特·平台侧栏头共用)
@@ -488,6 +494,18 @@
border-color: var(--heat-40);
font-weight: 600;
}
/* 手动输入比例:宽 : 高 两个数字框 */
.image-workbench .iw-ratio-manual {
display: flex; align-items: center; gap: 8px;
margin-top: 8px;
}
.image-workbench .iw-ratio-manual .input {
flex: 1; min-width: 0; height: 32px;
text-align: center;
}
.image-workbench .iw-ratio-manual .sep {
color: var(--black-alpha-48); font-size: 13px; flex-shrink: 0;
}
/* ── 模特选择 · 3:4 矩形卡多选(基线 .model-card)── */
.image-workbench .model-grid {
@@ -522,11 +540,14 @@
object-fit: cover; display: block;
background: var(--black-alpha-4);
}
.image-workbench .model-card .m-name { font-size: 13px; font-weight: 500; color: var(--accent-black); }
.image-workbench .model-card.selected .m-name { color: var(--heat); }
.image-workbench .model-card .m-tag {
font-family: var(--font-mono); font-size: 12px;
color: var(--black-alpha-48); letter-spacing: .02em;
/* 标题:遮罩层贴图片底部,白字单行省略,节省一行纵向空间 */
.image-workbench .model-card .m-name {
position: absolute; left: 0; right: 0; bottom: 0;
padding: 14px 8px 6px;
font-size: 13px; font-weight: 500; color: #fff;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
background: linear-gradient(to top, rgba(0, 0, 0, .62), rgba(0, 0, 0, 0));
pointer-events: none; z-index: 1;
}
.image-workbench .model-card .m-check {
position: absolute; top: 14px; right: 14px;
+54 -10
View File
@@ -432,7 +432,7 @@ const MODE_META: Record<
const RATIO_OPTIONS = ["1:1", "3:4", "4:5", "9:16", "16:9"];
const COUNT_OPTIONS = ["1", "2", "4"];
const MODEL_RATIO_OPTIONS = ["1:1", "3:4", "9:16"];
const MODEL_COUNT_OPTIONS = ["4", "8", "12"];
const MODEL_COUNT_OPTIONS = ["1", "2", "4"];
const COVER_COUNT_OPTIONS = ["4", "8", "12"];
/* 图片创作 · 空态提示词建议 chip(基线 image-optimize EXAMPLES) */
@@ -528,6 +528,10 @@ export function ImageWorkbenchPage({
const product = products.find((item) => item.id === productId) || products[0];
const [prompt, setPrompt] = useState(meta.promptTemplate(products[0]?.title || "商品"));
const [ratio, setRatio] = useState(meta.ratio);
// 手动输入比例:开启后用 W:H 两个输入框自定义,关闭则用预设 pill
const [ratioManual, setRatioManual] = useState(false);
const [ratioW, setRatioW] = useState("");
const [ratioH, setRatioH] = useState("");
const [style, setStyle] = useState("auto");
const [count, setCount] = useState(mode === "image" ? "4" : "4");
// 模特单选(长度恒 0/1);平台改回多选(P0③:可选多个平台,各出一组结果)
@@ -613,6 +617,9 @@ export function ImageWorkbenchPage({
if (product) setPrompt(meta.promptTemplate(product.title));
// mode 或商品切换都重置 prompt 与默认比例
setRatio(meta.ratio);
setRatioManual(false);
setRatioW("");
setRatioH("");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [productId, mode]);
@@ -1323,11 +1330,11 @@ export function ImageWorkbenchPage({
{product?.title || "未选择 · 请在左侧商品空间选一个"}
</span>
</div>
{/* P0④:商品库全屏选择器入口(多选商品,各起一批) */}
<button className="iw-pl-btn" type="button" onClick={() => { setPlDraft(productId ? [productId] : []); setPlQuery(""); setPlCat(""); setPlOpen(true); }} title="从商品库选择">
{/* P0④:商品库全屏选择器入口(多选商品,各起一批) — 临时屏蔽 */}
{/* <button className="iw-pl-btn" type="button" onClick={() => { setPlDraft(productId ? [productId] : []); setPlQuery(""); setPlCat(""); setPlOpen(true); }} title="">
<LayoutGrid size={13} />
</button>
</button> */}
<span className="spacer" />
<div className="tb-search-wrap">
{searchOpen && (
@@ -1402,9 +1409,8 @@ export function ImageWorkbenchPage({
<span className="ph-frame">{item.name.slice(0, 4)}</span>
</div>
)}
<div className="m-name">{item.name}</div>
</div>
<div className="m-name">{item.name}</div>
<div className="m-tag">// 真人模特</div>
</button>
);
})
@@ -1423,9 +1429,8 @@ export function ImageWorkbenchPage({
</span>
<div className="placeholder m-thumb">
<span className="ph-frame">{item.name}</span>
<div className="m-name">{item.name}</div>
</div>
<div className="m-name">{item.name}</div>
<div className="m-tag">{item.tag}</div>
</button>
))}
</div>
@@ -1481,13 +1486,52 @@ export function ImageWorkbenchPage({
<button
type="button"
key={value}
className={`opt ${ratio === value ? "active" : ""}`}
onClick={() => setRatio(value)}
className={`opt ${!ratioManual && ratio === value ? "active" : ""}`}
onClick={() => { setRatioManual(false); setRatio(value); }}
>
{value}
</button>
))}
<button
type="button"
className={`opt ${ratioManual ? "active" : ""}`}
onClick={() => {
setRatioManual(true);
if (ratioW && ratioH) setRatio(`${ratioW}:${ratioH}`);
}}
>
</button>
</div>
{ratioManual && (
<div className="iw-ratio-manual">
<input
className="input"
type="number"
min={1}
placeholder="宽"
value={ratioW}
onChange={(event) => {
const w = event.target.value;
setRatioW(w);
if (w && ratioH) setRatio(`${w}:${ratioH}`);
}}
/>
<span className="sep">:</span>
<input
className="input"
type="number"
min={1}
placeholder="高"
value={ratioH}
onChange={(event) => {
const h = event.target.value;
setRatioH(h);
if (ratioW && h) setRatio(`${ratioW}:${h}`);
}}
/>
</div>
)}
</div>
)}
<div className="iw-sub">
+66 -85
View File
@@ -10,6 +10,8 @@ import { SkeletonGrid } from "../components/loading";
import { api } from "../api";
const PROD_PAGE_SIZE = 10;
// 商品详情「AI 素材」grid 每页条数(4 列 → 3 整行)
const MAT_PAGE_SIZE = 12;
import type { Asset, Product, ProductMaterials, Project } from "../types";
import type { NavigateFn, Page } from "./route-config";
import "../product-create-page.css";
@@ -621,13 +623,8 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
const [triAdoptedId, setTriAdoptedId] = useState<string>("");
const triViewing = triVersions.find((v) => v.id === triViewingId) || triVersions[0];
const triUrl = triViewing?.url || "";
// 素材 tab 筛选 / 排序 / 分页 / 视图
const [openFilter, setOpenFilter] = useState<"" | "type" | "status" | "sort">("");
const [typeFilter, setTypeFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<"" | "pass" | "fail" | "pending">("");
const [assetView, setAssetView] = useState<"grid" | "list">("grid");
const [assetSortDesc, setAssetSortDesc] = useState(true);
const [assetLimit, setAssetLimit] = useState(12);
// AI 素材 grid 当前页(与全站列表一致:整体翻页)
const [matPage, setMatPage] = useState(1);
const [videoSortDesc, setVideoSortDesc] = useState(true);
// 素材审核状态为只读(直接反映 review_status),不再有用户点击覆盖
// 采用三视图后,本地 prepend 的新素材卡(供「采用版本联动 + prepend 新卡」即时呈现,不等服务端回灌)
@@ -665,19 +662,11 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
if (!alive) return;
setProductAssets(assetsRes?.results ?? []);
setMaterials(matRes);
setMatPage(1); // 切商品 / 刷新素材后回到第 1 页
}).finally(() => { if (alive) setAssetsLoading(false); });
return () => { alive = false; };
}, [product?.id, assetReload]);
useEffect(() => {
if (!openFilter) return;
const close = (event: MouseEvent) => {
if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenFilter("");
};
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [openFilter]);
async function onPickProductImage(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
@@ -757,29 +746,27 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
// 采用三视图后本地 prepend 的新卡:置顶且与服务端列表去重(服务端回灌同 id 后即不再重复)
const serverIds = new Set(serverImageAssets.map((a) => a.id));
const allImageAssets = [...adoptedAssets.filter((a) => !serverIds.has(a.id)), ...serverImageAssets];
// 联动采用状态前的「全部素材基线」(供 adoptTriView 找出该商品所有三视图)
const allAssetsBase = allImageAssets;
// 单个素材当前显示状态:本地覆盖(点击循环 / 采用联动)优先,否则回退真实 review_status 映射
const statusOf = (asset: Asset): PdAssetStatus => pdBaseStatus(asset);
// 类型筛选选项(当前素材里真实存在的 category)
const typeOptions = Array.from(new Set(allImageAssets.map((a) => a.category).filter(Boolean)));
// 全集计数(给状态筛选下拉做计数前缀,不随当前筛选变化)
const filteredAssets = allImageAssets
.filter((asset) => !typeFilter || asset.category === typeFilter)
.filter((asset) => !statusFilter || statusOf(asset) === statusFilter)
.slice()
.sort((a, b) => {
const cmp = (b.created_at || "").localeCompare(a.created_at || "");
return assetSortDesc ? cmp : -cmp;
});
const assetCount = filteredAssets.length;
const imageAssets = filteredAssets.slice(0, assetLimit);
// 结构化素材总数(角色 + 商品图 + 图片成品 + 项目包内素材),用于头部计数 / 空态判断
const matTotal = materials
? materials.roles.length + materials.product_images.length + materials.image_products.length + materials.project_packs.reduce((s, p) => s + p.items.length, 0)
: 0;
const hasActiveAssetFilter = Boolean(typeFilter || statusFilter);
const resetAssetFilters = () => { setTypeFilter(""); setStatusFilter(""); setAssetLimit(12); setOpenFilter(""); };
// 拍平 4 类素材为单序列(角色 → 商品图 → 图片成品 → 视频项目包),整体翻页(与全站列表统一 Pager 一致)
type MatItem =
| { kind: "role"; role: ProductMaterials["roles"][number] }
| { kind: "product_image"; asset: Asset }
| { kind: "image_product"; asset: Asset }
| { kind: "pack"; pack: ProductMaterials["project_packs"][number] };
const matItems: MatItem[] = materials
? [
...materials.roles.map((role) => ({ kind: "role", role } as MatItem)),
...materials.product_images.map((asset) => ({ kind: "product_image", asset } as MatItem)),
...materials.image_products.map((asset) => ({ kind: "image_product", asset } as MatItem)),
...materials.project_packs.map((pack) => ({ kind: "pack", pack } as MatItem)),
]
: [];
const matTotalPages = Math.max(1, Math.ceil(matItems.length / MAT_PAGE_SIZE));
const matCurPage = Math.min(matPage, matTotalPages);
const matPageItems = matItems.slice((matCurPage - 1) * MAT_PAGE_SIZE, matCurPage * MAT_PAGE_SIZE);
// 视频项目 · 用传入的该商品 projects 渲染真实项目名 / 状态 / 阶段(按更新时间排序)
const videoProjects = [...projects].sort((a, b) => {
const cmp = (b.updated_at || "").localeCompare(a.updated_at || "");
@@ -1075,68 +1062,62 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
<button className="btn btn-primary" type="button" onClick={() => navigate("imageOptimize", { productId: product.id })}></button>
</div>
) : (
<>
<div className="asset-grid pd-mat-grid">
{/* 角色:逐个(形象图+三视图绑一起);左上标签、左下「N 个项目引用」;点开看这套 */}
{materials!.roles.map((role) => {
const cover = pdAssetPreview(role.portrait);
return (
<div className="asset-card pd-role-card" key={`role-${role.id}`} role="button" tabIndex={0} title={role.name}
onClick={() => setOpenDetail({ title: role.name, sub: role.ref_count > 0 ? `角色 · ${role.ref_count} 个项目引用` : "角色 · 暂未引用", assets: [role.portrait, role.triview].filter(Boolean) as Asset[] })}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenDetail({ title: role.name, sub: role.ref_count > 0 ? `角色 · ${role.ref_count} 个项目引用` : "角色 · 暂未引用", assets: [role.portrait, role.triview].filter(Boolean) as Asset[] }); } }}>
<div className="thumb placeholder">
{cover ? <img src={cover} alt={role.name} loading="lazy" /> : <span className="ph-frame"></span>}
<span className="type-pill"></span>
{role.triview && <span className="pd-role-tri mono"></span>}
{matPageItems.map((item) => {
// 角色:逐个(形象图+三视图绑一起);左上标签、左下「N 个项目引用」;点开看这套
if (item.kind === "role") {
const role = item.role;
const cover = pdAssetPreview(role.portrait);
return (
<div className="asset-card pd-role-card" key={`role-${role.id}`} role="button" tabIndex={0} title={role.name}
onClick={() => setOpenDetail({ title: role.name, sub: role.ref_count > 0 ? `角色 · ${role.ref_count} 个项目引用` : "角色 · 暂未引用", assets: [role.portrait, role.triview].filter(Boolean) as Asset[] })}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenDetail({ title: role.name, sub: role.ref_count > 0 ? `角色 · ${role.ref_count} 个项目引用` : "角色 · 暂未引用", assets: [role.portrait, role.triview].filter(Boolean) as Asset[] }); } }}>
<div className="thumb placeholder">
{cover ? <img src={cover} alt={role.name} loading="lazy" /> : <span className="ph-frame"></span>}
<span className="type-pill"></span>
{role.triview && <span className="pd-role-tri mono"></span>}
</div>
<div className="meta pd-role-foot"><span className="mono">{role.ref_count > 0 ? `${role.ref_count} 个项目引用` : "// 暂未引用"}</span></div>
</div>
<div className="meta pd-role-foot"><span className="mono">{role.ref_count > 0 ? `${role.ref_count} 个项目引用` : "// 暂未引用"}</span></div>
</div>
);
})}
{/* 商品图:平铺 */}
{materials!.product_images.map((a) => {
);
}
// 视频项目包:场景+分镜+视频素材按项目打包,点开看整组
if (item.kind === "pack") {
const pack = item.pack;
const first = pack.items[0];
const cover = pdAssetPreview(first);
const isVid = first?.asset_type === "video";
return (
<div className="asset-card pd-pack-card" key={`pack-${pack.project_id || pack.project_name}`} role="button" tabIndex={0} title={pack.project_name}
onClick={() => setOpenDetail({ title: pack.project_name, sub: `视频项目 · ${pack.items.length} 个素材`, assets: pack.items })}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenDetail({ title: pack.project_name, sub: `视频项目 · ${pack.items.length} 个素材`, assets: pack.items }); } }}>
<div className="thumb placeholder">
{isVid && cover ? <video src={cover} muted playsInline preload="metadata" /> : cover ? <img src={cover} alt={pack.project_name} loading="lazy" /> : <span className="ph-frame"></span>}
<span className="type-pill"></span>
<span className="pd-group-count mono">{pack.items.length}</span>
</div>
<div className="meta"><span className="date">{pack.project_name}</span></div>
</div>
);
}
// 商品图 / 图片成品(模特上身图/平台套图/自由创作):平铺,各带类型标签
const a = item.asset;
const u = pdAssetPreview(a);
const label = item.kind === "product_image" ? "商品图" : pdAssetTypeLabel(a);
return (
<div className="asset-card" key={a.id}>
<div className="thumb placeholder" role={u ? "button" : undefined} style={u ? { cursor: "zoom-in" } : undefined} onClick={u ? () => setPreview({ src: u, name: a.name }) : undefined}>
{u ? <img src={u} alt={a.name} loading="lazy" /> : <span className="ph-frame"></span>}
<span className="type-pill"></span>
{u ? <img src={u} alt={a.name} loading="lazy" /> : <span className="ph-frame">{label}</span>}
<span className="type-pill">{label}</span>
</div>
<div className="meta"><span className="date">{(a.created_at || "").slice(0, 10)}</span></div>
</div>
);
})}
{/* 图片成品(模特上身图/平台套图/自由创作):平铺,各带类型标签 */}
{materials!.image_products.map((a) => {
const u = pdAssetPreview(a);
return (
<div className="asset-card" key={a.id}>
<div className="thumb placeholder" role={u ? "button" : undefined} style={u ? { cursor: "zoom-in" } : undefined} onClick={u ? () => setPreview({ src: u, name: a.name }) : undefined}>
{u ? <img src={u} alt={a.name} loading="lazy" /> : <span className="ph-frame">{pdAssetTypeLabel(a)}</span>}
<span className="type-pill">{pdAssetTypeLabel(a)}</span>
</div>
<div className="meta"><span className="date">{(a.created_at || "").slice(0, 10)}</span></div>
</div>
);
})}
{/* 视频项目包:场景+分镜+视频素材按项目打包,点开看整组 */}
{materials!.project_packs.map((pack) => {
const first = pack.items[0];
const cover = pdAssetPreview(first);
const isVid = first?.asset_type === "video";
return (
<div className="asset-card pd-pack-card" key={`pack-${pack.project_id || pack.project_name}`} role="button" tabIndex={0} title={pack.project_name}
onClick={() => setOpenDetail({ title: pack.project_name, sub: `视频项目 · ${pack.items.length} 个素材`, assets: pack.items })}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenDetail({ title: pack.project_name, sub: `视频项目 · ${pack.items.length} 个素材`, assets: pack.items }); } }}>
<div className="thumb placeholder">
{isVid && cover ? <video src={cover} muted playsInline preload="metadata" /> : cover ? <img src={cover} alt={pack.project_name} loading="lazy" /> : <span className="ph-frame"></span>}
<span className="type-pill"></span>
<span className="pd-group-count mono">{pack.items.length}</span>
</div>
<div className="meta"><span className="date">{pack.project_name}</span></div>
</div>
);
})}
</div>
<Pager page={matCurPage} total={matItems.length} pageSize={MAT_PAGE_SIZE} onChange={setMatPage} />
</>
)}
</div>