165 lines
5.9 KiB
JavaScript
165 lines
5.9 KiB
JavaScript
/**
|
|
* 云雾AI API 适配器 · yunwu.ai
|
|
* D136+ · 铸渊 ICE-GL-ZY001 · 2026-06-21
|
|
*
|
|
* Base URL: https://yunwu.ai/v1
|
|
* 模型: kling-video, kling-omni-video, kling-video-extend, kling-motion-control
|
|
* 兼容 OpenAI 格式
|
|
*
|
|
* 密钥: 从 video-ai-system/.env 或环境变量 YUNWU_API_KEY 读取
|
|
*/
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const https = require('https');
|
|
|
|
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.YUNWU_API_KEY || '';
|
|
const BASE_URL = 'yunwu.ai';
|
|
const BASE_PATH = '/v1';
|
|
|
|
const JZAO_SHOTS = '/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/zai-fu-fei-xiu-xian/ep01';
|
|
const LOCAL_SHOTS = path.resolve(__dirname, '../outputs/shots');
|
|
const OUT_DIR = fs.existsSync(JZAO_SHOTS) ? JZAO_SHOTS : LOCAL_SHOTS;
|
|
|
|
function apiRequest(method, path_, body = null) {
|
|
return new Promise((resolve, reject) => {
|
|
const options = {
|
|
hostname: BASE_URL,
|
|
path: path_,
|
|
method,
|
|
headers: {
|
|
'Authorization': `Bearer ${API_KEY}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
};
|
|
const req = https.request(options, (res) => {
|
|
let data = '';
|
|
res.on('data', chunk => data += chunk);
|
|
res.on('end', () => {
|
|
try { resolve({ status: res.statusCode, ...JSON.parse(data) }); }
|
|
catch (e) { resolve({ status: res.statusCode, raw: data }); }
|
|
});
|
|
});
|
|
req.on('error', reject);
|
|
req.setTimeout(300000, () => { req.destroy(); reject(new Error('timeout')); });
|
|
if (body) req.write(JSON.stringify(body));
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function downloadFile(url, dest) {
|
|
return new Promise((resolve, reject) => {
|
|
const file = fs.createWriteStream(dest);
|
|
const proto = url.startsWith('https') ? https : require('http');
|
|
proto.get(url, (res) => {
|
|
if (res.statusCode >= 300 && res.statusCode < 400) {
|
|
return downloadFile(res.headers.location, dest).then(resolve).catch(reject);
|
|
}
|
|
res.pipe(file);
|
|
file.on('finish', () => { file.close(); resolve(dest); });
|
|
}).on('error', reject);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 云雾AI视频生成 - 两层策略:
|
|
* 1. chat/completions 提交 → 检查响应中是否有视频数据
|
|
* 2. 如果chat返回空 → 尝试 video/generations 端点
|
|
*/
|
|
async function generateVideo({ prompt, duration = 5, outputPath, model = 'kling-video' }) {
|
|
if (!API_KEY) {
|
|
throw new Error('YUNWU_API_KEY 未设置。请写入 video-ai-system/.env 或环境变量。');
|
|
}
|
|
|
|
console.log('[yunwu.ai] 策略1: chat/completions 提交...');
|
|
|
|
// ═══ 策略1: chat/completions ═══
|
|
try {
|
|
const chatRes = await apiRequest('POST', `${BASE_PATH}/chat/completions`, {
|
|
model,
|
|
messages: [{ role: 'user', content: prompt }],
|
|
max_tokens: 4096,
|
|
});
|
|
|
|
// 检查响应中是否有视频URL或错误信息
|
|
if (chatRes.error) {
|
|
console.log(`[yunwu.ai] chat返回错误: ${JSON.stringify(chatRes.error).substring(0,200)}`);
|
|
} else if (chatRes.choices && chatRes.choices[0]?.message?.content) {
|
|
const content = chatRes.choices[0].message.content;
|
|
// 检查是否包含视频URL
|
|
const urlMatch = content.match(/https?:\/\/[^\s]+\.mp4[^\s]*/);
|
|
if (urlMatch) {
|
|
const videoUrl = urlMatch[0];
|
|
const out = outputPath || path.join(OUT_DIR, `yunwu-${Date.now()}.mp4`);
|
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
console.log(`[yunwu.ai] 下载视频...`);
|
|
await downloadFile(videoUrl, out);
|
|
console.log(`[yunwu.ai] ✅ ${path.basename(out)}`);
|
|
return { videoPath: out, taskId: chatRes.id };
|
|
}
|
|
} else if (chatRes.choices === null && chatRes.usage?.prompt_tokens > 0) {
|
|
console.log(`[yunwu.ai] chat接收了提示词但未生成视频(上游Kling返回空)`);
|
|
}
|
|
} catch (e) {
|
|
console.log(`[yunwu.ai] chat异常: ${e.message}`);
|
|
}
|
|
|
|
// ═══ 策略2: video/generations 端点 ═══
|
|
console.log('[yunwu.ai] 策略2: video/generations 端点...');
|
|
|
|
try {
|
|
const videoRes = await apiRequest('POST', `${BASE_PATH}/video/generations`, {
|
|
model,
|
|
prompt,
|
|
duration,
|
|
aspect_ratio: '16:9',
|
|
});
|
|
|
|
if (videoRes.code === 500 || videoRes.error) {
|
|
throw new Error(videoRes.message || videoRes.error?.message || 'video endpoint failed');
|
|
}
|
|
|
|
if (videoRes.data?.task_id) {
|
|
const taskId = videoRes.data.task_id;
|
|
console.log(`[yunwu.ai] 任务: ${taskId}`);
|
|
|
|
for (let i = 0; i < 180; i++) {
|
|
await new Promise(r => setTimeout(r, 2000));
|
|
const status = await apiRequest('GET', `${BASE_PATH}/video/generations/${taskId}`);
|
|
if (status.data?.task_status === 'succeed' || status.data?.task_status === 'completed') {
|
|
const videoUrl = status.data.video_url || status.data.task_result?.videos?.[0]?.url;
|
|
if (videoUrl) {
|
|
const out = outputPath || path.join(OUT_DIR, `yunwu-${taskId}.mp4`);
|
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
await downloadFile(videoUrl, out);
|
|
console.log(`[yunwu.ai] ✅ ${path.basename(out)}`);
|
|
return { videoPath: out, taskId };
|
|
}
|
|
throw new Error('任务完成但无视频URL');
|
|
}
|
|
if (status.data?.task_status === 'failed') {
|
|
throw new Error(`任务失败: ${JSON.stringify(status.data)}`);
|
|
}
|
|
}
|
|
throw new Error('轮询超时');
|
|
}
|
|
} catch (e) {
|
|
console.log(`[yunwu.ai] video端点异常: ${e.message}`);
|
|
}
|
|
|
|
throw new Error('yunwu.ai: 所有策略均失败。可能需要检查模型通道配置。');
|
|
}
|
|
|
|
module.exports = { generateVideo };
|