@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 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 appended to any MP3's in the source directory that were not generated.
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`, album, title, and unsynchronized lyrics are appended to any MP3's in the source directory that were not generated.
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 embeds lipsync timings into the MP3's ID3 metadata.
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 embeds lipsync timings into the MP3's ID3 metadata.
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.23.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": ">=18"
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",
@@ -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
- filter = (fileType, file) => (file.indexOf('.') !== 0) && (file.slice(-(fileType.length + 1)) === `.${fileType}`),
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(file.substring(0, file.length - fileType.length - 1));
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(file.substring(0, file.length - fileType.length - 1));
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 = file.substring(0, file.length - 4);
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 fileName of (await readdirOrEmpty(src)).filter(filter.bind(null, 'mp3'))) {
167
- const
168
- id = fileName.substring(0, fileName.length - 4);
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
- if (!(await isCurrent(id))) {
171
- list.push(fileName);
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 appendMP3Meta({
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
- {album, encodedBy, config, transcripts, updateList} = this,
192
- {files, output, language, voice} = config,
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
- await appendMP3Meta({
226
- album,
227
- captions: files,
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 = file.substring(0, file.length - 4);
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 = (await readdirOrEmpty(src)).filter(filter.bind(null, 'mp3')).filter((file) => {
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 = file.substring(0, file.length - 4),
192
+ id = stripExt(file),
184
193
  caption = files?.[id];
185
194
 
186
- return !check(id, caption);
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 = list[j].substring(0, list[j].length - 4);
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 = filename.substring(0, filename.length - 4),
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
- filter = (fileType, file) => {
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
- if (file.slice(-(fileType.length + 1)) !== `.${fileType}`) {
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
- base = file.slice(0, -(fileType.length + 1));
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
- file = await getFileData(`${path}${filename}`, fileType),
51
- key = filename.substring(0, filename.length - fileType.length - 1);
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: {mp3: {sourceExt: 'mp3'}}
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: {mp3: {sourceExt: 'mp3'}}
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: {mp3: {sourceExt: 'mp3'}}
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 !== 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'),