557 lines
20 KiB
JavaScript
557 lines
20 KiB
JavaScript
/**
|
||
* 光湖视频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, // 用于检测是否已配置
|
||
};
|