将 `cmd-init.js` 和 `cmd-validate.js` 中的虚值校验(`!item[f]`、`!item.script && !item.text`)改为更精确的字段存在性校验(`!(f in item) || item[f] == null`、`!('script' in item || 'text' in item)`),避免因空字符串或零值等合法空输入导致误报错误。
155 lines
5.6 KiB
JavaScript
155 lines
5.6 KiB
JavaScript
/**
|
||
* Command: init — 从 account.json + AI 创意内容生成规范 manifest.json
|
||
*
|
||
* 校验 items → 继承账号配置 → 创建输出目录 → 写出 manifest
|
||
*/
|
||
|
||
const fs = require('fs')
|
||
const path = require('path')
|
||
const { loadAccountConfig, saveManifest, ensureDir, slugify, 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'))
|
||
console.log(` 已读取并清理临时文件: ${path.basename(filePath)}`)
|
||
fs.unlinkSync(filePath)
|
||
} 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', 'script']
|
||
const optionalFields = ['imagePrompt', 'videoPrompt', 'lastFramePrompt']
|
||
const resolvedMode = mode || 'single'
|
||
|
||
for (let i = 0; i < rawItems.length; i++) {
|
||
const item = rawItems[i]
|
||
for (const f of requiredFields) {
|
||
if (!(f in item) || item[f] == null) {
|
||
console.error(`错误: items[${i}] 缺少必填字段 "${f}"`)
|
||
process.exit(1)
|
||
}
|
||
}
|
||
if (resolvedMode === 'framePair' && !item.lastFramePrompt) {
|
||
delete item.lastFramePrompt // 首尾帧模式 Step 2-A 补充
|
||
}
|
||
}
|
||
|
||
// 从 account.json 继承参考图(顶层 references),仅使用带签名 OSS URL 的条目
|
||
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
|
||
})
|
||
const refsWithoutUrl = references.filter(r => !r.url)
|
||
if (refsWithoutUrl.length > 0) {
|
||
console.log(` ⚠ ${refsWithoutUrl.length} 个参考图缺少 OSS URL,images 阶段会自动上传`)
|
||
}
|
||
|
||
// 构建 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,
|
||
imagePrompt: raw.imagePrompt,
|
||
confirmed: false,
|
||
}
|
||
if (raw.directorRef) item.directorRef = raw.directorRef
|
||
if (raw.videoPrompt) item.videoPrompt = raw.videoPrompt
|
||
if (raw.keyword) item.keyword = raw.keyword
|
||
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 } : {}),
|
||
...(accountConfig.ttsRate ? { ttsRate: accountConfig.ttsRate } : {}),
|
||
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.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()
|
||
|
||
return manifestPath
|
||
}
|
||
|
||
module.exports = { initManifest }
|