diff --git a/apps/playground/src/video-pipeline-demo.ts b/apps/playground/src/video-pipeline-demo.ts index 2dae978..529e9d2 100644 --- a/apps/playground/src/video-pipeline-demo.ts +++ b/apps/playground/src/video-pipeline-demo.ts @@ -15,18 +15,18 @@ import { take, tap, distinctUntilChanged, - fromEvent, - filter, + fromEvent, withLatestFrom, share, delay, delayWhen, from, of, } from 'rxjs'; 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'; +import type {SegmentComponent, AudioTrackContext, VideoTrackContext} from "@konoplayer/matroska/systems"; + export class VideoPipelineDemo extends LitElement { static styles = css``; @@ -43,6 +43,7 @@ export class VideoPipelineDemo extends LitElement { audioContext = new AudioContext(); seek$ = new ReplaySubject(1); + cluster$ = new Subject>(); videoFrameBuffer$ = new BehaviorSubject(new Queue()); audioFrameBuffer$ = new BehaviorSubject(new Queue()); @@ -62,37 +63,47 @@ export class VideoPipelineDemo extends LitElement { url: src, }); - const segment$ = controller$.pipe( + const segmentContext$ = controller$.pipe( switchMap(({ segments$ }) => segments$.pipe(take(1))) ); - const cluster$ = combineLatest({ - seekTime: this.seek$, - segment: segment$, - }).pipe(switchMap(({ seekTime, segment }) => segment.seek(seekTime))); + const videoTrack$ = segmentContext$.pipe( - const decode$ = segment$.pipe( + ) + + const currentCluster$ = combineLatest({ + seekTime: this.seek$, + segmentContext: segmentContext$, + }).pipe( + delayWhen(({ segmentContext: { segment } }) => from(segment.track.flushContexts())), + switchMap(({ seekTime, segmentContext }) => combineLatest({ + segmentContext: of(segmentContext), + cluster: segmentContext.seek(seekTime), + })), + share() + ); + + const decodeVideo$ = currentCluster$.pipe( + + ) + + const decode$ = segmentContext$.pipe( switchMap(({ withMeta$ }) => withMeta$), map((segment) => { const trackSystem = segment.track; const infoSystem = segment.info; - const tracks = { - video: trackSystem.getTrackEntry({ - predicate: (c) => - c.TrackType === TrackTypeRestrictionEnum.VIDEO && - c.FlagEnabled !== 0, - }), - audio: trackSystem.getTrackEntry({ + const videoTrack = trackSystem.getTrackContext({ + predicate: (c) => + c.TrackType === TrackTypeRestrictionEnum.VIDEO && + c.FlagEnabled !== 0, + }); + const audioTrack = trackSystem.getTrackContext({ predicate: (c) => c.TrackType === TrackTypeRestrictionEnum.AUDIO && c.FlagEnabled !== 0, - }), - subtitle: trackSystem.getTrackEntry({ - predicate: (c) => - c.TrackType === TrackTypeRestrictionEnum.SUBTITLE && - c.FlagEnabled !== 0, - }), - }; + }); + + const videoDecode$ = track const videoDecode$ = tracks.video ? new Observable((subscriber) => { @@ -354,7 +365,7 @@ export class VideoPipelineDemo extends LitElement { this.pipeline$$.add(video$.subscribe()); this.pipeline$$.add(addToVideoFrameBuffer$.subscribe()); this.pipeline$$.add(addToAudioFrameBuffer$.subscribe()); - this.pipeline$$.add(cluster$.subscribe(this.cluster$)); + this.pipeline$$.add(currentCluster$.subscribe(this.cluster$)); this.pipeline$$.add( fromEvent(document.body, 'click').subscribe(() => { this.audioContext.resume(); diff --git a/packages/core/src/elements/media-trait.ts b/packages/core/src/elements/media-trait.ts new file mode 100644 index 0000000..43288ab --- /dev/null +++ b/packages/core/src/elements/media-trait.ts @@ -0,0 +1,356 @@ +import { + BehaviorSubject, + distinctUntilChanged, + filter, + interval, + map, + merge, Observable, + Subject, + type Subscription, switchMap, takeUntil, tap, +} from 'rxjs'; +import {NetworkState, ReadyState} from "./state.ts"; + +export interface Metadata { + duration: number +} + +export abstract class VideoElementTrait { + private playbackTimer: Subscription | undefined; + + _src$ = new BehaviorSubject(''); + _currentTime$ = new BehaviorSubject(0); + _duration$ = new BehaviorSubject(Number.NaN); + _paused$ = new BehaviorSubject(true); + _ended$ = new BehaviorSubject(false); + _volume$ = new BehaviorSubject(1.0); + _muted$ = new BehaviorSubject(false); + _playbackRate$ = new BehaviorSubject(1.0); + _readyState$ = new BehaviorSubject(0); // HAVE_NOTHING + _networkState$ = new BehaviorSubject(0); // NETWORK_EMPTY + _width$ = new BehaviorSubject(0); + _height$ = new BehaviorSubject(0); + _videoWidth$ = new BehaviorSubject(0); // 只读,视频内在宽度 + _videoHeight$ = new BehaviorSubject(0); // 只读,视频内在高度 + _poster$ = new BehaviorSubject(''); + + _destroyRef$ = new Subject(); + + + _progress$ = new Subject(); + _error$ = new Subject(); + _abort$ = new Subject(); + _emptied$ = new Subject(); + _stalled$ = new Subject(); + _loadeddata$ = new Subject(); + _playing$ = new Subject(); + _waiting$ = new Subject(); + _seeked$ = new Subject(); + _timeupdate$ = new Subject(); + _play$ = new Subject(); + _resize$ = new Subject(); + + _setCurrentTime$ = new Subject(); + _setSrc$ = new Subject(); + _callLoadMetadataStart$ = new Subject(); + _callLoadMetadataEnd$ = new Subject(); + _callLoadDataStart$ = new Subject(); + _callLoadDataEnd$ = new Subject(); + + protected constructor() { + this._setCurrentTime$.pipe( + takeUntil(this._destroyRef$)) + .subscribe(this._currentTime$) + + this.seeking$.pipe( + takeUntil(this._destroyRef$), + switchMap(() => this._seek()), + map(() => new Event("seeked")) + ).subscribe(this._seeked$) + + this._setSrc$.pipe( + takeUntil(this._destroyRef$), + ).subscribe(this._src$) + this._setSrc$.pipe( + takeUntil(this._destroyRef$), + switchMap(() => this._load()) + ).subscribe(); + + this._readyState$.pipe( + takeUntil(this._destroyRef$), + filter((r) => r === ReadyState.HAVE_NOTHING), + map(() => 0) + ).subscribe(this._currentTime$); + this._readyState$.pipe( + takeUntil(this._destroyRef$), + filter((r) => r === ReadyState.HAVE_NOTHING), + map(() => true), + ).subscribe(this._paused$); + this._readyState$.pipe( + takeUntil(this._destroyRef$), + filter((r) => r === ReadyState.HAVE_NOTHING), + map(() => false) + ).subscribe(this._ended$) + + this._callLoadMetadataStart$.pipe( + takeUntil(this._destroyRef$), + map(() => NetworkState.NETWORK_LOADING) + ).subscribe( + this._networkState$ + ); + + this._callLoadDataEnd$.pipe( + takeUntil(this._destroyRef$), + map(() => NetworkState.NETWORK_IDLE) + ).subscribe(this._networkState$); + this._callLoadMetadataEnd$.pipe( + takeUntil(this._destroyRef$), + map(() => ReadyState.HAVE_METADATA) + ).subscribe(this._readyState$) + + this._callLoadMetadataEnd$.pipe( + takeUntil(this._destroyRef$), + map(meta => meta.duration) + ).subscribe(this._duration$); + + this._callLoadDataEnd$.pipe( + takeUntil(this._destroyRef$), + map(() => ReadyState.HAVE_CURRENT_DATA) + ).subscribe(this._readyState$); + } + + get canplay$ () { + return this._readyState$.pipe( + filter((s) => { + return s >= ReadyState.HAVE_CURRENT_DATA + }), + distinctUntilChanged(), + map(() => new Event('canplay')), + ) + } + + get canplaythrough$ () { + return this._readyState$.pipe( + filter((s) => s >= ReadyState.HAVE_ENOUGH_DATA), + distinctUntilChanged(), + map(() => new Event('canplaythrough')), + ) + } + + get seeked$ () { + return this._seeked$.asObservable(); + } + + get loadstart$() { + return this._readyState$.pipe( + filter((s) => s === ReadyState.HAVE_ENOUGH_DATA), + distinctUntilChanged(), + map(() => new Event('loadstart')) + ) + } + + get loadedmetadata$() { + return this._readyState$.pipe( + filter((r) => r >= ReadyState.HAVE_METADATA), + distinctUntilChanged(), + map(() => new Event('loadedmetadata')) + ); + } + + get pause$() { + return this._paused$.pipe( + distinctUntilChanged(), + filter(s => s), + map(() => new Event('pause')) + ) + } + + get volumechange$() { + return merge( + this._volume$, + this._muted$, + ).pipe( + map(() => new Event('volumechange')) + ) + } + + get ratechange$() { + return this._playbackRate$.pipe( + map(() => new Event('ratechange')) + ) + } + + get durationchange$() { + return this._duration$.pipe( + map(() => new Event('durationchange')) + ) + } + + get ended$() { + return this._ended$.pipe( + distinctUntilChanged(), + filter(s => s), + map(() => new Event('ended')) + ) + } + + get seeking$() { + return this._setCurrentTime$.pipe( + map(() => new Event('seeking')) + ) + } + + // 属性 getter/setter + get src(): string { + return this._src$.value; + } + + set src(value: string) { + this._setSrc$.next(value); + } + + get currentTime(): number { + return this._currentTime$.value; + } + + set currentTime(value: number) { + if (value < 0 || value > this.duration) { + return + } + this._setCurrentTime$.next( + value + ) + this._seeked$.next(new Event('seeked')); + this._timeupdate$.next(new Event('timeupdate')); + } + + get duration(): number { + return this._duration$.value; + } + + get paused(): boolean { + return this._paused$.value; + } + + get ended(): boolean { + return this._ended$.value; + } + + get volume(): number { + return this._volume$.value; + } + + set volume(value: number) { + if (value < 0 || value > 1) { + return + } + this._volume$.next(value); + } + + get muted(): boolean { + return this._muted$.value; + } + + set muted(value: boolean) { + this._muted$.next(value); + } + + get playbackRate(): number { + return this._playbackRate$.value; + } + + set playbackRate(value: number) { + if (value <= 0) { + return; + } + this._playbackRate$.next(value); + } + + get readyState(): number { + return this._readyState$.value; + } + + get networkState(): number { + return this._networkState$.value; + } + + load(): void { + this._load() + } + + // 方法 + _load(): Observable { + this._callLoadMetadataStart$.next(undefined); + return this._loadMetadata() + .pipe( + tap((metadata) => this._callLoadMetadataEnd$.next(metadata)), + tap((metadata) => this._callLoadDataStart$.next(metadata)), + switchMap((metadata) => this._loadData(metadata)), + tap(() => this._callLoadDataEnd$) + ) + } + + play(): Promise { + if (!this._paused$.value) { + return Promise.resolve() + } + if (this._readyState$.value < ReadyState.HAVE_FUTURE_DATA) { + this._waiting$.next(new Event('waiting')); + return Promise.reject(new Error('Not enough data')); + } + + this._paused$.next(false); + this._play$.next(new Event('play')); + this._playing$.next(new Event('playing')); + + // 模拟播放进度 + this.playbackTimer = this._playbackRate$.pipe( + switchMap(playbackRate => interval(1000 / playbackRate)), + takeUntil( + merge( + this._paused$, + this._destroyRef$, + this._ended$ + ) + ) + ).subscribe(() => { + const newTime = this.currentTime + 1; + if (newTime >= this.duration) { + this._currentTime$.next(this.duration); + this._paused$.next(true); + this._ended$.next(true); + } else { + this._currentTime$.next(newTime); + this._timeupdate$.next(new Event('timeupdate')); + } + }); + + return Promise.resolve(); + } + + pause(): void { + if (this._paused$.value) { + return; + } + this._paused$.next(true); + } + + canPlayType(type: string): string { + // 简化的实现,实际需要根据 MIME 类型检查支持情况 + return type.includes('video/mp4') ? 'probably' : ''; + } + + addTextTrack(kind: string, label: string, language: string): void { + // 实现文本轨道逻辑(此处简化为占位符) + console.log(`Added text track: ${kind}, ${label}, ${language}`); + } + + abstract _seek (): Observable + + abstract _loadMetadata (): Observable + + abstract _loadData(metadata: Metadata): Observable + + [Symbol.dispose]() { + this._destroyRef$.next(undefined) + } +} \ No newline at end of file diff --git a/packages/core/src/elements/state.ts b/packages/core/src/elements/state.ts new file mode 100644 index 0000000..d25584a --- /dev/null +++ b/packages/core/src/elements/state.ts @@ -0,0 +1,14 @@ +export enum NetworkState { + NETWORK_EMPTY = 0, + NETWORK_IDLE = 1, + NETWORK_LOADING = 2, + NETWORK_NO_SOURCE = 3, +} + +export enum ReadyState { + HAVE_NOTHING = 0, + HAVE_METADATA = 1, + HAVE_CURRENT_DATA = 2, + HAVE_FUTURE_DATA = 3, + HAVE_ENOUGH_DATA = 4 +} \ No newline at end of file diff --git a/packages/matroska/src/codecs/index.ts b/packages/matroska/src/codecs/index.ts index cd87b33..018994d 100644 --- a/packages/matroska/src/codecs/index.ts +++ b/packages/matroska/src/codecs/index.ts @@ -1,4 +1,4 @@ -import { UnsupportedCodecError } from '@konoplayer/core/errors'; +import {ParseCodecError, UnsupportedCodecError} from '@konoplayer/core/errors'; import { VideoCodec, AudioCodec } from '@konoplayer/core/codecs'; import type { TrackEntryType } from '../schema'; import { @@ -19,7 +19,7 @@ import { } from './hevc.ts'; import { genCodecStringByVP9DecoderConfigurationRecord, - parseVP9DecoderConfigurationRecord, + parseVP9DecoderConfigurationRecord, VP9_CODEC_TYPE, } from './vp9.ts'; export const VideoCodecId = { @@ -122,9 +122,13 @@ export interface VideoDecoderConfigExt extends VideoDecoderConfig { codecType: VideoCodec; } +export function videoCodecIdRequirePeekingKeyframe(codecId: VideoCodecIdType) { + return codecId === VideoCodecId.VP9 +} + export function videoCodecIdToWebCodecs( track: TrackEntryType, - keyframe: Uint8Array + keyframe: Uint8Array | undefined ): VideoDecoderConfigExt { const codecId = track.CodecID; const codecPrivate = track.CodecPrivate; @@ -141,6 +145,9 @@ export function videoCodecIdToWebCodecs( ), }; case VideoCodecId.VP9: + if (!keyframe) { + throw new ParseCodecError(VP9_CODEC_TYPE, 'keyframe is required to parse VP9 codec') + } return { ...shareOptions, codecType: VideoCodec.VP9, @@ -195,8 +202,15 @@ export interface AudioDecoderConfigExt extends AudioDecoderConfig { codecType: AudioCodec; } +export function isAudioCodecIdRequirePeekingKeyframe ( + _track: TrackEntryType, +) { + return false; +} + export function audioCodecIdToWebCodecs( - track: TrackEntryType + track: TrackEntryType, + _keyframe: Uint8Array | undefined ): AudioDecoderConfigExt { const codecId = track.CodecID; const codecPrivate = track.CodecPrivate; diff --git a/packages/matroska/src/model.ts b/packages/matroska/src/model.ts deleted file mode 100644 index d76ad1a..0000000 --- a/packages/matroska/src/model.ts +++ /dev/null @@ -1,469 +0,0 @@ -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 & { - get segment(): SegmentSystem; -}; - -export function withSegment( - component: T, - segment: SegmentSystem -): SegmentComponent { - const component_ = component as T & { segment: SegmentSystem }; - component_.segment = segment; - return component_; -} - -export class SegmentComponentSystemTrait< - E extends EbmlMasterTagType, - S extends Type, -> { - segment: SegmentSystem; - - get schema(): S { - throw new Error('unimplemented!'); - } - - constructor(segment: SegmentSystem) { - this.segment = segment; - } - - componentFromTag(tag: E): SegmentComponent> { - const extracted = convertEbmlTagToComponent(tag); - const result = this.schema(extracted) as - | (InferType & { 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 = 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; - - 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[] = []; - - addClusterWithTag(tag: EbmlClusterTagType) { - const cluster = this.componentFromTag(tag); - this.clustersBuffer.push(cluster); - return cluster; - } -} - -export interface GetTrackEntryOptions { - priority?: (v: SegmentComponent) => number; - predicate?: (v: SegmentComponent) => boolean; -} - -export interface TrackState { - decoder: Decoder; - configuration?: Config; - frameBuffer$: BehaviorSubject>; -} - -export class TrackSystem extends SegmentComponentSystemTrait< - EbmlTrackEntryTagType, - typeof TrackEntrySchema -> { - override get schema() { - return TrackEntrySchema; - } - - tracks: SegmentComponent[] = []; - videoTrackState = new WeakMap< - TrackEntryType, - TrackState - >(); - audioTrackState = new WeakMap< - TrackEntryType, - TrackState - >(); - - 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()); - 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()); - 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[] = []; - - 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[] = []; - - 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; - } -} diff --git a/packages/matroska/src/reactive.ts b/packages/matroska/src/reactive.ts index bd1d4d3..59edf25 100644 --- a/packages/matroska/src/reactive.ts +++ b/packages/matroska/src/reactive.ts @@ -10,12 +10,11 @@ import { filter, finalize, from, - isEmpty, + isEmpty, last, map, merge, Observable, of, - reduce, scan, share, shareReplay, @@ -28,51 +27,33 @@ 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'; +import {SEEK_ID_KAX_CUES, SEEK_ID_KAX_TAGS, type CueSystem, type SegmentComponent, SegmentSystem} from "./systems"; export interface CreateRangedEbmlStreamOptions extends CreateRangedStreamOptions { - tee?: boolean; } export function createRangedEbmlStream({ url, byteStart = 0, byteEnd, - tee = false, }: CreateRangedEbmlStreamOptions): Observable<{ ebml$: Observable; totalSize?: number; response: Response; body: ReadableStream; controller: AbortController; - teeBody: ReadableStream | undefined; }> { const stream$ = from(createRangedStream({ url, byteStart, byteEnd })); return stream$.pipe( switchMap(({ controller, body, totalSize, response }) => { let requestCompleted = false; - let teeStream: ReadableStream | undefined; - - let stream: ReadableStream; - - if (tee) { - [stream, teeStream] = body.tee(); - } else { - stream = body; - } const originRequest$ = new Observable((subscriber) => { - stream + body .pipeThrough( new EbmlStreamDecoder({ streamStartOffset: byteStart, @@ -130,8 +111,7 @@ export function createRangedEbmlStream({ ebml$, totalSize, response, - body: stream, - teeBody: teeStream, + body, controller, }); }) @@ -149,11 +129,10 @@ export function createEbmlController({ ...options, url, byteStart: 0, - tee: true, }); const controller$ = metaRequest$.pipe( - map(({ totalSize, ebml$, response, controller, teeBody }) => { + map(({ totalSize, ebml$, response, controller }) => { const head$ = ebml$.pipe( filter(isTagIdPos(EbmlTagIdEnum.EBML, EbmlTagPosition.End)), take(1), @@ -174,36 +153,23 @@ export function createEbmlController({ */ const segments$ = segmentStart$.pipe( map((startTag) => { - const segment = new SegmentSystem(startTag, teeBody!); + const segment = new SegmentSystem(startTag); const clusterSystem = segment.cluster; const seekSystem = segment.seek; - const meta$ = ebml$.pipe( + const metaScan$ = 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.segment.scanMeta(tag); acc.tag = tag; return acc; }, - { hasKeyframe: false, tag: undefined as unknown as EbmlTagType } + { + segment, + 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), + takeWhile((acc) => acc.segment.canCompleteMeta(), true), share({ resetOnComplete: false, resetOnError: false, @@ -211,10 +177,13 @@ export function createEbmlController({ }) ); - const withMeta$ = meta$.pipe( - reduce((segment, meta) => segment.scanMeta(meta), segment), - switchMap(() => segment.completeMeta()), - take(1), + const meta$ = metaScan$.pipe( + map(({ tag }) => tag) + ); + + const withMeta$ = metaScan$.pipe( + last(), + switchMap(({ segment }) => segment.completeMeta()), shareReplay(1) ); diff --git a/packages/matroska/src/systems/cluster.ts b/packages/matroska/src/systems/cluster.ts new file mode 100644 index 0000000..0592269 --- /dev/null +++ b/packages/matroska/src/systems/cluster.ts @@ -0,0 +1,20 @@ +import type {EbmlClusterTagType} from "konoebml"; +import {ClusterSchema, type ClusterType} from "../schema"; +import {type SegmentComponent, SegmentComponentSystemTrait} from "./segment"; + +export class ClusterSystem extends SegmentComponentSystemTrait< + EbmlClusterTagType, + typeof ClusterSchema +> { + override get schema() { + return ClusterSchema; + } + + clustersBuffer: SegmentComponent[] = []; + + addClusterWithTag(tag: EbmlClusterTagType) { + const cluster = this.componentFromTag(tag); + this.clustersBuffer.push(cluster); + return cluster; + } +} \ No newline at end of file diff --git a/packages/matroska/src/systems/cue.ts b/packages/matroska/src/systems/cue.ts new file mode 100644 index 0000000..8951b53 --- /dev/null +++ b/packages/matroska/src/systems/cue.ts @@ -0,0 +1,84 @@ +import {type EbmlCuePointTagType, type EbmlCuesTagType, EbmlTagIdEnum} from "konoebml"; +import {CuePointSchema, type CuePointType, type CueTrackPositionsType} from "../schema.ts"; +import {maxBy} from "lodash-es"; +import {type SegmentComponent, SegmentComponentSystemTrait} from "./segment.ts"; + +export class CueSystem extends SegmentComponentSystemTrait< + EbmlCuePointTagType, + typeof CuePointSchema +> { + override get schema() { + return CuePointSchema; + } + + cues: SegmentComponent[] = []; + + 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; + } +} \ No newline at end of file diff --git a/packages/matroska/src/systems/index.ts b/packages/matroska/src/systems/index.ts new file mode 100644 index 0000000..b1df635 --- /dev/null +++ b/packages/matroska/src/systems/index.ts @@ -0,0 +1,7 @@ +export { TrackContext, AudioTrackContext, VideoTrackContext, DefaultTrackContext, type GetTrackEntryOptions, TrackSystem } from './track'; +export { CueSystem } from './cue'; +export { TagSystem } from './tag'; +export { ClusterSystem } from './cluster'; +export { InfoSystem } from './info'; +export { type SegmentComponent, SegmentSystem, SegmentComponentSystemTrait, withSegment } from './segment'; +export { SeekSystem, SEEK_ID_KAX_CUES, SEEK_ID_KAX_INFO, SEEK_ID_KAX_TAGS, SEEK_ID_KAX_TRACKS } from './seek'; \ No newline at end of file diff --git a/packages/matroska/src/systems/info.ts b/packages/matroska/src/systems/info.ts new file mode 100644 index 0000000..c84af72 --- /dev/null +++ b/packages/matroska/src/systems/info.ts @@ -0,0 +1,19 @@ +import type {EbmlInfoTagType} from "konoebml"; +import {InfoSchema, type InfoType} from "../schema.ts"; +import {type SegmentComponent, SegmentComponentSystemTrait} from "./segment.ts"; + +export class InfoSystem extends SegmentComponentSystemTrait< + EbmlInfoTagType, + typeof InfoSchema +> { + override get schema() { + return InfoSchema; + } + + info!: SegmentComponent; + + prepareWithInfoTag(tag: EbmlInfoTagType) { + this.info = this.componentFromTag(tag); + return this; + } +} \ No newline at end of file diff --git a/packages/matroska/src/systems/seek.ts b/packages/matroska/src/systems/seek.ts new file mode 100644 index 0000000..73edc58 --- /dev/null +++ b/packages/matroska/src/systems/seek.ts @@ -0,0 +1,65 @@ +import type {EbmlSeekHeadTagType, EbmlTagType} from "konoebml"; +import {SeekHeadSchema, type SeekHeadType} from "../schema.ts"; +import {isEqual} from "lodash-es"; +import {UnreachableOrLogicError} from "@konoplayer/core/errors.ts"; + +import {SegmentComponentSystemTrait} from "./segment.ts"; + +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 SeekSystem extends SegmentComponentSystemTrait< + EbmlSeekHeadTagType, + typeof SeekHeadSchema +> { + override get schema() { + return SeekHeadSchema; + } + + seekHeads: SeekHeadType[] = []; + private offsetToTagMemo: Map = new Map(); + + memoOffset(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; + } +} \ No newline at end of file diff --git a/packages/matroska/src/systems/segment.ts b/packages/matroska/src/systems/segment.ts new file mode 100644 index 0000000..3b7f96a --- /dev/null +++ b/packages/matroska/src/systems/segment.ts @@ -0,0 +1,156 @@ +import { + type EbmlClusterTagType, + type EbmlMasterTagType, + type EbmlSegmentTagType, + EbmlTagIdEnum, + EbmlTagPosition, + type EbmlTagType +} from "konoebml"; +import {ArkErrors, type Type} from "arktype"; +import {convertEbmlTagToComponent, type InferType} from "../util.ts"; +import {CueSystem} from "./cue.ts"; +import {ClusterSystem} from "./cluster.ts"; +import {SEEK_ID_KAX_CUES, SEEK_ID_KAX_INFO, SEEK_ID_KAX_TAGS, SEEK_ID_KAX_TRACKS, SeekSystem} from "./seek.ts"; +import {InfoSystem} from "./info.ts"; +import {TrackSystem} from "./track.ts"; +import {TagSystem} from "./tag.ts"; +import type {BlockGroupType} from "../schema.ts"; + +export class SegmentSystem { + startTag: EbmlSegmentTagType; + metaTags: 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.metaTags.push(tag); + this.seek.memoOffset(tag); + if (tag.id === EbmlTagIdEnum.Cluster && !this.firstCluster) { + this.firstCluster = tag; + this.seekLocal(); + } + if (this.firstCluster) { + if (tag.id === EbmlTagIdEnum.SimpleBlock && tag.keyframe) { + this.track.tryPeekKeyframe(tag); + } else if (tag.id === EbmlTagIdEnum.BlockGroup) { + const blockGroup = convertEbmlTagToComponent(tag) as BlockGroupType; + // keep frame + if (blockGroup && !blockGroup.ReferenceBlock && blockGroup.Block) { + this.track.tryPeekKeyframe(blockGroup.Block); + } + } + } + return this; + } + + canCompleteMeta() { + const lastTag = this.metaTags.at(-1); + if (!lastTag) { + return false; + } + if (lastTag.id === EbmlTagIdEnum.Segment && lastTag.position === EbmlTagPosition.End) { + return true; + } + return !!(this.firstCluster && this.track.preparedToConfigureTracks()); + } + + async completeMeta() { + this.seekLocal(); + + await this.track.buildTracksConfiguration(); + + return this; + } +} + +export type SegmentComponent = T & { + get segment(): SegmentSystem; +}; + +export function withSegment( + component: T, + segment: SegmentSystem +): SegmentComponent { + const component_ = component as T & { segment: SegmentSystem }; + component_.segment = segment; + return component_; +} + +export class SegmentComponentSystemTrait< + E extends EbmlMasterTagType, + S extends Type, +> { + segment: SegmentSystem; + + get schema(): S { + throw new Error('unimplemented!'); + } + + constructor(segment: SegmentSystem) { + this.segment = segment; + } + + componentFromTag(tag: E): SegmentComponent> { + const extracted = convertEbmlTagToComponent(tag); + const result = this.schema(extracted) as + | (InferType & { 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; + } +} \ No newline at end of file diff --git a/packages/matroska/src/systems/tag.ts b/packages/matroska/src/systems/tag.ts new file mode 100644 index 0000000..7d2ab19 --- /dev/null +++ b/packages/matroska/src/systems/tag.ts @@ -0,0 +1,26 @@ +import {EbmlTagIdEnum, type EbmlTagsTagType, type EbmlTagTagType} from "konoebml"; +import {TagSchema, type TagType} from "../schema.ts"; + +import {type SegmentComponent, SegmentComponentSystemTrait} from "./segment.ts"; + +export class TagSystem extends SegmentComponentSystemTrait< + EbmlTagTagType, + typeof TagSchema +> { + override get schema() { + return TagSchema; + } + + tags: SegmentComponent[] = []; + + 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; + } +} \ No newline at end of file diff --git a/packages/matroska/src/systems/track.ts b/packages/matroska/src/systems/track.ts new file mode 100644 index 0000000..f54f416 --- /dev/null +++ b/packages/matroska/src/systems/track.ts @@ -0,0 +1,160 @@ +import {ParseCodecErrors, UnsupportedCodecError} from "@konoplayer/core/errors.ts"; +import { + EbmlTagIdEnum, + type EbmlTrackEntryTagType, + type EbmlTracksTagType +} from "konoebml"; +import { + audioCodecIdToWebCodecs, + videoCodecIdRequirePeekingKeyframe, + videoCodecIdToWebCodecs, type AudioDecoderConfigExt, type VideoDecoderConfigExt +} from "../codecs"; +import {TrackEntrySchema, type TrackEntryType, TrackTypeRestrictionEnum} from "../schema"; +import {type SegmentComponent, SegmentComponentSystemTrait} from "./segment"; + +export interface GetTrackEntryOptions { + priority?: (v: SegmentComponent) => number; + predicate?: (v: SegmentComponent) => boolean; +} + +export abstract class TrackContext { + peekingKeyframe?: Uint8Array; + trackEntry: TrackEntryType + + constructor(trackEntry: TrackEntryType) { + this.trackEntry = trackEntry; + } + + peekKeyframe (payload: Uint8Array) { + this.peekingKeyframe = payload; + } + + preparedToConfigure () { + if (this.requirePeekKeyframe()) { + return !!this.peekingKeyframe; + } + return true; + } + + abstract requirePeekKeyframe (): boolean; + + abstract buildConfiguration (): Promise; +} + +export class DefaultTrackContext extends TrackContext { + override requirePeekKeyframe(): boolean { + return false; + } + + override async buildConfiguration(): Promise {} +} + +export class VideoTrackContext extends TrackContext { + configuration!: VideoDecoderConfigExt; + + override requirePeekKeyframe (): boolean { + return videoCodecIdRequirePeekingKeyframe(this.trackEntry.CodecID); + } + + async buildConfiguration () { + const configuration = videoCodecIdToWebCodecs(this.trackEntry, this.peekingKeyframe); + if (await VideoDecoder.isConfigSupported(configuration)) { + throw new UnsupportedCodecError(configuration.codec, 'video decoder'); + } + this.configuration = configuration; + } +} + +export class AudioTrackContext extends TrackContext { + configuration!: AudioDecoderConfigExt; + + override requirePeekKeyframe (): boolean { + return videoCodecIdRequirePeekingKeyframe(this.trackEntry.CodecID); + } + + async buildConfiguration () { + const configuration = audioCodecIdToWebCodecs(this.trackEntry, this.peekingKeyframe); + if (await AudioDecoder.isConfigSupported(configuration)) { + throw new UnsupportedCodecError(configuration.codec, 'audio decoder'); + } + + this.configuration = configuration; + } +} + + + +export class TrackSystem extends SegmentComponentSystemTrait< + EbmlTrackEntryTagType, + typeof TrackEntrySchema +> { + override get schema() { + return TrackEntrySchema; + } + + tracks: SegmentComponent[] = []; + trackContexts: Map = new Map(); + + 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); + } + + getTrackContext (options: GetTrackEntryOptions): T | undefined { + const trackEntry = this.getTrackEntry(options); + const trackNum = trackEntry?.TrackNumber!; + return this.trackContexts.get(trackNum) as T | undefined; + } + + prepareTracksWithTag(tag: EbmlTracksTagType) { + this.tracks = tag.children + .filter((c) => c.id === EbmlTagIdEnum.TrackEntry) + .map((c) => this.componentFromTag(c)); + for (const track of this.tracks) { + if (track.TrackType === TrackTypeRestrictionEnum.VIDEO) { + this.trackContexts.set(track.TrackNumber, new VideoTrackContext(track)) + } else if (track.TrackType === TrackTypeRestrictionEnum.AUDIO) { + this.trackContexts.set(track.TrackNumber, new AudioTrackContext(track)) + } + } + return this; + } + + async buildTracksConfiguration () { + const parseErrors = new ParseCodecErrors(); + + for (const context of this.trackContexts.values()) { + try { + await context.buildConfiguration(); + } catch (e) { + parseErrors.cause.push(e as Error); + } + } + if (parseErrors.cause.length > 0) { + console.error(parseErrors); + } + } + + tryPeekKeyframe (tag: { track: number | bigint, frames: Uint8Array[] }) { + for (const c of this.trackContexts.values()) { + if (c.trackEntry.TrackNumber === tag.track) { + c.peekKeyframe(tag.frames?.[0]) + } + } + } + + preparedToConfigureTracks (): boolean { + for (const c of this.trackContexts.values()) { + if (!c.preparedToConfigure()) { + return false; + } + } + return true; + } +} \ No newline at end of file