@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 +9 -0
- package/README.md +66 -5
- package/package.json +1 -1
- package/src/classes/Sharp.js +15 -0
- package/src/classes/Stackfully.js +15 -0
- package/src/classes/TextToSpeech.js +40 -6
- package/src/classes/Transcription.js +51 -2
- package/src/helpers/getCaptions.js +21 -2
- package/src/index.js +2 -0
- package/src/say.js +29 -3
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": "
|
|
79
|
+
"voice": "21m00Tcm4TlvDq8ikWAM"
|
|
80
80
|
}]
|
|
81
81
|
}
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
-
You may set a single `voice` value
|
|
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
|
|
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
|
@@ -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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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 = {}) => {
|