diff --git a/video-ai-system/engines/video-api-adapter.js b/video-ai-system/engines/video-api-adapter.js index 74b42ca..8e24ed7 100644 --- a/video-ai-system/engines/video-api-adapter.js +++ b/video-ai-system/engines/video-api-adapter.js @@ -9,10 +9,16 @@ * const { generateVideo, validateAndGenerate } = require('./video-api-adapter'); * const result = await validateAndGenerate({ prompt: '...', duration: 10 }); * + * 输出路径优先级: + * 1. opts.outputPath(显式指定) + * 2. 外接硬盘 JZAO /Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/ + * 3. 本地 fallback video-ai-system/outputs/ + * * 环境变量(放在 video-ai-system/.env): * JIMENG_API_KEY=xxx 火山方舟 API Key * JIMENG_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 * JIMENG_MODEL=doubao-seedance-2-0-260128 + * VIDEO_OUTPUT_ROOT=/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频 (可选·覆盖默认) */ const fs = require('fs'); @@ -39,6 +45,85 @@ const MODEL = process.env.JIMENG_MODEL || 'doubao-seedance-2-0-260128'; const POLL_INTERVAL_MS = parseInt(process.env.POLL_INTERVAL_MS, 10) || 5000; const MAX_POLL_ATTEMPTS = parseInt(process.env.MAX_POLL_ATTEMPTS, 10) || 120; // 最多轮询10分钟 +// 【D135】输出路径:外接硬盘优先 +const JZAO_VIDEO_ROOT = '/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频'; +const LOCAL_OUTPUT_ROOT = path.resolve(__dirname, '../outputs'); +const VIDEO_OUTPUT_ROOT = (() => { + const env = process.env.VIDEO_OUTPUT_ROOT; + if (env) return env; + if (fs.existsSync(JZAO_VIDEO_ROOT)) return JZAO_VIDEO_ROOT; + return LOCAL_OUTPUT_ROOT; +})(); +const VIDEO_REGISTRY_PATH = path.resolve(__dirname, '../outputs/video-registry.json'); + +console.log(`[VideoAPI] 输出路径: ${VIDEO_OUTPUT_ROOT}`); + +/** + * 【D135】解析输出路径 — 外接硬盘JZAO优先,本地fallback + * @param {string} projectKey - 项目标识,如 "zai-fu-fei-xiu-xian/ep01" + * @param {string} filename - 文件名 + * @returns {string} 完整输出路径 + */ +function resolveOutputPath(projectKey, filename) { + const dir = path.join(VIDEO_OUTPUT_ROOT, projectKey); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + return path.join(dir, filename); +} + +/** + * 【D135】视频注册 — 镜编号→硬盘路径的双向索引 + * 存到仓库里,人不用翻文件夹,系统毫秒级定位 + */ +function registerVideo({ projectKey, shotId, taskId, filePath, duration, resolution, prompt }) { + let registry = { _meta: { updated: new Date().toISOString(), by: '铸渊 ICE-GL-ZY001' }, shots: {} }; + + try { + if (fs.existsSync(VIDEO_REGISTRY_PATH)) { + registry = JSON.parse(fs.readFileSync(VIDEO_REGISTRY_PATH, 'utf-8')); + } + } catch (e) { + // 文件损坏,重建 + } + + const key = shotId || taskId; + registry.shots[key] = { + shotId: key, + taskId, + projectKey, + filePath, + duration, + resolution, + promptPreview: (prompt || '').substring(0, 80), + generatedAt: new Date().toISOString(), + dNumber: 'D135', + }; + registry._meta.updated = new Date().toISOString(); + registry._meta.totalShots = Object.keys(registry.shots).length; + + const dir = path.dirname(VIDEO_REGISTRY_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(VIDEO_REGISTRY_PATH, JSON.stringify(registry, null, 2), 'utf-8'); + console.log(`[VideoAPI·注册] ${key} → ${filePath}`); +} + +/** + * 【D135】按镜编号查找视频 — 毫秒级定位 + * @param {string} shotId + * @returns {{ found: boolean, filePath?: string, info?: object }} + */ +function findVideo(shotId) { + try { + if (!fs.existsSync(VIDEO_REGISTRY_PATH)) return { found: false }; + const registry = JSON.parse(fs.readFileSync(VIDEO_REGISTRY_PATH, 'utf-8')); + const entry = registry.shots[shotId]; + if (!entry) return { found: false }; + const onDisk = fs.existsSync(entry.filePath); + return { found: true, filePath: entry.filePath, onDisk, info: entry }; + } catch (e) { + return { found: false, error: e.message }; + } +} + /** * HTTP POST 请求封装(Node.js 原生,无依赖) */ @@ -362,23 +447,26 @@ async function probeVideoDuration(videoUrl) { } /** - * 生成视频(提交 + 自动轮询 + 下载) + * 生成视频(提交 + 自动轮询 + 下载 + 注册索引) * @param {object} opts * @param {string} opts.prompt - 视频提示词 - * @param {string} [opts.duration] - 时长 - * @param {string} [opts.resolution] - 分辨率 - * @param {string} [opts.style] - 风格 [已废弃] - * @param {string} [opts.outputPath] - 输出路径,默认 outputs/{timestamp}.mp4 - * @returns {Promise<{videoPath: string, taskId: string, duration: string, preflight: object}>} + * @param {number} [opts.duration] - 时长 4-15秒 + * @param {string} [opts.resolution] - 分辨率 480p/720p + * @param {string} [opts.style] - [已废弃] + * @param {string} [opts.outputPath] - 输出路径(可选,优先于默认JZAO路径) + * @param {string} [opts.shotId] - 镜编号,用于注册索引(如 'ep01-shot01') + * @param {string} [opts.projectKey] - 项目标识(如 'zai-fu-fei-xiu-xian/ep01') + * @returns {Promise<{videoPath: string, taskId: string, duration: number, preflight: object}>} */ -async function generateVideo({ prompt, duration, resolution, style, outputPath }) { +async function generateVideo({ prompt, duration, resolution, style, outputPath, shotId, projectKey }) { if (!API_KEY) { throw new Error('未配置 JIMENG_API_KEY。请在 video-ai-system/.env 中设置。'); } // 1. 提交任务(含预校验) const { taskId, preflight } = await submitTask({ prompt, duration, resolution, style }); - const requestedDuration = preflight.corrected.duration; + const finalDuration = preflight.corrected.duration; + const finalResolution = preflight.corrected.resolution; // 2. 轮询等待 let attempts = 0; @@ -394,12 +482,36 @@ async function generateVideo({ prompt, duration, resolution, style, outputPath } console.log(`[VideoAPI] API返回的元数据:`, JSON.stringify(result.videoMeta)); } - // 3. 下载视频 - const finalPath = outputPath || path.resolve(__dirname, `../outputs/${taskId}.mp4`); + // 3. 【D135】解析输出路径 — 外接硬盘JZAO优先 + let finalPath; + if (outputPath) { + finalPath = outputPath; + } else if (shotId && projectKey) { + // 有编号 → 走 JZAO 编号文件夹 + finalPath = resolveOutputPath(projectKey, `${shotId}.mp4`); + } else { + // 兜底: JZAO根目录用taskId + finalPath = path.join(VIDEO_OUTPUT_ROOT, `${taskId}.mp4`); + } + console.log(`[VideoAPI] 生成完成!正在下载到: ${finalPath}`); await downloadVideo(result.videoUrl, finalPath); console.log(`[VideoAPI] 视频已保存: ${finalPath}`); - return { videoPath: finalPath, taskId, duration: requestedDuration, preflight }; + + // 4. 【D135】注册视频索引 — 仓库↔硬盘双向映射 + if (shotId || taskId) { + registerVideo({ + projectKey: projectKey || 'unknown', + shotId: shotId || taskId, + taskId, + filePath: finalPath, + duration: finalDuration, + resolution: finalResolution, + prompt, + }); + } + + return { videoPath: finalPath, taskId, duration: finalDuration, resolution: finalResolution, preflight }; } if (result.status === 'failed') { @@ -429,9 +541,9 @@ async function generateVideo({ prompt, duration, resolution, style, outputPath } * @param {boolean} [opts.forceDownload] - 跳过探针验证直接下载 * @returns {Promise<{videoPath: string, taskId: string, actualDuration: number, matched: boolean, preflight: object}>} */ -async function validateAndGenerate({ prompt, duration, resolution, outputPath, forceDownload }) { +async function validateAndGenerate({ prompt, duration, resolution, outputPath, shotId, projectKey, forceDownload }) { // 提交 + 轮询 + 下载 - const result = await generateVideo({ prompt, duration, resolution, outputPath }); + const result = await generateVideo({ prompt, duration, resolution, outputPath, shotId, projectKey }); // 【D135】探针验证实际时长 let probeResult = null; @@ -486,7 +598,12 @@ module.exports = { preflightCheck, probeVideoDuration, downloadVideo, + resolveOutputPath, + registerVideo, + findVideo, MODEL, BASE_URL, API_SPEC, + VIDEO_OUTPUT_ROOT, + VIDEO_REGISTRY_PATH, };