From 4e2e577bf1189e6014e30a0c9620d919f44c4b1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Thu, 11 Jun 2026 19:42:52 +0800 Subject: [PATCH] =?UTF-8?q?D130:=20video-api-adapter.js=20=E5=AE=8C?= =?UTF-8?q?=E6=88=90=20=C2=B7=20=E5=9F=BA=E4=BA=8ESeedance=202.0=20=C2=B7?= =?UTF-8?q?=20=E6=8F=90=E4=BA=A4+=E8=BD=AE=E8=AF=A2+=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E5=B0=81=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- video-ai-system/.env.example | 15 + video-ai-system/engines/video-api-adapter.js | 273 +++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 video-ai-system/.env.example create mode 100644 video-ai-system/engines/video-api-adapter.js diff --git a/video-ai-system/.env.example b/video-ai-system/.env.example new file mode 100644 index 0000000..aa7273e --- /dev/null +++ b/video-ai-system/.env.example @@ -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直读 diff --git a/video-ai-system/engines/video-api-adapter.js b/video-ai-system/engines/video-api-adapter.js new file mode 100644 index 0000000..f4118f3 --- /dev/null +++ b/video-ai-system/engines/video-api-adapter.js @@ -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, +};