@makefully/dutifully 1.10.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.
@@ -0,0 +1,133 @@
1
+ const
2
+ Cheer = require('./Cheer'),
3
+ fs = require('fs').promises,
4
+ getJSON = require('../helpers/getJSON'),
5
+ id3 = require('node-id3').Promise,
6
+ filter = (fileType, file) => (file.indexOf('.') !== 0) && (file.slice(-(fileType.length + 1)) === `.${fileType}`),
7
+ combine = async (path, fileType) => {
8
+ const
9
+ files = (await fs.readdir(path)).filter(filter.bind(null, fileType)),
10
+ list = [];
11
+
12
+ for (let i = 0; i < files.length; i++) {
13
+ const
14
+ file = files[i];
15
+
16
+ if (fileType === 'mp3') { // check id3 for lyrics presence.
17
+ const
18
+ {synchronisedLyrics} = await id3.read(`${path}${file}`);
19
+
20
+ if (synchronisedLyrics) {
21
+ const
22
+ index = synchronisedLyrics.map(({shortText}) => shortText).indexOf('lipsync');
23
+
24
+ if (index >= 0) {
25
+ list.push(file.substring(0, file.length - fileType.length - 1));
26
+ }
27
+ }
28
+ } else {
29
+ list.push(file.substring(0, file.length - fileType.length - 1));
30
+ }
31
+ }
32
+
33
+ return list;
34
+ },
35
+ sortKeys = (obj) => Object.keys(obj).sort().reduce((s, key) => {
36
+ s[key] = obj[key];
37
+ return s;
38
+ }, {}),
39
+ mapReduction = (map, list) => list.reduce((obj, file) => {
40
+ const
41
+ id = file.substring(0, file.length - 4);
42
+
43
+ obj[id] = map[id];
44
+ return obj;
45
+ }, {}),
46
+ LipSync = class LipSync extends Cheer {
47
+ async prepare (data) {
48
+ this.differenceOnly = false;
49
+ this.updateList = null;
50
+ await this.replaceConfigPathWithJSON('script', 'files');
51
+ return super.prepare(data);
52
+ }
53
+
54
+ async checkDifference () {
55
+ const
56
+ {config} = this,
57
+ {files = {}, options = {}, output, src} = config,
58
+ {exportFormat = 'json', output: outputType = 'json'} = options,
59
+ file = 'mouthCues.json',
60
+ raw = outputType === 'json' && exportFormat !== 'mp3' ? await getJSON(`${output}${file}`) ?? {} : null,
61
+ alreadyLipSynced = raw ? Object.keys(raw) : await combine(output, exportFormat),
62
+ check = (id) => {
63
+ const
64
+ index = alreadyLipSynced.indexOf(id);
65
+
66
+ if (index >= 0) {
67
+ alreadyLipSynced.splice(index, 1);
68
+ return true;
69
+ } else {
70
+ return false;
71
+ }
72
+ },
73
+ mergeLipSync = async (newLipSync = false) => { // Must run _after_ all checks and will remove what's left.
74
+ for (let i = 0; i < alreadyLipSynced.length; i++) {
75
+ const
76
+ key = alreadyLipSynced[i];
77
+
78
+ if (raw) {
79
+ delete raw[key];
80
+ } else {
81
+ await fs.rm(`${output}${key}.${exportFormat}`);
82
+ }
83
+ console.log(`Removed "${key}"`);
84
+ }
85
+ if (raw) {
86
+ await fs.writeFile(`${output}${file}`, JSON.stringify(sortKeys({
87
+ ...raw,
88
+ ...newLipSync ? await getJSON(`${output}${file}`) ?? {} : {} // get new version.
89
+ }), null, 4));
90
+ }
91
+
92
+ return alreadyLipSynced.length;
93
+ },
94
+ list = (await fs.readdir(src)).filter(filter.bind(null, 'mp3')).filter((file) => !check(file.substring(0, file.length - 4)));
95
+
96
+ this.differenceOnly = true;
97
+
98
+ if (list.length === 0) {
99
+ // We'll still run the merge in case any have been removed.
100
+ if (await mergeLipSync()) {
101
+ throw Error('Old lip-sync removed, but no new ones required generation.');
102
+ } else {
103
+ throw Error('Lip-sync already up to date.');
104
+ }
105
+ }
106
+ this.updateList = list;
107
+ config.files = mapReduction(files, list);
108
+ this.mergeLipSync = mergeLipSync;
109
+ }
110
+
111
+ beforeSend (archive) {
112
+ const
113
+ {src} = this.config;
114
+
115
+ if (this.differenceOnly) {
116
+ this.updateList.forEach((file) => archive.file(`${src}${file}`, {
117
+ name: file
118
+ }));
119
+ } else {
120
+ archive.directory(src, false);
121
+ }
122
+ super.beforeSend(archive);
123
+ }
124
+
125
+ afterExport (...args) {
126
+ if (this.mergeLipSync) {
127
+ this.mergeLipSync(true);
128
+ }
129
+ super.afterExport(...args)
130
+ }
131
+ };
132
+
133
+ module.exports = LipSync;
@@ -0,0 +1,10 @@
1
+ const
2
+ TextToSpeech = require('./TextToSpeech');
3
+
4
+ module.exports = class Polly extends TextToSpeech {
5
+ constructor (data) {
6
+ data.album = data.contents.id;
7
+ data.encodedBy = 'Amazon Polly';
8
+ super(data);
9
+ }
10
+ };
@@ -0,0 +1,13 @@
1
+ const
2
+ Cheer = require('./Cheer'),
3
+ Rasterize = class Rasterize extends Cheer {
4
+ beforeSend (archive) {
5
+ const
6
+ {src} = this.config;
7
+
8
+ archive.directory(src, false);
9
+ super.beforeSend(archive);
10
+ }
11
+ };
12
+
13
+ module.exports = Rasterize;
@@ -0,0 +1,205 @@
1
+ const
2
+ Cheer = require('./Cheer'),
3
+ Transcript = require('./Transcript'),
4
+ fs = require('fs').promises,
5
+ getFileData = require('../helpers/getFileData'),
6
+ id3 = require('node-id3').Promise,
7
+ isoLanguageMap = {
8
+ 'en-US': 'eng',
9
+ 'es-ES': 'spa'
10
+ },
11
+ matches = (file, types) => types.reduce((prev, current) => prev || (file.slice(-(current.length + 1)) === `.${current}`), false),
12
+ getMP3s = async ({encodedBy = '', output = './', fileTypes = [], transcripts}) => {
13
+ const
14
+ all = (await fs.readdir(output))
15
+ .filter((file) => (file.indexOf('.') !== 0) && (fileTypes.length === 0 || matches(file, fileTypes)));
16
+
17
+ if (transcripts) {
18
+ const
19
+ keys = Object.keys(transcripts),
20
+ list = keys.map((id) => `${id}.mp3`),
21
+ missing = [],
22
+ present = [];
23
+
24
+ // need to check for presence _and_ whether lyrics match.
25
+ for (let i = 0; i < list.length; i++) {
26
+ const
27
+ file = list[i];
28
+
29
+ if (all.indexOf(file) === -1) {
30
+ missing.push(file)
31
+ } else { // not actually missing, so let's check the artist and lyrics.
32
+ const
33
+ fileHash = (await getFileData(`${output}${file}`, 'mp3')).getHash({
34
+ hashKey: 'GenerationHash'
35
+ });
36
+
37
+ // We'll assume the original file should be kept if it doesn't include a generation hash.
38
+ if (fileHash && fileHash !== transcripts[keys[i]].getHash({
39
+ seed: encodedBy
40
+ })) {
41
+ missing.push(file);
42
+ } else {
43
+ present.push(file);
44
+ }
45
+ }
46
+ }
47
+
48
+ return {
49
+ missing,
50
+ present,
51
+ unlisted: all.filter((id) => list.indexOf(id) === -1),
52
+ all
53
+ };
54
+ }
55
+
56
+ return {
57
+ all
58
+ };
59
+ },
60
+ captionText = (caption) => (Array.isArray(caption) ? caption : [caption])
61
+ .map((cap) => cap?.caption ?? cap ?? null)
62
+ .filter((cap) => cap !== null)
63
+ .join(' '),
64
+ clamp = (text, max) => {
65
+ if (text.length <= max) {
66
+ return text;
67
+ } else {
68
+ const
69
+ arr = text.substring(0, max).split(' ');
70
+
71
+ if (arr.length > 1) {
72
+ arr.length -= 1; // remove last word which may be partial.
73
+ return `${arr.join(' ')}...`;
74
+ } else {
75
+ return arr[0];
76
+ }
77
+ }
78
+ },
79
+ appendMP3Meta = ({album, captions, generated = false, language, output, voice}) => {
80
+ Object.keys(captions).forEach(async (id) => {
81
+ const
82
+ text = captionText(captions[id]),
83
+ tags = {
84
+ album,
85
+ title: clamp(text, 40),
86
+ unsynchronisedLyrics: {
87
+ language: isoLanguageMap[language] ?? 'eng',
88
+ text
89
+ }
90
+ };
91
+
92
+ await id3.update(tags, `${output}${id}.mp3`);
93
+
94
+ if (generated) {
95
+ console.log(`Voice generated for "${id}".`);
96
+ } else {
97
+ console.log(`Voice meta data updated for "${id}".`);
98
+ }
99
+ });
100
+ },
101
+ mapReduction = (map, list) => list.reduce((obj, file) => {
102
+ const
103
+ id = file.substring(0, file.length - 4);
104
+
105
+ obj[id] = map[id];
106
+ return obj;
107
+ }, {}),
108
+ TextToSpeech = class extends Cheer {
109
+ constructor (data) {
110
+ const
111
+ {album, encodedBy} = data;
112
+
113
+ super(data);
114
+ this.album = album;
115
+ this.encodedBy = encodedBy;
116
+ }
117
+
118
+ async prepare (data) {
119
+ await this.replaceConfigPathWithJSON('script', 'files');
120
+
121
+ // create standardized transcripts and an accompanying hash for each.
122
+ const
123
+ {files, voice} = this.config,
124
+ standardizedFiles = {},
125
+ transcripts = {};
126
+
127
+ Object.keys(files).forEach((key) => {
128
+ const
129
+ transcript = new Transcript(files[key], {voice});
130
+
131
+ standardizedFiles[key] = transcript.toJSON();
132
+ transcripts[key] = transcript;
133
+ });
134
+ this.config.files = standardizedFiles;
135
+ this.transcripts = transcripts;
136
+
137
+ return super.prepare(data);
138
+ }
139
+
140
+ async checkDifference () {
141
+ const
142
+ {album, config, encodedBy = '', transcripts} = this,
143
+ {output, files = {}, language, updateAllMetaData = false} = config,
144
+ {missing, present, unlisted} = await getMP3s({
145
+ encodedBy,
146
+ fileTypes: ['mp3'],
147
+ output,
148
+ transcripts
149
+ });
150
+
151
+ config.files = mapReduction(files, missing);
152
+
153
+ for (let i = 0; i < unlisted.length; i++) {
154
+ await fs.rm(`${output}${unlisted[i]}`);
155
+ console.log(`Removed "${unlisted[i]}"`);
156
+ }
157
+
158
+ if (updateAllMetaData && present.length) {
159
+ appendMP3Meta({
160
+ album,
161
+ captions: mapReduction(files, present),
162
+ language,
163
+ output
164
+ });
165
+ }
166
+
167
+ if (missing.length === 0) {
168
+ throw Error('All voice-over files already exist.');
169
+ }
170
+
171
+ this.updateList = missing;
172
+ }
173
+
174
+ async afterExport (...args) {
175
+ const
176
+ {album, encodedBy, config, transcripts, updateList} = this,
177
+ {files, output, language, voice} = config,
178
+ list = updateList ?? Object.keys(files).map((id) => `${id}.mp3`);
179
+
180
+ appendMP3Meta({
181
+ album,
182
+ captions: files,
183
+ generated: true,
184
+ language,
185
+ output,
186
+ voice
187
+ });
188
+ for (let i = 0; i < list.length; i++) {
189
+ const
190
+ filename = list[i],
191
+ id = filename.substring(0, filename.length - 4),
192
+ file = await getFileData(`${output}${filename}`, 'mp3');
193
+
194
+ file.addHash(transcripts[id].getHash({
195
+ seed: encodedBy ?? ''
196
+ }), {
197
+ hashKey: 'GenerationHash'
198
+ });
199
+ await file.save();
200
+ }
201
+ super.afterExport(...args);
202
+ }
203
+ };
204
+
205
+ module.exports = TextToSpeech;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Processes and manages transcript data with voice resolution and key normalization.
3
+ *
4
+ * This class transforms raw transcript input into a standardized format. It ensures
5
+ * every line has an assigned voice, sorts object keys for consistent hashing,
6
+ * and provides utility for generating integrity hashes of the content.
7
+ *
8
+ * @class Transcript
9
+ * @param {string|string[]|Object|Object[]} transcript - The raw dialogue or data lines.
10
+ * @param {Object} options - Configuration object.
11
+ * @param {string|Object} options.voice - A single voice ID or a mapping of speaker names to IDs.
12
+ */
13
+
14
+ const
15
+ crypto = require('crypto'),
16
+ sortKeys = (obj) => Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b))),
17
+ resolveVoice = (voice, speaker) => {
18
+ if (typeof voice === 'string') {
19
+ return voice;
20
+ }
21
+
22
+ if (speaker && voice[speaker]) {
23
+ return voice[speaker];
24
+ }
25
+
26
+ return voice.default ?? Object.values(voice)[0];
27
+ },
28
+ Transcript = class {
29
+ constructor (transcript, {voice: voiceOrVoiceMap}) {
30
+
31
+ this.data = (Array.isArray(transcript) ? transcript : [transcript]).map((line) => {
32
+ if (typeof line === 'string') {
33
+ const
34
+ result = {caption: line};
35
+
36
+ if (voiceOrVoiceMap) {
37
+ result.voice = resolveVoice(voiceOrVoiceMap);
38
+ }
39
+
40
+ return result;
41
+ }
42
+
43
+ const
44
+ {caption, speaker, voice} = line,
45
+ resolvedVoice = voice ?? (voiceOrVoiceMap ? resolveVoice(voiceOrVoiceMap, speaker) : null),
46
+ result = {caption};
47
+
48
+ if (speaker) {
49
+ result.speaker = speaker;
50
+ }
51
+
52
+ if (resolvedVoice) {
53
+ result.voice = resolvedVoice;
54
+ }
55
+
56
+ return sortKeys(result);
57
+ });
58
+ }
59
+
60
+ getHash ({seed = ''} = {}) {
61
+ return crypto.createHash('sha256').update(seed + JSON.stringify(this.data)).digest('hex');
62
+ }
63
+
64
+ toJSON () {
65
+ return this.data;
66
+ }
67
+ };
68
+
69
+ module.exports = Transcript;
@@ -0,0 +1,163 @@
1
+ const
2
+ Cheer = require('./Cheer'),
3
+ Transcript = require('./Transcript'),
4
+ fs = require('fs').promises,
5
+ getCaptions = require('../helpers/getCaptions'),
6
+ getFileData = require('../helpers/getFileData'),
7
+ getJSON = require('../helpers/getJSON'),
8
+ SEPARATE_CHARACTER = '|',
9
+ JOIN_CHARACTER = '^',
10
+ filter = (fileType, file) => (file.indexOf('.') !== 0) && (file.slice(-(fileType.length + 1)) === `.${fileType}`),
11
+ mapReduction = (map, list) => list.reduce((obj, file) => {
12
+ const
13
+ id = file.substring(0, file.length - 4);
14
+
15
+ obj[id] = map[id];
16
+ return obj;
17
+ }, {}),
18
+ Transcription = class Transcription extends Cheer {
19
+ async prepare (data) {
20
+ this.differenceOnly = false;
21
+ this.updateList = null;
22
+ await this.replaceConfigPathWithJSON('script', 'files');
23
+
24
+ // create standardized transcripts and an accompanying hash for each.
25
+ const
26
+ {files, voice} = this.config,
27
+ standardizedFiles = {},
28
+ transcripts = {};
29
+
30
+ Object.keys(files).forEach((key) => {
31
+ const
32
+ transcript = new Transcript(files[key], {voice});
33
+
34
+ standardizedFiles[key] = transcript.toJSON();
35
+ transcripts[key] = transcript;
36
+ });
37
+ this.config.files = standardizedFiles;
38
+ this.transcripts = transcripts;
39
+
40
+ return super.prepare(data);
41
+ }
42
+
43
+ async checkDifference () {
44
+ const
45
+ {config} = this,
46
+ {files, format = 'json', output, src} = config,
47
+ fileType = format.toLowerCase(),
48
+ {captions: alreadyCaptioned, file: captionsFile, files: fileMap} = await getCaptions(output, fileType),
49
+ check = (id, caption) => {
50
+ const
51
+ file = fileMap?.[id],
52
+ original = alreadyCaptioned[id],
53
+ hash = file?.getHash();
54
+
55
+ delete alreadyCaptioned[id];
56
+
57
+ if (hash) {
58
+ const
59
+ same = hash === this.transcripts[id].getHash();
60
+
61
+ if (!same) {
62
+ console.log(`Updating "${id}".`);
63
+ }
64
+
65
+ return same;
66
+ } else {
67
+ const
68
+ cap = (Array.isArray(caption) ? caption : [caption]).map((cap) => cap?.caption ?? cap ?? null).filter((cap) => cap !== null).join(' ').replaceAll(JOIN_CHARACTER, ' ').replaceAll(SEPARATE_CHARACTER, ' ');
69
+
70
+ if (cap && cap !== original) {
71
+ console.log(`Updating "${id}".`);
72
+ }
73
+
74
+ // if a caption is not supplied, we'll assume the existence of an original caption means we don't need to redo.
75
+ return cap === '' || cap === original;
76
+ }
77
+ },
78
+ mergeCaptions = async (newCaptions = false) => { // Must run _after_ all checks and will remove what's left.
79
+ const
80
+ olds = Object.keys(alreadyCaptioned);
81
+
82
+ for (let i = 0; i < olds.length; i++) {
83
+ const
84
+ caption = olds[i];
85
+
86
+ if (captionsFile) {
87
+ captionsFile.removeKey(caption);
88
+ } else {
89
+ await fs.rm(`${output}${caption}.${fileType}`);
90
+ }
91
+ console.log(`Removed "${caption}"`);
92
+ }
93
+ if (captionsFile) {
94
+ if (newCaptions) {
95
+ captionsFile.mergeData(await getJSON(`${output}captions.json`));
96
+ }
97
+ await captionsFile.save();
98
+ }
99
+
100
+ return olds.length;
101
+ },
102
+ list = (await fs.readdir(src)).filter(filter.bind(null, 'mp3')).filter((file) => {
103
+ const
104
+ id = file.substring(0, file.length - 4),
105
+ caption = files?.[id];
106
+
107
+ return !check(id, caption);
108
+ });
109
+
110
+ this.differenceOnly = true;
111
+
112
+ if (list.length === 0) {
113
+ // We'll still run the merge in case any have been removed.
114
+ if (await mergeCaptions()) {
115
+ throw Error('Old captions removed, but no new captions required generation.');
116
+ } else {
117
+ throw Error('Captions already up to date.');
118
+ }
119
+ }
120
+
121
+ this.updateList = list;
122
+ config.files = mapReduction(files, list);
123
+ this.mergeCaptions = mergeCaptions;
124
+ }
125
+
126
+ beforeSend (archive) {
127
+ const
128
+ {src} = this.config;
129
+
130
+ if (this.differenceOnly) {
131
+ this.updateList.forEach((file) => archive.file(`${src}${file}`, {
132
+ name: file
133
+ }));
134
+ } else {
135
+ archive.directory(src, false);
136
+ }
137
+ super.beforeSend(archive);
138
+ }
139
+
140
+ async afterExport (...args) {
141
+ const
142
+ {config, transcripts, updateList} = this,
143
+ {format = 'json', output} = config;
144
+
145
+ if (this.mergeCaptions) {
146
+ await this.mergeCaptions(true);
147
+ }
148
+ if (format !== 'json') {
149
+ for (let i = 0; i < updateList.length; i++) {
150
+ const
151
+ filename = updateList[i],
152
+ id = filename.substring(0, filename.length - 4),
153
+ file = await getFileData(`${output}${id}.${format}`, format);
154
+
155
+ file.addHash(transcripts[id].getHash());
156
+ await file.save();
157
+ }
158
+ }
159
+ super.afterExport(...args)
160
+ }
161
+ };
162
+
163
+ module.exports = Transcription;
@@ -0,0 +1,27 @@
1
+ const
2
+ fs = require('fs').promises,
3
+ File = class {
4
+ constructor ({fileType}) {
5
+ this.data = null;
6
+ this.fileType = fileType;
7
+ }
8
+
9
+ async load (path) {
10
+ this.path = path;
11
+ return null;
12
+ }
13
+
14
+ getHash () {
15
+ return null; // does not support hashes.
16
+ }
17
+
18
+ save () { // return a promise
19
+ return fs.writeFile(`${this.path}`, this.data ?? '');
20
+ }
21
+
22
+ toString () {
23
+ return '';
24
+ }
25
+ }
26
+
27
+ module.exports = File;
@@ -0,0 +1,40 @@
1
+ const
2
+ crypto = require('crypto'),
3
+ fs = require('fs').promises,
4
+ File = require("./File"),
5
+ getJSON = require('../helpers/getJSON'),
6
+ sortKeys = (obj) => Object.keys(obj).sort().reduce((s, key) => {
7
+ s[key] = obj[key];
8
+ return s;
9
+ }, {});
10
+
11
+ module.exports = class JSONFile extends File {
12
+ async load (path) {
13
+ await super.load(path);
14
+ this.data = await getJSON(path);
15
+ return this.data;
16
+ }
17
+
18
+ getHash () {
19
+ return this.data ? crypto.createHash('sha256').update(JSON.stringify(this.data)).digest('hex') : null;
20
+ }
21
+
22
+ toString () {
23
+ return Object.keys(this.data).map((key) => this.data[key].map(({content}) => content).join(' ')).join('\n');
24
+ }
25
+
26
+ removeKey (key) {
27
+ delete this.data[key];
28
+ }
29
+
30
+ mergeData (data) {
31
+ this.data = sortKeys({
32
+ ...this.data,
33
+ ...data
34
+ });
35
+ }
36
+
37
+ save () { // return a promise
38
+ return fs.writeFile(`${this.path}`, JSON.stringify(this.data, null, 4));
39
+ }
40
+ }