From ab2d0a8978ea64c1ea1b6b94fba2760d4c6112bd Mon Sep 17 00:00:00 2001 From: kang Date: Sun, 17 May 2026 21:14:42 +0800 Subject: [PATCH] auto-save 2026-05-17 21:14 (~3) --- .memory/worklog.json | 14 +- docs/source-analysis.html | 33 ++- web/components/ad-recreation-board.tsx | 275 +++++++++++++++++++------ 3 files changed, 238 insertions(+), 84 deletions(-) diff --git a/.memory/worklog.json b/.memory/worklog.json index c1e9993..d7f5733 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,12 +1,5 @@ { "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, "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 12:53 (~1)", @@ -3261,6 +3254,13 @@ "type": "session-heartbeat", "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 3 项未提交变更 · 最近提交:feat: add storyboard script rewriting", "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 } ] } diff --git a/docs/source-analysis.html b/docs/source-analysis.html index a36ed68..0477207 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -569,13 +569,13 @@

业务管线

-

当前产品方向已收窄为“信息流广告快速复刻”:主界面左侧是素材输入列,右侧先完成音频解析,再进入信息流复刻分镜工作台。用户粘贴 TK 链接或上传视频后点击“开始”,系统自动下载源视频;下载完成后优先提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效。分镜规划按逐句时间轴生成,抽帧和视频生成由用户按单条分镜触发,不在当前开始流程里自动全量运行。

+

当前产品方向已收窄为“信息流广告快速复刻”:主界面左侧是素材输入列,右侧先完成音频解析,再进入信息流复刻分镜工作台。用户粘贴 TK 链接或上传视频后点击“开始”,系统自动下载源视频;下载完成后优先提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效。分镜规划按逐句时间轴生成;视觉参考改为在原版视频旁统一抽取 12 张关键帧,由人工选择后生成“类似但不复刻”的相似主角 6 张白底视图,再按分镜生成视频候选。

1

导入素材

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

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信息流广告复刻工作表:左侧素材输入;右侧展示视频下载状态、默认折叠的音频文案依据,以及统一的音频解析结果面板;面板顶部是一行讲话人/节奏/背景音摘要,下方左侧为原视频播放器、右侧为逐句时间轴,底部横向音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px,但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写,原内容和新文案列压缩为紧凑脚本列;生成本条视频时使用当前编辑后的新口播文案。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入、参考帧/关键元素和 6 个候选视频槽。单条生成会从产品素材池按分镜角色、视角优先级、用途标签、置信度和风险自动挑选最多 6 张相关产品图,不会把全部产品图提交给生视频模型,然后把产品坐标系、视角标注、方向、结构点和风险写入 Seedance 提示。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 + web/components/ad-recreation-board.tsx信息流广告复刻工作表:左侧素材输入;右侧展示视频下载状态、默认折叠的音频文案依据,以及统一的音频解析结果面板;面板顶部是一行讲话人/节奏/背景音摘要,下方左侧为原视频播放器,中间为“关键帧 / 相似主角”,右侧为逐句时间轴,底部横向音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。关键帧区一键按动作目标重新抽取 12 张源视频关键帧,人工勾选后调用 generateSubjectAssetssource_actor + similar 模式生成 6 张白底相似主角视图;这是新演员重构,不做像素提取或精确复刻源人物身份。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px,但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写,原内容和新文案列压缩为紧凑脚本列;生成本条视频时使用当前编辑后的新口播文案。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入和 6 个候选视频槽;单条生成会从全局选中关键帧或 12 张关键帧中取最贴近本句时间点的参考帧。单条生成会从产品素材池按分镜角色、视角优先级、用途标签、置信度和风险自动挑选最多 6 张相关产品图,不会把全部产品图提交给生视频模型,然后把产品坐标系、视角标注、方向、结构点和风险写入 Seedance 提示。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 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,8 +626,8 @@ web/app/page.tsx -> 信息流广告复刻工作表:web/components/ad-recreation-board.tsx -> 开始:创建/激活 job → 下载完成后自动触发音频处理 - -> 左侧素材输入列 + 右侧默认折叠的音频文案依据 + 统一音频解析结果面板(声音摘要在上,原视频与逐句时间轴并排,底部连续响度波形联动) - -> 信息流复刻分镜工作台:同一产品素材池不限量上传 → 自动识别视角 / 背景 / 用途 / 风险 → 人工检查备注 → 单条生成自动挑选最多 6 张相关产品图 → 逐句时间轴 → 原内容 / 新口播文案 / 画面规划与产品融入 / 参考帧与关键元素 / 6 个候选视频槽 + -> 左侧素材输入列 + 右侧默认折叠的音频文案依据 + 统一音频解析结果面板(声音摘要在上,原视频 / 12 张关键帧选择 / 相似主角 6 白底视图 / 逐句时间轴并排,底部连续响度波形联动) + -> 信息流复刻分镜工作台:同一产品素材池不限量上传 → 自动识别视角 / 背景 / 用途 / 风险 → 人工检查备注 → 单条生成自动挑选最多 6 张相关产品图 → 逐句时间轴 → 原内容 / 新口播文案 / 画面规划与产品融入 / 6 个候选视频槽 -> 底部音频条:不再渲染,音频结果集中到右侧工作表 -> 旧节点/深度素材面板:web/components/nodes/index.tsx、web/components/lightbox.tsx、web/components/storyboard-workbench.tsx(底层保留,当前不作为主入口) -> API 契约:web/lib/api.ts @@ -649,12 +649,12 @@ api/main.py
你看到的区域音频解析结果表
-
主要源码AudioIntakePanel / AudioIntakeStatus in web/components/ad-recreation-board.tsx;复用 triggerTranscribeAudioScript
-
适合怎么描述“原视频播放、连续响度波形、逐句时间轴滚动、高亮和跳转联动还需要怎么调整”。
+
主要源码AudioIntakePanel / AudioIntakeStatus / SourceReferenceBuildPanel in web/components/ad-recreation-board.tsx;复用 triggerTranscribeAudioScriptanalyzeJobgenerateSubjectAssets
+
适合怎么描述“原视频播放、12 张关键帧选择、相似主角 6 白底视图、连续响度波形、逐句时间轴滚动、高亮和跳转联动还需要怎么调整”。
你看到的区域信息流复刻分镜工作台
-
主要源码AudioStoryboardPlanPanelProductReferenceCardMissingProductViewSlotbuildAudioStoryboardRowsselectProductItemsForRowbuildStoryboardSceneFromAudioRowStoryboardVideoSlots in web/components/ad-recreation-board.tsx;逐行定向抽帧复用 onAddManualFrameForJob,产品白底图上传复用 uploadStoryboardAsset,视角自动识别调用 analyzeProductViews,缺角度自动补图调用 generateProductAngleAsset,单条生成复用 onGenerateVideoPUT /frames/{idx}/storyboard
+
主要源码AudioStoryboardPlanPanelProductReferenceCardMissingProductViewSlotbuildAudioStoryboardRowsselectProductItemsForRowbuildStoryboardSceneFromAudioRowStoryboardVideoSlots in web/components/ad-recreation-board.tsx;产品白底图上传复用 uploadStoryboardAsset,视角自动识别调用 analyzeProductViews,缺角度自动补图调用 generateProductAngleAsset,单条生成按全局关键帧池匹配当前句时间点,复用 onGenerateVideoPUT /frames/{idx}/storyboard
适合怎么描述“按音频逐句生成产品分镜、每行怎样改写口播、产品素材池识别/补图后的备注是否准确、单条生成该选哪几张产品图、生成的视频应该回显到哪一行”。
@@ -858,7 +858,7 @@ SubjectAsset { 创建任务POST /jobscreateJob提交 TK 链接,后台开始下载;前端“开始”队列会在 downloaded 后自动触发音频解析。 上传视频POST /jobs/uploaduploadJob保存 source.mp4,然后同样进入下载完成状态;当前上传后也加入第一步队列,下载完成后自动解析音频。 删除输入视频DELETE /jobs/{id}deleteJob从任务队列、URL 和磁盘 jobs/<id> 目录移除整个 job,包括源视频、关键帧、元素提取图和生成视频。 - 解析视频POST /jobs/{id}/analyze?frames=&target=&mode=&quality=analyzeJob后续阶段保留的抽帧能力。默认 frames=12target 支持透明骨架人、综合、清晰主体、转场变化、表情瞬间、动作峰值。当前第一步主流程不自动调用该接口。 + 解析视频POST /jobs/{id}/analyze?frames=&target=&mode=&quality=analyzeJob后续阶段保留的抽帧能力。默认 frames=12target 支持透明骨架人、综合、清晰主体、转场变化、表情瞬间、动作峰值。当前第一步主流程不自动调用该接口;原版视频旁的“抽取 12 帧”会显式用 target=motionquality=accuratemode=replace 重新生成全局关键帧池。 音频文案轨POST /jobs/{id}/transcribetriggerTranscribe若尚未拆轨,先从 source.mp4 提取 audio.wav 并回填 source_audio_url;随后用 ASR 提取原始文案,翻译成中文,写入 audio_script.source_textsource_zh 和逐句 transcript。远端 ASR_MODEL 失败后先走本机 LOCAL_ASR_BIN/LOCAL_ASR_MODEL(默认 mlx_whisper),再尝试 ASR_FALLBACK_MODEL。后端会拒绝重复文本、逐秒假字幕或覆盖率过低的结果,不再把不可听的多模态输出写进时间轴。再用 ASR_FALLBACK_MODEL 多模态音频分析讲话人、语速节奏、停顿、背景音乐/环境声/音效,写入 speaker_profilerhythm_profilebackground_audio_profile。当前第一步不默认生成 SKG 新口播和 MiniMax 配音。 分镜脚本改写POST /jobs/{id}/script/rewriterewriteStoryboardScript根据原参考文案、当前新口播、分镜角色、时间段和作者想法改写中文口播。mode=segment 只改一段;mode=all 一次改完整片,要求整片前后连贯。接口只返回 items[index,text],前端暂存在当前页面状态里,生成本条视频时写入 StoryboardScene.action。 原始音频文件GET /jobs/{id}/audio.wavsourceAudioUrl返回拆轨得到的 wav;当前主界面不再渲染底部吸附音频条,右侧复刻工作表会读取该文件生成参考图式横向响度波形,并和原视频、逐句时间轴联动。 @@ -870,7 +870,7 @@ SubjectAsset { 应用清洗POST /cleanup/applyapplyCleanedFrame物理覆盖 frames/{idx}.jpg,并备份原图。 元素增改删POST/PATCH/DELETE /elementsaddElement/updateElement/deleteElement让用户修正 Vision 错误,避免候选结果锁死。 元素提取POST /elements/{element_id}/cutoutcutoutElement调用图像模型生成独立白底素材图,每次累积一张 cutout。 - 主体资产包POST /elements/{element_id}/subject-assetsgenerateSubjectAssets根据参考帧重新绘制一个统一主体资产包;前端默认把全部关键帧作为 source_frame_indices,如果用户手动选择了关键帧则只传已选帧,后端拼参考板。人物默认输出六张身份标准图,另有表情补充和动作补充分组可选;纯白/黑背景,不含其他元素,并裁去空白让主体占满画面。 + 主体资产包POST /elements/{element_id}/subject-assetsgenerateSubjectAssets根据参考帧重新绘制一个统一主体资产包;前端默认把全部关键帧作为 source_frame_indices,如果用户手动选择了关键帧则只传已选帧,后端拼参考板。新增 subject_style=source_actorreconstruction_mode=similar 用于信息流相似主角:最多读取 12 张已选关键帧,生成 6 张白底新演员视图,保留角色气质、动作词汇、机位和服装类别,但不复刻源人物身份或像素。旧透明骨架人流程仍默认走 subject_style=transparent_human。 首尾帧资产POST /frames/{idx}/scene-assetgenerateSceneAsset同一接口兼容旧场景图和新首尾帧;新流程传 asset_role=first_frame/last_frame,后端走文字生图,参考帧只用于理解透明骨架人形象、比例、机位和光线,生成结果仍保存在 scene_assets 并自动填入产品融合镜头。 产品图库GET /product-library/skglistProductLibrary读取内置 SKG 白底图库 manifest,返回产品标题、品类、尺寸、白底评分和预览图 URL。 产品图入库到 jobPOST /jobs/{id}/assetsPOST /jobs/{id}/assets/product-libraryuploadStoryboardAssetcopyProductLibraryAsset上传产品图或把内置产品图库条目复制为当前 job 的普通 asset。后端统一生成最长边 1600px、JPEG 92 的 AI 工作副本,透明底铺白,过大/过小图片会在 ImageRef.asset_meta 里返回转换动作和风险;黑底/白底背景本身不强行转换。 @@ -985,6 +985,19 @@ SubjectAsset {

变更记录

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

+
+
+

2026-05-17 · 参考帧改为原视频旁全局关键帧与相似主角

+ API + UI + Workflow +
+
+

问题:分镜行里的“参考帧 / 关键元素”会把抽帧、人物参考和单条生成混在每一行里,占空间,也容易让用户误解为要逐行抽取并复刻源视频人物。

+

改动:AudioIntakePanel 在原版视频旁新增 SourceReferenceBuildPanel:一键重新抽取 12 张关键帧,人工选择主角参考后生成 6 张白底相似主角视图;AudioStoryboardPlanPanel 移除每行参考帧/关键元素列,生成视频时按全局关键帧池匹配当前句时间点。generateSubjectAssets 新增 subject_style=source_actorreconstruction_mode=similar,用于生成类似但不复刻的正常广告演员。

+

影响:api/main.pyweb/lib/api.tsweb/components/ad-recreation-board.tsxdocs/source-analysis.html。旧透明骨架人主体资产仍保留默认模式;信息流主流程的相似主角不走透明骨架人 prompt,也不做像素提取或精确身份复刻。

+
+

2026-05-17 · 分镜脚本支持单段和整片 AI 改写

diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx index 91dbe26..f8434ae 100644 --- a/web/components/ad-recreation-board.tsx +++ b/web/components/ad-recreation-board.tsx @@ -1197,20 +1197,199 @@ function AudioIntakePanel({ ) } +function SourceReferenceBuildPanel({ + job, + selectedFrames, + onToggleFrame, + onJobUpdate, +}: { + job: Job + selectedFrames: Set + 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 ( +
+
+ } title="关键帧 / 相似主角" /> + + {frames.length ? `${frames.length} 张` : "待抽帧"} · 已选 {selectedReferenceFrames.length} + +
+
+
+ + +
+ +
+ {frames.slice(0, 12).map((frame, index) => { + const selected = selectedFrames.has(frame.index) + return ( + + ) + })} + {!frames.length && ( +
+ 点击“抽取 12 帧”后,这里会展示原视频关键画面。 +
+ )} +
+ +
+
+ 相似主角白底视图 + {actorAssets.length}/6 +
+ {actorAssets.length ? ( +
+ {actorAssets.slice(-6).map((asset) => ( + + {asset.label + + ))} +
+ ) : ( +
+ 选择能代表主角状态的关键帧后,用图像模型生成“类似但不复刻”的 6 张白底视图。 +
+ )} +
+
+
+ ) +} + function AudioStoryboardPlanPanel({ job, - onAddFrame, - onOpenFrame, + selectedFrames, onJobUpdate, onGenerateVideo, }: { job: Job | null - onAddFrame?: (jobId: string, t: number) => Promise | void - onOpenFrame?: (idx: number) => void + selectedFrames: Set onJobUpdate?: (job: Job) => void onGenerateVideo?: (frameIdx: number, scene: StoryboardScene, model: string) => Promise | void }) { - const [busyRow, setBusyRow] = useState(null) const [videoBusyRow, setVideoBusyRow] = useState(null) const [productItems, setProductItems] = useState([]) const [productUploading, setProductUploading] = useState(false) @@ -1222,6 +1401,11 @@ function AudioStoryboardPlanPanel({ const productFileRef = useRef(null) const rows = useMemo(() => buildAudioStoryboardRows(job), [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(() => { setProductItems([]) @@ -1245,23 +1429,12 @@ function AudioStoryboardPlanPanel({ current_text: copyForRow(row), }) - const framesForRow = (row: AudioStoryboardRow) => - orderedFrames.filter((frame) => frame.timestamp >= row.start - 0.2 && frame.timestamp <= row.end + 0.2).slice(0, 3) + const referenceFrameForRow = (row: AudioStoryboardRow) => + closestFrameForTime(rowReferencePool, clampNumber((row.start + row.end) / 2, 0, Math.max(job?.duration || row.end, row.end))) - const videosForRow = (refs: KeyFrame[]) => { - const refIndices = new Set(refs.map((frame) => frame.index)) - return (job?.generated_videos ?? []).filter((video) => refIndices.has(video.frame_idx)) - } - - 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 videosForFrame = (frame: KeyFrame | null) => { + if (!frame) return [] + return (job?.generated_videos ?? []).filter((video) => video.frame_idx === frame.index) } 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[]) => { - if (!job || !refs.length || !onGenerateVideo) return - const frame = refs[0] + 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) setVideoBusyRow(row.index) @@ -1483,7 +1655,7 @@ function AudioStoryboardPlanPanel({
} title="信息流复刻分镜工作台" /> -

每条分镜纵向排列;行内从左到右完成原内容、新文案、画面/产品、参考帧和生成视频。

+

每条分镜纵向排列;行内完成原内容、新文案、画面/产品和视频候选。关键帧选择与相似主角重构在原版视频旁统一处理。

0} detail={rows.length ? `${rows.length} 条` : "待音频"} /> @@ -1596,15 +1768,14 @@ function AudioStoryboardPlanPanel({
{rows.map((row) => { - const refs = framesForRow(row) - const rowVideos = videosForRow(refs) - const busy = busyRow === row.index + const referenceFrame = referenceFrameForRow(row) + const rowVideos = videosForFrame(referenceFrame) const generating = videoBusyRow === row.index const copyText = copyForRow(row) return (
{row.start.toFixed(1)}-{row.end.toFixed(1)}s
@@ -1642,45 +1813,15 @@ function AudioStoryboardPlanPanel({

- - {refs.length ? ( -
- {refs.map((frame) => ( - - ))} -
- ) : ( -

{row.referencePlan}

- )} -
- - {row.keyElements} + + +
+ {referenceFrame ? `参考 ${referenceFrame.timestamp.toFixed(1)}s · 可多次生成候选` : "先在原版视频旁抽取 12 帧"}
-
- - - 0} /> -
) : ( - + )} ) @@ -1862,7 +2003,7 @@ function StoryboardVideoSlots({ job, videos, enabled }: { job: Job; videos: Gene ))} {Array.from({ length: emptyCount }).map((_, index) => (
- {enabled ? `候选 ${visible.length + index + 1}` : "先抽参考帧"} + {enabled ? `候选 ${visible.length + index + 1}` : "先抽 12 帧"}
))}