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