video-fps-probe 0.3.0 → 0.4.0-beta.2
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 +19 -11
- package/dist/analyze.js +10 -1
- package/dist/api/codes.d.ts +6 -0
- package/dist/api/codes.js +90 -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/cjs/analyze.d.cts +9 -0
- package/dist/cjs/api/agreement.d.cts +18 -0
- package/dist/cjs/api/codes.d.cts +6 -0
- package/dist/cjs/api/errors.d.cts +12 -0
- package/dist/cjs/api/guards.d.cts +4 -0
- package/dist/cjs/api/summary.d.cts +22 -0
- package/dist/cjs/aspect/index.d.cts +13 -0
- package/dist/cjs/aspect.cjs +1 -0
- package/dist/cjs/check/index.d.cts +60 -0
- package/dist/cjs/check.cjs +1 -0
- 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-B_HKNwqg.cjs +1 -0
- package/dist/cjs/chunks/config-CSbXzEqr.cjs +1 -0
- package/dist/cjs/chunks/errors-0oBg1Ai6.cjs +1 -0
- package/dist/cjs/chunks/fallback-CyPIR3RM.cjs +1 -0
- package/dist/cjs/chunks/guards-DbQNcAZS.cjs +1 -0
- package/dist/cjs/chunks/inline-Bx-TQg8f.cjs +1 -0
- 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-Cn1DtL-C.cjs +1 -0
- package/dist/cjs/chunks/rational-Dq5Vza-Q.cjs +1 -0
- package/dist/cjs/chunks/reader-Dw11ozVU.cjs +1 -0
- package/dist/cjs/chunks/rfps-C-pyKnu8.cjs +1 -0
- package/dist/cjs/chunks/runtime-BgAlQQee.cjs +1 -0
- package/dist/cjs/chunks/segments-B1H6bDz-.cjs +1 -0
- package/dist/cjs/chunks/sniff-CXwqOGwh.cjs +1 -0
- package/dist/cjs/chunks/stats-DW7dVdFa.cjs +1 -0
- package/dist/cjs/client/types.d.cts +96 -0
- package/dist/cjs/color/index.d.cts +7 -0
- package/dist/cjs/color.cjs +1 -0
- package/dist/cjs/core/reader.d.cts +47 -0
- package/dist/cjs/core/types.d.cts +194 -0
- package/dist/cjs/engines/types.d.cts +69 -0
- package/dist/cjs/header/index.d.cts +28 -0
- package/dist/cjs/header/limits.d.cts +26 -0
- package/dist/cjs/header.cjs +1 -0
- package/dist/cjs/index.cjs +1 -0
- package/dist/cjs/index.d.cts +13 -0
- package/dist/cjs/plugins/types.d.cts +17 -0
- package/dist/cjs/presets/index.d.cts +19 -0
- package/dist/cjs/presets.cjs +1 -0
- package/dist/cjs/probe.d.cts +3 -0
- package/dist/cjs/url/index.d.cts +47 -0
- package/dist/cjs/url.cjs +1 -0
- package/dist/cjs/worker/protocol.d.cts +47 -0
- 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/dispatch.js +18 -29
- package/dist/core/isobmff/boxes.js +6 -5
- package/dist/core/isobmff/build.js +401 -0
- package/dist/core/isobmff/config.js +34 -0
- package/dist/core/isobmff/header.js +677 -0
- package/dist/core/isobmff/index.js +9 -381
- package/dist/core/isobmff/moof.js +2 -2
- package/dist/core/isobmff/moov.js +3 -3
- package/dist/core/matroska/header.js +10 -4
- package/dist/core/matroska/index.js +11 -5
- package/dist/core/options.js +1 -0
- package/dist/core/reader.d.ts +3 -0
- package/dist/core/reader.js +13 -7
- package/dist/core/result.js +1 -0
- package/dist/core/stats.js +3 -0
- package/dist/core/types.d.ts +60 -1
- package/dist/engines/common.js +2 -0
- package/dist/header/index.d.ts +28 -0
- package/dist/header/index.js +182 -0
- package/dist/header/limits.d.ts +26 -0
- package/dist/header/limits.js +144 -0
- package/dist/header/metadata.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -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/dist/url/index.d.ts +47 -0
- package/dist/url/index.js +335 -0
- package/dist/worker/single.js +2 -1
- package/package.json +117 -2
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-beta.2 - 2026-10-31
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `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
|
+
- `video-fps-probe/aspect`: `aspectOf(track)` and `withAspect(result)` give `sampleAspectRatio`, `displayAspectRatio`, `displayWidth` and `displayHeight`, with the pasp, tkhd matrix, tkhd size and SPS VUI precedence of ffprobe 8.1.1 for MP4 and MOV and the Display and PixelCrop elements for Matroska and WebM. The display size has the rotation applied, for Matroska and WebM the ProjectionPoseRoll that ffprobe reports as the display matrix ([aspect ratio and display size](docs/api.md#aspect-ratio-and-display-size)).
|
|
13
|
+
- `codecConfig: true` keeps the raw codec configuration of each video track in `codecConfig` for both entries, with no read beyond the walk: the TrackEntry of a Matroska or WebM track, the stsd, tkhd and mvhd boxes of an MP4 or MOV track in `analyzeHeader` and the root `analyze`, and in a probe a view of the moov with the index of the trak, `{ family: 'isobmff-moov', moov, trak }`, a family of its own, from which both entries cut the same boxes. The view lies in a copy of the moov that the walk makes only with the option, so a probe posts the bytes of the moov and nothing else. A configuration whose bytes are missing or no longer `Uint8Array` or `DataView` values, as after `JSON.parse`, or that has a buffer transferred to another thread in any member, the view of the moov, a box or the TrackEntry, gives `color` and the aspect fields undefined, as a track without one does.
|
|
14
|
+
- `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
|
+
- 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
|
+
- The new optional result fields `codecConfig`, `color`, `sampleAspectRatio`, `displayAspectRatio`, `displayWidth` and `displayHeight` on `VideoTrackResult`, and the types `CodecConfig`, `ColorInfo`, `ColorSource` and `DolbyVisionConfig`.
|
|
17
|
+
- CommonJS copies of `video-fps-probe/color` and `video-fps-probe/aspect`, so the seven server entries load with `require()` and in Jest with no configuration, and their types name no DOM type.
|
|
18
|
+
|
|
19
|
+
### Frozen from 0.4.0-beta.2
|
|
20
|
+
|
|
21
|
+
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.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- 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.
|
|
26
|
+
- 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.
|
|
27
|
+
- `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.
|
|
28
|
+
- `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.
|
|
29
|
+
- 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).
|
|
30
|
+
|
|
31
|
+
### Known limitations
|
|
32
|
+
|
|
33
|
+
- A probe gives an MP4 or MOV track a view of the moov rather than the three boxes, because cutting them in the worker measured 0.25 KB on the first MP4 path, over the 0.08 KB this release allows there. `colorOf` and `aspectOf` read both forms. The view lies in a copy of the moov, so `postMessage` copies the moov and nothing else: the moov box, up to 256 MiB, or the part the walk parsed of a moov whose size runs past the end of the file, up to 16 MiB. Without the copy a probe posted the whole read the moov came from, up to 16 MiB with media data after an mdat. While the walk runs it can hold the moov twice, the read and the copy, up to 512 MiB for a moov at the 256 MiB limit.
|
|
34
|
+
- 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
|
+
|
|
36
|
+
## 0.4.0-beta.1 - 2026-10-31
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- `errorCode` on every own result: a code for `error` from a closed list, such as `no-header`, `truncated` or `read-failed`, null exactly when `error` is null. Every `error` text is unchanged ([error codes](docs/api.md#error-codes)).
|
|
41
|
+
- `errorCodeOf`, `errorClass`, `warningCodeOf` and `warningCodesOf` in the root entry, for results of any engine or version, with stable warning codes: `invalid-timescale`, `truncated-mdat`, `truncated-box` and `trailing-data` from the parsers, `fragment-index-unread`, `duration-unresolved` and `fragments-ignored` from the header entry, and `structure-limit` for `too many top-level boxes` ([error codes](docs/api.md#error-codes)).
|
|
42
|
+
- `layout` on ISO BMFF results: `faststart`, `moov-at-end` or `fragmented`, the moov offset and size, a sidx before the first moof, and an empty moov.
|
|
43
|
+
- `medianFrameInterval` on video tracks of own, mp4box and mediabunny, and `enabled` (tkhd or FlagEnabled) on own tracks. `TrackField` gains `'medianFrameInterval'`, so an exhaustive `switch` needs the case.
|
|
44
|
+
- `video-fps-probe/header`: `analyzeHeader(source, options)` reads only the header of an MP4 or MOV file, in the calling thread, under limits on the bytes requested, the reads, the wall time, the tracks and the top-level boxes (`HEADER_DEFAULT_LIMITS`: 16 MiB, 8 reads, 10 s, 64 tracks and 1024 boxes). A fragmented file gets its duration from a covering sidx, the mfra index or mehd, and new codes name what stopped it: `no-duration`, `byte-limit`, `read-limit`, `time-limit` and `structure-limit`. It rejects only on the caller's abort and on an invalid option, with a `TypeError`, and adds at most 1 byte to the other entries ([header-only analysis](docs/api.md#header-only-analysis)). `readAhead` takes only `head` and `tail`, so the `fragment` or `matroskaScan` of `analyze` there is a `TypeError` rather than a silent no-op, and the options and `limits` ignore keys they do not know, so an option or a limit of a later version does not fail here. The entry also exports `isAbortError` and `isTimeoutError`. The signature, the members of `HeaderOptions` and `HeaderLimits`, these rules for keys, the error and warning codes and these exports are frozen from 0.4.0-beta.1.
|
|
45
|
+
- `video-fps-probe/url`: `rangeSource(fetcher, { size })`, `openRangeSource(fetcher)`, `httpRange(url)`, `readCapped(body, expectedLength)` and `RangeSourceError` turn any function that fetches a byte range, such as `fetch` with a presigned URL or the client of an object-storage SDK, into a source for `analyzeHeader` and the in-thread `analyze`. No SDK dependency, one request per read and no retries. Every reply is checked before its body is read, its status, its `Content-Range` with the object size and its `Content-Length`, and the body is capped while it streams, so a server that ignores the range, an object whose size changed or an oversized body fails the read as `read-failed` instead of giving wrong bytes. An object overwritten at the same size is caught only by an `If-Match` with its ETag, which the caller sends. A failed or aborted body is discarded, and a Node stream body is destroyed, so its socket closes. `openRangeSource` learns the size from one request for the first 64 KiB, accepts the clamped 206 of a smaller object and serves the first read of `analyzeHeader` from those bytes, so the requests equal `io.reads`. `RangeSourceError` carries a `code`, such as `unexpected-status` or `size-changed`, and the `status` ([Range source](docs/api.md#range-source)).
|
|
46
|
+
- A CommonJS copy of the five server entries, `video-fps-probe`, `video-fps-probe/header`, `video-fps-probe/url`, `video-fps-probe/check` and `video-fps-probe/presets`, minified with the names of classes and functions kept, so `error.name`, `error.constructor.name` and the export names equal the ESM build, with `.d.cts` types, under the conditions `module-sync`, `module` and `require`, and `main`. Node 22.12 and newer still load one ESM instance from `require()` and bundlers that match the `module` condition, as Vite, webpack, Rspack and esbuild do by default, still resolve the ESM build, so the copy serves only `require()` where `require(esm)` is off. Jest 30 runs these entries with no configuration and `jest` as the only dependency, and with `--experimental-vm-modules` on Node 24.9 and newer it loads the ESM build ([CommonJS](docs/api.md#commonjs), [Jest](docs/api.md#jest)).
|
|
47
|
+
- The types of `video-fps-probe/header`, `video-fps-probe/url`, `video-fps-probe/check` and `video-fps-probe/presets` name no DOM type, so a server that imports only those compiles with `"lib": ["ES2022"]` and the node types, without `skipLibCheck`.
|
|
48
|
+
|
|
49
|
+
### Frozen from 0.4.0-beta.1
|
|
50
|
+
|
|
51
|
+
These are API from 0.4.0-beta.1 on, and 0.4.0-beta.2, 0.4.0 and every later release only add to them ([versioning](docs/api.md#versioning)):
|
|
52
|
+
|
|
53
|
+
- `FileErrorCode`: `empty-file`, `unrecognised`, `unsupported-container`, `unsupported`, `too-large`, `no-header`, `truncated`, `corrupt`, `no-duration`, `byte-limit`, `read-limit`, `time-limit`, `structure-limit`, `read-failed`, `load-failed`, `engine-failed` and `internal`, with the `ErrorClass` of each.
|
|
54
|
+
- `WarningCode`: `invalid-timescale`, `truncated-mdat`, `truncated-box`, `trailing-data`, `fragment-index-unread`, `duration-unresolved`, `fragments-ignored` and `structure-limit`, with the warning prefixes they classify. The four parser prefixes are `invalid mdhd timescale ` or `invalid timescale `, `'mdat' at <offset> is truncated`, `'<type>' at <offset> is truncated` and `ignored trailing data at `, and the header entry adds `fragment index not read: `, `format duration unresolved: `, `fragments ignored: ` and `too many top-level boxes`.
|
|
55
|
+
- `LayoutKind`, `faststart`, `moov-at-end` and `fragmented`, and the members of `ContainerLayout`: `kind`, `moovOffset`, `moovSize`, `sidx` and `emptyMoov`.
|
|
56
|
+
- The members of `HeaderLimits`, `maxBytes`, `maxReads`, `maxMs`, `maxTracks` and `maxTopLevelBoxes`, and of `HeaderOptions`, `name`, `signal`, `limits`, `readAhead` with `head` and `tail` only and `fragmentedDuration`, with the export `HEADER_DEFAULT_LIMITS` and its keys, the signature `analyzeHeader(source: Blob | BlobLike, options?: HeaderOptions): Promise<HeaderResult>`, and the exports `isAbortError` and `isTimeoutError` of `video-fps-probe/header`. A default value of `HEADER_DEFAULT_LIMITS` may change later, as a behaviour change that the changelog names.
|
|
57
|
+
- How `analyzeHeader` treats keys: `readAhead` refuses any key but `head` and `tail` with a `TypeError`, and the options and `limits` ignore keys they do not know.
|
|
58
|
+
- The whole `video-fps-probe/url` API: `rangeSource`, `openRangeSource`, `httpRange`, `readCapped`, `RangeSourceError` with its `code` and `status`, the `RangeSourceErrorCode` values `unverifiable-reply`, `unexpected-status`, `range-mismatch`, `size-changed`, `length-mismatch`, `body-too-long`, `body-too-short` and `invalid-reply`, and the types `RangeRequest`, `RangeReply`, `RangeFetcher`, `RangeSource`, `RangeSourceInit`, `OpenRangeSourceInit`, `HttpRangeInit` and `RangeSourceErrorOptions`, with `BlobLike` and `BlobSlice`, the types of the root entry that it re-exports unchanged.
|
|
59
|
+
|
|
60
|
+
`ColorInfo` and `DolbyVisionConfig` join this list at 0.4.0-beta.2.
|
|
61
|
+
|
|
62
|
+
### Changed
|
|
63
|
+
|
|
64
|
+
- The installed package may take up to 867 KB unpacked (580 KB in 0.3.0), measured at 842 KB: about 80 KB more for the error codes, layout, median frame interval, the header-only entry, the Range source and the longer README and CHANGELOG, and about 190 KB for the CommonJS copy of the server entries, with the names of its classes and functions kept, and its declarations, which took the cap from 632 KB to 867 KB, 3 percent above the measurement. A bundler still takes only the modules a host imports, and no browser bundle holds the CommonJS copy.
|
|
65
|
+
- Node 22.12 is the lowest supported version, as in 0.3.0, and now the only line the docs name: Node 20 is past its end of life.
|
|
66
|
+
- The first MP4 loads 23.07 KB gzip (22.73 KB in 0.3.0), 24.85 KB with Vite's default worker format (24.47 KB), and the first WebM or MKV 25.73 KB (25.54 KB) ([size matrix](report/size-matrix.md)).
|
|
67
|
+
- Still 0 mismatches against ffprobe 8.1.1, now on 168 committed fixtures, with 2 known deviations on a new `+cmaf+global_sidx` fixture with B-frames: ffprobe adds the largest negative composition offset of the fragments it reads to every video timestamp, and own does not yet, so its video starts at 0 instead of 0.066667 s and the file ends 0.045 s earlier ([accuracy](docs/benchmarks.md#accuracy-vs-ffprobe)).
|
|
68
|
+
|
|
69
|
+
### Fixed
|
|
70
|
+
|
|
71
|
+
- A `readAhead.tail` that is NaN no longer spins the Matroska probe forever: it scans the smallest tail, as every value below 4096 does.
|
|
72
|
+
|
|
73
|
+
### Known limitations
|
|
74
|
+
|
|
75
|
+
- In a browser `httpRange` checks the `Content-Range` of a cross-origin reply only when the server lists it in `Access-Control-Expose-Headers`, and the status and length checks remain. `openRangeSource` needs that header, or a known `size`, because discovery learns the size from it, and rejects with `unverifiable-reply` without it.
|
|
76
|
+
- The Range source checks the size of the object on every read, so an overwrite at the same size between two reads is caught only by an `If-Match` that the caller sends. `httpRange` does not keep the ETag of its first reply.
|
|
77
|
+
- Full mode reads every fragment, so on a `+cmaf+global_sidx` output of several fragments where every track has a sidx that covers the file and the last AAC fragment runs past its sidx, it reports that end, 1024 samples later than ffprobe 8.1.1, for example 20.521333 s against 20.5 s, as in 0.3.0. `analyzeHeader` gives the ffprobe value there. On ffmpeg's `+cmaf+global_sidx` output with a `mov_text` or `-timecode` track, whose sidx is written last and ends early, ffprobe reads every fragment too, and both modes give its value. On `+cmaf+global_sidx` files patched so that the one sidx left before the first moof covers the file and another track has none, ffprobe 8.1.1 gave the sidx end for every stream in the cases measured, 6.0 s where full mode gives 6.021333 s. Whether that holds as a rule is a hypothesis with counter-cases, so `analyzeHeader` gives the full-mode value there through the mfra, or without one keeps the video duration and leaves `formatDurationSec` null with a warning ([notes](docs/implementation-notes.md#what-the-header-walk-reads)).
|
|
78
|
+
|
|
7
79
|
## 0.3.0 - 2026-10-30
|
|
8
80
|
|
|
9
81
|
### Added
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# video-fps-probe
|
|
2
2
|
|
|
3
|
-
Frame rate, frame count, VFR detection and a rate profile for MP4, MOV, WebM and MKV files, read from the container headers in a Web Worker. Nothing is decoded and nothing leaves the browser, and the values match ffprobe 8.1.1 on the test corpus (0 mismatches). ESM
|
|
3
|
+
Frame rate, frame count, VFR detection and a rate profile for MP4, MOV, WebM and MKV files, read from the container headers in a Web Worker. Nothing is decoded and nothing leaves the browser, and the values match ffprobe 8.1.1 on the test corpus (0 mismatches). ESM with a CommonJS copy of the server entries, no runtime dependencies, types included.
|
|
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
|
|
|
@@ -13,7 +13,7 @@ pnpm add video-fps-probe
|
|
|
13
13
|
bun add video-fps-probe
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
The first npm release is 0.2.0
|
|
16
|
+
The first npm release is 0.2.0, and one publish serves every package manager ([publishing](docs/publishing.md)). npm is the tested package manager: CI installs the packed tarball with npm. Yarn, pnpm and bun are expected to work but are not tested in CI yet (pnpm, Yarn PnP and bun installs join the bundler matrix in 0.4.1). The package has no dependencies. Its peers are optional and no package manager installs them for you: install one yourself, only for the entry you import: `mp4box` for `video-fps-probe/mp4box`, `mediabunny` for `video-fps-probe/mediabunny`, React 18 or 19 for `video-fps-probe/react`.
|
|
17
17
|
|
|
18
18
|
For a commit that is not released yet, install a packed tarball. A git dependency gets no code with npm (Yarn, pnpm and bun are not tested): `prepack` builds `dist/`, and npm does not run it for a git install.
|
|
19
19
|
|
|
@@ -98,9 +98,17 @@ 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 name DOM types such as `Worker` and `HTMLElement`, so a TypeScript project for Node needs `"DOM"` in `lib` or `skipLibCheck: true`.
|
|
101
|
+
In-thread `analyze()` is tested on Node 24, the version in `.nvmrc`, and on Node 22.12, the lowest supported version: CI runs the unit, fixture and package tests on both. The example needs `fs.openAsBlob`, available since Node 19.8 and stable in Node 24. Files above 4 GiB need a `BlobLike` in Node 24 ([in-thread analyze](docs/api.md#in-thread-analyze)). The type declarations 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.
|
|
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
|
+
|
|
105
|
+
A server that checks uploads in object storage can read only the header: `analyzeHeader(source, { limits })` from `video-fps-probe/header` reads the moov of an MP4 or MOV file under limits on the bytes, the reads, the time, the tracks and the top-level boxes, in one to three reads for most files, and gives the codec, size, rotation, duration and layout, and the frame count of a file that is not fragmented, without walking the samples. A fragmented file gets its duration from a sidx, the mfra index or mehd, and the error code `no-duration` when it has none of them ([header-only analysis](docs/api.md#header-only-analysis)).
|
|
106
|
+
|
|
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
|
+
|
|
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).
|
|
104
112
|
|
|
105
113
|
## Cancellation, timeouts and failing open
|
|
106
114
|
|
|
@@ -135,8 +143,8 @@ Rules are JSON, such as `{ fps: { min: 24, max: 60 }, duration: { max: 10 }, vfr
|
|
|
135
143
|
|
|
136
144
|
| Host Vite config | Critical path | First MP4 | First MKV | All own paths | Worker files |
|
|
137
145
|
|---|---:|---:|---:|---:|---:|
|
|
138
|
-
| `worker: { format: 'es' }` (recommended) | **0.67 KB** |
|
|
139
|
-
| 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 |
|
|
140
148
|
|
|
141
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:
|
|
142
150
|
|
|
@@ -146,9 +154,9 @@ import { defineConfig } from 'vite';
|
|
|
146
154
|
export default defineConfig({ worker: { format: 'es' } });
|
|
147
155
|
```
|
|
148
156
|
|
|
149
|
-
Engines cost nothing until imported. Importing a descriptor grows the critical path a little, from 0.67 KB to 0.89 KB with mp4box or mediabunny, 0.77 KB with rVFC and 1.01 KB with all three, and the engine itself loads lazily: mp4box 44.
|
|
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).
|
|
150
158
|
|
|
151
|
-
`video-fps-probe/react` adds 1.20 KB to the critical path for the provider, `useVideoFps` and its store (React itself not counted)
|
|
159
|
+
`video-fps-probe/react` adds 1.20 KB to the critical path for the provider, `useVideoFps` and its store (React itself not counted), and its first analysis also fetches a 0.43 KB summary module, in parallel with the probe client. `video-fps-probe/check` adds 3.99 KB when imported statically, and nothing lazily. One preset from `video-fps-probe/presets` adds 0.46 KB.
|
|
152
160
|
|
|
153
161
|
<!-- tables:size:end -->
|
|
154
162
|
|
|
@@ -156,9 +164,9 @@ Engines cost nothing until imported. Importing a descriptor grows the critical p
|
|
|
156
164
|
|
|
157
165
|
<!-- tables:accuracy-summary:start -->
|
|
158
166
|
|
|
159
|
-
**0 mismatches against ffprobe 8.1.1** on the
|
|
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.
|
|
160
168
|
|
|
161
|
-
Of the
|
|
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).
|
|
162
170
|
|
|
163
171
|
<!-- tables:accuracy-summary:end -->
|
|
164
172
|
|
|
@@ -170,8 +178,8 @@ Known limitations: [changelog](CHANGELOG.md#known-limitations), [benchmarks](doc
|
|
|
170
178
|
|
|
171
179
|
## Roadmap
|
|
172
180
|
|
|
173
|
-
- **0.3.x**: a bundler matrix in CI: webpack 5, Rspack, Next.js, Angular CLI, SvelteKit, Nuxt, Parcel, Bun and Deno, with pnpm, Yarn PnP and bun installs.
|
|
174
181
|
- **0.4.0**: a header-only ISO BMFF mode with byte, read and time limits, machine-readable error codes and a container layout field, a Range source for S3 and R2 style object storage (a fetcher function, no SDK dependency), minimal color, HDR and Dolby Vision metadata, the pixel aspect ratio and display size, a median frame interval that `check` uses, and a CommonJS build of the server entries only, while the browser entries stay ESM.
|
|
182
|
+
- **0.4.1**: a bundler matrix in CI: webpack 5, Rspack, Next.js, Angular CLI, SvelteKit, Nuxt, Parcel, Bun and Deno, with pnpm, Yarn PnP and bun installs.
|
|
175
183
|
- **0.5.0**: a Node entry (`fromPath`, DOM-free types, a `worker_threads` pool), NestJS validators, a CLI, bitrate, GOP and audio details, cadence detection, framework adapters for Vue, Svelte and Angular, and later MPEG-TS and HLS.
|
|
176
184
|
|
|
177
185
|
## 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
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { FileErrorCode, FileResult, WarningCode } from '../core/types.js';
|
|
2
|
+
export type ErrorClass = 'file' | 'limit' | 'io' | 'environment' | 'internal';
|
|
3
|
+
export declare function errorCodeOf(result: Pick<FileResult, 'error'> & Partial<Pick<FileResult, 'errorCode' | 'engine'>>): FileErrorCode | null;
|
|
4
|
+
export declare function errorClass(code: FileErrorCode): ErrorClass;
|
|
5
|
+
export declare function warningCodeOf(warning: string): WarningCode | null;
|
|
6
|
+
export declare function warningCodesOf(result: Pick<FileResult, 'warnings' | 'videoTracks'>): WarningCode[];
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const ERROR_PREFIXES = [
|
|
2
|
+
['not a recognised container: empty file', 'empty-file'],
|
|
3
|
+
['not a recognised container', 'unrecognised'],
|
|
4
|
+
['unsupported: moov larger', 'too-large'],
|
|
5
|
+
['corrupt file: moof larger', 'too-large'],
|
|
6
|
+
['unsupported:', 'unsupported'],
|
|
7
|
+
['incomplete file: moov is truncated', 'truncated'],
|
|
8
|
+
['incomplete file: no moov', 'no-header'],
|
|
9
|
+
['incomplete header:', 'no-duration'],
|
|
10
|
+
['limit reached: maxBytes', 'byte-limit'],
|
|
11
|
+
['limit reached: maxReads', 'read-limit'],
|
|
12
|
+
['limit reached: maxMs', 'time-limit'],
|
|
13
|
+
['limit reached: maxTracks', 'structure-limit'],
|
|
14
|
+
['limit reached: maxTopLevelBoxes', 'structure-limit'],
|
|
15
|
+
['corrupt file:', 'corrupt'],
|
|
16
|
+
['file read failed', 'read-failed'],
|
|
17
|
+
['the own ', 'unsupported-container'],
|
|
18
|
+
['unsupported by ', 'unsupported-container'],
|
|
19
|
+
['container not read by', 'unsupported-container'],
|
|
20
|
+
['invalid EBML header', 'corrupt'],
|
|
21
|
+
['no Segment element', 'no-header'],
|
|
22
|
+
['no complete TrackEntry in Tracks', 'truncated'],
|
|
23
|
+
['no Tracks element found', 'no-header'],
|
|
24
|
+
];
|
|
25
|
+
const CLASS_OF = {
|
|
26
|
+
'empty-file': 'file',
|
|
27
|
+
unrecognised: 'file',
|
|
28
|
+
'unsupported-container': 'file',
|
|
29
|
+
unsupported: 'file',
|
|
30
|
+
'too-large': 'file',
|
|
31
|
+
'no-header': 'file',
|
|
32
|
+
truncated: 'file',
|
|
33
|
+
corrupt: 'file',
|
|
34
|
+
'no-duration': 'file',
|
|
35
|
+
'engine-failed': 'file',
|
|
36
|
+
'byte-limit': 'limit',
|
|
37
|
+
'read-limit': 'limit',
|
|
38
|
+
'time-limit': 'limit',
|
|
39
|
+
'structure-limit': 'limit',
|
|
40
|
+
'read-failed': 'io',
|
|
41
|
+
'load-failed': 'environment',
|
|
42
|
+
internal: 'internal',
|
|
43
|
+
};
|
|
44
|
+
const WARNING_PREFIXES = [
|
|
45
|
+
['invalid mdhd timescale ', 'invalid-timescale'],
|
|
46
|
+
['invalid timescale ', 'invalid-timescale'],
|
|
47
|
+
['ignored trailing data at ', 'trailing-data'],
|
|
48
|
+
['fragment index not read: ', 'fragment-index-unread'],
|
|
49
|
+
['format duration unresolved: ', 'duration-unresolved'],
|
|
50
|
+
['fragments ignored: ', 'fragments-ignored'],
|
|
51
|
+
['too many top-level boxes', 'structure-limit'],
|
|
52
|
+
['codec configuration unavailable', 'codec-config-unavailable'],
|
|
53
|
+
['color and aspect unavailable', 'metadata-unavailable'],
|
|
54
|
+
];
|
|
55
|
+
const TRUNCATED_BOX = /^'(.{4})' at \d+ is truncated/;
|
|
56
|
+
export function errorCodeOf(result) {
|
|
57
|
+
if (result?.errorCode !== undefined)
|
|
58
|
+
return result.errorCode;
|
|
59
|
+
const error = result?.error;
|
|
60
|
+
if (error === null)
|
|
61
|
+
return null;
|
|
62
|
+
if (typeof error !== 'string')
|
|
63
|
+
return 'internal';
|
|
64
|
+
for (const [prefix, code] of ERROR_PREFIXES)
|
|
65
|
+
if (error.startsWith(prefix))
|
|
66
|
+
return code;
|
|
67
|
+
return result.engine !== undefined && result.engine !== 'own' ? 'engine-failed' : 'internal';
|
|
68
|
+
}
|
|
69
|
+
export function errorClass(code) {
|
|
70
|
+
return Object.hasOwn(CLASS_OF, code) ? CLASS_OF[code] : 'internal';
|
|
71
|
+
}
|
|
72
|
+
export function warningCodeOf(warning) {
|
|
73
|
+
if (typeof warning !== 'string')
|
|
74
|
+
return null;
|
|
75
|
+
for (const [prefix, code] of WARNING_PREFIXES)
|
|
76
|
+
if (warning.startsWith(prefix))
|
|
77
|
+
return code;
|
|
78
|
+
const box = TRUNCATED_BOX.exec(warning);
|
|
79
|
+
return box ? (box[1] === 'mdat' ? 'truncated-mdat' : 'truncated-box') : null;
|
|
80
|
+
}
|
|
81
|
+
export function warningCodesOf(result) {
|
|
82
|
+
const codes = new Set();
|
|
83
|
+
const listOf = (value) => (Array.isArray(value) ? value : []);
|
|
84
|
+
for (const w of [...listOf(result?.warnings), ...listOf(result?.videoTracks).flatMap((t) => listOf(t?.warnings))]) {
|
|
85
|
+
const code = warningCodeOf(w);
|
|
86
|
+
if (code)
|
|
87
|
+
codes.add(code);
|
|
88
|
+
}
|
|
89
|
+
return [...codes];
|
|
90
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ProbeResult } from './client/types.cjs';
|
|
2
|
+
import type { BlobLike } from './core/reader.cjs';
|
|
3
|
+
import type { AnalyzeOptions } from './core/types.cjs';
|
|
4
|
+
import type { EngineDescriptor } from './plugins/types.cjs';
|
|
5
|
+
export interface InThreadOptions extends AnalyzeOptions {
|
|
6
|
+
name?: string;
|
|
7
|
+
engine?: EngineDescriptor;
|
|
8
|
+
}
|
|
9
|
+
export declare function analyze(input: Blob | BlobLike, options?: InThreadOptions): Promise<ProbeResult>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { EngineId, FileResult, Rational } from '../core/types.cjs';
|
|
2
|
+
export type ComparedFieldName = 'avgFps' | 'nominalFps' | 'minFps' | 'maxFps' | 'frames' | 'durationSec';
|
|
3
|
+
export type ComparedValue = Rational | number | null;
|
|
4
|
+
export interface ComparedField<K extends EngineId> {
|
|
5
|
+
field: ComparedFieldName;
|
|
6
|
+
values: Partial<Record<K, ComparedValue>>;
|
|
7
|
+
agree: boolean | null;
|
|
8
|
+
differ: K[];
|
|
9
|
+
}
|
|
10
|
+
export interface Agreement<K extends EngineId> {
|
|
11
|
+
reference: K | null;
|
|
12
|
+
engines: K[];
|
|
13
|
+
fields: ComparedField<K>[];
|
|
14
|
+
agree: boolean | null;
|
|
15
|
+
}
|
|
16
|
+
export declare const RELATIVE_TOLERANCE = 0.003;
|
|
17
|
+
export declare const DURATION_TOLERANCE_SEC = 0.000001;
|
|
18
|
+
export declare function compareResults<K extends EngineId>(results: Partial<Record<K, FileResult>>, reference?: NoInfer<K>): Agreement<K>;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { FileErrorCode, FileResult, WarningCode } from '../core/types.cjs';
|
|
2
|
+
export type ErrorClass = 'file' | 'limit' | 'io' | 'environment' | 'internal';
|
|
3
|
+
export declare function errorCodeOf(result: Pick<FileResult, 'error'> & Partial<Pick<FileResult, 'errorCode' | 'engine'>>): FileErrorCode | null;
|
|
4
|
+
export declare function errorClass(code: FileErrorCode): ErrorClass;
|
|
5
|
+
export declare function warningCodeOf(warning: string): WarningCode | null;
|
|
6
|
+
export declare function warningCodesOf(result: Pick<FileResult, 'warnings' | 'videoTracks'>): WarningCode[];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { EngineId } from '../core/types.cjs';
|
|
2
|
+
export type ProbeErrorCode = 'aborted' | 'timeout' | 'worker-failed' | 'engine-not-registered' | 'unknown-engine' | 'unsupported-environment' | 'protocol';
|
|
3
|
+
export interface ProbeErrorOptions {
|
|
4
|
+
engine?: EngineId | null;
|
|
5
|
+
cause?: unknown;
|
|
6
|
+
}
|
|
7
|
+
export declare class ProbeError extends Error {
|
|
8
|
+
readonly code: ProbeErrorCode;
|
|
9
|
+
readonly engine: EngineId | null;
|
|
10
|
+
readonly reason?: 'load' | 'crash';
|
|
11
|
+
constructor(code: ProbeErrorCode, message: string, options?: ProbeErrorOptions);
|
|
12
|
+
}
|