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:
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -78,9 +78,11 @@ async function getReferences(manifest, accountConfig) {
|
||||
}
|
||||
|
||||
// 优先读 manifest.references(agent 创建时写入)
|
||||
// 兼容字符串格式("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)
|
||||
|
||||
189
.claude/skills/video-from-script/scripts/minimax-tts.js
Normal file
189
.claude/skills/video-from-script/scripts/minimax-tts.js
Normal 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 }
|
||||
172
.claude/skills/video-from-script/scripts/minimax-voice-clone.js
Normal file
172
.claude/skills/video-from-script/scripts/minimax-voice-clone.js
Normal 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) })
|
||||
@@ -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 = {}) {
|
||||
|
||||
347
.claude/skills/video-from-script/scripts/口播_assemble.js
Normal file
347
.claude/skills/video-from-script/scripts/口播_assemble.js
Normal 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)
|
||||
})
|
||||
573
.claude/skills/video-from-script/scripts/口播_pipeline.js
Normal file
573
.claude/skills/video-from-script/scripts/口播_pipeline.js
Normal 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 }
|
||||
Reference in New Issue
Block a user