- 新增 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>
263 lines
9.7 KiB
JavaScript
263 lines
9.7 KiB
JavaScript
#!/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) })
|