From 2a2b6450c9d6d30149a8b3b4d0b3f7c0d960e2ac Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:25:46 +0200 Subject: [PATCH] Add better doc blocks, special deprecated logic for API doc generation, add multi-tiered SegmentedInput hydration for reducing file request count, ditch VirtualInputFormat indirection --- dev/demux.html | 7 +- scripts/generate-api-docs.ts | 110 ++++++++++--- src/hls/hls-demuxer.ts | 158 ++++++++++-------- src/hls/hls-segmented-input.ts | 5 +- src/input-format.ts | 31 ---- src/input-track.ts | 63 ++++--- src/segmented-input.ts | 291 +++++++++++++++++++-------------- test/node/hls-input.test.ts | 19 ++- 8 files changed, 409 insertions(+), 275 deletions(-) diff --git a/dev/demux.html b/dev/demux.html index 30cff7b..126237b 100644 --- a/dev/demux.html +++ b/dev/demux.html @@ -33,11 +33,12 @@ Mediabunny.ALL_FORMATS, ); - const [track] = await manifest.getTracks(); - console.log(track); + const tracks = await manifest.getTracks(); + console.log(tracks); + const [track] = tracks; window.kekw = () => { - console.log(track.getFirstTimestamp()) + console.log(track.getDurationFromMetadata()) }; return; diff --git a/scripts/generate-api-docs.ts b/scripts/generate-api-docs.ts index 250df45..8876810 100644 --- a/scripts/generate-api-docs.ts +++ b/scripts/generate-api-docs.ts @@ -68,6 +68,10 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) const classHierarchy = new Map(); // Maps parent class to array of subclasses const classInstances = new Map(); // Maps class name to array of instance variable names + const hasDeprecatedTag = (node: ts.Node): boolean => { + return ts.getJSDocTags(node).some(tag => tag.tagName.text === 'deprecated'); + }; + const collectExportedTypes = (module: ts.Symbol, visited = new Set()): void => { if (visited.has(module)) return; visited.add(module); @@ -77,12 +81,12 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) const declaration = exportSymbol.valueDeclaration || exportSymbol.declarations?.[0]; if (!declaration) return; - // Collect classes, interfaces, types, enums, variables (only if @public) + // Collect classes, interfaces, types, enums, variables (only if @public and not @deprecated) if (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration) || ts.isTypeAliasDeclaration(declaration) || ts.isEnumDeclaration(declaration) || ts.isVariableDeclaration(declaration)) { const hasPublicTag = ts.getJSDocTags(declaration).some(tag => tag.tagName.text === 'public'); - if (hasPublicTag) { + if (hasPublicTag && !hasDeprecatedTag(declaration)) { exportedTypes.add(exportSymbol.getName()); } } @@ -92,9 +96,9 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) const aliasedSymbol = typeChecker.getAliasedSymbol(exportSymbol); const aliasedDeclaration = aliasedSymbol.valueDeclaration || aliasedSymbol.declarations?.[0]; if (aliasedDeclaration) { - // Check if the aliased symbol has @public tag + // Check if the aliased symbol has @public tag and is not deprecated const hasPublicTag = ts.getJSDocTags(aliasedDeclaration).some(tag => tag.tagName.text === 'public'); - if (hasPublicTag) { + if (hasPublicTag && !hasDeprecatedTag(aliasedDeclaration)) { exportedTypes.add(exportSymbol.getName()); } @@ -133,10 +137,10 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) } } } - // Otherwise, add any symbol with @public tag (we'll filter by type later) + // Otherwise, add any symbol with @public tag and not @deprecated (we'll filter by type later) else { const hasPublicTag = ts.getJSDocTags(declaration).some(tag => tag.tagName.text === 'public'); - if (hasPublicTag) { + if (hasPublicTag && !hasDeprecatedTag(declaration)) { symbols.push(exportSymbol); } } @@ -621,9 +625,10 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) const nodeKind = ts.SyntaxKind[declaration.kind]; const symbolName = (declaration as any).name?.getText() || exportSymbol.getName(); - // Only process symbols with @public tag + // Only process symbols with @public tag, and skip deprecated ones entirely const hasPublicTag = ts.getJSDocTags(declaration).some(tag => tag.tagName.text === 'public'); if (!hasPublicTag) return; + if (hasDeprecatedTag(declaration)) return; // Check for @group tag (handle re-exports by looking at the original declaration) let targetDeclaration = declaration; @@ -766,6 +771,8 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) const events: string[] = []; const methods: string[] = []; const staticMethods: string[] = []; + const deprecatedProperties: string[] = []; + const deprecatedMethods: string[] = []; let constructor: string | null = null; let extendsClause = ''; let implementsClause = ''; @@ -1021,6 +1028,53 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) const hasInternalTag = ts.getJSDocTags(member).some(tag => tag.tagName.text === 'internal'); if (hasInternalTag) return; + const isDeprecatedMember = hasDeprecatedTag(member); + + const getDeprecationNotice = () => { + const tag = ts.getJSDocTags(member).find(t => t.tagName.text === 'deprecated'); + let text = ''; + if (tag?.comment) { + if (typeof tag.comment === 'string') { + text = processLinkTags(tag.comment.trim(), className); + } else { + // comment is a NodeArray of JSDocComment elements (text + inline tags) + const raw = tag.comment.map((part) => { + if (ts.isJSDocLinkLike(part)) { + const linkName = part.name?.getText() ?? ''; + const linkText = part.text?.trim() ?? ''; + // Reconstruct as {@link Name text} + return `{@link ${linkName}${linkText ? ' ' + linkText : ''}}`; + } + return part.text ?? ''; + }).join(''); + text = processLinkTags(raw.trim(), className); + } + } + return text ? `> **Deprecated.** ${text}\n\n` : '> **Deprecated.**\n\n'; + }; + + const addDeprecationNotice = (content: string) => { + // Insert the deprecation notice right after the heading line + const headingEnd = content.indexOf('\n'); + return content.slice(0, headingEnd) + '\n\n' + getDeprecationNotice() + content.slice(headingEnd + 1); + }; + + const pushProperty = (content: string) => { + if (isDeprecatedMember) { + deprecatedProperties.push(addDeprecationNotice(content)); + } else { + properties.push(content); + } + }; + + const pushMethod = (content: string) => { + if (isDeprecatedMember) { + deprecatedMethods.push(addDeprecationNotice(content)); + } else { + methods.push(content); + } + }; + if (ts.isConstructorDeclaration(member) && !isAbstract) { // Skip private constructors const isPrivate = member.modifiers?.some(mod => mod.kind === ts.SyntaxKind.PrivateKeyword); @@ -1231,7 +1285,7 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) if (isEventHandler) { events.push(propertyContent); } else { - properties.push(propertyContent); + pushProperty(propertyContent); } } else if (ts.isGetAccessorDeclaration(member) && member.name) { const name = member.name.getText(); @@ -1264,7 +1318,7 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) references.forEach(ref => addUsage(ref, className, name, 'property')); const inheritedBadge = ''; - properties.push(`### \`${name}\`${inheritedBadge}\n\n\`\`\`ts\n${accessorDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`); + pushProperty(`### \`${name}\`${inheritedBadge}\n\n\`\`\`ts\n${accessorDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`); } else if (ts.isSetAccessorDeclaration(member) && member.name) { const name = member.name.getText(); const param = member.parameters[0]; @@ -1287,7 +1341,7 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) references.forEach(ref => addUsage(ref, className, name, 'property')); const inheritedBadge = ''; - properties.push(`### \`${name}\`${inheritedBadge}\n\n\`\`\`ts\n${accessorDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`); + pushProperty(`### \`${name}\`${inheritedBadge}\n\n\`\`\`ts\n${accessorDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`); } else if ((ts.isMethodDeclaration(member) || ts.isMethodSignature(member)) && member.name) { const name = member.name.getText(); const isStatic = member.modifiers?.some(mod => mod.kind === ts.SyntaxKind.StaticKeyword); @@ -1385,7 +1439,7 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) if (isStatic) { staticMethods.push(methodContent); } else { - methods.push(methodContent); + pushMethod(methodContent); } } }; @@ -1497,20 +1551,27 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) } } - // Sort properties and methods alphabetically (but not for type aliases - keep source order) + // Sort properties and methods alphabetically, but with bracketed names like + // `[Symbol.dispose]()` always at the bottom. Type aliases keep source order. + const compareMemberNames = (a: string, b: string) => { + const nameA = a.match(/### `([^`]+)`/)?.[1] ?? ''; + const nameB = b.match(/### `([^`]+)`/)?.[1] ?? ''; + const aIsBracket = nameA.startsWith('['); + const bIsBracket = nameB.startsWith('['); + if (aIsBracket !== bIsBracket) { + return aIsBracket ? 1 : -1; + } + return nameA.localeCompare(nameB); + }; + if (!ts.isTypeAliasDeclaration(declaration)) { - properties.sort((a, b) => { - const nameA = a.match(/### (.+)/)?.[1] || ''; - const nameB = b.match(/### (.+)/)?.[1] || ''; - return nameA.localeCompare(nameB); - }); + properties.sort(compareMemberNames); + methods.sort(compareMemberNames); } - staticMethods.sort((a, b) => { - const nameA = a.match(/### (.+)/)?.[1] || ''; - const nameB = b.match(/### (.+)/)?.[1] || ''; - return nameA.localeCompare(nameB); - }); + staticMethods.sort(compareMemberNames); + deprecatedProperties.sort(compareMemberNames); + deprecatedMethods.sort(compareMemberNames); let markdown = ''; @@ -1685,6 +1746,11 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) markdown += `\n## Methods\n\n${methods.join('\n\n')}\n`; } + const deprecated = [...deprecatedProperties, ...deprecatedMethods]; + if (deprecated.length > 0) { + markdown += `\n## Deprecated\n\n${deprecated.join('\n\n')}\n`; + } + generatedDocs.set(className, markdown); } }); diff --git a/src/hls/hls-demuxer.ts b/src/hls/hls-demuxer.ts index 7144980..240479e 100644 --- a/src/hls/hls-demuxer.ts +++ b/src/hls/hls-demuxer.ts @@ -11,7 +11,6 @@ import { Demuxer, DurationMetadataRequestOptions } from '../demuxer'; import { Input } from '../input'; import { InputAudioTrackBacking, - InputTrack, InputTrackBacking, InputVideoTrackBacking, } from '../input-track'; @@ -24,11 +23,12 @@ import { readAllLines } from '../reader'; import { AttributeList, canIgnoreLine } from './hls-misc'; import { HlsSegmentedInput } from './hls-segmented-input'; import { PathedSource } from '../source'; +import { SegmentedInputTrackDeclaration } from '../segmented-input'; type InternalTrack = { id: number; demuxer: HlsDemuxer; - backingTrack: InputTrack | null; + backingTrack: InputTrackBacking | null; default: boolean; autoselect: boolean; languageCode: string; @@ -56,8 +56,8 @@ type InternalAudioTrack = InternalTrack & { info: { type: 'audio' } }; export class HlsDemuxer extends Demuxer { metadataPromise: Promise | null = null; - trackBackings: InputTrackBacking[] = []; - internalTracks: InternalTrack[] = []; + trackBackings: InputTrackBacking[] | null = null; + internalTracks: InternalTrack[] | null = null; segmentedInputs: HlsSegmentedInput[] = []; hasMasterPlaylist = true; @@ -161,13 +161,11 @@ export class HlsDemuxer extends Demuxer { // iFramesOnlyTagFound = true; } else if (line.startsWith('#EXTINF:')) { // This is a media playlist, not a master playlist - const segmentedInput = new HlsSegmentedInput(this, source.rootPath, lines); + const segmentedInput = new HlsSegmentedInput(this, source.rootPath, null, lines); + this.segmentedInputs = [segmentedInput]; this.hasMasterPlaylist = false; - - const input = segmentedInput.toInput(); - const demuxer = await input._getDemuxer(); - this.trackBackings = await demuxer.getTrackBackings(); + this.trackBackings = await segmentedInput.getTrackBackings(); return; } @@ -197,17 +195,16 @@ export class HlsDemuxer extends Demuxer { } else { // No codecs were specified, we need to read the underlying media data const segmentedInput = this.getSegmentedInputForPath(variantStream.fullPath); - const input = segmentedInput.toInput(); - const tracks = await input.getTracks(); + const trackBackings = await segmentedInput.getTrackBackings(); const tracksWithCodec = await Promise.all( - tracks.map(async t => ({ track: t, codec: await t.getCodec() })), + trackBackings.map(async t => ({ track: t, codec: await t.getCodec() })), ); codecStrings = await Promise.all( tracksWithCodec .filter(x => x.codec !== null) - .map(x => x.track.getCodecParameterString()), - ) as string[]; + .map(x => x.track.getDecoderConfig().then(x => x!.codec)), + ); } const videoGroupId = variantStream.attributes.get('video'); @@ -243,14 +240,14 @@ export class HlsDemuxer extends Demuxer { const fullPath = joinPaths(source.rootPath, uri); const segmentedInput = this.getSegmentedInputForPath(fullPath); - const input = segmentedInput.toInput(); - const videoTrack = await input.getPrimaryVideoTrack(); + const trackBackings = await segmentedInput.getTrackBackings(); + const videoTrack = trackBackings.find(x => x.getType() === 'video'); if (!videoTrack || (await videoTrack.getCodec()) === null) { return null; } - const codecParameterString = await videoTrack.getCodecParameterString(); + const codecParameterString = await videoTrack.getDecoderConfig().then(x => x?.codec ?? null); assert(codecParameterString !== null); return codecParameterString; })); @@ -284,14 +281,14 @@ export class HlsDemuxer extends Demuxer { const fullPath = joinPaths(source.rootPath, uri); const segmentedInput = this.getSegmentedInputForPath(fullPath); - const input = segmentedInput.toInput(); - const audioTrack = await input.getPrimaryAudioTrack(); + const trackBackings = await segmentedInput.getTrackBackings(); + const audioTrack = trackBackings.find(x => x.getType() === 'audio'); if (!audioTrack || (await audioTrack.getCodec()) === null) { return null; } - const codecParameterString = await audioTrack.getCodecParameterString(); + const codecParameterString = await audioTrack.getDecoderConfig().then(x => x?.codec ?? null); assert(codecParameterString !== null); return codecParameterString; })); @@ -563,6 +560,7 @@ export class HlsDemuxer extends Demuxer { // Order tracks by how they appear in the file internalTracks.sort((a, b) => a.lineNumber - b.lineNumber); + this.trackBackings = []; for (const internalTrack of internalTracks) { if (internalTrack.info.type === 'video') { this.trackBackings.push( @@ -581,6 +579,8 @@ export class HlsDemuxer extends Demuxer { async getTrackBackings() { await this.readMetadata(); + assert(this.trackBackings); + return this.trackBackings; } @@ -590,7 +590,16 @@ export class HlsDemuxer extends Demuxer { return segmentedInput; } - segmentedInput = new HlsSegmentedInput(this, path, null); + let decls: SegmentedInputTrackDeclaration[] | null = null; + if (this.internalTracks) { + const tracks = this.internalTracks.filter(x => x.fullPath === path); + decls = tracks.map(x => ({ + id: x.id, + type: x.info.type, + })); + } + + segmentedInput = new HlsSegmentedInput(this, path, decls, null); this.segmentedInputs.push(segmentedInput); return segmentedInput; @@ -605,10 +614,12 @@ export class HlsDemuxer extends Demuxer { } override dispose(): void { - for (const segInput of this.segmentedInputs) { - segInput.dispose(); + if (this.segmentedInputs) { + for (const segInput of this.segmentedInputs) { + segInput.dispose(); + } + this.segmentedInputs.length = 0; } - this.segmentedInputs.length = 0; } } @@ -623,25 +634,40 @@ abstract class HlsInputTrackBacking implements InputTrackBacking { hydrate() { return this.hydrationPromise ??= (async () => { const segmentedInput = this.internalTrack.demuxer.getSegmentedInputForPath(this.internalTrack.fullPath); - const input = segmentedInput.toInput(); - let track: InputTrack | null; - if (this instanceof HlsInputVideoTrackBacking) { - track = await input.getPrimaryVideoTrack({ - filter: async t => (await t.getCodec()) === this.getCodec(), - }); + let trackBacking: InputTrackBacking | null = null; + + const trackBackings = await segmentedInput.getTrackBackings(); + const matchingType = trackBackings.filter(x => x.getType() === this.getType()); + + if (matchingType.length === 1) { + // Avoids reading fields on the track + trackBacking = matchingType[0]!; } else { - assert(this instanceof HlsInputAudioTrackBacking); - track = await input.getPrimaryAudioTrack({ - filter: async t => (await t.getCodec()) === this.getCodec(), - }); + if (this instanceof HlsInputVideoTrackBacking) { + for (const backing of matchingType) { + if ((await backing.getCodec()) === this.getCodec()) { + trackBacking = backing; + break; + } + } + } else { + assert(this instanceof HlsInputAudioTrackBacking); + + for (const backing of matchingType) { + if ((await backing.getCodec()) === this.getCodec()) { + trackBacking = backing; + break; + } + } + } } - if (!track) { + if (!trackBacking) { throw new Error('Could not find matching track in underlying media data.'); } - this.internalTrack.backingTrack = track; + this.internalTrack.backingTrack = trackBacking; })(); } @@ -688,6 +714,8 @@ abstract class HlsInputTrackBacking implements InputTrackBacking { } getNumber(): number { + assert(this.internalTrack.demuxer.internalTracks); + const trackType = this.internalTrack.info.type; let number = 0; for (const track of this.internalTrack.demuxer.internalTracks) { @@ -704,11 +732,11 @@ abstract class HlsInputTrackBacking implements InputTrackBacking { } getTimeResolution(): MaybePromise { - return this.delegate(() => this.internalTrack.backingTrack!._backing.getTimeResolution()); + return this.delegate(() => this.internalTrack.backingTrack!.getTimeResolution()); } isRelativeToUnixEpoch(): MaybePromise { - return this.delegate(() => this.internalTrack.backingTrack!._backing.isRelativeToUnixEpoch()); + return this.delegate(() => this.internalTrack.backingTrack!.isRelativeToUnixEpoch()); } getBitrate(): number | null { @@ -721,12 +749,12 @@ abstract class HlsInputTrackBacking implements InputTrackBacking { async getDurationFromMetadata(options: DurationMetadataRequestOptions): Promise { await this.hydrate(); - return this.internalTrack.backingTrack!._backing.getDurationFromMetadata(options); + return this.internalTrack.backingTrack!.getDurationFromMetadata(options); } async getLiveRefreshInterval(): Promise { await this.hydrate(); - return this.internalTrack.backingTrack!._backing.getLiveRefreshInterval(); + return this.internalTrack.backingTrack!.getLiveRefreshInterval(); } getHasOnlyKeyPackets() { @@ -735,27 +763,27 @@ abstract class HlsInputTrackBacking implements InputTrackBacking { async getFirstPacket(options: PacketRetrievalOptions): Promise { await this.hydrate(); - return this.internalTrack.backingTrack!._backing.getFirstPacket(options); + return this.internalTrack.backingTrack!.getFirstPacket(options); } async getPacket(timestamp: number, options: PacketRetrievalOptions): Promise { await this.hydrate(); - return this.internalTrack.backingTrack!._backing.getPacket(timestamp, options); + return this.internalTrack.backingTrack!.getPacket(timestamp, options); } async getKeyPacket(timestamp: number, options: PacketRetrievalOptions): Promise { await this.hydrate(); - return this.internalTrack.backingTrack!._backing.getKeyPacket(timestamp, options); + return this.internalTrack.backingTrack!.getKeyPacket(timestamp, options); } async getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions): Promise { await this.hydrate(); - return this.internalTrack.backingTrack!._backing.getNextPacket(packet, options); + return this.internalTrack.backingTrack!.getNextPacket(packet, options); } async getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions): Promise { await this.hydrate(); - return this.internalTrack.backingTrack!._backing.getNextKeyPacket(packet, options); + return this.internalTrack.backingTrack!.getNextKeyPacket(packet, options); } } @@ -768,13 +796,12 @@ class HlsInputVideoTrackBacking super(internalTrack); } - getType() { - return 'video' as const; + get backingVideoTrack() { + return this.internalTrack.backingTrack as InputVideoTrackBacking | null; } - get backingVideoTrack() { - const bt = this.internalTrack.backingTrack; - return bt?.isVideoTrack() ? bt : null; + getType() { + return 'video' as const; } override getCodec(): VideoCodec | null { @@ -783,19 +810,19 @@ class HlsInputVideoTrackBacking } getCodedWidth(): MaybePromise { - return this.delegate(() => this.backingVideoTrack!._backing.getCodedWidth()); + return this.delegate(() => this.backingVideoTrack!.getCodedWidth()); } getCodedHeight(): MaybePromise { - return this.delegate(() => this.backingVideoTrack!._backing.getCodedHeight()); + return this.delegate(() => this.backingVideoTrack!.getCodedHeight()); } getSquarePixelWidth(): MaybePromise { - return this.delegate(() => this.backingVideoTrack!._backing.getSquarePixelWidth()); + return this.delegate(() => this.backingVideoTrack!.getSquarePixelWidth()); } getSquarePixelHeight(): MaybePromise { - return this.delegate(() => this.backingVideoTrack!._backing.getSquarePixelHeight()); + return this.delegate(() => this.backingVideoTrack!.getSquarePixelHeight()); } getMetadataDisplayWidth(): number | null { @@ -815,17 +842,17 @@ class HlsInputVideoTrackBacking } getRotation(): MaybePromise { - return this.delegate(() => this.backingVideoTrack!._backing.getRotation()); + return this.delegate(() => this.backingVideoTrack!.getRotation()); } async getColorSpace(): Promise { await this.hydrate(); - return this.backingVideoTrack!._backing.getColorSpace(); + return this.backingVideoTrack!.getColorSpace(); } async canBeTransparent(): Promise { await this.hydrate(); - return this.backingVideoTrack!._backing.canBeTransparent(); + return this.backingVideoTrack!.canBeTransparent(); } getMetadataCodecParameterString(): string | null { @@ -837,7 +864,7 @@ class HlsInputVideoTrackBacking async getDecoderConfig(): Promise { await this.hydrate(); - return this.backingVideoTrack!._backing.getDecoderConfig(); + return this.backingVideoTrack!.getDecoderConfig(); } } @@ -850,13 +877,12 @@ class HlsInputAudioTrackBacking super(internalTrack); } - getType() { - return 'audio' as const; + get backingAudioTrack() { + return this.internalTrack.backingTrack as InputAudioTrackBacking | null; } - get backingAudioTrack() { - const bt = this.internalTrack.backingTrack; - return bt?.isAudioTrack() ? bt : null; + getType() { + return 'audio' as const; } override getCodec(): AudioCodec | null { @@ -869,11 +895,11 @@ class HlsInputAudioTrackBacking return this.internalTrack.info.numberOfChannels; } - return this.delegate(() => this.backingAudioTrack!._backing.getNumberOfChannels()); + return this.delegate(() => this.backingAudioTrack!.getNumberOfChannels()); } getSampleRate(): MaybePromise { - return this.delegate(() => this.backingAudioTrack!._backing.getSampleRate()); + return this.delegate(() => this.backingAudioTrack!.getSampleRate()); } getMetadataCodecParameterString(): string | null { @@ -885,7 +911,7 @@ class HlsInputAudioTrackBacking async getDecoderConfig(): Promise { await this.hydrate(); - return this.backingAudioTrack!._backing.getDecoderConfig(); + return this.backingAudioTrack!.getDecoderConfig(); } } diff --git a/src/hls/hls-segmented-input.ts b/src/hls/hls-segmented-input.ts index 579cd34..17ae125 100644 --- a/src/hls/hls-segmented-input.ts +++ b/src/hls/hls-segmented-input.ts @@ -8,7 +8,7 @@ import { AES_128_BLOCK_SIZE, createAes128CbcDecryptStream } from '../aes'; import { ENCRYPTION_KEY_CACHE_GROUP, Input } from '../input'; -import { Segment, SegmentedInput, SegmentRetrievalOptions } from '../segmented-input'; +import { Segment, SegmentedInput, SegmentedInputTrackDeclaration, SegmentRetrievalOptions } from '../segmented-input'; import { toDataView, joinPaths, last, assert, binarySearchLessOrEqual, arrayArgmin, wait } from '../misc'; import { readAllLines, readBytes, Reader } from '../reader'; import { PathedSource, ReadableStreamSource, SourceRef } from '../source'; @@ -51,9 +51,10 @@ export class HlsSegmentedInput extends SegmentedInput { constructor( demuxer: HlsDemuxer, path: string, + trackDeclarations: SegmentedInputTrackDeclaration[] | null, lines: string[] | null, ) { - super(demuxer.input, path); + super(demuxer.input, path, trackDeclarations); this.demuxer = demuxer; this.nextLines = lines; diff --git a/src/input-format.ts b/src/input-format.ts index a563009..de09cc6 100644 --- a/src/input-format.ts +++ b/src/input-format.ts @@ -614,37 +614,6 @@ export class HlsInputFormat extends InputFormat { } } -export class VirtualInputFormat extends InputFormat { - /** @internal */ - _createDemuxerFn: (input: Input) => Demuxer; - - /** @internal */ - constructor(createDemuxer: (input: Input) => Demuxer) { - super(); - this._createDemuxerFn = createDemuxer; - } - - /** @internal */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async _canReadInput(input: Input) { - return true; - } - - /** @internal */ - - _createDemuxer(input: Input): Demuxer { - return this._createDemuxerFn(input); - } - - get name() { - return 'Virtual input format'; - } - - get mimeType() { - return 'application/magic'; - } -} - /** * MP4 input format singleton. * @group Input formats diff --git a/src/input-track.ts b/src/input-track.ts index c0347c5..a735425 100644 --- a/src/input-track.ts +++ b/src/input-track.ts @@ -77,7 +77,7 @@ export abstract class InputTrack { /** The type of the track. */ abstract get type(): TrackType; - /** The codec of the track's packets. */ + /** Returns the codec of the track's packets. */ abstract getCodec(): Promise; /** * The codec of the track's packets. @@ -94,7 +94,10 @@ export abstract class InputTrack { * into its bitstream. Returns null if the type couldn't be determined. */ abstract determinePacketType(packet: EncodedPacket): Promise; - /** Whether the track metadata says that this track only contains key packets. The actual packets may differ. */ + /** + * Returns whether the track metadata says that this track only contains key packets. The actual packets may + * differ. + */ abstract getHasOnlyKeyPackets(): Promise; /** * Whether the track metadata says that this track only contains key packets. The actual packets may differ. @@ -128,7 +131,7 @@ export abstract class InputTrack { } /** - * The identifier of the codec used internally by the container. It is not homogenized by Mediabunny + * Returns the identifier of the codec used internally by the container. It is not homogenized by Mediabunny * and depends entirely on the container format. * * This method can be used to determine the codec of a track in case Mediabunny doesn't know that codec. @@ -153,7 +156,7 @@ export abstract class InputTrack { } /** - * The ISO 639-2/T language code for this track. If the language is unknown, this resolves to `'und'` + * Returns the ISO 639-2/T language code for this track. If the language is unknown, this resolves to `'und'` * (undetermined). */ async getLanguageCode() { @@ -168,7 +171,7 @@ export abstract class InputTrack { return requireSync(this._backing.getLanguageCode(), 'languageCode', 'getLanguageCode'); } - /** A user-defined name for this track. */ + /** Returns the user-defined name for this track. */ async getName() { return this._backing.getName(); } @@ -182,7 +185,7 @@ export abstract class InputTrack { } /** - * A positive number x such that all timestamps and durations of all packets of this track are + * Returns a positive number x such that all timestamps and durations of all packets of this track are * integer multiples of 1/x. */ async getTimeResolution() { @@ -199,8 +202,8 @@ export abstract class InputTrack { } /** - * Whether the timestamps of this track are relative to the Unix epoch (January 1, 1970 00:00:00 UTC). When `true`, - * each timestamp maps to a definitive point in time. + * Returns whether the timestamps of this track are relative to the Unix epoch (January 1, 1970 00:00:00 UTC). + * When `true`, each timestamp maps to a definitive point in time. */ async getIsRelativeToUnixEpoch() { return this._backing.isRelativeToUnixEpoch(); @@ -219,7 +222,7 @@ export abstract class InputTrack { ); } - /** The track's disposition, i.e. information about its intended usage. */ + /** Returns the track's disposition, i.e. information about its intended usage. */ async getDisposition() { return this._backing.getDisposition(); } @@ -233,8 +236,8 @@ export abstract class InputTrack { } /** - * The peak bitrate of the track, in bits per second, as specified in the track's metadata. This might not match the - * actual media data's bitrate. + * Returns the peak bitrate of the track in bits per second, as specified in the track's metadata. This might not + * match the actual media data's bitrate. */ async getBitrate() { return this._backing.getBitrate(); @@ -250,8 +253,8 @@ export abstract class InputTrack { } /** - * The average bitrate of the track, in bits per second, as specified in the track's metadata. This might not match - * the actual media data's bitrate. + * Returns the average bitrate of the track in bits per second, as specified in the track's metadata. This might + * not match the actual media data's bitrate. */ async getAverageBitrate() { return this._backing.getAverageBitrate(); @@ -552,7 +555,10 @@ export class InputVideoTrack extends InputTrack { return requireSync(this._backing.getCodec(), 'codec', 'getCodec'); } - /** Whether the track metadata says that this track only contains key packets. The actual packets may differ. */ + /** + * Returns whether the track metadata says that this track only contains key packets. The actual packets may + * differ. + */ async getHasOnlyKeyPackets() { return (await this._backing.getHasOnlyKeyPackets?.()) ?? false; } @@ -569,7 +575,7 @@ export class InputVideoTrack extends InputTrack { return requireSync(raw, 'hasOnlyKeyPackets', 'getHasOnlyKeyPackets') ?? false; } - /** The width in pixels of the track's coded samples, before any transformations or rotations. */ + /** Returns the width in pixels of the track's coded samples, before any transformations or rotations. */ async getCodedWidth() { return this._backing.getCodedWidth(); } @@ -582,7 +588,7 @@ export class InputVideoTrack extends InputTrack { return requireSync(this._backing.getCodedWidth(), 'codedWidth', 'getCodedWidth'); } - /** The height in pixels of the track's coded samples, before any transformations or rotations. */ + /** Returns the height in pixels of the track's coded samples, before any transformations or rotations. */ async getCodedHeight() { return this._backing.getCodedHeight(); } @@ -595,7 +601,7 @@ export class InputVideoTrack extends InputTrack { return requireSync(this._backing.getCodedHeight(), 'codedHeight', 'getCodedHeight'); } - /** The angle in degrees by which the track's frames should be rotated (clockwise). */ + /** Returns the angle in degrees by which the track's frames should be rotated (clockwise). */ async getRotation() { return this._backing.getRotation(); } @@ -608,7 +614,9 @@ export class InputVideoTrack extends InputTrack { return requireSync(this._backing.getRotation(), 'rotation', 'getRotation'); } - /** The width of the track's frames in square pixels, adjusted for pixel aspect ratio but before rotation. */ + /** + * Returns the width of the track's frames in square pixels, adjusted for pixel aspect ratio but before rotation. + */ async getSquarePixelWidth() { return this._backing.getSquarePixelWidth(); } @@ -621,7 +629,9 @@ export class InputVideoTrack extends InputTrack { return requireSync(this._backing.getSquarePixelWidth(), 'squarePixelWidth', 'getSquarePixelWidth'); } - /** The height of the track's frames in square pixels, adjusted for pixel aspect ratio but before rotation. */ + /** + * Returns the height of the track's frames in square pixels, adjusted for pixel aspect ratio but before rotation. + */ async getSquarePixelHeight() { return this._backing.getSquarePixelHeight(); } @@ -635,7 +645,7 @@ export class InputVideoTrack extends InputTrack { } /** - * The pixel aspect ratio of the track's frames, as a rational number in its reduced form. Most videos use + * Returns the pixel aspect ratio of the track's frames as a rational number in its reduced form. Most videos use * square pixels (1:1). */ async getPixelAspectRatio() { @@ -661,7 +671,7 @@ export class InputVideoTrack extends InputTrack { }); } - /** The display width of the track's frames in pixels, after aspect ratio adjustment and rotation. */ + /** Returns the display width of the track's frames in pixels, after aspect ratio adjustment and rotation. */ async getDisplayWidth() { const metadata = await this._backing.getMetadataDisplayWidth?.(); if (metadata != null) { @@ -692,7 +702,7 @@ export class InputVideoTrack extends InputTrack { return requireSync(value, 'displayWidth', 'getDisplayWidth'); } - /** The display height of the track's frames in pixels, after aspect ratio adjustment and rotation. */ + /** Returns the display height of the track's frames in pixels, after aspect ratio adjustment and rotation. */ async getDisplayHeight() { const metadata = await this._backing.getMetadataDisplayHeight?.(); if (metadata != null) { @@ -848,7 +858,10 @@ export class InputAudioTrack extends InputTrack { return requireSync(this._backing.getCodec(), 'codec', 'getCodec'); } - /** Whether the track metadata says that this track only contains key packets. The actual packets may differ. */ + /** + * Returns whether the track metadata says that this track only contains key packets. The actual packets may + * differ. + */ async getHasOnlyKeyPackets() { return (await this._backing.getHasOnlyKeyPackets?.()) ?? true; } @@ -865,7 +878,7 @@ export class InputAudioTrack extends InputTrack { return requireSync(raw, 'hasOnlyKeyPackets', 'getHasOnlyKeyPackets') ?? true; } - /** The number of audio channels in the track. */ + /** Returns the number of audio channels in the track. */ async getNumberOfChannels() { return this._backing.getNumberOfChannels(); } @@ -878,7 +891,7 @@ export class InputAudioTrack extends InputTrack { return requireSync(this._backing.getNumberOfChannels(), 'numberOfChannels', 'getNumberOfChannels'); } - /** The track's audio sample rate in hertz. */ + /** Returns the track's audio sample rate in hertz. */ async getSampleRate() { return this._backing.getSampleRate(); } diff --git a/src/segmented-input.ts b/src/segmented-input.ts index 58b3781..dc6ce0b 100644 --- a/src/segmented-input.ts +++ b/src/segmented-input.ts @@ -8,9 +8,8 @@ import { TrackType } from './output'; import { MediaCodec } from './codec'; -import { Demuxer, DurationMetadataRequestOptions } from './demuxer'; +import { DurationMetadataRequestOptions } from './demuxer'; import { Input } from './input'; -import { VirtualInputFormat } from './input-format'; import { InputAudioTrack, InputAudioTrackBacking, @@ -20,10 +19,8 @@ import { InputVideoTrackBacking, } from './input-track'; import { PacketRetrievalOptions } from './media-sink'; -import { MetadataTags } from './metadata'; -import { arrayCount, assert, roundToDivisor } from './misc'; +import { arrayCount, assert, MaybePromise, roundToDivisor } from './misc'; import { EncodedPacket } from './packet'; -import { NullSource } from './source'; export type SegmentedInputMetadata = { name: string | null; @@ -52,11 +49,16 @@ export type SegmentRetrievalOptions = { skipLiveWait?: boolean; }; +export type SegmentedInputTrackDeclaration = { + id: number; + type: TrackType; +}; + export abstract class SegmentedInput { input: Input; path: string; + trackDeclarations: SegmentedInputTrackDeclaration[] | null; - virtualInput: Input | null = null; nextInputCacheAge = 0; inputCache: { segment: Segment; @@ -64,9 +66,14 @@ export abstract class SegmentedInput { age: number; }[] = []; - constructor(input: Input, path: string) { + trackBackingsPromise: Promise | null = null; + firstSegment: Segment | null = null; + firstSegmentFirstTimestamps = new WeakMap(); + + constructor(input: Input, path: string, trackDeclarations: SegmentedInputTrackDeclaration[] | null) { this.input = input; this.path = path; + this.trackDeclarations = trackDeclarations; } abstract getFirstSegment(options: SegmentRetrievalOptions): Promise; @@ -88,67 +95,56 @@ export abstract class SegmentedInput { return lastSegment.timestamp + lastSegment.duration; } - toInput() { - return this.virtualInput ??= new Input({ - source: new NullSource(), - formats: [new VirtualInputFormat(() => new SegmentedInputDemuxer(this.input, this))], - }); - } - - dispose() { - for (const entry of this.inputCache) { - entry.input.dispose(); - } - this.inputCache.length = 0; - - this.virtualInput?.dispose(); - } -} - -class SegmentedInputDemuxer extends Demuxer { - segmentedInput: SegmentedInput; - trackBackingsPromise: Promise | null = null; - firstSegment: Segment | null = null; - firstSegmentFirstTimestamps = new WeakMap(); - - constructor(input: Input, segmentedInput: SegmentedInput) { - super(input); - - this.segmentedInput = segmentedInput; - } - - async getMetadataTags(): Promise { - throw new Error('Unreachable'); - } - - async getMimeType(): Promise { - throw new Error('Unreachable'); - } - async getTrackBackings(): Promise { return this.trackBackingsPromise ??= (async () => { - this.firstSegment = await this.segmentedInput.getFirstSegment({}); - if (!this.firstSegment) { - return []; - } - - const input = this.segmentedInput.getInputForSegment(this.firstSegment); - const inputTracks = await input.getTracks(); - const backings: InputTrackBacking[] = []; - for (const track of inputTracks) { - if (track.type === 'video') { - const number = arrayCount(backings, x => x.getType() === 'video') + 1; - backings.push( - new SegmentedInputInputVideoTrackBacking(track, this, number), - ); - } else if (track.type === 'audio') { - const number = arrayCount(backings, x => x.getType() === 'audio') + 1; + if (this.trackDeclarations) { + for (const decl of this.trackDeclarations) { + if (decl.type === 'video') { + const number = arrayCount(backings, x => x.getType() === 'video') + 1; - backings.push( - new SegmentedInputInputAudioTrackBacking(track, this, number), - ); + backings.push( + new SegmentedInputInputVideoTrackBacking(this, decl, number), + ); + } else if (decl.type === 'audio') { + const number = arrayCount(backings, x => x.getType() === 'audio') + 1; + + backings.push( + new SegmentedInputInputAudioTrackBacking(this, decl, number), + ); + } + } + } else { + // There are no declarations, we must determine the tracks from the first segment + this.firstSegment = await this.getFirstSegment({}); + if (!this.firstSegment) { + return []; + } + + const input = this.getInputForSegment(this.firstSegment); + const inputTracks = await input.getTracks(); + + for (const track of inputTracks) { + if (track.type === 'video') { + const number = arrayCount(backings, x => x.getType() === 'video') + 1; + + backings.push( + new SegmentedInputInputVideoTrackBacking(this, { + id: backings.length + 1, + type: 'video', + }, number), + ); + } else if (track.type === 'audio') { + const number = arrayCount(backings, x => x.getType() === 'audio') + 1; + + backings.push( + new SegmentedInputInputAudioTrackBacking(this, { + id: backings.length + 1, + type: 'audio', + }, number), + ); + } } } @@ -163,7 +159,7 @@ class SegmentedInputDemuxer extends Demuxer { if (this.firstSegmentFirstTimestamps.has(firstSegment)) { firstSegmentFirstTimestamp = this.firstSegmentFirstTimestamps.get(firstSegment)!; } else { - const firstInput = this.segmentedInput.getInputForSegment(firstSegment); + const firstInput = this.getInputForSegment(firstSegment); firstSegmentFirstTimestamp = await firstInput.getFirstTimestamp(); this.firstSegmentFirstTimestamps.set(firstSegment, firstSegmentFirstTimestamp); } @@ -189,6 +185,13 @@ class SegmentedInputDemuxer extends Demuxer { return segment.timestamp - segmentFirstTimestamp; } } + + dispose() { + for (const entry of this.inputCache) { + entry.input.dispose(); + } + this.inputCache.length = 0; + } } type PacketInfo = { @@ -198,93 +201,126 @@ type PacketInfo = { }; class SegmentedInputInputTrackBacking implements InputTrackBacking { - firstInputTrack: InputTrack; - demuxer: SegmentedInputDemuxer; - packetInfos = new WeakMap(); + segmentedInput: SegmentedInput; + decl: SegmentedInputTrackDeclaration; number: number; + packetInfos = new WeakMap(); - constructor(firstInputTrack: InputTrack, demuxer: SegmentedInputDemuxer, number: number) { - this.firstInputTrack = firstInputTrack; - this.demuxer = demuxer; + hydrationPromise: Promise | null = null; + firstInputTrack: InputTrack | null = null; + + constructor(segmentedInput: SegmentedInput, decl: SegmentedInputTrackDeclaration, number: number) { + this.segmentedInput = segmentedInput; + this.decl = decl; this.number = number; } - getType(): TrackType { - return this.firstInputTrack._backing.getType(); - } + hydrate() { + return this.hydrationPromise ??= (async () => { + this.segmentedInput.firstSegment ??= await this.segmentedInput.getFirstSegment({}); + if (!this.segmentedInput.firstSegment) { + throw new Error('Missing first segment, can\'t retrieve track.'); + } - getDecoderConfig() { - return this.firstInputTrack._backing.getDecoderConfig(); - } + const input = this.segmentedInput.getInputForSegment(this.segmentedInput.firstSegment); + const inputTracks = await input.getTracks(); - getHasOnlyKeyPackets() { - return this.firstInputTrack.getHasOnlyKeyPackets(); + const track = inputTracks.find(x => x.type === this.decl.type && x.number === this.number); + if (!track) { + throw new Error('No matching track found in underlying media data.'); + } + + this.firstInputTrack = track; + })(); } getId(): number { - return this.firstInputTrack._backing.getId(); + return this.decl.id; } - getPairingMask() { - return this.firstInputTrack._backing.getPairingMask(); + getType(): TrackType { + return this.decl.type; } getNumber(): number { return this.number; } + /** If the backing track is already present, delegate synchronously; otherwise, hydrate first. */ + delegate(fn: () => MaybePromise): MaybePromise { + if (this.firstInputTrack) { + return fn(); + } + + return this.hydrate().then(fn); + } + + async getDecoderConfig() { + return this.delegate(() => this.firstInputTrack!._backing.getDecoderConfig()); + } + + getHasOnlyKeyPackets() { + return this.delegate(() => this.firstInputTrack!._backing.getHasOnlyKeyPackets?.() ?? null); + } + + getPairingMask() { + return 1n; + } + getCodec() { - return this.firstInputTrack._backing.getCodec(); + return this.delegate(() => this.firstInputTrack!._backing.getCodec()); } getInternalCodecId() { - return this.firstInputTrack._backing.getInternalCodecId(); + return this.delegate(() => this.firstInputTrack!._backing.getInternalCodecId()); } getDisposition() { - return this.firstInputTrack._backing.getDisposition(); + return this.delegate(() => this.firstInputTrack!._backing.getDisposition()); } getLanguageCode() { - return this.firstInputTrack._backing.getLanguageCode(); + return this.delegate(() => this.firstInputTrack!._backing.getLanguageCode()); } getName() { - return this.firstInputTrack._backing.getName(); + return this.delegate(() => this.firstInputTrack!._backing.getName()); } getTimeResolution() { - return this.firstInputTrack._backing.getTimeResolution(); + return this.delegate(() => this.firstInputTrack!._backing.getTimeResolution()); } - isRelativeToUnixEpoch() { - assert(this.demuxer.firstSegment); - return this.demuxer.firstSegment.relativeToUnixEpoch; + async isRelativeToUnixEpoch() { + await this.hydrate(); + + assert(this.segmentedInput.firstSegment); + return this.segmentedInput.firstSegment.relativeToUnixEpoch; } getBitrate() { - return this.firstInputTrack._backing.getBitrate(); + return this.delegate(() => this.firstInputTrack!._backing.getBitrate()); } getAverageBitrate() { - return this.firstInputTrack._backing.getAverageBitrate(); + return this.delegate(() => this.firstInputTrack!._backing.getAverageBitrate()); } getDurationFromMetadata(options: DurationMetadataRequestOptions): Promise { - return this.demuxer.segmentedInput.getDurationFromMetadata(options); + return this.segmentedInput.getDurationFromMetadata(options); } getLiveRefreshInterval(): Promise { - return this.demuxer.segmentedInput.getLiveRefreshInterval(); + return this.segmentedInput.getLiveRefreshInterval(); } async createAdjustedPacket(packet: EncodedPacket, segment: Segment, track: InputTrack) { assert(packet.sequenceNumber >= 0); - assert(this.demuxer.firstSegment); + assert(this.segmentedInput.firstSegment); - const mediaOffset = await this.demuxer.getMediaOffset(segment, track.input); + const mediaOffset = await this.segmentedInput.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 segmentTimestampRelativeToFirst = segment.timestamp - this.segmentedInput.firstSegment.timestamp; const modified = packet.clone({ timestamp: roundToDivisor( @@ -306,14 +342,17 @@ class SegmentedInputInputTrackBacking implements InputTrackBacking { } async getFirstPacket(options: PacketRetrievalOptions): Promise { - assert(this.demuxer.firstSegment); + await this.hydrate(); + + assert(this.segmentedInput.firstSegment); + assert(this.firstInputTrack); const packet = await this.firstInputTrack._backing.getFirstPacket(options); if (!packet) { return null; } - return this.createAdjustedPacket(packet, this.demuxer.firstSegment, this.firstInputTrack); + return this.createAdjustedPacket(packet, this.segmentedInput.firstSegment, this.firstInputTrack); } getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions): Promise { @@ -343,14 +382,14 @@ class SegmentedInputInputTrackBacking implements InputTrackBacking { let currentSegment: Segment | null = info.segment; while (true) { - const nextSegment = await this.demuxer.segmentedInput.getNextSegment(currentSegment, { + const nextSegment = await this.segmentedInput.getNextSegment(currentSegment, { skipLiveWait: options.skipLiveWait, }); if (!nextSegment) { return null; } - const nextInput = this.demuxer.segmentedInput.getInputForSegment(nextSegment); + const nextInput = this.segmentedInput.getInputForSegment(nextSegment); const nextTracks = await nextInput.getTracks(); const nextTrack = nextTracks.find(t => t.type === info.track.type && t.number === info.track.number); @@ -381,29 +420,31 @@ class SegmentedInputInputTrackBacking implements InputTrackBacking { options: PacketRetrievalOptions, keyframesOnly: boolean, ): Promise { - let currentSegment = await this.demuxer.segmentedInput.getSegmentAt(timestamp, { + let currentSegment = await this.segmentedInput.getSegmentAt(timestamp, { skipLiveWait: options.skipLiveWait, }); if (!currentSegment) { return null; } + await this.hydrate(); + while (currentSegment) { - const input = this.demuxer.segmentedInput.getInputForSegment(currentSegment); + const input = this.segmentedInput.getInputForSegment(currentSegment); const tracks = await input.getTracks(); const track = tracks.find(t => ( - t.type === this.firstInputTrack.type && t.number === this.firstInputTrack.number + t.type === this.firstInputTrack!.type && t.number === this.firstInputTrack!.number )); if (!track) { // Search the previous segment - currentSegment = await this.demuxer.segmentedInput.getPreviousSegment(currentSegment, { + currentSegment = await this.segmentedInput.getPreviousSegment(currentSegment, { skipLiveWait: options.skipLiveWait, }); continue; } - const mediaOffset = await this.demuxer.getMediaOffset(currentSegment, input); + const mediaOffset = await this.segmentedInput.getMediaOffset(currentSegment, input); const offsetTimestamp = timestamp - mediaOffset; const packet = keyframesOnly @@ -412,7 +453,7 @@ class SegmentedInputInputTrackBacking implements InputTrackBacking { if (!packet) { // Search the previous segment - currentSegment = await this.demuxer.segmentedInput.getPreviousSegment(currentSegment, { + currentSegment = await this.segmentedInput.getPreviousSegment(currentSegment, { skipLiveWait: options.skipLiveWait, }); continue; @@ -428,46 +469,46 @@ class SegmentedInputInputTrackBacking implements InputTrackBacking { class SegmentedInputInputVideoTrackBacking extends SegmentedInputInputTrackBacking implements InputVideoTrackBacking { - override firstInputTrack!: InputVideoTrack; + override firstInputTrack!: InputVideoTrack | null; override getType() { return 'video' as const; } override getCodec() { - return this.firstInputTrack._backing.getCodec(); + return this.delegate(() => this.firstInputTrack!._backing.getCodec()); } getCodedWidth() { - return this.firstInputTrack._backing.getCodedWidth(); + return this.delegate(() => this.firstInputTrack!._backing.getCodedWidth()); } getCodedHeight() { - return this.firstInputTrack._backing.getCodedHeight(); + return this.delegate(() => this.firstInputTrack!._backing.getCodedHeight()); } getSquarePixelWidth() { - return this.firstInputTrack._backing.getSquarePixelWidth(); + return this.delegate(() => this.firstInputTrack!._backing.getSquarePixelWidth()); } getSquarePixelHeight() { - return this.firstInputTrack._backing.getSquarePixelHeight(); + return this.delegate(() => this.firstInputTrack!._backing.getSquarePixelHeight()); } getRotation() { - return this.firstInputTrack._backing.getRotation(); + return this.delegate(() => this.firstInputTrack!._backing.getRotation()); } - getColorSpace(): Promise { - return this.firstInputTrack._backing.getColorSpace(); + async getColorSpace(): Promise { + return this.delegate(() => this.firstInputTrack!._backing.getColorSpace()); } - canBeTransparent(): Promise { - return this.firstInputTrack._backing.canBeTransparent(); + async canBeTransparent(): Promise { + return this.delegate(() => this.firstInputTrack!._backing.canBeTransparent()); } - override getDecoderConfig(): Promise { - return this.firstInputTrack._backing.getDecoderConfig(); + override async getDecoderConfig(): Promise { + return this.delegate(() => this.firstInputTrack!._backing.getDecoderConfig()); } } @@ -481,18 +522,18 @@ class SegmentedInputInputAudioTrackBacking } override getCodec() { - return this.firstInputTrack._backing.getCodec(); + return this.delegate(() => this.firstInputTrack._backing.getCodec()); } getNumberOfChannels() { - return this.firstInputTrack._backing.getNumberOfChannels(); + return this.delegate(() => this.firstInputTrack._backing.getNumberOfChannels()); } getSampleRate() { - return this.firstInputTrack._backing.getSampleRate(); + return this.delegate(() => this.firstInputTrack._backing.getSampleRate()); } - override getDecoderConfig(): Promise { - return this.firstInputTrack._backing.getDecoderConfig(); + override async getDecoderConfig(): Promise { + return this.delegate(() => this.firstInputTrack._backing.getDecoderConfig()); } } diff --git a/test/node/hls-input.test.ts b/test/node/hls-input.test.ts index ae31bd1..d4a2631 100644 --- a/test/node/hls-input.test.ts +++ b/test/node/hls-input.test.ts @@ -204,7 +204,7 @@ test.concurrent('Big Buck Bunny', { timeout: 15_000 }, async () => { expect(await input.getDurationFromMetadata()).not.toBe(null); }); -test.concurrent('Big Buck Bunny codec parameter strings from master playlist', { timeout: 15_000 }, async () => { +test.concurrent('Big Buck Bunny, codec parameter strings from master playlist', { timeout: 15_000 }, async () => { using input = createInputFrom('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', ALL_FORMATS); let sourceCount = 0; @@ -228,6 +228,23 @@ test.concurrent('Big Buck Bunny codec parameter strings from master playlist', { expect(sourceCount).toBe(1); }); +test.concurrent('Big Buck Bunny, determining duration from metadata', { timeout: 15_000 }, async () => { + using input = createInputFrom('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', ALL_FORMATS); + + let sourceCount = 0; + input.on('source', () => sourceCount++); + + const track = await input.getPrimaryVideoTrack(); + assert(track); + + expect(sourceCount).toBe(1); + + const approxDuration = await track.getDurationFromMetadata(); + expect(approxDuration).toBe(634.567); + + expect(sourceCount).toBe(2); // We needed to read the playlist, but not any segment +}); + test.concurrent('Single-variant Big Buck Bunny', { timeout: 15_000 }, async () => { using input = createInputFrom('https://test-streams.mux.dev/x36xhzz/url_6/193039199_mp4_h264_aac_hq_7.m3u8', ALL_FORMATS);