npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

video-fps-probe

v0.4.2

Published

Video frame rate, frame count and VFR detection from MP4/MOV/WebM/MKV container headers in a Web Worker. Matches ffprobe 8.1.1 on the test corpus (0 mismatches), no decoding.

Readme

video-fps-probe

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.

Live demo · API · Bundlers · Benchmarks · Architecture · Changelog

The demo runs every capability on your own files or on its samples, each with a short explanation and code to copy for the browser and for a Node server: the full analysis with color, HDR, Dolby Vision and aspect ratio, the header-only analysis under limits, a file from a URL through Range requests, upload rules with the presets, and the error codes.

Install

npm install video-fps-probe
yarn add video-fps-probe   # Yarn classic and berry
pnpm add video-fps-probe
bun add video-fps-probe

The first npm release is 0.2.0, and one publish serves every package manager (publishing). CI installs the tarball with npm, pnpm, Yarn PnP and bun, and builds it with Vite, webpack, Rspack, Next.js, Angular, SvelteKit, Nuxt, Parcel and Bun (bundlers). 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.

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.

git clone https://github.com/vbondarets/video-fps-probe.git && cd video-fps-probe
npm ci && TGZ="$PWD/$(npm pack | tail -n 1)"   # builds and checks dist/, writes the tarball, keeps its path
cd ../your-app && npm install "$TGZ"

In PowerShell: $tgz = Join-Path $PWD (npm pack | Select-Object -Last 1), then cd ../your-app; npm install $tgz.

Quick start

import { lazyProbe } from 'video-fps-probe/lazy';

const probe = lazyProbe(); // a small stub: loads nothing until the first call

const result = await probe.analyze(file); // a broken file is result.error, not a rejection
const track = result.videoTracks.find((t) => t.trackId === result.primaryTrackId);
track?.avgFps; // exact rational { num, den, value }
track?.avgFpsFfprobe; // the avg_frame_rate string of ffprobe, for example '30000/1001'
track?.presentedFrames; // the frame count
track?.isVfr;
track?.rateProfile?.summary; // for example 'Uneven: 5 fps for 4 s, 200 fps for 4 s'

The first call loads the probe client and the own worker of the file's container family, picked by its first 4 bytes, with the parser inside; probe.prewarm() loads them ahead, for example when an upload dialog opens. createProbe() from video-fps-probe is the probe without the stub; summarize(result) flattens the primary track.

React

import { useState } from 'react';
import { lazyProbe } from 'video-fps-probe/lazy';
import { VideoFpsProvider, useVideoFps } from 'video-fps-probe/react';

const probe = lazyProbe(); // once, at module scope

export const App = () => (
  <VideoFpsProvider probe={probe}>
    <Upload />
  </VideoFpsProvider>
);

function Upload() {
  const [file, setFile] = useState<File | null>(null);
  const { status, summary } = useVideoFps(file); // 'idle', 'pending', 'success' or 'error'; also result, error, progress, cancel(), retry()
  return (
    <>
      <input type="file" onChange={(e) => setFile(e.currentTarget.files?.[0] ?? null)} />
      {status === 'success' && <p>{summary?.ok ? `${summary.fps?.toFixed(3)} fps` : summary?.error}</p>}
    </>
  );
}

React 18 and 19. useVideoFps caches each result by the file and the options, runs one analysis through the double effects of StrictMode, and aborts a pending one when the last component using it unmounts; usePrewarmVideoFps() returns a function that prewarms the probe (details).

Engines

Own, the default, needs nothing else. mp4box.js and mediabunny are optional peers for a second opinion or a fallback, and rVFC estimates by playing the file:

import { lazyProbe } from 'video-fps-probe/lazy';
import { mediabunny } from 'video-fps-probe/mediabunny'; // npm install mediabunny
import { mp4box } from 'video-fps-probe/mp4box'; // npm install mp4box

const probe = lazyProbe({ engines: [mp4box(), mediabunny()] });
await probe.analyze(file, { engine: 'mediabunny' });
await probe.analyze(file, { fallback: ['mediabunny'] }); // on an error, no frame rate, a timeout or a crash
const { outcomes, agreement } = await probe.compare(file); // experimental

agreement.agree is true, false, or null when nothing could be compared (rules). rVFC (video-fps-probe/rvfc) is never used implicitly and needs a visible host element (engines).

Node

import { openAsBlob } from 'node:fs';
import { analyze } from 'video-fps-probe';

const result = await analyze(await openAsBlob(path), { name: path }); // in the calling thread, no worker

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). 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.

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).

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).

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).

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, Aspect ratio and display size).

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.

Cancellation, timeouts and failing open

probe.prewarm(); // when the upload dialog opens
const deadline = new AbortController();
const late = new Promise<null>((resolve) => setTimeout(() => (deadline.abort(), resolve(null)), 5000));
const checks = Promise.all(files.map((f) => probe.tryAnalyze(f, { timeoutMs: 3000, signal: deadline.signal })));
const results = await Promise.race([checks, late]); // null, or null entries: upload without the check

tryAnalyze resolves null where analyze rejects. timeoutMs counts per job once a worker takes it and the signal cancels queued and running jobs, but neither covers main-thread chunk downloads: hence prewarm() and the race. A probe and a lazy stub reject only with a ProbeError; code that did not load, such as a chunk that is gone after a redeploy, has reason: 'load'. Details.

Check before upload

import { check } from 'video-fps-probe/check';
import { klingO1ReferenceVideo } from 'video-fps-probe/presets';

const report = check(await probe.tryAnalyze(file, { timeoutMs: 3000 }), klingO1ReferenceVideo.rules);
if (report.verdict === 'fail') showErrors(report.violations, report.suggestedFps?.reason); // for example 'convert to 24 fps'
else upload(file); // 'pass', 'warn', or 'unknown': no result, an error or a missing field fails open

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, presets).

Size

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:

| Host Vite config | Critical path | First MP4 | First MKV | All own paths | Worker files | |---|---:|---:|---:|---:|---:| | worker: { format: 'es' } (recommended) | 0.67 KB | 23.13 KB | 25.76 KB | 49.23 KB | 2 | | none (Vite's default, 'iife') | 0.66 KB | 24.91 KB | 27.21 KB | 48.85 KB | 2 |

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:

import { defineConfig } from 'vite';

export default defineConfig({ worker: { format: 'es' } });

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.18 KB, rVFC 2.19 KB. Gzip -9 of production builds from the packed tarball, budgeted in CI. Every configuration is in docs/benchmarks.md.

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.

Accuracy

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 has the outcomes and where the libraries win.

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.

Browser support

Chrome and Edge 94, Firefox 114, Safari and iOS 15.4: floors derived from the features used, not tested on those versions; CI runs Chromium, Firefox and WebKit. mp4box.js needs Safari 16.4; rVFC needs requestVideoFrameCallback (Chrome 83, Firefox 132, Safari 15.4), a visible foreground tab and a playable codec. Details.

The package writes nothing to the console. CI fails a browser test on a console warning or error of a page or a worker, a page error, an unhandled rejection or a failed script request, and a Node test on any output (console).

Known limitations: changelog, benchmarks. Development: demo/README.md, scripts.

Roadmap

  • 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.
  • 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.

Feedback

Questions, and how you use the package, go to GitHub Discussions. For a wrong value or a setup that does not build, open a bug report with the browser or Node version, the container, the Copy JSON output of the demo or the result JSON, and the ffprobe output if you have it. The package and the demo collect no telemetry.

License

MIT. The optional peers have their own licenses: mediabunny is MPL-2.0, mp4box is BSD-3-Clause.