'use strict'; const express = require('express'); const axios = require('axios'); const cors = require('cors'); const path = require('path'); const app = express(); const PORT = process.env.PORT || 3000; // Middlewares app.use(cors()); app.use(express.json({ limit: '50mb' })); app.use(express.urlencoded({ extended: true })); // Serve static app.use(express.static(path.join(__dirname, 'public'))); // Health app.get('/health', (req, res) => { res.json({ status: 'ok' }); }); // Helper: extract API key function extractApiKey(req) { const h = req.headers || {}; let key = h['x-api-key'] || h['X-API-KEY']; if (!key && h.authorization) { const m = String(h.authorization).match(/Bearer\s+(.+)/i); if (m) key = m[1]; } if (!key && req.body && req.body.apiKey) key = req.body.apiKey; return key; } // Helpers: sanitize/prune function emptyToUndef(value) { if (value === '') return undefined; if (value === 'null') return undefined; if (Array.isArray(value)) return value.map(emptyToUndef); if (value && typeof value === 'object') { const out = {}; for (const [k, v] of Object.entries(value)) out[k] = emptyToUndef(v); return out; } return value; } function pruneNullUndef(obj) { if (Array.isArray(obj)) { return obj.map(pruneNullUndef).filter((v) => v !== undefined && v !== null); } if (obj && typeof obj === 'object') { const out = {}; for (const [k, v] of Object.entries(obj)) { const pv = pruneNullUndef(v); if (pv !== undefined && pv !== null && pv !== '') out[k] = pv; } return out; } return obj; } function prepareArgs(body) { const x = emptyToUndef({ ...body }); delete x.apiKey; return pruneNullUndef(x); } // Detect image string type for logging/debug function detectImageType(s) { if (!s) return 'empty'; if (typeof s !== 'string') return typeof s; if (/^https?:\/\//i.test(s)) return 'url'; if (/^data:image\//i.test(s)) return 'dataurl'; // 粗略判断 base64 if (/^[A-Za-z0-9+/=\s]+$/.test(s) && s.length > 512) return 'base64'; return 'string'; } function headSample(s, n = 48) { if (typeof s !== 'string') return ''; return s.slice(0, n).replace(/\s+/g, ' '); } function stripDataUrl(dataUrl) { const i = String(dataUrl).indexOf(','); return i >= 0 ? dataUrl.slice(i + 1) : dataUrl; } // 将 dataURL 或含空白的 base64 规范化为纯标准 base64(无头、无换行) function normalizeBase64(s) { if (typeof s !== 'string') return s; const cleaned = s .replace(/^data:image\/[a-zA-Z0-9.+-]+;base64,/, '') .replace(/\s+/g, ''); try { const buf = Buffer.from(cleaned, 'base64'); if (!buf || buf.length === 0) return cleaned; return buf.toString('base64'); // 标准化 } catch { return cleaned; } } // 根据 base64 头部猜测 MIME(用于 dataURL 回退尝试) function guessMimeFromBase64(b64) { if (typeof b64 !== 'string') return 'image/jpeg'; const head = b64.slice(0, 10); if (head.startsWith('/9j/')) return 'image/jpeg'; // JPEG if (head.startsWith('iVBOR')) return 'image/png'; // PNG if (head.startsWith('R0lGOD')) return 'image/gif'; // GIF if (head.startsWith('UklGR')) return 'image/webp'; // WEBP return 'image/jpeg'; } // Uniform upstream sender (handles JSON/ArrayBuffer) function sendUpstreamResponse(res, upstream) { const status = upstream.status || 200; const ct = upstream.headers?.['content-type'] || upstream.headers?.['Content-Type'] || 'application/octet-stream'; const data = upstream.data; if (ct.includes('application/json')) { try { const str = Buffer.isBuffer(data) ? data.toString('utf8') : (data instanceof ArrayBuffer ? Buffer.from(data).toString('utf8') : (typeof data === 'string' ? data : JSON.stringify(data))); const json = JSON.parse(str); return res.status(status).json(json); } catch { const text = Buffer.isBuffer(data) ? data.toString('utf8') : (data instanceof ArrayBuffer ? Buffer.from(data).toString('utf8') : String(data)); res.status(status).set('Content-Type', 'application/json').send(text); return; } } const buf = Buffer.isBuffer(data) ? data : (data instanceof ArrayBuffer ? Buffer.from(data) : Buffer.from(String(data))); res.status(status).set('Content-Type', ct).send(buf); } function parseErrorResponse(errResp) { try { const ct = errResp.headers?.['content-type'] || ''; if (!ct.includes('application/json')) return {}; const str = Buffer.isBuffer(errResp.data) ? errResp.data.toString('utf8') : (errResp.data instanceof ArrayBuffer ? Buffer.from(errResp.data).toString('utf8') : String(errResp.data)); return JSON.parse(str); } catch { return {}; } } function upstreamError(err, res) { if (err.response) { const json = parseErrorResponse(err.response); const { status } = err.response; return res.status(status || 502).json({ error: true, message: json?.message || json?.detail || 'Upstream error', upstream: json, status }); } else if (err.request) { res.status(504).json({ error: true, message: 'No response from upstream' }); } else { res.status(500).json({ error: true, message: err.message || 'Server error' }); } } // Axios factory function axPost(url, apiKey, body) { return axios.post(url, body, { headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', Accept: '*/*' }, timeout: 300000, responseType: 'arraybuffer', validateStatus: () => true, // 允许较大的 base64 图像负载 maxContentLength: Infinity, maxBodyLength: Infinity }); } // 在 public API 与内部 schema 差异不明确时,按顺序尝试两种载荷:plain 与 {args:...} async function postVariants(url, apiKey, args) { const variants = [ { label: 'plain', body: args }, { label: 'args', body: { args } } ]; let last = null; for (const v of variants) { console.log(`[postVariants] ${url} variant=${v.label}`); const resp = await axPost(url, apiKey, v.body); if (resp.status >= 200 && resp.status < 300) { return { ok: true, resp, variant: v.label }; } const j = parseErrorResponse(resp); console.warn(`[postVariants] variant=${v.label} status=${resp.status} detail="${j?.detail || j?.message || ''}"`); last = resp; } return { ok: false, last }; } // ========== WAN 2.2 ========== // Strategy: 自动尝试两维度变体 // 1) 载荷包装形态:plain(直接传参)与 args({ args: {...} }) // 2) 图像编码:dataURL(data:image/...;base64,xxx)优先,失败回退为纯 base64(仅当 image 非 http(s) 时) app.post('/api/wan22/generate', async (req, res) => { const apiKey = extractApiKey(req); if (!apiKey) return res.status(400).json({ error: true, message: 'Missing API key. Use header x-api-key or Authorization: Bearer .' }); // 严格按照 schema 组装 { I2VArgs },并仅保留允许字段 const raw = prepareArgs(req.body) || {}; const allow = new Set([ 'fps', 'fast', 'seed', 'image', 'frames', 'prompt', 'resolution', 'guidance_scale', 'negative_prompt', 'guidance_scale_2' ]); const args = {}; for (const k of Object.keys(raw)) { if (allow.has(k)) args[k] = raw[k]; } // 校验 prompt 与 image(必填) const prompt = typeof args.prompt === 'string' ? args.prompt.trim() : ''; let image = typeof args.image === 'string' ? args.image.trim() : ''; if (!prompt || prompt.length < 3) { return res.status(400).json({ error: true, message: 'Invalid prompt: must be string length >= 3' }); } if (!image) { return res.status(400).json({ error: true, message: 'Invalid image: must be https URL or base64 string' }); } // 若为 dataURL,剥离头得到纯 base64;若非 URL 则清空白 if (/^data:image\/[a-zA-Z0-9.+-]+;base64,/.test(image)) { image = image.split(',')[1] || ''; } if (!/^https?:\/\//i.test(image)) { image = image.replace(/\s+/g, ''); } // 规范化其他字段(按 schema 取值范围裁剪) function clampInt(v, min, max) { if (v === undefined || v === null || v === '') return undefined; const n = parseInt(v, 10); if (!Number.isFinite(n)) return undefined; return Math.max(min, Math.min(max, n)); } function clampNum(v, min, max) { if (v === undefined || v === null || v === '') return undefined; const n = Number(v); if (!Number.isFinite(n)) return undefined; return Math.max(min, Math.min(max, n)); } function toBool(v) { if (typeof v === 'boolean') return v; if (v === 'true' || v === '1' || v === 1) return true; if (v === 'false' || v === '0' || v === 0) return false; return undefined; } const fps = clampInt(args.fps, 16, 24) ?? 16; const frames = clampInt(args.frames, 21, 140) ?? 81; const seed = args.seed === null ? null : clampInt(args.seed, -2147483648, 2147483647); const fast = toBool(args.fast); const guidance_scale = clampNum(args.guidance_scale, 0, 10) ?? 1; const guidance_scale_2 = clampNum(args.guidance_scale_2, 0, 10) ?? 1; const negative_prompt = typeof args.negative_prompt === 'string' ? args.negative_prompt : undefined; let resolution = (typeof args.resolution === 'string' ? args.resolution : '480p'); if (resolution !== '480p' && resolution !== '720p') resolution = '480p'; // 仅保留 schema 字段 const finalArgs = { prompt, image, fps, frames, resolution, guidance_scale, guidance_scale_2 }; if (fast !== undefined) finalArgs.fast = fast; if (seed !== undefined) finalArgs.seed = seed; if (negative_prompt !== undefined) finalArgs.negative_prompt = negative_prompt; console.log('[WAN2.2] final args ->', { keys: Object.keys(finalArgs), promptLen: prompt.length, imageType: /^https?:\/\//i.test(image) ? 'url' : 'base64', imageHead: headSample(image) }); try { const url = 'https://chutes-wan-2-2-i2v-14b-fast.chutes.ai/generate'; const isHttpUrl = /^https?:\/\//i.test(image); async function tryWithWrapperVariants(a) { const pv = await postVariants(url, apiKey, a); console.log('[WAN2.2] tryWithWrapperVariants status=', pv.ok ? pv.resp.status : pv.last?.status, 'variant=', pv.ok ? pv.variant : 'last'); return pv.ok ? pv.resp : pv.last; } let upstream; if (isHttpUrl) { // URL 直接两种包装形态 upstream = await tryWithWrapperVariants(finalArgs); } else { // 非 URL:先 dataURL,再纯 base64;每一步都尝试两种包装形态 const mime = guessMimeFromBase64(image); const asDataUrl = { ...finalArgs, image: `data:${mime};base64,${image}` }; console.log('[WAN2.2] primary: image as dataURL + wrapper variants'); upstream = await tryWithWrapperVariants(asDataUrl); if (!(upstream.status >= 200 && upstream.status < 300)) { console.warn('[WAN2.2] fallback: image as pure base64 + wrapper variants'); upstream = await tryWithWrapperVariants(finalArgs); } } return sendUpstreamResponse(res, upstream); } catch (err) { return upstreamError(err, res); } }); // ========== WAN 2.1 ========== // text2video app.post('/api/wan21/text2video', async (req, res) => { const apiKey = extractApiKey(req); if (!apiKey) return res.status(400).json({ error: true, message: 'Missing API key. Use header x-api-key or Authorization: Bearer .' }); // 按 schema 归一化/裁剪为 { args: VideoGenInput } const raw = prepareArgs(req.body) || {}; const allow = new Set(['fps','seed','steps','frames','prompt','resolution','sample_shift','single_frame','guidance_scale','negative_prompt']); const clean = {}; for (const k of Object.keys(raw)) if (allow.has(k)) clean[k] = raw[k]; const prompt = typeof clean.prompt === 'string' ? clean.prompt.trim() : ''; if (!prompt) return res.status(400).json({ error: true, message: 'Invalid prompt' }); function clampInt(v, min, max) { if (v===undefined||v===null||v==='') return undefined; const n=parseInt(v,10); if(!Number.isFinite(n)) return undefined; return Math.max(min, Math.min(max, n)); } function clampNum(v, min, max) { if (v===undefined||v===null||v==='') return undefined; const n=Number(v); if(!Number.isFinite(n)) return undefined; return Math.max(min, Math.min(max, n)); } function toBool(v){ if(typeof v==='boolean') return v; if(v==='true'||v==='1'||v===1) return true; if(v==='false'||v==='0'||v===0) return false; return undefined; } const fps = clampInt(clean.fps, 16, 60) ?? 16; const steps = clampInt(clean.steps, 10, 30) ?? 25; let frames = clean.frames === null ? null : (clampInt(clean.frames, 81, 241) ?? 81); // 确保frames符合4n+1格式(single_frame时为1) if (frames !== null && frames !== 1) { frames = frames - (frames % 4) + 1; } const seed = clean.seed === null ? null : clampInt(clean.seed, -2147483648, 2147483647); const sample_shift = clean.sample_shift === null ? null : clampNum(clean.sample_shift, 1, 7); const single_frame = toBool(clean.single_frame); const guidance_scale = clean.guidance_scale === null ? null : clampNum(clean.guidance_scale, 1, 7.5) ?? 5; const negative_prompt= typeof clean.negative_prompt === 'string' ? clean.negative_prompt : undefined; let resolution = typeof clean.resolution === 'string' ? clean.resolution : undefined; const RES_ENUM = new Set(['1280*720','720*1280','832*480','480*832','1024*1024']); if (resolution && !RES_ENUM.has(resolution)) resolution = undefined; // 让上游走默认 const finalArgs = { prompt, fps, steps }; if (frames !== undefined) finalArgs.frames = frames; if (seed !== undefined) finalArgs.seed = seed; if (resolution !== undefined) finalArgs.resolution = resolution; if (sample_shift !== undefined) finalArgs.sample_shift = sample_shift; if (single_frame !== undefined) finalArgs.single_frame = single_frame; if (guidance_scale !== undefined) finalArgs.guidance_scale = guidance_scale; if (negative_prompt !== undefined) finalArgs.negative_prompt = negative_prompt; console.log('[WAN2.1 text2video] final args ->', { keys:Object.keys(finalArgs), promptLen:prompt.length, frames, resolution: finalArgs.resolution || '(default)' }); try { const url = 'https://chutes-wan2-1-14b.chutes.ai/text2video'; // WAN2.1:同时尝试 plain 与 {args:...} 载荷,提升兼容性 const pv = await postVariants(url, apiKey, finalArgs); const upstream = pv.ok ? pv.resp : pv.last; return sendUpstreamResponse(res, upstream); } catch (err) { return upstreamError(err, res); } }); // 强制尝试接口:允许通过 query 指定 img 编码与载荷变体,便于定位上游参数期望 // 用法示例:POST /api/wan22/generate_force?img=normalized&variant=args // img ∈ { normalized | dataurl | raw },variant ∈ { plain | args | (省略=自动两种都试) } app.post('/api/wan22/generate_force', async (req, res) => { const apiKey = extractApiKey(req); if (!apiKey) return res.status(400).json({ error: true, message: 'Missing API key. Use header x-api-key or Authorization: Bearer <token>.' }); const argsRaw = prepareArgs(req.body); const imageRaw = argsRaw.image; const imgType = detectImageType(imageRaw); const isUrl = imgType === 'url'; let imageNorm = imageRaw; if (!isUrl && typeof imageRaw === 'string') { imageNorm = normalizeBase64(imageRaw); } const mime = guessMimeFromBase64(typeof imageNorm === 'string' ? imageNorm : ''); const dataUrl = typeof imageNorm === 'string' ? `data:${mime};base64,${imageNorm}` : imageNorm; const imgSel = (req.query.img || 'normalized').toString(); let argsSel; if (imgSel === 'raw') { argsSel = { ...argsRaw, image: imageRaw }; } else if (imgSel === 'dataurl') { argsSel = { ...argsRaw, image: dataUrl }; } else { // normalized argsSel = { ...argsRaw, image: isUrl ? imageRaw : imageNorm }; } const variant = req.query.variant ? req.query.variant.toString() : ''; const url = 'https://chutes-wan-2-2-i2v-14b-fast.chutes.ai/generate'; try { console.log(`[WAN2.2 force] img=${imgSel} variant=${variant || 'auto'} imageHead="${headSample(argsSel.image)}"`); if (variant === 'plain') { const r = await axPost(url, apiKey, argsSel); return sendUpstreamResponse(res, r); } if (variant === 'args') { const r = await axPost(url, apiKey, { args: argsSel }); return sendUpstreamResponse(res, r); } // 自动尝试两种载荷 const pv = await postVariants(url, apiKey, argsSel); if (pv.ok) return sendUpstreamResponse(res, pv.resp); return sendUpstreamResponse(res, pv.last); } catch (err) { return upstreamError(err, res); } }); // image2video app.post('/api/wan21/image2video', async (req, res) => { const apiKey = extractApiKey(req); if (!apiKey) return res.status(400).json({ error: true, message: 'Missing API key. Use header x-api-key or Authorization: Bearer .' }); // 按 schema 归一化/裁剪为 { args: I2VInput } const raw = prepareArgs(req.body) || {}; const allow = new Set(['fps','seed','steps','frames','prompt','image_b64','image_url','sample_shift','single_frame','guidance_scale','negative_prompt']); const clean = {}; for (const k of Object.keys(raw)) if (allow.has(k)) clean[k] = raw[k]; const prompt = typeof clean.prompt === 'string' ? clean.prompt.trim() : ''; if (!prompt) return res.status(400).json({ error: true, message: 'Invalid prompt' }); // image_b64 必填;允许从 image_url 下载转换为 base64 兜底 let image_b64 = typeof clean.image_b64 === 'string' ? clean.image_b64.trim() : ''; if (!image_b64 && typeof clean.image_url === 'string' && clean.image_url.trim()) { try { const imgResp = await axios.get(clean.image_url.trim(), { responseType: 'arraybuffer', timeout: 60000, validateStatus: () => true }); if (imgResp.status >= 200 && imgResp.status < 300 && imgResp.data) { const buf = Buffer.isBuffer(imgResp.data) ? imgResp.data : (imgResp.data instanceof ArrayBuffer ? Buffer.from(imgResp.data) : Buffer.from(String(imgResp.data))); image_b64 = buf.toString('base64'); } else { console.warn('[WAN2.1 image2video] fetch image_url failed status=', imgResp.status); } } catch (e) { console.warn('[WAN2.1 image2video] fetch image_url error:', e?.message || e); } } if (!image_b64) return res.status(400).json({ error: true, message: 'image_b64 is required (upload image or provide image_url)' }); if (/^data:image\/[a-zA-Z0-9.+-]+;base64,/.test(image_b64)) image_b64 = image_b64.split(',')[1] || ''; image_b64 = image_b64.replace(/\s+/g, ''); function clampInt(v, min, max) { if (v===undefined||v===null||v==='') return undefined; const n=parseInt(v,10); if(!Number.isFinite(n)) return undefined; return Math.max(min, Math.min(max, n)); } function clampNum(v, min, max) { if (v===undefined||v===null||v==='') return undefined; const n=Number(v); if(!Number.isFinite(n)) return undefined; return Math.max(min, Math.min(max, n)); } function toBool(v){ if(typeof v==='boolean') return v; if(v==='true'||v==='1'||v===1) return true; if(v==='false'||v==='0'||v===0) return false; return undefined; } const fps = clampInt(clean.fps, 16, 60) ?? 24; const steps = clampInt(clean.steps, 20, 50) ?? 25; // API硬编码frames为81,忽略用户输入 const frames = 81; const seed = clean.seed === null ? null : clampInt(clean.seed, -2147483648, 2147483647); const sample_shift = clean.sample_shift === null ? null : clampNum(clean.sample_shift, 1, 7); const single_frame = toBool(clean.single_frame); const guidance_scale = clean.guidance_scale === null ? null : clampNum(clean.guidance_scale, 1, 7.5) ?? 5; const negative_prompt= typeof clean.negative_prompt === 'string' ? clean.negative_prompt : undefined; const finalArgs = { prompt, image_b64, fps, steps }; if (frames !== undefined) finalArgs.frames = frames; if (seed !== undefined) finalArgs.seed = seed; if (sample_shift !== undefined) finalArgs.sample_shift = sample_shift; if (single_frame !== undefined) finalArgs.single_frame = single_frame; if (guidance_scale !== undefined) finalArgs.guidance_scale = guidance_scale; if (negative_prompt !== undefined) finalArgs.negative_prompt = negative_prompt; console.log('[WAN2.1 image2video] final args ->', { keys:Object.keys(finalArgs), promptLen:prompt.length, b64Len:image_b64.length, b64Head: headSample(image_b64) }); try { const url = 'https://chutes-wan2-1-14b.chutes.ai/image2video'; // WAN2.1:同时尝试 plain 与 {args:...} 载荷,提升兼容性 const pv = await postVariants(url, apiKey, finalArgs); const upstream = pv.ok ? pv.resp : pv.last; return sendUpstreamResponse(res, upstream); } catch (err) { return upstreamError(err, res); } }); // text2image app.post('/api/wan21/text2image', async (req, res) => { const apiKey = extractApiKey(req); if (!apiKey) return res.status(400).json({ error: true, message: 'Missing API key. Use header x-api-key or Authorization: Bearer .' }); // 按 schema 归一化/裁剪为 { args: ImageGenInput } const raw = prepareArgs(req.body) || {}; const allow = new Set(['seed','prompt','resolution','sample_shift','guidance_scale','negative_prompt']); const clean = {}; for (const k of Object.keys(raw)) if (allow.has(k)) clean[k] = raw[k]; const prompt = typeof clean.prompt === 'string' ? clean.prompt.trim() : ''; if (!prompt) return res.status(400).json({ error: true, message: 'Invalid prompt' }); function clampInt(v, min, max) { if (v===undefined||v===null||v==='') return undefined; const n=parseInt(v,10); if(!Number.isFinite(n)) return undefined; return Math.max(min, Math.min(max, n)); } function clampNum(v, min, max) { if (v===undefined||v===null||v==='') return undefined; const n=Number(v); if(!Number.isFinite(n)) return undefined; return Math.max(min, Math.min(max, n)); } let resolution = typeof clean.resolution === 'string' ? clean.resolution : undefined; const RES_ENUM = new Set(['1280*720','720*1280','832*480','480*832','1024*1024']); if (resolution && !RES_ENUM.has(resolution)) resolution = undefined; // 让上游默认 const seed = clean.seed === null ? null : clampInt(clean.seed, -2147483648, 2147483647); const sample_shift = clean.sample_shift === null ? null : clampNum(clean.sample_shift, 1, 7); const guidance_scale = clean.guidance_scale === null ? null : clampNum(clean.guidance_scale, 1, 7.5) ?? 5; const negative_prompt= typeof clean.negative_prompt === 'string' ? clean.negative_prompt : undefined; const finalArgs = { prompt }; if (seed !== undefined) finalArgs.seed = seed; if (resolution !== undefined) finalArgs.resolution = resolution; if (sample_shift !== undefined) finalArgs.sample_shift = sample_shift; if (guidance_scale !== undefined) finalArgs.guidance_scale = guidance_scale; if (negative_prompt !== undefined) finalArgs.negative_prompt = negative_prompt; console.log('[WAN2.1 text2image] final args ->', { keys:Object.keys(finalArgs), promptLen:prompt.length, resolution: finalArgs.resolution || '(default)' }); try { const url = 'https://chutes-wan2-1-14b.chutes.ai/text2image'; // WAN2.1:同时尝试 plain 与 {args:...} 载荷,提升兼容性 const pv = await postVariants(url, apiKey, finalArgs); const upstream = pv.ok ? pv.resp : pv.last; return sendUpstreamResponse(res, upstream); } catch (err) { return upstreamError(err, res); } }); // Fallback to index.html app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); });