diff --git a/api/main.py b/api/main.py index 4d1b42e..7c3b3cd 100644 --- a/api/main.py +++ b/api/main.py @@ -3975,12 +3975,10 @@ 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 = ( - 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. " + f"Use the generated subject asset references as the primary character identity lock ({len(subject_ref_paths)} image(s)); preserve the subject type, material, proportions, style, age/gender presentation, pose vocabulary, and ad-friendly identity exactly as shown in those selected views. " 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. " + "No generated subject reference was provided for this endpoint. Do not add a main character unless the user scene direction explicitly asks for one. " ) ) mode_clause = { @@ -4026,6 +4024,15 @@ def generate_scene_asset(job_id: str, idx: int, req: GenerateSceneAssetReq) -> J if product_ref_paths else "Do not invent a random product. Only include an SKG product if the user prompt explicitly asks for it. " ) + subject_asset_clause = ( + TRANSPARENT_HUMAN_POSITIVE_PROMPT + " " + + TRANSPARENT_HUMAN_NEGATIVE_PROMPT + " " + + "If the selected subject references are transparent humanoid assets, keep 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. " + + "If the selected subject references are normal actor assets, keep them as a normal believable commercial actor and do not convert them into a transparent skeleton. " + + "Use the selected subject views only to understand identity, proportions, material, pose vocabulary, camera language, and lighting; do not copy watermarks, subtitles, platform UI, logos, or accidental artifacts. " + if subject_ref_paths + else "No main character should be generated unless the user scene direction explicitly requires one; product-only and environment-only frames should stay product-only or scene-only. " + ) if req.asset_role == "scene": prompt = ( "Create one clean high-definition scene/background reference image from this frame. " @@ -4052,11 +4059,8 @@ def generate_scene_asset(job_id: str, idx: int, req: GenerateSceneAssetReq) -> J + 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. " - + "Use the references only to understand character identity, proportions, transparent shell, white bones, pose vocabulary, camera language, and lighting; do not copy watermarks, subtitles, platform UI, logos, or accidental artifacts. " - + "Do not create a plain background plate. Do not remove the character. Do not include SKG product unless the user prompt explicitly asks for it. " + + subject_asset_clause + + "Do not create a plain background plate. Do not include SKG product unless the user prompt explicitly asks for it. " + "The output should be ready as a first/last frame for Seedance video generation, with stable composition, believable perspective, clear subject, no text, no watermark, no gore, no medical surgery imagery." ) models = [GPT_IMAGE_MODEL] diff --git a/docs/source-analysis.html b/docs/source-analysis.html index 4871667..baadc11 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -575,7 +575,7 @@
2

下载源视频

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

3

并行素材分析

下载完成后前端同时触发 triggerTranscribeanalyzeJob:音频路生成字幕/节奏/背景音,视觉路自动抽 12 张参考帧。

4

资产包准备

用户可删除/补选参考帧并生成相似主体视图;参考帧到这里为止,后续首尾帧和视频不再把原关键帧当画面真源。产品图上传后自动识别视角、结构和风险,并补缺角度,形成产品资产包。

-
5

首尾帧闸门

按逐句时间轴生成竖向分镜行;每行先规划镜头类型、是否需要人物/产品、首帧、尾帧和产品出现方式,再用相似主体视图和产品素材生成首尾帧,先看图确认,不直接批量提交视频。

+
5

首尾帧闸门

按逐句时间轴生成竖向分镜行;每行先规划镜头类型、人物描述、是否需要人物/产品、首帧、尾帧和产品出现方式,再按需求从相似主体 6/10 视图里选择合适人物视角,并和产品素材一起生成首尾帧,先看图确认,不直接批量提交视频。

@@ -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、生成任务状态;主渲染为全屏素材输入列 + 信息流广告复刻工作表;“开始分析”会把 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_humansource_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/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 改写,分镜时间和原内容列压缩为窄摘要列,新口播列进一步收窄,把横向空间留给画面规划和首尾帧。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入和历史候选视频槽;画面规划区先选择镜头类型(人物/情绪、人物+产品、产品特写、场景过渡),再用人物/产品开关、首帧规划、尾帧规划和产品出现方式决定这一条到底需不需要产品图或相似主体参考。当前主流程暂停直接调用视频模型,不再提供“生成本条 · Seedance”或“一键提交全部”视频入口;行内新增“首尾帧闸门”,分别显示/生成首帧和尾帧,旧 keyframe 类型首尾帧会被忽略,只认真正的 asset 首尾帧。生成首尾帧时调用 generateSceneAsset,先按人物描述、镜头类型、首尾状态和产品佩戴需求,从相似主体 6/10 视图里自动挑选最多 5 张最相关主体视角,再传入 subject_images 和该行自动挑选的产品图 product_images;关键帧只作为前置主体重构证据和行数据承载位置,不再作为后续视频首尾帧参考。视频候选槽只展示历史候选和待生成占位,按钮改为“保存本条规划 / 保存全部规划”。只有该行勾选“产品”时,首尾帧生成才会从产品素材池按分镜角色、视角优先级、用途标签、置信度和风险自动挑选最多 6 张相关产品图;未勾选产品时不会把产品图提交给首尾帧/后续生视频模型。只有该行勾选“人物”时,才会传按需筛选后的相似主体参考图;否则 prompt 会明确禁止强行添加主角式透明骨架人,后端也不会再给产品特写强加透明骨架人约束。ModelTrace 会在音频解析、产品识别/补图、相似主体高清视图包、脚本改写等入口旁直接展示模型名;所有生图入口都显示并使用 gpt-image-2,没有其他图片模型 fallback;点击后用固定浮层展示模型链路、输入输出和回退逻辑。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 web/components/media-asset-tile.tsx项目内媒体素材缩略图基底组件:图片、视频、抽帧、产品图、相似主体图、首尾帧和视频候选默认从这里获得统一交互。组件负责缩略图显示、顶层固定浮层 hover 放大、删除按钮、重新生成等操作按钮、忙碌遮罩和图片/视频共用预览,避免每个新板块重复手写不同的媒体交互。 web/app/login/page.tsx生产登录页:访问账号/访问密钥表单、保持登录、错误/成功状态;当前只在原版 Digital Oasis 动态背景上叠加一个组合登录框,桌面端左侧是动态角色,右侧是图标化登录表单;面板左上角展示官网 SKG 字标和中文“营销内容工作台”系统标识。 web/app/login/layout.tsx登录路由专属 layout:覆盖全站默认网页标题和描述为空,避免 /login 继承工作台 metadata 后在页面源码里继续出现登录界面文字以外的文案。 @@ -629,7 +629,7 @@ web/app/page.tsx -> 信息流广告复刻工作表:web/components/ad-recreation-board.tsx -> 开始分析:创建/激活 job → 下载完成后并行触发视频视觉路 analyzeJob 与音频文案路 triggerTranscribe -> 左侧素材输入列 + 右侧四路状态条 + 默认折叠的文案依据 + 源视频工作区(音频解析结果默认折叠,竖版 9:16 原视频播放器内可当前点抽帧,右侧上方连续响度波形显示当前/总时长/指针停点,右侧下方逐句时间轴联动滚动,参考帧池在下方多列铺开且主入口为“自动抽帧 12 张”,相似主体高清视图包生成按钮放在视图区;不勾选帧则默认用全部帧,勾选后只用已选帧,可叠加 5 套内置形象) - -> 信息流复刻分镜工作台:同一产品素材池不限量上传 → 自动识别视角 / 背景 / 用途 / 风险 → 人工检查备注 → 逐句时间轴 → 原内容 / 新口播文案 / 画面规划与产品融入(镜头类型、人物/产品开关、首帧、尾帧、产品出现方式)→ 首尾帧闸门:用相似主体视图 + 产品素材生成首帧/尾帧 → 保存规划 → 历史候选视频槽(当前不直接批量提交视频) + -> 信息流复刻分镜工作台:同一产品素材池不限量上传 → 自动识别视角 / 背景 / 用途 / 风险 → 人工检查备注 → 逐句时间轴 → 原内容 / 新口播文案 / 画面规划与产品融入(镜头类型、人物描述、人物/产品开关、首帧、尾帧、产品出现方式)→ 首尾帧闸门:按需求选择主体视角 + 产品素材生成首帧/尾帧 → 保存规划 → 历史候选视频槽(当前不直接批量提交视频) -> 底部音频条:不再渲染,音频结果集中到右侧工作表 -> 旧节点/深度素材面板:web/components/nodes/index.tsx、web/components/lightbox.tsx、web/components/storyboard-workbench.tsx(底层保留,当前不作为主入口) -> API 契约:web/lib/api.ts @@ -656,7 +656,7 @@ api/main.py
你看到的区域信息流复刻分镜工作台
-
主要源码AudioStoryboardPlanPanelProductReferenceCardMissingProductViewSlotbuildAudioStoryboardRowsselectProductItemsForRowsubjectAssetRefsForPlanningendpointAssetRefbuildEndpointFramePromptbuildStoryboardSceneFromAudioRowgenerateEndpointFrameForRowsaveRowStoryboardDraftsaveAllStoryboardDraftsEndpointFrameSlotStoryboardVideoSlots in web/components/ad-recreation-board.tsx;产品图、首尾帧和视频候选缩略图统一复用 MediaAssetTile,包括顶层 hover 放大和删除入口。产品白底图上传复用 uploadStoryboardAsset,视角自动识别调用 analyzeProductViews,缺角度自动补图调用 generateProductAngleAsset。当前单条/批量按钮只保存规划;首尾帧按钮调用 generateSceneAsset,把相似主体白底视图和产品素材写入 subject_images/product_images,再用 PUT /frames/{idx}/storyboard 保存 asset 首尾帧引用;首尾帧删除只移除本条规划中的引用,避免继续误用旧资产。web/app/page.tsx 的视频提交回调有暂停保护,旧入口误触也不会请求 /storyboard/video
+
主要源码AudioStoryboardPlanPanelProductReferenceCardMissingProductViewSlotbuildAudioStoryboardRowsselectProductItemsForRowsubjectAssetRefsForPlanningendpointAssetRefbuildEndpointFramePromptbuildStoryboardSceneFromAudioRowgenerateEndpointFrameForRowsaveRowStoryboardDraftsaveAllStoryboardDraftsEndpointFrameSlotStoryboardVideoSlots in web/components/ad-recreation-board.tsx;产品图、首尾帧和视频候选缩略图统一复用 MediaAssetTile,包括顶层 hover 放大和删除入口。产品白底图上传复用 uploadStoryboardAsset,视角自动识别调用 analyzeProductViews,缺角度自动补图调用 generateProductAngleAsset。当前单条/批量按钮只保存规划;首尾帧按钮调用 generateSceneAsset,把按需求筛选后的相似主体白底视图和产品素材写入 subject_images/product_images,再用 PUT /frames/{idx}/storyboard 保存 asset 首尾帧引用;首尾帧删除只移除本条规划中的引用,避免继续误用旧资产。web/app/page.tsx 的视频提交回调有暂停保护,旧入口误触也不会请求 /storyboard/video
适合怎么描述“按音频逐句生成产品分镜、每行怎样改写口播、哪几句不需要产品或人物、首帧/尾帧该怎么停、首尾帧是否已经生成并准确、产品素材池识别/补图后的备注是否准确、哪些分镜后续才值得进入单条视频候选”。
@@ -911,7 +911,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,目前可用。 @@ -1016,6 +1016,19 @@ ProductRefStateItem {

变更记录

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

+
+
+

2026-05-18 · 首尾帧按人物描述选择主体视角

+ UI + API + Workflow +
+
+

问题:首尾帧不能直接把全部主体视图和产品图丢给模型;每条视频真正需要先确定场景、人、产品和动作,再按需求选择人物参考视角。产品特写不需要人物时,也不能被后端默认强加透明骨架人。

+

改动:AudioStoryboardPlanPanel 的每条分镜补充可编辑人物描述;没有描述时按分镜角色自动补统一相似主体说明。selectSubjectRefsForRow 会根据人物描述、镜头类型、首尾帧状态、佩戴/后颈/近景等关键词,从已生成的 6/10 张主体视图里自动选择最多 5 张最相关视角。后端 generate_scene_asset 只有收到主体图时才加人物身份约束;没有主体图时不会给产品特写硬塞透明骨架人。

+

影响:后续描述需求时应把人物理解为“主体资产池里按镜头需求调度视角”,不是“每条首尾帧都传全部人物图”。

+
+

2026-05-18 · 媒体素材交互收口为统一组件

diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx index 1cf8d52..c347fd0 100644 --- a/web/components/ad-recreation-board.tsx +++ b/web/components/ad-recreation-board.tsx @@ -96,6 +96,7 @@ type AudioStoryboardRow = { visualMode: StoryboardVisualMode needsProduct: boolean needsSubject: boolean + subjectDescription: string skgCopy: string visualPlan: string firstFramePlan: string @@ -107,9 +108,10 @@ type AudioStoryboardRow = { } type ProductRefItem = ProductRefStateItem +type SubjectPlanningRef = ImageRef & { view: string; roleHint: string } type SubjectStyleMode = "transparent_human" | "source_actor" type StoryboardVisualMode = NonNullable -type RowPlanPatch = Partial> +type RowPlanPatch = Partial> const VISUAL_MODE_OPTIONS: Array<{ value: StoryboardVisualMode; label: string; description: string }> = [ { value: "person_only", label: "人物/情绪", description: "只拍人物、状态、痛点或口播,不强制露产品。" }, @@ -156,6 +158,7 @@ const PRODUCT_VIEW_SLOTS = [ ] as const const MAX_PRODUCT_REFS_PER_VIDEO = 6 +const MAX_SUBJECT_REFS_PER_ENDPOINT = 5 const PRODUCT_BACKGROUND_LABELS: Record = { white: "白底", @@ -603,6 +606,17 @@ function buildLastFramePlan(role: string) { return "动作小幅推进并稳定停住,保留与下一句衔接的方向感。" } +function buildSubjectDescription(role: string, visualMode: StoryboardVisualMode) { + if (visualMode === "product_only" || visualMode === "environment") return "" + const base = "统一相似主体:透明或半透明皮肤包裹可见白色骨架的人形,广告感、非恐怖、肩颈/锁骨/上背区域清晰,适合佩戴肩颈按摩仪。" + if (role === "开场钩子") return `${base} 正面或半身口播状态,表情有痛点或好奇感,能快速抓住注意。` + if (role === "痛点推进") return `${base} 肩颈紧绷、低头久坐或按揉脖子的状态,重点看清脖子、肩线和上背。` + if (role === "利益证明") return `${base} 产品佩戴或即将佩戴的放松状态,优先肩颈近景、侧面和后颈肩背角度。` + if (role === "方案过渡") return `${base} 手部调整产品或展示佩戴贴合感,人物姿态自然,产品位置不能挡住关键结构。` + if (role === "转化收口") return `${base} 状态稳定、放松、干净收尾,可用正面/三分之二视角或产品佩戴后的稳定状态。` + return `${base} 保持与整片一致的主体身份、材质、体型、性别表现和广告气质。` +} + function buildAudioStoryboardRows(job: Job | null): AudioStoryboardRow[] { if (!job?.transcript.length) return [] return job.transcript.map((segment, index) => { @@ -619,6 +633,7 @@ function buildAudioStoryboardRows(job: Job | null): AudioStoryboardRow[] { visualMode, needsProduct: defaults.needsProduct, needsSubject: defaults.needsSubject, + subjectDescription: buildSubjectDescription(role, visualMode), skgCopy: buildSkgCopy(role, index), visualPlan: buildVisualPlan(role), firstFramePlan: buildFirstFramePlan(role), @@ -833,6 +848,7 @@ function savedScenePatch(scene?: StoryboardScene | null): RowPlanPatch { visualMode: scene.visual_mode, needsProduct: scene.needs_product, needsSubject: scene.needs_subject, + subjectDescription: scene.subject?.split("\n").find((line) => line.trim() && !line.startsWith("主体真源") && !line.startsWith("本条不需要"))?.trim(), 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, @@ -848,6 +864,7 @@ function applyPlanPatch(row: AudioStoryboardRow, patch?: RowPlanPatch): AudioSto visualMode: patch.visualMode ?? row.visualMode, needsProduct: patch.needsProduct ?? row.needsProduct, needsSubject: patch.needsSubject ?? row.needsSubject, + subjectDescription: patch.subjectDescription ?? row.subjectDescription, visualPlan: patch.visualPlan ?? row.visualPlan, firstFramePlan: patch.firstFramePlan ?? row.firstFramePlan, lastFramePlan: patch.lastFramePlan ?? row.lastFramePlan, @@ -930,7 +947,77 @@ function selectProductItemsForRow(row: AudioStoryboardRow, items: ProductRefItem return picked } -function subjectAssetRefsForPlanning(source: { frame: KeyFrame; element: KeyElement } | null): ImageRef[] { +function subjectViewLabel(view: string) { + return SUBJECT_ASSET_VIEWS.find((item) => item.value === view)?.label ?? view +} + +function subjectViewRoleHint(view: string) { + const hints: Record = { + front: "正面口播、开场、情绪表达、转化收口", + three_quarter_left: "左前45度、口播、佩戴前动作、自然转身", + three_quarter_right: "右前45度、口播、佩戴前动作、自然转身", + left: "左侧、肩颈侧面、佩戴动作、产品厚度与位置", + right: "右侧、肩颈侧面、佩戴动作、产品厚度与位置", + back: "背面、后颈肩背、产品佩戴落位", + bust_front: "肩颈正面近景、痛点表情、佩戴比例", + bust_left_45: "肩颈左前近景、手部调整、佩戴贴合", + bust_right_45: "肩颈右前近景、手部调整、佩戴贴合", + back_neck_detail: "后颈肩背特写、触点位置、产品贴合", + } + return hints[view] ?? "主体参考视角" +} + +function subjectDescriptionForRow(row: AudioStoryboardRow, subjectRefs: SubjectPlanningRef[]) { + const trimmed = row.subjectDescription.trim() + if (trimmed) return trimmed + const labels = subjectRefs.slice(0, 4).map((ref) => ref.label || subjectViewLabel(ref.view)).join("、") + return [ + "统一相似主体:使用已生成的主体视图作为人物真源,保持同一人物身份、体型、材质、年龄段、性别表现和广告气质。", + labels ? `可用主体视角:${labels}。` : "", + "如果本条需要人物但缺少更具体描述,默认保持透明皮肤包裹白色骨架、非恐怖、肩颈区域清晰可佩戴产品。", + ].filter(Boolean).join("") +} + +function subjectPriorityForRow(row: AudioStoryboardRow, role?: "first_frame" | "last_frame") { + const text = `${row.role} ${row.visualMode} ${row.subjectDescription} ${row.visualPlan} ${row.firstFramePlan} ${row.lastFramePlan} ${row.productPlacement}`.toLowerCase() + if (/后颈|肩背|上背|背面|背部|贴合|佩戴完成|已正确佩戴/.test(text)) { + return ["back_neck_detail", "back", "bust_left_45", "bust_right_45", "left", "right", "bust_front", "three_quarter_left", "three_quarter_right", "front"] + } + if (/侧面|左侧|右侧|45|调整|拿起|准备佩戴|靠近肩颈|手部/.test(text)) { + return ["bust_left_45", "bust_right_45", "left", "right", "three_quarter_left", "three_quarter_right", "bust_front", "front", "back_neck_detail", "back"] + } + if (/近景|半身|肩颈|锁骨|脖子|揉脖子|低头|紧绷/.test(text)) { + return ["bust_front", "bust_left_45", "bust_right_45", "front", "three_quarter_left", "three_quarter_right", "left", "right", "back_neck_detail", "back"] + } + if (role === "last_frame" && row.needsProduct) { + return ["back_neck_detail", "back", "bust_left_45", "bust_right_45", "bust_front", "left", "right", "front", "three_quarter_left", "three_quarter_right"] + } + return ["front", "three_quarter_left", "three_quarter_right", "bust_front", "left", "right", "bust_left_45", "bust_right_45", "back_neck_detail", "back"] +} + +function selectSubjectRefsForRow(row: AudioStoryboardRow, refs: SubjectPlanningRef[], role?: "first_frame" | "last_frame") { + if (!row.needsSubject || !refs.length) return [] + const priority = subjectPriorityForRow(row, role) + return refs + .map((ref, index) => { + const rank = priority.indexOf(ref.view) + const labelText = `${ref.label || ""} ${ref.roleHint}`.toLowerCase() + const closeupScore = /肩颈|后颈|近景|贴合|佩戴/.test(row.visualPlan + row.firstFramePlan + row.lastFramePlan + row.productPlacement) + && /bust|neck|近景|肩颈|后颈/.test(`${ref.view} ${labelText}`) + ? 12 + : 0 + return { ref, score: (rank >= 0 ? 100 - rank * 8 : 0) + closeupScore - index } + }) + .sort((a, b) => b.score - a.score) + .slice(0, MAX_SUBJECT_REFS_PER_ENDPOINT) + .map((item) => item.ref) +} + +function subjectReferenceNotes(refs: SubjectPlanningRef[]) { + return refs.map((ref, index) => `${index + 1}. ${ref.label || subjectViewLabel(ref.view)}|${ref.roleHint}`).join(";") +} + +function subjectAssetRefsForPlanning(source: { frame: KeyFrame; element: KeyElement } | null): SubjectPlanningRef[] { if (!source) return [] return (source.element.subject_assets ?? []).slice(0, 10).map((asset) => ({ kind: "asset", @@ -938,6 +1025,8 @@ function subjectAssetRefsForPlanning(source: { frame: KeyFrame; element: KeyElem element_id: asset.id, cutout_id: asset.id, label: asset.label || asset.view || "相似主体视图", + view: asset.view, + roleHint: subjectViewRoleHint(asset.view), })) } @@ -959,10 +1048,12 @@ function endpointAssetRef(frame: KeyFrame | null, role: "first_frame" | "last_fr } } -function buildEndpointFramePrompt(row: AudioStoryboardRow, role: "first_frame" | "last_frame", selectedProductItems: ProductRefItem[], subjectRefs: ImageRef[]) { +function buildEndpointFramePrompt(row: AudioStoryboardRow, role: "first_frame" | "last_frame", selectedProductItems: ProductRefItem[], subjectRefs: SubjectPlanningRef[]) { const target = role === "first_frame" ? row.firstFramePlan : row.lastFramePlan const opposite = role === "first_frame" ? row.lastFramePlan : row.firstFramePlan const productNotes = selectedProductItems.length ? productReferenceNotes(selectedProductItems) : "" + const subjectNotes = subjectRefs.length ? subjectReferenceNotes(subjectRefs) : "" + const subjectDescription = subjectDescriptionForRow(row, subjectRefs) return [ `分镜 ${row.index + 1} ${role === "first_frame" ? "首帧" : "尾帧"}。`, `新口播文案:${row.skgCopy}`, @@ -971,7 +1062,7 @@ function buildEndpointFramePrompt(row: AudioStoryboardRow, role: "first_frame" | `另一端画面用于连续性参考:${opposite}`, `画面规划:${row.visualPlan}`, row.needsSubject - ? `人物主体:必须使用已生成的相似主体白底视图作为人物真源;已提供 ${subjectRefs.length} 张主体参考。不要回到原视频关键帧复刻人物。` + ? `人物主体:${subjectDescription} 必须使用已生成的相似主体白底视图作为人物真源;本次只选择 ${subjectRefs.length} 张最符合镜头需求的主体视角:${subjectNotes}。不要回到原视频关键帧复刻人物。` : "本条不需要主角人物;如出现人物,只能是局部手部、背影或环境人物,不要生成透明骨架主角。", row.needsProduct ? `产品融入:${row.productPlacement}。${row.productIntegration}。已提供 ${selectedProductItems.length} 张同一 SKG 肩颈按摩仪产品参考;${productNotes}。产品是套在脖子上的 U 形肩颈按摩仪,必须保持真实佩戴大小、左右非对称和贴颈位置。` @@ -984,12 +1075,14 @@ function buildStoryboardSceneFromAudioRow( row: AudioStoryboardRow, frame: KeyFrame, productItems: ProductRefItem[] = [], - subjectRefs: ImageRef[] = [], + subjectRefs: SubjectPlanningRef[] = [], 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) + const subjectDescription = subjectDescriptionForRow(row, subjectRefs) + const subjectNotes = subjectReferenceNotes(subjectRefs) const productGuidance = !row.needsProduct ? "本条规划为不露出产品或不把产品作为画面主体;视频生成时不要硬插 SKG 产品、包装、白底图或错误商品。" : productItems.length @@ -1010,7 +1103,7 @@ function buildStoryboardSceneFromAudioRow( subject_images: row.needsSubject ? subjectRefs : [], subject_image: row.needsSubject ? subjectRefs[0] ?? null : null, subject: row.needsSubject - ? `${row.keyElements}\n主体真源:使用已生成的相似主体白底视图,共 ${subjectRefs.length} 张;关键帧只用于前置主体提取,不作为后续视频首尾帧参考。` + ? `${subjectDescription}\n主体动作/画面要素:${row.keyElements}\n主体真源:从已生成的相似主体白底视图中按本镜头需求选择 ${subjectRefs.length} 张;${subjectNotes}。关键帧只用于前置主体提取,不作为后续视频首尾帧参考。` : "本条不需要人物主体或相似主体参考;如画面里出现人物,只作为背景或局部,不作为主角。", 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}`, @@ -2168,10 +2261,12 @@ function AudioStoryboardPlanPanel({ const applyVisualMode = (rowIndex: number, mode: StoryboardVisualMode) => { const defaults = visualModeDefaults(mode) + const row = rows.find((item) => item.index === rowIndex) patchRowPlan(rowIndex, { visualMode: mode, needsProduct: defaults.needsProduct, needsSubject: defaults.needsSubject, + subjectDescription: row ? buildSubjectDescription(row.role, mode) : "", productPlacement: defaults.productPlacement, }) } @@ -2412,7 +2507,8 @@ function AudioStoryboardPlanPanel({ const saveRowStoryboardDraft = async (row: AudioStoryboardRow, frame: KeyFrame) => { if (!job) return const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row) } - const scene = buildStoryboardSceneFromAudioRow(plannedRow, frame, productItems, subjectRefs, { + const selectedSubjectRefs = plannedRow.needsSubject ? selectSubjectRefsForRow(plannedRow, subjectRefs) : [] + const scene = buildStoryboardSceneFromAudioRow(plannedRow, frame, productItems, selectedSubjectRefs, { firstImage: endpointAssetRef(frame, "first_frame"), lastImage: endpointAssetRef(frame, "last_frame"), }) @@ -2423,7 +2519,8 @@ function AudioStoryboardPlanPanel({ 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) { + const selectedSubjectRefs = plannedRow.needsSubject ? selectSubjectRefsForRow(plannedRow, subjectRefs, role) : [] + if (plannedRow.needsSubject && !selectedSubjectRefs.length) { toast.warning("先在上方生成相似主体白底视图,再生成首尾帧") return } @@ -2441,14 +2538,14 @@ function AudioStoryboardPlanPanel({ scene_mode: "similar", scene_style: "premium_product", asset_role: role, - prompt: buildEndpointFramePrompt(plannedRow, role, selectedProductItems, subjectRefs), - subject_images: plannedRow.needsSubject ? subjectRefs : [], + prompt: buildEndpointFramePrompt(plannedRow, role, selectedProductItems, selectedSubjectRefs), + subject_images: selectedSubjectRefs, 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, { + const scene = buildStoryboardSceneFromAudioRow(plannedRow, updatedFrame, productItems, selectedSubjectRefs, { firstImage: role === "first_frame" ? generatedRef : endpointAssetRef(updatedFrame, "first_frame"), lastImage: role === "last_frame" ? generatedRef : endpointAssetRef(updatedFrame, "last_frame"), }) @@ -2468,7 +2565,8 @@ function AudioStoryboardPlanPanel({ const busyKey = `${row.index}:clear_${role}` setEndpointFrameBusy(busyKey) try { - const scene = buildStoryboardSceneFromAudioRow(plannedRow, frame, productItems, subjectRefs, { + const selectedSubjectRefs = plannedRow.needsSubject ? selectSubjectRefsForRow(plannedRow, subjectRefs, role) : [] + const scene = buildStoryboardSceneFromAudioRow(plannedRow, frame, productItems, selectedSubjectRefs, { firstImage: role === "first_frame" ? null : endpointAssetRef(frame, "first_frame"), lastImage: role === "last_frame" ? null : endpointAssetRef(frame, "last_frame"), }) @@ -2663,6 +2761,7 @@ function AudioStoryboardPlanPanel({ const savingStoryboard = storyboardSaveBusyRow === row.index const copyText = copyForRow(row) const selectedProductCount = plannedRow.needsProduct ? selectProductItemsForRow(plannedRow, productItems).length : 0 + const selectedSubjectRefs = plannedRow.needsSubject ? selectSubjectRefsForRow(plannedRow, subjectRefs) : [] return (
+ {plannedRow.needsSubject && ( +