Fix SourceRef race conditions, fix "source" and "target" getter returning unused instances, add missing validation, make "getDurationFromMetadata" only exist on tracks, fix examples, expand media player to support live playback, change the signatures of some Input APIs

This commit is contained in:
Vanilagy
2026-04-10 13:38:14 +02:00
parent 0813bcab28
commit aa810fc433
24 changed files with 493 additions and 476 deletions
+2
View File
@@ -78,6 +78,8 @@
<p id="duration" class="tabular-nums w-10 sm:w-24 text-sm"></p>
<button id="live-dot" title="Seek to live edge" style="display: none;" class="mx-2 size-3 rounded-full bg-red-500 cursor-pointer shrink-0 animate-pulse"></button>
<button class="p-2 group outline-none" id="fullscreen-button">
<div class="size-6 invert group-hover:scale-110">
<img src="../../docs/assets/fullscreen-icon.svg" class="size-full">
+90 -119
View File
@@ -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<AudioBufferSourceNode> = 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;
@@ -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 => ({
@@ -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);