easycaptionkit 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +175 -2
- package/package.json +42 -4
- package/skills/easycaptionkit/SKILL.md +58 -0
- package/skills/easycaptionkit/scripts/easycaptionkit.js +386 -0
- package/skills/easycaptionkit/scripts/lib/apng.js +136 -0
- package/skills/easycaptionkit/scripts/lib/caption.js +1395 -0
- package/skills/easycaptionkit/scripts/lib/chrome.js +424 -0
- package/skills/easycaptionkit/scripts/lib/cues.js +317 -0
- package/skills/easycaptionkit/scripts/lib/draw.js +288 -0
- package/skills/easycaptionkit/scripts/lib/ffmpeg.js +1032 -0
- package/skills/easycaptionkit/scripts/lib/files.js +249 -0
- package/skills/easycaptionkit/scripts/lib/fontfile.js +355 -0
- package/skills/easycaptionkit/scripts/lib/look.js +571 -0
- package/skills/easycaptionkit/scripts/lib/places.js +337 -0
- package/skills/easycaptionkit/scripts/lib/render.js +374 -0
- package/skills/easycaptionkit/scripts/lib/report.js +604 -0
- package/skills/easycaptionkit/scripts/lib/running.js +62 -0
- package/skills/easycaptionkit/scripts/lib/subtitles.js +489 -0
- package/skills/easycaptionkit/scripts/lib/text.js +115 -0
- package/skills/easycaptionkit/scripts/lib/version.js +5 -0
- package/skills/easycaptionkit/scripts/lib/words.js +1090 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// CAPTIONS.md, for people and agents, and captions.json, for tools: what
|
|
4
|
+
// was made, the look, where the captions sit and why, how fast they read,
|
|
5
|
+
// and the license facts of the font burned into the picture.
|
|
6
|
+
|
|
7
|
+
const fs = require("node:fs");
|
|
8
|
+
const path = require("node:path");
|
|
9
|
+
const { variableFace, sameName } = require("./look");
|
|
10
|
+
const { onPath } = require("./ffmpeg");
|
|
11
|
+
const { FORMATS } = require("./words");
|
|
12
|
+
|
|
13
|
+
const round = (value, places = 3) => Math.round(value * 10 ** places) / 10 ** places;
|
|
14
|
+
// A moment as m:ss.s, rounded to the tenth before the minutes are taken,
|
|
15
|
+
// so that 59.96 s is 1:00.0 and not 0:60.0.
|
|
16
|
+
const clock = (t) => {
|
|
17
|
+
const tenths = Math.max(0, Math.round(t * 10));
|
|
18
|
+
return `${Math.floor(tenths / 600)}:${((tenths % 600) / 10).toFixed(1).padStart(4, "0")}`;
|
|
19
|
+
};
|
|
20
|
+
const list = (items) => (items.length <= 1 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`);
|
|
21
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
22
|
+
// A name on one line of Markdown or of the terminal: its control
|
|
23
|
+
// characters become a space, as a line break in it would end a heading or
|
|
24
|
+
// a list item, or start a line of its own in a message, and an escape
|
|
25
|
+
// would be a sequence the terminal acts on, as one that sets its title.
|
|
26
|
+
const shown = (name) => String(name).replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ");
|
|
27
|
+
|
|
28
|
+
// A value as a shell takes it: as it is when it holds only plain letters,
|
|
29
|
+
// else in single quotes, inside which sh, bash and zsh expand nothing, not
|
|
30
|
+
// even the ! of an interactive shell's history, with a quote in it written
|
|
31
|
+
// '\''. Control characters, the C1 ones too, are written out in $'...',
|
|
32
|
+
// which bash and zsh read, so the command stays on one line and shows as
|
|
33
|
+
// it is in a message: each as the bytes of its UTF-8, \x0a, \xc2\x9b,
|
|
34
|
+
// which give the name's own bytes in any locale, and a quote or a ! as
|
|
35
|
+
// \x27 and \x21 there. On Windows, whose cmd takes no single quotes, in
|
|
36
|
+
// double ones, with every letter as it is, a backslash, a $ and a ` too,
|
|
37
|
+
// as Windows splits a command line: only the backslashes before a quote,
|
|
38
|
+
// which a Windows name never holds, or before the closing one, as a path
|
|
39
|
+
// to a drive's top folder ends, are doubled. PowerShell still reads a
|
|
40
|
+
// $name inside double quotes as its variable.
|
|
41
|
+
const quote = (value, platform = process.platform) => {
|
|
42
|
+
const text = String(value);
|
|
43
|
+
if (/^[\w./:@%+=-]+$/.test(text)) return text;
|
|
44
|
+
if (platform === "win32") return `"${text.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1")}"`;
|
|
45
|
+
const bytes = (c) => [...Buffer.from(c, "utf8")].map((b) => `\\x${b.toString(16).padStart(2, "0")}`).join("");
|
|
46
|
+
if (/[\u0000-\u001f\u007f-\u009f]/.test(text)) return `$'${text.replace(/\\/g, "\\\\").replace(/['!\u0000-\u001f\u007f-\u009f]/g, bytes)}'`;
|
|
47
|
+
return `'${text.replace(/'/g, "'\\''")}'`;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// A path as a shell takes it, quoted as above. A relative one that starts
|
|
51
|
+
// with a dash, as -take2/words.json, is written from ./ (.\ on Windows),
|
|
52
|
+
// so the program does not take it for an option.
|
|
53
|
+
const arg = (p, platform = process.platform) => {
|
|
54
|
+
const text = String(p);
|
|
55
|
+
return quote(text.startsWith("-") ? `${platform === "win32" ? ".\\" : "./"}${text}` : text, platform);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Text as a Markdown code span: fenced by one backtick more than the
|
|
59
|
+
// longest run of them in it, so a ` in a path does not end it, and copied
|
|
60
|
+
// whole, nothing of the sentence around it comes with it.
|
|
61
|
+
const code = (text) => {
|
|
62
|
+
const longest = Math.max(0, ...(String(text).match(/`+/g) || []).map((run) => run.length));
|
|
63
|
+
const fence = "`".repeat(longest + 1);
|
|
64
|
+
const pad = /^`|`$/.test(text) ? " " : "";
|
|
65
|
+
return `${fence}${pad}${text}${pad}${fence}`;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// The program a printed command starts with, as this run was started, so
|
|
69
|
+
// the command runs as given in the shell it was started from: npx's copy
|
|
70
|
+
// again with npx, the command npm install -g puts on PATH by its name, a
|
|
71
|
+
// project's own copy that npx or an npm script ran with npx, which runs
|
|
72
|
+
// that copy, and any other with node and its whole path, as the skill
|
|
73
|
+
// runs it. npm puts a project's node_modules/.bin first on the PATH of
|
|
74
|
+
// what it runs, for that run alone, so a name found there is not on the
|
|
75
|
+
// shell's PATH. `script` is the file node runs.
|
|
76
|
+
function programOf(script = process.argv[1] || "", { pathTo = onPath, env = process.env } = {}) {
|
|
77
|
+
if (!script) return "npx easycaptionkit@latest";
|
|
78
|
+
const real = (file) => {
|
|
79
|
+
try {
|
|
80
|
+
return fs.realpathSync(file);
|
|
81
|
+
} catch {
|
|
82
|
+
return path.resolve(file);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const parts = (file) => String(file).split(/[\\/]/);
|
|
86
|
+
const within = (name) => [script, real(script)].some((file) => parts(file).includes(name));
|
|
87
|
+
if (within("_npx")) return "npx easycaptionkit@latest";
|
|
88
|
+
const npmBin = (dir) => {
|
|
89
|
+
const p = parts(dir).filter(Boolean);
|
|
90
|
+
return p[p.length - 1] === ".bin" && p[p.length - 2] === "node_modules";
|
|
91
|
+
};
|
|
92
|
+
const named = pathTo("easycaptionkit", { skip: npmBin });
|
|
93
|
+
if (named && !npmBin(path.dirname(named)) && real(named) === real(script)) return "easycaptionkit";
|
|
94
|
+
if ((env.npm_command || env.npm_lifecycle_event) && within("node_modules")) return "npx easycaptionkit";
|
|
95
|
+
return `node ${arg(script)}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The subtitles command that makes a run again from the words of `words`
|
|
99
|
+
// into `out`: with its video and every option of the run that is not its
|
|
100
|
+
// default, each path as it was given, so it runs from where the run was
|
|
101
|
+
// made. `options` are the run's: { program, video, places, style, lines,
|
|
102
|
+
// maxChars, size, position, box, brand, fonts, font, highlight }, program
|
|
103
|
+
// from programOf, as npx runs the kit where it is not given.
|
|
104
|
+
function subtitlesCommand(options, words, out) {
|
|
105
|
+
const o = options || {};
|
|
106
|
+
const parts = [o.program || "npx easycaptionkit@latest", "subtitles", arg(words)];
|
|
107
|
+
if (o.video) parts.push("--video", arg(o.video));
|
|
108
|
+
if (o.places && o.places.length) parts.push("--for", o.places.join(","));
|
|
109
|
+
if (o.style && o.style !== "karaoke") parts.push("--style", o.style);
|
|
110
|
+
if (o.lines !== undefined && o.lines !== 2) parts.push("--lines", String(o.lines));
|
|
111
|
+
if (o.maxChars !== undefined && o.maxChars !== 42) parts.push("--max-chars", String(o.maxChars));
|
|
112
|
+
if (o.size !== undefined && o.size !== (o.style === "word" ? 9.5 : 6.5)) parts.push("--size", String(o.size));
|
|
113
|
+
if (o.position && o.position !== "bottom") parts.push("--position", o.position);
|
|
114
|
+
if (o.box) parts.push("--box");
|
|
115
|
+
for (const [flag, value] of [["--brand", o.brand], ["--fonts", o.fonts]]) if (value) parts.push(flag, arg(value));
|
|
116
|
+
for (const [flag, value] of [["--font", o.font], ["--highlight", o.highlight]]) if (value) parts.push(flag, quote(value));
|
|
117
|
+
parts.push("--out", arg(out));
|
|
118
|
+
return parts.join(" ");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The font captions.ass names, for the subtitles report: { name, more },
|
|
122
|
+
// the name as the sentence gives it and what follows it. libass and the
|
|
123
|
+
// players that read ASS size a font by its own heights, so a font asked
|
|
124
|
+
// for by name is sized for the one this machine gives for it.
|
|
125
|
+
function assFontText(f) {
|
|
126
|
+
const look = f.look;
|
|
127
|
+
const m = f.libassFont || null;
|
|
128
|
+
if (!look.family) return { name: "Arial, as captions.ass asks for it by name", more: "" };
|
|
129
|
+
if (look.from === "fonts.css") return { name: `${look.family} from fonts.css`, more: [f.assFamily && !sameName(f.assFamily, look.family) ? `captions.ass names it ${f.assFamily}, the family name in its file, which players find a font by.` : "", thinner(f)].filter(Boolean).join(" ") };
|
|
130
|
+
if (m && sameName(m.family, look.family)) return { name: `${look.family}, as installed on this machine`, more: "" };
|
|
131
|
+
if (m) return { name: look.family, more: `${look.family} was asked for by name, and this machine does not have it: captions.ass is sized for ${m.family}${m.style ? ` ${m.style}` : ""}, which fontconfig gives for that name, so a player with another font for it draws the captions larger or smaller.` };
|
|
132
|
+
return { name: look.family, more: `${look.family} was asked for by name, and captions.ass is sized for Arial's heights: nothing here says what this machine gives for that name.` };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Where ffmpeg's ass filter finds a font of fonts.css, which is on no
|
|
136
|
+
// machine's list of fonts, for the captions.ass row of a table: in its
|
|
137
|
+
// folder, from the folder of captions.ass, given as the filter's
|
|
138
|
+
// fontsdir. A player draws it once it is installed.
|
|
139
|
+
function fontsWhere(f) {
|
|
140
|
+
if (!f.assFonts) return "";
|
|
141
|
+
const cell = (text) => code(shown(text)).replace(/\|/g, "\\|");
|
|
142
|
+
// A folder of plain letters goes into the filter as it is.
|
|
143
|
+
const filter = `ass=captions.ass:fontsdir=${/^[\w./@%+=-]+$/.test(f.assFonts) ? f.assFonts : "<that folder>"}:shaping=complex`;
|
|
144
|
+
// Where Chrome drew the captions, libass's letters are thinner than its,
|
|
145
|
+
// and a subtitles run says so where it says what captions.ass draws.
|
|
146
|
+
const thin = !f.subtitlesOnly && f.drawnWith !== "libass" ? thinner(f, "in captioned.mp4", cell) : "";
|
|
147
|
+
return `; its font is a file of fonts.css in ${cell(f.assFonts)}, which the filter finds when that is its fontsdir, as in ${cell(filter)} run in this folder, and a player once the font is installed${thin ? `. ${thin.replace(/\.$/, "")}` : ""}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// That libass draws a variable font file of fonts.css at its default
|
|
151
|
+
// weight, as captions.ass is drawn, and what the letters come out thinner
|
|
152
|
+
// than, if anything; "" for a font of fixed weights, or where libass draws
|
|
153
|
+
// no letter with it. `name` writes the file's name.
|
|
154
|
+
function thinner(f, than = "", name = (text) => text) {
|
|
155
|
+
const face = f.look.from === "fonts.css" && setsAny(f) ? variableFace(f.look) : null;
|
|
156
|
+
if (!face) return "";
|
|
157
|
+
return `libass draws ${name(face.name || path.basename(face.file))}, a file of the weights ${face.weight[0]} to ${face.weight[1]}, at its default weight, not at ${f.look.weight}, so the letters come out thinner${than ? ` than ${than}` : ""}.`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// How captioned.mp4's sound was written, from its tracks, each { copied,
|
|
161
|
+
// from }: "sound copied as it was", "sound made AAC", and for several
|
|
162
|
+
// tracks which were made AAC, by number and the codec they had.
|
|
163
|
+
function soundText(tracks = []) {
|
|
164
|
+
if (!tracks.length) return "no sound";
|
|
165
|
+
const made = tracks.map((t, i) => ({ ...t, n: i + 1 })).filter((t) => !t.copied);
|
|
166
|
+
if (tracks.length === 1) return made.length ? "sound made AAC" : "sound copied as it was";
|
|
167
|
+
if (!made.length) return `${tracks.length} sound tracks, copied as they were`;
|
|
168
|
+
if (made.length === tracks.length) return `${tracks.length} sound tracks, made AAC`;
|
|
169
|
+
return `sound copied as it was, ${made.length === 1 ? "track" : "tracks"} ${list(made.map((t) => `${t.n} (${t.from})`))} made AAC`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The places as the subject of a sentence, with its verb.
|
|
173
|
+
const placesLeave = (places) => (places.length === 1 ? `${places[0].name} leaves` : `${list(places.map((z) => z.name))} leave`);
|
|
174
|
+
|
|
175
|
+
const STYLE_WORDS = {
|
|
176
|
+
karaoke: "the karaoke style (each caption whole, the spoken word lit)",
|
|
177
|
+
line: "the line style (each caption whole)",
|
|
178
|
+
word: "the word style (one word at a time)",
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
function marginsText(zone) {
|
|
182
|
+
const r = zone.reference;
|
|
183
|
+
const m = r.margins;
|
|
184
|
+
const column = r.column ? `, buttons ${r.column.width} px wide from ${r.column.from} px down` : "";
|
|
185
|
+
return `top ${m.top}, bottom ${m.bottom}, left ${m.left}, right ${m.right} px of a ${r.width}x${r.height} ${zone.screen ? "screen" : "frame"}${column}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// What fontconfig gives for a name libass asks for, as a sentence, or
|
|
189
|
+
// that libass does not say.
|
|
190
|
+
const givenFor = (m) => (m ? `fontconfig, which libass looks fonts up through on Linux, gives ${m.family}${m.style ? ` ${m.style}` : ""} for that name.` : "libass draws with what this machine gives for that name and does not say which font that is.");
|
|
191
|
+
|
|
192
|
+
// What captions.ass sets in other fonts than its own: the letters its font
|
|
193
|
+
// lacks, each in the font fontconfig gives for it, sized to the same em.
|
|
194
|
+
function fallbackLine(f) {
|
|
195
|
+
const fb = f.assFallbacks || [];
|
|
196
|
+
if (!fb.length) return "";
|
|
197
|
+
const n = fb.reduce((a, b) => a + b.letters, 0);
|
|
198
|
+
const where = `in ${list(fb.map((x) => x.family))}, which fontconfig gives for ${n === 1 ? "it" : "them"}, at the size that makes ${n === 1 ? "it a" : "them"} ${f.layout.fontPx} px ${n === 1 ? "letter" : "letters"} too.`;
|
|
199
|
+
if (setsNone(f)) return `The font has none of the ${plural(n, "letter", "letters")}: captions.ass sets ${n === 1 ? "it" : "them"} ${where}`;
|
|
200
|
+
return `captions.ass sets the ${plural(n, "letter", "letters")} the font lacks ${where}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Whether the font captions.ass names sets any letter of the captions,
|
|
204
|
+
// and whether it sets none and other fonts set them.
|
|
205
|
+
const setsAny = (f) => !f.assLetters || f.assLetters.own > 0;
|
|
206
|
+
const setsNone = (f) => !setsAny(f) && (f.assFallbacks || []).length > 0;
|
|
207
|
+
|
|
208
|
+
// The font the captions were drawn with: none named, the first of the
|
|
209
|
+
// common sans fonts this machine has, which Chrome found for Latin
|
|
210
|
+
// letters, and the fonts that drew the letters it lacks.
|
|
211
|
+
function fontLines(f) {
|
|
212
|
+
const lines = [];
|
|
213
|
+
const look = f.look;
|
|
214
|
+
const drawn = (f.fontsDrawn || []).filter((x) => x.glyphs > 0);
|
|
215
|
+
const total = drawn.reduce((a, b) => a + b.glyphs, 0);
|
|
216
|
+
const libass = f.drawnWith === "libass";
|
|
217
|
+
const m = libass ? f.libassFont || null : null;
|
|
218
|
+
const fill = (fonts) => list(fonts.map((x) => (fonts.length > 1 ? `${x.family} (${x.glyphs})` : x.family)));
|
|
219
|
+
const filled = libass && fallbackLine(f) ? [` ${fallbackLine(f)}`] : [];
|
|
220
|
+
if (!look.family) {
|
|
221
|
+
if (libass) {
|
|
222
|
+
lines.push(`- Font: Arial ${look.weight}, as captions.ass asks for it by name. ${m && sameName(m.family, "Arial") ? "This machine has it." : givenFor(m)}`, ...filled);
|
|
223
|
+
return lines;
|
|
224
|
+
}
|
|
225
|
+
// Chrome did not say which font is the first of the list: the fonts
|
|
226
|
+
// that drew, and none named as that one.
|
|
227
|
+
const first = f.fontFirst || "";
|
|
228
|
+
if (!first) {
|
|
229
|
+
lines.push(`- Font: a common sans ${look.weight}, the first of the common sans fonts easycaptionkit asks for that this machine has.`);
|
|
230
|
+
if (drawn.length) lines.push(` Chrome drew the ${total} letters with ${fill(drawn)}.`);
|
|
231
|
+
return lines;
|
|
232
|
+
}
|
|
233
|
+
lines.push(`- Font: ${first} ${look.weight}, the first of the common sans fonts easycaptionkit asks for that this machine has.`);
|
|
234
|
+
const own = drawn.find((x) => x.family === first);
|
|
235
|
+
const others = drawn.filter((x) => x.family !== first);
|
|
236
|
+
if (others.length) {
|
|
237
|
+
const rest = total - (own ? own.glyphs : 0);
|
|
238
|
+
lines.push(own ? ` Chrome drew ${own.glyphs} of ${total} letters with it, and the ${rest} it lacks with ${fill(others)}.` : ` It has none of the ${total} letters, so Chrome drew them with ${fill(others)}.`);
|
|
239
|
+
}
|
|
240
|
+
lines.push(...lighterLines(look, others));
|
|
241
|
+
return lines;
|
|
242
|
+
}
|
|
243
|
+
const files = `fonts.css (${look.faces.map((x) => x.name || x.file.split(/[\\/]/).slice(-2).join("/")).join(", ")})`;
|
|
244
|
+
if (look.from === "machine") {
|
|
245
|
+
if (libass) {
|
|
246
|
+
if (m && sameName(m.family, look.family)) lines.push(`- Font: ${look.family} ${look.weight}, as installed on this machine.`);
|
|
247
|
+
else if (m) lines.push(`- Font: ${look.family} ${look.weight} was asked for by name, and this machine does not have it: ${givenFor(m)} Give easyfontkit's folder with --fonts to burn the real font.`);
|
|
248
|
+
else lines.push(`- Font: ${look.family} ${look.weight}, asked for by name. ${givenFor(m)}`);
|
|
249
|
+
lines.push(...filled);
|
|
250
|
+
return lines;
|
|
251
|
+
}
|
|
252
|
+
if (drawn.length && !drawn.some((x) => drewWith(x, look))) {
|
|
253
|
+
if (machineHas(f)) lines.push(`- Font: ${look.family} ${look.weight}, as installed on this machine. It has none of the ${total} letters, so Chrome drew them with ${fill(drawn)}.`, ...lighterLines(look, drawn));
|
|
254
|
+
else lines.push(`- Font: ${look.family} ${look.weight} was asked for by name, and this machine does not have it, so Chrome drew the letters with ${fill(drawn)}. Give easyfontkit's folder with --fonts to burn the real font.`);
|
|
255
|
+
return lines;
|
|
256
|
+
}
|
|
257
|
+
lines.push(`- Font: ${look.family} ${look.weight}, as installed on this machine.`);
|
|
258
|
+
} else if (libass && variableFace(look) && setsAny(f)) {
|
|
259
|
+
const face = variableFace(look);
|
|
260
|
+
lines.push(`- Font: ${look.family}, from ${files}. That file holds the weights ${face.weight[0]} to ${face.weight[1]}, and libass draws such a file at its default weight, not at ${look.weight}, so the letters are thinner than Chrome draws them.`);
|
|
261
|
+
} else lines.push(`- Font: ${look.family} ${look.weight}, from ${files}.`);
|
|
262
|
+
if (f.assFamily && !sameName(f.assFamily, look.family)) lines.push(` captions.ass names it ${f.assFamily}, the family name in its file, which libass and the players that read ASS find a font by.`);
|
|
263
|
+
if (libass) lines.push(" libass drew the letters and does not say which font drew each.", ...filled);
|
|
264
|
+
if (!drawn.length) return lines;
|
|
265
|
+
// A font file of fonts.css is the only web font on the page, and Chrome
|
|
266
|
+
// names it by the family in the file, which fonts.css can call
|
|
267
|
+
// otherwise.
|
|
268
|
+
const own = drawn.filter((x) => drewWith(x, look));
|
|
269
|
+
const others = drawn.filter((x) => !drewWith(x, look));
|
|
270
|
+
if (!own.length) lines.push(` Chrome drew no letter with the file of ${look.family}: ${drawn[0].family} drew them.`);
|
|
271
|
+
else if (!others.length) lines.push(" Chrome drew every letter with it.");
|
|
272
|
+
else {
|
|
273
|
+
const mine = own.reduce((a, b) => a + b.glyphs, 0);
|
|
274
|
+
lines.push(` Chrome drew ${mine} of ${total} letters with it, and the ${total - mine} it lacks with ${fill(others)}.`);
|
|
275
|
+
}
|
|
276
|
+
lines.push(...lighterLines(look, others));
|
|
277
|
+
return lines;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// A face lighter than the weight asked for, by its PostScript name, as a
|
|
281
|
+
// system's fallback gives a regular one: Noto Sans CJK JP's
|
|
282
|
+
// NotoSansCJKjp-Regular for letters asked for at 700.
|
|
283
|
+
const LIGHTER = /-(?:Thin|ExtraLight|UltraLight|Light|DemiLight|SemiLight|Book|Regular|Normal|Roman|Medium|W[0-5])$/i;
|
|
284
|
+
|
|
285
|
+
// The lines that say a font that filled in drew from a face lighter than
|
|
286
|
+
// the weight asked for: a family of the list, which Chrome matches with
|
|
287
|
+
// that weight, has no bolder face here; a font of the system's fallback,
|
|
288
|
+
// for letters no font of the list has, can come in its regular face though
|
|
289
|
+
// its bold is there.
|
|
290
|
+
function lighterLines(look, drawn) {
|
|
291
|
+
if (look.weight < 600) return [];
|
|
292
|
+
const listed = (x) => (look.listed || []).some((name) => sameName(name, x.family));
|
|
293
|
+
return drawn
|
|
294
|
+
.filter((x) => !x.custom && LIGHTER.test(x.postScriptName || ""))
|
|
295
|
+
.map((x) => ` ${x.family} drew its ${plural(x.glyphs, "letter", "letters")} from ${x.postScriptName}, a face lighter than the ${look.weight} asked for, ${listed(x) ? "since Chrome found no bolder face of it on this machine" : "the face the system's fallback gave Chrome for letters no font of the list has"}, so they are thinner than the others.`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// The fonts that drew every letter of the captions where the font asked
|
|
299
|
+
// for, by name or from fonts.css, drew none of them, as a Latin font draws
|
|
300
|
+
// no Japanese: the fonts Chrome drew them with, or with libass the fonts
|
|
301
|
+
// captions.ass sets them in. Their names, or [] when the font drew any
|
|
302
|
+
// letter, no font was asked for, or nothing says.
|
|
303
|
+
function drewNone(f) {
|
|
304
|
+
if (f.drawnWith === "libass") return f.look.family && setsNone(f) ? f.assFallbacks.map((x) => x.family) : [];
|
|
305
|
+
const drawn = (f.fontsDrawn || []).filter((x) => x.glyphs > 0);
|
|
306
|
+
if (!f.look.family || !drawn.length || drawn.some((x) => drewWith(x, f.look))) return [];
|
|
307
|
+
return drawn.map((x) => x.family);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Whether this machine has the font asked for by name, as far as Chrome
|
|
311
|
+
// says: it is the first of the list Chrome draws plain Latin letters with.
|
|
312
|
+
const machineHas = (f) => Boolean(f.fontFirst) && sameName(f.fontFirst, f.look.family);
|
|
313
|
+
|
|
314
|
+
// Whether a font Chrome drew with is the one asked for.
|
|
315
|
+
const drewWith = (x, look) => (look.from === "fonts.css" && x.custom) || x.family.toLowerCase() === look.family.toLowerCase();
|
|
316
|
+
|
|
317
|
+
// The spoken word as it was drawn: Chrome puts a color too dark for
|
|
318
|
+
// letters on a patch, libass in a thick border.
|
|
319
|
+
function highlightLine(look, style, drawnWith) {
|
|
320
|
+
if (style === "line") return [];
|
|
321
|
+
const ink = look.highlightInk === "#ffffff" ? "white" : "black";
|
|
322
|
+
const dark = `since ${look.highlight} is too dark to read as letters on the outline`;
|
|
323
|
+
const how = look.highlightMode !== "patch" ? `in ${look.highlight}` : drawnWith === "libass" ? `in ${ink} with a thick border of ${look.highlight}, ${dark}` : `on a patch of ${look.highlight}, in ${ink}, ${dark}`;
|
|
324
|
+
const from = look.highlightFrom === "given" ? "the color given" : look.highlightFrom === "default" ? "the default" : look.highlightFrom;
|
|
325
|
+
return [`- The spoken word: ${how} (${from}).`];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const STATUS = { open: "an open license", commercial: "a commercial license", unknown: "a license easyfontkit could not tell" };
|
|
329
|
+
|
|
330
|
+
// What the license section says of fonts that came with this machine.
|
|
331
|
+
const systemFonts = (names) => `${list(names)}, which came with this machine. easycaptionkit has no license facts for ${names.length === 1 ? "it" : "them"}; a system's fonts are licensed with the system.`;
|
|
332
|
+
|
|
333
|
+
function licenseSection(f) {
|
|
334
|
+
const look = f.look;
|
|
335
|
+
const lines = ["## Font license", ""];
|
|
336
|
+
const l = look.license;
|
|
337
|
+
const status = l ? (Object.hasOwn(STATUS, l.status) ? STATUS[l.status] : l.status) : "";
|
|
338
|
+
const read = l ? `easyfontkit read ${l.family} as under ${status}${l.name ? `: ${l.name}` : ""}${l.url ? ` (${l.url})` : ""}` : "";
|
|
339
|
+
const libass = f.drawnWith === "libass";
|
|
340
|
+
// A font that drew none of the letters is in no copy of the video: the
|
|
341
|
+
// ones that drew them are, whatever its license.
|
|
342
|
+
const instead = drewNone(f);
|
|
343
|
+
if (instead.length) {
|
|
344
|
+
const drew = libass ? "captions.ass sets them all in" : "Chrome drew them all with";
|
|
345
|
+
// A font asked for by name that this machine has, or one from fonts.css, lacks the letters.
|
|
346
|
+
const lacks = look.from === "fonts.css" || (libass ? f.libassFont && sameName(f.libassFont.family, look.family) : machineHas(f));
|
|
347
|
+
const lead = read ? `${read}, but no letter of it is in the video` : `No letter of ${look.family} is in the video`;
|
|
348
|
+
lines.push(`${lead}, since ${lacks ? "it has none of these letters" : "this machine does not have it"}: ${drew} ${systemFonts(instead)}`);
|
|
349
|
+
return lines;
|
|
350
|
+
}
|
|
351
|
+
if (!l) {
|
|
352
|
+
// libass says nothing of what it drew with; fontconfig can say what
|
|
353
|
+
// it gives for the name, and for the letters the font lacks.
|
|
354
|
+
const m = libass ? f.libassFont || null : null;
|
|
355
|
+
const filled = libass ? (f.assFallbacks || []).map((x) => x.family) : [];
|
|
356
|
+
const byName = libass && look.from === "machine" && !(m && sameName(m.family, look.family));
|
|
357
|
+
if (look.from === "fonts.css") lines.push(`fonts.json was not next to fonts.css, so there are no license facts for ${look.family} here.`);
|
|
358
|
+
else if (byName && m) lines.push(`The captions ask for ${look.family} by name, and this machine does not have it: fontconfig gives ${m.family}, which came with this machine. easycaptionkit has no license facts for it; a system's fonts are licensed with the system.`);
|
|
359
|
+
else if (byName) lines.push(`The captions ask for ${look.family} by name, and libass draws whatever this machine gives for that name. easycaptionkit has no license facts for it; a system's fonts are licensed with the system, and easyfontkit reads a site's from its own font files.`);
|
|
360
|
+
else if (look.from === "machine") lines.push(`${look.family} came from this machine by its name. easycaptionkit has no license facts for it; easyfontkit reads them from the site's own font files.`);
|
|
361
|
+
// No font asked for: the one fontconfig gives for Arial, where it sets
|
|
362
|
+
// any letter, and those it gives for the letters that one lacks.
|
|
363
|
+
else if (m || filled.length) lines.push(`The captions use ${systemFonts([...new Set([...(m && setsAny(f) ? [m.family] : []), ...filled])])}`);
|
|
364
|
+
else lines.push("The captions use a font that came with this machine. easycaptionkit has no license facts for it; a system's fonts are licensed with the system.");
|
|
365
|
+
return lines;
|
|
366
|
+
}
|
|
367
|
+
lines.push(`${read}.`);
|
|
368
|
+
if (l.copyright) lines.push("", `- Copyright: ${l.copyright}`);
|
|
369
|
+
if (l.maker) lines.push(`- Maker: ${l.maker}`);
|
|
370
|
+
if (l.basis) lines.push(`- How it was read: ${l.basis}`);
|
|
371
|
+
if (l.from) lines.push(`- The file came from ${l.from}`);
|
|
372
|
+
if (l.status !== "open") {
|
|
373
|
+
lines.push("", `Captions burned into a video carry the font's letters into every copy of it, and whether that use is allowed is up to ${l.family}'s license and the license the site holds, not easycaptionkit.`);
|
|
374
|
+
if (l.free) lines.push(`easyfontkit found ${l.free.family} (${l.free.from}, ${l.free.license}) as the closest free font. To burn it instead, install it on this machine and run again with ${code(`--font ${quote(l.free.family)}`)}.`);
|
|
375
|
+
}
|
|
376
|
+
return lines;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Whether the words are a demo's steps, from steps.json or from a
|
|
380
|
+
// words.json made of them, which names steps.json as the format its words
|
|
381
|
+
// first came in or where they first came from. They were written, not
|
|
382
|
+
// heard: no speech-to-text gives them times, a word of one is changed, not
|
|
383
|
+
// misheard, and each step's words show while the step plays.
|
|
384
|
+
const fromSteps = (w) => [w.format, w.firstFormat, w.origin && w.origin.format].includes("steps");
|
|
385
|
+
|
|
386
|
+
function readingSection(f) {
|
|
387
|
+
const r = f.reading;
|
|
388
|
+
const lines = ["## Reading speed", ""];
|
|
389
|
+
lines.push(`Median ${r.median} characters a second, the fastest caption ${r.fastest}. People read about ${r.comfort} comfortably; past ${r.limit} many cannot finish a caption before it goes.`);
|
|
390
|
+
if (!r.fast.length) lines.push("", `No caption goes faster than ${r.limit} characters a second.`);
|
|
391
|
+
else {
|
|
392
|
+
lines.push("", `${plural(r.fast.length, "caption goes", "captions go")} faster than ${r.limit}:`, "");
|
|
393
|
+
for (const c of r.fast.slice(0, 8)) lines.push(`- ${c.n} at ${clock(c.start)}, ${c.cps} a second: "${c.text}"`);
|
|
394
|
+
if (r.fast.length > 8) lines.push(`- and ${r.fast.length - 8} more, listed in captions.json`);
|
|
395
|
+
// A demo has no speaker: its steps give the times. readSteps in
|
|
396
|
+
// words.js spreads a step's words until the next step with words
|
|
397
|
+
// starts, over ten seconds at most, and time in cues.js keeps the
|
|
398
|
+
// step's last caption up after them when nothing comes sooner, 0.4 to
|
|
399
|
+
// 1.2 s by its length, or on to the next caption when that starts
|
|
400
|
+
// within 0.3 s more. So a wait after a step gives its words more time
|
|
401
|
+
// only up to those ten seconds and that moment.
|
|
402
|
+
const slower = fromSteps(f.source) ? "The times follow the demo's steps: a step's words are spread over the time until the next step with words starts, ten seconds at most, and its last caption stays a moment after them when nothing comes sooner. Fewer words in a step's note make them easier to read, and so does a wait after that step, up to those ten seconds and that moment, when the demo is recorded again." : "The times follow the speaker. Fewer words or a slower take make them easier to read.";
|
|
403
|
+
lines.push("", slower);
|
|
404
|
+
}
|
|
405
|
+
return lines;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function wordsSection(f) {
|
|
409
|
+
const w = f.source;
|
|
410
|
+
const lines = ["## The words", ""];
|
|
411
|
+
// The line style lights no word: there the estimate moves only the cut
|
|
412
|
+
// of a line too long for one caption.
|
|
413
|
+
const off = f.style === "line" ? "where a line too long for one caption is cut, the cut can come a little early or late" : "a lit word can come a little early or late";
|
|
414
|
+
const steps = fromSteps(w);
|
|
415
|
+
// Words with times of their own but for a few the file gives none, as
|
|
416
|
+
// WhisperX gives a numeral none, which were placed between the words
|
|
417
|
+
// around them: they have no times for whole lines, however few have
|
|
418
|
+
// their own.
|
|
419
|
+
const given = f.wordList || [];
|
|
420
|
+
const own = given.filter((x) => x.timed).length;
|
|
421
|
+
const placed = given.filter((x) => x.placed).length;
|
|
422
|
+
const some = own > 0 && placed > 0 && own + placed === given.length;
|
|
423
|
+
const how = some ? `${own} with ${own === 1 ? "its" : "their"} own time and ${placed} with none of ${placed === 1 ? "its" : "their"} own, placed between the words around ${placed === 1 ? "it" : "them"}` : w.byWord ? "each with its own time" : steps ? "each step's words shown while the step plays, spread over that time by their length" : `with times for whole lines only, spread over their words by length, so ${off}. A speech-to-text JSON with word times fixes that`;
|
|
424
|
+
// Where the words first came from: the file the folder's first run read,
|
|
425
|
+
// unless that was a words.json of this kit too, as one taken from another
|
|
426
|
+
// folder is, which names no source; then the format its words first came
|
|
427
|
+
// in, which that words.json keeps.
|
|
428
|
+
const origin = w.origin && w.origin.format !== "easycaptionkit" ? w.origin : null;
|
|
429
|
+
const first = origin ? `, first read from ${shown(origin.file)}` : w.firstFormat ? `, first read from ${FORMATS[w.firstFormat] || shown(w.firstFormat)}` : "";
|
|
430
|
+
lines.push(`${plural(w.count, "word", "words")} from ${shown(w.file)} (${w.about}${first}), ${how}.`);
|
|
431
|
+
const words = f.out ? path.join(f.out, "words.json") : "words.json";
|
|
432
|
+
const out = f.out || ".";
|
|
433
|
+
// subtitles takes the words file as its argument, not --words, and the
|
|
434
|
+
// command is given whole, with the video and the options of the run. It
|
|
435
|
+
// is said in words first, so it still reads as a call to make where the
|
|
436
|
+
// kit runs as a tool and its flags are the tool's parameters. A
|
|
437
|
+
// captioning run gives the flags to add to its command. Both are code,
|
|
438
|
+
// so the sentence's full stop is no part of what is copied.
|
|
439
|
+
const video = f.rerun && f.rerun.video ? "--video and " : "";
|
|
440
|
+
const again = f.subtitlesOnly ? `run subtitles again with ${arg(words)} as the words file, the same ${video}options and --out ${arg(out)}: ${code(subtitlesCommand(f.rerun, words, out))}` : `run the same command again with ${code(`--words ${arg(words)} --out ${arg(out)}`)}`;
|
|
441
|
+
lines.push("", `words.json has them as read. ${steps ? "To change a word of a step" : "To fix a misheard word"}, edit its text there and ${again}.`);
|
|
442
|
+
return lines;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function placesTable(f) {
|
|
446
|
+
const lines = ["| Place | Margins | From | Read |", "| --- | --- | --- | --- |"];
|
|
447
|
+
for (const z of f.places) lines.push(`| ${z.name} | ${marginsText(z)} | ${z.source} ([link](${z.url})) | ${z.read} |`);
|
|
448
|
+
const notes = f.places.filter((z) => z.note);
|
|
449
|
+
if (notes.length) lines.push("", ...notes.map((z) => `- ${z.name}: ${z.note}`));
|
|
450
|
+
return lines;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function captionsMarkdown(f) {
|
|
454
|
+
const v = f.output;
|
|
455
|
+
const placeNames = list(f.places.map((z) => z.name));
|
|
456
|
+
const L = f.layout;
|
|
457
|
+
const lines = [`# Captions for ${shown(f.video.file)}`, ""];
|
|
458
|
+
lines.push(`${plural(f.cues.length, "caption", "captions")} in ${STYLE_WORDS[f.style]}, burned into captioned.mp4 clear of the buttons of ${placeNames}, and the same captions in captions.srt, captions.vtt and captions.ass. Made by ${f.tool}.`);
|
|
459
|
+
lines.push("", "## Files", "", "| File | What it is |", "| --- | --- |");
|
|
460
|
+
lines.push(`| captioned.mp4 | ${v.width}x${v.height}, ${v.fps} fps, ${v.duration} s, H.264 (${v.encoder}), ${soundText(v.audio)} |`);
|
|
461
|
+
lines.push("| captions.srt | the captions as text with times, for the places that take a captions file (YouTube, LinkedIn, X, Vimeo) |");
|
|
462
|
+
lines.push("| captions.vtt | the same in WebVTT, for the web's <track> and most players |");
|
|
463
|
+
lines.push(`| captions.ass | the same with the font, the colors and the place, for players that read ASS and for ffmpeg's ass filter${fontsWhere(f)} |`);
|
|
464
|
+
lines.push(`| words.json | the ${f.source.count} words as read, with their times |`);
|
|
465
|
+
lines.push("| captions.json | everything in this file, for tools |");
|
|
466
|
+
lines.push(`| sheet.png | a frame of captioned.mp4 for ${f.sheet === f.cues.length ? "each caption" : `${f.sheet} of the captions, spread over the video`} |`);
|
|
467
|
+
lines.push("| safe-zones.png | a frame under each place's buttons, with the margins the captions keep out of |");
|
|
468
|
+
if (f.timelineCopied) lines.push("| captioned.timeline.json | launch-film's timeline of the film, so its check finds the cuts |");
|
|
469
|
+
lines.push("", "## Look", "");
|
|
470
|
+
lines.push(...fontLines(f));
|
|
471
|
+
lines.push(...highlightLine(f.look, f.style, f.drawnWith));
|
|
472
|
+
lines.push(`- Size: ${L.fontPx} px letters, ${f.style === "word" ? "one word at a time" : `up to ${plural(L.lines, "line", "lines")} of up to ${L.maxChars} characters`}, ${f.look.box ? "white on a dark box" : "white with a black outline"}.`);
|
|
473
|
+
lines.push("", "## Where the captions sit", "");
|
|
474
|
+
// The rows the captions reach as Chrome drew them, not the band of the
|
|
475
|
+
// pictures, which has room around them.
|
|
476
|
+
const reach = f.reach ? ` Drawn, they reach from y ${f.reach.top} to ${f.reach.bottom} of the ${v.height} px picture, ${f.look.box ? "the box" : "outline and shadow"} included, and the safe space runs from y ${Math.round(L.safe.top)} to ${Math.round(v.height - L.safe.bottom)}.` : "";
|
|
477
|
+
lines.push(`At the ${L.position} of the space ${f.places.length === 1 ? placesLeave(f.places) : "all the places leave"} free, centered, ${Math.round(L.side)} px in from each side.${reach} safe-zones.png shows the frame at ${clock(f.moment)}.`);
|
|
478
|
+
lines.push("", ...placesTable(f));
|
|
479
|
+
lines.push("", "The figures come from the apps' ad guides or are estimates, and apps change their screens. Look at safe-zones.png, and check one post on a phone before posting many.");
|
|
480
|
+
lines.push("", ...readingSection(f));
|
|
481
|
+
lines.push("", ...wordsSection(f));
|
|
482
|
+
lines.push("", ...licenseSection(f));
|
|
483
|
+
if (f.notes.length) lines.push("", "## Notes", "", ...f.notes.map((n) => `- ${shown(n)}`));
|
|
484
|
+
lines.push("", "## Next", "");
|
|
485
|
+
lines.push(`- Look at sheet.png: ${f.sheet === f.cues.length ? "each caption on its frame" : `${f.sheet} of the ${f.cues.length} captions on their frames, spread over the video; captions.json has the time of every caption`}. Then safe-zones.png.`);
|
|
486
|
+
lines.push(`- ${code(`npx launch-film@latest check ${f.out ? arg(path.join(f.out, "captioned.mp4")) : "captioned.mp4"}`)} measures the result: size, frame rate, length, black and still stretches, loudness and peaks.`);
|
|
487
|
+
return oneLineEach(lines);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// The lines of a report, each one line whatever the names in it: a font
|
|
491
|
+
// file's name from a fonts.css made elsewhere, a family name in a font
|
|
492
|
+
// file or one Chrome or fontconfig gives can hold a line break, which
|
|
493
|
+
// would start a heading or a list item of its own in the file agents read
|
|
494
|
+
// first, or an escape a terminal that prints it takes for a command.
|
|
495
|
+
const oneLineEach = (lines) => lines.map(shown).join("\n") + "\n";
|
|
496
|
+
|
|
497
|
+
function subtitlesMarkdown(f) {
|
|
498
|
+
const L = f.layout;
|
|
499
|
+
const lines = [`# Subtitles from ${shown(f.source.file)}`, ""];
|
|
500
|
+
const narrower = L.assMaxChars && L.assMaxChars < L.maxChars;
|
|
501
|
+
lines.push(`${plural(f.cues.length, "caption", "captions")} of up to ${plural(L.lines, "line", "lines")} and ${L.maxChars} characters a line, in ${narrower ? `captions.srt and captions.vtt, and ${plural(L.assCues, "caption", "captions")} of up to ${L.assMaxChars} characters a line in captions.ass` : "captions.srt, captions.vtt and captions.ass"}. The video was not changed. Made by ${f.tool}.`);
|
|
502
|
+
lines.push("", "## Files", "", "| File | What it is |", "| --- | --- |");
|
|
503
|
+
lines.push("| captions.srt | the captions as text with times, for the places that take a captions file (YouTube, LinkedIn, X, Vimeo) |");
|
|
504
|
+
lines.push("| captions.vtt | the same in WebVTT, for the web's <track> and most players |");
|
|
505
|
+
lines.push(`| captions.ass | the same with the font, the colors and the place${f.video ? `, laid out for ${f.video.width}x${f.video.height}` : `, laid out for ${f.frame.width}x${f.frame.height}`}, for players that read ASS and for ffmpeg's ass filter${fontsWhere(f)} |`);
|
|
506
|
+
lines.push(`| words.json | the ${f.source.count} words as read, with their times |`);
|
|
507
|
+
lines.push("| captions.json | everything in this file, for tools |");
|
|
508
|
+
lines.push("", ...readingSection(f));
|
|
509
|
+
lines.push("", ...wordsSection(f));
|
|
510
|
+
lines.push("", "## Where captions.ass puts them", "");
|
|
511
|
+
const width = narrower ? `, up to ${L.assMaxChars} characters a line, what fits across the ${Math.round(L.textWidth)} px of safe space at that size` : `, up to ${L.maxChars} characters a line`;
|
|
512
|
+
const font = assFontText(f);
|
|
513
|
+
const more = [font.more, fallbackLine(f)].filter(Boolean).join(" ");
|
|
514
|
+
lines.push(`At the ${L.position} of the space ${placesLeave(f.places)} free, ${L.fontPx} px letters in ${font.name}${width}.${more ? ` ${more}` : ""} No font goes with these files: a player draws captions.ass with the fonts of its own machine.`, "", ...placesTable(f));
|
|
515
|
+
if (f.notes.length) lines.push("", "## Notes", "", ...f.notes.map((n) => `- ${shown(n)}`));
|
|
516
|
+
return oneLineEach(lines);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// The first frame of a video that shows a change at t, from the times of
|
|
520
|
+
// its frames: the first at or after the time the change is drawn at, t to
|
|
521
|
+
// the millisecond (Chrome's states) or to the hundredth (ASS). Past the
|
|
522
|
+
// last frame it is the number of frames.
|
|
523
|
+
function frameOf(times, step) {
|
|
524
|
+
return (t) => {
|
|
525
|
+
const at = Math.round(t / step) * step - 1e-7;
|
|
526
|
+
let lo = 0;
|
|
527
|
+
let hi = times.length;
|
|
528
|
+
while (lo < hi) {
|
|
529
|
+
const mid = (lo + hi) >> 1;
|
|
530
|
+
if (times[mid] >= at) hi = mid;
|
|
531
|
+
else lo = mid + 1;
|
|
532
|
+
}
|
|
533
|
+
return lo;
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function captionsJson(f) {
|
|
538
|
+
const frames = Boolean(f.frameTimes && f.frameTimes.length);
|
|
539
|
+
const frame = frames ? frameOf(f.frameTimes, f.frameStep || 0.001) : () => undefined;
|
|
540
|
+
const L = f.layout;
|
|
541
|
+
return {
|
|
542
|
+
version: 1,
|
|
543
|
+
tool: f.tool,
|
|
544
|
+
video: f.video,
|
|
545
|
+
output: f.output || null,
|
|
546
|
+
words: f.source,
|
|
547
|
+
style: f.style,
|
|
548
|
+
drawnWith: f.drawnWith || null,
|
|
549
|
+
look: {
|
|
550
|
+
font: f.look.family || null,
|
|
551
|
+
weight: f.look.weight,
|
|
552
|
+
from: f.look.from,
|
|
553
|
+
files: f.look.faces.map((x) => x.name || x.file),
|
|
554
|
+
license: f.look.license,
|
|
555
|
+
drawn: f.fontsDrawn || [],
|
|
556
|
+
text: f.look.textColor,
|
|
557
|
+
highlight: f.look.highlight,
|
|
558
|
+
highlightFrom: f.look.highlightFrom,
|
|
559
|
+
highlightMode: f.look.highlightMode,
|
|
560
|
+
box: f.look.box,
|
|
561
|
+
},
|
|
562
|
+
layout: {
|
|
563
|
+
width: L.frame.width,
|
|
564
|
+
height: L.frame.height,
|
|
565
|
+
position: L.position,
|
|
566
|
+
fontPx: L.fontPx,
|
|
567
|
+
lines: L.lines,
|
|
568
|
+
maxChars: L.maxChars,
|
|
569
|
+
side: round(L.side, 1),
|
|
570
|
+
textWidth: round(L.textWidth, 1),
|
|
571
|
+
safe: Object.fromEntries(Object.entries(L.safe).map(([k, x]) => [k, round(x, 1)])),
|
|
572
|
+
band: f.band || null,
|
|
573
|
+
reach: f.reach || null,
|
|
574
|
+
...(L.assMaxChars ? { ass: { maxChars: L.assMaxChars, cues: L.assCues } } : {}),
|
|
575
|
+
},
|
|
576
|
+
places: f.places.map((z) => ({
|
|
577
|
+
place: z.key,
|
|
578
|
+
name: z.name,
|
|
579
|
+
margins: Object.fromEntries(Object.entries(z.margins).map(([k, x]) => [k, round(x, 1)])),
|
|
580
|
+
column: z.column ? { from: round(z.column.from, 1), width: round(z.column.width, 1) } : null,
|
|
581
|
+
reference: z.reference,
|
|
582
|
+
source: z.source,
|
|
583
|
+
...(z.note ? { note: z.note } : {}),
|
|
584
|
+
url: z.url,
|
|
585
|
+
read: z.read,
|
|
586
|
+
})),
|
|
587
|
+
cues: f.cues.map((c) => ({
|
|
588
|
+
n: c.n,
|
|
589
|
+
start: round(c.start),
|
|
590
|
+
end: round(c.end),
|
|
591
|
+
...(frames ? { startFrame: frame(c.start), endFrame: frame(c.end) } : {}),
|
|
592
|
+
text: c.text,
|
|
593
|
+
lines: c.lines,
|
|
594
|
+
cps: round(c.cps, 1),
|
|
595
|
+
words: f.wordList.slice(c.first, c.last + 1).map((w) => ({ text: w.text, start: round(w.start), end: round(w.end), ...(frames ? { frame: frame(Math.max(c.start, w.start)) } : {}) })),
|
|
596
|
+
})),
|
|
597
|
+
reading: f.reading,
|
|
598
|
+
notes: f.notes,
|
|
599
|
+
// The other files the run wrote, with their sizes.
|
|
600
|
+
files: f.files || {},
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
module.exports = { captionsMarkdown, subtitlesMarkdown, captionsJson, marginsText, drewWith, setsAny, setsNone, machineHas, arg, quote, code, clock, subtitlesCommand, programOf, shown, soundText };
|