Add per-sink decoder preferences (hardwareAcceleration, optimizeForLatency) (#406)

* Add per-sink decoder preferences to VideoSampleSink and CanvasSink

Adds an optional VideoSinkDecoderOptions ({ hardwareAcceleration,
optimizeForLatency }) parameter to VideoSampleSink, exposed on CanvasSink
via options.decoderOptions, applied to the decoder config before the
VideoDecoderWrapper is constructed.

Motivation: applications that run many sinks concurrently (multi-track
video editors) need to manage hardware decode sessions deliberately -
the number of concurrent hardware sessions is OS-limited, undocumented,
and exceeding it fails silently on some platforms (macOS VideoToolbox
accepts configure() and decode() and simply never outputs). Such an
application places overflow sinks on 'prefer-software' explicitly.
optimizeForLatency is exposed alongside it since it is the other
WebCodecs decoder-config preference an application may want per sink.

The override composes with the existing interlaced-AVC Chromium
workaround, which runs later and can only strengthen the preference
toward software.

Validation mirrors decode.ts's validateVideoDecodingConfig.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Modify docs

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: Vanilagy <[email protected]>
This commit is contained in:
Tom Yang
2026-06-16 11:42:24 +00:00
committed by GitHub
co-authored by Claude Opus 4.8 Vanilagy
parent 324fae5153
commit 4affad934e
3 changed files with 66 additions and 3 deletions
+1
View File
@@ -270,6 +270,7 @@ export {
EncodedPacketSink,
type PacketRetrievalOptions,
VideoSampleSink,
type VideoSinkDecoderOptions,
type WrappedAudioBuffer,
type WrappedCanvas,
} from './media-sink';
+54 -3
View File
@@ -1749,6 +1749,42 @@ const colorAlphaMergerWorkerCode = () => {
};
};
/**
* 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
@@ -1757,16 +1793,20 @@ const colorAlphaMergerWorkerCode = () => {
export class VideoSampleSink extends BaseMediaSampleSink<VideoSample> {
/** @internal */
_track: InputVideoTrack;
/** @internal */
_decoderOptions: VideoSinkDecoderOptions;
/** Creates a new {@link VideoSampleSink} for the given {@link InputVideoTrack}. */
constructor(videoTrack: 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 */
@@ -1783,10 +1823,16 @@ export class VideoSampleSink extends BaseMediaSampleSink<VideoSample> {
const codec = await this._track.getCodec();
const rotation = await this._track.getRotation();
const decoderConfig = await this._track.getDecoderConfig();
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, timeResolution);
}
@@ -1903,6 +1949,8 @@ export type CanvasSinkOptions = {
* canvas is created each time.
*/
poolSize?: number;
/** Additional preferences for the underlying video decoder. */
decoderOptions?: VideoSinkDecoderOptions;
};
/**
@@ -1982,12 +2030,15 @@ export class CanvasSink {
) {
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);
this._videoSampleSink = new VideoSampleSink(videoTrack, options.decoderOptions);
this._canvasPool = Array.from({ length: options.poolSize ?? 0 }, () => null);
}