@torrent-tv/proxy 2.72.0 → 2.72.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 +16 -0
- package/package.json +1 -1
- package/services/container/Container.js +35 -0
- package/services/container/MatroskaContainer.js +42 -0
- package/services/container/Mp4Container.js +22 -1
- package/services/container/SubtitleFileContainer.js +261 -0
- package/services/container/index.js +1 -0
- package/services/container-index/matroska-subtitles.js +6 -1
- package/services/controllers/SubtitleController.js +6 -25
- package/services/hls-session-manager.js +10584 -10243
- package/services/subtitle-convert.js +74 -80
- package/services/torrent-worker/subtitle-cues.js +18 -53
- package/services/tracks/TextSubtitleTrack.js +18 -0
- package/services/tracks/index.js +1 -0
- package/services/tracks/subtitle-markup.js +104 -0
- package/test/produced-copy-choice.test.js +361 -0
- package/test/subtitle-cue-framing.test.js +202 -0
- package/test/subtitle-language.test.js +19 -8
|
@@ -5,8 +5,17 @@
|
|
|
5
5
|
* ASS/SSA (.ass/.ssa) to WebVTT so the browser can attach them to a `<track>`
|
|
6
6
|
* without any client-side conversion. The proxy owns subtitle conversion so it
|
|
7
7
|
* can also run language detection where the full text is available.
|
|
8
|
+
*
|
|
9
|
+
* Reading a format's own framing is NOT here — it is `SubtitleFileContainer`
|
|
10
|
+
* for a file beside the film, and `MatroskaContainer` / `Mp4Container` for a
|
|
11
|
+
* track inside it. What is here is everything after that: a cue's missing end
|
|
12
|
+
* time, its codec's markup, and writing the WebVTT document. One writer, so a
|
|
13
|
+
* pushed cue and a pulled one cannot read differently.
|
|
8
14
|
*/
|
|
9
15
|
|
|
16
|
+
import { SubtitleFileContainer } from "./container/SubtitleFileContainer.js";
|
|
17
|
+
import { plainCueText } from "./tracks/subtitle-markup.js";
|
|
18
|
+
|
|
10
19
|
/**
|
|
11
20
|
* Decode subtitle bytes to text. Prefers UTF-8 (honouring a BOM); if the UTF-8
|
|
12
21
|
* decode yields many replacement characters the bytes are re-decoded as
|
|
@@ -41,110 +50,95 @@ function stripBom(text) {
|
|
|
41
50
|
return typeof text === "string" && text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
42
51
|
}
|
|
43
52
|
|
|
44
|
-
function srtTsToVtt(ts) {
|
|
45
|
-
return ts.replace(",", ".");
|
|
46
|
-
}
|
|
47
|
-
|
|
48
53
|
/**
|
|
49
|
-
*
|
|
54
|
+
* One cue's start or end as WebVTT writes it: `hh:mm:ss.mmm`.
|
|
50
55
|
*
|
|
51
|
-
* @param {
|
|
56
|
+
* @param {number} seconds
|
|
52
57
|
* @returns {string}
|
|
53
58
|
*/
|
|
54
|
-
function
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
return out.join("\n");
|
|
59
|
+
export function vttTime(seconds) {
|
|
60
|
+
const safe = Math.max(0, Number(seconds) || 0);
|
|
61
|
+
const hours = Math.floor(safe / 3600);
|
|
62
|
+
const minutes = Math.floor((safe % 3600) / 60);
|
|
63
|
+
const rest = safe % 60;
|
|
64
|
+
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${rest.toFixed(3).padStart(6, "0")}`;
|
|
62
65
|
}
|
|
63
66
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Resolve what a cue is missing and take its codec's markup off, so what is
|
|
69
|
+
* left is what a player shows.
|
|
70
|
+
*
|
|
71
|
+
* The container's framing is NOT undone here — it is undone where the cue is
|
|
72
|
+
* read, by the container that framed it, which is the only place the framing is
|
|
73
|
+
* known. Until 2.72.1 this function tried to do both by counting commas, and
|
|
74
|
+
* on an embedded ASS track it showed every field of the dialogue row to the
|
|
75
|
+
* viewer.
|
|
76
|
+
*
|
|
77
|
+
* A cue with no duration — a Matroska SimpleBlock, which subtitles rarely use —
|
|
78
|
+
* is given the time until the next one IN THIS LIST, and the last such cue a
|
|
79
|
+
* few seconds. Not an invention about the film: it is what a player does with
|
|
80
|
+
* an open-ended cue, made explicit so every consumer agrees on it.
|
|
81
|
+
*
|
|
82
|
+
* @param {{ startSeconds: number, endSeconds?: number | null, text: string }[]} cues
|
|
83
|
+
* @param {string} codecId
|
|
84
|
+
* @returns {{ startSeconds: number, endSeconds: number, text: string }[]}
|
|
85
|
+
*/
|
|
86
|
+
export function finalizeCues(cues, codecId) {
|
|
87
|
+
const result = [];
|
|
88
|
+
(Array.isArray(cues) ? cues : []).forEach((cue, index) => {
|
|
89
|
+
const next = cues[index + 1];
|
|
90
|
+
const endSeconds = cue.endSeconds ?? (next ? next.startSeconds : cue.startSeconds + 4);
|
|
91
|
+
const text = plainCueText(cue.text, codecId);
|
|
92
|
+
if (!text) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
result.push({ startSeconds: cue.startSeconds, endSeconds, text });
|
|
96
|
+
});
|
|
97
|
+
return result;
|
|
80
98
|
}
|
|
81
99
|
|
|
82
100
|
/**
|
|
83
|
-
*
|
|
101
|
+
* A WebVTT document from a list of cues — the one writer, used by every path
|
|
102
|
+
* that produces subtitles: a file beside the film, a track inside it, a pull
|
|
103
|
+
* and a push.
|
|
84
104
|
*
|
|
85
|
-
* @param {string}
|
|
105
|
+
* @param {{ startSeconds: number, endSeconds?: number | null, text: string }[]} cues
|
|
106
|
+
* @param {string} codecId
|
|
86
107
|
* @returns {string}
|
|
87
108
|
*/
|
|
88
|
-
function
|
|
89
|
-
const lines =
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const trimmed = line.trim();
|
|
95
|
-
if (trimmed === "[Events]") {
|
|
96
|
-
inEvents = true;
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
if (trimmed.startsWith("[") && trimmed.endsWith("]") && inEvents) {
|
|
100
|
-
inEvents = false;
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
if (!inEvents) {
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
if (trimmed.startsWith("Format:")) {
|
|
107
|
-
formatCols = trimmed.slice("Format:".length).split(",").map((c) => c.trim().toLowerCase());
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
|
-
if (trimmed.startsWith("Dialogue:") && formatCols) {
|
|
111
|
-
const parts = trimmed.slice("Dialogue:".length).split(",");
|
|
112
|
-
const startIdx = formatCols.indexOf("start");
|
|
113
|
-
const endIdx = formatCols.indexOf("end");
|
|
114
|
-
const textIdx = formatCols.indexOf("text");
|
|
115
|
-
if (startIdx < 0 || endIdx < 0 || textIdx < 0) {
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
const cueText = stripAssTags(parts.slice(textIdx).join(","));
|
|
119
|
-
if (!cueText) {
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
cues.push(`${assTsToVtt((parts[startIdx] ?? "").trim())} --> ${assTsToVtt((parts[endIdx] ?? "").trim())}\n${cueText}`);
|
|
123
|
-
}
|
|
109
|
+
export function cuesToVtt(cues, codecId) {
|
|
110
|
+
const lines = ["WEBVTT", ""];
|
|
111
|
+
for (const cue of finalizeCues(cues, codecId)) {
|
|
112
|
+
lines.push(`${vttTime(cue.startSeconds)} --> ${vttTime(cue.endSeconds)}`);
|
|
113
|
+
lines.push(cue.text);
|
|
114
|
+
lines.push("");
|
|
124
115
|
}
|
|
125
|
-
return
|
|
116
|
+
return lines.join("\n");
|
|
126
117
|
}
|
|
127
118
|
|
|
128
119
|
/**
|
|
129
120
|
* Convert subtitle text to WebVTT by file extension. Returns null for formats
|
|
130
121
|
* that cannot be converted in-place (image-based .sup, ambiguous .sub, .ttml).
|
|
131
122
|
*
|
|
123
|
+
* The reading is `SubtitleFileContainer`'s: the file states how its own cues
|
|
124
|
+
* are framed — SubRip by position, ASS by the `Format:` line of `[Events]` —
|
|
125
|
+
* and that is a fact about the file, not about this conversion.
|
|
126
|
+
*
|
|
132
127
|
* @param {string} text
|
|
133
128
|
* @param {string} ext - Lowercase extension including the dot, e.g. ".srt".
|
|
134
129
|
* @returns {string | null}
|
|
135
130
|
*/
|
|
136
131
|
export function convertSubtitleToVtt(text, ext) {
|
|
137
132
|
const clean = stripBom(text);
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
return assToVtt(clean);
|
|
147
|
-
default:
|
|
148
|
-
return null;
|
|
133
|
+
const extension = String(ext ?? "").toLowerCase();
|
|
134
|
+
if (extension === ".vtt" || extension === ".webvtt") {
|
|
135
|
+
// Already what a browser reads. Parsing it to write it back would drop its
|
|
136
|
+
// styles, its regions and its cue identifiers for nothing.
|
|
137
|
+
return clean.trimStart().startsWith("WEBVTT") ? clean : `WEBVTT\n\n${clean}`;
|
|
138
|
+
}
|
|
139
|
+
if (!SubtitleFileContainer.detect(extension)) {
|
|
140
|
+
return null;
|
|
149
141
|
}
|
|
142
|
+
const cues = new SubtitleFileContainer({ extension }).readCues(clean);
|
|
143
|
+
return cues === null ? null : cuesToVtt(cues, extension);
|
|
150
144
|
}
|
|
@@ -18,8 +18,11 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { readSubtitlePlan, harvestCluster } from "../container-index/matroska-subtitles.js";
|
|
21
|
-
import {
|
|
21
|
+
import { readMp4SubtitlePlan } from "../container-index/mp4-subtitles.js";
|
|
22
22
|
import { iterateElements } from "../container-index/ebml-reader.js";
|
|
23
|
+
import { MatroskaContainer } from "../container/MatroskaContainer.js";
|
|
24
|
+
import { Mp4Container } from "../container/Mp4Container.js";
|
|
25
|
+
import { finalizeCues } from "../subtitle-convert.js";
|
|
23
26
|
import { detectLanguage } from "../language-detect.js";
|
|
24
27
|
import { logger } from "../../utils/logger.js";
|
|
25
28
|
|
|
@@ -358,7 +361,8 @@ async function walkFor(torrent, fileIndex, state, plan, track, trackNumber) {
|
|
|
358
361
|
continue;
|
|
359
362
|
}
|
|
360
363
|
harvested.add(sample.offset);
|
|
361
|
-
|
|
364
|
+
// The MP4 has framed this cue and is the one that unframes it.
|
|
365
|
+
const text = Mp4Container.cueTextOf(bytes, track.codecId);
|
|
362
366
|
if (text) {
|
|
363
367
|
cues.push({
|
|
364
368
|
startSeconds: sample.startSeconds,
|
|
@@ -424,9 +428,18 @@ async function walkFor(torrent, fileIndex, state, plan, track, trackNumber) {
|
|
|
424
428
|
state.cues.set(candidate.trackNumber, into);
|
|
425
429
|
}
|
|
426
430
|
let found = false;
|
|
427
|
-
for (const
|
|
428
|
-
|
|
429
|
-
|
|
431
|
+
for (const block of harvestCluster(bytes, candidate.trackNumber, plan.secondsPerTick)) {
|
|
432
|
+
// The block's bytes become this track's text HERE, where the container
|
|
433
|
+
// that framed them is known. A cue kept in its framed form and unframed
|
|
434
|
+
// later cannot be unframed at all: nothing downstream knows which
|
|
435
|
+
// container it came out of, and guessing from the field count is what
|
|
436
|
+
// showed the dialogue row's own fields to the viewer.
|
|
437
|
+
into.push({
|
|
438
|
+
startSeconds: block.startSeconds,
|
|
439
|
+
endSeconds: block.endSeconds,
|
|
440
|
+
text: MatroskaContainer.cueTextOf(block.payload, candidate.codecId),
|
|
441
|
+
seq: nextSeq(state, candidate.trackNumber)
|
|
442
|
+
});
|
|
430
443
|
found = true;
|
|
431
444
|
}
|
|
432
445
|
if (found) {
|
|
@@ -515,54 +528,6 @@ export async function warmSubtitleCues(torrent, fileIndex, sourceKey) {
|
|
|
515
528
|
return fresh;
|
|
516
529
|
}
|
|
517
530
|
|
|
518
|
-
/**
|
|
519
|
-
* Resolve a cue's end time and strip codec-specific formatting, turning a raw
|
|
520
|
-
* block-derived cue into text a player can show directly. The same step
|
|
521
|
-
* `routes/api/subtitles/get.js` applies when building WebVTT for a pull —
|
|
522
|
-
* factored out here so a pushed cue and a pulled one read identically.
|
|
523
|
-
*
|
|
524
|
-
* A cue with no duration — a SimpleBlock, which subtitles rarely use — is
|
|
525
|
-
* given the time until the next one IN THIS LIST, and the last such cue a few
|
|
526
|
-
* seconds. Not an invention about the film: it is what a player does with an
|
|
527
|
-
* open-ended cue, made explicit so every consumer agrees on it.
|
|
528
|
-
*
|
|
529
|
-
* @param {{ startSeconds: number, endSeconds: number | null, text: string }[]} cues
|
|
530
|
-
* @param {string} codecId
|
|
531
|
-
* @returns {{ startSeconds: number, endSeconds: number, text: string }[]}
|
|
532
|
-
*/
|
|
533
|
-
export function finalizeCues(cues, codecId) {
|
|
534
|
-
const isAss = codecId === "S_TEXT/ASS" || codecId === "S_TEXT/SSA";
|
|
535
|
-
const result = [];
|
|
536
|
-
cues.forEach((cue, index) => {
|
|
537
|
-
const next = cues[index + 1];
|
|
538
|
-
const endSeconds = cue.endSeconds ?? (next ? next.startSeconds : cue.startSeconds + 4);
|
|
539
|
-
const text = isAss ? assDialogueToText(cue.text) : cue.text.trim();
|
|
540
|
-
if (!text) {
|
|
541
|
-
return;
|
|
542
|
-
}
|
|
543
|
-
result.push({ startSeconds: cue.startSeconds, endSeconds, text });
|
|
544
|
-
});
|
|
545
|
-
return result;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
/**
|
|
549
|
-
* The visible text of an ASS dialogue row.
|
|
550
|
-
*
|
|
551
|
-
* A block carries the fields after `Dialogue:` without their header — nine of
|
|
552
|
-
* them, then the text, which itself holds override groups in braces.
|
|
553
|
-
*
|
|
554
|
-
* @param {string} raw
|
|
555
|
-
* @returns {string}
|
|
556
|
-
*/
|
|
557
|
-
function assDialogueToText(raw) {
|
|
558
|
-
const fields = raw.split(",");
|
|
559
|
-
const text = fields.length > 9 ? fields.slice(9).join(",") : raw;
|
|
560
|
-
return text
|
|
561
|
-
.replace(/\{[^}]*\}/g, "")
|
|
562
|
-
.replace(/\\N/gi, "\n")
|
|
563
|
-
.trim();
|
|
564
|
-
}
|
|
565
|
-
|
|
566
531
|
/**
|
|
567
532
|
* The text subtitle tracks of a file, for the menu the viewer sees.
|
|
568
533
|
*
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { SubtitleTrack } from "./SubtitleTrack.js";
|
|
10
|
+
import { markupKindOf, plainCueText } from "./subtitle-markup.js";
|
|
10
11
|
|
|
11
12
|
const TEXT_CODECS_MATROSKA = new Set(["S_TEXT/UTF8", "S_TEXT/ASS", "S_TEXT/SSA", "S_TEXT/WEBVTT"]);
|
|
12
13
|
const TEXT_FORMATS_MP4 = new Set(["tx3g", "text", "wvtt"]);
|
|
@@ -21,6 +22,23 @@ export class TextSubtitleTrack extends SubtitleTrack {
|
|
|
21
22
|
return true;
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
/** Which markup this track's cue text carries — see `subtitle-markup.js`. */
|
|
26
|
+
get markupKind() {
|
|
27
|
+
return markupKindOf(this.textCodec);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The visible text of one of this track's cues.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} textField - The cue's text field, already out of its
|
|
34
|
+
* container's framing. This method knows the codec and not the container,
|
|
35
|
+
* which is why it cannot be handed a whole dialogue row.
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
plainText(textField) {
|
|
39
|
+
return plainCueText(textField, this.textCodec);
|
|
40
|
+
}
|
|
41
|
+
|
|
24
42
|
static isTextCodec(codecId) {
|
|
25
43
|
return TEXT_CODECS_MATROSKA.has(codecId) || TEXT_FORMATS_MP4.has(codecId);
|
|
26
44
|
}
|
package/services/tracks/index.js
CHANGED
|
@@ -4,6 +4,7 @@ export { AudioTrack } from "./AudioTrack.js";
|
|
|
4
4
|
export { SubtitleTrack } from "./SubtitleTrack.js";
|
|
5
5
|
export { TextSubtitleTrack, TEXT_CODECS_MATROSKA, TEXT_FORMATS_MP4 } from "./TextSubtitleTrack.js";
|
|
6
6
|
export { ImageSubtitleTrack } from "./ImageSubtitleTrack.js";
|
|
7
|
+
export { MarkupKind, markupKindOf, plainCueText } from "./subtitle-markup.js";
|
|
7
8
|
// There is deliberately no class for a track that lives in a file of its own.
|
|
8
9
|
// `<name>.mka` is a Matroska container holding an `AudioTrack`, and
|
|
9
10
|
// `MatroskaContainer` reads it exactly as it reads the picture's — so "external"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The markup a subtitle codec puts INSIDE the text of one cue, and how to
|
|
3
|
+
* take it off. One axis of variation, and only one.
|
|
4
|
+
*
|
|
5
|
+
* Nothing here knows which container the text came out of. That is the other
|
|
6
|
+
* axis and it belongs to `container/`: how a cue's bytes are framed is stated by
|
|
7
|
+
* the CONTAINER's specification, not by the subtitle format's. Matroska
|
|
8
|
+
* reorders an ASS dialogue row and drops its two timing fields
|
|
9
|
+
* (`matroska.org/technical/subtitles.html`); a `.ass` file states its own field
|
|
10
|
+
* order in the `Format:` line of `[Events]`; an MP4 carries each cue as a sample
|
|
11
|
+
* with a length prefix. Every one of those is a fact about the container.
|
|
12
|
+
*
|
|
13
|
+
* What IS a fact about ASS, wherever it is stored: override groups in braces,
|
|
14
|
+
* `\N` and `\n` for a line break, `\h` for a hard space. That is this file.
|
|
15
|
+
*
|
|
16
|
+
* The two were mixed in one function until 2.72.1, which is what broke English
|
|
17
|
+
* subtitles on an embedded ASS track: the function counted commas to guess
|
|
18
|
+
* which framing it had been handed, expected ten fields — the shape of a row in
|
|
19
|
+
* a FILE — and a Matroska block carries nine. Every field of the row was then
|
|
20
|
+
* shown to the viewer as if it were dialogue. A function that has to guess the
|
|
21
|
+
* shape of its input is being called by someone who knew and did not say.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** How a cue's text is marked up, once the container's framing is off. */
|
|
25
|
+
export const MarkupKind = {
|
|
26
|
+
/** Sub Station Alpha and its advanced form: `{\pos(…)}`, `\N`, `\h`. */
|
|
27
|
+
ASS: "ass",
|
|
28
|
+
/** Nothing to strip: the text is what is shown. */
|
|
29
|
+
NONE: "none"
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Which markup a codec's cue text carries.
|
|
34
|
+
*
|
|
35
|
+
* The keys are every name this proxy has for a text subtitle codec: Matroska
|
|
36
|
+
* CodecIDs (RFC 9559 §5.1.4.1.28 and the codec mappings beside it), MP4 sample
|
|
37
|
+
* entry types (ISO/IEC 14496-12 §12.6, plus Apple's `tx3g`), and the file
|
|
38
|
+
* extensions a subtitle shipped beside the film uses. They are listed together
|
|
39
|
+
* because the ANSWER is the same for all of them — ASS is ASS whether it sits
|
|
40
|
+
* in a Matroska block, in a file, or nowhere yet — and keeping three tables
|
|
41
|
+
* would mean three places to forget.
|
|
42
|
+
*
|
|
43
|
+
* @type {Map<string, string>}
|
|
44
|
+
*/
|
|
45
|
+
const MARKUP_BY_CODEC = new Map([
|
|
46
|
+
["S_TEXT/ASS", MarkupKind.ASS],
|
|
47
|
+
["S_TEXT/SSA", MarkupKind.ASS],
|
|
48
|
+
[".ass", MarkupKind.ASS],
|
|
49
|
+
[".ssa", MarkupKind.ASS],
|
|
50
|
+
["S_TEXT/UTF8", MarkupKind.NONE],
|
|
51
|
+
["S_TEXT/WEBVTT", MarkupKind.NONE],
|
|
52
|
+
[".srt", MarkupKind.NONE],
|
|
53
|
+
[".vtt", MarkupKind.NONE],
|
|
54
|
+
[".webvtt", MarkupKind.NONE],
|
|
55
|
+
["tx3g", MarkupKind.NONE],
|
|
56
|
+
["text", MarkupKind.NONE],
|
|
57
|
+
["wvtt", MarkupKind.NONE]
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The markup kind of a codec, by any of its names.
|
|
62
|
+
*
|
|
63
|
+
* An unknown codec is answered `NONE` rather than refused: the text is then
|
|
64
|
+
* shown as it is, which is wrong only in so far as some markup stays visible,
|
|
65
|
+
* where a refusal would show nothing at all.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} codecId - Matroska CodecID, MP4 sample entry type, or a file
|
|
68
|
+
* extension including the dot. Case is ignored for extensions, which arrive
|
|
69
|
+
* from file names, and kept for the others, which are spelled by a spec.
|
|
70
|
+
* @returns {string} One of {@link MarkupKind}.
|
|
71
|
+
*/
|
|
72
|
+
export function markupKindOf(codecId) {
|
|
73
|
+
const name = String(codecId ?? "");
|
|
74
|
+
return MARKUP_BY_CODEC.get(name) ?? MARKUP_BY_CODEC.get(name.toLowerCase()) ?? MarkupKind.NONE;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The visible text of one cue: its markup taken off, nothing else touched.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} text - The cue's text FIELD, already out of the container's
|
|
81
|
+
* framing. Handing a whole dialogue row to this is the mistake described at
|
|
82
|
+
* the top of this file.
|
|
83
|
+
* @param {string} codecId
|
|
84
|
+
* @returns {string} Possibly empty — a cue whose text is only a drawing command
|
|
85
|
+
* or a positioning group has nothing to show, and the caller drops it.
|
|
86
|
+
*/
|
|
87
|
+
export function plainCueText(text, codecId) {
|
|
88
|
+
const raw = String(text ?? "");
|
|
89
|
+
if (markupKindOf(codecId) !== MarkupKind.ASS) {
|
|
90
|
+
return raw.trim();
|
|
91
|
+
}
|
|
92
|
+
return raw
|
|
93
|
+
// An override group. Any brace content is a directive, never dialogue:
|
|
94
|
+
// drawing commands, karaoke timing, positioning, font changes.
|
|
95
|
+
.replace(/\{[^}]*\}/g, "")
|
|
96
|
+
// Both breaks reach a player as a break. ASS distinguishes them — `\N` is
|
|
97
|
+
// always a break, `\n` only where the style does not wrap — and a WebVTT
|
|
98
|
+
// cue has no way to express the difference, so it takes the break.
|
|
99
|
+
.replace(/\\N/g, "\n")
|
|
100
|
+
.replace(/\\n/g, "\n")
|
|
101
|
+
// A space the renderer may not collapse.
|
|
102
|
+
.replace(/\\h/g, " ")
|
|
103
|
+
.trim();
|
|
104
|
+
}
|