Compare commits

...

4 Commits

Author SHA1 Message Date
0ecbe5f217 docs(video-pipeline): 用 DOT 图表替换账号创建流程中的 ASCII 流程图
更新 account-creation.md 文档,将纯文本流程图替换为可视化 DOT 图表,并添加数据注入全景图以清晰展示 Q&A 答案到输出文件的映射关系。
2026-05-01 17:06:55 +08:00
49537ea02f feat(video-pipeline): 添加 Ken Burns 镜头动画及账号创建流程优化
- 新增 Ken Burns 关键帧动画功能,为图片片段添加慢速缩放/平移效果
- 重构账号创建流程为10维度画像体系(定位→受众→内容气质→视觉基调→画风质感→色彩体系→镜头运动→声音身份→文字呈现→节奏律动)
- 新增爆款潜力自检机制,在汇总确认前检查差异化定位、情绪价值等指标
- 优化转场策略,基于累计时长间隔添加转场动画
- 改进 OSS 上传功能,支持并发上传
- 添加 BGM 淡入淡出效果(0.5s淡入/1s淡出)
- 新增 Hook 策略偏好(结论前置/认知冲突/身份挑衅等)和目标情绪回路字段
- 更新模板 JSON 配置,集成 kenBurns 字段
2026-05-01 17:01:39 +08:00
4913e74c05 fix(video-pipeline): 修复转场函数调用时缺少分镜数组参数 2026-05-01 16:12:11 +08:00
74680c5ea6 feat(video-pipeline): 重构转场与字幕系统,提取公用辅助函数
- 优化转场策略:仅首尾段跳过转场,rhythm模式下支持directorRef和keyword驱动的动态转场
- 提取 `applyAnimationProps` 和 `distributeSegments` 公用辅助函数,减少重复代码
- 支持按导演风格 (`tarantino`/`kitano`/`fincher`) 自动选择入场动画
- 重构字幕与配音段的时长分配逻辑,提高精确性
- 修复多处 `writeFileSync` 为 `saveManifest` 以统一清单写入入口
2026-05-01 16:12:03 +08:00
5 changed files with 472 additions and 146 deletions

View File

@@ -1,28 +1,37 @@
# 账号创建流程
> Agent 通过结构化问答收集信息,基于通用 prompt 模板 + 风格注入,生成完整的账号配置。
>
> **10 维度账号画像**:账号定位 → 目标受众 → 内容气质 → 视觉基调 → 画风质感 → 色彩体系 → 镜头运动 → 声音身份 → 文字呈现 → 节奏律动
---
## 创建流程
```
用户说"创建账号"/"新账号"
Phase 1: 账号身份(必须)
Phase 2: 内容 DNA必须
Phase 3: 视觉身份必须3种输入方式
Phase 4: 技术配置(有默认值,可跳过)
汇总确认 → 生成文件 → 校验
```dot
digraph creation_flow {
rankdir=TB
node [shape=box style=rounded fontsize=11]
edge [arrowsize=0.7]
start [label="\"创建账号\"/\"新账号\"" shape=ellipse style=filled fillcolor="#E3F2FD"]
p1 [label="Phase 1\n账号定位必须"]
p2 [label="Phase 2\n内容DNA必须"]
p3 [label="Phase 3\n视觉身份必须"]
p4 [label="Phase 4\n成片风格可跳过" style=filled fillcolor="#E8F5E9"]
check [label="爆款自检" shape=diamond style=filled fillcolor="#FFF3E0"]
confirm [label="汇总确认"]
gen [label="生成文件"]
val [label="校验"]
start -> p1 -> p2 -> p3 -> p4
p4 -> check -> confirm -> gen -> val
}
```
---
## Phase 1账号身份
## Phase 1账号定位
| # | 问题 | 必填 | 说明 |
|---|------|------|------|
@@ -40,10 +49,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 +96,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 +212,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}
确认 "开始" → 创建账号
修改 → 调整后重新输出
@@ -192,7 +282,51 @@ Phase 4: 技术配置(有默认值,可跳过)
### 文件创建
确认后Agent 按以下顺序生成
确认后Agent 按以下顺序生成
**数据注入全景图**
```dot
digraph injection {
rankdir=LR
node [shape=box fontsize=10]
subgraph cluster_qa {
label="Q&A 收集"
style=dashed
q12 [label="Q1-Q2\n定位"]
q3_56 [label="Q3-Q5.6\n内容DNA"]
q6_9 [label="Q6-Q9\n视觉身份"]
q10_11 [label="Q10-Q11\n镜头运动"]
q12_14 [label="Q12-Q14\n声音"]
q15_16 [label="Q15-Q16\n文字"]
q17_22 [label="Q17-Q22\n节奏"]
}
subgraph cluster_out {
label="输出文件"
style=dashed
account [label="account.json"]
storyboard [label="分镜.md"]
image [label="图片提示词.md"]
video [label="视频提示词.md"]
}
q12 -> account
q3_56 -> storyboard
q6_9 -> storyboard
q6_9 -> image
q10_11 -> video
q10_11 -> account [label="kenBurns" style=dashed]
q12_14 -> account
q15_16 -> account
q17_22 -> account
}
```
> 每个 Q 答案都有明确的消费者文件,无死数据。
**生成步骤**
1. **创建目录结构**
```
@@ -208,14 +342,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 写法规范、输入输出格式、质量自检

View File

@@ -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/

View File

@@ -17,7 +17,7 @@ const path = require('path')
const fs = require('fs')
const { execFile } = require('child_process')
const { syncDraft, registerDraft, triggerDirectoryScan } = require('./sync-to-jianying')
const { splitTextIntoSentences } = require('./lib/pipeline-utils')
const { splitTextIntoSentences, loadAccountConfig: loadAccountConfigFromUtils, saveManifest } = require('./lib/pipeline-utils')
// ============================================================================
// 配置
@@ -113,15 +113,18 @@ async function uploadToOSS(filePath) {
// 转场选择策略
// ============================================================================
function getTransition(item, index, totalCount, transitionConfig) {
function getTransition(item, index, totalCount, transitionConfig, allItems, timeline) {
// 无配置 → 不加转场
if (!transitionConfig) return { name: '', duration: 0 }
const defaultT = transitionConfig.default || { name: '闪白', duration: 150000 }
const strategy = transitionConfig.strategy || 'fixed'
// 前两段不加转场(避免开头黑屏/闪烁)
if (index <= 1) return { name: '', duration: 0 }
// 第一段不加转场(避免开头黑屏/闪烁)
if (index === 0) return { name: '', duration: 0 }
// 最后一段不需要转场(没有下一段可过渡)
if (index >= totalCount - 1) return { name: '', duration: 0 }
switch (strategy) {
case 'director': {
@@ -132,10 +135,39 @@ function getTransition(item, index, totalCount, transitionConfig) {
}
case 'rhythm': {
// 按位置选择转场:只在 keypoint / closing 加转场,其余不加
// 按位置选择转场:hook/keypoint/closing 加转场,body 段按累计时长间隔
const rules = transitionConfig.byPosition || {}
// 收尾段
if (index >= totalCount - 2) return rules.closing || defaultT
if (index % 4 === 0) return rules.keypoint || defaultT
// 内容结构变化检测directorRef 变化处、keyword 标记处加转场
if (allItems && index > 0) {
const prev = allItems[index - 1]
if (item.directorRef && prev.directorRef && item.directorRef !== prev.directorRef) {
return rules.keypoint || defaultT
}
if (item.keyword && !prev.keyword) {
return rules.keypoint || 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 }
}
@@ -145,18 +177,24 @@ function getTransition(item, index, totalCount, transitionConfig) {
}
}
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
}
@@ -216,7 +254,7 @@ async function assemble(args) {
format = '9:16',
apiKey = '',
duration = '4',
animation = '缩放',
animation = '轻微放大',
} = args
if (!input) throw new Error('缺少 --input 参数')
@@ -302,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
@@ -338,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 调速(在上传前,避免传两份)
@@ -375,8 +414,8 @@ async function assemble(args) {
if (manifestFile) {
try {
const m = JSON.parse(fs.readFileSync(manifestFile, 'utf-8'))
const mi = m.items.find(i => i.id === item.id || i.script === item.script || i.text === item.text)
if (mi) { mi.videoUrl = url; fs.writeFileSync(manifestFile, JSON.stringify(m, null, 2)) }
const mi = m.items.find(i => i.id === item.id)
if (mi) { mi.videoUrl = url; saveManifest(manifestFile, m) }
} catch (_) {}
}
} catch (err) {
@@ -387,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) {
@@ -411,7 +456,7 @@ async function assemble(args) {
}
}
}
if (changed) fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2))
if (changed) saveManifest(manifestFile, manifest)
}
} catch (err) {
console.log(` OSS 上传失败,将尝试本地路径: ${err.message}\n`)
@@ -512,14 +557,24 @@ async function assemble(args) {
// 添加图片(自动上传到 OSS
// ============================================================================
// 按 directorRef 映射不同入场动画IntroType一次性不循环不晕
const DIRECTOR_ANIMATIONS = {
tarantino: '动感放大', // 强冲击感
kitano: '轻微放大', // 静谧微推进
fincher: '渐显', // 克制、精确、不露痕迹
}
function getAnimationForDirector(directorRef, defaultAnimation) {
if (!directorRef) return defaultAnimation
return DIRECTOR_ANIMATIONS[directorRef.toLowerCase()] || defaultAnimation
}
async function addImages(draftUrl, items, imgUrls, timeline, width, height, animation = '', transitionConfig = null) {
const imageInfos = items.map((item, i) => {
const url = imgUrls[item.file]
if (!url) throw new Error(`图片 ${item.file} 未上传成功,无法添加`)
const tl = timeline[i]
const t = getTransition(item, i, items.length, transitionConfig)
// animation 解析:支持 "缩放" (group), "放大" (in), "渐显+缩小" (in+out)
const t = getTransition(item, i, items.length, transitionConfig, items, timeline)
const info = {
image_url: url,
width,
@@ -531,12 +586,13 @@ async function addImages(draftUrl, items, imgUrls, timeline, width, height, anim
transition_duration: t.duration,
}
// animation 解析:循环动画 → loop_animation其余 → in_animation
// animation 解析:按 directorRef 选择风格,循环动画 → loop_animation
// GroupAnimationType循环缩放, 缩放 II, 回弹伸缩, 旋转伸缩, ...
// IntroType入场渐显, 放大, 缩小, 向右滑动, 轻微放大, ...
// OutroType出场渐隐, 缩小, 放大, 向左滑动, ...
if (animation) {
const parts = animation.split('+').map(p => p.trim()).filter(Boolean)
const itemAnimation = getAnimationForDirector(item.directorRef, animation)
if (itemAnimation) {
const parts = itemAnimation.split('+').map(p => p.trim()).filter(Boolean)
const loopNames = ['缩放', '缩放 II', '回弹伸缩', '旋转伸缩']
const loopAnims = parts.filter(p => loopNames.includes(p))
const inAnims = parts.filter(p => !loopNames.includes(p))
@@ -561,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%/秒)× 时长 = 实际 endScaleclamp 上限
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 读取时长)
// ============================================================================
@@ -594,8 +741,8 @@ async function adjustVideoSpeed(videoPath, targetDurationSec) {
const atempo = Math.min(speed, 2.0) // atempo 单次上限 2.0
execFile('ffmpeg', [
'-y', '-i', videoPath,
'-filter_complex', `setpts=PTS/${speed}${videoPath.endsWith('.mp4') ? `,atempo=${atempo}` : ''}`,
'-an', // 去掉音频(视频模式不需要原始音轨)
'-filter_complex', `setpts=PTS/${speed}`,
'-an',
outPath
], { timeout: 30000 }, (err) => {
if (err) { console.log(` 调速失败,使用原始视频: ${err.message}`); resolve(videoPath); return }
@@ -622,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)
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,
@@ -750,37 +897,28 @@ async function addVoiceover(draftUrl, inputDir, items, timeline, audioUrls = {})
if (segments) {
// 多段音频:按 segment 逐段添加,使用精确时长
const totalSegDur = segments.reduce((sum, s) => sum + s.duration * US, 0)
const tlDuration = tl.end - tl.start
let currentTime = tl.start
for (let j = 0; j < segments.length; j++) {
const seg = segments[j]
const segDurUs = Math.round(seg.duration * US)
let duration = Math.round(tlDuration * (segDurUs / totalSegDur))
if (j === segments.length - 1) duration = tl.end - currentTime
duration = Math.max(duration, 100000)
const audioUrl = resolveAudio(seg.audio)
const slots = distributeSegments(tl, segments)
for (const slot of slots) {
const audioUrl = resolveAudio(slot.audio)
audioInfos.push({
audio_url: audioUrl,
start: currentTime,
end: currentTime + duration,
duration,
start: slot.start,
end: slot.end,
duration: slot.duration,
volume: 1.0,
})
currentTime += duration
}
} 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,
})
}
@@ -813,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([{
@@ -821,21 +962,46 @@ 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)`)
}
// ============================================================================
// 读取账号配置
// ============================================================================
// 动画属性注入DRY helper
function applyAnimationProps(cap, style = {}) {
if (style.inAnimation) cap.in_animation = style.inAnimation
if (style.outAnimation) cap.out_animation = style.outAnimation
if (style.inAnimDuration) cap.in_animation_duration = style.inAnimDuration
if (style.outAnimDuration) cap.out_animation_duration = style.outAnimDuration
}
// segments 按比例分配到时间线DRY helper
function distributeSegments(tl, segments) {
const totalSegDur = segments.reduce((sum, s) => sum + (s.duration || 0) * US, 0)
if (totalSegDur <= 0) return []
const tlDuration = tl.end - tl.start
let currentTime = tl.start
return segments.map((seg, idx) => {
const segDurUs = Math.round((seg.duration || 0) * US)
let duration = Math.round(tlDuration * (segDurUs / totalSegDur))
if (idx === segments.length - 1) duration = tl.end - currentTime
duration = Math.max(duration, 100000)
const entry = { start: currentTime, end: currentTime + duration, duration, text: seg.text, audio: seg.audio }
currentTime += duration
return entry
})
}
function loadAccountConfig(manifest) {
const account = manifest.account
if (!account) return {}
const accountFile = path.join(__dirname, '..', '..', '..', '..', 'accounts', account, 'account.json')
if (!fs.existsSync(accountFile)) return {}
try { return JSON.parse(fs.readFileSync(accountFile, 'utf-8')) } catch { return {} }
try { return loadAccountConfigFromUtils(account) } catch { return {} }
}
function loadSubtitleStyle(manifest) {
@@ -868,10 +1034,7 @@ async function addKeywordOverlays(draftUrl, items, timeline, style = {}) {
end: tl.end,
text: item.keyword,
}
if (style.inAnimation) cap.in_animation = style.inAnimation
if (style.outAnimation) cap.out_animation = style.outAnimation
if (style.inAnimDuration) cap.in_animation_duration = style.inAnimDuration
if (style.outAnimDuration) cap.out_animation_duration = style.outAnimDuration
applyAnimationProps(cap, style)
captions.push(cap)
}
@@ -915,10 +1078,12 @@ async function addSubtitles(draftUrl, items, timeline, style = {}, split = false
const captions = []
// 从账号配置读取动画参数
const inAnimation = style.inAnimation || ''
const outAnimation = style.outAnimation || ''
const inAnimDuration = style.inAnimationDuration || null
const outAnimDuration = style.outAnimationDuration || null
const animStyle = {
inAnimation: style.inAnimation || '',
outAnimation: style.outAnimation || '',
inAnimDuration: style.inAnimationDuration || null,
outAnimDuration: style.outAnimationDuration || null,
}
for (let i = 0; i < items.length; i++) {
const item = items[i]
@@ -933,33 +1098,13 @@ async function addSubtitles(draftUrl, items, timeline, style = {}, split = false
if (segments) {
// 精确模式:用 segments 的实际音频时长
const totalSegDur = segments.reduce((sum, s) => sum + s.duration * US, 0)
const tlDuration = tl.end - tl.start
let currentTime = tl.start
segments.forEach((seg, idx) => {
const segDurUs = Math.round(seg.duration * US)
// 按实际时长占比映射到时间线(处理 ffprobe 重新测量的差异)
let duration = Math.round(tlDuration * (segDurUs / totalSegDur))
if (idx === segments.length - 1) {
duration = tl.end - currentTime
}
duration = Math.max(duration, 1000000)
const cap = {
start: currentTime,
end: currentTime + duration,
text: seg.text,
}
if (inAnimation) cap.in_animation = inAnimation
if (outAnimation) cap.out_animation = outAnimation
if (inAnimDuration) cap.in_animation_duration = inAnimDuration
if (outAnimDuration) cap.out_animation_duration = outAnimDuration
const slots = distributeSegments(tl, segments)
for (const slot of slots) {
const cap = { start: slot.start, end: slot.end, text: slot.text }
applyAnimationProps(cap, animStyle)
captions.push(cap)
currentTime += duration
})
}
} else {
// 回退:字数权重估算
const sentences = splitTextIntoSentences(text)
@@ -977,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,
@@ -985,11 +1130,7 @@ async function addSubtitles(draftUrl, items, timeline, style = {}, split = false
text: sentence,
}
if (inAnimation) cap.in_animation = inAnimation
if (outAnimation) cap.out_animation = outAnimation
if (inAnimDuration) cap.in_animation_duration = inAnimDuration
if (outAnimDuration) cap.out_animation_duration = outAnimDuration
applyAnimationProps(cap, animStyle)
captions.push(cap)
currentTime += duration
})
@@ -1002,11 +1143,7 @@ async function addSubtitles(draftUrl, items, timeline, style = {}, split = false
text,
}
if (inAnimation) cap.in_animation = inAnimation
if (outAnimation) cap.out_animation = outAnimation
if (inAnimDuration) cap.in_animation_duration = inAnimDuration
if (outAnimDuration) cap.out_animation_duration = outAnimDuration
applyAnimationProps(cap, animStyle)
captions.push(cap)
}
}
@@ -1043,7 +1180,7 @@ async function addSubtitles(draftUrl, items, timeline, style = {}, split = false
style_text: 0,
})
console.log(` 已添加 ${captions.length} 条字幕${split ? ' (分句模式)' : ''} (字体: ${style.font || '默认'}, 动画: ${inAnimation || '无'}${outAnimation || '无'})`)
console.log(` 已添加 ${captions.length} 条字幕${split ? ' (分句模式)' : ''} (字体: ${style.font || '默认'}, 动画: ${animStyle.inAnimation || '无'}${animStyle.outAnimation || '无'})`)
}
// ============================================================================

View File

@@ -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 }
}
}
}
}

View File

@@ -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 }
}
}
}
}