@makefully/dutifully 1.11.0 → 1.12.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 +8 -0
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/classes/LipSync.js +11 -3
- package/src/classes/TextToSpeech.js +14 -3
- package/src/classes/Transcript.js +5 -1
- package/src/classes/Transcription.js +9 -2
- package/src/formats/File.js +3 -1
- package/src/formats/JSONFile.js +4 -2
- package/src/formats/MP3.js +4 -2
- package/src/helpers/dirs.js +30 -0
- package/src/helpers/getCaptions.js +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.12.1
|
|
4
|
+
|
|
5
|
+
- Preserve optional script-line `events` through Transcript for timed marker definitions.
|
|
6
|
+
|
|
7
|
+
## 1.12.0
|
|
8
|
+
|
|
9
|
+
- Treat missing `src`/`output` directories as empty instead of failing; create output directories when writing.
|
|
10
|
+
|
|
3
11
|
## 1.11.0
|
|
4
12
|
|
|
5
13
|
- Preserve optional script-line `class` through transcription for VTT cue classes, SAMI spans, and JSON caption output.
|
package/README.md
CHANGED
|
@@ -205,6 +205,14 @@ If your scripts include any phrases that should remain unbroken by the captionin
|
|
|
205
205
|
|
|
206
206
|
Script lines may include an optional `class` (for example `"title"` or `"stage"`). In VTT exports this becomes a cue class span (`<c.title>…</c>`). In SAMI/SMI the paragraph `Class` is the job language plus `CC` (for example `ENUSCC` for `en-US`); when a speaker and/or script `class` is present, the dialog is wrapped in `<SPAN Class="…">` (for example `Class="todd stage"`). In JSON exports it is included on each caption entry. SRT, LRC, and MP3 SYLT ignore it.
|
|
207
207
|
|
|
208
|
+
Script lines may also include an optional `events` array for one-shot timed markers. Entries may be:
|
|
209
|
+
|
|
210
|
+
* a **number** — add to the running absolute time offset (ms)
|
|
211
|
+
* a **string** `"<phrase"` / `">phrase"` — set the offset to just before/after that phrase in the caption; any other string fires that event at the current offset
|
|
212
|
+
* an **object** `{ "event", "time"?, "before"?, "after"? }` — fires `event`; optional fields set the absolute offset (`time` wins over `before` over `after`)
|
|
213
|
+
|
|
214
|
+
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"`).
|
|
215
|
+
|
|
208
216
|
## Example
|
|
209
217
|
|
|
210
218
|
Find an example script in the `./examples/` folder. You can run either `npm run test-elevenlabs` or `npm run test-polly` to create VO tracks in the examples folder that you can then use to try out `npm run test-rhubarb` and `npm run test-transcription`.
|
package/package.json
CHANGED
package/src/classes/LipSync.js
CHANGED
|
@@ -2,11 +2,12 @@ const
|
|
|
2
2
|
Cheer = require('./Cheer'),
|
|
3
3
|
fs = require('fs').promises,
|
|
4
4
|
getJSON = require('../helpers/getJSON'),
|
|
5
|
+
{ensureDir, ensureParentDir, readdirOrEmpty} = require('../helpers/dirs'),
|
|
5
6
|
id3 = require('node-id3').Promise,
|
|
6
7
|
filter = (fileType, file) => (file.indexOf('.') !== 0) && (file.slice(-(fileType.length + 1)) === `.${fileType}`),
|
|
7
8
|
combine = async (path, fileType) => {
|
|
8
9
|
const
|
|
9
|
-
files = (await
|
|
10
|
+
files = (await readdirOrEmpty(path)).filter(filter.bind(null, fileType)),
|
|
10
11
|
list = [];
|
|
11
12
|
|
|
12
13
|
for (let i = 0; i < files.length; i++) {
|
|
@@ -56,7 +57,13 @@ const
|
|
|
56
57
|
{config} = this,
|
|
57
58
|
{files = {}, options = {}, output, src} = config,
|
|
58
59
|
{exportFormat = 'json', output: outputType = 'json'} = options,
|
|
59
|
-
file = 'mouthCues.json'
|
|
60
|
+
file = 'mouthCues.json';
|
|
61
|
+
|
|
62
|
+
if (output) {
|
|
63
|
+
await ensureDir(output);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const
|
|
60
67
|
raw = outputType === 'json' && exportFormat !== 'mp3' ? await getJSON(`${output}${file}`) ?? {} : null,
|
|
61
68
|
alreadyLipSynced = raw ? Object.keys(raw) : await combine(output, exportFormat),
|
|
62
69
|
check = (id) => {
|
|
@@ -83,6 +90,7 @@ const
|
|
|
83
90
|
console.log(`Removed "${key}"`);
|
|
84
91
|
}
|
|
85
92
|
if (raw) {
|
|
93
|
+
await ensureParentDir(`${output}${file}`);
|
|
86
94
|
await fs.writeFile(`${output}${file}`, JSON.stringify(sortKeys({
|
|
87
95
|
...raw,
|
|
88
96
|
...newLipSync ? await getJSON(`${output}${file}`) ?? {} : {} // get new version.
|
|
@@ -91,7 +99,7 @@ const
|
|
|
91
99
|
|
|
92
100
|
return alreadyLipSynced.length;
|
|
93
101
|
},
|
|
94
|
-
list = (await
|
|
102
|
+
list = (await readdirOrEmpty(src)).filter(filter.bind(null, 'mp3')).filter((file) => !check(file.substring(0, file.length - 4)));
|
|
95
103
|
|
|
96
104
|
this.differenceOnly = true;
|
|
97
105
|
|
|
@@ -3,6 +3,7 @@ const
|
|
|
3
3
|
Transcript = require('./Transcript'),
|
|
4
4
|
fs = require('fs').promises,
|
|
5
5
|
getFileData = require('../helpers/getFileData'),
|
|
6
|
+
{ensureDir, readdirOrEmpty} = require('../helpers/dirs'),
|
|
6
7
|
id3 = require('node-id3').Promise,
|
|
7
8
|
isoLanguageMap = {
|
|
8
9
|
'en-US': 'eng',
|
|
@@ -11,7 +12,7 @@ const
|
|
|
11
12
|
matches = (file, types) => types.reduce((prev, current) => prev || (file.slice(-(current.length + 1)) === `.${current}`), false),
|
|
12
13
|
getMP3s = async ({encodedBy = '', output = './', fileTypes = [], transcripts}) => {
|
|
13
14
|
const
|
|
14
|
-
all = (await
|
|
15
|
+
all = (await readdirOrEmpty(output))
|
|
15
16
|
.filter((file) => (file.indexOf('.') !== 0) && (fileTypes.length === 0 || matches(file, fileTypes)));
|
|
16
17
|
|
|
17
18
|
if (transcripts) {
|
|
@@ -76,7 +77,11 @@ const
|
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
79
|
},
|
|
79
|
-
appendMP3Meta = ({album, captions, generated = false, language, output, voice}) => {
|
|
80
|
+
appendMP3Meta = async ({album, captions, generated = false, language, output, voice}) => {
|
|
81
|
+
if (output) {
|
|
82
|
+
await ensureDir(output);
|
|
83
|
+
}
|
|
84
|
+
|
|
80
85
|
Object.keys(captions).forEach(async (id) => {
|
|
81
86
|
const
|
|
82
87
|
text = captionText(captions[id]),
|
|
@@ -140,7 +145,13 @@ const
|
|
|
140
145
|
async checkDifference () {
|
|
141
146
|
const
|
|
142
147
|
{album, config, encodedBy = '', transcripts} = this,
|
|
143
|
-
{output, files = {}, language, updateAllMetaData = false} = config
|
|
148
|
+
{output, files = {}, language, updateAllMetaData = false} = config;
|
|
149
|
+
|
|
150
|
+
if (output) {
|
|
151
|
+
await ensureDir(output);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const
|
|
144
155
|
{missing, present, unlisted} = await getMP3s({
|
|
145
156
|
encodedBy,
|
|
146
157
|
fileTypes: ['mp3'],
|
|
@@ -41,7 +41,7 @@ const
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
const
|
|
44
|
-
{caption, class: lineClass, speaker, voice} = line,
|
|
44
|
+
{caption, class: lineClass, speaker, voice, events} = line,
|
|
45
45
|
resolvedVoice = voice ?? (voiceOrVoiceMap ? resolveVoice(voiceOrVoiceMap, speaker) : null),
|
|
46
46
|
result = {caption};
|
|
47
47
|
|
|
@@ -53,6 +53,10 @@ const
|
|
|
53
53
|
result.speaker = speaker;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
if (events) {
|
|
57
|
+
result.events = events;
|
|
58
|
+
}
|
|
59
|
+
|
|
56
60
|
if (resolvedVoice) {
|
|
57
61
|
result.voice = resolvedVoice;
|
|
58
62
|
}
|
|
@@ -5,6 +5,7 @@ const
|
|
|
5
5
|
getCaptions = require('../helpers/getCaptions'),
|
|
6
6
|
getFileData = require('../helpers/getFileData'),
|
|
7
7
|
getJSON = require('../helpers/getJSON'),
|
|
8
|
+
{ensureDir, readdirOrEmpty} = require('../helpers/dirs'),
|
|
8
9
|
SEPARATE_CHARACTER = '|',
|
|
9
10
|
JOIN_CHARACTER = '^',
|
|
10
11
|
filter = (fileType, file) => (file.indexOf('.') !== 0) && (file.slice(-(fileType.length + 1)) === `.${fileType}`),
|
|
@@ -44,7 +45,13 @@ const
|
|
|
44
45
|
const
|
|
45
46
|
{config} = this,
|
|
46
47
|
{files, format = 'json', output, src} = config,
|
|
47
|
-
fileType = format.toLowerCase()
|
|
48
|
+
fileType = format.toLowerCase();
|
|
49
|
+
|
|
50
|
+
if (output) {
|
|
51
|
+
await ensureDir(output);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const
|
|
48
55
|
{captions: alreadyCaptioned, file: captionsFile, files: fileMap} = await getCaptions(output, fileType),
|
|
49
56
|
check = (id, caption) => {
|
|
50
57
|
const
|
|
@@ -99,7 +106,7 @@ const
|
|
|
99
106
|
|
|
100
107
|
return olds.length;
|
|
101
108
|
},
|
|
102
|
-
list = (await
|
|
109
|
+
list = (await readdirOrEmpty(src)).filter(filter.bind(null, 'mp3')).filter((file) => {
|
|
103
110
|
const
|
|
104
111
|
id = file.substring(0, file.length - 4),
|
|
105
112
|
caption = files?.[id];
|
package/src/formats/File.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const
|
|
2
2
|
fs = require('fs').promises,
|
|
3
|
+
{ensureParentDir} = require('../helpers/dirs'),
|
|
3
4
|
File = class {
|
|
4
5
|
constructor ({fileType}) {
|
|
5
6
|
this.data = null;
|
|
@@ -15,7 +16,8 @@ const
|
|
|
15
16
|
return null; // does not support hashes.
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
save () { // return a promise
|
|
19
|
+
async save () { // return a promise
|
|
20
|
+
await ensureParentDir(this.path);
|
|
19
21
|
return fs.writeFile(`${this.path}`, this.data ?? '');
|
|
20
22
|
}
|
|
21
23
|
|
package/src/formats/JSONFile.js
CHANGED
|
@@ -2,6 +2,7 @@ const
|
|
|
2
2
|
crypto = require('crypto'),
|
|
3
3
|
fs = require('fs').promises,
|
|
4
4
|
File = require("./File"),
|
|
5
|
+
{ensureParentDir} = require('../helpers/dirs'),
|
|
5
6
|
getJSON = require('../helpers/getJSON'),
|
|
6
7
|
sortKeys = (obj) => Object.keys(obj).sort().reduce((s, key) => {
|
|
7
8
|
s[key] = obj[key];
|
|
@@ -11,7 +12,7 @@ const
|
|
|
11
12
|
module.exports = class JSONFile extends File {
|
|
12
13
|
async load (path) {
|
|
13
14
|
await super.load(path);
|
|
14
|
-
this.data = await getJSON(path);
|
|
15
|
+
this.data = await getJSON(path) ?? {};
|
|
15
16
|
return this.data;
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -34,7 +35,8 @@ module.exports = class JSONFile extends File {
|
|
|
34
35
|
});
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
save () { // return a promise
|
|
38
|
+
async save () { // return a promise
|
|
39
|
+
await ensureParentDir(this.path);
|
|
38
40
|
return fs.writeFile(`${this.path}`, JSON.stringify(this.data, null, 4));
|
|
39
41
|
}
|
|
40
42
|
}
|
package/src/formats/MP3.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const
|
|
2
2
|
id3 = require('node-id3').Promise,
|
|
3
|
-
File = require("./File")
|
|
3
|
+
File = require("./File"),
|
|
4
|
+
{ensureParentDir} = require('../helpers/dirs');
|
|
4
5
|
|
|
5
6
|
module.exports = class MP3 extends File {
|
|
6
7
|
async load (path) {
|
|
@@ -31,7 +32,8 @@ module.exports = class MP3 extends File {
|
|
|
31
32
|
return null;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
save () { // return a promise
|
|
35
|
+
async save () { // return a promise
|
|
36
|
+
await ensureParentDir(this.path);
|
|
35
37
|
return id3.write(this.data, this.path);
|
|
36
38
|
}
|
|
37
39
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const
|
|
2
|
+
fs = require('fs').promises,
|
|
3
|
+
path = require('path'),
|
|
4
|
+
ensureDir = async (dir) => {
|
|
5
|
+
if (dir) {
|
|
6
|
+
await fs.mkdir(dir, {recursive: true});
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
ensureParentDir = async (filePath) => {
|
|
10
|
+
if (filePath) {
|
|
11
|
+
await ensureDir(path.dirname(filePath));
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
readdirOrEmpty = async (dir) => {
|
|
15
|
+
try {
|
|
16
|
+
return await fs.readdir(dir);
|
|
17
|
+
} catch (e) {
|
|
18
|
+
if (e?.code === 'ENOENT') {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
throw e;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
module.exports = {
|
|
27
|
+
ensureDir,
|
|
28
|
+
ensureParentDir,
|
|
29
|
+
readdirOrEmpty
|
|
30
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const
|
|
2
|
-
fs = require('fs').promises,
|
|
3
2
|
getFileData = require("./getFileData"),
|
|
3
|
+
{readdirOrEmpty} = require("./dirs"),
|
|
4
4
|
filter = (fileType, file) => (file.indexOf('.') !== 0) && (file.slice(-(fileType.length + 1)) === `.${fileType}`);
|
|
5
5
|
|
|
6
6
|
module.exports = async (path, fileType) => {
|
|
@@ -9,10 +9,10 @@ module.exports = async (path, fileType) => {
|
|
|
9
9
|
if (fileType.toLowerCase() === 'json') {
|
|
10
10
|
const
|
|
11
11
|
file = await getFileData(`${path}captions.json`, 'json'),
|
|
12
|
-
{data: json} = file;
|
|
12
|
+
{data: json = {}} = file;
|
|
13
13
|
|
|
14
14
|
return {
|
|
15
|
-
captions: Object.keys(json).reduce((obj, key) => {
|
|
15
|
+
captions: Object.keys(json ?? {}).reduce((obj, key) => {
|
|
16
16
|
obj[key] = json[key].map(({content}) => content).join(' ');
|
|
17
17
|
return obj;
|
|
18
18
|
}, {}),
|
|
@@ -21,7 +21,7 @@ module.exports = async (path, fileType) => {
|
|
|
21
21
|
};
|
|
22
22
|
} else { // Otherwise we pull all the files and construct a key/value object.
|
|
23
23
|
const
|
|
24
|
-
files = (await
|
|
24
|
+
files = (await readdirOrEmpty(path)).filter(filter.bind(null, fileType)),
|
|
25
25
|
captions = {},
|
|
26
26
|
fileMap = {};
|
|
27
27
|
|