#!/usr/bin/env node /** * 执黑先行二号 — 剩余4视频 素材预渲染(不含 assemble) * TTS + images + upload + videos,2 路并发 * 等 CapCut Mate 恢复后再串行装配 */ const fs = require('fs') const path = require('path') const { spawn, spawnSync } = require('child_process') const CONCURRENCY = 2 const ACCOUNT = '执黑先行二号-风格延伸' const SCRIPTS_DIR = path.join(__dirname, '.claude/skills/video-from-script/scripts') const OUTPUT_BASE = path.join(__dirname, 'output') const VIDEOS = [ { 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 processVideo(v, jobNum, logDir, seqNum) { return new Promise((resolve) => { const logPath = path.join(logDir, `${seqNum}_${v.id}_${v.topicB}.log`) const logFd = fs.openSync(logPath, 'w') logToFile(logFd, `开始: ${seqNum}/04 ${v.style} | ${v.voice} | 脚本${v.scriptNum}`) if (!fs.existsSync(v.storyboardFile)) { logToFile(logFd, `❌ 分镜文件不存在`) fs.closeSync(logFd) return resolve({ id: v.id, style: v.style, status: 'failed', error: '分镜文件缺失' }) } 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, style: v.style, status: 'failed', error: '分镜解析失败' }) } const validItems = items.map(it => { const d = Number(it.duration) || 5 return { ...it, duration: Math.min(d, 5.9) } }) logToFile(logFd, `分镜: ${validItems.length} shots`) // ---- 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)}`) fs.closeSync(logFd) return resolve({ id: v.id, style: v.style, status: 'failed', error: `init failed` }) } 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, style: v.style, status: 'failed', error: 'no manifest path' }) } 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, style: v.style, status: 'failed', error: 'manifest read failed' }) } 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 更新完成 (minimax 1.15x, 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' ], { cwd: __dirname, stdio: ['ignore', logFd, logFd], timeout: 3600000 }) runChild.on('close', (code) => { logToFile(logFd, `pipeline 退出码: ${code}`) if (code === 0) { logToFile(logFd, `✅ 素材阶段完成 (待装配)`) fs.closeSync(logFd) resolve({ id: v.id, scriptNum: v.scriptNum, style: v.style, voice: v.voice, status: 'ready', manifestPath, shots: validItems.length, logPath }) } 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}`, manifestPath, shots: validItems.length, logPath }) } }) runChild.on('error', (err) => { logToFile(logFd, `❌ spawn 错误: ${err.message}`) fs.closeSync(logFd) resolve({ id: v.id, style: v.style, status: 'failed', error: `spawn: ${err.message}` }) }) }) } // ============================================================================ 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_prerender_${Date.now()}` fs.mkdirSync(logDir, { recursive: true }) console.log(`${'='.repeat(60)}`) console.log(`执黑先行二号 — 剩余4视频 素材预渲染 (2路并发)`) console.log(`阶段: tts → images → upload → videos | 日志: ${logDir}`) console.log(`${'='.repeat(60)}\n`) const startTime = Date.now() const tasks = VIDEOS.map((v, i) => { const seqNum = String(i + 1).padStart(2, '0') return () => processVideo(v, seqNum, logDir, seqNum) }) const doneSet = new Set() const progressPrinter = setInterval(() => { const done = doneSet.size const elapsed = Math.floor((Date.now() - startTime) / 60000) process.stdout.write(`\r 进度: ${done}/${VIDEOS.length} 完成 | ${elapsed} 分钟 `) }, 10000) 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 === 'ready' ? '✅' : '❌' console.log(`\n [${done}/${VIDEOS.length}] ${status} ${result.style} | ${result.voice} | 脚本${result.scriptNum} | ${result.status === 'ready' ? result.manifestPath : result.error}`) return result } }) const results = await runWithConcurrency(wrappedTasks, CONCURRENCY) clearInterval(progressPrinter) const totalTime = Math.floor((Date.now() - startTime) / 60000) const readyCount = results.filter(r => r.status === 'ready').length const failCount = results.filter(r => r.status === 'failed').length console.log(`\n${'='.repeat(60)}`) console.log(`素材预渲染完成 — ${totalTime} 分钟 | 就绪: ${readyCount} | 失败: ${failCount}`) console.log(`${'='.repeat(60)}\n`) results.forEach((r, i) => { const s = r.status === 'ready' ? '✅' : '❌' console.log(` ${s} 脚本${r.scriptNum} ${r.style} | ${r.manifestPath || r.error}`) }) const resultPath = path.join(OUTPUT_BASE, `${ACCOUNT}_prerender_${new Date().toISOString().slice(0,10)}.json`) fs.writeFileSync(resultPath, JSON.stringify(results, null, 2), 'utf-8') console.log(`\n结果: ${resultPath}`) process.exit(failCount > 0 ? 1 : 0) } main().catch(err => { console.error(err.message); process.exit(1) })