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.
@@ -0,0 +1,230 @@
1
+ import fs from 'fs-extra';
2
+ import axios from 'axios';
3
+ import path from 'path';
4
+ import { FileService } from '../services/file.services.js';
5
+ import { ImageService } from '../services/image.services.js';
6
+ import { ReportService } from '../services/report.services.js';
7
+ import { DEFAULTS, CONFIG_DESCRIPTIONS, SUPPORTED_EXTENSIONS, INPUT_EXTENSION_DESCRIPTIONS, SUPPORTED_OUTPUT_FORMATS, OUTPUT_FORMAT_DESCRIPTIONS, } from '../config/config.constants.js';
8
+ import { APP_CONFIG } from '../config/app.config.js';
9
+ export class ToolController {
10
+ static async handleDownloadImage(args) {
11
+ try {
12
+ await fs.ensureDir(path.dirname(args.outputPath));
13
+ const response = await axios({
14
+ method: 'GET',
15
+ url: args.url,
16
+ responseType: 'arraybuffer',
17
+ headers: {
18
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
19
+ },
20
+ timeout: 30000,
21
+ });
22
+ await fs.writeFile(args.outputPath, response.data);
23
+ return {
24
+ content: [{ type: 'text', text: `Successfully downloaded image to ${args.outputPath}` }],
25
+ };
26
+ }
27
+ catch (error) {
28
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
29
+ console.error('Download error:', errorMessage);
30
+ return { content: [{ type: 'text', text: `Failed to download image: ${errorMessage}` }], isError: true };
31
+ }
32
+ }
33
+ static async handleCompressImage(args) {
34
+ try {
35
+ await FileService.ensureDir(path.dirname(args.outputPath));
36
+ const result = await ImageService.compressImage(args.inputPath, args.outputPath, {
37
+ quality: args.quality,
38
+ lossless: args.lossless,
39
+ effort: args.effort,
40
+ width: args.width,
41
+ height: args.height,
42
+ maxDimension: args.maxDimension,
43
+ recursiveCompress: args.recursiveCompress,
44
+ expectedSizeKB: args.expectedSizeKB,
45
+ qualityStepDown: args.qualityStepDown,
46
+ minimumQualityFloor: args.minimumQualityFloor,
47
+ outputFormat: args.outputFormat,
48
+ });
49
+ if (!result.success) {
50
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], isError: true };
51
+ }
52
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
53
+ }
54
+ catch (error) {
55
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
56
+ return { content: [{ type: 'text', text: `Failed to compress image: ${errorMessage}` }], isError: true };
57
+ }
58
+ }
59
+ static async handleCompressDirectory(args) {
60
+ const inputDir = path.resolve(args.inputDir);
61
+ const outputDir = path.resolve(args.outputDir);
62
+ const dirExists = await fs.pathExists(inputDir);
63
+ if (!dirExists) {
64
+ return { content: [{ type: 'text', text: `Input directory does not exist: ${inputDir}` }], isError: true };
65
+ }
66
+ await FileService.ensureDir(outputDir);
67
+ const outputFormat = args.outputFormat || DEFAULTS.outputFormat;
68
+ const quality = args.quality ?? DEFAULTS.quality;
69
+ const lossless = args.lossless ?? DEFAULTS.lossless;
70
+ const effort = args.effort ?? DEFAULTS.effort;
71
+ const maxDimension = args.maxDimension ?? DEFAULTS.maxDimension;
72
+ const recursiveCompress = args.recursiveCompress ?? false;
73
+ const expectedSizeKB = args.expectedSizeKB ?? DEFAULTS.expectedSizeKB;
74
+ const qualityStepDown = args.qualityStepDown ?? DEFAULTS.qualityStepDown;
75
+ const minimumQualityFloor = args.minimumQualityFloor ?? DEFAULTS.minimumQualityFloor;
76
+ const fileNaming = {
77
+ enabled: args.normalizeFilename ?? false,
78
+ replaceChars: args.filenameReplaceChars
79
+ ? args.filenameReplaceChars.split(',').map(c => c.trim()).filter(c => c)
80
+ : DEFAULTS.filenameReplaceChars,
81
+ case: args.filenameCase || DEFAULTS.filenameCase,
82
+ };
83
+ const dirNaming = {
84
+ enabled: args.normalizeDirname ?? false,
85
+ replaceChars: args.dirnameReplaceChars
86
+ ? args.dirnameReplaceChars.split(',').map(c => c.trim()).filter(c => c)
87
+ : DEFAULTS.dirnameReplaceChars,
88
+ case: args.dirnameCase || DEFAULTS.dirnameCase,
89
+ };
90
+ try {
91
+ const startTime = Date.now();
92
+ const allImageFiles = await FileService.getImageFiles(inputDir);
93
+ const toProcess = [];
94
+ const skipped = [];
95
+ for (const f of allImageFiles) {
96
+ if (path.extname(f).toLowerCase() === '.' + outputFormat) {
97
+ skipped.push(f);
98
+ }
99
+ else {
100
+ toProcess.push(f);
101
+ }
102
+ }
103
+ const results = [];
104
+ for (const inputPath of toProcess) {
105
+ const outputPath = FileService.getOutputPath(inputPath, inputDir, outputDir, outputFormat, fileNaming, dirNaming);
106
+ await FileService.ensureDir(path.dirname(outputPath));
107
+ const result = await ImageService.compressImage(inputPath, outputPath, {
108
+ quality,
109
+ lossless,
110
+ effort,
111
+ maxDimension,
112
+ recursiveCompress,
113
+ expectedSizeKB,
114
+ qualityStepDown,
115
+ minimumQualityFloor,
116
+ outputFormat,
117
+ });
118
+ results.push(result);
119
+ }
120
+ const duration = Date.now() - startTime;
121
+ const reportPath = await ReportService.generateReport(results, inputDir, outputDir, outputFormat, startTime, duration, skipped);
122
+ const successful = results.filter(r => r.success).length;
123
+ const failed = results.length - successful;
124
+ const totalOriginal = results.reduce((s, r) => s + r.originalSize, 0);
125
+ const totalCompressed = results.reduce((s, r) => s + r.compressedSize, 0);
126
+ const overallReduction = totalOriginal > 0
127
+ ? parseFloat(((totalOriginal - totalCompressed) / totalOriginal * 100).toFixed(2))
128
+ : 0;
129
+ return {
130
+ content: [{
131
+ type: 'text',
132
+ text: JSON.stringify({
133
+ success: true,
134
+ totalFiles: results.length,
135
+ successful,
136
+ failed,
137
+ totalOriginalSize: totalOriginal,
138
+ totalCompressedSize: totalCompressed,
139
+ overallReduction,
140
+ durationMs: duration,
141
+ reportPath,
142
+ }, null, 2),
143
+ }],
144
+ };
145
+ }
146
+ catch (error) {
147
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
148
+ return { content: [{ type: 'text', text: `Failed to process directory: ${errorMessage}` }], isError: true };
149
+ }
150
+ }
151
+ static handleGetAcknowledgement() {
152
+ const inputExtensions = SUPPORTED_EXTENSIONS.map(ext => ({
153
+ ext,
154
+ description: INPUT_EXTENSION_DESCRIPTIONS[ext] || 'Image format',
155
+ }));
156
+ const outputFormats = SUPPORTED_OUTPUT_FORMATS.map(format => ({
157
+ format,
158
+ description: OUTPUT_FORMAT_DESCRIPTIONS[format] || 'Output format',
159
+ }));
160
+ const tools = [
161
+ {
162
+ name: 'download_image',
163
+ description: 'Download an image from a URL to a specified path',
164
+ parameters: [
165
+ { name: 'url', type: 'string', required: true, description: 'URL of the image to download' },
166
+ { name: 'outputPath', type: 'string', required: true, description: 'Local path where to save the downloaded image' },
167
+ ],
168
+ },
169
+ {
170
+ name: 'compress_image',
171
+ description: 'Compress or convert a single image with format conversion, quality adjustment, resizing, and optional recursive compression',
172
+ parameters: [
173
+ { name: 'inputPath', type: 'string', required: true, description: 'Path to the input image' },
174
+ { name: 'outputPath', type: 'string', required: true, description: 'Path where to save the compressed image' },
175
+ { name: 'outputFormat', type: 'string', required: false, default: DEFAULTS.outputFormat, description: 'Output image format. Supported: ' + SUPPORTED_OUTPUT_FORMATS.join(', ') },
176
+ { name: 'quality', type: 'number', required: false, default: DEFAULTS.quality, description: 'Compression quality (1-100). Higher = better quality, larger file.' },
177
+ { name: 'lossless', type: 'boolean', required: false, default: DEFAULTS.lossless, description: 'Use lossless compression. Preserves all pixel data.' },
178
+ { name: 'effort', type: 'number', required: false, default: DEFAULTS.effort, description: 'CPU effort (0-6). Higher = better compression, slower.' },
179
+ { name: 'width', type: 'number', required: false, description: 'Exact target width in pixels. Aspect ratio maintained if only width specified.' },
180
+ { name: 'height', type: 'number', required: false, description: 'Exact target height in pixels. Aspect ratio maintained if only height specified.' },
181
+ { name: 'maxDimension', type: 'number', required: false, default: DEFAULTS.maxDimension, description: 'Auto-resize if width or height exceeds this pixel threshold.' },
182
+ { name: 'recursiveCompress', type: 'boolean', required: false, default: false, description: 'Re-compress until file size is under expectedSizeKB.' },
183
+ { name: 'expectedSizeKB', type: 'number', required: false, default: DEFAULTS.expectedSizeKB, description: 'Target file size in KB for recursive compression.' },
184
+ { name: 'qualityStepDown', type: 'number', required: false, default: DEFAULTS.qualityStepDown, description: 'Quality reduction per recursive iteration.' },
185
+ { name: 'minimumQualityFloor', type: 'number', required: false, default: DEFAULTS.minimumQualityFloor, description: 'Minimum quality allowed during recursive compression.' },
186
+ ],
187
+ },
188
+ {
189
+ name: 'compress_directory',
190
+ description: 'Batch compress all images in a directory recursively, preserving folder structure, with auto-generated report',
191
+ parameters: [
192
+ { name: 'inputDir', type: 'string', required: true, description: 'Input directory containing images to compress' },
193
+ { name: 'outputDir', type: 'string', required: true, description: 'Output directory where compressed images will be saved' },
194
+ { name: 'outputFormat', type: 'string', required: false, default: DEFAULTS.outputFormat, description: 'Output image format. Supported: ' + SUPPORTED_OUTPUT_FORMATS.join(', ') },
195
+ { name: 'quality', type: 'number', required: false, default: DEFAULTS.quality, description: 'Compression quality (1-100)' },
196
+ { name: 'lossless', type: 'boolean', required: false, default: DEFAULTS.lossless, description: 'Use lossless compression' },
197
+ { name: 'effort', type: 'number', required: false, default: DEFAULTS.effort, description: 'CPU effort (0-6)' },
198
+ { name: 'maxDimension', type: 'number', required: false, default: DEFAULTS.maxDimension, description: 'Auto-resize threshold in pixels' },
199
+ { name: 'normalizeFilename', type: 'boolean', required: false, default: false, description: 'Enable filename normalization (replace chars, apply case)' },
200
+ { name: 'filenameReplaceChars', type: 'string', required: false, description: 'Characters to replace with underscore, comma-separated' },
201
+ { name: 'filenameCase', type: 'string', required: false, default: DEFAULTS.filenameCase, description: 'Filename case: lowercase, uppercase, or original' },
202
+ { name: 'normalizeDirname', type: 'boolean', required: false, default: false, description: 'Enable directory name normalization' },
203
+ { name: 'dirnameReplaceChars', type: 'string', required: false, description: 'Characters to replace in directory names, comma-separated' },
204
+ { name: 'dirnameCase', type: 'string', required: false, default: DEFAULTS.dirnameCase, description: 'Directory name case: lowercase, uppercase, or original' },
205
+ { name: 'recursiveCompress', type: 'boolean', required: false, default: false, description: 'Re-compress oversized outputs until target size' },
206
+ { name: 'expectedSizeKB', type: 'number', required: false, default: DEFAULTS.expectedSizeKB, description: 'Target file size in KB' },
207
+ { name: 'qualityStepDown', type: 'number', required: false, default: DEFAULTS.qualityStepDown, description: 'Quality decrease per iteration' },
208
+ { name: 'minimumQualityFloor', type: 'number', required: false, default: DEFAULTS.minimumQualityFloor, description: 'Minimum quality floor' },
209
+ ],
210
+ },
211
+ {
212
+ name: 'get_acknowledgement',
213
+ description: 'Get all configuration defaults, supported formats, and detailed tool descriptions. Use this tool to understand the full capabilities of this MCP server.',
214
+ parameters: [],
215
+ },
216
+ ];
217
+ return {
218
+ content: [{
219
+ type: 'text',
220
+ text: JSON.stringify({
221
+ app: { name: APP_CONFIG.name, version: APP_CONFIG.version },
222
+ configDefaults: CONFIG_DESCRIPTIONS,
223
+ supportedInputExtensions: inputExtensions,
224
+ supportedOutputFormats: outputFormats,
225
+ tools,
226
+ }, null, 2),
227
+ }],
228
+ };
229
+ }
230
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/build/index.js ADDED
@@ -0,0 +1,179 @@
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 { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
5
+ import sharp from 'sharp';
6
+ import { ToolController } from './controllers/tool.controller.js';
7
+ import { APP_CONFIG } from './config/app.config.js';
8
+ sharp.cache(false);
9
+ class ImageDownloaderServer {
10
+ constructor() {
11
+ this.server = new Server({
12
+ name: APP_CONFIG.name,
13
+ version: APP_CONFIG.version,
14
+ }, {
15
+ capabilities: {
16
+ tools: {},
17
+ },
18
+ });
19
+ this.setupToolHandlers();
20
+ this.server.onerror = (error) => console.error('[MCP Error]', error);
21
+ process.on('SIGINT', async () => {
22
+ await this.server.close();
23
+ process.exit(0);
24
+ });
25
+ }
26
+ isDownloadImageArgs(args) {
27
+ if (!args || typeof args !== 'object')
28
+ return false;
29
+ const a = args;
30
+ return typeof a.url === 'string' && typeof a.outputPath === 'string';
31
+ }
32
+ isCompressImageArgs(args) {
33
+ if (!args || typeof args !== 'object')
34
+ return false;
35
+ const a = args;
36
+ return (typeof a.inputPath === 'string' &&
37
+ typeof a.outputPath === 'string' &&
38
+ (a.outputFormat === undefined || typeof a.outputFormat === 'string') &&
39
+ (a.quality === undefined || typeof a.quality === 'number') &&
40
+ (a.lossless === undefined || typeof a.lossless === 'boolean') &&
41
+ (a.effort === undefined || typeof a.effort === 'number') &&
42
+ (a.width === undefined || typeof a.width === 'number') &&
43
+ (a.height === undefined || typeof a.height === 'number') &&
44
+ (a.maxDimension === undefined || typeof a.maxDimension === 'number') &&
45
+ (a.recursiveCompress === undefined || typeof a.recursiveCompress === 'boolean') &&
46
+ (a.expectedSizeKB === undefined || typeof a.expectedSizeKB === 'number') &&
47
+ (a.qualityStepDown === undefined || typeof a.qualityStepDown === 'number') &&
48
+ (a.minimumQualityFloor === undefined || typeof a.minimumQualityFloor === 'number'));
49
+ }
50
+ isCompressDirectoryArgs(args) {
51
+ if (!args || typeof args !== 'object')
52
+ return false;
53
+ const a = args;
54
+ return (typeof a.inputDir === 'string' &&
55
+ typeof a.outputDir === 'string' &&
56
+ (a.outputFormat === undefined || typeof a.outputFormat === 'string') &&
57
+ (a.quality === undefined || typeof a.quality === 'number') &&
58
+ (a.lossless === undefined || typeof a.lossless === 'boolean') &&
59
+ (a.effort === undefined || typeof a.effort === 'number') &&
60
+ (a.maxDimension === undefined || typeof a.maxDimension === 'number') &&
61
+ (a.normalizeFilename === undefined || typeof a.normalizeFilename === 'boolean') &&
62
+ (a.filenameReplaceChars === undefined || typeof a.filenameReplaceChars === 'string') &&
63
+ (a.filenameCase === undefined || typeof a.filenameCase === 'string') &&
64
+ (a.normalizeDirname === undefined || typeof a.normalizeDirname === 'boolean') &&
65
+ (a.dirnameReplaceChars === undefined || typeof a.dirnameReplaceChars === 'string') &&
66
+ (a.dirnameCase === undefined || typeof a.dirnameCase === 'string') &&
67
+ (a.recursiveCompress === undefined || typeof a.recursiveCompress === 'boolean') &&
68
+ (a.expectedSizeKB === undefined || typeof a.expectedSizeKB === 'number') &&
69
+ (a.qualityStepDown === undefined || typeof a.qualityStepDown === 'number') &&
70
+ (a.minimumQualityFloor === undefined || typeof a.minimumQualityFloor === 'number'));
71
+ }
72
+ setupToolHandlers() {
73
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
74
+ tools: [
75
+ {
76
+ name: 'download_image',
77
+ description: 'Download an image from a URL to a specified path',
78
+ inputSchema: {
79
+ type: 'object',
80
+ properties: {
81
+ url: { type: 'string', description: 'URL of the image to download' },
82
+ outputPath: { type: 'string', description: 'Path where to save the image' },
83
+ },
84
+ required: ['url', 'outputPath'],
85
+ },
86
+ },
87
+ {
88
+ name: 'compress_image',
89
+ description: 'Compress or convert a single image with format conversion, quality adjustment, resizing, and optional recursive compression',
90
+ inputSchema: {
91
+ type: 'object',
92
+ properties: {
93
+ inputPath: { type: 'string', description: 'Path to the input image' },
94
+ outputPath: { type: 'string', description: 'Path where to save the compressed image' },
95
+ outputFormat: { type: 'string', description: 'Output format: webp, avif, jpeg, png, tiff, gif (default: webp)' },
96
+ quality: { type: 'number', description: 'Compression quality 1-100 (default: 85)', minimum: 1, maximum: 100 },
97
+ lossless: { type: 'boolean', description: 'Use lossless compression (default: false)' },
98
+ effort: { type: 'number', description: 'CPU effort 0-6, 6 is slowest with best compression (default: 6)', minimum: 0, maximum: 6 },
99
+ width: { type: 'number', description: 'Exact target width (maintains aspect ratio if only width is specified)' },
100
+ height: { type: 'number', description: 'Exact target height (maintains aspect ratio if only height is specified)' },
101
+ maxDimension: { type: 'number', description: 'Auto-resize if width or height exceeds this threshold (default: 2000)' },
102
+ recursiveCompress: { type: 'boolean', description: 'Re-compress until file is under target size (default: false)' },
103
+ expectedSizeKB: { type: 'number', description: 'Target file size in KB for recursive compression (default: 100)' },
104
+ qualityStepDown: { type: 'number', description: 'Quality decrease per recursive iteration (default: 5)' },
105
+ minimumQualityFloor: { type: 'number', description: 'Minimum quality allowed during recursive compression (default: 10)' },
106
+ },
107
+ required: ['inputPath', 'outputPath'],
108
+ },
109
+ },
110
+ {
111
+ name: 'compress_directory',
112
+ description: 'Batch compress all images in a directory recursively, preserving folder structure, with an auto-generated report',
113
+ inputSchema: {
114
+ type: 'object',
115
+ properties: {
116
+ inputDir: { type: 'string', description: 'Input directory containing images' },
117
+ outputDir: { type: 'string', description: 'Output directory for compressed images' },
118
+ outputFormat: { type: 'string', description: 'Output format: webp, avif, jpeg, png, tiff, gif (default: webp)' },
119
+ quality: { type: 'number', description: 'Compression quality 1-100 (default: 85)', minimum: 1, maximum: 100 },
120
+ lossless: { type: 'boolean', description: 'Use lossless compression (default: false)' },
121
+ effort: { type: 'number', description: 'CPU effort 0-6 (default: 6)', minimum: 0, maximum: 6 },
122
+ maxDimension: { type: 'number', description: 'Auto-resize threshold in pixels (default: 2000)' },
123
+ normalizeFilename: { type: 'boolean', description: 'Enable filename normalization' },
124
+ filenameReplaceChars: { type: 'string', description: 'Characters to replace with underscore, comma-separated' },
125
+ filenameCase: { type: 'string', description: 'Filename case: lowercase, uppercase, original' },
126
+ normalizeDirname: { type: 'boolean', description: 'Enable directory name normalization' },
127
+ dirnameReplaceChars: { type: 'string', description: 'Characters to replace in directory names, comma-separated' },
128
+ dirnameCase: { type: 'string', description: 'Directory name case: lowercase, uppercase, original' },
129
+ recursiveCompress: { type: 'boolean', description: 'Re-compress oversized outputs until target size' },
130
+ expectedSizeKB: { type: 'number', description: 'Target file size in KB (default: 100)' },
131
+ qualityStepDown: { type: 'number', description: 'Quality decrease per iteration (default: 5)' },
132
+ minimumQualityFloor: { type: 'number', description: 'Minimum quality floor (default: 10)' },
133
+ },
134
+ required: ['inputDir', 'outputDir'],
135
+ },
136
+ },
137
+ {
138
+ name: 'get_acknowledgement',
139
+ description: 'Get all configuration defaults, supported formats, and detailed tool descriptions with parameter explanations',
140
+ inputSchema: {
141
+ type: 'object',
142
+ properties: {},
143
+ required: [],
144
+ },
145
+ },
146
+ ],
147
+ }));
148
+ this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
149
+ switch (request.params.name) {
150
+ case 'download_image':
151
+ if (!this.isDownloadImageArgs(request.params.arguments)) {
152
+ throw new McpError(ErrorCode.InvalidParams, 'Invalid arguments for download_image');
153
+ }
154
+ return ToolController.handleDownloadImage(request.params.arguments);
155
+ case 'compress_image':
156
+ if (!this.isCompressImageArgs(request.params.arguments)) {
157
+ throw new McpError(ErrorCode.InvalidParams, 'Invalid arguments for compress_image');
158
+ }
159
+ return ToolController.handleCompressImage(request.params.arguments);
160
+ case 'compress_directory':
161
+ if (!this.isCompressDirectoryArgs(request.params.arguments)) {
162
+ throw new McpError(ErrorCode.InvalidParams, 'Invalid arguments for compress_directory');
163
+ }
164
+ return ToolController.handleCompressDirectory(request.params.arguments);
165
+ case 'get_acknowledgement':
166
+ return ToolController.handleGetAcknowledgement();
167
+ default:
168
+ throw new McpError(ErrorCode.MethodNotFound, "Unknown tool: " + request.params.name);
169
+ }
170
+ });
171
+ }
172
+ async run() {
173
+ const transport = new StdioServerTransport();
174
+ await this.server.connect(transport);
175
+ console.error('Image Downloader MCP server running on stdio');
176
+ }
177
+ }
178
+ const server = new ImageDownloaderServer();
179
+ server.run().catch(console.error);
@@ -0,0 +1,17 @@
1
+ import type { FileNamingOptions, ScanFile, TreeNode } from '../types.js';
2
+ export declare class FileService {
3
+ static getImageFiles(dirPath: string, extensions?: string[]): Promise<string[]>;
4
+ static getFileSize(filePath: string): Promise<number>;
5
+ static ensureDir(dirPath: string): Promise<void>;
6
+ static normalizeName(name: string, options: {
7
+ replaceChars: string[];
8
+ case: string;
9
+ }): string;
10
+ static getOutputPath(inputPath: string, inputDir: string, outputDir: string, format: string, fileNaming: FileNamingOptions, dirNaming: FileNamingOptions): string;
11
+ static scanFiles(dirPath: string, baseDir: string, extensions: string[]): Promise<ScanFile[]>;
12
+ static buildTree(files: ScanFile[], baseDir: string): TreeNode;
13
+ private static sortTree;
14
+ static formatTreeMarkdown(node: TreeNode, indent?: string): string[];
15
+ static normalizeKey(str: string): string;
16
+ static formatBytes(bytes: number): string;
17
+ }
@@ -0,0 +1,170 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { SUPPORTED_EXTENSIONS } from '../config/config.constants.js';
4
+ export class FileService {
5
+ static async getImageFiles(dirPath, extensions = SUPPORTED_EXTENSIONS) {
6
+ const files = [];
7
+ const items = await fs.readdir(dirPath, { withFileTypes: true });
8
+ for (const item of items) {
9
+ const fullPath = path.join(dirPath, item.name);
10
+ if (item.isDirectory()) {
11
+ const subFiles = await this.getImageFiles(fullPath, extensions);
12
+ files.push(...subFiles);
13
+ }
14
+ else if (item.isFile()) {
15
+ const ext = path.extname(item.name).toLowerCase();
16
+ if (extensions.includes(ext)) {
17
+ files.push(fullPath);
18
+ }
19
+ }
20
+ }
21
+ return files;
22
+ }
23
+ static async getFileSize(filePath) {
24
+ try {
25
+ const stats = await fs.stat(filePath);
26
+ return stats.size;
27
+ }
28
+ catch {
29
+ return 0;
30
+ }
31
+ }
32
+ static async ensureDir(dirPath) {
33
+ await fs.ensureDir(dirPath);
34
+ }
35
+ static normalizeName(name, options) {
36
+ let normalized = name;
37
+ if (options.replaceChars.length > 0) {
38
+ for (const char of options.replaceChars) {
39
+ if (char) {
40
+ normalized = normalized.split(char).join('_');
41
+ }
42
+ }
43
+ normalized = normalized.replace(/_+/g, '_').replace(/^_|_$/g, '');
44
+ }
45
+ if (options.case === 'lowercase')
46
+ normalized = normalized.toLowerCase();
47
+ else if (options.case === 'uppercase')
48
+ normalized = normalized.toUpperCase();
49
+ return normalized;
50
+ }
51
+ static getOutputPath(inputPath, inputDir, outputDir, format, fileNaming, dirNaming) {
52
+ const relativePath = path.relative(inputDir, inputPath);
53
+ let normalizedPath = relativePath;
54
+ if (dirNaming.enabled) {
55
+ const parts = relativePath.split(path.sep);
56
+ const dirParts = parts.slice(0, -1).map(d => this.normalizeName(d, dirNaming));
57
+ const filename = parts[parts.length - 1];
58
+ normalizedPath = dirParts.length > 0 ? path.join(...dirParts, filename) : filename;
59
+ }
60
+ if (fileNaming.enabled) {
61
+ const ext = path.extname(normalizedPath);
62
+ const name = path.basename(normalizedPath, ext);
63
+ const dir = path.dirname(normalizedPath);
64
+ const normalizedName = this.normalizeName(name, fileNaming);
65
+ normalizedPath = dir === '.' ? `${normalizedName}${ext}` : path.join(dir, `${normalizedName}${ext}`);
66
+ }
67
+ const baseName = path.basename(normalizedPath, path.extname(normalizedPath));
68
+ const dir = path.dirname(normalizedPath);
69
+ const newExt = `.${format}`;
70
+ const outputRelPath = dir === '.' ? `${baseName}${newExt}` : path.join(dir, `${baseName}${newExt}`);
71
+ return path.join(outputDir, outputRelPath);
72
+ }
73
+ static async scanFiles(dirPath, baseDir, extensions) {
74
+ const files = [];
75
+ const items = await fs.readdir(dirPath, { withFileTypes: true });
76
+ for (const item of items) {
77
+ const fullPath = path.join(dirPath, item.name);
78
+ if (item.isDirectory()) {
79
+ const subFiles = await this.scanFiles(fullPath, baseDir, extensions);
80
+ files.push(...subFiles);
81
+ }
82
+ else if (item.isFile()) {
83
+ const ext = path.extname(item.name).toLowerCase();
84
+ if (extensions.includes(ext)) {
85
+ const stats = await fs.stat(fullPath);
86
+ files.push({
87
+ fullPath,
88
+ relativePath: path.relative(baseDir, fullPath),
89
+ size: stats.size,
90
+ ext,
91
+ nameWithoutExt: path.basename(item.name, path.extname(item.name)),
92
+ });
93
+ }
94
+ }
95
+ }
96
+ return files;
97
+ }
98
+ static buildTree(files, baseDir) {
99
+ const root = { name: path.basename(path.resolve(baseDir)), type: 'directory', children: [] };
100
+ for (const file of files) {
101
+ const parts = file.relativePath.split(path.sep);
102
+ let current = root;
103
+ for (let i = 0; i < parts.length; i++) {
104
+ const part = parts[i];
105
+ const isLast = i === parts.length - 1;
106
+ let child = current.children?.find(c => c.name === part);
107
+ if (!child) {
108
+ child = {
109
+ name: part,
110
+ type: isLast ? 'file' : 'directory',
111
+ children: isLast ? undefined : [],
112
+ size: isLast ? file.size : undefined,
113
+ };
114
+ current.children.push(child);
115
+ }
116
+ current = child;
117
+ }
118
+ }
119
+ this.sortTree(root);
120
+ return root;
121
+ }
122
+ static sortTree(node) {
123
+ if (!node.children)
124
+ return;
125
+ node.children.sort((a, b) => {
126
+ if (a.type !== b.type)
127
+ return a.type === 'directory' ? -1 : 1;
128
+ return a.name.localeCompare(b.name);
129
+ });
130
+ for (const child of node.children) {
131
+ this.sortTree(child);
132
+ }
133
+ }
134
+ static formatTreeMarkdown(node, indent = '') {
135
+ const lines = [];
136
+ if (indent === '') {
137
+ lines.push('.');
138
+ }
139
+ if (!node.children)
140
+ return lines;
141
+ for (let i = 0; i < node.children.length; i++) {
142
+ const child = node.children[i];
143
+ const isLast = i === node.children.length - 1;
144
+ const connector = isLast ? '`-- ' : '|-- ';
145
+ if (child.type === 'file') {
146
+ const sizeStr = this.formatBytes(child.size || 0);
147
+ lines.push(`${indent}${connector}${child.name} (${sizeStr})`);
148
+ }
149
+ else {
150
+ lines.push(`${indent}${connector}${child.name}`);
151
+ const newIndent = indent + (isLast ? ' ' : '| ');
152
+ if (child.children) {
153
+ lines.push(...this.formatTreeMarkdown(child, newIndent));
154
+ }
155
+ }
156
+ }
157
+ return lines;
158
+ }
159
+ static normalizeKey(str) {
160
+ return str.toLowerCase().replace(/[-\s.]+/g, '_');
161
+ }
162
+ static formatBytes(bytes) {
163
+ if (bytes === 0)
164
+ return '0 Bytes';
165
+ const k = 1024;
166
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
167
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
168
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
169
+ }
170
+ }
@@ -0,0 +1,10 @@
1
+ import sharp from 'sharp';
2
+ import type { CompressionResult, CompressOptions } from '../types.js';
3
+ export declare class ImageService {
4
+ static compressImage(inputPath: string, outputPath: string, options: CompressOptions): Promise<CompressionResult>;
5
+ static applyFormat(pipeline: sharp.Sharp, format: string, options: {
6
+ quality: number;
7
+ lossless: boolean;
8
+ effort: number;
9
+ }): sharp.Sharp;
10
+ }