lazy-media-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +118 -0
- package/bin/lazy-media-mcp.js +2 -0
- package/dist/cli.js +41 -0
- package/dist/config.js +66 -0
- package/dist/engines/ffmpegVideo.js +147 -0
- package/dist/engines/process.js +66 -0
- package/dist/engines/sharpImage.js +98 -0
- package/dist/errors.js +57 -0
- package/dist/formats/profiles.js +156 -0
- package/dist/index.js +124 -0
- package/dist/jobs/workdir.js +19 -0
- package/dist/logger.js +36 -0
- package/dist/policyGuard.js +121 -0
- package/dist/services/mediaService.js +367 -0
- package/dist/toolSchemas.js +49 -0
- package/package.json +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# lazy-media-mcp
|
|
2
|
+
|
|
3
|
+
Local MCP server that **compresses images/videos** and **prepares media for AI vision agents**.
|
|
4
|
+
|
|
5
|
+
Designed for coding agents (Claude / Codex / Grok): returns **file paths only** (no inline base64).
|
|
6
|
+
|
|
7
|
+
## Why this exists
|
|
8
|
+
|
|
9
|
+
Large screenshots and long demos burn context and often fail tool limits. This server:
|
|
10
|
+
|
|
11
|
+
1. Shrinks images to a sensible size/quality
|
|
12
|
+
2. Turns videos into **frame packs** agents can actually open
|
|
13
|
+
3. Uses **JPEG by default** for widest agent compatibility
|
|
14
|
+
|
|
15
|
+
### WebP / WebM — do they help AI “read better”?
|
|
16
|
+
|
|
17
|
+
| Format | Role | Default here? |
|
|
18
|
+
|--------|------|----------------|
|
|
19
|
+
| **JPEG** | Best universal image input for local agents | **Yes** |
|
|
20
|
+
| **PNG** | Sharper for OCR / UI text / alpha | `ocr_text` profile |
|
|
21
|
+
| **WebP** | Smaller files when the host supports it | **Opt-in only** |
|
|
22
|
+
| **MP4** | Storage/sharing re-encode | Video compress default |
|
|
23
|
+
| **WebM** | Optional container | Opt-in via `video_compress` |
|
|
24
|
+
|
|
25
|
+
**Format does not improve model understanding by itself.** Resolution, blur, and compression artifacts matter more. Over-aggressive WebP/JPEG hurts OCR.
|
|
26
|
+
|
|
27
|
+
Local agents usually **do not natively watch WebM/MP4**. Prefer `prepare_for_ai` / `video_extract_frames` → JPEG paths.
|
|
28
|
+
|
|
29
|
+
## Requirements
|
|
30
|
+
|
|
31
|
+
- Node.js ≥ 20
|
|
32
|
+
- [ffmpeg](https://ffmpeg.org/) + ffprobe on `PATH` (video tools)
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# macOS
|
|
36
|
+
brew install ffmpeg
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Install / run
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
cd lazy-media-mcp
|
|
43
|
+
npm install
|
|
44
|
+
npm run build
|
|
45
|
+
npm test
|
|
46
|
+
node dist/cli.js # stdio MCP
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### MCP client config (example)
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"mcpServers": {
|
|
54
|
+
"lazy-media": {
|
|
55
|
+
"command": "node",
|
|
56
|
+
"args": ["/absolute/path/to/lazy-media-mcp/dist/cli.js"],
|
|
57
|
+
"env": {
|
|
58
|
+
"MEDIA_ALLOWED_ROOTS": "/Users/you,/Users/you/WorkSpace",
|
|
59
|
+
"MEDIA_WORKDIR": "/Users/you/.cache/lazy-media-mcp/jobs"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Tools
|
|
67
|
+
|
|
68
|
+
| Tool | Purpose |
|
|
69
|
+
|------|---------|
|
|
70
|
+
| `media_inspect` | Metadata only |
|
|
71
|
+
| `image_compress` | Resize/compress image → workdir path |
|
|
72
|
+
| `video_compress` | Re-encode video (default MP4) |
|
|
73
|
+
| `video_extract_frames` | Extract frames for vision |
|
|
74
|
+
| `prepare_for_ai` | One-shot profile pipeline (**recommended**) |
|
|
75
|
+
| `media_cleanup` | Delete a job directory by `job_id` |
|
|
76
|
+
|
|
77
|
+
### `prepare_for_ai` profiles
|
|
78
|
+
|
|
79
|
+
| Profile | Behavior |
|
|
80
|
+
|---------|----------|
|
|
81
|
+
| `ai_vision` (default) | Image → JPEG ≤1536 edge; video → up to 10 JPEG frames |
|
|
82
|
+
| `ocr_text` | Prefer PNG / higher quality |
|
|
83
|
+
| `inline_small` | Smaller edges, fewer frames |
|
|
84
|
+
| `archive` | Higher quality + optional compressed MP4 |
|
|
85
|
+
|
|
86
|
+
## Environment
|
|
87
|
+
|
|
88
|
+
| Variable | Default |
|
|
89
|
+
|----------|---------|
|
|
90
|
+
| `MEDIA_ALLOWED_ROOTS` | `$HOME`, cwd, workdir |
|
|
91
|
+
| `MEDIA_WORKDIR` | `~/.cache/lazy-media-mcp/jobs` |
|
|
92
|
+
| `MEDIA_MAX_INPUT_BYTES` | 500MB |
|
|
93
|
+
| `MEDIA_MAX_OUTPUT_BYTES` | 200MB |
|
|
94
|
+
| `MEDIA_MAX_FRAMES` | 24 |
|
|
95
|
+
| `MEDIA_PROCESS_TIMEOUT_MS` | 120000 |
|
|
96
|
+
| `FFMPEG_BIN` / `FFPROBE_BIN` | `ffmpeg` / `ffprobe` |
|
|
97
|
+
| `LOG_LEVEL` | `info` |
|
|
98
|
+
|
|
99
|
+
## Security
|
|
100
|
+
|
|
101
|
+
- Path allowlist (realpath checks)
|
|
102
|
+
- Input/output size caps
|
|
103
|
+
- Process timeout
|
|
104
|
+
- ffmpeg/ffprobe invoked with argv arrays only (no shell interpolation)
|
|
105
|
+
- Outputs go to workdir; originals are not overwritten
|
|
106
|
+
- Cleanup only deletes direct children of workdir by `job_id`
|
|
107
|
+
|
|
108
|
+
## Typical agent flow
|
|
109
|
+
|
|
110
|
+
```text
|
|
111
|
+
1. prepare_for_ai({ path: "/path/to/demo.mp4", profile: "ai_vision" })
|
|
112
|
+
2. Read returned outputs[].path frame files in the next vision step
|
|
113
|
+
3. media_cleanup({ job_id }) when done (optional)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { startServer } from "./index.js";
|
|
2
|
+
function usage() {
|
|
3
|
+
return [
|
|
4
|
+
"Usage:",
|
|
5
|
+
" lazy-media-mcp Start MCP server over stdio",
|
|
6
|
+
" lazy-media-mcp --help Show this help",
|
|
7
|
+
"",
|
|
8
|
+
"Environment:",
|
|
9
|
+
" MEDIA_ALLOWED_ROOTS Comma-separated allowed path roots (default: $HOME,cwd,workdir)",
|
|
10
|
+
" MEDIA_WORKDIR Job output directory (default: ~/.cache/lazy-media-mcp/jobs)",
|
|
11
|
+
" MEDIA_MAX_INPUT_BYTES Max input file size (default: 500MB)",
|
|
12
|
+
" MEDIA_MAX_OUTPUT_BYTES Max single output file size (default: 200MB)",
|
|
13
|
+
" MEDIA_MAX_FRAMES Max frames per extract (default: 24)",
|
|
14
|
+
" MEDIA_PROCESS_TIMEOUT_MS ffmpeg/ffprobe timeout (default: 120000)",
|
|
15
|
+
" FFMPEG_BIN / FFPROBE_BIN Binary paths (default: ffmpeg / ffprobe)",
|
|
16
|
+
" LOG_LEVEL debug|info|warn|error"
|
|
17
|
+
].join("\n");
|
|
18
|
+
}
|
|
19
|
+
async function main() {
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
const command = args[0] ?? "";
|
|
22
|
+
if (command === "--help" || command === "-h") {
|
|
23
|
+
process.stdout.write(`${usage()}\n`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (command !== "" && command !== undefined) {
|
|
27
|
+
process.stderr.write(`Unknown command: ${command}\n`);
|
|
28
|
+
process.stderr.write("Run 'lazy-media-mcp --help' for usage.\n");
|
|
29
|
+
process.exitCode = 1;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
await startServer();
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
37
|
+
process.stderr.write(`${JSON.stringify({ level: "error", event: "fatal", error: message })}\n`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
void main();
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
function parseCsv(input) {
|
|
5
|
+
if (!input) {
|
|
6
|
+
return [];
|
|
7
|
+
}
|
|
8
|
+
return input
|
|
9
|
+
.split(",")
|
|
10
|
+
.map((value) => value.trim())
|
|
11
|
+
.filter((value) => value.length > 0);
|
|
12
|
+
}
|
|
13
|
+
function parsePositiveInt(input, fallback) {
|
|
14
|
+
if (!input) {
|
|
15
|
+
return fallback;
|
|
16
|
+
}
|
|
17
|
+
const value = Number.parseInt(input, 10);
|
|
18
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
19
|
+
throw new Error(`Invalid positive integer: ${input}`);
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
export function loadConfig(env = process.env) {
|
|
24
|
+
const home = os.homedir();
|
|
25
|
+
const workdir = env.MEDIA_WORKDIR ?? path.join(home, ".cache", "lazy-media-mcp", "jobs");
|
|
26
|
+
const configuredRoots = parseCsv(env.MEDIA_ALLOWED_ROOTS).map((root) => path.resolve(root));
|
|
27
|
+
const defaultRoots = [home, process.cwd(), workdir].map((root) => path.resolve(root));
|
|
28
|
+
const allowedRoots = configuredRoots.length > 0 ? configuredRoots : defaultRoots;
|
|
29
|
+
const logLevel = env.LOG_LEVEL ?? "info";
|
|
30
|
+
if (!["debug", "info", "warn", "error"].includes(logLevel)) {
|
|
31
|
+
throw new Error(`Invalid LOG_LEVEL: ${logLevel}`);
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
allowedRoots: dedupePaths([...allowedRoots, path.resolve(workdir)]),
|
|
35
|
+
workdir: path.resolve(workdir),
|
|
36
|
+
logLevel,
|
|
37
|
+
ffmpegBin: env.FFMPEG_BIN ?? "ffmpeg",
|
|
38
|
+
ffprobeBin: env.FFPROBE_BIN ?? "ffprobe",
|
|
39
|
+
maxInputBytes: parsePositiveInt(env.MEDIA_MAX_INPUT_BYTES, 500 * 1024 * 1024),
|
|
40
|
+
maxOutputBytes: parsePositiveInt(env.MEDIA_MAX_OUTPUT_BYTES, 200 * 1024 * 1024),
|
|
41
|
+
maxFrames: parsePositiveInt(env.MEDIA_MAX_FRAMES, 24),
|
|
42
|
+
processTimeoutMs: parsePositiveInt(env.MEDIA_PROCESS_TIMEOUT_MS, 120_000)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function dedupePaths(paths) {
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
const result = [];
|
|
48
|
+
for (const entry of paths) {
|
|
49
|
+
const absolute = path.resolve(entry);
|
|
50
|
+
let normalized = absolute;
|
|
51
|
+
try {
|
|
52
|
+
if (fs.existsSync(absolute)) {
|
|
53
|
+
normalized = fs.realpathSync(absolute);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
normalized = absolute;
|
|
58
|
+
}
|
|
59
|
+
if (seen.has(normalized)) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
seen.add(normalized);
|
|
63
|
+
result.push(normalized);
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AppError } from "../errors.js";
|
|
4
|
+
import { extensionForImageFormat, extensionForVideoContainer } from "../formats/profiles.js";
|
|
5
|
+
import { runCommand } from "./process.js";
|
|
6
|
+
export async function probeMedia(filePath, options) {
|
|
7
|
+
const result = await runCommand(options.ffprobeBin, [
|
|
8
|
+
"-v",
|
|
9
|
+
"quiet",
|
|
10
|
+
"-print_format",
|
|
11
|
+
"json",
|
|
12
|
+
"-show_format",
|
|
13
|
+
"-show_streams",
|
|
14
|
+
filePath
|
|
15
|
+
], {
|
|
16
|
+
timeoutMs: options.timeoutMs,
|
|
17
|
+
traceId: options.traceId,
|
|
18
|
+
label: "ffprobe"
|
|
19
|
+
});
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(result.stdout);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
throw new AppError({
|
|
25
|
+
message: "ffprobe returned invalid JSON",
|
|
26
|
+
code: "ERR_FFPROBE_PARSE",
|
|
27
|
+
category: "dependency",
|
|
28
|
+
traceId: options.traceId,
|
|
29
|
+
cause: error
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function toVideoMeta(filePath, probe) {
|
|
34
|
+
const streams = probe.streams ?? [];
|
|
35
|
+
const video = streams.find((s) => s.codec_type === "video");
|
|
36
|
+
const audio = streams.find((s) => s.codec_type === "audio");
|
|
37
|
+
const durationRaw = probe.format?.duration ?? video?.duration;
|
|
38
|
+
const sizeRaw = probe.format?.size;
|
|
39
|
+
return {
|
|
40
|
+
path: filePath,
|
|
41
|
+
bytes: sizeRaw ? Number.parseInt(sizeRaw, 10) : fs.statSync(filePath).size,
|
|
42
|
+
duration_sec: durationRaw ? Number.parseFloat(durationRaw) : null,
|
|
43
|
+
width: video?.width ?? null,
|
|
44
|
+
height: video?.height ?? null,
|
|
45
|
+
video_codec: video?.codec_name ?? null,
|
|
46
|
+
audio_codec: audio?.codec_name ?? null,
|
|
47
|
+
format_name: probe.format?.format_name ?? null,
|
|
48
|
+
has_audio: Boolean(audio),
|
|
49
|
+
has_video: Boolean(video)
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export async function compressVideo(input) {
|
|
53
|
+
// spawn without shell: no backslash escaping needed for commas
|
|
54
|
+
const scaleFilter = `scale=-2:min(ih\\,${input.maxHeight})`;
|
|
55
|
+
const args = [
|
|
56
|
+
"-y",
|
|
57
|
+
"-i",
|
|
58
|
+
input.inputPath,
|
|
59
|
+
"-map_metadata",
|
|
60
|
+
"-1",
|
|
61
|
+
"-vf",
|
|
62
|
+
scaleFilter,
|
|
63
|
+
"-c:v",
|
|
64
|
+
input.container === "webm" ? "libvpx-vp9" : "libx264",
|
|
65
|
+
"-crf",
|
|
66
|
+
String(input.crf),
|
|
67
|
+
"-pix_fmt",
|
|
68
|
+
"yuv420p"
|
|
69
|
+
];
|
|
70
|
+
if (input.container === "mp4") {
|
|
71
|
+
args.push("-preset", "medium", "-movflags", "+faststart");
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
args.push("-b:v", "0");
|
|
75
|
+
}
|
|
76
|
+
args.push("-c:a", input.container === "webm" ? "libopus" : "aac", "-b:a", `${input.audioBitrateK}k`);
|
|
77
|
+
args.push(input.outputPath);
|
|
78
|
+
await fs.promises.mkdir(path.dirname(input.outputPath), { recursive: true });
|
|
79
|
+
await runCommand(input.ffmpegBin, args, {
|
|
80
|
+
timeoutMs: input.timeoutMs,
|
|
81
|
+
traceId: input.traceId,
|
|
82
|
+
label: "ffmpeg-compress"
|
|
83
|
+
});
|
|
84
|
+
const stat = await fs.promises.stat(input.outputPath);
|
|
85
|
+
return { path: input.outputPath, bytes: stat.size };
|
|
86
|
+
}
|
|
87
|
+
export async function extractFrames(input) {
|
|
88
|
+
await fs.promises.mkdir(input.outputDir, { recursive: true });
|
|
89
|
+
const ext = extensionForImageFormat(input.format);
|
|
90
|
+
const pattern = path.join(input.outputDir, `frame_%04d.${ext}`);
|
|
91
|
+
const fpsExpr = buildFpsExpression(input);
|
|
92
|
+
const vfParts = [`fps=${fpsExpr}`, `scale=min(${input.maxEdge}\\,iw):-2`];
|
|
93
|
+
const args = ["-y", "-i", input.inputPath, "-vf", vfParts.join(","), "-frames:v", String(input.maxFrames)];
|
|
94
|
+
if (input.format === "jpeg") {
|
|
95
|
+
args.push("-q:v", jpegQualityToFfmpeg(input.quality));
|
|
96
|
+
}
|
|
97
|
+
else if (input.format === "webp") {
|
|
98
|
+
args.push("-quality", String(input.quality));
|
|
99
|
+
}
|
|
100
|
+
args.push(pattern);
|
|
101
|
+
await runCommand(input.ffmpegBin, args, {
|
|
102
|
+
timeoutMs: input.timeoutMs,
|
|
103
|
+
traceId: input.traceId,
|
|
104
|
+
label: "ffmpeg-frames"
|
|
105
|
+
});
|
|
106
|
+
const files = (await fs.promises.readdir(input.outputDir))
|
|
107
|
+
.filter((name) => name.startsWith("frame_") && name.endsWith(`.${ext}`))
|
|
108
|
+
.sort()
|
|
109
|
+
.map((name) => path.join(input.outputDir, name));
|
|
110
|
+
if (files.length === 0) {
|
|
111
|
+
throw new AppError({
|
|
112
|
+
message: "No frames were extracted from the video",
|
|
113
|
+
code: "ERR_NO_FRAMES",
|
|
114
|
+
category: "business",
|
|
115
|
+
traceId: input.traceId
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return files.slice(0, input.maxFrames);
|
|
119
|
+
}
|
|
120
|
+
function buildFpsExpression(input) {
|
|
121
|
+
if (input.mode === "interval") {
|
|
122
|
+
const interval = Math.max(0.2, input.intervalSec);
|
|
123
|
+
return `1/${interval}`;
|
|
124
|
+
}
|
|
125
|
+
const duration = input.durationSec && input.durationSec > 0 ? input.durationSec : null;
|
|
126
|
+
if (!duration) {
|
|
127
|
+
// Fallback: ~1 fps capped by maxFrames via -frames:v
|
|
128
|
+
return "1";
|
|
129
|
+
}
|
|
130
|
+
// Spread maxFrames across duration (avoid div by zero)
|
|
131
|
+
const fps = Math.max(input.maxFrames / duration, 0.05);
|
|
132
|
+
return String(fps);
|
|
133
|
+
}
|
|
134
|
+
/** Map 1-100 quality to ffmpeg mjpeg qscale (2 best .. 31 worst). */
|
|
135
|
+
function jpegQualityToFfmpeg(quality) {
|
|
136
|
+
const q = Math.min(100, Math.max(1, quality));
|
|
137
|
+
const scale = Math.round(2 + ((100 - q) / 100) * 29);
|
|
138
|
+
return String(scale);
|
|
139
|
+
}
|
|
140
|
+
export function defaultVideoOutputName(baseName, container) {
|
|
141
|
+
const stem = path.parse(baseName).name || "video";
|
|
142
|
+
return `${stem}.${extensionForVideoContainer(container)}`;
|
|
143
|
+
}
|
|
144
|
+
export function isLikelyVideoPath(filePath) {
|
|
145
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
146
|
+
return [".mp4", ".mov", ".mkv", ".webm", ".avi", ".m4v", ".mpeg", ".mpg", ".wmv", ".flv", ".3gp"].includes(ext);
|
|
147
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { AppError } from "../errors.js";
|
|
3
|
+
export async function runCommand(bin, args, options) {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
const child = spawn(bin, args, {
|
|
6
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
7
|
+
});
|
|
8
|
+
let stdout = "";
|
|
9
|
+
let stderr = "";
|
|
10
|
+
let settled = false;
|
|
11
|
+
const timer = setTimeout(() => {
|
|
12
|
+
if (settled) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
settled = true;
|
|
16
|
+
child.kill("SIGKILL");
|
|
17
|
+
reject(new AppError({
|
|
18
|
+
message: `${options.label} timed out after ${options.timeoutMs}ms`,
|
|
19
|
+
code: "ERR_PROCESS_TIMEOUT",
|
|
20
|
+
category: "dependency",
|
|
21
|
+
traceId: options.traceId
|
|
22
|
+
}));
|
|
23
|
+
}, options.timeoutMs);
|
|
24
|
+
child.stdout.on("data", (chunk) => {
|
|
25
|
+
stdout += chunk.toString("utf8");
|
|
26
|
+
});
|
|
27
|
+
child.stderr.on("data", (chunk) => {
|
|
28
|
+
stderr += chunk.toString("utf8");
|
|
29
|
+
});
|
|
30
|
+
child.on("error", (error) => {
|
|
31
|
+
if (settled) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
settled = true;
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
const notFound = "code" in error && error.code === "ENOENT";
|
|
37
|
+
reject(new AppError({
|
|
38
|
+
message: notFound
|
|
39
|
+
? `${options.label} binary not found: ${bin}. Install ffmpeg (brew install ffmpeg) or set FFMPEG_BIN/FFPROBE_BIN.`
|
|
40
|
+
: `${options.label} failed to start: ${error.message}`,
|
|
41
|
+
code: notFound ? "ERR_BINARY_NOT_FOUND" : "ERR_PROCESS_SPAWN",
|
|
42
|
+
category: "dependency",
|
|
43
|
+
traceId: options.traceId,
|
|
44
|
+
cause: error
|
|
45
|
+
}));
|
|
46
|
+
});
|
|
47
|
+
child.on("close", (code) => {
|
|
48
|
+
if (settled) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
settled = true;
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
const exitCode = code ?? 1;
|
|
54
|
+
if (exitCode !== 0) {
|
|
55
|
+
reject(new AppError({
|
|
56
|
+
message: `${options.label} exited with code ${exitCode}: ${stderr.trim() || stdout.trim() || "no output"}`,
|
|
57
|
+
code: "ERR_PROCESS_FAILED",
|
|
58
|
+
category: "dependency",
|
|
59
|
+
traceId: options.traceId
|
|
60
|
+
}));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
resolve({ stdout, stderr, code: exitCode });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import sharp from "sharp";
|
|
4
|
+
import { AppError } from "../errors.js";
|
|
5
|
+
import { clampQuality, extensionForImageFormat, resolveImageFormat } from "../formats/profiles.js";
|
|
6
|
+
export async function inspectImage(filePath, traceId) {
|
|
7
|
+
try {
|
|
8
|
+
const stat = fs.statSync(filePath);
|
|
9
|
+
const meta = await sharp(filePath).metadata();
|
|
10
|
+
return {
|
|
11
|
+
path: filePath,
|
|
12
|
+
bytes: stat.size,
|
|
13
|
+
width: meta.width ?? null,
|
|
14
|
+
height: meta.height ?? null,
|
|
15
|
+
format: meta.format ?? null,
|
|
16
|
+
hasAlpha: Boolean(meta.hasAlpha),
|
|
17
|
+
space: meta.space ?? null
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
throw new AppError({
|
|
22
|
+
message: `Failed to inspect image: ${filePath}`,
|
|
23
|
+
code: "ERR_IMAGE_INSPECT",
|
|
24
|
+
category: "dependency",
|
|
25
|
+
traceId,
|
|
26
|
+
cause: error
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function compressImage(input) {
|
|
31
|
+
const source = await inspectImage(input.inputPath, input.traceId);
|
|
32
|
+
const format = resolveImageFormat(input.format, {
|
|
33
|
+
hasAlpha: source.hasAlpha,
|
|
34
|
+
preferLossless: input.preferLossless
|
|
35
|
+
});
|
|
36
|
+
const quality = clampQuality(input.quality, input.qualityFloor);
|
|
37
|
+
try {
|
|
38
|
+
let pipeline = sharp(input.inputPath, { failOn: "none" }).rotate();
|
|
39
|
+
if (source.width && source.height) {
|
|
40
|
+
const longest = Math.max(source.width, source.height);
|
|
41
|
+
if (longest > input.maxEdge) {
|
|
42
|
+
pipeline = pipeline.resize({
|
|
43
|
+
width: source.width >= source.height ? input.maxEdge : undefined,
|
|
44
|
+
height: source.height > source.width ? input.maxEdge : undefined,
|
|
45
|
+
fit: "inside",
|
|
46
|
+
withoutEnlargement: true
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// sharp strips most metadata by default; only re-attach when strip is false
|
|
51
|
+
if (!input.stripMetadata) {
|
|
52
|
+
pipeline = pipeline.withMetadata();
|
|
53
|
+
}
|
|
54
|
+
switch (format) {
|
|
55
|
+
case "jpeg":
|
|
56
|
+
pipeline = pipeline.jpeg({ quality, mozjpeg: true });
|
|
57
|
+
break;
|
|
58
|
+
case "png":
|
|
59
|
+
pipeline = pipeline.png({ compressionLevel: 9 });
|
|
60
|
+
break;
|
|
61
|
+
case "webp":
|
|
62
|
+
pipeline = pipeline.webp({ quality });
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
await fs.promises.mkdir(path.dirname(input.outputPath), { recursive: true });
|
|
66
|
+
const info = await pipeline.toFile(input.outputPath);
|
|
67
|
+
return {
|
|
68
|
+
path: input.outputPath,
|
|
69
|
+
bytes: info.size,
|
|
70
|
+
width: info.width,
|
|
71
|
+
height: info.height,
|
|
72
|
+
format,
|
|
73
|
+
input_bytes: source.bytes,
|
|
74
|
+
input_width: source.width,
|
|
75
|
+
input_height: source.height
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error instanceof AppError) {
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
throw new AppError({
|
|
83
|
+
message: `Image compression failed: ${error instanceof Error ? error.message : "unknown"}`,
|
|
84
|
+
code: "ERR_IMAGE_COMPRESS",
|
|
85
|
+
category: "dependency",
|
|
86
|
+
traceId: input.traceId,
|
|
87
|
+
cause: error
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export function defaultImageOutputName(baseName, format) {
|
|
92
|
+
const stem = path.parse(baseName).name || "image";
|
|
93
|
+
return `${stem}.${extensionForImageFormat(format)}`;
|
|
94
|
+
}
|
|
95
|
+
export function isLikelyImagePath(filePath) {
|
|
96
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
97
|
+
return [".jpg", ".jpeg", ".png", ".webp", ".gif", ".tif", ".tiff", ".heic", ".heif", ".avif", ".bmp"].includes(ext);
|
|
98
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ZodError } from "zod";
|
|
2
|
+
export class AppError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
category;
|
|
5
|
+
traceId;
|
|
6
|
+
constructor(options) {
|
|
7
|
+
super(options.message, { cause: options.cause });
|
|
8
|
+
this.name = "AppError";
|
|
9
|
+
this.code = options.code;
|
|
10
|
+
this.category = options.category;
|
|
11
|
+
this.traceId = options.traceId;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function toErrorResponse(error) {
|
|
15
|
+
return {
|
|
16
|
+
error: error.message,
|
|
17
|
+
code: error.code,
|
|
18
|
+
trace_id: error.traceId
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function normalizeError(error, traceId) {
|
|
22
|
+
if (error instanceof AppError) {
|
|
23
|
+
return error;
|
|
24
|
+
}
|
|
25
|
+
if (error instanceof ZodError) {
|
|
26
|
+
return new AppError({
|
|
27
|
+
message: summarizeZodError(error),
|
|
28
|
+
code: "ERR_VALIDATION",
|
|
29
|
+
category: "validation",
|
|
30
|
+
traceId,
|
|
31
|
+
cause: error
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
if (error instanceof Error) {
|
|
35
|
+
return new AppError({
|
|
36
|
+
message: error.message,
|
|
37
|
+
code: "ERR_INTERNAL",
|
|
38
|
+
category: "system",
|
|
39
|
+
traceId,
|
|
40
|
+
cause: error
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return new AppError({
|
|
44
|
+
message: "Unknown server error",
|
|
45
|
+
code: "ERR_INTERNAL",
|
|
46
|
+
category: "system",
|
|
47
|
+
traceId
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function summarizeZodError(error) {
|
|
51
|
+
const issue = error.issues[0];
|
|
52
|
+
if (!issue) {
|
|
53
|
+
return "Invalid tool arguments";
|
|
54
|
+
}
|
|
55
|
+
const path = issue.path.length > 0 ? issue.path.join(".") : "arguments";
|
|
56
|
+
return `Invalid tool arguments: ${path} ${issue.message}`;
|
|
57
|
+
}
|