mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-03 13:53:53 +02:00
Update media player example to use cursors, fix some cursor bugs, add more cursor tests
This commit is contained in:
@@ -1,12 +1,10 @@
|
||||
import {
|
||||
ALL_FORMATS,
|
||||
AudioBufferSink,
|
||||
AudioSampleCursor,
|
||||
BlobSource,
|
||||
CanvasSink,
|
||||
Input,
|
||||
UrlSource,
|
||||
WrappedAudioBuffer,
|
||||
WrappedCanvas,
|
||||
VideoSampleCursor,
|
||||
} from 'mediabunny';
|
||||
|
||||
import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4';
|
||||
@@ -41,8 +39,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 totalDuration = 0;
|
||||
/** The value of the audio context's currentTime the moment the playback was started. */
|
||||
@@ -51,17 +49,8 @@ let playing = false;
|
||||
/** The timestamp within the media file when the playback was started. */
|
||||
let playbackTimeAtStart = 0;
|
||||
|
||||
let videoFrameIterator: AsyncGenerator<WrappedCanvas, void, unknown> | null = null;
|
||||
let audioBufferIterator: AsyncGenerator<WrappedAudioBuffer, void, unknown> | null = null;
|
||||
let nextFrame: WrappedCanvas | null = null;
|
||||
const queuedAudioNodes: Set<AudioBufferSourceNode> = 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 draggingProgressBar = false;
|
||||
let volume = 0.7;
|
||||
let draggingVolumeBar = false;
|
||||
@@ -77,9 +66,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;
|
||||
@@ -155,15 +143,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) {
|
||||
@@ -185,8 +172,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();
|
||||
@@ -195,7 +180,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 = '';
|
||||
@@ -212,33 +197,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();
|
||||
@@ -248,14 +207,17 @@ const render = (requestFrame = true) => {
|
||||
playbackTimeAtStart = totalDuration;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -272,50 +234,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!);
|
||||
|
||||
const startTimestamp = audioContextStartTime! + timestamp - playbackTimeAtStart;
|
||||
const startTimestamp = audioContextStartTime! + sample.timestamp - playbackTimeAtStart;
|
||||
|
||||
// Two cases: Either, the audio starts in the future or in the past
|
||||
if (startTimestamp >= audioContext!.currentTime) {
|
||||
@@ -333,15 +275,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<void>((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;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -367,16 +314,15 @@ const play = async () => {
|
||||
if (getPlaybackTime() === totalDuration) {
|
||||
// If we're at the end, let's snap back to the start
|
||||
playbackTimeAtStart = 0;
|
||||
await startVideoIterator();
|
||||
await videoCursor?.seekTo(0);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -387,8 +333,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) {
|
||||
@@ -419,7 +365,7 @@ const seekToTime = async (seconds: number) => {
|
||||
|
||||
playbackTimeAtStart = seconds;
|
||||
|
||||
await startVideoIterator();
|
||||
await videoCursor?.seekTo(seconds);
|
||||
|
||||
if (wasPlaying && playbackTimeAtStart < totalDuration) {
|
||||
void play();
|
||||
@@ -518,7 +464,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;
|
||||
}
|
||||
@@ -551,7 +497,7 @@ playerContainer.addEventListener('pointermove', (event) => {
|
||||
}
|
||||
});
|
||||
playerContainer.addEventListener('pointerleave', (event) => {
|
||||
if (!videoSink) {
|
||||
if (!videoCursor) {
|
||||
// Shouldn't run if there's only an audio track
|
||||
return;
|
||||
}
|
||||
|
||||
Generated
+11
-11
@@ -352,9 +352,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": {
|
||||
@@ -362,13 +362,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"
|
||||
@@ -388,14 +388,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"
|
||||
|
||||
+1
-1
@@ -75,6 +75,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",
|
||||
@@ -91,7 +92,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": [
|
||||
|
||||
+99
-55
@@ -338,7 +338,7 @@ export class PacketCursor {
|
||||
let stopped = false;
|
||||
const stop = () => stopped = true;
|
||||
|
||||
const donePromise = this._callSerializer.done();
|
||||
const donePromise = this._callSerializer.waitUntilIdle();
|
||||
if (donePromise) await donePromise;
|
||||
|
||||
while (true) {
|
||||
@@ -362,7 +362,7 @@ export class PacketCursor {
|
||||
|
||||
// eslint-disable-next-line @stylistic/generator-star-spacing
|
||||
async *[Symbol.asyncIterator]() {
|
||||
const donePromise = this._callSerializer.done();
|
||||
const donePromise = this._callSerializer.waitUntilIdle();
|
||||
if (donePromise) await donePromise;
|
||||
|
||||
while (true) {
|
||||
@@ -380,7 +380,11 @@ export class PacketCursor {
|
||||
}
|
||||
|
||||
waitUntilIdle() {
|
||||
return this._callSerializer.done();
|
||||
return this._callSerializer.waitUntilIdle();
|
||||
}
|
||||
|
||||
isIdle() {
|
||||
return this._callSerializer.isIdle();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,7 +421,7 @@ export abstract class SampleCursor<
|
||||
Sample extends VideoSample | AudioSample,
|
||||
TransformedSample = Sample,
|
||||
> implements AsyncDisposable {
|
||||
reader: PacketReader;
|
||||
track: InputTrack;
|
||||
current: TransformedSample | null = null;
|
||||
|
||||
private _transform: SampleTransformer<Sample, TransformedSample>;
|
||||
@@ -425,10 +429,13 @@ export abstract class SampleCursor<
|
||||
|
||||
private _mutex = new AsyncMutex4();
|
||||
|
||||
private _packetReader: PacketReader;
|
||||
private _packetCursor: PacketCursor;
|
||||
/** @internal */
|
||||
_decoder: DecoderWrapper<Sample> | 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[] = [];
|
||||
/** Used to pause and resume the pump. */
|
||||
@@ -457,7 +464,10 @@ export abstract class SampleCursor<
|
||||
private _error: unknown = null;
|
||||
private _errorSet = false;
|
||||
|
||||
/** @internal */
|
||||
/**
|
||||
* A bunch of utilities for simulating certain behaviors for testing.
|
||||
* @internal
|
||||
*/
|
||||
_debug = {
|
||||
enabled: false,
|
||||
pumpsStarted: 0,
|
||||
@@ -466,6 +476,8 @@ export abstract class SampleCursor<
|
||||
throwInDecoderInit: false,
|
||||
throwInPump: false,
|
||||
throwDecoderError: false,
|
||||
unthrottledPump: false,
|
||||
pumpEnded: new AsyncGate(),
|
||||
};
|
||||
|
||||
get closed(): boolean {
|
||||
@@ -477,15 +489,16 @@ export abstract class SampleCursor<
|
||||
}
|
||||
|
||||
protected constructor(
|
||||
reader: PacketReader,
|
||||
track: InputTrack,
|
||||
options: SampleCursorOptions<Sample, TransformedSample>,
|
||||
) {
|
||||
this.reader = reader;
|
||||
this._packetCursor = new PacketCursor(reader);
|
||||
this.track = track;
|
||||
this._packetReader = new PacketReader(track);
|
||||
this._packetCursor = new PacketCursor(this._packetReader);
|
||||
this._autoClose = options.autoClose ?? true;
|
||||
this._transform = options.transform ?? (sample => sample as unknown as TransformedSample);
|
||||
|
||||
reader.track.input._openSampleCursors.add(this);
|
||||
track.input._openSampleCursors.add(this);
|
||||
|
||||
const lock = this._mutex.lock();
|
||||
assert(!lock.pending);
|
||||
@@ -497,17 +510,17 @@ export abstract class SampleCursor<
|
||||
}
|
||||
|
||||
seekToFirst(): MaybePromise<TransformedSample | null> {
|
||||
return this._getSample(result => this._seekToPacket(result, this.reader.readFirst()));
|
||||
return this._getSample(result => this._seekToPacket(result, this._packetReader.readFirst()));
|
||||
}
|
||||
|
||||
seekTo(timestamp: number): MaybePromise<TransformedSample | null> {
|
||||
validateTimestamp(timestamp);
|
||||
return this._getSample(result => this._seekToPacket(result, this.reader.readAt(timestamp)));
|
||||
return this._getSample(result => this._seekToPacket(result, this._packetReader.readAt(timestamp)));
|
||||
}
|
||||
|
||||
seekToKey(timestamp: number): MaybePromise<TransformedSample | null> {
|
||||
validateTimestamp(timestamp);
|
||||
return this._getSample(result => this._seekToPacket(result, this.reader.readKeyAt(timestamp)));
|
||||
return this._getSample(result => this._seekToPacket(result, this._packetReader.readKeyAt(timestamp)));
|
||||
}
|
||||
|
||||
next(): MaybePromise<TransformedSample | null> {
|
||||
@@ -639,7 +652,7 @@ export abstract class SampleCursor<
|
||||
this._nextIsFirst = true;
|
||||
this._lazyPump = 0;
|
||||
|
||||
this.reader.track.input._openSampleCursors.add(this);
|
||||
this.track.input._openSampleCursors.add(this);
|
||||
|
||||
try {
|
||||
const newDecoder = await this._initDecoder();
|
||||
@@ -685,6 +698,10 @@ export abstract class SampleCursor<
|
||||
}
|
||||
}
|
||||
|
||||
isIdle(): boolean {
|
||||
return this._pendingRequests.length === 0 && !this._mutex.locked;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
abstract _initDecoder(): Promise<DecoderWrapper<Sample>>;
|
||||
|
||||
@@ -761,6 +778,7 @@ export abstract class SampleCursor<
|
||||
|
||||
this._currentSample?.close();
|
||||
this._currentSample = newCurrentRaw;
|
||||
this._currentSampleTimestamp = newCurrentRaw?.timestamp ?? null;
|
||||
this.current = null;
|
||||
}
|
||||
|
||||
@@ -839,7 +857,7 @@ export abstract class SampleCursor<
|
||||
|
||||
const lastTimestamp = Math.max(
|
||||
this._lastTarget?.timestamp ?? -Infinity,
|
||||
this._currentSample?.timestamp ?? -Infinity, // This one's revelant if we had next() requests
|
||||
this._currentSampleTimestamp ?? -Infinity, // This one's revelant if we had next() requests
|
||||
);
|
||||
|
||||
if (lastTimestamp !== -Infinity && lastTimestamp <= targetPacket.timestamp) {
|
||||
@@ -863,7 +881,10 @@ export abstract class SampleCursor<
|
||||
} else {
|
||||
if (this._packetCursor.current) {
|
||||
// We need to see if the target packet is ahead of the decoder, GOP-wise
|
||||
let nextKey = this.reader.readNextKey(this._packetCursor.current, { verifyKeyPackets: true });
|
||||
let nextKey = this._packetReader.readNextKey(
|
||||
this._packetCursor.current,
|
||||
{ verifyKeyPackets: true },
|
||||
);
|
||||
if (nextKey instanceof Promise) nextKey = await nextKey;
|
||||
|
||||
needsNewPump = !!nextKey && targetPacket.sequenceNumber >= nextKey.sequenceNumber;
|
||||
@@ -924,7 +945,7 @@ export abstract class SampleCursor<
|
||||
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.reader.readFirst(), lock);
|
||||
return await this._seekToPacket(res, this._packetReader.readFirst(), lock);
|
||||
}
|
||||
|
||||
// See if the request can be satisfied using already-decoded samples
|
||||
@@ -979,7 +1000,7 @@ export abstract class SampleCursor<
|
||||
|
||||
if (this._nextIsFirst) {
|
||||
// await is important so that the lock doesn't release too early
|
||||
return await this._seekToPacket(res, this.reader.readFirst(), lock);
|
||||
return await this._seekToPacket(res, this._packetReader.readFirst(), lock);
|
||||
}
|
||||
|
||||
let timestampToCheck: number;
|
||||
@@ -1001,8 +1022,8 @@ export abstract class SampleCursor<
|
||||
this._ensureNotClosed();
|
||||
}
|
||||
|
||||
if (this._currentSample) {
|
||||
timestampToCheck = this._currentSample.timestamp;
|
||||
if (this._currentSampleTimestamp !== null) {
|
||||
timestampToCheck = this._currentSampleTimestamp;
|
||||
} else {
|
||||
// We're at the end
|
||||
return res.set(null);
|
||||
@@ -1014,11 +1035,11 @@ export abstract class SampleCursor<
|
||||
// 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.reader.readKeyAt(timestampToCheck, { verifyKeyPackets: true });
|
||||
let key = this._packetReader.readKeyAt(timestampToCheck, { verifyKeyPackets: true });
|
||||
if (key instanceof Promise) key = await key;
|
||||
assert(key); // Must be
|
||||
|
||||
let nextKey = this.reader.readNextKey(key, { verifyKeyPackets: true });
|
||||
let nextKey = this._packetReader.readNextKey(key, { verifyKeyPackets: true });
|
||||
if (nextKey instanceof Promise) nextKey = await nextKey;
|
||||
|
||||
if (!nextKey) {
|
||||
@@ -1038,8 +1059,16 @@ export abstract class SampleCursor<
|
||||
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++;
|
||||
@@ -1069,7 +1098,11 @@ export abstract class SampleCursor<
|
||||
}
|
||||
|
||||
const decodeQueueSize = this._decoder.getDecodeQueueSize();
|
||||
if (this._sampleQueue.length + decodeQueueSize >= 4 && !this._pumpStopQueued) {
|
||||
if (
|
||||
this._sampleQueue.length + decodeQueueSize >= 4
|
||||
&& !this._pumpStopQueued
|
||||
&& !(this._debug.enabled && this._debug.unthrottledPump)
|
||||
) {
|
||||
await this._pumpGate.wait();
|
||||
continue;
|
||||
}
|
||||
@@ -1086,19 +1119,24 @@ export abstract class SampleCursor<
|
||||
if (maybePromise instanceof Promise) await maybePromise;
|
||||
}
|
||||
|
||||
if (this._pendingRequests.length > 0 || !this._closed) {
|
||||
if (!this._closed || this._pendingRequests.length > 0) {
|
||||
await this._decoder.flush();
|
||||
} 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<TransformedSample>) => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve whatever requests remain with null
|
||||
this._setCurrentRaw(null);
|
||||
this._pendingRequests.forEach(resolveWithNull);
|
||||
} catch (error) {
|
||||
if (!this._decoder.closed && this._pendingRequests.length > 0) {
|
||||
@@ -1109,11 +1147,6 @@ export abstract class SampleCursor<
|
||||
this._pumpRunning = false; // So that close() doesn't attempt to stop the pump
|
||||
void this._closeWithError(error);
|
||||
} finally {
|
||||
for (const sample of this._sampleQueue) {
|
||||
sample.close();
|
||||
}
|
||||
this._sampleQueue.length = 0;
|
||||
|
||||
this._pendingRequests.length = 0;
|
||||
this._lastPendingRequest = null;
|
||||
this._pumpRunning = false;
|
||||
@@ -1122,6 +1155,10 @@ export abstract class SampleCursor<
|
||||
this._lastTarget = null;
|
||||
|
||||
this._pumpStopped.open();
|
||||
|
||||
if (this._debug.enabled) {
|
||||
this._debug.pumpEnded.open();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1130,12 +1167,18 @@ export abstract class SampleCursor<
|
||||
|
||||
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.reader.track.input._openSampleCursors.delete(this);
|
||||
this.track.input._openSampleCursors.delete(this);
|
||||
|
||||
let lock: AsyncMutexLock | null = null;
|
||||
if (doLock) {
|
||||
@@ -1150,6 +1193,11 @@ export abstract class SampleCursor<
|
||||
await this._stopPump();
|
||||
}
|
||||
|
||||
for (const sample of this._sampleQueue) {
|
||||
sample.close();
|
||||
}
|
||||
this._sampleQueue.length = 0;
|
||||
|
||||
this._setCurrentRaw(null);
|
||||
this._decoder?.close();
|
||||
this._decoder = null;
|
||||
@@ -1202,25 +1250,23 @@ export abstract class SampleCursor<
|
||||
}
|
||||
|
||||
export class VideoSampleCursor<TransformedSample = VideoSample> extends SampleCursor<VideoSample, TransformedSample> {
|
||||
override reader!: PacketReader<InputVideoTrack>;
|
||||
override track!: InputVideoTrack;
|
||||
|
||||
constructor(
|
||||
reader: PacketReader<InputVideoTrack>,
|
||||
track: InputVideoTrack,
|
||||
options: SampleCursorOptions<VideoSample, TransformedSample> = {},
|
||||
) {
|
||||
if (!(reader instanceof PacketReader) || !(reader.track instanceof InputVideoTrack)) {
|
||||
throw new TypeError('reader must be a PacketReader for an InputVideoTrack.');
|
||||
if (!(track instanceof InputVideoTrack)) {
|
||||
throw new TypeError('track must be an InputVideoTrack.');
|
||||
}
|
||||
validateSampleCursorOptions(options);
|
||||
|
||||
super(reader, options);
|
||||
super(track, options);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
override async _initDecoder(): Promise<DecoderWrapper<VideoSample>> {
|
||||
const track = this.reader.track;
|
||||
|
||||
if (!(await track.canDecode())) {
|
||||
if (!(await this.track.canDecode())) {
|
||||
throw new Error(
|
||||
'This video track cannot be decoded by this browser. Make sure to check decodability before using'
|
||||
+ ' a track.',
|
||||
@@ -1231,17 +1277,17 @@ export class VideoSampleCursor<TransformedSample = VideoSample> extends SampleCu
|
||||
throw new Error('Fake decoder init error!');
|
||||
}
|
||||
|
||||
const decoderConfig = await track.getDecoderConfig();
|
||||
const decoderConfig = await this.track.getDecoderConfig();
|
||||
assert(decoderConfig);
|
||||
assert(track.codec);
|
||||
assert(this.track.codec);
|
||||
|
||||
const decoder = new VideoDecoderWrapper(
|
||||
sample => this._onDecoderSample(sample),
|
||||
error => this._onDecoderError(error),
|
||||
track.codec,
|
||||
this.track.codec,
|
||||
decoderConfig,
|
||||
track.rotation,
|
||||
track.timeResolution,
|
||||
this.track.rotation,
|
||||
this.track.timeResolution,
|
||||
);
|
||||
|
||||
decoder.onDequeue = () => this._onDecoderDequeue();
|
||||
@@ -1251,25 +1297,23 @@ export class VideoSampleCursor<TransformedSample = VideoSample> extends SampleCu
|
||||
}
|
||||
|
||||
export class AudioSampleCursor<TransformedSample = AudioSample> extends SampleCursor<AudioSample, TransformedSample> {
|
||||
override reader!: PacketReader<InputAudioTrack>;
|
||||
override track!: InputAudioTrack;
|
||||
|
||||
constructor(
|
||||
reader: PacketReader<InputAudioTrack>,
|
||||
track: InputAudioTrack,
|
||||
options: SampleCursorOptions<AudioSample, TransformedSample> = {},
|
||||
) {
|
||||
if (!(reader instanceof PacketReader) || !(reader.track instanceof InputAudioTrack)) {
|
||||
throw new TypeError('reader must be a PacketReader for an InputAudioTrack.');
|
||||
if (!(track instanceof InputAudioTrack)) {
|
||||
throw new TypeError('track must be an InputAudioTrack.');
|
||||
}
|
||||
validateSampleCursorOptions(options);
|
||||
|
||||
super(reader, options);
|
||||
super(track, options);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
override async _initDecoder(): Promise<DecoderWrapper<AudioSample>> {
|
||||
const track = this.reader.track;
|
||||
|
||||
if (!(await track.canDecode())) {
|
||||
if (!(await this.track.canDecode())) {
|
||||
throw new Error(
|
||||
'This audio track cannot be decoded by this browser. Make sure to check decodability before using'
|
||||
+ ' a track.',
|
||||
@@ -1280,8 +1324,8 @@ export class AudioSampleCursor<TransformedSample = AudioSample> extends SampleCu
|
||||
throw new Error('Fake decoder init error!');
|
||||
}
|
||||
|
||||
const codec = track.codec;
|
||||
const decoderConfig = await track.getDecoderConfig();
|
||||
const codec = this.track.codec;
|
||||
const decoderConfig = await this.track.getDecoderConfig();
|
||||
assert(codec && decoderConfig);
|
||||
|
||||
let decoder: AudioDecoderWrapper | PcmAudioDecoderWrapper;
|
||||
|
||||
@@ -183,8 +183,11 @@ export {
|
||||
WrappedCanvas,
|
||||
} from './media-sink';
|
||||
export {
|
||||
PacketReader,
|
||||
PacketCursor,
|
||||
SampleCursor,
|
||||
VideoSampleCursor,
|
||||
AudioSampleCursor,
|
||||
} from './cursors';
|
||||
export {
|
||||
Conversion,
|
||||
|
||||
@@ -2360,7 +2360,10 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
||||
}
|
||||
|
||||
async computeDuration() {
|
||||
const lastPacket = await this.getPacket(Infinity, { metadataOnly: true });
|
||||
const result = new ResultValue<EncodedPacket | null>();
|
||||
await this.getPacket(result, Infinity, { metadataOnly: true });
|
||||
|
||||
const lastPacket = result.value;
|
||||
return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0);
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -932,7 +932,7 @@ export interface AsyncMutexLock extends Disposable {
|
||||
}
|
||||
|
||||
export class AsyncMutex4 {
|
||||
private locked = false;
|
||||
locked = false;
|
||||
private resolverQueue: (() => void)[] = [];
|
||||
|
||||
lock() {
|
||||
@@ -1013,7 +1013,7 @@ export class CallSerializer2 {
|
||||
}
|
||||
}
|
||||
|
||||
done() {
|
||||
waitUntilIdle() {
|
||||
if (this.currentPromise) {
|
||||
return this.currentPromise
|
||||
.catch(() => {})
|
||||
@@ -1022,6 +1022,10 @@ export class CallSerializer2 {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
isIdle() {
|
||||
return !this.currentPromise;
|
||||
}
|
||||
}
|
||||
|
||||
export class AsyncGate {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { afterEach, expect, test } from 'vitest';
|
||||
import { Input } from '../../src/input.js';
|
||||
import { BufferSource, UrlSource } from '../../src/source.js';
|
||||
import { ALL_FORMATS } from '../../src/input-format.js';
|
||||
@@ -6,12 +6,16 @@ import {
|
||||
audioBufferTransformer,
|
||||
AudioSampleCursor,
|
||||
canvasTransformer,
|
||||
PacketReader,
|
||||
VideoSampleCursor,
|
||||
} from '../../src/cursors.js';
|
||||
import { AudioSample, VideoSample } from '../../src/sample.js';
|
||||
import { promiseAllEnsureOrder, promiseIterateAll } from '../../src/misc.js';
|
||||
|
||||
afterEach(() => {
|
||||
expect(VideoSample._openSampleCount).toBe(0);
|
||||
expect(AudioSample._openSampleCount).toBe(0);
|
||||
});
|
||||
|
||||
test('Sample cursor seeking', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/trim-buck-bunny.mov'),
|
||||
@@ -19,8 +23,7 @@ test('Sample cursor seeking', async () => {
|
||||
});
|
||||
|
||||
const videoTrack = (await input.getPrimaryVideoTrack())!;
|
||||
const reader = new PacketReader(videoTrack);
|
||||
await using cursor = new VideoSampleCursor(reader);
|
||||
await using cursor = new VideoSampleCursor(videoTrack);
|
||||
cursor._debug.enabled = true;
|
||||
|
||||
expect(cursor.current).toBe(null);
|
||||
@@ -124,28 +127,38 @@ test('Sample cursor seeking', async () => {
|
||||
|
||||
expect(cursor._debug.pumpsStarted).toBe(5);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
expect(cursor.current).toBe(sample9);
|
||||
|
||||
// Seek to the end again
|
||||
const seekToResult10 = cursor.seekTo(Infinity);
|
||||
expect(seekToResult10).not.toBeInstanceOf(Promise);
|
||||
const sample10 = await seekToResult10;
|
||||
expect(sample9).toBe(sample10);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
expect(cursor._debug.pumpsStarted).toBe(5);
|
||||
|
||||
// Seek to before the start
|
||||
const seekToResult10 = cursor.seekTo(-Infinity);
|
||||
expect(seekToResult10).toBe(null);
|
||||
const seekToResult11 = cursor.seekTo(-Infinity);
|
||||
expect(seekToResult11).toBe(null);
|
||||
expect(sample9.closed).toBe(true);
|
||||
expect(cursor.current).toBe(null);
|
||||
|
||||
const seekToResult11 = cursor.seekToKey(2.5);
|
||||
expect(seekToResult11).toBeInstanceOf(Promise);
|
||||
const sample11 = (await seekToResult11)!;
|
||||
expect(sample11).toBe(cursor.current);
|
||||
expect(sample11.timestamp).toBe(2);
|
||||
expect(sample11.closed).toBe(false);
|
||||
const seekToResult12 = cursor.seekToKey(2.5);
|
||||
expect(seekToResult12).toBeInstanceOf(Promise);
|
||||
const sample12 = (await seekToResult12)!;
|
||||
expect(sample12).toBe(cursor.current);
|
||||
expect(sample12.timestamp).toBe(2);
|
||||
expect(sample12.closed).toBe(false);
|
||||
|
||||
await cursor.close();
|
||||
|
||||
expect(sample11.closed).toBe(true);
|
||||
expect(sample12.closed).toBe(true);
|
||||
expect(cursor.current).toBe(null);
|
||||
expect(cursor._debug.pumpsStarted).toBe(6);
|
||||
|
||||
await cursor.close();
|
||||
|
||||
expect(VideoSample._openSampleCount).toBe(0);
|
||||
});
|
||||
|
||||
test('Sample cursor advancing', async () => {
|
||||
@@ -155,8 +168,7 @@ test('Sample cursor advancing', async () => {
|
||||
});
|
||||
|
||||
const videoTrack = (await input.getPrimaryVideoTrack())!;
|
||||
const reader = new PacketReader(videoTrack);
|
||||
const cursor = new VideoSampleCursor(reader);
|
||||
const cursor = new VideoSampleCursor(videoTrack);
|
||||
cursor._debug.enabled = true;
|
||||
|
||||
expect(cursor.current).toBe(null);
|
||||
@@ -239,8 +251,6 @@ test('Sample cursor advancing', async () => {
|
||||
await cursor.close();
|
||||
|
||||
expect(cursor._debug.pumpsStarted).toBe(5);
|
||||
|
||||
expect(VideoSample._openSampleCount).toBe(0);
|
||||
});
|
||||
|
||||
test('Sample cursor advancing, cold start', async () => {
|
||||
@@ -250,8 +260,7 @@ test('Sample cursor advancing, cold start', async () => {
|
||||
});
|
||||
|
||||
const videoTrack = (await input.getPrimaryVideoTrack())!;
|
||||
const reader = new PacketReader(videoTrack);
|
||||
const cursor = new VideoSampleCursor(reader);
|
||||
const cursor = new VideoSampleCursor(videoTrack);
|
||||
cursor._debug.enabled = true;
|
||||
|
||||
let firstSample = (await cursor.next())!;
|
||||
@@ -281,7 +290,7 @@ test('Sample cursor advancing, cold start', async () => {
|
||||
// Ensure the calls were serialized correctly
|
||||
expect(cursor._debug.seekPackets.map(x => x?.timestamp ?? null)).toEqual([0, null, 0, null, 0, 2]);
|
||||
|
||||
const cursor2 = new VideoSampleCursor(reader);
|
||||
const cursor2 = new VideoSampleCursor(videoTrack);
|
||||
for await (const sample of cursor2) {
|
||||
expect(sample.timestamp).toBe(0);
|
||||
break;
|
||||
@@ -293,8 +302,6 @@ test('Sample cursor advancing, cold start', async () => {
|
||||
});
|
||||
|
||||
await cursor2.close();
|
||||
|
||||
expect(VideoSample._openSampleCount).toBe(0);
|
||||
});
|
||||
|
||||
test('Sample cursor advancing, nextKey', async () => {
|
||||
@@ -304,8 +311,7 @@ test('Sample cursor advancing, nextKey', async () => {
|
||||
});
|
||||
|
||||
const videoTrack = (await input.getPrimaryVideoTrack())!;
|
||||
const reader = new PacketReader(videoTrack);
|
||||
await using cursor = new VideoSampleCursor(reader);
|
||||
await using cursor = new VideoSampleCursor(videoTrack);
|
||||
|
||||
await cursor.seekToFirst();
|
||||
|
||||
@@ -348,8 +354,7 @@ test('Sample cursor sample reuse', async () => {
|
||||
});
|
||||
|
||||
const videoTrack = (await input.getPrimaryVideoTrack())!;
|
||||
const reader = new PacketReader(videoTrack);
|
||||
const cursor1 = new VideoSampleCursor(reader);
|
||||
const cursor1 = new VideoSampleCursor(videoTrack);
|
||||
cursor1._debug.enabled = true;
|
||||
|
||||
const sample1 = await cursor1.seekToFirst();
|
||||
@@ -363,7 +368,7 @@ test('Sample cursor sample reuse', async () => {
|
||||
expect(sample1!.closed).toBe(true);
|
||||
expect(sample2!.closed).toBe(true);
|
||||
|
||||
const cursor2 = new VideoSampleCursor(reader, {
|
||||
const cursor2 = new VideoSampleCursor(videoTrack, {
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
@@ -378,7 +383,7 @@ test('Sample cursor sample reuse', async () => {
|
||||
sample4!.close();
|
||||
|
||||
let count = 0;
|
||||
const cursor3 = new VideoSampleCursor(reader, {
|
||||
const cursor3 = new VideoSampleCursor(videoTrack, {
|
||||
transform: () => count++,
|
||||
});
|
||||
|
||||
@@ -388,7 +393,7 @@ test('Sample cursor sample reuse', async () => {
|
||||
expect(count).toBe(1);
|
||||
|
||||
count = 0;
|
||||
const cursor4 = new VideoSampleCursor(reader, {
|
||||
const cursor4 = new VideoSampleCursor(videoTrack, {
|
||||
autoClose: false,
|
||||
transform: sample => (sample.close(), count++),
|
||||
});
|
||||
@@ -402,8 +407,6 @@ test('Sample cursor sample reuse', async () => {
|
||||
await cursor2.close();
|
||||
await cursor3.close();
|
||||
await cursor4.close();
|
||||
|
||||
expect(VideoSample._openSampleCount).toBe(0);
|
||||
});
|
||||
|
||||
test('Sample cursor reset', async () => {
|
||||
@@ -413,8 +416,7 @@ test('Sample cursor reset', async () => {
|
||||
});
|
||||
|
||||
const videoTrack = (await input.getPrimaryVideoTrack())!;
|
||||
const reader = new PacketReader(videoTrack);
|
||||
await using cursor = new VideoSampleCursor(reader);
|
||||
await using cursor = new VideoSampleCursor(videoTrack);
|
||||
|
||||
expect(cursor.closed).toBe(false);
|
||||
|
||||
@@ -455,7 +457,7 @@ test('Sample cursor reset', async () => {
|
||||
expect(results[4]!.timestamp).toBe(results[0]!.timestamp);
|
||||
expect(results[6]!.timestamp).toBe(firstSample!.timestamp);
|
||||
|
||||
await using cursor2 = new VideoSampleCursor(reader);
|
||||
await using cursor2 = new VideoSampleCursor(videoTrack);
|
||||
cursor2._debug.enabled = true;
|
||||
|
||||
// Test if queueing a reset makes the decoder decode minimally many packets
|
||||
@@ -476,8 +478,7 @@ test('Decoder setup error & reset', async () => {
|
||||
});
|
||||
|
||||
const videoTrack = (await input.getPrimaryVideoTrack())!;
|
||||
const reader = new PacketReader(videoTrack);
|
||||
await using cursor1 = new VideoSampleCursor(reader);
|
||||
await using cursor1 = new VideoSampleCursor(videoTrack);
|
||||
cursor1._debug.enabled = true;
|
||||
cursor1._debug.throwInDecoderInit = true;
|
||||
expect(cursor1.closed).toBe(false);
|
||||
@@ -501,7 +502,7 @@ test('Decoder setup error & reset', async () => {
|
||||
expect(firstSample).not.toBe(null);
|
||||
|
||||
// Let's test directly closing after opening
|
||||
const cursor2 = new VideoSampleCursor(reader);
|
||||
const cursor2 = new VideoSampleCursor(videoTrack);
|
||||
cursor2._debug.enabled = true;
|
||||
cursor2._debug.throwInDecoderInit = true;
|
||||
await cursor2.close();
|
||||
@@ -514,8 +515,7 @@ test('Decoder pump error handling & reset', async () => {
|
||||
});
|
||||
|
||||
const videoTrack = (await input.getPrimaryVideoTrack())!;
|
||||
const reader = new PacketReader(videoTrack);
|
||||
const cursor = new VideoSampleCursor(reader);
|
||||
const cursor = new VideoSampleCursor(videoTrack);
|
||||
cursor._debug.enabled = true;
|
||||
|
||||
cursor._debug.throwInPump = true;
|
||||
@@ -528,8 +528,6 @@ test('Decoder pump error handling & reset', async () => {
|
||||
cursor._debug.throwInPump = false;
|
||||
await expect(async () => cursor.seekToFirst()).rejects.toThrow('Fake pump error'); // It's bricked
|
||||
|
||||
expect(VideoSample._openSampleCount).toBe(0);
|
||||
|
||||
await cursor.reset();
|
||||
|
||||
const firstSample = await cursor.seekToFirst();
|
||||
@@ -543,9 +541,8 @@ test('Decoder errors & reset', async () => {
|
||||
});
|
||||
|
||||
const videoTrack = (await input.getPrimaryVideoTrack())!;
|
||||
const reader = new PacketReader(videoTrack);
|
||||
|
||||
const cursor1 = new VideoSampleCursor(reader);
|
||||
const cursor1 = new VideoSampleCursor(videoTrack);
|
||||
cursor1._debug.enabled = true;
|
||||
cursor1._debug.throwDecoderError = true;
|
||||
|
||||
@@ -561,7 +558,7 @@ test('Decoder errors & reset', async () => {
|
||||
const firstSample = await cursor1.seekToFirst();
|
||||
expect(firstSample!.timestamp).toBe(0);
|
||||
|
||||
const cursor2 = new VideoSampleCursor(reader);
|
||||
const cursor2 = new VideoSampleCursor(videoTrack);
|
||||
cursor2._debug.enabled = true;
|
||||
|
||||
await cursor2.seekToFirst();
|
||||
@@ -580,9 +577,8 @@ test('Use after close', async () => {
|
||||
});
|
||||
|
||||
const videoTrack = (await input.getPrimaryVideoTrack())!;
|
||||
const reader = new PacketReader(videoTrack);
|
||||
|
||||
const cursor1 = new VideoSampleCursor(reader);
|
||||
const cursor1 = new VideoSampleCursor(videoTrack);
|
||||
|
||||
expect(cursor1.closed).toBe(false);
|
||||
await cursor1.close();
|
||||
@@ -593,7 +589,7 @@ test('Use after close', async () => {
|
||||
await expect(async () => await cursor1.seekToKey(0)).rejects.toThrow('cursor has been closed');
|
||||
await expect(async () => await cursor1.next()).rejects.toThrow('cursor has been closed');
|
||||
|
||||
const cursor2 = new VideoSampleCursor(reader);
|
||||
const cursor2 = new VideoSampleCursor(videoTrack);
|
||||
const commands2 = [
|
||||
cursor2.seekToFirst(),
|
||||
cursor2.next(),
|
||||
@@ -609,7 +605,7 @@ test('Use after close', async () => {
|
||||
expect(cursor2._pumpRunning).toBe(false);
|
||||
expect(cursor2._decoder).toBe(null);
|
||||
|
||||
const cursor3 = new VideoSampleCursor(reader);
|
||||
const cursor3 = new VideoSampleCursor(videoTrack);
|
||||
const commands3 = [
|
||||
cursor3.seekToFirst(),
|
||||
cursor3.next(),
|
||||
@@ -621,8 +617,6 @@ test('Use after close', async () => {
|
||||
await expect(commands3[1]).resolves.toBeInstanceOf(VideoSample);
|
||||
await expect(commands3[2]).resolves.toBeUndefined();
|
||||
await expect(commands3[3]).rejects.toThrow('cursor has been closed');
|
||||
|
||||
expect(VideoSample._openSampleCount).toBe(0);
|
||||
});
|
||||
|
||||
test('Wait until idle', async () => {
|
||||
@@ -634,23 +628,26 @@ test('Wait until idle', async () => {
|
||||
});
|
||||
|
||||
const videoTrack = (await input.getPrimaryVideoTrack())!;
|
||||
const reader = new PacketReader(videoTrack);
|
||||
const cursor = new VideoSampleCursor(reader);
|
||||
const cursor = new VideoSampleCursor(videoTrack);
|
||||
|
||||
const promise1 = cursor.waitUntilIdle();
|
||||
expect(promise1).toBeInstanceOf(Promise); // The decoder is setting up
|
||||
expect(cursor.isIdle()).toBe(false);
|
||||
await promise1;
|
||||
|
||||
expect(cursor.waitUntilIdle()).toBe(null);
|
||||
expect(cursor.isIdle()).toBe(true);
|
||||
|
||||
const promise2 = cursor.seekToFirst();
|
||||
const promise3 = cursor.waitUntilIdle();
|
||||
expect(cursor.isIdle()).toBe(false);
|
||||
expect(promise2).toBeInstanceOf(Promise);
|
||||
expect(promise3).toBeInstanceOf(Promise);
|
||||
|
||||
expect(cursor.current).toBe(null);
|
||||
await promise3;
|
||||
expect(cursor.current!.timestamp).toBe(0);
|
||||
expect(cursor.isIdle()).toBe(true);
|
||||
|
||||
expect(cursor.waitUntilIdle()).toBe(null);
|
||||
|
||||
@@ -672,6 +669,7 @@ test('Wait until idle', async () => {
|
||||
expect(cursor.closed).toBe(false);
|
||||
await promise7;
|
||||
expect(cursor.closed).toBe(true);
|
||||
expect(cursor.isIdle()).toBe(true);
|
||||
});
|
||||
|
||||
test('Command queuing', async () => {
|
||||
@@ -682,9 +680,8 @@ test('Command queuing', async () => {
|
||||
});
|
||||
|
||||
const videoTrack = (await input.getPrimaryVideoTrack())!;
|
||||
const reader = new PacketReader(videoTrack);
|
||||
|
||||
const cursor0 = new VideoSampleCursor(reader);
|
||||
const cursor0 = new VideoSampleCursor(videoTrack);
|
||||
cursor0._debug.enabled = true;
|
||||
|
||||
const commands0 = [
|
||||
@@ -698,7 +695,7 @@ test('Command queuing', async () => {
|
||||
|
||||
expect(cursor0._debug.pumpsStarted).toBe(1);
|
||||
|
||||
const cursor1 = new VideoSampleCursor(reader);
|
||||
const cursor1 = new VideoSampleCursor(videoTrack);
|
||||
cursor1._debug.enabled = true;
|
||||
|
||||
const commands1 = [
|
||||
@@ -711,7 +708,7 @@ test('Command queuing', async () => {
|
||||
expect(results1[0]!.timestamp).toBe(0);
|
||||
expect(cursor1._debug.decodedPackets.map(x => x.timestamp)).toEqual([0]);
|
||||
|
||||
const cursor2 = new VideoSampleCursor(reader);
|
||||
const cursor2 = new VideoSampleCursor(videoTrack);
|
||||
cursor2._debug.enabled = true;
|
||||
|
||||
const commands2 = [
|
||||
@@ -739,7 +736,7 @@ test('Command queuing', async () => {
|
||||
]);
|
||||
expect(cursor2._debug.pumpsStarted).toBe(6);
|
||||
|
||||
const cursor3 = new VideoSampleCursor(reader);
|
||||
const cursor3 = new VideoSampleCursor(videoTrack);
|
||||
cursor3._debug.enabled = true;
|
||||
|
||||
const commands3 = [
|
||||
@@ -766,7 +763,7 @@ test('Command queuing', async () => {
|
||||
expect(cursor3._debug.decodedPackets.every(x => x.timestamp <= 0.5)).toBe(true);
|
||||
expect(cursor3._debug.pumpsStarted).toBe(6);
|
||||
|
||||
const cursor4 = new VideoSampleCursor(reader);
|
||||
const cursor4 = new VideoSampleCursor(videoTrack);
|
||||
cursor4._debug.enabled = true;
|
||||
|
||||
const commands4 = [
|
||||
@@ -787,7 +784,7 @@ test('Command queuing', async () => {
|
||||
expect(results4[3]!.timestamp).toBe(1);
|
||||
expect(cursor4._debug.decodedPackets.length).toBeGreaterThan(3); // Because .next() goes into "sequential mode"
|
||||
|
||||
const cursor5 = new VideoSampleCursor(reader);
|
||||
const cursor5 = new VideoSampleCursor(videoTrack);
|
||||
cursor5._debug.enabled = true;
|
||||
|
||||
const commands5 = [
|
||||
@@ -823,7 +820,7 @@ test('Command queuing', async () => {
|
||||
|
||||
expect(cursor5._debug.pumpsStarted).toBe(2);
|
||||
|
||||
const cursor6 = new VideoSampleCursor(reader);
|
||||
const cursor6 = new VideoSampleCursor(videoTrack);
|
||||
cursor6._debug.enabled = true;
|
||||
|
||||
const commands6 = [
|
||||
@@ -849,7 +846,7 @@ test('Command queuing', async () => {
|
||||
|
||||
expect(cursor6._debug.pumpsStarted).toBe(3);
|
||||
|
||||
const cursor7 = new VideoSampleCursor(reader);
|
||||
const cursor7 = new VideoSampleCursor(videoTrack);
|
||||
const commands7 = [
|
||||
cursor7.close(),
|
||||
cursor7.close(),
|
||||
@@ -858,7 +855,7 @@ test('Command queuing', async () => {
|
||||
|
||||
await promiseAllEnsureOrder(commands7);
|
||||
|
||||
const cursor8 = new VideoSampleCursor(reader, { autoClose: false });
|
||||
const cursor8 = new VideoSampleCursor(videoTrack, { autoClose: false });
|
||||
|
||||
const firstSample = await cursor8.seekToFirst();
|
||||
firstSample!.close();
|
||||
@@ -877,7 +874,7 @@ test('Command queuing', async () => {
|
||||
|
||||
await cursor8.close();
|
||||
|
||||
const cursor9 = new VideoSampleCursor(reader, { autoClose: false });
|
||||
const cursor9 = new VideoSampleCursor(videoTrack, { autoClose: false });
|
||||
|
||||
const commands9 = [
|
||||
cursor9.seekToFirst(),
|
||||
@@ -893,7 +890,7 @@ test('Command queuing', async () => {
|
||||
|
||||
await cursor9.close();
|
||||
|
||||
const cursor10 = new VideoSampleCursor(reader);
|
||||
const cursor10 = new VideoSampleCursor(videoTrack);
|
||||
cursor10._debug.enabled = true;
|
||||
|
||||
const commands10 = [
|
||||
@@ -911,8 +908,6 @@ test('Command queuing', async () => {
|
||||
expect(results10[2]!.timestamp).toBe(0);
|
||||
expect(results10[3]!.timestamp).toBe(results10[1]!.timestamp);
|
||||
expect(cursor10._debug.pumpsStarted).toBe(2);
|
||||
|
||||
expect(VideoSample._openSampleCount).toBe(0);
|
||||
});
|
||||
|
||||
test('Automatic cursor disposal', async () => {
|
||||
@@ -922,9 +917,8 @@ test('Automatic cursor disposal', async () => {
|
||||
});
|
||||
|
||||
const videoTrack = (await input.getPrimaryVideoTrack())!;
|
||||
const reader = new PacketReader(videoTrack);
|
||||
|
||||
const cursor = new VideoSampleCursor(reader);
|
||||
const cursor = new VideoSampleCursor(videoTrack);
|
||||
await cursor.seekToFirst();
|
||||
|
||||
// No cursor.close() here, but the disposed Input closes the cursor
|
||||
@@ -943,8 +937,7 @@ test('Video with stubborn first sample emit', async () => {
|
||||
});
|
||||
|
||||
const videoTrack = (await input.getPrimaryVideoTrack())!;
|
||||
const reader = new PacketReader(videoTrack);
|
||||
await using cursor = new VideoSampleCursor(reader);
|
||||
await using cursor = new VideoSampleCursor(videoTrack);
|
||||
|
||||
const firstSample = (await cursor.seekToFirst())!;
|
||||
expect(firstSample).not.toBe(null);
|
||||
@@ -958,8 +951,7 @@ test('AudioSampleCursor', async () => {
|
||||
});
|
||||
|
||||
const audioTrack = (await input.getPrimaryAudioTrack())!;
|
||||
const reader = new PacketReader(audioTrack);
|
||||
const cursor = new AudioSampleCursor(reader);
|
||||
const cursor = new AudioSampleCursor(audioTrack);
|
||||
cursor._debug.enabled = true;
|
||||
|
||||
const firstSample = (await cursor.seekToFirst())!;
|
||||
@@ -1032,8 +1024,6 @@ test('AudioSampleCursor', async () => {
|
||||
expect(cursor._debug.pumpsStarted).toBe(6);
|
||||
|
||||
await cursor.close();
|
||||
|
||||
expect(AudioSample._openSampleCount).toBe(0);
|
||||
});
|
||||
|
||||
test('Sample mapping', async () => {
|
||||
@@ -1043,10 +1033,9 @@ test('Sample mapping', async () => {
|
||||
});
|
||||
|
||||
const videoTrack = (await input.getPrimaryVideoTrack())!;
|
||||
const reader = new PacketReader(videoTrack);
|
||||
|
||||
let callCount = 0;
|
||||
await using cursor = new VideoSampleCursor(reader, {
|
||||
await using cursor = new VideoSampleCursor(videoTrack, {
|
||||
transform: (sample) => {
|
||||
callCount++;
|
||||
|
||||
@@ -1081,9 +1070,8 @@ test('Canvas transformer', async () => {
|
||||
});
|
||||
|
||||
const videoTrack = (await input.getPrimaryVideoTrack())!;
|
||||
const reader = new PacketReader(videoTrack);
|
||||
|
||||
const cursor1 = new VideoSampleCursor(reader, {
|
||||
const cursor1 = new VideoSampleCursor(videoTrack, {
|
||||
transform: canvasTransformer(),
|
||||
});
|
||||
|
||||
@@ -1101,7 +1089,7 @@ test('Canvas transformer', async () => {
|
||||
|
||||
await cursor1.close();
|
||||
|
||||
const cursor2 = new VideoSampleCursor(reader, {
|
||||
const cursor2 = new VideoSampleCursor(videoTrack, {
|
||||
transform: canvasTransformer({
|
||||
width: 320,
|
||||
poolSize: 2,
|
||||
@@ -1124,8 +1112,6 @@ test('Canvas transformer', async () => {
|
||||
expect(sample2.canvas).toBe(sample4.canvas);
|
||||
|
||||
await cursor2.close();
|
||||
|
||||
expect(VideoSample._openSampleCount).toBe(0);
|
||||
});
|
||||
|
||||
test('AudioBuffer transformer', async () => {
|
||||
@@ -1135,9 +1121,8 @@ test('AudioBuffer transformer', async () => {
|
||||
});
|
||||
|
||||
const audioTrack = (await input.getPrimaryAudioTrack())!;
|
||||
const reader = new PacketReader(audioTrack);
|
||||
|
||||
const cursor = new AudioSampleCursor(reader, {
|
||||
const cursor = new AudioSampleCursor(audioTrack, {
|
||||
transform: audioBufferTransformer(),
|
||||
});
|
||||
|
||||
@@ -1154,6 +1139,75 @@ test('AudioBuffer transformer', async () => {
|
||||
expect(nextSample.buffer.duration).toBe(nextSample.duration);
|
||||
|
||||
await cursor.close();
|
||||
});
|
||||
|
||||
expect(AudioSample._openSampleCount).toBe(0);
|
||||
test('Unthrottled decoder', async () => {
|
||||
// While the decoder is throttled in practice, these tests simulate what happens when the decoder naturally reaches
|
||||
// the end of the encodable packets without having been stopped; in this case, we still expect the cursor to keep
|
||||
// functioning normally, just that requests are now fully resolved through the sample queue only (when possible).
|
||||
|
||||
using input = new Input({
|
||||
source: new BufferSource(await fetch('/trim-buck-bunny.mov').then(x => x.arrayBuffer())),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const audioTrack = (await input.getPrimaryAudioTrack())!;
|
||||
|
||||
const cursor1 = new AudioSampleCursor(audioTrack);
|
||||
cursor1._debug.enabled = true;
|
||||
cursor1._debug.unthrottledPump = true;
|
||||
|
||||
const first = await cursor1.seekToFirst();
|
||||
expect(first!.timestamp).toBe(0);
|
||||
|
||||
await cursor1._debug.pumpEnded.wait();
|
||||
expect(cursor1._pumpRunning).toBe(false);
|
||||
|
||||
// All of these requests can be fully satisfied only by using the sample queue:
|
||||
|
||||
const next = await cursor1.next();
|
||||
expect(next).not.toBe(null);
|
||||
expect(next!.timestamp).toBeCloseTo(first!.timestamp + first!.duration);
|
||||
|
||||
const nextNext = await cursor1.next();
|
||||
expect(nextNext).not.toBe(null);
|
||||
expect(nextNext!.timestamp).toBeCloseTo(next!.timestamp + next!.duration);
|
||||
|
||||
const seeked = await cursor1.seekTo(1);
|
||||
expect(seeked).not.toBe(null);
|
||||
expect(seeked!.timestamp).toBeGreaterThan(0.9);
|
||||
expect(seeked!.timestamp).toBeLessThanOrEqual(1);
|
||||
|
||||
const nextKey = await cursor1.nextKey();
|
||||
expect(nextKey).not.toBe(null);
|
||||
expect(nextKey!.timestamp).toBeCloseTo(seeked!.timestamp + seeked!.duration);
|
||||
|
||||
const theLast = await cursor1.seekTo(Infinity);
|
||||
expect(theLast).not.toBe(null);
|
||||
|
||||
const pastTheLast = await cursor1.next();
|
||||
expect(pastTheLast).toBe(null);
|
||||
|
||||
expect(cursor1._debug.pumpsStarted).toBe(1);
|
||||
|
||||
const someRandomSample = await cursor1.seekTo(0.5);
|
||||
expect(someRandomSample).not.toBe(null);
|
||||
expect(someRandomSample!.timestamp).toBeGreaterThan(0.4);
|
||||
expect(someRandomSample!.timestamp).toBeLessThanOrEqual(0.5);
|
||||
|
||||
expect(cursor1._debug.pumpsStarted).toBe(2);
|
||||
|
||||
await cursor1.close();
|
||||
|
||||
const cursor2 = new AudioSampleCursor(audioTrack);
|
||||
cursor2._debug.enabled = true;
|
||||
cursor2._debug.unthrottledPump = true;
|
||||
|
||||
await cursor2.seekToFirst();
|
||||
|
||||
await cursor2._debug.pumpEnded.wait();
|
||||
expect(cursor2._pumpRunning).toBe(false);
|
||||
|
||||
// Test that all samples in the queue get closed now
|
||||
await cursor2.close();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user