#!/usr/bin/env node /** * 统一视频生产流程编排器 * * 串联: 生图 → 上传 → 生视频 → TTS → 成片 * manifest.json 是唯一数据源,支持断点续跑 * * 用法: * node pipeline.js run --manifest ./output/batch/manifest.json --account military --resume * node pipeline.js run --manifest ./output/batch/manifest.json --phase upload,videos * node pipeline.js status --manifest ./output/batch/manifest.json */ const fs = require('fs') const path = require('path') const SCRIPTS_DIR = __dirname const SKILLS_DIR = path.join(SCRIPTS_DIR, '..') const CONFIG_PATH = path.join(SKILLS_DIR, 'config.json') const ACCOUNTS_DIR = path.join(SCRIPTS_DIR, '..', 'accounts') // ============================================================================ // 工具函数 // ============================================================================ 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.references(agent 创建时写入) 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, keyword, suffix) { if (!oldRelPath) return oldRelPath const oldAbs = path.resolve(dir, oldRelPath) if (!fs.existsSync(oldAbs)) return oldRelPath const ext = path.extname(oldAbs) const slug = keyword ? slugify(keyword) : '' 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)) } // ============================================================================ // 阶段: images // ============================================================================ 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 降级为 Gemini(MJ 出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.keyword, '' ) } } 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) // MJ 拆分后4张候选,全部存入 candidates,默认取第一张 if (result.files && result.files.length > 0) { item.candidates = result.files.map((f, ci) => renameGeneratedFile( path.relative(dir, f).replace(/\\/g, '/'), dir, idx, item.keyword, `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.keyword, '' ) } } 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) { try { item.status = 'generating' saveManifest(manifestPath, manifest) const firstFramePath = path.resolve(dir, item.file) let lastResult if (model === 'gemini') { const { generate: geminiGen, 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') // MJ: 尾帧上传首帧到 OSS 后用 URL 做 sref 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]).split(String.fromCharCode(92)).join(String.fromCharCode(47)), dir, idx, item.keyword, 'last' ) item.status = 'done' log('images', `[${idx}/${items.length}] lastFrame 完成: ${item.lastFrame}`) } else { item.status = 'failed' item.error = 'lastFrame 生成器未返回文件' log('images', `[${idx}/${items.length}] lastFrame 失败: 未返回文件`) } } } catch (err) { item.status = 'failed' item.error = `lastFrame 失败: ${err.message}` log('images', `[${idx}/${items.length}] lastFrame 失败: ${err.message}`) } } } catch (err) { item.status = 'failed' item.error = err.message log('images', `[${idx}/${items.length}] 失败: ${err.message}`) } saveManifest(manifestPath, manifest) } } // ============================================================================ // 阶段: upload (OSS) // ============================================================================ async function phaseUpload(manifest, manifestPath, options) { 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}`) } // 首尾帧模式:上传 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}/${items.length}] lastFrame → OK`) } catch (err) { log('upload', `[${i + 1}/${items.length}] lastFrame 上传失败: ${err.message}`) } } saveManifest(manifestPath, manifest) } } // ============================================================================ // 阶段: videos (VEO/Grok) // ============================================================================ 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.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, } // 单图 or 首尾帧 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, }) // 将结果写回 manifest(generators return results in same order as tasks) 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,回写 videoUrl 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) } // ============================================================================ // 阶段: tts // ============================================================================ async function phaseTts(manifest, manifestPath, options) { 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.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.text, { outputDir: audioDir, id: item.id || idx, }) item.audio = path.relative(dir, filePath).replace(/\\/g, '/') item.duration = Math.round(duration * 1000) / 1000 log('tts', `[${idx}/${items.length}] ${duration.toFixed(1)}s: ${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) } } // ============================================================================ // 阶段: assemble (CapCut) // ============================================================================ async function phaseAssemble(manifest, manifestPath, options) { const dir = getManifestDir(manifestPath) const accountConfig = options.accountConfig || {} const capcutConfig = accountConfig.capcut || {} // 判断模式: 有 video 字段 → videos 模式(仅限成功的 item) const videoItems = manifest.items.filter(it => it.video && it.status === 'done') const hasVideos = videoItems.length > 0 const mode = hasVideos ? 'videos' : 'images' // 构造 capcut_assemble 参数 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}`) // 调用 capcut_assemble const { assemble } = require('./capcut_assemble') await assemble(assembleArgs) log('assemble', '成片完成') } // ============================================================================ // Pipeline 编排 // ============================================================================ const ALL_PHASES = ['images', 'upload', 'videos', 'tts', 'assemble'] async function runPipeline(manifestPath, options) { const manifest = loadManifest(manifestPath) const dir = getManifestDir(manifestPath) // 加载账号配置 const accountId = manifest.account || options.account let accountConfig = {} if (accountId) { accountConfig = loadAccountConfig(accountId) log('pipeline', `账号: ${accountConfig.name || accountId}`) } // 确定要跑的阶段 const phases = options.phases || ALL_PHASES // 确保 pipeline 状态字段存在 if (!manifest.pipeline) { manifest.pipeline = { phases: {} } } // --retry-failed: 重置失败 item 状态,允许重新处理 if (options.retryFailed) { let resetCount = 0 for (const item of manifest.items) { if (item.status === 'failed' || item.status === 'partial') { // 根据 item 是否有 url 判断该重置到哪个阶段 if (item.url && item.videoPrompt && !item.video) { // 图片已生成、有视频提示词、但没视频 → 重置为可生视频 item.status = 'done' item.error = '' resetCount++ } else if (!item.url && item.imagePrompt) { // 没有图片但有提示词 → 重置为可生图 item.status = 'pending' item.error = '' resetCount++ } } } // 重置对应阶段状态 if (phases.includes('videos')) { const hasVideoItems = manifest.items.some(it => it.status === 'done' && it.url && it.videoPrompt && !it.video) if (hasVideoItems) manifest.pipeline.phases.videos = 'pending' } if (phases.includes('images')) { const hasImageItems = manifest.items.some(it => !it.status || it.status === 'pending') if (hasImageItems) manifest.pipeline.phases.images = 'pending' } if (phases.includes('upload')) { manifest.pipeline.phases.upload = 'pending' } if (phases.includes('tts')) { manifest.pipeline.phases.tts = 'pending' } if (resetCount > 0) { log('pipeline', `重置 ${resetCount} 个失败 item (--retry-failed)`) saveManifest(manifestPath, manifest) } } log('pipeline', `阶段: ${phases.join(' → ')}`) const phaseHandlers = { images: phaseImages, upload: phaseUpload, videos: phaseVideos, tts: phaseTts, assemble: phaseAssemble, } for (const phase of phases) { const handler = phaseHandlers[phase] if (!handler) { log('pipeline', `未知阶段: ${phase}`); continue } // resume 模式下跳过已完成阶段 if (options.resume && manifest.pipeline.phases[phase] === 'done') { log(phase, '已完成,跳过 (--resume)') continue } manifest.pipeline.phases[phase] = 'running' saveManifest(manifestPath, manifest) try { await handler(manifest, manifestPath, { ...options, accountConfig }) // 检查是否所有 item 都完成 const failedCount = manifest.items.filter(it => it.status === 'failed').length if (failedCount > 0) { manifest.pipeline.phases[phase] = 'partial' log(phase, `完成 (${failedCount} 个失败)`) } else { manifest.pipeline.phases[phase] = 'done' log(phase, '完成') } } catch (err) { manifest.pipeline.phases[phase] = 'failed' log(phase, `阶段失败: ${err.message}`) saveManifest(manifestPath, manifest) throw err } saveManifest(manifestPath, manifest) } log('pipeline', '全部完成') } // ============================================================================ // status 命令 // ============================================================================ 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() } // ============================================================================ // 命令: init — 从 account.json + AI 创意内容生成规范 manifest.json // ============================================================================ function initManifest(options) { const { account: accountId, mode, items: itemsJson, itemsFile } = options if (!accountId) { console.error('错误: 必须指定 --account <账号ID>') process.exit(1) } // 1. 加载账号配置 const accountConfig = loadAccountConfig(accountId) // 2. 解析 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 或 --items-file ') process.exit(1) } if (!Array.isArray(rawItems) || rawItems.length === 0) { console.error('错误: items 必须是非空数组') process.exit(1) } // 3. 校验 items 必填字段 const requiredFields = ['text', '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) } } // 首尾帧模式:imagePrompt 是第一帧,lastFramePrompt 是第二帧 if (resolvedMode === 'framePair') { if (!item.lastFramePrompt) { console.error(`错误: 首尾帧模式 items[${i}] 缺少 "lastFramePrompt"(imagePrompt 作为第一帧)`) process.exit(1) } } } // 4. 构建 manifest — 从 account.json 继承结构字段 const styles = accountConfig.styles || {} const firstStyleKey = Object.keys(styles)[0] const styleRefs = firstStyleKey ? (styles[firstStyleKey].references || []) : [] // 将 account references 转为 manifest references 格式 const references = styleRefs.map(ref => { const entry = {} if (ref.file) { // 转为相对路径(相对于 manifest 所在目录) entry.file = path.join(ACCOUNTS_DIR, accountId, 'references', ref.file) } if (ref.url) entry.url = ref.url return entry }) // 5. 构建 items(AI 只提供创意字段,脚本补齐 status) const items = rawItems.map((raw, i) => { const item = { id: i + 1, status: 'pending', text: raw.text, imagePrompt: raw.imagePrompt, keyword: raw.keyword || '', keywordColor: raw.keywordColor || accountConfig.capcut?.subtitleStyle?.highlightColor || '#FF6B35', } if (raw.videoPrompt) item.videoPrompt = raw.videoPrompt if (resolvedMode === 'framePair') { // imagePrompt 就是第一帧,lastFramePrompt 是第二帧 item.lastFramePrompt = raw.lastFramePrompt } return item }) // 6. 组装 manifest const manifest = { account: accountId, imageModel: accountConfig.imageModel || 'gemini', videoModel: accountConfig.videoModel || 'veo3-fast-frames', format: accountConfig.defaultFormat || '9:16', mode: resolvedMode, references, items, } // 7. 创建输出目录(自增序号) 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')) // 8. 写出 manifest.json 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 } // ============================================================================ // 命令: validate — 校验已有 manifest.json 的完整性 // ============================================================================ 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('缺少顶层 mode(single 或 framePair)') // items 校验 if (manifest.items && Array.isArray(manifest.items)) { manifest.items.forEach((item, i) => { const prefix = `items[${i}]` if (!item.text) issues.push(`${prefix} 缺少 text(中文字幕)`) if (!item.imagePrompt) issues.push(`${prefix} 缺少 imagePrompt`) if (manifest.mode === 'framePair') { if (!item.lastFramePrompt) issues.push(`${prefix} 首尾帧模式缺少 lastFramePrompt(imagePrompt 作为第一帧)`) } 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) } } // ============================================================================ // 命令: create-account — 一键创建账号 // ============================================================================ 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, '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, 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 } // ============================================================================ // 命令: validate-account — 校验账号完整性 // ============================================================================ 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)') } // 参考图 URL 有效性 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) } } // ============================================================================ // CLI // ============================================================================ function parseArgs(argv) { const args = {} for (let i = 0; i < argv.length; i++) { if (argv[i] === '--manifest' && argv[i + 1]) args.manifest = argv[++i] else if (argv[i] === '--account' && argv[i + 1]) args.account = argv[++i] else if (argv[i] === '--phase' && argv[i + 1]) args.phases = argv[++i].split(',') else if (argv[i] === '--resume') args.resume = true else if (argv[i] === '--retry-failed') args.retryFailed = true else if (argv[i] === '--mode' && argv[i + 1]) args.mode = argv[++i] else if (argv[i] === '--items' && argv[i + 1]) args.items = argv[++i] else if (argv[i] === '--items-file' && argv[i + 1]) args.itemsFile = argv[++i] else if (argv[i] === '--id' && argv[i + 1]) args.id = argv[++i] else if (argv[i] === '--name' && argv[i + 1]) args.name = argv[++i] else if (argv[i] === '--desc' && argv[i + 1]) args.desc = argv[++i] else if (argv[i] === '--format' && argv[i + 1]) args.format = argv[++i] else if (argv[i] === '--image-model' && argv[i + 1]) args.imageModel = argv[++i] else if (argv[i] === '--video-model' && argv[i + 1]) args.videoModel = argv[++i] else if (argv[i] === '--references' && argv[i + 1]) args.references = argv[++i] else if (argv[i] === '--style' && argv[i + 1]) args.style = argv[++i] else if (!args.command) args.command = argv[i] } return args } async function main() { const args = parseArgs(process.argv.slice(2)) const command = args.command if (command === 'init') { initManifest(args) return } if (command === 'validate') { if (!args.manifest) { console.error('用法: pipeline.js validate --manifest '); process.exit(1) } validateManifest(args.manifest) return } if (command === 'status') { if (!args.manifest) { console.error('用法: pipeline.js status --manifest '); process.exit(1) } showStatus(args.manifest) return } if (command === 'run') { if (!args.manifest) { console.error('用法: pipeline.js run --manifest [--account id] [--phase p1,p2] [--resume] [--retry-failed]'); process.exit(1) } await runPipeline(args.manifest, args) return } if (command === 'create-account') { await createAccount(args) return } if (command === 'validate-account') { if (!args.account) { console.error('用法: pipeline.js validate-account --account '); process.exit(1) } validateAccount(args.account) return } console.log('用法:') console.log(' pipeline.js create-account --id --name <名称> [--desc ...] [--references file1,file2]') console.log(' pipeline.js validate-account --account ') console.log(' pipeline.js init --account --mode --items [--items-file ]') console.log(' pipeline.js validate --manifest ') console.log(' pipeline.js run --manifest [--account id] [--phase p1,p2] [--resume] [--retry-failed]') console.log(' pipeline.js status --manifest ') console.log('') console.log('Manifest 路径约定: output/{account}_{date}_{NNN}/manifest.json(同天自增序号)') console.log('阶段: images, upload, videos, tts, assemble') } if (require.main === module) { main().catch(err => { console.error(`\n错误: ${err.message}`) process.exit(1) }) } module.exports = { runPipeline, showStatus, initManifest, validateManifest, createAccount, validateAccount }