feat: enhance mkv model type

This commit is contained in:
2025-03-20 03:20:44 +08:00
parent c0d4de4d28
commit 4537190096
17 changed files with 739 additions and 334 deletions

View File

@@ -8,10 +8,11 @@ import {
type EbmlCuesTagType,
type EbmlSeekHeadTagType,
type EbmlSegmentTagType,
type EbmlClusterTagType,
} from 'konoebml';
import { isTagEnd } from './util';
import { isTagIdPos, simpleMasterExtractor } from './util';
import { isEqual } from 'lodash-es';
import { type } from 'arktype';
import { TagWithArktype } from './util';
export const SEEK_ID_KAX_INFO = new Uint8Array([0x15, 0x49, 0xa9, 0x66]);
export const SEEK_ID_KAX_TRACKS = new Uint8Array([0x16, 0x54, 0xae, 0x6b]);
@@ -40,8 +41,7 @@ export class EbmlSegment {
private addSeekHead(node: EbmlSeekHeadTagType) {
this.seekHeadNode = node;
this.seekEntries = this.seekHeadNode.children
.filter(isTagEnd)
.filter((c) => c.id === EbmlTagIdEnum.Seek)
.filter(isTagIdPos(EbmlTagIdEnum.Seek, EbmlTagPosition.End))
.map((c) => {
const seekId = c.children.find(
(item) => item.id === EbmlTagIdEnum.SeekID
@@ -74,7 +74,7 @@ export class EbmlSegment {
findLocalNodeBySeekPosition(
seekPosition: number | undefined
): EbmlTagType | undefined {
return Number.isSafeInteger(seekPosition)
return seekPosition! >= 0
? this.metaOffsets.get(seekPosition as number)
: undefined;
}
@@ -104,6 +104,45 @@ export class EbmlSegment {
}
}
export class TrackEntry extends TagWithArktype({
id: EbmlTagIdEnum.TrackEntry,
schema: type({
trackNumber: 'number',
trackType: 'number',
trackUID: 'number',
}),
extract: simpleMasterExtractor({
[EbmlTagIdEnum.TrackNumber]: {
key: 'trackNumber',
extract: (t) => t.data as number,
},
[EbmlTagIdEnum.TrackType]: {
key: 'trackType',
extract: (t) => t.data as number,
},
[EbmlTagIdEnum.TrackUID]: {
key: 'trackUID',
extract: (t) => t.data as number,
},
}),
}) {}
const TracksSchema = type({
tracks: type.instanceOf(TrackEntry).array(),
});
export class Tracks extends TagWithArktype({
id: EbmlTagIdEnum.Tracks,
schema: TracksSchema,
extract: simpleMasterExtractor({
[EbmlTagIdEnum.TrackEntry]: {
key: 'tracks',
multi: true,
extract: TrackEntry.fromTag.bind(TrackEntry),
},
}),
}) {}
export interface EbmlSeekEntry {
seekId: Uint8Array;
seekPosition: number;
@@ -117,33 +156,59 @@ export class EbmlHead {
}
}
export class EbmlCluster {
cluster: EbmlClusterTagType;
_timestamp: number;
export class SimpleBlock extends TagWithArktype({
id: EbmlTagIdEnum.SimpleBlock,
schema: type({
frame: type.instanceOf(Uint8Array),
}),
extract: (tag) => ({
frame: tag.payload,
}),
}) {}
constructor(cluster: EbmlClusterTagType) {
this.cluster = cluster;
this._timestamp = cluster.children.find(
(c) => c.id === EbmlTagIdEnum.Timecode
)?.data as number;
}
export class Cluster extends TagWithArktype({
id: EbmlTagIdEnum.Cluster,
schema: type({
timestamp: 'number',
position: 'number?',
prevSize: 'number?',
simpleBlock: type.instanceOf(SimpleBlock).array(),
}),
extract: simpleMasterExtractor({
[EbmlTagIdEnum.Timecode]: {
key: 'timestamp',
extract: (t) => t.data as number,
},
[EbmlTagIdEnum.PrevSize]: {
key: 'prevSize',
extract: (t) => t.data as number,
},
[EbmlTagIdEnum.SimpleBlock]: {
key: 'simpleBlock',
multi: true,
extract: SimpleBlock.fromTag.bind(SimpleBlock),
},
}),
}) {}
get timestamp(): number {
return this._timestamp;
}
export interface TrackPositions {
track: number;
clusterPosition: number;
relativePosition?: number;
duration?: number;
}
export class EbmlCue {
export class CuePoint {
node: EbmlCuePointTagType;
_timestamp: number;
trackPositions: { track: number; position: number }[];
trackPositions: TrackPositions[];
get timestamp(): number {
return this._timestamp;
}
get position(): number {
return Math.max(...this.trackPositions.map((t) => t.position));
return Math.max(...this.trackPositions.map((t) => t.clusterPosition));
}
constructor(node: EbmlCuePointTagType) {
@@ -151,38 +216,64 @@ export class EbmlCue {
this._timestamp = node.children.find((c) => c.id === EbmlTagIdEnum.CueTime)
?.data as number;
this.trackPositions = node.children
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: <explanation>
.map((t) => {
if (
t.id === EbmlTagIdEnum.CueTrackPositions &&
t.position === EbmlTagPosition.End
) {
const track = t.children.find((t) => t.id === EbmlTagIdEnum.CueTrack)
?.data as number;
const position = t.children.find(
(t) => t.id === EbmlTagIdEnum.CueClusterPosition
)?.data as number;
let track!: number;
let clusterPosition!: number;
let relativePosition: number | undefined;
let duration: number | undefined;
return track! >= 0 && position! >= 0 ? { track, position } : null;
for (const c of t.children) {
if (c.id === EbmlTagIdEnum.CueTrack) {
track = c.data as number;
}
if (c.id === EbmlTagIdEnum.CueClusterPosition) {
clusterPosition = c.data as number;
}
if (c.id === EbmlTagIdEnum.CueRelativePosition) {
relativePosition = c.data as number;
}
if (c.id === EbmlTagIdEnum.CueDuration) {
duration = c.data as number;
}
}
if (track! >= 0 && clusterPosition! >= 0) {
return {
track: track!,
clusterPosition: clusterPosition!,
relativePosition,
duration,
} as TrackPositions;
}
throw new Error(
`Tracking positions missing track of cluster position at ${t.startOffset}`
);
}
return null;
})
.filter((a): a is { track: number; position: number } => !!a);
.filter((a): a is TrackPositions => !!a);
}
}
export class EbmlCues {
node: EbmlCuesTagType;
cues: EbmlCue[];
constructor(node: EbmlCuesTagType) {
this.node = node;
this.cues = node.children
.filter(isTagEnd)
.filter((c) => c.id === EbmlTagIdEnum.CuePoint)
.map((c) => new EbmlCue(c));
}
findClosestCue(seekTime: number): EbmlCue | null {
export class Cues extends TagWithArktype({
id: EbmlTagIdEnum.Cues,
schema: type({
cues: type.instanceOf(CuePoint).array(),
}),
extract: simpleMasterExtractor({
[EbmlTagIdEnum.CuePoint]: {
key: 'cues',
multi: true,
extract: (t) => new CuePoint(t),
},
}),
}) {
findClosestCue(seekTime: number): CuePoint | null {
const cues = this.cues;
if (!cues || cues.length === 0) {
return null;

View File

@@ -0,0 +1,327 @@
import {
type EbmlTagType,
EbmlStreamDecoder,
EbmlTagIdEnum,
EbmlTagPosition,
} from 'konoebml';
import {
Observable,
from,
switchMap,
share,
defer,
EMPTY,
of,
filter,
finalize,
isEmpty,
map,
merge,
raceWith,
reduce,
scan,
shareReplay,
take,
takeUntil,
withLatestFrom,
} from 'rxjs';
import { createRangedStream } from '@/fetch';
import { EbmlSegment, Cluster, SEEK_ID_KAX_CUES, Cues } from './model';
import { isTagIdPos } from './util';
export function createRangedEbmlStream(
url: string,
byteStart = 0,
byteEnd?: number
): Observable<{
ebml$: Observable<EbmlTagType>;
totalSize?: number;
response: Response;
body: ReadableStream<Uint8Array>;
controller: AbortController;
}> {
const stream$ = from(createRangedStream(url, byteStart, byteEnd));
return stream$.pipe(
switchMap(({ controller, body, totalSize, response }) => {
let requestCompleted = false;
const originRequest$ = new Observable<EbmlTagType>((subscriber) => {
body
.pipeThrough(
new EbmlStreamDecoder({
streamStartOffset: byteStart,
collectChild: (child) => child.id !== EbmlTagIdEnum.Cluster,
})
)
.pipeTo(
new WritableStream({
write: (tag) => subscriber.next(tag),
close: () => {
if (!requestCompleted) {
subscriber.complete();
}
},
})
)
.catch((error) => {
if (requestCompleted && error?.name === 'AbortError') {
return;
}
subscriber.error(error);
});
return () => {
requestCompleted = true;
controller.abort();
};
}).pipe(
share({
resetOnComplete: false,
resetOnError: false,
resetOnRefCountZero: true,
})
);
const ebml$ = defer(() =>
requestCompleted ? EMPTY : originRequest$
).pipe(
share({
resetOnError: false,
resetOnComplete: true,
resetOnRefCountZero: true,
})
);
return of({
ebml$,
totalSize,
response,
body,
controller,
});
})
);
}
export function createEbmlController(src: string) {
const request$ = createRangedEbmlStream(src, 0);
const controller$ = request$.pipe(
map(({ totalSize, ebml$, response, controller }) => {
const head$ = ebml$.pipe(
filter(isTagIdPos(EbmlTagIdEnum.EBML, EbmlTagPosition.End)),
take(1),
shareReplay(1)
);
console.debug(
`stream of video "${src}" created, total size is ${totalSize ?? 'unknown'}`
);
const segmentStart$ = ebml$.pipe(
filter((s) => s.position === EbmlTagPosition.Start),
filter((tag) => tag.id === EbmlTagIdEnum.Segment)
);
const segments$ = segmentStart$.pipe(
map((startTag) => {
const segment = new EbmlSegment(startTag);
const continuousReusedCluster$ = ebml$.pipe(
filter(isTagIdPos(EbmlTagIdEnum.Cluster, EbmlTagPosition.End)),
filter((s) => s.id === EbmlTagIdEnum.Cluster),
map(Cluster.fromTag.bind(Cluster))
);
const segmentEnd$ = ebml$.pipe(
filter(isTagIdPos(EbmlTagIdEnum.Segment, EbmlTagPosition.End)),
filter((tag) => tag.id === EbmlTagIdEnum.Segment),
take(1)
);
const clusterStart$ = ebml$.pipe(
filter(isTagIdPos(EbmlTagIdEnum.Cluster, EbmlTagPosition.Start)),
take(1),
shareReplay(1)
);
const meta$ = ebml$.pipe(
takeUntil(clusterStart$.pipe(raceWith(segmentEnd$))),
share({
resetOnComplete: false,
resetOnError: false,
resetOnRefCountZero: true,
})
);
const withMeta$ = meta$.pipe(
reduce((segment, meta) => {
segment.scanMeta(meta);
return segment;
}, segment),
map((segment) => {
segment.markMetaEnd();
return segment;
}),
take(1),
shareReplay(1)
);
const withRemoteCues$ = withMeta$.pipe(
switchMap((s) => {
if (s.cuesNode) {
return EMPTY;
}
const cuesStartOffset =
s.dataOffset +
(s.findSeekPositionBySeekId(SEEK_ID_KAX_CUES) ?? Number.NaN);
if (cuesStartOffset >= 0) {
return createRangedEbmlStream(src, cuesStartOffset).pipe(
switchMap((req) => req.ebml$),
filter(isTagIdPos(EbmlTagIdEnum.Cues, EbmlTagPosition.End)),
withLatestFrom(withMeta$),
map(([cues, withMeta]) => {
withMeta.cuesNode = cues;
return withMeta;
})
);
}
return EMPTY;
}),
take(1),
shareReplay(1)
);
const withLocalCues$ = withMeta$.pipe(
switchMap((s) => {
if (s.cuesNode) {
return of(s);
}
return EMPTY;
}),
shareReplay(1)
);
const withCues$ = merge(withLocalCues$, withRemoteCues$).pipe(
take(1)
);
const withoutCues$ = withCues$.pipe(
isEmpty(),
switchMap((empty) => (empty ? withMeta$ : EMPTY))
);
const seekWithoutCues = (seekTime: number): Observable<Cluster> => {
const cluster$ = continuousReusedCluster$.pipe(
isEmpty(),
switchMap((empty) => {
return empty
? clusterStart$.pipe(
switchMap((startTag) =>
createRangedEbmlStream(src, startTag.startOffset)
),
switchMap((req) => req.ebml$),
filter(
isTagIdPos(EbmlTagIdEnum.Cluster, EbmlTagPosition.End)
),
map(Cluster.fromTag.bind(Cluster))
)
: continuousReusedCluster$;
})
);
if (seekTime === 0) {
return cluster$;
}
return cluster$.pipe(
scan(
(prev, curr) =>
[prev?.[1], curr] as [
Cluster | undefined,
Cluster | undefined,
],
[undefined, undefined] as [
Cluster | undefined,
Cluster | undefined,
]
),
filter((c) => c[1]?.timestamp! > seekTime),
map((c) => c[0] ?? c[1]!)
);
};
const seekWithCues = (
cues: Cues,
seekTime: number
): Observable<Cluster> => {
if (seekTime === 0) {
return seekWithoutCues(seekTime);
}
const cuePoint = cues.findClosestCue(seekTime);
if (!cuePoint) {
return seekWithoutCues(seekTime);
}
return createRangedEbmlStream(
src,
cuePoint.position + segment.dataOffset
).pipe(
switchMap((req) => req.ebml$),
filter(isTagIdPos(EbmlTagIdEnum.Cluster, EbmlTagPosition.End)),
map(Cluster.fromTag.bind(Cluster))
);
};
const seek = (seekTime: number): Observable<Cluster> => {
if (seekTime === 0) {
const subscripton = merge(withCues$, withoutCues$).subscribe();
// if seekTime equals to 0 at start, reuse the initialize stream
return seekWithoutCues(seekTime).pipe(
finalize(() => {
subscripton.unsubscribe();
})
);
}
return merge(
withCues$.pipe(
switchMap((s) =>
seekWithCues(Cues.fromTag(s.cuesNode!), seekTime)
)
),
withoutCues$.pipe(switchMap((_) => seekWithoutCues(seekTime)))
);
};
return {
startTag,
head$,
segment,
meta$,
withMeta$,
withCues$,
withoutCues$,
seekWithCues,
seekWithoutCues,
seek,
};
})
);
return {
segments$,
head$,
totalSize,
ebml$,
controller,
response,
};
})
);
return {
controller$,
request$,
};
}

View File

@@ -1,5 +1,150 @@
import { EbmlTagPosition, type EbmlTagType } from 'konoebml';
import type { Type } from 'arktype';
import type { EbmlMasterTagType, EbmlTagIdEnum, EbmlTagType } from 'konoebml';
export function isTagEnd(tag: EbmlTagType): boolean {
return tag.position === EbmlTagPosition.End;
export type InferType<T> = T extends Type<infer U> ? U : never;
export interface TagWithArktypeOptions<
I extends EbmlTagType['id'],
S extends Type<any>,
> {
id: I;
schema: S;
extract: (tag: Extract<EbmlTagType, { id: I }>, schema: S) => InferType<S>;
}
export type TagWithArktypeClassInstance<
I extends EbmlTagType['id'],
S extends Type<any>,
> = InferType<S> & {
tag: Extract<EbmlTagType, { id: I }>;
};
export interface TagWithArktypeClass<
I extends EbmlTagType['id'],
S extends Type<any>,
> {
new (
tag: Extract<EbmlTagType, { id: I }>,
validatedTag: InferType<S>
): TagWithArktypeClassInstance<I, S>;
fromTag<R extends TagWithArktypeClassInstance<I, S>>(
this: new (
tag: Extract<EbmlTagType, { id: I }>,
validatedTag: InferType<S>
) => TagWithArktypeClassInstance<I, S>,
tag: Extract<EbmlTagType, { id: I }>
): R;
id: I;
schema: S;
}
export function TagWithArktype<
I extends EbmlTagType['id'],
S extends Type<any>,
>({
id,
schema,
extract,
}: TagWithArktypeOptions<I, S>): TagWithArktypeClass<I, S> {
const tagWithArktypeImpl = class TagWithArktypeImpl {
static id = id;
static schema = schema;
tag: Extract<EbmlTagType, { id: I }>;
constructor(
tag: Extract<EbmlTagType, { id: I }>,
validatedTag: InferType<S>
) {
Object.assign(this, validatedTag);
this.tag = tag;
}
static fromTag(tag: Extract<EbmlTagType, { id: I }>) {
const extractedData = extract(tag, schema);
const validatedExtractedData = schema(extractedData);
// biome-ignore lint/complexity/noThisInStatic: <explanation>
return new this(tag, validatedExtractedData);
}
};
return tagWithArktypeImpl as unknown as TagWithArktypeClass<I, S>;
}
export type PredicateIdExtract<T, K> = Extract<T, { id: K }>;
export type PredicatePositionExtract<
T extends { position: string },
P,
> = P extends T['position'] ? T : never;
export function isTagIdPos<
I extends EbmlTagIdEnum,
P extends PredicateIdExtract<EbmlTagType, I>['position'] | '*' = '*',
>(id: I, pos?: P) {
return (tag: EbmlTagType): tag is PredicateIdExtract<EbmlTagType, I> =>
tag.id === id && (pos === '*' || pos === tag.position);
}
export function isTagPos<
T extends { position: string },
P extends T['position'],
>(pos: P | '*' = '*') {
return (tag: T): tag is PredicatePositionExtract<T, P> =>
pos === '*' || pos === tag.position;
}
export type MasterChildExtractMap<T, K> = {
[id in EbmlTagIdEnum]?: K extends keyof T
?
| {
key: K;
multi: true;
extract: (
tag: Extract<EbmlTagType, { id: id }>
) => T[K] extends Array<infer U> ? U : never;
}
| {
key: K;
multi?: false;
extract: (tag: Extract<EbmlTagType, { id: id }>) => T[K];
}
: never;
};
export function simpleMasterExtractor<
T extends EbmlMasterTagType,
S extends Type<any>,
EM extends MasterChildExtractMap<InferType<S>, keyof InferType<S>>,
>(map: EM) {
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: <explanation>
return (tag: T, _schema: S): InferType<S> => {
if (!tag?.children?.length) {
return {} as unknown as InferType<S>;
}
const value = {} as Record<string, any>;
for (const c of tag.children) {
const entry = (
map as unknown as Record<
string,
{ id: number; multi: boolean; extract: (tag: any) => any }
>
)[c.id as number] as any;
if (entry?.key) {
const key = entry.key;
const item = entry.extract ? entry.extract(c) : c.data;
if (entry.multi) {
if (value[key]) {
value[key].push(item);
} else {
value[key] = [item];
}
} else {
value[key] = item;
}
}
}
return value as unknown as InferType<S>;
};
}