avifify 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 +21 -0
- package/README.md +220 -0
- package/bin/avifify.js +16 -0
- package/package.json +48 -0
- package/src/cli.js +251 -0
- package/src/config.js +128 -0
- package/src/converter.js +189 -0
- package/src/hooks.js +161 -0
- package/src/index.js +52 -0
- package/src/logger.js +125 -0
- package/src/scanner.js +127 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024
|
|
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,220 @@
|
|
|
1
|
+
# avifify
|
|
2
|
+
|
|
3
|
+
Production-ready CLI tool to convert images to AVIF format. Run on demand, in CI/CD pipelines, or as a git pre-push hook.
|
|
4
|
+
|
|
5
|
+
AVIF offers **30-60% better compression** than JPEG and **20-40% better** than WebP at equivalent visual quality. This tool makes adoption painless.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Install
|
|
11
|
+
npm install avifify --save-dev
|
|
12
|
+
|
|
13
|
+
# Convert all images in the current directory
|
|
14
|
+
npx avifify
|
|
15
|
+
|
|
16
|
+
# Convert specific paths
|
|
17
|
+
npx avifify src/assets "public/images/**/*.png"
|
|
18
|
+
|
|
19
|
+
# Preview without changing anything
|
|
20
|
+
npx avifify --dry-run --verbose
|
|
21
|
+
|
|
22
|
+
# Install as a git pre-push hook
|
|
23
|
+
npx avifify hook install
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- **Fast** — parallel conversion using [sharp](https://sharp.pixelplumbing.com/) (libvips)
|
|
29
|
+
- **Smart defaults** — quality 50, skips if AVIF is larger, auto-concurrency
|
|
30
|
+
- **Git hook ready** — install as `pre-push` or `pre-commit` hook in one command
|
|
31
|
+
- **Pipeline friendly** — `--json` output, proper exit codes, non-TTY aware
|
|
32
|
+
- **Configurable** — `.avififyrc.json`, `package.json`, or CLI flags
|
|
33
|
+
- **Safe** — dry-run mode, skip-larger protection, preserves originals on demand
|
|
34
|
+
- **Programmatic API** — import and use in Node.js scripts or build tools
|
|
35
|
+
|
|
36
|
+
## Supported Input Formats
|
|
37
|
+
|
|
38
|
+
JPEG, PNG, WebP, TIFF, GIF, BMP, HEIF/HEIC
|
|
39
|
+
|
|
40
|
+
> SVG is excluded by default (vector format — converting to AVIF is lossy and almost never desirable). You can force it by passing SVG files explicitly.
|
|
41
|
+
|
|
42
|
+
## CLI Reference
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
avifify [options] [paths...]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Encoding Options
|
|
49
|
+
|
|
50
|
+
| Flag | Default | Description |
|
|
51
|
+
|------|---------|-------------|
|
|
52
|
+
| `-q, --quality <n>` | `50` | AVIF quality (1-100, lower = smaller) |
|
|
53
|
+
| `-s, --speed <n>` | `5` | Encoding speed (0-8, lower = better compression) |
|
|
54
|
+
| `--lossless` | `false` | Lossless encoding |
|
|
55
|
+
| `--chroma <mode>` | `4:2:0` | Chroma subsampling (`4:2:0` or `4:4:4`) |
|
|
56
|
+
|
|
57
|
+
### Output Options
|
|
58
|
+
|
|
59
|
+
| Flag | Default | Description |
|
|
60
|
+
|------|---------|-------------|
|
|
61
|
+
| `-o, --out-dir <dir>` | in-place | Write AVIF files to a separate directory |
|
|
62
|
+
| `--suffix <str>` | none | Suffix before extension (e.g. `--suffix .min` → `photo.min.avif`) |
|
|
63
|
+
| `--preserve` | `false` | Keep original files after conversion |
|
|
64
|
+
|
|
65
|
+
### Behavior Options
|
|
66
|
+
|
|
67
|
+
| Flag | Default | Description |
|
|
68
|
+
|------|---------|-------------|
|
|
69
|
+
| `--skip-larger` | `true` | Don't keep AVIF if it's bigger than the original |
|
|
70
|
+
| `--no-skip-larger` | | Always keep AVIF output |
|
|
71
|
+
| `--skip-existing` | `false` | Skip conversion if `.avif` already exists |
|
|
72
|
+
| `--staged-only` | `false` | Only process git-staged images |
|
|
73
|
+
| `--changed-since-push` | `false` | Only process images changed since last push |
|
|
74
|
+
| `-c, --concurrency <n>` | CPU-1 | Number of parallel conversions |
|
|
75
|
+
| `-n, --dry-run` | `false` | Preview changes without writing files |
|
|
76
|
+
|
|
77
|
+
### Output Options
|
|
78
|
+
|
|
79
|
+
| Flag | Description |
|
|
80
|
+
|------|-------------|
|
|
81
|
+
| `--json` | JSON Lines output (for piping to `jq` etc.) |
|
|
82
|
+
| `-v, --verbose` | Detailed per-file progress |
|
|
83
|
+
| `--debug` | Everything (very noisy) |
|
|
84
|
+
| `--silent` | Suppress all output |
|
|
85
|
+
|
|
86
|
+
### Hook Commands
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
avifify hook install # Install pre-push hook
|
|
90
|
+
avifify hook install --hook-type pre-commit # Use pre-commit instead
|
|
91
|
+
avifify hook uninstall # Remove the hook
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Exit Codes
|
|
95
|
+
|
|
96
|
+
| Code | Meaning |
|
|
97
|
+
|------|---------|
|
|
98
|
+
| `0` | All files converted (or nothing to do) |
|
|
99
|
+
| `1` | Some files failed to convert |
|
|
100
|
+
| `2` | Bad arguments or config error |
|
|
101
|
+
|
|
102
|
+
## Configuration
|
|
103
|
+
|
|
104
|
+
Config is resolved with this precedence: **CLI flags > `.avififyrc.json` > `package.json` > defaults**
|
|
105
|
+
|
|
106
|
+
### `.avififyrc.json`
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"quality": 40,
|
|
111
|
+
"speed": 4,
|
|
112
|
+
"include": ["src/images/**"],
|
|
113
|
+
"exclude": ["**/thumbnails/**"],
|
|
114
|
+
"preserveOriginal": true,
|
|
115
|
+
"skipLarger": true
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### `package.json`
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
{
|
|
123
|
+
"avifify": {
|
|
124
|
+
"quality": 40,
|
|
125
|
+
"include": ["assets/**"]
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Git Hook Integration
|
|
131
|
+
|
|
132
|
+
### Standalone
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
npx avifify hook install
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
This creates a `pre-push` hook that:
|
|
139
|
+
1. Scans staged image files
|
|
140
|
+
2. Converts them to AVIF
|
|
141
|
+
3. Auto-stages the new `.avif` files
|
|
142
|
+
4. Blocks the push if conversion fails
|
|
143
|
+
|
|
144
|
+
### With Husky
|
|
145
|
+
|
|
146
|
+
If you're already using Husky, `avifify` detects the `.husky/` directory and installs there:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
npx avifify hook install
|
|
150
|
+
# Creates .husky/pre-push with avifify block
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Or add it manually to an existing Husky hook:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
# .husky/pre-push
|
|
157
|
+
npx avifify --staged-only
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### CI/CD Pipeline
|
|
161
|
+
|
|
162
|
+
```yaml
|
|
163
|
+
# GitHub Actions example
|
|
164
|
+
- name: Convert images to AVIF
|
|
165
|
+
run: npx avifify --json --verbose
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
```yaml
|
|
169
|
+
# GitLab CI example
|
|
170
|
+
optimize-images:
|
|
171
|
+
script:
|
|
172
|
+
- npx avifify --json | tee avifify-report.json
|
|
173
|
+
artifacts:
|
|
174
|
+
reports:
|
|
175
|
+
dotenv: avifify-report.json
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Programmatic API
|
|
179
|
+
|
|
180
|
+
```js
|
|
181
|
+
import { avifify, convertFile } from 'avifify';
|
|
182
|
+
|
|
183
|
+
// Batch convert
|
|
184
|
+
const results = await avifify({
|
|
185
|
+
quality: 40,
|
|
186
|
+
paths: ['src/images'],
|
|
187
|
+
preserveOriginal: true,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
console.log(`Converted ${results.filter(r => r.status === 'converted').length} files`);
|
|
191
|
+
|
|
192
|
+
// Single file
|
|
193
|
+
const result = await convertFile('photo.jpg', { quality: 50 });
|
|
194
|
+
console.log(result);
|
|
195
|
+
// { file: 'photo.jpg', output: 'photo.avif', status: 'converted', savedBytes: 123456, ... }
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Why AVIF?
|
|
199
|
+
|
|
200
|
+
| Format | Lossy Compression | Browser Support (2024) |
|
|
201
|
+
|--------|-------------------|----------------------|
|
|
202
|
+
| JPEG | Baseline | 100% |
|
|
203
|
+
| WebP | ~30% better than JPEG | 97% |
|
|
204
|
+
| **AVIF** | **~50% better than JPEG** | **95%+** |
|
|
205
|
+
|
|
206
|
+
AVIF supports transparency, HDR, wide color gamuts, and both lossy and lossless modes. It's the clear successor for web images.
|
|
207
|
+
|
|
208
|
+
## Quality Guide
|
|
209
|
+
|
|
210
|
+
| Quality | Use Case | Typical Savings |
|
|
211
|
+
|---------|----------|-----------------|
|
|
212
|
+
| 30-40 | Thumbnails, backgrounds | 70-80% smaller |
|
|
213
|
+
| 50 | General web images (default) | 50-65% smaller |
|
|
214
|
+
| 60-70 | Photography, hero images | 40-55% smaller |
|
|
215
|
+
| 80-90 | High-fidelity, print-ready | 20-35% smaller |
|
|
216
|
+
| 100 / `--lossless` | Archival, pixel-perfect | 10-30% smaller |
|
|
217
|
+
|
|
218
|
+
## License
|
|
219
|
+
|
|
220
|
+
MIT
|
package/bin/avifify.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* avifify - Convert images to AVIF format
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* avifify [options] [paths...]
|
|
8
|
+
* avifify hook install
|
|
9
|
+
* avifify hook uninstall
|
|
10
|
+
*
|
|
11
|
+
* Run `avifify --help` for full usage info.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { run } from '../src/cli.js';
|
|
15
|
+
|
|
16
|
+
run(process.argv.slice(2));
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "avifify",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Production-ready CLI tool to convert images to AVIF. Run standalone, in CI/CD pipelines, or as a git pre-push hook.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"avif",
|
|
7
|
+
"image",
|
|
8
|
+
"converter",
|
|
9
|
+
"compression",
|
|
10
|
+
"optimize",
|
|
11
|
+
"git-hook",
|
|
12
|
+
"pre-push",
|
|
13
|
+
"cli",
|
|
14
|
+
"pipeline",
|
|
15
|
+
"sharp"
|
|
16
|
+
],
|
|
17
|
+
"author": "is-harshul",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"type": "module",
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18.0.0"
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"avifify": "./bin/avifify.js"
|
|
25
|
+
},
|
|
26
|
+
"main": "./src/index.js",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./src/index.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"bin/",
|
|
32
|
+
"src/",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "node --test 'tests/**/*.test.js'",
|
|
38
|
+
"lint": "echo 'Add your linter here'",
|
|
39
|
+
"prepublishOnly": "npm test"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"sharp": "^0.33.0",
|
|
43
|
+
"fast-glob": "^3.3.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"c8": "^8.0.0"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI entry point — parses args, resolves config, orchestrates conversion.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { resolveConfig } from './config.js';
|
|
6
|
+
import { scanFiles, getChangedSinceLastPush } from './scanner.js';
|
|
7
|
+
import { convertBatch } from './converter.js';
|
|
8
|
+
import { createLogger } from './logger.js';
|
|
9
|
+
import { installHook, uninstallHook } from './hooks.js';
|
|
10
|
+
|
|
11
|
+
const VERSION = '1.0.0';
|
|
12
|
+
|
|
13
|
+
const HELP = `
|
|
14
|
+
avifify v${VERSION}
|
|
15
|
+
Convert images to AVIF — on demand, in CI/CD, or as a git hook.
|
|
16
|
+
|
|
17
|
+
USAGE
|
|
18
|
+
avifify [options] [paths...] Convert matching images to AVIF
|
|
19
|
+
avifify hook install [--hook-type] Install git hook (default: pre-push)
|
|
20
|
+
avifify hook uninstall Remove git hook
|
|
21
|
+
|
|
22
|
+
PATHS
|
|
23
|
+
Provide files, directories, or glob patterns.
|
|
24
|
+
Defaults to scanning the current directory using include/exclude config.
|
|
25
|
+
|
|
26
|
+
OPTIONS
|
|
27
|
+
-q, --quality <n> AVIF quality 1-100 (default: 50)
|
|
28
|
+
-s, --speed <n> Encoding speed 0-8 (default: 5)
|
|
29
|
+
--lossless Enable lossless encoding
|
|
30
|
+
--chroma <mode> Chroma subsampling 4:2:0|4:4:4 (default: 4:2:0)
|
|
31
|
+
|
|
32
|
+
-o, --out-dir <dir> Output directory (default: in-place)
|
|
33
|
+
--suffix <str> Add suffix before .avif (e.g. ".min")
|
|
34
|
+
--preserve Keep original files (default: delete)
|
|
35
|
+
|
|
36
|
+
--skip-larger Don't keep AVIF if it's bigger (default: true)
|
|
37
|
+
--no-skip-larger Always keep the AVIF output
|
|
38
|
+
--skip-existing Skip if .avif file exists
|
|
39
|
+
--staged-only Only process git-staged images
|
|
40
|
+
--changed-since-push Only process images changed since last push
|
|
41
|
+
|
|
42
|
+
-c, --concurrency <n> Parallel conversions (default: CPU-1)
|
|
43
|
+
-n, --dry-run Preview changes without writing
|
|
44
|
+
--json Output JSON lines (for pipelines)
|
|
45
|
+
|
|
46
|
+
-v, --verbose Show detailed progress
|
|
47
|
+
--debug Show everything (very noisy)
|
|
48
|
+
--silent Suppress all output
|
|
49
|
+
|
|
50
|
+
--hook-type <type> Hook type: pre-push|pre-commit (default: pre-push)
|
|
51
|
+
-h, --help Show this help
|
|
52
|
+
--version Show version
|
|
53
|
+
|
|
54
|
+
CONFIG FILES
|
|
55
|
+
.avififyrc or .avififyrc.json in project root:
|
|
56
|
+
{ "quality": 40, "include": ["src/images/**"], "preserve": true }
|
|
57
|
+
|
|
58
|
+
Or in package.json:
|
|
59
|
+
{ "avifify": { "quality": 40 } }
|
|
60
|
+
|
|
61
|
+
CLI args override config files. Config files override defaults.
|
|
62
|
+
|
|
63
|
+
EXAMPLES
|
|
64
|
+
avifify # Convert all images in current dir
|
|
65
|
+
avifify src/assets # Convert images in specific directory
|
|
66
|
+
avifify "**/*.png" # Convert only PNGs
|
|
67
|
+
avifify -q 30 --preserve # Low quality, keep originals
|
|
68
|
+
avifify --dry-run --verbose # Preview what would happen
|
|
69
|
+
avifify --json | jq # Pipe JSON output to jq
|
|
70
|
+
avifify hook install # Install pre-push git hook
|
|
71
|
+
avifify --changed-since-push # Only convert images changed since last push
|
|
72
|
+
|
|
73
|
+
EXIT CODES
|
|
74
|
+
0 All files converted successfully (or nothing to do)
|
|
75
|
+
1 Some files failed to convert
|
|
76
|
+
2 Invalid arguments or configuration error
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Parse CLI arguments (minimal parser, no dependencies).
|
|
81
|
+
*/
|
|
82
|
+
function parseArgs(argv) {
|
|
83
|
+
const args = { _: [] };
|
|
84
|
+
let i = 0;
|
|
85
|
+
|
|
86
|
+
while (i < argv.length) {
|
|
87
|
+
const arg = argv[i];
|
|
88
|
+
|
|
89
|
+
// Flags with values
|
|
90
|
+
const withValue = {
|
|
91
|
+
'-q': 'quality', '--quality': 'quality',
|
|
92
|
+
'-s': 'speed', '--speed': 'speed',
|
|
93
|
+
'-o': 'outDir', '--out-dir': 'outDir',
|
|
94
|
+
'-c': 'concurrency', '--concurrency': 'concurrency',
|
|
95
|
+
'--suffix': 'suffix',
|
|
96
|
+
'--chroma': 'chromaSubsampling',
|
|
97
|
+
'--hook-type': 'hookType',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Boolean flags
|
|
101
|
+
const booleans = {
|
|
102
|
+
'--lossless': 'lossless',
|
|
103
|
+
'--preserve': 'preserveOriginal',
|
|
104
|
+
'--skip-larger': ['skipLarger', true],
|
|
105
|
+
'--no-skip-larger': ['skipLarger', false],
|
|
106
|
+
'--skip-existing': 'skipExisting',
|
|
107
|
+
'--staged-only': 'stagedOnly',
|
|
108
|
+
'--changed-since-push': 'changedSincePush',
|
|
109
|
+
'-n': 'dryRun', '--dry-run': 'dryRun',
|
|
110
|
+
'--json': 'json',
|
|
111
|
+
'-v': 'verbose', '--verbose': 'verbose',
|
|
112
|
+
'--debug': 'debug',
|
|
113
|
+
'--silent': 'silent',
|
|
114
|
+
'-h': 'help', '--help': 'help',
|
|
115
|
+
'--version': 'version',
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
if (withValue[arg]) {
|
|
119
|
+
const key = withValue[arg];
|
|
120
|
+
const val = argv[++i];
|
|
121
|
+
if (val === undefined) {
|
|
122
|
+
console.error(`Missing value for ${arg}`);
|
|
123
|
+
process.exit(2);
|
|
124
|
+
}
|
|
125
|
+
// Convert numeric values
|
|
126
|
+
args[key] = ['quality', 'speed', 'concurrency'].includes(key) ? Number(val) : val;
|
|
127
|
+
} else if (booleans[arg]) {
|
|
128
|
+
const mapping = booleans[arg];
|
|
129
|
+
if (Array.isArray(mapping)) {
|
|
130
|
+
args[mapping[0]] = mapping[1];
|
|
131
|
+
} else {
|
|
132
|
+
args[mapping] = true;
|
|
133
|
+
}
|
|
134
|
+
} else if (arg.startsWith('-')) {
|
|
135
|
+
console.error(`Unknown option: ${arg}\nRun 'avifify --help' for usage.`);
|
|
136
|
+
process.exit(2);
|
|
137
|
+
} else {
|
|
138
|
+
args._.push(arg);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
i++;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return args;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Main CLI entry point.
|
|
149
|
+
*/
|
|
150
|
+
export async function run(argv) {
|
|
151
|
+
const args = parseArgs(argv);
|
|
152
|
+
|
|
153
|
+
// Simple flags
|
|
154
|
+
if (args.help) {
|
|
155
|
+
console.log(HELP);
|
|
156
|
+
process.exit(0);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (args.version) {
|
|
160
|
+
console.log(VERSION);
|
|
161
|
+
process.exit(0);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Sub-commands
|
|
165
|
+
const subcommand = args._[0];
|
|
166
|
+
if (subcommand === 'hook') {
|
|
167
|
+
return handleHookCommand(args);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Main conversion flow
|
|
171
|
+
try {
|
|
172
|
+
const config = await resolveConfig(args);
|
|
173
|
+
const logger = createLogger({ level: config.logLevel, json: config.json });
|
|
174
|
+
|
|
175
|
+
logger.debug('Resolved config:', config);
|
|
176
|
+
|
|
177
|
+
// Discover files
|
|
178
|
+
let files;
|
|
179
|
+
if (config.changedSincePush) {
|
|
180
|
+
logger.info('Scanning for images changed since last push…');
|
|
181
|
+
files = await getChangedSinceLastPush(process.cwd());
|
|
182
|
+
} else {
|
|
183
|
+
logger.info('Scanning for images…');
|
|
184
|
+
files = await scanFiles(config, args._.length > 0 ? args._ : [], process.cwd());
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (files.length === 0) {
|
|
188
|
+
logger.info('No images found matching the configured patterns.');
|
|
189
|
+
process.exit(0);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
logger.info(`Found ${files.length} image${files.length === 1 ? '' : 's'} to process`);
|
|
193
|
+
|
|
194
|
+
if (config.dryRun) {
|
|
195
|
+
logger.info('Dry run — no files will be modified\n');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Convert
|
|
199
|
+
const results = await convertBatch(files, config, logger);
|
|
200
|
+
|
|
201
|
+
// Summarize
|
|
202
|
+
const summary = results.reduce(
|
|
203
|
+
(acc, r) => {
|
|
204
|
+
acc.total++;
|
|
205
|
+
if (r.status === 'converted') {
|
|
206
|
+
acc.converted++;
|
|
207
|
+
acc.savedBytes += r.savedBytes ?? 0;
|
|
208
|
+
} else if (r.status === 'skipped') {
|
|
209
|
+
acc.skipped++;
|
|
210
|
+
} else {
|
|
211
|
+
acc.errors++;
|
|
212
|
+
}
|
|
213
|
+
return acc;
|
|
214
|
+
},
|
|
215
|
+
{ total: 0, converted: 0, skipped: 0, errors: 0, savedBytes: 0 }
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
logger.summary(summary);
|
|
219
|
+
|
|
220
|
+
// Exit code: 1 if any errors, 0 otherwise
|
|
221
|
+
process.exit(summary.errors > 0 ? 1 : 0);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
const logger = createLogger({ level: 'error', json: args.json });
|
|
224
|
+
logger.error(err.message);
|
|
225
|
+
if (args.debug) logger.error(err.stack);
|
|
226
|
+
process.exit(2);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Handle `avifify hook install` / `avifify hook uninstall`
|
|
232
|
+
*/
|
|
233
|
+
async function handleHookCommand(args) {
|
|
234
|
+
const action = args._[1];
|
|
235
|
+
const hookType = args.hookType ?? 'pre-push';
|
|
236
|
+
const logger = createLogger({ level: args.silent ? 'silent' : 'info', json: args.json });
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
if (action === 'install') {
|
|
240
|
+
await installHook({ hookType }, logger);
|
|
241
|
+
} else if (action === 'uninstall') {
|
|
242
|
+
await uninstallHook({ hookType }, logger);
|
|
243
|
+
} else {
|
|
244
|
+
console.error(`Unknown hook action: ${action}\nUsage: avifify hook install|uninstall`);
|
|
245
|
+
process.exit(2);
|
|
246
|
+
}
|
|
247
|
+
} catch (err) {
|
|
248
|
+
logger.error(err.message);
|
|
249
|
+
process.exit(2);
|
|
250
|
+
}
|
|
251
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration resolution with layered precedence:
|
|
3
|
+
* defaults < .avififyrc.json < package.json["avifify"] < CLI args
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
import { resolve, dirname } from 'node:path';
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
|
|
10
|
+
const DEFAULTS = {
|
|
11
|
+
// Paths / Globs
|
|
12
|
+
include: ['**/*.{jpg,jpeg,png,webp,tiff,tif,gif,bmp,heif,heic}'],
|
|
13
|
+
exclude: ['**/node_modules/**', '**/dist/**', '**/.git/**', '**/vendor/**'],
|
|
14
|
+
|
|
15
|
+
// Output
|
|
16
|
+
outDir: null, // null = convert in-place (same directory)
|
|
17
|
+
suffix: '', // e.g. '.min' → photo.min.avif (empty = photo.avif)
|
|
18
|
+
preserveOriginal: false,
|
|
19
|
+
|
|
20
|
+
// AVIF encoding options
|
|
21
|
+
quality: 50, // 1-100, lower = smaller. 50 is a solid default
|
|
22
|
+
speed: 5, // 0-8, lower = slower but better compression
|
|
23
|
+
effort: 5, // alias for speed (sharp uses this)
|
|
24
|
+
lossless: false,
|
|
25
|
+
chromaSubsampling: '4:2:0', // '4:2:0' or '4:4:4'
|
|
26
|
+
|
|
27
|
+
// Behavior
|
|
28
|
+
concurrency: null, // null = auto (CPU count)
|
|
29
|
+
skipLarger: true, // skip if AVIF is bigger than the original
|
|
30
|
+
skipExisting: false, // skip if .avif already exists
|
|
31
|
+
dryRun: false,
|
|
32
|
+
json: false,
|
|
33
|
+
verbose: false,
|
|
34
|
+
debug: false,
|
|
35
|
+
silent: false,
|
|
36
|
+
|
|
37
|
+
// Git hook specific
|
|
38
|
+
stagedOnly: false, // only process git-staged files
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const RC_FILENAMES = ['.avififyrc', '.avififyrc.json'];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Walk up from `cwd` to find the nearest config file.
|
|
45
|
+
*/
|
|
46
|
+
async function findRcFile(cwd) {
|
|
47
|
+
let dir = resolve(cwd);
|
|
48
|
+
const root = dirname(dir) === dir ? dir : undefined;
|
|
49
|
+
|
|
50
|
+
while (true) {
|
|
51
|
+
for (const name of RC_FILENAMES) {
|
|
52
|
+
const candidate = resolve(dir, name);
|
|
53
|
+
if (existsSync(candidate)) return candidate;
|
|
54
|
+
}
|
|
55
|
+
const parent = dirname(dir);
|
|
56
|
+
if (parent === dir || dir === root) break;
|
|
57
|
+
dir = parent;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function loadJsonFile(path) {
|
|
63
|
+
try {
|
|
64
|
+
const raw = await readFile(path, 'utf-8');
|
|
65
|
+
return JSON.parse(raw);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the final config object.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} cliArgs - Parsed CLI arguments (only defined keys)
|
|
75
|
+
* @param {string} cwd - Working directory
|
|
76
|
+
* @returns {Promise<object>} Merged configuration
|
|
77
|
+
*/
|
|
78
|
+
export async function resolveConfig(cliArgs = {}, cwd = process.cwd()) {
|
|
79
|
+
// Layer 1: RC file
|
|
80
|
+
const rcPath = await findRcFile(cwd);
|
|
81
|
+
const rcConfig = rcPath ? (await loadJsonFile(rcPath)) ?? {} : {};
|
|
82
|
+
|
|
83
|
+
// Layer 2: package.json "avifify" key
|
|
84
|
+
const pkgPath = resolve(cwd, 'package.json');
|
|
85
|
+
const pkg = await loadJsonFile(pkgPath);
|
|
86
|
+
const pkgConfig = pkg?.avifify ?? {};
|
|
87
|
+
|
|
88
|
+
// Merge with precedence
|
|
89
|
+
const merged = { ...DEFAULTS, ...rcConfig, ...pkgConfig, ...stripUndefined(cliArgs) };
|
|
90
|
+
|
|
91
|
+
// Normalize
|
|
92
|
+
merged.quality = clamp(merged.quality, 1, 100);
|
|
93
|
+
merged.speed = clamp(merged.speed, 0, 8);
|
|
94
|
+
merged.effort = merged.speed; // sharp calls it `effort`
|
|
95
|
+
|
|
96
|
+
if (merged.concurrency === null) {
|
|
97
|
+
const { availableParallelism } = await import('node:os');
|
|
98
|
+
merged.concurrency = Math.max(1, (availableParallelism?.() ?? 4) - 1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (merged.outDir) {
|
|
102
|
+
merged.outDir = resolve(cwd, merged.outDir);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Determine log level
|
|
106
|
+
if (merged.silent) merged.logLevel = 'silent';
|
|
107
|
+
else if (merged.debug) merged.logLevel = 'debug';
|
|
108
|
+
else if (merged.verbose) merged.logLevel = 'verbose';
|
|
109
|
+
else merged.logLevel = 'info';
|
|
110
|
+
|
|
111
|
+
// Source info for debugging
|
|
112
|
+
merged._sources = {
|
|
113
|
+
rc: rcPath ?? null,
|
|
114
|
+
pkg: pkg?.avifify ? pkgPath : null,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return merged;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function stripUndefined(obj) {
|
|
121
|
+
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function clamp(val, min, max) {
|
|
125
|
+
return Math.min(max, Math.max(min, Number(val) || min));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export { DEFAULTS };
|
package/src/converter.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core AVIF converter: processes files with concurrency control,
|
|
3
|
+
* progress reporting, and detailed result tracking.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import sharp from 'sharp';
|
|
7
|
+
import { stat, mkdir, unlink, access } from 'node:fs/promises';
|
|
8
|
+
import { dirname, basename, extname, join, relative, resolve } from 'node:path';
|
|
9
|
+
import { formatBytes } from './logger.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Object} ConvertResult
|
|
13
|
+
* @property {string} file - Original file path
|
|
14
|
+
* @property {string} output - Output file path
|
|
15
|
+
* @property {'converted'|'skipped'|'error'} status
|
|
16
|
+
* @property {number} [inputSize] - Original file size in bytes
|
|
17
|
+
* @property {number} [outputSize] - AVIF file size in bytes
|
|
18
|
+
* @property {number} [savedBytes] - Bytes saved (positive = smaller)
|
|
19
|
+
* @property {string} [reason] - Why it was skipped or errored
|
|
20
|
+
* @property {number} [duration] - Conversion time in ms
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Convert a batch of image files to AVIF.
|
|
25
|
+
*
|
|
26
|
+
* @param {string[]} files - Absolute paths of source images
|
|
27
|
+
* @param {object} config - Resolved configuration
|
|
28
|
+
* @param {object} logger - Logger instance
|
|
29
|
+
* @returns {Promise<ConvertResult[]>}
|
|
30
|
+
*/
|
|
31
|
+
export async function convertBatch(files, config, logger) {
|
|
32
|
+
const results = [];
|
|
33
|
+
const { concurrency } = config;
|
|
34
|
+
let completed = 0;
|
|
35
|
+
|
|
36
|
+
// Process files in chunks for controlled concurrency
|
|
37
|
+
for (let i = 0; i < files.length; i += concurrency) {
|
|
38
|
+
const chunk = files.slice(i, i + concurrency);
|
|
39
|
+
|
|
40
|
+
const chunkResults = await Promise.allSettled(
|
|
41
|
+
chunk.map(file => convertSingle(file, config, logger))
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
for (const result of chunkResults) {
|
|
45
|
+
const res = result.status === 'fulfilled'
|
|
46
|
+
? result.value
|
|
47
|
+
: { file: 'unknown', status: 'error', reason: result.reason?.message ?? String(result.reason) };
|
|
48
|
+
|
|
49
|
+
results.push(res);
|
|
50
|
+
completed++;
|
|
51
|
+
logger.progress(completed, files.length, res.file);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return results;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Convert a single image file to AVIF.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} filePath - Absolute path to the source image
|
|
62
|
+
* @param {object} config - Resolved configuration
|
|
63
|
+
* @param {object} logger - Logger instance
|
|
64
|
+
* @returns {Promise<ConvertResult>}
|
|
65
|
+
*/
|
|
66
|
+
export async function convertSingle(filePath, config, logger) {
|
|
67
|
+
const start = Date.now();
|
|
68
|
+
const cwd = process.cwd();
|
|
69
|
+
const relPath = relative(cwd, filePath);
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
// Determine output path
|
|
73
|
+
const outputPath = getOutputPath(filePath, config);
|
|
74
|
+
const relOutput = relative(cwd, outputPath);
|
|
75
|
+
|
|
76
|
+
// Check if output already exists
|
|
77
|
+
if (config.skipExisting) {
|
|
78
|
+
try {
|
|
79
|
+
await access(outputPath);
|
|
80
|
+
logger.verbose(`Skipped (exists): ${relPath}`);
|
|
81
|
+
return { file: relPath, output: relOutput, status: 'skipped', reason: 'already exists' };
|
|
82
|
+
} catch {
|
|
83
|
+
// File doesn't exist, proceed
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Get input file size
|
|
88
|
+
const inputStat = await stat(filePath);
|
|
89
|
+
const inputSize = inputStat.size;
|
|
90
|
+
|
|
91
|
+
// Dry run — report what would happen
|
|
92
|
+
if (config.dryRun) {
|
|
93
|
+
logger.info(`[dry-run] Would convert: ${relPath} → ${relOutput}`);
|
|
94
|
+
return { file: relPath, output: relOutput, status: 'skipped', reason: 'dry run', inputSize };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Ensure output directory exists
|
|
98
|
+
const outDir = dirname(outputPath);
|
|
99
|
+
await mkdir(outDir, { recursive: true });
|
|
100
|
+
|
|
101
|
+
// Build the sharp pipeline
|
|
102
|
+
let pipeline = sharp(filePath, {
|
|
103
|
+
failOn: 'none', // Don't fail on minor issues
|
|
104
|
+
sequentialRead: true, // Better memory usage for large images
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Optionally get metadata for logging
|
|
108
|
+
const metadata = logger.isVerbose ? await pipeline.metadata() : null;
|
|
109
|
+
|
|
110
|
+
// Convert to AVIF
|
|
111
|
+
pipeline = pipeline.avif({
|
|
112
|
+
quality: config.lossless ? 100 : config.quality,
|
|
113
|
+
effort: config.effort,
|
|
114
|
+
lossless: config.lossless,
|
|
115
|
+
chromaSubsampling: config.chromaSubsampling,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Write output
|
|
119
|
+
const outputInfo = await pipeline.toFile(outputPath);
|
|
120
|
+
const outputSize = outputInfo.size;
|
|
121
|
+
const savedBytes = inputSize - outputSize;
|
|
122
|
+
const duration = Date.now() - start;
|
|
123
|
+
|
|
124
|
+
// Skip if AVIF is larger and config says so
|
|
125
|
+
if (config.skipLarger && outputSize >= inputSize) {
|
|
126
|
+
// Remove the larger AVIF file
|
|
127
|
+
try { await unlink(outputPath); } catch { /* ignore */ }
|
|
128
|
+
logger.verbose(`Skipped (AVIF larger): ${relPath} — ${formatBytes(inputSize)} → ${formatBytes(outputSize)}`);
|
|
129
|
+
return {
|
|
130
|
+
file: relPath,
|
|
131
|
+
output: relOutput,
|
|
132
|
+
status: 'skipped',
|
|
133
|
+
reason: 'avif is larger',
|
|
134
|
+
inputSize,
|
|
135
|
+
outputSize,
|
|
136
|
+
savedBytes,
|
|
137
|
+
duration,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Delete original if not preserving
|
|
142
|
+
if (!config.preserveOriginal && outputPath !== filePath) {
|
|
143
|
+
try { await unlink(filePath); } catch { /* ignore */ }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const pct = inputSize > 0 ? Math.round((1 - outputSize / inputSize) * 100) : 0;
|
|
147
|
+
logger.verbose(
|
|
148
|
+
`Converted: ${relPath} → ${formatBytes(inputSize)} → ${formatBytes(outputSize)} (${pct}% smaller, ${duration}ms)`,
|
|
149
|
+
metadata ? { dimensions: `${metadata.width}×${metadata.height}` } : undefined
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
file: relPath,
|
|
154
|
+
output: relOutput,
|
|
155
|
+
status: 'converted',
|
|
156
|
+
inputSize,
|
|
157
|
+
outputSize,
|
|
158
|
+
savedBytes,
|
|
159
|
+
duration,
|
|
160
|
+
};
|
|
161
|
+
} catch (err) {
|
|
162
|
+
const duration = Date.now() - start;
|
|
163
|
+
logger.error(`Failed: ${relPath} — ${err.message}`);
|
|
164
|
+
logger.debug(err.stack);
|
|
165
|
+
return {
|
|
166
|
+
file: relPath,
|
|
167
|
+
output: null,
|
|
168
|
+
status: 'error',
|
|
169
|
+
reason: err.message,
|
|
170
|
+
duration,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Compute the output .avif path for a given input file.
|
|
177
|
+
*/
|
|
178
|
+
function getOutputPath(filePath, config) {
|
|
179
|
+
const ext = extname(filePath);
|
|
180
|
+
const base = basename(filePath, ext);
|
|
181
|
+
const dir = config.outDir
|
|
182
|
+
? resolve(config.outDir, relative(process.cwd(), dirname(filePath)))
|
|
183
|
+
: dirname(filePath);
|
|
184
|
+
|
|
185
|
+
const suffix = config.suffix || '';
|
|
186
|
+
return join(dir, `${base}${suffix}.avif`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export { getOutputPath };
|
package/src/hooks.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git hook management — install/uninstall avifify as a git hook.
|
|
3
|
+
*
|
|
4
|
+
* Supports:
|
|
5
|
+
* - Direct .git/hooks/ installation
|
|
6
|
+
* - Husky v4+ integration (detects .husky/ directory)
|
|
7
|
+
* - Custom hooks directory (core.hooksPath)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFile, writeFile, chmod, mkdir, unlink } from 'node:fs/promises';
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { resolve, join } from 'node:path';
|
|
13
|
+
import { execFile } from 'node:child_process';
|
|
14
|
+
import { promisify } from 'node:util';
|
|
15
|
+
|
|
16
|
+
const execFileAsync = promisify(execFile);
|
|
17
|
+
|
|
18
|
+
const HOOK_MARKER_START = '# >>> avifify pre-push hook >>>';
|
|
19
|
+
const HOOK_MARKER_END = '# <<< avifify pre-push hook <<<';
|
|
20
|
+
|
|
21
|
+
const HOOK_TYPES = ['pre-push', 'pre-commit'];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Generate the hook script content.
|
|
25
|
+
*/
|
|
26
|
+
function hookScript(hookType) {
|
|
27
|
+
return `${HOOK_MARKER_START}
|
|
28
|
+
# Auto-generated by avifify — do not edit between markers
|
|
29
|
+
npx avifify --staged-only
|
|
30
|
+
AVIFIFY_EXIT=$?
|
|
31
|
+
if [ $AVIFIFY_EXIT -ne 0 ]; then
|
|
32
|
+
echo "avifify: image conversion failed or found unconverted images"
|
|
33
|
+
echo "Run 'npx avifify' to convert images before pushing."
|
|
34
|
+
exit 1
|
|
35
|
+
fi
|
|
36
|
+
# If images were converted, auto-stage them
|
|
37
|
+
git diff --name-only | grep '\\.avif$' | xargs -r git add
|
|
38
|
+
${HOOK_MARKER_END}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Detect the git hooks directory.
|
|
43
|
+
*/
|
|
44
|
+
async function getHooksDir(cwd) {
|
|
45
|
+
// Check for husky directory
|
|
46
|
+
const huskyDir = resolve(cwd, '.husky');
|
|
47
|
+
if (existsSync(huskyDir)) {
|
|
48
|
+
return { type: 'husky', path: huskyDir };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Check for custom hooksPath in git config
|
|
52
|
+
try {
|
|
53
|
+
const { stdout } = await execFileAsync('git', ['config', 'core.hooksPath'], { cwd });
|
|
54
|
+
const customPath = stdout.trim();
|
|
55
|
+
if (customPath) {
|
|
56
|
+
return { type: 'custom', path: resolve(cwd, customPath) };
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// No custom hooks path set
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Default to .git/hooks
|
|
63
|
+
const gitHooksDir = resolve(cwd, '.git', 'hooks');
|
|
64
|
+
if (existsSync(resolve(cwd, '.git'))) {
|
|
65
|
+
return { type: 'git', path: gitHooksDir };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
throw new Error('Not a git repository (no .git directory found)');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Install the git hook.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} options
|
|
75
|
+
* @param {string} [options.hookType='pre-push'] - Hook type
|
|
76
|
+
* @param {string} [options.cwd=process.cwd()] - Working directory
|
|
77
|
+
* @param {object} logger - Logger instance
|
|
78
|
+
*/
|
|
79
|
+
export async function installHook({ hookType = 'pre-push', cwd = process.cwd() } = {}, logger) {
|
|
80
|
+
if (!HOOK_TYPES.includes(hookType)) {
|
|
81
|
+
throw new Error(`Unsupported hook type: ${hookType}. Supported: ${HOOK_TYPES.join(', ')}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const { type, path: hooksDir } = await getHooksDir(cwd);
|
|
85
|
+
await mkdir(hooksDir, { recursive: true });
|
|
86
|
+
|
|
87
|
+
const hookPath = join(hooksDir, hookType);
|
|
88
|
+
const script = hookScript(hookType);
|
|
89
|
+
|
|
90
|
+
if (existsSync(hookPath)) {
|
|
91
|
+
const existing = await readFile(hookPath, 'utf-8');
|
|
92
|
+
|
|
93
|
+
// Already installed?
|
|
94
|
+
if (existing.includes(HOOK_MARKER_START)) {
|
|
95
|
+
// Replace existing avifify block
|
|
96
|
+
const regex = new RegExp(
|
|
97
|
+
`${escapeRegex(HOOK_MARKER_START)}[\\s\\S]*?${escapeRegex(HOOK_MARKER_END)}`,
|
|
98
|
+
'm'
|
|
99
|
+
);
|
|
100
|
+
const updated = existing.replace(regex, script);
|
|
101
|
+
await writeFile(hookPath, updated, 'utf-8');
|
|
102
|
+
logger.success(`Updated avifify in existing ${hookType} hook (${type})`);
|
|
103
|
+
} else {
|
|
104
|
+
// Append to existing hook
|
|
105
|
+
const updated = existing.trimEnd() + '\n\n' + script + '\n';
|
|
106
|
+
await writeFile(hookPath, updated, 'utf-8');
|
|
107
|
+
logger.success(`Appended avifify to existing ${hookType} hook (${type})`);
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
// Create new hook file
|
|
111
|
+
const content = `#!/usr/bin/env sh\n\n${script}\n`;
|
|
112
|
+
await writeFile(hookPath, content, 'utf-8');
|
|
113
|
+
logger.success(`Created ${hookType} hook at ${hookPath} (${type})`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Make executable
|
|
117
|
+
await chmod(hookPath, 0o755);
|
|
118
|
+
logger.info(`Hook installed via ${type} at ${hookPath}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Uninstall the git hook (removes only the avifify block).
|
|
123
|
+
*/
|
|
124
|
+
export async function uninstallHook({ hookType = 'pre-push', cwd = process.cwd() } = {}, logger) {
|
|
125
|
+
const { type, path: hooksDir } = await getHooksDir(cwd);
|
|
126
|
+
const hookPath = join(hooksDir, hookType);
|
|
127
|
+
|
|
128
|
+
if (!existsSync(hookPath)) {
|
|
129
|
+
logger.warn(`No ${hookType} hook found at ${hookPath}`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const existing = await readFile(hookPath, 'utf-8');
|
|
134
|
+
|
|
135
|
+
if (!existing.includes(HOOK_MARKER_START)) {
|
|
136
|
+
logger.warn(`No avifify block found in ${hookType} hook`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const regex = new RegExp(
|
|
141
|
+
`\\n?${escapeRegex(HOOK_MARKER_START)}[\\s\\S]*?${escapeRegex(HOOK_MARKER_END)}\\n?`,
|
|
142
|
+
'm'
|
|
143
|
+
);
|
|
144
|
+
const cleaned = existing.replace(regex, '\n').trim();
|
|
145
|
+
|
|
146
|
+
// If the hook only had our content, remove the file entirely
|
|
147
|
+
const isShebangOnly = /^#!\/usr\/bin\/env\s+sh\s*$/m.test(cleaned) && cleaned.split('\n').filter(l => l.trim()).length <= 1;
|
|
148
|
+
|
|
149
|
+
if (!cleaned || isShebangOnly) {
|
|
150
|
+
await unlink(hookPath);
|
|
151
|
+
logger.success(`Removed ${hookType} hook entirely (was only avifify)`);
|
|
152
|
+
} else {
|
|
153
|
+
await writeFile(hookPath, cleaned + '\n', 'utf-8');
|
|
154
|
+
await chmod(hookPath, 0o755);
|
|
155
|
+
logger.success(`Removed avifify block from ${hookType} hook`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function escapeRegex(str) {
|
|
160
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
161
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* avifify — Programmatic API
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* import { avifify, convertFile } from 'avifify';
|
|
6
|
+
*
|
|
7
|
+
* // Convert all images in a directory
|
|
8
|
+
* const results = await avifify({ quality: 40, include: ['src/**'] });
|
|
9
|
+
*
|
|
10
|
+
* // Convert a single file
|
|
11
|
+
* const result = await convertFile('photo.jpg', { quality: 50 });
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { resolveConfig } from './config.js';
|
|
15
|
+
import { scanFiles } from './scanner.js';
|
|
16
|
+
import { convertBatch, convertSingle, getOutputPath } from './converter.js';
|
|
17
|
+
import { createLogger } from './logger.js';
|
|
18
|
+
import { installHook, uninstallHook } from './hooks.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Convert images to AVIF.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} [options] - Override any config option
|
|
24
|
+
* @param {string[]} [options.paths] - Explicit file/glob paths to process
|
|
25
|
+
* @param {string} [options.cwd] - Working directory (default: process.cwd())
|
|
26
|
+
* @returns {Promise<import('./converter.js').ConvertResult[]>}
|
|
27
|
+
*/
|
|
28
|
+
export async function avifify(options = {}) {
|
|
29
|
+
const { paths = [], cwd = process.cwd(), ...rest } = options;
|
|
30
|
+
const config = await resolveConfig({ ...rest, silent: rest.silent ?? true }, cwd);
|
|
31
|
+
const logger = createLogger({ level: config.logLevel, json: config.json });
|
|
32
|
+
|
|
33
|
+
const files = await scanFiles(config, paths, cwd);
|
|
34
|
+
if (files.length === 0) return [];
|
|
35
|
+
|
|
36
|
+
return convertBatch(files, config, logger);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Convert a single file to AVIF.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} filePath - Path to the source image
|
|
43
|
+
* @param {object} [options] - Override any config option
|
|
44
|
+
* @returns {Promise<import('./converter.js').ConvertResult>}
|
|
45
|
+
*/
|
|
46
|
+
export async function convertFile(filePath, options = {}) {
|
|
47
|
+
const config = await resolveConfig({ ...options, silent: options.silent ?? true });
|
|
48
|
+
const logger = createLogger({ level: 'silent' });
|
|
49
|
+
return convertSingle(filePath, config, logger);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export { installHook, uninstallHook, getOutputPath, resolveConfig };
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger with TTY-aware formatting.
|
|
3
|
+
* - Interactive terminal: colored, human-readable output
|
|
4
|
+
* - Piped / CI: structured JSON lines (--json) or plain text
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const LEVELS = { silent: 0, error: 1, warn: 2, info: 3, verbose: 4, debug: 5 };
|
|
8
|
+
|
|
9
|
+
// ANSI codes — stripped automatically when not a TTY
|
|
10
|
+
const RESET = '\x1b[0m';
|
|
11
|
+
const BOLD = '\x1b[1m';
|
|
12
|
+
const DIM = '\x1b[2m';
|
|
13
|
+
const RED = '\x1b[31m';
|
|
14
|
+
const GREEN = '\x1b[32m';
|
|
15
|
+
const YELLOW = '\x1b[33m';
|
|
16
|
+
const CYAN = '\x1b[36m';
|
|
17
|
+
const MAGENTA = '\x1b[35m';
|
|
18
|
+
|
|
19
|
+
class Logger {
|
|
20
|
+
#level;
|
|
21
|
+
#json;
|
|
22
|
+
#isTTY;
|
|
23
|
+
#startTime;
|
|
24
|
+
|
|
25
|
+
constructor({ level = 'info', json = false } = {}) {
|
|
26
|
+
this.#level = LEVELS[level] ?? LEVELS.info;
|
|
27
|
+
this.#json = json;
|
|
28
|
+
this.#isTTY = process.stdout.isTTY && !json;
|
|
29
|
+
this.#startTime = Date.now();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get isVerbose() {
|
|
33
|
+
return this.#level >= LEVELS.verbose;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get isDebug() {
|
|
37
|
+
return this.#level >= LEVELS.debug;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#elapsed() {
|
|
41
|
+
return `${((Date.now() - this.#startTime) / 1000).toFixed(1)}s`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#write(level, color, label, message, data) {
|
|
45
|
+
if (LEVELS[level] > this.#level) return;
|
|
46
|
+
|
|
47
|
+
if (this.#json) {
|
|
48
|
+
const entry = { level, message, timestamp: new Date().toISOString(), ...data };
|
|
49
|
+
process.stdout.write(JSON.stringify(entry) + '\n');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const prefix = this.#isTTY ? `${color}${BOLD}${label}${RESET}` : label;
|
|
54
|
+
const dim = this.#isTTY ? DIM : '';
|
|
55
|
+
const reset = this.#isTTY ? RESET : '';
|
|
56
|
+
|
|
57
|
+
let line = `${prefix} ${message}`;
|
|
58
|
+
if (data?.file) line += ` ${dim}${data.file}${reset}`;
|
|
59
|
+
if (data?.saved) line += ` ${dim}(${data.saved})${reset}`;
|
|
60
|
+
|
|
61
|
+
const stream = level === 'error' ? process.stderr : process.stdout;
|
|
62
|
+
stream.write(line + '\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
error(message, data) { this.#write('error', RED, '✖', message, data); }
|
|
66
|
+
warn(message, data) { this.#write('warn', YELLOW, '⚠', message, data); }
|
|
67
|
+
info(message, data) { this.#write('info', CYAN, '●', message, data); }
|
|
68
|
+
success(message, data) { this.#write('info', GREEN, '✔', message, data); }
|
|
69
|
+
verbose(message, data) { this.#write('verbose', MAGENTA, '…', message, data); }
|
|
70
|
+
debug(message, data) { this.#write('debug', DIM, '⊡', message, data); }
|
|
71
|
+
|
|
72
|
+
/** Print a summary table at the end */
|
|
73
|
+
summary({ total, converted, skipped, errors, savedBytes }) {
|
|
74
|
+
if (this.#json) {
|
|
75
|
+
process.stdout.write(JSON.stringify({
|
|
76
|
+
level: 'summary',
|
|
77
|
+
total,
|
|
78
|
+
converted,
|
|
79
|
+
skipped,
|
|
80
|
+
errors,
|
|
81
|
+
savedBytes,
|
|
82
|
+
elapsed: this.#elapsed(),
|
|
83
|
+
}) + '\n');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const saved = formatBytes(savedBytes);
|
|
88
|
+
const c = this.#isTTY;
|
|
89
|
+
console.log('');
|
|
90
|
+
console.log(c ? `${BOLD}${GREEN} avifify complete${RESET}` : ' avifify complete');
|
|
91
|
+
console.log(` ─────────────────────────────`);
|
|
92
|
+
console.log(` Files scanned ${total}`);
|
|
93
|
+
console.log(` Converted ${c ? GREEN : ''}${converted}${c ? RESET : ''}`);
|
|
94
|
+
console.log(` Skipped ${skipped}`);
|
|
95
|
+
if (errors > 0) {
|
|
96
|
+
console.log(` Errors ${c ? RED : ''}${errors}${c ? RESET : ''}`);
|
|
97
|
+
}
|
|
98
|
+
console.log(` Space saved ${c ? CYAN : ''}${saved}${c ? RESET : ''}`);
|
|
99
|
+
console.log(` Elapsed ${this.#elapsed()}`);
|
|
100
|
+
console.log('');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Inline progress for TTY */
|
|
104
|
+
progress(current, total, file) {
|
|
105
|
+
if (!this.#isTTY || this.#level < LEVELS.info) return;
|
|
106
|
+
const pct = Math.round((current / total) * 100);
|
|
107
|
+
const bar = '█'.repeat(Math.floor(pct / 4)) + '░'.repeat(25 - Math.floor(pct / 4));
|
|
108
|
+
process.stdout.write(`\r${DIM} ${bar} ${pct}% (${current}/${total})${RESET} `);
|
|
109
|
+
if (current === total) process.stdout.write('\n');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function formatBytes(bytes) {
|
|
114
|
+
if (bytes === 0) return '0 B';
|
|
115
|
+
const neg = bytes < 0;
|
|
116
|
+
const abs = Math.abs(bytes);
|
|
117
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
118
|
+
const i = Math.min(Math.floor(Math.log(abs) / Math.log(1024)), units.length - 1);
|
|
119
|
+
const val = (abs / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1);
|
|
120
|
+
return `${neg ? '-' : ''}${val} ${units[i]}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function createLogger(opts) {
|
|
124
|
+
return new Logger(opts);
|
|
125
|
+
}
|
package/src/scanner.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File discovery: glob-based scanning or git-staged file detection.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fg from 'fast-glob';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { resolve, extname } from 'node:path';
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
|
|
12
|
+
const IMAGE_EXTENSIONS = new Set([
|
|
13
|
+
'.jpg', '.jpeg', '.png', '.webp', '.tiff', '.tif',
|
|
14
|
+
'.gif', '.bmp', '.heif', '.heic',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Find image files matching the config patterns.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} config - Resolved config
|
|
21
|
+
* @param {string[]} explicitPaths - Explicit file/dir paths from CLI positional args
|
|
22
|
+
* @param {string} cwd - Working directory
|
|
23
|
+
* @returns {Promise<string[]>} Absolute paths of image files
|
|
24
|
+
*/
|
|
25
|
+
export async function scanFiles(config, explicitPaths = [], cwd = process.cwd()) {
|
|
26
|
+
// Mode 1: git-staged files only (for pre-push hook)
|
|
27
|
+
if (config.stagedOnly) {
|
|
28
|
+
return getStagedImages(cwd);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Mode 2: explicit paths provided
|
|
32
|
+
if (explicitPaths.length > 0) {
|
|
33
|
+
const { statSync } = await import('node:fs');
|
|
34
|
+
const patterns = explicitPaths.map(p => {
|
|
35
|
+
// If it looks like a glob, use as-is
|
|
36
|
+
if (p.includes('*') || p.includes('{')) return p;
|
|
37
|
+
// If it's a directory, append recursive glob
|
|
38
|
+
try {
|
|
39
|
+
if (statSync(resolve(cwd, p)).isDirectory()) return `${p}/**/*`;
|
|
40
|
+
} catch { /* not a dir, treat as file path */ }
|
|
41
|
+
return p;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const files = await fg(patterns, {
|
|
45
|
+
cwd,
|
|
46
|
+
absolute: true,
|
|
47
|
+
onlyFiles: true,
|
|
48
|
+
ignore: config.exclude,
|
|
49
|
+
followSymbolicLinks: false,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return files.filter(f => IMAGE_EXTENSIONS.has(extname(f).toLowerCase()));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Mode 3: scan using include/exclude globs
|
|
56
|
+
const files = await fg(config.include, {
|
|
57
|
+
cwd,
|
|
58
|
+
absolute: true,
|
|
59
|
+
onlyFiles: true,
|
|
60
|
+
ignore: config.exclude,
|
|
61
|
+
followSymbolicLinks: false,
|
|
62
|
+
dot: false,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return files.filter(f => IMAGE_EXTENSIONS.has(extname(f).toLowerCase()));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get image files staged in git (for hook usage).
|
|
70
|
+
*/
|
|
71
|
+
async function getStagedImages(cwd) {
|
|
72
|
+
try {
|
|
73
|
+
// Get files staged for commit (including ones that are added/modified)
|
|
74
|
+
const { stdout } = await execFileAsync(
|
|
75
|
+
'git',
|
|
76
|
+
['diff', '--cached', '--name-only', '--diff-filter=ACMR'],
|
|
77
|
+
{ cwd, maxBuffer: 10 * 1024 * 1024 }
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return stdout
|
|
81
|
+
.split('\n')
|
|
82
|
+
.map(f => f.trim())
|
|
83
|
+
.filter(f => f && IMAGE_EXTENSIONS.has(extname(f).toLowerCase()))
|
|
84
|
+
.map(f => resolve(cwd, f));
|
|
85
|
+
} catch (err) {
|
|
86
|
+
// Not a git repo or git not available
|
|
87
|
+
throw new Error(`Failed to get staged files: ${err.message}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get image files changed since the last push (for pre-push hook).
|
|
93
|
+
*/
|
|
94
|
+
export async function getChangedSinceLastPush(cwd) {
|
|
95
|
+
try {
|
|
96
|
+
const { stdout } = await execFileAsync(
|
|
97
|
+
'git',
|
|
98
|
+
['diff', '--name-only', '@{push}..HEAD', '--diff-filter=ACMR'],
|
|
99
|
+
{ cwd, maxBuffer: 10 * 1024 * 1024 }
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
return stdout
|
|
103
|
+
.split('\n')
|
|
104
|
+
.map(f => f.trim())
|
|
105
|
+
.filter(f => f && IMAGE_EXTENSIONS.has(extname(f).toLowerCase()))
|
|
106
|
+
.map(f => resolve(cwd, f));
|
|
107
|
+
} catch {
|
|
108
|
+
// Fallback: if no upstream, scan all tracked images
|
|
109
|
+
try {
|
|
110
|
+
const { stdout } = await execFileAsync(
|
|
111
|
+
'git',
|
|
112
|
+
['ls-files', '--cached'],
|
|
113
|
+
{ cwd, maxBuffer: 10 * 1024 * 1024 }
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
return stdout
|
|
117
|
+
.split('\n')
|
|
118
|
+
.map(f => f.trim())
|
|
119
|
+
.filter(f => f && IMAGE_EXTENSIONS.has(extname(f).toLowerCase()))
|
|
120
|
+
.map(f => resolve(cwd, f));
|
|
121
|
+
} catch (err) {
|
|
122
|
+
throw new Error(`Failed to list git files: ${err.message}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export { IMAGE_EXTENSIONS };
|