video-fps-probe 0.4.0-beta.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +72 -0
- package/README.md +13 -10
- package/dist/analyze.js +10 -1
- package/dist/api/codes.js +2 -0
- package/dist/aspect/index.d.ts +13 -0
- package/dist/aspect/index.js +180 -0
- package/dist/aspect/rational.js +61 -0
- package/dist/check/index.d.ts +2 -0
- package/dist/check/index.js +56 -53
- package/dist/cjs/aspect/index.d.cts +13 -0
- package/dist/cjs/aspect.cjs +1 -0
- package/dist/cjs/check/index.d.cts +2 -0
- package/dist/cjs/check.cjs +1 -1
- package/dist/cjs/chunks/boxes-B0T2nt3e.cjs +1 -0
- package/dist/cjs/chunks/build-DhETwJLO.cjs +1 -0
- package/dist/cjs/chunks/color-xqJHSfhZ.cjs +1 -0
- package/dist/cjs/chunks/{compare-D7RVqlqf.cjs → compare-B_HKNwqg.cjs} +1 -1
- package/dist/cjs/chunks/config-CSbXzEqr.cjs +1 -0
- package/dist/cjs/chunks/{fallback-D9Ak2zFv.cjs → fallback-CyPIR3RM.cjs} +1 -1
- package/dist/cjs/chunks/{guards-Dqpjtkdn.cjs → guards-DbQNcAZS.cjs} +1 -1
- package/dist/cjs/chunks/isobmff-4RrRf9N-.cjs +1 -0
- package/dist/cjs/chunks/matroska-BhgWtuPU.cjs +1 -0
- package/dist/cjs/chunks/metadata-ECdzNLkE.cjs +1 -0
- package/dist/cjs/chunks/options-B_vLHxHd.cjs +1 -0
- package/dist/cjs/chunks/{probe-DDLUfr23.cjs → probe-Cn1DtL-C.cjs} +1 -1
- package/dist/cjs/chunks/reader-Dw11ozVU.cjs +1 -0
- package/dist/cjs/chunks/runtime-BgAlQQee.cjs +1 -0
- package/dist/cjs/chunks/{segments-i-hPN7H3.cjs → segments-B1H6bDz-.cjs} +1 -1
- package/dist/cjs/chunks/stats-DW7dVdFa.cjs +1 -0
- package/dist/cjs/color/index.d.cts +7 -0
- package/dist/cjs/color.cjs +1 -0
- package/dist/cjs/core/types.d.cts +46 -1
- package/dist/cjs/header/index.d.cts +3 -0
- package/dist/cjs/header/limits.d.cts +1 -0
- package/dist/cjs/header.cjs +1 -1
- package/dist/cjs/index.cjs +1 -1
- package/dist/color/codec-string.js +121 -0
- package/dist/color/config.js +199 -0
- package/dist/color/index.d.ts +7 -0
- package/dist/color/index.js +139 -0
- package/dist/color/names.js +33 -0
- package/dist/color/sps.js +275 -0
- package/dist/core/isobmff/build.js +6 -3
- package/dist/core/isobmff/config.js +34 -0
- package/dist/core/isobmff/header.js +23 -0
- package/dist/core/isobmff/moov.js +1 -1
- package/dist/core/matroska/header.js +1 -0
- package/dist/core/matroska/index.js +4 -2
- package/dist/core/options.js +1 -0
- package/dist/core/types.d.ts +46 -1
- package/dist/header/index.d.ts +3 -0
- package/dist/header/index.js +39 -3
- package/dist/header/limits.d.ts +1 -0
- package/dist/header/limits.js +4 -0
- package/dist/header/metadata.js +2 -0
- package/dist/standalone/own-isobmff.worker.js +1 -1
- package/dist/standalone/own-matroska.worker.js +1 -1
- package/dist/standalone/own.worker.js +1 -1
- package/package.json +41 -3
- package/dist/cjs/chunks/build-CT5g6_bs.cjs +0 -1
- package/dist/cjs/chunks/isobmff-B_bb5rS5.cjs +0 -1
- package/dist/cjs/chunks/matroska-CLoGzfKA.cjs +0 -1
- package/dist/cjs/chunks/reader-DNLR-_tm.cjs +0 -1
- package/dist/cjs/chunks/runtime-DtgT0xzU.cjs +0 -1
- package/dist/cjs/chunks/stats-D68frkk2.cjs +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,78 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions follo
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.4.0 - 2026-11-01
|
|
8
|
+
|
|
9
|
+
### Summary of 0.4.0
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
- **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)).
|
|
14
|
+
- **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)).
|
|
15
|
+
- **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)).
|
|
16
|
+
- **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)).
|
|
17
|
+
- **New track fields.** `medianFrameInterval` on the tracks of own, mp4box and mediabunny, and `enabled` on own tracks.
|
|
18
|
+
- **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)).
|
|
19
|
+
- **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)).
|
|
20
|
+
- **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.
|
|
21
|
+
- **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)).
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- `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)).
|
|
26
|
+
|
|
27
|
+
### Frozen from 0.4.0
|
|
28
|
+
|
|
29
|
+
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.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- **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.
|
|
34
|
+
- `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.
|
|
35
|
+
- 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.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- 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.
|
|
40
|
+
|
|
41
|
+
### Known limitations
|
|
42
|
+
|
|
43
|
+
- 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.
|
|
44
|
+
- `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.
|
|
45
|
+
- The `rotation` of a Matroska or WebM track stays 0, while `withAspect` applies its ProjectionPoseRoll to the display size.
|
|
46
|
+
- 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.
|
|
47
|
+
- 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`.
|
|
48
|
+
- 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.
|
|
49
|
+
|
|
50
|
+
## 0.4.0-beta.2 - 2026-10-31
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
|
|
54
|
+
- `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)).
|
|
55
|
+
- `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)).
|
|
56
|
+
- `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.
|
|
57
|
+
- `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.
|
|
58
|
+
- 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.
|
|
59
|
+
- The new optional result fields `codecConfig`, `color`, `sampleAspectRatio`, `displayAspectRatio`, `displayWidth` and `displayHeight` on `VideoTrackResult`, and the types `CodecConfig`, `ColorInfo`, `ColorSource` and `DolbyVisionConfig`.
|
|
60
|
+
- 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.
|
|
61
|
+
|
|
62
|
+
### Frozen from 0.4.0-beta.2
|
|
63
|
+
|
|
64
|
+
The members of `ColorInfo` and `DolbyVisionConfig`, the `ColorSource` values, the `codecConfig`, `color` and `aspect` options of `analyzeHeader`, the warning codes `codec-config-unavailable` and `metadata-unavailable` with their prefixes and the signatures of `colorOf`, `withColor`, `aspectOf` and `withAspect` join the list of 0.4.0-beta.1. Later releases only add to them. `CodecConfig` is raw bytes for the two entries and may gain members.
|
|
65
|
+
|
|
66
|
+
### Changed
|
|
67
|
+
|
|
68
|
+
- The installed package may take up to 936 KB unpacked (867 KB in 0.4.0-beta.1), measured at 920 KB, for the two entries, their CommonJS copies and the longer docs.
|
|
69
|
+
- The first MKV or WebM loads 25.76 KB gzip (25.73 KB in 0.4.0-beta.1) for the TrackEntry copy: the Matroska header walk copies every TrackEntry, and the analyzer attaches the copy to the video tracks only with the option. The first MP4 loads 23.13 KB (23.07 KB in 0.4.0-beta.1), 24.91 KB with Vite's default worker format, for the view of the moov that the worker attaches with the option and the copy of the moov it lies in. The 0.4.0 plan gave this step 0.05 KB on the first MP4 path, and the copy takes it to 0.08 KB, measured 0.06 to 0.08 KB over 0.4.0-beta.1, with every first MP4 row inside its budget and the 24.95 KB cap.
|
|
70
|
+
- `video-fps-probe/color` puts 4.93 KB gzip in the entry chunk of an app that imports it, under a budget of 5.0 KB rather than the 4.00 KB the 0.4.0 plan set: the two SPS walkers and the FFmpeg name tables are what make the color result equal to ffprobe, and cutting the boxes out of the moov view of a probe adds 0.25 KB.
|
|
71
|
+
- `video-fps-probe/header` puts 19.81 KB gzip of package code in the entry chunk (19.28 KB in 0.4.0-beta.1). It loads the codec configuration capture and the two entries with `import()`, so an app whose only dynamic import is this one gets Vite's preload helper, 0.75 KB, in its entry chunk too.
|
|
72
|
+
- Still 0 mismatches against ffprobe 8.1.1 on the 168 committed fixtures, now with checks of the color names, bit depth, profile, codec string, Dolby Vision record and both aspect ratios: 4878 checks, with 5 new known deviations where ffprobe takes the color range or matrix from a bitstream the color entry does not read (the AV1 sequence header, ProRes, MPEG-4 Part 2).
|
|
73
|
+
|
|
74
|
+
### Known limitations
|
|
75
|
+
|
|
76
|
+
- 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.
|
|
77
|
+
- 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.
|
|
78
|
+
|
|
7
79
|
## 0.4.0-beta.1 - 2026-10-31
|
|
8
80
|
|
|
9
81
|
### Added
|
package/README.md
CHANGED
|
@@ -98,7 +98,7 @@ import { analyze } from 'video-fps-probe';
|
|
|
98
98
|
const result = await analyze(await openAsBlob(path), { name: path }); // in the calling thread, no worker
|
|
99
99
|
```
|
|
100
100
|
|
|
101
|
-
In-thread `analyze()` is tested on Node 24, 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.
|
|
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/color`, `video-fps-probe/aspect`, `video-fps-probe/check` and `video-fps-probe/presets` need neither.
|
|
102
102
|
|
|
103
103
|
A server can branch on `result.errorCode`, a machine-readable code for `error` such as `unrecognised`, `no-header`, `truncated` or `read-failed`, and on `result.layout`, which tells a faststart MP4 from one with its moov at the end or a fragmented one. `errorCodeOf(result)` also codes results of other engines and stored 0.3.0 JSON from their text, and `errorClass(code)` tells a file problem, which can be cached, from a read failure worth a retry ([error codes](docs/api.md#error-codes)).
|
|
104
104
|
|
|
@@ -106,7 +106,9 @@ A server that checks uploads in object storage can read only the header: `analyz
|
|
|
106
106
|
|
|
107
107
|
`video-fps-probe/url` reads such an object over HTTP Range requests: `analyzeHeader(await openRangeSource(httpRange(presignedUrl)), { limits })`, or `rangeSource(fetcher, { size })` over the client of any object-storage SDK. It needs no SDK and makes one request per read, with no retries, and it checks the status, `Content-Range` and `Content-Length` of every reply before the body is read and caps the body while it streams, so a server that ignores the range or an object whose size changed fails the read as `read-failed` instead of returning wrong bytes. An `If-Match` with the object's ETag, which the caller sends, also catches an overwrite at the same size ([Range source](docs/api.md#range-source)).
|
|
108
108
|
|
|
109
|
-
|
|
109
|
+
The same call can tell an HDR upload from an SDR one and give the display size: `analyzeHeader(source, { color: true, aspect: true })` fills `color` with the range, primaries, transfer and matrix names ffprobe prints, the bit depth, the profile, the RFC 6381 codec string, `hdr` (`'pq'` or `'hlg'`) and the Dolby Vision record, and fills `sampleAspectRatio`, `displayAspectRatio`, `displayWidth` and `displayHeight`, from the boxes of the moov it reads anyway. The container wins over the H.264 or HEVC SPS as in FFmpeg 8.1.1, and the SPS speaks when the container is silent. `video-fps-probe/color` and `video-fps-probe/aspect` do the same for results of `analyzeHeader`, the root `analyze` or a probe with `codecConfig: true` ([Color](docs/api.md#color), [Aspect ratio and display size](docs/api.md#aspect-ratio-and-display-size)).
|
|
110
|
+
|
|
111
|
+
CommonJS works on Node 22.12 and newer, the lowest supported version, which load ES modules from `require()`: `const { analyze } = require('video-fps-probe')`, the subpaths included, and a TypeScript project with `"module": "commonjs"` and `moduleResolution` `node10`, such as a NestJS 11 app. The seven server entries, `video-fps-probe`, `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`, also ship a minified CommonJS copy with `.d.cts` types for loaders that do not run `require(esm)`, so Jest 30 runs them with no configuration and `jest` as the only dependency, and with `--experimental-vm-modules` on Node 24.9 it loads the ESM build instead. The package tests check all of this from the packed tarball on Node 22.12 and 24. The other entries still need babel-jest in Jest: the tested configuration and a stub for unit tests are in [Jest](docs/api.md#jest).
|
|
110
112
|
|
|
111
113
|
## Cancellation, timeouts and failing open
|
|
112
114
|
|
|
@@ -131,18 +133,18 @@ if (report.verdict === 'fail') showErrors(report.violations, report.suggestedFps
|
|
|
131
133
|
else upload(file); // 'pass', 'warn', or 'unknown': no result, an error or a missing field fails open
|
|
132
134
|
```
|
|
133
135
|
|
|
134
|
-
Rules are JSON, such as `{ fps: { min: 24, max: 60 }, duration: { max: 10 }, vfr: 'warn' }`, and frame rates compare as exact rationals: 23.976 fps (`24000/1001`) is below a minimum of 24, 59.94 meets a maximum of 60. The presets hold the published input limits of Kling O1 and Motion Control, Runway Aleph 2.0, Veo 3.1, and Wan 2.6 on fal and Seedance 2.0 on EvoLink (gateways, so they only warn), each with its source and the day it was verified
|
|
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`. 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)).
|
|
135
137
|
|
|
136
138
|
## Size
|
|
137
139
|
|
|
138
140
|
<!-- tables:size:start -->
|
|
139
141
|
|
|
140
|
-
**0.
|
|
142
|
+
**0.67 KB gzip on the critical path**: the package code in the entry chunk of a Vite 8.3.5 app that imports `video-fps-probe/lazy` (1.40 KB with Vite's preload helper). The rest loads lazily, on the first file:
|
|
141
143
|
|
|
142
144
|
| Host Vite config | Critical path | First MP4 | First MKV | All own paths | Worker files |
|
|
143
145
|
|---|---:|---:|---:|---:|---:|
|
|
144
|
-
| `worker: { format: 'es' }` (recommended) | **0.
|
|
145
|
-
| none (Vite's default, 'iife') | **0.66 KB** | 24.
|
|
146
|
+
| `worker: { format: 'es' }` (recommended) | **0.67 KB** | 23.13 KB | 25.76 KB | 49.23 KB | 2 |
|
|
147
|
+
| none (Vite's default, 'iife') | **0.66 KB** | 24.91 KB | 27.21 KB | 48.85 KB | 2 |
|
|
146
148
|
|
|
147
149
|
Own runs on one worker per container family, picked by the first four bytes of the file, so a first file loads only the worker of its family. An iife worker cannot split, so without the option each own worker is one file with its lazy parts inlined:
|
|
148
150
|
|
|
@@ -152,9 +154,9 @@ import { defineConfig } from 'vite';
|
|
|
152
154
|
export default defineConfig({ worker: { format: 'es' } });
|
|
153
155
|
```
|
|
154
156
|
|
|
155
|
-
Engines cost nothing until imported. Importing a descriptor grows the critical path a little, from 0.
|
|
157
|
+
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).
|
|
156
158
|
|
|
157
|
-
`video-fps-probe/react` adds 1.20 KB to the critical path for the provider, `useVideoFps` and its store (React itself not counted), and its first analysis also fetches a 0.43 KB summary module, in parallel with the probe client. `video-fps-probe/check` adds
|
|
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 4.22 KB when imported statically, and nothing lazily. One preset from `video-fps-probe/presets` adds 0.46 KB.
|
|
158
160
|
|
|
159
161
|
<!-- tables:size:end -->
|
|
160
162
|
|
|
@@ -162,7 +164,7 @@ Engines cost nothing until imported. Importing a descriptor grows the critical p
|
|
|
162
164
|
|
|
163
165
|
<!-- tables:accuracy-summary:start -->
|
|
164
166
|
|
|
165
|
-
**0 mismatches against ffprobe 8.1.1** on the 168 committed fixtures:
|
|
167
|
+
**0 mismatches against ffprobe 8.1.1** on the 168 committed fixtures: 4878 field checks, 4675 exact, 13 within tolerance (9 of them a definition difference by design: the measured average of a Matroska file that stores no duration for its last frame, which avgFps gives the median interval and ffprobe its own estimate), 7 known deviations with a recorded reason, 183 not applicable. The same comparison runs mp4box.js 2.4.1 and mediabunny 1.62.1 in 5 arms (their idiomatic recipes, mediabunny with a full scan, and this package's tuned adapters) and sorts each difference into wrong, definition differs, unverified, not provided or unsupported container. The [accuracy section](docs/benchmarks.md#accuracy-vs-ffprobe) has the outcomes and where the libraries win.
|
|
166
168
|
|
|
167
169
|
Of the 168 committed fixtures, 156 are synthetic files made with ffmpeg and 12 are real MediaRecorder recordings from headless Chromium, committed as recorded. Per-engine tables, speed and method: [docs/benchmarks.md](docs/benchmarks.md).
|
|
168
170
|
|
|
@@ -176,8 +178,9 @@ Known limitations: [changelog](CHANGELOG.md#known-limitations), [benchmarks](doc
|
|
|
176
178
|
|
|
177
179
|
## Roadmap
|
|
178
180
|
|
|
179
|
-
- **0.4.0**: a header-only ISO BMFF mode with byte, read and time limits, machine-readable error codes and a container layout field, a Range source for S3 and R2 style object storage (a fetcher function, no SDK dependency), minimal color, HDR and Dolby Vision metadata, the pixel aspect ratio and display size, a median frame interval that `check` uses, and a CommonJS build of the server entries only, while the browser entries stay ESM.
|
|
180
181
|
- **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.
|
|
182
|
+
- **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.
|
|
183
|
+
- **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.
|
|
181
184
|
- **0.5.0**: a Node entry (`fromPath`, DOM-free types, a `worker_threads` pool), NestJS validators, a CLI, bitrate, GOP and audio details, cadence detection, framework adapters for Vue, Svelte and Angular, and later MPEG-TS and HLS.
|
|
182
185
|
|
|
183
186
|
## Feedback
|
package/dist/analyze.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
export async function analyze(input, options = {}) {
|
|
2
2
|
const { name = typeof File !== 'undefined' && input instanceof File ? input.name : '', engine, ...opts } = options;
|
|
3
3
|
const module = engine ? await engine.load() : (await import("./worker/runtime.js")).ownEngine;
|
|
4
|
-
|
|
4
|
+
const result = await module.run(input, name, opts);
|
|
5
|
+
if (!result.videoTracks.some((t) => t.codecConfig?.family === 'isobmff-moov'))
|
|
6
|
+
return result;
|
|
7
|
+
try {
|
|
8
|
+
const { isoBoxes } = await import("./core/isobmff/config.js");
|
|
9
|
+
return { ...result, videoTracks: result.videoTracks.map((t) => (t.codecConfig?.family === 'isobmff-moov' ? { ...t, codecConfig: isoBoxes(t.codecConfig.moov, t.codecConfig.trak) } : t)) };
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
5
14
|
}
|
package/dist/api/codes.js
CHANGED
|
@@ -49,6 +49,8 @@ const WARNING_PREFIXES = [
|
|
|
49
49
|
['format duration unresolved: ', 'duration-unresolved'],
|
|
50
50
|
['fragments ignored: ', 'fragments-ignored'],
|
|
51
51
|
['too many top-level boxes', 'structure-limit'],
|
|
52
|
+
['codec configuration unavailable', 'codec-config-unavailable'],
|
|
53
|
+
['color and aspect unavailable', 'metadata-unavailable'],
|
|
52
54
|
];
|
|
53
55
|
const TRUNCATED_BOX = /^'(.{4})' at \d+ is truncated/;
|
|
54
56
|
export function errorCodeOf(result) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { FileResult, Rational, VideoTrackResult } from '../core/types.js';
|
|
2
|
+
export type { CodecConfig, Rational } from '../core/types.js';
|
|
3
|
+
export interface AspectInfo {
|
|
4
|
+
sampleAspectRatio: Rational | null;
|
|
5
|
+
displayAspectRatio: Rational | null;
|
|
6
|
+
displayWidth: number | null;
|
|
7
|
+
displayHeight: number | null;
|
|
8
|
+
}
|
|
9
|
+
export type AspectTrack = Pick<VideoTrackResult, 'codecName' | 'width' | 'height' | 'rotation' | 'codecConfig'>;
|
|
10
|
+
export declare function aspectOf(track: AspectTrack): AspectInfo | undefined;
|
|
11
|
+
export declare function withAspect<R extends Pick<FileResult, 'videoTracks'>>(result: R, options?: {
|
|
12
|
+
keepCodecConfig?: boolean;
|
|
13
|
+
}): R;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { boxPayload, elementUint, elements, firstBox, i32, ID_DISPLAY_HEIGHT, ID_DISPLAY_UNIT, ID_DISPLAY_WIDTH, ID_PIXEL_HEIGHT, ID_PIXEL_WIDTH, ID_STEREO_MODE, isoConfig, matroskaConfig, u32, } from "../color/config.js";
|
|
2
|
+
import { avcConfigSps, hevcConfigSps } from "../color/sps.js";
|
|
3
|
+
import { avD2q, avReduce, INT_MAX } from "./rational.js";
|
|
4
|
+
const DAR_MAX = 1048576n;
|
|
5
|
+
const MATRIX_SCALE_LIMIT = 1 << 24;
|
|
6
|
+
const MATRIX_SCALE_TOLERANCE = 0.01;
|
|
7
|
+
const DISPLAY_UNIT_UNKNOWN = 4;
|
|
8
|
+
const WIDTH_TIMES_TWO = new Set([1, 8, 9, 11]);
|
|
9
|
+
const HEIGHT_TIMES_TWO = new Set([2, 3, 6, 7]);
|
|
10
|
+
const ID_PIXEL_CROP_BOTTOM = 0x54aa;
|
|
11
|
+
const ID_PIXEL_CROP_TOP = 0x54bb;
|
|
12
|
+
const ID_PIXEL_CROP_LEFT = 0x54cc;
|
|
13
|
+
const ID_PIXEL_CROP_RIGHT = 0x54dd;
|
|
14
|
+
const ID_PROJECTION = 0x7670;
|
|
15
|
+
const ID_PROJECTION_TYPE = 0x7671;
|
|
16
|
+
const ID_PROJECTION_PRIVATE = 0x7672;
|
|
17
|
+
const ID_PROJECTION_POSE_YAW = 0x7673;
|
|
18
|
+
const ID_PROJECTION_POSE_PITCH = 0x7674;
|
|
19
|
+
const ID_PROJECTION_POSE_ROLL = 0x7675;
|
|
20
|
+
function rational([num, den]) {
|
|
21
|
+
return { num: Number(num), den: Number(den), value: Number(num) / Number(den) };
|
|
22
|
+
}
|
|
23
|
+
const valid = (r) => r !== null && r[0] > 0n && r[1] > 0n;
|
|
24
|
+
function vuiRatio(sps) {
|
|
25
|
+
const sar = sps?.vui?.sar;
|
|
26
|
+
return sar ? avReduce(BigInt(sar[0]), BigInt(sar[1]), INT_MAX) : null;
|
|
27
|
+
}
|
|
28
|
+
function configSps(codec, b, s) {
|
|
29
|
+
return !s ? null : codec === 'h264' ? avcConfigSps(b, s) : codec === 'hevc' ? hevcConfigSps(b, s) : null;
|
|
30
|
+
}
|
|
31
|
+
function matrixOf(box, offset) {
|
|
32
|
+
const p = boxPayload(box);
|
|
33
|
+
if (!box || !p)
|
|
34
|
+
return null;
|
|
35
|
+
const at = p.start + offset(box[p.start]);
|
|
36
|
+
if (at + 36 > p.end)
|
|
37
|
+
return null;
|
|
38
|
+
return Array.from({ length: 9 }, (_, i) => BigInt(i32(box, at + i * 4)));
|
|
39
|
+
}
|
|
40
|
+
function tkhdSizeAt(iso) {
|
|
41
|
+
const tkhd = boxPayload(iso.tkhd);
|
|
42
|
+
const at = iso.tkhd && tkhd ? tkhd.start + (iso.tkhd[tkhd.start] === 1 ? 88 : 76) : -1;
|
|
43
|
+
return tkhd && at + 8 <= tkhd.end ? at : -1;
|
|
44
|
+
}
|
|
45
|
+
function matrixRatio(iso, sizeAt) {
|
|
46
|
+
if (!iso.tkhd || sizeAt < 0 || !u32(iso.tkhd, sizeAt) || !u32(iso.tkhd, sizeAt + 4))
|
|
47
|
+
return null;
|
|
48
|
+
const track = matrixOf(iso.tkhd, (v) => (v === 1 ? 52 : 40));
|
|
49
|
+
if (!track)
|
|
50
|
+
return null;
|
|
51
|
+
const movie = matrixOf(iso.mvhd, (v) => (v === 1 ? 48 : 36)) ?? Array(9).fill(0n);
|
|
52
|
+
const shift = [16n, 16n, 30n];
|
|
53
|
+
const m = [];
|
|
54
|
+
for (let i = 0; i < 3; i++) {
|
|
55
|
+
for (let j = 0; j < 3; j++) {
|
|
56
|
+
let sum = 0n;
|
|
57
|
+
for (let e = 0; e < 3; e++)
|
|
58
|
+
sum += (track[i * 3 + e] * movie[e * 3 + j]) >> shift[e];
|
|
59
|
+
m.push(Number(BigInt.asIntN(32, sum)));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const identity = m[0] === 65536 && m[4] === 65536 && m[8] === 1 << 30 && !m[1] && !m[2] && !m[3] && !m[5] && !m[6] && !m[7];
|
|
63
|
+
if (identity)
|
|
64
|
+
return null;
|
|
65
|
+
const x = Math.hypot(m[0], m[3]);
|
|
66
|
+
const y = Math.hypot(m[1], m[4]);
|
|
67
|
+
if (!(x > 1 && y > 1 && x < MATRIX_SCALE_LIMIT && y < MATRIX_SCALE_LIMIT && Math.abs(x / y - 1) > MATRIX_SCALE_TOLERANCE))
|
|
68
|
+
return null;
|
|
69
|
+
return avD2q(x / y, INT_MAX);
|
|
70
|
+
}
|
|
71
|
+
function isoRatio(track, iso) {
|
|
72
|
+
const sizeAt = tkhdSizeAt(iso);
|
|
73
|
+
let pasp = null;
|
|
74
|
+
for (const entry of iso.entries) {
|
|
75
|
+
for (const c of entry.children) {
|
|
76
|
+
if (c.type === 'pasp' && c.end - c.start >= 8 && i32(iso.bytes, c.start + 4))
|
|
77
|
+
pasp = [BigInt(i32(iso.bytes, c.start)), BigInt(i32(iso.bytes, c.start + 4))];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
let sar = pasp?.[0] ? avReduce(pasp[0], pasp[1], INT_MAX) : matrixRatio(iso, sizeAt);
|
|
81
|
+
if (!sar?.[0] && iso.tkhd && sizeAt >= 0) {
|
|
82
|
+
const w = BigInt(i32(iso.tkhd, sizeAt) >> 16);
|
|
83
|
+
const h = BigInt(i32(iso.tkhd, sizeAt + 4) >> 16);
|
|
84
|
+
const sw = BigInt(track.width);
|
|
85
|
+
const sh = BigInt(track.height);
|
|
86
|
+
if (w && h && sw && sh && (sw !== w || sh !== h))
|
|
87
|
+
sar = avReduce(sh * w, sw * h, INT_MAX);
|
|
88
|
+
}
|
|
89
|
+
if (valid(sar))
|
|
90
|
+
return sar;
|
|
91
|
+
const entry = iso.entries[0];
|
|
92
|
+
return vuiRatio(configSps(track.codecName, iso.bytes, firstBox(entry, track.codecName === 'h264' ? 'avcC' : 'hvcC')));
|
|
93
|
+
}
|
|
94
|
+
function matroskaCodec(mkv) {
|
|
95
|
+
return mkv.codecId === 'V_MPEG4/ISO/AVC' ? 'h264' : mkv.codecId === 'V_MPEGH/ISO/HEVC' ? 'hevc' : '';
|
|
96
|
+
}
|
|
97
|
+
function matroskaRatio(mkv) {
|
|
98
|
+
const b = mkv.bytes;
|
|
99
|
+
const video = (id) => elementUint(b, mkv.video.get(id));
|
|
100
|
+
const unit = video(ID_DISPLAY_UNIT) ?? 0;
|
|
101
|
+
let sar = null;
|
|
102
|
+
const croppedWidth = (video(ID_PIXEL_WIDTH) ?? 0) - (video(ID_PIXEL_CROP_LEFT) ?? 0) - (video(ID_PIXEL_CROP_RIGHT) ?? 0);
|
|
103
|
+
const croppedHeight = (video(ID_PIXEL_HEIGHT) ?? 0) - (video(ID_PIXEL_CROP_TOP) ?? 0) - (video(ID_PIXEL_CROP_BOTTOM) ?? 0);
|
|
104
|
+
if (unit < DISPLAY_UNIT_UNKNOWN && croppedWidth > 0 && croppedHeight > 0) {
|
|
105
|
+
const dw = video(ID_DISPLAY_WIDTH) ?? (unit === 0 ? croppedWidth : null);
|
|
106
|
+
const dh = video(ID_DISPLAY_HEIGHT) ?? (unit === 0 ? croppedHeight : null);
|
|
107
|
+
const stereo = video(ID_STEREO_MODE) ?? 0;
|
|
108
|
+
if (dw && dh) {
|
|
109
|
+
const wmul = WIDTH_TIMES_TWO.has(stereo) ? 2n : 1n;
|
|
110
|
+
const hmul = HEIGHT_TIMES_TWO.has(stereo) ? 2n : 1n;
|
|
111
|
+
sar = avReduce(BigInt(croppedHeight) * BigInt(dw) * wmul, BigInt(croppedWidth) * BigInt(dh) * hmul, INT_MAX);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return valid(sar) ? sar : vuiRatio(configSps(matroskaCodec(mkv), b, mkv.codecPrivate));
|
|
115
|
+
}
|
|
116
|
+
function elementFloat(b, span) {
|
|
117
|
+
const n = span ? span.end - span.start : 0;
|
|
118
|
+
if (!span || (n !== 4 && n !== 8))
|
|
119
|
+
return 0;
|
|
120
|
+
const dv = new DataView(b.buffer, b.byteOffset + span.start, n);
|
|
121
|
+
return n === 4 ? dv.getFloat32(0) : dv.getFloat64(0);
|
|
122
|
+
}
|
|
123
|
+
function projectionRotation(mkv) {
|
|
124
|
+
const span = mkv.video.get(ID_PROJECTION);
|
|
125
|
+
if (!span)
|
|
126
|
+
return 0;
|
|
127
|
+
const b = mkv.bytes;
|
|
128
|
+
const projection = new Map();
|
|
129
|
+
for (const e of elements(b, span.start, span.end))
|
|
130
|
+
projection.set(e.id, e);
|
|
131
|
+
const extra = projection.get(ID_PROJECTION_PRIVATE);
|
|
132
|
+
if ((elementUint(b, projection.get(ID_PROJECTION_TYPE)) ?? 0) !== 0 || (extra && extra.end > extra.start && b[extra.start] !== 0))
|
|
133
|
+
return 0;
|
|
134
|
+
const yaw = elementFloat(b, projection.get(ID_PROJECTION_POSE_YAW));
|
|
135
|
+
const roll = elementFloat(b, projection.get(ID_PROJECTION_POSE_ROLL));
|
|
136
|
+
if (elementFloat(b, projection.get(ID_PROJECTION_POSE_PITCH)) !== 0 || (yaw !== 0 && Math.abs(yaw) !== 180) || Number.isNaN(roll))
|
|
137
|
+
return 0;
|
|
138
|
+
const flip = yaw !== 0 ? -1 : 1;
|
|
139
|
+
const radians = (roll * flip * Math.PI) / 180;
|
|
140
|
+
const a = (flip * Math.trunc(Math.cos(radians) * 65536)) / 65536;
|
|
141
|
+
const c = Math.trunc(-Math.sin(radians) * 65536) / 65536;
|
|
142
|
+
const s0 = Math.hypot(a, (flip * Math.trunc(Math.sin(radians) * 65536)) / 65536);
|
|
143
|
+
const s1 = Math.hypot(c, Math.trunc(Math.cos(radians) * 65536) / 65536);
|
|
144
|
+
return s0 && s1 ? Math.trunc(-((Math.atan2(c / s1, a / s0) * 180) / Math.PI)) || 0 : 0;
|
|
145
|
+
}
|
|
146
|
+
export function aspectOf(track) {
|
|
147
|
+
const config = track.codecConfig;
|
|
148
|
+
const iso = config && isoConfig(config);
|
|
149
|
+
const mkv = config && !iso ? matroskaConfig(config) : null;
|
|
150
|
+
if (!iso && !mkv)
|
|
151
|
+
return undefined;
|
|
152
|
+
const sar = iso ? isoRatio(track, iso) : matroskaRatio(mkv);
|
|
153
|
+
const usable = valid(sar) ? sar : null;
|
|
154
|
+
const { width, height } = track;
|
|
155
|
+
if (width <= 0 || height <= 0)
|
|
156
|
+
return { sampleAspectRatio: usable && rational(usable), displayAspectRatio: null, displayWidth: null, displayHeight: null };
|
|
157
|
+
const [num, den] = usable ?? [1n, 1n];
|
|
158
|
+
const storedWidth = Number((2n * BigInt(width) * num + den) / (2n * den));
|
|
159
|
+
const rotation = track.rotation || (mkv ? projectionRotation(mkv) : 0);
|
|
160
|
+
const swap = Math.abs(rotation) % 180 === 90;
|
|
161
|
+
return {
|
|
162
|
+
sampleAspectRatio: usable && rational(usable),
|
|
163
|
+
displayAspectRatio: usable && rational(avReduce(BigInt(width) * num, BigInt(height) * den, DAR_MAX)),
|
|
164
|
+
displayWidth: swap ? height : storedWidth,
|
|
165
|
+
displayHeight: swap ? storedWidth : height,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
export function withAspect(result, options = {}) {
|
|
169
|
+
return {
|
|
170
|
+
...result,
|
|
171
|
+
videoTracks: result.videoTracks.map((t) => {
|
|
172
|
+
if (!t.codecConfig)
|
|
173
|
+
return t;
|
|
174
|
+
const out = { ...t, ...aspectOf(t) };
|
|
175
|
+
if (!options.keepCodecConfig)
|
|
176
|
+
delete out.codecConfig;
|
|
177
|
+
return out;
|
|
178
|
+
}),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export const INT_MAX = 2147483647n;
|
|
2
|
+
const abs = (v) => (v < 0n ? -v : v);
|
|
3
|
+
function gcd(a, b) {
|
|
4
|
+
while (b)
|
|
5
|
+
[a, b] = [b, a % b];
|
|
6
|
+
return a;
|
|
7
|
+
}
|
|
8
|
+
export function avReduce(num, den, max) {
|
|
9
|
+
let a0 = [0n, 1n];
|
|
10
|
+
let a1 = [1n, 0n];
|
|
11
|
+
const negative = num < 0n !== den < 0n;
|
|
12
|
+
const g = gcd(abs(num), abs(den));
|
|
13
|
+
if (g) {
|
|
14
|
+
num = abs(num) / g;
|
|
15
|
+
den = abs(den) / g;
|
|
16
|
+
}
|
|
17
|
+
if (num <= max && den <= max) {
|
|
18
|
+
a1 = [num, den];
|
|
19
|
+
den = 0n;
|
|
20
|
+
}
|
|
21
|
+
while (den) {
|
|
22
|
+
let x = num / den;
|
|
23
|
+
const nextDen = num - den * x;
|
|
24
|
+
const a2n = x * a1[0] + a0[0];
|
|
25
|
+
const a2d = x * a1[1] + a0[1];
|
|
26
|
+
if (a2n > max || a2d > max) {
|
|
27
|
+
if (a1[0])
|
|
28
|
+
x = (max - a0[0]) / a1[0];
|
|
29
|
+
if (a1[1])
|
|
30
|
+
x = x < (max - a0[1]) / a1[1] ? x : (max - a0[1]) / a1[1];
|
|
31
|
+
if (den * (2n * x * a1[1] + a0[1]) > num * a1[1])
|
|
32
|
+
a1 = [x * a1[0] + a0[0], x * a1[1] + a0[1]];
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
a0 = a1;
|
|
36
|
+
a1 = [a2n, a2d];
|
|
37
|
+
num = den;
|
|
38
|
+
den = nextDen;
|
|
39
|
+
}
|
|
40
|
+
return [negative ? -a1[0] : a1[0], a1[1]];
|
|
41
|
+
}
|
|
42
|
+
function binaryExponent(v) {
|
|
43
|
+
if (v === 0)
|
|
44
|
+
return 0;
|
|
45
|
+
let e = Math.floor(Math.log2(v));
|
|
46
|
+
while (2 ** e > v)
|
|
47
|
+
e--;
|
|
48
|
+
while (2 ** (e + 1) <= v)
|
|
49
|
+
e++;
|
|
50
|
+
return e;
|
|
51
|
+
}
|
|
52
|
+
export function avD2q(d, max) {
|
|
53
|
+
if (Number.isNaN(d))
|
|
54
|
+
return [0n, 0n];
|
|
55
|
+
if (Math.abs(d) > Number(INT_MAX) + 3)
|
|
56
|
+
return [d < 0 ? -1n : 1n, 0n];
|
|
57
|
+
const exponent = Math.max(binaryExponent(Math.abs(d)), 0);
|
|
58
|
+
const den = 1n << BigInt(61 - exponent);
|
|
59
|
+
const num = BigInt(Math.floor(d * Number(den) + 0.5));
|
|
60
|
+
return avReduce(num, den, max);
|
|
61
|
+
}
|
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;
|