@makefully/dutifully 1.13.0 → 1.14.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.14.0
4
+
5
+ - Add `sharp` service client for image convert/compress/resize, thumbnails, and favicon packs.
6
+ - Add `stackfully` service client for multi-layer image compositing.
7
+
8
+ ## 1.13.1
9
+
10
+ - Harden `say` / ElevenLabs export when the result zip has no MP3 (clearer error, no unhandled ENOENT from ID3 metadata).
11
+
3
12
  ## 1.13.0
4
13
 
5
14
  - Add `packfully` service client for packing images into texture atlases.
package/README.md CHANGED
@@ -76,14 +76,14 @@ This text-to-voice service requires a script as input and will download audio VO
76
76
  "output": "./example/vo/en/",
77
77
  "model": "eleven_multilingual_v1",
78
78
  "updateAllMetaData": true,
79
- "voice": "Laura"
79
+ "voice": "21m00Tcm4TlvDq8ikWAM"
80
80
  }]
81
81
  }
82
82
  ```
83
83
 
84
- You may set a single `voice` value matching an available ElevenLabs voice id or this may be an object of key/value pairs with speaker name keys matched to voice ids. The speaker names must match speakers listed in the script. If "default" is provided as a key, this voice id will be used for any scripts not specifying a speaker.
84
+ You may set a single `voice` value to an ElevenLabs voice id, or an object of speaker name keys mapped to voice ids. The speaker names must match speakers listed in the script. If "default" is provided as a key, this voice id will be used for any scripts not specifying a speaker.
85
85
 
86
- Specifying the `model` is optional: if unspecified, Cheerfully will pick a good option for the selected `voice`.
86
+ Specifying the `model` is optional: if unspecified, Cheerfully tries to look up the voice id and pick a good model; if lookup fails it falls back to `eleven_multilingual_v2`.
87
87
 
88
88
  Specifying `updateAllMetaData` is `false` by default. If it's set to `true`, album, title, and unsynchronized lyrics are appended to any MP3's in the source directory that were not generated.
89
89
 
@@ -154,6 +154,67 @@ 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
+ ### Sharp
158
+
159
+ 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.
160
+
161
+ ```javascript
162
+ {
163
+ "sharp": [{
164
+ "src": "./example/images/",
165
+ "output": "./example/out/",
166
+ "options": {
167
+ "format": "webp",
168
+ "quality": 80,
169
+ "resize": { "width": 1920, "fit": "inside", "withoutEnlargement": true }
170
+ },
171
+ "files": {
172
+ "logo.png": {
173
+ "format": ["ico", "png"],
174
+ "sizes": [16, 32, 48, 64, 128, 256],
175
+ "prefix": "favicon"
176
+ }
177
+ }
178
+ }]
179
+ }
180
+ ```
181
+
182
+ ### Stackfully
183
+
184
+ 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`.
185
+
186
+ ```javascript
187
+ {
188
+ "stackfully": [{
189
+ "src": "./example/images/",
190
+ "output": "./example/composites/",
191
+ "options": {
192
+ "format": "png"
193
+ },
194
+ "files": {
195
+ "badge.png": { "composite": true }
196
+ },
197
+ "outputs": {
198
+ "hero.png": {
199
+ "width": 1024,
200
+ "height": 1024,
201
+ "format": "png",
202
+ "layers": [
203
+ "background.png",
204
+ {
205
+ "input": "foreground.png",
206
+ "alpha": 0.85,
207
+ "blend": "over",
208
+ "bottom": 0,
209
+ "right": 0
210
+ }
211
+ ]
212
+ }
213
+ }
214
+ }]
215
+ }
216
+ ```
217
+
157
218
  ### Polly
158
219
 
159
220
  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:
@@ -253,8 +314,8 @@ Script lines may include an optional `class` (for example `"title"` or `"stage"`
253
314
  Script lines may also include an optional `events` array for one-shot timed markers. Entries may be:
254
315
 
255
316
  * a **number** — add to the running absolute time offset (ms)
256
- * a **string** `"<phrase"` / `">phrase"` — set the offset to just before/after that phrase in the caption; any other string fires that event at the current offset
257
- * an **object** `{ "event", "time"?, "before"?, "after"? }` — fires `event`; optional fields set the absolute offset (`time` wins over `before` over `after`)
317
+ * a **string** `"<phrase"` / `">phrase"` — set the offset to just before/after that phrase in the caption (first word may be an incomplete suffix, last word an incomplete prefix); unmatched phrases leave the offset unchanged and are warned/logged for the client; any other string fires that event at the current offset
318
+ * an **object** `{ "event", "time"?, "before"?, "after"? }` — fires `event`; optional fields set the absolute offset (`time` wins over `before` over `after`; same phrase edge rules)
258
319
 
259
320
  Cheerfully resolves these against caption timings and exports them in parallel using the same caption `format` (`events.json`, `{id}.events.vtt` / `.srt` / `.sami` / `.smi` / `.lrc`, or a separate MP3 SYLT frame with content type `EVENTS` labeled `"events"`).
260
321
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makefully/dutifully",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "Client tools for Cheerfully service.",
5
5
  "main": "./src/index.js",
6
6
  "bin": {
@@ -0,0 +1,15 @@
1
+ const
2
+ Cheer = require('./Cheer'),
3
+ Sharp = class Sharp 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 = Sharp;
@@ -0,0 +1,15 @@
1
+ const
2
+ Cheer = require('./Cheer'),
3
+ Stackfully = class Stackfully 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 = Stackfully;
@@ -82,8 +82,12 @@ const
82
82
  await ensureDir(output);
83
83
  }
84
84
 
85
- Object.keys(captions).forEach(async (id) => {
85
+ const
86
+ ids = Object.keys(captions);
87
+
88
+ for (let i = 0; i < ids.length; i++) {
86
89
  const
90
+ id = ids[i],
87
91
  text = captionText(captions[id]),
88
92
  tags = {
89
93
  album,
@@ -95,13 +99,13 @@ const
95
99
  };
96
100
 
97
101
  await id3.update(tags, `${output}${id}.mp3`);
98
-
102
+
99
103
  if (generated) {
100
104
  console.log(`Voice generated for "${id}".`);
101
105
  } else {
102
106
  console.log(`Voice meta data updated for "${id}".`);
103
107
  }
104
- });
108
+ }
105
109
  },
106
110
  mapReduction = (map, list) => list.reduce((obj, file) => {
107
111
  const
@@ -167,7 +171,7 @@ const
167
171
  }
168
172
 
169
173
  if (updateAllMetaData && present.length) {
170
- appendMP3Meta({
174
+ await appendMP3Meta({
171
175
  album,
172
176
  captions: mapReduction(files, present),
173
177
  language,
@@ -186,9 +190,39 @@ const
186
190
  const
187
191
  {album, encodedBy, config, transcripts, updateList} = this,
188
192
  {files, output, language, voice} = config,
189
- list = updateList ?? Object.keys(files).map((id) => `${id}.mp3`);
193
+ list = updateList ?? Object.keys(files).map((id) => `${id}.mp3`),
194
+ missing = [];
195
+
196
+ for (let i = 0; i < list.length; i++) {
197
+ const
198
+ filename = list[i],
199
+ fullPath = `${output}${filename}`;
200
+
201
+ try {
202
+ await fs.access(fullPath);
203
+ } catch (e) {
204
+ missing.push(filename);
205
+ }
206
+ }
207
+
208
+ if (missing.length) {
209
+ let detail = '';
210
+
211
+ try {
212
+ const
213
+ log = JSON.parse(await fs.readFile(`${output}log.json`, 'utf8'));
214
+
215
+ if (log?.errors?.length) {
216
+ detail = ` Server errors: ${log.errors.join('; ')}`;
217
+ }
218
+ } catch (e) { /* no log.json */ }
219
+
220
+ throw new Error(
221
+ `Voice export missing ${missing.length === 1 ? `"${missing[0]}"` : `${missing.length} files (${missing.join(', ')})`}.${detail}`
222
+ );
223
+ }
190
224
 
191
- appendMP3Meta({
225
+ await appendMP3Meta({
192
226
  album,
193
227
  captions: files,
194
228
  generated: true,
@@ -53,6 +53,8 @@ const
53
53
 
54
54
  const
55
55
  {captions: alreadyCaptioned, file: captionsFile, files: fileMap} = await getCaptions(output, fileType),
56
+ eventsJsonExisting = fileType === 'json' ? await getJSON(`${output}events.json`) : null,
57
+ eventsFile = eventsJsonExisting ? await getFileData(`${output}events.json`, 'json') : null,
56
58
  check = (id, caption) => {
57
59
  const
58
60
  file = fileMap?.[id],
@@ -82,6 +84,17 @@ const
82
84
  return cap === '' || cap === original;
83
85
  }
84
86
  },
87
+ saveEventsFile = async () => {
88
+ if (!eventsFile) {
89
+ return;
90
+ }
91
+
92
+ if (Object.keys(eventsFile.data ?? {}).length === 0) {
93
+ await fs.rm(eventsFile.path, {force: true});
94
+ } else {
95
+ await eventsFile.save();
96
+ }
97
+ },
85
98
  mergeCaptions = async (newCaptions = false) => { // Must run _after_ all checks and will remove what's left.
86
99
  const
87
100
  olds = Object.keys(alreadyCaptioned);
@@ -92,14 +105,40 @@ const
92
105
 
93
106
  if (captionsFile) {
94
107
  captionsFile.removeKey(caption);
108
+ eventsFile?.removeKey(caption);
95
109
  } else {
96
- await fs.rm(`${output}${caption}.${fileType}`);
110
+ await fs.rm(`${output}${caption}.${fileType}`, {force: true});
111
+ // Remove parallel event export for the same caption id, if present.
112
+ await fs.rm(`${output}${caption}.events.${fileType}`, {force: true});
97
113
  }
98
114
  console.log(`Removed "${caption}"`);
99
115
  }
100
116
  if (captionsFile) {
101
117
  if (newCaptions) {
102
118
  captionsFile.mergeData(await getJSON(`${output}captions.json`));
119
+
120
+ // Drop events for every regenerated caption, then merge whatever the server returned.
121
+ // Captions that no longer export events stay removed (no orphan keys).
122
+ const
123
+ updated = this.updateList ?? [],
124
+ newEvents = await getJSON(`${output}events.json`) ?? {};
125
+
126
+ updated.forEach((filename) => {
127
+ const
128
+ id = filename.substring(0, filename.length - 4);
129
+
130
+ eventsFile?.removeKey(id);
131
+ });
132
+
133
+ if (eventsFile) {
134
+ eventsFile.mergeData(newEvents);
135
+ await saveEventsFile();
136
+ } else if (Object.keys(newEvents).length === 0) {
137
+ await fs.rm(`${output}events.json`, {force: true});
138
+ }
139
+ // else: first events.json for this output — unzipped partial is already correct.
140
+ } else if (olds.length) {
141
+ await saveEventsFile();
103
142
  }
104
143
  await captionsFile.save();
105
144
  }
@@ -128,6 +167,16 @@ const
128
167
  this.updateList = list;
129
168
  config.files = mapReduction(files, list);
130
169
  this.mergeCaptions = mergeCaptions;
170
+
171
+ // Sidecar event files are not overwritten when the server omits them — clear before regenerate.
172
+ if (fileType !== 'json' && fileType !== 'mp3') {
173
+ for (let i = 0; i < list.length; i++) {
174
+ const
175
+ id = list[i].substring(0, list[i].length - 4);
176
+
177
+ await fs.rm(`${output}${id}.events.${fileType}`, {force: true});
178
+ }
179
+ }
131
180
  }
132
181
 
133
182
  beforeSend (archive) {
@@ -167,4 +216,4 @@ const
167
216
  }
168
217
  };
169
218
 
170
- module.exports = Transcription;
219
+ module.exports = Transcription;
@@ -1,7 +1,21 @@
1
1
  const
2
2
  getFileData = require("./getFileData"),
3
3
  {readdirOrEmpty} = require("./dirs"),
4
- filter = (fileType, file) => (file.indexOf('.') !== 0) && (file.slice(-(fileType.length + 1)) === `.${fileType}`);
4
+ filter = (fileType, file) => {
5
+ if (file.indexOf('.') === 0) {
6
+ return false;
7
+ }
8
+
9
+ if (file.slice(-(fileType.length + 1)) !== `.${fileType}`) {
10
+ return false;
11
+ }
12
+
13
+ // Parallel event exports are `{id}.events.{format}` — not caption tracks.
14
+ const
15
+ base = file.slice(0, -(fileType.length + 1));
16
+
17
+ return !base.endsWith('.events');
18
+ };
5
19
 
6
20
  module.exports = async (path, fileType) => {
7
21
 
@@ -13,6 +27,11 @@ module.exports = async (path, fileType) => {
13
27
 
14
28
  return {
15
29
  captions: Object.keys(json ?? {}).reduce((obj, key) => {
30
+ // Guard against any legacy/mistaken event keys in captions.json.
31
+ if (key.endsWith('.events')) {
32
+ return obj;
33
+ }
34
+
16
35
  obj[key] = json[key].map(({content}) => content).join(' ');
17
36
  return obj;
18
37
  }, {}),
@@ -41,4 +60,4 @@ module.exports = async (path, fileType) => {
41
60
  files: fileMap
42
61
  };
43
62
  }
44
- };
63
+ };
package/src/index.js CHANGED
@@ -10,6 +10,8 @@ const
10
10
  polly: require('./classes/Polly'),
11
11
  rasterize: require('./classes/Rasterize'),
12
12
  rhubarb: LipSync,
13
+ sharp: require('./classes/Sharp'),
14
+ stackfully: require('./classes/Stackfully'),
13
15
  transcription: require('./classes/Transcription')
14
16
  },
15
17
  getJSON = require('./helpers/getJSON'),
package/src/say.js CHANGED
@@ -59,17 +59,20 @@ const
59
59
  },
60
60
  path: '',
61
61
  voice,
62
- output
62
+ output,
63
+ extract: true
63
64
  },
64
65
  handler = new ElevenLabs({
65
66
  config,
66
67
  contents: {
67
68
  ...contents,
68
- service: 'elevenlabs'
69
+ service: 'elevenlabs',
70
+ extract: true
69
71
  }
70
72
  });
71
73
 
72
74
  delete config.script;
75
+ delete config.src;
73
76
 
74
77
  await handler.prepare({
75
78
  difference: false,
@@ -79,7 +82,30 @@ const
79
82
  instanceId: `${contents.id}-say`
80
83
  });
81
84
 
82
- return path.join(CACHE_DIR, `${key}.mp3`);
85
+ const
86
+ mp3File = path.join(CACHE_DIR, `${key}.mp3`);
87
+
88
+ try {
89
+ await fsp.access(mp3File);
90
+ } catch (e) {
91
+ let detail = '';
92
+
93
+ try {
94
+ const
95
+ log = JSON.parse(await fsp.readFile(path.join(CACHE_DIR, 'log.json'), 'utf8'));
96
+
97
+ if (log?.errors?.length) {
98
+ detail = ` ${log.errors.join('; ')}`;
99
+ }
100
+ } catch (err) { /* ignore */ }
101
+
102
+ throw new Error(
103
+ `Speech generation finished but "${mp3File}" was not created.${detail} ` +
104
+ 'Check that the Cheerfully worker is running and picking up elevenlabs chores.'
105
+ );
106
+ }
107
+
108
+ return mp3File;
83
109
  };
84
110
 
85
111
  module.exports = async (textOrArgs = {}) => {