guanghulab/video-ai-system/engines/video-api-adapter.js
冰朔 958342011c
Some checks failed
自动更新代码和重启 / update-and-restart (push) Has been cancelled
CI检查 + 自动部署 / check (push) Has been cancelled
CI检查 + 自动部署 / deploy (push) Has been cancelled
D131: video-api-adapter content.video_url修复 + hldp-prompt.js 引擎 + 模型名修正 doubao-seedance-2-0-260128
2026-06-12 22:22:41 +08:00

275 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 光湖视频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,
};