@makefully/dutifully 1.14.1 → 1.16.0

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