D130: video-api-adapter.js 完成 · 基于Seedance 2.0 · 提交+轮询+下载封装
Some checks failed
自动更新代码和重启 / update-and-restart (push) Has been cancelled
CI检查 + 自动部署 / check (push) Has been cancelled
CI检查 + 自动部署 / deploy (push) Has been cancelled

This commit is contained in:
冰朔 2026-06-11 19:42:52 +08:00
parent 9d37b0d8e4
commit 4e2e577bf1
2 changed files with 288 additions and 0 deletions

View File

@ -0,0 +1,15 @@
# 光湖视频AI系统 · 环境变量
# D130 · 铸渊 ICE-GL-ZY001
# === 火山方舟(即梦 Seedance===
# 获取 API Key: https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey
JIMENG_API_KEY=
JIMENG_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
JIMENG_MODEL=seedance-2-0
# === 轮询参数 ===
POLL_INTERVAL_MS=5000
MAX_POLL_ATTEMPTS=120
# === 腾讯文档已通过MCP连接此处仅供参考===
# 剧本存储在腾讯文档铸渊通过MCP connector直读

View File

@ -0,0 +1,273 @@
/**
* 光湖视频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 || 'seedance-2-0';
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.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,
};