From 75c5d113ee63977768127b3fa326c757f7b862ca Mon Sep 17 00:00:00 2001 From: kang Date: Mon, 18 May 2026 09:47:13 +0800 Subject: [PATCH] feat: plan storyboard frame endpoints --- RULES.md | 2 +- api/main.py | 18 ++ docs/source-analysis.html | 34 +++- web/app/page.tsx | 68 ++++++-- web/components/ad-recreation-board.tsx | 225 +++++++++++++++++++++++-- web/lib/api.ts | 8 +- 6 files changed, 316 insertions(+), 39 deletions(-) diff --git a/RULES.md b/RULES.md index 4feb71d..4930db5 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-17 再确认):先解决信息流广告快速复刻的第一步,不再沿用“开始后自动抽帧、分镜、元素生成、合成”的默认做法。主界面为“左侧素材输入列 + 右侧音频解析工作表”。用户粘贴 TK 链接或上传视频后点击“开始”,系统自动下载源视频;下载完成后优先提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效。抽帧、分镜规划、产品融入、相似主体高清视图包(最多 10 张,含肩颈/后背特写)和视频合成暂作为后续能力保留,不在当前第一步自动触发。 +- 当前产品方向(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 6ccd9fd..674993e 100644 --- a/api/main.py +++ b/api/main.py @@ -331,6 +331,12 @@ class StoryboardScene(BaseModel): last_image: dict | None = None product_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 + needs_subject: bool = True + first_frame_plan: str = "" + last_frame_plan: str = "" + product_placement: str = "" # 4 图槽:dict 含 {kind, frame_idx, element_id?, cutout_id?, label} subject_image: dict | None = None scene_image: dict | None = None @@ -4379,6 +4385,12 @@ class UpdateStoryboardReq(BaseModel): last_image: dict | None = None product_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 + needs_subject: bool = True + first_frame_plan: str = "" + last_frame_plan: str = "" + product_placement: str = "" subject_image: dict | None = None scene_image: dict | None = None product_image: dict | None = None @@ -5548,6 +5560,12 @@ def update_storyboard(job_id: str, idx: int, req: UpdateStoryboardReq) -> Job: last_image=req.last_image, product_images=list(req.product_images), product_fusion_shots=list(req.product_fusion_shots), + visual_mode=req.visual_mode, + needs_product=bool(req.needs_product), + needs_subject=bool(req.needs_subject), + first_frame_plan=req.first_frame_plan.strip(), + last_frame_plan=req.last_frame_plan.strip(), + product_placement=req.product_placement.strip(), subject_image=req.subject_image, scene_image=req.scene_image, product_image=req.product_image, diff --git a/docs/source-analysis.html b/docs/source-analysis.html index 58b4b88..5dabd0b 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -575,7 +575,7 @@
2

下载源视频

后端用 yt-dlp 或本地上传文件落 source.mp4,记录时长、尺寸和视频只读地址。

3

解析音频

source.mp4 提取 audio.wav,ASR 提取原文案,翻译成中文,并写入逐句时间轴。

4

声音分析

用音频模型分析讲话人、口播节奏、停顿、背景音乐/环境声/音效;不默认改写配音或生成视频。

-
5

分镜生成

按逐句时间轴生成竖向分镜行,单行内只承接原内容、新口播、画面规划和候选视频;关键帧和相似主体在源视频工作区下方全局处理。

+
5

分镜生成

按逐句时间轴生成竖向分镜行;每行先规划镜头类型、是否需要人物/产品、首帧、尾帧和产品出现方式,再决定后续生视频提交哪些参考图。

@@ -589,7 +589,7 @@ web/next.config.mjsNext.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator,并移除 Next 16 已不支持的 eslint 顶层配置,避免本地 dev 出现配置 Issue 提示。 web/app/globals.css全局主题变量、登录页视觉样式、ReactFlow 样式引用,以及本地开发态 nextjs-portal 遮挡隐藏规则。 web/app/page.tsx产品工作台主状态:jobs、activeJobId、生成任务状态;主渲染为全屏素材输入列 + 信息流广告复刻工作表;“开始”编排状态只负责在下载完成后自动触发 triggerTranscribe,不再默认触发抽帧、Vision 扫描或分镜初稿保存;底部吸附音频条不再从主界面渲染。 - web/components/ad-recreation-board.tsx信息流广告复刻工作表:左侧素材输入只负责链接/上传和任务切换,不再重复放横版原视频预览;右侧展示视频下载状态、默认折叠的文案依据,以及源视频工作区。音频解析结果改成默认折叠的辅助信息,展开后同一行看讲话人/节奏/背景音;主工作区左侧是按 9:16 显示的竖版原视频播放器,播放器内覆盖“当前点抽帧”,按当前播放秒数手动补参考帧;右侧上方是音频波形 / 切点参考,下方是逐句时间轴;下一行铺开“关键帧 / 相似主体”。音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点,顶部同时显示当前播放秒数、总时长和鼠标指针停点秒数。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。关键帧区的主入口是“自动抽帧 12 张”,一键按动作峰值目标重新抽取 12 张源视频参考帧,优先抓手势、表情变化、节奏点和镜头变化,缩略图按竖版完整比例显示不裁切并用更多列紧凑铺开,鼠标停留会通过固定浮层放大展示完整帧。“生成 10 张高清图”放在相似主体白底视图区,不和抽参考按钮平齐;如果用户没有勾选帧,默认把全部关键帧作为主体参考,勾选后只传已选帧;生成区可在“透明骨架 / 普通真人”之间切换,可选择桌面导入的 5 套内置形象作为创意方向,并可填写统一主体方向,例如年轻女性、更运动、更高级。关键帧和相似主体白底视图都用更小的竖版缩略图密排;白底视图只展示每个 view 的最新一张,缩略图上提供“重新生成这一张”和“删除这一张”,单张重生会用 replace_views=true 替换同一视角。前端调用 generateSubjectAssets 时按主体类型传 subject_style=transparent_humansource_actor,按需传 character_id,并使用 reconstruction_mode=similar;后端会把关键帧和内置形象视为同一个主体的创意证据,并锁定同一性别表现、年龄段、体型、材质、风格和视觉身份,同时生成全身多视角 + 肩颈正/左右近景 + 后颈肩背特写,避免整套图出现男女性别、老少年龄或样式混杂。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px,但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写,分镜时间和原内容列压缩为窄摘要列,把横向空间留给新口播、画面规划和视频候选;生成本条视频时使用当前编辑后的新口播文案。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入和 6 个候选视频槽;候选视频槽在宽屏下一排显示 6 个竖版预览,避免前面空旷、后面拥挤。单条生成会从全局选中关键帧或 12 张关键帧中取最贴近本句时间点的参考帧。单条生成会从产品素材池按分镜角色、视角优先级、用途标签、置信度和风险自动挑选最多 6 张相关产品图,不会把全部产品图提交给生视频模型,然后把产品坐标系、视角标注、方向、结构点和风险写入 Seedance 提示。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_humansource_actor,按需传 character_id,并使用 reconstruction_mode=similar;后端会把关键帧和内置形象视为同一个主体的创意证据,并锁定同一性别表现、年龄段、体型、材质、风格和视觉身份,同时生成全身多视角 + 肩颈正/左右近景 + 后颈肩背特写,避免整套图出现男女性别、老少年龄或样式混杂。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px,但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写,分镜时间和原内容列压缩为窄摘要列,把横向空间留给新口播、画面规划和视频候选;生成本条视频时使用当前编辑后的新口播文案。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入和 6 个候选视频槽;画面规划区先选择镜头类型(人物/情绪、人物+产品、产品特写、场景过渡),再用人物/产品开关、首帧规划、尾帧规划和产品出现方式决定这一条到底需不需要产品图或相似主体参考。候选视频槽在宽屏下一排显示 6 个竖版预览,避免前面空旷、后面拥挤。单条生成会从全局选中关键帧或 12 张关键帧中取最贴近本句时间点的参考帧。只有该行勾选“产品”时,单条生成才会从产品素材池按分镜角色、视角优先级、用途标签、置信度和风险自动挑选最多 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 → 下载完成后自动触发音频处理 -> 左侧素材输入列 + 右侧默认折叠的文案依据 + 源视频工作区(音频解析结果默认折叠,竖版 9:16 原视频播放器内可当前点抽帧,右侧上方连续响度波形显示当前/总时长/指针停点,右侧下方逐句时间轴联动滚动,参考帧池在下方多列铺开且主入口为“自动抽帧 12 张”,相似主体高清视图包生成按钮放在视图区;不勾选帧则默认用全部帧,勾选后只用已选帧,可叠加 5 套内置形象) - -> 信息流复刻分镜工作台:同一产品素材池不限量上传 → 自动识别视角 / 背景 / 用途 / 风险 → 人工检查备注 → 单条生成自动挑选最多 6 张相关产品图 → 逐句时间轴 → 原内容 / 新口播文案 / 画面规划与产品融入 / 6 个候选视频槽 + -> 信息流复刻分镜工作台:同一产品素材池不限量上传 → 自动识别视角 / 背景 / 用途 / 风险 → 人工检查备注 → 逐句时间轴 → 原内容 / 新口播文案 / 画面规划与产品融入(镜头类型、人物/产品开关、首帧、尾帧、产品出现方式)→ 单条生成按规划选择是否传产品图和相似主体参考图 → 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
你看到的区域信息流复刻分镜工作台
-
主要源码AudioStoryboardPlanPanelProductReferenceCardMissingProductViewSlotbuildAudioStoryboardRowsselectProductItemsForRowbuildStoryboardSceneFromAudioRowStoryboardVideoSlots in web/components/ad-recreation-board.tsx;产品白底图上传复用 uploadStoryboardAsset,视角自动识别调用 analyzeProductViews,缺角度自动补图调用 generateProductAngleAsset,单条生成按全局关键帧池匹配当前句时间点,复用 onGenerateVideoPUT /frames/{idx}/storyboard
-
适合怎么描述“按音频逐句生成产品分镜、每行怎样改写口播、产品素材池识别/补图后的备注是否准确、单条生成该选哪几张产品图、生成的视频应该回显到哪一行”。
+
主要源码AudioStoryboardPlanPanelProductReferenceCardMissingProductViewSlotbuildAudioStoryboardRowsselectProductItemsForRowbuildStoryboardSceneFromAudioRowStoryboardVideoSlots in web/components/ad-recreation-board.tsx;产品白底图上传复用 uploadStoryboardAsset,视角自动识别调用 analyzeProductViews,缺角度自动补图调用 generateProductAngleAsset。单条生成按全局关键帧池匹配当前句时间点,并把镜头类型、人物/产品开关、首帧规划、尾帧规划和产品出现方式写入 StoryboardScene,复用 onGenerateVideoPUT /frames/{idx}/storyboard
+
适合怎么描述“按音频逐句生成产品分镜、每行怎样改写口播、哪几句不需要产品或人物、首帧/尾帧该怎么停、产品素材池识别/补图后的备注是否准确、单条生成该选哪几张参考图、生成的视频应该回显到哪一行”。
你看到的区域旧深度素材面板(当前不作为主路径)
@@ -850,6 +850,15 @@ ProductRefStateItem {

分镜编排结果,不是复刻说明。它把参考图和 SKG 改造方向绑定到一个分镜上。

StoryboardScene {
   duration,
+  visual_mode: person_only | person_product | product_only | environment,
+  needs_product,
+  needs_subject,
+  first_frame_plan,
+  last_frame_plan,
+  product_placement,
+  first_image,
+  last_image,
+  product_images[],
   subject_image,
   scene_image,
   product_image,
@@ -900,7 +909,7 @@ ProductRefStateItem {
             角色图入库到 jobPOST /jobs/{id}/assets/character-librarycopyCharacterLibraryAssets把所选角色的 7 张参考图复制为当前 job asset,返回 subject_images,产品融合生成视频时作为人物身份参考图提交。
             产品融合引导图POST /jobs/{id}/product-fusion/guidecreateProductFusionGuide旧流程兼容接口:读取产品图和白底人物图,按 product_region 合成位置引导图。当前内置角色 + 产品 + 描述流程不再主动调用它。
             产品融合描述词POST /jobs/{id}/product-fusion/descriptionsgenerateProductFusionDescriptions兼容接口:可生成产品融合动作描述库。当前前端默认直接用本地 36 条镜头语言模板预填 6 行镜头,并通过“换一组”按钮按 6 条一组轮换。
-            分镜保存PUT /frames/{idx}/storyboardupdateStoryboard保存 4 图槽、时长和改造说明。
+            分镜保存PUT /frames/{idx}/storyboardupdateStoryboard保存 4 图槽、时长、改造说明,以及当前主工作表的镜头类型、人物/产品开关、首帧规划、尾帧规划和产品出现方式。
             生图POST /frames/{idx}/generategenerateImage基于关键帧或已选生成图做 image-to-image,目前可用。
           
         
@@ -1005,6 +1014,19 @@ ProductRefStateItem {
         

变更记录

这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。

+
+
+

2026-05-18 · 分镜画面规划加入首尾帧和人物/产品开关

+ UI + API + Workflow +
+
+

问题:“画面规划 / 产品融入”默认把每句都理解成产品 + 人物视频,导致痛点铺垫、场景过渡和产品特写无法按信息流广告真实节奏拆开。

+

改动:AudioStoryboardPlanPanel 每行新增镜头类型、人物/产品开关、首帧规划、尾帧规划和产品出现方式;默认按角色把开场/痛点设为人物情绪、利益证明设为人物+产品、转化收口设为产品特写。StoryboardScene 新增 visual_modeneeds_productneeds_subjectfirst_frame_planlast_frame_planproduct_placement,后端保存到 state.json

+

影响:web/components/ad-recreation-board.tsxweb/app/page.tsxweb/lib/api.tsapi/main.pyRULES.mddocs/source-analysis.html。单条生成时,只有勾选产品才会自动选最多 6 张产品图;只有勾选人物才会传相似主体参考图,避免不需要产品/人物的镜头被硬塞错内容。

+
+

2026-05-18 · 生图网关增加显式代理和网络错误提示

diff --git a/web/app/page.tsx b/web/app/page.tsx index 6c1d538..7277b61 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -84,6 +84,20 @@ const PRODUCT_FUSION_NEGATIVE_PROMPT = [ "no product passing through the neck, no product inside the transparent body, no x-ray blending, no transparent product, no product becoming bones or skin, no product fused with spine/ribs/throat, no clipping through shoulders, no floating device, no melted device, no deformed U-shape, no wrong body part, no necklace/scarf/headphones/brace, no random replacement product.", ].join("\n") +function storyboardNeedsProduct(scene: StoryboardScene) { + if (scene.needs_product === false) return false + if (scene.needs_product === true) return true + const text = `${scene.visual_mode ?? ""} ${scene.product ?? ""} ${scene.product_placement ?? ""}`.toLowerCase() + return !/(不出现产品|不露产品|无需产品|不需要产品|无产品|no product|environment|person_only)/.test(text) +} + +function storyboardNeedsSubject(scene: StoryboardScene) { + if (scene.needs_subject === false) return false + if (scene.needs_subject === true) return true + const text = `${scene.visual_mode ?? ""} ${scene.subject ?? ""}`.toLowerCase() + return !/(不需要人物|无人物|不出现人物|no person|product_only|environment)/.test(text) +} + // 合并 input + download + split 为一个节点 // 分叉:上路 input → visual lab ↘ // 下路 input → audio ──────────────────────────→ compose @@ -565,8 +579,10 @@ export default function Home() { : null const firstRef = scene.first_image ?? keyframeRef const lastRef = scene.last_image ?? defaultLastRef - let productRefs = (scene.product_images?.length ? scene.product_images : scene.product_image ? [scene.product_image] : []) - if (productRefs.length === 0) { + const needsProduct = storyboardNeedsProduct(scene) + const needsSubject = storyboardNeedsSubject(scene) + let productRefs = needsProduct ? (scene.product_images?.length ? scene.product_images : scene.product_image ? [scene.product_image] : []) : [] + if (needsProduct && productRefs.length === 0) { try { productRefs = await ensureDefaultProductRefs(job.id) } catch (e) { @@ -574,7 +590,7 @@ export default function Home() { return } } - const subjectRefs: ImageRef[] = (frame.elements ?? []) + const subjectRefs: ImageRef[] = needsSubject ? (frame.elements ?? []) .flatMap((element) => element.subject_assets ?? []) .slice(0, 6) .map((asset) => ({ @@ -583,8 +599,8 @@ export default function Home() { element_id: asset.id, cutout_id: asset.id, label: asset.label, - })) - const primarySubjectRef = subjectRefs[0] ?? firstRef + })) : [] + const primarySubjectRef = needsSubject ? (subjectRefs[0] ?? firstRef) : null const duration = scene.duration && scene.duration > 0 ? scene.duration : 5 const sourceScene = frame.description?.scene ? `参考画面识别:${frame.description.scene}` : "" const sourceStyle = frame.description?.style ? `参考风格:${frame.description.style}` : "" @@ -607,31 +623,47 @@ export default function Home() { ].join("\n") const prompt = [ `竖屏 9:16,${duration.toFixed(1)} 秒,SKG 产品短视频广告。`, - productNature, - productRefs.length + needsProduct + ? productNature + : "本条分镜规划为非产品主镜头:可以只拍人物状态、场景过渡、情绪停点或节奏承接。不要硬插 SKG 产品、白底产品图、包装或任何随机商品。", + needsProduct && productRefs.length ? `已上传 ${productRefs.length} 张 SKG 真实产品参考图。产品参考图是唯一产品真源:视频中出现的产品必须严格匹配这些图的外观、颜色、材质、结构比例和关键细节。` - : "未上传产品图时,仍需生成一个干净高级的 SKG 产品广告画面,但不得保留原视频里的竞品包装或平台元素。", - "首帧和尾帧只用于控制画面起止、构图、场景和动作方向;如果首尾帧里有竞品、文字包装或非 SKG 产品,必须替换为上传的 SKG 产品参考。", + : needsProduct + ? "未上传产品图时,仍需生成一个干净高级的 SKG 产品广告画面,但不得保留原视频里的竞品包装或平台元素。" + : "本条不传产品参考图;如首尾帧里出现竞品、包装或非 SKG 商品,应弱化、移除或作为模糊背景,不要替换成 SKG 产品。", + needsProduct + ? "首帧和尾帧只用于控制画面起止、构图、场景和动作方向;如果首尾帧里有竞品、文字包装或非 SKG 产品,必须替换为上传的 SKG 产品参考。" + : "首帧和尾帧用于控制画面起止、构图、场景和动作方向;本条没有产品任务,不要因为广告语而自动添加产品。", "使用首帧和尾帧生成连续过渡视频:首帧必须严格作为视频开始画面,尾帧必须作为视频结束目标画面,中间只做自然运动补间。", "生成一段单镜头连续视频,一镜到底,从首帧平滑过渡到尾帧;不要跳切,不要突然换场景,不要突然换主体,不要蒙太奇,不要多镜头拼接。", "如果提供了原视频链接,把它只作为节奏、镜头运动、动作顺序和画面调度参考;不要照搬原视频里的品牌、文字、水印、竞品产品或具体人物。", "时间线:0%-15% 锁住首帧构图并轻微启动;15%-85% 做平滑连续运动;85%-100% 缓慢贴近尾帧并稳定收住。", - TRANSPARENT_HUMAN_VIDEO_PROMPT, + `镜头类型:${scene.visual_mode ?? "未标注"};需要人物=${needsSubject ? "是" : "否"};需要产品=${needsProduct ? "是" : "否"}。`, + scene.first_frame_plan ? `首帧规划:${scene.first_frame_plan}` : "", + scene.last_frame_plan ? `尾帧规划:${scene.last_frame_plan}` : "", + scene.product_placement ? `产品出现方式:${scene.product_placement}` : "", + needsSubject + ? TRANSPARENT_HUMAN_VIDEO_PROMPT + : "本条不传人物主体参考图;如果画面需要人物,只能作为背景、手部局部或模糊生活方式元素,不要生成主角式透明骨架人。", `主体改造:${subjectDirection}`, - `产品替换:${productDirection} 产品必须作为颈部/肩颈按摩仪被正确佩戴或展示,不要放在脸上、手臂上、桌面当摆件,也不要变成瓶子、面霜、医疗设备或食品。`, + needsProduct + ? `产品替换:${productDirection} 产品必须作为颈部/肩颈按摩仪被正确佩戴或展示,不要放在脸上、手臂上、桌面当摆件,也不要变成瓶子、面霜、医疗设备或食品。` + : `产品处理:${productDirection} 本条不需要露出 SKG 产品,不要硬插产品、包装、瓶罐、医疗器械或随机商品。`, `场景改造:${sceneDirection}`, `连续动作和镜头:${actionDirection}`, `首帧:${labelOf(firstRef, "当前分镜关键帧")}`, `尾帧:${labelOf(lastRef, "未指定,按首帧小幅自然运动收尾")}`, - `SKG 产品参考:${productRefs.length ? productRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "SKG 产品角度")}`).join(";") : "SKG 产品视觉主角"}`, - subjectRefs.length ? `关键元素 6 视图参考:${subjectRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "元素视图")}`).join(";")}` : "如果该分镜还没有关键元素 6 视图,优先使用首帧主体关系生成。", + needsProduct ? `SKG 产品参考:${productRefs.length ? productRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "SKG 产品角度")}`).join(";") : "SKG 产品视觉主角"}` : "SKG 产品参考:本条不使用产品参考图。", + needsSubject + ? (subjectRefs.length ? `关键元素 6 视图参考:${subjectRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "元素视图")}`).join(";")}` : "如果该分镜还没有关键元素 6 视图,优先使用首帧主体关系生成。") + : "关键元素 6 视图参考:本条不使用人物主体参考图。", sourceScene, sourceStyle, sourceObjects, - "产品一致性要求:整个视频只能出现同一个白色 U 形 SKG 颈部按摩仪或同一套包装;不要生成第二种产品,不要改变 U 形机身、金属按摩触点、侧边按键、白色材质和整体比例,不要凭空增加屏幕、线缆、文字标签或说明书。", - "产品呈现要求:至少一次让产品在脖子/肩颈位置清晰占据视觉中心,边缘清楚、材质真实、比例可信;手部接触产品时不要遮挡关键外观,产品不能融化、扭曲、穿帮或漂移。", - "状态改善要求:画面应形成明确的使用前后感受变化:使用前可以是低头久坐、揉脖子、肩颈疲惫或紧绷;使用后变为肩颈放松、抬头、动作舒展、精神更好。人形骷髅也可以表现为从僵硬难受变轻松放松。表达舒缓和放松,不要承诺治疗。", - "运动要求:动作幅度小而连续,速度均匀,手部和产品位置前后一致,产品外形不变形,人物表情和姿态不漂移,背景只允许轻微景深和光影变化。", + needsProduct ? "产品一致性要求:整个视频只能出现同一个白色 U 形 SKG 颈部按摩仪或同一套包装;不要生成第二种产品,不要改变 U 形机身、金属按摩触点、侧边按键、白色材质和整体比例,不要凭空增加屏幕、线缆、文字标签或说明书。" : "", + needsProduct ? "产品呈现要求:至少一次让产品在脖子/肩颈位置清晰占据视觉中心,边缘清楚、材质真实、比例可信;手部接触产品时不要遮挡关键外观,产品不能融化、扭曲、穿帮或漂移。" : "", + needsSubject || needsProduct ? "状态改善要求:画面应形成明确的使用前后感受变化:使用前可以是低头久坐、揉脖子、肩颈疲惫或紧绷;使用后变为肩颈放松、抬头、动作舒展、精神更好。人形骷髅也可以表现为从僵硬难受变轻松放松。表达舒缓和放松,不要承诺治疗。" : "节奏要求:作为过渡镜头时只负责情绪、空间和节奏承接,不承诺疗效,不强行展示使用动作。", + needsProduct ? "运动要求:动作幅度小而连续,速度均匀,手部和产品位置前后一致,产品外形不变形,人物表情和姿态不漂移,背景只允许轻微景深和光影变化。" : "运动要求:动作幅度小而连续,速度均匀,构图从首帧自然过渡到尾帧,不突然添加人物或产品。", "商业质感:真实拍摄感,干净高级,柔和稳定打光,产品边缘清晰,材质真实,画面无抖动、无拉伸、无闪烁。", "禁止:字幕、文字、平台 UI、TikTok 水印、logo 水印、免责声明、竞品包装、随机新物体、非 SKG 产品、医学骨架、夸张病症画面、恐怖元素、画面撕裂、人物或产品突然变形。", TRANSPARENT_HUMAN_NEGATIVE_PROMPT, @@ -649,7 +681,7 @@ export default function Home() { subject_image: primarySubjectRef, subject_images: subjectRefs, scene_image: null, - product_image: productRefs[0] ?? null, + product_image: needsProduct ? (productRefs[0] ?? null) : null, action_image: null, source_ref: sourceUrl ? { kind: "source_video", url: sourceUrl } : null, model, diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx index 712e87e..37a0217 100644 --- a/web/components/ad-recreation-board.tsx +++ b/web/components/ad-recreation-board.tsx @@ -91,15 +91,30 @@ type AudioStoryboardRow = { end: number source: string role: string + visualMode: StoryboardVisualMode + needsProduct: boolean + needsSubject: boolean skgCopy: string visualPlan: string + firstFramePlan: string + lastFramePlan: string referencePlan: string keyElements: string productIntegration: string + productPlacement: string } type ProductRefItem = ProductRefStateItem type SubjectStyleMode = "transparent_human" | "source_actor" +type StoryboardVisualMode = NonNullable +type RowPlanPatch = Partial> + +const VISUAL_MODE_OPTIONS: Array<{ value: StoryboardVisualMode; label: string; description: string }> = [ + { value: "person_only", label: "人物/情绪", description: "只拍人物、状态、痛点或口播,不强制露产品。" }, + { value: "person_product", label: "人物+产品", description: "人物佩戴、拿起、调整或使用 SKG 产品。" }, + { value: "product_only", label: "产品特写", description: "只拍产品、包装、功能细节或 hero packshot。" }, + { value: "environment", label: "场景过渡", description: "只做空间、生活方式、转场或情绪氛围。" }, +] const SUBJECT_ASSET_VIEWS = [ { value: "front", label: "正面" }, @@ -526,22 +541,84 @@ function buildVisualPlan(role: string) { return "保持原片同类构图和运镜,把画面内容替换成 SKG 肩颈放松场景。" } +function visualModeDefaults(mode: StoryboardVisualMode) { + if (mode === "person_only") { + return { + needsProduct: false, + needsSubject: true, + productPlacement: "本条不出现产品,只用人物状态、痛点或口播承接节奏;不要硬插 SKG 产品。", + } + } + if (mode === "product_only") { + return { + needsProduct: true, + needsSubject: false, + productPlacement: "只展示 SKG 肩颈按摩仪本体、佩戴角度或功能细节;不要强行加入人物。", + } + } + if (mode === "environment") { + return { + needsProduct: false, + needsSubject: false, + productPlacement: "本条作为场景/情绪/节奏过渡,不出现产品和人物主体;只保留空间、光线和运动节奏。", + } + } + return { + needsProduct: true, + needsSubject: true, + productPlacement: "SKG 肩颈按摩仪作为外置佩戴产品出现,围绕拿起、佩戴、调整、按键或放松状态展开。", + } +} + +function visualModeForRole(role: string): StoryboardVisualMode { + if (role === "开场钩子" || role === "痛点推进") return "person_only" + if (role === "转化收口") return "product_only" + if (role === "节奏承接") return "environment" + return "person_product" +} + +function buildFirstFramePlan(role: string) { + if (role === "开场钩子") return "人物近景看向镜头或低头办公,手轻扶后颈,画面先不露产品。" + if (role === "痛点推进") return "保留原片人物动作节奏,肩颈紧绷、低头、揉脖子或久坐状态明确。" + if (role === "利益证明") return "人物拿起或准备佩戴 SKG 肩颈按摩仪,产品位置清晰但动作刚开始。" + if (role === "方案过渡") return "人物从痛点状态切到拿起产品/靠近肩颈,准备进入使用动作。" + if (role === "转化收口") return "产品干净特写或佩戴完成后的稳定画面,留出转化收口的视觉焦点。" + return "按原视频当前句的构图启动,先承接节奏,不强行改变镜头主体。" +} + +function buildLastFramePlan(role: string) { + if (role === "开场钩子") return "人物抬头或表情更集中,给下一镜产品或方案进入留出空间。" + if (role === "痛点推进") return "紧绷状态被放大到一个明确停点,准备切入产品解决方案。" + if (role === "利益证明") return "产品已正确佩戴在后颈/肩颈位置,人物放松,产品比例稳定。" + if (role === "方案过渡") return "产品贴合肩颈,手部调整完成,画面自然进入功能细节或放松状态。" + if (role === "转化收口") return "产品或佩戴状态稳定收住,画面干净,适合后续接购买/行动号召。" + return "动作小幅推进并稳定停住,保留与下一句衔接的方向感。" +} + function buildAudioStoryboardRows(job: Job | null): AudioStoryboardRow[] { if (!job?.transcript.length) return [] return job.transcript.map((segment, index) => { const source = segment.zh?.trim() || segment.en?.trim() || "原音频文案待补充" const role = classifyAudioRole(`${segment.en} ${segment.zh}`, index, job.transcript.length) + const visualMode = visualModeForRole(role) + const defaults = visualModeDefaults(visualMode) return { index: segment.index, start: segment.start, end: segment.end, source, role, + visualMode, + needsProduct: defaults.needsProduct, + needsSubject: defaults.needsSubject, skgCopy: buildSkgCopy(role, index), visualPlan: buildVisualPlan(role), + firstFramePlan: buildFirstFramePlan(role), + lastFramePlan: buildLastFramePlan(role), referencePlan: `从原视频 ${segment.start.toFixed(1)}-${segment.end.toFixed(1)}s 定向抽 1-2 张参考帧。`, keyElements: role === "利益证明" ? "佩戴动作、产品位置、手部按键、放松表情" : "口播构图、人物动作、表情节奏、场景光线", productIntegration: "把原片产品/道具语境替换为 SKG 白色 U 形颈部按摩仪,产品必须外置佩戴在肩颈位置。", + productPlacement: defaults.productPlacement, } }) } @@ -742,6 +819,35 @@ function productReferenceNotes(items: ProductRefItem[]) { .join(";") } +function savedScenePatch(scene?: StoryboardScene | null): RowPlanPatch { + if (!scene) return {} + return { + visualMode: scene.visual_mode, + needsProduct: scene.needs_product, + needsSubject: scene.needs_subject, + visualPlan: scene.scene?.split("\n").find((line) => line.trim() && !line.startsWith("镜头类型") && !line.startsWith("首帧规划") && !line.startsWith("尾帧规划") && !line.startsWith("原音频依据"))?.trim(), + firstFramePlan: scene.first_frame_plan, + lastFramePlan: scene.last_frame_plan, + productIntegration: scene.product?.split("\n").find((line) => line.trim() && !line.startsWith("产品需求") && !line.startsWith("产品出现方式") && !line.startsWith("产品素材池") && !line.startsWith("未上传产品图") && !line.startsWith("本条规划"))?.trim(), + productPlacement: scene.product_placement, + } +} + +function applyPlanPatch(row: AudioStoryboardRow, patch?: RowPlanPatch): AudioStoryboardRow { + if (!patch) return row + return { + ...row, + visualMode: patch.visualMode ?? row.visualMode, + needsProduct: patch.needsProduct ?? row.needsProduct, + needsSubject: patch.needsSubject ?? row.needsSubject, + visualPlan: patch.visualPlan ?? row.visualPlan, + firstFramePlan: patch.firstFramePlan ?? row.firstFramePlan, + lastFramePlan: patch.lastFramePlan ?? row.lastFramePlan, + productIntegration: patch.productIntegration ?? row.productIntegration, + productPlacement: patch.productPlacement ?? row.productPlacement, + } +} + function productPriorityForRow(row: AudioStoryboardRow) { const viewPriorityByRole: Record = { "开场钩子": ["front", "left_45", "right_45", "side_thickness"], @@ -817,22 +923,30 @@ function selectProductItemsForRow(row: AudioStoryboardRow, items: ProductRefItem } function buildStoryboardSceneFromAudioRow(row: AudioStoryboardRow, frame: KeyFrame, nextFrame?: KeyFrame | null, productItems: ProductRefItem[] = []): StoryboardScene { - const selectedProductItems = selectProductItemsForRow(row, productItems) + const selectedProductItems = row.needsProduct ? selectProductItemsForRow(row, productItems) : [] const productRefs = selectedProductItems.map((item) => item.ref) const notes = productReferenceNotes(selectedProductItems) - const productGuidance = productItems.length + const productGuidance = !row.needsProduct + ? "本条规划为不露出产品或不把产品作为画面主体;视频生成时不要硬插 SKG 产品、包装、白底图或错误商品。" + : productItems.length ? `产品素材池共有 ${productItems.length} 张,本条只选用 ${selectedProductItems.length} 张最相关参考图,不要把未选素材混入本条画面。产品硬定义:这是套在脖子上的 U 形肩颈按摩仪,不是耳机、头戴设备或护颈枕。坐标系硬规则:左/右按佩戴者身体左右,不能按图片左右;上=靠近下巴/脸/颈部上沿,下=靠近锁骨/肩部下沿;内侧=贴颈皮肤/按摩触点,外侧=外壳/按键/Logo。所选图片只作为产品结构、角度、比例和细节参考,不要照搬参考图的白底/黑底/棚拍背景。视角标注:${notes}。保留左右非对称细节,不要把两边做成镜像对称;肩颈产品大小必须贴近真实佩戴比例,不能缩成耳机,也不能放大成护颈枕。` : "未上传产品图时使用默认 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, + visual_mode: row.visualMode, + needs_product: row.needsProduct, + needs_subject: row.needsSubject, + first_frame_plan: row.firstFramePlan, + last_frame_plan: row.lastFramePlan, + product_placement: row.productPlacement, product_images: productRefs, product_image: productRefs[0] ?? null, - subject: row.keyElements, - scene: `${row.visualPlan}\n原音频依据:${row.source}`, - product: `${row.productIntegration}\n${productGuidance}`, - action: row.skgCopy, + subject: row.needsSubject ? row.keyElements : "本条不需要人物主体或相似主体参考;如画面里出现人物,只作为背景或局部,不作为主角。", + 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连续动作:从首帧规划自然过渡到尾帧规划,镜头类型和产品/人物需求不能中途改变。`, reference_ids: [], } } @@ -2039,6 +2153,7 @@ function AudioStoryboardPlanPanel({ const [productAnalyzing, setProductAnalyzing] = useState(false) const [productAngleBusy, setProductAngleBusy] = useState(null) const [copyOverrides, setCopyOverrides] = useState>({}) + const [planOverrides, setPlanOverrides] = useState>({}) const [authorIntent, setAuthorIntent] = useState("") const [scriptRewriteBusy, setScriptRewriteBusy] = useState<"all" | number | null>(null) const productFileRef = useRef(null) @@ -2054,6 +2169,7 @@ function AudioStoryboardPlanPanel({ useEffect(() => { setProductItems((job?.product_refs ?? []).map(normalizeStoredProductItem)) setCopyOverrides({}) + setPlanOverrides({}) setAuthorIntent("") setScriptRewriteBusy(null) }, [job?.id]) @@ -2080,6 +2196,23 @@ function AudioStoryboardPlanPanel({ setCopyOverrides((prev) => ({ ...prev, [rowIndex]: value })) } + const patchRowPlan = (rowIndex: number, patch: RowPlanPatch) => { + setPlanOverrides((prev) => ({ ...prev, [rowIndex]: { ...(prev[rowIndex] ?? {}), ...patch } })) + } + + const applyVisualMode = (rowIndex: number, mode: StoryboardVisualMode) => { + const defaults = visualModeDefaults(mode) + patchRowPlan(rowIndex, { + visualMode: mode, + needsProduct: defaults.needsProduct, + needsSubject: defaults.needsSubject, + productPlacement: defaults.productPlacement, + }) + } + + const planForRow = (row: AudioStoryboardRow, frame: KeyFrame | null) => + applyPlanPatch(applyPlanPatch(row, savedScenePatch(frame?.storyboard)), planOverrides[row.index]) + const rewriteSegmentForRow = (row: AudioStoryboardRow): StoryboardScriptRewriteSegment => ({ index: row.index, start: row.start, @@ -2313,7 +2446,8 @@ function AudioStoryboardPlanPanel({ const generateRowVideo = async (row: AudioStoryboardRow, frame: KeyFrame | null) => { if (!job || !frame || !onGenerateVideo) return const nextFrame = orderedFrames.find((item) => item.timestamp > frame.timestamp) ?? null - const scene = buildStoryboardSceneFromAudioRow({ ...row, skgCopy: copyForRow(row) }, frame, nextFrame, productItems) + const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row) } + const scene = buildStoryboardSceneFromAudioRow(plannedRow, frame, nextFrame, productItems) setVideoBusyRow(row.index) try { const updated = await updateStoryboard(job.id, frame.index, scene) @@ -2449,9 +2583,11 @@ function AudioStoryboardPlanPanel({
{rows.map((row) => { const referenceFrame = referenceFrameForRow(row) + const plannedRow = planForRow(row, referenceFrame) const rowVideos = videosForFrame(referenceFrame) const generating = videoBusyRow === row.index const copyText = copyForRow(row) + const selectedProductCount = plannedRow.needsProduct ? selectProductItemsForRow(plannedRow, productItems).length : 0 return (
-

{row.visualPlan}

-

- - {row.productIntegration} -

+
+
+ + + +
+