@torrent-tv/proxy 2.73.1 → 2.74.0
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 +1447 -1437
- package/CLAUDE.md +165 -160
- package/docs/container-architecture.md +192 -184
- package/package.json +1 -1
- package/routes/api/subtitles/get.js +205 -205
- package/services/container/Container.js +354 -135
- package/services/container/MatroskaContainer.js +1155 -516
- package/services/container/Mp4Container.js +858 -392
- package/services/container/SubtitleFileContainer.js +323 -261
- package/services/controllers/SubtitleController.js +128 -127
- package/services/delivery-probe.js +64 -6
- package/services/hls-session-manager.js +32 -35
- package/services/language-detect.js +174 -228
- package/services/playback-planner.js +747 -747
- package/services/produced-index.js +300 -0
- package/services/torrent-worker/subtitle-cues.js +582 -633
- package/services/tracks/TextSubtitleTrack.js +287 -47
- package/services/tracks/index.js +14 -14
- package/test/delivery-probe.test.js +67 -0
- package/test/matroska-blocks.test.js +0 -0
- package/test/mp4-subtitles.test.js +173 -127
- package/test/produced-index.test.js +188 -0
- package/test/subtitle-cue-framing.test.js +200 -202
- package/test/subtitle-cue-walk.test.js +369 -0
- package/test/subtitle-defaults.test.js +97 -97
- package/test/subtitle-language.test.js +252 -252
- package/test/subtitle-track-numbering.test.js +370 -370
- package/services/container-index/matroska-blocks.js +0 -202
- package/services/container-index/matroska-subtitles.js +0 -372
- package/services/container-index/mp4-subtitles.js +0 -404
- package/services/subtitle-convert.js +0 -144
- package/services/subtitle-defaults.js +0 -157
- package/services/tracks/subtitle-markup.js +0 -104
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Subtitle blocks out of Matroska clusters, from bytes already in hand.
|
|
3
|
-
*
|
|
4
|
-
* A subtitle track is sparse — a few kilobytes spread across a whole film — and
|
|
5
|
-
* ffmpeg cannot extract a time range of one without walking the container to
|
|
6
|
-
* the end. Measured 2026-08-19 on `Minions.and.Monsters.1080p.mkv`: asking for
|
|
7
|
-
* four seconds at minute twenty read the file through and pulled the download
|
|
8
|
-
* from 2.7 % to 81 %; asking with `-copyts -ss -to` took 154 s on a copy that
|
|
9
|
-
* was already 81 % local and still emitted the whole track. So the subtitles
|
|
10
|
-
* are not asked of ffmpeg.
|
|
11
|
-
*
|
|
12
|
-
* They do not have to be. A subtitle block sits in the same cluster as the
|
|
13
|
-
* picture around it, and those clusters are being downloaded anyway for the
|
|
14
|
-
* viewer to watch. Reading them as they arrive costs no network at all, and it
|
|
15
|
-
* puts the cues ahead of the playhead by construction — which is the whole
|
|
16
|
-
* requirement: subtitles arrive like the picture does, or they are not offered.
|
|
17
|
-
*
|
|
18
|
-
* This module is the byte-level half: given a cluster's bytes, it returns the
|
|
19
|
-
* blocks of one track with their times. It reads element headers and skips
|
|
20
|
-
* payloads by their declared length; nothing is decoded.
|
|
21
|
-
*
|
|
22
|
-
* Structure, from RFC 9559 §5.1.3 and §5.1.4:
|
|
23
|
-
*
|
|
24
|
-
* Cluster (0x1F43B675)
|
|
25
|
-
* Timestamp (0xE7) — the cluster's own time, in ticks
|
|
26
|
-
* SimpleBlock (0xA3) — a block with no duration of its own
|
|
27
|
-
* BlockGroup (0xA0)
|
|
28
|
-
* Block (0xA1) — the same header, and where subtitles live
|
|
29
|
-
* BlockDuration (0x9B) — how long the cue stays on screen
|
|
30
|
-
*
|
|
31
|
-
* A block's header is: the track number as a variable-length integer, a signed
|
|
32
|
-
* 16-bit timestamp relative to the cluster, and one byte of flags. Subtitles
|
|
33
|
-
* are normally in a BlockGroup, because a cue without a duration has no end.
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
|
-
import { iterateElements, readUint, readVint } from "./ebml-reader.js";
|
|
37
|
-
|
|
38
|
-
const ID_TIMESTAMP = 0xe7;
|
|
39
|
-
const ID_SIMPLE_BLOCK = 0xa3;
|
|
40
|
-
const ID_BLOCK_GROUP = 0xa0;
|
|
41
|
-
const ID_BLOCK = 0xa1;
|
|
42
|
-
const ID_BLOCK_DURATION = 0x9b;
|
|
43
|
-
|
|
44
|
-
/** Bits 1-2 of the flags byte say how a block is laced, or that it is not. */
|
|
45
|
-
const LACING_MASK = 0x06;
|
|
46
|
-
const LACING_NONE = 0x00;
|
|
47
|
-
const LACING_XIPH = 0x02;
|
|
48
|
-
const LACING_FIXED = 0x04;
|
|
49
|
-
const LACING_EBML = 0x06;
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* @typedef {object} SubtitleBlock
|
|
53
|
-
* @property {number} startSeconds - When the cue appears.
|
|
54
|
-
* @property {number | null} durationSeconds - How long it stays, or null when
|
|
55
|
-
* the block carried no duration (a SimpleBlock; the caller decides).
|
|
56
|
-
* @property {Buffer} payload - The block's own bytes, still in the codec's form.
|
|
57
|
-
*/
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Read one block's header.
|
|
61
|
-
*
|
|
62
|
-
* @param {Buffer} buffer
|
|
63
|
-
* @param {number} start - First byte of the block's payload.
|
|
64
|
-
* @param {number} end - One past its last byte.
|
|
65
|
-
* @returns {{ trackNumber: number, relativeTicks: number, flags: number, dataOffset: number } | null}
|
|
66
|
-
*/
|
|
67
|
-
function readBlockHeader(buffer, start, end) {
|
|
68
|
-
const track = readVint(buffer, start, false);
|
|
69
|
-
if (!track || track.value === null) {
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
const timestampAt = start + track.length;
|
|
73
|
-
// Signed, and it can be negative: a block may belong slightly before the
|
|
74
|
-
// cluster it is stored in.
|
|
75
|
-
if (timestampAt + 3 > end) {
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
return {
|
|
79
|
-
trackNumber: Number(track.value),
|
|
80
|
-
relativeTicks: buffer.readInt16BE(timestampAt),
|
|
81
|
-
flags: buffer[timestampAt + 2],
|
|
82
|
-
dataOffset: timestampAt + 3
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Where a laced block's first frame begins.
|
|
88
|
-
*
|
|
89
|
-
* Subtitles are rarely laced, but a block that IS laced starts with a frame
|
|
90
|
-
* count and a table of sizes, and reading the payload without stepping over
|
|
91
|
-
* them yields the table as though it were text.
|
|
92
|
-
*
|
|
93
|
-
* @param {Buffer} buffer
|
|
94
|
-
* @param {number} dataOffset - First byte after the block header.
|
|
95
|
-
* @param {number} end
|
|
96
|
-
* @param {number} flags
|
|
97
|
-
* @returns {number | null} The offset of the first frame, or null when the
|
|
98
|
-
* lacing cannot be read.
|
|
99
|
-
*/
|
|
100
|
-
function firstFrameOffset(buffer, dataOffset, end, flags) {
|
|
101
|
-
const lacing = flags & LACING_MASK;
|
|
102
|
-
if (lacing === LACING_NONE) {
|
|
103
|
-
return dataOffset;
|
|
104
|
-
}
|
|
105
|
-
if (dataOffset >= end) {
|
|
106
|
-
return null;
|
|
107
|
-
}
|
|
108
|
-
const frames = buffer[dataOffset] + 1;
|
|
109
|
-
let at = dataOffset + 1;
|
|
110
|
-
if (lacing === LACING_FIXED) {
|
|
111
|
-
return at;
|
|
112
|
-
}
|
|
113
|
-
if (lacing === LACING_XIPH) {
|
|
114
|
-
// Each size but the last is a run of 0xFF bytes ending in a smaller one.
|
|
115
|
-
for (let frame = 0; frame < frames - 1; frame += 1) {
|
|
116
|
-
while (at < end && buffer[at] === 0xff) {
|
|
117
|
-
at += 1;
|
|
118
|
-
}
|
|
119
|
-
at += 1;
|
|
120
|
-
}
|
|
121
|
-
return at <= end ? at : null;
|
|
122
|
-
}
|
|
123
|
-
if (lacing === LACING_EBML) {
|
|
124
|
-
// The first size is a plain variable-length integer, the rest are signed
|
|
125
|
-
// differences from it; either way each is one such integer to step over.
|
|
126
|
-
for (let frame = 0; frame < frames - 1; frame += 1) {
|
|
127
|
-
const size = readVint(buffer, at, false);
|
|
128
|
-
if (!size) {
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
|
-
at += size.length;
|
|
132
|
-
}
|
|
133
|
-
return at <= end ? at : null;
|
|
134
|
-
}
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Every block of one track inside one cluster.
|
|
140
|
-
*
|
|
141
|
-
* @param {Buffer} buffer - Bytes holding the cluster's payload.
|
|
142
|
-
* @param {{ dataOffset: number, size: number }} cluster - Where that payload is.
|
|
143
|
-
* @param {number} trackNumber - The track to keep.
|
|
144
|
-
* @param {number} secondsPerTick - From the segment's timestamp scale.
|
|
145
|
-
* @returns {SubtitleBlock[]}
|
|
146
|
-
*/
|
|
147
|
-
export function blocksOfTrack(buffer, cluster, trackNumber, secondsPerTick) {
|
|
148
|
-
const end = Math.min(buffer.length, cluster.dataOffset + cluster.size);
|
|
149
|
-
/** @type {SubtitleBlock[]} */
|
|
150
|
-
const blocks = [];
|
|
151
|
-
let clusterTicks = null;
|
|
152
|
-
|
|
153
|
-
const take = (blockStart, blockEnd, durationTicks) => {
|
|
154
|
-
const header = readBlockHeader(buffer, blockStart, blockEnd);
|
|
155
|
-
if (!header || header.trackNumber !== trackNumber || clusterTicks === null) {
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
const payloadAt = firstFrameOffset(buffer, header.dataOffset, blockEnd, header.flags);
|
|
159
|
-
if (payloadAt === null || payloadAt >= blockEnd) {
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
blocks.push({
|
|
163
|
-
startSeconds: (clusterTicks + header.relativeTicks) * secondsPerTick,
|
|
164
|
-
durationSeconds: durationTicks === null ? null : durationTicks * secondsPerTick,
|
|
165
|
-
payload: buffer.subarray(payloadAt, blockEnd)
|
|
166
|
-
});
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
for (const element of iterateElements(buffer, cluster.dataOffset, end)) {
|
|
170
|
-
const elementEnd = Math.min(end, element.dataOffset + element.size);
|
|
171
|
-
if (element.id === ID_TIMESTAMP) {
|
|
172
|
-
clusterTicks = readUint(buffer, element.dataOffset, element.size);
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
if (element.id === ID_SIMPLE_BLOCK) {
|
|
176
|
-
take(element.dataOffset, elementEnd, null);
|
|
177
|
-
continue;
|
|
178
|
-
}
|
|
179
|
-
if (element.id !== ID_BLOCK_GROUP) {
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
// A group holds the block and, for a subtitle, the duration that says when
|
|
183
|
-
// the cue leaves the screen. Both are read before either is used, because
|
|
184
|
-
// the duration may be written after the block.
|
|
185
|
-
let blockStart = null;
|
|
186
|
-
let blockEnd = null;
|
|
187
|
-
let durationTicks = null;
|
|
188
|
-
for (const field of iterateElements(buffer, element.dataOffset, elementEnd)) {
|
|
189
|
-
const fieldEnd = Math.min(elementEnd, field.dataOffset + field.size);
|
|
190
|
-
if (field.id === ID_BLOCK) {
|
|
191
|
-
blockStart = field.dataOffset;
|
|
192
|
-
blockEnd = fieldEnd;
|
|
193
|
-
} else if (field.id === ID_BLOCK_DURATION) {
|
|
194
|
-
durationTicks = readUint(buffer, field.dataOffset, field.size);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
if (blockStart !== null) {
|
|
198
|
-
take(blockStart, blockEnd, durationTicks);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
return blocks;
|
|
202
|
-
}
|
|
@@ -1,372 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file The subtitle tracks of a Matroska file, and where their cues live.
|
|
3
|
-
*
|
|
4
|
-
* Two halves, both reading only what is asked for by byte range:
|
|
5
|
-
*
|
|
6
|
-
* - {@link readSubtitlePlan} — once per file: which tracks are text
|
|
7
|
-
* subtitles, in what codec, and the cluster positions their cue points
|
|
8
|
-
* name. Two short reads, the head and the Cues element, exactly as the
|
|
9
|
-
* keyframe reader already does.
|
|
10
|
-
* - {@link harvestCluster} — per cluster, over bytes already downloaded:
|
|
11
|
-
* the cues inside it, ready to be shown.
|
|
12
|
-
*
|
|
13
|
-
* Kept apart from `matroska.js` because that file answers one question (where
|
|
14
|
-
* are the keyframes) and is read on the path that starts playback; this one is
|
|
15
|
-
* only ever consulted for a viewer who asked for subtitles.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { findElement, iterateElements, readUint } from "./ebml-reader.js";
|
|
19
|
-
import { blocksOfTrack } from "./matroska-blocks.js";
|
|
20
|
-
|
|
21
|
-
const ID_SEGMENT = 0x18538067;
|
|
22
|
-
const ID_SEEK_HEAD = 0x114d9b74;
|
|
23
|
-
const ID_SEEK = 0x4dbb;
|
|
24
|
-
const ID_SEEK_ID = 0x53ab;
|
|
25
|
-
const ID_SEEK_POSITION = 0x53ac;
|
|
26
|
-
const ID_INFO = 0x1549a966;
|
|
27
|
-
const ID_TIMESTAMP_SCALE = 0x2ad7b1;
|
|
28
|
-
const ID_TRACKS = 0x1654ae6b;
|
|
29
|
-
const ID_TRACK_ENTRY = 0xae;
|
|
30
|
-
const ID_TRACK_NUMBER = 0xd7;
|
|
31
|
-
const ID_TRACK_TYPE = 0x83;
|
|
32
|
-
const ID_CODEC_ID = 0x86;
|
|
33
|
-
const ID_CODEC_PRIVATE = 0x63a2;
|
|
34
|
-
const ID_LANGUAGE = 0x22b59c;
|
|
35
|
-
const ID_NAME = 0x536e;
|
|
36
|
-
const ID_FLAG_DEFAULT = 0x88;
|
|
37
|
-
/**
|
|
38
|
-
* The rest of what a TrackEntry says about itself, RFC 9559 §5.1.4.1. Read
|
|
39
|
-
* because the file states them and a releaser's own wording in `Name` is the
|
|
40
|
-
* only thing we had before: "fors" and "SDH" in a menu were whatever text
|
|
41
|
-
* someone happened to type.
|
|
42
|
-
*
|
|
43
|
-
* `FlagEnabled` defaults to 1 and means "the track is usable"; a track that
|
|
44
|
-
* says 0 is counted but not offered. `FlagForced` applies only to subtitles and
|
|
45
|
-
* defaults to 0. `FlagHearingImpaired` is set "if and only if the track is
|
|
46
|
-
* suitable for users with hearing impairments". `FlagVisualImpaired`,
|
|
47
|
-
* `FlagOriginal` and `FlagCommentary` bear on the AUDIO choice and are read
|
|
48
|
-
* with that work, not here — see roadmap item 55.
|
|
49
|
-
*/
|
|
50
|
-
const ID_FLAG_ENABLED = 0xb9;
|
|
51
|
-
const ID_FLAG_FORCED = 0x55aa;
|
|
52
|
-
const ID_FLAG_HEARING_IMPAIRED = 0x55ab;
|
|
53
|
-
/**
|
|
54
|
-
* The language as RFC 5646 writes it. The specification is a MUST: "If this
|
|
55
|
-
* element is used, then any Language elements used in the same TrackEntry MUST
|
|
56
|
-
* be ignored" — so where both are present, this one is the answer and the
|
|
57
|
-
* three-letter code is not.
|
|
58
|
-
*/
|
|
59
|
-
const ID_LANGUAGE_BCP47 = 0x22b59d;
|
|
60
|
-
const ID_CUES = 0x1c53bb6b;
|
|
61
|
-
const ID_CUE_POINT = 0xbb;
|
|
62
|
-
const ID_CUE_TRACK_POSITIONS = 0xb7;
|
|
63
|
-
const ID_CUE_TRACK = 0xf7;
|
|
64
|
-
const ID_CUE_CLUSTER_POSITION = 0xf1;
|
|
65
|
-
|
|
66
|
-
/** TrackType 17 is subtitles; 1 is video and 2 audio. */
|
|
67
|
-
const TRACK_TYPE_SUBTITLE = 17;
|
|
68
|
-
/** How much of the file start to read: the same window the keyframe reader uses. */
|
|
69
|
-
const HEAD_BYTES = 64 * 1024;
|
|
70
|
-
/** Cap on the Cues read; a long film indexes to tens of KB. */
|
|
71
|
-
const MAX_CUES_BYTES = 8 * 1024 * 1024;
|
|
72
|
-
const DEFAULT_TIMESTAMP_SCALE = 1_000_000;
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* The codecs whose blocks are text this proxy can turn into WebVTT.
|
|
76
|
-
*
|
|
77
|
-
* `S_TEXT/UTF8` is a plain line of text and needs nothing. `S_TEXT/ASS` and
|
|
78
|
-
* `S_TEXT/SSA` carry a dialogue row whose fields have to be stripped, and their
|
|
79
|
-
* header lives in CodecPrivate — supported, with the stripping done where the
|
|
80
|
-
* cue is turned into WebVTT. `S_HDMV/PGS` and `S_VOBSUB` are pictures, not
|
|
81
|
-
* text, and are deliberately absent: offering them would promise something this
|
|
82
|
-
* path cannot deliver.
|
|
83
|
-
*/
|
|
84
|
-
const TEXT_CODECS = new Set(["S_TEXT/UTF8", "S_TEXT/ASS", "S_TEXT/SSA"]);
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* @typedef {object} SubtitleTrackPlan
|
|
88
|
-
* @property {number} trackNumber - As the blocks name it.
|
|
89
|
-
* @property {number} declaredIndex - Its position among ALL of the file's
|
|
90
|
-
* subtitle tracks, picture-based ones included — which is the number ffmpeg
|
|
91
|
-
* gives the same stream in `0:s:N`, and therefore the only number the browser
|
|
92
|
-
* ever names. Text tracks alone are not a numbering: a file whose PGS track
|
|
93
|
-
* comes first would have every text track one lower here than in the browser.
|
|
94
|
-
* @property {string} codecId
|
|
95
|
-
* @property {string} language - The language the file declares: its RFC 5646
|
|
96
|
-
* tag where it writes one, and the three-letter code otherwise. The
|
|
97
|
-
* specification requires that order — where `LanguageBCP47` is present, the
|
|
98
|
-
* `Language` element MUST be ignored.
|
|
99
|
-
* @property {string} languageBcp47 - The RFC 5646 tag alone, or "".
|
|
100
|
-
* @property {string} name - What the file calls the track, if anything.
|
|
101
|
-
* @property {boolean} isDefault
|
|
102
|
-
* @property {boolean} isForced - `FlagForced`: the track carries what a viewer
|
|
103
|
-
* needs even when they asked for no subtitles — signs, and dialogue in
|
|
104
|
-
* another language. It does NOT carry the film's own dialogue.
|
|
105
|
-
* @property {boolean} isHearingImpaired - `FlagHearingImpaired`: suitable for
|
|
106
|
-
* viewers who cannot hear, so it carries non-speech sound as well as speech.
|
|
107
|
-
* @property {string} codecPrivate - The ASS/SSA header, base64, or "".
|
|
108
|
-
* @property {number[]} clusterPositions - File offsets of clusters whose cue
|
|
109
|
-
* points name this track, ascending. Empty when the file indexes only its
|
|
110
|
-
* picture, and then the caller has to walk clusters as they arrive instead.
|
|
111
|
-
*/
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Read a string element, trimming the padding some muxers leave.
|
|
115
|
-
*
|
|
116
|
-
* @param {Buffer} buffer
|
|
117
|
-
* @param {{ dataOffset: number, size: number }} element
|
|
118
|
-
* @returns {string}
|
|
119
|
-
*/
|
|
120
|
-
function readString(buffer, element) {
|
|
121
|
-
return buffer
|
|
122
|
-
.toString("utf8", element.dataOffset, element.dataOffset + element.size)
|
|
123
|
-
.replace(/\0+$/, "");
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Everything about a file's text subtitle tracks that can be learned without
|
|
128
|
-
* reading the film.
|
|
129
|
-
*
|
|
130
|
-
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
131
|
-
* @param {number} fileSize
|
|
132
|
-
* @returns {Promise<{ tracks: SubtitleTrackPlan[], declared: object[], secondsPerTick: number, segmentDataOffset: number } | null>}
|
|
133
|
-
*/
|
|
134
|
-
export async function readSubtitlePlan(readRange, fileSize) {
|
|
135
|
-
const head = await readRange(0, Math.min(HEAD_BYTES, Math.max(0, fileSize - 1)));
|
|
136
|
-
if (!head || head.length < 4 || head.readUInt32BE(0) !== 0x1a45dfa3) {
|
|
137
|
-
return null;
|
|
138
|
-
}
|
|
139
|
-
const segment = findElement(head, ID_SEGMENT, []);
|
|
140
|
-
if (!segment) {
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
143
|
-
const base = segment.dataOffset;
|
|
144
|
-
|
|
145
|
-
const info = findElement(head, ID_INFO, [], base);
|
|
146
|
-
let scale = DEFAULT_TIMESTAMP_SCALE;
|
|
147
|
-
if (info) {
|
|
148
|
-
const declared = findElement(head, ID_TIMESTAMP_SCALE, [], info.dataOffset, info.dataOffset + info.size);
|
|
149
|
-
if (declared) {
|
|
150
|
-
const value = readUint(head, declared.dataOffset, declared.size);
|
|
151
|
-
if (value > 0) {
|
|
152
|
-
scale = value;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const tracksElement = findElement(head, ID_TRACKS, [], base);
|
|
158
|
-
if (!tracksElement) {
|
|
159
|
-
return null;
|
|
160
|
-
}
|
|
161
|
-
const tracksEnd = Math.min(head.length, tracksElement.dataOffset + tracksElement.size);
|
|
162
|
-
/** @type {SubtitleTrackPlan[]} */
|
|
163
|
-
const tracks = [];
|
|
164
|
-
/**
|
|
165
|
-
* Every subtitle track the file declares, in the order the Tracks element
|
|
166
|
-
* names them, text or picture. This is not for extraction — `tracks` is —
|
|
167
|
-
* but for lining ffmpeg's `0:s:N` numbering up against the container, which
|
|
168
|
-
* only holds while nothing is missing from the middle of the list.
|
|
169
|
-
*
|
|
170
|
-
* @type {Array<{ trackNumber: number, codecId: string, language: string, name: string, isDefault: boolean, declaresDefault: boolean }>}
|
|
171
|
-
*/
|
|
172
|
-
const declared = [];
|
|
173
|
-
for (const entry of iterateElements(head, tracksElement.dataOffset, tracksEnd)) {
|
|
174
|
-
if (entry.id !== ID_TRACK_ENTRY) {
|
|
175
|
-
continue;
|
|
176
|
-
}
|
|
177
|
-
const entryEnd = Math.min(tracksEnd, entry.dataOffset + entry.size);
|
|
178
|
-
let trackNumber = null;
|
|
179
|
-
let type = null;
|
|
180
|
-
let codecId = "";
|
|
181
|
-
let language = "";
|
|
182
|
-
let name = "";
|
|
183
|
-
let codecPrivate = "";
|
|
184
|
-
// Matroska's `FlagDefault` DEFAULTS TO 1, so a file whose muxer wrote it on
|
|
185
|
-
// no track is indistinguishable, once the default has been applied, from
|
|
186
|
-
// one that wrote it on every track — which is how ffmpeg's banner prints it
|
|
187
|
-
// and why the banner cannot answer this. Both are kept: what the flag
|
|
188
|
-
// amounts to, and whether the file said anything at all.
|
|
189
|
-
let isDefault = true;
|
|
190
|
-
let declaresDefault = false;
|
|
191
|
-
// Defaults straight from RFC 9559: a track is usable and not forced unless
|
|
192
|
-
// the file says otherwise, and the impaired flags are absent until claimed.
|
|
193
|
-
let isEnabled = true;
|
|
194
|
-
let isForced = false;
|
|
195
|
-
let isHearingImpaired = false;
|
|
196
|
-
let languageBcp47 = "";
|
|
197
|
-
for (const field of iterateElements(head, entry.dataOffset, entryEnd)) {
|
|
198
|
-
if (field.id === ID_TRACK_NUMBER) {
|
|
199
|
-
trackNumber = readUint(head, field.dataOffset, field.size);
|
|
200
|
-
} else if (field.id === ID_TRACK_TYPE) {
|
|
201
|
-
type = readUint(head, field.dataOffset, field.size);
|
|
202
|
-
} else if (field.id === ID_CODEC_ID) {
|
|
203
|
-
codecId = readString(head, field);
|
|
204
|
-
} else if (field.id === ID_LANGUAGE) {
|
|
205
|
-
language = readString(head, field);
|
|
206
|
-
} else if (field.id === ID_LANGUAGE_BCP47) {
|
|
207
|
-
languageBcp47 = readString(head, field);
|
|
208
|
-
} else if (field.id === ID_NAME) {
|
|
209
|
-
name = readString(head, field);
|
|
210
|
-
} else if (field.id === ID_FLAG_DEFAULT) {
|
|
211
|
-
isDefault = readUint(head, field.dataOffset, field.size) === 1;
|
|
212
|
-
declaresDefault = true;
|
|
213
|
-
} else if (field.id === ID_FLAG_ENABLED) {
|
|
214
|
-
// An element written with zero length carries its default, which for
|
|
215
|
-
// this one is 1 — so an empty element must not read as "unusable", and
|
|
216
|
-
// neither must a value outside the declared 0-1 range. Only an explicit
|
|
217
|
-
// zero takes a track away.
|
|
218
|
-
isEnabled = field.size === 0 || readUint(head, field.dataOffset, field.size) !== 0;
|
|
219
|
-
} else if (field.id === ID_FLAG_FORCED) {
|
|
220
|
-
isForced = field.size > 0 && readUint(head, field.dataOffset, field.size) !== 0;
|
|
221
|
-
} else if (field.id === ID_FLAG_HEARING_IMPAIRED) {
|
|
222
|
-
isHearingImpaired = field.size > 0 && readUint(head, field.dataOffset, field.size) !== 0;
|
|
223
|
-
} else if (field.id === ID_CODEC_PRIVATE) {
|
|
224
|
-
codecPrivate = head.toString("base64", field.dataOffset, field.dataOffset + field.size);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
if (type !== TRACK_TYPE_SUBTITLE || trackNumber === null) {
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
// A track the file marks unusable is still COUNTED. FlagEnabled says "the
|
|
231
|
-
// track is usable", and a player should not offer it — but ffmpeg does not
|
|
232
|
-
// drop it: `matroskadec.c` parses `MATROSKA_ID_TRACKFLAGENABLED` as
|
|
233
|
-
// `EBML_NONE`, reading the element and keeping nothing, so the stream is
|
|
234
|
-
// created and numbered like any other. Leaving it out of this list would
|
|
235
|
-
// therefore shift `declaredIndex` off ffmpeg's `0:s:N` for every track
|
|
236
|
-
// after it, which is the numbering defect this file was fixed for a day
|
|
237
|
-
// earlier. It is counted here and refused where it is offered instead.
|
|
238
|
-
//
|
|
239
|
-
// `language` here stays the three-letter code, because this list exists to
|
|
240
|
-
// be lined up against ffmpeg's banner, which prints that code. The RFC 5646
|
|
241
|
-
// tag rides beside it for whoever displays the track.
|
|
242
|
-
declared.push({
|
|
243
|
-
trackNumber,
|
|
244
|
-
codecId,
|
|
245
|
-
language,
|
|
246
|
-
languageBcp47,
|
|
247
|
-
name,
|
|
248
|
-
isDefault,
|
|
249
|
-
declaresDefault,
|
|
250
|
-
isEnabled,
|
|
251
|
-
isForced,
|
|
252
|
-
isHearingImpaired
|
|
253
|
-
});
|
|
254
|
-
if (!TEXT_CODECS.has(codecId) || !isEnabled) {
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
tracks.push({
|
|
258
|
-
trackNumber,
|
|
259
|
-
declaredIndex: declared.length - 1,
|
|
260
|
-
codecId,
|
|
261
|
-
// This list is ours and is not compared with ffmpeg's, so it carries the
|
|
262
|
-
// language the file states most precisely: where RFC 5646 is written, the
|
|
263
|
-
// three-letter code MUST be ignored.
|
|
264
|
-
language: languageBcp47 || language,
|
|
265
|
-
languageBcp47,
|
|
266
|
-
name,
|
|
267
|
-
isDefault,
|
|
268
|
-
isForced,
|
|
269
|
-
isHearingImpaired,
|
|
270
|
-
codecPrivate,
|
|
271
|
-
clusterPositions: []
|
|
272
|
-
});
|
|
273
|
-
}
|
|
274
|
-
if (tracks.length === 0) {
|
|
275
|
-
return { tracks, declared, secondsPerTick: scale / 1e9, segmentDataOffset: base };
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Where the clusters holding those tracks are. A file that indexes only its
|
|
279
|
-
// picture leaves these empty, which is not a failure: the caller then reads
|
|
280
|
-
// the clusters the viewer's own playback brings in.
|
|
281
|
-
const seekHead = findElement(head, ID_SEEK_HEAD, [], base);
|
|
282
|
-
let cuesRelative;
|
|
283
|
-
if (seekHead) {
|
|
284
|
-
const seekEnd = Math.min(head.length, seekHead.dataOffset + seekHead.size);
|
|
285
|
-
for (const seek of iterateElements(head, seekHead.dataOffset, seekEnd)) {
|
|
286
|
-
if (seek.id !== ID_SEEK) {
|
|
287
|
-
continue;
|
|
288
|
-
}
|
|
289
|
-
let target = null;
|
|
290
|
-
let position = null;
|
|
291
|
-
for (const field of iterateElements(head, seek.dataOffset, Math.min(seekEnd, seek.dataOffset + seek.size))) {
|
|
292
|
-
if (field.id === ID_SEEK_ID) {
|
|
293
|
-
target = readUint(head, field.dataOffset, field.size);
|
|
294
|
-
} else if (field.id === ID_SEEK_POSITION) {
|
|
295
|
-
position = readUint(head, field.dataOffset, field.size);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
if (target === ID_CUES && position !== null) {
|
|
299
|
-
cuesRelative = position;
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
if (cuesRelative !== undefined) {
|
|
304
|
-
const cuesAt = base + cuesRelative;
|
|
305
|
-
if (cuesAt > 0 && cuesAt < fileSize) {
|
|
306
|
-
const chunk = await readRange(cuesAt, Math.min(fileSize - 1, cuesAt + MAX_CUES_BYTES));
|
|
307
|
-
const element = chunk && [...iterateElements(chunk, 0, chunk.length)][0];
|
|
308
|
-
if (element && element.id === ID_CUES) {
|
|
309
|
-
const body = chunk.subarray(element.dataOffset, Math.min(chunk.length, element.dataOffset + element.size));
|
|
310
|
-
const byTrack = new Map(tracks.map((track) => [track.trackNumber, new Set()]));
|
|
311
|
-
for (const point of iterateElements(body, 0, body.length)) {
|
|
312
|
-
if (point.id !== ID_CUE_POINT) {
|
|
313
|
-
continue;
|
|
314
|
-
}
|
|
315
|
-
const pointEnd = Math.min(body.length, point.dataOffset + point.size);
|
|
316
|
-
for (const field of iterateElements(body, point.dataOffset, pointEnd)) {
|
|
317
|
-
if (field.id !== ID_CUE_TRACK_POSITIONS) {
|
|
318
|
-
continue;
|
|
319
|
-
}
|
|
320
|
-
let cueTrack = null;
|
|
321
|
-
let position = null;
|
|
322
|
-
for (const inner of iterateElements(body, field.dataOffset, Math.min(pointEnd, field.dataOffset + field.size))) {
|
|
323
|
-
if (inner.id === ID_CUE_TRACK) {
|
|
324
|
-
cueTrack = readUint(body, inner.dataOffset, inner.size);
|
|
325
|
-
} else if (inner.id === ID_CUE_CLUSTER_POSITION) {
|
|
326
|
-
position = readUint(body, inner.dataOffset, inner.size);
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
if (position !== null && byTrack.has(cueTrack)) {
|
|
330
|
-
byTrack.get(cueTrack).add(base + position);
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
for (const track of tracks) {
|
|
335
|
-
track.clusterPositions = [...byTrack.get(track.trackNumber)].sort((left, right) => left - right);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
return { tracks, declared, secondsPerTick: scale / 1e9, segmentDataOffset: base };
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* The cues of one track inside one cluster.
|
|
345
|
-
*
|
|
346
|
-
* @param {Buffer} bytes - The cluster, from its own element header onward.
|
|
347
|
-
* @param {number} trackNumber
|
|
348
|
-
* @param {number} secondsPerTick
|
|
349
|
-
* @returns {{ startSeconds: number, endSeconds: number | null, text: string }[]}
|
|
350
|
-
*/
|
|
351
|
-
export function harvestCluster(bytes, trackNumber, secondsPerTick) {
|
|
352
|
-
const header = [...iterateElements(bytes, 0, bytes.length)][0];
|
|
353
|
-
if (!header) {
|
|
354
|
-
return [];
|
|
355
|
-
}
|
|
356
|
-
const blocks = blocksOfTrack(
|
|
357
|
-
bytes,
|
|
358
|
-
{ dataOffset: header.dataOffset, size: header.size },
|
|
359
|
-
trackNumber,
|
|
360
|
-
secondsPerTick
|
|
361
|
-
);
|
|
362
|
-
// The payload is handed on as BYTES. What those bytes mean — which of them
|
|
363
|
-
// are the text and which are the eight fields Matroska puts before it — is
|
|
364
|
-
// stated by the container's specification and answered by
|
|
365
|
-
// `MatroskaContainer.cueTextOf`, not here: this function's subject is where a
|
|
366
|
-
// block sits and how long it lasts.
|
|
367
|
-
return blocks.map((block) => ({
|
|
368
|
-
startSeconds: block.startSeconds,
|
|
369
|
-
endSeconds: block.durationSeconds === null ? null : block.startSeconds + block.durationSeconds,
|
|
370
|
-
payload: block.payload
|
|
371
|
-
}));
|
|
372
|
-
}
|