Merge branch 'master' into feat/agent-ui

This commit is contained in:
2026-05-16 16:31:12 +08:00
39 changed files with 3606 additions and 945 deletions

View File

@@ -13,9 +13,6 @@
"grokModel": "grok-video-3",
"veoApiBaseUrl": "https://yunwu.ai",
"veoApiKey": "sk-m5inhwXqrbcBL6NNKOe7kTdhX8M31azvAvDvtSPGS71rRzd8",
"gptImageApiBaseUrl": "https://yunwu.ai",
"gptImageApiKey": "sk-m5inhwXqrbcBL6NNKOe7kTdhX8M31azvAvDvtSPGS71rRzd8",
"gptImageModel": "gpt-image-2",
"veoModel": "veo3-fast-frames",
"veoEnhancePrompt": true,
"veoEnableUpsample": true,
@@ -33,5 +30,28 @@
"ttsApiKey": "sk-1c503705b0f844a6b4f2386f6c1cc35b",
"ttsModel": "cosyvoice-v3.5-plus",
"ttsVoice": "cosyvoice-v3.5-plus-bailian-fa8787c0f70b4ba2a907c35511e6a6f6",
"ttsLanguage": "Chinese"
"ttsLanguage": "Chinese",
"ttsVoices": [
{
"name": "斯内普",
"id": "cosyvoice-v3.5-plus-bailian-fa8787c0f70b4ba2a907c35511e6a6f6",
"model": "cosyvoice-v3.5-plus",
"instruction": "用沉稳有力的男性声音朗读,语速适中,语气坚定有力,像是一个有经历有力量的人在平静地讲述生活的方向",
"style": "沉稳有力男声"
},
{
"name": "斯内普v3plus",
"id": "cosyvoice-v3-plus-bailian-155c1d86a5564d4ca981147d79e309b1",
"model": "cosyvoice-v3-plus",
"instruction": "用沉稳有力的男性声音朗读,语速适中,语气坚定有力,像是一个有经历有力量的人在平静地讲述生活的方向",
"style": "沉稳有力男声v3-plus模型"
},
{
"name": "六沉",
"id": "cosyvoice-v3.5-plus-bailian-91eb3b18acc64c96976a63a64bc6c169",
"model": "cosyvoice-v3.5-plus",
"instruction": "音量由正常对话迅速增强至高喊,性格直率,情绪易激动且外露",
"style": "直率激动,由低到高"
}
]
}

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,803 @@
#!/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 (argv[i] === '--topic' && argv[i + 1]) args.topic = argv[++i]
else if (argv[i] === '--topic-a' && argv[i + 1]) args.topicA = argv[++i]
else if (argv[i] === '--topic-b' && argv[i + 1]) args.topicB = argv[++i]
else if (argv[i] === '--draft-name' && argv[i + 1]) args.draftName = argv[++i]
else if (argv[i] === '--forward-copy' && argv[i + 1]) args.forwardCopy = argv[++i]
else if (argv[i] === '--hashtags' && argv[i + 1]) args.hashtags = argv[++i]
else if (argv[i] === '--format' && argv[i + 1]) args.format = argv[++i]
else if (argv[i] === '--draft-dir' && argv[i + 1]) args.draftDir = argv[++i]
else if (argv[i] === '--draft-url' && argv[i + 1]) args.draftUrl = argv[++i]
else if (argv[i] === '--with-script') args.withScript = true
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']) || ''
const account = extractField(row, ['账号', 'account']) || defaultAccount
const mode = extractField(row, ['模式', 'mode']) || defaultMode
const voiceName = extractField(row, ['音色', 'voice']) || defaultVoice
const forwardRaw = extractField(row, ['转发文案带话题', '转发文案', 'forwardCopy', '分享文案'])
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) : ''
// 解析转发文案带话题:分离出 hashtags 和转发文案
const { forwardCopy, hashtags } = parseForwardField(forwardRaw)
items.push({
row: i + 1,
title: title || '', // 原 Excel 选题(可为空,由 AI 后续填充)
account: account || defaultAccount,
mode: mode || defaultMode,
voice: resolvedVoice,
forwardCopy: forwardCopy || '',
hashtags: hashtags || '',
topicA: '', // 方案A: 双句封面 ≤12字
topicB: '', // 方案B: ≤4字极致精简
draftName: '', // 草稿名称: 账号_月日_序号_方案B
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)
}
const displayTitle = (it) => it.topicA || it.topic || it.title || ''
if (grouped.completed.length > 0) {
console.log(` ✅ 完成 (${grouped.completed.length}):`)
for (const it of grouped.completed) {
console.log(` #${it.row} ${displayTitle(it)}${it.manifestPath || ''}`)
}
}
if (grouped.failed.length > 0) {
console.log(` ❌ 失败 (${grouped.failed.length}):`)
for (const it of grouped.failed) {
console.log(` #${it.row} ${displayTitle(it)}${it.error || '未知错误'}`)
}
}
if (grouped.processing.length > 0) {
console.log(` 🔄 进行中 (${grouped.processing.length}):`)
for (const it of grouped.processing) {
console.log(` #${it.row} ${displayTitle(it)}`)
}
}
if (grouped.pending.length > 0) {
console.log(` ⏳ 待处理 (${grouped.pending.length}):`)
for (const it of grouped.pending) {
console.log(` #${it.row} ${displayTitle(it)} (账号: ${it.account || '未指定'}, 模式: ${it.mode}, 音色: ${it.voice || '账号默认'})`)
}
}
// 输出下一个待处理的行号(方便 AI agent 消费)
const next = batch.items.find(it => it.status === 'pending')
if (next) {
console.log(`\n ▶ 下一条: #${next.row} ${displayTitle(next)} (账号: ${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
// 可选元数据更新
if (args.topic) item.topicA = args.topic // 向后兼容 --topic
if (args.topicA) item.topicA = args.topicA
if (args.topicB) item.topicB = args.topicB
if (args.draftName) item.draftName = args.draftName
if (args.forwardCopy) item.forwardCopy = args.forwardCopy
if (args.hashtags) item.hashtags = args.hashtags
if (args.draftUrl) item.draftUrl = args.draftUrl
batch.stats = calcStats(batch.items)
writeJson(manifestPath, batch)
const label = item.topicA || item.title || ''
console.log(`#${item.row} ${label}: ${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
}
const result = {
done: false,
row: item.row,
title: item.title || '',
account: item.account,
mode: item.mode,
voice: item.voice || '',
forwardCopy: item.forwardCopy || '',
hashtags: item.hashtags || '',
topicA: item.topicA || '',
topicB: item.topicB || '',
draftName: item.draftName || '',
draftUrl: item.draftUrl || '',
scriptFile: path.resolve(batchDir, item.scriptFile),
}
// --with-script附带脚本内容方便 AI 直接基于脚本生成选题/转发文案
if (args.withScript) {
try {
result.script = fs.readFileSync(path.resolve(batchDir, item.scriptFile), 'utf-8')
} catch {
result.script = ''
}
}
console.log(JSON.stringify(result))
}
// ============================================================================
// export: 输出最终表格(含草稿名称列)
// ============================================================================
function cmdExport(args) {
const manifestPath = path.resolve(args.file)
const batch = readJson(manifestPath)
const batchDir = path.dirname(manifestPath)
// 构建导出行
const rows = []
for (const item of batch.items) {
// 读取脚本文件
let script = ''
try {
script = fs.readFileSync(path.resolve(batchDir, item.scriptFile), 'utf-8').trim()
} catch {}
// 重组转发文案带话题(避免 hashtags 重复)
const htags = (item.hashtags || '').trim()
let forwardBody = (item.forwardCopy || '').trim()
// 如果 forwardCopy 已包含 hashtags则剥离避免重复
if (htags && forwardBody.endsWith(htags)) {
forwardBody = forwardBody.slice(0, -htags.length).trim()
}
const forwardFull = [forwardBody, htags].filter(Boolean).join('')
// 选题列topicA方案A> 旧字段 topic > 原 title
const topicDisplay = item.topicA || item.topic || item.title || ''
rows.push({
row: item.row,
选题: topicDisplay,
脚本: script,
账号: item.account,
模式: item.mode,
音色: item.voice || '',
转发文案带话题: forwardFull,
草稿名称: item.draftName || '',
草稿地址: item.draftUrl || '',
})
}
// 按 row 排序
rows.sort((a, b) => a.row - b.row)
const format = args.format || 'csv'
const dateStr = formatDate(new Date())
const baseName = path.basename(manifestPath, '.json')
if (format === 'xlsx') {
exportXlsx(manifestPath, rows)
} else {
exportCsv(manifestPath, rows)
}
}
function exportCsv(manifestPath, rows) {
const outPath = manifestPath.replace('.json', '_export.csv')
const headers = ['选题', '脚本', '账号', '模式', '音色', '转发文案带话题', '草稿名称', '草稿地址']
const lines = [headers.join(',')]
for (const r of rows) {
const vals = headers.map(h => {
const v = String(r[h] || '')
// CSV 转义:含逗号、引号、换行的字段用引号包裹
if (v.includes(',') || v.includes('"') || v.includes('\n')) {
return `"${v.replace(/"/g, '""')}"`
}
return v
})
lines.push(vals.join(','))
}
fs.writeFileSync(outPath, lines.join('\n'), 'utf-8')
console.log(`表格已导出: ${outPath}`)
console.log(`${rows.length} 条记录`)
// 同时打印到控制台
console.log()
printTable(rows, headers)
}
function exportXlsx(manifestPath, rows) {
try {
const XLSX = require('xlsx')
const headers = ['选题', '脚本', '账号', '模式', '音色', '转发文案带话题', '草稿名称', '草稿地址']
const data = rows.map(r => headers.map(h => r[h] || ''))
data.unshift(headers)
const ws = XLSX.utils.aoa_to_sheet(data)
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, '视频清单')
const outPath = manifestPath.replace('.json', '_export.xlsx')
XLSX.writeFile(wb, outPath)
console.log(`表格已导出: ${outPath}`)
console.log(`${rows.length} 条记录`)
console.log()
printTable(rows, headers)
} catch (err) {
if (err.code === 'MODULE_NOT_FOUND') {
console.warn('xlsx 模块未安装,改用 CSV 格式')
exportCsv(manifestPath, rows)
} else {
throw err
}
}
}
function printTable(rows, headers) {
// 计算每列最大宽度
const widths = headers.map(h => {
const maxData = rows.reduce((m, r) => Math.max(m, String(r[h] || '').length), 0)
return Math.min(Math.max(maxData, h.length), 40) // 单列最长 40 字符
})
// 分隔线
const sep = '|-' + widths.map(w => '-'.repeat(w)).join('-|-') + '-|'
// 表头
const headerLine = '| ' + headers.map((h, i) => pad(h, widths[i])).join(' | ') + ' |'
console.log(sep)
console.log(headerLine)
console.log(sep)
for (const r of rows) {
const line = '| ' + headers.map((h, i) => pad(String(r[h] || ''), widths[i])).join(' | ') + ' |'
console.log(line)
}
console.log(sep)
}
function pad(s, width) {
// 中文字符占 2 个显示宽度
let displayLen = 0
for (const ch of s) {
displayLen += /[一-鿿＀-￯]/.test(ch) ? 2 : 1
}
const padding = Math.max(0, width - displayLen)
return s + ' '.repeat(padding)
}
// ============================================================================
// rename-drafts: 批量重命名剪映草稿箱文件夹
// Mac 版剪映草稿显示名 = 文件夹名,直接 mv 即可
// ============================================================================
function cmdRenameDrafts(args) {
const manifestPath = path.resolve(args.file)
const batch = readJson(manifestPath)
const batchDir = path.dirname(manifestPath)
// 草稿目录:默认 Mac 版 JianyingPro 路径
const homeDir = require('os').homedir()
const draftDir = args.draftDir || path.join(homeDir, 'Movies', 'JianyingPro', 'User Data', 'Projects', 'com.lveditor.draft')
if (!fs.existsSync(draftDir)) {
console.error(`草稿目录不存在: ${draftDir}`)
process.exit(1)
}
// 构建脚本指纹 → draftName 映射
const itemLookup = []
for (const it of batch.items) {
if (!it.draftName) continue
const sp = path.resolve(batchDir, it.scriptFile)
let s = ''
try { s = fs.readFileSync(sp, 'utf-8').trim().replace(/\s+/g, '') } catch {}
itemLookup.push({ row: it.row, draftName: it.draftName, sig: s.slice(0, 30) })
}
// 扫描草稿目录
const allDrafts = fs.readdirSync(draftDir)
.filter(d => fs.statSync(path.join(draftDir, d)).isDirectory())
// 匹配
const matches = []
for (const folderId of allDrafts) {
const cp = path.join(draftDir, folderId, 'draft_content.json')
if (!fs.existsSync(cp)) continue
let content
try {
const raw = fs.readFileSync(cp, 'utf-8')
if (!raw.startsWith('{')) continue
content = JSON.parse(raw)
} catch { continue }
const texts = (content.materials?.texts || [])
.filter(t => t.type === 'subtitle')
.map(t => { try { const c = JSON.parse(t.content); return c.text || '' } catch { return '' } })
const fullText = texts.join('').replace(/\s+/g, '')
if (fullText.length < 50) continue
for (const item of itemLookup) {
if (fullText.includes(item.sig)) {
matches.push({ folderId, item, dateStr: folderId.slice(0, 14) })
break
}
}
}
// 按 row 分组,按日期排序(同 row 多个草稿用 _v2 区分)
const rowGroups = {}
for (const m of matches) {
if (!rowGroups[m.item.row]) rowGroups[m.item.row] = []
rowGroups[m.item.row].push(m)
}
// 收集所有目标名称,用于跳过已改名的
const targetNames = new Set()
for (const [row, group] of Object.entries(rowGroups)) {
for (let i = 0; i < group.length; i++) {
targetNames.add(i === 0 ? group[i].item.draftName : group[i].item.draftName + '_v' + (i + 1))
}
}
let renamed = 0
for (const [row, group] of Object.entries(rowGroups)) {
group.sort((a, b) => a.dateStr.localeCompare(b.dateStr))
for (let i = 0; i < group.length; i++) {
const m = group[i]
let newName = m.item.draftName
if (i > 0) newName = m.item.draftName + '_v' + (i + 1)
// 跳过已是目标名称或已被其他行匹配占用的
if (m.folderId === newName || (targetNames.has(m.folderId) && m.folderId !== newName)) {
continue
}
const oldPath = path.join(draftDir, m.folderId)
const newPath = path.join(draftDir, newName)
if (oldPath === newPath) continue
try {
fs.renameSync(oldPath, newPath)
console.log('#' + row + ' ' + m.folderId + ' → ' + newName)
renamed++
} catch (e) {
console.log('#' + row + ' ' + m.folderId + ' FAILED: ' + e.message)
}
}
}
const unmatched = itemLookup.filter(it => !matches.some(m => m.item.row === it.row))
console.log('\n改名: ' + renamed + ' 匹配: ' + matches.length + ' 未匹配: ' + unmatched.length)
if (unmatched.length > 0) {
console.log('未匹配:')
for (const u of unmatched) console.log(' #' + u.row + ' ' + u.draftName)
}
}
// ============================================================================
// 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
}
/**
* 解析「转发文案带话题」字段
* 输入: "孩子只要长大,就会开始清算父母。#反派人格#执黑先行"
* 输出: { forwardCopy: "孩子只要长大,就会开始清算父母。", hashtags: "#反派人格#执黑先行" }
*/
function parseForwardField(raw) {
if (!raw || !raw.trim()) return { forwardCopy: '', hashtags: '' }
// 提取所有 #xxx 格式的话题
const hashtagRe = /#[^\s#]+/g
const hashtagMatches = raw.match(hashtagRe) || []
const hashtags = hashtagMatches.join('')
// 移除所有话题后剩余的是转发文案
let forwardCopy = raw
for (const tag of hashtagMatches) {
forwardCopy = forwardCopy.replace(tag, '')
}
// 清理多余空白和标点周围的空格
forwardCopy = forwardCopy.replace(/\s+/g, ' ').trim()
return { forwardCopy, hashtags }
}
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>] [--topic-a <方案A>] [--topic-b <方案B>] [--draft-name <草稿名称>] [--forward-copy <转发文案>] [--hashtags <话题>]')
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 if (command === 'export') {
if (!args.file) {
console.error('用法: batch-pipeline.js export --file <batch-manifest.json> [--format csv|xlsx]')
process.exit(1)
}
cmdExport(args)
} else if (command === 'rename-drafts') {
if (!args.file) {
console.error('用法: batch-pipeline.js rename-drafts --file <batch-manifest.json> [--draft-dir <草稿箱路径>]')
process.exit(1)
}
cmdRenameDrafts(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>] [--topic-a <方案A>] [--topic-b <方案B>] [--draft-name <草稿名称>] [--forward-copy <转发文案>] [--hashtags <话题>]')
console.log(' batch-pipeline.js retry-failed --file <batch-manifest.json>')
console.log(' batch-pipeline.js export --file <batch-manifest.json> [--format csv|xlsx]')
console.log(' batch-pipeline.js rename-drafts --file <batch-manifest.json> [--draft-dir <路径>]')
console.log('')
console.log('Excel 格式:')
console.log(' 选题 | 脚本 | 账号 | 模式 | 音色 | 转发文案带话题')
console.log(' 选题/标题/title — 标题(可选,留空则由 AI 根据脚本自动生成)')
console.log(' 脚本/文案/旁白 — 口播文案(必填)')
console.log(' 账号/account — 账号ID可选可由 --account 指定默认值)')
console.log(' 模式/mode — single|framePair可选可由 --mode 指定默认值)')
console.log(' 音色/voice — 音色名称或ID可选可由 --voice 指定默认值)')
console.log(' 转发文案带话题/转发文案/forwardCopy — 转发文案+#话题(可选,留空则由 AI 生成)')
}
}
if (require.main === module) {
main()
}
module.exports = { cmdInit, cmdStatus, cmdMark, cmdRetryFailed, cmdNext, cmdExport, cmdRenameDrafts }

View File

@@ -27,6 +27,7 @@ const {
addSubtitles,
consolidateTracks,
addEffects, addFilter,
addKenBurns,
} = require('./lib/capcut-tracks')
const { saveManifest } = require('./lib/pipeline-utils')
const { syncDraft, registerDraft } = require('./sync-to-jianying')
@@ -177,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)
@@ -434,6 +449,8 @@ async function assemble(args) {
if (mode === 'videos' && subtitles === 'false') {
console.log(`\n >> 视频模式未加字幕,请在剪映中打开草稿 → 识别字幕 → 语音识别生成\n`)
}
return { draftUrl, draftId }
}
// ============================================================================

View File

@@ -64,7 +64,7 @@ const Config = {
// 超时设置(毫秒)
timeout: {
default: 180000, // 默认2分钟
default: 300000, // 默认5分钟
max: 300000 // 最大5分钟
}
}

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

@@ -4,6 +4,7 @@
* 图片/视频 + TTS → 剪映草稿
*/
const fs = require('fs')
const { log, getManifestDir } = require('./pipeline-utils')
async function phaseAssemble(manifest, manifestPath, options) {
@@ -45,7 +46,13 @@ async function phaseAssemble(manifest, manifestPath, options) {
try {
const { assemble } = require('../capcut_assemble')
await assemble(assembleArgs)
const result = await assemble(assembleArgs)
// 保存草稿地址到 manifest供批量导出使用
if (result && result.draftUrl) {
manifest.draftUrl = result.draftUrl
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8')
log('assemble', `草稿地址已保存: ${result.draftUrl}`)
}
log('assemble', '成片完成')
} catch (err) {
log('assemble', `成片失败: ${err.message}`)

View File

@@ -119,14 +119,32 @@ async function phaseTts(manifest, manifestPath, options = {}) {
const segInput = rawSegments[j]
const segId = `${item.id}_${j + 1}`
try {
const { filePath, duration: realDuration } = await synthesize(segInput.text, {
outputDir: audioDir,
id: segId,
voice: manifest.ttsVoice || undefined,
instruction: manifest.ttsInstruction || undefined,
rate: ttsRate,
})
// 带重试的合成最多3次指数退避
let synthResult = null
let lastErr = null
for (let retry = 0; retry < 3; retry++) {
try {
synthResult = await synthesize(segInput.text, {
outputDir: audioDir,
id: segId,
voice: manifest.ttsVoice || undefined,
model: manifest.ttsModel || undefined,
instruction: manifest.ttsInstruction || undefined,
rate: ttsRate,
})
break
} catch (e) {
lastErr = e
if (retry < 2) {
const delay = Math.pow(2, retry) * 3000
log('tts', `[${idx}/${items.length}] 段${j + 1} 重试 ${retry + 1}/3, ${delay / 1000}s 后重试...`)
await new Promise(r => setTimeout(r, delay))
}
}
}
if (synthResult) {
const { filePath, duration: realDuration } = synthResult
const segment = {
id: segId,
@@ -140,8 +158,8 @@ async function phaseTts(manifest, manifestPath, options = {}) {
globalOffset += realDuration
log('tts', `[${idx}/${items.length}] 段${j + 1}: 估算${segInput.estimatedDuration.toFixed(2)}s → 实测${realDuration.toFixed(2)}s | ${segInput.text.slice(0, 15)}...`)
} catch (err) {
log('tts', `[${idx}/${items.length}] 段${j + 1} 合成失败: ${err.message}`)
} else {
log('tts', `[${idx}/${items.length}] 段${j + 1} 合成失败(重试3次后): ${lastErr?.message || '未知错误'}`)
segments.push({
id: segId,
text: segInput.text,
@@ -149,7 +167,7 @@ async function phaseTts(manifest, manifestPath, options = {}) {
estimatedDuration: segInput.estimatedDuration,
duration: 0,
startOffset: globalOffset,
error: err.message,
error: lastErr?.message || '未知错误',
})
globalOffset += segInput.estimatedDuration
}

View File

@@ -5,7 +5,6 @@
* 支持 task ID 恢复:中断后重跑时优先恢复已有任务
*/
const fs = require('fs')
const path = require('path')
const { saveManifest, ensureDir, log, getManifestDir } = require('./pipeline-utils')
@@ -24,15 +23,33 @@ 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)
})
// 对重试 item 自动清理旧视频引用,无需 agent 手动删除
if (videoCandidates.length === 0) {
console.log("\n⚠ [videos] 没有符合条件的 item 进入视频生成阶段")
console.log(" manifest 中共有", manifest.items.length, "个 item逐一诊断:")
for (const it of manifest.items) {
const reasons = []
if (it.confirmed === false) reasons.push("confirmed=false")
if (!it.url) reasons.push("缺少 url图片未上传")
if (!it.videoPrompt) reasons.push("缺少 videoPrompt")
if (it.confirmed !== false && it.url && it.videoPrompt && !["done","pending","failed"].includes(it.status)) {
reasons.push("status=" + (it.status || "undefined") + "(不在 done/pending/failed 中)")
}
console.log(" - item", it.id || manifest.items.indexOf(it), ":", reasons.length > 0 ? reasons.join(", ") : "已满足全部条件(不应在此)")
}
console.log("\n 修复命令:")
console.log(" node .claude/skills/video-from-script/scripts/pipeline.js confirm --manifest", manifestPath, "--all")
console.log()
}
// 已有视频(本地或 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
@@ -70,7 +87,6 @@ async function phaseVideos(manifest, manifestPath, options) {
}
}
// 轮询恢复的任务
if (recovered.length > 0) {
log('videos', `尝试恢复 ${recovered.length} 个中断任务...`)
await Promise.allSettled(
@@ -86,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} 恢复成功`)
}
@@ -94,15 +111,15 @@ 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
// Phase 2: 提交新任务(并发 5Kling 最大并发
const concurrency = 5
log('videos', `提交 ${needSubmit.length} 个新任务(并发: ${concurrency}...`)
const submitted = []
@@ -110,13 +127,9 @@ async function phaseVideos(manifest, manifestPath, options) {
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 }
@@ -169,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')
@@ -192,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,14 +23,30 @@ 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'))
}
function saveManifest(manifestPath, manifest) {
const tmp = manifestPath + '.tmp'
fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2), 'utf-8')
fs.renameSync(tmp, manifestPath)
try {
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8')
} catch (err) {
if (err.code === 'EPERM') {
// rename/tmp fallback on EPERM
const tmp = manifestPath + '.tmp'
fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2), 'utf-8')
try { fs.renameSync(tmp, manifestPath) } catch (_) {}
}
}
}
function loadAccountConfig(accountId) {
@@ -221,6 +238,7 @@ module.exports = {
CONFIG_PATH,
ACCOUNTS_DIR,
loadConfig,
resolveVoice,
loadManifest,
saveManifest,
loadAccountConfig,

View File

@@ -0,0 +1,110 @@
/**
* 共享视频轮询重试工具
*
* 提供 pollWithRetry 工厂函数,供 kling/veo/grok 三个视频生成器共用。
* 两层重试:轮询级(同一 taskId处理网络瞬断→ 任务级(创建新 task + 优化提示词)
*/
const path = require('path')
const fs = require('fs')
const https = require('https')
const http = require('http')
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 || '')
}
async function download(url, outputPath) {
const protocol = url.startsWith('https') ? https : http
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(outputPath)
protocol.get(url, (response) => {
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
file.close()
fs.unlinkSync(outputPath)
return download(response.headers.location, outputPath).then(resolve).catch(reject)
}
response.pipe(file)
file.on('finish', () => { file.close(); resolve(outputPath) })
}).on('error', (err) => {
file.close()
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath)
reject(err)
})
})
}
/**
* 创建 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

@@ -17,6 +17,9 @@ importers:
sharp:
specifier: ^0.34.5
version: 0.34.5
xlsx:
specifier: ^0.18.5
version: 0.18.5
packages:
@@ -53,89 +56,105 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -164,6 +183,10 @@ packages:
resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==}
engines: {node: '>= 10.0.0'}
adler-32@1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'}
agentkeepalive@3.5.3:
resolution: {integrity: sha512-yqXL+k5rr8+ZRpOAntkaaRgWgE5o8ESAj5DyRmVTCSoZxXmqemb9Dd7T4i5UzwuERdLAJUy6XzR9zFVuf0kzkw==}
engines: {node: '>= 4.0.0'}
@@ -195,6 +218,14 @@ packages:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
cfb@1.2.2:
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
engines: {node: '>=0.8'}
codepage@1.15.0:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
engines: {node: '>=0.8'}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
@@ -209,6 +240,11 @@ packages:
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
hasBin: true
dateformat@2.2.0:
resolution: {integrity: sha512-GODcnWq3YGoTnygPfi02ygEiRxqUxpJwuRHjdhJYuxpcZmDq4rjBiXYmbCCzStxo176ixfLT6i4NPwQooRySnw==}
@@ -294,6 +330,10 @@ packages:
formstream@1.5.2:
resolution: {integrity: sha512-NASf0lgxC1AyKNXQIrXTEYkiX99LhCEXTkiGObXAkpBui86a4u8FjH1o2bGb3PpqI3kafC+yw4zWeK6l6VHTgg==}
frac@1.1.2:
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
engines: {node: '>=0.8'}
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@@ -482,6 +522,10 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
ssf@0.11.2:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
statuses@1.5.0:
resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
engines: {node: '>= 0.6'}
@@ -536,9 +580,22 @@ packages:
resolution: {integrity: sha512-iCRnKVvGxOQdsKhcQId2PXV1vV3J/sDPXKA4Oe9+Eti2nb2ESEsYHRYls/UjoUW3bIc5ZDO8dTH50A/5iVN+bw==}
engines: {node: '>=0.10.0'}
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
word@0.3.0:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
engines: {node: '>=0.8'}
hasBin: true
xml2js@0.6.2:
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
engines: {node: '>=4.0.0'}
@@ -656,6 +713,8 @@ snapshots:
address@1.2.2: {}
adler-32@1.3.1: {}
agentkeepalive@3.5.3:
dependencies:
humanize-ms: 1.2.1
@@ -717,6 +776,13 @@ snapshots:
call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0
cfb@1.2.2:
dependencies:
adler-32: 1.3.1
crc-32: 1.2.2
codepage@1.15.0: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
@@ -727,6 +793,8 @@ snapshots:
core-util-is@1.0.3: {}
crc-32@1.2.2: {}
dateformat@2.2.0: {}
debug@4.4.3:
@@ -797,6 +865,8 @@ snapshots:
node-hex: 1.0.1
pause-stream: 0.0.11
frac@1.1.2: {}
function-bind@1.1.2: {}
get-intrinsic@1.3.0:
@@ -1008,6 +1078,10 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
ssf@0.11.2:
dependencies:
frac: 1.1.2
statuses@1.5.0: {}
stream-http@2.8.2:
@@ -1072,8 +1146,22 @@ snapshots:
dependencies:
semver: 5.7.2
wmf@1.0.2: {}
word@0.3.0: {}
wrappy@1.0.2: {}
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1
cfb: 1.2.2
codepage: 1.15.0
crc-32: 1.2.2
ssf: 0.11.2
wmf: 1.0.2
word: 0.3.0
xml2js@0.6.2:
dependencies:
sax: 1.6.0

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', [
@@ -46,7 +55,7 @@ function getAudioDuration(filePath) {
return parseFloat(out.trim())
} catch {
const stat = fs.statSync(filePath)
return stat.size * 8 / 32000
return stat.size * 8 / 160000
}
}
@@ -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