@makefully/dutifully 1.15.0 → 1.18.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 +30 -2
- package/package.json +4 -3
- package/src/classes/Cheer.js +92 -15
- package/src/classes/Classfully.js +15 -0
- 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 +23 -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.18.0
|
|
6
|
+
|
|
7
|
+
- Store GIF content hashes in a Comment Extension (`CheerfullyHashes:{...}`), lossless.
|
|
8
|
+
|
|
9
|
+
## 1.17.0
|
|
10
|
+
|
|
11
|
+
- 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.
|
|
12
|
+
- Rename `ExifImage` → `XmpImage`; keep PNG on `tEXt` keywords.
|
|
13
|
+
- Reject non-zip Cheerfully responses with a readable preview instead of crashing inside `unzip-stream`.
|
|
14
|
+
- Surface chore `errors` as hard failures while polling status.
|
|
15
|
+
- Run multi-config services sequentially and catch per-job failures (`for`/`await` instead of fire-and-forget `forEach`).
|
|
16
|
+
- Document Sharp `extract` (center cutout) for favicon packs.
|
|
17
|
+
|
|
18
|
+
## 1.16.0
|
|
19
|
+
|
|
20
|
+
- Add `classfully` service client for approximating images as CSS class rules.
|
|
21
|
+
|
|
3
22
|
## 1.15.0
|
|
4
23
|
|
|
5
24
|
- Skip unchanged Sharp, Packfully, and Stackfully image outputs via embedded per-service metadata hashes (`CheerfullySharpHash`, `CheerfullyPackfullyHash`, `CheerfullyStackfullyHash`).
|
package/README.md
CHANGED
|
@@ -156,9 +156,36 @@ Spacing: `padding` is empty gap beyond `extrude`; `extrude` is edge-pixel bleed
|
|
|
156
156
|
|
|
157
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
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
|
+
|
|
159
186
|
### Sharp
|
|
160
187
|
|
|
161
|
-
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.
|
|
188
|
+
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.
|
|
162
189
|
|
|
163
190
|
```javascript
|
|
164
191
|
{
|
|
@@ -172,6 +199,7 @@ This still-image service converts, compresses, resizes, and can emit multi-size
|
|
|
172
199
|
},
|
|
173
200
|
"files": {
|
|
174
201
|
"logo.png": {
|
|
202
|
+
"extract": { "width": 1024, "height": 1024 },
|
|
175
203
|
"format": ["ico", "png"],
|
|
176
204
|
"sizes": [16, 32, 48, 64, 128, 256],
|
|
177
205
|
"prefix": "favicon"
|
|
@@ -181,7 +209,7 @@ This still-image service converts, compresses, resizes, and can emit multi-size
|
|
|
181
209
|
}
|
|
182
210
|
```
|
|
183
211
|
|
|
184
|
-
Unchanged outputs are skipped using embedded `CheerfullySharpHash` metadata (PNG tEXt / JPEG·WebP
|
|
212
|
+
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.
|
|
185
213
|
|
|
186
214
|
### Stackfully
|
|
187
215
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@makefully/dutifully",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.0",
|
|
4
4
|
"description": "Client tools for Cheerfully service.",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"scripts": {
|
|
19
19
|
"test": "node --test test/**/*.test.js",
|
|
20
20
|
"test-allosaurus": "node ./cheer.js -service allosaurus",
|
|
21
|
+
"test-classfully": "node ./cheer.js -service classfully",
|
|
21
22
|
"test-elevenlabs": "node ./cheer.js -service elevenlabs",
|
|
22
23
|
"test-ffmpeg": "node ./cheer.js -service ffmpeg",
|
|
23
24
|
"test-packfully": "node ./cheer.js -service packfully",
|
|
@@ -48,7 +49,8 @@
|
|
|
48
49
|
"captions",
|
|
49
50
|
"packfully",
|
|
50
51
|
"atlas",
|
|
51
|
-
"spritesheet"
|
|
52
|
+
"spritesheet",
|
|
53
|
+
"classfully"
|
|
52
54
|
],
|
|
53
55
|
"author": "Derek Detweiler <derek@makefullystudios.com>",
|
|
54
56
|
"contributors": [
|
|
@@ -65,7 +67,6 @@
|
|
|
65
67
|
"archiver": "^7.0.1",
|
|
66
68
|
"node-id3": "^0.2.6",
|
|
67
69
|
"node-web-audio-api": "^2.0.0",
|
|
68
|
-
"piexifjs": "^1.0.6",
|
|
69
70
|
"png-chunk-text": "^1.0.0",
|
|
70
71
|
"png-chunks-encode": "^1.0.0",
|
|
71
72
|
"png-chunks-extract": "^1.0.0",
|
package/src/classes/Cheer.js
CHANGED
|
@@ -4,8 +4,17 @@ 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,
|
|
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
|
+
},
|
|
9
18
|
postStream = (url, stream) => new Promise ((resolve, reject) => {
|
|
10
19
|
const
|
|
11
20
|
protocol = url.startsWith('https') ? https : http;
|
|
@@ -62,7 +71,12 @@ const
|
|
|
62
71
|
|
|
63
72
|
if (response.json) {
|
|
64
73
|
if (response.json.errors) {
|
|
65
|
-
|
|
74
|
+
const
|
|
75
|
+
list = Array.isArray(response.json.errors)
|
|
76
|
+
? response.json.errors
|
|
77
|
+
: [response.json.errors];
|
|
78
|
+
|
|
79
|
+
reject(new Error(list.join('; ')));
|
|
66
80
|
} else if (response.json.status) {
|
|
67
81
|
shareStatus(response.json.status);
|
|
68
82
|
setTimeout(checkStatus, 10000);
|
|
@@ -91,22 +105,81 @@ const
|
|
|
91
105
|
const
|
|
92
106
|
protocol = url.startsWith('https') ? https : http;
|
|
93
107
|
|
|
94
|
-
protocol.get(url,
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
res.
|
|
98
|
-
|
|
99
|
-
|
|
108
|
+
protocol.get(url, (res) => {
|
|
109
|
+
const
|
|
110
|
+
contentType = res.headers['content-type'] || '',
|
|
111
|
+
statusCode = res.statusCode || 0;
|
|
112
|
+
|
|
113
|
+
if (contentType.indexOf('application/json') >= 0) {
|
|
114
|
+
const
|
|
115
|
+
chunks = [];
|
|
116
|
+
|
|
117
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
118
|
+
res.on('error', reject);
|
|
100
119
|
res.on('close', () => {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
120
|
+
try {
|
|
121
|
+
resolve({
|
|
122
|
+
json: JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
123
|
+
});
|
|
124
|
+
} catch (e) {
|
|
125
|
+
reject(e);
|
|
126
|
+
}
|
|
108
127
|
});
|
|
128
|
+
return;
|
|
109
129
|
}
|
|
130
|
+
|
|
131
|
+
// Non-JSON chore results should be zip archives. Peek at the first
|
|
132
|
+
// bytes so HTML/text error bodies become actionable errors instead of
|
|
133
|
+
// opaque unzip-stream failures.
|
|
134
|
+
const
|
|
135
|
+
chunks = [];
|
|
136
|
+
let
|
|
137
|
+
settled = false,
|
|
138
|
+
total = 0;
|
|
139
|
+
|
|
140
|
+
res.on('error', (err) => {
|
|
141
|
+
if (!settled) {
|
|
142
|
+
settled = true;
|
|
143
|
+
reject(err);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
res.on('data', (chunk) => {
|
|
147
|
+
if (settled) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
chunks.push(chunk);
|
|
151
|
+
total += chunk.length;
|
|
152
|
+
if (total < 4) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const
|
|
157
|
+
head = Buffer.concat(chunks);
|
|
158
|
+
|
|
159
|
+
settled = true;
|
|
160
|
+
if (statusCode >= 400 || !isZipMagic(head)) {
|
|
161
|
+
res.resume();
|
|
162
|
+
reject(new Error(
|
|
163
|
+
`Expected a zip from Cheerfully (HTTP ${statusCode}, ${contentType || 'no content-type'}): ${previewText(head)}`
|
|
164
|
+
));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const
|
|
169
|
+
out = new PassThrough();
|
|
170
|
+
|
|
171
|
+
out.write(head);
|
|
172
|
+
res.pipe(out);
|
|
173
|
+
resolve({stream: out});
|
|
174
|
+
});
|
|
175
|
+
res.on('end', () => {
|
|
176
|
+
if (!settled) {
|
|
177
|
+
settled = true;
|
|
178
|
+
reject(new Error(
|
|
179
|
+
`Expected a zip from Cheerfully (HTTP ${statusCode}, ${contentType || 'no content-type'}): ${previewText(Buffer.concat(chunks))}`
|
|
180
|
+
));
|
|
181
|
+
}
|
|
182
|
+
});
|
|
110
183
|
}).on('error', (err) => reject(err));
|
|
111
184
|
}),
|
|
112
185
|
archive = async function (parser) {
|
|
@@ -203,7 +276,11 @@ const
|
|
|
203
276
|
} else {
|
|
204
277
|
dst.on('finish', () => finish(this.afterWrite));
|
|
205
278
|
}
|
|
206
|
-
dst.on('error',
|
|
279
|
+
dst.on('error', (err) => {
|
|
280
|
+
reject(new Error(
|
|
281
|
+
`Failed to unpack Cheerfully result into "${output}": ${err.message}`
|
|
282
|
+
));
|
|
283
|
+
});
|
|
207
284
|
|
|
208
285
|
try {
|
|
209
286
|
const
|
|
@@ -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;
|
|
@@ -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
|
@@ -4,6 +4,7 @@ const
|
|
|
4
4
|
LipSync = require('./classes/LipSync'),
|
|
5
5
|
parsers = {
|
|
6
6
|
allosaurus: LipSync,
|
|
7
|
+
classfully: require('./classes/Classfully'),
|
|
7
8
|
elevenlabs: require('./classes/ElevenLabs'),
|
|
8
9
|
ffmpeg: require('./classes/FFMPEG'),
|
|
9
10
|
packfully: require('./classes/Packfully'),
|
|
@@ -15,30 +16,39 @@ const
|
|
|
15
16
|
transcription: require('./classes/Transcription')
|
|
16
17
|
},
|
|
17
18
|
getJSON = require('./helpers/getJSON'),
|
|
18
|
-
send = function (contents) {
|
|
19
|
+
send = async function (contents) {
|
|
19
20
|
const
|
|
20
21
|
{id, service} = contents,
|
|
21
22
|
configs = Array.isArray(contents[service]) ? contents[service] : [contents[service]];
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
for (let index = 0; index < configs.length; index++) {
|
|
25
|
+
const
|
|
26
|
+
config = configs[index],
|
|
27
|
+
{length} = configs;
|
|
28
|
+
|
|
24
29
|
if (!config) {
|
|
25
30
|
console.warn(`Empty configuration for "${service}" service.`);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
+
}
|
|
36
43
|
|
|
44
|
+
try {
|
|
37
45
|
await serviceHandler.send({
|
|
38
46
|
instanceId: `${id}-${service}${length > 1 ? `-${index}` : ''}`
|
|
39
47
|
});
|
|
48
|
+
} catch (e) {
|
|
49
|
+
console.warn(`Error running "${service}" (${index}): ${e.message || e}`);
|
|
40
50
|
}
|
|
41
|
-
}
|
|
51
|
+
}
|
|
42
52
|
};
|
|
43
53
|
|
|
44
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;
|