@makefully/dutifully 1.23.1 → 1.24.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 +13 -4
- package/package.json +3 -2
- package/src/classes/LipSync.js +51 -11
- package/src/classes/Metadata.js +34 -0
- package/src/classes/TextToSpeech.js +72 -24
- package/src/classes/Transcription.js +27 -8
- package/src/formats/WebM.js +96 -0
- package/src/helpers/getCaptions.js +24 -5
- package/src/helpers/getFileData.js +3 -0
- package/src/helpers/jobExports.js +44 -6
- package/src/index.js +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 1.24.0
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- WebM export format mirror for lipsync / transcription (`webm` beside `mp3`); `.weba` is an accepted alias that preserves the `.weba` filename on embed; read-only `WebM.js` via `music-metadata@^11`.
|
|
10
|
+
- TTS jobs send `album` + `generationHashes` for Cheerfully server-side metadata stamping; drop post-download ID3 hash writes for newly generated VO.
|
|
11
|
+
- `Metadata` Cheerfully client + `updateAllMetaData` via the `metadata` service (MP3 local ID3 fallback).
|
|
12
|
+
- Require Node `>=22` (for `music-metadata` ESM).
|
|
13
|
+
|
|
5
14
|
## 1.23.1
|
|
6
15
|
|
|
7
16
|
### Added
|
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ Client API for Cheerfully services. Cheerfully makes voice-over easy.
|
|
|
6
6
|
npm install @makefully/dutifully
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
+
Requires **Node.js 22+** (for `music-metadata` WebM reads).
|
|
10
|
+
|
|
9
11
|
To use the API, you will need a root `./env-cheerfully.json` with Showfully Yap credentials and optional per-service auth defaults:
|
|
10
12
|
|
|
11
13
|
```javascript
|
|
@@ -133,7 +135,9 @@ You may set a single `voice` value to an ElevenLabs voice id, or an object of sp
|
|
|
133
135
|
|
|
134
136
|
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`.
|
|
135
137
|
|
|
136
|
-
Specifying `updateAllMetaData` is `false` by default. If it's set to `true`, album, title, and unsynchronized lyrics are
|
|
138
|
+
Specifying `updateAllMetaData` is `false` by default. If it's set to `true`, album, title, and unsynchronized lyrics are applied to any present VO files that were not regenerated, via Cheerfully’s `metadata` service (works for `.mp3` and `.webm`; falls back to local ID3 for MP3 if the service is unavailable).
|
|
139
|
+
|
|
140
|
+
Newly generated VO is tagged on the server (`updateMediaMeta`) using `album` and per-file `generationHashes` from the Dutifully job. WebM is **read-only** on Dutifully (`music-metadata`); all WebM writes go through Cheerfully.
|
|
137
141
|
|
|
138
142
|
You may include credentials on the job as `auth.apiKey`, or set defaults in `env-cheerfully.json`:
|
|
139
143
|
|
|
@@ -149,6 +153,10 @@ You may include credentials on the job as `auth.apiKey`, or set defaults in `env
|
|
|
149
153
|
|
|
150
154
|
Legacy `elevenLabsApiKey` at the top level of `env-cheerfully.json` is still accepted.
|
|
151
155
|
|
|
156
|
+
### Metadata
|
|
157
|
+
|
|
158
|
+
Tag-only Cheerfully chore (`service: "metadata"`). Upload media plus a `metadata.json` payload of per-file tags (same schema as Cheerfully’s metadata service). Dutifully’s `Metadata` parser mirrors this for `updateAllMetaData` and ad-hoc retags.
|
|
159
|
+
|
|
152
160
|
### FFMPEG
|
|
153
161
|
|
|
154
162
|
This audio file process accepts ffmpeg commandline arguments to perform operations on an audio file.
|
|
@@ -322,7 +330,7 @@ This text-to-voice service requires a script as input and will download audio VO
|
|
|
322
330
|
|
|
323
331
|
You may set a single `voice` value matching an available AMazon Polly voice or this may be an object of key/value pairs with speaker name keys matched to voices. The speaker names must match speakers listed in the script. If "default" is provided as a key, this voice will be used for any scripts not specifying a speaker.
|
|
324
332
|
|
|
325
|
-
Specifying `updateAllMetaData` is `false` by default. If it's set to `true`,
|
|
333
|
+
Specifying `updateAllMetaData` is `false` by default. If it's set to `true`, present VO files are retagged via Cheerfully’s `metadata` service (same behavior as ElevenLabs).
|
|
326
334
|
|
|
327
335
|
### Allosaurus
|
|
328
336
|
|
|
@@ -341,7 +349,7 @@ This audio-to-lip-flap service requires audio as input and will download a mouth
|
|
|
341
349
|
}
|
|
342
350
|
```
|
|
343
351
|
|
|
344
|
-
Export formats include those supported by Rhubarb as well as `mp3`, which
|
|
352
|
+
Export formats include those supported by Rhubarb as well as `mp3` / `webm`, which embed lipsync timings into the source audio (ID3 SYLT or WebVTT track).
|
|
345
353
|
|
|
346
354
|
Set `exportFormat`, `exports`, or omit/`null` for export selection:
|
|
347
355
|
|
|
@@ -383,7 +391,7 @@ This audio-to-lip-flap service requires audio as input and will download a mouth
|
|
|
383
391
|
}
|
|
384
392
|
```
|
|
385
393
|
|
|
386
|
-
Export formats include those supported by Rhubarb as well as `mp3`, which
|
|
394
|
+
Export formats include those supported by Rhubarb as well as `mp3` / `webm`, which embed lipsync timings into the source audio (ID3 SYLT or WebVTT track).
|
|
387
395
|
|
|
388
396
|
Use `exportFormat`, `exports`, or omit/`null` with the same string | array | all semantics as Rhubarb (see Allosaurus above).
|
|
389
397
|
|
|
@@ -413,6 +421,7 @@ Transcription supports the following formats:
|
|
|
413
421
|
* JSON
|
|
414
422
|
* LRC
|
|
415
423
|
* MP3 (Embedded in source MP3 as a SYLT ID3 tag labeled "captions".)
|
|
424
|
+
* WebM (Embedded WebVTT track titled `captions` / `words`. Accepts `.webm` / `.weba` sources; embeds keep the source extension. Dutifully reads via `music-metadata`; embedding is server-side.)
|
|
416
425
|
* SAMI / SMI
|
|
417
426
|
* SRT
|
|
418
427
|
* VTT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@makefully/dutifully",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.0",
|
|
4
4
|
"description": "Client tools for Cheerfully service.",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -66,10 +66,11 @@
|
|
|
66
66
|
"access": "public"
|
|
67
67
|
},
|
|
68
68
|
"engines": {
|
|
69
|
-
"node": ">=
|
|
69
|
+
"node": ">=22"
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
72
|
"archiver": "^7.0.1",
|
|
73
|
+
"music-metadata": "^11.16.1",
|
|
73
74
|
"node-id3": "^0.2.6",
|
|
74
75
|
"node-web-audio-api": "^2.0.0",
|
|
75
76
|
"png-chunk-text": "^1.0.0",
|
package/src/classes/LipSync.js
CHANGED
|
@@ -2,10 +2,32 @@ const
|
|
|
2
2
|
Cheer = require('./Cheer'),
|
|
3
3
|
fs = require('fs').promises,
|
|
4
4
|
getJSON = require('../helpers/getJSON'),
|
|
5
|
-
{resolveJobExports} = require('../helpers/jobExports'),
|
|
5
|
+
{resolveJobExports, isWebMExt} = require('../helpers/jobExports'),
|
|
6
6
|
{ensureDir, ensureParentDir, readdirOrEmpty} = require('../helpers/dirs'),
|
|
7
7
|
id3 = require('node-id3').Promise,
|
|
8
|
-
|
|
8
|
+
getFileData = require('../helpers/getFileData'),
|
|
9
|
+
fileExt = (file) => {
|
|
10
|
+
const
|
|
11
|
+
i = file.lastIndexOf('.');
|
|
12
|
+
|
|
13
|
+
return i >= 0 ? file.slice(i + 1).toLowerCase() : '';
|
|
14
|
+
},
|
|
15
|
+
filter = (fileType, file) => {
|
|
16
|
+
if (file.indexOf('.') === 0) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const
|
|
21
|
+
ext = fileExt(file);
|
|
22
|
+
|
|
23
|
+
if (fileType === 'webm') {
|
|
24
|
+
return isWebMExt(ext);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return ext === fileType;
|
|
28
|
+
},
|
|
29
|
+
AUDIO_SRC_EXTS = ['mp3', 'webm', 'weba'],
|
|
30
|
+
stripExt = (file) => file.replace(/\.[^.]+$/, ''),
|
|
9
31
|
combine = async (path, fileType) => {
|
|
10
32
|
const
|
|
11
33
|
files = (await readdirOrEmpty(path)).filter(filter.bind(null, fileType)),
|
|
@@ -13,7 +35,8 @@ const
|
|
|
13
35
|
|
|
14
36
|
for (let i = 0; i < files.length; i++) {
|
|
15
37
|
const
|
|
16
|
-
file = files[i]
|
|
38
|
+
file = files[i],
|
|
39
|
+
ext = fileExt(file);
|
|
17
40
|
|
|
18
41
|
if (fileType === 'mp3') {
|
|
19
42
|
const
|
|
@@ -24,11 +47,22 @@ const
|
|
|
24
47
|
index = synchronisedLyrics.map(({shortText}) => shortText).indexOf('lipsync');
|
|
25
48
|
|
|
26
49
|
if (index >= 0) {
|
|
27
|
-
list.push(
|
|
50
|
+
list.push(stripExt(file));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} else if (fileType === 'webm' || isWebMExt(ext)) {
|
|
54
|
+
try {
|
|
55
|
+
const
|
|
56
|
+
webm = await getFileData(`${path}${file}`, ext);
|
|
57
|
+
|
|
58
|
+
if (webm.hasTimedTrack('lipsync')) {
|
|
59
|
+
list.push(stripExt(file));
|
|
28
60
|
}
|
|
61
|
+
} catch (e) {
|
|
62
|
+
// ignore unreadable webm/weba
|
|
29
63
|
}
|
|
30
64
|
} else {
|
|
31
|
-
list.push(
|
|
65
|
+
list.push(stripExt(file));
|
|
32
66
|
}
|
|
33
67
|
}
|
|
34
68
|
|
|
@@ -40,7 +74,7 @@ const
|
|
|
40
74
|
}, {}),
|
|
41
75
|
mapReduction = (map, list) => list.reduce((obj, file) => {
|
|
42
76
|
const
|
|
43
|
-
id =
|
|
77
|
+
id = stripExt(file);
|
|
44
78
|
|
|
45
79
|
obj[id] = map[id];
|
|
46
80
|
return obj;
|
|
@@ -145,6 +179,10 @@ const
|
|
|
145
179
|
|
|
146
180
|
if (formatId !== 'json') {
|
|
147
181
|
await fs.rm(`${output}${key}.${formatId}`, {force: true});
|
|
182
|
+
|
|
183
|
+
if (formatId === 'webm') {
|
|
184
|
+
await fs.rm(`${output}${key}.weba`, {force: true});
|
|
185
|
+
}
|
|
148
186
|
}
|
|
149
187
|
}
|
|
150
188
|
|
|
@@ -163,12 +201,14 @@ const
|
|
|
163
201
|
},
|
|
164
202
|
list = [];
|
|
165
203
|
|
|
166
|
-
for (const
|
|
167
|
-
const
|
|
168
|
-
|
|
204
|
+
for (const ext of AUDIO_SRC_EXTS) {
|
|
205
|
+
for (const fileName of (await readdirOrEmpty(src)).filter(filter.bind(null, ext))) {
|
|
206
|
+
const
|
|
207
|
+
id = stripExt(fileName);
|
|
169
208
|
|
|
170
|
-
|
|
171
|
-
|
|
209
|
+
if (!(await isCurrent(id))) {
|
|
210
|
+
list.push(fileName);
|
|
211
|
+
}
|
|
172
212
|
}
|
|
173
213
|
}
|
|
174
214
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const
|
|
2
|
+
Cheer = require('./Cheer'),
|
|
3
|
+
Metadata = class Metadata extends Cheer {
|
|
4
|
+
constructor (data) {
|
|
5
|
+
const
|
|
6
|
+
{mediaFiles = [], ...rest} = data;
|
|
7
|
+
|
|
8
|
+
super({
|
|
9
|
+
...rest,
|
|
10
|
+
contents: {
|
|
11
|
+
...rest.contents,
|
|
12
|
+
service: 'metadata'
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
this.mediaFiles = mediaFiles;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
beforeSend (archive) {
|
|
19
|
+
const
|
|
20
|
+
{src} = this.config;
|
|
21
|
+
|
|
22
|
+
if (this.mediaFiles.length) {
|
|
23
|
+
this.mediaFiles.forEach(({path: filePath, name}) => {
|
|
24
|
+
archive.file(filePath, {name});
|
|
25
|
+
});
|
|
26
|
+
} else if (src) {
|
|
27
|
+
archive.directory(src, false);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
super.beforeSend(archive);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
module.exports = Metadata;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const
|
|
2
2
|
Cheer = require('./Cheer'),
|
|
3
|
+
Metadata = require('./Metadata'),
|
|
3
4
|
Transcript = require('./Transcript'),
|
|
4
5
|
fs = require('fs').promises,
|
|
5
6
|
getFileData = require('../helpers/getFileData'),
|
|
@@ -143,6 +144,15 @@ const
|
|
|
143
144
|
this.config.files = standardizedFiles;
|
|
144
145
|
this.transcripts = transcripts;
|
|
145
146
|
|
|
147
|
+
// Server-side metadata stamping (Cheerfully updateMediaMeta).
|
|
148
|
+
this.config.album = this.album;
|
|
149
|
+
this.config.generationHashes = Object.keys(transcripts).reduce((map, id) => {
|
|
150
|
+
map[id] = transcripts[id].getHash({
|
|
151
|
+
seed: this.encodedBy ?? ''
|
|
152
|
+
});
|
|
153
|
+
return map;
|
|
154
|
+
}, {});
|
|
155
|
+
|
|
146
156
|
return super.prepare(data);
|
|
147
157
|
}
|
|
148
158
|
|
|
@@ -171,11 +181,12 @@ const
|
|
|
171
181
|
}
|
|
172
182
|
|
|
173
183
|
if (updateAllMetaData && present.length) {
|
|
174
|
-
await
|
|
184
|
+
await this.updatePresentMeta({
|
|
175
185
|
album,
|
|
176
186
|
captions: mapReduction(files, present),
|
|
177
187
|
language,
|
|
178
|
-
output
|
|
188
|
+
output,
|
|
189
|
+
present
|
|
179
190
|
});
|
|
180
191
|
}
|
|
181
192
|
|
|
@@ -186,10 +197,64 @@ const
|
|
|
186
197
|
this.updateList = missing;
|
|
187
198
|
}
|
|
188
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Retag existing VO via Cheerfully `metadata` service (mp3 / webm).
|
|
202
|
+
* Falls back to local ID3 writes if the metadata chore fails.
|
|
203
|
+
*/
|
|
204
|
+
async updatePresentMeta ({album, captions, language, output, present}) {
|
|
205
|
+
const
|
|
206
|
+
{contents} = this,
|
|
207
|
+
tagFiles = {},
|
|
208
|
+
mediaFiles = present.map((filename) => {
|
|
209
|
+
const
|
|
210
|
+
id = filename.substring(0, filename.lastIndexOf('.')),
|
|
211
|
+
text = captionText(captions[id]);
|
|
212
|
+
|
|
213
|
+
tagFiles[filename] = {
|
|
214
|
+
album,
|
|
215
|
+
title: clamp(text, 40),
|
|
216
|
+
lyrics: text,
|
|
217
|
+
language
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
path: `${output}${filename}`,
|
|
222
|
+
name: filename
|
|
223
|
+
};
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const
|
|
228
|
+
job = new Metadata({
|
|
229
|
+
config: {files: tagFiles},
|
|
230
|
+
contents: {
|
|
231
|
+
...contents,
|
|
232
|
+
service: 'metadata',
|
|
233
|
+
output,
|
|
234
|
+
extract: true
|
|
235
|
+
},
|
|
236
|
+
mediaFiles
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await job.send({instanceId: `${contents.id || 'tts'}-metadata`});
|
|
240
|
+
present.forEach((filename) => {
|
|
241
|
+
console.log(`Voice meta data updated for "${filename.substring(0, filename.lastIndexOf('.'))}".`);
|
|
242
|
+
});
|
|
243
|
+
} catch (e) {
|
|
244
|
+
console.warn(`metadata service failed (${e.message || e}); falling back to local MP3 ID3 writes.`);
|
|
245
|
+
await appendMP3Meta({
|
|
246
|
+
album,
|
|
247
|
+
captions,
|
|
248
|
+
language,
|
|
249
|
+
output
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
189
254
|
async afterExport (...args) {
|
|
190
255
|
const
|
|
191
|
-
{
|
|
192
|
-
{files, output
|
|
256
|
+
{config, updateList} = this,
|
|
257
|
+
{files, output} = config,
|
|
193
258
|
list = updateList ?? Object.keys(files).map((id) => `${id}.mp3`),
|
|
194
259
|
missing = [];
|
|
195
260
|
|
|
@@ -222,27 +287,10 @@ const
|
|
|
222
287
|
);
|
|
223
288
|
}
|
|
224
289
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
generated: true,
|
|
229
|
-
language,
|
|
230
|
-
output,
|
|
231
|
-
voice
|
|
290
|
+
// Album / title / lyrics / GenerationHash are stamped server-side via updateMediaMeta.
|
|
291
|
+
list.forEach((filename) => {
|
|
292
|
+
console.log(`Voice generated for "${filename.substring(0, filename.lastIndexOf('.'))}".`);
|
|
232
293
|
});
|
|
233
|
-
for (let i = 0; i < list.length; i++) {
|
|
234
|
-
const
|
|
235
|
-
filename = list[i],
|
|
236
|
-
id = filename.substring(0, filename.length - 4),
|
|
237
|
-
file = await getFileData(`${output}${filename}`, 'mp3');
|
|
238
|
-
|
|
239
|
-
file.addHash(transcripts[id].getHash({
|
|
240
|
-
seed: encodedBy ?? ''
|
|
241
|
-
}), {
|
|
242
|
-
hashKey: 'GenerationHash'
|
|
243
|
-
});
|
|
244
|
-
await file.save();
|
|
245
|
-
}
|
|
246
294
|
super.afterExport(...args);
|
|
247
295
|
}
|
|
248
296
|
};
|
|
@@ -10,9 +10,11 @@ const
|
|
|
10
10
|
SEPARATE_CHARACTER = '|',
|
|
11
11
|
JOIN_CHARACTER = '^',
|
|
12
12
|
filter = (fileType, file) => (file.indexOf('.') !== 0) && (file.slice(-(fileType.length + 1)) === `.${fileType}`),
|
|
13
|
+
AUDIO_SRC_EXTS = ['mp3', 'webm', 'weba'],
|
|
14
|
+
stripExt = (file) => file.replace(/\.[^.]+$/, ''),
|
|
13
15
|
mapReduction = (map, list) => list.reduce((obj, file) => {
|
|
14
16
|
const
|
|
15
|
-
id =
|
|
17
|
+
id = stripExt(file);
|
|
16
18
|
|
|
17
19
|
obj[id] = map[id];
|
|
18
20
|
return obj;
|
|
@@ -141,6 +143,10 @@ const
|
|
|
141
143
|
if (fileType !== 'json') {
|
|
142
144
|
await fs.rm(`${output}${caption}.${fileType}`, {force: true});
|
|
143
145
|
await fs.rm(`${output}${caption}.events.${fileType}`, {force: true});
|
|
146
|
+
|
|
147
|
+
if (fileType === 'webm') {
|
|
148
|
+
await fs.rm(`${output}${caption}.weba`, {force: true});
|
|
149
|
+
}
|
|
144
150
|
}
|
|
145
151
|
}
|
|
146
152
|
}
|
|
@@ -178,13 +184,19 @@ const
|
|
|
178
184
|
|
|
179
185
|
return staleIds.size;
|
|
180
186
|
},
|
|
181
|
-
list =
|
|
187
|
+
list = [];
|
|
188
|
+
|
|
189
|
+
for (const ext of AUDIO_SRC_EXTS) {
|
|
190
|
+
for (const file of (await readdirOrEmpty(src)).filter(filter.bind(null, ext))) {
|
|
182
191
|
const
|
|
183
|
-
id =
|
|
192
|
+
id = stripExt(file),
|
|
184
193
|
caption = files?.[id];
|
|
185
194
|
|
|
186
|
-
|
|
187
|
-
|
|
195
|
+
if (!check(id, caption)) {
|
|
196
|
+
list.push(file);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
188
200
|
|
|
189
201
|
if (exportFormats.length === 1) {
|
|
190
202
|
config.format = exportFormats[0];
|
|
@@ -213,10 +225,10 @@ const
|
|
|
213
225
|
const
|
|
214
226
|
fileType = exportFormats[i];
|
|
215
227
|
|
|
216
|
-
if (fileType !== 'json' && fileType !== 'mp3') {
|
|
228
|
+
if (fileType !== 'json' && fileType !== 'mp3' && fileType !== 'webm') {
|
|
217
229
|
for (let j = 0; j < list.length; j++) {
|
|
218
230
|
const
|
|
219
|
-
id =
|
|
231
|
+
id = stripExt(list[j]);
|
|
220
232
|
|
|
221
233
|
await fs.rm(`${output}${id}.events.${fileType}`, {force: true});
|
|
222
234
|
}
|
|
@@ -258,7 +270,14 @@ const
|
|
|
258
270
|
for (let j = 0; j < updateList.length; j++) {
|
|
259
271
|
const
|
|
260
272
|
filename = updateList[j],
|
|
261
|
-
id =
|
|
273
|
+
id = stripExt(filename);
|
|
274
|
+
|
|
275
|
+
// WebM hashes are stamped server-side (read-only client format).
|
|
276
|
+
if (formatId === 'webm') {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const
|
|
262
281
|
file = await getFileData(`${output}${id}.${formatId}`, formatId);
|
|
263
282
|
|
|
264
283
|
file.addHash(transcripts[id].getHash());
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const
|
|
2
|
+
File = require('./File');
|
|
3
|
+
|
|
4
|
+
let musicMetadataPromise = null;
|
|
5
|
+
|
|
6
|
+
const loadMusicMetadata = () => {
|
|
7
|
+
if (!musicMetadataPromise) {
|
|
8
|
+
musicMetadataPromise = import('music-metadata');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return musicMetadataPromise;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const trackName = (track) => track?.name || track?.codecName || '';
|
|
15
|
+
|
|
16
|
+
module.exports = class WebM extends File {
|
|
17
|
+
async load (path) {
|
|
18
|
+
await super.load(path);
|
|
19
|
+
const
|
|
20
|
+
mm = await loadMusicMetadata(),
|
|
21
|
+
metadata = await mm.parseFile(path);
|
|
22
|
+
|
|
23
|
+
this.data = metadata;
|
|
24
|
+
this.common = metadata.common || {};
|
|
25
|
+
this.native = metadata.native || {};
|
|
26
|
+
this.format = metadata.format || {};
|
|
27
|
+
return this.data;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read CAPTION_HASH / GENERATION_HASH (or Capti* aliases) from Matroska native tags.
|
|
32
|
+
*/
|
|
33
|
+
getHash ({hashKey = 'CaptionHash'} = {}) {
|
|
34
|
+
const
|
|
35
|
+
wanted = new Set([
|
|
36
|
+
hashKey,
|
|
37
|
+
hashKey.toUpperCase(),
|
|
38
|
+
hashKey.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase(),
|
|
39
|
+
String(hashKey).replace(/\s+/g, '_').toUpperCase()
|
|
40
|
+
]),
|
|
41
|
+
matroska = this.native.matroska || this.native.Matroska || [];
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < matroska.length; i++) {
|
|
44
|
+
const
|
|
45
|
+
{id, value} = matroska[i] || {};
|
|
46
|
+
|
|
47
|
+
if (wanted.has(id) || wanted.has(String(id || '').toUpperCase())) {
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Fallback: common / format tags
|
|
53
|
+
if (hashKey === 'CaptionHash' || hashKey === 'CAPTION_HASH') {
|
|
54
|
+
return this.common.comment?.[0] ?? null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
addHash () {
|
|
61
|
+
throw new Error('WebM hashes must be written via Cheerfully metadata service (read-only on Dutifully).');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async save () {
|
|
65
|
+
throw new Error('WebM.save is not supported on Dutifully; use Cheerfully metadata service.');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
hasTimedTrack (name) {
|
|
69
|
+
const
|
|
70
|
+
tracks = this.format.trackInfo || [];
|
|
71
|
+
|
|
72
|
+
return tracks.some((track) => {
|
|
73
|
+
const
|
|
74
|
+
n = trackName(track).toLowerCase();
|
|
75
|
+
|
|
76
|
+
return n === String(name).toLowerCase();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
toString () {
|
|
81
|
+
if (this.common.lyrics) {
|
|
82
|
+
const
|
|
83
|
+
lyrics = this.common.lyrics;
|
|
84
|
+
|
|
85
|
+
if (typeof lyrics === 'string') {
|
|
86
|
+
return lyrics;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (Array.isArray(lyrics)) {
|
|
90
|
+
return lyrics.map((entry) => entry?.text ?? entry).join(' ');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return this.common.title || '';
|
|
95
|
+
}
|
|
96
|
+
};
|
|
@@ -1,18 +1,36 @@
|
|
|
1
1
|
const
|
|
2
2
|
getFileData = require("./getFileData"),
|
|
3
3
|
{readdirOrEmpty} = require("./dirs"),
|
|
4
|
-
|
|
4
|
+
{isWebMExt} = require("./jobExports"),
|
|
5
|
+
fileExt = (file) => {
|
|
6
|
+
const
|
|
7
|
+
i = file.lastIndexOf('.');
|
|
8
|
+
|
|
9
|
+
return i >= 0 ? file.slice(i + 1).toLowerCase() : '';
|
|
10
|
+
},
|
|
11
|
+
matchesType = (fileType, file) => {
|
|
5
12
|
if (file.indexOf('.') === 0) {
|
|
6
13
|
return false;
|
|
7
14
|
}
|
|
8
15
|
|
|
9
|
-
|
|
16
|
+
const
|
|
17
|
+
ext = fileExt(file);
|
|
18
|
+
|
|
19
|
+
if (fileType === 'webm') {
|
|
20
|
+
return isWebMExt(ext);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return ext === fileType;
|
|
24
|
+
},
|
|
25
|
+
filter = (fileType, file) => {
|
|
26
|
+
if (!matchesType(fileType, file)) {
|
|
10
27
|
return false;
|
|
11
28
|
}
|
|
12
29
|
|
|
13
30
|
// Parallel event exports are `{id}.events.{format}` — not caption tracks.
|
|
14
31
|
const
|
|
15
|
-
|
|
32
|
+
ext = fileExt(file),
|
|
33
|
+
base = file.slice(0, -(ext.length + 1));
|
|
16
34
|
|
|
17
35
|
return !base.endsWith('.events');
|
|
18
36
|
};
|
|
@@ -47,8 +65,9 @@ module.exports = async (path, fileType) => {
|
|
|
47
65
|
for (let i = 0; i < files.length; i++) {
|
|
48
66
|
const
|
|
49
67
|
filename = files[i],
|
|
50
|
-
|
|
51
|
-
|
|
68
|
+
ext = fileExt(filename),
|
|
69
|
+
file = await getFileData(`${path}${filename}`, ext),
|
|
70
|
+
key = filename.substring(0, filename.length - ext.length - 1);
|
|
52
71
|
|
|
53
72
|
captions[key] = file.toString();
|
|
54
73
|
fileMap[key] = file;
|
|
@@ -2,6 +2,7 @@ const
|
|
|
2
2
|
JSONFile = require("../formats/JSONFile"),
|
|
3
3
|
LRC = require("../formats/LRC"),
|
|
4
4
|
MP3 = require("../formats/MP3"),
|
|
5
|
+
WebM = require("../formats/WebM"),
|
|
5
6
|
SAMI = require("../formats/SAMI"),
|
|
6
7
|
SRT = require("../formats/SRT"),
|
|
7
8
|
VTT = require("../formats/VTT"),
|
|
@@ -9,6 +10,8 @@ const
|
|
|
9
10
|
json: JSONFile,
|
|
10
11
|
lrc: LRC,
|
|
11
12
|
mp3: MP3,
|
|
13
|
+
webm: WebM,
|
|
14
|
+
weba: WebM,
|
|
12
15
|
sami: SAMI,
|
|
13
16
|
smi: SAMI,
|
|
14
17
|
srt: SRT,
|
|
@@ -3,26 +3,37 @@
|
|
|
3
3
|
* Keep catalog subsets in sync with the canonical Cheerfully module.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
const LYRIC_FORMATS = ['json', 'lrc', 'mp3', 'sami', 'smi', 'srt', 'vtt', 'tsv', 'xml', 'dat'];
|
|
7
|
-
const TRANSCRIPTION_FORMATS = ['json', 'lrc', 'mp3', 'sami', 'smi', 'srt', 'vtt'];
|
|
6
|
+
const LYRIC_FORMATS = ['json', 'lrc', 'mp3', 'sami', 'smi', 'srt', 'vtt', 'tsv', 'xml', 'dat', 'webm'];
|
|
7
|
+
const TRANSCRIPTION_FORMATS = ['json', 'lrc', 'mp3', 'sami', 'smi', 'srt', 'vtt', 'webm'];
|
|
8
8
|
const PACKFULLY_FORMATS = ['texturepacker', 'json-array', 'createjs', 'spine', 'css', 'starling', 'unity', 'godot'];
|
|
9
9
|
const TRANSLATE_FORMATS = ['json', 'i18n', 'catalog', 'po', 'xliff', 'properties', 'yml', 'yaml', 'strings', 'xml', 'csv', 'tsv'];
|
|
10
|
+
/** Extensions that satisfy the `webm` embed export (same Matroska container). */
|
|
11
|
+
const WEB_M_SOURCE_EXTS = ['webm', 'weba'];
|
|
10
12
|
|
|
11
13
|
const EXPORT_CATALOG = {
|
|
12
14
|
transcription: {
|
|
13
15
|
keys: ['format', 'exportFormat', 'exports', 'f'],
|
|
14
16
|
formats: TRANSCRIPTION_FORMATS,
|
|
15
|
-
constraints: {
|
|
17
|
+
constraints: {
|
|
18
|
+
mp3: {sourceExt: 'mp3'},
|
|
19
|
+
webm: {sourceExt: WEB_M_SOURCE_EXTS}
|
|
20
|
+
}
|
|
16
21
|
},
|
|
17
22
|
rhubarb: {
|
|
18
23
|
keys: ['exportFormat', 'exports', 'f'],
|
|
19
24
|
formats: LYRIC_FORMATS,
|
|
20
|
-
constraints: {
|
|
25
|
+
constraints: {
|
|
26
|
+
mp3: {sourceExt: 'mp3'},
|
|
27
|
+
webm: {sourceExt: WEB_M_SOURCE_EXTS}
|
|
28
|
+
}
|
|
21
29
|
},
|
|
22
30
|
allosaurus: {
|
|
23
31
|
keys: ['exportFormat', 'exports', 'f'],
|
|
24
32
|
formats: LYRIC_FORMATS,
|
|
25
|
-
constraints: {
|
|
33
|
+
constraints: {
|
|
34
|
+
mp3: {sourceExt: 'mp3'},
|
|
35
|
+
webm: {sourceExt: WEB_M_SOURCE_EXTS}
|
|
36
|
+
}
|
|
26
37
|
},
|
|
27
38
|
translate: {
|
|
28
39
|
keys: ['format', 'exports'],
|
|
@@ -61,6 +72,30 @@ const normalizeFormatId = (format) => {
|
|
|
61
72
|
return id;
|
|
62
73
|
};
|
|
63
74
|
|
|
75
|
+
const normalizeExt = (ext) => String(ext || '').toLowerCase().replace(/^\./, '');
|
|
76
|
+
|
|
77
|
+
const isWebMExt = (ext) => WEB_M_SOURCE_EXTS.includes(normalizeExt(ext));
|
|
78
|
+
|
|
79
|
+
const embedArchiveExt = (exportFormat, sourceExt) => {
|
|
80
|
+
if (exportFormat === 'webm' && isWebMExt(sourceExt)) {
|
|
81
|
+
return normalizeExt(sourceExt);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return exportFormat;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const sourceExtAllowed = (ruleExt, sourceExt) => {
|
|
88
|
+
if (!ruleExt || !sourceExt) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const
|
|
93
|
+
allowed = Array.isArray(ruleExt) ? ruleExt : [ruleExt],
|
|
94
|
+
src = normalizeExt(sourceExt);
|
|
95
|
+
|
|
96
|
+
return allowed.map(normalizeExt).includes(src);
|
|
97
|
+
};
|
|
98
|
+
|
|
64
99
|
const extractRawValue = (job = {}, keys = []) => {
|
|
65
100
|
for (let i = 0; i < keys.length; i++) {
|
|
66
101
|
const
|
|
@@ -86,7 +121,7 @@ const applyConstraints = (formats, constraints = {}, {sourceExt} = {}) => format
|
|
|
86
121
|
return true;
|
|
87
122
|
}
|
|
88
123
|
|
|
89
|
-
if (rule.sourceExt && sourceExt && rule.sourceExt
|
|
124
|
+
if (rule.sourceExt && sourceExt && !sourceExtAllowed(rule.sourceExt, sourceExt)) {
|
|
90
125
|
return false;
|
|
91
126
|
}
|
|
92
127
|
|
|
@@ -143,5 +178,8 @@ const resolveJobExports = (job = {}, serviceId, {sourceExt, defaultFormats} = {}
|
|
|
143
178
|
|
|
144
179
|
module.exports = {
|
|
145
180
|
EXPORT_CATALOG,
|
|
181
|
+
WEB_M_SOURCE_EXTS,
|
|
182
|
+
isWebMExt,
|
|
183
|
+
embedArchiveExt,
|
|
146
184
|
resolveJobExports
|
|
147
185
|
};
|
package/src/index.js
CHANGED
|
@@ -11,6 +11,7 @@ const
|
|
|
11
11
|
elevenlabs: require('./classes/ElevenLabs'),
|
|
12
12
|
ffmpeg: require('./classes/FFMPEG'),
|
|
13
13
|
googletranslate: Translate,
|
|
14
|
+
metadata: require('./classes/Metadata'),
|
|
14
15
|
microsofttranslate: Translate,
|
|
15
16
|
packfully: require('./classes/Packfully'),
|
|
16
17
|
polly: require('./classes/Polly'),
|