video-fps-probe 0.2.0 → 0.3.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 (52) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +26 -14
  3. package/dist/api/errors.js +1 -1
  4. package/dist/api/guards.d.ts +2 -0
  5. package/dist/api/guards.js +2 -1
  6. package/dist/check/index.js +12 -2
  7. package/dist/client/compare.js +2 -2
  8. package/dist/client/create.d.ts +3 -0
  9. package/dist/client/create.js +8 -0
  10. package/dist/client/fallback.js +2 -2
  11. package/dist/client/index.d.ts +1 -1
  12. package/dist/client/index.js +1 -1
  13. package/dist/client/inline.js +1 -1
  14. package/dist/client/lazy.js +1 -1
  15. package/dist/client/probe-client.js +4 -0
  16. package/dist/client/probe.js +23 -22
  17. package/dist/client/route.js +20 -0
  18. package/dist/client/split.js +9 -0
  19. package/dist/client/stub.js +2 -2
  20. package/dist/client/types.d.ts +1 -0
  21. package/dist/core/analyze.js +4 -78
  22. package/dist/core/dispatch.js +76 -0
  23. package/dist/core/isobmff/boxes.js +4 -7
  24. package/dist/core/isobmff/index.js +8 -5
  25. package/dist/core/isobmff/model.js +1 -1
  26. package/dist/core/isobmff/moov.js +12 -7
  27. package/dist/core/isobmff/track.js +4 -2
  28. package/dist/core/matroska/ebml.js +1 -1
  29. package/dist/core/reader.d.ts +0 -12
  30. package/dist/core/reader.js +10 -27
  31. package/dist/engines/common.js +6 -0
  32. package/dist/engines/mediabunny.js +17 -4
  33. package/dist/engines/mp4box.js +2 -1
  34. package/dist/plugins/own.js +5 -2
  35. package/dist/presets/index.d.ts +1 -0
  36. package/dist/presets/sources.json +8 -0
  37. package/dist/probe.js +2 -1
  38. package/dist/react/store.js +1 -1
  39. package/dist/standalone/own-isobmff.worker.js +1 -0
  40. package/dist/standalone/own-matroska.worker.js +1 -0
  41. package/dist/standalone/own.worker.js +1 -1
  42. package/dist/worker/own-isobmff.worker.js +4 -0
  43. package/dist/worker/own-matroska.worker.js +4 -0
  44. package/dist/worker/runtime.d.ts +2 -3
  45. package/dist/worker/runtime.js +2 -97
  46. package/dist/worker/serve.d.ts +3 -0
  47. package/dist/worker/serve.js +97 -0
  48. package/dist/worker/single.js +13 -0
  49. package/package.json +8 -2
  50. package/dist/client/inline.d.ts +0 -19
  51. package/dist/client/pool.d.ts +0 -29
  52. package/dist/client/probe.d.ts +0 -29
package/CHANGELOG.md CHANGED
@@ -4,6 +4,57 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions follo
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.3.0 - 2026-10-30
8
+
9
+ ### Added
10
+
11
+ - `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)).
12
+ - `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.
13
+ - `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)).
14
+ - 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)).
15
+ - Issue templates for bugs and feature requests, and GitHub Discussions for questions and for showing how you use the package ([feedback](README.md#feedback)).
16
+
17
+ ### Changed
18
+
19
+ - **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.
20
+ - 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)).
21
+ - `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.
22
+ - 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.
23
+ - 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.
24
+ - `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).
25
+ - 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.
26
+ - Still 0 mismatches against ffprobe 8.1.1, now on 130 committed fixtures ([accuracy](docs/benchmarks.md#accuracy-vs-ffprobe)).
27
+ - 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.
28
+
29
+ ### Deprecated
30
+
31
+ - `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.
32
+
33
+ ### Fixed
34
+
35
+ - 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.
36
+ - 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.
37
+ - Warnings could hold raw NUL and other control characters from the file, such as a box type. Every engine now replaces them with `?`.
38
+ - 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.
39
+ - 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)).
40
+ - `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)).
41
+
42
+ ### Known limitations
43
+
44
+ - 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" } }`.
45
+ - 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' }`.
46
+ - 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.
47
+ - 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)).
48
+ - 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.
49
+ - `check` judges the stored frame size: a `pasp` box or a Matroska DisplayWidth is not applied.
50
+ - The only real recordings in the corpus come from Chromium's MediaRecorder. More in [benchmarks](docs/benchmarks.md#limitations).
51
+
52
+ ### Not yet
53
+
54
+ - 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.
55
+ - 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.
56
+ - 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.
57
+
7
58
  ## 0.2.0 - 2026-10-25
8
59
 
9
60
  ### Added
package/README.md CHANGED
@@ -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; 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.3.x). 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,9 @@ 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 name DOM types such as `Worker` and `HTMLElement`, so a TypeScript project for Node needs `"DOM"` in `lib` or `skipLibCheck: true`.
102
+
103
+ CommonJS works on Node 22.12 and newer, 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 package test checks both from the packed tarball on Node 22.12 and 24. Jest needs babel-jest to transform the package, with plugins for `import.meta` and `import()`: the tested configuration and a stub for unit tests are in [Jest](docs/api.md#jest).
102
104
 
103
105
  ## Cancellation, timeouts and failing open
104
106
 
@@ -123,20 +125,20 @@ if (report.verdict === 'fail') showErrors(report.violations, report.suggestedFps
123
125
  else upload(file); // 'pass', 'warn', or 'unknown': no result, an error or a missing field fails open
124
126
  ```
125
127
 
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)).
128
+ 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
129
 
128
130
  ## Size
129
131
 
130
132
  <!-- tables:size:start -->
131
133
 
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:
134
+ **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.40 KB with Vite's preload helper). The rest loads lazily, on the first file:
133
135
 
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 |
136
+ | Host Vite config | Critical path | First MP4 | First MKV | All own paths | Worker files |
137
+ |---|---:|---:|---:|---:|---:|
138
+ | `worker: { format: 'es' }` (recommended) | **0.67 KB** | 22.73 KB | 25.54 KB | 48.61 KB | 2 |
139
+ | none (Vite's default, 'iife') | **0.66 KB** | 24.47 KB | 27.08 KB | 48.27 KB | 2 |
138
140
 
139
- An iife worker cannot split, so without the option the own worker is one file with both parsers:
141
+ 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
142
 
141
143
  ```js
142
144
  import { defineConfig } from 'vite';
@@ -144,9 +146,9 @@ import { defineConfig } from 'vite';
144
146
  export default defineConfig({ worker: { format: 'es' } });
145
147
  ```
146
148
 
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).
149
+ 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.92 KB, mediabunny 54.15 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
150
 
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.
151
+ `video-fps-probe/react` adds 1.20 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.99 KB when imported statically, and nothing lazily; one preset from `video-fps-probe/presets` adds 0.46 KB.
150
152
 
151
153
  <!-- tables:size:end -->
152
154
 
@@ -154,9 +156,9 @@ Engines cost nothing until imported. Importing a descriptor grows the critical p
154
156
 
155
157
  <!-- tables:accuracy-summary:start -->
156
158
 
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.
159
+ **0 mismatches against ffprobe 8.1.1** on the 130 committed fixtures: 2054 field checks, 1976 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
160
 
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).
161
+ Of the 130 committed fixtures, 118 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
162
 
161
163
  <!-- tables:accuracy-summary:end -->
162
164
 
@@ -166,6 +168,16 @@ Chrome and Edge 94, Firefox 114, Safari and iOS 15.4: floors derived from the fe
166
168
 
167
169
  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
170
 
171
+ ## Roadmap
172
+
173
+ - **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.
174
+ - **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.
175
+ - **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.
176
+
177
+ ## Feedback
178
+
179
+ 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.
180
+
169
181
  ## License
170
182
 
171
183
  [MIT](LICENSE). The optional peers have their own licenses: mediabunny is MPL-2.0, mp4box is BSD-3-Clause.
@@ -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];
@@ -1,6 +1,6 @@
1
1
  import { compareResults } from "../api/agreement.js";
2
2
  import { ProbeError } from "../api/errors.js";
3
- import { isAbortError } from "../api/guards.js";
3
+ import { isAbortError, isProbeError } from "../api/guards.js";
4
4
  import { failureOf } from "./fallback.js";
5
5
  import { nameOf } from "./probe.js";
6
6
  export async function compare(ctx, file, o = {}) {
@@ -25,7 +25,7 @@ export async function compare(ctx, file, o = {}) {
25
25
  catch (e) {
26
26
  if (isAbortError(e))
27
27
  throw e;
28
- const error = e instanceof ProbeError ? e : Object.assign(new ProbeError('worker-failed', String(e), { engine: id, cause: e }), { reason: 'crash' });
28
+ const error = isProbeError(e) ? e : Object.assign(new ProbeError('worker-failed', String(e), { engine: id, cause: e }), { reason: 'crash' });
29
29
  if (error.code === 'unsupported-environment')
30
30
  return { status: 'skipped', reason: 'unsupported-environment' };
31
31
  return { status: error.code === 'timeout' ? 'timeout' : 'crashed', error };
@@ -0,0 +1,3 @@
1
+ import type { EngineDescriptor, WorkerEngineId } from '../plugins/types.js';
2
+ import type { ClientOptions, IdOf, Probe } from './types.js';
3
+ export declare function createProbeClient<const E extends readonly EngineDescriptor[] = [], const W extends WorkerEngineId = never>(options: ClientOptions<E, W>): Probe<W | IdOf<E[number]> | 'own'>;
@@ -0,0 +1,8 @@
1
+ import { createProbeCore } from "./probe.js";
2
+ import { splitBy } from "./split.js";
3
+ const splitOnFirstRun = splitBy((pool, lazy, life) => ({
4
+ run: (request, options) => lazy(import("./route.js"), request.engine).then((m) => m.runByHead(pool, lazy, life).run(request, options)),
5
+ }));
6
+ export function createProbeClient(options) {
7
+ return createProbeCore({ ...options, split: splitOnFirstRun });
8
+ }
@@ -1,5 +1,5 @@
1
1
  import { ProbeError } from "../api/errors.js";
2
- import { isAbortError } from "../api/guards.js";
2
+ import { isAbortError, isProbeError } from "../api/guards.js";
3
3
  import { emptyAnalysis, primaryTrack } from "../core/result.js";
4
4
  export const failureOf = (r) => {
5
5
  const t = primaryTrack(r);
@@ -25,7 +25,7 @@ export async function runChain(run, chain, file, name, wire, call, first) {
25
25
  catch (e) {
26
26
  if (isAbortError(e))
27
27
  throw e;
28
- last = e instanceof ProbeError ? e : Object.assign(new ProbeError('worker-failed', String(e), { engine: id, cause: e }), { reason: 'crash' });
28
+ last = isProbeError(e) ? e : Object.assign(new ProbeError('worker-failed', String(e), { engine: id, cause: e }), { reason: 'crash' });
29
29
  failures.push({ engine: id, error: last.message });
30
30
  continue;
31
31
  }
@@ -7,5 +7,5 @@ export type * from '../core/types.js';
7
7
  export type { MediabunnyNative, MediabunnyTrackNative, Mp4boxNative, Mp4boxTrackNative, RvfcConfidence, RvfcDetails, RvfcPass } from '../engines/types.js';
8
8
  export type { ContainerFamily, EngineDescriptor, EngineModule, WorkerEngineId } from '../plugins/types.js';
9
9
  export type { WireOptions } from '../worker/protocol.js';
10
- export { createProbeClient } from './probe.js';
10
+ export { createProbeClient } from './create.js';
11
11
  export type * from './types.js';
@@ -2,4 +2,4 @@ export { compareResults } from "../api/agreement.js";
2
2
  export { ProbeError } from "../api/errors.js";
3
3
  export { isAbortError, isTimeoutError } from "../api/guards.js";
4
4
  export { summarize } from "../api/summary.js";
5
- export { createProbeClient } from "./probe.js";
5
+ export { createProbeClient } from "./create.js";
@@ -38,6 +38,6 @@ export function runInline(load, file, name, options, run) {
38
38
  }
39
39
  const opts = { ...options, signal: controller.signal, onProgress, host };
40
40
  return { type: 'result', result: await module.run(file, name, opts) };
41
- })().then((reply) => finish(() => resolve(reply)), (e) => (e instanceof ProbeError ? finish(() => reject(e)) : fail(Object.assign(new ProbeError('worker-failed', e instanceof Error ? e.message || e.name : String(e), { engine, cause: e }), { reason: loading ? 'load' : 'crash' }))));
41
+ })().then((reply) => finish(() => resolve(reply)), (e) => (e?.name === 'ProbeError' && typeof e.code === 'string' ? finish(() => reject(e)) : fail(Object.assign(new ProbeError('worker-failed', e instanceof Error ? e.message || e.name : String(e), { engine, cause: e }), { reason: loading ? 'load' : 'crash' }))));
42
42
  });
43
43
  }
@@ -3,5 +3,5 @@ export function lazyProbeFrom(factory) {
3
3
  return lazyStub(factory, []);
4
4
  }
5
5
  export function lazyProbeClient(options) {
6
- return lazyStub(() => import("./probe.js").then((m) => m.createProbeClient(options)), knownEngines(options.worker, options.engines));
6
+ return lazyStub(() => import("./probe-client.js").then((m) => m.loadProbeClient(options)), knownEngines(options.worker, options.engines));
7
7
  }
@@ -0,0 +1,4 @@
1
+ import { createProbeCore } from "./probe.js";
2
+ export const loadProbeClient = (options) => options.worker?.byContainer
3
+ ? import("./route.js").then((m) => createProbeCore({ ...options, split: m.splitRoute }))
4
+ : createProbeCore(options);
@@ -2,10 +2,11 @@ import { ProbeError } from "../api/errors.js";
2
2
  import { WorkerPool, workerFailed } from "./pool.js";
3
3
  const notRegistered = (id, message = `engine ${id} is not registered`) => new ProbeError('engine-not-registered', message, { engine: id });
4
4
  export const nameOf = (file) => (typeof File !== 'undefined' && file instanceof File ? file.name : '');
5
- const loadOwnInThread = async () => (await import("../worker/runtime.js")).ownEngine;
5
+ const loadOwnInThread = () => import("../worker/runtime.js").then((m) => m.ownEngine);
6
6
  export function createProbeCore(options) {
7
- const { worker, engines: descriptors = [], engine: defaultEngine = 'own', fallback: defaultFallback = [], workers, idleTimeoutMs, timeoutMs: defaultTimeout, defaults } = options;
7
+ const { worker, engines: descriptors = [], engine: defaultEngine = 'own', fallback: defaultFallback = [], workers, idleTimeoutMs, timeoutMs: defaultTimeout, defaults, split } = options;
8
8
  const custom = typeof worker === 'object' ? worker : null;
9
+ const byContainer = custom?.byContainer;
9
10
  const inline = worker === 'inline' || typeof Worker === 'undefined';
10
11
  const pools = new Map();
11
12
  const byId = new Map();
@@ -21,27 +22,29 @@ export function createProbeCore(options) {
21
22
  stop();
22
23
  chunk.then(resolve, (e) => reject(workerFailed(String(e), 'load', engine, e))).finally(() => life.signal.removeEventListener('abort', stop));
23
24
  });
24
- const poolOf = (kind, create) => ({
25
- kind,
26
- pool: () => {
27
- let pool = pools.get(kind);
28
- if (!pool)
29
- pools.set(kind, (pool = new WorkerPool({ create, size: workers, idleTimeoutMs })));
30
- return pool;
31
- },
32
- });
25
+ const poolFor = (key, create) => {
26
+ let pool = pools.get(key);
27
+ if (!pool)
28
+ pools.set(key, (pool = new WorkerPool({ create, size: workers, idleTimeoutMs })));
29
+ return pool;
30
+ };
31
+ const poolOf = (kind, create) => ({ kind, pool: () => poolFor(kind, create) });
33
32
  const servedBy = (d, key) => {
34
33
  const load = d?.[key];
35
34
  if (!d || !load)
36
35
  return null;
37
- return !inline && d.thread === 'worker' && d.createWorker ? poolOf(d.id, d.createWorker) : { load };
36
+ if (inline || d.thread !== 'worker')
37
+ return { load };
38
+ if (d.createWorker)
39
+ return poolOf(d.id, d.createWorker);
40
+ return (d.createWorkers && split?.(d.id, d.createWorkers, poolFor, lazy, life.signal)) || { load };
38
41
  };
39
42
  const routeOf = (id) => {
40
43
  const listed = !custom?.engines.includes(id)
41
44
  ? null
42
45
  : inline
43
46
  ? { error: new ProbeError('unsupported-environment', `engine ${id} runs in a worker and Worker is not available`, { engine: id }) }
44
- : poolOf('custom', custom.create);
47
+ : (id === 'own' && byContainer && split?.('custom', byContainer, poolFor, lazy, life.signal, custom.create)) || poolOf('custom', custom.create);
45
48
  if (listed && 'pool' in listed)
46
49
  return listed;
47
50
  const route = id === 'own'
@@ -106,13 +109,14 @@ export function createProbeCore(options) {
106
109
  continue;
107
110
  if ('load' in route)
108
111
  jobs.push(lazy(route.load(), id).then((m) => m.warm?.(containers)));
109
- else {
110
- const pool = route.pool();
111
- warm.set(pool, [...(warm.get(pool) ?? []), id]);
112
- }
112
+ else
113
+ for (const [pool, families] of route.warmPools ? route.warmPools(containers) : [[route.pool(), containers]]) {
114
+ const [engines, union] = warm.get(pool) ?? [[], []];
115
+ warm.set(pool, [[...engines, id], [...new Set([...union, ...families])]]);
116
+ }
113
117
  }
114
- for (const [pool, engines] of warm)
115
- jobs.push(pool.run({ type: 'warm', engines, containers }, { timeoutMs: defaultTimeout }));
118
+ for (const [pool, [engines, union]] of warm)
119
+ jobs.push(pool.run({ type: 'warm', engines, containers: union }, { timeoutMs: defaultTimeout }));
116
120
  await Promise.allSettled(jobs);
117
121
  },
118
122
  dispose() {
@@ -126,6 +130,3 @@ export function createProbeCore(options) {
126
130
  },
127
131
  };
128
132
  }
129
- export function createProbeClient(options) {
130
- return createProbeCore(options);
131
- }
@@ -0,0 +1,20 @@
1
+ import { splitBy } from "./split.js";
2
+ export const EBML = [0x1a, 0x45, 0xdf, 0xa3];
3
+ export const familyOf = (file, signal) => new Promise((resolve) => {
4
+ const iso = () => resolve('isobmff');
5
+ if (signal?.aborted)
6
+ return iso();
7
+ signal?.addEventListener('abort', iso);
8
+ Promise.resolve()
9
+ .then(() => file.slice(0, 4).arrayBuffer())
10
+ .then((b) => {
11
+ const head = new Uint8Array(b);
12
+ return EBML.every((x, i) => head[i] === x) ? 'matroska' : 'isobmff';
13
+ })
14
+ .then(resolve, iso)
15
+ .finally(() => signal?.removeEventListener('abort', iso));
16
+ });
17
+ export const runByHead = (pool, lazy, life) => ({
18
+ run: (request, options) => lazy(familyOf(request.file, options.signal), request.engine).then((family) => (life.aborted ? lazy(Promise.reject(), request.engine) : pool(family).run(request, options))),
19
+ });
20
+ export const splitRoute = splitBy(runByHead);
@@ -0,0 +1,9 @@
1
+ const FAMILIES = ['isobmff', 'matroska'];
2
+ export const splitBy = (runner) => (kind, byFamily, pools, lazy, life, create) => {
3
+ const pool = (family) => {
4
+ const own = byFamily[family];
5
+ return own ? pools(`${kind}-${family}`, own) : pools(kind, create);
6
+ };
7
+ const run = runner(pool, lazy, life);
8
+ return { kind, pool: () => run, warmPools: (containers) => FAMILIES.filter((f) => containers.includes(f)).map((f) => [pool(f), [f]]) };
9
+ };
@@ -1,5 +1,5 @@
1
1
  import { ProbeError } from "../api/errors.js";
2
- export const knownEngines = (worker, descriptors = []) => [...new Set(['own', ...(typeof worker === 'object' ? worker.engines : []), ...descriptors.map((d) => d.id)])];
2
+ export const knownEngines = (worker, descriptors = []) => [...new Set(['own', ...(worker?.engines ?? []), ...descriptors.map((d) => d.id)])];
3
3
  export function lazyStub(factory, known) {
4
4
  let probe = null;
5
5
  let loading = null;
@@ -11,7 +11,7 @@ export function lazyStub(factory, known) {
11
11
  return (probe = p);
12
12
  }, (e) => {
13
13
  loading = null;
14
- throw e instanceof ProbeError ? e : Object.assign(new ProbeError('worker-failed', String(e), { cause: e }), { reason: 'load' });
14
+ throw e?.name === 'ProbeError' && typeof e.code === 'string' ? e : Object.assign(new ProbeError('worker-failed', String(e), { cause: e }), { reason: 'load' });
15
15
  }));
16
16
  const loadUnlessDestroyed = () => new Promise((resolve, reject) => {
17
17
  const stop = () => reject(aborted());
@@ -20,6 +20,7 @@ export interface ProbeOptions<E extends readonly EngineDescriptor[] = []> extend
20
20
  export interface CustomWorker<W extends WorkerEngineId> {
21
21
  create: () => Worker;
22
22
  engines: readonly W[];
23
+ byContainer?: Readonly<Partial<Record<ContainerFamily, () => Worker>>>;
23
24
  }
24
25
  export interface ClientOptions<E extends readonly EngineDescriptor[] = [], W extends WorkerEngineId = never> extends BaseOptions<NoInfer<W | IdOf<E[number]> | 'own'>> {
25
26
  worker: CustomWorker<W> | 'inline';
@@ -1,8 +1,5 @@
1
- import { throwIfAborted } from "./abort.js";
2
- import { resolveOptions } from "./options.js";
3
- import { BlobReader } from "./reader.js";
4
- import { emptyAnalysis } from "./result.js";
5
- import { sniff } from "./sniff.js";
1
+ import { analyzeWith } from "./dispatch.js";
2
+ export { errorMessage, firstReadAhead, isAbortError, SINGLE_READ_MAX_BYTES } from "./dispatch.js";
6
3
  export const DEFAULT_LOADERS = {
7
4
  isobmff: async () => (await Promise.all([import("./isobmff/index.js"), import("./stats.js")]))[0].analyzeIsoBmff,
8
5
  matroska: async () => (await Promise.all([import("./matroska/index.js"), import("./stats.js")]))[0].analyzeMatroska,
@@ -11,77 +8,6 @@ export const DEFAULT_ANALYZERS = {
11
8
  isobmff: async (reader, opts) => (await DEFAULT_LOADERS.isobmff())(reader, opts),
12
9
  matroska: async (reader, opts) => (await DEFAULT_LOADERS.matroska())(reader, opts),
13
10
  };
14
- const SNIFF_BYTES = 1024;
15
- export const SINGLE_READ_MAX_BYTES = 1024 * 1024;
16
- export function firstReadAhead(size, opts) {
17
- const { perReadMs, bytesPerMs } = opts.costModel;
18
- const head = opts.readAhead.head;
19
- return size < Math.max(head, Math.min(head + perReadMs * bytesPerMs, SINGLE_READ_MAX_BYTES)) ? size : head;
20
- }
21
- export function isAbortError(e) {
22
- return typeof e === 'object' && e !== null && 'name' in e && e.name === 'AbortError';
23
- }
24
- export function errorMessage(e) {
25
- if (e instanceof Error)
26
- return e.message || e.name;
27
- return String(e);
28
- }
29
- function applyAnalysis(target, a) {
30
- target.container = a.container;
31
- target.brand = a.brand;
32
- target.formatDurationSec = a.formatDurationSec;
33
- target.scan = a.scan;
34
- target.videoTracks = a.videoTracks;
35
- target.audioTrackCount = a.audioTrackCount;
36
- target.primaryTrackId = a.primaryTrackId;
37
- target.error = a.error;
38
- target.warnings = a.warnings;
39
- }
40
- export async function analyze(blob, name, opts = {}, analyzers = DEFAULT_ANALYZERS, loaders = analyzers === DEFAULT_ANALYZERS ? DEFAULT_LOADERS : null) {
41
- const t0 = performance.now();
42
- const resolved = resolveOptions(opts);
43
- throwIfAborted(resolved.signal);
44
- const result = {
45
- name,
46
- size: blob.size,
47
- ...emptyAnalysis('unknown'),
48
- io: { reads: 0, bytesRead: 0, ms: 0 },
49
- engine: 'own',
50
- };
51
- let reader = null;
52
- let loadMs = 0;
53
- try {
54
- reader = new BlobReader(blob, { headWindow: resolved.readAhead.head, signal: resolved.signal });
55
- if (reader.size === 0) {
56
- result.error = 'not a recognised container: empty file';
57
- }
58
- else {
59
- const head = await reader.bytes(0, Math.min(SNIFF_BYTES, reader.size), firstReadAhead(reader.size, resolved));
60
- const sniffed = sniff(head);
61
- if (sniffed.kind === 'unknown') {
62
- result.error = `not a recognised container: ${sniffed.reason}`;
63
- }
64
- else {
65
- result.container = sniffed.kind === 'isobmff' ? 'mp4' : 'matroska';
66
- let analyzer = analyzers[sniffed.kind];
67
- if (loaders) {
68
- const l0 = performance.now();
69
- analyzer = await loaders[sniffed.kind]();
70
- loadMs = performance.now() - l0;
71
- }
72
- applyAnalysis(result, await analyzer(reader, resolved));
73
- }
74
- }
75
- }
76
- catch (e) {
77
- if (resolved.signal?.aborted || isAbortError(e))
78
- throw e;
79
- result.error = errorMessage(e);
80
- }
81
- if (reader) {
82
- result.io.reads = reader.reads;
83
- result.io.bytesRead = reader.bytesFetched;
84
- }
85
- result.io.ms = performance.now() - t0 - loadMs;
86
- return result;
11
+ export function analyze(blob, name, opts = {}, analyzers = DEFAULT_ANALYZERS, loaders = analyzers === DEFAULT_ANALYZERS ? DEFAULT_LOADERS : null) {
12
+ return analyzeWith(blob, name, opts, loaders ? (kind) => loaders[kind]() : (kind) => analyzers[kind]);
87
13
  }
@@ -0,0 +1,76 @@
1
+ import { throwIfAborted } from "./abort.js";
2
+ import { resolveOptions } from "./options.js";
3
+ import { BlobReader } from "./reader.js";
4
+ import { emptyAnalysis } from "./result.js";
5
+ import { sniff } from "./sniff.js";
6
+ const SNIFF_BYTES = 1024;
7
+ export const SINGLE_READ_MAX_BYTES = 1024 * 1024;
8
+ export function firstReadAhead(size, opts) {
9
+ const { perReadMs, bytesPerMs } = opts.costModel;
10
+ const head = opts.readAhead.head;
11
+ return size < Math.max(head, Math.min(head + perReadMs * bytesPerMs, SINGLE_READ_MAX_BYTES)) ? size : head;
12
+ }
13
+ export function isAbortError(e) {
14
+ return e?.name === 'AbortError';
15
+ }
16
+ export function errorMessage(e) {
17
+ if (e instanceof Error)
18
+ return e.message || e.name;
19
+ return String(e);
20
+ }
21
+ function applyAnalysis(target, a) {
22
+ target.container = a.container;
23
+ target.brand = a.brand;
24
+ target.formatDurationSec = a.formatDurationSec;
25
+ target.scan = a.scan;
26
+ target.videoTracks = a.videoTracks;
27
+ target.audioTrackCount = a.audioTrackCount;
28
+ target.primaryTrackId = a.primaryTrackId;
29
+ target.error = a.error;
30
+ target.warnings = a.warnings;
31
+ }
32
+ export async function analyzeWith(blob, name, opts, analyzerOf) {
33
+ const t0 = performance.now();
34
+ const resolved = resolveOptions(opts);
35
+ throwIfAborted(resolved.signal);
36
+ const result = {
37
+ name,
38
+ size: blob.size,
39
+ ...emptyAnalysis('unknown'),
40
+ io: { reads: 0, bytesRead: 0, ms: 0 },
41
+ engine: 'own',
42
+ };
43
+ let reader = null;
44
+ let loadMs = 0;
45
+ try {
46
+ reader = new BlobReader(blob, { headWindow: resolved.readAhead.head, signal: resolved.signal });
47
+ if (reader.size === 0) {
48
+ result.error = 'not a recognised container: empty file';
49
+ }
50
+ else {
51
+ const head = await reader.bytes(0, Math.min(SNIFF_BYTES, reader.size), firstReadAhead(reader.size, resolved));
52
+ const sniffed = sniff(head);
53
+ if (sniffed.kind === 'unknown') {
54
+ result.error = `not a recognised container: ${sniffed.reason}`;
55
+ }
56
+ else {
57
+ result.container = sniffed.kind === 'isobmff' ? 'mp4' : 'matroska';
58
+ const l0 = performance.now();
59
+ const analyzer = await analyzerOf(sniffed.kind);
60
+ loadMs = performance.now() - l0;
61
+ applyAnalysis(result, await analyzer(reader, resolved));
62
+ }
63
+ }
64
+ }
65
+ catch (e) {
66
+ if (resolved.signal?.aborted || isAbortError(e))
67
+ throw e;
68
+ result.error = errorMessage(e);
69
+ }
70
+ if (reader) {
71
+ result.io.reads = reader.reads;
72
+ result.io.bytesRead = reader.bytesFetched;
73
+ }
74
+ result.io.ms = performance.now() - t0 - loadMs;
75
+ return result;
76
+ }