video-fps-probe 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/LICENSE +21 -0
  3. package/README.md +170 -2
  4. package/dist/analyze.d.ts +9 -0
  5. package/dist/analyze.js +5 -0
  6. package/dist/api/agreement.d.ts +18 -0
  7. package/dist/api/agreement.js +55 -0
  8. package/dist/api/errors.d.ts +12 -0
  9. package/dist/api/errors.js +10 -0
  10. package/dist/api/guards.d.ts +2 -0
  11. package/dist/api/guards.js +8 -0
  12. package/dist/api/summary.d.ts +22 -0
  13. package/dist/api/summary.js +25 -0
  14. package/dist/check/index.d.ts +60 -0
  15. package/dist/check/index.js +271 -0
  16. package/dist/client/compare.js +57 -0
  17. package/dist/client/fallback.js +43 -0
  18. package/dist/client/index.d.ts +11 -0
  19. package/dist/client/index.js +5 -0
  20. package/dist/client/inline.d.ts +19 -0
  21. package/dist/client/inline.js +43 -0
  22. package/dist/client/lazy.d.ts +6 -0
  23. package/dist/client/lazy.js +7 -0
  24. package/dist/client/pool.d.ts +29 -0
  25. package/dist/client/pool.js +174 -0
  26. package/dist/client/probe.d.ts +29 -0
  27. package/dist/client/probe.js +131 -0
  28. package/dist/client/stub.js +45 -0
  29. package/dist/client/types.d.ts +95 -0
  30. package/dist/client/types.js +1 -0
  31. package/dist/core/abort.js +4 -0
  32. package/dist/core/analyze.js +87 -0
  33. package/dist/core/index.js +6 -0
  34. package/dist/core/isobmff/boxes.js +76 -0
  35. package/dist/core/isobmff/codecs.js +43 -0
  36. package/dist/core/isobmff/index.js +421 -0
  37. package/dist/core/isobmff/model.js +60 -0
  38. package/dist/core/isobmff/moof.js +232 -0
  39. package/dist/core/isobmff/moov.js +341 -0
  40. package/dist/core/isobmff/prefetch.js +90 -0
  41. package/dist/core/isobmff/track.js +544 -0
  42. package/dist/core/matroska/ebml.js +164 -0
  43. package/dist/core/matroska/header.js +380 -0
  44. package/dist/core/matroska/index.js +466 -0
  45. package/dist/core/matroska/scan.js +419 -0
  46. package/dist/core/matroska/source.js +145 -0
  47. package/dist/core/options.js +38 -0
  48. package/dist/core/rational.js +101 -0
  49. package/dist/core/reader.d.ts +56 -0
  50. package/dist/core/reader.js +217 -0
  51. package/dist/core/result.js +49 -0
  52. package/dist/core/rfps.js +438 -0
  53. package/dist/core/segments.js +234 -0
  54. package/dist/core/sniff.js +99 -0
  55. package/dist/core/stats.js +622 -0
  56. package/dist/core/types.d.ts +135 -0
  57. package/dist/core/types.js +1 -0
  58. package/dist/engines/common.js +124 -0
  59. package/dist/engines/counting.js +78 -0
  60. package/dist/engines/mediabunny.d.ts +9 -0
  61. package/dist/engines/mediabunny.js +241 -0
  62. package/dist/engines/mp4box.d.ts +8 -0
  63. package/dist/engines/mp4box.js +429 -0
  64. package/dist/engines/rvfc.js +216 -0
  65. package/dist/engines/types.d.ts +69 -0
  66. package/dist/engines/types.js +1 -0
  67. package/dist/index.d.ts +12 -0
  68. package/dist/index.js +6 -0
  69. package/dist/lazy.d.ts +6 -0
  70. package/dist/lazy.js +6 -0
  71. package/dist/plugins/mediabunny.d.ts +2 -0
  72. package/dist/plugins/mediabunny.js +9 -0
  73. package/dist/plugins/mp4box.d.ts +2 -0
  74. package/dist/plugins/mp4box.js +9 -0
  75. package/dist/plugins/own.js +6 -0
  76. package/dist/plugins/rvfc.d.ts +5 -0
  77. package/dist/plugins/rvfc.js +3 -0
  78. package/dist/plugins/types.d.ts +17 -0
  79. package/dist/plugins/types.js +1 -0
  80. package/dist/presets/index.d.ts +18 -0
  81. package/dist/presets/index.js +54 -0
  82. package/dist/probe.d.ts +3 -0
  83. package/dist/probe.js +6 -0
  84. package/dist/react/index.d.ts +14 -0
  85. package/dist/react/index.js +30 -0
  86. package/dist/react/store.d.ts +25 -0
  87. package/dist/react/store.js +95 -0
  88. package/dist/standalone/own.worker.js +1 -0
  89. package/dist/worker/mediabunny.worker.js +5 -0
  90. package/dist/worker/mp4box.worker.js +5 -0
  91. package/dist/worker/own.worker.js +2 -0
  92. package/dist/worker/protocol.d.ts +47 -0
  93. package/dist/worker/protocol.js +1 -0
  94. package/dist/worker/runtime.d.ts +4 -0
  95. package/dist/worker/runtime.js +104 -0
  96. package/package.json +201 -4
package/CHANGELOG.md ADDED
@@ -0,0 +1,79 @@
1
+ # Changelog
2
+
3
+ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions follow semver with the [0.x policy](docs/api.md#versioning).
4
+
5
+ ## Unreleased
6
+
7
+ ## 0.2.0 - 2026-10-25
8
+
9
+ ### Added
10
+
11
+ - First release on npm: `npm install video-fps-probe`; Yarn, pnpm and bun install the same publish ([Publishing](docs/publishing.md)).
12
+ - `video-fps-probe/react`: `VideoFpsProvider`, `useVideoFps(file)`, `useVideoFpsProbe()` and `usePrewarmVideoFps()` for React 18 and 19. The hook caches results per file, runs one analysis under `StrictMode` and aborts it when its last component unmounts ([React](docs/api.md#react)).
13
+ - `video-fps-probe/check`: `check(result, rules)` judges a result against upload limits before the upload: frame rate, duration, frame size, aspect ratio, pixels, file size, container, codec, audio, VFR and uneven pacing. The rules are plain JSON ([Check](docs/api.md#check)).
14
+ - `check` compares exact rationals, so 23.976 fps is below `min: 24`, and an NTSC decimal such as 29.97 also matches its x/1001 rate. A missing, failed or malformed result is `unknown` where it lacks a value, never an exception, so an upload can fail open; a broken rule throws a `TypeError` naming it.
15
+ - The `check` report has a verdict, one violation per broken rule with a readable message, and a `suggestedFps` to convert to: a clean 1-in-k frame drop when the file is at least 1.5 times the nearest allowed rate (50 fps under `max: 30` gets 25), else the nearest allowed rate (31 fps gets 30).
16
+ - `video-fps-probe/presets`: the published input limits of six video generation APIs as `check` rules, with sources, verified on 2026-10-25: Kling O1 and Motion Control 3.0, Runway Aleph 2.0, Veo 3.1, and Wan 2.6 on fal and Seedance 2.0 on EvoLink, which only warn ([Presets](docs/api.md#presets)).
17
+ - `ProbeError.reason`: `'load'` when code did not load, such as a chunk that is gone after a redeploy ([deploy skew](docs/bundlers.md#deploy-skew)), `'crash'` when it failed after loading. The code stays `worker-failed`.
18
+ - `avgFpsFfprobe` and `rFpsFfprobe` for a WebM or Matroska track without DefaultDuration, such as a MediaRecorder recording: ffprobe's estimate for VP8, VP9, AV1 and Baseline H.264 in the [listed cases](docs/implementation-notes.md#ffprobe-frame-rates-without-defaultduration-020), else null with a warning.
19
+
20
+ ### Changed
21
+
22
+ - **Behaviour change:** a lazy stub whose probe client fails to load rejects with `ProbeError('worker-failed')`, `reason: 'load'` and the import error as `cause`, instead of the browser's import error. Code that caught that error should test `e.reason === 'load'`.
23
+ - `isAbortError` and `isTimeoutError` check the error's name and code instead of `instanceof`, so they also recognize a `ProbeError` from another copy of the package.
24
+ - A file smaller than 465,536 bytes is read whole in one read, so a small MP4 with its moov at the end takes 1 read instead of 2; that read is at most 1 MiB whatever the cost model.
25
+ - In `auto` mode the exact scan of a Matroska track without DefaultDuration also feeds the ffprobe estimate, so the first Clusters are read once; the track's warning says whether the estimate was reproduced.
26
+ - The mediabunny engine is verified with mediabunny 1.62.1; the peer range stays `^1.62.0`.
27
+ - Sizes (gzip): the first MP4 of the default configuration loads about 23.6 KB (23.3 KB in 0.1.0) and the critical path stays under 0.7 KB. The React entry adds about 1.2 KB, `check` about 3.9 KB and one preset about 0.5 KB, each only when imported ([size matrix](report/size-matrix.md)).
28
+ - Still 0 mismatches against ffprobe 8.1.1, now on 126 committed fixtures: 114 made with ffmpeg and 12 real MediaRecorder recordings from Chromium ([benchmarks](docs/benchmarks.md#accuracy-vs-ffprobe)).
29
+
30
+ ### Fixed
31
+
32
+ - A worker script that failed to load, such as a 404 after a redeploy, was reported as a crash. It now rejects with `reason: 'load'` and says the app may have been redeployed; when the last worker fails to load, the queued jobs reject once instead of each loading the script again.
33
+ - The mp4box engine ended a fragmented track at the sum of its sample durations. It now follows the tfdt timeline like ffprobe, so a Chrome MediaRecorder fMP4 gets ffprobe's duration (4.524533 s instead of 4.557667 s on `rec_chromium_h264.mp4`).
34
+
35
+ ### Known limitations
36
+
37
+ - React 18 and 19 only. React is an optional peer, `^18.0.0 || ^19.0.0`: 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" } }` to your `package.json`.
38
+ - Zero-config Vite (iife workers) loads about 39 KB for the first MP4, one worker with both parsers; `worker: { format: 'es' }` makes it about 23.6 KB.
39
+ - Only Vite 8 and npm are tested in CI; other bundlers are documented ([bundlers](docs/bundlers.md)), and Yarn, pnpm and bun are expected to work.
40
+ - Browser floors (Chrome and Edge 94, Firefox 114, Safari 15.4, mp4box.js Safari 16.4) are derived, not tested; CI runs Playwright's Chromium, Firefox and WebKit, and in-thread `analyze()` is tested on Node 24 only.
41
+ - The type declarations name DOM types, so a TypeScript project for Node needs `"DOM"` in `lib` or `skipLibCheck: true`.
42
+ - The corpus has real recordings from Chromium's MediaRecorder only: none from Firefox, Safari, phones, cameras or screen recorders.
43
+ - A regular cadence such as 2:3 pulldown is VFR, but its rate profile reads as Steady.
44
+ - `check` judges the stored frame size: the sample aspect ratio of a `pasp` box or a Matroska DisplayWidth is not applied.
45
+ - `check` takes the duration of a VFR WebM or Matroska track on 1 ms ticks from its measured span, up to one frame shorter than ffprobe's: `vfr_screenrec_ts1000.webm` lasts 5.999 s against 6.000000, so `duration: { min: 6 }` fails it.
46
+ - rVFC needs playback: a visible foreground tab, a playable codec and seconds per file.
47
+ - Files above 2^53 bytes, `cmov` headers and a moov above 256 MiB are rejected as error results.
48
+ - In `auto` mode a large Matroska file gets a sampled probe unless the exact scan is predicted to take at most 150 ms.
49
+
50
+ ### Not yet
51
+
52
+ - Bundler matrix and peers job (0.3.0): Vite 6, webpack 5, Rspack, Next.js 16 on Turbopack and webpack, a pnpm install and a Yarn PnP install.
53
+ - Demo: no prewarm before the first file.
54
+
55
+ ## 0.1.0 - 2026-10-24
56
+
57
+ First release; not on npm yet (`"private": true`).
58
+
59
+ ### Added
60
+
61
+ - Frame rate (an exact rational and ffprobe's `avg_frame_rate`), nominal, minimum and maximum FPS, frame counts, duration, codec, size and rotation per video track, a VFR flag and a rate profile, read from MP4, MOV (fragmented included), WebM and Matroska headers in a module worker, without decoding. Values carry provenance tags; file problems are `result.error`.
62
+ - Entries: `.` and `./lazy` with the own worker, `./client` and `./client/lazy` for a host worker, the `./mp4box`, `./mediabunny` and `./rvfc` engines, `./worker-runtime` and `./engines/*` for a custom worker, and `./standalone/own.worker.js` ([API](docs/api.md)).
63
+ - `analyze` with `engine` and `fallback`, `tryAnalyze`, `compare` (experimental), `prewarm`, `dispose`, `destroy`, `summarize`, `ProbeError`, the in-thread `analyze` for Node, and worker protocol v1.
64
+ - Chrome and Edge 94, Firefox 114, Safari 15.4 (derived floors); CI runs Chromium, Firefox and WebKit.
65
+ - 0.67 KB gzip on the critical path of a Vite 8.3.5 host; the first MP4 loads 23.26 KB lazily with `worker: { format: 'es' }`.
66
+ - 0 mismatches against ffprobe 8.1.1 on the 111 committed fixtures.
67
+
68
+ ### Known limitations
69
+
70
+ - Zero-config Vite (iife workers) loads 34.77 KB for the first MP4: the probe client and one 32.11 KB worker file with both parsers.
71
+ - A regular cadence such as 2:3 pulldown is VFR, but its rate profile reads as Steady.
72
+ - rVFC needs playback: a visible foreground tab, a playable codec and seconds per file.
73
+ - Files above 2^53 bytes, `cmov` headers and a moov above 256 MiB are rejected as error results.
74
+ - In `auto` mode a large Matroska file gets a sampled probe unless the exact scan is predicted to take at most 150 ms.
75
+ - Only Vite 8 is verified; the other bundlers are documented, not tested ([bundlers](docs/bundlers.md)).
76
+
77
+ ### Not yet
78
+
79
+ - React entry (0.2.0), bundler matrix and peers job (0.3.0), npm publishing (1.0.0).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Volodymyr Bondarets
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,171 @@
1
- # Temporary Holding Version
1
+ # video-fps-probe
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
4
+
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
+
7
+ ## Install
8
+
9
+ ```
10
+ npm install video-fps-probe
11
+ yarn add video-fps-probe # Yarn classic and berry
12
+ pnpm add video-fps-probe
13
+ bun add video-fps-probe
14
+ ```
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`.
17
+
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
+
20
+ ```
21
+ git clone https://github.com/vbondarets/video-fps-probe.git && cd video-fps-probe
22
+ npm ci && TGZ="$PWD/$(npm pack | tail -n 1)" # builds and checks dist/, writes the tarball, keeps its path
23
+ cd ../your-app && npm install "$TGZ"
24
+ ```
25
+
26
+ In PowerShell: `$tgz = Join-Path $PWD (npm pack | Select-Object -Last 1)`, then `cd ../your-app; npm install $tgz`.
27
+
28
+ ## Quick start
29
+
30
+ ```ts
31
+ import { lazyProbe } from 'video-fps-probe/lazy';
32
+
33
+ const probe = lazyProbe(); // a small stub: loads nothing until the first call
34
+
35
+ const result = await probe.analyze(file); // a broken file is result.error, not a rejection
36
+ const track = result.videoTracks.find((t) => t.trackId === result.primaryTrackId);
37
+ track?.avgFps; // exact rational { num, den, value }
38
+ track?.avgFpsFfprobe; // the avg_frame_rate string of ffprobe, for example '30000/1001'
39
+ track?.presentedFrames; // the frame count
40
+ track?.isVfr;
41
+ track?.rateProfile?.summary; // for example 'Uneven: 5 fps for 4 s, 200 fps for 4 s'
42
+ ```
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.
45
+
46
+ ## React
47
+
48
+ ```tsx
49
+ import { useState } from 'react';
50
+ import { lazyProbe } from 'video-fps-probe/lazy';
51
+ import { VideoFpsProvider, useVideoFps } from 'video-fps-probe/react';
52
+
53
+ const probe = lazyProbe(); // once, at module scope
54
+
55
+ export const App = () => (
56
+ <VideoFpsProvider probe={probe}>
57
+ <Upload />
58
+ </VideoFpsProvider>
59
+ );
60
+
61
+ function Upload() {
62
+ const [file, setFile] = useState<File | null>(null);
63
+ const { status, summary } = useVideoFps(file); // 'idle', 'pending', 'success' or 'error'; also result, error, progress, cancel(), retry()
64
+ return (
65
+ <>
66
+ <input type="file" onChange={(e) => setFile(e.currentTarget.files?.[0] ?? null)} />
67
+ {status === 'success' && <p>{summary?.ok ? `${summary.fps?.toFixed(3)} fps` : summary?.error}</p>}
68
+ </>
69
+ );
70
+ }
71
+ ```
72
+
73
+ React 18 and 19. `useVideoFps` caches each result by the file and the options, runs one analysis through the double effects of `StrictMode`, and aborts a pending one when the last component using it unmounts; `usePrewarmVideoFps()` returns a function that prewarms the probe ([details](docs/api.md#react)).
74
+
75
+ ## Engines
76
+
77
+ Own, the default, needs nothing else. mp4box.js and mediabunny are optional peers for a second opinion or a fallback, and rVFC estimates by playing the file:
78
+
79
+ ```ts
80
+ import { lazyProbe } from 'video-fps-probe/lazy';
81
+ import { mediabunny } from 'video-fps-probe/mediabunny'; // npm install mediabunny
82
+ import { mp4box } from 'video-fps-probe/mp4box'; // npm install mp4box
83
+
84
+ const probe = lazyProbe({ engines: [mp4box(), mediabunny()] });
85
+ await probe.analyze(file, { engine: 'mediabunny' });
86
+ await probe.analyze(file, { fallback: ['mediabunny'] }); // on an error, no frame rate, a timeout or a crash
87
+ const { outcomes, agreement } = await probe.compare(file); // experimental
88
+ ```
89
+
90
+ `agreement.agree` is `true`, `false`, or `null` when nothing could be compared ([rules](docs/api.md#compare-and-agreement)). rVFC (`video-fps-probe/rvfc`) is never used implicitly and needs a visible host element ([engines](docs/api.md#engines)).
91
+
92
+ ## Node
93
+
94
+ ```ts
95
+ import { openAsBlob } from 'node:fs';
96
+ import { analyze } from 'video-fps-probe';
97
+
98
+ const result = await analyze(await openAsBlob(path), { name: path }); // in the calling thread, no worker
99
+ ```
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`.
102
+
103
+ ## Cancellation, timeouts and failing open
104
+
105
+ ```ts
106
+ probe.prewarm(); // when the upload dialog opens
107
+ const deadline = new AbortController();
108
+ const late = new Promise<null>((resolve) => setTimeout(() => (deadline.abort(), resolve(null)), 5000));
109
+ const checks = Promise.all(files.map((f) => probe.tryAnalyze(f, { timeoutMs: 3000, signal: deadline.signal })));
110
+ const results = await Promise.race([checks, late]); // null, or null entries: upload without the check
111
+ ```
112
+
113
+ `tryAnalyze` resolves `null` where `analyze` rejects. `timeoutMs` counts per job once a worker takes it and the signal cancels queued and running jobs, but neither covers main-thread chunk downloads: hence `prewarm()` and the race. A probe and a lazy stub reject only with a `ProbeError`; code that did not load, such as a chunk that is gone after a redeploy, has `reason: 'load'`. [Details](docs/api.md#cancellation-and-failing-open).
114
+
115
+ ## Check before upload
116
+
117
+ ```ts
118
+ import { check } from 'video-fps-probe/check';
119
+ import { klingO1ReferenceVideo } from 'video-fps-probe/presets';
120
+
121
+ const report = check(await probe.tryAnalyze(file, { timeoutMs: 3000 }), klingO1ReferenceVideo.rules);
122
+ if (report.verdict === 'fail') showErrors(report.violations, report.suggestedFps?.reason); // for example 'convert to 24 fps'
123
+ else upload(file); // 'pass', 'warn', or 'unknown': no result, an error or a missing field fails open
124
+ ```
125
+
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)).
127
+
128
+ ## Size
129
+
130
+ <!-- tables:size:start -->
131
+
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:
133
+
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 |
138
+
139
+ An iife worker cannot split, so without the option the own worker is one file with both parsers:
140
+
141
+ ```js
142
+ import { defineConfig } from 'vite';
143
+
144
+ export default defineConfig({ worker: { format: 'es' } });
145
+ ```
146
+
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).
148
+
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.
150
+
151
+ <!-- tables:size:end -->
152
+
153
+ ## Accuracy
154
+
155
+ <!-- tables:accuracy-summary:start -->
156
+
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.
158
+
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).
160
+
161
+ <!-- tables:accuracy-summary:end -->
162
+
163
+ ## Browser support
164
+
165
+ Chrome and Edge 94, Firefox 114, Safari and iOS 15.4: floors derived from the features used, not tested on those versions; CI runs Chromium, Firefox and WebKit. mp4box.js needs Safari 16.4; rVFC needs `requestVideoFrameCallback` (Chrome 83, Firefox 132, Safari 15.4), a visible foreground tab and a playable codec. [Details](docs/benchmarks.md#browser-support).
166
+
167
+ 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
+
169
+ ## License
170
+
171
+ [MIT](LICENSE). The optional peers have their own licenses: mediabunny is MPL-2.0, mp4box is BSD-3-Clause.
@@ -0,0 +1,9 @@
1
+ import type { ProbeResult } from './client/types.js';
2
+ import type { BlobLike } from './core/reader.js';
3
+ import type { AnalyzeOptions } from './core/types.js';
4
+ import type { EngineDescriptor } from './plugins/types.js';
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,5 @@
1
+ export async function analyze(input, options = {}) {
2
+ const { name = typeof File !== 'undefined' && input instanceof File ? input.name : '', engine, ...opts } = options;
3
+ const module = engine ? await engine.load() : (await import("./worker/runtime.js")).ownEngine;
4
+ return module.run(input, name, opts);
5
+ }
@@ -0,0 +1,18 @@
1
+ import type { EngineId, FileResult, Rational } from '../core/types.js';
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,55 @@
1
+ import { primaryTrack } from "../core/result.js";
2
+ export const RELATIVE_TOLERANCE = 0.003;
3
+ export const DURATION_TOLERANCE_SEC = 1e-6;
4
+ const RATIONALS = ['avgFps', 'nominalFps', 'minFps', 'maxFps'];
5
+ const APPROXIMATE = new Set(['estimated', 'sampled']);
6
+ const sameRational = (a, b) => [a.num, a.den, b.num, b.den].every(Number.isSafeInteger) ? BigInt(a.num) * BigInt(b.den) === BigInt(b.num) * BigInt(a.den) : a.num / a.den === b.num / b.den;
7
+ function valueOf(t, field) {
8
+ if (field === 'frames')
9
+ return t.presentedFrames ?? t.frames;
10
+ if (field === 'durationSec')
11
+ return t.durationSec;
12
+ return t[field];
13
+ }
14
+ function equal(field, a, b) {
15
+ const x = valueOf(a, field);
16
+ const y = valueOf(b, field);
17
+ if (typeof x === 'number' && typeof y === 'number')
18
+ return field === 'durationSec' ? Math.abs(x - y) <= DURATION_TOLERANCE_SEC : x === y;
19
+ const p = x;
20
+ const q = y;
21
+ if (sameRational(p, q))
22
+ return true;
23
+ const approximate = APPROXIMATE.has(a.provenance[field]) || APPROXIMATE.has(b.provenance[field]);
24
+ return approximate && Math.abs(p.value / q.value - 1) <= RELATIVE_TOLERANCE;
25
+ }
26
+ export function compareResults(results, reference) {
27
+ const tracks = new Map();
28
+ for (const [engine, result] of Object.entries(results)) {
29
+ const t = result && !result.error ? primaryTrack(result) : null;
30
+ if (t)
31
+ tracks.set(engine, t);
32
+ }
33
+ const engines = [...tracks.keys()];
34
+ const ref = reference !== undefined ? (tracks.has(reference) ? reference : null) : tracks.has('own') ? 'own' : (engines[0] ?? null);
35
+ const fields = [...RATIONALS, 'frames', 'durationSec'].map((field) => {
36
+ const values = {};
37
+ for (const [engine, t] of tracks)
38
+ values[engine] = valueOf(t, field);
39
+ const base = ref === null ? null : tracks.get(ref);
40
+ const differ = [];
41
+ let compared = 0;
42
+ if (base && valueOf(base, field) !== null) {
43
+ for (const [engine, t] of tracks) {
44
+ if (engine === ref || valueOf(t, field) === null)
45
+ continue;
46
+ compared++;
47
+ if (!equal(field, base, t))
48
+ differ.push(engine);
49
+ }
50
+ }
51
+ return { field, values, agree: compared === 0 ? null : differ.length === 0, differ };
52
+ });
53
+ const decided = fields.filter((f) => f.agree !== null);
54
+ return { reference: ref, engines, fields, agree: decided.length === 0 ? null : decided.every((f) => f.agree) };
55
+ }
@@ -0,0 +1,12 @@
1
+ import type { EngineId } from '../core/types.js';
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,10 @@
1
+ export class ProbeError extends Error {
2
+ code;
3
+ engine;
4
+ constructor(code, message, options = {}) {
5
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
6
+ this.name = 'ProbeError';
7
+ this.code = code;
8
+ this.engine = options.engine ?? null;
9
+ }
10
+ }
@@ -0,0 +1,2 @@
1
+ export declare function isAbortError(e: unknown): boolean;
2
+ export declare function isTimeoutError(e: unknown): boolean;
@@ -0,0 +1,8 @@
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);
3
+ export function isAbortError(e) {
4
+ return probeCode(e, 'aborted', 'AbortError');
5
+ }
6
+ export function isTimeoutError(e) {
7
+ return probeCode(e, 'timeout', 'TimeoutError');
8
+ }
@@ -0,0 +1,22 @@
1
+ import type { ContainerKind, EngineId, FileResult } from '../core/types.js';
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,25 @@
1
+ const APPROXIMATE = new Set(['estimated', 'sampled']);
2
+ export function summarize(r) {
3
+ const t = r.primaryTrackId === null ? null : (r.videoTracks.find((x) => x.trackId === r.primaryTrackId) ?? null);
4
+ const error = r.error ?? (!t ? 'no video track' : t.avgFps ? null : 'no frame rate');
5
+ return {
6
+ ok: error === null,
7
+ name: r.name,
8
+ container: r.container,
9
+ engine: r.engine,
10
+ fps: t?.avgFps?.value ?? null,
11
+ fpsRational: t?.avgFps ? `${t.avgFps.num}/${t.avgFps.den}` : null,
12
+ nominalFps: t?.nominalFps?.value ?? null,
13
+ frames: t ? (t.presentedFrames ?? t.frames) : null,
14
+ durationSec: t?.durationSec ?? r.formatDurationSec,
15
+ width: t?.width ?? null,
16
+ height: t?.height ?? null,
17
+ rotation: t?.rotation ?? null,
18
+ isVfr: t?.isVfr ?? null,
19
+ minFps: t?.minFps?.value ?? null,
20
+ maxFps: t?.maxFps?.value ?? null,
21
+ approximate: !!t && ['avgFps', 'nominalFps', 'frames'].some((k) => APPROXIMATE.has(t.provenance[k])),
22
+ error,
23
+ warnings: [...r.warnings, ...(t?.warnings ?? [])],
24
+ };
25
+ }
@@ -0,0 +1,60 @@
1
+ import type { ContainerKind, FileResult } from '../core/types.js';
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;