@localnerve/gulp-images 0.3.1 → 0.4.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/README.md +35 -31
- package/package.json +8 -4
- package/src/index.js +3 -3
- package/src/optimize/avif.js +51 -0
- package/src/optimize/index.js +4 -4
- package/src/optimize/jxl.js +51 -0
- package/src/optimize/webp.js +51 -0
- package/src/transform/index.js +3 -4
- package/src/transform/toAvif.js +92 -0
- package/src/transform/toJxl.js +92 -0
- package/src/transform/toWebp.js +10 -4
- package/src/utils.js +43 -6
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @localnerve/gulp-images
|
|
2
2
|
|
|
3
|
-
> Portable (wasm & sharp), minimal dependency, streaming image processing for javascript builds. Optionally outputs image processing metadata to allow the images themselves to drive css generation and/or other work.
|
|
3
|
+
> Portable (wasm & sharp), minimal dependency, streaming image processing for javascript builds. Optionally outputs image processing metadata to allow the images themselves to drive css generation and/or other work. Eventually, this project will be wasm only, no build chain required.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
> **Offers Jpeg-xl optimization and transformations.**
|
|
6
6
|
|
|
7
7
|
Three functional groups — **optimize**, **responsive**, and **transform** — each independently importable and easily extensible.
|
|
8
8
|
|
|
@@ -22,7 +22,7 @@ Requires `gulp` as a peer dependency.
|
|
|
22
22
|
|
|
23
23
|
### Initialize WASM codecs first
|
|
24
24
|
|
|
25
|
-
All
|
|
25
|
+
All `optimize` and `transform` operations rely on WASM modules that must be initialized before any pipeline runs.
|
|
26
26
|
|
|
27
27
|
```js
|
|
28
28
|
import { initWasmModules } from '@localnerve/gulp-images';
|
|
@@ -33,6 +33,7 @@ await initWasmModules('/path/to/monorepo/root'); // explicit base path
|
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
### Full pipeline example
|
|
36
|
+
> Generates responsive images, then optimizes all images, then creates `webp` versions of all raster images.
|
|
36
37
|
|
|
37
38
|
```js
|
|
38
39
|
import gulp from 'gulp';
|
|
@@ -43,13 +44,15 @@ await initWasmModules();
|
|
|
43
44
|
const settings = {
|
|
44
45
|
prod: true,
|
|
45
46
|
svgoOptions: { /* svgo options */ },
|
|
47
|
+
avifOptions: { quality: 80, speed: 6 },
|
|
48
|
+
jxlOptions: { quality: 80 },
|
|
46
49
|
mozjpegOptions: { quality: 80 },
|
|
47
50
|
oxipngOptions: { level: 2 },
|
|
48
51
|
webpOptions: { quality: 80 },
|
|
49
52
|
responsiveConfig: { /* gulp-responsive config */ }
|
|
50
53
|
};
|
|
51
54
|
|
|
52
|
-
const output = {}; // optional — receives image metadata from responsive +
|
|
55
|
+
const output = {}; // optional — receives image metadata from responsive + transform
|
|
53
56
|
|
|
54
57
|
gulp.series(
|
|
55
58
|
function createResponsiveImages () {
|
|
@@ -60,13 +63,15 @@ gulp.series(
|
|
|
60
63
|
function optimizeImages () {
|
|
61
64
|
return gulp.src('dist/images/**', { encoding: false })
|
|
62
65
|
.pipe(optimize.svg(settings))
|
|
66
|
+
.pipe(optimize.avif(settings))
|
|
63
67
|
.pipe(optimize.jpeg(settings))
|
|
68
|
+
.pipe(optimize.jxl(settings))
|
|
64
69
|
.pipe(optimize.png(settings))
|
|
65
70
|
.pipe(gulp.dest('dist/images'));
|
|
66
71
|
},
|
|
67
72
|
function convertToWebp () {
|
|
68
73
|
return gulp.src('dist/images/**', { encoding: false })
|
|
69
|
-
.pipe(transform.toWebp(settings, output))
|
|
74
|
+
.pipe(transform.toWebp(settings, output)) // toAvif, toJxl available
|
|
70
75
|
.pipe(gulp.dest('dist/images'));
|
|
71
76
|
}
|
|
72
77
|
)();
|
|
@@ -83,21 +88,21 @@ gulp.series(
|
|
|
83
88
|
| `svg(settings)` | Optimizes SVG files via [svgo](https://github.com/svg/svgo). No-op unless `settings.prod === true`. |
|
|
84
89
|
| `jpeg(settings)` | Re-encodes JPEGs via mozjpeg ([`@jsquash/jpeg`](https://github.com/jamsinclair/jSquash)). No-op unless `settings.prod === true`. |
|
|
85
90
|
| `png(settings)` | Optimizes PNGs via oxipng ([`@jsquash/oxipng`](https://github.com/jamsinclair/jSquash)). No-op unless `settings.prod === true`. |
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
import * as optimize from '@localnerve/gulp-images/optimize';
|
|
90
|
-
const myOptimize = { ...optimize, avif: myAvifOptimizer };
|
|
91
|
-
```
|
|
91
|
+
| `avif(settings)` | Optimizes AVIFs via libavif ([`@jsquash/avif`](https://github.com/jamsinclair/jSquash)). No-op unless `settings.prod === true`. |
|
|
92
|
+
| `jxl(settings)` | Optimizes JXLs via libjxl ([`@jsquash/jxl`](https://github.com/jamsinclair/jSquash)). No-op unless `settings.prod === true`. |
|
|
93
|
+
| `webp(settings)` | Optimizes WEBPs via libavif ([`@jsquash/webp`](https://github.com/jamsinclair/jSquash)). No-op unless `settings.prod === true`. |
|
|
92
94
|
|
|
93
95
|
**`settings` keys used by optimize:**
|
|
94
96
|
|
|
95
|
-
| Key | Type | Used by |
|
|
96
|
-
|
|
97
|
-
| `prod` | `boolean` | all — enables optimization |
|
|
98
|
-
| `svgoOptions` | `object` | `svg` |
|
|
99
|
-
| `mozjpegOptions` | `object` | `jpeg` |
|
|
100
|
-
| `oxipngOptions` | `object` | `png` |
|
|
97
|
+
| Key | Type | Used by | Reference |
|
|
98
|
+
|-----|------|---------|-----------------|
|
|
99
|
+
| `prod` | `boolean` | all — enables optimization | **This Doc** |
|
|
100
|
+
| `svgoOptions` | `object` | `svg` | [reference](https://svgo.dev/docs/plugins/) |
|
|
101
|
+
| `mozjpegOptions` | `object` | `jpeg` | [reference](https://github.com/jamsinclair/jSquash/blob/main/packages/jpeg/meta.ts) |
|
|
102
|
+
| `oxipngOptions` | `object` | `png` | [reference](https://github.com/jamsinclair/jSquash/blob/main/packages/oxipng/meta.ts) |
|
|
103
|
+
| `avifOptions` | `object` | `avif` | [reference](https://github.com/jamsinclair/jSquash/blob/main/packages/avif/meta.ts) |
|
|
104
|
+
| `jxlOptions` | `object` | `jxl` | [reference](https://github.com/jamsinclair/jSquash/blob/main/packages/jxl/meta.ts) |
|
|
105
|
+
| `webpOptions` | `object` | `webp` | [reference](https://github.com/jamsinclair/jSquash/blob/main/packages/webp/meta.ts) |
|
|
101
106
|
|
|
102
107
|
---
|
|
103
108
|
|
|
@@ -116,9 +121,9 @@ output[originalName][width] = { basename, mimeType }
|
|
|
116
121
|
|
|
117
122
|
**`settings` keys used:**
|
|
118
123
|
|
|
119
|
-
| Key | Type | Description |
|
|
120
|
-
|
|
121
|
-
| `responsiveConfig` | `object` | Forwarded directly to `gulp-responsive` |
|
|
124
|
+
| Key | Type | Description | Reference |
|
|
125
|
+
|-----|------|-------------|-----------|
|
|
126
|
+
| `responsiveConfig` | `object` | Forwarded directly to `gulp-responsive` | [reference](https://github.com/localnerve/gulp-responsive/blob/public-package/README.md#configuration-unit) |
|
|
122
127
|
|
|
123
128
|
---
|
|
124
129
|
|
|
@@ -126,26 +131,25 @@ output[originalName][width] = { basename, mimeType }
|
|
|
126
131
|
|
|
127
132
|
| Export | Description |
|
|
128
133
|
|--------|-------------|
|
|
129
|
-
| `toWebp(settings, output?)` | Converts `.jpg`, `.jpeg`, and `.png` files to `.webp` using [`@jsquash/webp`](https://github.com/jamsinclair/jSquash). |
|
|
134
|
+
| `toWebp(settings, output?)` | Converts `.avif`, `.jpg`, `.jpeg`, `.jxl` and `.png` files to `.webp` using [`@jsquash/webp`](https://github.com/jamsinclair/jSquash). |
|
|
135
|
+
| `toAvif(settings, output?)` | Converts `.jpg`, `.jpeg`, `.jxl`, `.png`, and `.webp` files to `.avif` using [`@jsquash/avif`](https://github.com/jamsinclair/jSquash). |
|
|
136
|
+
| `toJxl(settings, output?)` | Converts `.avif`, `.jpg`, `.jpeg`, `.png`, and `.webp` files to `.jxl` using [`@jsquash/jxl`](https://github.com/jamsinclair/jSquash). |
|
|
130
137
|
|
|
131
138
|
`output` (optional) — if supplied, converted file metadata is updated:
|
|
132
139
|
```js
|
|
140
|
+
// example output created for `toWebp`:
|
|
133
141
|
output[key][width].basename = 'file.webp';
|
|
134
142
|
output[key][width].mimeType = 'image/webp';
|
|
135
143
|
```
|
|
136
|
-
The `key` and `width` are derived from the filename convention `<key1>-<key2>-<width>.<ext>` (same convention as `gulp-responsive` output).
|
|
137
|
-
|
|
138
|
-
**Extend the group:**
|
|
139
|
-
```js
|
|
140
|
-
import * as transform from '@localnerve/gulp-images/transform';
|
|
141
|
-
const myTransform = { ...transform, toAvif: myAvifConverter };
|
|
142
|
-
```
|
|
144
|
+
The `key` and `width` are derived from the input image filename convention `<key1>-<key2>-<width>.<ext>` (same convention as `gulp-responsive` output).
|
|
143
145
|
|
|
144
146
|
**`settings` keys used:**
|
|
145
147
|
|
|
146
|
-
| Key | Type | Description |
|
|
147
|
-
|
|
148
|
-
| `webpOptions` | `object` | Forwarded to the WebP encoder |
|
|
148
|
+
| Key | Type | Description | Reference |
|
|
149
|
+
|-----|------|-------------|-----------|
|
|
150
|
+
| `webpOptions` | `object` | Forwarded to the WebP encoder | [reference](https://github.com/jamsinclair/jSquash/blob/main/packages/webp/meta.ts) |
|
|
151
|
+
| `avifOptions` | `object` | Forwarded to the Avif encoder | [reference](https://github.com/jamsinclair/jSquash/blob/main/packages/avif/meta.ts) |
|
|
152
|
+
| `jxlOptions` | `object` | Forwarded to the Jxl encoder | [reference](https://github.com/jamsinclair/jSquash/blob/main/packages/jxl/meta.ts) |
|
|
149
153
|
|
|
150
154
|
---
|
|
151
155
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@localnerve/gulp-images",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Reusable gulp image processing transforms: optimize (svg, jpeg, png), responsive, and transform (toWebp).",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Reusable gulp image processing transforms: optimize (svg, jpeg, png, avif, jxl), responsive, and transform (toWebp, toAvif, toJxl).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"lint": "eslint src/ test/",
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
"svg",
|
|
28
28
|
"jpeg",
|
|
29
29
|
"png",
|
|
30
|
+
"jxl",
|
|
31
|
+
"avif",
|
|
30
32
|
"wasm"
|
|
31
33
|
],
|
|
32
34
|
"repository": {
|
|
@@ -43,11 +45,13 @@
|
|
|
43
45
|
"gulp": ">=5.0.0"
|
|
44
46
|
},
|
|
45
47
|
"dependencies": {
|
|
48
|
+
"@jsquash/avif": "^2.1.1",
|
|
46
49
|
"@jsquash/jpeg": "^1.6.0",
|
|
50
|
+
"@jsquash/jxl": "^1.3.0",
|
|
47
51
|
"@jsquash/oxipng": "^2.3.0",
|
|
48
52
|
"@jsquash/png": "^3.1.1",
|
|
49
53
|
"@jsquash/webp": "^1.5.0",
|
|
50
|
-
"@localnerve/gulp-responsive": ">=7.
|
|
54
|
+
"@localnerve/gulp-responsive": ">=7.8.0",
|
|
51
55
|
"plugin-error": "^2.0.1",
|
|
52
56
|
"svgo": "^4.0.1",
|
|
53
57
|
"wasm-feature-detect": "^1.8.0"
|
|
@@ -57,7 +61,7 @@
|
|
|
57
61
|
},
|
|
58
62
|
"devDependencies": {
|
|
59
63
|
"@eslint/js": "^10.0.1",
|
|
60
|
-
"eslint": "^10.
|
|
64
|
+
"eslint": "^10.3.0",
|
|
61
65
|
"gulp": "^5.0.1",
|
|
62
66
|
"vinyl": "^3.0.1"
|
|
63
67
|
}
|
package/src/index.js
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
* import { initWasmModules, optimize, responsive, transform } from '@localnerve/gulp-images';
|
|
11
11
|
*
|
|
12
12
|
* Or import individual groups directly:
|
|
13
|
-
* import { svg, jpeg, png } from '@localnerve/gulp-images/optimize';
|
|
14
|
-
* import { responsive }
|
|
15
|
-
* import { toWebp }
|
|
13
|
+
* import { svg, jpeg, png, avif, webp, jxl } from '@localnerve/gulp-images/optimize';
|
|
14
|
+
* import { responsive } from '@localnerve/gulp-images/responsive';
|
|
15
|
+
* import { toWebp, toAvif, toJxl } from '@localnerve/gulp-images/transform';
|
|
16
16
|
*/
|
|
17
17
|
export * as optimize from './optimize/index.js';
|
|
18
18
|
export * as responsive from './responsive/index.js';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @localnerve/gulp-images — optimize/avif
|
|
3
|
+
*
|
|
4
|
+
* Copyright (c) 2025 Alex Grant <info@localnerve.com> (https://www.localnerve.com), LocalNerve LLC
|
|
5
|
+
* AGPL-3.0-or-later
|
|
6
|
+
*/
|
|
7
|
+
import { Transform } from 'node:stream';
|
|
8
|
+
import { decodeAvif, encodeAvif, checkSkip, log, handleError, passThrough } from '../utils.js';
|
|
9
|
+
|
|
10
|
+
const pluginName = '@localnerve/optimize-avif';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Gulp Transform that re-encodes avif files using libavif (via @jsquash/avif).
|
|
14
|
+
* When `settings.prod` is false, files pass through unchanged.
|
|
15
|
+
*
|
|
16
|
+
* avif options reference:
|
|
17
|
+
* https://github.com/jamsinclair/jSquash/blob/main/packages/avif/meta.ts
|
|
18
|
+
*
|
|
19
|
+
* @param {Object} settings - Build settings
|
|
20
|
+
* @param {Object} [settings.avifOptions] - Options forwarded to the libavif encoder
|
|
21
|
+
* @param {boolean} settings.prod - Enable optimization only when true
|
|
22
|
+
* @returns {Transform} A Node.js Transform stream in object mode
|
|
23
|
+
*/
|
|
24
|
+
export function avif (settings) {
|
|
25
|
+
const { prod, avifOptions } = settings;
|
|
26
|
+
|
|
27
|
+
if (prod) {
|
|
28
|
+
return new Transform({
|
|
29
|
+
objectMode: true,
|
|
30
|
+
transform: async (file, encoding, next) => {
|
|
31
|
+
if (checkSkip(file, ['.avif'])) { return next(null, file); }
|
|
32
|
+
if (file.isBuffer()) {
|
|
33
|
+
try {
|
|
34
|
+
const originalLen = file.contents.length;
|
|
35
|
+
const imageData = await decodeAvif(file.contents);
|
|
36
|
+
file.contents = Buffer.from(await encodeAvif(imageData, avifOptions));
|
|
37
|
+
const optimizedLen = file.contents.length;
|
|
38
|
+
const reductionPerc = (((originalLen - optimizedLen) / originalLen) * 100).toFixed(2);
|
|
39
|
+
|
|
40
|
+
log(pluginName, file, `${file.extname.slice(1)} optimized (${reductionPerc}%)`);
|
|
41
|
+
next(null, file);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
handleError(pluginName, file, next, error);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return passThrough();
|
|
51
|
+
}
|
package/src/optimize/index.js
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
* Copyright (c) 2025 Alex Grant <info@localnerve.com> (https://www.localnerve.com), LocalNerve LLC
|
|
5
5
|
* AGPL-3.0-or-later
|
|
6
6
|
*
|
|
7
|
-
* Exports the built-in optimizers.
|
|
8
|
-
*
|
|
9
|
-
* import * as optimize from '@localnerve/gulp-images/optimize';
|
|
10
|
-
* const myOptimize = { ...optimize, avif: myAvifFn };
|
|
7
|
+
* Exports the built-in optimizers.
|
|
11
8
|
*/
|
|
12
9
|
export { svg } from './svg.js';
|
|
13
10
|
export { jpeg } from './jpeg.js';
|
|
14
11
|
export { png } from './png.js';
|
|
12
|
+
export { webp } from './webp.js';
|
|
13
|
+
export { avif } from './avif.js';
|
|
14
|
+
export { jxl } from './jxl.js';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @localnerve/gulp-images — optimize/jxl
|
|
3
|
+
*
|
|
4
|
+
* Copyright (c) 2025 Alex Grant <info@localnerve.com> (https://www.localnerve.com), LocalNerve LLC
|
|
5
|
+
* AGPL-3.0-or-later
|
|
6
|
+
*/
|
|
7
|
+
import { Transform } from 'node:stream';
|
|
8
|
+
import { decodeJxl, encodeJxl, checkSkip, log, handleError, passThrough } from '../utils.js';
|
|
9
|
+
|
|
10
|
+
const pluginName = '@localnerve/optimize-jxl';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Gulp Transform that re-encodes jxl files using libjxl (via @jsquash/jxl).
|
|
14
|
+
* When `settings.prod` is false, files pass through unchanged.
|
|
15
|
+
*
|
|
16
|
+
* jxl options reference:
|
|
17
|
+
* https://github.com/jamsinclair/jSquash/blob/main/packages/jxl/meta.ts
|
|
18
|
+
*
|
|
19
|
+
* @param {Object} settings - Build settings
|
|
20
|
+
* @param {Object} [settings.jxlOptions] - Options forwarded to the libjxl encoder
|
|
21
|
+
* @param {boolean} settings.prod - Enable optimization only when true
|
|
22
|
+
* @returns {Transform} A Node.js Transform stream in object mode
|
|
23
|
+
*/
|
|
24
|
+
export function jxl (settings) {
|
|
25
|
+
const { prod, jxlOptions } = settings;
|
|
26
|
+
|
|
27
|
+
if (prod) {
|
|
28
|
+
return new Transform({
|
|
29
|
+
objectMode: true,
|
|
30
|
+
transform: async (file, encoding, next) => {
|
|
31
|
+
if (checkSkip(file, ['.jxl'])) { return next(null, file); }
|
|
32
|
+
if (file.isBuffer()) {
|
|
33
|
+
try {
|
|
34
|
+
const originalLen = file.contents.length;
|
|
35
|
+
const imageData = await decodeJxl(file.contents);
|
|
36
|
+
file.contents = Buffer.from(await encodeJxl(imageData, jxlOptions));
|
|
37
|
+
const optimizedLen = file.contents.length;
|
|
38
|
+
const reductionPerc = (((originalLen - optimizedLen) / originalLen) * 100).toFixed(2);
|
|
39
|
+
|
|
40
|
+
log(pluginName, file, `${file.extname.slice(1)} optimized (${reductionPerc}%)`);
|
|
41
|
+
next(null, file);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
handleError(pluginName, file, next, error);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return passThrough();
|
|
51
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @localnerve/gulp-images — optimize/webp
|
|
3
|
+
*
|
|
4
|
+
* Copyright (c) 2025 Alex Grant <info@localnerve.com> (https://www.localnerve.com), LocalNerve LLC
|
|
5
|
+
* AGPL-3.0-or-later
|
|
6
|
+
*/
|
|
7
|
+
import { Transform } from 'node:stream';
|
|
8
|
+
import { decodeWebp, encodeWebp, checkSkip, log, handleError, passThrough } from '../utils.js';
|
|
9
|
+
|
|
10
|
+
const pluginName = '@localnerve/optimize-webp';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Gulp Transform that re-encodes webp files using libwebp (via @jsquash/webp).
|
|
14
|
+
* When `settings.prod` is false, files pass through unchanged.
|
|
15
|
+
*
|
|
16
|
+
* webp options reference:
|
|
17
|
+
* https://github.com/jamsinclair/jSquash/blob/main/packages/webp/meta.ts
|
|
18
|
+
*
|
|
19
|
+
* @param {Object} settings - Build settings
|
|
20
|
+
* @param {Object} [settings.webpOptions] - Options forwarded to the libwebp encoder
|
|
21
|
+
* @param {boolean} settings.prod - Enable optimization only when true
|
|
22
|
+
* @returns {Transform} A Node.js Transform stream in object mode
|
|
23
|
+
*/
|
|
24
|
+
export function webp (settings) {
|
|
25
|
+
const { prod, webpOptions } = settings;
|
|
26
|
+
|
|
27
|
+
if (prod) {
|
|
28
|
+
return new Transform({
|
|
29
|
+
objectMode: true,
|
|
30
|
+
transform: async (file, encoding, next) => {
|
|
31
|
+
if (checkSkip(file, ['.webp'])) { return next(null, file); }
|
|
32
|
+
if (file.isBuffer()) {
|
|
33
|
+
try {
|
|
34
|
+
const originalLen = file.contents.length;
|
|
35
|
+
const imageData = await decodeWebp(file.contents);
|
|
36
|
+
file.contents = Buffer.from(await encodeWebp(imageData, webpOptions));
|
|
37
|
+
const optimizedLen = file.contents.length;
|
|
38
|
+
const reductionPerc = (((originalLen - optimizedLen) / originalLen) * 100).toFixed(2);
|
|
39
|
+
|
|
40
|
+
log(pluginName, file, `${file.extname.slice(1)} optimized (${reductionPerc}%)`);
|
|
41
|
+
next(null, file);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
handleError(pluginName, file, next, error);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return passThrough();
|
|
51
|
+
}
|
package/src/transform/index.js
CHANGED
|
@@ -4,9 +4,8 @@
|
|
|
4
4
|
* Copyright (c) 2025 Alex Grant <info@localnerve.com> (https://www.localnerve.com), LocalNerve LLC
|
|
5
5
|
* AGPL-3.0-or-later
|
|
6
6
|
*
|
|
7
|
-
* Exports the built-in image transforms.
|
|
8
|
-
*
|
|
9
|
-
* import * as transform from '@localnerve/gulp-images/transform';
|
|
10
|
-
* const myTransform = { ...transform, toAvif: myAvifFn };
|
|
7
|
+
* Exports the built-in image transforms.
|
|
11
8
|
*/
|
|
9
|
+
export { toAvif } from './toAvif.js';
|
|
10
|
+
export { toJxl } from './toJxl.js';
|
|
12
11
|
export { toWebp } from './toWebp.js';
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @localnerve/gulp-images — transform/toAvif
|
|
3
|
+
*
|
|
4
|
+
* Copyright (c) 2025 Alex Grant <info@localnerve.com> (https://www.localnerve.com), LocalNerve LLC
|
|
5
|
+
* AGPL-3.0-or-later
|
|
6
|
+
*/
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { Transform } from 'node:stream';
|
|
9
|
+
import {
|
|
10
|
+
decodeJpeg,
|
|
11
|
+
decodePng,
|
|
12
|
+
decodeJxl,
|
|
13
|
+
decodeWebp,
|
|
14
|
+
encodeAvif,
|
|
15
|
+
checkSkip, log, handleError } from '../utils.js';
|
|
16
|
+
|
|
17
|
+
const pluginName = '@localnerve/to-avif';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Gulp Transform that converts raster images to Avif.
|
|
21
|
+
*
|
|
22
|
+
* Converted files have their extension replaced with `.avif`. When the
|
|
23
|
+
* filename follows the `<key>-<sub>-<width>` naming convention used by
|
|
24
|
+
* gulp-responsive output AND `output` is provided, the transform also
|
|
25
|
+
* updates `output[key][width]` with the new basename and mimeType—mirroring
|
|
26
|
+
* the `data.images` updates in the original implementation.
|
|
27
|
+
*
|
|
28
|
+
* Avif encoder options reference:
|
|
29
|
+
* https://github.com/jamsinclair/jSquash/blob/main/packages/avif/meta.ts
|
|
30
|
+
*
|
|
31
|
+
* @param {Object} settings - Build settings
|
|
32
|
+
* @param {Object} [settings.avifOptions] - Options forwarded to the avif encoder
|
|
33
|
+
* @param {Object} [output] - Optional object to receive updated image metadata
|
|
34
|
+
* (same shape written by `responsive`): output[key][width] = { basename, mimeType }
|
|
35
|
+
* @returns {Transform} A Node.js Transform stream in object mode
|
|
36
|
+
*/
|
|
37
|
+
export function toAvif(settings, output) {
|
|
38
|
+
const { avifOptions } = settings;
|
|
39
|
+
const exts = ['.jpg', '.jpeg', '.png', '.webp', '.jxl'];
|
|
40
|
+
const decoders = [decodeJpeg, decodeJpeg, decodePng, decodeWebp, decodeJxl];
|
|
41
|
+
|
|
42
|
+
return new Transform({
|
|
43
|
+
objectMode: true,
|
|
44
|
+
transform: async (file, encoding, next) => {
|
|
45
|
+
if (checkSkip(file, exts)) {
|
|
46
|
+
return next(null, file);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (file.isBuffer()) {
|
|
50
|
+
try {
|
|
51
|
+
const decoderIndex = exts.indexOf(file.extname.toLowerCase());
|
|
52
|
+
const originalFile = file.path;
|
|
53
|
+
const originalExt = file.extname;
|
|
54
|
+
|
|
55
|
+
// derive metadata keys from filename convention: <key-parts>-<width>
|
|
56
|
+
const name = path.parse(file.relative).name;
|
|
57
|
+
const nameParts = name.split('-');
|
|
58
|
+
const key = nameParts.slice(0, 2).join('-');
|
|
59
|
+
const width = nameParts.slice(2, 3)[0];
|
|
60
|
+
|
|
61
|
+
const imageData = await decoders[decoderIndex](file.contents);
|
|
62
|
+
file.contents = Buffer.from(await encodeAvif(imageData, avifOptions));
|
|
63
|
+
|
|
64
|
+
for (const ext of exts) {
|
|
65
|
+
file.path = file.path.replace(ext, '.avif');
|
|
66
|
+
|
|
67
|
+
if (originalFile !== file.path) {
|
|
68
|
+
// update caller-supplied output metadata if provided
|
|
69
|
+
if (output) {
|
|
70
|
+
const val = output?.[key]?.[width];
|
|
71
|
+
if (val) {
|
|
72
|
+
val.basename = file.basename;
|
|
73
|
+
val.mimeType = 'image/avif';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
break; // a file won't be both .jpg and .jpeg
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (file.stat) {
|
|
81
|
+
file.stat.atime = file.stat.mtime = file.stat.ctime = new Date();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
log(pluginName, file, `${originalExt.slice(1)} converted to avif`);
|
|
85
|
+
next(null, file);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
handleError(pluginName, file, next, error);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @localnerve/gulp-images — transform/toJxl
|
|
3
|
+
*
|
|
4
|
+
* Copyright (c) 2025 Alex Grant <info@localnerve.com> (https://www.localnerve.com), LocalNerve LLC
|
|
5
|
+
* AGPL-3.0-or-later
|
|
6
|
+
*/
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { Transform } from 'node:stream';
|
|
9
|
+
import {
|
|
10
|
+
decodeJpeg,
|
|
11
|
+
decodePng,
|
|
12
|
+
decodeAvif,
|
|
13
|
+
decodeWebp,
|
|
14
|
+
encodeJxl,
|
|
15
|
+
checkSkip, log, handleError } from '../utils.js';
|
|
16
|
+
|
|
17
|
+
const pluginName = '@localnerve/to-jxl';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Gulp Transform that converts raster images to Jxl.
|
|
21
|
+
*
|
|
22
|
+
* Converted files have their extension replaced with `.jxl`. When the
|
|
23
|
+
* filename follows the `<key>-<sub>-<width>` naming convention used by
|
|
24
|
+
* gulp-responsive output AND `output` is provided, the transform also
|
|
25
|
+
* updates `output[key][width]` with the new basename and mimeType—mirroring
|
|
26
|
+
* the `data.images` updates in the original implementation.
|
|
27
|
+
*
|
|
28
|
+
* Jxl encoder options reference:
|
|
29
|
+
* https://github.com/jamsinclair/jSquash/blob/main/packages/jxl/meta.ts
|
|
30
|
+
*
|
|
31
|
+
* @param {Object} settings - Build settings
|
|
32
|
+
* @param {Object} [settings.jxlOptions] - Options forwarded to the jxl encoder
|
|
33
|
+
* @param {Object} [output] - Optional object to receive updated image metadata
|
|
34
|
+
* (same shape written by `responsive`): output[key][width] = { basename, mimeType }
|
|
35
|
+
* @returns {Transform} A Node.js Transform stream in object mode
|
|
36
|
+
*/
|
|
37
|
+
export function toJxl(settings, output) {
|
|
38
|
+
const { jxlOptions } = settings;
|
|
39
|
+
const exts = ['.jpg', '.jpeg', '.png', '.webp', '.avif'];
|
|
40
|
+
const decoders = [decodeJpeg, decodeJpeg, decodePng, decodeWebp, decodeAvif];
|
|
41
|
+
|
|
42
|
+
return new Transform({
|
|
43
|
+
objectMode: true,
|
|
44
|
+
transform: async (file, encoding, next) => {
|
|
45
|
+
if (checkSkip(file, exts)) {
|
|
46
|
+
return next(null, file);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (file.isBuffer()) {
|
|
50
|
+
try {
|
|
51
|
+
const decoderIndex = exts.indexOf(file.extname.toLowerCase());
|
|
52
|
+
const originalFile = file.path;
|
|
53
|
+
const originalExt = file.extname;
|
|
54
|
+
|
|
55
|
+
// derive metadata keys from filename convention: <key-parts>-<width>
|
|
56
|
+
const name = path.parse(file.relative).name;
|
|
57
|
+
const nameParts = name.split('-');
|
|
58
|
+
const key = nameParts.slice(0, 2).join('-');
|
|
59
|
+
const width = nameParts.slice(2, 3)[0];
|
|
60
|
+
|
|
61
|
+
const imageData = await decoders[decoderIndex](file.contents);
|
|
62
|
+
file.contents = Buffer.from(await encodeJxl(imageData, jxlOptions));
|
|
63
|
+
|
|
64
|
+
for (const ext of exts) {
|
|
65
|
+
file.path = file.path.replace(ext, '.jxl');
|
|
66
|
+
|
|
67
|
+
if (originalFile !== file.path) {
|
|
68
|
+
// update caller-supplied output metadata if provided
|
|
69
|
+
if (output) {
|
|
70
|
+
const val = output?.[key]?.[width];
|
|
71
|
+
if (val) {
|
|
72
|
+
val.basename = file.basename;
|
|
73
|
+
val.mimeType = 'image/jxl';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
break; // a file won't be both .jpg and .jpeg
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (file.stat) {
|
|
81
|
+
file.stat.atime = file.stat.mtime = file.stat.ctime = new Date();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
log(pluginName, file, `${originalExt.slice(1)} converted to jxl`);
|
|
85
|
+
next(null, file);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
handleError(pluginName, file, next, error);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
package/src/transform/toWebp.js
CHANGED
|
@@ -6,12 +6,18 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { Transform } from 'node:stream';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
decodeJpeg,
|
|
11
|
+
decodePng,
|
|
12
|
+
decodeJxl,
|
|
13
|
+
decodeAvif,
|
|
14
|
+
encodeWebp,
|
|
15
|
+
checkSkip, log, handleError } from '../utils.js';
|
|
10
16
|
|
|
11
17
|
const pluginName = '@localnerve/to-webp';
|
|
12
18
|
|
|
13
19
|
/**
|
|
14
|
-
* Gulp Transform that converts raster images
|
|
20
|
+
* Gulp Transform that converts raster images to WebP.
|
|
15
21
|
*
|
|
16
22
|
* Converted files have their extension replaced with `.webp`. When the
|
|
17
23
|
* filename follows the `<key>-<sub>-<width>` naming convention used by
|
|
@@ -30,8 +36,8 @@ const pluginName = '@localnerve/to-webp';
|
|
|
30
36
|
*/
|
|
31
37
|
export function toWebp(settings, output) {
|
|
32
38
|
const { webpOptions } = settings;
|
|
33
|
-
const exts = ['.jpg', '.jpeg', '.png'];
|
|
34
|
-
const decoders = [decodeJpeg, decodeJpeg, decodePng];
|
|
39
|
+
const exts = ['.jpg', '.jpeg', '.png', '.avif', '.jxl'];
|
|
40
|
+
const decoders = [decodeJpeg, decodeJpeg, decodePng, decodeAvif, decodeJxl];
|
|
35
41
|
|
|
36
42
|
return new Transform({
|
|
37
43
|
objectMode: true,
|
package/src/utils.js
CHANGED
|
@@ -9,13 +9,29 @@ import fs from 'node:fs/promises';
|
|
|
9
9
|
import { Transform } from 'node:stream';
|
|
10
10
|
import PluginError from 'plugin-error';
|
|
11
11
|
import { simd, relaxedSimd } from 'wasm-feature-detect';
|
|
12
|
+
import decodeAvif, { init as initAvifDecode } from '@jsquash/avif/decode.js';
|
|
13
|
+
import encodeAvif, { init as initAvifEncode } from '@jsquash/avif/encode.js';
|
|
12
14
|
import decodeJpeg, { init as initJpegDecode } from '@jsquash/jpeg/decode.js';
|
|
13
15
|
import encodeJpeg, { init as initJpegEncode } from '@jsquash/jpeg/encode.js';
|
|
16
|
+
import decodeJxl, { init as initJxlDecode } from '@jsquash/jxl/decode.js';
|
|
17
|
+
import encodeJxl, { init as initJxlEncode } from '@jsquash/jxl/encode.js';
|
|
14
18
|
import decodePng, { init as initPngDecode } from '@jsquash/png/decode.js';
|
|
15
19
|
import encodePng, { init as initPngEncode } from '@jsquash/oxipng/optimise.js';
|
|
20
|
+
import decodeWebp, { init as initWebpDecode } from '@jsquash/webp/decode.js';
|
|
16
21
|
import encodeWebp, { init as initWebpEncode } from '@jsquash/webp/encode.js';
|
|
17
22
|
|
|
18
|
-
export {
|
|
23
|
+
export {
|
|
24
|
+
decodeAvif,
|
|
25
|
+
encodeAvif,
|
|
26
|
+
decodeJpeg,
|
|
27
|
+
encodeJpeg,
|
|
28
|
+
decodeJxl,
|
|
29
|
+
encodeJxl,
|
|
30
|
+
decodePng,
|
|
31
|
+
encodePng,
|
|
32
|
+
decodeWebp,
|
|
33
|
+
encodeWebp
|
|
34
|
+
};
|
|
19
35
|
|
|
20
36
|
/**
|
|
21
37
|
* Check skip condition for a vinyl stream object.
|
|
@@ -101,10 +117,15 @@ export function passThrough() {
|
|
|
101
117
|
// WASM codec paths relative to a base directory
|
|
102
118
|
// ---------------------------------------------------------------------------
|
|
103
119
|
const WASM_PATHS = {
|
|
120
|
+
avifDecode: 'node_modules/@jsquash/avif/codec/dec/avif_dec.wasm',
|
|
121
|
+
avifEncode: 'node_modules/@jsquash/avif/codec/enc/avif_enc.wasm',
|
|
104
122
|
jpegDecode: 'node_modules/@jsquash/jpeg/codec/dec/mozjpeg_dec.wasm',
|
|
105
123
|
jpegEncode: 'node_modules/@jsquash/jpeg/codec/enc/mozjpeg_enc.wasm',
|
|
124
|
+
jxlDecode: 'node_modules/@jsquash/jxl/codec/dec/jxl_dec.wasm',
|
|
125
|
+
jxlEncode: 'node_modules/@jsquash/jxl/codec/enc/jxl_enc.wasm',
|
|
106
126
|
pngDecode: 'node_modules/@jsquash/png/codec/pkg/squoosh_png_bg.wasm',
|
|
107
127
|
pngEncode: 'node_modules/@jsquash/oxipng/codec/pkg/squoosh_oxipng_bg.wasm',
|
|
128
|
+
webpDecode: 'node_modules/@jsquash/webp/codec/dec/webp_dec.wasm',
|
|
108
129
|
webpEncode: 'node_modules/@jsquash/webp/codec/enc/webp_enc.wasm',
|
|
109
130
|
webpEncodeSIMD: 'node_modules/@jsquash/webp/codec/enc/webp_enc_simd.wasm'
|
|
110
131
|
};
|
|
@@ -120,24 +141,40 @@ const WASM_PATHS = {
|
|
|
120
141
|
export async function initWasmModules(wasmBasePath = process.cwd()) {
|
|
121
142
|
const resolve = rel => path.join(wasmBasePath, rel);
|
|
122
143
|
|
|
144
|
+
// Test simd support
|
|
145
|
+
const simdSupport = await Promise.allSettled([simd(), relaxedSimd()]);
|
|
146
|
+
const hasSimdSupport = simdSupport.some(r => r.status === 'fulfilled' && r.value);
|
|
147
|
+
|
|
148
|
+
const avifDecWasmModule = await WebAssembly.compile(await fs.readFile(resolve(WASM_PATHS.avifDecode)));
|
|
149
|
+
await initAvifDecode(avifDecWasmModule);
|
|
150
|
+
|
|
151
|
+
const avifEncWasmModule = await WebAssembly.compile(await fs.readFile(resolve(WASM_PATHS.avifEncode)));
|
|
152
|
+
await initAvifEncode(avifEncWasmModule);
|
|
153
|
+
|
|
123
154
|
const jpegDecWasmModule = await WebAssembly.compile(await fs.readFile(resolve(WASM_PATHS.jpegDecode)));
|
|
124
155
|
await initJpegDecode(jpegDecWasmModule);
|
|
125
156
|
|
|
126
157
|
const jpegEncWasmModule = await WebAssembly.compile(await fs.readFile(resolve(WASM_PATHS.jpegEncode)));
|
|
127
158
|
await initJpegEncode(jpegEncWasmModule);
|
|
128
159
|
|
|
160
|
+
const jxlDecWasmModule = await WebAssembly.compile(await fs.readFile(resolve(WASM_PATHS.jxlDecode)));
|
|
161
|
+
await initJxlDecode(jxlDecWasmModule);
|
|
162
|
+
|
|
163
|
+
const jxlPath = WASM_PATHS.jxlEncode; // only single threaded for now
|
|
164
|
+
const jxlEncWasmModule = await WebAssembly.compile(await fs.readFile(resolve(jxlPath)));
|
|
165
|
+
await initJxlEncode(jxlEncWasmModule);
|
|
166
|
+
|
|
129
167
|
const pngDecWasmModule = await WebAssembly.compile(await fs.readFile(resolve(WASM_PATHS.pngDecode)));
|
|
130
168
|
await initPngDecode(pngDecWasmModule);
|
|
131
169
|
|
|
132
170
|
const oxipngWasmModule = await WebAssembly.compile(await fs.readFile(resolve(WASM_PATHS.pngEncode)));
|
|
133
171
|
await initPngEncode(oxipngWasmModule);
|
|
134
172
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const webpPath = simdSupport.some(r => r.status === 'fulfilled' && r.value)
|
|
138
|
-
? WASM_PATHS.webpEncodeSIMD
|
|
139
|
-
: WASM_PATHS.webpEncode;
|
|
173
|
+
const webpDecWasmModule = await WebAssembly.compile(await fs.readFile(resolve(WASM_PATHS.webpDecode)));
|
|
174
|
+
await initWebpDecode(webpDecWasmModule);
|
|
140
175
|
|
|
176
|
+
// use SIMD variant if supported (~10 % faster)
|
|
177
|
+
const webpPath = hasSimdSupport ? WASM_PATHS.webpEncodeSIMD : WASM_PATHS.webpEncode;
|
|
141
178
|
const webpEncWasmModule = await WebAssembly.compile(await fs.readFile(resolve(webpPath)));
|
|
142
179
|
await initWebpEncode(webpEncWasmModule);
|
|
143
180
|
}
|