diff --git a/dev/demux.html b/dev/demux.html
index 140d3cad1..b06e64073 100644
--- a/dev/demux.html
+++ b/dev/demux.html
@@ -14,48 +14,57 @@
const file = fileInput.files[0];
const input = new Mediabunny.Input({
formats: Mediabunny.ALL_FORMATS,
- source: new Mediabunny.BlobSource(file),
+ source: new Mediabunny.BufferSource(await file.arrayBuffer()),
});
-
+
const track = await input.getPrimaryVideoTrack();
- if (!track) {
- throw new Error('The synthetic file has no video track.');
- }
+ const cache = new Mediabunny.PacketCache({ maxCacheSize: Infinity });
+ const reader = new Mediabunny.PacketReader(track, { cache });
+
+ for (let i = 0; i < 50; i++) {
+ console.time()
+ let packet = await reader.getFirst();
+ let count = 0;
+ while (packet) {
+ packet = reader.getNext(packet);
+ if (packet instanceof Promise) packet = await packet;
+
+ count++;
+
+ /*
+ if (packet.type === 'key') {
+ i++;
+ }
+
+ if (i === 2) {
+ break;
+ }
+ */
+ }
+ console.timeEnd()
- let encodedPackets = 0;
- for await (const packet of new Mediabunny.EncodedPacketSink(track).packets()) {
- console.log(packet.timestamp)
- encodedPackets++;
+ console.log(count)
}
- let decodedFrames = 0;
- let lastTimestamp = null;
- const sink = new Mediabunny.VideoSampleSink(track, {
- hardwareAcceleration: 'prefer-software',
- });
- for await (const sample of sink.samples()) {
- console.log(sample.timestamp)
- decodedFrames++;
- lastTimestamp = sample.timestamp;
- sample.close();
- }
+ console.log("Done")
- input.dispose();
- const reproduced = encodedPackets === 48 && decodedFrames === 47;
- console.log({
- decodedFrames,
- encodedPackets,
- lastTimestamp,
- reproduced,
- });
+ //console.log(cache);
+//
+ //reader.getAt(0.5)
+ //reader.getAt(0.5)
+ //console.log(await reader.getAt(0.5));
+ //console.log(await reader.getAt(0.99));
/*
- const track = await input.getPrimaryVideoTrack();
- const sink = new Mediabunny.EncodedPacketSink(track);
+ const packetSink = new Mediabunny.EncodedPacketSink(track);
- const packet = await sink.getFirstPacket();
- console.log(packet);
+ const first = await packetSink.getFirstPacket({ verifyKeyPackets: true });
+ const second = await packetSink.getNextPacket(first, { verifyKeyPackets: true });
+ const third = await packetSink.getNextPacket(second, { verifyKeyPackets: true });
+ const fourth = await packetSink.getNextPacket(third, { verifyKeyPackets: true });
+ console.log(first, second, third, fourth);
*/
+
/*
for await (const packet of packetSink.packets()) {
@@ -292,14 +301,104 @@
}
*/
+ //alert(total / iters);
/*
- const videoTrack = await input.getPrimaryVideoTrack();
- const sink = new Mediabunny.EncodedPacketSink(videoTrack);
+ console.log(await cursor.seekTo(2.00));
+ console.log(await cursor.next());
+ console.log(await cursor.next());
+ */
- for await (const packet of sink.packets()) {
- console.log(packet);
+ //console.log(await cursor.seekTo(4.9))
+ //console.log(await cursor.seekTo(5.1))
+ //return;
+
+ /*
+ const mh = [
+ cursor.seekTo(2.05),
+ //cursor.seekTo(4.9),
+ //cursor.seekTo(4.9),
+ //cursor.seekTo(4.9),
+ //cursor.seekTo(4.9),
+ //cursor.next(),
+ cursor.next(),
+ cursor.next(),
+ cursor.next(),
+ cursor.next(),
+ cursor.next(),
+ cursor.next(),
+ cursor.next(),
+ cursor.next(),
+ cursor.next(),
+ cursor.next(),
+ cursor.next(),
+ cursor.next(),
+ cursor.next(),
+ cursor.next(),
+ cursor.next(),
+ cursor.next(),
+ cursor.close(),
+ //cursor.next(),
+ //cursor.next(),
+ //cursor.next(),
+ //cursor.next(),
+ //cursor.close(),
+ //cursor.seekTo(2.10),
+ //cursor.seekTo(5),
+ //cursor.close(),
+ //cursor.seekTo(2.00),
+ ];
+ */
+
+ /*
+ for (const yo of mh) {
+ const samp = await yo;
+ console.log(samp)
+ samp?.close();
+ }
+
+ console.log("done")
+ */
+
+ //console.log(await Promise.all(mh));
+
+ //console.log(await cursor.seekTo(2.05));
+ //console.log(await cursor.seekTo(2.10));
+ //console.log(await cursor.seekTo(10.2));
+ //console.log(await cursor.seekTo(5.2));
+
+ /*
+ setTimeout(async () => {
+ console.log("this")
+
+ }, 500);
+ */
+
+
+ /*
+ const iters = 5;
+ let total = 0;
+
+ for (let i = 0; i < iters; i++) {
+ const file = fileInput.files[0];
+ const input = new Mediabunny.Input({
+ formats: Mediabunny.ALL_FORMATS,
+ source: new Mediabunny.BufferSource(await file.arrayBuffer()),
+ });
+
+ const audioTrack = await input.getPrimaryAudioTrack();
+ const cursor = new Mediabunny.PacketCursor(audioTrack);
+
+ await cursor.seekToStart();
+
+ const start = performance.now();
+ await cursor.iterate(() => {});
+ //alert(performance.now() - start)
+
+ total += performance.now() - start;
}
+
+ alert(total / iters);
*/
/*
@@ -381,6 +480,8 @@
total += end - start;
//console.log(total / file.size, end - start);
}
+ alert(elapsed / iters);
+
const videoTrack = await input.getPrimaryVideoTrack();
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 3c240225a..6d58d8096 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -30,6 +30,19 @@ export default tseslint.config(
'@typescript-eslint/require-await': 'off',
'@stylistic/yield-star-spacing': ['error', { before: false, after: true }],
'@typescript-eslint/no-unsafe-enum-comparison': 'off',
+ '@typescript-eslint/no-unused-vars': [
+ 'error',
+ {
+ // From https://typescript-eslint.io/rules/no-unused-vars/
+ "args": "after-used",
+ "argsIgnorePattern": "^_",
+ "caughtErrors": "all",
+ "caughtErrorsIgnorePattern": "^_",
+ "destructuredArrayIgnorePattern": "^_",
+ "varsIgnorePattern": "^_",
+ "ignoreRestSiblings": true,
+ },
+ ],
'@typescript-eslint/no-unsafe-unary-minus': 'off',
'@typescript-eslint/no-deprecated': 'error',
'@typescript-eslint/consistent-type-exports': 'error',
diff --git a/examples/media-player/media-player.ts b/examples/media-player/media-player.ts
index 7882ae2be..b9bc361b4 100644
--- a/examples/media-player/media-player.ts
+++ b/examples/media-player/media-player.ts
@@ -1,12 +1,10 @@
import {
ALL_FORMATS,
- AudioBufferSink,
+ AudioSampleCursor,
BlobSource,
- CanvasSink,
Input,
UrlSource,
- WrappedAudioBuffer,
- WrappedCanvas,
+ VideoSampleCursor,
} from 'mediabunny';
import { registerAc3Decoder } from '@mediabunny/ac3';
import { registerDtsDecoder } from '@mediabunny/dts';
@@ -51,8 +49,8 @@ let audioContext: AudioContext | null = null;
let gainNode: GainNode | null = null;
let fileLoaded = false;
-let videoSink: CanvasSink | null = null;
-let audioSink: AudioBufferSink | null = null;
+let videoCursor: VideoSampleCursor | null = null;
+let audioCursor: AudioSampleCursor | null = null;
let firstTimestamp = 0;
let endTimestamp = 0;
@@ -63,17 +61,8 @@ let playing = false;
/** The timestamp within the media file when the playback was started. */
let playbackTimeAtStart = 0;
-let videoFrameIterator: AsyncGenerator | null = null;
-let audioBufferIterator: AsyncGenerator | null = null;
-let nextFrame: WrappedCanvas | null = null;
const queuedAudioNodes: Set = new Set();
-/**
- * Used to prevent async race conditions. When seekId is incremented, already-running async functions will be prevented
- * from having an effect.
- */
-let asyncId = 0;
-
let liveRefreshIntervalId = -1;
let draggingProgressBar = false;
@@ -91,10 +80,8 @@ const initMediaPlayer = async (resource: File | string) => {
pause();
}
- void videoFrameIterator?.return();
- void audioBufferIterator?.return();
-
- asyncId++;
+ void videoCursor?.close();
+ void audioCursor?.close();
fileLoaded = false;
fileNameElement.textContent = resource instanceof File ? resource.name : resource;
@@ -193,15 +180,14 @@ const initMediaPlayer = async (resource: File | string) => {
playerContainer.style.background = videoCanBeTransparent ? 'transparent' : '';
- // For video, let's use a CanvasSink as it handles rotation and closing video samples for us.
- // Pool size of 2: We'll only ever have the current and the next frame around, so we only need two canvases.
- videoSink = videoTrack && new CanvasSink(videoTrack, {
- poolSize: 2,
- fit: 'contain', // In case the video changes dimensions over time
- alpha: videoCanBeTransparent,
- });
- // For audio, we'll use an AudioBufferSink to directly retrieve AudioBuffers compatible with the Web Audio API
- audioSink = audioTrack && new AudioBufferSink(audioTrack);
+ if (videoTrack) {
+ videoCursor = new VideoSampleCursor(videoTrack);
+ await videoCursor.seekToFirst();
+ }
+ if (audioTrack) {
+ audioCursor = new AudioSampleCursor(audioTrack);
+ await audioCursor.seekToFirst();
+ }
// Show the canvas if there's a video track, otherwise hide it
if (videoTrack) {
@@ -223,8 +209,6 @@ const initMediaPlayer = async (resource: File | string) => {
fileLoaded = true;
- await startVideoIterator();
-
if (audioContext.state === 'running') {
// Start playback automatically if the audio context permits
// await play();
@@ -233,7 +217,7 @@ const initMediaPlayer = async (resource: File | string) => {
loadingElement.style.display = 'none';
playerContainer.style.display = '';
- if (!videoSink) {
+ if (!videoCursor) {
// If there's only an audio track, always show the controls
controlsElement.style.opacity = '1';
controlsElement.style.pointerEvents = '';
@@ -282,33 +266,7 @@ const initMediaPlayer = async (resource: File | string) => {
/** === VIDEO RENDERING LOGIC === */
-/** Creates a new video frame iterator and renders the first video frame. */
-const startVideoIterator = async () => {
- if (!videoSink) {
- return;
- }
-
- asyncId++;
-
- await videoFrameIterator?.return(); // Dispose of the current iterator
-
- // Create a new iterator
- videoFrameIterator = videoSink.canvases(getPlaybackTime());
-
- // Get the first two frames
- const firstFrame = (await videoFrameIterator.next()).value ?? null;
- const secondFrame = (await videoFrameIterator.next()).value ?? null;
-
- nextFrame = secondFrame;
-
- if (firstFrame) {
- // Draw the first frame
- context.clearRect(0, 0, canvas.width, canvas.height);
- context.drawImage(firstFrame.canvas, 0, 0);
- }
-};
-
-/** Runs every frame; updates the canvas if necessary. */
+/** Runs every frame and updates the canvas if possible. */
const render = (requestFrame = true) => {
if (fileLoaded) {
const playbackTime = getPlaybackTime();
@@ -318,14 +276,17 @@ const render = (requestFrame = true) => {
playbackTimeAtStart = endTimestamp;
}
- // Check if the current playback time has caught up to the next frame
- if (nextFrame && nextFrame.timestamp <= playbackTime) {
- context.clearRect(0, 0, canvas.width, canvas.height);
- context.drawImage(nextFrame.canvas, 0, 0);
- nextFrame = null;
+ if (videoCursor) {
+ if (videoCursor.isIdle()) {
+ // The seek is instant if the frame has already been decoded under the hood; if it is not, we'll just
+ // render the old frame until the new one is ready.
+ void videoCursor.seekTo(playbackTime);
+ }
- // Request the next frame
- void updateNextFrame();
+ context.clearRect(0, 0, canvas.width, canvas.height);
+ if (videoCursor.current) {
+ videoCursor.current.drawWithFit(context, { fit: 'contain' });
+ }
}
if (!draggingProgressBar) {
@@ -342,50 +303,30 @@ render();
// Also call the render function on an interval to make sure the video keeps updating even if the tab isn't visible
setInterval(() => render(false), 500);
-/** Iterates over the video frame iterator until it finds a video frame in the future. */
-const updateNextFrame = async () => {
- const currentAsyncId = asyncId;
-
- // We have a loop here because we may need to iterate over multiple frames until we reach a frame in the future
- while (true) {
- const newNextFrame = (await videoFrameIterator!.next()).value ?? null;
- if (!newNextFrame) {
- break;
- }
-
- if (currentAsyncId !== asyncId) {
- break;
- }
-
- const playbackTime = getPlaybackTime();
- if (newNextFrame.timestamp <= playbackTime) {
- // Draw it immediately
- context.clearRect(0, 0, canvas.width, canvas.height);
- context.drawImage(newNextFrame.canvas, 0, 0);
- } else {
- // Save it for later
- nextFrame = newNextFrame;
- break;
- }
- }
-};
-
/** === AUDIO PLAYBACK LOGIC === */
+let currentAudioIteratorId = 0;
+
/** Loops over the audio buffer iterator, scheduling the audio to be played in the audio context. */
const runAudioIterator = async () => {
- if (!audioSink) {
+ if (!audioCursor) {
return;
}
+ const id = ++currentAudioIteratorId;
+
// To play back audio, we loop over all audio chunks (typically very short) of the file and play them at the correct
// timestamp. The result is a continuous, uninterrupted audio signal.
- for await (const { buffer, timestamp } of audioBufferIterator!) {
+ for await (const sample of audioCursor) {
+ if (id !== currentAudioIteratorId) {
+ break;
+ }
+
const node = audioContext!.createBufferSource();
- node.buffer = buffer;
+ node.buffer = sample.toAudioBuffer();
node.connect(gainNode!);
- let startTimestamp = audioContextStartTime! + timestamp - playbackTimeAtStart;
+ let startTimestamp = audioContextStartTime! + sample.timestamp - playbackTimeAtStart;
// Round timestamp to the context's sample boundaries to prevent subsample audio glitches
startTimestamp = Math.round(audioContext!.sampleRate * startTimestamp) / audioContext!.sampleRate;
@@ -405,15 +346,20 @@ const runAudioIterator = async () => {
// If we're more than a second ahead of the current playback time, let's slow down the loop until time has
// passed.
- if (timestamp - getPlaybackTime() >= 1) {
+ if (sample.timestamp - getPlaybackTime() >= 1) {
await new Promise((resolve) => {
- const id = setInterval(() => {
- if (timestamp - getPlaybackTime() < 1) {
- clearInterval(id);
+ const timeoutId = setInterval(() => {
+ if (sample.timestamp - getPlaybackTime() < 1 || id !== currentAudioIteratorId) {
+ clearInterval(timeoutId);
resolve();
}
}, 100);
});
+
+ // This check is required to prevent advancing the cursor when it is no longer in our control
+ if (id !== currentAudioIteratorId) {
+ break;
+ }
}
}
};
@@ -439,16 +385,15 @@ const play = async () => {
if (getPlaybackTime() === endTimestamp) {
// If we're at the end, let's snap back to the start
playbackTimeAtStart = firstTimestamp;
- await startVideoIterator();
+ await videoCursor?.seekTo(firstTimestamp);
}
audioContextStartTime = audioContext!.currentTime;
playing = true;
- if (audioSink) {
+ if (audioCursor) {
// Start the audio iterator
- void audioBufferIterator?.return();
- audioBufferIterator = audioSink?.buffers(getPlaybackTime());
+ void audioCursor.seekTo(getPlaybackTime());
void runAudioIterator();
}
@@ -459,8 +404,8 @@ const play = async () => {
const pause = () => {
playbackTimeAtStart = getPlaybackTime();
playing = false;
- void audioBufferIterator?.return(); // This stops any for-loops that are iterating the iterator
- audioBufferIterator = null;
+
+ currentAudioIteratorId++; // This stops any ongoing cursor iteration
// Stop all audio nodes that were already queued to play
for (const node of queuedAudioNodes) {
@@ -491,7 +436,7 @@ const seekToTime = async (seconds: number) => {
playbackTimeAtStart = seconds;
- await startVideoIterator();
+ await videoCursor?.seekTo(seconds);
if (wasPlaying && playbackTimeAtStart < endTimestamp) {
void play();
@@ -590,7 +535,7 @@ volumeBarContainer.addEventListener('pointermove', (event) => {
/** === CONTROL UI LOGIC === */
const showControlsTemporarily = () => {
- if (!videoSink) {
+ if (!videoCursor) {
// Shouldn't run if there's only an audio track
return;
}
@@ -623,7 +568,7 @@ playerContainer.addEventListener('pointermove', (event) => {
}
});
playerContainer.addEventListener('pointerleave', (event) => {
- if (!videoSink) {
+ if (!videoCursor) {
// Shouldn't run if there's only an audio track
return;
}
diff --git a/examples/thumbnail-generation/thumbnail-generation.ts b/examples/thumbnail-generation/thumbnail-generation.ts
index 7add9bc8d..717020519 100644
--- a/examples/thumbnail-generation/thumbnail-generation.ts
+++ b/examples/thumbnail-generation/thumbnail-generation.ts
@@ -1,9 +1,10 @@
-import { Input, ALL_FORMATS, BlobSource, UrlSource, CanvasSink } from 'mediabunny';
+import { Input, ALL_FORMATS, BlobSource, UrlSource, VideoSampleCursor } from 'mediabunny';
import { registerAc3Decoder } from '@mediabunny/ac3';
import { registerDtsDecoder } from '@mediabunny/dts';
import { registerProresDecoder } from '@mediabunny/prores';
import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4';
+import { canvasTransformer } from '../../src/cursors.js';
// Enable codecs that aren't natively supported by WebCodecs.
registerAc3Decoder();
@@ -80,16 +81,23 @@ const generateThumbnails = async (resource: File | string) => {
(_, i) => firstTimestamp + i * (lastTimestamp - firstTimestamp) / THUMBNAIL_COUNT,
);
- // Create a CanvasSink for extracting resized frames from the video track
- const sink = new CanvasSink(videoTrack, {
- width: Math.floor(width * window.devicePixelRatio),
- height: Math.floor(height * window.devicePixelRatio),
- fit: 'fill',
+ // Create a cursor to extract frames from the video
+ const cursor = new VideoSampleCursor(videoTrack, {
+ transform: canvasTransformer({
+ width: Math.floor(width * window.devicePixelRatio),
+ height: Math.floor(height * window.devicePixelRatio),
+ fit: 'fill',
+ }),
});
+ // Queue all cursor commands at once, including closing the cursor; this allows it to optimize access patterns
+ const promises = timestamps.map(x => cursor.seekTo(x));
+ void cursor.close();
+
// Iterate over all thumbnail canvases
let i = 0;
- for await (const wrappedCanvas of sink.canvasesAtTimestamps(timestamps)) {
+ for (const promise of promises) {
+ const wrappedCanvas = await promise;
const container = thumbnailElements[i]!;
if (wrappedCanvas) {
diff --git a/package-lock.json b/package-lock.json
index badd60773..8d6776a5f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -353,9 +353,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
- "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -363,13 +363,13 @@
}
},
"node_modules/@babel/parser": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
- "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
+ "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.28.0"
+ "@babel/types": "^7.28.5"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -389,14 +389,14 @@
}
},
"node_modules/@babel/types": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz",
- "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
+ "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.27.1"
+ "@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
diff --git a/package.json b/package.json
index dc6ce8ce4..292e78c7d 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
"sideEffects": false,
"scripts": {
"build": "./scripts/build.sh",
+ "build-loose": "./scripts/build.sh --loose",
"watch": "tsx scripts/bundle.ts --watch",
"lint": "eslint .",
"test": "npm run pre-test && npx vitest --run",
@@ -87,6 +88,7 @@
"@tailwindcss/vite": "^4.1.7",
"@types/markdown-it-footnote": "^3.0.4",
"@types/node": "^22.13.10",
+ "@vitest/browser": "3.2.4",
"esbuild": "^0.25.1",
"esbuild-plugin-external-global": "^1.0.1",
"eslint": "^9.22.0",
@@ -103,7 +105,6 @@
"vitepress-plugin-llms": "^1.5.1",
"vitepress-plugin-mermaid": "^2.0.17",
"vitest": "3.2.4",
- "@vitest/browser": "3.2.4",
"webdriverio": "9.19.2"
},
"keywords": [
diff --git a/scripts/build.sh b/scripts/build.sh
index dec13899a..0a60adb6e 100755
--- a/scripts/build.sh
+++ b/scripts/build.sh
@@ -3,6 +3,17 @@ set -e
# This script must be executed via `npm run build`
+LOOSE=false
+if [ "$1" = "--loose" ]; then
+ LOOSE=true
+fi
+
+if [ "$LOOSE" = true ]; then
+ API_EXTRACTOR_FLAGS="--local"
+else
+ API_EXTRACTOR_FLAGS=""
+fi
+
# Clear the stuff from last build
rm -rf dist
rm -rf packages/mp3-encoder/dist
@@ -38,27 +49,29 @@ npm run fix-build-import-paths
tsx scripts/bundle.ts
# Declaration file rollup and checks
-api-extractor run
-api-extractor run -c packages/mp3-encoder/api-extractor.json
-api-extractor run -c packages/ac3/api-extractor.json
-api-extractor run -c packages/dts/api-extractor.json
-api-extractor run -c packages/aac-encoder/api-extractor.json
-api-extractor run -c packages/flac-encoder/api-extractor.json
-api-extractor run -c packages/prores/api-extractor.json
-api-extractor run -c packages/server/api-extractor.json
+api-extractor run $API_EXTRACTOR_FLAGS
+api-extractor run $API_EXTRACTOR_FLAGS -c packages/mp3-encoder/api-extractor.json
+api-extractor run $API_EXTRACTOR_FLAGS -c packages/ac3/api-extractor.json
+api-extractor run $API_EXTRACTOR_FLAGS -c packages/dts/api-extractor.json
+api-extractor run $API_EXTRACTOR_FLAGS -c packages/aac-encoder/api-extractor.json
+api-extractor run $API_EXTRACTOR_FLAGS -c packages/flac-encoder/api-extractor.json
+api-extractor run $API_EXTRACTOR_FLAGS -c packages/prores/api-extractor.json
+api-extractor run $API_EXTRACTOR_FLAGS -c packages/server/api-extractor.json
-# Checks that all symbols are documented
-tsx scripts/check-docblocks.ts dist/mediabunny.d.ts
-tsx scripts/check-docblocks.ts packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts
-tsx scripts/check-docblocks.ts packages/ac3/dist/mediabunny-ac3.d.ts
-tsx scripts/check-docblocks.ts packages/dts/dist/mediabunny-dts.d.ts
-tsx scripts/check-docblocks.ts packages/aac-encoder/dist/mediabunny-aac-encoder.d.ts
-tsx scripts/check-docblocks.ts packages/flac-encoder/dist/mediabunny-flac-encoder.d.ts
-tsx scripts/check-docblocks.ts packages/prores/dist/mediabunny-prores.d.ts
-tsx scripts/check-docblocks.ts packages/server/dist/mediabunny-server.d.ts
+if [ "$LOOSE" = false ]; then
+ # Checks that all symbols are documented
+ tsx scripts/check-docblocks.ts dist/mediabunny.d.ts
+ tsx scripts/check-docblocks.ts packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts
+ tsx scripts/check-docblocks.ts packages/ac3/dist/mediabunny-ac3.d.ts
+ tsx scripts/check-docblocks.ts packages/dts/dist/mediabunny-dts.d.ts
+ tsx scripts/check-docblocks.ts packages/aac-encoder/dist/mediabunny-aac-encoder.d.ts
+ tsx scripts/check-docblocks.ts packages/flac-encoder/dist/mediabunny-flac-encoder.d.ts
+ tsx scripts/check-docblocks.ts packages/prores/dist/mediabunny-prores.d.ts
+ tsx scripts/check-docblocks.ts packages/server/dist/mediabunny-server.d.ts
-# Checks that API docs are generatable
-npm run docs:generate -- --dry
+ # Checks that API docs are generatable
+ npm run docs:generate -- --dry
+fi
# Appends stuff to the declaration files to register the global variables these libraries expose
echo 'export as namespace Mediabunny;' >> dist/mediabunny.d.ts
diff --git a/scripts/generate-api-docs.ts b/scripts/generate-api-docs.ts
index 319daf5bb..8cdf2ee9d 100644
--- a/scripts/generate-api-docs.ts
+++ b/scripts/generate-api-docs.ts
@@ -3,7 +3,6 @@
// good, which is the point of a custom script for this: full, precise control.
/* eslint-disable @typescript-eslint/restrict-template-expressions */
-/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @stylistic/max-len */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
diff --git a/src/adts/adts-demuxer.ts b/src/adts/adts-demuxer.ts
index 96423e265..08ab8d960 100644
--- a/src/adts/adts-demuxer.ts
+++ b/src/adts/adts-demuxer.ts
@@ -16,17 +16,17 @@ import {
} from '../id3';
import { Input } from '../input';
import { InputAudioTrackBacking } from '../input-track';
-import { PacketRetrievalOptions } from '../media-sink';
import { DEFAULT_TRACK_DISPOSITION, MetadataTags } from '../metadata';
import {
assert,
AsyncMutex,
- binarySearchExact,
binarySearchLessOrEqual,
isThenable,
+ MaybeRelevantPromise,
+ ResultValue,
UNDETERMINED_LANGUAGE,
} from '../misc';
-import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
+import { EncodedPacket, PacketRetrievalOptions, PacketRetrievalResult, PLACEHOLDER_DATA } from '../packet';
import { readBytes, Reader } from '../reader';
import {
AdtsFrameHeader,
@@ -69,18 +69,21 @@ export class AdtsDemuxer extends Demuxer {
return this.metadataPromise ??= (async () => {
// Keep loading until we find the first frame header
while (!this.firstFrameHeader && !this.lastSampleLoaded) {
- await this.advanceReader();
+ const result = new ResultValue();
+ const promise = this.advanceReader(result);
+ if (result.pending) await promise;
}
- // There has to be a frame if this demuxer got selected
- assert(this.firstFrameHeader);
+ if (!this.firstFrameHeader) {
+ throw new Error('No valid ADTS frame found.');
+ }
// Create the single audio track
this.trackBackings = [new AdtsAudioTrackBacking(this)];
})();
}
- async advanceReader() {
+ async advanceReader(res: ResultValue): MaybeRelevantPromise {
if (this.lastLoadedPos === 0) {
// Skip all ID3v2 tags at the start of the file
while (true) {
@@ -89,7 +92,7 @@ export class AdtsDemuxer extends Demuxer {
if (!slice) {
this.lastSampleLoaded = true;
- return;
+ return res.set();
}
const id3V2Header = readId3V2Header(slice);
@@ -109,19 +112,19 @@ export class AdtsDemuxer extends Demuxer {
if (isThenable(slice)) slice = await slice;
if (!slice) {
this.lastSampleLoaded = true;
- return;
+ return res.set();
}
const header = readAdtsFrameHeader(slice);
if (!header) {
this.lastSampleLoaded = true;
- return;
+ return res.set();
}
if (this.reader.fileSize !== null && header.startPos + header.frameLength > this.reader.fileSize) {
// Frame doesn't fit in the rest of the file
this.lastSampleLoaded = true;
- return;
+ return res.set();
}
if (!this.firstFrameHeader) {
@@ -142,6 +145,8 @@ export class AdtsDemuxer extends Demuxer {
this.loadedSamples.push(sample);
this.nextTimestampInSamples += SAMPLES_PER_AAC_FRAME;
this.lastLoadedPos = header.startPos + header.frameLength;
+
+ return res.set();
}
async getMimeType() {
@@ -154,41 +159,38 @@ export class AdtsDemuxer extends Demuxer {
}
async getMetadataTags() {
- const release = await this.readingMutex.acquire();
+ using lock = this.readingMutex.lock();
+ if (lock.pending) await lock.ready;
- try {
- await this.readMetadata();
+ await this.readMetadata();
- if (this.metadataTags) {
- return this.metadataTags;
- }
+ if (this.metadataTags) {
+ return this.metadataTags;
+ }
- this.metadataTags = {};
- let currentPos = 0;
+ this.metadataTags = {};
+ let currentPos = 0;
- while (true) {
- let headerSlice = this.reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE);
- if (isThenable(headerSlice)) headerSlice = await headerSlice;
- if (!headerSlice) break;
+ while (true) {
+ let headerSlice = this.reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE);
+ if (isThenable(headerSlice)) headerSlice = await headerSlice;
+ if (!headerSlice) break;
- const id3V2Header = readId3V2Header(headerSlice);
- if (!id3V2Header) {
- break;
- }
-
- let contentSlice = this.reader.requestSlice(headerSlice.filePos, id3V2Header.size);
- if (isThenable(contentSlice)) contentSlice = await contentSlice;
- if (!contentSlice) break;
+ const id3V2Header = readId3V2Header(headerSlice);
+ if (!id3V2Header) {
+ break;
+ }
- parseId3V2Tag(contentSlice, id3V2Header, this.metadataTags);
+ let contentSlice = this.reader.requestSlice(headerSlice.filePos, id3V2Header.size);
+ if (isThenable(contentSlice)) contentSlice = await contentSlice;
+ if (!contentSlice) break;
- currentPos = headerSlice.filePos + id3V2Header.size;
- }
+ parseId3V2Tag(contentSlice, id3V2Header, this.metadataTags);
- return this.metadataTags;
- } finally {
- release();
+ currentPos = headerSlice.filePos + id3V2Header.size;
}
+
+ return this.metadataTags;
}
}
@@ -292,14 +294,18 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking {
};
}
- async getPacketAtIndex(sampleIndex: number, options: PacketRetrievalOptions) {
+ async getPacketAtIndex(
+ res: ResultValue,
+ sampleIndex: number,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise {
if (sampleIndex === -1) {
- return null;
+ return res.set(new PacketRetrievalResult(null));
}
const rawSample = this.demuxer.loadedSamples[sampleIndex];
if (!rawSample) {
- return null;
+ return res.set(new PacketRetrievalResult(null));
}
let data: Uint8Array;
@@ -310,92 +316,102 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking {
if (isThenable(slice)) slice = await slice;
if (!slice) {
- return null; // Data didn't fit into the rest of the file
+ return res.set(new PacketRetrievalResult(null)); // Data didn't fit into the rest of the file
}
data = readBytes(slice, rawSample.dataSize);
}
- return new EncodedPacket(
+ return res.set(new PacketRetrievalResult(new EncodedPacket(
data,
'key',
rawSample.timestamp,
rawSample.duration,
sampleIndex,
rawSample.dataSize,
- );
+ )));
}
- getFirstPacket(options: PacketRetrievalOptions) {
- return this.getPacketAtIndex(0, options);
+ getFirstPacket(res: ResultValue, options: PacketRetrievalOptions) {
+ return this.getPacketAtIndex(res, 0, options);
}
- async getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions) {
- const release = await this.demuxer.readingMutex.acquire();
+ async getNextPacket(
+ res: ResultValue,
+ packet: EncodedPacket,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise {
+ using lock = this.demuxer.readingMutex.lock();
+ if (lock.pending) await lock.ready;
+
+ const sampleIndex = packet.sequenceNumber;
+ assert(sampleIndex >= 0);
+
+ const nextIndex = sampleIndex + 1;
+
+ // Ensure the next sample exists
+ while (
+ nextIndex >= this.demuxer.loadedSamples.length
+ && !this.demuxer.lastSampleLoaded
+ ) {
+ const result = new ResultValue();
+ const promise = this.demuxer.advanceReader(result);
+ if (result.pending) await promise;
+ }
- try {
- const sampleIndex = binarySearchExact(
+ return this.getPacketAtIndex(res, nextIndex, options);
+ }
+
+ async getPacket(
+ res: ResultValue,
+ timestamp: number,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise {
+ using lock = this.demuxer.readingMutex.lock();
+ if (lock.pending) await lock.ready;
+
+ while (true) {
+ const index = binarySearchLessOrEqual(
this.demuxer.loadedSamples,
- packet.timestamp,
+ timestamp,
x => x.timestamp,
);
- if (sampleIndex === -1) {
- throw new Error('Packet was not created from this track.');
- }
- const nextIndex = sampleIndex + 1;
- // Ensure the next sample exists
- while (
- nextIndex >= this.demuxer.loadedSamples.length
- && !this.demuxer.lastSampleLoaded
- ) {
- await this.demuxer.advanceReader();
+ if (index === -1 && this.demuxer.loadedSamples.length > 0) {
+ // We're before the first sample
+ return res.set(new PacketRetrievalResult(null));
}
- return this.getPacketAtIndex(nextIndex, options);
- } finally {
- release();
- }
- }
-
- async getPacket(timestamp: number, options: PacketRetrievalOptions) {
- const release = await this.demuxer.readingMutex.acquire();
-
- try {
- while (true) {
- const index = binarySearchLessOrEqual(
- this.demuxer.loadedSamples,
- timestamp,
- x => x.timestamp,
- );
- if (index === -1 && this.demuxer.loadedSamples.length > 0) {
- // We're before the first sample
- return null;
- }
-
- if (this.demuxer.lastSampleLoaded) {
- // All data is loaded, return what we found
- return this.getPacketAtIndex(index, options);
- }
-
- if (index >= 0 && index + 1 < this.demuxer.loadedSamples.length) {
- // The next packet also exists, we're done
- return this.getPacketAtIndex(index, options);
- }
+ if (this.demuxer.lastSampleLoaded) {
+ // All data is loaded, return what we found
+ return this.getPacketAtIndex(res, index, options);
+ }
- // Otherwise, keep loading data
- await this.demuxer.advanceReader();
+ if (index >= 0 && index + 1 < this.demuxer.loadedSamples.length) {
+ // The next packet also exists, we're done
+ return this.getPacketAtIndex(res, index, options);
}
- } finally {
- release();
+
+ // Otherwise, keep loading data
+ const result = new ResultValue();
+ const promise = this.demuxer.advanceReader(result);
+ if (result.pending) await promise;
}
}
- getKeyPacket(timestamp: number, options: PacketRetrievalOptions) {
- return this.getPacket(timestamp, options);
+ getKeyPacket(
+ res: ResultValue,
+ timestamp: number,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise {
+ return this.getPacket(res, timestamp, options);
}
- getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions) {
- return this.getNextPacket(packet, options);
+ getNextKeyPacket(
+ res: ResultValue,
+ packet: EncodedPacket,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise {
+ return this.getNextPacket(res, packet, options);
}
}
diff --git a/src/adts/adts-muxer.ts b/src/adts/adts-muxer.ts
index 1e33ee798..c8643107b 100644
--- a/src/adts/adts-muxer.ts
+++ b/src/adts/adts-muxer.ts
@@ -32,7 +32,8 @@ export class AdtsMuxer extends Muxer {
}
async start() {
- const release = await this.mutex.acquire();
+ using lock = this.mutex.lock();
+ if (lock.pending) await lock.ready;
this.writer = await this.output._getRootWriter(true);
@@ -40,8 +41,6 @@ export class AdtsMuxer extends Muxer {
const id3Writer = new Id3V2Writer(this.writer);
id3Writer.writeId3V2Tag(this.output._metadataTags);
}
-
- release();
}
async getMimeType() {
@@ -57,60 +56,57 @@ export class AdtsMuxer extends Muxer {
packet: EncodedPacket,
meta?: EncodedAudioChunkMetadata,
) {
- const release = await this.mutex.acquire();
+ using lock = this.mutex.lock();
+ if (lock.pending) await lock.ready;
- try {
- this.validateTimestamp(track, packet.timestamp, packet.type === 'key');
+ this.validateTimestamp(track, packet.timestamp, packet.type === 'key');
- // First packet - determine input format from metadata
- if (this.inputIsAdts === null) {
- validateAudioChunkMetadata(meta, track.source._codec);
+ // First packet - determine input format from metadata
+ if (this.inputIsAdts === null) {
+ validateAudioChunkMetadata(meta, track.source._codec);
- const description = meta?.decoderConfig?.description;
+ const description = meta?.decoderConfig?.description;
- // Follows from the Mediabunny Codec Registry:
- this.inputIsAdts = !description;
+ // Follows from the Mediabunny Codec Registry:
+ this.inputIsAdts = !description;
- if (!this.inputIsAdts) {
- const config = parseAacAudioSpecificConfig(toUint8Array(description!));
- const template = buildAdtsHeaderTemplate(config);
- this.header = template.header;
- this.headerBitstream = template.bitstream;
- }
+ if (!this.inputIsAdts) {
+ const config = parseAacAudioSpecificConfig(toUint8Array(description!));
+ const template = buildAdtsHeaderTemplate(config);
+ this.header = template.header;
+ this.headerBitstream = template.bitstream;
}
+ }
- if (this.inputIsAdts) {
- // Packets are already ADTS frames, write them directly
- const startPos = this.writer.getPos();
- this.writer.write(packet.data);
+ if (this.inputIsAdts) {
+ // Packets are already ADTS frames, write them directly
+ const startPos = this.writer.getPos();
+ this.writer.write(packet.data);
- if (this.format._options.onFrame) {
- this.format._options.onFrame(packet.data, startPos);
- }
- } else {
- assert(this.header);
+ if (this.format._options.onFrame) {
+ this.format._options.onFrame(packet.data, startPos);
+ }
+ } else {
+ assert(this.header);
- // Packets are raw AAC, we gotta turn it into ADTS
- const frameLength = packet.data.byteLength + this.header.byteLength;
- writeAdtsFrameLength(this.headerBitstream!, frameLength);
+ // Packets are raw AAC, we gotta turn it into ADTS
+ const frameLength = packet.data.byteLength + this.header.byteLength;
+ writeAdtsFrameLength(this.headerBitstream!, frameLength);
- const startPos = this.writer.getPos();
- this.writer.write(this.header);
- this.writer.write(packet.data);
+ const startPos = this.writer.getPos();
+ this.writer.write(this.header);
+ this.writer.write(packet.data);
- if (this.format._options.onFrame) {
- const frameBytes = new Uint8Array(frameLength);
- frameBytes.set(this.header, 0);
- frameBytes.set(packet.data, this.header.byteLength);
+ if (this.format._options.onFrame) {
+ const frameBytes = new Uint8Array(frameLength);
+ frameBytes.set(this.header, 0);
+ frameBytes.set(packet.data, this.header.byteLength);
- this.format._options.onFrame(frameBytes, startPos);
- }
+ this.format._options.onFrame(frameBytes, startPos);
}
-
- await this.writer.flush();
- } finally {
- release();
}
+
+ await this.writer.flush();
}
async addSubtitleCue() {
@@ -118,12 +114,11 @@ export class AdtsMuxer extends Muxer {
}
async finalize() {
- const release = await this.mutex.acquire(); // Required so that finalize() can't resolve before other calls
+ using lock = this.mutex.lock();
+ if (lock.pending) await lock.ready; // Required so that finalize() can't resolve before other calls
if (this.inputIsAdts === null) {
throw new Error('Cannot finalize an empty ADTS file: not a single packet was added.');
}
-
- release();
}
}
diff --git a/src/conversion.ts b/src/conversion.ts
index 83c85cd48..999a91301 100644
--- a/src/conversion.ts
+++ b/src/conversion.ts
@@ -25,11 +25,7 @@ import {
import { Input } from './input';
import { InputAudioTrack, InputTrack, InputVideoTrack } from './input-track';
import { Logging } from './logging';
-import {
- AudioSampleSink,
- EncodedPacketSink,
- VideoSampleSink,
-} from './media-sink';
+import { AudioSampleCursor, PacketCursor, VideoSampleCursor } from './cursors';
import {
AudioSource,
EncodedVideoPacketSource,
@@ -52,7 +48,7 @@ import {
} from './misc';
import { Output, OutputTrackGroup } from './output';
import { Mp4OutputFormat } from './output-format';
-import { EncodedPacket } from './packet';
+import { EncodedPacket, PacketReader } from './packet';
import {
AudioSample,
clampCropRectangle,
@@ -745,6 +741,8 @@ export class Conversion {
_computeProgress = false;
/** @internal */
_lastProgress = 0;
+ /** @internal */
+ _lastProcessedTime = 0;
/**
* Whether this conversion, as it has been configured, is valid and can be executed. If this field is `false`, check
@@ -1408,8 +1406,9 @@ export class Conversion {
}
if (this._computeProgress) {
- const minTimestamp = Math.min(...this._maxTimestamps.values());
- this.onProgress?.(1, minTimestamp);
+ // By this point, all tracks have closed and removed themselves from _maxTimestamps, so we use the last
+ // processed time we saw while they were still open
+ this.onProgress?.(1, this._lastProcessedTime);
}
}
}
@@ -1531,9 +1530,9 @@ export class Conversion {
if (!needsTranscode) {
// Check if we can copy it
- const sink = new EncodedPacketSink(track);
- let startPacket = await sink.getKeyPacket(this._startTimestamp, { verifyKeyPackets: true })
- ?? await sink.getFirstKeyPacket({ verifyKeyPackets: true });
+ const reader = new PacketReader(track);
+ let startPacket = await reader.getKeyAt(this._startTimestamp, { verifyKeyPackets: true })
+ ?? await reader.getFirstKey({ verifyKeyPackets: true });
if (
startPacket
@@ -1541,7 +1540,7 @@ export class Conversion {
&& startPacket.timestamp + startPacket.duration <= this._startTimestamp
&& this._copyBoundaryPolicy === 'shrink'
) {
- startPacket = await sink.getNextKeyPacket(startPacket, { verifyKeyPackets: true });
+ startPacket = await reader.getNextKey(startPacket, { verifyKeyPackets: true });
}
copyStartPacket = startPacket;
@@ -1627,17 +1626,18 @@ export class Conversion {
videoSource = source;
this._registerTrackPump(async (pump) => {
- const sink = new EncodedPacketSink(track);
+ const cursor = new PacketCursor(track, { options: { verifyKeyPackets: true } });
+ const reader = new PacketReader(track);
const decoderConfig = await track.getDecoderConfig();
const meta: EncodedVideoChunkMetadata = { decoderConfig: decoderConfig ?? undefined };
let maxTimestamp: number | null = null;
+ if (copyStartPacket) {
+ await cursor.seekToKey(copyStartPacket.timestamp);
+ }
+
// eslint-disable-next-line curly
- if (copyStartPacket) for await (const packet of sink.packets(
- copyStartPacket,
- undefined,
- { verifyKeyPackets: true },
- )) {
+ if (copyStartPacket) for await (const packet of cursor) {
if (this._state === 'canceled') {
break;
}
@@ -1654,7 +1654,7 @@ export class Conversion {
const lookahead = 6; // Heuristic, but should be enough for most streams
for (let i = 0; i < lookahead; i++) {
- const next = await sink.getNextPacket(current, { metadataOnly: true });
+ const next = await reader.getNext(current, { metadataOnly: true });
if (!next) {
break;
}
@@ -1703,7 +1703,7 @@ export class Conversion {
type: packetType,
});
- this._reportProgress(outputTrackId, modifiedPacket.timestamp + modifiedPacket.duration);
+ this._updateProgress(outputTrackId, modifiedPacket.timestamp + modifiedPacket.duration);
await source.add(modifiedPacket, meta);
if (this._synchronizer.shouldWait(outputTrackId, modifiedPacket.timestamp)) {
@@ -1714,7 +1714,7 @@ export class Conversion {
}
source.close();
- this._synchronizer.closeTrack(outputTrackId);
+ this._closeTrack(outputTrackId);
});
} else {
// We need to decode & reencode the video
@@ -1795,13 +1795,13 @@ export class Conversion {
await tempOutput.start();
// Let's just use the first sample to test
- const sink = new VideoSampleSink(track);
- using firstSample = await sink.getSample(await track.getFirstTimestamp());
+ const cursor = new VideoSampleCursor(track, { closeSamples: false });
+ using firstSample = await cursor.seekToFirst();
+ await cursor.close();
if (firstSample) {
try {
await tempSource.add(firstSample);
- firstSample.close();
await tempOutput.finalize();
} catch (error) {
Logging._warn(
@@ -1851,13 +1851,18 @@ export class Conversion {
videoSource = source;
this._registerTrackPump(async (pump) => {
- const sink = new VideoSampleSink(track);
+ await using cursor = new VideoSampleCursor(track);
+ await cursor.seekTo(this._startTimestamp);
- for await (using sample of sink.samples(this._startTimestamp, this._endTimestamp)) {
+ for await (const sample of cursor) {
if (this._state === 'canceled') {
break;
}
+ if (sample.timestamp >= this._endTimestamp) {
+ break;
+ }
+
const clampedStartTimestamp = Math.max(this._startTimestamp, sample.timestamp);
const clampedEndTimestamp = Math.min(this._endTimestamp, sample.timestamp + sample.duration);
@@ -1866,12 +1871,14 @@ export class Conversion {
continue;
}
- sample.setTimestamp(clampedStartTimestamp + this._timestampOffset);
- sample.setDuration(clampedEndTimestamp - clampedStartTimestamp);
+ using adjustedSample = sample.clone({
+ timestamp: clampedStartTimestamp + this._timestampOffset,
+ duration: clampedEndTimestamp - clampedStartTimestamp,
+ });
- this._reportProgress(outputTrackId, sample.timestamp + sample.duration);
- await source.add(sample);
- sample.close();
+ this._updateProgress(outputTrackId, adjustedSample.timestamp + adjustedSample.duration);
+ await source.add(adjustedSample);
+ adjustedSample.close();
if (lastSampleTimestamp !== null) {
if (this._synchronizer.shouldWait(outputTrackId, lastSampleTimestamp)) {
@@ -1883,7 +1890,7 @@ export class Conversion {
}
source.close();
- this._synchronizer.closeTrack(outputTrackId);
+ this._closeTrack(outputTrackId);
});
}
@@ -1963,9 +1970,9 @@ export class Conversion {
if (!needsTranscode) {
// Check if we can copy it
- const sink = new EncodedPacketSink(track);
- let startPacket = await sink.getKeyPacket(this._startTimestamp)
- ?? await sink.getFirstKeyPacket();
+ const reader = new PacketReader(track);
+ let startPacket = await reader.getKeyAt(this._startTimestamp)
+ ?? await reader.getFirstKey();
if (
startPacket
@@ -1981,14 +1988,14 @@ export class Conversion {
)
)
) {
- startPacket = await sink.getNextKeyPacket(startPacket);
+ startPacket = await reader.getNextKey(startPacket);
}
const hasDecoderWarmup = (NON_PCM_AUDIO_CODECS as readonly AudioCodec[]).includes(sourceCodec)
&& sourceCodec !== 'flac';
if (startPacket && this._copyBoundaryPolicy === 'expand' && hasDecoderWarmup) {
// Go one packet back
- const previousPacket = await sink.getKeyPacket(
+ const previousPacket = await reader.getKeyAt(
startPacket.timestamp - 1 / (await track.getTimeResolution()),
);
if (previousPacket) {
@@ -2072,13 +2079,17 @@ export class Conversion {
audioSource = source;
this._registerTrackPump(async (pump) => {
- const sink = new EncodedPacketSink(track);
+ const cursor = new PacketCursor(track);
const decoderConfig = await track.getDecoderConfig();
const meta: EncodedAudioChunkMetadata = { decoderConfig: decoderConfig ?? undefined };
let maxTimestamp: number | null = null;
+ if (copyStartPacket) {
+ await cursor.seekToKey(copyStartPacket.timestamp);
+ }
+
// eslint-disable-next-line curly
- if (copyStartPacket) for await (const packet of sink.packets(copyStartPacket)) {
+ if (copyStartPacket) for await (const packet of cursor) {
if (this._state === 'canceled') {
break;
}
@@ -2106,7 +2117,7 @@ export class Conversion {
duration: packet.duration,
});
- this._reportProgress(outputTrackId, modifiedPacket.timestamp + modifiedPacket.duration);
+ this._updateProgress(outputTrackId, modifiedPacket.timestamp + modifiedPacket.duration);
await source.add(modifiedPacket, meta);
if (this._synchronizer.shouldWait(outputTrackId, modifiedPacket.timestamp)) {
@@ -2117,7 +2128,7 @@ export class Conversion {
}
source.close();
- this._synchronizer.closeTrack(outputTrackId);
+ this._closeTrack(outputTrackId);
});
} else {
// We need to decode & reencode the audio
@@ -2216,12 +2227,18 @@ export class Conversion {
this._registerTrackPump(async (pump) => {
let needsPadding: boolean | null = null;
- const sink = new AudioSampleSink(track);
- for await (using sample of sink.samples(this._startTimestamp, this._endTimestamp)) {
+ await using cursor = new AudioSampleCursor(track);
+ await cursor.seekTo(this._startTimestamp);
+
+ for await (const sample of cursor) {
if (this._state === 'canceled') {
break;
}
+ if (sample.timestamp >= this._endTimestamp) {
+ break;
+ }
+
let startFrame = 0;
let endFrame = sample.numberOfFrames;
@@ -2234,30 +2251,24 @@ export class Conversion {
if (startFrame >= endFrame) {
// Sample lies wholly out of trim region
- sample.close();
continue; // No break since we may be before the start
}
- // Can't assign to "using" identifiers so we gotta do this
- let finalSampleLet: AudioSample;
- if (startFrame > 0 || endFrame < sample.numberOfFrames) {
- // Trim the sample if it sticks out of the trim region on either end
- const trimmedSample = sample.trim(startFrame, endFrame);
- sample.close();
- finalSampleLet = trimmedSample;
-
- if (trimmedSample.numberOfFrames === 0) {
- trimmedSample.close();
- continue;
- }
- } else {
- finalSampleLet = sample;
- }
+ // Trim the sample if it sticks out of the trim region on either end
+ using trimmedSample = startFrame > 0 || endFrame < sample.numberOfFrames
+ ? sample.trim(startFrame, endFrame)
+ : null;
- using finalSample = finalSampleLet;
+ if (trimmedSample?.numberOfFrames === 0) {
+ continue;
+ }
- // Offset the timestamp as needed
- finalSample.setTimestamp(finalSample.timestamp + this._timestampOffset);
+ // Offset the timestamp as needed. The clone also gives us a sample that we own (the one yielded
+ // by the cursor belongs to the cursor).
+ const sourceSample = trimmedSample ?? sample;
+ using finalSample = sourceSample.clone({
+ timestamp: sourceSample.timestamp + this._timestampOffset,
+ });
if (needsPadding === null) {
needsPadding = finalSample.timestamp > 0 && !this.output.format.supportsTimestampedMediaData;
@@ -2296,7 +2307,7 @@ export class Conversion {
}
source.close();
- this._synchronizer.closeTrack(outputTrackId);
+ this._closeTrack(outputTrackId);
});
}
@@ -2340,7 +2351,7 @@ export class Conversion {
outputTrackId: number,
getLastSampleTimestamp: () => number | null,
) {
- this._reportProgress(outputTrackId, sample.timestamp + sample.duration);
+ this._updateProgress(outputTrackId, sample.timestamp + sample.duration);
await source.add(sample);
sample.close();
@@ -2387,20 +2398,38 @@ export class Conversion {
}
/** @internal */
- _reportProgress(trackId: number, endTimestamp: number) {
+ _closeTrack(id: number) {
+ this._synchronizer.closeTrack(id);
+
+ this._maxTimestamps.delete(id);
+ this._reportProgress();
+ }
+
+ /** @internal */
+ _updateProgress(trackId: number, endTimestamp: number) {
if (!this._computeProgress) {
return;
}
- assert(this._totalDuration !== null);
this._maxTimestamps.set(
trackId,
Math.max(endTimestamp, this._maxTimestamps.get(trackId)!),
);
+ this._reportProgress();
+ }
+
+ /** @internal */
+ _reportProgress() {
+ if (!this._computeProgress || this._maxTimestamps.size === 0) {
+ return;
+ }
+ assert(this._totalDuration !== null);
const minTimestamp = Math.min(...this._maxTimestamps.values());
const newProgress = clamp(minTimestamp / this._totalDuration, 0, 1);
+ this._lastProcessedTime = minTimestamp;
+
if (newProgress !== this._lastProgress) {
this._lastProgress = newProgress;
this.onProgress?.(newProgress, minTimestamp);
diff --git a/src/cursors.ts b/src/cursors.ts
new file mode 100644
index 000000000..2e55e1be0
--- /dev/null
+++ b/src/cursors.ts
@@ -0,0 +1,1610 @@
+/*!
+ * Copyright (c) 2026-present, Vanilagy and contributors
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+import { PCM_AUDIO_CODECS } from './codec';
+import { AudioDecoderWrapper, DecoderWrapper, PcmAudioDecoderWrapper, VideoDecoderWrapper } from './decode';
+import { InputAudioTrack, InputTrack, InputVideoTrack } from './input-track';
+import {
+ assert,
+ AsyncMutex,
+ AsyncMutexLock,
+ ForgivingCallSerializer,
+ defer,
+ isFirefox,
+ isThenable,
+ last,
+ MaybePromise,
+ missingWebCodecsClassMessage,
+ polyfillSymbolDispose,
+ promiseWithResolvers,
+ ResultValue,
+ Rotation,
+ AsyncGate,
+ MaybeRelevantPromise,
+} from './misc';
+import {
+ EncodedPacket,
+ PacketCache,
+ PacketReader,
+ PacketRetrievalOptions,
+ validatePacketRetrievalOptions,
+ validateTimestamp,
+} from './packet';
+import { AudioSample, clampCropRectangle, CropRectangle, validateCropRectangle, VideoSample } from './sample';
+
+polyfillSymbolDispose();
+
+export type PacketCursorConfig = {
+ options?: PacketRetrievalOptions;
+ cache?: PacketCache;
+};
+
+export class PacketCursor {
+ track: T;
+ current: EncodedPacket | null = null;
+
+ private _reader: PacketReader;
+ private _options: PacketRetrievalOptions;
+ private _nextIsFirst = true;
+ private _callSerializer = new ForgivingCallSerializer();
+
+ constructor(track: T, config: PacketCursorConfig = {}) {
+ if (!(track instanceof InputTrack)) {
+ throw new TypeError('track must be an InputTrack.');
+ }
+ if (typeof config !== 'object' || config === null) {
+ throw new TypeError('config must be an object.');
+ }
+ if (config.options !== undefined) {
+ validatePacketRetrievalOptions(config.options, 'config.options');
+ }
+ if (config.cache !== undefined && !(config.cache instanceof PacketCache)) {
+ throw new TypeError('config.cache, when provided, must be a PacketCache.');
+ }
+
+ this.track = track;
+ this._reader = new PacketReader(track, { cache: config.cache });
+ this._options = config.options ?? {};
+ }
+
+ private _seekToFirstDirect(): MaybePromise {
+ const result = this._reader.getFirst(this._options);
+
+ const onPacket = (packet: EncodedPacket | null) => {
+ this._nextIsFirst = false;
+ return this.current = packet;
+ };
+
+ if (isThenable(result)) {
+ return result.then(onPacket);
+ } else {
+ return onPacket(result);
+ }
+ }
+
+ seekToFirst(): MaybePromise {
+ return this._callSerializer.call(() => this._seekToFirstDirect());
+ }
+
+ seekToFirstKey(): MaybePromise {
+ return this._callSerializer.call(() => {
+ const result = this._reader.getFirstKey(this._options);
+
+ const onPacket = (packet: EncodedPacket | null) => {
+ this._nextIsFirst = false;
+ return this.current = packet;
+ };
+
+ if (isThenable(result)) {
+ return result.then(onPacket);
+ } else {
+ return onPacket(result);
+ }
+ });
+ }
+
+ seekTo(timestamp: number): MaybePromise {
+ validateTimestamp(timestamp);
+
+ return this._callSerializer.call(() => {
+ const result = this._reader.getAt(timestamp, this._options);
+
+ const onPacket = (packet: EncodedPacket | null) => {
+ this._nextIsFirst = !packet;
+ return this.current = packet;
+ };
+
+ if (isThenable(result)) {
+ return result.then(onPacket);
+ } else {
+ return onPacket(result);
+ }
+ });
+ }
+
+ seekToKey(timestamp: number): MaybePromise {
+ validateTimestamp(timestamp);
+
+ return this._callSerializer.call(() => {
+ const result = this._reader.getKeyAt(timestamp, this._options);
+
+ const onPacket = (packet: EncodedPacket | null) => {
+ this._nextIsFirst = !packet;
+ return this.current = packet;
+ };
+
+ if (isThenable(result)) {
+ return result.then(onPacket);
+ } else {
+ return onPacket(result);
+ }
+ });
+ }
+
+ next(): MaybePromise {
+ return this._callSerializer.call(() => {
+ if (this._nextIsFirst) {
+ return this._seekToFirstDirect();
+ }
+
+ if (!this.current) {
+ return null;
+ }
+
+ const result = this._reader.getNext(this.current, this._options);
+
+ const onPacket = (packet: EncodedPacket | null) => {
+ return this.current = packet;
+ };
+
+ if (isThenable(result)) {
+ return result.then(onPacket);
+ } else {
+ return onPacket(result);
+ }
+ });
+ }
+
+ nextKey(): MaybePromise {
+ return this._callSerializer.call(() => {
+ if (this._nextIsFirst) {
+ return this._seekToFirstDirect();
+ }
+
+ if (!this.current) {
+ return null;
+ }
+
+ const result = this._reader.getNextKey(this.current, this._options);
+
+ const onPacket = (packet: EncodedPacket | null) => {
+ return this.current = packet;
+ };
+
+ if (isThenable(result)) {
+ return result.then(onPacket);
+ } else {
+ return onPacket(result);
+ }
+ });
+ }
+
+ async iterate(
+ callback: (packet: EncodedPacket) => MaybePromise,
+ ) {
+ if (typeof callback !== 'function') {
+ throw new TypeError('callback must be a function.');
+ }
+
+ const donePromise = this._callSerializer.waitUntilIdle();
+ if (donePromise) await donePromise;
+
+ while (true) {
+ if (this.current) {
+ let result = callback(this.current);
+ if (isThenable(result)) result = await result;
+
+ if (result === false) {
+ break;
+ }
+ }
+
+ const result = this.next();
+ if (isThenable(result)) await result;
+
+ if (!this.current) {
+ break;
+ }
+ }
+ }
+
+ // eslint-disable-next-line @stylistic/generator-star-spacing
+ async *[Symbol.asyncIterator]() {
+ const donePromise = this._callSerializer.waitUntilIdle();
+ if (donePromise) await donePromise;
+
+ while (true) {
+ if (this.current) {
+ yield this.current;
+ }
+
+ const result = this.next();
+ if (isThenable(result)) await result;
+
+ if (!this.current) {
+ break;
+ }
+ }
+ }
+
+ waitUntilIdle() {
+ return this._callSerializer.waitUntilIdle();
+ }
+
+ isIdle() {
+ return this._callSerializer.isIdle();
+ }
+}
+
+type PendingRequest = {
+ timestamp: number;
+ promise: Promise;
+ resolve: (sample: T | null) => void;
+ reject: (error: unknown) => void;
+ successor: PendingRequest | null;
+};
+
+export type SampleTransformer = (sample: Sample) => TransformedSample;
+
+export type SampleCursorOptions = {
+ closeSamples?: boolean;
+ transform?: SampleTransformer;
+ skipLiveWait?: boolean;
+ cache?: PacketCache;
+};
+
+const validateSampleCursorOptions = (
+ options: SampleCursorOptions,
+) => {
+ if (!options || typeof options !== 'object') {
+ throw new TypeError('options must an object.');
+ }
+ if (options.closeSamples !== undefined && typeof options.closeSamples !== 'boolean') {
+ throw new TypeError('options.closeSamples, when provided, must be a boolean.');
+ }
+ if (options.transform !== undefined && typeof options.transform !== 'function') {
+ throw new TypeError('options.transform, when provided, must be a function.');
+ }
+ if (options.skipLiveWait !== undefined && typeof options.skipLiveWait !== 'boolean') {
+ throw new TypeError('options.skipLiveWait, when provided, must be a boolean.');
+ }
+ if (options.cache !== undefined && !(options.cache instanceof PacketCache)) {
+ throw new TypeError('options.cache, when provided, must be a PacketCache.');
+ }
+};
+
+export abstract class SampleCursor<
+ Sample extends VideoSample | AudioSample,
+ TransformedSample = Sample,
+> implements AsyncDisposable {
+ track: InputTrack;
+ current: TransformedSample | null = null;
+
+ private _transform: SampleTransformer;
+ private _closeSamples: boolean;
+ private _retrievalOptions: PacketRetrievalOptions;
+
+ /** @internal */
+ _mutex = new AsyncMutex();
+
+ private _packetReader: PacketReader;
+ private _packetCursor: PacketCursor;
+ /** @internal */
+ _decoder: DecoderWrapper | null = null;
+ private _currentSample: Sample | null = null;
+ /** Updated when _currentSample is updated, but reset when a new pump is started. */
+ private _currentSampleTimestamp: number | null = null;
+ /** The queue of samples that have been decoded and are now waiting. */
+ private _sampleQueue: Sample[] = [];
+ private _pendingRequests: PendingRequest[] = [];
+ private _lastPendingRequest: PendingRequest | null = null;
+ /** Whether the next sample is the first sample. */
+ private _nextIsFirst = true;
+ private _queuedResets = 0;
+
+ /** @internal */
+ _pumpRunning = false;
+ /** Used to pause and resume the pump. */
+ private _pumpGate = new AsyncGate();
+ private _pumpStopQueued = false;
+ private _pumpStopped = new AsyncGate();
+ /** The minimum target packet until which the pump should decode. */
+ private _pumpTarget: EncodedPacket | null = null;
+ private _lastTarget: EncodedPacket | null = null;
+ private _decoderFlushPromise: Promise | null = null;
+ /**
+ * When this value is above 0, the pump is instructed to be lazy: that is, only decode packets until the target and
+ * not further to increase decoder efficiency.
+ */
+ private _lazyPump = 0;
+
+ private _closed = false;
+ private _closePromise: Promise | null = null;
+ private _error: unknown = null;
+ private _errorSet = false;
+
+ /**
+ * A bunch of utilities for simulating certain behaviors for testing.
+ * @internal
+ */
+ _debug = {
+ enabled: false,
+ pumpsStarted: 0,
+ seekPackets: [] as (EncodedPacket | null)[],
+ decodedPackets: [] as EncodedPacket[],
+ throwInDecoderInit: false,
+ throwInPump: false,
+ throwDecoderError: false,
+ unthrottledPump: false,
+ pumpEnded: new AsyncGate(),
+ };
+
+ get closed(): boolean {
+ return this._closed;
+ }
+
+ get errored(): boolean {
+ return this._errorSet;
+ }
+
+ protected constructor(
+ track: InputTrack,
+ options: SampleCursorOptions,
+ ) {
+ this.track = track;
+ this._retrievalOptions = { skipLiveWait: options.skipLiveWait };
+ this._packetReader = new PacketReader(track, { cache: options.cache });
+ this._packetCursor = new PacketCursor(track, { options: this._retrievalOptions, cache: options.cache });
+ this._closeSamples = options.closeSamples ?? true;
+ this._transform = options.transform ?? (sample => sample as unknown as TransformedSample);
+
+ track.input._openSampleCursors.add(this);
+
+ const lock = this._mutex.lock();
+ assert(!lock.pending);
+
+ void this._initDecoder()
+ .then(decoder => this._decoder = decoder)
+ .catch(error => this._closeWithError(error, false))
+ .finally(() => lock.release());
+ }
+
+ private _getSample(
+ callback: (result: ResultValue) => MaybeRelevantPromise,
+ ): MaybePromise {
+ this._ensureWillBeOpen();
+
+ try {
+ const result = new ResultValue();
+ const promise = callback(result);
+
+ if (result.pending) {
+ return promise
+ .then(() => result.value)
+ .catch(this._closeWithErrorAndThrow.bind(this));
+ } else {
+ return result.value;
+ }
+ } catch (error) {
+ this._closeWithErrorAndThrow(error);
+ }
+ }
+
+ seekToFirst(): MaybePromise {
+ // The first packet, as far as sample cursors are concerned, is the first *key* packet - it's the first packet
+ // with which decoding can begin.
+ return this._getSample(result => this._seekToPacket(
+ result,
+ this._packetReader.getFirstKey({ ...this._retrievalOptions, verifyKeyPackets: true }),
+ ));
+ }
+
+ seekTo(timestamp: number): MaybePromise {
+ validateTimestamp(timestamp);
+ return this._getSample(result => this._seekToPacket(
+ result,
+ this._packetReader.getAt(timestamp, this._retrievalOptions),
+ ));
+ }
+
+ seekToKey(timestamp: number): MaybePromise {
+ validateTimestamp(timestamp);
+ return this._getSample(result => this._seekToPacket(
+ result,
+ this._packetReader.getKeyAt(timestamp, { ...this._retrievalOptions, verifyKeyPackets: true }),
+ ));
+ }
+
+ next(): MaybePromise {
+ return this._getSample(result => this._nextInternal(result));
+ }
+
+ nextKey(): MaybePromise {
+ return this._getSample(result => this._nextKeyInternal(result));
+ }
+
+ hasNext(): MaybePromise {
+ this._ensureWillBeOpen();
+
+ try {
+ const result = new ResultValue();
+ const promise = this._hasNextInternal(result);
+
+ if (result.pending) {
+ return promise
+ .then(() => result.value)
+ .catch(this._closeWithErrorAndThrow.bind(this));
+ } else {
+ return result.value;
+ }
+ } catch (error) {
+ this._closeWithErrorAndThrow(error);
+ }
+ }
+
+ async iterate(
+ callback: (sample: TransformedSample) => MaybePromise,
+ ) {
+ if (typeof callback !== 'function') {
+ throw new TypeError('callback must be a function.');
+ }
+
+ this._ensureWillBeOpen();
+
+ const waitPromise = this.waitUntilIdle();
+ if (waitPromise) await waitPromise;
+
+ this._ensureNotClosed();
+
+ while (true) {
+ if (this.current) {
+ let result = callback(this.current);
+ if (isThenable(result)) result = await result;
+
+ if (result === false) {
+ break;
+ }
+ }
+
+ const result = this.next();
+ if (isThenable(result)) await result;
+
+ if (!this.current) {
+ break;
+ }
+ }
+ }
+
+ // eslint-disable-next-line @stylistic/generator-star-spacing
+ async *[Symbol.asyncIterator]() {
+ this._ensureWillBeOpen();
+
+ const waitPromise = this.waitUntilIdle();
+ if (waitPromise) await waitPromise;
+
+ this._ensureNotClosed();
+
+ while (true) {
+ if (this.current) {
+ yield this.current;
+ }
+
+ const result = this.next();
+ if (isThenable(result)) await result;
+
+ if (!this.current) {
+ break;
+ }
+ }
+ }
+
+ close() {
+ return this._closePromise ??= this._closed
+ ? Promise.resolve()
+ : this._closeInternal();
+ }
+
+ [Symbol.asyncDispose]() {
+ return this.close();
+ }
+
+ async reset() {
+ this._lazyPump++;
+
+ this._queuedResets++;
+ using _ = defer(() => this._queuedResets--);
+
+ using lock = this._mutex.lock();
+ if (lock.pending) await lock.ready;
+
+ if (!this._closed) {
+ await this._closeInternal(false);
+ }
+
+ // All of this should automatically be true after a close
+ assert(!this._pumpRunning);
+ assert(!this._pumpStopQueued);
+ assert(!this._currentSample);
+ assert(!this.current);
+ assert(this._sampleQueue.length === 0);
+ assert(this._pendingRequests.length === 0);
+ assert(this._lastPendingRequest === null);
+ assert(this._pumpTarget === null);
+ assert(!this._decoder || this._decoder.closed);
+
+ this._closed = false;
+ this._closePromise = null;
+ this._error = null;
+ this._errorSet = false;
+ this._nextIsFirst = true;
+ this._lazyPump = 0;
+
+ this.track.input._openSampleCursors.add(this);
+
+ try {
+ const newDecoder = await this._initDecoder();
+ this._decoder = newDecoder;
+ } catch (error) {
+ this._closeWithErrorAndThrow(error, false);
+ }
+ }
+
+ /**
+ * Returns a Promise that resolves when currently pending operations (at the time of calling this method)
+ * are settled, or `null` if there are none.
+ */
+ waitUntilIdle(): Promise | null {
+ const lock = this._mutex.lock();
+ if (!lock.pending && this._pendingRequests.length === 0) {
+ lock.release();
+ return null;
+ }
+
+ const getLastPendingPromise = () => {
+ lock.release();
+
+ if (this._pendingRequests.length === 0) {
+ return;
+ }
+
+ let lastRequest = last(this._pendingRequests)!;
+ while (lastRequest.successor) {
+ lastRequest = lastRequest.successor;
+ }
+
+ return lastRequest.promise
+ .catch(() => {})
+ .then(() => {});
+ };
+
+ if (lock.pending) {
+ assert(lock.ready);
+ return lock.ready.then(getLastPendingPromise);
+ } else {
+ return getLastPendingPromise() ?? null;
+ }
+ }
+
+ isIdle(): boolean {
+ return this._pendingRequests.length === 0 && !this._mutex.locked;
+ }
+
+ /** @internal */
+ abstract _initDecoder(): Promise>;
+
+ protected _onDecoderSample(sample: Sample): void {
+ try {
+ if (this._debug.enabled && this._debug.throwDecoderError) {
+ sample.close();
+
+ if (!this._closed) {
+ // Let's fake a decoder error this way
+ return this._onDecoderError(new Error('Fake decoder error!'));
+ } else {
+ return;
+ }
+ }
+
+ if (this._pendingRequests.length === 0) {
+ if (this._pumpStopQueued || !this._pumpRunning) {
+ // Don't care about it anymore
+ sample.close();
+ } else {
+ // Let's save it for later
+ this._sampleQueue.push(sample);
+ }
+ } else {
+ // If requests are pending, the sample queue must be empty; samples are queued only when there is
+ // nobody to hand them to
+ assert(this._sampleQueue.length === 0);
+
+ let given = false;
+ let nextInsertionIndex = 0;
+
+ // Let's hand the sample to all matching requests
+ for (let i = 0; i < this._pendingRequests.length; i++) {
+ const request = this._pendingRequests[i]!;
+ if (request.timestamp > sample.timestamp) {
+ break;
+ }
+
+ this._setCurrentRaw(sample);
+ request.resolve(this._transformSample());
+
+ this._pendingRequests.splice(i--, 1);
+ given = true;
+
+ if (request.successor) {
+ // If the request has a successor request, "unlock" that successor and add it to the
+ // start of the queue
+ this._pendingRequests.splice(nextInsertionIndex, 0, request.successor);
+ i++;
+ nextInsertionIndex++;
+ }
+ }
+
+ if (!given) {
+ sample.close();
+ }
+ }
+
+ this._pumpGate.open();
+ } catch (error) {
+ void this._closeWithError(error);
+ }
+ }
+
+ protected _onDecoderError(error: unknown): void {
+ void this._closeWithError(error);
+ }
+
+ protected _onDecoderDequeue() {
+ this._pumpGate.open();
+ }
+
+ private _setCurrentRaw(newCurrentRaw: Sample | null) {
+ if (this._currentSample === newCurrentRaw) {
+ return;
+ }
+
+ this._currentSample?.close();
+ this._currentSample = newCurrentRaw;
+ this._currentSampleTimestamp = newCurrentRaw?.timestamp ?? null;
+ this.current = null;
+ }
+
+ private _transformSample() {
+ assert(this._currentSample && !this._currentSample.closed);
+
+ if (this._closeSamples) {
+ // Here, the transformation is memoized: repeated calls will not transform the same sample twice.
+ return this.current ??= this._transform(this._currentSample);
+ } else {
+ // Here, the transformation happens every time since the sample is also cloned every time
+ const clone = this._currentSample.clone();
+ const transformed = this._transform(clone as Sample);
+
+ return this.current = transformed;
+ }
+ }
+
+ private async _seekToPacket(
+ res: ResultValue,
+ targetPacketPromise: MaybePromise,
+ lock?: AsyncMutexLock,
+ ): MaybeRelevantPromise {
+ this._lazyPump++;
+
+ if (!lock) {
+ lock = this._mutex.lock();
+ if (lock.pending) await lock.ready;
+ }
+
+ using deferred = defer(() => {
+ lock?.release();
+ this._lazyPump--;
+ });
+
+ this._ensureNotClosed();
+
+ // First, let's wait for the packet to be retrieved
+ const targetPacket = isThenable(targetPacketPromise)
+ ? await targetPacketPromise
+ : targetPacketPromise;
+
+ if (this._debug.enabled) {
+ this._debug.seekPackets.push(targetPacket);
+ }
+
+ // A null packet means we're before the first packet
+ this._nextIsFirst = !targetPacket;
+
+ if (!targetPacket) {
+ this._lastTarget = null;
+ this._setCurrentRaw(null);
+ return res.set(null);
+ }
+
+ if (this._currentSample?.timestamp === targetPacket.timestamp && !this._currentSample.closed) {
+ // We can reuse the current sample, the timestamp is the same
+ return res.set(this._transformSample());
+ }
+
+ let needsNewPump: boolean;
+
+ let lastRequest = last(this._pendingRequests) ?? null;
+ while (lastRequest?.successor) {
+ lastRequest = lastRequest.successor;
+ }
+
+ if (lastRequest?.timestamp === -Infinity) {
+ // next() requests are queued, so in order to know if we need to start a new pump or not, we'll need to wait
+ await lastRequest.promise
+ .then(() => {})
+ .catch(() => {});
+
+ this._ensureNotClosed();
+ }
+
+ const lastTimestamp = Math.max(
+ this._lastTarget?.timestamp ?? -Infinity,
+ this._currentSampleTimestamp ?? -Infinity, // This one's revelant if we had next() requests
+ );
+
+ if (lastTimestamp !== -Infinity && lastTimestamp <= targetPacket.timestamp) {
+ const findQueuedSatisfyingSample = () => {
+ while (this._sampleQueue.length > 0) {
+ const nextSample = this._sampleQueue.shift()!;
+ this._pumpGate.open();
+
+ if (targetPacket.timestamp <= nextSample.timestamp) {
+ return nextSample;
+ } else {
+ nextSample.close();
+ }
+ }
+ };
+
+ // First, let's see if an already-decoded sample can satisfy the request
+ let satisfyingSample = findQueuedSatisfyingSample();
+ if (satisfyingSample) {
+ this._setCurrentRaw(satisfyingSample);
+ return res.set(this._transformSample());
+ }
+
+ if (this.track.type === 'audio' && targetPacket.timestamp - lastTimestamp < 0.1) {
+ // The difference is too small for it to be worth to set up a new pump, relevant for audio tracks
+ needsNewPump = false;
+ } else {
+ if (this._packetCursor.current) {
+ // We need to see if the target packet is ahead of the decoder, GOP-wise
+ let nextKey = this._packetReader.getNextKey(
+ this._packetCursor.current,
+ { ...this._retrievalOptions, verifyKeyPackets: true },
+ );
+ if (isThenable(nextKey)) nextKey = await nextKey;
+
+ needsNewPump = !!nextKey && targetPacket.sequenceNumber >= nextKey.sequenceNumber;
+ } else {
+ needsNewPump = true;
+ }
+ }
+
+ // We need to check the queue again because the pump may have advanced ever since the last await
+ satisfyingSample = findQueuedSatisfyingSample();
+ if (satisfyingSample) {
+ this._setCurrentRaw(satisfyingSample);
+ return res.set(this._transformSample());
+ }
+
+ if (!this._pumpRunning) {
+ if (targetPacket.timestamp > lastTimestamp) {
+ // The pump decoded everything to the end and nothing satisfied the request; nothing ever will
+ this._setCurrentRaw(null);
+ return res.set(null);
+ }
+
+ // The sample at lastTimestamp has already been consumed, so it must be decoded again
+ needsNewPump = true;
+ }
+ } else {
+ // This is the first packet or we went backwards, create a new pump
+ needsNewPump = true;
+ }
+
+ if (needsNewPump && this._pumpRunning) {
+ await this._stopPump();
+ }
+
+ if (!this._pumpTarget || targetPacket.sequenceNumber > this._pumpTarget.sequenceNumber) {
+ this._pumpTarget = targetPacket;
+ }
+ this._lastTarget = targetPacket;
+
+ if (needsNewPump) {
+ // Set the cursor to the right spot
+ const result = this._packetCursor.seekToKey(targetPacket.timestamp);
+ if (isThenable(result)) await result;
+
+ // Start the new pump
+ void this._runPump();
+ }
+
+ this._ensureNotClosed();
+
+ // A request may only ever be registered when the sample queue is empty, otherwise the queued samples would
+ // never be reconsidered and could clog up the pump
+ assert(this._sampleQueue.length === 0);
+ // There must be a pump running if we reach this point. If there isn't, then the request would never be
+ // fulfilled, which would cause a hang.
+ assert(this._pumpRunning);
+
+ // Add the request to the queue
+ const request = promiseWithResolvers();
+ const pendingRequest: PendingRequest = {
+ timestamp: targetPacket.timestamp,
+ promise: request.promise,
+ resolve: request.resolve,
+ reject: request.reject,
+ successor: null,
+ };
+ this._pendingRequests.push(pendingRequest);
+ this._lastPendingRequest = pendingRequest;
+
+ this._pumpGate.open();
+ deferred.execute(); // Waiting for the return would be too long
+
+ return res.set(await request.promise);
+ }
+
+ private async _nextInternal(res: ResultValue): MaybeRelevantPromise {
+ using lock = this._mutex.lock();
+ if (lock.pending) await lock.ready;
+
+ this._ensureNotClosed();
+
+ if (this._nextIsFirst) {
+ // Easy, just seek to the first sample
+ // await is important so that the lock doesn't release too early
+ return await this._seekToPacket(
+ res,
+ this._packetReader.getFirstKey({ ...this._retrievalOptions, verifyKeyPackets: true }),
+ lock,
+ );
+ }
+
+ // See if the request can be satisfied using already-decoded samples
+ if (this._sampleQueue.length > 0) {
+ const nextSample = this._sampleQueue.shift()!;
+ this._pumpGate.open();
+
+ this._setCurrentRaw(nextSample);
+ return res.set(this._transformSample());
+ }
+
+ if (!this._pumpRunning) {
+ // No pump is running (but the cursor isn't closed), the pump must've reached the end
+ this._setCurrentRaw(null);
+ return res.set(null);
+ }
+
+ assert(this._lastPendingRequest);
+
+ this._ensureNotClosed();
+
+ // A request may only ever be registered when the sample queue is empty
+ assert(this._sampleQueue.length === 0);
+
+ // Instead of figuring out what the next presentation timestamp is (no easy way to do that), we simply add a
+ // new request that can be fulfilled by *any* timestamp. This way, whatever the decoder produces next (and the
+ // decoder is required to output samples in presentation order) is what we'll return.
+ const request = promiseWithResolvers();
+ const pendingRequest: PendingRequest = {
+ timestamp: -Infinity,
+ promise: request.promise,
+ resolve: request.resolve,
+ reject: request.reject,
+ successor: null,
+ };
+
+ if (this._pendingRequests.length === 0) {
+ this._pendingRequests.push(pendingRequest);
+ } else {
+ // The request will only get "unlocked" when the previous request is fulfilled
+ this._lastPendingRequest.successor = pendingRequest;
+ }
+ this._lastPendingRequest = pendingRequest;
+
+ this._pumpGate.open();
+ lock.release(); // Waiting for the return would be too long
+
+ return res.set(await request.promise);
+ }
+
+ private async _nextKeyInternal(res: ResultValue): MaybeRelevantPromise {
+ using lock = this._mutex.lock();
+ if (lock.pending) await lock.ready;
+
+ this._ensureNotClosed();
+
+ if (this._nextIsFirst) {
+ // await is important so that the lock doesn't release too early
+ return await this._seekToPacket(
+ res,
+ this._packetReader.getFirstKey({ ...this._retrievalOptions, verifyKeyPackets: true }),
+ lock,
+ );
+ }
+
+ let timestampToCheck: number;
+
+ const lastPendingRequest = last(this._pendingRequests);
+ if (lastPendingRequest && !lastPendingRequest.successor) {
+ timestampToCheck = lastPendingRequest.timestamp;
+ } else {
+ if (lastPendingRequest?.successor) {
+ let last = lastPendingRequest.successor;
+ while (last.successor) {
+ last = last.successor;
+ }
+
+ await last.promise
+ .then(() => {})
+ .catch(() => {});
+
+ this._ensureNotClosed();
+ }
+
+ if (this._currentSampleTimestamp !== null) {
+ timestampToCheck = this._currentSampleTimestamp;
+ } else {
+ // We're at the end
+ return res.set(null);
+ }
+ }
+
+ // The reason we don't just call readNextKey directly is as follows: readNextKey retrieves the next key in
+ // *decode* order, however we want the next key in *presentation* order. We know that at least the key frames
+ // are ascending in timestamp, so we first get the current key (based on a presentation-order search), then
+ // get the next key after that, which will be the answer we're looking for.
+
+ let key = this._packetReader.getKeyAt(timestampToCheck, { ...this._retrievalOptions, verifyKeyPackets: true });
+ if (isThenable(key)) key = await key;
+ assert(key); // Must be
+
+ let nextKey = this._packetReader.getNextKey(key, { ...this._retrievalOptions, verifyKeyPackets: true });
+ if (isThenable(nextKey)) nextKey = await nextKey;
+
+ if (!nextKey) {
+ this._setCurrentRaw(null);
+ return res.set(null);
+ }
+
+ return await this._seekToPacket(res, nextKey, lock);
+ }
+
+ private async _hasNextInternal(res: ResultValue): MaybeRelevantPromise {
+ using lock = this._mutex.lock();
+ if (lock.pending) await lock.ready;
+
+ this._ensureNotClosed();
+
+ if (this._nextIsFirst) {
+ let first = this._packetReader.getFirstKey({ ...this._retrievalOptions, verifyKeyPackets: true });
+ if (isThenable(first)) first = await first;
+
+ return res.set(!!first);
+ }
+
+ if (!this._pumpRunning) {
+ return res.set(false);
+ }
+
+ if (this._decoderFlushPromise) {
+ await this._decoderFlushPromise;
+ }
+
+ return res.set(this._sampleQueue.length > 0 || this._pumpRunning);
+ }
+
+ /**
+ * Starts the "pump process", which handles pushing packets into the decoder. It throttles itself if it is far
+ * enough ahead and must be woken up again by the outside. It also stops itself when the outside tells it to.
+ */
+ private async _runPump() {
+ assert(this._packetCursor.current);
+ assert(this._pumpTarget);
+ assert(this._decoder);
+
+ // Close whatever's left from the previous pump run (only relevant if the previous pump ended naturally, i.e.
+ // it wasn't stopped)
+ for (const sample of this._sampleQueue) {
+ sample.close();
+ }
+ this._sampleQueue.length = 0;
+
+ try {
+ this._pumpRunning = true;
+ this._currentSampleTimestamp = null;
+
+ if (this._debug.enabled) {
+ this._debug.pumpsStarted++;
+ }
+
+ // Yield once so that the caller can register its request before the first packet is decoded (some decoders
+ // are synchronous)
+ await Promise.resolve();
+
+ // Main loop
+ while (this._packetCursor.current) {
+ if (this._debug.enabled && this._debug.throwInPump) {
+ throw new Error('Fake pump error!');
+ }
+
+ const isAheadOfTarget = this._packetCursor.current.sequenceNumber > this._pumpTarget.sequenceNumber;
+ const nextRequestExists = this._pendingRequests.some(x => x.successor || x.timestamp === -Infinity);
+ if (isAheadOfTarget && !nextRequestExists) {
+ if (this._pumpStopQueued) {
+ break;
+ }
+
+ if (this._lazyPump > 0) {
+ await this._pumpGate.wait();
+ continue;
+ } else {
+ // We're eager! That means even if we're past the target, we'll keep decoding samples to
+ // prefill the sample queue to have samples ready. This is the common case when not batching
+ // commands.
+ }
+ }
+
+ const decodeQueueSize = this._decoder.getDecodeQueueSize();
+ if (
+ this._sampleQueue.length + decodeQueueSize >= 4
+ && !this._pumpStopQueued
+ && !(this._debug.enabled && this._debug.unthrottledPump)
+ ) {
+ await this._pumpGate.wait();
+ continue;
+ }
+
+ // Send the packet to the decoder
+ this._decoder.decode(this._packetCursor.current);
+
+ if (this._debug.enabled) {
+ this._debug.decodedPackets.push(this._packetCursor.current);
+ }
+
+ // Advance the cursor
+ const maybePromise = this._packetCursor.next();
+ if (isThenable(maybePromise)) await maybePromise;
+ }
+
+ if (!this._closed || this._pendingRequests.length > 0) {
+ const { promise, resolve } = promiseWithResolvers();
+ this._decoderFlushPromise = promise;
+
+ try {
+ await this._decoder.flush();
+ } finally {
+ resolve();
+ this._decoderFlushPromise = null;
+ }
+ } else {
+ // We're closed with no pending requests, don't bother flushing what's left
+ }
+
+ // Resolve whatever requests remain with null. The reason this is correct: assume there are still pending
+ // requests. Then the above flush() call ensured that all possible samples that we can get from the decoder
+ // we have received. If some of the pending requests are still unsatisfied after seeing all samples we
+ // decoded, then there is no other way for them to be solved but with null.
+ const resolveWithNull = (request: PendingRequest) => {
+ this._setCurrentRaw(null); // Note that this only runs if there exists at least one pending request
+
+ request.resolve(null);
+ if (request.successor) {
+ resolveWithNull(request.successor);
+ }
+ };
+ this._pendingRequests.forEach(resolveWithNull);
+ } catch (error) {
+ if (!this._decoder.closed && this._pendingRequests.length > 0) {
+ // The pump errored but the decoder is still fine, let's first flush the decoder before continuing
+ try {
+ await this._decoder.flush();
+ } catch {
+ // The original pump error is the interesting one; _closeWithError below settles the requests
+ }
+ }
+
+ this._pumpRunning = false; // So that close() doesn't attempt to stop the pump
+ void this._closeWithError(error);
+ } finally {
+ this._pendingRequests.length = 0;
+ this._lastPendingRequest = null;
+ this._pumpRunning = false;
+ this._pumpTarget = null;
+ this._pumpStopQueued = false;
+ this._lastTarget = null;
+
+ this._pumpStopped.open();
+
+ if (this._debug.enabled) {
+ this._debug.pumpEnded.open();
+ }
+ }
+ }
+
+ private async _stopPump() {
+ assert(this._pumpRunning);
+
+ this._pumpStopQueued = true;
+ this._pumpGate.open();
+
+ for (const sample of this._sampleQueue) {
+ sample.close();
+ }
+ this._sampleQueue.length = 0;
+
+ await this._pumpStopped.wait();
+ }
+
+ private async _closeInternal(doLock = true) {
+ this._lazyPump++;
+ this.track.input._openSampleCursors.delete(this);
+
+ let lock: AsyncMutexLock | null = null;
+ if (doLock) {
+ lock = this._mutex.lock();
+ if (lock.pending) await lock.ready;
+ }
+ using _ = defer(() => lock?.release());
+
+ this._closed = true;
+
+ if (this._pumpRunning) {
+ await this._stopPump();
+ }
+
+ for (const sample of this._sampleQueue) {
+ sample.close();
+ }
+ this._sampleQueue.length = 0;
+
+ this._setCurrentRaw(null);
+ this._decoder?.close();
+ this._decoder = null;
+ }
+
+ private _closeWithError(error: unknown, doLock?: boolean) {
+ if (this._closed) {
+ return;
+ }
+
+ this._closed = true;
+ this._error = error;
+ this._errorSet = true;
+
+ const rejectWithError = (request: PendingRequest) => {
+ request.reject(error);
+ if (request.successor) {
+ rejectWithError(request.successor);
+ }
+ };
+ this._pendingRequests.forEach(rejectWithError);
+
+ return this._closeInternal(doLock);
+ }
+
+ private _closeWithErrorAndThrow(error: unknown, doLock?: boolean): never {
+ void this._closeWithError(error, doLock);
+ throw error;
+ }
+
+ /** Ensures that the cursor is not currently closed. */
+ private _ensureNotClosed() {
+ if (this.closed) {
+ if (this._errorSet) {
+ throw this._error;
+ } else {
+ throw new Error('This cursor has been closed and can no longer be used.');
+ }
+ }
+ }
+
+ /** Ensures that the cursor is either open or will be open again at some point, even if it currently closed. */
+ private _ensureWillBeOpen() {
+ if (this._queuedResets > 0) {
+ return;
+ }
+
+ this._ensureNotClosed();
+ }
+}
+
+export type VideoSampleCursorOptions =
+ SampleCursorOptions & {
+ /**
+ * A hint that configures the hardware acceleration method of the decoder. This is best left on
+ * `'no-preference'`, the default.
+ */
+ hardwareAcceleration?: 'no-preference' | 'prefer-hardware' | 'prefer-software';
+ /**
+ * Hint that the selected decoder should be configured to minimize the number of packets that have to be decoded
+ * before video frames are output.
+ */
+ optimizeForLatency?: boolean;
+ };
+
+const validateVideoSampleCursorOptions = (
+ options: VideoSampleCursorOptions,
+) => {
+ validateSampleCursorOptions(options);
+
+ if (
+ options.hardwareAcceleration !== undefined
+ && !['no-preference', 'prefer-hardware', 'prefer-software'].includes(options.hardwareAcceleration)
+ ) {
+ throw new TypeError(
+ 'options.hardwareAcceleration, when provided, must be \'no-preference\', \'prefer-hardware\' or'
+ + ' \'prefer-software\'.',
+ );
+ }
+ if (options.optimizeForLatency !== undefined && typeof options.optimizeForLatency !== 'boolean') {
+ throw new TypeError('options.optimizeForLatency, when provided, must be a boolean.');
+ }
+};
+
+export class VideoSampleCursor extends SampleCursor {
+ override track!: InputVideoTrack;
+
+ private _options: VideoSampleCursorOptions;
+
+ constructor(
+ track: InputVideoTrack,
+ options: VideoSampleCursorOptions = {},
+ ) {
+ if (!(track instanceof InputVideoTrack)) {
+ throw new TypeError('track must be an InputVideoTrack.');
+ }
+ validateVideoSampleCursorOptions(options);
+
+ super(track, options);
+
+ this._options = options;
+ }
+
+ /** @internal */
+ override async _initDecoder(): Promise> {
+ if (!(await this.track.canDecode())) {
+ if (typeof VideoDecoder === 'undefined') {
+ throw new Error(missingWebCodecsClassMessage('VideoDecoder'));
+ }
+
+ throw new Error(
+ 'This video track cannot be decoded in this environment. Make sure to check decodability before using'
+ + ' a track.',
+ );
+ }
+
+ if (this._debug.enabled && this._debug.throwInDecoderInit) {
+ throw new Error('Fake decoder init error!');
+ }
+
+ const codec = await this.track.getCodec();
+ const rotation = await this.track.getRotation();
+ const flip = await this.track.getFlip();
+ let decoderConfig = await this.track.getDecoderConfig();
+ const timeResolution = await this.track.getTimeResolution();
+ assert(codec && decoderConfig);
+
+ decoderConfig = {
+ ...decoderConfig,
+ hardwareAcceleration: this._options.hardwareAcceleration,
+ optimizeForLatency: this._options.optimizeForLatency,
+ };
+
+ const decoder = new VideoDecoderWrapper(
+ sample => this._onDecoderSample(sample),
+ error => this._onDecoderError(error),
+ codec,
+ decoderConfig,
+ rotation,
+ flip,
+ timeResolution,
+ );
+
+ decoder.onDequeue = () => this._onDecoderDequeue();
+
+ return decoder;
+ }
+}
+
+export type AudioSampleCursorOptions =
+ SampleCursorOptions;
+
+const validateAudioSampleCursorOptions = (
+ options: AudioSampleCursorOptions,
+) => {
+ validateSampleCursorOptions(options);
+};
+
+export class AudioSampleCursor extends SampleCursor {
+ override track!: InputAudioTrack;
+
+ constructor(
+ track: InputAudioTrack,
+ options: AudioSampleCursorOptions = {},
+ ) {
+ if (!(track instanceof InputAudioTrack)) {
+ throw new TypeError('track must be an InputAudioTrack.');
+ }
+ validateAudioSampleCursorOptions(options);
+
+ super(track, options);
+ }
+
+ /** @internal */
+ override async _initDecoder(): Promise> {
+ if (!(await this.track.canDecode())) {
+ if (typeof AudioDecoder === 'undefined') {
+ throw new Error(missingWebCodecsClassMessage('AudioDecoder'));
+ }
+
+ throw new Error(
+ 'This audio track cannot be decoded in this environment. Make sure to check decodability before using'
+ + ' a track.',
+ );
+ }
+
+ if (this._debug.enabled && this._debug.throwInDecoderInit) {
+ throw new Error('Fake decoder init error!');
+ }
+
+ const codec = await this.track.getCodec();
+ const decoderConfig = await this.track.getDecoderConfig();
+ assert(codec && decoderConfig);
+
+ let decoder: AudioDecoderWrapper | PcmAudioDecoderWrapper;
+ if ((PCM_AUDIO_CODECS as readonly string[]).includes(decoderConfig.codec)) {
+ decoder = new PcmAudioDecoderWrapper(
+ sample => this._onDecoderSample(sample),
+ error => this._onDecoderError(error),
+ decoderConfig,
+ );
+ } else {
+ decoder = new AudioDecoderWrapper(
+ sample => this._onDecoderSample(sample),
+ error => this._onDecoderError(error),
+ codec,
+ decoderConfig,
+ );
+ }
+
+ decoder.onDequeue = () => this._onDecoderDequeue();
+
+ return decoder;
+ }
+}
+
+/**
+ * A canvas with additional timing information (timestamp & duration).
+ * @public
+ */
+export class WrappedCanvas {
+ /** A canvas element or offscreen canvas. */
+ canvas: HTMLCanvasElement | OffscreenCanvas;
+ /** The timestamp of the corresponding video sample, in seconds. */
+ timestamp: number;
+ /** The duration of the corresponding video sample, in seconds. */
+ duration: number;
+
+ constructor(canvas: HTMLCanvasElement | OffscreenCanvas, timestamp: number, duration: number) {
+ this.canvas = canvas;
+ this.timestamp = timestamp;
+ this.duration = duration;
+ }
+};
+
+/**
+ * Options for constructing a canvas transformer to be used with {@link VideoSampleCursor}.
+ * @public
+ */
+export type CanvasTransformerOptions = {
+ /**
+ * Whether the output canvases should have transparency instead of a black background. Defaults to `false`. Set
+ * this to `true` when reading transparent videos.
+ */
+ alpha?: boolean;
+ /**
+ * The width of the output canvas in pixels, defaulting to the display width of the video track. If height is not
+ * set, it will be deduced automatically based on aspect ratio.
+ */
+ width?: number;
+ /**
+ * The height of the output canvas in pixels, defaulting to the display height of the video track. If width is not
+ * set, it will be deduced automatically based on aspect ratio.
+ */
+ height?: number;
+ /**
+ * The fitting algorithm in case both width and height are set.
+ *
+ * - `'fill'` will stretch the image to fill the entire box, potentially altering aspect ratio.
+ * - `'contain'` will contain the entire image within the box while preserving aspect ratio. This may lead to
+ * letterboxing.
+ * - `'cover'` will scale the image until the entire box is filled, while preserving aspect ratio.
+ */
+ fit?: 'fill' | 'contain' | 'cover';
+ /**
+ * The clockwise rotation by which to rotate the raw video frame. Defaults to the rotation set in the file metadata.
+ * Rotation is applied before flipping.
+ */
+ rotation?: Rotation;
+ /**
+ * Whether to flip the raw video frame horizontally (about the vertical axis). Defaults to the flip set in the file
+ * metadata. The flip is applied after rotation but before cropping and resizing.
+ */
+ flip?: boolean;
+ /**
+ * Specifies the rectangular region of the input video to crop to. The crop region will automatically be clamped to
+ * the dimensions of the input video track. Cropping is performed after rotation and flip but before resizing. The
+ * crop region is in the _display pixel space_ of the underlying video data.
+ */
+ crop?: CropRectangle;
+ /**
+ * When set, specifies the number of canvases in the pool. These canvases will be reused in a ring buffer /
+ * round-robin type fashion. This keeps the amount of allocated VRAM constant and relieves the browser from
+ * constantly allocating/deallocating canvases. A pool size of 0 or `undefined` disables the pool and means a new
+ * canvas is created each time.
+ */
+ poolSize?: number;
+};
+
+export const canvasTransformer = (
+ options: CanvasTransformerOptions = {},
+): SampleTransformer => {
+ if (options && typeof options !== 'object') {
+ throw new TypeError('options must be an object.');
+ }
+ if (options.alpha !== undefined && typeof options.alpha !== 'boolean') {
+ throw new TypeError('options.alpha, when provided, must be a boolean.');
+ }
+ if (options.width !== undefined && (!Number.isInteger(options.width) || options.width <= 0)) {
+ throw new TypeError('options.width, when defined, must be a positive integer.');
+ }
+ if (options.height !== undefined && (!Number.isInteger(options.height) || options.height <= 0)) {
+ throw new TypeError('options.height, when defined, must be a positive integer.');
+ }
+ if (options.fit !== undefined && !['fill', 'contain', 'cover'].includes(options.fit)) {
+ throw new TypeError('options.fit, when provided, must be one of "fill", "contain", or "cover".');
+ }
+ if (
+ options.width !== undefined
+ && options.height !== undefined
+ && options.fit === undefined
+ ) {
+ throw new TypeError(
+ 'When both options.width and options.height are provided, options.fit must also be provided.',
+ );
+ }
+ if (options.rotation !== undefined && ![0, 90, 180, 270].includes(options.rotation)) {
+ throw new TypeError('options.rotation, when provided, must be 0, 90, 180 or 270.');
+ }
+ if (options.flip !== undefined && typeof options.flip !== 'boolean') {
+ throw new TypeError('options.flip, when provided, must be a boolean.');
+ }
+ if (options.crop !== undefined) {
+ validateCropRectangle(options.crop, 'options.');
+ }
+ if (
+ options.poolSize !== undefined
+ && (typeof options.poolSize !== 'number' || !Number.isInteger(options.poolSize) || options.poolSize < 0)
+ ) {
+ throw new TypeError('poolSize must be a non-negative integer.');
+ }
+
+ let needsSetup = true;
+ let alpha: boolean;
+ let width: number;
+ let height: number;
+ let fit: 'fill' | 'contain' | 'cover';
+ let rotation: Rotation;
+ let flip: boolean;
+ let crop: { left: number; top: number; width: number; height: number } | undefined;
+ let canvasPool: (HTMLCanvasElement | OffscreenCanvas | null)[];
+ let nextCanvasIndex = 0;
+
+ return (sample) => {
+ if (needsSetup) {
+ rotation = options.rotation ?? sample.rotation;
+ flip = options.flip ?? sample.flip;
+
+ const [rotatedWidth, rotatedHeight] = rotation % 180 === 0
+ ? [sample.squarePixelWidth, sample.squarePixelHeight]
+ : [sample.squarePixelHeight, sample.squarePixelWidth];
+
+ crop = options.crop;
+ if (crop) {
+ crop = clampCropRectangle(crop, rotatedWidth, rotatedHeight);
+ }
+
+ [width, height] = crop
+ ? [crop.width, crop.height]
+ : [rotatedWidth, rotatedHeight];
+ const originalAspectRatio = width / height;
+
+ // If width and height aren't defined together, deduce the missing value using the aspect ratio
+ if (options.width !== undefined && options.height === undefined) {
+ width = options.width;
+ height = Math.round(width / originalAspectRatio);
+ } else if (options.width === undefined && options.height !== undefined) {
+ height = options.height;
+ width = Math.round(height * originalAspectRatio);
+ } else if (options.width !== undefined && options.height !== undefined) {
+ width = options.width;
+ height = options.height;
+ }
+
+ alpha = options.alpha ?? false;
+ fit = options.fit ?? 'fill';
+ canvasPool = Array.from({ length: options.poolSize ?? 0 }, () => null);
+ needsSetup = false;
+ }
+
+ let canvas = canvasPool[nextCanvasIndex];
+ let canvasIsNew = false;
+
+ if (!canvas) {
+ if (typeof document !== 'undefined') {
+ // Prefer an HTMLCanvasElement
+ canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ } else {
+ canvas = new OffscreenCanvas(width, height);
+ }
+
+ if (canvasPool.length > 0) {
+ canvasPool[nextCanvasIndex] = canvas;
+ }
+
+ canvasIsNew = true;
+ }
+
+ if (canvasPool.length > 0) {
+ nextCanvasIndex = (nextCanvasIndex + 1) % canvasPool.length;
+ }
+
+ const context = canvas.getContext('2d', {
+ alpha: alpha || isFirefox(), // Firefox has VideoFrame glitches with opaque canvases
+ }) as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
+ assert(context);
+
+ sample._drawWithFitAndMipmapping(canvas, context, {
+ fit,
+ rotation,
+ flip,
+ crop,
+ targetIsFresh: canvasIsNew,
+ fillBlack: !alpha && isFirefox(),
+ });
+ sample.close();
+
+ return new WrappedCanvas(canvas, sample.timestamp, sample.duration);
+ };
+};
diff --git a/src/decode.ts b/src/decode.ts
index 62f5c5e72..ea60b07e3 100644
--- a/src/decode.ts
+++ b/src/decode.ts
@@ -15,12 +15,1333 @@ import {
guessDescriptionForVideo,
inferCodecFromCodecString,
MediaCodec,
+ parsePcmCodec,
PCM_AUDIO_CODECS,
+ PcmAudioCodec,
VIDEO_CODECS,
VideoCodec,
} from './codec';
-import { customAudioDecoders, customVideoDecoders } from './custom-coder';
-import { isAllowSharedBufferSource, SetOptional } from './misc';
+import {
+ addAvcBitstreamRestriction,
+ AvcNalUnitType,
+ concatAvcNalUnits,
+ deserializeAvcDecoderConfigurationRecord,
+ determineVideoPacketType,
+ extractNalUnitTypeForAvc,
+ extractNalUnitTypeForHevc,
+ HevcNalUnitType,
+ iterateAvcNalUnits,
+ iterateHevcNalUnits,
+ parseAvcSps,
+ sanitizeHevcPacketForChromium,
+ serializeAvcDecoderConfigurationRecord,
+} from './codec-data';
+import { CustomAudioDecoder, customAudioDecoders, CustomVideoDecoder, customVideoDecoders } from './custom-coder';
+import {
+ assert,
+ assertNever,
+ clamp,
+ colorSpaceIsComplete,
+ getInt24,
+ getUint24,
+ insertSorted,
+ isAllowSharedBufferSource,
+ isChromium,
+ isWebKit,
+ last,
+ NaiveCallSerializer,
+ promiseWithResolvers,
+ removeItem,
+ Rotation,
+ SetOptional,
+ toDataView,
+ toUint8Array,
+} from './misc';
+import { EncodedPacket } from './packet';
+import { fromAlaw, fromUlaw } from './pcm';
+import { AudioSample, VideoSample, VideoSamplePixelFormat } from './sample';
+
+export abstract class DecoderWrapper<
+ MediaSample extends VideoSample | AudioSample,
+> {
+ constructor(
+ public onSample: (sample: MediaSample) => unknown,
+ public onError: (error: unknown) => unknown,
+ ) {}
+
+ abstract getDecodeQueueSize(): number;
+ abstract decode(packet: EncodedPacket): void;
+ abstract flush(): Promise;
+ abstract close(): void;
+
+ abstract get closed(): boolean;
+}
+
+export class VideoDecoderWrapper extends DecoderWrapper {
+ decoder: VideoDecoder | null = null;
+
+ customDecoder: CustomVideoDecoder | null = null;
+ customDecoderCallSerializer = new NaiveCallSerializer();
+ customDecoderQueueSize = 0;
+ customDecoderClosed = false;
+
+ inputTimestamps: number[] = []; // Timestamps input into the decoder, sorted.
+ frameQueue: VideoFrame[] = []; // Safari-specific thing, check usage.
+ currentPacketIndex = 0;
+ raslSkipped = false; // For HEVC stuff
+
+ // Alpha stuff
+ alphaDecoder: VideoDecoder | null = null;
+ alphaHadKeyframe = false;
+ colorQueue: VideoFrame[] = [];
+ alphaQueue: (VideoFrame | null)[] = [];
+ merger: ColorAlphaMerger | null = null;
+ decodedAlphaChunkCount = 0;
+ alphaDecoderQueueSize = 0;
+ /** Each value is the number of decoded alpha chunks at which a null alpha frame should be added. */
+ nullAlphaFrameQueue: number[] = [];
+ currentAlphaPacketIndex = 0;
+ alphaRaslSkipped = false; // For HEVC stuff
+ finalFrames: { frame: VideoFrame | null }[] = [];
+ mergeAlphaPromises: Promise[] = [];
+
+ onDequeue: (() => unknown) | null = null;
+
+ constructor(
+ onSample: (sample: VideoSample) => unknown,
+ onError: (error: unknown) => unknown,
+ public codec: VideoCodec,
+ public decoderConfig: VideoDecoderConfig,
+ public rotation: Rotation,
+ public flip: boolean,
+ public timeResolution: number,
+ ) {
+ super(onSample, onError);
+
+ const MatchingCustomDecoder = customVideoDecoders.find(x => x.supports(codec, decoderConfig));
+ if (MatchingCustomDecoder) {
+ // @ts-expect-error "Can't create instance of abstract class 🤓"
+ this.customDecoder = new MatchingCustomDecoder() as CustomVideoDecoder;
+ // @ts-expect-error It's technically readonly
+ this.customDecoder.codec = codec;
+ // @ts-expect-error It's technically readonly
+ this.customDecoder.config = decoderConfig;
+ // @ts-expect-error It's technically readonly
+ this.customDecoder.onSample = (sample) => {
+ if (!(sample instanceof VideoSample)) {
+ throw new TypeError('The argument passed to onSample must be a VideoSample.');
+ }
+
+ // @ts-expect-error Readonly
+ sample.rotation = this.rotation;
+ // @ts-expect-error Readonly
+ sample.flip = this.flip;
+
+ this.onSample(sample);
+ };
+ // @ts-expect-error It's technically readonly
+ this.customDecoder.onError = (error) => {
+ onError(error);
+ };
+
+ void this.customDecoderCallSerializer
+ .call(() => this.customDecoder!.init())
+ .catch(error => onError(error));
+ } else {
+ const colorHandler = (frame: VideoFrame) => {
+ if (this.alphaQueue.length > 0) {
+ // Even when no alpha data is present (most of the time), there will be nulls in this queue
+ const alphaFrame = this.alphaQueue.shift();
+ assert(alphaFrame !== undefined);
+
+ void this.mergeAlpha(frame, alphaFrame);
+ } else {
+ this.colorQueue.push(frame);
+ }
+ };
+
+ if (isChromium()) {
+ if (codec === 'avc' && this.decoderConfig.description) {
+ const record = deserializeAvcDecoderConfigurationRecord(
+ toUint8Array(this.decoderConfig.description),
+ );
+ if (record && record.sequenceParameterSets.length > 0) {
+ const sps = parseAvcSps(record.sequenceParameterSets[0]!);
+ if (sps) {
+ if (sps.frameMbsOnlyFlag === 0) {
+ // Chromium has/had a bug with playing interlaced AVC
+ // (https://issues.chromium.org/issues/456919096) which can be worked around by
+ // requesting that software decoding be used. So, here we peek into the AVC description,
+ // if present, and switch to software decoding if we find interlaced content.
+ this.decoderConfig = {
+ ...this.decoderConfig,
+ hardwareAcceleration: 'prefer-software',
+ };
+ }
+
+ if (sps.maxDecFrameBuffering !== 0 && sps.bitstreamRestrictionFlag !== 1) {
+ // Modify the SPS to fix potential loss of B frames
+ record.sequenceParameterSets[0] = addAvcBitstreamRestriction(sps);
+ this.decoderConfig = {
+ ...this.decoderConfig,
+ description: serializeAvcDecoderConfigurationRecord(record),
+ };
+ }
+ }
+ }
+ }
+
+ if (!colorSpaceIsComplete(this.decoderConfig.colorSpace)) {
+ // Found via https://github.com/remotion-dev/remotion/issues/10841.
+ // If the color space is incomplete (which is often that it's just partially filled), Chromium has
+ // some nasty logic where it doesn't pass that information along to the GPU at all. The result is
+ // that information is genuinely lost, like the color matrix for example. Chromium has other code
+ // paths where it just fills the missing values with a hardcoded default, so we do the exact same
+ // thing here, with the same hardcoded defaults:
+ this.decoderConfig = {
+ ...this.decoderConfig,
+ colorSpace: {
+ primaries: this.decoderConfig.colorSpace?.primaries ?? 'bt709',
+ matrix: this.decoderConfig.colorSpace?.matrix ?? 'bt709',
+ transfer: this.decoderConfig.colorSpace?.transfer ?? 'bt709',
+ fullRange: this.decoderConfig.colorSpace?.fullRange ?? false,
+ },
+ };
+ }
+ }
+
+ const stack = new Error('Decoding error').stack;
+
+ this.decoder = new VideoDecoder({
+ output: (frame) => {
+ try {
+ colorHandler(frame);
+ } catch (error) {
+ this.onError(error);
+ }
+ },
+ error: (error) => {
+ error.stack = stack; // Provide a more useful stack trace, the default one sucks
+ this.onError(error);
+ },
+ });
+ this.decoder.configure(this.decoderConfig);
+
+ this.decoder.addEventListener('dequeue', () => {
+ this.onDequeue?.();
+ });
+ }
+ }
+
+ getDecodeQueueSize() {
+ if (this.customDecoder) {
+ return this.customDecoderQueueSize;
+ } else {
+ assert(this.decoder);
+
+ let result = Math.max(
+ this.decoder.decodeQueueSize,
+ this.alphaDecoder?.decodeQueueSize ?? 0,
+ );
+
+ // Frames in an in-flight alpha merge must also count towards the queue size
+ result += this.finalFrames.length;
+
+ return result;
+ }
+ }
+
+ decode(packet: EncodedPacket) {
+ assert(!packet.isMetadataOnly);
+
+ if (this.codec === 'hevc' && this.currentPacketIndex > 0 && !this.raslSkipped) {
+ if (this.hasHevcRaslPicture(packet.data)) {
+ return; // Drop
+ }
+
+ this.raslSkipped = true;
+ }
+
+ if (this.customDecoder) {
+ this.customDecoderQueueSize++;
+ void this.customDecoderCallSerializer
+ .call(() => this.customDecoder!.decode(packet))
+ .catch(error => this.onError(error))
+ .finally(() => {
+ this.customDecoderQueueSize--;
+ this.onDequeue?.();
+ });
+ } else {
+ assert(this.decoder);
+
+ if (!isWebKit()) {
+ insertSorted(this.inputTimestamps, packet.timestamp, x => x);
+ }
+
+ if (isChromium() && this.currentPacketIndex === 0) {
+ if (this.codec === 'avc') {
+ // Workaround for https://issues.chromium.org/issues/470109459
+ const filteredNalUnits: Uint8Array[] = [];
+ let hasFrameData = false;
+
+ for (const loc of iterateAvcNalUnits(packet.data, this.decoderConfig)) {
+ const type = extractNalUnitTypeForAvc(packet.data[loc.offset]!);
+ hasFrameData ||= type >= 1 && type <= 5;
+
+ if (type === AvcNalUnitType.AUD) {
+ if (hasFrameData) {
+ // Already has actual frame data, so treat an AUD as simply the end of the packet
+ break;
+ } else {
+ // If packets contain an AUD and have NALUs before it, this trips up Chromium's key
+ // frame detector. Clear the NALUs if an AUD is encountered.
+ // https://github.com/Vanilagy/mediabunny/issues/396
+ filteredNalUnits.length = 0;
+ }
+ }
+
+ // These trip up Chromium's key frame detection, so let's strip them
+ if (!(type >= 20 && type <= 31)) {
+ filteredNalUnits.push(packet.data.subarray(loc.offset, loc.offset + loc.length));
+ }
+ }
+
+ if (!this.decoderConfig.description) {
+ // Do SPS fixups if necessary
+ for (let i = 0; i < filteredNalUnits.length; i++) {
+ const nalUnit = filteredNalUnits[i]!;
+ if (extractNalUnitTypeForAvc(nalUnit[0]!) !== AvcNalUnitType.SPS) {
+ continue;
+ }
+
+ const sps = parseAvcSps(nalUnit);
+ if (sps && sps.maxDecFrameBuffering !== 0 && sps.bitstreamRestrictionFlag !== 1) {
+ filteredNalUnits[i] = addAvcBitstreamRestriction(sps);
+ }
+
+ break;
+ }
+ }
+
+ const newData = concatAvcNalUnits(filteredNalUnits, this.decoderConfig);
+ packet = new EncodedPacket(newData, packet.type, packet.timestamp, packet.duration);
+ } else if (this.codec === 'hevc') {
+ // Workaround for https://issues.chromium.org/issues/507611247
+ const sanitizedData = sanitizeHevcPacketForChromium(packet.data, this.decoderConfig);
+ if (sanitizedData) {
+ packet = new EncodedPacket(sanitizedData, packet.type, packet.timestamp, packet.duration);
+ }
+ }
+ }
+
+ this.decoder.decode(packet.toEncodedVideoChunk());
+ this.decodeAlphaData(packet);
+ }
+
+ this.currentPacketIndex++;
+ }
+
+ decodeAlphaData(packet: EncodedPacket) {
+ if (!packet.sideData.alpha) {
+ // No alpha side data in the packet, most common case
+ this.pushNullAlphaFrame();
+ return;
+ }
+
+ if (!this.merger) {
+ this.merger = new ColorAlphaMerger();
+ }
+
+ // Check if we need to set up the alpha decoder
+ if (!this.alphaDecoder) {
+ const alphaHandler = (frame: VideoFrame) => {
+ if (this.colorQueue.length > 0) {
+ const colorFrame = this.colorQueue.shift();
+ assert(colorFrame !== undefined);
+
+ void this.mergeAlpha(colorFrame, frame);
+ } else {
+ this.alphaQueue.push(frame);
+ }
+
+ // Check if any null frames have been queued for this point
+ this.decodedAlphaChunkCount++;
+ while (
+ this.nullAlphaFrameQueue.length > 0
+ && this.nullAlphaFrameQueue[0] === this.decodedAlphaChunkCount
+ ) {
+ this.nullAlphaFrameQueue.shift();
+
+ if (this.colorQueue.length > 0) {
+ const colorFrame = this.colorQueue.shift();
+ assert(colorFrame !== undefined);
+
+ void this.mergeAlpha(colorFrame, null);
+ } else {
+ this.alphaQueue.push(null);
+ }
+ }
+
+ this.alphaDecoderQueueSize--;
+ };
+
+ const stack = new Error('Decoding error').stack;
+
+ this.alphaDecoder = new VideoDecoder({
+ output: (frame) => {
+ try {
+ alphaHandler(frame);
+ } catch (error) {
+ this.onError(error);
+ }
+ },
+ error: (error) => {
+ error.stack = stack; // Provide a more useful stack trace, the default one sucks
+ this.onError(error);
+ },
+ });
+ this.alphaDecoder.configure({
+ ...this.decoderConfig,
+ // Alpha is always full range, regardless of what the color track says. The decoder only honors the
+ // override if all fields are set tho.
+ colorSpace: {
+ fullRange: true,
+ // These fields are irrelevant:
+ matrix: 'bt709',
+ primaries: 'bt709',
+ transfer: 'bt709',
+ },
+ });
+
+ this.alphaDecoder.addEventListener('dequeue', () => {
+ this.onDequeue?.();
+ });
+ }
+
+ const type = determineVideoPacketType(this.codec, this.decoderConfig, packet.sideData.alpha);
+
+ // Alpha packets might follow a different key frame rhythm than the main packets. Therefore, before we start
+ // decoding, we must first find a packet that's actually a key frame. Until then, we treat the image as opaque.
+ if (!this.alphaHadKeyframe) {
+ this.alphaHadKeyframe = type === 'key';
+ }
+
+ if (this.alphaHadKeyframe) {
+ // Same RASL skipping logic as for color, unlikely to be hit (since who uses HEVC with separate alpha??) but
+ // here for symmetry.
+ if (this.codec === 'hevc' && this.currentAlphaPacketIndex > 0 && !this.alphaRaslSkipped) {
+ if (this.hasHevcRaslPicture(packet.sideData.alpha)) {
+ this.pushNullAlphaFrame();
+ return;
+ }
+
+ this.alphaRaslSkipped = true;
+ }
+
+ this.currentAlphaPacketIndex++;
+ this.alphaDecoder.decode(packet.alphaToEncodedVideoChunk(type ?? packet.type));
+ this.alphaDecoderQueueSize++;
+ } else {
+ this.pushNullAlphaFrame();
+ }
+ }
+
+ pushNullAlphaFrame() {
+ if (this.alphaDecoderQueueSize === 0) {
+ // Easy
+ this.alphaQueue.push(null);
+ } else {
+ // There are still alpha chunks being decoded, so pushing `null` immediately would result in out-of-order
+ // data and be incorrect. Instead, we need to enqueue a "null frame" for when the current decoder workload
+ // has finished.
+ this.nullAlphaFrameQueue.push(this.decodedAlphaChunkCount + this.alphaDecoderQueueSize);
+ }
+ }
+
+ /**
+ * If we're using HEVC, we need to make sure to skip any RASL slices that follow a non-IDR key frame such as
+ * CRA_NUT. This is because RASL slices cannot be decoded without data before the CRA_NUT. Browsers behave
+ * differently here: Chromium drops the packets, Safari throws a decoder error. Either way, it's not good
+ * and causes bugs upstream. So, let's take the dropping into our own hands.
+ */
+ hasHevcRaslPicture(packetData: Uint8Array) {
+ for (const loc of iterateHevcNalUnits(packetData, this.decoderConfig)) {
+ const type = extractNalUnitTypeForHevc(packetData[loc.offset]!);
+ if (type === HevcNalUnitType.RASL_N || type === HevcNalUnitType.RASL_R) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /** Handler for the WebCodecs VideoDecoder for ironing out browser differences. */
+ frameHandler(frame: VideoFrame) {
+ if (isWebKit()) {
+ // For correct B-frame handling, we don't just hand over the frames directly but instead add them to
+ // a queue, because we want to ensure frames are emitted in presentation order. We flush the queue
+ // each time we receive a frame with a timestamp larger than the highest we've seen so far, as we
+ // can sure that is not a B-frame. Typically, WebCodecs automatically guarantees that frames are
+ // emitted in presentation order, but Safari doesn't always follow this rule.
+ if (this.frameQueue.length > 0 && (frame.timestamp >= last(this.frameQueue)!.timestamp)) {
+ for (const frame of this.frameQueue) {
+ this.finalizeAndEmitSample(frame);
+ }
+
+ this.frameQueue.length = 0;
+ }
+
+ insertSorted(this.frameQueue, frame, x => x.timestamp);
+ } else {
+ // Assign it the next earliest timestamp from the input. We do this because browsers, by spec, are
+ // required to emit decoded frames in presentation order *while* retaining the timestamp of their
+ // originating EncodedVideoChunk. For files with B-frames but no out-of-order timestamps (like a
+ // missing ctts box, for example), this causes a mismatch. We therefore fix the timestamps and
+ // ensure they are sorted by doing this.
+ const timestamp = this.inputTimestamps.shift();
+
+ // There's no way we'd have more decoded frames than encoded packets we passed in. Actually, the
+ // correspondence should be 1:1.
+ assert(timestamp !== undefined);
+ this.finalizeAndEmitSample(frame, timestamp);
+ }
+ }
+
+ finalizeAndEmitSample(frame: VideoFrame, timestampOverride?: number) {
+ const sample = new VideoSample(frame, {
+ // Round the timestamps to the time resolution
+ timestamp: Math.round(
+ (timestampOverride ?? (frame.timestamp / 1e6)) * this.timeResolution,
+ ) / this.timeResolution,
+ duration: Math.round(
+ (frame.duration ?? 0) / 1e6 * this.timeResolution,
+ ) / this.timeResolution,
+ rotation: this.rotation,
+ flip: this.flip,
+ });
+
+ this.onSample(sample);
+ }
+
+ async mergeAlpha(color: VideoFrame, alpha: VideoFrame | null) {
+ const resolver = promiseWithResolvers();
+ this.mergeAlphaPromises.push(resolver.promise);
+
+ // Alpha merging is concurrent but the frames must still be emitted in the same order in which the merging
+ // began. Therefore, serialize the results in an array.
+ const result: { frame: VideoFrame | null } = { frame: null };
+ this.finalFrames.push(result);
+
+ let finalFrameRemoved = false;
+
+ try {
+ if (!alpha) {
+ // Nothing needs to be merged
+ result.frame = color;
+ } else {
+ assert(this.merger);
+
+ // The merger takes ownership of the frames, so no need to close them ourselves
+ result.frame = await this.merger.merge(color, alpha);
+ }
+
+ // Emit any leading frames that are ready, preserving input order
+ while (this.finalFrames.length > 0 && this.finalFrames[0]!.frame !== null) {
+ const next = this.finalFrames.shift()!;
+ finalFrameRemoved = true;
+ this.frameHandler(next.frame!);
+ }
+ } catch (error) {
+ removeItem(this.finalFrames, result);
+ finalFrameRemoved = true;
+ this.onError(error);
+ } finally {
+ removeItem(this.mergeAlphaPromises, resolver.promise);
+ resolver.resolve();
+
+ if (finalFrameRemoved) {
+ // Since final frames affect the decode queue size
+ this.onDequeue?.();
+ }
+ }
+ }
+
+ async flush() {
+ if (this.customDecoder) {
+ await this.customDecoderCallSerializer.call(() => this.customDecoder!.flush());
+ } else {
+ assert(this.decoder);
+ await Promise.all([
+ this.decoder.flush(),
+ this.alphaDecoder?.flush(),
+ ]);
+ await Promise.all(this.mergeAlphaPromises);
+
+ this.colorQueue.forEach(x => x.close());
+ this.colorQueue.length = 0;
+ this.alphaQueue.forEach(x => x?.close());
+ this.alphaQueue.length = 0;
+
+ this.alphaHadKeyframe = false;
+ this.decodedAlphaChunkCount = 0;
+ this.alphaDecoderQueueSize = 0;
+ this.nullAlphaFrameQueue.length = 0;
+ this.currentAlphaPacketIndex = 0;
+ this.alphaRaslSkipped = false;
+ }
+
+ if (isWebKit()) {
+ for (const sample of this.frameQueue) {
+ this.finalizeAndEmitSample(sample);
+ }
+
+ this.frameQueue.length = 0;
+ }
+
+ this.currentPacketIndex = 0;
+ this.raslSkipped = false;
+ }
+
+ close() {
+ if (this.customDecoder) {
+ if (!this.customDecoderClosed) {
+ this.customDecoderClosed = true;
+ void this.customDecoderCallSerializer.call(() => this.customDecoder!.close());
+ }
+ } else {
+ assert(this.decoder);
+
+ if (this.decoder.state !== 'closed') {
+ this.decoder.close();
+ }
+ if (this.alphaDecoder && this.alphaDecoder.state !== 'closed') {
+ this.alphaDecoder.close();
+ }
+
+ this.colorQueue.forEach(x => x.close());
+ this.colorQueue.length = 0;
+ this.alphaQueue.forEach(x => x?.close());
+ this.alphaQueue.length = 0;
+
+ this.merger?.close();
+ }
+
+ for (const sample of this.frameQueue) {
+ sample.close();
+ }
+ this.frameQueue.length = 0;
+ }
+
+ get closed() {
+ if (this.customDecoder) {
+ if (this.customDecoderClosed) {
+ return true;
+ }
+
+ return !this.customDecoderCallSerializer.errored;
+ } else {
+ assert(this.decoder);
+ return this.decoder.state === 'closed';
+ }
+ }
+}
+
+let mergerWorkerUrl: string | null = null;
+
+/** Utility class that merges together color and alpha information on the CPU in a pool of workers. */
+class ColorAlphaMerger {
+ private workers: Worker[] = [];
+ private nextWorkerIndex = 0;
+ private pendingRequests = new Map>>();
+ private nextRequestId = 0;
+
+ merge(color: VideoFrame, alpha: VideoFrame): Promise {
+ if (this.workers.length === 0) {
+ if (!mergerWorkerUrl) {
+ const blob = new Blob(
+ [`(${colorAlphaMergerWorkerCode.toString()})()`],
+ { type: 'application/javascript' },
+ );
+ mergerWorkerUrl = URL.createObjectURL(blob);
+ }
+
+ const poolSize = clamp(navigator.hardwareConcurrency, 1, 4);
+ for (let i = 0; i < poolSize; i++) {
+ const worker = new Worker(mergerWorkerUrl);
+
+ worker.addEventListener('message', (event: MessageEvent) => {
+ const data = event.data;
+ const pending = this.pendingRequests.get(data.id);
+ if (!pending) {
+ return;
+ }
+ this.pendingRequests.delete(data.id);
+
+ if ('error' in data) {
+ pending.reject(new Error(data.error));
+ } else {
+ pending.resolve(data.frame);
+ }
+ });
+
+ worker.addEventListener('error', (event) => {
+ const error = new Error(event.message || 'Color/alpha merge worker error.');
+ for (const pending of this.pendingRequests.values()) {
+ pending.reject(error);
+ }
+ this.pendingRequests.clear();
+ });
+
+ this.workers.push(worker);
+ }
+ }
+
+ const id = this.nextRequestId++;
+ const pending = promiseWithResolvers();
+ this.pendingRequests.set(id, pending);
+
+ // Hand the job to the next worker in round-robin fashion
+ const worker = this.workers[this.nextWorkerIndex]!;
+ this.nextWorkerIndex = (this.nextWorkerIndex + 1) % this.workers.length;
+ worker.postMessage({ id, color, alpha }, { transfer: [color, alpha] });
+
+ return pending.promise;
+ }
+
+ close() {
+ for (const worker of this.workers) {
+ worker.terminate();
+ }
+ this.workers.length = 0;
+
+ const error = new Error('Color/alpha merger closed.');
+ for (const pending of this.pendingRequests.values()) {
+ pending.reject(error);
+ }
+ this.pendingRequests.clear();
+ }
+}
+
+type ColorAlphaMergerWorkerRequest = {
+ id: number;
+ color: VideoFrame;
+ alpha: VideoFrame;
+};
+
+type ColorAlphaMergerWorkerResponse =
+ | { id: number; frame: VideoFrame }
+ | { id: number; error: string };
+
+const colorAlphaMergerWorkerCode = () => {
+ // These buffers are reused across frames as long as the size matches, since consecutive frames usually share
+ // dimensions
+ let cpuAlphaBuffer: Uint8Array | null = null;
+ let cpuColorBuffer: Uint8Array | null = null;
+
+ // Serialize execution internally so concurrent requests don't race on the shared cpu*Buffer state.
+ let chain: Promise = Promise.resolve();
+ self.addEventListener('message', (event: MessageEvent) => {
+ const { id, color, alpha } = event.data;
+ chain = chain.then(async () => {
+ try {
+ const frame = await merge(color, alpha);
+ self.postMessage({ id, frame }, { transfer: [frame] });
+ } catch (error) {
+ self.postMessage({ id, error: (error as Error).message });
+ } finally {
+ // We took ownership of the inputs via transfer; close them now that the merge (or its error) is done.
+ color.close();
+ alpha.close();
+ }
+ });
+ });
+
+ const merge = async (color: VideoFrame, alpha: VideoFrame): Promise => {
+ const format = color.format as VideoSamplePixelFormat | null;
+ const alphaFormat = alpha.format as VideoSamplePixelFormat | null;
+ if (!format || !alphaFormat) {
+ throw new Error('CPU color/alpha merging requires a known VideoFrame format.');
+ }
+
+ // The alpha frame must have the same bit depth as the color frame
+ const colorIs10 = format.includes('P10');
+ const colorIs12 = format.includes('P12');
+ const alphaIs10 = alphaFormat.includes('P10');
+ const alphaIs12 = alphaFormat.includes('P12');
+ if (alphaIs10 !== colorIs10 || alphaIs12 !== colorIs12) {
+ throw new Error(
+ `CPU color/alpha merging requires the alpha frame to have the same bit depth as the color frame`
+ + ` (color: '${format}', alpha: '${alphaFormat}').`,
+ );
+ }
+
+ if (format === 'RGBX' || format === 'RGBA' || format === 'BGRX' || format === 'BGRA') {
+ return await mergeInterleavedRgba(color, alpha, format);
+ } else if (
+ format === 'I420' || format === 'I420P10' || format === 'I420P12'
+ || format === 'I422' || format === 'I422P10' || format === 'I422P12'
+ || format === 'I444' || format === 'I444P10' || format === 'I444P12'
+ ) {
+ return await mergePlanarYuv(color, alpha, format);
+ } else if (format === 'NV12') {
+ return await mergeNv12(color, alpha);
+ }
+
+ throw new Error(`CPU color/alpha merging does not support format '${format}'.`);
+ };
+
+ const mergeInterleavedRgba = async (
+ color: VideoFrame,
+ alpha: VideoFrame,
+ format: 'RGBX' | 'RGBA' | 'BGRX' | 'BGRA',
+ ): Promise => {
+ const width = color.visibleRect?.width ?? color.codedWidth;
+ const height = color.visibleRect?.height ?? color.codedHeight;
+
+ const pixelCount = width * height;
+ const output = new Uint8Array(pixelCount * 4);
+
+ // Color goes straight into the output buffer via copyTo, no intermediate copy needed
+ await color.copyTo(output);
+
+ // And now add the alpha data
+ const alphaY = await readAlpha(alpha, width, height, 1);
+ for (let i = 0, j = 3; i < pixelCount; i++, j += 4) {
+ output[j] = alphaY[i]!;
+ }
+
+ const outputFormat = (format === 'RGBX' || format === 'RGBA') ? 'RGBA' : 'BGRA';
+ const init = {
+ format: outputFormat,
+ codedWidth: width,
+ codedHeight: height,
+ timestamp: color.timestamp,
+ duration: color.duration ?? undefined,
+ transfer: [output.buffer],
+ } as const;
+
+ return new VideoFrame(output, init);
+ };
+
+ const mergePlanarYuv = async (
+ color: VideoFrame,
+ alpha: VideoFrame,
+ format:
+ | 'I420' | 'I420P10' | 'I420P12'
+ | 'I422' | 'I422P10' | 'I422P12'
+ | 'I444' | 'I444P10' | 'I444P12',
+ ): Promise => {
+ const width = color.visibleRect?.width ?? color.codedWidth;
+ const height = color.visibleRect?.height ?? color.codedHeight;
+
+ const is10 = format.includes('P10');
+ const is12 = format.includes('P12');
+ const bytesPerSample = (is10 || is12) ? 2 : 1;
+
+ let chromaW: number;
+ let chromaH: number;
+ if (format.startsWith('I420')) {
+ chromaW = Math.ceil(width / 2);
+ chromaH = Math.ceil(height / 2);
+ } else if (format.startsWith('I422')) {
+ chromaW = Math.ceil(width / 2);
+ chromaH = height;
+ } else {
+ chromaW = width;
+ chromaH = height;
+ }
+
+ const ySamples = width * height;
+ const uvSamples = chromaW * chromaH;
+ const yBytes = ySamples * bytesPerSample;
+ const uvBytes = uvSamples * bytesPerSample;
+ const aBytes = ySamples * bytesPerSample;
+
+ const outputBytes = yBytes + 2 * uvBytes + aBytes;
+ const output = new Uint8Array(outputBytes);
+
+ // Write color planes directly into the output buffer via copyTo, no intermediate copy
+ await color.copyTo(output);
+
+ const alphaY = await readAlpha(alpha, width, height, bytesPerSample);
+ const aOffset = yBytes + 2 * uvBytes;
+ output.set(alphaY, aOffset);
+
+ const outputFormat = (format.slice(0, 4) + 'A' + format.slice(4)) as VideoPixelFormat;
+
+ const init = {
+ format: outputFormat,
+ codedWidth: width,
+ codedHeight: height,
+ timestamp: color.timestamp,
+ duration: color.duration ?? undefined,
+ transfer: [output.buffer],
+ };
+
+ return new VideoFrame(output, init);
+ };
+
+ const mergeNv12 = async (
+ color: VideoFrame,
+ alpha: VideoFrame,
+ ): Promise => {
+ const width = color.visibleRect?.width ?? color.codedWidth;
+ const height = color.visibleRect?.height ?? color.codedHeight;
+
+ const ySize = width * height;
+ const chromaW = Math.ceil(width / 2);
+ const chromaH = Math.ceil(height / 2);
+ const uvSize = chromaW * chromaH;
+
+ const sourceSize = color.allocationSize();
+ if (!cpuColorBuffer || cpuColorBuffer.byteLength !== sourceSize) {
+ cpuColorBuffer = new Uint8Array(sourceSize);
+ }
+ await color.copyTo(cpuColorBuffer);
+
+ const output = new Uint8Array(ySize + 2 * uvSize + ySize);
+
+ // Y plane copies straight over
+ output.set(cpuColorBuffer.subarray(0, ySize), 0);
+
+ // Deinterleave the UV plane into separate U and V planes
+ const uOffset = ySize;
+ const vOffset = ySize + uvSize;
+ const uvStart = ySize;
+ for (let i = 0; i < uvSize; i++) {
+ output[uOffset + i] = cpuColorBuffer[uvStart + i * 2]!;
+ output[vOffset + i] = cpuColorBuffer[uvStart + i * 2 + 1]!;
+ }
+
+ const alphaY = await readAlpha(alpha, width, height, 1);
+ output.set(alphaY, ySize + 2 * uvSize);
+
+ const init = {
+ format: 'I420A',
+ codedWidth: width,
+ codedHeight: height,
+ timestamp: color.timestamp,
+ duration: color.duration ?? undefined,
+ transfer: [output.buffer],
+ } as const;
+
+ return new VideoFrame(output, init);
+ };
+
+ const readAlpha = async (alpha: VideoFrame, width: number, height: number, bytesPerSample: number) => {
+ const size = alpha.allocationSize();
+ if (!cpuAlphaBuffer || cpuAlphaBuffer.byteLength !== size) {
+ cpuAlphaBuffer = new Uint8Array(size);
+ }
+ await alpha.copyTo(cpuAlphaBuffer);
+
+ const format = alpha.format;
+ if (format === 'RGBA' || format === 'BGRA' || format === 'RGBX' || format === 'BGRX') {
+ // Pack alpha data tightly. Assume alpha is stored in RGB, so sample just from R for simplicity.
+ const rOffset = (format === 'RGBA' || format === 'RGBX') ? 0 : 2;
+ const pixelCount = width * height;
+ for (let i = 0; i < pixelCount; i++) {
+ cpuAlphaBuffer[i] = cpuAlphaBuffer[i * 4 + rOffset]!;
+ }
+ return cpuAlphaBuffer.subarray(0, pixelCount);
+ } else {
+ // For Y-plane-first formats (I*** and NV12), the leading width*height samples are the Y plane
+ const yPlane = cpuAlphaBuffer.subarray(0, width * height * bytesPerSample);
+
+ if (alpha.colorSpace.fullRange === false) {
+ // Some decoders hand us limited-range alpha, so stretch it back out to full range
+ const bitDepth = format!.includes('P12') ? 12 : format!.includes('P10') ? 10 : 8;
+ const low = 16 << (bitDepth - 8);
+ const high = 235 << (bitDepth - 8);
+ const max = (1 << bitDepth) - 1;
+ const samples = bytesPerSample === 2
+ ? new Uint16Array(yPlane.buffer, 0, width * height)
+ : yPlane;
+
+ for (let i = 0; i < samples.length; i++) {
+ const value = Math.round((samples[i]! - low) * max / (high - low));
+ samples[i] = Math.min(Math.max(value, 0), max);
+ }
+ }
+
+ return yPlane;
+ }
+ };
+};
+
+export class AudioDecoderWrapper extends DecoderWrapper {
+ decoder: AudioDecoder | null = null;
+
+ customDecoder: CustomAudioDecoder | null = null;
+ customDecoderCallSerializer = new NaiveCallSerializer();
+ customDecoderQueueSize = 0;
+ customDecoderClosed = false;
+
+ // Internal state to accumulate a precise current timestamp based on audio durations, not the (potentially
+ // inaccurate) packet timestamps.
+ currentTimestamp: number | null = null;
+ // Chromium does not respect negative packet timestamps, so we must do the fixin' ourselves
+ expectedFirstTimestamp: number | null = null;
+ timestampOffset = 0;
+
+ onDequeue: (() => unknown) | null = null;
+
+ constructor(
+ onSample: (sample: AudioSample) => unknown,
+ onError: (error: unknown) => unknown,
+ codec: AudioCodec,
+ decoderConfig: AudioDecoderConfig,
+ ) {
+ super(onSample, onError);
+
+ const sampleHandler = (sample: AudioSample) => {
+ let sampleTimestamp = sample.timestamp;
+
+ if (this.expectedFirstTimestamp !== null && this.currentTimestamp === null) {
+ this.timestampOffset = this.expectedFirstTimestamp - sampleTimestamp;
+ }
+
+ sampleTimestamp += this.timestampOffset;
+
+ if (
+ this.currentTimestamp === null
+ || Math.abs(sampleTimestamp - this.currentTimestamp) >= sample.duration
+ ) {
+ // We need to sync with the sample timestamp again
+ this.currentTimestamp = sampleTimestamp;
+ }
+
+ const preciseTimestamp = this.currentTimestamp;
+ this.currentTimestamp += sample.duration;
+
+ if (sample.numberOfFrames === 0) {
+ // We skip zero-data (empty) AudioSamples. These are sometimes emitted, for example, by Firefox when it
+ // decodes Vorbis (at the start).
+ sample.close();
+ return;
+ }
+
+ // Round the timestamp to the sample rate
+ const sampleRate = decoderConfig.sampleRate;
+ // @ts-expect-error Readonly
+ sample.timestamp = Math.round(preciseTimestamp * sampleRate) / sampleRate;
+
+ onSample(sample);
+ };
+
+ const MatchingCustomDecoder = customAudioDecoders.find(x => x.supports(codec, decoderConfig));
+ if (MatchingCustomDecoder) {
+ // @ts-expect-error "Can't create instance of abstract class 🤓"
+ this.customDecoder = new MatchingCustomDecoder() as CustomAudioDecoder;
+ // @ts-expect-error It's technically readonly
+ this.customDecoder.codec = codec;
+ // @ts-expect-error It's technically readonly
+ this.customDecoder.config = decoderConfig;
+ // @ts-expect-error It's technically readonly
+ this.customDecoder.onSample = (sample) => {
+ if (!(sample instanceof AudioSample)) {
+ throw new TypeError('The argument passed to onSample must be an AudioSample.');
+ }
+
+ sampleHandler(sample);
+ };
+ // @ts-expect-error It's technically readonly
+ this.customDecoder.onError = (error) => {
+ onError(error);
+ };
+
+ void this.customDecoderCallSerializer
+ .call(() => this.customDecoder!.init())
+ .catch(error => onError(error));
+ } else {
+ const stack = new Error('Decoding error').stack;
+
+ this.decoder = new AudioDecoder({
+ output: (data) => {
+ try {
+ sampleHandler(new AudioSample(data));
+ } catch (error) {
+ this.onError(error);
+ }
+ },
+ error: (error) => {
+ error.stack = stack; // Provide a more useful stack trace, the default one sucks
+ this.onError(error);
+ },
+ });
+ this.decoder.configure(decoderConfig);
+
+ this.decoder.addEventListener('dequeue', () => {
+ this.onDequeue?.();
+ });
+ }
+ }
+
+ getDecodeQueueSize() {
+ if (this.customDecoder) {
+ return this.customDecoderQueueSize;
+ } else {
+ assert(this.decoder);
+ return this.decoder.decodeQueueSize;
+ }
+ }
+
+ decode(packet: EncodedPacket) {
+ assert(!packet.isMetadataOnly);
+
+ if (this.customDecoder) {
+ this.customDecoderQueueSize++;
+ void this.customDecoderCallSerializer
+ .call(() => this.customDecoder!.decode(packet))
+ .catch(error => this.onError(error))
+ .finally(() => {
+ this.customDecoderQueueSize--;
+ this.onDequeue?.();
+ });
+ } else {
+ assert(this.decoder);
+
+ this.expectedFirstTimestamp ??= packet.timestamp;
+ this.decoder.decode(packet.toEncodedAudioChunk());
+ }
+ }
+
+ async flush() {
+ if (this.customDecoder) {
+ await this.customDecoderCallSerializer.call(() => this.customDecoder!.flush());
+ } else {
+ assert(this.decoder);
+ await this.decoder.flush();
+ }
+
+ this.currentTimestamp = null;
+ this.expectedFirstTimestamp = null;
+ this.timestampOffset = 0;
+ }
+
+ close() {
+ if (this.customDecoder) {
+ if (!this.customDecoderClosed) {
+ this.customDecoderClosed = true;
+ void this.customDecoderCallSerializer.call(() => this.customDecoder!.close());
+ }
+ } else {
+ assert(this.decoder);
+
+ if (this.decoder.state !== 'closed') {
+ this.decoder.close();
+ }
+ }
+ }
+
+ get closed() {
+ if (this.customDecoder) {
+ if (this.customDecoderClosed) {
+ return true;
+ }
+
+ return !this.customDecoderCallSerializer.errored;
+ } else {
+ assert(this.decoder);
+ return this.decoder.state === 'closed';
+ }
+ }
+}
+
+// There are a lot of PCM variants not natively supported by the browser and by AudioData. Therefore we need a simple
+// decoder that maps any input PCM format into a PCM format supported by the browser.
+export class PcmAudioDecoderWrapper extends DecoderWrapper {
+ codec: PcmAudioCodec;
+
+ inputSampleSize: 1 | 2 | 3 | 4 | 8;
+ readInputValue: (view: DataView, byteOffset: number) => number;
+
+ outputSampleSize: 1 | 2 | 4;
+ outputFormat: 'u8' | 's16' | 's32' | 'f32';
+ writeOutputValue: (view: DataView, byteOffset: number, value: number) => void;
+
+ // Internal state to accumulate a precise current timestamp based on audio durations, not the (potentially
+ // inaccurate) packet timestamps.
+ currentTimestamp: number | null = null;
+
+ isClosed = false;
+
+ onDequeue: (() => unknown) | null = null;
+
+ constructor(
+ onSample: (sample: AudioSample) => unknown,
+ onError: (error: unknown) => unknown,
+ public decoderConfig: AudioDecoderConfig,
+ ) {
+ super(onSample, onError);
+
+ assert((PCM_AUDIO_CODECS as readonly string[]).includes(decoderConfig.codec));
+ this.codec = decoderConfig.codec as PcmAudioCodec;
+
+ const { dataType, sampleSize, littleEndian } = parsePcmCodec(this.codec);
+ this.inputSampleSize = sampleSize;
+
+ switch (sampleSize) {
+ case 1: {
+ if (dataType === 'unsigned') {
+ this.readInputValue = (view, byteOffset) => view.getUint8(byteOffset) - 2 ** 7;
+ } else if (dataType === 'signed') {
+ this.readInputValue = (view, byteOffset) => view.getInt8(byteOffset);
+ } else if (dataType === 'ulaw') {
+ this.readInputValue = (view, byteOffset) => fromUlaw(view.getUint8(byteOffset));
+ } else if (dataType === 'alaw') {
+ this.readInputValue = (view, byteOffset) => fromAlaw(view.getUint8(byteOffset));
+ } else {
+ assert(false);
+ }
+ }; break;
+ case 2: {
+ if (dataType === 'unsigned') {
+ this.readInputValue = (view, byteOffset) => view.getUint16(byteOffset, littleEndian) - 2 ** 15;
+ } else if (dataType === 'signed') {
+ this.readInputValue = (view, byteOffset) => view.getInt16(byteOffset, littleEndian);
+ } else {
+ assert(false);
+ }
+ }; break;
+ case 3: {
+ if (dataType === 'unsigned') {
+ this.readInputValue = (view, byteOffset) => getUint24(view, byteOffset, littleEndian) - 2 ** 23;
+ } else if (dataType === 'signed') {
+ this.readInputValue = (view, byteOffset) => getInt24(view, byteOffset, littleEndian);
+ } else {
+ assert(false);
+ }
+ }; break;
+ case 4: {
+ if (dataType === 'unsigned') {
+ this.readInputValue = (view, byteOffset) => view.getUint32(byteOffset, littleEndian) - 2 ** 31;
+ } else if (dataType === 'signed') {
+ this.readInputValue = (view, byteOffset) => view.getInt32(byteOffset, littleEndian);
+ } else if (dataType === 'float') {
+ this.readInputValue = (view, byteOffset) => view.getFloat32(byteOffset, littleEndian);
+ } else {
+ assert(false);
+ }
+ }; break;
+ case 8: {
+ if (dataType === 'float') {
+ this.readInputValue = (view, byteOffset) => view.getFloat64(byteOffset, littleEndian);
+ } else {
+ assert(false);
+ }
+ }; break;
+ default: {
+ assertNever(sampleSize);
+ assert(false);
+ };
+ }
+
+ switch (sampleSize) {
+ case 1: {
+ if (dataType === 'ulaw' || dataType === 'alaw') {
+ this.outputSampleSize = 2;
+ this.outputFormat = 's16';
+ this.writeOutputValue = (view, byteOffset, value) => view.setInt16(byteOffset, value, true);
+ } else {
+ this.outputSampleSize = 1;
+ this.outputFormat = 'u8';
+ this.writeOutputValue = (view, byteOffset, value) => view.setUint8(byteOffset, value + 2 ** 7);
+ }
+ }; break;
+ case 2: {
+ this.outputSampleSize = 2;
+ this.outputFormat = 's16';
+ this.writeOutputValue = (view, byteOffset, value) => view.setInt16(byteOffset, value, true);
+ }; break;
+ case 3: {
+ this.outputSampleSize = 4;
+ this.outputFormat = 's32';
+ // From https://www.w3.org/TR/webcodecs:
+ // AudioData containing 24-bit samples SHOULD store those samples in s32 or f32. When samples are
+ // stored in s32, each sample MUST be left-shifted by 8 bits.
+ this.writeOutputValue = (view, byteOffset, value) => view.setInt32(byteOffset, value << 8, true);
+ }; break;
+ case 4: {
+ this.outputSampleSize = 4;
+
+ if (dataType === 'float') {
+ this.outputFormat = 'f32';
+ this.writeOutputValue = (view, byteOffset, value) => view.setFloat32(byteOffset, value, true);
+ } else {
+ this.outputFormat = 's32';
+ this.writeOutputValue = (view, byteOffset, value) => view.setInt32(byteOffset, value, true);
+ }
+ }; break;
+ case 8: {
+ this.outputSampleSize = 4;
+
+ this.outputFormat = 'f32';
+ this.writeOutputValue = (view, byteOffset, value) => view.setFloat32(byteOffset, value, true);
+ }; break;
+ default: {
+ assertNever(sampleSize);
+ assert(false);
+ };
+ };
+ }
+
+ getDecodeQueueSize() {
+ return 0;
+ }
+
+ decode(packet: EncodedPacket) {
+ this.onDequeue?.();
+
+ const inputView = toDataView(packet.data);
+
+ const numberOfFrames = packet.byteLength / this.decoderConfig.numberOfChannels / this.inputSampleSize;
+
+ const outputBufferSize = numberOfFrames * this.decoderConfig.numberOfChannels * this.outputSampleSize;
+ const outputBuffer = new ArrayBuffer(outputBufferSize);
+ const outputView = new DataView(outputBuffer);
+
+ for (let i = 0; i < numberOfFrames * this.decoderConfig.numberOfChannels; i++) {
+ const inputIndex = i * this.inputSampleSize;
+ const outputIndex = i * this.outputSampleSize;
+
+ const value = this.readInputValue(inputView, inputIndex);
+ this.writeOutputValue(outputView, outputIndex, value);
+ }
+
+ const preciseDuration = numberOfFrames / this.decoderConfig.sampleRate;
+ if (this.currentTimestamp === null || Math.abs(packet.timestamp - this.currentTimestamp) >= preciseDuration) {
+ // We need to sync with the packet timestamp again
+ this.currentTimestamp = packet.timestamp;
+ }
+
+ const preciseTimestamp = this.currentTimestamp;
+ this.currentTimestamp += preciseDuration;
+
+ const audioSample = new AudioSample({
+ format: this.outputFormat,
+ data: outputBuffer,
+ numberOfChannels: this.decoderConfig.numberOfChannels,
+ sampleRate: this.decoderConfig.sampleRate,
+ numberOfFrames,
+ timestamp: preciseTimestamp,
+ });
+
+ this.onSample(audioSample);
+ }
+
+ async flush() {
+ // Do nothing
+ }
+
+ close() {
+ this.isClosed = true;
+ }
+
+ get closed() {
+ return this.isClosed;
+ }
+}
export const canDecodeVideoMemo = new Map>();
export const canDecodeAudioMemo = new Map>();
diff --git a/src/flac/flac-demuxer.ts b/src/flac/flac-demuxer.ts
index 7a581d00e..3d2852cc7 100644
--- a/src/flac/flac-demuxer.ts
+++ b/src/flac/flac-demuxer.ts
@@ -10,16 +10,17 @@ import { FlacBlockType, readVorbisComments } from '../codec-data';
import { Demuxer } from '../demuxer';
import { Input } from '../input';
import { InputAudioTrackBacking } from '../input-track';
-import { PacketRetrievalOptions } from '../media-sink';
import {
assert,
AsyncMutex,
binarySearchLessOrEqual,
isThenable,
+ MaybeRelevantPromise,
+ ResultValue,
textDecoder,
UNDETERMINED_LANGUAGE,
} from '../misc';
-import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
+import { EncodedPacket, PacketRetrievalOptions, PacketRetrievalResult, PLACEHOLDER_DATA } from '../packet';
import {
FileSlice,
readBytes,
@@ -286,13 +287,16 @@ export class FlacDemuxer extends Demuxer {
})());
}
- async readNextFlacFrame({
- startPos,
- isFirstPacket,
- }: {
- startPos: number;
- isFirstPacket: boolean;
- }): Promise {
+ async readNextFlacFrame(
+ res: ResultValue,
+ {
+ startPos,
+ isFirstPacket,
+ }: {
+ startPos: number;
+ isFirstPacket: boolean;
+ },
+ ): MaybeRelevantPromise {
assert(this.audioInfo);
// we expect that there are at least `minimumFrameSize` bytes left in the file
@@ -337,14 +341,15 @@ export class FlacDemuxer extends Demuxer {
const maximumSliceLength
= effectiveMaxFrameSize + maximumHeaderLength;
- const slice = await this.reader.requestSliceRange(
+ let slice = this.reader.requestSliceRange(
startPos,
maximumHeaderLength,
maximumSliceLength,
);
+ if (isThenable(slice)) slice = await slice;
if (!slice) {
- return null;
+ return res.set(null);
}
const frameHeader = this.readFlacFrameHeader({
@@ -353,7 +358,7 @@ export class FlacDemuxer extends Demuxer {
});
if (!frameHeader) {
- return null;
+ return res.set(null);
}
// We don't know exactly how long the packet is, we only know the `minimumFrameSize` and `maximumFrameSize`
@@ -367,13 +372,13 @@ export class FlacDemuxer extends Demuxer {
while (true) {
// Reached end of the file, packet is over
if (slice.filePos > slice.end - minimumHeaderLength) {
- return {
+ return res.set({
num: frameHeader.num,
blockSize: frameHeader.blockSize,
sampleRate: frameHeader.sampleRate,
size: slice.end - startPos,
isLastFrame: true,
- };
+ });
}
const nextByte = readU8(slice);
@@ -419,13 +424,13 @@ export class FlacDemuxer extends Demuxer {
}
}
- return {
+ return res.set({
num: frameHeader.num,
blockSize: frameHeader.blockSize,
sampleRate: frameHeader.sampleRate,
size: lengthIfNextFlacFrameHeaderIsLegit,
isLastFrame: false,
- };
+ });
}
}
}
@@ -533,21 +538,24 @@ export class FlacDemuxer extends Demuxer {
return { num, blockSize, sampleRate };
}
- async advanceReader() {
- await this.readMetadata();
+ async advanceReader(res: ResultValue): MaybeRelevantPromise {
assert(this.lastLoadedPos !== null);
assert(this.audioInfo);
const startPos = this.lastLoadedPos;
- const frame = await this.readNextFlacFrame({
+
+ const frameResult = new ResultValue();
+ const promise = this.readNextFlacFrame(frameResult, {
startPos,
isFirstPacket: this.loadedSamples.length === 0,
});
+ if (frameResult.pending) await promise;
+ const frame = frameResult.value;
if (!frame) {
// Unexpected case, failed to read next FLAC frame
// handling gracefully
this.lastSampleLoaded = true;
- return;
+ return res.set();
}
const lastSample = this.loadedSamples[this.loadedSamples.length - 1];
@@ -567,8 +575,9 @@ export class FlacDemuxer extends Demuxer {
if (frame.isLastFrame) {
this.lastSampleLoaded = true;
- return;
}
+
+ return res.set();
}
}
@@ -670,102 +679,118 @@ class FlacAudioTrackBacking implements InputAudioTrackBacking {
}
async getPacket(
+ res: ResultValue,
timestamp: number,
options: PacketRetrievalOptions,
- ): Promise {
+ ): MaybeRelevantPromise {
assert(this.demuxer.audioInfo);
if (timestamp < 0) {
- return null;
+ return res.set(new PacketRetrievalResult(null));
}
- const release = await this.demuxer.readingMutex.acquire();
+ using lock = this.demuxer.readingMutex.lock();
+ if (lock.pending) await lock.ready;
- try {
- while (true) {
- const packetIndex = binarySearchLessOrEqual(
- this.demuxer.loadedSamples,
- timestamp,
- x => x.blockOffset / this.demuxer.audioInfo!.sampleRate,
- );
- if (packetIndex === -1) {
- await this.demuxer.advanceReader();
- continue;
- }
+ const advanceResult = new ResultValue();
+
+ while (true) {
+ const packetIndex = binarySearchLessOrEqual(
+ this.demuxer.loadedSamples,
+ timestamp,
+ x => x.blockOffset / this.demuxer.audioInfo!.sampleRate,
+ );
+ if (packetIndex === -1) {
+ advanceResult.reset();
+ const promise = this.demuxer.advanceReader(advanceResult);
+ if (advanceResult.pending) await promise;
- const packet = this.demuxer.loadedSamples[packetIndex]!;
- const sampleTimestamp
+ continue;
+ }
+
+ const packet = this.demuxer.loadedSamples[packetIndex]!;
+ const sampleTimestamp
= packet.blockOffset / this.demuxer.audioInfo.sampleRate;
- const sampleDuration
+ const sampleDuration
= packet.blockSize / this.demuxer.audioInfo.sampleRate;
- if (sampleTimestamp + sampleDuration <= timestamp) {
- if (this.demuxer.lastSampleLoaded) {
- return this.getPacketAtIndex(
- this.demuxer.loadedSamples.length - 1,
- options,
- );
- }
-
- await this.demuxer.advanceReader();
- continue;
+ if (sampleTimestamp + sampleDuration <= timestamp) {
+ if (this.demuxer.lastSampleLoaded) {
+ return this.getPacketAtIndex(
+ res,
+ this.demuxer.loadedSamples.length - 1,
+ options,
+ );
}
- return this.getPacketAtIndex(packetIndex, options);
+ advanceResult.reset();
+ const promise = this.demuxer.advanceReader(advanceResult);
+ if (advanceResult.pending) await promise;
+
+ continue;
}
- } finally {
- release();
+
+ return this.getPacketAtIndex(res, packetIndex, options);
}
}
async getNextPacket(
+ res: ResultValue,
packet: EncodedPacket,
options: PacketRetrievalOptions,
- ): Promise {
- const release = await this.demuxer.readingMutex.acquire();
- try {
- const nextIndex = packet.sequenceNumber + 1;
- if (
- this.demuxer.lastSampleLoaded
- && nextIndex >= this.demuxer.loadedSamples.length
- ) {
- return null;
- }
+ ): MaybeRelevantPromise {
+ using lock = this.demuxer.readingMutex.lock();
+ if (lock.pending) await lock.ready;
- // Ensure the next sample exists
- while (
- nextIndex >= this.demuxer.loadedSamples.length
- && !this.demuxer.lastSampleLoaded
- ) {
- await this.demuxer.advanceReader();
- }
- return this.getPacketAtIndex(nextIndex, options);
- } finally {
- release();
+ assert(packet.sequenceNumber >= 0);
+
+ const nextIndex = packet.sequenceNumber + 1;
+ if (
+ this.demuxer.lastSampleLoaded
+ && nextIndex >= this.demuxer.loadedSamples.length
+ ) {
+ return res.set(new PacketRetrievalResult(null));
}
+
+ const advanceResult = new ResultValue();
+
+ // Ensure the next sample exists
+ while (
+ nextIndex >= this.demuxer.loadedSamples.length
+ && !this.demuxer.lastSampleLoaded
+ ) {
+ advanceResult.reset();
+ const promise = this.demuxer.advanceReader(advanceResult);
+ if (advanceResult.pending) await promise;
+ }
+
+ return this.getPacketAtIndex(res, nextIndex, options);
}
getKeyPacket(
+ res: ResultValue,
timestamp: number,
options: PacketRetrievalOptions,
- ): Promise {
- return this.getPacket(timestamp, options);
+ ): MaybeRelevantPromise {
+ return this.getPacket(res, timestamp, options);
}
getNextKeyPacket(
+ res: ResultValue,
packet: EncodedPacket,
options: PacketRetrievalOptions,
- ): Promise {
- return this.getNextPacket(packet, options);
+ ): MaybeRelevantPromise {
+ return this.getNextPacket(res, packet, options);
}
async getPacketAtIndex(
+ res: ResultValue,
sampleIndex: number,
options: PacketRetrievalOptions,
- ): Promise {
+ ): MaybeRelevantPromise {
const rawSample = this.demuxer.loadedSamples[sampleIndex];
if (!rawSample) {
- return null;
+ return res.set(new PacketRetrievalResult(null));
}
let data: Uint8Array;
@@ -779,7 +804,7 @@ class FlacAudioTrackBacking implements InputAudioTrackBacking {
if (isThenable(slice)) slice = await slice;
if (!slice) {
- return null; // Data didn't fit into the rest of the file
+ return res.set(new PacketRetrievalResult(null)); // Data didn't fit into the rest of the file
}
data = readBytes(slice, rawSample.byteSize);
@@ -788,27 +813,33 @@ class FlacAudioTrackBacking implements InputAudioTrackBacking {
assert(this.demuxer.audioInfo);
const timestamp = rawSample.blockOffset / this.demuxer.audioInfo.sampleRate;
const duration = rawSample.blockSize / this.demuxer.audioInfo.sampleRate;
- return new EncodedPacket(
+
+ return res.set(new PacketRetrievalResult(new EncodedPacket(
data,
'key',
timestamp,
duration,
sampleIndex,
rawSample.byteSize,
- );
+ )));
}
async getFirstPacket(
+ res: ResultValue,
options: PacketRetrievalOptions,
- ): Promise {
+ ): MaybeRelevantPromise {
+ const advanceResult = new ResultValue();
+
// Ensure the next sample exists
while (
this.demuxer.loadedSamples.length === 0
&& !this.demuxer.lastSampleLoaded
) {
- await this.demuxer.advanceReader();
+ advanceResult.reset();
+ const promise = this.demuxer.advanceReader(advanceResult);
+ if (advanceResult.pending) await promise;
}
- return this.getPacketAtIndex(0, options);
+ return this.getPacketAtIndex(res, 0, options);
}
}
diff --git a/src/flac/flac-muxer.ts b/src/flac/flac-muxer.ts
index 411240c6b..5b6792b01 100644
--- a/src/flac/flac-muxer.ts
+++ b/src/flac/flac-muxer.ts
@@ -52,7 +52,8 @@ export class FlacMuxer extends Muxer {
}
async start() {
- const release = await this.mutex.acquire();
+ using lock = this.mutex.lock();
+ if (lock.pending) await lock.ready;
this.writer = await this.output._getRootWriter(!!this.format._options.appendOnly);
this.writer.write(FLAC_HEADER);
@@ -65,8 +66,6 @@ export class FlacMuxer extends Muxer {
validateAudioChunkMetadata({ decoderConfig: track.metadata.decoderConfig }, track.source._codec);
this.applyDecoderConfig(track.metadata.decoderConfig);
}
-
- release();
}
applyDecoderConfig(decoderConfig: AudioDecoderConfig) {
@@ -259,57 +258,54 @@ export class FlacMuxer extends Muxer {
packet: EncodedPacket,
meta?: EncodedAudioChunkMetadata,
): Promise {
- const release = await this.mutex.acquire();
-
- try {
- this.validateTimestamp(
- track,
- packet.timestamp,
- packet.type === 'key',
- );
+ using lock = this.mutex.lock();
+ if (lock.pending) await lock.ready;
- if (this.sampleRate === null) {
- // It's the first packet
- validateAudioChunkMetadata(meta, track.source._codec);
+ this.validateTimestamp(
+ track,
+ packet.timestamp,
+ packet.type === 'key',
+ );
- assert(meta);
- assert(meta.decoderConfig);
+ if (this.sampleRate === null) {
+ // It's the first packet
+ validateAudioChunkMetadata(meta, track.source._codec);
- this.applyDecoderConfig(meta.decoderConfig);
- }
+ assert(meta);
+ assert(meta.decoderConfig);
- if (!this.metadataWritten) {
- this.writeVorbisCommentAndPictureBlock();
- }
+ this.applyDecoderConfig(meta.decoderConfig);
+ }
- const slice = FileSlice.tempFromBytes(packet.data);
- slice.skip(2);
- const bytes = readBytes(slice, 2);
- const bitstream = new Bitstream(bytes);
- const blockSizeOrUncommon = getBlockSizeOrUncommon(bitstream.readBits(4));
- if (blockSizeOrUncommon === null) {
- throw new Error('Invalid FLAC frame: Invalid block size.');
- }
+ if (!this.metadataWritten) {
+ this.writeVorbisCommentAndPictureBlock();
+ }
- readCodedNumber(slice); // num
- const blockSize = readBlockSize(slice, blockSizeOrUncommon);
+ const slice = FileSlice.tempFromBytes(packet.data);
+ slice.skip(2);
+ const bytes = readBytes(slice, 2);
+ const bitstream = new Bitstream(bytes);
+ const blockSizeOrUncommon = getBlockSizeOrUncommon(bitstream.readBits(4));
+ if (blockSizeOrUncommon === null) {
+ throw new Error('Invalid FLAC frame: Invalid block size.');
+ }
- if (!this.format._options.appendOnly) {
- this.blockSizes.push(blockSize);
- this.frameSizes.push(packet.data.length);
- }
+ readCodedNumber(slice); // num
+ const blockSize = readBlockSize(slice, blockSizeOrUncommon);
- const startPos = this.writer.getPos();
- this.writer.write(packet.data);
+ if (!this.format._options.appendOnly) {
+ this.blockSizes.push(blockSize);
+ this.frameSizes.push(packet.data.length);
+ }
- if (this.format._options.onFrame) {
- this.format._options.onFrame(packet.data, startPos);
- }
+ const startPos = this.writer.getPos();
+ this.writer.write(packet.data);
- await this.writer.flush();
- } finally {
- release();
+ if (this.format._options.onFrame) {
+ this.format._options.onFrame(packet.data, startPos);
}
+
+ await this.writer.flush();
}
override addSubtitleCue(): Promise {
@@ -317,7 +313,8 @@ export class FlacMuxer extends Muxer {
}
async finalize(): Promise {
- const release = await this.mutex.acquire();
+ using lock = this.mutex.lock();
+ if (lock.pending) await lock.ready;
if (this.sampleRate === null) {
throw new Error(
@@ -377,7 +374,5 @@ export class FlacMuxer extends Muxer {
totalSamples,
});
}
-
- release();
}
}
diff --git a/src/hls/hls-demuxer.ts b/src/hls/hls-demuxer.ts
index 4b203ed3d..6f56bc338 100644
--- a/src/hls/hls-demuxer.ts
+++ b/src/hls/hls-demuxer.ts
@@ -14,11 +14,18 @@ import {
InputTrackBacking,
InputVideoTrackBacking,
} from '../input-track';
-import { PacketRetrievalOptions } from '../media-sink';
import { DEFAULT_TRACK_DISPOSITION, MetadataTags, TrackDisposition } from '../metadata';
import { TrackType } from '../output';
-import { assert, joinPaths, MaybePromise, TransformationMatrix, UNDETERMINED_LANGUAGE } from '../misc';
-import { EncodedPacket } from '../packet';
+import {
+ assert,
+ joinPaths,
+ MaybePromise,
+ MaybeRelevantPromise,
+ ResultValue,
+ TransformationMatrix,
+ UNDETERMINED_LANGUAGE,
+} from '../misc';
+import { EncodedPacket, PacketRetrievalOptions, PacketRetrievalResult } from '../packet';
import { readAllLines } from '../reader';
import {
AttributeList,
@@ -754,29 +761,58 @@ abstract class HlsInputTrackBacking implements InputTrackBacking {
return this.internalTrack.hasOnlyKeyPackets || null;
}
- async getFirstPacket(options: PacketRetrievalOptions): Promise {
- await this.hydrate();
- return this.internalTrack.backingTrack!.getFirstPacket(options);
+ async getFirstPacket(
+ res: ResultValue,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise {
+ if (!this.internalTrack.backingTrack) {
+ await this.hydrate();
+ }
+ return this.internalTrack.backingTrack!.getFirstPacket(res, options);
}
- async getPacket(timestamp: number, options: PacketRetrievalOptions): Promise {
- await this.hydrate();
- return this.internalTrack.backingTrack!.getPacket(timestamp, options);
+ async getPacket(
+ res: ResultValue,
+ timestamp: number,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise {
+ if (!this.internalTrack.backingTrack) {
+ await this.hydrate();
+ }
+ return this.internalTrack.backingTrack!.getPacket(res, timestamp, options);
}
- async getKeyPacket(timestamp: number, options: PacketRetrievalOptions): Promise {
- await this.hydrate();
- return this.internalTrack.backingTrack!.getKeyPacket(timestamp, options);
+ async getKeyPacket(
+ res: ResultValue,
+ timestamp: number,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise {
+ if (!this.internalTrack.backingTrack) {
+ await this.hydrate();
+ }
+ return this.internalTrack.backingTrack!.getKeyPacket(res, timestamp, options);
}
- async getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions): Promise {
- await this.hydrate();
- return this.internalTrack.backingTrack!.getNextPacket(packet, options);
+ async getNextPacket(
+ res: ResultValue,
+ packet: EncodedPacket,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise {
+ if (!this.internalTrack.backingTrack) {
+ await this.hydrate();
+ }
+ return this.internalTrack.backingTrack!.getNextPacket(res, packet, options);
}
- async getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions): Promise {
- await this.hydrate();
- return this.internalTrack.backingTrack!.getNextKeyPacket(packet, options);
+ async getNextKeyPacket(
+ res: ResultValue,
+ packet: EncodedPacket,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise {
+ if (!this.internalTrack.backingTrack) {
+ await this.hydrate();
+ }
+ return this.internalTrack.backingTrack!.getNextKeyPacket(res, packet, options);
}
}
diff --git a/src/hls/hls-muxer.ts b/src/hls/hls-muxer.ts
index d06b9ff52..8c6952925 100644
--- a/src/hls/hls-muxer.ts
+++ b/src/hls/hls-muxer.ts
@@ -172,7 +172,8 @@ export class HlsMuxer extends Muxer {
}
async start(): Promise {
- const release = await this.mutex.acquire();
+ using lock = this.mutex.lock();
+ if (lock.pending) await lock.ready;
const someRelative = this.output.tracks.some(t => t.metadata.isRelativeToUnixEpoch);
const someNotRelative = this.output.tracks.some(t => !t.metadata.isRelativeToUnixEpoch);
@@ -553,8 +554,6 @@ export class HlsMuxer extends Muxer {
);
}
}
-
- release();
}
async getMimeType(): Promise {
@@ -581,13 +580,10 @@ export class HlsMuxer extends Muxer {
const playlist = this.playlists.find(x => x.tracks.includes(track));
assert(playlist); // If there isn't one then the assignment algo failed innit
- const release = await playlist.mutex.acquire();
+ using lock = playlist.mutex.lock();
+ if (lock.pending) await lock.ready;
- try {
- await this.advancePlaylist(playlist);
- } finally {
- release();
- }
+ await this.advancePlaylist(playlist);
}
getVideoTrackData(track: OutputVideoTrack, packet: EncodedPacket | null, meta?: EncodedVideoChunkMetadata) {
@@ -658,25 +654,22 @@ export class HlsMuxer extends Muxer {
const trackData = this.getVideoTrackData(track, packet, meta);
const playlist = trackData.playlist;
- const release = await playlist.mutex.acquire();
-
- try {
- this.validateTimestamp(track, packet.timestamp, packet.type === 'key');
- trackData.packets.push(packet);
+ using lock = playlist.mutex.lock();
+ if (lock.pending) await lock.ready;
- if (playlist.currentSegmentStartTimestamp === null) {
- playlist.currentSegmentStartTimestamp = packet.timestamp;
- } else if (!playlist.currentSegmentStartTimestampIsFixed) {
- playlist.currentSegmentStartTimestamp = Math.min(
- playlist.currentSegmentStartTimestamp,
- packet.timestamp,
- );
- }
+ this.validateTimestamp(track, packet.timestamp, packet.type === 'key');
+ trackData.packets.push(packet);
- await this.advancePlaylist(playlist);
- } finally {
- release();
+ if (playlist.currentSegmentStartTimestamp === null) {
+ playlist.currentSegmentStartTimestamp = packet.timestamp;
+ } else if (!playlist.currentSegmentStartTimestampIsFixed) {
+ playlist.currentSegmentStartTimestamp = Math.min(
+ playlist.currentSegmentStartTimestamp,
+ packet.timestamp,
+ );
}
+
+ await this.advancePlaylist(playlist);
}
async addEncodedAudioPacket(
@@ -687,25 +680,22 @@ export class HlsMuxer extends Muxer {
const trackData = this.getAudioTrackData(track, packet, meta);
const playlist = trackData.playlist;
- const release = await playlist.mutex.acquire();
-
- try {
- this.validateTimestamp(track, packet.timestamp, packet.type === 'key');
- trackData.packets.push(packet);
+ using lock = playlist.mutex.lock();
+ if (lock.pending) await lock.ready;
- if (playlist.currentSegmentStartTimestamp === null) {
- playlist.currentSegmentStartTimestamp = packet.timestamp;
- } else if (!playlist.currentSegmentStartTimestampIsFixed) {
- playlist.currentSegmentStartTimestamp = Math.min(
- playlist.currentSegmentStartTimestamp,
- packet.timestamp,
- );
- }
+ this.validateTimestamp(track, packet.timestamp, packet.type === 'key');
+ trackData.packets.push(packet);
- await this.advancePlaylist(playlist);
- } finally {
- release();
+ if (playlist.currentSegmentStartTimestamp === null) {
+ playlist.currentSegmentStartTimestamp = packet.timestamp;
+ } else if (!playlist.currentSegmentStartTimestampIsFixed) {
+ playlist.currentSegmentStartTimestamp = Math.min(
+ playlist.currentSegmentStartTimestamp,
+ packet.timestamp,
+ );
}
+
+ await this.advancePlaylist(playlist);
}
async addSubtitleCue(
@@ -1623,35 +1613,32 @@ export class HlsMuxer extends Muxer {
this.format._options.onMaster?.(masterPlaylistText);
- const release = await this.mutex.acquire();
-
- try {
- 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(true);
- } 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,
- mimeType: HLS_MIME_TYPE,
- });
- writer = new Writer(target, true);
- writer.start();
- }
+ using lock = this.mutex.lock();
+ if (lock.pending) await lock.ready;
+
+ 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(true);
+ } 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,
+ mimeType: HLS_MIME_TYPE,
+ });
+ writer = new Writer(target, true);
+ writer.start();
+ }
- writer.write(textEncoder.encode(masterPlaylistText));
+ writer.write(textEncoder.encode(masterPlaylistText));
- await writer.flush();
- await writer.finalize();
+ await writer.flush();
+ await writer.finalize();
- this.numWrittenMasterPlaylists++;
- } finally {
- release();
- }
+ this.numWrittenMasterPlaylists++;
}
private async tryWriteMasterPlaylist() {
@@ -1668,8 +1655,11 @@ export class HlsMuxer extends Muxer {
}
async finalize() {
- const releases = await Promise.all(this.playlists.map(p => p.mutex.acquire()));
- releases.forEach(release => release());
+ // Wait for all playlist mutexes
+ await Promise.all(this.playlists.map(async (playlist) => {
+ using lock = playlist.mutex.lock();
+ if (lock.pending) await lock.ready;
+ }));
for (const trackData of this.trackDatas) {
trackData.closed = true;
diff --git a/src/hls/hls-segmented-input.ts b/src/hls/hls-segmented-input.ts
index 30b8da014..a13c80d62 100644
--- a/src/hls/hls-segmented-input.ts
+++ b/src/hls/hls-segmented-input.ts
@@ -8,7 +8,13 @@
import { AES_128_BLOCK_SIZE, createAes128CbcDecryptStream } from '../aes';
import { ENCRYPTION_KEY_CACHE_GROUP, Input } from '../input';
-import { Segment, SegmentedInput, SegmentedInputTrackDeclaration, SegmentRetrievalOptions } from '../segmented-input';
+import {
+ Segment,
+ SegmentedInput,
+ SegmentedInputTrackDeclaration,
+ SegmentRetrievalOptions,
+ SegmentRetrievalResult,
+} from '../segmented-input';
import {
toDataView,
joinPaths,
@@ -18,6 +24,8 @@ import {
arrayArgmin,
wait,
base64ToBytes,
+ MaybeRelevantPromise,
+ ResultValue,
} from '../misc';
import { readAllLines, readBytes, Reader } from '../reader';
import { CustomPathedSource, PathedSource, ReadableStreamSource, SourceRef, SourceRequest } from '../source';
@@ -540,15 +548,19 @@ export class HlsSegmentedInput extends SegmentedInput {
}
}
- async getFirstSegment() {
+ async getFirstSegment(res: ResultValue): MaybeRelevantPromise {
if (this.segments.length === 0) {
await this.runUpdateSegments();
}
- return this.segments[0] ?? null;
+ return res.set(new SegmentRetrievalResult(this.segments[0] ?? null));
}
- async getSegmentAt(timestamp: number, options: SegmentRetrievalOptions) {
+ async getSegmentAt(
+ res: ResultValue,
+ timestamp: number,
+ options: SegmentRetrievalOptions,
+ ): MaybeRelevantPromise {
if (this.segments.length === 0) {
await this.runUpdateSegments();
}
@@ -559,16 +571,24 @@ export class HlsSegmentedInput extends SegmentedInput {
while (true) {
const index = binarySearchLessOrEqual(this.segments, timestamp, x => x.timestamp);
if (index === -1) {
- return null;
+ return res.set(new SegmentRetrievalResult(null));
}
- if (index < this.segments.length - 1 || this.streamHasEnded || isLazy) {
- return this.segments[index]!;
+ if (index < this.segments.length - 1 || this.streamHasEnded) {
+ return res.set(new SegmentRetrievalResult(this.segments[index]!));
}
const segment = this.segments[index]!;
if (timestamp < segment.timestamp + segment.duration) {
- return segment;
+ return res.set(new SegmentRetrievalResult(segment));
+ }
+
+ if (isLazy) {
+ // The timestamp lies past the live edge, so a future segment may still end up containing it
+ const result = new SegmentRetrievalResult(segment);
+ result.provisional = true;
+
+ return res.set(result);
}
await this.runUpdateSegments();
@@ -579,7 +599,11 @@ export class HlsSegmentedInput extends SegmentedInput {
}
}
- async getNextSegment(segment: Segment, options: SegmentRetrievalOptions) {
+ async getNextSegment(
+ res: ResultValue,
+ segment: Segment,
+ options: SegmentRetrievalOptions,
+ ): MaybeRelevantPromise {
const index = this.segments.indexOf(segment as HlsSegment);
assert(index !== -1);
@@ -590,11 +614,19 @@ export class HlsSegmentedInput extends SegmentedInput {
while (true) {
if (nextIndex < this.segments.length) {
- return this.segments[nextIndex]!;
+ return res.set(new SegmentRetrievalResult(this.segments[nextIndex]!));
}
- if (this.streamHasEnded || isLazy) {
- return null;
+ if (this.streamHasEnded) {
+ return res.set(new SegmentRetrievalResult(null));
+ }
+
+ if (isLazy) {
+ // There's no next segment yet, but one may still come
+ const result = new SegmentRetrievalResult(null);
+ result.provisional = true;
+
+ return res.set(result);
}
await this.runUpdateSegments();
@@ -605,11 +637,11 @@ export class HlsSegmentedInput extends SegmentedInput {
}
}
- async getPreviousSegment(segment: Segment) {
+ async getPreviousSegment(res: ResultValue, segment: Segment): MaybeRelevantPromise {
const index = this.segments.indexOf(segment as HlsSegment);
assert(index !== -1);
- return this.segments[index - 1] ?? null;
+ return res.set(new SegmentRetrievalResult(this.segments[index - 1] ?? null));
}
getInputForSegment(segment: Segment): Input {
diff --git a/src/index.ts b/src/index.ts
index 798c19291..d187ce770 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -97,6 +97,7 @@ export {
SUBTITLE_CODECS,
} from './codec';
export {
+ VideoDecoderWrapper,
canDecode,
canDecodeVideo,
canDecodeAudio,
@@ -264,6 +265,10 @@ export {
EncodedPacket,
type EncodedPacketSideData,
type PacketType,
+ PacketCache,
+ type PacketCacheOptions,
+ PacketReader,
+ type PacketReaderOptions,
} from './packet';
export {
AudioSample,
@@ -283,18 +288,16 @@ export {
registerVideoSampleTransformer,
} from './sample';
export {
- AudioBufferSink,
- AudioSampleSink,
- BaseMediaSampleSink,
- CanvasSink,
- type CanvasSinkOptions,
- EncodedPacketSink,
- type PacketRetrievalOptions,
- VideoSampleSink,
- type VideoSinkDecoderOptions,
- type WrappedAudioBuffer,
- type WrappedCanvas,
-} from './media-sink';
+ PacketCursor,
+ type PacketCursorConfig,
+ SampleCursor,
+ VideoSampleCursor,
+ AudioSampleCursor,
+ type VideoSampleCursorOptions,
+ type AudioSampleCursorOptions,
+ canvasTransformer,
+ type CanvasTransformerOptions,
+} from './cursors';
export {
Conversion,
type ConversionOptions,
diff --git a/src/input-format.ts b/src/input-format.ts
index 7a420a358..6942fc355 100644
--- a/src/input-format.ts
+++ b/src/input-format.ts
@@ -22,7 +22,7 @@ import {
} from './matroska/ebml';
import { MatroskaDemuxer } from './matroska/matroska-demuxer';
import { Mp3Demuxer } from './mp3/mp3-demuxer';
-import { MP3_FRAME_HEADER_SIZE, getXingOffset, INFO, XING } from '../shared/mp3-misc';
+import { MP3_FRAME_HEADER_SIZE, getXingOffset, INFO, XING, Mp3FrameHeader } from '../shared/mp3-misc';
import { ID3_V2_HEADER_SIZE, readId3V2Header } from './id3';
import { readNextMp3FrameHeader } from './mp3/mp3-reader';
import { OggDemuxer } from './ogg/ogg-demuxer';
@@ -36,7 +36,7 @@ import { TS_PACKET_SIZE } from './mpeg-ts/mpeg-ts-misc';
import { HlsDemuxer } from './hls/hls-demuxer';
import { HLS_MIME_TYPE } from './hls/hls-misc';
import { PathedSource } from './source';
-import { isThenable, MaybePromise } from './misc';
+import { isThenable, MaybePromise, ResultValue } from './misc';
/**
* Base class representing an input media file format.
@@ -335,7 +335,15 @@ export class Mp3InputFormat extends InputFormat {
currentPos = slice.filePos + id3V2Header.size;
}
- const firstResult = await readNextMp3FrameHeader(input._reader, currentPos, currentPos + 4096);
+ const result = new ResultValue<{
+ header: Mp3FrameHeader;
+ startPos: number;
+ } | null>();
+
+ let promise = readNextMp3FrameHeader(result, input._reader, currentPos, currentPos + 4096);
+ if (result.pending) await promise;
+
+ const firstResult = result.value;
if (!firstResult) {
return false;
}
@@ -359,11 +367,11 @@ export class Mp3InputFormat extends InputFormat {
// Fine, we found one frame header, but we're still not entirely sure this is MP3. Let's check if we can find
// another header right after it:
- const secondResult = await readNextMp3FrameHeader(
- input._reader,
- currentPos,
- currentPos + MP3_FRAME_HEADER_SIZE,
- );
+ result.reset();
+ promise = readNextMp3FrameHeader(result, input._reader, currentPos, currentPos + MP3_FRAME_HEADER_SIZE);
+ if (result.pending) await promise;
+
+ const secondResult = result.value;
if (!secondResult) {
return false;
}
diff --git a/src/input-track.ts b/src/input-track.ts
index 8020ad811..c6d0b3e13 100644
--- a/src/input-track.ts
+++ b/src/input-track.ts
@@ -11,7 +11,6 @@ import { determineVideoPacketType } from './codec-data';
import { customAudioDecoders, customVideoDecoders } from './custom-coder';
import { Input } from './input';
import { Logging } from './logging';
-import { EncodedPacketSink, PacketRetrievalOptions } from './media-sink';
import {
assert,
extractRotationFromMatrix,
@@ -19,14 +18,17 @@ import {
isThenable,
matrixIsFlipped,
MaybePromise,
+ MaybeRelevantPromise,
Rational,
+ ResultValue,
roundToDivisor,
simplifyRational,
TransformationMatrix,
} from './misc';
import { TrackType } from './output';
-import { EncodedPacket, PacketType } from './packet';
+import { EncodedPacket, PacketRetrievalOptions, PacketRetrievalResult, PacketType } from './packet';
import { TrackDisposition } from './metadata';
+import { PacketCursor } from './cursors';
import { DurationMetadataRequestOptions } from './demuxer';
/**
@@ -134,13 +136,34 @@ export interface InputTrackBacking {
getDecoderConfig(): Promise;
getMetadataCodecParameterString?(): MaybePromise;
- getFirstPacket(options: PacketRetrievalOptions): Promise;
- getPacket(timestamp: number, options: PacketRetrievalOptions): Promise;
- getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions): Promise;
- getKeyPacket(timestamp: number, options: PacketRetrievalOptions): Promise;
- getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions): Promise;
+ getFirstPacket(
+ res: ResultValue,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise;
+ getNextPacket(
+ res: ResultValue,
+ packet: EncodedPacket,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise;
+ getPacket(
+ res: ResultValue,
+ timestamp: number,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise;
+ getKeyPacket(
+ res: ResultValue,
+ timestamp: number,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise;
+ getNextKeyPacket(
+ res: ResultValue,
+ packet: EncodedPacket,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise;
}
+let nextInputTrackId = 0;
+
/**
* Represents a media track in an input file.
* @group Input files & tracks
@@ -151,6 +174,9 @@ export abstract class InputTrack {
readonly input: Input;
/** @internal */
_backing: InputTrackBacking;
+ /** @internal */
+ _uniqueId = nextInputTrackId++;
+
/** @internal */
constructor(input: Input, backing: InputTrackBacking) {
this.input = input;
@@ -342,7 +368,9 @@ export abstract class InputTrack {
* with a negative timestamp should not be presented.
*/
async getFirstTimestamp() {
- const firstPacket = await this._backing.getFirstPacket({ metadataOnly: true });
+ const resultValue = new ResultValue();
+ await this._backing.getFirstPacket(resultValue, { metadataOnly: true });
+ const firstPacket = resultValue.value.packet;
return firstPacket?.timestamp ?? 0;
}
@@ -354,7 +382,9 @@ export abstract class InputTrack {
* in the options.
*/
async computeDuration(options?: PacketRetrievalOptions) {
- const lastPacket = await this._backing.getPacket(Infinity, { metadataOnly: true, ...options });
+ const resultValue = new ResultValue();
+ await this._backing.getPacket(resultValue, Infinity, { metadataOnly: true, ...options });
+ const lastPacket = resultValue.value.packet;
const result = (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0);
return roundToDivisor(result, await this.getTimeResolution());
@@ -387,20 +417,20 @@ export abstract class InputTrack {
* {@link PacketRetrievalOptions.skipLiveWait} to `true` in the options.
*/
async computePacketStats(targetPacketCount = Infinity, options?: PacketRetrievalOptions): Promise {
- const sink = new EncodedPacketSink(this);
+ const cursor = new PacketCursor(this, { options: { ...options, metadataOnly: true } });
let startTimestamp = Infinity;
let endTimestamp = -Infinity;
let packetCount = 0;
let totalPacketBytes = 0;
- for await (const packet of sink.packets(undefined, undefined, { metadataOnly: true, ...options })) {
+ await cursor.iterate((packet) => {
if (
packetCount >= targetPacketCount
// This additional condition is needed to produce correct results with out-of-presentation-order packets
&& packet.timestamp >= endTimestamp
) {
- break;
+ return false;
}
startTimestamp = Math.min(startTimestamp, packet.timestamp);
@@ -408,7 +438,7 @@ export abstract class InputTrack {
packetCount++;
totalPacketBytes += packet.byteLength;
- }
+ });
return {
packetCount,
@@ -914,25 +944,25 @@ export class InputVideoTrack extends InputTrack {
const timeResolution = await this.getTimeResolution();
const targetPacketCount = options.targetPacketCount ?? 256;
- const sink = new EncodedPacketSink(this);
+ const cursor = new PacketCursor(this, { options: { metadataOnly: true } });
const timestamps: number[] = [];
let maxTimestamp = -Infinity;
let probedPacketCount = 0;
- for await (const packet of sink.packets(undefined, undefined, { metadataOnly: true })) {
+ await cursor.iterate((packet) => {
if (
timestamps.length >= targetPacketCount
// Needed for out-of-presentation-order packets.
&& packet.timestamp >= maxTimestamp
) {
- break;
+ return false;
}
timestamps.push(packet.timestamp);
maxTimestamp = Math.max(maxTimestamp, packet.timestamp);
probedPacketCount++;
- }
+ });
const ticks = new Float64Array(timestamps.length);
diff --git a/src/input.ts b/src/input.ts
index a58fd76e6..05937658f 100644
--- a/src/input.ts
+++ b/src/input.ts
@@ -8,6 +8,7 @@
import { Demuxer, DurationMetadataRequestOptions } from './demuxer';
import { InputFormat, InputFormatOptions, validateInputFormatOptions } from './input-format';
+import { SampleCursor } from './cursors';
import {
InputAudioTrack,
InputAudioTrackBacking,
@@ -22,7 +23,6 @@ import {
prefer,
desc,
} from './input-track';
-import { PacketRetrievalOptions } from './media-sink';
import {
arrayArgmin,
arrayCount,
@@ -30,6 +30,7 @@ import {
EventEmitter,
polyfillSymbolDispose,
removeItem,
+ ResultValue,
} from './misc';
import { Reader } from './reader';
import {
@@ -39,6 +40,7 @@ import {
SourceRequest,
sourceRequestsAreEqual,
} from './source';
+import { PacketRetrievalOptions, PacketRetrievalResult } from './packet';
polyfillSymbolDispose();
@@ -120,7 +122,6 @@ export class Input extends EventEmitter
_backingToTrack = new Map();
/** @internal */
_disposed = false;
- /** @internal */
_nextSourceCacheAge = 0;
/** @internal */
_sourceRefs: SourceRef[] = [];
@@ -133,6 +134,10 @@ export class Input extends EventEmitter
promise: Promise;
}[] = [];
+ /** @internal */
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ _openSampleCursors = new Set>();
+
/** @internal */
_formatOptions: InputFormatOptions;
/** @internal */
@@ -337,7 +342,13 @@ export class Input extends EventEmitter
}
// Only count the timestamps of tracks that have at least one packet
- const firstPackets = await Promise.all(filtered.map(x => x._backing.getFirstPacket({ metadataOnly: true })));
+ const firstPackets = await Promise.all(filtered.map(async (x) => {
+ const result = new ResultValue();
+ const promise = x._backing.getFirstPacket(result, { metadataOnly: true });
+ if (result.pending) await promise;
+
+ return result.value.packet;
+ }));
const result = Math.min(...firstPackets.map(x => x?.timestamp ?? Infinity));
return result === Infinity ? 0 : result;
@@ -536,6 +547,10 @@ export class Input extends EventEmitter
.then(demuxer => demuxer.dispose())
.catch(() => {});
}
+
+ for (const cursor of [...this._openSampleCursors]) {
+ void cursor.close();
+ }
}
/**
diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts
index 17d648dc2..e3a5a6926 100644
--- a/src/isobmff/isobmff-demuxer.ts
+++ b/src/isobmff/isobmff-demuxer.ts
@@ -54,7 +54,6 @@ import {
InputTrackBacking,
InputVideoTrackBacking,
} from '../input-track';
-import { PacketRetrievalOptions } from '../media-sink';
import {
assert,
binarySearchExact,
@@ -74,12 +73,14 @@ import {
UNDETERMINED_LANGUAGE,
toDataView,
roundIfAlmostInteger,
+ ResultValue,
+ MaybeRelevantPromise,
hexStringToBytes,
HEX_STRING_REGEX,
EMPTY_COLOR_SPACE,
colorSpaceIsComplete,
} from '../misc';
-import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
+import { EncodedPacket, PacketRetrievalOptions, PacketRetrievalResult, PLACEHOLDER_DATA } from '../packet';
import { buildIsobmffMimeType, parsePsshBoxContents, psshBoxesAreEqual, PsshBox } from './isobmff-misc';
import {
MAX_BOX_HEADER_SIZE,
@@ -687,9 +688,9 @@ export class IsobmffDemuxer extends Demuxer {
return sampleTable;
}
- async readFragment(startPos: number): Promise {
+ async readFragment(res: ResultValue, startPos: number): MaybeRelevantPromise {
if (this.lastReadFragment?.moofOffset === startPos) {
- return this.lastReadFragment;
+ return res.set(this.lastReadFragment);
}
let headerSlice = this.reader.requestSliceRange(startPos, MIN_BOX_HEADER_SIZE, MAX_BOX_HEADER_SIZE);
@@ -775,7 +776,7 @@ export class IsobmffDemuxer extends Demuxer {
}
}
- return fragment;
+ return res.set(fragment);
}
readContiguousBoxes(slice: FileSlice) {
@@ -2857,12 +2858,6 @@ export class IsobmffDemuxer extends Demuxer {
}
abstract class IsobmffTrackBacking implements InputTrackBacking {
- packetToSampleIndex = new WeakMap();
- packetToFragmentLocation = new WeakMap();
-
constructor(public internalTrack: InternalTrack) {}
abstract getType(): TrackType;
@@ -2944,7 +2939,9 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
assert(track.trackBacking);
- const firstPacket = await track.trackBacking.getFirstPacket({ metadataOnly: true });
+ const result = new ResultValue();
+ await track.trackBacking.getFirstPacket(result, { metadataOnly: true });
+ const firstPacket = result.value.packet;
return (firstPacket?.timestamp ?? 0) + track.durationInMediaTimescale / track.timescale;
}
@@ -2952,14 +2949,21 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
return null;
}
- async getFirstPacket(options: PacketRetrievalOptions) {
- const regularPacket = await this.fetchPacketForSampleIndex(0, options);
- if (regularPacket || !this.internalTrack.demuxer.isFragmented) {
+ async getFirstPacket(
+ res: ResultValue,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise {
+ const result = new ResultValue();
+ const promise = this.fetchPacketForSampleIndex(result, 0, options);
+ if (result.pending) await promise;
+
+ if (result.value.packet || !this.internalTrack.demuxer.isFragmented) {
// If there's a non-fragmented packet, always prefer that
- return regularPacket;
+ return res.set(result.value);
}
return this.performFragmentedLookup(
+ res,
null,
(fragment) => {
const trackData = fragment.trackData.get(this.internalTrack.id);
@@ -2988,19 +2992,22 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
return roundIfAlmostInteger(timestamp * this.internalTrack.timescale) + this.internalTrack.editListOffset;
}
- async getPacket(timestamp: number, options: PacketRetrievalOptions) {
+ async getPacket(
+ res: ResultValue,
+ timestamp: number,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise {
const timestampInTimescale = this.mapTimestampIntoTimescale(timestamp);
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
- const sampleIndex = getSampleIndexForTimestamp(sampleTable, timestampInTimescale);
- const regularPacket = await this.fetchPacketForSampleIndex(sampleIndex, options);
-
if (!sampleTableIsEmpty(sampleTable) || !this.internalTrack.demuxer.isFragmented) {
// Prefer the non-fragmented packet
- return regularPacket;
+ const sampleIndex = getSampleIndexForTimestamp(sampleTable, timestampInTimescale);
+ return this.fetchPacketForSampleIndex(res, sampleIndex, options);
}
return this.performFragmentedLookup(
+ res,
null,
(fragment) => {
const trackData = fragment.trackData.get(this.internalTrack.id);
@@ -3025,28 +3032,34 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
);
}
- async getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions) {
- const regularSampleIndex = this.packetToSampleIndex.get(packet);
+ async getNextPacket(
+ res: ResultValue,
+ packet: EncodedPacket,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise {
+ const isFromFragment = packet._internal !== undefined;
+ if (!isFromFragment) {
+ const sampleIndex = packet.sequenceNumber; // sequenceNumber = sampleIndex in this case
+ assert(sampleIndex >= 0);
- if (regularSampleIndex !== undefined) {
- // Prefer the non-fragmented packet
- return this.fetchPacketForSampleIndex(regularSampleIndex + 1, options);
+ return this.fetchPacketForSampleIndex(res, sampleIndex + 1, options);
}
- const locationInFragment = this.packetToFragmentLocation.get(packet);
- if (locationInFragment === undefined) {
- throw new Error('Packet was not created from this track.');
- }
+ const moofOffset = packet._internal;
+ assert(typeof moofOffset === 'number');
+
+ const sampleIndex = packet.sequenceNumber - moofOffset; // since sequenceNumber = moofOffset + sampleIndex
return this.performFragmentedLookup(
- locationInFragment.fragment,
+ res,
+ moofOffset,
(fragment) => {
- if (fragment === locationInFragment.fragment) {
+ if (fragment.moofOffset === moofOffset) {
const trackData = fragment.trackData.get(this.internalTrack.id)!;
- if (locationInFragment.sampleIndex + 1 < trackData.samples.length) {
+ if (sampleIndex + 1 < trackData.samples.length) {
// We can simply take the next sample in the fragment
return {
- sampleIndex: locationInFragment.sampleIndex + 1,
+ sampleIndex: sampleIndex + 1,
correctSampleFound: true,
};
}
@@ -3071,19 +3084,22 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
);
}
- async getKeyPacket(timestamp: number, options: PacketRetrievalOptions) {
+ async getKeyPacket(
+ res: ResultValue,
+ timestamp: number,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise {
const timestampInTimescale = this.mapTimestampIntoTimescale(timestamp);
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
- const sampleIndex = getKeyframeSampleIndexForTimestamp(sampleTable, timestampInTimescale);
- const regularPacket = await this.fetchPacketForSampleIndex(sampleIndex, options);
-
if (!sampleTableIsEmpty(sampleTable) || !this.internalTrack.demuxer.isFragmented) {
// Prefer the non-fragmented packet
- return regularPacket;
+ const sampleIndex = getKeyframeSampleIndexForTimestamp(sampleTable, timestampInTimescale);
+ return this.fetchPacketForSampleIndex(res, sampleIndex, options);
}
return this.performFragmentedLookup(
+ res,
null,
(fragment) => {
const trackData = fragment.trackData.get(this.internalTrack.id);
@@ -3107,27 +3123,34 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
);
}
- async getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions) {
- const regularSampleIndex = this.packetToSampleIndex.get(packet);
- if (regularSampleIndex !== undefined) {
- // Prefer the non-fragmented packet
+ async getNextKeyPacket(
+ res: ResultValue,
+ packet: EncodedPacket,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise {
+ const isFromFragment = packet._internal !== undefined;
+ if (!isFromFragment) {
+ const sampleIndex = packet.sequenceNumber; // sequenceNumber = sampleIndex in this case
+ assert(sampleIndex >= 0);
+
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
- const nextKeyFrameSampleIndex = getNextKeyframeIndexForSample(sampleTable, regularSampleIndex);
- return this.fetchPacketForSampleIndex(nextKeyFrameSampleIndex, options);
+ const nextKeyFrameSampleIndex = getNextKeyframeIndexForSample(sampleTable, sampleIndex);
+ return this.fetchPacketForSampleIndex(res, nextKeyFrameSampleIndex, options);
}
- const locationInFragment = this.packetToFragmentLocation.get(packet);
- if (locationInFragment === undefined) {
- throw new Error('Packet was not created from this track.');
- }
+ const moofOffset = packet._internal;
+ assert(typeof moofOffset === 'number');
+
+ const sampleIndex = packet.sequenceNumber - moofOffset; // since sequenceNumber = moofOffset + sampleIndex
return this.performFragmentedLookup(
- locationInFragment.fragment,
+ res,
+ moofOffset,
(fragment) => {
- if (fragment === locationInFragment.fragment) {
+ if (fragment.moofOffset === moofOffset) {
const trackData = fragment.trackData.get(this.internalTrack.id)!;
const nextKeyFrameIndex = trackData.samples.findIndex(
- (x, i) => x.isKeyFrame && i > locationInFragment.sampleIndex,
+ (x, i) => x.isKeyFrame && i > sampleIndex,
);
if (nextKeyFrameIndex !== -1) {
@@ -3161,15 +3184,19 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
);
}
- private async fetchPacketForSampleIndex(sampleIndex: number, options: PacketRetrievalOptions) {
+ private async fetchPacketForSampleIndex(
+ res: ResultValue,
+ sampleIndex: number,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise {
if (sampleIndex === -1) {
- return null;
+ return res.set(new PacketRetrievalResult(null));
}
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
const sampleInfo = getSampleInfo(sampleTable, sampleIndex);
if (!sampleInfo) {
- return null;
+ return res.set(new PacketRetrievalResult(null));
}
let data: Uint8Array;
@@ -3182,7 +3209,7 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
);
if (isThenable(slice)) slice = await slice;
if (!slice) {
- return null; // Data is outside
+ return res.set(new PacketRetrievalResult(null)); // Data is outside
}
data = readBytes(slice, sampleInfo.sampleSize);
@@ -3221,14 +3248,17 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
sampleInfo.sampleSize,
);
- this.packetToSampleIndex.set(packet, sampleIndex);
-
- return packet;
+ return res.set(new PacketRetrievalResult(packet));
}
- private async fetchPacketInFragment(fragment: Fragment, sampleIndex: number, options: PacketRetrievalOptions) {
+ private async fetchPacketInFragment(
+ res: ResultValue,
+ fragment: Fragment,
+ sampleIndex: number,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise {
if (sampleIndex === -1) {
- return null;
+ return res.set(new PacketRetrievalResult(null));
}
const trackData = fragment.trackData.get(this.internalTrack.id)!;
@@ -3245,7 +3275,7 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
);
if (isThenable(slice)) slice = await slice;
if (!slice) {
- return null; // Data is outside
+ return res.set(new PacketRetrievalResult(null)); // Data is outside
}
data = readBytes(slice, fragmentSample.byteSize);
@@ -3271,15 +3301,16 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
fragmentSample.byteSize,
);
- this.packetToFragmentLocation.set(packet, { fragment, sampleIndex });
+ packet._internal = fragment.moofOffset;
- return packet;
+ return res.set(new PacketRetrievalResult(packet));
}
/** Looks for a packet in the fragments while trying to load as few fragments as possible to retrieve it. */
private async performFragmentedLookup(
- // The fragment where we start looking
- startFragment: Fragment | null,
+ res: ResultValue,
+ // The position where we'll start the lookup
+ startOffset: number | null,
// This function returns the best-matching sample in a given fragment
getMatchInFragment: (fragment: Fragment) => { sampleIndex: number; correctSampleFound: boolean },
// The timestamp with which we can search the lookup table
@@ -3287,20 +3318,25 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
// The timestamp for which we know the correct sample will not come after it
latestTimestamp: number,
options: PacketRetrievalOptions,
- ): Promise {
+ ): MaybeRelevantPromise {
const demuxer = this.internalTrack.demuxer;
+ let currentPos = startOffset ?? 0;
let currentFragment: Fragment | null = null;
let bestFragment: Fragment | null = null;
let bestSampleIndex = -1;
- if (startFragment) {
+ if (startOffset !== null && demuxer.lastReadFragment?.moofOffset === startOffset) {
+ const startFragment = demuxer.lastReadFragment;
const { sampleIndex, correctSampleFound } = getMatchInFragment(startFragment);
if (correctSampleFound) {
- return this.fetchPacketInFragment(startFragment, sampleIndex, options);
+ return this.fetchPacketInFragment(res, startFragment, sampleIndex, options);
}
+ currentPos = startFragment.moofOffset + startFragment.moofSize; // Start reading from the next fragment
+ currentFragment = startFragment;
+
if (sampleIndex !== -1) {
bestFragment = startFragment;
bestSampleIndex = sampleIndex;
@@ -3332,18 +3368,10 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
positionCacheEntry?.moofOffset ?? 0,
) || null;
- let currentPos: number;
-
- if (!startFragment) {
- currentPos = lookupEntryPosition ?? 0;
- } else {
- if (lookupEntryPosition === null || startFragment.moofOffset >= lookupEntryPosition) {
- currentPos = startFragment.moofOffset + startFragment.moofSize;
- currentFragment = startFragment;
- } else {
- // Use the lookup entry
- currentPos = lookupEntryPosition;
- }
+ if (lookupEntryPosition !== null && lookupEntryPosition > currentPos) {
+ // Use the lookup entry
+ currentPos = lookupEntryPosition;
+ currentFragment = null;
}
while (true) {
@@ -3367,10 +3395,15 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
}
if (boxInfo.name === 'moof') {
- currentFragment = await demuxer.readFragment(boxStartPos);
+ const result = new ResultValue();
+ const promise = demuxer.readFragment(result, boxStartPos);
+ if (result.pending) await promise;
+
+ currentFragment = result.value;
+
const { sampleIndex, correctSampleFound } = getMatchInFragment(currentFragment);
if (correctSampleFound) {
- return this.fetchPacketInFragment(currentFragment, sampleIndex, options);
+ return this.fetchPacketInFragment(res, currentFragment, sampleIndex, options);
}
if (sampleIndex !== -1) {
bestFragment = currentFragment;
@@ -3390,6 +3423,7 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
const newSearchTimestamp = previousLookupEntry?.timestamp ?? -Infinity;
return this.performFragmentedLookup(
+ res,
null,
getMatchInFragment,
newSearchTimestamp,
@@ -3400,10 +3434,10 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
if (bestFragment) {
// If we finished looping but didn't find a perfect match, still return the best match we found
- return this.fetchPacketInFragment(bestFragment, bestSampleIndex, options);
+ return this.fetchPacketInFragment(res, bestFragment, bestSampleIndex, options);
}
- return null;
+ return res.set(new PacketRetrievalResult(null));
}
}
@@ -3470,12 +3504,20 @@ class IsobmffVideoTrackBacking extends IsobmffTrackBacking implements InputVideo
}
return this.decoderConfigPromise ??= (async (): Promise => {
+ const getFirstPacket = async () => {
+ const result = new ResultValue();
+ const promise = this.getFirstPacket(result, {});
+ if (result.pending) await promise;
+
+ return result.value.packet;
+ };
+
if (this.internalTrack.info.codec === 'avc' && !this.internalTrack.info.codecDescription) {
- const firstPacket = await this.getFirstPacket({});
+ const firstPacket = await getFirstPacket();
this.internalTrack.info.avcCodecInfo
= firstPacket && extractAvcDecoderConfigurationRecord(firstPacket.data);
} else if (this.internalTrack.info.codec === 'hevc' && !this.internalTrack.info.codecDescription) {
- const firstPacket = await this.getFirstPacket({});
+ const firstPacket = await getFirstPacket();
this.internalTrack.info.hevcCodecInfo
= firstPacket && extractHevcDecoderConfigurationRecord(firstPacket.data);
} else if (
@@ -3486,7 +3528,7 @@ class IsobmffVideoTrackBacking extends IsobmffTrackBacking implements InputVideo
|| !vp9CodecInfoHasColorInfo(this.internalTrack.info.vp9CodecInfo)
)
) {
- const firstPacket = await this.getFirstPacket({});
+ const firstPacket = await getFirstPacket();
const packetInfo = firstPacket && extractVp9CodecInfoFromPacket(firstPacket.data);
if (packetInfo) {
@@ -3508,14 +3550,14 @@ class IsobmffVideoTrackBacking extends IsobmffTrackBacking implements InputVideo
)
)
) {
- const firstPacket = await this.getFirstPacket({});
+ const firstPacket = await getFirstPacket();
const packetInfo = firstPacket && extractAv1CodecInfoFromPacket(firstPacket.data);
if (packetInfo) {
this.internalTrack.info.av1CodecInfo = packetInfo;
}
} else if (this.internalTrack.info.codec === 'prores' && !this.internalTrack.info.proresCodecInfo) {
- const firstPacket = await this.getFirstPacket({});
+ const firstPacket = await getFirstPacket();
this.internalTrack.info.proresCodecInfo
= firstPacket && extractProresCodecInfoFromPacket(firstPacket.data);
}
@@ -3583,7 +3625,11 @@ class IsobmffAudioTrackBacking extends IsobmffTrackBacking implements InputAudio
return this.decoderConfigPromise ??= (async (): Promise => {
if (this.internalTrack.info.codec === 'dts' && !this.internalTrack.info.dtsFormat) {
// Gotta check the packet to determine the DTS variant
- const firstPacket = await this.getFirstPacket({});
+ const result = new ResultValue();
+ const promise = this.getFirstPacket(result, {});
+ if (result.pending) await promise;
+
+ const firstPacket = result.value.packet;
this.internalTrack.info.dtsFormat = firstPacket && extractDtsFourCcFromPacket(firstPacket.data);
}
@@ -3668,8 +3714,6 @@ type SampleInfo = {
duration: number;
sampleOffset: number;
sampleSize: number;
- chunkOffset: number;
- chunkSize: number;
isKeyFrame: boolean;
};
@@ -3704,20 +3748,13 @@ const getSampleInfo = (sampleTable: SampleTable, sampleIndex: number): SampleInf
const startSampleIndexOfChunk = chunkEntry.startSampleIndex
+ (chunkIndex - chunkEntry.startChunkIndex) * chunkEntry.samplesPerChunk;
- let chunkSize = 0;
let sampleOffset = chunkOffset;
if (sampleTable.sampleSizes.length === 1) {
sampleOffset += sampleSize * (sampleIndex - startSampleIndexOfChunk);
- chunkSize += sampleSize * chunkEntry.samplesPerChunk;
} else {
- for (let i = startSampleIndexOfChunk; i < startSampleIndexOfChunk + chunkEntry.samplesPerChunk; i++) {
- const sampleSize = sampleTable.sampleSizes[i]!;
-
- if (i < sampleIndex) {
- sampleOffset += sampleSize;
- }
- chunkSize += sampleSize;
+ for (let i = startSampleIndexOfChunk; i < sampleIndex; i++) {
+ sampleOffset += sampleTable.sampleSizes[i]!;
}
}
@@ -3740,8 +3777,6 @@ const getSampleInfo = (sampleTable: SampleTable, sampleIndex: number): SampleInf
duration,
sampleOffset,
sampleSize,
- chunkOffset,
- chunkSize,
isKeyFrame: sampleTable.keySampleIndices
? binarySearchExact(sampleTable.keySampleIndices, sampleIndex, x => x) !== -1
: true,
diff --git a/src/isobmff/isobmff-muxer.ts b/src/isobmff/isobmff-muxer.ts
index 6af8668c8..ae40842be 100644
--- a/src/isobmff/isobmff-muxer.ts
+++ b/src/isobmff/isobmff-muxer.ts
@@ -248,7 +248,8 @@ export class IsobmffMuxer extends Muxer {
}
async start() {
- const release = await this.mutex.acquire();
+ using lock = this.mutex.lock();
+ if (lock.pending) await lock.ready;
if (!this.isCmaf) {
this.writer = await this.output._getRootWriter(target => (
@@ -360,8 +361,6 @@ export class IsobmffMuxer extends Muxer {
);
}
}
-
- release();
}
private allTracksAreKnown() {
@@ -666,116 +665,110 @@ export class IsobmffMuxer extends Muxer {
}
async addEncodedVideoPacket(track: OutputVideoTrack, packet: EncodedPacket, meta?: EncodedVideoChunkMetadata) {
- const release = await this.mutex.acquire();
+ using lock = this.mutex.lock();
+ if (lock.pending) await lock.ready;
- try {
- const trackData = this.getVideoTrackData(track, packet, meta);
+ const trackData = this.getVideoTrackData(track, packet, meta);
- let packetData = packet.data;
- if (trackData.info.requiresAnnexBTransformation) {
- const nalUnits = [...iterateNalUnitsInAnnexB(packetData)]
- .map(loc => packetData.subarray(loc.offset, loc.offset + loc.length));
- if (nalUnits.length === 0) {
- // It's not valid Annex B data
- throw new Error(
- 'Failed to transform packet data. Make sure all packets are provided in Annex B format, as'
- + ' specified in ITU-T-REC-H.264 and ITU-T-REC-H.265.',
- );
- }
-
- // We don't strip things like SPS or PPS NALUs here, mainly because they can also appear in the middle
- // of a stream and potentially modify the parameters of it. So, let's just leave them in to be sure.
- packetData = concatNalUnitsInLengthPrefixed(nalUnits, 4);
+ let packetData = packet.data;
+ if (trackData.info.requiresAnnexBTransformation) {
+ const nalUnits = [...iterateNalUnitsInAnnexB(packetData)]
+ .map(loc => packetData.subarray(loc.offset, loc.offset + loc.length));
+ if (nalUnits.length === 0) {
+ // It's not valid Annex B data
+ throw new Error(
+ 'Failed to transform packet data. Make sure all packets are provided in Annex B format, as'
+ + ' specified in ITU-T-REC-H.264 and ITU-T-REC-H.265.',
+ );
}
- this.validateTimestamp(
- trackData.track,
- packet.timestamp,
- packet.type === 'key',
- );
- const internalSample = this.createSampleForTrack(
- trackData,
- packetData,
- packet.timestamp,
- packet.duration,
- packet.type,
- );
-
- await this.registerSample(trackData, internalSample);
- } finally {
- release();
+ // We don't strip things like SPS or PPS NALUs here, mainly because they can also appear in the middle
+ // of a stream and potentially modify the parameters of it. So, let's just leave them in to be sure.
+ packetData = concatNalUnitsInLengthPrefixed(nalUnits, 4);
}
+
+ this.validateTimestamp(
+ trackData.track,
+ packet.timestamp,
+ packet.type === 'key',
+ );
+ const internalSample = this.createSampleForTrack(
+ trackData,
+ packetData,
+ packet.timestamp,
+ packet.duration,
+ packet.type,
+ );
+
+ await this.registerSample(trackData, internalSample);
}
async addEncodedAudioPacket(track: OutputAudioTrack, packet: EncodedPacket, meta?: EncodedAudioChunkMetadata) {
- const release = await this.mutex.acquire();
+ using lock = this.mutex.lock();
+ if (lock.pending) await lock.ready;
- try {
- const trackData = this.getAudioTrackData(track, packet, meta);
+ const trackData = this.getAudioTrackData(track, packet, meta);
- let packetData = packet.data;
- if (trackData.info.requiresAdtsStripping) {
- const adtsFrame = readAdtsFrameHeader(FileSlice.tempFromBytes(packetData));
- if (!adtsFrame) {
- throw new Error('Expected ADTS frame, didn\'t get one.');
- }
-
- const headerLength = adtsFrame.crcCheck === null
- ? MIN_ADTS_FRAME_HEADER_SIZE
- : MAX_ADTS_FRAME_HEADER_SIZE;
- packetData = packetData.subarray(headerLength);
+ let packetData = packet.data;
+ if (trackData.info.requiresAdtsStripping) {
+ const adtsFrame = readAdtsFrameHeader(FileSlice.tempFromBytes(packetData));
+ if (!adtsFrame) {
+ throw new Error('Expected ADTS frame, didn\'t get one.');
}
- this.validateTimestamp(
- trackData.track,
- packet.timestamp,
- packet.type === 'key',
- );
+ const headerLength = adtsFrame.crcCheck === null
+ ? MIN_ADTS_FRAME_HEADER_SIZE
+ : MAX_ADTS_FRAME_HEADER_SIZE;
+ packetData = packetData.subarray(headerLength);
+ }
- let timestamp = packet.timestamp;
- let duration = packet.duration;
+ this.validateTimestamp(
+ trackData.track,
+ packet.timestamp,
+ packet.type === 'key',
+ );
- if (trackData.info.requiresPcmTransformation) {
- // Packets may have only approximate timestamp/duration information, but for our PCM logic, we need it
- // to be precise. So here, we refine the values.
+ let timestamp = packet.timestamp;
+ let duration = packet.duration;
- const pcmInfo = parsePcmCodec(
- trackData.info.decoderConfig.codec as PcmAudioCodec,
- );
- const frameSize = pcmInfo.sampleSize * trackData.info.numberOfChannels;
+ if (trackData.info.requiresPcmTransformation) {
+ // Packets may have only approximate timestamp/duration information, but for our PCM logic, we need it
+ // to be precise. So here, we refine the values.
- // Compute the precise duration
- duration = packetData.byteLength / frameSize / trackData.info.sampleRate;
+ const pcmInfo = parsePcmCodec(
+ trackData.info.decoderConfig.codec as PcmAudioCodec,
+ );
+ const frameSize = pcmInfo.sampleSize * trackData.info.numberOfChannels;
- if (trackData.info.expectedNextPcmPacketTimestamp !== null) {
- const diff = timestamp - trackData.info.expectedNextPcmPacketTimestamp;
- if (diff < 0.01) {
- timestamp = trackData.info.expectedNextPcmPacketTimestamp;
- } else {
- const paddedDuration = await this.padWithSilence(
- trackData,
- trackData.info.expectedNextPcmPacketTimestamp,
- diff,
- );
- timestamp = trackData.info.expectedNextPcmPacketTimestamp + paddedDuration;
- }
- }
+ // Compute the precise duration
+ duration = packetData.byteLength / frameSize / trackData.info.sampleRate;
- trackData.info.expectedNextPcmPacketTimestamp = timestamp + duration;
+ if (trackData.info.expectedNextPcmPacketTimestamp !== null) {
+ const diff = timestamp - trackData.info.expectedNextPcmPacketTimestamp;
+ if (diff < 0.01) {
+ timestamp = trackData.info.expectedNextPcmPacketTimestamp;
+ } else {
+ const paddedDuration = await this.padWithSilence(
+ trackData,
+ trackData.info.expectedNextPcmPacketTimestamp,
+ diff,
+ );
+ timestamp = trackData.info.expectedNextPcmPacketTimestamp + paddedDuration;
+ }
}
- const internalSample = this.createSampleForTrack(
- trackData,
- packetData,
- timestamp,
- duration,
- packet.type,
- );
-
- await this.registerSample(trackData, internalSample);
- } finally {
- release();
+ trackData.info.expectedNextPcmPacketTimestamp = timestamp + duration;
}
+
+ const internalSample = this.createSampleForTrack(
+ trackData,
+ packetData,
+ timestamp,
+ duration,
+ packet.type,
+ );
+
+ await this.registerSample(trackData, internalSample);
}
private async padWithSilence(trackData: IsobmffAudioTrackData, timestamp: number, duration: number) {
@@ -803,21 +796,18 @@ export class IsobmffMuxer extends Muxer {
}
async addSubtitleCue(track: OutputSubtitleTrack, cue: SubtitleCue, meta?: SubtitleMetadata) {
- const release = await this.mutex.acquire();
+ using lock = this.mutex.lock();
+ if (lock.pending) await lock.ready;
- try {
- const trackData = this.getSubtitleTrackData(track, meta);
+ const trackData = this.getSubtitleTrackData(track, meta);
- this.validateTimestamp(trackData.track, cue.timestamp, true);
+ this.validateTimestamp(trackData.track, cue.timestamp, true);
- if (track.source._codec === 'webvtt') {
- trackData.cueQueue.push(cue);
- await this.processWebVTTCues(trackData, cue.timestamp);
- } else {
- // TODO
- }
- } finally {
- release();
+ if (track.source._codec === 'webvtt') {
+ trackData.cueQueue.push(cue);
+ await this.processWebVTTCues(trackData, cue.timestamp);
+ } else {
+ // TODO
}
}
@@ -1486,7 +1476,8 @@ export class IsobmffMuxer extends Muxer {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
override async onTrackClose(track: OutputTrack) {
- const release = await this.mutex.acquire();
+ using lock = this.mutex.lock();
+ if (lock.pending) await lock.ready;
const trackData = this.trackDatas.find(x => x.track === track);
if (trackData) {
@@ -1507,8 +1498,6 @@ export class IsobmffMuxer extends Muxer {
// Since a track is now closed, we may be able to write out chunks that were previously waiting
await this.interleaveSamples();
}
-
- release();
}
ensureOneEnabledTrack() {
@@ -1537,27 +1526,25 @@ export class IsobmffMuxer extends Muxer {
async forceFragmentFinalization() {
assert(this.isFragmented);
- const release = await this.mutex.acquire();
-
- try {
- for (const trackData of this.trackDatas) {
- if (trackData.type === 'subtitle' && trackData.track.source._codec === 'webvtt') {
- await this.processWebVTTCues(trackData, Infinity);
- }
+ using lock = this.mutex.lock();
+ if (lock.pending) await lock.ready;
- this.processTimestamps(trackData);
+ for (const trackData of this.trackDatas) {
+ if (trackData.type === 'subtitle' && trackData.track.source._codec === 'webvtt') {
+ await this.processWebVTTCues(trackData, Infinity);
}
- await this.interleaveSamples(true);
- await this.finalizeFragment();
- } finally {
- release();
+ this.processTimestamps(trackData);
}
+
+ await this.interleaveSamples(true);
+ await this.finalizeFragment();
}
/** Finalizes the file, making it ready for use. Must be called after all video and audio chunks have been added. */
async finalize() {
- const release = await this.mutex.acquire();
+ using lock = this.mutex.lock();
+ if (lock.pending) await lock.ready;
this.allTracksKnown.resolve();
this.ensureOneEnabledTrack();
@@ -1755,7 +1742,5 @@ export class IsobmffMuxer extends Muxer {
this.formatOptions.onMoov(data, start);
}
}
-
- release();
}
}
diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts
index f0524c32f..1c43b99ad 100644
--- a/src/matroska/matroska-demuxer.ts
+++ b/src/matroska/matroska-demuxer.ts
@@ -37,7 +37,6 @@ import {
InputVideoTrackBacking,
} from '../input-track';
import { AttachedFile, DEFAULT_TRACK_DISPOSITION, MetadataTags, TrackDisposition } from '../metadata';
-import { PacketRetrievalOptions } from '../media-sink';
import {
assert,
binarySearchLessOrEqual,
@@ -49,10 +48,12 @@ import {
isThenable,
last,
MATRIX_COEFFICIENTS_MAP_INVERSE,
+ MaybeRelevantPromise,
centeredTransformationMatrix,
DEG_TO_RAD,
multiplyMatrices,
normalizeRotation,
+ ResultValue,
Rotation,
rotationMatrix,
roundIfAlmostInteger,
@@ -62,7 +63,13 @@ import {
TRANSFER_CHARACTERISTICS_MAP_INVERSE,
UNDETERMINED_LANGUAGE,
} from '../misc';
-import { EncodedPacket, EncodedPacketSideData, PLACEHOLDER_DATA } from '../packet';
+import {
+ EncodedPacket,
+ EncodedPacketSideData,
+ PacketRetrievalOptions,
+ PacketRetrievalResult,
+ PLACEHOLDER_DATA,
+} from '../packet';
import {
assertDefinedSize,
CODEC_STRING_MAP,
@@ -654,9 +661,9 @@ export class MatroskaDemuxer extends Demuxer {
this.currentSegment = null;
}
- async readCluster(startPos: number, segment: Segment) {
+ async readCluster(res: ResultValue, startPos: number, segment: Segment): MaybeRelevantPromise {
if (segment.lastReadCluster?.elementStartPos === startPos) {
- return segment.lastReadCluster;
+ return res.set(segment.lastReadCluster);
}
let headerSlice = this.reader.requestSliceRange(startPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
@@ -786,7 +793,7 @@ export class MatroskaDemuxer extends Demuxer {
}
segment.lastReadCluster = cluster;
- return cluster;
+ return res.set(cluster);
}
getTrackDataInCluster(cluster: Cluster, trackNumber: number) {
@@ -2031,11 +2038,6 @@ export class MatroskaDemuxer extends Demuxer {
}
abstract class MatroskaTrackBacking implements InputTrackBacking {
- packetToClusterLocation = new WeakMap();
-
constructor(public internalTrack: InternalTrack) {}
abstract getType(): TrackType;
@@ -2117,8 +2119,12 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
return null;
}
- async getFirstPacket(options: PacketRetrievalOptions) {
+ async getFirstPacket(
+ res: ResultValue,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise {
return this.performClusterLookup(
+ res,
null,
(cluster) => {
const trackData = cluster.trackData.get(this.internalTrack.id);
@@ -2147,10 +2153,15 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
return roundIfAlmostInteger(timestamp * this.internalTrack.segment.timestampFactor);
}
- async getPacket(timestamp: number, options: PacketRetrievalOptions) {
+ async getPacket(
+ res: ResultValue,
+ timestamp: number,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise {
const timestampInTimescale = this.intoTimescale(timestamp);
return this.performClusterLookup(
+ res,
null,
(cluster) => {
const trackData = cluster.trackData.get(this.internalTrack.id);
@@ -2175,21 +2186,27 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
);
}
- async getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions) {
- const locationInCluster = this.packetToClusterLocation.get(packet);
- if (locationInCluster === undefined) {
- throw new Error('Packet was not created from this track.');
- }
+ async getNextPacket(
+ res: ResultValue,
+ packet: EncodedPacket,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise {
+ const clusterStartPos = packet._internal;
+ assert(typeof clusterStartPos === 'number');
+
+ // sequenceNumber = clusterStartPos + blockIndex, so:
+ const blockIndex = packet.sequenceNumber - clusterStartPos;
return this.performClusterLookup(
- locationInCluster.cluster,
+ res,
+ clusterStartPos,
(cluster) => {
- if (cluster === locationInCluster.cluster) {
+ if (cluster.elementStartPos === clusterStartPos) {
const trackData = cluster.trackData.get(this.internalTrack.id)!;
- if (locationInCluster.blockIndex + 1 < trackData.blocks.length) {
+ if (blockIndex + 1 < trackData.blocks.length) {
// We can simply take the next block in the cluster
return {
- blockIndex: locationInCluster.blockIndex + 1,
+ blockIndex: blockIndex + 1,
correctBlockFound: true,
};
}
@@ -2214,10 +2231,15 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
);
}
- async getKeyPacket(timestamp: number, options: PacketRetrievalOptions) {
+ async getKeyPacket(
+ res: ResultValue,
+ timestamp: number,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise {
const timestampInTimescale = this.intoTimescale(timestamp);
return this.performClusterLookup(
+ res,
null,
(cluster) => {
const trackData = cluster.trackData.get(this.internalTrack.id);
@@ -2241,19 +2263,25 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
);
}
- async getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions) {
- const locationInCluster = this.packetToClusterLocation.get(packet);
- if (locationInCluster === undefined) {
- throw new Error('Packet was not created from this track.');
- }
+ async getNextKeyPacket(
+ res: ResultValue,
+ packet: EncodedPacket,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise {
+ const clusterStartPos = packet._internal;
+ assert(typeof clusterStartPos === 'number');
+
+ // sequenceNumber = clusterStartPos + blockIndex, so:
+ const blockIndex = packet.sequenceNumber - clusterStartPos;
return this.performClusterLookup(
- locationInCluster.cluster,
+ res,
+ clusterStartPos,
(cluster) => {
- if (cluster === locationInCluster.cluster) {
+ if (cluster.elementStartPos === clusterStartPos) {
const trackData = cluster.trackData.get(this.internalTrack.id)!;
const nextKeyFrameIndex = trackData.blocks.findIndex(
- (x, i) => x.isKeyFrame && i > locationInCluster.blockIndex,
+ (x, i) => x.isKeyFrame && i > blockIndex,
);
if (nextKeyFrameIndex !== -1) {
@@ -2287,7 +2315,11 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
);
}
- private async fetchPacketInCluster(cluster: Cluster, blockIndex: number, options: PacketRetrievalOptions) {
+ private fetchPacketInCluster(
+ cluster: Cluster,
+ blockIndex: number,
+ options: PacketRetrievalOptions,
+ ) {
if (blockIndex === -1) {
return null;
}
@@ -2346,20 +2378,21 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
block.isKeyFrame ? 'key' : 'delta',
timestamp,
duration,
- cluster.dataStartPos + blockIndex,
+ cluster.elementStartPos + blockIndex,
block.data.byteLength,
sideData,
);
- this.packetToClusterLocation.set(packet, { cluster, blockIndex });
+ packet._internal = cluster.elementStartPos;
return packet;
}
/** Looks for a packet in the clusters while trying to load as few clusters as possible to retrieve it. */
private async performClusterLookup(
- // The cluster where we start looking
- startCluster: Cluster | null,
+ res: ResultValue,
+ // The position where we'll start the lookup
+ startOffset: number | null,
// This function returns the best-matching block in a given cluster
getMatchInCluster: (cluster: Cluster) => { blockIndex: number; correctBlockFound: boolean },
// The timestamp with which we can search the lookup table
@@ -2367,20 +2400,25 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
// The timestamp for which we know the correct block will not come after it
latestTimestamp: number,
options: PacketRetrievalOptions,
- ): Promise {
+ ): MaybeRelevantPromise {
const { demuxer, segment } = this.internalTrack;
+ let currentPos = startOffset ?? 0;
let currentCluster: Cluster | null = null;
let bestCluster: Cluster | null = null;
let bestBlockIndex = -1;
- if (startCluster) {
+ if (startOffset !== null && segment.lastReadCluster?.elementStartPos === startOffset) {
+ const startCluster = segment.lastReadCluster;
const { blockIndex, correctBlockFound } = getMatchInCluster(startCluster);
if (correctBlockFound) {
- return this.fetchPacketInCluster(startCluster, blockIndex, options);
+ return res.set(new PacketRetrievalResult(this.fetchPacketInCluster(startCluster, blockIndex, options)));
}
+ currentPos = startCluster.elementEndPos; // Start reading from the next cluster
+ currentCluster = startCluster;
+
if (blockIndex !== -1) {
bestCluster = startCluster;
bestBlockIndex = blockIndex;
@@ -2413,18 +2451,14 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
positionCacheEntry?.elementStartPos ?? 0,
) || null;
- let currentPos: number;
+ if (lookupEntryPosition !== null && lookupEntryPosition > currentPos) {
+ // Use the lookup entry
+ currentPos = lookupEntryPosition;
+ currentCluster = null;
+ }
- if (!startCluster) {
+ if (startOffset === null) {
currentPos = lookupEntryPosition ?? segment.clusterSeekStartPos;
- } else {
- if (lookupEntryPosition === null || startCluster.elementStartPos >= lookupEntryPosition) {
- currentPos = startCluster.elementEndPos;
- currentCluster = startCluster;
- } else {
- // Use the lookup entry
- currentPos = lookupEntryPosition;
- }
}
while (segment.elementEndPos === null || currentPos <= segment.elementEndPos - MIN_HEADER_SIZE) {
@@ -2470,13 +2504,20 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
const dataStartPos = slice.filePos;
if (id === EBMLId.Cluster) {
- currentCluster = await demuxer.readCluster(elementStartPos, segment);
+ const result = new ResultValue();
+ const promise = demuxer.readCluster(result, elementStartPos, segment);
+ if (result.pending) await promise;
+
+ currentCluster = result.value;
+
// readCluster computes the proper size even if it's undefined in the header, so let's use that instead
size = currentCluster.elementEndPos - dataStartPos;
const { blockIndex, correctBlockFound } = getMatchInCluster(currentCluster);
if (correctBlockFound) {
- return this.fetchPacketInCluster(currentCluster, blockIndex, options);
+ return res.set(
+ new PacketRetrievalResult(this.fetchPacketInCluster(currentCluster, blockIndex, options)),
+ );
}
if (blockIndex !== -1) {
@@ -2530,15 +2571,17 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
assert(!previousCuePoint || previousCuePoint.time < cuePoint.time);
const newSearchTimestamp = previousCuePoint?.time ?? -Infinity;
- return this.performClusterLookup(null, getMatchInCluster, newSearchTimestamp, latestTimestamp, options);
+ return this.performClusterLookup(
+ res, null, getMatchInCluster, newSearchTimestamp, latestTimestamp, options,
+ );
}
if (bestCluster) {
// If we finished looping but didn't find a perfect match, still return the best match we found
- return this.fetchPacketInCluster(bestCluster, bestBlockIndex, options);
+ return res.set(new PacketRetrievalResult(this.fetchPacketInCluster(bestCluster, bestBlockIndex, options)));
}
- return null;
+ return res.set(new PacketRetrievalResult(null));
}
}
@@ -2626,7 +2669,11 @@ class MatroskaVideoTrackBacking extends MatroskaTrackBacking implements InputVid
|| (this.internalTrack.info.codec === 'hevc' && !this.internalTrack.info.codecDescription);
if (needsPacketForAdditionalInfo) {
- firstPacket = await this.getFirstPacket({});
+ const result = new ResultValue();
+ const promise = this.getFirstPacket(result, {});
+ if (result.pending) await promise;
+
+ firstPacket = result.value.packet;
}
const codecInfo = {
@@ -2717,7 +2764,11 @@ class MatroskaAudioTrackBacking extends MatroskaTrackBacking implements InputAud
return this.decoderConfigPromise ??= (async (): Promise => {
if (this.internalTrack.info.codec === 'dts' && !this.internalTrack.info.dtsFormat) {
// Gotta check the packet to determine the DTS variant
- const firstPacket = await this.getFirstPacket({});
+ const result = new ResultValue();
+ const promise = this.getFirstPacket(result, {});
+ if (result.pending) await promise;
+
+ const firstPacket = result.value.packet;
this.internalTrack.info.dtsFormat = firstPacket && extractDtsFourCcFromPacket(firstPacket.data);
}
diff --git a/src/matroska/matroska-muxer.ts b/src/matroska/matroska-muxer.ts
index a0f5fcc57..d969d716b 100644
--- a/src/matroska/matroska-muxer.ts
+++ b/src/matroska/matroska-muxer.ts
@@ -175,7 +175,8 @@ export class MatroskaMuxer extends Muxer {
}
async start() {
- const release = await this.mutex.acquire();
+ using lock = this.mutex.lock();
+ if (lock.pending) await lock.ready;
this.writer = await this.output._getRootWriter(!!this.format._options.appendOnly);
this.ebmlWriter = new EBMLWriter(this.writer);
@@ -202,8 +203,6 @@ export class MatroskaMuxer extends Muxer {
);
}
}
-
- release();
}
private writeEBMLHeader() {
@@ -983,115 +982,106 @@ export class MatroskaMuxer extends Muxer {
}
async addEncodedVideoPacket(track: OutputVideoTrack, packet: EncodedPacket, meta?: EncodedVideoChunkMetadata) {
- const release = await this.mutex.acquire();
-
- try {
- const trackData = this.getVideoTrackData(track, packet, meta);
- trackData.info.alphaMode ??= !!packet.sideData.alpha;
+ using lock = this.mutex.lock();
+ if (lock.pending) await lock.ready;
- let packetData = packet.data;
- if (track.source._codec === 'prores') {
- if (packetData.byteLength < 8) {
- throw new Error('ProRes packet too small, expected at least 8 bytes.');
- }
+ const trackData = this.getVideoTrackData(track, packet, meta);
+ trackData.info.alphaMode ??= !!packet.sideData.alpha;
- // Trim off the frame container atom header. FFmpeg does this too and cites the "Matroska spec" as the
- // reason, despite the spec not saying anything about this.
- packetData = packetData.subarray(8);
+ let packetData = packet.data;
+ if (track.source._codec === 'prores') {
+ if (packetData.byteLength < 8) {
+ throw new Error('ProRes packet too small, expected at least 8 bytes.');
}
- const isKeyFrame = packet.type === 'key';
- this.validateTimestamp(trackData.track, packet.timestamp, isKeyFrame);
+ // Trim off the frame container atom header. FFmpeg does this too and cites the "Matroska spec" as the
+ // reason, despite the spec not saying anything about this.
+ packetData = packetData.subarray(8);
+ }
- let timestamp = packet.timestamp;
- let duration = packet.duration;
+ const isKeyFrame = packet.type === 'key';
+ this.validateTimestamp(trackData.track, packet.timestamp, isKeyFrame);
- if (track.metadata.frameRate !== undefined) {
- // Constrain the time values to the frame rate
- timestamp = roundToDivisor(timestamp, track.metadata.frameRate);
- duration = roundToDivisor(duration, track.metadata.frameRate);
- }
+ let timestamp = packet.timestamp;
+ let duration = packet.duration;
- const additions = trackData.info.alphaMode
- ? packet.sideData.alpha ?? null
- : null;
+ if (track.metadata.frameRate !== undefined) {
+ // Constrain the time values to the frame rate
+ timestamp = roundToDivisor(timestamp, track.metadata.frameRate);
+ duration = roundToDivisor(duration, track.metadata.frameRate);
+ }
- const videoChunk = this.createInternalChunk(packetData, timestamp, duration, packet.type, additions);
- if (track.source._codec === 'vp9') this.fixVP9ColorSpace(trackData, videoChunk);
+ const additions = trackData.info.alphaMode
+ ? packet.sideData.alpha ?? null
+ : null;
- trackData.chunkQueue.push(videoChunk);
- await this.interleaveChunks();
- } finally {
- release();
- }
+ const videoChunk = this.createInternalChunk(packetData, timestamp, duration, packet.type, additions);
+ if (track.source._codec === 'vp9') this.fixVP9ColorSpace(trackData, videoChunk);
+
+ trackData.chunkQueue.push(videoChunk);
+ await this.interleaveChunks();
}
async addEncodedAudioPacket(track: OutputAudioTrack, packet: EncodedPacket, meta?: EncodedAudioChunkMetadata) {
- const release = await this.mutex.acquire();
+ using lock = this.mutex.lock();
+ if (lock.pending) await lock.ready;
- try {
- const trackData = this.getAudioTrackData(track, packet, meta);
+ const trackData = this.getAudioTrackData(track, packet, meta);
- let packetData = packet.data;
- if (trackData.info.requiresAdtsStripping) {
- const adtsFrame = readAdtsFrameHeader(FileSlice.tempFromBytes(packetData));
- if (!adtsFrame) {
- throw new Error('Expected ADTS frame, didn\'t get one.');
- }
-
- const headerLength = adtsFrame.crcCheck === null
- ? MIN_ADTS_FRAME_HEADER_SIZE
- : MAX_ADTS_FRAME_HEADER_SIZE;
- packetData = packetData.subarray(headerLength);
+ let packetData = packet.data;
+ if (trackData.info.requiresAdtsStripping) {
+ const adtsFrame = readAdtsFrameHeader(FileSlice.tempFromBytes(packetData));
+ if (!adtsFrame) {
+ throw new Error('Expected ADTS frame, didn\'t get one.');
}
- const isKeyFrame = packet.type === 'key';
- this.validateTimestamp(trackData.track, packet.timestamp, isKeyFrame);
-
- const audioChunk = this.createInternalChunk(packetData, packet.timestamp, packet.duration, packet.type);
- trackData.chunkQueue.push(audioChunk);
- await this.interleaveChunks();
- } finally {
- release();
+ const headerLength = adtsFrame.crcCheck === null
+ ? MIN_ADTS_FRAME_HEADER_SIZE
+ : MAX_ADTS_FRAME_HEADER_SIZE;
+ packetData = packetData.subarray(headerLength);
}
+
+ const isKeyFrame = packet.type === 'key';
+ this.validateTimestamp(trackData.track, packet.timestamp, isKeyFrame);
+
+ const audioChunk = this.createInternalChunk(packetData, packet.timestamp, packet.duration, packet.type);
+ trackData.chunkQueue.push(audioChunk);
+ await this.interleaveChunks();
}
async addSubtitleCue(track: OutputSubtitleTrack, cue: SubtitleCue, meta?: SubtitleMetadata) {
- const release = await this.mutex.acquire();
+ using lock = this.mutex.lock();
+ if (lock.pending) await lock.ready;
- try {
- const trackData = this.getSubtitleTrackData(track, meta);
+ const trackData = this.getSubtitleTrackData(track, meta);
- this.validateTimestamp(trackData.track, cue.timestamp, true);
+ this.validateTimestamp(trackData.track, cue.timestamp, true);
- let bodyText = cue.text;
- const timestampMs = Math.round(cue.timestamp * 1000);
+ let bodyText = cue.text;
+ const timestampMs = Math.round(cue.timestamp * 1000);
- // Replace in-body timestamps so that they're relative to the cue start time
- inlineTimestampRegex.lastIndex = 0;
- bodyText = bodyText.replace(inlineTimestampRegex, (match) => {
- const time = parseSubtitleTimestamp(match.slice(1, -1));
- const offsetTime = time - timestampMs;
+ // Replace in-body timestamps so that they're relative to the cue start time
+ inlineTimestampRegex.lastIndex = 0;
+ bodyText = bodyText.replace(inlineTimestampRegex, (match) => {
+ const time = parseSubtitleTimestamp(match.slice(1, -1));
+ const offsetTime = time - timestampMs;
- return `<${formatSubtitleTimestamp(offsetTime)}>`;
- });
+ return `<${formatSubtitleTimestamp(offsetTime)}>`;
+ });
- const body = textEncoder.encode(bodyText);
- const additions = `${cue.settings ?? ''}\n${cue.identifier ?? ''}\n${cue.notes ?? ''}`;
+ const body = textEncoder.encode(bodyText);
+ const additions = `${cue.settings ?? ''}\n${cue.identifier ?? ''}\n${cue.notes ?? ''}`;
- const subtitleChunk = this.createInternalChunk(
- body,
- cue.timestamp,
- cue.duration,
- 'key',
- additions.trim() ? textEncoder.encode(additions) : null,
- );
+ const subtitleChunk = this.createInternalChunk(
+ body,
+ cue.timestamp,
+ cue.duration,
+ 'key',
+ additions.trim() ? textEncoder.encode(additions) : null,
+ );
- trackData.chunkQueue.push(subtitleChunk);
- await this.interleaveChunks();
- } finally {
- release();
- }
+ trackData.chunkQueue.push(subtitleChunk);
+ await this.interleaveChunks();
}
private async interleaveChunks(isFinalCall = false) {
@@ -1396,7 +1386,8 @@ export class MatroskaMuxer extends Muxer {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
override async onTrackClose(track: OutputTrack) {
- const release = await this.mutex.acquire();
+ using lock = this.mutex.lock();
+ if (lock.pending) await lock.ready;
const trackData = this.trackDatas.find(x => x.track === track);
if (trackData) {
@@ -1409,15 +1400,12 @@ export class MatroskaMuxer extends Muxer {
// Since a track is now closed, we may be able to write out chunks that were previously waiting
await this.interleaveChunks();
-
- release();
}
/** Finalizes the file, making it ready for use. Must be called after all media chunks have been added. */
async finalize() {
- const release = await this.mutex.acquire();
-
- this.allTracksKnown.resolve();
+ using lock = this.mutex.lock();
+ if (lock.pending) await lock.ready;
for (const trackData of this.trackDatas) {
trackData.closed = true;
@@ -1455,7 +1443,5 @@ export class MatroskaMuxer extends Muxer {
this.maybeCreateSeekHead(true);
this.ebmlWriter.writeEBML(this.seekHead);
}
-
- release();
}
}
diff --git a/src/media-sink.ts b/src/media-sink.ts
deleted file mode 100644
index 45e4dd88e..000000000
--- a/src/media-sink.ts
+++ /dev/null
@@ -1,2708 +0,0 @@
-/*!
- * Copyright (c) 2026-present, Vanilagy and contributors
- *
- * This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at https://mozilla.org/MPL/2.0/.
- */
-
-import { parsePcmCodec, PCM_AUDIO_CODECS, PcmAudioCodec, VideoCodec, AudioCodec } from './codec';
-import {
- addAvcBitstreamRestriction,
- AvcNalUnitType,
- concatAvcNalUnits,
- deserializeAvcDecoderConfigurationRecord,
- determineVideoPacketType,
- extractNalUnitTypeForAvc,
- extractNalUnitTypeForHevc,
- HevcNalUnitType,
- iterateAvcNalUnits,
- iterateHevcNalUnits,
- parseAvcSps,
- sanitizeHevcPacketForChromium,
- serializeAvcDecoderConfigurationRecord,
-} from './codec-data';
-import { CustomVideoDecoder, customVideoDecoders, CustomAudioDecoder, customAudioDecoders } from './custom-coder';
-import { InputDisposedError } from './input';
-import { InputAudioTrack, InputTrack, InputVideoTrack } from './input-track';
-import {
- AnyIterable,
- assert,
- assertNever,
- CallSerializer,
- clamp,
- colorSpaceIsComplete,
- getInt24,
- getUint24,
- insertSorted,
- isChromium,
- isFirefox,
- isNumber,
- isWebKit,
- last,
- mapAsyncGenerator,
- missingWebCodecsClassMessage,
- promiseWithResolvers,
- removeItem,
- Rotation,
- toAsyncIterator,
- toDataView,
- toUint8Array,
- validateAnyIterable,
-} from './misc';
-import { EncodedPacket } from './packet';
-import { fromAlaw, fromUlaw } from './pcm';
-import {
- AudioSample,
- clampCropRectangle,
- CropRectangle,
- validateCropRectangle,
- VideoSample,
- VideoSamplePixelFormat,
-} from './sample';
-
-/**
- * Additional options for controlling packet retrieval.
- * @group Media sinks
- * @public
- */
-export type PacketRetrievalOptions = {
- /**
- * When set to `true`, only packet metadata (like timestamp) will be retrieved - the actual packet data will not
- * be loaded.
- */
- metadataOnly?: boolean;
-
- /**
- * When set to `true`, key packets will be verified upon retrieval by looking into the packet's bitstream.
- * If not enabled, the packet types will be determined solely by what's stored in the containing file and may be
- * incorrect, potentially leading to decoder errors. Since determining a packet's actual type requires looking into
- * its data, this option cannot be enabled together with `metadataOnly`.
- */
- verifyKeyPackets?: boolean;
-
- /**
- * When querying packets in live media that are in the future relative to the current live edge, Mediabunny will,
- * by default, wait for the stream to advance until the query can be satisfied. In a sense, Mediabunny simply treats
- * live streams as media files that are still being written, and any read that depends on future information will
- * wait until it can be fulfilled.
- *
- * If you want to query packets based only on the currently known information, set this field to `true` - this way,
- * Mediabunny will never wait for the live stream to catch up.
- *
- * For non-live media, this field has no effect.
- */
- skipLiveWait?: boolean;
-};
-
-const validatePacketRetrievalOptions = (options: PacketRetrievalOptions) => {
- if (!options || typeof options !== 'object') {
- throw new TypeError('options must be an object.');
- }
- if (options.metadataOnly !== undefined && typeof options.metadataOnly !== 'boolean') {
- throw new TypeError('options.metadataOnly, when defined, must be a boolean.');
- }
- if (options.verifyKeyPackets !== undefined && typeof options.verifyKeyPackets !== 'boolean') {
- throw new TypeError('options.verifyKeyPackets, when defined, must be a boolean.');
- }
- if (options.verifyKeyPackets && options.metadataOnly) {
- throw new TypeError('options.verifyKeyPackets and options.metadataOnly cannot be enabled together.');
- }
- if (options.skipLiveWait !== undefined && typeof options.skipLiveWait !== 'boolean') {
- throw new TypeError('options.skipLiveWait, when defined, must be a boolean.');
- }
-};
-
-const validateTimestamp = (timestamp: number) => {
- if (!isNumber(timestamp)) {
- throw new TypeError('timestamp must be a number.'); // It can be non-finite, that's fine
- }
-};
-
-const maybeFixPacketType = (
- track: InputTrack,
- promise: Promise,
- options: PacketRetrievalOptions,
-) => {
- if (options.verifyKeyPackets) {
- return promise.then(async (packet) => {
- if (!packet || packet.type === 'delta') {
- return packet;
- }
-
- const determinedType = await track.determinePacketType(packet);
- if (determinedType) {
- // @ts-expect-error Technically readonly
- packet.type = determinedType;
- }
-
- return packet;
- });
- } else {
- return promise;
- }
-};
-
-/**
- * Sink for retrieving encoded packets from an input track.
- * @group Media sinks
- * @public
- */
-export class EncodedPacketSink {
- /** @internal */
- _track: InputTrack;
-
- /** Creates a new {@link EncodedPacketSink} for the given {@link InputTrack}. */
- constructor(track: InputTrack) {
- if (!(track instanceof InputTrack)) {
- throw new TypeError('track must be an InputTrack.');
- }
-
- this._track = track;
- }
-
- /**
- * Retrieves the track's first packet (in decode order), or null if it has no packets. The first packet is very
- * likely to be a key packet, but it doesn't have to be.
- */
- async getFirstPacket(options: PacketRetrievalOptions = {}) {
- validatePacketRetrievalOptions(options);
-
- if (this._track.input._disposed) {
- throw new InputDisposedError();
- }
-
- return maybeFixPacketType(this._track, this._track._backing.getFirstPacket(options), options);
- }
-
- /** Retrieves the track's first key packet (in decode order), or null if it has no key packets. */
- async getFirstKeyPacket(options: PacketRetrievalOptions = {}) {
- validatePacketRetrievalOptions(options);
-
- const firstPacket = await this.getFirstPacket(options);
- if (!firstPacket) {
- return null;
- }
-
- if (firstPacket.type === 'key') {
- // Great
- return firstPacket;
- }
-
- return this.getNextKeyPacket(firstPacket, options);
- }
-
- /**
- * Retrieves the packet corresponding to the given timestamp, in seconds. More specifically, returns the last packet
- * (in presentation order) with a start timestamp less than or equal to the given timestamp. This method can be
- * used to retrieve a track's last packet using `getPacket(Infinity)`. The method returns null if the timestamp
- * is before the first packet in the track.
- *
- * @param timestamp - The timestamp used for retrieval, in seconds.
- */
- async getPacket(timestamp: number, options: PacketRetrievalOptions = {}) {
- validateTimestamp(timestamp);
- validatePacketRetrievalOptions(options);
-
- if (this._track.input._disposed) {
- throw new InputDisposedError();
- }
-
- return maybeFixPacketType(this._track, this._track._backing.getPacket(timestamp, options), options);
- }
-
- /**
- * Retrieves the packet following the given packet (in decode order), or null if the given packet is the
- * last packet.
- */
- async getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions = {}) {
- if (!(packet instanceof EncodedPacket)) {
- throw new TypeError('packet must be an EncodedPacket.');
- }
- validatePacketRetrievalOptions(options);
-
- if (this._track.input._disposed) {
- throw new InputDisposedError();
- }
-
- return maybeFixPacketType(this._track, this._track._backing.getNextPacket(packet, options), options);
- }
-
- /**
- * Retrieves the key packet corresponding to the given timestamp, in seconds. More specifically, returns the last
- * key packet (in presentation order) with a start timestamp less than or equal to the given timestamp. A key packet
- * is a packet that doesn't require previous packets to be decoded. This method can be used to retrieve a track's
- * last key packet using `getKeyPacket(Infinity)`. The method returns null if the timestamp is before the first
- * key packet in the track.
- *
- * To ensure that the returned packet is guaranteed to be a real key frame, enable `options.verifyKeyPackets`.
- *
- * @param timestamp - The timestamp used for retrieval, in seconds.
- */
- async getKeyPacket(timestamp: number, options: PacketRetrievalOptions = {}): Promise {
- validateTimestamp(timestamp);
- validatePacketRetrievalOptions(options);
-
- if (this._track.input._disposed) {
- throw new InputDisposedError();
- }
-
- if (!options.verifyKeyPackets) {
- return this._track._backing.getKeyPacket(timestamp, options);
- }
-
- const packet = await this._track._backing.getKeyPacket(timestamp, options);
- if (!packet) {
- return packet;
- }
- assert(packet.type === 'key');
-
- const determinedType = await this._track.determinePacketType(packet);
- if (determinedType === 'delta') {
- // Try returning the previous key packet (in hopes that it's actually a key packet)
- return this.getKeyPacket(packet.timestamp - 1 / await this._track.getTimeResolution(), options);
- }
-
- return packet;
- }
-
- /**
- * Retrieves the key packet following the given packet (in decode order), or null if the given packet is the last
- * key packet.
- *
- * To ensure that the returned packet is guaranteed to be a real key frame, enable `options.verifyKeyPackets`.
- */
- async getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions = {}): Promise {
- if (!(packet instanceof EncodedPacket)) {
- throw new TypeError('packet must be an EncodedPacket.');
- }
- validatePacketRetrievalOptions(options);
-
- if (this._track.input._disposed) {
- throw new InputDisposedError();
- }
-
- if (!options.verifyKeyPackets) {
- return this._track._backing.getNextKeyPacket(packet, options);
- }
-
- const nextPacket = await this._track._backing.getNextKeyPacket(packet, options);
- if (!nextPacket) {
- return nextPacket;
- }
- assert(nextPacket.type === 'key');
-
- const determinedType = await this._track.determinePacketType(nextPacket);
- if (determinedType === 'delta') {
- // Try returning the next key packet (in hopes that it's actually a key packet)
- return this.getNextKeyPacket(nextPacket, options);
- }
-
- return nextPacket;
- }
-
- /**
- * Creates an async iterator that yields the packets in this track in decode order. To enable fast iteration, this
- * method will intelligently preload packets based on the speed of the consumer.
- *
- * @param startPacket - (optional) The packet from which iteration should begin. This packet will also be yielded.
- * @param endPacket - (optional) The packet at which iteration should end. This packet will _not_ be yielded.
- */
- packets(
- startPacket?: EncodedPacket,
- endPacket?: EncodedPacket,
- options: PacketRetrievalOptions = {},
- ): AsyncGenerator {
- if (startPacket !== undefined && !(startPacket instanceof EncodedPacket)) {
- throw new TypeError('startPacket must be an EncodedPacket.');
- }
- if (startPacket !== undefined && startPacket.isMetadataOnly && !options?.metadataOnly) {
- throw new TypeError('startPacket can only be metadata-only if options.metadataOnly is enabled.');
- }
- if (endPacket !== undefined && !(endPacket instanceof EncodedPacket)) {
- throw new TypeError('endPacket must be an EncodedPacket.');
- }
- validatePacketRetrievalOptions(options);
-
- if (this._track.input._disposed) {
- throw new InputDisposedError();
- }
-
- const packetQueue: EncodedPacket[] = [];
-
- let { promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers();
- let { promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers();
- let ended = false;
- let terminated = false;
-
- // This stores errors that are "out of band" in the sense that they didn't occur in the normal flow of this
- // method but instead in a different context. This error should not go unnoticed and must be bubbled up to
- // the consumer.
- let outOfBandError = null as unknown;
- let hasOutOfBandError = false;
-
- const timestamps: number[] = [];
- // The queue should always be big enough to hold 1 second worth of packets
- const maxQueueSize = () => Math.max(2, timestamps.length);
-
- // The following is the "pump" process that keeps pumping packets into the queue
- (async () => {
- let packet = startPacket ?? await this.getFirstPacket(options);
-
- while (packet && !terminated && !this._track.input._disposed) {
- if (endPacket && packet.sequenceNumber >= endPacket?.sequenceNumber) {
- break;
- }
-
- if (packetQueue.length > maxQueueSize()) {
- ({ promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers());
- await queueDequeue;
- continue;
- }
-
- packetQueue.push(packet);
-
- onQueueNotEmpty();
- ({ promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers());
-
- packet = await this.getNextPacket(packet, options);
- }
-
- ended = true;
- onQueueNotEmpty();
- })().catch((error) => {
- if (!hasOutOfBandError) {
- outOfBandError = error;
- hasOutOfBandError = true;
- onQueueNotEmpty();
- }
- });
-
- const track = this._track;
-
- return {
- async next() {
- while (true) {
- if (track.input._disposed) {
- throw new InputDisposedError();
- } else if (terminated) {
- return { value: undefined, done: true };
- } else if (hasOutOfBandError) {
- throw outOfBandError;
- } else if (packetQueue.length > 0) {
- const value = packetQueue.shift()!;
- const now = performance.now();
- timestamps.push(now);
-
- while (timestamps.length > 0 && now - timestamps[0]! >= 1000) {
- timestamps.shift();
- }
-
- onQueueDequeue();
-
- return { value, done: false };
- } else if (ended) {
- return { value: undefined, done: true };
- } else {
- await queueNotEmpty;
- }
- }
- },
- async return() {
- terminated = true;
- onQueueDequeue();
- onQueueNotEmpty();
-
- return { value: undefined, done: true };
- },
- async throw(error) {
- throw error;
- },
- [Symbol.asyncIterator]() {
- return this;
- },
- };
- }
-}
-
-abstract class DecoderWrapper<
- MediaSample extends VideoSample | AudioSample,
-> {
- constructor(
- public onSample: (sample: MediaSample) => unknown,
- public onError: (error: unknown) => unknown,
- ) {}
-
- abstract getDecodeQueueSize(): number;
- abstract decode(packet: EncodedPacket): void;
- abstract flush(): Promise;
- abstract close(): void;
-}
-
-/**
- * Base class for decoded media sample sinks.
- * @group Media sinks
- * @public
- */
-export abstract class BaseMediaSampleSink<
- MediaSample extends VideoSample | AudioSample,
-> {
- /** @internal */
- abstract _track: InputTrack;
-
- /** @internal */
- abstract _createDecoder(
- onSample: (sample: MediaSample) => unknown,
- onError: (error: unknown) => unknown
- ): Promise>;
- /** @internal */
- abstract _createPacketSink(): EncodedPacketSink;
-
- /** @internal */
- protected mediaSamplesInRange(
- startTimestamp = -Infinity,
- endTimestamp = Infinity,
- options: PacketRetrievalOptions,
- ): AsyncGenerator {
- validateTimestamp(startTimestamp);
- validateTimestamp(endTimestamp);
-
- const sampleQueue: MediaSample[] = [];
- let firstSampleQueued = false;
- let lastSample: MediaSample | null = null;
- let { promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers();
- let { promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers();
- let decoderIsFlushed = false;
- let ended = false;
- let terminated = false;
- let decoder: DecoderWrapper | null = null;
-
- // This stores errors that are "out of band" in the sense that they didn't occur in the normal flow of this
- // method but instead in a different context. This error should not go unnoticed and must be bubbled up to
- // the consumer.
- let outOfBandError = null as unknown;
- let hasOutOfBandError = false;
-
- const packetRetrievalOptions: PacketRetrievalOptions = {
- ...options,
- verifyKeyPackets: true,
- metadataOnly: false,
- };
-
- // The following is the "pump" process that keeps pumping packets into the decoder
- (async () => {
- decoder = await this._createDecoder((sample) => {
- onQueueDequeue();
- if (sample.timestamp >= endTimestamp) {
- ended = true;
- }
-
- if (ended) {
- sample.close();
- return;
- }
-
- if (lastSample) {
- if (sample.timestamp > startTimestamp) {
- // We don't know ahead of time what the first first is. This is because the first first is the
- // last first whose timestamp is less than or equal to the start timestamp. Therefore we need to
- // wait for the first first after the start timestamp, and then we'll know that the previous
- // first was the first first.
- sampleQueue.push(lastSample);
- firstSampleQueued = true;
- } else {
- lastSample.close();
- }
- }
-
- if (sample.timestamp >= startTimestamp) {
- sampleQueue.push(sample);
- firstSampleQueued = true;
- }
-
- lastSample = firstSampleQueued ? null : sample;
-
- if (sampleQueue.length > 0) {
- onQueueNotEmpty();
- ({ promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers());
- }
- }, (error) => {
- if (!hasOutOfBandError) {
- outOfBandError = error;
- hasOutOfBandError = true;
- onQueueNotEmpty();
- }
- });
-
- const packetSink = this._createPacketSink();
- const keyPacket = await packetSink.getKeyPacket(startTimestamp, packetRetrievalOptions)
- ?? await packetSink.getFirstKeyPacket(packetRetrievalOptions);
-
- let currentPacket: EncodedPacket | null = keyPacket;
-
- // B-frames make it exceedingly difficult to properly define an upper bound for packet iteration if an end
- // timestamp is set, so we just don't do it. The case that makes it especially tricky is when the frames
- // following a key frame have a lower timestamp than the keyframe; something that quite frequently happens
- // in HEVC streams. The price to pay for not upper-bounding the packet iterator is a slight increase in
- // decoder work at the end of the range, but the added correctness and reliability makes this tradeoff worth
- // it.
- const endPacket = undefined;
-
- const packets = packetSink.packets(keyPacket ?? undefined, endPacket, packetRetrievalOptions);
- await packets.next(); // Skip the start packet as we already have it
-
- while (currentPacket && !ended && !this._track.input._disposed) {
- const maxQueueSize = computeMaxQueueSize(sampleQueue.length);
- if (sampleQueue.length + decoder.getDecodeQueueSize() > maxQueueSize) {
- ({ promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers());
- await queueDequeue;
- continue;
- }
-
- decoder.decode(currentPacket);
-
- const packetResult = await packets.next();
- if (packetResult.done) {
- break;
- }
-
- currentPacket = packetResult.value;
- }
-
- await packets.return();
-
- if (!terminated && !this._track.input._disposed) {
- await decoder.flush();
- }
-
- if (!firstSampleQueued && lastSample) {
- sampleQueue.push(lastSample);
- }
-
- decoderIsFlushed = true;
- onQueueNotEmpty(); // To unstuck (unstick?) the generator
- })().catch((error) => {
- if (!hasOutOfBandError) {
- outOfBandError = error;
- hasOutOfBandError = true;
- onQueueNotEmpty();
- }
- }).finally(() => {
- decoder?.close();
- });
-
- const track = this._track;
- const closeSamples = () => {
- lastSample?.close();
- for (const sample of sampleQueue) {
- sample.close();
- }
- };
-
- return {
- async next() {
- while (true) {
- if (track.input._disposed) {
- // Once next() throws, the consumer will never call return(), so terminate the
- // iteration here - otherwise, the pump keeps queueing decoded samples that
- // nothing can ever close.
- terminated = true;
- ended = true;
- closeSamples();
- throw new InputDisposedError();
- } else if (terminated) {
- return { value: undefined, done: true };
- } else if (hasOutOfBandError) {
- terminated = true;
- ended = true;
- closeSamples();
- throw outOfBandError;
- } else if (sampleQueue.length > 0) {
- const value = sampleQueue.shift()!;
- onQueueDequeue();
- return { value, done: false };
- } else if (!decoderIsFlushed) {
- await queueNotEmpty;
- } else {
- return { value: undefined, done: true };
- }
- }
- },
- async return() {
- terminated = true;
- ended = true;
- onQueueDequeue();
- onQueueNotEmpty();
- closeSamples();
-
- return { value: undefined, done: true };
- },
- async throw(error) {
- throw error;
- },
- [Symbol.asyncIterator]() {
- return this;
- },
- };
- }
-
- /** @internal */
- protected mediaSamplesAtTimestamps(
- timestamps: AnyIterable,
- options: PacketRetrievalOptions,
- ): AsyncGenerator {
- validateAnyIterable(timestamps);
- const timestampIterator = toAsyncIterator(timestamps);
- const timestampsOfInterest: number[] = [];
-
- const sampleQueue: (MediaSample | null)[] = [];
- let { promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers();
- let { promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers();
- let decoderIsFlushed = false;
- let terminated = false;
- let decoder: DecoderWrapper | null = null;
-
- // This stores errors that are "out of band" in the sense that they didn't occur in the normal flow of this
- // method but instead in a different context. This error should not go unnoticed and must be bubbled up to
- // the consumer.
- let outOfBandError = null as unknown;
- let hasOutOfBandError = false;
-
- const pushToQueue = (sample: MediaSample | null) => {
- sampleQueue.push(sample);
- onQueueNotEmpty();
- ({ promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers());
- };
-
- const retrievalOptions: PacketRetrievalOptions = {
- ...options,
- verifyKeyPackets: true,
- metadataOnly: false,
- };
-
- // The following is the "pump" process that keeps pumping packets into the decoder
- (async () => {
- decoder = await this._createDecoder((sample) => {
- onQueueDequeue();
-
- if (terminated) {
- sample.close();
- return;
- }
-
- let sampleUses = 0;
- while (
- timestampsOfInterest.length > 0
- && sample.timestamp - timestampsOfInterest[0]! > -1e-10 // Give it a little epsilon
- ) {
- sampleUses++;
- timestampsOfInterest.shift();
- }
-
- if (sampleUses > 0) {
- for (let i = 0; i < sampleUses; i++) {
- // Clone the sample if we need to emit it multiple times
- pushToQueue((i < sampleUses - 1 ? sample.clone() : sample) as MediaSample);
- }
- } else {
- sample.close();
- }
- }, (error) => {
- if (!hasOutOfBandError) {
- outOfBandError = error;
- hasOutOfBandError = true;
- onQueueNotEmpty();
- }
- });
-
- const packetSink = this._createPacketSink();
- let lastPacket: EncodedPacket | null = null;
- let lastKeyPacket: EncodedPacket | null = null;
-
- // The end sequence number (inclusive) in the next batch of packets that will be decoded. The batch starts
- // at the last key frame and goes until this sequence number.
- let maxSequenceNumber = -1;
-
- const decodePackets = async () => {
- assert(lastKeyPacket);
- assert(decoder);
-
- // Start at the current key packet
- let currentPacket = lastKeyPacket;
- decoder.decode(currentPacket);
-
- while (currentPacket.sequenceNumber < maxSequenceNumber) {
- const maxQueueSize = computeMaxQueueSize(sampleQueue.length);
- while (sampleQueue.length + decoder.getDecodeQueueSize() > maxQueueSize && !terminated) {
- ({ promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers());
- await queueDequeue;
- }
-
- if (terminated) {
- break;
- }
-
- const nextPacket = await packetSink.getNextPacket(currentPacket, retrievalOptions);
- assert(nextPacket);
-
- decoder.decode(nextPacket);
- currentPacket = nextPacket;
- }
-
- maxSequenceNumber = -1;
- };
-
- const flushDecoder = async () => {
- assert(decoder);
- await decoder.flush();
-
- // We don't expect this list to have any elements in it anymore, but in case it does, let's emit
- // nulls for every remaining element, then clear it.
- for (let i = 0; i < timestampsOfInterest.length; i++) {
- pushToQueue(null);
- }
- timestampsOfInterest.length = 0;
- };
-
- for await (const timestamp of timestampIterator) {
- validateTimestamp(timestamp);
-
- if (terminated || this._track.input._disposed) {
- break;
- }
-
- const targetPacket = await packetSink.getPacket(timestamp, retrievalOptions);
- const keyPacket = targetPacket && await packetSink.getKeyPacket(timestamp, retrievalOptions);
-
- if (!keyPacket) {
- if (maxSequenceNumber !== -1) {
- await decodePackets();
- await flushDecoder();
- }
-
- pushToQueue(null);
- lastPacket = null;
- continue;
- }
-
- // Check if the key packet has changed or if we're going back in time
- if (
- lastPacket
- && (
- keyPacket.sequenceNumber !== lastKeyPacket!.sequenceNumber
- || targetPacket.timestamp < lastPacket.timestamp
- )
- ) {
- await decodePackets();
- await flushDecoder(); // Always flush here, improves decoder compatibility
- }
-
- timestampsOfInterest.push(targetPacket.timestamp);
- maxSequenceNumber = Math.max(targetPacket.sequenceNumber, maxSequenceNumber);
-
- lastPacket = targetPacket;
- lastKeyPacket = keyPacket;
- }
-
- if (!terminated && !this._track.input._disposed) {
- if (maxSequenceNumber !== -1) {
- // We still need to decode packets
- await decodePackets();
- }
-
- await flushDecoder();
- }
-
- decoderIsFlushed = true;
- onQueueNotEmpty(); // To unstuck the generator
- })().catch((error) => {
- if (!hasOutOfBandError) {
- outOfBandError = error;
- hasOutOfBandError = true;
- onQueueNotEmpty();
- }
- }).finally(() => {
- decoder?.close();
- });
-
- const track = this._track;
- const closeSamples = () => {
- for (const sample of sampleQueue) {
- sample?.close();
- }
- };
-
- return {
- async next() {
- while (true) {
- if (track.input._disposed) {
- // Once next() throws, the consumer will never call return(), so terminate the
- // iteration here - otherwise, the pump keeps queueing decoded samples that
- // nothing can ever close.
- terminated = true;
- closeSamples();
- throw new InputDisposedError();
- } else if (terminated) {
- return { value: undefined, done: true };
- } else if (hasOutOfBandError) {
- terminated = true;
- closeSamples();
- throw outOfBandError;
- } else if (sampleQueue.length > 0) {
- const value = sampleQueue.shift();
- assert(value !== undefined);
- onQueueDequeue();
- return { value, done: false };
- } else if (!decoderIsFlushed) {
- await queueNotEmpty;
- } else {
- return { value: undefined, done: true };
- }
- }
- },
- async return() {
- terminated = true;
- onQueueDequeue();
- onQueueNotEmpty();
- closeSamples();
-
- return { value: undefined, done: true };
- },
- async throw(error) {
- throw error;
- },
- [Symbol.asyncIterator]() {
- return this;
- },
- };
- }
-}
-
-const computeMaxQueueSize = (decodedSampleQueueSize: number) => {
- // If we have decoded samples lying around, limit the total queue size to a small value (decoded samples can use up
- // a lot of memory). If not, we're fine with a much bigger queue of encoded packets waiting to be decoded. In fact,
- // some decoders only start flushing out decoded chunks when the packet queue is large enough.
- return decodedSampleQueueSize === 0 ? 40 : 8;
-};
-
-class VideoDecoderWrapper extends DecoderWrapper {
- decoder: VideoDecoder | null = null;
-
- customDecoder: CustomVideoDecoder | null = null;
- customDecoderCallSerializer = new CallSerializer();
- customDecoderQueueSize = 0;
-
- inputTimestamps: number[] = []; // Timestamps input into the decoder, sorted.
- sampleQueue: VideoSample[] = []; // Safari-specific thing, check usage.
- currentPacketIndex = 0;
- raslSkipped = false; // For HEVC stuff
-
- // Alpha stuff
- alphaDecoder: VideoDecoder | null = null;
- alphaHadKeyframe = false;
- colorQueue: VideoFrame[] = [];
- alphaQueue: (VideoFrame | null)[] = [];
- merger: ColorAlphaMerger | null = null;
- decodedAlphaChunkCount = 0;
- alphaDecoderQueueSize = 0;
- /** Each value is the number of decoded alpha chunks at which a null alpha frame should be added. */
- nullAlphaFrameQueue: number[] = [];
- currentAlphaPacketIndex = 0;
- alphaRaslSkipped = false; // For HEVC stuff
- finalSamples: { sample: VideoSample | null }[] = [];
- mergeAlphaPromises: Promise[] = [];
-
- constructor(
- onSample: (sample: VideoSample) => unknown,
- onError: (error: unknown) => unknown,
- public codec: VideoCodec,
- public decoderConfig: VideoDecoderConfig,
- public rotation: Rotation,
- public flip: boolean,
- public timeResolution: number,
- ) {
- super(onSample, onError);
-
- const MatchingCustomDecoder = customVideoDecoders.find(x => x.supports(codec, decoderConfig));
- if (MatchingCustomDecoder) {
- // @ts-expect-error "Can't create instance of abstract class 🤓"
- this.customDecoder = new MatchingCustomDecoder() as CustomVideoDecoder;
- // @ts-expect-error It's technically readonly
- this.customDecoder.codec = codec;
- // @ts-expect-error It's technically readonly
- this.customDecoder.config = decoderConfig;
- // @ts-expect-error It's technically readonly
- this.customDecoder.onSample = (sample) => {
- if (!(sample instanceof VideoSample)) {
- throw new TypeError('The argument passed to onSample must be a VideoSample.');
- }
-
- this.finalizeAndEmitSample(sample);
- };
- // @ts-expect-error It's technically readonly
- this.customDecoder.onError = (error) => {
- onError(error);
- };
-
- void this.customDecoderCallSerializer
- .call(() => this.customDecoder!.init())
- .catch(error => onError(error));
- } else {
- const colorHandler = (frame: VideoFrame) => {
- if (this.alphaQueue.length > 0) {
- // Even when no alpha data is present (most of the time), there will be nulls in this queue
- const alphaFrame = this.alphaQueue.shift();
- assert(alphaFrame !== undefined);
-
- void this.mergeAlpha(frame, alphaFrame);
- } else {
- this.colorQueue.push(frame);
- }
- };
-
- if (isChromium()) {
- if (codec === 'avc' && this.decoderConfig.description) {
- const record = deserializeAvcDecoderConfigurationRecord(
- toUint8Array(this.decoderConfig.description),
- );
- if (record && record.sequenceParameterSets.length > 0) {
- const sps = parseAvcSps(record.sequenceParameterSets[0]!);
- if (sps) {
- if (sps.frameMbsOnlyFlag === 0) {
- // Chromium has/had a bug with playing interlaced AVC
- // (https://issues.chromium.org/issues/456919096) which can be worked around by
- // requesting that software decoding be used. So, here we peek into the AVC description,
- // if present, and switch to software decoding if we find interlaced content.
- this.decoderConfig = {
- ...this.decoderConfig,
- hardwareAcceleration: 'prefer-software',
- };
- }
-
- if (sps.maxDecFrameBuffering !== 0 && sps.bitstreamRestrictionFlag !== 1) {
- // Modify the SPS to fix potential loss of B frames
- record.sequenceParameterSets[0] = addAvcBitstreamRestriction(sps);
- this.decoderConfig = {
- ...this.decoderConfig,
- description: serializeAvcDecoderConfigurationRecord(record),
- };
- }
- }
- }
- }
-
- if (!colorSpaceIsComplete(this.decoderConfig.colorSpace)) {
- // Found via https://github.com/remotion-dev/remotion/issues/10841.
- // If the color space is incomplete (which is often that it's just partially filled), Chromium has
- // some nasty logic where it doesn't pass that information along to the GPU at all. The result is
- // that information is genuinely lost, like the color matrix for example. Chromium has other code
- // paths where it just fills the missing values with a hardcoded default, so we do the exact same
- // thing here, with the same hardcoded defaults:
- this.decoderConfig = {
- ...this.decoderConfig,
- colorSpace: {
- primaries: this.decoderConfig.colorSpace?.primaries ?? 'bt709',
- matrix: this.decoderConfig.colorSpace?.matrix ?? 'bt709',
- transfer: this.decoderConfig.colorSpace?.transfer ?? 'bt709',
- fullRange: this.decoderConfig.colorSpace?.fullRange ?? false,
- },
- };
- }
- }
-
- const stack = new Error('Decoding error').stack;
-
- this.decoder = new VideoDecoder({
- output: (frame) => {
- try {
- colorHandler(frame);
- } catch (error) {
- this.onError(error);
- }
- },
- error: (error) => {
- error.stack = stack; // Provide a more useful stack trace, the default one sucks
- this.onError(error);
- },
- });
- this.decoder.configure(this.decoderConfig);
- }
- }
-
- getDecodeQueueSize() {
- if (this.customDecoder) {
- return this.customDecoderQueueSize;
- } else {
- assert(this.decoder);
-
- return Math.max(
- this.decoder.decodeQueueSize,
- this.alphaDecoder?.decodeQueueSize ?? 0,
- );
- }
- }
-
- decode(packet: EncodedPacket) {
- if (this.codec === 'hevc' && this.currentPacketIndex > 0 && !this.raslSkipped) {
- if (this.hasHevcRaslPicture(packet.data)) {
- return; // Drop
- }
-
- this.raslSkipped = true;
- }
-
- if (this.customDecoder) {
- this.customDecoderQueueSize++;
- void this.customDecoderCallSerializer
- .call(() => this.customDecoder!.decode(packet))
- .catch(error => this.onError(error))
- .finally(() => this.customDecoderQueueSize--);
- } else {
- assert(this.decoder);
-
- if (!isWebKit()) {
- insertSorted(this.inputTimestamps, packet.timestamp, x => x);
- }
-
- if (isChromium() && this.currentPacketIndex === 0) {
- if (this.codec === 'avc') {
- // Workaround for https://issues.chromium.org/issues/470109459
- const filteredNalUnits: Uint8Array[] = [];
- let hasFrameData = false;
-
- for (const loc of iterateAvcNalUnits(packet.data, this.decoderConfig)) {
- const type = extractNalUnitTypeForAvc(packet.data[loc.offset]!);
- hasFrameData ||= type >= 1 && type <= 5;
-
- if (type === AvcNalUnitType.AUD) {
- if (hasFrameData) {
- // Already has actual frame data, so treat an AUD as simply the end of the packet
- break;
- } else {
- // If packets contain an AUD and have NALUs before it, this trips up Chromium's key
- // frame detector. Clear the NALUs if an AUD is encountered.
- // https://github.com/Vanilagy/mediabunny/issues/396
- filteredNalUnits.length = 0;
- }
- }
-
- // These trip up Chromium's key frame detection, so let's strip them
- if (!(type >= 20 && type <= 31)) {
- filteredNalUnits.push(packet.data.subarray(loc.offset, loc.offset + loc.length));
- }
- }
-
- if (!this.decoderConfig.description) {
- // Do SPS fixups if necessary
- for (let i = 0; i < filteredNalUnits.length; i++) {
- const nalUnit = filteredNalUnits[i]!;
- if (extractNalUnitTypeForAvc(nalUnit[0]!) !== AvcNalUnitType.SPS) {
- continue;
- }
-
- const sps = parseAvcSps(nalUnit);
- if (sps && sps.maxDecFrameBuffering !== 0 && sps.bitstreamRestrictionFlag !== 1) {
- filteredNalUnits[i] = addAvcBitstreamRestriction(sps);
- }
-
- break;
- }
- }
-
- const newData = concatAvcNalUnits(filteredNalUnits, this.decoderConfig);
- packet = new EncodedPacket(newData, packet.type, packet.timestamp, packet.duration);
- } else if (this.codec === 'hevc') {
- // Workaround for https://issues.chromium.org/issues/507611247
- const sanitizedData = sanitizeHevcPacketForChromium(packet.data, this.decoderConfig);
- if (sanitizedData) {
- packet = new EncodedPacket(sanitizedData, packet.type, packet.timestamp, packet.duration);
- }
- }
- }
-
- this.decoder.decode(packet.toEncodedVideoChunk());
- this.decodeAlphaData(packet);
- }
-
- this.currentPacketIndex++;
- }
-
- decodeAlphaData(packet: EncodedPacket) {
- if (!packet.sideData.alpha) {
- // No alpha side data in the packet, most common case
- this.pushNullAlphaFrame();
- return;
- }
-
- if (!this.merger) {
- this.merger = new ColorAlphaMerger();
- }
-
- // Check if we need to set up the alpha decoder
- if (!this.alphaDecoder) {
- const alphaHandler = (frame: VideoFrame) => {
- if (this.colorQueue.length > 0) {
- const colorFrame = this.colorQueue.shift();
- assert(colorFrame !== undefined);
-
- void this.mergeAlpha(colorFrame, frame);
- } else {
- this.alphaQueue.push(frame);
- }
-
- // Check if any null frames have been queued for this point
- this.decodedAlphaChunkCount++;
- while (
- this.nullAlphaFrameQueue.length > 0
- && this.nullAlphaFrameQueue[0] === this.decodedAlphaChunkCount
- ) {
- this.nullAlphaFrameQueue.shift();
-
- if (this.colorQueue.length > 0) {
- const colorFrame = this.colorQueue.shift();
- assert(colorFrame !== undefined);
-
- void this.mergeAlpha(colorFrame, null);
- } else {
- this.alphaQueue.push(null);
- }
- }
-
- this.alphaDecoderQueueSize--;
- };
-
- const stack = new Error('Decoding error').stack;
-
- this.alphaDecoder = new VideoDecoder({
- output: (frame) => {
- try {
- alphaHandler(frame);
- } catch (error) {
- this.onError(error);
- }
- },
- error: (error) => {
- error.stack = stack; // Provide a more useful stack trace, the default one sucks
- this.onError(error);
- },
- });
- this.alphaDecoder.configure({
- ...this.decoderConfig,
- // Alpha is always full range, regardless of what the color track says. The decoder only honors the
- // override if all fields are set tho.
- colorSpace: {
- fullRange: true,
- // These fields are irrelevant:
- matrix: 'bt709',
- primaries: 'bt709',
- transfer: 'bt709',
- },
- });
- }
-
- const type = determineVideoPacketType(this.codec, this.decoderConfig, packet.sideData.alpha);
-
- // Alpha packets might follow a different key frame rhythm than the main packets. Therefore, before we start
- // decoding, we must first find a packet that's actually a key frame. Until then, we treat the image as opaque.
- if (!this.alphaHadKeyframe) {
- this.alphaHadKeyframe = type === 'key';
- }
-
- if (this.alphaHadKeyframe) {
- // Same RASL skipping logic as for color, unlikely to be hit (since who uses HEVC with separate alpha??) but
- // here for symmetry.
- if (this.codec === 'hevc' && this.currentAlphaPacketIndex > 0 && !this.alphaRaslSkipped) {
- if (this.hasHevcRaslPicture(packet.sideData.alpha)) {
- this.pushNullAlphaFrame();
- return;
- }
-
- this.alphaRaslSkipped = true;
- }
-
- this.currentAlphaPacketIndex++;
- this.alphaDecoder.decode(packet.alphaToEncodedVideoChunk(type ?? packet.type));
- this.alphaDecoderQueueSize++;
- } else {
- this.pushNullAlphaFrame();
- }
- }
-
- pushNullAlphaFrame() {
- if (this.alphaDecoderQueueSize === 0) {
- // Easy
- this.alphaQueue.push(null);
- } else {
- // There are still alpha chunks being decoded, so pushing `null` immediately would result in out-of-order
- // data and be incorrect. Instead, we need to enqueue a "null frame" for when the current decoder workload
- // has finished.
- this.nullAlphaFrameQueue.push(this.decodedAlphaChunkCount + this.alphaDecoderQueueSize);
- }
- }
-
- /**
- * If we're using HEVC, we need to make sure to skip any RASL slices that follow a non-IDR key frame such as
- * CRA_NUT. This is because RASL slices cannot be decoded without data before the CRA_NUT. Browsers behave
- * differently here: Chromium drops the packets, Safari throws a decoder error. Either way, it's not good
- * and causes bugs upstream. So, let's take the dropping into our own hands.
- */
- hasHevcRaslPicture(packetData: Uint8Array) {
- for (const loc of iterateHevcNalUnits(packetData, this.decoderConfig)) {
- const type = extractNalUnitTypeForHevc(packetData[loc.offset]!);
- if (type === HevcNalUnitType.RASL_N || type === HevcNalUnitType.RASL_R) {
- return true;
- }
- }
-
- return false;
- }
-
- /** Handler for the WebCodecs VideoDecoder for ironing out browser differences. */
- sampleHandler(sample: VideoSample) {
- if (isWebKit()) {
- // For correct B-frame handling, we don't just hand over the frames directly but instead add them to
- // a queue, because we want to ensure frames are emitted in presentation order. We flush the queue
- // each time we receive a frame with a timestamp larger than the highest we've seen so far, as we
- // can sure that is not a B-frame. Typically, WebCodecs automatically guarantees that frames are
- // emitted in presentation order, but Safari doesn't always follow this rule.
- if (this.sampleQueue.length > 0 && (sample.timestamp >= last(this.sampleQueue)!.timestamp)) {
- for (const sample of this.sampleQueue) {
- this.finalizeAndEmitSample(sample);
- }
-
- this.sampleQueue.length = 0;
- }
-
- insertSorted(this.sampleQueue, sample, x => x.timestamp);
- } else {
- // Assign it the next earliest timestamp from the input. We do this because browsers, by spec, are
- // required to emit decoded frames in presentation order *while* retaining the timestamp of their
- // originating EncodedVideoChunk. For files with B-frames but no out-of-order timestamps (like a
- // missing ctts box, for example), this causes a mismatch. We therefore fix the timestamps and
- // ensure they are sorted by doing this.
- const timestamp = this.inputTimestamps.shift();
-
- // There's no way we'd have more decoded frames than encoded packets we passed in. Actually, the
- // correspondence should be 1:1.
- assert(timestamp !== undefined);
-
- sample.setTimestamp(timestamp);
- this.finalizeAndEmitSample(sample);
- }
- }
-
- finalizeAndEmitSample(sample: VideoSample) {
- // Round the timestamps to the time resolution
- sample.setTimestamp(Math.round(sample.timestamp * this.timeResolution) / this.timeResolution);
- sample.setDuration(Math.round(sample.duration * this.timeResolution) / this.timeResolution);
- sample.setRotation(this.rotation);
- sample.setFlip(this.flip);
-
- this.onSample(sample);
- }
-
- async mergeAlpha(color: VideoFrame, alpha: VideoFrame | null) {
- const resolver = promiseWithResolvers();
- this.mergeAlphaPromises.push(resolver.promise);
-
- // Alpha merging is concurrent but the samples must still be emitted in the same order in which the merging
- // began. Therefore, serialize the results in an array.
- const result: { sample: VideoSample | null } = { sample: null };
- this.finalSamples.push(result);
-
- try {
- if (!alpha) {
- // Nothing needs to be merged
- result.sample = new VideoSample(color);
- } else {
- assert(this.merger);
-
- // The merger takes ownership of the frames, so no need to close them ourselves
- const finalFrame = await this.merger.merge(color, alpha);
- result.sample = new VideoSample(finalFrame);
- }
-
- // Emit any leading samples that are ready, preserving input order
- while (this.finalSamples.length > 0 && this.finalSamples[0]!.sample !== null) {
- const next = this.finalSamples.shift()!;
- this.sampleHandler(next.sample!);
- }
- } catch (error) {
- removeItem(this.finalSamples, result);
- this.onError(error);
- } finally {
- removeItem(this.mergeAlphaPromises, resolver.promise);
- resolver.resolve();
- }
- }
-
- async flush() {
- if (this.customDecoder) {
- await this.customDecoderCallSerializer.call(() => this.customDecoder!.flush());
- } else {
- assert(this.decoder);
- await Promise.all([
- this.decoder.flush(),
- this.alphaDecoder?.flush(),
- ]);
- await Promise.all(this.mergeAlphaPromises);
-
- this.colorQueue.forEach(x => x.close());
- this.colorQueue.length = 0;
- this.alphaQueue.forEach(x => x?.close());
- this.alphaQueue.length = 0;
-
- this.alphaHadKeyframe = false;
- this.decodedAlphaChunkCount = 0;
- this.alphaDecoderQueueSize = 0;
- this.nullAlphaFrameQueue.length = 0;
- this.currentAlphaPacketIndex = 0;
- this.alphaRaslSkipped = false;
- }
-
- if (isWebKit()) {
- for (const sample of this.sampleQueue) {
- this.finalizeAndEmitSample(sample);
- }
-
- this.sampleQueue.length = 0;
- }
-
- this.currentPacketIndex = 0;
- this.raslSkipped = false;
- }
-
- close() {
- if (this.customDecoder) {
- void this.customDecoderCallSerializer.call(() => this.customDecoder!.close());
- } else {
- assert(this.decoder);
-
- if (this.decoder.state !== 'closed') {
- this.decoder.close();
- }
- if (this.alphaDecoder && this.alphaDecoder.state !== 'closed') {
- this.alphaDecoder.close();
- }
-
- this.colorQueue.forEach(x => x.close());
- this.colorQueue.length = 0;
- this.alphaQueue.forEach(x => x?.close());
- this.alphaQueue.length = 0;
-
- this.merger?.close();
- }
-
- for (const sample of this.sampleQueue) {
- sample.close();
- }
- this.sampleQueue.length = 0;
- }
-}
-
-let mergerWorkerUrl: string | null = null;
-
-/** Utility class that merges together color and alpha information on the CPU in a pool of workers. */
-export class ColorAlphaMerger {
- private workers: Worker[] = [];
- private nextWorkerIndex = 0;
- private pendingRequests = new Map>>();
- private nextRequestId = 0;
-
- merge(color: VideoFrame, alpha: VideoFrame): Promise {
- if (this.workers.length === 0) {
- if (!mergerWorkerUrl) {
- const blob = new Blob(
- [`(${colorAlphaMergerWorkerCode.toString()})()`],
- { type: 'application/javascript' },
- );
- mergerWorkerUrl = URL.createObjectURL(blob);
- }
-
- const poolSize = clamp(navigator.hardwareConcurrency, 1, 4);
- for (let i = 0; i < poolSize; i++) {
- const worker = new Worker(mergerWorkerUrl);
-
- worker.addEventListener('message', (event: MessageEvent) => {
- const data = event.data;
- const pending = this.pendingRequests.get(data.id);
- if (!pending) {
- return;
- }
- this.pendingRequests.delete(data.id);
-
- if ('error' in data) {
- pending.reject(new Error(data.error));
- } else {
- pending.resolve(data.frame);
- }
- });
-
- worker.addEventListener('error', (event) => {
- const error = new Error(event.message || 'Color/alpha merge worker error.');
- for (const pending of this.pendingRequests.values()) {
- pending.reject(error);
- }
- this.pendingRequests.clear();
- });
-
- this.workers.push(worker);
- }
- }
-
- const id = this.nextRequestId++;
- const pending = promiseWithResolvers();
- this.pendingRequests.set(id, pending);
-
- // Hand the job to the next worker in round-robin fashion
- const worker = this.workers[this.nextWorkerIndex]!;
- this.nextWorkerIndex = (this.nextWorkerIndex + 1) % this.workers.length;
- worker.postMessage({ id, color, alpha }, { transfer: [color, alpha] });
-
- return pending.promise;
- }
-
- close() {
- for (const worker of this.workers) {
- worker.terminate();
- }
- this.workers.length = 0;
-
- const error = new Error('Color/alpha merger closed.');
- for (const pending of this.pendingRequests.values()) {
- pending.reject(error);
- }
- this.pendingRequests.clear();
- }
-}
-
-type ColorAlphaMergerWorkerRequest = {
- id: number;
- color: VideoFrame;
- alpha: VideoFrame;
-};
-
-type ColorAlphaMergerWorkerResponse =
- | { id: number; frame: VideoFrame }
- | { id: number; error: string };
-
-const colorAlphaMergerWorkerCode = () => {
- // These buffers are reused across frames as long as the size matches, since consecutive frames usually share
- // dimensions
- let cpuAlphaBuffer: Uint8Array | null = null;
- let cpuColorBuffer: Uint8Array | null = null;
-
- // Serialize execution internally so concurrent requests don't race on the shared cpu*Buffer state.
- let chain: Promise = Promise.resolve();
- self.addEventListener('message', (event: MessageEvent) => {
- const { id, color, alpha } = event.data;
- chain = chain.then(async () => {
- try {
- const frame = await merge(color, alpha);
- self.postMessage({ id, frame }, { transfer: [frame] });
- } catch (error) {
- self.postMessage({ id, error: (error as Error).message });
- } finally {
- // We took ownership of the inputs via transfer; close them now that the merge (or its error) is done.
- color.close();
- alpha.close();
- }
- });
- });
-
- const merge = async (color: VideoFrame, alpha: VideoFrame): Promise => {
- const format = color.format as VideoSamplePixelFormat | null;
- const alphaFormat = alpha.format as VideoSamplePixelFormat | null;
- if (!format || !alphaFormat) {
- throw new Error('CPU color/alpha merging requires a known VideoFrame format.');
- }
-
- // The alpha frame must have the same bit depth as the color frame
- const colorIs10 = format.includes('P10');
- const colorIs12 = format.includes('P12');
- const alphaIs10 = alphaFormat.includes('P10');
- const alphaIs12 = alphaFormat.includes('P12');
- if (alphaIs10 !== colorIs10 || alphaIs12 !== colorIs12) {
- throw new Error(
- `CPU color/alpha merging requires the alpha frame to have the same bit depth as the color frame`
- + ` (color: '${format}', alpha: '${alphaFormat}').`,
- );
- }
-
- if (format === 'RGBX' || format === 'RGBA' || format === 'BGRX' || format === 'BGRA') {
- return await mergeInterleavedRgba(color, alpha, format);
- } else if (
- format === 'I420' || format === 'I420P10' || format === 'I420P12'
- || format === 'I422' || format === 'I422P10' || format === 'I422P12'
- || format === 'I444' || format === 'I444P10' || format === 'I444P12'
- ) {
- return await mergePlanarYuv(color, alpha, format);
- } else if (format === 'NV12') {
- return await mergeNv12(color, alpha);
- }
-
- throw new Error(`CPU color/alpha merging does not support format '${format}'.`);
- };
-
- const mergeInterleavedRgba = async (
- color: VideoFrame,
- alpha: VideoFrame,
- format: 'RGBX' | 'RGBA' | 'BGRX' | 'BGRA',
- ): Promise => {
- const width = color.visibleRect?.width ?? color.codedWidth;
- const height = color.visibleRect?.height ?? color.codedHeight;
-
- const pixelCount = width * height;
- const output = new Uint8Array(pixelCount * 4);
-
- // Color goes straight into the output buffer via copyTo, no intermediate copy needed
- await color.copyTo(output);
-
- // And now add the alpha data
- const alphaY = await readAlpha(alpha, width, height, 1);
- for (let i = 0, j = 3; i < pixelCount; i++, j += 4) {
- output[j] = alphaY[i]!;
- }
-
- const outputFormat = (format === 'RGBX' || format === 'RGBA') ? 'RGBA' : 'BGRA';
- const init = {
- format: outputFormat,
- codedWidth: width,
- codedHeight: height,
- timestamp: color.timestamp,
- duration: color.duration ?? undefined,
- transfer: [output.buffer],
- } as const;
-
- return new VideoFrame(output, init);
- };
-
- const mergePlanarYuv = async (
- color: VideoFrame,
- alpha: VideoFrame,
- format:
- | 'I420' | 'I420P10' | 'I420P12'
- | 'I422' | 'I422P10' | 'I422P12'
- | 'I444' | 'I444P10' | 'I444P12',
- ): Promise => {
- const width = color.visibleRect?.width ?? color.codedWidth;
- const height = color.visibleRect?.height ?? color.codedHeight;
-
- const is10 = format.includes('P10');
- const is12 = format.includes('P12');
- const bytesPerSample = (is10 || is12) ? 2 : 1;
-
- let chromaW: number;
- let chromaH: number;
- if (format.startsWith('I420')) {
- chromaW = Math.ceil(width / 2);
- chromaH = Math.ceil(height / 2);
- } else if (format.startsWith('I422')) {
- chromaW = Math.ceil(width / 2);
- chromaH = height;
- } else {
- chromaW = width;
- chromaH = height;
- }
-
- const ySamples = width * height;
- const uvSamples = chromaW * chromaH;
- const yBytes = ySamples * bytesPerSample;
- const uvBytes = uvSamples * bytesPerSample;
- const aBytes = ySamples * bytesPerSample;
-
- const outputBytes = yBytes + 2 * uvBytes + aBytes;
- const output = new Uint8Array(outputBytes);
-
- // Write color planes directly into the output buffer via copyTo, no intermediate copy
- await color.copyTo(output);
-
- const alphaY = await readAlpha(alpha, width, height, bytesPerSample);
- const aOffset = yBytes + 2 * uvBytes;
- output.set(alphaY, aOffset);
-
- const outputFormat = (format.slice(0, 4) + 'A' + format.slice(4)) as VideoPixelFormat;
-
- const init = {
- format: outputFormat,
- codedWidth: width,
- codedHeight: height,
- timestamp: color.timestamp,
- duration: color.duration ?? undefined,
- transfer: [output.buffer],
- };
-
- return new VideoFrame(output, init);
- };
-
- const mergeNv12 = async (
- color: VideoFrame,
- alpha: VideoFrame,
- ): Promise => {
- const width = color.visibleRect?.width ?? color.codedWidth;
- const height = color.visibleRect?.height ?? color.codedHeight;
-
- const ySize = width * height;
- const chromaW = Math.ceil(width / 2);
- const chromaH = Math.ceil(height / 2);
- const uvSize = chromaW * chromaH;
-
- const sourceSize = color.allocationSize();
- if (!cpuColorBuffer || cpuColorBuffer.byteLength !== sourceSize) {
- cpuColorBuffer = new Uint8Array(sourceSize);
- }
- await color.copyTo(cpuColorBuffer);
-
- const output = new Uint8Array(ySize + 2 * uvSize + ySize);
-
- // Y plane copies straight over
- output.set(cpuColorBuffer.subarray(0, ySize), 0);
-
- // Deinterleave the UV plane into separate U and V planes
- const uOffset = ySize;
- const vOffset = ySize + uvSize;
- const uvStart = ySize;
- for (let i = 0; i < uvSize; i++) {
- output[uOffset + i] = cpuColorBuffer[uvStart + i * 2]!;
- output[vOffset + i] = cpuColorBuffer[uvStart + i * 2 + 1]!;
- }
-
- const alphaY = await readAlpha(alpha, width, height, 1);
- output.set(alphaY, ySize + 2 * uvSize);
-
- const init = {
- format: 'I420A',
- codedWidth: width,
- codedHeight: height,
- timestamp: color.timestamp,
- duration: color.duration ?? undefined,
- transfer: [output.buffer],
- } as const;
-
- return new VideoFrame(output, init);
- };
-
- const readAlpha = async (alpha: VideoFrame, width: number, height: number, bytesPerSample: number) => {
- const size = alpha.allocationSize();
- if (!cpuAlphaBuffer || cpuAlphaBuffer.byteLength !== size) {
- cpuAlphaBuffer = new Uint8Array(size);
- }
- await alpha.copyTo(cpuAlphaBuffer);
-
- const format = alpha.format;
- if (format === 'RGBA' || format === 'BGRA' || format === 'RGBX' || format === 'BGRX') {
- // Pack alpha data tightly. Assume alpha is stored in RGB, so sample just from R for simplicity.
- const rOffset = (format === 'RGBA' || format === 'RGBX') ? 0 : 2;
- const pixelCount = width * height;
- for (let i = 0; i < pixelCount; i++) {
- cpuAlphaBuffer[i] = cpuAlphaBuffer[i * 4 + rOffset]!;
- }
- return cpuAlphaBuffer.subarray(0, pixelCount);
- } else {
- // For Y-plane-first formats (I*** and NV12), the leading width*height samples are the Y plane
- const yPlane = cpuAlphaBuffer.subarray(0, width * height * bytesPerSample);
-
- if (alpha.colorSpace.fullRange === false) {
- // Some decoders hand us limited-range alpha, so stretch it back out to full range
- const bitDepth = format!.includes('P12') ? 12 : format!.includes('P10') ? 10 : 8;
- const low = 16 << (bitDepth - 8);
- const high = 235 << (bitDepth - 8);
- const max = (1 << bitDepth) - 1;
- const samples = bytesPerSample === 2
- ? new Uint16Array(yPlane.buffer, 0, width * height)
- : yPlane;
-
- for (let i = 0; i < samples.length; i++) {
- const value = Math.round((samples[i]! - low) * max / (high - low));
- samples[i] = Math.min(Math.max(value, 0), max);
- }
- }
-
- return yPlane;
- }
- };
-};
-
-/**
- * Describes additional decoder preferences for video sinks.
- * @group Media sinks
- * @public
- */
-export type VideoSinkDecoderOptions = {
- /**
- * A hint that configures the hardware acceleration method of the decoder. This is best left on `'no-preference'`,
- * the default.
- */
- hardwareAcceleration?: 'no-preference' | 'prefer-hardware' | 'prefer-software';
- /**
- * Hint that the selected decoder should be configured to minimize the number of packets that have to be decoded
- * before video frames are output.
- */
- optimizeForLatency?: boolean;
-};
-
-const validateVideoSinkDecoderOptions = (decoderOptions: VideoSinkDecoderOptions) => {
- if (!decoderOptions || typeof decoderOptions !== 'object') {
- throw new TypeError('decoderOptions must be an object.');
- }
- if (
- decoderOptions.hardwareAcceleration !== undefined
- && !['no-preference', 'prefer-hardware', 'prefer-software'].includes(decoderOptions.hardwareAcceleration)
- ) {
- throw new TypeError(
- 'decoderOptions.hardwareAcceleration, when provided, must be \'no-preference\', \'prefer-hardware\' or'
- + ' \'prefer-software\'.',
- );
- }
- if (decoderOptions.optimizeForLatency !== undefined && typeof decoderOptions.optimizeForLatency !== 'boolean') {
- throw new TypeError('decoderOptions.optimizeForLatency, when provided, must be a boolean.');
- }
-};
-
-/**
- * A sink that retrieves decoded video samples (video frames) from a video track.
- * @group Media sinks
- * @public
- */
-export class VideoSampleSink extends BaseMediaSampleSink {
- /** @internal */
- _track: InputVideoTrack;
- /** @internal */
- _decoderOptions: VideoSinkDecoderOptions;
-
- /** Creates a new {@link VideoSampleSink} for the given {@link InputVideoTrack}. */
- constructor(videoTrack: InputVideoTrack, decoderOptions: VideoSinkDecoderOptions = {}) {
- if (!(videoTrack instanceof InputVideoTrack)) {
- throw new TypeError('videoTrack must be an InputVideoTrack.');
- }
- validateVideoSinkDecoderOptions(decoderOptions);
-
- super();
-
- this._track = videoTrack;
- this._decoderOptions = decoderOptions;
- }
-
- /** @internal */
- async _createDecoder(
- onSample: (sample: VideoSample) => unknown,
- onError: (error: unknown) => unknown,
- ) {
- if (!(await this._track.canDecode())) {
- if (typeof VideoDecoder === 'undefined') {
- throw new Error(missingWebCodecsClassMessage('VideoDecoder'));
- }
-
- throw new Error(
- 'This video track cannot be decoded in this environment. Make sure to check decodability before using'
- + ' a track.',
- );
- }
-
- const codec = await this._track.getCodec();
- const rotation = await this._track.getRotation();
- const flip = await this._track.getFlip();
- let decoderConfig = await this._track.getDecoderConfig();
- const timeResolution = await this._track.getTimeResolution();
- assert(codec && decoderConfig);
-
- decoderConfig = {
- ...decoderConfig,
- hardwareAcceleration: this._decoderOptions.hardwareAcceleration,
- optimizeForLatency: this._decoderOptions.optimizeForLatency,
- };
-
- return new VideoDecoderWrapper(onSample, onError, codec, decoderConfig, rotation, flip, timeResolution);
- }
-
- /** @internal */
- _createPacketSink() {
- return new EncodedPacketSink(this._track);
- }
-
- /**
- * Retrieves the video sample (frame) corresponding to the given timestamp, in seconds. More specifically, returns
- * the last video sample (in presentation order) with a start timestamp less than or equal to the given timestamp.
- * Returns null if the timestamp is before the track's first timestamp.
- *
- * @param timestamp - The timestamp used for retrieval, in seconds.
- * @param options - Options used for the underlying packet retrieval.
- */
- async getSample(timestamp: number, options: PacketRetrievalOptions = {}) {
- validateTimestamp(timestamp);
-
- for await (const sample of this.mediaSamplesAtTimestamps([timestamp], options)) {
- return sample;
- }
- throw new Error('Internal error: Iterator returned nothing.');
- }
-
- /**
- * Creates an async iterator that yields the video samples (frames) of this track in presentation order. This method
- * will intelligently pre-decode a few frames ahead to enable fast iteration.
- *
- * @param startTimestamp - The timestamp in seconds at which to start yielding samples (inclusive).
- * @param endTimestamp - The timestamp in seconds at which to stop yielding samples (exclusive).
- * @param options - Options used for the underlying packet retrieval.
- */
- samples(startTimestamp?: number, endTimestamp?: number, options: PacketRetrievalOptions = {}) {
- return this.mediaSamplesInRange(startTimestamp, endTimestamp, options);
- }
-
- /**
- * Creates an async iterator that yields a video sample (frame) for each timestamp in the argument. This method
- * uses an optimized decoding pipeline if these timestamps are monotonically sorted, decoding each packet at most
- * once, and is therefore more efficient than manually getting the sample for every timestamp. The iterator may
- * yield null if no frame is available for a given timestamp.
- *
- * This method is good for sparse access of media data. If you want primarily sequential media access, prefer
- * {@link VideoSampleSink.samples} instead.
- *
- * @param timestamps - An iterable or async iterable of timestamps in seconds.
- * @param options - Options used for the underlying packet retrieval.
- */
- samplesAtTimestamps(timestamps: AnyIterable, options: PacketRetrievalOptions = {}) {
- return this.mediaSamplesAtTimestamps(timestamps, options);
- }
-}
-
-/**
- * A canvas with additional timing information (timestamp & duration).
- * @group Media sinks
- * @public
- */
-export type WrappedCanvas = {
- /** A canvas element or offscreen canvas. */
- canvas: HTMLCanvasElement | OffscreenCanvas;
- /** The timestamp of the corresponding video sample, in seconds. */
- timestamp: number;
- /** The duration of the corresponding video sample, in seconds. */
- duration: number;
-};
-
-/**
- * Options for constructing a CanvasSink.
- * @group Media sinks
- * @public
- */
-export type CanvasSinkOptions = {
- /**
- * Whether the output canvases should have transparency instead of a black background. Defaults to `false`. Set
- * this to `true` when using this sink to read transparent videos.
- */
- alpha?: boolean;
- /**
- * The width of the output canvas in pixels, defaulting to the display width of the video track. If height is not
- * set, it will be deduced automatically based on aspect ratio.
- */
- width?: number;
- /**
- * The height of the output canvas in pixels, defaulting to the display height of the video track. If width is not
- * set, it will be deduced automatically based on aspect ratio.
- */
- height?: number;
- /**
- * The fitting algorithm in case both width and height are set.
- *
- * - `'fill'` will stretch the image to fill the entire box, potentially altering aspect ratio.
- * - `'contain'` will contain the entire image within the box while preserving aspect ratio. This may lead to
- * letterboxing.
- * - `'cover'` will scale the image until the entire box is filled, while preserving aspect ratio.
- */
- fit?: 'fill' | 'contain' | 'cover';
- /**
- * The clockwise rotation by which to rotate the raw video frame. Defaults to the rotation set in the file metadata.
- * Rotation is applied before flipping.
- */
- rotation?: Rotation;
- /**
- * Whether to flip the raw video frame horizontally (about the vertical axis). Defaults to the flip set in the file
- * metadata. The flip is applied after rotation but before cropping and resizing.
- */
- flip?: boolean;
- /**
- * Specifies the rectangular region of the input video to crop to. The crop region will automatically be clamped to
- * the dimensions of the input video track. Cropping is performed after rotation and flip but before resizing. The
- * crop region is in the _display pixel space_ of the underlying video data.
- */
- crop?: CropRectangle;
- /**
- * When set, specifies the number of canvases in the pool. These canvases will be reused in a ring buffer /
- * round-robin type fashion. This keeps the amount of allocated VRAM constant and relieves the browser from
- * constantly allocating/deallocating canvases. A pool size of 0 or `undefined` disables the pool and means a new
- * canvas is created each time.
- */
- poolSize?: number;
- /** Additional preferences for the underlying video decoder. */
- decoderOptions?: VideoSinkDecoderOptions;
-};
-
-/**
- * A sink that renders video samples (frames) of the given video track to canvases. This is often more useful than
- * directly retrieving frames, as it comes with common preprocessing steps such as resizing or applying rotation and
- * flip metadata.
- *
- * This sink will yield `HTMLCanvasElement`s when in a DOM context, and `OffscreenCanvas`es otherwise.
- *
- * @group Media sinks
- * @public
- */
-export class CanvasSink {
- /** @internal */
- _videoTrack: InputVideoTrack;
- /** @internal */
- _alpha: boolean;
- /** @internal */
- _width!: number;
- /** @internal */
- _height!: number;
- /** @internal */
- _options: CanvasSinkOptions;
- /** @internal */
- _fit: 'fill' | 'contain' | 'cover';
- /** @internal */
- _rotation: Rotation = 0;
- /** @internal */
- _flip = false;
- /** @internal */
- _crop?: { left: number; top: number; width: number; height: number };
- /** @internal */
- _initPromise: Promise | null = null;
- /** @internal */
- _videoSampleSink: VideoSampleSink;
- /** @internal */
- _canvasPool: (HTMLCanvasElement | OffscreenCanvas | null)[];
- /** @internal */
- _nextCanvasIndex = 0;
-
- /** Creates a new {@link CanvasSink} for the given {@link InputVideoTrack}. */
- constructor(videoTrack: InputVideoTrack, options: CanvasSinkOptions = {}) {
- if (!(videoTrack instanceof InputVideoTrack)) {
- throw new TypeError('videoTrack must be an InputVideoTrack.');
- }
- if (options && typeof options !== 'object') {
- throw new TypeError('options must be an object.');
- }
- if (options.alpha !== undefined && typeof options.alpha !== 'boolean') {
- throw new TypeError('options.alpha, when provided, must be a boolean.');
- }
- if (options.width !== undefined && (!Number.isInteger(options.width) || options.width <= 0)) {
- throw new TypeError('options.width, when defined, must be a positive integer.');
- }
- if (options.height !== undefined && (!Number.isInteger(options.height) || options.height <= 0)) {
- throw new TypeError('options.height, when defined, must be a positive integer.');
- }
- if (options.fit !== undefined && !['fill', 'contain', 'cover'].includes(options.fit)) {
- throw new TypeError('options.fit, when provided, must be one of "fill", "contain", or "cover".');
- }
- if (
- options.width !== undefined
- && options.height !== undefined
- && options.fit === undefined
- ) {
- throw new TypeError(
- 'When both options.width and options.height are provided, options.fit must also be provided.',
- );
- }
- if (options.rotation !== undefined && ![0, 90, 180, 270].includes(options.rotation)) {
- throw new TypeError('options.rotation, when provided, must be 0, 90, 180 or 270.');
- }
- if (options.flip !== undefined && typeof options.flip !== 'boolean') {
- throw new TypeError('options.flip, when provided, must be a boolean.');
- }
- if (options.crop !== undefined) {
- validateCropRectangle(options.crop, 'options.');
- }
- if (
- options.poolSize !== undefined
- && (typeof options.poolSize !== 'number' || !Number.isInteger(options.poolSize) || options.poolSize < 0)
- ) {
- throw new TypeError('poolSize must be a non-negative integer.');
- }
- if (options.decoderOptions !== undefined) {
- validateVideoSinkDecoderOptions(options.decoderOptions);
- }
-
- this._videoTrack = videoTrack;
- this._alpha = options.alpha ?? false;
- this._options = options;
- this._fit = options.fit ?? 'fill';
- this._videoSampleSink = new VideoSampleSink(videoTrack, options.decoderOptions);
- this._canvasPool = Array.from({ length: options.poolSize ?? 0 }, () => null);
- }
-
- /** @internal */
- _ensureInit() {
- return this._initPromise ??= (async () => {
- const options = this._options;
- const videoTrack = this._videoTrack;
-
- const rotation = options.rotation ?? await videoTrack.getRotation();
- const flip = options.flip ?? await videoTrack.getFlip();
- const squarePixelWidth = await videoTrack.getSquarePixelWidth();
- const squarePixelHeight = await videoTrack.getSquarePixelHeight();
-
- const [rotatedWidth, rotatedHeight] = rotation % 180 === 0
- ? [squarePixelWidth, squarePixelHeight]
- : [squarePixelHeight, squarePixelWidth];
-
- let crop = options.crop;
- if (crop) {
- crop = clampCropRectangle(crop, rotatedWidth, rotatedHeight);
- }
-
- let [width, height] = crop
- ? [crop.width, crop.height]
- : [rotatedWidth, rotatedHeight];
- const originalAspectRatio = width / height;
-
- // If width and height aren't defined together, deduce the missing value using the aspect ratio
- if (options.width !== undefined && options.height === undefined) {
- width = options.width;
- height = Math.round(width / originalAspectRatio);
- } else if (options.width === undefined && options.height !== undefined) {
- height = options.height;
- width = Math.round(height * originalAspectRatio);
- } else if (options.width !== undefined && options.height !== undefined) {
- width = options.width;
- height = options.height;
- }
-
- this._width = width;
- this._height = height;
- this._rotation = rotation;
- this._flip = flip;
- this._crop = crop;
- })();
- }
-
- /** @internal */
- _videoSampleToWrappedCanvas(sample: VideoSample): WrappedCanvas {
- const width = this._width;
- const height = this._height;
- let canvas = this._canvasPool[this._nextCanvasIndex];
- let canvasIsNew = false;
-
- if (!canvas) {
- if (typeof document !== 'undefined') {
- // Prefer an HTMLCanvasElement
- canvas = document.createElement('canvas');
- canvas.width = width;
- canvas.height = height;
- } else {
- canvas = new OffscreenCanvas(width, height);
- }
-
- if (this._canvasPool.length > 0) {
- this._canvasPool[this._nextCanvasIndex] = canvas;
- }
-
- canvasIsNew = true;
- }
-
- if (this._canvasPool.length > 0) {
- this._nextCanvasIndex = (this._nextCanvasIndex + 1) % this._canvasPool.length;
- }
-
- const context = canvas.getContext('2d', {
- alpha: this._alpha || isFirefox(), // Firefox has VideoFrame glitches with opaque canvases
- }) as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
- assert(context);
-
- sample._drawWithFitAndMipmapping(canvas, context, {
- fit: this._fit,
- rotation: this._rotation,
- flip: this._flip,
- crop: this._crop,
- targetIsFresh: canvasIsNew,
- fillBlack: !this._alpha && isFirefox(),
- });
-
- const result = {
- canvas,
- timestamp: sample.timestamp,
- duration: sample.duration,
- };
-
- sample.close();
- return result;
- }
-
- /**
- * Retrieves a canvas with the video frame corresponding to the given timestamp, in seconds. More specifically,
- * returns the last video frame (in presentation order) with a start timestamp less than or equal to the given
- * timestamp. Returns null if the timestamp is before the track's first timestamp.
- *
- * @param timestamp - The timestamp used for retrieval, in seconds.
- * @param options - Options used for the underlying packet retrieval.
- */
- async getCanvas(timestamp: number, options?: PacketRetrievalOptions) {
- validateTimestamp(timestamp);
- await this._ensureInit();
-
- const sample = await this._videoSampleSink.getSample(timestamp, options);
- return sample && this._videoSampleToWrappedCanvas(sample);
- }
-
- /**
- * Creates an async iterator that yields canvases with the video frames of this track in presentation order. This
- * method will intelligently pre-decode a few frames ahead to enable fast iteration.
- *
- * @param startTimestamp - The timestamp in seconds at which to start yielding canvases (inclusive).
- * @param endTimestamp - The timestamp in seconds at which to stop yielding canvases (exclusive).
- * @param options - Options used for the underlying packet retrieval.
- */
- async* canvases(startTimestamp?: number, endTimestamp?: number, options?: PacketRetrievalOptions) {
- await this._ensureInit();
- yield* mapAsyncGenerator(
- this._videoSampleSink.samples(startTimestamp, endTimestamp, options),
- sample => this._videoSampleToWrappedCanvas(sample),
- );
- }
-
- /**
- * Creates an async iterator that yields a canvas for each timestamp in the argument. This method uses an optimized
- * decoding pipeline if these timestamps are monotonically sorted, decoding each packet at most once, and is
- * therefore more efficient than manually getting the canvas for every timestamp. The iterator may yield null if
- * no frame is available for a given timestamp.
- *
- * This method is good for sparse access of media data. If you want primarily sequential media access, prefer
- * {@link CanvasSink.canvases} instead.
- *
- * @param timestamps - An iterable or async iterable of timestamps in seconds.
- * @param options - Options used for the underlying packet retrieval.
- */
- async* canvasesAtTimestamps(timestamps: AnyIterable, options?: PacketRetrievalOptions) {
- await this._ensureInit();
- yield* mapAsyncGenerator(
- this._videoSampleSink.samplesAtTimestamps(timestamps, options),
- sample => sample && this._videoSampleToWrappedCanvas(sample),
- );
- }
-}
-
-class AudioDecoderWrapper extends DecoderWrapper {
- decoder: AudioDecoder | null = null;
-
- customDecoder: CustomAudioDecoder | null = null;
- customDecoderCallSerializer = new CallSerializer();
- customDecoderQueueSize = 0;
-
- // Internal state to accumulate a precise current timestamp based on audio durations, not the (potentially
- // inaccurate) packet timestamps.
- currentTimestamp: number | null = null;
- // Chromium does not respect negative packet timestamps, so we must do the fixin' ourselves
- expectedFirstTimestamp: number | null = null;
- timestampOffset = 0;
-
- constructor(
- onSample: (sample: AudioSample) => unknown,
- onError: (error: unknown) => unknown,
- codec: AudioCodec,
- decoderConfig: AudioDecoderConfig,
- ) {
- super(onSample, onError);
-
- const sampleHandler = (sample: AudioSample) => {
- let sampleTimestamp = sample.timestamp;
-
- if (this.expectedFirstTimestamp !== null && this.currentTimestamp === null) {
- this.timestampOffset = this.expectedFirstTimestamp - sampleTimestamp; ;
- }
-
- sampleTimestamp += this.timestampOffset;
-
- if (
- this.currentTimestamp === null
- || Math.abs(sampleTimestamp - this.currentTimestamp) >= sample.duration
- ) {
- // We need to sync with the sample timestamp again
- this.currentTimestamp = sampleTimestamp;
- }
-
- const preciseTimestamp = this.currentTimestamp;
- this.currentTimestamp += sample.duration;
-
- if (sample.numberOfFrames === 0) {
- // We skip zero-data (empty) AudioSamples. These are sometimes emitted, for example, by Firefox when it
- // decodes Vorbis (at the start).
- sample.close();
- return;
- }
-
- // Round the timestamp to the sample rate
- const sampleRate = decoderConfig.sampleRate;
- sample.setTimestamp(Math.round(preciseTimestamp * sampleRate) / sampleRate);
-
- onSample(sample);
- };
-
- const MatchingCustomDecoder = customAudioDecoders.find(x => x.supports(codec, decoderConfig));
- if (MatchingCustomDecoder) {
- // @ts-expect-error "Can't create instance of abstract class 🤓"
- this.customDecoder = new MatchingCustomDecoder() as CustomAudioDecoder;
- // @ts-expect-error It's technically readonly
- this.customDecoder.codec = codec;
- // @ts-expect-error It's technically readonly
- this.customDecoder.config = decoderConfig;
- // @ts-expect-error It's technically readonly
- this.customDecoder.onSample = (sample) => {
- if (!(sample instanceof AudioSample)) {
- throw new TypeError('The argument passed to onSample must be an AudioSample.');
- }
-
- sampleHandler(sample);
- };
- // @ts-expect-error It's technically readonly
- this.customDecoder.onError = (error) => {
- onError(error);
- };
-
- void this.customDecoderCallSerializer
- .call(() => this.customDecoder!.init())
- .catch(error => onError(error));
- } else {
- const stack = new Error('Decoding error').stack;
-
- this.decoder = new AudioDecoder({
- output: (data) => {
- try {
- sampleHandler(new AudioSample(data));
- } catch (error) {
- this.onError(error);
- }
- },
- error: (error) => {
- error.stack = stack; // Provide a more useful stack trace, the default one sucks
- this.onError(error);
- },
- });
- this.decoder.configure(decoderConfig);
- }
- }
-
- getDecodeQueueSize() {
- if (this.customDecoder) {
- return this.customDecoderQueueSize;
- } else {
- assert(this.decoder);
- return this.decoder.decodeQueueSize;
- }
- }
-
- decode(packet: EncodedPacket) {
- if (this.customDecoder) {
- this.customDecoderQueueSize++;
- void this.customDecoderCallSerializer
- .call(() => this.customDecoder!.decode(packet))
- .catch(error => this.onError(error))
- .finally(() => this.customDecoderQueueSize--);
- } else {
- assert(this.decoder);
-
- this.expectedFirstTimestamp ??= packet.timestamp;
- this.decoder.decode(packet.toEncodedAudioChunk());
- }
- }
-
- async flush() {
- if (this.customDecoder) {
- await this.customDecoderCallSerializer.call(() => this.customDecoder!.flush());
- } else {
- assert(this.decoder);
- await this.decoder.flush();
- }
-
- this.currentTimestamp = null;
- this.expectedFirstTimestamp = null;
- this.timestampOffset = 0;
- }
-
- close() {
- if (this.customDecoder) {
- void this.customDecoderCallSerializer.call(() => this.customDecoder!.close());
- } else {
- assert(this.decoder);
-
- if (this.decoder.state !== 'closed') {
- this.decoder.close();
- }
- }
- }
-}
-
-// There are a lot of PCM variants not natively supported by the browser and by AudioData. Therefore we need a simple
-// decoder that maps any input PCM format into a PCM format supported by the browser.
-class PcmAudioDecoderWrapper extends DecoderWrapper {
- codec: PcmAudioCodec;
-
- inputSampleSize: 1 | 2 | 3 | 4 | 8;
- readInputValue: (view: DataView, byteOffset: number) => number;
-
- outputSampleSize: 1 | 2 | 4;
- outputFormat: 'u8' | 's16' | 's32' | 'f32';
- writeOutputValue: (view: DataView, byteOffset: number, value: number) => void;
-
- // Internal state to accumulate a precise current timestamp based on audio durations, not the (potentially
- // inaccurate) packet timestamps.
- currentTimestamp: number | null = null;
-
- constructor(
- onSample: (sample: AudioSample) => unknown,
- onError: (error: unknown) => unknown,
- public decoderConfig: AudioDecoderConfig,
- ) {
- super(onSample, onError);
-
- assert((PCM_AUDIO_CODECS as readonly string[]).includes(decoderConfig.codec));
- this.codec = decoderConfig.codec as PcmAudioCodec;
-
- const { dataType, sampleSize, littleEndian } = parsePcmCodec(this.codec);
- this.inputSampleSize = sampleSize;
-
- switch (sampleSize) {
- case 1: {
- if (dataType === 'unsigned') {
- this.readInputValue = (view, byteOffset) => view.getUint8(byteOffset) - 2 ** 7;
- } else if (dataType === 'signed') {
- this.readInputValue = (view, byteOffset) => view.getInt8(byteOffset);
- } else if (dataType === 'ulaw') {
- this.readInputValue = (view, byteOffset) => fromUlaw(view.getUint8(byteOffset));
- } else if (dataType === 'alaw') {
- this.readInputValue = (view, byteOffset) => fromAlaw(view.getUint8(byteOffset));
- } else {
- assert(false);
- }
- }; break;
- case 2: {
- if (dataType === 'unsigned') {
- this.readInputValue = (view, byteOffset) => view.getUint16(byteOffset, littleEndian) - 2 ** 15;
- } else if (dataType === 'signed') {
- this.readInputValue = (view, byteOffset) => view.getInt16(byteOffset, littleEndian);
- } else {
- assert(false);
- }
- }; break;
- case 3: {
- if (dataType === 'unsigned') {
- this.readInputValue = (view, byteOffset) => getUint24(view, byteOffset, littleEndian) - 2 ** 23;
- } else if (dataType === 'signed') {
- this.readInputValue = (view, byteOffset) => getInt24(view, byteOffset, littleEndian);
- } else {
- assert(false);
- }
- }; break;
- case 4: {
- if (dataType === 'unsigned') {
- this.readInputValue = (view, byteOffset) => view.getUint32(byteOffset, littleEndian) - 2 ** 31;
- } else if (dataType === 'signed') {
- this.readInputValue = (view, byteOffset) => view.getInt32(byteOffset, littleEndian);
- } else if (dataType === 'float') {
- this.readInputValue = (view, byteOffset) => view.getFloat32(byteOffset, littleEndian);
- } else {
- assert(false);
- }
- }; break;
- case 8: {
- if (dataType === 'float') {
- this.readInputValue = (view, byteOffset) => view.getFloat64(byteOffset, littleEndian);
- } else {
- assert(false);
- }
- }; break;
- default: {
- assertNever(sampleSize);
- assert(false);
- };
- }
-
- switch (sampleSize) {
- case 1: {
- if (dataType === 'ulaw' || dataType === 'alaw') {
- this.outputSampleSize = 2;
- this.outputFormat = 's16';
- this.writeOutputValue = (view, byteOffset, value) => view.setInt16(byteOffset, value, true);
- } else {
- this.outputSampleSize = 1;
- this.outputFormat = 'u8';
- this.writeOutputValue = (view, byteOffset, value) => view.setUint8(byteOffset, value + 2 ** 7);
- }
- }; break;
- case 2: {
- this.outputSampleSize = 2;
- this.outputFormat = 's16';
- this.writeOutputValue = (view, byteOffset, value) => view.setInt16(byteOffset, value, true);
- }; break;
- case 3: {
- this.outputSampleSize = 4;
- this.outputFormat = 's32';
- // From https://www.w3.org/TR/webcodecs:
- // AudioData containing 24-bit samples SHOULD store those samples in s32 or f32. When samples are
- // stored in s32, each sample MUST be left-shifted by 8 bits.
- this.writeOutputValue = (view, byteOffset, value) => view.setInt32(byteOffset, value << 8, true);
- }; break;
- case 4: {
- this.outputSampleSize = 4;
-
- if (dataType === 'float') {
- this.outputFormat = 'f32';
- this.writeOutputValue = (view, byteOffset, value) => view.setFloat32(byteOffset, value, true);
- } else {
- this.outputFormat = 's32';
- this.writeOutputValue = (view, byteOffset, value) => view.setInt32(byteOffset, value, true);
- }
- }; break;
- case 8: {
- this.outputSampleSize = 4;
-
- this.outputFormat = 'f32';
- this.writeOutputValue = (view, byteOffset, value) => view.setFloat32(byteOffset, value, true);
- }; break;
- default: {
- assertNever(sampleSize);
- assert(false);
- };
- };
- }
-
- getDecodeQueueSize() {
- return 0;
- }
-
- decode(packet: EncodedPacket) {
- const inputView = toDataView(packet.data);
-
- const numberOfFrames = packet.byteLength / this.decoderConfig.numberOfChannels / this.inputSampleSize;
-
- const outputBufferSize = numberOfFrames * this.decoderConfig.numberOfChannels * this.outputSampleSize;
- const outputBuffer = new ArrayBuffer(outputBufferSize);
- const outputView = new DataView(outputBuffer);
-
- for (let i = 0; i < numberOfFrames * this.decoderConfig.numberOfChannels; i++) {
- const inputIndex = i * this.inputSampleSize;
- const outputIndex = i * this.outputSampleSize;
-
- const value = this.readInputValue(inputView, inputIndex);
- this.writeOutputValue(outputView, outputIndex, value);
- }
-
- const preciseDuration = numberOfFrames / this.decoderConfig.sampleRate;
- if (this.currentTimestamp === null || Math.abs(packet.timestamp - this.currentTimestamp) >= preciseDuration) {
- // We need to sync with the packet timestamp again
- this.currentTimestamp = packet.timestamp;
- }
-
- const preciseTimestamp = this.currentTimestamp;
- this.currentTimestamp += preciseDuration;
-
- const audioSample = new AudioSample({
- format: this.outputFormat,
- data: outputBuffer,
- numberOfChannels: this.decoderConfig.numberOfChannels,
- sampleRate: this.decoderConfig.sampleRate,
- numberOfFrames,
- timestamp: preciseTimestamp,
- });
-
- this.onSample(audioSample);
- }
-
- async flush() {
- // Do nothing
- }
-
- close() {
- // Do nothing
- }
-}
-
-/**
- * Sink for retrieving decoded audio samples from an audio track.
- * @group Media sinks
- * @public
- */
-export class AudioSampleSink extends BaseMediaSampleSink {
- /** @internal */
- _track: InputAudioTrack;
-
- /** Creates a new {@link AudioSampleSink} for the given {@link InputAudioTrack}. */
- constructor(audioTrack: InputAudioTrack) {
- if (!(audioTrack instanceof InputAudioTrack)) {
- throw new TypeError('audioTrack must be an InputAudioTrack.');
- }
-
- super();
-
- this._track = audioTrack;
- }
-
- /** @internal */
- async _createDecoder(
- onSample: (sample: AudioSample) => unknown,
- onError: (error: unknown) => unknown,
- ) {
- if (!(await this._track.canDecode())) {
- if (typeof AudioDecoder === 'undefined') {
- throw new Error(missingWebCodecsClassMessage('AudioDecoder'));
- }
-
- throw new Error(
- 'This audio track cannot be decoded in this environment. Make sure to check decodability before using'
- + ' a track.',
- );
- }
-
- const codec = await this._track.getCodec();
- const decoderConfig = await this._track.getDecoderConfig();
- assert(codec && decoderConfig);
-
- if ((PCM_AUDIO_CODECS as readonly string[]).includes(decoderConfig.codec)) {
- return new PcmAudioDecoderWrapper(onSample, onError, decoderConfig);
- } else {
- return new AudioDecoderWrapper(onSample, onError, codec, decoderConfig);
- }
- }
-
- /** @internal */
- _createPacketSink() {
- return new EncodedPacketSink(this._track);
- }
-
- /**
- * Retrieves the audio sample corresponding to the given timestamp, in seconds. More specifically, returns
- * the last audio sample (in presentation order) with a start timestamp less than or equal to the given timestamp.
- * Returns null if the timestamp is before the track's first timestamp.
- *
- * @param timestamp - The timestamp used for retrieval, in seconds.
- * @param options - Options used for the underlying packet retrieval.
- */
- async getSample(timestamp: number, options: PacketRetrievalOptions = {}) {
- validateTimestamp(timestamp);
-
- for await (const sample of this.mediaSamplesAtTimestamps([timestamp], options)) {
- return sample;
- }
- throw new Error('Internal error: Iterator returned nothing.');
- }
-
- /**
- * Creates an async iterator that yields the audio samples of this track in presentation order. This method
- * will intelligently pre-decode a few samples ahead to enable fast iteration.
- *
- * @param startTimestamp - The timestamp in seconds at which to start yielding samples (inclusive).
- * @param endTimestamp - The timestamp in seconds at which to stop yielding samples (exclusive).
- * @param options - Options used for the underlying packet retrieval.
- */
- samples(startTimestamp?: number, endTimestamp?: number, options: PacketRetrievalOptions = {}) {
- return this.mediaSamplesInRange(startTimestamp, endTimestamp, options);
- }
-
- /**
- * Creates an async iterator that yields an audio sample for each timestamp in the argument. This method
- * uses an optimized decoding pipeline if these timestamps are monotonically sorted, decoding each packet at most
- * once, and is therefore more efficient than manually getting the sample for every timestamp. The iterator may
- * yield null if no sample is available for a given timestamp.
- *
- * This method is good for sparse access of media data. If you want primarily sequential media access, prefer
- * {@link AudioSampleSink.samples} instead.
- *
- * @param timestamps - An iterable or async iterable of timestamps in seconds.
- * @param options - Options used for the underlying packet retrieval.
- */
- samplesAtTimestamps(timestamps: AnyIterable, options: PacketRetrievalOptions = {}) {
- return this.mediaSamplesAtTimestamps(timestamps, options);
- }
-}
-
-/**
- * An AudioBuffer with additional timing information (timestamp & duration).
- * @group Media sinks
- * @public
- */
-export type WrappedAudioBuffer = {
- /** An AudioBuffer. */
- buffer: AudioBuffer;
- /** The timestamp of the corresponding audio sample, in seconds. */
- timestamp: number;
- /** The duration of the corresponding audio sample, in seconds. */
- duration: number;
-};
-
-/**
- * A sink that retrieves decoded audio samples from an audio track and converts them to `AudioBuffer` instances. This is
- * often more useful than directly retrieving audio samples, as audio buffers can be directly used with the
- * Web Audio API.
- * @group Media sinks
- * @public
- */
-export class AudioBufferSink {
- /** @internal */
- _audioSampleSink: AudioSampleSink;
-
- /** Creates a new {@link AudioBufferSink} for the given {@link InputAudioTrack}. */
- constructor(audioTrack: InputAudioTrack) {
- if (!(audioTrack instanceof InputAudioTrack)) {
- throw new TypeError('audioTrack must be an InputAudioTrack.');
- }
-
- this._audioSampleSink = new AudioSampleSink(audioTrack);
- }
-
- /** @internal */
- _audioSampleToWrappedArrayBuffer(sample: AudioSample): WrappedAudioBuffer {
- const result: WrappedAudioBuffer = {
- buffer: sample.toAudioBuffer(),
- timestamp: sample.timestamp,
- duration: sample.duration,
- };
-
- sample.close();
- return result;
- }
-
- /**
- * Retrieves the audio buffer corresponding to the given timestamp, in seconds. More specifically, returns
- * the last audio buffer (in presentation order) with a start timestamp less than or equal to the given timestamp.
- * Returns null if the timestamp is before the track's first timestamp.
- *
- * @param timestamp - The timestamp used for retrieval, in seconds.
- * @param options - Options used for the underlying packet retrieval.
- */
- async getBuffer(timestamp: number, options?: PacketRetrievalOptions) {
- validateTimestamp(timestamp);
-
- const data = await this._audioSampleSink.getSample(timestamp, options);
- return data && this._audioSampleToWrappedArrayBuffer(data);
- }
-
- /**
- * Creates an async iterator that yields audio buffers of this track in presentation order. This method
- * will intelligently pre-decode a few buffers ahead to enable fast iteration.
- *
- * @param startTimestamp - The timestamp in seconds at which to start yielding buffers (inclusive).
- * @param endTimestamp - The timestamp in seconds at which to stop yielding buffers (exclusive).
- * @param options - Options used for the underlying packet retrieval.
- */
- buffers(startTimestamp?: number, endTimestamp?: number, options?: PacketRetrievalOptions) {
- return mapAsyncGenerator(
- this._audioSampleSink.samples(startTimestamp, endTimestamp, options),
- data => this._audioSampleToWrappedArrayBuffer(data),
- );
- }
-
- /**
- * Creates an async iterator that yields an audio buffer for each timestamp in the argument. This method
- * uses an optimized decoding pipeline if these timestamps are monotonically sorted, decoding each packet at most
- * once, and is therefore more efficient than manually getting the buffer for every timestamp. The iterator may
- * yield null if no buffer is available for a given timestamp.
- *
- * @param timestamps - An iterable or async iterable of timestamps in seconds.
- * @param options - Options used for the underlying packet retrieval.
- */
- buffersAtTimestamps(timestamps: AnyIterable, options?: PacketRetrievalOptions) {
- return mapAsyncGenerator(
- this._audioSampleSink.samplesAtTimestamps(timestamps, options),
- data => data && this._audioSampleToWrappedArrayBuffer(data),
- );
- }
-}
diff --git a/src/media-source.ts b/src/media-source.ts
index dad5832fb..973abcbd4 100644
--- a/src/media-source.ts
+++ b/src/media-source.ts
@@ -24,7 +24,7 @@ import {
assert,
assertNever,
binarySearchLessOrEqual,
- CallSerializer,
+ NaiveCallSerializer,
clamp,
clearIntervalUnthrottled,
floorToDivisor,
@@ -266,7 +266,7 @@ class VideoEncoderWrapper {
}[] = [];
private customEncoder: CustomVideoEncoder | null = null;
- private customEncoderCallSerializer = new CallSerializer();
+ private customEncoderCallSerializer = new NaiveCallSerializer();
private customEncoderQueueSize = 0;
// Set when the encoder uses quantizer-based rate control; carries the quantizer value applied to each frame
@@ -299,8 +299,6 @@ class VideoEncoderWrapper {
constructor(private source: VideoSource, private encodingConfig: VideoEncodingConfig) {}
async add(videoSample: VideoSample, shouldClose: boolean, encodeOptions?: VideoEncoderEncodeOptions) {
- const originalSample = videoSample;
-
try {
this.checkForEncoderError();
this.source._ensureValidAdd();
@@ -402,14 +400,17 @@ class VideoEncoderWrapper {
}
}
- // Clone if the sample is still the user's, to avoid mutating externally-owned data
- if (videoSample === originalSample) {
- videoSample = videoSample.clone();
- shouldClose = true;
+ const adjusted = videoSample.clone({
+ timestamp: alignedTimestamp,
+ duration: 1 / frameRate,
+ });
+
+ if (shouldClose) {
+ videoSample.close();
}
- videoSample.setTimestamp(alignedTimestamp);
- videoSample.setDuration(1 / frameRate);
+ videoSample = adjusted;
+ shouldClose = true;
this.frameRateLastSample?.close();
this.frameRateLastSample = videoSample.clone();
@@ -656,9 +657,10 @@ class VideoEncoderWrapper {
const frameDifference = Math.round((until - this.frameRateLastTimestamp!) * frameRate);
for (let i = 1; i < frameDifference; i++) {
- using sample = this.frameRateLastSample.clone();
- sample.setTimestamp(this.frameRateLastTimestamp! + i / frameRate);
- sample.setDuration(1 / frameRate);
+ using sample = this.frameRateLastSample.clone({
+ timestamp: this.frameRateLastTimestamp! + i / frameRate,
+ duration: 1 / frameRate,
+ });
await this.processAndEncode(sample, encodeOptions);
}
}
@@ -1902,7 +1904,7 @@ class AudioEncoderWrapper {
private writeOutputValue: ((view: DataView, byteOffset: number, value: number) => void) | null = null;
private customEncoder: CustomAudioEncoder | null = null;
- private customEncoderCallSerializer = new CallSerializer();
+ private customEncoderCallSerializer = new NaiveCallSerializer();
private customEncoderQueueSize = 0;
private lastEndSampleIndex: number | null = null;
@@ -2806,7 +2808,8 @@ export class MediaStreamAudioTrackSource extends AudioSource {
return;
}
- audioSample.setTimestamp(currentTimestamp + timestampOffset);
+ // @ts-expect-error Readonly, kind of a hack
+ audioSample.timestamp = currentTimestamp + timestampOffset;
void this._encoder.add(audioSample, true)
.catch((error) => {
diff --git a/src/metadata.ts b/src/metadata.ts
index d452b855b..561d44e6b 100644
--- a/src/metadata.ts
+++ b/src/metadata.ts
@@ -81,7 +81,7 @@ export type MetadataTags = {
* Additionally, the `'vendor'` key refers to the vendor string within this header. If a key exists more than once,
* a string array is used instead.
* - WAVE: The individual metadata chunks within the RIFF INFO chunk. Values are always ISO 8859-1 strings.
- * - FLAC: The key-value string pairs from the vorbis metadata block (see RFC 9639, Section D.2.3).
+ * - FLAC: The key-value string pairs from the Vorbis metadata block (see RFC 9639, Section D.2.3).
* Additionally, the `'vendor'` key refers to the vendor string within this header. If a key exists more than once,
* a string array is used instead. If ID3v2 tags appear at the start of the file, their content is stored just like
* for MP3.
diff --git a/src/misc.ts b/src/misc.ts
index b87cd147b..50b4ca469 100644
--- a/src/misc.ts
+++ b/src/misc.ts
@@ -156,6 +156,19 @@ export const composeRotationAndFlip = (
};
};
+/** The inverse of {@link composeRotationAndFlip}: what needs to be composed onto the base to arrive at the target. */
+export const relativeRotationAndFlip = (
+ baseRotation: Rotation,
+ baseFlip: boolean,
+ targetRotation: Rotation,
+ targetFlip: boolean,
+) => {
+ return {
+ rotation: normalizeRotation(baseFlip ? baseRotation - targetRotation : targetRotation - baseRotation),
+ flip: baseFlip !== targetFlip,
+ };
+};
+
export const last = (arr: T[]) => {
return arr && arr[arr.length - 1];
};
@@ -485,36 +498,6 @@ export const isAllowSharedBufferSource = (x: unknown) => {
);
};
-export class AsyncMutex {
- currentPromise = Promise.resolve();
- pending = 0;
-
- async acquire() {
- let resolver: () => void;
- const nextPromise = new Promise((resolve) => {
- let resolved = false;
-
- resolver = () => {
- if (resolved) {
- return;
- }
-
- resolve();
- this.pending--;
- resolved = true;
- };
- });
-
- const currentPromiseAlias = this.currentPromise;
- this.currentPromise = nextPromise;
- this.pending++;
-
- await currentPromiseAlias;
-
- return resolver!;
- }
-}
-
export const HEX_STRING_REGEX = /^[0-9a-fA-F]+$/;
export const bytesToHexString = (bytes: Uint8Array) => {
@@ -600,6 +583,35 @@ export const promiseWithResolvers = () => {
return { promise, resolve: resolve!, reject: reject! };
};
+export const promiseAllEnsureOrder = async (promises: T[]) => {
+ const results: Awaited[] = [];
+ const { promise, resolve, reject } = promiseWithResolvers();
+
+ const onValue = (value: Awaited, i: number) => {
+ if (results.length === i) {
+ results.push(value);
+
+ if (results.length === promises.length) {
+ resolve();
+ }
+ } else {
+ reject(new Error('Order violation'));
+ }
+ };
+
+ for (let i = 0; i < promises.length; i++) {
+ const value = promises[i]!;
+ if (isThenable(value)) {
+ void value.then(x => onValue(x as Awaited, i));
+ } else {
+ onValue(value as Awaited, i);
+ }
+ }
+
+ await promise;
+ return results;
+};
+
export const removeItem = (arr: T[], item: T) => {
const index = arr.indexOf(item);
if (index !== -1) {
@@ -782,6 +794,37 @@ export const floorToDivisor = (value: number, multiple: number) => {
return Math.floor(value * multiple) / multiple;
};
+const nextDownView = /* #__PURE__ */ new DataView(new ArrayBuffer(8));
+
+/** Returns the largest number smaller than the given number. */
+export const nextDown = (value: number) => {
+ if (value === 0) {
+ return -Number.MIN_VALUE;
+ }
+
+ nextDownView.setFloat64(0, value);
+ const high = nextDownView.getUint32(0);
+ const low = nextDownView.getUint32(4);
+
+ if (value > 0) {
+ if (low === 0) {
+ nextDownView.setUint32(0, high - 1);
+ nextDownView.setUint32(4, 0xffffffff);
+ } else {
+ nextDownView.setUint32(4, low - 1);
+ }
+ } else {
+ if (low === 0xffffffff) {
+ nextDownView.setUint32(0, high + 1);
+ nextDownView.setUint32(4, 0);
+ } else {
+ nextDownView.setUint32(4, low + 1);
+ }
+ }
+
+ return nextDownView.getFloat64(0);
+};
+
export const ilog = (x: number) => {
let ret = 0;
while (x) {
@@ -973,14 +1016,6 @@ export const computeRationalApproximation = (x: number, maxDenominator: number):
};
};
-export class CallSerializer {
- currentPromise = Promise.resolve();
-
- call(fn: () => Promise | void) {
- return this.currentPromise = this.currentPromise.then(fn);
- }
-}
-
let isWebKitCache: boolean | null = null;
export const isWebKit = () => {
if (isWebKitCache !== null) {
@@ -1171,12 +1206,217 @@ export const polyfillSymbolDispose = () => {
// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html
// @ts-expect-error Readonly
Symbol.dispose ??= Symbol('Symbol.dispose');
+ // @ts-expect-error Readonly
+ Symbol.asyncDispose ??= Symbol('Symbol.asyncDispose');
};
export const isNumber = (x: unknown) => {
return typeof x === 'number' && !Number.isNaN(x);
};
+// We use a unique symbol to ensure that any function using the ResultValue system actually returns when setting it,
+// instead of simply setting it and continuing on.
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+const returnSymbol: unique symbol = Symbol();
+export type ReturnSymbol = typeof returnSymbol;
+export type MaybeRelevantPromise = Promise;
+
+/**
+ * Represents a wrapper that holds a function's return value. Instead of the function returning its return value, it
+ * instead gets passed a ResultValue instance and writes the return value into it. This allows async functions that
+ * don't hit any async path to synchronously expose their return value, allowing the caller to ignore the returned
+ * promise if possible. This allows for "optinally-asynchronous code".
+ */
+export class ResultValue {
+ value!: T;
+ pending = true;
+
+ // @ts-expect-error Return value just for the types
+ set(value: T): ReturnSymbol {
+ this.value = value;
+ this.pending = false;
+ }
+
+ reset() {
+ this.pending = true;
+ }
+}
+
+export class AsyncMutex {
+ locked = false;
+ resolverQueue: (() => void)[] = [];
+
+ lock() {
+ if (!this.locked) {
+ // Fast path
+ this.locked = true;
+ return new AsyncMutexLock(this, false, null);
+ }
+
+ const { promise, resolve } = promiseWithResolvers();
+ this.resolverQueue.push(resolve);
+
+ return new AsyncMutexLock(this, true, promise);
+ }
+
+ dispatch() {
+ if (this.resolverQueue.length > 0) {
+ const resolve = this.resolverQueue.shift()!;
+ resolve();
+ } else {
+ this.locked = false;
+ }
+ }
+
+ async waitForUnlock() {
+ const lock = this.lock();
+ await lock.ready;
+ lock.release();
+ }
+}
+
+export class AsyncMutexLock implements Disposable {
+ private released = false;
+
+ constructor(
+ private readonly mutex: AsyncMutex,
+ public readonly pending: boolean,
+ public readonly ready: Promise | null,
+ ) {}
+
+ release() {
+ if (this.released) {
+ return;
+ }
+
+ this.released = true;
+ this.mutex.dispatch();
+ }
+
+ [Symbol.dispose]() {
+ this.release();
+ }
+}
+
+/**
+ * A simple call serializer that works by chaining promises. When one callback throws, the serializer becomes bricked,
+ * meaning all future calls will also throw.
+ */
+export class NaiveCallSerializer {
+ currentPromise = Promise.resolve();
+ errored = false;
+
+ call(fn: () => Promise | void) {
+ return this.currentPromise = this.currentPromise
+ .then(fn)
+ .catch((error) => {
+ this.errored = true;
+ throw error;
+ });
+ }
+}
+
+/**
+ * A more complex call serializer implementation that works with optionally asynchronous functions. It is forgiving
+ * in the sense that when a call throws, the error is surfaced but subsequent calls will go through again. So, it
+ * recovers.
+ */
+export class ForgivingCallSerializer {
+ private currentPromise: Promise | null = null;
+ private queuedCalls = 0;
+
+ call(fn: () => T) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ type ReturnType = T extends Promise ? T : T | Promise;
+
+ if (this.currentPromise) {
+ this.queuedCalls++;
+
+ return (this.currentPromise = this.currentPromise
+ .catch(() => {})
+ .then(() => {
+ this.queuedCalls--;
+ return fn();
+ })
+ .finally(() => {
+ if (this.queuedCalls === 0) {
+ this.currentPromise = null;
+ }
+ })) as unknown as ReturnType;
+ } else {
+ const result = fn();
+
+ if (isThenable(result)) {
+ this.currentPromise = result
+ .catch(() => {})
+ .finally(() => {
+ if (this.queuedCalls === 0) {
+ this.currentPromise = null;
+ }
+ });
+ }
+
+ return result as unknown as ReturnType;
+ }
+ }
+
+ waitUntilIdle() {
+ if (this.currentPromise) {
+ return this.currentPromise
+ .catch(() => {})
+ .then(() => {});
+ } else {
+ return null;
+ }
+ }
+
+ isIdle() {
+ return !this.currentPromise;
+ }
+}
+
+export class AsyncGate {
+ resolvers: (() => void)[] = [];
+
+ wait() {
+ const { promise, resolve } = promiseWithResolvers();
+
+ this.resolvers.push(resolve);
+ return promise;
+ }
+
+ open() {
+ if (this.resolvers.length > 0) {
+ this.resolvers.forEach(fn => fn());
+ this.resolvers.length = 0;
+ }
+ }
+}
+
+export const defer = (callback: () => void) => {
+ let executed = false;
+
+ return {
+ execute() {
+ if (executed) {
+ return;
+ }
+
+ executed = true;
+ callback();
+ },
+ [Symbol.dispose]() {
+ this.execute();
+ },
+ };
+};
+
+export const promiseIterateAll = async function* (iterable: Iterable) {
+ for (const promise of iterable) {
+ yield await promise;
+ }
+};
+
/**
* A path to a file. File paths can be relative or absolute, and be local paths or full URLs. Paths must be POSIX-like,
* using `/` as the separator.
@@ -1392,6 +1632,13 @@ export const validateRectangle = (rect: Rectangle, propertyPath: string) => {
}
};
+export const rectangleToDOMRectInit = (rectangle: Rectangle): DOMRectInit => ({
+ x: rectangle.left,
+ y: rectangle.top,
+ width: rectangle.width,
+ height: rectangle.height,
+});
+
export type NonFunctionKeys = {
[K in keyof T]-?: T[K] extends ((...args: never[]) => unknown) ? never : K
}[keyof T];
diff --git a/src/mp3/mp3-demuxer.ts b/src/mp3/mp3-demuxer.ts
index a89d22203..15b682f85 100644
--- a/src/mp3/mp3-demuxer.ts
+++ b/src/mp3/mp3-demuxer.ts
@@ -11,17 +11,17 @@ import { Demuxer } from '../demuxer';
import { Input } from '../input';
import { InputAudioTrackBacking } from '../input-track';
import { DEFAULT_TRACK_DISPOSITION, MetadataTags } from '../metadata';
-import { PacketRetrievalOptions } from '../media-sink';
import {
assert,
AsyncMutex,
- binarySearchExact,
binarySearchLessOrEqual,
isThenable,
toDataView,
+ MaybeRelevantPromise,
+ ResultValue,
UNDETERMINED_LANGUAGE,
} from '../misc';
-import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
+import { EncodedPacket, PLACEHOLDER_DATA, PacketRetrievalOptions, PacketRetrievalResult } from '../packet';
import {
Mp3FrameHeader,
getXingOffset,
@@ -80,7 +80,9 @@ export class Mp3Demuxer extends Demuxer {
return this.metadataPromise ??= (async () => {
// Keep loading until we find the first frame header
while (!this.firstFrameHeader && !this.lastSampleLoaded) {
- await this.advanceReader();
+ const result = new ResultValue();
+ const promise = this.advanceReader(result);
+ if (result.pending) await promise;
}
if (!this.firstFrameHeader && this.xingFrameHeader) {
@@ -98,7 +100,7 @@ export class Mp3Demuxer extends Demuxer {
})();
}
- async advanceReader() {
+ async advanceReader(res: ResultValue): MaybeRelevantPromise {
if (this.lastLoadedPos === 0) {
// Let's skip all ID3v2 tags at the start of the file
while (true) {
@@ -107,7 +109,7 @@ export class Mp3Demuxer extends Demuxer {
if (!slice) {
this.lastSampleLoaded = true;
- return;
+ return res.set();
}
const id3V2Header = readId3V2Header(slice);
@@ -119,24 +121,31 @@ export class Mp3Demuxer extends Demuxer {
}
}
- const result = await readNextMp3FrameHeader(
+ const result = new ResultValue<{
+ header: Mp3FrameHeader;
+ startPos: number;
+ } | null>();
+ const promise = readNextMp3FrameHeader(
+ result,
this.reader,
this.lastLoadedPos,
this.reader.fileSize,
this.firstFrameHeader,
);
- if (!result) {
+ if (result.pending) await promise;
+
+ if (!result.value) {
this.lastSampleLoaded = true;
- return;
+ return res.set();
}
- const header = result.header;
+ const header = result.value.header;
- this.lastLoadedPos = result.startPos + header.totalSize - 1; // -1 in case the frame is 1 byte too short
+ this.lastLoadedPos = result.value.startPos + header.totalSize - 1; // -1 in case the frame is 1 byte too short
const xingOffset = getXingOffset(header.mpegVersionId, header.channel);
- let slice = this.reader.requestSlice(result.startPos + xingOffset, 4);
+ let slice = this.reader.requestSlice(result.value.startPos + xingOffset, 4);
if (isThenable(slice)) slice = await slice;
if (slice) {
const word = readU32Be(slice);
@@ -147,11 +156,11 @@ export class Mp3Demuxer extends Demuxer {
if (!this.xingFrameHeader) {
this.xingFrameHeader = header;
- this.xingFrameHeaderPos = result.startPos;
+ this.xingFrameHeaderPos = result.value.startPos;
}
if (!this.xingData) {
- let xingDataSlice = this.reader.requestSlice(result.startPos + xingOffset + 4, 12);
+ let xingDataSlice = this.reader.requestSlice(result.value.startPos + xingOffset + 4, 12);
if (isThenable(xingDataSlice)) xingDataSlice = await xingDataSlice;
if (xingDataSlice) {
const xingData = readBytes(xingDataSlice, 12);
@@ -169,27 +178,27 @@ export class Mp3Demuxer extends Demuxer {
}
}
- return;
+ return res.set();
}
}
if (!this.firstFrameHeader) {
this.firstFrameHeader = header;
- this.firstFrameHeaderPos = result.startPos;
+ this.firstFrameHeaderPos = result.value.startPos;
}
const sampleDuration = header.audioSamplesInFrame / this.firstFrameHeader.sampleRate;
const sample: Sample = {
timestamp: this.nextTimestampInSamples / this.firstFrameHeader.sampleRate,
duration: sampleDuration,
- dataStart: result.startPos,
+ dataStart: result.value.startPos,
dataSize: header.totalSize,
};
this.loadedSamples.push(sample);
this.nextTimestampInSamples += header.audioSamplesInFrame;
- return;
+ return res.set();
}
async getMimeType() {
@@ -202,56 +211,53 @@ export class Mp3Demuxer extends Demuxer {
}
async getMetadataTags() {
- const release = await this.readingMutex.acquire();
+ using lock = this.readingMutex.lock();
+ if (lock.pending) await lock.ready;
- try {
- await this.readMetadata();
+ await this.readMetadata();
- if (this.metadataTags) {
- return this.metadataTags;
- }
+ if (this.metadataTags) {
+ return this.metadataTags;
+ }
- this.metadataTags = {};
- let currentPos = 0;
- let id3V2HeaderFound = false;
+ this.metadataTags = {};
+ let currentPos = 0;
+ let id3V2HeaderFound = false;
- while (true) {
- let headerSlice = this.reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE);
- if (isThenable(headerSlice)) headerSlice = await headerSlice;
- if (!headerSlice) break;
+ while (true) {
+ let headerSlice = this.reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE);
+ if (isThenable(headerSlice)) headerSlice = await headerSlice;
+ if (!headerSlice) break;
- const id3V2Header = readId3V2Header(headerSlice);
- if (!id3V2Header) {
- break;
- }
+ const id3V2Header = readId3V2Header(headerSlice);
+ if (!id3V2Header) {
+ break;
+ }
- id3V2HeaderFound = true;
+ id3V2HeaderFound = true;
- let contentSlice = this.reader.requestSlice(headerSlice.filePos, id3V2Header.size);
- if (isThenable(contentSlice)) contentSlice = await contentSlice;
- if (!contentSlice) break;
+ let contentSlice = this.reader.requestSlice(headerSlice.filePos, id3V2Header.size);
+ if (isThenable(contentSlice)) contentSlice = await contentSlice;
+ if (!contentSlice) break;
- parseId3V2Tag(contentSlice, id3V2Header, this.metadataTags);
+ parseId3V2Tag(contentSlice, id3V2Header, this.metadataTags);
- currentPos = headerSlice.filePos + id3V2Header.size;
- }
+ currentPos = headerSlice.filePos + id3V2Header.size;
+ }
- if (!id3V2HeaderFound && this.reader.fileSize !== null && this.reader.fileSize >= ID3_V1_TAG_SIZE) {
- // Try reading an ID3v1 tag at the end of the file
- let slice = this.reader.requestSlice(this.reader.fileSize - ID3_V1_TAG_SIZE, ID3_V1_TAG_SIZE);
- if (isThenable(slice)) slice = await slice;
- assert(slice);
+ if (!id3V2HeaderFound && this.reader.fileSize !== null && this.reader.fileSize >= ID3_V1_TAG_SIZE) {
+ // Try reading an ID3v1 tag at the end of the file
+ let slice = this.reader.requestSlice(this.reader.fileSize - ID3_V1_TAG_SIZE, ID3_V1_TAG_SIZE);
+ if (isThenable(slice)) slice = await slice;
+ assert(slice);
- const tag = readAscii(slice, 3);
- if (tag === 'TAG') {
- parseId3V1Tag(slice, this.metadataTags);
- }
+ const tag = readAscii(slice, 3);
+ if (tag === 'TAG') {
+ parseId3V1Tag(slice, this.metadataTags);
}
-
- return this.metadataTags;
- } finally {
- release();
}
+
+ return this.metadataTags;
}
}
@@ -374,14 +380,18 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
};
}
- async getPacketAtIndex(sampleIndex: number, options: PacketRetrievalOptions) {
+ async getPacketAtIndex(
+ res: ResultValue,
+ sampleIndex: number,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise {
if (sampleIndex === -1) {
- return null;
+ return res.set(new PacketRetrievalResult(null));
}
const rawSample = this.demuxer.loadedSamples[sampleIndex];
if (!rawSample) {
- return null;
+ return res.set(new PacketRetrievalResult(null));
}
let data: Uint8Array;
@@ -392,93 +402,102 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
if (isThenable(slice)) slice = await slice;
if (!slice) {
- return null; // Data didn't fit into the rest of the file
+ return res.set(new PacketRetrievalResult(null)); // Data didn't fit into the rest of the file
}
data = readBytes(slice, rawSample.dataSize);
}
- return new EncodedPacket(
+ return res.set(new PacketRetrievalResult(new EncodedPacket(
data,
'key',
rawSample.timestamp,
rawSample.duration,
sampleIndex,
rawSample.dataSize,
- );
+ )));
}
- getFirstPacket(options: PacketRetrievalOptions) {
- return this.getPacketAtIndex(0, options);
+ getFirstPacket(res: ResultValue, options: PacketRetrievalOptions) {
+ return this.getPacketAtIndex(res, 0, options);
}
- async getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions) {
- const release = await this.demuxer.readingMutex.acquire();
+ async getNextPacket(
+ res: ResultValue,
+ packet: EncodedPacket,
+ options: PacketRetrievalOptions,
+ ): MaybeRelevantPromise {
+ using lock = this.demuxer.readingMutex.lock();
+ if (lock.pending) await lock.ready;
+
+ const sampleIndex = packet.sequenceNumber;
+ assert(sampleIndex >= 0);
+
+ const nextIndex = sampleIndex + 1;
+
+ // Ensure the next sample exists
+ while (
+ nextIndex >= this.demuxer.loadedSamples.length
+ && !this.demuxer.lastSampleLoaded
+ ) {
+ const result = new ResultValue