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>
This commit is contained in:
lc
2026-05-24 20:05:56 +08:00
parent 817c181cb5
commit 1e110219ff
31 changed files with 5098 additions and 34 deletions

View File

@@ -37,12 +37,19 @@ const cfg = loadConfig()
const Config = {
apiBase: (cfg.kelingApiBaseUrl || 'https://api-beijing.klingai.com').replace(/\/+$/, ''),
model: cfg.kelingModel || 'kling-v3',
yunwuApiBase: (cfg.kelingYunwuApiBase || '').replace(/\/+$/, ''),
yunwuApiKey: cfg.kelingYunwuApiKey || cfg.minimaxApiKey || '',
model: cfg.kelingModel || 'kling-v2-5-turbo',
pollInterval: 10000,
maxPollTime: 600000,
maxRetries: 3,
}
// 是否走云雾代理模式(用 Bearer token 而非 JWT
function isYunwuMode() {
return !!(Config.yunwuApiBase && Config.yunwuApiKey)
}
// ============================================================================
// JWT 鉴权(来自可灵官方 API 规范)
// ============================================================================
@@ -180,7 +187,7 @@ function extractCoreSubject(prompt) {
}
// ============================================================================
// 官方可灵 API
// 可灵 API(官方 JWT 模式 + 云雾 Bearer Token 模式)
// ============================================================================
const KlingApi = {
@@ -192,8 +199,11 @@ const KlingApi = {
lastFrameUrl = '',
} = options
const creds = loadCredentials()
const token = makeJwt(creds.ak, creds.sk)
const yunwu = isYunwuMode()
const apiBase = yunwu ? Config.yunwuApiBase : Config.apiBase
const authHeader = yunwu
? `Bearer ${Config.yunwuApiKey}`
: `Bearer ${makeJwt(...Object.values(loadCredentials()))}`
const body = {
model_name: model,
@@ -210,8 +220,8 @@ const KlingApi = {
const modeLabel = lastFrameUrl ? '首尾帧' : '单图'
console.log(`\n📡 提交可灵视频任务 [${modeLabel}]`)
console.log(` API: ${Config.apiBase}`)
console.log(`\n📡 提交可灵视频任务 [${modeLabel}]${yunwu ? ' [云雾]' : ' [官方]'}`)
console.log(` API: ${apiBase}`)
console.log(` 模型: ${model}`)
console.log(` 提示词: ${prompt.substring(0, 80)}...`)
if (lastFrameUrl) {
@@ -222,11 +232,11 @@ const KlingApi = {
}
console.log(` 时长: ${duration}s | 画质: ${mode}`)
const res = await fetch(`${Config.apiBase}/v1/videos/image2video`, {
const res = await fetch(`${apiBase}/v1/videos/image2video`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'Authorization': authHeader,
},
body: JSON.stringify(body),
})
@@ -249,12 +259,15 @@ const KlingApi = {
},
async query(taskId) {
const creds = loadCredentials()
const token = makeJwt(creds.ak, creds.sk)
const yunwu = isYunwuMode()
const apiBase = yunwu ? Config.yunwuApiBase : Config.apiBase
const authHeader = yunwu
? `Bearer ${Config.yunwuApiKey}`
: `Bearer ${makeJwt(...Object.values(loadCredentials()))}`
const res = await fetch(`${Config.apiBase}/v1/videos/image2video/${taskId}`, {
const res = await fetch(`${apiBase}/v1/videos/image2video/${taskId}`, {
headers: {
'Authorization': `Bearer ${token}`,
'Authorization': authHeader,
'Content-Type': 'application/json',
},
})

View File

@@ -18,7 +18,23 @@ async function phaseImages(manifest, manifestPath, options) {
((!it.status || it.status === 'pending' || it.status === 'generating') && it.imagePrompt) ||
(it.status === 'done' && manifest.mode === 'framePair' && it.file && it.lastFramePrompt && !it.lastFrame)
)
if (items.length === 0) { log('images', '无待处理 item跳过'); return }
if (items.length === 0) {
// 检查是否是真实的无待处理项(已有图片)还是配置错误
const total = manifest.items.length
const withImg = manifest.items.filter(it => it.url || it.file).length
const withPrompt = manifest.items.filter(it => it.imagePrompt).length
if (withImg === 0) {
console.error("\n" + "=".repeat(60))
console.error("❌ [images] 严重错误:没有任何待处理的图片生成项,且 manifest 中也没有已生成的图片!")
console.error("=".repeat(60))
console.error(` 总数: ${total} | 有imagePrompt: ${withPrompt} | 已有图片: ${withImg}`)
if (withPrompt === 0) console.error(" 根因: 所有 item 都缺少 imagePrompt — 分镜文件可能有问题")
console.error("=".repeat(60) + "\n")
throw new Error(`图片阶段中断: ${total} 个 item 均无图片且无待处理项 (imagePrompt=${withPrompt}/${total})`)
}
log('images', '所有图片已生成,跳过')
return
}
const accountConfig = options.accountConfig || {}
let model = options.imageModel || manifest.imageModel || accountConfig.imageModel || 'gemini'

View File

@@ -87,7 +87,8 @@ async function phaseTts(manifest, manifestPath, options = {}) {
const audioDir = path.join(dir, 'audio')
ensureDir(audioDir)
const { synthesize } = require('../qwen-tts')
const engine = manifest.ttsEngine || 'qwen'
const { synthesize } = engine === 'minimax' ? require('../minimax-tts') : require('../qwen-tts')
const videoDur = manifest.estimatedVideoDuration || 6
const ttsRate = manifest.ttsRate || 1.15
@@ -95,7 +96,22 @@ async function phaseTts(manifest, manifestPath, options = {}) {
const items = manifest.items.filter(it =>
(it.script || it.text) && !it.audio
)
if (items.length === 0) { log('tts', '无待处理 item已合成跳过'); return }
if (items.length === 0) {
const total = manifest.items.length
const withAudio = manifest.items.filter(it => it.audio).length
const withScript = manifest.items.filter(it => it.script || it.text).length
if (withAudio === 0) {
console.error("\n" + "=".repeat(60))
console.error("❌ [tts] 严重错误:没有任何待处理的配音项,且 manifest 中也没有已生成的音频!")
console.error("=".repeat(60))
console.error(` 总数: ${total} | 有script: ${withScript} | 已有audio: ${withAudio}`)
if (withScript === 0) console.error(" 根因: 所有 item 都缺少 script 文本 — 分镜文件有问题")
console.error("=".repeat(60) + "\n")
throw new Error(`TTS 阶段中断: ${total} 个 item 均无音频且无待处理项 (script=${withScript}/${total})`)
}
log('tts', '所有音频已合成,跳过')
return
}
log('tts', `${items.length} 段, 视频固定时长=${videoDur}s, TTS语速=${ttsRate}x`)
@@ -131,6 +147,9 @@ async function phaseTts(manifest, manifestPath, options = {}) {
model: manifest.ttsModel || undefined,
instruction: manifest.ttsInstruction || undefined,
rate: ttsRate,
emotion: manifest.ttsEmotion || undefined,
languageBoost: manifest.ttsLanguageBoost || undefined,
pitch: manifest.ttsPitch ?? undefined,
})
break
} catch (e) {

View File

@@ -49,23 +49,35 @@ async function phaseVideos(manifest, manifestPath, options) {
})
if (videoCandidates.length === 0) {
console.log("\n⚠️ [videos] 没有符合条件的 item 进入视频生成阶段")
console.log(" manifest 中共有", manifest.items.length, "个 item逐一诊断:")
console.error("\n" + "=".repeat(60))
console.error("❌ [videos] 严重错误:没有任何 item 可以进入视频生成阶段!")
console.error("=".repeat(60))
console.error(" manifest 中共有", manifest.items.length, "个 item逐一诊断:")
let reasonSummary = {}
for (const it of manifest.items) {
const reasons = []
if (it.confirmed === false) reasons.push("confirmed=false")
if (!it.url) reasons.push("缺少 url图片未上传")
if (!it.videoPrompt) reasons.push("缺少 videoPrompt")
if (it.confirmed === false) { reasons.push("confirmed=false(未确认)"); reasonSummary['confirmed=false'] = (reasonSummary['confirmed=false']||0)+1 }
if (!it.url) { reasons.push("缺少 url图片未上传"); reasonSummary['no_url'] = (reasonSummary['no_url']||0)+1 }
if (!it.videoPrompt) { reasons.push("缺少 videoPrompt"); reasonSummary['no_videoPrompt'] = (reasonSummary['no_videoPrompt']||0)+1 }
if (it.status === 'done' && (it.video || it.videoUrl)) {
reasons.push("视频已生成,已跳过")
reasons.push("视频已生成")
reasonSummary['already_done'] = (reasonSummary['already_done']||0)+1
} else if (!["done","pending","failed"].includes(it.status)) {
reasons.push("status=" + (it.status || "undefined") + "(不在 done/pending/failed 中)")
reasonSummary['bad_status'] = (reasonSummary['bad_status']||0)+1
}
console.log(" - item", it.id || manifest.items.indexOf(it), ":", reasons.length > 0 ? reasons.join(", ") : "已满足全部条件(不应在此)")
console.error(` - item ${it.id || manifest.items.indexOf(it)}: ${reasons.join(", ")}`)
}
console.log("\n 修复命令:")
console.log(" node .claude/skills/video-from-script/scripts/pipeline.js confirm --manifest", manifestPath, "--all")
console.log()
console.error("\n 原因汇总:", JSON.stringify(reasonSummary))
let fixCmd = ""
if (reasonSummary['confirmed=false']) fixCmd = `node ${path.join(__dirname, '..', 'pipeline.js')} confirm --manifest ${manifestPath} --all`
else if (reasonSummary['no_url']) fixCmd = `node ${path.join(__dirname, '..', 'pipeline.js')} run --manifest ${manifestPath} --phase images,upload`
else if (reasonSummary['bad_status']) fixCmd = `手动将 item status 改为 done/pending/failed 后重新运行 videos`
console.error("\n 修复命令: " + fixCmd)
console.error("=".repeat(60) + "\n")
throw new Error(`视频阶段中断: ${manifest.items.length} 个 item 均不符合视频生成条件 (${Object.entries(reasonSummary).map(([k,v])=>k+'×'+v).join(', ')})`)
}
const items = []
@@ -92,7 +104,10 @@ async function phaseVideos(manifest, manifestPath, options) {
}
items.push(it)
}
if (items.length === 0) { log('videos', '无待处理 item跳过'); return }
if (items.length === 0) {
console.error("\n❌ [videos] 严重错误:视频候选列表非空但处理后无待处理项(磁盘恢复后全部跳过)")
throw new Error('视频阶段中断: 处理后无待处理 item')
}
let Api, pollFn
const modelLower = videoModel.toLowerCase()
@@ -159,9 +174,11 @@ async function phaseVideos(manifest, manifestPath, options) {
if (needSubmit.length === 0) { log('videos', '全部通过恢复完成'); return }
// Phase 2+3: 分批提交+轮询(严格并发 ≤ 5等一批完成再提交下一批
const concurrency = 5
const concurrency = 1
log('videos', `提交 ${needSubmit.length} 个新任务(并发: ${concurrency}...`)
let totalSubmitted = 0
for (let i = 0; i < needSubmit.length; i += concurrency) {
const batch = needSubmit.slice(i, i + concurrency).filter(item => !item.videoTaskId)
if (batch.length === 0) continue
@@ -245,6 +262,34 @@ async function phaseVideos(manifest, manifestPath, options) {
}
saveManifest(manifestPath, manifest)
}
// 最终检查:如果提交了视频任务但成功率极低,硬中断
const successCount = manifest.items.filter(it => it.video || it.videoUrl).length
const failCount = manifest.items.filter(it => it.status === 'failed').length
const totalCandidates = videoCandidates.length
if (successCount === 0 && totalCandidates > 0) {
console.error("\n" + "=".repeat(60))
console.error("❌ [videos] 严重错误:所有视频生成任务全部失败!")
console.error("=".repeat(60))
console.error(` 候选 item: ${totalCandidates} | 成功: ${successCount} | 失败: ${failCount}`)
const errSamples = manifest.items.filter(it => it.status === 'failed' && it.error).slice(0, 3)
for (const it of errSamples) {
console.error(` - item ${it.id}: ${it.error?.slice(0,100)}`)
}
const allErrors = errSamples.map(it => it.error || '').join()
if (/负载已饱和|rate.?limit|quota|额度/.test(allErrors)) {
console.error("\n 根因: Kling API 上游过载或额度不足,稍后重试")
}
console.error("=".repeat(60) + "\n")
throw new Error(`视频阶段中断: 所有 ${totalCandidates} 个候选任务失败,成功 0 个`)
}
if (successCount < totalCandidates * 0.3 && totalCandidates >= 5) {
console.error("\n⚠ [videos] 警告:视频生成成功率极低 (<30%)")
console.error(` 候选: ${totalCandidates} | 成功: ${successCount} | 失败: ${failCount}`)
console.error(" 已生成视频将继续使用,未生成的片段将以静态图片替代\n")
}
}
module.exports = { phaseVideos }

View File

@@ -78,9 +78,11 @@ async function getReferences(manifest, accountConfig) {
}
// 优先读 manifest.referencesagent 创建时写入)
// 兼容字符串格式("path/to/ref.jpg")和对象格式({file, url}
const refs = manifest.references || []
if (refs.length > 0) {
for (const ref of refs) {
for (const rawRef of refs) {
const ref = typeof rawRef === 'string' ? { file: rawRef } : rawRef
if (ref.url) result.urls.push(ref.url)
if (ref.file) {
const localPath = path.isAbsolute(ref.file) ? ref.file : path.resolve(ref.file)

View File

@@ -0,0 +1,189 @@
#!/usr/bin/env node
/**
* MiniMax TTS — T2A v2 HTTP 语音合成
*
* 模块用法:
* const { synthesize } = require('./minimax-tts')
* const { filePath, duration } = await synthesize('你好', { voice: 'my_voice_id' })
*
* CLI 用法:
* node minimax-tts.js <input.json>
* input.json: { "segments": [{"id": 1, "text": "..."}], "voice": "voice_id", "output_dir": "./audio" }
*/
const https = require('https')
const fs = require('fs')
const path = require('path')
const { execFileSync } = require('child_process')
const CONFIG_PATH = path.join(__dirname, '..', '..', 'config.json')
function loadConfig() {
if (!fs.existsSync(CONFIG_PATH)) throw new Error(`config.json 不存在: ${CONFIG_PATH}`)
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'))
}
function resolveVoice(voice, config) {
if (!voice) return voice
const voices = config.minimaxVoices || {}
return voices[voice] || voice
}
function getAudioDuration(filePath) {
try {
const out = execFileSync('ffprobe', [
'-v', 'quiet', '-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1', filePath,
], { encoding: 'utf-8', timeout: 10000 })
return parseFloat(out.trim())
} catch {
const stat = fs.statSync(filePath)
return stat.size * 8 / 128000
}
}
/**
* 单段语音合成MiniMax T2A v2 HTTP非流式
* @param {string} text
* @param {object} options - { voice, model, outputDir, id, rate, apiKey }
* @returns {Promise<{filePath: string, duration: number}>}
*/
function synthesize(text, options = {}) {
return new Promise((resolve, reject) => {
const config = loadConfig()
const apiKey = options.apiKey || config.minimaxApiKey
if (!apiKey) { reject(new Error('minimaxApiKey 未配置,请在 config.json 中添加')); return }
const model = options.model || config.minimaxTtsModel || 'speech-02-hd'
const voiceId = resolveVoice(options.voice || config.minimaxTtsVoice, config)
if (!voiceId) { reject(new Error('未指定 voice_id请在 options.voice 或 config.minimaxTtsVoice 中配置')); return }
const outputDir = options.outputDir || './audio'
fs.mkdirSync(outputDir, { recursive: true })
text = text.trimEnd()
if (!/[。!?;,.!?…]$/.test(text)) text += '。'
const id = options.id || 1
const fileName = `seg_${String(id).padStart(3, '0')}.mp3`
const filePath = path.resolve(outputDir, fileName)
const voiceSetting = {
voice_id: voiceId,
speed: options.rate || 1.0,
vol: 1.0,
pitch: options.pitch ?? 0,
}
if (options.emotion) voiceSetting.emotion = options.emotion
const requestBody = {
model,
text,
stream: false,
voice_setting: voiceSetting,
audio_setting: {
sample_rate: 32000,
format: 'mp3',
channel: 1,
},
}
if (options.languageBoost) requestBody.language_boost = options.languageBoost
const body = JSON.stringify(requestBody)
const baseUrl = config.minimaxApiBase || 'https://api.yunwu.ai/minimax'
const url = new URL(`${baseUrl}/v1/t2a_v2`)
const reqOptions = {
hostname: url.hostname,
path: url.pathname,
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
}
let settled = false
const timer = setTimeout(() => {
if (!settled) { settled = true; req.destroy(); reject(new Error('MiniMax TTS 超时 (90s)')) }
}, 90000)
const req = https.request(reqOptions, (res) => {
let body = ''
res.on('data', chunk => { body += chunk.toString() })
res.on('end', () => {
clearTimeout(timer)
if (settled) return
settled = true
try {
const d = JSON.parse(body)
if (d.base_resp?.status_code && d.base_resp.status_code !== 0) {
reject(new Error(`MiniMax TTS 失败 [${d.base_resp.status_code}]: ${d.base_resp.status_msg}`))
return
}
const hex = d.data?.audio
if (!hex) { reject(new Error('MiniMax TTS 未返回音频')); return }
const audioBuffer = Buffer.from(hex, 'hex')
fs.writeFileSync(filePath, audioBuffer)
resolve({ filePath, duration: getAudioDuration(filePath) })
} catch (e) {
reject(new Error(`MiniMax TTS 解析响应失败: ${e.message}`))
}
})
res.on('error', (e) => {
clearTimeout(timer)
if (!settled) { settled = true; reject(e) }
})
})
req.on('error', (e) => {
clearTimeout(timer)
if (!settled) { settled = true; reject(new Error(`MiniMax TTS 请求失败: ${e.message}`)) }
})
req.write(body)
req.end()
})
}
async function synthesizeBatch(segments, options = {}) {
const results = []
for (const seg of segments) {
console.error(` 合成 #${seg.id}: ${seg.text.substring(0, 30)}...`)
try {
const { filePath, duration } = await synthesize(seg.text, { ...options, id: seg.id })
results.push({ id: seg.id, text: seg.text, audio: filePath, duration: Math.round(duration * 1000) / 1000 })
} catch (err) {
results.push({ id: seg.id, text: seg.text, audio: '', duration: 0, error: err.message })
}
await new Promise(r => setTimeout(r, 300))
}
return results
}
async function main() {
const inputJson = process.argv[2]
if (!inputJson) {
console.error('用法: node minimax-tts.js <input.json>')
console.error(JSON.stringify({ segments: [{ id: 1, text: '文案' }], voice: 'voice_id', output_dir: './audio' }, null, 2))
process.exit(1)
}
const input = JSON.parse(fs.readFileSync(inputJson, 'utf-8'))
const results = await synthesizeBatch(input.segments, { voice: input.voice, outputDir: input.output_dir || './audio' })
process.stdout.write(JSON.stringify({ segments: results }, null, 2) + '\n')
}
if (require.main === module) {
main().catch(err => { console.error('合成失败:', err.message); process.exit(1) })
}
module.exports = { synthesize, synthesizeBatch }

View File

@@ -0,0 +1,172 @@
#!/usr/bin/env node
/**
* MiniMax 音色克隆
*
* 用法:
* node minimax-voice-clone.js --file /path/to/audio.mp3 --voice-id my_voice_name
*
* 流程:
* 1. 上传音频文件 → 获取 file_id
* 2. 调用 voice_clone → 注册 voice_id
* 3. 输出 voice_id写入 config.json 的 minimaxVoices
*
* 选项:
* --file <path> 待克隆的音频文件MP3/WAV/M4A≥10s
* --voice-id <name> 自定义音色 ID字母数字下划线如 "snape_mm"
* --name <name> 音色中文名(写入 config默认同 voice-id
* --model <model> 模型(默认 speech-02-hd
* --save 自动写入 config.json 的 minimaxVoices
*/
const https = require('https')
const fs = require('fs')
const path = require('path')
const CONFIG_PATH = path.join(__dirname, '..', '..', 'config.json')
const BOUNDARY = `----FormBoundary${Date.now().toString(16)}`
function loadConfig() {
if (!fs.existsSync(CONFIG_PATH)) throw new Error(`config.json 不存在: ${CONFIG_PATH}`)
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'))
}
function httpsRequest(options, body) {
return new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
const chunks = []
res.on('data', c => chunks.push(c))
res.on('end', () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')))
} catch (e) {
reject(new Error(`响应解析失败: ${e.message}`))
}
})
})
req.on('error', reject)
if (body) req.write(body)
req.end()
})
}
async function uploadFile(apiKey, filePath, baseUrl, purpose = 'voice_clone') {
const fileBuffer = fs.readFileSync(filePath)
const fileName = path.basename(filePath)
const bodyParts = [
`--${BOUNDARY}\r\nContent-Disposition: form-data; name="purpose"\r\n\r\n${purpose}`,
`--${BOUNDARY}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`,
]
const body = Buffer.concat([
Buffer.from(bodyParts[0] + '\r\n'),
Buffer.from(bodyParts[1]),
fileBuffer,
Buffer.from(`\r\n--${BOUNDARY}--\r\n`),
])
const url = new URL(`${baseUrl}/v1/files`)
const result = await httpsRequest({
hostname: url.hostname,
path: url.pathname,
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': `multipart/form-data; boundary=${BOUNDARY}`,
'Content-Length': body.length,
},
}, body)
if (result.base_resp?.status_code !== 0) {
throw new Error(`上传失败 [${result.base_resp?.status_code}]: ${result.base_resp?.status_msg}`)
}
return result.file.file_id
}
async function cloneVoice(apiKey, fileId, voiceId, model, baseUrl) {
const body = JSON.stringify({
file_id: fileId,
voice_id: voiceId,
text: '这是一段用于验证音色克隆效果的测试文本,请仔细聆听音色特征。',
model,
need_noise_reduction: true,
need_volume_normalization: true,
})
const url = new URL(`${baseUrl}/v1/voice_clone`)
const result = await httpsRequest({
hostname: url.hostname,
path: url.pathname,
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
}, body)
if (result.base_resp?.status_code !== 0) {
throw new Error(`克隆失败 [${result.base_resp?.status_code}]: ${result.base_resp?.status_msg}`)
}
return result
}
function parseArgs(argv) {
const args = {}
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith('--')) {
const key = argv[i].slice(2)
args[key] = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true
}
}
return args
}
async function main() {
const args = parseArgs(process.argv.slice(2))
if (!args.file || !args['voice-id']) {
console.error('用法: node minimax-voice-clone.js --file <音频文件> --voice-id <自定义ID> [--name <中文名>] [--save]')
console.error('示例: node minimax-voice-clone.js --file snape.mp3 --voice-id snape_mm --name 斯内普MM --save')
process.exit(1)
}
const config = loadConfig()
const apiKey = config.minimaxApiKey
if (!apiKey) { console.error('config.json 中未配置 minimaxApiKey'); process.exit(1) }
const filePath = path.resolve(args.file)
if (!fs.existsSync(filePath)) { console.error(`文件不存在: ${filePath}`); process.exit(1) }
const voiceId = args['voice-id']
const voiceName = args.name || voiceId
const model = args.model || config.minimaxTtsModel || 'speech-2.8-hd'
const baseUrl = config.minimaxApiBase || 'https://api.yunwu.ai/minimax'
console.log(`\n[1/2] 上传音频文件: ${path.basename(filePath)}`)
const fileId = await uploadFile(apiKey, filePath, baseUrl)
console.log(` file_id: ${fileId}`)
console.log(`[2/2] 克隆音色: ${voiceId} (模型: ${model})`)
await cloneVoice(apiKey, fileId, voiceId, model, baseUrl)
console.log(` 克隆成功!`)
console.log(`\n✅ voice_id: ${voiceId}`)
if (args.save) {
const voices = config.minimaxVoices || {}
voices[voiceName] = voiceId
config.minimaxVoices = voices
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2))
console.log(` 已写入 config.json: minimaxVoices["${voiceName}"] = "${voiceId}"`)
} else {
console.log(`\n 手动写入 config.json:`)
console.log(` "minimaxVoices": { "${voiceName}": "${voiceId}" }`)
console.log(`\n 或运行时加 --save 参数自动写入`)
}
}
main().catch(err => { console.error('\n❌ 失败:', err.message); process.exit(1) })

View File

@@ -56,13 +56,18 @@ async function uploadFile(filePath, options = {}) {
const basename = options.name || path.basename(filePath)
const ossPath = `${folder}${basename}`
const buffer = fs.readFileSync(filePath)
await client.put(ossPath, buffer)
const stat = fs.statSync(filePath)
const opts = stat.size > 50 * 1024 * 1024
? { timeout: 600000 } // 10min for large files
: {}
const stream = fs.createReadStream(filePath)
await client.putStream(ossPath, stream, { ...opts, contentLength: stat.size })
const expires = config.ossExpires || 31536000
const url = client.signatureUrl(ossPath, { expires })
return { url, ossPath, size: buffer.length }
return { url, ossPath, size: stat.size }
}
async function uploadBuffer(buffer, options = {}) {

View File

@@ -0,0 +1,347 @@
#!/usr/bin/env node
/**
* 口播批量剪辑 — CapCut 草稿组装 v2.1
*
* 策略:
* 1. ffmpeg 按 edit 字幕时间轴裁剪视频片段 + concat 拼接 + 1.1x 倍速 → 单文件
* 2. 上传裁剪后视频到 OSS → 剪映里显示为一条连续轨道
* 3. 长字幕按标点拆分为 2-3s 短段,避免长时间遮挡画面
*
* 注: 尝试过 slot 级 source_timerange 方案(v3),但 CapCut Mate API 不支持,
* 本地改写 draft_content.json 会被云同步覆盖/删除。故回到 ffmpeg 预裁剪。
*
* 用法:
* node 口播_assemble.js --video <视频路径> --transcript <_edit.txt路径> [--name <草稿名>] [--speed 1.1]
*/
const path = require('path')
const fs = require('fs')
const { execSync, execFileSync } = require('child_process')
const { api, US, getConfig } = require('./lib/capcut-api')
const { addVideos, addSubtitles } = require('./lib/capcut-tracks')
const { syncDraft, registerDraft } = require('./sync-to-jianying')
const ossUpload = require('./oss-upload')
// ============================================================================
// 解析参数
// ============================================================================
function parseArgs() {
const args = process.argv.slice(2)
const result = {}
for (let i = 0; i < args.length; i++) {
if (args[i].startsWith('--')) {
result[args[i].slice(2)] = args[i + 1] || true
i++
}
}
return result
}
// ============================================================================
// 解析字幕文件 → [{begin_ms, end_ms, text}]
// ============================================================================
function parseTranscript(filePath) {
const lines = fs.readFileSync(filePath, 'utf-8').trim().split('\n')
const sentences = []
const re = /\[(\d+):(\d+)\.(\d+)\s*-->\s*(\d+):(\d+)\.(\d+)\]\s*(.*)/
for (const line of lines) {
const m = line.match(re)
if (!m) continue
const [, m1, s1, ms1, m2, s2, ms2, text] = m
const begin_ms = (parseInt(m1) * 60 + parseInt(s1)) * 1000 + parseInt(ms1.padEnd(3, '0').slice(0, 3))
const end_ms = (parseInt(m2) * 60 + parseInt(s2)) * 1000 + parseInt(ms2.padEnd(3, '0').slice(0, 3))
if (text.trim()) {
sentences.push({ begin_ms, end_ms, text: text.trim() })
}
}
return sentences
}
// ============================================================================
// 字幕拆分:长句按标点拆成 2-3s 短段
// ============================================================================
function splitSubtitleChunks(text, totalDurationMs) {
if (!text) return []
const rawParts = text.split(/(?<=[,。!?,\.!\?、;:])/).filter(Boolean)
if (rawParts.length === 0) return [{ text, ratio: 1 }]
const charCounts = rawParts.map(p => p.length)
const totalChars = charCounts.reduce((a, b) => a + b, 0)
if (totalChars === 0) return [{ text, ratio: 1 }]
const merged = []
let buffer = { text: '', chars: 0 }
for (let i = 0; i < rawParts.length; i++) {
buffer.text += rawParts[i]
buffer.chars += charCounts[i]
const estDuration = (buffer.chars / totalChars) * totalDurationMs
if (estDuration >= 1500 || i === rawParts.length - 1) {
merged.push({
text: buffer.text,
ratio: buffer.chars / totalChars,
})
buffer = { text: '', chars: 0 }
}
}
if (buffer.chars > 0) {
if (merged.length > 0) {
merged[merged.length - 1].text += buffer.text
merged[merged.length - 1].ratio += buffer.chars / totalChars
} else {
merged.push({ text: buffer.text, ratio: buffer.chars / totalChars })
}
}
return merged.length > 0 ? merged : [{ text, ratio: 1 }]
}
// ============================================================================
// 构建 ffmpeg trim+concat+speed 命令
// ============================================================================
function buildTrimCommand(inputPath, outputPath, segments, speed) {
const ffmpeg = '/usr/local/bin/ffmpeg'
const filters = []
const concatParts = []
const n = segments.length
for (let i = 0; i < n; i++) {
const seg = segments[i]
const t0 = seg.begin_ms / 1000
const t1 = seg.end_ms / 1000
filters.push(`[0:v]trim=${t0}:${t1},setpts=PTS-STARTPTS[vt${i}];`)
filters.push(`[0:a]atrim=${t0}:${t1},asetpts=PTS-STARTPTS[at${i}];`)
concatParts.push(`[vt${i}][at${i}]`)
}
const concatInputs = concatParts.join('')
const filterComplex = (
filters.join('') +
`${concatInputs}concat=n=${n}:v=1:a=1[vraw][araw];` +
`[vraw]setpts=${(1/speed).toFixed(6)}*PTS[v];` +
`[araw]atempo=${speed}[a]`
)
return [
ffmpeg, '-i', inputPath,
'-filter_complex', filterComplex,
'-map', '[v]', '-map', '[a]',
'-c:v', 'libx264', '-crf', '23', '-preset', 'fast',
'-c:a', 'aac', '-b:a', '128k',
outputPath, '-y', '-loglevel', 'error',
]
}
// ============================================================================
// 获取视频时长(毫秒)
// ============================================================================
function getVideoDurationMs(videoPath) {
try {
const out = execSync(
`/usr/local/bin/ffmpeg -i "${videoPath.replace(/"/g, '\\"')}" 2>&1 | grep Duration || true`
).toString()
const m = out.match(/Duration:\s*(\d+):(\d+):(\d+)\.(\d+)/)
if (!m) throw new Error('Duration not found')
return (parseInt(m[1]) * 3600 + parseInt(m[2]) * 60 + parseInt(m[3])) * 1000
+ Math.round(parseInt(m[4].padEnd(3, '0').slice(0, 3)))
} catch (e) {
throw new Error(`无法读取视频时长: ${e.message}`)
}
}
// ============================================================================
// 获取视频分辨率
// ============================================================================
function getVideoResolution(videoPath) {
try {
const out = execSync(
`/usr/local/bin/ffmpeg -i "${videoPath.replace(/"/g, '\\"')}" 2>&1 | grep 'Video:' || true`
).toString()
const m = out.match(/(\d{3,4})x(\d{3,4})/)
if (m) return { width: parseInt(m[1]), height: parseInt(m[2]) }
} catch {}
return { width: 1080, height: 1920 }
}
// ============================================================================
// 获取下一个全局序号
// ============================================================================
function getNextGlobalIndex() {
const config = getConfig()
const draftDir = config.jianyingDraftPath
if (!fs.existsSync(draftDir)) return 1
const folders = fs.readdirSync(draftDir)
let maxIdx = 0
for (const f of folders) {
const m = f.match(/_(\d{2})_/)
if (m) maxIdx = Math.max(maxIdx, parseInt(m[1]))
}
return maxIdx + 1
}
// ============================================================================
// 主流程
// ============================================================================
async function main() {
const args = parseArgs()
if (!args.video || !args.transcript) {
console.error('用法: node 口播_assemble.js --video <视频> --transcript <字幕文件> [--name <草稿名>] [--speed 1.1]')
process.exit(1)
}
const videoPath = path.resolve(args.video)
const transcriptPath = path.resolve(args.transcript)
const speed = parseFloat(args.speed) || 1.1
if (!fs.existsSync(videoPath)) { console.error(`视频不存在: ${videoPath}`); process.exit(1) }
if (!fs.existsSync(transcriptPath)) { console.error(`字幕文件不存在: ${transcriptPath}`); process.exit(1) }
// 账号字幕样式
const accountDir = path.resolve(__dirname, '..', '..', '..', 'accounts', '口播批量剪辑')
const accountConfig = fs.existsSync(path.join(accountDir, 'account.json'))
? JSON.parse(fs.readFileSync(path.join(accountDir, 'account.json'), 'utf-8'))
: {}
const subtitleStyle = accountConfig.capcut?.subtitleStyle || {}
// 1. 解析字幕
console.log('[1/6] 解析字幕...')
const sentences = parseTranscript(transcriptPath)
console.log(` ${sentences.length} 句字幕`)
if (sentences.length === 0) {
console.error('字幕文件为空或格式不对')
process.exit(1)
}
console.log(' --- 保留时间段(已删除气口/停顿)---')
for (let i = 0; i < sentences.length; i++) {
const s = sentences[i]
const segDur = (s.end_ms - s.begin_ms) / 1000
console.log(` [${i+1}] ${(s.begin_ms/1000).toFixed(2)}s → ${(s.end_ms/1000).toFixed(2)}s (${segDur.toFixed(1)}s) ${s.text.slice(0,20)}...`)
}
// 2. 读取视频信息
console.log('[2/6] 读取视频信息...')
const { width: vw, height: vh } = getVideoResolution(videoPath)
const width = 1080
const height = 1920
console.log(` 原始分辨率: ${vw}x${vh}`)
// 3. 压缩到1080p + 按字幕裁剪 + 倍速
console.log(`[3/6] 裁剪视频(${sentences.length}段)+ ${speed}x 倍速...`)
// 先压缩到 1080p
const tmpCompressed = `/tmp/口播_compressed_${Date.now()}.mp4`
execSync(
`/usr/local/bin/ffmpeg -i "${videoPath.replace(/"/g, '\\"')}" -vf "scale=1920:1080:force_original_aspect_ratio=decrease" -c:v libx264 -crf 23 -preset fast -c:a aac -b:a 128k "${tmpCompressed}" -y -loglevel error`,
{ stdio: 'pipe' }
)
console.log(` 压缩后: ${(fs.statSync(tmpCompressed).size / 1024 / 1024).toFixed(1)} MB`)
// trim + concat + speed
const tmpTrimmed = `/tmp/口播_trimmed_${Date.now()}.mp4`
const trimArgs = buildTrimCommand(tmpCompressed, tmpTrimmed, sentences, speed)
execFileSync(trimArgs[0], trimArgs.slice(1), { stdio: 'pipe', maxBuffer: 10 * 1024 * 1024 })
console.log(` 剪辑后: ${(fs.statSync(tmpTrimmed).size / 1024 / 1024).toFixed(1)} MB`)
const trimmedDurationMs = getVideoDurationMs(tmpTrimmed)
const totalSourceMs = sentences.reduce((a, s) => a + (s.end_ms - s.begin_ms), 0)
console.log(` 剪辑后时长: ${(trimmedDurationMs / 1000).toFixed(1)}s (原 ${totalSourceMs / 1000}s ÷ ${speed})`)
fs.unlinkSync(tmpCompressed)
// 4. 上传到 OSS
console.log('[4/6] 上传到 OSS...')
const { url: videoUrl } = await ossUpload.uploadFile(tmpTrimmed)
fs.unlinkSync(tmpTrimmed)
console.log(` ${videoUrl.slice(0, 60)}...`)
// 5. 创建草稿 + 添加视频 + 字幕
console.log('[5/6] 创建草稿 + 添加视频 + 字幕...')
const draftRes = await api('create_draft', { width, height })
const draftUrl = draftRes.draft_url
const draftId = new URL(draftUrl).searchParams.get('draft_id')
console.log(` draft_id: ${draftId}`)
// 单段视频(已裁剪拼接+倍速)
const durationUs = trimmedDurationMs * 1000
const videoItems = [{ videoUrl, id: 'main' }]
const videoTimeline = [{ start: 0, end: durationUs, duration: durationUs }]
await addVideos(draftUrl, path.dirname(videoPath), videoItems, videoTimeline, width, height)
// 字幕 timeline按倍速后时间轴逐句拆分短段
const subtitleItems = []
const subtitleTimeline = []
let cumUs = 0
for (const s of sentences) {
const segDurationMs = (s.end_ms - s.begin_ms) / speed
const segDurationUs = Math.round(segDurationMs * 1000)
const chunks = splitSubtitleChunks(s.text, segDurationMs)
let chunkCumMs = 0
for (const chunk of chunks) {
const chunkDurationMs = chunk.ratio * segDurationMs
const chunkDurationUs = Math.round(chunkDurationMs * 1000)
const chunkStartUs = cumUs + Math.round(chunkCumMs * 1000)
subtitleItems.push({ text: chunk.text })
subtitleTimeline.push({
start: chunkStartUs,
end: chunkStartUs + chunkDurationUs,
duration: chunkDurationUs,
})
chunkCumMs += chunkDurationMs
}
cumUs += segDurationUs
}
console.log(` 字幕拆分为 ${subtitleItems.length}`)
await addSubtitles(draftUrl, subtitleItems, subtitleTimeline, subtitleStyle, false)
// 6. 同步到本地剪映
console.log('[6/6] 同步到剪映...')
const today = new Date()
const mmdd = String(today.getMonth() + 1).padStart(2, '0') + String(today.getDate()).padStart(2, '0')
const idx = String(getNextGlobalIndex()).padStart(2, '0')
const shortTitle = args.name || path.basename(videoPath, path.extname(videoPath)).replace(/[\s()]/g, '').slice(0, 6)
const draftName = `口播批量剪辑_${mmdd}_${idx}_${shortTitle}`
await syncDraft(draftUrl, { name: draftName })
registerDraft(draftId, draftName, durationUs)
// 重命名草稿文件夹Mac 版剪映显示名 = 文件夹名)
const config = getConfig()
const oldDir = path.join(config.jianyingDraftPath, draftId)
const newDir = path.join(config.jianyingDraftPath, draftName)
if (fs.existsSync(oldDir) && !fs.existsSync(newDir)) {
fs.renameSync(oldDir, newDir)
console.log(` 草稿文件夹已重命名: ${draftName}`)
}
console.log(`\n完成!`)
console.log(` 草稿名称: ${draftName}`)
console.log(` 草稿地址: ${draftUrl}`)
console.log(` 倍速: ${speed}x | 视频: 1段连续 | 字幕: ${subtitleItems.length}`)
console.log(`\n下一步: 封面生成`)
}
main().catch(err => {
console.error('组装失败:', err.message)
process.exit(1)
})

View File

@@ -0,0 +1,573 @@
#!/usr/bin/env node
/**
* 口播视频生产编排器
*
* 流程: 视频 → ASR转写 → 用户编辑字幕 → 裁剪组装 → 封面生成 → 输出表格
*
* 用法:
* # 单视频
* node 口播_pipeline.js init --video <路径> --name <标题> [--account <账号>]
* node 口播_pipeline.js assemble --manifest <path> [--topic-a <封面标题>] [--forward-copy <转发文案>] [--hashtags <话题>]
*
* # 批量
* node 口播_pipeline.js batch-init --file <csv> [--account <账号>]
* node 口播_pipeline.js batch-assemble --file <batch-manifest> --row <N>
* node 口播_pipeline.js export --file <batch-manifest> [--format csv|xlsx]
*/
const fs = require('fs')
const path = require('path')
const { execSync, execFileSync } = require('child_process')
const os = require('os')
const SKILLS_DIR = path.resolve(__dirname, '..')
const ACCOUNTS_DIR = path.resolve(SKILLS_DIR, '..', '..', 'accounts')
const OUTPUT_BASE = path.resolve(SKILLS_DIR, '..', '..', 'output')
// ============================================================================
// 工具函数
// ============================================================================
function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }) }
function readJson(p) { return JSON.parse(fs.readFileSync(p, 'utf-8')) }
function writeJson(p, data) {
ensureDir(path.dirname(p))
fs.writeFileSync(p, JSON.stringify(data, null, 2), 'utf-8')
}
function formatDate(d) {
return [d.getFullYear(), String(d.getMonth()+1).padStart(2,'0'), String(d.getDate()).padStart(2,'0')].join('')
}
function calcStats(items) {
const stats = { total: items.length, pending: 0, processing: 0, completed: 0, failed: 0 }
for (const it of items) {
if (stats[it.status] !== undefined) stats[it.status]++
else stats.pending++
}
return stats
}
// ============================================================================
// CLI 参数解析
// ============================================================================
function parseArgs(argv) {
const args = {}
for (let i = 0; i < argv.length; i++) {
const k = argv[i]
if (k.startsWith('--') && argv[i+1] && !argv[i+1].startsWith('--')) {
args[k.slice(2)] = argv[++i]
} else if (k.startsWith('--')) {
args[k.slice(2)] = true
} else if (!args.command) {
args.command = k
}
}
return args
}
// ============================================================================
// ASR 转写(调用 Paraformer
// ============================================================================
function runAsr(videoPath, outputPath) {
const asrScript = path.resolve(os.homedir(), '.claude', 'scripts', 'asr_transcribe.py')
if (!fs.existsSync(asrScript)) {
throw new Error(`ASR 脚本不存在: ${asrScript}`)
}
console.log(' 转写中...')
execSync(`python3 "${asrScript}" "${videoPath}" --output "${outputPath}"`, {
stdio: 'pipe',
timeout: 120000,
})
}
// ============================================================================
// 组装(调用 口播_assemble.js
// ============================================================================
function runAssemble(videoPath, transcriptPath, draftName, speed = 1.1) {
const scriptPath = path.resolve(__dirname, '口播_assemble.js')
const result = execFileSync('node', [
scriptPath,
'--video', videoPath,
'--transcript', transcriptPath,
'--name', draftName,
'--speed', String(speed),
], { stdio: 'pipe', timeout: 300000 }).toString()
// 从输出中提取 draft_url 和 draft_id
let draftUrl = ''
let draftId = ''
for (const line of result.split('\n')) {
if (line.includes('草稿地址:')) draftUrl = line.split('草稿地址:')[1].trim()
if (line.includes('草稿名称:')) draftName = line.split('草稿名称:')[1].trim()
if (line.includes('draft_id:')) draftId = line.split('draft_id:')[1].trim()
}
console.log(result)
return { draftUrl, draftName, draftId }
}
// ============================================================================
// 封面生成(占位 — 后续接入完整封面流程)
// ============================================================================
async function generateCovers(manifest, draftName, topicA) {
// 封面后续完整实现,当前返回空
console.log(' 封面: 待账号模板锁定后启用')
return { cover9x16: '', cover3x4: '' }
}
// ============================================================================
// === 单视频模式 ===
// ============================================================================
function cmdInit(args) {
if (!args.video || !args.name) {
console.error('用法: 口播_pipeline.js init --video <路径> --name <标题> [--account <账号>]')
process.exit(1)
}
const videoPath = path.resolve(args.video)
const title = args.name
const account = args.account || '口播批量剪辑'
if (!fs.existsSync(videoPath)) {
console.error(`视频不存在: ${videoPath}`)
process.exit(1)
}
// 验证账号
const accountJsonPath = path.join(ACCOUNTS_DIR, account, 'account.json')
if (!fs.existsSync(accountJsonPath)) {
console.error(`账号不存在: ${account},可用账号:`)
for (const d of fs.readdirSync(ACCOUNTS_DIR)) {
if (d.startsWith('_')) continue
if (fs.existsSync(path.join(ACCOUNTS_DIR, d, 'account.json'))) console.error(` - ${d}`)
}
process.exit(1)
}
// 创建输出目录
const dateStr = formatDate(new Date())
let seq = 1
while (fs.existsSync(path.join(OUTPUT_BASE, `${account}_${dateStr}_${String(seq).padStart(3,'0')}`))) seq++
const outDir = path.join(OUTPUT_BASE, `${account}_${dateStr}_${String(seq).padStart(3,'0')}`)
ensureDir(outDir)
// 复制视频到输出目录
const videoBasename = path.basename(videoPath)
const localVideo = path.join(outDir, 'source', videoBasename)
ensureDir(path.dirname(localVideo))
fs.copyFileSync(videoPath, localVideo)
console.log(`视频已复制: ${localVideo}`)
// ASR 转写
console.log('[ASR] 语音转文字...')
const rawTxt = path.join(outDir, 'transcript_raw.txt')
runAsr(localVideo, rawTxt)
// 复制一份作为 edit 模板
const editTxt = path.join(outDir, 'transcript_edit.txt')
fs.copyFileSync(rawTxt, editTxt)
// 写 manifest
const manifest = {
account,
name: title,
type: 'talking-head',
videoPath: `source/${videoBasename}`,
transcriptRaw: 'transcript_raw.txt',
transcriptEdit: 'transcript_edit.txt',
speed: 1.1,
status: 'editing', // editing → assembled → done
draftName: '',
draftUrl: '',
topicA: '',
topicB: '',
forwardCopy: '',
hashtags: '',
coverUrl_抖音: '',
coverUrl_小红书: '',
createdAt: new Date().toISOString(),
}
const manifestPath = path.join(outDir, 'manifest.json')
writeJson(manifestPath, manifest)
console.log(`\n初始化完成: ${manifestPath}`)
console.log(` 视频: ${localVideo}`)
console.log(` 原始字幕: ${rawTxt}`)
console.log(` 编辑字幕: ${editTxt} ← 请编辑此文件(删气口/错字/冗余)`)
console.log(`\n编辑完成后运行:`)
console.log(` node .claude/skills/video-from-script/scripts/口播_pipeline.js assemble --manifest ${manifestPath}`)
}
// ============================================================================
function cmdAssemble(args) {
if (!args.manifest) {
console.error('用法: 口播_pipeline.js assemble --manifest <path> [--topic-a <封面标题>] [--forward-copy <转发文案>] [--hashtags <话题>]')
process.exit(1)
}
const manifestPath = path.resolve(args.manifest)
const manifest = readJson(manifestPath)
const outDir = path.dirname(manifestPath)
const videoPath = path.resolve(outDir, manifest.videoPath)
const editTxt = path.resolve(outDir, manifest.transcriptEdit)
if (!fs.existsSync(videoPath)) { console.error(`视频不存在: ${videoPath}`); process.exit(1) }
if (!fs.existsSync(editTxt)) { console.error(`字幕文件不存在: ${editTxt},请先编辑 transcript_edit.txt`); process.exit(1) }
// 更新元数据
if (args.topicA) manifest.topicA = args.topicA
if (args.forwardCopy) manifest.forwardCopy = args.forwardCopy
if (args.hashtags) manifest.hashtags = args.hashtags
if (args.topicB) manifest.topicB = args.topicB
// 草稿名称
const today = new Date()
const mmdd = formatDate(today).slice(4)
const draftName = `${manifest.account}_${mmdd}_${manifest.name}`
manifest.draftName = draftName
// 组装
console.log('[Assemble] 视频裁剪 + 字幕 + 倍速...')
manifest.status = 'assembling'
writeJson(manifestPath, manifest)
const result = runAssemble(videoPath, editTxt, manifest.name, manifest.speed || 1.1)
manifest.draftUrl = result.draftUrl
manifest.draftName = result.draftName || draftName
// 封面
console.log('\n[Cover] 封面生成...')
// 封面后续接入
manifest.status = 'done'
manifest.completedAt = new Date().toISOString()
writeJson(manifestPath, manifest)
// 输出表格行
const htags = manifest.hashtags || ''
let forwardBody = (manifest.forwardCopy || '').trim()
if (htags && forwardBody.endsWith(htags)) forwardBody = forwardBody.slice(0, -htags.length).trim()
const forwardFull = [forwardBody, htags].filter(Boolean).join('')
console.log('\n' + '='.repeat(70))
console.log('成片输出')
console.log('='.repeat(70))
console.log(` 草稿名称: ${manifest.draftName}`)
console.log(` 选题(封面): ${manifest.topicA || '(未指定)'}`)
console.log(` 转发文案: ${forwardFull || '(未指定)'}`)
console.log(` draftUrl: ${manifest.draftUrl}`)
console.log(` 封面_抖音: ${manifest.coverUrl_抖音 || '(待生成)'}`)
console.log(` 封面_小红书: ${manifest.coverUrl_小红书 || '(待生成)'}`)
}
// ============================================================================
// === 批量模式 ===
// ============================================================================
function parseCsv(filePath) {
const content = fs.readFileSync(filePath, 'utf-8')
const lines = content.split(/\r?\n/).filter(l => l.trim())
if (lines.length < 2) return []
// 解析 CSV 行(处理引号内逗号)
function parseLine(line) {
const result = []; let cur = ''; let inQ = false
for (const ch of line) {
if (ch === '"') inQ = !inQ
else if (ch === ',' && !inQ) { result.push(cur); cur = '' }
else cur += ch
}
result.push(cur)
return result
}
const headers = parseLine(lines[0]).map(h => h.trim())
const rows = []
for (let i = 1; i < lines.length; i++) {
const vals = parseLine(lines[i])
const row = {}
headers.forEach((h, j) => { row[h] = (vals[j] || '').trim() })
if (Object.values(row).some(v => v)) rows.push(row)
}
return rows
}
function cmdBatchInit(args) {
if (!args.file) {
console.error('用法: 口播_pipeline.js batch-init --file <csv> [--account <账号>]')
process.exit(1)
}
const csvPath = path.resolve(args.file)
if (!fs.existsSync(csvPath)) { console.error(`文件不存在: ${csvPath}`); process.exit(1) }
const rows = parseCsv(csvPath)
if (rows.length === 0) { console.error('CSV 为空'); process.exit(1) }
const defaultAccount = args.account || '口播批量剪辑'
// 创建 batch 目录
const dateStr = formatDate(new Date())
let seq = 1
while (fs.existsSync(path.join(OUTPUT_BASE, `batch_口播_${dateStr}_${String(seq).padStart(3,'0')}`))) seq++
const batchDir = path.join(OUTPUT_BASE, `batch_口播_${dateStr}_${String(seq).padStart(3,'0')}`)
ensureDir(batchDir)
// 构建 items
const items = []
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
const videoPath = row['视频路径'] || row['video'] || row['视频'] || ''
const title = row['选题'] || row['标题'] || row['title'] || row['名称'] || ''
const account = row['账号'] || row['account'] || defaultAccount
const forwardRaw = row['转发文案带话题'] || row['转发文案'] || row['forwardCopy'] || ''
if (!videoPath) {
console.warn(` ⚠ 第 ${i+2} 行缺少视频路径,跳过`)
continue
}
// 验证视频存在
if (!fs.existsSync(path.resolve(videoPath))) {
console.warn(` ⚠ 视频不存在: ${videoPath}`)
continue
}
// 解析转发文案
let forwardCopy = forwardRaw; let hashtags = ''
const hMatch = forwardRaw.match(/(#[^\s#]+)/g)
if (hMatch) {
hashtags = hMatch.join('')
forwardCopy = forwardRaw.replace(/#[^\s#]+/g, '').replace(/\s+/g, ' ').trim()
}
// 复制视频
const vExt = path.extname(videoPath)
const localVideo = path.join(batchDir, 'videos', `row_${String(i+1).padStart(3,'0')}${vExt}`)
ensureDir(path.dirname(localVideo))
fs.copyFileSync(path.resolve(videoPath), localVideo)
// ASR 转写
const rawTxt = path.join(batchDir, 'transcripts', `row_${String(i+1).padStart(3,'0')}_raw.txt`)
const editTxt = path.join(batchDir, 'transcripts', `row_${String(i+1).padStart(3,'0')}_edit.txt`)
ensureDir(path.dirname(rawTxt))
try {
runAsr(localVideo, rawTxt)
fs.copyFileSync(rawTxt, editTxt)
} catch (e) {
console.warn(` ⚠ ASR 转写失败: ${e.message}`)
// 创建一个空文件标记需要手动处理
fs.writeFileSync(rawTxt, '# ASR 失败,请手动填入字幕\n', 'utf-8')
fs.copyFileSync(rawTxt, editTxt)
}
items.push({
row: i + 1,
title: title || '',
account,
status: 'editing', // editing → assembled → done
videoPath: path.relative(batchDir, localVideo),
transcriptRaw: path.relative(batchDir, rawTxt),
transcriptEdit: path.relative(batchDir, editTxt),
topicA: '',
topicB: '',
draftName: '',
draftUrl: '',
forwardCopy,
hashtags,
coverUrl_抖音: '',
coverUrl_小红书: '',
speed: 1.1,
error: null,
})
}
if (items.length === 0) {
console.error('没有有效行')
process.exit(1)
}
const batchManifest = {
source: path.basename(csvPath),
type: 'talking-head-batch',
createdAt: new Date().toISOString(),
defaults: { account: defaultAccount, speed: 1.1 },
stats: calcStats(items),
items,
}
const manifestPath = path.join(batchDir, 'batch-manifest.json')
writeJson(manifestPath, batchManifest)
console.log(`\n批量任务已创建: ${manifestPath}`)
console.log(` 来源: ${path.basename(csvPath)}`)
console.log(` 总数: ${items.length}`)
console.log(` 账号: ${defaultAccount}`)
console.log(` 视频目录: ${path.join(batchDir, 'videos')}/`)
console.log(` 字幕目录: ${path.join(batchDir, 'transcripts')}/`)
console.log(`\n 请编辑 transcripts/ 目录下的 _edit.txt 文件(删气口/错字/冗余),`)
console.log(` 编辑完后运行 batch-assemble。`)
}
// ============================================================================
function cmdBatchAssemble(args) {
if (!args.file || !args.row) {
console.error('用法: 口播_pipeline.js batch-assemble --file <batch-manifest> --row <N>')
process.exit(1)
}
const manifestPath = path.resolve(args.file)
const batch = readJson(manifestPath)
const batchDir = path.dirname(manifestPath)
const item = batch.items.find(it => it.row === args.row)
if (!item) { console.error(`${args.row} 不存在`); process.exit(1) }
const videoPath = path.resolve(batchDir, item.videoPath)
const editTxt = path.resolve(batchDir, item.transcriptEdit)
if (!fs.existsSync(videoPath)) { console.error(`视频不存在: ${videoPath}`); process.exit(1) }
if (!fs.existsSync(editTxt)) { console.error(`字幕文件不存在: ${editTxt}`); process.exit(1) }
const today = new Date()
const mmdd = formatDate(today).slice(4)
const shortTitle = item.topicB || item.title || `row${String(item.row).padStart(2,'0')}`
const draftName = `${item.account}_${mmdd}_${String(item.row).padStart(2,'0')}_${shortTitle}`
console.log(`[#${item.row}] 组装中...`)
// 更新状态
item.status = 'assembling'
batch.stats = calcStats(batch.items)
writeJson(manifestPath, batch)
try {
const result = runAssemble(videoPath, editTxt, shortTitle, item.speed || 1.1)
item.draftUrl = result.draftUrl
item.draftName = result.draftName || draftName
item.status = 'completed'
item.error = null
console.log(`[#${item.row}] 组装完成: ${item.draftName}`)
} catch (e) {
item.status = 'failed'
item.error = e.message
console.error(`[#${item.row}] 组装失败: ${e.message}`)
}
batch.stats = calcStats(batch.items)
writeJson(manifestPath, batch)
}
// ============================================================================
// export: 批量输出表格
// ============================================================================
function cmdExport(args) {
const manifestPath = path.resolve(args.file)
const batch = readJson(manifestPath)
const rows = []
for (const item of batch.items) {
const htags = item.hashtags || ''
let forwardBody = (item.forwardCopy || '').trim()
if (htags && forwardBody.endsWith(htags)) forwardBody = forwardBody.slice(0, -htags.length).trim()
const forwardFull = [forwardBody, htags].filter(Boolean).join('')
rows.push({
序号: item.row,
草稿名称: item.draftName || '',
选题_封面: item.topicA || item.title || '',
转发文案带话题: forwardFull,
draftUrl: item.draftUrl || '',
封面_抖音: item.coverUrl_抖音 || '',
封面_小红书: item.coverUrl_小红书 || '',
})
}
rows.sort((a, b) => a.序号 - b.序号)
// 生成文件名
const accountName = batch.defaults.account || '口播批量剪辑'
const dateMMDD = formatDate(new Date()).slice(4)
const nums = rows.map(r => r.序号)
const rowStart = String(Math.min(...nums)).padStart(2, '0')
const rowEnd = String(Math.max(...nums)).padStart(2, '0')
const exportName = `${accountName}_${dateMMDD}_${rowStart}~${rowEnd}`
const outPath = path.join(path.dirname(manifestPath), `${exportName}.csv`)
const headers = ['序号', '草稿名称', '选题_封面', '转发文案带话题', 'draftUrl', '封面_抖音', '封面_小红书']
const lines = [headers.join(',')]
for (const r of rows) {
const vals = headers.map(h => {
const v = String(r[h] || '')
if (v.includes(',') || v.includes('"') || v.includes('\n')) return `"${v.replace(/"/g,'""')}"`
return v
})
lines.push(vals.join(','))
}
fs.writeFileSync(outPath, lines.join('\n'), 'utf-8')
console.log(`表格已导出: ${outPath}`)
console.log(`${rows.length}\n`)
// 打印表格
const widths = headers.map(h => {
const maxD = rows.reduce((m, r) => Math.max(m, String(r[h]||'').length), 0)
return Math.min(Math.max(maxD, h.length), 50)
})
const sep = '|-' + widths.map(w => '-'.repeat(w)).join('-|-') + '-|'
const hdr = '| ' + headers.map((h, i) => h.padEnd(widths[i])).join(' | ') + ' |'
console.log(sep)
console.log(hdr)
console.log(sep)
for (const r of rows) {
const line = '| ' + headers.map((h, i) => String(r[h]||'').padEnd(widths[i])).join(' | ') + ' |'
console.log(line)
}
console.log(sep)
}
// ============================================================================
// CLI 入口
// ============================================================================
function main() {
const args = parseArgs(process.argv.slice(2))
const cmd = args.command
if (cmd === 'init') cmdInit(args)
else if (cmd === 'assemble') cmdAssemble(args)
else if (cmd === 'batch-init') cmdBatchInit(args)
else if (cmd === 'batch-assemble') cmdBatchAssemble(args)
else if (cmd === 'export') cmdExport(args)
else {
console.log('口播视频生产编排器')
console.log('')
console.log('单视频:')
console.log(' init --video <路径> --name <标题> [--account <账号>]')
console.log(' assemble --manifest <path> [--topic-a <封面标题>] [--forward-copy <转发文案>] [--hashtags <话题>]')
console.log('')
console.log('批量:')
console.log(' batch-init --file <csv> [--account <账号>]')
console.log(' batch-assemble --file <batch-manifest> --row <N>')
console.log(' export --file <batch-manifest> [--format csv|xlsx]')
console.log('')
console.log('批量 CSV 格式: 视频路径,选题,转发文案带话题,账号')
}
}
if (require.main === module) main()
module.exports = { cmdInit, cmdAssemble, cmdBatchInit, cmdBatchAssemble, cmdExport }