Files
video-create/.claude/skills/video-from-script/scripts/lib/cmd-init.js
sion123 18fce1b5a1 feat(video-from-script): 添加 TTS 音色管理和解析功能
- 在 config.json 中添加 `ttsVoices` 音色库,支持音色名称到 ID 的映射
- 实现 `resolveVoice` 函数,将音色名称解析为实际 ID
- 更新账号系统和批量管道,支持通过音色名称配置 TTS 语音
- Excel 导入和 CLI 参数新增音色字段,支持按行指定不同音色
2026-05-08 23:53:37 +08:00

182 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Command: init — 从 account.json + AI 创意内容生成规范 manifest.json
*
* 校验 items → 继承账号配置 → 创建输出目录 → 写出 manifest
*/
const fs = require('fs')
const path = require('path')
const { loadAccountConfig, loadConfig, resolveVoice, 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)
const globalConfig = loadConfig()
// 解析 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 URLimages 阶段会自动上传`)
}
// 从 videoModel 推算固定时长(秒)
const videoModelFixedDurations = {
'kling': 6,
'kling-v2-5-turbo': 6,
'veo3-fast': 8,
'veo3-fast-frames': 8,
'grok-video-3': 6,
}
const estimatedVideoDuration = videoModelFixedDurations[options.videoModel || accountConfig.videoModel] || 6
// 校验时长约束
for (let i = 0; i < rawItems.length; i++) {
const item = rawItems[i]
const dur = Number(item.duration) || 5
if (dur > estimatedVideoDuration) {
console.error(`错误: items[${i}] 的 TTS 估算 duration=${dur}s > videoModel 固定时长 ${estimatedVideoDuration}s`)
console.error(` 必须先拆分 shot 再执行 init`)
console.error(` script: "${item.script}"`)
process.exit(1)
}
}
// 构建 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: Number(raw.duration) || 5,
estimatedVideoDuration,
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: resolveVoice(accountConfig.ttsVoice, globalConfig) } : {}),
...(options.ttsVoice ? { ttsVoice: resolveVoice(options.ttsVoice, globalConfig) } : {}),
...(accountConfig.ttsInstruction ? { ttsInstruction: accountConfig.ttsInstruction } : {}),
// 铁律ttsRate 写死 1.15x,不允许配置覆盖(除非显式传入)
ttsRate: options.ttsRate || 1.15,
items,
estimatedVideoDuration, // 顶层冗余,便于 assemble 直接读取
}
// 创建输出目录(自增序号)
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 }