diff --git a/RULES.md b/RULES.md
index 6a850aa..89647a8 100644
--- a/RULES.md
+++ b/RULES.md
@@ -11,7 +11,7 @@
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`)
- 第一冲刺:步骤 1-4(下载 / 拆轨 / 关键帧 / ASR+翻译)
-- 当前产品方向(2026-05-18 再确认):先解决信息流广告快速复刻的第一步,不再沿用“开始后线性完成抽帧、分镜、元素生成、合成”的旧做法。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取 12 张参考帧,供人工选择可用主体并生成相似主体视图。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴规划新口播、镜头类型、首帧/尾帧、人物需求和产品出现方式;单条或“一键提交全部”生成视频时,按该行规划自动调取产品图、人物主体和参考帧。
+- 当前产品方向(2026-05-18 再确认):先解决信息流广告快速复刻的第一步,不再沿用“开始后线性完成抽帧、分镜、元素生成、合成”的旧做法。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧,供人工选择可用主体并生成相似主体白底视图。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴规划新口播、镜头类型、首帧/尾帧、人物需求和产品出现方式;当前暂停直接调视频模型,先逐条用“相似主体视图 + 产品素材池 + 首尾帧文字规划”生成并审核首帧/尾帧,保存规划后再决定哪些分镜进入单条视频候选。
## 部署事实
- 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik)
diff --git a/api/main.py b/api/main.py
index 5b54dbc..4d1b42e 100644
--- a/api/main.py
+++ b/api/main.py
@@ -330,6 +330,7 @@ class StoryboardScene(BaseModel):
first_image: dict | None = None
last_image: dict | None = None
product_images: list[dict] = Field(default_factory=list)
+ subject_images: list[dict] = Field(default_factory=list)
product_fusion_shots: list[dict] = Field(default_factory=list)
visual_mode: Literal["person_only", "person_product", "product_only", "environment"] = "person_product"
needs_product: bool = True
@@ -1274,6 +1275,44 @@ def _make_reference_contact_sheet(job_id: str, frame_indices: list[int], out_pat
return out_path
+def _make_paths_contact_sheet(paths: list[Path], out_path: Path, max_items: int = 10) -> Path | None:
+ usable: list[Path] = []
+ seen: set[str] = set()
+ max_items = max(2, min(12, int(max_items or 10)))
+ for p in paths:
+ key = str(p)
+ if key in seen or not p.exists():
+ continue
+ seen.add(key)
+ usable.append(p)
+ if len(usable) >= max_items:
+ break
+ if len(usable) <= 1:
+ return usable[0] if usable else None
+
+ thumbs: list[Image.Image] = []
+ for p in usable:
+ try:
+ im = Image.open(p).convert("RGB")
+ im.thumbnail((420, 420), Image.Resampling.LANCZOS)
+ canvas = Image.new("RGB", (420, 420), (245, 245, 245))
+ canvas.paste(im, ((420 - im.width) // 2, (420 - im.height) // 2))
+ thumbs.append(canvas)
+ except Exception:
+ continue
+ if len(thumbs) <= 1:
+ return usable[0] if usable else None
+
+ cols = 4 if len(thumbs) > 6 else (3 if len(thumbs) > 2 else 2)
+ rows = (len(thumbs) + cols - 1) // cols
+ sheet = Image.new("RGB", (cols * 420, rows * 420), (245, 245, 245))
+ for i, thumb in enumerate(thumbs):
+ sheet.paste(thumb, ((i % cols) * 420, (i // cols) * 420))
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ sheet.save(out_path, "JPEG", quality=92)
+ return out_path
+
+
SUBJECT_VIEW_LABELS: dict[str, str] = {
"front": "正面",
"back": "背面",
@@ -3732,6 +3771,8 @@ class GenerateSceneAssetReq(BaseModel):
asset_role: SceneAssetRole = "scene"
prompt: str = ""
source_frame_indices: list[int] | None = None
+ subject_images: list[dict] = Field(default_factory=list)
+ product_images: list[dict] = Field(default_factory=list)
class GenerateSubjectAssetsReq(BaseModel):
@@ -3899,11 +3940,20 @@ def generate_scene_asset(job_id: str, idx: int, req: GenerateSceneAssetReq) -> J
source_indices = list(dict.fromkeys(source_indices))[:8]
model_src = src
sheet_tmp: Path | None = None
+ asset_sheet_tmp: Path | None = None
if len(source_indices) > 1:
sheet_tmp = job_dir(job_id) / "tmp" / f"scene_refs_{idx:03d}_{uuid.uuid4().hex[:6]}.jpg"
sheet = _make_reference_contact_sheet(job_id, source_indices, sheet_tmp)
if sheet:
model_src = sheet
+ subject_ref_paths = [p for p in (storyboard_ref_path(job_id, r) for r in req.subject_images[:8]) if p and p.exists()]
+ product_ref_paths = [p for p in (storyboard_ref_path(job_id, r) for r in req.product_images[:6]) if p and p.exists()]
+ asset_ref_paths = [*subject_ref_paths, *product_ref_paths]
+ if req.asset_role != "scene" and asset_ref_paths:
+ asset_sheet_tmp = job_dir(job_id) / "tmp" / f"endpoint_refs_{idx:03d}_{uuid.uuid4().hex[:6]}.jpg"
+ asset_sheet = _make_paths_contact_sheet(asset_ref_paths, asset_sheet_tmp, max_items=10)
+ if asset_sheet:
+ model_src = asset_sheet
confirmed_subjects = [
(e.name_en or e.name_zh).strip()
@@ -3925,9 +3975,13 @@ def generate_scene_asset(job_id: str, idx: int, req: GenerateSceneAssetReq) -> J
else "Remove the main foreground subject from the frame if present. "
)
identity_clause = (
- "Known character identity cues: " + ", ".join(confirmed_subjects) + ". "
- if confirmed_subjects
- else "Infer one consistent friendly transparent human character identity from the provided references. "
+ f"Use the generated subject asset references as the primary character identity lock ({len(subject_ref_paths)} image(s)); keep the same transparent body shell, clean visible skeleton, proportions, material, and ad-friendly non-horror identity. "
+ if subject_ref_paths
+ else (
+ "Known character identity cues: " + ", ".join(confirmed_subjects) + ". "
+ if confirmed_subjects
+ else "Infer one consistent friendly transparent human character identity from the provided references. "
+ )
)
mode_clause = {
"remove_subject": (
@@ -3956,10 +4010,21 @@ def generate_scene_asset(job_id: str, idx: int, req: GenerateSceneAssetReq) -> J
if user_prompt
else ""
)
- reference_clause = (
- f"Use the selected reference frame contact sheet as visual evidence for location, composition, lighting, materials, and atmosphere. Reference frame indices: {', '.join(str(i + 1) for i in source_indices)}. "
- if len(source_indices) > 1
- else "Use the provided frame as the primary visual reference. "
+ if req.asset_role != "scene" and asset_ref_paths:
+ reference_clause = (
+ f"Use the provided asset contact sheet as the primary visual reference: {len(subject_ref_paths)} generated subject image(s) and {len(product_ref_paths)} SKG product image(s). "
+ "Do not use the original keyframe as the first/last-frame truth; it is only a storage anchor for this row. "
+ )
+ else:
+ reference_clause = (
+ f"Use the selected reference frame contact sheet as visual evidence for location, composition, lighting, materials, and atmosphere. Reference frame indices: {', '.join(str(i + 1) for i in source_indices)}. "
+ if len(source_indices) > 1
+ else "Use the provided frame as the primary visual reference. "
+ )
+ product_asset_clause = (
+ "Use the provided SKG product references as the rigid product truth when the user prompt asks for product presence: a white U-shaped neck-and-shoulder wearable massage device worn around the neck/shoulders, not headphones, a collar pillow, skincare, food, or a medical prop. Keep product scale believable, preserve left/right asymmetry, side thickness, inner contact pads, buttons, white material, and real wearable placement. "
+ if product_ref_paths
+ else "Do not invent a random product. Only include an SKG product if the user prompt explicitly asks for it. "
)
if req.asset_role == "scene":
prompt = (
@@ -3986,6 +4051,7 @@ def generate_scene_asset(job_id: str, idx: int, req: GenerateSceneAssetReq) -> J
+ reference_clause
+ user_prompt_clause
+ style_clause + " "
+ + product_asset_clause
+ TRANSPARENT_HUMAN_POSITIVE_PROMPT + " "
+ TRANSPARENT_HUMAN_NEGATIVE_PROMPT + " "
+ "The frame must feature the same friendly transparent or translucent human character: glass/acrylic/vinyl-like transparent outer body, visible clean white skeleton inside, clean commercial wellness style, non-horror. "
@@ -3997,6 +4063,8 @@ def generate_scene_asset(job_id: str, idx: int, req: GenerateSceneAssetReq) -> J
try:
if req.asset_role == "scene":
img_bytes, _mode = _image_edit_call(model_src, prompt, models=models, fallback_text=False, max_attempts=3, max_side=1280)
+ elif asset_ref_paths:
+ img_bytes, _mode = _image_edit_call(model_src, prompt, models=models, fallback_text=False, max_attempts=3, max_side=1600)
else:
img_bytes, _mode = _image_text_call(prompt, models=models, max_attempts=3)
except RuntimeError as e:
@@ -4005,6 +4073,9 @@ def generate_scene_asset(job_id: str, idx: int, req: GenerateSceneAssetReq) -> J
if sheet_tmp and sheet_tmp.exists():
try: sheet_tmp.unlink()
except OSError: pass
+ if asset_sheet_tmp and asset_sheet_tmp.exists():
+ try: asset_sheet_tmp.unlink()
+ except OSError: pass
asset_id = f"scene_{idx:03d}_{uuid.uuid4().hex[:8]}"
out_path = job_dir(job_id) / "assets" / f"{asset_id}.jpg"
@@ -4387,6 +4458,7 @@ class UpdateStoryboardReq(BaseModel):
first_image: dict | None = None
last_image: dict | None = None
product_images: list[dict] = Field(default_factory=list)
+ subject_images: list[dict] = Field(default_factory=list)
product_fusion_shots: list[dict] = Field(default_factory=list)
visual_mode: Literal["person_only", "person_product", "product_only", "environment"] = "person_product"
needs_product: bool = True
@@ -5562,6 +5634,7 @@ def update_storyboard(job_id: str, idx: int, req: UpdateStoryboardReq) -> Job:
first_image=req.first_image,
last_image=req.last_image,
product_images=list(req.product_images),
+ subject_images=list(req.subject_images),
product_fusion_shots=list(req.product_fusion_shots),
visual_mode=req.visual_mode,
needs_product=bool(req.needs_product),
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index 8e74971..e05433c 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -569,13 +569,13 @@
业务管线
- 当前产品方向已收窄为“信息流广告快速复刻”:主界面左侧是素材输入列,右侧是信息流复刻工作表。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动音频文案路和视频视觉路。音频文案路提取原音频文案/字幕,分析讲话人、语速节奏、背景音乐/环境声/音效,并为后续新口播和分镜文案提供时间轴;视频视觉路同步自动抽取 12 张参考帧,供人工选择可用主体并生成相似主体视图包。产品图上传后独立形成产品资产包:自动识别视角、左右/上下/内外侧、结构点、比例和风险,并补缺角度。最终分镜规划按逐句时间轴把文案、参考帧、主体资产和产品资产汇合,单条或“一键提交全部”生成视频时再按行调取需要的资源。
+ 当前产品方向已收窄为“信息流广告快速复刻”:主界面左侧是素材输入列,右侧是信息流复刻工作表。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动音频文案路和视频视觉路。音频文案路提取原音频文案/字幕,分析讲话人、语速节奏、背景音乐/环境声/音效,并为后续新口播和分镜文案提供时间轴;视频视觉路同步抽取参考帧,参考帧只用于人工选择主体并生成相似主体白底视图。产品图上传后独立形成产品资产包:自动识别视角、左右/上下/内外侧、结构点、比例和风险,并补缺角度。最终分镜规划按逐句时间轴把文案、相似主体资产和产品资产汇合;当前暂停直接调视频模型,先逐条生成并审核首帧/尾帧,确认后再决定哪些分镜进入视频候选。
1
导入素材
粘贴 TK / 信息流视频链接或上传本地视频;“开始”只把任务放入第一步队列。
2
下载源视频
后端用 yt-dlp 或本地上传文件落 source.mp4,记录时长、尺寸和视频只读地址。
3
并行素材分析
下载完成后前端同时触发 triggerTranscribe 和 analyzeJob:音频路生成字幕/节奏/背景音,视觉路自动抽 12 张参考帧。
-
4
资产包准备
用户可删除/补选参考帧并生成相似主体视图;产品图上传后自动识别视角、结构和风险,并补缺角度,形成产品资产包。
-
5
分镜生成
按逐句时间轴生成竖向分镜行;每行先规划镜头类型、是否需要人物/产品、首帧、尾帧和产品出现方式,再决定后续生视频提交哪些参考图。
+
4
资产包准备
用户可删除/补选参考帧并生成相似主体视图;参考帧到这里为止,后续首尾帧和视频不再把原关键帧当画面真源。产品图上传后自动识别视角、结构和风险,并补缺角度,形成产品资产包。
+
5
首尾帧闸门
按逐句时间轴生成竖向分镜行;每行先规划镜头类型、是否需要人物/产品、首帧、尾帧和产品出现方式,再用相似主体视图和产品素材生成首尾帧,先看图确认,不直接批量提交视频。
@@ -589,7 +589,7 @@
web/next.config.mjs | Next.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator,并移除 Next 16 已不支持的 eslint 顶层配置,避免本地 dev 出现配置 Issue 提示。 |
web/app/globals.css | 全局主题变量、登录页视觉样式、ReactFlow 样式引用,以及本地开发态 nextjs-portal 遮挡隐藏规则。 |
web/app/page.tsx | 产品工作台主状态:jobs、activeJobId、生成任务状态;主渲染为全屏素材输入列 + 信息流广告复刻工作表;“开始分析”会把 job 放入并行素材分析队列,下载完成后触发 triggerTranscribe 解析音频,并触发 analyzeJob 自动抽 12 张参考帧,形成“音频文案路 + 视频视觉路”同步推进;底部吸附音频条不再从主界面渲染。 |
- web/components/ad-recreation-board.tsx | 信息流广告复刻工作表:左侧素材输入只负责链接/上传和任务切换,不再重复放横版原视频预览;右侧顶部用“音频文案路、视频视觉路、主体资产、产品资产”四个状态条显示后台并行进度。源视频工作区展示视频下载状态和默认折叠的文案依据。音频解析结果改成默认折叠的辅助信息,展开后同一行看讲话人/节奏/背景音;主工作区左侧是按 9:16 显示的竖版原视频播放器,播放器内覆盖“当前点抽帧”,按当前播放秒数手动补参考帧;右侧上方是音频波形 / 切点参考,下方是逐句时间轴;下一行铺开“关键帧 / 相似主体”。音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点,顶部同时显示当前播放秒数、总时长和鼠标指针停点秒数。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。关键帧区的主入口是“自动抽帧 12 张”,一键按动作峰值目标重新抽取 12 张源视频参考帧,优先抓手势、表情变化、节奏点和镜头变化,缩略图按竖版完整比例显示不裁切并用更多列紧凑铺开,鼠标停留会通过固定浮层放大展示完整帧。“生成 10 张高清图”放在相似主体白底视图区,不和抽参考按钮平齐;如果用户没有勾选帧,默认把全部关键帧作为主体参考,勾选后只传已选帧;生成区可在“透明骨架 / 普通真人”之间切换,可选择桌面导入的 5 套内置形象作为创意方向,并可填写统一主体方向,例如年轻女性、更运动、更高级。关键帧和相似主体白底视图都用更小的竖版缩略图密排;白底视图只展示每个 view 的最新一张,缩略图上提供“重新生成这一张”和“删除这一张”,单张重生会用 replace_views=true 替换同一视角。前端调用 generateSubjectAssets 时按主体类型传 subject_style=transparent_human 或 source_actor,按需传 character_id,并使用 reconstruction_mode=similar;后端会把关键帧和内置形象视为同一个主体的创意证据,并锁定同一性别表现、年龄段、体型、材质、风格和视觉身份,同时生成全身多视角 + 肩颈正/左右近景 + 后颈肩背特写,避免整套图出现男女性别、老少年龄或样式混杂。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px,但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写,分镜时间和原内容列压缩为窄摘要列,把横向空间留给新口播、画面规划和视频候选;生成本条视频时使用当前编辑后的新口播文案。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入和 6 个候选视频槽;画面规划区先选择镜头类型(人物/情绪、人物+产品、产品特写、场景过渡),再用人物/产品开关、首帧规划、尾帧规划和产品出现方式决定这一条到底需不需要产品图或相似主体参考。候选视频槽在宽屏下一排显示 6 个竖版预览,避免前面空旷、后面拥挤。单条生成会从全局选中关键帧或 12 张关键帧中取最贴近本句时间点的参考帧;“一键提交全部”会逐行保存当前画面规划并批量提交 Seedance 候选视频。只有该行勾选“产品”时,单条生成才会从产品素材池按分镜角色、视角优先级、用途标签、置信度和风险自动挑选最多 6 张相关产品图;未勾选产品时不会把产品图提交给生视频模型。只有该行勾选“人物”时,才会传相似主体参考图;否则视频 prompt 会明确禁止强行添加主角式透明骨架人。ModelTrace 会在音频解析、产品识别/补图、相似主体高清视图包、脚本改写和单条生视频入口旁直接展示模型名;所有生图入口都显示并使用 gpt-image-2,没有其他图片模型 fallback;点击后用固定浮层展示模型链路、输入输出和回退逻辑。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 |
+ web/components/ad-recreation-board.tsx | 信息流广告复刻工作表:左侧素材输入只负责链接/上传和任务切换,不再重复放横版原视频预览;右侧顶部用“音频文案路、视频视觉路、主体资产、产品资产”四个状态条显示后台并行进度。源视频工作区展示视频下载状态和默认折叠的文案依据。音频解析结果改成默认折叠的辅助信息,展开后同一行看讲话人/节奏/背景音;主工作区左侧是按 9:16 显示的竖版原视频播放器,播放器内覆盖“当前点抽帧”,按当前播放秒数手动补参考帧;右侧上方是音频波形 / 切点参考,下方是逐句时间轴;下一行铺开“关键帧 / 相似主体”。音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点,顶部同时显示当前播放秒数、总时长和鼠标指针停点秒数。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。关键帧区的主入口是“自动抽帧 12 张”,一键按动作峰值目标重新抽取 12 张源视频参考帧,优先抓手势、表情变化、节奏点和镜头变化,缩略图按竖版完整比例显示不裁切并用更多列紧凑铺开,鼠标停留会通过固定浮层放大展示完整帧。“生成 10 张高清图”放在相似主体白底视图区,不和抽参考按钮平齐;如果用户没有勾选帧,默认把全部关键帧作为主体参考,勾选后只传已选帧;生成区可在“透明骨架 / 普通真人”之间切换,可选择桌面导入的 5 套内置形象作为创意方向,并可填写统一主体方向,例如年轻女性、更运动、更高级。关键帧和相似主体白底视图都用更小的竖版缩略图密排;白底视图只展示每个 view 的最新一张,缩略图上提供“重新生成这一张”和“删除这一张”,单张重生会用 replace_views=true 替换同一视角。前端调用 generateSubjectAssets 时按主体类型传 subject_style=transparent_human 或 source_actor,按需传 character_id,并使用 reconstruction_mode=similar;后端会把关键帧和内置形象视为同一个主体的创意证据,并锁定同一性别表现、年龄段、体型、材质、风格和视觉身份,同时生成全身多视角 + 肩颈正/左右近景 + 后颈肩背特写,避免整套图出现男女性别、老少年龄或样式混杂。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px,但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写,分镜时间和原内容列压缩为窄摘要列,新口播列进一步收窄,把横向空间留给画面规划和首尾帧。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入和历史候选视频槽;画面规划区先选择镜头类型(人物/情绪、人物+产品、产品特写、场景过渡),再用人物/产品开关、首帧规划、尾帧规划和产品出现方式决定这一条到底需不需要产品图或相似主体参考。当前主流程暂停直接调用视频模型,不再提供“生成本条 · Seedance”或“一键提交全部”视频入口;行内新增“首尾帧闸门”,分别显示/生成首帧和尾帧,旧 keyframe 类型首尾帧会被忽略,只认真正的 asset 首尾帧。生成首尾帧时调用 generateSceneAsset,传入相似主体白底视图 subject_images 和该行自动挑选的产品图 product_images;关键帧只作为前置主体重构证据和行数据承载位置,不再作为后续视频首尾帧参考。视频候选槽只展示历史候选和待生成占位,按钮改为“保存本条规划 / 保存全部规划”。只有该行勾选“产品”时,首尾帧生成才会从产品素材池按分镜角色、视角优先级、用途标签、置信度和风险自动挑选最多 6 张相关产品图;未勾选产品时不会把产品图提交给首尾帧/后续生视频模型。只有该行勾选“人物”时,才会传相似主体参考图;否则 prompt 会明确禁止强行添加主角式透明骨架人。ModelTrace 会在音频解析、产品识别/补图、相似主体高清视图包、脚本改写等入口旁直接展示模型名;所有生图入口都显示并使用 gpt-image-2,没有其他图片模型 fallback;点击后用固定浮层展示模型链路、输入输出和回退逻辑。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 |
web/app/login/page.tsx | 生产登录页:访问账号/访问密钥表单、保持登录、错误/成功状态;当前只在原版 Digital Oasis 动态背景上叠加一个组合登录框,桌面端左侧是动态角色,右侧是图标化登录表单;面板左上角展示官网 SKG 字标和中文“营销内容工作台”系统标识。 |
web/app/login/layout.tsx | 登录路由专属 layout:覆盖全站默认网页标题和描述为空,避免 /login 继承工作台 metadata 后在页面源码里继续出现登录界面文字以外的文案。 |
web/components/login/oasis-canvas.tsx | 登录页全屏动态视觉层:用 iframe 直接承载下载包 web/public/oasis-source/index.html 的原 WebGPU / Three.js 草场源码;父级登录页只覆盖自己的文案和表单,并在捕获阶段把全局鼠标坐标同时用原生事件和 postMessage 转发给 iframe,避免登录面板或输入框遮挡时草地失去鼠标响应。 |
@@ -628,7 +628,7 @@ web/app/page.tsx
-> 信息流广告复刻工作表:web/components/ad-recreation-board.tsx
-> 开始分析:创建/激活 job → 下载完成后并行触发视频视觉路 analyzeJob 与音频文案路 triggerTranscribe
-> 左侧素材输入列 + 右侧四路状态条 + 默认折叠的文案依据 + 源视频工作区(音频解析结果默认折叠,竖版 9:16 原视频播放器内可当前点抽帧,右侧上方连续响度波形显示当前/总时长/指针停点,右侧下方逐句时间轴联动滚动,参考帧池在下方多列铺开且主入口为“自动抽帧 12 张”,相似主体高清视图包生成按钮放在视图区;不勾选帧则默认用全部帧,勾选后只用已选帧,可叠加 5 套内置形象)
- -> 信息流复刻分镜工作台:同一产品素材池不限量上传 → 自动识别视角 / 背景 / 用途 / 风险 → 人工检查备注 → 逐句时间轴 → 原内容 / 新口播文案 / 画面规划与产品融入(镜头类型、人物/产品开关、首帧、尾帧、产品出现方式)→ 单条或一键提交全部按规划选择是否传产品图和相似主体参考图 → 6 个候选视频槽
+ -> 信息流复刻分镜工作台:同一产品素材池不限量上传 → 自动识别视角 / 背景 / 用途 / 风险 → 人工检查备注 → 逐句时间轴 → 原内容 / 新口播文案 / 画面规划与产品融入(镜头类型、人物/产品开关、首帧、尾帧、产品出现方式)→ 首尾帧闸门:用相似主体视图 + 产品素材生成首帧/尾帧 → 保存规划 → 历史候选视频槽(当前不直接批量提交视频)
-> 底部音频条:不再渲染,音频结果集中到右侧工作表
-> 旧节点/深度素材面板:web/components/nodes/index.tsx、web/components/lightbox.tsx、web/components/storyboard-workbench.tsx(底层保留,当前不作为主入口)
-> API 契约:web/lib/api.ts
@@ -655,8 +655,8 @@ api/main.py
你看到的区域信息流复刻分镜工作台
-
主要源码AudioStoryboardPlanPanel、ProductReferenceCard、MissingProductViewSlot、buildAudioStoryboardRows、selectProductItemsForRow、buildStoryboardSceneFromAudioRow、submitRowVideo、generateAllRowVideos、StoryboardVideoSlots in web/components/ad-recreation-board.tsx;产品白底图上传复用 uploadStoryboardAsset,视角自动识别调用 analyzeProductViews,缺角度自动补图调用 generateProductAngleAsset。单条生成和“一键提交全部”都会按全局关键帧池匹配当前句时间点,并把镜头类型、人物/产品开关、首帧规划、尾帧规划和产品出现方式写入 StoryboardScene,复用 onGenerateVideo 和 PUT /frames/{idx}/storyboard。
-
适合怎么描述“按音频逐句生成产品分镜、每行怎样改写口播、哪几句不需要产品或人物、首帧/尾帧该怎么停、产品素材池识别/补图后的备注是否准确、单条或批量生成该选哪几张参考图、生成的视频应该回显到哪一行”。
+
主要源码AudioStoryboardPlanPanel、ProductReferenceCard、MissingProductViewSlot、buildAudioStoryboardRows、selectProductItemsForRow、subjectAssetRefsForPlanning、endpointAssetRef、buildEndpointFramePrompt、buildStoryboardSceneFromAudioRow、generateEndpointFrameForRow、saveRowStoryboardDraft、saveAllStoryboardDrafts、EndpointFrameSlot、StoryboardVideoSlots in web/components/ad-recreation-board.tsx;产品白底图上传复用 uploadStoryboardAsset,视角自动识别调用 analyzeProductViews,缺角度自动补图调用 generateProductAngleAsset。当前单条/批量按钮只保存规划;首尾帧按钮调用 generateSceneAsset,把相似主体白底视图和产品素材写入 subject_images/product_images,再用 PUT /frames/{idx}/storyboard 保存 asset 首尾帧引用。web/app/page.tsx 的视频提交回调有暂停保护,旧入口误触也不会请求 /storyboard/video。
+
适合怎么描述“按音频逐句生成产品分镜、每行怎样改写口播、哪几句不需要产品或人物、首帧/尾帧该怎么停、首尾帧是否已经生成并准确、产品素材池识别/补图后的备注是否准确、哪些分镜后续才值得进入单条视频候选”。
你看到的区域旧深度素材面板(当前不作为主路径)
@@ -847,7 +847,7 @@ ProductRefStateItem {
StoryboardScene
-
分镜编排结果,不是复刻说明。它把参考图和 SKG 改造方向绑定到一个分镜上。
+
分镜编排结果,不是复刻说明。当前主流程里 first_image/last_image 只接受生成后的 asset 首尾帧;旧 keyframe 首尾帧引用会被前端忽略。subject_images 保存相似主体白底视图,product_images 保存该行挑选的产品参考。
StoryboardScene {
duration,
visual_mode: person_only | person_product | product_only | environment,
@@ -859,6 +859,7 @@ ProductRefStateItem {
first_image,
last_image,
product_images[],
+ subject_images[],
subject_image,
scene_image,
product_image,
@@ -887,7 +888,7 @@ ProductRefStateItem {
| 删除输入视频 | DELETE /jobs/{id} | deleteJob | 从任务队列、URL 和磁盘 jobs/<id> 目录移除整个 job,包括源视频、关键帧、元素提取图和生成视频。 |
| 解析视频 | POST /jobs/{id}/analyze?frames=&target=&mode=&quality= | analyzeJob | 后续阶段保留的抽帧能力。默认 frames=12;target 支持透明骨架人、综合、清晰主体、转场变化、表情瞬间、动作峰值。当前第一步主流程不自动调用该接口;原版视频旁的“抽参考 12 帧”会显式用 target=motion、quality=accurate、mode=replace 重新生成全局动作/节奏参考帧池。 |
| 音频文案轨 | POST /jobs/{id}/transcribe | triggerTranscribe | 若尚未拆轨,先从 source.mp4 提取 audio.wav 并回填 source_audio_url;随后用 ASR 提取原始文案,翻译成中文,写入 audio_script.source_text、source_zh 和逐句 transcript。远端 ASR_MODEL 失败后先走本机 LOCAL_ASR_BIN/LOCAL_ASR_MODEL(默认 mlx_whisper),再尝试 ASR_FALLBACK_MODEL。后端会拒绝重复文本、逐秒假字幕或覆盖率过低的结果,不再把不可听的多模态输出写进时间轴。再用 ASR_FALLBACK_MODEL 多模态音频分析讲话人、语速节奏、停顿、背景音乐/环境声/音效,写入 speaker_profile、rhythm_profile、background_audio_profile。当前第一步不默认生成 SKG 新口播和 Azure OpenAI 配音。 |
- | 分镜脚本改写 | POST /jobs/{id}/script/rewrite | rewriteStoryboardScript | 根据原参考文案、当前新口播、分镜角色、时间段和作者想法改写中文口播。mode=segment 只改一段;mode=all 一次改完整片,要求整片前后连贯。接口只返回 items[index,text],前端暂存在当前页面状态里,生成本条视频时写入 StoryboardScene.action。 |
+ | 分镜脚本改写 | POST /jobs/{id}/script/rewrite | rewriteStoryboardScript | 根据原参考文案、当前新口播、分镜角色、时间段和作者想法改写中文口播。mode=segment 只改一段;mode=all 一次改完整片,要求整片前后连贯。接口只返回 items[index,text],前端暂存在当前页面状态里,保存规划或生成首尾帧时写入 StoryboardScene.action。 |
| 原始音频文件 | GET /jobs/{id}/audio.wav | sourceAudioUrl | 返回拆轨得到的 wav;当前主界面不再渲染底部吸附音频条,右侧复刻工作表会读取该文件生成参考图式横向响度波形,并和原视频、逐句时间轴联动;波形标题栏显示当前播放秒数、总时长和鼠标指针停点秒数。 |
| 改写配音文件 | GET /jobs/{id}/audio-script.mp3 | apiAssetUrl(job.audio_script.voice_url) | 后续新配音阶段保留的 TTS 产物;默认走 VOICE_PROVIDER=azure_openai,通过 AZURE_OPENAI_BASE_URL 的 OpenAI 协议 /audio/speech 生成 mp3。当前第一步不默认生成该文件。 |
| 手动加帧 | POST /jobs/{id}/frames?t= | addManualFrame | 按视频时间戳抽一帧,index 递增但 frames 按 timestamp 排序。当前主界面会把原版视频播放器的播放秒数传给 AudioIntakePanel 标题栏右侧的“当前点抽帧”。 |
@@ -899,7 +900,7 @@ ProductRefStateItem {
| 元素增改删 | POST/PATCH/DELETE /elements | addElement/updateElement/deleteElement | 让用户修正 Vision 错误,避免候选结果锁死。 |
| 元素提取 | POST /elements/{element_id}/cutout | cutoutElement | 调用图像模型生成独立白底素材图,每次累积一张 cutout。 |
| 主体资产包 | POST /elements/{element_id}/subject-assets
DELETE /elements/{element_id}/subject-assets/{asset_id} | generateSubjectAssets
deleteSubjectAsset | 根据参考帧和可选内置形象重新绘制一个统一主体资产包;前端默认把全部关键帧作为 source_frame_indices,如果用户手动选择了关键帧则只传已选帧,也可传 character_id 选择 5 套内置透明骨架形象之一。当前源视频工作区支持 subject_style=transparent_human 和 source_actor 两种相似主体:透明骨架人会保持透明/半透明皮肤包裹可见白色骨架,普通真人会保持正常广告演员方向。两种模式都使用 reconstruction_mode=similar,后端最多把 10 张参考图作为独立 image[] 提交给 gpt-image-2,生成默认 10 张 2048 高清白底图:正面、左右 45、左右侧、背面、肩颈正/左右近景、后颈肩背特写。Prompt 明确这是肩颈按摩设备视频素材,要求脖颈、锁骨、肩线、上背和肩胛区域清晰无遮挡;内置形象只作为创意方向,不照抄。后端强制使用 gpt-image-2,不再接受前端或环境变量切到其他图片模型,也不做图片模型 fallback;后端会加身份锁定约束,统一性别表现、年龄段、体型、材质、风格和视觉身份,避免整套图混成不同人物。如果参考帧是竖屏,prompt 会明确要求竖版 9:16 风格画布,落盘也按源帧纵横比归一化。前端白底视图缩略图和关键帧一样,鼠标停留会用顶层浮层放大预览,点击仍打开原图;后端每个 view 单独调用一次生图,并明确禁止多视图拼图、contact sheet、多主体、多面板、标签或对比排版,保证一个视角一张照片。图片调用走统一 ai_http_client,可用 AI_HTTP_PROXY / IMAGE_HTTP_PROXY 处理本地 launchd 代理继承问题;网络/DNS 失败返回 503 并提示检查代理配置。replace_views=true 时会替换同一视角旧图;删除接口会移除对应 subject asset 记录并删除本地 jpg 文件。 |
- | 首尾帧资产 | POST /frames/{idx}/scene-asset | generateSceneAsset | 同一接口兼容旧场景图和新首尾帧;新流程传 asset_role=first_frame/last_frame,后端走文字生图,参考帧只用于理解透明骨架人形象、比例、机位和光线,生成结果仍保存在 scene_assets 并自动填入产品融合镜头。 |
+ | 首尾帧资产 | POST /frames/{idx}/scene-asset | generateSceneAsset | 同一接口兼容旧场景图和新首尾帧;当前信息流复刻流程传 asset_role=first_frame/last_frame、subject_images 和 product_images。后端优先把相似主体白底视图与产品素材拼成 asset contact sheet 给 gpt-image-2 做图像编辑,关键帧只作为行数据承载位置。生成结果保存在 scene_assets,前端再写入 StoryboardScene.first_image/last_image。 |
| 产品图库 | GET /product-library/skg | listProductLibrary | 读取内置 SKG 白底图库 manifest,返回产品标题、品类、尺寸、白底评分和预览图 URL。 |
| 产品图入库到 job | POST /jobs/{id}/assets、POST /jobs/{id}/assets/product-library | uploadStoryboardAsset、copyProductLibraryAsset | 上传产品图或把内置产品图库条目复制为当前 job 的普通 asset。后端统一生成最长边 1600px、JPEG 92 的 AI 工作副本,透明底铺白,过大/过小图片会在 ImageRef.asset_meta 里返回转换动作和风险;黑底/白底背景本身不强行转换。注意该接口只写图片文件,产品素材池列表另由 PUT /jobs/{id}/product-refs 持久化。 |
| 产品素材池保存 | PUT /jobs/{id}/product-refs | saveProductRefs | 把当前 job 的产品素材池列表、识别视角、用途标签、方向、结构点、备注、AI 补图和删除结果保存到 Job.product_refs / state.json。前端上传、识别完成、补角度、编辑备注和删除时都会同步保存;刷新页面或热更新后从 job 恢复,不再要求重新上传和重新识别。 |
@@ -1014,6 +1015,19 @@ ProductRefStateItem {
变更记录
这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。
+
+
+ 2026-05-18 · 暂停直接视频提交,改为首尾帧闸门
+ UI
+ API
+ Workflow
+
+
+
问题:分镜还没有真实首尾帧时直接一键提交视频,会把几十上百条视频任务一次性打到 Seedance,成本不可控;同时旧逻辑还会把关键帧继续当作后续视频参考,偏离“关键帧只用于主体重构”的新流程。
+
改动:AudioStoryboardPlanPanel 取消当前主路径里的“生成本条 · Seedance”和“一键提交全部”,改成“保存本条规划 / 保存全部规划”。画面规划列新增 EndpointFrameSlot 首尾帧闸门,按行生成首帧和尾帧;endpointAssetRef 忽略旧的 keyframe 首尾帧,只认可 asset 类型首尾帧。generateEndpointFrameForRow 调用 generateSceneAsset,传相似主体白底视图和按行挑选的产品图。web/app/page.tsx 加暂停保护,旧视频回调误触也不会请求视频接口。
+
影响:后续正确链路是“抽帧选主体 → 生成相似主体视图 → 上传/识别产品 → 写新口播和画面规划 → 逐条生成首尾帧 → 人工审核首尾帧 → 再开放单条视频生成”。历史视频候选仍展示,但不会继续自动追加。
+
+
2026-05-18 · 开始分析改为音频与视觉并行资产流
@@ -1023,7 +1037,7 @@ ProductRefStateItem {
问题:信息流复刻不应该等音频字幕完成后才抽帧;字幕是分镜文案前置条件,但主体参考帧和产品资产可以并行准备。旧体验让用户感觉流程线性且复杂。
改动:web/app/page.tsx 新增并行启动逻辑:视频下载完成后触发 triggerTranscribe 解析音频,同时触发 analyzeJob 自动抽 12 张参考帧。api/main.py 允许抽帧在音频转写状态下排队执行,并在音频路已运行时跳过重复拆音轨,避免并发写同一个 audio.wav。AdRecreationBoard 顶部新增音频文案路、视频视觉路、主体资产和产品资产状态条,素材输入按钮改成“开始分析”。
-
影响:AudioStoryboardPlanPanel 新增“一键提交全部”,会逐行保存当前画面规划并提交 Seedance 候选视频;前台保留人工检查主体帧、主体视图和产品标注的入口,但主流程更接近“上传素材后后台并行跑,最终按分镜一键提交视频”。
+
影响:当时 AudioStoryboardPlanPanel 新增“一键提交全部”以验证批量提交思路;该视频提交入口已在后续“首尾帧闸门”改动中暂停,当前主流程只保存规划并先生成首尾帧。
@@ -1300,7 +1314,7 @@ ProductRefStateItem {
问题:“原内容 / 新口播文案”两列占用空间偏多,并且新口播只是固定模板,不能按作者想法或原片节奏继续改写;单条生成前也无法微调文案。
改动:AudioStoryboardPlanPanel 在脚本区上方新增作者想法输入、整片改写和还原初稿;每行新口播改成可编辑 textarea,并提供“单段改写”。新增 POST /jobs/{id}/script/rewrite 和前端 rewriteStoryboardScript,后端根据原参考文案、当前新文案、分镜角色、时间段和作者想法返回中文口播。
-
影响:api/main.py、web/lib/api.ts、web/components/ad-recreation-board.tsx、docs/source-analysis.html。当前改写结果是页面级脚本状态,点击“生成本条”时会写入对应 StoryboardScene.action;后续如果要刷新后保留,应新增 job 级脚本草稿持久化字段。
+
影响:api/main.py、web/lib/api.ts、web/components/ad-recreation-board.tsx、docs/source-analysis.html。当前改写结果是页面级脚本状态,保存规划或生成首尾帧时会写入对应 StoryboardScene.action;后续如果要刷新后保留,应新增 job 级脚本草稿持久化字段。
@@ -1313,7 +1327,7 @@ ProductRefStateItem {
问题:旧识别只要求模型在“正面/左45/右45/侧厚/触点/背底”里选一类,没有说明这是套在脖子上的 U 形肩颈按摩仪,也没有定义佩戴者左/右、上/下、贴颈内侧和外壳外侧,容易把图片左右当成产品左右,后续生视频会把局部细节和方向搞乱。
改动:api/main.py 将 analyzeProductViews 改为同一产品多图批量识别,prompt 固定产品为挂脖肩颈按摩仪,并新增 orientation 和 landmarks 输出;单图兜底 prompt 也同步要求产品坐标系。web/lib/api.ts 扩展 ProductViewAnalysisItem;ProductReferenceCard 展示“方向已识别”和结构点,悬停预览显示方向、结构和风险。
-
影响:单条视频生成时,buildStoryboardSceneFromAudioRow 会把“左/右按佩戴者身体左右,上/下按佩戴方向,内侧=贴颈触点,外侧=外壳按键”作为硬规则写入产品提示,并把每张选中产品图的方向和结构点一并交给视频模型。
+
影响:首尾帧生成和后续单条视频生成时,buildStoryboardSceneFromAudioRow 会把“左/右按佩戴者身体左右,上/下按佩戴方向,内侧=贴颈触点,外侧=外壳按键”作为硬规则写入产品提示,并把每张选中产品图的方向和结构点一并交给模型。
diff --git a/web/app/page.tsx b/web/app/page.tsx
index 31b4448..33360e7 100644
--- a/web/app/page.tsx
+++ b/web/app/page.tsx
@@ -38,6 +38,7 @@ const NODE_TYPES = {
const KEYFRAME_PANEL_ID = "keyframe-detail-panel"
const VIDEO_FRAME_PANEL_ID = "video-frame-panel"
const FLOATING_PANEL_IDS = new Set([KEYFRAME_PANEL_ID, VIDEO_FRAME_PANEL_ID])
+const DIRECT_VIDEO_GENERATION_PAUSED = true
const FRAME_TARGET_LABELS: Record = {
transparent_human: "透明骨架人",
balanced: "综合关键帧",
@@ -592,6 +593,10 @@ export default function Home() {
}, [jobs, productionJobIds, startProductionLanesForJob])
const handleQuickGenerateVideo = useCallback(async (frameIdx: number, scene: StoryboardScene, model: string) => {
+ if (DIRECT_VIDEO_GENERATION_PAUSED) {
+ toast.info("视频生成调用已暂停:先生成并审核每条分镜的首帧/尾帧,再开放单条提交")
+ return
+ }
if (!job) return
const frame = job.frames.find((f) => f.index === frameIdx)
if (!frame) return
@@ -728,6 +733,10 @@ export default function Home() {
}, [ensureDefaultProductRefs, job, selectedFrames, updateJobInList])
const handleGenerateProductFusionVideo = useCallback(async (frameIdx: number, shot: ProductFusionShot) => {
+ if (DIRECT_VIDEO_GENERATION_PAUSED) {
+ toast.info("视频生成调用已暂停:当前只做首尾帧和素材规划")
+ return
+ }
if (!job) return
const frame = job.frames.find((f) => f.index === frameIdx)
if (!frame) return
diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx
index ddfce47..6188cb1 100644
--- a/web/components/ad-recreation-board.tsx
+++ b/web/components/ad-recreation-board.tsx
@@ -32,6 +32,7 @@ import {
cutoutElement,
deleteSubjectAsset,
effectiveFrameUrl,
+ generateSceneAsset,
generateProductAngleAsset,
generateSubjectAssets,
generatedImageUrl,
@@ -471,7 +472,7 @@ function videoModelTrace(models: RuntimeModels | undefined, model: string): Mode
`前端选择:${model}`,
`后端解析:${resolveVideoModelLabel(models, model)}`,
`服务商:${modelValue(models?.video_provider)} · ${modelValue(models?.video_base_url)}`,
- "输入:当前分镜文案、参考帧、产品素材、产品方向标注和画面规划",
+ "输入:已确认的首尾帧、当前分镜文案、产品素材、相似主体资产和画面规划",
"输出:异步候选视频,完成后回填到对应分镜行",
],
}
@@ -479,7 +480,6 @@ function videoModelTrace(models: RuntimeModels | undefined, model: string): Mode
function buildFallbackScene(job: Job, frame: KeyFrame, order: number): StoryboardScene {
const frames = [...job.frames].sort((a, b) => a.timestamp - b.timestamp)
- const nextFrame = frames.find((item) => item.timestamp > frame.timestamp) ?? null
const duration = Math.max(3.5, Math.min(7.5, Math.max(job.duration || 0, frames.length * 5) / Math.max(frames.length, 1)))
const audio = job.audio_script?.rewritten_text?.trim()
|| job.transcript?.slice(0, 4).map((item) => item.en || item.zh).filter(Boolean).join(" ")
@@ -487,10 +487,10 @@ function buildFallbackScene(job: Job, frame: KeyFrame, order: number): Storyboar
const objects = frame.description?.objects?.slice(0, 5).map((item) => item.name).filter(Boolean).join("、")
return {
duration: Number(duration.toFixed(1)),
- first_image: { kind: "keyframe", frame_idx: frame.index, label: `分镜 ${order + 1} 首帧` },
- last_image: nextFrame ? { kind: "keyframe", frame_idx: nextFrame.index, label: `分镜 ${order + 1} 尾帧` } : null,
+ first_image: null,
+ last_image: null,
subject: objects ? `关键元素候选:${objects}` : "保留原视频最重要的主体动作和构图关系。",
- scene: `${frame.description?.scene || `参考第 ${order + 1} 个关键画面规划 SKG 信息流广告分镜。`}\n音频节奏依据:${audio.slice(0, 220)}`,
+ scene: `${frame.description?.scene || `按第 ${order + 1} 段音频规划 SKG 信息流广告分镜。`}\n音频节奏依据:${audio.slice(0, 220)}`,
product: "把原素材里的产品/痛点转成 SKG 颈部/肩颈按摩仪表达,默认使用 SKG 四张产品角度图做产品真源。",
action: frame.description?.style
? `沿用原画面的讲话节奏、动作节点和 ${frame.description.style},突出使用前紧绷、使用后放松。`
@@ -929,7 +929,60 @@ function selectProductItemsForRow(row: AudioStoryboardRow, items: ProductRefItem
return picked
}
-function buildStoryboardSceneFromAudioRow(row: AudioStoryboardRow, frame: KeyFrame, nextFrame?: KeyFrame | null, productItems: ProductRefItem[] = []): StoryboardScene {
+function subjectAssetRefsForPlanning(source: { frame: KeyFrame; element: KeyElement } | null): ImageRef[] {
+ if (!source) return []
+ return (source.element.subject_assets ?? []).slice(0, 10).map((asset) => ({
+ kind: "asset",
+ frame_idx: source.frame.index,
+ element_id: asset.id,
+ cutout_id: asset.id,
+ label: asset.label || asset.view || "相似主体视图",
+ }))
+}
+
+function endpointAssetRef(frame: KeyFrame | null, role: "first_frame" | "last_frame"): ImageRef | null {
+ if (!frame) return null
+ const saved = role === "first_frame" ? frame.storyboard?.first_image : frame.storyboard?.last_image
+ if (saved && saved.kind !== "keyframe") return saved
+ const asset = [...(frame.scene_assets ?? [])].reverse().find((item) => item.asset_role === role)
+ if (!asset) return null
+ return {
+ kind: "asset",
+ frame_idx: frame.index,
+ element_id: asset.id,
+ cutout_id: asset.id,
+ label: asset.label || (role === "first_frame" ? "首帧" : "尾帧"),
+ }
+}
+
+function buildEndpointFramePrompt(row: AudioStoryboardRow, role: "first_frame" | "last_frame", selectedProductItems: ProductRefItem[], subjectRefs: ImageRef[]) {
+ const target = role === "first_frame" ? row.firstFramePlan : row.lastFramePlan
+ const opposite = role === "first_frame" ? row.lastFramePlan : row.firstFramePlan
+ const productNotes = selectedProductItems.length ? productReferenceNotes(selectedProductItems) : ""
+ return [
+ `分镜 ${row.index + 1} ${role === "first_frame" ? "首帧" : "尾帧"}。`,
+ `新口播文案:${row.skgCopy}`,
+ `镜头类型:${VISUAL_MODE_OPTIONS.find((item) => item.value === row.visualMode)?.label ?? row.visualMode}。`,
+ `当前要生成的画面:${target}`,
+ `另一端画面用于连续性参考:${opposite}`,
+ `画面规划:${row.visualPlan}`,
+ row.needsSubject
+ ? `人物主体:必须使用已生成的相似主体白底视图作为人物真源;已提供 ${subjectRefs.length} 张主体参考。不要回到原视频关键帧复刻人物。`
+ : "本条不需要主角人物;如出现人物,只能是局部手部、背影或环境人物,不要生成透明骨架主角。",
+ row.needsProduct
+ ? `产品融入:${row.productPlacement}。${row.productIntegration}。已提供 ${selectedProductItems.length} 张同一 SKG 肩颈按摩仪产品参考;${productNotes}。产品是套在脖子上的 U 形肩颈按摩仪,必须保持真实佩戴大小、左右非对称和贴颈位置。`
+ : "本条不露出产品,不要强行生成 SKG 产品、包装、白底图或随机商品。",
+ "输出一张单独的 9:16 高清首/尾帧,不要拼图,不要字幕,不要平台 UI,不要水印。画面要能作为后续视频生成的明确起止帧。",
+ ].join("\n")
+}
+
+function buildStoryboardSceneFromAudioRow(
+ row: AudioStoryboardRow,
+ frame: KeyFrame,
+ productItems: ProductRefItem[] = [],
+ subjectRefs: ImageRef[] = [],
+ endpointRefs: { firstImage?: ImageRef | null; lastImage?: ImageRef | null } = {},
+): StoryboardScene {
const selectedProductItems = row.needsProduct ? selectProductItemsForRow(row, productItems) : []
const productRefs = selectedProductItems.map((item) => item.ref)
const notes = productReferenceNotes(selectedProductItems)
@@ -940,8 +993,8 @@ function buildStoryboardSceneFromAudioRow(row: AudioStoryboardRow, frame: KeyFra
: "未上传产品图时使用默认 SKG 产品图;生成前建议先建立同一产品素材池,锁定左右差异、厚度和佩戴比例。"
return {
duration: Number(Math.max(3.2, Math.min(6.5, row.end - row.start || 4.5)).toFixed(1)),
- first_image: { kind: "keyframe", frame_idx: frame.index, label: `分镜 ${row.index + 1} 参考帧` },
- last_image: nextFrame ? { kind: "keyframe", frame_idx: nextFrame.index, label: `分镜 ${row.index + 1} 尾帧` } : null,
+ first_image: endpointRefs.firstImage ?? null,
+ last_image: endpointRefs.lastImage ?? null,
visual_mode: row.visualMode,
needs_product: row.needsProduct,
needs_subject: row.needsSubject,
@@ -950,7 +1003,11 @@ function buildStoryboardSceneFromAudioRow(row: AudioStoryboardRow, frame: KeyFra
product_placement: row.productPlacement,
product_images: productRefs,
product_image: productRefs[0] ?? null,
- subject: row.needsSubject ? row.keyElements : "本条不需要人物主体或相似主体参考;如画面里出现人物,只作为背景或局部,不作为主角。",
+ subject_images: row.needsSubject ? subjectRefs : [],
+ subject_image: row.needsSubject ? subjectRefs[0] ?? null : null,
+ subject: row.needsSubject
+ ? `${row.keyElements}\n主体真源:使用已生成的相似主体白底视图,共 ${subjectRefs.length} 张;关键帧只用于前置主体提取,不作为后续视频首尾帧参考。`
+ : "本条不需要人物主体或相似主体参考;如画面里出现人物,只作为背景或局部,不作为主角。",
scene: `镜头类型:${VISUAL_MODE_OPTIONS.find((item) => item.value === row.visualMode)?.label ?? row.visualMode}\n${row.visualPlan}\n首帧规划:${row.firstFramePlan}\n尾帧规划:${row.lastFramePlan}\n原音频依据:${row.source}`,
product: `产品需求:${row.needsProduct ? "需要产品参考" : "本条不需要产品"}\n产品出现方式:${row.productPlacement}\n${row.needsProduct ? row.productIntegration : "本条以情绪、人物状态、空间或节奏过渡为主,不露出产品。"}\n${productGuidance}`,
action: `${row.skgCopy}\n连续动作:从首帧规划自然过渡到尾帧规划,镜头类型和产品/人物需求不能中途改变。`,
@@ -988,6 +1045,9 @@ export function AdRecreationBoard({
const visualReady = (job?.frames.length ?? 0) > 0
const subjectAssetCount = countSubjectAssetViews(job)
const productAssetCount = job?.product_refs?.length ?? 0
+ const statusMessage = job?.message?.startsWith("视频生成已提交")
+ ? "历史候选视频已保留;当前已暂停直接提交视频,先逐条生成并审核首尾帧。"
+ : job?.message
useEffect(() => {
setDraftSegments([])
@@ -1203,8 +1263,8 @@ export function AdRecreationBoard({
02
源视频解析与参考帧
-
- {job?.message || "下载源视频后解析音频,再抽参考帧并生成相似主体。"}
+
+ {statusMessage || "下载源视频后解析音频,再抽参考帧并生成相似主体。"}
@@ -1260,7 +1320,6 @@ export function AdRecreationBoard({
job={job}
selectedFrames={data.selectedFrames}
onJobUpdate={data.onJobUpdate}
- onGenerateVideo={onGenerateVideo}
runtimeModels={runtimeModels}
/>
@@ -2157,17 +2216,16 @@ function AudioStoryboardPlanPanel({
job,
selectedFrames,
onJobUpdate,
- onGenerateVideo,
runtimeModels,
}: {
job: Job | null
selectedFrames: Set
onJobUpdate?: (job: Job) => void
- onGenerateVideo?: (frameIdx: number, scene: StoryboardScene, model: string) => Promise | void
runtimeModels?: RuntimeModels
}) {
- const [videoBusyRow, setVideoBusyRow] = useState(null)
- const [batchVideoBusy, setBatchVideoBusy] = useState(false)
+ const [storyboardSaveBusyRow, setStoryboardSaveBusyRow] = useState(null)
+ const [batchStoryboardSaveBusy, setBatchStoryboardSaveBusy] = useState(false)
+ const [endpointFrameBusy, setEndpointFrameBusy] = useState(null)
const [productItems, setProductItems] = useState([])
const [productUploading, setProductUploading] = useState(false)
const [productAnalyzing, setProductAnalyzing] = useState(false)
@@ -2185,6 +2243,11 @@ function AudioStoryboardPlanPanel({
[orderedFrames, selectedFrames],
)
const rowReferencePool = selectedReferenceFrames.length ? selectedReferenceFrames : orderedFrames
+ const similarActorSource = useMemo(
+ () => findSimilarActorSource(selectedReferenceFrames, orderedFrames),
+ [selectedReferenceFrames, orderedFrames],
+ )
+ const subjectRefs = useMemo(() => subjectAssetRefsForPlanning(similarActorSource), [similarActorSource])
useEffect(() => {
setProductItems((job?.product_refs ?? []).map(normalizeStoredProductItem))
@@ -2463,56 +2526,100 @@ function AudioStoryboardPlanPanel({
}
}
- const submitRowVideo = async (row: AudioStoryboardRow, frame: KeyFrame) => {
- if (!job || !onGenerateVideo) return
- const nextFrame = orderedFrames.find((item) => item.timestamp > frame.timestamp) ?? null
+ const saveRowStoryboardDraft = async (row: AudioStoryboardRow, frame: KeyFrame) => {
+ if (!job) return
const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row) }
- const scene = buildStoryboardSceneFromAudioRow(plannedRow, frame, nextFrame, productItems)
+ const scene = buildStoryboardSceneFromAudioRow(plannedRow, frame, productItems, subjectRefs, {
+ firstImage: endpointAssetRef(frame, "first_frame"),
+ lastImage: endpointAssetRef(frame, "last_frame"),
+ })
const updated = await updateStoryboard(job.id, frame.index, scene)
onJobUpdate?.(updated)
- await onGenerateVideo(frame.index, scene, "seedance")
}
- const generateRowVideo = async (row: AudioStoryboardRow, frame: KeyFrame | null) => {
- if (!job || !frame || !onGenerateVideo) return
- setVideoBusyRow(row.index)
+ const generateEndpointFrameForRow = async (row: AudioStoryboardRow, frame: KeyFrame | null, role: "first_frame" | "last_frame") => {
+ if (!job || !frame) return
+ const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row) }
+ if (plannedRow.needsSubject && !subjectRefs.length) {
+ toast.warning("先在上方生成相似主体白底视图,再生成首尾帧")
+ return
+ }
+ if (plannedRow.needsProduct && !productItems.length) {
+ toast.warning("本条需要产品,请先上传并识别产品素材池")
+ return
+ }
+ const selectedProductItems = plannedRow.needsProduct ? selectProductItemsForRow(plannedRow, productItems) : []
+ const busyKey = `${row.index}:${role}`
+ setEndpointFrameBusy(busyKey)
try {
- await submitRowVideo(row, frame)
+ await saveRowStoryboardDraft(plannedRow, frame)
+ const updated = await generateSceneAsset(job.id, frame.index, {
+ size: SUBJECT_ASSET_SIZE,
+ scene_mode: "similar",
+ scene_style: "premium_product",
+ asset_role: role,
+ prompt: buildEndpointFramePrompt(plannedRow, role, selectedProductItems, subjectRefs),
+ subject_images: plannedRow.needsSubject ? subjectRefs : [],
+ product_images: selectedProductItems.map((item) => item.ref),
+ source_frame_indices: [],
+ })
+ const updatedFrame = updated.frames.find((item) => item.index === frame.index) ?? frame
+ const generatedRef = endpointAssetRef(updatedFrame, role)
+ const scene = buildStoryboardSceneFromAudioRow(plannedRow, updatedFrame, productItems, subjectRefs, {
+ firstImage: role === "first_frame" ? generatedRef : endpointAssetRef(updatedFrame, "first_frame"),
+ lastImage: role === "last_frame" ? generatedRef : endpointAssetRef(updatedFrame, "last_frame"),
+ })
+ const saved = await updateStoryboard(job.id, frame.index, scene)
+ onJobUpdate?.(saved)
+ toast.success(`分镜 ${row.index + 1} ${role === "first_frame" ? "首帧" : "尾帧"}已生成`)
} catch (e) {
- toast.error("生成本条视频失败:" + (e instanceof Error ? e.message : String(e)))
+ toast.error(`${role === "first_frame" ? "首帧" : "尾帧"}生成失败:` + (e instanceof Error ? e.message : String(e)))
} finally {
- setVideoBusyRow(null)
+ setEndpointFrameBusy(null)
}
}
- const generateAllRowVideos = async () => {
- if (!job || !onGenerateVideo || !rows.length) return
+ const saveSingleRowStoryboardDraft = async (row: AudioStoryboardRow, frame: KeyFrame | null) => {
+ if (!job || !frame) return
+ setStoryboardSaveBusyRow(row.index)
+ try {
+ await saveRowStoryboardDraft(row, frame)
+ toast.success("已保存本条分镜规划;视频生成入口已暂停,等待首尾帧资产")
+ } catch (e) {
+ toast.error("保存本条规划失败:" + (e instanceof Error ? e.message : String(e)))
+ } finally {
+ setStoryboardSaveBusyRow(null)
+ }
+ }
+
+ const saveAllStoryboardDrafts = async () => {
+ if (!job || !rows.length) return
const jobsToSubmit = rows
.map((row) => ({ row: planForRow(row, referenceFrameForRow(row)), frame: referenceFrameForRow(row) }))
.filter((item): item is { row: AudioStoryboardRow; frame: KeyFrame } => !!item.frame)
if (!jobsToSubmit.length) {
- toast.warning("先完成自动抽帧,或在原版视频上手动补参考帧")
+ toast.warning("先完成前置抽帧,让每条分镜有可保存的承载位置")
return
}
- setBatchVideoBusy(true)
+ setBatchStoryboardSaveBusy(true)
let ok = 0
let failed = 0
try {
for (const item of jobsToSubmit) {
- setVideoBusyRow(item.row.index)
+ setStoryboardSaveBusyRow(item.row.index)
try {
- await submitRowVideo(item.row, item.frame)
+ await saveRowStoryboardDraft(item.row, item.frame)
ok += 1
} catch (e) {
failed += 1
- console.warn("批量提交分镜失败", item.row.index, e)
+ console.warn("批量保存分镜规划失败", item.row.index, e)
}
}
- if (failed) toast.warning(`已提交 ${ok} 条,${failed} 条失败`)
- else toast.success(`已提交全部 ${ok} 条分镜视频`)
+ if (failed) toast.warning(`已保存 ${ok} 条规划,${failed} 条失败`)
+ else toast.success(`已保存全部 ${ok} 条分镜规划;视频生成入口已暂停`)
} finally {
- setVideoBusyRow(null)
- setBatchVideoBusy(false)
+ setStoryboardSaveBusyRow(null)
+ setBatchStoryboardSaveBusy(false)
}
}
@@ -2528,7 +2635,7 @@ function AudioStoryboardPlanPanel({
0} detail={rows.length ? `${rows.length} 条` : "待音频"} />
0} detail={orderedFrames.length ? `${orderedFrames.length} 张` : "待抽帧"} />
- 0} detail={`${job.generated_videos?.length ?? 0} 条`} />
+ 0} detail={`${job.generated_videos?.length ?? 0} 条历史`} />
@@ -2636,12 +2743,12 @@ function AudioStoryboardPlanPanel({
@@ -2650,13 +2757,13 @@ function AudioStoryboardPlanPanel({
const referenceFrame = referenceFrameForRow(row)
const plannedRow = planForRow(row, referenceFrame)
const rowVideos = videosForFrame(referenceFrame)
- const generating = videoBusyRow === row.index
+ const savingStoryboard = storyboardSaveBusyRow === row.index
const copyText = copyForRow(row)
const selectedProductCount = plannedRow.needsProduct ? selectProductItemsForRow(plannedRow, productItems).length : 0
return (
{row.start.toFixed(1)}-{row.end.toFixed(1)}s
@@ -2744,8 +2851,37 @@ function AudioStoryboardPlanPanel({
placeholder="产品出现方式:不出现 / 首帧出现 / 尾帧出现 / 全程佩戴 / 产品特写"
className="min-h-[38px] w-full resize-y rounded border border-white/10 bg-black/32 px-2 py-1.5 text-[10.5px] leading-snug text-white/68 outline-none placeholder:text-white/25 focus:border-rose-300/45"
/>
+
+
+
+ 首尾帧闸门
+
+ {endpointAssetRef(referenceFrame, "first_frame") && endpointAssetRef(referenceFrame, "last_frame") ? "可进入视频候选" : "先看图再生视频"}
+
+
+
+ 关键帧只用于前置主体重构;这里用相似主体视图{plannedRow.needsProduct ? " + 产品素材池" : ""}生成首尾帧。
+
+
+
void generateEndpointFrameForRow(plannedRow, referenceFrame, "first_frame")}
+ />
+ void generateEndpointFrameForRow(plannedRow, referenceFrame, "last_frame")}
+ />
+
- {plannedRow.needsProduct ? `将自动挑选 ${selectedProductCount || 0} 张产品参考图` : "本条不传产品图"}
+ {plannedRow.needsSubject ? `主体视图 ${subjectRefs.length} 张` : "本条不传主体"} · {plannedRow.needsProduct ? `产品参考 ${selectedProductCount || 0} 张` : "本条不传产品图"}
-
-
-
- {referenceFrame ? `参考 ${referenceFrame.timestamp.toFixed(1)}s · 可多次生成候选` : "先在关键帧区自动抽帧 12 张"}
+
+
+
+ {endpointAssetRef(referenceFrame, "first_frame") && endpointAssetRef(referenceFrame, "last_frame")
+ ? "首尾帧已就绪 · 待开放单条视频提交"
+ : "先生成并确认首帧 / 尾帧"}
- 生视频模型
-
+ 视频生成
+ 已暂停
+
+
+ 先保存画面规划;等 SKG 首帧/尾帧资产确认后再开放单条视频提交。
@@ -2782,7 +2927,7 @@ function AudioStoryboardPlanPanel({
>
) : (
-
+
)}
)
@@ -2951,7 +3096,7 @@ function StoryboardVideoSlots({ job, videos, enabled }: { job: Job; videos: Gene
))}
{Array.from({ length: emptyCount }).map((_, index) => (
- {enabled ? `候选 ${visible.length + index + 1}` : "先抽 12 帧"}
+ {enabled ? `候选 ${visible.length + index + 1}` : "待首尾帧"}
))}
@@ -2962,6 +3107,53 @@ function StoryboardVideoSlots({ job, videos, enabled }: { job: Job; videos: Gene
)
}
+function EndpointFrameSlot({
+ job,
+ frame,
+ role,
+ busy,
+ disabled,
+ onGenerate,
+}: {
+ job: Job
+ frame: KeyFrame | null
+ role: "first_frame" | "last_frame"
+ busy: boolean
+ disabled: boolean
+ onGenerate: () => void
+}) {
+ const ref = endpointAssetRef(frame, role)
+ const src = ref ? resolveImageRefUrl(job.id, ref) : ""
+ const label = role === "first_frame" ? "首帧" : "尾帧"
+ return (
+
+
+ {src ? (
+
+
+
+ ) : (
+
先生成{label}
+ )}
+ {busy && (
+
+
+
+ )}
+
+
+
+ )
+}
+
function StoryboardVideoPreview({ job, video, className = "h-20 w-12" }: { job: Job; video: GeneratedVideo; className?: string }) {
const src = videoSrc(video)
const poster = videoPoster(job, video)
diff --git a/web/lib/api.ts b/web/lib/api.ts
index 10acf44..bfd111b 100644
--- a/web/lib/api.ts
+++ b/web/lib/api.ts
@@ -117,6 +117,7 @@ export interface StoryboardScene {
first_image?: ImageRef | null
last_image?: ImageRef | null
product_images?: ImageRef[]
+ subject_images?: ImageRef[]
product_fusion_shots?: ProductFusionShot[]
visual_mode?: "person_only" | "person_product" | "product_only" | "environment"
needs_product?: boolean
@@ -1049,6 +1050,8 @@ export async function generateSceneAsset(
asset_role?: SceneAssetRole
prompt?: string
source_frame_indices?: number[]
+ subject_images?: ImageRef[]
+ product_images?: ImageRef[]
} = {},
): Promise {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/scene-asset`, {
@@ -1062,6 +1065,8 @@ export async function generateSceneAsset(
asset_role: body.asset_role ?? "scene",
prompt: body.prompt ?? "",
source_frame_indices: body.source_frame_indices ?? null,
+ subject_images: body.subject_images ?? [],
+ product_images: body.product_images ?? [],
}),
})
if (!res.ok) {