guanghulab/video-ai-system/engines/wan-api-adapter.js
冰朔 8cc582317e
Some checks failed
自动更新代码和重启 / update-and-restart (push) Has been cancelled
CI检查 + 自动部署 / check (push) Has been cancelled
CI检查 + 自动部署 / deploy (push) Has been cancelled
D140: generate EP01 preview 002
2026-06-23 15:59:46 +08:00

557 lines
20 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 适配器
* D140 · 铸渊 ICE-GL-ZY001 · 2026-06-23
*
* 阿里云百炼平台 · 万相2.7视频生成
* 文档: https://help.aliyun.com/zh/model-studio/text-to-video-api-reference
*
* 支持能力:
* - 文生视频 (wan2.7-t2v): 纯文本prompt生成视频
* - 图生视频 (wan2.7-i2v): 首帧/首尾帧/音频驱动 + 文本生成视频
* - 视频续写: 首视频片段续写
*
* 使用方式:
* const { generateWanVideo, generateWanImageToVideo, queryWanTask } = require('./wan-api-adapter');
* const result = await generateWanVideo({ prompt: '一只小猫在月光下奔跑', duration: 5 });
*
* 环境变量(放在 video-ai-system/.env:
* ALIYUN_BAILIAN_API_KEY=
* ALIYUN_BAILIAN_BASE_URL=https://dashscope.aliyuncs.com/api/v1 (默认)
* ALIYUN_BAILIAN_WORKSPACE_ID= (可选·新加坡地域时需要)
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
const http = require('http');
const { loadVideoAiEnv } = require('./env-loader');
loadVideoAiEnv(path.resolve(__dirname, '../.env'));
const API_KEY = process.env.ALIYUN_BAILIAN_API_KEY || '';
const WORKSPACE_ID = process.env.ALIYUN_BAILIAN_WORKSPACE_ID || '';
// 北京地域默认URL新加坡地域需要 workspaceId
const BASE_URL = WORKSPACE_ID
? `https://${WORKSPACE_ID}.ap-southeast-1.maas.aliyuncs.com/api/v1`
: (process.env.ALIYUN_BAILIAN_BASE_URL || 'https://dashscope.aliyuncs.com/api/v1');
const POLL_INTERVAL_MS = parseInt(process.env.WAN_POLL_INTERVAL_MS, 10) || 15000; // 万相建议15秒
const MAX_POLL_ATTEMPTS = parseInt(process.env.WAN_MAX_POLL_ATTEMPTS, 10) || 80; // 最多约20分钟
// 输出路径
const JZAO_VIDEO_ROOT = '/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频';
const LOCAL_OUTPUT_ROOT = path.resolve(__dirname, '../outputs');
const VIDEO_OUTPUT_ROOT = (() => {
const env = process.env.VIDEO_OUTPUT_ROOT;
if (env) return env;
if (fs.existsSync(JZAO_VIDEO_ROOT)) return JZAO_VIDEO_ROOT;
return LOCAL_OUTPUT_ROOT;
})();
// ==================== HTTP 工具 ====================
function httpRequest(method, urlStr, body, apiKey, extraHeaders = {}) {
const urlObj = new URL(urlStr);
const isHttps = urlObj.protocol === 'https:';
const transport = isHttps ? https : http;
const payload = body ? JSON.stringify(body) : null;
return new Promise((resolve, reject) => {
const headers = {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'X-DashScope-Async': 'enable', // 万相必须设置异步头
...extraHeaders,
};
if (payload) headers['Content-Length'] = Buffer.byteLength(payload);
const options = {
hostname: urlObj.hostname,
port: urlObj.port || (isHttps ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method,
headers,
timeout: 30000,
};
const req = transport.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const json = JSON.parse(data);
// 万相错误码在顶层: { code: "xxx", message: "xxx" }
if (json.code && json.code !== 'Success' && res.statusCode >= 400) {
reject(new Error(`万相API错误(${json.code}): ${json.message}`));
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('请求超时')); });
if (payload) req.write(payload);
req.end();
});
}
async function httpPost(url, body, apiKey) {
return httpRequest('POST', url, body, apiKey);
}
async function httpGet(url, apiKey) {
return httpRequest('GET', url, null, apiKey);
}
async function downloadFile(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}`;
downloadFile(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);
});
}
// ==================== API 规范常量 ====================
const WAN_T2V_SPEC = {
model: 'wan2.7-t2v',
duration: { min: 2, max: 15, default: 5 },
resolution: { values: ['720P', '1080P'], default: '1080P' },
ratio: { values: ['16:9', '9:16', '1:1', '4:3', '3:4'], default: '16:9' },
promptMaxLen: 5000,
};
const WAN_I2V_SPEC = {
model: 'wan2.7-i2v-2026-04-25',
duration: { min: 2, max: 15, default: 5 },
resolution: { values: ['720P', '1080P'], default: '1080P' },
promptMaxLen: 5000,
// 图生视频的宽高比由输入素材决定不需要单独传ratio
mediaTypes: ['first_frame', 'last_frame', 'driving_audio', 'first_clip'],
};
// ==================== 预校验 ====================
function preflightCheckT2V({ prompt, duration, resolution, ratio }) {
const warnings = [];
const errors = [];
const corrected = {};
// prompt
if (!prompt || !prompt.trim()) {
errors.push('提示词不能为空');
} else if (prompt.length > WAN_T2V_SPEC.promptMaxLen) {
warnings.push(`提示词超过 ${WAN_T2V_SPEC.promptMaxLen} 字符,将自动截断`);
}
corrected.prompt = prompt;
// duration
const dur = parseInt(duration, 10);
if (duration !== undefined && duration !== null) {
if (isNaN(dur) || dur < WAN_T2V_SPEC.duration.min || dur > WAN_T2V_SPEC.duration.max) {
errors.push(`duration 范围: ${WAN_T2V_SPEC.duration.min}~${WAN_T2V_SPEC.duration.max}秒,收到: ${duration}`);
} else {
corrected.duration = dur;
}
} else {
corrected.duration = WAN_T2V_SPEC.duration.default;
}
// resolution
if (!resolution || !WAN_T2V_SPEC.resolution.values.includes(resolution)) {
if (resolution) warnings.push(`resolution "${resolution}" 不支持,修正为 ${WAN_T2V_SPEC.resolution.default}`);
corrected.resolution = WAN_T2V_SPEC.resolution.default;
} else {
corrected.resolution = resolution;
}
// ratio
if (!ratio || !WAN_T2V_SPEC.ratio.values.includes(ratio)) {
if (ratio) warnings.push(`ratio "${ratio}" 不支持,修正为 ${WAN_T2V_SPEC.ratio.default}`);
corrected.ratio = WAN_T2V_SPEC.ratio.default;
} else {
corrected.ratio = ratio;
}
return { valid: errors.length === 0, warnings, errors, corrected };
}
function preflightCheckI2V({ prompt, media, duration, resolution }) {
const warnings = [];
const errors = [];
const corrected = {};
// media必选
if (!media || !Array.isArray(media) || media.length === 0) {
errors.push('图生视频必须提供 media 素材(至少一个 first_frame');
} else {
// 检查media格式
for (const m of media) {
if (!WAN_I2V_SPEC.mediaTypes.includes(m.type)) {
errors.push(`media type "${m.type}" 不支持,可选: ${WAN_I2V_SPEC.mediaTypes.join(', ')}`);
}
if (!m.url) {
errors.push(`media ${m.type} 缺少 url`);
}
}
corrected.media = media;
}
// prompt可选
corrected.prompt = prompt || '';
// duration
const dur = parseInt(duration, 10);
if (duration !== undefined && duration !== null) {
if (isNaN(dur) || dur < WAN_I2V_SPEC.duration.min || dur > WAN_I2V_SPEC.duration.max) {
errors.push(`duration 范围: ${WAN_I2V_SPEC.duration.min}~${WAN_I2V_SPEC.duration.max}`);
} else {
corrected.duration = dur;
}
} else {
corrected.duration = WAN_I2V_SPEC.duration.default;
}
// resolution
if (!resolution || !WAN_I2V_SPEC.resolution.values.includes(resolution)) {
if (resolution) warnings.push(`resolution "${resolution}" 不支持,修正为 ${WAN_I2V_SPEC.resolution.default}`);
corrected.resolution = WAN_I2V_SPEC.resolution.default;
} else {
corrected.resolution = resolution;
}
return { valid: errors.length === 0, warnings, errors, corrected };
}
// ==================== 提交任务 ====================
const SUBMIT_PATH = '/services/aigc/video-generation/video-synthesis';
const QUERY_PATH = '/tasks/';
/**
* 提交万相文生视频任务
* @param {object} opts
* @param {string} opts.prompt - 视频描述提示词
* @param {number} [opts.duration] - 时长 2-15秒默认5
* @param {string} [opts.resolution] - '720P' | '1080P'默认1080P
* @param {string} [opts.ratio] - '16:9' 等默认16:9
* @param {string} [opts.negativePrompt] - 反向提示词
* @param {boolean} [opts.promptExtend=true] - 是否智能改写prompt
* @param {boolean} [opts.watermark=false] - 是否加水印
* @param {number} [opts.seed] - 随机种子
* @returns {Promise<{taskId: string, preflight: object}>}
*/
async function submitT2VTask({ prompt, duration, resolution, ratio, negativePrompt, promptExtend, watermark, seed }) {
const preflight = preflightCheckT2V({ prompt, duration, resolution, ratio });
if (!preflight.valid) {
console.error('[Wan·预校验] ❌ 参数错误:');
preflight.errors.forEach(e => console.error(`${e}`));
throw new Error(`预校验失败: ${preflight.errors.join('; ')}`);
}
if (preflight.warnings.length > 0) {
console.warn('[Wan·预校验] ⚠️', preflight.warnings.join('; '));
}
const c = preflight.corrected;
const body = {
model: WAN_T2V_SPEC.model,
input: {
prompt: c.prompt,
},
parameters: {
resolution: c.resolution,
ratio: c.ratio,
duration: c.duration,
prompt_extend: promptExtend !== false,
watermark: watermark === true,
},
};
if (negativePrompt) body.input.negative_prompt = negativePrompt;
if (seed !== undefined) body.parameters.seed = seed;
console.log(`[Wan·T2V] 提交: "${c.prompt.substring(0, 60)}..." ${c.duration}s ${c.resolution} ${c.ratio}`);
const url = `${BASE_URL}${SUBMIT_PATH}`;
const data = await httpPost(url, body, API_KEY);
const taskId = data.output?.task_id;
if (!taskId) {
throw new Error(`万相未返回task_id: ${JSON.stringify(data).substring(0, 300)}`);
}
console.log(`[Wan·T2V] 任务ID: ${taskId} 状态: ${data.output?.task_status}`);
return { taskId, preflight };
}
/**
* 提交万相图生视频任务
* @param {object} opts
* @param {string} [opts.prompt] - 辅助文本描述
* @param {Array} opts.media - 媒体素材 [{ type: 'first_frame', url: '...' }, ...]
* type可选: first_frame, last_frame, driving_audio, first_clip
* url支持: 公网URL / oss://临时URL / data:image/png;base64,...
* @param {number} [opts.duration] - 时长 2-15秒
* @param {string} [opts.resolution] - '720P' | '1080P'
* @param {string} [opts.negativePrompt] - 反向提示词
* @param {boolean} [opts.promptExtend=true] - 智能改写
* @param {boolean} [opts.watermark=false] - 加水印
* @param {number} [opts.seed] - 随机种子
* @returns {Promise<{taskId: string, preflight: object}>}
*/
async function submitI2VTask({ prompt, media, duration, resolution, negativePrompt, promptExtend, watermark, seed }) {
// 支持本地图片路径自动转base64
const processedMedia = media.map(m => {
if (m.url && fs.existsSync(m.url)) {
// 本地文件 → 转base64
const imgBuffer = fs.readFileSync(m.url);
const ext = path.extname(m.url).toLowerCase();
const mimeMap = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.bmp': 'image/bmp' };
const mime = mimeMap[ext] || 'image/png';
const b64 = imgBuffer.toString('base64');
console.log(`[Wan·I2V] 本地素材已转base64: ${path.basename(m.url)} (${(imgBuffer.length/1024).toFixed(0)}KB)`);
return { type: m.type, url: `data:${mime};base64,${b64}` };
}
return m;
});
const preflight = preflightCheckI2V({ prompt, media: processedMedia, duration, resolution });
if (!preflight.valid) {
console.error('[Wan·预校验] ❌ 参数错误:');
preflight.errors.forEach(e => console.error(`${e}`));
throw new Error(`预校验失败: ${preflight.errors.join('; ')}`);
}
if (preflight.warnings.length > 0) {
console.warn('[Wan·预校验] ⚠️', preflight.warnings.join('; '));
}
const c = preflight.corrected;
const body = {
model: WAN_I2V_SPEC.model,
input: {
prompt: c.prompt,
media: c.media,
},
parameters: {
resolution: c.resolution,
duration: c.duration,
prompt_extend: promptExtend !== false,
watermark: watermark === true,
},
};
if (negativePrompt) body.input.negative_prompt = negativePrompt;
if (seed !== undefined) body.parameters.seed = seed;
const mediaTypes = c.media.map(m => m.type).join('+');
console.log(`[Wan·I2V] 提交: ${mediaTypes} + "${c.prompt.substring(0, 40)}..." ${c.duration}s ${c.resolution}`);
const url = `${BASE_URL}${SUBMIT_PATH}`;
const data = await httpPost(url, body, API_KEY);
const taskId = data.output?.task_id;
if (!taskId) {
throw new Error(`万相未返回task_id: ${JSON.stringify(data).substring(0, 300)}`);
}
console.log(`[Wan·I2V] 任务ID: ${taskId} 状态: ${data.output?.task_status}`);
return { taskId, preflight };
}
// ==================== 查询任务 ====================
/**
* 查询万相任务状态
* @param {string} taskId
* @returns {Promise<{status: 'generating'|'completed'|'failed', videoUrl?: string, videoMeta?: object, rawResponse?: object, error?: string}>}
*/
async function queryWanTask(taskId) {
const url = `${BASE_URL}${QUERY_PATH}${taskId}`;
const data = await httpGet(url, API_KEY);
const taskStatus = (data.output?.task_status || '').toUpperCase();
if (taskStatus === 'SUCCEEDED') {
const videoUrl = data.output?.video_url;
if (!videoUrl) {
return { status: 'failed', error: '任务成功但未返回视频URL', rawResponse: data };
}
const videoMeta = {};
if (data.usage) {
if (data.usage.duration !== undefined) videoMeta.duration = data.usage.duration;
if (data.usage.output_video_duration !== undefined) videoMeta.outputDuration = data.usage.output_video_duration;
if (data.usage.SR !== undefined) videoMeta.resolutionTier = data.usage.SR;
if (data.usage.ratio !== undefined) videoMeta.ratio = data.usage.ratio;
if (data.usage.video_count !== undefined) videoMeta.videoCount = data.usage.video_count;
}
if (data.output?.orig_prompt) videoMeta.origPrompt = data.output.orig_prompt;
return { status: 'completed', videoUrl, videoMeta, rawResponse: data };
}
if (taskStatus === 'FAILED') {
const errMsg = data.output?.message || data.message || '生成失败';
const errCode = data.output?.code || data.code || '';
return { status: 'failed', error: `${errCode}: ${errMsg}`, rawResponse: data };
}
if (taskStatus === 'CANCELED') {
return { status: 'failed', error: '任务被取消', rawResponse: data };
}
if (taskStatus === 'UNKNOWN') {
return { status: 'failed', error: 'task_id不存在或已过期(24h)', rawResponse: data };
}
// PENDING / RUNNING
return { status: 'generating', rawResponse: data };
}
// ==================== 生成视频(完整流程)====================
/**
* 万相文生视频 — 提交 + 轮询 + 下载
* @param {object} opts
* @param {string} opts.prompt - 视频提示词
* @param {number} [opts.duration=5] - 时长 2-15秒
* @param {string} [opts.resolution='1080P'] - 分辨率
* @param {string} [opts.ratio='16:9'] - 宽高比
* @param {string} [opts.negativePrompt] - 反向提示词
* @param {string} [opts.outputPath] - 输出路径
* @param {string} [opts.shotId] - 镜编号
* @param {string} [opts.projectKey] - 项目标识
* @returns {Promise<{videoPath: string, taskId: string, videoMeta: object, preflight: object}>}
*/
async function generateWanVideo(opts) {
if (!API_KEY) {
throw new Error('未配置 ALIYUN_BAILIAN_API_KEY。请在 video-ai-system/.env 中设置。');
}
const { taskId, preflight } = await submitT2VTask(opts);
// 轮询
for (let i = 1; i <= MAX_POLL_ATTEMPTS; i++) {
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
const result = await queryWanTask(taskId);
if (result.status === 'completed') {
// 解析输出路径
let finalPath;
if (opts.outputPath) {
finalPath = opts.outputPath;
} else if (opts.shotId && opts.projectKey) {
const dir = path.join(VIDEO_OUTPUT_ROOT, opts.projectKey);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
finalPath = path.join(dir, `${opts.shotId}-wan.mp4`);
} else {
finalPath = path.join(VIDEO_OUTPUT_ROOT, `wan-${taskId}.mp4`);
}
console.log(`[Wan] 生成完成!下载到: ${finalPath}`);
await downloadFile(result.videoUrl, finalPath);
console.log(`[Wan] ✅ 视频已保存: ${finalPath}`);
if (result.videoMeta && Object.keys(result.videoMeta).length > 0) {
console.log(`[Wan] 元数据:`, JSON.stringify(result.videoMeta));
}
return { videoPath: finalPath, taskId, videoMeta: result.videoMeta || {}, preflight };
}
if (result.status === 'failed') {
throw new Error(`万相生成失败: ${result.error}`);
}
if (i % 5 === 0) console.log(`[Wan] 生成中... (${i}/${MAX_POLL_ATTEMPTS}, ${i * POLL_INTERVAL_MS / 1000}s)`);
}
throw new Error(`万相轮询超时(${MAX_POLL_ATTEMPTS * POLL_INTERVAL_MS / 1000}s)`);
}
/**
* 万相图生视频 — 提交 + 轮询 + 下载
* @param {object} opts - 同 submitI2VTask 参数 + outputPath/shotId/projectKey
* @returns {Promise<{videoPath: string, taskId: string, videoMeta: object, preflight: object}>}
*/
async function generateWanImageToVideo(opts) {
if (!API_KEY) {
throw new Error('未配置 ALIYUN_BAILIAN_API_KEY。请在 video-ai-system/.env 中设置。');
}
const { taskId, preflight } = await submitI2VTask(opts);
// 轮询
for (let i = 1; i <= MAX_POLL_ATTEMPTS; i++) {
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
const result = await queryWanTask(taskId);
if (result.status === 'completed') {
let finalPath;
if (opts.outputPath) {
finalPath = opts.outputPath;
} else if (opts.shotId && opts.projectKey) {
const dir = path.join(VIDEO_OUTPUT_ROOT, opts.projectKey);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
finalPath = path.join(dir, `${opts.shotId}-wan-i2v.mp4`);
} else {
finalPath = path.join(VIDEO_OUTPUT_ROOT, `wan-i2v-${taskId}.mp4`);
}
console.log(`[Wan·I2V] 生成完成!下载到: ${finalPath}`);
await downloadFile(result.videoUrl, finalPath);
console.log(`[Wan·I2V] ✅ 视频已保存: ${finalPath}`);
return { videoPath: finalPath, taskId, videoMeta: result.videoMeta || {}, preflight };
}
if (result.status === 'failed') {
throw new Error(`万相图生视频失败: ${result.error}`);
}
if (i % 5 === 0) console.log(`[Wan·I2V] 生成中... (${i}/${MAX_POLL_ATTEMPTS})`);
}
throw new Error(`万相I2V轮询超时(${MAX_POLL_ATTEMPTS * POLL_INTERVAL_MS / 1000}s)`);
}
// ==================== 导出 ====================
module.exports = {
submitT2VTask,
submitI2VTask,
queryWanTask,
generateWanVideo,
generateWanImageToVideo,
preflightCheckT2V,
preflightCheckI2V,
downloadFile,
WAN_T2V_SPEC,
WAN_I2V_SPEC,
BASE_URL,
API_KEY, // 用于检测是否已配置
};