@makefully/dutifully 1.23.0 → 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,21 @@
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
+
14
+ ## 1.23.1
15
+
16
+ ### Added
17
+
18
+ - Shared `auth` on jobs and `env-cheerfully.json` `auth.<service>` map (job wins; env fills defaults). Legacy `elevenLabsApiKey` and top-level ElevenLabs `apiKey` still map into `auth.apiKey`.
19
+
5
20
  ## 1.23.0
6
21
 
7
22
  - Provider umbrellas: `translate` (`amazon` default → `amazontranslate`, or `deepl` / `google` / `microsoft`), `lipsync` (`rhubarb` default / `allosaurus`), and `voiceover` (`elevenlabs` default / `polly`) resolve to Cheerfully concrete services before Yap submit.
package/README.md CHANGED
@@ -6,17 +6,30 @@ Client API for Cheerfully services. Cheerfully makes voice-over easy.
6
6
  npm install @makefully/dutifully
7
7
  ```
8
8
 
9
- To use the API, you will need to specify a root `./env-cheerfully.json` file with the following format:
9
+ Requires **Node.js 22+** (for `music-metadata` WebM reads).
10
+
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
12
14
  {
13
15
  "server": "http://server:port",
14
- "accessToken": "BearerTokenForShowfullyYap"
16
+ "accessToken": "BearerTokenForShowfullyYap",
17
+ "auth": {
18
+ "elevenlabs": { "apiKey": "…" },
19
+ "deepl": { "apiKey": "…", "serverUrl": "https://api-free.deepl.com" },
20
+ "googletranslate": { "apiKey": "…" },
21
+ "microsofttranslate": { "key": "…", "region": "eastus" },
22
+ "amazontranslate": { "config": { } },
23
+ "polly": { "config": { }, "bucket": "…", "root": "…" },
24
+ "transcription": { "config": { }, "bucket": "…", "root": "…" }
25
+ }
15
26
  }
16
27
  ```
17
28
 
18
29
  `accessToken` is sent as `Authorization: Bearer …` on yap submit/status/download (Showfully 1.4+). For local Showfully, use the same value as `SHOWFULLY_DEBUG_TOKEN` with `SHOWFULLY_ALLOW_DEBUG_TOKEN=1`.
19
30
 
31
+ Job listings may include their own `auth` object (same shapes). Dutifully merges `auth.<service>` from env into each job; **job `auth` wins**. Legacy `elevenLabsApiKey` still maps to `auth.elevenlabs.apiKey`.
32
+
20
33
  ## Services
21
34
 
22
35
  All services can be specified in `cheerfully.json` as described in the following sections. Services can be run as scripts specified in `package.json` as:
@@ -100,7 +113,7 @@ await cheerfully.say('The build is complete');
100
113
  ```
101
114
 
102
115
 
103
- Voice settings come from the first `elevenlabs` entry in `cheerfully.json` (same as other ElevenLabs chores). Server credentials and `elevenLabsApiKey` come from `env-cheerfully.json`. Cached files are keyed by voice + text, so repeats play immediately without regenerating.
116
+ Voice settings come from the first `elevenlabs` entry in `cheerfully.json` (same as other ElevenLabs chores). Server credentials and optional `auth.elevenlabs` (or legacy `elevenLabsApiKey`) come from `env-cheerfully.json`. Cached files are keyed by voice + text, so repeats play immediately without regenerating.
104
117
 
105
118
  ### ElevenLabs
106
119
 
@@ -122,16 +135,28 @@ You may set a single `voice` value to an ElevenLabs voice id, or an object of sp
122
135
 
123
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`.
124
137
 
125
- 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).
126
139
 
127
- You may include your ElevenLabs API Key directly in the ElevenLabs spec above as `apiKey`, or set your API Key separately in `env-cheerfully.json`:
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.
141
+
142
+ You may include credentials on the job as `auth.apiKey`, or set defaults in `env-cheerfully.json`:
128
143
 
129
144
  ```javascript
130
145
  {
131
- "elevenLabsApiKey": "elevenlabsapikeyforyouraccount"
146
+ "auth": {
147
+ "elevenlabs": {
148
+ "apiKey": "elevenlabsapikeyforyouraccount"
149
+ }
150
+ }
132
151
  }
133
152
  ```
134
153
 
154
+ Legacy `elevenLabsApiKey` at the top level of `env-cheerfully.json` is still accepted.
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
+
135
160
  ### FFMPEG
136
161
 
137
162
  This audio file process accepts ffmpeg commandline arguments to perform operations on an audio file.
@@ -305,7 +330,7 @@ This text-to-voice service requires a script as input and will download audio VO
305
330
 
306
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.
307
332
 
308
- 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).
309
334
 
310
335
  ### Allosaurus
311
336
 
@@ -324,7 +349,7 @@ This audio-to-lip-flap service requires audio as input and will download a mouth
324
349
  }
325
350
  ```
326
351
 
327
- 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).
328
353
 
329
354
  Set `exportFormat`, `exports`, or omit/`null` for export selection:
330
355
 
@@ -366,7 +391,7 @@ This audio-to-lip-flap service requires audio as input and will download a mouth
366
391
  }
367
392
  ```
368
393
 
369
- 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).
370
395
 
371
396
  Use `exportFormat`, `exports`, or omit/`null` with the same string | array | all semantics as Rhubarb (see Allosaurus above).
372
397
 
@@ -396,6 +421,7 @@ Transcription supports the following formats:
396
421
  * JSON
397
422
  * LRC
398
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.)
399
425
  * SAMI / SMI
400
426
  * SRT
401
427
  * VTT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makefully/dutifully",
3
- "version": "1.23.0",
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",
@@ -6,6 +6,7 @@ const
6
6
  https = require('https'),
7
7
  {PassThrough} = require('stream'),
8
8
  unzipper = require('unzip-stream'),
9
+ {applyServiceAuth} = require('../helpers/applyServiceAuth'),
9
10
  cleanPath = (path) => path[path.length - 1] === '/' ? path.substring(0, path.length - 1) : path,
10
11
  isZipMagic = (buf) => buf && buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b &&
11
12
  ((buf[2] === 0x03 && buf[3] === 0x04) || (buf[2] === 0x05 && buf[3] === 0x06) || (buf[2] === 0x07 && buf[3] === 0x08)),
@@ -264,9 +265,9 @@ const
264
265
  },
265
266
  Cheer = class {
266
267
  constructor ({config, contents}) {
267
- this.config = config;
268
268
  this.contents = contents;
269
269
  this.service = contents.service;
270
+ this.config = applyServiceAuth(config, contents, contents.service);
270
271
  }
271
272
 
272
273
  prepare ({difference = true, extract = true}) {
@@ -4,14 +4,11 @@ const
4
4
  module.exports = class ElevenLabs extends TextToSpeech {
5
5
  constructor (data) {
6
6
  const
7
- {id, elevenLabsApiKey} = data.contents;
7
+ {id} = data.contents;
8
8
 
9
9
  data.album = id;
10
10
  data.encodedBy = 'ElevenLabs';
11
- if (elevenLabsApiKey) {
12
- data.config.apiKey = elevenLabsApiKey;
13
- }
14
-
11
+ // Auth (env auth.elevenlabs / elevenLabsApiKey / job.auth) is applied in Cheer.
15
12
  super(data);
16
13
  }
17
- };
14
+ };
@@ -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
+ };
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Merge Dutifully env-cheerfully.json `auth.<service>` into a job's `auth`.
3
+ * Job-level auth wins; env fills defaults. Legacy `elevenLabsApiKey` maps to elevenlabs.
4
+ */
5
+
6
+ const isPlainObject = (value) => (
7
+ value != null && typeof value === 'object' && !Array.isArray(value)
8
+ );
9
+
10
+ /**
11
+ * Normalize legacy env aliases into `contents.auth`.
12
+ * @param {Record<string, unknown>} contents
13
+ * @returns {Record<string, unknown>}
14
+ */
15
+ const normalizeContentsAuth = (contents = {}) => {
16
+ const
17
+ auth = isPlainObject(contents.auth) ? {...contents.auth} : {};
18
+
19
+ if (contents.elevenLabsApiKey) {
20
+ auth.elevenlabs = {
21
+ ...(isPlainObject(auth.elevenlabs) ? auth.elevenlabs : {}),
22
+ apiKey: contents.elevenLabsApiKey
23
+ };
24
+ }
25
+
26
+ return auth;
27
+ };
28
+
29
+ /**
30
+ * Apply service auth defaults onto a job config (mutates config).
31
+ * @param {Record<string, unknown>} config job listing from cheerfully.json
32
+ * @param {Record<string, unknown>} contents merged cheer + env + CLI
33
+ * @param {string} [service] Cheerfully service id (defaults to contents.service)
34
+ * @returns {Record<string, unknown>} config
35
+ */
36
+ const applyServiceAuth = (config, contents = {}, service = contents.service) => {
37
+ if (!config || typeof config !== 'object') {
38
+ return config;
39
+ }
40
+
41
+ const
42
+ authMap = normalizeContentsAuth(contents),
43
+ fromEnv = service && isPlainObject(authMap[service]) ? {...authMap[service]} : {},
44
+ fromJob = isPlainObject(config.auth) ? {...config.auth} : {},
45
+ // Legacy ElevenLabs top-level apiKey on the job
46
+ legacyJobKey = service === 'elevenlabs'
47
+ && typeof config.apiKey === 'string'
48
+ && config.apiKey
49
+ && fromJob.apiKey == null
50
+ ? {apiKey: config.apiKey}
51
+ : {};
52
+
53
+ const
54
+ merged = {
55
+ ...fromEnv,
56
+ ...legacyJobKey,
57
+ ...fromJob
58
+ };
59
+
60
+ if (isPlainObject(fromEnv.config) || isPlainObject(fromJob.config) || isPlainObject(legacyJobKey.config)) {
61
+ merged.config = {
62
+ ...(isPlainObject(fromEnv.config) ? fromEnv.config : {}),
63
+ ...(isPlainObject(fromJob.config) ? fromJob.config : {})
64
+ };
65
+
66
+ if (isPlainObject(fromEnv.config?.credentials) || isPlainObject(fromJob.config?.credentials)) {
67
+ merged.config.credentials = {
68
+ ...(isPlainObject(fromEnv.config?.credentials) ? fromEnv.config.credentials : {}),
69
+ ...(isPlainObject(fromJob.config?.credentials) ? fromJob.config.credentials : {})
70
+ };
71
+ }
72
+ }
73
+
74
+ if (Object.keys(merged).length) {
75
+ config.auth = merged;
76
+ }
77
+
78
+ // Prefer auth container; drop legacy top-level apiKey once folded in.
79
+ if (service === 'elevenlabs' && config.apiKey != null && config.auth?.apiKey) {
80
+ delete config.apiKey;
81
+ }
82
+
83
+ return config;
84
+ };
85
+
86
+ module.exports = {
87
+ normalizeContentsAuth,
88
+ applyServiceAuth
89
+ };
@@ -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'),