feat(video-from-script): 批量生产元数据增强 — 选题/转发文案/草稿命名/导出/草稿箱改名

- batch-pipeline.js: 新增 mark 元数据字段(topicA/B, draftName, forwardCopy, hashtags)
- batch-pipeline.js: 新增 export 命令导出 CSV/XLSX 最终表格
- batch-pipeline.js: 新增 rename-drafts 命令批量重命名剪映草稿(Mac 直接 mv 文件夹)
- batch-pipeline.js: 完善 displayTitle 向后兼容旧 topic 字段
- lib/phase-tts: 增强 TTS 生成稳定性
- lib/phase-videos: 视频生成优化
- lib/video-poll-utils: 提取轮询重试共享工具
- CLAUDE.md: 补充批量生产选题/转发文案/草稿命名/导出/草稿箱改名文档
- 执黑先行 account.json: 配置更新

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
lc
2026-05-14 23:16:12 +08:00
parent 2449fbabdf
commit 8787d369d3
9 changed files with 617 additions and 49 deletions

View File

@@ -119,14 +119,32 @@ async function phaseTts(manifest, manifestPath, options = {}) {
const segInput = rawSegments[j]
const segId = `${item.id}_${j + 1}`
try {
const { filePath, duration: realDuration } = await synthesize(segInput.text, {
outputDir: audioDir,
id: segId,
voice: manifest.ttsVoice || undefined,
instruction: manifest.ttsInstruction || undefined,
rate: ttsRate,
})
// 带重试的合成最多3次指数退避
let synthResult = null
let lastErr = null
for (let retry = 0; retry < 3; retry++) {
try {
synthResult = await synthesize(segInput.text, {
outputDir: audioDir,
id: segId,
voice: manifest.ttsVoice || undefined,
model: manifest.ttsModel || undefined,
instruction: manifest.ttsInstruction || undefined,
rate: ttsRate,
})
break
} catch (e) {
lastErr = e
if (retry < 2) {
const delay = Math.pow(2, retry) * 3000
log('tts', `[${idx}/${items.length}] 段${j + 1} 重试 ${retry + 1}/3, ${delay / 1000}s 后重试...`)
await new Promise(r => setTimeout(r, delay))
}
}
}
if (synthResult) {
const { filePath, duration: realDuration } = synthResult
const segment = {
id: segId,
@@ -140,8 +158,8 @@ async function phaseTts(manifest, manifestPath, options = {}) {
globalOffset += realDuration
log('tts', `[${idx}/${items.length}] 段${j + 1}: 估算${segInput.estimatedDuration.toFixed(2)}s → 实测${realDuration.toFixed(2)}s | ${segInput.text.slice(0, 15)}...`)
} catch (err) {
log('tts', `[${idx}/${items.length}] 段${j + 1} 合成失败: ${err.message}`)
} else {
log('tts', `[${idx}/${items.length}] 段${j + 1} 合成失败(重试3次后): ${lastErr?.message || '未知错误'}`)
segments.push({
id: segId,
text: segInput.text,
@@ -149,7 +167,7 @@ async function phaseTts(manifest, manifestPath, options = {}) {
estimatedDuration: segInput.estimatedDuration,
duration: 0,
startOffset: globalOffset,
error: err.message,
error: lastErr?.message || '未知错误',
})
globalOffset += segInput.estimatedDuration
}

View File

@@ -118,8 +118,8 @@ async function phaseVideos(manifest, manifestPath, options) {
if (needSubmit.length === 0) { log('videos', '全部通过恢复完成'); return }
// Phase 2: 提交新任务(并发 3
const concurrency = 3
// Phase 2: 提交新任务(并发 5Kling 最大并发
const concurrency = 5
log('videos', `提交 ${needSubmit.length} 个新任务(并发: ${concurrency}...`)
const submitted = []

View File

@@ -5,6 +5,11 @@
* 两层重试:轮询级(同一 taskId处理网络瞬断→ 任务级(创建新 task + 优化提示词)
*/
const path = require('path')
const fs = require('fs')
const https = require('https')
const http = require('http')
const TRANSIENT_RE = /timeout|ECONNRESET|ETIMEDOUT|network|socket/i
const POLL_RETRIES = 2 // 同一 task 轮询重试次数
@@ -15,6 +20,26 @@ function isTransientError(err) {
return TRANSIENT_RE.test(err.message || '')
}
async function download(url, outputPath) {
const protocol = url.startsWith('https') ? https : http
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(outputPath)
protocol.get(url, (response) => {
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
file.close()
fs.unlinkSync(outputPath)
return download(response.headers.location, outputPath).then(resolve).catch(reject)
}
response.pipe(file)
file.on('finish', () => { file.close(); resolve(outputPath) })
}).on('error', (err) => {
file.close()
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath)
reject(err)
})
})
}
/**
* 创建 pollWithRetry 函数
*