- 重写 `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` 参数
139 lines
4.6 KiB
JavaScript
139 lines
4.6 KiB
JavaScript
/**
|
||
* 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 继承参考图(顶层 references)
|
||
const accountRefs = accountConfig.references || []
|
||
const references = accountRefs.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: options.imageModel || accountConfig.imageModel || 'gemini',
|
||
videoModel: options.videoModel || accountConfig.videoModel || 'veo3-fast-frames',
|
||
format: options.format || accountConfig.defaultFormat || '9:16',
|
||
mode: resolvedMode,
|
||
references,
|
||
...(accountConfig.ttsVoice ? { ttsVoice: accountConfig.ttsVoice } : {}),
|
||
...(accountConfig.ttsInstruction ? { ttsInstruction: accountConfig.ttsInstruction } : {}),
|
||
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 }
|