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 +63 -3
- package/README.md +8 -6
- package/dist/check/index.d.ts +2 -0
- package/dist/check/index.js +56 -53
- package/dist/cjs/check/index.d.cts +2 -0
- package/dist/cjs/check.cjs +1 -1
- package/package.json +4 -1
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
|
|
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
|
|
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
|
|
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)).
|
|
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
|
|
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
|
|
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.
|
|
182
|
-
- **0.4.
|
|
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
|
package/dist/check/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/check/index.js
CHANGED
|
@@ -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
|
|
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: [...
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
96
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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 ? '
|
|
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,
|
|
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(
|
|
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
|
|
229
|
-
const
|
|
230
|
-
|
|
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)}
|
|
235
|
-
const
|
|
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,
|
|
240
|
+
limits('frameSize', { min: d?.[`min${side}`], max: d?.[`max${side}`] }, sized ? [BigInt(x), 1n] : null, x, ' px', sizeOf, side);
|
|
242
241
|
}
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
limits('
|
|
246
|
-
limits('
|
|
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;
|
package/dist/cjs/check.cjs
CHANGED
|
@@ -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(
|
|
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&÷d(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.
|
|
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",
|