/** * 光湖视频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 }); * * 环境变量(由 LOCAL-SECRETS-PATH.hdlp 的苍耳本机路径加载): * 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。请按 LOCAL-SECRETS-PATH.hdlp 在苍耳本机仓库外配置。'); } 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。请按 LOCAL-SECRETS-PATH.hdlp 在苍耳本机仓库外配置。'); } 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, // 用于检测是否已配置 };