From a9a455e132574083e7ff82f1e35af8f2a17c4880 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:03:36 +0100 Subject: [PATCH] Add metadata-based duration retrieval --- dev/demux.html | 10 +- shared/mp3-misc.ts | 25 +++++ src/adts/adts-demuxer.ts | 8 ++ src/demuxer.ts | 17 ++++ src/flac/flac-demuxer.ts | 19 ++++ src/hls/hls-demuxer.ts | 20 +++- src/hls/hls-segmented-input.ts | 1 - src/input-track.ts | 32 +++++- src/input.ts | 17 +++- src/isobmff/isobmff-boxes.ts | 48 +++++---- src/isobmff/isobmff-demuxer.ts | 31 ++++++ src/matroska/matroska-demuxer.ts | 32 ++++++ src/matroska/matroska-muxer.ts | 11 +- src/mp3/mp3-demuxer.ts | 80 ++++++++++++++- src/mp3/mp3-muxer.ts | 18 ++-- src/mp3/mp3-writer.ts | 7 +- src/mpeg-ts/mpeg-ts-demuxer.ts | 8 ++ src/ogg/ogg-demuxer.ts | 8 ++ src/segmented-input.ts | 22 +++- src/wave/wave-demuxer.ts | 11 ++ test/node/duration-from-metadata.test.ts | 124 +++++++++++++++++++++++ test/node/hls-input.test.ts | 16 +++ todo.txt | 1 - 23 files changed, 517 insertions(+), 49 deletions(-) create mode 100644 test/node/duration-from-metadata.test.ts diff --git a/dev/demux.html b/dev/demux.html index 7ab2608..464b425 100644 --- a/dev/demux.html +++ b/dev/demux.html @@ -18,8 +18,10 @@ source: new Mediabunny.BlobSource(file), }); - const audTrack = await input.getPrimaryAudioTrack(); - console.log((await audTrack.getDecoderConfig()).description.join(',')); + const track = await input.getPrimaryAudioTrack(); + console.log(await track.getDurationFromMetadata(), await track.computeDuration()); + + //console.log(await input.getDurationFromMetadata(), await input.computeDuration()); return; */ @@ -32,6 +34,10 @@ const videoTrack = await manifest.getPrimaryVideoTrack(); await videoTrack.hydrate(); + console.log("Doing...") + console.log(await videoTrack.getDurationFromMetadata({skipLiveWait: true})); + return; + const sink = new Mediabunny.EncodedPacketSink(videoTrack); const firstTimestamp = await videoTrack.getFirstTimestamp(); diff --git a/shared/mp3-misc.ts b/shared/mp3-misc.ts index aa6a5a3..1b53994 100644 --- a/shared/mp3-misc.ts +++ b/shared/mp3-misc.ts @@ -30,6 +30,7 @@ export const INFO = 0x496e666f; export type Mp3FrameHeader = { totalSize: number; mpegVersionId: number; + lowSamplingFrequency: number; layer: number; bitrate: number; frequencyIndex: number; @@ -60,6 +61,23 @@ export const computeMp3FrameSize = ( } }; +export const computeAverageMp3FrameSize = ( + lowSamplingFrequency: number, + layer: number, + bitrate: number, + sampleRate: number, +) => { + if (layer === 0) { + return 0; // Not expected that this is hit + } else if (layer === 1) { + return 144 * bitrate / (sampleRate << lowSamplingFrequency); + } else if (layer === 2) { + return 144 * bitrate / sampleRate; + } else { // layer === 3 + return (12 * bitrate / sampleRate) * 4; + } +}; + export const getXingOffset = (mpegVersionId: number, channel: number) => { return mpegVersionId === 3 ? (channel === 3 ? 21 : 36) @@ -144,6 +162,7 @@ export const readMp3FrameHeader = (word: number, remainingBytes: number | null): header: { totalSize: frameLength, mpegVersionId, + lowSamplingFrequency, layer, bitrate, frequencyIndex, @@ -187,3 +206,9 @@ export const decodeSynchsafe = (synchsafed: number) => { return unsynchsafed; }; + +export enum XingFlags { + FrameCount = 1 << 0, + FileSize = 1 << 1, + Toc = 1 << 2, +} diff --git a/src/adts/adts-demuxer.ts b/src/adts/adts-demuxer.ts index aa710aa..c447f70 100644 --- a/src/adts/adts-demuxer.ts +++ b/src/adts/adts-demuxer.ts @@ -143,6 +143,10 @@ export class AdtsDemuxer extends Demuxer { this.lastLoadedPos = header.startPos + header.frameLength; } + async getDurationFromMetadata(): Promise { + return null; // No way + } + async getMimeType() { return 'audio/aac'; } @@ -227,6 +231,10 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking { return null; } + async getDurationFromMetadata() { + return null; + } + async getLiveRefreshInterval() { return null; } diff --git a/src/demuxer.ts b/src/demuxer.ts index 97a3057..9e5d22a 100644 --- a/src/demuxer.ts +++ b/src/demuxer.ts @@ -10,6 +10,22 @@ import { Input } from './input'; import { InputTrack } from './input-track'; import { MetadataTags } from './metadata'; +/** + * Options for retrieving media duration from metadata. + * @group Input files & tracks + * @public + */ +export type DurationMetadataRequestOptions = { + /** + * When the underlying media is live, querying the duration will, by default, wait until the live stream has ended. + * Setting this field to `true` skips that wait and returns the current duration of the stream. When the media isn't + * live, this field has no effect. + * + * See also {@link PacketRetrievalOptions.skipLiveWait}. + */ + skipLiveWait?: boolean; +}; + export abstract class Demuxer { input: Input; @@ -20,6 +36,7 @@ export abstract class Demuxer { abstract getTracks(): Promise; abstract getMimeType(): Promise; abstract getMetadataTags(): Promise; + abstract getDurationFromMetadata(options: DurationMetadataRequestOptions): Promise; dispose() { // Can be overridden diff --git a/src/flac/flac-demuxer.ts b/src/flac/flac-demuxer.ts index 77eb89d..9a2d98d 100644 --- a/src/flac/flac-demuxer.ts +++ b/src/flac/flac-demuxer.ts @@ -97,6 +97,17 @@ export class FlacDemuxer extends Demuxer { return [this.track]; } + async getDurationFromMetadata(): Promise { + await this.readMetadata(); + assert(this.audioInfo); + + if (this.audioInfo.totalSamples === 0) { + return null; + } + + return this.audioInfo.totalSamples / this.audioInfo.sampleRate; + } + async getMimeType() { return 'audio/flac'; } @@ -252,6 +263,10 @@ export class FlacDemuxer extends Demuxer { break; } } + + if (!this.audioInfo) { + throw new Error('Missing STREAMINFO metadata block! Corrupted FLAC file.'); + } })()); } @@ -578,6 +593,10 @@ class FlacAudioTrackBacking implements InputAudioTrackBacking { return null; } + getDurationFromMetadata() { + return this.demuxer.getDurationFromMetadata(); + } + async getLiveRefreshInterval() { return null; } diff --git a/src/hls/hls-demuxer.ts b/src/hls/hls-demuxer.ts index fb67631..4f85b2b 100644 --- a/src/hls/hls-demuxer.ts +++ b/src/hls/hls-demuxer.ts @@ -1,5 +1,5 @@ import { AUDIO_CODECS, AudioCodec, inferCodecFromCodecString, MediaCodec, VIDEO_CODECS, VideoCodec } from '../codec'; -import { Demuxer } from '../demuxer'; +import { Demuxer, DurationMetadataRequestOptions } from '../demuxer'; import { Input } from '../input'; import { InputAudioTrack, @@ -52,6 +52,7 @@ export class HlsDemuxer extends Demuxer { metadataPromise: Promise | null = null; tracks: InputTrack[] = []; segmentedInputs: HlsSegmentedInput[] = []; + hasMasterPlaylist = true; constructor(input: Input) { super(input); @@ -153,7 +154,8 @@ export class HlsDemuxer extends Demuxer { } else if (line.startsWith('#EXTINF:')) { // This is a media playlist, not a master playlist const segmentedInput = new HlsSegmentedInput(this, this.input._entryPath, lines); - this.segmentedInputs.push(segmentedInput); + this.segmentedInputs = [segmentedInput]; + this.hasMasterPlaylist = false; const input = segmentedInput.toInput(); this.tracks = await input.getTracks(); @@ -569,6 +571,12 @@ export class HlsDemuxer extends Demuxer { return segmentedInput; } + async getDurationFromMetadata(options: DurationMetadataRequestOptions): Promise { + return this.hasMasterPlaylist + ? null + : this.segmentedInputs[0]!.getDurationFromMetadata(options); + } + async getMetadataTags(): Promise { return {}; } @@ -693,6 +701,14 @@ abstract class HlsInputTrackBacking implements InputTrackBacking { return this.internalTrack.averageBitrate; } + async getDurationFromMetadata(options: DurationMetadataRequestOptions): Promise { + if (!this.internalTrack.backingTrack) { + throw new TrackNotHydratedError(); + } + + return this.internalTrack.backingTrack._backing.getDurationFromMetadata(options); + } + async getLiveRefreshInterval(): Promise { if (!this.internalTrack.backingTrack) { throw new TrackNotHydratedError(); diff --git a/src/hls/hls-segmented-input.ts b/src/hls/hls-segmented-input.ts index 710d1b4..c321c90 100644 --- a/src/hls/hls-segmented-input.ts +++ b/src/hls/hls-segmented-input.ts @@ -12,7 +12,6 @@ const IV_STRING_REGEX = /^0[xX][0-9a-fA-F]+$/; export type HlsSegment = Segment & { sequenceNumber: number | null; location: HlsSegmentLocation; - duration: number; encryption: HlsEncryptionInfo | null; firstSegment: HlsSegment | null; initSegment: HlsSegment | null; diff --git a/src/input-track.ts b/src/input-track.ts index bd5e413..77a1223 100644 --- a/src/input-track.ts +++ b/src/input-track.ts @@ -11,10 +11,11 @@ import { determineVideoPacketType } from './codec-data'; import { customAudioDecoders, customVideoDecoders } from './custom-coder'; import { Input } from './input'; import { EncodedPacketSink, PacketRetrievalOptions } from './media-sink'; -import { assert, MaybePromise, NonFunctionKeys, Rational, Rotation, simplifyRational } from './misc'; +import { assert, MaybePromise, NonFunctionKeys, Rational, Rotation, roundToDivisor, simplifyRational } from './misc'; import { TrackType } from './output'; import { EncodedPacket, PacketType } from './packet'; import { TrackDisposition } from './metadata'; +import { DurationMetadataRequestOptions } from './demuxer'; /** * Contains aggregate statistics about the encoded packets of a track. @@ -44,6 +45,7 @@ export interface InputTrackBacking { getPairingMask(): bigint; getBitrate(): number | null; getAverageBitrate(): number | null; + getDurationFromMetadata(options: DurationMetadataRequestOptions): Promise; getLiveRefreshInterval(): Promise; getHasOnlyKeyPackets?(): boolean | null; @@ -209,7 +211,27 @@ export abstract class InputTrack { } const lastPacket = await this._backing.getPacket(Infinity, { metadataOnly: true, ...options }); - return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0); + const result = (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0); + + return roundToDivisor(result, this.timeResolution); + } + + /** + * Gets the duration (end timestamp) of this track from metadata stored in the file. This value may be + * approximate or diverge from the actual, precise duration returned by `.computeDuration()`, but compared to that + * method, this method is very cheap. When the duration cannot be determined from the file metadata, `null` + * is returned. + * + * By default, when the underlying media is live, this method will only resolve once the live stream + * ends. If you want to query the current duration of the media, set + * {@link DurationMetadataRequestOptions.skipLiveWait} to `true` in the options. + */ + async getDurationFromMetadata(options: DurationMetadataRequestOptions = {}) { + if (!this.isHydrated) { + await this.hydrate(); + } + + return this._backing.getDurationFromMetadata(options); } /** @@ -263,7 +285,11 @@ export abstract class InputTrack { return (await this._backing.getLiveRefreshInterval()) !== null; } - getLiveRefreshInterval() { + async getLiveRefreshInterval() { + if (!this.isHydrated) { + await this.hydrate(); + } + return this._backing.getLiveRefreshInterval(); } diff --git a/src/input.ts b/src/input.ts index a616a8b..e1c6ea8 100644 --- a/src/input.ts +++ b/src/input.ts @@ -6,7 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { Demuxer } from './demuxer'; +import { Demuxer, DurationMetadataRequestOptions } from './demuxer'; import { InputFormat } from './input-format'; import { InputAudioTrack, @@ -321,6 +321,21 @@ export class Input implements Disposable { return Math.min(...firstTimestamps); } + /** + * Gets the duration (end timestamp) of the input file from metadata stored in the file. This value may be + * approximate or diverge from the actual, precise duration returned by `.computeDuration()`, but compared to that + * method, this method is very cheap. When the duration cannot be determined from the file metadata, `null` + * is returned. + * + * By default, when the underlying media is live, this method will only resolve once the live stream + * ends. If you want to query the current duration of the media, set + * {@link DurationMetadataRequestOptions.skipLiveWait} to `true` in the options. + */ + async getDurationFromMetadata(options: DurationMetadataRequestOptions = {}) { + const demuxer = await this._getDemuxer(); + return demuxer.getDurationFromMetadata(options); + } + /** Returns the list of all tracks of this input file in the order in which they appear in the file. */ async getTracks(query?: TrackQuery) { const demuxer = await this._getDemuxer(); diff --git a/src/isobmff/isobmff-boxes.ts b/src/isobmff/isobmff-boxes.ts index 7115b1c..b2546b1 100644 --- a/src/isobmff/isobmff-boxes.ts +++ b/src/isobmff/isobmff-boxes.ts @@ -220,18 +220,6 @@ const ascii = (text: string, nullTerminated = false) => { return bytes; }; -const lastPresentedSample = (samples: Sample[]) => { - let result: Sample | null = null; - - for (const sample of samples) { - if (!result || sample.timestamp > result.timestamp) { - result = sample; - } - } - - return result; -}; - const rotationMatrix = (rotationInDegrees: number): TransformationMatrix => { const theta = rotationInDegrees * (Math.PI / 180); const cosTheta = Math.round(Math.cos(theta)); @@ -353,11 +341,7 @@ export const mvhd = ( const duration = intoTimescale(Math.max( 0, ...trackDatas - .filter(x => x.samples.length > 0) - .map((x) => { - const lastSample = lastPresentedSample(x.samples)!; - return lastSample.timestamp + lastSample.duration; - }), + .map(x => presentationSpan(x)), ), GLOBAL_TIMESCALE); const nextTrackId = Math.max(0, ...trackDatas.map(x => x.track.id)) + 1; @@ -379,6 +363,30 @@ export const mvhd = ( ]); }; +const presentationSpan = (trackData: IsobmffTrackData) => { + if (trackData.samples.length === 0) { + return 0; + } + + let minTimestamp = Infinity; + let maxEndTimestamp = -Infinity; + + for (const sample of trackData.samples) { + if (sample.timestamp < minTimestamp) { + minTimestamp = sample.timestamp; + } + if (sample.timestamp + sample.duration > maxEndTimestamp) { + maxEndTimestamp = sample.timestamp + sample.duration; + } + } + + if (minTimestamp === Infinity) { + return 0; + } + + return maxEndTimestamp - minTimestamp; +}; + /** * Track Box: Defines a single track of a movie. A movie may consist of one or more tracks. Each track is * independent of the other tracks in the movie and carries its own temporal and spatial information. Each Track Box @@ -405,9 +413,8 @@ export const tkhd = ( trackData: IsobmffTrackData, creationTime: number, ) => { - const lastSample = lastPresentedSample(trackData.samples); const durationInGlobalTimescale = intoTimescale( - lastSample ? lastSample.timestamp + lastSample.duration : 0, + presentationSpan(trackData), GLOBAL_TIMESCALE, ); @@ -456,9 +463,8 @@ export const mdhd = ( trackData: IsobmffTrackData, creationTime: number, ) => { - const lastSample = lastPresentedSample(trackData.samples); const localDuration = intoTimescale( - lastSample ? lastSample.timestamp + lastSample.duration : 0, + presentationSpan(trackData), trackData.timescale, ); diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts index 6696e31..dc98366 100644 --- a/src/isobmff/isobmff-demuxer.ts +++ b/src/isobmff/isobmff-demuxer.ts @@ -447,6 +447,26 @@ export class IsobmffDemuxer extends Demuxer { })(); } + async getDurationFromMetadata(): Promise { + await this.readMetadata(); + + if (this.movieDurationInTimescale <= 0) { + // The duration is often zero for fragmented files for example; return `null` to signal that the duration + // must be computed instead. + return null; + } + + let endTimestamp = this.movieDurationInTimescale / this.movieTimescale; + if (this.tracks.length > 0) { + const minFirstTimestamp = Math.min( + ...(await Promise.all(this.tracks.map(x => x.inputTrack!.getFirstTimestamp()))), + ); + endTimestamp += minFirstTimestamp; + } + + return endTimestamp; + } + getSampleTableForTrack(internalTrack: InternalTrack) { if (internalTrack.sampleTable) { return internalTrack.sampleTable; @@ -2560,6 +2580,17 @@ abstract class IsobmffTrackBacking implements InputTrackBacking { return null; } + async getDurationFromMetadata() { + const track = this.internalTrack; + if (track.durationInMediaTimescale <= 0) { + return null; + } + + assert(track.inputTrack); + + return (await track.inputTrack.getFirstTimestamp()) + track.durationInMediaTimescale / track.timescale; + } + async getLiveRefreshInterval() { return null; } diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts index f0d7d81..4c4ef6d 100644 --- a/src/matroska/matroska-demuxer.ts +++ b/src/matroska/matroska-demuxer.ts @@ -316,6 +316,29 @@ export class MatroskaDemuxer extends Demuxer { return metadataTags; } + async getDurationFromMetadata(): Promise { + await this.readMetadata(); + + let maxEndTimestamp: number | null = null; + for (const segment of this.segments) { + if (segment.duration > 0) { + let endTimestamp = segment.duration / segment.timestampFactor; + + if (segment.tracks.length > 0) { + const minFirstTimestamp = Math.min( + ...await Promise.all(segment.tracks.map(x => x.inputTrack!.getFirstTimestamp())), + ); + + endTimestamp += minFirstTimestamp; + } + + maxEndTimestamp = Math.max(maxEndTimestamp ?? -Infinity, endTimestamp); + } + } + + return maxEndTimestamp; + } + readMetadata() { return this.readMetadataPromise ??= (async () => { let currentPos = 0; @@ -1970,6 +1993,15 @@ abstract class MatroskaTrackBacking implements InputTrackBacking { return null; } + async getDurationFromMetadata() { + const segment = this.internalTrack.segment; + if (segment.tracks.length > 1) { + return null; + } + + return this.internalTrack.demuxer.getDurationFromMetadata(); + } + async getLiveRefreshInterval() { return null; } diff --git a/src/matroska/matroska-muxer.ts b/src/matroska/matroska-muxer.ts index b39b11d..0af05ae 100644 --- a/src/matroska/matroska-muxer.ts +++ b/src/matroska/matroska-muxer.ts @@ -152,7 +152,8 @@ export class MatroskaMuxer extends Muxer { firstMsTimestamp: number; }>(); - private duration = 0; + private startTimestamp = Infinity; + private endTimestamp = -Infinity; constructor(output: Output, format: MkvOutputFormat) { super(output); @@ -1197,7 +1198,8 @@ export class MatroskaMuxer extends Muxer { this.ebmlWriter.writeEBML(blockGroup); } - this.duration = Math.max(this.duration, msTimestamp + msDuration); + this.startTimestamp = Math.min(this.startTimestamp, msTimestamp); + this.endTimestamp = Math.max(this.endTimestamp, msTimestamp + msDuration); trackData.lastWrittenMsTimestamp = msTimestamp; if (!this.trackDatasInCurrentCluster.has(trackData)) { @@ -1325,7 +1327,10 @@ export class MatroskaMuxer extends Muxer { this.ebmlWriter.writeVarInt(segmentSize, SEGMENT_SIZE_BYTES); // Write the duration of the media to the Segment - this.segmentDuration!.data = new EBMLFloat64(this.duration); + const duration = this.startTimestamp === Infinity + ? 0 + : this.endTimestamp - this.startTimestamp; + this.segmentDuration!.data = new EBMLFloat64(duration); this.writer.seek(this.ebmlWriter.offsets.get(this.segmentDuration!)!); this.ebmlWriter.writeEBML(this.segmentDuration); diff --git a/src/mp3/mp3-demuxer.ts b/src/mp3/mp3-demuxer.ts index fd2f841..cae1524 100644 --- a/src/mp3/mp3-demuxer.ts +++ b/src/mp3/mp3-demuxer.ts @@ -12,9 +12,23 @@ import { Input } from '../input'; import { InputAudioTrack, InputAudioTrackBacking } from '../input-track'; import { DEFAULT_TRACK_DISPOSITION, MetadataTags } from '../metadata'; import { PacketRetrievalOptions } from '../media-sink'; -import { assert, AsyncMutex, binarySearchExact, binarySearchLessOrEqual, UNDETERMINED_LANGUAGE } from '../misc'; +import { + assert, + AsyncMutex, + binarySearchExact, + binarySearchLessOrEqual, + toDataView, + UNDETERMINED_LANGUAGE, +} from '../misc'; import { EncodedPacket, PLACEHOLDER_DATA } from '../packet'; -import { Mp3FrameHeader, getXingOffset, INFO, XING } from '../../shared/mp3-misc'; +import { + Mp3FrameHeader, + getXingOffset, + INFO, + XING, + XingFlags, + computeAverageMp3FrameSize, +} from '../../shared/mp3-misc'; import { ID3_V1_TAG_SIZE, ID3_V2_HEADER_SIZE, @@ -37,8 +51,13 @@ export class Mp3Demuxer extends Demuxer { metadataPromise: Promise | null = null; firstFrameHeader: Mp3FrameHeader | null = null; + firstFrameHeaderPos: number | null = null; loadedSamples: Sample[] = []; // All samples from the start of the file to lastLoadedPos metadataTags: MetadataTags | null = null; + xingData: { + frameCount: number | null; + fileSize: number | null; + } | null = null; tracks: InputAudioTrack[] = []; @@ -109,12 +128,33 @@ export class Mp3Demuxer extends Demuxer { if (isXing) { // There's no actual audio data in this frame, so let's skip it + + if (!this.xingData) { + let xingDataSlice = this.reader.requestSlice(result.startPos + xingOffset + 4, 12); + if (xingDataSlice instanceof Promise) xingDataSlice = await xingDataSlice; + if (xingDataSlice) { + const xingData = readBytes(xingDataSlice, 12); + const view = toDataView(xingData); + const flags = view.getUint32(0, false); + + this.xingData = { + frameCount: (flags & XingFlags.FrameCount) + ? view.getUint32(4, false) + : null, + fileSize: (flags & XingFlags.FileSize) + ? view.getUint32(8, false) + : null, + }; + } + } + return; } } if (!this.firstFrameHeader) { this.firstFrameHeader = header; + this.firstFrameHeaderPos = result.startPos; } if (header.sampleRate !== this.firstFrameHeader.sampleRate) { @@ -138,6 +178,38 @@ export class Mp3Demuxer extends Demuxer { return; } + async getDurationFromMetadata(): Promise { + await this.readMetadata(); + assert(this.firstFrameHeader !== null); + assert(this.firstFrameHeaderPos !== null); + + if (this.xingData) { + if (this.xingData.frameCount !== null) { + return this.xingData.frameCount + * this.firstFrameHeader.audioSamplesInFrame + / this.firstFrameHeader.sampleRate; + } + } else { + // No Xing, assuming CBR + + if (this.reader.fileSize !== null) { + const averageFrameSize = computeAverageMp3FrameSize( + this.firstFrameHeader.lowSamplingFrequency, + this.firstFrameHeader.layer, + this.firstFrameHeader.bitrate, + this.firstFrameHeader.sampleRate, + ); + const frameCount = (this.reader.fileSize - this.firstFrameHeaderPos) / averageFrameSize; + + return Math.round(frameCount) + * this.firstFrameHeader.audioSamplesInFrame + / this.firstFrameHeader.sampleRate; + } + } + + return null; + } + async getMimeType() { return 'audio/mpeg'; } @@ -237,6 +309,10 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking { return null; } + getDurationFromMetadata() { + return this.demuxer.getDurationFromMetadata(); + } + async getLiveRefreshInterval() { return null; } diff --git a/src/mp3/mp3-muxer.ts b/src/mp3/mp3-muxer.ts index 9fd6007..59d3da3 100644 --- a/src/mp3/mp3-muxer.ts +++ b/src/mp3/mp3-muxer.ts @@ -6,7 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { assert, toDataView } from '../misc'; +import { toDataView } from '../misc'; import { metadataTagsAreEmpty } from '../metadata'; import { Muxer } from '../muxer'; import { Output, OutputAudioTrack } from '../output'; @@ -107,14 +107,14 @@ export class Mp3Muxer extends Muxer { this.validateAndNormalizeTimestamp(track, packet.timestamp, packet.type === 'key'); + if (writeXingHeader) { + this.framePositions.push(this.writer.getPos()); + } + this.writer.write(packet.data); this.frameCount++; await this.writer.flush(); - - if (writeXingHeader) { - this.framePositions.push(this.writer.getPos()); - } } finally { release(); } @@ -132,20 +132,20 @@ export class Mp3Muxer extends Muxer { const release = await this.mutex.acquire(); const endPos = this.writer.getPos(); + const audioDataEndPos = endPos - this.xingFramePos; this.writer.seek(this.xingFramePos); const toc = new Uint8Array(100); for (let i = 0; i < 100; i++) { const index = Math.floor(this.framePositions.length * (i / 100)); - assert(index !== -1 && index < this.framePositions.length); - const byteOffset = this.framePositions[index]!; - toc[i] = 256 * (byteOffset / endPos); + const byteOffset = this.framePositions[index]! - this.xingFramePos; + toc[i] = 256 * (byteOffset / audioDataEndPos); } this.xingFrameData.frameCount = this.frameCount; - this.xingFrameData.fileSize = endPos; + this.xingFrameData.fileSize = audioDataEndPos; this.xingFrameData.toc = toc; if (this.format._options.onXingFrame) { diff --git a/src/mp3/mp3-writer.ts b/src/mp3/mp3-writer.ts index 2fab488..3ebd833 100644 --- a/src/mp3/mp3-writer.ts +++ b/src/mp3/mp3-writer.ts @@ -12,6 +12,7 @@ import { getXingOffset, KILOBIT_RATES, XING, + XingFlags, } from '../../shared/mp3-misc'; export type XingFrameData = { @@ -96,13 +97,13 @@ export class Mp3Writer { let flags = 0; if (data.frameCount !== null) { - flags |= 1; + flags |= XingFlags.FrameCount; } if (data.fileSize !== null) { - flags |= 2; + flags |= XingFlags.FileSize; } if (data.toc !== null) { - flags |= 4; + flags |= XingFlags.Toc; } this.writeU32(flags); diff --git a/src/mpeg-ts/mpeg-ts-demuxer.ts b/src/mpeg-ts/mpeg-ts-demuxer.ts index 5cf054a..3c03745 100644 --- a/src/mpeg-ts/mpeg-ts-demuxer.ts +++ b/src/mpeg-ts/mpeg-ts-demuxer.ts @@ -708,6 +708,10 @@ export class MpegTsDemuxer extends Demuxer { return this.tracks; } + async getDurationFromMetadata(): Promise { + return null; + } + async getMetadataTags(): Promise { return {}; // Nothing for now } @@ -1098,6 +1102,10 @@ abstract class MpegTsTrackBacking implements InputTrackBacking { return null; } + async getDurationFromMetadata() { + return null; + } + async getLiveRefreshInterval() { return null; } diff --git a/src/ogg/ogg-demuxer.ts b/src/ogg/ogg-demuxer.ts index ef3f7cd..8f2a7a5 100644 --- a/src/ogg/ogg-demuxer.ts +++ b/src/ogg/ogg-demuxer.ts @@ -386,6 +386,10 @@ export class OggDemuxer extends Demuxer { }); } + async getDurationFromMetadata(): Promise { + return null; // Not stored anywhere + } + async getTracks() { await this.readMetadata(); return this.tracks; @@ -464,6 +468,10 @@ class OggAudioTrackBacking implements InputAudioTrackBacking { return null; } + async getDurationFromMetadata() { + return null; + } + async getLiveRefreshInterval() { return null; } diff --git a/src/segmented-input.ts b/src/segmented-input.ts index d0f3e4c..665c384 100644 --- a/src/segmented-input.ts +++ b/src/segmented-input.ts @@ -7,7 +7,7 @@ */ import { AudioCodec, MediaCodec, VideoCodec } from './codec'; -import { Demuxer } from './demuxer'; +import { Demuxer, DurationMetadataRequestOptions } from './demuxer'; import { Input } from './input'; import { VirtualInputFormat } from './input-format'; import { @@ -42,6 +42,7 @@ export type AssociatedGroup = { export type Segment = { timestamp: number; + duration: number; relativeToUnixEpoch: boolean; firstSegment: Segment | null; }; @@ -75,6 +76,17 @@ export abstract class SegmentedInput { abstract getLiveRefreshInterval(): Promise; + async getDurationFromMetadata(options: DurationMetadataRequestOptions) { + const lastSegment = await this.getSegmentAt(Infinity, { + skipLiveWait: options.skipLiveWait, + }); + if (!lastSegment) { + return null; + } + + return lastSegment.timestamp + lastSegment.duration; + } + toInput() { return this.virtualInput ??= new Input({ source: new NullSource(), @@ -102,6 +114,10 @@ class SegmentedInputDemuxer extends Demuxer { this.segmentedInput = segmentedInput; } + async getDurationFromMetadata(): Promise { + throw new Error('Unreachable'); + } + async getMetadataTags(): Promise { throw new Error('Unreachable'); } @@ -253,6 +269,10 @@ class SegmentedInputInputTrackBacking implements InputTrackBacking { return this.firstInputTrack._backing.getAverageBitrate(); } + getDurationFromMetadata(options: DurationMetadataRequestOptions): Promise { + return this.demuxer.segmentedInput.getDurationFromMetadata(options); + } + getLiveRefreshInterval(): Promise { return this.demuxer.segmentedInput.getLiveRefreshInterval(); } diff --git a/src/wave/wave-demuxer.ts b/src/wave/wave-demuxer.ts index 276ccfa..946d0f2 100644 --- a/src/wave/wave-demuxer.ts +++ b/src/wave/wave-demuxer.ts @@ -328,6 +328,13 @@ export class WaveDemuxer extends Demuxer { return null; } + async getDurationFromMetadata(): Promise { + await this.readMetadata(); + assert(this.dataSize !== -1); + + return this.dataSize / this.audioInfo!.blockSizeInBytes / this.audioInfo!.sampleRate; + } + async getMimeType() { return 'audio/wav'; } @@ -414,6 +421,10 @@ class WaveAudioTrackBacking implements InputAudioTrackBacking { return null; } + getDurationFromMetadata() { + return this.demuxer.getDurationFromMetadata(); + } + async getLiveRefreshInterval() { return null; } diff --git a/test/node/duration-from-metadata.test.ts b/test/node/duration-from-metadata.test.ts new file mode 100644 index 0000000..a0cdfcd --- /dev/null +++ b/test/node/duration-from-metadata.test.ts @@ -0,0 +1,124 @@ +import { expect, test } from 'vitest'; +import path from 'node:path'; +import { ALL_FORMATS, Input, FilePathSource } from '../../src/index.js'; +import { assert } from '../../src/misc.js'; + +const __dirname = new URL('.', import.meta.url).pathname; +const publicPath = (file: string) => path.join(__dirname, '../public', file); + +test('MP4 ISOBMFF duration metadata', async () => { + using input = new Input({ + source: new FilePathSource(publicPath('video.mp4')), + formats: ALL_FORMATS, + }); + + expect(await input.getDurationFromMetadata()).toBe(5.056); + expect(await input.computeDuration()).toBe(5.056); + + const videoTrack = await input.getPrimaryVideoTrack(); + assert(videoTrack); + expect(await videoTrack.getDurationFromMetadata()).toBe(5); + + const audioTrack = await input.getPrimaryAudioTrack(); + assert(audioTrack); + expect(await audioTrack.getDurationFromMetadata()).toBe(5.056); +}); + +test('Matroska MKV duration metadata', async () => { + using input = new Input({ + source: new FilePathSource(publicPath('ac3.mkv')), + formats: ALL_FORMATS, + }); + + expect(await input.getDurationFromMetadata()).toBe(2); + expect(await input.computeDuration()).toBe(2); + + const audioTrack = await input.getPrimaryAudioTrack(); + assert(audioTrack); + expect(await audioTrack.getDurationFromMetadata()).toBe(2); +}); + +test('MPEG-TS duration metadata', async () => { + using input = new Input({ + source: new FilePathSource(publicPath('0.ts')), + formats: ALL_FORMATS, + }); + + expect(await input.getDurationFromMetadata()).toBe(null); + expect(await input.computeDuration()).not.toBe(null); + + const tracks = await input.getTracks(); + for (const track of tracks) { + expect(await track.getDurationFromMetadata()).toBe(null); + } +}); + +test('FLAC duration metadata', async () => { + using input = new Input({ + source: new FilePathSource(publicPath('sample.flac')), + formats: ALL_FORMATS, + }); + + expect(await input.getDurationFromMetadata()).toBe(19.714285714285715); + expect(await input.computeDuration()).toBe(19.714285714285715); + + const audioTrack = await input.getPrimaryAudioTrack(); + assert(audioTrack); + expect(await audioTrack.getDurationFromMetadata()).toBe(19.714285714285715); +}); + +test('WAV duration metadata', async () => { + using input = new Input({ + source: new FilePathSource(publicPath('glitch-hop-is-dead.wav')), + formats: ALL_FORMATS, + }); + + expect(await input.getDurationFromMetadata()).toBe(9.63718820861678); + expect(await input.computeDuration()).toBe(9.63718820861678); + + const audioTrack = await input.getPrimaryAudioTrack(); + assert(audioTrack); + expect(await audioTrack.getDurationFromMetadata()).toBe(9.63718820861678); +}); + +test('OGG Vorbis duration metadata', async () => { + using input = new Input({ + source: new FilePathSource(publicPath('vorbis-eos.ogg')), + formats: ALL_FORMATS, + }); + + expect(await input.getDurationFromMetadata()).toBe(null); + + const tracks = await input.getTracks(); + for (const track of tracks) { + expect(await track.getDurationFromMetadata()).toBe(null); + } +}); + +test('ADTS AAC duration metadata', async () => { + using input = new Input({ + source: new FilePathSource(publicPath('sample3.aac')), + formats: ALL_FORMATS, + }); + + expect(await input.getDurationFromMetadata()).toBe(null); + + const tracks = await input.getTracks(); + for (const track of tracks) { + expect(await track.getDurationFromMetadata()).toBe(null); + } +}); + +test('MP3 duration metadata', async () => { + using input = new Input({ + source: new FilePathSource(publicPath('Toothsome-Meme.VBRv2.mp3')), + formats: ALL_FORMATS, + }); + + expect(await input.getDurationFromMetadata()).toBe(38.568); + expect(await input.computeDuration()).toBe(38.568000000000005); + + const audioTrack = await input.getPrimaryAudioTrack(); + assert(audioTrack); + expect(await audioTrack.getDurationFromMetadata()).toBe(38.568); +}); diff --git a/test/node/hls-input.test.ts b/test/node/hls-input.test.ts index 1e812c1..5151f43 100644 --- a/test/node/hls-input.test.ts +++ b/test/node/hls-input.test.ts @@ -19,6 +19,7 @@ test.concurrent('Big Buck Bunny', { timeout: 15_000 }, async () => { expect(await input.getFormat()).toBeInstanceOf(HlsInputFormat); expect(await input.getFormat()).toBe(HLS); + expect(await input.getDurationFromMetadata()).toBe(null); // Since it's a master playlist! expect(sourceCount).toBe(1); @@ -154,6 +155,9 @@ test.concurrent('Big Buck Bunny', { timeout: 15_000 }, async () => { expect(await track.isLive()).toBe(false); } + expect(await videoTracks[0]!.getDurationFromMetadata()).toBe(634.584); + expect(await audioTracks[0]!.getDurationFromMetadata()).toBe(634.584); + expect(videoTracks[0]!.codedWidth).toBe(1280); expect(videoTracks[0]!.codedHeight).toBe(720); expect(videoTracks[1]!.codedWidth).toBe(320); @@ -797,4 +801,16 @@ test.concurrent('Live HLS', { timeout: 30_000 }, async () => { const nextPacket = await sink.getNextPacket(currentLastPacket); assert(nextPacket); expect(nextPacket.sequenceNumber).toBeGreaterThan(currentLastPacket.sequenceNumber); + + const metadataDurationFailed = Promise.race([ + videoTrack.getDurationFromMetadata(), + rejectAfter(2000, 'Baby you make me smile'), + ]); + await expect(metadataDurationFailed).rejects.toThrow('Baby you make me smile'); + + const metadataDuration = await Promise.race([ + videoTrack.getDurationFromMetadata({ skipLiveWait: true }), + rejectAfter(2000, 'Baby you make me smile'), + ]); + expect(metadataDuration).toBeGreaterThan((Date.now() / 1000) - 3600); }); diff --git a/todo.txt b/todo.txt index 5d5a71d..d7be6ec 100644 --- a/todo.txt +++ b/todo.txt @@ -5,6 +5,5 @@ Also, why not just have the packet metadata on the packet? I think that would ma - ALL_FORMATS but for HLS only -- computeDuration and getFirstTimestamp are dangerous with HLS - keep input/demuxer.isSupported()? - getCodecParamterString() hack feels dirty; idk. well its not really a hack but, surface it over a different field? \ No newline at end of file