@makefully/dutifully 1.16.0 → 1.19.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 +19 -0
- package/README.md +6 -3
- package/package.json +1 -2
- package/src/classes/Cheer.js +105 -20
- package/src/formats/image/GIF.js +26 -0
- package/src/formats/image/JPEG.js +12 -42
- package/src/formats/image/XmpImage.js +56 -0
- package/src/helpers/getImageFile.js +7 -6
- package/src/helpers/gifHashes.js +190 -0
- package/src/helpers/xmpHashes.js +252 -0
- package/src/index.js +22 -13
- package/src/formats/image/ExifImage.js +0 -123
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 1.19.0
|
|
6
|
+
|
|
7
|
+
- Authenticate Showfully yap chores with `Authorization: Bearer` and `POST /yap/:service` (token no longer in the URL path).
|
|
8
|
+
|
|
9
|
+
## 1.18.0
|
|
10
|
+
|
|
11
|
+
- Store GIF content hashes in a Comment Extension (`CheerfullyHashes:{...}`), lossless.
|
|
12
|
+
|
|
13
|
+
## 1.17.0
|
|
14
|
+
|
|
15
|
+
- Store JPEG/WebP/TIFF/AVIF content hashes in embedded XMP (`cheerfully:hashes`) instead of EXIF `ImageDescription`, so they no longer appear as Windows Title/Subject.
|
|
16
|
+
- Rename `ExifImage` → `XmpImage`; keep PNG on `tEXt` keywords.
|
|
17
|
+
- Reject non-zip Cheerfully responses with a readable preview instead of crashing inside `unzip-stream`.
|
|
18
|
+
- Surface chore `errors` as hard failures while polling status.
|
|
19
|
+
- Run multi-config services sequentially and catch per-job failures (`for`/`await` instead of fire-and-forget `forEach`).
|
|
20
|
+
- Document Sharp `extract` (center cutout) for favicon packs.
|
|
21
|
+
|
|
3
22
|
## 1.16.0
|
|
4
23
|
|
|
5
24
|
- Add `classfully` service client for approximating images as CSS class rules.
|
package/README.md
CHANGED
|
@@ -11,10 +11,12 @@ To use the API, you will need to specify a root `./env-cheerfully.json` file wit
|
|
|
11
11
|
```javascript
|
|
12
12
|
{
|
|
13
13
|
"server": "http://server:port",
|
|
14
|
-
"accessToken": "
|
|
14
|
+
"accessToken": "BearerTokenForShowfullyYap"
|
|
15
15
|
}
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
+
`accessToken` is sent as `Authorization: Bearer …` on yap submit/status/download (Showfully 1.4+). For local Showfully, use the same value as `SHOWFULLY_DEBUG_TOKEN` with `SHOWFULLY_ALLOW_DEBUG_TOKEN=1`.
|
|
19
|
+
|
|
18
20
|
## Services
|
|
19
21
|
|
|
20
22
|
All services can be specified in `cheerfully.json` as described in the following sections. Services can be run as scripts specified in `package.json` as:
|
|
@@ -185,7 +187,7 @@ This image-to-CSS service approximates still images as a CSS class rule (determi
|
|
|
185
187
|
|
|
186
188
|
### Sharp
|
|
187
189
|
|
|
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.
|
|
190
|
+
This still-image service converts, compresses, resizes, center-crops (`extract`), and can emit multi-size thumbnails or favicon packs (including `.ico`). Every image under `src` is processed; `files` overrides are optional.
|
|
189
191
|
|
|
190
192
|
```javascript
|
|
191
193
|
{
|
|
@@ -199,6 +201,7 @@ This still-image service converts, compresses, resizes, and can emit multi-size
|
|
|
199
201
|
},
|
|
200
202
|
"files": {
|
|
201
203
|
"logo.png": {
|
|
204
|
+
"extract": { "width": 1024, "height": 1024 },
|
|
202
205
|
"format": ["ico", "png"],
|
|
203
206
|
"sizes": [16, 32, 48, 64, 128, 256],
|
|
204
207
|
"prefix": "favicon"
|
|
@@ -208,7 +211,7 @@ This still-image service converts, compresses, resizes, and can emit multi-size
|
|
|
208
211
|
}
|
|
209
212
|
```
|
|
210
213
|
|
|
211
|
-
Unchanged outputs are skipped using embedded `CheerfullySharpHash` metadata (PNG tEXt / JPEG·WebP
|
|
214
|
+
Unchanged outputs are skipped using embedded `CheerfullySharpHash` metadata (PNG tEXt / GIF comment / JPEG·WebP·TIFF·AVIF XMP). Per-service keys so Sharp and Packfully/Stackfully hashes do not clobber each other. Formats that cannot store metadata (e.g. `.ico`) are always reprocessed, with a console note.
|
|
212
215
|
|
|
213
216
|
### Stackfully
|
|
214
217
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@makefully/dutifully",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.0",
|
|
4
4
|
"description": "Client tools for Cheerfully service.",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -67,7 +67,6 @@
|
|
|
67
67
|
"archiver": "^7.0.1",
|
|
68
68
|
"node-id3": "^0.2.6",
|
|
69
69
|
"node-web-audio-api": "^2.0.0",
|
|
70
|
-
"piexifjs": "^1.0.6",
|
|
71
70
|
"png-chunk-text": "^1.0.0",
|
|
72
71
|
"png-chunks-encode": "^1.0.0",
|
|
73
72
|
"png-chunks-extract": "^1.0.0",
|
package/src/classes/Cheer.js
CHANGED
|
@@ -4,15 +4,28 @@ const
|
|
|
4
4
|
getJSON = require('../helpers/getJSON'),
|
|
5
5
|
http = require('http'),
|
|
6
6
|
https = require('https'),
|
|
7
|
+
{PassThrough} = require('stream'),
|
|
7
8
|
unzipper = require('unzip-stream'),
|
|
8
9
|
cleanPath = (path) => path[path.length - 1] === '/' ? path.substring(0, path.length - 1) : path,
|
|
9
|
-
|
|
10
|
+
isZipMagic = (buf) => buf && buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b &&
|
|
11
|
+
((buf[2] === 0x03 && buf[3] === 0x04) || (buf[2] === 0x05 && buf[3] === 0x06) || (buf[2] === 0x07 && buf[3] === 0x08)),
|
|
12
|
+
previewText = (buf, max = 240) => {
|
|
13
|
+
if (!buf || !buf.length) {
|
|
14
|
+
return '(empty)';
|
|
15
|
+
}
|
|
16
|
+
return buf.subarray(0, max).toString('utf8').replace(/\s+/g, ' ').trim();
|
|
17
|
+
},
|
|
18
|
+
authHeaders = (accessToken) => (accessToken
|
|
19
|
+
? {Authorization: `Bearer ${accessToken}`}
|
|
20
|
+
: {}),
|
|
21
|
+
postStream = (url, stream, headers = {}) => new Promise ((resolve, reject) => {
|
|
10
22
|
const
|
|
11
23
|
protocol = url.startsWith('https') ? https : http;
|
|
12
24
|
req = protocol.request(url, {
|
|
13
25
|
method: 'post',
|
|
14
26
|
headers: {
|
|
15
|
-
'Content-Type': 'application/zip'
|
|
27
|
+
'Content-Type': 'application/zip',
|
|
28
|
+
...headers
|
|
16
29
|
}
|
|
17
30
|
}, async (res) => {
|
|
18
31
|
let data = '';
|
|
@@ -52,7 +65,7 @@ const
|
|
|
52
65
|
let response = null;
|
|
53
66
|
|
|
54
67
|
try {
|
|
55
|
-
response = await fetchStream(`${url}/${choreId}
|
|
68
|
+
response = await fetchStream(`${url}/${choreId}`, headers);
|
|
56
69
|
} catch (e) {
|
|
57
70
|
console.warn(e);
|
|
58
71
|
|
|
@@ -62,7 +75,12 @@ const
|
|
|
62
75
|
|
|
63
76
|
if (response.json) {
|
|
64
77
|
if (response.json.errors) {
|
|
65
|
-
|
|
78
|
+
const
|
|
79
|
+
list = Array.isArray(response.json.errors)
|
|
80
|
+
? response.json.errors
|
|
81
|
+
: [response.json.errors];
|
|
82
|
+
|
|
83
|
+
reject(new Error(list.join('; ')));
|
|
66
84
|
} else if (response.json.status) {
|
|
67
85
|
shareStatus(response.json.status);
|
|
68
86
|
setTimeout(checkStatus, 10000);
|
|
@@ -87,26 +105,85 @@ const
|
|
|
87
105
|
|
|
88
106
|
stream.pipe(req);
|
|
89
107
|
}),
|
|
90
|
-
fetchStream = (url) => new Promise((resolve, reject) => {
|
|
108
|
+
fetchStream = (url, headers = {}) => new Promise((resolve, reject) => {
|
|
91
109
|
const
|
|
92
110
|
protocol = url.startsWith('https') ? https : http;
|
|
93
111
|
|
|
94
|
-
protocol.get(url,
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
res.
|
|
98
|
-
|
|
99
|
-
|
|
112
|
+
protocol.get(url, {headers}, (res) => {
|
|
113
|
+
const
|
|
114
|
+
contentType = res.headers['content-type'] || '',
|
|
115
|
+
statusCode = res.statusCode || 0;
|
|
116
|
+
|
|
117
|
+
if (contentType.indexOf('application/json') >= 0) {
|
|
118
|
+
const
|
|
119
|
+
chunks = [];
|
|
120
|
+
|
|
121
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
122
|
+
res.on('error', reject);
|
|
100
123
|
res.on('close', () => {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
124
|
+
try {
|
|
125
|
+
resolve({
|
|
126
|
+
json: JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
127
|
+
});
|
|
128
|
+
} catch (e) {
|
|
129
|
+
reject(e);
|
|
130
|
+
}
|
|
108
131
|
});
|
|
132
|
+
return;
|
|
109
133
|
}
|
|
134
|
+
|
|
135
|
+
// Non-JSON chore results should be zip archives. Peek at the first
|
|
136
|
+
// bytes so HTML/text error bodies become actionable errors instead of
|
|
137
|
+
// opaque unzip-stream failures.
|
|
138
|
+
const
|
|
139
|
+
chunks = [];
|
|
140
|
+
let
|
|
141
|
+
settled = false,
|
|
142
|
+
total = 0;
|
|
143
|
+
|
|
144
|
+
res.on('error', (err) => {
|
|
145
|
+
if (!settled) {
|
|
146
|
+
settled = true;
|
|
147
|
+
reject(err);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
res.on('data', (chunk) => {
|
|
151
|
+
if (settled) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
chunks.push(chunk);
|
|
155
|
+
total += chunk.length;
|
|
156
|
+
if (total < 4) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const
|
|
161
|
+
head = Buffer.concat(chunks);
|
|
162
|
+
|
|
163
|
+
settled = true;
|
|
164
|
+
if (statusCode >= 400 || !isZipMagic(head)) {
|
|
165
|
+
res.resume();
|
|
166
|
+
reject(new Error(
|
|
167
|
+
`Expected a zip from Cheerfully (HTTP ${statusCode}, ${contentType || 'no content-type'}): ${previewText(head)}`
|
|
168
|
+
));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const
|
|
173
|
+
out = new PassThrough();
|
|
174
|
+
|
|
175
|
+
out.write(head);
|
|
176
|
+
res.pipe(out);
|
|
177
|
+
resolve({stream: out});
|
|
178
|
+
});
|
|
179
|
+
res.on('end', () => {
|
|
180
|
+
if (!settled) {
|
|
181
|
+
settled = true;
|
|
182
|
+
reject(new Error(
|
|
183
|
+
`Expected a zip from Cheerfully (HTTP ${statusCode}, ${contentType || 'no content-type'}): ${previewText(Buffer.concat(chunks))}`
|
|
184
|
+
));
|
|
185
|
+
}
|
|
186
|
+
});
|
|
110
187
|
}).on('error', (err) => reject(err));
|
|
111
188
|
}),
|
|
112
189
|
archive = async function (parser) {
|
|
@@ -203,12 +280,20 @@ const
|
|
|
203
280
|
} else {
|
|
204
281
|
dst.on('finish', () => finish(this.afterWrite));
|
|
205
282
|
}
|
|
206
|
-
dst.on('error',
|
|
283
|
+
dst.on('error', (err) => {
|
|
284
|
+
reject(new Error(
|
|
285
|
+
`Failed to unpack Cheerfully result into "${output}": ${err.message}`
|
|
286
|
+
));
|
|
287
|
+
});
|
|
207
288
|
|
|
208
289
|
try {
|
|
209
290
|
const
|
|
210
291
|
archiveStream = await archive((archive) => this.beforeSend(archive)),
|
|
211
|
-
data = await postStream(
|
|
292
|
+
data = await postStream(
|
|
293
|
+
`${cleanPath(server)}/yap/${service}`,
|
|
294
|
+
archiveStream,
|
|
295
|
+
authHeaders(accessToken)
|
|
296
|
+
);
|
|
212
297
|
|
|
213
298
|
// listen for all archive data to be written
|
|
214
299
|
archiveStream.on('close', function () {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GIF hash embedder — Comment Extension JSON map (no recompress).
|
|
3
|
+
* Multiple service keys merge in one CheerfullyHashes comment.
|
|
4
|
+
*/
|
|
5
|
+
const
|
|
6
|
+
fs = require('fs').promises,
|
|
7
|
+
ImageFile = require('./ImageFile'),
|
|
8
|
+
{ensureParentDir} = require('../../helpers/dirs'),
|
|
9
|
+
{readGifHashes, writeGifHashes} = require('../../helpers/gifHashes'),
|
|
10
|
+
GIF = class GIF extends ImageFile {
|
|
11
|
+
async load (path) {
|
|
12
|
+
this.path = path;
|
|
13
|
+
this.buffer = await fs.readFile(path);
|
|
14
|
+
this.hashes = readGifHashes(this.buffer);
|
|
15
|
+
return this.hashes;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async save () {
|
|
19
|
+
await ensureParentDir(this.path);
|
|
20
|
+
this.buffer = writeGifHashes(this.buffer, this.hashes);
|
|
21
|
+
await fs.writeFile(this.path, this.buffer);
|
|
22
|
+
return this.path;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
module.exports = GIF;
|
|
@@ -1,62 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* JPEG hash embedder —
|
|
3
|
-
* Multiple service keys merge in one JSON
|
|
2
|
+
* JPEG hash embedder — lossless XMP APP1 packet (no recompress).
|
|
3
|
+
* Multiple service keys merge in one cheerfully:hashes JSON map.
|
|
4
4
|
*/
|
|
5
5
|
const
|
|
6
6
|
fs = require('fs').promises,
|
|
7
|
-
piexif = require('piexifjs'),
|
|
8
7
|
ImageFile = require('./ImageFile'),
|
|
9
8
|
{ensureParentDir} = require('../../helpers/dirs'),
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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)}`,
|
|
9
|
+
{
|
|
10
|
+
parseHashesFromXmp,
|
|
11
|
+
injectHashesIntoXmp,
|
|
12
|
+
readJpegXmp,
|
|
13
|
+
writeJpegXmp
|
|
14
|
+
} = require('../../helpers/xmpHashes'),
|
|
28
15
|
JPEG = class JPEG extends ImageFile {
|
|
29
16
|
async load (path) {
|
|
30
17
|
this.path = path;
|
|
31
18
|
this.buffer = await fs.readFile(path);
|
|
32
|
-
this.
|
|
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);
|
|
19
|
+
this.hashes = parseHashesFromXmp(readJpegXmp(this.buffer));
|
|
42
20
|
return this.hashes;
|
|
43
21
|
}
|
|
44
22
|
|
|
45
23
|
async save () {
|
|
46
24
|
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
25
|
|
|
55
26
|
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
out = Buffer.from(base64, 'base64');
|
|
27
|
+
existing = readJpegXmp(this.buffer),
|
|
28
|
+
xmp = injectHashesIntoXmp(existing, this.hashes),
|
|
29
|
+
out = writeJpegXmp(this.buffer, xmp);
|
|
60
30
|
|
|
61
31
|
await fs.writeFile(this.path, out);
|
|
62
32
|
this.buffer = out;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XMP-backed image hash embedder (WebP / TIFF / AVIF) via Sharp.
|
|
3
|
+
* Stores a JSON map of hash keys in cheerfully:hashes; 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
|
+
{
|
|
11
|
+
parseHashesFromXmp,
|
|
12
|
+
injectHashesIntoXmp
|
|
13
|
+
} = require('../../helpers/xmpHashes'),
|
|
14
|
+
XmpImage = class XmpImage extends ImageFile {
|
|
15
|
+
async load (path) {
|
|
16
|
+
this.path = path;
|
|
17
|
+
this.buffer = await fs.readFile(path);
|
|
18
|
+
const
|
|
19
|
+
meta = await sharp(this.buffer).metadata(),
|
|
20
|
+
xmp = meta.xmpAsString || meta.xmp || null;
|
|
21
|
+
|
|
22
|
+
this.format = meta.format;
|
|
23
|
+
this.hashes = parseHashesFromXmp(xmp);
|
|
24
|
+
return this.hashes;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async save () {
|
|
28
|
+
await ensureParentDir(this.path);
|
|
29
|
+
|
|
30
|
+
const
|
|
31
|
+
meta = await sharp(this.buffer).metadata(),
|
|
32
|
+
existing = meta.xmpAsString || meta.xmp || null,
|
|
33
|
+
xmp = injectHashesIntoXmp(existing, this.hashes),
|
|
34
|
+
pipeline = sharp(this.buffer).withMetadata().withXmp(xmp),
|
|
35
|
+
format = this.format || this.fileType;
|
|
36
|
+
|
|
37
|
+
let
|
|
38
|
+
out;
|
|
39
|
+
|
|
40
|
+
if (format === 'webp') {
|
|
41
|
+
out = await pipeline.webp({quality: 100, lossless: true}).toBuffer();
|
|
42
|
+
} else if (format === 'tiff' || format === 'tif') {
|
|
43
|
+
out = await pipeline.tiff({compression: 'lzw'}).toBuffer();
|
|
44
|
+
} else if (format === 'avif') {
|
|
45
|
+
out = await pipeline.avif({quality: 100, lossless: true}).toBuffer();
|
|
46
|
+
} else {
|
|
47
|
+
out = await pipeline.toBuffer();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await fs.writeFile(this.path, out);
|
|
51
|
+
this.buffer = out;
|
|
52
|
+
return this.path;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
module.exports = XmpImage;
|
|
@@ -2,17 +2,18 @@ const
|
|
|
2
2
|
path = require('path'),
|
|
3
3
|
PNG = require('../formats/image/PNG'),
|
|
4
4
|
JPEG = require('../formats/image/JPEG'),
|
|
5
|
-
|
|
5
|
+
GIF = require('../formats/image/GIF'),
|
|
6
|
+
XmpImage = require('../formats/image/XmpImage'),
|
|
6
7
|
UnsupportedImage = require('../formats/image/UnsupportedImage'),
|
|
7
8
|
formats = {
|
|
8
9
|
png: PNG,
|
|
9
10
|
jpg: JPEG,
|
|
10
11
|
jpeg: JPEG,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
gif: GIF,
|
|
13
|
+
webp: XmpImage,
|
|
14
|
+
tif: XmpImage,
|
|
15
|
+
tiff: XmpImage,
|
|
16
|
+
avif: XmpImage,
|
|
16
17
|
ico: UnsupportedImage,
|
|
17
18
|
json: UnsupportedImage,
|
|
18
19
|
css: UnsupportedImage,
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GIF Comment Extension helpers for Cheerfully hash maps (lossless).
|
|
3
|
+
* Comment text: CheerfullyHashes:{"CheerfullySharpHash":"..."}.
|
|
4
|
+
*/
|
|
5
|
+
const
|
|
6
|
+
MAP_MARKER = 'CheerfullyHashes:',
|
|
7
|
+
parseHashesFromComment = (text) => {
|
|
8
|
+
if (!text || typeof text !== 'string') {
|
|
9
|
+
return {};
|
|
10
|
+
}
|
|
11
|
+
const
|
|
12
|
+
idx = text.indexOf(MAP_MARKER);
|
|
13
|
+
|
|
14
|
+
if (idx < 0) {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(text.slice(idx + MAP_MARKER.length)) || {};
|
|
19
|
+
} catch (e) {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
serializeHashesComment = (hashes) => `${MAP_MARKER}${JSON.stringify(hashes || {})}`,
|
|
24
|
+
isGif = (buffer) => {
|
|
25
|
+
if (!buffer || buffer.length < 6) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
const
|
|
29
|
+
sig = buffer.slice(0, 6).toString('ascii');
|
|
30
|
+
|
|
31
|
+
return sig === 'GIF87a' || sig === 'GIF89a';
|
|
32
|
+
},
|
|
33
|
+
/**
|
|
34
|
+
* Read sub-blocks starting at offset (first byte is block size).
|
|
35
|
+
* @returns {{data: Buffer, next: number}}
|
|
36
|
+
*/
|
|
37
|
+
readSubBlocks = (buffer, offset) => {
|
|
38
|
+
const
|
|
39
|
+
chunks = [];
|
|
40
|
+
|
|
41
|
+
let
|
|
42
|
+
pos = offset;
|
|
43
|
+
|
|
44
|
+
while (pos < buffer.length) {
|
|
45
|
+
const
|
|
46
|
+
size = buffer[pos];
|
|
47
|
+
|
|
48
|
+
pos += 1;
|
|
49
|
+
if (size === 0) {
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
if (pos + size > buffer.length) {
|
|
53
|
+
throw new Error('Truncated GIF sub-blocks');
|
|
54
|
+
}
|
|
55
|
+
chunks.push(buffer.slice(pos, pos + size));
|
|
56
|
+
pos += size;
|
|
57
|
+
}
|
|
58
|
+
return {data: Buffer.concat(chunks), next: pos};
|
|
59
|
+
},
|
|
60
|
+
encodeCommentExtension = (text) => {
|
|
61
|
+
const
|
|
62
|
+
bytes = Buffer.from(text, 'utf8'),
|
|
63
|
+
parts = [Buffer.from([0x21, 0xfe])];
|
|
64
|
+
|
|
65
|
+
for (let i = 0; i < bytes.length; i += 255) {
|
|
66
|
+
const
|
|
67
|
+
chunk = bytes.slice(i, i + 255);
|
|
68
|
+
|
|
69
|
+
parts.push(Buffer.from([chunk.length]), chunk);
|
|
70
|
+
}
|
|
71
|
+
parts.push(Buffer.from([0x00]));
|
|
72
|
+
return Buffer.concat(parts);
|
|
73
|
+
},
|
|
74
|
+
/**
|
|
75
|
+
* Collect Cheerfully hash comments; return all comments' ranges for rewrite.
|
|
76
|
+
*/
|
|
77
|
+
scanGifComments = (buffer) => {
|
|
78
|
+
if (!isGif(buffer)) {
|
|
79
|
+
throw new Error('Not a GIF buffer');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let
|
|
83
|
+
offset = 6; // skip signature
|
|
84
|
+
|
|
85
|
+
const
|
|
86
|
+
packed = buffer[offset + 4];
|
|
87
|
+
|
|
88
|
+
offset += 7; // logical screen descriptor
|
|
89
|
+
if (packed & 0x80) {
|
|
90
|
+
offset += 3 * (2 ** ((packed & 0x07) + 1));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const
|
|
94
|
+
comments = [],
|
|
95
|
+
cheerfully = [];
|
|
96
|
+
|
|
97
|
+
while (offset < buffer.length) {
|
|
98
|
+
const
|
|
99
|
+
marker = buffer[offset];
|
|
100
|
+
|
|
101
|
+
if (marker === 0x3b) {
|
|
102
|
+
return {comments, cheerfully, trailerAt: offset};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (marker === 0x21) {
|
|
106
|
+
const
|
|
107
|
+
start = offset,
|
|
108
|
+
label = buffer[offset + 1],
|
|
109
|
+
{data, next} = readSubBlocks(buffer, offset + 2),
|
|
110
|
+
text = data.toString('utf8');
|
|
111
|
+
|
|
112
|
+
comments.push({start, end: next, label, text});
|
|
113
|
+
if (label === 0xfe && text.includes(MAP_MARKER)) {
|
|
114
|
+
cheerfully.push({start, end: next, text});
|
|
115
|
+
}
|
|
116
|
+
offset = next;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (marker === 0x2c) {
|
|
121
|
+
// Image descriptor: 10 bytes header after marker, then optional LCT, then LZW + sub-blocks
|
|
122
|
+
if (offset + 10 > buffer.length) {
|
|
123
|
+
throw new Error('Truncated GIF image descriptor');
|
|
124
|
+
}
|
|
125
|
+
const
|
|
126
|
+
localPacked = buffer[offset + 9];
|
|
127
|
+
|
|
128
|
+
offset += 10;
|
|
129
|
+
if (localPacked & 0x80) {
|
|
130
|
+
offset += 3 * (2 ** ((localPacked & 0x07) + 1));
|
|
131
|
+
}
|
|
132
|
+
// LZW minimum code size
|
|
133
|
+
offset += 1;
|
|
134
|
+
({next: offset} = readSubBlocks(buffer, offset));
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
throw new Error(`Unexpected GIF block 0x${marker.toString(16)} at ${offset}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
throw new Error('GIF missing trailer');
|
|
142
|
+
},
|
|
143
|
+
readGifHashes = (buffer) => {
|
|
144
|
+
const
|
|
145
|
+
{cheerfully} = scanGifComments(buffer);
|
|
146
|
+
|
|
147
|
+
if (!cheerfully.length) {
|
|
148
|
+
return {};
|
|
149
|
+
}
|
|
150
|
+
// Last cheerfully comment wins (in case of duplicates)
|
|
151
|
+
return parseHashesFromComment(cheerfully[cheerfully.length - 1].text);
|
|
152
|
+
},
|
|
153
|
+
writeGifHashes = (buffer, hashes) => {
|
|
154
|
+
const
|
|
155
|
+
{cheerfully, trailerAt} = scanGifComments(buffer),
|
|
156
|
+
comment = encodeCommentExtension(serializeHashesComment(hashes)),
|
|
157
|
+
ranges = cheerfully.slice().sort((a, b) => a.start - b.start),
|
|
158
|
+
parts = [];
|
|
159
|
+
|
|
160
|
+
let
|
|
161
|
+
cursor = 0,
|
|
162
|
+
removedBeforeTrailer = 0;
|
|
163
|
+
|
|
164
|
+
for (const range of ranges) {
|
|
165
|
+
parts.push(buffer.slice(cursor, range.start));
|
|
166
|
+
cursor = range.end;
|
|
167
|
+
if (range.end <= trailerAt) {
|
|
168
|
+
removedBeforeTrailer += range.end - range.start;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const
|
|
173
|
+
kept = Buffer.concat([...parts, buffer.slice(cursor)]),
|
|
174
|
+
trailerIdx = trailerAt - removedBeforeTrailer;
|
|
175
|
+
|
|
176
|
+
return Buffer.concat([
|
|
177
|
+
kept.slice(0, trailerIdx),
|
|
178
|
+
comment,
|
|
179
|
+
kept.slice(trailerIdx)
|
|
180
|
+
]);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
module.exports = {
|
|
184
|
+
MAP_MARKER,
|
|
185
|
+
parseHashesFromComment,
|
|
186
|
+
serializeHashesComment,
|
|
187
|
+
readGifHashes,
|
|
188
|
+
writeGifHashes,
|
|
189
|
+
isGif
|
|
190
|
+
};
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Cheerfully hash map in embedded XMP (custom namespace).
|
|
3
|
+
* PNG keeps tEXt; GIF uses Comment Extension; JPEG/WebP/TIFF/AVIF use this packet.
|
|
4
|
+
*/
|
|
5
|
+
const
|
|
6
|
+
NS = 'https://makefullystudios.com/ns/cheerfully/1.0/',
|
|
7
|
+
NS_PREFIX = 'cheerfully',
|
|
8
|
+
PROP = 'hashes',
|
|
9
|
+
// JPEG APP1 XMP identification (null-terminated)
|
|
10
|
+
JPEG_XMP_NAMESPACE = 'http://ns.adobe.com/xap/1.0/\0',
|
|
11
|
+
escapeXml = (value) => String(value)
|
|
12
|
+
.replace(/&/g, '&')
|
|
13
|
+
.replace(/</g, '<')
|
|
14
|
+
.replace(/"/g, '"')
|
|
15
|
+
.replace(/'/g, '''),
|
|
16
|
+
unescapeXml = (value) => String(value)
|
|
17
|
+
.replace(/"/g, '"')
|
|
18
|
+
.replace(/'/g, "'")
|
|
19
|
+
.replace(/</g, '<')
|
|
20
|
+
.replace(/>/g, '>')
|
|
21
|
+
.replace(/&/g, '&'),
|
|
22
|
+
attrPattern = () => new RegExp(
|
|
23
|
+
`${NS_PREFIX}:${PROP}\\s*=\\s*"([^"]*)"`,
|
|
24
|
+
'i'
|
|
25
|
+
),
|
|
26
|
+
parseHashesFromXmp = (xmp) => {
|
|
27
|
+
if (!xmp) {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
const
|
|
31
|
+
text = Buffer.isBuffer(xmp) ? xmp.toString('utf8') : String(xmp),
|
|
32
|
+
match = text.match(attrPattern());
|
|
33
|
+
|
|
34
|
+
if (!match) {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(unescapeXml(match[1])) || {};
|
|
39
|
+
} catch (e) {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
buildXmpPacket = (hashes) => {
|
|
44
|
+
const
|
|
45
|
+
json = escapeXml(JSON.stringify(hashes || {}));
|
|
46
|
+
|
|
47
|
+
return [
|
|
48
|
+
'<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>',
|
|
49
|
+
'<x:xmpmeta xmlns:x="adobe:ns:meta/">',
|
|
50
|
+
' <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">',
|
|
51
|
+
' <rdf:Description rdf:about=""',
|
|
52
|
+
` xmlns:${NS_PREFIX}="${NS}"`,
|
|
53
|
+
` ${NS_PREFIX}:${PROP}="${json}"/>`,
|
|
54
|
+
' </rdf:RDF>',
|
|
55
|
+
'</x:xmpmeta>',
|
|
56
|
+
'<?xpacket end="w"?>'
|
|
57
|
+
].join('\n');
|
|
58
|
+
},
|
|
59
|
+
/**
|
|
60
|
+
* Merge cheerfully:hashes into existing XMP, or build a fresh packet.
|
|
61
|
+
* Replaces a prior cheerfully:hashes attribute when present.
|
|
62
|
+
*/
|
|
63
|
+
injectHashesIntoXmp = (existingXmp, hashes) => {
|
|
64
|
+
const
|
|
65
|
+
packet = existingXmp
|
|
66
|
+
? (Buffer.isBuffer(existingXmp) ? existingXmp.toString('utf8') : String(existingXmp))
|
|
67
|
+
: '',
|
|
68
|
+
json = escapeXml(JSON.stringify(hashes || {}));
|
|
69
|
+
|
|
70
|
+
if (!packet.trim()) {
|
|
71
|
+
return buildXmpPacket(hashes);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (attrPattern().test(packet)) {
|
|
75
|
+
return packet.replace(attrPattern(), `${NS_PREFIX}:${PROP}="${json}"`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Inject xmlns + attribute onto first rdf:Description (keep /> if self-closing)
|
|
79
|
+
if (/<rdf:Description\b[^>]*\/?>/i.test(packet)) {
|
|
80
|
+
return packet.replace(/<rdf:Description\b([^>]*?)(\/?)>/i, (full, attrs, selfClose) => {
|
|
81
|
+
let
|
|
82
|
+
next = attrs;
|
|
83
|
+
|
|
84
|
+
if (!new RegExp(`xmlns:${NS_PREFIX}=`, 'i').test(next)) {
|
|
85
|
+
next += ` xmlns:${NS_PREFIX}="${NS}"`;
|
|
86
|
+
}
|
|
87
|
+
next += ` ${NS_PREFIX}:${PROP}="${json}"`;
|
|
88
|
+
return `<rdf:Description${next}${selfClose}>`;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Fallback: replace whole packet (keeps our hashes readable)
|
|
93
|
+
return buildXmpPacket(hashes);
|
|
94
|
+
},
|
|
95
|
+
/**
|
|
96
|
+
* Lossless JPEG: find/replace/insert XMP APP1 segment. Returns null if not a JPEG.
|
|
97
|
+
*/
|
|
98
|
+
readJpegXmp = (buffer) => {
|
|
99
|
+
if (!buffer || buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
let
|
|
103
|
+
offset = 2;
|
|
104
|
+
|
|
105
|
+
while (offset + 4 <= buffer.length) {
|
|
106
|
+
if (buffer[offset] !== 0xff) {
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
const
|
|
110
|
+
marker = buffer[offset + 1];
|
|
111
|
+
|
|
112
|
+
// Standalone markers without length
|
|
113
|
+
if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
114
|
+
offset += 2;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (marker === 0x00 || marker === 0xff) {
|
|
118
|
+
offset += 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const
|
|
122
|
+
segLen = buffer.readUInt16BE(offset + 2),
|
|
123
|
+
segStart = offset + 4,
|
|
124
|
+
segEnd = offset + 2 + segLen;
|
|
125
|
+
|
|
126
|
+
if (segLen < 2 || segEnd > buffer.length) {
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
// SOS — image data follows
|
|
130
|
+
if (marker === 0xda) {
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
if (marker === 0xe1) {
|
|
134
|
+
const
|
|
135
|
+
ns = JPEG_XMP_NAMESPACE,
|
|
136
|
+
nsBuf = Buffer.from(ns, 'latin1');
|
|
137
|
+
|
|
138
|
+
if (buffer.compare(nsBuf, 0, nsBuf.length, segStart, segStart + nsBuf.length) === 0) {
|
|
139
|
+
return buffer.slice(segStart + nsBuf.length, segEnd).toString('utf8');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
offset = segEnd;
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
},
|
|
146
|
+
writeJpegXmp = (buffer, xmpString) => {
|
|
147
|
+
if (!buffer || buffer.length < 2 || buffer[0] !== 0xff || buffer[1] !== 0xd8) {
|
|
148
|
+
throw new Error('Not a JPEG buffer');
|
|
149
|
+
}
|
|
150
|
+
const
|
|
151
|
+
nsBuf = Buffer.from(JPEG_XMP_NAMESPACE, 'latin1'),
|
|
152
|
+
xmpBuf = Buffer.from(xmpString, 'utf8'),
|
|
153
|
+
payload = Buffer.concat([nsBuf, xmpBuf]),
|
|
154
|
+
parts = [buffer.slice(0, 2)]; // SOI
|
|
155
|
+
|
|
156
|
+
let
|
|
157
|
+
offset = 2,
|
|
158
|
+
inserted = false,
|
|
159
|
+
insertAfter = 2; // default: right after SOI
|
|
160
|
+
|
|
161
|
+
while (offset + 4 <= buffer.length) {
|
|
162
|
+
if (buffer[offset] !== 0xff) {
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
const
|
|
166
|
+
marker = buffer[offset + 1];
|
|
167
|
+
|
|
168
|
+
if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
169
|
+
parts.push(buffer.slice(offset, offset + 2));
|
|
170
|
+
offset += 2;
|
|
171
|
+
insertAfter = parts.length;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (marker === 0x00 || marker === 0xff) {
|
|
175
|
+
parts.push(buffer.slice(offset, offset + 1));
|
|
176
|
+
offset += 1;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const
|
|
180
|
+
segLen = buffer.readUInt16BE(offset + 2),
|
|
181
|
+
segEnd = offset + 2 + segLen;
|
|
182
|
+
|
|
183
|
+
if (segLen < 2 || segEnd > buffer.length) {
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
if (marker === 0xda) {
|
|
187
|
+
// Insert XMP before scan data if not already inserted
|
|
188
|
+
if (!inserted) {
|
|
189
|
+
const
|
|
190
|
+
len = payload.length + 2,
|
|
191
|
+
header = Buffer.alloc(4);
|
|
192
|
+
|
|
193
|
+
header[0] = 0xff;
|
|
194
|
+
header[1] = 0xe1;
|
|
195
|
+
header.writeUInt16BE(len, 2);
|
|
196
|
+
parts.push(header, payload);
|
|
197
|
+
inserted = true;
|
|
198
|
+
}
|
|
199
|
+
parts.push(buffer.slice(offset));
|
|
200
|
+
return Buffer.concat(parts);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const
|
|
204
|
+
isXmp = marker === 0xe1 &&
|
|
205
|
+
buffer.compare(nsBuf, 0, nsBuf.length, offset + 4, offset + 4 + nsBuf.length) === 0;
|
|
206
|
+
|
|
207
|
+
if (isXmp) {
|
|
208
|
+
// Drop old XMP; write replacement at this position once
|
|
209
|
+
if (!inserted) {
|
|
210
|
+
const
|
|
211
|
+
len = payload.length + 2,
|
|
212
|
+
header = Buffer.alloc(4);
|
|
213
|
+
|
|
214
|
+
header[0] = 0xff;
|
|
215
|
+
header[1] = 0xe1;
|
|
216
|
+
header.writeUInt16BE(len, 2);
|
|
217
|
+
parts.push(header, payload);
|
|
218
|
+
inserted = true;
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
parts.push(buffer.slice(offset, segEnd));
|
|
222
|
+
// Prefer after APP0/JFIF or EXIF APP1
|
|
223
|
+
if (marker === 0xe0 || marker === 0xe1) {
|
|
224
|
+
insertAfter = parts.length;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
offset = segEnd;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!inserted) {
|
|
231
|
+
const
|
|
232
|
+
len = payload.length + 2,
|
|
233
|
+
header = Buffer.alloc(4),
|
|
234
|
+
head = parts.slice(0, insertAfter),
|
|
235
|
+
tail = parts.slice(insertAfter);
|
|
236
|
+
|
|
237
|
+
header[0] = 0xff;
|
|
238
|
+
header[1] = 0xe1;
|
|
239
|
+
header.writeUInt16BE(len, 2);
|
|
240
|
+
return Buffer.concat([...head, header, payload, ...tail, buffer.slice(offset)]);
|
|
241
|
+
}
|
|
242
|
+
return Buffer.concat([...parts, buffer.slice(offset)]);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
module.exports = {
|
|
246
|
+
NS,
|
|
247
|
+
parseHashesFromXmp,
|
|
248
|
+
buildXmpPacket,
|
|
249
|
+
injectHashesIntoXmp,
|
|
250
|
+
readJpegXmp,
|
|
251
|
+
writeJpegXmp
|
|
252
|
+
};
|
package/src/index.js
CHANGED
|
@@ -16,30 +16,39 @@ const
|
|
|
16
16
|
transcription: require('./classes/Transcription')
|
|
17
17
|
},
|
|
18
18
|
getJSON = require('./helpers/getJSON'),
|
|
19
|
-
send = function (contents) {
|
|
19
|
+
send = async function (contents) {
|
|
20
20
|
const
|
|
21
21
|
{id, service} = contents,
|
|
22
22
|
configs = Array.isArray(contents[service]) ? contents[service] : [contents[service]];
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
for (let index = 0; index < configs.length; index++) {
|
|
25
|
+
const
|
|
26
|
+
config = configs[index],
|
|
27
|
+
{length} = configs;
|
|
28
|
+
|
|
25
29
|
if (!config) {
|
|
26
30
|
console.warn(`Empty configuration for "${service}" service.`);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const
|
|
35
|
+
serviceHandler = new parsers[service]({config, contents});
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
await serviceHandler.prepare({...contents, ...config});
|
|
39
|
+
} catch (e) {
|
|
40
|
+
console.warn(e.message);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
37
43
|
|
|
44
|
+
try {
|
|
38
45
|
await serviceHandler.send({
|
|
39
46
|
instanceId: `${id}-${service}${length > 1 ? `-${index}` : ''}`
|
|
40
47
|
});
|
|
48
|
+
} catch (e) {
|
|
49
|
+
console.warn(`Error running "${service}" (${index}): ${e.message || e}`);
|
|
41
50
|
}
|
|
42
|
-
}
|
|
51
|
+
}
|
|
43
52
|
};
|
|
44
53
|
|
|
45
54
|
const cheer = async (cmdArgs) => {
|
|
@@ -1,123 +0,0 @@
|
|
|
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;
|