image-processor-mcp 0.1.0 → 0.1.2

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/src/index.ts DELETED
@@ -1,204 +0,0 @@
1
- #!/usr/bin/env node
2
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
- import {
5
- CallToolRequestSchema,
6
- ErrorCode,
7
- ListToolsRequestSchema,
8
- McpError,
9
- } from '@modelcontextprotocol/sdk/types.js';
10
- import sharp from 'sharp';
11
- import { ToolController } from './controllers/tool.controller.js';
12
- import { APP_CONFIG } from './config/app.config.js';
13
- import type { DownloadImageArgs, CompressImageArgs, CompressDirectoryArgs } from './types.js';
14
-
15
- sharp.cache(false);
16
-
17
- class ImageDownloaderServer {
18
- private server: Server;
19
-
20
- constructor() {
21
- this.server = new Server(
22
- {
23
- name: APP_CONFIG.name,
24
- version: APP_CONFIG.version,
25
- },
26
- {
27
- capabilities: {
28
- tools: {},
29
- },
30
- }
31
- );
32
-
33
- this.setupToolHandlers();
34
- this.server.onerror = (error) => console.error('[MCP Error]', error);
35
- process.on('SIGINT', async () => {
36
- await this.server.close();
37
- process.exit(0);
38
- });
39
- }
40
-
41
- private isDownloadImageArgs(args: unknown): args is DownloadImageArgs {
42
- if (!args || typeof args !== 'object') return false;
43
- const a = args as Record<string, unknown>;
44
- return typeof a.url === 'string' && typeof a.outputPath === 'string';
45
- }
46
-
47
- private isCompressImageArgs(args: unknown): args is CompressImageArgs {
48
- if (!args || typeof args !== 'object') return false;
49
- const a = args as Record<string, unknown>;
50
- return (
51
- typeof a.inputPath === 'string' &&
52
- typeof a.outputPath === 'string' &&
53
- (a.outputFormat === undefined || typeof a.outputFormat === 'string') &&
54
- (a.quality === undefined || typeof a.quality === 'number') &&
55
- (a.lossless === undefined || typeof a.lossless === 'boolean') &&
56
- (a.effort === undefined || typeof a.effort === 'number') &&
57
- (a.width === undefined || typeof a.width === 'number') &&
58
- (a.height === undefined || typeof a.height === 'number') &&
59
- (a.maxDimension === undefined || typeof a.maxDimension === 'number') &&
60
- (a.recursiveCompress === undefined || typeof a.recursiveCompress === 'boolean') &&
61
- (a.expectedSizeKB === undefined || typeof a.expectedSizeKB === 'number') &&
62
- (a.qualityStepDown === undefined || typeof a.qualityStepDown === 'number') &&
63
- (a.minimumQualityFloor === undefined || typeof a.minimumQualityFloor === 'number')
64
- );
65
- }
66
-
67
- private isCompressDirectoryArgs(args: unknown): args is CompressDirectoryArgs {
68
- if (!args || typeof args !== 'object') return false;
69
- const a = args as Record<string, unknown>;
70
- return (
71
- typeof a.inputDir === 'string' &&
72
- typeof a.outputDir === 'string' &&
73
- (a.outputFormat === undefined || typeof a.outputFormat === 'string') &&
74
- (a.quality === undefined || typeof a.quality === 'number') &&
75
- (a.lossless === undefined || typeof a.lossless === 'boolean') &&
76
- (a.effort === undefined || typeof a.effort === 'number') &&
77
- (a.maxDimension === undefined || typeof a.maxDimension === 'number') &&
78
- (a.normalizeFilename === undefined || typeof a.normalizeFilename === 'boolean') &&
79
- (a.filenameReplaceChars === undefined || typeof a.filenameReplaceChars === 'string') &&
80
- (a.filenameCase === undefined || typeof a.filenameCase === 'string') &&
81
- (a.normalizeDirname === undefined || typeof a.normalizeDirname === 'boolean') &&
82
- (a.dirnameReplaceChars === undefined || typeof a.dirnameReplaceChars === 'string') &&
83
- (a.dirnameCase === undefined || typeof a.dirnameCase === 'string') &&
84
- (a.recursiveCompress === undefined || typeof a.recursiveCompress === 'boolean') &&
85
- (a.expectedSizeKB === undefined || typeof a.expectedSizeKB === 'number') &&
86
- (a.qualityStepDown === undefined || typeof a.qualityStepDown === 'number') &&
87
- (a.minimumQualityFloor === undefined || typeof a.minimumQualityFloor === 'number')
88
- );
89
- }
90
-
91
- private setupToolHandlers() {
92
- this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
93
- tools: [
94
- {
95
- name: 'download_image',
96
- description: 'Download an image from a URL to a specified path',
97
- inputSchema: {
98
- type: 'object',
99
- properties: {
100
- url: { type: 'string', description: 'URL of the image to download' },
101
- outputPath: { type: 'string', description: 'Path where to save the image' },
102
- },
103
- required: ['url', 'outputPath'],
104
- },
105
- },
106
- {
107
- name: 'compress_image',
108
- description: 'Compress or convert a single image with format conversion, quality adjustment, resizing, and optional recursive compression',
109
- inputSchema: {
110
- type: 'object',
111
- properties: {
112
- inputPath: { type: 'string', description: 'Path to the input image' },
113
- outputPath: { type: 'string', description: 'Path where to save the compressed image' },
114
- outputFormat: { type: 'string', description: 'Output format: webp, avif, jpeg, png, tiff, gif (default: webp)' },
115
- quality: { type: 'number', description: 'Compression quality 1-100 (default: 85)', minimum: 1, maximum: 100 },
116
- lossless: { type: 'boolean', description: 'Use lossless compression (default: false)' },
117
- effort: { type: 'number', description: 'CPU effort 0-6, 6 is slowest with best compression (default: 6)', minimum: 0, maximum: 6 },
118
- width: { type: 'number', description: 'Exact target width (maintains aspect ratio if only width is specified)' },
119
- height: { type: 'number', description: 'Exact target height (maintains aspect ratio if only height is specified)' },
120
- maxDimension: { type: 'number', description: 'Auto-resize if width or height exceeds this threshold (default: 2000)' },
121
- recursiveCompress: { type: 'boolean', description: 'Re-compress until file is under target size (default: false)' },
122
- expectedSizeKB: { type: 'number', description: 'Target file size in KB for recursive compression (default: 100)' },
123
- qualityStepDown: { type: 'number', description: 'Quality decrease per recursive iteration (default: 5)' },
124
- minimumQualityFloor: { type: 'number', description: 'Minimum quality allowed during recursive compression (default: 10)' },
125
- },
126
- required: ['inputPath', 'outputPath'],
127
- },
128
- },
129
- {
130
- name: 'compress_directory',
131
- description: 'Batch compress all images in a directory recursively, preserving folder structure, with an auto-generated report',
132
- inputSchema: {
133
- type: 'object',
134
- properties: {
135
- inputDir: { type: 'string', description: 'Input directory containing images' },
136
- outputDir: { type: 'string', description: 'Output directory for compressed images' },
137
- outputFormat: { type: 'string', description: 'Output format: webp, avif, jpeg, png, tiff, gif (default: webp)' },
138
- quality: { type: 'number', description: 'Compression quality 1-100 (default: 85)', minimum: 1, maximum: 100 },
139
- lossless: { type: 'boolean', description: 'Use lossless compression (default: false)' },
140
- effort: { type: 'number', description: 'CPU effort 0-6 (default: 6)', minimum: 0, maximum: 6 },
141
- maxDimension: { type: 'number', description: 'Auto-resize threshold in pixels (default: 2000)' },
142
- normalizeFilename: { type: 'boolean', description: 'Enable filename normalization' },
143
- filenameReplaceChars: { type: 'string', description: 'Characters to replace with underscore, comma-separated' },
144
- filenameCase: { type: 'string', description: 'Filename case: lowercase, uppercase, original' },
145
- normalizeDirname: { type: 'boolean', description: 'Enable directory name normalization' },
146
- dirnameReplaceChars: { type: 'string', description: 'Characters to replace in directory names, comma-separated' },
147
- dirnameCase: { type: 'string', description: 'Directory name case: lowercase, uppercase, original' },
148
- recursiveCompress: { type: 'boolean', description: 'Re-compress oversized outputs until target size' },
149
- expectedSizeKB: { type: 'number', description: 'Target file size in KB (default: 100)' },
150
- qualityStepDown: { type: 'number', description: 'Quality decrease per iteration (default: 5)' },
151
- minimumQualityFloor: { type: 'number', description: 'Minimum quality floor (default: 10)' },
152
- },
153
- required: ['inputDir', 'outputDir'],
154
- },
155
- },
156
- {
157
- name: 'get_acknowledgement',
158
- description: 'Get all configuration defaults, supported formats, and detailed tool descriptions with parameter explanations',
159
- inputSchema: {
160
- type: 'object',
161
- properties: {},
162
- required: [],
163
- },
164
- },
165
- ],
166
- }));
167
-
168
- this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
169
- switch (request.params.name) {
170
- case 'download_image':
171
- if (!this.isDownloadImageArgs(request.params.arguments)) {
172
- throw new McpError(ErrorCode.InvalidParams, 'Invalid arguments for download_image');
173
- }
174
- return ToolController.handleDownloadImage(request.params.arguments);
175
- case 'compress_image':
176
- if (!this.isCompressImageArgs(request.params.arguments)) {
177
- throw new McpError(ErrorCode.InvalidParams, 'Invalid arguments for compress_image');
178
- }
179
- return ToolController.handleCompressImage(request.params.arguments);
180
- case 'compress_directory':
181
- if (!this.isCompressDirectoryArgs(request.params.arguments)) {
182
- throw new McpError(ErrorCode.InvalidParams, 'Invalid arguments for compress_directory');
183
- }
184
- return ToolController.handleCompressDirectory(request.params.arguments);
185
- case 'get_acknowledgement':
186
- return ToolController.handleGetAcknowledgement();
187
- default:
188
- throw new McpError(
189
- ErrorCode.MethodNotFound,
190
- "Unknown tool: " + request.params.name
191
- );
192
- }
193
- });
194
- }
195
-
196
- async run() {
197
- const transport = new StdioServerTransport();
198
- await this.server.connect(transport);
199
- console.error('Image Downloader MCP server running on stdio');
200
- }
201
- }
202
-
203
- const server = new ImageDownloaderServer();
204
- server.run().catch(console.error);
@@ -1,179 +0,0 @@
1
- import fs from 'fs-extra';
2
- import path from 'path';
3
- import type { FileNamingOptions, ScanFile, TreeNode } from '../types.js';
4
- import { SUPPORTED_EXTENSIONS } from '../config/config.constants.js';
5
-
6
- export class FileService {
7
- static async getImageFiles(dirPath: string, extensions: string[] = SUPPORTED_EXTENSIONS): Promise<string[]> {
8
- const files: string[] = [];
9
- const items = await fs.readdir(dirPath, { withFileTypes: true });
10
- for (const item of items) {
11
- const fullPath = path.join(dirPath, item.name);
12
- if (item.isDirectory()) {
13
- const subFiles = await this.getImageFiles(fullPath, extensions);
14
- files.push(...subFiles);
15
- } else if (item.isFile()) {
16
- const ext = path.extname(item.name).toLowerCase();
17
- if (extensions.includes(ext)) {
18
- files.push(fullPath);
19
- }
20
- }
21
- }
22
- return files;
23
- }
24
-
25
- static async getFileSize(filePath: string): Promise<number> {
26
- try {
27
- const stats = await fs.stat(filePath);
28
- return stats.size;
29
- } catch {
30
- return 0;
31
- }
32
- }
33
-
34
- static async ensureDir(dirPath: string): Promise<void> {
35
- await fs.ensureDir(dirPath);
36
- }
37
-
38
- static normalizeName(name: string, options: { replaceChars: string[]; case: string }): string {
39
- let normalized = name;
40
- if (options.replaceChars.length > 0) {
41
- for (const char of options.replaceChars) {
42
- if (char) {
43
- normalized = normalized.split(char).join('_');
44
- }
45
- }
46
- normalized = normalized.replace(/_+/g, '_').replace(/^_|_$/g, '');
47
- }
48
- if (options.case === 'lowercase') normalized = normalized.toLowerCase();
49
- else if (options.case === 'uppercase') normalized = normalized.toUpperCase();
50
- return normalized;
51
- }
52
-
53
- static getOutputPath(
54
- inputPath: string, inputDir: string, outputDir: string,
55
- format: string, fileNaming: FileNamingOptions, dirNaming: FileNamingOptions,
56
- ): string {
57
- const relativePath = path.relative(inputDir, inputPath);
58
- let normalizedPath = relativePath;
59
-
60
- if (dirNaming.enabled) {
61
- const parts = relativePath.split(path.sep);
62
- const dirParts = parts.slice(0, -1).map(d => this.normalizeName(d, dirNaming));
63
- const filename = parts[parts.length - 1];
64
- normalizedPath = dirParts.length > 0 ? path.join(...dirParts, filename) : filename;
65
- }
66
-
67
- if (fileNaming.enabled) {
68
- const ext = path.extname(normalizedPath);
69
- const name = path.basename(normalizedPath, ext);
70
- const dir = path.dirname(normalizedPath);
71
- const normalizedName = this.normalizeName(name, fileNaming);
72
- normalizedPath = dir === '.' ? `${normalizedName}${ext}` : path.join(dir, `${normalizedName}${ext}`);
73
- }
74
-
75
- const baseName = path.basename(normalizedPath, path.extname(normalizedPath));
76
- const dir = path.dirname(normalizedPath);
77
- const newExt = `.${format}`;
78
- const outputRelPath = dir === '.' ? `${baseName}${newExt}` : path.join(dir, `${baseName}${newExt}`);
79
-
80
- return path.join(outputDir, outputRelPath);
81
- }
82
-
83
- static async scanFiles(dirPath: string, baseDir: string, extensions: string[]): Promise<ScanFile[]> {
84
- const files: ScanFile[] = [];
85
- const items = await fs.readdir(dirPath, { withFileTypes: true });
86
- for (const item of items) {
87
- const fullPath = path.join(dirPath, item.name);
88
- if (item.isDirectory()) {
89
- const subFiles = await this.scanFiles(fullPath, baseDir, extensions);
90
- files.push(...subFiles);
91
- } else if (item.isFile()) {
92
- const ext = path.extname(item.name).toLowerCase();
93
- if (extensions.includes(ext)) {
94
- const stats = await fs.stat(fullPath);
95
- files.push({
96
- fullPath,
97
- relativePath: path.relative(baseDir, fullPath),
98
- size: stats.size,
99
- ext,
100
- nameWithoutExt: path.basename(item.name, path.extname(item.name)),
101
- });
102
- }
103
- }
104
- }
105
- return files;
106
- }
107
-
108
- static buildTree(files: ScanFile[], baseDir: string): TreeNode {
109
- const root: TreeNode = { name: path.basename(path.resolve(baseDir)), type: 'directory', children: [] };
110
- for (const file of files) {
111
- const parts = file.relativePath.split(path.sep);
112
- let current = root;
113
- for (let i = 0; i < parts.length; i++) {
114
- const part = parts[i];
115
- const isLast = i === parts.length - 1;
116
- let child = current.children?.find(c => c.name === part);
117
- if (!child) {
118
- child = {
119
- name: part,
120
- type: isLast ? 'file' : 'directory',
121
- children: isLast ? undefined : [],
122
- size: isLast ? file.size : undefined,
123
- };
124
- current.children!.push(child);
125
- }
126
- current = child;
127
- }
128
- }
129
- this.sortTree(root);
130
- return root;
131
- }
132
-
133
- private static sortTree(node: TreeNode): void {
134
- if (!node.children) return;
135
- node.children.sort((a, b) => {
136
- if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
137
- return a.name.localeCompare(b.name);
138
- });
139
- for (const child of node.children) {
140
- this.sortTree(child);
141
- }
142
- }
143
-
144
- static formatTreeMarkdown(node: TreeNode, indent: string = ''): string[] {
145
- const lines: string[] = [];
146
- if (indent === '') {
147
- lines.push('.');
148
- }
149
- if (!node.children) return lines;
150
- for (let i = 0; i < node.children.length; i++) {
151
- const child = node.children[i];
152
- const isLast = i === node.children.length - 1;
153
- const connector = isLast ? '`-- ' : '|-- ';
154
- if (child.type === 'file') {
155
- const sizeStr = this.formatBytes(child.size || 0);
156
- lines.push(`${indent}${connector}${child.name} (${sizeStr})`);
157
- } else {
158
- lines.push(`${indent}${connector}${child.name}`);
159
- const newIndent = indent + (isLast ? ' ' : '| ');
160
- if (child.children) {
161
- lines.push(...this.formatTreeMarkdown(child, newIndent));
162
- }
163
- }
164
- }
165
- return lines;
166
- }
167
-
168
- static normalizeKey(str: string): string {
169
- return str.toLowerCase().replace(/[-\s.]+/g, '_');
170
- }
171
-
172
- static formatBytes(bytes: number): string {
173
- if (bytes === 0) return '0 Bytes';
174
- const k = 1024;
175
- const sizes = ['Bytes', 'KB', 'MB', 'GB'];
176
- const i = Math.floor(Math.log(bytes) / Math.log(k));
177
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
178
- }
179
- }
@@ -1,132 +0,0 @@
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
- }
@@ -1,147 +0,0 @@
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
- }