@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 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": "accessTokenToUseCheerfullyService"
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 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.
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.16.0",
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",
@@ -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
- postStream = (url, stream) => new Promise ((resolve, reject) => {
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
- console.warn('Error', response.json.errors);
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, async (res) => {
95
- if (res.headers['content-type']?.indexOf('application/json') >= 0) {
96
- let data = '';
97
- res.on('data', (chunk) => {
98
- data += chunk;
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
- resolve({
102
- json: JSON.parse(data)
103
- });
104
- });
105
- } else {
106
- resolve({
107
- stream: res
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', reject);
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(`${cleanPath(server)}/yap/${service}/${accessToken}`, archiveStream);
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 — piexifjs ImageDescription JSON map (no recompress).
3
- * Multiple service keys merge in one JSON object.
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
- 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)}`,
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.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);
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
- 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');
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
- ExifImage = require('../formats/image/ExifImage'),
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
- webp: ExifImage,
12
- tif: ExifImage,
13
- tiff: ExifImage,
14
- avif: ExifImage,
15
- gif: UnsupportedImage,
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, '&amp;')
13
+ .replace(/</g, '&lt;')
14
+ .replace(/"/g, '&quot;')
15
+ .replace(/'/g, '&apos;'),
16
+ unescapeXml = (value) => String(value)
17
+ .replace(/&quot;/g, '"')
18
+ .replace(/&apos;/g, "'")
19
+ .replace(/&lt;/g, '<')
20
+ .replace(/&gt;/g, '>')
21
+ .replace(/&amp;/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
- configs.forEach(async (config, index, {length}) => {
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
- } else {
28
- const
29
- serviceHandler = new parsers[service]({config, contents});
30
-
31
- try {
32
- await serviceHandler.prepare({...contents, ...config});
33
- } catch (e) {
34
- console.warn(e.message);
35
- return;
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;