mkvpeek 0.1.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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -0
  3. package/dist/browser.d.ts +12 -0
  4. package/dist/browser.js +3 -0
  5. package/dist/ebml.d.ts +168 -0
  6. package/dist/ebml.js +224 -0
  7. package/dist/entry/browser.d.ts +44 -0
  8. package/dist/entry/browser.js +9 -0
  9. package/dist/entry/node.d.ts +37 -0
  10. package/dist/entry/node.js +38 -0
  11. package/dist/index.d.ts +9 -0
  12. package/dist/index.js +3 -0
  13. package/dist/io/file.d.ts +2 -0
  14. package/dist/io/file.js +47 -0
  15. package/dist/io/lanes.d.ts +17 -0
  16. package/dist/io/lanes.js +124 -0
  17. package/dist/io/memory.d.ts +2 -0
  18. package/dist/io/memory.js +16 -0
  19. package/dist/io/range.d.ts +22 -0
  20. package/dist/io/range.js +190 -0
  21. package/dist/io/source.d.ts +102 -0
  22. package/dist/io/source.js +31 -0
  23. package/dist/io/url-node.d.ts +10 -0
  24. package/dist/io/url-node.js +99 -0
  25. package/dist/io/url.d.ts +21 -0
  26. package/dist/io/url.js +80 -0
  27. package/dist/io/windows.d.ts +37 -0
  28. package/dist/io/windows.js +105 -0
  29. package/dist/matroska/block.d.ts +47 -0
  30. package/dist/matroska/block.js +114 -0
  31. package/dist/matroska/chain.d.ts +55 -0
  32. package/dist/matroska/chain.js +47 -0
  33. package/dist/matroska/clusters.d.ts +4 -0
  34. package/dist/matroska/clusters.js +232 -0
  35. package/dist/matroska/cues.d.ts +23 -0
  36. package/dist/matroska/cues.js +93 -0
  37. package/dist/matroska/header.d.ts +52 -0
  38. package/dist/matroska/header.js +531 -0
  39. package/dist/peek/contract.d.ts +57 -0
  40. package/dist/peek/contract.js +24 -0
  41. package/dist/peek/engine.d.ts +19 -0
  42. package/dist/peek/engine.js +32 -0
  43. package/dist/peek/target.d.ts +11 -0
  44. package/dist/peek/target.js +56 -0
  45. package/dist/subtitle/assemble.d.ts +21 -0
  46. package/dist/subtitle/assemble.js +114 -0
  47. package/dist/subtitle/conclude.d.ts +20 -0
  48. package/dist/subtitle/conclude.js +57 -0
  49. package/dist/subtitle/indexed.d.ts +5 -0
  50. package/dist/subtitle/indexed.js +172 -0
  51. package/dist/subtitle/options.d.ts +76 -0
  52. package/dist/subtitle/options.js +1 -0
  53. package/dist/subtitle/peek.d.ts +5 -0
  54. package/dist/subtitle/peek.js +107 -0
  55. package/dist/subtitle/tuning.d.ts +4 -0
  56. package/dist/subtitle/tuning.js +76 -0
  57. package/dist/subtitle/walked.d.ts +5 -0
  58. package/dist/subtitle/walked.js +28 -0
  59. package/dist/tracks/core.d.ts +25 -0
  60. package/dist/tracks/core.js +24 -0
  61. package/dist/tracks/list.d.ts +8 -0
  62. package/dist/tracks/list.js +6 -0
  63. package/dist/tracks/subtitle.d.ts +45 -0
  64. package/dist/tracks/subtitle.js +38 -0
  65. package/dist/vocabulary.d.ts +88 -0
  66. package/dist/vocabulary.js +10 -0
  67. package/package.json +68 -0
@@ -0,0 +1,56 @@
1
+ import { memorySource } from "../io/memory.js";
2
+ import { abortReason } from "../io/source.js";
3
+ import { urlSource, whyCredentialed, whyInadmissible } from "../io/url.js";
4
+ import { absent, refused, wantsOf } from "./contract.js";
5
+ export const urlRefusal = (parsed) => whyInadmissible(parsed) === null ? null : "invalid-target";
6
+ export async function overTarget(target, options, work, open = viaUrl) {
7
+ const wants = wantsOf(options);
8
+ const absentAnswer = absent(wants);
9
+ if (typeof target !== "string") {
10
+ if (target instanceof ArrayBuffer || ArrayBuffer.isView(target)) {
11
+ let fromBytes;
12
+ try {
13
+ fromBytes = memorySource(target);
14
+ }
15
+ catch {
16
+ return refused("invalid-target", absentAnswer);
17
+ }
18
+ return work(fromBytes, options);
19
+ }
20
+ const readsAndSizes = target !== null &&
21
+ target !== undefined &&
22
+ typeof target.read === "function" &&
23
+ typeof target.size === "function";
24
+ if (readsAndSizes)
25
+ return work(target, options);
26
+ return refused("invalid-target", absentAnswer);
27
+ }
28
+ const parsed = URL.parse(target);
29
+ const secret = parsed === null ? null : whyCredentialed(parsed);
30
+ if (parsed !== null && secret !== null) {
31
+ return refused("invalid-target", absentAnswer);
32
+ }
33
+ let source;
34
+ try {
35
+ source = await open(target, options.signal);
36
+ }
37
+ catch {
38
+ if (abortReason(options.signal) !== null)
39
+ return refused("cancelled", absentAnswer);
40
+ return refused("source-failed", absentAnswer);
41
+ }
42
+ if (typeof source === "string")
43
+ return refused(source, absentAnswer);
44
+ try {
45
+ return await work(source, options);
46
+ }
47
+ finally {
48
+ await source.close?.().catch(() => { });
49
+ }
50
+ }
51
+ const viaUrl = async (target, signal) => {
52
+ const parsed = URL.parse(target);
53
+ if (parsed === null)
54
+ return "invalid-target";
55
+ return urlRefusal(parsed) ?? urlSource(target, { signal });
56
+ };
@@ -0,0 +1,21 @@
1
+ export interface Event {
2
+ readOrder: number | null;
3
+ startMs: number;
4
+ durationMs: number;
5
+ payload: string;
6
+ }
7
+ export interface Timeline {
8
+ msPerTick: number;
9
+ shiftMs: number;
10
+ }
11
+ interface TickedFrame {
12
+ readOrder: number | null;
13
+ startTicks: number;
14
+ durationTicks: number | null;
15
+ payload: string;
16
+ }
17
+ export declare const eventOf: (frame: TickedFrame, timeline: Timeline) => Event;
18
+ export declare function assembleAss(codecPrivate: string, events: readonly Event[]): string;
19
+ export declare function assembleVtt(events: readonly Event[]): string;
20
+ export declare function assembleSrt(events: readonly Event[]): string;
21
+ export {};
@@ -0,0 +1,114 @@
1
+ export const eventOf = (frame, timeline) => ({
2
+ readOrder: frame.readOrder,
3
+ startMs: frame.startTicks * timeline.msPerTick + timeline.shiftMs,
4
+ durationMs: (frame.durationTicks ?? 0) * timeline.msPerTick,
5
+ payload: frame.payload,
6
+ });
7
+ export function assembleAss(codecPrivate, events) {
8
+ const script = codecPrivate.replace(/\r\n/g, "\n");
9
+ const headEnd = assHeadEnd(script);
10
+ const ssaMode = /^\[V4 Styles\]/m.test(script);
11
+ const head = script.slice(0, headEnd);
12
+ const trailer = script.slice(headEnd);
13
+ let body = "";
14
+ for (const e of events) {
15
+ const { layer: raw, middle, text } = splitAssPayload(e.payload);
16
+ const marked = raw === "" ? "0" : raw;
17
+ const layer = ssaMode ? `Marked=${marked}` : raw;
18
+ const startCs = toCentiseconds(e.startMs);
19
+ body += `Dialogue: ${layer},${assTime(startCs)},${assTime(startCs + toCentiseconds(e.durationMs))},${middle},${text}\n`;
20
+ }
21
+ const separator = head.endsWith("\n") ? "" : "\r\n";
22
+ const written = `${head}${separator}${body === "" ? "\n" : body}${trailer}`;
23
+ return written.endsWith("\n") ? written : `${written}\n`;
24
+ }
25
+ export function assembleVtt(events) {
26
+ let body = "";
27
+ for (const e of events) {
28
+ const lines = e.payload.replace(/\r\n?/g, "\n").split("\n");
29
+ const whole = lines.length >= 3;
30
+ const id = whole ? lines[0] : "";
31
+ const settings = whole ? lines[1] : "";
32
+ const text = (whole ? lines.slice(2) : lines).join("\n").replace(/\n$/, "");
33
+ const start = Math.round(e.startMs);
34
+ const at = `${vttTime(start)} --> ${vttTime(start + Math.round(e.durationMs))}`;
35
+ const head = id === "" ? "" : `${id}\n`;
36
+ body += `\n${head}${at}${settings === "" ? "" : ` ${settings}`}\n${text}\n`;
37
+ }
38
+ return `WEBVTT\n${body === "" ? "\n\n" : body}`;
39
+ }
40
+ export function assembleSrt(events) {
41
+ let body = "";
42
+ let sequence = 0;
43
+ for (const e of events) {
44
+ sequence += 1;
45
+ const start = Math.round(e.startMs);
46
+ const text = e.payload.replace(/\r\n/g, "\n").replace(/\n$/, "");
47
+ body += `${String(sequence)}\n${srtTime(start)} --> ${srtTime(start + Math.round(e.durationMs))}\n${text}\n\n`;
48
+ }
49
+ return body;
50
+ }
51
+ const DIALOGUE_LAYER_FIELD = 1;
52
+ const DIALOGUE_TEXT_FIELD = 8;
53
+ const MS_PER_SECOND = 1000;
54
+ const CS_PER_SECOND = 100;
55
+ const toCentiseconds = (ms) => Math.round(ms / 10);
56
+ const padded = (n, width = 2) => String(n).padStart(width, "0");
57
+ const padded2 = (n) => (n < 10 ? `0${String(n)}` : String(n));
58
+ function splitAssPayload(payload) {
59
+ const afterReadOrder = payload.indexOf(",");
60
+ const afterLayer = payload.indexOf(",", afterReadOrder + 1);
61
+ if (afterLayer < 0)
62
+ return { layer: payload.slice(afterReadOrder + 1), middle: "", text: "" };
63
+ let afterEffect = afterLayer;
64
+ for (let field = DIALOGUE_LAYER_FIELD + 1; field < DIALOGUE_TEXT_FIELD; field++) {
65
+ const next = payload.indexOf(",", afterEffect + 1);
66
+ if (next < 0) {
67
+ return {
68
+ layer: payload.slice(afterReadOrder + 1, afterLayer),
69
+ middle: payload.slice(afterLayer + 1),
70
+ text: "",
71
+ };
72
+ }
73
+ afterEffect = next;
74
+ }
75
+ return {
76
+ layer: payload.slice(afterReadOrder + 1, afterLayer),
77
+ middle: payload.slice(afterLayer + 1, afterEffect),
78
+ text: payload.slice(afterEffect + 1),
79
+ };
80
+ }
81
+ function assHeadEnd(script) {
82
+ const events = script.indexOf("\n[Events]");
83
+ if (events < 0)
84
+ return script.length;
85
+ const format = script.indexOf("Format:", events);
86
+ if (format < 0)
87
+ return script.length;
88
+ const eol = script.indexOf("\n", format);
89
+ return eol < 0 ? script.length : eol + 1;
90
+ }
91
+ function clockFields(duration, perSecond) {
92
+ const at = Math.max(0, duration);
93
+ const perMinute = 60 * perSecond;
94
+ const perHour = 60 * perMinute;
95
+ return {
96
+ hours: Math.floor(at / perHour),
97
+ minutes: Math.floor((at % perHour) / perMinute),
98
+ seconds: Math.floor((at % perMinute) / perSecond),
99
+ fraction: at % perSecond,
100
+ };
101
+ }
102
+ function assTime(cs) {
103
+ const { hours, minutes, seconds, fraction } = clockFields(cs, CS_PER_SECOND);
104
+ return `${String(hours)}:${padded2(minutes)}:${padded2(seconds)}.${padded2(fraction)}`;
105
+ }
106
+ function vttTime(ms) {
107
+ const { hours, minutes, seconds, fraction } = clockFields(ms, MS_PER_SECOND);
108
+ const belowHours = `${padded(minutes)}:${padded(seconds)}.${padded(fraction, 3)}`;
109
+ return hours === 0 ? belowHours : `${padded(hours)}:${belowHours}`;
110
+ }
111
+ function srtTime(ms) {
112
+ const { hours, minutes, seconds, fraction } = clockFields(ms, MS_PER_SECOND);
113
+ return `${padded(hours)}:${padded(minutes)}:${padded(seconds)},${padded(fraction, 3)}`;
114
+ }
@@ -0,0 +1,20 @@
1
+ import type { TimedHeader, TrackEntry } from "../matroska/header.js";
2
+ import type { RefusalCode } from "../vocabulary.js";
3
+ import { type Event, type Timeline } from "./assemble.js";
4
+ export type TrackTexts = Map<number, string>;
5
+ export interface ReadPlan {
6
+ wanted: readonly TrackEntry[];
7
+ timeline: Timeline;
8
+ }
9
+ interface TrackBody {
10
+ text: string;
11
+ }
12
+ /**
13
+ * Whether the events handed over can be shown to be every frame of the track.
14
+ *
15
+ * A list cut at the front looks whole and answers the first frame wrongly.
16
+ */
17
+ type Completeness = "whole" | "may-be-short";
18
+ export declare function planFrom(header: TimedHeader): ReadPlan | RefusalCode;
19
+ export declare function assembleTrack(track: TrackEntry, events: readonly Event[], completeness: Completeness): TrackBody | RefusalCode;
20
+ export {};
@@ -0,0 +1,57 @@
1
+ import { kindOf } from "../tracks/core.js";
2
+ import { isNumbered, isSupported, SUPPORTED_CODECS, subtitleEntries } from "../tracks/subtitle.js";
3
+ import { assembleAss, assembleSrt, assembleVtt } from "./assemble.js";
4
+ export function planFrom(header) {
5
+ const wanted = subtitleEntries(header).filter(isSupported);
6
+ const timeline = timelineOf(header);
7
+ if (typeof timeline === "string")
8
+ return timeline;
9
+ return { wanted, timeline };
10
+ }
11
+ export function assembleTrack(track, events, completeness) {
12
+ const format = SUPPORTED_CODECS.get(track.codecId);
13
+ const numbered = isNumbered(track);
14
+ const ordered = orderedAndChecked(events, numbered, completeness);
15
+ if (typeof ordered === "string")
16
+ return ordered;
17
+ if (format === "ass")
18
+ return { text: assembleAss(track.codecPrivate, ordered) };
19
+ if (format === "vtt")
20
+ return { text: assembleVtt(ordered) };
21
+ void format;
22
+ return { text: assembleSrt(ordered) };
23
+ }
24
+ const NS_PER_MS = 1_000_000;
25
+ const TIMELINE_KINDS = new Set(["video", "audio", "complex"]);
26
+ function timelineOf(header) {
27
+ const shiftMs = timelineShift(header);
28
+ if (shiftMs === null) {
29
+ return "malformed";
30
+ }
31
+ return { msPerTick: header.info.timestampScale / NS_PER_MS, shiftMs };
32
+ }
33
+ function timelineShift(header) {
34
+ const delayNs = header.trackEntries.reduce((most, t) => Math.max(most, t.codecDelayNs), 0);
35
+ const delayMs = Math.round(delayNs / NS_PER_MS);
36
+ if (!header.trackEntries.some((t) => TIMELINE_KINDS.has(kindOf(t))))
37
+ return delayMs;
38
+ if (header.firstClusterTicks === null)
39
+ return null;
40
+ return delayMs - (header.firstClusterTicks * header.info.timestampScale) / NS_PER_MS;
41
+ }
42
+ function sortEvents(events, numbered) {
43
+ const ordered = [...events];
44
+ if (numbered)
45
+ ordered.sort((a, b) => (a.readOrder ?? 0) - (b.readOrder ?? 0));
46
+ else
47
+ ordered.sort((a, b) => a.startMs - b.startMs);
48
+ return ordered;
49
+ }
50
+ function orderedAndChecked(events, numbered, completeness) {
51
+ const ordered = sortEvents(events, numbered);
52
+ if (numbered && ordered.some((e) => e.readOrder === null))
53
+ return "malformed";
54
+ if (completeness === "may-be-short" && ordered[0]?.durationMs === 0)
55
+ return "unreadable";
56
+ return ordered;
57
+ }
@@ -0,0 +1,5 @@
1
+ import { type IndexFetch, type Source, type Watch } from "../io/source.js";
2
+ import type { IndexedHeader } from "../matroska/header.js";
3
+ import type { RefusalCode } from "../vocabulary.js";
4
+ import { type TrackTexts } from "./conclude.js";
5
+ export declare function readByIndex(source: Source, knobs: IndexFetch, header: IndexedHeader, watch: Watch): Promise<TrackTexts | RefusalCode>;
@@ -0,0 +1,172 @@
1
+ import { available, stopIfAborted, } from "../io/source.js";
2
+ import { coalesce, fetchRanges, fetchSpans, WindowIndex } from "../io/windows.js";
3
+ import { planRanges, planShortfall, readFrame } from "../matroska/cues.js";
4
+ import { isNumbered, statedFrameCount } from "../tracks/subtitle.js";
5
+ import { eventOf } from "./assemble.js";
6
+ import { assembleTrack, planFrom } from "./conclude.js";
7
+ export async function readByIndex(source, knobs, header, watch) {
8
+ const plan = planFrom(header);
9
+ if (typeof plan === "string")
10
+ return plan;
11
+ const collected = await followIndex(source, knobs, header, plan, watch);
12
+ if (!(collected instanceof Map))
13
+ return collected;
14
+ const out = new Map();
15
+ for (const track of plan.wanted) {
16
+ const events = collected.get(track.number) ?? [];
17
+ const vouched = anchorless(events) ? "may-be-short" : "whole";
18
+ const assembled = assembleTrack(track, events, vouched);
19
+ if (typeof assembled === "string")
20
+ return assembled;
21
+ out.set(track.streamIndex, assembled.text);
22
+ }
23
+ return out;
24
+ }
25
+ const FRAMES_PER_ROUND = 4096;
26
+ const unheld = (ranges, headBytes, fileSize) => ranges.flatMap((range) => {
27
+ const length = available(range.at, range.length, fileSize);
28
+ if (length === 0 || range.at + length <= headBytes)
29
+ return [];
30
+ return [{ at: range.at, length }];
31
+ });
32
+ async function followIndex(source, knobs, header, plan, watch) {
33
+ const planned = planFrames(header, plan.wanted);
34
+ if (!Array.isArray(planned))
35
+ return planned;
36
+ const headBytes = header.head.bytes.length;
37
+ const rounds = [];
38
+ for (let start = 0; start < planned.length; start += FRAMES_PER_ROUND) {
39
+ const slice = planned.slice(start, start + FRAMES_PER_ROUND);
40
+ const cues = slice.map((p) => p.cue);
41
+ const wanted = planRanges(cues, knobs.cueAheadBytes);
42
+ const bought = unheld(wanted, headBytes, header.fileSize);
43
+ const spans = coalesce(bought, knobs);
44
+ const beyondHead = (sum, span) => sum + (span.at + span.length - Math.max(span.at, headBytes));
45
+ const round = { slice, cues, spans, bytes: spans.reduce(beyondHead, 0) };
46
+ rounds.push(round);
47
+ }
48
+ const totalBytes = headBytes + rounds.reduce((sum, r) => sum + r.bytes, 0);
49
+ watch.onProgress(headBytes, totalBytes);
50
+ const round = {
51
+ sized: { source, fileSize: header.fileSize },
52
+ knobs,
53
+ timeline: plan.timeline,
54
+ head: header.head,
55
+ };
56
+ const progressBytes = { headBytes, totalBytes };
57
+ const collected = await collectByRound(round, rounds, progressBytes, watch);
58
+ if (!Array.isArray(collected))
59
+ return collected;
60
+ return vouchedRuns(header, plan.wanted, collected);
61
+ }
62
+ function planFrames(header, wanted) {
63
+ const planned = [];
64
+ for (const track of wanted) {
65
+ const cues = header.cues.get(track.number);
66
+ if (cues === undefined || cues.length === 0) {
67
+ return "unreadable";
68
+ }
69
+ if (repeatedPosition(cues)) {
70
+ return "unreadable";
71
+ }
72
+ const numbered = isNumbered(track);
73
+ for (const cue of cues)
74
+ planned.push({ track, numbered, cue });
75
+ }
76
+ planned.sort((a, b) => a.cue.clusterPos - b.cue.clusterPos || a.cue.relPos - b.cue.relPos);
77
+ return planned;
78
+ }
79
+ function vouchedRuns(header, wanted, collected) {
80
+ const out = new Map();
81
+ for (const track of wanted) {
82
+ const events = collected.filter((e) => e.track === track.number).map((e) => e.event);
83
+ if (brokenRun(events))
84
+ return "unreadable";
85
+ const frames = statedFrameCount(header, track);
86
+ if (frames !== null && frames !== events.length) {
87
+ return "unreadable";
88
+ }
89
+ if (frames === null && !isNumbered(track)) {
90
+ return "unreadable";
91
+ }
92
+ if (frames === null && anchorless(events)) {
93
+ return "unreadable";
94
+ }
95
+ out.set(track.number, events);
96
+ }
97
+ return out;
98
+ }
99
+ function repeatedPosition(cues) {
100
+ const seen = new Map();
101
+ for (const { clusterPos, relPos } of cues) {
102
+ const inCluster = seen.get(clusterPos);
103
+ if (inCluster === undefined) {
104
+ const first = new Set([relPos]);
105
+ seen.set(clusterPos, first);
106
+ continue;
107
+ }
108
+ if (inCluster.has(relPos))
109
+ return true;
110
+ inCluster.add(relPos);
111
+ }
112
+ return false;
113
+ }
114
+ async function collectByRound(round, rounds, { headBytes, totalBytes }, { onProgress, signal }) {
115
+ const events = [];
116
+ let doneBytes = headBytes;
117
+ for (const { slice, cues, spans, bytes } of rounds) {
118
+ stopIfAborted(signal);
119
+ const first = await fetchSpans(round.sized, spans, round.knobs.concurrency, signal);
120
+ const index = new WindowIndex(first, round.head);
121
+ const missing = planShortfall(index, cues);
122
+ const shortfall = unheld(missing, headBytes, round.sized.fileSize);
123
+ const rest = await fetchRanges(round.sized, shortfall, round.knobs, signal);
124
+ const windows = new WindowIndex([...first, ...rest], round.head);
125
+ const roundEvents = await readRound(round.timeline, windows, slice);
126
+ if (!Array.isArray(roundEvents))
127
+ return roundEvents;
128
+ events.push(...roundEvents);
129
+ doneBytes += bytes;
130
+ onProgress(doneBytes, totalBytes);
131
+ }
132
+ stopIfAborted(signal);
133
+ return events;
134
+ }
135
+ async function readRound(timeline, windows, planned) {
136
+ const events = [];
137
+ const memo = { last: null };
138
+ for (const { track, numbered, cue: point } of planned) {
139
+ const frame = await readFrame(windows, point, track.number, numbered, memo);
140
+ if (typeof frame === "string")
141
+ return "unreadable";
142
+ const attributed = { track: track.number, event: eventOf(frame, timeline) };
143
+ events.push(attributed);
144
+ }
145
+ return events;
146
+ }
147
+ function anchorless(events) {
148
+ return (events.length > 0 &&
149
+ events.every((e) => e.readOrder !== null) &&
150
+ !events.some((e) => e.readOrder === 0));
151
+ }
152
+ function brokenRun(events) {
153
+ let low = Number.POSITIVE_INFINITY;
154
+ let high = Number.NEGATIVE_INFINITY;
155
+ let tally = 0;
156
+ const distinct = new Set();
157
+ for (const { readOrder } of events) {
158
+ if (readOrder === null)
159
+ break;
160
+ low = Math.min(low, readOrder);
161
+ high = Math.max(high, readOrder);
162
+ distinct.add(readOrder);
163
+ tally += 1;
164
+ }
165
+ if (tally === events.length && tally > 0) {
166
+ if (distinct.size !== tally)
167
+ return true;
168
+ if (high - low + 1 !== tally)
169
+ return true;
170
+ }
171
+ return false;
172
+ }
@@ -0,0 +1,76 @@
1
+ import type { IndexFetch, WalkFetch } from "../io/source.js";
2
+ import type { PeekOptions } from "../peek/contract.js";
3
+ import type { SubtitleFinder } from "../vocabulary.js";
4
+ export type FetchKnobs = IndexFetch & WalkFetch;
5
+ /** What a caller can say about the cost of a read. */
6
+ export interface SubtitleTuning {
7
+ preset?: SubtitlePreset;
8
+ overrides?: Partial<FetchKnobs>;
9
+ }
10
+ /**
11
+ * - `balanced`: (default) the knee of the time-transfer curve
12
+ *
13
+ * - `fastest`: minimises time.
14
+ *
15
+ * - `leanest`: minimises transfer.
16
+ */
17
+ export type SubtitlePreset = "balanced" | "fastest" | "leanest";
18
+ /**
19
+ * The policy for finding a subtitle track's frames.
20
+ *
21
+ * - `index`: follows the container's Cues and picks up only the frames.
22
+ * Fast, but a muxer may have written no index, so it accepts fewer containers than `walk`.
23
+ *
24
+ * - `walk`: visits every cluster. Slow, reading block by block, but it answers without an index.
25
+ *
26
+ * - `both`: (default) `index` first,
27
+ * falling back to `walk` only when what made it back off was the index or the source.
28
+ * When the container is at fault `walk` would do the same, so it ends.
29
+ */
30
+ export type SubtitleVia = "both" | SubtitleFinder;
31
+ /** The options of `peekSubtitles`. */
32
+ export interface SubtitleOptions extends PeekOptions, SubtitleTuning {
33
+ /**
34
+ * An explicit `false` reads the header alone and returns early with no bodies.
35
+ *
36
+ * The finding half ({@linkcode via}, the tuning, {@linkcode onProgress}) is ignored then.
37
+ */
38
+ text?: boolean;
39
+ via?: SubtitleVia;
40
+ /**
41
+ * @example
42
+ * await peekSubtitles(path, {
43
+ * onProgress: ({ done, total, finder }) => bar.set(finder, done / total),
44
+ * });
45
+ *
46
+ * @example
47
+ * // A budget, aborting past a per-path cap
48
+ * const stop = new AbortController();
49
+ * const most = { index: 100 * 1024 ** 2, walk: 1024 ** 3 };
50
+ * await peekSubtitles(path, {
51
+ * via: "both",
52
+ * signal: stop.signal,
53
+ * onProgress: (p) => { if (p.total > most[p.finder]) stop.abort(); },
54
+ * });
55
+ */
56
+ onProgress?: (progress: SubtitleProgress) => void;
57
+ }
58
+ /**
59
+ * One progress report, which {@linkcode SubtitleOptions.onProgress} receives.
60
+ */
61
+ export interface SubtitleProgress {
62
+ /**
63
+ * The path doing the reading.
64
+ *
65
+ * Under {@linkcode SubtitleVia} `both`, a `walk` here means `index` backed off.
66
+ */
67
+ finder: SubtitleFinder;
68
+ doneBytes: number;
69
+ /**
70
+ * - `index`: the bytes the plan means to fetch
71
+ *
72
+ * - `walk`: the file size
73
+ */
74
+ totalBytes: number;
75
+ }
76
+ export declare const DEFAULT_VIA: SubtitleVia;
@@ -0,0 +1 @@
1
+ export const DEFAULT_VIA = "both";
@@ -0,0 +1,5 @@
1
+ import type { Source } from "../io/source.js";
2
+ import { type PeekOutcome } from "../peek/contract.js";
3
+ import type { SubtitleTrack } from "../tracks/subtitle.js";
4
+ import { type SubtitleOptions } from "./options.js";
5
+ export declare function peekSubtitlesFrom(source: Source, options?: SubtitleOptions): Promise<PeekOutcome<SubtitleTrack>>;
@@ -0,0 +1,107 @@
1
+ import { abortReason } from "../io/source.js";
2
+ import { readContainer } from "../matroska/header.js";
3
+ import { absent, answerOf, refused, served, wantsOf } from "../peek/contract.js";
4
+ import { fromHeader, thrownRefusal } from "../peek/engine.js";
5
+ import { subtitleTracks } from "../tracks/subtitle.js";
6
+ import { readByIndex } from "./indexed.js";
7
+ import { DEFAULT_VIA } from "./options.js";
8
+ import { knobsForIndex, knobsForWalk } from "./tuning.js";
9
+ import { readByWalk } from "./walked.js";
10
+ export async function peekSubtitlesFrom(source, options = {}) {
11
+ if (options.text === false)
12
+ return fromHeader(source, options, subtitleTracks);
13
+ const viaAsked = options.via;
14
+ const via = viaAsked !== undefined && Object.hasOwn(VIA_ROUTES, viaAsked) ? viaAsked : DEFAULT_VIA;
15
+ const wants = wantsOf(options);
16
+ let carried = null;
17
+ if (via === "index" || via === "both") {
18
+ const indexed = await attempt(source, options, wants, INDEX);
19
+ if (indexed.refusal === null)
20
+ return enveloped(indexed, wants);
21
+ const worthWalking = indexed.refusal === "unreadable" || indexed.refusal === "source-failed";
22
+ if (via === "index" || !worthWalking)
23
+ return enveloped(indexed, wants);
24
+ carried = indexed.header;
25
+ }
26
+ const walked = await attempt(source, options, wants, WALK, carried);
27
+ return enveloped(walked, wants);
28
+ }
29
+ const VIA_ROUTES = { both: true, index: true, walk: true };
30
+ const INDEX = {
31
+ open: (source, wants, concurrency, signal) => {
32
+ const readOptions = { ...wants, concurrency, signal };
33
+ return readContainer(source, "indexed", readOptions);
34
+ },
35
+ knobsFor: knobsForIndex,
36
+ find: readByIndex,
37
+ finder: "index",
38
+ };
39
+ const WALK = {
40
+ open: (source, wants, concurrency, signal) => {
41
+ const readOptions = { ...wants, concurrency, signal };
42
+ return readContainer(source, "timed", readOptions);
43
+ },
44
+ knobsFor: knobsForWalk,
45
+ find: readByWalk,
46
+ finder: "walk",
47
+ };
48
+ const tracksWith = (header, text, servedBy) => subtitleTracks(header).map((track) => {
49
+ const body = text?.get(track.index) ?? null;
50
+ return { ...track, text: body, servedBy: body === null ? null : servedBy };
51
+ });
52
+ function reporter(listener, finder) {
53
+ if (listener === undefined)
54
+ return () => { };
55
+ return (doneBytes, totalBytes) => {
56
+ try {
57
+ listener({ finder, doneBytes, totalBytes });
58
+ }
59
+ catch { }
60
+ };
61
+ }
62
+ async function attempt(source, options, wants, route, carried = null) {
63
+ const signal = options.signal;
64
+ if (abortReason(signal) !== null)
65
+ return stopped(carried);
66
+ const watch = { onProgress: reporter(options.onProgress, route.finder), signal };
67
+ const knobs = route.knobsFor(source, options);
68
+ const header = carried ??
69
+ (await route
70
+ .open(source, wants, knobs.concurrency, signal)
71
+ .catch((error) => thrownRefusal(error, "malformed", signal)));
72
+ if (typeof header === "string")
73
+ return { refusal: header, header: null };
74
+ if (abortReason(signal) !== null)
75
+ return stopped(header);
76
+ const listed = tracksWith(header, null, null);
77
+ if (listed.every((track) => track.unsupported !== null)) {
78
+ return { refusal: null, header, tracks: listed };
79
+ }
80
+ try {
81
+ const texts = await route.find(source, knobs, header, watch);
82
+ if (typeof texts === "string")
83
+ return { refusal: texts, header, tracks: listed };
84
+ const tracks = tracksWith(header, texts, route.finder);
85
+ return { refusal: null, header, tracks };
86
+ }
87
+ catch (error) {
88
+ const refusal = thrownRefusal(error, "unreadable", signal);
89
+ return { refusal, header, tracks: listed };
90
+ }
91
+ }
92
+ function enveloped(tried, wants) {
93
+ const absentAnswer = absent(wants);
94
+ if (tried.header === null)
95
+ return refused(tried.refusal, absentAnswer);
96
+ const answer = answerOf(tried.header, tried.tracks, wants);
97
+ if (tried.refusal === null)
98
+ return served(answer);
99
+ return refused(tried.refusal, answer);
100
+ }
101
+ function stopped(header) {
102
+ const refusal = "cancelled";
103
+ if (header === null)
104
+ return { refusal, header: null };
105
+ const tracks = tracksWith(header, null, null);
106
+ return { refusal, header, tracks };
107
+ }
@@ -0,0 +1,4 @@
1
+ import type { IndexFetch, Source, WalkFetch } from "../io/source.js";
2
+ import type { SubtitleTuning } from "./options.js";
3
+ export declare function knobsForIndex(source: Source, options: SubtitleTuning): IndexFetch;
4
+ export declare function knobsForWalk(source: Source, options: SubtitleTuning): WalkFetch;