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