From 4ccfbca0d315ddfdd684f70f08d87d1dd9e218f9 Mon Sep 17 00:00:00 2001 From: insome Date: Tue, 16 Jun 2026 09:52:19 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(panai-media):=20=E8=A7=86=E9=A2=91/?= =?UTF-8?q?=E5=9B=BE=E7=89=87=20MCP=20=E2=80=94=20Agnes=20=E4=BC=98?= =?UTF-8?q?=E5=85=88=E4=BA=91=E7=AB=AF=E9=95=BF=E7=89=87=20+=20=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E6=8C=89=E9=9C=80=E5=90=8E=E7=AB=AF=E5=B0=BA=E5=AF=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 视频 MCP(panai_video_gen_mcp.mjs): - 策略改为 Agnes 云端优先、默认 10s/1080p;仅 Agnes 失败才退本机 ComfyUI 安全档 - Agnes 多段 5s + 末帧图生续接 + ffmpeg 拼接出长片(本机原生1080p/长片会OOM撑崩) - 嵌套轮询(data.{status,progress})+ 0% 停滞守卫 + create/poll 重试 + 显式 resolution/size - 新增参数 resolution/duration_seconds/backend;本机降级安全档(≤73帧/768×512)避免崩机 图片 MCP(panai_image_gen_mcp.mjs,新):内置 panai_image_generation drop-in 升级 - 按需选后端(auto→Agnes/local→FLUX)+ 给宽高则 ffmpeg cover-crop 到精确尺寸 - 经 engine_server 7071 多 provider 出图;失败兜底另一后端 Co-Authored-By: Claude Opus 4.8 --- tools/panai_image_gen_mcp.mjs | 130 +++++++++++++++++++++++++++ tools/panai_video_gen_mcp.mjs | 162 ++++++++++++++++++++++++++++------ 2 files changed, 265 insertions(+), 27 deletions(-) create mode 100644 tools/panai_image_gen_mcp.mjs diff --git a/tools/panai_image_gen_mcp.mjs b/tools/panai_image_gen_mcp.mjs new file mode 100644 index 0000000..b8e07cf --- /dev/null +++ b/tools/panai_image_gen_mcp.mjs @@ -0,0 +1,130 @@ +#!/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 { 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'; + +const SERVER_INFO = { name: 'panai-image-generation', version: '2.0.0' }; +const TOOL = { + // 与内置工具同名,作为 drop-in 升级:新增按需选后端 + 精确尺寸。 + name: 'panai_image_generation', + description: '生成图片的必用工具(用户想生成/制作一张图片时调用)。按需选后端:本地 FLUX(任意尺寸、无需联网)或 Agnes 云端;给定宽高时生成后裁到精确尺寸。返回图片文件路径。', + inputSchema: { + type: 'object', + properties: { + prompt: { type: 'string', description: '图片内容描述(英文效果最好)' }, + negative: { type: 'string', description: '不希望出现的内容(可选)' }, + width: { type: 'integer', description: '宽(像素;给了宽高会裁到精确尺寸)' }, + height: { type: 'integer', description: '高(像素)' }, + aspect_ratio: { type: 'string', description: "宽高比,如 '16:9' / '1:1'(未给精确宽高时用)" }, + backend: { type: 'string', description: "后端: 'auto'(默认) / '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 } }); + +// 调 engine_server 文生图端点,返回临时 png 路径。 +async function callEngine({ prompt, negative, width, height, aspect_ratio, model, providerHint, seed }, ts) { + const parameters = {}; + if (width) parameters.width = width; + if (height) parameters.height = height; + if (aspect_ratio) parameters.aspectRatio = aspect_ratio; + 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,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 }; +} + +async function generateImage(args) { + if (!args.prompt) throw new Error('prompt 必填'); + const ts = Date.now(); + 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({ ...args, ...primary }, ts); used = primary.model; } + catch (e1) { + try { png = await callEngine({ ...args, ...alt }, ts); used = alt.model; note = `首选失败: ${e1.message}`; } + catch (e2) { throw new Error(`两后端都失败 — ${primary.model}: ${e1.message} | ${alt.model}: ${e2.message}`); } + } + let path = png; + if (args.width && args.height) path = resizeCover(png, args.width, args.height, ts); + else { path = join(OUTPUT_DIR, `panai_img_${ts}.png`); try { mkdirSync(OUTPUT_DIR, { recursive: true }); } catch {} execSync(`cp '${png}' '${path}'`); } + return { path, model: used, 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}):\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 }); + } +} diff --git a/tools/panai_video_gen_mcp.mjs b/tools/panai_video_gen_mcp.mjs index 3463a1f..ef73148 100644 --- a/tools/panai_video_gen_mcp.mjs +++ b/tools/panai_video_gen_mcp.mjs @@ -8,7 +8,10 @@ // COMFY_TENC = gemma_3_12B_it_fp4_mixed.safetensors // 兜底 Agnes: AGNES_API_KEY / AGNES_BASE_URL / AGNES_VIDEO_MODEL -import { readFileSync } from 'node:fs'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { execSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; 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'; @@ -18,19 +21,25 @@ const AGNES_KEY = process.env.AGNES_API_KEY || ''; const AGNES_BASE = (process.env.AGNES_BASE_URL || 'https://apihub.agnes-ai.com/v1').replace(/\/$/, ''); const AGNES_MODEL = process.env.AGNES_VIDEO_MODEL || 'agnes-video-v2.0'; -const SERVER_INFO = { name: 'panai-video-generation', version: '3.0.0' }; +const AGNES_SEG_SECONDS = Number(process.env.AGNES_SEG_SECONDS || 5); // Agnes 单段时长(约5s) +const OUTPUT_DIR = process.env.PANAI_VIDEO_OUTPUT_DIR || '/home/insome/ComfyUI/output/video'; + +const SERVER_INFO = { name: 'panai-video-generation', version: '3.1.0' }; const TOOL = { name: 'generate_video', - description: '文生视频。首选本地 ComfyUI/LTX-2.3(GPU,无需联网),失败自动兜底 Agnes 云端。返回生成的视频文件路径/URL。用户想生成/制作一段视频时调用。', + description: '文生视频。默认 Agnes 云端、10秒、1080p(云端用「多段5s+末帧续接」拼成长片,本机跑长片/1080p会OOM)。仅当 Agnes 失败才退回本机 ComfyUI 安全档(短片)。backend=comfyui 可强制本机。返回视频文件路径/URL。用户想生成/制作一段视频时调用。', inputSchema: { type: 'object', properties: { prompt: { type: 'string', description: '视频内容描述(英文效果最好)' }, negative: { type: 'string', description: '不希望出现的内容(可选)' }, - width: { type: 'integer', description: '宽(默认768)' }, - height: { type: 'integer', description: '高(默认512)' }, - length: { type: 'integer', description: '帧数(默认73,约3秒@25fps)' }, - steps: { type: 'integer', description: '采样步数(默认20)' }, + width: { type: 'integer', description: '宽(默认768;本地)' }, + height: { type: 'integer', description: '高(默认512;本地)' }, + length: { type: 'integer', description: '帧数(默认73,约3秒@25fps;本地)' }, + duration_seconds: { type: 'integer', description: '目标时长(秒)。>5 时云端自动多段续接拼接' }, + resolution: { type: 'string', description: "分辨率,如 '1080p' / '720p'(云端 Agnes 用;1080p 自动走云端)" }, + backend: { type: 'string', description: "强制后端: 'auto'(默认) / 'comfyui'(本地) / 'agnes'(云端)" }, + steps: { type: 'integer', description: '采样步数(默认20;本地)' }, seed: { type: 'integer', description: '随机种子(可选)' }, }, required: ['prompt'], @@ -103,35 +112,132 @@ async function comfyGenerate(args) { throw new Error('ComfyUI 生成超时(20分钟)'); } -async function agnesGenerate({ prompt }) { - if (!AGNES_KEY) throw new Error('Agnes 未配置'); - const H = { Authorization: `Bearer ${AGNES_KEY}`, 'Content-Type': 'application/json' }; - const sub = await (await fetch(`${AGNES_BASE}/video/generations`, { method: 'POST', headers: H, body: JSON.stringify({ model: AGNES_MODEL, prompt }) })).json(); - const tid = sub.task_id || sub.id; - if (!tid) throw new Error(`Agnes 提交失败: ${JSON.stringify(sub).slice(0, 200)}`); +const AGNES_H = () => ({ Authorization: `Bearer ${AGNES_KEY}`, 'Content-Type': 'application/json' }); +const VURL_RE = /https?:\/\/[^\s"']+\.(?:mp4|mov|webm)/i; + +// Agnes 轮询返回是嵌套结构 {data:{status,progress,...}};停滞守卫避免排队卡死空等。 +async function agnesPoll(tid, label) { const t0 = Date.now(); - const re = /https?:\/\/[^\s"']+\.(?:mp4|mov|webm)/i; + let bestP = 0, lastChange = t0; while (Date.now() - t0 < 20 * 60 * 1000) { await sleep(12000); - const txt = await (await fetch(`${AGNES_BASE}/video/generations/${tid}`, { headers: H })).text(); - const m = txt.match(re); - if (m) return { url: m[0] }; - if (/FAIL|ERROR/i.test(txt)) throw new Error('Agnes 生成失败'); + const txt = await (await fetch(`${AGNES_BASE}/video/generations/${tid}`, { headers: AGNES_H() }).catch(() => ({ text: () => '' }))).text(); + let j = {}; try { j = JSON.parse(txt); } catch {} + const dd = j.data || {}; + const st = String(dd.status || j.status || '').toLowerCase(); + let pg = dd.progress; if (typeof pg === 'string') pg = parseInt(pg) || 0; if (pg == null) pg = (dd.data && dd.data.progress) || 0; + if (pg > bestP) { bestP = pg; lastChange = Date.now(); } + const m = txt.match(VURL_RE); + if (m) return m[0]; + if (/fail|error|cancel|expire|reject/.test(st)) throw new Error(`Agnes ${label} 失败: ${txt.slice(0, 200)}`); + if (Date.now() - lastChange > 360000) throw new Error(`Agnes ${label} 排队停滞 ${bestP}% 超 360s (taskId ${tid})`); + } + throw new Error(`Agnes ${label} 超时 20min`); +} + +async function agnesSubmit(body) { + const r = await (await fetch(`${AGNES_BASE}/video/generations`, { method: 'POST', headers: AGNES_H(), body: JSON.stringify(body) })).json(); + const tid = r.task_id || r.id; + if (!tid) throw new Error(`Agnes 提交失败: ${JSON.stringify(r).slice(0, 200)}`); + return tid; +} + +async function agnesDownload(url, out) { + const hh = { Accept: '*/*' }; + if (/agnes-ai\.com/.test(url)) hh.Authorization = `Bearer ${AGNES_KEY}`; + const buf = Buffer.from(await (await fetch(url, { headers: hh })).arrayBuffer()); + writeFileSync(out, buf); + return buf.length; +} + +function aspectFrom({ width, height }) { + if (width && height) { + const g = (a, b) => b ? g(b, a % b) : a; const d = g(width, height); + return `${width / d}:${height / d}`; } - throw new Error('Agnes 超时'); + return '16:9'; +} +function lastFrameDataUrl(mp4) { + const png = mp4.replace(/\.mp4$/, '_last.png'); + execSync(`ffmpeg -y -sseof -0.12 -i '${mp4}' -update 1 -frames:v 1 '${png}' 2>/dev/null`); + return 'data:image/png;base64,' + readFileSync(png).toString('base64'); +} + +// Agnes 云端:单段或「多段5s+末帧续接」出长片/1080p。 +async function agnesGenerate(args) { + if (!AGNES_KEY) throw new Error('Agnes 未配置'); + const { prompt } = args; + const aspect = aspectFrom(args); + const resolution = args.resolution || ((args.width >= 1920 || args.height >= 1080) ? '1080p' : undefined); + const size = (args.width && args.height) ? `${args.width}x${args.height}` : (resolution === '1080p' ? '1920x1080' : undefined); + const durSec = args.duration_seconds || (args.length ? Math.round(args.length / 25) : AGNES_SEG_SECONDS); + const segCount = Math.max(1, Math.ceil(durSec / AGNES_SEG_SECONDS)); + const base = { model: AGNES_MODEL, prompt, aspect_ratio: aspect, ...(resolution ? { resolution } : {}), ...(size ? { size } : {}) }; + const ts = Date.now(); + const segFiles = []; + for (let i = 1; i <= segCount; i++) { + const body = { ...base }; + if (i > 1) body.images = [lastFrameDataUrl(segFiles[i - 2])]; // 末帧续接 + const tid = await agnesSubmit(body); + const url = await agnesPoll(tid, `第${i}/${segCount}段`); + const out = join(tmpdir(), `panai_agnes_${ts}_${i}.mp4`); + await agnesDownload(url, out); + segFiles.push(out); + } + const resLabel = resolution || (size ? size : '默认720p'); + if (segCount === 1) return { url: null, path: segFiles[0], res: resLabel }; + // ffmpeg 拼接,丢掉续接处重复首帧 + const final = join(OUTPUT_DIR, `panai_agnes_${ts}.mp4`); + const inputs = segFiles.map((f) => `-i '${f}'`).join(' '); + const trims = segFiles.slice(1).map((_, k) => `[${k + 1}:v]trim=start_frame=1,setpts=PTS-STARTPTS[v${k + 1}]`).join(';'); + const chain = '[0:v]' + segFiles.slice(1).map((_, k) => `[v${k + 1}]`).join('') + `concat=n=${segFiles.length}:v=1:a=0[out]`; + execSync(`ffmpeg -y ${inputs} -filter_complex "${trims};${chain}" -map "[out]" -c:v libx264 -pix_fmt yuv420p -crf 18 '${final}' 2>/dev/null`); + return { url: null, path: final, segments: segCount, res: resLabel }; } +// 本机 ComfyUI 安全档:实测本机崩溃看「帧数」(最终合成一次性持有全部帧), +// 只稳 ≤~3s(73帧)/中小尺寸;长片或 1080p 必 OOM 撑崩。兜底时降级到此档避免崩机。 +const COMFY_SAFE_MAX_FRAMES = Number(process.env.COMFY_SAFE_MAX_FRAMES || 73); +const COMFY_SAFE_MAX_W = Number(process.env.COMFY_SAFE_MAX_W || 768); +const COMFY_SAFE_MAX_H = Number(process.env.COMFY_SAFE_MAX_H || 512); +async function comfyGenerateSafe(args) { + const reqLen = args.length || Math.round((args.duration_seconds || 3) * 25); + const safe = { + ...args, + width: Math.min(args.width || COMFY_SAFE_MAX_W, COMFY_SAFE_MAX_W), + height: Math.min(args.height || COMFY_SAFE_MAX_H, COMFY_SAFE_MAX_H), + length: Math.min(reqLen, COMFY_SAFE_MAX_FRAMES), + }; + const degraded = safe.length < reqLen || (args.width && args.width > COMFY_SAFE_MAX_W) || (args.height && args.height > COMFY_SAFE_MAX_H) || args.resolution === '1080p'; + const r = await comfyGenerate(safe); + return { ...r, degraded, safe: { width: safe.width, height: safe.height, length: safe.length } }; +} + +// 策略:Agnes 云端优先(默认 10s / 1080p);仅当 Agnes 失败才退回本机 ComfyUI, +// 且本机降级到安全档(避免长片/1080p 撑崩机器)。backend='comfyui' 可强制本机(同样走安全档)。 async function generateVideo(args) { if (!args.prompt) throw new Error('prompt 必填'); + // 默认 10s、1080p(未显式指定尺寸/时长时) + if (args.duration_seconds == null && args.length == null) args.duration_seconds = 10; + if (args.resolution == null && !args.width && !args.height) args.resolution = '1080p'; + + if (args.backend === 'comfyui') { + const r = await comfyGenerateSafe(args); + const note = r.degraded ? `本机只能稳跑短片,已降级为 ${r.safe.width}x${r.safe.height}×${r.safe.length}帧(长片/1080p 请用 Agnes)` : undefined; + return { backend: 'ComfyUI/LTX-2.3(本地·安全档)', note, ...r }; + } + // Agnes 优先 try { - const r = await comfyGenerate(args); - return { backend: 'ComfyUI/LTX-2.3(本地)', ...r }; - } catch (e1) { + const r = await agnesGenerate(args); + return { backend: r.segments ? `Agnes云端(${r.segments}段续接·${r.res || ''})` : `Agnes云端(${r.res || ''})`, ...r }; + } catch (eA) { + if (args.backend === 'agnes') throw eA; // 显式只要 Agnes,不退本机 try { - const r = await agnesGenerate(args); - return { backend: 'Agnes(兜底)', note: `本地失败: ${e1.message}`, ...r }; - } catch (e2) { - throw new Error(`两个后端都失败 — 本地LTX: ${e1.message} | Agnes兜底: ${e2.message}`); + const r = await comfyGenerateSafe(args); + const deg = r.degraded ? `(本机降级为 ${r.safe.width}x${r.safe.height}×${r.safe.length}帧)` : ''; + return { backend: `ComfyUI/LTX-2.3(本地·兜底)`, note: `Agnes 失败转本机兜底${deg}: ${eA.message}`, ...r }; + } catch (eC) { + throw new Error(`Agnes 失败: ${eA.message} | 本机兜底也失败: ${eC.message}`); } } } @@ -150,7 +256,9 @@ async function handle(line) { else if (method === 'tools/call') { if (params?.name !== 'generate_video') return replyErr(id, -32601, `unknown tool: ${params?.name}`); const res = await generateVideo(params?.arguments || {}); - const txt = `视频已生成(${res.backend}):\n${res.path || res.url}\n预览URL: ${res.url}${res.note ? '\n注:' + res.note : ''}`; + const txt = `视频已生成(${res.backend}):\n${res.path || res.url}` + + (res.url ? `\n预览URL: ${res.url}` : '') + + (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}`); From 685164159b4a5c446fcf8f97b39d5d18edfd031d Mon Sep 17 00:00:00 2001 From: insome Date: Tue, 16 Jun 2026 10:17:35 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(panai-media):=20=E5=9B=BE=E7=89=87=20M?= =?UTF-8?q?CP=20=E5=85=A8=E5=8F=82=E6=95=B0=E5=8C=96=20=E2=80=94=20?= =?UTF-8?q?=E9=A3=8E=E6=A0=BC/=E6=AF=94=E4=BE=8B/=E5=88=86=E8=BE=A8?= =?UTF-8?q?=E7=8E=87/=E7=94=BB=E8=B4=A8=E5=9D=87=E5=8F=AF=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 比例 aspect_ratio(1:1/16:9/9:16/4:3/3:2/21:9… 或自定义 W:H) - 分辨率 resolution(1k/hd/fhd/2k/4k 或长边px),computeSize 算精确 W×H(/8 取整) - 画质 quality(standard/hd 透传)、风格 style(archviz/photoreal/3d/anime… 注入提示词) - Agnes 原生出任意尺寸(传 size=WxH;aspectRatio 无效);ensureSize 仅在尺寸不符时 cover-crop 兜底 Co-Authored-By: Claude Opus 4.8 --- tools/panai_image_gen_mcp.mjs | 78 ++++++++++++++++++++++++++++------- 1 file changed, 62 insertions(+), 16 deletions(-) diff --git a/tools/panai_image_gen_mcp.mjs b/tools/panai_image_gen_mcp.mjs index b8e07cf..a6a2e8b 100644 --- a/tools/panai_image_gen_mcp.mjs +++ b/tools/panai_image_gen_mcp.mjs @@ -20,18 +20,21 @@ const OUTPUT_DIR = process.env.PANAI_IMG_OUTPUT_DIR || '/home/insome/ComfyUI/out const SERVER_INFO = { name: 'panai-image-generation', version: '2.0.0' }; const TOOL = { - // 与内置工具同名,作为 drop-in 升级:新增按需选后端 + 精确尺寸。 + // 与内置工具同名,作为 drop-in 升级:全参数化(风格/比例/分辨率/画质/后端均可自定义,不锁死)。 name: 'panai_image_generation', - description: '生成图片的必用工具(用户想生成/制作一张图片时调用)。按需选后端:本地 FLUX(任意尺寸、无需联网)或 Agnes 云端;给定宽高时生成后裁到精确尺寸。返回图片文件路径。', + description: '生成图片的必用工具(用户想生成/制作一张图片时调用)。风格/比例/分辨率/画质/后端全部可自定义。Agnes 云端原生支持任意尺寸。返回图片文件路径。', inputSchema: { type: 'object', properties: { prompt: { type: 'string', description: '图片内容描述(英文效果最好)' }, negative: { type: 'string', description: '不希望出现的内容(可选)' }, - width: { type: 'integer', description: '宽(像素;给了宽高会裁到精确尺寸)' }, - height: { type: 'integer', description: '高(像素)' }, - aspect_ratio: { type: 'string', description: "宽高比,如 '16:9' / '1:1'(未给精确宽高时用)" }, - backend: { type: 'string', description: "后端: 'auto'(默认) / 'local'(本地FLUX) / 'agnes'(云端)" }, + 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: "分辨率: 1k / 2k / 4k / hd(1280) / fhd(1920),或长边像素数(默认 1k)。与比例组合算出精确 W×H" }, + quality: { type: 'string', description: "画质: standard / hd(默认 standard;Agnes 透传,本地映射采样步数)" }, + 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: '随机种子(可选)' }, }, @@ -43,12 +46,43 @@ 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] }; +const RES_PRESETS = { '1k': 1024, 'hd': 1280, 'fhd': 1920, '2k': 2048, 'qhd': 2560, '4k': 3840 }; +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 res = String(args.resolution || '1k').trim().toLowerCase(); + const L = RES_PRESETS[res] || parseInt(res) || 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 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, aspect_ratio, model, providerHint, seed }, ts) { +async function callEngine({ prompt, negative, width, height, quality, model, providerHint, seed }, ts) { const parameters = {}; if (width) parameters.width = width; - if (height) parameters.height = height; - if (aspect_ratio) parameters.aspectRatio = aspect_ratio; + if (height) parameters.height = height; // engine_server 会拼成 size=WxH 传 Agnes(原生出图) + if (quality) parameters.quality = quality; if (negative) parameters.negativePrompt = negative; if (seed != null) parameters.seed = seed; const body = { prompt, model, ...(providerHint ? { providerHint } : {}), parameters }; @@ -87,23 +121,35 @@ function planBackend(args) { 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); +} + async function generateImage(args) { if (!args.prompt) throw new Error('prompt 必填'); const ts = Date.now(); + const { W, H } = computeSize(args); + const styledPrompt = applyStyle(args.prompt, args.style); + const callArgs = { ...args, prompt: styledPrompt, width: W, height: 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({ ...args, ...primary }, ts); used = primary.model; } + try { png = await callEngine({ ...callArgs, ...primary }, ts); used = primary.model; } catch (e1) { - try { png = await callEngine({ ...args, ...alt }, ts); used = alt.model; note = `首选失败: ${e1.message}`; } + 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 = png; - if (args.width && args.height) path = resizeCover(png, args.width, args.height, ts); - else { path = join(OUTPUT_DIR, `panai_img_${ts}.png`); try { mkdirSync(OUTPUT_DIR, { recursive: true }); } catch {} execSync(`cp '${png}' '${path}'`); } - return { path, model: used, note }; + const path = ensureSize(png, W, H, ts); + return { path, model: used, size: `${W}x${H}`, style: args.style || 'none', note }; } let buf = ''; @@ -120,7 +166,7 @@ async function handle(line) { 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}):\n${res.path}` + (res.note ? '\n注:' + res.note : ''); + 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}`); From 54e17c46eebbbd241b28c18328eaf0eb0e4038b9 Mon Sep 17 00:00:00 2001 From: insome Date: Tue, 16 Jun 2026 10:31:02 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(panai-media):=20=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=94=BB=E8=B4=A8=E6=A1=A3=E4=BD=8D=E8=A6=86=E7=9B=96=E5=88=B0?= =?UTF-8?q?=204K/8K(720p/1080p/2k/4k/8k)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - quality 与 resolution 同义,共用档位表 TIERS;quality 覆盖 720p/1080p/2k/4k/8k - Agnes 图像原生最多约 2K(实测 2560 可、4K/8K 报错);超 2K 自动 lanczos 放大到目标并注明 - 移除 quality 作为 Agnes 渲染参数(改为分辨率档),size cover-crop 改 lanczos Co-Authored-By: Claude Opus 4.8 --- tools/panai_image_gen_mcp.mjs | 40 ++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/tools/panai_image_gen_mcp.mjs b/tools/panai_image_gen_mcp.mjs index a6a2e8b..cf2b819 100644 --- a/tools/panai_image_gen_mcp.mjs +++ b/tools/panai_image_gen_mcp.mjs @@ -30,8 +30,8 @@ const TOOL = { 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: "分辨率: 1k / 2k / 4k / hd(1280) / fhd(1920),或长边像素数(默认 1k)。与比例组合算出精确 W×H" }, - quality: { type: 'string', description: "画质: standard / hd(默认 standard;Agnes 透传,本地映射采样步数)" }, + 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'(云端)" }, @@ -48,7 +48,16 @@ const replyErr = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code // 比例预设 + 分辨率预设 + 风格预设(全部可被自定义值覆盖)。 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] }; -const RES_PRESETS = { '1k': 1024, 'hd': 1280, 'fhd': 1920, '2k': 2048, 'qhd': 2560, '4k': 3840 }; +// 画质/分辨率档位(长边像素)。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', @@ -65,12 +74,18 @@ function computeSize(args) { 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 res = String(args.resolution || '1k').trim().toLowerCase(); - const L = RES_PRESETS[res] || parseInt(res) || 1024; + 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; // 未知预设当自定义风格描述注入 @@ -78,11 +93,10 @@ function applyStyle(prompt, style) { } // 调 engine_server 文生图端点,返回临时 png 路径。 -async function callEngine({ prompt, negative, width, height, quality, model, providerHint, seed }, ts) { +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 (quality) parameters.quality = quality; if (negative) parameters.negativePrompt = negative; if (seed != null) parameters.seed = seed; const body = { prompt, model, ...(providerHint ? { providerHint } : {}), parameters }; @@ -109,7 +123,7 @@ async function callEngine({ prompt, negative, width, height, quality, model, pro 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,crop=${W}:${H}" '${out}' 2>/dev/null`); + 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; } @@ -135,9 +149,10 @@ function ensureSize(png, W, H, ts) { async function generateImage(args) { if (!args.prompt) throw new Error('prompt 必填'); const ts = Date.now(); - const { W, H } = computeSize(args); + 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: W, height: H }; + const callArgs = { ...args, prompt: styledPrompt, width: gen.W, height: gen.H }; const primary = planBackend(args); const alt = primary.providerHint ? { model: AGNES_MODEL } // 本地失败 → 兜底 Agnes @@ -148,8 +163,9 @@ async function generateImage(args) { 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}`); } } - const path = ensureSize(png, W, H, ts); - return { path, model: used, size: `${W}x${H}`, style: args.style || 'none', note }; + const path = ensureSize(png, target.W, target.H, ts); // 原生没出对/需放大 → lanczos 到精确目标 + if (gen.up) note = [note, `原生 ${gen.W}x${gen.H} 放大到 ${target.W}x${target.H}(Agnes 原生上限~2K)`].filter(Boolean).join('; '); + return { path, model: used, size: `${target.W}x${target.H}`, style: args.style || 'none', note }; } let buf = ''; From 8cbba5e249a857fe6c66c2d4b54bc949b7505f55 Mon Sep 17 00:00:00 2001 From: insome Date: Tue, 16 Jun 2026 10:48:33 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat(panai-media):=204K/8K=20=E6=8E=A5=20Re?= =?UTF-8?q?al-ESRGAN=20=E7=9C=9F=E8=B6=85=E5=88=86(=E6=9B=BF=E4=BB=A3=20la?= =?UTF-8?q?nczos)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 超 2K 时经 ComfyUI UpscaleModelLoader+ImageUpscaleWithModel(RealESRGAN_x4plus)x4 真超分, 再 ffmpeg 降采样到精确目标(比 lanczos 清晰);超分不可用才回退 lanczos - 修 readFileSync 未 import 导致 token 读空、超分静默回退的 bug - 模型需放 ~/ComfyUI/models/upscale_models/RealESRGAN_x4plus.pth(env COMFY_UPSCALE_MODEL 可换) Co-Authored-By: Claude Opus 4.8 --- tools/panai_image_gen_mcp.mjs | 54 +++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/tools/panai_image_gen_mcp.mjs b/tools/panai_image_gen_mcp.mjs index cf2b819..327644a 100644 --- a/tools/panai_image_gen_mcp.mjs +++ b/tools/panai_image_gen_mcp.mjs @@ -8,7 +8,7 @@ // PANAI_IMG_AGNES_MODEL = agnes-image-2.1-flash (Agnes 云端默认模型) // PANAI_IMG_OUTPUT_DIR = /home/insome/ComfyUI/output (落盘目录) -import { writeFileSync, mkdirSync } from 'node:fs'; +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { execSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -17,6 +17,11 @@ const BASE = (process.env.PANAI_IMG_BASE_URL || 'http://127.0.0.1:7071/v1').repl 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 = { @@ -146,6 +151,42 @@ function ensureSize(png, W, H, ts) { 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(); @@ -163,8 +204,15 @@ async function generateImage(args) { 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}`); } } - const path = ensureSize(png, target.W, target.H, ts); // 原生没出对/需放大 → lanczos 到精确目标 - if (gen.up) note = [note, `原生 ${gen.W}x${gen.H} 放大到 ${target.W}x${target.H}(Agnes 原生上限~2K)`].filter(Boolean).join('; '); + 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 }; }