video-fps-probe 0.4.0-beta.2 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -4,17 +4,77 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions follo
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.4.1 - 2026-11-01
8
+
9
+ ### Added
10
+
11
+ - A bundler matrix in CI ([bundlers](docs/bundlers.md#bundler-matrix)): webpack, Rspack, Next.js with webpack and with Turbopack, Angular, SvelteKit, Nuxt, Parcel and `bun build`, the Vite consumer fixtures installed with pnpm, Yarn Plug'n'Play and Bun, and the seven server entries in the Next.js, SvelteKit and Nuxt routes, in Bun and in Deno. In Chromium, Firefox and WebKit each app must give the Node results, load the probe client lazily, start one own worker per container family and show no error, console warning or failed request. Versions are exact and every fixture with dependencies has a lockfile. A weekly canary runs the latest tools.
12
+
13
+ ### Changed
14
+
15
+ - The bundler docs state what CI verifies. webpack 5, Rspack, Next.js with webpack and with Turbopack, SvelteKit and Nuxt work without configuration, and the sections of the Angular application builder, `bun build` and Parcel show the configuration each needs ([bundlers](docs/bundlers.md#status)). Parcel 2.16.4 also needs `maxParallelRequests: 1`, because a bundle it shares between the classic own workers runs a module before the bundle that registers it is loaded. The package code does not change.
16
+ - The loader table of the CommonJS docs gains Bun, where `require()` takes the CommonJS copy, Deno, where it takes the ESM build, and Node under Yarn Plug'n'Play, where it takes the CommonJS copy ([CommonJS](docs/api.md#commonjs)).
17
+
18
+ ### Known limitations
19
+
20
+ - Without the configuration in [bundlers](docs/bundlers.md#status), `ng build` and `bun build` leave the prebuilt workers out of the output and Parcel 2 does not resolve the subpath exports.
21
+ - `require()` in Bun and in Node under Yarn Plug'n'Play takes the CommonJS copy, a second instance next to `import` ([CommonJS](docs/api.md#commonjs)).
22
+ - The other limitations of [0.4.0](CHANGELOG.md#040---2026-11-01) still hold.
23
+
24
+ ## 0.4.0 - 2026-11-01
25
+
26
+ ### Summary of 0.4.0
27
+
28
+ What 0.4.0 adds over 0.3.0, in one place. The [0.4.0-beta.1](CHANGELOG.md#040-beta1---2026-10-31) and [0.4.0-beta.2](CHANGELOG.md#040-beta2---2026-10-31) sections of the changelog give the details. Every 0.3.0 field and option stays, and the only change to an existing `check()` verdict is the duration window marked as a behaviour change under Changed.
29
+
30
+ - **Header-only analysis on a server.** `video-fps-probe/header` reads only the header of an MP4 or MOV file in the calling thread, under limits on the bytes, the reads, the time, the tracks and the top-level boxes, and resolves the duration of a fragmented file from a sidx, the mfra index or mehd ([header-only analysis](docs/api.md#header-only-analysis)).
31
+ - **A Range source.** `video-fps-probe/url` turns any function that fetches a byte range, such as `fetch` with a presigned URL or an object-storage SDK client, into a source for `analyzeHeader` and the in-thread `analyze`, and checks every reply before it reads the body ([Range source](docs/api.md#range-source)).
32
+ - **Codes and layout.** `errorCode` on every own result, `errorCodeOf`, `errorClass`, `warningCodeOf` and `warningCodesOf` for results of any engine or version, and `layout` on ISO BMFF results: faststart, moov at the end or fragmented ([error codes](docs/api.md#error-codes)).
33
+ - **Color, HDR and aspect ratio.** `codecConfig: true` keeps the codec configuration of each video track. `video-fps-probe/color` reads from it the color names ffprobe 8.1.1 prints, the bit depth, the profile, the RFC 6381 codec string, `hdr` and the Dolby Vision record, and `video-fps-probe/aspect` the sample and display aspect ratios and the display size ([color](docs/api.md#color), [aspect ratio and display size](docs/api.md#aspect-ratio-and-display-size)).
34
+ - **New track fields.** `medianFrameInterval` on the tracks of own, mp4box and mediabunny, and `enabled` on own tracks.
35
+ - **New check rules.** `sizeBasis: 'display'` judges the display size and the display aspect ratio, the `hdr` policy PQ, HLG and Dolby Vision as the stream keys of ffprobe signal them, from the container first and from the SPS when the container is silent, and the duration window of a WebM or Matroska track takes the median frame interval ([check](docs/api.md#check)).
36
+ - **CommonJS for the server entries.** The seven server entries load with `require()` and in Jest with no configuration, and the types of all of them but the root entry name no DOM type. The browser entries stay ESM ([CommonJS](docs/api.md#commonjs)).
37
+ - **The same accuracy and the same critical path.** 0 mismatches against ffprobe 8.1.1 on the 168 committed fixtures and 4878 checks. The package code in the entry chunk of the default configuration is byte for byte that of 0.3.0, 0.67 KB. The first MP4 loads 23.13 KB gzip (22.73 KB in 0.3.0), 24.91 KB with Vite's default worker format (24.47 KB), and the first WebM or MKV 25.76 KB (25.54 KB) ([size matrix](report/size-matrix.md)). The installed package takes 931 KB unpacked (580 KB in 0.3.0), most of the growth the CommonJS copy and the longer docs.
38
+ - **Frozen API.** The codes, the layout, the header and Range source APIs, the color and aspect entries and the new check rule keys are frozen: later releases only add to them ([versioning](docs/api.md#versioning)).
39
+
40
+ ### Added
41
+
42
+ - `check()` rules `sizeBasis` and `hdr`. `sizeBasis: 'display'` makes `frameSize` and `pixels` judge `displayWidth` x `displayHeight`, which `withAspect` sets with the rotation applied, and `aspectRatio` the exact `displayAspectRatio` that ffprobe prints, turned the same way, so an anamorphic 1440x1080 file stored for 16:9 display passes `aspectRatio: { oneOf: ['16:9'] }`, and so does NTSC widescreen, 720x480 with a sample aspect ratio of 32:27, whose display size rounds to 853x480. The default, `'stored'`, judges the stored size as before. `hdr` is a `RatePolicy` over the `color` that `withColor` sets: PQ, HLG, an 8-bit HLG file included, and Dolby Vision are HDR, BT.2020 primaries or 10 bits alone are not. It judges the signalling as the stream keys of ffprobe do, so a `colr` that names a transfer wins over the SPS and an alternative transfer characteristics SEI is not read (see Known limitations below). A missing display size or color is unknown, never a pass, each with a hint: `no display size (needs codecConfig: true and withAspect)`, `no color (needs codecConfig: true and withColor)`, `no color in codecConfig` for a codec configuration without a color source and `no transfer in codecConfig` when the container and the SPS are silent, as for an AV1 or VP9 file whose container carries no color, or the transfer is `reserved`. A result analyzed without `codecConfig: true` (with `analyzeHeader`, without `color: true` for `hdr` or `aspect: true` for the display basis), and one persisted by 0.3.0, are therefore unknown under these rules. A 0.3.x `check()` throws on rules that use `sizeBasis` or `hdr`, since it validates the keys strictly ([check](docs/api.md#check)).
43
+
44
+ ### Frozen from 0.4.0
45
+
46
+ The `CheckRules` keys `hdr`, a `RatePolicy`, and `sizeBasis`, `'stored'` or `'display'` with `'stored'` the default, join the lists of 0.4.0-beta.1 and 0.4.0-beta.2.
47
+
48
+ ### Changed
49
+
50
+ - **Behaviour change:** `check()` now takes the median frame interval, `medianFrameInterval`, as the typical frame interval of a WebM or Matroska track that has no dominant rate and is not constant, where 0.3.0 and the betas took the average interval. `duration` judges the format duration of such a track when it is within that interval of the measured span, so the window of the Chromium recordings narrows from about 27 ms to 17 ms: for a format duration more than 17 ms and at most about 27 ms off the span, the span is judged instead. Tracks with a dominant rate or a constant rate and MP4 and MOV tracks are judged as before, and so are Matroska header scans and results persisted by 0.3.0, which have no `medianFrameInterval`. Every preset verdict over the committed fixtures is unchanged.
51
+ - `video-fps-probe/check` puts 4.22 KB gzip in the entry chunk (3.99 KB in 0.3.0), after a trim that kept every message and every verdict. The critical path of a host that imports it is 4.78 KB (4.55 KB), under its 5.00 KB budget, and with one preset 5.13 KB (4.90 KB). That is over the 5.00 KB budget of the preset configuration, so that budget moved to its measurement plus 3 percent, rounded up to 0.5 KB, 5.50 KB, as the 0.4.0 plan allowed for this opt-in entry. The default critical path does not change.
52
+ - The installed package measures 931 KB unpacked (920 KB in 0.4.0-beta.2), under its 936 KB cap, with the longer changelog and the check rules.
53
+
54
+ ### Fixed
55
+
56
+ - The docs and the 0.4.0-beta.2 notes said a probe posts the bytes of the moov and nothing else. That holds for every moov whose size fits in the file. For a moov whose size field runs past the end of the file, the walk reads and copies everything from the start of the moov to the end of the file, up to 16 MiB, so when such a moov is not the last box the posted copy also holds the boxes after it, an mdat included. The text now says so, and the code is unchanged.
57
+
58
+ ### Known limitations
59
+
60
+ - The color entry does not read the AV1 sequence header, the VP9 frame header, ProRes or HDR10 static metadata (`mdcv`, `clli`, MasteringMetadata), so their values stay null where ffprobe takes them from the decoder, and `hdr` is unknown for such a file whose container carries no color.
61
+ - `hdr` judges the signalling as the stream keys of ffprobe do. A `colr` that names a transfer wins over the SPS, and an alternative transfer characteristics SEI is not read, so a `bt709` colr over a PQ SPS, and HLG signalled only in such an SEI next to a `bt2020-10` transfer (x265 `--atc-sei 18`), pass `hdr: 'reject'` while their decoded frames are HDR.
62
+ - The `rotation` of a Matroska or WebM track stays 0, while `withAspect` applies its ProjectionPoseRoll to the display size.
63
+ - With `codecConfig: true` a probe posts a copy of the moov box, up to 256 MiB, and nothing else, except for a moov whose size field runs past the end of the file, whose copy runs to the end of the file, up to 16 MiB, with the boxes after it when it is not the last box. While the walk runs it can hold the moov twice.
64
+ - The types of the root entry name DOM types such as `Worker`, so a TypeScript project for Node that imports it needs `"DOM"` in `lib` or `skipLibCheck: true`.
65
+ - Only Vite 8 and npm are tested in CI until the bundler matrix of 0.4.1 ([bundlers](docs/bundlers.md)). The Range source and full-mode limitations of [0.4.0-beta.1](CHANGELOG.md#040-beta1---2026-10-31) and the React, zero-config Vite and corpus limitations of [0.3.0](CHANGELOG.md#030---2026-10-30) still hold.
66
+
7
67
  ## 0.4.0-beta.2 - 2026-10-31
8
68
 
9
69
  ### Added
10
70
 
11
71
  - `video-fps-probe/color`: `colorOf(track)` and `withColor(result)` read the codec configuration of a video track and give `color`: the range, primaries, transfer and matrix names that ffprobe 8.1.1 prints, where they came from (`sps`, `colr`, `vpcC` or `Colour`), the bit depth, the numeric profile, the RFC 6381 codec string, `hdr` (`'pq'` or `'hlg'`) and the Dolby Vision configuration record. The container wins over the H.264 or HEVC SPS, as in FFmpeg, and the SPS speaks when the container is silent. A value that is not null equals ffprobe on every committed fixture ([color](docs/api.md#color)).
12
72
  - `video-fps-probe/aspect`: `aspectOf(track)` and `withAspect(result)` give `sampleAspectRatio`, `displayAspectRatio`, `displayWidth` and `displayHeight`, with the pasp, tkhd matrix, tkhd size and SPS VUI precedence of ffprobe 8.1.1 for MP4 and MOV and the Display and PixelCrop elements for Matroska and WebM. The display size has the rotation applied, for Matroska and WebM the ProjectionPoseRoll that ffprobe reports as the display matrix ([aspect ratio and display size](docs/api.md#aspect-ratio-and-display-size)).
13
- - `codecConfig: true` keeps the raw codec configuration of each video track in `codecConfig` for both entries, with no read beyond the walk: the TrackEntry of a Matroska or WebM track, the stsd, tkhd and mvhd boxes of an MP4 or MOV track in `analyzeHeader` and the root `analyze`, and in a probe a view of the moov with the index of the trak, `{ family: 'isobmff-moov', moov, trak }`, a family of its own, from which both entries cut the same boxes. The view lies in a copy of the moov that the walk makes only with the option, so a probe posts the bytes of the moov and nothing else. A configuration whose bytes are missing or no longer `Uint8Array` or `DataView` values, as after `JSON.parse`, or that has a buffer transferred to another thread in any member, the view of the moov, a box or the TrackEntry, gives `color` and the aspect fields undefined, as a track without one does.
73
+ - `codecConfig: true` keeps the raw codec configuration of each video track in `codecConfig` for both entries, with no read beyond the walk: the TrackEntry of a Matroska or WebM track, the stsd, tkhd and mvhd boxes of an MP4 or MOV track in `analyzeHeader` and the root `analyze`, and in a probe a view of the moov with the index of the trak, `{ family: 'isobmff-moov', moov, trak }`, a family of its own, from which both entries cut the same boxes. The view lies in a copy of the moov that the walk makes only with the option, so a probe posts the moov box and nothing else, except for a moov whose size field runs past the end of the file: then it posts everything from the start of the moov to the end of the file, up to 16 MiB, the boxes after a moov that is not the last box included. A configuration whose bytes are missing or no longer `Uint8Array` or `DataView` values, as after `JSON.parse`, or that has a buffer transferred to another thread in any member, the view of the moov, a box or the TrackEntry, gives `color` and the aspect fields undefined, as a track without one does.
14
74
  - `analyzeHeader` options `codecConfig`, `color` and `aspect`. `color: true` and `aspect: true` apply the two entries from a chunk loaded with `import()` and read no extra byte. Each chunk loads under the deadline and the signal of the call. A chunk never turns a result into an error: one that fails to load or has not loaded by `maxMs` leaves the result as it is with a coded warning, and one that an earlier call loaded, or started to load before it lost to its deadline, is applied even after the deadline, since it reads no byte.
15
75
  - The warning codes `codec-config-unavailable`, for the prefix `codec configuration unavailable`, and `metadata-unavailable`, for `color and aspect unavailable`, which `warningCodeOf` gives to an `analyzeHeader` result whose codec configuration or color and aspect step did not run, so an exhaustive switch over `WarningCode` needs the two cases.
16
76
  - The new optional result fields `codecConfig`, `color`, `sampleAspectRatio`, `displayAspectRatio`, `displayWidth` and `displayHeight` on `VideoTrackResult`, and the types `CodecConfig`, `ColorInfo`, `ColorSource` and `DolbyVisionConfig`.
17
- - CommonJS copies of `video-fps-probe/color` and `video-fps-probe/aspect`, so the seven server entries load with `require()` and in Jest with no configuration, and their types name no DOM type.
77
+ - CommonJS copies of `video-fps-probe/color` and `video-fps-probe/aspect`, so the seven server entries load with `require()` and in Jest with no configuration, and the types of all of them but the root entry name no DOM type.
18
78
 
19
79
  ### Frozen from 0.4.0-beta.2
20
80
 
@@ -30,7 +90,7 @@ The members of `ColorInfo` and `DolbyVisionConfig`, the `ColorSource` values, th
30
90
 
31
91
  ### Known limitations
32
92
 
33
- - A probe gives an MP4 or MOV track a view of the moov rather than the three boxes, because cutting them in the worker measured 0.25 KB on the first MP4 path, over the 0.08 KB this release allows there. `colorOf` and `aspectOf` read both forms. The view lies in a copy of the moov, so `postMessage` copies the moov and nothing else: the moov box, up to 256 MiB, or the part the walk parsed of a moov whose size runs past the end of the file, up to 16 MiB. Without the copy a probe posted the whole read the moov came from, up to 16 MiB with media data after an mdat. While the walk runs it can hold the moov twice, the read and the copy, up to 512 MiB for a moov at the 256 MiB limit.
93
+ - A probe gives an MP4 or MOV track a view of the moov rather than the three boxes, because cutting them in the worker measured 0.25 KB on the first MP4 path, over the 0.08 KB this release allows there. `colorOf` and `aspectOf` read both forms. The view lies in a copy of the moov, so `postMessage` copies the moov box, up to 256 MiB, and nothing else, except for a moov whose size field runs past the end of the file, where it copies everything from the start of the moov to the end of the file, up to 16 MiB, the boxes after a moov that is not the last box included. Without the copy a probe posted the whole read the moov came from, up to 16 MiB with media data after an mdat. While the walk runs it can hold the moov twice, the read and the copy, up to 512 MiB for a moov at the 256 MiB limit.
34
94
  - The color entry does not read the AV1 sequence header, the VP9 frame header, ProRes or HDR10 static metadata (`mdcv`, `clli`, MasteringMetadata), so their values stay null where ffprobe takes them from the decoder. An HLG stream signalled only by an alternative transfer characteristics SEI keeps the `bt2020-10` transfer of its VUI, as the stream keys of ffprobe do. The `rotation` of a Matroska or WebM track stays 0, while `withAspect` applies its ProjectionPoseRoll to the display size.
35
95
 
36
96
  ## 0.4.0-beta.1 - 2026-10-31
package/README.md CHANGED
@@ -4,6 +4,8 @@ Frame rate, frame count, VFR detection and a rate profile for MP4, MOV, WebM and
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
 
7
+ The demo runs every capability on your own files or on its samples, each with a short explanation and code to copy for the browser and for a Node server: the [full analysis](https://vbondarets.github.io/video-fps-probe/) with color, HDR, Dolby Vision and aspect ratio, the [header-only analysis](https://vbondarets.github.io/video-fps-probe/#header) under limits, a file [from a URL](https://vbondarets.github.io/video-fps-probe/#url) through Range requests, [upload rules](https://vbondarets.github.io/video-fps-probe/#check) with the presets, and the [error codes](https://vbondarets.github.io/video-fps-probe/#errors).
8
+
7
9
  ## Install
8
10
 
9
11
  ```
@@ -13,7 +15,7 @@ pnpm add video-fps-probe
13
15
  bun add video-fps-probe
14
16
  ```
15
17
 
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`.
18
+ The first npm release is 0.2.0, and one publish serves every package manager ([publishing](docs/publishing.md)). CI installs the tarball with npm, pnpm, Yarn PnP and bun, and builds it with Vite, webpack, Rspack, Next.js, Angular, SvelteKit, Nuxt, Parcel and Bun ([bundlers](docs/bundlers.md#status)). 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
19
 
18
20
  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
21
 
@@ -98,7 +100,7 @@ import { analyze } from 'video-fps-probe';
98
100
  const result = await analyze(await openAsBlob(path), { name: path }); // in the calling thread, no worker
99
101
  ```
100
102
 
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.
103
+ 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/color`, `video-fps-probe/aspect`, `video-fps-probe/check` and `video-fps-probe/presets` need neither.
102
104
 
103
105
  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
106
 
@@ -133,7 +135,7 @@ if (report.verdict === 'fail') showErrors(report.violations, report.suggestedFps
133
135
  else upload(file); // 'pass', 'warn', or 'unknown': no result, an error or a missing field fails open
134
136
  ```
135
137
 
136
- 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)).
138
+ 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`. On a result analyzed with `codecConfig: true`, `sizeBasis: 'display'` judges the display size and the display aspect ratio that `withAspect` sets, and `hdr: 'reject'` fails PQ, HLG and Dolby Vision as the stream keys of ffprobe signal them, the container first and the SPS when the container is silent, from the color that `withColor` sets, while a file whose color is not known stays unknown ([check](docs/api.md#check), [presets](docs/api.md#presets)).
137
139
 
138
140
  ## Size
139
141
 
@@ -156,7 +158,7 @@ export default defineConfig({ worker: { format: 'es' } });
156
158
 
157
159
  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.95 KB, mediabunny 54.16 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).
158
160
 
159
- `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.
161
+ `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 4.22 KB when imported statically, and nothing lazily. One preset from `video-fps-probe/presets` adds 0.46 KB.
160
162
 
161
163
  <!-- tables:size:end -->
162
164
 
@@ -178,8 +180,8 @@ Known limitations: [changelog](CHANGELOG.md#known-limitations), [benchmarks](doc
178
180
 
179
181
  ## Roadmap
180
182
 
181
- - **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.
182
- - **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.
183
+ - **0.4.2**: a CI guarantee of a clean console: the e2e and consumer smoke tests fail on any console warning or error, page error or unhandled rejection in Chromium, Firefox and WebKit, and a Node test checks that the library writes nothing to stdout or stderr.
184
+ - **Later in 0.4.x**: HDR10 static metadata (`mdcv`, `clli`, MasteringMetadata) and the color of the AV1 sequence header, which the color entry of 0.4.0 does not read yet.
183
185
  - **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.
184
186
 
185
187
  ## Feedback
@@ -38,6 +38,8 @@ export interface CheckRules {
38
38
  audio?: 'required' | 'forbidden';
39
39
  vfr?: RatePolicy;
40
40
  uneven?: RatePolicy;
41
+ hdr?: RatePolicy;
42
+ sizeBasis?: 'stored' | 'display';
41
43
  severity?: 'fail' | 'warn';
42
44
  }
43
45
  export type CheckExpected = number | string | readonly (number | string)[] | null;
@@ -4,7 +4,8 @@ const SPAN_SLACK_MS = 3n;
4
4
  const MAX_EXPONENT = 400;
5
5
  const NOMINAL_SLACK_PER_S = 10000000n;
6
6
  const NTSC = [24n, 30n, 48n, 60n, 120n];
7
- const STANDARD = [...[24n, 25n, 30n, 48n, 50n, 60n, 100n, 120n].map((m) => [m, 1n]), ...NTSC.map((m) => [m * 1000n, 1001n])];
7
+ const drop = (m) => [m * 1000n, 1001n];
8
+ const STANDARD = [...[24n, 25n, 30n, 48n, 50n, 60n, 100n, 120n].map((m) => [m, 1n]), ...NTSC.map(drop)];
8
9
  const RELATION = { min: 'below the minimum', max: 'above the maximum', exact: 'not', oneOf: 'not one of' };
9
10
  const BOUNDS = Object.keys(RELATION);
10
11
  const GROUPS = ['fps', 'duration', 'frameSize', 'aspectRatio', 'pixels', 'fileSize'];
@@ -14,14 +15,14 @@ const AUDIO_ENGINES = ['own', 'mp4box', 'mediabunny'];
14
15
  const RANGE = ['min', 'max'];
15
16
  const FILE_RULES = ['fileSize', 'containers', 'audio'];
16
17
  const GROUP_KEYS = {
17
- fps: [...RANGE, 'exact', 'oneOf', 'source'],
18
+ fps: [...BOUNDS, 'source'],
18
19
  duration: RANGE,
19
20
  frameSize: ['Width', 'Height', 'ShortSide', 'LongSide'].flatMap((side) => RANGE.map((b) => b + side)),
20
21
  aspectRatio: [...RANGE, 'oneOf'],
21
22
  pixels: RANGE,
22
23
  fileSize: RANGE,
23
24
  };
24
- const RULE_KEYS = [...GROUPS, 'containers', 'codecs', 'audio', 'vfr', 'uneven', 'severity'];
25
+ const RULE_KEYS = [...GROUPS, 'containers', 'codecs', 'audio', 'vfr', 'uneven', 'hdr', 'sizeBasis', 'severity'];
25
26
  const invalid = (rule, v, what) => {
26
27
  throw new TypeError(`${rule}: ${typeof v === 'number' ? v : JSON.stringify(v)} is not ${what}`);
27
28
  };
@@ -35,6 +36,7 @@ function decimal(s) {
35
36
  const n = BigInt(m[1] + (m[2] ?? ''));
36
37
  return e >= 0 ? [n * 10n ** BigInt(e), 1n] : [n, 10n ** BigInt(-e)];
37
38
  }
39
+ const quantity = (x) => (typeof x === 'number' ? decimal(String(x)) : null);
38
40
  function ratio(v, rule, amount = false) {
39
41
  const parts = typeof v === 'number' || typeof v === 'string' ? String(v).split(/[/:]/) : [];
40
42
  const [a, b] = parts.map(decimal);
@@ -43,14 +45,13 @@ function ratio(v, rule, amount = false) {
43
45
  return q && q[1] !== 0n ? q : invalid(rule, v, amount ? `a non-negative number${ratios}` : "a non-negative number or 'num/den'");
44
46
  }
45
47
  const cmp = (a, b) => a[0] * b[1] - b[0] * a[1];
46
- const ntsc = (v, q) => (/[/:]/.test(String(v)) ? [] : NTSC.filter((m) => q[0] * 1000n === q[1] * m * 999n).map((m) => [m * 1000n, 1001n]));
48
+ const ntsc = (v, q) => (/[/:]/.test(String(v)) ? [] : NTSC.filter((m) => q[0] * 1000n === q[1] * m * 999n).map(drop));
47
49
  const readings = (v, rule) => {
48
50
  const q = ratio(v, rule);
49
51
  return [q, ...ntsc(v, q)];
50
52
  };
51
53
  function close(n, a, b, slack, perSecond) {
52
- const gap = n * (a[1] * b[0] - b[1] * a[0]);
53
- return (gap < 0n ? -gap : gap) * perSecond <= slack * a[0] * b[0];
54
+ return abs(n * cmp(a, b)) * perSecond <= slack * a[0] * b[0];
54
55
  }
55
56
  const whole = (x) => Number.isSafeInteger(x) && x > 0;
56
57
  const record = (x) => !!x && typeof x === 'object' && !Array.isArray(x);
@@ -69,7 +70,6 @@ const failedWith = (e) => `the analysis failed${typeof e === 'string' && e.trim(
69
70
  const fromRational = (r) => (r && whole(r.num) && whole(r.den) ? [BigInt(r.num), BigInt(r.den)] : null);
70
71
  const gcd = (a, b) => (b === 0n ? a : gcd(b, a % b));
71
72
  const value = (q) => Number(q[0]) / Number(q[1]);
72
- const round = (x) => +x.toFixed(3);
73
73
  const micros = (q) => (q[0] * 2000000n + q[1]) / (2n * q[1]);
74
74
  function digits(q, against) {
75
75
  let d = 3;
@@ -82,28 +82,30 @@ function fraction(q) {
82
82
  return `${q[0] / g}/${q[1] / g}`;
83
83
  }
84
84
  const fpsText = (q, against = []) => (q[0] % q[1] === 0n ? `${q[0] / q[1]}` : `${digits(q, against)} (${fraction(q)})`);
85
- function steady(t) {
85
+ function onTicks(t) {
86
86
  const avg = fromRational(t.avgFps);
87
- const nominal = fromRational(t.nominalFps);
88
87
  const frames = t.presentedFrames ?? t.frames;
89
- if (!avg || !nominal || !t.timestampsQuantized || t.isVfr !== false || !whole(frames))
90
- return null;
91
- const n = BigInt(frames);
92
- return close(n, avg, nominal, SPAN_SLACK_MS, 1000n) ? [nominal, [n * nominal[1], nominal[0]]] : null;
88
+ return avg && t.timestampsQuantized && t.isVfr === false && whole(frames) ? [avg, BigInt(frames)] : null;
89
+ }
90
+ function steady(t) {
91
+ const q = onTicks(t);
92
+ const nominal = fromRational(t.nominalFps);
93
+ return q && nominal && close(q[1], q[0], nominal, SPAN_SLACK_MS, 1000n) ? [nominal, [q[1] * nominal[1], nominal[0]]] : null;
93
94
  }
94
95
  function resolution(t, cfr) {
95
- const avg = fromRational(t.avgFps);
96
- const frames = t.presentedFrames ?? t.frames;
97
- if (!avg || !t.timestampsQuantized || t.isVfr !== false || !whole(frames))
96
+ const q = onTicks(t);
97
+ if (!q)
98
98
  return undefined;
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
+ return cfr ? (x) => close(1n, cfr[0], x, 1n, NOMINAL_SLACK_PER_S) : (x) => close(q[1], q[0], x, SPAN_SLACK_MS, 1000n);
100
100
  }
101
101
  function printedDuration(t, result) {
102
102
  const span = t.durationSec;
103
103
  const format = result.formatDurationSec;
104
- const typical = fromRational(t.dominantFps?.rate ?? (t.isVfr === false ? t.nominalFps : null) ?? t.avgFps);
104
+ const rate = t.dominantFps?.rate ?? (t.isVfr === false ? t.nominalFps : null);
105
+ const median = rate ? null : t.medianFrameInterval;
106
+ const typical = fromRational(median ? { num: median.den, den: median.num } : (rate ?? t.avgFps));
105
107
  const matroska = result.container === 'webm' || result.container === 'matroska';
106
- const [a, b] = [span, format].map((x) => (typeof x === 'number' ? decimal(String(x)) : null));
108
+ const [a, b] = [span, format].map(quantity);
107
109
  const gap = a && b && abs(micros(a) - micros(b));
108
110
  return matroska && typical && gap != null && gap * typical[0] <= 1000000n * typical[1] ? format : (span ?? format);
109
111
  }
@@ -153,57 +155,50 @@ function fit(target, rule = {}) {
153
155
  const decimates = cmp(target, [DECIMATE_FROM[0] * closest[0], DECIMATE_FROM[1] * closest[1]]) >= 0;
154
156
  return (decimates && divided(allowed)) || [closest, 0];
155
157
  }
158
+ function known(path, v, keys, prefix = `${path}.`) {
159
+ if (!record(v))
160
+ invalid(path, v, 'an object');
161
+ for (const k of Object.keys(v))
162
+ if (!keys.includes(k))
163
+ throw new TypeError(`${prefix}${k}: not a rule; expected one of ${keys.join(', ')}`);
164
+ }
156
165
  export function check(result, rules) {
157
- const known = (path, v, keys) => {
158
- if (!v || typeof v !== 'object' || Array.isArray(v))
159
- invalid(path, v, 'an object');
160
- const prefix = path === 'rules' ? '' : `${path}.`;
161
- for (const k of Object.keys(v))
162
- if (!keys.includes(k))
163
- throw new TypeError(`${prefix}${k}: not a rule; expected one of ${keys.join(', ')}`);
164
- };
165
- known('rules', rules, RULE_KEYS);
166
+ known('rules', rules, RULE_KEYS, '');
166
167
  for (const g of GROUPS)
167
168
  if (rules[g] != null)
168
169
  known(g, rules[g], GROUP_KEYS[g]);
169
- const choices = [
170
- ['fps.source', rules.fps?.source, SOURCES],
171
- ['audio', rules.audio, ['required', 'forbidden']],
172
- ['vfr', rules.vfr, POLICIES],
173
- ['uneven', rules.uneven, POLICIES],
174
- ['severity', rules.severity, ['fail', 'warn']],
175
- ];
176
- for (const [rule, v, list] of choices)
170
+ const choices = { 'fps.source': SOURCES, audio: ['required', 'forbidden'], vfr: POLICIES, uneven: POLICIES, hdr: POLICIES, sizeBasis: ['stored', 'display'], severity: ['fail', 'warn'] };
171
+ for (const [rule, list] of Object.entries(choices)) {
172
+ const v = rule === 'fps.source' ? rules.fps?.source : rules[rule];
177
173
  if (v != null && !list.includes(v))
178
174
  invalid(rule, v, `one of ${list.join(', ')}`);
175
+ }
179
176
  const violations = [];
180
177
  const severity = rules.severity === 'warn' ? 'warn' : 'fail';
181
- const failure = result == null ? 'the analysis failed: no result' : result.error ? failedWith(result.error) : null;
178
+ const failure = result == null ? failedWith('no result') : result.error ? failedWith(result.error) : null;
182
179
  const found = failure ?? primaryTrack(result);
183
180
  const t = typeof found === 'string' ? null : found;
184
181
  const add = (rule, s, expected, actual, message) => violations.push({ rule, severity: s, expected, actual, message: `${rule}: ${message}` });
185
182
  const unknown = (rule, missing) => violations.some((v) => v.rule === rule) ||
186
183
  add(rule, 'unknown', null, null, `unknown, ${t || FILE_RULES.includes(rule) ? `no ${missing}${failure ? `; ${failure}` : ''}` : found}`);
187
- const limits = (group, rule, actual, shown, text, unit, missing, side = '', near) => {
184
+ const limits = (group, rule, actual, shown, unit, missing, side = '', text = () => `${shown}`, near) => {
188
185
  const bounds = BOUNDS.filter((b) => rule?.[b] != null).map((b) => {
189
186
  const path = `${group}.${b}${side}`;
190
187
  const values = (b === 'oneOf' ? [rule[b]].flat() : [rule[b]]).map((v) => (group === 'fps' ? readings(v, path) : [ratio(v, path, group !== 'aspectRatio')]));
191
- return [b, rule[b], values];
188
+ return [b, rule[b], values, path];
192
189
  });
193
190
  if (bounds.length === 0)
194
191
  return;
195
192
  if (!actual)
196
193
  return void unknown(group, missing);
197
- for (const [b, expected, values] of bounds) {
194
+ for (const [b, expected, values, path] of bounds) {
198
195
  const meets = (q) => near?.(q) || (b === 'min' ? cmp(actual, q) >= 0n : b === 'max' ? cmp(actual, q) <= 0n : cmp(actual, q) === 0n);
199
196
  const listed = [expected].flat();
200
197
  const named = listed.length ? `${listed.join(', ')}${unit}` : 'an empty list';
201
- const actualText = typeof text === 'string' ? text : text(values.flat());
202
198
  if (!values.flat().some(meets))
203
- add(`${group}.${b}${side}`, severity, expected, shown, `${actualText}${unit} is ${RELATION[b]} ${named}`);
199
+ add(path, severity, expected, shown, `${text(values.flat())}${unit} is ${RELATION[b]} ${named}`);
204
200
  }
205
201
  };
206
- const quantity = (x) => (typeof x === 'number' ? decimal(String(x)) : null);
207
202
  const allow = (rule, list, actual, missing) => {
208
203
  if (list == null)
209
204
  return;
@@ -225,25 +220,30 @@ export function check(result, rules) {
225
220
  return state === true;
226
221
  };
227
222
  const cfr = t && steady(t);
228
- const fps = t && judgedFps(t, rules.fps?.source, cfr);
229
- const near = t && (rules.fps?.source ?? 'avg') === 'avg' ? resolution(t, cfr) : undefined;
230
- limits('fps', rules.fps, fps, fps ? fraction(fps) : '', (against) => fpsText(fps, against), ' fps', `${rules.fps?.source ?? 'avg'} frame rate`, '', near);
223
+ const source = rules.fps?.source ?? 'avg';
224
+ const fps = t && judgedFps(t, source, cfr);
225
+ const near = t && source === 'avg' ? resolution(t, cfr) : undefined;
226
+ limits('fps', rules.fps, fps, fps ? fraction(fps) : '', ' fps', `${source} frame rate`, '', (against) => fpsText(fps, against), near);
231
227
  const exact = cfr?.[1] ?? quantity(t && printedDuration(t, result));
232
228
  const span = exact && [micros(exact), 1000000n];
233
229
  const seconds = span ? value(span) : 0;
234
- limits('duration', rules.duration, span, seconds, (against) => `${digits(span, against)}`, ' s', 'duration');
235
- const size = t ? (Math.abs(t.rotation) % 180 === 90 ? [t.height, t.width] : [t.width, t.height]) : [0, 0];
230
+ limits('duration', rules.duration, span, seconds, ' s', 'duration', '', (against) => `${digits(span, against)}`);
231
+ const display = rules.sizeBasis === 'display';
232
+ const turned = Math.abs(t?.rotation) % 180 === 90;
233
+ const size = t ? (display ? [t.displayWidth, t.displayHeight] : turned ? [t.height, t.width] : [t.width, t.height]) : [0, 0];
236
234
  const sized = size.every(whole);
237
235
  const [w, h] = sized ? size : [0, 0];
236
+ const sizeOf = display ? `display size${t?.displayWidth === undefined ? ' (needs codecConfig: true and withAspect)' : ''}` : 'frame size';
238
237
  const sides = { Width: w, Height: h, ShortSide: Math.min(w, h), LongSide: Math.max(w, h) };
239
238
  const d = rules.frameSize;
240
239
  for (const [side, x] of Object.entries(sides)) {
241
- limits('frameSize', { min: d?.[`min${side}`], max: d?.[`max${side}`] }, sized ? [BigInt(x), 1n] : null, x, `${x}`, ' px', 'frame size', side);
240
+ limits('frameSize', { min: d?.[`min${side}`], max: d?.[`max${side}`] }, sized ? [BigInt(x), 1n] : null, x, ' px', sizeOf, side);
242
241
  }
243
- const area = [BigInt(w), BigInt(h)];
244
- limits('aspectRatio', rules.aspectRatio, sized ? area : null, fraction(area), `${w}x${h} (${round(w / h)})`, '', 'frame size');
245
- limits('pixels', rules.pixels, sized ? [BigInt(w) * BigInt(h), 1n] : null, w * h, `${w * h}`, ' px', 'frame size');
246
- limits('fileSize', rules.fileSize, quantity(result?.size), result?.size ?? '', `${result?.size}`, ' bytes', 'file size');
242
+ const dar = display && fromRational(t?.displayAspectRatio);
243
+ const area = dar ? (turned || t.displayHeight !== t.height ? [dar[1], dar[0]] : dar) : [BigInt(w), BigInt(h)];
244
+ limits('aspectRatio', rules.aspectRatio, sized ? area : null, fraction(area), '', sizeOf, '', () => `${w}x${h} (${digits(area, [])})`);
245
+ limits('pixels', rules.pixels, sized ? [BigInt(w) * BigInt(h), 1n] : null, w * h, ' px', sizeOf);
246
+ limits('fileSize', rules.fileSize, quantity(result?.size), result?.size ?? '', ' bytes', 'file size');
247
247
  allow('containers', rules.containers, result?.container, 'container');
248
248
  allow('codecs', rules.codecs, t?.codecName, 'codec name');
249
249
  if (rules.audio) {
@@ -261,6 +261,9 @@ export function check(result, rules) {
261
261
  const profile = t?.rateProfile;
262
262
  const summary = record(profile) && typeof profile.summary === 'string' ? `: ${profile.summary}` : '';
263
263
  const uneven = policy('uneven', t && unevenOf(t), `${profile == null ? '' : 'uneven flag in the '}rate profile`, `the frame rate is uneven${summary}`);
264
+ const c = t?.color;
265
+ const hdr = record(c?.dolbyVision) || c?.hdr === 'pq' || c?.hdr === 'hlg' || (typeof c?.transfer === 'string' && c.transfer && c.transfer !== 'reserved' ? false : null);
266
+ policy('hdr', hdr, c === undefined ? 'color (needs codecConfig: true and withColor)' : c ? 'transfer in codecConfig' : 'color in codecConfig', 'the video is HDR');
264
267
  const report = { verdict: 'pass', violations };
265
268
  const fpsOff = violations.some((v) => v.rule.startsWith('fps.'));
266
269
  if (t && (fpsOff || vfr || uneven)) {
@@ -38,6 +38,8 @@ export interface CheckRules {
38
38
  audio?: 'required' | 'forbidden';
39
39
  vfr?: RatePolicy;
40
40
  uneven?: RatePolicy;
41
+ hdr?: RatePolicy;
42
+ sizeBasis?: 'stored' | 'display';
41
43
  severity?: 'fail' | 'warn';
42
44
  }
43
45
  export type CheckExpected = number | string | readonly (number | string)[] | null;
@@ -1 +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;
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=[3n,2n],t=3n,n=[24n,30n,48n,60n,120n],drop=e=>[e*1000n,1001n],r=[...[24n,25n,30n,48n,50n,60n,100n,120n].map(e=>[e,1n]),...n.map(drop)],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:[...a,`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`,`hdr`,`sizeBasis`,`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)]}const quantity=e=>typeof e==`number`?decimal(String(e)):null;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(drop),readings=(e,t)=>{let n=ratio(e,t);return[n,...ntsc(e,n)]};function close(e,t,n,r,i){return abs(e*cmp(t,n))*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]),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 onTicks(e){let t=fromRational(e.avgFps),n=e.presentedFrames??e.frames;return t&&e.timestampsQuantized&&e.isVfr===!1&&whole(n)?[t,BigInt(n)]:null}function steady(e){let n=onTicks(e),r=fromRational(e.nominalFps);return n&&r&&close(n[1],n[0],r,t,1000n)?[r,[n[1]*r[1],r[0]]]:null}function resolution(e,n){let r=onTicks(e);if(r)return n?e=>close(1n,n[0],e,1n,10000000n):e=>close(r[1],r[0],e,t,1000n)}function printedDuration(e,t){let n=e.durationSec,r=t.formatDurationSec,i=e.dominantFps?.rate??(e.isVfr===!1?e.nominalFps:null),a=i?null:e.medianFrameInterval,o=fromRational(a?{num:a.den,den:a.num}:i??e.avgFps),s=t.container===`webm`||t.container===`matroska`,[c,l]=[n,r].map(quantity),u=c&&l&&abs(micros(c)-micros(l));return s&&o&&u!=null&&u*o[0]<=1000000n*o[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 known(e,t,n,r=`${e}.`){record(t)||invalid(e,t,`an object`);for(let e of Object.keys(t))if(!n.includes(e))throw TypeError(`${r}${e}: not a rule; expected one of ${n.join(`, `)}`)}function check(e,t){known(`rules`,t,p,``);for(let e of o)t[e]!=null&&known(e,t[e],f[e]);let n={"fps.source":c,audio:[`required`,`forbidden`],vfr:s,uneven:s,hdr:s,sizeBasis:[`stored`,`display`],severity:[`fail`,`warn`]};for(let[e,r]of Object.entries(n)){let n=e===`fps.source`?t.fps?.source:t[e];n!=null&&!r.includes(n)&&invalid(e,n,`one of ${r.join(`, `)}`)}let r=[],u=t.severity===`warn`?`warn`:`fail`,m=e==null?failedWith(`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=()=>`${r}`,d)=>{let f=a.filter(e=>t?.[e]!=null).map(n=>{let r=`${e}.${n}${c}`,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,r]});if(f.length!==0){if(!n)return void unknown(e,s);for(let[e,t,a,s]of f){let meets=t=>d?.(t)||(e===`min`?cmp(n,t)>=0n:e===`max`?cmp(n,t)<=0n:cmp(n,t)===0n),c=[t].flat(),f=c.length?`${c.join(`, `)}${o}`:`an empty list`;a.flat().some(meets)||add(s,u,t,r,`${l(a.flat())}${o} is ${i[e]} ${f}`)}}},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=t.fps?.source??`avg`,y=g&&judgedFps(g,v,_),b=g&&v===`avg`?resolution(g,_):void 0;limits(`fps`,t.fps,y,y?fraction(y):``,` fps`,`${v} frame rate`,``,e=>fpsText(y,e),b);let x=_?.[1]??quantity(g&&printedDuration(g,e)),S=x&&[micros(x),1000000n],C=S?value(S):0;limits(`duration`,t.duration,S,C,` s`,`duration`,``,e=>`${digits(S,e)}`);let w=t.sizeBasis===`display`,T=Math.abs(g?.rotation)%180==90,E=g?w?[g.displayWidth,g.displayHeight]:T?[g.height,g.width]:[g.width,g.height]:[0,0],D=E.every(whole),[O,k]=D?E:[0,0],A=w?`display size${g?.displayWidth===void 0?` (needs codecConfig: true and withAspect)`:``}`:`frame size`,j={Width:O,Height:k,ShortSide:Math.min(O,k),LongSide:Math.max(O,k)},M=t.frameSize;for(let[e,t]of Object.entries(j))limits(`frameSize`,{min:M?.[`min${e}`],max:M?.[`max${e}`]},D?[BigInt(t),1n]:null,t,` px`,A,e);let N=w&&fromRational(g?.displayAspectRatio),P=N?T||g.displayHeight!==g.height?[N[1],N[0]]:N:[BigInt(O),BigInt(k)];if(limits(`aspectRatio`,t.aspectRatio,D?P:null,fraction(P),``,A,``,()=>`${O}x${k} (${digits(P,[])})`),limits(`pixels`,t.pixels,D?[BigInt(O)*BigInt(k),1n]:null,O*k,` px`,A),limits(`fileSize`,t.fileSize,quantity(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 F=policy(`vfr`,g?.isVfr,`VFR flag`,`the frame rate is variable`),I=g?.rateProfile,L=record(I)&&typeof I.summary==`string`?`: ${I.summary}`:``,R=policy(`uneven`,g&&unevenOf(g),`${I==null?``:`uneven flag in the `}rate profile`,`the frame rate is uneven${L}`),z=g?.color;policy(`hdr`,record(z?.dolbyVision)||z?.hdr===`pq`||z?.hdr===`hlg`||(typeof z?.transfer==`string`&&z.transfer&&z.transfer!==`reserved`?!1:null),z===void 0?`color (needs codecConfig: true and withColor)`:z?`transfer in codecConfig`:`color in codecConfig`,`the video is HDR`);let B={verdict:`pass`,violations:r},V=r.some(e=>e.rule.startsWith(`fps.`));if(g&&(V||F||R)){let e=V?y: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=V?`${fpsText(y,r?[r[0]]:[])} fps is not allowed`:`the frame rate is ${F?`variable`:`uneven`}`;if(!r)B.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`:``;B.suggestedFps={value:value(e),reason:`${i}: ${V?`convert to`:`re-encode at a constant`} ${fpsText(e)} fps${n}`}}}return B.verdict=[`fail`,`unknown`,`warn`].find(e=>r.some(t=>t.severity===e))??`pass`,B}exports.check=check;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "video-fps-probe",
3
3
  "private": false,
4
- "version": "0.4.0-beta.2",
4
+ "version": "0.4.1",
5
5
  "description": "Video frame rate, frame count and VFR detection from MP4/MOV/WebM/MKV container headers in a Web Worker. Matches ffprobe 8.1.1 on the test corpus (0 mismatches), no decoding.",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -298,6 +298,9 @@
298
298
  "size": "node scripts/size-report.mjs demo/dist",
299
299
  "consumers:setup": "node scripts/consumers.mjs setup",
300
300
  "consumers:build": "node scripts/consumers.mjs build",
301
+ "bundlers:setup": "node scripts/bundlers.mjs setup",
302
+ "bundlers:build": "node scripts/bundlers.mjs build",
303
+ "test:bundlers": "playwright test -c playwright.bundlers.config.ts",
301
304
  "size:matrix": "node scripts/size-matrix.mjs --check",
302
305
  "size:matrix:write": "node scripts/size-matrix.mjs",
303
306
  "bench": "node scripts/bench/run-bench.mjs",