mirror of
https://github.com/codedread/bitjs
synced 2025-10-04 01:59:15 +02:00
codecs: Properly detect Matroska audio/video. Add codec support for AV1 video and DTS audio. Bump to 1.1.4.
This commit is contained in:
parent
96a74d910c
commit
bb0f40394e
7 changed files with 213 additions and 137 deletions
|
@ -53,6 +53,13 @@ const FORMAT_NAME_TO_SHORT_TYPE = {
|
||||||
'wav': 'audio/wav',
|
'wav': 'audio/wav',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Containers#webm says that only
|
||||||
|
// the following codecs are supported for webm:
|
||||||
|
// - video: AV1, VP8, VP9
|
||||||
|
// - audio: Opus, Vorbis
|
||||||
|
const WEBM_AUDIO_CODECS = [ 'opus', 'vorbis' ];
|
||||||
|
const WEBM_VIDEO_CODECS = [ 'av1', 'vp8', 'vp9' ];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO: Reconcile this with file/sniffer.js findMimeType() which does signature matching.
|
* TODO: Reconcile this with file/sniffer.js findMimeType() which does signature matching.
|
||||||
* @param {ProbeInfo} info
|
* @param {ProbeInfo} info
|
||||||
|
@ -94,9 +101,16 @@ export function getShortMIMEString(info) {
|
||||||
case 'ogg':
|
case 'ogg':
|
||||||
subType = 'ogg';
|
subType = 'ogg';
|
||||||
break;
|
break;
|
||||||
// Should we detect .mkv files as x-matroska?
|
|
||||||
case 'matroska,webm':
|
case 'matroska,webm':
|
||||||
subType = 'webm';
|
let isWebM = true;
|
||||||
|
for (const stream of info.streams) {
|
||||||
|
if ( (stream.codec_type === 'audio' && !WEBM_AUDIO_CODECS.includes(stream.codec_name))
|
||||||
|
|| (stream.codec_type === 'video' && !WEBM_VIDEO_CODECS.includes(stream.codec_name))) {
|
||||||
|
isWebM = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subType = isWebM ? 'webm' : 'x-matroska';
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw `Cannot handle format ${formatName} yet. ` +
|
throw `Cannot handle format ${formatName} yet. ` +
|
||||||
|
@ -132,11 +146,12 @@ export function getFullMIMEString(info) {
|
||||||
default:
|
default:
|
||||||
switch (stream.codec_name) {
|
switch (stream.codec_name) {
|
||||||
case 'aac': codecFrags.add(getMP4ACodecString(stream)); break;
|
case 'aac': codecFrags.add(getMP4ACodecString(stream)); break;
|
||||||
case 'vorbis': codecFrags.add('vorbis'); break;
|
|
||||||
case 'opus': codecFrags.add('opus'); break;
|
|
||||||
// I'm going off of what Chromium calls this one, with the dash.
|
// I'm going off of what Chromium calls this one, with the dash.
|
||||||
case 'ac3': codecFrags.add('ac-3'); break;
|
case 'ac3': codecFrags.add('ac-3'); break;
|
||||||
|
case 'dts': codecFrags.add('dts'); break;
|
||||||
case 'flac': codecFrags.add('flac'); break;
|
case 'flac': codecFrags.add('flac'); break;
|
||||||
|
case 'opus': codecFrags.add('opus'); break;
|
||||||
|
case 'vorbis': codecFrags.add('vorbis'); break;
|
||||||
default:
|
default:
|
||||||
throw `Could not handle audio codec_name ${stream.codec_name}, ` +
|
throw `Could not handle audio codec_name ${stream.codec_name}, ` +
|
||||||
`codec_tag_string ${stream.codec_tag_string} for file ${info.format.filename} yet. ` +
|
`codec_tag_string ${stream.codec_tag_string} for file ${info.format.filename} yet. ` +
|
||||||
|
@ -153,10 +168,12 @@ export function getFullMIMEString(info) {
|
||||||
case 'png': continue;
|
case 'png': continue;
|
||||||
default:
|
default:
|
||||||
switch (stream.codec_name) {
|
switch (stream.codec_name) {
|
||||||
|
case 'av1': codecFrags.add('av1'); break;
|
||||||
case 'h264': codecFrags.add(getAVC1CodecString(stream)); break;
|
case 'h264': codecFrags.add(getAVC1CodecString(stream)); break;
|
||||||
case 'mpeg2video': codecFrags.add('mpeg2video'); break;
|
|
||||||
// Skip mjpeg as a video stream for the codecs string.
|
// Skip mjpeg as a video stream for the codecs string.
|
||||||
case 'mjpeg': break;
|
case 'mjpeg': break;
|
||||||
|
case 'mpeg2video': codecFrags.add('mpeg2video'); break;
|
||||||
|
case 'vp8': codecFrags.add('vp8'); break;
|
||||||
case 'vp9': codecFrags.add(getVP09CodecString(stream)); break;
|
case 'vp9': codecFrags.add(getVP09CodecString(stream)); break;
|
||||||
default:
|
default:
|
||||||
throw `Could not handle video codec_name ${stream.codec_name}, ` +
|
throw `Could not handle video codec_name ${stream.codec_name}, ` +
|
||||||
|
|
|
@ -32,7 +32,7 @@ export class BitBuffer {
|
||||||
*/
|
*/
|
||||||
constructor(numBytes, mtl = false) {
|
constructor(numBytes, mtl = false) {
|
||||||
if (typeof numBytes != typeof 1 || numBytes <= 0) {
|
if (typeof numBytes != typeof 1 || numBytes <= 0) {
|
||||||
throw "Error! ByteBuffer initialized with '" + numBytes + "'";
|
throw "Error! BitBuffer initialized with '" + numBytes + "'";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -51,7 +51,7 @@ export class BitStream {
|
||||||
*/
|
*/
|
||||||
constructor(ab, mtl, opt_offset, opt_length) {
|
constructor(ab, mtl, opt_offset, opt_length) {
|
||||||
if (!(ab instanceof ArrayBuffer)) {
|
if (!(ab instanceof ArrayBuffer)) {
|
||||||
throw 'Error! BitArray constructed with an invalid ArrayBuffer object';
|
throw 'Error! BitStream constructed with an invalid ArrayBuffer object';
|
||||||
}
|
}
|
||||||
|
|
||||||
const offset = opt_offset || 0;
|
const offset = opt_offset || 0;
|
||||||
|
|
|
@ -22,7 +22,8 @@ export class ByteStream {
|
||||||
*/
|
*/
|
||||||
constructor(ab, opt_offset, opt_length) {
|
constructor(ab, opt_offset, opt_length) {
|
||||||
if (!(ab instanceof ArrayBuffer)) {
|
if (!(ab instanceof ArrayBuffer)) {
|
||||||
throw 'Error! BitArray constructed with an invalid ArrayBuffer object';
|
console.error(typeof ab);
|
||||||
|
throw 'Error! ByteStream constructed with an invalid ArrayBuffer object';
|
||||||
}
|
}
|
||||||
|
|
||||||
const offset = opt_offset || 0;
|
const offset = opt_offset || 0;
|
||||||
|
|
4
package-lock.json
generated
4
package-lock.json
generated
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "@codedread/bitjs",
|
"name": "@codedread/bitjs",
|
||||||
"version": "1.1.2",
|
"version": "1.1.4",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@codedread/bitjs",
|
"name": "@codedread/bitjs",
|
||||||
"version": "1.1.2",
|
"version": "1.1.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"c8": "^7.12.0",
|
"c8": "^7.12.0",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@codedread/bitjs",
|
"name": "@codedread/bitjs",
|
||||||
"version": "1.1.3",
|
"version": "1.1.4",
|
||||||
"description": "Binary Tools for JavaScript",
|
"description": "Binary Tools for JavaScript",
|
||||||
"homepage": "https://github.com/codedread/bitjs",
|
"homepage": "https://github.com/codedread/bitjs",
|
||||||
"author": "Jeff Schiller",
|
"author": "Jeff Schiller",
|
||||||
|
|
|
@ -113,16 +113,30 @@ describe('codecs test suite', () => {
|
||||||
it('detects WEBM video', () => {
|
it('detects WEBM video', () => {
|
||||||
expect(getShortMIMEString({
|
expect(getShortMIMEString({
|
||||||
format: { format_name: 'matroska,webm' },
|
format: { format_name: 'matroska,webm' },
|
||||||
streams: [ { codec_type: 'video' } ],
|
streams: [ { codec_type: 'video', codec_name: 'vp8' } ],
|
||||||
})).equals('video/webm');
|
})).equals('video/webm');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detects WEBM audio', () => {
|
it('detects WEBM audio', () => {
|
||||||
expect(getShortMIMEString({
|
expect(getShortMIMEString({
|
||||||
format: { format_name: 'matroska,webm' },
|
format: { format_name: 'matroska,webm' },
|
||||||
streams: [ { codec_type: 'audio' } ],
|
streams: [ { codec_type: 'audio', codec_name: 'vorbis' } ],
|
||||||
})).equals('audio/webm');
|
})).equals('audio/webm');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('detects Matroska Video', () => {
|
||||||
|
expect(getShortMIMEString({
|
||||||
|
format: { format_name: 'matroska,webm' },
|
||||||
|
streams: [ { codec_type: 'video', codec_name: 'h264' } ],
|
||||||
|
})).equals('video/x-matroska');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects Matroska audio', () => {
|
||||||
|
expect(getShortMIMEString({
|
||||||
|
format: { format_name: 'matroska,webm' },
|
||||||
|
streams: [ { codec_type: 'audio', codec_name: 'dts' } ],
|
||||||
|
})).equals('audio/x-matroska');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getFullMIMEString()', () => {
|
describe('getFullMIMEString()', () => {
|
||||||
|
@ -228,86 +242,193 @@ describe('codecs test suite', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('VP09 / VP9', () => {
|
describe('WebM' ,() => {
|
||||||
/** @type {ProbeInfo} */
|
describe('AV1', () => {
|
||||||
let info;
|
/** @type {ProbeInfo} */
|
||||||
|
let info;
|
||||||
beforeEach(() => {
|
|
||||||
info = {
|
|
||||||
format: { format_name: 'matroska,webm' },
|
|
||||||
streams: [{
|
|
||||||
codec_type: 'video',
|
|
||||||
codec_tag_string: 'vp09',
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Profile tests', () => {
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
info.streams[0].level = 20;
|
info = {
|
||||||
|
format: { format_name: 'matroska,webm' },
|
||||||
|
streams: [{ codec_type: 'video', codec_name: 'av1' }],
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detects Profile 0', () => {
|
it('outputs MIME string', () => {
|
||||||
info.streams[0].profile = 'Profile 0';
|
expect(getShortMIMEString(info)).equals('video/webm');
|
||||||
expect(getFullMIMEString(info))
|
expect(getFullMIMEString(info)).equals('video/webm; codecs="av1"')
|
||||||
.to.be.a('string')
|
|
||||||
.and.satisfy(s => s.startsWith('video/webm; codecs="vp09.00.'));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Level tests', () => {
|
describe('VP8', () => {
|
||||||
|
/** @type {ProbeInfo} */
|
||||||
|
let info;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
info.streams[0].profile = 'Profile 0';
|
info = {
|
||||||
|
format: { format_name: 'matroska,webm' },
|
||||||
|
streams: [{ codec_type: 'video', codec_name: 'vp8' }],
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detects 2-digit hex level', () => {
|
it('outputs MIME string', () => {
|
||||||
|
expect(getShortMIMEString(info)).equals('video/webm');
|
||||||
|
expect(getFullMIMEString(info)).equals('video/webm; codecs="vp8"')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('VP09 / VP9', () => {
|
||||||
|
/** @type {ProbeInfo} */
|
||||||
|
let info;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
info = {
|
||||||
|
format: { format_name: 'matroska,webm' },
|
||||||
|
streams: [{ codec_type: 'video', codec_name: 'vp9', codec_tag_string: 'vp09' }],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Profile tests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
info.streams[0].level = 20;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects Profile 0', () => {
|
||||||
|
info.streams[0].profile = 'Profile 0';
|
||||||
|
expect(getFullMIMEString(info))
|
||||||
|
.to.be.a('string')
|
||||||
|
.and.satisfy(s => s.startsWith('video/webm; codecs="vp09.00.'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Level tests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
info.streams[0].profile = 'Profile 0';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects 2-digit hex level', () => {
|
||||||
|
info.streams[0].level = 21; // 0x15
|
||||||
|
expect(getFullMIMEString(info))
|
||||||
|
.to.be.a('string')
|
||||||
|
.and.satisfy(s => s.startsWith('video/webm; codecs="vp09.'))
|
||||||
|
.and.satisfy(s => {
|
||||||
|
const matches = s.match(/vp09\.[0-9]{2}\.([0-9A-F]{2})\.[0-9A-F]{2}/);
|
||||||
|
return matches && matches.length === 2 && matches[1] === '15';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects level = -99', () => {
|
||||||
|
info.streams[0].level = -99; // I'm not sure what ffprobe means by this.
|
||||||
|
expect(getFullMIMEString(info))
|
||||||
|
.to.be.a('string')
|
||||||
|
.and.equals('video/webm; codecs="vp9"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects codec_name=vp9 but no codec_tag_string', () => {
|
||||||
|
info.streams[0].codec_name = 'vp9';
|
||||||
|
info.streams[0].codec_tag_string = '[0][0][0][0]';
|
||||||
|
info.streams[0].profile = 'Profile 0';
|
||||||
info.streams[0].level = 21; // 0x15
|
info.streams[0].level = 21; // 0x15
|
||||||
expect(getFullMIMEString(info))
|
expect(getFullMIMEString(info))
|
||||||
.to.be.a('string')
|
.to.be.a('string')
|
||||||
.and.satisfy(s => s.startsWith('video/webm; codecs="vp09.'))
|
.and.satisfy(s => s.startsWith('video/webm; codecs="vp09.00.15'));
|
||||||
.and.satisfy(s => {
|
|
||||||
const matches = s.match(/vp09\.[0-9]{2}\.([0-9A-F]{2})\.[0-9A-F]{2}/);
|
|
||||||
return matches && matches.length === 2 && matches[1] === '15';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('detects level = -99', () => {
|
|
||||||
info.streams[0].level = -99; // I'm not sure what ffprobe means by this.
|
|
||||||
expect(getFullMIMEString(info))
|
|
||||||
.to.be.a('string')
|
|
||||||
.and.equals('video/webm; codecs="vp9"');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detects codec_name=vp9 but no codec_tag_string', () => {
|
describe('Vorbis', () => {
|
||||||
info.streams[0].codec_name = 'vp9';
|
/** @type {ProbeInfo} */
|
||||||
info.streams[0].codec_tag_string = '[0][0][0][0]';
|
let info;
|
||||||
info.streams[0].profile = 'Profile 0';
|
|
||||||
info.streams[0].level = 21; // 0x15
|
beforeEach(() => {
|
||||||
expect(getFullMIMEString(info))
|
info = {
|
||||||
.to.be.a('string')
|
format: { format_name: 'matroska,webm' },
|
||||||
.and.satisfy(s => s.startsWith('video/webm; codecs="vp09.00.15'));
|
streams: [{ codec_type: 'audio', codec_name: 'vorbis' }],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects vorbis', () => {
|
||||||
|
expect(getFullMIMEString(info))
|
||||||
|
.to.be.a('string')
|
||||||
|
.and.equals('audio/webm; codecs="vorbis"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Opus', () => {
|
||||||
|
/** @type {ProbeInfo} */
|
||||||
|
let info;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
info = {
|
||||||
|
format: { format_name: 'matroska,webm' },
|
||||||
|
streams: [{
|
||||||
|
codec_type: 'audio',
|
||||||
|
codec_name: 'opus',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects opus', () => {
|
||||||
|
expect(getFullMIMEString(info))
|
||||||
|
.to.be.a('string')
|
||||||
|
.and.equals('audio/webm; codecs="opus"');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('MPEG2', () => {
|
describe('Matroska', () => {
|
||||||
/** @type {ProbeInfo} */
|
describe('MPEG2 codec', () => {
|
||||||
let info;
|
/** @type {ProbeInfo} */
|
||||||
|
let info;
|
||||||
beforeEach(() => {
|
|
||||||
info = {
|
beforeEach(() => {
|
||||||
format: { format_name: 'matroska,webm' },
|
info = {
|
||||||
streams: [{
|
format: { format_name: 'matroska,webm' },
|
||||||
codec_type: 'video',
|
streams: [{ codec_type: 'video', codec_name: 'mpeg2video' }],
|
||||||
codec_name: 'mpeg2video',
|
};
|
||||||
}],
|
});
|
||||||
};
|
|
||||||
|
it('outputs full MIME string', () => {
|
||||||
|
expect(getFullMIMEString(info))
|
||||||
|
.to.be.a('string')
|
||||||
|
.and.equals('video/x-matroska; codecs="mpeg2video"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('AC-3', () => {
|
||||||
|
/** @type {ProbeInfo} */
|
||||||
|
let info;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
info = {
|
||||||
|
format: { format_name: 'matroska,webm' },
|
||||||
|
streams: [{ codec_type: 'audio', codec_name: 'ac3' }],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects AC-3', () => {
|
||||||
|
expect(getFullMIMEString(info))
|
||||||
|
.to.be.a('string')
|
||||||
|
.and.equals('audio/x-matroska; codecs="ac-3"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DTS', () => {
|
||||||
|
/** @type {ProbeInfo} */
|
||||||
|
let info;
|
||||||
|
|
||||||
it('detects mpeg2video', () => {
|
beforeEach(() => {
|
||||||
expect(getFullMIMEString(info))
|
info = {
|
||||||
.to.be.a('string')
|
format: { format_name: 'matroska,webm' },
|
||||||
.and.equals('video/webm; codecs="mpeg2video"');
|
streams: [{ codec_type: 'audio', codec_name: 'dts' }],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('outputs full MIME string', () => {
|
||||||
|
expect(getFullMIMEString(info))
|
||||||
|
.to.be.a('string')
|
||||||
|
.and.equals('audio/x-matroska; codecs="dts"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -393,69 +514,6 @@ describe('codecs test suite', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Vorbis', () => {
|
|
||||||
/** @type {ProbeInfo} */
|
|
||||||
let info;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
info = {
|
|
||||||
format: { format_name: 'matroska,webm' },
|
|
||||||
streams: [{
|
|
||||||
codec_type: 'audio',
|
|
||||||
codec_name: 'vorbis',
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
it('detects vorbis', () => {
|
|
||||||
expect(getFullMIMEString(info))
|
|
||||||
.to.be.a('string')
|
|
||||||
.and.equals('audio/webm; codecs="vorbis"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Opus', () => {
|
|
||||||
/** @type {ProbeInfo} */
|
|
||||||
let info;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
info = {
|
|
||||||
format: { format_name: 'matroska,webm' },
|
|
||||||
streams: [{
|
|
||||||
codec_type: 'audio',
|
|
||||||
codec_name: 'opus',
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
it('detects opus', () => {
|
|
||||||
expect(getFullMIMEString(info))
|
|
||||||
.to.be.a('string')
|
|
||||||
.and.equals('audio/webm; codecs="opus"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('AC-3', () => {
|
|
||||||
/** @type {ProbeInfo} */
|
|
||||||
let info;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
info = {
|
|
||||||
format: { format_name: 'matroska,webm' },
|
|
||||||
streams: [{
|
|
||||||
codec_type: 'audio',
|
|
||||||
codec_name: 'ac3',
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
it('detects AC-3', () => {
|
|
||||||
expect(getFullMIMEString(info))
|
|
||||||
.to.be.a('string')
|
|
||||||
.and.equals('audio/webm; codecs="ac-3"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('AVI', () => {
|
describe('AVI', () => {
|
||||||
it('detects AVI', () => {
|
it('detects AVI', () => {
|
||||||
/** @type {ProbeInfo} */
|
/** @type {ProbeInfo} */
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue