Fix decoded frame timestamps (#49)

This commit is contained in:
Vanilagy
2025-08-10 19:26:41 +02:00
parent 5608f519a7
commit 56ae97f994
3 changed files with 85 additions and 27 deletions
+8
View File
@@ -16,6 +16,14 @@
source source
}); });
const videoTrack = await input.getPrimaryVideoTrack();
const sink = new Mediabunny.VideoSampleSink(videoTrack);
for await (const sample of sink.samples(0, 2)) {
console.log(sample.timestamp);
sample.close();
}
/* /*
const audioTrack = await input.getPrimaryAudioTrack(); const audioTrack = await input.getPrimaryAudioTrack();
const sink = new Mediabunny.EncodedPacketSink(audioTrack); const sink = new Mediabunny.EncodedPacketSink(audioTrack);
+56 -24
View File
@@ -17,6 +17,7 @@ import {
CallSerializer, CallSerializer,
getInt24, getInt24,
getUint24, getUint24,
isSafari,
last, last,
mapAsyncGenerator, mapAsyncGenerator,
promiseWithResolvers, promiseWithResolvers,
@@ -750,7 +751,8 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
customDecoderCallSerializer = new CallSerializer(); customDecoderCallSerializer = new CallSerializer();
customDecoderQueueSize = 0; customDecoderQueueSize = 0;
sampleQueue: VideoSample[] = []; inputTimestamps: number[] = []; // Timestamps input into the decoder, sorted.
sampleQueue: VideoSample[] = []; // Safari-specific thing, check usage.
constructor( constructor(
onSample: (sample: VideoSample) => unknown, onSample: (sample: VideoSample) => unknown,
@@ -762,28 +764,6 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
) { ) {
super(onSample, onError); super(onSample, onError);
const sampleHandler = (sample: VideoSample) => {
// 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 some browsers (Safari) don'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;
}
const insertionIndex = binarySearchLessOrEqual(
this.sampleQueue,
sample.timestamp,
x => x.timestamp,
);
this.sampleQueue.splice(insertionIndex + 1, 0, sample);
};
const MatchingCustomDecoder = customVideoDecoders.find(x => x.supports(codec, decoderConfig)); const MatchingCustomDecoder = customVideoDecoders.find(x => x.supports(codec, decoderConfig));
if (MatchingCustomDecoder) { if (MatchingCustomDecoder) {
// @ts-expect-error "Can't create instance of abstract class 🤓" // @ts-expect-error "Can't create instance of abstract class 🤓"
@@ -798,11 +778,50 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
throw new TypeError('The argument passed to onSample must be a VideoSample.'); throw new TypeError('The argument passed to onSample must be a VideoSample.');
} }
sampleHandler(sample); this.finalizeAndEmitSample(sample);
}; };
void this.customDecoderCallSerializer.call(() => this.customDecoder!.init()); void this.customDecoderCallSerializer.call(() => this.customDecoder!.init());
} else { } else {
// Specific handler for the WebCodecs VideoDecoder to iron out browser differences
const sampleHandler = (sample: VideoSample) => {
if (isSafari()) {
// 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;
}
const insertionIndex = binarySearchLessOrEqual(
this.sampleQueue,
sample.timestamp,
x => x.timestamp,
);
this.sampleQueue.splice(insertionIndex + 1, 0, sample);
} 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);
}
};
this.decoder = new VideoDecoder({ this.decoder = new VideoDecoder({
output: frame => sampleHandler(new VideoSample(frame)), output: frame => sampleHandler(new VideoSample(frame)),
error: onError, error: onError,
@@ -837,6 +856,16 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
.then(() => this.customDecoderQueueSize--); .then(() => this.customDecoderQueueSize--);
} else { } else {
assert(this.decoder); assert(this.decoder);
if (!isSafari()) {
const insertionIndex = binarySearchLessOrEqual(
this.inputTimestamps,
packet.timestamp,
x => x,
);
this.inputTimestamps.splice(insertionIndex + 1, 0, packet.timestamp);
}
this.decoder.decode(packet.toEncodedVideoChunk()); this.decoder.decode(packet.toEncodedVideoChunk());
} }
} }
@@ -849,11 +878,14 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
await this.decoder.flush(); await this.decoder.flush();
} }
if (isSafari()) {
for (const sample of this.sampleQueue) { for (const sample of this.sampleQueue) {
this.finalizeAndEmitSample(sample); this.finalizeAndEmitSample(sample);
} }
this.sampleQueue.length = 0; this.sampleQueue.length = 0;
} }
}
close() { close() {
if (this.customDecoder) { if (this.customDecoder) {
+18
View File
@@ -586,3 +586,21 @@ export class CallSerializer {
return this.currentPromise = this.currentPromise.then(fn); return this.currentPromise = this.currentPromise.then(fn);
} }
} }
let isSafariCache: boolean | null = null;
export const isSafari = () => {
if (isSafariCache !== null) {
return isSafariCache;
}
const result = !!(
typeof navigator !== 'undefined'
&& navigator.vendor?.match(/apple/i)
&& !navigator.userAgent?.match(/crios/i)
&& !navigator.userAgent?.match(/fxios/i)
&& !navigator.userAgent?.match(/Opera|OPT\//)
);
isSafariCache = result;
return result;
};