feat: refactor folder structure & add new codec parser and gen & add unit tests
This commit is contained in:
11
packages/matroska/package.json
Normal file
11
packages/matroska/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@konoplayer/matroska",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {},
|
||||
"dependencies": {
|
||||
"@konoplayer/core": "workspace:*",
|
||||
"konoebml": "^0.1.2"
|
||||
}
|
||||
}
|
||||
110
packages/matroska/src/codecs/aac.ts
Normal file
110
packages/matroska/src/codecs/aac.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { ParseCodecError } from '@konoplayer/core/errors';
|
||||
import { ArkErrors, type } from 'arktype';
|
||||
|
||||
export const AAC_CODEC_TYPE = 'AAC';
|
||||
|
||||
export const AudioObjectTypeSchema = type('1 | 2 | 3 | 4 | 5 | 29 | 67');
|
||||
|
||||
export const SamplingFrequencyIndexSchema = type('1|2|3|4|5|6|7|8|9|10|11|12');
|
||||
|
||||
export const ChannelConfigurationSchema = type('1 | 2 | 3 | 4 | 5 | 6 | 7');
|
||||
|
||||
export const AudioSpecificConfigSchema = type({
|
||||
audioObjectType: AudioObjectTypeSchema, // AAC profiles: Main, LC, SSR, LTP, HE, HE v2
|
||||
samplingFrequencyIndex: SamplingFrequencyIndexSchema.optional(), // Sampling rate index
|
||||
channelConfiguration: ChannelConfigurationSchema, // Channel config (1-7)
|
||||
sbrPresent: type.boolean.optional(), // Optional: Indicates SBR presence
|
||||
psPresent: type.boolean.optional(), // Optional: Indicates PS presence (for HE-AAC v2)
|
||||
});
|
||||
|
||||
export type AudioSpecificConfigType = typeof AudioSpecificConfigSchema.infer;
|
||||
|
||||
/**
|
||||
* Parse AudioSpecificConfig from codec_private Uint8Array
|
||||
* @param codecPrivate - Uint8Array containing codec_private data
|
||||
* @returns Parsed AudioSpecificConfig or throws an error if invalid
|
||||
*/
|
||||
export function parseAudioSpecificConfig(
|
||||
codecPrivate: Uint8Array
|
||||
): AudioSpecificConfigType {
|
||||
if (codecPrivate.length < 2) {
|
||||
throw new ParseCodecError(AAC_CODEC_TYPE, 'codec_private data too short');
|
||||
}
|
||||
|
||||
// Create a DataView for bit-level manipulation
|
||||
const view = new DataView(
|
||||
codecPrivate.buffer,
|
||||
codecPrivate.byteOffset,
|
||||
codecPrivate.byteLength
|
||||
);
|
||||
let byteOffset = 0;
|
||||
let bitOffset = 0;
|
||||
|
||||
// Helper function to read specific number of bits
|
||||
function readBits(bits: number): number {
|
||||
let value = 0;
|
||||
for (let i = 0; i < bits; i++) {
|
||||
const byte = view.getUint8(byteOffset);
|
||||
const bit = (byte >> (7 - bitOffset)) & 1;
|
||||
value = (value << 1) | bit;
|
||||
bitOffset++;
|
||||
if (bitOffset === 8) {
|
||||
bitOffset = 0;
|
||||
byteOffset++;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Read 5 bits for audioObjectType
|
||||
const audioObjectType = readBits(5);
|
||||
|
||||
// Read 4 bits for samplingFrequencyIndex
|
||||
const samplingFrequencyIndex = readBits(4);
|
||||
|
||||
// Read 4 bits for channelConfiguration
|
||||
const channelConfiguration = readBits(4);
|
||||
|
||||
// Check for SBR/PS extension (if audioObjectType indicates HE-AAC)
|
||||
let sbrPresent = false;
|
||||
let psPresent = false;
|
||||
if (audioObjectType === 5 || audioObjectType === 29) {
|
||||
sbrPresent = true;
|
||||
if (audioObjectType === 29) {
|
||||
psPresent = true; // HE-AAC v2 includes Parametric Stereo
|
||||
}
|
||||
// Skip extension-specific bits if present (simplified here)
|
||||
// In real cases, additional parsing may be needed
|
||||
}
|
||||
|
||||
// Construct the result object
|
||||
const config: AudioSpecificConfigType = {
|
||||
audioObjectType:
|
||||
audioObjectType as AudioSpecificConfigType['audioObjectType'],
|
||||
samplingFrequencyIndex:
|
||||
samplingFrequencyIndex as AudioSpecificConfigType['samplingFrequencyIndex'],
|
||||
channelConfiguration:
|
||||
channelConfiguration as AudioSpecificConfigType['channelConfiguration'],
|
||||
...(sbrPresent && { sbrPresent }),
|
||||
...(psPresent && { psPresent }),
|
||||
};
|
||||
|
||||
// Validate with arktype
|
||||
const validation = AudioSpecificConfigSchema(config);
|
||||
if (validation instanceof ArkErrors) {
|
||||
const error = new ParseCodecError(
|
||||
AAC_CODEC_TYPE,
|
||||
'Invalid AudioSpecificConfig'
|
||||
);
|
||||
error.cause = validation;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export function genCodecIdByAudioSpecificConfig(
|
||||
config: AudioSpecificConfigType
|
||||
) {
|
||||
return `mp4a.40.${config.audioObjectType}`;
|
||||
}
|
||||
167
packages/matroska/src/codecs/av1.ts
Normal file
167
packages/matroska/src/codecs/av1.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { BitReader } from '@konoplayer/core/data';
|
||||
import { type } from 'arktype';
|
||||
import type { TrackEntryType } from '../schema';
|
||||
import { ParseCodecError } from '@konoplayer/core/errors';
|
||||
|
||||
export const AV1_CODEC_TYPE = 'AV1';
|
||||
|
||||
export const AV1DecoderConfigurationRecordSchema = type({
|
||||
marker: type.number, // 1 bit, must be 1
|
||||
version: type.number, // 7 bits, must be 1
|
||||
seqProfile: type.number, // 3 bits, seq profile (0-7)
|
||||
seqLevelIdx0: type.number, // 5 bits, seq level (0-31)
|
||||
seqTier0: type.number, // 1 bit, tier (0 or 1)
|
||||
highBitdepth: type.number, // 1 bit, high or low
|
||||
twelveBit: type.number, // 1 bit, if 12-bit
|
||||
monochrome: type.number, // 1 bit, if mono chrome
|
||||
chromaSubsamplingX: type.number, // 1 bit, sub sampling X
|
||||
chromaSubsamplingY: type.number, // 1 bit, sub sampling Y
|
||||
chromaSamplePosition: type.number, // 2 bits
|
||||
initialPresentationDelayPresent: type.number, // 1 bit
|
||||
initialPresentationDelayMinus1: type.number.optional(), // 4 bits, optoinal
|
||||
configOBUs: type.instanceOf(Uint8Array<ArrayBufferLike>), // remain OBU data
|
||||
});
|
||||
|
||||
export type AV1DecoderConfigurationRecordType =
|
||||
typeof AV1DecoderConfigurationRecordSchema.infer;
|
||||
|
||||
/**
|
||||
* [webkit impl](https://github.com/movableink/webkit/blob/7e43fe7000b319ce68334c09eed1031642099726/Source/WebCore/platform/graphics/AV1Utilities.cpp#L48)
|
||||
*/
|
||||
export function parseAV1DecoderConfigurationRecord(
|
||||
track: TrackEntryType
|
||||
): AV1DecoderConfigurationRecordType {
|
||||
const codecPrivate = track.CodecPrivate;
|
||||
|
||||
if (!codecPrivate) {
|
||||
throw new ParseCodecError(
|
||||
AV1_CODEC_TYPE,
|
||||
'CodecPrivate of AVC Track is missing'
|
||||
);
|
||||
}
|
||||
|
||||
if (codecPrivate.length < 4) {
|
||||
throw new ParseCodecError(
|
||||
AV1_CODEC_TYPE,
|
||||
'Input data too short for AV1DecoderConfigurationRecord'
|
||||
);
|
||||
}
|
||||
|
||||
const reader = new BitReader(codecPrivate);
|
||||
|
||||
// Byte 0
|
||||
const marker = reader.readBits(1);
|
||||
const version = reader.readBits(7);
|
||||
if (marker !== 1 || version !== 1) {
|
||||
throw new ParseCodecError(
|
||||
AV1_CODEC_TYPE,
|
||||
`Invalid marker (${marker}) or version (${version})`
|
||||
);
|
||||
}
|
||||
|
||||
const seqProfile = reader.readBits(3);
|
||||
const seqLevelIdx0 = reader.readBits(5);
|
||||
|
||||
// Byte 1
|
||||
const seqTier0 = reader.readBits(1);
|
||||
const highBitdepth = reader.readBits(1);
|
||||
const twelveBit = reader.readBits(1);
|
||||
const monochrome = reader.readBits(1);
|
||||
const chromaSubsamplingX = reader.readBits(1);
|
||||
const chromaSubsamplingY = reader.readBits(1);
|
||||
const chromaSamplePosition = reader.readBits(2);
|
||||
|
||||
// Byte 2
|
||||
const reserved1 = reader.readBits(3);
|
||||
if (reserved1 !== 0) {
|
||||
throw new ParseCodecError(
|
||||
AV1_CODEC_TYPE,
|
||||
`Reserved bits must be 0, got ${reserved1}`
|
||||
);
|
||||
}
|
||||
const initialPresentationDelayPresent = reader.readBits(1);
|
||||
let initialPresentationDelayMinus1: number | undefined;
|
||||
if (initialPresentationDelayPresent) {
|
||||
initialPresentationDelayMinus1 = reader.readBits(4);
|
||||
} else {
|
||||
const reserved2 = reader.readBits(4);
|
||||
if (reserved2 !== 0) {
|
||||
throw new ParseCodecError(
|
||||
AV1_CODEC_TYPE,
|
||||
`Reserved bits must be 0, got ${reserved2}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// remain bytes as configOBUs
|
||||
const configOBUs = reader.getRemainingBytes();
|
||||
|
||||
return {
|
||||
marker,
|
||||
version,
|
||||
seqProfile,
|
||||
seqLevelIdx0,
|
||||
seqTier0,
|
||||
highBitdepth,
|
||||
twelveBit,
|
||||
monochrome,
|
||||
chromaSubsamplingX,
|
||||
chromaSubsamplingY,
|
||||
chromaSamplePosition,
|
||||
initialPresentationDelayPresent,
|
||||
initialPresentationDelayMinus1,
|
||||
configOBUs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* [webkit impl](https://github.com/movableink/webkit/blob/7e43fe7000b319ce68334c09eed1031642099726/Source/WebCore/platform/graphics/AV1Utilities.cpp#L197)
|
||||
*/
|
||||
export function genCodecStringByAV1DecoderConfigurationRecord(
|
||||
config: AV1DecoderConfigurationRecordType
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Prefix
|
||||
parts.push('av01');
|
||||
|
||||
// Profile
|
||||
parts.push(config.seqProfile.toString());
|
||||
|
||||
// Level and Tier
|
||||
const levelStr = config.seqLevelIdx0.toString().padStart(2, '0');
|
||||
const tierStr = config.seqTier0 === 0 ? 'M' : 'H';
|
||||
parts.push(`${levelStr}${tierStr}`);
|
||||
|
||||
// Bit Depth
|
||||
let bitDepthStr: string;
|
||||
if (config.highBitdepth === 0) {
|
||||
bitDepthStr = '08'; // 8-bit
|
||||
} else if (config.twelveBit === 0) {
|
||||
bitDepthStr = '10'; // 10-bit
|
||||
} else {
|
||||
bitDepthStr = '12'; // 12-bit
|
||||
}
|
||||
parts.push(bitDepthStr);
|
||||
|
||||
// Monochrome
|
||||
parts.push(config.monochrome.toString());
|
||||
|
||||
// Chroma Subsampling
|
||||
const chromaSubsampling = `${config.chromaSubsamplingX}${config.chromaSubsamplingY}${config.chromaSamplePosition}`;
|
||||
parts.push(chromaSubsampling);
|
||||
|
||||
// Initial Presentation Delay(optional)
|
||||
if (
|
||||
config.initialPresentationDelayPresent === 1 &&
|
||||
config.initialPresentationDelayMinus1 !== undefined
|
||||
) {
|
||||
const delay = (config.initialPresentationDelayMinus1 + 1)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
parts.push(delay);
|
||||
}
|
||||
|
||||
// joined
|
||||
return parts.join('.');
|
||||
}
|
||||
148
packages/matroska/src/codecs/avc.ts
Normal file
148
packages/matroska/src/codecs/avc.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { ParseCodecError } from '@konoplayer/core/errors';
|
||||
import { type } from 'arktype';
|
||||
import type { TrackEntryType } from '../schema';
|
||||
|
||||
export const AVC_CODEC_TYPE = 'h264(AVC)';
|
||||
|
||||
export const AVCDecoderConfigurationRecordSchema = type({
|
||||
configurationVersion: type.number, // Configuration version, typically 1
|
||||
avcProfileIndication: type.number, // AVC profile
|
||||
profileCompatibility: type.number, // Profile compatibility
|
||||
avcLevelIndication: type.number, // AVC level
|
||||
lengthSizeMinusOne: type.number, // NAL unit length field size minus 1
|
||||
sps: type
|
||||
.instanceOf(Uint8Array<ArrayBufferLike>)
|
||||
.array()
|
||||
.atLeastLength(1), // Sequence Parameter Sets (SPS)
|
||||
pps: type
|
||||
.instanceOf(Uint8Array<ArrayBufferLike>)
|
||||
.array()
|
||||
.atLeastLength(1), // Picture Parameter Sets (PPS)
|
||||
});
|
||||
|
||||
export type AVCDecoderConfigurationRecordType =
|
||||
typeof AVCDecoderConfigurationRecordSchema.infer;
|
||||
|
||||
/**
|
||||
*
|
||||
* @see [webkit](https://github.com/movableink/webkit/blob/7e43fe7000b319ce68334c09eed1031642099726/Source/WebCore/platform/graphics/HEVCUtilities.cpp#L84)
|
||||
*/
|
||||
export function parseAVCDecoderConfigurationRecord(
|
||||
track: TrackEntryType
|
||||
): AVCDecoderConfigurationRecordType {
|
||||
// ISO/IEC 14496-10:2014
|
||||
// 7.3.2.1.1 Sequence parameter set data syntax
|
||||
const codecPrivate = track.CodecPrivate;
|
||||
|
||||
if (!codecPrivate) {
|
||||
throw new ParseCodecError(
|
||||
AVC_CODEC_TYPE,
|
||||
'CodecPrivate of AVC Track is missing'
|
||||
);
|
||||
}
|
||||
|
||||
// AVCDecoderConfigurationRecord is at a minimum 24 bytes long
|
||||
if (codecPrivate.length < 24) {
|
||||
throw new ParseCodecError(
|
||||
AVC_CODEC_TYPE,
|
||||
'Input data too short for AVCDecoderConfigurationRecord'
|
||||
);
|
||||
}
|
||||
|
||||
const view = new DataView(codecPrivate.buffer);
|
||||
let offset = 0;
|
||||
|
||||
const readUint8 = (move: boolean) => {
|
||||
const result = view.getUint8(offset);
|
||||
if (move) {
|
||||
offset += 1;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const readUint16 = (move: boolean) => {
|
||||
const result = view.getUint16(offset, false);
|
||||
if (move) {
|
||||
offset += 2;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const configurationVersion = readUint8(true);
|
||||
const avcProfileIndication = readUint8(true);
|
||||
const profileCompatibility = readUint8(true);
|
||||
const avcLevelIndication = readUint8(true);
|
||||
|
||||
// Read lengthSizeMinusOne (first 6 bits are reserved, typically 0xFF, last 2 bits are the value)
|
||||
const lengthSizeMinusOne = readUint8(true) & 0x03;
|
||||
|
||||
// Read number of SPS (first 3 bits are reserved, typically 0xE0, last 5 bits are SPS count)
|
||||
const numOfSPS = readUint8(true) & 0x1f;
|
||||
const sps: Uint8Array[] = [];
|
||||
|
||||
// Parse SPS
|
||||
for (let i = 0; i < numOfSPS; i++) {
|
||||
if (offset + 2 > codecPrivate.length) {
|
||||
throw new ParseCodecError(AVC_CODEC_TYPE, 'Invalid SPS length');
|
||||
}
|
||||
|
||||
const spsLength = readUint16(true);
|
||||
|
||||
if (offset + spsLength > codecPrivate.length) {
|
||||
throw new ParseCodecError(
|
||||
AVC_CODEC_TYPE,
|
||||
'SPS data exceeds buffer length'
|
||||
);
|
||||
}
|
||||
|
||||
sps.push(codecPrivate.subarray(offset, offset + spsLength));
|
||||
offset += spsLength;
|
||||
}
|
||||
|
||||
// Read number of PPS
|
||||
if (offset >= codecPrivate.length) {
|
||||
throw new ParseCodecError(AVC_CODEC_TYPE, 'No space for PPS count');
|
||||
}
|
||||
const numOfPPS = readUint8(true);
|
||||
const pps: Uint8Array[] = [];
|
||||
|
||||
// Parse PPS
|
||||
for (let i = 0; i < numOfPPS; i++) {
|
||||
if (offset + 2 > codecPrivate.length) {
|
||||
throw new ParseCodecError(AVC_CODEC_TYPE, 'Invalid PPS length');
|
||||
}
|
||||
|
||||
const ppsLength = readUint16(true);
|
||||
|
||||
if (offset + ppsLength > codecPrivate.length) {
|
||||
throw new ParseCodecError(
|
||||
AVC_CODEC_TYPE,
|
||||
'PPS data exceeds buffer length'
|
||||
);
|
||||
}
|
||||
|
||||
pps.push(codecPrivate.subarray(offset, offset + ppsLength));
|
||||
offset += ppsLength;
|
||||
}
|
||||
|
||||
return {
|
||||
configurationVersion,
|
||||
avcProfileIndication,
|
||||
profileCompatibility,
|
||||
avcLevelIndication,
|
||||
lengthSizeMinusOne,
|
||||
sps,
|
||||
pps,
|
||||
};
|
||||
}
|
||||
|
||||
export function genCodecStringByAVCDecoderConfigurationRecord(
|
||||
config: AVCDecoderConfigurationRecordType
|
||||
): string {
|
||||
const profileHex = config.avcProfileIndication.toString(16).padStart(2, '0');
|
||||
const profileCompatHex = config.profileCompatibility
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
const levelHex = config.avcLevelIndication.toString(16).padStart(2, '0');
|
||||
return `avc1.${profileHex}${profileCompatHex}${levelHex}`;
|
||||
}
|
||||
214
packages/matroska/src/codecs/hevc.ts
Normal file
214
packages/matroska/src/codecs/hevc.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { ParseCodecError } from '@konoplayer/core/errors';
|
||||
import { ArkErrors, type } from 'arktype';
|
||||
import type { TrackEntryType } from '../schema';
|
||||
|
||||
export const HEVC_CODEC_TYPE = 'h265(HEVC)';
|
||||
|
||||
export const HEVCDecoderConfigurationRecordArraySchema = type({
|
||||
arrayCompleteness: type.number,
|
||||
nalUnitType: type.number,
|
||||
numNalus: type.number,
|
||||
nalUnit: type.instanceOf(Uint8Array<ArrayBufferLike>).array(),
|
||||
});
|
||||
|
||||
export type HEVCDecoderConfigurationRecordArrayType =
|
||||
typeof HEVCDecoderConfigurationRecordArraySchema.infer;
|
||||
|
||||
// Define the schema for HEVCDecoderConfigurationRecord
|
||||
export const HEVCDecoderConfigurationRecordSchema = type({
|
||||
configurationVersion: type.number, // Must be 1
|
||||
generalProfileSpace: type.number,
|
||||
generalTierFlag: type.number,
|
||||
generalProfileIdc: type.number,
|
||||
generalProfileCompatibilityFlags: type.number,
|
||||
generalConstraintIndicatorFlags: type.number,
|
||||
generalLevelIdc: type.number,
|
||||
minSpatialSegmentationIdc: type.number,
|
||||
parallelismType: type.number,
|
||||
chromaFormat: type.number,
|
||||
bitDepthLumaMinus8: type.number,
|
||||
bitDepthChromaMinus8: type.number,
|
||||
avgFrameRate: type.number,
|
||||
constantFrameRate: type.number,
|
||||
numTemporalLayers: type.number,
|
||||
temporalIdNested: type.number,
|
||||
lengthSizeMinusOne: type.number,
|
||||
numOfArrays: type.number,
|
||||
nalUnits: HEVCDecoderConfigurationRecordArraySchema.array(),
|
||||
});
|
||||
|
||||
export type HEVCDecoderConfigurationRecordType =
|
||||
typeof HEVCDecoderConfigurationRecordSchema.infer;
|
||||
|
||||
export function parseHEVCDecoderConfigurationRecord(
|
||||
track: TrackEntryType
|
||||
): HEVCDecoderConfigurationRecordType {
|
||||
const codecPrivate = track.CodecPrivate;
|
||||
if (!codecPrivate) {
|
||||
throw new ParseCodecError(
|
||||
HEVC_CODEC_TYPE,
|
||||
'CodecPrivate of HEVC Track is missing'
|
||||
);
|
||||
}
|
||||
const view = new DataView(codecPrivate.buffer);
|
||||
let offset = 0;
|
||||
|
||||
const readUint8 = (move: boolean) => {
|
||||
const result = view.getUint8(offset);
|
||||
if (move) {
|
||||
offset += 1;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const readUint16 = (move: boolean) => {
|
||||
const result = view.getUint16(offset, false);
|
||||
if (move) {
|
||||
offset += 2;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const readUint48 = (move: boolean) => {
|
||||
const result =
|
||||
view.getUint16(offset, false) * 2 ** 32 +
|
||||
view.getUint32(offset + 2, false);
|
||||
if (move) {
|
||||
offset += 6;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const readUint32 = (move: boolean) => {
|
||||
const result = view.getUint32(offset, false);
|
||||
if (move) {
|
||||
offset += 4;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Read and validate basic fields
|
||||
const config: HEVCDecoderConfigurationRecordType = {
|
||||
configurationVersion: readUint8(true),
|
||||
generalProfileSpace: (readUint8(false) & 0xc0) >> 6,
|
||||
generalTierFlag: (readUint8(false) & 0x20) >> 5,
|
||||
generalProfileIdc: readUint8(true) & 0x1f,
|
||||
generalProfileCompatibilityFlags: readUint32(true),
|
||||
generalConstraintIndicatorFlags: readUint48(true),
|
||||
generalLevelIdc: readUint8(true),
|
||||
minSpatialSegmentationIdc: readUint16(true) & 0x0fff,
|
||||
parallelismType: readUint8(true) & 0x03,
|
||||
chromaFormat: readUint8(true) & 0x03,
|
||||
bitDepthLumaMinus8: readUint8(true) & 0x07,
|
||||
bitDepthChromaMinus8: readUint8(true) & 0x07,
|
||||
avgFrameRate: readUint16(true),
|
||||
constantFrameRate: (readUint8(false) & 0xc0) >> 6,
|
||||
numTemporalLayers: (readUint8(false) & 0x38) >> 3,
|
||||
temporalIdNested: (readUint8(false) & 0x04) >> 2,
|
||||
lengthSizeMinusOne: readUint8(true) & 0x03,
|
||||
numOfArrays: readUint8(true),
|
||||
nalUnits: [],
|
||||
};
|
||||
|
||||
// Parse NAL unit arrays
|
||||
const arrays = config.nalUnits;
|
||||
|
||||
for (let i = 0; i < config.numOfArrays; i++) {
|
||||
const array: HEVCDecoderConfigurationRecordArrayType = {
|
||||
arrayCompleteness: (readUint8(false) & 0x80) >> 7,
|
||||
nalUnitType: readUint8(true) & 0x3f,
|
||||
numNalus: readUint16(true),
|
||||
nalUnit: [] as Uint8Array<ArrayBufferLike>[],
|
||||
};
|
||||
|
||||
for (let j = 0; j < array.numNalus; j++) {
|
||||
const nalUnitLength = readUint16(true);
|
||||
array.nalUnit.push(codecPrivate.subarray(offset, offset + nalUnitLength));
|
||||
offset += nalUnitLength;
|
||||
}
|
||||
arrays.push(array);
|
||||
}
|
||||
|
||||
// Validate using arktype
|
||||
const validation = HEVCDecoderConfigurationRecordSchema(config);
|
||||
if (validation instanceof ArkErrors) {
|
||||
const error = new ParseCodecError(
|
||||
HEVC_CODEC_TYPE,
|
||||
'Invalid HEVC configuration record'
|
||||
);
|
||||
error.cause = validation;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return validation;
|
||||
}
|
||||
|
||||
function reverseBits32(value: number): number {
|
||||
let result = 0;
|
||||
for (let i = 0; i < 32; i++) {
|
||||
result = (result << 1) | ((value >> i) & 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see[webkit implementation](https://github.com/movableink/webkit/blob/7e43fe7000b319ce68334c09eed1031642099726/Source/WebCore/platform/graphics/HEVCUtilities.cpp#L204)
|
||||
*/
|
||||
export function genCodecStringByHEVCDecoderConfigurationRecord(
|
||||
config: HEVCDecoderConfigurationRecordType
|
||||
) {
|
||||
const result: string[] = [];
|
||||
|
||||
// prefix
|
||||
result.push(`hev${config.configurationVersion}`);
|
||||
|
||||
// Profile Space
|
||||
if (config.generalProfileSpace > 0) {
|
||||
const profileSpaceChar = String.fromCharCode(
|
||||
'A'.charCodeAt(0) + config.generalProfileSpace - 1
|
||||
);
|
||||
result.push(profileSpaceChar + config.generalProfileIdc.toString());
|
||||
} else {
|
||||
result.push(config.generalProfileIdc.toString());
|
||||
}
|
||||
|
||||
// Profile Compatibility Flags
|
||||
const compatFlags = reverseBits32(config.generalProfileCompatibilityFlags)
|
||||
.toString(16)
|
||||
.toUpperCase();
|
||||
result.push(compatFlags);
|
||||
|
||||
// Tier Flag and Level IDC
|
||||
const tierPrefix = config.generalTierFlag ? 'H' : 'L';
|
||||
result.push(tierPrefix + config.generalLevelIdc.toString());
|
||||
|
||||
// Constraint Indicator Flags
|
||||
let constraintBytes: number[];
|
||||
if (Array.isArray(config.generalConstraintIndicatorFlags)) {
|
||||
constraintBytes = config.generalConstraintIndicatorFlags as number[];
|
||||
} else {
|
||||
// split 48 bit integer into 6 byte
|
||||
const flags = BigInt(config.generalConstraintIndicatorFlags);
|
||||
constraintBytes = [];
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
constraintBytes.push(Number((flags >> BigInt(8 * i)) & BigInt(0xff)));
|
||||
}
|
||||
}
|
||||
|
||||
// find last non-zero byte
|
||||
const lastNonZeroIndex = constraintBytes.reduce(
|
||||
(last, byte, i) => (byte ? i : last),
|
||||
-1
|
||||
);
|
||||
if (lastNonZeroIndex >= 0) {
|
||||
for (let i = 0; i <= lastNonZeroIndex; i++) {
|
||||
const byteHex = constraintBytes[i]
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
.toUpperCase();
|
||||
result.push(byteHex);
|
||||
}
|
||||
}
|
||||
|
||||
return result.join('.');
|
||||
}
|
||||
347
packages/matroska/src/codecs/index.ts
Normal file
347
packages/matroska/src/codecs/index.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
import { UnsupportedCodecError } from '@konoplayer/core/errors';
|
||||
import { VideoCodec, AudioCodec } from '@konoplayer/core/codecs';
|
||||
import type { TrackEntryType } from '../schema';
|
||||
import {
|
||||
genCodecIdByAudioSpecificConfig,
|
||||
parseAudioSpecificConfig,
|
||||
} from './aac';
|
||||
import {
|
||||
genCodecStringByAVCDecoderConfigurationRecord,
|
||||
parseAVCDecoderConfigurationRecord,
|
||||
} from './avc';
|
||||
import {
|
||||
genCodecStringByAV1DecoderConfigurationRecord,
|
||||
parseAV1DecoderConfigurationRecord,
|
||||
} from './av1.ts';
|
||||
import {
|
||||
genCodecStringByHEVCDecoderConfigurationRecord,
|
||||
parseHEVCDecoderConfigurationRecord,
|
||||
} from './hevc.ts';
|
||||
import {
|
||||
genCodecStringByVP9DecoderConfigurationRecord,
|
||||
parseVP9DecoderConfigurationRecord,
|
||||
} from './vp9.ts';
|
||||
|
||||
export const VideoCodecId = {
|
||||
VCM: 'V_MS/VFW/FOURCC',
|
||||
UNCOMPRESSED: 'V_UNCOMPRESSED',
|
||||
MPEG4_ISO_SP: 'V_MPEG4/ISO/SP',
|
||||
MPEG4_ISO_ASP: 'V_MPEG4/ISO/ASP',
|
||||
MPEG4_ISO_AP: 'V_MPEG4/ISO/AP',
|
||||
MPEG4_MS_V3: 'V_MPEG4/MS/V3',
|
||||
MPEG1: 'V_MPEG1',
|
||||
MPEG2: 'V_MPEG2',
|
||||
H264: 'V_MPEG4/ISO/AVC',
|
||||
HEVC: 'V_MPEGH/ISO/HEVC',
|
||||
AVS2: 'V_AVS2',
|
||||
AVS3: 'V_AVS3',
|
||||
RV10: 'V_REAL/RV10',
|
||||
RV20: 'V_REAL/RV20',
|
||||
RV30: 'V_REAL/RV30',
|
||||
RV40: 'V_REAL/RV40',
|
||||
QUICKTIME: 'V_QUICKTIME',
|
||||
THEORA: 'V_THEORA',
|
||||
PROPRES: 'V_PRORES',
|
||||
VP8: 'V_VP8',
|
||||
VP9: 'V_VP9',
|
||||
FFV1: 'V_FFV1',
|
||||
AV1: 'V_AV1',
|
||||
} as const;
|
||||
|
||||
export type VideoCodecIdType =
|
||||
| `${(typeof VideoCodecId)[keyof typeof VideoCodecId]}`
|
||||
| string;
|
||||
|
||||
export const AudioCodecId = {
|
||||
MPEG_L3: 'A_MPEG/L3',
|
||||
MPEG_L2: 'A_MPEG/L2',
|
||||
MPEG_L1: 'A_MPEG/L1',
|
||||
PCM_INT_BIG: 'A_PCM/INT/BIG',
|
||||
PCM_INT_LIT: 'A_PCM/INT/LIT',
|
||||
PCM_FLOAT_IEEE: 'A_PCM/FLOAT/IEEE',
|
||||
MPC: 'A_MPC',
|
||||
AC3: 'A_AC3',
|
||||
AC3_BSID9: 'A_AC3/BSID9',
|
||||
AC3_BSID10: 'A_AC3/BSID10',
|
||||
ALAC: 'A_ALAC',
|
||||
DTS: 'A_DTS',
|
||||
DTS_EXPRESS: 'A_DTS/EXPRESS',
|
||||
DTS_LOSSLESS: 'A_DTS/LOSSLESS',
|
||||
VORBIS: 'A_VORBIS',
|
||||
OPUS: 'A_OPUS',
|
||||
FLAC: 'A_FLAC',
|
||||
EAC3: 'A_EAC3',
|
||||
REAL_14_4: 'A_REAL/14_4',
|
||||
REAL_28_8: 'A_REAL/28_8',
|
||||
REAL_COOK: 'A_REAL/COOK',
|
||||
REAL_SIPR: 'A_REAL/SIPR',
|
||||
REAL_RALF: 'A_REAL/RALF',
|
||||
REAL_ATRC: 'A_REAL/ATRC',
|
||||
MS_ACM: 'A_MS/ACM',
|
||||
AAC: 'A_AAC',
|
||||
AAC_MPEG2_MAIN: 'A_AAC/MPEG2/MAIN',
|
||||
AAC_MPEG2_LC: 'A_AAC/MPEG2/LC',
|
||||
AAC_MPEG2_LC_SBR: 'A_AAC/MPEG2/LC/SBR',
|
||||
AAC_MPEG2_SSR: 'A_AAC/MPEG2/SSR',
|
||||
AAC_MPEG4_MAIN: 'A_AAC/MPEG4/MAIN',
|
||||
AAC_MPEG4_LC: 'A_AAC/MPEG4/LC',
|
||||
AAC_MPEG4_SBR: 'A_AAC/MPEG4/LC/SBR',
|
||||
AAC_MPEG4_SSR: 'A_AAC/MPEG4/SSR',
|
||||
AAC_MPEG4_LTP: 'A_AAC/MPEG4/LTP',
|
||||
QUICKTIME: 'A_QUICKTIME',
|
||||
QDMC: 'A_QUICKTIME/QDMC',
|
||||
QDM2: 'A_QUICKTIME/QDM2',
|
||||
TTA1: 'A_TTA1',
|
||||
WAVEPACK4: 'A_WAVPACK4',
|
||||
ATRAC: 'A_ATRAC/AT1',
|
||||
} as const;
|
||||
|
||||
export type AudioCodecIdType =
|
||||
| `${(typeof AudioCodecId)[keyof typeof AudioCodecId]}`
|
||||
| string;
|
||||
|
||||
export const SubtitleCodecId = {
|
||||
UTF8: 'S_TEXT/UTF8',
|
||||
SSA: 'S_TEXT/SSA',
|
||||
ASS: 'S_TEXT/ASS',
|
||||
WEBVTT: 'S_TEXT/WEBVTT',
|
||||
BMP: 'S_IMAGE/BMP',
|
||||
DVBSUB: 'S_DVBSUB',
|
||||
VOBSUB: 'S_VOBSUB',
|
||||
HDMV_PGS: 'S_HDMV/PGS',
|
||||
HDMV_TEXTST: 'S_HDMV/TEXTST',
|
||||
KATE: 'S_KATE',
|
||||
ARIBSUB: 'S_ARIBSUB',
|
||||
} as const;
|
||||
|
||||
export type SubtitleCodecIdType =
|
||||
| `${(typeof SubtitleCodecId)[keyof typeof SubtitleCodecId]}`
|
||||
| string;
|
||||
|
||||
export interface VideoDecoderConfigExt extends VideoDecoderConfig {
|
||||
codecType: VideoCodec;
|
||||
}
|
||||
|
||||
export function videoCodecIdToWebCodecs(
|
||||
track: TrackEntryType,
|
||||
keyframe: Uint8Array
|
||||
): VideoDecoderConfigExt {
|
||||
const codecId = track.CodecID;
|
||||
const codecPrivate = track.CodecPrivate;
|
||||
const shareOptions = {
|
||||
description: codecPrivate,
|
||||
};
|
||||
switch (codecId) {
|
||||
case VideoCodecId.HEVC:
|
||||
return {
|
||||
...shareOptions,
|
||||
codecType: VideoCodec.HEVC,
|
||||
codec: genCodecStringByHEVCDecoderConfigurationRecord(
|
||||
parseHEVCDecoderConfigurationRecord(track)
|
||||
),
|
||||
};
|
||||
case VideoCodecId.VP9:
|
||||
return {
|
||||
...shareOptions,
|
||||
codecType: VideoCodec.VP9,
|
||||
codec: genCodecStringByVP9DecoderConfigurationRecord(
|
||||
parseVP9DecoderConfigurationRecord(track, keyframe)
|
||||
),
|
||||
};
|
||||
case VideoCodecId.AV1:
|
||||
return {
|
||||
...shareOptions,
|
||||
codecType: VideoCodec.AV1,
|
||||
codec: genCodecStringByAV1DecoderConfigurationRecord(
|
||||
parseAV1DecoderConfigurationRecord(track)
|
||||
),
|
||||
};
|
||||
case VideoCodecId.H264:
|
||||
return {
|
||||
...shareOptions,
|
||||
codecType: VideoCodec.H264,
|
||||
codec: genCodecStringByAVCDecoderConfigurationRecord(
|
||||
parseAVCDecoderConfigurationRecord(track)
|
||||
),
|
||||
};
|
||||
case VideoCodecId.THEORA:
|
||||
return { ...shareOptions, codecType: VideoCodec.Theora, codec: 'theora' };
|
||||
case VideoCodecId.VP8:
|
||||
return { ...shareOptions, codecType: VideoCodec.VP8, codec: 'vp8' };
|
||||
case VideoCodecId.MPEG4_ISO_SP:
|
||||
return {
|
||||
...shareOptions,
|
||||
codecType: VideoCodec.MPEG4,
|
||||
codec: 'mp4v.01.3',
|
||||
};
|
||||
case VideoCodecId.MPEG4_ISO_ASP:
|
||||
return {
|
||||
...shareOptions,
|
||||
codecType: VideoCodec.MPEG4,
|
||||
codec: 'mp4v.20.9',
|
||||
};
|
||||
case VideoCodecId.MPEG4_ISO_AP:
|
||||
return {
|
||||
...shareOptions,
|
||||
codecType: VideoCodec.MPEG4,
|
||||
codec: 'mp4v.20.9',
|
||||
};
|
||||
default:
|
||||
throw new UnsupportedCodecError(codecId, 'web codecs video decoder');
|
||||
}
|
||||
}
|
||||
|
||||
export interface AudioDecoderConfigExt extends AudioDecoderConfig {
|
||||
codecType: AudioCodec;
|
||||
}
|
||||
|
||||
export function audioCodecIdToWebCodecs(
|
||||
track: TrackEntryType
|
||||
): AudioDecoderConfigExt {
|
||||
const codecId = track.CodecID;
|
||||
const codecPrivate = track.CodecPrivate;
|
||||
const bitDepth = track.Audio?.BitDepth;
|
||||
const numberOfChannels = Number(track.Audio?.Channels);
|
||||
const sampleRate = Number(track.Audio?.SamplingFrequency);
|
||||
|
||||
const shareOptions = {
|
||||
numberOfChannels,
|
||||
sampleRate,
|
||||
description: codecPrivate,
|
||||
};
|
||||
|
||||
switch (track.CodecID) {
|
||||
case AudioCodecId.AAC_MPEG4_MAIN:
|
||||
case AudioCodecId.AAC_MPEG2_MAIN:
|
||||
return {
|
||||
...shareOptions,
|
||||
codecType: AudioCodec.AAC,
|
||||
codec: 'mp4a.40.1',
|
||||
};
|
||||
case AudioCodecId.AAC_MPEG2_LC:
|
||||
case AudioCodecId.AAC_MPEG4_LC:
|
||||
return {
|
||||
...shareOptions,
|
||||
codecType: AudioCodec.AAC,
|
||||
codec: 'mp4a.40.2',
|
||||
};
|
||||
case AudioCodecId.AAC_MPEG2_SSR:
|
||||
case AudioCodecId.AAC_MPEG4_SSR:
|
||||
return {
|
||||
...shareOptions,
|
||||
codecType: AudioCodec.AAC,
|
||||
codec: 'mp4a.40.3',
|
||||
};
|
||||
case AudioCodecId.AAC_MPEG4_LTP:
|
||||
return {
|
||||
...shareOptions,
|
||||
codecType: AudioCodec.AAC,
|
||||
codec: 'mp4a.40.4',
|
||||
};
|
||||
case AudioCodecId.AAC_MPEG2_LC_SBR:
|
||||
case AudioCodecId.AAC_MPEG4_SBR:
|
||||
return {
|
||||
...shareOptions,
|
||||
codecType: AudioCodec.AAC,
|
||||
codec: 'mp4a.40.5',
|
||||
};
|
||||
case AudioCodecId.AAC:
|
||||
return {
|
||||
...shareOptions,
|
||||
codecType: AudioCodec.AAC,
|
||||
codec: codecPrivate
|
||||
? genCodecIdByAudioSpecificConfig(
|
||||
parseAudioSpecificConfig(codecPrivate)
|
||||
)
|
||||
: 'mp4a.40.2',
|
||||
};
|
||||
case AudioCodecId.AC3:
|
||||
case AudioCodecId.AC3_BSID9:
|
||||
return {
|
||||
...shareOptions,
|
||||
codecType: AudioCodec.AC3,
|
||||
codec: 'ac-3',
|
||||
};
|
||||
case AudioCodecId.EAC3:
|
||||
case AudioCodecId.AC3_BSID10:
|
||||
return {
|
||||
...shareOptions,
|
||||
codecType: AudioCodec.EAC3,
|
||||
codec: 'ec-3',
|
||||
};
|
||||
case AudioCodecId.MPEG_L3:
|
||||
return {
|
||||
...shareOptions,
|
||||
codecType: AudioCodec.MP3,
|
||||
codec: 'mp3',
|
||||
};
|
||||
case AudioCodecId.VORBIS:
|
||||
return { ...shareOptions, codecType: AudioCodec.Vorbis, codec: 'vorbis' };
|
||||
case AudioCodecId.FLAC:
|
||||
return { ...shareOptions, codecType: AudioCodec.FLAC, codec: 'flac' };
|
||||
case AudioCodecId.OPUS:
|
||||
return { ...shareOptions, codecType: AudioCodec.Opus, codec: 'opus' };
|
||||
case AudioCodecId.ALAC:
|
||||
return { ...shareOptions, codecType: AudioCodec.ALAC, codec: 'alac' };
|
||||
case AudioCodecId.PCM_INT_BIG:
|
||||
if (bitDepth === 16) {
|
||||
return {
|
||||
...shareOptions,
|
||||
codecType: AudioCodec.PCM_S16BE,
|
||||
codec: 'pcm-s16be',
|
||||
};
|
||||
}
|
||||
if (bitDepth === 24) {
|
||||
return {
|
||||
...shareOptions,
|
||||
codecType: AudioCodec.PCM_S24BE,
|
||||
codec: 'pcm-s24be',
|
||||
};
|
||||
}
|
||||
if (bitDepth === 32) {
|
||||
return {
|
||||
...shareOptions,
|
||||
codecType: AudioCodec.PCM_S32BE,
|
||||
codec: 'pcm-s32be',
|
||||
};
|
||||
}
|
||||
throw new UnsupportedCodecError(
|
||||
`${codecId}(${bitDepth}b)`,
|
||||
'web codecs audio decoder'
|
||||
);
|
||||
case AudioCodecId.PCM_INT_LIT:
|
||||
if (bitDepth === 16) {
|
||||
return {
|
||||
...shareOptions,
|
||||
codecType: AudioCodec.PCM_S16LE,
|
||||
codec: 'pcm-s16le',
|
||||
};
|
||||
}
|
||||
if (bitDepth === 24) {
|
||||
return {
|
||||
...shareOptions,
|
||||
codecType: AudioCodec.PCM_S24LE,
|
||||
codec: 'pcm-s24le',
|
||||
};
|
||||
}
|
||||
if (bitDepth === 32) {
|
||||
return {
|
||||
...shareOptions,
|
||||
codecType: AudioCodec.PCM_S32LE,
|
||||
codec: 'pcm-s32le',
|
||||
};
|
||||
}
|
||||
throw new UnsupportedCodecError(
|
||||
`${codecId}(${bitDepth}b)`,
|
||||
'web codecs audio decoder'
|
||||
);
|
||||
case AudioCodecId.PCM_FLOAT_IEEE:
|
||||
return {
|
||||
...shareOptions,
|
||||
codecType: AudioCodec.PCM_F32LE,
|
||||
codec: 'pcm-f32le',
|
||||
};
|
||||
default:
|
||||
throw new UnsupportedCodecError(codecId, 'web codecs audio decoder');
|
||||
}
|
||||
}
|
||||
232
packages/matroska/src/codecs/vp9.ts
Normal file
232
packages/matroska/src/codecs/vp9.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { type } from 'arktype';
|
||||
import type { TrackEntryType } from '../schema';
|
||||
import { BitReader } from '@konoplayer/core/data';
|
||||
import { ParseCodecError } from '@konoplayer/core/errors';
|
||||
|
||||
export const VP9_CODEC_TYPE = 'vp9';
|
||||
|
||||
export enum VP9ColorSpaceEnum {
|
||||
UNKNOWN = 0,
|
||||
BT_601 = 1, // eq bt_470bg
|
||||
BT_709 = 2,
|
||||
SMPTE_170 = 3,
|
||||
SMPTE_240 = 4,
|
||||
BT_2020 = 5,
|
||||
RESERVED = 6,
|
||||
SRGB = 7,
|
||||
}
|
||||
|
||||
export enum VP9YUVRange {
|
||||
STUDIO_SWING = 0,
|
||||
FULL_SWING = 1,
|
||||
}
|
||||
|
||||
export enum VP9Subsampling {
|
||||
UNKNOWN = 0,
|
||||
YUV420 = 1,
|
||||
YUV422 = 2,
|
||||
YUV440 = 3,
|
||||
YUV444 = 4,
|
||||
}
|
||||
|
||||
export const VP9PerformenceLevel = [
|
||||
{ level: '10', maxSampleRate: 829440, maxResolution: 36864 }, // Level 1
|
||||
{ level: '11', maxSampleRate: 2764800, maxResolution: 73728 }, // Level 1
|
||||
{ level: '20', maxSampleRate: 4608000, maxResolution: 122880 }, // Level 2
|
||||
{ level: '21', maxSampleRate: 9216000, maxResolution: 245760 }, // Level 2.1
|
||||
{ level: '30', maxSampleRate: 20736000, maxResolution: 552960 }, // Level 3
|
||||
{ level: '31', maxSampleRate: 36864000, maxResolution: 983040 }, // Level 3.1
|
||||
{ level: '40', maxSampleRate: 83558400, maxResolution: 2228224 }, // Level 4
|
||||
{ level: '41', maxSampleRate: 160432128, maxResolution: 2228224 }, // Level 4.1
|
||||
{ level: '50', maxSampleRate: 311951360, maxResolution: 8912896 }, // Level 5
|
||||
{ level: '51', maxSampleRate: 588251136, maxResolution: 8912896 }, // Level 5.1
|
||||
{ level: '52', maxSampleRate: 1176502272, maxResolution: 8912896 }, // Level 5.2
|
||||
{ level: '60', maxSampleRate: 1176502272, maxResolution: 35651584 }, // Level 6
|
||||
{ level: '61', maxSampleRate: 2353004544, maxResolution: 35651584 }, // Level 6.1
|
||||
{ level: '62', maxSampleRate: 4706009088, maxResolution: 35651584 }, // Level 6.2
|
||||
];
|
||||
|
||||
export const VP9DecoderConfigurationRecordSchema = type({
|
||||
profile: type.number, // 0 | 1 | 2 | 3,
|
||||
bitDepth: type.number, // 8 | 10 | 12
|
||||
colorSpace: type.number,
|
||||
subsampling: type.number, // 420 | 422 | 444
|
||||
width: type.number,
|
||||
height: type.number,
|
||||
yuvRangeFlag: type.number.optional(),
|
||||
hasScaling: type.boolean,
|
||||
renderWidth: type.number,
|
||||
renderHeight: type.number,
|
||||
frameRate: type.number, // frame per second
|
||||
estimateLevel: type.string,
|
||||
});
|
||||
|
||||
export type VP9DecoderConfigurationRecordType =
|
||||
typeof VP9DecoderConfigurationRecordSchema.infer;
|
||||
|
||||
export function parseVP9DecoderConfigurationRecord(
|
||||
track: TrackEntryType,
|
||||
keyframe: Uint8Array
|
||||
): VP9DecoderConfigurationRecordType {
|
||||
const reader = new BitReader(keyframe);
|
||||
const frameRate = 1_000_000_000 / Number(track.DefaultDuration) || 30;
|
||||
|
||||
// Frame Marker: 2 bits, must be 0b10
|
||||
const frameMarker = reader.readBits(2);
|
||||
if (frameMarker !== 2) {
|
||||
throw new ParseCodecError(VP9_CODEC_TYPE, 'invalid frame marker');
|
||||
}
|
||||
|
||||
// Profile: 2 bits
|
||||
const version = reader.readBits(1);
|
||||
const high = reader.readBits(1);
|
||||
|
||||
const profile = (high << 1) + version;
|
||||
|
||||
let reservedZero = 0;
|
||||
if (profile === 3) {
|
||||
reservedZero = reader.readBits(1);
|
||||
if (reservedZero !== 0) {
|
||||
throw new ParseCodecError(
|
||||
VP9_CODEC_TYPE,
|
||||
'Invalid reserved zero bit for profile 3'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Show Existing Frame: 1 bit
|
||||
const showExistingFrame = reader.readBits(1);
|
||||
if (showExistingFrame === 1) {
|
||||
throw new ParseCodecError(VP9_CODEC_TYPE, 'not a keyframe to parse');
|
||||
}
|
||||
|
||||
// Frame Type: 1 bit (0 = keyframe)
|
||||
const frameType = reader.readBits(1);
|
||||
if (frameType !== 0) {
|
||||
throw new ParseCodecError(VP9_CODEC_TYPE, 'not a keyframe to parse');
|
||||
}
|
||||
|
||||
// Show Frame and Error Resilient
|
||||
const _showFrame = reader.readBits(1);
|
||||
const _errorResilient = reader.readBits(1);
|
||||
|
||||
// Sync Code: 3 bytes (0x49, 0x83, 0x42)
|
||||
const syncCode =
|
||||
(reader.readBits(8) << 16) | (reader.readBits(8) << 8) | reader.readBits(8);
|
||||
if (syncCode !== 0x498342) {
|
||||
throw new ParseCodecError(VP9_CODEC_TYPE, 'Invalid sync code');
|
||||
}
|
||||
|
||||
// Bit Depth
|
||||
let bitDepth: number;
|
||||
if (profile >= 2) {
|
||||
const tenOrTwelveBit = reader.readBits(1);
|
||||
bitDepth = tenOrTwelveBit === 0 ? 10 : 12;
|
||||
} else {
|
||||
bitDepth = 8;
|
||||
}
|
||||
|
||||
const colorSpace = reader.readBits(3);
|
||||
|
||||
let subsamplingX: number;
|
||||
let subsamplingY: number;
|
||||
let yuvRangeFlag: number | undefined;
|
||||
if (colorSpace !== VP9ColorSpaceEnum.SRGB) {
|
||||
yuvRangeFlag = reader.readBits(1);
|
||||
if (profile === 1 || profile === 3) {
|
||||
subsamplingX = reader.readBits(1);
|
||||
subsamplingY = reader.readBits(1);
|
||||
reservedZero = reader.readBits(1);
|
||||
} else {
|
||||
subsamplingX = 1;
|
||||
subsamplingY = 1;
|
||||
}
|
||||
} else {
|
||||
if (profile !== 1 && profile !== 3) {
|
||||
throw new ParseCodecError(
|
||||
VP9_CODEC_TYPE,
|
||||
'VP9 profile with sRGB ColorSpace must be 1 or 3'
|
||||
);
|
||||
}
|
||||
subsamplingX = 0;
|
||||
subsamplingY = 0;
|
||||
reservedZero = reader.readBits(1);
|
||||
}
|
||||
|
||||
let subsampling: VP9Subsampling;
|
||||
|
||||
if (!subsamplingX && subsamplingY) {
|
||||
subsampling = VP9Subsampling.YUV440;
|
||||
} else if (subsamplingX && !subsamplingY) {
|
||||
subsampling = VP9Subsampling.YUV422;
|
||||
} else if (subsamplingX && subsamplingY) {
|
||||
subsampling = VP9Subsampling.YUV420;
|
||||
} else if (!subsamplingX && !subsamplingY) {
|
||||
subsampling = VP9Subsampling.YUV444;
|
||||
} else {
|
||||
subsampling = VP9Subsampling.UNKNOWN;
|
||||
}
|
||||
|
||||
// Frame Size (resolution)
|
||||
const widthMinus1 = reader.readBits(16);
|
||||
const heightMinus1 = reader.readBits(16);
|
||||
const hasScaling = !!reader.readBits(1);
|
||||
let renderWidthMinus1 = widthMinus1;
|
||||
let renderHeightMinus1 = heightMinus1;
|
||||
if (hasScaling) {
|
||||
renderWidthMinus1 = reader.readBits(16);
|
||||
renderHeightMinus1 = reader.readBits(16);
|
||||
}
|
||||
|
||||
const width = widthMinus1 + 1;
|
||||
const height = heightMinus1 + 1;
|
||||
|
||||
const sampleRate = width * height * frameRate;
|
||||
const resolution = width * height;
|
||||
|
||||
let estimateLevel = '62';
|
||||
for (const { level, maxSampleRate, maxResolution } of VP9PerformenceLevel) {
|
||||
if (sampleRate <= maxSampleRate && resolution <= maxResolution) {
|
||||
// 检查 profile 和 bitDepth 的额外要求
|
||||
if (profile >= 2 && bitDepth > 8 && Number.parseFloat(level) < 20) {
|
||||
continue;
|
||||
}
|
||||
estimateLevel = level;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
profile,
|
||||
bitDepth,
|
||||
colorSpace,
|
||||
subsampling,
|
||||
yuvRangeFlag,
|
||||
width,
|
||||
height,
|
||||
hasScaling,
|
||||
renderWidth: renderWidthMinus1 + 1,
|
||||
renderHeight: renderHeightMinus1 + 1,
|
||||
frameRate,
|
||||
estimateLevel,
|
||||
};
|
||||
}
|
||||
|
||||
// The format of the 'vp09' codec string is specified in the webm GitHub repo:
|
||||
// <https://github.com/webmproject/vp9-dash/blob/master/VPCodecISOMediaFileFormatBinding.md#codecs-parameter-string>
|
||||
//
|
||||
// The codecs parameter string for the VP codec family is as follows:
|
||||
// <sample entry 4CC>.<profile>.<level>.<bitDepth>.<chromaSubsampling>.
|
||||
// <colourPrimaries>.<transferCharacteristics>.<matrixCoefficients>.
|
||||
// <videoFullRangeFlag>
|
||||
// All parameter values are expressed as double-digit decimals.
|
||||
// sample entry 4CC, profile, level, and bitDepth are all mandatory fields.
|
||||
export function genCodecStringByVP9DecoderConfigurationRecord(
|
||||
config: VP9DecoderConfigurationRecordType
|
||||
): string {
|
||||
const profileStr = config.profile.toString().padStart(2, '0');
|
||||
const bitDepthStr = config.bitDepth.toString().padStart(2, '0');
|
||||
const levelStr = config.estimateLevel;
|
||||
|
||||
return `vp09.${profileStr}.${levelStr}.${bitDepthStr}`;
|
||||
}
|
||||
0
packages/matroska/src/index.ts
Normal file
0
packages/matroska/src/index.ts
Normal file
469
packages/matroska/src/model.ts
Normal file
469
packages/matroska/src/model.ts
Normal file
@@ -0,0 +1,469 @@
|
||||
import {
|
||||
type EbmlClusterTagType,
|
||||
type EbmlCuePointTagType,
|
||||
type EbmlCuesTagType,
|
||||
type EbmlInfoTagType,
|
||||
type EbmlMasterTagType,
|
||||
type EbmlSeekHeadTagType,
|
||||
type EbmlSegmentTagType,
|
||||
EbmlTagIdEnum,
|
||||
EbmlTagPosition,
|
||||
type EbmlTagsTagType,
|
||||
type EbmlTagTagType,
|
||||
type EbmlTagType,
|
||||
type EbmlTrackEntryTagType,
|
||||
type EbmlTracksTagType,
|
||||
} from 'konoebml';
|
||||
import { convertEbmlTagToComponent, type InferType } from './util';
|
||||
import { isEqual, maxBy } from 'lodash-es';
|
||||
import { ArkErrors, type Type } from 'arktype';
|
||||
import {
|
||||
ClusterSchema,
|
||||
type ClusterType,
|
||||
CuePointSchema,
|
||||
type CuePointType,
|
||||
type CueTrackPositionsType,
|
||||
InfoSchema,
|
||||
type InfoType,
|
||||
SeekHeadSchema,
|
||||
type SeekHeadType,
|
||||
TagSchema,
|
||||
type TagType,
|
||||
TrackEntrySchema,
|
||||
type TrackEntryType,
|
||||
TrackTypeRestrictionEnum,
|
||||
} from './schema';
|
||||
import { concatBufs } from 'konoebml/lib/tools';
|
||||
import {
|
||||
ParseCodecErrors,
|
||||
UnreachableOrLogicError,
|
||||
UnsupportedCodecError,
|
||||
} from '@konoplayer/core/errors';
|
||||
import { audioCodecIdToWebCodecs, videoCodecIdToWebCodecs } from './codecs';
|
||||
import { Queue } from 'mnemonist';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
export const SEEK_ID_KAX_INFO = new Uint8Array([0x15, 0x49, 0xa9, 0x66]);
|
||||
export const SEEK_ID_KAX_TRACKS = new Uint8Array([0x16, 0x54, 0xae, 0x6b]);
|
||||
export const SEEK_ID_KAX_CUES = new Uint8Array([0x1c, 0x53, 0xbb, 0x6b]);
|
||||
export const SEEK_ID_KAX_TAGS = new Uint8Array([0x12, 0x54, 0xc3, 0x67]);
|
||||
|
||||
export class SegmentSystem {
|
||||
startTag: EbmlSegmentTagType;
|
||||
headTags: EbmlTagType[] = [];
|
||||
firstCluster: EbmlClusterTagType | undefined;
|
||||
|
||||
cue: CueSystem;
|
||||
cluster: ClusterSystem;
|
||||
seek: SeekSystem;
|
||||
info: InfoSystem;
|
||||
track: TrackSystem;
|
||||
tag: TagSystem;
|
||||
|
||||
constructor(startNode: EbmlSegmentTagType) {
|
||||
this.startTag = startNode;
|
||||
this.cue = new CueSystem(this);
|
||||
this.cluster = new ClusterSystem(this);
|
||||
this.seek = new SeekSystem(this);
|
||||
this.info = new InfoSystem(this);
|
||||
this.track = new TrackSystem(this);
|
||||
this.tag = new TagSystem(this);
|
||||
}
|
||||
|
||||
get contentStartOffset() {
|
||||
return this.startTag.startOffset + this.startTag.headerLength;
|
||||
}
|
||||
|
||||
private seekLocal() {
|
||||
const infoTag = this.seek.seekTagBySeekId(SEEK_ID_KAX_INFO);
|
||||
const tracksTag = this.seek.seekTagBySeekId(SEEK_ID_KAX_TRACKS);
|
||||
const cuesTag = this.seek.seekTagBySeekId(SEEK_ID_KAX_CUES);
|
||||
const tagsTag = this.seek.seekTagBySeekId(SEEK_ID_KAX_TAGS);
|
||||
|
||||
if (cuesTag?.id === EbmlTagIdEnum.Cues) {
|
||||
this.cue.prepareCuesWithTag(cuesTag);
|
||||
}
|
||||
if (infoTag?.id === EbmlTagIdEnum.Info) {
|
||||
this.info.prepareWithInfoTag(infoTag);
|
||||
}
|
||||
if (tracksTag?.id === EbmlTagIdEnum.Tracks) {
|
||||
this.track.prepareTracksWithTag(tracksTag);
|
||||
}
|
||||
if (tagsTag?.id === EbmlTagIdEnum.Tags) {
|
||||
this.tag.prepareTagsWithTag(tagsTag);
|
||||
}
|
||||
}
|
||||
|
||||
scanMeta(tag: EbmlTagType) {
|
||||
if (
|
||||
tag.id === EbmlTagIdEnum.SeekHead &&
|
||||
tag.position === EbmlTagPosition.End
|
||||
) {
|
||||
this.seek.addSeekHeadTag(tag);
|
||||
}
|
||||
this.headTags.push(tag);
|
||||
this.seek.memoTag(tag);
|
||||
if (tag.id === EbmlTagIdEnum.Cluster && !this.firstCluster) {
|
||||
this.firstCluster = tag;
|
||||
this.seekLocal();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
async completeMeta() {
|
||||
this.seekLocal();
|
||||
|
||||
await this.parseCodes();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
async parseCodes() {
|
||||
const candidates = this.track.tracks.filter(
|
||||
(c) =>
|
||||
c.TrackType === TrackTypeRestrictionEnum.AUDIO ||
|
||||
c.TrackType === TrackTypeRestrictionEnum.VIDEO
|
||||
);
|
||||
const parseErrors = new ParseCodecErrors();
|
||||
|
||||
for (const t of candidates) {
|
||||
try {
|
||||
await this.track.initTrack(t, this.);
|
||||
} catch (e) {
|
||||
parseErrors.cause.push(e as Error);
|
||||
}
|
||||
}
|
||||
if (parseErrors.cause.length > 0) {
|
||||
console.error(parseErrors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type SegmentComponent<T> = T & {
|
||||
get segment(): SegmentSystem;
|
||||
};
|
||||
|
||||
export function withSegment<T extends object>(
|
||||
component: T,
|
||||
segment: SegmentSystem
|
||||
): SegmentComponent<T> {
|
||||
const component_ = component as T & { segment: SegmentSystem };
|
||||
component_.segment = segment;
|
||||
return component_;
|
||||
}
|
||||
|
||||
export class SegmentComponentSystemTrait<
|
||||
E extends EbmlMasterTagType,
|
||||
S extends Type<any>,
|
||||
> {
|
||||
segment: SegmentSystem;
|
||||
|
||||
get schema(): S {
|
||||
throw new Error('unimplemented!');
|
||||
}
|
||||
|
||||
constructor(segment: SegmentSystem) {
|
||||
this.segment = segment;
|
||||
}
|
||||
|
||||
componentFromTag(tag: E): SegmentComponent<InferType<S>> {
|
||||
const extracted = convertEbmlTagToComponent(tag);
|
||||
const result = this.schema(extracted) as
|
||||
| (InferType<S> & { segment: SegmentSystem })
|
||||
| ArkErrors;
|
||||
if (result instanceof ArkErrors) {
|
||||
const errors = result;
|
||||
console.error(
|
||||
'Parse component from tag error:',
|
||||
tag.toDebugRecord(),
|
||||
errors.flatProblemsByPath
|
||||
);
|
||||
throw errors;
|
||||
}
|
||||
result.segment = this.segment;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export class SeekSystem extends SegmentComponentSystemTrait<
|
||||
EbmlSeekHeadTagType,
|
||||
typeof SeekHeadSchema
|
||||
> {
|
||||
override get schema() {
|
||||
return SeekHeadSchema;
|
||||
}
|
||||
|
||||
seekHeads: SeekHeadType[] = [];
|
||||
private offsetToTagMemo: Map<number, EbmlTagType> = new Map();
|
||||
|
||||
memoTag(tag: EbmlTagType) {
|
||||
this.offsetToTagMemo.set(tag.startOffset, tag);
|
||||
}
|
||||
|
||||
addSeekHeadTag(tag: EbmlSeekHeadTagType) {
|
||||
const seekHead = this.componentFromTag(tag);
|
||||
this.seekHeads.push(seekHead);
|
||||
return seekHead;
|
||||
}
|
||||
|
||||
offsetFromSeekPosition(position: number): number {
|
||||
return position + this.segment.contentStartOffset;
|
||||
}
|
||||
|
||||
seekTagByStartOffset(
|
||||
startOffset: number | undefined
|
||||
): EbmlTagType | undefined {
|
||||
return startOffset! >= 0
|
||||
? this.offsetToTagMemo.get(startOffset!)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
seekOffsetBySeekId(seekId: Uint8Array): number | undefined {
|
||||
const seekPosition = this.seekHeads[0]?.Seek?.find((c) =>
|
||||
isEqual(c.SeekID, seekId)
|
||||
)?.SeekPosition;
|
||||
return seekPosition! >= 0
|
||||
? this.offsetFromSeekPosition(seekPosition! as number)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
seekTagBySeekId(seekId: Uint8Array): EbmlTagType | undefined {
|
||||
return this.seekTagByStartOffset(this.seekOffsetBySeekId(seekId));
|
||||
}
|
||||
|
||||
get firstClusterOffset() {
|
||||
if (!this.segment.firstCluster) {
|
||||
throw new UnreachableOrLogicError('first cluster not found');
|
||||
}
|
||||
return this.segment.firstCluster.startOffset;
|
||||
}
|
||||
}
|
||||
|
||||
export class InfoSystem extends SegmentComponentSystemTrait<
|
||||
EbmlInfoTagType,
|
||||
typeof InfoSchema
|
||||
> {
|
||||
override get schema() {
|
||||
return InfoSchema;
|
||||
}
|
||||
|
||||
info!: SegmentComponent<InfoType>;
|
||||
|
||||
prepareWithInfoTag(tag: EbmlInfoTagType) {
|
||||
this.info = this.componentFromTag(tag);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterSystem extends SegmentComponentSystemTrait<
|
||||
EbmlClusterTagType,
|
||||
typeof ClusterSchema
|
||||
> {
|
||||
override get schema() {
|
||||
return ClusterSchema;
|
||||
}
|
||||
|
||||
clustersBuffer: SegmentComponent<ClusterType>[] = [];
|
||||
|
||||
addClusterWithTag(tag: EbmlClusterTagType) {
|
||||
const cluster = this.componentFromTag(tag);
|
||||
this.clustersBuffer.push(cluster);
|
||||
return cluster;
|
||||
}
|
||||
}
|
||||
|
||||
export interface GetTrackEntryOptions {
|
||||
priority?: (v: SegmentComponent<TrackEntryType>) => number;
|
||||
predicate?: (v: SegmentComponent<TrackEntryType>) => boolean;
|
||||
}
|
||||
|
||||
export interface TrackState<Decoder, Config, Frame> {
|
||||
decoder: Decoder;
|
||||
configuration?: Config;
|
||||
frameBuffer$: BehaviorSubject<Queue<Frame>>;
|
||||
}
|
||||
|
||||
export class TrackSystem extends SegmentComponentSystemTrait<
|
||||
EbmlTrackEntryTagType,
|
||||
typeof TrackEntrySchema
|
||||
> {
|
||||
override get schema() {
|
||||
return TrackEntrySchema;
|
||||
}
|
||||
|
||||
tracks: SegmentComponent<TrackEntryType>[] = [];
|
||||
videoTrackState = new WeakMap<
|
||||
TrackEntryType,
|
||||
TrackState<VideoDecoder, VideoDecoderConfig, VideoFrame>
|
||||
>();
|
||||
audioTrackState = new WeakMap<
|
||||
TrackEntryType,
|
||||
TrackState<AudioDecoder, AudioDecoderConfig, AudioData>
|
||||
>();
|
||||
|
||||
getTrackEntry({
|
||||
priority = (track) =>
|
||||
(Number(!!track.FlagForced) << 4) + Number(!!track.FlagDefault),
|
||||
predicate = (track) => track.FlagEnabled !== 0,
|
||||
}: GetTrackEntryOptions) {
|
||||
return this.tracks
|
||||
.filter(predicate)
|
||||
.toSorted((a, b) => priority(b) - priority(a))
|
||||
.at(0);
|
||||
}
|
||||
|
||||
prepareTracksWithTag(tag: EbmlTracksTagType) {
|
||||
this.tracks = tag.children
|
||||
.filter((c) => c.id === EbmlTagIdEnum.TrackEntry)
|
||||
.map((c) => this.componentFromTag(c));
|
||||
return this;
|
||||
}
|
||||
|
||||
async initTrack(track: TrackEntryType) {
|
||||
if (track.TrackType === TrackTypeRestrictionEnum.AUDIO) {
|
||||
const configuration = audioCodecIdToWebCodecs(track);
|
||||
if (await AudioDecoder.isConfigSupported(configuration)) {
|
||||
throw new UnsupportedCodecError(configuration.codec, 'audio decoder');
|
||||
}
|
||||
|
||||
const queue$ = new BehaviorSubject(new Queue<AudioData>());
|
||||
this.audioTrackState.set(track, {
|
||||
configuration,
|
||||
decoder: new AudioDecoder({
|
||||
output: (audioData) => {
|
||||
const queue = queue$.getValue();
|
||||
queue.enqueue(audioData);
|
||||
queue$.next(queue);
|
||||
},
|
||||
error: (e) => {
|
||||
queue$.error(e);
|
||||
},
|
||||
}),
|
||||
frameBuffer$: queue$,
|
||||
});
|
||||
} else if (track.TrackType === TrackTypeRestrictionEnum.VIDEO) {
|
||||
const configuration = videoCodecIdToWebCodecs(track, this.keyframe);
|
||||
if (await VideoDecoder.isConfigSupported(configuration)) {
|
||||
throw new UnsupportedCodecError(configuration.codec, 'audio decoder');
|
||||
}
|
||||
|
||||
const queue$ = new BehaviorSubject(new Queue<VideoFrame>());
|
||||
this.videoTrackState.set(track, {
|
||||
configuration,
|
||||
decoder: new VideoDecoder({
|
||||
output: (audioData) => {
|
||||
const queue = queue$.getValue();
|
||||
queue.enqueue(audioData);
|
||||
queue$.next(queue);
|
||||
},
|
||||
error: (e) => {
|
||||
queue$.error(e);
|
||||
},
|
||||
}),
|
||||
frameBuffer$: queue$,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class CueSystem extends SegmentComponentSystemTrait<
|
||||
EbmlCuePointTagType,
|
||||
typeof CuePointSchema
|
||||
> {
|
||||
override get schema() {
|
||||
return CuePointSchema;
|
||||
}
|
||||
|
||||
cues: SegmentComponent<CuePointType>[] = [];
|
||||
|
||||
prepareCuesWithTag(tag: EbmlCuesTagType) {
|
||||
this.cues = tag.children
|
||||
.filter((c) => c.id === EbmlTagIdEnum.CuePoint)
|
||||
.map(this.componentFromTag.bind(this));
|
||||
return this;
|
||||
}
|
||||
|
||||
findClosestCue(seekTime: number): CuePointType | undefined {
|
||||
const cues = this.cues;
|
||||
if (!cues || cues.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let left = 0;
|
||||
let right = cues.length - 1;
|
||||
|
||||
if (seekTime <= cues[0].CueTime) {
|
||||
return cues[0];
|
||||
}
|
||||
|
||||
if (seekTime >= cues[right].CueTime) {
|
||||
return cues[right];
|
||||
}
|
||||
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
|
||||
if (cues[mid].CueTime === seekTime) {
|
||||
return cues[mid];
|
||||
}
|
||||
|
||||
if (cues[mid].CueTime < seekTime) {
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
const before = cues[right];
|
||||
const after = cues[left];
|
||||
return Math.abs((before.CueTime as number) - seekTime) <
|
||||
Math.abs((after.CueTime as number) - seekTime)
|
||||
? before
|
||||
: after;
|
||||
}
|
||||
|
||||
getCueTrackPositions(
|
||||
cuePoint: CuePointType,
|
||||
track?: number
|
||||
): CueTrackPositionsType {
|
||||
let cueTrackPositions: CueTrackPositionsType | undefined;
|
||||
if (track! >= 0) {
|
||||
cueTrackPositions = cuePoint.CueTrackPositions.find(
|
||||
(c) => c.CueTrack === track
|
||||
);
|
||||
}
|
||||
if (!cueTrackPositions) {
|
||||
cueTrackPositions = maxBy(
|
||||
cuePoint.CueTrackPositions,
|
||||
(c) => c.CueClusterPosition
|
||||
)!;
|
||||
}
|
||||
return cueTrackPositions;
|
||||
}
|
||||
|
||||
get prepared(): boolean {
|
||||
return this.cues.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
export class TagSystem extends SegmentComponentSystemTrait<
|
||||
EbmlTagTagType,
|
||||
typeof TagSchema
|
||||
> {
|
||||
override get schema() {
|
||||
return TagSchema;
|
||||
}
|
||||
|
||||
tags: SegmentComponent<TagType>[] = [];
|
||||
|
||||
prepareTagsWithTag(tag: EbmlTagsTagType) {
|
||||
this.tags = tag.children
|
||||
.filter((c) => c.id === EbmlTagIdEnum.Tag)
|
||||
.map((c) => this.componentFromTag(c));
|
||||
return this;
|
||||
}
|
||||
|
||||
get prepared(): boolean {
|
||||
return this.tags.length > 0;
|
||||
}
|
||||
}
|
||||
430
packages/matroska/src/reactive.ts
Normal file
430
packages/matroska/src/reactive.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import {
|
||||
EbmlStreamDecoder,
|
||||
EbmlTagIdEnum,
|
||||
EbmlTagPosition,
|
||||
type EbmlTagType,
|
||||
} from 'konoebml';
|
||||
import {
|
||||
defer,
|
||||
EMPTY,
|
||||
filter,
|
||||
finalize,
|
||||
from,
|
||||
isEmpty,
|
||||
map,
|
||||
merge,
|
||||
Observable,
|
||||
of,
|
||||
reduce,
|
||||
scan,
|
||||
share,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
take,
|
||||
takeWhile,
|
||||
withLatestFrom,
|
||||
} from 'rxjs';
|
||||
import {
|
||||
createRangedStream,
|
||||
type CreateRangedStreamOptions,
|
||||
} from '@konoplayer/core/data';
|
||||
import {
|
||||
type CueSystem,
|
||||
SEEK_ID_KAX_CUES,
|
||||
SEEK_ID_KAX_TAGS,
|
||||
type SegmentComponent,
|
||||
SegmentSystem,
|
||||
} from './model';
|
||||
import { isTagIdPos, waitTick } from './util';
|
||||
import type { ClusterType } from './schema';
|
||||
|
||||
export interface CreateRangedEbmlStreamOptions
|
||||
extends CreateRangedStreamOptions {
|
||||
tee?: boolean;
|
||||
}
|
||||
|
||||
export function createRangedEbmlStream({
|
||||
url,
|
||||
byteStart = 0,
|
||||
byteEnd,
|
||||
tee = false,
|
||||
}: CreateRangedEbmlStreamOptions): Observable<{
|
||||
ebml$: Observable<EbmlTagType>;
|
||||
totalSize?: number;
|
||||
response: Response;
|
||||
body: ReadableStream<Uint8Array>;
|
||||
controller: AbortController;
|
||||
teeBody: ReadableStream<Uint8Array> | undefined;
|
||||
}> {
|
||||
const stream$ = from(createRangedStream({ url, byteStart, byteEnd }));
|
||||
|
||||
return stream$.pipe(
|
||||
switchMap(({ controller, body, totalSize, response }) => {
|
||||
let requestCompleted = false;
|
||||
let teeStream: ReadableStream<Uint8Array> | undefined;
|
||||
|
||||
let stream: ReadableStream<Uint8Array>;
|
||||
|
||||
if (tee) {
|
||||
[stream, teeStream] = body.tee();
|
||||
} else {
|
||||
stream = body;
|
||||
}
|
||||
|
||||
const originRequest$ = new Observable<EbmlTagType>((subscriber) => {
|
||||
stream
|
||||
.pipeThrough(
|
||||
new EbmlStreamDecoder({
|
||||
streamStartOffset: byteStart,
|
||||
collectChild: (child) => child.id !== EbmlTagIdEnum.Cluster,
|
||||
backpressure: {
|
||||
eventLoop: waitTick,
|
||||
},
|
||||
})
|
||||
)
|
||||
.pipeTo(
|
||||
new WritableStream({
|
||||
write: async (tag) => {
|
||||
await waitTick();
|
||||
subscriber.next(tag);
|
||||
},
|
||||
close: () => {
|
||||
if (!requestCompleted) {
|
||||
requestCompleted = true;
|
||||
subscriber.complete();
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
.catch((error) => {
|
||||
if (requestCompleted && error?.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
requestCompleted = true;
|
||||
subscriber.error(error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
requestCompleted = true;
|
||||
controller.abort();
|
||||
};
|
||||
}).pipe(
|
||||
share({
|
||||
resetOnComplete: false,
|
||||
resetOnError: false,
|
||||
resetOnRefCountZero: true,
|
||||
})
|
||||
);
|
||||
|
||||
const ebml$ = defer(() =>
|
||||
requestCompleted ? EMPTY : originRequest$
|
||||
).pipe(
|
||||
share({
|
||||
resetOnError: false,
|
||||
resetOnComplete: true,
|
||||
resetOnRefCountZero: true,
|
||||
})
|
||||
);
|
||||
|
||||
return of({
|
||||
ebml$,
|
||||
totalSize,
|
||||
response,
|
||||
body: stream,
|
||||
teeBody: teeStream,
|
||||
controller,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export interface CreateEbmlControllerOptions
|
||||
extends Omit<CreateRangedEbmlStreamOptions, 'byteStart' | 'byteEnd'> {}
|
||||
|
||||
export function createEbmlController({
|
||||
url,
|
||||
...options
|
||||
}: CreateEbmlControllerOptions) {
|
||||
const metaRequest$ = createRangedEbmlStream({
|
||||
...options,
|
||||
url,
|
||||
byteStart: 0,
|
||||
tee: true,
|
||||
});
|
||||
|
||||
const controller$ = metaRequest$.pipe(
|
||||
map(({ totalSize, ebml$, response, controller, teeBody }) => {
|
||||
const head$ = ebml$.pipe(
|
||||
filter(isTagIdPos(EbmlTagIdEnum.EBML, EbmlTagPosition.End)),
|
||||
take(1),
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
console.debug(
|
||||
`stream of video "${url}" created, total size is ${totalSize ?? 'unknown'}`
|
||||
);
|
||||
|
||||
const segmentStart$ = ebml$.pipe(
|
||||
filter(isTagIdPos(EbmlTagIdEnum.Segment, EbmlTagPosition.Start))
|
||||
);
|
||||
|
||||
/**
|
||||
* while [matroska v4](https://www.matroska.org/technical/elements.html) doc tell that there is only one segment in a file
|
||||
* some mkv generated by strange tools will emit several
|
||||
*/
|
||||
const segments$ = segmentStart$.pipe(
|
||||
map((startTag) => {
|
||||
const segment = new SegmentSystem(startTag, teeBody!);
|
||||
const clusterSystem = segment.cluster;
|
||||
const seekSystem = segment.seek;
|
||||
|
||||
const meta$ = ebml$.pipe(
|
||||
scan(
|
||||
(acc, tag) => {
|
||||
// avoid object recreation
|
||||
acc.hasKeyframe =
|
||||
acc.hasKeyframe ||
|
||||
(tag.id === EbmlTagIdEnum.SimpleBlock && tag.keyframe) ||
|
||||
(tag.id === EbmlTagIdEnum.BlockGroup &&
|
||||
tag.children.every(
|
||||
(c) => c.id !== EbmlTagIdEnum.ReferenceBlock
|
||||
));
|
||||
acc.tag = tag;
|
||||
return acc;
|
||||
},
|
||||
{ hasKeyframe: false, tag: undefined as unknown as EbmlTagType }
|
||||
),
|
||||
takeWhile(({ tag, hasKeyframe }) => {
|
||||
return (
|
||||
!isTagIdPos(EbmlTagIdEnum.Segment, EbmlTagPosition.End)(tag) &&
|
||||
!(
|
||||
isTagIdPos(EbmlTagIdEnum.Cluster, EbmlTagPosition.End)(tag) &&
|
||||
hasKeyframe
|
||||
)
|
||||
);
|
||||
}, true),
|
||||
map(({ tag }) => tag),
|
||||
share({
|
||||
resetOnComplete: false,
|
||||
resetOnError: false,
|
||||
resetOnRefCountZero: true,
|
||||
})
|
||||
);
|
||||
|
||||
const withMeta$ = meta$.pipe(
|
||||
reduce((segment, meta) => segment.scanMeta(meta), segment),
|
||||
switchMap(() => segment.completeMeta()),
|
||||
take(1),
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
const withRemoteCues$ = withMeta$.pipe(
|
||||
switchMap((s) => {
|
||||
const cueSystem = s.cue;
|
||||
const seekSystem = s.seek;
|
||||
if (cueSystem.prepared) {
|
||||
return EMPTY;
|
||||
}
|
||||
const remoteCuesTagStartOffset =
|
||||
seekSystem.seekOffsetBySeekId(SEEK_ID_KAX_CUES);
|
||||
if (remoteCuesTagStartOffset! >= 0) {
|
||||
return createRangedEbmlStream({
|
||||
...options,
|
||||
url,
|
||||
byteStart: remoteCuesTagStartOffset,
|
||||
}).pipe(
|
||||
switchMap((req) => req.ebml$),
|
||||
filter(isTagIdPos(EbmlTagIdEnum.Cues, EbmlTagPosition.End)),
|
||||
withLatestFrom(withMeta$),
|
||||
map(([cues, withMeta]) => {
|
||||
withMeta.cue.prepareCuesWithTag(cues);
|
||||
return withMeta;
|
||||
})
|
||||
);
|
||||
}
|
||||
return EMPTY;
|
||||
}),
|
||||
take(1),
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
const withLocalCues$ = withMeta$.pipe(
|
||||
switchMap((s) => (s.cue.prepared ? of(s) : EMPTY)),
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
const withRemoteTags$ = withMeta$.pipe(
|
||||
switchMap((s) => {
|
||||
const tagSystem = s.tag;
|
||||
const seekSystem = s.seek;
|
||||
if (tagSystem.prepared) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
const remoteTagsTagStartOffset =
|
||||
seekSystem.seekOffsetBySeekId(SEEK_ID_KAX_TAGS);
|
||||
if (remoteTagsTagStartOffset! >= 0) {
|
||||
return createRangedEbmlStream({
|
||||
...options,
|
||||
url,
|
||||
byteStart: remoteTagsTagStartOffset,
|
||||
}).pipe(
|
||||
switchMap((req) => req.ebml$),
|
||||
filter(isTagIdPos(EbmlTagIdEnum.Tags, EbmlTagPosition.End)),
|
||||
withLatestFrom(withMeta$),
|
||||
map(([tags, withMeta]) => {
|
||||
withMeta.tag.prepareTagsWithTag(tags);
|
||||
return withMeta;
|
||||
})
|
||||
);
|
||||
}
|
||||
return EMPTY;
|
||||
}),
|
||||
take(1),
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
const withLocalTags$ = withMeta$.pipe(
|
||||
switchMap((s) => (s.tag.prepared ? of(s) : EMPTY)),
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
const withCues$ = merge(withLocalCues$, withRemoteCues$).pipe(
|
||||
take(1)
|
||||
);
|
||||
|
||||
const withoutCues$ = withCues$.pipe(
|
||||
isEmpty(),
|
||||
switchMap((empty) => (empty ? withMeta$ : EMPTY))
|
||||
);
|
||||
|
||||
const withTags$ = merge(withLocalTags$, withRemoteTags$).pipe(
|
||||
take(1)
|
||||
);
|
||||
|
||||
const withoutTags$ = withTags$.pipe(
|
||||
isEmpty(),
|
||||
switchMap((empty) => (empty ? withMeta$ : EMPTY))
|
||||
);
|
||||
|
||||
const seekWithoutCues = (
|
||||
seekTime: number
|
||||
): Observable<SegmentComponent<ClusterType>> => {
|
||||
const request$ = withMeta$.pipe(
|
||||
switchMap(() =>
|
||||
createRangedEbmlStream({
|
||||
...options,
|
||||
url,
|
||||
byteStart: seekSystem.firstClusterOffset,
|
||||
})
|
||||
)
|
||||
);
|
||||
const cluster$ = request$.pipe(
|
||||
switchMap((req) => req.ebml$),
|
||||
filter(isTagIdPos(EbmlTagIdEnum.Cluster, EbmlTagPosition.End)),
|
||||
map((tag) => clusterSystem.addClusterWithTag(tag))
|
||||
);
|
||||
|
||||
if (seekTime === 0) {
|
||||
return cluster$;
|
||||
}
|
||||
|
||||
return cluster$.pipe(
|
||||
scan(
|
||||
(acc, curr) => {
|
||||
// avoid object recreation
|
||||
acc.prev = acc.next;
|
||||
acc.next = curr;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
prev: undefined as SegmentComponent<ClusterType> | undefined,
|
||||
next: undefined as SegmentComponent<ClusterType> | undefined,
|
||||
}
|
||||
),
|
||||
filter((c) => c.next?.Timestamp! > seekTime),
|
||||
map((c) => c.prev ?? c.next!)
|
||||
);
|
||||
};
|
||||
|
||||
const seekWithCues = (
|
||||
cueSystem: CueSystem,
|
||||
seekTime: number
|
||||
): Observable<SegmentComponent<ClusterType>> => {
|
||||
if (seekTime === 0) {
|
||||
return seekWithoutCues(seekTime);
|
||||
}
|
||||
|
||||
const cuePoint = cueSystem.findClosestCue(seekTime);
|
||||
|
||||
if (!cuePoint) {
|
||||
return seekWithoutCues(seekTime);
|
||||
}
|
||||
|
||||
return createRangedEbmlStream({
|
||||
...options,
|
||||
url,
|
||||
byteStart: seekSystem.offsetFromSeekPosition(
|
||||
cueSystem.getCueTrackPositions(cuePoint)
|
||||
.CueClusterPosition as number
|
||||
),
|
||||
}).pipe(
|
||||
switchMap((req) => req.ebml$),
|
||||
filter(isTagIdPos(EbmlTagIdEnum.Cluster, EbmlTagPosition.End)),
|
||||
map(clusterSystem.addClusterWithTag.bind(clusterSystem))
|
||||
);
|
||||
};
|
||||
|
||||
const seek = (
|
||||
seekTime: number
|
||||
): Observable<SegmentComponent<ClusterType>> => {
|
||||
if (seekTime === 0) {
|
||||
const subscription = merge(withCues$, withoutCues$).subscribe();
|
||||
|
||||
// if seekTime equals to 0 at start, reuse the initialize stream
|
||||
return seekWithoutCues(seekTime).pipe(
|
||||
finalize(() => {
|
||||
subscription.unsubscribe();
|
||||
})
|
||||
);
|
||||
}
|
||||
return merge(
|
||||
withCues$.pipe(switchMap((s) => seekWithCues(s.cue, seekTime))),
|
||||
withoutCues$.pipe(switchMap((_) => seekWithoutCues(seekTime)))
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
startTag,
|
||||
head$,
|
||||
segment,
|
||||
meta$,
|
||||
withMeta$,
|
||||
withCues$,
|
||||
withoutCues$,
|
||||
withTags$,
|
||||
withoutTags$,
|
||||
seekWithCues,
|
||||
seekWithoutCues,
|
||||
seek,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
segments$,
|
||||
head$,
|
||||
totalSize,
|
||||
ebml$,
|
||||
controller,
|
||||
response,
|
||||
};
|
||||
}),
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
return {
|
||||
controller$,
|
||||
request$: metaRequest$,
|
||||
};
|
||||
}
|
||||
1157
packages/matroska/src/schema.ts
Normal file
1157
packages/matroska/src/schema.ts
Normal file
File diff suppressed because it is too large
Load Diff
62
packages/matroska/src/util.ts
Normal file
62
packages/matroska/src/util.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Type } from 'arktype';
|
||||
import { EbmlElementType, EbmlTagIdEnum, type EbmlTagType } from 'konoebml';
|
||||
import { IdMultiSet } from './schema';
|
||||
|
||||
export type InferType<T extends Type<any>> = T['infer'];
|
||||
|
||||
export type PredicateIdExtract<T, K> = Extract<T, { id: K }>;
|
||||
|
||||
export type PredicatePositionExtract<
|
||||
T extends { position: string },
|
||||
P,
|
||||
> = P extends T['position'] ? T : never;
|
||||
|
||||
export function isTagIdPos<
|
||||
I extends EbmlTagIdEnum,
|
||||
P extends PredicateIdExtract<EbmlTagType, I>['position'] | '*' = '*',
|
||||
>(id: I, pos?: P) {
|
||||
return (tag: EbmlTagType): tag is PredicateIdExtract<EbmlTagType, I> =>
|
||||
tag.id === id && (pos === '*' || pos === tag.position);
|
||||
}
|
||||
|
||||
export function isTagPos<
|
||||
T extends { position: string },
|
||||
P extends T['position'],
|
||||
>(pos: P | '*' = '*') {
|
||||
return (tag: T): tag is PredicatePositionExtract<T, P> =>
|
||||
pos === '*' || pos === tag.position;
|
||||
}
|
||||
|
||||
export function convertEbmlTagToComponent(tag: EbmlTagType) {
|
||||
if (tag.type === EbmlElementType.Master) {
|
||||
const obj: Record<string, any> = {};
|
||||
const children = tag.children;
|
||||
for (const c of children) {
|
||||
const name = EbmlTagIdEnum[c.id];
|
||||
const converted = convertEbmlTagToComponent(c);
|
||||
if (IdMultiSet.has(c.id)) {
|
||||
if (obj[name]) {
|
||||
obj[name].push(converted);
|
||||
} else {
|
||||
obj[name] = [converted];
|
||||
}
|
||||
} else {
|
||||
obj[name] = converted;
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
if (tag.id === EbmlTagIdEnum.SimpleBlock || tag.id === EbmlTagIdEnum.Block) {
|
||||
return tag;
|
||||
}
|
||||
return tag.data;
|
||||
}
|
||||
|
||||
export function waitTick() {
|
||||
return new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
resolve();
|
||||
timeout && clearTimeout(timeout);
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
20
packages/matroska/tsconfig.json
Normal file
20
packages/matroska/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "./dist",
|
||||
"paths": {
|
||||
"@konoplayer/core/*": [
|
||||
"../core/src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../core"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user