D135b: 视频输出→外接硬盘JZAO · 编号双向索引
输出路径优先级:
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:
parent
3361d2d2fc
commit
09e5cf382a
@ -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,
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user