poops-images 1.0.0 → 1.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.
- package/README.md +259 -2
- package/lib/analyze.js +15 -10
- package/lib/config.js +22 -2
- package/lib/discover.js +12 -8
- package/lib/formats.js +3 -5
- package/lib/preprocess.js +242 -0
- package/lib/processor.js +260 -63
- package/package.json +1 -1
- package/poops-images.js +32 -0
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ Features:
|
|
|
16
16
|
- Smart format selection — compares JPEG vs WebP, keeps whichever is smaller
|
|
17
17
|
- Transparency detection — auto-converts opaque PNGs and GIFs to JPEG
|
|
18
18
|
- Never upscales — skips sizes larger than the source
|
|
19
|
+
- Preprocessors — apply transformations (blur, grayscale, custom handlers, etc.) before generating variants, great for LQIP placeholders, hover effects, and artistic filters like halftone
|
|
19
20
|
- Watch mode with incremental processing
|
|
20
21
|
- Configurable concurrency for parallel processing
|
|
21
22
|
- Keeps track with cache
|
|
@@ -76,6 +77,7 @@ Usage: poops-images [input] [options]
|
|
|
76
77
|
-w, --watch Watch for changes and process incrementally
|
|
77
78
|
-f, --force Ignore cache, regenerate everything
|
|
78
79
|
--dry-run Show what would be processed without writing
|
|
80
|
+
-P, --preprocess <ops> Preprocess operations (e.g. blur:20,grayscale,sharpen:1.5)
|
|
79
81
|
-q, --quiet Suppress progress output
|
|
80
82
|
-v, --version Show version
|
|
81
83
|
-h, --help Show help
|
|
@@ -146,7 +148,15 @@ The config file is resolved in order:
|
|
|
146
148
|
"exclude": [],
|
|
147
149
|
"concurrency": 4,
|
|
148
150
|
"skipOriginal": false,
|
|
149
|
-
"cache": true
|
|
151
|
+
"cache": true,
|
|
152
|
+
"preprocessors": [
|
|
153
|
+
{
|
|
154
|
+
"name": "lqip",
|
|
155
|
+
"operations": [{ "type": "blur", "sigma": 30 }],
|
|
156
|
+
"sizes": [{ "width": 32 }],
|
|
157
|
+
"skipOriginal": true
|
|
158
|
+
}
|
|
159
|
+
]
|
|
150
160
|
}
|
|
151
161
|
```
|
|
152
162
|
|
|
@@ -163,6 +173,7 @@ The config file is resolved in order:
|
|
|
163
173
|
| `include` | `string` | `"**/*.{jpg,jpeg,png,tiff,tif,webp,heic,heif}"` | Glob pattern for source images |
|
|
164
174
|
| `exclude` | `array` | `[]` | Glob patterns to exclude |
|
|
165
175
|
| `concurrency` | `number` | `4` | Max parallel image operations |
|
|
176
|
+
| `preprocessors`| `array` | `[]` | Preprocessor definitions (see [Preprocessors](#preprocessors) below) |
|
|
166
177
|
| `cache` | `true\|false\|string` | `true` | Cache behavior. `true` = default cache file in output dir, `false` = no cache, `"path"` = custom cache file path (relative to output dir or absolute) |
|
|
167
178
|
|
|
168
179
|
### Size definitions
|
|
@@ -234,6 +245,14 @@ const processor2 = new ImageProcessor({
|
|
|
234
245
|
],
|
|
235
246
|
format: "webp",
|
|
236
247
|
quality: { jpg: 85, webp: 80 },
|
|
248
|
+
preprocessors: [
|
|
249
|
+
{
|
|
250
|
+
name: "lqip",
|
|
251
|
+
operations: [{ type: "blur", sigma: 30 }],
|
|
252
|
+
sizes: [{ width: 32 }],
|
|
253
|
+
skipOriginal: true,
|
|
254
|
+
},
|
|
255
|
+
],
|
|
237
256
|
});
|
|
238
257
|
|
|
239
258
|
const stats = await processor2.processAll();
|
|
@@ -427,12 +446,248 @@ src/images/icons/logo.svg
|
|
|
427
446
|
→ dist/static/images/icons/logo.svg (minified)
|
|
428
447
|
```
|
|
429
448
|
|
|
449
|
+
SVGs can also be processed by preprocessors that have `"svg": true` set. The SVG is rasterized at its native dimensions, run through the preprocessor operations, and saved as a raster image (PNG) at original size only. See [Preprocessors](#preprocessors) for details.
|
|
450
|
+
|
|
430
451
|
### GIF handling
|
|
431
452
|
|
|
432
453
|
**Static GIFs** (single-frame) are treated like any other raster image — resized, cropped, and format-converted. Opaque static GIFs become JPEG, transparent ones become PNG (or whatever `format` is set to).
|
|
433
454
|
|
|
434
455
|
**Animated GIFs** (multi-frame) are copied to the output directory unchanged. No resizing or format conversion — animated GIFs would lose their frames through sharp's raster pipeline.
|
|
435
456
|
|
|
457
|
+
### Preprocessors
|
|
458
|
+
|
|
459
|
+
Preprocessors apply sharp transformations to the source image **before** the resize/format pipeline runs. Each preprocessor generates its own set of variants alongside the untouched original's variants. This is useful for generating blurred placeholder images (LQIP), grayscale variants for hover effects, watermarked versions for public galleries, etc.
|
|
460
|
+
|
|
461
|
+
#### CLI usage
|
|
462
|
+
|
|
463
|
+
The `--preprocess` / `-P` flag is a quick way to add a single preprocessor:
|
|
464
|
+
|
|
465
|
+
```bash
|
|
466
|
+
# Blur all images
|
|
467
|
+
npx poops-images --in src/images --out dist --preprocess blur:20
|
|
468
|
+
|
|
469
|
+
# Grayscale
|
|
470
|
+
npx poops-images --in src/images --out dist --preprocess grayscale
|
|
471
|
+
|
|
472
|
+
# Chain operations
|
|
473
|
+
npx poops-images --in src/images --out dist --preprocess grayscale,blur:10
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
When used via CLI, the preprocessor is named `"preprocessed"` and produces files like `photo-preprocessed-medium-300w.jpg`.
|
|
477
|
+
|
|
478
|
+
#### Config usage
|
|
479
|
+
|
|
480
|
+
For full control, define preprocessors in the config file. Each preprocessor has a `name` (used in filenames) and an `operations` array:
|
|
481
|
+
|
|
482
|
+
```json
|
|
483
|
+
{
|
|
484
|
+
"in": "src/images",
|
|
485
|
+
"out": "dist/images",
|
|
486
|
+
"sizes": [
|
|
487
|
+
{ "name": "small", "width": 480 },
|
|
488
|
+
{ "name": "medium", "width": 1024 }
|
|
489
|
+
],
|
|
490
|
+
"preprocessors": [
|
|
491
|
+
{
|
|
492
|
+
"name": "blurred",
|
|
493
|
+
"operations": [{ "type": "blur", "sigma": 15 }]
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
"name": "lqip",
|
|
497
|
+
"operations": [{ "type": "blur", "sigma": 30 }],
|
|
498
|
+
"sizes": [{ "width": 32 }],
|
|
499
|
+
"skipOriginal": true
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
"name": "gray",
|
|
503
|
+
"operations": [{ "type": "grayscale" }],
|
|
504
|
+
"sizes": [{ "name": "thumb", "width": 200, "height": 200, "crop": true }],
|
|
505
|
+
"skipOriginal": true
|
|
506
|
+
}
|
|
507
|
+
]
|
|
508
|
+
}
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
This produces for `photo.jpg` (assuming it's large enough):
|
|
512
|
+
|
|
513
|
+
```
|
|
514
|
+
photo.jpg # original passthrough
|
|
515
|
+
photo-small-480w.jpg # original sized
|
|
516
|
+
photo-medium-1024w.jpg # original sized
|
|
517
|
+
photo-blurred.jpg # blurred passthrough
|
|
518
|
+
photo-blurred-small-480w.jpg # blurred sized
|
|
519
|
+
photo-blurred-medium-1024w.jpg # blurred sized
|
|
520
|
+
photo-lqip-32w.jpg # tiny blurred placeholder only
|
|
521
|
+
photo-gray-thumb-200w.jpg # grayscale thumbnail only
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
#### Preprocessor definition
|
|
525
|
+
|
|
526
|
+
| Field | Type | Default | Description |
|
|
527
|
+
| -------------- | --------- | --------- | ---------------------------------------------------------------------- |
|
|
528
|
+
| `name` | `string` | required | Identifier used in output filenames. Must be unique, alphanumeric/dash/underscore only |
|
|
529
|
+
| `operations` | `array` | required | Ordered list of operations to apply (see table below) |
|
|
530
|
+
| `sizes` | `array` | _(global)_ | Override the global `sizes` for this preprocessor |
|
|
531
|
+
| `format` | same as global | _(global)_ | Override the global `format` for this preprocessor |
|
|
532
|
+
| `quality` | same as global | _(global)_ | Override the global `quality` for this preprocessor |
|
|
533
|
+
| `skipOriginal` | `boolean` | _(global)_ | Override the global `skipOriginal` for this preprocessor |
|
|
534
|
+
| `svg` | `boolean` | `false` | Also process SVG source files through this preprocessor (rasterize → preprocess → save at original size) |
|
|
535
|
+
| `resizeFirst` | `boolean` or `object` | `false` | Resize before preprocessing instead of after (see below) |
|
|
536
|
+
|
|
537
|
+
Operations are composable — they chain in sequence on the sharp pipeline. For example, `[{ "type": "grayscale" }, { "type": "blur", "sigma": 5 }]` first desaturates, then blurs.
|
|
538
|
+
|
|
539
|
+
#### resizeFirst — order of resize and operations
|
|
540
|
+
|
|
541
|
+
By default, operations run once on the full-size source, and size variants are generated from that result. That's the right order for most filters (blur, tint, grayscale look the same either way), but effects with a fixed pixel scale — pixelate blocks, halftone dots, ASCII cells — come out at a different visual density on every variant. `resizeFirst` flips the order:
|
|
542
|
+
|
|
543
|
+
| Value | Behavior |
|
|
544
|
+
| ----------------- | --------------------------------------------------------------------------- |
|
|
545
|
+
| `false` (default) | Preprocess the full-size source once, then resize into variants |
|
|
546
|
+
| `true` | Resize each variant first, then preprocess it — pixel-scale effects look identical across variants |
|
|
547
|
+
| `{ width, height, crop }` | Resize the source to this base size once, preprocess once, then generate variants from the base — one preprocessing pass, and variants never exceed the base dimensions |
|
|
548
|
+
|
|
549
|
+
```json
|
|
550
|
+
{
|
|
551
|
+
"preprocessors": [
|
|
552
|
+
{
|
|
553
|
+
"name": "pixel",
|
|
554
|
+
"operations": [{ "type": "pixelate", "blockSize": 8 }],
|
|
555
|
+
"resizeFirst": true
|
|
556
|
+
},
|
|
557
|
+
{
|
|
558
|
+
"name": "lqip",
|
|
559
|
+
"operations": [{ "type": "blur", "sigma": 10 }],
|
|
560
|
+
"resizeFirst": { "width": 64 },
|
|
561
|
+
"sizes": [{ "width": 32 }],
|
|
562
|
+
"skipOriginal": true
|
|
563
|
+
}
|
|
564
|
+
]
|
|
565
|
+
}
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
With `"resizeFirst": true`, the 8-pixel blocks are 8 output pixels in every variant. Without it, blocks are 8 pixels of the source, so a 4000px source downscaled to 480px shows ~1px blocks.
|
|
569
|
+
|
|
570
|
+
The object form accepts the same shape as a size definition (`width` and/or `height`, optional `crop`) and never upscales — a base larger than the source is clamped to the source, keeping the crop aspect ratio. It's also a performance lever: expensive operations run once on a small base instead of once per variant or once at full size.
|
|
571
|
+
|
|
572
|
+
Sidecar-emitting handlers (like `halftone`'s SVG) write their sidecar once per preprocessor: from the full-size pass by default, from the first variant with `resizeFirst: true`, or from the base with the object form.
|
|
573
|
+
|
|
574
|
+
#### Available operations
|
|
575
|
+
|
|
576
|
+
All operations map directly to [sharp](https://sharp.pixelplumbing.com/) methods:
|
|
577
|
+
|
|
578
|
+
| Operation | Parameters | Description |
|
|
579
|
+
| ----------- | ------------------------------------------------------- | ------------------------------------------------ |
|
|
580
|
+
| `blur` | `sigma` (number, 0.3–1000) | Gaussian blur |
|
|
581
|
+
| `grayscale` | _(none)_ | Convert to grayscale |
|
|
582
|
+
| `sharpen` | `sigma` (number, optional) | Sharpen |
|
|
583
|
+
| `tint` | `color` (string, e.g. `"#ff0000"`) | Tint with a color |
|
|
584
|
+
| `modulate` | `brightness`, `saturation`, `hue`, `lightness` (numbers) | Adjust brightness/saturation/hue |
|
|
585
|
+
| `negate` | _(none)_ | Invert colors |
|
|
586
|
+
| `normalize` | _(none)_ | Stretch contrast to full range |
|
|
587
|
+
| `rotate` | `angle` (number, degrees) | Rotate by exact angle |
|
|
588
|
+
| `flip` | _(none)_ | Flip vertically |
|
|
589
|
+
| `flop` | _(none)_ | Flip horizontally |
|
|
590
|
+
| `gamma` | `value` (number) | Apply gamma correction |
|
|
591
|
+
| `composite` | `input` (path), `gravity`, `blend`, `top`, `left` | Overlay an image (e.g. watermark) |
|
|
592
|
+
| _(path)_ | any extra params | Run a custom JS handler — use a file path as the `type` (see below) |
|
|
593
|
+
|
|
594
|
+
The `composite` operation resolves the `input` path relative to the config file directory (or CWD). Example watermark config:
|
|
595
|
+
|
|
596
|
+
```json
|
|
597
|
+
{
|
|
598
|
+
"name": "watermarked",
|
|
599
|
+
"operations": [
|
|
600
|
+
{ "type": "composite", "input": "assets/watermark.png", "gravity": "southeast" }
|
|
601
|
+
]
|
|
602
|
+
}
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
#### Custom handlers
|
|
606
|
+
|
|
607
|
+
If the `type` is not a built-in operation, it's treated as a custom handler. Resolution order:
|
|
608
|
+
|
|
609
|
+
1. **Short name** — `"type": "halftone"` resolves to `handlers/halftone.js` relative to the config file directory (or CWD)
|
|
610
|
+
2. **File path** — `"type": "./effects/halftone.js"` resolves the path directly relative to the config file directory (or CWD)
|
|
611
|
+
|
|
612
|
+
A custom handler is a JS module that exports a function:
|
|
613
|
+
|
|
614
|
+
```javascript
|
|
615
|
+
/**
|
|
616
|
+
* @param {Buffer} buffer - Current image as a raw buffer
|
|
617
|
+
* @param {object} params - All extra properties from the operation config, plus { width, height }
|
|
618
|
+
* @param {Function} sharp - The sharp module, for convenience
|
|
619
|
+
* @returns {Promise<Buffer|{buffer: Buffer, sidecars: Array}>} - Transformed image buffer, or object with sidecars
|
|
620
|
+
*/
|
|
621
|
+
export default async function (buffer, params, sharp) {
|
|
622
|
+
// Transform the image using any library
|
|
623
|
+
return sharp(buffer).negate().png().toBuffer()
|
|
624
|
+
}
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
The handler receives:
|
|
628
|
+
- `buffer` — the current image as a Buffer (already EXIF-rotated, and with any prior operations applied)
|
|
629
|
+
- `params` — all extra properties from the operation config object (everything except `type`), plus `width` and `height` of the current image
|
|
630
|
+
- `sharp` — the sharp module, so you don't need to import it separately
|
|
631
|
+
|
|
632
|
+
The handler can return:
|
|
633
|
+
- A `Buffer` — the transformed image
|
|
634
|
+
- An object `{ buffer, sidecars }` — the transformed image plus extra files to save alongside it. Each sidecar is `{ ext, data }` where `ext` is the file extension (e.g. `"svg"`) and `data` is a `Buffer`. Sidecars are saved as `{name}-{ppName}.{ext}` in the output directory.
|
|
635
|
+
|
|
636
|
+
**Config example (short name):**
|
|
637
|
+
|
|
638
|
+
```json
|
|
639
|
+
{
|
|
640
|
+
"name": "halftone",
|
|
641
|
+
"operations": [
|
|
642
|
+
{
|
|
643
|
+
"type": "halftone",
|
|
644
|
+
"dotSize": "0.8%",
|
|
645
|
+
"spacing": "1%",
|
|
646
|
+
"shape": "square",
|
|
647
|
+
"foreground": "#43523d",
|
|
648
|
+
"background": "#c7f0d8"
|
|
649
|
+
}
|
|
650
|
+
],
|
|
651
|
+
"sizes": [{ "name": "medium", "width": 1024 }],
|
|
652
|
+
"svg": true
|
|
653
|
+
}
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
This looks for `handlers/halftone.js` in the config directory. The handler receives `{ dotSize: "0.8%", spacing: "1%", shape: "square", foreground: "#43523d", background: "#c7f0d8", width: ..., height: ... }` as `params`. The `svg: true` flag makes this preprocessor also process SVG source files (rasterized at their native dimensions).
|
|
657
|
+
|
|
658
|
+
**Chaining:** Custom handlers can be mixed with built-in operations in any order. When a handler operation is encountered, the pipeline flushes the current buffer, calls your handler, and creates a new sharp instance from the result:
|
|
659
|
+
|
|
660
|
+
```json
|
|
661
|
+
{
|
|
662
|
+
"name": "styled",
|
|
663
|
+
"operations": [
|
|
664
|
+
{ "type": "grayscale" },
|
|
665
|
+
{ "type": "halftone", "dotSize": 4 },
|
|
666
|
+
{ "type": "blur", "sigma": 1 }
|
|
667
|
+
]
|
|
668
|
+
}
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
**Bundled example handlers** (in the `handlers/` directory):
|
|
672
|
+
|
|
673
|
+
- `halftone` — converts images to a halftone dot pattern. Supports circular dots (classic newspaper print) and square dots (Nokia LCD look). Emits an SVG sidecar alongside the raster output. Params: `dotSize`, `spacing`, `shape` (`"circle"` or `"square"`), `background`, `foreground`. Both `dotSize` and `spacing` accept absolute pixels (e.g. `8`) or percentages relative to the shortest side (e.g. `"1%"`)
|
|
674
|
+
- `pixelate` — chunky pixel art / retro Nokia look via nearest-neighbor downscale/upscale. Params: `blockSize`, `colors` (palette limit), `grayscale`
|
|
675
|
+
|
|
676
|
+
#### Output naming
|
|
677
|
+
|
|
678
|
+
The preprocessor name is inserted between the source name and the size name:
|
|
679
|
+
|
|
680
|
+
```
|
|
681
|
+
Original: {name}-{sizeName}-{width}w.{ext} → photo-medium-1024w.jpg
|
|
682
|
+
Preprocessed: {name}-{ppName}-{sizeName}-{width}w.{ext} → photo-blurred-medium-1024w.jpg
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
#### Edge cases
|
|
686
|
+
|
|
687
|
+
- **SVGs** — preprocessors with `"svg": true` also process SVG source files. The SVG is rasterized via sharp, run through the preprocessor operations, and saved at its native dimensions only (no resize variants). The minified SVG is still saved separately. Preprocessors without `"svg": true` skip SVG files
|
|
688
|
+
- **Animated GIFs** — preprocessors do not apply (copied as-is). Static GIFs go through preprocessors normally
|
|
689
|
+
- **Cache invalidation** — adding, removing, or changing any preprocessor invalidates all cache entries
|
|
690
|
+
|
|
436
691
|
### Caching
|
|
437
692
|
|
|
438
693
|
A cache file (`.poops-images-cache.json`) is stored in the output directory. It tracks per image: source mtime, size, original dimensions, EXIF metadata, and generated outputs with their dimensions.
|
|
@@ -469,7 +724,7 @@ A cache file (`.poops-images-cache.json`) is stored in the output directory. It
|
|
|
469
724
|
**Skip logic:**
|
|
470
725
|
|
|
471
726
|
1. `--force` — always reprocess
|
|
472
|
-
2. Config hash changed (sizes/format/quality/skipOriginal differ) — reprocess everything
|
|
727
|
+
2. Config hash changed (sizes/format/quality/skipOriginal/preprocessors differ) — reprocess everything
|
|
473
728
|
3. Per file: skip if source mtime + size unchanged AND all expected outputs exist on disk
|
|
474
729
|
4. On source deletion (watch mode): remove all generated variants
|
|
475
730
|
|
|
@@ -575,6 +830,7 @@ If you deploy GitHub Pages, do not run `poops-images` in the GitHub Actions to w
|
|
|
575
830
|
| Config file | JSON, poops.json fallback | CLI flags only | JS API only | JSON | JS API (Eleventy-coupled) |
|
|
576
831
|
| CLI | Standalone | Standalone | No (API only) | No (API only) | No (Eleventy plugin) |
|
|
577
832
|
| Concurrency control | Configurable worker count | No | No | Multi-threaded | Yes |
|
|
833
|
+
| Preprocessors | Blur, grayscale, watermark, etc. per-image | No | No | No | No |
|
|
578
834
|
| SSG coupling | Designed for poops, usable standalone | None | None | None | Tightly coupled to Eleventy |
|
|
579
835
|
| Maintained | Active | Last publish 2022 | Last publish 2019 | Last publish 2018 | Active |
|
|
580
836
|
|
|
@@ -585,6 +841,7 @@ If you deploy GitHub Pages, do not run `poops-images` in the GitHub Actions to w
|
|
|
585
841
|
- **WordPress-style crop API** — full 9-position anchor grid (`["left", "top"]`), not just center crop.
|
|
586
842
|
- **Integrated SVG pipeline** — SVGO minification in the same tool. Others require a separate build step.
|
|
587
843
|
- **Convention-based naming** — `{name}-{sizeName}-{width}w.{ext}` is purpose-built for poops' `discoverImageVariants()` srcset generation.
|
|
844
|
+
- **Preprocessors** — generate LQIP placeholders, grayscale hover variants, or watermarked copies alongside originals, all from config. No other tool has a built-in preprocessor pipeline.
|
|
588
845
|
- **Standalone CLI + API** — works with any build system or none at all, unlike Eleventy-coupled or webpack-coupled alternatives.
|
|
589
846
|
|
|
590
847
|
## License
|
package/lib/analyze.js
CHANGED
|
@@ -5,6 +5,15 @@ import { hasTransparency } from './formats.js'
|
|
|
5
5
|
import { dmsToDecimal, formatDms } from './utils/format.js'
|
|
6
6
|
import log from './utils/log.js'
|
|
7
7
|
|
|
8
|
+
export function buildEffectiveSizes(sizes, skipOriginal) {
|
|
9
|
+
if (skipOriginal) return sizes
|
|
10
|
+
|
|
11
|
+
const hasPassthrough = sizes.some(s => s.width === 0 && s.height === 0)
|
|
12
|
+
if (hasPassthrough) return sizes
|
|
13
|
+
|
|
14
|
+
return [{ name: '', width: 0, height: 0, crop: false }, ...sizes]
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
function formatExposure(value) {
|
|
9
18
|
if (value >= 1) return `${value}s`
|
|
10
19
|
return `1/${Math.round(1 / value)}`
|
|
@@ -114,9 +123,11 @@ export async function analyzeImage(inputPath, config, stat) {
|
|
|
114
123
|
// Formats that support alpha and need transparency detection
|
|
115
124
|
const alphaFormats = new Set(['png', 'tiff', 'heic', 'heif', 'gif'])
|
|
116
125
|
|
|
117
|
-
// Check transparency for alpha-capable formats (needed for default + smart modes)
|
|
118
|
-
|
|
119
|
-
|
|
126
|
+
// Check transparency for alpha-capable formats (needed for default + smart modes) —
|
|
127
|
+
// smart may come from the global format or any preprocessor's format override
|
|
128
|
+
const includesSmart = (f) => f === 'smart' || (Array.isArray(f) && f.includes('smart'))
|
|
129
|
+
const formatIncludesSmart = includesSmart(config.format) ||
|
|
130
|
+
(config.preprocessors || []).some(pp => includesSmart(pp.format))
|
|
120
131
|
|
|
121
132
|
const needsTransparencyCheck =
|
|
122
133
|
alphaFormats.has(normalizedExt) &&
|
|
@@ -147,13 +158,7 @@ export async function analyzeImage(inputPath, config, stat) {
|
|
|
147
158
|
}
|
|
148
159
|
|
|
149
160
|
// Build effective sizes list — include original unless skipOriginal
|
|
150
|
-
|
|
151
|
-
if (!config.skipOriginal) {
|
|
152
|
-
const hasPassthrough = effectiveSizes.some(s => s.width === 0 && s.height === 0)
|
|
153
|
-
if (!hasPassthrough) {
|
|
154
|
-
effectiveSizes = [{ name: '', width: 0, height: 0, crop: false }, ...effectiveSizes]
|
|
155
|
-
}
|
|
156
|
-
}
|
|
161
|
+
const effectiveSizes = buildEffectiveSizes(config.sizes, config.skipOriginal)
|
|
157
162
|
|
|
158
163
|
// EXIF orientations 5-8 swap width/height
|
|
159
164
|
const swapped = metadata.orientation >= 5
|
package/lib/config.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'node:fs'
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import crypto from 'node:crypto'
|
|
4
4
|
import { validateSize } from './sizes.js'
|
|
5
|
+
import { validatePreprocessors } from './preprocess.js'
|
|
5
6
|
|
|
6
7
|
// Output formats only — tiff/tif/heic/heif/gif are input formats normalized to jpg/png during processing
|
|
7
8
|
const SUPPORTED_FORMATS = new Set(['jpg', 'jpeg', 'png', 'webp', 'avif'])
|
|
@@ -21,7 +22,8 @@ const DEFAULTS = {
|
|
|
21
22
|
concurrency: 4,
|
|
22
23
|
format: false,
|
|
23
24
|
skipOriginal: false,
|
|
24
|
-
cache: true
|
|
25
|
+
cache: true,
|
|
26
|
+
preprocessors: []
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export function loadConfig(configPath) {
|
|
@@ -68,6 +70,11 @@ export function loadConfig(configPath) {
|
|
|
68
70
|
}
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
// Custom handlers and composite overlays resolve relative to the config file
|
|
74
|
+
if (raw.configDir === undefined) {
|
|
75
|
+
raw.configDir = path.dirname(resolvedPath)
|
|
76
|
+
}
|
|
77
|
+
|
|
71
78
|
return validateConfig(raw)
|
|
72
79
|
}
|
|
73
80
|
|
|
@@ -124,10 +131,22 @@ export function validateConfig(raw) {
|
|
|
124
131
|
|
|
125
132
|
config.skipOriginal = !!config.skipOriginal
|
|
126
133
|
|
|
134
|
+
config.configDir = typeof config.configDir === 'string'
|
|
135
|
+
? path.resolve(config.configDir)
|
|
136
|
+
: process.cwd()
|
|
137
|
+
|
|
127
138
|
if (config.cache !== false && config.cache !== true && typeof config.cache !== 'string') {
|
|
128
139
|
throw new Error('Config "cache" must be false, true, or a string path')
|
|
129
140
|
}
|
|
130
141
|
|
|
142
|
+
// Validate preprocessors
|
|
143
|
+
if (config.preprocessors && config.preprocessors.length > 0) {
|
|
144
|
+
const sizeNames = new Set(config.sizes.map(s => s.name).filter(Boolean))
|
|
145
|
+
config.preprocessors = validatePreprocessors(config.preprocessors, sizeNames)
|
|
146
|
+
} else {
|
|
147
|
+
config.preprocessors = []
|
|
148
|
+
}
|
|
149
|
+
|
|
131
150
|
if (config.format !== false) {
|
|
132
151
|
const fmts = Array.isArray(config.format)
|
|
133
152
|
? config.format
|
|
@@ -153,7 +172,8 @@ export function configHash(config) {
|
|
|
153
172
|
sizes: config.sizes,
|
|
154
173
|
format: config.format,
|
|
155
174
|
quality: config.quality,
|
|
156
|
-
skipOriginal: config.skipOriginal
|
|
175
|
+
skipOriginal: config.skipOriginal,
|
|
176
|
+
preprocessors: config.preprocessors
|
|
157
177
|
}
|
|
158
178
|
return crypto.createHash('md5').update(JSON.stringify(hashable)).digest('hex')
|
|
159
179
|
}
|
package/lib/discover.js
CHANGED
|
@@ -1,26 +1,30 @@
|
|
|
1
1
|
import { glob } from 'glob'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
|
|
4
|
+
// glob treats \ in patterns as an escape character, so patterns must use
|
|
5
|
+
// forward slashes even on Windows
|
|
6
|
+
const globJoin = (...parts) => path.join(...parts).split(path.sep).join('/')
|
|
7
|
+
|
|
4
8
|
export async function discoverSources(config) {
|
|
5
|
-
const pattern =
|
|
9
|
+
const pattern = globJoin(config.in, config.include)
|
|
6
10
|
const ignore = [
|
|
7
|
-
...config.exclude.map(e =>
|
|
11
|
+
...config.exclude.map(e => globJoin(config.in, e)),
|
|
8
12
|
// SVG and GIF have dedicated pipelines — exclude from raster discovery
|
|
9
|
-
|
|
10
|
-
|
|
13
|
+
globJoin(config.in, '**/*.svg'),
|
|
14
|
+
globJoin(config.in, '**/*.gif')
|
|
11
15
|
]
|
|
12
16
|
return glob(pattern, { ignore, nodir: true })
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
export async function discoverSvgs(config) {
|
|
16
|
-
const pattern =
|
|
17
|
-
const ignore = config.exclude.map(e =>
|
|
20
|
+
const pattern = globJoin(config.in, '**/*.svg')
|
|
21
|
+
const ignore = config.exclude.map(e => globJoin(config.in, e))
|
|
18
22
|
return glob(pattern, { ignore, nodir: true })
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
export async function discoverGifs(config) {
|
|
22
|
-
const pattern =
|
|
23
|
-
const ignore = config.exclude.map(e =>
|
|
26
|
+
const pattern = globJoin(config.in, '**/*.gif')
|
|
27
|
+
const ignore = config.exclude.map(e => globJoin(config.in, e))
|
|
24
28
|
return glob(pattern, { ignore, nodir: true })
|
|
25
29
|
}
|
|
26
30
|
|
package/lib/formats.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import sharp from 'sharp'
|
|
2
|
-
|
|
3
1
|
const FORMAT_MAP = {
|
|
4
2
|
jpg: 'jpeg',
|
|
5
3
|
jpeg: 'jpeg',
|
|
@@ -63,7 +61,7 @@ export async function convertFormat(sharpInstance, targetFormat, qualityConfig)
|
|
|
63
61
|
return buffer
|
|
64
62
|
}
|
|
65
63
|
|
|
66
|
-
export async function resolveTargetFormats(outputExt, transparent,
|
|
64
|
+
export async function resolveTargetFormats(outputExt, transparent, toSharp, formatConfig, qualityConfig) {
|
|
67
65
|
const preEncoded = new Map()
|
|
68
66
|
|
|
69
67
|
// Default: just the normalized base format
|
|
@@ -82,10 +80,10 @@ export async function resolveTargetFormats(outputExt, transparent, rawData, form
|
|
|
82
80
|
} else {
|
|
83
81
|
// Compare jpg vs webp, pick smaller — keep the winner buffer to avoid re-encoding
|
|
84
82
|
const [jpgBuf, webpBuf] = await Promise.all([
|
|
85
|
-
|
|
83
|
+
toSharp()
|
|
86
84
|
.toFormat('jpeg', { quality: getQuality('jpg', qualityConfig) })
|
|
87
85
|
.toBuffer(),
|
|
88
|
-
|
|
86
|
+
toSharp()
|
|
89
87
|
.toFormat('webp', { quality: getQuality('webp', qualityConfig) })
|
|
90
88
|
.toBuffer()
|
|
91
89
|
])
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
import sharp from 'sharp'
|
|
5
|
+
import { validateSize } from './sizes.js'
|
|
6
|
+
|
|
7
|
+
const OPERATION_REGISTRY = {
|
|
8
|
+
blur: (pipeline, params) => {
|
|
9
|
+
if (params.sigma == null) throw new Error('blur requires "sigma" parameter')
|
|
10
|
+
if (typeof params.sigma !== 'number' || params.sigma < 0.3 || params.sigma > 1000) {
|
|
11
|
+
throw new Error('blur "sigma" must be a number between 0.3 and 1000')
|
|
12
|
+
}
|
|
13
|
+
return pipeline.blur(params.sigma)
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
grayscale: (pipeline) => pipeline.grayscale(),
|
|
17
|
+
|
|
18
|
+
sharpen: (pipeline, params) => {
|
|
19
|
+
const opts = {}
|
|
20
|
+
if (params.sigma != null) opts.sigma = params.sigma
|
|
21
|
+
return pipeline.sharpen(opts)
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
tint: (pipeline, params) => {
|
|
25
|
+
if (!params.color) throw new Error('tint requires "color" parameter')
|
|
26
|
+
return pipeline.tint(params.color)
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
modulate: (pipeline, params) => {
|
|
30
|
+
const opts = {}
|
|
31
|
+
if (params.brightness != null) opts.brightness = params.brightness
|
|
32
|
+
if (params.saturation != null) opts.saturation = params.saturation
|
|
33
|
+
if (params.hue != null) opts.hue = params.hue
|
|
34
|
+
if (params.lightness != null) opts.lightness = params.lightness
|
|
35
|
+
return pipeline.modulate(opts)
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
negate: (pipeline) => pipeline.negate(),
|
|
39
|
+
|
|
40
|
+
normalize: (pipeline) => pipeline.normalize(),
|
|
41
|
+
|
|
42
|
+
rotate: (pipeline, params) => {
|
|
43
|
+
if (params.angle == null) throw new Error('rotate requires "angle" parameter')
|
|
44
|
+
return pipeline.rotate(params.angle)
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
flip: (pipeline) => pipeline.flip(),
|
|
48
|
+
|
|
49
|
+
flop: (pipeline) => pipeline.flop(),
|
|
50
|
+
|
|
51
|
+
gamma: (pipeline, params) => {
|
|
52
|
+
if (params.value == null) throw new Error('gamma requires "value" parameter')
|
|
53
|
+
return pipeline.gamma(params.value)
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
composite: (pipeline, params, configDir) => {
|
|
57
|
+
if (!params.input) throw new Error('composite requires "input" parameter')
|
|
58
|
+
|
|
59
|
+
const overlayPath = path.resolve(configDir || process.cwd(), params.input)
|
|
60
|
+
if (!fs.existsSync(overlayPath)) {
|
|
61
|
+
throw new Error(`composite overlay not found: ${overlayPath}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const compositeOpts = { input: overlayPath }
|
|
65
|
+
if (params.gravity) compositeOpts.gravity = params.gravity
|
|
66
|
+
if (params.blend) compositeOpts.blend = params.blend
|
|
67
|
+
if (params.top != null) compositeOpts.top = params.top
|
|
68
|
+
if (params.left != null) compositeOpts.left = params.left
|
|
69
|
+
|
|
70
|
+
return pipeline.composite([compositeOpts])
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const VALID_OPERATIONS = Object.keys(OPERATION_REGISTRY)
|
|
75
|
+
|
|
76
|
+
// Cache for loaded custom handler modules, keyed by path + mtime so an edited
|
|
77
|
+
// handler is re-imported on the next run (watch mode) without a restart
|
|
78
|
+
const handlerCache = new Map()
|
|
79
|
+
|
|
80
|
+
function resolveHandlerPath(name, configDir) {
|
|
81
|
+
const base = configDir || process.cwd()
|
|
82
|
+
|
|
83
|
+
// If name looks like a path (contains / or .), resolve directly
|
|
84
|
+
if (name.includes('/') || name.includes('.')) {
|
|
85
|
+
return path.resolve(base, name)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Otherwise, look for handlers/{name}.js in the config directory
|
|
89
|
+
return path.resolve(base, 'handlers', `${name}.js`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function loadHandler(name, configDir) {
|
|
93
|
+
const resolved = resolveHandlerPath(name, configDir)
|
|
94
|
+
|
|
95
|
+
if (!fs.existsSync(resolved)) {
|
|
96
|
+
throw new Error(`Custom handler not found: ${resolved}`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// import() caches by URL for the process lifetime, so key both the local
|
|
100
|
+
// cache and the import URL by mtime — an edited handler loads fresh
|
|
101
|
+
const mtime = fs.statSync(resolved).mtimeMs
|
|
102
|
+
const key = `${resolved}:${mtime}`
|
|
103
|
+
if (handlerCache.has(key)) return handlerCache.get(key)
|
|
104
|
+
|
|
105
|
+
const mod = await import(`${pathToFileURL(resolved).href}?v=${mtime}`)
|
|
106
|
+
const fn = mod.default || mod
|
|
107
|
+
|
|
108
|
+
if (typeof fn !== 'function') {
|
|
109
|
+
throw new Error(`Custom handler must export a function: ${resolved}`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
handlerCache.set(key, fn)
|
|
113
|
+
return fn
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function validateResizeFirst(value, name) {
|
|
117
|
+
if (value == null || value === false) return false
|
|
118
|
+
if (value === true) return true
|
|
119
|
+
|
|
120
|
+
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
121
|
+
// 0 counts as unset — validation must be idempotent, since the config is
|
|
122
|
+
// validated twice (loadConfig and the processor constructor) and the first
|
|
123
|
+
// pass normalizes a missing dimension to 0
|
|
124
|
+
const okDim = (v) => v === undefined || (typeof v === 'number' && Number.isFinite(v) && v >= 0)
|
|
125
|
+
const width = value.width || 0
|
|
126
|
+
const height = value.height || 0
|
|
127
|
+
if (!okDim(value.width) || !okDim(value.height) || (width === 0 && height === 0)) {
|
|
128
|
+
throw new Error(`Preprocessor "${name}": "resizeFirst" object requires a positive numeric "width" and/or "height"`)
|
|
129
|
+
}
|
|
130
|
+
// Reuse size validation for crop rules
|
|
131
|
+
const validated = validateSize({ width: value.width || 0, height: value.height || 0, crop: value.crop })
|
|
132
|
+
return { width: validated.width, height: validated.height, crop: validated.crop }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
throw new Error(`Preprocessor "${name}": "resizeFirst" must be true or an object like { width, height, crop }`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function validatePreprocessor(preprocessor) {
|
|
139
|
+
if (!preprocessor || typeof preprocessor !== 'object') {
|
|
140
|
+
throw new Error('Preprocessor must be an object')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!preprocessor.name || typeof preprocessor.name !== 'string') {
|
|
144
|
+
throw new Error('Preprocessor "name" is required and must be a non-empty string')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(preprocessor.name)) {
|
|
148
|
+
throw new Error(`Preprocessor name "${preprocessor.name}" must contain only letters, numbers, hyphens, and underscores`)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!Array.isArray(preprocessor.operations) || preprocessor.operations.length === 0) {
|
|
152
|
+
throw new Error(`Preprocessor "${preprocessor.name}": "operations" must be a non-empty array`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const op of preprocessor.operations) {
|
|
156
|
+
if (!op.type || typeof op.type !== 'string') {
|
|
157
|
+
throw new Error(`Preprocessor "${preprocessor.name}": each operation must have a "type" string`)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
name: preprocessor.name,
|
|
163
|
+
operations: preprocessor.operations,
|
|
164
|
+
sizes: preprocessor.sizes || null,
|
|
165
|
+
format: preprocessor.format !== undefined ? preprocessor.format : null,
|
|
166
|
+
quality: preprocessor.quality !== undefined ? preprocessor.quality : null,
|
|
167
|
+
skipOriginal: preprocessor.skipOriginal !== undefined ? preprocessor.skipOriginal : null,
|
|
168
|
+
svg: !!preprocessor.svg,
|
|
169
|
+
resizeFirst: validateResizeFirst(preprocessor.resizeFirst, preprocessor.name)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function validatePreprocessors(preprocessors, sizeNames) {
|
|
174
|
+
if (!Array.isArray(preprocessors)) {
|
|
175
|
+
throw new Error('Config "preprocessors" must be an array')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const names = new Set()
|
|
179
|
+
const validated = []
|
|
180
|
+
|
|
181
|
+
for (const pp of preprocessors) {
|
|
182
|
+
const result = validatePreprocessor(pp)
|
|
183
|
+
|
|
184
|
+
if (names.has(result.name)) {
|
|
185
|
+
throw new Error(`Duplicate preprocessor name: "${result.name}"`)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (sizeNames && sizeNames.has(result.name)) {
|
|
189
|
+
throw new Error(`Preprocessor name "${result.name}" conflicts with a size name`)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
names.add(result.name)
|
|
193
|
+
validated.push(result)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return validated
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function applyOperations(input, operations, configDir, rawInfo = null) {
|
|
200
|
+
// Auto-rotate for EXIF only when input is a file path and no explicit rotate operation
|
|
201
|
+
const isBuffer = Buffer.isBuffer(input)
|
|
202
|
+
const hasExplicitRotate = operations.some(op => op.type === 'rotate')
|
|
203
|
+
// rawInfo = { width, height, channels } when input is a raw pixel buffer
|
|
204
|
+
const create = () => rawInfo ? sharp(input, { raw: rawInfo }) : sharp(input)
|
|
205
|
+
let pipeline = (!isBuffer && !hasExplicitRotate) ? create().rotate() : create()
|
|
206
|
+
const sidecars = []
|
|
207
|
+
|
|
208
|
+
for (const op of operations) {
|
|
209
|
+
if (OPERATION_REGISTRY[op.type]) {
|
|
210
|
+
const registryHandler = OPERATION_REGISTRY[op.type]
|
|
211
|
+
pipeline = registryHandler(pipeline, op, configDir)
|
|
212
|
+
} else {
|
|
213
|
+
// Treat type as a handler — flush pipeline to buffer, call handler, create new pipeline.
|
|
214
|
+
// Flush as fast lossless PNG: flushing in the source format would re-encode lossily.
|
|
215
|
+
const buf = await pipeline.png({ compressionLevel: 0 }).toBuffer()
|
|
216
|
+
const meta = await sharp(buf).metadata()
|
|
217
|
+
const handler = await loadHandler(op.type, configDir)
|
|
218
|
+
|
|
219
|
+
// Extract params (everything except type)
|
|
220
|
+
const { type: _type, ...params } = op
|
|
221
|
+
const result = await handler(buf, { ...params, width: meta.width, height: meta.height }, sharp)
|
|
222
|
+
|
|
223
|
+
// Handlers can return Buffer or { buffer, sidecars: [{ ext, data }] }
|
|
224
|
+
if (Buffer.isBuffer(result)) {
|
|
225
|
+
pipeline = sharp(result)
|
|
226
|
+
} else if (result && Buffer.isBuffer(result.buffer)) {
|
|
227
|
+
pipeline = sharp(result.buffer)
|
|
228
|
+
if (Array.isArray(result.sidecars)) {
|
|
229
|
+
sidecars.push(...result.sidecars)
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
throw new Error(`Custom handler "${op.type}" must return a Buffer or { buffer, sidecars }`)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Fast lossless PNG — the final encode (format + quality) happens downstream;
|
|
238
|
+
// flushing in the source format here would add a hidden generation of loss.
|
|
239
|
+
const output = await pipeline.png({ compressionLevel: 0 }).toBuffer({ resolveWithObject: true })
|
|
240
|
+
output.sidecars = sidecars
|
|
241
|
+
return output
|
|
242
|
+
}
|
package/lib/processor.js
CHANGED
|
@@ -6,7 +6,8 @@ import { validateConfig, configHash } from './config.js'
|
|
|
6
6
|
import { toSharpOptions, filterByUpscale } from './sizes.js'
|
|
7
7
|
import { convertFormat, resolveTargetFormats } from './formats.js'
|
|
8
8
|
import { discoverAll } from './discover.js'
|
|
9
|
-
import { analyzeImage } from './analyze.js'
|
|
9
|
+
import { analyzeImage, buildEffectiveSizes } from './analyze.js'
|
|
10
|
+
import { applyOperations } from './preprocess.js'
|
|
10
11
|
import Cache from './cache.js'
|
|
11
12
|
import { processSvg } from './svg.js'
|
|
12
13
|
import log from './utils/log.js'
|
|
@@ -15,6 +16,7 @@ import { formatBytes, formatTime } from './utils/format.js'
|
|
|
15
16
|
export default class ImageProcessor {
|
|
16
17
|
constructor(config) {
|
|
17
18
|
this.config = validateConfig(config)
|
|
19
|
+
this._configDir = this.config.configDir
|
|
18
20
|
this.cache = new Cache(this.config.out, this.config.cache)
|
|
19
21
|
this.stats = { processed: 0, variants: 0, skipped: 0, bytes: 0, startTime: 0 }
|
|
20
22
|
this.watcher = null
|
|
@@ -104,20 +106,132 @@ export default class ImageProcessor {
|
|
|
104
106
|
// Get previous outputs before processing (for stale cleanup)
|
|
105
107
|
const previousOutputs = this.cache.getOutputs(relativePath)
|
|
106
108
|
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
// 1. Original pipeline
|
|
110
|
+
await this._processVariants(inputPath, job, null, outputs, startTime)
|
|
111
|
+
|
|
112
|
+
// 2. Preprocessor pipelines
|
|
113
|
+
for (const preprocessor of this.config.preprocessors) {
|
|
114
|
+
try {
|
|
115
|
+
if (preprocessor.resizeFirst === true) {
|
|
116
|
+
// Per-variant: _processVariants resizes each variant, then preprocesses it
|
|
117
|
+
await this._processVariants(inputPath, job, preprocessor, outputs, startTime)
|
|
118
|
+
} else if (preprocessor.resizeFirst) {
|
|
119
|
+
// Object: resize to base size once, preprocess once, then generate variants.
|
|
120
|
+
// Base dims cap the variant sizes so nothing upscales past the base.
|
|
121
|
+
const base = await this._resizeToBase(inputPath, preprocessor.resizeFirst, job)
|
|
122
|
+
const result = await applyOperations(base.data, preprocessor.operations, this._configDir, {
|
|
123
|
+
width: base.info.width, height: base.info.height, channels: base.info.channels
|
|
124
|
+
})
|
|
125
|
+
await this._processVariants(result.data, job, preprocessor, outputs, startTime, base.info)
|
|
126
|
+
this._writeSidecars(result.sidecars, job, preprocessor, outputs, startTime)
|
|
127
|
+
} else {
|
|
128
|
+
const result = await applyOperations(inputPath, preprocessor.operations, this._configDir)
|
|
129
|
+
await this._processVariants(result.data, job, preprocessor, outputs, startTime)
|
|
130
|
+
this._writeSidecars(result.sidecars, job, preprocessor, outputs, startTime)
|
|
131
|
+
}
|
|
132
|
+
} catch (err) {
|
|
133
|
+
log({ tag: 'error', text: `Preprocessor "${preprocessor.name}" failed: ${err.message}`, link: relativePath })
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Clean up stale outputs from previous runs of this source file
|
|
138
|
+
const newPaths = new Set(outputs.map(o => o.path))
|
|
139
|
+
for (const prev of previousOutputs) {
|
|
140
|
+
const prevPath = typeof prev === 'string' ? prev : prev.path
|
|
141
|
+
if (!newPaths.has(prevPath)) {
|
|
142
|
+
const fullPath = path.join(this.config.out, prevPath)
|
|
143
|
+
if (fs.existsSync(fullPath)) {
|
|
144
|
+
fs.unlinkSync(fullPath)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
this.cache.setEntry(relativePath, {
|
|
150
|
+
mtime: job.mtime,
|
|
151
|
+
size: job.fileSize,
|
|
152
|
+
width: job.sourceWidth,
|
|
153
|
+
height: job.sourceHeight,
|
|
154
|
+
exif: job.exif,
|
|
155
|
+
outputs
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
this.stats.processed++
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Process size variants for a given input (file path or buffer).
|
|
163
|
+
* @param {string|Buffer} input - file path (original) or Buffer (preprocessed)
|
|
164
|
+
* @param {object} job - analysis result from analyzeImage
|
|
165
|
+
* @param {object|null} preprocessor - preprocessor config, or null for original pipeline
|
|
166
|
+
* @param {Array} outputs - accumulator for output entries
|
|
167
|
+
* @param {number} startTime - processing start timestamp
|
|
168
|
+
* @param {object|null} baseDims - dims of the input when it's a pre-shrunk base buffer
|
|
169
|
+
* (resizeFirst object form); caps variant sizes so nothing upscales past the base
|
|
170
|
+
*/
|
|
171
|
+
async _processVariants(input, job, preprocessor, outputs, startTime, baseDims = null) {
|
|
172
|
+
const relativePath = job.relativePath
|
|
173
|
+
// resizeFirst: true — operations run on each already-resized variant
|
|
174
|
+
const preprocessPerVariant = preprocessor?.resizeFirst === true
|
|
175
|
+
|
|
176
|
+
// Determine effective config — preprocessor overrides or global
|
|
177
|
+
const skipOrig = preprocessor?.skipOriginal != null ? preprocessor.skipOriginal : this.config.skipOriginal
|
|
178
|
+
let sizes
|
|
179
|
+
if (preprocessor?.sizes) {
|
|
180
|
+
sizes = buildEffectiveSizes(
|
|
181
|
+
preprocessor.sizes.map(s => ({ width: s.width || 0, height: s.height || 0, name: s.name || '', crop: s.crop || false })),
|
|
182
|
+
skipOrig
|
|
183
|
+
)
|
|
184
|
+
} else if (preprocessor && preprocessor.skipOriginal != null) {
|
|
185
|
+
// Preprocessor overrides skipOriginal but uses global sizes
|
|
186
|
+
sizes = buildEffectiveSizes(this.config.sizes, skipOrig)
|
|
187
|
+
} else {
|
|
188
|
+
sizes = job.effectiveSizes
|
|
189
|
+
}
|
|
190
|
+
const format = preprocessor?.format != null ? preprocessor.format : this.config.format
|
|
191
|
+
const quality = preprocessor?.quality != null
|
|
192
|
+
? (typeof preprocessor.quality === 'number'
|
|
193
|
+
? { jpg: preprocessor.quality, webp: preprocessor.quality, avif: preprocessor.quality, png: preprocessor.quality }
|
|
194
|
+
: { ...this.config.quality, ...preprocessor.quality })
|
|
195
|
+
: this.config.quality
|
|
196
|
+
|
|
197
|
+
const namePrefix = preprocessor ? `${job.parsed.name}-${preprocessor.name}` : job.parsed.name
|
|
198
|
+
|
|
199
|
+
// Filter sizes by upscale constraints — against the base buffer dims when pre-shrunk
|
|
200
|
+
const srcWidth = baseDims ? baseDims.width : job.sourceWidth
|
|
201
|
+
const srcHeight = baseDims ? baseDims.height : job.sourceHeight
|
|
202
|
+
const eligibleSizes = filterByUpscale(sizes, srcWidth, srcHeight)
|
|
203
|
+
let sidecarsWritten = false
|
|
111
204
|
|
|
112
205
|
for (const sizeDef of eligibleSizes) {
|
|
113
206
|
const conversionOnly = sizeDef.width === 0 && sizeDef.height === 0
|
|
114
207
|
|
|
115
|
-
// Resize
|
|
208
|
+
// Resize — flush to raw pixels; the only encode is the final one per format
|
|
116
209
|
const sharpOpts = toSharpOptions(sizeDef)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
210
|
+
let pipeline = typeof input === 'string' ? sharp(input).rotate() : sharp(input)
|
|
211
|
+
if (sharpOpts) pipeline = pipeline.resize(sharpOpts)
|
|
212
|
+
const resized = await pipeline.raw().toBuffer({ resolveWithObject: true })
|
|
213
|
+
|
|
214
|
+
// data is either raw pixels (with rawInfo) or an encoded buffer (rawInfo null)
|
|
215
|
+
let data = resized.data
|
|
216
|
+
let rawInfo = { width: resized.info.width, height: resized.info.height, channels: resized.info.channels }
|
|
217
|
+
let actualWidth = resized.info.width
|
|
218
|
+
let actualHeight = resized.info.height
|
|
219
|
+
|
|
220
|
+
if (preprocessPerVariant) {
|
|
221
|
+
const ppResult = await applyOperations(data, preprocessor.operations, this._configDir, rawInfo)
|
|
222
|
+
data = ppResult.data
|
|
223
|
+
rawInfo = null
|
|
224
|
+
actualWidth = ppResult.info.width
|
|
225
|
+
actualHeight = ppResult.info.height
|
|
226
|
+
|
|
227
|
+
// Sidecar filename has no size suffix — write once, from the first variant
|
|
228
|
+
if (!sidecarsWritten && ppResult.sidecars.length > 0) {
|
|
229
|
+
this._writeSidecars(ppResult.sidecars, job, preprocessor, outputs, startTime)
|
|
230
|
+
sidecarsWritten = true
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const toSharp = () => rawInfo ? sharp(data, { raw: rawInfo }) : sharp(data)
|
|
121
235
|
|
|
122
236
|
// Build output path
|
|
123
237
|
const outDir = path.join(this.config.out, job.parsed.dir)
|
|
@@ -125,26 +239,25 @@ export default class ImageProcessor {
|
|
|
125
239
|
|
|
126
240
|
let baseName
|
|
127
241
|
if (conversionOnly) {
|
|
128
|
-
baseName =
|
|
242
|
+
baseName = namePrefix
|
|
129
243
|
} else if (sizeDef.name) {
|
|
130
|
-
baseName = `${
|
|
244
|
+
baseName = `${namePrefix}-${sizeDef.name}-${actualWidth}w`
|
|
131
245
|
} else {
|
|
132
|
-
baseName = `${
|
|
246
|
+
baseName = `${namePrefix}-${actualWidth}w`
|
|
133
247
|
}
|
|
134
248
|
|
|
135
249
|
// Resolve which formats to generate
|
|
136
250
|
const { formats: targetFormats, preEncoded } = await resolveTargetFormats(
|
|
137
|
-
job.outputExt, job.transparent,
|
|
138
|
-
|
|
251
|
+
job.outputExt, job.transparent, toSharp,
|
|
252
|
+
format, quality
|
|
139
253
|
)
|
|
140
254
|
|
|
141
255
|
// Encode + write each target format
|
|
142
256
|
for (const targetExt of targetFormats) {
|
|
143
257
|
let buffer
|
|
144
258
|
try {
|
|
145
|
-
// Reuse pre-encoded buffer from smart format comparison when available
|
|
146
259
|
buffer = preEncoded.get(targetExt) || await convertFormat(
|
|
147
|
-
|
|
260
|
+
toSharp(), targetExt, quality
|
|
148
261
|
)
|
|
149
262
|
} catch (err) {
|
|
150
263
|
log({ tag: 'error', text: `Format conversion failed (${targetExt}): ${err.message}`, link: relativePath })
|
|
@@ -162,36 +275,70 @@ export default class ImageProcessor {
|
|
|
162
275
|
|
|
163
276
|
log({
|
|
164
277
|
tag: 'image',
|
|
165
|
-
text: 'Compiled:',
|
|
278
|
+
text: preprocessor ? `Compiled [${preprocessor.name}]:` : 'Compiled:',
|
|
166
279
|
link: relPath,
|
|
167
280
|
size: formatBytes(buffer.length),
|
|
168
281
|
time: formatTime(Date.now() - startTime)
|
|
169
282
|
})
|
|
170
283
|
}
|
|
171
284
|
}
|
|
285
|
+
}
|
|
172
286
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
287
|
+
/**
|
|
288
|
+
* Resize input to a base size before preprocessing.
|
|
289
|
+
* Used when resizeFirst is an object like { width: 800 } (validated size shape).
|
|
290
|
+
* Returns { data, info } — raw pixels at the base dimensions.
|
|
291
|
+
*/
|
|
292
|
+
async _resizeToBase(input, resizeDef, job) {
|
|
293
|
+
// Don't upscale: clamp target to source dimensions
|
|
294
|
+
const def = {
|
|
295
|
+
name: '',
|
|
296
|
+
width: resizeDef.width || 0,
|
|
297
|
+
height: resizeDef.height || 0,
|
|
298
|
+
crop: resizeDef.crop || false
|
|
299
|
+
}
|
|
300
|
+
if (def.crop && def.width > 0 && def.height > 0) {
|
|
301
|
+
// Proportional clamp — independent clamping would change the crop aspect ratio
|
|
302
|
+
const scale = Math.min(job.sourceWidth / def.width, job.sourceHeight / def.height, 1)
|
|
303
|
+
def.width = Math.max(1, Math.round(def.width * scale))
|
|
304
|
+
def.height = Math.max(1, Math.round(def.height * scale))
|
|
305
|
+
} else {
|
|
306
|
+
def.width = Math.min(def.width, job.sourceWidth)
|
|
307
|
+
def.height = Math.min(def.height, job.sourceHeight)
|
|
183
308
|
}
|
|
309
|
+
const opts = toSharpOptions(def)
|
|
184
310
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
size: job.fileSize,
|
|
188
|
-
width: job.sourceWidth,
|
|
189
|
-
height: job.sourceHeight,
|
|
190
|
-
exif: job.exif,
|
|
191
|
-
outputs
|
|
192
|
-
})
|
|
311
|
+
let pipeline = typeof input === 'string' ? sharp(input).rotate() : sharp(input)
|
|
312
|
+
if (opts) pipeline = pipeline.resize(opts)
|
|
193
313
|
|
|
194
|
-
|
|
314
|
+
return pipeline.raw().toBuffer({ resolveWithObject: true })
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
_writeSidecars(sidecars, job, preprocessor, outputs, startTime) {
|
|
318
|
+
if (!sidecars || sidecars.length === 0) return
|
|
319
|
+
|
|
320
|
+
const namePrefix = preprocessor ? `${job.parsed.name}-${preprocessor.name}` : job.parsed.name
|
|
321
|
+
const outDir = path.join(this.config.out, job.parsed.dir)
|
|
322
|
+
fs.mkdirSync(outDir, { recursive: true })
|
|
323
|
+
|
|
324
|
+
for (const sidecar of sidecars) {
|
|
325
|
+
const filename = `${namePrefix}.${sidecar.ext}`
|
|
326
|
+
const outputPath = path.join(outDir, filename)
|
|
327
|
+
const relPath = path.join(job.parsed.dir, filename)
|
|
328
|
+
|
|
329
|
+
fs.writeFileSync(outputPath, sidecar.data)
|
|
330
|
+
outputs.push({ path: relPath, width: null, height: null })
|
|
331
|
+
this.stats.variants++
|
|
332
|
+
this.stats.bytes += sidecar.data.length
|
|
333
|
+
|
|
334
|
+
log({
|
|
335
|
+
tag: 'image',
|
|
336
|
+
text: preprocessor ? `Compiled [${preprocessor.name}]:` : 'Compiled:',
|
|
337
|
+
link: relPath,
|
|
338
|
+
size: formatBytes(sidecar.data.length),
|
|
339
|
+
time: formatTime(Date.now() - startTime)
|
|
340
|
+
})
|
|
341
|
+
}
|
|
195
342
|
}
|
|
196
343
|
|
|
197
344
|
watch() {
|
|
@@ -216,20 +363,9 @@ export default class ImageProcessor {
|
|
|
216
363
|
})
|
|
217
364
|
|
|
218
365
|
this.watcher.on('unlink', (filePath) => {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
for (const output of outputs) {
|
|
223
|
-
const outputPath = typeof output === 'string' ? output : output.path
|
|
224
|
-
const fullPath = path.join(this.config.out, outputPath)
|
|
225
|
-
if (fs.existsSync(fullPath)) {
|
|
226
|
-
fs.unlinkSync(fullPath)
|
|
227
|
-
log({ tag: 'image', text: 'Removed:', link: outputPath })
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
this.cache.removeEntry(relativePath)
|
|
232
|
-
this.cache.save()
|
|
366
|
+
// Through the queue — deleting outputs mid-processing races with writes
|
|
367
|
+
this._watchQueue.push({ type: 'unlink', filePath })
|
|
368
|
+
this._drainWatchQueue()
|
|
233
369
|
})
|
|
234
370
|
|
|
235
371
|
return this.watcher
|
|
@@ -274,16 +410,58 @@ export default class ImageProcessor {
|
|
|
274
410
|
}
|
|
275
411
|
|
|
276
412
|
const result = await processSvg(svgFile, this.config.out, this.config.in)
|
|
277
|
-
if (result)
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
413
|
+
if (!result) continue
|
|
414
|
+
|
|
415
|
+
const outputs = [{ path: result.relativePath, width: result.width, height: result.height }]
|
|
416
|
+
this.stats.variants++
|
|
417
|
+
this.stats.bytes += result.outputSize
|
|
418
|
+
|
|
419
|
+
// Run SVG-enabled preprocessors on rasterized SVG (original size only)
|
|
420
|
+
const svgPreprocessors = this.config.preprocessors.filter(pp => pp.svg)
|
|
421
|
+
if (svgPreprocessors.length > 0) {
|
|
422
|
+
const startTime = Date.now()
|
|
423
|
+
const parsed = path.parse(relativePath)
|
|
424
|
+
|
|
425
|
+
let meta
|
|
426
|
+
try {
|
|
427
|
+
meta = await sharp(svgFile).metadata()
|
|
428
|
+
} catch (err) {
|
|
429
|
+
log({ tag: 'error', text: `Failed to rasterize SVG: ${err.message}`, link: relativePath })
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (meta) {
|
|
433
|
+
const svgJob = {
|
|
434
|
+
relativePath,
|
|
435
|
+
parsed,
|
|
436
|
+
sourceWidth: meta.width,
|
|
437
|
+
sourceHeight: meta.height,
|
|
438
|
+
outputExt: 'png',
|
|
439
|
+
transparent: true,
|
|
440
|
+
effectiveSizes: [{ name: '', width: 0, height: 0, crop: false }]
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
for (const preprocessor of svgPreprocessors) {
|
|
444
|
+
try {
|
|
445
|
+
const ppResult = await applyOperations(svgFile, preprocessor.operations, this._configDir)
|
|
446
|
+
// Force original-only sizes for SVG preprocessing
|
|
447
|
+
const svgPP = { ...preprocessor, sizes: null, skipOriginal: null }
|
|
448
|
+
await this._processVariants(ppResult.data, svgJob, svgPP, outputs, startTime)
|
|
449
|
+
this._writeSidecars(ppResult.sidecars, svgJob, svgPP, outputs, startTime)
|
|
450
|
+
} catch (err) {
|
|
451
|
+
log({ tag: 'error', text: `Preprocessor "${preprocessor.name}" failed for SVG: ${err.message}`, link: relativePath })
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
286
455
|
}
|
|
456
|
+
|
|
457
|
+
this.cache.setEntry(relativePath, {
|
|
458
|
+
mtime: stat.mtimeMs,
|
|
459
|
+
size: stat.size,
|
|
460
|
+
width: result.width,
|
|
461
|
+
height: result.height,
|
|
462
|
+
outputs
|
|
463
|
+
})
|
|
464
|
+
this.stats.processed++
|
|
287
465
|
}
|
|
288
466
|
}
|
|
289
467
|
|
|
@@ -348,18 +526,37 @@ export default class ImageProcessor {
|
|
|
348
526
|
}
|
|
349
527
|
|
|
350
528
|
_enqueueWatch(filePath, force) {
|
|
351
|
-
this._watchQueue.push({ filePath, force })
|
|
529
|
+
this._watchQueue.push({ type: 'process', filePath, force })
|
|
352
530
|
this._drainWatchQueue()
|
|
353
531
|
}
|
|
354
532
|
|
|
533
|
+
_watchRemoveOutputs(filePath) {
|
|
534
|
+
const relativePath = path.relative(this.config.in, filePath)
|
|
535
|
+
const outputs = this.cache.getOutputs(relativePath)
|
|
536
|
+
|
|
537
|
+
for (const output of outputs) {
|
|
538
|
+
const outputPath = typeof output === 'string' ? output : output.path
|
|
539
|
+
const fullPath = path.join(this.config.out, outputPath)
|
|
540
|
+
if (fs.existsSync(fullPath)) {
|
|
541
|
+
fs.unlinkSync(fullPath)
|
|
542
|
+
log({ tag: 'image', text: 'Removed:', link: outputPath })
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
this.cache.removeEntry(relativePath)
|
|
547
|
+
this.cache.save()
|
|
548
|
+
}
|
|
549
|
+
|
|
355
550
|
async _drainWatchQueue() {
|
|
356
551
|
if (this._watchProcessing) return
|
|
357
552
|
this._watchProcessing = true
|
|
358
553
|
|
|
359
554
|
while (this._watchQueue.length > 0) {
|
|
360
|
-
const { filePath, force } = this._watchQueue.shift()
|
|
555
|
+
const { type, filePath, force } = this._watchQueue.shift()
|
|
361
556
|
try {
|
|
362
|
-
if (
|
|
557
|
+
if (type === 'unlink') {
|
|
558
|
+
this._watchRemoveOutputs(filePath)
|
|
559
|
+
} else if (this._isSvg(filePath)) {
|
|
363
560
|
await this._watchProcessSvg(filePath)
|
|
364
561
|
} else if (this._isGif(filePath)) {
|
|
365
562
|
await this._watchProcessGif(filePath, force)
|
package/package.json
CHANGED
package/poops-images.js
CHANGED
|
@@ -77,6 +77,32 @@ cli.option('quality', {
|
|
|
77
77
|
})
|
|
78
78
|
cli.option('dry-run', { description: 'Show what would be processed without writing' })
|
|
79
79
|
cli.option('skip-original', { description: 'Skip the original (non-resized) compressed image' })
|
|
80
|
+
cli.option('preprocess', {
|
|
81
|
+
short: 'P',
|
|
82
|
+
value: '<ops>',
|
|
83
|
+
description: 'Preprocess operations (e.g. blur:20,grayscale,sharpen:1.5)',
|
|
84
|
+
callback: (val) => {
|
|
85
|
+
const PRIMARY_PARAM = {
|
|
86
|
+
blur: 'sigma',
|
|
87
|
+
sharpen: 'sigma',
|
|
88
|
+
gamma: 'value',
|
|
89
|
+
rotate: 'angle',
|
|
90
|
+
tint: 'color'
|
|
91
|
+
}
|
|
92
|
+
return val.split(',').map(op => {
|
|
93
|
+
const [type, ...args] = op.trim().split(':')
|
|
94
|
+
const params = { type }
|
|
95
|
+
if (args.length === 1) {
|
|
96
|
+
const key = PRIMARY_PARAM[type]
|
|
97
|
+
if (key) {
|
|
98
|
+
const num = Number(args[0])
|
|
99
|
+
params[key] = isNaN(num) ? args[0] : num
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return params
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
})
|
|
80
106
|
|
|
81
107
|
cli.line('')
|
|
82
108
|
cli.line('Quick mode (no config file needed):')
|
|
@@ -97,6 +123,7 @@ try {
|
|
|
97
123
|
const cliFormat = flags.format
|
|
98
124
|
const cliQuality = flags.quality
|
|
99
125
|
const skipOriginal = flags['skip-original']
|
|
126
|
+
const cliPreprocess = flags.preprocess
|
|
100
127
|
|
|
101
128
|
if (quiet) setQuiet(true)
|
|
102
129
|
|
|
@@ -138,6 +165,7 @@ try {
|
|
|
138
165
|
if (cliFormat !== null) raw.format = cliFormat
|
|
139
166
|
if (cliQuality != null) raw.quality = cliQuality
|
|
140
167
|
if (skipOriginal) raw.skipOriginal = true
|
|
168
|
+
if (cliPreprocess) raw.preprocessors = [{ name: 'preprocessed', operations: cliPreprocess }]
|
|
141
169
|
config = validateConfig(raw)
|
|
142
170
|
} else {
|
|
143
171
|
config = loadConfig(configPath)
|
|
@@ -153,6 +181,10 @@ try {
|
|
|
153
181
|
}
|
|
154
182
|
}
|
|
155
183
|
if (skipOriginal) config.skipOriginal = true
|
|
184
|
+
if (cliPreprocess) {
|
|
185
|
+
config.preprocessors = config.preprocessors || []
|
|
186
|
+
config.preprocessors.push({ name: 'preprocessed', operations: cliPreprocess })
|
|
187
|
+
}
|
|
156
188
|
}
|
|
157
189
|
const processor = new ImageProcessor(config)
|
|
158
190
|
|