Files
mediabunny/test/node/frame-rate.test.ts

165 lines
5.7 KiB
TypeScript

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