@makefully/dutifully 1.22.1 → 1.23.1

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,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.23.1
6
+
7
+ ### Added
8
+
9
+ - 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`.
10
+
11
+ ## 1.23.0
12
+
13
+ - 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.
14
+ - Concrete MT parsers: `amazontranslate`, `deepl`, `googletranslate`, `microsofttranslate`.
15
+
5
16
  ## 1.22.1
6
17
 
7
18
  - Allow translate `format: "catalog"` (Cheerfully flat `{lang}.json` app catalogs) alongside existing loc formats.
package/README.md CHANGED
@@ -6,17 +6,28 @@ 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
+ To use the API, you will need a root `./env-cheerfully.json` with Showfully Yap credentials and optional per-service auth defaults:
10
10
 
11
11
  ```javascript
12
12
  {
13
13
  "server": "http://server:port",
14
- "accessToken": "BearerTokenForShowfullyYap"
14
+ "accessToken": "BearerTokenForShowfullyYap",
15
+ "auth": {
16
+ "elevenlabs": { "apiKey": "…" },
17
+ "deepl": { "apiKey": "…", "serverUrl": "https://api-free.deepl.com" },
18
+ "googletranslate": { "apiKey": "…" },
19
+ "microsofttranslate": { "key": "…", "region": "eastus" },
20
+ "amazontranslate": { "config": { } },
21
+ "polly": { "config": { }, "bucket": "…", "root": "…" },
22
+ "transcription": { "config": { }, "bucket": "…", "root": "…" }
23
+ }
15
24
  }
16
25
  ```
17
26
 
18
27
  `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
28
 
29
+ 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`.
30
+
20
31
  ## Services
21
32
 
22
33
  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 +111,7 @@ await cheerfully.say('The build is complete');
100
111
  ```
101
112
 
102
113
 
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.
114
+ 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
115
 
105
116
  ### ElevenLabs
106
117
 
@@ -124,14 +135,20 @@ Specifying the `model` is optional: if unspecified, Cheerfully tries to look up
124
135
 
125
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.
126
137
 
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`:
138
+ You may include credentials on the job as `auth.apiKey`, or set defaults in `env-cheerfully.json`:
128
139
 
129
140
  ```javascript
130
141
  {
131
- "elevenLabsApiKey": "elevenlabsapikeyforyouraccount"
142
+ "auth": {
143
+ "elevenlabs": {
144
+ "apiKey": "elevenlabsapikeyforyouraccount"
145
+ }
146
+ }
132
147
  }
133
148
  ```
134
149
 
150
+ Legacy `elevenLabsApiKey` at the top level of `env-cheerfully.json` is still accepted.
151
+
135
152
  ### FFMPEG
136
153
 
137
154
  This audio file process accepts ffmpeg commandline arguments to perform operations on an audio file.
@@ -432,13 +449,22 @@ Script lines may also include an optional `events` array for one-shot timed mark
432
449
 
433
450
  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"`).
434
451
 
435
- ### Translate
452
+ ### Provider umbrellas (`translate`, `lipsync`, `voiceover`)
453
+
454
+ Dutifully logical services pick a Cheerfully processor via per-job `provider` (stripped before upload):
455
+
456
+ | Dutifully `-service` | `provider` | Default | Cheerfully target |
457
+ | --- | --- | --- | --- |
458
+ | `translate` | `amazon`, `deepl`, `google`, `microsoft` | `amazon` | `amazontranslate`, `deepl`, `googletranslate`, `microsofttranslate` |
459
+ | `lipsync` | `rhubarb`, `allosaurus` | `rhubarb` | same as provider |
460
+ | `voiceover` | `polly`, `elevenlabs` | `elevenlabs` | same as provider |
436
461
 
437
- This text-to-text service translates Cheerfully scripts or language-matrix CSV/TSV via Amazon Translate. Set jobs in `cheerfully.json`:
462
+ Concrete keys (`deepl`, `polly`, `rhubarb`, …) still work without `provider`.
438
463
 
439
464
  ```javascript
440
465
  {
441
466
  "translate": [{
467
+ "provider": "amazon",
442
468
  "script": "./example/script.json",
443
469
  "language": "en-US",
444
470
  "languages": ["es-ES", "fr-FR"],
@@ -449,15 +475,29 @@ This text-to-text service translates Cheerfully scripts or language-matrix CSV/T
449
475
  "language": "en-US",
450
476
  "languages": ["es-ES", "fr-FR"],
451
477
  "output": "./example/scripts/"
478
+ }],
479
+ "voiceover": [{
480
+ "provider": "elevenlabs",
481
+ "script": "./example/script.json",
482
+ "output": "./example/vo/en/",
483
+ "voice": "21m00Tcm4TlvDq8ikWAM"
484
+ }],
485
+ "lipsync": [{
486
+ "provider": "rhubarb",
487
+ "src": "./example/vo/en/",
488
+ "output": "./example/lipsync/en/"
452
489
  }]
453
490
  }
454
491
  ```
455
492
 
493
+ Translate job fields:
494
+
456
495
  * `script` — path to a Cheerfully `.json` script or a language-matrix `.csv` / `.tsv` (first column = ids, first row = language tags).
457
496
  * `language` — source locale (CSV: source column). Matrix jobs require this; JSON may use `auto`.
458
497
  * `languages` — target locales to produce or fill.
459
498
  * `format` / `exports` — optional. Defaults to the source extension (`json`, `csv`, or `tsv`).
460
499
  * `ignore` — phrases left untranslated.
500
+ * `provider` — MT engine (see table); default `amazon`.
461
501
 
462
502
  JSON sources write `{lang}/{basename}.{ext}` under `output` (`catalog` → `{lang}.json`). CSV/TSV sources write an updated matrix file and leave existing non-empty cells unchanged.
463
503
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makefully/dutifully",
3
- "version": "1.22.1",
3
+ "version": "1.23.1",
4
4
  "description": "Client tools for Cheerfully service.",
5
5
  "main": "./src/index.js",
6
6
  "bin": {
@@ -52,7 +52,10 @@
52
52
  "atlas",
53
53
  "spritesheet",
54
54
  "classfully",
55
- "translate"
55
+ "translate",
56
+ "deepl",
57
+ "voiceover",
58
+ "lipsync"
56
59
  ],
57
60
  "author": "Derek Detweiler <derek@makefullystudios.com>",
58
61
  "contributors": [
@@ -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
+ };
@@ -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
+ };
@@ -28,6 +28,22 @@ const EXPORT_CATALOG = {
28
28
  keys: ['format', 'exports'],
29
29
  formats: TRANSLATE_FORMATS
30
30
  },
31
+ amazontranslate: {
32
+ keys: ['format', 'exports'],
33
+ formats: TRANSLATE_FORMATS
34
+ },
35
+ deepl: {
36
+ keys: ['format', 'exports'],
37
+ formats: TRANSLATE_FORMATS
38
+ },
39
+ googletranslate: {
40
+ keys: ['format', 'exports'],
41
+ formats: TRANSLATE_FORMATS
42
+ },
43
+ microsofttranslate: {
44
+ keys: ['format', 'exports'],
45
+ formats: TRANSLATE_FORMATS
46
+ },
31
47
  packfully: {
32
48
  keys: ['format', 'exports', 'exportFormat'],
33
49
  formats: PACKFULLY_FORMATS
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Map Dutifully umbrella services + provider → Cheerfully concrete service ids.
3
+ */
4
+
5
+ const
6
+ UMBRELLAS = {
7
+ translate: {
8
+ defaultProvider: 'amazon',
9
+ providers: {
10
+ amazon: 'amazontranslate',
11
+ amazontranslate: 'amazontranslate',
12
+ deepl: 'deepl',
13
+ google: 'googletranslate',
14
+ googletranslate: 'googletranslate',
15
+ microsoft: 'microsofttranslate',
16
+ microsofttranslate: 'microsofttranslate'
17
+ }
18
+ },
19
+ lipsync: {
20
+ defaultProvider: 'rhubarb',
21
+ providers: {
22
+ rhubarb: 'rhubarb',
23
+ allosaurus: 'allosaurus'
24
+ }
25
+ },
26
+ voiceover: {
27
+ defaultProvider: 'elevenlabs',
28
+ providers: {
29
+ polly: 'polly',
30
+ elevenlabs: 'elevenlabs'
31
+ }
32
+ }
33
+ },
34
+ isUmbrella = (service) => Boolean(UMBRELLAS[service]),
35
+ /**
36
+ * @returns {{ cheerfullyService: string, provider: string, job: object }}
37
+ */
38
+ resolveProviderService = (umbrella, job = {}) => {
39
+ const
40
+ table = UMBRELLAS[umbrella];
41
+
42
+ if (!table) {
43
+ const
44
+ cleaned = {...job};
45
+
46
+ delete cleaned.provider;
47
+
48
+ return {
49
+ cheerfullyService: umbrella,
50
+ provider: null,
51
+ job: cleaned
52
+ };
53
+ }
54
+
55
+ const
56
+ raw = job.provider == null || job.provider === ''
57
+ ? table.defaultProvider
58
+ : String(job.provider).toLowerCase().trim(),
59
+ cheerfullyService = table.providers[raw];
60
+
61
+ if (!cheerfullyService) {
62
+ const
63
+ allowed = [...new Set(Object.keys(table.providers))].sort().join(', ');
64
+
65
+ throw new Error(
66
+ `Unknown provider "${job.provider}" for "${umbrella}". Use one of: ${allowed}.`
67
+ );
68
+ }
69
+
70
+ const
71
+ cleaned = {...job};
72
+
73
+ delete cleaned.provider;
74
+
75
+ return {
76
+ cheerfullyService,
77
+ provider: raw,
78
+ job: cleaned
79
+ };
80
+ };
81
+
82
+ module.exports = {
83
+ UMBRELLAS,
84
+ isUmbrella,
85
+ resolveProviderService
86
+ };
package/src/index.js CHANGED
@@ -2,11 +2,16 @@
2
2
  /* global console, process, require */
3
3
  const
4
4
  LipSync = require('./classes/LipSync'),
5
+ Translate = require('./classes/Translate'),
5
6
  parsers = {
6
7
  allosaurus: LipSync,
8
+ amazontranslate: Translate,
7
9
  classfully: require('./classes/Classfully'),
10
+ deepl: Translate,
8
11
  elevenlabs: require('./classes/ElevenLabs'),
9
12
  ffmpeg: require('./classes/FFMPEG'),
13
+ googletranslate: Translate,
14
+ microsofttranslate: Translate,
10
15
  packfully: require('./classes/Packfully'),
11
16
  polly: require('./classes/Polly'),
12
17
  rasterize: require('./classes/Rasterize'),
@@ -14,10 +19,12 @@ const
14
19
  sharp: require('./classes/Sharp'),
15
20
  stackfully: require('./classes/Stackfully'),
16
21
  transcription: require('./classes/Transcription'),
17
- translate: require('./classes/Translate')
22
+ // Concrete only — umbrellas (translate / lipsync / voiceover) resolve first.
23
+ translate: Translate
18
24
  },
19
25
  getJSON = require('./helpers/getJSON'),
20
26
  {normalizeOnly, filterJobsById} = require('./helpers/filterJobs'),
27
+ {isUmbrella, resolveProviderService} = require('./helpers/resolveProviderService'),
21
28
  send = async function (contents) {
22
29
  const
23
30
  {id, service} = contents,
@@ -36,11 +43,40 @@ const
36
43
  continue;
37
44
  }
38
45
 
46
+ let
47
+ cheerfullyService = service,
48
+ jobConfig = config;
49
+
50
+ if (isUmbrella(service)) {
51
+ try {
52
+ const
53
+ resolved = resolveProviderService(service, config);
54
+
55
+ cheerfullyService = resolved.cheerfullyService;
56
+ jobConfig = resolved.job;
57
+ } catch (e) {
58
+ console.warn(e.message);
59
+ continue;
60
+ }
61
+ }
62
+
63
+ const
64
+ Parser = parsers[cheerfullyService];
65
+
66
+ if (!Parser) {
67
+ console.warn(`No Dutifully parser for Cheerfully service "${cheerfullyService}".`);
68
+ continue;
69
+ }
70
+
39
71
  const
40
- serviceHandler = new parsers[service]({config, contents});
72
+ contentsForJob = {
73
+ ...contents,
74
+ service: cheerfullyService
75
+ },
76
+ serviceHandler = new Parser({config: jobConfig, contents: contentsForJob});
41
77
 
42
78
  try {
43
- await serviceHandler.prepare({...contents, ...config});
79
+ await serviceHandler.prepare({...contentsForJob, ...jobConfig});
44
80
  } catch (e) {
45
81
  console.warn(e.message);
46
82
  continue;
@@ -48,10 +84,10 @@ const
48
84
 
49
85
  try {
50
86
  await serviceHandler.send({
51
- instanceId: `${id}-${service}${length > 1 ? `-${index}` : ''}`
87
+ instanceId: `${id}-${cheerfullyService}${length > 1 ? `-${index}` : ''}`
52
88
  });
53
89
  } catch (e) {
54
- console.warn(`Error running "${service}" (${index}): ${e.message || e}`);
90
+ console.warn(`Error running "${cheerfullyService}" (${index}): ${e.message || e}`);
55
91
  }
56
92
  }
57
93
  };