
diff --git a/examples/media-player/media-player.ts b/examples/media-player/media-player.ts
index bb8b94d..1e1169a 100644
--- a/examples/media-player/media-player.ts
+++ b/examples/media-player/media-player.ts
@@ -1,19 +1,10 @@
import {
ALL_FORMATS,
AudioBufferSink,
- BlobSource,
CanvasSink,
- Input,
- InputAudioTrack,
- InputVideoTrack,
- UrlSource,
+ createInputFrom,
WrappedAudioBuffer,
WrappedCanvas,
- asc,
- canDecodeAudio,
- createInputFrom,
- desc,
- prefer,
} from 'mediabunny';
import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4';
@@ -38,6 +29,7 @@ const volumeBarContainer = document.querySelector('#volume-bar-container') as HT
const volumeBar = document.querySelector('#volume-bar') as HTMLDivElement;
const volumeIconWrapper = document.querySelector('#volume-icon-wrapper') as HTMLDivElement;
const volumeButton = document.querySelector('#volume-button') as HTMLButtonElement;
+const liveDot = document.querySelector('#live-dot') as HTMLButtonElement;
const fullscreenButton = document.querySelector('#fullscreen-button') as HTMLButtonElement;
const errorElement = document.querySelector('#error-element') as HTMLDivElement;
const warningElement = document.querySelector('#warning-element') as HTMLDivElement;
@@ -51,7 +43,9 @@ let fileLoaded = false;
let videoSink: CanvasSink | null = null;
let audioSink: AudioBufferSink | null = null;
-let totalDuration = 0;
+let firstTimestamp = 0;
+let endTimestamp = 0;
+let isRelativeToUnixEpoch = false;
/** The value of the audio context's currentTime the moment the playback was started. */
let audioContextStartTime: number | null = null;
let playing = false;
@@ -69,6 +63,8 @@ const queuedAudioNodes: Set
= new Set();
*/
let asyncId = 0;
+let liveRefreshIntervalId = -1;
+
let draggingProgressBar = false;
let volume = 0.7;
let draggingVolumeBar = false;
@@ -84,107 +80,49 @@ const initMediaPlayer = async (resource: File | string) => {
pause();
}
- // const dirHandle = await showDirectoryPicker({ mode: 'read' });
-
void videoFrameIterator?.return();
void audioBufferIterator?.return();
+
asyncId++;
fileLoaded = false;
- fileNameElement.textContent = 'pish'; // resource instanceof File ? resource.name : resource;
+ fileNameElement.textContent = resource instanceof File ? resource.name : resource;
horizontalRule.style.display = '';
loadingElement.style.display = '';
playerContainer.style.display = 'none';
errorElement.textContent = '';
warningElement.textContent = '';
+ liveDot.style.display = 'none';
+ clearTimeout(liveRefreshIntervalId);
- const start = 0;
-
- let videoTrack: InputVideoTrack | null = null;
- let audioTrack: InputAudioTrack | null = null;
- if (true || typeof resource === 'string' && resource.includes('.m3u8')) {
- const input = createInputFrom(resource, ALL_FORMATS);
-
- /*
- const input = new Input({
- entryPath: resource, // 'master.m3u8',
- source: async ({ path }) => {
- return new UrlSource(path);
- const fileHandle = await dirHandle.getFileHandle(path);
- const file = await fileHandle.getFile();
- return new BlobSource(file);
- },
- formats: ALL_FORMATS,
- });
- */
- // const variant = (await manifestInput.getVariants())[0]!;
-
- // console.log(await input.getFormat(), await input.getTracks());
- // return;
-
- /*
- await input.getVideoTracks({
- filter: desc => desc.hasPairableAudioTrack(),
- sortBy: desc => asc(desc.bitrate),
- });
- */
-
- videoTrack = await input.getPrimaryVideoTrack({
- filter: async desc => (desc.displayHeight ?? (await desc.getTrack()).displayHeight) < 720,
- // filter: async track => (await track.resolve('displayHeight')) < 1080,
- });
- audioTrack = await input.getPrimaryAudioTrack({
- sortBy: desc => prefer(desc.canBePairedWith(videoTrack)),
- });
-
- // await videoTrack?.hydrate();
- // await audioTrack?.hydrate();
-
- totalDuration = Math.max(
- await videoTrack?.computeDuration({ skipLiveWait: true }) ?? 0,
- await audioTrack?.computeDuration({ skipLiveWait: true }) ?? 0,
- );
-
- console.log(videoTrack, audioTrack, totalDuration);
-
- // start = totalDuration - 2;
-
- // totalDuration += 3600;
-
- // https://test-streams.mux.dev/test_001/stream.m3u8
- // https://test-streams.mux.dev/test_001/stream_1000k_48k_640x360_050.ts
- } else {
- const source = resource instanceof File
- ? new BlobSource(resource)
- : new UrlSource(resource);
-
- const input = new Input({
- source,
- formats: ALL_FORMATS,
- });
-
- totalDuration = await input.computeDuration();
- videoTrack = await input.getPrimaryVideoTrack();
- audioTrack = await input.getPrimaryAudioTrack();
- }
-
- /*
// Create an Input from the resource
- const source = resource instanceof File
- ? new BlobSource(resource)
- : new UrlSource(resource);
- */
+ const input = createInputFrom(resource, ALL_FORMATS);
- /*
- const input = new Input({
- source,
- formats: ALL_FORMATS,
- });
- */
+ let videoTrack = await input.getPrimaryVideoTrack();
+ let audioTrack = await input.getPrimaryAudioTrack();
- playbackTimeAtStart = 0;
+ const tracks = [videoTrack, audioTrack].filter(t => t !== null);
- durationElement.textContent = formatSeconds(totalDuration);
+ firstTimestamp = Math.max(
+ await input.getFirstTimestamp(tracks),
+ 0,
+ );
+ endTimestamp = await input.getDurationFromMetadata(tracks, { skipLiveWait: true })
+ ?? await input.computeDuration(tracks, { skipLiveWait: true });
+ isRelativeToUnixEpoch = tracks.some(t => t?.isRelativeToUnixEpoch);
+ playbackTimeAtStart = firstTimestamp;
+
+ // Configure the time display elements accordingly
+ const timestampFontSize = isRelativeToUnixEpoch ? '12px' : '';
+ const timestampWhiteSpace = isRelativeToUnixEpoch ? 'pre' : '';
+ const timestampTextAlign = isRelativeToUnixEpoch ? 'center' : '';
+ currentTimeElement.style.fontSize = timestampFontSize;
+ currentTimeElement.style.whiteSpace = timestampWhiteSpace;
+ currentTimeElement.style.textAlign = timestampTextAlign;
+ durationElement.style.fontSize = timestampFontSize;
+ durationElement.style.whiteSpace = timestampWhiteSpace;
+ durationElement.style.textAlign = timestampTextAlign;
+ durationElement.textContent = formatTimestamp(endTimestamp);
let problemMessage = '';
@@ -269,8 +207,6 @@ const initMediaPlayer = async (resource: File | string) => {
await startVideoIterator();
if (audioContext.state === 'running') {
- await seekToTime(start);
-
// Start playback automatically if the audio context permits
await play();
}
@@ -284,6 +220,38 @@ const initMediaPlayer = async (resource: File | string) => {
controlsElement.style.pointerEvents = '';
playerContainer.style.cursor = '';
}
+
+ const refreshIntervals = await Promise.all(tracks.map(t => t.getLiveRefreshInterval()));
+ const nonNullIntervals = refreshIntervals.filter(x => x !== null);
+
+ if (nonNullIntervals.length > 0) {
+ // At least one track is live! This means that we'll need to continually refresh the end timestamp of the
+ // media to allow continuous live playback.
+
+ const interval = Math.min(...nonNullIntervals);
+ liveDot.style.display = '';
+ liveDot.onclick = () => {
+ void seekToTime(endTimestamp - interval * 1.5);
+ };
+
+ const scheduleLiveRefresh = () => {
+ // eslint-disable-next-line @typescript-eslint/no-misused-promises
+ liveRefreshIntervalId = window.setTimeout(async () => {
+ endTimestamp = await input.getDurationFromMetadata(tracks, { skipLiveWait: true })
+ ?? await input.computeDuration(tracks, { skipLiveWait: true });
+ durationElement.textContent = formatTimestamp(endTimestamp);
+
+ // Check if we're still live
+ const stillLive = await Promise.all(tracks.map(t => t.isLive()));
+ if (stillLive.every(live => !live)) {
+ liveDot.style.display = 'none';
+ } else {
+ scheduleLiveRefresh();
+ }
+ }, interval * 1000);
+ };
+ scheduleLiveRefresh();
+ }
} catch (error) {
console.error(error);
@@ -325,10 +293,10 @@ const startVideoIterator = async () => {
const render = (requestFrame = true) => {
if (fileLoaded) {
const playbackTime = getPlaybackTime();
- if (playbackTime >= totalDuration) {
+ if (playbackTime >= endTimestamp) {
// Pause playback once the end is reached
pause();
- playbackTimeAtStart = totalDuration;
+ playbackTimeAtStart = endTimestamp;
}
// Check if the current playback time has caught up to the next frame
@@ -449,9 +417,9 @@ const play = async () => {
await audioContext!.resume();
}
- if (getPlaybackTime() === totalDuration) {
+ if (getPlaybackTime() === endTimestamp) {
// If we're at the end, let's snap back to the start
- playbackTimeAtStart = 0;
+ playbackTimeAtStart = firstTimestamp;
await startVideoIterator();
}
@@ -506,7 +474,7 @@ const seekToTime = async (seconds: number) => {
await startVideoIterator();
- if (wasPlaying && playbackTimeAtStart < totalDuration) {
+ if (wasPlaying && playbackTimeAtStart < endTimestamp) {
void play();
}
};
@@ -514,8 +482,8 @@ const seekToTime = async (seconds: number) => {
/** === PROGRESS BAR LOGIC === */
const updateProgressBarTime = (seconds: number) => {
- currentTimeElement.textContent = formatSeconds(seconds);
- progressBar.style.width = `${(seconds / totalDuration) * 100}%`;
+ currentTimeElement.textContent = formatTimestamp(seconds);
+ progressBar.style.width = `${((seconds - firstTimestamp) / (endTimestamp - firstTimestamp)) * 100}%`;
};
progressBarContainer.addEventListener('pointerdown', (event) => {
@@ -524,7 +492,7 @@ progressBarContainer.addEventListener('pointerdown', (event) => {
const rect = progressBarContainer.getBoundingClientRect();
const completion = Math.max(Math.min((event.clientX - rect.left) / rect.width, 1), 0);
- updateProgressBarTime(completion * totalDuration);
+ updateProgressBarTime(firstTimestamp + completion * (endTimestamp - firstTimestamp));
clearTimeout(hideControlsTimeout);
@@ -534,7 +502,7 @@ progressBarContainer.addEventListener('pointerdown', (event) => {
const rect = progressBarContainer.getBoundingClientRect();
const completion = Math.max(Math.min((event.clientX - rect.left) / rect.width, 1), 0);
- const newTime = completion * totalDuration;
+ const newTime = firstTimestamp + completion * (endTimestamp - firstTimestamp);
void seekToTime(newTime);
showControlsTemporarily();
@@ -545,7 +513,7 @@ progressBarContainer.addEventListener('pointermove', (event) => {
if (draggingProgressBar) {
const rect = progressBarContainer.getBoundingClientRect();
const completion = Math.max(Math.min((event.clientX - rect.left) / rect.width, 1), 0);
- updateProgressBarTime(completion * totalDuration);
+ updateProgressBarTime(firstTimestamp + completion * (endTimestamp - firstTimestamp));
}
});
@@ -662,10 +630,10 @@ window.addEventListener('keydown', (e) => {
} else if (e.code === 'KeyF') {
fullscreenButton.click();
} else if (e.code === 'ArrowLeft') {
- const newTime = Math.max(getPlaybackTime() - 5, 0);
+ const newTime = Math.max(getPlaybackTime() - 5, firstTimestamp);
void seekToTime(newTime);
} else if (e.code === 'ArrowRight') {
- const newTime = Math.min(getPlaybackTime() + 5, totalDuration);
+ const newTime = Math.min(getPlaybackTime() + 5, endTimestamp);
void seekToTime(newTime);
} else if (e.code === 'KeyM') {
volumeButton.click();
@@ -711,6 +679,15 @@ controlsElement.addEventListener('click', (event) => {
/** === UTILS === */
+const formatTimestamp = (seconds: number) => {
+ if (isRelativeToUnixEpoch) {
+ const iso = new Date(seconds * 1000).toISOString();
+ return iso.replace('T', '\n');
+ }
+
+ return formatSeconds(seconds);
+};
+
const formatSeconds = (seconds: number) => {
const showMilliseconds = window.innerWidth >= 640;
@@ -737,9 +714,9 @@ const formatSeconds = (seconds: number) => {
};
window.addEventListener('resize', () => {
- if (totalDuration) {
+ if (endTimestamp) {
updateProgressBarTime(getPlaybackTime());
- durationElement.textContent = formatSeconds(totalDuration);
+ durationElement.textContent = formatTimestamp(endTimestamp);
}
});
@@ -779,12 +756,6 @@ document.addEventListener('dragover', (event) => {
event.dataTransfer!.dropEffect = 'copy';
});
-/*
-document.addEventListener('click', () => {
- void initMediaPlayer();
-}, { once: true });
-*/
-
document.addEventListener('drop', (event) => {
event.preventDefault();
const files = event.dataTransfer?.files;
diff --git a/examples/metadata-extraction/metadata-extraction.ts b/examples/metadata-extraction/metadata-extraction.ts
index c58804f..af2acd1 100644
--- a/examples/metadata-extraction/metadata-extraction.ts
+++ b/examples/metadata-extraction/metadata-extraction.ts
@@ -1,4 +1,4 @@
-import { Input, ALL_FORMATS, BlobSource, UrlSource } from 'mediabunny';
+import { ALL_FORMATS, createInputFrom } from 'mediabunny';
import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4';
(document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl;
@@ -10,44 +10,42 @@ const horizontalRule = document.querySelector('hr') as HTMLHRElement;
const bytesReadElement = document.querySelector('#bytes-read') as HTMLParagraphElement;
const metadataContainer = document.querySelector('#metadata-container') as HTMLDivElement;
-const extractMetadata = async (resource: File | string) => {
+const extractMetadata = (resource: File | string) => {
// Create a new input from the resource
- let input: Input;
- if (resource instanceof File) {
- input = new Input({
- source: new BlobSource(resource),
- formats: ALL_FORMATS, // Accept all formats
- });
- } else {
- input = new Input({
- entryPath: resource,
- source: ({ path }) => new UrlSource(path),
- formats: ALL_FORMATS, // Accept all formats
- });
- }
+ const input = createInputFrom(resource, ALL_FORMATS); // Accept all formats
let bytesRead = 0;
let fileSize: number | null = null;
+ let obtainedSources = 0;
const updateBytesRead = () => {
- bytesReadElement.textContent = `Bytes read: ${bytesRead} / ${fileSize === null ? '?' : fileSize}`;
+ if (obtainedSources > 1) {
+ bytesReadElement.textContent = `Bytes read: ${bytesRead} across ${obtainedSources} files`;
+ } else {
+ bytesReadElement.textContent = `Bytes read: ${bytesRead} / ${fileSize === null ? '?' : fileSize}`;
- if (fileSize !== null) {
- bytesReadElement.textContent += ` (${(100 * bytesRead / fileSize).toPrecision(3)}% of entire file)`;
+ if (fileSize !== null) {
+ bytesReadElement.textContent += ` (${(100 * bytesRead / fileSize).toPrecision(3)}% of entire file)`;
+ }
}
};
- const source = await input.getSource();
+ input.on('source', ({ source, isRoot }) => {
+ if (isRoot) {
+ // Get the input's size
+ void source.getSize().then((size) => {
+ fileSize = size;
+ updateBytesRead();
+ });
+ }
- source.onread = (start, end) => {
- bytesRead += end - start;
+ obtainedSources++;
updateBytesRead();
- };
- // Get the input's size
- void source.getSize().then((size) => {
- fileSize = size;
- updateBytesRead();
+ source.on('read', ({ start, end }) => {
+ bytesRead += end - start;
+ updateBytesRead();
+ });
});
// This object contains all the data that gets displayed:
@@ -57,28 +55,26 @@ const extractMetadata = async (resource: File | string) => {
'Starts at': input.getFirstTimestamp().then(start => `${start} seconds`),
'Ends at': input.computeDuration().then(duration => `${duration} seconds`),
'Tracks': input.getTracks().then(tracks => tracks.map(track => ({
- 'Type': track.resolve('type').then(type => type),
- 'Codec': track.resolve('codec').then(codec => codec),
+ 'Type': track.type,
+ 'Codec': track.codec,
'Full codec string': track.getCodecParameterString(),
'Starts at': track.getFirstTimestamp().then(start => `${start} seconds`),
'Ends at': track.computeDuration().then(duration => `${duration} seconds`),
- 'Language code': track.resolve('languageCode').then(languageCode => languageCode),
+ 'Language code': track.languageCode,
...(track.isVideoTrack()
? {
- 'Coded width': track.resolve('codedWidth').then(codedWidth => `${codedWidth} pixels`),
- 'Coded height': track.resolve('codedHeight').then(codedHeight => `${codedHeight} pixels`),
- 'Rotation': track.resolve('rotation').then(rotation => `${rotation}° clockwise`),
- 'Pixel aspect ratio': track.resolve('pixelAspectRatio').then(pixelAspectRatio =>
- `${pixelAspectRatio.num}:${pixelAspectRatio.den}`,
- ),
- 'Display width': track.resolve('displayWidth').then(displayWidth => `${displayWidth} pixels`),
- 'Display height': track.resolve('displayHeight').then(displayHeight => `${displayHeight} pixels`),
+ 'Coded width': `${track.codedWidth} pixels`,
+ 'Coded height': `${track.codedHeight} pixels`,
+ 'Rotation': `${track.rotation}° clockwise`,
+ 'Pixel aspect ratio': `${track.pixelAspectRatio.num}:${track.pixelAspectRatio.den}`,
+ 'Display width': `${track.displayWidth} pixels`,
+ 'Display height': `${track.displayHeight} pixels`,
'Transparency': track.canBeTransparent(),
}
: track.isAudioTrack()
? {
- 'Number of channels': track.resolve('numberOfChannels').then(numberOfChannels => numberOfChannels),
- 'Sample rate': track.resolve('sampleRate').then(sampleRate => `${sampleRate} Hz`),
+ 'Number of channels': track.numberOfChannels,
+ 'Sample rate': `${track.sampleRate} Hz`,
}
: {}),
'Packet statistics': shortDelay().then(() => track.computePacketStats()).then(stats => ({
diff --git a/examples/procedural-generation/procedural-generation.ts b/examples/procedural-generation/procedural-generation.ts
index 93efdb2..ca4d90d 100644
--- a/examples/procedural-generation/procedural-generation.ts
+++ b/examples/procedural-generation/procedural-generation.ts
@@ -8,12 +8,6 @@ import {
getFirstEncodableAudioCodec,
getFirstEncodableVideoCodec,
OutputFormat,
- HlsOutputFormat,
- OutputTrackGroup,
- MpegTsOutputFormat,
- AdtsOutputFormat,
- StreamTarget,
- CmafOutputFormat,
} from 'mediabunny';
const durationSlider = document.querySelector('#duration-slider') as HTMLInputElement;
@@ -72,11 +66,6 @@ const generateVideo = async () => {
let progressInterval = -1;
try {
- const dirHandle = await showDirectoryPicker({ mode: 'readwrite' });
- for await (const [name, handle] of dirHandle) {
- await dirHandle.removeEntry(name, { recursive: true });
- }
-
// Let's set some DOM state
renderButton.disabled = true;
renderButton.textContent = 'Generating...';
@@ -97,39 +86,8 @@ const generateVideo = async () => {
// Create a new output file
output = new Output({
- rootPath: 'master.m3u8',
- target: async ({ path }) => {
- const fileHandle = await dirHandle.getFileHandle(path, { create: true });
- const writable = await fileHandle.createWritable();
-
- const target = new StreamTarget(writable);
- target.on('finalized', () => console.log('Finalizado', path));
-
- return target;
-
- /*
- const target = new BufferTarget();
- target.on('finalized', async () => {
- if (path.includes('m3u8')) {
- console.log(new TextDecoder().decode(target.buffer!));
- }
- });
-
- if (path.includes('m4s')) {
- console.log('here');
- target.on('write', ({ start, end }) => console.log(start, end));
- target.on('finalized', () => console.log('yippie'));
- }
-
- return target;
- */
- },
- format: new HlsOutputFormat({
- segmentFormat: new CmafOutputFormat(),
- // singleFilePerPlaylist: true,
- // live: true,
- getPlaylistPath: info => `sussex-${info.n}.m3u8`,
- }),
+ target: new BufferTarget(), // Stored in memory
+ format: new Mp4OutputFormat(),
});
// Retrieve the first video codec supported by this browser that can be contained in the output format
@@ -145,18 +103,11 @@ const generateVideo = async () => {
const canvasSource = new CanvasSource(renderCanvas, {
codec: videoCodec,
bitrate: QUALITY_HIGH,
- keyFrameInterval: 2,
- transform: {
- // frameRate: 5,
- },
});
output.addVideoTrack(canvasSource, { frameRate });
- // output._defaultTrackGroup.pair(otherGroup);
-
// For audio, we use ArrayBufferSource, because we'll be creating an ArrayBuffer with OfflineAudioContext
let audioBufferSource: AudioBufferSource | null = null;
- const audioBufferSource2: AudioBufferSource | null = null;
// Retrieve the first audio codec supported by this browser that can be contained in the output format
const audioCodec = await getFirstEncodableAudioCodec(output.format.getSupportedAudioCodecs(), {
@@ -167,19 +118,8 @@ const generateVideo = async () => {
audioBufferSource = new AudioBufferSource({
codec: audioCodec,
bitrate: QUALITY_HIGH,
- transform: {
-
- },
});
output.addAudioTrack(audioBufferSource);
-
- /*
- audioBufferSource2 = new AudioBufferSource({
- codec: audioCodec,
- bitrate: QUALITY_HIGH,
- });
- output.addAudioTrack(audioBufferSource2, { languageCode: 'esp' });
- */
} else {
alert('Your browser doesn\'t support audio encoding, so we won\'t include audio in the output file.');
}
@@ -211,8 +151,6 @@ const generateVideo = async () => {
// Add the current state of the canvas as a frame to the video. Using `await` here is crucial to
// automatically slow down the rendering loop when the encoder can't keep up.
await canvasSource.add(currentTime, 1 / frameRate);
-
- // await new Promise(resolve => setTimeout(resolve, 1000 / frameRate));
}
// Signal to the output that no more video frames are coming (not necessary, but recommended)
@@ -224,9 +162,6 @@ const generateVideo = async () => {
const audioBuffer = await audioContext.startRendering();
await audioBufferSource.add(audioBuffer);
audioBufferSource.close();
-
- // await audioBufferSource2!.add(audioBuffer);
- // audioBufferSource2!.close();
}
clearInterval(progressInterval);
@@ -245,14 +180,12 @@ const generateVideo = async () => {
videoInfo.style.display = '';
// Display and play the resulting media file
- /*
const videoBlob = new Blob([output.target.buffer!], { type: output.format.mimeType });
resultVideo.src = URL.createObjectURL(videoBlob);
void resultVideo.play();
const fileSizeMiB = (videoBlob.size / (1024 * 1024)).toPrecision(3);
videoInfo.textContent = `File size: ${fileSizeMiB} MiB`;
- */
} catch (error) {
console.error(error);
diff --git a/src/adts/adts-demuxer.ts b/src/adts/adts-demuxer.ts
index 79957cf..5c78489 100644
--- a/src/adts/adts-demuxer.ts
+++ b/src/adts/adts-demuxer.ts
@@ -143,10 +143,6 @@ export class AdtsDemuxer extends Demuxer {
this.lastLoadedPos = header.startPos + header.frameLength;
}
- async getDurationFromMetadata(): Promise {
- return null; // No way
- }
-
async getMimeType() {
return 'audio/aac';
}
@@ -232,7 +228,7 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking {
}
async getDurationFromMetadata() {
- return null;
+ return null; // No way
}
async getLiveRefreshInterval() {
diff --git a/src/conversion.ts b/src/conversion.ts
index e4430a7..2c3449a 100644
--- a/src/conversion.ts
+++ b/src/conversion.ts
@@ -733,9 +733,7 @@ export class Conversion {
// down later due to discarded tracks, but we need to fix the start timestamp now due to track processing
// depending on it.
this._startTimestamp = Math.max(
- Math.min(
- ...await Promise.all(filteredTracks.map(x => x.getFirstTimestamp())),
- ),
+ await this.input.getFirstTimestamp(filteredTracks),
// Samples can also have negative timestamps, but the meaning typically is "don't present me", so let's
// cut those out by default.
0,
diff --git a/src/demuxer.ts b/src/demuxer.ts
index 8cac71d..36a746c 100644
--- a/src/demuxer.ts
+++ b/src/demuxer.ts
@@ -36,7 +36,6 @@ export abstract class Demuxer {
abstract getTrackBackings(): 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 7617058..10a77d5 100644
--- a/src/flac/flac-demuxer.ts
+++ b/src/flac/flac-demuxer.ts
@@ -97,17 +97,6 @@ export class FlacDemuxer extends Demuxer {
return [this.trackBacking];
}
- 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';
}
@@ -618,8 +607,14 @@ class FlacAudioTrackBacking implements InputAudioTrackBacking {
return null;
}
- getDurationFromMetadata() {
- return this.demuxer.getDurationFromMetadata();
+ async getDurationFromMetadata() {
+ assert(this.demuxer.audioInfo);
+
+ if (this.demuxer.audioInfo.totalSamples === 0) {
+ return null;
+ }
+
+ return this.demuxer.audioInfo.totalSamples / this.demuxer.audioInfo.sampleRate;
}
async getLiveRefreshInterval() {
diff --git a/src/hls/hls-demuxer.ts b/src/hls/hls-demuxer.ts
index 4ba874a..45dcdd9 100644
--- a/src/hls/hls-demuxer.ts
+++ b/src/hls/hls-demuxer.ts
@@ -593,12 +593,6 @@ 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 {};
}
diff --git a/src/hls/hls-muxer.ts b/src/hls/hls-muxer.ts
index a52a69e..b3f078c 100644
--- a/src/hls/hls-muxer.ts
+++ b/src/hls/hls-muxer.ts
@@ -99,6 +99,7 @@ export class HlsMuxer extends Muxer {
maxLiveSegmentCount: number;
isRelativeToUnixEpoch = false;
globalTargetDuration: number;
+ numWrittenMasterPlaylists = 0;
playlists: Playlist[] = [];
playlistDeclarations: PlaylistDeclaration[] = [];
@@ -909,11 +910,11 @@ export class HlsMuxer extends Muxer {
return slice;
} else {
const playlistInfo = toPlaylistInfo(playlist);
- const path = await this.getInitPath(playlistInfo);
- validateInitPath(path);
+ const initPath = await this.getInitPath(playlistInfo);
+ validateInitPath(initPath);
playlist.initSegment = {
- path,
+ path: initPath,
duration: 0,
timestamp: 0,
byteSize: 0,
@@ -921,7 +922,7 @@ export class HlsMuxer extends Muxer {
};
const target = await this.output._getTarget({
- path,
+ path: initPath,
isRoot: false,
});
target.on('write', ({ end }) => {
@@ -1371,14 +1372,25 @@ export class HlsMuxer extends Muxer {
this.format._options.onMaster?.(masterPlaylistText);
- const target = await this.output._getTarget({ path: pathedTarget.rootPath, isRoot: true });
- const writer = new Writer(target);
+ let writer: Writer;
+ if (this.numWrittenMasterPlaylists === 0) {
+ // For the first master playlist write, we use the normal root writer getter, so that the target returned by
+ // Output.target emits valid write events.
+ writer = await this.output._getRootWriter();
+ } else {
+ // For subsequent master playlist writes, we *must* obtain a different target in order to overwrite
+ // the file.
+ const target = await this.output._getTarget({ path: pathedTarget.rootPath, isRoot: true });
+ writer = new Writer(target);
+ writer.start();
+ }
- writer.start();
writer.write(textEncoder.encode(masterPlaylistText));
await writer.flush();
await writer.finalize();
+
+ this.numWrittenMasterPlaylists++;
}
private async tryWriteMasterPlaylist() {
diff --git a/src/input-track.ts b/src/input-track.ts
index 11c9481..48c249a 100644
--- a/src/input-track.ts
+++ b/src/input-track.ts
@@ -179,16 +179,16 @@ export abstract class InputTrack {
}
/**
- * The peak bitrate of the track as specified in the track's metadata. This might not match the actual
- * media data's bitrate.
+ * 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.
*/
get bitrate() {
return this._backing.getBitrate();
}
/**
- * The average bitrate of the track as specified in the track's metadata. This might not match the actual
- * media data's bitrate.
+ * 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.
*/
get averageBitrate() {
return this._backing.getAverageBitrate();
@@ -219,9 +219,9 @@ export abstract class InputTrack {
}
/**
- * Gets the duration (end timestamp) of this track from metadata stored in the file. This value may be
+ * Gets the duration (end timestamp) in seconds 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`
+ * method, this method is cheaper. 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
diff --git a/src/input.ts b/src/input.ts
index 1287105..35e4c23 100644
--- a/src/input.ts
+++ b/src/input.ts
@@ -33,6 +33,7 @@ import {
arrayCount,
assert,
EventEmitter,
+ MaybePromise,
polyfillSymbolDispose,
removeItem,
} from './misc';
@@ -53,9 +54,14 @@ import {
UrlSource,
UrlSourceOptions,
} from './source';
+import * as nodeAlias from './node';
polyfillSymbolDispose();
+const node = typeof nodeAlias !== 'undefined'
+ ? nodeAlias // Aliasing it prevents some bundler warnings
+ : undefined!;
+
export const DEFAULT_SOURCE_CACHE_GROUP = 1;
export const ENCRYPTION_KEY_CACHE_GROUP = 2;
@@ -109,6 +115,8 @@ export type InputEvents = {
source: Source;
/** The request that led to loading this source, or `null` if the input is not pathed. */
request: SourceRequest | null;
+ /** Whether the source is the root file of the media. */
+ isRoot: boolean;
};
};
@@ -149,6 +157,10 @@ export class Input extends EventEmitter
/** @internal */
_sourceCache: SourceCacheEntry[] = [];
/** @internal */
+ _rootRef: SourceRef | null = null;
+ /** @internal */
+ _rootRefPromise: Promise> | null = null;
+ /** @internal */
_sourceCachePromises: {
request: SourceRequest;
cacheGroup: number;
@@ -180,6 +192,9 @@ export class Input extends EventEmitter
)) {
throw new TypeError('options.source must be a Source, SourceRef, or PathedSource.');
}
+ if (options.source instanceof Source && options.source._disposed) {
+ throw new TypeError('options.source must not be a disposed Source.');
+ }
if (options.initInput !== undefined && !(options.initInput instanceof Input)) {
throw new TypeError('options.initInput, when provided, must be an Input.');
}
@@ -200,17 +215,34 @@ export class Input extends EventEmitter
inputFinalizationRegistry?.register(this, this._sourceRefs, this);
}
+ /** @internal */
+ _getSourceValidated(request: SourceRequest): MaybePromise> {
+ assert(this._source instanceof PathedSource);
+
+ const result = this._source.getSource(request);
+ const handleResult = (result: S | SourceRef) => {
+ if (!(result instanceof Source || result instanceof SourceRef)) {
+ throw new TypeError('getSource must return a Source or a SourceRef.');
+ }
+ if (result instanceof Source && result._disposed) {
+ throw new TypeError('The returned Source must not be disposed.');
+ }
+
+ return result;
+ };
+
+ if (result instanceof Promise) {
+ return result.then(handleResult);
+ } else {
+ return handleResult(result);
+ }
+ }
+
/** @internal */
async _getSourceUncached(request: SourceRequest) {
assert(this._source instanceof PathedSource);
- const source = await this._source.getSource(request);
- if (!(source instanceof Source || source instanceof SourceRef)) {
- throw new TypeError('The source function must return a Source or a SourceRef.');
- }
- if (source instanceof Source && source._disposed) {
- throw new TypeError('The returned Source must not be disposed.');
- }
+ const source = await this._getSourceValidated(request);
let ref: SourceRef;
if (source instanceof Source) {
@@ -219,7 +251,7 @@ export class Input extends EventEmitter
ref = source;
}
- this._emit('source', { source: ref.source, request });
+ this._emit('source', { source: ref.source, request, isRoot: request.isRoot });
return ref;
}
@@ -243,22 +275,13 @@ export class Input extends EventEmitter
const promise = (async () => {
const sourceRef = await this._getSourceUncached(request);
- const cacheEntry: SourceCacheEntry = {
- request,
- sourceRef,
- age: this._nextSourceCacheAge++,
- cacheGroup,
- };
-
- this._sourceCache.push(cacheEntry);
-
const MAX_SOURCE_CACHE_SIZE = 4;
const count = arrayCount(
this._sourceCache,
x => x.cacheGroup === cacheGroup && x.sourceRef.source._refCount === 1,
);
- if (count > MAX_SOURCE_CACHE_SIZE) {
+ if (count >= MAX_SOURCE_CACHE_SIZE) {
const minAgeIndex = arrayArgmin(
this._sourceCache,
x => x.cacheGroup === cacheGroup && x.sourceRef.source._refCount === 1 ? x.age : Infinity,
@@ -268,7 +291,7 @@ export class Input extends EventEmitter
this._sourceCache.splice(minAgeIndex, 1);
entry.sourceRef.free();
- removeItem(this._sourceRefs, sourceRef);
+ removeItem(this._sourceRefs, entry.sourceRef);
}
this._sourceRefs.push(sourceRef);
@@ -277,6 +300,12 @@ export class Input extends EventEmitter
assert(promiseIndex !== -1);
this._sourceCachePromises.splice(promiseIndex, 1);
+ const cacheEntry: SourceCacheEntry = {
+ request,
+ sourceRef,
+ age: this._nextSourceCacheAge++,
+ cacheGroup,
+ };
return cacheEntry;
})();
@@ -286,22 +315,64 @@ export class Input extends EventEmitter
promise,
});
- return promise.then(x => x.sourceRef.source.ref());
+ return promise.then((entry) => {
+ const ref = entry.sourceRef.source.ref();
+
+ // We need to add it to the cache this late to avoid the ref being freed prematurely due to race conditions
+ this._sourceCache.push(entry);
+
+ return ref;
+ });
+ }
+
+ /** @internal */
+ _getRootSourceRef(): MaybePromise> {
+ if (this._rootRef) {
+ return this._rootRef;
+ }
+ if (this._rootRefPromise) {
+ return this._rootRefPromise;
+ }
+
+ if (this._source instanceof SourceRef) {
+ this._emit('source', { source: this._source.source, request: null, isRoot: true });
+ this._rootRef = this._source;
+
+ assert(this._sourceRefs.includes(this._source)); // Assert that it's already been added
+
+ return this._source;
+ }
+
+ const request: SourceRequest = { path: this._source.rootPath, isRoot: true };
+ const result = this._getSourceValidated(request);
+
+ const handleResult = (result: S | SourceRef) => {
+ let ref: SourceRef;
+ if (result instanceof Source) {
+ ref = result.ref();
+ } else {
+ ref = result;
+ }
+
+ this._sourceRefs.push(ref);
+ this._emit('source', { source: ref.source, request, isRoot: true });
+ this._rootRef = ref;
+
+ return ref;
+ };
+
+ if (result instanceof Promise) {
+ return this._rootRefPromise = result.then(handleResult);
+ } else {
+ return handleResult(result);
+ }
}
/** @internal */
_getDemuxer() {
return this._demuxerPromise ??= (async () => {
- let ref: SourceRef;
- if (this._source instanceof SourceRef) {
- ref = this._source;
- this._emit('source', { source: ref.source, request: null });
- } else {
- ref = await this._getSourceUncached({ path: this._source.rootPath, isRoot: true });
- this._sourceRefs.push(ref);
- }
-
- this._reader = new Reader(ref.source);
+ const rootRef = await this._getRootSourceRef();
+ this._reader = new Reader(rootRef.source);
for (const format of this._formats) {
const canRead = await format._canReadInput(this);
@@ -316,27 +387,27 @@ export class Input extends EventEmitter
}
/**
- * Returns the source from which this input file reads data for the entry path. Throws if the source-resolving
- * function returns a promise; prefer the `'source'` event for those cases.
+ * Returns the source from which this input file reads data for the root path. Throws when using
+ * {@link PathedSource} with an async callback; prefer the `'source'` event for those cases.
*/
get source(): S {
- if (this._source instanceof SourceRef) {
- return this._source.source;
+ const errorMessage = 'Input.source cannot be used when using PathedSource with an async callback.'
+ + ' Use the \'source\' event instead.';
+
+ // We use this field to make sure we can reliably throw in the `source` getter whenever retrieving the source
+ // requiring awaiting a promise. We do this so there is no different behavior based on order: if the source has
+ // already been retrieved via the normal internal operations, and then somebody calls the `source` getter, even
+ // if the source is now available, the getter should still throw to be consistent in behavior and in definition.
+ if (this._rootRefPromise) {
+ throw new TypeError(errorMessage);
}
- const source = this._source.getSource({ path: this._source.rootPath, isRoot: true });
- if (source instanceof Promise) {
- throw new TypeError(
- 'Input.source cannot be used when the source function resolves asynchronously.'
- + ' Use the \'source\' event instead.',
- );
+ const rootRefResult = this._getRootSourceRef();
+ if (rootRefResult instanceof Promise) {
+ throw new TypeError(errorMessage);
}
- if (source instanceof Source) {
- return source;
- } else {
- return source.source;
- }
+ return rootRefResult.source;
}
/**
@@ -364,54 +435,76 @@ export class Input extends EventEmitter
}
}
+ /**
+ * Returns the timestamp at which the input file starts. More precisely, returns the smallest starting timestamp
+ * among all tracks.
+ *
+ * Optionally, you can pass in the list of tracks for which you want to compute the starting timestamp.
+ *
+ * Note that this method is potentially expensive for inputs with many tracks (such as HLS manifests), since it
+ * probes every track.
+ */
+ async getFirstTimestamp(tracks?: InputTrack[]) {
+ tracks ??= await this.getTracks();
+
+ const filtered = tracks.filter(x => x !== null);
+ if (filtered.length === 0) {
+ return 0;
+ }
+
+ const firstTimestamps = await Promise.all(filtered.map(x => x.getFirstTimestamp()));
+ return Math.min(...firstTimestamps);
+ }
+
/**
* Computes the duration of the input file, in seconds. More precisely, returns the largest end timestamp among
* all tracks.
*
+ * Optionally, you can pass in the list of tracks for which you want to compute the duration.
+ *
+ * This method can be potentially expensive depending on the underlying file format, because it returns the most
+ * accurate duration possible and must check all tracks. Use {@link Input.getDurationFromMetadata} for a faster but
+ * less accurate estimate of duration.
+ *
* By default, when any track in 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 PacketRetrievalOptions.skipLiveWait}
* to `true` in the options.
*/
- async computeDuration(options?: PacketRetrievalOptions) {
- const tracks = await this.getTracks();
- if (tracks.length === 0) {
+ async computeDuration(tracks?: InputTrack[], options?: PacketRetrievalOptions) {
+ tracks ??= await this.getTracks();
+
+ const filtered = tracks.filter(x => x !== null);
+ if (filtered.length === 0) {
return 0;
}
- const tracksDurations = await Promise.all(tracks.map(x => x.computeDuration(options)));
+ const tracksDurations = await Promise.all(filtered.map(x => x.computeDuration(options)));
return Math.max(...tracksDurations);
}
/**
- * Returns the timestamp at which the input file starts. More precisely, returns the smallest starting timestamp
- * among all tracks.
- *
- * Note that this method is potentially expensive for inputs with many tracks (such as HLS manifests), since it
- * probes every track.
- */
- async getFirstTimestamp() {
- const tracks = await this.getTracks();
- if (tracks.length === 0) {
- return 0;
- }
-
- const firstTimestamps = await Promise.all(tracks.map(x => x.getFirstTimestamp()));
- 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`
+ * Gets the duration (end timestamp) in seconds 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 cheaper. When the duration cannot be determined from the file metadata, `null`
* is returned.
*
+ * Optionally, you can pass in the list of tracks for which you want to get the duration from metadata.
+ *
* 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);
+ async getDurationFromMetadata(tracks?: InputTrack[], options?: DurationMetadataRequestOptions) {
+ tracks ??= await this.getTracks();
+
+ const filtered = tracks.filter(x => x !== null);
+ const tracksDurations = await Promise.all(filtered.map(x => x.getDurationFromMetadata(options)));
+ const nonNullDurations = tracksDurations.filter(x => x !== null);
+ if (nonNullDurations.length === 0) {
+ return null;
+ }
+
+ return Math.max(...nonNullDurations);
}
/**
@@ -737,6 +830,9 @@ export type CreateInputFromOptions =
* The available options are the union of the options for each {@link Source}. Check the sources to see which field
* applies to which source.
*
+ * **Note:** In server-side environments, it is critical that you validate the input to this function if it is a string.
+ * If you're expecting a user-defined URL, you must validate that it's actually a URL and not a local file path.
+ *
* @group Input files & tracks
* @public
*/
@@ -812,9 +908,8 @@ export const createInputFrom = (
}
if (typeof data === 'string') {
- const isUrl = data.includes('://');
-
- if (isUrl) {
+ const isTreatedAsUrl = !node.fs || data.includes('://');
+ if (isTreatedAsUrl) {
return new Input({
formats,
source: new PathedSource(
@@ -825,7 +920,7 @@ export const createInputFrom = (
});
}
- // It's a file path; this throws automatically if this isn't server-side
+ // Treat it as a local file path
return new Input({
formats,
source: new PathedSource(
diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts
index e1b4b3d..7b6381a 100644
--- a/src/isobmff/isobmff-demuxer.ts
+++ b/src/isobmff/isobmff-demuxer.ts
@@ -446,29 +446,6 @@ 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(async (x) => {
- const p = await x.trackBacking!.getFirstPacket({ metadataOnly: true });
- return p?.timestamp ?? 0;
- }))),
- );
- endTimestamp += minFirstTimestamp;
- }
-
- return endTimestamp;
- }
-
getSampleTableForTrack(internalTrack: InternalTrack) {
if (internalTrack.sampleTable) {
return internalTrack.sampleTable;
@@ -2584,6 +2561,8 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
async getDurationFromMetadata() {
const track = this.internalTrack;
if (track.durationInMediaTimescale <= 0) {
+ // The duration is often zero for fragmented files for example; return `null` to signal that the duration
+ // must be computed instead.
return null;
}
diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts
index 2945945..a88f077 100644
--- a/src/matroska/matroska-demuxer.ts
+++ b/src/matroska/matroska-demuxer.ts
@@ -317,32 +317,6 @@ 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.trackBacking!.getFirstPacket({ metadataOnly: true })
- .then(p => p?.timestamp ?? 0),
- )),
- );
-
- endTimestamp += minFirstTimestamp;
- }
-
- maxEndTimestamp = Math.max(maxEndTimestamp ?? -Infinity, endTimestamp);
- }
- }
-
- return maxEndTimestamp;
- }
-
readMetadata() {
return this.readMetadataPromise ??= (async () => {
let currentPos = 0;
@@ -1995,11 +1969,16 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
async getDurationFromMetadata() {
const segment = this.internalTrack.segment;
- if (segment.tracks.length > 1) {
+ if (segment.duration <= 0) {
return null;
}
- return this.internalTrack.demuxer.getDurationFromMetadata();
+ let endTimestamp = segment.duration / segment.timestampFactor;
+
+ const firstPacket = await this.getFirstPacket({ metadataOnly: true });
+ endTimestamp += firstPacket?.timestamp ?? 0;
+
+ return endTimestamp;
}
async getLiveRefreshInterval() {
diff --git a/src/mp3/mp3-demuxer.ts b/src/mp3/mp3-demuxer.ts
index 646b313..e1b6c86 100644
--- a/src/mp3/mp3-demuxer.ts
+++ b/src/mp3/mp3-demuxer.ts
@@ -178,38 +178,6 @@ 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';
}
@@ -309,8 +277,37 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
return null;
}
- getDurationFromMetadata() {
- return this.demuxer.getDurationFromMetadata();
+ async getDurationFromMetadata() {
+ const demuxer = this.demuxer;
+
+ assert(demuxer.firstFrameHeader !== null);
+ assert(demuxer.firstFrameHeaderPos !== null);
+
+ if (demuxer.xingData) {
+ if (demuxer.xingData.frameCount !== null) {
+ return demuxer.xingData.frameCount
+ * demuxer.firstFrameHeader.audioSamplesInFrame
+ / demuxer.firstFrameHeader.sampleRate;
+ }
+ } else {
+ // No Xing, assuming CBR
+
+ if (demuxer.reader.fileSize !== null) {
+ const averageFrameSize = computeAverageMp3FrameSize(
+ demuxer.firstFrameHeader.lowSamplingFrequency,
+ demuxer.firstFrameHeader.layer,
+ demuxer.firstFrameHeader.bitrate,
+ demuxer.firstFrameHeader.sampleRate,
+ );
+ const frameCount = (demuxer.reader.fileSize - demuxer.firstFrameHeaderPos) / averageFrameSize;
+
+ return Math.round(frameCount)
+ * demuxer.firstFrameHeader.audioSamplesInFrame
+ / demuxer.firstFrameHeader.sampleRate;
+ }
+ }
+
+ return null;
}
async getLiveRefreshInterval() {
diff --git a/src/mpeg-ts/mpeg-ts-demuxer.ts b/src/mpeg-ts/mpeg-ts-demuxer.ts
index 260fd68..652c796 100644
--- a/src/mpeg-ts/mpeg-ts-demuxer.ts
+++ b/src/mpeg-ts/mpeg-ts-demuxer.ts
@@ -714,10 +714,6 @@ export class MpegTsDemuxer extends Demuxer {
return this.trackBackingEntries;
}
- async getDurationFromMetadata(): Promise {
- return null;
- }
-
async getMetadataTags(): Promise {
return {}; // Nothing for now
}
diff --git a/src/ogg/ogg-demuxer.ts b/src/ogg/ogg-demuxer.ts
index d54482e..6191e7f 100644
--- a/src/ogg/ogg-demuxer.ts
+++ b/src/ogg/ogg-demuxer.ts
@@ -388,10 +388,6 @@ export class OggDemuxer extends Demuxer {
});
}
- async getDurationFromMetadata(): Promise {
- return null; // Not stored anywhere
- }
-
async getTrackBackings() {
await this.readMetadata();
return this.trackBackings;
@@ -471,7 +467,7 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
}
async getDurationFromMetadata() {
- return null;
+ return null; // Not stored anywhere
}
async getLiveRefreshInterval() {
diff --git a/src/output.ts b/src/output.ts
index fb9f8d9..26ff181 100644
--- a/src/output.ts
+++ b/src/output.ts
@@ -335,6 +335,8 @@ export type OutputEvents = {
target: Target;
/** The request that led to the target being obtained, or `null` if the output is not pathed. */
request: TargetRequest | null;
+ /** Whether the target is the root file of the media. */
+ isRoot: boolean;
};
};
@@ -379,21 +381,33 @@ export class Output<
_mutex = new AsyncMutex();
/** @internal */
_metadataTags: MetadataTags = {};
+ /** @internal */
+ _rootTarget: T | null = null;
+ /** @internal */
+ _rootTargetPromise: Promise | null = null;
- /** The target to which the root file will be written. Throws if the target-resolving function returns a Promise. */
+ /**
+ * The target to which the root file will be written. Throws when using {@link PathedTarget} with an async callback;
+ * prefer the `'target'` event for those cases.
+ */
get target(): T {
- if (this._target instanceof Target) {
- return this._target;
+ const errorMessage = 'Output.target cannot be used when using PathedTarget with an async callback.'
+ + ' Use the \'target\' event instead.';
+
+ // We use this field to make sure we can reliably throw in the `target` getter whenever retrieving the target
+ // requires awaiting a promise. We do this so there is no different behavior based on order: if the target has
+ // already been retrieved via the normal internal operations, and then somebody calls the `target` getter, even
+ // if the target is now available, the getter should still throw to be consistent in behavior and in definition.
+ if (this._rootTargetPromise) {
+ throw new TypeError(errorMessage);
}
- const target = this._target.getTarget({ path: this._target.rootPath, isRoot: true });
- if (target instanceof Promise) {
- throw new TypeError(
- 'Output.target cannot be used when the target function resolves asynchronously.',
- );
+ const rootTargetResult = this._getRootTarget();
+ if (rootTargetResult instanceof Promise) {
+ throw new TypeError(errorMessage);
}
- return target;
+ return rootTargetResult;
}
/**
@@ -443,13 +457,33 @@ export class Output<
this._muxer = options.format._createMuxer(this);
}
+ /** @internal */
+ _getTargetValidated(request: TargetRequest): MaybePromise {
+ assert(this._target instanceof PathedTarget);
+
+ const result = this._target.getTarget(request);
+ const handleResult = (result: T) => {
+ if (!(result instanceof Target)) {
+ throw new TypeError('getTarget must return a Target.');
+ }
+
+ return result;
+ };
+
+ if (result instanceof Promise) {
+ return result.then(handleResult);
+ } else {
+ return handleResult(result);
+ }
+ }
+
/** @internal */
async _getTarget(request: TargetRequest) {
assert(this._target instanceof PathedTarget);
- const target = await this._target.getTarget(request);
+ const target = await this._getTargetValidated(request);
target._output = this;
- this._emit('target', { target, request });
+ this._emit('target', { target, request, isRoot: request.isRoot });
if (this.state === 'canceled') {
await target._close();
@@ -485,17 +519,49 @@ export class Output<
return this._initTarget !== null;
}
+ /** @internal */
+ _getRootTarget(): MaybePromise {
+ if (this._rootTarget) {
+ return this._rootTarget;
+ }
+ if (this._rootTargetPromise) {
+ return this._rootTargetPromise;
+ }
+
+ if (this._target instanceof Target) {
+ this._emit('target', { target: this._target, request: null, isRoot: true });
+ this._rootTarget = this._target;
+ return this._target;
+ }
+
+ const request: TargetRequest = { path: this._target.rootPath, isRoot: true };
+ const result = this._getTargetValidated(request);
+
+ const handleResult = (target: T) => {
+ target._output = this;
+
+ if (this.state === 'canceled') {
+ void target._close();
+ } else {
+ this._targets.add(target);
+ }
+
+ this._emit('target', { target, request, isRoot: true });
+ this._rootTarget = target;
+ return target;
+ };
+
+ if (result instanceof Promise) {
+ return this._rootTargetPromise = result.then(handleResult);
+ } else {
+ return handleResult(result);
+ }
+ }
+
/** @internal */
_getRootWriter() {
return this._rootWriterPromise ??= (async () => {
- let target: Target;
-
- if (this._target instanceof PathedTarget) {
- target = await this._getTarget({ path: this._target.rootPath, isRoot: true });
- } else {
- target = this._target;
- this._emit('target', { target: this._target, request: null });
- }
+ const target = await this._getRootTarget();
const writer = new Writer(target);
writer.start();
@@ -799,8 +865,10 @@ export class Output<
if (this._rootWriterPromise) {
const rootWriter = await this._rootWriterPromise;
- await rootWriter.flush();
- await rootWriter.finalize();
+ if (!rootWriter.finalized) {
+ await rootWriter.flush();
+ await rootWriter.finalize();
+ }
}
this.state = 'finalized';
diff --git a/src/segmented-input.ts b/src/segmented-input.ts
index 0846b76..77f01fe 100644
--- a/src/segmented-input.ts
+++ b/src/segmented-input.ts
@@ -117,10 +117,6 @@ class SegmentedInputDemuxer extends Demuxer {
this.segmentedInput = segmentedInput;
}
- async getDurationFromMetadata(): Promise {
- throw new Error('Unreachable');
- }
-
async getMetadataTags(): Promise {
throw new Error('Unreachable');
}
diff --git a/src/source.ts b/src/source.ts
index 92915b1..11404fc 100644
--- a/src/source.ts
+++ b/src/source.ts
@@ -207,11 +207,11 @@ export class SourceRef implements Disposable {
/**
* Frees the ref, decrementing the source's internal reference count. If the source's internal reference count
- * reaches zero, it gets disposed. This method is idempotent.
+ * reaches zero, it gets disposed. To catch bugs, this method throws if the ref is already freed.
*/
free() {
if (this._freed) {
- return;
+ throw new Error('Illegal operation: double free on SourceRef.');
}
const source = this.source;
diff --git a/src/wave/wave-demuxer.ts b/src/wave/wave-demuxer.ts
index c9f6545..f52231c 100644
--- a/src/wave/wave-demuxer.ts
+++ b/src/wave/wave-demuxer.ts
@@ -328,13 +328,6 @@ 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';
}
@@ -421,8 +414,10 @@ class WaveAudioTrackBacking implements InputAudioTrackBacking {
return null;
}
- getDurationFromMetadata() {
- return this.demuxer.getDurationFromMetadata();
+ async getDurationFromMetadata() {
+ assert(this.demuxer.dataSize !== -1);
+
+ return this.demuxer.dataSize / this.demuxer.audioInfo!.blockSizeInBytes / this.demuxer.audioInfo!.sampleRate;
}
async getLiveRefreshInterval() {
diff --git a/src/writer.ts b/src/writer.ts
index f3cf4c6..43c1622 100644
--- a/src/writer.ts
+++ b/src/writer.ts
@@ -11,6 +11,8 @@ import { Target } from './target';
export class Writer {
target: Target;
+ finalized = false;
+ started = false;
private pos = 0;
@@ -19,7 +21,9 @@ export class Writer {
}
start() {
+ assert(!this.started);
this.target._start();
+ this.started = true;
}
ensureMonotonicity() {
@@ -29,6 +33,8 @@ export class Writer {
/** Writes the given data to the target, at the current position. */
write(data: Uint8Array) {
+ assert(this.started && !this.finalized);
+
this.maybeTrackWrites(data);
this.target._write(data, this.pos);
this.pos += data.byteLength;
@@ -46,15 +52,19 @@ export class Writer {
/** Signals to the writer that it may be time to flush. */
async flush() {
+ assert(this.started && !this.finalized);
return this.target._flush();
}
/** Called after muxing has finished. */
async finalize() {
+ assert(this.started && !this.finalized);
assert(this.target._output);
await this.target._finalize();
this.target._output._targets.delete(this.target);
+
+ this.finalized = true;
}
private trackedWrites: Uint8Array | null = null;
diff --git a/test/node/hls-input.test.ts b/test/node/hls-input.test.ts
index 14a84e5..688fb40 100644
--- a/test/node/hls-input.test.ts
+++ b/test/node/hls-input.test.ts
@@ -13,11 +13,14 @@ test.concurrent('Big Buck Bunny', { timeout: 15_000 }, async () => {
let sourceCount = 0;
input.on('source', () => sourceCount++);
+ let rootReadCount = 0;
+ input.source.on('read', () => rootReadCount++);
+
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);
+ expect(rootReadCount).toBeGreaterThan(0);
// Test descriptors (unhydrated metadata from master playlist)
const descriptors = await input.getTrackDescriptors();
@@ -211,6 +214,8 @@ test.concurrent('Big Buck Bunny', { timeout: 15_000 }, async () => {
// Since they're the highest-bitrate option
expect(primaryVideoTrack).toBe(videoTracks[4]);
expect(primaryAudioTrack).toBe(audioTracks[4]);
+
+ expect(await input.getDurationFromMetadata()).not.toBe(null);
});
test.concurrent('Single-variant Big Buck Bunny', { timeout: 15_000 }, async () => {
diff --git a/test/node/hls-output.test.ts b/test/node/hls-output.test.ts
index ef3c877..292c35e 100644
--- a/test/node/hls-output.test.ts
+++ b/test/node/hls-output.test.ts
@@ -1817,7 +1817,7 @@ segment-1-1.ts
);
});
-test('onSegment, onPlaylist, onMaster events', async () => {
+test('write, onSegment, onPlaylist, onMaster events', async () => {
const onSegment = vi.fn();
const onPlaylist = vi.fn();
const onMaster = vi.fn();
@@ -1832,6 +1832,9 @@ test('onSegment, onPlaylist, onMaster events', async () => {
target: new PathedTarget('', () => new BufferTarget()),
});
+ let targetWrites = 0;
+ output.target.on('write', () => targetWrites++);
+
const source = videoSource();
output.addVideoTrack(source);
@@ -1858,6 +1861,8 @@ test('onSegment, onPlaylist, onMaster events', async () => {
await output.finalize();
+ expect(targetWrites).toBeGreaterThan(0);
+
// Second segment finalized on close
expect(onSegment).toHaveBeenCalledTimes(2);
expect(onSegment.mock.calls[1]![1]).toEqual(expect.objectContaining({ n: 2 }));