mirror of https://github.com/VERT-sh/VERT.git
feat: add MCP server for file conversion via Claude Code
Standalone stdio MCP server exposing VERT's conversion capabilities. 140+ image formats (ImageMagick WASM), 28+ audio (system ffmpeg), 11 document formats (system pandoc). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
897ae56702
commit
97cfba2acb
|
|
@ -0,0 +1,2 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"name": "@vert-sh/mcp-server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"bin": "./dist/index.js",
|
||||||
|
"files": [
|
||||||
|
"dist/"
|
||||||
|
],
|
||||||
|
"license": "AGPL-3.0-only",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/VERT-sh/VERT",
|
||||||
|
"directory": "mcp"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"prepublishOnly": "tsc"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@imagemagick/magick-wasm": "0.0.38",
|
||||||
|
"@modelcontextprotocol/sdk": "1.27.0",
|
||||||
|
"byte-data": "19.0.1",
|
||||||
|
"riff-file": "1.0.3",
|
||||||
|
"which": "^3.0.0",
|
||||||
|
"yazl": "3.3.1",
|
||||||
|
"zod": "^3.25.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22",
|
||||||
|
"@types/which": "^3",
|
||||||
|
"@types/yazl": "^2",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,355 @@
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { stat } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import which from "which";
|
||||||
|
import { FormatInfo } from "./types.js";
|
||||||
|
import type { ConvertOptions, ConvertResult, NodeConverter } from "./types.js";
|
||||||
|
|
||||||
|
/** Conversion timeout in milliseconds. */
|
||||||
|
const TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
/** Maximum number of concurrent ffmpeg processes. */
|
||||||
|
const MAX_CONCURRENT = 3;
|
||||||
|
|
||||||
|
// ── Audio format definitions (from VERT ffmpeg.svelte.ts lines 43-74) ──
|
||||||
|
|
||||||
|
const AUDIO_FORMATS: FormatInfo[] = [
|
||||||
|
new FormatInfo("mp3"),
|
||||||
|
new FormatInfo("wav"),
|
||||||
|
new FormatInfo("flac"),
|
||||||
|
new FormatInfo("ogg"),
|
||||||
|
new FormatInfo("mogg", true, false), // from only
|
||||||
|
new FormatInfo("oga"),
|
||||||
|
new FormatInfo("opus"),
|
||||||
|
new FormatInfo("aac"),
|
||||||
|
new FormatInfo("alac"), // output extension becomes .m4a
|
||||||
|
new FormatInfo("m4a"),
|
||||||
|
new FormatInfo("caf", true, false), // from only
|
||||||
|
new FormatInfo("wma"),
|
||||||
|
new FormatInfo("amr"),
|
||||||
|
new FormatInfo("ac3"),
|
||||||
|
new FormatInfo("aiff"),
|
||||||
|
new FormatInfo("aifc"),
|
||||||
|
new FormatInfo("aif"),
|
||||||
|
new FormatInfo("mp1", true, false), // from only
|
||||||
|
new FormatInfo("mp2"),
|
||||||
|
new FormatInfo("mpc", true, false), // from only
|
||||||
|
new FormatInfo("dsd", true, false), // from only
|
||||||
|
new FormatInfo("dsf", true, false), // from only
|
||||||
|
new FormatInfo("dff", true, false), // from only
|
||||||
|
new FormatInfo("mqa", true, false), // from only
|
||||||
|
new FormatInfo("au"),
|
||||||
|
new FormatInfo("m4b"),
|
||||||
|
new FormatInfo("voc"),
|
||||||
|
new FormatInfo("weba"),
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Video format definitions (input only for audio extraction) ──
|
||||||
|
|
||||||
|
const VIDEO_FORMATS: FormatInfo[] = [
|
||||||
|
new FormatInfo("mkv", true, false, false),
|
||||||
|
new FormatInfo("mp4", true, false, false),
|
||||||
|
new FormatInfo("avi", true, false, false),
|
||||||
|
new FormatInfo("mov", true, false, false),
|
||||||
|
new FormatInfo("webm", true, false, false),
|
||||||
|
new FormatInfo("ts", true, false, false),
|
||||||
|
new FormatInfo("mts", true, false, false),
|
||||||
|
new FormatInfo("m2ts", true, false, false),
|
||||||
|
new FormatInfo("wmv", true, false, false),
|
||||||
|
new FormatInfo("mpg", true, false, false),
|
||||||
|
new FormatInfo("mpeg", true, false, false),
|
||||||
|
new FormatInfo("flv", true, false, false),
|
||||||
|
new FormatInfo("f4v", true, false, false),
|
||||||
|
new FormatInfo("vob", true, false, false),
|
||||||
|
new FormatInfo("m4v", true, false, false),
|
||||||
|
new FormatInfo("3gp", true, false, false),
|
||||||
|
new FormatInfo("3g2", true, false, false),
|
||||||
|
new FormatInfo("mxf", true, false, false),
|
||||||
|
new FormatInfo("ogv", true, false, false),
|
||||||
|
new FormatInfo("rm", true, false, false),
|
||||||
|
new FormatInfo("rmvb", true, false, false),
|
||||||
|
new FormatInfo("divx", true, false, false),
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Set of video extensions (undotted, lowercase) for detecting video input. */
|
||||||
|
const VIDEO_EXTENSIONS = new Set(
|
||||||
|
VIDEO_FORMATS.map((f) => f.name.slice(1).toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Codec mappings (from VERT ffmpeg.svelte.ts lines 636-699 + additions) ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the FFmpeg codec string for a given output format extension.
|
||||||
|
* Adapted from VERT's getCodecs() with additions for missing codecs.
|
||||||
|
*/
|
||||||
|
function getCodec(ext: string): string {
|
||||||
|
switch (ext) {
|
||||||
|
case "mp3":
|
||||||
|
return "libmp3lame";
|
||||||
|
case "wav":
|
||||||
|
return "pcm_s16le";
|
||||||
|
case "flac":
|
||||||
|
return "flac";
|
||||||
|
case "ogg":
|
||||||
|
case "oga":
|
||||||
|
return "libvorbis";
|
||||||
|
case "opus":
|
||||||
|
return "libopus";
|
||||||
|
case "aac":
|
||||||
|
return "aac";
|
||||||
|
case "alac":
|
||||||
|
return "alac";
|
||||||
|
case "m4a":
|
||||||
|
return "aac";
|
||||||
|
case "m4b":
|
||||||
|
return "aac";
|
||||||
|
case "wma":
|
||||||
|
return "wmav2";
|
||||||
|
case "amr":
|
||||||
|
return "libopencore_amrnb";
|
||||||
|
case "ac3":
|
||||||
|
return "ac3";
|
||||||
|
case "aiff":
|
||||||
|
case "aifc":
|
||||||
|
case "aif":
|
||||||
|
return "pcm_s16be";
|
||||||
|
case "mp2":
|
||||||
|
return "mp2";
|
||||||
|
case "au":
|
||||||
|
return "pcm_mulaw";
|
||||||
|
case "voc":
|
||||||
|
return "pcm_u8";
|
||||||
|
case "weba":
|
||||||
|
return "libopus";
|
||||||
|
case "caf":
|
||||||
|
return "pcm_s16le";
|
||||||
|
default:
|
||||||
|
return "copy";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the given extension is a video format we recognize as input.
|
||||||
|
*/
|
||||||
|
function isVideoInput(ext: string): boolean {
|
||||||
|
return VIDEO_EXTENSIONS.has(ext.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Concurrency limiter ──
|
||||||
|
|
||||||
|
let activeProcesses = 0;
|
||||||
|
const waitQueue: Array<() => void> = [];
|
||||||
|
|
||||||
|
function acquireSlot(): Promise<void> {
|
||||||
|
if (activeProcesses < MAX_CONCURRENT) {
|
||||||
|
activeProcesses++;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
waitQueue.push(() => {
|
||||||
|
activeProcesses++;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseSlot(): void {
|
||||||
|
activeProcesses--;
|
||||||
|
const next = waitQueue.shift();
|
||||||
|
if (next) {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run ffmpeg with the given arguments via execFile (no shell).
|
||||||
|
* Returns a promise that resolves on success or rejects on failure.
|
||||||
|
*/
|
||||||
|
function runFFmpeg(ffmpegPath: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(
|
||||||
|
ffmpegPath,
|
||||||
|
args,
|
||||||
|
{
|
||||||
|
timeout: TIMEOUT_MS,
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
windowsHide: true,
|
||||||
|
},
|
||||||
|
(error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(
|
||||||
|
`ffmpeg failed: ${error.message}\n${stderr}`,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
resolve({ stdout, stderr });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the FFmpeg conversion command arguments.
|
||||||
|
* Adapted from VERT's buildConversionCommand() (ffmpeg.svelte.ts lines 315-527).
|
||||||
|
*/
|
||||||
|
function buildConversionArgs(options: ConvertOptions, inputExt: string): string[] {
|
||||||
|
const {
|
||||||
|
inputPath,
|
||||||
|
outputPath,
|
||||||
|
outputFormat,
|
||||||
|
audioBitrate,
|
||||||
|
sampleRate,
|
||||||
|
keepMetadata,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Determine the actual output format and codec
|
||||||
|
let actualOutputFormat = outputFormat.toLowerCase();
|
||||||
|
let actualOutputPath = outputPath;
|
||||||
|
|
||||||
|
// ALAC: output_format="alac" -> actual file ext .m4a, codec alac
|
||||||
|
if (actualOutputFormat === "alac") {
|
||||||
|
// The output path should already have .m4a extension (handled by caller),
|
||||||
|
// but the codec is "alac"
|
||||||
|
actualOutputFormat = "alac";
|
||||||
|
}
|
||||||
|
|
||||||
|
const codec = getCodec(actualOutputFormat);
|
||||||
|
const isVideoSource = isVideoInput(inputExt);
|
||||||
|
|
||||||
|
const args: string[] = [];
|
||||||
|
|
||||||
|
// Security: restrict protocols to prevent SSRF
|
||||||
|
args.push("-protocol_whitelist", "file,pipe");
|
||||||
|
|
||||||
|
// Overwrite output without asking (we handle uniqueness ourselves)
|
||||||
|
args.push("-y");
|
||||||
|
|
||||||
|
// Input file
|
||||||
|
// -i takes its argument directly; no -- between flag and value.
|
||||||
|
args.push("-i", inputPath);
|
||||||
|
|
||||||
|
// If extracting audio from video, map only the first audio stream
|
||||||
|
if (isVideoSource) {
|
||||||
|
args.push("-map", "0:a:0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio codec
|
||||||
|
if (codec !== "copy") {
|
||||||
|
args.push("-c:a", codec);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata handling
|
||||||
|
if (keepMetadata === false) {
|
||||||
|
args.push("-map_metadata", "-1");
|
||||||
|
args.push("-map_chapters", "-1");
|
||||||
|
if (!isVideoSource) {
|
||||||
|
args.push("-map", "a");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio bitrate
|
||||||
|
if (audioBitrate) {
|
||||||
|
args.push("-b:a", audioBitrate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample rate handling
|
||||||
|
if (sampleRate) {
|
||||||
|
// Opus 44100 -> 48000 auto-adjustment
|
||||||
|
if ((actualOutputFormat === "opus" || actualOutputFormat === "weba") && sampleRate === 44100) {
|
||||||
|
args.push("-ar", "48000");
|
||||||
|
} else {
|
||||||
|
args.push("-ar", String(sampleRate));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no sample rate specified and output is opus, default to 48000
|
||||||
|
// (Opus doesn't support 44100Hz)
|
||||||
|
if (actualOutputFormat === "opus" || actualOutputFormat === "weba") {
|
||||||
|
args.push("-ar", "48000");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// End of options separator, then output file path
|
||||||
|
// -- prevents output paths starting with "-" from being interpreted as flags
|
||||||
|
args.push("--", actualOutputPath);
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FFmpeg Node Converter ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FFmpeg-based audio converter using the system ffmpeg binary.
|
||||||
|
* Gracefully unavailable if ffmpeg is not installed.
|
||||||
|
*/
|
||||||
|
export class FFmpegNodeConverter implements NodeConverter {
|
||||||
|
readonly name = "ffmpeg";
|
||||||
|
readonly supportedFormats: FormatInfo[] = [...AUDIO_FORMATS, ...VIDEO_FORMATS];
|
||||||
|
|
||||||
|
private ffmpegPath: string | null = null;
|
||||||
|
private available: boolean | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if ffmpeg is available on the system.
|
||||||
|
* Caches the result after the first check.
|
||||||
|
*/
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
if (this.available !== null) {
|
||||||
|
return this.available;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.ffmpegPath = await which("ffmpeg");
|
||||||
|
this.available = true;
|
||||||
|
} catch {
|
||||||
|
this.ffmpegPath = null;
|
||||||
|
this.available = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.available;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an audio file using system ffmpeg.
|
||||||
|
* Supports audio-to-audio conversion and video-to-audio extraction.
|
||||||
|
*/
|
||||||
|
async convert(options: ConvertOptions): Promise<ConvertResult> {
|
||||||
|
if (!(await this.isAvailable()) || !this.ffmpegPath) {
|
||||||
|
throw new Error("ffmpeg is not installed on this system");
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputExt = path.extname(options.inputPath).slice(1).toLowerCase();
|
||||||
|
|
||||||
|
// Determine actual output path (ALAC uses .m4a extension)
|
||||||
|
let actualOutputPath = options.outputPath;
|
||||||
|
if (options.outputFormat.toLowerCase() === "alac") {
|
||||||
|
const dir = path.dirname(options.outputPath);
|
||||||
|
const base = path.basename(options.outputPath, path.extname(options.outputPath));
|
||||||
|
actualOutputPath = path.join(dir, `${base}.m4a`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveOptions: ConvertOptions = {
|
||||||
|
...options,
|
||||||
|
outputPath: actualOutputPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
const args = buildConversionArgs(effectiveOptions, inputExt);
|
||||||
|
|
||||||
|
await acquireSlot();
|
||||||
|
try {
|
||||||
|
await runFFmpeg(this.ffmpegPath, args);
|
||||||
|
} finally {
|
||||||
|
releaseSlot();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify output exists and get size
|
||||||
|
const stats = await stat(actualOutputPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
outputPath: actualOutputPath,
|
||||||
|
format: options.outputFormat.toLowerCase(),
|
||||||
|
sizeBytes: stats.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,620 @@
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { readFileSync, writeFileSync, statSync } from "node:fs";
|
||||||
|
import { basename, extname } from "node:path";
|
||||||
|
import {
|
||||||
|
initializeImageMagick,
|
||||||
|
ImageMagick,
|
||||||
|
MagickFormat,
|
||||||
|
MagickReadSettings,
|
||||||
|
} from "@imagemagick/magick-wasm";
|
||||||
|
import yazl from "yazl";
|
||||||
|
import { parseAni } from "../util/parse-ani.js";
|
||||||
|
import { FormatInfo, type ConvertOptions, type ConvertResult, type NodeConverter } from "./types.js";
|
||||||
|
|
||||||
|
/** Maximum number of frames to extract from ICO/ANI files. */
|
||||||
|
const MAX_FRAMES = 256;
|
||||||
|
|
||||||
|
/** Conversion timeout in milliseconds. */
|
||||||
|
const CONVERSION_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Format lists
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Blocked formats that lack WASM delegates or are unsafe:
|
||||||
|
// svg, nef, cr2, arw, dng, rw2, raf, orf, pef, mos, raw, dcr, crw, cr3, 3fr,
|
||||||
|
// erf, mrw, mef, nrw, srw, sr2, srf, eps, icns, xcf
|
||||||
|
// ps, ps1, svgz, epdf, epi, eps2, eps3, epsf, epsi, ept, ept2, ept3
|
||||||
|
|
||||||
|
/** Manually tested image formats from VERT (magick.svelte.ts lines 19-78), blocked formats removed. */
|
||||||
|
const MANUAL_FORMATS: FormatInfo[] = [
|
||||||
|
new FormatInfo("png", true, true),
|
||||||
|
new FormatInfo("jpeg", true, true),
|
||||||
|
new FormatInfo("jpg", true, true),
|
||||||
|
new FormatInfo("webp", true, true),
|
||||||
|
new FormatInfo("gif", true, true),
|
||||||
|
// svg blocked (no WASM delegate)
|
||||||
|
new FormatInfo("jxl", true, true),
|
||||||
|
new FormatInfo("avif", true, true),
|
||||||
|
new FormatInfo("heic", true, false),
|
||||||
|
new FormatInfo("heif", true, false),
|
||||||
|
new FormatInfo("ico", true, true),
|
||||||
|
new FormatInfo("bmp", true, true),
|
||||||
|
new FormatInfo("cur", true, true),
|
||||||
|
new FormatInfo("ani", true, false),
|
||||||
|
// icns blocked (needs vert-wasm)
|
||||||
|
// nef blocked (needs libraw)
|
||||||
|
// cr2 blocked (needs libraw)
|
||||||
|
new FormatInfo("hdr", true, true),
|
||||||
|
new FormatInfo("jpe", true, true),
|
||||||
|
new FormatInfo("mat", true, true),
|
||||||
|
new FormatInfo("pbm", true, true),
|
||||||
|
new FormatInfo("pfm", true, true),
|
||||||
|
new FormatInfo("pgm", true, true),
|
||||||
|
new FormatInfo("pnm", true, true),
|
||||||
|
new FormatInfo("ppm", true, true),
|
||||||
|
new FormatInfo("tiff", true, true),
|
||||||
|
new FormatInfo("jfif", true, true),
|
||||||
|
// eps blocked (needs Ghostscript)
|
||||||
|
new FormatInfo("psd", true, true),
|
||||||
|
new FormatInfo("svg", false, true),
|
||||||
|
new FormatInfo("svgz", false, true),
|
||||||
|
// arw blocked (needs libraw)
|
||||||
|
new FormatInfo("tif", true, true),
|
||||||
|
// dng blocked (needs libraw)
|
||||||
|
// xcf blocked
|
||||||
|
// rw2 blocked (needs libraw)
|
||||||
|
// raf blocked (needs libraw)
|
||||||
|
// orf blocked (needs libraw)
|
||||||
|
// pef blocked (needs libraw)
|
||||||
|
// mos blocked (needs libraw)
|
||||||
|
// raw blocked (needs libraw)
|
||||||
|
// dcr blocked (needs libraw)
|
||||||
|
// crw blocked (needs libraw)
|
||||||
|
// cr3 blocked (needs libraw)
|
||||||
|
// 3fr blocked (needs libraw)
|
||||||
|
// erf blocked (needs libraw)
|
||||||
|
// mrw blocked (needs libraw)
|
||||||
|
// mef blocked (needs libraw)
|
||||||
|
// nrw blocked (needs libraw)
|
||||||
|
// srw blocked (needs libraw)
|
||||||
|
// sr2 blocked (needs libraw)
|
||||||
|
// srf blocked (needs libraw)
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Automated formats from VERT (magick-automated.ts), blocked formats removed. */
|
||||||
|
const AUTOMATED_FORMATS: FormatInfo[] = [
|
||||||
|
new FormatInfo("a", false, true),
|
||||||
|
new FormatInfo("aai", true, true),
|
||||||
|
new FormatInfo("ai", false, true),
|
||||||
|
new FormatInfo("art", false, true),
|
||||||
|
new FormatInfo("avs", true, true),
|
||||||
|
new FormatInfo("b", false, true),
|
||||||
|
new FormatInfo("bgr", false, true),
|
||||||
|
new FormatInfo("bgra", false, true),
|
||||||
|
new FormatInfo("bgro", false, true),
|
||||||
|
new FormatInfo("bmp2", true, true),
|
||||||
|
new FormatInfo("bmp3", true, true),
|
||||||
|
new FormatInfo("brf", false, true),
|
||||||
|
new FormatInfo("cal", false, true),
|
||||||
|
new FormatInfo("cals", false, true),
|
||||||
|
new FormatInfo("cin", true, true),
|
||||||
|
new FormatInfo("cip", false, true),
|
||||||
|
new FormatInfo("cmyk", false, true),
|
||||||
|
new FormatInfo("cmyka", false, true),
|
||||||
|
new FormatInfo("dcx", true, true),
|
||||||
|
new FormatInfo("dds", true, true),
|
||||||
|
new FormatInfo("dpx", true, true),
|
||||||
|
new FormatInfo("dxt1", true, true),
|
||||||
|
new FormatInfo("dxt5", true, true),
|
||||||
|
// epdf blocked
|
||||||
|
// epi blocked
|
||||||
|
// eps2 blocked
|
||||||
|
// eps3 blocked
|
||||||
|
// epsf blocked
|
||||||
|
// epsi blocked
|
||||||
|
// ept blocked
|
||||||
|
// ept2 blocked
|
||||||
|
// ept3 blocked
|
||||||
|
new FormatInfo("exr", true, true),
|
||||||
|
new FormatInfo("farbfeld", true, true),
|
||||||
|
new FormatInfo("fax", true, true),
|
||||||
|
new FormatInfo("ff", true, true),
|
||||||
|
new FormatInfo("fit", true, true),
|
||||||
|
new FormatInfo("fits", true, true),
|
||||||
|
new FormatInfo("fl32", true, true),
|
||||||
|
new FormatInfo("fts", true, true),
|
||||||
|
new FormatInfo("ftxt", false, true),
|
||||||
|
new FormatInfo("g", false, true),
|
||||||
|
new FormatInfo("g3", true, true),
|
||||||
|
new FormatInfo("g4", false, true),
|
||||||
|
new FormatInfo("gif87", true, true),
|
||||||
|
new FormatInfo("gray", false, true),
|
||||||
|
new FormatInfo("graya", false, true),
|
||||||
|
new FormatInfo("group4", false, true),
|
||||||
|
new FormatInfo("hrz", true, true),
|
||||||
|
new FormatInfo("icb", true, true),
|
||||||
|
new FormatInfo("icon", true, true),
|
||||||
|
new FormatInfo("info", false, true),
|
||||||
|
new FormatInfo("ipl", true, true),
|
||||||
|
new FormatInfo("isobrl", false, true),
|
||||||
|
new FormatInfo("isobrl6", false, true),
|
||||||
|
new FormatInfo("j2c", true, true),
|
||||||
|
new FormatInfo("j2k", true, true),
|
||||||
|
new FormatInfo("jng", true, true),
|
||||||
|
new FormatInfo("jp2", true, true),
|
||||||
|
new FormatInfo("jpc", true, true),
|
||||||
|
new FormatInfo("jpm", true, true),
|
||||||
|
new FormatInfo("jps", true, true),
|
||||||
|
new FormatInfo("map", false, true),
|
||||||
|
new FormatInfo("miff", true, true),
|
||||||
|
new FormatInfo("mng", true, true),
|
||||||
|
new FormatInfo("mono", false, true),
|
||||||
|
new FormatInfo("mtv", true, true),
|
||||||
|
new FormatInfo("o", false, true),
|
||||||
|
new FormatInfo("otb", true, true),
|
||||||
|
new FormatInfo("pal", false, true),
|
||||||
|
new FormatInfo("palm", true, true),
|
||||||
|
new FormatInfo("pam", true, true),
|
||||||
|
new FormatInfo("pcd", true, true),
|
||||||
|
new FormatInfo("pcds", true, true),
|
||||||
|
new FormatInfo("pcl", false, true),
|
||||||
|
new FormatInfo("pct", true, true),
|
||||||
|
new FormatInfo("pcx", true, true),
|
||||||
|
new FormatInfo("pdb", true, true),
|
||||||
|
new FormatInfo("pgx", true, true),
|
||||||
|
new FormatInfo("phm", true, true),
|
||||||
|
new FormatInfo("picon", true, true),
|
||||||
|
new FormatInfo("pict", true, true),
|
||||||
|
new FormatInfo("pjpeg", true, true),
|
||||||
|
new FormatInfo("png00", true, true),
|
||||||
|
new FormatInfo("png24", true, true),
|
||||||
|
new FormatInfo("png32", true, true),
|
||||||
|
new FormatInfo("png48", true, true),
|
||||||
|
new FormatInfo("png64", true, true),
|
||||||
|
new FormatInfo("png8", true, true),
|
||||||
|
// ps blocked
|
||||||
|
// ps1 blocked
|
||||||
|
new FormatInfo("ps2", false, true),
|
||||||
|
new FormatInfo("ps3", false, true),
|
||||||
|
new FormatInfo("psb", true, true),
|
||||||
|
new FormatInfo("ptif", true, true),
|
||||||
|
new FormatInfo("qoi", true, true),
|
||||||
|
new FormatInfo("r", false, true),
|
||||||
|
new FormatInfo("ras", true, true),
|
||||||
|
new FormatInfo("rgb", false, true),
|
||||||
|
new FormatInfo("rgba", false, true),
|
||||||
|
new FormatInfo("rgbo", false, true),
|
||||||
|
new FormatInfo("rgf", true, true),
|
||||||
|
new FormatInfo("sgi", true, true),
|
||||||
|
new FormatInfo("six", true, true),
|
||||||
|
new FormatInfo("sixel", true, true),
|
||||||
|
new FormatInfo("sparse-color", false, true),
|
||||||
|
new FormatInfo("strimg", false, true),
|
||||||
|
new FormatInfo("sun", true, true),
|
||||||
|
// svgz blocked
|
||||||
|
new FormatInfo("tga", true, true),
|
||||||
|
new FormatInfo("tiff64", true, true),
|
||||||
|
new FormatInfo("ubrl", false, true),
|
||||||
|
new FormatInfo("ubrl6", false, true),
|
||||||
|
new FormatInfo("uil", false, true),
|
||||||
|
new FormatInfo("uyvy", false, true),
|
||||||
|
new FormatInfo("vda", true, true),
|
||||||
|
new FormatInfo("vicar", true, true),
|
||||||
|
new FormatInfo("viff", true, true),
|
||||||
|
new FormatInfo("vips", true, true),
|
||||||
|
new FormatInfo("vst", true, true),
|
||||||
|
new FormatInfo("wbmp", true, true),
|
||||||
|
new FormatInfo("wpg", true, true),
|
||||||
|
new FormatInfo("xbm", true, true),
|
||||||
|
new FormatInfo("xpm", true, true),
|
||||||
|
new FormatInfo("xv", true, true),
|
||||||
|
new FormatInfo("ycbcr", false, true),
|
||||||
|
new FormatInfo("ycbcra", false, true),
|
||||||
|
new FormatInfo("yuv", false, true),
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Format normalization helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Normalize input extension: .jfif->.jpeg, .fit->.fits (bidirectional). */
|
||||||
|
function normalizeInputExt(ext: string): string {
|
||||||
|
const lower = ext.toLowerCase();
|
||||||
|
if (lower === ".jfif") return ".jpeg";
|
||||||
|
if (lower === ".fit") return ".fits";
|
||||||
|
return lower;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize output extension: .jfif->.jpeg, .fit->.fits (bidirectional). */
|
||||||
|
function normalizeOutputExt(ext: string): string {
|
||||||
|
const lower = ext.toLowerCase();
|
||||||
|
if (lower === ".jfif") return ".jpeg";
|
||||||
|
if (lower === ".fit") return ".fits";
|
||||||
|
return lower;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert an extension (without dot) to a MagickFormat enum value. */
|
||||||
|
function extToMagickFormat(ext: string): MagickFormat {
|
||||||
|
return ext.toUpperCase() as unknown as MagickFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Serialization queue
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Simple serialization queue — ensures one conversion at a time for the single WASM instance. */
|
||||||
|
class SerialQueue {
|
||||||
|
private queue: Array<{ run: () => Promise<void>; }> = [];
|
||||||
|
private running = false;
|
||||||
|
|
||||||
|
enqueue<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
this.queue.push({
|
||||||
|
run: async () => {
|
||||||
|
try {
|
||||||
|
resolve(await fn());
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.drain();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async drain(): Promise<void> {
|
||||||
|
if (this.running) return;
|
||||||
|
this.running = true;
|
||||||
|
while (this.queue.length > 0) {
|
||||||
|
const item = this.queue.shift()!;
|
||||||
|
await item.run();
|
||||||
|
}
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ZIP helper using yazl
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Create a ZIP buffer from an array of named buffers. */
|
||||||
|
function createZip(entries: Array<{ name: string; data: Buffer }>): Promise<Buffer> {
|
||||||
|
return new Promise<Buffer>((resolve, reject) => {
|
||||||
|
const zipFile = new yazl.ZipFile();
|
||||||
|
for (const entry of entries) {
|
||||||
|
zipFile.addBuffer(entry.data, entry.name);
|
||||||
|
}
|
||||||
|
zipFile.end();
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
zipFile.outputStream.on("data", (chunk: Buffer) => {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
});
|
||||||
|
zipFile.outputStream.on("end", () => {
|
||||||
|
resolve(Buffer.concat(chunks));
|
||||||
|
});
|
||||||
|
zipFile.outputStream.on("error", (err: Error) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Timeout helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
reject(new Error(`Conversion timed out after ${ms / 1000} seconds: ${label}`));
|
||||||
|
}, ms);
|
||||||
|
promise.then(
|
||||||
|
(val) => { clearTimeout(timer); resolve(val); },
|
||||||
|
(err) => { clearTimeout(timer); reject(err); },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MagickNodeConverter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ImageMagick WASM converter for Node.js.
|
||||||
|
* Uses @imagemagick/magick-wasm with lazy initialization, serialized queue,
|
||||||
|
* and callback-based auto-disposal.
|
||||||
|
*/
|
||||||
|
export class MagickNodeConverter implements NodeConverter {
|
||||||
|
readonly name = "imagemagick";
|
||||||
|
readonly supportedFormats: FormatInfo[] = [...MANUAL_FORMATS, ...AUTOMATED_FORMATS];
|
||||||
|
|
||||||
|
private initialized = false;
|
||||||
|
private initPromise: Promise<void> | null = null;
|
||||||
|
private readonly queue = new SerialQueue();
|
||||||
|
|
||||||
|
/** Lazy-initialize the WASM module on first call. */
|
||||||
|
private async ensureInitialized(): Promise<void> {
|
||||||
|
if (this.initialized) return;
|
||||||
|
if (this.initPromise) {
|
||||||
|
await this.initPromise;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.initPromise = (async () => {
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const wasmPath = require.resolve("@imagemagick/magick-wasm/magick.wasm");
|
||||||
|
const wasmBytes = readFileSync(wasmPath);
|
||||||
|
await initializeImageMagick(wasmBytes);
|
||||||
|
this.initialized = true;
|
||||||
|
})();
|
||||||
|
await this.initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ImageMagick WASM is always available (bundled). */
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert an image file. */
|
||||||
|
async convert(options: ConvertOptions): Promise<ConvertResult> {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
|
||||||
|
return withTimeout(
|
||||||
|
this.queue.enqueue(() => this.doConvert(options)),
|
||||||
|
CONVERSION_TIMEOUT_MS,
|
||||||
|
`${basename(options.inputPath)} -> ${options.outputFormat}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Internal conversion dispatched inside the serialized queue. */
|
||||||
|
private async doConvert(options: ConvertOptions): Promise<ConvertResult> {
|
||||||
|
const { inputPath, outputPath, quality, keepMetadata = true } = options;
|
||||||
|
const inputBytes = new Uint8Array(readFileSync(inputPath));
|
||||||
|
|
||||||
|
const rawInputExt = extname(inputPath).toLowerCase();
|
||||||
|
const from = normalizeInputExt(rawInputExt);
|
||||||
|
|
||||||
|
let outputExt = options.outputFormat.startsWith(".")
|
||||||
|
? options.outputFormat.toLowerCase()
|
||||||
|
: `.${options.outputFormat.toLowerCase()}`;
|
||||||
|
outputExt = normalizeOutputExt(outputExt);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// ICO input: extract individual frames → ZIP
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
if (from === ".ico") {
|
||||||
|
return this.convertIco(inputBytes, outputExt, outputPath, keepMetadata, quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// ANI input: parse frames → convert each → ZIP
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
if (from === ".ani") {
|
||||||
|
return this.convertAni(inputBytes, outputExt, outputPath, keepMetadata, quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Animated GIF/WebP input → GIF/WebP output: use readCollection
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
if (
|
||||||
|
(from === ".gif" || from === ".webp") &&
|
||||||
|
(outputExt === ".gif" || outputExt === ".webp")
|
||||||
|
) {
|
||||||
|
return this.convertAnimated(inputBytes, from, outputExt, outputPath, keepMetadata, quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Standard single-image conversion
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
const outputFmt = extToMagickFormat(outputExt.slice(1));
|
||||||
|
const inputFmt = extToMagickFormat(from.slice(1));
|
||||||
|
|
||||||
|
const result = ImageMagick.read(inputBytes, new MagickReadSettings({ format: inputFmt }), (img) => {
|
||||||
|
// ICO output: clamp to 256x256 max
|
||||||
|
if (outputExt === ".ico") {
|
||||||
|
this.clampForIco(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quality !== undefined) {
|
||||||
|
img.quality = quality;
|
||||||
|
}
|
||||||
|
if (!keepMetadata) {
|
||||||
|
img.strip();
|
||||||
|
}
|
||||||
|
|
||||||
|
let outputBuffer: Buffer = Buffer.alloc(0);
|
||||||
|
img.write(outputFmt, (data) => {
|
||||||
|
outputBuffer = Buffer.from(data);
|
||||||
|
});
|
||||||
|
return outputBuffer;
|
||||||
|
});
|
||||||
|
|
||||||
|
writeFileSync(outputPath, result);
|
||||||
|
|
||||||
|
const stat = statSync(outputPath);
|
||||||
|
return {
|
||||||
|
outputPath,
|
||||||
|
format: outputExt.slice(1),
|
||||||
|
sizeBytes: stat.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert ICO: read individual frames, convert each, ZIP output. */
|
||||||
|
private async convertIco(
|
||||||
|
inputBytes: Uint8Array,
|
||||||
|
outputExt: string,
|
||||||
|
outputPath: string,
|
||||||
|
keepMetadata: boolean,
|
||||||
|
quality?: number,
|
||||||
|
): Promise<ConvertResult> {
|
||||||
|
const outputFmt = extToMagickFormat(outputExt.slice(1));
|
||||||
|
const entries: Array<{ name: string; data: Buffer }> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < MAX_FRAMES; i++) {
|
||||||
|
try {
|
||||||
|
const frameBuffer = ImageMagick.read(
|
||||||
|
inputBytes,
|
||||||
|
new MagickReadSettings({
|
||||||
|
format: MagickFormat.Ico,
|
||||||
|
frameIndex: i,
|
||||||
|
}),
|
||||||
|
(img) => {
|
||||||
|
if (outputExt === ".ico") {
|
||||||
|
this.clampForIco(img);
|
||||||
|
}
|
||||||
|
if (quality !== undefined) {
|
||||||
|
img.quality = quality;
|
||||||
|
}
|
||||||
|
if (!keepMetadata) {
|
||||||
|
img.strip();
|
||||||
|
}
|
||||||
|
|
||||||
|
let buf: Buffer = Buffer.alloc(0);
|
||||||
|
img.write(outputFmt, (data) => {
|
||||||
|
buf = Buffer.from(data);
|
||||||
|
});
|
||||||
|
return buf;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
entries.push({
|
||||||
|
name: `image${i}${outputExt}`,
|
||||||
|
data: frameBuffer,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// No more frames
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
throw new Error("Failed to read ICO — no images found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipPath = outputPath.replace(/\.[^.]+$/, ".zip");
|
||||||
|
const zipBuffer = await createZip(entries);
|
||||||
|
writeFileSync(zipPath, zipBuffer);
|
||||||
|
|
||||||
|
const stat = statSync(zipPath);
|
||||||
|
return {
|
||||||
|
outputPath: zipPath,
|
||||||
|
format: "zip",
|
||||||
|
sizeBytes: stat.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert ANI: parse with parseAni(), convert each frame, ZIP output. */
|
||||||
|
private async convertAni(
|
||||||
|
inputBytes: Uint8Array,
|
||||||
|
outputExt: string,
|
||||||
|
outputPath: string,
|
||||||
|
keepMetadata: boolean,
|
||||||
|
quality?: number,
|
||||||
|
): Promise<ConvertResult> {
|
||||||
|
let parsedAni;
|
||||||
|
try {
|
||||||
|
parsedAni = parseAni(inputBytes);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to parse ANI file: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputFmt = extToMagickFormat(outputExt.slice(1));
|
||||||
|
const frames = parsedAni.images.slice(0, MAX_FRAMES);
|
||||||
|
|
||||||
|
if (frames.length === 0) {
|
||||||
|
throw new Error("Failed to parse ANI — no frames found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: Array<{ name: string; data: Buffer }> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < frames.length; i++) {
|
||||||
|
const frameData = frames[i];
|
||||||
|
const frameBuffer = ImageMagick.read(
|
||||||
|
frameData,
|
||||||
|
new MagickReadSettings({ format: MagickFormat.Ico }),
|
||||||
|
(img) => {
|
||||||
|
if (outputExt === ".ico") {
|
||||||
|
this.clampForIco(img);
|
||||||
|
}
|
||||||
|
if (quality !== undefined) {
|
||||||
|
img.quality = quality;
|
||||||
|
}
|
||||||
|
if (!keepMetadata) {
|
||||||
|
img.strip();
|
||||||
|
}
|
||||||
|
|
||||||
|
let buf: Buffer = Buffer.alloc(0);
|
||||||
|
img.write(outputFmt, (data) => {
|
||||||
|
buf = Buffer.from(data);
|
||||||
|
});
|
||||||
|
return buf;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
entries.push({
|
||||||
|
name: `image${i}${outputExt}`,
|
||||||
|
data: frameBuffer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipPath = outputPath.replace(/\.[^.]+$/, ".zip");
|
||||||
|
const zipBuffer = await createZip(entries);
|
||||||
|
writeFileSync(zipPath, zipBuffer);
|
||||||
|
|
||||||
|
const stat = statSync(zipPath);
|
||||||
|
return {
|
||||||
|
outputPath: zipPath,
|
||||||
|
format: "zip",
|
||||||
|
sizeBytes: stat.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert animated GIF/WebP → GIF/WebP using readCollection. */
|
||||||
|
private async convertAnimated(
|
||||||
|
inputBytes: Uint8Array,
|
||||||
|
_from: string,
|
||||||
|
outputExt: string,
|
||||||
|
outputPath: string,
|
||||||
|
keepMetadata: boolean,
|
||||||
|
quality?: number,
|
||||||
|
): Promise<ConvertResult> {
|
||||||
|
const outputFmt = outputExt === ".gif" ? MagickFormat.Gif : MagickFormat.WebP;
|
||||||
|
|
||||||
|
const result = ImageMagick.readCollection(inputBytes, (imgs) => {
|
||||||
|
for (const img of imgs) {
|
||||||
|
if (quality !== undefined) {
|
||||||
|
img.quality = quality;
|
||||||
|
}
|
||||||
|
if (!keepMetadata) {
|
||||||
|
img.strip();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let outputBuffer: Buffer = Buffer.alloc(0);
|
||||||
|
imgs.write(outputFmt, (data) => {
|
||||||
|
outputBuffer = Buffer.from(data);
|
||||||
|
});
|
||||||
|
return outputBuffer;
|
||||||
|
});
|
||||||
|
|
||||||
|
writeFileSync(outputPath, result);
|
||||||
|
|
||||||
|
const stat = statSync(outputPath);
|
||||||
|
return {
|
||||||
|
outputPath,
|
||||||
|
format: outputExt.slice(1),
|
||||||
|
sizeBytes: stat.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp image dimensions to 256x256 for ICO output. */
|
||||||
|
private clampForIco(img: { width: number; height: number; resize: (w: number, h: number) => void }): void {
|
||||||
|
const max = 256;
|
||||||
|
const w = img.width;
|
||||||
|
const h = img.height;
|
||||||
|
if (w > max || h > max) {
|
||||||
|
const scale = max / Math.max(w, h);
|
||||||
|
const newW = Math.max(1, Math.round(w * scale));
|
||||||
|
const newH = Math.max(1, Math.round(h * scale));
|
||||||
|
img.resize(newW, newH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { stat, rm, mkdtemp } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import which from "which";
|
||||||
|
import { FormatInfo, type ConvertOptions, type ConvertResult, type NodeConverter } from "./types.js";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
/** Conversion timeout in milliseconds. */
|
||||||
|
const TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
/** Max concurrent pandoc processes. */
|
||||||
|
const MAX_CONCURRENT = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map file extension (without dot) to pandoc reader name.
|
||||||
|
*/
|
||||||
|
function formatToReader(ext: string): string {
|
||||||
|
switch (ext) {
|
||||||
|
case "md":
|
||||||
|
case "markdown":
|
||||||
|
return "markdown";
|
||||||
|
case "docx":
|
||||||
|
return "docx";
|
||||||
|
case "csv":
|
||||||
|
return "csv";
|
||||||
|
case "tsv":
|
||||||
|
return "tsv";
|
||||||
|
case "docbook":
|
||||||
|
return "docbook";
|
||||||
|
case "epub":
|
||||||
|
return "epub";
|
||||||
|
case "html":
|
||||||
|
return "html";
|
||||||
|
case "json":
|
||||||
|
return "json";
|
||||||
|
case "odt":
|
||||||
|
return "odt";
|
||||||
|
case "rtf":
|
||||||
|
return "rtf";
|
||||||
|
case "rst":
|
||||||
|
return "rst";
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported pandoc input format: ${ext}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map file extension (without dot) to pandoc writer name.
|
||||||
|
* Separate from reader because some writer names differ.
|
||||||
|
*/
|
||||||
|
function formatToWriter(ext: string): string {
|
||||||
|
switch (ext) {
|
||||||
|
case "md":
|
||||||
|
case "markdown":
|
||||||
|
return "markdown";
|
||||||
|
case "docx":
|
||||||
|
return "docx";
|
||||||
|
case "csv":
|
||||||
|
return "csv";
|
||||||
|
case "tsv":
|
||||||
|
return "tsv";
|
||||||
|
case "docbook":
|
||||||
|
return "docbook5";
|
||||||
|
case "epub":
|
||||||
|
return "epub3";
|
||||||
|
case "html":
|
||||||
|
return "html5";
|
||||||
|
case "json":
|
||||||
|
return "json";
|
||||||
|
case "odt":
|
||||||
|
return "odt";
|
||||||
|
case "rst":
|
||||||
|
return "rst";
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported pandoc output format: ${ext}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize format aliases to canonical extension names.
|
||||||
|
*/
|
||||||
|
function normalizeFormat(ext: string): string {
|
||||||
|
if (ext === "markdown") return "md";
|
||||||
|
return ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pandoc-based document converter.
|
||||||
|
*
|
||||||
|
* Uses system `pandoc` via `child_process.execFile`.
|
||||||
|
* Gracefully unavailable if pandoc is not installed.
|
||||||
|
*/
|
||||||
|
export class PandocNodeConverter implements NodeConverter {
|
||||||
|
readonly name = "pandoc";
|
||||||
|
|
||||||
|
readonly supportedFormats: FormatInfo[] = [
|
||||||
|
new FormatInfo("docx", true, true),
|
||||||
|
// .doc is NOT supported — pandoc docx reader can't handle binary .doc
|
||||||
|
new FormatInfo("md", true, true),
|
||||||
|
new FormatInfo("html", true, true),
|
||||||
|
new FormatInfo("rtf", true, false), // read only — RTF output is blocked
|
||||||
|
new FormatInfo("csv", true, true),
|
||||||
|
new FormatInfo("tsv", true, true),
|
||||||
|
new FormatInfo("json", true, true), // pandoc-converted JSON only
|
||||||
|
new FormatInfo("rst", true, true),
|
||||||
|
new FormatInfo("epub", true, true),
|
||||||
|
new FormatInfo("odt", true, true),
|
||||||
|
new FormatInfo("docbook", true, true),
|
||||||
|
];
|
||||||
|
|
||||||
|
private pandocPath: string | null = null;
|
||||||
|
private available: boolean | null = null;
|
||||||
|
private activeCount = 0;
|
||||||
|
|
||||||
|
/** Detect if pandoc is installed on the system. */
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
if (this.available !== null) return this.available;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.pandocPath = await which("pandoc");
|
||||||
|
this.available = true;
|
||||||
|
} catch {
|
||||||
|
this.available = false;
|
||||||
|
console.error("[pandoc] pandoc not found on system PATH");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.available;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a document file using pandoc. */
|
||||||
|
async convert(options: ConvertOptions): Promise<ConvertResult> {
|
||||||
|
if (!(await this.isAvailable()) || !this.pandocPath) {
|
||||||
|
throw new Error("pandoc is not available on this system");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.activeCount >= MAX_CONCURRENT) {
|
||||||
|
throw new Error(
|
||||||
|
`Too many concurrent pandoc conversions (max ${MAX_CONCURRENT}). Try again later.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputExt = path.extname(options.inputPath).slice(1).toLowerCase();
|
||||||
|
const outputExt = normalizeFormat(options.outputFormat.toLowerCase());
|
||||||
|
|
||||||
|
// Block RTF output
|
||||||
|
if (outputExt === "rtf") {
|
||||||
|
throw new Error("Converting to RTF is not supported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = formatToReader(normalizeFormat(inputExt));
|
||||||
|
const writer = formatToWriter(outputExt);
|
||||||
|
|
||||||
|
// Create temp dir for --extract-media (cleaned in finally)
|
||||||
|
const mediaTmpDir = await mkdtemp(path.join(tmpdir(), "pandoc-media-"));
|
||||||
|
|
||||||
|
this.activeCount++;
|
||||||
|
try {
|
||||||
|
const args: string[] = [
|
||||||
|
"--sandbox",
|
||||||
|
"-f", reader,
|
||||||
|
"-t", writer,
|
||||||
|
"--extract-media", mediaTmpDir,
|
||||||
|
"-o", options.outputPath,
|
||||||
|
"--", options.inputPath,
|
||||||
|
];
|
||||||
|
|
||||||
|
await execFileAsync(this.pandocPath, args, {
|
||||||
|
timeout: TIMEOUT_MS,
|
||||||
|
maxBuffer: 50 * 1024 * 1024, // 50 MB stdout/stderr buffer
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputStats = await stat(options.outputPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
outputPath: options.outputPath,
|
||||||
|
format: outputExt,
|
||||||
|
sizeBytes: outputStats.size,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
this.activeCount--;
|
||||||
|
// Clean up extracted media temp dir
|
||||||
|
try {
|
||||||
|
await rm(mediaTmpDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Best-effort cleanup — don't throw from finally
|
||||||
|
console.error(`[pandoc] Failed to clean up temp dir: ${mediaTmpDir}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
import type { FormatInfo, NodeConverter } from "./types.js";
|
||||||
|
import { MIME_TYPES } from "./types.js";
|
||||||
|
import { MagickNodeConverter } from "./magick.js";
|
||||||
|
import { FFmpegNodeConverter } from "./ffmpeg.js";
|
||||||
|
import { PandocNodeConverter } from "./pandoc.js";
|
||||||
|
|
||||||
|
/** All converter instances, in priority order. */
|
||||||
|
const converters: NodeConverter[] = [
|
||||||
|
new MagickNodeConverter(),
|
||||||
|
new FFmpegNodeConverter(),
|
||||||
|
new PandocNodeConverter(),
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Category definitions. */
|
||||||
|
export type Category = "image" | "audio" | "doc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the format allowlist (undotted format names) from all converters.
|
||||||
|
*/
|
||||||
|
export function buildFormatAllowlist(): Set<string> {
|
||||||
|
const allowlist = new Set<string>();
|
||||||
|
for (const converter of converters) {
|
||||||
|
for (const fmt of converter.supportedFormats) {
|
||||||
|
// Store undotted names
|
||||||
|
const name = fmt.name.startsWith(".") ? fmt.name.slice(1) : fmt.name;
|
||||||
|
allowlist.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allowlist;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the appropriate converter for a given input and output format.
|
||||||
|
* Converter priority: magick -> ffmpeg -> pandoc (array order).
|
||||||
|
*/
|
||||||
|
export function getConverter(
|
||||||
|
inputFormat: string,
|
||||||
|
outputFormat: string,
|
||||||
|
): NodeConverter | null {
|
||||||
|
const inputDotted = inputFormat.startsWith(".") ? inputFormat : `.${inputFormat}`;
|
||||||
|
const outputDotted = outputFormat.startsWith(".") ? outputFormat : `.${outputFormat}`;
|
||||||
|
|
||||||
|
for (const converter of converters) {
|
||||||
|
const supportsInput = converter.supportedFormats.some(
|
||||||
|
(f) => f.name === inputDotted && f.fromSupported,
|
||||||
|
);
|
||||||
|
const supportsOutput = converter.supportedFormats.some(
|
||||||
|
(f) => f.name === outputDotted && f.toSupported,
|
||||||
|
);
|
||||||
|
if (supportsInput && supportsOutput) {
|
||||||
|
return converter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: find any converter that handles the output format
|
||||||
|
// (e.g., video input with audio output — ffmpeg handles video input)
|
||||||
|
for (const converter of converters) {
|
||||||
|
const supportsInput = converter.supportedFormats.some(
|
||||||
|
(f) => f.name === inputDotted && f.fromSupported,
|
||||||
|
);
|
||||||
|
if (supportsInput) {
|
||||||
|
// Check if any other converter can handle the output
|
||||||
|
for (const outConverter of converters) {
|
||||||
|
const supportsOutput = outConverter.supportedFormats.some(
|
||||||
|
(f) => f.name === outputDotted && f.toSupported,
|
||||||
|
);
|
||||||
|
if (supportsOutput && converter === outConverter) {
|
||||||
|
return converter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get format info by extension (undotted).
|
||||||
|
*/
|
||||||
|
export function getFormatInfo(format: string): {
|
||||||
|
name: string;
|
||||||
|
fromSupported: boolean;
|
||||||
|
toSupported: boolean;
|
||||||
|
category: Category | null;
|
||||||
|
mimeType: string | null;
|
||||||
|
converter: string | null;
|
||||||
|
} | null {
|
||||||
|
const dotted = format.startsWith(".") ? format : `.${format}`;
|
||||||
|
const undotted = format.startsWith(".") ? format.slice(1) : format;
|
||||||
|
|
||||||
|
for (const converter of converters) {
|
||||||
|
const fmt = converter.supportedFormats.find((f) => f.name === dotted);
|
||||||
|
if (fmt) {
|
||||||
|
return {
|
||||||
|
name: undotted,
|
||||||
|
fromSupported: fmt.fromSupported,
|
||||||
|
toSupported: fmt.toSupported,
|
||||||
|
category: getCategory(converter.name),
|
||||||
|
mimeType: MIME_TYPES[undotted] ?? null,
|
||||||
|
converter: converter.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List formats, optionally filtered by category.
|
||||||
|
*/
|
||||||
|
export function listFormats(category?: Category): {
|
||||||
|
name: string;
|
||||||
|
fromSupported: boolean;
|
||||||
|
toSupported: boolean;
|
||||||
|
category: Category;
|
||||||
|
mimeType: string | null;
|
||||||
|
}[] {
|
||||||
|
const results: {
|
||||||
|
name: string;
|
||||||
|
fromSupported: boolean;
|
||||||
|
toSupported: boolean;
|
||||||
|
category: Category;
|
||||||
|
mimeType: string | null;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const converter of converters) {
|
||||||
|
const cat = getCategory(converter.name);
|
||||||
|
if (category && cat !== category) continue;
|
||||||
|
|
||||||
|
for (const fmt of converter.supportedFormats) {
|
||||||
|
// Skip video formats (input-only, not shown in list_formats)
|
||||||
|
if (!fmt.isNative && !fmt.toSupported) continue;
|
||||||
|
|
||||||
|
const undotted = fmt.name.startsWith(".") ? fmt.name.slice(1) : fmt.name;
|
||||||
|
if (seen.has(undotted)) continue;
|
||||||
|
seen.add(undotted);
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
name: undotted,
|
||||||
|
fromSupported: fmt.fromSupported,
|
||||||
|
toSupported: fmt.toSupported,
|
||||||
|
category: cat,
|
||||||
|
mimeType: MIME_TYPES[undotted] ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check which converters are available on this system.
|
||||||
|
*/
|
||||||
|
export async function checkAvailability(): Promise<Record<string, boolean>> {
|
||||||
|
const results: Record<string, boolean> = {};
|
||||||
|
for (const converter of converters) {
|
||||||
|
results[converter.name] = await converter.isAvailable();
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map converter name to category. */
|
||||||
|
function getCategory(converterName: string): Category {
|
||||||
|
switch (converterName) {
|
||||||
|
case "imagemagick":
|
||||||
|
return "image";
|
||||||
|
case "ffmpeg":
|
||||||
|
return "audio";
|
||||||
|
case "pandoc":
|
||||||
|
return "doc";
|
||||||
|
default:
|
||||||
|
return "image";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
/**
|
||||||
|
* Format metadata for a single file format.
|
||||||
|
* Adapted from VERT's FormatInfo class (converter.svelte.ts).
|
||||||
|
*/
|
||||||
|
export class FormatInfo {
|
||||||
|
public name: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
name: string,
|
||||||
|
public fromSupported = true,
|
||||||
|
public toSupported = true,
|
||||||
|
public isNative = true,
|
||||||
|
) {
|
||||||
|
this.name = name;
|
||||||
|
if (!this.name.startsWith(".")) {
|
||||||
|
this.name = `.${this.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.fromSupported && !this.toSupported) {
|
||||||
|
throw new Error("Format must support at least one direction");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options passed to a converter's convert method. */
|
||||||
|
export interface ConvertOptions {
|
||||||
|
inputPath: string;
|
||||||
|
outputPath: string;
|
||||||
|
outputFormat: string;
|
||||||
|
quality?: number;
|
||||||
|
keepMetadata?: boolean;
|
||||||
|
audioBitrate?: string;
|
||||||
|
sampleRate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result returned from a successful conversion. */
|
||||||
|
export interface ConvertResult {
|
||||||
|
outputPath: string;
|
||||||
|
format: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Base interface for all converters. */
|
||||||
|
export interface NodeConverter {
|
||||||
|
/** Converter name (e.g. "imagemagick", "ffmpeg", "pandoc"). */
|
||||||
|
readonly name: string;
|
||||||
|
/** Supported formats. */
|
||||||
|
readonly supportedFormats: FormatInfo[];
|
||||||
|
/** Whether this converter is available on the system. */
|
||||||
|
isAvailable(): Promise<boolean>;
|
||||||
|
/** Convert a file. */
|
||||||
|
convert(options: ConvertOptions): Promise<ConvertResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Common MIME types by extension (undotted). */
|
||||||
|
export const MIME_TYPES: Record<string, string> = {
|
||||||
|
// Common image formats
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
jpe: "image/jpeg",
|
||||||
|
jfif: "image/jpeg",
|
||||||
|
gif: "image/gif",
|
||||||
|
webp: "image/webp",
|
||||||
|
svg: "image/svg+xml",
|
||||||
|
svgz: "image/svg+xml",
|
||||||
|
bmp: "image/bmp",
|
||||||
|
ico: "image/x-icon",
|
||||||
|
cur: "image/x-icon",
|
||||||
|
tiff: "image/tiff",
|
||||||
|
tif: "image/tiff",
|
||||||
|
avif: "image/avif",
|
||||||
|
heic: "image/heic",
|
||||||
|
heif: "image/heif",
|
||||||
|
jxl: "image/jxl",
|
||||||
|
psd: "image/vnd.adobe.photoshop",
|
||||||
|
hdr: "image/vnd.radiance",
|
||||||
|
exr: "image/x-exr",
|
||||||
|
dds: "image/vnd.ms-dds",
|
||||||
|
pbm: "image/x-portable-bitmap",
|
||||||
|
pgm: "image/x-portable-graymap",
|
||||||
|
ppm: "image/x-portable-pixmap",
|
||||||
|
pnm: "image/x-portable-anymap",
|
||||||
|
pam: "image/x-portable-arbitrarymap",
|
||||||
|
pcx: "image/x-pcx",
|
||||||
|
tga: "image/x-tga",
|
||||||
|
qoi: "image/x-qoi",
|
||||||
|
ani: "application/x-navi-animation",
|
||||||
|
icns: "image/x-icns",
|
||||||
|
|
||||||
|
// Audio formats
|
||||||
|
mp3: "audio/mpeg",
|
||||||
|
wav: "audio/wav",
|
||||||
|
flac: "audio/flac",
|
||||||
|
ogg: "audio/ogg",
|
||||||
|
oga: "audio/ogg",
|
||||||
|
opus: "audio/opus",
|
||||||
|
aac: "audio/aac",
|
||||||
|
m4a: "audio/mp4",
|
||||||
|
m4b: "audio/mp4",
|
||||||
|
wma: "audio/x-ms-wma",
|
||||||
|
amr: "audio/amr",
|
||||||
|
ac3: "audio/ac3",
|
||||||
|
aiff: "audio/aiff",
|
||||||
|
aif: "audio/aiff",
|
||||||
|
aifc: "audio/aiff",
|
||||||
|
au: "audio/basic",
|
||||||
|
mp2: "audio/mpeg",
|
||||||
|
caf: "audio/x-caf",
|
||||||
|
voc: "audio/x-voc",
|
||||||
|
weba: "audio/webm",
|
||||||
|
|
||||||
|
// Document formats
|
||||||
|
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
md: "text/markdown",
|
||||||
|
html: "text/html",
|
||||||
|
rtf: "application/rtf",
|
||||||
|
csv: "text/csv",
|
||||||
|
tsv: "text/tab-separated-values",
|
||||||
|
json: "application/json",
|
||||||
|
rst: "text/x-rst",
|
||||||
|
epub: "application/epub+zip",
|
||||||
|
odt: "application/vnd.oasis.opendocument.text",
|
||||||
|
docbook: "application/docbook+xml",
|
||||||
|
|
||||||
|
// Video formats (input only for audio extraction)
|
||||||
|
mp4: "video/mp4",
|
||||||
|
mkv: "video/x-matroska",
|
||||||
|
avi: "video/x-msvideo",
|
||||||
|
mov: "video/quicktime",
|
||||||
|
webm: "video/webm",
|
||||||
|
wmv: "video/x-ms-wmv",
|
||||||
|
flv: "video/x-flv",
|
||||||
|
mpg: "video/mpeg",
|
||||||
|
mpeg: "video/mpeg",
|
||||||
|
m4v: "video/mp4",
|
||||||
|
"3gp": "video/3gpp",
|
||||||
|
ogv: "video/ogg",
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
validateInputPath,
|
||||||
|
validateOutputPath,
|
||||||
|
validateFormat,
|
||||||
|
validateAudioBitrate,
|
||||||
|
validateSampleRate,
|
||||||
|
} from "./security.js";
|
||||||
|
import {
|
||||||
|
buildFormatAllowlist,
|
||||||
|
getConverter,
|
||||||
|
getFormatInfo,
|
||||||
|
listFormats,
|
||||||
|
checkAvailability,
|
||||||
|
} from "./converters/registry.js";
|
||||||
|
|
||||||
|
// Build format allowlist once at startup
|
||||||
|
const FORMAT_ALLOWLIST = buildFormatAllowlist();
|
||||||
|
|
||||||
|
const server = new McpServer({
|
||||||
|
name: "vert",
|
||||||
|
version: "0.1.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── convert_file ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"convert_file",
|
||||||
|
"Convert a file between formats. Supports 140+ image formats (via ImageMagick WASM), 28+ audio formats (via system ffmpeg), and 11 document formats (via system pandoc). Video input is supported for audio extraction (e.g., MP4 to MP3).",
|
||||||
|
{
|
||||||
|
input_path: z.string().describe("Absolute path to the input file"),
|
||||||
|
output_format: z.string().describe("Target format extension (e.g. \"webp\", \"mp3\", \"docx\")"),
|
||||||
|
output_path: z.string().optional().describe("Output file path. Defaults to input directory with new extension"),
|
||||||
|
quality: z.number().min(1).max(100).optional().describe("Compression quality for lossy image formats (1-100)"),
|
||||||
|
keep_metadata: z.boolean().optional().describe("Preserve file metadata. Defaults to true"),
|
||||||
|
audio_bitrate: z.string().optional().describe("Audio bitrate (e.g. \"128k\", \"320k\")"),
|
||||||
|
sample_rate: z.number().optional().describe("Audio sample rate in Hz (e.g. 44100, 48000)"),
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
try {
|
||||||
|
// Validate input path
|
||||||
|
const resolvedInput = await validateInputPath(params.input_path);
|
||||||
|
|
||||||
|
// Validate output format
|
||||||
|
const outputFormat = validateFormat(params.output_format, FORMAT_ALLOWLIST);
|
||||||
|
|
||||||
|
// Validate input format
|
||||||
|
const inputExt = path.extname(resolvedInput).slice(1).toLowerCase();
|
||||||
|
if (!inputExt) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "Input file has no extension. Cannot determine format." }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Input format validation: check it's a known format (but don't block unsupported WASM
|
||||||
|
// formats for input — they may be handled by ffmpeg/pandoc)
|
||||||
|
if (!FORMAT_ALLOWLIST.has(inputExt)) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Unknown input format: ${inputExt}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine output path
|
||||||
|
let outputPath: string;
|
||||||
|
if (params.output_path) {
|
||||||
|
outputPath = params.output_path;
|
||||||
|
} else {
|
||||||
|
const dir = path.dirname(resolvedInput);
|
||||||
|
const base = path.basename(resolvedInput, path.extname(resolvedInput));
|
||||||
|
outputPath = path.join(dir, `${base}.${outputFormat}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate output path
|
||||||
|
const resolvedOutput = await validateOutputPath(outputPath, resolvedInput);
|
||||||
|
|
||||||
|
// Validate optional params
|
||||||
|
if (params.audio_bitrate !== undefined) {
|
||||||
|
validateAudioBitrate(params.audio_bitrate);
|
||||||
|
}
|
||||||
|
if (params.sample_rate !== undefined) {
|
||||||
|
validateSampleRate(params.sample_rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a converter
|
||||||
|
const converter = getConverter(inputExt, outputFormat);
|
||||||
|
if (!converter) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `No converter available for ${inputExt} → ${outputFormat}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the converter is available on this system
|
||||||
|
const available = await converter.isAvailable();
|
||||||
|
if (!available) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: `Converter "${converter.name}" is not available on this system. ${converter.name === "ffmpeg" ? "Install ffmpeg to convert audio files." : converter.name === "pandoc" ? "Install pandoc to convert documents." : ""}`,
|
||||||
|
}],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run conversion
|
||||||
|
const result = await converter.convert({
|
||||||
|
inputPath: resolvedInput,
|
||||||
|
outputPath: resolvedOutput,
|
||||||
|
outputFormat,
|
||||||
|
quality: params.quality,
|
||||||
|
keepMetadata: params.keep_metadata,
|
||||||
|
audioBitrate: params.audio_bitrate,
|
||||||
|
sampleRate: params.sample_rate,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify({
|
||||||
|
output_path: result.outputPath,
|
||||||
|
format: result.format,
|
||||||
|
size_bytes: result.sizeBytes,
|
||||||
|
}, null, 2),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Conversion failed: ${(err as Error).message}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── list_formats ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"list_formats",
|
||||||
|
"List supported file formats, optionally filtered by category. Returns format name, supported directions, category, and MIME type.",
|
||||||
|
{
|
||||||
|
category: z.enum(["image", "audio", "doc"]).optional().describe(
|
||||||
|
"Filter by category: \"image\", \"audio\", or \"doc\""
|
||||||
|
),
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
try {
|
||||||
|
const formats = listFormats(params.category);
|
||||||
|
const availability = await checkAvailability();
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify({
|
||||||
|
converters: availability,
|
||||||
|
formats,
|
||||||
|
total: formats.length,
|
||||||
|
}, null, 2),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Failed to list formats: ${(err as Error).message}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── get_format_info ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"get_format_info",
|
||||||
|
"Get detailed information about a specific file format, including supported conversion directions, category, MIME type, and which converter handles it.",
|
||||||
|
{
|
||||||
|
format: z.string().describe("File extension without dot (e.g. \"png\", \"mp3\", \"docx\")"),
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
try {
|
||||||
|
const normalized = params.format.startsWith(".")
|
||||||
|
? params.format.slice(1).toLowerCase()
|
||||||
|
: params.format.toLowerCase();
|
||||||
|
|
||||||
|
const info = getFormatInfo(normalized);
|
||||||
|
if (!info) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Unknown format: ${normalized}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(info, null, 2),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Failed to get format info: ${(err as Error).message}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Start server ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await server.connect(transport);
|
||||||
|
console.error("[vert-mcp] Server started on stdio");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("[vert-mcp] Fatal error:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
import { stat, realpath, access, constants } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/** Dangerous ImageMagick format names that could execute code or fetch URLs. */
|
||||||
|
const DANGEROUS_FORMATS = new Set([
|
||||||
|
"mvg", "msl", "ephemeral", "url", "http", "https",
|
||||||
|
"ftp", "text", "label", "caption",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Formats that lack WASM delegates and will fail silently or produce garbage. */
|
||||||
|
const UNSUPPORTED_WASM_FORMATS = new Set([
|
||||||
|
"pdf", "pdfa", "eps", "ps", "ps1", "ps2", "ps3",
|
||||||
|
// SVG INPUT is unsupported (needs librsvg), but OUTPUT works via IM's internal writer.
|
||||||
|
// Blocked at the converter level (fromSupported=false) instead of here.
|
||||||
|
// RAW camera formats (need libraw)
|
||||||
|
"cr2", "nef", "arw", "dng", "cr3", "orf", "rw2", "pef",
|
||||||
|
"srf", "sr2", "raf", "mrw", "erf", "kdc", "dcr", "rwl",
|
||||||
|
"iiq", "3fr", "crw", "mef", "nrw", "srw", "mos", "raw",
|
||||||
|
// Old binary .doc (pandoc docx reader can't handle it)
|
||||||
|
"doc",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and resolve an input file path.
|
||||||
|
* Returns the resolved absolute path.
|
||||||
|
*/
|
||||||
|
export async function validateInputPath(inputPath: string): Promise<string> {
|
||||||
|
assertNoPathInjection(inputPath);
|
||||||
|
|
||||||
|
const resolved = path.resolve(inputPath);
|
||||||
|
const real = await realpath(resolved);
|
||||||
|
|
||||||
|
const stats = await stat(real);
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
throw new Error(`Input path is not a regular file: ${inputPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 100 MB limit
|
||||||
|
const MAX_SIZE = 100 * 1024 * 1024;
|
||||||
|
if (stats.size > MAX_SIZE) {
|
||||||
|
throw new Error(
|
||||||
|
`Input file exceeds 100 MB limit: ${(stats.size / 1024 / 1024).toFixed(1)} MB`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return real;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and resolve an output file path.
|
||||||
|
* Returns the resolved absolute path, ensuring it won't overwrite existing files.
|
||||||
|
*/
|
||||||
|
export async function validateOutputPath(
|
||||||
|
outputPath: string,
|
||||||
|
inputPath: string,
|
||||||
|
): Promise<string> {
|
||||||
|
assertNoPathInjection(outputPath);
|
||||||
|
|
||||||
|
const resolved = path.resolve(outputPath);
|
||||||
|
|
||||||
|
// Verify output !== input after resolution
|
||||||
|
if (resolved === inputPath) {
|
||||||
|
throw new Error("Output path cannot be the same as input path");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify parent directory exists and is writable
|
||||||
|
const parentDir = path.dirname(resolved);
|
||||||
|
try {
|
||||||
|
await access(parentDir, constants.W_OK);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Output directory is not writable: ${parentDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't overwrite existing files — auto-suffix
|
||||||
|
return await findAvailablePath(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a format string against the allowlist.
|
||||||
|
* Returns the normalized undotted format string.
|
||||||
|
*/
|
||||||
|
export function validateFormat(format: string, allowlist: Set<string>): string {
|
||||||
|
// Strip leading dot if present
|
||||||
|
const normalized = format.startsWith(".") ? format.slice(1) : format;
|
||||||
|
const lower = normalized.toLowerCase();
|
||||||
|
|
||||||
|
if (DANGEROUS_FORMATS.has(lower)) {
|
||||||
|
throw new Error(`Blocked dangerous format: ${lower}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (UNSUPPORTED_WASM_FORMATS.has(lower)) {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported format: ${lower} (requires system libraries not available in WASM)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowlist.has(lower)) {
|
||||||
|
throw new Error(`Unknown format: ${lower}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lower;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate audio bitrate parameter.
|
||||||
|
*/
|
||||||
|
export function validateAudioBitrate(bitrate: string): string {
|
||||||
|
if (!/^\d+k$/.test(bitrate)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid audio bitrate: ${bitrate}. Expected format like "128k" or "320k".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return bitrate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate sample rate parameter.
|
||||||
|
*/
|
||||||
|
export function validateSampleRate(rate: number): number {
|
||||||
|
if (!Number.isInteger(rate) || rate < 8000 || rate > 192000) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid sample rate: ${rate}. Must be an integer between 8000 and 192000.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check for path injection attempts. */
|
||||||
|
function assertNoPathInjection(filePath: string): void {
|
||||||
|
// Block null bytes
|
||||||
|
if (filePath.includes("\0")) {
|
||||||
|
throw new Error("Path contains null bytes");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block Windows UNC paths
|
||||||
|
if (filePath.startsWith("\\\\")) {
|
||||||
|
throw new Error("UNC paths are not allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block NTFS alternate data streams (colons after drive prefix)
|
||||||
|
// Allow the drive letter colon (e.g., C:\) but block file.txt:stream
|
||||||
|
const withoutDrive = filePath.replace(/^[A-Za-z]:/, "");
|
||||||
|
if (withoutDrive.includes(":")) {
|
||||||
|
throw new Error("NTFS alternate data streams are not allowed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find an available path by appending _1, _2, etc. if the file exists. */
|
||||||
|
async function findAvailablePath(filePath: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
await stat(filePath);
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist, path is available
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(filePath);
|
||||||
|
const base = filePath.slice(0, -ext.length || undefined);
|
||||||
|
|
||||||
|
for (let i = 1; i <= 1000; i++) {
|
||||||
|
const candidate = `${base}_${i}${ext}`;
|
||||||
|
try {
|
||||||
|
await stat(candidate);
|
||||||
|
} catch {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Could not find available output path after 1000 attempts");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
// THIS CODE IS FROM https://github.com/captbaritone/webamp/blob/15b0312cb794973a0e615d894df942452e920c36/packages/ani-cursor/src/parser.ts
|
||||||
|
// LICENSED UNDER MIT. (c) Jordan Eldredge and Webamp contributors
|
||||||
|
|
||||||
|
// this code is ripped from their project because i didn't want to
|
||||||
|
// re-invent the wheel, BUT the library they provide (ani-cursor)
|
||||||
|
// doesn't expose the internals.
|
||||||
|
|
||||||
|
import riffFile from "riff-file";
|
||||||
|
const { RIFFFile } = riffFile;
|
||||||
|
import { unpackArray, unpackString } from "byte-data";
|
||||||
|
|
||||||
|
type Chunk = {
|
||||||
|
format: string;
|
||||||
|
chunkId: string;
|
||||||
|
chunkData: {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
};
|
||||||
|
subChunks: Chunk[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// https://www.informit.com/articles/article.aspx?p=1189080&seqNum=3
|
||||||
|
type AniMetadata = {
|
||||||
|
cbSize: number; // Data structure size (in bytes)
|
||||||
|
nFrames: number; // Number of images (also known as frames) stored in the file
|
||||||
|
nSteps: number; // Number of frames to be displayed before the animation repeats
|
||||||
|
iWidth: number; // Width of frame (in pixels)
|
||||||
|
iHeight: number; // Height of frame (in pixels)
|
||||||
|
iBitCount: number; // Number of bits per pixel
|
||||||
|
nPlanes: number; // Number of color planes
|
||||||
|
iDispRate: number; // Default frame display rate (measured in 1/60th-of-a-second units)
|
||||||
|
bfAttributes: number; // ANI attribute bit flags
|
||||||
|
};
|
||||||
|
|
||||||
|
type ParsedAni = {
|
||||||
|
rate: number[] | null;
|
||||||
|
seq: number[] | null;
|
||||||
|
images: Uint8Array[];
|
||||||
|
metadata: AniMetadata;
|
||||||
|
artist: string | null;
|
||||||
|
title: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DWORD = { bits: 32, be: false, signed: false, fp: false };
|
||||||
|
|
||||||
|
export function parseAni(arr: Uint8Array): ParsedAni {
|
||||||
|
const riff = new RIFFFile();
|
||||||
|
|
||||||
|
riff.setSignature(arr);
|
||||||
|
|
||||||
|
const signature = riff.signature as Chunk;
|
||||||
|
if (signature.format !== "ACON") {
|
||||||
|
throw new Error(
|
||||||
|
`Expected format. Expected "ACON", got "${signature.format}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get a chunk by chunkId and transform it if it's non-null.
|
||||||
|
function mapChunk<T>(
|
||||||
|
chunkId: string,
|
||||||
|
mapper: (chunk: Chunk) => T,
|
||||||
|
): T | null {
|
||||||
|
const chunk = riff.findChunk(chunkId) as Chunk | null;
|
||||||
|
return chunk == null ? null : mapper(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readImages(chunk: Chunk, frameCount: number): Uint8Array[] {
|
||||||
|
return chunk.subChunks.slice(0, frameCount).map((c) => {
|
||||||
|
if (c.chunkId !== "icon") {
|
||||||
|
throw new Error(`Unexpected chunk type in fram: ${c.chunkId}`);
|
||||||
|
}
|
||||||
|
return arr.slice(c.chunkData.start, c.chunkData.end);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = mapChunk("anih", (c) => {
|
||||||
|
const words = unpackArray(
|
||||||
|
arr,
|
||||||
|
DWORD,
|
||||||
|
c.chunkData.start,
|
||||||
|
c.chunkData.end,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
cbSize: words[0],
|
||||||
|
nFrames: words[1],
|
||||||
|
nSteps: words[2],
|
||||||
|
iWidth: words[3],
|
||||||
|
iHeight: words[4],
|
||||||
|
iBitCount: words[5],
|
||||||
|
nPlanes: words[6],
|
||||||
|
iDispRate: words[7],
|
||||||
|
bfAttributes: words[8],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (metadata == null) {
|
||||||
|
throw new Error("Did not find anih");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rate = mapChunk("rate", (c) => {
|
||||||
|
return unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end);
|
||||||
|
});
|
||||||
|
// chunkIds are always four chars, hence the trailing space.
|
||||||
|
const seq = mapChunk("seq ", (c) => {
|
||||||
|
return unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end);
|
||||||
|
});
|
||||||
|
|
||||||
|
const lists = riff.findChunk("LIST", true) as Chunk[] | null;
|
||||||
|
const imageChunk = lists?.find((c) => c.format === "fram");
|
||||||
|
if (imageChunk == null) {
|
||||||
|
throw new Error("Did not find fram LIST");
|
||||||
|
}
|
||||||
|
|
||||||
|
let images = readImages(imageChunk, metadata.nFrames);
|
||||||
|
|
||||||
|
let title = null;
|
||||||
|
let artist = null;
|
||||||
|
|
||||||
|
const infoChunk = lists?.find((c) => c.format === "INFO");
|
||||||
|
if (infoChunk != null) {
|
||||||
|
infoChunk.subChunks.forEach((c) => {
|
||||||
|
switch (c.chunkId) {
|
||||||
|
case "INAM":
|
||||||
|
title = unpackString(
|
||||||
|
arr,
|
||||||
|
c.chunkData.start,
|
||||||
|
c.chunkData.end,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "IART":
|
||||||
|
artist = unpackString(
|
||||||
|
arr,
|
||||||
|
c.chunkData.start,
|
||||||
|
c.chunkData.end,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "LIST":
|
||||||
|
// Some cursors with an artist of "Created with Take ONE 3.5 (unregisterred version)" seem to have their frames here for some reason?
|
||||||
|
if (c.format === "fram") {
|
||||||
|
images = readImages(c, metadata.nFrames);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Unexpected subchunk
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { images, rate, seq, metadata, artist, title };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"declaration": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue