use crate::av1::{Av1CodecConfigurationRecord, Av1Obu, Av1Parser};
use crate::codec::{
CancellationToken, CodecProfile, EncodedVideoSample, ExactFrameReader, HardwarePreference,
VideoDecoderConfig, VideoDecoderFactory,
};
use crate::codec_config::derive_codec_string;
use crate::io::MemorySource;
use crate::media::{Codec, ColorRange, PixelFormat, VideoDimensions, VideoFrame};
use crate::mp4_demux::Mp4Track;
use crate::timeline::FrameIndex;
use crate::{Error, ErrorKind, Limits, Result};
use std::cell::RefCell;
use std::collections::{HashMap, HashSet, VecDeque};
use std::rc::Rc;
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use web_sys::{
EncodedVideoChunk, EncodedVideoChunkInit, EncodedVideoChunkType,
VideoDecoder as JsVideoDecoder, VideoDecoderConfig as JsVideoDecoderConfig, VideoDecoderInit,
VideoDecoderSupport, VideoFrame as JsVideoFrame, VideoFrameCopyToOptions, VideoPixelFormat,
};
use zvidlib_core::codec_config::box_payload;
const MAX_IN_FLIGHT_CHUNKS: u32 = 16;
const MAX_OPEN_FRAMES: usize = 4;
pub(crate) fn schedule_event_loop_tick(resolve: &js_sys::Function) {
let global = js_sys::global();
if let Ok(set_timeout) = js_sys::Reflect::get(&global, &JsValue::from_str("setTimeout"))
&& let Ok(set_timeout) = set_timeout.dyn_into::<js_sys::Function>()
{
let _ = set_timeout.call2(&global, resolve, &JsValue::from_f64(0.0));
}
}
fn codec_description(codec: Codec, decoder_config: &[u8]) -> Result<&[u8]> {
match codec {
Codec::Hevc => box_payload(decoder_config, b"hvcC"),
Codec::Av1 => box_payload(decoder_config, b"av1C"),
Codec::Vp8 => Ok(&[]),
Codec::Vp9 => Ok(&[]),
_ => Err(Error::new(
ErrorKind::Unsupported,
"only HEVC, AV1, VP8 and VP9 have a WebCodecs decoder backend",
)),
}
}
pub(crate) fn js_to_promise(value: impl JsCast) -> js_sys::Promise {
value.unchecked_into()
}
async fn parse_video_track(source: &MemorySource, index: u32, limits: &Limits) -> Result<Mp4Track> {
crate::container::open_tracks(source, limits)
.await?
.into_iter()
.filter(|track| track.kind == crate::codec::TrackKind::Video)
.nth(index as usize)
.ok_or_else(|| Error::new(ErrorKind::InvalidInput, "no such video track"))
}
pub async fn video_frame_durations_ms(
bytes: &[u8],
track_index: u32,
limits: &Limits,
) -> Result<Vec<f64>> {
let source = MemorySource::new(bytes.to_vec());
let track = parse_video_track(&source, track_index, limits).await?;
let milliseconds_per_tick = 1_000.0 / f64::from(track.timescale);
track
.presentation_order
.iter()
.map(|&decode_index| {
track
.samples
.get(decode_index)
.map(|sample| f64::from(sample.duration) * milliseconds_per_tick)
.ok_or_else(|| {
Error::new(
ErrorKind::MalformedMedia,
"video presentation index references a missing sample",
)
})
})
.collect()
}
pub async fn video_random_access_points(
bytes: &[u8],
track_index: u32,
limits: &Limits,
) -> Result<Vec<u64>> {
let source = MemorySource::new(bytes.to_vec());
let track = parse_video_track(&source, track_index, limits).await?;
let mut points: Vec<u64> = track
.presentation_order
.iter()
.enumerate()
.filter_map(|(presentation_index, &decode_index)| {
let sample = track.samples.get(decode_index)?;
sample.is_sync.then_some(presentation_index as u64)
})
.collect();
points.sort_unstable();
Ok(points)
}
pub struct WebVideoDecodeSession {
dimensions: VideoDimensions,
frame_count: u64,
backend: DecodeBackend,
}
enum DecodeBackend {
WebCodecs(WebCodecsDecoder),
Software(SoftwareDecoder),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum BackendChoice {
Automatic,
#[cfg(all(test, feature = "all"))]
SoftwareOnly,
}
impl WebVideoDecodeSession {
pub async fn open(bytes: &[u8], track_index: u32, limits: &Limits) -> Result<Self> {
Self::open_with(bytes, track_index, limits, BackendChoice::Automatic).await
}
async fn open_with(
bytes: &[u8],
track_index: u32,
limits: &Limits,
choice: BackendChoice,
) -> Result<Self> {
let source = MemorySource::new(bytes.to_vec());
let track = parse_video_track(&source, track_index, limits).await?;
let dimensions = track.dimensions.ok_or_else(|| {
Error::new(ErrorKind::MalformedMedia, "video track has no dimensions")
})?;
let derived = derive_codec_string(track.codec, &track.decoder_config)?;
let description = codec_description(track.codec, &track.decoder_config)?;
let config = JsVideoDecoderConfig::new(&derived.codec_string);
config.set_coded_width(dimensions.width);
config.set_coded_height(dimensions.height);
if !description.is_empty() {
config.set_description_u8_array(&js_sys::Uint8Array::from(description));
}
config.set_optimize_for_latency(true);
let webcodecs_supported = match choice {
BackendChoice::Automatic => {
let support: VideoDecoderSupport =
JsFuture::from(js_to_promise(JsVideoDecoder::is_config_supported(&config)))
.await
.map_err(|error| {
normalize_js_error(error, "querying WebCodecs decoder support")
})?
.unchecked_into();
support.get_supported().unwrap_or(false)
}
#[cfg(all(test, feature = "all"))]
BackendChoice::SoftwareOnly => false,
};
let samples = track.to_encoded_video_samples(&source, limits).await?;
let frame_count = track.presentation_order.len() as u64;
let backend = if webcodecs_supported {
DecodeBackend::WebCodecs(WebCodecsDecoder::open(config, samples, limits)?)
} else {
let decoder =
SoftwareDecoder::open(&track, derived.profile, dimensions, samples, limits)
.map_err(|error| {
Error::new(
ErrorKind::Unsupported,
format!(
"this browser cannot decode {} via WebCodecs, and the software \
decoder cannot either: {}",
derived.codec_string,
error.message()
),
)
})?;
DecodeBackend::Software(decoder)
};
Ok(Self {
dimensions,
frame_count,
backend,
})
}
pub fn dimensions(&self) -> VideoDimensions {
self.dimensions
}
pub fn frame_count(&self) -> u64 {
self.frame_count
}
#[cfg(all(test, feature = "all"))]
fn is_software(&self) -> bool {
matches!(self.backend, DecodeBackend::Software(_))
}
#[cfg(all(test, feature = "all"))]
pub(crate) async fn decodes_through_webcodecs(bytes: &[u8], track_index: u32) -> bool {
Self::open(bytes, track_index, &Limits::default())
.await
.is_ok_and(|session| !session.is_software())
}
pub async fn get(
&mut self,
presentation_index: FrameIndex,
cancellation: &CancellationToken,
) -> Result<(VideoDimensions, Vec<u8>)> {
match &mut self.backend {
DecodeBackend::WebCodecs(decoder) => {
decoder.get(presentation_index, cancellation).await
}
DecodeBackend::Software(decoder) => decoder.get(presentation_index, cancellation).await,
}
}
}
fn software_decoder_factory(codec: Codec) -> Result<Box<dyn VideoDecoderFactory>> {
match codec {
#[cfg(feature = "hevc-decoder")]
Codec::Hevc => Ok(Box::new(crate::native_hevc_video_decoder_factory())),
#[cfg(feature = "av1-decoder")]
Codec::Av1 => Ok(Box::new(crate::native_av1_video_decoder_factory())),
#[cfg(feature = "vp8-decoder")]
Codec::Vp8 => Ok(Box::new(crate::native_vp8_video_decoder_factory())),
#[cfg(feature = "vp9-decoder")]
Codec::Vp9 => Ok(Box::new(crate::native_vp9_video_decoder_factory())),
_ => Err(Error::new(
ErrorKind::Unsupported,
"only HEVC, AV1, VP8 and VP9 have a software decoder backend, each with its \
<codec>-decoder Cargo feature",
)),
}
}
struct SoftwareDecoder {
reader: ExactFrameReader,
}
impl SoftwareDecoder {
fn open(
track: &Mp4Track,
profile: CodecProfile,
dimensions: VideoDimensions,
samples: Vec<EncodedVideoSample>,
limits: &Limits,
) -> Result<Self> {
let factory = software_decoder_factory(track.codec)?;
let (profile, color_range) = match track.codec {
Codec::Av1 => (
CodecProfile::Av1Main,
av1_color_range(track, &samples, limits),
),
Codec::Vp9 => (profile, vp9_color_range(track, &samples)),
_ => (profile, ColorRange::Limited),
};
let configuration = VideoDecoderConfig {
codec: track.codec,
profile,
coded_dimensions: dimensions,
output_format: PixelFormat::Rgba8,
color_range,
hardware: HardwarePreference::Avoid,
configuration: track.decoder_config.clone(),
};
let reader = ExactFrameReader::new(factory.as_ref(), configuration, samples, *limits)?;
Ok(Self { reader })
}
async fn get(
&mut self,
presentation_index: FrameIndex,
cancellation: &CancellationToken,
) -> Result<(VideoDimensions, Vec<u8>)> {
yield_to_event_loop().await;
let frame = self.reader.get(presentation_index, cancellation)?;
Ok((frame.dimensions, packed_rgba(&frame)))
}
}
fn av1_color_range(
track: &Mp4Track,
samples: &[EncodedVideoSample],
limits: &Limits,
) -> ColorRange {
let from_config = Av1CodecConfigurationRecord::parse(&track.decoder_config, limits)
.ok()
.and_then(|record| {
record.config_obus.into_iter().find_map(|obu| match obu {
Av1Obu::SequenceHeader { sequence, .. } => Some(sequence),
_ => None,
})
});
let sequence = from_config.or_else(|| {
let mut parser = Av1Parser::new(*limits).ok()?;
parser.parse_low_overhead(&samples.first()?.data).ok()?;
parser.sequence
});
match sequence {
Some(sequence) if sequence.color_config.color_range => ColorRange::Full,
_ => ColorRange::Limited,
}
}
fn vp9_color_range(track: &Mp4Track, samples: &[EncodedVideoSample]) -> ColorRange {
let full = samples
.first()
.and_then(|sample| zvidlib_vp9_syntax::chunk_full_range(&sample.data))
.or_else(|| {
crate::Vp9CodecConfig::parse(&track.decoder_config)
.ok()
.map(|config| config.video_full_range)
});
if full == Some(true) {
ColorRange::Full
} else {
ColorRange::Limited
}
}
fn packed_rgba(frame: &VideoFrame) -> Vec<u8> {
let plane = &frame.planes[0];
let row = frame.dimensions.width as usize * 4;
plane
.data
.chunks(plane.stride)
.take(frame.dimensions.height as usize)
.flat_map(|line| &line[..row])
.copied()
.collect()
}
async fn yield_to_event_loop() {
let promise = js_sys::Promise::new(&mut |resolve, _reject| schedule_event_loop_tick(&resolve));
let _ = JsFuture::from(promise).await;
}
struct WebCodecsDecoder {
samples: Vec<EncodedVideoSample>,
decode_position_by_presentation: HashMap<FrameIndex, usize>,
decoder: JsVideoDecoder,
config: JsVideoDecoderConfig,
pending_frames: Rc<RefCell<Vec<JsVideoFrame>>>,
decode_error: Rc<RefCell<Option<String>>>,
cache: HashMap<FrameIndex, JsVideoFrame>,
cache_order: VecDeque<FrameIndex>,
next_decode_position: Option<usize>,
session_start: Option<usize>,
published_since_reset: HashSet<FrameIndex>,
waker: Rc<RefCell<Option<js_sys::Function>>>,
limits: Limits,
_output_closure: Closure<dyn FnMut(JsVideoFrame)>,
_error_closure: Closure<dyn FnMut(JsValue)>,
}
impl WebCodecsDecoder {
fn open(
config: JsVideoDecoderConfig,
samples: Vec<EncodedVideoSample>,
limits: &Limits,
) -> Result<Self> {
let pending_frames: Rc<RefCell<Vec<JsVideoFrame>>> = Rc::new(RefCell::new(Vec::new()));
let decode_error: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
let waker: Rc<RefCell<Option<js_sys::Function>>> = Rc::new(RefCell::new(None));
let output_frames = Rc::clone(&pending_frames);
let output_waker = Rc::clone(&waker);
let output_closure = Closure::new(move |frame: JsVideoFrame| {
output_frames.borrow_mut().push(frame);
if let Some(resolve) = output_waker.borrow_mut().take() {
let _ = resolve.call0(&JsValue::NULL);
}
});
let error_state = Rc::clone(&decode_error);
let error_waker = Rc::clone(&waker);
let error_closure = Closure::new(move |error: JsValue| {
let message = js_sys::Reflect::get(&error, &JsValue::from_str("message"))
.ok()
.and_then(|value| value.as_string())
.unwrap_or_else(|| "WebCodecs decoder reported an error".to_owned());
*error_state.borrow_mut() = Some(message);
if let Some(resolve) = error_waker.borrow_mut().take() {
let _ = resolve.call0(&JsValue::NULL);
}
});
let init = VideoDecoderInit::new(
error_closure.as_ref().unchecked_ref(),
output_closure.as_ref().unchecked_ref(),
);
let decoder = JsVideoDecoder::new(&init)
.map_err(|error| normalize_js_error(error, "constructing a WebCodecs VideoDecoder"))?;
decoder
.configure(&config)
.map_err(|error| normalize_js_error(error, "configuring the WebCodecs VideoDecoder"))?;
let mut decode_position_by_presentation = HashMap::with_capacity(samples.len());
for (position, sample) in samples.iter().enumerate() {
decode_position_by_presentation.insert(sample.presentation_index, position);
}
Ok(Self {
samples,
decode_position_by_presentation,
decoder,
config,
pending_frames,
decode_error,
cache: HashMap::new(),
cache_order: VecDeque::new(),
next_decode_position: None,
session_start: None,
published_since_reset: HashSet::new(),
waker,
limits: *limits,
_output_closure: output_closure,
_error_closure: error_closure,
})
}
fn nearest_random_access(&self, position: usize) -> usize {
let target = self.samples[position].presentation_index;
(0..=position)
.rev()
.find(|&candidate| {
let sample = &self.samples[candidate];
sample.random_access && sample.presentation_index <= target
})
.unwrap_or(0)
}
async fn get(
&mut self,
presentation_index: FrameIndex,
cancellation: &CancellationToken,
) -> Result<(VideoDimensions, Vec<u8>)> {
cancellation.check()?;
self.trim_cache(presentation_index);
if let Some(frame) = self.cache.get(&presentation_index) {
return self.copy_frame_rgba(frame).await;
}
let target_position = *self
.decode_position_by_presentation
.get(&presentation_index)
.ok_or_else(|| {
Error::new(ErrorKind::InvalidInput, "presentation frame is not indexed")
})?;
let random_access_position = self.nearest_random_access(target_position);
let can_reuse = can_continue_session(
self.next_decode_position,
self.session_start,
random_access_position,
&self.published_since_reset,
presentation_index,
);
if !can_reuse {
let required_span = target_position - random_access_position + 1;
if required_span > self.limits.max_decode_samples_per_seek as usize {
return Err(Error::new(
ErrorKind::ResourceLimit,
"exact-frame request exceeded the configured decode-work limit",
));
}
self.decoder.reset().map_err(|error| {
normalize_js_error(error, "resetting the WebCodecs VideoDecoder")
})?;
self.close_pending_frames();
self.close_cached_frames();
*self.decode_error.borrow_mut() = None;
self.published_since_reset.clear();
self.decoder.configure(&self.config).map_err(|error| {
normalize_js_error(error, "reconfiguring the WebCodecs VideoDecoder")
})?;
self.next_decode_position = Some(random_access_position);
self.session_start = Some(random_access_position);
}
let mut submitted = 0_u32;
let mut drained_after_flush = false;
loop {
cancellation.check()?;
if let Some(message) = self.decode_error.borrow_mut().take() {
self.next_decode_position = None;
return Err(Error::new(ErrorKind::Codec, message));
}
self.drain_pending_frames(presentation_index);
if self.cache.contains_key(&presentation_index) {
let frame = &self.cache[&presentation_index];
return self.copy_frame_rgba(frame).await;
}
if drained_after_flush {
return Err(Error::new(
ErrorKind::Internal,
"decoder did not output the requested frame",
));
}
if let Some(position) = self.next_decode_position {
if position < self.samples.len()
&& self.decoder.decode_queue_size() < MAX_IN_FLIGHT_CHUNKS
{
if submitted >= self.limits.max_decode_samples_per_seek {
return Err(Error::new(
ErrorKind::ResourceLimit,
"exact-frame request exceeded the configured decode-work limit",
));
}
self.submit_at(position)?;
submitted += 1;
continue;
}
}
if self.decoder.decode_queue_size() == 0 {
self.next_decode_position = None;
JsFuture::from(js_to_promise(self.decoder.flush()))
.await
.map_err(|error| {
normalize_js_error(error, "flushing the WebCodecs VideoDecoder")
})?;
drained_after_flush = true;
continue;
}
self.wait_for_output().await;
}
}
fn drain_pending_frames(&mut self, wanted: FrameIndex) {
let frames = std::mem::take(&mut *self.pending_frames.borrow_mut());
for frame in frames {
let index = FrameIndex(frame.timestamp() as u64);
self.published_since_reset.insert(index);
self.insert_cache(index, frame, wanted);
}
}
fn close_pending_frames(&self) {
for frame in std::mem::take(&mut *self.pending_frames.borrow_mut()) {
frame.close();
}
}
fn close_cached_frames(&mut self) {
self.cache_order.clear();
for (_, frame) in self.cache.drain() {
frame.close();
}
}
fn submit_at(&mut self, position: usize) -> Result<()> {
match self.submit(&self.samples[position]) {
Ok(()) => {
self.next_decode_position = Some(position + 1);
Ok(())
}
Err(error) => {
self.next_decode_position = None;
Err(error)
}
}
}
async fn wait_for_output(&self) {
let waker = Rc::clone(&self.waker);
let promise = js_sys::Promise::new(&mut |resolve, _reject| {
schedule_event_loop_tick(&resolve);
*waker.borrow_mut() = Some(resolve);
});
let _ = JsFuture::from(promise).await;
}
fn insert_cache(&mut self, index: FrameIndex, frame: JsVideoFrame, wanted: FrameIndex) {
match self.cache.insert(index, frame) {
Some(replaced) => replaced.close(),
None => self.cache_order.push_back(index),
}
self.trim_cache(wanted);
}
fn trim_cache(&mut self, wanted: FrameIndex) {
let history = (self.limits.max_cached_frames as usize).clamp(1, MAX_OPEN_FRAMES);
while self
.cache_order
.iter()
.filter(|&&cached| cached <= wanted)
.count()
> history
{
let Some(oldest) = self
.cache_order
.iter()
.copied()
.filter(|&cached| cached < wanted)
.min()
else {
break;
};
self.evict(oldest);
}
let capacity = (self.limits.max_cached_frames as usize).max(1);
while self.cache.len() > capacity {
let Some(furthest) = self
.cache_order
.iter()
.copied()
.filter(|&cached| cached > wanted)
.max()
else {
break;
};
self.evict(furthest);
}
}
fn evict(&mut self, index: FrameIndex) {
self.cache_order.retain(|&cached| cached != index);
if let Some(frame) = self.cache.remove(&index) {
frame.close();
}
}
fn submit(&self, sample: &EncodedVideoSample) -> Result<()> {
let kind = if sample.random_access {
EncodedVideoChunkType::Key
} else {
EncodedVideoChunkType::Delta
};
let data = js_sys::Uint8Array::from(sample.data.as_slice());
let init = EncodedVideoChunkInit::new_with_u8_array(&data, 0, kind);
init.set_timestamp_f64(sample.presentation_index.0 as f64);
let chunk = EncodedVideoChunk::new(&init)
.map_err(|error| normalize_js_error(error, "constructing an EncodedVideoChunk"))?;
self.decoder
.decode(&chunk)
.map_err(|error| normalize_js_error(error, "decoding a video sample"))
}
async fn copy_frame_rgba(&self, frame: &JsVideoFrame) -> Result<(VideoDimensions, Vec<u8>)> {
let dimensions =
VideoDimensions::new(frame.display_width(), frame.display_height(), &self.limits)
.map_err(|error| Error::new(error.kind(), error.message()))?;
let byte_length = (dimensions.width as usize)
.checked_mul(dimensions.height as usize)
.and_then(|pixels| pixels.checked_mul(4))
.ok_or_else(|| {
Error::new(
ErrorKind::ResourceLimit,
"decoded frame allocation overflow",
)
})?;
let destination = js_sys::Uint8Array::new_with_length(byte_length as u32);
let options = VideoFrameCopyToOptions::new();
options.set_format(VideoPixelFormat::Rgba);
JsFuture::from(js_to_promise(
frame.copy_to_with_u8_array_and_options(&destination, &options),
))
.await
.map_err(|error| normalize_js_error(error, "copying a decoded video frame"))?;
Ok((dimensions, destination.to_vec()))
}
}
impl Drop for WebCodecsDecoder {
fn drop(&mut self) {
self.close_pending_frames();
self.close_cached_frames();
let _ = self.decoder.close();
}
}
fn can_continue_session(
next_decode_position: Option<usize>,
session_start: Option<usize>,
random_access_position: usize,
published_since_reset: &HashSet<FrameIndex>,
presentation_index: FrameIndex,
) -> bool {
next_decode_position.is_some_and(|position| position >= random_access_position)
&& session_start.is_some_and(|start| start <= random_access_position)
&& !published_since_reset.contains(&presentation_index)
}
pub(crate) fn normalize_js_error(error: JsValue, context: &str) -> Error {
let detail = js_sys::Reflect::get(&error, &JsValue::from_str("message"))
.ok()
.and_then(|value| value.as_string())
.or_else(|| error.as_string())
.unwrap_or_else(|| "WebCodecs operation failed".to_owned());
Error::new(ErrorKind::Codec, format!("{context}: {detail}"))
}
#[cfg(all(test, feature = "all"))]
mod tests {
use super::*;
use wasm_bindgen_test::*;
wasm_bindgen_test_configure!(run_in_browser);
#[wasm_bindgen_test]
fn reordered_output_keeps_the_session_open_past_the_target_position() {
let published = HashSet::new();
assert!(can_continue_session(
Some(9),
Some(0),
0,
&published,
FrameIndex(2)
));
assert!(can_continue_session(
Some(120),
Some(0),
0,
&published,
FrameIndex(3)
));
}
#[wasm_bindgen_test]
fn a_frame_already_emitted_since_the_last_reset_requires_a_reset() {
let published = HashSet::from([FrameIndex(2)]);
assert!(!can_continue_session(
Some(9),
Some(0),
0,
&published,
FrameIndex(2)
));
assert!(can_continue_session(
Some(9),
Some(0),
0,
&published,
FrameIndex(3)
));
}
#[wasm_bindgen_test]
fn seeking_before_the_open_session_random_access_point_requires_a_reset() {
let published = HashSet::new();
assert!(!can_continue_session(
Some(4),
Some(0),
30,
&published,
FrameIndex(31)
));
assert!(can_continue_session(
Some(30),
Some(30),
30,
&published,
FrameIndex(31)
));
}
#[wasm_bindgen_test]
fn a_request_before_the_open_session_start_requires_a_reset() {
let published = HashSet::new();
assert!(!can_continue_session(
Some(41),
Some(32),
0,
&published,
FrameIndex(5)
));
assert!(can_continue_session(
Some(41),
Some(32),
32,
&published,
FrameIndex(45)
));
}
#[wasm_bindgen_test]
fn an_invalidated_session_requires_a_reset() {
let published = HashSet::new();
assert!(!can_continue_session(
None,
Some(0),
0,
&published,
FrameIndex(0)
));
assert!(!can_continue_session(
Some(0),
None,
0,
&published,
FrameIndex(0)
));
}
const SMALL_HEVC: &[u8] =
include_bytes!("../crates/zvidlib-hevc-decoder/tests/fixtures/bbb_hevc_512x288_gop32.mp4");
fn digest(dimensions: VideoDimensions, rgba: Vec<u8>) -> String {
let limits = Limits::default();
let stride = dimensions.width as usize * 4;
let frame = VideoFrame::new(
dimensions,
PixelFormat::Rgba8,
ColorRange::Limited,
vec![crate::media::Plane { data: rgba, stride }],
&limits,
)
.unwrap();
crate::conformance::FrameDigest::from_frame(&frame)
.unwrap()
.to_hex()
}
#[wasm_bindgen_test(async)]
async fn software_fallback_decodes_hevc_like_the_native_decoder() {
let mut session = WebVideoDecodeSession::open_with(
SMALL_HEVC,
0,
&Limits::default(),
BackendChoice::SoftwareOnly,
)
.await
.unwrap();
assert!(session.is_software());
assert_eq!(
(session.dimensions().width, session.dimensions().height),
(512, 288)
);
assert_eq!(session.frame_count(), 768);
let expected = [
(
0,
"d405441057a0528fe3a0cda232a667177168004b7153c079080f25aafab078ce",
),
(
1,
"2d7594d6df9dcacb1949b9f56b82e28f4c46309ae41a269619dc364d19ea7c75",
),
(
20,
"79ac5da3a64bf292dbd490f9c92fe9321610c022dd9be117692b23ba19a281a0",
),
(
2,
"c9213b4b6961cef722c723ed0ac1e302b99f0f72162e447191ae4c0e3b03211f",
),
(
40,
"dedcc605951fd99f5546e98c7d5243c6e31535e37726f45b2aa7faea025d2e3c",
),
(
29,
"50a1d7185ea2a54d245d7e3d4e8c4e182dd2e61e9bfb72b025cf688353bdc55e",
),
(
32,
"c8aa7d647b3c001d1c0bb473ff7c9d195481a2490273c37404db5430165312f9",
),
];
for (frame, expected) in expected {
let (dimensions, rgba) = session
.get(FrameIndex(frame), &CancellationToken::new())
.await
.unwrap();
assert_eq!(rgba.len(), 512 * 288 * 4);
assert_eq!(digest(dimensions, rgba), expected, "frame {frame}");
}
}
#[wasm_bindgen_test(async)]
async fn a_request_before_the_open_session_start_returns_the_requested_frame() {
let limits = Limits::default();
let cancellation = CancellationToken::new();
let mut fresh = WebVideoDecodeSession::open(SMALL_HEVC, 0, &limits)
.await
.unwrap();
if fresh.is_software() {
return;
}
let expected = fresh.get(FrameIndex(5), &cancellation).await.unwrap();
let mut session = WebVideoDecodeSession::open(SMALL_HEVC, 0, &limits)
.await
.unwrap();
session.get(FrameIndex(40), &cancellation).await.unwrap();
let (dimensions, rgba) = session.get(FrameIndex(5), &cancellation).await.unwrap();
assert_eq!(
(dimensions.width, dimensions.height),
(expected.0.width, expected.0.height)
);
assert!(
rgba == expected.1,
"frame 5 after frame 40 differs from a fresh decode"
);
}
#[wasm_bindgen_test(async)]
async fn software_fallback_decodes_hevc_main10() {
const MAIN10: &[u8] = include_bytes!(
"../crates/zvidlib-hevc-decoder/tests/fixtures/bbb_hevc_main10_128x72.mp4"
);
let expected: Vec<&str> = include_str!(
"../crates/zvidlib-hevc-decoder/tests/fixtures/bbb_hevc_main10_128x72_rgba.sha256"
)
.lines()
.map(|line| line.split_once(' ').unwrap().1)
.collect();
let mut session = WebVideoDecodeSession::open_with(
MAIN10,
0,
&Limits::default(),
BackendChoice::SoftwareOnly,
)
.await
.unwrap();
assert!(session.is_software());
assert_eq!(session.frame_count(), 12);
for frame in [0_u64, 5, 11, 3] {
let (dimensions, rgba) = session
.get(FrameIndex(frame), &CancellationToken::new())
.await
.unwrap();
assert_eq!((dimensions.width, dimensions.height), (128, 72));
assert_eq!(
digest(dimensions, rgba),
expected[frame as usize],
"frame {frame}"
);
}
}
#[wasm_bindgen_test(async)]
async fn webcodecs_stays_preferred_when_the_browser_supports_the_config() {
let limits = Limits::default();
let session = WebVideoDecodeSession::open(SMALL_HEVC, 0, &limits)
.await
.unwrap();
let source = MemorySource::new(SMALL_HEVC.to_vec());
let track = parse_video_track(&source, 0, &limits).await.unwrap();
let derived = derive_codec_string(track.codec, &track.decoder_config).unwrap();
let config = JsVideoDecoderConfig::new(&derived.codec_string);
config.set_coded_width(512);
config.set_coded_height(288);
config.set_description_u8_array(&js_sys::Uint8Array::from(
codec_description(track.codec, &track.decoder_config).unwrap(),
));
config.set_optimize_for_latency(true);
let support: VideoDecoderSupport =
JsFuture::from(js_to_promise(JsVideoDecoder::is_config_supported(&config)))
.await
.unwrap()
.unchecked_into();
assert_eq!(
session.is_software(),
!support.get_supported().unwrap_or(false)
);
}
#[wasm_bindgen_test(async)]
async fn software_fallback_decodes_av1_back_to_its_source() {
use crate::codec::{VideoEncoderConfig, VideoEncoderFactory};
use crate::io::MemorySink;
use crate::mp4::{Mp4Muxer, Mp4TrackConfig, Mp4TrackFormat};
use crate::transfer::{CpuFrameSource, FrameSource, Orientation};
let limits = Limits::default();
let dimensions = VideoDimensions::new(32, 18, &limits).unwrap();
let gray = |index: u64| -> Vec<u8> {
(0..dimensions.height)
.flat_map(|y| {
(0..dimensions.width)
.map(move |x| ((x * 7 + y * 3 + index as u32 * 5) % 256) as u8)
})
.collect()
};
let mut encoder = crate::native_av1_video_encoder_factory()
.create(
&VideoEncoderConfig {
codec: Codec::Av1,
profile: CodecProfile::Av1Main,
coded_dimensions: dimensions,
input_format: PixelFormat::Gray8,
color_range: ColorRange::Full,
hardware: HardwarePreference::Avoid,
timescale: 30,
frame_duration: 1,
configuration: Vec::new(),
},
&limits,
)
.unwrap();
let mut muxer = Mp4Muxer::new(
MemorySink::new(),
vec![Mp4TrackConfig {
encoder: encoder.config().clone(),
format: Mp4TrackFormat::Video(dimensions),
}],
60,
)
.await
.unwrap();
for index in 0..3_u64 {
let frame = VideoFrame::new(
dimensions,
PixelFormat::Gray8,
ColorRange::Full,
vec![crate::media::Plane {
data: gray(index),
stride: dimensions.width as usize,
}],
&limits,
)
.unwrap();
let samples = encoder
.encode(
FrameIndex(index),
FrameSource::Cpu(CpuFrameSource {
frame: &frame,
orientation: Orientation::TopLeft,
}),
)
.await
.unwrap();
for sample in samples {
muxer.write_sample(0, sample).await.unwrap();
}
}
for sample in encoder.finish().await.unwrap() {
muxer.write_sample(0, sample).await.unwrap();
}
let bytes = muxer.finish().await.unwrap().into_inner();
let mut session =
WebVideoDecodeSession::open_with(&bytes, 0, &limits, BackendChoice::SoftwareOnly)
.await
.unwrap();
assert!(session.is_software());
for index in [2_u64, 0] {
let (decoded, rgba) = session
.get(FrameIndex(index), &CancellationToken::new())
.await
.unwrap();
assert_eq!(decoded, dimensions);
let expected: Vec<u8> = gray(index)
.into_iter()
.flat_map(|level| [level, level, level, 255])
.collect();
assert_eq!(rgba, expected, "frame {index}");
}
}
#[wasm_bindgen_test(async)]
async fn webcodecs_decodes_native_vp9_output() {
use crate::codec::{VideoEncoderConfig, VideoEncoderFactory};
use crate::io::MemorySink;
use crate::mp4::{Mp4Muxer, Mp4TrackConfig, Mp4TrackFormat};
use crate::transfer::{CpuFrameSource, FrameSource, Orientation};
let limits = Limits::default();
let dimensions = VideoDimensions::new(64, 36, &limits).unwrap();
let rgba = |index: u64| -> Vec<u8> {
(0..dimensions.height)
.flat_map(|y| {
(0..dimensions.width).flat_map(move |x| {
let u = x + index as u32 * 2;
[(u * 3) as u8, (y * 6) as u8, 200 - (u * 2) as u8, 255]
})
})
.collect()
};
let mut encoder = crate::native_vp9_video_encoder_factory()
.create(
&VideoEncoderConfig {
codec: Codec::Vp9,
profile: CodecProfile::Vp9Profile0,
coded_dimensions: dimensions,
input_format: PixelFormat::Rgba8,
color_range: ColorRange::Limited,
hardware: HardwarePreference::Avoid,
timescale: 30,
frame_duration: 1,
configuration: vec![40, 0, 3],
},
&limits,
)
.unwrap();
let mut muxer = Mp4Muxer::new(
MemorySink::new(),
vec![Mp4TrackConfig {
encoder: encoder.config().clone(),
format: Mp4TrackFormat::Video(dimensions),
}],
60,
)
.await
.unwrap();
for index in 0..7_u64 {
let frame = VideoFrame::new(
dimensions,
PixelFormat::Rgba8,
ColorRange::Limited,
vec![crate::media::Plane {
data: rgba(index),
stride: dimensions.width as usize * 4,
}],
&limits,
)
.unwrap();
let samples = encoder
.encode(
FrameIndex(index),
FrameSource::Cpu(CpuFrameSource {
frame: &frame,
orientation: Orientation::TopLeft,
}),
)
.await
.unwrap();
for sample in samples {
muxer.write_sample(0, sample).await.unwrap();
}
}
for sample in encoder.finish().await.unwrap() {
muxer.write_sample(0, sample).await.unwrap();
}
let bytes = muxer.finish().await.unwrap().into_inner();
let Ok(mut session) = WebVideoDecodeSession::open(&bytes, 0, &limits).await else {
return;
};
assert!(!session.is_software());
for index in [5_u64, 1, 6] {
let (decoded, pixels) = session
.get(FrameIndex(index), &CancellationToken::new())
.await
.unwrap();
assert_eq!(decoded, dimensions);
let expected = rgba(index);
let error = pixels
.iter()
.zip(&expected)
.map(|(&a, &b)| (f64::from(a) - f64::from(b)).powi(2))
.sum::<f64>()
/ pixels.len() as f64;
let psnr = 10.0 * (255.0 * 255.0 / error.max(1e-9)).log10();
assert!(psnr > 30.0, "frame {index}: {psnr:.1} dB");
}
}
#[wasm_bindgen_test(async)]
async fn software_fallback_decodes_color_av1_like_an_independent_decoder() {
const COLOR_AV1: &[u8] = include_bytes!("../examples/media/BigBuckBunny.av1.mp4");
let mut session = WebVideoDecodeSession::open_with(
COLOR_AV1,
0,
&Limits::default(),
BackendChoice::SoftwareOnly,
)
.await
.unwrap();
assert!(session.is_software());
assert_eq!(
(session.dimensions().width, session.dimensions().height),
(960, 540)
);
assert_eq!(session.frame_count(), 768);
let expected = [
(
0,
"3c7fbf07cee4f4e3b72c9667061e162362019f6aea2c035fad2032180dd8742f",
),
(
1,
"2c189d695e847d075bb765611f51287594eca1e8bbabc5a403c6d623c21dc5c7",
),
(
20,
"387c5e72026d94a8c123c3e27977e44a84bc6ec902629e7f44b040c957979886",
),
];
for (frame, expected) in expected {
let (dimensions, rgba) = session
.get(FrameIndex(frame), &CancellationToken::new())
.await
.unwrap();
assert_eq!(rgba.len(), 960 * 540 * 4);
assert_eq!(digest(dimensions, rgba), expected, "frame {frame}");
}
}
#[wasm_bindgen_test(async)]
async fn sequential_webcodecs_reads_do_not_exhaust_the_decoder() {
const COLOR_AV1: &[u8] = include_bytes!("../examples/media/BigBuckBunny.av1.mp4");
let mut session = WebVideoDecodeSession::open(COLOR_AV1, 0, &Limits::default())
.await
.unwrap();
if session.is_software() {
return;
}
for frame in 0..48 {
let cancellation = CancellationToken::new();
let timeout = cancellation.clone();
let cancel = Closure::once_into_js(move || timeout.cancel());
let set_timeout: js_sys::Function =
js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("setTimeout"))
.unwrap()
.unchecked_into();
let timer = set_timeout
.call2(&JsValue::NULL, &cancel, &JsValue::from_f64(10_000.0))
.unwrap();
let result = session.get(FrameIndex(frame), &cancellation).await;
let clear_timeout: js_sys::Function =
js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("clearTimeout"))
.unwrap()
.unchecked_into();
clear_timeout.call1(&JsValue::NULL, &timer).unwrap();
let (dimensions, rgba) =
result.unwrap_or_else(|error| panic!("frame {frame}: {error:?}"));
assert_eq!((dimensions.width, dimensions.height), (960, 540));
assert_eq!(rgba.len(), 960 * 540 * 4);
}
}
#[wasm_bindgen_test(async)]
async fn paced_sequential_reads_keep_the_frames_the_decoder_ran_ahead_to() {
const COLOR_AV1: &[u8] = include_bytes!("../examples/media/BigBuckBunny.av1.mp4");
let mut session = WebVideoDecodeSession::open(COLOR_AV1, 0, &Limits::default())
.await
.unwrap();
if session.is_software() {
return;
}
for frame in 0..24 {
let index = FrameIndex(frame);
if frame > 0 {
let DecodeBackend::WebCodecs(decoder) = &session.backend else {
unreachable!("checked above");
};
let position = decoder.decode_position_by_presentation[&index];
assert!(
decoder.cache.contains_key(&index)
|| can_continue_session(
decoder.next_decode_position,
decoder.session_start,
decoder.nearest_random_access(position),
&decoder.published_since_reset,
index,
),
"reading frame {frame} would reset the decoder"
);
}
session
.get(index, &CancellationToken::new())
.await
.unwrap_or_else(|error| panic!("frame {frame}: {error:?}"));
let pause = js_sys::Promise::new(&mut |resolve, _reject| {
let set_timeout: js_sys::Function =
js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("setTimeout"))
.unwrap()
.unchecked_into();
set_timeout
.call2(&JsValue::NULL, &resolve, &JsValue::from_f64(40.0))
.unwrap();
});
JsFuture::from(pause).await.unwrap();
}
}
#[wasm_bindgen_test(async)]
async fn software_fallback_decodes_vp9_like_libvpx() {
const VP9: &[u8] =
include_bytes!("../crates/zvidlib-vp9-decoder/tests/fixtures/vp9_bbb_256x144.mp4");
let expected: Vec<&str> = include_str!(
"../crates/zvidlib-vp9-decoder/tests/fixtures/vp9_bbb_256x144_rgba.sha256"
)
.lines()
.map(|line| line.split_once(' ').unwrap().1)
.collect();
let mut session = WebVideoDecodeSession::open_with(
VP9,
0,
&Limits::default(),
BackendChoice::SoftwareOnly,
)
.await
.unwrap();
assert!(session.is_software());
assert_eq!(session.frame_count(), 48);
for frame in [0_u64, 1, 30, 3] {
let (dimensions, rgba) = session
.get(FrameIndex(frame), &CancellationToken::new())
.await
.unwrap();
assert_eq!((dimensions.width, dimensions.height), (256, 144));
assert_eq!(
digest(dimensions, rgba),
expected[frame as usize],
"frame {frame}"
);
}
}
#[wasm_bindgen_test(async)]
async fn vp9_tracks_decode_through_webcodecs_or_the_fallback() {
const VP9: &[u8] =
include_bytes!("../crates/zvidlib-vp9-decoder/tests/fixtures/vp9_bbb_256x144.mp4");
let source = MemorySource::new(VP9.to_vec());
let track = parse_video_track(&source, 0, &Limits::default())
.await
.unwrap();
let derived = derive_codec_string(track.codec, &track.decoder_config).unwrap();
assert!(
derived.codec_string.starts_with("vp09.00."),
"{}",
derived.codec_string
);
assert!(
derived.codec_string.ends_with(".08"),
"{}",
derived.codec_string
);
let mut session = WebVideoDecodeSession::open(VP9, 0, &Limits::default())
.await
.unwrap();
for frame in [0_u64, 47, 12] {
let (dimensions, rgba) = session
.get(FrameIndex(frame), &CancellationToken::new())
.await
.unwrap();
assert_eq!((dimensions.width, dimensions.height), (256, 144));
assert_eq!(rgba.len(), 256 * 144 * 4, "frame {frame}");
}
}
#[wasm_bindgen_test(async)]
async fn a_track_the_software_decoder_refuses_stays_unsupported() {
const MAIN_10_AV1: &[u8] =
include_bytes!("../crates/zvidlib-av1-decoder/tests/fixtures/av1_main10_64x64.mp4");
let error = match WebVideoDecodeSession::open_with(
MAIN_10_AV1,
0,
&Limits::default(),
BackendChoice::SoftwareOnly,
)
.await
{
Ok(_) => panic!("the AV1 software decoder covers 8-bit streams only"),
Err(error) => error,
};
assert_eq!(error.kind(), ErrorKind::Unsupported);
assert!(
error.message().contains("software decoder cannot either"),
"{}",
error.message()
);
}
const VP8_ALTREF: &[u8] =
include_bytes!("../crates/zvidlib-vp8/tests/fixtures/vp8/vp8_altref_98x66.webm");
#[wasm_bindgen_test(async)]
async fn hidden_vp8_frames_are_decoded_through_but_not_counted() {
let limits = Limits::default();
let cancellation = CancellationToken::new();
let source = MemorySource::new(VP8_ALTREF.to_vec());
let track = parse_video_track(&source, 0, &limits).await.unwrap();
let samples = track
.to_encoded_video_samples(&source, &limits)
.await
.unwrap();
assert_eq!(samples.len(), 43);
let factory = crate::native_vp8_video_decoder_factory();
let configuration = VideoDecoderConfig {
codec: track.codec,
profile: CodecProfile::Vp8,
coded_dimensions: track.dimensions.unwrap(),
output_format: PixelFormat::Rgba8,
color_range: ColorRange::Limited,
hardware: HardwarePreference::Avoid,
configuration: Vec::new(),
};
let mut decoder = factory.create(&configuration, &limits).unwrap();
let mut native = vec![String::new(); 40];
for sample in &samples {
for output in decoder.submit(sample, &cancellation).unwrap() {
native[output.presentation_index.0 as usize] =
crate::conformance::FrameDigest::from_frame(&output.frame)
.unwrap()
.to_hex();
}
}
assert!(native.iter().all(|digest| !digest.is_empty()));
assert_eq!(
video_frame_durations_ms(VP8_ALTREF, 0, &limits)
.await
.unwrap()
.len(),
40
);
let mut alternating = Vec::new();
let (mut low, mut high) = (0, 40);
while low < high {
high -= 1;
alternating.push(high);
if low < high {
alternating.push(low);
low += 1;
}
}
let orders = [
(0..40).collect::<Vec<u64>>(),
(0..40).rev().collect(),
alternating,
];
let webcodecs = WebVideoDecodeSession::open(VP8_ALTREF, 0, &limits)
.await
.unwrap();
let expected = if webcodecs.is_software() {
native.clone()
} else {
let mut session = webcodecs;
let mut digests = Vec::new();
for index in 0..40 {
let (dimensions, rgba) =
session.get(FrameIndex(index), &cancellation).await.unwrap();
digests.push(digest(dimensions, rgba));
}
digests
};
for (choice, expected) in [
(BackendChoice::SoftwareOnly, &native),
(BackendChoice::Automatic, &expected),
] {
for order in &orders {
let mut session = WebVideoDecodeSession::open_with(VP8_ALTREF, 0, &limits, choice)
.await
.unwrap();
assert_eq!(session.frame_count(), 40);
for &index in order {
let (dimensions, rgba) = session
.get(FrameIndex(index), &cancellation)
.await
.unwrap_or_else(|error| panic!("{choice:?} frame {index}: {error}"));
assert_eq!(
digest(dimensions, rgba),
expected[index as usize],
"{choice:?} frame {index}"
);
}
}
}
}
}