@torrent-tv/proxy 2.63.0 → 2.64.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.
@@ -0,0 +1,126 @@
1
+ /**
2
+ * @file Subtitle controller — interface layer over SubtitleOrchestrator.
3
+ *
4
+ * Routes (HTTP or data-channel) call this, not the domain module directly.
5
+ * Handles external files vs embedded tracks branching, header setting, and
6
+ * cursor/covered-cluster bookkeeping. Domain work (cluster walk, conversion,
7
+ * language detection) stays in orchestrator/domain.
8
+ */
9
+
10
+ import { subtitleOrchestrator } from "../orchestrators/SubtitleOrchestrator.js";
11
+ import { convertSubtitleToVtt, decodeSubtitleBytes } from "../subtitle-convert.js";
12
+ import { detectLanguage } from "../language-detect.js";
13
+ import { finalizeCues } from "../torrent-worker/subtitle-cues.js";
14
+
15
+ const EXTERNAL_MAX_BYTES = 8 * 1024 * 1024;
16
+
17
+ function vttTime(s) {
18
+ const safe = Math.max(0, s);
19
+ const h = Math.floor(safe / 3600);
20
+ const m = Math.floor((safe % 3600) / 60);
21
+ const r = safe % 60;
22
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${r.toFixed(3).padStart(6, "0")}`;
23
+ }
24
+
25
+ function cuesToVtt(cues, codecId) {
26
+ const lines = ["WEBVTT", ""];
27
+ for (const c of finalizeCues(cues, codecId)) {
28
+ lines.push(`${vttTime(c.startSeconds)} --> ${vttTime(c.endSeconds)}`);
29
+ lines.push(c.text);
30
+ lines.push("");
31
+ }
32
+ return lines.join("\n");
33
+ }
34
+
35
+ function readFileFully(file, maxBytes) {
36
+ return new Promise((resolve, reject) => {
37
+ const stream = file.createReadStream();
38
+ const chunks = [];
39
+ let total = 0;
40
+ stream.on("data", (chunk) => {
41
+ total += chunk.length;
42
+ if (total > maxBytes) { stream.destroy(); reject(new Error("subtitle file exceeds size cap")); return; }
43
+ chunks.push(chunk);
44
+ });
45
+ stream.on("end", () => resolve(Buffer.concat(chunks)));
46
+ stream.on("error", reject);
47
+ });
48
+ }
49
+
50
+ export class SubtitleController {
51
+ constructor({ sourceRegistry, torrentPool }) {
52
+ this.sourceRegistry = sourceRegistry;
53
+ this.torrentPool = torrentPool;
54
+ this.orchestrator = subtitleOrchestrator;
55
+ }
56
+
57
+ /**
58
+ * Serve external subtitle file or embedded track.
59
+ * Returns { vtt, language, headers } or { error, status }.
60
+ */
61
+ async getSubtitle({ sourceKey, fileIndex, trackIndex, since, after }) {
62
+ const rec = this.sourceRegistry.get(sourceKey);
63
+ if (!rec) return { error: "Source key was not found.", status: 404 };
64
+ const torrent = await this.torrentPool.getTorrent(rec.sourceType, rec.source);
65
+ const file = torrent.files[fileIndex];
66
+ if (!file) return { error: "File index was not found in torrent.", status: 404 };
67
+
68
+ const hasTrack = trackIndex !== undefined && trackIndex !== "" && Number.isFinite(Number(trackIndex));
69
+ if (!hasTrack) {
70
+ const name = file.name ?? "";
71
+ const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
72
+ const release = this.torrentPool.acquireFile(torrent, fileIndex);
73
+ try {
74
+ const bytes = await readFileFully(file, EXTERNAL_MAX_BYTES);
75
+ const text = decodeSubtitleBytes(bytes);
76
+ const vtt = convertSubtitleToVtt(text, ext);
77
+ if (!vtt) return { error: `Unsupported subtitle format: ${ext}`, status: 422 };
78
+ return { vtt, language: detectLanguage(text), headers: {} };
79
+ } catch (e) {
80
+ return { error: `Could not read subtitle file: ${e?.message ?? e}`, status: 502 };
81
+ } finally {
82
+ release();
83
+ }
84
+ }
85
+
86
+ const idx = Number(trackIndex);
87
+ if (!Number.isInteger(idx) || idx < 0) return { error: "trackIndex must be a non-negative integer.", status: 400 };
88
+
89
+ // Resolve via orchestrator (domain: cluster walk or MP4 sample ranges)
90
+ const tracks = await this.orchestrator.getTracks(torrent, fileIndex, sourceKey);
91
+ const track = Array.isArray(tracks) ? tracks.find((c) => c.declaredIndex === idx) ?? null : null;
92
+ // Also try domain's declaredIndex-agnostic lookup via getCues path — keep compat with existing subtitle-cues declaredIndex
93
+ let held = null;
94
+ try {
95
+ // Need trackNumber for domain call — find via declared workspace
96
+ const domainTracks = await this.orchestrator.getDeclaredTracks(torrent, fileIndex, sourceKey);
97
+ // If not found, fall back to direct cuesHeldFor via trackNumber from tracks list
98
+ const target = track ?? domainTracks.find((t) => t.declaredIndex === idx) ?? null;
99
+ const trackNumber = target?.trackNumber ?? track?.trackNumber;
100
+ if (trackNumber != null) {
101
+ held = await this.orchestrator.getCues(torrent, fileIndex, sourceKey, trackNumber);
102
+ }
103
+ } catch {}
104
+ if (held && Array.isArray(held.cues)) {
105
+ const cursor = held.cues.reduce((h, c) => Math.max(h, Number(c.seq) || 0), 0);
106
+ const fresh = Number.isInteger(since) ? held.cues.filter((c) => (Number(c.seq) || 0) > since)
107
+ : Number.isFinite(after) ? held.cues.filter((c) => c.startSeconds > after) : held.cues;
108
+ const vtt = cuesToVtt(fresh, held.track?.codecId ?? track?.codecId ?? "");
109
+ const language = held.cues.length > 0 ? detectLanguage(held.cues.map((c) => c.text).join("\n")) : null;
110
+ return {
111
+ vtt,
112
+ language,
113
+ headers: {
114
+ "X-Subtitle-Covered-Clusters": String(held.coveredClusters ?? 0),
115
+ "X-Subtitle-Indexed-Clusters": String(held.indexedClusters ?? 0),
116
+ "X-Subtitle-Cursor": String(cursor)
117
+ }
118
+ };
119
+ }
120
+ return { pending: true, status: 202 };
121
+ }
122
+
123
+ async warm(torrent, fileIndex, sourceKey) {
124
+ return this.orchestrator.warm(torrent, fileIndex, sourceKey);
125
+ }
126
+ }
@@ -0,0 +1,2 @@
1
+ export { PlaybackController } from "./PlaybackController.js";
2
+ export { SubtitleController } from "./SubtitleController.js";
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @file Container orchestrator — application layer over Container domain.
3
+ *
4
+ * Holds a per-file cache of Container instances (key sourceKey:fileIndex) so
5
+ * Tracks and keyframe index are read once per file, not per request.
6
+ * Delegates format detection to ContainerFactory. Transport-agnostic — takes
7
+ * readRange, knows nothing about torrents or HTTP.
8
+ */
9
+
10
+ import { ContainerFactory } from "../container/ContainerFactory.js";
11
+ import { logger } from "../../utils/logger.js";
12
+
13
+ export class ContainerOrchestrator {
14
+ constructor() {
15
+ /** @type {Map<string, import("../container/Container.js").Container|null>} */
16
+ this.cache = new Map();
17
+ /** @type {Map<string, Promise<import("../container/Container.js").Container|null>>} */
18
+ this.pending = new Map();
19
+ }
20
+
21
+ /**
22
+ * @param {object} params
23
+ * @param {string} params.sourceKey
24
+ * @param {number} params.fileIndex
25
+ * @param {(start:number,end:number)=>Promise<Buffer|null>} params.readRange
26
+ * @param {number} params.fileSize
27
+ * @param {string} [params.label]
28
+ * @returns {Promise<import("../container/Container.js").Container|null>}
29
+ */
30
+ async getContainer({ sourceKey, fileIndex, readRange, fileSize, label = "" }) {
31
+ const key = `${sourceKey}:${fileIndex}`;
32
+ if (this.cache.has(key)) return this.cache.get(key);
33
+ if (this.pending.has(key)) return this.pending.get(key);
34
+ const p = ContainerFactory.create({ readRange, fileSize, label }).then((c) => {
35
+ this.cache.set(key, c);
36
+ this.pending.delete(key);
37
+ if (c) logger.info(`container: ${c.formatName} for "${label}"`);
38
+ else logger.info(`container: unknown for "${label}"`);
39
+ return c;
40
+ }).catch((e) => {
41
+ this.pending.delete(key);
42
+ logger.warn(`container: failed for "${label}": ${e?.message ?? e}`);
43
+ return null;
44
+ });
45
+ this.pending.set(key, p);
46
+ return p;
47
+ }
48
+
49
+ /**
50
+ * @param {object} params - same as getContainer
51
+ * @returns {Promise<import("../tracks/index.js").ContainerTrack[]>}
52
+ */
53
+ async getTracks(params) {
54
+ const container = await this.getContainer(params);
55
+ if (!container) return [];
56
+ try {
57
+ return await container.readTracks();
58
+ } catch (e) {
59
+ logger.warn(`container: readTracks failed for "${params.label}": ${e?.message ?? e}`);
60
+ return [];
61
+ }
62
+ }
63
+
64
+ /**
65
+ * @param {object} params - same as getContainer
66
+ * @returns {Promise<{times:number[],tolerance:number}|null>}
67
+ */
68
+ async getKeyframeIndex(params) {
69
+ const container = await this.getContainer(params);
70
+ if (!container) return null;
71
+ try {
72
+ return await container.readKeyframeIndex();
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ forget(sourceKey, fileIndex) {
79
+ if (fileIndex === undefined) {
80
+ for (const k of [...this.cache.keys()]) if (k.startsWith(`${sourceKey}:`)) this.cache.delete(k);
81
+ for (const k of [...this.pending.keys()]) if (k.startsWith(`${sourceKey}:`)) this.pending.delete(k);
82
+ return;
83
+ }
84
+ this.cache.delete(`${sourceKey}:${fileIndex}`);
85
+ this.pending.delete(`${sourceKey}:${fileIndex}`);
86
+ }
87
+ }
88
+
89
+ export const containerOrchestrator = new ContainerOrchestrator();
@@ -0,0 +1,101 @@
1
+ /**
2
+ * @file Subtitle orchestrator — application layer over subtitle domain.
3
+ *
4
+ * Wraps torrent-worker/subtitle-cues.js domain (planFor, cuesHeldFor,
5
+ * warmSubtitleCues, forgetSubtitles) behind Container/Track abstraction.
6
+ * Provides per-file track list and cue streaming, with the same "only already
7
+ * downloaded clusters" rule as before. Controllers (HTTP or data-channel)
8
+ * depend on this, not on the worker module directly.
9
+ *
10
+ * Delegates to ContainerOrchestrator for track enumeration so subtitle tracks
11
+ * and their flags come from the unified ContainerTrack hierarchy.
12
+ */
13
+
14
+ import { containerOrchestrator } from "./ContainerOrchestrator.js";
15
+ import {
16
+ cuesHeldFor as domainCuesHeldFor,
17
+ warmSubtitleCues as domainWarm,
18
+ subtitleTracksOf,
19
+ declaredSubtitleTracksOf,
20
+ forgetSubtitles as domainForget
21
+ } from "../torrent-worker/subtitle-cues.js";
22
+ import { logger } from "../../utils/logger.js";
23
+
24
+ export class SubtitleOrchestrator {
25
+ /**
26
+ * @param {import("./ContainerOrchestrator.js").ContainerOrchestrator} containerOrchestrator
27
+ */
28
+ constructor(containerOrchestrator) {
29
+ this.containers = containerOrchestrator;
30
+ }
31
+
32
+ /**
33
+ * Tracks for menu — text tracks via domain, enriched with ContainerTrack flags.
34
+ * Falls back to container tracks when domain has no plan yet.
35
+ * @param {object} torrent
36
+ * @param {number} fileIndex
37
+ * @param {string} sourceKey
38
+ * @param {(start:number,end:number)=>Promise<Buffer|null>} [readRange]
39
+ * @param {number} [fileSize]
40
+ * @returns {Promise<import("../tracks/index.js").ContainerTrack[]>}
41
+ */
42
+ async getTracks(torrent, fileIndex, sourceKey, readRange, fileSize) {
43
+ try {
44
+ const domain = await subtitleTracksOf(torrent, fileIndex, sourceKey);
45
+ if (Array.isArray(domain) && domain.length > 0) return domain;
46
+ } catch {}
47
+ if (readRange && Number.isFinite(fileSize)) {
48
+ try {
49
+ const tracks = await this.containers.getTracks({ sourceKey, fileIndex, readRange, fileSize, label: torrent?.files?.[fileIndex]?.name ?? "" });
50
+ return tracks.filter((t) => t.type === "subtitle");
51
+ } catch {}
52
+ }
53
+ return [];
54
+ }
55
+
56
+ /**
57
+ * Declared subtitle tracks in container order (including image tracks) — for declaredIndex alignment.
58
+ */
59
+ async getDeclaredTracks(torrent, fileIndex, sourceKey) {
60
+ try {
61
+ return await declaredSubtitleTracksOf(torrent, fileIndex, sourceKey);
62
+ } catch {
63
+ return [];
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Cues already downloaded for one track.
69
+ * @param {object} torrent
70
+ * @param {number} fileIndex
71
+ * @param {string} sourceKey
72
+ * @param {number} trackNumber - Container trackNumber
73
+ */
74
+ async getCues(torrent, fileIndex, sourceKey, trackNumber) {
75
+ try {
76
+ return await domainCuesHeldFor(torrent, fileIndex, sourceKey, trackNumber);
77
+ } catch (e) {
78
+ logger.warn(`subtitle-orchestrator: getCues failed: ${e?.message ?? e}`);
79
+ return { cues: [], coveredClusters: 0, indexedClusters: 0, track: null };
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Warm all subtitle tracks of a file — called periodically and on verified pieces.
85
+ * Returns per-track fresh cues for push.
86
+ */
87
+ async warm(torrent, fileIndex, sourceKey) {
88
+ try {
89
+ return await domainWarm(torrent, fileIndex, sourceKey);
90
+ } catch {
91
+ return [];
92
+ }
93
+ }
94
+
95
+ forget(sourceKey, fileIndex) {
96
+ domainForget(sourceKey, fileIndex);
97
+ this.containers.forget(sourceKey, fileIndex);
98
+ }
99
+ }
100
+
101
+ export const subtitleOrchestrator = new SubtitleOrchestrator(containerOrchestrator);
@@ -0,0 +1,2 @@
1
+ export { ContainerOrchestrator, containerOrchestrator } from "./ContainerOrchestrator.js";
2
+ export { SubtitleOrchestrator, subtitleOrchestrator } from "./SubtitleOrchestrator.js";
@@ -411,7 +411,11 @@ export class SharedPieceStore {
411
411
 
412
412
  /** How many pieces fit in memory at once. */
413
413
  get capacity() {
414
- return this.#capacity;
414
+ // What may be held NOW, not the reservation this store was created with.
415
+ // A reader sizes its window from this (`ceilingPieces` in piece-reader),
416
+ // and sizing it from an allowance the machine has since withdrawn is how a
417
+ // reader comes to want more pieces than the store can hold.
418
+ return this.#growthCeiling;
415
419
  }
416
420
 
417
421
  /** How many pieces are resident right now. */
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @file Audio track — extends ContainerTrack with audio-specific flags.
3
+ *
4
+ * Matroska RFC 9559 §5.1.4.1:
5
+ * - FlagOriginal 0x55AE (default 0) — track is original language.
6
+ * - FlagCommentary 0x55AF — commentary track.
7
+ * - FlagVisualImpaired 0x55AC — audio description for visually impaired.
8
+ * - FlagTextDescriptions 0x55AD, FlagHearingImpaired etc. may also appear
9
+ * but the three above drive the audio menu (research/container-spec-conformance).
10
+ * MP4 ISO/IEC 14496-12: tkhd alternate_group groups alternate audio tracks.
11
+ */
12
+
13
+ import { ContainerTrack } from "./ContainerTrack.js";
14
+
15
+ export class AudioTrack extends ContainerTrack {
16
+ /**
17
+ * @param {object} params - See ContainerTrack plus:
18
+ * @param {boolean} params.isOriginal - FlagOriginal
19
+ * @param {boolean} params.isCommentary - FlagCommentary
20
+ * @param {boolean} params.isVisualImpaired - FlagVisualImpaired / descriptive audio
21
+ * @param {number | null} params.channels
22
+ * @param {number | null} params.samplingFrequency
23
+ */
24
+ constructor(params) {
25
+ super({ ...params, type: "audio" });
26
+ this.isOriginal = params.isOriginal === true;
27
+ this.isCommentary = params.isCommentary === true;
28
+ this.isVisualImpaired = params.isVisualImpaired === true;
29
+ this.channels = Number.isFinite(params.channels) ? params.channels : null;
30
+ this.samplingFrequency = Number.isFinite(params.samplingFrequency) ? params.samplingFrequency : null;
31
+ }
32
+
33
+ /** Human label helper — commentary and descriptive audio must not look like main track. */
34
+ audioRoleLabel() {
35
+ if (this.isCommentary) return "commentary";
36
+ if (this.isVisualImpaired) return "descriptive";
37
+ if (this.isOriginal) return "original";
38
+ return "";
39
+ }
40
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @file Base container track — what the container declares about one track.
3
+ *
4
+ * Spec sources:
5
+ * - Matroska RFC 9559 §5.1.4.1 TrackEntry: TrackNumber(0xD7), TrackType(0x83),
6
+ * FlagEnabled(0xB9, default 1), FlagDefault(0x88, default 1),
7
+ * Language(0x22B59C, default "eng"), LanguageBCP47(0x22B59D, MUST ignore
8
+ * Language when present), Name(0x536E), CodecID(0x86), CodecPrivate(0x63A2).
9
+ * - MP4 ISO/IEC 14496-12 §8.3.2 tkhd (track_ID, flags track_enabled 0x000001,
10
+ * alternate_group), §8.4.2 mdhd (language, timescale), §8.4.5 hdlr
11
+ * (handler_type), §8.4.6 elng (extended language).
12
+ *
13
+ * Only fields that exist for EVERY track type live here. Type-specific flags
14
+ * (FlagForced, FlagHearingImpaired, FlagVisualImpaired, FlagOriginal,
15
+ * FlagCommentary) belong to subclasses — RFC 9559 states FlagForced "Applies
16
+ * only to subtitles", so VideoTrack must not carry it.
17
+ */
18
+
19
+ export class ContainerTrack {
20
+ /**
21
+ * @param {object} params
22
+ * @param {number} params.trackNumber - Matroska TrackNumber or MP4 track_ID.
23
+ * @param {number} params.declaredIndex - Position among ALL subtitle/audio/video tracks as container orders them; equals ffmpeg 0:s:N / 0:a:N for that type. Stable across image/text filtering.
24
+ * @param {string} params.type - "video" | "audio" | "subtitle" | "other"
25
+ * @param {string} params.codecId - Matroska CodecID or MP4 sample entry type (e.g. "S_TEXT/UTF8", "avc1", "mp4a").
26
+ * @param {string} params.language - Three-letter code or packed mdhd code; empty when absent. For Matroska, when LanguageBCP47 is present this is the BCP47 value ignored per MUST — caller stores both, but `language` here is the resolved one.
27
+ * @param {string} params.languageBcp47 - RFC 5646 tag from LanguageBCP47 / elng, or "".
28
+ * @param {string} params.name - Track Name / title, or "".
29
+ * @param {boolean} params.isEnabled - FlagEnabled / tkhd track_enabled. Default true per both specs. Matroska zero-length element means default, not disabled.
30
+ * @param {boolean} params.isDefault - FlagDefault / tkhd? For MP4, derived from handler default? For Matroska, after applying default 1.
31
+ * @param {boolean} params.declaresDefault - Whether FlagDefault was explicitly written (Matroska) or inferred. Needed because ffmpeg banner cannot distinguish "every track marked" from "no track marked".
32
+ * @param {string} [params.codecPrivateB64] - Base64 of CodecPrivate (ASS header etc.), or "".
33
+ * @param {number} [params.alternateGroup] - MP4 alternate_group (0 = no group).
34
+ */
35
+ constructor({
36
+ trackNumber,
37
+ declaredIndex,
38
+ type,
39
+ codecId,
40
+ language,
41
+ languageBcp47,
42
+ name,
43
+ isEnabled,
44
+ isDefault,
45
+ declaresDefault,
46
+ codecPrivateB64 = "",
47
+ alternateGroup = 0
48
+ }) {
49
+ this.trackNumber = trackNumber;
50
+ this.declaredIndex = declaredIndex;
51
+ this.type = type;
52
+ this.codecId = codecId ?? "";
53
+ this.language = language ?? "";
54
+ this.languageBcp47 = languageBcp47 ?? "";
55
+ this.name = name ?? "";
56
+ this.isEnabled = isEnabled !== false;
57
+ this.isDefault = isDefault === true;
58
+ this.declaresDefault = declaresDefault === true;
59
+ this.codecPrivateB64 = codecPrivateB64 ?? "";
60
+ this.alternateGroup = Number.isFinite(alternateGroup) ? alternateGroup : 0;
61
+ }
62
+
63
+ /** Whether this track should be offered in UI menus. Base rule: disabled tracks hidden but still counted for declaredIndex. */
64
+ isOfferable() {
65
+ return this.isEnabled;
66
+ }
67
+
68
+ /** RFC 5646 tag wins over three-letter code when present. */
69
+ resolvedLanguage() {
70
+ return this.languageBcp47 || this.language;
71
+ }
72
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @file External subtitle file — not part of the media container, but shares TextSubtitleTrack API.
3
+ *
4
+ * A standalone .srt/.ass/.ssa/.vtt file beside the video in the same torrent.
5
+ * Has no container flags; language comes from filename suffix or franc detection.
6
+ */
7
+
8
+ export class ExternalSubtitleFile {
9
+ /**
10
+ * @param {object} params
11
+ * @param {string} params.fileName
12
+ * @param {number} params.fileIndex - Torrent file index.
13
+ * @param {string} params.extension - ".srt" etc. lowercased.
14
+ * @param {string} params.language - Hint from filename or "".
15
+ */
16
+ constructor({ fileName, fileIndex, extension, language = "" }) {
17
+ this.fileName = fileName;
18
+ this.fileIndex = fileIndex;
19
+ this.extension = extension;
20
+ this.language = language;
21
+ this.type = "external-subtitle";
22
+ }
23
+
24
+ isTextBased() {
25
+ return true;
26
+ }
27
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @file Image subtitle track — PGS / VobSub / subp / clcp. Not convertible to WebVTT.
3
+ *
4
+ * Matroska: S_HDMV/PGS, S_VOBSUB, S_IMAGE/BMP etc.
5
+ * MP4: stpp is TTML (XML, excluded from Text), subp (VobSub), clcp (closed captions)
6
+ * Kept in the model only to preserve declaredIndex alignment with ffmpeg 0:s:N.
7
+ */
8
+
9
+ import { SubtitleTrack } from "./SubtitleTrack.js";
10
+
11
+ export class ImageSubtitleTrack extends SubtitleTrack {
12
+ constructor(params) {
13
+ super(params);
14
+ }
15
+
16
+ isTextBased() {
17
+ return false;
18
+ }
19
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @file Subtitle track — extends ContainerTrack with subtitle-only flags.
3
+ *
4
+ * Matroska RFC 9559:
5
+ * - FlagForced 0x55AA default 0, applies ONLY to subtitles.
6
+ * - FlagHearingImpaired 0x55AB, FlagVisualImpaired 0x55AC (SDH etc.)
7
+ * - FlagTextDescriptions 0x55AD also subtitle-related.
8
+ * - FlagEnabled 0xB9 still handled in base (disabled tracks counted but not offered).
9
+ * MP4 ISO 14496-12 + Apple tx3g extension:
10
+ * - handler sbtl/subt/text/wvtt vs subp/clcp (image)
11
+ * - tx3g displayFlags forced bits 0x40000000 / 0x80000000 (unconfirmed primary source, carried as raw flags)
12
+ */
13
+
14
+ import { ContainerTrack } from "./ContainerTrack.js";
15
+
16
+ export class SubtitleTrack extends ContainerTrack {
17
+ /**
18
+ * @param {object} params - See ContainerTrack plus:
19
+ * @param {boolean} params.isForced - FlagForced (matroska) or tx3g forced display flag.
20
+ * @param {boolean} params.isHearingImpaired - FlagHearingImpaired
21
+ * @param {boolean} params.isVisualImpaired - FlagVisualImpaired
22
+ * @param {number[]} params.clusterPositions - Matroska: file offsets of clusters whose CuePoints name this track (for push). Empty when indexless.
23
+ * @param {Array<{offset:number,size:number,startSeconds:number,endSeconds:number}>} params.samples - MP4: per-cue byte ranges from sample tables.
24
+ */
25
+ constructor(params) {
26
+ super({ ...params, type: "subtitle" });
27
+ this.isForced = params.isForced === true;
28
+ this.isHearingImpaired = params.isHearingImpaired === true;
29
+ this.isVisualImpaired = params.isVisualImpaired === true;
30
+ this.clusterPositions = Array.isArray(params.clusterPositions) ? params.clusterPositions : [];
31
+ this.samples = Array.isArray(params.samples) ? params.samples : [];
32
+ }
33
+
34
+ /** Whether this track can be converted to WebVTT in current pipeline. Overridden by subclasses. */
35
+ isTextBased() {
36
+ return false;
37
+ }
38
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @file Text subtitle track — convertible to WebVTT.
3
+ *
4
+ * Matroska: S_TEXT/UTF8, S_TEXT/ASS, S_TEXT/SSA
5
+ * MP4: tx3g, text, wvtt (stpp/TTML is NOT text for this pipeline — excluded)
6
+ * External files: .srt .ass .ssa .vtt — modelled as TextSubtitleTrack with no container backing.
7
+ */
8
+
9
+ import { SubtitleTrack } from "./SubtitleTrack.js";
10
+
11
+ const TEXT_CODECS_MATROSKA = new Set(["S_TEXT/UTF8", "S_TEXT/ASS", "S_TEXT/SSA"]);
12
+ const TEXT_FORMATS_MP4 = new Set(["tx3g", "text", "wvtt"]);
13
+
14
+ export class TextSubtitleTrack extends SubtitleTrack {
15
+ constructor(params) {
16
+ super(params);
17
+ this.textCodec = params.codecId ?? "";
18
+ }
19
+
20
+ isTextBased() {
21
+ return true;
22
+ }
23
+
24
+ static isTextCodec(codecId) {
25
+ return TEXT_CODECS_MATROSKA.has(codecId) || TEXT_FORMATS_MP4.has(codecId);
26
+ }
27
+ }
28
+
29
+ export { TEXT_CODECS_MATROSKA, TEXT_FORMATS_MP4 };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @file Video track — extends ContainerTrack with video-specific container fields.
3
+ *
4
+ * Matroska RFC 9559: TrackEntry/Video (PixelWidth/Height, DisplayWidth/Height)
5
+ * MP4 ISO/IEC 14496-12: tkhd width/height, mdhd timescale, stsd sample entry
6
+ * (avc1/hev1/etc.), stss/stts for keyframes, colr for HDR.
7
+ * No FlagForced / FlagHearingImpaired — those are subtitle-only per spec.
8
+ */
9
+
10
+ import { ContainerTrack } from "./ContainerTrack.js";
11
+
12
+ export class VideoTrack extends ContainerTrack {
13
+ /**
14
+ * @param {object} params - See ContainerTrack plus:
15
+ * @param {number | null} params.width - Coded width (PixelWidth / tkhd width).
16
+ * @param {number | null} params.height - Coded height.
17
+ * @param {number | null} params.displayWidth
18
+ * @param {number | null} params.displayHeight
19
+ * @param {number | null} params.fps - From container when available (otherwise from ffmpeg banner elsewhere).
20
+ * @param {boolean} params.isHdr - From container colr / ffmpeg detection later.
21
+ * @param {number | null} params.bitDepth
22
+ */
23
+ constructor(params) {
24
+ super({ ...params, type: "video" });
25
+ this.width = Number.isFinite(params.width) ? params.width : null;
26
+ this.height = Number.isFinite(params.height) ? params.height : null;
27
+ this.displayWidth = Number.isFinite(params.displayWidth) ? params.displayWidth : null;
28
+ this.displayHeight = Number.isFinite(params.displayHeight) ? params.displayHeight : null;
29
+ this.fps = Number.isFinite(params.fps) ? params.fps : null;
30
+ this.isHdr = params.isHdr === true;
31
+ this.bitDepth = Number.isFinite(params.bitDepth) ? params.bitDepth : null;
32
+ }
33
+ }
@@ -0,0 +1,7 @@
1
+ export { ContainerTrack } from "./ContainerTrack.js";
2
+ export { VideoTrack } from "./VideoTrack.js";
3
+ export { AudioTrack } from "./AudioTrack.js";
4
+ export { SubtitleTrack } from "./SubtitleTrack.js";
5
+ export { TextSubtitleTrack, TEXT_CODECS_MATROSKA, TEXT_FORMATS_MP4 } from "./TextSubtitleTrack.js";
6
+ export { ImageSubtitleTrack } from "./ImageSubtitleTrack.js";
7
+ export { ExternalSubtitleFile } from "./ExternalSubtitleFile.js";