@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.
- package/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/cheer.js +37 -0
- package/package.json +63 -0
- package/say.js +15 -0
- package/src/classes/Cheer.js +255 -0
- package/src/classes/ElevenLabs.js +17 -0
- package/src/classes/FFMPEG.js +13 -0
- package/src/classes/LipSync.js +133 -0
- package/src/classes/Polly.js +10 -0
- package/src/classes/Rasterize.js +13 -0
- package/src/classes/TextToSpeech.js +205 -0
- package/src/classes/Transcript.js +69 -0
- package/src/classes/Transcription.js +163 -0
- package/src/formats/File.js +27 -0
- package/src/formats/JSONFile.js +40 -0
- package/src/formats/LRC.js +38 -0
- package/src/formats/MP3.js +60 -0
- package/src/formats/SAMI.js +59 -0
- package/src/formats/SRT.js +40 -0
- package/src/formats/VTT.js +38 -0
- package/src/helpers/getCaptions.js +44 -0
- package/src/helpers/getFileData.js +33 -0
- package/src/helpers/getJSON.js +33 -0
- package/src/index.js +58 -0
- package/src/say.js +141 -0
|
@@ -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,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
|
+
}
|