mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-28 03:13:51 +02:00
Add HLS reading tests, InputTrack.hasOnlyKeyPackets, faster whole-file reading
This commit is contained in:
+4
-4
@@ -17,11 +17,11 @@ export abstract class Demuxer {
|
||||
this.input = input;
|
||||
}
|
||||
|
||||
async isSupported() {
|
||||
return true;
|
||||
}
|
||||
|
||||
abstract getTracks(): Promise<InputTrack[]>;
|
||||
abstract getMimeType(): Promise<string>;
|
||||
abstract getMetadataTags(): Promise<MetadataTags>;
|
||||
|
||||
dispose() {
|
||||
// Can be overridden
|
||||
}
|
||||
}
|
||||
|
||||
+57
-25
@@ -14,7 +14,7 @@ import { PacketRetrievalOptions } from '../media-sink';
|
||||
import { DEFAULT_TRACK_DISPOSITION, MetadataTags, TrackDisposition } from '../metadata';
|
||||
import { assert, joinPaths, Rotation, UNDETERMINED_LANGUAGE } from '../misc';
|
||||
import { EncodedPacket } from '../packet';
|
||||
import { LineReader } from '../reader';
|
||||
import { readAllLines } from '../reader';
|
||||
import { AttributeList, canIgnoreLine } from './hls-misc';
|
||||
import { HlsSegmentedInput } from './hls-segmented-input';
|
||||
|
||||
@@ -34,6 +34,7 @@ type InternalTrack = {
|
||||
peakBitrate: number | null;
|
||||
averageBitrate: number | null;
|
||||
name: string | null;
|
||||
hasOnlyKeyPackets: boolean;
|
||||
|
||||
info: {
|
||||
type: 'video';
|
||||
@@ -49,30 +50,26 @@ type InternalAudioTrack = InternalTrack & { info: { type: 'audio' } };
|
||||
|
||||
export class HlsDemuxer extends Demuxer {
|
||||
metadataPromise: Promise<void> | null = null;
|
||||
lineReader: LineReader;
|
||||
tracks: InputTrack[] = [];
|
||||
segmentedInputs: HlsSegmentedInput[] = [];
|
||||
|
||||
constructor(input: Input) {
|
||||
super(input);
|
||||
this.lineReader = new LineReader(() => input._reader, canIgnoreLine);
|
||||
}
|
||||
|
||||
readMetadata() {
|
||||
return this.metadataPromise ??= (async () => {
|
||||
assert(this.input._entryPath !== null);
|
||||
|
||||
let line = this.lineReader.readNextLine();
|
||||
if (line instanceof Promise) line = await line;
|
||||
|
||||
if (line !== '#EXTM3U') {
|
||||
throw new Error('Invalid M3U8 file; expected first line to be #EXTM3U.');
|
||||
}
|
||||
const slice = await this.input._reader.requestEntireFile();
|
||||
assert(slice);
|
||||
const lines = readAllLines(slice, slice.length, { ignore: canIgnoreLine });
|
||||
|
||||
const variantStreams: {
|
||||
fullPath: string;
|
||||
attributes: AttributeList;
|
||||
lineNumber: number;
|
||||
hasOnlyKeyPackets: boolean;
|
||||
}[] = [];
|
||||
const mediaTags: {
|
||||
fullPath: string | null;
|
||||
@@ -82,20 +79,13 @@ export class HlsDemuxer extends Demuxer {
|
||||
|
||||
// Let's first iterate through the entire file, collecting all variant streams and media tags
|
||||
|
||||
while (true) {
|
||||
let line = this.lineReader.readNextLine();
|
||||
if (line instanceof Promise) line = await line;
|
||||
|
||||
if (line === null) {
|
||||
break;
|
||||
}
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
|
||||
if (line.startsWith('#EXT-X-STREAM-INF:')) {
|
||||
const streamInfLineNumber = this.lineReader.currentLineNumber;
|
||||
let playlistPath = this.lineReader.readNextLine();
|
||||
if (playlistPath instanceof Promise) playlistPath = await playlistPath;
|
||||
|
||||
if (playlistPath === null) {
|
||||
const streamInfLineNumber = i;
|
||||
const playlistPath = lines[++i];
|
||||
if (playlistPath === undefined) {
|
||||
throw new Error('Incorrect M3U8 file; a line must follow the #EXT-X-STREAM-INF tag.');
|
||||
}
|
||||
|
||||
@@ -110,7 +100,12 @@ export class HlsDemuxer extends Demuxer {
|
||||
);
|
||||
}
|
||||
|
||||
variantStreams.push({ fullPath: fullPath, attributes, lineNumber: streamInfLineNumber });
|
||||
variantStreams.push({
|
||||
fullPath,
|
||||
attributes,
|
||||
lineNumber: streamInfLineNumber,
|
||||
hasOnlyKeyPackets: false,
|
||||
});
|
||||
} else if (line.startsWith('#EXT-X-I-FRAME-STREAM-INF:')) {
|
||||
const attributes = new AttributeList(line.slice(18));
|
||||
const playlistPath = attributes.get('uri');
|
||||
@@ -123,7 +118,12 @@ export class HlsDemuxer extends Demuxer {
|
||||
|
||||
const fullPath = joinPaths(this.input._entryPath, playlistPath);
|
||||
|
||||
variantStreams.push({ fullPath, attributes, lineNumber: this.lineReader.currentLineNumber });
|
||||
variantStreams.push({
|
||||
fullPath,
|
||||
attributes,
|
||||
lineNumber: i,
|
||||
hasOnlyKeyPackets: true,
|
||||
});
|
||||
} else if (line.startsWith('#EXT-X-MEDIA:')) {
|
||||
const attributes = new AttributeList(line.slice(13));
|
||||
|
||||
@@ -147,12 +147,12 @@ export class HlsDemuxer extends Demuxer {
|
||||
fullPath = joinPaths(this.input._entryPath, uri);
|
||||
}
|
||||
|
||||
mediaTags.push({ fullPath, attributes, lineNumber: this.lineReader.currentLineNumber });
|
||||
mediaTags.push({ fullPath, attributes, lineNumber: i });
|
||||
} else if (line === '#EXT-X-I-FRAMES-ONLY') {
|
||||
// iFramesOnlyTagFound = true;
|
||||
} else if (line.startsWith('#EXTINF:')) {
|
||||
// This is a media playlist, not a master playlist
|
||||
const segmentedInput = new HlsSegmentedInput(this, this.input._entryPath, this.input._reader);
|
||||
const segmentedInput = new HlsSegmentedInput(this, this.input._entryPath, lines);
|
||||
this.segmentedInputs.push(segmentedInput);
|
||||
|
||||
const input = segmentedInput.toInput();
|
||||
@@ -346,6 +346,7 @@ export class HlsDemuxer extends Demuxer {
|
||||
peakBitrate: bandwidth,
|
||||
averageBitrate: averageBandwidth,
|
||||
name,
|
||||
hasOnlyKeyPackets: variantStream.hasOnlyKeyPackets,
|
||||
info: {
|
||||
type: 'video',
|
||||
width,
|
||||
@@ -396,6 +397,7 @@ export class HlsDemuxer extends Demuxer {
|
||||
peakBitrate: null,
|
||||
averageBitrate: null,
|
||||
name: mediaTag.attributes.get('name'),
|
||||
hasOnlyKeyPackets: variantStream.hasOnlyKeyPackets,
|
||||
info: {
|
||||
type: 'video',
|
||||
width,
|
||||
@@ -437,6 +439,7 @@ export class HlsDemuxer extends Demuxer {
|
||||
peakBitrate: bandwidth,
|
||||
averageBitrate: averageBandwidth,
|
||||
name,
|
||||
hasOnlyKeyPackets: variantStream.hasOnlyKeyPackets,
|
||||
info: {
|
||||
type: 'audio',
|
||||
numberOfChannels:
|
||||
@@ -484,6 +487,7 @@ export class HlsDemuxer extends Demuxer {
|
||||
peakBitrate: null,
|
||||
averageBitrate: null,
|
||||
name: mediaTag.attributes.get('name'),
|
||||
hasOnlyKeyPackets: variantStream.hasOnlyKeyPackets,
|
||||
info: {
|
||||
type: 'audio',
|
||||
numberOfChannels:
|
||||
@@ -682,6 +686,10 @@ abstract class HlsInputTrackBacking implements InputTrackBacking {
|
||||
return this.internalTrack.averageBitrate;
|
||||
}
|
||||
|
||||
getHasOnlyKeyPackets() {
|
||||
return this.internalTrack.hasOnlyKeyPackets || null;
|
||||
}
|
||||
|
||||
getFirstPacket(options: PacketRetrievalOptions): Promise<EncodedPacket | null> {
|
||||
if (!this.internalTrack.backingTrack) {
|
||||
throw new TrackNotHydratedError();
|
||||
@@ -774,10 +782,18 @@ class HlsInputVideoTrackBacking
|
||||
}
|
||||
|
||||
getDisplayWidth(): number | null {
|
||||
if (this.backingTrack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.internalTrack.info.width;
|
||||
}
|
||||
|
||||
getDisplayHeight(): number | null {
|
||||
if (this.backingTrack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.internalTrack.info.height;
|
||||
}
|
||||
|
||||
@@ -805,6 +821,14 @@ class HlsInputVideoTrackBacking
|
||||
return this.backingTrack._backing.canBeTransparent();
|
||||
}
|
||||
|
||||
getCodecParameterString(): Promise<string | null> {
|
||||
if (!this.backingTrack) {
|
||||
return Promise.resolve(this.internalTrack.fullCodecString);
|
||||
}
|
||||
|
||||
return this.backingTrack.getCodecParameterString();
|
||||
}
|
||||
|
||||
getDecoderConfig(): Promise<VideoDecoderConfig | null> {
|
||||
if (!this.backingTrack) {
|
||||
throw new TrackNotHydratedError();
|
||||
@@ -852,6 +876,14 @@ class HlsInputAudioTrackBacking
|
||||
return this.backingTrack._backing.getSampleRate();
|
||||
}
|
||||
|
||||
getCodecParameterString(): Promise<string | null> {
|
||||
if (!this.backingTrack) {
|
||||
return Promise.resolve(this.internalTrack.fullCodecString);
|
||||
}
|
||||
|
||||
return this.backingTrack.getCodecParameterString();
|
||||
}
|
||||
|
||||
getDecoderConfig(): Promise<AudioDecoderConfig | null> {
|
||||
if (!this.backingTrack) {
|
||||
throw new TrackNotHydratedError();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AES_128_BLOCK_SIZE } from '../aes';
|
||||
import { Segment, SegmentEncryptionInfo, SegmentLocation } from '../segment';
|
||||
import { SegmentedInput } from '../segmented-input';
|
||||
import { toDataView, joinPaths, last } from '../misc';
|
||||
import { LineReader, Reader } from '../reader';
|
||||
import { toDataView, joinPaths, last, assert } from '../misc';
|
||||
import { readAllLines, Reader } from '../reader';
|
||||
import { HlsDemuxer } from './hls-demuxer';
|
||||
import { AttributeList, canIgnoreLine } from './hls-misc';
|
||||
|
||||
@@ -11,7 +11,6 @@ const IV_STRING_REGEX = /^0[xX][0-9a-fA-F]+$/;
|
||||
export class HlsSegmentedInput extends SegmentedInput {
|
||||
demuxer: HlsDemuxer;
|
||||
nextSegmentDuration: number | null = null;
|
||||
nextSegmentTitle: string | null = null;
|
||||
accumulatedTime = 0;
|
||||
headerRead = false;
|
||||
segmentsPromise: Promise<Segment[]>;
|
||||
@@ -26,32 +25,26 @@ export class HlsSegmentedInput extends SegmentedInput {
|
||||
constructor(
|
||||
demuxer: HlsDemuxer,
|
||||
path: string,
|
||||
reader: Reader | null,
|
||||
lines: string[] | null,
|
||||
) {
|
||||
super(demuxer.input, path);
|
||||
|
||||
this.demuxer = demuxer;
|
||||
|
||||
let lineReader: LineReader;
|
||||
if (reader) {
|
||||
lineReader = new LineReader(() => reader, canIgnoreLine);
|
||||
} else {
|
||||
lineReader = new LineReader(async () => {
|
||||
const source = await this.demuxer.input._getSourceUncached({ path: this.path });
|
||||
return new Reader(source);
|
||||
}, canIgnoreLine);
|
||||
}
|
||||
|
||||
this.segmentsPromise ??= (async () => {
|
||||
if (!lines) {
|
||||
const source = await demuxer.input._getSourceUncached({ path: this.path });
|
||||
const reader = new Reader(source);
|
||||
|
||||
const slice = await reader.requestEntireFile();
|
||||
assert(slice);
|
||||
lines = readAllLines(slice, slice.length, { ignore: canIgnoreLine });
|
||||
}
|
||||
|
||||
const segments: Segment[] = [];
|
||||
|
||||
while (true) {
|
||||
let line = lineReader.readNextLine();
|
||||
if (line instanceof Promise) line = await line;
|
||||
|
||||
if (line === null) {
|
||||
break;
|
||||
}
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
|
||||
if (!this.headerRead) {
|
||||
if (line !== '#EXTM3U') {
|
||||
@@ -94,7 +87,6 @@ export class HlsSegmentedInput extends SegmentedInput {
|
||||
this.accumulatedTime,
|
||||
this.lastProgramDateTimeSeconds !== null,
|
||||
this.nextSegmentDuration,
|
||||
this.nextSegmentTitle,
|
||||
key,
|
||||
this.currentFirstSegment,
|
||||
this.currentInitSegment,
|
||||
@@ -105,7 +97,6 @@ export class HlsSegmentedInput extends SegmentedInput {
|
||||
this.currentFirstSegment ??= segment;
|
||||
|
||||
this.nextSegmentDuration = null;
|
||||
this.nextSegmentTitle = null;
|
||||
|
||||
if (this.nextByteRange === null) {
|
||||
this.lastByteRangeEnd = null;
|
||||
@@ -122,10 +113,8 @@ export class HlsSegmentedInput extends SegmentedInput {
|
||||
if (!Number.isFinite(duration) || duration < 0) {
|
||||
throw new Error(`Invalid #EXTINF tag duration '${durationStr}'.`);
|
||||
}
|
||||
const title = commaIndex === -1 ? null : extinfContent.slice(commaIndex + 1).trim() || null;
|
||||
|
||||
this.nextSegmentDuration = duration;
|
||||
this.nextSegmentTitle = title;
|
||||
} else if (line.startsWith('#EXT-X-MAP:')) {
|
||||
const attributes = new AttributeList(line.slice(11));
|
||||
const uri = attributes.get('uri');
|
||||
@@ -156,7 +145,6 @@ export class HlsSegmentedInput extends SegmentedInput {
|
||||
this.accumulatedTime,
|
||||
this.lastProgramDateTimeSeconds !== null,
|
||||
0,
|
||||
null,
|
||||
this.currentKey,
|
||||
null,
|
||||
null,
|
||||
@@ -166,7 +154,6 @@ export class HlsSegmentedInput extends SegmentedInput {
|
||||
this.currentInitSegment = segment;
|
||||
|
||||
this.nextSegmentDuration = null;
|
||||
this.nextSegmentTitle = null;
|
||||
|
||||
if (this.nextByteRange === null) {
|
||||
this.lastByteRangeEnd = null;
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface InputTrackBacking {
|
||||
getPairingMask(): bigint;
|
||||
getBitrate(): number | null;
|
||||
getAverageBitrate(): number | null;
|
||||
getHasOnlyKeyPackets?(): boolean | null;
|
||||
|
||||
getFirstPacket(options: PacketRetrievalOptions): Promise<EncodedPacket | null>;
|
||||
getPacket(timestamp: number, options: PacketRetrievalOptions): Promise<EncodedPacket | null>;
|
||||
@@ -87,6 +88,8 @@ export abstract class InputTrack {
|
||||
* into its bitstream. Returns null if the type couldn't be determined.
|
||||
*/
|
||||
abstract determinePacketType(packet: EncodedPacket): Promise<PacketType | null>;
|
||||
/** Whether the track metadata says that this track only contains key packets. The actual packets may differ. */
|
||||
abstract get hasOnlyKeyPackets(): boolean;
|
||||
|
||||
/** Returns true if and only if this track is a video track. */
|
||||
isVideoTrack(): this is InputVideoTrack {
|
||||
@@ -365,6 +368,7 @@ export interface InputVideoTrackBacking extends InputTrackBacking {
|
||||
getColorSpace(): Promise<VideoColorSpaceInit>;
|
||||
canBeTransparent(): Promise<boolean>;
|
||||
getDecoderConfig(): Promise<VideoDecoderConfig | null>;
|
||||
getCodecParameterString?(): Promise<string | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -393,6 +397,10 @@ export class InputVideoTrack extends InputTrack {
|
||||
return this._backing.getCodec();
|
||||
}
|
||||
|
||||
get hasOnlyKeyPackets() {
|
||||
return this._backing.getHasOnlyKeyPackets?.() ?? false;
|
||||
}
|
||||
|
||||
/** The width in pixels of the track's coded samples, before any transformations or rotations. */
|
||||
get codedWidth() {
|
||||
return this._backing.getCodedWidth();
|
||||
@@ -492,6 +500,10 @@ export class InputVideoTrack extends InputTrack {
|
||||
}
|
||||
|
||||
async getCodecParameterString() {
|
||||
if (this._backing.getCodecParameterString) {
|
||||
return this._backing.getCodecParameterString();
|
||||
}
|
||||
|
||||
if (!this.isHydrated) {
|
||||
await this.hydrate();
|
||||
}
|
||||
@@ -554,6 +566,7 @@ export interface InputAudioTrackBacking extends InputTrackBacking {
|
||||
getNumberOfChannels(): number;
|
||||
getSampleRate(): number;
|
||||
getDecoderConfig(): Promise<AudioDecoderConfig | null>;
|
||||
getCodecParameterString?(): Promise<string | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -580,6 +593,10 @@ export class InputAudioTrack extends InputTrack {
|
||||
return this._backing.getCodec();
|
||||
}
|
||||
|
||||
get hasOnlyKeyPackets() {
|
||||
return this._backing.getHasOnlyKeyPackets?.() ?? true;
|
||||
}
|
||||
|
||||
/** The number of audio channels in the track. */
|
||||
get numberOfChannels() {
|
||||
return this._backing.getNumberOfChannels();
|
||||
@@ -604,6 +621,10 @@ export class InputAudioTrack extends InputTrack {
|
||||
}
|
||||
|
||||
async getCodecParameterString() {
|
||||
if (this._backing.getCodecParameterString) {
|
||||
return this._backing.getCodecParameterString();
|
||||
}
|
||||
|
||||
if (!this.isHydrated) {
|
||||
await this.hydrate();
|
||||
}
|
||||
|
||||
+26
-7
@@ -22,7 +22,6 @@ import { Source } from './source';
|
||||
|
||||
polyfillSymbolDispose();
|
||||
|
||||
const UNSUPPORTED_INPUT_FORMAT_MESSAGE = 'Input has an unsupported or unrecognizable format.';
|
||||
export const DEFAULT_SOURCE_CACHE_GROUP = 1;
|
||||
export const ENCRYPTION_KEY_CACHE_GROUP = 2;
|
||||
|
||||
@@ -150,7 +149,7 @@ export class Input<S extends Source = Source> implements Disposable {
|
||||
return cachedEntry.sourcePromise;
|
||||
}
|
||||
|
||||
const sourcePromise = Promise.resolve(this._getSourceUncached(request));
|
||||
const sourcePromise = this._getSourceUncached(request);
|
||||
this._sourceCache.push({
|
||||
request,
|
||||
sourcePromise,
|
||||
@@ -163,7 +162,13 @@ export class Input<S extends Source = Source> implements Disposable {
|
||||
|
||||
if (count > MAX_SOURCE_CACHE_SIZE) {
|
||||
const minAgeIndex = arrayArgmin(this._sourceCache, x => x.cacheGroup === cacheGroup ? x.age : Infinity);
|
||||
const entry = this._sourceCache[minAgeIndex]!;
|
||||
this._sourceCache.splice(minAgeIndex, 1);
|
||||
|
||||
/*
|
||||
void entry.sourcePromise
|
||||
.then(source => source._dispose());
|
||||
*/
|
||||
}
|
||||
|
||||
return sourcePromise;
|
||||
@@ -191,7 +196,7 @@ export class Input<S extends Source = Source> implements Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(UNSUPPORTED_INPUT_FORMAT_MESSAGE);
|
||||
throw new UnsupportedInputFormatError();
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -247,10 +252,10 @@ export class Input<S extends Source = Source> implements Disposable {
|
||||
|
||||
async isSupported(): Promise<boolean> {
|
||||
try {
|
||||
const demuxer = await this._getDemuxer();
|
||||
return demuxer.isSupported();
|
||||
await this._getDemuxer();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === UNSUPPORTED_INPUT_FORMAT_MESSAGE) {
|
||||
if (error instanceof UnsupportedInputFormatError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -323,6 +328,7 @@ export class Input<S extends Source = Source> implements Disposable {
|
||||
sortBy: track => [
|
||||
prefer(track.disposition.default),
|
||||
prefer(track.hasPairableAudioTrack()),
|
||||
prefer(!track.hasOnlyKeyPackets),
|
||||
desc(track.bitrate),
|
||||
],
|
||||
}));
|
||||
@@ -387,7 +393,7 @@ export class Input<S extends Source = Source> implements Disposable {
|
||||
} else {
|
||||
// TODO
|
||||
// TODO
|
||||
throw new Error('TODO');
|
||||
// throw new Error('TODO');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,6 +406,19 @@ export class Input<S extends Source = Source> implements Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when trying to operate on an input that has an unsupported or unrecognizable format.
|
||||
* @group Input files & tracks
|
||||
* @public
|
||||
*/
|
||||
export class UnsupportedInputFormatError extends Error {
|
||||
/** Creates a new {@link UnsupportedInputFormatError}. */
|
||||
constructor(message = 'Input has an unsupported or unrecognizable format.') {
|
||||
super(message);
|
||||
this.name = 'UnsupportedInputFormatError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when an operation was prevented because the corresponding {@link Input} has been disposed.
|
||||
* @group Input files & tracks
|
||||
|
||||
@@ -20,12 +20,12 @@ import {
|
||||
normalizeRotation,
|
||||
promiseWithResolvers,
|
||||
Rational,
|
||||
roundToMultiple,
|
||||
simplifyRational,
|
||||
textEncoder,
|
||||
toUint8Array,
|
||||
uint8ArraysAreEqual,
|
||||
writeBits,
|
||||
roundToDivisor,
|
||||
} from '../misc';
|
||||
import {
|
||||
CODEC_STRING_MAP,
|
||||
@@ -911,8 +911,8 @@ export class MatroskaMuxer extends Muxer {
|
||||
|
||||
if (track.metadata.frameRate !== undefined) {
|
||||
// Constrain the time values to the frame rate
|
||||
timestamp = roundToMultiple(timestamp, 1 / track.metadata.frameRate);
|
||||
duration = roundToMultiple(duration, 1 / track.metadata.frameRate);
|
||||
timestamp = roundToDivisor(timestamp, track.metadata.frameRate);
|
||||
duration = roundToDivisor(duration, track.metadata.frameRate);
|
||||
}
|
||||
|
||||
const additions = trackData.info.alphaMode
|
||||
|
||||
@@ -435,6 +435,10 @@ export const roundToMultiple = (value: number, multiple: number) => {
|
||||
return Math.round(value / multiple) * multiple;
|
||||
};
|
||||
|
||||
export const roundToDivisor = (value: number, multiple: number) => {
|
||||
return Math.round(value * multiple) / multiple;
|
||||
};
|
||||
|
||||
export const floorToMultiple = (value: number, multiple: number) => {
|
||||
return Math.floor(value / multiple) * multiple;
|
||||
};
|
||||
|
||||
+60
-4
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { InputDisposedError } from './input';
|
||||
import { assert, clamp, getUint24, MaybePromise, toDataView } from './misc';
|
||||
import { assert, clamp, getUint24, MaybePromise, textDecoder, toDataView } from './misc';
|
||||
import { DEFAULT_MAX_READ_POSITION, DEFAULT_MIN_READ_POSITION, Source } from './source';
|
||||
|
||||
export class Reader {
|
||||
@@ -35,10 +35,15 @@ export class Reader {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.fileSizeNonStrict != null && start + length > this.fileSizeNonStrict) {
|
||||
if (this.fileSizeNonStrict !== null && start + length > this.fileSizeNonStrict) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (length === 0) {
|
||||
const buffer = new Uint8Array(0);
|
||||
return new FileSlice(buffer, toDataView(buffer), 0, start, start);
|
||||
}
|
||||
|
||||
const end = start + length;
|
||||
const result = this.source._read(start, end, DEFAULT_MIN_READ_POSITION, DEFAULT_MAX_READ_POSITION);
|
||||
|
||||
@@ -68,7 +73,7 @@ export class Reader {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.fileSizeNonStrict != null) {
|
||||
if (this.fileSizeNonStrict !== null) {
|
||||
return this.requestSlice(
|
||||
start,
|
||||
clamp(this.fileSizeNonStrict - start, minLength, maxLength),
|
||||
@@ -82,7 +87,7 @@ export class Reader {
|
||||
}
|
||||
|
||||
// The slice couldn't fit, meaning we must know the file size now
|
||||
assert(this.fileSizeNonStrict != null);
|
||||
assert(this.fileSizeNonStrict !== null);
|
||||
|
||||
return this.requestSlice(
|
||||
start,
|
||||
@@ -97,6 +102,46 @@ export class Reader {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requestEntireFile(): MaybePromise<FileSlice | null> {
|
||||
if (this.fileSizeNonStrict !== null) {
|
||||
return this.requestSlice(0, this.fileSizeNonStrict);
|
||||
}
|
||||
|
||||
const CHUNK_SIZE = 1024;
|
||||
|
||||
return (async () => {
|
||||
const chunks: Uint8Array[] = [];
|
||||
let currentSize = 0;
|
||||
|
||||
while (true) {
|
||||
if (chunks.length === 1 && this.fileSizeNonStrict !== null) {
|
||||
// It only took one read to get to know the whole file size
|
||||
return this.requestSlice(0, this.fileSizeNonStrict);
|
||||
}
|
||||
|
||||
const startOffset = chunks.length * CHUNK_SIZE;
|
||||
let slice = this.requestSliceRange(startOffset, 0, CHUNK_SIZE);
|
||||
if (slice instanceof Promise) slice = await slice;
|
||||
|
||||
if (!slice) {
|
||||
break;
|
||||
}
|
||||
|
||||
chunks.push(readBytes(slice, slice.length));
|
||||
currentSize += slice.length;
|
||||
}
|
||||
|
||||
const joined = new Uint8Array(currentSize);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
joined.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
return new FileSlice(joined, toDataView(joined), 0, 0, currentSize);
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
export class FileSlice {
|
||||
@@ -333,6 +378,17 @@ export const readAscii = (slice: FileSlice, length: number) => {
|
||||
return str;
|
||||
};
|
||||
|
||||
export const readAllLines = (slice: FileSlice, length: number, options?: {
|
||||
ignore?: (line: string) => boolean;
|
||||
}) => {
|
||||
const text = textDecoder.decode(readBytes(slice, length));
|
||||
const lines = text.split('\n')
|
||||
.map(x => x.trim())
|
||||
.filter(x => x.length > 0 && !options?.ignore?.(x));
|
||||
|
||||
return lines;
|
||||
};
|
||||
|
||||
export class LineReader {
|
||||
getReader: () => MaybePromise<Reader>;
|
||||
ignore?: (line: string) => boolean;
|
||||
|
||||
@@ -24,7 +24,6 @@ export class Segment {
|
||||
timestamp: number;
|
||||
relativeToUnixEpoch: boolean;
|
||||
duration: number;
|
||||
title: string | null;
|
||||
encryption: SegmentEncryptionInfo | null;
|
||||
firstSegment: Segment | null;
|
||||
initSegment: Segment | null;
|
||||
@@ -35,7 +34,6 @@ export class Segment {
|
||||
timestamp: number,
|
||||
relativeToUnixEpoch: boolean,
|
||||
duration: number,
|
||||
title: string | null,
|
||||
encryption: SegmentEncryptionInfo | null,
|
||||
firstSegment: Segment | null,
|
||||
initSegment: Segment | null,
|
||||
@@ -45,7 +43,6 @@ export class Segment {
|
||||
this.timestamp = timestamp;
|
||||
this.relativeToUnixEpoch = relativeToUnixEpoch;
|
||||
this.duration = duration;
|
||||
this.title = title;
|
||||
this.encryption = encryption;
|
||||
this.firstSegment = firstSegment;
|
||||
this.initSegment = initSegment;
|
||||
|
||||
+13
-14
@@ -21,7 +21,7 @@ import {
|
||||
import { Segment } from './segment';
|
||||
import { PacketRetrievalOptions } from './media-sink';
|
||||
import { MetadataTags, TrackDisposition } from './metadata';
|
||||
import { arrayCount, assert, binarySearchLessOrEqual, Rotation, roundToMultiple } from './misc';
|
||||
import { arrayCount, assert, binarySearchLessOrEqual, Rotation, roundToDivisor } from './misc';
|
||||
import { EncodedPacket } from './packet';
|
||||
import { NullSource } from './source';
|
||||
|
||||
@@ -111,16 +111,6 @@ class SegmentedInputDemuxer extends Demuxer {
|
||||
this.segmentedInput = segmentedInput;
|
||||
}
|
||||
|
||||
override async isSupported() {
|
||||
const firstSegment = await this.segmentedInput.getFirstSegment();
|
||||
if (!firstSegment) {
|
||||
return true; // There's no data but that's supported
|
||||
}
|
||||
|
||||
const input = firstSegment.toInput();
|
||||
return input.isSupported();
|
||||
}
|
||||
|
||||
async getMetadataTags(): Promise<MetadataTags> {
|
||||
throw new Error('Unreachable');
|
||||
}
|
||||
@@ -215,6 +205,10 @@ class SegmentedInputInputTrackBacking implements InputTrackBacking {
|
||||
this.number = number;
|
||||
}
|
||||
|
||||
getHasOnlyKeyPackets() {
|
||||
return this.firstInputTrack.hasOnlyKeyPackets;
|
||||
}
|
||||
|
||||
getId(): number {
|
||||
return this.firstInputTrack._backing.getId();
|
||||
}
|
||||
@@ -269,16 +263,21 @@ class SegmentedInputInputTrackBacking implements InputTrackBacking {
|
||||
}
|
||||
|
||||
async createAdjustedPacket(packet: EncodedPacket, segment: Segment, track: InputTrack) {
|
||||
assert(packet.sequenceNumber >= 0);
|
||||
assert(this.demuxer.firstSegment);
|
||||
|
||||
const mediaOffset = await this.demuxer.getMediaOffset(segment, track.input);
|
||||
// If we didn't do this then sequence numbers would exceed Number.MAX_SAFE_INTEGER for Unix-timestamped segments
|
||||
const segmentTimestampRelativeToFirst = segment.timestamp - this.demuxer.firstSegment.timestamp;
|
||||
|
||||
const modified = packet.clone({
|
||||
timestamp: roundToMultiple(
|
||||
timestamp: roundToDivisor(
|
||||
packet.timestamp + mediaOffset,
|
||||
1 / track.timeResolution,
|
||||
track.timeResolution,
|
||||
),
|
||||
// The 1e8 assumes a max of 100 MB per second, highly unlikely to be hit, so this should guarantee
|
||||
// monotonically increasing sequence numbers across segments.
|
||||
sequenceNumber: Math.floor(1e8 * segment.timestamp) + packet.sequenceNumber,
|
||||
sequenceNumber: Math.floor(1e8 * segmentTimestampRelativeToFirst) + packet.sequenceNumber,
|
||||
});
|
||||
|
||||
this.packetInfos.set(modified, {
|
||||
|
||||
Reference in New Issue
Block a user