Compare commits

...

6 Commits

Author SHA1 Message Date
163a83ab6d feat(video-from-script): 新增产品宣传片账户配置及 Ken Burns 效果支持
- 添加 product_viral_factory 账户配置,支持产品宣传片自动生成
- 集成 Ken Burns 效果到组装流程
- 优化视频生成阶段的空结果诊断和修复提示
- 改进 manifest 保存逻辑,处理 EPERM 权限错误
- 添加 .claudeignore 忽略生成文件
2026-05-12 01:28:40 +08:00
06f44ddafa refactor(video-from-script): 提取轮询重试逻辑为共享工具
提取三个视频生成器中重复的 `pollWithRetry` 函数到共享模块 `video-poll-utils`,消除代码重复。新增两层重试机制:轮询级(处理网络瞬断)和任务级(创建新任务 + 提示词优化)。同时优化 `phase-videos` 中视频状态管理和 manifest 保存逻辑。
2026-05-12 01:24:55 +08:00
7b743dc701 feat(video-from-script): 支持分段音频时长测量
为 `capcut_assemble.js` 中的 item 增加 segments 属性支持,当 item 包含多个音频分段时,计算各分段总时长并赋给 `audioDuration`,提升对分段式音轨素材的处理能力。
2026-05-12 01:08:49 +08:00
18fce1b5a1 feat(video-from-script): 添加 TTS 音色管理和解析功能
- 在 config.json 中添加 `ttsVoices` 音色库,支持音色名称到 ID 的映射
- 实现 `resolveVoice` 函数,将音色名称解析为实际 ID
- 更新账号系统和批量管道,支持通过音色名称配置 TTS 语音
- Excel 导入和 CLI 参数新增音色字段,支持按行指定不同音色
2026-05-08 23:53:37 +08:00
4a15e38169 chore(video-from-script): 删除临时文件 items_tmp.json 2026-05-08 23:06:40 +08:00
cfdf30d438 feat(video-from-script): 添加批量视频生产编排器
新增 `batch-pipeline.js` 脚本,实现从 Excel/CSV 批量创建视频生产任务的核心功能:
- 支持 `init`、`status`、`mark`、`retry-failed`、`next` 等 CLI 子命令
- 解析表格数据,提取脚本、账号、模式等字段,生成 batch-manifest.json 元数据
- 支持 `xlsx`/`csv` 格式输入,自动校验账号存在性
- 提供状态追踪与任务重试机制,为 Orchestrator-Worker 批量模式奠定基础

同时更新 `CLAUDE.md` 文档,添加批量生产功能的操作指南。
2026-05-08 23:06:17 +08:00
21 changed files with 803 additions and 195 deletions

View File

@@ -32,6 +32,10 @@
"ttsApiBaseUrl": "https://dashscope.aliyuncs.com/api/v1",
"ttsApiKey": "sk-1c503705b0f844a6b4f2386f6c1cc35b",
"ttsModel": "cosyvoice-v3.5-plus",
"ttsVoice": "cosyvoice-v3.5-plus-bailian-fa8787c0f70b4ba2a907c35511e6a6f6",
"ttsLanguage": "Chinese"
"ttsVoice": "斯内普",
"ttsLanguage": "Chinese",
"ttsVoices": {
"斯内普": "cosyvoice-v3.5-plus-bailian-fa8787c0f70b4ba2a907c35511e6a6f6",
"布拉德": "cosyvoice-v3.5-plus-bailian-574be4b7013a4e1f924de08fa8b9bdef"
}
}

View File

@@ -56,7 +56,11 @@ B 模式又分两种:**单图模式**1 图 → 1 段视频)/ **首尾帧
3. 账号:扫描 accounts/*/account.json → 展示可用账号 → 用户选
→ 未指定让选,不匹配告知并问是否新建
4. 参数:画幅、生图模型、(B 模式)视频模型 — 优先从 account.json 继承
4. 音色:读取 config.json 的 ttsVoices 音色库,展示可用音色让用户选
→ 默认用 account.json 的 ttsVoice未指定则用 config.json 全局 ttsVoice
→ 用户也可指定音色 ID
5. 参数:画幅、生图模型、(B 模式)视频模型 — 优先从 account.json 继承
```
→ 5 项确认后,输出执行计划让用户最终确认。用户说"开始"才进入 Step 0。

View File

@@ -1,12 +0,0 @@
[
{"id": 1, "shotDesc": "A man stands at a crossroad frozen in place, four massive mirrors orbiting him at different angles simultaneously, each reflecting a distorted opposite version of himself — one reading while thinking, one reaching for freedom while chained, one pursuing love without resources, one chasing wealth without action. Fincher cold blue directional light cuts through the scene with architectural shadow lines on the ground. Urban modern fashion.", "script": "99%的人都没意识到的四个致命现象", "duration": 3.8, "directorRef": "fincher"},
{"id": 2, "shotDesc": "A stylish urban figure is split in half by a hard vertical shadow line — left side leans in with an open book, right side rests a hand on a thinking forehead. Twin halves pull in opposite directions, fabric and posture straining against each other. Fincher cold blue practical light, high contrast chiaroscuro.", "script": "不读书却爱思考,不独立却想要自由,没物质却想谈真爱,没执行力却想要发财", "duration": 4.0, "directorRef": "fincher"},
{"id": 3, "shotDesc": "A suited man kicks an old broken clock lying on the ground, its pendulum snapped, yet the clock face still shows hands spinning uncontrollably. The scene freezes mid-kick. Fincher sharp cold practical light, strong shadow, dramatic tension.", "script": "很多事做错了能重来", "duration": 3.0, "directorRef": "fincher"},
{"id": 4, "shotDesc": "A man stands with a black sandbag crushing his shoulders into the ground, four thick chains extending from the bag — each chain wrapped tight around a different part of his body. He strains but cannot break free. Fincher cold blue light, urban background, precise shadow architecture.", "script": "但这四个坑只要踩中一个", "duration": 3.0, "directorRef": "fincher"},
{"id": 5, "shotDesc": "A man running on a treadmill that moves faster and faster beneath him, but the surrounding walls close in with each stride — the space shrinking relentlessly. His expression shifts from determination to desperation. Fincher cold blue overhead light, architectural shadow lines on walls, urban setting.", "script": "你这辈子注定越折腾越穷,越懂事越惨", "duration": 3.4, "directorRef": "fincher"},
{"id": 6, "shotDesc": "A man freezes mid-step on a bridge over dark water, looking down at the vast emptiness below, hands open in the realization. Fincher cold blue natural light, negative space composition, silent tension.", "script": "这不是吓唬你", "duration": 1.0, "directorRef": "fincher"},
{"id": 7, "shotDesc": "A person of any age walks through a dark room full of four suspended iron traps above, each glowing faintly with red warning light overhead. The person looks up, scanning every trap with alert focused eyes. Fincher calculated cold blue overhead practical light, architectural shadows, urban modern fashion.", "script": "一个人无论身处任何年龄阶段都必须提防这四大陷阱", "duration": 4.0, "directorRef": "fincher"},
{"id": 8, "shotDesc": "A person stops at the entrance of four diverging corridors, each corridor is a different trap — fire, ice, void, thorns. The person observes and calculates, then reaches for one. Fincher sharp cold blue light, precise shadow edges, urban modern fashion, strong visual anchor.", "script": "今天这条视频就带你拆解清楚", "duration": 1.2, "directorRef": "fincher"},
{"id": 9, "shotDesc": "A spider diagram fills the frame — four different trap icons on four sides of the screen all connect to a single glowing core node at the center. Lightning bolts from center to each icon, showing they are secretly linked. Fincher cold blue analytical lighting, precise architectural lines, the core node pulses.", "script": "这四个看似互不关联,底层逻辑却互通的陷阱", "duration": 4.8, "directorRef": "fincher"},
{"id": 10, "shotDesc": "A stylish urban person walks through an open door into white light, the four iron traps behind them dissolve into smoke and shatter. Fincher cold blue backlight silhouette, the door is an analytical white bright light with negative space composition.", "script": "越早警惕,才能越早破解", "duration": 4.8, "directorRef": "fincher"}
]

View File

@@ -116,7 +116,7 @@ digraph creation_flow {
| # | 问题 | 默认值 | 说明 |
|---|------|--------|------|
| 12 | TTS 音色? | config.json 全局 ttsVoice | account.json 的 ttsVoice,留空用全局默认 |
| 12 | TTS 音色? | config.json 全局 ttsVoice | account.json 的 ttsVoice。从 config.json 的 `ttsVoices` 音色库中选择(如"斯内普"、"布拉德"),也可直接填音色 ID |
| 13 | TTS 语气指令? | 无 | account.json 的 ttsInstruction描述期望的语气风格 |
| 14 | 背景音乐偏好? | 无 | account.json 的 capcut.defaultBGM。提供 URL 或描述风格Agent 辅助查找 |

View File

@@ -49,7 +49,7 @@ accounts/ # 项目根目录下
]
}
},
"ttsVoice": "cosyvoice-v3.5-plus-bailian-xxx",
"ttsVoice": "斯内普",
"ttsInstruction": "用冷静理性的男性声音朗读,语速适中",
"storyboardPrompt": "prompts/分镜.md",
"imageStylePrompt": "prompts/图片提示词.md",
@@ -108,7 +108,7 @@ accounts/ # 项目根目录下
| `videoModel` | string | 默认视频模型(`veo3-fast` / `grok-video-3` / `kling` |
| `batchSize` | number | 默认批量生成数量 |
| `styles` | object | 命名风格预设,每项含 `references` 数组 |
| `ttsVoice` | string | TTS 音色 ID留空用 config.json 全局默认 |
| `ttsVoice` | string | TTS 音色名称(如"斯内普")或音色 ID留空用 config.json 全局默认。可用音色见 config.json 的 `ttsVoices` |
| `ttsInstruction` | string | TTS 语气指令(描述期望的语气、语速、情感) |
| `storyboardPrompt` | string | 分镜提示词模板路径(相对于账号目录) |
| `imageStylePrompt` | string | 图片提示词模板路径(相对于账号目录) |

View File

@@ -0,0 +1,463 @@
#!/usr/bin/env node
/**
* 批量视频生产编排器
*
* 职责:读 Excel/CSV → 创建 batch-manifest → 管理 batch 状态
* 不负责:分镜、生图、生视频(由 AI Worker 子 Agent 承担)
*
* 用法:
* node batch-pipeline.js init --file <xlsx/csv> [--account <账号>] [--mode <模式>]
* node batch-pipeline.js status --file <batch-manifest.json>
* node batch-pipeline.js mark --file <batch-manifest.json> --row <N> --status <状态> [--manifest-path <path>] [--error <msg>]
* node batch-pipeline.js retry-failed --file <batch-manifest.json>
*/
const fs = require('fs')
const path = require('path')
const { SKILLS_DIR, ACCOUNTS_DIR, loadConfig, resolveVoice } = require('./lib/pipeline-utils')
// output/ 在项目根的父级(美图/output/
const OUTPUT_BASE = path.join(SKILLS_DIR, '..', '..', '..', 'output')
// ============================================================================
// CLI 参数解析
// ============================================================================
function parseArgs(argv) {
const args = {}
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--file' && argv[i + 1]) args.file = argv[++i]
else if (argv[i] === '--account' && argv[i + 1]) args.account = argv[++i]
else if (argv[i] === '--mode' && argv[i + 1]) args.mode = argv[++i]
else if (argv[i] === '--voice' && argv[i + 1]) args.voice = argv[++i]
else if (argv[i] === '--row' && argv[i + 1]) args.row = parseInt(argv[++i])
else if (argv[i] === '--status' && argv[i + 1]) args.status = argv[++i]
else if (argv[i] === '--manifest-path' && argv[i + 1]) args.manifestPath = argv[++i]
else if (argv[i] === '--error' && argv[i + 1]) args.error = argv[++i]
else if (!args.command) args.command = argv[i]
}
return args
}
// ============================================================================
// init: 读 Excel/CSV → batch-manifest.json + 提取脚本文件
// ============================================================================
function cmdInit(args) {
const filePath = path.resolve(args.file)
if (!fs.existsSync(filePath)) {
console.error(`文件不存在: ${filePath}`)
process.exit(1)
}
const ext = path.extname(filePath).toLowerCase()
let rows
if (ext === '.csv') {
rows = parseCsv(filePath)
} else if (ext === '.xlsx' || ext === '.xls') {
rows = parseExcel(filePath)
} else {
console.error(`不支持的格式: ${ext},仅支持 .xlsx .xls .csv`)
process.exit(1)
}
if (rows.length === 0) {
console.error('表格为空(无数据行)')
process.exit(1)
}
// 创建 batch 输出目录
const dateStr = formatDate(new Date())
let seq = 1
while (fs.existsSync(path.join(OUTPUT_BASE, `batch_${dateStr}_${String(seq).padStart(3, '0')}`))) {
seq++
}
const batchDir = path.join(OUTPUT_BASE, `batch_${dateStr}_${String(seq).padStart(3, '0')}`)
const scriptsDir = path.join(batchDir, 'scripts')
ensureDir(batchDir)
ensureDir(scriptsDir)
const defaultAccount = args.account || ''
const defaultMode = args.mode || 'single'
const defaultVoice = args.voice || ''
// 构建 items + 提取脚本
const items = []
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
const script = extractField(row, ['脚本', 'script', '文案', '旁白'])
const title = extractField(row, ['选题', '标题', 'title', 'name']) || `视频${i + 1}`
const account = extractField(row, ['账号', 'account']) || defaultAccount
const mode = extractField(row, ['模式', 'mode']) || defaultMode
const voiceName = extractField(row, ['音色', 'voice']) || defaultVoice
if (!script || !script.trim()) {
console.warn(` ⚠ 第 ${i + 2} 行(${title})脚本为空,跳过`)
continue
}
const scriptFile = path.join(scriptsDir, `row_${String(i + 1).padStart(3, '0')}.txt`)
fs.writeFileSync(scriptFile, script.trim(), 'utf-8')
// 解析音色名称 → ID
const resolvedVoice = voiceName ? resolveVoice(voiceName) : ''
items.push({
row: i + 1,
title,
account: account || defaultAccount,
mode: mode || defaultMode,
voice: resolvedVoice,
scriptFile: `scripts/row_${String(i + 1).padStart(3, '0')}.txt`,
status: 'pending',
manifestPath: null,
error: null,
})
}
if (items.length === 0) {
console.error('没有有效的脚本行')
process.exit(1)
}
// 校验账号
validateAccounts(items)
// 写 batch-manifest
const batchManifest = {
source: path.basename(filePath),
createdAt: new Date().toISOString(),
defaults: { account: defaultAccount, mode: defaultMode, voice: defaultVoice ? resolveVoice(defaultVoice) : '' },
stats: calcStats(items),
items,
}
const manifestPath = path.join(batchDir, 'batch-manifest.json')
writeJson(manifestPath, batchManifest)
console.log(`\n批量任务已创建: ${manifestPath}`)
console.log(` 来源: ${path.basename(filePath)}`)
console.log(` 总数: ${items.length}`)
console.log(` 默认账号: ${defaultAccount || '(未指定,需每行填写)'}`)
console.log(` 默认模式: ${defaultMode}`)
console.log(` 默认音色: ${defaultVoice || '(用账号配置)'}`)
console.log(` 脚本目录: ${scriptsDir}/`)
console.log()
}
// ============================================================================
// status: 展示批量进度
// ============================================================================
function cmdStatus(args) {
const manifestPath = path.resolve(args.file)
const batch = readJson(manifestPath)
const batchDir = path.dirname(manifestPath)
console.log(`\n批量任务: ${batch.source}`)
console.log(` 创建时间: ${batch.createdAt}`)
console.log(` 进度: ${batch.stats.completed}/${batch.stats.total} 完成`)
console.log()
const grouped = { pending: [], processing: [], completed: [], failed: [] }
for (const item of batch.items) {
const list = grouped[item.status] || grouped.pending
list.push(item)
}
if (grouped.completed.length > 0) {
console.log(` ✅ 完成 (${grouped.completed.length}):`)
for (const it of grouped.completed) {
console.log(` #${it.row} ${it.title}${it.manifestPath || ''}`)
}
}
if (grouped.failed.length > 0) {
console.log(` ❌ 失败 (${grouped.failed.length}):`)
for (const it of grouped.failed) {
console.log(` #${it.row} ${it.title}${it.error || '未知错误'}`)
}
}
if (grouped.processing.length > 0) {
console.log(` 🔄 进行中 (${grouped.processing.length}):`)
for (const it of grouped.processing) {
console.log(` #${it.row} ${it.title}`)
}
}
if (grouped.pending.length > 0) {
console.log(` ⏳ 待处理 (${grouped.pending.length}):`)
for (const it of grouped.pending) {
console.log(` #${it.row} ${it.title} (账号: ${it.account || '未指定'}, 模式: ${it.mode}, 音色: ${it.voice || '账号默认'})`)
}
}
// 输出下一个待处理的行号(方便 AI agent 消费)
const next = batch.items.find(it => it.status === 'pending')
if (next) {
console.log(`\n ▶ 下一条: #${next.row} (账号: ${next.account}, 模式: ${next.mode}, 音色: ${next.voice || '账号默认'})`)
console.log(` 脚本文件: ${path.resolve(batchDir, next.scriptFile)}`)
}
console.log()
}
// ============================================================================
// mark: 标记某行状态
// ============================================================================
function cmdMark(args) {
const manifestPath = path.resolve(args.file)
const batch = readJson(manifestPath)
const item = batch.items.find(it => it.row === args.row)
if (!item) {
console.error(`${args.row} 不存在`)
process.exit(1)
}
const validStatuses = ['pending', 'processing', 'completed', 'failed']
if (!validStatuses.includes(args.status)) {
console.error(`无效状态: ${args.status},可选: ${validStatuses.join(', ')}`)
process.exit(1)
}
const oldStatus = item.status
item.status = args.status
if (args.manifestPath) item.manifestPath = args.manifestPath
if (args.error) item.error = args.error
if (args.status !== 'failed') item.error = null
batch.stats = calcStats(batch.items)
writeJson(manifestPath, batch)
console.log(`#${item.row} ${item.title}: ${oldStatus}${args.status}`)
}
// ============================================================================
// retry-failed: 重置失败行
// ============================================================================
function cmdRetryFailed(args) {
const manifestPath = path.resolve(args.file)
const batch = readJson(manifestPath)
let count = 0
for (const item of batch.items) {
if (item.status === 'failed') {
item.status = 'pending'
item.error = null
count++
}
}
batch.stats = calcStats(batch.items)
writeJson(manifestPath, batch)
console.log(`已重置 ${count} 条失败记录为 pending`)
}
// ============================================================================
// next: 输出下一条待处理的信息(机器友好格式)
// ============================================================================
function cmdNext(args) {
const manifestPath = path.resolve(args.file)
const batch = readJson(manifestPath)
const batchDir = path.dirname(manifestPath)
const item = batch.items.find(it => it.status === 'pending')
if (!item) {
console.log(JSON.stringify({ done: true }))
return
}
console.log(JSON.stringify({
done: false,
row: item.row,
title: item.title,
account: item.account,
mode: item.mode,
voice: item.voice || '',
scriptFile: path.resolve(batchDir, item.scriptFile),
}))
}
// ============================================================================
// Helpers
// ============================================================================
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true })
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
}
function writeJson(filePath, data) {
const tmp = filePath + '.tmp'
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8')
fs.renameSync(tmp, filePath)
}
function calcStats(items) {
const stats = { total: items.length, pending: 0, processing: 0, completed: 0, failed: 0 }
for (const item of items) {
if (stats[item.status] !== undefined) stats[item.status]++
else stats.pending++
}
return stats
}
function formatDate(d) {
return [
d.getFullYear(),
String(d.getMonth() + 1).padStart(2, '0'),
String(d.getDate()).padStart(2, '0'),
].join('')
}
function extractField(row, names) {
for (const name of names) {
if (row[name] != null && String(row[name]).trim()) return String(row[name]).trim()
}
return ''
}
function validateAccounts(items) {
const uniqueAccounts = [...new Set(items.map(it => it.account).filter(Boolean))]
const missing = uniqueAccounts.filter(acc => {
return !fs.existsSync(path.join(ACCOUNTS_DIR, acc, 'account.json'))
})
if (missing.length > 0) {
console.warn(`\n ⚠ 以下账号不存在: ${missing.join(', ')}`)
const available = fs.readdirSync(ACCOUNTS_DIR).filter(d => {
if (d.startsWith('_')) return false
return fs.existsSync(path.join(ACCOUNTS_DIR, d, 'account.json'))
})
console.warn(' 可用账号:')
for (const acc of available) {
try {
const cfg = readJson(path.join(ACCOUNTS_DIR, acc, 'account.json'))
console.warn(` - ${acc} (${cfg.name})`)
} catch {}
}
console.warn()
}
}
// ============================================================================
// Excel / CSV 解析
// ============================================================================
function parseExcel(filePath) {
try {
const XLSX = require('xlsx')
const workbook = XLSX.readFile(filePath)
const sheetName = workbook.SheetNames[0]
const sheet = workbook.Sheets[sheetName]
return XLSX.utils.sheet_to_json(sheet)
} catch (err) {
if (err.code === 'MODULE_NOT_FOUND') {
console.error('需要安装 xlsx: cd scripts && pnpm add xlsx')
process.exit(1)
}
throw err
}
}
function parseCsv(filePath) {
const content = fs.readFileSync(filePath, 'utf-8')
const lines = content.split(/\r?\n/).filter(l => l.trim())
if (lines.length < 2) return []
const headers = parseCsvLine(lines[0])
const rows = []
for (let i = 1; i < lines.length; i++) {
const values = parseCsvLine(lines[i])
const row = {}
headers.forEach((h, j) => { row[h.trim()] = (values[j] || '').trim() })
rows.push(row)
}
return rows
}
function parseCsvLine(line) {
const result = []
let current = ''
let inQuotes = false
for (const char of line) {
if (char === '"') { inQuotes = !inQuotes }
else if (char === ',' && !inQuotes) { result.push(current); current = '' }
else { current += char }
}
result.push(current)
return result
}
// ============================================================================
// CLI 入口
// ============================================================================
function main() {
const args = parseArgs(process.argv.slice(2))
const command = args.command
if (command === 'init') {
if (!args.file) {
console.error('用法: batch-pipeline.js init --file <xlsx/csv> [--account <账号>] [--mode <模式>]')
process.exit(1)
}
cmdInit(args)
} else if (command === 'status') {
if (!args.file) {
console.error('用法: batch-pipeline.js status --file <batch-manifest.json>')
process.exit(1)
}
cmdStatus(args)
} else if (command === 'mark') {
if (!args.file || !args.row || !args.status) {
console.error('用法: batch-pipeline.js mark --file <batch-manifest.json> --row <N> --status <pending|processing|completed|failed> [--manifest-path <path>] [--error <msg>]')
process.exit(1)
}
cmdMark(args)
} else if (command === 'retry-failed') {
if (!args.file) {
console.error('用法: batch-pipeline.js retry-failed --file <batch-manifest.json>')
process.exit(1)
}
cmdRetryFailed(args)
} else if (command === 'next') {
if (!args.file) {
console.error('用法: batch-pipeline.js next --file <batch-manifest.json>')
process.exit(1)
}
cmdNext(args)
} else {
console.log('批量视频生产编排器')
console.log('')
console.log('用法:')
console.log(' batch-pipeline.js init --file <xlsx/csv> [--account <账号>] [--mode <single|framePair>] [--voice <音色>]')
console.log(' batch-pipeline.js status --file <batch-manifest.json>')
console.log(' batch-pipeline.js next --file <batch-manifest.json>')
console.log(' batch-pipeline.js mark --file <...> --row <N> --status <pending|processing|completed|failed> [--manifest-path <path>] [--error <msg>]')
console.log(' batch-pipeline.js retry-failed --file <batch-manifest.json>')
console.log('')
console.log('Excel 格式:')
console.log(' 选题 | 脚本 | 账号 | 模式')
console.log(' 选题/标题/title — 标题(可选)')
console.log(' 脚本/文案/旁白 — 口播文案(必填)')
console.log(' 账号/account — 账号ID可选可由 --account 指定默认值)')
console.log(' 模式/mode — single|framePair可选可由 --mode 指定默认值)')
console.log(' 音色/voice — 音色名称或ID可选可由 --voice 指定默认值)')
}
}
if (require.main === module) {
main()
}
module.exports = { cmdInit, cmdStatus, cmdMark, cmdRetryFailed, cmdNext }

View File

@@ -178,7 +178,21 @@ async function assemble(args) {
// 测量实际时长
let audioMeasured = 0, videoMeasured = 0
for (const item of items) {
if (item.audio && !item.audio.startsWith('http')) {
if (item.segments && item.segments.length > 0) {
let totalDur = 0
for (const seg of item.segments) {
if (seg.audio && !seg.error) {
const segPath = path.isAbsolute(seg.audio) ? seg.audio : path.resolve(inputDir, seg.audio)
if (fs.existsSync(segPath)) {
const d = await getAudioDurationSec(segPath)
if (d != null) totalDur += d
} else if (seg.duration) {
totalDur += seg.duration
}
}
}
if (totalDur > 0) { item.audioDuration = totalDur; audioMeasured++ }
} else if (item.audio && !item.audio.startsWith('http')) {
const audioPath = path.isAbsolute(item.audio)
? item.audio
: path.resolve(inputDir, item.audio)

View File

@@ -402,57 +402,28 @@ async function batchGenerate(tasks, options = {}) {
return results
}
/**
* 轮询 + 失败重试(单任务)
*/
const { makePollWithRetry } = require('./lib/video-poll-utils')
const pollWithRetryBase = makePollWithRetry({
Api: GrokApi,
suffix: '_grok',
duration: 6,
maxRetries: Config.maxRetries,
optimizePrompt: (prompt, failReason, attempt) => PromptOptimizer.optimize(prompt, failReason, attempt),
buildCreateOpts: (options) => ({ aspectRatio: options.aspectRatio, size: options.size }),
})
async function pollWithRetry(taskId, prompt, options = {}) {
let currentTaskId = taskId
let currentPrompt = prompt
let lastError = null
const result = await pollWithRetryBase(taskId, prompt, options)
for (let attempt = 0; attempt <= Config.maxRetries; attempt++) {
try {
if (attempt > 0) {
currentPrompt = PromptOptimizer.optimize(prompt, lastError, attempt)
console.log(`\n 🔄 重试 (任务 ${currentTaskId.substring(0, 8)}...): ${currentPrompt.substring(0, 50)}`)
currentTaskId = await GrokApi.create(
options.imageUrl || '',
currentPrompt,
{ aspectRatio: options.aspectRatio, size: options.size }
)
}
const result = await GrokApi.poll(currentTaskId)
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const videoFile = path.join(options.outputDir || './output', `${timestamp}_grok.mp4`)
await download(result.videoUrl, videoFile)
let thumbnailFile = null
if (result.thumbnailUrl) {
thumbnailFile = path.join(options.outputDir || './output', `${timestamp}_thumb.jpg`)
try { await download(result.thumbnailUrl, thumbnailFile) } catch (_) {}
}
return {
taskId: currentTaskId,
prompt: currentPrompt,
originalPrompt: prompt,
attempts: attempt + 1,
file: videoFile,
files: [videoFile],
duration: 6,
thumbnail: thumbnailFile,
}
} catch (err) {
lastError = err.message
if (attempt < Config.maxRetries) {
await new Promise(r => setTimeout(r, 5000))
}
}
let thumbnailFile = null
if (result.thumbnailUrl) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
thumbnailFile = path.join(options.outputDir || './output', `${timestamp}_thumb.jpg`)
try { await download(result.thumbnailUrl, thumbnailFile) } catch (_) {}
}
throw new Error(`重试 ${Config.maxRetries} 次后仍失败: ${lastError}`)
return { ...result, thumbnail: thumbnailFile }
}
// ============================================================================

View File

@@ -503,51 +503,16 @@ async function batchGenerate(tasks, options = {}) {
return results
}
/**
* 轮询 + 失败重试(单任务)
*/
async function pollWithRetry(taskId, prompt, options = {}) {
let currentTaskId = taskId
let currentPrompt = prompt
let lastError = null
const { makePollWithRetry } = require('./lib/video-poll-utils')
for (let attempt = 0; attempt <= Config.maxRetries; attempt++) {
try {
if (attempt > 0) {
currentPrompt = PromptOptimizer.optimize(prompt, lastError, attempt)
console.log(`\n 🔄 重试 (任务 ${currentTaskId.substring(0, 8)}...): ${currentPrompt.substring(0, 50)}`)
currentTaskId = await KlingApi.create(
options.imageUrl || '',
currentPrompt,
{ duration: options.duration, mode: options.mode, lastFrameUrl: options.lastFrameUrl || '' }
)
}
const result = await KlingApi.poll(currentTaskId)
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const videoFile = path.join(options.outputDir || './output', `${timestamp}_kling.mp4`)
await download(result.videoUrl, videoFile)
return {
taskId: currentTaskId,
prompt: currentPrompt,
originalPrompt: prompt,
attempts: attempt + 1,
file: videoFile,
files: [videoFile],
duration: 6,
}
} catch (err) {
lastError = err.message
if (attempt < Config.maxRetries) {
await new Promise(r => setTimeout(r, 5000))
}
}
}
throw new Error(`重试 ${Config.maxRetries} 次后仍失败: ${lastError}`)
}
const pollWithRetry = makePollWithRetry({
Api: KlingApi,
suffix: '_kling',
duration: 6,
maxRetries: Config.maxRetries,
optimizePrompt: (prompt, failReason, attempt) => PromptOptimizer.optimize(prompt, failReason, attempt),
buildCreateOpts: (options) => ({ duration: options.duration, mode: options.mode, lastFrameUrl: options.lastFrameUrl || '' }),
})
// ============================================================================
// CLI

View File

@@ -6,7 +6,7 @@
const fs = require('fs')
const path = require('path')
const { loadAccountConfig, saveManifest, ensureDir, slugify, ACCOUNTS_DIR, SKILLS_DIR } = require('./pipeline-utils')
const { loadAccountConfig, loadConfig, resolveVoice, saveManifest, ensureDir, slugify, ACCOUNTS_DIR, SKILLS_DIR } = require('./pipeline-utils')
function initManifest(options) {
const { account: accountId, mode, items: itemsJson, itemsFile } = options
@@ -17,6 +17,7 @@ function initManifest(options) {
}
const accountConfig = loadAccountConfig(accountId)
const globalConfig = loadConfig()
// 解析 items
let rawItems
@@ -123,7 +124,8 @@ function initManifest(options) {
format: options.format || accountConfig.defaultFormat || '9:16',
mode: resolvedMode,
references,
...(accountConfig.ttsVoice ? { ttsVoice: accountConfig.ttsVoice } : {}),
...(accountConfig.ttsVoice ? { ttsVoice: resolveVoice(accountConfig.ttsVoice, globalConfig) } : {}),
...(options.ttsVoice ? { ttsVoice: resolveVoice(options.ttsVoice, globalConfig) } : {}),
...(accountConfig.ttsInstruction ? { ttsInstruction: accountConfig.ttsInstruction } : {}),
// 铁律ttsRate 写死 1.15x,不允许配置覆盖(除非显式传入)
ttsRate: options.ttsRate || 1.15,

View File

@@ -3,11 +3,8 @@
*
* 图生视频,批量提交,生成后自动上传 OSS
* 支持 task ID 恢复:中断后重跑时优先恢复已有任务
*
* ⚠注意items 必须 confirmed=true 才能进入视频生成阶段
*/
const fs = require('fs')
const path = require('path')
const { saveManifest, ensureDir, log, getManifestDir } = require('./pipeline-utils')
@@ -26,8 +23,7 @@ async function phaseVideos(manifest, manifestPath, options) {
const videoCandidates = manifest.items.filter(it => {
if (it.confirmed === false) return false
if (!it.url || !it.videoPrompt) return false
if (['done', 'pending', 'failed'].includes(it.status)) return true
return false
return ['done', 'pending', 'failed'].includes(it.status)
})
if (videoCandidates.length === 0) {
@@ -48,26 +44,19 @@ async function phaseVideos(manifest, manifestPath, options) {
console.log()
}
// 对重试 item 自动清理旧视频引用,无需 agent 手动删除
// 已有视频(本地或 OSS且状态为 done 的跳过,其余清理后重新生成
const items = []
for (const it of videoCandidates) {
if (it.video) {
if (it.status === 'done') continue // 已有视频且完成,跳过
delete it.video // pending/failed 但有旧 video → 清理重来
if (it.video || it.videoUrl) {
if (it.status === 'done') continue
delete it.video
delete it.videoUrl
delete it.videoDuration
delete it.videoTaskId
}
items.push(it)
}
const pendingItems = items.filter(it => !it.video)
if (pendingItems.length === 0) {
// 有 videoCandidates 但全部已有 video → 直接返回(不打印跳过消息)
return
}
log('videos', `${pendingItems.length} 个待处理`)
if (items.length === 0) { log('videos', '无待处理 item跳过'); return }
// 选择生成器
let Api, pollFn
@@ -98,7 +87,6 @@ async function phaseVideos(manifest, manifestPath, options) {
}
}
// 轮询恢复的任务
if (recovered.length > 0) {
log('videos', `尝试恢复 ${recovered.length} 个中断任务...`)
await Promise.allSettled(
@@ -114,6 +102,7 @@ async function phaseVideos(manifest, manifestPath, options) {
if (result.file) {
item.video = path.relative(dir, result.file).replace(/\\/g, '/')
item.videoDuration = result.duration
item.status = 'done'
delete item.videoTaskId
log('videos', ` item ${item.id} 恢复成功`)
}
@@ -122,29 +111,25 @@ async function phaseVideos(manifest, manifestPath, options) {
delete item.videoTaskId
needSubmit.push(item)
}
saveManifest(manifestPath, manifest)
})
)
saveManifest(manifestPath, manifest)
}
if (needSubmit.length === 0) { log('videos', '全部通过恢复完成'); return }
// Phase 2: 提交新任务(并发 3
const concurrency = 3
log('videos', ` ${needSubmit.length} 个新任务(并发: ${concurrency}...`)
log('videos', `提交 ${needSubmit.length} 个新任务(并发: ${concurrency}...`)
const submitted = []
for (let i = 0; i < needSubmit.length; i += concurrency) {
const batch = needSubmit.slice(i, i + concurrency)
const batchResults = await Promise.allSettled(
batch.map(async (item) => {
const images = item.lastFrameUrl
? [item.url, item.lastFrameUrl]
: [item.url]
const extraOpts = item.lastFrameUrl
? { aspectRatio: ratio, lastFrameUrl: item.lastFrameUrl, mode: 'pro' }
: { aspectRatio: ratio }
try {
const taskId = await Api.create(item.url, item.videoPrompt, extraOpts)
return { item, taskId, error: null }
@@ -197,14 +182,15 @@ async function phaseVideos(manifest, manifestPath, options) {
if (val.ok && val.result.file) {
val.item.video = path.relative(dir, val.result.file).replace(/\\/g, '/')
val.item.videoDuration = val.result.duration
val.item.status = 'done'
delete val.item.videoTaskId
} else if (val.item) {
val.item.status = 'failed'
val.item.error = val.error || '视频生成未返回文件'
delete val.item.videoTaskId
}
saveManifest(manifestPath, manifest)
}
saveManifest(manifestPath, manifest)
// 上传视频到 OSS
const { uploadFile } = require('../oss-upload')
@@ -220,11 +206,9 @@ async function phaseVideos(manifest, manifestPath, options) {
} catch (err) {
log('videos', ` ${item.video} 上传失败: ${err.message}`)
}
saveManifest(manifestPath, manifest)
}
saveManifest(manifestPath, manifest)
}
saveManifest(manifestPath, manifest)
}
module.exports = { phaseVideos }

View File

@@ -8,11 +8,12 @@ const fs = require('fs')
const path = require('path')
// 路径常量(基于 lib/ 的父目录 scripts/
const SCRIPTS_DIR = path.join(__dirname, '..')
const SKILLS_DIR = path.join(SCRIPTS_DIR, '..')
const PROJECT_ROOT = path.join(SKILLS_DIR, '..', '..')
const CONFIG_PATH = path.join(SKILLS_DIR, 'config.json')
const ACCOUNTS_DIR = path.join(PROJECT_ROOT, '..', 'accounts')
const SCRIPTS_DIR = path.join(__dirname, '..') // scripts/
const SKILLS_DIR = path.join(SCRIPTS_DIR, '..') // video-from-script/
const SKILL_PARENT_DIR = path.join(SKILLS_DIR, '..') // skills/
const PROJECT_ROOT = path.join(SKILLS_DIR, '..', '..') // .claude/
const CONFIG_PATH = path.join(SKILL_PARENT_DIR, 'config.json') // skills/config.json
const ACCOUNTS_DIR = path.join(PROJECT_ROOT, '..', 'accounts') // 美图/accounts
// ============================================================================
// 配置 & Manifest
@@ -22,6 +23,15 @@ function loadConfig() {
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'))
}
/**
* 解析音色:名称 → ID。如果是音色库中的名称则查 ttsVoices 映射表,否则原样返回。
*/
function resolveVoice(voice, config) {
if (!voice) return voice
const voices = (config || loadConfig()).ttsVoices || {}
return voices[voice] || voice
}
function loadManifest(manifestPath) {
return JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
}
@@ -228,6 +238,7 @@ module.exports = {
CONFIG_PATH,
ACCOUNTS_DIR,
loadConfig,
resolveVoice,
loadManifest,
saveManifest,
loadAccountConfig,

View File

@@ -0,0 +1,85 @@
/**
* 共享视频轮询重试工具
*
* 提供 pollWithRetry 工厂函数,供 kling/veo/grok 三个视频生成器共用。
* 两层重试:轮询级(同一 taskId处理网络瞬断→ 任务级(创建新 task + 优化提示词)
*/
const TRANSIENT_RE = /timeout|ECONNRESET|ETIMEDOUT|network|socket/i
const POLL_RETRIES = 2 // 同一 task 轮询重试次数
const POLL_RETRY_DELAY = 5000 // 轮询重试间隔 ms
const TASK_RETRY_DELAY = 5000 // 任务级重试间隔 ms
function isTransientError(err) {
return TRANSIENT_RE.test(err.message || '')
}
/**
* 创建 pollWithRetry 函数
*
* @param {object} opts
* @param {object} opts.Api - 有 create() 和 poll() 方法的 API 对象
* @param {string} opts.suffix - 输出文件后缀(如 '_kling'
* @param {number} opts.duration - 视频时长(秒)
* @param {number} [opts.maxRetries=3] - 任务级最大重试次数
* @param {function} [opts.optimizePrompt] - 提示词优化函数 (prompt, failReason, attempt) => optimizedPrompt
* @param {function} opts.buildCreateOpts - (item_options) => create() 的第三个参数
* @returns {function} pollWithRetry(taskId, prompt, options)
*/
function makePollWithRetry({ Api, suffix, duration, maxRetries = 3, optimizePrompt, buildCreateOpts }) {
return async function pollWithRetry(taskId, prompt, options = {}) {
let currentTaskId = taskId
let currentPrompt = prompt
let lastError = null
for (let attempt = 0; attempt <= maxRetries; attempt++) {
if (attempt > 0) {
if (optimizePrompt) {
currentPrompt = optimizePrompt(prompt, lastError, attempt)
}
console.log(`\n 🔄 重试 (任务 ${currentTaskId.substring(0, 8)}...): ${currentPrompt.substring(0, 50)}`)
const createOpts = buildCreateOpts(options)
currentTaskId = await Api.create(options.imageUrl || '', currentPrompt, createOpts)
}
const outputDir = options.outputDir || './output'
for (let pollAttempt = 0; pollAttempt <= POLL_RETRIES; pollAttempt++) {
try {
const result = await Api.poll(currentTaskId)
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const videoFile = path.join(outputDir, `${timestamp}${suffix}.mp4`)
await download(result.videoUrl, videoFile)
return {
taskId: currentTaskId,
prompt: currentPrompt,
originalPrompt: prompt,
attempts: attempt + 1,
file: videoFile,
files: [videoFile],
duration,
}
} catch (err) {
lastError = err.message
if (isTransientError(err) && pollAttempt < POLL_RETRIES) {
console.log(` ⚠ 轮询瞬断 (${pollAttempt + 1}/${POLL_RETRIES}): ${err.message.slice(0, 60)}`)
await new Promise(r => setTimeout(r, POLL_RETRY_DELAY))
continue
}
break
}
}
if (attempt < maxRetries) {
await new Promise(r => setTimeout(r, TASK_RETRY_DELAY))
}
}
throw new Error(`重试 ${maxRetries} 次后仍失败: ${lastError}`)
}
}
module.exports = { makePollWithRetry, POLL_RETRIES, POLL_RETRY_DELAY, TASK_RETRY_DELAY }

View File

@@ -7,7 +7,8 @@
"dependencies": {
"ali-oss": "^6.21.0",
"axios": "^1.15.2",
"sharp": "^0.34.5"
"sharp": "^0.34.5",
"xlsx": "^0.18.5"
}
},
"node_modules/@img/colour": {
@@ -47,6 +48,15 @@
"node": ">= 10.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agentkeepalive": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-3.5.3.tgz",
@@ -159,6 +169,28 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -192,6 +224,18 @@
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/dateformat": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-2.2.0.tgz",
@@ -416,6 +460,15 @@
"pause-stream": "~0.0.11"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -992,6 +1045,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/statuses": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
@@ -1151,12 +1216,51 @@
"semver": "bin/semver"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",

View File

@@ -2,6 +2,7 @@
"dependencies": {
"ali-oss": "^6.21.0",
"axios": "^1.15.2",
"sharp": "^0.34.5"
"sharp": "^0.34.5",
"xlsx": "^0.18.5"
}
}

View File

@@ -168,6 +168,7 @@ function parseArgs(argv) {
else if (argv[i] === '--format' && argv[i + 1]) args.format = argv[++i]
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] === '--tts-voice' && argv[i + 1]) args.ttsVoice = argv[++i]
else if (argv[i] === '--references' && argv[i + 1]) args.references = argv[++i]
else if (argv[i] === '--all') args.all = true
else if (!args.command) args.command = argv[i]
@@ -225,7 +226,7 @@ async function main() {
console.log('用法:')
console.log(' pipeline.js create-account --id <id> --name <名称> [--desc ...] [--references file1,file2]')
console.log(' pipeline.js validate-account --account <id>')
console.log(' pipeline.js init --account <id> --mode <single|framePair> --items <JSON> [--items-file <path>] [--image-model gemini|gpt-image|mj] [--video-model veo3-fast|grok|kling] [--format 9:16]')
console.log(' pipeline.js init --account <id> --mode <single|framePair> --items <JSON> [--items-file <path>] [--image-model gemini|gpt-image|mj] [--video-model veo3-fast|grok|kling] [--format 9:16] [--tts-voice <音色>]')
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')

View File

@@ -37,6 +37,15 @@ function loadConfig() {
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'))
}
/**
* 解析音色:名称 → ID。如果是名称则查 ttsVoices 映射表,否则原样返回。
*/
function resolveVoice(voice, config) {
if (!voice) return voice
const voices = config.ttsVoices || {}
return voices[voice] || voice
}
function getAudioDuration(filePath) {
try {
const out = execFileSync('ffprobe', [
@@ -64,7 +73,7 @@ function synthesize(text, options = {}) {
if (!apiKey) { reject(new Error('ttsApiKey 未配置')); return }
const model = options.model || config.ttsModel || 'cosyvoice-v3-flash'
const voice = options.voice || config.ttsVoice || 'longanyang'
const voice = resolveVoice(options.voice || config.ttsVoice, config) || 'longanyang'
const instruction = options.instruction || config.ttsInstruction || ''
const outputDir = options.outputDir || './audio'

View File

@@ -406,51 +406,16 @@ async function batchGenerate(tasks, options = {}) {
return results
}
/**
* 轮询 + 失败重试(单任务)
*/
async function pollWithRetry(taskId, prompt, options = {}) {
let currentTaskId = taskId
let currentPrompt = prompt
let lastError = null
const { makePollWithRetry } = require('./lib/video-poll-utils')
for (let attempt = 0; attempt <= Config.maxRetries; attempt++) {
try {
if (attempt > 0) {
currentPrompt = PromptOptimizer.optimize(prompt, lastError, attempt)
console.log(`\n 🔄 重试 (任务 ${currentTaskId.substring(0, 8)}...): ${currentPrompt.substring(0, 50)}`)
currentTaskId = await VeoApi.create(
options.imageUrl || '',
currentPrompt,
{ aspectRatio: options.aspectRatio, lastFrameUrl: options.lastFrameUrl || '' }
)
}
const result = await VeoApi.poll(currentTaskId)
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const videoFile = path.join(options.outputDir || './output', `${timestamp}_veo.mp4`)
await download(result.videoUrl, videoFile)
return {
taskId: currentTaskId,
prompt: currentPrompt,
originalPrompt: prompt,
attempts: attempt + 1,
file: videoFile,
files: [videoFile],
duration: 8,
}
} catch (err) {
lastError = err.message
if (attempt < Config.maxRetries) {
await new Promise(r => setTimeout(r, 5000))
}
}
}
throw new Error(`重试 ${Config.maxRetries} 次后仍失败: ${lastError}`)
}
const pollWithRetry = makePollWithRetry({
Api: VeoApi,
suffix: '_veo',
duration: 8,
maxRetries: Config.maxRetries,
optimizePrompt: (prompt, failReason, attempt) => PromptOptimizer.optimize(prompt, failReason, attempt),
buildCreateOpts: (options) => ({ aspectRatio: options.aspectRatio, lastFrameUrl: options.lastFrameUrl || '' }),
})
// ============================================================================
// CLI

View File

@@ -9,6 +9,7 @@
| 生图、批量出图、MJ、Gemini | `image-generator` |
| 成片、组装、剪映、图片轮播 | `capcut` |
| 做视频、图文成片、图生视频、首尾帧 | `video-from-script` |
| 批量生产、给Excel出视频 | `video-from-script`(批量模式,见下方) |
| 创建账号、新账号 | 参考 [account-creation.md](.claude/skills/video-from-script/references/account-creation.md) |
# 工作流
@@ -30,6 +31,42 @@
| 重新生成草稿、重做草稿、草稿再生 | 1. 将 manifest 中 `pipeline.phases.assemble` 改为 `"pending"`2. 执行 `node .claude/skills/video-from-script/scripts/pipeline.js run --manifest output/{name}/manifest.json --phase assemble` |
| 查看草稿进度、草稿状态 | `node .claude/skills/video-from-script/scripts/pipeline.js status --manifest output/{name}/manifest.json` |
| 重跑某个阶段 | 将 manifest 中对应 phase 改为 `"pending"`,再跑 `--phase <阶段名>`。阶段: `images``upload``videos``tts``assemble` |
| 批量生产、给Excel出视频 | 见下方「批量生产」 |
**草稿 = CapCut 剪映项目文件**,由 pipeline 的 `assemble` 阶段生成,输出到本地剪映目录。
# 批量生产
用户给一个 Excel/CSV每行一条视频Agent 逐条 spawn Worker 子 Agent 执行完整 pipeline。
**Excel 格式:** `选题 | 脚本 | 账号 | 模式 | 音色`(账号/模式/音色可选,可由 CLI 参数指定默认值)
**CLI 命令:**
```bash
# 1. 初始化批量任务
node .claude/skills/video-from-script/scripts/batch-pipeline.js init --file <xlsx/csv> --account <默认账号> --mode single --voice <默认音色>
# 2. 查看进度
node .claude/skills/video-from-script/scripts/batch-pipeline.js status --file output/batch_XXX/batch-manifest.json
# 3. 获取下一条待处理JSON 格式)
node .claude/skills/video-from-script/scripts/batch-pipeline.js next --file output/batch_XXX/batch-manifest.json
# 4. 标记状态
node .claude/skills/video-from-script/scripts/batch-pipeline.js mark --file ... --row <N> --status <completed|failed> [--manifest-path <path>] [--error <msg>]
# 5. 重跑失败项
node .claude/skills/video-from-script/scripts/batch-pipeline.js retry-failed --file output/batch_XXX/batch-manifest.json
```
**执行策略Orchestrator-Worker**
- **Orchestrator主 Agent**:读 batch-manifest 元数据,逐条 spawn Worker 子 Agent收集结果
- **Worker子 Agent**:独立上下文,处理单条视频的完整流程(分镜 → 生图 → 生视频 → TTS → 成片。Worker 调用 `pipeline.js init` 时通过 `--tts-voice` 传入音色
- Orchestrator 上下文只存 batch-manifest 元数据,不读脚本正文
- 脚本正文通过文件路径传给 WorkerWorker 自行 Read
- 批量模式下人工确认环节自动跳过(`confirm --all`
**草稿 = CapCut 剪映项目文件**,由 pipeline 的 `assemble` 阶段生成,输出到本地剪映目录。

View File

@@ -14,7 +14,7 @@
]
}
},
"ttsVoice": "cosyvoice-v3.5-plus-bailian-fa8787c0f70b4ba2a907c35511e6a6f6",
"ttsVoice": "斯内普",
"ttsInstruction": "用沉稳有力的男性声音朗读,语速适中偏慢,语气低沉、坚定、有压迫感,像是一个看透人性的老手在冷静地讲述残酷的真相",
"storyboardPrompt": "prompts/分镜.md",
"imageStylePrompt": "prompts/图片提示词.md",

View File

@@ -12,7 +12,7 @@
"references": []
}
},
"ttsVoice": "cosyvoice-v3.5-plus-bailian-fa8787c0f70b4ba2a907c35511e6a6f6",
"ttsVoice": "斯内普",
"ttsInstruction": "用沉稳有力的男性声音朗读,语速适中,语气坚定有力,像是一个有经历有力量的人在平静地讲述生活的方向",
"storyboardPrompt": "prompts/分镜.md",
"imageStylePrompt": "prompts/图片提示词.md",