feat(video-pipeline): 重构视频流水线,优化成片时间线规则和状态管理

- 引入 manifest.json 作为唯一状态源,所有子 Agent 操作回写 manifest
- 重构 timebuilder 逻辑,支持四种视频适配策略(加速/裁剪/放缓/画面停顿)
- 统一 TTS 阶段输出结构,单句和多句均写入 segments[]
- 重写字幕和配音生成,基于 segments 精确时长实现音画同步
- 新增 confirm 命令支持按 id 范围确认,上传阶段分离图片和视频
- 添加中间产物写入 output/ 目录的约束,清理废弃配置参数
This commit is contained in:
2026-05-02 00:14:40 +08:00
parent b4b92854db
commit 0998fd6ae1
14 changed files with 457 additions and 205 deletions

View File

@@ -5,21 +5,26 @@
const { loadManifest, saveManifest } = require('./pipeline-utils')
function confirmManifest(options) {
const { manifest: manifestPath, all } = options
const { manifest: manifestPath, all, items: itemsStr } = options
if (!manifestPath) {
console.error('用法: pipeline.js confirm --manifest <path> --all')
console.error(' pipeline.js confirm --manifest <path> --items 1,3,5')
process.exit(1)
}
if (!all) {
console.error('错误: 必须指定 --all')
if (!all && !itemsStr) {
console.error('错误: 必须指定 --all 或 --items <id列表>')
process.exit(1)
}
const manifest = loadManifest(manifestPath)
const targetIds = itemsStr
? new Set(itemsStr.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n)))
: null
let count = 0
for (const item of manifest.items) {
if (targetIds && !targetIds.has(item.id)) continue
if (item.file && item.status === 'done' && !item.confirmed) {
item.confirmed = true
count++
@@ -30,7 +35,8 @@ function confirmManifest(options) {
const total = manifest.items.length
const confirmed = manifest.items.filter(it => it.confirmed).length
console.log(`已确认: ${count} items${confirmed}/${total} 已确认)`)
const scope = targetIds ? `${Array.from(targetIds).join(',')}` : '全部'
console.log(`已确认: ${count} items范围: ${scope},共 ${confirmed}/${total} 已确认)`)
}
module.exports = { confirmManifest }

View File

@@ -6,7 +6,7 @@
const fs = require('fs')
const path = require('path')
const { loadAccountConfig, saveManifest, ensureDir, ACCOUNTS_DIR, SKILLS_DIR } = require('./pipeline-utils')
const { loadAccountConfig, saveManifest, ensureDir, slugify, ACCOUNTS_DIR, SKILLS_DIR } = require('./pipeline-utils')
function initManifest(options) {
const { account: accountId, mode, items: itemsJson, itemsFile } = options
@@ -40,7 +40,8 @@ function initManifest(options) {
}
// 校验必填字段
const requiredFields = ['shotDesc', 'script', 'imagePrompt']
const requiredFields = ['shotDesc', 'script']
const optionalFields = ['imagePrompt', 'videoPrompt', 'lastFramePrompt']
const resolvedMode = mode || 'single'
for (let i = 0; i < rawItems.length; i++) {
@@ -52,8 +53,7 @@ function initManifest(options) {
}
}
if (resolvedMode === 'framePair' && !item.lastFramePrompt) {
console.error(`错误: 首尾帧模式 items[${i}] 缺少 "lastFramePrompt"imagePrompt 作为第一帧)`)
process.exit(1)
delete item.lastFramePrompt // 首尾帧模式 Step 2-A 补充
}
}
@@ -68,9 +68,11 @@ function initManifest(options) {
// 构建 items
const items = rawItems.map((raw, i) => {
const slug = slugify(raw.shotDesc || raw.script || `scene_${i + 1}`)
const item = {
id: i + 1,
status: 'pending',
file: `images/scene_${String(i + 1).padStart(2, '0')}_${slug}.jpeg`,
shotDesc: raw.shotDesc || '',
script: raw.script || '',
duration: raw.duration || 5,
@@ -129,7 +131,13 @@ function initManifest(options) {
console.log(` 画幅: ${manifest.format}, 模式: ${manifest.mode}`)
console.log(` Items: ${items.length}`)
console.log(` 参考图: ${references.length}`)
if (items.some(it => !it.videoPrompt)) {
if (items.some(it => !it.imagePrompt)) {
console.log(`${items.filter(it => !it.imagePrompt).length} 个 item 缺少 imagePrompt请运行 Step 2-A图片提示词补充`)
}
if (resolvedMode === 'framePair' && items.some(it => !it.lastFramePrompt)) {
console.log(`${items.filter(it => !it.lastFramePrompt).length} 个 item 缺少 lastFramePrompt请运行 Step 2-A 补充`)
}
if (items.some(it => !it.videoPrompt && resolvedMode !== 'framePair')) {
console.log(`${items.filter(it => !it.videoPrompt).length} 个 item 缺少 videoPrompt生视频阶段将跳过`)
}
console.log()

View File

@@ -41,6 +41,9 @@ function validateManifest(manifestPath) {
if (item.status && !['pending', 'generating', 'done', 'failed'].includes(item.status)) {
issues.push(`${prefix} status 无效: ${item.status}`)
}
if (item.status === 'done' && !item.file && !item.video && !item.url) {
issues.push(`${prefix} status=done 但缺少 file/video/url素材路径`)
}
})
}

View File

@@ -15,6 +15,14 @@ async function phaseAssemble(manifest, manifestPath, options) {
const hasVideos = videoItems.length > 0
const mode = hasVideos ? 'videos' : 'images'
// 前置校验:图片模式下检查 file 字段
if (mode === 'images') {
const missingFile = manifest.items.filter(it => !it.file)
if (missingFile.length > 0) {
throw new Error(`${missingFile.length} 个 item 缺少 file 字段id: ${missingFile.map(it => it.id).join(', ')}),请先运行 images 阶段生成图片`)
}
}
const assembleArgs = {
input: dir,
manifest: manifestPath,
@@ -22,7 +30,6 @@ async function phaseAssemble(manifest, manifestPath, options) {
format: manifest.format || accountConfig.defaultFormat || '9:16',
subtitles: mode === 'images' ? 'true' : 'false',
voiceover: manifest.items.some(it => it.audio) ? 'true' : 'false',
duration: '4',
animation: capcutConfig.animation || '渐显+放大',
}

View File

@@ -17,7 +17,8 @@ async function phaseImages(manifest, manifestPath, options) {
ensureDir(imagesDir)
const items = manifest.items.filter(it =>
(!it.status || it.status === 'pending' || it.status === 'generating') && it.imagePrompt
((!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 }
@@ -45,6 +46,14 @@ async function phaseImages(manifest, manifestPath, options) {
item.status = 'generating'
saveManifest(manifestPath, manifest)
// 仅补 lastFrame首帧已存在跳过首帧生成
if (item.file && manifest.mode === 'framePair' && item.lastFramePrompt && !item.lastFrame) {
log('images', `[${idx}] 补生成 lastFrame首帧已有: ${item.file}`)
await generateLastFrame(item, idx, manifest, dir, imagesDir, model, ratio, manifestPath)
saveManifest(manifestPath, manifest)
return { ok: true }
}
let result
if (model === 'gemini') {
result = await generateGemini(item, idx, dir, imagesDir, ratio, refs)

View File

@@ -2,7 +2,8 @@
* Phase: tts — 语音合成(逐句分句生成)
*
* 将每个 item 的 script 按标点切分为短句,每句单独生成 TTS 音频。
* 结果写入 item.segments[]实现字幕与语音精确对齐
* 统一写入 item.segments[]单句时数组仅 1 个元素
* item.audio 指向第一段item.audioDuration 为累计时长。
*/
const path = require('path')
@@ -29,47 +30,32 @@ async function phaseTts(manifest, manifestPath, options = {}) {
try {
const sentences = splitTextIntoSentences(fullText)
const segments = []
let totalDuration = 0
if (sentences.length <= 1) {
// 单句:不需要 segments走原逻辑
const { filePath, duration } = await synthesize(fullText, {
for (let j = 0; j < sentences.length; j++) {
const sentence = sentences[j]
const segId = `${item.id || idx}_${j + 1}`
const { filePath, duration } = await synthesize(sentence, {
outputDir: audioDir,
id: item.id || idx,
id: segId,
voice: manifest.ttsVoice || undefined,
instruction: manifest.ttsInstruction || undefined,
rate: manifest.ttsRate || undefined,
})
item.audio = path.relative(dir, filePath).replace(/\\/g, '/')
item.audioDuration = Math.round(duration * 1000) / 1000
log('tts', `[${idx}/${items.length}] ${duration.toFixed(1)}s: ${fullText.substring(0, 30)}...`)
} else {
// 多句:逐句生成,写入 segments
const segments = []
let totalDuration = 0
for (let j = 0; j < sentences.length; j++) {
const sentence = sentences[j]
const segId = `${item.id || idx}_${j + 1}`
const { filePath, duration } = await synthesize(sentence, {
outputDir: audioDir,
id: segId,
voice: manifest.ttsVoice || undefined,
instruction: manifest.ttsInstruction || undefined,
rate: manifest.ttsRate || undefined,
})
segments.push({
text: sentence,
audio: path.relative(dir, filePath).replace(/\\/g, '/'),
duration: Math.round(duration * 1000) / 1000,
})
totalDuration += duration
}
item.segments = segments
item.audio = segments[0].audio
item.audioDuration = Math.round(totalDuration * 1000) / 1000
log('tts', `[${idx}/${items.length}] ${totalDuration.toFixed(1)}s (${segments.length}句): ${fullText.substring(0, 30)}...`)
segments.push({
text: sentence,
audio: path.relative(dir, filePath).replace(/\\/g, '/'),
duration: Math.round(duration * 1000) / 1000,
})
totalDuration += duration
}
// 统一使用 segments 数组(单句 = 1 元素,多句 = N 元素)
item.segments = segments
item.audio = segments[0].audio
item.audioDuration = Math.round(totalDuration * 1000) / 1000
log('tts', `[${idx}/${items.length}] ${totalDuration.toFixed(1)}s (${segments.length}句): ${fullText.substring(0, 30)}...`)
} catch (err) {
item.status = 'failed'
item.error = `TTS失败: ${err.message}`

View File

@@ -1,7 +1,7 @@
/**
* Phase: upload — OSS 上传
*
* 将生成的图片(含首尾帧)上传到 OSS回写 url
* 将图片(含首尾帧)和视频上传到 OSS回写 url / videoUrl
*/
const path = require('path')
@@ -11,35 +11,64 @@ async function phaseUpload(manifest, manifestPath) {
const dir = getManifestDir(manifestPath)
const { uploadFile } = require('../oss-upload')
const items = manifest.items.filter(it =>
// 图片(含首尾帧 first frame
const imageItems = manifest.items.filter(it =>
it.status === 'done' && it.file && !it.url
)
if (items.length === 0) { log('upload', '无待上传 item跳过'); return }
// 视频
const videoItems = manifest.items.filter(it =>
it.status === 'done' && it.video && !it.videoUrl
)
log('upload', `${items.length} 个文件`)
if (imageItems.length === 0 && videoItems.length === 0) {
log('upload', '无待上传文件,跳过')
return
}
for (let i = 0; i < items.length; i++) {
const item = items[i]
const filePath = path.resolve(dir, item.file)
try {
const { url } = await uploadFile(filePath)
item.url = url
log('upload', `[${i + 1}/${items.length}] ${item.file}${url.substring(0, 60)}...`)
} catch (err) {
item.error = `上传失败: ${err.message}`
log('upload', `[${i + 1}/${items.length}] 失败: ${err.message}`)
}
if (item.url && item.lastFrame && !item.lastFrameUrl) {
const lastPath = path.resolve(dir, item.lastFrame)
// 上传图片
if (imageItems.length > 0) {
log('upload', `图片: ${imageItems.length}`)
for (let i = 0; i < imageItems.length; i++) {
const item = imageItems[i]
const filePath = path.resolve(dir, item.file)
try {
const { url } = await uploadFile(lastPath)
item.lastFrameUrl = url
log('upload', `[${i + 1}/${items.length}] lastFrame → OK`)
const { url } = await uploadFile(filePath)
item.url = url
log('upload', ` [${i + 1}/${imageItems.length}] ${item.file} → OK`)
} catch (err) {
log('upload', `[${i + 1}/${items.length}] lastFrame 上传失败: ${err.message}`)
item.error = `上传失败: ${err.message}`
log('upload', ` [${i + 1}/${imageItems.length}] 失败: ${err.message}`)
}
// 首尾帧模式:上传 lastFrame
if (item.url && item.lastFrame && !item.lastFrameUrl) {
const lastPath = path.resolve(dir, item.lastFrame)
try {
const { url } = await uploadFile(lastPath)
item.lastFrameUrl = url
log('upload', ` [${i + 1}/${imageItems.length}] lastFrame → OK`)
} catch (err) {
log('upload', ` [${i + 1}/${imageItems.length}] lastFrame 上传失败: ${err.message}`)
}
}
saveManifest(manifestPath, manifest)
}
}
// 上传视频
if (videoItems.length > 0) {
log('upload', `视频: ${videoItems.length}`)
for (let i = 0; i < videoItems.length; i++) {
const item = videoItems[i]
const videoPath = path.resolve(dir, item.video)
try {
const { url } = await uploadFile(videoPath)
item.videoUrl = url
log('upload', ` [${i + 1}/${videoItems.length}] ${item.video} → OK`)
} catch (err) {
log('upload', ` [${i + 1}/${videoItems.length}] 失败: ${err.message}`)
}
saveManifest(manifestPath, manifest)
}
saveManifest(manifestPath, manifest)
}
}