Compare commits
6 Commits
540d104d72
...
163a83ab6d
| Author | SHA1 | Date | |
|---|---|---|---|
| 163a83ab6d | |||
| 06f44ddafa | |||
| 7b743dc701 | |||
| 18fce1b5a1 | |||
| 4a15e38169 | |||
| cfdf30d438 |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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。
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
@@ -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 辅助查找 |
|
||||
|
||||
|
||||
@@ -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 | 图片提示词模板路径(相对于账号目录) |
|
||||
|
||||
463
.claude/skills/video-from-script/scripts/batch-pipeline.js
Normal file
463
.claude/skills/video-from-script/scripts/batch-pipeline.js
Normal 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 }
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
37
CLAUDE.md
37
CLAUDE.md
@@ -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 元数据,不读脚本正文
|
||||
- 脚本正文通过文件路径传给 Worker,Worker 自行 Read
|
||||
- 批量模式下人工确认环节自动跳过(`confirm --all`)
|
||||
|
||||
**草稿 = CapCut 剪映项目文件**,由 pipeline 的 `assemble` 阶段生成,输出到本地剪映目录。
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"ttsVoice": "cosyvoice-v3.5-plus-bailian-fa8787c0f70b4ba2a907c35511e6a6f6",
|
||||
"ttsVoice": "斯内普",
|
||||
"ttsInstruction": "用沉稳有力的男性声音朗读,语速适中偏慢,语气低沉、坚定、有压迫感,像是一个看透人性的老手在冷静地讲述残酷的真相",
|
||||
"storyboardPrompt": "prompts/分镜.md",
|
||||
"imageStylePrompt": "prompts/图片提示词.md",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"references": []
|
||||
}
|
||||
},
|
||||
"ttsVoice": "cosyvoice-v3.5-plus-bailian-fa8787c0f70b4ba2a907c35511e6a6f6",
|
||||
"ttsVoice": "斯内普",
|
||||
"ttsInstruction": "用沉稳有力的男性声音朗读,语速适中,语气坚定有力,像是一个有经历有力量的人在平静地讲述生活的方向",
|
||||
"storyboardPrompt": "prompts/分镜.md",
|
||||
"imageStylePrompt": "prompts/图片提示词.md",
|
||||
|
||||
Reference in New Issue
Block a user