feat(video-from-script): 重构工作流为子Agent分步执行并新增提示词模板系统

将视频制作工作流拆分为独立子步骤:分镜 → 图片提示词 → 生图 → 视频提示词 → 生视频 → 成片,每步由子Agent独立执行。引入prompts/目录统一管理提示词模板(分镜.md、图片提示词.md、视频提示词.md),通过account.json的storyboardPrompt/imageStylePrompt/videoStylePrompt字段引用。

变更内容:
- 新增confirmed机制和pipeline.js confirm命令,生图后必须人工确认才能继续
- manifest schema改用shotDesc/narration/duration/directorRef替代旧字段
- 文件命名规则从keyword改为slug(从shotDesc/narration派生)
- 删除旧的storyboard-rules.md和prompt-rules.md
- pipeline.js脚本拆分为lib/目录下的独立模块(cmd-init/cmd-confirm/cmd-validate/phase-*)
- 新增cmd-create-account支持一键创建带prompts目录的账号
- capcut_assemble支持narration字段替代text作为字幕源
- 新增.gitclaude/settings.json权限配置
This commit is contained in:
2026-04-30 21:18:31 +08:00
parent 7f955647fe
commit 86b9b7948d
32 changed files with 2826 additions and 1292 deletions

View File

@@ -0,0 +1,36 @@
/**
* Command: confirm — 人工确认分镜图
*/
const { loadManifest, saveManifest } = require('./pipeline-utils')
function confirmManifest(options) {
const { manifest: manifestPath, all } = options
if (!manifestPath) {
console.error('用法: pipeline.js confirm --manifest <path> --all')
process.exit(1)
}
if (!all) {
console.error('错误: 必须指定 --all')
process.exit(1)
}
const manifest = loadManifest(manifestPath)
let count = 0
for (const item of manifest.items) {
if (item.file && item.status === 'done' && !item.confirmed) {
item.confirmed = true
count++
}
}
saveManifest(manifestPath, manifest)
const total = manifest.items.length
const confirmed = manifest.items.filter(it => it.confirmed).length
console.log(`已确认: ${count} items${confirmed}/${total} 已确认)`)
}
module.exports = { confirmManifest }

View File

@@ -0,0 +1,138 @@
/**
* Command: create-account — 一键创建账号
*
* 创建目录结构 → 复制参考图 → 上传 OSS → 生成 account.json + 风格骨架
*/
const fs = require('fs')
const path = require('path')
const { ensureDir, log, ACCOUNTS_DIR } = require('./pipeline-utils')
async function createAccount(args) {
const { id, name, desc, format, imageModel, videoModel, references } = args
if (!id) { console.error('错误: 必须指定 --id <账号ID>'); process.exit(1) }
if (!/^[a-z0-9_-]+$/.test(id)) { console.error('错误: id 只允许小写字母/数字/短横线/下划线'); process.exit(1) }
if (!name) { console.error('错误: 必须指定 --name <账号名>'); process.exit(1) }
const accountDir = path.join(ACCOUNTS_DIR, id)
if (fs.existsSync(accountDir)) { console.error(`错误: 账号已存在: ${accountDir}`); process.exit(1) }
ensureDir(accountDir)
ensureDir(path.join(accountDir, 'prompts'))
ensureDir(path.join(accountDir, 'references'))
ensureDir(path.join(accountDir, 'styles'))
// 复制参考图到 references/ 并上传 OSS
const refs = (references || '').split(',').filter(Boolean)
const uploadedRefs = []
if (refs.length > 0) {
const { uploadFile } = require('../oss-upload')
for (const refPath of refs) {
const absPath = path.resolve(refPath)
if (!fs.existsSync(absPath)) { console.error(`参考图不存在: ${absPath}`); continue }
const fileName = path.basename(absPath)
const destPath = path.join(accountDir, 'references', fileName)
fs.copyFileSync(absPath, destPath)
try {
const { url } = await uploadFile(destPath)
uploadedRefs.push({ file: fileName, url })
log('account', `参考图 ${fileName} → OK`)
} catch (err) {
uploadedRefs.push({ file: fileName })
log('account', `参考图 ${fileName} 上传失败: ${err.message}(仅保存本地)`)
}
}
}
// 生成 account.json
const styleName = args.style || id
const accountConfig = {
id,
name,
description: desc || '',
defaultFormat: format || '9:16',
imageModel: imageModel || 'gemini',
videoModel: videoModel || '',
batchSize: 30,
storyboardPrompt: 'prompts/分镜.md',
imageStylePrompt: 'prompts/图片提示词.md',
videoStylePrompt: 'prompts/视频提示词.md',
capcut: {
effects: [],
filter: '',
subtitleStyle: {
fontSize: 36,
color: '#FFFFFF',
highlightColor: '#FF6B35',
bold: true,
},
defaultBGM: '',
},
}
if (uploadedRefs.length > 0) {
accountConfig.styles = {
[styleName]: { references: uploadedRefs },
}
}
const accountPath = path.join(accountDir, 'account.json')
fs.writeFileSync(accountPath, JSON.stringify(accountConfig, null, 2), 'utf-8')
// 生成默认风格骨架
const stylePath = path.join(accountDir, 'styles', `${styleName}.md`)
const styleContent = [
`# ${styleName}`,
'',
`${desc || name} 的视觉风格。`,
'',
'---',
'',
'## 图片提示词',
'',
'### 核心视觉要素',
'',
'(待填充:描述关键视觉元素)',
'',
'### 色调方案',
'',
'(待填充)',
'',
'### 图片 Prompt 模板',
'',
'(待填充)',
'',
'### 图片禁止项',
'',
'- 文字水印',
'- 字幕覆盖',
'',
'---',
'',
'## 视频提示词',
'',
'### 运镜规则',
'',
'(待填充)',
'',
'### 视频 Prompt 模板',
'',
'(待填充)',
'',
].join('\n')
fs.writeFileSync(stylePath, styleContent, 'utf-8')
console.log(`\n账号已创建: ${accountDir}`)
console.log(` ID: ${id}`)
console.log(` 名称: ${name}`)
console.log(` 模型: ${accountConfig.imageModel} + ${accountConfig.videoModel || '(未指定)'}`)
console.log(` 参考图: ${uploadedRefs.length} 张(${uploadedRefs.filter(r => r.url).length} 已上传)`)
console.log(` 风格: ${styleName}`)
console.log(`\n下一步: 编辑 ${stylePath} 完善提示词策略\n`)
return accountPath
}
module.exports = { createAccount }

View File

@@ -0,0 +1,138 @@
/**
* Command: init — 从 account.json + AI 创意内容生成规范 manifest.json
*
* 校验 items → 继承账号配置 → 创建输出目录 → 写出 manifest
*/
const fs = require('fs')
const path = require('path')
const { loadAccountConfig, saveManifest, ensureDir, ACCOUNTS_DIR, SKILLS_DIR } = require('./pipeline-utils')
function initManifest(options) {
const { account: accountId, mode, items: itemsJson, itemsFile } = options
if (!accountId) {
console.error('错误: 必须指定 --account <账号ID>')
process.exit(1)
}
const accountConfig = loadAccountConfig(accountId)
// 解析 items
let rawItems
if (itemsFile) {
const filePath = path.resolve(itemsFile)
if (!fs.existsSync(filePath)) {
console.error(`错误: items 文件不存在: ${filePath}`)
process.exit(1)
}
rawItems = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
} else if (itemsJson) {
rawItems = JSON.parse(itemsJson)
} else {
console.error('错误: 必须指定 --items <JSON> 或 --items-file <path>')
process.exit(1)
}
if (!Array.isArray(rawItems) || rawItems.length === 0) {
console.error('错误: items 必须是非空数组')
process.exit(1)
}
// 校验必填字段
const requiredFields = ['shotDesc', 'narration', 'imagePrompt']
const resolvedMode = mode || 'single'
for (let i = 0; i < rawItems.length; i++) {
const item = rawItems[i]
for (const f of requiredFields) {
if (!item[f]) {
console.error(`错误: items[${i}] 缺少必填字段 "${f}"`)
process.exit(1)
}
}
if (resolvedMode === 'framePair' && !item.lastFramePrompt) {
console.error(`错误: 首尾帧模式 items[${i}] 缺少 "lastFramePrompt"imagePrompt 作为第一帧)`)
process.exit(1)
}
}
// 从 account.json 继承参考图
const styles = accountConfig.styles || {}
const firstStyleKey = Object.keys(styles)[0]
const styleRefs = firstStyleKey ? (styles[firstStyleKey].references || []) : []
const references = styleRefs.map(ref => {
const entry = {}
if (ref.file) entry.file = path.join(ACCOUNTS_DIR, accountId, 'references', ref.file)
if (ref.url) entry.url = ref.url
return entry
})
// 构建 items
const items = rawItems.map((raw, i) => {
const item = {
id: i + 1,
status: 'pending',
shotDesc: raw.shotDesc || '',
narration: raw.narration || raw.text || '',
duration: raw.duration || 5,
imagePrompt: raw.imagePrompt,
confirmed: false,
}
if (raw.directorRef) item.directorRef = raw.directorRef
if (raw.videoPrompt) item.videoPrompt = raw.videoPrompt
if (resolvedMode === 'framePair') item.lastFramePrompt = raw.lastFramePrompt
return item
})
// 组装 manifest
const manifest = {
account: accountId,
imageModel: accountConfig.imageModel || 'gemini',
videoModel: accountConfig.videoModel || 'veo3-fast-frames',
format: accountConfig.defaultFormat || '9:16',
mode: resolvedMode,
references,
items,
}
// 创建输出目录(自增序号)
const date = new Date()
const dateStr = [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'),
].join('')
const prefix = `${accountConfig.name}_${dateStr}`
const outputBase = path.join(SKILLS_DIR, '..', '..', '..', 'output')
ensureDir(outputBase)
let seq = 1
while (fs.existsSync(path.join(outputBase, `${prefix}_${String(seq).padStart(3, '0')}`))) {
seq++
}
const dirName = `${prefix}_${String(seq).padStart(3, '0')}`
const outputDir = path.join(outputBase, dirName)
ensureDir(outputDir)
ensureDir(path.join(outputDir, 'images'))
ensureDir(path.join(outputDir, 'videos'))
ensureDir(path.join(outputDir, 'audio'))
const manifestPath = path.join(outputDir, 'manifest.json')
saveManifest(manifestPath, manifest)
console.log(`\nManifest 已创建: ${manifestPath}`)
console.log(` 账号: ${accountId} (${accountConfig.name})`)
console.log(` 模型: ${manifest.imageModel} + ${manifest.videoModel}`)
console.log(` 画幅: ${manifest.format}, 模式: ${manifest.mode}`)
console.log(` Items: ${items.length}`)
console.log(` 参考图: ${references.length}`)
if (items.some(it => !it.videoPrompt)) {
console.log(`${items.filter(it => !it.videoPrompt).length} 个 item 缺少 videoPrompt生视频阶段将跳过`)
}
console.log()
return manifestPath
}
module.exports = { initManifest }

View File

@@ -0,0 +1,38 @@
/**
* Command: status — 显示 manifest 执行状态
*/
const { loadManifest } = require('./pipeline-utils')
const ALL_PHASES = ['images', 'upload', 'videos', 'tts', 'assemble']
function showStatus(manifestPath) {
const manifest = loadManifest(manifestPath)
const phases = manifest.pipeline?.phases || {}
console.log(`\nManifest: ${manifestPath}`)
console.log(`Account: ${manifest.account || '(未指定)'}`)
console.log(`\n阶段状态:`)
for (const p of ALL_PHASES) {
const status = phases[p] || 'pending'
const icon = status === 'done' ? '✓' : status === 'running' ? '→' : status === 'failed' ? '✗' : status === 'partial' ? '~' : '·'
console.log(` ${icon} ${p}: ${status}`)
}
const items = manifest.items || []
const done = items.filter(it => it.status === 'done').length
const failed = items.filter(it => it.status === 'failed').length
const pending = items.filter(it => !it.status || it.status === 'pending').length
console.log(`\nItems: ${items.length} 总计, ${done} 完成, ${failed} 失败, ${pending} 待处理`)
if (failed > 0) {
console.log(`\n失败项:`)
items.filter(it => it.status === 'failed').forEach((it, i) => {
console.log(` [${it.id || i + 1}] ${it.error || '未知错误'}`)
})
}
console.log()
}
module.exports = { showStatus }

View File

@@ -0,0 +1,110 @@
/**
* Command: validate — 校验 manifest.json 完整性
* Command: validate-account — 校验账号目录完整性
*/
const fs = require('fs')
const path = require('path')
const { loadManifest, ACCOUNTS_DIR } = require('./pipeline-utils')
function validateManifest(manifestPath) {
const issues = []
if (!fs.existsSync(manifestPath)) {
console.error(`错误: manifest 不存在: ${manifestPath}`)
process.exit(1)
}
let manifest
try {
manifest = loadManifest(manifestPath)
} catch (e) {
console.error(`错误: JSON 解析失败: ${e.message}`)
process.exit(1)
}
if (!manifest.account) issues.push('缺少顶层 account')
if (!manifest.imageModel) issues.push('缺少顶层 imageModel可选: gemini, mj')
if (!manifest.format) issues.push('缺少顶层 format如 9:16')
if (!manifest.items || !Array.isArray(manifest.items)) issues.push('缺少顶层 items 数组')
if (!manifest.mode) issues.push('缺少顶层 modesingle 或 framePair')
if (manifest.items && Array.isArray(manifest.items)) {
manifest.items.forEach((item, i) => {
const prefix = `items[${i}]`
if (!item.narration && !item.text) issues.push(`${prefix} 缺少 narration 或 text中文旁白`)
if (!item.shotDesc) issues.push(`${prefix} 缺少 shotDesc分镜描述`)
if (!item.imagePrompt) issues.push(`${prefix} 缺少 imagePrompt`)
if (manifest.mode === 'framePair' && !item.lastFramePrompt) {
issues.push(`${prefix} 首尾帧模式缺少 lastFramePromptimagePrompt 作为第一帧)`)
}
if (item.status && !['pending', 'generating', 'done', 'failed'].includes(item.status)) {
issues.push(`${prefix} status 无效: ${item.status}`)
}
})
}
if (issues.length === 0) {
console.log(`✓ Manifest 校验通过: ${manifestPath}`)
console.log(` ${manifest.items?.length || 0} items, account=${manifest.account}, mode=${manifest.mode}`)
} else {
console.error(`✗ 发现 ${issues.length} 个问题:`)
issues.forEach(issue => console.error(` - ${issue}`))
process.exit(1)
}
}
function validateAccount(accountId) {
const issues = []
const accountDir = path.join(ACCOUNTS_DIR, accountId)
if (!fs.existsSync(accountDir)) { console.error(`错误: 账号不存在: ${accountDir}`); process.exit(1) }
const accountPath = path.join(accountDir, 'account.json')
if (!fs.existsSync(accountPath)) { console.error('错误: 缺少 account.json'); process.exit(1) }
let config
try { config = JSON.parse(fs.readFileSync(accountPath, 'utf-8')) }
catch (e) { console.error(`错误: JSON 解析失败: ${e.message}`); process.exit(1) }
if (config.id !== accountId) issues.push(`id 不匹配: json="${config.id}" vs 目录="${accountId}"`)
if (!config.name) issues.push('缺少 name')
if (!config.imageModel) issues.push('缺少 imageModel')
if (!config.defaultFormat) issues.push('缺少 defaultFormat')
const refDir = path.join(accountDir, 'references')
const styles = config.styles || {}
const hasStyleRefs = Object.values(styles).some(s => s.references && s.references.length > 0)
const localRefs = fs.existsSync(refDir)
? fs.readdirSync(refDir).filter(f => /\.(png|jpg|jpeg|webp)$/i.test(f))
: []
if (localRefs.length === 0 && !hasStyleRefs) {
issues.push('无参考图(建议至少 1 张)')
}
const stylesDir = path.join(accountDir, 'styles')
const styleFiles = fs.existsSync(stylesDir)
? fs.readdirSync(stylesDir).filter(f => f.endsWith('.md'))
: []
if (styleFiles.length === 0) {
issues.push('无风格文件styles/ 下至少 1 个 .md')
}
for (const [sName, sConf] of Object.entries(styles)) {
for (const ref of (sConf.references || [])) {
if (!ref.url) issues.push(`styles.${sName}: 参考图 ${ref.file} 缺少 url未上传 OSS`)
}
}
if (issues.length === 0) {
console.log(`✓ 账号校验通过: ${accountId}`)
console.log(` ${config.name}, 模型: ${config.imageModel}+${config.videoModel || '(未指定)'}`)
console.log(` 参考图: ${localRefs.length} 本地, 风格: ${styleFiles.length}`)
} else {
console.error(`✗ 发现 ${issues.length} 个问题:`)
issues.forEach(i => console.error(` - ${i}`))
process.exit(1)
}
}
module.exports = { validateManifest, validateAccount }

View File

@@ -0,0 +1,41 @@
/**
* Phase: assemble — CapCut 成片组装
*
* 图片/视频 + TTS → 剪映草稿
*/
const { log, getManifestDir } = require('./pipeline-utils')
async function phaseAssemble(manifest, manifestPath, options) {
const dir = getManifestDir(manifestPath)
const accountConfig = options.accountConfig || {}
const capcutConfig = accountConfig.capcut || {}
const videoItems = manifest.items.filter(it => it.video && it.status === 'done')
const hasVideos = videoItems.length > 0
const mode = hasVideos ? 'videos' : 'images'
const assembleArgs = {
input: dir,
manifest: manifestPath,
mode,
format: manifest.format || accountConfig.defaultFormat || '9:16',
subtitles: mode === 'images' ? 'true' : 'false',
voiceover: manifest.items.some(it => it.audio) ? 'true' : 'false',
duration: '4',
animation: 'kenburns-zoom',
}
if (capcutConfig.defaultBGM) assembleArgs.bgm = capcutConfig.defaultBGM
if (capcutConfig.effects) assembleArgs.effects = capcutConfig.effects.join(',')
if (capcutConfig.filter) assembleArgs.filter = capcutConfig.filter
log('assemble', `模式: ${mode}, 字幕: true, 配音: ${assembleArgs.voiceover}`)
const { assemble } = require('../capcut_assemble')
await assemble(assembleArgs)
log('assemble', '成片完成')
}
module.exports = { phaseAssemble }

View File

@@ -0,0 +1,173 @@
/**
* Phase: images — 图片生成
*
* 支持 Gemini / MJ / Kling 三种模型,含首尾帧模式
*/
const path = require('path')
const { saveManifest, getReferences, ensureDir, renameGeneratedFile, log, getManifestDir } = require('./pipeline-utils')
async function phaseImages(manifest, manifestPath, options) {
const dir = getManifestDir(manifestPath)
const imagesDir = path.join(dir, 'images')
ensureDir(imagesDir)
const items = manifest.items.filter(it =>
(!it.status || it.status === 'pending' || it.status === 'generating') && it.imagePrompt
)
if (items.length === 0) { log('images', '无待处理 item跳过'); return }
const accountConfig = options.accountConfig || {}
let model = options.imageModel || manifest.imageModel || accountConfig.imageModel || 'gemini'
const ratio = manifest.format || accountConfig.defaultFormat || '9:16'
// 首尾帧模式MJ 降级为 GeminiMJ 出4张候选图无法一一对应首尾帧
if (model === 'mj' && manifest.mode === 'framePair') {
log('images', '首尾帧模式不支持 MJ自动降级为 Gemini')
model = 'gemini'
}
const refs = getReferences(manifest, accountConfig)
log('images', `${items.length} 张, 模型: ${model}, 画幅: ${ratio}, 参考图: ${refs.localPaths.length}本地/${refs.urls.length}URL`)
for (let i = 0; i < items.length; i++) {
const item = items[i]
const idx = i + 1
try {
item.status = 'generating'
saveManifest(manifestPath, manifest)
let result
if (model === 'gemini') {
const { generate: geminiGen, edit: geminiEdit } = require('../gemini-image-generator')
if (refs.localPaths.length > 0) {
log('images', `[${idx}/${items.length}] Gemini 图生图: ${item.imagePrompt.substring(0, 60)}...`)
result = await geminiEdit(item.imagePrompt, refs.localPaths, {
outputDir: imagesDir,
aspectRatio: ratio,
})
} else {
log('images', `[${idx}/${items.length}] Gemini 文生图: ${item.imagePrompt.substring(0, 60)}...`)
result = await geminiGen(item.imagePrompt, {
outputDir: imagesDir,
aspectRatio: ratio,
})
}
if (result.savedFiles && result.savedFiles.length > 0) {
item.file = renameGeneratedFile(
path.relative(dir, result.savedFiles[0]).replace(/\\/g, '/'),
dir, idx, item.narration || item.shotDesc, ''
)
}
} else if (model === 'mj') {
const { generate: mjGen } = require('../mj-image-generator')
const mjOpts = { outputDir: imagesDir, aspectRatio: ratio, split: true }
if (refs.urls.length > 0) {
mjOpts.referenceImages = refs.urls
mjOpts.styleWeight = 200
}
log('images', `[${idx}/${items.length}] MJ 生图: ${item.imagePrompt.substring(0, 60)}...`)
result = await mjGen(item.imagePrompt, mjOpts)
if (result.files && result.files.length > 0) {
item.candidates = result.files.map((f, ci) =>
renameGeneratedFile(
path.relative(dir, f).replace(/\\/g, '/'),
dir, idx, item.narration || item.shotDesc, `cand${ci + 1}`
)
)
item.file = item.candidates[0]
log('images', `[${idx}/${items.length}] ${result.files.length} 张候选默认选第1张`)
}
} else if (model === 'kling') {
const { generate: klingGen } = require('../kling-image-generator')
const klingOpts = { outputDir: imagesDir, aspectRatio: ratio }
if (refs.urls.length > 0) {
klingOpts.styleImageUrl = refs.urls[0]
}
log('images', `[${idx}/${items.length}] 可灵生图: ${item.imagePrompt.substring(0, 60)}...`)
result = await klingGen(item.imagePrompt, klingOpts)
if (result.savedFiles && result.savedFiles.length > 0) {
item.file = renameGeneratedFile(
path.relative(dir, result.savedFiles[0]).replace(/\\/g, '/'),
dir, idx, item.narration || item.shotDesc, ''
)
}
} else {
throw new Error(`不支持的模型: ${model}(支持: gemini, mj, kling`)
}
if (item.file) {
item.status = 'done'
log('images', `[${idx}/${items.length}] 完成: ${item.file}`)
} else {
item.status = 'failed'
item.error = '生成器未返回文件'
log('images', `[${idx}/${items.length}] 失败: 生成器未返回文件`)
}
// 首尾帧模式生成第二张图lastFrame
if (item.status === 'done' && manifest.mode === 'framePair' && item.lastFramePrompt && !item.lastFrame) {
await generateLastFrame(item, idx, items.length, manifest, dir, imagesDir, model, ratio, manifestPath)
}
} catch (err) {
item.status = 'failed'
item.error = err.message
log('images', `[${idx}/${items.length}] 失败: ${err.message}`)
}
saveManifest(manifestPath, manifest)
}
}
async function generateLastFrame(item, idx, total, manifest, dir, imagesDir, model, ratio, manifestPath) {
try {
item.status = 'generating'
saveManifest(manifestPath, manifest)
const firstFramePath = path.resolve(dir, item.file)
let lastResult
if (model === 'gemini') {
const { edit: geminiEdit } = require('../gemini-image-generator')
lastResult = await geminiEdit(item.lastFramePrompt, [firstFramePath], {
outputDir: imagesDir,
aspectRatio: ratio,
})
} else if (model === 'mj') {
const { generate: mjGen } = require('../mj-image-generator')
const mjOpts = { outputDir: imagesDir, aspectRatio: ratio, split: false }
if (item.url) {
mjOpts.referenceImages = [item.url]
mjOpts.styleWeight = 200
}
lastResult = await mjGen(item.lastFramePrompt, mjOpts)
} else if (model === 'kling') {
const { generate: klingGen } = require('../kling-image-generator')
lastResult = await klingGen(item.lastFramePrompt, {
outputDir: imagesDir,
styleImageUrl: item.url || '',
aspectRatio: ratio,
})
}
if (lastResult) {
const files = lastResult.savedFiles || lastResult.files || []
if (files.length > 0) {
item.lastFrame = renameGeneratedFile(
path.relative(dir, files[0]).replace(/\\/g, '/'),
dir, idx, item.narration || item.shotDesc, 'last'
)
item.status = 'done'
log('images', `[${idx}/${total}] lastFrame 完成: ${item.lastFrame}`)
} else {
item.status = 'failed'
item.error = 'lastFrame 生成器未返回文件'
log('images', `[${idx}/${total}] lastFrame 失败: 未返回文件`)
}
}
} catch (err) {
item.status = 'failed'
item.error = `lastFrame 失败: ${err.message}`
log('images', `[${idx}/${total}] lastFrame 失败: ${err.message}`)
}
}
module.exports = { phaseImages }

View File

@@ -0,0 +1,44 @@
/**
* Phase: tts — 语音合成
*
* 使用通义千问 TTS 生成旁白音频
*/
const path = require('path')
const { saveManifest, ensureDir, log, getManifestDir } = require('./pipeline-utils')
async function phaseTts(manifest, manifestPath) {
const dir = getManifestDir(manifestPath)
const audioDir = path.join(dir, 'audio')
ensureDir(audioDir)
const { synthesize } = require('../qwen-tts')
const items = manifest.items.filter(it =>
it.status === 'done' && (it.narration || it.text) && !it.audio
)
if (items.length === 0) { log('tts', '无待处理 item跳过'); return }
log('tts', `${items.length}`)
for (let i = 0; i < items.length; i++) {
const item = items[i]
const idx = i + 1
try {
const { filePath, duration } = await synthesize(item.narration || item.text, {
outputDir: audioDir,
id: item.id || idx,
})
item.audio = path.relative(dir, filePath).replace(/\\/g, '/')
item.audioDuration = Math.round(duration * 1000) / 1000
log('tts', `[${idx}/${items.length}] ${duration.toFixed(1)}s: ${(item.narration || item.text).substring(0, 30)}...`)
} catch (err) {
item.status = 'failed'
item.error = `TTS失败: ${err.message}`
log('tts', `[${idx}/${items.length}] 失败: ${err.message}`)
}
saveManifest(manifestPath, manifest)
}
}
module.exports = { phaseTts }

View File

@@ -0,0 +1,46 @@
/**
* Phase: upload — OSS 上传
*
* 将生成的图片(含首尾帧)上传到 OSS回写 url
*/
const path = require('path')
const { saveManifest, log, getManifestDir } = require('./pipeline-utils')
async function phaseUpload(manifest, manifestPath) {
const dir = getManifestDir(manifestPath)
const { uploadFile } = require('../oss-upload')
const items = manifest.items.filter(it =>
it.status === 'done' && it.file && !it.url
)
if (items.length === 0) { log('upload', '无待上传 item跳过'); return }
log('upload', `${items.length} 个文件`)
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)
try {
const { url } = await uploadFile(lastPath)
item.lastFrameUrl = url
log('upload', `[${i + 1}/${items.length}] lastFrame → OK`)
} catch (err) {
log('upload', `[${i + 1}/${items.length}] lastFrame 上传失败: ${err.message}`)
}
}
saveManifest(manifestPath, manifest)
}
}
module.exports = { phaseUpload }

View File

@@ -0,0 +1,104 @@
/**
* Phase: videos — 视频生成VEO / Grok / Kling
*
* 图生视频,批量提交,生成后自动上传 OSS
*/
const path = require('path')
const { saveManifest, ensureDir, log, getManifestDir } = require('./pipeline-utils')
async function phaseVideos(manifest, manifestPath, options) {
const dir = getManifestDir(manifestPath)
const videosDir = path.join(dir, 'videos')
ensureDir(videosDir)
const accountConfig = options.accountConfig || {}
const videoModel = manifest.videoModel || accountConfig.videoModel || 'veo3-fast-frames'
const items = manifest.items.filter(it =>
it.status === 'done' && it.confirmed !== false && it.url && it.videoPrompt && !it.video
)
if (items.length === 0) { log('videos', '无待处理 item跳过'); return }
// 选择生成器
let generator
const modelLower = videoModel.toLowerCase()
if (modelLower.includes('grok')) {
generator = require('../grok-video-generator')
} else if (modelLower.includes('kling')) {
generator = require('../kling-video-generator')
} else {
generator = require('../veo-video-generator')
}
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
} else {
task.images = [item.url]
}
return task
})
try {
const results = await generator.batchGenerate(tasks, {
videoModel,
aspectRatio: manifest.format || '9:16',
outputDir: videosDir,
skipManifestWrite: true,
})
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}`
}
}
}
// 上传视频到 OSS
const { uploadFile } = require('../oss-upload')
const videoItems = manifest.items.filter(it => it.video && !it.videoUrl)
if (videoItems.length > 0) {
log('videos', `上传 ${videoItems.length} 个视频到 OSS...`)
for (const item of videoItems) {
const videoPath = path.resolve(dir, item.video)
try {
const { url } = await uploadFile(videoPath)
item.videoUrl = url
log('videos', ` ${item.video} → OK`)
} catch (err) {
log('videos', ` ${item.video} 上传失败: ${err.message}`)
}
saveManifest(manifestPath, manifest)
}
}
saveManifest(manifestPath, manifest)
}
module.exports = { phaseVideos }

View File

@@ -0,0 +1,170 @@
/**
* Pipeline 共享工具函数与路径常量
*
* 所有 phase/command 模块共用:配置加载、文件操作、路径计算、日志
*/
const fs = require('fs')
const path = require('path')
// 路径常量(基于 lib/ 的父目录 scripts/
const SCRIPTS_DIR = path.join(__dirname, '..')
const SKILLS_DIR = path.join(SCRIPTS_DIR, '..')
const PROJECT_ROOT = path.join(SKILLS_DIR, '..', '..')
const CONFIG_PATH = path.join(SKILLS_DIR, 'config.json')
const ACCOUNTS_DIR = path.join(PROJECT_ROOT, 'accounts')
// ============================================================================
// 配置 & Manifest
// ============================================================================
function loadConfig() {
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'))
}
function loadManifest(manifestPath) {
return JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
}
function saveManifest(manifestPath, manifest) {
const tmp = manifestPath + '.tmp'
fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2), 'utf-8')
fs.renameSync(tmp, manifestPath)
}
function loadAccountConfig(accountId) {
const accountPath = path.join(ACCOUNTS_DIR, accountId, 'account.json')
if (!fs.existsSync(accountPath)) throw new Error(`账号不存在: ${accountPath}`)
return JSON.parse(fs.readFileSync(accountPath, 'utf-8'))
}
// ============================================================================
// 参考图解析
// ============================================================================
function getReferences(manifest, accountConfig) {
const result = { localPaths: [], urls: [] }
const accountId = accountConfig.id || manifest.account || ''
// 优先读 manifest.referencesagent 创建时写入)
const refs = manifest.references || []
if (refs.length > 0) {
for (const ref of refs) {
if (ref.url) result.urls.push(ref.url)
if (ref.file) {
const localPath = path.isAbsolute(ref.file) ? ref.file : path.resolve(ref.file)
if (fs.existsSync(localPath)) {
result.localPaths.push(localPath)
} else {
log('images', `参考图不存在: ${ref.file}`)
}
}
}
if (result.localPaths.length > 0 || result.urls.length > 0) return result
log('images', 'manifest.references 全部无效,尝试 account fallback')
}
// Fallback 1: 从 account.json 的 styles.*.references 读取
const styles = accountConfig.styles || {}
for (const [, style] of Object.entries(styles)) {
for (const ref of (style.references || [])) {
if (ref.url) result.urls.push(ref.url)
if (ref.file && accountId) {
const localPath = path.join(ACCOUNTS_DIR, accountId, 'references', ref.file)
if (fs.existsSync(localPath)) {
result.localPaths.push(localPath)
}
}
}
}
if (result.localPaths.length > 0 || result.urls.length > 0) return result
// Fallback 2: 扫描 account 的 references 目录
if (accountId) {
const refDir = path.join(ACCOUNTS_DIR, accountId, 'references')
if (fs.existsSync(refDir)) {
const files = fs.readdirSync(refDir).filter(f =>
/\.(png|jpg|jpeg|webp)$/i.test(f)
)
for (const f of files) {
result.localPaths.push(path.join(refDir, f))
}
if (files.length > 0) {
log('images', `从 references 目录兜底扫描到 ${files.length} 个参考图`)
}
}
}
if (result.localPaths.length === 0 && result.urls.length === 0) {
log('images', '无参考图,将使用纯文生图模式')
}
return result
}
// ============================================================================
// 文件操作
// ============================================================================
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true })
}
function slugify(text) {
return text
.replace(/[^\w一-鿿]/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '')
.substring(0, 20)
}
function renameGeneratedFile(oldRelPath, dir, seq, nameHint, suffix) {
if (!oldRelPath) return oldRelPath
const oldAbs = path.resolve(dir, oldRelPath)
if (!fs.existsSync(oldAbs)) return oldRelPath
const ext = path.extname(oldAbs)
const slug = nameHint ? slugify(nameHint) : ''
const tag = suffix ? `_${suffix}` : ''
const newName = slug
? `scene_${String(seq).padStart(2, '0')}_${slug}${tag}${ext}`
: `scene_${String(seq).padStart(2, '0')}${tag}${ext}`
const newAbs = path.join(path.dirname(oldAbs), newName)
if (oldAbs !== newAbs) {
try { fs.renameSync(oldAbs, newAbs) } catch (_) { return oldRelPath }
}
return path.relative(dir, newAbs).replace(/\\/g, '/')
}
// ============================================================================
// 日志 & 路径
// ============================================================================
function log(phase, msg) {
console.log(`[${phase}] ${msg}`)
}
function getManifestDir(manifestPath) {
return path.dirname(path.resolve(manifestPath))
}
// ============================================================================
// Exports
// ============================================================================
module.exports = {
SCRIPTS_DIR,
SKILLS_DIR,
PROJECT_ROOT,
CONFIG_PATH,
ACCOUNTS_DIR,
loadConfig,
loadManifest,
saveManifest,
loadAccountConfig,
getReferences,
ensureDir,
slugify,
renameGeneratedFile,
log,
getManifestDir,
}