/** * 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')) } // ============================================================================ // 参考图解析 // ============================================================================ async function getReferences(manifest, accountConfig) { const result = { localPaths: [], urls: [] } const accountId = accountConfig.id || manifest.account || '' // 自动上传本地文件到 OSS 的辅助函数 const ensureUrl = async (localPath, label) => { try { const { uploadFile } = require('../oss-upload') const { url } = await uploadFile(localPath) result.urls.push(url) log('images', `参考图已自动上传 OSS: ${label}`) return url } catch (err) { log('images', `参考图上传 OSS 失败: ${label} (${err.message}),仅本地使用`) return null } } // 优先读 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) // 有本地文件但没有 OSS URL → 自动上传,杜绝 agent 只用本地路径 if (!ref.url) { ref.url = await ensureUrl(localPath, path.basename(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 (!ref.url) { ref.url = await ensureUrl(localPath, ref.file) } } } } if (result.localPaths.length > 0 || result.urls.length > 0) return result // Fallback 2: 扫描 account 的 references 目录(自动上传 OSS) 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) { const localPath = path.join(refDir, f) result.localPaths.push(localPath) await ensureUrl(localPath, f) } if (files.length > 0) { log('images', `从 references 目录兜底扫描到 ${files.length} 个参考图(已自动上传 OSS)`) } } } 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 sentences = [] let current = '' for (const char of text) { current += char if (sentenceEnders.test(char)) { 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, }