398 lines
15 KiB
JavaScript
398 lines
15 KiB
JavaScript
|
|
#!/usr/bin/env node
|
|||
|
|
/**
|
|||
|
|
* 执黑先行二号-风格延伸 — 7视频 并行批量生产
|
|||
|
|
* 3 路并发,每路独立进程+独立日志
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const fs = require('fs')
|
|||
|
|
const path = require('path')
|
|||
|
|
const { spawn, spawnSync } = require('child_process')
|
|||
|
|
|
|||
|
|
const CONCURRENCY = 3
|
|||
|
|
const ACCOUNT = '执黑先行二号-风格延伸'
|
|||
|
|
const SCRIPTS_DIR = path.join(__dirname, '.claude/skills/video-from-script/scripts')
|
|||
|
|
const OUTPUT_BASE = path.join(__dirname, 'output')
|
|||
|
|
|
|||
|
|
// ============================================================================
|
|||
|
|
// 7个视频配置
|
|||
|
|
// ============================================================================
|
|||
|
|
|
|||
|
|
const VIDEOS = [
|
|||
|
|
{
|
|||
|
|
id: 'v8', scriptNum: 8, style: '硬核线条', voice: '不羁青年', topicB: '搞女人',
|
|||
|
|
refs: [
|
|||
|
|
'/Users/lc/Pictures/参考图片/硬核线条/微信图片_20260522223358_119_18.png',
|
|||
|
|
'/Users/lc/Pictures/参考图片/硬核线条/微信图片_20260522223358_120_18.png',
|
|||
|
|
'/Users/lc/Pictures/参考图片/硬核线条/微信图片_20260522223358_121_18.png'
|
|||
|
|
],
|
|||
|
|
storyboardFile: '/tmp/sb_v8.json'
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'v9', scriptNum: 9, style: '梦核水池', voice: '不羁青年', topicB: '没人懂你',
|
|||
|
|
refs: [
|
|||
|
|
'/Users/lc/Pictures/参考图片/梦核水池/微信图片_20260522230621_239_18.png',
|
|||
|
|
'/Users/lc/Pictures/参考图片/梦核水池/微信图片_20260522230621_245_18.png',
|
|||
|
|
'/Users/lc/Pictures/参考图片/梦核水池/微信图片_20260522232446_266_18.png'
|
|||
|
|
],
|
|||
|
|
storyboardFile: '/tmp/sb_v9.json'
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'v12', scriptNum: 12, style: '水墨', voice: '温暖少女', topicB: '看人看结果',
|
|||
|
|
refs: [
|
|||
|
|
'/Users/lc/Pictures/参考图片/水墨/5bbcc329-dd8e-4b84-b901-97daa00943c9.png'
|
|||
|
|
],
|
|||
|
|
storyboardFile: '/tmp/sb_v12.json'
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'v13', scriptNum: 13, style: '梦核人物', voice: '温暖少女', topicB: '内耗三层',
|
|||
|
|
refs: [
|
|||
|
|
'/Users/lc/Pictures/参考图片/梦核人物/微信图片_20260522232203_259_18.png',
|
|||
|
|
'/Users/lc/Pictures/参考图片/梦核人物/微信图片_20260522232204_260_18.png',
|
|||
|
|
'/Users/lc/Pictures/参考图片/梦核人物/微信图片_20260522232204_261_18.png'
|
|||
|
|
],
|
|||
|
|
storyboardFile: '/tmp/sb_v13.json'
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'v14', scriptNum: 14, style: '梦核怪异城市', voice: '不羁青年', topicB: '地狱相',
|
|||
|
|
refs: [
|
|||
|
|
'/Users/lc/Pictures/参考图片/梦核怪异城市/微信图片_20260522231537_252_18.png',
|
|||
|
|
'/Users/lc/Pictures/参考图片/梦核怪异城市/微信图片_20260522231537_253_18.png',
|
|||
|
|
'/Users/lc/Pictures/参考图片/梦核怪异城市/微信图片_20260522231537_254_18.png'
|
|||
|
|
],
|
|||
|
|
storyboardFile: '/tmp/sb_v14.json'
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'v15', scriptNum: 15, style: '梦核花池', voice: '温暖少女', topicB: '孩子结账',
|
|||
|
|
refs: [
|
|||
|
|
'/Users/lc/Pictures/参考图片/梦核花池/微信图片_20260522231256_246_18.png',
|
|||
|
|
'/Users/lc/Pictures/参考图片/梦核花池/微信图片_20260522231256_247_18.png',
|
|||
|
|
'/Users/lc/Pictures/参考图片/梦核花池/微信图片_20260522231256_248_18.png'
|
|||
|
|
],
|
|||
|
|
storyboardFile: '/tmp/sb_v15.json'
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'v16', scriptNum: 16, style: '剪纸报', voice: '不羁青年', topicB: '借鸡生蛋',
|
|||
|
|
refs: [
|
|||
|
|
'/Users/lc/Pictures/参考图片/剪纸报/7b730552-efb6-426a-ae69-d2c8d396fe16.png'
|
|||
|
|
],
|
|||
|
|
storyboardFile: '/tmp/sb_v16.json'
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
// ============================================================================
|
|||
|
|
// 工具函数
|
|||
|
|
// ============================================================================
|
|||
|
|
|
|||
|
|
function logToFile(fd, msg) {
|
|||
|
|
const ts = new Date().toISOString().slice(11, 19)
|
|||
|
|
fs.writeSync(fd, `[${ts}] ${msg}\n`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
|
|||
|
|
|
|||
|
|
// ============================================================================
|
|||
|
|
// 处理单个视频
|
|||
|
|
// ============================================================================
|
|||
|
|
|
|||
|
|
function processVideo(v, jobNum, logDir) {
|
|||
|
|
return new Promise((resolve) => {
|
|||
|
|
const logPath = path.join(logDir, `${jobNum}_${v.id}_${v.topicB}.log`)
|
|||
|
|
const logFd = fs.openSync(logPath, 'w')
|
|||
|
|
|
|||
|
|
logToFile(logFd, `开始: ${jobNum}/07 ${v.style} | ${v.voice} | 脚本${v.scriptNum}`)
|
|||
|
|
logToFile(logFd, `分镜: ${v.storyboardFile}`)
|
|||
|
|
|
|||
|
|
// ---- 检查分镜文件 ----
|
|||
|
|
if (!fs.existsSync(v.storyboardFile)) {
|
|||
|
|
logToFile(logFd, `❌ 分镜文件不存在: ${v.storyboardFile}`)
|
|||
|
|
fs.closeSync(logFd)
|
|||
|
|
return resolve({ id: v.id, scriptNum: v.scriptNum, style: v.style, voice: v.voice,
|
|||
|
|
status: 'failed', error: '分镜文件缺失', shots: 0, logPath })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let items
|
|||
|
|
try {
|
|||
|
|
items = JSON.parse(fs.readFileSync(v.storyboardFile, 'utf-8'))
|
|||
|
|
} catch (e) {
|
|||
|
|
logToFile(logFd, `❌ 分镜文件解析失败: ${e.message}`)
|
|||
|
|
fs.closeSync(logFd)
|
|||
|
|
return resolve({ id: v.id, scriptNum: v.scriptNum, style: v.style, voice: v.voice,
|
|||
|
|
status: 'failed', error: '分镜解析失败', shots: 0, logPath })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const validItems = items.map(it => {
|
|||
|
|
const d = Number(it.duration) || 5
|
|||
|
|
return { ...it, duration: Math.min(d, 5.9) }
|
|||
|
|
})
|
|||
|
|
logToFile(logFd, `分镜: ${validItems.length} 个 shot (原始${items.length})`)
|
|||
|
|
|
|||
|
|
// ---- Init manifest ----
|
|||
|
|
const tmpItems = `/tmp/items_${v.id}_${Date.now()}.json`
|
|||
|
|
fs.writeFileSync(tmpItems, JSON.stringify(validItems), 'utf-8')
|
|||
|
|
|
|||
|
|
logToFile(logFd, '→ init manifest...')
|
|||
|
|
const initResult = spawnSync('node', [
|
|||
|
|
path.join(SCRIPTS_DIR, 'pipeline.js'), 'init',
|
|||
|
|
'--account', ACCOUNT,
|
|||
|
|
'--mode', 'single',
|
|||
|
|
'--items-file', tmpItems,
|
|||
|
|
'--image-model', 'gpt-image',
|
|||
|
|
'--video-model', 'kling',
|
|||
|
|
'--tts-voice', v.voice,
|
|||
|
|
'--format', '9:16'
|
|||
|
|
], { cwd: __dirname, encoding: 'utf-8', timeout: 60000, stdio: 'pipe' })
|
|||
|
|
|
|||
|
|
try { fs.unlinkSync(tmpItems) } catch {}
|
|||
|
|
|
|||
|
|
if (initResult.status !== 0) {
|
|||
|
|
logToFile(logFd, `❌ init 失败 (exit ${initResult.status})`)
|
|||
|
|
logToFile(logFd, `stderr: ${initResult.stderr.slice(0, 500)}`)
|
|||
|
|
logToFile(logFd, `stdout: ${initResult.stdout.slice(0, 500)}`)
|
|||
|
|
fs.closeSync(logFd)
|
|||
|
|
return resolve({ id: v.id, scriptNum: v.scriptNum, style: v.style, voice: v.voice,
|
|||
|
|
status: 'failed', error: `init failed: ${initResult.stderr.slice(0,200)}`, shots: validItems.length, logPath })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const initOut = initResult.stdout + initResult.stderr
|
|||
|
|
const manifestMatch = initOut.match(/Manifest 已创建: (.+\.json)/)
|
|||
|
|
if (!manifestMatch) {
|
|||
|
|
logToFile(logFd, '❌ 无法提取 manifest 路径')
|
|||
|
|
logToFile(logFd, `输出: ${initOut.slice(0, 500)}`)
|
|||
|
|
fs.closeSync(logFd)
|
|||
|
|
return resolve({ id: v.id, scriptNum: v.scriptNum, style: v.style, voice: v.voice,
|
|||
|
|
status: 'failed', error: 'no manifest path', shots: validItems.length, logPath })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const manifestPath = manifestMatch[1].trim()
|
|||
|
|
logToFile(logFd, `✅ manifest: ${path.basename(path.dirname(manifestPath))}`)
|
|||
|
|
|
|||
|
|
// ---- 更新 manifest ----
|
|||
|
|
let manifest
|
|||
|
|
try {
|
|||
|
|
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
|
|||
|
|
} catch (e) {
|
|||
|
|
logToFile(logFd, `❌ 无法读取 manifest: ${e.message}`)
|
|||
|
|
fs.closeSync(logFd)
|
|||
|
|
return resolve({ id: v.id, scriptNum: v.scriptNum, style: v.style, voice: v.voice,
|
|||
|
|
status: 'failed', error: 'manifest read failed', shots: validItems.length, logPath })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
manifest.ttsEngine = 'minimax'
|
|||
|
|
manifest.ttsRate = 1.15
|
|||
|
|
manifest.references = v.refs.map(f => ({ file: f }))
|
|||
|
|
manifest.items.forEach((item, idx) => {
|
|||
|
|
if (!item.videoPrompt && validItems[idx]?.videoPrompt) {
|
|||
|
|
item.videoPrompt = validItems[idx].videoPrompt
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8')
|
|||
|
|
logToFile(logFd, `✅ manifest 更新完成 (ttsEngine=minimax, refs=${v.refs.length})`)
|
|||
|
|
|
|||
|
|
// ---- 运行 pipeline (异步,长时间运行) ----
|
|||
|
|
logToFile(logFd, '→ 运行 pipeline (tts→images→upload→videos→assemble)...')
|
|||
|
|
|
|||
|
|
const runChild = spawn('node', [
|
|||
|
|
path.join(SCRIPTS_DIR, 'pipeline.js'), 'run',
|
|||
|
|
'--manifest', manifestPath,
|
|||
|
|
'--phase', 'tts,images,upload,videos,assemble'
|
|||
|
|
], {
|
|||
|
|
cwd: __dirname,
|
|||
|
|
stdio: ['ignore', logFd, logFd],
|
|||
|
|
timeout: 3600000
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
runChild.on('close', (code) => {
|
|||
|
|
logToFile(logFd, `pipeline 退出码: ${code}`)
|
|||
|
|
|
|||
|
|
// 读取最终 manifest
|
|||
|
|
let draftUrl = '', draftId = ''
|
|||
|
|
try {
|
|||
|
|
const finalManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
|
|||
|
|
draftUrl = finalManifest.draftUrl || ''
|
|||
|
|
if (draftUrl) draftId = draftUrl.split('draft_id=')[1] || ''
|
|||
|
|
} catch (e) {
|
|||
|
|
logToFile(logFd, `⚠ 读取最终 manifest 失败: ${e.message}`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (code === 0 && draftUrl) {
|
|||
|
|
// 重命名草稿
|
|||
|
|
const today = new Date()
|
|||
|
|
const dateStr = `${today.getMonth()+1}${String(today.getDate()).padStart(2,'0')}`
|
|||
|
|
const draftName = `${ACCOUNT}_${dateStr}_${jobNum}_${v.topicB}`
|
|||
|
|
|
|||
|
|
if (draftId) {
|
|||
|
|
const draftBase = '/Users/lc/Movies/JianyingPro/User Data/Projects/com.lveditor.draft'
|
|||
|
|
const oldPath = path.join(draftBase, draftId)
|
|||
|
|
const newPath = path.join(draftBase, draftName)
|
|||
|
|
if (fs.existsSync(oldPath)) {
|
|||
|
|
try {
|
|||
|
|
fs.renameSync(oldPath, newPath)
|
|||
|
|
logToFile(logFd, `✅ 草稿已重命名: ${draftName}`)
|
|||
|
|
} catch (e) {
|
|||
|
|
logToFile(logFd, `⚠ 草稿重命名失败: ${e.message}`)
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
logToFile(logFd, `⚠ 草稿路径不存在: ${oldPath}`)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logToFile(logFd, `✅ 完成 | draftUrl: ${draftUrl}`)
|
|||
|
|
fs.closeSync(logFd)
|
|||
|
|
resolve({
|
|||
|
|
id: v.id, scriptNum: v.scriptNum, style: v.style, voice: v.voice,
|
|||
|
|
status: 'done', draftUrl, draftName,
|
|||
|
|
shots: validItems.length, logPath, manifestPath
|
|||
|
|
})
|
|||
|
|
} else {
|
|||
|
|
logToFile(logFd, `❌ 失败 (exit ${code})`)
|
|||
|
|
fs.closeSync(logFd)
|
|||
|
|
resolve({
|
|||
|
|
id: v.id, scriptNum: v.scriptNum, style: v.style, voice: v.voice,
|
|||
|
|
status: 'failed', error: `pipeline exit ${code}`, shots: validItems.length, logPath
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
runChild.on('error', (err) => {
|
|||
|
|
logToFile(logFd, `❌ spawn 错误: ${err.message}`)
|
|||
|
|
fs.closeSync(logFd)
|
|||
|
|
resolve({
|
|||
|
|
id: v.id, scriptNum: v.scriptNum, style: v.style, voice: v.voice,
|
|||
|
|
status: 'failed', error: `spawn error: ${err.message}`, shots: validItems.length, logPath
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================================
|
|||
|
|
// 并发调度器
|
|||
|
|
// ============================================================================
|
|||
|
|
|
|||
|
|
async function runWithConcurrency(tasks, concurrency) {
|
|||
|
|
const results = []
|
|||
|
|
const inFlight = new Set()
|
|||
|
|
|
|||
|
|
for (const task of tasks) {
|
|||
|
|
const promise = task().then(r => {
|
|||
|
|
inFlight.delete(promise)
|
|||
|
|
return r
|
|||
|
|
})
|
|||
|
|
inFlight.add(promise)
|
|||
|
|
results.push(promise)
|
|||
|
|
|
|||
|
|
if (inFlight.size >= concurrency) {
|
|||
|
|
await Promise.race(inFlight)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return Promise.all(results)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================================
|
|||
|
|
// 主流程
|
|||
|
|
// ============================================================================
|
|||
|
|
|
|||
|
|
async function main() {
|
|||
|
|
const logDir = `/tmp/batch_logs_${Date.now()}`
|
|||
|
|
fs.mkdirSync(logDir, { recursive: true })
|
|||
|
|
|
|||
|
|
console.log(`${'='.repeat(60)}`)
|
|||
|
|
console.log(`执黑先行二号-风格延伸 — 7个视频并行批量生产`)
|
|||
|
|
console.log(`并发数: ${CONCURRENCY} | 日志: ${logDir}`)
|
|||
|
|
console.log(`${'='.repeat(60)}\n`)
|
|||
|
|
|
|||
|
|
const summaryLog = path.join(logDir, '_summary.log')
|
|||
|
|
const summaryFd = fs.openSync(summaryLog, 'w')
|
|||
|
|
|
|||
|
|
const startTime = Date.now()
|
|||
|
|
|
|||
|
|
// 构建任务列表
|
|||
|
|
const tasks = VIDEOS.map((v, i) => {
|
|||
|
|
const jobNum = String(i + 1).padStart(2, '0')
|
|||
|
|
return () => processVideo(v, jobNum, logDir)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 进度打印
|
|||
|
|
const doneSet = new Set()
|
|||
|
|
const progressPrinter = setInterval(() => {
|
|||
|
|
const done = doneSet.size
|
|||
|
|
const elapsed = Math.floor((Date.now() - startTime) / 60000)
|
|||
|
|
const remaining = VIDEOS.length - done - (CONCURRENCY <= done ? 0 : CONCURRENCY - done)
|
|||
|
|
process.stdout.write(`\r 进度: ${done}/${VIDEOS.length} 完成 | 已运行 ${elapsed} 分钟 | 预计剩余 ~${Math.max(0, remaining * 25)} 分钟 `)
|
|||
|
|
}, 15000)
|
|||
|
|
|
|||
|
|
// 包装任务以追踪完成
|
|||
|
|
const wrappedTasks = tasks.map((taskFn, i) => {
|
|||
|
|
return async () => {
|
|||
|
|
const result = await taskFn()
|
|||
|
|
doneSet.add(i)
|
|||
|
|
const done = doneSet.size
|
|||
|
|
const elapsed = Math.floor((Date.now() - startTime) / 60000)
|
|||
|
|
const status = result.status === 'done' ? '✅' : '❌'
|
|||
|
|
console.log(`\n [${done}/${VIDEOS.length}] ${status} ${result.style} | ${result.voice} | 脚本${result.scriptNum} | ${result.status === 'done' ? result.draftName : result.error}`)
|
|||
|
|
return result
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 启动并发执行
|
|||
|
|
const results = await runWithConcurrency(wrappedTasks, CONCURRENCY)
|
|||
|
|
|
|||
|
|
clearInterval(progressPrinter)
|
|||
|
|
|
|||
|
|
const totalTime = Math.floor((Date.now() - startTime) / 60000)
|
|||
|
|
|
|||
|
|
// ====== 最终汇总 ======
|
|||
|
|
console.log(`\n\n${'='.repeat(60)}`)
|
|||
|
|
console.log(`批量生产完成 — 总耗时 ${totalTime} 分钟`)
|
|||
|
|
console.log(`${'='.repeat(60)}\n`)
|
|||
|
|
|
|||
|
|
const doneCount = results.filter(r => r.status === 'done').length
|
|||
|
|
const failCount = results.filter(r => r.status === 'failed').length
|
|||
|
|
|
|||
|
|
console.log(`成功: ${doneCount} | 失败: ${failCount}\n`)
|
|||
|
|
|
|||
|
|
// 输出表格
|
|||
|
|
console.log('| # | 脚本 | 风格 | 音色 | Shot数 | 状态 | 草稿名称 | draftUrl |')
|
|||
|
|
console.log('|---|------|------|------|-------|------|---------|---------|')
|
|||
|
|
results.forEach((r, i) => {
|
|||
|
|
const num = String(i + 1).padStart(2, '0')
|
|||
|
|
const status = r.status === 'done' ? '✅' : '❌'
|
|||
|
|
const draftName = r.draftName || '-'
|
|||
|
|
const url = r.draftUrl || '-'
|
|||
|
|
console.log(`| ${num} | ${r.scriptNum || '-'} | ${r.style} | ${r.voice || '-'} | ${r.shots || '-'} | ${status} | ${draftName} | ${url} |`)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 保存结果 JSON
|
|||
|
|
const resultData = results.map((r, i) => ({
|
|||
|
|
index: i + 1,
|
|||
|
|
scriptNum: r.scriptNum,
|
|||
|
|
style: r.style,
|
|||
|
|
voice: r.voice,
|
|||
|
|
shots: r.shots,
|
|||
|
|
status: r.status,
|
|||
|
|
draftName: r.draftName || null,
|
|||
|
|
draftUrl: r.draftUrl || null,
|
|||
|
|
error: r.error || null,
|
|||
|
|
logPath: r.logPath
|
|||
|
|
}))
|
|||
|
|
|
|||
|
|
const resultPath = path.join(OUTPUT_BASE, `${ACCOUNT}_${new Date().toISOString().slice(0, 10)}_7视频批量结果.json`)
|
|||
|
|
fs.writeFileSync(resultPath, JSON.stringify(resultData, null, 2), 'utf-8')
|
|||
|
|
console.log(`\n结果已保存: ${resultPath}`)
|
|||
|
|
console.log(`日志目录: ${logDir}`)
|
|||
|
|
|
|||
|
|
// 写入 summary
|
|||
|
|
fs.writeSync(summaryFd, `总耗时: ${totalTime} 分钟\n成功: ${doneCount} | 失败: ${failCount}\n\n`)
|
|||
|
|
resultData.forEach(r => {
|
|||
|
|
fs.writeSync(summaryFd, `${r.status === 'done' ? '✅' : '❌'} ${r.style} | ${r.voice} | 脚本${r.scriptNum} | ${r.draftName || r.error}\n`)
|
|||
|
|
})
|
|||
|
|
fs.closeSync(summaryFd)
|
|||
|
|
|
|||
|
|
process.exit(failCount > 0 ? 1 : 0)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
main().catch(err => {
|
|||
|
|
console.error('脚本执行失败:', err.message)
|
|||
|
|
process.exit(1)
|
|||
|
|
})
|