275 lines
8.7 KiB
JavaScript
275 lines
8.7 KiB
JavaScript
/**
|
||
* 光湖视频AI系统 · 视频API适配层
|
||
* D130 · 铸渊 ICE-GL-ZY001
|
||
*
|
||
* 基于 guanghuclip 的即梦 Seedance 对接实现
|
||
* 升级 Seedance 1.5 → 2.0
|
||
*
|
||
* 使用方式:
|
||
* const { generateVideo } = require('./video-api-adapter');
|
||
* const result = await generateVideo({ prompt: '...', duration: 5 });
|
||
*
|
||
* 环境变量(放在 video-ai-system/.env):
|
||
* JIMENG_API_KEY=xxx 火山方舟 API Key
|
||
* JIMENG_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||
* JIMENG_MODEL=seedance-2-0 (默认 2.0,可回退 1.5)
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const https = require('https');
|
||
const http = require('http');
|
||
|
||
// 读取环境变量
|
||
const envPath = path.resolve(__dirname, '../.env');
|
||
if (fs.existsSync(envPath)) {
|
||
const envContent = fs.readFileSync(envPath, 'utf-8');
|
||
envContent.split('\n').forEach(line => {
|
||
const trimmed = line.trim();
|
||
if (trimmed && !trimmed.startsWith('#')) {
|
||
const [key, ...vals] = trimmed.split('=');
|
||
if (key && vals.length) process.env[key.trim()] = vals.join('=').trim();
|
||
}
|
||
});
|
||
}
|
||
|
||
const API_KEY = process.env.JIMENG_API_KEY || '';
|
||
const BASE_URL = process.env.JIMENG_BASE_URL || 'https://ark.cn-beijing.volces.com/api/v3';
|
||
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分钟
|
||
|
||
/**
|
||
* HTTP POST 请求封装(Node.js 原生,无依赖)
|
||
*/
|
||
async function httpPost(url, body, apiKey) {
|
||
const urlObj = new URL(url);
|
||
const isHttps = urlObj.protocol === 'https:';
|
||
const transport = isHttps ? https : http;
|
||
const payload = JSON.stringify(body);
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const req = transport.request(url, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${apiKey}`,
|
||
'Content-Type': 'application/json',
|
||
'Content-Length': Buffer.byteLength(payload),
|
||
},
|
||
timeout: 30000,
|
||
}, (res) => {
|
||
let data = '';
|
||
res.on('data', chunk => data += chunk);
|
||
res.on('end', () => {
|
||
try {
|
||
const json = JSON.parse(data);
|
||
if (res.statusCode >= 400) {
|
||
const errMsg = json.error?.message || json.message || `HTTP ${res.statusCode}`;
|
||
reject(new Error(`API错误(${res.statusCode}): ${errMsg}`));
|
||
return;
|
||
}
|
||
resolve(json);
|
||
} catch (e) {
|
||
reject(new Error(`JSON解析失败: ${data.substring(0, 200)}`));
|
||
}
|
||
});
|
||
});
|
||
req.on('error', reject);
|
||
req.on('timeout', () => { req.destroy(); reject(new Error('请求超时')); });
|
||
req.write(payload);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* HTTP GET 请求封装
|
||
*/
|
||
async function httpGet(url, apiKey) {
|
||
const urlObj = new URL(url);
|
||
const isHttps = urlObj.protocol === 'https:';
|
||
const transport = isHttps ? https : http;
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const req = transport.request(url, {
|
||
method: 'GET',
|
||
headers: {
|
||
'Authorization': `Bearer ${apiKey}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
timeout: 10000,
|
||
}, (res) => {
|
||
let data = '';
|
||
res.on('data', chunk => data += chunk);
|
||
res.on('end', () => {
|
||
try {
|
||
const json = JSON.parse(data);
|
||
resolve(json);
|
||
} catch (e) {
|
||
reject(new Error(`JSON解析失败: ${data.substring(0, 200)}`));
|
||
}
|
||
});
|
||
});
|
||
req.on('error', reject);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 下载视频到本地,避免外网URL过期
|
||
*/
|
||
async function downloadVideo(videoUrl, outputPath) {
|
||
const urlObj = new URL(videoUrl);
|
||
const isHttps = urlObj.protocol === 'https:';
|
||
const transport = isHttps ? https : http;
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const dir = path.dirname(outputPath);
|
||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||
|
||
const file = fs.createWriteStream(outputPath);
|
||
transport.get(videoUrl, (res) => {
|
||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||
// 重定向
|
||
const redirectUrl = res.headers.location.startsWith('http')
|
||
? res.headers.location
|
||
: `${urlObj.protocol}//${urlObj.host}${res.headers.location}`;
|
||
downloadVideo(redirectUrl, outputPath).then(resolve).catch(reject);
|
||
return;
|
||
}
|
||
res.pipe(file);
|
||
file.on('finish', () => { file.close(); resolve(outputPath); });
|
||
file.on('error', (err) => { fs.unlinkSync(outputPath); reject(err); });
|
||
}).on('error', reject);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 提交视频生成任务
|
||
* @param {object} opts
|
||
* @param {string} opts.prompt - 视频描述提示词(中文)
|
||
* @param {string} [opts.duration] - 时长 '5' | '10',默认 5
|
||
* @param {string} [opts.resolution] - 分辨率 '720p' | '1080p',默认 1080p
|
||
* @param {string} [opts.style] - 风格(可选: cinematic/anime/3d/cyberpunk/watercolor)
|
||
* @returns {Promise<{taskId: string}>}
|
||
*/
|
||
async function submitTask({ prompt, duration = '5', resolution = '1080p', style }) {
|
||
console.log(`[VideoAPI] 提交任务: ${prompt.substring(0, 60)}...`);
|
||
|
||
const payload = {
|
||
model: MODEL,
|
||
content: [
|
||
{ type: 'text', text: prompt }
|
||
],
|
||
parameters: {
|
||
video_length: String(duration),
|
||
resolution: resolution,
|
||
},
|
||
};
|
||
|
||
if (style) {
|
||
payload.parameters.style = style;
|
||
}
|
||
|
||
const data = await httpPost(`${BASE_URL}/contents/generations/tasks`, payload, API_KEY);
|
||
const taskId = data.id || data.task_id || data.data?.task_id || data.data?.id;
|
||
|
||
if (!taskId) {
|
||
throw new Error(`即梦API未返回任务ID: ${JSON.stringify(data).substring(0, 200)}`);
|
||
}
|
||
|
||
console.log(`[VideoAPI] 任务已提交: ${taskId} 模型: ${MODEL} 时长: ${duration}s 分辨率: ${resolution}`);
|
||
return { taskId };
|
||
}
|
||
|
||
/**
|
||
* 查询任务状态
|
||
* @param {string} taskId
|
||
* @returns {Promise<{status: 'generating'|'completed'|'failed', videoUrl?: string, error?: string}>}
|
||
*/
|
||
async function queryTask(taskId) {
|
||
const data = await httpGet(`${BASE_URL}/contents/generations/tasks/${taskId}`, API_KEY);
|
||
const rawStatus = (data.status || data.data?.status || '').toLowerCase();
|
||
|
||
if (['succeeded', 'completed', 'success', 'done'].includes(rawStatus)) {
|
||
const videoUrl = data.content?.video_url
|
||
|| data.output?.video_url
|
||
|| data.output?.url
|
||
|| data.data?.output?.video_url
|
||
|| data.data?.output?.url
|
||
|| data.result?.video_url
|
||
|| data.content?.[0]?.url
|
||
|| data.data?.content?.[0]?.url;
|
||
|
||
if (!videoUrl) {
|
||
return { status: 'failed', error: '任务完成但未返回视频地址' };
|
||
}
|
||
return { status: 'completed', videoUrl };
|
||
}
|
||
|
||
if (['failed', 'error', 'cancelled'].includes(rawStatus)) {
|
||
const errMsg = data.error?.message || data.data?.error?.message || data.message || '生成失败';
|
||
return { status: 'failed', error: errMsg };
|
||
}
|
||
|
||
return { status: 'generating' };
|
||
}
|
||
|
||
/**
|
||
* 生成视频(提交 + 自动轮询 + 下载)
|
||
* @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}>}
|
||
*/
|
||
async function generateVideo({ prompt, duration = '5', resolution = '1080p', style, outputPath }) {
|
||
if (!API_KEY) {
|
||
throw new Error('未配置 JIMENG_API_KEY。请在 video-ai-system/.env 中设置。');
|
||
}
|
||
if (!prompt || !prompt.trim()) {
|
||
throw new Error('提示词不能为空');
|
||
}
|
||
|
||
// 1. 提交任务
|
||
const { taskId } = await submitTask({ prompt, duration, resolution, style });
|
||
|
||
// 2. 轮询等待
|
||
let attempts = 0;
|
||
while (attempts < MAX_POLL_ATTEMPTS) {
|
||
attempts++;
|
||
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
|
||
|
||
const result = await queryTask(taskId);
|
||
|
||
if (result.status === 'completed') {
|
||
// 3. 下载视频
|
||
const finalPath = outputPath || path.resolve(__dirname, `../outputs/${taskId}.mp4`);
|
||
console.log(`[VideoAPI] 生成完成!正在下载到: ${finalPath}`);
|
||
await downloadVideo(result.videoUrl, finalPath);
|
||
console.log(`[VideoAPI] 视频已保存: ${finalPath}`);
|
||
return { videoPath: finalPath, taskId, duration };
|
||
}
|
||
|
||
if (result.status === 'failed') {
|
||
throw new Error(`视频生成失败: ${result.error}`);
|
||
}
|
||
|
||
console.log(`[VideoAPI] 生成中... (${attempts}/${MAX_POLL_ATTEMPTS})`);
|
||
}
|
||
|
||
throw new Error(`轮询超时(${MAX_POLL_ATTEMPTS * POLL_INTERVAL_MS / 1000}秒)`);
|
||
}
|
||
|
||
// ==================== 导出 ====================
|
||
|
||
module.exports = {
|
||
submitTask,
|
||
queryTask,
|
||
generateVideo,
|
||
downloadVideo,
|
||
MODEL,
|
||
BASE_URL,
|
||
};
|