Compare commits
3 Commits
b4b92854db
...
ac753ef367
| Author | SHA1 | Date | |
|---|---|---|---|
| ac753ef367 | |||
| 4d5c8cb96d | |||
| 0998fd6ae1 |
@@ -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",
|
||||
|
||||
@@ -35,14 +35,15 @@ B 模式又分两种:**单图模式**(1 图 → 1 段视频)/ **首尾帧
|
||||
### 核心约束
|
||||
|
||||
1. **不可跳步**:
|
||||
- A(幻灯片):分镜 → 图片提示词 → 生图 → TTS+成片。无视频阶段
|
||||
- B(AI视频):分镜 → 图片提示词 → 生图 → 视频提示词 → 生视频 → TTS+成片
|
||||
- A(幻灯片):分镜 → manifest init → 图片提示词 → 生图 → TTS+成片。无视频阶段
|
||||
- B(AI视频):分镜 → 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?
|
||||
|
||||
|
||||
@@ -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(首尾帧加 _last,MJ 候选加 _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
103
.claude/skills/video-from-script/scripts/get-template-path.js
Normal file
103
.claude/skills/video-from-script/scripts/get-template-path.js
Normal 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 }
|
||||
94
.claude/skills/video-from-script/scripts/lib/capcut-api.js
Normal file
94
.claude/skills/video-from-script/scripts/lib/capcut-api.js
Normal 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 }
|
||||
229
.claude/skills/video-from-script/scripts/lib/capcut-timeline.js
Normal file
229
.claude/skills/video-from-script/scripts/lib/capcut-timeline.js
Normal 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 }
|
||||
621
.claude/skills/video-from-script/scripts/lib/capcut-tracks.js
Normal file
621
.claude/skills/video-from-script/scripts/lib/capcut-tracks.js
Normal 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,
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(素材路径)`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || '渐显+放大',
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
2
.gitignore
vendored
@@ -2,7 +2,7 @@
|
||||
node_modules/
|
||||
|
||||
|
||||
|
||||
config.json
|
||||
|
||||
# Local settings
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -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 选择规则
|
||||
|
||||
|
||||
@@ -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,未混用其他导演
|
||||
- [ ] 账号风格词尾已替换,非占位文本
|
||||
|
||||
@@ -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隐性动势 > 导演运动模板
|
||||
|
||||
@@ -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 选择规则
|
||||
|
||||
|
||||
@@ -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 的内容(禁止)
|
||||
|
||||
@@ -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 的具体意象(禁止剧透)
|
||||
- 允许铺垫:运动可以暗示后续情绪趋势,但不使用具体物件
|
||||
- 允许承接:运动可以延续前一片段的动势方向
|
||||
- 运动幅度符合账号「隐忍张力」基调
|
||||
- 片段结尾留有余势
|
||||
- 语言和参数格式与目标模型匹配
|
||||
|
||||
Reference in New Issue
Block a user