diff --git a/RULES.md b/RULES.md index 4e4f994..303a08f 100644 --- a/RULES.md +++ b/RULES.md @@ -11,11 +11,11 @@ - 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解 - 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`) - 第一冲刺:步骤 1-4(下载 / 拆轨 / 关键帧 / ASR+翻译) -- 当前产品方向(2026-05-19 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧,供人工选择可用主体并生成相似主体白底视图。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。 +- 当前产品方向(2026-05-19 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧,供人工选择可用主体并生成相似主体白底视图。波形下方的画面胶片只是临时预览,点击只跳转原视频时间点,拖进参考帧池才正式加入关键帧。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。 ## 部署事实 - 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik) -- 发布状态:已部署并验证(2026-05-19,逐句时间轴窄版面板 + 隐藏源视频工作区音频解析摘要卡 + 隐藏工作区顶部状态提示条 + 三字段候选生成工作流 + 折叠紧凑候选区);`https://marketing.skg.com` 已启用应用内登录页,未登录 API 返回 401,认证后首页 200;容器内 `/health` 返回 `ok:true` +- 发布状态:已部署并验证(2026-05-19,逐句时间轴窄版面板 + 波形下方临时画面胶片选帧 + 隐藏源视频工作区音频解析摘要卡 + 隐藏工作区顶部状态提示条 + 三字段候选生成工作流 + 折叠紧凑候选区);`https://marketing.skg.com` 已启用应用内登录页,未登录 API 返回 401,认证后首页 200;容器内 `/health` 返回 `ok:true` - 主站 / 前端:`https://marketing.skg.com` - API / 后端:`https://marketing.skg.com/api` - 代码仓库 / Gitea:`https://git.kang-kang.com/kangwan/20260512-skg-tk` diff --git a/docs/source-analysis.html b/docs/source-analysis.html index ebb6825..9acae61 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -569,7 +569,7 @@

业务管线

-

当前产品方向已收窄为“信息流广告快速复刻”:主界面左侧是素材输入列,右侧是信息流复刻工作表。后台仍按 01-09 流程顺序计算素材任务、源视频、音频文案、抽帧、主体资产、产品资产、分镜文案、三字段规划和视频候选这些状态,但这些判断不再默认显现在工作区顶部,避免状态提示挤占首屏操作空间。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动音频文案路和视频视觉路。音频文案路提取原音频文案/字幕,分析讲话人、语速节奏、背景音乐/环境声/音效,并为后续新口播和分镜文案提供时间轴;视频视觉路同步抽取参考帧,参考帧只用于人工选择主体并生成相似主体白底视图。产品图上传后独立形成产品资产包:自动识别视角、左右/上下/内外侧、结构点、比例和风险,并补缺角度。最终分镜规划按逐句时间轴把文案、相似主体资产和产品资产汇合;每条分镜默认是左侧“文案 / 场景一句话 / 人物+产品+动作”三字段、右侧横向视频候选轨。客户可直接改中文镜像,前端会调用改写/翻译链路自动优化对应英文主值;单条和整片都可选择生成数量,整片按行排队提交。首尾帧、视觉规划、产品出现方式等细节保留在高级抽屉和后端自动展开逻辑里,不再作为客户默认闸门。

+

当前产品方向已收窄为“信息流广告快速复刻”:主界面左侧是素材输入列,右侧是信息流复刻工作表。后台仍按 01-09 流程顺序计算素材任务、源视频、音频文案、抽帧、主体资产、产品资产、分镜文案、三字段规划和视频候选这些状态,但这些判断不再默认显现在工作区顶部,避免状态提示挤占首屏操作空间。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动音频文案路和视频视觉路。音频文案路提取原音频文案/字幕,分析讲话人、语速节奏、背景音乐/环境声/音效,并为后续新口播和分镜文案提供时间轴;视频视觉路同步抽取参考帧,参考帧只用于人工选择主体并生成相似主体白底视图。波形下方的画面胶片由前端临时从源视频截取,密度可调,点击只跳转原视频时间点,拖入参考帧池才调用手动抽帧接口正式写入关键帧。产品图上传后独立形成产品资产包:自动识别视角、左右/上下/内外侧、结构点、比例和风险,并补缺角度。最终分镜规划按逐句时间轴把文案、相似主体资产和产品资产汇合;每条分镜默认是左侧“文案 / 场景一句话 / 人物+产品+动作”三字段、右侧横向视频候选轨。客户可直接改中文镜像,前端会调用改写/翻译链路自动优化对应英文主值;单条和整片都可选择生成数量,整片按行排队提交。首尾帧、视觉规划、产品出现方式等细节保留在高级抽屉和后端自动展开逻辑里,不再作为客户默认闸门。

01

素材输入

有当前素材任务即通过;输入框只负责创建或切换任务。

02

源视频下载

job.video_url 存在即通过;created/downloading 视为运行中。公开视频默认不带 cookies 下载;只有 TikTok 明确要求登录态时才配置 YTDLP_COOKIES_FILE,生产容器禁止使用 YTDLP_COOKIES_FROM_BROWSER=chrome

@@ -593,7 +593,7 @@ web/next.config.mjsNext.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator,并移除 Next 16 已不支持的 eslint 顶层配置,避免本地 dev 出现配置 Issue 提示。 web/app/globals.css全局主题变量、登录页视觉样式、信息流工作台同源品牌 token、ReactFlow 样式引用,以及本地开发态 nextjs-portal 遮挡隐藏规则。工作台在 skg-board-theme 内定义 --skg-gold-1--skg-gold-2--skg-cream--skg-bg-*--skg-text-*--skg-radius-* 和按钮阴影等变量,并新增 skg-board-brandskg-stat-cardskg-primary-actionskg-secondary-actionskg-empty-state 等样式。暗色工作台复用登录页金色聚焦、米白主按钮和弱暖光氛围;明亮模式通过 skg-board-theme--light 复用同一套结构,改成暖白底、白色 panel、黑底主 CTA 和深色文本,不另起一套界面。 web/app/page.tsx产品工作台主状态:jobs、activeJobId、生成任务状态;主渲染为全屏素材输入列 + 信息流广告复刻工作表;“开始分析”会把 job 放入并行素材分析队列,下载完成后触发 triggerTranscribe 解析音频,并触发 analyzeJob 自动抽 12 张参考帧,形成“音频文案路 + 视频视觉路”同步推进;底部吸附音频条和旧全局浮动主题按钮不再从主界面渲染,避免和工作台内的明暗模式切换重复。 - web/components/ad-recreation-board.tsx信息流广告复刻工作表:顶部先展示与登录页连续的 SKG brand strip,包含 SKG 字标、“未来健康 · 营销内容工作台”和“营销内容工作台 · TK 二创”;右侧素材/任务/视频/文案统计改为米白 stat 卡片,主动作按钮统一走 skg-primary-action,次动作走 skg-secondary-action,空状态复用 AnimatedLoginCharactersbuildWorkflowSteps 仍统一生成 01-09 流程顺序、状态和判定依据,WorkflowStepBadge / PipelineLane / 分镜列标题也继续共用同一套编号;但完整 WorkflowOrderBar、右侧素材/视频/音频/文案/参考帧需求 chips、文案依据下拉和“音频文案、抽帧参考、相似主体、产品素材池”四个状态条不再默认渲染在工作区顶部。左侧素材输入只负责链接/上传和任务切换,不再重复放横版原视频预览;右侧源视频工作区直接进入核心操作。讲话人、节奏和背景音分析仍写入 AudioScript,但不再作为“音频解析结果”卡片默认渲染;主工作区左侧放大为按 9:16 显示的竖版原视频播放器,播放器内覆盖“当前点抽帧”,按当前播放秒数手动补参考帧;右侧上方是音频波形 / 切点参考,下方左侧是参考帧池,右侧是窄版逐句时间轴,桌面最大宽度约 620-680px,不再随大屏无限拉宽;下一行只保留“相似主体 / 主体模板”。音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点,顶部同时显示当前播放秒数、总时长和鼠标指针停点秒数。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。逐句时间轴左侧参考帧池的主入口是“自动抽帧 12 张”,一键按动作峰值目标重新抽取 12 张源视频参考帧,优先抓手势、表情变化、节奏点和镜头变化;缩略图按竖版完整比例显示不裁切并用更多列紧凑铺开,点选状态直接叠在参考帧池缩略图上,鼠标停留会通过固定浮层放大展示完整帧。“生成 10 张高清图”放在下方相似主体白底视图区,不和抽参考按钮平齐;如果用户没有勾选帧,默认把全部关键帧作为主体参考,勾选后只传已选帧;生成区可在“透明骨架 / 普通真人”之间切换,可选择桌面导入的 5 套内置形象作为创意方向,并可填写统一主体方向,例如年轻女性、更运动、更高级。关键帧和相似主体白底视图都用更小的竖版缩略图密排;白底视图只展示每个 view 的最新一张,缩略图上提供“重新生成这一张”和“删除这一张”,单张重生会用 replace_views=true 替换同一视角。前端调用 generateSubjectAssets 时按主体类型传 subject_style=transparent_humansource_actor,按需传 character_id,并使用 reconstruction_mode=similar;后端会把关键帧和内置形象视为同一个主体的创意证据,并锁定同一性别表现、年龄段、体型、材质、风格和视觉身份,同时生成全身多视角 + 肩颈正/左右近景 + 后颈肩背特写,避免整套图出现男女性别、老少年龄或样式混杂。主体生成完成后会形成 subject_consensus_brief,主体模板保存区可预览/编辑这段 brief。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px,但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写,分镜时间和原内容列压缩为窄摘要列,新口播列进一步收窄,把横向空间留给画面规划和首尾帧。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入和历史候选视频槽;画面规划区先选择镜头类型(人物/情绪、人物+产品、产品特写、场景过渡),再用人物/产品开关、首帧规划、尾帧规划和产品出现方式决定这一条到底需不需要产品图或相似主体参考。当前主流程暂停直接调用视频模型,不再提供“生成本条 · Seedance”或“一键提交全部”视频入口;行内新增“首尾帧闸门”,分别显示/生成首帧和尾帧,旧 keyframe 类型首尾帧会被忽略,只认真正的 asset 首尾帧。生成首尾帧时调用 generateSceneAsset,主体只传 subject_brief,不再传主体图;产品按端点选择最多 1-2 张硬参考图,默认正面,侧面/后颈/厚度/特写等关键词会额外补一张对应视角。关键帧只作为前置主体重构证据和行数据承载位置,不再作为后续视频首尾帧参考。视频候选槽只展示历史候选和待生成占位,按钮改为“保存本条规划 / 保存全部规划”。只有该行勾选“产品”时,首尾帧生成才会从产品素材池按端点视角策略自动挑选最多 1-2 张相关产品图;未勾选产品时不会把产品图提交给首尾帧/后续生视频模型,并走纯文字首尾帧。只有该行勾选“人物”时,才会把主体 brief 注入 prompt;否则 prompt 会明确禁止强行添加主角式透明骨架人,后端也不会再给产品特写强加透明骨架人约束。ModelTrace 会在音频解析、产品识别/补图、相似主体高清视图包、脚本改写等入口旁直接展示模型名;所有生图入口都显示并使用 gpt-image-2,没有其他图片模型 fallback;点击后用固定浮层展示模型链路、输入输出和回退逻辑。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 + web/components/ad-recreation-board.tsx信息流广告复刻工作表:顶部先展示与登录页连续的 SKG brand strip,包含 SKG 字标、“未来健康 · 营销内容工作台”和“营销内容工作台 · TK 二创”;右侧素材/任务/视频/文案统计改为米白 stat 卡片,主动作按钮统一走 skg-primary-action,次动作走 skg-secondary-action,空状态复用 AnimatedLoginCharactersbuildWorkflowSteps 仍统一生成 01-09 流程顺序、状态和判定依据,WorkflowStepBadge / PipelineLane / 分镜列标题也继续共用同一套编号;但完整 WorkflowOrderBar、右侧素材/视频/音频/文案/参考帧需求 chips、文案依据下拉和“音频文案、抽帧参考、相似主体、产品素材池”四个状态条不再默认渲染在工作区顶部。左侧素材输入只负责链接/上传和任务切换,不再重复放横版原视频预览;右侧源视频工作区直接进入核心操作。讲话人、节奏和背景音分析仍写入 AudioScript,但不再作为“音频解析结果”卡片默认渲染;主工作区左侧放大为按 9:16 显示的竖版原视频播放器,播放器内覆盖“当前点抽帧”,按当前播放秒数手动补参考帧;右侧上方是音频波形 / 切点参考,下方左侧是参考帧池,右侧是窄版逐句时间轴,桌面最大宽度约 620-680px,不再随大屏无限拉宽;下一行只保留“相似主体 / 主体模板”。音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点,顶部同时显示当前播放秒数、总时长和鼠标指针停点秒数。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频波形下方新增 TimelineFilmstrip 临时画面胶片,前端按低/中/高密度从源视频 canvas 截取预览缩略图,缩略图倾斜重叠排列,hover 会扶正并放大;点击胶片只跳转原视频时间,不写入任务数据,只有拖进 SourceKeyframePicker 参考帧池时才调用手动抽帧并正式加入 job.frames。逐句时间轴左侧参考帧池的主入口是“自动抽帧 12 张”,一键按动作峰值目标重新抽取 12 张源视频参考帧,优先抓手势、表情变化、节奏点和镜头变化;缩略图按竖版完整比例显示不裁切并用更多列紧凑铺开,点选状态直接叠在参考帧池缩略图上,鼠标停留会通过固定浮层放大展示完整帧。“生成 10 张高清图”放在下方相似主体白底视图区,不和抽参考按钮平齐;如果用户没有勾选帧,默认把全部关键帧作为主体参考,勾选后只传已选帧;生成区可在“透明骨架 / 普通真人”之间切换,可选择桌面导入的 5 套内置形象作为创意方向,并可填写统一主体方向,例如年轻女性、更运动、更高级。关键帧和相似主体白底视图都用更小的竖版缩略图密排;白底视图只展示每个 view 的最新一张,缩略图上提供“重新生成这一张”和“删除这一张”,单张重生会用 replace_views=true 替换同一视角。前端调用 generateSubjectAssets 时按主体类型传 subject_style=transparent_humansource_actor,按需传 character_id,并使用 reconstruction_mode=similar;后端会把关键帧和内置形象视为同一个主体的创意证据,并锁定同一性别表现、年龄段、体型、材质、风格和视觉身份,同时生成全身多视角 + 肩颈正/左右近景 + 后颈肩背特写,避免整套图出现男女性别、老少年龄或样式混杂。主体生成完成后会形成 subject_consensus_brief,主体模板保存区可预览/编辑这段 brief。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px,但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写,分镜时间和原内容列压缩为窄摘要列,新口播列进一步收窄,把横向空间留给画面规划和首尾帧。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入和历史候选视频槽;画面规划区先选择镜头类型(人物/情绪、人物+产品、产品特写、场景过渡),再用人物/产品开关、首帧规划、尾帧规划和产品出现方式决定这一条到底需不需要产品图或相似主体参考。当前主流程暂停直接调用视频模型,不再提供“生成本条 · Seedance”或“一键提交全部”视频入口;行内新增“首尾帧闸门”,分别显示/生成首帧和尾帧,旧 keyframe 类型首尾帧会被忽略,只认真正的 asset 首尾帧。生成首尾帧时调用 generateSceneAsset,主体只传 subject_brief,不再传主体图;产品按端点选择最多 1-2 张硬参考图,默认正面,侧面/后颈/厚度/特写等关键词会额外补一张对应视角。关键帧只作为前置主体重构证据和行数据承载位置,不再作为后续视频首尾帧参考。视频候选槽只展示历史候选和待生成占位,按钮改为“保存本条规划 / 保存全部规划”。只有该行勾选“产品”时,首尾帧生成才会从产品素材池按端点视角策略自动挑选最多 1-2 张相关产品图;未勾选产品时不会把产品图提交给首尾帧/后续生视频模型,并走纯文字首尾帧。只有该行勾选“人物”时,才会把主体 brief 注入 prompt;否则 prompt 会明确禁止强行添加主角式透明骨架人,后端也不会再给产品特写强加透明骨架人约束。ModelTrace 会在音频解析、产品识别/补图、相似主体高清视图包、脚本改写等入口旁直接展示模型名;所有生图入口都显示并使用 gpt-image-2,没有其他图片模型 fallback;点击后用固定浮层展示模型链路、输入输出和回退逻辑。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 AudioStoryboardPlanPanel 三字段候选生成当前分镜主路径:每行是左右双栏,左侧默认显示 skg_copy_*scene_one_line_*action_one_line_* 三组中英字段,右侧直接显示视频候选横向轨。用户改中文镜像后,字段失焦会通过 refineStoryboard 优化对应英文主值,失败时退回 translateText;英文仍是后续 prompt 主值。quickPlanStoryboard 把三字段和主体 brief 展开为完整 StoryboardScenegenerateStoryboardVideocount 可由单行数字控件选择,候选新生成后持续向右追加,不再用 4-grid 撑高每行。整片生成同样可选择每行数量,并以 concurrency=1 按行排队提交。产品素材池、批量控制、每行主体区和高级区都可折叠,高级抽屉仍展示旧 6 字段、首尾帧 prompt 和首尾帧资产槽,但客户默认不用先处理首尾帧。 web/components/resource-library/library-drawer.tsx全局资源中心浮窗:由工作台顶部“资源库”按钮打开,叠加在工作台上方但不阻塞主界面;尺寸、位置和当前 Tab 写入 localStorage["skg-resource-library-drawer"]。提示词 Tab 固定 5 列(场景描述、视频描述、主体描述、SKG 文案、产品角度),每列先显示 use_count 排名前 5 的“常用”,再按月份倒序分组;提示词节点常驻复制按钮,hover 可选英文/中文/双语复制,并调用 use 接口。素材 Tab 固定 4 列(主体、产品、场景、视频),节点不可拖动,按月份倒序硬编码排列;“应用到当前 job”只调用后端复制接口,得到普通 ImageRef(kind="asset") 后再写入产品素材池或复制 ID。浮窗顶部最近 24 小时横条混合显示提示词和素材;新建提示词、上传素材、删除前查引用、详情侧栏都在该组件内完成。 AdRecreationBoard 主题切换顶部指标区左侧有“明亮/暗色”按钮,使用 Sun / Moon 图标切换 skg-board-theme--light 类名,并把选择写入 localStorage["skg-board-theme"]。暗色仍是默认模式;明亮模式只改变工作台外观,不改变任务、素材、分镜、模型调用或接口数据。 @@ -639,7 +639,7 @@ web/app/page.tsx -> 信息流广告复刻工作表:web/components/ad-recreation-board.tsx -> 开始分析:创建/激活 job → 下载完成后并行触发视频视觉路 analyzeJob 与音频文案路 triggerTranscribe -> 后台流程判定:01 素材输入 → 02 源视频下载 → 03 音频文案 → 04 抽帧参考 → 05 相似主体 → 06 产品素材池 → 07 分镜文案 → 08 三字段规划 → 09 视频候选;每步从 buildWorkflowSteps 取判定依据和状态,但默认不渲染完整状态条 - -> 左侧素材输入列 + 源视频工作区(竖版 9:16 原视频播放器放大并内置当前点抽帧,右侧上方连续响度波形显示当前/总时长/指针停点,右侧下方左侧是参考帧池,右侧是窄版逐句时间轴联动滚动,桌面最大宽度约 620-680px;讲话人/节奏/背景音分析写入数据但不默认显示成卡片;参考帧池缩略图自身显示是否已选,不再单独重复显示已选关键帧;下方只保留相似主体 / 主体模板和相似主体高清视图包;不勾选帧则默认用全部帧,勾选后只用已选帧,可叠加 5 套内置形象;主体模板区分为模板库与本次生成 / 入库草稿,数据库接口未完成前只允许命名和备注,不提交保存) + -> 左侧素材输入列 + 源视频工作区(竖版 9:16 原视频播放器放大并内置当前点抽帧,右侧上方连续响度波形显示当前/总时长/指针停点,波形下方是可调低/中/高密度的临时画面胶片,点击仅跳转、拖入参考帧池才正式选帧;右侧下方左侧是参考帧池,右侧是窄版逐句时间轴联动滚动,桌面最大宽度约 620-680px;讲话人/节奏/背景音分析写入数据但不默认显示成卡片;参考帧池缩略图自身显示是否已选,不再单独重复显示已选关键帧;下方只保留相似主体 / 主体模板和相似主体高清视图包;不勾选帧则默认用全部帧,勾选后只用已选帧,可叠加 5 套内置形象;主体模板区分为模板库与本次生成 / 入库草稿,数据库接口未完成前只允许命名和备注,不提交保存) -> 信息流复刻分镜工作台:06 同一产品素材池不限量上传 → 自动识别视角 / 背景 / 用途 / 风险 → 人工检查备注 → 07 逐句时间轴 / 原内容 / 新口播文案 → 08 紧凑三字段(文案、场景一句话、人物+产品+动作;可折叠)→ quick-plan 自动展开高级字段 → 单条生成 4 个视频候选 / 收起态迷你缩略条 / 展开态 4-grid / 追加生成 / 选中候选 → 09 整片一键后台批量提交 -> 底部音频条:不再渲染,音频结果集中到右侧工作表 -> 旧节点/深度素材面板:web/components/nodes/index.tsx、web/components/lightbox.tsx、web/components/storyboard-workbench.tsx(底层保留,当前不作为主入口) @@ -1108,6 +1108,18 @@ ProductRefStateItem {

变更记录

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

+
+
+

2026-05-19 · 音频波形下新增临时画面胶片

+ UI + Workflow +
+
+

问题:用户通常只需要 1-2 张关键帧,但只靠自动抽帧或当前播放点补帧,浏览大量候选画面不够快,直接抽很多帧又会污染正式关键帧池。

+

改动:AudioIntakePanelAudioWaveform 下方新增 TimelineFilmstrip,前端从源视频临时截取低/中/高密度胶片缩略图,倾斜重叠排列;hover 扶正放大,点击只跳转视频时间点,拖进 SourceKeyframePicker 参考帧池才调用现有手动抽帧入口。

+

影响:临时胶片不会写入 job.frames,只有拖入后才成为正式关键帧;后续如果要调摆放密度或倾斜角度,改 FILMSTRIP_DENSITIESFILMSTRIP_TILT_CLASSES

+
+

2026-05-19 · 逐句时间轴改为窄版面板

diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx index 1a6d16e..44a52f7 100644 --- a/web/components/ad-recreation-board.tsx +++ b/web/components/ad-recreation-board.tsx @@ -111,6 +111,21 @@ type AudioFeature = { type AudioFeatureStatus = "idle" | "loading" | "ready" | "failed" +type FilmstripDensitySeconds = 1 | 2 | 5 +type FilmstripStatus = "idle" | "loading" | "ready" | "failed" +type FilmstripPreviewFrame = { + time: number + src: string +} + +const FILMSTRIP_DRAG_TYPE = "application/x-skg-filmstrip-time" +const FILMSTRIP_DENSITIES: Array<{ value: FilmstripDensitySeconds; label: string; detail: string }> = [ + { value: 5, label: "低", detail: "5s/张" }, + { value: 2, label: "中", detail: "2s/张" }, + { value: 1, label: "高", detail: "1s/张" }, +] +const FILMSTRIP_TILT_CLASSES = ["-rotate-[8deg]", "-rotate-[6deg]", "-rotate-[9deg]"] + type AudioStoryboardRow = { index: number start: number @@ -575,6 +590,102 @@ async function decodeAudioFeatures(url: string, targetFrames = 640): Promise((resolve, reject) => { + const timer = window.setTimeout(() => { + cleanup() + reject(new Error(`${eventName} timeout`)) + }, timeoutMs) + const cleanup = () => { + window.clearTimeout(timer) + target.removeEventListener(eventName, onReady) + target.removeEventListener("error", onError) + } + const onReady = () => { + cleanup() + resolve() + } + const onError = () => { + cleanup() + reject(new Error("video load failed")) + } + target.addEventListener(eventName, onReady, { once: true }) + target.addEventListener("error", onError, { once: true }) + }) +} + +function waitForVideoSeek(video: HTMLVideoElement, time: number) { + return new Promise((resolve, reject) => { + const timer = window.setTimeout(() => { + cleanup() + reject(new Error("seek timeout")) + }, 8000) + const cleanup = () => { + window.clearTimeout(timer) + video.removeEventListener("seeked", onSeeked) + video.removeEventListener("error", onError) + } + const onSeeked = () => { + cleanup() + resolve() + } + const onError = () => { + cleanup() + reject(new Error("video seek failed")) + } + video.addEventListener("seeked", onSeeked, { once: true }) + video.addEventListener("error", onError, { once: true }) + video.currentTime = time + }) +} + +async function captureVideoFilmstrip( + url: string, + duration: number, + step: FilmstripDensitySeconds, + shouldCancel: () => boolean, +): Promise { + if (!url || duration <= 0) return [] + const video = document.createElement("video") + video.muted = true + video.playsInline = true + video.preload = "auto" + video.src = url + video.load() + if (video.readyState < 1) await waitForMediaEvent(video, "loadedmetadata") + const sourceDuration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : duration + const usableDuration = Math.max(Math.min(duration || sourceDuration, sourceDuration), 0.1) + const times: number[] = [] + for (let time = 0; time < usableDuration; time += step) { + times.push(clampNumber(time + 0.08, 0, Math.max(usableDuration - 0.05, 0))) + } + if (!times.length) times.push(0.05) + + const canvas = document.createElement("canvas") + canvas.width = 96 + canvas.height = 170 + const ctx = canvas.getContext("2d") + if (!ctx) throw new Error("canvas unavailable") + const frames: FilmstripPreviewFrame[] = [] + for (const time of times) { + if (shouldCancel()) break + await waitForVideoSeek(video, time) + if (shouldCancel()) break + ctx.fillStyle = "#050505" + ctx.fillRect(0, 0, canvas.width, canvas.height) + const vw = video.videoWidth || canvas.width + const vh = video.videoHeight || canvas.height + const scale = Math.min(canvas.width / vw, canvas.height / vh) + const dw = vw * scale + const dh = vh * scale + ctx.drawImage(video, (canvas.width - dw) / 2, (canvas.height - dh) / 2, dw, dh) + frames.push({ time: Number(time.toFixed(2)), src: canvas.toDataURL("image/jpeg", 0.68) }) + } + video.removeAttribute("src") + video.load() + return frames +} + function frameLabel(frame: KeyFrame, order: number) { return `S${String(order + 1).padStart(2, "0")} · ${frame.timestamp.toFixed(1)}s` } @@ -2257,10 +2368,16 @@ function AudioIntakePanel({ const [extracting, setExtracting] = useState(false) const [deletingFrame, setDeletingFrame] = useState(null) const [waveHoverTime, setWaveHoverTime] = useState(null) + const [filmstripDensity, setFilmstripDensity] = useState(2) + const [filmstripPreviews, setFilmstripPreviews] = useState([]) + const [filmstripStatus, setFilmstripStatus] = useState("idle") + const [filmstripDragTime, setFilmstripDragTime] = useState(null) + const [filmstripBusyTime, setFilmstripBusyTime] = useState(null) const videoRef = useRef(null) const rowRefs = useRef>({}) const syncFrameRef = useRef(null) const audioSrcUrl = job ? apiAssetUrl(job.source_audio_url) || sourceAudioUrl(job.id) : "" + const videoSrcUrl = job ? apiAssetUrl(job.video_url) || videoUrl(job.id) : "" const processing = !!job && (job.status === "transcribing" || job.audio_script?.status === "rewriting") const timelineDuration = useMemo(() => { if (!job) return 1 @@ -2315,6 +2432,31 @@ function AudioIntakePanel({ if (activeSegment) rowRefs.current[activeSegment.index]?.scrollIntoView({ block: "nearest" }) }, [activeSegment?.index]) + useEffect(() => { + if (!job?.id || !job.video_url || !videoSrcUrl || timelineDuration <= 0) { + setFilmstripPreviews([]) + setFilmstripStatus("idle") + return + } + let cancelled = false + setFilmstripPreviews([]) + setFilmstripStatus("loading") + captureVideoFilmstrip(videoSrcUrl, timelineDuration, filmstripDensity, () => cancelled) + .then((frames) => { + if (!cancelled) { + setFilmstripPreviews(frames) + setFilmstripStatus(frames.length ? "ready" : "idle") + } + }) + .catch(() => { + if (!cancelled) { + setFilmstripPreviews([]) + setFilmstripStatus("failed") + } + }) + return () => { cancelled = true } + }, [filmstripDensity, job?.id, job?.video_url, timelineDuration, videoSrcUrl]) + useEffect(() => { return () => { if (syncFrameRef.current !== null) cancelAnimationFrame(syncFrameRef.current) @@ -2360,6 +2502,24 @@ function AudioIntakePanel({ } } + const addFilmstripFrame = async (time: number) => { + if (!job || !onAddFrame) return + const next = clampNumber(time, 0, timelineDuration) + const duplicate = frames.find((frame) => Math.abs(frame.timestamp - next) < 0.45) + if (duplicate) { + toast.warning(`附近已有关键帧:${duplicate.timestamp.toFixed(1)}s`) + return + } + setFilmstripBusyTime(next) + try { + await onAddFrame(job.id, next) + toast.success(`已加入关键帧:${next.toFixed(1)}s`) + } finally { + setFilmstripBusyTime(null) + setFilmstripDragTime(null) + } + } + const extractKeyframes = async () => { if (!job) return setExtracting(true) @@ -2391,8 +2551,6 @@ function AudioIntakePanel({ return } - const videoSrcUrl = apiAssetUrl(job.video_url) || videoUrl(job.id) - return (
@@ -2466,6 +2624,18 @@ function AudioIntakePanel({ onSeek={seekTo} onHoverTimeChange={setWaveHoverTime} /> + frame.timestamp)} + busyTime={filmstripBusyTime} + onDensityChange={setFilmstripDensity} + onSeek={seekTo} + onDragStart={setFilmstripDragTime} + onDragEnd={() => setFilmstripDragTime(null)} + />
@@ -2479,6 +2649,8 @@ function AudioIntakePanel({ onToggleFrame={onToggleFrame} onExtract={() => void extractKeyframes()} onDeleteFrame={onDeleteFrame ? (idx) => void deleteReferenceFrame(idx) : undefined} + filmstripDragging={filmstripDragTime !== null} + onDropFilmstripFrame={(time) => void addFilmstripFrame(time)} />
@@ -2536,6 +2708,117 @@ function AudioIntakePanel({ ) } +function TimelineFilmstrip({ + frames, + status, + density, + currentTime, + selectedTimes, + busyTime, + onDensityChange, + onSeek, + onDragStart, + onDragEnd, +}: { + frames: FilmstripPreviewFrame[] + status: FilmstripStatus + density: FilmstripDensitySeconds + currentTime: number + selectedTimes: number[] + busyTime: number | null + onDensityChange: (density: FilmstripDensitySeconds) => void + onSeek: (time: number) => void + onDragStart: (time: number) => void + onDragEnd: () => void +}) { + return ( +
+
+
+
画面胶片
+
临时预览,拖到关键帧才选取
+
+
+ {FILMSTRIP_DENSITIES.map((item) => ( + + ))} +
+
+ +
+ {status === "loading" ? ( +
+ + 正在生成临时胶片 +
+ ) : status === "failed" ? ( +
+ 胶片预览生成失败,可继续用当前点抽帧。 +
+ ) : frames.length ? ( +
+ {frames.map((frame, index) => { + const selected = selectedTimes.some((time) => Math.abs(time - frame.time) < 0.45) + const active = Math.abs(currentTime - frame.time) <= Math.max(density * 0.45, 0.45) + const busy = busyTime !== null && Math.abs(busyTime - frame.time) < 0.45 + const tiltClass = FILMSTRIP_TILT_CLASSES[index % FILMSTRIP_TILT_CLASSES.length] + return ( +
{ + event.dataTransfer.setData(FILMSTRIP_DRAG_TYPE, frame.time.toFixed(2)) + event.dataTransfer.effectAllowed = "copy" + onDragStart(frame.time) + }} + onDragEnd={onDragEnd} + className={`relative shrink-0 ${index ? "-ml-2.5" : ""} ${tiltClass} cursor-grab transition-transform duration-150 hover:z-30 hover:rotate-0 hover:scale-[2.15] active:cursor-grabbing`} + title={`${frame.time.toFixed(1)}s · 拖到关键帧库才选取`} + > + onSeek(frame.time)} + title="点击跳到该时间点,拖入关键帧库才正式选取" + topRight={busy ? : selected ? : undefined} + bottom={{frame.time.toFixed(1)}s} + /> +
+ ) + })} +
+ ) : ( +
+ 等待原视频生成临时胶片 +
+ )} +
+
+ ) +} + function SourceKeyframePicker({ job, frames, @@ -2546,6 +2829,8 @@ function SourceKeyframePicker({ onToggleFrame, onExtract, onDeleteFrame, + filmstripDragging, + onDropFilmstripFrame, }: { job: Job frames: KeyFrame[] @@ -2556,7 +2841,10 @@ function SourceKeyframePicker({ onToggleFrame: (idx: number) => void onExtract: () => void onDeleteFrame?: (idx: number) => void + filmstripDragging?: boolean + onDropFilmstripFrame?: (time: number) => void }) { + const [dropActive, setDropActive] = useState(false) return (
@@ -2578,10 +2866,41 @@ function SourceKeyframePicker({
-
+
{ + if (!onDropFilmstripFrame) return + event.preventDefault() + setDropActive(true) + }} + onDragOver={(event) => { + if (!onDropFilmstripFrame) return + event.preventDefault() + event.dataTransfer.dropEffect = "copy" + }} + onDragLeave={(event) => { + const next = event.relatedTarget as Node | null + if (next && event.currentTarget.contains(next)) return + setDropActive(false) + }} + onDrop={(event) => { + if (!onDropFilmstripFrame) return + event.preventDefault() + setDropActive(false) + const raw = event.dataTransfer.getData(FILMSTRIP_DRAG_TYPE) + const time = Number(raw) + if (Number.isFinite(time)) onDropFilmstripFrame(time) + }} + >
参考帧池 - 点选即为已选,悬停放大 + {filmstripDragging ? "松手加入关键帧" : "拖入胶片选帧,悬停放大"}
{frames.map((frame, index) => {