image-processor-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/.github/workflows/ci.yml +30 -0
- package/.github/workflows/publish.yml +34 -0
- package/LICENSE +21 -0
- package/NPM_PUBLISH_GUIDE.md +73 -0
- package/README.md +81 -0
- package/build/config/app.config.d.ts +4 -0
- package/build/config/app.config.js +4 -0
- package/build/config/config.constants.d.ts +24 -0
- package/build/config/config.constants.js +170 -0
- package/build/controllers/tool.controller.d.ts +48 -0
- package/build/controllers/tool.controller.js +230 -0
- package/build/index.d.ts +2 -0
- package/build/index.js +179 -0
- package/build/services/file.services.d.ts +17 -0
- package/build/services/file.services.js +170 -0
- package/build/services/image.services.d.ts +10 -0
- package/build/services/image.services.js +99 -0
- package/build/services/report.services.d.ts +4 -0
- package/build/services/report.services.js +121 -0
- package/build/types.d.ts +82 -0
- package/build/types.js +1 -0
- package/package.json +40 -0
- package/src/config/app.config.ts +4 -0
- package/src/config/config.constants.ts +181 -0
- package/src/controllers/tool.controller.ts +255 -0
- package/src/index.ts +204 -0
- package/src/services/file.services.ts +179 -0
- package/src/services/image.services.ts +132 -0
- package/src/services/report.services.ts +147 -0
- package/src/types.ts +89 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import { FileService } from './file.services.js';
|
|
3
|
+
import type { CompressionResult, CompressOptions } from '../types.js';
|
|
4
|
+
import { DEFAULTS } from '../config/config.constants.js';
|
|
5
|
+
|
|
6
|
+
export class ImageService {
|
|
7
|
+
static async compressImage(
|
|
8
|
+
inputPath: string,
|
|
9
|
+
outputPath: string,
|
|
10
|
+
options: CompressOptions,
|
|
11
|
+
): Promise<CompressionResult> {
|
|
12
|
+
const {
|
|
13
|
+
quality = DEFAULTS.quality,
|
|
14
|
+
lossless = DEFAULTS.lossless,
|
|
15
|
+
effort = DEFAULTS.effort,
|
|
16
|
+
width,
|
|
17
|
+
height,
|
|
18
|
+
maxDimension = DEFAULTS.maxDimension,
|
|
19
|
+
recursiveCompress = false,
|
|
20
|
+
expectedSizeKB = DEFAULTS.expectedSizeKB,
|
|
21
|
+
qualityStepDown = DEFAULTS.qualityStepDown,
|
|
22
|
+
minimumQualityFloor = DEFAULTS.minimumQualityFloor,
|
|
23
|
+
outputFormat = DEFAULTS.outputFormat,
|
|
24
|
+
} = options;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const originalSize = await FileService.getFileSize(inputPath);
|
|
28
|
+
const metadata = await sharp(inputPath).metadata();
|
|
29
|
+
const imgWidth = metadata.width || 0;
|
|
30
|
+
const imgHeight = metadata.height || 0;
|
|
31
|
+
|
|
32
|
+
let resizeOptions: { width?: number; height?: number } = {};
|
|
33
|
+
|
|
34
|
+
if (width || height) {
|
|
35
|
+
resizeOptions = { ...(width ? { width } : {}), ...(height ? { height } : {}) };
|
|
36
|
+
} else if (imgWidth > maxDimension || imgHeight > maxDimension) {
|
|
37
|
+
const ratio = Math.min(maxDimension / imgWidth, maxDimension / imgHeight);
|
|
38
|
+
resizeOptions = {
|
|
39
|
+
width: Math.round(imgWidth * ratio),
|
|
40
|
+
height: Math.round(imgHeight * ratio),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let currentQuality = quality;
|
|
45
|
+
let iterations = 0;
|
|
46
|
+
let compressedSize = 0;
|
|
47
|
+
const qualitySteps: number[] = [];
|
|
48
|
+
|
|
49
|
+
while (true) {
|
|
50
|
+
let pipeline = sharp(inputPath);
|
|
51
|
+
|
|
52
|
+
if (Object.keys(resizeOptions).length > 0) {
|
|
53
|
+
pipeline = pipeline.resize(resizeOptions.width, resizeOptions.height, {
|
|
54
|
+
fit: 'inside',
|
|
55
|
+
withoutEnlargement: true,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
pipeline = this.applyFormat(pipeline, outputFormat, { quality: currentQuality, lossless, effort });
|
|
60
|
+
await pipeline.toFile(outputPath);
|
|
61
|
+
|
|
62
|
+
compressedSize = await FileService.getFileSize(outputPath);
|
|
63
|
+
iterations++;
|
|
64
|
+
qualitySteps.push(currentQuality);
|
|
65
|
+
|
|
66
|
+
const compressedKB = compressedSize / 1024;
|
|
67
|
+
|
|
68
|
+
if (recursiveCompress && compressedKB > expectedSizeKB && currentQuality - qualityStepDown >= minimumQualityFloor) {
|
|
69
|
+
currentQuality -= qualityStepDown;
|
|
70
|
+
} else {
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const compressionRatio = originalSize > 0
|
|
76
|
+
? parseFloat(((originalSize - compressedSize) / originalSize * 100).toFixed(2))
|
|
77
|
+
: 0;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
inputPath,
|
|
81
|
+
outputPath,
|
|
82
|
+
originalSize,
|
|
83
|
+
compressedSize,
|
|
84
|
+
compressionRatio,
|
|
85
|
+
success: true,
|
|
86
|
+
iterations,
|
|
87
|
+
qualitySteps,
|
|
88
|
+
finalQuality: qualitySteps[qualitySteps.length - 1],
|
|
89
|
+
exceededTarget: recursiveCompress && (compressedSize / 1024) > expectedSizeKB,
|
|
90
|
+
};
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return {
|
|
93
|
+
inputPath,
|
|
94
|
+
outputPath,
|
|
95
|
+
originalSize: 0,
|
|
96
|
+
compressedSize: 0,
|
|
97
|
+
compressionRatio: 0,
|
|
98
|
+
success: false,
|
|
99
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
100
|
+
iterations: 0,
|
|
101
|
+
qualitySteps: [],
|
|
102
|
+
finalQuality: 0,
|
|
103
|
+
exceededTarget: false,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
static applyFormat(
|
|
109
|
+
pipeline: sharp.Sharp,
|
|
110
|
+
format: string,
|
|
111
|
+
options: { quality: number; lossless: boolean; effort: number },
|
|
112
|
+
): sharp.Sharp {
|
|
113
|
+
const { quality, lossless, effort } = options;
|
|
114
|
+
|
|
115
|
+
switch (format) {
|
|
116
|
+
case 'webp':
|
|
117
|
+
return pipeline.webp({ quality, lossless, effort, smartSubsample: true, alphaQuality: 90 });
|
|
118
|
+
case 'avif':
|
|
119
|
+
return pipeline.avif({ quality, lossless, effort });
|
|
120
|
+
case 'jpeg':
|
|
121
|
+
return pipeline.jpeg({ quality, mozjpeg: true });
|
|
122
|
+
case 'png':
|
|
123
|
+
return pipeline.png({ compressionLevel: 9 });
|
|
124
|
+
case 'tiff':
|
|
125
|
+
return pipeline.tiff({ quality, compression: 'lzw' });
|
|
126
|
+
case 'gif':
|
|
127
|
+
return pipeline.gif();
|
|
128
|
+
default:
|
|
129
|
+
return pipeline.webp({ quality, lossless });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import type { CompressionResult } from '../types.js';
|
|
4
|
+
import { FileService } from './file.services.js';
|
|
5
|
+
import { APP_CONFIG } from '../config/app.config.js';
|
|
6
|
+
import { SUPPORTED_OUTPUT_FORMATS } from '../config/config.constants.js';
|
|
7
|
+
|
|
8
|
+
export class ReportService {
|
|
9
|
+
static async generateReport(
|
|
10
|
+
results: CompressionResult[],
|
|
11
|
+
inputDir: string,
|
|
12
|
+
outputDir: string,
|
|
13
|
+
outputFormat: string,
|
|
14
|
+
startTime: number,
|
|
15
|
+
duration: number,
|
|
16
|
+
skippedFiles: string[] = [],
|
|
17
|
+
): Promise<string> {
|
|
18
|
+
const reportDir = path.join(outputDir, 'report');
|
|
19
|
+
await fs.ensureDir(reportDir);
|
|
20
|
+
|
|
21
|
+
const now = new Date();
|
|
22
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
23
|
+
const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
|
|
24
|
+
const reportFilename = `scan-report-${timestamp}.md`;
|
|
25
|
+
const reportPath = path.join(reportDir, reportFilename);
|
|
26
|
+
|
|
27
|
+
const successful = results.filter(r => r.success);
|
|
28
|
+
const failed = results.filter(r => !r.success);
|
|
29
|
+
const totalOriginalSize = results.reduce((s, r) => s + r.originalSize, 0);
|
|
30
|
+
const totalCompressedSize = results.reduce((s, r) => s + r.compressedSize, 0);
|
|
31
|
+
const overallReduction = totalOriginalSize > 0
|
|
32
|
+
? ((totalOriginalSize - totalCompressedSize) / totalOriginalSize * 100).toFixed(2)
|
|
33
|
+
: '0.00';
|
|
34
|
+
|
|
35
|
+
const lines: string[] = [];
|
|
36
|
+
|
|
37
|
+
lines.push('# Compression Report');
|
|
38
|
+
lines.push('');
|
|
39
|
+
lines.push('**MCP Server:** ' + APP_CONFIG.name + ' v' + APP_CONFIG.version);
|
|
40
|
+
lines.push('**Generated:** ' + now.toLocaleString());
|
|
41
|
+
lines.push('');
|
|
42
|
+
lines.push('**Duration:** ' + (duration / 1000).toFixed(2) + 's');
|
|
43
|
+
lines.push('');
|
|
44
|
+
|
|
45
|
+
if (results.length > 0 && skippedFiles.length === 0) {
|
|
46
|
+
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp', `.${outputFormat}`];
|
|
47
|
+
const outputFiles = await FileService.scanFiles(outputDir, outputDir, extensions);
|
|
48
|
+
const tree = FileService.buildTree(outputFiles, outputDir);
|
|
49
|
+
const treeLines = FileService.formatTreeMarkdown(tree);
|
|
50
|
+
|
|
51
|
+
lines.push('## Summary');
|
|
52
|
+
lines.push('');
|
|
53
|
+
lines.push('| Metric | Value |');
|
|
54
|
+
lines.push('|--------|-------|');
|
|
55
|
+
lines.push(`| Total files | ${results.length} |`);
|
|
56
|
+
lines.push(`| Successful | ${successful.length} |`);
|
|
57
|
+
lines.push(`| Failed | ${failed.length} |`);
|
|
58
|
+
lines.push(`| Total original size | ${FileService.formatBytes(totalOriginalSize)} |`);
|
|
59
|
+
lines.push(`| Total compressed size | ${FileService.formatBytes(totalCompressedSize)} |`);
|
|
60
|
+
lines.push(`| Overall reduction | ${overallReduction}% |`);
|
|
61
|
+
lines.push('');
|
|
62
|
+
|
|
63
|
+
lines.push('## File Tree');
|
|
64
|
+
lines.push('');
|
|
65
|
+
lines.push('```');
|
|
66
|
+
lines.push(...treeLines);
|
|
67
|
+
lines.push('```');
|
|
68
|
+
lines.push('');
|
|
69
|
+
|
|
70
|
+
lines.push('## File Details');
|
|
71
|
+
lines.push('');
|
|
72
|
+
const uniqueResults = new Map<string, typeof successful[0]>();
|
|
73
|
+
for (const r of successful) {
|
|
74
|
+
uniqueResults.set(r.outputPath, r);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const r of uniqueResults.values()) {
|
|
78
|
+
const relPath = path.relative(outputDir, r.outputPath);
|
|
79
|
+
const outKB = (r.compressedSize / 1024).toFixed(2);
|
|
80
|
+
const inKB = (r.originalSize / 1024).toFixed(2);
|
|
81
|
+
const warning = r.exceededTarget ? ' ⚠ EXCEEDS LIMIT' : '';
|
|
82
|
+
|
|
83
|
+
lines.push(`### ${relPath}`);
|
|
84
|
+
lines.push(`- Input: ${path.basename(r.inputPath)} | ${inKB} KB`);
|
|
85
|
+
lines.push(`- Output: ${path.basename(r.outputPath)} | ${outKB} KB${warning}`);
|
|
86
|
+
lines.push(`- Compression: ${r.compressionRatio}%`);
|
|
87
|
+
if (r.iterations > 1) {
|
|
88
|
+
lines.push(`- Iterations: ${r.iterations} | Quality steps: ${r.qualitySteps.join(' → ')} → ${r.finalQuality}`);
|
|
89
|
+
}
|
|
90
|
+
lines.push('');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (failed.length > 0) {
|
|
94
|
+
lines.push('## Failed Files');
|
|
95
|
+
lines.push('');
|
|
96
|
+
for (const r of failed) {
|
|
97
|
+
const relPath = path.relative(inputDir, r.inputPath);
|
|
98
|
+
lines.push(`- ${relPath}: ${r.error}`);
|
|
99
|
+
}
|
|
100
|
+
lines.push('');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (successful.length > 0) {
|
|
104
|
+
lines.push('## Input vs Output Comparison');
|
|
105
|
+
lines.push('');
|
|
106
|
+
lines.push('| File | Input Size | Output Size | Compression | Status |');
|
|
107
|
+
lines.push('|------|-----------|------------|-------------|--------|');
|
|
108
|
+
for (const r of uniqueResults.values()) {
|
|
109
|
+
const relPath = path.relative(outputDir, r.outputPath);
|
|
110
|
+
const warn = (r.compressedSize / 1024) > 100 ? ' ⚠' : '';
|
|
111
|
+
lines.push(`| ${relPath} | ${FileService.formatBytes(r.originalSize)} | ${FileService.formatBytes(r.compressedSize)} | ${r.compressionRatio}% | ✅${warn} |`);
|
|
112
|
+
}
|
|
113
|
+
lines.push('');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (skippedFiles.length > 0) {
|
|
118
|
+
const altFormats = SUPPORTED_OUTPUT_FORMATS.filter(f => f !== outputFormat);
|
|
119
|
+
const formatsList = altFormats.map(f => '`' + f + '`').join(', ');
|
|
120
|
+
|
|
121
|
+
lines.push('### ⚠ Notice');
|
|
122
|
+
lines.push('');
|
|
123
|
+
lines.push(`The following **${skippedFiles.length}** file(s) already have the \`.${outputFormat}\` extension and were **skipped** from compression:`);
|
|
124
|
+
lines.push('');
|
|
125
|
+
lines.push('> To compress these files:');
|
|
126
|
+
lines.push('> - **Option 1:** Delete the existing `.${outputFormat}` files from the input directory, then run again');
|
|
127
|
+
lines.push(`> - **Option 2:** Choose a different output format (e.g., ${formatsList}) instead of \`${outputFormat}\``);
|
|
128
|
+
lines.push('');
|
|
129
|
+
for (const f of skippedFiles) {
|
|
130
|
+
lines.push(`- \`${path.relative(path.resolve(inputDir), f)}\``);
|
|
131
|
+
}
|
|
132
|
+
lines.push('');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
lines.push('');
|
|
136
|
+
lines.push('---');
|
|
137
|
+
lines.push('---');
|
|
138
|
+
lines.push('');
|
|
139
|
+
lines.push('**Developed by Dhvanil Pansuriya**');
|
|
140
|
+
lines.push('');
|
|
141
|
+
lines.push('[GitHub](https://github.com/pansuriyadhvanil/) · [LinkedIn](https://linkedin.com/in/dhvanil-pansuriya/)');
|
|
142
|
+
lines.push('');
|
|
143
|
+
|
|
144
|
+
await fs.writeFile(reportPath, lines.join('\n'), 'utf-8');
|
|
145
|
+
return reportPath;
|
|
146
|
+
}
|
|
147
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export interface DownloadImageArgs {
|
|
2
|
+
url: string;
|
|
3
|
+
outputPath: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface CompressImageArgs {
|
|
7
|
+
inputPath: string;
|
|
8
|
+
outputPath: string;
|
|
9
|
+
outputFormat?: string;
|
|
10
|
+
quality?: number;
|
|
11
|
+
lossless?: boolean;
|
|
12
|
+
effort?: number;
|
|
13
|
+
width?: number;
|
|
14
|
+
height?: number;
|
|
15
|
+
maxDimension?: number;
|
|
16
|
+
recursiveCompress?: boolean;
|
|
17
|
+
expectedSizeKB?: number;
|
|
18
|
+
qualityStepDown?: number;
|
|
19
|
+
minimumQualityFloor?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CompressDirectoryArgs {
|
|
23
|
+
inputDir: string;
|
|
24
|
+
outputDir: string;
|
|
25
|
+
outputFormat?: string;
|
|
26
|
+
quality?: number;
|
|
27
|
+
lossless?: boolean;
|
|
28
|
+
effort?: number;
|
|
29
|
+
maxDimension?: number;
|
|
30
|
+
normalizeFilename?: boolean;
|
|
31
|
+
filenameReplaceChars?: string;
|
|
32
|
+
filenameCase?: string;
|
|
33
|
+
normalizeDirname?: boolean;
|
|
34
|
+
dirnameReplaceChars?: string;
|
|
35
|
+
dirnameCase?: string;
|
|
36
|
+
recursiveCompress?: boolean;
|
|
37
|
+
expectedSizeKB?: number;
|
|
38
|
+
qualityStepDown?: number;
|
|
39
|
+
minimumQualityFloor?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface FileNamingOptions {
|
|
43
|
+
enabled: boolean;
|
|
44
|
+
replaceChars: string[];
|
|
45
|
+
case: 'lowercase' | 'uppercase' | 'original';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CompressionResult {
|
|
49
|
+
inputPath: string;
|
|
50
|
+
outputPath: string;
|
|
51
|
+
originalSize: number;
|
|
52
|
+
compressedSize: number;
|
|
53
|
+
compressionRatio: number;
|
|
54
|
+
success: boolean;
|
|
55
|
+
error?: string;
|
|
56
|
+
iterations: number;
|
|
57
|
+
qualitySteps: number[];
|
|
58
|
+
finalQuality: number;
|
|
59
|
+
exceededTarget: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ScanFile {
|
|
63
|
+
fullPath: string;
|
|
64
|
+
relativePath: string;
|
|
65
|
+
size: number;
|
|
66
|
+
ext: string;
|
|
67
|
+
nameWithoutExt: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface TreeNode {
|
|
71
|
+
name: string;
|
|
72
|
+
type: 'directory' | 'file';
|
|
73
|
+
children?: TreeNode[];
|
|
74
|
+
size?: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface CompressOptions {
|
|
78
|
+
quality?: number;
|
|
79
|
+
lossless?: boolean;
|
|
80
|
+
effort?: number;
|
|
81
|
+
width?: number;
|
|
82
|
+
height?: number;
|
|
83
|
+
maxDimension?: number;
|
|
84
|
+
recursiveCompress?: boolean;
|
|
85
|
+
expectedSizeKB?: number;
|
|
86
|
+
qualityStepDown?: number;
|
|
87
|
+
minimumQualityFloor?: number;
|
|
88
|
+
outputFormat?: string;
|
|
89
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "node16",
|
|
5
|
+
"moduleResolution": "node16",
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"outDir": "build",
|
|
9
|
+
"rootDir": "src",
|
|
10
|
+
"declaration": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*"],
|
|
15
|
+
"exclude": ["node_modules", "build"]
|
|
16
|
+
}
|