Compare commits

...

3 Commits

Author SHA1 Message Date
ac753ef367 feat(video-pipeline): 优化子 Agent 模板交互并新增模板路径工具
- 重构 SKILL.md,要求子 Agent 直接读取模板文件而非由主 Agent 摘要传送
- 新增 get-template-path.js 脚本,支持按账号和类型获取模板文件绝对路径
- 移除 capcut_assemble.js 中的关键字氛围词功能及相关依赖
2026-05-02 01:18:30 +08:00
4d5c8cb96d refactor(video-pipeline): 将 CapCut 组装脚本拆分为模块化架构
将 monolith 的 capcut_assemble.js 重构为核心编排器,提取基础设施层(capcut-api)、时间线算法(capcut-timeline)和轨道操作(capcut-tracks)为独立模块。此拆分使 Agent 未来对字幕风格、Ken Burns、转场、特效等调整只需关注 capcut-tracks.js,无需理解全流程编排逻辑。
2026-05-02 00:27:54 +08:00
0998fd6ae1 feat(video-pipeline): 重构视频流水线,优化成片时间线规则和状态管理
- 引入 manifest.json 作为唯一状态源,所有子 Agent 操作回写 manifest
- 重构 timebuilder 逻辑,支持四种视频适配策略(加速/裁剪/放缓/画面停顿)
- 统一 TTS 阶段输出结构,单句和多句均写入 segments[]
- 重写字幕和配音生成,基于 segments 精确时长实现音画同步
- 新增 confirm 命令支持按 id 范围确认,上传阶段分离图片和视频
- 添加中间产物写入 output/ 目录的约束,清理废弃配置参数
2026-05-02 00:14:40 +08:00
23 changed files with 1590 additions and 1054 deletions

View File

@@ -1,6 +1,6 @@
{
"jianyingDraftPath": "/Users/lc/Movies/JianyingPro/User Data/Projects/com.lveditor.draft",
"capcutMateDir": "/Users/lc/capcut-mate",
"jianyingDraftPath": "C:/Users/45070/AppData/Local/JianyingPro/User Data/Projects/com.lveditor.draft",
"capcutMateDir": "C:/Users/45070/capcut-mate",
"capcutMateApiBase": "http://capcut.muyetools.cn/openapi/capcut-mate/v1",
"imgbbApiKey": "deprecated",
"geminiApiBaseUrl": "https://yunwu.ai",

View File

@@ -35,14 +35,15 @@ B 模式又分两种:**单图模式**1 图 → 1 段视频)/ **首尾帧
### 核心约束
1. **不可跳步**
- A幻灯片分镜 → 图片提示词 → 生图 → TTS+成片。无视频阶段
- BAI视频分镜 → 图片提示词 → 生图 → 视频提示词 → 生视频 → TTS+成片
- A幻灯片分镜 → manifest init → 图片提示词 → 生图 → TTS+成片。无视频阶段
- BAI视频分镜 → manifest init → 图片提示词 → 生图 → 视频提示词 → 生视频 → TTS+成片
- 阶段之间必须审查
2. **manifest.json 是唯一状态源**任何操作完成后立即回写
2. **manifest.json 是唯一状态源**`pipeline.js init` 在分镜确认后立即执行,创建 `output/{name}/` 目录和初始 manifest。后续所有子 Agent 输出回写此 manifest不再传裸 JSON
3. **禁止 curl 调 API**:生图/生视频必须通过 `pipeline.js` 或对应 generator 脚本
4. **并行优先**:独立子任务用子 Agent 并行
5. **分镜表是脊骨契约**:用户确认分镜表后,下游子 Agent 只能加字段,禁止改 shot 数量/顺序/字段值。主 Agent 每次接收子 Agent 输出,第一件事数数量是否对得上
6. **prompts/*.md 只被子 Agent 读**:主 Agent 读 account.json不读子 Agent 提示词模板
7. **中间产物落 output**所有中间文件items JSON、urls 缓存、子 Agent 输出)必须写入 `output/{name}/` 目录,禁止散落在项目根目录
### Step -1: 意图确认(逐项确认,缺一不可)
@@ -69,7 +70,21 @@ B 模式又分两种:**单图模式**1 图 → 1 段视频)/ **首尾帧
### Step 1: 分镜脚本(子 Agent 执行)
主 Agent 将**用户文案 + 分镜模板路径**给子 Agent → 子 Agent 按模板输出分镜表 JSON
主 Agent 获取账号专属模板路径 → 将**模板文件绝对路径 + 用户文案**给子 Agent → 子 Agent **自行 Read 模板文件全文** 按模板规则输出分镜表 JSON
**模板路径获取方式**
```bash
node .claude/skills/video-from-script/scripts/get-template-path.js --account <账号ID> --type storyboard
```
输出示例:`accounts\军事账号\prompts\分镜.md`
**子 Agent prompt 必须包含**
1. `模板文件绝对路径:{get-template-path.js 输出的路径,转为绝对路径}`,并指示子 Agent "先 Read 此文件全文,严格按模板规则执行"
2. 用户完整口播文案
3. 成片模式(图文/视频)
4. 输出格式要求JSON 数组)
**禁止**:主 Agent 不得摘要模板内容传给子 Agent必须让子 Agent 直接读文件。
```json
[{"id":1,"shotDesc":"英文画面描述","script":"中文口播文案","duration":5,"directorRef":"tarantino","keyword":"权力"}]
@@ -79,26 +94,33 @@ B 模式又分两种:**单图模式**1 图 → 1 段视频)/ **首尾帧
→ 展示给用户确认。确认后**分镜表锁定为脊骨契约**,下游禁止增减 shot。
### Step 2-A: 图片提示词(子 Agent 执行)
- 主 Agent 传**完整分镜表 JSON**(不传原始文案)+ 图片提示词模板路径给子 Agent
- 子 Agent 为每个 shot 追加 `imagePrompt` 字段:
- 入参来自分镜表shotDesc + script + directorRef + keyword
- 出参:分镜表 JSON + imagePrompt
- **硬约束:输出 shot 数量 == 输入 shot 数量**
**主 Agent 审查**:① 数量对得上?② shotDesc 内容完整保留?③ 光影策略对应 directorRef
### Step 2-B: 生图 + Manifest 初始化
### Step 2-0: Manifest 初始化
```bash
node scripts/pipeline.js init --account <id> --mode <single|framePair> \
--items '[{"shotDesc":"...","script":"...","duration":5,"imagePrompt":"...","directorRef":"tarantino","keyword":"权力"}]'
--items '[{"id":1,"shotDesc":"...","script":"...","duration":5,"directorRef":"tarantino","keyword":"权力"}]'
```
- items 不含 videoPrompt后续 Step 3-A 补充
- 分镜确认后立即执行,创建 `output/{name}/` 目录和初始 `manifest.json`
- 脚本从 account.json 继承imageModel、videoModel、format、references
- 首尾帧模式:每个 item 必须有 `lastFramePrompt`
- `imagePrompt` 暂为空Step 2-A 补充;`videoPrompt` 暂为空Step 3-A 补充
- 输出路径打印到控制台,后续所有操作以此为工作目录
### Step 2-A: 图片提示词(子 Agent 执行)
- 主 Agent 获取账号专属图片模板路径:`node .../get-template-path.js --account <账号ID> --type image`
- 将**模板文件绝对路径 + manifest 绝对路径**传给子 Agent
- 子 Agent **先 Read 模板文件全文**,再 Read manifest.json 的 items为每个 shot 追加 `imagePrompt` 字段后回写 manifest
- **硬约束:输出 shot 数量 == 输入 shot 数量**
**子 Agent prompt 必须包含**
1. `模板文件绝对路径:{get-template-path.js 输出的路径,转为绝对路径}`,并指示 "先 Read 此文件全文,严格按模板规则执行"
2. `manifest 绝对路径`,指示 "Read manifest.json 的 items 数组,为每个 item 生成 imagePrompt 后回写"
3. **禁止**:主 Agent 不得摘要模板内容传给子 Agent
**主 Agent 审查**:① 数量对得上?② shotDesc 内容完整保留?③ 光影策略对应 directorRef
### Step 2-B: 生图
```bash
node scripts/pipeline.js run --manifest <path> --phase images
@@ -111,12 +133,15 @@ node scripts/pipeline.js run --manifest <path> --phase images
### Step 3-A: 视频提示词B 模式专属,子 Agent 执行)
- 主 Agent 传分镜表 JSON含已确认分镜图路径+ 视频提示词模板路径给子 Agent
- 子 Agent 为每个 shot 生成 `videoPrompt`
- 入参shotDesc + directorRef + 已确认分镜图 + 目标模型
- 出参videoPrompt描述镜头运动非画面内容
- 主 Agent 获取账号专属视频模板路径:`node .../get-template-path.js --account <账号ID> --type video`
- 将**模板文件绝对路径 + manifest 绝对路径**传给子 Agent
- 子 Agent **先 Read 模板文件全文**,再 Read manifest.items含已确认分镜图路径为每个 shot 生成 `videoPrompt` 后回写 manifest
- **硬约束:输出数量 == 分镜表 shot 数量**
- Agent 按 id 对齐回写 manifest.json
**子 Agent prompt 必须包含**
1. `模板文件绝对路径:{get-template-path.js 输出的路径,转为绝对路径}`,并指示 "先 Read 此文件全文,严格按模板规则执行"
2. `manifest 绝对路径`,指示 "Read manifest.json 的 items 数组,为每个 item 生成 videoPrompt 后回写"
3. **禁止**:主 Agent 不得摘要模板内容传给子 Agent
**主 Agent 审查**:① 数量对得上?② 描述运动而非内容?③ 字数 ≤ 50

View File

@@ -9,9 +9,9 @@
## 创建方式
```bash
# Step 2-A 生成 imagePrompt 后,通过脚本初始化(不含 videoPrompt
# Step 2-0分镜确认后立即初始化imagePrompt/videoPrompt 后续补充
node scripts/pipeline.js init --account 军事账号 --mode single \
--items '[{"shotDesc":"英文画面描述","script":"中文口播文案","duration":5,"imagePrompt":"English prompt","directorRef":"tarantino","keyword":"权力"}]'
--items '[{"shotDesc":"英文画面描述","script":"中文口播文案","duration":5,"directorRef":"tarantino","keyword":"权力"}]'
# 或从文件读取
node scripts/pipeline.js init --account 军事账号 --mode single --items-file ./items.json
@@ -193,7 +193,7 @@ node scripts/pipeline.js run --manifest <path> --retry-failed
## 目录结构
```
output/{account}_{YYYYMMDD}_{NNN}/
output/{name}_{YYYYMMDD}_{NNN}/
├── manifest.json # 主清单
├── images/ # scene_{NN}_{slug}.jpeg首尾帧加 _lastMJ 候选加 _cand{1-4}
├── videos/ # scene_{NN}_{slug}.mp4
@@ -206,7 +206,7 @@ slug 从 `shotDesc` 派生slugify: 保留中文和字母数字,最多 20
## segments[] 字段TTS 分句)
TTS 阶段自动生成。仅当 `script` 被切分为 2 句及以上时才写入。单句时不写 segments
TTS 阶段统一生成,单句时数组仅 1 个元素,多句时 N 个元素。assemble 阶段直接使用各 segment 的实际音频时长对齐字幕
| 字段 | 说明 |
|------|------|
@@ -214,4 +214,26 @@ TTS 阶段自动生成。仅当 `script` 被切分为 2 句及以上时才写入
| `audio` | 该句音频路径(相对 manifest |
| `duration` | 该句音频时长(秒) |
`item.audio` 指向所有分段合并后的完整音频`item.audioDuration` 为各段累计时长。assemble 阶段优先用 `segments` 的精确时长对齐字幕,无 segments 时回退到字数权重估算。
`item.audio` 指向 `segments[0].audio``item.audioDuration` 为各段累计时长。assemble 阶段遍历 segments 逐一添加音频和字幕,使用实际文件时长(非比例分配),确保音频与字幕精确同步,消除留白。
---
## 成片时间线规则
### 图片模式images
图片没有独立时长。TTS 音频时长 = 画面时长。无 TTS 音频的 item 时长为 0跳过不显示
### 视频模式videos
TTS 音频为主轴,视频通过以下策略适配音频时长:
| ratio = videoDur/audioDur | 策略 | 说明 |
|---------------------------|------|------|
| 0.9 ~ 1.1 | none | 接近匹配,无需调整 |
| > 1.1, ≤ 2 | speed_up | 加速setpts 压缩时间) |
| > 2 | trim | 裁剪(截断到音频时长) |
| < 0.9, ≥ 0.5 | slow_down | 放缓setpts 拉长时间) |
| < 0.5 | freeze | 画面停顿(视频原速 + 最后一帧冻结补时长) |
所有策略失败后兜底:截断到目标时长。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env node
/**
* 获取账号专属模板文件的完整路径
*
* 用法:
* node get-template-path.js --account 军事账号 --type storyboard
* node get-template-path.js --account 军事账号 --type image
* node get-template-path.js --account 军事账号 --type video
*
* 输出:
* 完整的绝对路径,可直接传给 Read 工具或 fs.readFileSync
*/
const fs = require('fs')
const path = require('path')
// 路径常量
const SCRIPTS_DIR = path.join(__dirname, '..')
const SKILLS_DIR = path.join(SCRIPTS_DIR, '..')
const PROJECT_ROOT = path.join(SKILLS_DIR, '..', '..')
const ACCOUNTS_DIR = path.join(PROJECT_ROOT, 'accounts')
function getTemplatePath(accountId, templateType) {
// 1. 读取账号配置
const accountPath = path.join(ACCOUNTS_DIR, accountId, 'account.json')
if (!fs.existsSync(accountPath)) {
throw new Error(`账号不存在: ${accountPath}`)
}
const account = JSON.parse(fs.readFileSync(accountPath, 'utf-8'))
// 2. 映射模板类型到字段名
const fieldMap = {
storyboard: 'storyboardPrompt',
image: 'imageStylePrompt',
video: 'videoStylePrompt'
}
const fieldName = fieldMap[templateType]
if (!fieldName) {
throw new Error(`未知模板类型: ${templateType},支持: storyboard, image, video`)
}
// 3. 获取相对路径
const relativePath = account[fieldName]
if (!relativePath) {
throw new Error(`账号配置缺少字段: ${fieldName}`)
}
// 4. 判断是否为绝对路径
let absolutePath
if (path.isAbsolute(relativePath)) {
// 已经是绝对路径,直接使用
absolutePath = relativePath
} else {
// 相对路径,拼接账号目录(相对于账号目录)
absolutePath = path.join(ACCOUNTS_DIR, accountId, relativePath)
}
// 5. 检查文件是否存在
if (!fs.existsSync(absolutePath)) {
throw new Error(`模板文件不存在: ${absolutePath}`)
}
// 6. 转换为相对于项目根目录的相对路径(子 Agent 友好)
const relativeToProject = path.relative(PROJECT_ROOT, absolutePath)
return relativeToProject
}
// ============================================================================
// CLI 入口
// ============================================================================
function main() {
const args = process.argv.slice(2)
const accountIndex = args.indexOf('--account')
const typeIndex = args.indexOf('--type')
if (accountIndex === -1 || typeIndex === -1) {
console.error('用法node get-template-path.js --account <账号ID> --type <storyboard|image|video>')
process.exit(1)
}
const accountId = args[accountIndex + 1]
const templateType = args[typeIndex + 1]
try {
const fullPath = getTemplatePath(accountId, templateType)
console.log(fullPath)
} catch (err) {
console.error(`错误: ${err.message}`)
process.exit(1)
}
}
if (require.main === module) {
main()
}
module.exports = { getTemplatePath }

View File

@@ -0,0 +1,94 @@
/**
* CapCut API 基础设施层
*
* 提供: 配置加载、API 封装、CLI 解析、工具函数
* 无业务逻辑,纯基础设施。
*/
const axios = require('axios')
const path = require('path')
const fs = require('fs')
const { execFile } = require('child_process')
const US = 1_000_000
let _config = null
function getConfig() {
if (_config) return _config
const configPath = path.join(__dirname, '..', '..', '..', 'config.json')
if (!fs.existsSync(configPath)) {
console.error('缺少配置文件: skills/config.json')
console.error('请运行 node setup.js 生成配置')
process.exit(1)
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
if (!config.jianyingDraftPath || !config.capcutMateDir || !config.capcutMateApiBase) {
console.error('config.json 需要填写 jianyingDraftPath、capcutMateDir 和 capcutMateApiBase')
process.exit(1)
}
_config = config
return _config
}
const BASE_URL = getConfig().capcutMateApiBase
async function api(endpoint, data = {}, timeout = 60000) {
const url = `${BASE_URL}/${endpoint}`
const method = endpoint === 'get_draft' ? 'get' : 'post'
try {
const res = method === 'get'
? await axios.get(url, { params: data, timeout })
: await axios.post(url, data, { timeout })
if (res.data.code !== undefined && res.data.code !== 0) {
throw new Error(`API [${endpoint}] 返回错误: ${res.data.message}`)
}
return res.data
} catch (err) {
if (err.response) {
throw new Error(`API [${endpoint}] HTTP ${err.response.status}: ${JSON.stringify(err.response.data)}`)
}
throw err
}
}
function parseArgs(argv) {
const args = {}
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith('--')) {
const key = argv[i].slice(2)
const value = argv[i + 1]
if (value && !value.startsWith('--')) {
args[key] = value
i++
} else {
args[key] = true
}
}
}
return args
}
function getResolution(format) {
const map = {
'9:16': { width: 1080, height: 1920 },
'16:9': { width: 1920, height: 1080 },
'1:1': { width: 1080, height: 1080 },
'4:3': { width: 1440, height: 1080 },
}
return map[format] || map['9:16']
}
function getAudioDurationSec(filePath) {
return new Promise((resolve) => {
execFile('ffprobe', [
'-v', 'quiet', '-show_entries', 'format=duration',
'-of', 'csv=p=0', filePath
], (err, stdout) => {
if (err) { resolve(null); return }
const dur = parseFloat(stdout.trim())
resolve(dur > 0 ? dur : null)
})
})
}
module.exports = { US, getConfig, BASE_URL, api, parseArgs, getResolution, getAudioDurationSec }

View File

@@ -0,0 +1,229 @@
/**
* 时间线构建 + 视频调整策略
*
* 核心算法模块。纯函数 + ffmpeg自包含可测试。
*
* 规则:
* 图片模式: TTS 音频时长 = 画面时长,无音频 = 跳过
* 视频模式: TTS 为主轴,视频通过策略适配
* 视频比音频长 → 加速(≤2x) / 裁剪(>2x)
* 视频比音频短 → 放缓(≥0.5x) / 画面停顿(<0.5x)
* 所有策略失败 → 兜底截断
*/
const fs = require('fs')
const path = require('path')
const { execFile } = require('child_process')
const { US } = require('./capcut-api')
// ============================================================================
// 时间线构建
// ============================================================================
function buildTimeline(items) {
let offset = 0
return items.map(item => {
let audioDur
if (item.segments && item.segments.length > 0) {
audioDur = item.segments.reduce((sum, s) => sum + (s.duration || 0), 0) * US
} else {
audioDur = (item.audioDuration != null) ? item.audioDuration * US : 0
}
const videoDur = (item.videoDuration != null) ? item.videoDuration * US : 0
const hasVideo = !!(item.video || item.videoUrl || item.url)
// 无 TTS 音频
if (audioDur <= 0) {
if (hasVideo && videoDur > 0) {
const entry = { start: offset, end: offset + videoDur, duration: videoDur, speed: 1, strategy: 'none' }
offset += videoDur
return entry
}
const entry = { start: offset, end: offset, duration: 0, speed: 1, strategy: 'none', skip: true }
return entry
}
const dur = audioDur
if (!hasVideo || videoDur <= 0) {
const entry = { start: offset, end: offset + dur, duration: dur, speed: 1, strategy: 'none' }
offset += dur
return entry
}
// 视频模式:策略选择
const ratio = videoDur / audioDur
if (ratio > 1.1) {
if (ratio <= 2) {
const entry = { start: offset, end: offset + dur, duration: dur, speed: ratio, strategy: 'speed_up' }
offset += dur
return entry
} else {
const entry = { start: offset, end: offset + dur, duration: dur, speed: 1, strategy: 'trim' }
offset += dur
return entry
}
} else if (ratio < 0.9) {
if (ratio >= 0.5) {
const entry = { start: offset, end: offset + dur, duration: dur, speed: ratio, strategy: 'slow_down' }
offset += dur
return entry
} else {
const entry = {
start: offset, end: offset + dur, duration: dur, speed: 1,
strategy: 'freeze', freezeExtra: dur - videoDur,
}
offset += dur
return entry
}
} else {
const entry = { start: offset, end: offset + dur, duration: dur, speed: 1, strategy: 'none' }
offset += dur
return entry
}
})
}
// ============================================================================
// 视频调整ffmpeg 策略)
// ============================================================================
/**
* ffmpeg 视频调整:根据策略适配音频时长
*
* 策略(按 ratio = videoDur / audioDur 选择):
* speed_up (ratio > 1.1, ≤2x) → setpts 压缩时间(加速)
* trim (ratio > 2x) → 截断到目标时长
* slow_down (ratio < 0.9, ≥0.5x) → setpts 拉长时间(慢放)
* freeze (ratio < 0.5x) → 视频原速 + 最后一帧冻结补时长
* none (0.9~1.1) → 无需调整
*
* 所有策略失败后兜底:截断到目标时长
*/
async function adjustVideoSpeed(videoPath, targetDurationSec, strategy = 'none', speed = 1, freezeExtraUs = 0) {
if (!fs.existsSync(videoPath)) return videoPath
if (strategy === 'none') return videoPath
function fallbackTrim(cb) {
execFile('ffmpeg', [
'-y', '-i', videoPath,
'-t', String(targetDurationSec),
'-c', 'copy',
videoPath.replace(/(\.\w+)$/, '_adj$1')
], { timeout: 30000 }, (err) => {
if (err) { cb(videoPath); return }
cb(videoPath.replace(/(\.\w+)$/, '_adj$1'))
})
}
return new Promise((resolve) => {
execFile('ffprobe', [
'-v', 'quiet', '-show_entries', 'format=duration',
'-of', 'csv=p=0', videoPath
], (err, stdout) => {
if (err) { fallbackTrim(resolve); return }
const videoDur = parseFloat(stdout.trim())
if (!videoDur || videoDur <= 0) { fallbackTrim(resolve); return }
const outPath = videoPath.replace(/(\.\w+)$/, '_adj$1')
if (strategy === 'trim') {
execFile('ffmpeg', [
'-y', '-i', videoPath,
'-t', String(targetDurationSec),
'-c', 'copy',
outPath
], { timeout: 30000 }, (err) => {
if (err) { console.log(` 截断失败: ${err.message}`); resolve(videoPath); return }
console.log(` 截断: ${videoDur.toFixed(1)}s → ${targetDurationSec.toFixed(1)}s`)
resolve(outPath)
})
} else if (strategy === 'speed_up') {
const speedVal = speed.toFixed(3)
execFile('ffmpeg', [
'-y', '-i', videoPath,
'-filter_complex', `setpts=PTS/${speedVal}`,
'-an',
outPath
], { timeout: 30000 }, (err) => {
if (err) {
console.log(` 加速失败,兜底截断: ${err.message}`)
fallbackTrim(resolve)
return
}
console.log(` 加速: ${videoDur.toFixed(1)}s → ${targetDurationSec.toFixed(1)}s (${speedVal}x)`)
resolve(outPath)
})
} else if (strategy === 'slow_down') {
const factor = (1 / speed).toFixed(3)
execFile('ffmpeg', [
'-y', '-i', videoPath,
'-filter_complex', `setpts=PTS*${factor}`,
'-an',
outPath
], { timeout: 30000 }, (err) => {
if (err) {
console.log(` 放缓失败,兜底截断: ${err.message}`)
fallbackTrim(resolve)
return
}
console.log(` 放缓: ${videoDur.toFixed(1)}s → ${targetDurationSec.toFixed(1)}s (${speed.toFixed(2)}x speed)`)
resolve(outPath)
})
} else if (strategy === 'freeze') {
const freezeSec = freezeExtraUs / US
execFile('ffmpeg', [
'-y', '-i', videoPath,
'-filter_complex', `tpad=stop=-1:stop_duration=${freezeSec.toFixed(3)}`,
'-an',
outPath
], { timeout: 30000 }, (err) => {
if (err) {
console.log(` tpad freeze 失败,尝试 concat 方案: ${err.message}`)
const lastFrame = videoPath.replace(/(\.\w+)$/, '_lastframe.png')
const frozenVideo = videoPath.replace(/(\.\w+)$/, '_frozen.mp4')
execFile('ffmpeg', [
'-y', '-sseof', '-0.1', '-i', videoPath,
'-frames:v', '1', lastFrame
], { timeout: 10000 }, (err2) => {
if (err2) { console.log(` concat 方案也失败,兜底截断`); fallbackTrim(resolve); return }
execFile('ffmpeg', [
'-y', '-loop', '1', '-i', lastFrame,
'-t', String(freezeSec.toFixed(3)),
'-pix_fmt', 'yuv420p',
'-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2',
frozenVideo
], { timeout: 15000 }, (err3) => {
if (err3) {
try { fs.unlinkSync(lastFrame) } catch (_) {}
console.log(` 冻结帧视频生成失败,兜底截断`)
fallbackTrim(resolve)
return
}
const concatList = path.join(path.dirname(videoPath), '_freeze_concat.txt')
fs.writeFileSync(concatList, `file '${videoPath}'\nfile '${frozenVideo}'\n`)
execFile('ffmpeg', [
'-y', '-f', 'concat', '-safe', '0', '-i', concatList,
'-c', 'copy', outPath
], { timeout: 30000 }, (err4) => {
try { fs.unlinkSync(lastFrame); fs.unlinkSync(frozenVideo); fs.unlinkSync(concatList) } catch (_) {}
if (err4) { console.log(` 拼接失败,兜底截断`); fallbackTrim(resolve); return }
console.log(` 画面停顿: ${videoDur.toFixed(1)}s + 冻结 ${freezeSec.toFixed(1)}s = ${targetDurationSec.toFixed(1)}s`)
resolve(outPath)
})
})
})
return
}
console.log(` 画面停顿: ${videoDur.toFixed(1)}s + 冻结 ${freezeSec.toFixed(1)}s = ${targetDurationSec.toFixed(1)}s`)
resolve(outPath)
})
} else {
resolve(videoPath)
}
})
})
}
module.exports = { buildTimeline, adjustVideoSpeed }

View File

@@ -0,0 +1,621 @@
/**
* CapCut 轨道操作
*
* 所有 add* 函数 + 转场策略 + 账号配置读取。
* Agent 修改字幕风格、Ken Burns、转场、特效等只需关注此文件。
*/
const path = require('path')
const { api, US } = require('./capcut-api')
const { splitTextIntoSentences, loadAccountConfig: loadAccountConfigFromUtils } = require('./pipeline-utils')
// ============================================================================
// 账号配置读取
// ============================================================================
function loadAccountConfig(manifest) {
const account = manifest.account
if (!account) return {}
try { return loadAccountConfigFromUtils(account) } catch { return {} }
}
function loadSubtitleStyle(manifest) {
return loadAccountConfig(manifest).capcut?.subtitleStyle || {}
}
function loadKeywordStyle(manifest) {
return loadAccountConfig(manifest).capcut?.keywordStyle || {}
}
function loadTransitions(manifest) {
return loadAccountConfig(manifest).capcut?.transitions || null
}
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
}
// ============================================================================
// 转场策略
// ============================================================================
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 === 0) return { name: '', duration: 0 }
if (index >= totalCount - 1) return { name: '', duration: 0 }
switch (strategy) {
case 'director': {
const ref = (item.directorRef || '').toLowerCase()
const byDirector = transitionConfig.byDirector || {}
return byDirector[ref] || defaultT
}
case 'rhythm': {
const rules = transitionConfig.byPosition || {}
if (index >= totalCount - 2) return rules.closing || defaultT
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
}
}
if (timeline && timeline.length > 0) {
const elapsed = timeline[index].start
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 {
if (index % 3 === 0) return rules.body || defaultT
}
return { name: '', duration: 0 }
}
case 'fixed':
default:
return defaultT
}
}
// ============================================================================
// 入场动画映射
// ============================================================================
const DIRECTOR_ANIMATIONS = {
tarantino: '动感放大',
kitano: '轻微放大',
fincher: '渐显',
}
function getAnimationForDirector(directorRef, defaultAnimation) {
if (!directorRef) return defaultAnimation
return DIRECTOR_ANIMATIONS[directorRef.toLowerCase()] || defaultAnimation
}
// ============================================================================
// Ken Burns 关键帧动画
// ============================================================================
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
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 },
)
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} 段)`)
}
// ============================================================================
// 添加图片
// ============================================================================
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, items, timeline)
const info = {
image_url: url,
width,
height,
start: tl.start,
end: tl.end,
duration: tl.duration,
transition: t.name,
transition_duration: t.duration,
}
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))
if (loopAnims.length > 0) info.loop_animation = loopAnims.join('|')
if (inAnims.length > 0) info.in_animation = inAnims.join('|')
}
return info
})
console.log(` 一次性添加 ${imageInfos.length} 张图片...`)
const res = await api('add_images', {
draft_url: draftUrl,
image_infos: JSON.stringify(imageInfos),
alpha: 1, scale_x: 1, scale_y: 1,
transform_x: 0, transform_y: 0,
}, 300000)
const allSegmentIds = res.segment_ids || []
console.log(` 已添加 ${items.length} 张图片`)
return allSegmentIds
}
// ============================================================================
// 添加视频
// ============================================================================
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, timeline)
return {
video_url: item.videoUrl || (item.video ? path.resolve(inputDir, item.video) : null) || item.url || path.resolve(inputDir, item.file),
width,
height,
start: tl.start,
end: tl.end,
duration: tl.duration,
mask: '',
transition: t.name,
transition_duration: t.duration,
volume: item.volume || 1,
}
})
try {
const res = await api('add_videos', {
draft_url: draftUrl,
video_infos: JSON.stringify(videoInfos),
alpha: 1, scale_x: 1, scale_y: 1,
transform_x: 0, transform_y: 0,
scene_timelines: [],
})
console.log(` 已添加 ${items.length} 个视频片段(全量)`)
return res.segment_ids || []
} catch (err) {
if (!err.message.includes('504') && !err.message.includes('timeout')) throw err
console.log(` 全量提交超时,降级为分批添加...`)
}
const BATCH_SIZE = 3
const allSegmentIds = []
for (let i = 0; i < videoInfos.length; i += BATCH_SIZE) {
const batch = videoInfos.slice(i, i + BATCH_SIZE)
const batchNum = Math.floor(i / BATCH_SIZE) + 1
const totalBatches = Math.ceil(videoInfos.length / BATCH_SIZE)
console.log(` 分批 [${batchNum}/${totalBatches}] 添加 ${batch.length} 个片段...`)
const res = await api('add_videos', {
draft_url: draftUrl,
video_infos: JSON.stringify(batch),
alpha: 1, scale_x: 1, scale_y: 1,
transform_x: 0, transform_y: 0,
scene_timelines: [],
})
if (res.segment_ids) allSegmentIds.push(...res.segment_ids)
}
console.log(` 已添加 ${items.length} 个视频片段(分批)`)
return allSegmentIds
}
// ============================================================================
// 添加 TTS 配音
// ============================================================================
async function addVoiceover(draftUrl, inputDir, items, timeline, audioUrls = {}) {
const audioItems = items.filter(item => item.audio || (item.segments && item.segments.length > 0))
if (audioItems.length === 0) {
console.log(' 无 TTS 音频文件,跳过')
return
}
const audioInfos = []
const resolveAudio = (relPath) => {
if (relPath.startsWith('http')) return relPath
if (audioUrls[relPath]) return audioUrls[relPath]
return path.isAbsolute(relPath) ? relPath : path.resolve(inputDir, relPath)
}
for (let i = 0; i < items.length; i++) {
const item = items[i]
const tl = timeline[i]
if (item.segments && item.segments.length > 0) {
let currentTime = tl.start
for (let si = 0; si < item.segments.length; si++) {
const seg = item.segments[si]
const audioUrl = resolveAudio(seg.audio)
const segDurUs = (seg.duration || 0) * US
if (segDurUs <= 0) continue
const isLast = si === item.segments.length - 1
const endTime = isLast ? tl.end : currentTime + segDurUs
audioInfos.push({
audio_url: audioUrl,
start: currentTime,
end: endTime,
duration: endTime - currentTime,
volume: 1.0,
})
currentTime = endTime
}
} else if (item.audio) {
const audioUrl = resolveAudio(item.audio)
const audioDurUs = item.audioDuration ? item.audioDuration * US : tl.duration
audioInfos.push({
audio_url: audioUrl,
start: tl.start,
end: tl.start + audioDurUs,
duration: audioDurUs,
volume: 1.0,
})
}
}
if (audioInfos.length === 0) {
console.log(' 无可用音频,跳过配音')
return
}
await api('add_audios', {
draft_url: draftUrl,
audio_infos: JSON.stringify(audioInfos),
})
const ossCount = audioInfos.filter(a => a.audio_url.startsWith('http')).length
console.log(` 已添加 ${audioInfos.length} 段 TTS 配音 (${ossCount > 0 ? `${ossCount} 段 OSS + ` : ''}${audioInfos.length - ossCount} 段本地)`)
}
// ============================================================================
// 添加 BGM
// ============================================================================
async function addBGM(draftUrl, bgmUrl, totalDurationUs) {
let audioDuration = totalDurationUs
try {
const durRes = await api('get_audio_duration', { mp3_url: bgmUrl })
if (durRes.duration) audioDuration = durRes.duration
} catch (_) {}
const fadeIn = 500000
const fadeOut = 1000000
await api('add_audios', {
draft_url: draftUrl,
audio_infos: JSON.stringify([{
audio_url: bgmUrl,
duration: audioDuration,
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, fade 0.5s/1s)`)
}
// ============================================================================
// 添加字幕
// ============================================================================
async function addSubtitles(draftUrl, items, timeline, style = {}, split = false) {
const captions = []
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]
const text = item.script || item.text || item.caption || ''
if (!text) continue
const tl = timeline[i]
if (split) {
if (item.segments && item.segments.length > 0) {
let currentTime = tl.start
for (let si = 0; si < item.segments.length; si++) {
const seg = item.segments[si]
const segDurUs = (seg.duration || 0) * US
if (segDurUs <= 0) continue
const isLast = si === item.segments.length - 1
const endTime = isLast ? tl.end : currentTime + segDurUs
const cap = { start: currentTime, end: endTime, text: seg.text }
applyAnimationProps(cap, animStyle)
captions.push(cap)
currentTime = endTime
}
} else {
const sentences = splitTextIntoSentences(text)
if (sentences.length === 0) continue
const totalDuration = tl.end - tl.start
const totalChars = sentences.reduce((sum, s) => sum + s.length, 0)
let currentTime = tl.start
sentences.forEach((sentence, idx) => {
const charRatio = sentence.length / totalChars
let duration = Math.round(totalDuration * charRatio)
if (idx === sentences.length - 1) {
duration = tl.end - currentTime
}
duration = Math.max(duration, 500000)
const cap = {
start: currentTime,
end: currentTime + duration,
text: sentence,
}
applyAnimationProps(cap, animStyle)
captions.push(cap)
currentTime += duration
})
}
} else {
const cap = {
start: tl.start,
end: tl.end,
text,
}
applyAnimationProps(cap, animStyle)
captions.push(cap)
}
}
if (captions.length === 0) {
console.log(' 无字幕内容,跳过')
return
}
await api('add_captions', {
draft_url: draftUrl,
captions: JSON.stringify(captions),
font: style.font || null,
font_size: style.fontSize || 15,
text_color: style.color || '#ffffff',
alignment: 1,
bold: style.bold || false,
italic: false,
underline: false,
has_shadow: style.hasShadow || false,
shadow_info: style.shadowAlpha ? {
shadow_alpha: style.shadowAlpha,
shadow_color: style.shadowColor || '#000000',
shadow_diffuse: 15,
shadow_distance: 5,
shadow_angle: -45,
} : undefined,
letter_spacing: style.letterSpacing || 0,
line_spacing: style.lineSpacing || 0,
alpha: style.alpha || 1,
scale_x: 1, scale_y: 1,
transform_x: 0,
transform_y: style.transformY || 0,
style_text: 0,
})
console.log(` 已添加 ${captions.length} 条字幕${split ? ' (分句模式)' : ''} (字体: ${style.font || '默认'}, 动画: ${animStyle.inAnimation || '无'}${animStyle.outAnimation || '无'})`)
}
// ============================================================================
// 添加关键字氛围词
// ============================================================================
async function addKeywordOverlays(draftUrl, items, timeline, style = {}) {
const keywordItems = items.filter(item => item.keyword)
if (keywordItems.length === 0) {
console.log(' 无关键字,跳过')
return
}
const captions = []
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (!item.keyword) continue
const tl = timeline[i]
const cap = {
start: tl.start,
end: tl.end,
text: item.keyword,
}
applyAnimationProps(cap, style)
captions.push(cap)
}
if (captions.length === 0) return
await api('add_captions', {
draft_url: draftUrl,
captions: JSON.stringify(captions),
font: style.font || null,
font_size: style.fontSize || 60,
text_color: style.color || '#FFFFFF',
alignment: 1,
bold: style.bold || false,
has_shadow: style.hasShadow || false,
shadow_info: style.shadowAlpha ? {
shadow_alpha: style.shadowAlpha,
shadow_color: style.shadowColor || '#000000',
shadow_diffuse: 15,
shadow_distance: 5,
shadow_angle: -45,
} : undefined,
alpha: style.alpha || 1,
scale_x: 1, scale_y: 1,
transform_x: 0,
transform_y: style.transformY || 0,
text_effect: style.textEffect || null,
})
console.log(` 已添加 ${captions.length} 个关键字氛围词 (效果: ${style.textEffect || '无'})`)
}
// ============================================================================
// 添加特效
// ============================================================================
async function addEffects(draftUrl, effectsStr, totalDurationUs) {
const effectNames = effectsStr.split(',').map(s => s.trim()).filter(Boolean)
const effectInfos = effectNames.map(name => ({
effect_title: name,
start: 0,
end: totalDurationUs,
}))
await api('add_effects', {
draft_url: draftUrl,
effect_infos: JSON.stringify(effectInfos),
})
console.log(` 已添加: ${effectNames.join(', ')}`)
}
// ============================================================================
// 添加滤镜
// ============================================================================
async function addFilter(draftUrl, filterStr, totalDurationUs) {
const [name, intensity] = filterStr.split(':')
await api('add_filters', {
draft_url: draftUrl,
filter_infos: JSON.stringify([{
filter_title: (name || '').trim(),
start: 0,
end: totalDurationUs,
intensity: parseFloat(intensity) || 50,
}]),
})
console.log(` 已添加: ${(name || '').trim()} 强度 ${intensity || 50}`)
}
module.exports = {
loadAccountConfig,
loadSubtitleStyle,
loadKeywordStyle,
loadKenBurns,
loadTransitions,
getTransition,
addImages,
addVideos,
addKenBurns,
addVoiceover,
addBGM,
addSubtitles,
addKeywordOverlays,
addEffects,
addFilter,
}

View File

@@ -5,21 +5,26 @@
const { loadManifest, saveManifest } = require('./pipeline-utils')
function confirmManifest(options) {
const { manifest: manifestPath, all } = options
const { manifest: manifestPath, all, items: itemsStr } = options
if (!manifestPath) {
console.error('用法: pipeline.js confirm --manifest <path> --all')
console.error(' pipeline.js confirm --manifest <path> --items 1,3,5')
process.exit(1)
}
if (!all) {
console.error('错误: 必须指定 --all')
if (!all && !itemsStr) {
console.error('错误: 必须指定 --all 或 --items <id列表>')
process.exit(1)
}
const manifest = loadManifest(manifestPath)
const targetIds = itemsStr
? new Set(itemsStr.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n)))
: null
let count = 0
for (const item of manifest.items) {
if (targetIds && !targetIds.has(item.id)) continue
if (item.file && item.status === 'done' && !item.confirmed) {
item.confirmed = true
count++
@@ -30,7 +35,8 @@ function confirmManifest(options) {
const total = manifest.items.length
const confirmed = manifest.items.filter(it => it.confirmed).length
console.log(`已确认: ${count} items${confirmed}/${total} 已确认)`)
const scope = targetIds ? `${Array.from(targetIds).join(',')}` : '全部'
console.log(`已确认: ${count} items范围: ${scope},共 ${confirmed}/${total} 已确认)`)
}
module.exports = { confirmManifest }

View File

@@ -6,7 +6,7 @@
const fs = require('fs')
const path = require('path')
const { loadAccountConfig, saveManifest, ensureDir, ACCOUNTS_DIR, SKILLS_DIR } = require('./pipeline-utils')
const { loadAccountConfig, saveManifest, ensureDir, slugify, ACCOUNTS_DIR, SKILLS_DIR } = require('./pipeline-utils')
function initManifest(options) {
const { account: accountId, mode, items: itemsJson, itemsFile } = options
@@ -40,7 +40,8 @@ function initManifest(options) {
}
// 校验必填字段
const requiredFields = ['shotDesc', 'script', 'imagePrompt']
const requiredFields = ['shotDesc', 'script']
const optionalFields = ['imagePrompt', 'videoPrompt', 'lastFramePrompt']
const resolvedMode = mode || 'single'
for (let i = 0; i < rawItems.length; i++) {
@@ -52,8 +53,7 @@ function initManifest(options) {
}
}
if (resolvedMode === 'framePair' && !item.lastFramePrompt) {
console.error(`错误: 首尾帧模式 items[${i}] 缺少 "lastFramePrompt"imagePrompt 作为第一帧)`)
process.exit(1)
delete item.lastFramePrompt // 首尾帧模式 Step 2-A 补充
}
}
@@ -68,9 +68,11 @@ function initManifest(options) {
// 构建 items
const items = rawItems.map((raw, i) => {
const slug = slugify(raw.shotDesc || raw.script || `scene_${i + 1}`)
const item = {
id: i + 1,
status: 'pending',
file: `images/scene_${String(i + 1).padStart(2, '0')}_${slug}.jpeg`,
shotDesc: raw.shotDesc || '',
script: raw.script || '',
duration: raw.duration || 5,
@@ -129,7 +131,13 @@ function initManifest(options) {
console.log(` 画幅: ${manifest.format}, 模式: ${manifest.mode}`)
console.log(` Items: ${items.length}`)
console.log(` 参考图: ${references.length}`)
if (items.some(it => !it.videoPrompt)) {
if (items.some(it => !it.imagePrompt)) {
console.log(`${items.filter(it => !it.imagePrompt).length} 个 item 缺少 imagePrompt请运行 Step 2-A图片提示词补充`)
}
if (resolvedMode === 'framePair' && items.some(it => !it.lastFramePrompt)) {
console.log(`${items.filter(it => !it.lastFramePrompt).length} 个 item 缺少 lastFramePrompt请运行 Step 2-A 补充`)
}
if (items.some(it => !it.videoPrompt && resolvedMode !== 'framePair')) {
console.log(`${items.filter(it => !it.videoPrompt).length} 个 item 缺少 videoPrompt生视频阶段将跳过`)
}
console.log()

View File

@@ -41,6 +41,9 @@ function validateManifest(manifestPath) {
if (item.status && !['pending', 'generating', 'done', 'failed'].includes(item.status)) {
issues.push(`${prefix} status 无效: ${item.status}`)
}
if (item.status === 'done' && !item.file && !item.video && !item.url) {
issues.push(`${prefix} status=done 但缺少 file/video/url素材路径`)
}
})
}

View File

@@ -15,6 +15,14 @@ async function phaseAssemble(manifest, manifestPath, options) {
const hasVideos = videoItems.length > 0
const mode = hasVideos ? 'videos' : 'images'
// 前置校验:图片模式下检查 file 字段
if (mode === 'images') {
const missingFile = manifest.items.filter(it => !it.file)
if (missingFile.length > 0) {
throw new Error(`${missingFile.length} 个 item 缺少 file 字段id: ${missingFile.map(it => it.id).join(', ')}),请先运行 images 阶段生成图片`)
}
}
const assembleArgs = {
input: dir,
manifest: manifestPath,
@@ -22,7 +30,6 @@ async function phaseAssemble(manifest, manifestPath, options) {
format: manifest.format || accountConfig.defaultFormat || '9:16',
subtitles: mode === 'images' ? 'true' : 'false',
voiceover: manifest.items.some(it => it.audio) ? 'true' : 'false',
duration: '4',
animation: capcutConfig.animation || '渐显+放大',
}

View File

@@ -17,7 +17,8 @@ async function phaseImages(manifest, manifestPath, options) {
ensureDir(imagesDir)
const items = manifest.items.filter(it =>
(!it.status || it.status === 'pending' || it.status === 'generating') && it.imagePrompt
((!it.status || it.status === 'pending' || it.status === 'generating') && it.imagePrompt) ||
(it.status === 'done' && manifest.mode === 'framePair' && it.file && it.lastFramePrompt && !it.lastFrame)
)
if (items.length === 0) { log('images', '无待处理 item跳过'); return }
@@ -45,6 +46,14 @@ async function phaseImages(manifest, manifestPath, options) {
item.status = 'generating'
saveManifest(manifestPath, manifest)
// 仅补 lastFrame首帧已存在跳过首帧生成
if (item.file && manifest.mode === 'framePair' && item.lastFramePrompt && !item.lastFrame) {
log('images', `[${idx}] 补生成 lastFrame首帧已有: ${item.file}`)
await generateLastFrame(item, idx, manifest, dir, imagesDir, model, ratio, manifestPath)
saveManifest(manifestPath, manifest)
return { ok: true }
}
let result
if (model === 'gemini') {
result = await generateGemini(item, idx, dir, imagesDir, ratio, refs)

View File

@@ -2,7 +2,8 @@
* Phase: tts — 语音合成(逐句分句生成)
*
* 将每个 item 的 script 按标点切分为短句,每句单独生成 TTS 音频。
* 结果写入 item.segments[]实现字幕与语音精确对齐
* 统一写入 item.segments[]单句时数组仅 1 个元素
* item.audio 指向第一段item.audioDuration 为累计时长。
*/
const path = require('path')
@@ -29,47 +30,32 @@ async function phaseTts(manifest, manifestPath, options = {}) {
try {
const sentences = splitTextIntoSentences(fullText)
const segments = []
let totalDuration = 0
if (sentences.length <= 1) {
// 单句:不需要 segments走原逻辑
const { filePath, duration } = await synthesize(fullText, {
for (let j = 0; j < sentences.length; j++) {
const sentence = sentences[j]
const segId = `${item.id || idx}_${j + 1}`
const { filePath, duration } = await synthesize(sentence, {
outputDir: audioDir,
id: item.id || idx,
id: segId,
voice: manifest.ttsVoice || undefined,
instruction: manifest.ttsInstruction || undefined,
rate: manifest.ttsRate || undefined,
})
item.audio = path.relative(dir, filePath).replace(/\\/g, '/')
item.audioDuration = Math.round(duration * 1000) / 1000
log('tts', `[${idx}/${items.length}] ${duration.toFixed(1)}s: ${fullText.substring(0, 30)}...`)
} else {
// 多句:逐句生成,写入 segments
const segments = []
let totalDuration = 0
for (let j = 0; j < sentences.length; j++) {
const sentence = sentences[j]
const segId = `${item.id || idx}_${j + 1}`
const { filePath, duration } = await synthesize(sentence, {
outputDir: audioDir,
id: segId,
voice: manifest.ttsVoice || undefined,
instruction: manifest.ttsInstruction || undefined,
rate: manifest.ttsRate || undefined,
})
segments.push({
text: sentence,
audio: path.relative(dir, filePath).replace(/\\/g, '/'),
duration: Math.round(duration * 1000) / 1000,
})
totalDuration += duration
}
item.segments = segments
item.audio = segments[0].audio
item.audioDuration = Math.round(totalDuration * 1000) / 1000
log('tts', `[${idx}/${items.length}] ${totalDuration.toFixed(1)}s (${segments.length}句): ${fullText.substring(0, 30)}...`)
segments.push({
text: sentence,
audio: path.relative(dir, filePath).replace(/\\/g, '/'),
duration: Math.round(duration * 1000) / 1000,
})
totalDuration += duration
}
// 统一使用 segments 数组(单句 = 1 元素,多句 = N 元素)
item.segments = segments
item.audio = segments[0].audio
item.audioDuration = Math.round(totalDuration * 1000) / 1000
log('tts', `[${idx}/${items.length}] ${totalDuration.toFixed(1)}s (${segments.length}句): ${fullText.substring(0, 30)}...`)
} catch (err) {
item.status = 'failed'
item.error = `TTS失败: ${err.message}`

View File

@@ -1,7 +1,7 @@
/**
* Phase: upload — OSS 上传
*
* 将生成的图片(含首尾帧)上传到 OSS回写 url
* 将图片(含首尾帧)和视频上传到 OSS回写 url / videoUrl
*/
const path = require('path')
@@ -11,35 +11,64 @@ async function phaseUpload(manifest, manifestPath) {
const dir = getManifestDir(manifestPath)
const { uploadFile } = require('../oss-upload')
const items = manifest.items.filter(it =>
// 图片(含首尾帧 first frame
const imageItems = manifest.items.filter(it =>
it.status === 'done' && it.file && !it.url
)
if (items.length === 0) { log('upload', '无待上传 item跳过'); return }
// 视频
const videoItems = manifest.items.filter(it =>
it.status === 'done' && it.video && !it.videoUrl
)
log('upload', `${items.length} 个文件`)
if (imageItems.length === 0 && videoItems.length === 0) {
log('upload', '无待上传文件,跳过')
return
}
for (let i = 0; i < items.length; i++) {
const item = items[i]
const filePath = path.resolve(dir, item.file)
try {
const { url } = await uploadFile(filePath)
item.url = url
log('upload', `[${i + 1}/${items.length}] ${item.file}${url.substring(0, 60)}...`)
} catch (err) {
item.error = `上传失败: ${err.message}`
log('upload', `[${i + 1}/${items.length}] 失败: ${err.message}`)
}
if (item.url && item.lastFrame && !item.lastFrameUrl) {
const lastPath = path.resolve(dir, item.lastFrame)
// 上传图片
if (imageItems.length > 0) {
log('upload', `图片: ${imageItems.length}`)
for (let i = 0; i < imageItems.length; i++) {
const item = imageItems[i]
const filePath = path.resolve(dir, item.file)
try {
const { url } = await uploadFile(lastPath)
item.lastFrameUrl = url
log('upload', `[${i + 1}/${items.length}] lastFrame → OK`)
const { url } = await uploadFile(filePath)
item.url = url
log('upload', ` [${i + 1}/${imageItems.length}] ${item.file} → OK`)
} catch (err) {
log('upload', `[${i + 1}/${items.length}] lastFrame 上传失败: ${err.message}`)
item.error = `上传失败: ${err.message}`
log('upload', ` [${i + 1}/${imageItems.length}] 失败: ${err.message}`)
}
// 首尾帧模式:上传 lastFrame
if (item.url && item.lastFrame && !item.lastFrameUrl) {
const lastPath = path.resolve(dir, item.lastFrame)
try {
const { url } = await uploadFile(lastPath)
item.lastFrameUrl = url
log('upload', ` [${i + 1}/${imageItems.length}] lastFrame → OK`)
} catch (err) {
log('upload', ` [${i + 1}/${imageItems.length}] lastFrame 上传失败: ${err.message}`)
}
}
saveManifest(manifestPath, manifest)
}
}
// 上传视频
if (videoItems.length > 0) {
log('upload', `视频: ${videoItems.length}`)
for (let i = 0; i < videoItems.length; i++) {
const item = videoItems[i]
const videoPath = path.resolve(dir, item.video)
try {
const { url } = await uploadFile(videoPath)
item.videoUrl = url
log('upload', ` [${i + 1}/${videoItems.length}] ${item.video} → OK`)
} catch (err) {
log('upload', ` [${i + 1}/${videoItems.length}] 失败: ${err.message}`)
}
saveManifest(manifestPath, manifest)
}
saveManifest(manifestPath, manifest)
}
}

View File

@@ -112,13 +112,23 @@ function applyRetryFailed(manifest, phases) {
for (const item of manifest.items) {
if (item.status === 'failed' || item.status === 'partial') {
if (item.url && item.videoPrompt && !item.video) {
// 图片已上传但视频未生成 → 直接重试视频阶段
item.status = 'done'
item.error = ''
resetCount++
} else if (!item.url && item.imagePrompt) {
item.status = 'pending'
item.error = ''
resetCount++
// 图片未上传 → 重试图片阶段
// 如果首帧已存在但 lastFrame 失败,只重置 lastFrame 相关
if (item.file && manifest.mode === 'framePair' && !item.lastFrame) {
item.status = 'done' // 保留首帧,只补 lastFrame
item.error = ''
resetCount++
} else {
item.status = 'pending'
item.error = ''
delete item.file // 清除旧文件引用,避免重复
resetCount++
}
}
}
}
@@ -128,7 +138,7 @@ function applyRetryFailed(manifest, phases) {
}
}
if (phases.includes('images')) {
if (manifest.items.some(it => !it.status || it.status === 'pending')) {
if (manifest.items.some(it => (!it.status || it.status === 'pending') || (it.status === 'done' && manifest.mode === 'framePair' && !it.lastFrame))) {
manifest.pipeline.phases.images = 'pending'
}
}
@@ -159,7 +169,6 @@ function parseArgs(argv) {
else if (argv[i] === '--image-model' && argv[i + 1]) args.imageModel = argv[++i]
else if (argv[i] === '--video-model' && argv[i + 1]) args.videoModel = argv[++i]
else if (argv[i] === '--references' && argv[i + 1]) args.references = argv[++i]
else if (argv[i] === '--style' && argv[i + 1]) args.style = argv[++i]
else if (argv[i] === '--all') args.all = true
else if (!args.command) args.command = argv[i]
}
@@ -219,6 +228,7 @@ async function main() {
console.log(' pipeline.js init --account <id> --mode <single|framePair> --items <JSON> [--items-file <path>] [--image-model gemini|mj] [--video-model veo3-fast|grok|kling] [--format 9:16]')
console.log(' pipeline.js validate --manifest <path>')
console.log(' pipeline.js confirm --manifest <path> --all')
console.log(' pipeline.js confirm --manifest <path> --items 1,3,5')
console.log(' pipeline.js run --manifest <path> [--account id] [--phase p1,p2] [--resume] [--retry-failed]')
console.log(' pipeline.js status --manifest <path>')
console.log('')

2
.gitignore vendored
View File

@@ -2,7 +2,7 @@
node_modules/
config.json
# Local settings
.claude/settings.local.json

View File

@@ -327,6 +327,44 @@ undone
- 禁止写画质参数(`8K` / `cinematic`)——留给图片提示词
- **视频成片:** 禁止纯静止描述,必须附加至少一个隐性动势词
- **图文成片:** 禁止连续两张同景别/同构图的 shot
- **禁止剧透**:不能提前使用文案后续才出现的具体意象、物件、动作
### 6.7 语义-画面对齐规则(剧透、铺垫与承接)
**三定律**
- **禁止剧透**:不能提前使用文案后续才出现的具体意象、物件、动作
- **允许铺垫**:当前画面可以暗示后续情绪趋势,但不使用具体意象
- **允许承接**:当前画面可以延续前一个镜头的情绪或视觉元素
**错误——剧透**
```
Shot 2 script: "这件事情你做的越多,运气就越差。"
Shot 3 script: "你把刀的把柄,亲手递给对方。"
Shot 2 shotDesc: "Close-up of hand gripping knife handle..." ❌ 剧透
→ "刀柄"是 Shot 3 才出现的意象Shot 2 不能提前使用
```
**正确——铺垫**
```
Shot 2 shotDesc: "a figure standing at the edge of crumbling ground,
one hand slowly extends outward into darkness, not yet holding
anything, but the gesture has already begun" ✅ 铺垫
→ 暗示后续会有"递出"的动作,但没有剧透"刀柄"的具体意象
```
**正确——承接**
```
Shot 3 shotDesc: "the extended hand from the previous frame now
receives an unseen object — darkness conceals what passes
between the two figures" ✅ 承接
→ 延续 Shot 2 "伸出的手",动作连贯
```
**检查方法**:每条 shotDesc 写完后,只看当前 script + shotDesc——画面内容是否只来自当前这段文案如果不是重写。
## 七、directorRef 选择规则

View File

@@ -2,9 +2,7 @@
## 一、角色定义
你是一位专精图片生成模型的提示词工程师,具备深厚的视觉叙事和光影设计能力
你的唯一任务是将输入的分镜描述shotDesc作为核心内容依据结合旁白语义、文案上下文以及上游指定的导演风格生成一条可直接送给图片生成模型的完整 imagePrompt。
你是一位拥有 15 年经验的电影摄影指导DP擅长将文字分镜转化为高表现力的视觉起始帧。你不仅关注“画了什么”更关注“空间叙述”与“光影秩序”
> **重要前提:** 你生成的图片是下游视频片段的起始帧。构图和姿态必须是「即将发生」的瞬间,而非「已完成」的状态。
@@ -12,13 +10,13 @@
| 参数 | 角色 | 规则 |
|------|------|------|
| **shotDesc** | 主输入 / 内容硬边界 | 画面里所有元素的来源,必须完整体现。不得替换、删减或用其他内容覆盖。imagePrompt 的内容层 100% 来自 shotDesc |
| **当前旁白** | 聚焦核心 / 情绪与氛围 | 理解当前 Shot 的情绪基调和语义重点。用于提取情绪强度、关键意象、构图暗示。不得用旁白内容替代或扩展 shotDesc 的画面描述 |
| **完整文案** | 叙事上下文 / 氛围参考 | 仅用于理解当前 Shot 在整体视频中的叙事位置。决定情绪强度(开场/高潮/收尾)。不得将其他段落的内容引入当前画面 |
| **directorRef** | 光影风格来源 | 由上游分镜脚本生成器指定,本层只执行光影渲染层。不改变 shotDesc 的构图内容,只改变光如何落在画面上。可选值:`tarantino` / `kitano` / `fincher` |
| **shotDesc** | 主内容 / 画面硬边界 | 画面里所有视觉元素的来源之一,必须完整体现。不得替换、删减 |
| **当前旁白script** | 主内容 / 情绪与意象 | 与 shotDesc 共同构成画面主线。从中提取情绪基调、关键意象、构图暗示。 shotDesc 一起决定画面内容 |
| **完整文案** | 仅氛围参考 / 不影响画面内容 | 仅用于理解整体氛围、情绪浓度和核心主题。**禁止将其他段落的意象、物件、动作引入当前画面** |
| **directorRef** | 光影风格来源 | 由上游分镜指定,本层只执行光影渲染层。不改变 shotDesc 的构图内容,只改变光如何落在画面上。可选值:`tarantino` / `kitano` / `fincher` |
| **账号风格** | 视觉身份注入 | 由账号配置文件提供画风、色彩、质感参数。直接替换第五节「固定风格词尾」的占位内容 |
**一句话总结:** shotDesc 决定画什么,旁白决定情绪浓度,完整文案决定叙事分量directorRef 决定光怎么落,账号风格决定整体画风。
**一句话总结:** shotDesc + 当前旁白共同决定画什么,完整文案只提供氛围参考directorRef 决定光怎么落,账号风格决定整体画风。
## 三、导演光影词库(图片层专用)
@@ -164,10 +162,46 @@ Vertical format, aspect ratio [账号画幅].
[完整提示词,可直接复制使用]
```
## 九、质量自检清单
## 九、语义-画面对齐规则(强制)
### 三定律
- **禁止剧透**:不能在 imagePrompt 中引入文案后续才出现的具体意象、物件、动作
- **允许铺垫**:画面可以暗示后续情绪趋势(如光影变暗、空间收窄),但不使用具体意象
- **允许承接**:可以延续前一个镜头的情绪氛围或视觉元素
### 错误示例——剧透
```
shotDesc: "a figure standing at the edge of a crumbling platform"
当前旁白: "这件事情你做的越多,运气就越差。"
完整文案后续: "你把刀的把柄,亲手递给对方。"
❌ 剧透: "...a hand gripping a knife handle..."
→ shotDesc 里没有刀,是文案后面才出现的意象
```
### 正确示例——铺垫
```
✅ 铺垫: "...a figure at the edge of crumbling ground, one hand
slowly extends into darkness, not yet holding anything — [光影词]..."
→ 手伸出但没握住东西,暗示后续"递出"但没剧透"刀柄"
```
### 检查方法
> 画面中每个视觉元素,都能在 shotDesc + 当前旁白中找到对应吗?
> 有任何元素只出现在文案后续段落但当前旁白没提?
> 铺垫用的是情绪暗示还是具体物件?(必须前者)
> 有问题 → **删除该元素,重写**
## 十、质量自检清单
- [ ] shotDesc 的主体和动势完整体现(不得缺失或替换)
- [ ] 未引入其他 Shot 的内容
- [ ] 未引入其他 Shot 的具体意象(禁止剧透)
- [ ] 允许铺垫:情绪暗示可以,具体物件不行
- [ ] 允许承接:延续前一镜头情绪可以
- [ ] 画面是「趋势中的瞬间」非「已完成状态」
- [ ] 光影词库对应 directorRef未混用其他导演
- [ ] 账号风格词尾已替换,非占位文本

View File

@@ -12,17 +12,17 @@
| 参数 | 角色 | 规则 |
|------|------|------|
| **shotDesc** | 主输入 / 画面硬边界 | 定义画面里有什么、人物姿态、环境。运动设计从 shotDesc 的隐性动势出发并放大。不得替换场景或重新设计人物 |
| **当前旁白** | 聚焦核心 / 运动的灵魂 | 提取情绪节奏 → 对应运动的快慢。提取关键动词和意象 → 转化为具体画面动势。提取暗喻/比喻 → 转化为视觉运动设计。不得用旁白内容替代 shotDesc 的画面主体 |
| **完整文案** | 叙事上下文 / 运动强度参考 | 理解当前 Shot 的叙事位置(开场/高潮/收尾)。决定运动幅度和情绪强度。不得将其他段落内容引入当前片段 |
| **directorRef** | 运动风格来源 | 由上游分镜脚本生成器指定,本层只执行运动节奏层。不改变 shotDesc 的画面内容,只改变运动如何发生。可选值:`tarantino` / `kitano` / `fincher` |
| **shotDesc** | 主内容 / 画面硬边界 | 定义画面里有什么、人物姿态、环境。运动设计从 shotDesc 的隐性动势出发并放大。不得替换场景或重新设计人物 |
| **当前旁白script** | 主内容 / 运动的灵魂 | 与 shotDesc 共同构成运动主线。提取情绪节奏 → 对应运动的快慢。提取关键动词和意象 → 转化为具体画面动势。提取暗喻/比喻 → 转化为视觉运动设计 |
| **完整文案** | 仅氛围参考 / 不影响画面内容 | 仅用于理解整体氛围、情绪浓度和核心主题。**禁止将其他段落的意象、物件、动作引入当前片段** |
| **directorRef** | 运动风格来源 | 由上游分镜指定,本层只执行运动节奏层。不改变 shotDesc 的画面内容,只改变运动如何发生。可选值:`tarantino` / `kitano` / `fincher` |
| **账号运动风格** | 运动基调约束 | 由账号配置文件提供运动风格基调(克制/激烈/缓慢等)。约束整体运动幅度,导演词库在此范围内执行 |
**运动来源优先级:**
旁白意象 > shotDesc 隐性动势 > directorRef 运动模板
当前旁白意象 > shotDesc 隐性动势 > directorRef 运动模板
> 文案里找运动的理由,导演风格是执行方式,不是内容来源。
> 当前旁白里找运动的理由,导演风格是执行方式,不是内容来源。
## 三、导演运动词库(视频层专用)
@@ -250,10 +250,12 @@ Vertical format 9:16, [X] seconds, cinematic, no text.
- [ ] 覆盖三层运动中的至少两层
- [ ] 主体运动有具体身体部位,非抽象情绪词
- [ ] 镜头运动来自 directorRef 对应词库,未混用其他导演
- [ ] 从旁白中提取了意象并转化为运动设计
- [ ] 未引入其他 Shot 的内容
- [ ]当前旁白中提取了意象并转化为运动设计
- [ ] 未引入其他 Shot 的具体意象(禁止剧透)
- [ ] 允许铺垫:运动可以暗示后续情绪趋势,但不使用具体物件
- [ ] 允许承接:运动可以延续前一片段的动势方向
- [ ] 片段结尾留有余势
- [ ] 语言和参数格式与目标模型匹配
- [ ] 视频第一帧 = 静态分镜图状态,对不上则整个片段脱锚
- [ ] directorRef 只影响运动节奏层,画面内容始终来自 shotDesc
- [ ] 运动来源优先级:旁白意象 > shotDesc隐性动势 > 导演运动模板
- [ ] 运动来源优先级:当前旁白意象 > shotDesc隐性动势 > 导演运动模板

View File

@@ -351,6 +351,54 @@ something that cannot be undone
- **视频成片:** 禁止纯静止描述,必须附加至少一个隐性动势词
- **图文成片:** 禁止连续两张同景别/同构图的 shot
- 禁止出现真实政治人物姓名
- **禁止意象提前透支**shotDesc 的画面内容必须严格匹配当前 script 的语义,不能提前消费文案后续才出现的隐喻、意象或物件
### 7.7 语义-画面对齐规则
**核心原则**:每个 Shot 的 shotDesc 画面必须从当前 script 提取视觉元素,禁止从文案的其他段落借用意象。
**三定律**
- **禁止剧透**:不能提前使用文案后续才出现的具体意象、物件、动作
- **允许铺垫**:当前画面可以暗示后续情绪趋势,但不使用具体意象
- **允许承接**:当前画面可以延续前一个镜头的情绪或视觉元素
**错误——剧透(意象提前透支)**
文案原文:
```
Shot 2 script: "这件事情你做的越多,你的人生风水和你的运气就越差。"
Shot 3 script: "为什么?解释这个动作,底层是在做一件事——你把刀的把柄,亲手递给对方。"
```
```
Shot 2 shotDesc: "Close-up of hand gripping knife handle..." ❌ 剧透
```
→ "刀柄"意象在 Shot 3 的文案才出现Shot 2 不能提前使用。
**正确——铺垫**
```
Shot 2 shotDesc: "a figure standing at the edge of a crumbling platform,
shadows lengthening beneath, the ground beneath the boots beginning
to crack and give way — one hand slowly extends outward into
darkness, not yet holding anything, but the gesture has already
begun" ✅ 铺垫
```
→ 画面从"运气越差"提取"崩塌/衰败"意象。"手伸出但还没握住东西"是铺垫——暗示后续会有"递出"的动作,但没有剧透"刀柄"的具体意象。
**正确——承接**
```
Shot 3 shotDesc: "the extended hand from the previous frame now receives
an unseen object — darkness conceals what passes between the two figures"
✅ 承接
```
→ 延续 Shot 2 "伸出的手",现在"接住了东西"。画面承接前一个镜头的动作,同时"暗处遮住了物件"保留了悬念。
**检查方法**:每条 shotDesc 写完后,遮住其他所有 Shot只看当前 script + shotDesc——画面内容是否只来自当前这段文案如果不是重写。
## 八、directorRef 选择规则

View File

@@ -12,12 +12,12 @@
| 参数 | 角色 | 使用规则 |
|-----|------|---------|
| **shotDesc** | 主输入 / 内容硬边界 | 画面里所有元素的来源,必须完整体现;不得替换、删减或用其他内容覆盖imagePrompt 的内容层 100% 来自 shotDesc |
| **当前旁白** | 聚焦核心 / 情绪与氛围 | 理解当前 Shot 的情绪基调和语义重点;用于提取情绪强度、关键意象、构图暗示;不得用旁白内容替代或扩展 shotDesc 的画面描述 |
| **完整文案** | 叙事上下文 / 氛围参考 | 仅用于理解当前 Shot 在整体视频中的叙事位置;决定情绪强度(开场 / 高潮 / 收尾);不得将其他段落的内容引入当前画面 |
| **directorRef** | 光影风格来源 / 向下游透传 | 由上游分镜脚本生成器指定,本层只执行光影渲染;不改变 shotDesc 的构图内容只改变光如何落在画面上可选值tarantino / kitano / fincher |
| **shotDesc** | 主内容 / 画面硬边界 | 画面里所有视觉元素的来源之一,必须完整体现;不得替换、删减 |
| **当前旁白script** | 主内容 / 情绪与意象 | 与 shotDesc 共同构成画面主线;从中提取情绪基调、关键意象、构图暗示; shotDesc 一起决定画面内容 |
| **完整文案** | 仅氛围参考 / 不影响画面内容 | 仅用于理解整体氛围、情绪浓度和核心主题;**禁止将其他段落的意象、物件、动作引入当前画面** |
| **directorRef** | 光影风格 / 向下游透传 | 由上游分镜指定,本层只执行光影渲染;不改变 shotDesc 的构图内容只改变光如何落在画面上可选值tarantino / kitano / fincher |
一句话总结shotDesc 决定画什么,旁白决定情绪浓度,完整文案决定叙事分量directorRef 决定光怎么落。
一句话总结:**shotDesc + 当前旁白共同决定画什么**,完整文案只提供氛围参考directorRef 决定光怎么落。
## 三、账号视觉基础风格(所有导演共用底层)
@@ -240,7 +240,60 @@ edge-to-edge, no border, no frame, no text, no watermark
--ar 9:16 --style raw --q 2 --v 6.1
```
## 十、质量自检清单
## 十、语义-画面对齐规则
### 10.1 核心原则
imagePrompt 的画面内容 **100% 来自 shotDesc**。shotDesc 是上游分镜脚本对画面的精确设计,本层只负责渲染(光影、色调、质感),**禁止修改、替换或扩展画面内容**。
### 10.2 禁止行为
- ❌ 从完整文案的其他段落借用意象、物件、动作
- ❌ 添加 shotDesc 中未提及的道具、人物、场景元素
- ❌ 用旁白的比喻意象替换 shotDesc 的画面主体
- ❌ 因为"觉得画面不够丰富"而自行添加额外元素
### 10.3 剧透、铺垫与承接
**三定律**
- **禁止剧透**:不能在 imagePrompt 中引入文案后续才出现的具体意象、物件、动作
- **允许铺垫**:画面可以暗示后续情绪趋势(如光影变暗、空间收窄),但不使用具体意象
- **允许承接**:可以延续前一个镜头的情绪氛围或视觉元素
**错误——剧透**
```
shotDesc: "a figure standing at the edge of a crumbling platform,
shadows lengthening beneath"
当前旁白: "这件事情你做的越多,你的人生运气就越差。"
完整文案中后续提到: "你把刀的把柄,亲手递给对方。"
❌ 剧透 imagePrompt:
"...a hand gripping a knife handle, knuckles white..."
→ shotDesc 里没有刀,是文案后面才出现的意象
```
**正确——铺垫**
```
✅ 铺垫 imagePrompt:
"...a figure standing at the edge of a crumbling platform,
shadows lengthening beneath, one hand slowly extends outward
into darkness, not yet holding anything — [光影词], [画风词]..."
→ 严格按 shotDesc 渲染,手伸出但没握住东西,
暗示后续会有"递出"但没剧透"刀柄"
```
### 10.4 检查方法
生成 imagePrompt 后,逐项核对:
> 画面中每个视觉元素,都能在 shotDesc + 当前旁白中找到对应描述吗?
> 有任何元素只出现在文案后续段落但当前旁白没提?
> 铺垫用的是情绪暗示还是具体物件?(必须前者)
> 答案有问题的 → **删除该元素,重写**
## 十一、质量自检清单
- shotDesc 的主体和动势完整体现(不得缺失或替换)
- 是否引入了其他 Shot 的内容(禁止)

View File

@@ -12,13 +12,13 @@
| 参数 | 角色 | 使用规则 |
|-----|------|---------|
| **shotDesc** | 主输入 / 画面硬边界 | 定义画面里有什么、人物姿态、环境;运动设计从 shotDesc 的隐性动势出发并放大;不得替换场景或重新设计人物 |
| **当前旁白** | 聚焦核心 / 运动的灵魂 | 提取情绪节奏 → 对应运动的快慢;提取关键动词和意象 → 转化为具体画面动势;提取暗喻/比喻 → 转化为视觉运动设计;不得用旁白内容替代 shotDesc 的画面主体 |
| **完整文案** | 叙事上下文 / 运动强度参考 | 理解当前 Shot 的叙事位置(开场/高潮/收尾);决定运动幅度和情绪强度;不得将其他段落内容引入当前片段 |
| **directorRef** | 运动风格来源 / 向下游透传 | 由上游分镜脚本生成器指定,本层只执行运动节奏层;不改变 shotDesc 的画面内容只改变运动如何发生可选值tarantino / kitano / fincher |
| **shotDesc** | 主内容 / 画面硬边界 | 定义画面里有什么、人物姿态、环境;运动设计从 shotDesc 的隐性动势出发并放大;不得替换场景或重新设计人物 |
| **当前旁白script** | 主内容 / 运动的灵魂 | 与 shotDesc 共同构成运动主线;提取情绪节奏 → 对应运动的快慢;提取关键动词和意象 → 转化为具体画面动势;提取暗喻/比喻 → 转化为视觉运动设计 |
| **完整文案** | 仅氛围参考 / 不影响画面内容 | 仅用于理解整体氛围、情绪浓度和核心主题;**禁止将其他段落的意象、物件、动作引入当前片段** |
| **directorRef** | 运动风格 / 向下游透传 | 由上游分镜指定,本层只执行运动节奏层;不改变 shotDesc 的画面内容只改变运动如何发生可选值tarantino / kitano / fincher |
运动来源优先级:旁白意象 > shotDesc 隐性动势 > directorRef 运动模板
文案里找运动的理由,导演风格是执行方式,不是内容来源
运动来源优先级:当前旁白意象 > shotDesc 隐性动势 > directorRef 运动模板
当前旁白里找运动的理由,导演风格是执行方式,不是内容来源
## 三、账号视觉运动基础风格(固定,不因导演而变)
@@ -312,8 +312,10 @@ shotDesc「knuckles beginning to whiten」
- 覆盖三层运动中的至少两层
- 主体运动有具体身体部位,非抽象情绪词
- 镜头运动来自 directorRef 对应词库,未混用其他导演
- 从旁白中提取了意象并转化为运动设计
- 未引入其他 Shot 的内容
-当前旁白中提取了意象并转化为运动设计
- 未引入其他 Shot 的具体意象(禁止剧透)
- 允许铺垫:运动可以暗示后续情绪趋势,但不使用具体物件
- 允许承接:运动可以延续前一片段的动势方向
- 运动幅度符合账号「隐忍张力」基调
- 片段结尾留有余势
- 语言和参数格式与目标模型匹配