Spaces:
Sleeping
Sleeping
| ; | |
| 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 <token>.' }); | |
| // 严格按照 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 <token>.' }); | |
| // 按 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 <token>.' }); | |
| // 按 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 <token>.' }); | |
| // 按 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}`); | |
| }); |