mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 19:03:46 +02:00
165 lines
5.7 KiB
TypeScript
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);
|