feat(video-pipeline): 重构多阶段生成管线并集成 CosyVoice TTS

- 重写 `phase-images`:改为并发 3 张并行生成,每个 item 完成立即写入 manifest,支持 MJ task ID 恢复
- 重写 `phase-videos`:先恢复已有 task ID 再提交新任务(并发 3),支持中断恢复
- 迁移 TTS 引擎:从 Qwen-TTS HTTP 接口切换为 CosyVoice WebSocket 接口,支持音色/语气参数透传
- 精简账号系统:移除 `styles/` 目录、`taskId` 过滤和 `--id` 正则校验,`references` 改为顶层字段
- 调整 `slugify`:限制中文字符 5 个、其他 10 个,避免文件名过长
- 更新文档:`manifest-schema.md` 中 `narration` 改为完整原文案,`account-creation.md` 新增 TTS 配置项
- 配置更新:默认 TTS 模型切换为 `cosyvoice-v3.5-plus`,新增 `localAudio` 参数
This commit is contained in:
2026-05-01 00:44:18 +08:00
parent 3326f6cb37
commit 7d526d2b60
19 changed files with 888 additions and 411 deletions

View File

@@ -2,8 +2,10 @@
* Phase: videos — 视频生成VEO / Grok / Kling
*
* 图生视频,批量提交,生成后自动上传 OSS
* 支持 task ID 恢复:中断后重跑时优先恢复已有任务
*/
const fs = require('fs')
const path = require('path')
const { saveManifest, ensureDir, log, getManifestDir } = require('./pipeline-utils')
@@ -21,63 +23,140 @@ async function phaseVideos(manifest, manifestPath, options) {
if (items.length === 0) { log('videos', '无待处理 item跳过'); return }
// 选择生成器
let generator
let Api, pollFn
const modelLower = videoModel.toLowerCase()
if (modelLower.includes('grok')) {
generator = require('../grok-video-generator')
const gen = require('../grok-video-generator')
Api = gen.GrokApi; pollFn = gen.pollWithRetry
} else if (modelLower.includes('kling')) {
generator = require('../kling-video-generator')
const gen = require('../kling-video-generator')
Api = gen.KlingApi; pollFn = gen.pollWithRetry
} else {
generator = require('../veo-video-generator')
const gen = require('../veo-video-generator')
Api = gen.VeoApi; pollFn = gen.pollWithRetry
}
const ratio = manifest.format || '9:16'
log('videos', `${items.length} 个, 模型: ${videoModel}`)
const tasks = items.map((item, i) => {
const task = {
id: item.id || i + 1,
prompt: item.videoPrompt,
image: item.url,
outputDir: videosDir,
}
if (item.lastFrameUrl) {
task.images = [item.url, item.lastFrameUrl]
task.lastFrameUrl = item.lastFrameUrl
// Phase 1: 恢复已有任务(有 videoTaskId 的 item
const recovered = []
const needSubmit = []
for (const item of items) {
if (item.videoTaskId) {
recovered.push(item)
} else {
task.images = [item.url]
needSubmit.push(item)
}
return task
})
}
try {
const results = await generator.batchGenerate(tasks, {
videoModel,
aspectRatio: manifest.format || '9:16',
outputDir: videosDir,
skipManifestWrite: true,
// 轮询恢复的任务
if (recovered.length > 0) {
log('videos', `尝试恢复 ${recovered.length} 个中断任务...`)
await Promise.allSettled(
recovered.map(async (item) => {
try {
log('videos', ` 恢复 item ${item.id}: ${item.videoTaskId}`)
const result = await pollFn(item.videoTaskId, item.videoPrompt, {
outputDir: videosDir,
aspectRatio: ratio,
imageUrl: item.url,
lastFrameUrl: item.lastFrameUrl || '',
})
if (result.file) {
item.video = path.relative(dir, result.file).replace(/\\/g, '/')
item.videoDuration = result.duration
delete item.videoTaskId
log('videos', ` item ${item.id} 恢复成功`)
}
} catch (err) {
log('videos', ` item ${item.id} 恢复失败: ${err.message},将重新提交`)
delete item.videoTaskId
needSubmit.push(item)
}
saveManifest(manifestPath, manifest)
})
)
}
if (needSubmit.length === 0) { log('videos', '全部通过恢复完成'); return }
// Phase 2: 提交新任务(并发 3
const concurrency = 3
log('videos', `提交 ${needSubmit.length} 个新任务(并发: ${concurrency}...`)
const submitted = []
for (let i = 0; i < needSubmit.length; i += concurrency) {
const batch = needSubmit.slice(i, i + concurrency)
const batchResults = await Promise.allSettled(
batch.map(async (item) => {
const images = item.lastFrameUrl
? [item.url, item.lastFrameUrl]
: [item.url]
const extraOpts = item.lastFrameUrl
? { aspectRatio: ratio, lastFrameUrl: item.lastFrameUrl }
: { aspectRatio: ratio }
try {
const taskId = await Api.create(item.url, item.videoPrompt, extraOpts)
return { item, taskId, error: null }
} catch (err) {
return { item, taskId: null, error: err.message }
}
})
)
for (const r of batchResults) {
const val = r.status === 'fulfilled' ? r.value : { item: null, taskId: null, error: r.reason }
submitted.push(val)
if (val.item && val.taskId) {
val.item.videoTaskId = val.taskId
}
}
saveManifest(manifestPath, manifest)
}
// Phase 3: 轮询新任务
const pending = submitted.filter(s => s.taskId)
if (pending.length === 0) {
log('videos', '所有任务提交失败')
for (const s of submitted) {
if (s.item) { s.item.status = 'failed'; s.item.error = s.error || '提交失败' }
}
saveManifest(manifestPath, manifest)
return
}
log('videos', `等待 ${pending.length} 个视频生成...`)
const pollResults = await Promise.allSettled(
pending.map(async ({ item, taskId }) => {
try {
const result = await pollFn(taskId, item.videoPrompt, {
outputDir: videosDir,
aspectRatio: ratio,
imageUrl: item.url,
lastFrameUrl: item.lastFrameUrl || '',
})
return { item, result, ok: true }
} catch (err) {
return { item, error: err.message, ok: false }
}
})
)
for (let i = 0; i < results.length; i++) {
const result = results[i]
const item = items[i]
if (!item) continue
if (result.success && result.file) {
item.video = path.relative(dir, result.file).replace(/\\/g, '/')
item.videoDuration = result.duration
} else {
item.status = 'failed'
item.error = result.error || '视频生成失败'
log('videos', ` item ${(item.id || '?')} 失败: ${item.error}`)
}
}
} catch (err) {
log('videos', `批量生成失败: ${err.message}`)
for (const item of items) {
if (!item.video) {
item.status = 'failed'
item.error = `批量生成异常: ${err.message}`
}
for (const r of pollResults) {
const val = r.status === 'fulfilled' ? r.value : { ok: false, error: r.reason?.message }
if (val.ok && val.result.file) {
val.item.video = path.relative(dir, val.result.file).replace(/\\/g, '/')
val.item.videoDuration = val.result.duration
delete val.item.videoTaskId
} else if (val.item) {
val.item.status = 'failed'
val.item.error = val.error || '视频生成未返回文件'
delete val.item.videoTaskId
}
saveManifest(manifestPath, manifest)
}
// 上传视频到 OSS