D135b: 视频输出→外接硬盘JZAO · 编号双向索引
Some checks failed
自动更新代码和重启 / update-and-restart (push) Has been cancelled
CI检查 + 自动部署 / check (push) Has been cancelled
CI检查 + 自动部署 / deploy (push) Has been cancelled

输出路径优先级:
  1. opts.outputPath(显式)
  2. JZAO外接硬盘 /Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/
  3. 本地fallback video-ai-system/outputs/

新增:
- resolveOutputPath() — 按projectKey/shotId自动创建目录+生成路径
- registerVideo() — 生成后自动写入仓库端video-registry.json
- findVideo(shotId) — 毫秒级定位视频在硬盘上的位置
  返回 { found, filePath, onDisk, info }

原理:
  仓库端存索引(几KB) → 硬盘端存视频(几百MB)
  '镜ep01-shot01在哪?' → findVideo('ep01-shot01') → 路径毫秒返回
This commit is contained in:
冰朔 2026-06-17 14:03:48 +08:00
parent 3361d2d2fc
commit 09e5cf382a

View File

@ -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,
};