Merge main into release for tag v1.54.0

This commit is contained in:
github-actions[bot]
2026-08-14 09:30:53 +00:00
16 changed files with 684 additions and 33 deletions
+1 -7
View File
@@ -18,13 +18,7 @@
});
const track = await input.getPrimaryVideoTrack();
const packetSink = new Mediabunny.EncodedPacketSink(track);
const first = await packetSink.getFirstPacket({ verifyKeyPackets: true });
const second = await packetSink.getNextPacket(first, { verifyKeyPackets: true });
const third = await packetSink.getNextPacket(second, { verifyKeyPackets: true });
const fourth = await packetSink.getNextPacket(third, { verifyKeyPackets: true });
console.log(first, second, third, fourth);
console.log(await track.computeFrameRateMetrics());
/*
+2 -2
View File
@@ -27,8 +27,8 @@ if (videoTrack) {
await videoTrack.getRotation(); // in degrees clockwise
// Estimate frame rate (FPS)
const packetStats = await videoTrack.computePacketStats(100);
const averageFrameRate = packetStats.averagePacketRate;
const frameRateMetrics = await videoTrack.computeFrameRateMetrics();
const frameRate = frameRateMetrics.bestGuessFrameRate;
}
// Extract audio metadata
+18 -7
View File
@@ -241,7 +241,7 @@ Intuitively, this is the maximum possible "frame rate" of the track (assuming th
$$ \frac{k}{x},\quad k \in \mathbb{Z} $$
::: info
This field only gives an upper bound on a track's frame rate. To get a track's actual frame rate based on its samples, compute its [packet statistics](#packet-statistics).
This field only gives an upper bound on a track's frame rate. To determine a video track's actual frame rate based on its frames, compute its [frame rate metrics](#frame-rate-metrics).
:::
Some tracks (especially live tracks) have timestamps which are relative to the Unix epoch (Jan 1 1970, midnight UTC). In other words, their timestamps *are* Unix timestamps. This allows you to map the media data to a definitive point in wall-clock time. To see if this is the case, use:
@@ -281,7 +281,7 @@ This means the video track has a total of 14315 frames, a frame rate of exactly
```ts
await track.computePacketStats(50);
```
This will only look at the first ~50 packets and then return the result. This is great for quickly getting an estimate of frame rate and bitrate, without having to scan through the entire file. For videos with a constant frame rate, this will also always return the correct frame rate.
This will only look at the first ~50 packets and then return the result. This is great for quickly getting an estimate of bitrate, without having to scan through the entire file. To determine a video track's frame rate, prefer using [frame rate metrics](#frame-rate-metrics) instead.
### Video track metadata
@@ -310,11 +310,7 @@ await videoTrack.getRotation(); // => 0 | 90 | 180 | 270
await videoTrack.getPixelAspectRatio(); // => { num: number, den: number }
```
To compute a video track's average frame rate (FPS), use [`computePacketStats`](#packet-statistics):
```ts
const stats = await videoTrack.computePacketStats(100);
const frameRate = stats.averagePacketRate; // Approximate, but often exact
```
To determine a video track's frame rate (FPS), compute its [frame rate metrics](#frame-rate-metrics).
You can retrieve the track's decoder configuration, which is a `VideoDecoderConfig` from the WebCodecs API for usage within `VideoDecoder`:
```ts
@@ -350,6 +346,21 @@ await videoTrack.hasHighDynamicRange(); // => boolean
```
This method compares with the available color space metadata. If it resolves to `true`, then the video is HDR; if it resolves to `false`, the video may or may not be HDR.
#### Frame rate metrics
You can compute metrics about a video track's frame rate (FPS):
```ts
const metrics = await videoTrack.computeFrameRateMetrics(); // => FrameRateMetrics
metrics.bestGuessFrameRate; // => number
```
Frame rate is never determined from file metadata (which is unreliable) but is always deduced directly from the actual frame timestamps. See [`FrameRateMetrics`](../api/FrameRateMetrics) for more.
By default, this method probes the first 256 packets of the track, which is enough for a reliable estimate. You can control this number using the `targetPacketCount` option, but note that increasing it makes the call more expensive:
```ts
await videoTrack.computeFrameRateMetrics({ targetPacketCount: 1024 });
```
### Audio track metadata
In addition to the [common track metadata](#common-track-metadata), audio tracks have additional metadata you can query:
+1
View File
@@ -123,6 +123,7 @@ const sponsors = {
{ image: 'https://avatars.githubusercontent.com/u/67356781', name: 'Ahmed Rowaihi', url: 'https://github.com/ahmedrowaihi' },
{ image: 'https://avatars.githubusercontent.com/u/4714175', name: 'Phoomparin Mano', url: 'https://github.com/heypoom' },
{ image: 'https://avatars.githubusercontent.com/u/504909', name: 'Hirbod', url: 'https://github.com/hirbod' },
{ image: 'https://avatars.githubusercontent.com/u/15199031', name: 'Marc Rousavy', url: 'https://github.com/mrousavy' },
{ image: 'https://avatars.githubusercontent.com/u/2565549', name: 'MatthewNorton', url: 'https://github.com/MatthewNorton' },
{ image: 'https://avatars.githubusercontent.com/u/272247752', name: 'Motionik', url: 'https://github.com/Motionik' },
{ image: 'https://avatars.githubusercontent.com/u/2698271', name: 'Matthew Gardner', url: 'https://github.com/spheric' },
+9 -9
View File
@@ -1,12 +1,12 @@
{
"name": "mediabunny",
"version": "1.53.1",
"version": "1.54.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
"version": "1.53.1",
"version": "1.54.0",
"license": "MPL-2.0",
"workspaces": [
".",
@@ -12954,7 +12954,7 @@
},
"packages/aac-encoder": {
"name": "@mediabunny/aac-encoder",
"version": "1.53.1",
"version": "1.54.0",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
@@ -12969,7 +12969,7 @@
},
"packages/ac3": {
"name": "@mediabunny/ac3",
"version": "1.53.1",
"version": "1.54.0",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
@@ -12984,7 +12984,7 @@
},
"packages/flac-encoder": {
"name": "@mediabunny/flac-encoder",
"version": "1.53.1",
"version": "1.54.0",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
@@ -12999,7 +12999,7 @@
},
"packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder",
"version": "1.53.1",
"version": "1.54.0",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
@@ -13014,7 +13014,7 @@
},
"packages/prores": {
"name": "@mediabunny/prores",
"version": "1.53.1",
"version": "1.54.0",
"license": "MPL-2.0",
"dependencies": {
"turbores": "^1.2.2"
@@ -13029,10 +13029,10 @@
},
"packages/server": {
"name": "@mediabunny/server",
"version": "1.53.1",
"version": "1.54.0",
"license": "MPL-2.0",
"dependencies": {
"@mediabunny/prores": "^1.53.1",
"@mediabunny/prores": "^1.54.0",
"node-av": "^6.0.0"
},
"funding": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
"version": "1.53.1",
"version": "1.54.0",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module",
"workspaces": [
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/aac-encoder",
"author": "Vanilagy",
"version": "1.53.1",
"version": "1.54.0",
"description": "AAC encoder extension for Mediabunny, based on FFmpeg.",
"main": "./dist/bundles/mediabunny-aac-encoder.mjs",
"module": "./dist/bundles/mediabunny-aac-encoder.mjs",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/ac3",
"author": "Vanilagy",
"version": "1.53.1",
"version": "1.54.0",
"description": "AC-3 and E-AC-3 (Dolby Digital) decoder and encoder extension for Mediabunny, based on FFmpeg.",
"main": "./dist/bundles/mediabunny-ac3.mjs",
"module": "./dist/bundles/mediabunny-ac3.mjs",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/flac-encoder",
"author": "Vanilagy",
"version": "1.53.1",
"version": "1.54.0",
"description": "FLAC encoder extension for Mediabunny, based on libFLAC.",
"main": "./dist/bundles/mediabunny-flac-encoder.mjs",
"module": "./dist/bundles/mediabunny-flac-encoder.mjs",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/mp3-encoder",
"author": "Vanilagy",
"version": "1.53.1",
"version": "1.54.0",
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/prores",
"author": "Vanilagy",
"version": "1.53.1",
"version": "1.54.0",
"description": "Apple ProRes decoder extension for Mediabunny, based on TurboRes.",
"main": "./dist/bundles/mediabunny-prores.mjs",
"module": "./dist/bundles/mediabunny-prores.mjs",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/server",
"author": "Vanilagy",
"version": "1.53.1",
"version": "1.54.0",
"description": "Adds full video and audio decoder and encoder support to Mediabunny for use in server-side environments (Node, Bun, Deno). Based on NodeAV.",
"main": "./dist/bundles/mediabunny-server.cjs",
"module": "./dist/bundles/mediabunny-server.mjs",
@@ -35,7 +35,7 @@
},
"dependencies": {
"node-av": "^6.0.0",
"@mediabunny/prores": "^1.53.1"
"@mediabunny/prores": "^1.54.0"
},
"peerDependencies": {
"mediabunny": "^1.45.0"
+2
View File
@@ -251,6 +251,8 @@ export {
InputVideoTrack,
InputAudioTrack,
type InputTrackQuery,
type FrameRateMetrics,
type FrameRateMetricsOptions,
type PacketStats,
asc,
desc,
+479
View File
@@ -32,6 +32,75 @@ export type PacketStats = {
averageBitrate: number;
};
/**
* Computed metrics about the frame rate of a video track.
* @group Input files & tracks
* @public
*/
export type FrameRateMetrics = {
/**
* The true, underlying frame rate of the video that produced the frame timestamps. This value is heuristically
* determined by examining the time differences between frames, excluding outliers, and then fitting them to
* fractions. This algorithm is stable even when frames are dropped, as long as all frames lie roughly on one
* uniform lattice.
*
* If this field is not `null`, Mediabunny is extremely confident in having found the actual frame rate of
* the video.
*
* This field is `null` for videos where no consistent underlying frame rate was detected, indicating a variable
* frame-rate (VFR) video.
*/
underlyingFrameRate: number | null;
/**
* Mediabunny's best guess for the video's actual intended frame rate.
*
* This value is determined heuristically. If `underlyingFrameRate` exists, this field will be equal to it. If it
* doesn't, Mediabunny will check if `medianFrameRate` is close to a "common" frame rate and if so, snap to it.
* Otherwise, this field simply falls back to `medianFrameRate`.
*/
bestGuessFrameRate: number;
/**
* The minimum frame rate of the video at any given point, based on the largest distance between two
* consecutive frames.
*/
minFrameRate: number;
/**
* The maximum frame rate of the video at any given point, based on the smallest distance between two
* consecutive frames.
*/
maxFrameRate: number;
/**
* The average frame rate across the duration of the probed packets. This value represents the average frames per
* second; it is not the average of frame rates across the video.
*/
averageFrameRate: number;
/**
* The median frame rate, as determined by the distance between any two consecutive frames. For variable frame-rate
* (VFR) videos, this field gives you a good idea of the _de facto_ frame rate of the video.
*/
medianFrameRate: number;
/** Is `true` only when the underlying video is CFR (constant frame-rate) and no frames are skipped. */
frameRateIsConstant: boolean;
/**
* The number of packets that were probed/inspected to estimate the frame rate. Probe at least 256 packets for a
* reliable estimation of frame rate.
*/
probedPacketCount: number;
};
/**
* Options for controlling how a video track's {@link FrameRateMetrics} are computed.
* @group Input files & tracks
* @public
*/
export type FrameRateMetricsOptions = {
/**
* The target number of packets to inspect to compute the frame rate. Defaults to `256`. Pass `Infinity` to scan
* the entire file.
*/
targetPacketCount?: number;
};
export interface InputTrackBacking {
getType(): TrackType;
getId(): number;
@@ -788,6 +857,152 @@ export class InputVideoTrack extends InputTrack {
return determineVideoPacketType(codec, decoderConfig, packet.data);
}
/**
* Computes frame rate metrics for this video track, i.e. estimates the video's frame rate. Frame rate is never
* determined from file metadata (which is unreliable) but is always deduced directly from the actual frame
* timestamps.
*/
async computeFrameRateMetrics(options: FrameRateMetricsOptions = {}): Promise<FrameRateMetrics> {
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
}
if (
options.targetPacketCount !== undefined
&& (!Number.isFinite(options.targetPacketCount) || options.targetPacketCount < 0)
) {
throw new TypeError('options.targetPacketCount must be a non-negative number.');
}
const timeResolution = await this.getTimeResolution();
const targetPacketCount = options.targetPacketCount ?? 256;
const sink = new EncodedPacketSink(this);
const timestamps: number[] = [];
let maxTimestamp = -Infinity;
let probedPacketCount = 0;
for await (const packet of sink.packets(undefined, undefined, { metadataOnly: true })) {
if (
timestamps.length >= targetPacketCount
// Needed for out-of-presentation-order packets.
&& packet.timestamp >= maxTimestamp
) {
break;
}
timestamps.push(packet.timestamp);
maxTimestamp = Math.max(maxTimestamp, packet.timestamp);
probedPacketCount++;
}
const ticks = new Float64Array(timestamps.length);
for (let i = 0; i < timestamps.length; i++) {
ticks[i] = Math.round(timestamps[i]! * timeResolution);
}
ticks.sort();
// Deduplicate in-place; only ticks[0..n) remains active.
let n = 1;
for (let i = 1; i < ticks.length; i++) {
if (ticks[i] !== ticks[n - 1]) {
ticks[n++] = ticks[i]!;
}
}
if (n < 2) {
return {
underlyingFrameRate: null,
bestGuessFrameRate: timeResolution,
minFrameRate: timeResolution,
maxFrameRate: timeResolution,
averageFrameRate: timeResolution,
medianFrameRate: timeResolution,
frameRateIsConstant: true,
probedPacketCount,
};
}
const activeTicks = ticks.subarray(0, n);
const underlyingFrameRate = findUnderlyingFrameRate(activeTicks, timeResolution);
// If an underlying frame rate exists, differences are expressed in inferred frame intervals. Otherwise they
// remain raw timestamp ticks.
const unitRate = underlyingFrameRate ?? timeResolution;
const ticksPerFrame = underlyingFrameRate !== null
? timeResolution / underlyingFrameRate
: null;
const histogram = new Map<number, number>();
let minDifference = Infinity;
let maxDifference = -Infinity;
let totalDifference = 0;
for (let i = 1; i < n; i++) {
const tickDifference = activeTicks[i]! - activeTicks[i - 1]!;
const difference = ticksPerFrame !== null
? Math.max(1, Math.round(tickDifference / ticksPerFrame))
: tickDifference;
histogram.set(difference, (histogram.get(difference) ?? 0) + 1);
minDifference = Math.min(minDifference, difference);
maxDifference = Math.max(maxDifference, difference);
totalDifference += difference;
}
const differenceCount = n - 1;
// Get the two middle differences so the even-sized case has a true median.
const sortedDifferences = [...histogram.keys()].sort((a, b) => a - b);
const middleA = (differenceCount - 1) >> 1;
const middleB = differenceCount >> 1;
let medianDifferenceA = 0;
let medianDifferenceB = 0;
let cumulativeCount = 0;
for (const difference of sortedDifferences) {
cumulativeCount += histogram.get(difference)!;
if (medianDifferenceA === 0 && cumulativeCount > middleA) {
medianDifferenceA = difference;
}
if (cumulativeCount > middleB) {
medianDifferenceB = difference;
break;
}
}
// Median is defined over instantaneous frame rates, not durations.
const medianFrameRate = (
unitRate / medianDifferenceA
+ unitRate / medianDifferenceB
) / 2;
return {
underlyingFrameRate,
bestGuessFrameRate: underlyingFrameRate !== null
? underlyingFrameRate
: getBestGuessFrameRate(medianFrameRate),
minFrameRate: unitRate / maxDifference,
maxFrameRate: unitRate / minDifference,
averageFrameRate: unitRate * differenceCount / totalDifference,
medianFrameRate,
frameRateIsConstant:
underlyingFrameRate !== null
&& minDifference === 1
&& maxDifference === 1,
probedPacketCount,
};
}
}
export interface InputAudioTrackBacking extends InputTrackBacking {
@@ -1139,3 +1354,267 @@ export const queryInputTracks = async <T extends InputTrack>(
})
.map(x => x.track);
};
/**
* Estimates the underlying CFR-like frame rate from timestamp deltas.
*
* Each delta is modeled as an integer multiple of one frame period, allowing dropped frames and a small number of
* malformed timestamps.
*/
export const findUnderlyingFrameRate = (
ticks: Float64Array,
resolution: number,
): number | null => {
const MAX_DENOMINATOR = 1_000_000;
const MIN_INLIER_RATIO = 0.98;
const DELTA_TOLERANCE = 1 + 1e-9;
const MAX_EFFECTIVE_FRAME_SPAN = 1000;
const KNOWN_FRAME_RATES = [
12,
15,
20,
24000 / 1001,
24,
25,
30000 / 1001,
30,
48,
50,
60000 / 1001,
60,
100,
120000 / 1001,
120,
144,
240,
];
if (ticks.length < 2) {
return null;
}
const gaps = new Float64Array(ticks.length - 1);
for (let i = 1; i < ticks.length; i++) {
const gap = ticks[i]! - ticks[i - 1]!;
if (!(gap > 0)) {
return null;
}
gaps[i - 1] = gap;
}
// Start near the low end so dropped frames don't inflate the estimate, without letting one anomalously short gap
// determine it.
const sortedGaps = gaps.slice();
sortedGaps.sort();
let period = sortedGaps[Math.floor(sortedGaps.length * 0.05)]!;
// Repeatedly infer how many frame periods each gap spans and refine the underlying period from the locally
// consistent gaps.
for (let iteration = 0; iteration < 6; iteration++) {
let totalTicks = 0;
let totalFrames = 0;
for (const gap of gaps) {
const multiple = Math.max(1, Math.round(gap / period));
if (Math.abs(gap - multiple * period) >= DELTA_TOLERANCE) {
continue;
}
totalTicks += gap;
totalFrames += multiple;
}
if (totalFrames === 0) {
return null;
}
const refinedPeriod = totalTicks / totalFrames;
if (
Math.abs(refinedPeriod - period)
<= 1e-12 * Math.max(1, period)
) {
period = refinedPeriod;
break;
}
period = refinedPeriod;
}
let inlierCount = 0;
let totalTicks = 0;
let totalFrames = 0;
for (const gap of gaps) {
const multiple = Math.max(1, Math.round(gap / period));
if (Math.abs(gap - multiple * period) >= DELTA_TOLERANCE) {
continue;
}
inlierCount++;
totalTicks += gap;
totalFrames += multiple;
}
if (inlierCount / gaps.length < MIN_INLIER_RATIO) {
return null;
}
period = totalTicks / totalFrames;
// Don't let arbitrarily long files force arbitrarily precise rational reconstruction from microscopic clock drift.
const uncertainty = 1 / Math.min(
totalFrames,
MAX_EFFECTIVE_FRAME_SPAN,
);
const periodLo = Math.max(Number.EPSILON, period - uncertainty);
const periodHi = period + uncertainty;
const fpsLo = resolution / periodHi;
const fpsHi = resolution / periodLo;
const fittedFps = resolution / period;
// Prefer established frame rates when they're supported by the measured interval. If several fit, use the one
// closest to the measured cadence.
let fps: number | null = null;
let bestKnownError = Infinity;
for (const candidate of KNOWN_FRAME_RATES) {
if (candidate < fpsLo || candidate > fpsHi) {
continue;
}
const error = Math.abs(candidate / fittedFps - 1);
if (error < bestKnownError) {
fps = candidate;
bestKnownError = error;
}
}
if (fps === null) {
// Otherwise, the natural fraction may be simpler either as FPS or as ticks/frame.
const periodFraction = simplestFractionBetween(
periodLo,
periodHi,
MAX_DENOMINATOR,
);
const fpsFraction = simplestFractionBetween(
fpsLo,
fpsHi,
MAX_DENOMINATOR,
);
if (
fpsFraction
&& (
!periodFraction
|| fpsFraction.den < periodFraction.den
|| (
fpsFraction.den === periodFraction.den
&& fpsFraction.num <= periodFraction.num
)
)
) {
fps = fpsFraction.num / fpsFraction.den;
} else if (periodFraction) {
fps = resolution * periodFraction.den / periodFraction.num;
} else {
return null;
}
}
// Make sure the chosen rate still explains the deltas.
const finalPeriod = resolution / fps;
let finalInlierCount = 0;
for (const gap of gaps) {
const multiple = Math.max(1, Math.round(gap / finalPeriod));
if (
Math.abs(gap - multiple * finalPeriod)
< DELTA_TOLERANCE
) {
finalInlierCount++;
}
}
if (finalInlierCount / gaps.length < MIN_INLIER_RATIO) {
return null;
}
return fps;
};
const simplestFractionBetween = (
lo: number,
hi: number,
maxDenominator: number,
): Rational | null => {
for (let den = 1; den <= maxDenominator; den++) {
const num = Math.floor(lo * den) + 1;
if (num / den < hi) {
return simplifyRational({ num, den });
}
}
return null;
};
const getBestGuessFrameRate = (frameRate: number) => {
const SPECIAL_FRAME_RATES = [
24 / 1.001,
30 / 1.001,
60 / 1.001,
120 / 1.001,
];
const COMMON_FRAME_RATES = [
12,
15,
20,
24,
25,
30,
48,
50,
60,
100,
120,
144,
240,
];
const SPECIAL_TOLERANCE = 0.0005;
const COMMON_TOLERANCE = 0.025;
for (const candidate of SPECIAL_FRAME_RATES) {
if (Math.abs(candidate / frameRate - 1) <= SPECIAL_TOLERANCE) {
return candidate;
}
}
let best = frameRate;
let bestError = Infinity;
for (const candidate of COMMON_FRAME_RATES) {
const error = Math.abs(candidate / frameRate - 1);
if (error <= COMMON_TOLERANCE && error < bestError) {
best = candidate;
bestError = error;
}
}
return best;
};
+164
View File
@@ -0,0 +1,164 @@
import { expect, test } from 'vitest';
import path from 'node:path';
import { ALL_FORMATS, Input, FilePathSource, EncodedPacketSink } from '../../src/index.js';
import { findUnderlyingFrameRate } from '../../src/input-track.js';
import { assert } from '../../src/misc.js';
test('findUnderlyingFrameRate with 30 FPS at 30 Hz time resolution', () => {
const ticks = makeTicks(30, 30);
expect(findUnderlyingFrameRate(ticks, 30)).toBe(30);
});
test('findUnderlyingFrameRate with 30 FPS at 1000 Hz time resolution', () => {
expect(findUnderlyingFrameRate(makeTicks(30, 1000, { quantize: Math.floor }), 1000)).toBe(30);
expect(findUnderlyingFrameRate(makeTicks(30, 1000, { quantize: Math.round }), 1000)).toBe(30);
expect(findUnderlyingFrameRate(makeTicks(30, 1000, { quantize: Math.ceil }), 1000)).toBe(30);
});
test('findUnderlyingFrameRate with 30 FPS at 30 Hz with dropped frames', () => {
const ticks = makeTicks(30, 30, { dropFrame: dropSomeFrames });
expect(findUnderlyingFrameRate(ticks, 30)).toBe(30);
});
test('findUnderlyingFrameRate with 30 FPS at 1000 Hz with dropped frames', () => {
expect(findUnderlyingFrameRate(
makeTicks(30, 1000, { quantize: Math.floor, dropFrame: dropSomeFrames }),
1000,
)).toBe(30);
expect(findUnderlyingFrameRate(
makeTicks(30, 1000, { quantize: Math.round, dropFrame: dropSomeFrames }),
1000,
)).toBe(30);
expect(findUnderlyingFrameRate(
makeTicks(30, 1000, { quantize: Math.ceil, dropFrame: dropSomeFrames }),
1000,
)).toBe(30);
});
test('findUnderlyingFrameRate with 24/1.001 FPS at 24000 Hz time resolution', () => {
const ticks = makeTicks(24000 / 1001, 24000);
expect(findUnderlyingFrameRate(ticks, 24000)).toBe(24000 / 1001);
});
test('findUnderlyingFrameRate with 24/1.001 FPS at 1000 Hz time resolution (1 minute)', () => {
const frameCount = Math.floor(60 * 24000 / 1001);
const ticks = makeTicks(24000 / 1001, 1000, { frameCount });
expect(findUnderlyingFrameRate(ticks, 1000)).toBe(24000 / 1001);
});
test('findUnderlyingFrameRate with 24/1.001 FPS at 1000 Hz time resolution (60 minutes)', () => {
const frameCount = Math.floor(3600 * 24000 / 1001);
const ticks = makeTicks(24000 / 1001, 1000, { frameCount });
expect(findUnderlyingFrameRate(ticks, 1000)).toBe(24000 / 1001);
});
test('findUnderlyingFrameRate with 30 FPS at 1000 Hz starting at 1e9 seconds', () => {
const ticks = makeTicks(30, 1000, { startTime: 1e9 });
expect(findUnderlyingFrameRate(ticks, 1000)).toBe(30);
});
test('findUnderlyingFrameRate with irregular timestamps yields null', () => {
const ticks = new Float64Array([0, 0.2, 0.5].map(x => Math.round(x * 100)));
expect(findUnderlyingFrameRate(ticks, 100)).toBe(null);
});
test('findUnderlyingFrameRate with cursed VFR timestamps yields null', async () => {
const { ticks, timeResolution } = await getSortedTrackTicks(publicPath('cursed-vfr.mp4'));
expect(findUnderlyingFrameRate(ticks, timeResolution)).toBe(null);
});
test('computeFrameRateMetrics with constant frame rate video', async () => {
using input = new Input({
source: new FilePathSource(publicPath('video.mp4')),
formats: ALL_FORMATS,
});
const videoTrack = await input.getPrimaryVideoTrack();
assert(videoTrack);
const metrics = await videoTrack.computeFrameRateMetrics();
expect(metrics.underlyingFrameRate).toBe(25);
expect(metrics.bestGuessFrameRate).toBe(25);
expect(metrics.minFrameRate).toBe(25);
expect(metrics.maxFrameRate).toBe(25);
expect(metrics.averageFrameRate).toBe(25);
expect(metrics.medianFrameRate).toBe(25);
expect(metrics.frameRateIsConstant).toBe(true);
});
test('computeFrameRateMetrics with cursed VFR video', async () => {
using input = new Input({
source: new FilePathSource(publicPath('cursed-vfr.mp4')),
formats: ALL_FORMATS,
});
const videoTrack = await input.getPrimaryVideoTrack();
assert(videoTrack);
const metrics = await videoTrack.computeFrameRateMetrics();
expect(metrics.underlyingFrameRate).toBe(null);
expect(metrics.bestGuessFrameRate).toBe(30);
expect(metrics.minFrameRate).toBe(0.6369426751592356);
expect(metrics.maxFrameRate).toBe(188.85245901639345);
expect(metrics.averageFrameRate).toBe(8.664659340979288);
expect(metrics.medianFrameRate).toBe(30.165016356826754);
expect(metrics.frameRateIsConstant).toBe(false);
});
const makeTicks = (
frameRate: number,
timeResolution: number,
options: {
frameCount?: number;
startTime?: number;
quantize?: (value: number) => number;
dropFrame?: (index: number) => boolean;
} = {},
) => {
const frameCount = options.frameCount ?? 300;
const startTime = options.startTime ?? 0;
const quantize = options.quantize ?? Math.round;
const ticks: number[] = [];
for (let i = 0; i < frameCount; i++) {
if (options.dropFrame?.(i)) {
continue;
}
ticks.push(quantize((startTime + i / frameRate) * timeResolution));
}
return new Float64Array(ticks);
};
const dropSomeFrames = (index: number) => index % 11 === 4 || index % 17 === 9;
const getSortedTrackTicks = async (filePath: string) => {
using input = new Input({
source: new FilePathSource(filePath),
formats: ALL_FORMATS,
});
const videoTrack = await input.getPrimaryVideoTrack();
assert(videoTrack);
const timeResolution = await videoTrack.getTimeResolution();
const sink = new EncodedPacketSink(videoTrack);
const ticks: number[] = [];
for await (const packet of sink.packets(undefined, undefined, { metadataOnly: true })) {
ticks.push(Math.round(packet.timestamp * timeResolution));
}
ticks.sort((a, b) => a - b);
const dedupedTicks = ticks.filter((tick, index) => index === 0 || tick !== ticks[index - 1]);
return { ticks: new Float64Array(dedupedTicks), timeResolution };
};
const __dirname = new URL('.', import.meta.url).pathname;
const publicPath = (file: string) => path.join(__dirname, '../public', file);
Binary file not shown.