#!/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) })