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 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
- const formatIncludesSmart = config.format === 'smart' ||
119
- (Array.isArray(config.format) && config.format.includes('smart'))
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
- let effectiveSizes = config.sizes
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 = path.join(config.in, config.include)
9
+ const pattern = globJoin(config.in, config.include)
6
10
  const ignore = [
7
- ...config.exclude.map(e => path.join(config.in, e)),
11
+ ...config.exclude.map(e => globJoin(config.in, e)),
8
12
  // SVG and GIF have dedicated pipelines — exclude from raster discovery
9
- path.join(config.in, '**/*.svg'),
10
- path.join(config.in, '**/*.gif')
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 = path.join(config.in, '**/*.svg')
17
- const ignore = config.exclude.map(e => path.join(config.in, 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 = path.join(config.in, '**/*.gif')
23
- const ignore = config.exclude.map(e => path.join(config.in, 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, rawData, formatConfig, qualityConfig) {
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
- sharp(rawData)
83
+ toSharp()
86
84
  .toFormat('jpeg', { quality: getQuality('jpg', qualityConfig) })
87
85
  .toBuffer(),
88
- sharp(rawData)
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
- // Filter sizes by upscale constraints
108
- const eligibleSizes = filterByUpscale(
109
- job.effectiveSizes, job.sourceWidth, job.sourceHeight
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
- const pipeline = sharpOpts ? sharp(inputPath).rotate().resize(sharpOpts) : sharp(inputPath).rotate()
118
- const resizedBuffer = await pipeline.toBuffer({ resolveWithObject: true })
119
- const actualWidth = resizedBuffer.info.width
120
- const actualHeight = resizedBuffer.info.height
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 = job.parsed.name
242
+ baseName = namePrefix
129
243
  } else if (sizeDef.name) {
130
- baseName = `${job.parsed.name}-${sizeDef.name}-${actualWidth}w`
244
+ baseName = `${namePrefix}-${sizeDef.name}-${actualWidth}w`
131
245
  } else {
132
- baseName = `${job.parsed.name}-${actualWidth}w`
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, resizedBuffer.data,
138
- this.config.format, this.config.quality
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
- sharp(resizedBuffer.data), targetExt, this.config.quality
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
- // Clean up stale outputs from previous runs of this source file
174
- const newPaths = new Set(outputs.map(o => o.path))
175
- for (const prev of previousOutputs) {
176
- const prevPath = typeof prev === 'string' ? prev : prev.path
177
- if (!newPaths.has(prevPath)) {
178
- const fullPath = path.join(this.config.out, prevPath)
179
- if (fs.existsSync(fullPath)) {
180
- fs.unlinkSync(fullPath)
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
- this.cache.setEntry(relativePath, {
186
- mtime: job.mtime,
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
- this.stats.processed++
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
- const relativePath = path.relative(this.config.in, filePath)
220
- const outputs = this.cache.getOutputs(relativePath)
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
- this.cache.setEntry(relativePath, {
279
- mtime: stat.mtimeMs,
280
- size: stat.size,
281
- outputs: [{ path: result.relativePath, width: result.width, height: result.height }]
282
- })
283
- this.stats.processed++
284
- this.stats.variants++
285
- this.stats.bytes += result.outputSize
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 (this._isSvg(filePath)) {
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "poops-images",
3
3
  "description": "CLI tool for preparing images for the web.",
4
- "version": "1.0.0",
4
+ "version": "1.1.0",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "lib/processor.js",
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