poops-images 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Stamat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,598 @@
1
+ # poops-images 💩📸
2
+
3
+ CLI tool for preparing images for the web.
4
+
5
+ Features:
6
+
7
+ - Compresses, generates size variants and crops
8
+ - Based on: `sharp` and `svgo`
9
+ - WordPress-like notation for resizing and cropping with 9-position anchor grid
10
+ - Supported input formats: JPEG, PNG, TIFF, WebP, HEIC, HEIF, SVG, GIF
11
+ - HEIC/HEIF → JPEG (opaque) or PNG (transparent)
12
+ - TIFF → JPEG (opaque) or PNG (transparent)
13
+ - SVG → SVG minified with SVGO, no crops
14
+ - GIF (static) → JPEG (opaque) or PNG (transparent), resized and cropped like other raster images
15
+ - GIF (animated) → copied as-is, no compression, no crops
16
+ - Smart format selection — compares JPEG vs WebP, keeps whichever is smaller
17
+ - Transparency detection — auto-converts opaque PNGs and GIFs to JPEG
18
+ - Never upscales — skips sizes larger than the source
19
+ - Watch mode with incremental processing
20
+ - Configurable concurrency for parallel processing
21
+ - Keeps track with cache
22
+ - Extracts EXIF metadata (camera, lens, GPS, exposure) and stores it in the cache
23
+ - Cache file tracks source dimensions, output dimensions, and generated variants
24
+
25
+ ## Why
26
+
27
+ Built cause I hate opening Pixelmator Pro and ImageOptim both, I want to be able to convert the format and optimize the image in one go, regardless of the source format. Also **sometimes JPEG is lighter then WebP** and then I have to inspect it to decide which one I'll keep and so on... And you need to optimize images for the web.
28
+
29
+ And let me ask you this: What happens when you have to create a `srcset`!? Make the image responsive? You are responsible, right? Right?
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ npm install poops-images
35
+ ```
36
+
37
+ ## CLI
38
+
39
+ ### Quick examples
40
+
41
+ No config file needed — just pass flags:
42
+
43
+ ```bash
44
+ # Compress a single image (output defaults to current dir)
45
+ npx poops-images photo.jpg
46
+
47
+ # Specify input and output
48
+ npx poops-images --in src/images --out dist/images
49
+
50
+ # Convert to webp
51
+ npx poops-images --format webp --in photo.jpg --out dist/images
52
+
53
+ # Convert to webp at lower quality
54
+ npx poops-images --in photo.jpg --out dist/images --format webp --quality 60
55
+
56
+ # Process a directory with multiple size variants
57
+ npx poops-images src/images --out dist/images --widths 300,768,1024
58
+
59
+ # Multiple formats + per-format quality
60
+ npx poops-images --in src/images --out dist/images --widths 300,768,1024 --format webp,avif --quality webp:70,avif:50
61
+ ```
62
+
63
+ ### Options
64
+
65
+ ```
66
+ Usage: poops-images [input] [options]
67
+
68
+ -i, --in <path> Input directory or file path (default: .)
69
+ -o, --out <path> Output directory (default: .)
70
+ -s, --widths <list> Comma-separated widths (e.g. 300,768,1024)
71
+ -F, --format <format> Output format(s): smart, webp, avif, or comma-separated (e.g. smart,avif)
72
+ -Q, --quality <value> Quality 1-100 (all formats) or per-format (e.g. webp:60,avif:40)
73
+ --skip-original Skip the original (non-resized) compressed image
74
+ -c, --config <path> Config file path (default: poops-images.json)
75
+ -b, --build Process all images and exit (default)
76
+ -w, --watch Watch for changes and process incrementally
77
+ -f, --force Ignore cache, regenerate everything
78
+ --dry-run Show what would be processed without writing
79
+ -q, --quiet Suppress progress output
80
+ -v, --version Show version
81
+ -h, --help Show help
82
+ ```
83
+
84
+ The first positional argument is treated as the input path:
85
+
86
+ ```bash
87
+ npx poops-images photo.jpg # same as --in photo.jpg
88
+ npx poops-images src/images --out dist # same as --in src/images --out dist
89
+ npx poops-images -c my-config.json --out /tmp/resized # config file + override output dir
90
+ ```
91
+
92
+ ### Config file
93
+
94
+ For repeatable setups, create a `poops-images.json` in your project root:
95
+
96
+ ```json
97
+ {
98
+ "in": "src/images",
99
+ "out": "dist/static/images",
100
+ "sizes": [
101
+ { "name": "thumbnail", "width": 150, "height": 150, "crop": true },
102
+ { "name": "medium", "width": 300, "height": 300 },
103
+ { "name": "large", "width": 1024, "height": 1024 }
104
+ ]
105
+ }
106
+ ```
107
+
108
+ ```bash
109
+ npx poops-images
110
+ ```
111
+
112
+ The config file is resolved in order:
113
+
114
+ 1. Explicit path via `-c`
115
+ 2. `poops-images.json` in the working directory
116
+ 3. `images` key inside `poops.json`
117
+ 4. `images` key inside `💩.json`
118
+
119
+ #### Full config example
120
+
121
+ ```json
122
+ {
123
+ "in": "src/images",
124
+ "out": "dist/static/images",
125
+ "sizes": [
126
+ { "name": "thumbnail", "width": 150, "height": 150, "crop": true },
127
+ { "name": "medium", "width": 300, "height": 300 },
128
+ { "name": "medium_large", "width": 768, "height": 0 },
129
+ { "name": "large", "width": 1024, "height": 1024 },
130
+ { "name": "hero", "width": 1920, "height": 600, "crop": ["center", "top"] },
131
+ {
132
+ "name": "card",
133
+ "width": 400,
134
+ "height": 300,
135
+ "crop": ["center", "center"]
136
+ }
137
+ ],
138
+ "format": ["webp", "avif"],
139
+ "quality": {
140
+ "jpg": 82,
141
+ "webp": 80,
142
+ "avif": 60,
143
+ "png": 90
144
+ },
145
+ "include": "**/*.{jpg,jpeg,png,tiff,tif,webp,heic,heif}",
146
+ "exclude": [],
147
+ "concurrency": 4,
148
+ "skipOriginal": false,
149
+ "cache": true
150
+ }
151
+ ```
152
+
153
+ ### Config options
154
+
155
+ | Field | Type | Default | Description |
156
+ | -------------- | ---------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
157
+ | `in` | `string` | `"."` | Source directory |
158
+ | `out` | `string` | `"."` | Output directory |
159
+ | `sizes` | `array` | `[]` | Size definitions (see below). Empty = conversion-only |
160
+ | `format` | `false\|string\|array` | `false` | Output format(s). `false` = normalize to web-ready, `"smart"` = smallest of jpg/webp, or explicit format(s) like `"webp"` or `["webp", "avif"]` |
161
+ | `quality` | `number\|object` | `{jpg: 82, webp: 80, avif: 60, png: 90}` | Quality 1-100 for all formats, or per-format object |
162
+ | `skipOriginal` | `boolean` | `false` | Skip the original (non-resized) compressed image |
163
+ | `include` | `string` | `"**/*.{jpg,jpeg,png,tiff,tif,webp,heic,heif}"` | Glob pattern for source images |
164
+ | `exclude` | `array` | `[]` | Glob patterns to exclude |
165
+ | `concurrency` | `number` | `4` | Max parallel image operations |
166
+ | `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
+
168
+ ### Size definitions
169
+
170
+ The config API mirrors WordPress's `add_image_size(name, width, height, crop)`.
171
+
172
+ | Field | Type | Default | Description |
173
+ | -------- | ------------- | ------- | ------------------------------------------------------------------------------------------- |
174
+ | `name` | `string` | `""` | Size identifier, appended to filename. Optional — omit or leave empty for width-only naming |
175
+ | `width` | `number` | `0` | Target width in px. `0` = scale by height only |
176
+ | `height` | `number` | `0` | Target height in px. `0` = scale by width only |
177
+ | `crop` | `bool\|[x,y]` | `false` | Crop mode |
178
+
179
+ When both `width` and `height` are `0` (or omitted), the image is processed at its original dimensions — useful for format conversion without resizing.
180
+
181
+ #### Crop modes
182
+
183
+ **`false`** — Soft crop. Proportional resize to fit within the bounding box. No content is lost. Output dimensions may differ from config.
184
+
185
+ **`true`** — Hard crop, centered. Exact dimensions, cropped from center.
186
+
187
+ **`["x", "y"]`** — Hard crop with anchor. 9 possible positions:
188
+
189
+ | | `"left"` | `"center"` | `"right"` |
190
+ | -------------- | -------------------- | ---------------------- | --------------------- |
191
+ | **`"top"`** | `["left", "top"]` | `["center", "top"]` | `["right", "top"]` |
192
+ | **`"center"`** | `["left", "center"]` | `["center", "center"]` | `["right", "center"]` |
193
+ | **`"bottom"`** | `["left", "bottom"]` | `["center", "bottom"]` | `["right", "bottom"]` |
194
+
195
+ #### Size examples
196
+
197
+ ```json
198
+ { "name": "medium_large", "width": 768, "height": 0 }
199
+ ```
200
+
201
+ 768px wide, height scaled proportionally. No cropping.
202
+
203
+ ```json
204
+ { "name": "thumb", "width": 150, "height": 150, "crop": true }
205
+ ```
206
+
207
+ Always 150x150, cropped from center.
208
+
209
+ ```json
210
+ { "name": "hero", "width": 1920, "height": 600, "crop": ["center", "top"] }
211
+ ```
212
+
213
+ Always 1920x600, anchored to top-center (preserves sky/header area).
214
+
215
+ ## API
216
+
217
+ ```javascript
218
+ import ImageProcessor from "poops-images";
219
+
220
+ // Minimal — compress images at original size
221
+ const processor = new ImageProcessor({
222
+ in: "src/images",
223
+ out: "dist/images",
224
+ });
225
+ await processor.processAll();
226
+
227
+ // With sizes and format conversion
228
+ const processor2 = new ImageProcessor({
229
+ in: "src/images",
230
+ out: "dist/images",
231
+ sizes: [
232
+ { name: "thumb", width: 150, height: 150, crop: true },
233
+ { name: "large", width: 1024, height: 0 },
234
+ ],
235
+ format: "webp",
236
+ quality: { jpg: 85, webp: 80 },
237
+ });
238
+
239
+ const stats = await processor2.processAll();
240
+ // { processed: 12, variants: 48, skipped: 0, bytes: 245760, elapsed: 2300 }
241
+
242
+ // Force reprocess (ignore cache)
243
+ await processor2.processAll({ force: true });
244
+
245
+ // Dry run (log what would be processed)
246
+ await processor2.processAll({ dryRun: true });
247
+
248
+ // Watch mode
249
+ processor2.watch();
250
+
251
+ // Stop watching
252
+ processor2.stopWatch();
253
+ ```
254
+
255
+ The `ImageProcessor` constructor accepts the same config object as the JSON config file. See [Config options](#config-options) and [Size definitions](#size-definitions) above.
256
+
257
+ ## Features
258
+
259
+ ### Output naming
260
+
261
+ When `name` is provided:
262
+
263
+ ```
264
+ {originalName}-{sizeName}-{actualWidth}w.{ext}
265
+ ```
266
+
267
+ When `name` is omitted or empty:
268
+
269
+ ```
270
+ {originalName}-{actualWidth}w.{ext}
271
+ ```
272
+
273
+ When processing at original size (no resize):
274
+
275
+ ```
276
+ {originalName}.{ext}
277
+ ```
278
+
279
+ The width in the filename is the **actual** output width after resize, not the configured target. This matters for soft crops where the output may be smaller than the target due to aspect ratio.
280
+
281
+ #### Example output
282
+
283
+ Given `src/images/photo.jpg` (2000x1500) with `format: ["webp", "avif"]` and these sizes:
284
+
285
+ ```json
286
+ [
287
+ { "name": "medium", "width": 300, "height": 300 },
288
+ { "name": "large", "width": 1024, "height": 1024 },
289
+ { "width": 768 }
290
+ ]
291
+ ```
292
+
293
+ Produces:
294
+
295
+ ```
296
+ dist/static/images/photo.webp # original, re-encoded
297
+ dist/static/images/photo.avif # original, re-encoded
298
+ dist/static/images/photo-medium-300w.webp
299
+ dist/static/images/photo-medium-300w.avif
300
+ dist/static/images/photo-large-1024w.webp
301
+ dist/static/images/photo-large-1024w.avif
302
+ dist/static/images/photo-768w.webp
303
+ dist/static/images/photo-768w.avif
304
+ ```
305
+
306
+ The original (non-resized) image is always included, compressed and converted to the target format(s). Use `--skip-original` or `"skipOriginal": true` to omit it.
307
+
308
+ Without `format` set (default mode), only one file per size is produced in the normalized web format (e.g. jpg stays jpg, opaque PNG becomes jpg).
309
+
310
+ ### Directory structure
311
+
312
+ Directory structure is preserved from source to output:
313
+
314
+ ```
315
+ src/images/gallery/photo.jpg
316
+ → dist/static/images/gallery/photo.jpg (original, compressed)
317
+ → dist/static/images/gallery/photo-medium-300w.jpg (resized variant)
318
+ ```
319
+
320
+ ### No upscaling
321
+
322
+ Images are never upscaled. If the source is smaller than a target size:
323
+
324
+ - **Soft crop**: the size is skipped when the source is smaller than the target in both dimensions (sharp's `withoutEnlargement` handles the rest)
325
+ - **Hard crop**: the size is skipped when the source is smaller in either dimension
326
+
327
+ ### Format conversion
328
+
329
+ The `format` option controls exactly which output formats are produced per size. When not set, the tool normalizes to a web-ready format (opaque PNG/GIF becomes JPEG, TIFF/HEIC/HEIF becomes JPEG/PNG) and re-encodes.
330
+
331
+ | `format` value | Behavior | Outputs per size |
332
+ | ------------------- | ---------------------------------------------- | ---------------- |
333
+ | _(not set / false)_ | Normalize to web-ready format, re-encode | 1 |
334
+ | `"smart"` | Compare jpg vs webp, keep whichever is smaller | 1 |
335
+ | `"webp"` | Generate only webp | 1 |
336
+ | `["webp", "avif"]` | Generate exactly webp and avif | 2 |
337
+ | `["smart", "avif"]` | Smart pick (webp or jpg) + avif, deduped | 1-2 |
338
+
339
+ **Explicit formats** — generate exactly what you ask for, no size comparison:
340
+
341
+ ```bash
342
+ # Single format
343
+ npx poops-images --format webp
344
+ # photo-medium-300w.webp
345
+
346
+ # Multiple formats
347
+ npx poops-images --format webp,avif
348
+ # photo-medium-300w.webp
349
+ # photo-medium-300w.avif
350
+ ```
351
+
352
+ **`--format smart`** — for each variant, encodes both jpg and webp, keeps the smaller one. Transparent images always get webp. Smart never produces avif — combine with explicit formats if you want it:
353
+
354
+ ```bash
355
+ # Smart selection only
356
+ npx poops-images --format smart
357
+ # photo-medium-300w.webp (webp was smaller than jpg)
358
+
359
+ # Smart + explicit avif
360
+ npx poops-images --format smart,avif
361
+ # photo-medium-300w.webp (smart pick)
362
+ # photo-medium-300w.avif (explicit)
363
+ ```
364
+
365
+ In config:
366
+
367
+ ```json
368
+ { "format": "webp" }
369
+ ```
370
+
371
+ ```json
372
+ { "format": ["webp", "avif"] }
373
+ ```
374
+
375
+ ```json
376
+ { "format": ["smart", "avif"] }
377
+ ```
378
+
379
+ ### Transparency detection
380
+
381
+ When processing a PNG or static GIF, the tool checks whether any pixel has transparency (alpha < 255). If the image is fully opaque, it's converted to JPEG instead — typically 5-10x smaller with no quality loss.
382
+
383
+ Transparent images stay as PNG (or webp/avif when `format` is set).
384
+
385
+ ### EXIF metadata extraction
386
+
387
+ EXIF data is automatically extracted from JPEG and TIFF images and stored in the cache. The extracted fields are:
388
+
389
+ | Field | Description |
390
+ | ----------------- | ------------------------------------- |
391
+ | `make` | Camera manufacturer |
392
+ | `model` | Camera model |
393
+ | `orientation` | EXIF orientation tag (1-8) |
394
+ | `resolution` | `{ x, y }` DPI |
395
+ | `dateTime` | Original capture date |
396
+ | `offsetTime` | UTC offset string |
397
+ | `fNumber` | Aperture (e.g. `1.78`) |
398
+ | `exposure` | `{ value, formatted }` — e.g. `1/125` |
399
+ | `iso` | ISO speed |
400
+ | `focalLength` | Focal length in mm |
401
+ | `focalLength35mm` | 35mm equivalent focal length |
402
+ | `flash` | `true`/`false` — whether flash fired |
403
+ | `lensModel` | Lens identifier string |
404
+ | `software` | Processing software |
405
+ | `gps` | GPS block (see below) |
406
+
407
+ **GPS data** (when coordinates are present):
408
+
409
+ | Field | Description |
410
+ | --------------- | ----------------------------------------------------------- |
411
+ | `latitude` | `{ degrees, ref, decimal, formatted }` — both DMS and float |
412
+ | `longitude` | `{ degrees, ref, decimal, formatted }` — both DMS and float |
413
+ | `altitude` | `{ value, ref }` — meters above/below sea level |
414
+ | `direction` | Image direction in degrees |
415
+ | `speed` | `{ value, unit }` — km/h, mph, or knots |
416
+ | `dateTime` | Combined datestamp + timestamp as ISO 8601 UTC |
417
+ | `googleMapsUrl` | Direct link to coordinates on Google Maps |
418
+
419
+ This data is available in the cache file for downstream tools (e.g. nunjucks extensions) to generate image captions with camera info, location, etc.
420
+
421
+ ### SVG minification
422
+
423
+ SVG files are automatically discovered and minified with [SVGO](https://github.com/svg/svgo) (multipass). They're copied to the output directory with the same directory structure. No resize variants are generated.
424
+
425
+ ```
426
+ src/images/icons/logo.svg
427
+ → dist/static/images/icons/logo.svg (minified)
428
+ ```
429
+
430
+ ### GIF handling
431
+
432
+ **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
+
434
+ **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
+
436
+ ### Caching
437
+
438
+ 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.
439
+
440
+ ```json
441
+ {
442
+ "configHash": "a1b2c3...",
443
+ "entries": {
444
+ "photo.jpg": {
445
+ "mtime": 1709312400000,
446
+ "size": 2450000,
447
+ "width": 4032,
448
+ "height": 3024,
449
+ "exif": {
450
+ "make": "Apple",
451
+ "model": "iPhone 15 Pro",
452
+ "fNumber": 1.78,
453
+ "iso": 50,
454
+ "gps": {
455
+ "latitude": { "decimal": 48.8566, "formatted": "48° 51' 23.76\" N" },
456
+ "longitude": { "decimal": 2.3522, "formatted": "2° 21' 7.92\" E" },
457
+ "googleMapsUrl": "https://www.google.com/maps?q=48.8566,2.3522"
458
+ }
459
+ },
460
+ "outputs": [
461
+ { "path": "photo-thumb-150w.webp", "width": 150, "height": 112 },
462
+ { "path": "photo-large-1024w.webp", "width": 1024, "height": 768 }
463
+ ]
464
+ }
465
+ }
466
+ }
467
+ ```
468
+
469
+ **Skip logic:**
470
+
471
+ 1. `--force` — always reprocess
472
+ 2. Config hash changed (sizes/format/quality/skipOriginal differ) — reprocess everything
473
+ 3. Per file: skip if source mtime + size unchanged AND all expected outputs exist on disk
474
+ 4. On source deletion (watch mode): remove all generated variants
475
+
476
+ **Cache configuration:**
477
+
478
+ ```json
479
+ { "cache": true }
480
+ ```
481
+
482
+ Default. Cache file at `.poops-images-cache.json` in the output directory.
483
+
484
+ ```json
485
+ { "cache": false }
486
+ ```
487
+
488
+ Disable caching entirely. No cache file is read or written. Every build reprocesses all images. Watch mode still only processes the changed file (chokidar handles that).
489
+
490
+ ```json
491
+ { "cache": ".cache/images.json" }
492
+ ```
493
+
494
+ Custom cache path, relative to the output directory.
495
+
496
+ ```json
497
+ { "cache": "/tmp/poops-cache.json" }
498
+ ```
499
+
500
+ Absolute path, used as-is.
501
+
502
+ ## Poops Integration
503
+
504
+ Next to being a standalone tool, `poops-images` is designed to work with [poops](https://github.com/stamat/poops) SSG.
505
+
506
+ It generates responsive image variants that poops can consume via `discoverImageVariants()` for automatic `srcset` generation. Both the `srcset` filter and `image` extension use the naming convention `/^(.+)-(\d+)w\.([a-z0-9]+)$/` to discover variants.
507
+
508
+ ### Running together
509
+
510
+ ```bash
511
+ # Build once, then run poops
512
+ npx poops-images && npx poops
513
+
514
+ # Watch mode alongside poops
515
+ npx poops-images --watch & npx poops
516
+ ```
517
+
518
+ ### How it works
519
+
520
+ 1. **poops-images** generates variants from the images source directory to the static directory.
521
+ 2. Use either `image` extension to generate an image tag with `srcset` or `srcset` filter to generate `srcset` attribute for the image tag.
522
+ 3. They both call `discoverImageVariants(imagePath, outputDir)` which scans the output directory for matching files.
523
+ 4. The `srcset` attribute is constructed by the available width sizes options with `relativePathPrefix` appended by default.
524
+
525
+ ### Nunjucks usage
526
+
527
+ ```html
528
+ <!-- srcset filter -->
529
+ <img
530
+ src="/images/photo.jpg"
531
+ srcset="{{ 'images/photo.jpg' | srcset }}"
532
+ sizes="100vw"
533
+ alt="A photo"
534
+ />
535
+
536
+ <!-- image extension (generates complete <img> with srcset) -->
537
+ {% image "images/photo.jpg", "A photo" %}
538
+ ```
539
+
540
+ ### Config in poops.json
541
+
542
+ Instead of a separate `poops-images.json`, you can embed the config in your `poops.json`:
543
+
544
+ ```json
545
+ {
546
+ "markup": { "...": "..." },
547
+ "images": {
548
+ "in": "src/images",
549
+ "out": "dist/static/images",
550
+ "sizes": [
551
+ { "name": "thumb", "width": 300, "height": 300 },
552
+ { "width": 800 }
553
+ { "width": 1024 }
554
+ ]
555
+ }
556
+ }
557
+ ```
558
+
559
+ If you deploy GitHub Pages, do not run `poops-images` in the GitHub Actions to waste resources. Do this instead: Output the images into the `static` directory and then use poops `copy` functionality to move the static files into dist. Commit the static directory and build with Actions.
560
+
561
+ ## Comparison
562
+
563
+ | Feature | **poops-images** | [sharp-cli](https://github.com/vseventer/sharp-cli) | [responsive-images-generator](https://www.npmjs.com/package/responsive-images-generator) | [responsive-image-builder](https://www.npmjs.com/package/responsive-image-builder) | [@11ty/eleventy-img](https://www.11ty.dev/docs/plugins/image/) |
564
+ | ---------------------- | ---------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------- |
565
+ | Multiple size variants | Config array, all at once | One size per command | Config array | Config-driven | Config array |
566
+ | Output naming | `{name}-{sizeName}-{width}w.{ext}` | Manual | Custom suffix | Custom template | Hash-based |
567
+ | Crop modes | `false` / `true` / `[x,y]` (9 positions) | Via sharp flags | `crop: true` only (center) | Basic | None |
568
+ | WebP/AVIF conversion | Auto, per variant | Manual per command | Single format option | WebP only | WebP + AVIF |
569
+ | Smart format selection | `smart` picks smallest of jpg/webp | No | No | No | No |
570
+ | Transparency detection | Auto JPEG if opaque | No | No | No | No |
571
+ | SVG minification | SVGO built-in | No | No | No | SVG passthrough |
572
+ | GIF handling | Static: full pipeline; animated: copy | Process (loses animation) | No | No | Passthrough |
573
+ | Watch mode | Chokidar, incremental | No | No | No | Dev server integration |
574
+ | Caching | Manifest + mtime/size + config hash | No | No | Fingerprinting | In-memory + disk |
575
+ | Config file | JSON, poops.json fallback | CLI flags only | JS API only | JSON | JS API (Eleventy-coupled) |
576
+ | CLI | Standalone | Standalone | No (API only) | No (API only) | No (Eleventy plugin) |
577
+ | Concurrency control | Configurable worker count | No | No | Multi-threaded | Yes |
578
+ | SSG coupling | Designed for poops, usable standalone | None | None | None | Tightly coupled to Eleventy |
579
+ | Maintained | Active | Last publish 2022 | Last publish 2019 | Last publish 2018 | Active |
580
+
581
+ ### Key differentiators
582
+
583
+ - **Smart format selection** — `smart` mode compares jpg vs webp and keeps whichever is smaller. Others write all formats blindly, sometimes producing larger files.
584
+ - **Transparency detection** — auto-converts opaque PNGs and static GIFs to JPEG. No other tool does this.
585
+ - **WordPress-style crop API** — full 9-position anchor grid (`["left", "top"]`), not just center crop.
586
+ - **Integrated SVG pipeline** — SVGO minification in the same tool. Others require a separate build step.
587
+ - **Convention-based naming** — `{name}-{sizeName}-{width}w.{ext}` is purpose-built for poops' `discoverImageVariants()` srcset generation.
588
+ - **Standalone CLI + API** — works with any build system or none at all, unlike Eleventy-coupled or webpack-coupled alternatives.
589
+
590
+ ## License
591
+
592
+ MIT
593
+
594
+ ## P.S.
595
+
596
+ All my projects are 💩... Hopefully useful 💩. With this AI boost I could call it diarrhea. But I'm not going to be that rude. 🤣
597
+
598
+ Made with ❤️ by your's truly, @stamat.