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
package/CHANGELOG.md CHANGED
@@ -4,6 +4,100 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions follo
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.4.0-beta.1 - 2026-10-31
8
+
9
+ ### Added
10
+
11
+ - `errorCode` on every own result: a code for `error` from a closed list, such as `no-header`, `truncated` or `read-failed`, null exactly when `error` is null. Every `error` text is unchanged ([error codes](docs/api.md#error-codes)).
12
+ - `errorCodeOf`, `errorClass`, `warningCodeOf` and `warningCodesOf` in the root entry, for results of any engine or version, with stable warning codes: `invalid-timescale`, `truncated-mdat`, `truncated-box` and `trailing-data` from the parsers, `fragment-index-unread`, `duration-unresolved` and `fragments-ignored` from the header entry, and `structure-limit` for `too many top-level boxes` ([error codes](docs/api.md#error-codes)).
13
+ - `layout` on ISO BMFF results: `faststart`, `moov-at-end` or `fragmented`, the moov offset and size, a sidx before the first moof, and an empty moov.
14
+ - `medianFrameInterval` on video tracks of own, mp4box and mediabunny, and `enabled` (tkhd or FlagEnabled) on own tracks. `TrackField` gains `'medianFrameInterval'`, so an exhaustive `switch` needs the case.
15
+ - `video-fps-probe/header`: `analyzeHeader(source, options)` reads only the header of an MP4 or MOV file, in the calling thread, under limits on the bytes requested, the reads, the wall time, the tracks and the top-level boxes (`HEADER_DEFAULT_LIMITS`: 16 MiB, 8 reads, 10 s, 64 tracks and 1024 boxes). A fragmented file gets its duration from a covering sidx, the mfra index or mehd, and new codes name what stopped it: `no-duration`, `byte-limit`, `read-limit`, `time-limit` and `structure-limit`. It rejects only on the caller's abort and on an invalid option, with a `TypeError`, and adds at most 1 byte to the other entries ([header-only analysis](docs/api.md#header-only-analysis)). `readAhead` takes only `head` and `tail`, so the `fragment` or `matroskaScan` of `analyze` there is a `TypeError` rather than a silent no-op, and the options and `limits` ignore keys they do not know, so an option or a limit of a later version does not fail here. The entry also exports `isAbortError` and `isTimeoutError`. The signature, the members of `HeaderOptions` and `HeaderLimits`, these rules for keys, the error and warning codes and these exports are frozen from 0.4.0-beta.1.
16
+ - `video-fps-probe/url`: `rangeSource(fetcher, { size })`, `openRangeSource(fetcher)`, `httpRange(url)`, `readCapped(body, expectedLength)` and `RangeSourceError` turn any function that fetches a byte range, such as `fetch` with a presigned URL or the client of an object-storage SDK, into a source for `analyzeHeader` and the in-thread `analyze`. No SDK dependency, one request per read and no retries. Every reply is checked before its body is read, its status, its `Content-Range` with the object size and its `Content-Length`, and the body is capped while it streams, so a server that ignores the range, an object whose size changed or an oversized body fails the read as `read-failed` instead of giving wrong bytes. An object overwritten at the same size is caught only by an `If-Match` with its ETag, which the caller sends. A failed or aborted body is discarded, and a Node stream body is destroyed, so its socket closes. `openRangeSource` learns the size from one request for the first 64 KiB, accepts the clamped 206 of a smaller object and serves the first read of `analyzeHeader` from those bytes, so the requests equal `io.reads`. `RangeSourceError` carries a `code`, such as `unexpected-status` or `size-changed`, and the `status` ([Range source](docs/api.md#range-source)).
17
+ - A CommonJS copy of the five server entries, `video-fps-probe`, `video-fps-probe/header`, `video-fps-probe/url`, `video-fps-probe/check` and `video-fps-probe/presets`, minified with the names of classes and functions kept, so `error.name`, `error.constructor.name` and the export names equal the ESM build, with `.d.cts` types, under the conditions `module-sync`, `module` and `require`, and `main`. Node 22.12 and newer still load one ESM instance from `require()` and bundlers that match the `module` condition, as Vite, webpack, Rspack and esbuild do by default, still resolve the ESM build, so the copy serves only `require()` where `require(esm)` is off. Jest 30 runs these entries with no configuration and `jest` as the only dependency, and with `--experimental-vm-modules` on Node 24.9 and newer it loads the ESM build ([CommonJS](docs/api.md#commonjs), [Jest](docs/api.md#jest)).
18
+ - The types of `video-fps-probe/header`, `video-fps-probe/url`, `video-fps-probe/check` and `video-fps-probe/presets` name no DOM type, so a server that imports only those compiles with `"lib": ["ES2022"]` and the node types, without `skipLibCheck`.
19
+
20
+ ### Frozen from 0.4.0-beta.1
21
+
22
+ These are API from 0.4.0-beta.1 on, and 0.4.0-beta.2, 0.4.0 and every later release only add to them ([versioning](docs/api.md#versioning)):
23
+
24
+ - `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` and `internal`, with the `ErrorClass` of each.
25
+ - `WarningCode`: `invalid-timescale`, `truncated-mdat`, `truncated-box`, `trailing-data`, `fragment-index-unread`, `duration-unresolved`, `fragments-ignored` and `structure-limit`, with the warning prefixes they classify. The four parser prefixes are `invalid mdhd timescale ` or `invalid timescale `, `'mdat' at <offset> is truncated`, `'<type>' at <offset> is truncated` and `ignored trailing data at `, and the header entry adds `fragment index not read: `, `format duration unresolved: `, `fragments ignored: ` and `too many top-level boxes`.
26
+ - `LayoutKind`, `faststart`, `moov-at-end` and `fragmented`, and the members of `ContainerLayout`: `kind`, `moovOffset`, `moovSize`, `sidx` and `emptyMoov`.
27
+ - The members of `HeaderLimits`, `maxBytes`, `maxReads`, `maxMs`, `maxTracks` and `maxTopLevelBoxes`, and of `HeaderOptions`, `name`, `signal`, `limits`, `readAhead` with `head` and `tail` only and `fragmentedDuration`, with the export `HEADER_DEFAULT_LIMITS` and its keys, the signature `analyzeHeader(source: Blob | BlobLike, options?: HeaderOptions): Promise<HeaderResult>`, and the exports `isAbortError` and `isTimeoutError` of `video-fps-probe/header`. A default value of `HEADER_DEFAULT_LIMITS` may change later, as a behaviour change that the changelog names.
28
+ - How `analyzeHeader` treats keys: `readAhead` refuses any key but `head` and `tail` with a `TypeError`, and the options and `limits` ignore keys they do not know.
29
+ - The whole `video-fps-probe/url` API: `rangeSource`, `openRangeSource`, `httpRange`, `readCapped`, `RangeSourceError` with its `code` and `status`, the `RangeSourceErrorCode` values `unverifiable-reply`, `unexpected-status`, `range-mismatch`, `size-changed`, `length-mismatch`, `body-too-long`, `body-too-short` and `invalid-reply`, and the types `RangeRequest`, `RangeReply`, `RangeFetcher`, `RangeSource`, `RangeSourceInit`, `OpenRangeSourceInit`, `HttpRangeInit` and `RangeSourceErrorOptions`, with `BlobLike` and `BlobSlice`, the types of the root entry that it re-exports unchanged.
30
+
31
+ `ColorInfo` and `DolbyVisionConfig` join this list at 0.4.0-beta.2.
32
+
33
+ ### Changed
34
+
35
+ - The installed package may take up to 867 KB unpacked (580 KB in 0.3.0), measured at 842 KB: about 80 KB more for the error codes, layout, median frame interval, the header-only entry, the Range source and the longer README and CHANGELOG, and about 190 KB for the CommonJS copy of the server entries, with the names of its classes and functions kept, and its declarations, which took the cap from 632 KB to 867 KB, 3 percent above the measurement. A bundler still takes only the modules a host imports, and no browser bundle holds the CommonJS copy.
36
+ - Node 22.12 is the lowest supported version, as in 0.3.0, and now the only line the docs name: Node 20 is past its end of life.
37
+ - The first MP4 loads 23.07 KB gzip (22.73 KB in 0.3.0), 24.85 KB with Vite's default worker format (24.47 KB), and the first WebM or MKV 25.73 KB (25.54 KB) ([size matrix](report/size-matrix.md)).
38
+ - Still 0 mismatches against ffprobe 8.1.1, now on 168 committed fixtures, with 2 known deviations on a new `+cmaf+global_sidx` fixture with B-frames: ffprobe adds the largest negative composition offset of the fragments it reads to every video timestamp, and own does not yet, so its video starts at 0 instead of 0.066667 s and the file ends 0.045 s earlier ([accuracy](docs/benchmarks.md#accuracy-vs-ffprobe)).
39
+
40
+ ### Fixed
41
+
42
+ - A `readAhead.tail` that is NaN no longer spins the Matroska probe forever: it scans the smallest tail, as every value below 4096 does.
43
+
44
+ ### Known limitations
45
+
46
+ - In a browser `httpRange` checks the `Content-Range` of a cross-origin reply only when the server lists it in `Access-Control-Expose-Headers`, and the status and length checks remain. `openRangeSource` needs that header, or a known `size`, because discovery learns the size from it, and rejects with `unverifiable-reply` without it.
47
+ - The Range source checks the size of the object on every read, so an overwrite at the same size between two reads is caught only by an `If-Match` that the caller sends. `httpRange` does not keep the ETag of its first reply.
48
+ - Full mode reads every fragment, so on a `+cmaf+global_sidx` output of several fragments where every track has a sidx that covers the file and the last AAC fragment runs past its sidx, it reports that end, 1024 samples later than ffprobe 8.1.1, for example 20.521333 s against 20.5 s, as in 0.3.0. `analyzeHeader` gives the ffprobe value there. On ffmpeg's `+cmaf+global_sidx` output with a `mov_text` or `-timecode` track, whose sidx is written last and ends early, ffprobe reads every fragment too, and both modes give its value. On `+cmaf+global_sidx` files patched so that the one sidx left before the first moof covers the file and another track has none, ffprobe 8.1.1 gave the sidx end for every stream in the cases measured, 6.0 s where full mode gives 6.021333 s. Whether that holds as a rule is a hypothesis with counter-cases, so `analyzeHeader` gives the full-mode value there through the mfra, or without one keeps the video duration and leaves `formatDurationSec` null with a warning ([notes](docs/implementation-notes.md#what-the-header-walk-reads)).
49
+
50
+ ## 0.3.0 - 2026-10-30
51
+
52
+ ### Added
53
+
54
+ - `byContainer` on a custom worker: `{ create, engines, byContainer: { matroska: () => new Worker(...) } }` runs own on the worker of the file's container family, and every other engine and family on `create` ([api](docs/api.md#createprobeclient-client-flavor)).
55
+ - `video-fps-probe/standalone/own-isobmff.worker.js` and `own-matroska.worker.js`: the two own workers as classic scripts with no imports, about 21.1 and 23.7 KB gzip, for `byContainer`. The combined `own.worker.js` stays.
56
+ - `video-fps-probe/presets/sources.json`: the source page of every preset, keyed by the name of its export, such as `sources.klingO1ReferenceVideo` ([presets](docs/api.md#presets)).
57
+ - CommonJS is tested from the packed tarball: `require()` on Node 22.12 and 24, a TypeScript `commonjs` and `node10` project as in NestJS 11, and Jest 30 with babel-jest ([Jest](docs/api.md#jest)).
58
+ - Issue templates for bugs and feature requests, and GitHub Discussions for questions and for showing how you use the package ([feedback](README.md#feedback)).
59
+
60
+ ### Changed
61
+
62
+ - **Behaviour change:** own runs on one worker per container family, `own-isobmff.worker` or `own-matroska.worker`, picked by the file's first 4 bytes, so a first file loads only the parser of its family.
63
+ - The first MP4 loads about 22.7 KB gzip (23.6 KB in 0.2.0) and the first WebM or MKV without DefaultDuration 25.5 KB (26.7 KB). With Vite's default worker format they load 24.5 and 27.1 KB instead of 38.9 KB ([size matrix](report/size-matrix.md)).
64
+ - `workers` counts per worker kind, so `workers: 1` can run one MP4 and one WebM at once. `prewarm()` starts the worker of each container family it is given, ISO BMFF by default.
65
+ - A missing own worker file, such as a 404 after a redeploy, rejects only the jobs of its container family with `reason: 'load'`, and the next call retries.
66
+ - Service worker precache lists that name `own.worker` need both new worker files. A custom worker that runs `serveProbeWorker({ own: ownEngine })` from `video-fps-probe/worker-runtime` keeps the 0.2.0 layout.
67
+ - `lazyProbeClient` and `createProbeClient` load the 0.4 KB routing by container family only with `byContainer`, so a custom worker without it loads 23.6 KB for the first MP4 (23.5 KB in 0.2.0).
68
+ - The package is about 569 KB unpacked (442 KB in 0.2.0), mostly the two new standalone files, which a bundler takes only when imported.
69
+ - Still 0 mismatches against ffprobe 8.1.1, now on 130 committed fixtures ([accuracy](docs/benchmarks.md#accuracy-vs-ffprobe)).
70
+ - CI also tests Node 22.12, the lowest supported version. A weekly canary builds with the latest mp4box, mediabunny, Vite and rolldown and fails when an own result changes, an own size budget is exceeded or the build breaks, not when only mp4box or mediabunny code grows.
71
+
72
+ ### Deprecated
73
+
74
+ - `sourceUrl` of a preset: use `video-fps-probe/presets/sources.json`. Presets keep it until the next major, which leaves the URL strings that supply-chain scanners flag in `sources.json` only.
75
+
76
+ ### Fixed
77
+
78
+ - A version 1 `mehd` or `elst` holding 0xFFFFFFFFFFFFFFFF, an unknown duration, failed the whole file. `mehd` is now ignored, as ffprobe does, and such an edit lasts to the end of the media, with a warning.
79
+ - A top-level header after the moov that runs past the end of the file, such as the trailer of some phone MOV files, marked the file truncated. Unless it is an `mdat` or a `moof`, it now ends the walk with a warning, and samples past the end are still cut.
80
+ - Warnings could hold raw NUL and other control characters from the file, such as a box type. Every engine now replaces them with `?`.
81
+ - An unreadable file, such as a picked file deleted since (`NotReadableError`), now gives `file read failed: <error>` for every container and engine, not `corrupt file: ...` or the browser's bare message.
82
+ - A `ProbeError` from another copy of the package, such as one from an engine module or a custom probe given to `VideoFpsProvider`, became `worker-failed`. It now keeps its code everywhere ([errors](docs/api.md#errors)).
83
+ - `check` judged the `duration` of a VFR WebM or Matroska track by its measured span, up to one frame shorter than ffprobe's. It now judges `formatDurationSec` when it is within one typical frame interval of the span ([check](docs/api.md#check)).
84
+
85
+ ### Known limitations
86
+
87
+ - React 18 and 19 only: next to another React major npm refuses the install with `ERESOLVE` unless you pass `--legacy-peer-deps` or add `"overrides": { "video-fps-probe": { "react": "$react" } }`.
88
+ - Zero-config Vite (iife workers) builds each own worker as one file, so the first MP4 loads about 1.7 KB more than with `worker: { format: 'es' }`.
89
+ - Only Vite 8 and npm are tested in CI ([bundlers](docs/bundlers.md)). Browser floors (Chrome and Edge 94, Firefox 114, Safari 15.4) are derived, not tested.
90
+ - A TypeScript project for Node needs `"DOM"` in `lib` or `skipLibCheck: true`, and Jest needs babel-jest to transform the package ([Jest](docs/api.md#jest)).
91
+ - For a WebM or Matroska duration `check` takes the interval of `dominantFps`, `nominalFps` or `avgFps`, so a recording without a dominant rate uses its average interval, not its median.
92
+ - `check` judges the stored frame size: a `pasp` box or a Matroska DisplayWidth is not applied.
93
+ - The only real recordings in the corpus come from Chromium's MediaRecorder. More in [benchmarks](docs/benchmarks.md#limitations).
94
+
95
+ ### Not yet
96
+
97
+ - 0.3.x: a bundler matrix in CI: webpack 5, Rspack, Next.js, Angular CLI, SvelteKit, Nuxt, Parcel, Bun and Deno, with pnpm, Yarn PnP and bun installs.
98
+ - 0.4.0: a header-only ISO BMFF mode with byte, read and time limits, machine-readable error codes, a layout field, a Range source for object storage (a fetcher, no SDK), color, HDR and Dolby Vision basics, PAR and display size, a median frame interval for `check` and CommonJS server entries only.
99
+ - 0.5.0: a Node entry (`fromPath`, DOM-free types, a `worker_threads` pool), NestJS validators, a CLI, bitrate, GOP and audio details, cadence detection, Vue, Svelte and Angular adapters, and later MPEG-TS and HLS.
100
+
7
101
  ## 0.2.0 - 2026-10-25
8
102
 
9
103
  ### Added
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # video-fps-probe
2
2
 
3
- Frame rate, frame count, VFR detection and a rate profile for MP4, MOV, WebM and MKV files, read from the container headers in a Web Worker. Nothing is decoded and nothing leaves the browser, and the values match ffprobe 8.1.1 on the test corpus (0 mismatches). ESM only, no runtime dependencies, types included.
3
+ Frame rate, frame count, VFR detection and a rate profile for MP4, MOV, WebM and MKV files, read from the container headers in a Web Worker. Nothing is decoded and nothing leaves the browser, and the values match ffprobe 8.1.1 on the test corpus (0 mismatches). ESM with a CommonJS copy of the server entries, no runtime dependencies, types included.
4
4
 
5
5
  **[Live demo](https://vbondarets.github.io/video-fps-probe/)** · [API](docs/api.md) · [Bundlers](docs/bundlers.md) · [Benchmarks](docs/benchmarks.md) · [Architecture](docs/architecture.md) · [Changelog](CHANGELOG.md)
6
6
 
@@ -13,7 +13,7 @@ pnpm add video-fps-probe
13
13
  bun add video-fps-probe
14
14
  ```
15
15
 
16
- The first npm release is 0.2.0; one publish serves every package manager ([publishing](docs/publishing.md)). npm is the tested package manager: CI installs the packed tarball with npm. Yarn, pnpm and bun are expected to work but are not tested in CI yet (pnpm and Yarn PnP join the matrix in 0.3.0). The package has no dependencies. Its peers are optional and no package manager installs them for you: install one yourself, only for the entry you import: `mp4box` for `video-fps-probe/mp4box`, `mediabunny` for `video-fps-probe/mediabunny`, React 18 or 19 for `video-fps-probe/react`.
16
+ The first npm release is 0.2.0, and one publish serves every package manager ([publishing](docs/publishing.md)). npm is the tested package manager: CI installs the packed tarball with npm. Yarn, pnpm and bun are expected to work but are not tested in CI yet (pnpm, Yarn PnP and bun installs join the bundler matrix in 0.4.1). The package has no dependencies. Its peers are optional and no package manager installs them for you: install one yourself, only for the entry you import: `mp4box` for `video-fps-probe/mp4box`, `mediabunny` for `video-fps-probe/mediabunny`, React 18 or 19 for `video-fps-probe/react`.
17
17
 
18
18
  For a commit that is not released yet, install a packed tarball. A git dependency gets no code with npm (Yarn, pnpm and bun are not tested): `prepack` builds `dist/`, and npm does not run it for a git install.
19
19
 
@@ -41,7 +41,7 @@ track?.isVfr;
41
41
  track?.rateProfile?.summary; // for example 'Uneven: 5 fps for 4 s, 200 fps for 4 s'
42
42
  ```
43
43
 
44
- The first call loads the probe client, the worker and the parser of the sniffed container; `probe.prewarm()` loads them ahead, for example when an upload dialog opens. `createProbe()` from `video-fps-probe` is the probe without the stub; `summarize(result)` flattens the primary track.
44
+ The first call loads the probe client and the own worker of the file's container family, picked by its first 4 bytes, with the parser inside; `probe.prewarm()` loads them ahead, for example when an upload dialog opens. `createProbe()` from `video-fps-probe` is the probe without the stub; `summarize(result)` flattens the primary track.
45
45
 
46
46
  ## React
47
47
 
@@ -98,7 +98,15 @@ import { analyze } from 'video-fps-probe';
98
98
  const result = await analyze(await openAsBlob(path), { name: path }); // in the calling thread, no worker
99
99
  ```
100
100
 
101
- In-thread `analyze()` is tested on Node 24 only, the version in `.nvmrc` that every CI job uses. The example needs `fs.openAsBlob`, available since Node 19.8 and stable in Node 24. Files above 4 GiB need a `BlobLike` in Node 24 ([in-thread analyze](docs/api.md#in-thread-analyze)). The type declarations name DOM types such as `Worker` and `HTMLElement`, so a TypeScript project for Node needs `"DOM"` in `lib` or `skipLibCheck: true`.
101
+ In-thread `analyze()` is tested on Node 24, the version in `.nvmrc`, and on Node 22.12, the lowest supported version: CI runs the unit, fixture and package tests on both. The example needs `fs.openAsBlob`, available since Node 19.8 and stable in Node 24. Files above 4 GiB need a `BlobLike` in Node 24 ([in-thread analyze](docs/api.md#in-thread-analyze)). The type declarations of the root entry name DOM types such as `Worker` and `HTMLElement`, so a TypeScript project for Node that imports it needs `"DOM"` in `lib` or `skipLibCheck: true`. The server entries `video-fps-probe/header`, `video-fps-probe/url`, `video-fps-probe/check` and `video-fps-probe/presets` need neither.
102
+
103
+ A server can branch on `result.errorCode`, a machine-readable code for `error` such as `unrecognised`, `no-header`, `truncated` or `read-failed`, and on `result.layout`, which tells a faststart MP4 from one with its moov at the end or a fragmented one. `errorCodeOf(result)` also codes results of other engines and stored 0.3.0 JSON from their text, and `errorClass(code)` tells a file problem, which can be cached, from a read failure worth a retry ([error codes](docs/api.md#error-codes)).
104
+
105
+ A server that checks uploads in object storage can read only the header: `analyzeHeader(source, { limits })` from `video-fps-probe/header` reads the moov of an MP4 or MOV file under limits on the bytes, the reads, the time, the tracks and the top-level boxes, in one to three reads for most files, and gives the codec, size, rotation, duration and layout, and the frame count of a file that is not fragmented, without walking the samples. A fragmented file gets its duration from a sidx, the mfra index or mehd, and the error code `no-duration` when it has none of them ([header-only analysis](docs/api.md#header-only-analysis)).
106
+
107
+ `video-fps-probe/url` reads such an object over HTTP Range requests: `analyzeHeader(await openRangeSource(httpRange(presignedUrl)), { limits })`, or `rangeSource(fetcher, { size })` over the client of any object-storage SDK. It needs no SDK and makes one request per read, with no retries, and it checks the status, `Content-Range` and `Content-Length` of every reply before the body is read and caps the body while it streams, so a server that ignores the range or an object whose size changed fails the read as `read-failed` instead of returning wrong bytes. An `If-Match` with the object's ETag, which the caller sends, also catches an overwrite at the same size ([Range source](docs/api.md#range-source)).
108
+
109
+ CommonJS works on Node 22.12 and newer, the lowest supported version, which load ES modules from `require()`: `const { analyze } = require('video-fps-probe')`, the subpaths included, and a TypeScript project with `"module": "commonjs"` and `moduleResolution` `node10`, such as a NestJS 11 app. The five server entries, `video-fps-probe`, `video-fps-probe/header`, `video-fps-probe/url`, `video-fps-probe/check` and `video-fps-probe/presets`, also ship a minified CommonJS copy with `.d.cts` types for loaders that do not run `require(esm)`, so Jest 30 runs them with no configuration and `jest` as the only dependency, and with `--experimental-vm-modules` on Node 24.9 it loads the ESM build instead. The package tests check all of this from the packed tarball on Node 22.12 and 24. The other entries still need babel-jest in Jest: the tested configuration and a stub for unit tests are in [Jest](docs/api.md#jest).
102
110
 
103
111
  ## Cancellation, timeouts and failing open
104
112
 
@@ -123,20 +131,20 @@ if (report.verdict === 'fail') showErrors(report.violations, report.suggestedFps
123
131
  else upload(file); // 'pass', 'warn', or 'unknown': no result, an error or a missing field fails open
124
132
  ```
125
133
 
126
- Rules are JSON, such as `{ fps: { min: 24, max: 60 }, duration: { max: 10 }, vfr: 'warn' }`, and frame rates compare as exact rationals: 23.976 fps (`24000/1001`) is below a minimum of 24, 59.94 meets a maximum of 60. The presets hold the published input limits of Kling O1 and Motion Control, Runway Aleph 2.0, Veo 3.1, and Wan 2.6 on fal and Seedance 2.0 on EvoLink (gateways, so they only warn), each with its source and the day it was verified ([check](docs/api.md#check), [presets](docs/api.md#presets)).
134
+ Rules are JSON, such as `{ fps: { min: 24, max: 60 }, duration: { max: 10 }, vfr: 'warn' }`, and frame rates compare as exact rationals: 23.976 fps (`24000/1001`) is below a minimum of 24, 59.94 meets a maximum of 60. The presets hold the published input limits of Kling O1 and Motion Control, Runway Aleph 2.0, Veo 3.1, and Wan 2.6 on fal and Seedance 2.0 on EvoLink (gateways, so they only warn), each with its source and the day it was verified; the source pages are also in `video-fps-probe/presets/sources.json`, where `sources[name]` replaces the deprecated `sourceUrl` ([check](docs/api.md#check), [presets](docs/api.md#presets)).
127
135
 
128
136
  ## Size
129
137
 
130
138
  <!-- tables:size:start -->
131
139
 
132
- **0.67 KB gzip on the critical path**: the package code in the entry chunk of a Vite 8.3.5 app that imports `video-fps-probe/lazy` (1.39 KB with Vite's preload helper). The rest loads lazily, on the first file:
140
+ **0.66 KB gzip on the critical path**: the package code in the entry chunk of a Vite 8.3.5 app that imports `video-fps-probe/lazy` (1.40 KB with Vite's preload helper). The rest loads lazily, on the first file:
133
141
 
134
- | Host Vite config | Critical path | First MP4 | All own paths | Worker files |
135
- |---|---:|---:|---:|---:|
136
- | `worker: { format: 'es' }` (recommended) | **0.67 KB** | 23.59 KB | 40.76 KB | 1 |
137
- | none (Vite's default, 'iife') | **0.66 KB** | 38.92 KB | 38.92 KB | 1 |
142
+ | Host Vite config | Critical path | First MP4 | First MKV | All own paths | Worker files |
143
+ |---|---:|---:|---:|---:|---:|
144
+ | `worker: { format: 'es' }` (recommended) | **0.66 KB** | 23.07 KB | 25.73 KB | 49.14 KB | 2 |
145
+ | none (Vite's default, 'iife') | **0.66 KB** | 24.85 KB | 27.19 KB | 48.75 KB | 2 |
138
146
 
139
- An iife worker cannot split, so without the option the own worker is one file with both parsers:
147
+ Own runs on one worker per container family, picked by the first four bytes of the file, so a first file loads only the worker of its family. An iife worker cannot split, so without the option each own worker is one file with its lazy parts inlined:
140
148
 
141
149
  ```js
142
150
  import { defineConfig } from 'vite';
@@ -144,9 +152,9 @@ import { defineConfig } from 'vite';
144
152
  export default defineConfig({ worker: { format: 'es' } });
145
153
  ```
146
154
 
147
- Engines cost nothing until imported. Importing a descriptor grows the critical path a little, from 0.67 KB to 0.89 KB with mp4box or mediabunny, 0.77 KB with rVFC and 1.01 KB with all three, and the engine itself loads lazily: mp4box 44.86 KB, mediabunny 53.97 KB, rVFC 2.19 KB. Gzip -9 of production builds from the packed tarball, budgeted in CI; every configuration is in [docs/benchmarks.md](docs/benchmarks.md#package-size).
155
+ Engines cost nothing until imported. Importing a descriptor grows the critical path a little, from 0.66 KB to 0.89 KB with mp4box or mediabunny, 0.76 KB with rVFC and 1.01 KB with all three, and the engine itself loads lazily: mp4box 44.94 KB, mediabunny 54.17 KB, rVFC 2.19 KB. Gzip -9 of production builds from the packed tarball, budgeted in CI. Every configuration is in [docs/benchmarks.md](docs/benchmarks.md#package-size).
148
156
 
149
- `video-fps-probe/react` adds 1.18 KB to the critical path for the provider, `useVideoFps` and its store (React itself not counted); its first analysis also fetches a 0.43 KB summary module, in parallel with the probe client. `video-fps-probe/check` adds 3.89 KB when imported statically, and nothing lazily; one preset from `video-fps-probe/presets` adds 0.46 KB.
157
+ `video-fps-probe/react` adds 1.20 KB to the critical path for the provider, `useVideoFps` and its store (React itself not counted), and its first analysis also fetches a 0.43 KB summary module, in parallel with the probe client. `video-fps-probe/check` adds 3.99 KB when imported statically, and nothing lazily. One preset from `video-fps-probe/presets` adds 0.46 KB.
150
158
 
151
159
  <!-- tables:size:end -->
152
160
 
@@ -154,9 +162,9 @@ Engines cost nothing until imported. Importing a descriptor grows the critical p
154
162
 
155
163
  <!-- tables:accuracy-summary:start -->
156
164
 
157
- **0 mismatches against ffprobe 8.1.1** on the 126 committed fixtures: 2002 field checks, 1924 exact, 13 within tolerance (9 of them a definition difference by design: the measured average of a Matroska file that stores no duration for its last frame, which avgFps gives the median interval and ffprobe its own estimate), 0 known deviations with a recorded reason, 65 not applicable. The same comparison runs mp4box.js 2.4.1 and mediabunny 1.62.1 in 5 arms (their idiomatic recipes, mediabunny with a full scan, and this package's tuned adapters) and sorts each difference into wrong, definition differs, unverified, not provided or unsupported container; the [accuracy section](docs/benchmarks.md#accuracy-vs-ffprobe) has the outcomes and where the libraries win.
165
+ **0 mismatches against ffprobe 8.1.1** on the 168 committed fixtures: 3318 field checks, 3229 exact, 13 within tolerance (9 of them a definition difference by design: the measured average of a Matroska file that stores no duration for its last frame, which avgFps gives the median interval and ffprobe its own estimate), 2 known deviations with a recorded reason, 74 not applicable. The same comparison runs mp4box.js 2.4.1 and mediabunny 1.62.1 in 5 arms (their idiomatic recipes, mediabunny with a full scan, and this package's tuned adapters) and sorts each difference into wrong, definition differs, unverified, not provided or unsupported container. The [accuracy section](docs/benchmarks.md#accuracy-vs-ffprobe) has the outcomes and where the libraries win.
158
166
 
159
- Of the 126 committed fixtures, 114 are synthetic files made with ffmpeg and 12 are real MediaRecorder recordings from headless Chromium, committed as recorded; per-engine tables, speed and method: [docs/benchmarks.md](docs/benchmarks.md).
167
+ Of the 168 committed fixtures, 156 are synthetic files made with ffmpeg and 12 are real MediaRecorder recordings from headless Chromium, committed as recorded. Per-engine tables, speed and method: [docs/benchmarks.md](docs/benchmarks.md).
160
168
 
161
169
  <!-- tables:accuracy-summary:end -->
162
170
 
@@ -166,6 +174,16 @@ Chrome and Edge 94, Firefox 114, Safari and iOS 15.4: floors derived from the fe
166
174
 
167
175
  Known limitations: [changelog](CHANGELOG.md#known-limitations), [benchmarks](docs/benchmarks.md#limitations). Development: [demo/README.md](demo/README.md), [scripts](docs/benchmarks.md#reproducing-the-numbers).
168
176
 
177
+ ## Roadmap
178
+
179
+ - **0.4.0**: a header-only ISO BMFF mode with byte, read and time limits, machine-readable error codes and a container layout field, a Range source for S3 and R2 style object storage (a fetcher function, no SDK dependency), minimal color, HDR and Dolby Vision metadata, the pixel aspect ratio and display size, a median frame interval that `check` uses, and a CommonJS build of the server entries only, while the browser entries stay ESM.
180
+ - **0.4.1**: a bundler matrix in CI: webpack 5, Rspack, Next.js, Angular CLI, SvelteKit, Nuxt, Parcel, Bun and Deno, with pnpm, Yarn PnP and bun installs.
181
+ - **0.5.0**: a Node entry (`fromPath`, DOM-free types, a `worker_threads` pool), NestJS validators, a CLI, bitrate, GOP and audio details, cadence detection, framework adapters for Vue, Svelte and Angular, and later MPEG-TS and HLS.
182
+
183
+ ## Feedback
184
+
185
+ Questions, and how you use the package, go to [GitHub Discussions](https://github.com/vbondarets/video-fps-probe/discussions). For a wrong value or a setup that does not build, open a [bug report](https://github.com/vbondarets/video-fps-probe/issues/new/choose) with the browser or Node version, the container, the Copy JSON output of the demo or the result JSON, and the ffprobe output if you have it. The package and the demo collect no telemetry.
186
+
169
187
  ## License
170
188
 
171
189
  [MIT](LICENSE). The optional peers have their own licenses: mediabunny is MPL-2.0, mp4box is BSD-3-Clause.
@@ -0,0 +1,6 @@
1
+ import type { FileErrorCode, FileResult, WarningCode } from '../core/types.js';
2
+ export type ErrorClass = 'file' | 'limit' | 'io' | 'environment' | 'internal';
3
+ export declare function errorCodeOf(result: Pick<FileResult, 'error'> & Partial<Pick<FileResult, 'errorCode' | 'engine'>>): FileErrorCode | null;
4
+ export declare function errorClass(code: FileErrorCode): ErrorClass;
5
+ export declare function warningCodeOf(warning: string): WarningCode | null;
6
+ export declare function warningCodesOf(result: Pick<FileResult, 'warnings' | 'videoTracks'>): WarningCode[];
@@ -0,0 +1,88 @@
1
+ const ERROR_PREFIXES = [
2
+ ['not a recognised container: empty file', 'empty-file'],
3
+ ['not a recognised container', 'unrecognised'],
4
+ ['unsupported: moov larger', 'too-large'],
5
+ ['corrupt file: moof larger', 'too-large'],
6
+ ['unsupported:', 'unsupported'],
7
+ ['incomplete file: moov is truncated', 'truncated'],
8
+ ['incomplete file: no moov', 'no-header'],
9
+ ['incomplete header:', 'no-duration'],
10
+ ['limit reached: maxBytes', 'byte-limit'],
11
+ ['limit reached: maxReads', 'read-limit'],
12
+ ['limit reached: maxMs', 'time-limit'],
13
+ ['limit reached: maxTracks', 'structure-limit'],
14
+ ['limit reached: maxTopLevelBoxes', 'structure-limit'],
15
+ ['corrupt file:', 'corrupt'],
16
+ ['file read failed', 'read-failed'],
17
+ ['the own ', 'unsupported-container'],
18
+ ['unsupported by ', 'unsupported-container'],
19
+ ['container not read by', 'unsupported-container'],
20
+ ['invalid EBML header', 'corrupt'],
21
+ ['no Segment element', 'no-header'],
22
+ ['no complete TrackEntry in Tracks', 'truncated'],
23
+ ['no Tracks element found', 'no-header'],
24
+ ];
25
+ const CLASS_OF = {
26
+ 'empty-file': 'file',
27
+ unrecognised: 'file',
28
+ 'unsupported-container': 'file',
29
+ unsupported: 'file',
30
+ 'too-large': 'file',
31
+ 'no-header': 'file',
32
+ truncated: 'file',
33
+ corrupt: 'file',
34
+ 'no-duration': 'file',
35
+ 'engine-failed': 'file',
36
+ 'byte-limit': 'limit',
37
+ 'read-limit': 'limit',
38
+ 'time-limit': 'limit',
39
+ 'structure-limit': 'limit',
40
+ 'read-failed': 'io',
41
+ 'load-failed': 'environment',
42
+ internal: 'internal',
43
+ };
44
+ const WARNING_PREFIXES = [
45
+ ['invalid mdhd timescale ', 'invalid-timescale'],
46
+ ['invalid timescale ', 'invalid-timescale'],
47
+ ['ignored trailing data at ', 'trailing-data'],
48
+ ['fragment index not read: ', 'fragment-index-unread'],
49
+ ['format duration unresolved: ', 'duration-unresolved'],
50
+ ['fragments ignored: ', 'fragments-ignored'],
51
+ ['too many top-level boxes', 'structure-limit'],
52
+ ];
53
+ const TRUNCATED_BOX = /^'(.{4})' at \d+ is truncated/;
54
+ export function errorCodeOf(result) {
55
+ if (result?.errorCode !== undefined)
56
+ return result.errorCode;
57
+ const error = result?.error;
58
+ if (error === null)
59
+ return null;
60
+ if (typeof error !== 'string')
61
+ return 'internal';
62
+ for (const [prefix, code] of ERROR_PREFIXES)
63
+ if (error.startsWith(prefix))
64
+ return code;
65
+ return result.engine !== undefined && result.engine !== 'own' ? 'engine-failed' : 'internal';
66
+ }
67
+ export function errorClass(code) {
68
+ return Object.hasOwn(CLASS_OF, code) ? CLASS_OF[code] : 'internal';
69
+ }
70
+ export function warningCodeOf(warning) {
71
+ if (typeof warning !== 'string')
72
+ return null;
73
+ for (const [prefix, code] of WARNING_PREFIXES)
74
+ if (warning.startsWith(prefix))
75
+ return code;
76
+ const box = TRUNCATED_BOX.exec(warning);
77
+ return box ? (box[1] === 'mdat' ? 'truncated-mdat' : 'truncated-box') : null;
78
+ }
79
+ export function warningCodesOf(result) {
80
+ const codes = new Set();
81
+ const listOf = (value) => (Array.isArray(value) ? value : []);
82
+ for (const w of [...listOf(result?.warnings), ...listOf(result?.videoTracks).flatMap((t) => listOf(t?.warnings))]) {
83
+ const code = warningCodeOf(w);
84
+ if (code)
85
+ codes.add(code);
86
+ }
87
+ return [...codes];
88
+ }
@@ -2,7 +2,7 @@ export class ProbeError extends Error {
2
2
  code;
3
3
  engine;
4
4
  constructor(code, message, options = {}) {
5
- super(message, options.cause === undefined ? undefined : { cause: options.cause });
5
+ super(message, options.cause === undefined ? undefined : options);
6
6
  this.name = 'ProbeError';
7
7
  this.code = code;
8
8
  this.engine = options.engine ?? null;
@@ -1,2 +1,4 @@
1
+ import type { ProbeError } from './errors.js';
2
+ export declare const isProbeError: (e: unknown) => e is ProbeError;
1
3
  export declare function isAbortError(e: unknown): boolean;
2
4
  export declare function isTimeoutError(e: unknown): boolean;
@@ -1,5 +1,6 @@
1
1
  const field = (e, key) => (typeof e === 'object' && e !== null && key in e ? e[key] : undefined);
2
- const probeCode = (e, code, otherName) => (field(e, 'name') === 'ProbeError' ? field(e, 'code') === code : field(e, 'name') === otherName);
2
+ export const isProbeError = (e) => field(e, 'name') === 'ProbeError' && typeof field(e, 'code') === 'string';
3
+ const probeCode = (e, code, otherName) => (isProbeError(e) ? e.code === code : field(e, 'name') === otherName);
3
4
  export function isAbortError(e) {
4
5
  return probeCode(e, 'aborted', 'AbortError');
5
6
  }
@@ -70,6 +70,7 @@ const fromRational = (r) => (r && whole(r.num) && whole(r.den) ? [BigInt(r.num),
70
70
  const gcd = (a, b) => (b === 0n ? a : gcd(b, a % b));
71
71
  const value = (q) => Number(q[0]) / Number(q[1]);
72
72
  const round = (x) => +x.toFixed(3);
73
+ const micros = (q) => (q[0] * 2000000n + q[1]) / (2n * q[1]);
73
74
  function digits(q, against) {
74
75
  let d = 3;
75
76
  while (d < 12 && against.some((b) => value(b).toFixed(d) === value(q).toFixed(d)))
@@ -97,6 +98,15 @@ function resolution(t, cfr) {
97
98
  return undefined;
98
99
  return cfr ? (x) => close(1n, cfr[0], x, 1n, NOMINAL_SLACK_PER_S) : (x) => close(BigInt(frames), avg, x, SPAN_SLACK_MS, 1000n);
99
100
  }
101
+ function printedDuration(t, result) {
102
+ const span = t.durationSec;
103
+ const format = result.formatDurationSec;
104
+ const typical = fromRational(t.dominantFps?.rate ?? (t.isVfr === false ? t.nominalFps : null) ?? t.avgFps);
105
+ const matroska = result.container === 'webm' || result.container === 'matroska';
106
+ const [a, b] = [span, format].map((x) => (typeof x === 'number' ? decimal(String(x)) : null));
107
+ const gap = a && b && abs(micros(a) - micros(b));
108
+ return matroska && typical && gap != null && gap * typical[0] <= 1000000n * typical[1] ? format : (span ?? format);
109
+ }
100
110
  function judgedFps(t, source, cfr) {
101
111
  const m = /^(\d+)\/(\d+)$/.exec(t.avgFpsFfprobe ?? '');
102
112
  if (source === 'ffprobe')
@@ -218,8 +228,8 @@ export function check(result, rules) {
218
228
  const fps = t && judgedFps(t, rules.fps?.source, cfr);
219
229
  const near = t && (rules.fps?.source ?? 'avg') === 'avg' ? resolution(t, cfr) : undefined;
220
230
  limits('fps', rules.fps, fps, fps ? fraction(fps) : '', (against) => fpsText(fps, against), ' fps', `${rules.fps?.source ?? 'avg'} frame rate`, '', near);
221
- const exact = cfr?.[1] ?? quantity(t && (t.durationSec ?? result.formatDurationSec));
222
- const span = exact && [(exact[0] * 2000000n + exact[1]) / (2n * exact[1]), 1000000n];
231
+ const exact = cfr?.[1] ?? quantity(t && printedDuration(t, result));
232
+ const span = exact && [micros(exact), 1000000n];
223
233
  const seconds = span ? value(span) : 0;
224
234
  limits('duration', rules.duration, span, seconds, (against) => `${digits(span, against)}`, ' s', 'duration');
225
235
  const size = t ? (Math.abs(t.rotation) % 180 === 90 ? [t.height, t.width] : [t.width, t.height]) : [0, 0];
@@ -0,0 +1,9 @@
1
+ import type { ProbeResult } from './client/types.cjs';
2
+ import type { BlobLike } from './core/reader.cjs';
3
+ import type { AnalyzeOptions } from './core/types.cjs';
4
+ import type { EngineDescriptor } from './plugins/types.cjs';
5
+ export interface InThreadOptions extends AnalyzeOptions {
6
+ name?: string;
7
+ engine?: EngineDescriptor;
8
+ }
9
+ export declare function analyze(input: Blob | BlobLike, options?: InThreadOptions): Promise<ProbeResult>;
@@ -0,0 +1,18 @@
1
+ import type { EngineId, FileResult, Rational } from '../core/types.cjs';
2
+ export type ComparedFieldName = 'avgFps' | 'nominalFps' | 'minFps' | 'maxFps' | 'frames' | 'durationSec';
3
+ export type ComparedValue = Rational | number | null;
4
+ export interface ComparedField<K extends EngineId> {
5
+ field: ComparedFieldName;
6
+ values: Partial<Record<K, ComparedValue>>;
7
+ agree: boolean | null;
8
+ differ: K[];
9
+ }
10
+ export interface Agreement<K extends EngineId> {
11
+ reference: K | null;
12
+ engines: K[];
13
+ fields: ComparedField<K>[];
14
+ agree: boolean | null;
15
+ }
16
+ export declare const RELATIVE_TOLERANCE = 0.003;
17
+ export declare const DURATION_TOLERANCE_SEC = 0.000001;
18
+ export declare function compareResults<K extends EngineId>(results: Partial<Record<K, FileResult>>, reference?: NoInfer<K>): Agreement<K>;
@@ -0,0 +1,6 @@
1
+ import type { FileErrorCode, FileResult, WarningCode } from '../core/types.cjs';
2
+ export type ErrorClass = 'file' | 'limit' | 'io' | 'environment' | 'internal';
3
+ export declare function errorCodeOf(result: Pick<FileResult, 'error'> & Partial<Pick<FileResult, 'errorCode' | 'engine'>>): FileErrorCode | null;
4
+ export declare function errorClass(code: FileErrorCode): ErrorClass;
5
+ export declare function warningCodeOf(warning: string): WarningCode | null;
6
+ export declare function warningCodesOf(result: Pick<FileResult, 'warnings' | 'videoTracks'>): WarningCode[];
@@ -0,0 +1,12 @@
1
+ import type { EngineId } from '../core/types.cjs';
2
+ export type ProbeErrorCode = 'aborted' | 'timeout' | 'worker-failed' | 'engine-not-registered' | 'unknown-engine' | 'unsupported-environment' | 'protocol';
3
+ export interface ProbeErrorOptions {
4
+ engine?: EngineId | null;
5
+ cause?: unknown;
6
+ }
7
+ export declare class ProbeError extends Error {
8
+ readonly code: ProbeErrorCode;
9
+ readonly engine: EngineId | null;
10
+ readonly reason?: 'load' | 'crash';
11
+ constructor(code: ProbeErrorCode, message: string, options?: ProbeErrorOptions);
12
+ }
@@ -0,0 +1,4 @@
1
+ import type { ProbeError } from './errors.cjs';
2
+ export declare const isProbeError: (e: unknown) => e is ProbeError;
3
+ export declare function isAbortError(e: unknown): boolean;
4
+ export declare function isTimeoutError(e: unknown): boolean;
@@ -0,0 +1,22 @@
1
+ import type { ContainerKind, EngineId, FileResult } from '../core/types.cjs';
2
+ export interface VideoFpsSummary {
3
+ ok: boolean;
4
+ name: string;
5
+ container: ContainerKind;
6
+ engine: EngineId;
7
+ fps: number | null;
8
+ fpsRational: string | null;
9
+ nominalFps: number | null;
10
+ frames: number | null;
11
+ durationSec: number | null;
12
+ width: number | null;
13
+ height: number | null;
14
+ rotation: number | null;
15
+ isVfr: boolean | null;
16
+ minFps: number | null;
17
+ maxFps: number | null;
18
+ approximate: boolean;
19
+ error: string | null;
20
+ warnings: string[];
21
+ }
22
+ export declare function summarize(r: FileResult): VideoFpsSummary;
@@ -0,0 +1,60 @@
1
+ import type { ContainerKind, FileResult } from '../core/types.cjs';
2
+ export type RatioValue = number | `${number}/${number}` | `${number}:${number}`;
3
+ export type RatePolicy = 'allow' | 'warn' | 'reject';
4
+ export type FpsSource = 'avg' | 'ffprobe' | 'nominal' | 'dominant';
5
+ export type CheckVerdict = 'pass' | 'warn' | 'fail' | 'unknown';
6
+ export type CheckSeverity = 'fail' | 'warn' | 'unknown';
7
+ export interface RangeRule<T = number> {
8
+ min?: T;
9
+ max?: T;
10
+ }
11
+ export interface FpsRule extends RangeRule<RatioValue> {
12
+ exact?: RatioValue;
13
+ oneOf?: readonly RatioValue[];
14
+ source?: FpsSource;
15
+ }
16
+ export interface AspectRatioRule extends RangeRule<RatioValue> {
17
+ oneOf?: readonly RatioValue[];
18
+ }
19
+ export interface FrameSizeRule {
20
+ minWidth?: number;
21
+ maxWidth?: number;
22
+ minHeight?: number;
23
+ maxHeight?: number;
24
+ minShortSide?: number;
25
+ maxShortSide?: number;
26
+ minLongSide?: number;
27
+ maxLongSide?: number;
28
+ }
29
+ export interface CheckRules {
30
+ fps?: FpsRule;
31
+ duration?: RangeRule;
32
+ frameSize?: FrameSizeRule;
33
+ aspectRatio?: AspectRatioRule;
34
+ pixels?: RangeRule;
35
+ fileSize?: RangeRule;
36
+ containers?: readonly ContainerKind[];
37
+ codecs?: readonly string[];
38
+ audio?: 'required' | 'forbidden';
39
+ vfr?: RatePolicy;
40
+ uneven?: RatePolicy;
41
+ severity?: 'fail' | 'warn';
42
+ }
43
+ export type CheckExpected = number | string | readonly (number | string)[] | null;
44
+ export interface CheckViolation {
45
+ rule: string;
46
+ severity: CheckSeverity;
47
+ expected: CheckExpected;
48
+ actual: number | string | boolean | null;
49
+ message: string;
50
+ }
51
+ export interface SuggestedFps {
52
+ value: number | null;
53
+ reason: string;
54
+ }
55
+ export interface CheckReport {
56
+ verdict: CheckVerdict;
57
+ violations: CheckViolation[];
58
+ suggestedFps?: SuggestedFps;
59
+ }
60
+ export declare function check(result: FileResult | null | undefined, rules: CheckRules): CheckReport;
@@ -0,0 +1 @@
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=[3n,2n],t=3n,n=[24n,30n,48n,60n,120n],r=[...[24n,25n,30n,48n,50n,60n,100n,120n].map(e=>[e,1n]),...n.map(e=>[e*1000n,1001n])],i={min:`below the minimum`,max:`above the maximum`,exact:`not`,oneOf:`not one of`},a=Object.keys(i),o=[`fps`,`duration`,`frameSize`,`aspectRatio`,`pixels`,`fileSize`],s=[`allow`,`warn`,`reject`],c=[`avg`,`ffprobe`,`nominal`,`dominant`],l=[`own`,`mp4box`,`mediabunny`],u=[`min`,`max`],d=[`fileSize`,`containers`,`audio`],f={fps:[...u,`exact`,`oneOf`,`source`],duration:u,frameSize:[`Width`,`Height`,`ShortSide`,`LongSide`].flatMap(e=>u.map(t=>t+e)),aspectRatio:[...u,`oneOf`],pixels:u,fileSize:u},p=[...o,`containers`,`codecs`,`audio`,`vfr`,`uneven`,`severity`],invalid=(e,t,n)=>{throw TypeError(`${e}: ${typeof t==`number`?t:JSON.stringify(t)} is not ${n}`)};function decimal(e){let t=/^(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/i.exec(e.trim());if(!t||!Number.isFinite(Number(t[0])))return null;let n=Number(t[3]??0)-(t[2]?.length??0);if(Math.abs(n)>400)return null;let r=BigInt(t[1]+(t[2]??``));return n>=0?[r*10n**BigInt(n),1n]:[r,10n**BigInt(-n)]}function ratio(e,t,n=!1){let r=typeof e==`number`||typeof e==`string`?String(e).split(/[/:]/):[],[i,a]=r.map(decimal),o=i&&r.length===1?i:i&&a&&r.length===2&&!n?[i[0]*a[1],i[1]*a[0]]:null,s=r.length>1?`; 'num/den' and 'num:den' ratios are for fps and aspectRatio`:``;return o&&o[1]!==0n?o:invalid(t,e,n?`a non-negative number${s}`:`a non-negative number or 'num/den'`)}const cmp=(e,t)=>e[0]*t[1]-t[0]*e[1],ntsc=(e,t)=>/[/:]/.test(String(e))?[]:n.filter(e=>t[0]*1000n==t[1]*e*999n).map(e=>[e*1000n,1001n]),readings=(e,t)=>{let n=ratio(e,t);return[n,...ntsc(e,n)]};function close(e,t,n,r,i){let a=e*(t[1]*n[0]-n[1]*t[0]);return(a<0n?-a:a)*i<=r*t[0]*n[0]}const whole=e=>Number.isSafeInteger(e)&&e>0,record=e=>!!e&&typeof e==`object`&&!Array.isArray(e);function primaryTrack(e){let t=e.videoTracks,n=e.primaryTrackId;return Array.isArray(t)?t.length===0?`no video track`:t.find(e=>record(e)&&typeof n==`number`&&e.trackId===n)||`no primary track: primaryTrackId ${n===void 0?`is missing`:`${JSON.stringify(n)} matches no video track`}`:`the result has no videoTracks list`}const errorKind=e=>typeof e==`string`?`blank`:Array.isArray(e)?`a list`:`of type ${typeof e}`,failedWith=e=>`the analysis failed${typeof e==`string`&&e.trim()?`: ${e}`:`, and its error is ${errorKind(e)}`}`,fromRational=e=>e&&whole(e.num)&&whole(e.den)?[BigInt(e.num),BigInt(e.den)]:null,gcd=(e,t)=>t===0n?e:gcd(t,e%t),value=e=>Number(e[0])/Number(e[1]),round=e=>+e.toFixed(3),micros=e=>(e[0]*2000000n+e[1])/(2n*e[1]);function digits(e,t){let n=3;for(;n<12&&t.some(t=>value(t).toFixed(n)===value(e).toFixed(n));)n++;return+value(e).toFixed(n)}function fraction(e){let t=gcd(e[0],e[1])||1n;return`${e[0]/t}/${e[1]/t}`}const fpsText=(e,t=[])=>e[0]%e[1]===0n?`${e[0]/e[1]}`:`${digits(e,t)} (${fraction(e)})`;function steady(e){let n=fromRational(e.avgFps),r=fromRational(e.nominalFps),i=e.presentedFrames??e.frames;if(!n||!r||!e.timestampsQuantized||e.isVfr!==!1||!whole(i))return null;let a=BigInt(i);return close(a,n,r,t,1000n)?[r,[a*r[1],r[0]]]:null}function resolution(e,n){let r=fromRational(e.avgFps),i=e.presentedFrames??e.frames;if(r&&e.timestampsQuantized&&e.isVfr===!1&&whole(i))return n?e=>close(1n,n[0],e,1n,10000000n):e=>close(BigInt(i),r,e,t,1000n)}function printedDuration(e,t){let n=e.durationSec,r=t.formatDurationSec,i=fromRational(e.dominantFps?.rate??(e.isVfr===!1?e.nominalFps:null)??e.avgFps),a=t.container===`webm`||t.container===`matroska`,[o,s]=[n,r].map(e=>typeof e==`number`?decimal(String(e)):null),c=o&&s&&abs(micros(o)-micros(s));return a&&i&&c!=null&&c*i[0]<=1000000n*i[1]?r:n??r}function judgedFps(e,t,n){let r=/^(\d+)\/(\d+)$/.exec(e.avgFpsFfprobe??``);return t===`ffprobe`?r&&fromRational({num:+r[1],den:+r[2]}):t===`nominal`?fromRational(e.nominalFps):t===`dominant`?fromRational(e.dominantFps?.rate??(e.isVfr===!1?e.nominalFps:null)):n?.[0]??fromRational(e.avgFps)}function unevenOf(e){let t=e.rateProfile;return t==null?e.isVfr===!1||e.minFps&&e.maxFps&&e.maxFps.value<=e.minFps.value*1.075?!1:null:record(t)?t.uneven:null}const abs=e=>e<0n?-e:e;function fit(t,n={}){let list=e=>e==null?[]:[e].flat().flatMap(e=>readings(e,`fps`)),[i]=list(n.min),a=list(n.max).reduce((e,t)=>e&&cmp(e,t)>0?e:t,void 0),inside=e=>(!i||cmp(e,i)>=0)&&(!a||cmp(e,a)<=0),nearer=(e,n)=>abs(cmp(e,t))*n[1]-abs(cmp(n,t))*e[1]||cmp(e,n),nearest=e=>e.filter(inside).reduce((e,t)=>e&&nearer(e,t)<=0n?e:t,null),divided=e=>{for(let n=2n;n<=8n;n++){let r=[t[0],t[1]*n];if(e(r))return[r,Number(n)]}},o=n.oneOf==null?null:list(n.oneOf),s=n.exact==null?o:list(n.exact).filter(e=>!o||o.some(t=>cmp(t,e)===0n));if(i&&a&&cmp(i,a)>0)return null;if(!s&&inside(t))return[t,0];let c=(s??r).filter(inside),l=!s&&c.length===0,u=l?i&&cmp(t,i)<0?i:a:nearest(c);if(!u)return null;let d=l?inside:e=>c.some(t=>cmp(t,e)===0n);return cmp(t,[e[0]*u[0],e[1]*u[1]])>=0&&divided(d)||[u,0]}function check(e,t){let known=(e,t,n)=>{(!t||typeof t!=`object`||Array.isArray(t))&&invalid(e,t,`an object`);let r=e===`rules`?``:`${e}.`;for(let e of Object.keys(t))if(!n.includes(e))throw TypeError(`${r}${e}: not a rule; expected one of ${n.join(`, `)}`)};known(`rules`,t,p);for(let e of o)t[e]!=null&&known(e,t[e],f[e]);let n=[[`fps.source`,t.fps?.source,c],[`audio`,t.audio,[`required`,`forbidden`]],[`vfr`,t.vfr,s],[`uneven`,t.uneven,s],[`severity`,t.severity,[`fail`,`warn`]]];for(let[e,t,r]of n)t!=null&&!r.includes(t)&&invalid(e,t,`one of ${r.join(`, `)}`);let r=[],u=t.severity===`warn`?`warn`:`fail`,m=e==null?`the analysis failed: no result`:e.error?failedWith(e.error):null,h=m??primaryTrack(e),g=typeof h==`string`?null:h,add=(e,t,n,i,a)=>r.push({rule:e,severity:t,expected:n,actual:i,message:`${e}: ${a}`}),unknown=(e,t)=>r.some(t=>t.rule===e)||add(e,`unknown`,null,null,`unknown, ${g||d.includes(e)?`no ${t}${m?`; ${m}`:``}`:h}`),limits=(e,t,n,r,o,s,c,l=``,d)=>{let f=a.filter(e=>t?.[e]!=null).map(n=>{let r=`${e}.${n}${l}`,i=(n===`oneOf`?[t[n]].flat():[t[n]]).map(t=>e===`fps`?readings(t,r):[ratio(t,r,e!==`aspectRatio`)]);return[n,t[n],i]});if(f.length!==0){if(!n)return void unknown(e,c);for(let[t,a,c]of f){let meets=e=>d?.(e)||(t===`min`?cmp(n,e)>=0n:t===`max`?cmp(n,e)<=0n:cmp(n,e)===0n),f=[a].flat(),p=f.length?`${f.join(`, `)}${s}`:`an empty list`,m=typeof o==`string`?o:o(c.flat());c.flat().some(meets)||add(`${e}.${t}${l}`,u,a,r,`${m}${s} is ${i[t]} ${p}`)}}},quantity=e=>typeof e==`number`?decimal(String(e)):null,allow=(e,t,n,r)=>{t!=null&&((!Array.isArray(t)||t.some(e=>typeof e!=`string`))&&invalid(e,t,`a list of strings`),typeof n!=`string`||!n||n===`unknown`?unknown(e,r):t.some(e=>e.toLowerCase()===n.toLowerCase())||add(e,u,t,n,`${n} is not one of ${t.length?t.join(`, `):`an empty list`}`))},policy=(e,n,r,i)=>{let a=t[e];return!a||a===`allow`?!1:(typeof n==`boolean`?n&&add(e,a===`warn`?`warn`:u,a,!0,i):unknown(e,r),n===!0)},_=g&&steady(g),v=g&&judgedFps(g,t.fps?.source,_),y=g&&(t.fps?.source??`avg`)===`avg`?resolution(g,_):void 0;limits(`fps`,t.fps,v,v?fraction(v):``,e=>fpsText(v,e),` fps`,`${t.fps?.source??`avg`} frame rate`,``,y);let b=_?.[1]??quantity(g&&printedDuration(g,e)),x=b&&[micros(b),1000000n],S=x?value(x):0;limits(`duration`,t.duration,x,S,e=>`${digits(x,e)}`,` s`,`duration`);let C=g?Math.abs(g.rotation)%180==90?[g.height,g.width]:[g.width,g.height]:[0,0],w=C.every(whole),[T,E]=w?C:[0,0],D={Width:T,Height:E,ShortSide:Math.min(T,E),LongSide:Math.max(T,E)},O=t.frameSize;for(let[e,t]of Object.entries(D))limits(`frameSize`,{min:O?.[`min${e}`],max:O?.[`max${e}`]},w?[BigInt(t),1n]:null,t,`${t}`,` px`,`frame size`,e);let k=[BigInt(T),BigInt(E)];if(limits(`aspectRatio`,t.aspectRatio,w?k:null,fraction(k),`${T}x${E} (${round(T/E)})`,``,`frame size`),limits(`pixels`,t.pixels,w?[BigInt(T)*BigInt(E),1n]:null,T*E,`${T*E}`,` px`,`frame size`),limits(`fileSize`,t.fileSize,quantity(e?.size),e?.size??``,`${e?.size}`,` bytes`,`file size`),allow(`containers`,t.containers,e?.container,`container`),allow(`codecs`,t.codecs,g?.codecName,`codec name`),t.audio){let n=e?.engine,r=l.includes(n),i=e?.audioTrackCount,a=!m&&r&&Number.isSafeInteger(i)&&i>=0?i>0:null,o=t.audio===`required`;a===null?unknown(`audio`,`audio track count${!r&&typeof n==`string`?` (the ${n} engine reports none)`:``}`):a!==o&&add(`audio`,u,t.audio,a,`${t.audio}, the file has ${o?`no`:`an`} audio track`)}let A=policy(`vfr`,g?.isVfr,`VFR flag`,`the frame rate is variable`),j=g?.rateProfile,M=record(j)&&typeof j.summary==`string`?`: ${j.summary}`:``,N=policy(`uneven`,g&&unevenOf(g),`${j==null?``:`uneven flag in the `}rate profile`,`the frame rate is uneven${M}`),P={verdict:`pass`,violations:r},F=r.some(e=>e.rule.startsWith(`fps.`));if(g&&(F||A||N)){let e=F?v:fromRational(g.dominantFps?.rate)??fromRational(g.nominalFps),n=Math.round(g.avgFps?.value??0),r=fit(e??[whole(n)?BigInt(n):0n,1n],t.fps),i=F?`${fpsText(v,r?[r[0]]:[])} fps is not allowed`:`the frame rate is ${A?`variable`:`uneven`}`;if(!r)P.suggestedFps={value:null,reason:`${i}, and no frame rate meets every bound of the fps rule`};else if(r[0][0]>0n){let[e,t]=r,n=t>1?`, which keeps 1 of every ${t} frames`:``;P.suggestedFps={value:value(e),reason:`${i}: ${F?`convert to`:`re-encode at a constant`} ${fpsText(e)} fps${n}`}}}return P.verdict=[`fail`,`unknown`,`warn`].find(e=>r.some(t=>t.severity===e))??`pass`,P}exports.check=check;