feat: refactor folder structure & add new codec parser and gen & add unit tests

This commit is contained in:
master 2025-03-25 02:38:00 +08:00
parent 42e36e3c68
commit 39a4cf2773
67 changed files with 2211 additions and 514 deletions

View File

@ -1,5 +1,9 @@
{
"recommendations": [
"runem.lit-plugin"
"runem.lit-plugin",
"vitest.explorer",
"biomejs.biome",
"hbenl.vscode-test-explorer",
"zerotaskx.rust-extension-pack"
]
}

97
Cargo.lock generated
View File

@ -103,6 +103,12 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "extended"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
[[package]]
name = "ffmpeg-sys-next"
version = "7.1.0"
@ -139,17 +145,17 @@ dependencies = [
]
[[package]]
name = "konoplayer-codecs"
name = "konoplayer-ffmpeg"
version = "0.1.0"
dependencies = [
"ffmpeg-sys-next",
]
[[package]]
name = "konoplayer-demuxing"
name = "konoplayer-symphonia"
version = "0.1.0"
dependencies = [
"symphonia-format-mkv",
"symphonia",
]
[[package]]
@ -277,6 +283,67 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "symphonia"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "815c942ae7ee74737bb00f965fa5b5a2ac2ce7b6c01c0cc169bbeaf7abd5f5a9"
dependencies = [
"lazy_static",
"symphonia-bundle-flac",
"symphonia-codec-adpcm",
"symphonia-codec-pcm",
"symphonia-codec-vorbis",
"symphonia-core",
"symphonia-format-mkv",
"symphonia-format-ogg",
"symphonia-format-riff",
"symphonia-metadata",
]
[[package]]
name = "symphonia-bundle-flac"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72e34f34298a7308d4397a6c7fbf5b84c5d491231ce3dd379707ba673ab3bd97"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-codec-adpcm"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c94e1feac3327cd616e973d5be69ad36b3945f16b06f19c6773fc3ac0b426a0f"
dependencies = [
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-pcm"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f395a67057c2ebc5e84d7bb1be71cce1a7ba99f64e0f0f0e303a03f79116f89b"
dependencies = [
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-vorbis"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a98765fb46a0a6732b007f7e2870c2129b6f78d87db7987e6533c8f164a9f30"
dependencies = [
"log",
"symphonia-core",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-core"
version = "0.5.4"
@ -303,6 +370,30 @@ dependencies = [
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-ogg"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ada3505789516bcf00fc1157c67729eded428b455c27ca370e41f4d785bfa931"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-riff"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f7be232f962f937f4b7115cbe62c330929345434c834359425e043bfd15f50"
dependencies = [
"extended",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-metadata"
version = "0.5.4"

View File

@ -1,3 +1,3 @@
[workspace]
members = ["packages/demuxing", "packages/codecs"]
members = ["packages/symphonia", "packages/ffmpeg"]
resolver = "3"

View File

@ -1,2 +0,0 @@
public/video/huge/*
!public/video/huge/.gitkeep

View File

@ -1,5 +1,5 @@
{
"name": "mock",
"name": "@konoplayer/mock",
"version": "0.1.0",
"private": true,
"scripts": {

2
apps/mock/public/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
video/huge/*
!video/huge/.gitkeep

View File

@ -4,10 +4,8 @@
"composite": true,
"module": "CommonJS",
"moduleResolution": "node",
"declaration": true,
"emitDeclarationOnly": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowImportingTsExtensions": false,
"outDir": "./dist",
"rootDir": ".",

View File

@ -1,6 +1,6 @@
{
"name": "playground",
"version": "1.0.0",
"name": "@konoplayer/playground",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
@ -9,11 +9,11 @@
"preview": "rsbuild preview"
},
"dependencies": {
"konoebml": "0.1.2",
"lit": "^3.2.1"
"lit": "^3.2.1",
"@konoplayer/core": "workspace:*",
"@konoplayer/matroska": "workspace:*"
},
"devDependencies": {
"@rsbuild/core": "^1.2.14",
"typescript": "^5.8.2"
"@rsbuild/core": "^1.2.14"
}
}

View File

@ -1,60 +0,0 @@
export interface RangedStream {
controller: AbortController;
response: Response;
body: ReadableStream<Uint8Array>;
totalSize?: number;
}
export async function createRangedStream(
url: string,
byteStart = 0,
byteEnd?: number
) {
const controller = new AbortController();
const signal = controller.signal;
const headers = new Headers();
headers.append(
'Range',
typeof byteEnd === 'number'
? `bytes=${byteStart}-${byteEnd}`
: `bytes=${byteStart}-`
);
const response = await fetch(url, { signal, headers });
if (!response.ok) {
throw new Error('fetch video stream failed');
}
const acceptRanges = response.headers.get('Accept-Ranges');
if (acceptRanges !== 'bytes') {
throw new Error('video server does not support byte ranges');
}
const body = response.body;
if (!(body instanceof ReadableStream)) {
throw new Error('can not get readable stream from response.body');
}
const contentRange = response.headers.get('Content-Range');
//
// Content-Range Header Syntax:
// Content-Range: <unit> <range-start>-<range-end>/<size>
// Content-Range: <unit> <range-start>-<range-end>/*
// Content-Range: <unit> */<size>
//
const totalSize = contentRange
? Number.parseInt(contentRange.split('/')[1], 10)
: undefined;
return {
controller,
response,
body,
totalSize,
};
}

View File

@ -1,144 +0,0 @@
import { ParseCodecPrivateError } from '@/media/base/errors';
import { ArkErrors, type } from 'arktype';
export const HEVC_CODEC_TYPE = 'h265(HEVC)';
export const HEVCDecoderConfigurationRecordArraySchema = type({
arrayCompleteness: type.boolean,
reserved: type.number,
NALUnitType: type.number,
numNalus: type.number,
nalUnits: 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.boolean,
generalProfileIdc: type.number,
generalProfileCompatibilityFlags: type.number,
generalConstraintIndicatorFlags: type.number.array().exactlyLength(6), // Fixed 6-byte array
generalLevelIdc: type.number,
reserved1: type.number, // 4 bits reserved, must be 1111
minSpatialSegmentationIdc: type.number,
reserved2: type.number, // 6 bits reserved, must be 111111
parallelismType: type.number,
chromaFormat: type.number,
bitDepthLumaMinus8: type.number,
bitDepthChromaMinus8: type.number,
avgFrameRate: type.number,
constantFrameRate: type.number,
numTemporalLayers: type.number,
temporalIdNested: type.boolean,
lengthSizeMinusOne: type.number,
numOfArrays: type.number,
arrays: HEVCDecoderConfigurationRecordArraySchema.array(),
});
export type HEVCDecoderConfigurationRecordType =
typeof HEVCDecoderConfigurationRecordSchema.infer;
/**
* Parse HEVCDecoderConfigurationRecord from codec_private Uint8Array
* @param codecPrivate - Uint8Array containing codec_private data
* @returns Parsed HEVCDecoderConfigurationRecord or throws an error if invalid
*/
export function parseHEVCDecoderConfigurationRecord(
codecPrivate: Uint8Array
): HEVCDecoderConfigurationRecordType {
let offset = 0;
// Read and validate basic fields
const config: HEVCDecoderConfigurationRecordType = {
configurationVersion: codecPrivate[offset++],
generalProfileSpace: codecPrivate[offset] >> 6,
generalTierFlag: Boolean(codecPrivate[offset] & 0x20),
generalProfileIdc: codecPrivate[offset++] & 0x1f,
generalProfileCompatibilityFlags:
(codecPrivate[offset] << 24) |
(codecPrivate[offset + 1] << 16) |
(codecPrivate[offset + 2] << 8) |
codecPrivate[offset + 3],
generalConstraintIndicatorFlags: Array.from(
codecPrivate.subarray(offset + 4, offset + 10)
),
generalLevelIdc: codecPrivate[offset + 10],
reserved1: (codecPrivate[offset + 11] & 0xf0) >> 4, // 4 bits
minSpatialSegmentationIdc:
((codecPrivate[offset + 11] & 0x0f) << 8) | codecPrivate[offset + 12],
reserved2: (codecPrivate[offset + 13] & 0xfc) >> 2, // 6 bits
parallelismType: codecPrivate[offset + 13] & 0x03,
chromaFormat: (codecPrivate[offset + 14] & 0xe0) >> 5,
bitDepthLumaMinus8: (codecPrivate[offset + 14] & 0x1c) >> 2,
bitDepthChromaMinus8: codecPrivate[offset + 14] & 0x03,
avgFrameRate: (codecPrivate[offset + 15] << 8) | codecPrivate[offset + 16],
constantFrameRate: (codecPrivate[offset + 17] & 0xc0) >> 6,
numTemporalLayers: (codecPrivate[offset + 17] & 0x38) >> 3,
temporalIdNested: Boolean(codecPrivate[offset + 17] & 0x04),
lengthSizeMinusOne: codecPrivate[offset + 17] & 0x03,
numOfArrays: codecPrivate[offset + 18],
arrays: [],
};
offset += 19;
// Parse NAL unit arrays
const arrays = config.arrays;
for (let i = 0; i < config.numOfArrays; i++) {
const array: HEVCDecoderConfigurationRecordArrayType = {
arrayCompleteness: Boolean(codecPrivate[offset] & 0x80),
reserved: (codecPrivate[offset] & 0x40) >> 6,
NALUnitType: codecPrivate[offset] & 0x3f,
numNalus: (codecPrivate[offset + 1] << 8) | codecPrivate[offset + 2],
nalUnits: [] as Uint8Array<ArrayBufferLike>[],
};
offset += 3;
for (let j = 0; j < array.numNalus; j++) {
const nalUnitLength =
(codecPrivate[offset] << 8) | codecPrivate[offset + 1];
offset += 2;
array.nalUnits.push(
codecPrivate.subarray(offset, offset + nalUnitLength)
);
offset += nalUnitLength;
}
arrays.push(array);
}
const result = { ...config, arrays };
// Validate using arktype
const validation = HEVCDecoderConfigurationRecordSchema(result);
if (validation instanceof ArkErrors) {
const error = new ParseCodecPrivateError(
HEVC_CODEC_TYPE,
'Invalid HEVC configuration record'
);
error.cause = validation;
throw error;
}
return result;
}
export function genCodecStringByHEVCDecoderConfigurationRecord(
config: HEVCDecoderConfigurationRecordType
) {
const profileSpace =
config.generalProfileSpace === 0
? ''
: String.fromCharCode(65 + config.generalProfileSpace - 1);
const profileIdcHex = config.generalProfileIdc.toString(16);
const tier = config.generalTierFlag ? '7' : '6';
const levelMajor = Math.floor(config.generalLevelIdc / 30);
const levelMinor =
config.generalLevelIdc % 30 === 0 ? '0' : (config.generalLevelIdc % 30) / 3;
const levelStr = `L${config.generalLevelIdc.toString().padStart(3, '0')}`;
const constraint = '00';
return `hev1.${profileSpace}${profileIdcHex}.${tier}.${levelStr}.${constraint}`;
}

View File

@ -1,21 +0,0 @@
import { type } from 'arktype';
import type {TrackEntryType} from "@/media/mkv/schema.ts";
export const VP9DecoderProfileSchema = type('0 | 1 | 2 | 3');
export const VP9DecoderConfigurationRecordSchema = type({
profile: VP9DecoderProfileSchema,
level: type.number,
bitDepth: type.number,
});
export type VP9DecoderConfigurationRecordType =
typeof VP9DecoderConfigurationRecordSchema.infer;
export function parseVP9DecoderConfigurationRecord(track: TrackEntryType) {
const pixelWidth = Number(track.Video?.PixelWidth);
const pixelHeight = Number(track.Video?.PixelHeight);
const pixels = pixelWidth * pixelHeight;
const bitDepth = Number(track.Video?.Colour?.BitsPerChannel) || 10;
}

View File

@ -1,3 +0,0 @@
export interface ProbeInfo {
}

View File

@ -18,9 +18,12 @@ import {
fromEvent,
filter,
} from 'rxjs';
import { createEbmlController } from './media/mkv/reactive';
import { TrackTypeRestrictionEnum, type ClusterType } from './media/mkv/schema';
import type { SegmentComponent } from './media/mkv/model';
import { createEbmlController } from '@konoplayer/matroska/reactive';
import {
TrackTypeRestrictionEnum,
type ClusterType,
} from '@konoplayer/matroska/schema';
import type { SegmentComponent } from '@konoplayer/matroska/model';
import { createRef, ref, type Ref } from 'lit/directives/ref.js';
import { Queue } from 'mnemonist';
@ -113,7 +116,6 @@ export class VideoPipelineDemo extends LitElement {
description: videoTrack.CodecPrivate, // Uint8Array包含 VPS/SPS/PPS
});
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: <explanation>
const sub = this.cluster$.subscribe((c) => {
if (!isFinalized) {
for (const b of (c.SimpleBlock || []).filter(
@ -163,7 +165,7 @@ export class VideoPipelineDemo extends LitElement {
const numberOfChannels =
(audioTrack.Audio?.Channels as number) || 2;
const duration =
Math.round(Number(audioTrack.DefaultDuration / 1000)) ||
Math.round(Number(audioTrack.DefaultDuration) / 1000) ||
Math.round((1024 / sampleRate) * 1000000);
decoder.configure({

View File

@ -2,19 +2,25 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"target": "ES2020",
"outDir": "./dist",
"experimentalDecorators": true,
"module": "ESNext",
"moduleResolution": "bundler",
"useDefineForClassFields": false,
"paths": {
"@/*": [
"./src/*"
"@konoplayer/core/*": [
"../../packages/core/src/*"
],
"@konoplayer/matroska/*": [
"../../packages/matroska/src/*"
]
}
},
"include": [
"src"
],
"references": [
{
"path": "../../packages/core"
},
{
"path": "../../packages/matroska"
}
]
}

View File

@ -1,5 +1,5 @@
{
"name": "proxy",
"name": "@konoplayer/proxy",
"version": "0.1.0",
"private": true,
"scripts": {

View File

@ -0,0 +1 @@
{"version":"3.0.9","results":[[":src/matroska/codecs/av1.spec.ts",{"duration":52.71331099999952,"failed":false}]]}

17
apps/test/package.json Normal file
View File

@ -0,0 +1,17 @@
{
"name": "@konoplayer/test",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {},
"dependencies": {
"@konoplayer/core": "workspace:*",
"@konoplayer/matroska": "workspace:*",
"konoebml": "^0.1.2"
},
"devDependencies": {
"unplugin-swc": "^1.5.1",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.9"
}
}

2
apps/test/resources/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
video/huge/*
!video/huge/.gitkeep

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,47 @@
import { SegmentSchema, SegmentType } from '@konoplayer/matroska/schema';
import { VideoCodecId } from '@konoplayer/matroska/codecs';
import {
parseAV1DecoderConfigurationRecord,
genCodecStringByAV1DecoderConfigurationRecord,
} from '@konoplayer/matroska/codecs/av1';
import { loadComponentFromRangedResource } from '../utils/data';
import { EbmlTagIdEnum, EbmlTagPosition } from 'konoebml';
import { isTagIdPos } from '@konoplayer/matroska/util';
describe('AV1 code test', () => {
it('should parse av1 meta from track entry', async () => {
const [segment] = await loadComponentFromRangedResource<SegmentType>({
resource: 'video/test-av1.mkv',
predicate: isTagIdPos(EbmlTagIdEnum.Segment, EbmlTagPosition.End),
schema: SegmentSchema,
});
const av1Track = segment.Tracks?.TrackEntry.find(
(t) => t.CodecID === VideoCodecId.AV1
)!;
expect(av1Track).toBeDefined();
expect(av1Track.CodecPrivate).toBeDefined();
const meta = parseAV1DecoderConfigurationRecord(av1Track)!;
expect(meta).toBeDefined();
const codecStr = genCodecStringByAV1DecoderConfigurationRecord(meta);
expect(meta.marker).toBe(1);
expect(meta.version).toBe(1);
expect(meta.seqProfile).toBe(0);
expect(meta.seqLevelIdx0).toBe(1);
expect(meta.seqTier0).toBe(0);
expect(meta.highBitdepth).toBe(0);
expect(meta.monochrome).toBe(0);
expect(
`${meta.chromaSubsamplingX}${meta.chromaSubsamplingY}${meta.chromaSamplePosition}`
).toBe('110');
expect(meta.initialPresentationDelayMinus1).toBeUndefined();
expect(codecStr).toBe('av01.0.01M.08.0.110');
});
});

View File

@ -0,0 +1,40 @@
import { SegmentSchema, SegmentType } from '@konoplayer/matroska/schema';
import { VideoCodecId } from '@konoplayer/matroska/codecs';
import {
parseAVCDecoderConfigurationRecord,
genCodecStringByAVCDecoderConfigurationRecord,
} from '@konoplayer/matroska/codecs/avc';
import { loadComponentFromRangedResource } from '../utils/data';
import { EbmlTagIdEnum, EbmlTagPosition } from 'konoebml';
import { isTagIdPos } from '@konoplayer/matroska/util';
describe('AVC code test', () => {
it('should parse avc meta from track entry', async () => {
const [segment] = await loadComponentFromRangedResource<SegmentType>({
resource: 'video/test-avc.mkv',
predicate: isTagIdPos(EbmlTagIdEnum.Segment, EbmlTagPosition.End),
schema: SegmentSchema,
});
const avcTrack = segment.Tracks?.TrackEntry.find(
(t) => t.CodecID === VideoCodecId.H264
)!;
expect(avcTrack).toBeDefined();
expect(avcTrack.CodecPrivate).toBeDefined();
const meta = parseAVCDecoderConfigurationRecord(avcTrack)!;
expect(meta).toBeDefined();
const codecStr = genCodecStringByAVCDecoderConfigurationRecord(meta);
expect(meta.configurationVersion).toBe(1);
expect(meta.avcProfileIndication).toBe(100);
expect(meta.profileCompatibility).toBe(0);
expect(meta.avcLevelIndication).toBe(30);
expect(codecStr).toBe('avc1.64001e');
});
});

View File

@ -0,0 +1,106 @@
import { SegmentSchema, SegmentType } from '@konoplayer/matroska/schema';
import { VideoCodecId } from '@konoplayer/matroska/codecs';
import {
parseHEVCDecoderConfigurationRecord,
genCodecStringByHEVCDecoderConfigurationRecord,
HEVCDecoderConfigurationRecordType,
} from '@konoplayer/matroska/codecs/hevc';
import { loadComponentFromRangedResource } from '../utils/data';
import { EbmlTagIdEnum, EbmlTagPosition } from 'konoebml';
import { isTagIdPos } from '@konoplayer/matroska/util';
import { assert } from 'vitest';
describe('HEVC codec test', () => {
it('should parse hevc meta from track entry', async () => {
const [segment] = await loadComponentFromRangedResource<SegmentType>({
resource: 'video/test-hevc.mkv',
predicate: isTagIdPos(EbmlTagIdEnum.Segment, EbmlTagPosition.End),
schema: SegmentSchema,
});
const hevcTrack = segment.Tracks?.TrackEntry.find(
(t) => t.CodecID === VideoCodecId.HEVC
)!;
expect(hevcTrack).toBeDefined();
expect(hevcTrack.CodecPrivate).toBeDefined();
const meta = parseHEVCDecoderConfigurationRecord(hevcTrack);
expect(meta).toBeDefined();
const codecStr = genCodecStringByHEVCDecoderConfigurationRecord(meta);
expect(codecStr).toBe('hev1.1.6.L63.90');
});
it('should match chrome test suite', () => {
function makeHEVCParameterSet(
generalProfileSpace: number,
generalProfileIDC: number,
generalProfileCompatibilityFlags: number,
generalTierFlag: number,
generalConstraintIndicatorFlags: [
number,
number,
number,
number,
number,
number,
],
generalLevelIDC: number
) {
return {
generalProfileSpace: generalProfileSpace,
generalProfileIdc: generalProfileIDC,
generalProfileCompatibilityFlags: generalProfileCompatibilityFlags,
generalTierFlag: generalTierFlag,
generalConstraintIndicatorFlags: Number(
new DataView(
new Uint8Array([0, 0, ...generalConstraintIndicatorFlags]).buffer
).getBigUint64(0, false)
),
generalLevelIdc: generalLevelIDC,
} as unknown as HEVCDecoderConfigurationRecordType;
}
assert(
genCodecStringByHEVCDecoderConfigurationRecord(
makeHEVCParameterSet(0, 1, 0x60000000, 0, [0, 0, 0, 0, 0, 0], 93)
),
'hev1.1.6.L93'
);
assert(
genCodecStringByHEVCDecoderConfigurationRecord(
makeHEVCParameterSet(1, 4, 0x82000000, 1, [0, 0, 0, 0, 0, 0], 120)
),
'hev1.A4.41.H120'
);
assert(
genCodecStringByHEVCDecoderConfigurationRecord(
makeHEVCParameterSet(0, 1, 0x60000000, 0, [176, 0, 0, 0, 0, 0], 93)
),
'hev1.1.6.L93.B0'
);
assert(
genCodecStringByHEVCDecoderConfigurationRecord(
makeHEVCParameterSet(1, 4, 0x82000000, 1, [176, 35, 0, 0, 0, 0], 120)
),
'hev1.A4.41.H120.B0.23'
);
assert(
genCodecStringByHEVCDecoderConfigurationRecord(
makeHEVCParameterSet(
2,
1,
0xf77db57b,
1,
[18, 52, 86, 120, 154, 188],
254
)
),
'hev1.B1.DEADBEEF.H254.12.34.56.78.9A.BC'
);
});
});

View File

@ -0,0 +1,54 @@
import { SegmentSchema, SegmentType } from '@konoplayer/matroska/schema';
import { VideoCodecId } from '@konoplayer/matroska/codecs';
import {
genCodecStringByVP9DecoderConfigurationRecord,
parseVP9DecoderConfigurationRecord,
VP9ColorSpaceEnum,
VP9Subsampling,
} from '@konoplayer/matroska/codecs/vp9';
import { loadComponentFromRangedResource } from '../utils/data';
import { EbmlTagIdEnum, EbmlTagPosition } from 'konoebml';
import { isTagIdPos } from '@konoplayer/matroska/util';
describe('VP9 code test', () => {
it('should parse vp9 meta from track entry and keyframe', async () => {
const [segment] = await loadComponentFromRangedResource<SegmentType>({
resource: 'video/test-vp9.mkv',
predicate: isTagIdPos(EbmlTagIdEnum.Segment, EbmlTagPosition.End),
schema: SegmentSchema,
});
const vp9Track = segment.Tracks?.TrackEntry.find(
(t) => t.CodecID === VideoCodecId.VP9
)!;
expect(vp9Track).toBeDefined();
expect(vp9Track.CodecPrivate).toBeFalsy();
const keyframe = segment
.Cluster!.flatMap((c) => c.SimpleBlock || [])
.find((b) => b.keyframe && b.track === vp9Track.TrackNumber)!;
expect(keyframe).toBeDefined();
expect(keyframe.frames.length).toBe(1);
const meta = parseVP9DecoderConfigurationRecord(
vp9Track,
keyframe.frames[0]
)!;
expect(meta).toBeDefined();
expect(meta.bitDepth).toBe(8);
expect(meta.subsampling).toBe(VP9Subsampling.YUV420);
expect(meta.width).toBe(640);
expect(meta.height).toBe(360);
expect(meta.colorSpace).toBe(VP9ColorSpaceEnum.BT_601);
expect(meta.profile).toBe(0);
const codecStr = genCodecStringByVP9DecoderConfigurationRecord(meta);
expect(codecStr).toBe('vp09.00.21.08');
});
});

View File

@ -0,0 +1,56 @@
import { Type } from 'arktype';
import { EbmlStreamDecoder, EbmlTagPosition, EbmlTagType } from 'konoebml';
import { convertEbmlTagToComponent } from '@konoplayer/matroska/util';
import fs from 'node:fs';
import { Readable } from 'node:stream';
import { TransformStream } from 'node:stream/web';
import path from 'node:path';
export interface LoadRangedResourceOptions<S extends Type<any> = any> {
resource: string;
byteStart?: number;
byteEnd?: number;
schema?: S;
predicate?: (tag: EbmlTagType) => boolean;
}
export async function loadComponentFromRangedResource<
T,
S extends Type<any> = any,
>({
resource,
byteStart,
byteEnd,
predicate = (tag) => !tag?.parent && tag.position !== EbmlTagPosition.Start,
schema,
}: LoadRangedResourceOptions<S>): Promise<T[]> {
const input = Readable.toWeb(
fs.createReadStream(
path.join(import.meta.dirname, '..', '..', '..', 'resources', resource),
{
start: byteStart,
end: byteEnd,
}
)
);
const output = input.pipeThrough(
new EbmlStreamDecoder({
streamStartOffset: byteStart,
collectChild: true,
}) as unknown as TransformStream<Uint8Array, EbmlTagType>
);
const result: T[] = [];
for await (const t of output) {
if (predicate(t)) {
let component = convertEbmlTagToComponent(t) as T;
if (schema) {
component = schema.assert(component);
}
result.push(component);
}
}
return result;
}

30
apps/test/tsconfig.json Normal file
View File

@ -0,0 +1,30 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"types": [
"vitest/globals",
"node"
],
"paths": {
"@konoplayer/core/*": [
"../../packages/core/src/*"
],
"@konoplayer/matroska/*": [
"../../packages/matroska/src/*"
]
}
},
"include": [
"src"
],
"references": [
{
"path": "../../packages/core"
},
{
"path": "../../packages/matroska"
}
]
}

View File

@ -0,0 +1,33 @@
import swc from 'unplugin-swc';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
cacheDir: '.vitest',
test: {
setupFiles: ['src/init-test.ts'],
environment: 'happy-dom',
include: ['src/**/*.spec.ts'],
globals: true,
restoreMocks: true,
coverage: {
// you can include other reporters, but 'json-summary' is required, json is recommended
reporter: ['text', 'json-summary', 'json'],
// If you want a coverage reports even if your tests are failing, include the reportOnFailure option
reportOnFailure: true,
include: ['../../packages/core/src/**', '../../packages/matroska/src/**'],
},
},
plugins: [
tsconfigPaths(),
swc.vite({
include: /\.[mc]?[jt]sx?$/,
// for git+ package only
exclude: [
/node_modules\/(?!@konoplayer|\.pnpm)/,
/node_modules\/\.pnpm\/(?!@konoplayer)/,
] as any,
tsconfigFile: './tsconfig.json',
}),
],
});

View File

@ -17,7 +17,13 @@
"noSvgWithoutTitle": "off"
},
"complexity": {
"noBannedTypes": "off"
"noBannedTypes": "off",
"noExcessiveCognitiveComplexity": {
"level": "warn",
"options": {
"maxAllowedComplexity": 40
}
}
},
"nursery": {
"noEnum": "off",
@ -61,6 +67,33 @@
}
}
}
},
{
"include": [
"apps/test/**"
],
"javascript": {
"globals": [
"describe",
"beforeEach",
"it",
"expect",
"afterEach"
]
},
"linter": {
"rules": {
"style": {
"useImportType": "off"
},
"suspicious": {
"noConsole": "off"
},
"performance": {
"useTopLevelRegex": "off"
}
}
}
}
]
}

View File

@ -2,10 +2,10 @@ set windows-shell := ["pwsh.exe", "-c"]
set dotenv-load := true
dev-playground:
pnpm run --filter=playground dev
pnpm run --filter=@konoplayer/playground dev
dev-proxy:
pnpm run --filter proxy --filter mock dev
pnpm run --filter=@konoplayer/proxy --filter=@konoplayer/mock dev
download-samples:
pnpm run download-samples

View File

@ -1,19 +0,0 @@
{
"name": "konoplayer-codecs",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "rsbuild build",
"dev": "rsbuild dev",
"preview": "rsbuild preview"
},
"dependencies": {
"konoebml": "0.1.2-rc.5",
"lit": "^3.2.1"
},
"devDependencies": {
"@rsbuild/core": "^1.2.14",
"typescript": "^5.8.2"
}
}

View File

@ -0,0 +1,8 @@
{
"name": "@konoplayer/core",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {},
"dependencies": {}
}

View File

@ -0,0 +1,2 @@
export { AudioCodec } from './audio-codecs';
export { VideoCodec } from './video-codecs';

View File

@ -0,0 +1,39 @@
export class BitReader {
private data: Uint8Array;
private byteOffset = 0;
private bitOffset = 0;
constructor(data: Uint8Array) {
this.data = data;
}
readBits(numBits: number): number {
let value = 0;
for (let i = 0; i < numBits; i++) {
const bit = (this.data[this.byteOffset] >> (7 - this.bitOffset)) & 1;
value = (value << 1) | bit;
this.bitOffset++;
if (this.bitOffset === 8) {
this.bitOffset = 0;
this.byteOffset++;
}
}
return value;
}
skipBits(numBits: number): void {
this.bitOffset += numBits;
while (this.bitOffset >= 8) {
this.bitOffset -= 8;
this.byteOffset++;
}
}
hasData(): boolean {
return this.byteOffset < this.data.length;
}
getRemainingBytes(): Uint8Array {
return this.data.slice(this.byteOffset);
}
}

View File

@ -0,0 +1,6 @@
export {
type RangedStream,
type CreateRangedStreamOptions,
createRangedStream,
} from './fetch';
export { BitReader } from './bit';

View File

@ -4,7 +4,7 @@ export class UnsupportedCodecError extends Error {
}
}
export class ParseCodecPrivateError extends Error {
export class ParseCodecError extends Error {
constructor(codec: string, detail: string) {
super(`code ${codec} private parse failed: ${detail}`);
}
@ -12,7 +12,7 @@ export class ParseCodecPrivateError extends Error {
export class UnreachableOrLogicError extends Error {
constructor(detail: string) {
super(`unreachable or logic error: ${detail}`)
super(`unreachable or logic error: ${detail}`);
}
}
@ -22,4 +22,4 @@ export class ParseCodecErrors extends Error {
constructor() {
super('failed to parse codecs');
}
}
}

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist"
},
"include": [
"src"
]
}

View File

@ -1,5 +1,5 @@
[package]
name = "konoplayer-codecs"
name = "konoplayer-ffmpeg"
version = "0.1.0"
edition = "2024"

View 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"
}
}

View File

@ -1,4 +1,4 @@
import { ParseCodecPrivateError } from '@/media/base/errors';
import { ParseCodecError } from '@konoplayer/core/errors';
import { ArkErrors, type } from 'arktype';
export const AAC_CODEC_TYPE = 'AAC';
@ -28,10 +28,7 @@ export function parseAudioSpecificConfig(
codecPrivate: Uint8Array
): AudioSpecificConfigType {
if (codecPrivate.length < 2) {
throw new ParseCodecPrivateError(
AAC_CODEC_TYPE,
'codec_private data too short'
);
throw new ParseCodecError(AAC_CODEC_TYPE, 'codec_private data too short');
}
// Create a DataView for bit-level manipulation
@ -95,7 +92,7 @@ export function parseAudioSpecificConfig(
// Validate with arktype
const validation = AudioSpecificConfigSchema(config);
if (validation instanceof ArkErrors) {
const error = new ParseCodecPrivateError(
const error = new ParseCodecError(
AAC_CODEC_TYPE,
'Invalid AudioSpecificConfig'
);

View 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 Delayoptional
if (
config.initialPresentationDelayPresent === 1 &&
config.initialPresentationDelayMinus1 !== undefined
) {
const delay = (config.initialPresentationDelayMinus1 + 1)
.toString()
.padStart(2, '0');
parts.push(delay);
}
// joined
return parts.join('.');
}

View File

@ -1,5 +1,6 @@
import { ParseCodecPrivateError } from '@/media/base/errors';
import { ParseCodecError } from '@konoplayer/core/errors';
import { type } from 'arktype';
import type { TrackEntryType } from '../schema';
export const AVC_CODEC_TYPE = 'h264(AVC)';
@ -23,46 +24,72 @@ export type AVCDecoderConfigurationRecordType =
typeof AVCDecoderConfigurationRecordSchema.infer;
/**
* Parse AVCDecoderConfigurationRecord from codec_private Uint8Array
* @param codecPrivate - Uint8Array containing codec_private data
* @returns Parsed AVCDecoderConfigurationRecord or throws an error if invalid
*
* @see [webkit](https://github.com/movableink/webkit/blob/7e43fe7000b319ce68334c09eed1031642099726/Source/WebCore/platform/graphics/HEVCUtilities.cpp#L84)
*/
export function parseAVCDecoderConfigurationRecord(
codecPrivate: Uint8Array
track: TrackEntryType
): AVCDecoderConfigurationRecordType {
let offset = 0;
// ISO/IEC 14496-10:2014
// 7.3.2.1.1 Sequence parameter set data syntax
const codecPrivate = track.CodecPrivate;
// Check if data length is sufficient
if (codecPrivate.length < 5) {
throw new ParseCodecPrivateError(
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 configurationVersion = codecPrivate[offset++];
const avcProfileIndication = codecPrivate[offset++];
const profileCompatibility = codecPrivate[offset++];
const avcLevelIndication = codecPrivate[offset++];
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 = codecPrivate[offset++] & 0x03;
const lengthSizeMinusOne = readUint8(true) & 0x03;
// Read number of SPS (first 3 bits are reserved, typically 0xE0, last 5 bits are SPS count)
const numOfSPS = codecPrivate[offset++] & 0x1f;
const numOfSPS = readUint8(true) & 0x1f;
const sps: Uint8Array[] = [];
// Parse SPS
for (let i = 0; i < numOfSPS; i++) {
if (offset + 2 > codecPrivate.length) {
throw new ParseCodecPrivateError(AVC_CODEC_TYPE, 'Invalid SPS length');
throw new ParseCodecError(AVC_CODEC_TYPE, 'Invalid SPS length');
}
const spsLength = (codecPrivate[offset] << 8) | codecPrivate[offset + 1];
offset += 2;
const spsLength = readUint16(true);
if (offset + spsLength > codecPrivate.length) {
throw new ParseCodecPrivateError(
throw new ParseCodecError(
AVC_CODEC_TYPE,
'SPS data exceeds buffer length'
);
@ -74,22 +101,21 @@ export function parseAVCDecoderConfigurationRecord(
// Read number of PPS
if (offset >= codecPrivate.length) {
throw new ParseCodecPrivateError(AVC_CODEC_TYPE, 'No space for PPS count');
throw new ParseCodecError(AVC_CODEC_TYPE, 'No space for PPS count');
}
const numOfPPS = codecPrivate[offset++];
const numOfPPS = readUint8(true);
const pps: Uint8Array[] = [];
// Parse PPS
for (let i = 0; i < numOfPPS; i++) {
if (offset + 2 > codecPrivate.length) {
throw new ParseCodecPrivateError(AVC_CODEC_TYPE, 'Invalid PPS length');
throw new ParseCodecError(AVC_CODEC_TYPE, 'Invalid PPS length');
}
const ppsLength = (codecPrivate[offset] << 8) | codecPrivate[offset + 1];
offset += 2;
const ppsLength = readUint16(true);
if (offset + ppsLength > codecPrivate.length) {
throw new ParseCodecPrivateError(
throw new ParseCodecError(
AVC_CODEC_TYPE,
'PPS data exceeds buffer length'
);
@ -110,16 +136,13 @@ export function parseAVCDecoderConfigurationRecord(
};
}
export function genCodecIdByAVCDecoderConfigurationRecord(
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 / 10)
.toString(16)
.replace(/./g, '')
.padStart(2, '0');
const levelHex = config.avcLevelIndication.toString(16).padStart(2, '0');
return `avc1.${profileHex}${profileCompatHex}${levelHex}`;
}

View 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('.');
}

View File

@ -1,16 +1,26 @@
import { AudioCodec } from '../../base/audio_codecs';
import { UnsupportedCodecError } from '../../base/errors';
import { VideoCodec } from '../../base/video_codecs';
import { UnsupportedCodecError } from '@konoplayer/core/errors';
import { VideoCodec, AudioCodec } from '@konoplayer/core/codecs';
import type { TrackEntryType } from '../schema';
import {
genCodecIdByAudioSpecificConfig,
parseAudioSpecificConfig,
} from './aac';
import {
genCodecIdByAVCDecoderConfigurationRecord,
genCodecStringByAVCDecoderConfigurationRecord,
parseAVCDecoderConfigurationRecord,
} from './avc';
import type {ProbeInfo} from "@/media/mkv/enhance/probe.ts";
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',
@ -108,63 +118,85 @@ export type SubtitleCodecIdType =
| `${(typeof SubtitleCodecId)[keyof typeof SubtitleCodecId]}`
| string;
export interface VideoDecoderConfigExt extends VideoDecoderConfig {
codecType: VideoCodec,
codecType: VideoCodec;
}
export function videoCodecIdToWebCodecs(
track: TrackEntryType,
_probeInfo?: ProbeInfo
keyframe: Uint8Array
): VideoDecoderConfigExt {
const codecId = track.CodecID;
const codecPrivate = track.CodecPrivate;
const shareOptions = {
description: codecPrivate
}
description: codecPrivate,
};
switch (codecId) {
case VideoCodecId.HEVC:
return { ...shareOptions, codecType: VideoCodec.HEVC, codec: 'hevc' };
return {
...shareOptions,
codecType: VideoCodec.HEVC,
codec: genCodecStringByHEVCDecoderConfigurationRecord(
parseHEVCDecoderConfigurationRecord(track)
),
};
case VideoCodecId.VP9:
return { ...shareOptions, codecType: VideoCodec.VP9, codec: 'vp09' };
return {
...shareOptions,
codecType: VideoCodec.VP9,
codec: genCodecStringByVP9DecoderConfigurationRecord(
parseVP9DecoderConfigurationRecord(track, keyframe)
),
};
case VideoCodecId.AV1:
return { ...shareOptions, codecType: VideoCodec.AV1, codec: 'av1' };
return {
...shareOptions,
codecType: VideoCodec.AV1,
codec: genCodecStringByAV1DecoderConfigurationRecord(
parseAV1DecoderConfigurationRecord(track)
),
};
case VideoCodecId.H264:
if (!codecPrivate) {
throw new UnsupportedCodecError(
'h264(without codec_private profile)',
'web codecs audio decoder'
);
}
return {
...shareOptions,
codecType: VideoCodec.H264,
codec: genCodecIdByAVCDecoderConfigurationRecord(
parseAVCDecoderConfigurationRecord(codecPrivate)
)
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' };
return {
...shareOptions,
codecType: VideoCodec.MPEG4,
codec: 'mp4v.01.3',
};
case VideoCodecId.MPEG4_ISO_ASP:
return { ...shareOptions, codecType: VideoCodec.MPEG4, codec: 'mp4v.20.9' };
return {
...shareOptions,
codecType: VideoCodec.MPEG4,
codec: 'mp4v.20.9',
};
case VideoCodecId.MPEG4_ISO_AP:
return { ...shareOptions, codecType: VideoCodec.MPEG4, codec: 'mp4v.20.9' };
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,
codecType: AudioCodec;
}
export function audioCodecIdToWebCodecs(
track: TrackEntryType,
_probeInfo?: ProbeInfo
track: TrackEntryType
): AudioDecoderConfigExt {
const codecId = track.CodecID;
const codecPrivate = track.CodecPrivate;
@ -175,8 +207,8 @@ export function audioCodecIdToWebCodecs(
const shareOptions = {
numberOfChannels,
sampleRate,
description: codecPrivate
}
description: codecPrivate,
};
switch (track.CodecID) {
case AudioCodecId.AAC_MPEG4_MAIN:
@ -184,85 +216,94 @@ export function audioCodecIdToWebCodecs(
return {
...shareOptions,
codecType: AudioCodec.AAC,
codec: 'mp4a.40.1'
codec: 'mp4a.40.1',
};
case AudioCodecId.AAC_MPEG2_LC:
case AudioCodecId.AAC_MPEG4_LC:
return {
...shareOptions,
codecType: AudioCodec.AAC,
codec: 'mp4a.40.2'
codec: 'mp4a.40.2',
};
case AudioCodecId.AAC_MPEG2_SSR:
case AudioCodecId.AAC_MPEG4_SSR:
return {
...shareOptions,
codecType: AudioCodec.AAC,
codec: 'mp4a.40.3'
codec: 'mp4a.40.3',
};
case AudioCodecId.AAC_MPEG4_LTP:
return {
...shareOptions,
codecType: AudioCodec.AAC,
codec: 'mp4a.40.4'
codec: 'mp4a.40.4',
};
case AudioCodecId.AAC_MPEG2_LC_SBR:
case AudioCodecId.AAC_MPEG4_SBR:
return {
...shareOptions,
codecType: AudioCodec.AAC,
codec: 'mp4a.40.5'
codec: 'mp4a.40.5',
};
case AudioCodecId.AAC:
return {
...shareOptions,
codecType: AudioCodec.AAC,
codec: codecPrivate
? genCodecIdByAudioSpecificConfig(
parseAudioSpecificConfig(codecPrivate)
) : 'mp4a.40.2',
? genCodecIdByAudioSpecificConfig(
parseAudioSpecificConfig(codecPrivate)
)
: 'mp4a.40.2',
};
case AudioCodecId.AC3:
case AudioCodecId.AC3_BSID9:
return {
...shareOptions,
codecType: AudioCodec.AC3,
codec: 'ac-3'
codec: 'ac-3',
};
case AudioCodecId.EAC3:
case AudioCodecId.AC3_BSID10:
return {
...shareOptions,
codecType: AudioCodec.EAC3,
codec: 'ec-3'
codec: 'ec-3',
};
case AudioCodecId.MPEG_L3:
return {
...shareOptions,
codecType: AudioCodec.MP3,
codec: 'mp3'
codec: 'mp3',
};
case AudioCodecId.VORBIS:
return { ...shareOptions, codecType: AudioCodec.Vorbis, codec: 'vorbis' }
;
return { ...shareOptions, codecType: AudioCodec.Vorbis, codec: 'vorbis' };
case AudioCodecId.FLAC:
return { ...shareOptions, codecType: AudioCodec.FLAC, codec: 'flac' }
;
return { ...shareOptions, codecType: AudioCodec.FLAC, codec: 'flac' };
case AudioCodecId.OPUS:
return { ...shareOptions, codecType: AudioCodec.Opus, codec: 'opus' }
;
return { ...shareOptions, codecType: AudioCodec.Opus, codec: 'opus' };
case AudioCodecId.ALAC:
return { ...shareOptions, codecType: AudioCodec.ALAC, codec: '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' };
return {
...shareOptions,
codecType: AudioCodec.PCM_S16BE,
codec: 'pcm-s16be',
};
}
if (bitDepth === 24) {
return { ...shareOptions, codecType: AudioCodec.PCM_S24BE, codec: 'pcm-s24be' };
return {
...shareOptions,
codecType: AudioCodec.PCM_S24BE,
codec: 'pcm-s24be',
};
}
if (bitDepth === 32) {
return { ...shareOptions, codecType: AudioCodec.PCM_S32BE, codec: 'pcm-s32be' };
return {
...shareOptions,
codecType: AudioCodec.PCM_S32BE,
codec: 'pcm-s32be',
};
}
throw new UnsupportedCodecError(
`${codecId}(${bitDepth}b)`,
@ -270,20 +311,36 @@ export function audioCodecIdToWebCodecs(
);
case AudioCodecId.PCM_INT_LIT:
if (bitDepth === 16) {
return { ...shareOptions, codecType: AudioCodec.PCM_S16LE, codec: 'pcm-s16le' };
return {
...shareOptions,
codecType: AudioCodec.PCM_S16LE,
codec: 'pcm-s16le',
};
}
if (bitDepth === 24) {
return { ...shareOptions, codecType: AudioCodec.PCM_S24LE, codec: 'pcm-s24le' };
return {
...shareOptions,
codecType: AudioCodec.PCM_S24LE,
codec: 'pcm-s24le',
};
}
if (bitDepth === 32) {
return { ...shareOptions, codecType: AudioCodec.PCM_S32LE, codec: 'pcm-s32le' };
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' };
return {
...shareOptions,
codecType: AudioCodec.PCM_F32LE,
codec: 'pcm-f32le',
};
default:
throw new UnsupportedCodecError(codecId, 'web codecs audio decoder');
}

View 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}`;
}

View File

@ -30,14 +30,18 @@ import {
TagSchema,
type TagType,
TrackEntrySchema,
type TrackEntryType, TrackTypeRestrictionEnum,
type TrackEntryType,
TrackTypeRestrictionEnum,
} from './schema';
import {concatBufs} from "konoebml/lib/tools";
import {ParseCodecErrors, UnreachableOrLogicError, UnsupportedCodecError} from "@/media/base/errors.ts";
import type {ProbeInfo} from "@/media/mkv/enhance/probe.ts";
import {audioCodecIdToWebCodecs, videoCodecIdToWebCodecs} from "@/media/mkv/codecs";
import {Queue} from "mnemonist";
import {BehaviorSubject} from "rxjs";
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]);
@ -47,10 +51,7 @@ export const SEEK_ID_KAX_TAGS = new Uint8Array([0x12, 0x54, 0xc3, 0x67]);
export class SegmentSystem {
startTag: EbmlSegmentTagType;
headTags: EbmlTagType[] = [];
teeStream: ReadableStream<Uint8Array>
teeBufferTask: Promise<Uint8Array>;
firstCluster: EbmlClusterTagType | undefined;
probInfo?: ProbeInfo;
cue: CueSystem;
cluster: ClusterSystem;
@ -59,7 +60,7 @@ export class SegmentSystem {
track: TrackSystem;
tag: TagSystem;
constructor(startNode: EbmlSegmentTagType, teeStream: ReadableStream<Uint8Array>) {
constructor(startNode: EbmlSegmentTagType) {
this.startTag = startNode;
this.cue = new CueSystem(this);
this.cluster = new ClusterSystem(this);
@ -67,35 +68,13 @@ export class SegmentSystem {
this.info = new InfoSystem(this);
this.track = new TrackSystem(this);
this.tag = new TagSystem(this);
this.teeStream = teeStream;
this.teeBufferTask = this.teeWaitingProbingData(teeStream);
}
private async teeWaitingProbingData (teeStream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
const reader = teeStream.getReader();
const list: Uint8Array<ArrayBufferLike>[] = [];
while (true) {
try {
const { done, value } = await reader.read();
if (done) {
break;
}
list.push(value);
} catch (e: any) {
if (e?.name === 'AbortError') {
break;
}
throw e;
}
}
return concatBufs(...list)
}
get contentStartOffset() {
return this.startTag.startOffset + this.startTag.headerLength;
}
private seekLocal () {
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);
@ -131,7 +110,7 @@ export class SegmentSystem {
return this;
}
async completeMeta () {
async completeMeta() {
this.seekLocal();
await this.parseCodes();
@ -139,41 +118,19 @@ export class SegmentSystem {
return this;
}
async fetchProbeInfo (_payload: Uint8Array): Promise<ProbeInfo> {
// call local or remote ff-probe
return {}
}
async parseCodes () {
const candidates = this.track.tracks.filter(c => c.TrackType === TrackTypeRestrictionEnum.AUDIO || c.TrackType === TrackTypeRestrictionEnum.VIDEO);
async parseCodes() {
const candidates = this.track.tracks.filter(
(c) =>
c.TrackType === TrackTypeRestrictionEnum.AUDIO ||
c.TrackType === TrackTypeRestrictionEnum.VIDEO
);
const parseErrors = new ParseCodecErrors();
if (!this.probInfo) {
for (const t of candidates) {
try {
await this.track.initTrack(t, undefined)
} catch (e: unknown) {
parseErrors.cause.push(e as Error)
}
}
if (parseErrors.cause.length > 0) {
try {
const teeBuffer = await this.teeBufferTask;
this.probInfo = await this.fetchProbeInfo(teeBuffer);
} catch (e) {
parseErrors.cause.push(e as Error);
return;
}
} else {
return;
}
}
for (const t of candidates) {
try {
await this.track.initTrack(t, this.probInfo)
await this.track.initTrack(t, this.);
} catch (e) {
parseErrors.cause.push(e as Error)
parseErrors.cause.push(e as Error);
}
}
if (parseErrors.cause.length > 0) {
@ -274,9 +231,9 @@ export class SeekSystem extends SegmentComponentSystemTrait<
return this.seekTagByStartOffset(this.seekOffsetBySeekId(seekId));
}
get firstClusterOffset () {
get firstClusterOffset() {
if (!this.segment.firstCluster) {
throw new UnreachableOrLogicError("first cluster not found")
throw new UnreachableOrLogicError('first cluster not found');
}
return this.segment.firstCluster.startOffset;
}
@ -320,11 +277,10 @@ export interface GetTrackEntryOptions {
predicate?: (v: SegmentComponent<TrackEntryType>) => boolean;
}
export interface TrackState<Decoder, Config, Frame> {
decoder: Decoder,
configuration?: Config,
frameBuffer$: BehaviorSubject<Queue<Frame>>
decoder: Decoder;
configuration?: Config;
frameBuffer$: BehaviorSubject<Queue<Frame>>;
}
export class TrackSystem extends SegmentComponentSystemTrait<
@ -336,8 +292,14 @@ export class TrackSystem extends SegmentComponentSystemTrait<
}
tracks: SegmentComponent<TrackEntryType>[] = [];
videoTrackState = new WeakMap<TrackEntryType, TrackState< VideoDecoder, VideoDecoderConfig, VideoFrame>>();
audioTrackState = new WeakMap<TrackEntryType, TrackState<AudioDecoder, AudioDecoderConfig, AudioData>>();
videoTrackState = new WeakMap<
TrackEntryType,
TrackState<VideoDecoder, VideoDecoderConfig, VideoFrame>
>();
audioTrackState = new WeakMap<
TrackEntryType,
TrackState<AudioDecoder, AudioDecoderConfig, AudioData>
>();
getTrackEntry({
priority = (track) =>
@ -357,11 +319,11 @@ export class TrackSystem extends SegmentComponentSystemTrait<
return this;
}
async initTrack (track: TrackEntryType, probe?: ProbeInfo) {
async initTrack(track: TrackEntryType) {
if (track.TrackType === TrackTypeRestrictionEnum.AUDIO) {
const configuration = audioCodecIdToWebCodecs(track, probe);
const configuration = audioCodecIdToWebCodecs(track);
if (await AudioDecoder.isConfigSupported(configuration)) {
throw new UnsupportedCodecError(configuration.codec, 'audio decoder')
throw new UnsupportedCodecError(configuration.codec, 'audio decoder');
}
const queue$ = new BehaviorSubject(new Queue<AudioData>());
@ -378,11 +340,11 @@ export class TrackSystem extends SegmentComponentSystemTrait<
},
}),
frameBuffer$: queue$,
})
});
} else if (track.TrackType === TrackTypeRestrictionEnum.VIDEO) {
const configuration = videoCodecIdToWebCodecs(track, probe);
const configuration = videoCodecIdToWebCodecs(track, this.keyframe);
if (await VideoDecoder.isConfigSupported(configuration)) {
throw new UnsupportedCodecError(configuration.codec, 'audio decoder')
throw new UnsupportedCodecError(configuration.codec, 'audio decoder');
}
const queue$ = new BehaviorSubject(new Queue<VideoFrame>());
@ -399,7 +361,7 @@ export class TrackSystem extends SegmentComponentSystemTrait<
},
}),
frameBuffer$: queue$,
})
});
}
}
}

View File

@ -1,4 +1,9 @@
import {EbmlStreamDecoder, EbmlTagIdEnum, EbmlTagPosition, type EbmlTagType,} from 'konoebml';
import {
EbmlStreamDecoder,
EbmlTagIdEnum,
EbmlTagPosition,
type EbmlTagType,
} from 'konoebml';
import {
defer,
EMPTY,
@ -19,10 +24,19 @@ import {
takeWhile,
withLatestFrom,
} from 'rxjs';
import {createRangedStream, type CreateRangedStreamOptions} from '@/fetch';
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';
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 {
@ -33,7 +47,7 @@ export function createRangedEbmlStream({
url,
byteStart = 0,
byteEnd,
tee = false
tee = false,
}: CreateRangedEbmlStreamOptions): Observable<{
ebml$: Observable<EbmlTagType>;
totalSize?: number;
@ -135,12 +149,11 @@ export function createEbmlController({
...options,
url,
byteStart: 0,
tee: true
tee: true,
});
const controller$ = metaRequest$.pipe(
map(({ totalSize, ebml$, response, controller, teeBody }) => {
const head$ = ebml$.pipe(
filter(isTagIdPos(EbmlTagIdEnum.EBML, EbmlTagPosition.End)),
take(1),
@ -166,18 +179,30 @@ export function createEbmlController({
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);
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;
},
true
{ 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,
@ -312,7 +337,10 @@ export function createEbmlController({
acc.next = curr;
return acc;
},
({ prev: undefined as (SegmentComponent<ClusterType> | undefined), next: undefined as SegmentComponent<ClusterType> | undefined })
{
prev: undefined as SegmentComponent<ClusterType> | undefined,
next: undefined as SegmentComponent<ClusterType> | undefined,
}
),
filter((c) => c.next?.Timestamp! > seekTime),
map((c) => c.prev ?? c.next!)

View File

@ -0,0 +1,20 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"paths": {
"@konoplayer/core/*": [
"../core/src/*"
]
}
},
"include": [
"src"
],
"references": [
{
"path": "../core"
}
]
}

View File

@ -1,7 +1,7 @@
[package]
name = "konoplayer-demuxing"
name = "konoplayer-symphonia"
version = "0.1.0"
edition = "2024"
[dependencies]
symphonia-format-mkv = "0.5.4"
symphonia = "0.5.4"

709
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -363,7 +363,6 @@ function generateMkvSchemaHierarchy(elements_: EbmlElementType[]) {
const selfSchema = [
`export const ${el.name}Schema = type({`,
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: <explanation>
...associated.map((v) => {
let meta: any;
const restriction = generateRestriction(v);

View File

@ -24,9 +24,8 @@
"target": "ES2021",
"strictNullChecks": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"useDefineForClassFields": true,
"useDefineForClassFields": false,
"exactOptionalPropertyTypes": false,
"experimentalDecorators": true
}
}

View File

@ -9,6 +9,15 @@
},
{
"path": "./tsconfig.scripts.json"
},
{
"path": "./packages/matroska"
},
{
"path": "./packages/core"
},
{
"path": "./apps/test"
}
]
}