@aigne/doc-smith 0.9.9-beta.3 → 0.9.9
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/CHANGELOG.md +9 -0
- package/agents/media/load-media-description.mjs +228 -30
- package/package.json +1 -1
- package/utils/d2-utils.mjs +4 -4
- package/utils/image-compress.mjs +82 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.9.9](https://github.com/AIGNE-io/aigne-doc-smith/compare/v0.9.9-beta.4...v0.9.9) (2025-12-13)
|
|
4
|
+
|
|
5
|
+
## [0.9.9-beta.4](https://github.com/AIGNE-io/aigne-doc-smith/compare/v0.9.9-beta.3...v0.9.9-beta.4) (2025-12-12)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* compress large images before sending them to llm ([#362](https://github.com/AIGNE-io/aigne-doc-smith/issues/362)) ([ffb21f0](https://github.com/AIGNE-io/aigne-doc-smith/commit/ffb21f0f29cb414bf3c09df387bd29abf7248ea7))
|
|
11
|
+
|
|
3
12
|
## [0.9.9-beta.3](https://github.com/AIGNE-io/aigne-doc-smith/compare/v0.9.9-beta.2...v0.9.9-beta.3) (2025-12-11)
|
|
4
13
|
|
|
5
14
|
|
|
@@ -4,9 +4,17 @@ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { parse, stringify } from "yaml";
|
|
6
6
|
import { getMediaDescriptionCachePath } from "../../utils/file-utils.mjs";
|
|
7
|
+
import { compressImage } from "../../utils/image-compress.mjs";
|
|
8
|
+
import sharp from "sharp";
|
|
9
|
+
import { debug } from "../../utils/debug.mjs";
|
|
10
|
+
import { DOC_SMITH_DIR, TMP_DIR } from "../../utils/constants/index.mjs";
|
|
11
|
+
import { ensureTmpDir } from "../../utils/d2-utils.mjs";
|
|
12
|
+
import pMap from "p-map";
|
|
7
13
|
|
|
8
14
|
const SIZE_THRESHOLD = 10 * 1024 * 1024; // 10MB
|
|
9
15
|
const SVG_SIZE_THRESHOLD = 50 * 1024; // 50KB for SVG files
|
|
16
|
+
const MAX_IMAGE_SIZE = 1 * 1024 * 1024; // 1MB
|
|
17
|
+
const MAX_IMAGE_RESOLUTION = 2048; // 2K
|
|
10
18
|
|
|
11
19
|
// Supported MIME types for Gemini AI
|
|
12
20
|
const SUPPORTED_IMAGE_TYPES = new Set([
|
|
@@ -128,6 +136,107 @@ export default async function loadMediaDescription(input, options) {
|
|
|
128
136
|
console.warn(`Failed to read SVG file ${mediaFile.path}:`, error.message);
|
|
129
137
|
}
|
|
130
138
|
} else {
|
|
139
|
+
// For non-SVG media files, check if compression is needed
|
|
140
|
+
let finalImagePath = absolutePath;
|
|
141
|
+
let shouldCompress = false;
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
// Check file size and dimensions
|
|
145
|
+
const fileStats = await stat(absolutePath);
|
|
146
|
+
const fileSize = fileStats.size;
|
|
147
|
+
|
|
148
|
+
// Get image dimensions
|
|
149
|
+
const metadata = await sharp(absolutePath).metadata();
|
|
150
|
+
const { width, height } = metadata;
|
|
151
|
+
|
|
152
|
+
// Determine if compression is needed
|
|
153
|
+
// Compression rules:
|
|
154
|
+
// 1. Only compress files larger than 1MB
|
|
155
|
+
// 2. Compressed file must have resolution <= 2K and size < 1MB
|
|
156
|
+
// 3. Files <= 1MB are never compressed, regardless of resolution
|
|
157
|
+
const exceedsSize = fileSize > MAX_IMAGE_SIZE;
|
|
158
|
+
|
|
159
|
+
// Only compress if file size > 1MB
|
|
160
|
+
// For files <= 1MB, skip compression regardless of resolution
|
|
161
|
+
if (exceedsSize) {
|
|
162
|
+
// Create temporary compressed file path in temp directory with same relative structure
|
|
163
|
+
// Example: docs/assets/images/photo.jpg -> .aigne/doc-smith/.tmp/docs/assets/images/photo.compressed.jpg
|
|
164
|
+
await ensureTmpDir();
|
|
165
|
+
const tmpBaseDir = path.join(process.cwd(), DOC_SMITH_DIR, TMP_DIR);
|
|
166
|
+
|
|
167
|
+
// Get relative path from docsDir to maintain structure
|
|
168
|
+
// mediaFile.path is already relative to docsDir (e.g., "assets/images/photo.jpg")
|
|
169
|
+
const relativePath = mediaFile.path;
|
|
170
|
+
const relativeDir = path.dirname(relativePath);
|
|
171
|
+
const fileName = path.basename(relativePath, path.extname(relativePath));
|
|
172
|
+
const fileExt = path.extname(relativePath);
|
|
173
|
+
|
|
174
|
+
// Normalize docsDir to handle both relative and absolute paths
|
|
175
|
+
// If docsDir is absolute, extract the relative part from cwd
|
|
176
|
+
let normalizedDocsDir = docsDir;
|
|
177
|
+
if (path.isAbsolute(docsDir)) {
|
|
178
|
+
const cwd = process.cwd();
|
|
179
|
+
if (docsDir.startsWith(cwd)) {
|
|
180
|
+
normalizedDocsDir = path.relative(cwd, docsDir);
|
|
181
|
+
} else {
|
|
182
|
+
// If docsDir is outside cwd, use just the basename
|
|
183
|
+
normalizedDocsDir = path.basename(docsDir);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Create temp directory structure matching the relative path
|
|
188
|
+
// Structure: .aigne/doc-smith/.tmp/{docsDir}/{relativeDir}
|
|
189
|
+
const tempDir = path.join(tmpBaseDir, normalizedDocsDir, relativeDir);
|
|
190
|
+
await mkdir(tempDir, { recursive: true });
|
|
191
|
+
|
|
192
|
+
// Create compressed file path
|
|
193
|
+
const tempFileName = `${fileName}.compressed${fileExt}`;
|
|
194
|
+
const tempPath = path.join(tempDir, tempFileName);
|
|
195
|
+
|
|
196
|
+
// Check if compressed file already exists in cache directory
|
|
197
|
+
if (existsSync(tempPath)) {
|
|
198
|
+
debug(`Compressed file already exists for ${mediaFile.path}, skipping compression`);
|
|
199
|
+
finalImagePath = tempPath;
|
|
200
|
+
shouldCompress = true;
|
|
201
|
+
} else {
|
|
202
|
+
shouldCompress = true;
|
|
203
|
+
debug(
|
|
204
|
+
`Compressing image ${mediaFile.path} (size: ${(fileSize / 1024 / 1024).toFixed(2)}MB, resolution: ${width}x${height})`,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
// Compress image with constraints
|
|
208
|
+
// For files > 1MB: compress to resolution <= 2K and size < 1MB
|
|
209
|
+
finalImagePath = await compressImage(absolutePath, {
|
|
210
|
+
maxWidth: MAX_IMAGE_RESOLUTION, // Always limit to 2K
|
|
211
|
+
maxHeight: MAX_IMAGE_RESOLUTION, // Always limit to 2K
|
|
212
|
+
maxSizeBytes: MAX_IMAGE_SIZE, // Always limit to 1MB
|
|
213
|
+
outputPath: tempPath,
|
|
214
|
+
quality: 70, // Start with good quality
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Verify compressed file size
|
|
218
|
+
const compressedStats = await stat(finalImagePath);
|
|
219
|
+
if (compressedStats.size > MAX_IMAGE_SIZE) {
|
|
220
|
+
console.warn(
|
|
221
|
+
`Compressed image ${mediaFile.path} still exceeds ${MAX_IMAGE_SIZE / 1024 / 1024}MB (${(compressedStats.size / 1024 / 1024).toFixed(2)}MB), using compressed version anyway`,
|
|
222
|
+
);
|
|
223
|
+
} else {
|
|
224
|
+
debug(
|
|
225
|
+
`✅ Image compressed: ${mediaFile.path} -> ${(compressedStats.size / 1024 / 1024).toFixed(2)}MB`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
debug(
|
|
231
|
+
`Image ${mediaFile.path} is <= 1MB (size: ${(fileSize / 1024 / 1024).toFixed(2)}MB, resolution: ${width}x${height}), skipping compression`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
} catch (error) {
|
|
235
|
+
console.warn(`Failed to compress image ${mediaFile.path}:`, error.message);
|
|
236
|
+
// Use original path if compression fails
|
|
237
|
+
finalImagePath = absolutePath;
|
|
238
|
+
}
|
|
239
|
+
|
|
131
240
|
// For non-SVG media files, use mediaFile field
|
|
132
241
|
mediaToDescribe.push({
|
|
133
242
|
...mediaFile,
|
|
@@ -136,55 +245,144 @@ export default async function loadMediaDescription(input, options) {
|
|
|
136
245
|
mediaFile: [
|
|
137
246
|
{
|
|
138
247
|
type: "local",
|
|
139
|
-
path:
|
|
248
|
+
path: finalImagePath,
|
|
140
249
|
filename: mediaFile.name,
|
|
141
250
|
mimeType: mediaFile.mimeType,
|
|
142
251
|
},
|
|
143
252
|
],
|
|
253
|
+
_compressed: shouldCompress, // Track if compression was applied for cleanup
|
|
254
|
+
_originalPath: shouldCompress ? absolutePath : undefined, // Store original path for cleanup
|
|
144
255
|
});
|
|
145
256
|
}
|
|
146
257
|
}
|
|
147
258
|
}
|
|
148
259
|
|
|
149
|
-
// Generate descriptions for media files without cache -
|
|
260
|
+
// Generate descriptions for media files without cache - batch processing with incremental save
|
|
150
261
|
const newDescriptions = {};
|
|
262
|
+
const results = []; // Track all results for accurate counting
|
|
263
|
+
|
|
151
264
|
if (mediaToDescribe.length > 0) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
265
|
+
// Ensure cache directory exists
|
|
266
|
+
await mkdir(path.dirname(cacheFilePath), { recursive: true });
|
|
267
|
+
|
|
268
|
+
// Create a write lock queue to ensure thread-safe cache updates
|
|
269
|
+
let writeQueue = Promise.resolve();
|
|
270
|
+
// Keep in-memory cache in sync to avoid unnecessary file reads
|
|
271
|
+
const inMemoryCache = { ...cache };
|
|
272
|
+
|
|
273
|
+
// Helper function to save cache with lock
|
|
274
|
+
// Optimized: Use in-memory cache to reduce file I/O
|
|
275
|
+
const saveCacheWithLock = async (newEntry) => {
|
|
276
|
+
// Add to write queue to ensure sequential writes
|
|
277
|
+
writeQueue = writeQueue
|
|
278
|
+
.then(async () => {
|
|
279
|
+
try {
|
|
280
|
+
// Merge new entry into in-memory cache
|
|
281
|
+
Object.assign(inMemoryCache, newEntry);
|
|
160
282
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
283
|
+
// Save to disk
|
|
284
|
+
const cacheYaml = stringify({
|
|
285
|
+
descriptions: inMemoryCache,
|
|
286
|
+
lastUpdated: new Date().toISOString(),
|
|
287
|
+
});
|
|
288
|
+
await writeFile(cacheFilePath, cacheYaml, "utf8");
|
|
289
|
+
// Only update in-memory cache after successful write
|
|
290
|
+
return true;
|
|
291
|
+
} catch (error) {
|
|
292
|
+
// Rollback: remove the entry from in-memory cache if write failed
|
|
293
|
+
for (const key of Object.keys(newEntry)) {
|
|
294
|
+
delete inMemoryCache[key];
|
|
295
|
+
}
|
|
296
|
+
console.error(`Failed to save cache: ${error.message}`);
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
})
|
|
300
|
+
.catch(() => {
|
|
301
|
+
// Don't let one write failure break the queue
|
|
302
|
+
// Error is already logged above
|
|
303
|
+
return false;
|
|
304
|
+
});
|
|
305
|
+
await writeQueue;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// Process media files concurrently with incremental save
|
|
309
|
+
// Use pMap for concurrent processing with controlled concurrency
|
|
310
|
+
const CONCURRENCY = 5; // Process 5 files concurrently
|
|
311
|
+
|
|
312
|
+
await pMap(
|
|
313
|
+
mediaToDescribe,
|
|
314
|
+
async (mediaItem, index) => {
|
|
315
|
+
const result = { success: false, path: mediaItem.path, error: null };
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
// Generate description for single media file
|
|
319
|
+
// Note: If compression was applied, mediaItem.mediaFile[0].path points to compressed file
|
|
320
|
+
// This compressed file is used for upload, but cache uses original file hash and path
|
|
321
|
+
const agentResult = await options.context.invoke(
|
|
322
|
+
options.context.agents["generateMediaDescription"],
|
|
323
|
+
mediaItem,
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
// Check if description was generated successfully
|
|
327
|
+
// Note: agentResult.hash and agentResult.path come from the agent, but we need to ensure
|
|
328
|
+
// we use the original file path and hash for caching, not the compressed file path
|
|
329
|
+
if (agentResult?.hash && agentResult?.description) {
|
|
330
|
+
// Use original file path and hash for cache entry
|
|
331
|
+
// The compressed file is only used for upload, but cache should reference original file
|
|
332
|
+
const originalPath = mediaItem.path;
|
|
333
|
+
const originalHash = mediaItem.hash;
|
|
334
|
+
|
|
335
|
+
const descriptionEntry = {
|
|
336
|
+
path: originalPath, // Use original file path, not compressed file path
|
|
337
|
+
description: agentResult.description,
|
|
168
338
|
generatedAt: new Date().toISOString(),
|
|
169
339
|
};
|
|
340
|
+
|
|
341
|
+
// Immediately save to cache using lock mechanism
|
|
342
|
+
await saveCacheWithLock({ [originalHash]: descriptionEntry });
|
|
343
|
+
|
|
344
|
+
// Track in memory for summary
|
|
345
|
+
newDescriptions[originalHash] = descriptionEntry;
|
|
346
|
+
result.success = true;
|
|
347
|
+
|
|
348
|
+
debug(
|
|
349
|
+
`✅ Generated and saved description for ${mediaItem.path} (${index + 1}/${mediaToDescribe.length})`,
|
|
350
|
+
);
|
|
351
|
+
} else {
|
|
352
|
+
result.error = "No description in result";
|
|
353
|
+
console.warn(
|
|
354
|
+
`Failed to generate description for ${mediaItem.path}: No description in result`,
|
|
355
|
+
);
|
|
170
356
|
}
|
|
357
|
+
} catch (error) {
|
|
358
|
+
result.error = error.message;
|
|
359
|
+
console.error(`Failed to generate description for ${mediaItem.path}:`, error.message);
|
|
360
|
+
// Continue processing other files even if one fails
|
|
171
361
|
}
|
|
172
|
-
}
|
|
173
362
|
|
|
174
|
-
|
|
175
|
-
|
|
363
|
+
results.push(result);
|
|
364
|
+
return result;
|
|
365
|
+
},
|
|
366
|
+
{ concurrency: CONCURRENCY },
|
|
367
|
+
);
|
|
176
368
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
descriptions: cache,
|
|
181
|
-
lastUpdated: new Date().toISOString(),
|
|
182
|
-
});
|
|
183
|
-
await writeFile(cacheFilePath, cacheYaml, "utf8");
|
|
369
|
+
// Calculate accurate counts from results
|
|
370
|
+
const successCount = results.filter((r) => r.success).length;
|
|
371
|
+
const errorCount = results.filter((r) => !r.success).length;
|
|
184
372
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
373
|
+
// Update cache reference to in-memory cache
|
|
374
|
+
Object.assign(cache, inMemoryCache);
|
|
375
|
+
|
|
376
|
+
// Log summary
|
|
377
|
+
if (successCount > 0) {
|
|
378
|
+
console.log(
|
|
379
|
+
`Generated descriptions for ${successCount} media files (${errorCount} errors, ${mediaToDescribe.length - successCount - errorCount} skipped)`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
if (errorCount > 0) {
|
|
383
|
+
console.warn(
|
|
384
|
+
`⚠️ Failed to generate descriptions for ${errorCount} media files. Completed descriptions have been saved.`,
|
|
385
|
+
);
|
|
188
386
|
}
|
|
189
387
|
}
|
|
190
388
|
|
package/package.json
CHANGED
package/utils/d2-utils.mjs
CHANGED
|
@@ -13,21 +13,21 @@ export const DIAGRAM_PLACEHOLDER = "DIAGRAM_PLACEHOLDER";
|
|
|
13
13
|
// Diagram image regex patterns for reuse across the codebase
|
|
14
14
|
// Pattern 1: Match only the start marker (for checking existence)
|
|
15
15
|
export const diagramImageStartRegex =
|
|
16
|
-
/<!--\s*DIAGRAM_IMAGE_START:([A-Za-z0-9_-]+):(\d+:\d+)(
|
|
16
|
+
/<!--\s*DIAGRAM_IMAGE_START:([A-Za-z0-9_-]+):(\d+:\d+)(::?\d+)?\s*-->/g;
|
|
17
17
|
|
|
18
18
|
// Pattern 2: Match full diagram image block without capturing image path (for finding/replacing)
|
|
19
19
|
// Supports both old format (without aspectRatio) and new format (with aspectRatio)
|
|
20
20
|
export const diagramImageBlockRegex =
|
|
21
|
-
/<!--\s*DIAGRAM_IMAGE_START:([A-Za-z0-9_-]+)(?::(\d+:\d+))?(
|
|
21
|
+
/<!--\s*DIAGRAM_IMAGE_START:([A-Za-z0-9_-]+)(?::(\d+:\d+))?(::?\d+)?\s*-->\s*[\s\S]*?<!--\s*DIAGRAM_IMAGE_END\s*-->/g;
|
|
22
22
|
|
|
23
23
|
// Pattern 3: Match full diagram image block with image path capture (for extracting paths)
|
|
24
24
|
// Compatible with old format (without timestamp): <!-- DIAGRAM_IMAGE_START:type:aspectRatio -->
|
|
25
25
|
export const diagramImageWithPathRegex =
|
|
26
|
-
/<!--\s*DIAGRAM_IMAGE_START:([A-Za-z0-9_-]+):(\d+:\d+)(
|
|
26
|
+
/<!--\s*DIAGRAM_IMAGE_START:([A-Za-z0-9_-]+):(\d+:\d+)(::?\d+)?\s*-->\s*!\[[^\]]*\]\(([^)]+)\)\s*<!--\s*DIAGRAM_IMAGE_END\s*-->/g;
|
|
27
27
|
|
|
28
28
|
// Pattern 4: Match full diagram image block with all details (type, aspectRatio, timestamp, path, altText)
|
|
29
29
|
export const diagramImageFullRegex =
|
|
30
|
-
/<!--\s*DIAGRAM_IMAGE_START:([A-Za-z0-9_-]+):(\d+:\d+)(
|
|
30
|
+
/<!--\s*DIAGRAM_IMAGE_START:([A-Za-z0-9_-]+):(\d+:\d+)(::?\d+)?\s*-->\s*!\[([^\]]*)\]\(([^)]+)\)\s*<!--\s*DIAGRAM_IMAGE_END\s*-->/g;
|
|
31
31
|
|
|
32
32
|
export async function ensureTmpDir() {
|
|
33
33
|
const tmpDir = path.join(DOC_SMITH_DIR, TMP_DIR);
|
package/utils/image-compress.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import sharp from "sharp";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { stat } from "node:fs/promises";
|
|
3
4
|
import { debug } from "./debug.mjs";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -10,10 +11,13 @@ import { debug } from "./debug.mjs";
|
|
|
10
11
|
* @param {number} options.quality - Compression quality (0-100, default: 80)
|
|
11
12
|
* @param {string} options.outputFormat - Output format: 'jpeg', 'png', 'webp' (default: auto-detect from input)
|
|
12
13
|
* @param {string} options.outputPath - Output path for compressed image (if not provided, creates temp file)
|
|
14
|
+
* @param {number} options.maxWidth - Maximum width in pixels (default: undefined, no limit)
|
|
15
|
+
* @param {number} options.maxHeight - Maximum height in pixels (default: undefined, no limit)
|
|
16
|
+
* @param {number} options.maxSizeBytes - Maximum file size in bytes (default: undefined, no limit)
|
|
13
17
|
* @returns {Promise<string>} - Path to the compressed image (outputPath if provided, or temp path, or inputPath if compression fails)
|
|
14
18
|
*/
|
|
15
19
|
export async function compressImage(inputPath, options = {}) {
|
|
16
|
-
const { quality = 80, outputFormat, outputPath } = options;
|
|
20
|
+
const { quality = 80, outputFormat, outputPath, maxWidth, maxHeight, maxSizeBytes } = options;
|
|
17
21
|
|
|
18
22
|
try {
|
|
19
23
|
const inputExt = path.extname(inputPath).toLowerCase();
|
|
@@ -45,9 +49,37 @@ export async function compressImage(inputPath, options = {}) {
|
|
|
45
49
|
finalOutputPath = path.join(inputDir, `${inputBase}.compressed${outputExt}`);
|
|
46
50
|
}
|
|
47
51
|
|
|
48
|
-
//
|
|
52
|
+
// Get image metadata
|
|
53
|
+
const metadata = await sharp(inputPath).metadata();
|
|
54
|
+
const { width, height } = metadata;
|
|
55
|
+
|
|
56
|
+
// Calculate target dimensions if maxWidth or maxHeight is specified
|
|
57
|
+
let targetWidth = width;
|
|
58
|
+
let targetHeight = height;
|
|
59
|
+
if (maxWidth || maxHeight) {
|
|
60
|
+
const aspectRatio = width / height;
|
|
61
|
+
if (maxWidth && width > maxWidth) {
|
|
62
|
+
targetWidth = maxWidth;
|
|
63
|
+
targetHeight = Math.round(maxWidth / aspectRatio);
|
|
64
|
+
}
|
|
65
|
+
if (maxHeight && targetHeight > maxHeight) {
|
|
66
|
+
targetHeight = maxHeight;
|
|
67
|
+
targetWidth = Math.round(maxHeight * aspectRatio);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Create sharp instance
|
|
49
72
|
let sharpInstance = sharp(inputPath);
|
|
50
73
|
|
|
74
|
+
// Resize if needed
|
|
75
|
+
if (targetWidth !== width || targetHeight !== height) {
|
|
76
|
+
sharpInstance = sharpInstance.resize(targetWidth, targetHeight, {
|
|
77
|
+
fit: "inside",
|
|
78
|
+
withoutEnlargement: true,
|
|
79
|
+
});
|
|
80
|
+
debug(`Resizing image from ${width}x${height} to ${targetWidth}x${targetHeight}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
51
83
|
// Apply format-specific compression options
|
|
52
84
|
if (format === "jpeg") {
|
|
53
85
|
// mozjpeg is a valid sharp option for better JPEG compression
|
|
@@ -59,11 +91,58 @@ export async function compressImage(inputPath, options = {}) {
|
|
|
59
91
|
sharpInstance = sharpInstance.webp({ quality });
|
|
60
92
|
}
|
|
61
93
|
|
|
94
|
+
// If maxSizeBytes is specified, try to compress to target size
|
|
95
|
+
if (maxSizeBytes) {
|
|
96
|
+
let currentQuality = quality;
|
|
97
|
+
const compressedPath = finalOutputPath;
|
|
98
|
+
let attempts = 0;
|
|
99
|
+
const maxAttempts = 10;
|
|
100
|
+
const qualityStep = 5;
|
|
101
|
+
|
|
102
|
+
while (attempts < maxAttempts) {
|
|
103
|
+
// Create a new sharp instance for each attempt
|
|
104
|
+
let attemptInstance = sharp(inputPath);
|
|
105
|
+
if (targetWidth !== width || targetHeight !== height) {
|
|
106
|
+
attemptInstance = attemptInstance.resize(targetWidth, targetHeight, {
|
|
107
|
+
fit: "inside",
|
|
108
|
+
withoutEnlargement: true,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Apply format with current quality
|
|
113
|
+
if (format === "jpeg") {
|
|
114
|
+
attemptInstance = attemptInstance.jpeg({ quality: currentQuality, mozjpeg: true });
|
|
115
|
+
} else if (format === "png") {
|
|
116
|
+
attemptInstance = attemptInstance.png({ quality: currentQuality, compressionLevel: 9 });
|
|
117
|
+
} else if (format === "webp") {
|
|
118
|
+
attemptInstance = attemptInstance.webp({ quality: currentQuality });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
await attemptInstance.toFile(compressedPath);
|
|
122
|
+
|
|
123
|
+
const stats = await stat(compressedPath);
|
|
124
|
+
if (stats.size <= maxSizeBytes || currentQuality <= 20) {
|
|
125
|
+
// Target size achieved or quality too low, stop
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Reduce quality and try again
|
|
130
|
+
currentQuality = Math.max(20, currentQuality - qualityStep);
|
|
131
|
+
attempts++;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
debug(
|
|
135
|
+
`✅ Image compressed: ${inputPath} -> ${compressedPath} (format: ${format}, quality: ${currentQuality}, size: ${(await stat(compressedPath)).size} bytes)`,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
return compressedPath;
|
|
139
|
+
}
|
|
140
|
+
|
|
62
141
|
// Write compressed image directly to output path
|
|
63
142
|
await sharpInstance.toFile(finalOutputPath);
|
|
64
143
|
|
|
65
144
|
debug(
|
|
66
|
-
`✅ Image compressed: ${inputPath} -> ${finalOutputPath} (format: ${format}, quality: ${quality})`,
|
|
145
|
+
`✅ Image compressed: ${inputPath} -> ${finalOutputPath} (format: ${format}, quality: ${quality}, dimensions: ${targetWidth}x${targetHeight})`,
|
|
67
146
|
);
|
|
68
147
|
|
|
69
148
|
return finalOutputPath;
|