Files
video-create/run_prerender_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

263 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视频 素材预渲染(不含 assemble
* TTS + images + upload + videos2 路并发
* 等 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) })