diff --git a/RULES.md b/RULES.md index 4930db5..6a850aa 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 链接或上传视频后点击“开始”,系统自动下载源视频;下载完成后优先提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效。分镜工作台按逐句时间轴规划新口播、镜头类型、首帧/尾帧、人物需求和产品出现方式;不是所有分镜都必须是“人物 + 产品”,单条生成会按该行规划决定是否传产品图和相似主体参考图。 +- 当前产品方向(2026-05-18 再确认):先解决信息流广告快速复刻的第一步,不再沿用“开始后线性完成抽帧、分镜、元素生成、合成”的旧做法。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取 12 张参考帧,供人工选择可用主体并生成相似主体视图。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴规划新口播、镜头类型、首帧/尾帧、人物需求和产品出现方式;单条或“一键提交全部”生成视频时,按该行规划自动调取产品图、人物主体和参考帧。 ## 部署事实 - 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik) diff --git a/api/main.py b/api/main.py index 674993e..5b54dbc 100644 --- a/api/main.py +++ b/api/main.py @@ -1657,8 +1657,11 @@ def pipeline_analyze( raise RuntimeError("source.mp4 不存在,先完成下载") wav = d / "audio.wav" + audio_running = job_id in AUDIO_WORKERS_RUNNING or job.audio_script.status == "rewriting" if wav.exists(): update(job, status="splitting", message="复用音轨 · 准备抽帧…", progress=35, source_audio_url=f"/jobs/{job_id}/audio.wav") + elif audio_running: + update(job, status="splitting", message="音频路并行处理中 · 准备抽帧…", progress=35) else: update(job, status="splitting", message="ffmpeg 拆分音轨…", progress=35) run([ @@ -1778,7 +1781,7 @@ def pipeline_analyze( ) update( job, - status="frames_extracted", + status="transcribed" if job.transcript else "frames_extracted", frames=merged_frames, progress=70, error="", @@ -3140,8 +3143,8 @@ async def trigger_analyze( job = JOBS.get(job_id) if not job: raise HTTPException(404, "job not found") - if job.status not in {"downloaded", "frames_extracted", "transcribed", "failed"}: - raise HTTPException(409, f"status must be downloaded/failed, got {job.status}") + if job.status not in {"downloaded", "frames_extracted", "transcribed", "transcribing", "failed"}: + raise HTTPException(409, f"status must be downloaded/transcribing/failed, got {job.status}") ANALYZE_QUEUE.append((job_id, frames, target, mode, quality)) position = len(ANALYZE_QUEUE) update( diff --git a/docs/source-analysis.html b/docs/source-analysis.html index 5dabd0b..8e74971 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -569,12 +569,12 @@

业务管线

-

当前产品方向已收窄为“信息流广告快速复刻”:主界面左侧是素材输入列,右侧先完成音频解析,再进入信息流复刻分镜工作台。用户粘贴 TK 链接或上传视频后点击“开始”,系统自动下载源视频;下载完成后优先提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效。分镜规划按逐句时间轴生成;视觉参考改为原版视频下方的关键帧池:显眼保留“自动抽帧 12 张”,也可在竖版播放器内按当前播放点手动补帧;生成相似主体时未勾选关键帧则默认使用全部帧,勾选后只用已选帧,也可选择 5 套内置形象作为创意方向,并按透明骨架人或普通真人两种主体类型生成“类似但不复刻”的高清主体视图包。

+

当前产品方向已收窄为“信息流广告快速复刻”:主界面左侧是素材输入列,右侧是信息流复刻工作表。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动音频文案路和视频视觉路。音频文案路提取原音频文案/字幕,分析讲话人、语速节奏、背景音乐/环境声/音效,并为后续新口播和分镜文案提供时间轴;视频视觉路同步自动抽取 12 张参考帧,供人工选择可用主体并生成相似主体视图包。产品图上传后独立形成产品资产包:自动识别视角、左右/上下/内外侧、结构点、比例和风险,并补缺角度。最终分镜规划按逐句时间轴把文案、参考帧、主体资产和产品资产汇合,单条或“一键提交全部”生成视频时再按行调取需要的资源。

1

导入素材

粘贴 TK / 信息流视频链接或上传本地视频;“开始”只把任务放入第一步队列。

2

下载源视频

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

-
3

解析音频

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

-
4

声音分析

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

+
3

并行素材分析

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

+
4

资产包准备

用户可删除/补选参考帧并生成相似主体视图;产品图上传后自动识别视角、结构和风险,并补缺角度,形成产品资产包。

5

分镜生成

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

@@ -588,8 +588,8 @@ 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 张相关产品图;未勾选产品时不会把产品图提交给生视频模型。只有该行勾选“人物”时,才会传相似主体参考图;否则视频 prompt 会明确禁止强行添加主角式透明骨架人。ModelTrace 会在音频解析、产品识别/补图、相似主体高清视图包、脚本改写和单条生视频入口旁直接展示模型名;所有生图入口都显示并使用 gpt-image-2,没有其他图片模型 fallback;点击后用固定浮层展示模型链路、输入输出和回退逻辑。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 + 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 改写,分镜时间和原内容列压缩为窄摘要列,把横向空间留给新口播、画面规划和视频候选;生成本条视频时使用当前编辑后的新口播文案。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入和 6 个候选视频槽;画面规划区先选择镜头类型(人物/情绪、人物+产品、产品特写、场景过渡),再用人物/产品开关、首帧规划、尾帧规划和产品出现方式决定这一条到底需不需要产品图或相似主体参考。候选视频槽在宽屏下一排显示 6 个竖版预览,避免前面空旷、后面拥挤。单条生成会从全局选中关键帧或 12 张关键帧中取最贴近本句时间点的参考帧;“一键提交全部”会逐行保存当前画面规划并批量提交 Seedance 候选视频。只有该行勾选“产品”时,单条生成才会从产品素材池按分镜角色、视角优先级、用途标签、置信度和风险自动挑选最多 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,避免登录面板或输入框遮挡时草地失去鼠标响应。 @@ -626,9 +626,9 @@
前端主链路:
 web/app/page.tsx
   -> 信息流广告复刻工作表:web/components/ad-recreation-board.tsx
-  -> 开始:创建/激活 job → 下载完成后自动触发音频处理
-  -> 左侧素材输入列 + 右侧默认折叠的文案依据 + 源视频工作区(音频解析结果默认折叠,竖版 9:16 原视频播放器内可当前点抽帧,右侧上方连续响度波形显示当前/总时长/指针停点,右侧下方逐句时间轴联动滚动,参考帧池在下方多列铺开且主入口为“自动抽帧 12 张”,相似主体高清视图包生成按钮放在视图区;不勾选帧则默认用全部帧,勾选后只用已选帧,可叠加 5 套内置形象)
-  -> 信息流复刻分镜工作台:同一产品素材池不限量上传 → 自动识别视角 / 背景 / 用途 / 风险 → 人工检查备注 → 逐句时间轴 → 原内容 / 新口播文案 / 画面规划与产品融入(镜头类型、人物/产品开关、首帧、尾帧、产品出现方式)→ 单条生成按规划选择是否传产品图和相似主体参考图 → 6 个候选视频槽
+  -> 开始分析:创建/激活 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
@@ -646,7 +646,7 @@ api/main.py
           
你看到的区域信息流广告复刻工作表
主要源码AdRecreationBoard in web/components/ad-recreation-board.tsx;状态、轮询和接口回写仍在 web/app/page.tsx
-
适合怎么描述“素材输入列、开始后的自动下载/音频解析、逐句时间轴和声音背景音结果怎么展示”。
+
适合怎么描述“素材输入列、开始分析后的自动下载、音频文案路和视频视觉路怎样并行、四路状态条和声音背景音结果怎么展示”。
你看到的区域音频解析结果表
@@ -655,8 +655,8 @@ api/main.py
你看到的区域信息流复刻分镜工作台
-
主要源码AudioStoryboardPlanPanelProductReferenceCardMissingProductViewSlotbuildAudioStoryboardRowsselectProductItemsForRowbuildStoryboardSceneFromAudioRowStoryboardVideoSlots in web/components/ad-recreation-board.tsx;产品白底图上传复用 uploadStoryboardAsset,视角自动识别调用 analyzeProductViews,缺角度自动补图调用 generateProductAngleAsset。单条生成按全局关键帧池匹配当前句时间点,并把镜头类型、人物/产品开关、首帧规划、尾帧规划和产品出现方式写入 StoryboardScene,复用 onGenerateVideoPUT /frames/{idx}/storyboard
-
适合怎么描述“按音频逐句生成产品分镜、每行怎样改写口播、哪几句不需要产品或人物、首帧/尾帧该怎么停、产品素材池识别/补图后的备注是否准确、单条生成该选哪几张参考图、生成的视频应该回显到哪一行”。
+
主要源码AudioStoryboardPlanPanelProductReferenceCardMissingProductViewSlotbuildAudioStoryboardRowsselectProductItemsForRowbuildStoryboardSceneFromAudioRowsubmitRowVideogenerateAllRowVideosStoryboardVideoSlots in web/components/ad-recreation-board.tsx;产品白底图上传复用 uploadStoryboardAsset,视角自动识别调用 analyzeProductViews,缺角度自动补图调用 generateProductAngleAsset。单条生成和“一键提交全部”都会按全局关键帧池匹配当前句时间点,并把镜头类型、人物/产品开关、首帧规划、尾帧规划和产品出现方式写入 StoryboardScene,复用 onGenerateVideoPUT /frames/{idx}/storyboard
+
适合怎么描述“按音频逐句生成产品分镜、每行怎样改写口播、哪几句不需要产品或人物、首帧/尾帧该怎么停、产品素材池识别/补图后的备注是否准确、单条或批量生成该选哪几张参考图、生成的视频应该回显到哪一行”。
你看到的区域旧深度素材面板(当前不作为主路径)
@@ -1014,6 +1014,18 @@ ProductRefStateItem {

变更记录

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

+
+
+

2026-05-18 · 开始分析改为音频与视觉并行资产流

+ UI + Workflow +
+
+

问题:信息流复刻不应该等音频字幕完成后才抽帧;字幕是分镜文案前置条件,但主体参考帧和产品资产可以并行准备。旧体验让用户感觉流程线性且复杂。

+

改动:web/app/page.tsx 新增并行启动逻辑:视频下载完成后触发 triggerTranscribe 解析音频,同时触发 analyzeJob 自动抽 12 张参考帧。api/main.py 允许抽帧在音频转写状态下排队执行,并在音频路已运行时跳过重复拆音轨,避免并发写同一个 audio.wavAdRecreationBoard 顶部新增音频文案路、视频视觉路、主体资产和产品资产状态条,素材输入按钮改成“开始分析”。

+

影响:AudioStoryboardPlanPanel 新增“一键提交全部”,会逐行保存当前画面规划并提交 Seedance 候选视频;前台保留人工检查主体帧、主体视图和产品标注的入口,但主流程更接近“上传素材后后台并行跑,最终按分镜一键提交视频”。

+
+

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

diff --git a/web/app/page.tsx b/web/app/page.tsx index 7277b61..31b4448 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -58,6 +58,7 @@ const DEFAULT_PRODUCT_LIBRARY_IDS = [ "desktop-skg-product-angle-03", "desktop-skg-product-angle-04", ] +const VIDEO_READY_STATUSES: Job["status"][] = ["downloaded", "frames_extracted", "transcribed", "failed"] const PRODUCT_FUSION_WEARING_PROMPT = [ "Product placement must be physically correct:", @@ -229,7 +230,7 @@ export default function Home() { const created = await uploadJob(file) addJob(created) setProductionJobIds((prev) => new Set(prev).add(created.id)) - toast.success(`已上传 ${created.id.slice(0, 8)},下载完成后自动解析音频`) + toast.success(`已上传 ${created.id.slice(0, 8)},视频就绪后自动跑音频和抽帧`) } catch (e) { toast.error("上传失败:" + (e instanceof Error ? e.message : String(e))) } finally { @@ -461,6 +462,44 @@ export default function Home() { } }, [activeJobId, jobs, updateJobInList]) + const startProductionLanesForJob = useCallback(async (target: Job) => { + const videoReady = !!target.video_url && VIDEO_READY_STATUSES.includes(target.status) + if (!videoReady) return + + const audioKey = `${target.id}:audio` + const hasAudioResult = !!target.audio_script?.source_text || target.transcript.length > 0 + const audioRunning = target.status === "transcribing" || target.audio_script?.status === "rewriting" + if (!hasAudioResult && !audioRunning && !autoTriggeredRef.current.has(audioKey)) { + autoTriggeredRef.current.add(audioKey) + try { + const updated = await triggerTranscribe(target.id) + updateJobInList(updated) + toast.info("音频路已启动:字幕、讲话人、节奏和背景音同步解析") + } catch (e) { + autoTriggeredRef.current.delete(audioKey) + toast.error("音频解析启动失败:" + (e instanceof Error ? e.message : String(e))) + } + } + + const visualKey = `${target.id}:visual` + const hasVisualResult = target.frames.length > 0 + const visualRunning = target.status === "splitting" + if (!hasVisualResult && !visualRunning && !autoTriggeredRef.current.has(visualKey)) { + autoTriggeredRef.current.add(visualKey) + const frameTarget = frameTargets[target.id] ?? "motion" + const frameCount = frameCounts[target.id] ?? 12 + const frameQuality = frameQualities[target.id] ?? "accurate" + try { + const updated = await analyzeJob(target.id, frameCount, frameTarget, "replace", frameQuality) + updateJobInList(updated) + toast.info(`视觉路已启动:${FRAME_QUALITY_LABELS[frameQuality]} · ${FRAME_TARGET_LABELS[frameTarget]} · ${frameCount} 张参考帧`) + } catch (e) { + autoTriggeredRef.current.delete(visualKey) + toast.error("视觉抽帧启动失败:" + (e instanceof Error ? e.message : String(e))) + } + } + }, [frameCounts, frameQualities, frameTargets, updateJobInList]) + const ensureDefaultProductRefs = useCallback(async (jobId: string) => { const cached = defaultProductRefsByJob[jobId] if (cached?.length >= 4) return cached.slice(0, 4) @@ -538,26 +577,19 @@ export default function Home() { return } setProductionJobIds((prev) => new Set(prev).add(target.id)) - toast.success("已进入第一步:下载完成后自动解析音频文案、讲话人和背景音") - if (target.video_url && ["downloaded", "frames_extracted", "transcribed", "failed"].includes(target.status)) { - void handleTranscribeAudio(target.id, { silent: true }) - } - }, [handleSubmit, handleTranscribeAudio, job]) + toast.success("已进入并行素材分析:下载完成后自动跑音频文案路和视觉抽帧路") + void startProductionLanesForJob(target) + }, [handleSubmit, job, startProductionLanesForJob]) useEffect(() => { if (productionJobIds.size === 0) return for (const item of jobs) { if (!productionJobIds.has(item.id)) continue - const videoReady = !!item.video_url && ["downloaded", "frames_extracted", "transcribed", "failed"].includes(item.status) + const videoReady = !!item.video_url && VIDEO_READY_STATUSES.includes(item.status) if (!videoReady) continue - const audioKey = `${item.id}:audio` - const hasAudioResult = !!item.audio_script?.source_text || item.transcript.length > 0 - if (!autoTriggeredRef.current.has(audioKey) && item.audio_script?.status !== "rewriting" && !hasAudioResult) { - autoTriggeredRef.current.add(audioKey) - void handleTranscribeAudio(item.id, { silent: true }) - } + void startProductionLanesForJob(item) } - }, [handleTranscribeAudio, jobs, productionJobIds]) + }, [jobs, productionJobIds, startProductionLanesForJob]) const handleQuickGenerateVideo = useCallback(async (frameIdx: number, scene: StoryboardScene, model: string) => { if (!job) return diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx index 37a0217..ddfce47 100644 --- a/web/components/ad-recreation-board.tsx +++ b/web/components/ad-recreation-board.tsx @@ -285,6 +285,13 @@ function countReadySegments(job: Job | null, drafts: DraftSegment[]) { return frameStoryboards + draftCount } +function countSubjectAssetViews(job: Job | null) { + if (!job) return 0 + return job.frames.reduce((sum, frame) => + sum + (frame.elements ?? []).reduce((inner, element) => inner + (element.subject_assets?.length ?? 0), 0), + 0) +} + function guessSubjectKind(name: string): SubjectKind { return /人|人物|模特|骨架|身体|脸|手|person|people|human|body|face|hand|character/i.test(name) ? "living" @@ -976,6 +983,11 @@ export function AdRecreationBoard({ const readySegments = countReadySegments(job, draftSegments) const transcriptCount = job?.transcript.length ?? 0 const backgroundReady = !!job?.audio_script?.background_audio_profile?.trim() + const audioRunning = job?.status === "transcribing" || job?.audio_script?.status === "rewriting" + const visualRunning = job?.status === "splitting" + const visualReady = (job?.frames.length ?? 0) > 0 + const subjectAssetCount = countSubjectAssetViews(job) + const productAssetCount = job?.product_refs?.length ?? 0 useEffect(() => { setDraftSegments([]) @@ -1197,7 +1209,7 @@ export function AdRecreationBoard({
- data.onTranscribeAudio?.(job?.id)}> + data.onTranscribeAudio?.(job?.id)}> 解析音频 @@ -1208,8 +1220,9 @@ export function AdRecreationBoard({
- + +
@@ -1225,6 +1238,12 @@ export function AdRecreationBoard({
+
+ + + 0} /> + 0} /> +
@@ -1298,7 +1317,7 @@ function MaterialColumn({ disabled={data.submitting || (!url.trim() && !job)} className="inline-flex h-10 items-center justify-center rounded-md bg-rose-600 px-3 text-[13px] font-semibold text-white transition hover:bg-rose-500 disabled:cursor-not-allowed disabled:opacity-45" > - 开始 + 开始分析 +
@@ -3695,6 +3760,18 @@ function Requirement({ label, ready, detail }: { label: string; ready: boolean; ) } +function PipelineLane({ label, detail, ready, running }: { label: string; detail: string; ready: boolean; running?: boolean }) { + return ( +
+
+
{label}
+
{detail}
+
+ +
+ ) +} + function VideoCandidate({ job, video,