chutesmovie / server.js
Logankunfall's picture
Upload 811 files
04aef2a verified
'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 <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&amp;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 &lt;token&gt;.' });
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}`);
});