video-fps-probe 0.2.0 → 0.4.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +33 -15
  3. package/dist/api/codes.d.ts +6 -0
  4. package/dist/api/codes.js +88 -0
  5. package/dist/api/errors.js +1 -1
  6. package/dist/api/guards.d.ts +2 -0
  7. package/dist/api/guards.js +2 -1
  8. package/dist/check/index.js +12 -2
  9. package/dist/cjs/analyze.d.cts +9 -0
  10. package/dist/cjs/api/agreement.d.cts +18 -0
  11. package/dist/cjs/api/codes.d.cts +6 -0
  12. package/dist/cjs/api/errors.d.cts +12 -0
  13. package/dist/cjs/api/guards.d.cts +4 -0
  14. package/dist/cjs/api/summary.d.cts +22 -0
  15. package/dist/cjs/check/index.d.cts +60 -0
  16. package/dist/cjs/check.cjs +1 -0
  17. package/dist/cjs/chunks/build-CT5g6_bs.cjs +1 -0
  18. package/dist/cjs/chunks/compare-D7RVqlqf.cjs +1 -0
  19. package/dist/cjs/chunks/errors-0oBg1Ai6.cjs +1 -0
  20. package/dist/cjs/chunks/fallback-D9Ak2zFv.cjs +1 -0
  21. package/dist/cjs/chunks/guards-Dqpjtkdn.cjs +1 -0
  22. package/dist/cjs/chunks/inline-Bx-TQg8f.cjs +1 -0
  23. package/dist/cjs/chunks/isobmff-B_bb5rS5.cjs +1 -0
  24. package/dist/cjs/chunks/matroska-CLoGzfKA.cjs +1 -0
  25. package/dist/cjs/chunks/probe-DDLUfr23.cjs +1 -0
  26. package/dist/cjs/chunks/rational-Dq5Vza-Q.cjs +1 -0
  27. package/dist/cjs/chunks/reader-DNLR-_tm.cjs +1 -0
  28. package/dist/cjs/chunks/rfps-C-pyKnu8.cjs +1 -0
  29. package/dist/cjs/chunks/runtime-DtgT0xzU.cjs +1 -0
  30. package/dist/cjs/chunks/segments-i-hPN7H3.cjs +1 -0
  31. package/dist/cjs/chunks/sniff-CXwqOGwh.cjs +1 -0
  32. package/dist/cjs/chunks/stats-D68frkk2.cjs +1 -0
  33. package/dist/cjs/client/types.d.cts +96 -0
  34. package/dist/cjs/core/reader.d.cts +47 -0
  35. package/dist/cjs/core/types.d.cts +149 -0
  36. package/dist/cjs/engines/types.d.cts +69 -0
  37. package/dist/cjs/header/index.d.cts +25 -0
  38. package/dist/cjs/header/limits.d.cts +25 -0
  39. package/dist/cjs/header.cjs +1 -0
  40. package/dist/cjs/index.cjs +1 -0
  41. package/dist/cjs/index.d.cts +13 -0
  42. package/dist/cjs/plugins/types.d.cts +17 -0
  43. package/dist/cjs/presets/index.d.cts +19 -0
  44. package/dist/cjs/presets.cjs +1 -0
  45. package/dist/cjs/probe.d.cts +3 -0
  46. package/dist/cjs/url/index.d.cts +47 -0
  47. package/dist/cjs/url.cjs +1 -0
  48. package/dist/cjs/worker/protocol.d.cts +47 -0
  49. package/dist/client/compare.js +2 -2
  50. package/dist/client/create.d.ts +3 -0
  51. package/dist/client/create.js +8 -0
  52. package/dist/client/fallback.js +2 -2
  53. package/dist/client/index.d.ts +1 -1
  54. package/dist/client/index.js +1 -1
  55. package/dist/client/inline.js +1 -1
  56. package/dist/client/lazy.js +1 -1
  57. package/dist/client/probe-client.js +4 -0
  58. package/dist/client/probe.js +23 -22
  59. package/dist/client/route.js +20 -0
  60. package/dist/client/split.js +9 -0
  61. package/dist/client/stub.js +2 -2
  62. package/dist/client/types.d.ts +1 -0
  63. package/dist/core/analyze.js +4 -78
  64. package/dist/core/dispatch.js +65 -0
  65. package/dist/core/isobmff/boxes.js +6 -8
  66. package/dist/core/isobmff/build.js +398 -0
  67. package/dist/core/isobmff/header.js +654 -0
  68. package/dist/core/isobmff/index.js +9 -378
  69. package/dist/core/isobmff/model.js +1 -1
  70. package/dist/core/isobmff/moof.js +2 -2
  71. package/dist/core/isobmff/moov.js +14 -9
  72. package/dist/core/isobmff/track.js +4 -2
  73. package/dist/core/matroska/ebml.js +1 -1
  74. package/dist/core/matroska/header.js +9 -4
  75. package/dist/core/matroska/index.js +7 -3
  76. package/dist/core/reader.d.ts +3 -12
  77. package/dist/core/reader.js +21 -32
  78. package/dist/core/result.js +1 -0
  79. package/dist/core/stats.js +3 -0
  80. package/dist/core/types.d.ts +15 -1
  81. package/dist/engines/common.js +8 -0
  82. package/dist/engines/mediabunny.js +17 -4
  83. package/dist/engines/mp4box.js +2 -1
  84. package/dist/header/index.d.ts +25 -0
  85. package/dist/header/index.js +146 -0
  86. package/dist/header/limits.d.ts +25 -0
  87. package/dist/header/limits.js +140 -0
  88. package/dist/index.d.ts +1 -0
  89. package/dist/index.js +1 -0
  90. package/dist/plugins/own.js +5 -2
  91. package/dist/presets/index.d.ts +1 -0
  92. package/dist/presets/sources.json +8 -0
  93. package/dist/probe.js +2 -1
  94. package/dist/react/store.js +1 -1
  95. package/dist/standalone/own-isobmff.worker.js +1 -0
  96. package/dist/standalone/own-matroska.worker.js +1 -0
  97. package/dist/standalone/own.worker.js +1 -1
  98. package/dist/url/index.d.ts +47 -0
  99. package/dist/url/index.js +335 -0
  100. package/dist/worker/own-isobmff.worker.js +4 -0
  101. package/dist/worker/own-matroska.worker.js +4 -0
  102. package/dist/worker/runtime.d.ts +2 -3
  103. package/dist/worker/runtime.js +2 -97
  104. package/dist/worker/serve.d.ts +3 -0
  105. package/dist/worker/serve.js +97 -0
  106. package/dist/worker/single.js +14 -0
  107. package/package.json +85 -2
  108. package/dist/client/inline.d.ts +0 -19
  109. package/dist/client/pool.d.ts +0 -29
  110. package/dist/client/probe.d.ts +0 -29
@@ -1,3 +1,4 @@
1
+ import type { FileErrorCode } from './types.js';
1
2
  export interface BlobSlice {
2
3
  arrayBuffer(): Promise<ArrayBuffer>;
3
4
  }
@@ -10,17 +11,9 @@ export interface BlobReaderOptions {
10
11
  maxWindows?: number;
11
12
  signal?: AbortSignal | null;
12
13
  }
13
- export interface ReaderStats {
14
- reads: number;
15
- bytesFetched: number;
16
- bytesTransferred: number;
17
- }
18
14
  export declare const DEFAULT_HEAD_WINDOW: number;
19
15
  export declare const DEFAULT_MAX_WINDOWS = 4;
20
16
  export declare const MAX_SAFE_OFFSET: number;
21
- export declare class EndOfFileError extends Error {
22
- constructor(message: string);
23
- }
24
17
  export declare function toSafeNumber(value: bigint, what?: string): number;
25
18
  export declare class BlobReader {
26
19
  #private;
@@ -31,16 +24,12 @@ export declare class BlobReader {
31
24
  constructor(source: BlobLike, options?: BlobReaderOptions);
32
25
  get reads(): number;
33
26
  get bytesFetched(): number;
34
- get bytesTransferred(): number;
35
- stats(): ReaderStats;
36
27
  checkAbort(): void;
37
28
  read(offset: number, length: number): Promise<Uint8Array>;
38
29
  readMany(ranges: ReadonlyArray<readonly [offset: number, length: number]>, concurrency?: number): Promise<Uint8Array[]>;
39
30
  peek(offset: number, length: number): DataView | null;
40
31
  view(offset: number, length: number, readAhead?: number): Promise<DataView>;
41
- viewExact(offset: number, length: number, readAhead?: number): Promise<DataView>;
42
32
  bytes(offset: number, length: number, readAhead?: number): Promise<Uint8Array>;
43
- clearCache(): void;
44
33
  }
45
34
  export declare function u8(dv: DataView, p: number): number;
46
35
  export declare function u16(dv: DataView, p: number): number;
@@ -54,3 +43,5 @@ export declare function u64Big(dv: DataView, p: number): bigint;
54
43
  export declare function fixed16_16(dv: DataView, p: number): number;
55
44
  export declare function ascii(dv: DataView, p: number, length: number): string;
56
45
  export declare function fourcc(dv: DataView, p: number): string;
46
+ export declare function withErrorCode(code: FileErrorCode, message: string): Error;
47
+ export declare function whichErrorCode(e: unknown, fallback?: FileErrorCode): FileErrorCode;
@@ -2,15 +2,9 @@ import { throwIfAborted } from "./abort.js";
2
2
  export const DEFAULT_HEAD_WINDOW = 64 * 1024;
3
3
  export const DEFAULT_MAX_WINDOWS = 4;
4
4
  export const MAX_SAFE_OFFSET = Number.MAX_SAFE_INTEGER;
5
- export class EndOfFileError extends Error {
6
- constructor(message) {
7
- super(message);
8
- this.name = 'EndOfFileError';
9
- }
10
- }
11
- function assertSafeCount(value, what) {
5
+ function assertSafeCount(value, what, kind = RangeError) {
12
6
  if (!Number.isSafeInteger(value) || value < 0)
13
- throw new RangeError(`${what} must be a non-negative safe integer, got ${value}`);
7
+ throw new kind(`${what} must be a non-negative safe integer, got ${value}`);
14
8
  }
15
9
  export function toSafeNumber(value, what = 'value') {
16
10
  if (value > BigInt(MAX_SAFE_OFFSET) || value < -BigInt(MAX_SAFE_OFFSET))
@@ -27,15 +21,14 @@ export class BlobReader {
27
21
  #covered = [];
28
22
  #reads = 0;
29
23
  #unique = 0;
30
- #transferred = 0;
31
24
  constructor(source, options = {}) {
32
- assertSafeCount(source.size, 'blob size');
25
+ assertSafeCount(source.size, 'blob size', TypeError);
33
26
  this.#source = source;
34
27
  this.size = source.size;
35
28
  this.headWindow = options.headWindow ?? DEFAULT_HEAD_WINDOW;
36
29
  this.maxWindows = Math.max(1, options.maxWindows ?? DEFAULT_MAX_WINDOWS);
37
30
  this.signal = options.signal ?? null;
38
- assertSafeCount(this.headWindow, 'headWindow');
31
+ assertSafeCount(this.headWindow, 'headWindow', TypeError);
39
32
  }
40
33
  get reads() {
41
34
  return this.#reads;
@@ -43,12 +36,6 @@ export class BlobReader {
43
36
  get bytesFetched() {
44
37
  return this.#unique;
45
38
  }
46
- get bytesTransferred() {
47
- return this.#transferred;
48
- }
49
- stats() {
50
- return { reads: this.#reads, bytesFetched: this.#unique, bytesTransferred: this.#transferred };
51
- }
52
39
  checkAbort() {
53
40
  throwIfAborted(this.signal);
54
41
  }
@@ -61,12 +48,18 @@ export class BlobReader {
61
48
  if (end === offset)
62
49
  return new Uint8Array(0);
63
50
  this.checkAbort();
64
- const ab = await this.#source.slice(offset, end).arrayBuffer();
51
+ let ab;
52
+ try {
53
+ ab = await this.#source.slice(offset, end).arrayBuffer();
54
+ }
55
+ catch (e) {
56
+ this.checkAbort();
57
+ throw e?.name === 'AbortError' ? e : withErrorCode('read-failed', `file read failed: ${e}`);
58
+ }
65
59
  this.checkAbort();
66
60
  this.#reads++;
67
- this.#transferred += ab.byteLength;
68
61
  if (ab.byteLength !== end - offset) {
69
- throw new EndOfFileError(`short read at ${offset}: expected ${end - offset} bytes, got ${ab.byteLength}`);
62
+ throw withErrorCode('read-failed', `file read failed: short read at ${offset}: expected ${end - offset} bytes, got ${ab.byteLength}`);
70
63
  }
71
64
  this.#cover(offset, end);
72
65
  return new Uint8Array(ab);
@@ -105,7 +98,7 @@ export class BlobReader {
105
98
  async view(offset, length, readAhead = this.headWindow) {
106
99
  assertSafeCount(offset, 'offset');
107
100
  assertSafeCount(length, 'length');
108
- assertSafeCount(readAhead, 'readAhead');
101
+ assertSafeCount(readAhead, 'readAhead', TypeError);
109
102
  if (offset > this.size)
110
103
  throw new RangeError(`offset ${offset} is beyond the end of the file (${this.size})`);
111
104
  const end = Math.min(this.size, offset + length);
@@ -132,20 +125,10 @@ export class BlobReader {
132
125
  this.#windows.length = this.maxWindows;
133
126
  return new DataView(buf.buffer, buf.byteOffset, end - offset);
134
127
  }
135
- async viewExact(offset, length, readAhead = this.headWindow) {
136
- const dv = await this.view(offset, length, readAhead);
137
- if (dv.byteLength < length) {
138
- throw new EndOfFileError(`unexpected end of file: wanted ${length} bytes at ${offset}, file size ${this.size}`);
139
- }
140
- return dv;
141
- }
142
128
  async bytes(offset, length, readAhead = this.headWindow) {
143
129
  const dv = await this.view(offset, length, readAhead);
144
130
  return new Uint8Array(dv.buffer, dv.byteOffset, dv.byteLength);
145
131
  }
146
- clearCache() {
147
- this.#windows = [];
148
- }
149
132
  #cover(start, end) {
150
133
  const list = this.#covered;
151
134
  let lo = 0;
@@ -213,5 +196,11 @@ export function ascii(dv, p, length) {
213
196
  return s;
214
197
  }
215
198
  export function fourcc(dv, p) {
216
- return ascii(dv, p, 4);
199
+ return ascii(dv, p, 4).replace(/[^ -~]/g, '?');
200
+ }
201
+ export function withErrorCode(code, message) {
202
+ return Object.assign(Error(message), { errorCode: code });
203
+ }
204
+ export function whichErrorCode(e, fallback = 'internal') {
205
+ return e?.errorCode ?? (e instanceof RangeError ? 'corrupt' : fallback);
217
206
  }
@@ -38,6 +38,7 @@ export function emptyTrack(trackId) {
38
38
  timestampsQuantized: false,
39
39
  isStill: false,
40
40
  rateProfile: null,
41
+ medianFrameInterval: null,
41
42
  provenance: {},
42
43
  warnings: [],
43
44
  };
@@ -134,6 +134,7 @@ function emptyStats(timescale, warnings) {
134
134
  minDelta: null,
135
135
  maxDelta: null,
136
136
  medianDelta: null,
137
+ medianFrameInterval: null,
137
138
  meanDelta: null,
138
139
  tolerance: null,
139
140
  minFps: null,
@@ -235,6 +236,8 @@ function applyHistogram(base, hist, tickQuantized) {
235
236
  base.minDelta = minD;
236
237
  base.maxDelta = maxD;
237
238
  base.medianDelta = median;
239
+ const interval = rateFromTicks(ts, median);
240
+ base.medianFrameInterval = interval && makeRational(interval.den, interval.num);
238
241
  base.meanDelta = sum / total;
239
242
  base.tolerance = tol;
240
243
  base.minFps = rateFromTicks(ts, maxD);
@@ -17,7 +17,7 @@ export type ContainerKind = 'mp4' | 'mov' | 'webm' | 'matroska' | 'unknown';
17
17
  export type EngineId = 'own' | 'mediabunny' | 'mp4box' | 'rvfc';
18
18
  export type MatroskaMode = 'header' | 'probe' | 'exact' | 'auto';
19
19
  export type ScanDepth = 'header' | 'probe' | 'exact';
20
- export type TrackField = 'codec' | 'dimensions' | 'rotation' | 'frames' | 'presentedFrames' | 'durationSec' | 'startSec' | 'avgFps' | 'nominalFps' | 'dominantFps' | 'minFps' | 'maxFps' | 'isVfr' | 'duplicateTimestamps';
20
+ export type TrackField = 'codec' | 'dimensions' | 'rotation' | 'frames' | 'presentedFrames' | 'durationSec' | 'startSec' | 'avgFps' | 'nominalFps' | 'dominantFps' | 'minFps' | 'maxFps' | 'isVfr' | 'duplicateTimestamps' | 'medianFrameInterval';
21
21
  export type RateSegmentKind = 'steady' | 'variable';
22
22
  export interface RateSegment {
23
23
  startSec: number;
@@ -74,6 +74,8 @@ export interface VideoTrackResult {
74
74
  timestampsQuantized: boolean;
75
75
  isStill: boolean;
76
76
  rateProfile?: RateProfile | null;
77
+ medianFrameInterval?: Rational | null;
78
+ enabled?: boolean;
77
79
  provenance: Partial<Record<TrackField, Provenance>>;
78
80
  warnings: string[];
79
81
  }
@@ -82,6 +84,16 @@ export interface IoStats {
82
84
  bytesRead: number;
83
85
  ms: number;
84
86
  }
87
+ export type FileErrorCode = 'empty-file' | 'unrecognised' | 'unsupported-container' | 'unsupported' | 'too-large' | 'no-header' | 'truncated' | 'corrupt' | 'no-duration' | 'byte-limit' | 'read-limit' | 'time-limit' | 'structure-limit' | 'read-failed' | 'load-failed' | 'engine-failed' | 'internal';
88
+ export type LayoutKind = 'faststart' | 'moov-at-end' | 'fragmented';
89
+ export interface ContainerLayout {
90
+ kind: LayoutKind;
91
+ moovOffset: number | null;
92
+ moovSize: number | null;
93
+ sidx: boolean;
94
+ emptyMoov: boolean;
95
+ }
96
+ export type WarningCode = 'invalid-timescale' | 'truncated-mdat' | 'truncated-box' | 'trailing-data' | 'fragment-index-unread' | 'duration-unresolved' | 'fragments-ignored' | 'structure-limit';
85
97
  export interface FileResult {
86
98
  name: string;
87
99
  size: number;
@@ -93,6 +105,8 @@ export interface FileResult {
93
105
  audioTrackCount: number;
94
106
  primaryTrackId: number | null;
95
107
  error: string | null;
108
+ errorCode?: FileErrorCode | null;
109
+ layout?: ContainerLayout | null;
96
110
  warnings: string[];
97
111
  io: IoStats;
98
112
  engine: EngineId;
@@ -53,6 +53,12 @@ export function isAbort(e, signal) {
53
53
  export function newResult(name, size, engine) {
54
54
  return { name, size, ...emptyAnalysis('unknown'), io: { reads: 0, bytesRead: 0, ms: 0 }, engine };
55
55
  }
56
+ const printable = (warnings) => warnings.map((w) => w.replace(/[^\x20-\x7e\xa0-\uffff]/g, '?'));
57
+ export function printableWarnings(result) {
58
+ result.warnings = printable(result.warnings);
59
+ for (const t of result.videoTracks)
60
+ t.warnings = printable(t.warnings);
61
+ }
56
62
  export function ffprobeRotation(matrix) {
57
63
  const a = matrix[0] / 65536;
58
64
  const b = matrix[1] / 65536;
@@ -102,6 +108,7 @@ export function applyStats(track, stats, source = 'exact') {
102
108
  track.dominantFps = stats.dominantFps;
103
109
  track.minFps = stats.minFps;
104
110
  track.maxFps = stats.maxFps;
111
+ track.medianFrameInterval = stats.medianFrameInterval;
105
112
  track.isVfr = stats.isVfr;
106
113
  track.vfrRatio = stats.vfrRatio;
107
114
  track.distinctDeltas = stats.distinctDeltas;
@@ -120,5 +127,6 @@ export function applyStats(track, stats, source = 'exact') {
120
127
  maxFps: source,
121
128
  isVfr: source,
122
129
  duplicateTimestamps: source,
130
+ medianFrameInterval: source,
123
131
  });
124
132
  }
@@ -3,7 +3,7 @@ import { throwIfAborted } from "../core/abort.js";
3
3
  import { resolveOptions } from "../core/options.js";
4
4
  import { emptyTrack } from "../core/result.js";
5
5
  import { analyzeTimeline } from "../core/stats.js";
6
- import { applyStats, approxRational, codecNameFromFourcc, errorMessage, isAbort, newResult } from "./common.js";
6
+ import { applyStats, approxRational, codecNameFromFourcc, errorMessage, isAbort, newResult, printableWarnings } from "./common.js";
7
7
  import { CountingBlob, elapsed, now } from "./counting.js";
8
8
  export const MEDIABUNNY_VERSION = '1.62.1';
9
9
  const CODEC_NAMES = {
@@ -145,7 +145,18 @@ async function probe(counted, result, opts, t0) {
145
145
  ];
146
146
  const source = new CustomSource({
147
147
  getSize: () => counted.size,
148
- read: async (start, end) => new Uint8Array(await counted.slice(start, end).arrayBuffer()),
148
+ read: async (start, end) => {
149
+ let ab;
150
+ try {
151
+ ab = await counted.slice(start, end).arrayBuffer();
152
+ }
153
+ catch (e) {
154
+ throw isAbort(e, signal) ? e : Error(`file read failed: ${e}`);
155
+ }
156
+ if (ab.byteLength !== end - start)
157
+ throw Error(`file read failed: short read at ${start}: expected ${end - start} bytes, got ${ab.byteLength}`);
158
+ return new Uint8Array(ab);
159
+ },
149
160
  prefetchProfile: 'fileSystem',
150
161
  handleUnhandledError: (e) => {
151
162
  unhandled.push(errorMessage(e));
@@ -160,7 +171,8 @@ async function probe(counted, result, opts, t0) {
160
171
  catch (e) {
161
172
  if (isAbort(e, signal))
162
173
  throw e;
163
- result.error = `not a recognised container: ${errorMessage(e)}`;
174
+ const message = errorMessage(e);
175
+ result.error = message.startsWith('file read failed') ? message : `not a recognised container: ${message}`;
164
176
  return;
165
177
  }
166
178
  result.container = containerOf(format);
@@ -176,7 +188,7 @@ async function probe(counted, result, opts, t0) {
176
188
  works.push(await collectTrack(track, quantized, signal, maxPackets));
177
189
  }
178
190
  catch (e) {
179
- if (isAbort(e, signal))
191
+ if (isAbort(e, signal) || errorMessage(e).startsWith('file read failed'))
180
192
  throw e;
181
193
  result.warnings.push(`track ${track.id}: ${errorMessage(e)}`);
182
194
  }
@@ -235,6 +247,7 @@ export async function analyzeWithMediabunny(blob, name, opts = {}) {
235
247
  }
236
248
  if (result.mediabunny === undefined)
237
249
  result.io = counted.io(t0);
250
+ printableWarnings(result);
238
251
  return result;
239
252
  }
240
253
  export const containers = 'any';
@@ -5,7 +5,7 @@ import { BlobReader, fourcc, u32, u64 } from "../core/reader.js";
5
5
  import { emptyTrack } from "../core/result.js";
6
6
  import { sniff } from "../core/sniff.js";
7
7
  import { analyzeTimeline } from "../core/stats.js";
8
- import { applyStats, codecNameFromFourcc, errorMessage, ffprobeRotation, isAbort, newResult } from "./common.js";
8
+ import { applyStats, codecNameFromFourcc, errorMessage, ffprobeRotation, isAbort, newResult, printableWarnings } from "./common.js";
9
9
  import { CountingBlob, now } from "./counting.js";
10
10
  export const MP4BOX_VERSION = '2.4.1';
11
11
  const MAX_TOP_LEVEL_BOXES = 5_000_000;
@@ -423,6 +423,7 @@ export async function analyzeWithMp4box(blob, name, opts = {}) {
423
423
  result.error = errorMessage(e);
424
424
  }
425
425
  result.io = counted.io(t0);
426
+ printableWarnings(result);
426
427
  return result;
427
428
  }
428
429
  export const containers = ['isobmff'];
@@ -0,0 +1,25 @@
1
+ import { type BlobLike } from '../core/reader.js';
2
+ import type { FileResult, IoStats } from '../core/types.js';
3
+ import { type HeaderLimits } from './limits.js';
4
+ export { errorClass, errorCodeOf, warningCodeOf, warningCodesOf, type ErrorClass } from '../api/codes.js';
5
+ export { isAbortError, isTimeoutError } from '../api/guards.js';
6
+ export { HEADER_DEFAULT_LIMITS, type HeaderLimits } from './limits.js';
7
+ export type { BlobLike, BlobSlice } from '../core/reader.js';
8
+ export type * from '../core/types.js';
9
+ export type FragmentedDuration = 'header' | 'index';
10
+ export interface HeaderOptions {
11
+ name?: string;
12
+ signal?: AbortSignal | null;
13
+ limits?: HeaderLimits;
14
+ readAhead?: {
15
+ head?: number;
16
+ tail?: number;
17
+ };
18
+ fragmentedDuration?: FragmentedDuration;
19
+ }
20
+ export type HeaderResult = FileResult & {
21
+ io: IoStats & {
22
+ bytesRequested: number;
23
+ };
24
+ };
25
+ export declare function analyzeHeader(source: Blob | BlobLike, options?: HeaderOptions): Promise<HeaderResult>;
@@ -0,0 +1,146 @@
1
+ import { throwIfAborted } from "../core/abort.js";
2
+ import { analyzeIsoBmffHeader } from "../core/isobmff/header.js";
3
+ import { DEFAULT_READ_AHEAD } from "../core/options.js";
4
+ import { BlobReader, whichErrorCode } from "../core/reader.js";
5
+ import { emptyAnalysis } from "../core/result.js";
6
+ import { sniff } from "../core/sniff.js";
7
+ import { HEADER_DEFAULT_LIMITS, LimitedSource } from "./limits.js";
8
+ export { errorClass, errorCodeOf, warningCodeOf, warningCodesOf } from "../api/codes.js";
9
+ export { isAbortError, isTimeoutError } from "../api/guards.js";
10
+ export { HEADER_DEFAULT_LIMITS } from "./limits.js";
11
+ const SNIFF_BYTES = 1024;
12
+ const MIN_HEAD = SNIFF_BYTES;
13
+ const MIN_TAIL = 16;
14
+ function shown(value) {
15
+ if (typeof value === 'string')
16
+ return JSON.stringify(value);
17
+ if (typeof value === 'object' && value !== null)
18
+ return Array.isArray(value) ? 'an array' : 'an object';
19
+ return String(value);
20
+ }
21
+ function count(value, what, min = 0, infinite = false) {
22
+ if (infinite && value === Number.POSITIVE_INFINITY)
23
+ return value;
24
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < min) {
25
+ throw new TypeError(`${what} must be a non-negative safe integer${min > 0 ? ` of at least ${min}` : ''}${infinite ? ' or Infinity' : ''}, got ${shown(value)}`);
26
+ }
27
+ return value;
28
+ }
29
+ function record(value, what) {
30
+ if (value === undefined)
31
+ return {};
32
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
33
+ throw new TypeError(`${what} must be an object, got ${shown(value)}`);
34
+ return value;
35
+ }
36
+ function resolveHeaderOptions(source, options) {
37
+ const src = source;
38
+ if (typeof src !== 'object' || src === null || typeof src.slice !== 'function')
39
+ throw new TypeError(`source must be a Blob or a BlobLike with a size and a slice method, got ${shown(source)}`);
40
+ const size = count(src.size, 'source.size');
41
+ const o = record(options, 'options');
42
+ if (o.name !== undefined && typeof o.name !== 'string')
43
+ throw new TypeError(`name must be a string, got ${shown(o.name)}`);
44
+ const signal = o.signal ?? null;
45
+ if (signal !== null) {
46
+ const s = signal;
47
+ if (typeof s !== 'object' || typeof s.aborted !== 'boolean' || typeof s.addEventListener !== 'function' || typeof s.removeEventListener !== 'function')
48
+ throw new TypeError(`signal must be an AbortSignal, got ${shown(signal)}`);
49
+ }
50
+ const l = record(o.limits, 'limits');
51
+ const limits = { ...HEADER_DEFAULT_LIMITS };
52
+ for (const key of Object.keys(HEADER_DEFAULT_LIMITS))
53
+ if (l[key] !== undefined)
54
+ limits[key] = count(l[key], `limits.${key}`, 0, true);
55
+ const r = record(o.readAhead, 'readAhead');
56
+ for (const key of Object.keys(r))
57
+ if (key !== 'head' && key !== 'tail' && r[key] !== undefined)
58
+ throw new TypeError(`readAhead takes only head and tail, got ${key}`);
59
+ const head = r.head === undefined ? DEFAULT_READ_AHEAD.head : count(r.head, 'readAhead.head', MIN_HEAD);
60
+ const tail = r.tail === undefined ? DEFAULT_READ_AHEAD.tail : count(r.tail, 'readAhead.tail', MIN_TAIL);
61
+ const mode = o.fragmentedDuration ?? 'index';
62
+ if (mode !== 'index' && mode !== 'header')
63
+ throw new TypeError(`fragmentedDuration must be 'header' or 'index', got ${shown(mode)}`);
64
+ const name = o.name ?? (typeof File !== 'undefined' && source instanceof File ? source.name : '');
65
+ return { name, size, signal: signal, limits, head, tail, index: mode === 'index' };
66
+ }
67
+ function docTypeOf(head) {
68
+ let p = 4;
69
+ const vint = (marker) => {
70
+ if (p >= head.length)
71
+ return null;
72
+ const first = head[p];
73
+ let len = 1;
74
+ while (len <= 8 && !(first & (0x80 >> (len - 1))))
75
+ len++;
76
+ if (len > 8 || p + len > head.length)
77
+ return null;
78
+ let v = marker ? first : first & (0xff >> len);
79
+ for (let i = 1; i < len; i++)
80
+ v = v * 256 + head[p + i];
81
+ p += len;
82
+ return v;
83
+ };
84
+ const size = vint(false);
85
+ if (size === null)
86
+ return null;
87
+ const end = Math.min(head.length, p + size);
88
+ while (p < end) {
89
+ const id = vint(true);
90
+ const n = vint(false);
91
+ if (id === null || n === null || p + n > end)
92
+ return null;
93
+ if (id === 0x4282)
94
+ return String.fromCharCode(...head.subarray(p, p + n)).replace(/\0+$/, '');
95
+ p += n;
96
+ }
97
+ return null;
98
+ }
99
+ function failed(analysis, message, code) {
100
+ analysis.error = message;
101
+ analysis.errorCode = code;
102
+ return analysis;
103
+ }
104
+ async function analyzeSource(reader, limited, o) {
105
+ const analysis = { ...emptyAnalysis('unknown'), scan: 'header', errorCode: null, layout: null };
106
+ const size = reader.size;
107
+ if (size === 0)
108
+ return failed(analysis, 'not a recognised container: empty file', 'empty-file');
109
+ const head = await reader.bytes(0, Math.min(SNIFF_BYTES, size), Math.min(size, o.head, limited.bytesLeft));
110
+ const sniffed = sniff(head);
111
+ if (sniffed.kind === 'unknown')
112
+ return failed(analysis, `not a recognised container: ${sniffed.reason}`, 'unrecognised');
113
+ if (sniffed.kind === 'matroska') {
114
+ analysis.container = docTypeOf(head) === 'webm' ? 'webm' : 'matroska';
115
+ return failed(analysis, `container not read by the header entry: ${analysis.container}`, 'unsupported-container');
116
+ }
117
+ const { maxTracks, maxTopLevelBoxes } = o.limits;
118
+ return analyzeIsoBmffHeader(reader, limited, { head: o.head, tail: o.tail, maxTracks, maxTopLevelBoxes, index: o.index });
119
+ }
120
+ export async function analyzeHeader(source, options = {}) {
121
+ const t0 = performance.now();
122
+ const o = resolveHeaderOptions(source, options);
123
+ throwIfAborted(o.signal);
124
+ const limited = new LimitedSource(source, o.size, o.limits, o.signal);
125
+ const reader = new BlobReader(limited, { headWindow: o.head, signal: o.signal });
126
+ let analysis;
127
+ try {
128
+ analysis = await analyzeSource(reader, limited, o);
129
+ }
130
+ catch (e) {
131
+ throwIfAborted(o.signal);
132
+ const limit = limited.take();
133
+ analysis = { ...emptyAnalysis('unknown'), scan: 'header', errorCode: null, layout: null };
134
+ failed(analysis, limit ? limit.message : e instanceof Error ? e.message || e.name : String(e), whichErrorCode(limit ?? e));
135
+ }
136
+ finally {
137
+ limited.dispose();
138
+ }
139
+ return {
140
+ name: o.name,
141
+ size: o.size,
142
+ ...analysis,
143
+ io: { reads: reader.reads, bytesRead: reader.bytesFetched, ms: performance.now() - t0, bytesRequested: limited.bytesRequested },
144
+ engine: 'own',
145
+ };
146
+ }
@@ -0,0 +1,25 @@
1
+ import type { BlobLike, BlobSlice } from '../core/reader.js';
2
+ export interface HeaderLimits {
3
+ maxBytes?: number;
4
+ maxReads?: number;
5
+ maxMs?: number;
6
+ maxTracks?: number;
7
+ maxTopLevelBoxes?: number;
8
+ }
9
+ export declare const HEADER_DEFAULT_LIMITS: Readonly<Required<HeaderLimits>>;
10
+ export type LimitCode = 'byte-limit' | 'read-limit' | 'time-limit' | 'structure-limit';
11
+ export declare class LimitError extends Error {
12
+ readonly errorCode: LimitCode;
13
+ constructor(code: LimitCode, message: string);
14
+ }
15
+ export declare class LimitedSource implements BlobLike {
16
+ #private;
17
+ readonly size: number;
18
+ constructor(inner: BlobLike, size: number, limits: Required<Pick<HeaderLimits, 'maxBytes' | 'maxReads' | 'maxMs'>>, caller: AbortSignal | null);
19
+ get bytesLeft(): number;
20
+ get bytesRequested(): number;
21
+ checkTime(): void;
22
+ take(): LimitError | null;
23
+ slice(start?: number, end?: number): BlobSlice;
24
+ dispose(): void;
25
+ }
@@ -0,0 +1,140 @@
1
+ export const HEADER_DEFAULT_LIMITS = Object.freeze({
2
+ maxBytes: 16 * 1024 * 1024,
3
+ maxReads: 8,
4
+ maxMs: 10_000,
5
+ maxTracks: 64,
6
+ maxTopLevelBoxes: 1024,
7
+ });
8
+ export class LimitError extends Error {
9
+ constructor(code, message) {
10
+ super(message);
11
+ this.name = 'LimitError';
12
+ this.errorCode = code;
13
+ }
14
+ }
15
+ const noop = () => { };
16
+ const MAX_TIMER_MS = 0x7fffffff;
17
+ function sourceError(message) {
18
+ return Object.assign(new Error(message), { name: '' });
19
+ }
20
+ function bufferOf(value) {
21
+ if (ArrayBuffer.isView(value)) {
22
+ const bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
23
+ return bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength && bytes.buffer instanceof ArrayBuffer ? bytes.buffer : bytes.slice().buffer;
24
+ }
25
+ if (typeof value?.byteLength !== 'number')
26
+ throw sourceError(`the source returned ${value === null ? 'null' : typeof value} instead of bytes`);
27
+ return value;
28
+ }
29
+ export class LimitedSource {
30
+ size;
31
+ #inner;
32
+ #maxBytes;
33
+ #maxReads;
34
+ #maxMs;
35
+ #deadline;
36
+ #controller = new AbortController();
37
+ #stopped;
38
+ #caller;
39
+ #forward = () => this.#controller.abort(this.#caller?.reason);
40
+ #timer = null;
41
+ #timedOut = false;
42
+ #reads = 0;
43
+ #bytes = 0;
44
+ #tripped = null;
45
+ constructor(inner, size, limits, caller) {
46
+ this.size = size;
47
+ this.#inner = inner;
48
+ this.#maxBytes = limits.maxBytes;
49
+ this.#maxReads = limits.maxReads;
50
+ this.#maxMs = limits.maxMs;
51
+ this.#deadline = performance.now() + limits.maxMs;
52
+ this.#caller = caller;
53
+ const internal = this.#controller.signal;
54
+ this.#stopped = new Promise((_, reject) => internal.addEventListener('abort', () => reject(internal.reason), { once: true }));
55
+ this.#stopped.catch(noop);
56
+ if (caller) {
57
+ if (caller.aborted)
58
+ this.#forward();
59
+ else
60
+ caller.addEventListener('abort', this.#forward, { once: true });
61
+ }
62
+ if (Number.isFinite(limits.maxMs) && !internal.aborted)
63
+ this.#arm(limits.maxMs);
64
+ }
65
+ #arm(left) {
66
+ const step = Math.min(left, MAX_TIMER_MS);
67
+ this.#timer = setTimeout(() => {
68
+ if (left > step)
69
+ return this.#arm(left - step);
70
+ this.#timer = null;
71
+ this.#timedOut = true;
72
+ this.#controller.abort(new LimitError('time-limit', `limit reached: maxMs ${this.#maxMs}`));
73
+ }, step);
74
+ }
75
+ get bytesLeft() {
76
+ return this.#maxBytes - this.#bytes;
77
+ }
78
+ get bytesRequested() {
79
+ return this.#bytes;
80
+ }
81
+ checkTime() {
82
+ if (this.#timedOut || performance.now() >= this.#deadline)
83
+ throw this.#trip('time-limit', `limit reached: maxMs ${this.#maxMs}`);
84
+ }
85
+ take() {
86
+ const t = this.#tripped;
87
+ this.#tripped = null;
88
+ return t;
89
+ }
90
+ slice(start = 0, end = this.size) {
91
+ return { arrayBuffer: () => this.#fetch(start, end) };
92
+ }
93
+ dispose() {
94
+ if (this.#timer !== null)
95
+ clearTimeout(this.#timer);
96
+ this.#timer = null;
97
+ this.#caller?.removeEventListener('abort', this.#forward);
98
+ }
99
+ #trip(code, message) {
100
+ this.#tripped = new LimitError(code, message);
101
+ return this.#tripped;
102
+ }
103
+ #admission(bytes, offset) {
104
+ if (this.#reads + 1 > this.#maxReads)
105
+ return this.#trip('read-limit', `limit reached: maxReads ${this.#maxReads}`);
106
+ if (this.#bytes + bytes > this.#maxBytes)
107
+ return this.#trip('byte-limit', `limit reached: maxBytes ${this.#maxBytes} (${this.#bytes + bytes - this.#maxBytes} more bytes needed at offset ${offset})`);
108
+ try {
109
+ this.checkTime();
110
+ }
111
+ catch (e) {
112
+ return e;
113
+ }
114
+ return null;
115
+ }
116
+ #fetch(start, end) {
117
+ const length = Math.max(0, end - start);
118
+ const trip = this.#admission(length, start);
119
+ if (trip)
120
+ return Promise.reject(trip);
121
+ this.#reads++;
122
+ this.#bytes += length;
123
+ let pending;
124
+ try {
125
+ const inner = this.#inner;
126
+ pending = Promise.resolve(typeof inner.read === 'function' ? inner.read(start, end, this.#controller.signal) : inner.slice(start, end).arrayBuffer()).then(bufferOf);
127
+ }
128
+ catch (e) {
129
+ pending = Promise.reject(e);
130
+ }
131
+ pending.catch(noop);
132
+ return Promise.race([pending, this.#stopped]).catch((e) => {
133
+ if (e instanceof LimitError)
134
+ this.#tripped = e;
135
+ else if (!this.#controller.signal.aborted && e?.name === 'AbortError')
136
+ throw sourceError(String(e));
137
+ throw e;
138
+ });
139
+ }
140
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { analyze, type InThreadOptions } from './analyze.js';
2
2
  export { compareResults, type Agreement, type ComparedField, type ComparedFieldName, type ComparedValue } from './api/agreement.js';
3
+ export { errorClass, errorCodeOf, warningCodeOf, warningCodesOf, type ErrorClass } from './api/codes.js';
3
4
  export { ProbeError, type ProbeErrorCode } from './api/errors.js';
4
5
  export { isAbortError, isTimeoutError } from './api/guards.js';
5
6
  export { summarize, type VideoFpsSummary } from './api/summary.js';