UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

281 lines • 11.3 kB
import { readFile } from "node:fs/promises"; import { isAbsolute, resolve, sep } from "node:path"; import { requireSharp } from "./sharp-loader.js"; import { AssetRegistry } from "./asset-registry.js"; import { getOutputImage } from "./image-management.js"; import { resolveOutputDir } from "./output-dir.js"; import { ValidationError } from "../utils/errors.js"; // Heuristic thresholds (8-bit). Rough but practical; tuned for video frames. const TH = { liftedBlack: 16, // blackPoint above this = blacks not reaching 0 dimWhite: 235, // whitePoint below this = highlights don't reach top lowContrast: 45, // luma std below this = flat lowSaturation: 0.22, // mean HSV saturation below this = desaturated colorCast: 12, // spread between channel means above this = cast }; async function resolveBytes(opts) { if (opts.asset_id) { const record = AssetRegistry.get(opts.asset_id); if (!record) { throw new ValidationError(`No asset found for id "${opts.asset_id}". It may have expired or never been registered.`); } const validType = record.type === "output" || record.type === "input" || record.type === "temp"; const fetchType = validType ? record.type : "output"; const image = await getOutputImage(record.filename, fetchType, record.subfolder); return Buffer.from(image.base64, "base64"); } if (opts.filename) { const image = await getOutputImage(opts.filename, opts.type ?? "output", opts.subfolder ?? ""); return Buffer.from(image.base64, "base64"); } if (opts.path) { return readFile(await resolveSafePath(opts.path)); } throw new ValidationError('get_image (action:"analyze_color") requires one of: asset_id, filename (+optional subfolder/type), or path.'); } // Allow an absolute path anywhere readable, or a path under the ComfyUI output // dir. Reject parent-dir escapes on relative inputs. async function resolveSafePath(path) { if (path.trim().length === 0) { throw new ValidationError("path must be a non-empty string."); } if (isAbsolute(path)) return resolve(path); const outputDir = await resolveOutputDir(); const resolved = resolve(outputDir, path); if (resolved !== outputDir && !resolved.startsWith(outputDir + sep)) { throw new ValidationError("relative path must stay within the ComfyUI output directory."); } return resolved; } async function toRaw(bytes) { // #2411 — loaded HERE, not at module scope. A blocked libvips used to throw // while this file evaluated, which took every tool registration down with it. const sharp = await requireSharp("Colour analysis"); const { data, info } = await sharp(bytes, { limitInputPixels: 100_000_000 }) .removeAlpha() .toColourspace("srgb") .raw() .toBuffer({ resolveWithObject: true }); return { data, width: info.width, height: info.height, channels: info.channels }; } function percentile(hist, total, p) { if (total === 0) return 0; const target = p * total; let cum = 0; for (let i = 0; i < 256; i++) { cum += hist[i]; if (cum >= target) return i; } return 255; } export function computeStats(raw) { const { data, width, height, channels } = raw; const stride = channels; const pixelCount = width * height; const lumaHist = new Float64Array(256); let rSum = 0; let gSum = 0; let bSum = 0; let satSum = 0; let chromaSum = 0; for (let i = 0; i < data.length; i += stride) { const r = data[i]; const g = stride >= 3 ? data[i + 1] : r; const b = stride >= 3 ? data[i + 2] : r; rSum += r; gSum += g; bSum += b; // Rec.709 luma const luma = Math.round(0.2126 * r + 0.7152 * g + 0.0722 * b); lumaHist[luma > 255 ? 255 : luma]++; const max = Math.max(r, g, b); const min = Math.min(r, g, b); const chroma = max - min; chromaSum += chroma; satSum += max === 0 ? 0 : chroma / max; } // luma mean + std let lumaSum = 0; for (let v = 0; v < 256; v++) lumaSum += v * lumaHist[v]; const lumaMean = lumaSum / pixelCount; let varSum = 0; for (let v = 0; v < 256; v++) { const d = v - lumaMean; varSum += d * d * lumaHist[v]; } const lumaStd = Math.sqrt(varSum / pixelCount); const blackPoint = percentile(lumaHist, pixelCount, 0.01); const whitePoint = percentile(lumaHist, pixelCount, 0.99); const median = percentile(lumaHist, pixelCount, 0.5); let clippedLow = 0; let clippedHigh = 0; for (let v = 0; v <= 2; v++) clippedLow += lumaHist[v]; for (let v = 253; v <= 255; v++) clippedHigh += lumaHist[v]; const rMean = rSum / pixelCount; const gMean = gSum / pixelCount; const bMean = bSum / pixelCount; const meanSaturation = satSum / pixelCount; const meanChroma = chromaSum / pixelCount; const channelSpread = Math.max(rMean, gMean, bMean) - Math.min(rMean, gMean, bMean); let castHint = "neutral"; if (channelSpread > TH.colorCast) { const top = Math.max(rMean, gMean, bMean); castHint = top === rMean ? "warm (red-heavy)" : top === bMean ? "cool (blue-heavy)" : "green-heavy"; } const lowContrast = lumaStd < TH.lowContrast; const liftedBlacks = blackPoint > TH.liftedBlack; const dimHighlights = whitePoint < TH.dimWhite; const lowSaturation = meanSaturation < TH.lowSaturation; const colorCast = channelSpread > TH.colorCast; const washedOut = lowContrast && (liftedBlacks || lowSaturation || dimHighlights); const round = (n, d = 1) => Number(n.toFixed(d)); const issues = []; if (lowContrast) issues.push(`low contrast (std ${round(lumaStd)})`); if (liftedBlacks) issues.push(`lifted blacks (black point ${blackPoint})`); if (dimHighlights) issues.push(`dim highlights (white point ${whitePoint})`); if (lowSaturation) issues.push(`low saturation (${round(meanSaturation, 2)})`); if (colorCast) issues.push(`${castHint} cast`); const verdict = washedOut ? `WASHED OUT — ${issues.join(", ")}` : issues.length ? `OK with notes: ${issues.join(", ")}` : "Color looks healthy (good contrast, full range, saturated)"; return { width, height, luma: { mean: round(lumaMean), median, contrast: round(lumaStd), blackPoint, whitePoint, dynamicRange: whitePoint - blackPoint, clippedLowPct: round((clippedLow / pixelCount) * 100, 2), clippedHighPct: round((clippedHigh / pixelCount) * 100, 2), }, saturation: { meanSaturation: round(meanSaturation, 3), meanChroma: round(meanChroma), }, channels: { rMean: round(rMean), gMean: round(gMean), bMean: round(bMean), castHint, }, flags: { washedOut, lowContrast, liftedBlacks, dimHighlights, lowSaturation, colorCast, }, verdict, }; } // Render a compact overlaid R/G/B/luma histogram PNG for visual confirmation. async function renderHistogram(raw) { const { data, channels } = raw; const stride = channels; const W = 256; const H = 200; const rHist = new Float64Array(256); const gHist = new Float64Array(256); const bHist = new Float64Array(256); const lHist = new Float64Array(256); for (let i = 0; i < data.length; i += stride) { const r = data[i]; const g = stride >= 3 ? data[i + 1] : r; const b = stride >= 3 ? data[i + 2] : r; rHist[r]++; gHist[g]++; bHist[b]++; const luma = Math.min(255, Math.round(0.2126 * r + 0.7152 * g + 0.0722 * b)); lHist[luma]++; } const maxBin = Math.max(1, ...rHist, ...gHist, ...bHist, ...lHist); const buf = Buffer.alloc(W * H * 3); // dark background for (let i = 0; i < buf.length; i += 3) { buf[i] = 18; buf[i + 1] = 18; buf[i + 2] = 26; } const draw = (hist, cr, cg, cb) => { for (let x = 0; x < W; x++) { const h = Math.round((hist[x] / maxBin) * (H - 1)); for (let y = H - 1; y >= H - h; y--) { const idx = (y * W + x) * 3; // max-blend so overlaid curves stay visible buf[idx] = Math.max(buf[idx], cr); buf[idx + 1] = Math.max(buf[idx + 1], cg); buf[idx + 2] = Math.max(buf[idx + 2], cb); } } }; draw(lHist, 120, 120, 130); // luma (gray, drawn first/behind) draw(rHist, 220, 60, 60); draw(gHist, 60, 200, 80); draw(bHist, 80, 130, 240); const sharp = await requireSharp("Colour analysis"); const png = await sharp(buf, { raw: { width: W, height: H, channels: 3 } }) .png() .toBuffer(); return { data: png.toString("base64"), mimeType: "image/png" }; } function deltaReport(target, ref) { const d = (a, b) => { const v = Number((a - b).toFixed(1)); return v > 0 ? `+${v}` : `${v}`; }; return [ "Shot-match delta (target − reference):", ` contrast ${d(target.luma.contrast, ref.luma.contrast)} | ` + `black point ${d(target.luma.blackPoint, ref.luma.blackPoint)} | ` + `white point ${d(target.luma.whitePoint, ref.luma.whitePoint)}`, ` saturation ${d(target.saturation.meanSaturation, ref.saturation.meanSaturation)} | ` + `R ${d(target.channels.rMean, ref.channels.rMean)} ` + `G ${d(target.channels.gMean, ref.channels.gMean)} ` + `B ${d(target.channels.bMean, ref.channels.bMean)}`, ].join("\n"); } export async function analyzeColor(opts) { const raw = await toRaw(await resolveBytes(opts)); const stats = computeStats(raw); const content = []; // This header used to be the TOOL's name, which 0.50.0 slice 15 retired. It is // a label on a SUCCESS response, not remedy guidance, so it does not want to be // a call form (`get_image (action:"analyze_color") — 1024x1024` reads as an // instruction where none is wanted). It says what the block is instead — the // same information the tool name carried, minus a name that would 404. Every // other field of the response is unchanged. let summary = `Color analysis — ${stats.width}x${stats.height}\n${stats.verdict}\n\n` + JSON.stringify(stats, null, 2); if (opts.reference_path) { const refRaw = await toRaw(await readFile(await resolveSafePath(opts.reference_path))); const refStats = computeStats(refRaw); summary += `\n\n${deltaReport(stats, refStats)}`; } content.push({ type: "text", text: summary }); if (opts.histogram) { const hist = await renderHistogram(raw); content.push({ type: "image", data: hist.data, mimeType: hist.mimeType }); } return { content }; } //# sourceMappingURL=color-analysis.js.map