@ecopages/image-processor 0.2.1-beta.0 → 0.3.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.
@@ -1,141 +1,154 @@
1
- import path from "node:path";
2
- import { mergeProcessorOptions } from "@ecopages/core/plugins/processor";
3
- import { fileSystem } from "@ecopages/file-system";
4
- import { Logger } from "@ecopages/logger";
5
- import sharp from "sharp";
6
- import { ImageUtils } from "./image-utils.js";
7
- const appLogger = new Logger("[@ecopages/image-processor]", {
8
- debug: process.env.ECOPAGES_LOGGER_DEBUG === "true"
1
+ import path from 'node:path';
2
+ import { writeHashedBrowserAsset } from '@ecopages/core/processor';
3
+ import { mergeProcessorOptions } from '@ecopages/core/processor';
4
+ import { fileSystem } from '@ecopages/file-system';
5
+ import { Logger } from '@ecopages/logger';
6
+ import { isEcopagesEnvEnabled } from '@ecopages/core/processor';
7
+ import sharp, {} from 'sharp';
8
+ import { ImageUtils } from './image-utils.js';
9
+ const appLogger = new Logger('[@ecopages/image-processor]', {
10
+ debug: isEcopagesEnvEnabled('ECOPAGES_LOGGER_DEBUG'),
9
11
  });
10
- class ImageProcessor {
11
- config;
12
- cacheManager;
13
- constructor(config, cacheManager) {
14
- this.config = mergeProcessorOptions({ cacheEnabled: true }, config);
15
- this.cacheManager = cacheManager;
16
- fileSystem.ensureDir(this.config.outputDir);
17
- }
18
- async calculateDimensions(metadata, targetWidth) {
19
- const originalWidth = metadata.width || 0;
20
- const originalHeight = metadata.height || 0;
21
- const aspectRatio = originalHeight / originalWidth;
22
- const width = Math.min(targetWidth, originalWidth);
23
- const height = Math.round(width * aspectRatio);
24
- return { width, height };
25
- }
26
- getOutputPath(imagePath, width) {
27
- const hash = fileSystem.hash(imagePath);
28
- const ext = path.extname(imagePath);
29
- const base = path.basename(imagePath, ext);
30
- const filename = `${base}-${hash}-${width}.${this.config.format}`;
31
- return path.join(this.config.outputDir, filename);
32
- }
33
- async processImage(imagePath) {
34
- try {
35
- const fileHash = fileSystem.hash(imagePath);
36
- const cacheKey = `${path.basename(imagePath)}:${fileHash}`;
37
- if (this.config.cacheEnabled) {
38
- const cached = await this.cacheManager.readCache(cacheKey);
39
- if (cached) {
40
- const mainFilePath = path.join(process.cwd(), cached.attributes.src);
41
- const mainFileExists = fileSystem.exists(mainFilePath);
42
- const variantsExist = cached.variants.every(
43
- (v) => fileSystem.exists(path.join(process.cwd(), v.src))
44
- );
45
- if (mainFileExists && variantsExist) {
46
- appLogger.debug(`Cache hit for ${imagePath}`);
47
- return cached;
48
- }
49
- appLogger.debug(`Cache invalid for ${imagePath}, reprocessing`);
12
+ /**
13
+ * ImageProcessor
14
+ * This is the core class for processing images.
15
+ * It uses the sharp library to resize and optimize images.
16
+ */
17
+ export class ImageProcessor {
18
+ config;
19
+ cacheManager;
20
+ constructor(config, cacheManager) {
21
+ this.config = mergeProcessorOptions({ cacheEnabled: true }, config);
22
+ this.cacheManager = cacheManager;
23
+ fileSystem.ensureDir(this.config.outputDir);
24
+ }
25
+ async calculateDimensions(metadata, targetWidth) {
26
+ const originalWidth = metadata.width || 0;
27
+ const originalHeight = metadata.height || 0;
28
+ const aspectRatio = originalHeight / originalWidth;
29
+ const width = Math.min(targetWidth, originalWidth);
30
+ const height = Math.round(width * aspectRatio);
31
+ return { width, height };
32
+ }
33
+ /**
34
+ * @remarks
35
+ * Includes encoding options so a quality or format change cannot reuse a previous
36
+ * variant. File names come from a hash of the encoded bytes, not this key.
37
+ */
38
+ encodingCacheKey(imagePath, fileHash) {
39
+ const sizesKey = this.config.sizes.map((size) => `${size.width}:${size.label}`).join(',');
40
+ return `${path.basename(imagePath)}:${fileHash}:q${this.config.quality}:${this.config.format}:${sizesKey}`;
41
+ }
42
+ writeEncodedVariant(bytes) {
43
+ return writeHashedBrowserAsset({
44
+ bytes,
45
+ directory: this.config.outputDir,
46
+ extension: `.${this.config.format}`,
47
+ });
48
+ }
49
+ async processImage(imagePath) {
50
+ try {
51
+ const fileHash = fileSystem.hash(imagePath);
52
+ const cacheKey = this.encodingCacheKey(imagePath, fileHash);
53
+ if (this.config.cacheEnabled) {
54
+ const cached = await this.cacheManager.readCache(cacheKey);
55
+ if (cached) {
56
+ const mainFilePath = path.join(this.config.outputDir, path.basename(cached.attributes.src));
57
+ const mainFileExists = fileSystem.exists(mainFilePath);
58
+ const variantsExist = cached.variants.every((variant) => fileSystem.exists(path.join(this.config.outputDir, path.basename(variant.src))));
59
+ if (mainFileExists && variantsExist) {
60
+ appLogger.debug(`Cache hit for ${imagePath}`);
61
+ return cached;
62
+ }
63
+ appLogger.debug(`Cache invalid for ${imagePath}, reprocessing`);
64
+ }
65
+ }
66
+ fileSystem.ensureDir(this.config.outputDir);
67
+ const metadata = await sharp(imagePath).metadata();
68
+ const originalWidth = metadata.width || 0;
69
+ const originalHeight = metadata.height || 0;
70
+ if (this.config.sizes.length === 0) {
71
+ const encoded = await sharp(imagePath)
72
+ .toFormat(this.config.format, { quality: this.config.quality })
73
+ .toBuffer();
74
+ const outputPath = this.writeEncodedVariant(encoded);
75
+ const src = path.join(this.config.publicPath, path.basename(outputPath));
76
+ const imageSpecifications = {
77
+ attributes: {
78
+ src,
79
+ width: originalWidth,
80
+ height: originalHeight,
81
+ sizes: '',
82
+ },
83
+ variants: [],
84
+ cacheKey,
85
+ };
86
+ if (this.config.cacheEnabled) {
87
+ await this.cacheManager.writeCache(cacheKey, imageSpecifications);
88
+ }
89
+ return imageSpecifications;
90
+ }
91
+ let applicableSizes = this.config.sizes
92
+ .filter((size) => size.width <= originalWidth)
93
+ .sort((a, b) => b.width - a.width);
94
+ if (applicableSizes.length === 0) {
95
+ applicableSizes = this.config.sizes.sort((a, b) => b.width - a.width).slice(0, 1);
96
+ }
97
+ const variants = await Promise.all(applicableSizes.map(async ({ width: targetWidth, label }) => {
98
+ const { width, height } = await this.calculateDimensions(metadata, targetWidth);
99
+ const encoded = await sharp(imagePath)
100
+ .resize(width, height)
101
+ .toFormat(this.config.format, { quality: this.config.quality })
102
+ .toBuffer();
103
+ const outputPath = this.writeEncodedVariant(encoded);
104
+ const src = path.join(this.config.publicPath, path.basename(outputPath));
105
+ return {
106
+ width,
107
+ height,
108
+ src,
109
+ label,
110
+ };
111
+ }));
112
+ const mainVariant = variants[0];
113
+ const attributes = {
114
+ src: mainVariant.src,
115
+ width: mainVariant.width,
116
+ height: mainVariant.height,
117
+ sizes: ImageUtils.generateSizes(variants),
118
+ srcset: ImageUtils.generateSrcset(variants),
119
+ };
120
+ const imageSpecifications = {
121
+ attributes,
122
+ variants,
123
+ cacheKey,
124
+ };
125
+ if (this.config.cacheEnabled) {
126
+ await this.cacheManager.writeCache(cacheKey, imageSpecifications);
127
+ }
128
+ return imageSpecifications;
129
+ }
130
+ catch (error) {
131
+ appLogger.error(`Failed to process image ${imagePath}:`, error);
132
+ return null;
50
133
  }
51
- }
52
- fileSystem.ensureDir(this.config.outputDir);
53
- const metadata = await sharp(imagePath).metadata();
54
- const originalWidth = metadata.width || 0;
55
- const originalHeight = metadata.height || 0;
56
- if (this.config.sizes.length === 0) {
57
- const outputPath = this.getOutputPath(imagePath, originalWidth);
58
- if (fileSystem.exists(outputPath)) {
59
- appLogger.debug(`Using existing file for ${imagePath}`);
60
- } else {
61
- await sharp(imagePath).toFormat(this.config.format, { quality: this.config.quality }).toFile(outputPath);
134
+ }
135
+ async processDirectory() {
136
+ const acceptedFormats = this.config.acceptedFormats || ['jpg', 'jpeg', 'png', 'webp'];
137
+ const images = await fileSystem.glob([`${this.config.sourceDir}/**/*.{${acceptedFormats.join(',')}}`]);
138
+ appLogger.debugTime('Processing images');
139
+ const results = (await Promise.all(images.map(async (file) => {
140
+ const processed = await this.processImage(file);
141
+ if (!processed)
142
+ return null;
143
+ return [path.basename(file), processed];
144
+ }))).filter(Boolean);
145
+ appLogger.debugTimeEnd('Processing images');
146
+ if (isEcopagesEnvEnabled('ECOPAGES_BENCH') && !isEcopagesEnvEnabled('ECOPAGES_BENCH_VERBOSE')) {
147
+ appLogger.debug(`Processed ${results.length} images`);
62
148
  }
63
- const src = path.join(this.config.publicPath, path.basename(outputPath));
64
- const imageSpecifications2 = {
65
- attributes: {
66
- src,
67
- width: originalWidth,
68
- height: originalHeight,
69
- sizes: ""
70
- },
71
- variants: [],
72
- cacheKey
73
- };
74
- if (this.config.cacheEnabled) {
75
- await this.cacheManager.writeCache(cacheKey, imageSpecifications2);
149
+ else {
150
+ appLogger.info(`Processed ${results.length} images`);
76
151
  }
77
- return imageSpecifications2;
78
- }
79
- let applicableSizes = this.config.sizes.filter((size) => size.width <= originalWidth).sort((a, b) => b.width - a.width);
80
- if (applicableSizes.length === 0) {
81
- applicableSizes = this.config.sizes.sort((a, b) => b.width - a.width).slice(0, 1);
82
- }
83
- const variants = await Promise.all(
84
- applicableSizes.map(async ({ width: targetWidth, label }) => {
85
- const { width, height } = await this.calculateDimensions(metadata, targetWidth);
86
- const outputPath = this.getOutputPath(imagePath, width);
87
- if (fileSystem.exists(outputPath)) {
88
- appLogger.debug(`Variant ${width}px already exists for ${imagePath}`);
89
- } else {
90
- await sharp(imagePath).resize(width, height).toFormat(this.config.format, { quality: this.config.quality }).toFile(outputPath);
91
- }
92
- const src = path.join(this.config.publicPath, path.basename(outputPath));
93
- return {
94
- width,
95
- height,
96
- src,
97
- label
98
- };
99
- })
100
- );
101
- const mainVariant = variants[0];
102
- const attributes = {
103
- src: mainVariant.src,
104
- width: mainVariant.width,
105
- height: mainVariant.height,
106
- sizes: ImageUtils.generateSizes(variants),
107
- srcset: ImageUtils.generateSrcset(variants)
108
- };
109
- const imageSpecifications = {
110
- attributes,
111
- variants,
112
- cacheKey
113
- };
114
- if (this.config.cacheEnabled) {
115
- await this.cacheManager.writeCache(cacheKey, imageSpecifications);
116
- }
117
- return imageSpecifications;
118
- } catch (error) {
119
- appLogger.error(`Failed to process image ${imagePath}:`, error);
120
- return null;
152
+ return Object.fromEntries(results);
121
153
  }
122
- }
123
- async processDirectory() {
124
- const acceptedFormats = this.config.acceptedFormats || ["jpg", "jpeg", "png", "webp"];
125
- const images = await fileSystem.glob([`${this.config.sourceDir}/**/*.{${acceptedFormats.join(",")}}`]);
126
- appLogger.debugTime("Processing images");
127
- const results = (await Promise.all(
128
- images.map(async (file) => {
129
- const processed = await this.processImage(file);
130
- if (!processed) return null;
131
- return [path.basename(file), processed];
132
- })
133
- )).filter(Boolean);
134
- appLogger.debugTimeEnd("Processing images");
135
- appLogger.info(`Processed ${results.length} images`);
136
- return Object.fromEntries(results);
137
- }
138
154
  }
139
- export {
140
- ImageProcessor
141
- };
@@ -92,7 +92,7 @@ export interface GenerateAttributesResultJsx {
92
92
  sizes?: string;
93
93
  width?: number;
94
94
  height?: number;
95
- style?: React.CSSProperties;
95
+ style?: Record<string, string>;
96
96
  }
97
97
  /**
98
98
  * IImageRenderer