Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
240 changes: 240 additions & 0 deletions tools/panai_image_gen_mcp.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
#!/usr/bin/env node
// PanAI 图片生成 MCP — 按需选后端(本地 FLUX / Agnes 云端)+ 保证尺寸。
// 经 engine_server(7071)的多 provider 文生图端点出图,生成后用 ffmpeg cover+crop
// 裁到精确目标尺寸(绕开「模型不完全吃宽高参数」的限制)。零依赖 stdio JSON-RPC (MCP)。
// env:
// PANAI_IMG_BASE_URL = http://127.0.0.1:7071/v1 (engine_server 文生图)
// PANAI_IMG_LOCAL_MODEL = black-forest-labs/FLUX.2-dev (本地 HF 默认模型)
// PANAI_IMG_AGNES_MODEL = agnes-image-2.1-flash (Agnes 云端默认模型)
// PANAI_IMG_OUTPUT_DIR = /home/insome/ComfyUI/output (落盘目录)

import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const BASE = (process.env.PANAI_IMG_BASE_URL || 'http://127.0.0.1:7071/v1').replace(/\/$/, '');
const LOCAL_MODEL = process.env.PANAI_IMG_LOCAL_MODEL || 'black-forest-labs/FLUX.2-dev';
const AGNES_MODEL = process.env.PANAI_IMG_AGNES_MODEL || 'agnes-image-2.1-flash';
const OUTPUT_DIR = process.env.PANAI_IMG_OUTPUT_DIR || '/home/insome/ComfyUI/output';
// 超分(4K/8K 用 Real-ESRGAN 真超分,优于 lanczos 放大)
const COMFY_BASE = (process.env.COMFY_BASE || 'http://127.0.0.1:8188').replace(/\/$/, '');
const COMFY_TOKEN_FILE = process.env.COMFY_TOKEN_FILE || '/home/insome/ComfyUI/login/PASSWORD';
const COMFY_INPUT_DIR = process.env.COMFY_INPUT_DIR || '/home/insome/ComfyUI/input';
const COMFY_UPSCALE_MODEL = process.env.COMFY_UPSCALE_MODEL || 'RealESRGAN_x4plus.pth';

const SERVER_INFO = { name: 'panai-image-generation', version: '2.0.0' };
const TOOL = {
// 与内置工具同名,作为 drop-in 升级:全参数化(风格/比例/分辨率/画质/后端均可自定义,不锁死)。
name: 'panai_image_generation',
description: '生成图片的必用工具(用户想生成/制作一张图片时调用)。风格/比例/分辨率/画质/后端全部可自定义。Agnes 云端原生支持任意尺寸。返回图片文件路径。',
inputSchema: {
type: 'object',
properties: {
prompt: { type: 'string', description: '图片内容描述(英文效果最好)' },
negative: { type: 'string', description: '不希望出现的内容(可选)' },
style: { type: 'string', description: "风格预设: archviz(建筑效果图)/photoreal/3d/anime/watercolor/sketch/product/cinematic/none,或自定义风格描述(注入提示词)" },
aspect_ratio: { type: 'string', description: "比例预设: 1:1 / 16:9 / 9:16 / 4:3 / 3:4 / 3:2 / 2:3 / 21:9,或自定义 'W:H'(默认 1:1)" },
resolution: { type: 'string', description: "分辨率/画质档位(与 quality 同义): 720p / 1080p / 2k / 4k / 8k / 1k,或长边像素数(默认 1k)。与比例组合算出精确 W×H" },
quality: { type: 'string', description: "画质档位(=分辨率档): 720p(1280) / 1080p(1920) / 2k(2560) / 4k(3840) / 8k(7680)。Agnes 原生≤2K,4K/8K 自动 lanczos 放大" },
width: { type: 'integer', description: '直接指定宽(像素;覆盖 aspect_ratio/resolution)' },
height: { type: 'integer', description: '直接指定高(像素)' },
backend: { type: 'string', description: "后端: 'auto'(默认→Agnes) / 'local'(本地FLUX) / 'agnes'(云端)" },
model: { type: 'string', description: '强制指定模型(可选)' },
seed: { type: 'integer', description: '随机种子(可选)' },
},
required: ['prompt'],
},
};

const send = (m) => process.stdout.write(JSON.stringify(m) + '\n');
const reply = (id, result) => send({ jsonrpc: '2.0', id, result });
const replyErr = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });

// 比例预设 + 分辨率预设 + 风格预设(全部可被自定义值覆盖)。
const ASPECTS = { '1:1': [1, 1], '16:9': [16, 9], '9:16': [9, 16], '4:3': [4, 3], '3:4': [3, 4], '3:2': [3, 2], '2:3': [2, 3], '21:9': [21, 9], '2.39:1': [239, 100] };
// 画质/分辨率档位(长边像素)。quality 与 resolution 共用此表(互为别名)。
const TIERS = {
'1k': 1024, '720p': 1280, 'hd': 1280, 'standard': 1280,
'1080p': 1920, 'fhd': 1920, 'fullhd': 1920,
'1440p': 2560, '2k': 2560, 'qhd': 2560,
'4k': 3840, 'uhd': 3840, '8k': 7680,
};
const resolveEdge = (s) => { if (!s) return 0; const k = String(s).trim().toLowerCase(); return TIERS[k] || parseInt(k) || 0; };
// Agnes 原生最多约 2K(2560);超过则先出原生最大再放大。
const NATIVE_MAX_EDGE = Number(process.env.PANAI_IMG_NATIVE_MAX_EDGE || 2560);
const STYLES = {
archviz: 'photorealistic architectural visualization, professional archviz, V-Ray Corona render, realistic PBR materials, accurate global illumination, ultra detailed, 8k',
photoreal: 'photorealistic, ultra realistic, professional photography, sharp focus, high detail, natural lighting',
'3d': '3D render, Pixar / Octane style, cute, vibrant colors, soft global illumination, high detail',
anime: 'anime illustration, clean lineart, vibrant colors, cel shading',
watercolor: 'watercolor painting, soft color washes, artistic, hand-painted',
sketch: 'detailed pencil sketch, hand-drawn line art, monochrome',
product: 'product photography, studio lighting, clean seamless background, sharp, high detail',
cinematic: 'cinematic, dramatic lighting, shallow depth of field, color graded, film still',
};
const round8 = (n) => Math.max(64, Math.round(n / 8) * 8);
function computeSize(args) {
if (args.width && args.height) return { W: round8(args.width), H: round8(args.height) };
const ar = String(args.aspect_ratio || '1:1').trim();
let [aw, ah] = ASPECTS[ar] || ar.split(/[:x×]/).map(Number);
if (!aw || !ah) { aw = 1; ah = 1; }
const L = resolveEdge(args.resolution) || resolveEdge(args.quality) || 1024;
let W, H;
if (aw >= ah) { W = L; H = L * ah / aw; } else { H = L; W = L * aw / ah; }
return { W: round8(W), H: round8(H) };
}
// 把目标尺寸夹到后端原生可出的最大边(超出部分留给放大)。
function capToNative(W, H) {
const m = Math.max(W, H);
if (m <= NATIVE_MAX_EDGE) return { W, H, up: false };
const s = NATIVE_MAX_EDGE / m;
return { W: round8(W * s), H: round8(H * s), up: true };
}
function applyStyle(prompt, style) {
if (!style || String(style).toLowerCase() === 'none') return prompt;
const mod = STYLES[String(style).toLowerCase()] || style; // 未知预设当自定义风格描述注入
return `${prompt}, ${mod}`;
}

// 调 engine_server 文生图端点,返回临时 png 路径。
async function callEngine({ prompt, negative, width, height, model, providerHint, seed }, ts) {
const parameters = {};
if (width) parameters.width = width;
if (height) parameters.height = height; // engine_server 会拼成 size=WxH 传 Agnes(原生出图)
if (negative) parameters.negativePrompt = negative;
if (seed != null) parameters.seed = seed;
const body = { prompt, model, ...(providerHint ? { providerHint } : {}), parameters };
const r = await fetch(`${BASE}/generate/text-to-image`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
const txt = await r.text();
if (!r.ok) throw new Error(`HTTP ${r.status}: ${txt.slice(0, 300)}`);
let j = {}; try { j = JSON.parse(txt); } catch { throw new Error(`非 JSON 响应: ${txt.slice(0, 200)}`); }
// 兼容 artifacts[] 与顶层两种结构
const art = (j.artifacts && j.artifacts[0]) || j;
const b64 = art.base64 || art.contentBase64 || art.b64_json || j.contentBase64;
const out = join(tmpdir(), `panai_img_${ts}.png`);
if (b64) { writeFileSync(out, Buffer.from(b64, 'base64')); return out; }
const url = art.url || art.downloadUrl || j.downloadUrl;
if (url && /^https?:/.test(url)) {
const buf = Buffer.from(await (await fetch(url)).arrayBuffer());
writeFileSync(out, buf); return out;
}
throw new Error(`响应无图片数据: ${txt.slice(0, 200)}`);
}

// 裁到精确尺寸:等比放大铺满后中心裁剪(cover),不变形。
function resizeCover(png, W, H, ts) {
const out = join(OUTPUT_DIR, `panai_img_${ts}.png`);
try { mkdirSync(OUTPUT_DIR, { recursive: true }); } catch {}
execSync(`ffmpeg -y -i '${png}' -vf "scale=${W}:${H}:force_original_aspect_ratio=increase:flags=lanczos,crop=${W}:${H}" '${out}' 2>/dev/null`);
return out;
}

function planBackend(args) {
// auto:默认 Agnes 云端(~10s,快而稳;精确尺寸交给后处理 ffmpeg 裁剪保证)。
// 本地 FLUX 仅在显式 backend=local 时用(高分辨率较慢)。
const b = args.backend || 'auto';
if (b === 'local' || b === 'comfyui' || b === 'huggingface') return { model: args.model || LOCAL_MODEL, providerHint: 'huggingface' };
return { model: args.model || AGNES_MODEL };
}

// 落盘到精确 W×H:后端已原生出对则直接 copy,否则 cover-crop 保证。
function ensureSize(png, W, H, ts) {
try { mkdirSync(OUTPUT_DIR, { recursive: true }); } catch {}
try {
const wh = execSync(`ffprobe -v error -show_entries stream=width,height -of csv=p=0 '${png}'`).toString().trim();
const [aw, ah] = wh.split(',').map(Number);
if (aw === W && ah === H) { const out = join(OUTPUT_DIR, `panai_img_${ts}.png`); execSync(`cp '${png}' '${out}'`); return out; }
} catch {}
return resizeCover(png, W, H, ts);
}

// Real-ESRGAN 真超分(经 ComfyUI),再降采样到精确目标 W×H。
async function comfyUpscale(srcPng, W, H, ts) {
const tk = (() => { try { return readFileSync(COMFY_TOKEN_FILE, 'utf8').split('\n')[0].trim(); } catch { return ''; } })();
if (!tk) throw new Error('ComfyUI token 缺失');
const name = `panai_up_src_${ts}.png`;
execSync(`cp '${srcPng}' '${COMFY_INPUT_DIR}/${name}'`);
const q = `?token=${tk}`;
const wf = { prompt: {
'1': { class_type: 'LoadImage', inputs: { image: name } },
'2': { class_type: 'UpscaleModelLoader', inputs: { model_name: COMFY_UPSCALE_MODEL } },
'3': { class_type: 'ImageUpscaleWithModel', inputs: { upscale_model: ['2', 0], image: ['1', 0] } },
'4': { class_type: 'SaveImage', inputs: { images: ['3', 0], filename_prefix: 'upscale/panai_up' } },
} };
const sub = await (await fetch(`${COMFY_BASE}/prompt${q}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(wf) })).json();
const pid = sub.prompt_id;
if (!pid) throw new Error('超分提交失败: ' + JSON.stringify(sub).slice(0, 200));
const t0 = Date.now();
while (Date.now() - t0 < 5 * 60 * 1000) {
await new Promise((r) => setTimeout(r, 3000));
const h = await (await fetch(`${COMFY_BASE}/history/${pid}${q}`).catch(() => ({ json: () => ({}) }))).json().catch(() => ({}));
const rec = h[pid]; if (!rec) continue;
const st = rec.status || {};
if (st.status_str === 'error') throw new Error('超分错误: ' + JSON.stringify(st.messages || st).slice(0, 200));
if (st.completed || st.status_str === 'success') {
for (const o of Object.values(rec.outputs || {})) for (const im of (o.images || [])) {
const big = `${OUTPUT_DIR}/${im.subfolder ? im.subfolder + '/' : ''}${im.filename}`;
const out = join(OUTPUT_DIR, `panai_img_${ts}.png`);
execSync(`ffmpeg -y -i '${big}' -vf "scale=${W}:${H}:force_original_aspect_ratio=increase:flags=lanczos,crop=${W}:${H}" '${out}' 2>/dev/null`);
return out;
}
throw new Error('超分完成但无输出图');
}
}
throw new Error('超分超时');
}

async function generateImage(args) {
if (!args.prompt) throw new Error('prompt 必填');
const ts = Date.now();
const target = computeSize(args); // 目标尺寸(可达 4K/8K)
const gen = capToNative(target.W, target.H); // 后端实际出图尺寸(夹到原生上限)
const styledPrompt = applyStyle(args.prompt, args.style);
const callArgs = { ...args, prompt: styledPrompt, width: gen.W, height: gen.H };
const primary = planBackend(args);
const alt = primary.providerHint
? { model: AGNES_MODEL } // 本地失败 → 兜底 Agnes
: { model: LOCAL_MODEL, providerHint: 'huggingface' }; // Agnes 失败 → 兜底本地
let png, used, note;
try { png = await callEngine({ ...callArgs, ...primary }, ts); used = primary.model; }
catch (e1) {
try { png = await callEngine({ ...callArgs, ...alt }, ts); used = alt.model; note = `首选失败: ${e1.message}`; }
catch (e2) { throw new Error(`两后端都失败 — ${primary.model}: ${e1.message} | ${alt.model}: ${e2.message}`); }
}
let path, upInfo = '';
if (gen.up) {
// 超 2K:Real-ESRGAN 真超分 → 降采样到精确目标;超分不可用则 lanczos 兜底。
try { path = await comfyUpscale(png, target.W, target.H, ts); upInfo = `Real-ESRGAN x4 真超分(原生 ${gen.W}x${gen.H} → ${target.W}x${target.H})`; }
catch (eU) { path = resizeCover(png, target.W, target.H, ts); upInfo = `超分不可用转 lanczos 放大(${eU.message})`; }
} else {
path = ensureSize(png, target.W, target.H, ts);
}
if (upInfo) note = [note, upInfo].filter(Boolean).join('; ');
return { path, model: used, size: `${target.W}x${target.H}`, style: args.style || 'none', note };
}

let buf = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (c) => { buf += c; let nl; while ((nl = buf.indexOf('\n')) >= 0) { const l = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1); if (l) handle(l); } });

async function handle(line) {
let msg; try { msg = JSON.parse(line); } catch { return; }
const { id, method, params } = msg;
try {
if (method === 'initialize') reply(id, { protocolVersion: params?.protocolVersion || '2024-11-05', capabilities: { tools: {} }, serverInfo: SERVER_INFO });
else if (method === 'notifications/initialized') { /* no reply */ }
else if (method === 'tools/list') reply(id, { tools: [TOOL] });
else if (method === 'tools/call') {
if (params?.name !== 'panai_image_generation') return replyErr(id, -32601, `unknown tool: ${params?.name}`);
const res = await generateImage(params?.arguments || {});
const txt = `图片已生成(${res.model} · ${res.size} · 风格:${res.style}):\n${res.path}` + (res.note ? '\n注:' + res.note : '');
reply(id, { content: [{ type: 'text', text: txt }] });
} else if (method === 'ping') reply(id, {});
else if (id != null) replyErr(id, -32601, `method not found: ${method}`);
} catch (e) {
if (id != null) reply(id, { content: [{ type: 'text', text: `图片生成出错: ${e.message}` }], isError: true });
}
}
Loading
Loading