@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 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 EXIF). Per-service keys so Sharp and Packfully/Stackfully hashes do not clobber each other. Formats that cannot store metadata (e.g. `.ico`, `.gif`) are always reprocessed, with a console note.
212
+ 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.15.0",
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",
@@ -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
- console.warn('Error', response.json.errors);
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, async (res) => {
95
- if (res.headers['content-type']?.indexOf('application/json') >= 0) {
96
- let data = '';
97
- res.on('data', (chunk) => {
98
- data += chunk;
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
- resolve({
102
- json: JSON.parse(data)
103
- });
104
- });
105
- } else {
106
- resolve({
107
- stream: res
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', reject);
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 — 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
@@ -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
- 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
+
24
29
  if (!config) {
25
30
  console.warn(`Empty configuration for "${service}" service.`);
26
- } else {
27
- const
28
- serviceHandler = new parsers[service]({config, contents});
29
-
30
- try {
31
- await serviceHandler.prepare({...contents, ...config});
32
- } catch (e) {
33
- console.warn(e.message);
34
- return;
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;