Files
video-create/run_batch_4videos.js
lc 1e110219ff feat: MiniMax TTS集成、口播批量剪辑流水线、执黑先行二号账号
- 新增 minimax-tts.js 和 minimax-voice-clone.js 脚本
- 新增口播批量剪辑流水线 (口播_assemble.js, 口播_pipeline.js)
- 更新 video-from-script 各阶段脚本 (kling, images, tts, videos)
- 新增执黑先行二号-风格延伸账号
- 更新执黑先行 account.json 配置
- 替换 ugc_product_seeding 参考图
- 更新 CLAUDE.md 和依赖配置

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 20:05:56 +08:00

239 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 执黑先行二号 — 剩余4视频 完整生产
* 素材阶段 2 路并发 + 装配串行
*/
const fs = require('fs')
const path = require('path')
const { spawn, spawnSync } = require('child_process')
const SCRIPTS_DIR = path.join(__dirname, '.claude/skills/video-from-script/scripts')
const DRAFT_BASE = '/Users/lc/Movies/JianyingPro/User Data/Projects/com.lveditor.draft'
const ACCOUNT = '执黑先行二号-风格延伸'
const VIDEOS = [
{ id: 'v13', scriptNum: 13, style: '梦核人物', voice: '温暖少女', topicB: '内耗三层',
refs: ['微信图片_20260522232203_259_18.png','微信图片_20260522232204_260_18.png','微信图片_20260522232204_261_18.png'].map(f => '/Users/lc/Pictures/参考图片/梦核人物/'+f),
storyboardFile: '/tmp/sb_v13.json' },
{ id: 'v14', scriptNum: 14, style: '梦核怪异城市', voice: '不羁青年', topicB: '地狱相',
refs: ['微信图片_20260522231537_252_18.png','微信图片_20260522231537_253_18.png','微信图片_20260522231537_254_18.png'].map(f => '/Users/lc/Pictures/参考图片/梦核怪异城市/'+f),
storyboardFile: '/tmp/sb_v14.json' },
{ id: 'v15', scriptNum: 15, style: '梦核花池', voice: '温暖少女', topicB: '孩子结账',
refs: ['微信图片_20260522231256_246_18.png','微信图片_20260522231256_247_18.png','微信图片_20260522231256_248_18.png'].map(f => '/Users/lc/Pictures/参考图片/梦核花池/'+f),
storyboardFile: '/tmp/sb_v15.json' },
{ id: 'v16', scriptNum: 16, style: '剪纸报', voice: '不羁青年', topicB: '借鸡生蛋',
refs: ['7b730552-efb6-426a-ae69-d2c8d396fe16.png'].map(f => '/Users/lc/Pictures/参考图片/剪纸报/'+f),
storyboardFile: '/tmp/sb_v16.json' }
]
function log(fd, msg) { fs.writeSync(fd, `[${new Date().toISOString().slice(11,19)}] ${msg}\n`) }
// ---- 初始化单个视频 ----
function initVideo(v, logFd) {
if (!fs.existsSync(v.storyboardFile)) throw new Error('分镜文件不存在')
const items = JSON.parse(fs.readFileSync(v.storyboardFile, 'utf-8'))
const valid = items.map(it => ({ ...it, duration: Math.min(Number(it.duration)||5, 5.9) }))
log(logFd, `分镜: ${valid.length} shots`)
const tmpFile = `/tmp/items_${v.id}_${Date.now()}.json`
fs.writeFileSync(tmpFile, JSON.stringify(valid), 'utf-8')
const res = spawnSync('node', [
path.join(SCRIPTS_DIR, 'pipeline.js'), 'init',
'--account', ACCOUNT, '--mode', 'single', '--items-file', tmpFile,
'--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(tmpFile) } catch {}
if (res.status !== 0) throw new Error(`init failed: ${res.stderr.slice(0,200)}`)
const m = (res.stdout + res.stderr).match(/Manifest 已创建: (.+\.json)/)
if (!m) throw new Error('no manifest path in output')
return m[1].trim()
}
// ---- 完整处理一个视频 ----
function processVideo(v, seqNum, logDir) {
return new Promise((resolve) => {
const logPath = path.join(logDir, `${seqNum}_${v.id}_${v.topicB}.log`)
const lf = fs.openSync(logPath, 'w')
log(lf, `开始: ${seqNum}/04 ${v.style} | ${v.voice} | 脚本${v.scriptNum}`)
let manifestPath
try {
manifestPath = initVideo(v, lf)
log(lf, `✅ manifest: ${path.basename(path.dirname(manifestPath))}`)
} catch (e) {
log(lf, `${e.message}`)
fs.closeSync(lf)
return resolve({ id: v.id, style: v.style, status: 'failed', error: e.message })
}
// 更新 manifest
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
manifest.ttsEngine = 'minimax'
manifest.ttsRate = 1.15
manifest.references = v.refs.map(f => ({ file: f }))
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8')
log(lf, `✅ manifest 更新 (minimax 1.15x, refs=${v.refs.length})`)
// 素材阶段 (并发)
log(lf, '→ 素材阶段: tts→images→upload→videos')
const matChild = spawn('node', [
path.join(SCRIPTS_DIR, 'pipeline.js'), 'run',
'--manifest', manifestPath, '--phase', 'tts,images,upload,videos'
], { cwd: __dirname, stdio: ['ignore', lf, lf], timeout: 3600000 })
matChild.on('close', (matCode) => {
log(lf, `素材退出码: ${matCode}`)
if (matCode !== 0) {
log(lf, `❌ 素材阶段失败`)
fs.closeSync(lf)
return resolve({ id: v.id, style: v.style, status: 'failed', error: `material exit ${matCode}`, manifestPath, logPath })
}
// 装配阶段 (必须串行 — 在调用侧控制)
log(lf, '→ 素材就绪,等待装配槽位...')
fs.closeSync(lf)
resolve({ id: v.id, scriptNum: v.scriptNum, style: v.style, voice: v.voice, status: 'material_ready', manifestPath, logPath, logFd: null })
})
matChild.on('error', (err) => {
log(lf, `❌ spawn: ${err.message}`)
fs.closeSync(lf)
resolve({ id: v.id, style: v.style, status: 'failed', error: err.message })
})
})
}
// ---- 装配单个视频 (串行调用) ----
function assembleVideo(r, seqNum) {
return new Promise((resolve) => {
const lf = fs.openSync(r.logPath, 'a')
log(lf, '→ assemble (串行)...')
const child = spawn('node', [
path.join(SCRIPTS_DIR, 'pipeline.js'), 'run',
'--manifest', r.manifestPath, '--phase', 'assemble'
], { cwd: __dirname, stdio: ['ignore', lf, lf], timeout: 300000 })
child.on('close', (code) => {
let draftUrl = '', draftName = ''
if (code === 0) {
try {
const m = JSON.parse(fs.readFileSync(r.manifestPath, 'utf-8'))
draftUrl = m.draftUrl || ''
} catch {}
}
if (draftUrl) {
const today = new Date()
const ds = `${today.getMonth()+1}${String(today.getDate()).padStart(2,'0')}`
draftName = `${ACCOUNT}_${ds}_${seqNum}_${r.topicB || ''}`
const draftId = draftUrl.split('draft_id=')[1] || ''
if (draftId) {
const oldP = path.join(DRAFT_BASE, draftId)
const newP = path.join(DRAFT_BASE, draftName)
if (fs.existsSync(oldP)) {
try { fs.renameSync(oldP, newP); log(lf, `✅ 草稿: ${draftName}`) }
catch (e) { log(lf, `⚠ 改名失败: ${e.message}`) }
}
}
log(lf, `✅ 完成 | ${draftUrl}`)
} else {
log(lf, `❌ assemble 失败 (exit ${code})`)
}
fs.closeSync(lf)
resolve({ ...r, status: draftUrl ? 'done' : 'failed', draftUrl, draftName, assembleCode: code })
})
child.on('error', (err) => {
log(lf, `❌ assemble spawn: ${err.message}`)
fs.closeSync(lf)
resolve({ ...r, status: 'failed', error: err.message })
})
})
}
// ============================================================================
async function runParallel(tasks, limit) {
const results = [], inflight = new Set()
for (const t of tasks) {
const p = t().then(r => { inflight.delete(p); return r })
inflight.add(p); results.push(p)
if (inflight.size >= limit) await Promise.race(inflight)
}
return Promise.all(results)
}
async function runSequential(items, fn) {
const results = []
for (const item of items) results.push(await fn(item))
return results
}
// ============================================================================
async function main() {
const logDir = `/tmp/batch_final4_${Date.now()}`
fs.mkdirSync(logDir, { recursive: true })
// 全局序号:已用 18/19/20剩余从 21 开始
const baseSeq = 21
console.log(`${'='.repeat(60)}`)
console.log(`执黑先行二号 — 剩余4视频 (素材2并发 + 装配串行)`)
console.log(`全局序号: ${baseSeq}-${baseSeq+3} | 日志: ${logDir}`)
console.log(`${'='.repeat(60)}\n`)
const start = Date.now()
// ---- Phase 1: 素材并行 ----
console.log('▶ Phase 1: 素材生成 (tts+images+videos, 2并发)\n')
const tasks = VIDEOS.map((v, i) => {
const sn = String(baseSeq + i).padStart(2, '0')
return () => processVideo(v, sn, logDir)
})
const doneSet = new Set()
const timer = setInterval(() => {
process.stdout.write(`\r 素材: ${doneSet.size}/${VIDEOS.length} | ${Math.floor((Date.now()-start)/60000)}min `)
}, 10000)
const wrapped = tasks.map((t, i) => async () => { const r = await t(); doneSet.add(i); console.log(`\n [素材 ${doneSet.size}/4] ${r.status === 'material_ready' ? '✅' : '❌'} ${r.style} | ${r.manifestPath||r.error}`); return r })
const matResults = await runParallel(wrapped, 2)
clearInterval(timer)
const ready = matResults.filter(r => r.status === 'material_ready')
const matFailed = matResults.filter(r => r.status === 'failed')
console.log(`\n 素材完成: ${ready.length}/4 就绪 | ${matFailed.length} 失败 | ${Math.floor((Date.now()-start)/60000)}min\n`)
// ---- Phase 2: 装配串行 ----
if (ready.length > 0) {
console.log('▶ Phase 2: 装配 (串行, 1个/次)\n')
let ai = 0
const finalResults = await runSequential(ready, (r) => {
ai++
console.log(` [装配 ${ai}/${ready.length}] ${r.style}...`)
return assembleVideo(r, String(baseSeq + VIDEOS.findIndex(v => v.id === r.id)).padStart(2, '0'))
})
const allResults = [...finalResults, ...matFailed]
const done = allResults.filter(r => r.status === 'done')
const failed = allResults.filter(r => r.status !== 'done')
console.log(`\n${'='.repeat(60)}`)
console.log(`全部完成 — ${Math.floor((Date.now()-start)/60000)}min | ✅ ${done.length} | ❌ ${failed.length}`)
console.log(`${'='.repeat(60)}\n`)
allResults.forEach((r, i) => {
const s = r.status === 'done' ? '✅' : '❌'
console.log(` ${s} 脚本${r.scriptNum} ${r.style} | ${r.draftName || r.error || ''}`)
})
}
}
main().catch(e => { console.error(e.message); process.exit(1) })