diff --git a/.claude/skills/video-from-script/references/account-creation.md b/.claude/skills/video-from-script/references/account-creation.md index f6b3ee4..60a7c2b 100644 --- a/.claude/skills/video-from-script/references/account-creation.md +++ b/.claude/skills/video-from-script/references/account-creation.md @@ -1,6 +1,8 @@ # 账号创建流程 > Agent 通过结构化问答收集信息,基于通用 prompt 模板 + 风格注入,生成完整的账号配置。 +> +> **10 维度账号画像**:账号定位 → 目标受众 → 内容气质 → 视觉基调 → 画风质感 → 色彩体系 → 镜头运动 → 声音身份 → 文字呈现 → 节奏律动 --- @@ -9,20 +11,20 @@ ``` 用户说"创建账号"/"新账号" ↓ -Phase 1: 账号身份(必须) +Phase 1: 账号定位(必须) ↓ Phase 2: 内容 DNA(必须) ↓ Phase 3: 视觉身份(必须,3种输入方式) ↓ -Phase 4: 技术配置(有默认值,可跳过) +Phase 4: 成片风格(有默认值,可跳过) ↓ 汇总确认 → 生成文件 → 校验 ``` --- -## Phase 1:账号身份 +## Phase 1:账号定位 | # | 问题 | 必填 | 说明 | |---|------|------|------| @@ -40,10 +42,18 @@ Phase 4: 技术配置(有默认值,可跳过) | 3 | 核心内容方向?如:历史权谋、科技解说、情感故事、美食文化 | ✅ | 分镜.md → 角色定义 + 账号内容理解.核心方向 | | 4 | 目标受众?如:30岁男性、18-25岁女性 | ✅ | 分镜.md → 账号内容理解.目标受众 | | 5 | 内容气质?用 2-3 个关键词描述,如:冷峻洞察、温暖治愈、犀利反讽 | ✅ | 分镜.md → 账号内容理解.内容气质 | +| 5.5 | Hook 策略偏好?(选填) | ❌ | 分镜.md → 3秒钩子规则增强 | +| | A. 结论前置(默认):直接亮核心观点 | | | +| | B. 认知冲突:一句话打破常识,制造"凭什么" | | | +| | C. 身份挑衅:点中受众身份焦虑 | | | +| | D. 数据震惊:用震撼数字开场 | | | +| | E. 反转悬念:设一个反直觉的悬念 | | | +| 5.6 | 目标情绪回路?(选填)如:好奇→震惊→领悟,或平静→压迫→释放 | ❌ | 分镜.md → 账号内容理解.情绪回路 | **注入规则**: - 角色定义改为"专精{Q3}类口播文案转化为{Q6}画面的分镜导演" -- 新增「账号内容理解」节(仅供子 Agent 理解上下文,不输出到分镜表) +- 新增「账号内容理解」节(Q3+Q4+Q5+Q5.6,仅供子 Agent 理解上下文,不输出到分镜表) +- 如有 Q5.5,在「3秒钩子规则」中标注账号默认 Hook 策略 --- @@ -79,22 +89,69 @@ Phase 4: 技术配置(有默认值,可跳过) --- -## Phase 4:技术配置(有默认值) +## Phase 4:成片风格(有默认值) + +### 维度 7:镜头运动 | # | 问题 | 默认值 | 说明 | |---|------|--------|------| | 10 | 视频运动偏好? | 克制慢速 | 注入视频.md → 运动风格基调 | | | 选项:克制慢速 / 流畅中速 / 快速激烈 | | | -| 11 | 画幅? | 9:16 | account.json 的 defaultFormat | -| 12 | 生图模型? | gemini | account.json 的 imageModel | -| 13 | 视频模型? | veo3-fast | account.json 的 videoModel | -| 14 | 参考图文件? | 无 | 用户稍后放入 references/ 目录,Agent 上传 OSS 回写 URL | -| 15 | TTS 音色? | config.json 全局 ttsVoice | account.json 的 ttsVoice,留空用全局默认 | -| 16 | TTS 语气指令? | 无 | account.json 的 ttsInstruction,描述期望的语气风格 | -| 17 | 关键字氛围词? | 开启(默认样式) | 画面中央大字叠加,增强冲击力。选项:关闭 / 默认样式 / 描述期望效果 | +| 11 | Ken Burns 镜头动画? | 开启(按导演风格) | 图片成片时为每张图添加慢速缩放/平移,消除幻灯片感。选项:关闭 / 默认 / 自定义 | + +**Ken Burns 推荐表**(未配置时自动按导演风格匹配): + +| 内容气质 | 缩放速率 | 平移 | 效果 | +|---------|---------|------|------| +| 沉稳/克制/叙事 | 0.4-0.6 %/秒 | 无 | 几乎无感的缓慢推进(类似纪录片) | +| 均衡/节奏/科普 | 0.6-0.8 %/秒 | 微弱 | 稳定匀速推进,适合中长片段 | +| 冲击/激烈/权谋 | 0.8-1.0 %/秒 | 轻微 | 明显的推镜头,强化压迫感 | +| 唯美/治愈/风景 | 0.3-0.5 %/秒 | 强烈(0.3-0.6 /秒) | 缓慢平移+微缩放,展示画面纵深 | + +> Q11 选"自定义"时,Agent 应询问缩放速率偏好(快/中/慢)和平移方向偏好(固定/左右/上下),生成对应的 kenBurns 配置。 +> 选"默认"时根据 Q5 内容气质 + Q10 运动偏好自动匹配上表。 +> 视频模式自动跳过 Ken Burns(视频本身已有运动)。 + +### 维度 8:声音身份 + +| # | 问题 | 默认值 | 说明 | +|---|------|--------|------| +| 12 | TTS 音色? | config.json 全局 ttsVoice | account.json 的 ttsVoice,留空用全局默认 | +| 13 | TTS 语气指令? | 无 | account.json 的 ttsInstruction,描述期望的语气风格 | +| 14 | 背景音乐偏好? | 无 | account.json 的 capcut.defaultBGM。提供 URL 或描述风格,Agent 辅助查找 | + +**TTS 语气指令示例**: +- 军事/权谋:"用沉稳有力的男性声音朗读,语速偏慢,带历史厚重感" +- 科技/科普:"用清晰理性的男性声音朗读,语速适中,专业但亲和" +- 情感/治愈:"用温柔柔和的女性声音朗读,语速偏慢,有感染力" + +### 维度 9:文字呈现 + +| # | 问题 | 默认值 | 说明 | +|---|------|--------|------| +| 15 | 关键字氛围词? | 开启(默认样式) | 画面中央大字叠加,增强冲击力。选项:关闭 / 默认样式 / 描述期望效果 | | | 期望的花字风格?(选填) | 按账号风格推荐 | 根据视觉基调推荐,见下方花字推荐表 | -| 18 | 滤镜? | 无 | account.json 的 capcut.filter,格式 `滤镜名:强度`(如 `质感电影:40`)。见下方滤镜推荐表 | -| 19 | 转场偏好? | 默认闪白 | account.json 的 capcut.transitions。见下方转场推荐表 | +| 16 | 字幕风格偏好? | 默认白字 | 底部口播字幕。选项:默认白字 / 高亮关键词 / 描述期望 | + +**字幕风格推荐表**: + +| 账号风格 | 推荐配色 | 字号 | 特效 | +|---------|---------|------|------| +| 暗黑/军事 | 白色 + 橙红高亮 | 24 | 淡入淡出 | +| 科技/赛博 | 白色 + 青蓝高亮 | 22 | 淡入淡出 | +| 情感/治愈 | 白色 + 暖黄高亮 | 24 | 淡入淡出 | +| 简约/知识 | 白色 + 无高亮 | 22 | 淡入淡出 | + +### 维度 10:节奏律动 + +| # | 问题 | 默认值 | 说明 | +|---|------|--------|------| +| 17 | 滤镜? | 无 | account.json 的 capcut.filter,格式 `滤镜名:强度`(如 `质感电影:40`)。见下方滤镜推荐表 | +| 18 | 转场偏好? | 默认闪白 | account.json 的 capcut.transitions。见下方转场推荐表 | +| 19 | 画幅? | 9:16 | account.json 的 defaultFormat | +| 20 | 生图模型? | gemini | account.json 的 imageModel | +| 21 | 视频模型? | veo3-fast | account.json 的 videoModel | +| 22 | 参考图文件? | 无 | 用户稍后放入 references/ 目录,Agent 上传 OSS 回写 URL | **花字效果推荐表**(92 种免费效果,按风格分类): @@ -148,39 +205,65 @@ Phase 4: 技术配置(有默认值,可跳过) --- +## 爆款潜力自检(生成前必检) + +Agent 在汇总确认前,先做以下快速自检。任何一项为 ❌ 时建议用户补充: + +| 检查项 | 通过标准 | +|--------|---------| +| 差异化定位 | Q2 描述能让用户说清"为什么看这个号而不是别的" | +| 情绪价值 | Q5.6 有明确的情绪回路,不是"好看"而是"看完有感觉" | +| 前3秒策略 | Q5.5 选了明确的 Hook 模式,不是"先铺垫再讲" | +| 视觉记忆点 | Q7+Q8 色彩/画风能在信息流中一眼认出 | + +自检结果展示给用户:全部 ✅ → 进入汇总确认;有 ❌ → 建议补充后再继续(用户可强制跳过)。 + +--- + ## 汇总确认 -问答结束后,Agent 输出汇总表让用户确认: +问答结束后,Agent 按 10 维度输出汇总表让用户确认: ``` -## 账号创建确认 +## 账号创建确认(10 维度) -### 基本信息 +### 维度 1-2:定位 + 受众 - 名称:{name} - 描述:{description} - 目录:accounts/{id}/ - -### 内容方向 -- 核心方向:{Q3} - 目标受众:{Q4} -- 内容气质:{Q5} -### 视觉风格 +### 维度 3:内容气质 +- 核心方向:{Q3} +- 内容气质:{Q5} +- Hook 策略:{Q5.5 或"未指定,使用通用钩子规则"} +- 情绪回路:{Q5.6 或"未指定"} + +### 维度 4-6:视觉基调 + 画风 + 色彩 - 基调:{Q6} - 画风:{Q7} - 色彩:{Q8 摘要} - 质感:{Q9} -### 技术配置 -- 运动:{Q10} -- 画幅:{Q11} -- 生图模型:{Q12} -- 视频模型:{Q13} -- TTS音色:{Q15} -- TTS语气:{Q16} -- 关键字氛围词:{Q17 开启/关闭,花字风格} -- 滤镜:{Q18} -- 转场:{Q19} +### 维度 7:镜头运动 +- 视频运动:{Q10} +- Ken Burns:{Q11 开启/关闭,速率} + +### 维度 8:声音身份 +- TTS音色:{Q12} +- TTS语气:{Q13} +- BGM:{Q14} + +### 维度 9:文字呈现 +- 关键字氛围词:{Q15 开启/关闭,花字风格} +- 字幕风格:{Q16} + +### 维度 10:节奏律动 +- 滤镜:{Q17} +- 转场:{Q18} +- 画幅:{Q19} +- 生图模型:{Q20} +- 视频模型:{Q21} 确认 "开始" → 创建账号 修改 → 调整后重新输出 @@ -208,14 +291,18 @@ Phase 4: 技术配置(有默认值,可跳过) 2. **生成 account.json** - 从 `_template/account.json` 复制骨架 - 填入 id、name、description、模型、画幅等 - - Q17 选关闭时删除 `keywordStyle` 节;选自定义花字时更新 `textEffect` 字段 - - Q18 填入 `capcut.filter`(格式 `滤镜名:强度`) - - Q19 填入 `capcut.transitions` 配置 + - Q15 选关闭时删除 `keywordStyle` 节;选自定义花字时更新 `textEffect` 字段 + - Q16 选自定义时更新 `subtitleStyle` 的 color/highlightColor/fontSize + - Q17 填入 `capcut.filter`(格式 `滤镜名:强度`) + - Q18 填入 `capcut.transitions` 配置 + - Q11 选关闭时设 `kenBurns.enabled=false`;选自定义时按推荐表生成 `byDirector` 配置 + - Q14 填入 `capcut.defaultBGM`(URL) 3. **生成分镜.md** - 读取 `_template/prompts/通用分镜.md` - 在角色定义中注入 Q3 内容方向 - - 新增「账号内容理解」节(Q3+Q4+Q5) + - 新增「账号内容理解」节(Q3+Q4+Q5+Q5.6 情绪回路) + - 增强「3秒钩子规则」节:如有 Q5.5,标注账号默认 Hook 策略 - 新增「宏观视觉风格方向」节(Q6+Q7+推导) - 保留通用骨架:切割规则、导演构图词库、shotDesc 写法规范、输入输出格式、质量自检 diff --git a/.claude/skills/video-from-script/references/account-system.md b/.claude/skills/video-from-script/references/account-system.md index 121d62e..15e2395 100644 --- a/.claude/skills/video-from-script/references/account-system.md +++ b/.claude/skills/video-from-script/references/account-system.md @@ -119,8 +119,41 @@ accounts/ # 项目根目录下 | `capcut.subtitleStyle` | object | 字幕样式(font/字体、fontSize、color、highlightColor、bold、transformY、inAnimation/outAnimation) | | `capcut.keywordStyle` | object | 关键字氛围词样式(textEffect 花字、fontSize、color、动画),留空或删除则关闭 | | `capcut.transitions` | object | 转场配置(strategy + default + byPosition + byDirector) | +| `capcut.kenBurns` | object | Ken Burns 关键帧动画(消除图片幻灯片感),见下方 | | `capcut.defaultBGM` | string | 默认背景音乐 URL | +### kenBurns 字段 + +Ken Burns 关键帧动画:为每张图片添加慢速连续缩放/平移,消除幻灯片感。**速率驱动**,自动适配片段时长(短片段缩放少,长片段缩放多)。 + +```json +{ + "kenBurns": { + "enabled": true, + "default": { "startScale": 1.0, "scaleRate": 0.8, "panXRate": 0, "panYRate": 0 }, + "byDirector": { + "tarantino": { "startScale": 1.0, "scaleRate": 0.9, "panXRate": 0, "panYRate": -0.3 }, + "kitano": { "startScale": 1.03, "scaleRate": 0.5, "panXRate": 0.4, "panYRate": 0 }, + "fincher": { "startScale": 1.0, "scaleRate": 0.4, "panXRate": 0, "panYRate": 0 } + } + } +} +``` + +| 参数 | 类型 | 说明 | +|------|------|------| +| `enabled` | boolean | `false` 禁用 Ken Burns,默认 `true` | +| `default` | object | 默认 Ken Burns 参数(无 directorRef 匹配时使用) | +| `byDirector` | object | 按 directorRef 覆盖(key 为导演名小写) | +| `startScale` | number | 起始缩放(1.0=原始,1.03=略微放大) | +| `scaleRate` | number | 缩放速率(%/秒),如 0.8 = 每秒放大 0.8%。自动乘以片段时长计算 endScale,上限 1.20 | +| `panXRate` | number | 水平平移速率(单位/秒),正值右移。自动乘以片段时长 | +| `panYRate` | number | 垂直平移速率(单位/秒),正值下移。自动乘以片段时长 | + +**创建账号时 Agent 应询问**: +- 是否需要 Ken Burns 镜头动画?(图片成片必开,视频模式自动跳过) +- 镜头风格偏好:微缩放 / 平移+缩放 / 仅缩放 + --- ## 提示词模板(prompts/) diff --git a/.claude/skills/video-from-script/scripts/capcut_assemble.js b/.claude/skills/video-from-script/scripts/capcut_assemble.js index 7b8563f..2fd21e0 100644 --- a/.claude/skills/video-from-script/scripts/capcut_assemble.js +++ b/.claude/skills/video-from-script/scripts/capcut_assemble.js @@ -113,7 +113,7 @@ async function uploadToOSS(filePath) { // 转场选择策略 // ============================================================================ -function getTransition(item, index, totalCount, transitionConfig, allItems) { +function getTransition(item, index, totalCount, transitionConfig, allItems, timeline) { // 无配置 → 不加转场 if (!transitionConfig) return { name: '', duration: 0 } @@ -135,7 +135,7 @@ function getTransition(item, index, totalCount, transitionConfig, allItems) { } case 'rhythm': { - // 按位置选择转场:hook/keypoint/closing 加转场,body 段隔 3 段加一次 + // 按位置选择转场:hook/keypoint/closing 加转场,body 段按累计时长间隔 const rules = transitionConfig.byPosition || {} // 收尾段 if (index >= totalCount - 2) return rules.closing || defaultT @@ -149,8 +149,25 @@ function getTransition(item, index, totalCount, transitionConfig, allItems) { return rules.keypoint || defaultT } } - // 固定间隔兜底(每 3 段) - if (index % 3 === 0) return rules.body || defaultT + // 基于累计时长的间隔(约每 8 秒加一次转场) + if (timeline && timeline.length > 0) { + const elapsed = timeline[index].start + // 向前搜索上一个有转场的段落(只看 directorRef/keyword/closing,不递归) + let lastTransStart = 0 + for (let pi = index - 1; pi >= 1; pi--) { + const p = allItems[pi] + const pp = pi > 0 ? allItems[pi - 1] : null + if (pi >= totalCount - 2) { lastTransStart = timeline[pi].start; break } + if (pp && p.directorRef && pp.directorRef && p.directorRef !== pp.directorRef) { + lastTransStart = timeline[pi].start; break + } + if (p.keyword && pp && !pp.keyword) { lastTransStart = timeline[pi].start; break } + } + if (elapsed - lastTransStart >= 8000000) return rules.body || defaultT + } else { + // 无 timeline 时回退到固定间隔 + if (index % 3 === 0) return rules.body || defaultT + } return { name: '', duration: 0 } } @@ -160,18 +177,24 @@ function getTransition(item, index, totalCount, transitionConfig, allItems) { } } -async function batchUploadToOSS(inputDir, files) { +async function batchUploadToOSS(inputDir, files, concurrency = 3) { const urls = {} - for (const file of files) { - const filePath = path.join(inputDir, file) - if (!fs.existsSync(filePath)) continue - try { - urls[file] = await uploadToOSS(filePath) - console.log(` 上传: ${file} -> OK`) - } catch (err) { - console.error(` 上传失败: ${file} - ${err.message}`) + const queue = [...files] + const workers = Array(Math.min(concurrency, queue.length)).fill(null).map(async () => { + while (queue.length > 0) { + const file = queue.shift() + if (!file) break + const filePath = path.join(inputDir, file) + if (!fs.existsSync(filePath)) continue + try { + urls[file] = await uploadToOSS(filePath) + console.log(` 上传: ${file} -> OK`) + } catch (err) { + console.error(` 上传失败: ${file} - ${err.message}`) + } } - } + }) + await Promise.all(workers) return urls } @@ -231,7 +254,7 @@ async function assemble(args) { format = '9:16', apiKey = '', duration = '4', - animation = '缩放', + animation = '轻微放大', } = args if (!input) throw new Error('缺少 --input 参数') @@ -317,7 +340,7 @@ async function assemble(args) { const steps = [] if (mode === 'images') steps.push('upload') - steps.push('draft', 'materials', 'audio_oss', 'voiceover', 'audio', 'subtitles', 'keywords', 'effects', 'filter', 'save', 'sync') + steps.push('draft', 'materials', 'kenburns', 'audio_oss', 'voiceover', 'audio', 'subtitles', 'keywords', 'effects', 'filter', 'save', 'sync') const totalSteps = steps.length let step = 0 @@ -353,8 +376,9 @@ async function assemble(args) { // -- 导入素材 -- step++; console.log(`[${step}/${totalSteps}] 导入素材...`) + let imageSegmentIds = [] if (mode === 'images') { - await addImages(draftUrl, items, imgUrls, timeline, width, height, animation, transitionConfig) + imageSegmentIds = await addImages(draftUrl, items, imgUrls, timeline, width, height, animation, transitionConfig) } else { // 视频模式:调速 → 上传 OSS → 添加到草稿 // Step 1: ffmpeg 调速(在上传前,避免传两份) @@ -402,6 +426,12 @@ async function assemble(args) { await addVideos(draftUrl, inputDir, items, timeline, width, height, transitionConfig) } + // -- Ken Burns 关键帧动画(消除幻灯片感) -- + if (mode === 'images' && imageSegmentIds.length > 0) { + step++; console.log(`[${step}/${totalSteps}] 添加 Ken Burns 镜头动画...`) + await addKenBurns(draftUrl, imageSegmentIds, items, timeline, manifest) + } + // -- 上传 TTS 音频到 OSS -- let audioUrls = {} if (voiceover === 'true' && hasTTS) { @@ -527,11 +557,11 @@ async function assemble(args) { // 添加图片(自动上传到 OSS) // ============================================================================ -// 按 directorRef 映射不同动画风格 +// 按 directorRef 映射不同入场动画(IntroType,一次性不循环,不晕) const DIRECTOR_ANIMATIONS = { - tarantino: '缩放', // 快速冲击感缩放 - kitano: '回弹伸缩', // 缓慢静谧的弹性平移 - fincher: '缩放 II', // 精确微距推进 + tarantino: '动感放大', // 强冲击感 + kitano: '轻微放大', // 静谧微推进 + fincher: '渐显', // 克制、精确、不露痕迹 } function getAnimationForDirector(directorRef, defaultAnimation) { @@ -544,9 +574,7 @@ async function addImages(draftUrl, items, imgUrls, timeline, width, height, anim const url = imgUrls[item.file] if (!url) throw new Error(`图片 ${item.file} 未上传成功,无法添加`) const tl = timeline[i] - const t = getTransition(item, i, items.length, transitionConfig, items) - - // animation 解析:支持 "缩放" (group), "放大" (in), "渐显+缩小" (in+out) + const t = getTransition(item, i, items.length, transitionConfig, items, timeline) const info = { image_url: url, width, @@ -589,6 +617,97 @@ async function addImages(draftUrl, items, imgUrls, timeline, width, height, anim return allSegmentIds } +// ============================================================================ +// Ken Burns 关键帧动画(慢速缩放/平移,消除幻灯片感) +// ============================================================================ + +// 向后兼容:account.json 未配置 kenBurns 时的默认值 +const KEN_BURNS_FALLBACK = { + default: { startScale: 1.0, scaleRate: 0.8, panXRate: 0, panYRate: 0 }, + byDirector: { + tarantino: { startScale: 1.0, scaleRate: 0.9, panXRate: 0, panYRate: -0.3 }, + kitano: { startScale: 1.03, scaleRate: 0.5, panXRate: 0.4, panYRate: 0 }, + fincher: { startScale: 1.0, scaleRate: 0.4, panXRate: 0, panYRate: 0 }, + }, +} +const KEN_BURNS_MAX_SCALE = 1.20 + +function loadKenBurns(manifest) { + const cfg = loadAccountConfig(manifest).capcut?.kenBurns + if (cfg && cfg.default) return cfg + return KEN_BURNS_FALLBACK +} + +function getKenBurnsProfile(item, kbConfig) { + const director = (item.directorRef || '').toLowerCase() + const byDirector = kbConfig.byDirector || {} + return byDirector[director] || kbConfig.default +} + +async function addKenBurns(draftUrl, segmentIds, items, timeline, manifest) { + if (!segmentIds || segmentIds.length === 0) { + console.log(' 无 segment IDs,跳过 Ken Burns') + return + } + + const kbConfig = loadKenBurns(manifest) + if (kbConfig.enabled === false) { + console.log(' Ken Burns 已禁用(account.json kenBurns.enabled=false)') + return + } + + const keyframes = [] + for (let i = 0; i < segmentIds.length; i++) { + const segId = segmentIds[i] + const item = items[i] + const tl = timeline[i] + if (!segId || !tl) continue + + const profile = getKenBurnsProfile(item, kbConfig) + const durSec = tl.duration / US + const segDur = tl.duration // 微秒 + + // 速率驱动:scaleRate(%/秒)× 时长 = 实际 endScale,clamp 上限 + const startScale = profile.startScale || 1.0 + const scaleRate = profile.scaleRate != null ? profile.scaleRate : 0.8 + const endScale = Math.min(startScale + scaleRate * durSec / 100, KEN_BURNS_MAX_SCALE) + + keyframes.push( + { segment_id: segId, property: 'UNIFORM_SCALE', offset: 0, value: startScale }, + { segment_id: segId, property: 'UNIFORM_SCALE', offset: segDur, value: endScale }, + ) + + // 平移速率驱动:panXRate/panYRate(单位/秒)× 时长 = 实际偏移 + const panXRate = profile.panXRate || 0 + const panYRate = profile.panYRate || 0 + if (panXRate !== 0) { + const panX = panXRate * durSec + keyframes.push( + { segment_id: segId, property: 'KFTypePositionX', offset: 0, value: 0 }, + { segment_id: segId, property: 'KFTypePositionX', offset: segDur, value: panX }, + ) + } + if (panYRate !== 0) { + const panY = panYRate * durSec + keyframes.push( + { segment_id: segId, property: 'KFTypePositionY', offset: 0, value: 0 }, + { segment_id: segId, property: 'KFTypePositionY', offset: segDur, value: panY }, + ) + } + } + + if (keyframes.length === 0) { + console.log(' 无关键帧生成,跳过') + return + } + + const res = await api('add_keyframes', { + draft_url: draftUrl, + keyframes: JSON.stringify(keyframes), + }) + console.log(` 已添加 ${res.keyframes_added || keyframes.length} 个 Ken Burns 关键帧 (${segmentIds.length} 段)`) +} + // ============================================================================ // 添加视频(从 manifest 读取时长) // ============================================================================ @@ -650,7 +769,7 @@ async function adjustVideoSpeed(videoPath, targetDurationSec) { async function addVideos(draftUrl, inputDir, items, timeline, width, height, transitionConfig = null) { const videoInfos = items.map((item, i) => { const tl = timeline[i] - const t = getTransition(item, i, items.length, transitionConfig, items) + const t = getTransition(item, i, items.length, transitionConfig, items, timeline) return { video_url: item.videoUrl || (item.video ? path.resolve(inputDir, item.video) : null) || item.url || path.resolve(inputDir, item.file), width, @@ -791,14 +910,15 @@ async function addVoiceover(draftUrl, inputDir, items, timeline, audioUrls = {}) }) } } else if (item.audio) { - // 单段音频 + // 单段音频:用实际音频时长,不超过 timeline 时长 const audioUrl = resolveAudio(item.audio) + const audioDurUs = item.audioDuration ? item.audioDuration * US : tl.duration audioInfos.push({ audio_url: audioUrl, start: tl.start, - end: tl.end, - duration: tl.duration, + end: tl.start + audioDurUs, + duration: audioDurUs, volume: 1.0, }) } @@ -831,6 +951,9 @@ async function addBGM(draftUrl, bgmUrl, totalDurationUs) { // 无法获取时长就用视频总时长 } + const fadeIn = 500000 // 0.5s 淡入 + const fadeOut = 1000000 // 1s 淡出 + await api('add_audios', { draft_url: draftUrl, audio_infos: JSON.stringify([{ @@ -839,9 +962,11 @@ async function addBGM(draftUrl, bgmUrl, totalDurationUs) { end: Math.min(audioDuration, totalDurationUs), start: 0, volume: 0.15, + fade_in_duration: fadeIn, + fade_out_duration: fadeOut, }]), }) - console.log(` 已添加 BGM (${(audioDuration / US).toFixed(1)}s)`) + console.log(` 已添加 BGM (${(audioDuration / US).toFixed(1)}s, fade 0.5s/1s)`) } // ============================================================================ @@ -997,7 +1122,7 @@ async function addSubtitles(draftUrl, items, timeline, style = {}, split = false duration = tl.end - currentTime } - duration = Math.max(duration, 1000000) + duration = Math.max(duration, 500000) const cap = { start: currentTime, diff --git a/accounts/_template/account.json b/accounts/_template/account.json index aca44dd..6c9814d 100644 --- a/accounts/_template/account.json +++ b/accounts/_template/account.json @@ -47,6 +47,15 @@ "kitano": { "name": "溶解", "duration": 400000 }, "fincher": { "name": "闪黑", "duration": 200000 } } + }, + "kenBurns": { + "enabled": true, + "default": { "startScale": 1.0, "scaleRate": 0.8, "panXRate": 0, "panYRate": 0 }, + "byDirector": { + "tarantino": { "startScale": 1.0, "scaleRate": 0.9, "panXRate": 0, "panYRate": -0.3 }, + "kitano": { "startScale": 1.03, "scaleRate": 0.5, "panXRate": 0.4, "panYRate": 0 }, + "fincher": { "startScale": 1.0, "scaleRate": 0.4, "panXRate": 0, "panYRate": 0 } + } } } } diff --git a/accounts/军事账号/account.json b/accounts/军事账号/account.json index 16bb46e..6429eac 100644 --- a/accounts/军事账号/account.json +++ b/accounts/军事账号/account.json @@ -62,6 +62,15 @@ "kitano": { "name": "溶解", "duration": 400000 }, "fincher": { "name": "闪黑", "duration": 200000 } } + }, + "kenBurns": { + "enabled": true, + "default": { "startScale": 1.0, "scaleRate": 0.8, "panXRate": 0, "panYRate": 0 }, + "byDirector": { + "tarantino": { "startScale": 1.0, "scaleRate": 0.9, "panXRate": 0, "panYRate": -0.3 }, + "kitano": { "startScale": 1.03, "scaleRate": 0.5, "panXRate": 0.4, "panYRate": 0 }, + "fincher": { "startScale": 1.0, "scaleRate": 0.4, "panXRate": 0, "panYRate": 0 } + } } } }