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,76 @@
1
+ import { MAX_SPAN_BYTES } from "../io/source.js";
2
+ export function knobsForIndex(source, options) {
3
+ const given = options.overrides;
4
+ const shipped = shippedFor(source, options);
5
+ return {
6
+ gapBytes: clampedKnob(given?.gapBytes, shipped.gapBytes, "gapBytes"),
7
+ cueAheadBytes: clampedKnob(given?.cueAheadBytes, shipped.cueAheadBytes, "cueAheadBytes"),
8
+ concurrency: clampedKnob(given?.concurrency, INDEX_CONCURRENCY, "concurrency"),
9
+ };
10
+ }
11
+ export function knobsForWalk(source, options) {
12
+ const given = options.overrides;
13
+ const shipped = shippedFor(source, options);
14
+ return {
15
+ blockAheadBytes: clampedKnob(given?.blockAheadBytes, shipped.blockAheadBytes, "blockAheadBytes"),
16
+ concurrency: clampedKnob(given?.concurrency, shipped.concurrency, "concurrency"),
17
+ };
18
+ }
19
+ const DEFAULT_PRESET = "balanced";
20
+ const INDEX_CONCURRENCY = 16;
21
+ const TRIP_BLIND = {
22
+ gapBytes: 0,
23
+ cueAheadBytes: 256,
24
+ blockAheadBytes: 0,
25
+ concurrency: 16,
26
+ };
27
+ const LOCAL_KNOBS = {
28
+ gapBytes: 8 * 1024,
29
+ cueAheadBytes: 256,
30
+ blockAheadBytes: 2 * 1024 ** 2,
31
+ concurrency: 2,
32
+ };
33
+ const MOUNT_KNOBS = {
34
+ gapBytes: 16 * 1024,
35
+ cueAheadBytes: 256,
36
+ blockAheadBytes: 128,
37
+ concurrency: 16,
38
+ };
39
+ const REMOTE_KNOBS = {
40
+ gapBytes: 64 * 1024,
41
+ cueAheadBytes: 1024,
42
+ blockAheadBytes: 8 * 1024,
43
+ concurrency: 16,
44
+ };
45
+ const TUNING = {
46
+ leanest: { local: TRIP_BLIND, mount: TRIP_BLIND, remote: TRIP_BLIND },
47
+ balanced: { local: LOCAL_KNOBS, mount: MOUNT_KNOBS, remote: REMOTE_KNOBS },
48
+ fastest: {
49
+ local: LOCAL_KNOBS,
50
+ mount: { ...MOUNT_KNOBS, blockAheadBytes: 16 * 1024 },
51
+ remote: { ...REMOTE_KNOBS, blockAheadBytes: 64 * 1024 },
52
+ },
53
+ };
54
+ const KNOB_MIN = {
55
+ gapBytes: 0,
56
+ cueAheadBytes: 16,
57
+ blockAheadBytes: 0,
58
+ concurrency: 1,
59
+ };
60
+ const KNOB_MAX = {
61
+ gapBytes: Number.POSITIVE_INFINITY,
62
+ cueAheadBytes: MAX_SPAN_BYTES / 4,
63
+ blockAheadBytes: MAX_SPAN_BYTES,
64
+ concurrency: 64,
65
+ };
66
+ const clampedKnob = (given, shipped, key) => given !== undefined && Number.isInteger(given) && given >= KNOB_MIN[key]
67
+ ? Math.min(given, KNOB_MAX[key])
68
+ : shipped;
69
+ function shippedFor(source, options) {
70
+ const trip = source.trip;
71
+ const column = trip !== undefined && Object.hasOwn(TUNING[DEFAULT_PRESET], trip) ? trip : "mount";
72
+ const row = options.preset !== undefined && Object.hasOwn(TUNING, options.preset)
73
+ ? options.preset
74
+ : DEFAULT_PRESET;
75
+ return TUNING[row][column];
76
+ }
@@ -0,0 +1,5 @@
1
+ import type { Source, WalkFetch, Watch } from "../io/source.js";
2
+ import type { TimedHeader } from "../matroska/header.js";
3
+ import type { RefusalCode } from "../vocabulary.js";
4
+ import { type TrackTexts } from "./conclude.js";
5
+ export declare function readByWalk(source: Source, knobs: WalkFetch, header: TimedHeader, watch: Watch): Promise<TrackTexts | RefusalCode>;
@@ -0,0 +1,28 @@
1
+ import { walkFrames } from "../matroska/clusters.js";
2
+ import { isNumbered } from "../tracks/subtitle.js";
3
+ import { eventOf } from "./assemble.js";
4
+ import { assembleTrack, planFrom } from "./conclude.js";
5
+ export async function readByWalk(source, knobs, header, watch) {
6
+ const plan = planFrom(header);
7
+ if (typeof plan === "string")
8
+ return plan;
9
+ const { wanted, timeline } = plan;
10
+ const pass = { source, segment: header.segment, head: header.head, signal: watch.signal };
11
+ const numberedEntries = wanted.map((t) => [t.number, isNumbered(t)]);
12
+ const numberedByTrack = new Map(numberedEntries);
13
+ const framesByTrack = await walkFrames(pass, numberedByTrack, knobs, watch);
14
+ if (framesByTrack === null)
15
+ return "unreadable";
16
+ const out = new Map();
17
+ for (const track of wanted) {
18
+ const frames = framesByTrack.get(track.number);
19
+ if (frames === undefined || frames.length === 0)
20
+ return "unreadable";
21
+ const events = frames.map((frame) => eventOf(frame, timeline));
22
+ const assembled = assembleTrack(track, events, "whole");
23
+ if (typeof assembled === "string")
24
+ return assembled;
25
+ out.set(track.streamIndex, assembled.text);
26
+ }
27
+ return out;
28
+ }
@@ -0,0 +1,25 @@
1
+ import type { TrackEntry, TracksHeader } from "../matroska/header.js";
2
+ import type { TrackFlags, TrackTag } from "../vocabulary.js";
3
+ export type TrackType = "video" | "audio" | "complex" | "logo" | "subtitle" | "buttons" | "control" | "metadata" | "unknown";
4
+ export interface Track<T extends TrackType = TrackType> {
5
+ type: T;
6
+ /**
7
+ * The position in the track list.
8
+ *
9
+ * The same count as ffmpeg's `0:N`.
10
+ */
11
+ index: number;
12
+ /** The container's TrackNumber. */
13
+ number: number;
14
+ uid: string | null;
15
+ codecId: string;
16
+ /** Exactly what the container states, unnormalised; only no statement is null. */
17
+ language: string | null;
18
+ languageIetf: string | null;
19
+ name: string | null;
20
+ flags: TrackFlags;
21
+ codecDelayNs: number;
22
+ tags: TrackTag[];
23
+ }
24
+ export declare const kindOf: (track: TrackEntry) => TrackType;
25
+ export declare const trackCore: <T extends TrackType>(track: TrackEntry, header: TracksHeader, type: T) => Track<T>;
@@ -0,0 +1,24 @@
1
+ export const kindOf = (track) => KIND_BY_TYPE.get(track.type) ?? "unknown";
2
+ export const trackCore = (track, header, type) => ({
3
+ type: type,
4
+ index: track.streamIndex,
5
+ number: track.number,
6
+ uid: track.uid,
7
+ codecId: track.codecId,
8
+ language: track.language,
9
+ languageIetf: track.languageIetf,
10
+ name: track.name,
11
+ flags: { ...track.flags },
12
+ codecDelayNs: track.codecDelayNs,
13
+ tags: track.uid === null ? [] : (header.trackTags.get(track.uid) ?? []).map((tag) => ({ ...tag })),
14
+ });
15
+ const KIND_BY_TYPE = new Map([
16
+ [1, "video"],
17
+ [2, "audio"],
18
+ [3, "complex"],
19
+ [16, "logo"],
20
+ [17, "subtitle"],
21
+ [18, "buttons"],
22
+ [32, "control"],
23
+ [33, "metadata"],
24
+ ]);
@@ -0,0 +1,8 @@
1
+ import type { TracksHeader } from "../matroska/header.js";
2
+ import { type Track, type TrackType } from "./core.js";
3
+ import { type SubtitleTrack } from "./subtitle.js";
4
+ export type ListedTrack = SubtitleTrack | Track<Exclude<TrackType, SpecializedType>>;
5
+ /** The track types that have a specialised shape beyond the core. */
6
+ type SpecializedType = "subtitle";
7
+ export declare const allTracks: (header: TracksHeader) => ListedTrack[];
8
+ export {};
@@ -0,0 +1,6 @@
1
+ import { kindOf, trackCore } from "./core.js";
2
+ import { subtitleTrack } from "./subtitle.js";
3
+ export const allTracks = (header) => header.trackEntries.map((track) => {
4
+ const kind = kindOf(track);
5
+ return kind === "subtitle" ? subtitleTrack(track, header) : trackCore(track, header, kind);
6
+ });
@@ -0,0 +1,45 @@
1
+ import type { TrackEntry, TracksHeader } from "../matroska/header.js";
2
+ import type { SubtitleFinder } from "../vocabulary.js";
3
+ import { type Track } from "./core.js";
4
+ export interface SubtitleTrack extends Track<"subtitle"> {
5
+ /** Its `null` state and {@linkcode unsupported} are mutually exclusive. */
6
+ format: "ass" | "srt" | "vtt" | null;
7
+ /**
8
+ * The subtitle track's body.
9
+ *
10
+ * null when it was not served, for one of the reasons below.
11
+ *
12
+ * - `SubtitleOptions.text===false`: bodies were explicitly not requested
13
+ *
14
+ * - {@linkcode unsupported}: this reader does not handle the track
15
+ *
16
+ * - `RefusalCode`: the read was refused
17
+ */
18
+ text: string | null;
19
+ /**
20
+ * The finder that served the body.
21
+ *
22
+ * It goes with {@linkcode text}: when the body is null, so is this.
23
+ */
24
+ servedBy: SubtitleFinder | null;
25
+ /**
26
+ * Describes what this reader does not handle, not a verdict on the track itself.
27
+ *
28
+ * - `codec`: PGS image subtitles, S_TEXT/USF, D_WEBVTT/METADATA and the like
29
+ *
30
+ * - `content-encoded`: the container says the frames are zlib-compressed,
31
+ * header-stripped or encrypted
32
+ *
33
+ * - `null`: a kind the reader handles, which **does not guarantee it is always served**
34
+ */
35
+ unsupported: "codec" | "content-encoded" | null;
36
+ }
37
+ type SubtitleFormat = NonNullable<SubtitleTrack["format"]>;
38
+ export declare const SUPPORTED_CODECS: ReadonlyMap<string, SubtitleFormat>;
39
+ export declare const subtitleTracks: (header: TracksHeader) => SubtitleTrack[];
40
+ export declare const subtitleEntries: (header: TracksHeader) => TrackEntry[];
41
+ export declare const isSupported: (track: TrackEntry) => boolean;
42
+ export declare const isNumbered: (track: TrackEntry) => boolean;
43
+ export declare function subtitleTrack(track: TrackEntry, header: TracksHeader): SubtitleTrack;
44
+ export declare function statedFrameCount(header: TracksHeader, track: TrackEntry): number | null;
45
+ export {};
@@ -0,0 +1,38 @@
1
+ import { kindOf, trackCore } from "./core.js";
2
+ export const SUPPORTED_CODECS = new Map([
3
+ ["S_TEXT/ASS", "ass"],
4
+ ["S_TEXT/SSA", "ass"],
5
+ ["S_SSA", "ass"],
6
+ ["S_ASS", "ass"],
7
+ ["S_TEXT/UTF8", "srt"],
8
+ ["D_WEBVTT/SUBTITLES", "vtt"],
9
+ ["D_WEBVTT/CAPTIONS", "vtt"],
10
+ ]);
11
+ export const subtitleTracks = (header) => subtitleEntries(header).map((track) => subtitleTrack(track, header));
12
+ export const subtitleEntries = (header) => header.trackEntries.filter((track) => kindOf(track) === "subtitle");
13
+ export const isSupported = (track) => unsupportedReason(track) === null;
14
+ export const isNumbered = (track) => SUPPORTED_CODECS.get(track.codecId) === "ass";
15
+ export function subtitleTrack(track, header) {
16
+ const unsupported = unsupportedReason(track);
17
+ return {
18
+ ...trackCore(track, header, "subtitle"),
19
+ format: unsupported === null ? (SUPPORTED_CODECS.get(track.codecId) ?? null) : null,
20
+ text: null,
21
+ servedBy: null,
22
+ unsupported,
23
+ };
24
+ }
25
+ export function statedFrameCount(header, track) {
26
+ if (track.uid === null)
27
+ return null;
28
+ const stated = header.trackTags.get(track.uid)?.find((tag) => tag.name === "NUMBER_OF_FRAMES");
29
+ if (stated === undefined)
30
+ return null;
31
+ const parsed = stated.value.trim() === "" ? Number.NaN : Number(stated.value);
32
+ return Number.isInteger(parsed) ? parsed : null;
33
+ }
34
+ function unsupportedReason(track) {
35
+ if (!SUPPORTED_CODECS.has(track.codecId))
36
+ return "codec";
37
+ return track.encoded ? "content-encoded" : null;
38
+ }
@@ -0,0 +1,88 @@
1
+ /** The words below the parser boundary: refusals and codes, delivery requests, container facts. */
2
+ /**
3
+ * The flags whose structure the spec dictates.
4
+ *
5
+ * enabled, default and forced are effective values with the spec defaults (1, 1, 0) applied;
6
+ * the other five have no default.
7
+ */
8
+ export interface TrackFlags {
9
+ enabled: boolean;
10
+ default: boolean;
11
+ forced: boolean;
12
+ hearingImpaired: boolean | null;
13
+ visualImpaired: boolean | null;
14
+ textDescriptions: boolean | null;
15
+ original: boolean | null;
16
+ commentary: boolean | null;
17
+ }
18
+ export interface ContainerInfo {
19
+ title: string | null;
20
+ muxingApp: string | null;
21
+ writingApp: string | null;
22
+ durationMs: number | null;
23
+ timestampScale: number;
24
+ }
25
+ export interface Attachment {
26
+ fileName: string | null;
27
+ mimeType: string | null;
28
+ description: string | null;
29
+ size: number;
30
+ uid: string | null;
31
+ }
32
+ export interface TrackTag {
33
+ name: string;
34
+ value: string;
35
+ }
36
+ /** What was asked to be delivered in the envelope. */
37
+ export interface Wants {
38
+ info: boolean;
39
+ attachments: boolean;
40
+ }
41
+ /**
42
+ * The reasons a requested read could not be completed.
43
+ *
44
+ * - `not-matroska`: there is no EBML header.
45
+ *
46
+ * - `malformed`: the container does not conform.
47
+ *
48
+ * - `unreadable`: the container conforms, but this reader's path could not do it.
49
+ *
50
+ * - `source-failed`: the bytes did not arrive; retry, or check the transport.
51
+ *
52
+ * - `invalid-target`: the input itself cannot be opened; fix the call.
53
+ *
54
+ * - `cancelled`: the caller's signal fired.
55
+ */
56
+ export type RefusalCode = "not-matroska" | "malformed" | "unreadable" | "source-failed" | "invalid-target" | "cancelled";
57
+ /**
58
+ * Completion is the one code `served`.
59
+ *
60
+ * The refusal side can grow,
61
+ * so a branch on codes should leave room for a code that does not exist yet.
62
+ */
63
+ export type PeekCode = "served" | RefusalCode;
64
+ /** The two paths of a subtitle read. */
65
+ export type SubtitleFinder = "index" | "walk";
66
+ /**
67
+ * Whether the outcome is a refusal rather than a serve.
68
+ *
69
+ * @example
70
+ * const { code } = await peekSubtitles(path);
71
+ * if (isRefusalCode(code)) throw new Error(code);
72
+ */
73
+ export declare const isRefusalCode: (code: PeekCode) => code is RefusalCode;
74
+ /**
75
+ * Whether a file this reader refused is worth handing to another tool, ffmpeg say
76
+ *
77
+ * The test is whether another tool could do better with the same source,
78
+ * and a true is no guarantee of its success.
79
+ * A refusal that read the container is true;
80
+ * one where the bytes did not arrive or the call was wrong is false.
81
+ *
82
+ * @example
83
+ * const { code, tracks } = await peekSubtitles(path);
84
+ * if (!isRefusalCode(code)) return tracks;
85
+ * if (worthFallback(code)) return demux(path);
86
+ * throw new Error(code);
87
+ */
88
+ export declare const worthFallback: (code: PeekCode) => boolean;
@@ -0,0 +1,10 @@
1
+ export const isRefusalCode = (code) => Object.hasOwn(REFUSALS, code);
2
+ export const worthFallback = (code) => isRefusalCode(code) && REFUSALS[code].worthFallback;
3
+ const REFUSALS = {
4
+ "not-matroska": { worthFallback: true },
5
+ malformed: { worthFallback: true },
6
+ unreadable: { worthFallback: true },
7
+ "source-failed": { worthFallback: false },
8
+ "invalid-target": { worthFallback: false },
9
+ cancelled: { worthFallback: false },
10
+ };
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "mkvpeek",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Matroska tracks, container info and subtitle text with no ffmpeg, no native module, and a fraction of the file read",
6
+ "keywords": [
7
+ "matroska",
8
+ "mkv",
9
+ "demux",
10
+ "ebml",
11
+ "subtitles",
12
+ "ass",
13
+ "ssa",
14
+ "srt",
15
+ "vtt",
16
+ "webvtt",
17
+ "ffmpeg",
18
+ "ffprobe"
19
+ ],
20
+ "author": "6lvkro",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/6lvkro/mkvpeek.git"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/6lvkro/mkvpeek/issues"
27
+ },
28
+ "license": "MIT",
29
+ "sideEffects": false,
30
+ "main": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
32
+ "exports": {
33
+ ".": {
34
+ "browser": {
35
+ "types": "./dist/browser.d.ts",
36
+ "default": "./dist/browser.js"
37
+ },
38
+ "types": "./dist/index.d.ts",
39
+ "default": "./dist/index.js"
40
+ },
41
+ "./browser": {
42
+ "types": "./dist/browser.d.ts",
43
+ "default": "./dist/browser.js"
44
+ }
45
+ },
46
+ "files": [
47
+ "dist"
48
+ ],
49
+ "engines": {
50
+ "node": ">=22.1"
51
+ },
52
+ "scripts": {
53
+ "build": "tsx build.ts",
54
+ "lint": "biome check --error-on-warnings . && tsc -p tsconfig.json",
55
+ "format": "biome check --write .",
56
+ "check:package": "publint --strict && attw --pack . --profile esm-only",
57
+ "prepublishOnly": "pnpm run lint && pnpm run build && pnpm run check:package"
58
+ },
59
+ "devDependencies": {
60
+ "@arethetypeswrong/cli": "^0.18.5",
61
+ "@biomejs/biome": "^2.5.12",
62
+ "@types/node": "^22.20.1",
63
+ "publint": "^0.3.24",
64
+ "tsx": "^4.23.13",
65
+ "typescript": "^5.9.3"
66
+ },
67
+ "packageManager": "pnpm@12.3.4"
68
+ }