auto-save 2026-05-17 21:14 (~3)
This commit is contained in:
@@ -1,12 +1,5 @@
|
|||||||
{
|
{
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
|
||||||
"files_changed": 1,
|
|
||||||
"hash": "1cb9861",
|
|
||||||
"message": "auto-save 2026-05-15 12:53 (~1)",
|
|
||||||
"ts": "2026-05-15T12:53:35+08:00",
|
|
||||||
"type": "commit"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"files_changed": 1,
|
"files_changed": 1,
|
||||||
"message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 12:53 (~1)",
|
"message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 12:53 (~1)",
|
||||||
@@ -3261,6 +3254,13 @@
|
|||||||
"type": "session-heartbeat",
|
"type": "session-heartbeat",
|
||||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 3 项未提交变更 · 最近提交:feat: add storyboard script rewriting",
|
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 3 项未提交变更 · 最近提交:feat: add storyboard script rewriting",
|
||||||
"files_changed": 3
|
"files_changed": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-17T21:09:20+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "auto-save 2026-05-17 21:09 (~4)",
|
||||||
|
"hash": "252cdf4",
|
||||||
|
"files_changed": 4
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -569,13 +569,13 @@
|
|||||||
|
|
||||||
<section id="pipeline" data-search>
|
<section id="pipeline" data-search>
|
||||||
<h2>业务管线</h2>
|
<h2>业务管线</h2>
|
||||||
<p>当前产品方向已收窄为“信息流广告快速复刻”:主界面左侧是素材输入列,右侧先完成音频解析,再进入信息流复刻分镜工作台。用户粘贴 TK 链接或上传视频后点击“开始”,系统自动下载源视频;下载完成后优先提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效。分镜规划按逐句时间轴生成,抽帧和视频生成由用户按单条分镜触发,不在当前开始流程里自动全量运行。</p>
|
<p>当前产品方向已收窄为“信息流广告快速复刻”:主界面左侧是素材输入列,右侧先完成音频解析,再进入信息流复刻分镜工作台。用户粘贴 TK 链接或上传视频后点击“开始”,系统自动下载源视频;下载完成后优先提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效。分镜规划按逐句时间轴生成;视觉参考改为在原版视频旁统一抽取 12 张关键帧,由人工选择后生成“类似但不复刻”的相似主角 6 张白底视图,再按分镜生成视频候选。</p>
|
||||||
<div class="pipeline">
|
<div class="pipeline">
|
||||||
<div class="step"><div class="num">1</div><h3>导入素材</h3><p>粘贴 TK / 信息流视频链接或上传本地视频;“开始”只把任务放入第一步队列。</p></div>
|
<div class="step"><div class="num">1</div><h3>导入素材</h3><p>粘贴 TK / 信息流视频链接或上传本地视频;“开始”只把任务放入第一步队列。</p></div>
|
||||||
<div class="step"><div class="num">2</div><h3>下载源视频</h3><p>后端用 yt-dlp 或本地上传文件落 <code>source.mp4</code>,记录时长、尺寸和视频只读地址。</p></div>
|
<div class="step"><div class="num">2</div><h3>下载源视频</h3><p>后端用 yt-dlp 或本地上传文件落 <code>source.mp4</code>,记录时长、尺寸和视频只读地址。</p></div>
|
||||||
<div class="step"><div class="num">3</div><h3>解析音频</h3><p>从 <code>source.mp4</code> 提取 <code>audio.wav</code>,ASR 提取原文案,翻译成中文,并写入逐句时间轴。</p></div>
|
<div class="step"><div class="num">3</div><h3>解析音频</h3><p>从 <code>source.mp4</code> 提取 <code>audio.wav</code>,ASR 提取原文案,翻译成中文,并写入逐句时间轴。</p></div>
|
||||||
<div class="step"><div class="num">4</div><h3>声音分析</h3><p>用音频模型分析讲话人、口播节奏、停顿、背景音乐/环境声/音效;不默认改写配音或生成视频。</p></div>
|
<div class="step"><div class="num">4</div><h3>声音分析</h3><p>用音频模型分析讲话人、口播节奏、停顿、背景音乐/环境声/音效;不默认改写配音或生成视频。</p></div>
|
||||||
<div class="step"><div class="num">5</div><h3>分镜生成</h3><p>按逐句时间轴生成竖向分镜行,单行内从左到右承接原内容、新口播、画面规划、参考帧和候选视频。</p></div>
|
<div class="step"><div class="num">5</div><h3>分镜生成</h3><p>按逐句时间轴生成竖向分镜行,单行内只承接原内容、新口播、画面规划和候选视频;关键帧和相似主角在原视频旁全局处理。</p></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -589,7 +589,7 @@
|
|||||||
<tr><td><code>web/next.config.mjs</code></td><td>Next.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator,并移除 Next 16 已不支持的 <code>eslint</code> 顶层配置,避免本地 dev 出现配置 Issue 提示。</td></tr>
|
<tr><td><code>web/next.config.mjs</code></td><td>Next.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator,并移除 Next 16 已不支持的 <code>eslint</code> 顶层配置,避免本地 dev 出现配置 Issue 提示。</td></tr>
|
||||||
<tr><td><code>web/app/globals.css</code></td><td>全局主题变量、登录页视觉样式、ReactFlow 样式引用,以及本地开发态 <code>nextjs-portal</code> 遮挡隐藏规则。</td></tr>
|
<tr><td><code>web/app/globals.css</code></td><td>全局主题变量、登录页视觉样式、ReactFlow 样式引用,以及本地开发态 <code>nextjs-portal</code> 遮挡隐藏规则。</td></tr>
|
||||||
<tr><td><code>web/app/page.tsx</code></td><td>产品工作台主状态:jobs、activeJobId、生成任务状态;主渲染为全屏素材输入列 + 信息流广告复刻工作表;“开始”编排状态只负责在下载完成后自动触发 <code>triggerTranscribe</code>,不再默认触发抽帧、Vision 扫描或分镜初稿保存;底部吸附音频条不再从主界面渲染。</td></tr>
|
<tr><td><code>web/app/page.tsx</code></td><td>产品工作台主状态:jobs、activeJobId、生成任务状态;主渲染为全屏素材输入列 + 信息流广告复刻工作表;“开始”编排状态只负责在下载完成后自动触发 <code>triggerTranscribe</code>,不再默认触发抽帧、Vision 扫描或分镜初稿保存;底部吸附音频条不再从主界面渲染。</td></tr>
|
||||||
<tr><td><code>web/components/ad-recreation-board.tsx</code></td><td>信息流广告复刻工作表:左侧素材输入;右侧展示视频下载状态、默认折叠的音频文案依据,以及统一的音频解析结果面板;面板顶部是一行讲话人/节奏/背景音摘要,下方左侧为原视频播放器、右侧为逐句时间轴,底部横向音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点。视频播放时通过 <code>requestAnimationFrame</code> 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px,但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写,原内容和新文案列压缩为紧凑脚本列;生成本条视频时使用当前编辑后的新口播文案。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入、参考帧/关键元素和 6 个候选视频槽。单条生成会从产品素材池按分镜角色、视角优先级、用途标签、置信度和风险自动挑选最多 6 张相关产品图,不会把全部产品图提交给生视频模型,然后把产品坐标系、视角标注、方向、结构点和风险写入 Seedance 提示。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。</td></tr>
|
<tr><td><code>web/components/ad-recreation-board.tsx</code></td><td>信息流广告复刻工作表:左侧素材输入;右侧展示视频下载状态、默认折叠的音频文案依据,以及统一的音频解析结果面板;面板顶部是一行讲话人/节奏/背景音摘要,下方左侧为原视频播放器,中间为“关键帧 / 相似主角”,右侧为逐句时间轴,底部横向音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点。视频播放时通过 <code>requestAnimationFrame</code> 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。关键帧区一键按动作目标重新抽取 12 张源视频关键帧,人工勾选后调用 <code>generateSubjectAssets</code> 的 <code>source_actor + similar</code> 模式生成 6 张白底相似主角视图;这是新演员重构,不做像素提取或精确复刻源人物身份。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px,但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写,原内容和新文案列压缩为紧凑脚本列;生成本条视频时使用当前编辑后的新口播文案。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入和 6 个候选视频槽;单条生成会从全局选中关键帧或 12 张关键帧中取最贴近本句时间点的参考帧。单条生成会从产品素材池按分镜角色、视角优先级、用途标签、置信度和风险自动挑选最多 6 张相关产品图,不会把全部产品图提交给生视频模型,然后把产品坐标系、视角标注、方向、结构点和风险写入 Seedance 提示。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。</td></tr>
|
||||||
<tr><td><code>web/app/login/page.tsx</code></td><td>生产登录页:访问账号/访问密钥表单、保持登录、错误/成功状态;当前只在原版 Digital Oasis 动态背景上叠加一个组合登录框,桌面端左侧是动态角色,右侧是图标化登录表单;面板左上角展示官网 SKG 字标和中文“营销内容工作台”系统标识。</td></tr>
|
<tr><td><code>web/app/login/page.tsx</code></td><td>生产登录页:访问账号/访问密钥表单、保持登录、错误/成功状态;当前只在原版 Digital Oasis 动态背景上叠加一个组合登录框,桌面端左侧是动态角色,右侧是图标化登录表单;面板左上角展示官网 SKG 字标和中文“营销内容工作台”系统标识。</td></tr>
|
||||||
<tr><td><code>web/app/login/layout.tsx</code></td><td>登录路由专属 layout:覆盖全站默认网页标题和描述为空,避免 <code>/login</code> 继承工作台 metadata 后在页面源码里继续出现登录界面文字以外的文案。</td></tr>
|
<tr><td><code>web/app/login/layout.tsx</code></td><td>登录路由专属 layout:覆盖全站默认网页标题和描述为空,避免 <code>/login</code> 继承工作台 metadata 后在页面源码里继续出现登录界面文字以外的文案。</td></tr>
|
||||||
<tr><td><code>web/components/login/oasis-canvas.tsx</code></td><td>登录页全屏动态视觉层:用 iframe 直接承载下载包 <code>web/public/oasis-source/index.html</code> 的原 WebGPU / Three.js 草场源码;父级登录页只覆盖自己的文案和表单,并在捕获阶段把全局鼠标坐标同时用原生事件和 <code>postMessage</code> 转发给 iframe,避免登录面板或输入框遮挡时草地失去鼠标响应。</td></tr>
|
<tr><td><code>web/components/login/oasis-canvas.tsx</code></td><td>登录页全屏动态视觉层:用 iframe 直接承载下载包 <code>web/public/oasis-source/index.html</code> 的原 WebGPU / Three.js 草场源码;父级登录页只覆盖自己的文案和表单,并在捕获阶段把全局鼠标坐标同时用原生事件和 <code>postMessage</code> 转发给 iframe,避免登录面板或输入框遮挡时草地失去鼠标响应。</td></tr>
|
||||||
@@ -626,8 +626,8 @@
|
|||||||
web/app/page.tsx
|
web/app/page.tsx
|
||||||
-> 信息流广告复刻工作表:web/components/ad-recreation-board.tsx
|
-> 信息流广告复刻工作表:web/components/ad-recreation-board.tsx
|
||||||
-> 开始:创建/激活 job → 下载完成后自动触发音频处理
|
-> 开始:创建/激活 job → 下载完成后自动触发音频处理
|
||||||
-> 左侧素材输入列 + 右侧默认折叠的音频文案依据 + 统一音频解析结果面板(声音摘要在上,原视频与逐句时间轴并排,底部连续响度波形联动)
|
-> 左侧素材输入列 + 右侧默认折叠的音频文案依据 + 统一音频解析结果面板(声音摘要在上,原视频 / 12 张关键帧选择 / 相似主角 6 白底视图 / 逐句时间轴并排,底部连续响度波形联动)
|
||||||
-> 信息流复刻分镜工作台:同一产品素材池不限量上传 → 自动识别视角 / 背景 / 用途 / 风险 → 人工检查备注 → 单条生成自动挑选最多 6 张相关产品图 → 逐句时间轴 → 原内容 / 新口播文案 / 画面规划与产品融入 / 参考帧与关键元素 / 6 个候选视频槽
|
-> 信息流复刻分镜工作台:同一产品素材池不限量上传 → 自动识别视角 / 背景 / 用途 / 风险 → 人工检查备注 → 单条生成自动挑选最多 6 张相关产品图 → 逐句时间轴 → 原内容 / 新口播文案 / 画面规划与产品融入 / 6 个候选视频槽
|
||||||
-> 底部音频条:不再渲染,音频结果集中到右侧工作表
|
-> 底部音频条:不再渲染,音频结果集中到右侧工作表
|
||||||
-> 旧节点/深度素材面板:web/components/nodes/index.tsx、web/components/lightbox.tsx、web/components/storyboard-workbench.tsx(底层保留,当前不作为主入口)
|
-> 旧节点/深度素材面板:web/components/nodes/index.tsx、web/components/lightbox.tsx、web/components/storyboard-workbench.tsx(底层保留,当前不作为主入口)
|
||||||
-> API 契约:web/lib/api.ts
|
-> API 契约:web/lib/api.ts
|
||||||
@@ -649,12 +649,12 @@ api/main.py
|
|||||||
</div>
|
</div>
|
||||||
<div class="flow-row">
|
<div class="flow-row">
|
||||||
<div><strong>你看到的区域</strong><span>音频解析结果表</span></div>
|
<div><strong>你看到的区域</strong><span>音频解析结果表</span></div>
|
||||||
<div><strong>主要源码</strong><span><code>AudioIntakePanel</code> / <code>AudioIntakeStatus</code> in <code>web/components/ad-recreation-board.tsx</code>;复用 <code>triggerTranscribe</code> 和 <code>AudioScript</code>。</span></div>
|
<div><strong>主要源码</strong><span><code>AudioIntakePanel</code> / <code>AudioIntakeStatus</code> / <code>SourceReferenceBuildPanel</code> in <code>web/components/ad-recreation-board.tsx</code>;复用 <code>triggerTranscribe</code>、<code>AudioScript</code>、<code>analyzeJob</code> 和 <code>generateSubjectAssets</code>。</span></div>
|
||||||
<div><strong>适合怎么描述</strong><span>“原视频播放、连续响度波形、逐句时间轴滚动、高亮和跳转联动还需要怎么调整”。</span></div>
|
<div><strong>适合怎么描述</strong><span>“原视频播放、12 张关键帧选择、相似主角 6 白底视图、连续响度波形、逐句时间轴滚动、高亮和跳转联动还需要怎么调整”。</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flow-row">
|
<div class="flow-row">
|
||||||
<div><strong>你看到的区域</strong><span>信息流复刻分镜工作台</span></div>
|
<div><strong>你看到的区域</strong><span>信息流复刻分镜工作台</span></div>
|
||||||
<div><strong>主要源码</strong><span><code>AudioStoryboardPlanPanel</code>、<code>ProductReferenceCard</code>、<code>MissingProductViewSlot</code>、<code>buildAudioStoryboardRows</code>、<code>selectProductItemsForRow</code>、<code>buildStoryboardSceneFromAudioRow</code>、<code>StoryboardVideoSlots</code> in <code>web/components/ad-recreation-board.tsx</code>;逐行定向抽帧复用 <code>onAddManualFrameForJob</code>,产品白底图上传复用 <code>uploadStoryboardAsset</code>,视角自动识别调用 <code>analyzeProductViews</code>,缺角度自动补图调用 <code>generateProductAngleAsset</code>,单条生成复用 <code>onGenerateVideo</code> 和 <code>PUT /frames/{idx}/storyboard</code>。</span></div>
|
<div><strong>主要源码</strong><span><code>AudioStoryboardPlanPanel</code>、<code>ProductReferenceCard</code>、<code>MissingProductViewSlot</code>、<code>buildAudioStoryboardRows</code>、<code>selectProductItemsForRow</code>、<code>buildStoryboardSceneFromAudioRow</code>、<code>StoryboardVideoSlots</code> in <code>web/components/ad-recreation-board.tsx</code>;产品白底图上传复用 <code>uploadStoryboardAsset</code>,视角自动识别调用 <code>analyzeProductViews</code>,缺角度自动补图调用 <code>generateProductAngleAsset</code>,单条生成按全局关键帧池匹配当前句时间点,复用 <code>onGenerateVideo</code> 和 <code>PUT /frames/{idx}/storyboard</code>。</span></div>
|
||||||
<div><strong>适合怎么描述</strong><span>“按音频逐句生成产品分镜、每行怎样改写口播、产品素材池识别/补图后的备注是否准确、单条生成该选哪几张产品图、生成的视频应该回显到哪一行”。</span></div>
|
<div><strong>适合怎么描述</strong><span>“按音频逐句生成产品分镜、每行怎样改写口播、产品素材池识别/补图后的备注是否准确、单条生成该选哪几张产品图、生成的视频应该回显到哪一行”。</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flow-row">
|
<div class="flow-row">
|
||||||
@@ -858,7 +858,7 @@ SubjectAsset {
|
|||||||
<tr><td>创建任务</td><td><code>POST /jobs</code></td><td><code>createJob</code></td><td>提交 TK 链接,后台开始下载;前端“开始”队列会在 downloaded 后自动触发音频解析。</td></tr>
|
<tr><td>创建任务</td><td><code>POST /jobs</code></td><td><code>createJob</code></td><td>提交 TK 链接,后台开始下载;前端“开始”队列会在 downloaded 后自动触发音频解析。</td></tr>
|
||||||
<tr><td>上传视频</td><td><code>POST /jobs/upload</code></td><td><code>uploadJob</code></td><td>保存 source.mp4,然后同样进入下载完成状态;当前上传后也加入第一步队列,下载完成后自动解析音频。</td></tr>
|
<tr><td>上传视频</td><td><code>POST /jobs/upload</code></td><td><code>uploadJob</code></td><td>保存 source.mp4,然后同样进入下载完成状态;当前上传后也加入第一步队列,下载完成后自动解析音频。</td></tr>
|
||||||
<tr><td>删除输入视频</td><td><code>DELETE /jobs/{id}</code></td><td><code>deleteJob</code></td><td>从任务队列、URL 和磁盘 <code>jobs/<id></code> 目录移除整个 job,包括源视频、关键帧、元素提取图和生成视频。</td></tr>
|
<tr><td>删除输入视频</td><td><code>DELETE /jobs/{id}</code></td><td><code>deleteJob</code></td><td>从任务队列、URL 和磁盘 <code>jobs/<id></code> 目录移除整个 job,包括源视频、关键帧、元素提取图和生成视频。</td></tr>
|
||||||
<tr><td>解析视频</td><td><code>POST /jobs/{id}/analyze?frames=&target=&mode=&quality=</code></td><td><code>analyzeJob</code></td><td>后续阶段保留的抽帧能力。默认 <code>frames=12</code>;<code>target</code> 支持透明骨架人、综合、清晰主体、转场变化、表情瞬间、动作峰值。当前第一步主流程不自动调用该接口。</td></tr>
|
<tr><td>解析视频</td><td><code>POST /jobs/{id}/analyze?frames=&target=&mode=&quality=</code></td><td><code>analyzeJob</code></td><td>后续阶段保留的抽帧能力。默认 <code>frames=12</code>;<code>target</code> 支持透明骨架人、综合、清晰主体、转场变化、表情瞬间、动作峰值。当前第一步主流程不自动调用该接口;原版视频旁的“抽取 12 帧”会显式用 <code>target=motion</code>、<code>quality=accurate</code>、<code>mode=replace</code> 重新生成全局关键帧池。</td></tr>
|
||||||
<tr><td>音频文案轨</td><td><code>POST /jobs/{id}/transcribe</code></td><td><code>triggerTranscribe</code></td><td>若尚未拆轨,先从 <code>source.mp4</code> 提取 <code>audio.wav</code> 并回填 <code>source_audio_url</code>;随后用 ASR 提取原始文案,翻译成中文,写入 <code>audio_script.source_text</code>、<code>source_zh</code> 和逐句 <code>transcript</code>。远端 <code>ASR_MODEL</code> 失败后先走本机 <code>LOCAL_ASR_BIN</code>/<code>LOCAL_ASR_MODEL</code>(默认 <code>mlx_whisper</code>),再尝试 <code>ASR_FALLBACK_MODEL</code>。后端会拒绝重复文本、逐秒假字幕或覆盖率过低的结果,不再把不可听的多模态输出写进时间轴。再用 <code>ASR_FALLBACK_MODEL</code> 多模态音频分析讲话人、语速节奏、停顿、背景音乐/环境声/音效,写入 <code>speaker_profile</code>、<code>rhythm_profile</code>、<code>background_audio_profile</code>。当前第一步不默认生成 SKG 新口播和 MiniMax 配音。</td></tr>
|
<tr><td>音频文案轨</td><td><code>POST /jobs/{id}/transcribe</code></td><td><code>triggerTranscribe</code></td><td>若尚未拆轨,先从 <code>source.mp4</code> 提取 <code>audio.wav</code> 并回填 <code>source_audio_url</code>;随后用 ASR 提取原始文案,翻译成中文,写入 <code>audio_script.source_text</code>、<code>source_zh</code> 和逐句 <code>transcript</code>。远端 <code>ASR_MODEL</code> 失败后先走本机 <code>LOCAL_ASR_BIN</code>/<code>LOCAL_ASR_MODEL</code>(默认 <code>mlx_whisper</code>),再尝试 <code>ASR_FALLBACK_MODEL</code>。后端会拒绝重复文本、逐秒假字幕或覆盖率过低的结果,不再把不可听的多模态输出写进时间轴。再用 <code>ASR_FALLBACK_MODEL</code> 多模态音频分析讲话人、语速节奏、停顿、背景音乐/环境声/音效,写入 <code>speaker_profile</code>、<code>rhythm_profile</code>、<code>background_audio_profile</code>。当前第一步不默认生成 SKG 新口播和 MiniMax 配音。</td></tr>
|
||||||
<tr><td>分镜脚本改写</td><td><code>POST /jobs/{id}/script/rewrite</code></td><td><code>rewriteStoryboardScript</code></td><td>根据原参考文案、当前新口播、分镜角色、时间段和作者想法改写中文口播。<code>mode=segment</code> 只改一段;<code>mode=all</code> 一次改完整片,要求整片前后连贯。接口只返回 <code>items[index,text]</code>,前端暂存在当前页面状态里,生成本条视频时写入 <code>StoryboardScene.action</code>。</td></tr>
|
<tr><td>分镜脚本改写</td><td><code>POST /jobs/{id}/script/rewrite</code></td><td><code>rewriteStoryboardScript</code></td><td>根据原参考文案、当前新口播、分镜角色、时间段和作者想法改写中文口播。<code>mode=segment</code> 只改一段;<code>mode=all</code> 一次改完整片,要求整片前后连贯。接口只返回 <code>items[index,text]</code>,前端暂存在当前页面状态里,生成本条视频时写入 <code>StoryboardScene.action</code>。</td></tr>
|
||||||
<tr><td>原始音频文件</td><td><code>GET /jobs/{id}/audio.wav</code></td><td><code>sourceAudioUrl</code></td><td>返回拆轨得到的 wav;当前主界面不再渲染底部吸附音频条,右侧复刻工作表会读取该文件生成参考图式横向响度波形,并和原视频、逐句时间轴联动。</td></tr>
|
<tr><td>原始音频文件</td><td><code>GET /jobs/{id}/audio.wav</code></td><td><code>sourceAudioUrl</code></td><td>返回拆轨得到的 wav;当前主界面不再渲染底部吸附音频条,右侧复刻工作表会读取该文件生成参考图式横向响度波形,并和原视频、逐句时间轴联动。</td></tr>
|
||||||
@@ -870,7 +870,7 @@ SubjectAsset {
|
|||||||
<tr><td>应用清洗</td><td><code>POST /cleanup/apply</code></td><td><code>applyCleanedFrame</code></td><td>物理覆盖 frames/{idx}.jpg,并备份原图。</td></tr>
|
<tr><td>应用清洗</td><td><code>POST /cleanup/apply</code></td><td><code>applyCleanedFrame</code></td><td>物理覆盖 frames/{idx}.jpg,并备份原图。</td></tr>
|
||||||
<tr><td>元素增改删</td><td><code>POST/PATCH/DELETE /elements</code></td><td><code>addElement/updateElement/deleteElement</code></td><td>让用户修正 Vision 错误,避免候选结果锁死。</td></tr>
|
<tr><td>元素增改删</td><td><code>POST/PATCH/DELETE /elements</code></td><td><code>addElement/updateElement/deleteElement</code></td><td>让用户修正 Vision 错误,避免候选结果锁死。</td></tr>
|
||||||
<tr><td>元素提取</td><td><code>POST /elements/{element_id}/cutout</code></td><td><code>cutoutElement</code></td><td>调用图像模型生成独立白底素材图,每次累积一张 cutout。</td></tr>
|
<tr><td>元素提取</td><td><code>POST /elements/{element_id}/cutout</code></td><td><code>cutoutElement</code></td><td>调用图像模型生成独立白底素材图,每次累积一张 cutout。</td></tr>
|
||||||
<tr><td>主体资产包</td><td><code>POST /elements/{element_id}/subject-assets</code></td><td><code>generateSubjectAssets</code></td><td>根据参考帧重新绘制一个统一主体资产包;前端默认把全部关键帧作为 <code>source_frame_indices</code>,如果用户手动选择了关键帧则只传已选帧,后端拼参考板。人物默认输出六张身份标准图,另有表情补充和动作补充分组可选;纯白/黑背景,不含其他元素,并裁去空白让主体占满画面。</td></tr>
|
<tr><td>主体资产包</td><td><code>POST /elements/{element_id}/subject-assets</code></td><td><code>generateSubjectAssets</code></td><td>根据参考帧重新绘制一个统一主体资产包;前端默认把全部关键帧作为 <code>source_frame_indices</code>,如果用户手动选择了关键帧则只传已选帧,后端拼参考板。新增 <code>subject_style=source_actor</code> 与 <code>reconstruction_mode=similar</code> 用于信息流相似主角:最多读取 12 张已选关键帧,生成 6 张白底新演员视图,保留角色气质、动作词汇、机位和服装类别,但不复刻源人物身份或像素。旧透明骨架人流程仍默认走 <code>subject_style=transparent_human</code>。</td></tr>
|
||||||
<tr><td>首尾帧资产</td><td><code>POST /frames/{idx}/scene-asset</code></td><td><code>generateSceneAsset</code></td><td>同一接口兼容旧场景图和新首尾帧;新流程传 <code>asset_role=first_frame/last_frame</code>,后端走文字生图,参考帧只用于理解透明骨架人形象、比例、机位和光线,生成结果仍保存在 <code>scene_assets</code> 并自动填入产品融合镜头。</td></tr>
|
<tr><td>首尾帧资产</td><td><code>POST /frames/{idx}/scene-asset</code></td><td><code>generateSceneAsset</code></td><td>同一接口兼容旧场景图和新首尾帧;新流程传 <code>asset_role=first_frame/last_frame</code>,后端走文字生图,参考帧只用于理解透明骨架人形象、比例、机位和光线,生成结果仍保存在 <code>scene_assets</code> 并自动填入产品融合镜头。</td></tr>
|
||||||
<tr><td>产品图库</td><td><code>GET /product-library/skg</code></td><td><code>listProductLibrary</code></td><td>读取内置 SKG 白底图库 manifest,返回产品标题、品类、尺寸、白底评分和预览图 URL。</td></tr>
|
<tr><td>产品图库</td><td><code>GET /product-library/skg</code></td><td><code>listProductLibrary</code></td><td>读取内置 SKG 白底图库 manifest,返回产品标题、品类、尺寸、白底评分和预览图 URL。</td></tr>
|
||||||
<tr><td>产品图入库到 job</td><td><code>POST /jobs/{id}/assets</code>、<code>POST /jobs/{id}/assets/product-library</code></td><td><code>uploadStoryboardAsset</code>、<code>copyProductLibraryAsset</code></td><td>上传产品图或把内置产品图库条目复制为当前 job 的普通 asset。后端统一生成最长边 1600px、JPEG 92 的 AI 工作副本,透明底铺白,过大/过小图片会在 <code>ImageRef.asset_meta</code> 里返回转换动作和风险;黑底/白底背景本身不强行转换。</td></tr>
|
<tr><td>产品图入库到 job</td><td><code>POST /jobs/{id}/assets</code>、<code>POST /jobs/{id}/assets/product-library</code></td><td><code>uploadStoryboardAsset</code>、<code>copyProductLibraryAsset</code></td><td>上传产品图或把内置产品图库条目复制为当前 job 的普通 asset。后端统一生成最长边 1600px、JPEG 92 的 AI 工作副本,透明底铺白,过大/过小图片会在 <code>ImageRef.asset_meta</code> 里返回转换动作和风险;黑底/白底背景本身不强行转换。</td></tr>
|
||||||
@@ -985,6 +985,19 @@ SubjectAsset {
|
|||||||
<h2>变更记录</h2>
|
<h2>变更记录</h2>
|
||||||
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
||||||
<div class="changelog">
|
<div class="changelog">
|
||||||
|
<article class="change">
|
||||||
|
<header>
|
||||||
|
<h3>2026-05-17 · 参考帧改为原视频旁全局关键帧与相似主角</h3>
|
||||||
|
<span class="tag blue">API</span>
|
||||||
|
<span class="tag rose">UI</span>
|
||||||
|
<span class="tag cyan">Workflow</span>
|
||||||
|
</header>
|
||||||
|
<div class="body">
|
||||||
|
<p><strong>问题:</strong>分镜行里的“参考帧 / 关键元素”会把抽帧、人物参考和单条生成混在每一行里,占空间,也容易让用户误解为要逐行抽取并复刻源视频人物。</p>
|
||||||
|
<p><strong>改动:</strong><code>AudioIntakePanel</code> 在原版视频旁新增 <code>SourceReferenceBuildPanel</code>:一键重新抽取 12 张关键帧,人工选择主角参考后生成 6 张白底相似主角视图;<code>AudioStoryboardPlanPanel</code> 移除每行参考帧/关键元素列,生成视频时按全局关键帧池匹配当前句时间点。<code>generateSubjectAssets</code> 新增 <code>subject_style=source_actor</code> 与 <code>reconstruction_mode=similar</code>,用于生成类似但不复刻的正常广告演员。</p>
|
||||||
|
<p><strong>影响:</strong><code>api/main.py</code>、<code>web/lib/api.ts</code>、<code>web/components/ad-recreation-board.tsx</code>、<code>docs/source-analysis.html</code>。旧透明骨架人主体资产仍保留默认模式;信息流主流程的相似主角不走透明骨架人 prompt,也不做像素提取或精确身份复刻。</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
<article class="change">
|
<article class="change">
|
||||||
<header>
|
<header>
|
||||||
<h3>2026-05-17 · 分镜脚本支持单段和整片 AI 改写</h3>
|
<h3>2026-05-17 · 分镜脚本支持单段和整片 AI 改写</h3>
|
||||||
|
|||||||
@@ -1197,20 +1197,199 @@ function AudioIntakePanel({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SourceReferenceBuildPanel({
|
||||||
|
job,
|
||||||
|
selectedFrames,
|
||||||
|
onToggleFrame,
|
||||||
|
onJobUpdate,
|
||||||
|
}: {
|
||||||
|
job: Job
|
||||||
|
selectedFrames: Set<number>
|
||||||
|
onToggleFrame: (idx: number) => void
|
||||||
|
onJobUpdate: (job: Job) => void
|
||||||
|
}) {
|
||||||
|
const [extracting, setExtracting] = useState(false)
|
||||||
|
const [subjectBusy, setSubjectBusy] = useState(false)
|
||||||
|
const frames = useMemo(() => [...job.frames].sort((a, b) => a.timestamp - b.timestamp), [job.frames])
|
||||||
|
const selectedReferenceFrames = useMemo(
|
||||||
|
() => frames.filter((frame) => selectedFrames.has(frame.index)),
|
||||||
|
[frames, selectedFrames],
|
||||||
|
)
|
||||||
|
const actorSource = useMemo(() => {
|
||||||
|
const pool = selectedReferenceFrames.length ? selectedReferenceFrames : frames
|
||||||
|
for (const frame of pool) {
|
||||||
|
const element = frame.elements?.find(isSimilarActorElement)
|
||||||
|
if (element?.subject_assets?.length) return { frame, element }
|
||||||
|
}
|
||||||
|
for (const frame of pool) {
|
||||||
|
const element = frame.elements?.find(isSimilarActorElement)
|
||||||
|
if (element) return { frame, element }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}, [frames, selectedReferenceFrames])
|
||||||
|
const actorAssets = actorSource?.element.subject_assets ?? []
|
||||||
|
|
||||||
|
const extractKeyframes = async () => {
|
||||||
|
setExtracting(true)
|
||||||
|
try {
|
||||||
|
for (const frame of job.frames) {
|
||||||
|
if (selectedFrames.has(frame.index)) onToggleFrame(frame.index)
|
||||||
|
}
|
||||||
|
const updated = await analyzeJob(job.id, 12, "motion", "replace", "accurate")
|
||||||
|
onJobUpdate(updated)
|
||||||
|
toast.info("已开始重新抽取 12 张关键帧,完成后在这里人工选择参考。")
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("12 张关键帧抽取失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
|
} finally {
|
||||||
|
setExtracting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateSimilarActor = async () => {
|
||||||
|
if (!selectedReferenceFrames.length) {
|
||||||
|
toast.warning("请先从 12 张关键帧里选择主角参考帧。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const baseFrame = selectedReferenceFrames[0]
|
||||||
|
if (!baseFrame) return
|
||||||
|
setSubjectBusy(true)
|
||||||
|
try {
|
||||||
|
let workingJob = job
|
||||||
|
let workingFrame = workingJob.frames.find((frame) => frame.index === baseFrame.index) ?? baseFrame
|
||||||
|
let element = workingFrame.elements?.find(isSimilarActorElement)
|
||||||
|
if (!element) {
|
||||||
|
workingJob = await addElement(job.id, baseFrame.index, {
|
||||||
|
name_zh: "相似主角",
|
||||||
|
name_en: "similar ad actor",
|
||||||
|
position: "source-video main presenter selected from global keyframes",
|
||||||
|
source: "manual",
|
||||||
|
})
|
||||||
|
onJobUpdate(workingJob)
|
||||||
|
workingFrame = workingJob.frames.find((frame) => frame.index === baseFrame.index) ?? workingFrame
|
||||||
|
element = workingFrame.elements?.find(isSimilarActorElement)
|
||||||
|
?? workingFrame.elements?.[workingFrame.elements.length - 1]
|
||||||
|
}
|
||||||
|
if (!element) throw new Error("similar actor element missing")
|
||||||
|
|
||||||
|
const updated = await generateSubjectAssets(job.id, baseFrame.index, element.id, {
|
||||||
|
subject_kind: "living",
|
||||||
|
subject_style: "source_actor",
|
||||||
|
reconstruction_mode: "similar",
|
||||||
|
background: "white",
|
||||||
|
size: "1024",
|
||||||
|
source_frame_indices: selectedReferenceFrames.slice(0, 12).map((frame) => frame.index),
|
||||||
|
views: ["front", "back", "left", "right", "three_quarter_left", "three_quarter_right"],
|
||||||
|
prompt: "Create a new similar information-feed ad presenter, not a replica of the source person. Keep the creator-ad energy, pose vocabulary, wardrobe category, shot readability, and commercial realism. White background six-view reference sheet output, consistent actor identity across views.",
|
||||||
|
})
|
||||||
|
onJobUpdate(updated)
|
||||||
|
toast.success("相似主角 6 张白底视图已生成")
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("相似主角重构失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
|
} finally {
|
||||||
|
setSubjectBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
|
<SectionTitle icon={<ImageIcon className="h-4 w-4" />} title="关键帧 / 相似主角" />
|
||||||
|
<span className="rounded-md border border-white/10 bg-black/35 px-2 py-1 text-[11px] text-white/45">
|
||||||
|
{frames.length ? `${frames.length} 张` : "待抽帧"} · 已选 {selectedReferenceFrames.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-[238px] overflow-y-auto rounded-md border border-white/10 bg-black/32 p-2">
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void extractKeyframes()}
|
||||||
|
disabled={!job.video_url || extracting || job.status === "splitting"}
|
||||||
|
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.055] px-2 text-[10.5px] font-semibold text-white/66 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-35"
|
||||||
|
>
|
||||||
|
{extracting || job.status === "splitting" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Scissors className="h-3.5 w-3.5" />}
|
||||||
|
抽取 12 帧
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void generateSimilarActor()}
|
||||||
|
disabled={!selectedReferenceFrames.length || subjectBusy}
|
||||||
|
className="inline-flex h-7 items-center justify-center gap-1 rounded-md bg-white px-2 text-[10.5px] font-semibold text-black transition hover:bg-white/90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{subjectBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
|
||||||
|
生成 6 视图
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 grid grid-cols-4 gap-1.5">
|
||||||
|
{frames.slice(0, 12).map((frame, index) => {
|
||||||
|
const selected = selectedFrames.has(frame.index)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={frame.index}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleFrame(frame.index)}
|
||||||
|
className={`group relative h-12 overflow-hidden rounded border bg-black transition ${
|
||||||
|
selected ? "border-emerald-300/70" : "border-white/10 hover:border-cyan-300/40"
|
||||||
|
}`}
|
||||||
|
title={`关键帧 ${index + 1} · ${frame.timestamp.toFixed(1)}s`}
|
||||||
|
>
|
||||||
|
<img src={effectiveFrameUrl(job.id, frame)} alt="" className="h-full w-full object-cover" />
|
||||||
|
<span className="absolute left-1 top-1 rounded bg-black/72 px-1 font-mono text-[9px] text-white/70">{String(index + 1).padStart(2, "0")}</span>
|
||||||
|
<span className="absolute right-1 top-1 rounded-full bg-black/72 p-0.5">
|
||||||
|
{selected ? <Check className="h-3 w-3 text-emerald-200" /> : <Circle className="h-3 w-3 text-white/50" />}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{!frames.length && (
|
||||||
|
<div className="col-span-4 flex h-[106px] items-center justify-center rounded border border-dashed border-white/12 text-[11px] text-white/34">
|
||||||
|
点击“抽取 12 帧”后,这里会展示原视频关键画面。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 border-t border-white/8 pt-2">
|
||||||
|
<div className="mb-1.5 flex items-center justify-between text-[10px] text-white/36">
|
||||||
|
<span>相似主角白底视图</span>
|
||||||
|
<span>{actorAssets.length}/6</span>
|
||||||
|
</div>
|
||||||
|
{actorAssets.length ? (
|
||||||
|
<div className="grid grid-cols-6 gap-1.5">
|
||||||
|
{actorAssets.slice(-6).map((asset) => (
|
||||||
|
<a
|
||||||
|
key={asset.id}
|
||||||
|
href={subjectAssetUrl(job, asset)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="h-10 overflow-hidden rounded border border-white/10 bg-white"
|
||||||
|
title={asset.label || asset.view}
|
||||||
|
>
|
||||||
|
<img src={subjectAssetUrl(job, asset)} alt={asset.label || asset.view} className="h-full w-full object-contain" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded border border-dashed border-white/12 px-2 py-2 text-[10.5px] leading-snug text-white/32">
|
||||||
|
选择能代表主角状态的关键帧后,用图像模型生成“类似但不复刻”的 6 张白底视图。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function AudioStoryboardPlanPanel({
|
function AudioStoryboardPlanPanel({
|
||||||
job,
|
job,
|
||||||
onAddFrame,
|
selectedFrames,
|
||||||
onOpenFrame,
|
|
||||||
onJobUpdate,
|
onJobUpdate,
|
||||||
onGenerateVideo,
|
onGenerateVideo,
|
||||||
}: {
|
}: {
|
||||||
job: Job | null
|
job: Job | null
|
||||||
onAddFrame?: (jobId: string, t: number) => Promise<void> | void
|
selectedFrames: Set<number>
|
||||||
onOpenFrame?: (idx: number) => void
|
|
||||||
onJobUpdate?: (job: Job) => void
|
onJobUpdate?: (job: Job) => void
|
||||||
onGenerateVideo?: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
|
onGenerateVideo?: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
|
||||||
}) {
|
}) {
|
||||||
const [busyRow, setBusyRow] = useState<number | null>(null)
|
|
||||||
const [videoBusyRow, setVideoBusyRow] = useState<number | null>(null)
|
const [videoBusyRow, setVideoBusyRow] = useState<number | null>(null)
|
||||||
const [productItems, setProductItems] = useState<ProductRefItem[]>([])
|
const [productItems, setProductItems] = useState<ProductRefItem[]>([])
|
||||||
const [productUploading, setProductUploading] = useState(false)
|
const [productUploading, setProductUploading] = useState(false)
|
||||||
@@ -1222,6 +1401,11 @@ function AudioStoryboardPlanPanel({
|
|||||||
const productFileRef = useRef<HTMLInputElement | null>(null)
|
const productFileRef = useRef<HTMLInputElement | null>(null)
|
||||||
const rows = useMemo(() => buildAudioStoryboardRows(job), [job])
|
const rows = useMemo(() => buildAudioStoryboardRows(job), [job])
|
||||||
const orderedFrames = useMemo(() => job ? [...job.frames].sort((a, b) => a.timestamp - b.timestamp) : [], [job])
|
const orderedFrames = useMemo(() => job ? [...job.frames].sort((a, b) => a.timestamp - b.timestamp) : [], [job])
|
||||||
|
const selectedReferenceFrames = useMemo(
|
||||||
|
() => orderedFrames.filter((frame) => selectedFrames.has(frame.index)),
|
||||||
|
[orderedFrames, selectedFrames],
|
||||||
|
)
|
||||||
|
const rowReferencePool = selectedReferenceFrames.length ? selectedReferenceFrames : orderedFrames
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setProductItems([])
|
setProductItems([])
|
||||||
@@ -1245,23 +1429,12 @@ function AudioStoryboardPlanPanel({
|
|||||||
current_text: copyForRow(row),
|
current_text: copyForRow(row),
|
||||||
})
|
})
|
||||||
|
|
||||||
const framesForRow = (row: AudioStoryboardRow) =>
|
const referenceFrameForRow = (row: AudioStoryboardRow) =>
|
||||||
orderedFrames.filter((frame) => frame.timestamp >= row.start - 0.2 && frame.timestamp <= row.end + 0.2).slice(0, 3)
|
closestFrameForTime(rowReferencePool, clampNumber((row.start + row.end) / 2, 0, Math.max(job?.duration || row.end, row.end)))
|
||||||
|
|
||||||
const videosForRow = (refs: KeyFrame[]) => {
|
const videosForFrame = (frame: KeyFrame | null) => {
|
||||||
const refIndices = new Set(refs.map((frame) => frame.index))
|
if (!frame) return []
|
||||||
return (job?.generated_videos ?? []).filter((video) => refIndices.has(video.frame_idx))
|
return (job?.generated_videos ?? []).filter((video) => video.frame_idx === frame.index)
|
||||||
}
|
|
||||||
|
|
||||||
const addReferenceFrame = async (row: AudioStoryboardRow) => {
|
|
||||||
if (!job || !onAddFrame) return
|
|
||||||
const t = clampNumber((row.start + row.end) / 2, 0, Math.max(job.duration || row.end, row.end))
|
|
||||||
setBusyRow(row.index)
|
|
||||||
try {
|
|
||||||
await onAddFrame(job.id, t)
|
|
||||||
} finally {
|
|
||||||
setBusyRow(null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemSourceForRef = (ref: ImageRef) => productItems.find((item) => sameImageRef(item.ref, ref))?.source ?? "upload"
|
const itemSourceForRef = (ref: ImageRef) => productItems.find((item) => sameImageRef(item.ref, ref))?.source ?? "upload"
|
||||||
@@ -1459,9 +1632,8 @@ function AudioStoryboardPlanPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateRowVideo = async (row: AudioStoryboardRow, refs: KeyFrame[]) => {
|
const generateRowVideo = async (row: AudioStoryboardRow, frame: KeyFrame | null) => {
|
||||||
if (!job || !refs.length || !onGenerateVideo) return
|
if (!job || !frame || !onGenerateVideo) return
|
||||||
const frame = refs[0]
|
|
||||||
const nextFrame = orderedFrames.find((item) => item.timestamp > frame.timestamp) ?? null
|
const nextFrame = orderedFrames.find((item) => item.timestamp > frame.timestamp) ?? null
|
||||||
const scene = buildStoryboardSceneFromAudioRow({ ...row, skgCopy: copyForRow(row) }, frame, nextFrame, productItems)
|
const scene = buildStoryboardSceneFromAudioRow({ ...row, skgCopy: copyForRow(row) }, frame, nextFrame, productItems)
|
||||||
setVideoBusyRow(row.index)
|
setVideoBusyRow(row.index)
|
||||||
@@ -1483,7 +1655,7 @@ function AudioStoryboardPlanPanel({
|
|||||||
<div className="mb-2 flex items-start justify-between gap-3">
|
<div className="mb-2 flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<SectionTitle icon={<Sparkles className="h-4 w-4" />} title="信息流复刻分镜工作台" />
|
<SectionTitle icon={<Sparkles className="h-4 w-4" />} title="信息流复刻分镜工作台" />
|
||||||
<p className="mt-1 text-[11px] leading-snug text-white/42">每条分镜纵向排列;行内从左到右完成原内容、新文案、画面/产品、参考帧和生成视频。</p>
|
<p className="mt-1 text-[11px] leading-snug text-white/42">每条分镜纵向排列;行内完成原内容、新文案、画面/产品和视频候选。关键帧选择与相似主角重构在原版视频旁统一处理。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid shrink-0 grid-cols-3 gap-2 text-[11px] text-white/45">
|
<div className="grid shrink-0 grid-cols-3 gap-2 text-[11px] text-white/45">
|
||||||
<Requirement label="分镜" ready={rows.length > 0} detail={rows.length ? `${rows.length} 条` : "待音频"} />
|
<Requirement label="分镜" ready={rows.length > 0} detail={rows.length ? `${rows.length} 条` : "待音频"} />
|
||||||
@@ -1596,15 +1768,14 @@ function AudioStoryboardPlanPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="max-h-[560px] space-y-2 overflow-y-auto pr-1">
|
<div className="max-h-[560px] space-y-2 overflow-y-auto pr-1">
|
||||||
{rows.map((row) => {
|
{rows.map((row) => {
|
||||||
const refs = framesForRow(row)
|
const referenceFrame = referenceFrameForRow(row)
|
||||||
const rowVideos = videosForRow(refs)
|
const rowVideos = videosForFrame(referenceFrame)
|
||||||
const busy = busyRow === row.index
|
|
||||||
const generating = videoBusyRow === row.index
|
const generating = videoBusyRow === row.index
|
||||||
const copyText = copyForRow(row)
|
const copyText = copyForRow(row)
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
key={row.index}
|
key={row.index}
|
||||||
className="grid overflow-hidden rounded-md border border-white/10 bg-black/24 text-[11px] leading-snug text-white/64 xl:grid-cols-[64px_minmax(96px,0.5fr)_minmax(132px,0.68fr)_minmax(176px,1fr)_minmax(128px,0.72fr)_230px]"
|
className="grid overflow-hidden rounded-md border border-white/10 bg-black/24 text-[11px] leading-snug text-white/64 xl:grid-cols-[64px_minmax(104px,0.54fr)_minmax(148px,0.72fr)_minmax(220px,1fr)_270px]"
|
||||||
>
|
>
|
||||||
<StoryboardPlanCell label="分镜">
|
<StoryboardPlanCell label="分镜">
|
||||||
<div className="font-mono text-[11px] text-white/40">{row.start.toFixed(1)}-{row.end.toFixed(1)}s</div>
|
<div className="font-mono text-[11px] text-white/40">{row.start.toFixed(1)}-{row.end.toFixed(1)}s</div>
|
||||||
@@ -1642,45 +1813,15 @@ function AudioStoryboardPlanPanel({
|
|||||||
</p>
|
</p>
|
||||||
</StoryboardPlanCell>
|
</StoryboardPlanCell>
|
||||||
|
|
||||||
<StoryboardPlanCell label="参考帧 / 关键元素">
|
<StoryboardPlanCell label="生成视频" className="xl:border-r-0">
|
||||||
{refs.length ? (
|
<StoryboardVideoSlots job={job} videos={rowVideos} enabled={!!referenceFrame} />
|
||||||
<div className="mb-1.5 flex gap-1.5 overflow-x-auto pb-1">
|
<div className="mt-1 truncate text-[10px] text-white/34" title={referenceFrame ? `参考 ${referenceFrame.timestamp.toFixed(1)}s` : row.referencePlan}>
|
||||||
{refs.map((frame) => (
|
{referenceFrame ? `参考 ${referenceFrame.timestamp.toFixed(1)}s · 可多次生成候选` : "先在原版视频旁抽取 12 帧"}
|
||||||
<button
|
|
||||||
key={frame.index}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onOpenFrame?.(frame.index)}
|
|
||||||
className="h-14 w-9 shrink-0 overflow-hidden rounded border border-white/10 bg-black/45 transition hover:border-cyan-300/40"
|
|
||||||
title={`参考帧 ${frame.timestamp.toFixed(1)}s`}
|
|
||||||
>
|
|
||||||
<img src={effectiveFrameUrl(job.id, frame)} alt="" className="h-full w-full object-cover" />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="mb-2 line-clamp-2 text-white/34" title={row.referencePlan}>{row.referencePlan}</p>
|
|
||||||
)}
|
|
||||||
<div className="line-clamp-2 text-[10px] text-white/38" title={row.keyElements}>
|
|
||||||
<ImageIcon className="mr-1 inline h-3 w-3" />
|
|
||||||
{row.keyElements}
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => addReferenceFrame(row)}
|
onClick={() => generateRowVideo(row, referenceFrame)}
|
||||||
disabled={!onAddFrame || busy}
|
disabled={!referenceFrame || !onGenerateVideo || generating}
|
||||||
className="mt-1.5 inline-flex h-7 w-full items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.055] px-2 text-[11px] text-white/70 transition hover:border-white/25 hover:bg-white/[0.1] disabled:cursor-not-allowed disabled:opacity-45"
|
|
||||||
>
|
|
||||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Scissors className="h-3.5 w-3.5" />}
|
|
||||||
{refs.length ? "补抽参考帧" : "抽参考帧"}
|
|
||||||
</button>
|
|
||||||
</StoryboardPlanCell>
|
|
||||||
|
|
||||||
<StoryboardPlanCell label="生成视频" className="xl:border-r-0">
|
|
||||||
<StoryboardVideoSlots job={job} videos={rowVideos} enabled={refs.length > 0} />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => generateRowVideo(row, refs)}
|
|
||||||
disabled={!refs.length || !onGenerateVideo || generating}
|
|
||||||
className="mt-1.5 inline-flex h-8 w-full items-center justify-center gap-1 rounded-md bg-white px-2 text-[11px] font-semibold text-black transition hover:bg-white/90 disabled:cursor-not-allowed disabled:opacity-40"
|
className="mt-1.5 inline-flex h-8 w-full items-center justify-center gap-1 rounded-md bg-white px-2 text-[11px] font-semibold text-black transition hover:bg-white/90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{generating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
|
{generating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
|
||||||
@@ -1693,7 +1834,7 @@ function AudioStoryboardPlanPanel({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState text="音频解析完成后,这里会按逐句时间轴生成信息流复刻分镜工作台。先看结构,再按分镜定向抽参考帧和生成视频。" />
|
<EmptyState text="音频解析完成后,这里会按逐句时间轴生成信息流复刻分镜工作台。先在原版视频旁抽取 12 张关键帧并选择主角参考,再按分镜生成视频候选。" />
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
@@ -1862,7 +2003,7 @@ function StoryboardVideoSlots({ job, videos, enabled }: { job: Job; videos: Gene
|
|||||||
))}
|
))}
|
||||||
{Array.from({ length: emptyCount }).map((_, index) => (
|
{Array.from({ length: emptyCount }).map((_, index) => (
|
||||||
<div key={`empty-video-${index}`} className="flex h-[74px] min-w-0 items-center justify-center rounded border border-dashed border-white/12 bg-black/25 px-1 text-center text-[10px] leading-tight text-white/26">
|
<div key={`empty-video-${index}`} className="flex h-[74px] min-w-0 items-center justify-center rounded border border-dashed border-white/12 bg-black/25 px-1 text-center text-[10px] leading-tight text-white/26">
|
||||||
{enabled ? `候选 ${visible.length + index + 1}` : "先抽参考帧"}
|
{enabled ? `候选 ${visible.length + index + 1}` : "先抽 12 帧"}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user