@makefully/dutifully 1.14.1 → 1.16.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/CHANGELOG.md +9 -0
- package/README.md +33 -0
- package/package.json +12 -2
- package/src/classes/Classfully.js +15 -0
- package/src/classes/Packfully.js +94 -0
- package/src/classes/Sharp.js +140 -1
- package/src/classes/Stackfully.js +127 -0
- package/src/formats/image/ExifImage.js +123 -0
- package/src/formats/image/ImageFile.js +26 -0
- package/src/formats/image/JPEG.js +67 -0
- package/src/formats/image/PNG.js +66 -0
- package/src/formats/image/UnsupportedImage.js +31 -0
- package/src/helpers/getImageFile.js +46 -0
- package/src/helpers/imageHash.js +228 -0
- package/src/index.js +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.16.0
|
|
4
|
+
|
|
5
|
+
- Add `classfully` service client for approximating images as CSS class rules.
|
|
6
|
+
|
|
7
|
+
## 1.15.0
|
|
8
|
+
|
|
9
|
+
- Skip unchanged Sharp, Packfully, and Stackfully image outputs via embedded per-service metadata hashes (`CheerfullySharpHash`, `CheerfullyPackfullyHash`, `CheerfullyStackfullyHash`).
|
|
10
|
+
- Add shared image hash embedders (PNG tEXt, JPEG/WebP/TIFF/AVIF EXIF); unsupported types (e.g. ICO, GIF) always reprocess with a console note.
|
|
11
|
+
|
|
3
12
|
## 1.14.1
|
|
4
13
|
|
|
5
14
|
- Upgrade `archiver` so `directory()` works when a modern `glob` is hoisted (fixes `TypeError: glob is not a function`).
|
package/README.md
CHANGED
|
@@ -154,6 +154,35 @@ Use `exports: ["texturepacker", "createjs"]` (or `format`) to choose one or more
|
|
|
154
154
|
|
|
155
155
|
Spacing: `padding` is empty gap beyond `extrude`; `extrude` is edge-pixel bleed into that gap; `border` is margin from the atlas edge.
|
|
156
156
|
|
|
157
|
+
Up-to-date atlas PNGs are skipped via an embedded `CheerfullyPackfullyHash` (source bytes + options). Descriptor files (json/css/atlas/xml) are not hash carriers.
|
|
158
|
+
|
|
159
|
+
### Classfully
|
|
160
|
+
|
|
161
|
+
This image-to-CSS service approximates still images as a CSS class rule (deterministic mutation search scored via headless Chromium screenshots). Every image under `src` is processed; `files` overrides are optional.
|
|
162
|
+
|
|
163
|
+
```javascript
|
|
164
|
+
{
|
|
165
|
+
"classfully": [{
|
|
166
|
+
"src": "./example/classfully/",
|
|
167
|
+
"output": "./example/classfully/out/",
|
|
168
|
+
"options": {
|
|
169
|
+
"maxIterations": 40,
|
|
170
|
+
"minAccuracy": 0.9,
|
|
171
|
+
"maxMutations": 256
|
|
172
|
+
},
|
|
173
|
+
"files": {
|
|
174
|
+
"swatch.png": {
|
|
175
|
+
"className": "swatch",
|
|
176
|
+
"css": "swatch.css"
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}]
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
* Omit `seed` to start from the image’s average `background-color`; pass `seed` (declarations or a `.css` file in `src`) to refine existing CSS.
|
|
184
|
+
* Output defaults to `{stem}.css` with `.{stem} { … }`; override with `css` / `output` and `className`.
|
|
185
|
+
|
|
157
186
|
### Sharp
|
|
158
187
|
|
|
159
188
|
This still-image service converts, compresses, resizes, and can emit multi-size thumbnails or favicon packs (including `.ico`). Every image under `src` is processed; `files` overrides are optional.
|
|
@@ -179,6 +208,8 @@ This still-image service converts, compresses, resizes, and can emit multi-size
|
|
|
179
208
|
}
|
|
180
209
|
```
|
|
181
210
|
|
|
211
|
+
Unchanged outputs are skipped using embedded `CheerfullySharpHash` metadata (PNG tEXt / JPEG·WebP EXIF). Per-service keys so Sharp and Packfully/Stackfully hashes do not clobber each other. Formats that cannot store metadata (e.g. `.ico`, `.gif`) are always reprocessed, with a console note.
|
|
212
|
+
|
|
182
213
|
### Stackfully
|
|
183
214
|
|
|
184
215
|
This multi-layer compositing service builds sized outputs from ordered image layers (positioning, blend, alpha). Every image under `src` is available as a layer source; define composites under `outputs`.
|
|
@@ -215,6 +246,8 @@ This multi-layer compositing service builds sized outputs from ordered image lay
|
|
|
215
246
|
}
|
|
216
247
|
```
|
|
217
248
|
|
|
249
|
+
Unchanged composite outputs are skipped via embedded `CheerfullyStackfullyHash` (layer source bytes + recipe). Unsupported output types are always reprocessed with a console note.
|
|
250
|
+
|
|
218
251
|
### Polly
|
|
219
252
|
|
|
220
253
|
This text-to-voice service requires a script as input and will download audio VO generated by Amazon Polly. You can set Polly settings in `cheerfully.json` like so:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@makefully/dutifully",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.0",
|
|
4
4
|
"description": "Client tools for Cheerfully service.",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -16,13 +16,17 @@
|
|
|
16
16
|
"README.md"
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
|
+
"test": "node --test test/**/*.test.js",
|
|
19
20
|
"test-allosaurus": "node ./cheer.js -service allosaurus",
|
|
21
|
+
"test-classfully": "node ./cheer.js -service classfully",
|
|
20
22
|
"test-elevenlabs": "node ./cheer.js -service elevenlabs",
|
|
21
23
|
"test-ffmpeg": "node ./cheer.js -service ffmpeg",
|
|
22
24
|
"test-packfully": "node ./cheer.js -service packfully",
|
|
23
25
|
"test-polly": "node ./cheer.js -service polly",
|
|
24
26
|
"test-rasterize": "node ./cheer.js -service rasterize",
|
|
25
27
|
"test-rhubarb": "node ./cheer.js -service rhubarb",
|
|
28
|
+
"test-sharp": "node ./cheer.js -service sharp",
|
|
29
|
+
"test-stackfully": "node ./cheer.js -service stackfully",
|
|
26
30
|
"test-transcription": "node ./cheer.js -service transcription",
|
|
27
31
|
"say": "node ./say.js"
|
|
28
32
|
},
|
|
@@ -45,7 +49,8 @@
|
|
|
45
49
|
"captions",
|
|
46
50
|
"packfully",
|
|
47
51
|
"atlas",
|
|
48
|
-
"spritesheet"
|
|
52
|
+
"spritesheet",
|
|
53
|
+
"classfully"
|
|
49
54
|
],
|
|
50
55
|
"author": "Derek Detweiler <derek@makefullystudios.com>",
|
|
51
56
|
"contributors": [
|
|
@@ -62,6 +67,11 @@
|
|
|
62
67
|
"archiver": "^7.0.1",
|
|
63
68
|
"node-id3": "^0.2.6",
|
|
64
69
|
"node-web-audio-api": "^2.0.0",
|
|
70
|
+
"piexifjs": "^1.0.6",
|
|
71
|
+
"png-chunk-text": "^1.0.0",
|
|
72
|
+
"png-chunks-encode": "^1.0.0",
|
|
73
|
+
"png-chunks-extract": "^1.0.0",
|
|
74
|
+
"sharp": "^0.35.3",
|
|
65
75
|
"unzip-stream": "^0.3.0"
|
|
66
76
|
}
|
|
67
77
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const
|
|
2
|
+
Cheer = require('./Cheer'),
|
|
3
|
+
Classfully = class Classfully extends Cheer {
|
|
4
|
+
beforeSend (archive) {
|
|
5
|
+
const
|
|
6
|
+
{src} = this.config;
|
|
7
|
+
|
|
8
|
+
if (src) {
|
|
9
|
+
archive.directory(src, false);
|
|
10
|
+
}
|
|
11
|
+
super.beforeSend(archive);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
module.exports = Classfully;
|
package/src/classes/Packfully.js
CHANGED
|
@@ -1,6 +1,77 @@
|
|
|
1
1
|
const
|
|
2
|
+
path = require('path'),
|
|
2
3
|
Cheer = require('./Cheer'),
|
|
4
|
+
{
|
|
5
|
+
HASH_KEYS,
|
|
6
|
+
hashPayload,
|
|
7
|
+
fileContentHash,
|
|
8
|
+
checkImageOutputs,
|
|
9
|
+
writeImageHashes,
|
|
10
|
+
listImageFiles
|
|
11
|
+
} = require('../helpers/imageHash'),
|
|
12
|
+
{ensureDir, readdirOrEmpty} = require('../helpers/dirs'),
|
|
13
|
+
HASH_KEY = HASH_KEYS.packfully,
|
|
14
|
+
listFormats = (job = {}) => {
|
|
15
|
+
const
|
|
16
|
+
raw = job.exports ?? job.format ?? job.options?.format;
|
|
17
|
+
|
|
18
|
+
if (raw == null) {
|
|
19
|
+
return ['texturepacker'];
|
|
20
|
+
}
|
|
21
|
+
const
|
|
22
|
+
list = Array.isArray(raw) ? raw : [raw];
|
|
23
|
+
|
|
24
|
+
return [...new Set(list.map((f) => String(f).toLowerCase()))];
|
|
25
|
+
},
|
|
3
26
|
Packfully = class Packfully extends Cheer {
|
|
27
|
+
async checkDifference () {
|
|
28
|
+
const
|
|
29
|
+
{config} = this,
|
|
30
|
+
{src, output, options = {}, format, exports: exportList, files: fileOptions = {}} = config;
|
|
31
|
+
|
|
32
|
+
if (!src || !output) {
|
|
33
|
+
console.warn('Packfully difference check needs src and output; running all.');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
await ensureDir(output);
|
|
38
|
+
|
|
39
|
+
const
|
|
40
|
+
sprites = (await listImageFiles(src)).sort(),
|
|
41
|
+
byteHashes = {};
|
|
42
|
+
|
|
43
|
+
for (const name of sprites) {
|
|
44
|
+
byteHashes[name] = await fileContentHash(path.join(src, name));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const
|
|
48
|
+
prefix = options.prefix || config.prefix || 'packed',
|
|
49
|
+
inputHash = hashPayload({
|
|
50
|
+
sprites: byteHashes,
|
|
51
|
+
options,
|
|
52
|
+
formats: listFormats({format, exports: exportList, options}),
|
|
53
|
+
files: fileOptions
|
|
54
|
+
}),
|
|
55
|
+
existing = (await readdirOrEmpty(output))
|
|
56
|
+
.filter((name) => name.startsWith(`${prefix}-`) && name.endsWith('.png'))
|
|
57
|
+
.sort(),
|
|
58
|
+
expected = existing.length ? existing : [`${prefix}-0.png`],
|
|
59
|
+
{missing, present} = await checkImageOutputs({
|
|
60
|
+
outputDir: output,
|
|
61
|
+
expectedOutputs: expected,
|
|
62
|
+
inputHash,
|
|
63
|
+
hashKey: HASH_KEY
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
this.inputHash = inputHash;
|
|
67
|
+
this.prefix = prefix;
|
|
68
|
+
|
|
69
|
+
// Skip only when we already have atlas PNGs and every one matches.
|
|
70
|
+
if (existing.length && missing.length === 0 && present.length === existing.length) {
|
|
71
|
+
throw new Error('All packfully outputs already up to date.');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
4
75
|
beforeSend (archive) {
|
|
5
76
|
const
|
|
6
77
|
{src} = this.config;
|
|
@@ -10,6 +81,29 @@ const
|
|
|
10
81
|
}
|
|
11
82
|
super.beforeSend(archive);
|
|
12
83
|
}
|
|
84
|
+
|
|
85
|
+
async afterExport (...args) {
|
|
86
|
+
const
|
|
87
|
+
{config, inputHash, prefix = 'packed'} = this,
|
|
88
|
+
{output} = config;
|
|
89
|
+
|
|
90
|
+
if (!output || !inputHash) {
|
|
91
|
+
super.afterExport(...args);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const
|
|
96
|
+
atlases = (await readdirOrEmpty(output))
|
|
97
|
+
.filter((name) => name.startsWith(`${prefix}-`) && name.endsWith('.png'));
|
|
98
|
+
|
|
99
|
+
await writeImageHashes({
|
|
100
|
+
outputDir: output,
|
|
101
|
+
files: atlases,
|
|
102
|
+
inputHash,
|
|
103
|
+
hashKey: HASH_KEY
|
|
104
|
+
});
|
|
105
|
+
super.afterExport(...args);
|
|
106
|
+
}
|
|
13
107
|
};
|
|
14
108
|
|
|
15
109
|
module.exports = Packfully;
|
package/src/classes/Sharp.js
CHANGED
|
@@ -1,15 +1,154 @@
|
|
|
1
1
|
const
|
|
2
|
+
path = require('path'),
|
|
2
3
|
Cheer = require('./Cheer'),
|
|
4
|
+
{
|
|
5
|
+
HASH_KEYS,
|
|
6
|
+
hashPayload,
|
|
7
|
+
fileContentHash,
|
|
8
|
+
checkImageOutputs,
|
|
9
|
+
writeImageHashes,
|
|
10
|
+
listImageFiles,
|
|
11
|
+
predictSharpOutputs
|
|
12
|
+
} = require('../helpers/imageHash'),
|
|
13
|
+
{ensureDir} = require('../helpers/dirs'),
|
|
14
|
+
HASH_KEY = HASH_KEYS.sharp,
|
|
15
|
+
RESIZE_KEYS = ['width', 'height', 'fit', 'withoutEnlargement', 'position', 'background', 'kernel'],
|
|
16
|
+
normalizeOptions = (opts = {}) => {
|
|
17
|
+
const
|
|
18
|
+
out = {...opts},
|
|
19
|
+
flat = {};
|
|
20
|
+
|
|
21
|
+
for (const key of RESIZE_KEYS) {
|
|
22
|
+
if (out[key] != null) {
|
|
23
|
+
flat[key] = out[key];
|
|
24
|
+
delete out[key];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (Object.keys(flat).length) {
|
|
28
|
+
out.resize = {...(out.resize || {}), ...flat};
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
},
|
|
32
|
+
fileOptionsFor = (fileOptions, name) => {
|
|
33
|
+
const
|
|
34
|
+
base = path.basename(name),
|
|
35
|
+
id = base.replace(/\.[^.]+$/, ''),
|
|
36
|
+
raw = fileOptions[name] ?? fileOptions[base] ?? fileOptions[id];
|
|
37
|
+
|
|
38
|
+
if (raw == null || typeof raw === 'string') {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
return raw.options ? {...raw, ...raw.options} : raw;
|
|
42
|
+
},
|
|
3
43
|
Sharp = class Sharp extends Cheer {
|
|
44
|
+
async checkDifference () {
|
|
45
|
+
const
|
|
46
|
+
{config} = this,
|
|
47
|
+
{src, output, options = {}, files: fileOptions = {}} = config;
|
|
48
|
+
|
|
49
|
+
if (!src || !output) {
|
|
50
|
+
console.warn('Sharp difference check needs src and output; running all.');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await ensureDir(output);
|
|
55
|
+
|
|
56
|
+
const
|
|
57
|
+
sources = await listImageFiles(src),
|
|
58
|
+
stale = [],
|
|
59
|
+
updateMap = {},
|
|
60
|
+
hashes = {};
|
|
61
|
+
|
|
62
|
+
for (const name of sources) {
|
|
63
|
+
const
|
|
64
|
+
abs = path.join(src, name),
|
|
65
|
+
merged = normalizeOptions({...options, ...fileOptionsFor(fileOptions, name)}),
|
|
66
|
+
inputHash = hashPayload({
|
|
67
|
+
source: await fileContentHash(abs),
|
|
68
|
+
options: merged
|
|
69
|
+
}),
|
|
70
|
+
expected = predictSharpOutputs(name, merged),
|
|
71
|
+
{missing} = await checkImageOutputs({
|
|
72
|
+
outputDir: output,
|
|
73
|
+
expectedOutputs: expected,
|
|
74
|
+
inputHash,
|
|
75
|
+
hashKey: HASH_KEY
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
hashes[name] = {inputHash, expected};
|
|
79
|
+
if (missing.length) {
|
|
80
|
+
stale.push(name);
|
|
81
|
+
updateMap[name] = expected;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!stale.length) {
|
|
86
|
+
throw new Error('All sharp outputs already up to date.');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.updateList = stale;
|
|
90
|
+
this.updateMap = updateMap;
|
|
91
|
+
this.inputHashes = hashes;
|
|
92
|
+
this.differenceOnly = true;
|
|
93
|
+
|
|
94
|
+
// Drop up-to-date file overrides from the chore config.
|
|
95
|
+
if (fileOptions && typeof fileOptions === 'object') {
|
|
96
|
+
const
|
|
97
|
+
next = {};
|
|
98
|
+
|
|
99
|
+
for (const key of Object.keys(fileOptions)) {
|
|
100
|
+
const
|
|
101
|
+
base = path.basename(key),
|
|
102
|
+
match = stale.find((s) => s === key || s === base || s.replace(/\.[^.]+$/, '') === key);
|
|
103
|
+
|
|
104
|
+
if (match) {
|
|
105
|
+
next[key] = fileOptions[key];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
config.files = next;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
4
112
|
beforeSend (archive) {
|
|
5
113
|
const
|
|
6
114
|
{src} = this.config;
|
|
7
115
|
|
|
8
116
|
if (src) {
|
|
9
|
-
|
|
117
|
+
if (this.differenceOnly && this.updateList?.length) {
|
|
118
|
+
this.updateList.forEach((file) => {
|
|
119
|
+
archive.file(path.join(src, file), {name: file});
|
|
120
|
+
});
|
|
121
|
+
} else {
|
|
122
|
+
archive.directory(src, false);
|
|
123
|
+
}
|
|
10
124
|
}
|
|
11
125
|
super.beforeSend(archive);
|
|
12
126
|
}
|
|
127
|
+
|
|
128
|
+
async afterExport (...args) {
|
|
129
|
+
const
|
|
130
|
+
{config, updateList, updateMap, inputHashes} = this,
|
|
131
|
+
{output, src} = config,
|
|
132
|
+
list = updateList ?? (src ? await listImageFiles(src) : []);
|
|
133
|
+
|
|
134
|
+
for (const name of list) {
|
|
135
|
+
const
|
|
136
|
+
info = inputHashes?.[name],
|
|
137
|
+
expected = updateMap?.[name] ?? info?.expected ?? [],
|
|
138
|
+
inputHash = info?.inputHash;
|
|
139
|
+
|
|
140
|
+
if (!inputHash || !expected.length) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
await writeImageHashes({
|
|
144
|
+
outputDir: output,
|
|
145
|
+
files: expected,
|
|
146
|
+
inputHash,
|
|
147
|
+
hashKey: HASH_KEY
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
super.afterExport(...args);
|
|
151
|
+
}
|
|
13
152
|
};
|
|
14
153
|
|
|
15
154
|
module.exports = Sharp;
|
|
@@ -1,15 +1,142 @@
|
|
|
1
1
|
const
|
|
2
|
+
path = require('path'),
|
|
2
3
|
Cheer = require('./Cheer'),
|
|
4
|
+
{
|
|
5
|
+
HASH_KEYS,
|
|
6
|
+
hashPayload,
|
|
7
|
+
fileContentHash,
|
|
8
|
+
checkImageOutputs,
|
|
9
|
+
writeImageHashes,
|
|
10
|
+
listImageFiles
|
|
11
|
+
} = require('../helpers/imageHash'),
|
|
12
|
+
{ensureDir} = require('../helpers/dirs'),
|
|
13
|
+
HASH_KEY = HASH_KEYS.stackfully,
|
|
14
|
+
collectLayerInputs = (layers = []) => {
|
|
15
|
+
const
|
|
16
|
+
names = [];
|
|
17
|
+
|
|
18
|
+
for (const layer of layers) {
|
|
19
|
+
if (typeof layer === 'string') {
|
|
20
|
+
names.push(layer);
|
|
21
|
+
} else if (layer?.input) {
|
|
22
|
+
names.push(layer.input);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return names;
|
|
26
|
+
},
|
|
3
27
|
Stackfully = class Stackfully extends Cheer {
|
|
28
|
+
async checkDifference () {
|
|
29
|
+
const
|
|
30
|
+
{config} = this,
|
|
31
|
+
{src, output, options = {}, outputs = {}, files: fileOptions = {}} = config;
|
|
32
|
+
|
|
33
|
+
if (!src || !output) {
|
|
34
|
+
console.warn('Stackfully difference check needs src and output; running all.');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
await ensureDir(output);
|
|
39
|
+
|
|
40
|
+
const
|
|
41
|
+
sourceFiles = await listImageFiles(src),
|
|
42
|
+
sourceHashes = {},
|
|
43
|
+
staleOutputs = {},
|
|
44
|
+
hashes = {},
|
|
45
|
+
names = Object.keys(outputs);
|
|
46
|
+
|
|
47
|
+
for (const name of sourceFiles) {
|
|
48
|
+
sourceHashes[name] = await fileContentHash(path.join(src, name));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const outName of names) {
|
|
52
|
+
const
|
|
53
|
+
recipe = outputs[outName] || {},
|
|
54
|
+
layerInputs = collectLayerInputs(recipe.layers),
|
|
55
|
+
layerHashes = {};
|
|
56
|
+
|
|
57
|
+
for (const input of layerInputs) {
|
|
58
|
+
const
|
|
59
|
+
base = path.basename(input),
|
|
60
|
+
key = sourceHashes[input] != null
|
|
61
|
+
? input
|
|
62
|
+
: (sourceHashes[base] != null ? base : null);
|
|
63
|
+
|
|
64
|
+
if (key) {
|
|
65
|
+
layerHashes[input] = sourceHashes[key];
|
|
66
|
+
} else if (fileOptions[input]?.composite || fileOptions[base]?.composite) {
|
|
67
|
+
layerHashes[input] = `composite:${input}`;
|
|
68
|
+
} else {
|
|
69
|
+
layerHashes[input] = `missing:${input}`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const
|
|
74
|
+
inputHash = hashPayload({
|
|
75
|
+
options,
|
|
76
|
+
output: outName,
|
|
77
|
+
recipe,
|
|
78
|
+
layers: layerHashes
|
|
79
|
+
}),
|
|
80
|
+
{missing} = await checkImageOutputs({
|
|
81
|
+
outputDir: output,
|
|
82
|
+
expectedOutputs: [outName],
|
|
83
|
+
inputHash,
|
|
84
|
+
hashKey: HASH_KEY
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
hashes[outName] = inputHash;
|
|
88
|
+
if (missing.length) {
|
|
89
|
+
staleOutputs[outName] = recipe;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const
|
|
94
|
+
staleNames = Object.keys(staleOutputs);
|
|
95
|
+
|
|
96
|
+
if (!staleNames.length) {
|
|
97
|
+
throw new Error('All stackfully outputs already up to date.');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this.inputHashes = hashes;
|
|
101
|
+
this.updateList = staleNames;
|
|
102
|
+
config.outputs = staleOutputs;
|
|
103
|
+
this.differenceOnly = true;
|
|
104
|
+
}
|
|
105
|
+
|
|
4
106
|
beforeSend (archive) {
|
|
5
107
|
const
|
|
6
108
|
{src} = this.config;
|
|
7
109
|
|
|
8
110
|
if (src) {
|
|
111
|
+
// Always send full src — layer graphs may need shared bases even when
|
|
112
|
+
// some outputs were skipped. Omitting files would break composites.
|
|
9
113
|
archive.directory(src, false);
|
|
10
114
|
}
|
|
11
115
|
super.beforeSend(archive);
|
|
12
116
|
}
|
|
117
|
+
|
|
118
|
+
async afterExport (...args) {
|
|
119
|
+
const
|
|
120
|
+
{config, updateList, inputHashes} = this,
|
|
121
|
+
{output, outputs = {}} = config,
|
|
122
|
+
list = updateList ?? Object.keys(outputs);
|
|
123
|
+
|
|
124
|
+
for (const name of list) {
|
|
125
|
+
const
|
|
126
|
+
inputHash = inputHashes?.[name];
|
|
127
|
+
|
|
128
|
+
if (!inputHash) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
await writeImageHashes({
|
|
132
|
+
outputDir: output,
|
|
133
|
+
files: [name],
|
|
134
|
+
inputHash,
|
|
135
|
+
hashKey: HASH_KEY
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
super.afterExport(...args);
|
|
139
|
+
}
|
|
13
140
|
};
|
|
14
141
|
|
|
15
142
|
module.exports = Stackfully;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EXIF-backed image hash embedder (WebP / TIFF / AVIF) via Sharp.
|
|
3
|
+
* Stores a JSON map of hash keys in ImageDescription; merges on write.
|
|
4
|
+
*/
|
|
5
|
+
const
|
|
6
|
+
fs = require('fs').promises,
|
|
7
|
+
sharp = require('sharp'),
|
|
8
|
+
ImageFile = require('./ImageFile'),
|
|
9
|
+
{ensureParentDir} = require('../../helpers/dirs'),
|
|
10
|
+
MAP_MARKER = 'CheerfullyHashes:',
|
|
11
|
+
parseMap = (description) => {
|
|
12
|
+
if (!description || typeof description !== 'string') {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
const
|
|
16
|
+
idx = description.indexOf(MAP_MARKER);
|
|
17
|
+
|
|
18
|
+
if (idx < 0) {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(description.slice(idx + MAP_MARKER.length)) || {};
|
|
23
|
+
} catch (e) {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
serializeMap = (hashes) => `${MAP_MARKER}${JSON.stringify(hashes)}`,
|
|
28
|
+
ExifImage = class ExifImage extends ImageFile {
|
|
29
|
+
async load (path) {
|
|
30
|
+
this.path = path;
|
|
31
|
+
this.buffer = await fs.readFile(path);
|
|
32
|
+
const
|
|
33
|
+
meta = await sharp(this.buffer).metadata(),
|
|
34
|
+
desc = meta?.exif
|
|
35
|
+
? await this.readImageDescription(this.buffer)
|
|
36
|
+
: null;
|
|
37
|
+
|
|
38
|
+
this.format = meta.format;
|
|
39
|
+
this.hashes = parseMap(desc);
|
|
40
|
+
return this.hashes;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async readImageDescription (buffer) {
|
|
44
|
+
try {
|
|
45
|
+
const
|
|
46
|
+
meta = await sharp(buffer).metadata();
|
|
47
|
+
|
|
48
|
+
// sharp exposes exif as Buffer; parse ImageDescription via withMetadata round-trip is hard.
|
|
49
|
+
// Prefer XPComment / existing description if present in exif buffer as latin1 search.
|
|
50
|
+
if (!meta.exif) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const
|
|
54
|
+
asText = meta.exif.toString('binary'),
|
|
55
|
+
markerIdx = asText.indexOf(MAP_MARKER);
|
|
56
|
+
|
|
57
|
+
if (markerIdx >= 0) {
|
|
58
|
+
// Read until JSON object closes or null terminator
|
|
59
|
+
const
|
|
60
|
+
from = asText.slice(markerIdx),
|
|
61
|
+
jsonStart = from.indexOf('{'),
|
|
62
|
+
json = from.slice(jsonStart);
|
|
63
|
+
|
|
64
|
+
let
|
|
65
|
+
depth = 0,
|
|
66
|
+
end = -1;
|
|
67
|
+
|
|
68
|
+
for (let i = 0; i < json.length; i++) {
|
|
69
|
+
if (json[i] === '{') {
|
|
70
|
+
depth += 1;
|
|
71
|
+
} else if (json[i] === '}') {
|
|
72
|
+
depth -= 1;
|
|
73
|
+
if (depth === 0) {
|
|
74
|
+
end = i + 1;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (end > 0) {
|
|
80
|
+
return MAP_MARKER + json.slice(0, end);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch (e) { /* ignore */ }
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async save () {
|
|
88
|
+
await ensureParentDir(this.path);
|
|
89
|
+
|
|
90
|
+
const
|
|
91
|
+
description = serializeMap(this.hashes),
|
|
92
|
+
// Build minimal EXIF with ImageDescription (tag 0x010e)
|
|
93
|
+
// sharp accepts exif Buffer from piexif-like dump — use piexif for the IFD bytes on a dummy jpeg then strip...
|
|
94
|
+
// Simpler: sharp withMetadata exif object where supported.
|
|
95
|
+
pipeline = sharp(this.buffer).withMetadata({
|
|
96
|
+
exif: {
|
|
97
|
+
IFD0: {
|
|
98
|
+
ImageDescription: description
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}),
|
|
102
|
+
format = this.format || this.fileType;
|
|
103
|
+
|
|
104
|
+
let
|
|
105
|
+
out;
|
|
106
|
+
|
|
107
|
+
if (format === 'webp') {
|
|
108
|
+
out = await pipeline.webp({quality: 100, lossless: true}).toBuffer();
|
|
109
|
+
} else if (format === 'tiff' || format === 'tif') {
|
|
110
|
+
out = await pipeline.tiff({compression: 'lzw'}).toBuffer();
|
|
111
|
+
} else if (format === 'avif') {
|
|
112
|
+
out = await pipeline.avif({quality: 100, lossless: true}).toBuffer();
|
|
113
|
+
} else {
|
|
114
|
+
out = await pipeline.toBuffer();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await fs.writeFile(this.path, out);
|
|
118
|
+
this.buffer = out;
|
|
119
|
+
return this.path;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
module.exports = ExifImage;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const
|
|
2
|
+
File = require('../File'),
|
|
3
|
+
ImageFile = class ImageFile extends File {
|
|
4
|
+
constructor (opts) {
|
|
5
|
+
super(opts);
|
|
6
|
+
this.supportsHash = true;
|
|
7
|
+
this.hashes = {};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
addHash (hash, {hashKey = 'CheerfullyHash'} = {}) {
|
|
11
|
+
if (!this.supportsHash) {
|
|
12
|
+
return this;
|
|
13
|
+
}
|
|
14
|
+
this.hashes = {...this.hashes, [hashKey]: hash};
|
|
15
|
+
return this;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
getHash ({hashKey = 'CheerfullyHash'} = {}) {
|
|
19
|
+
if (!this.supportsHash) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return this.hashes[hashKey] ?? null;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
module.exports = ImageFile;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JPEG hash embedder — piexifjs ImageDescription JSON map (no recompress).
|
|
3
|
+
* Multiple service keys merge in one JSON object.
|
|
4
|
+
*/
|
|
5
|
+
const
|
|
6
|
+
fs = require('fs').promises,
|
|
7
|
+
piexif = require('piexifjs'),
|
|
8
|
+
ImageFile = require('./ImageFile'),
|
|
9
|
+
{ensureParentDir} = require('../../helpers/dirs'),
|
|
10
|
+
MAP_MARKER = 'CheerfullyHashes:',
|
|
11
|
+
parseMap = (description) => {
|
|
12
|
+
if (!description || typeof description !== 'string') {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
const
|
|
16
|
+
idx = description.indexOf(MAP_MARKER);
|
|
17
|
+
|
|
18
|
+
if (idx < 0) {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(description.slice(idx + MAP_MARKER.length)) || {};
|
|
23
|
+
} catch (e) {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
serializeMap = (hashes) => `${MAP_MARKER}${JSON.stringify(hashes)}`,
|
|
28
|
+
JPEG = class JPEG extends ImageFile {
|
|
29
|
+
async load (path) {
|
|
30
|
+
this.path = path;
|
|
31
|
+
this.buffer = await fs.readFile(path);
|
|
32
|
+
this.dataUrl = `data:image/jpeg;base64,${this.buffer.toString('base64')}`;
|
|
33
|
+
try {
|
|
34
|
+
this.exifObj = piexif.load(this.dataUrl);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
this.exifObj = {'0th': {}, Exif: {}, GPS: {}, '1st': {}, thumbnail: null};
|
|
37
|
+
}
|
|
38
|
+
const
|
|
39
|
+
desc = this.exifObj?.['0th']?.[piexif.ImageIFD.ImageDescription];
|
|
40
|
+
|
|
41
|
+
this.hashes = parseMap(desc);
|
|
42
|
+
return this.hashes;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async save () {
|
|
46
|
+
await ensureParentDir(this.path);
|
|
47
|
+
if (!this.exifObj) {
|
|
48
|
+
this.exifObj = {'0th': {}, Exif: {}, GPS: {}, '1st': {}, thumbnail: null};
|
|
49
|
+
}
|
|
50
|
+
if (!this.exifObj['0th']) {
|
|
51
|
+
this.exifObj['0th'] = {};
|
|
52
|
+
}
|
|
53
|
+
this.exifObj['0th'][piexif.ImageIFD.ImageDescription] = serializeMap(this.hashes);
|
|
54
|
+
|
|
55
|
+
const
|
|
56
|
+
exifBytes = piexif.dump(this.exifObj),
|
|
57
|
+
dataUrl = piexif.insert(exifBytes, this.dataUrl),
|
|
58
|
+
base64 = dataUrl.replace(/^data:image\/jpeg;base64,/, ''),
|
|
59
|
+
out = Buffer.from(base64, 'base64');
|
|
60
|
+
|
|
61
|
+
await fs.writeFile(this.path, out);
|
|
62
|
+
this.buffer = out;
|
|
63
|
+
return this.path;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
module.exports = JPEG;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PNG hash embedder — each hashKey is a tEXt keyword; other chunks preserved.
|
|
3
|
+
*/
|
|
4
|
+
const
|
|
5
|
+
fs = require('fs').promises,
|
|
6
|
+
extract = require('png-chunks-extract'),
|
|
7
|
+
encode = require('png-chunks-encode'),
|
|
8
|
+
text = require('png-chunk-text'),
|
|
9
|
+
ImageFile = require('./ImageFile'),
|
|
10
|
+
{ensureParentDir} = require('../../helpers/dirs'),
|
|
11
|
+
HASH_PREFIX = 'Cheerfully',
|
|
12
|
+
isHashKeyword = (keyword) => typeof keyword === 'string' && keyword.startsWith(HASH_PREFIX),
|
|
13
|
+
PNG = class PNG extends ImageFile {
|
|
14
|
+
async load (path) {
|
|
15
|
+
this.path = path;
|
|
16
|
+
this.buffer = await fs.readFile(path);
|
|
17
|
+
this.chunks = extract(this.buffer);
|
|
18
|
+
this.hashes = {};
|
|
19
|
+
|
|
20
|
+
for (const chunk of this.chunks) {
|
|
21
|
+
if (chunk.name === 'tEXt') {
|
|
22
|
+
try {
|
|
23
|
+
const
|
|
24
|
+
{keyword, text: value} = text.decode(chunk.data);
|
|
25
|
+
|
|
26
|
+
if (isHashKeyword(keyword)) {
|
|
27
|
+
this.hashes[keyword] = value;
|
|
28
|
+
}
|
|
29
|
+
} catch (e) { /* ignore bad text chunks */ }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return this.hashes;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async save () {
|
|
36
|
+
await ensureParentDir(this.path);
|
|
37
|
+
|
|
38
|
+
const
|
|
39
|
+
kept = this.chunks.filter((chunk) => {
|
|
40
|
+
if (chunk.name !== 'tEXt') {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const
|
|
45
|
+
{keyword} = text.decode(chunk.data);
|
|
46
|
+
|
|
47
|
+
return !isHashKeyword(keyword);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}),
|
|
52
|
+
// Insert tEXt before IEND
|
|
53
|
+
iend = kept.findIndex((c) => c.name === 'IEND'),
|
|
54
|
+
at = iend >= 0 ? iend : kept.length,
|
|
55
|
+
hashChunks = Object.keys(this.hashes).sort().map((keyword) =>
|
|
56
|
+
text.encode(keyword, String(this.hashes[keyword]))
|
|
57
|
+
),
|
|
58
|
+
next = [...kept.slice(0, at), ...hashChunks, ...kept.slice(at)];
|
|
59
|
+
|
|
60
|
+
this.buffer = Buffer.from(encode(next));
|
|
61
|
+
await fs.writeFile(this.path, this.buffer);
|
|
62
|
+
return this.path;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
module.exports = PNG;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image types that cannot carry Cheerfully hash metadata (always reprocessed).
|
|
3
|
+
*/
|
|
4
|
+
const
|
|
5
|
+
ImageFile = require('./ImageFile'),
|
|
6
|
+
UnsupportedImage = class UnsupportedImage extends ImageFile {
|
|
7
|
+
constructor (opts) {
|
|
8
|
+
super(opts);
|
|
9
|
+
this.supportsHash = false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async load (path) {
|
|
13
|
+
this.path = path;
|
|
14
|
+
this.hashes = {};
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
addHash () {
|
|
19
|
+
return this;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
getHash () {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async save () {
|
|
27
|
+
return this.path;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
module.exports = UnsupportedImage;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const
|
|
2
|
+
path = require('path'),
|
|
3
|
+
PNG = require('../formats/image/PNG'),
|
|
4
|
+
JPEG = require('../formats/image/JPEG'),
|
|
5
|
+
ExifImage = require('../formats/image/ExifImage'),
|
|
6
|
+
UnsupportedImage = require('../formats/image/UnsupportedImage'),
|
|
7
|
+
formats = {
|
|
8
|
+
png: PNG,
|
|
9
|
+
jpg: JPEG,
|
|
10
|
+
jpeg: JPEG,
|
|
11
|
+
webp: ExifImage,
|
|
12
|
+
tif: ExifImage,
|
|
13
|
+
tiff: ExifImage,
|
|
14
|
+
avif: ExifImage,
|
|
15
|
+
gif: UnsupportedImage,
|
|
16
|
+
ico: UnsupportedImage,
|
|
17
|
+
json: UnsupportedImage,
|
|
18
|
+
css: UnsupportedImage,
|
|
19
|
+
atlas: UnsupportedImage,
|
|
20
|
+
xml: UnsupportedImage
|
|
21
|
+
},
|
|
22
|
+
extOf = (filePath) => path.extname(filePath).replace(/^\./, '').toLowerCase(),
|
|
23
|
+
supportsHashExt = (ext) => {
|
|
24
|
+
const
|
|
25
|
+
File = formats[ext];
|
|
26
|
+
|
|
27
|
+
if (!File) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
// UnsupportedImage always sets supportsHash false
|
|
31
|
+
return File !== UnsupportedImage;
|
|
32
|
+
},
|
|
33
|
+
getImageFile = async (filePath, fileType) => {
|
|
34
|
+
const
|
|
35
|
+
ext = (fileType || extOf(filePath) || '').toLowerCase(),
|
|
36
|
+
File = formats[ext] ?? UnsupportedImage,
|
|
37
|
+
file = new File({fileType: ext || 'bin'});
|
|
38
|
+
|
|
39
|
+
await file.load(filePath);
|
|
40
|
+
return file;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
module.exports = getImageFile;
|
|
44
|
+
module.exports.formats = formats;
|
|
45
|
+
module.exports.extOf = extOf;
|
|
46
|
+
module.exports.supportsHashExt = supportsHashExt;
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared image content hashing + output skip/stamp for Sharp / Packfully / Stackfully.
|
|
3
|
+
*/
|
|
4
|
+
const
|
|
5
|
+
crypto = require('crypto'),
|
|
6
|
+
fs = require('fs').promises,
|
|
7
|
+
path = require('path'),
|
|
8
|
+
getImageFile = require('./getImageFile'),
|
|
9
|
+
{extOf, supportsHashExt} = getImageFile,
|
|
10
|
+
{ensureDir, readdirOrEmpty} = require('./dirs'),
|
|
11
|
+
HASH_KEYS = {
|
|
12
|
+
sharp: 'CheerfullySharpHash',
|
|
13
|
+
packfully: 'CheerfullyPackfullyHash',
|
|
14
|
+
stackfully: 'CheerfullyStackfullyHash'
|
|
15
|
+
},
|
|
16
|
+
notedUnsupported = new Set(),
|
|
17
|
+
noteUnsupported = (filePath) => {
|
|
18
|
+
const
|
|
19
|
+
ext = extOf(filePath) || path.extname(filePath),
|
|
20
|
+
key = `.${String(ext).replace(/^\./, '')}`;
|
|
21
|
+
|
|
22
|
+
if (!notedUnsupported.has(key)) {
|
|
23
|
+
notedUnsupported.add(key);
|
|
24
|
+
console.warn(`Hash skip unsupported for "${key}" (always reprocessed).`);
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
canonical = (value) => {
|
|
28
|
+
if (value == null || typeof value !== 'object') {
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
if (Array.isArray(value)) {
|
|
32
|
+
return value.map(canonical);
|
|
33
|
+
}
|
|
34
|
+
const
|
|
35
|
+
out = {};
|
|
36
|
+
|
|
37
|
+
Object.keys(value).sort().forEach((key) => {
|
|
38
|
+
out[key] = canonical(value[key]);
|
|
39
|
+
});
|
|
40
|
+
return out;
|
|
41
|
+
},
|
|
42
|
+
hashPayload = (parts) => crypto
|
|
43
|
+
.createHash('sha256')
|
|
44
|
+
.update(JSON.stringify(canonical(parts)))
|
|
45
|
+
.digest('hex'),
|
|
46
|
+
fileContentHash = async (absPath) => {
|
|
47
|
+
const
|
|
48
|
+
buf = await fs.readFile(absPath);
|
|
49
|
+
|
|
50
|
+
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
51
|
+
},
|
|
52
|
+
exists = async (absPath) => {
|
|
53
|
+
try {
|
|
54
|
+
await fs.access(absPath);
|
|
55
|
+
return true;
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
/**
|
|
61
|
+
* @param {object} opts
|
|
62
|
+
* @param {string} opts.outputDir
|
|
63
|
+
* @param {string[]} opts.expectedOutputs relative names under outputDir
|
|
64
|
+
* @param {string} opts.inputHash
|
|
65
|
+
* @param {string} opts.hashKey
|
|
66
|
+
*/
|
|
67
|
+
checkImageOutputs = async ({outputDir, expectedOutputs = [], inputHash, hashKey}) => {
|
|
68
|
+
const
|
|
69
|
+
missing = [],
|
|
70
|
+
present = [],
|
|
71
|
+
unsupported = [];
|
|
72
|
+
|
|
73
|
+
if (outputDir) {
|
|
74
|
+
await ensureDir(outputDir);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const name of expectedOutputs) {
|
|
78
|
+
const
|
|
79
|
+
fullPath = path.join(outputDir, name),
|
|
80
|
+
ext = extOf(name);
|
|
81
|
+
|
|
82
|
+
if (!supportsHashExt(ext)) {
|
|
83
|
+
noteUnsupported(name);
|
|
84
|
+
unsupported.push(name);
|
|
85
|
+
missing.push(name);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!(await exists(fullPath))) {
|
|
90
|
+
missing.push(name);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const
|
|
96
|
+
file = await getImageFile(fullPath),
|
|
97
|
+
stored = file.getHash({hashKey});
|
|
98
|
+
|
|
99
|
+
if (stored && stored === inputHash) {
|
|
100
|
+
present.push(name);
|
|
101
|
+
} else {
|
|
102
|
+
missing.push(name);
|
|
103
|
+
}
|
|
104
|
+
} catch (e) {
|
|
105
|
+
missing.push(name);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {missing, present, unsupported};
|
|
110
|
+
},
|
|
111
|
+
/**
|
|
112
|
+
* Stamp hashKey onto supporting output images.
|
|
113
|
+
* @param {object} opts
|
|
114
|
+
* @param {string} opts.outputDir
|
|
115
|
+
* @param {string[]} opts.files relative names
|
|
116
|
+
* @param {string} opts.inputHash
|
|
117
|
+
* @param {string} opts.hashKey
|
|
118
|
+
*/
|
|
119
|
+
writeImageHashes = async ({outputDir, files = [], inputHash, hashKey}) => {
|
|
120
|
+
for (const name of files) {
|
|
121
|
+
const
|
|
122
|
+
fullPath = path.join(outputDir, name),
|
|
123
|
+
ext = extOf(name);
|
|
124
|
+
|
|
125
|
+
if (!supportsHashExt(ext)) {
|
|
126
|
+
noteUnsupported(name);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!(await exists(fullPath))) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const
|
|
134
|
+
file = await getImageFile(fullPath);
|
|
135
|
+
|
|
136
|
+
if (!file.supportsHash) {
|
|
137
|
+
noteUnsupported(name);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
file.addHash(inputHash, {hashKey});
|
|
141
|
+
await file.save();
|
|
142
|
+
} catch (e) {
|
|
143
|
+
console.warn(`Unable to write hash to "${name}": ${e.message}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
listImageFiles = async (dir, types = ['png', 'jpg', 'jpeg', 'webp', 'avif', 'tiff', 'tif', 'gif']) => {
|
|
148
|
+
const
|
|
149
|
+
entries = await readdirOrEmpty(dir),
|
|
150
|
+
set = new Set(types.map((t) => t.toLowerCase()));
|
|
151
|
+
|
|
152
|
+
return entries.filter((name) => {
|
|
153
|
+
if (name.startsWith('.')) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
return set.has(extOf(name));
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
formatExt = (format, fallback = 'png') => {
|
|
160
|
+
if (format == null || format === '') {
|
|
161
|
+
return fallback;
|
|
162
|
+
}
|
|
163
|
+
const
|
|
164
|
+
f = String(format).toLowerCase().replace(/^\./, '');
|
|
165
|
+
|
|
166
|
+
if (f === 'jpeg') {
|
|
167
|
+
return 'jpg';
|
|
168
|
+
}
|
|
169
|
+
return f;
|
|
170
|
+
},
|
|
171
|
+
/**
|
|
172
|
+
* Predict sharp output filenames for one source (mirrors server helpers/sharp.js naming).
|
|
173
|
+
*/
|
|
174
|
+
predictSharpOutputs = (inputName, opts = {}) => {
|
|
175
|
+
const
|
|
176
|
+
stem = path.basename(inputName).replace(/\.[^.]+$/, ''),
|
|
177
|
+
inputExt = extOf(inputName) || 'png',
|
|
178
|
+
base = opts.prefix || stem,
|
|
179
|
+
rawFormat = opts.format,
|
|
180
|
+
formats = rawFormat == null || rawFormat === ''
|
|
181
|
+
? [formatExt(inputExt)]
|
|
182
|
+
: (Array.isArray(rawFormat) ? rawFormat : [rawFormat]).map((f) => formatExt(f, inputExt)),
|
|
183
|
+
sizes = opts.sizes,
|
|
184
|
+
names = [];
|
|
185
|
+
|
|
186
|
+
const
|
|
187
|
+
raster = formats.filter((f) => f !== 'ico'),
|
|
188
|
+
hasIco = formats.includes('ico');
|
|
189
|
+
|
|
190
|
+
if (sizes == null || (Array.isArray(sizes) && !sizes.length)) {
|
|
191
|
+
for (const format of raster) {
|
|
192
|
+
if (opts.output && raster.length === 1 && !hasIco) {
|
|
193
|
+
names.push(opts.output);
|
|
194
|
+
} else {
|
|
195
|
+
names.push(`${base}.${format}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} else if (typeof sizes[0] === 'number') {
|
|
199
|
+
for (const format of raster) {
|
|
200
|
+
for (const size of sizes) {
|
|
201
|
+
names.push(`${base}-${size}.${format}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
for (const format of raster) {
|
|
206
|
+
for (const entry of sizes) {
|
|
207
|
+
names.push(`${base}${entry.suffix || ''}.${format}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (hasIco) {
|
|
213
|
+
names.push(`${base}.ico`);
|
|
214
|
+
}
|
|
215
|
+
return [...new Set(names)];
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
module.exports = {
|
|
219
|
+
HASH_KEYS,
|
|
220
|
+
hashPayload,
|
|
221
|
+
fileContentHash,
|
|
222
|
+
checkImageOutputs,
|
|
223
|
+
writeImageHashes,
|
|
224
|
+
listImageFiles,
|
|
225
|
+
predictSharpOutputs,
|
|
226
|
+
canonical,
|
|
227
|
+
noteUnsupported
|
|
228
|
+
};
|
package/src/index.js
CHANGED