Files
video-create/.claude/skills/video-from-script/scripts/lib/pipeline-utils.js
sion123 9d19437a29 feat(video-pipeline): 实现 TTS 逐句分句生成与字幕精确对齐
TTS 阶段将长文本按标点切分为短句,逐句生成音频并记录每句时长到 `item.segments[]`。assemble 阶段优先使用 segments 的精确时长分配字幕时间线,无 segments 时回退到字数权重估算。同时优化音频上传流程,支持分段音频独立上传 OSS 并在配音时按段映射时间线。
2026-05-01 14:41:28 +08:00

215 lines
6.4 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.
/**
* 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 的顶层 references 读取
const topRefs = accountConfig.references || []
for (const ref of topRefs) {
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) {
// 限制中文字符最多5个,其他字符(英文数字)最多10个
let chineseChars = []
let otherChars = []
for (const char of text) {
if (/\p{Script=Han}/u.test(char)) {
// 中文字符
if (chineseChars.length < 5) {
chineseChars.push(char)
}
} else if (/\w/u.test(char)) {
// 英文、数字
if (otherChars.length < 10) {
otherChars.push(char)
}
}
}
const result = chineseChars.concat(otherChars).join('')
return result || 'untitled'
}
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))
}
// ============================================================================
// 文本切分
// ============================================================================
function splitTextIntoSentences(text) {
const sentenceEnders = /[。!?;]/
const clauseEnders = /[]/
const sentences = []
let current = ''
for (const char of text) {
current += char
if (sentenceEnders.test(char)) {
sentences.push(current.trim().replace(/[。!?;,:、]/g, ''))
current = ''
} else if (clauseEnders.test(char) && current.length > 8) {
sentences.push(current.trim().replace(/[。!?;,:、]/g, ''))
current = ''
}
}
if (current.trim()) {
sentences.push(current.trim().replace(/[。!?;,:、]/g, ''))
}
return sentences
}
// ============================================================================
// Exports
// ============================================================================
module.exports = {
SCRIPTS_DIR,
SKILLS_DIR,
PROJECT_ROOT,
CONFIG_PATH,
ACCOUNTS_DIR,
loadConfig,
loadManifest,
saveManifest,
loadAccountConfig,
getReferences,
ensureDir,
slugify,
renameGeneratedFile,
splitTextIntoSentences,
log,
getManifestDir,
}