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