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 { generateVideo, validateAndGenerate } = require('./video-api-adapter');
|
||||||
* const result = await validateAndGenerate({ prompt: '...', duration: 10 });
|
* 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):
|
* 环境变量(放在 video-ai-system/.env):
|
||||||
* JIMENG_API_KEY=xxx 火山方舟 API Key
|
* JIMENG_API_KEY=xxx 火山方舟 API Key
|
||||||
* JIMENG_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
* JIMENG_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||||
* JIMENG_MODEL=doubao-seedance-2-0-260128
|
* JIMENG_MODEL=doubao-seedance-2-0-260128
|
||||||
|
* VIDEO_OUTPUT_ROOT=/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频 (可选·覆盖默认)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const fs = require('fs');
|
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 POLL_INTERVAL_MS = parseInt(process.env.POLL_INTERVAL_MS, 10) || 5000;
|
||||||
const MAX_POLL_ATTEMPTS = parseInt(process.env.MAX_POLL_ATTEMPTS, 10) || 120; // 最多轮询10分钟
|
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 原生,无依赖)
|
* HTTP POST 请求封装(Node.js 原生,无依赖)
|
||||||
*/
|
*/
|
||||||
@ -362,23 +447,26 @@ async function probeVideoDuration(videoUrl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成视频(提交 + 自动轮询 + 下载)
|
* 生成视频(提交 + 自动轮询 + 下载 + 注册索引)
|
||||||
* @param {object} opts
|
* @param {object} opts
|
||||||
* @param {string} opts.prompt - 视频提示词
|
* @param {string} opts.prompt - 视频提示词
|
||||||
* @param {string} [opts.duration] - 时长
|
* @param {number} [opts.duration] - 时长 4-15秒
|
||||||
* @param {string} [opts.resolution] - 分辨率
|
* @param {string} [opts.resolution] - 分辨率 480p/720p
|
||||||
* @param {string} [opts.style] - 风格 [已废弃]
|
* @param {string} [opts.style] - [已废弃]
|
||||||
* @param {string} [opts.outputPath] - 输出路径,默认 outputs/{timestamp}.mp4
|
* @param {string} [opts.outputPath] - 输出路径(可选,优先于默认JZAO路径)
|
||||||
* @returns {Promise<{videoPath: string, taskId: string, duration: string, preflight: object}>}
|
* @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) {
|
if (!API_KEY) {
|
||||||
throw new Error('未配置 JIMENG_API_KEY。请在 video-ai-system/.env 中设置。');
|
throw new Error('未配置 JIMENG_API_KEY。请在 video-ai-system/.env 中设置。');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 提交任务(含预校验)
|
// 1. 提交任务(含预校验)
|
||||||
const { taskId, preflight } = await submitTask({ prompt, duration, resolution, style });
|
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. 轮询等待
|
// 2. 轮询等待
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
@ -394,12 +482,36 @@ async function generateVideo({ prompt, duration, resolution, style, outputPath }
|
|||||||
console.log(`[VideoAPI] API返回的元数据:`, JSON.stringify(result.videoMeta));
|
console.log(`[VideoAPI] API返回的元数据:`, JSON.stringify(result.videoMeta));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 下载视频
|
// 3. 【D135】解析输出路径 — 外接硬盘JZAO优先
|
||||||
const finalPath = outputPath || path.resolve(__dirname, `../outputs/${taskId}.mp4`);
|
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}`);
|
console.log(`[VideoAPI] 生成完成!正在下载到: ${finalPath}`);
|
||||||
await downloadVideo(result.videoUrl, finalPath);
|
await downloadVideo(result.videoUrl, finalPath);
|
||||||
console.log(`[VideoAPI] 视频已保存: ${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') {
|
if (result.status === 'failed') {
|
||||||
@ -429,9 +541,9 @@ async function generateVideo({ prompt, duration, resolution, style, outputPath }
|
|||||||
* @param {boolean} [opts.forceDownload] - 跳过探针验证直接下载
|
* @param {boolean} [opts.forceDownload] - 跳过探针验证直接下载
|
||||||
* @returns {Promise<{videoPath: string, taskId: string, actualDuration: number, matched: boolean, preflight: object}>}
|
* @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】探针验证实际时长
|
// 【D135】探针验证实际时长
|
||||||
let probeResult = null;
|
let probeResult = null;
|
||||||
@ -486,7 +598,12 @@ module.exports = {
|
|||||||
preflightCheck,
|
preflightCheck,
|
||||||
probeVideoDuration,
|
probeVideoDuration,
|
||||||
downloadVideo,
|
downloadVideo,
|
||||||
|
resolveOutputPath,
|
||||||
|
registerVideo,
|
||||||
|
findVideo,
|
||||||
MODEL,
|
MODEL,
|
||||||
BASE_URL,
|
BASE_URL,
|
||||||
API_SPEC,
|
API_SPEC,
|
||||||
|
VIDEO_OUTPUT_ROOT,
|
||||||
|
VIDEO_REGISTRY_PATH,
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user