use super::*;
use ffmpeg_next::ffi::AVColorRange;
fn write_every_plane(frame: &mut ffmpeg_next::Frame) {
unsafe {
for buf in (*frame.as_mut_ptr()).buf {
if !buf.is_null() {
core::ptr::write_bytes((*buf).data, 0, (*buf).size);
}
}
}
}
#[test]
fn map_range_for_forces_full_for_every_yuvj_variant() {
let yuvj = [
PixelFormat::Yuvj411p,
PixelFormat::Yuvj420p,
PixelFormat::Yuvj422p,
PixelFormat::Yuvj440p,
PixelFormat::Yuvj444p,
];
let unspecified = AVColorRange::AVCOL_RANGE_UNSPECIFIED as i32;
let mpeg = AVColorRange::AVCOL_RANGE_MPEG as i32;
let jpeg = AVColorRange::AVCOL_RANGE_JPEG as i32;
for pf in &yuvj {
assert_eq!(
map_range_for(pf, unspecified),
ColorRange::Full,
"{pf:?} with UNSPECIFIED color_range must deliver Full"
);
assert_eq!(
map_range_for(pf, mpeg),
ColorRange::Full,
"{pf:?} is full-range by definition even if color_range says MPEG"
);
assert_eq!(map_range_for(pf, jpeg), ColorRange::Full);
}
}
#[test]
fn map_range_for_defers_for_non_yuvj() {
let unspecified = AVColorRange::AVCOL_RANGE_UNSPECIFIED as i32;
let jpeg = AVColorRange::AVCOL_RANGE_JPEG as i32;
let mpeg = AVColorRange::AVCOL_RANGE_MPEG as i32;
assert_eq!(
map_range_for(&PixelFormat::Yuv420p, unspecified),
ColorRange::Unspecified
);
assert_eq!(map_range_for(&PixelFormat::Yuv420p, jpeg), ColorRange::Full);
assert_eq!(
map_range_for(&PixelFormat::Yuv420p, mpeg),
ColorRange::Limited
);
assert_eq!(
map_range_for(&PixelFormat::Rgb24, unspecified),
ColorRange::Unspecified
);
assert_eq!(
map_range_for(&PixelFormat::Rgba, unspecified),
ColorRange::Unspecified
);
}
#[test]
fn is_yuvj_covers_exactly_the_five_variants() {
for pf in &[
PixelFormat::Yuvj411p,
PixelFormat::Yuvj420p,
PixelFormat::Yuvj422p,
PixelFormat::Yuvj440p,
PixelFormat::Yuvj444p,
] {
assert!(is_yuvj(pf), "{pf:?} should be recognized as yuvj");
}
for pf in &[
PixelFormat::Yuv411p,
PixelFormat::Yuv420p,
PixelFormat::Yuv422p,
PixelFormat::Yuv440p,
PixelFormat::Yuv444p,
PixelFormat::Nv12,
PixelFormat::Rgb24,
PixelFormat::Gray8,
] {
assert!(!is_yuvj(pf), "{pf:?} must not be classified yuvj");
}
}
#[test]
fn yuvj420p_unspecified_range_delivers_full() {
let mut frame = ffmpeg_next::frame::Video::new(ffmpeg_next::format::Pixel::YUVJ420P, 64, 48);
write_every_plane(&mut frame);
unsafe {
(*frame.as_mut_ptr()).color_range = AVColorRange::AVCOL_RANGE_UNSPECIFIED;
}
let out = unsafe { video_frame_from(&frame, Timebase::default(), FrameLimits::default()) }
.expect("YUVJ420P frame should convert to a VideoFrame");
assert_eq!(*out.pixel_format(), PixelFormat::Yuvj420p);
assert_eq!(
out.color().range(),
ColorRange::Full,
"YUVJ420P with UNSPECIFIED color_range must be delivered as Full"
);
}
fn frame_with_raw_format(raw: i32) -> ffmpeg_next::frame::Video {
let mut frame = ffmpeg_next::frame::Video::empty();
unsafe {
let frame_ptr = frame.as_mut_ptr();
(*frame_ptr).format = raw;
(*frame_ptr).width = 64;
(*frame_ptr).height = 48;
}
frame
}
#[test]
fn an_unsupported_format_is_named_in_the_error() {
use ffmpeg_next::ffi::AVPixelFormat;
let raw = AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX as i32;
let frame = frame_with_raw_format(raw);
let Err(err) = (unsafe { video_frame_from(&frame, Timebase::default(), FrameLimits::default()) })
else {
panic!("a hardware surface carries no deliverable CPU layout");
};
let ConvertError::UnsupportedPixelFormat(p) = &err else {
panic!("expected UnsupportedPixelFormat, got {err:?}");
};
assert!(p.name().is_none() || p.raw() != 0);
assert_eq!(p.raw(), raw);
assert_eq!(p.name(), Some("videotoolbox_vld"));
let rendered = err.to_string();
assert!(
rendered.contains(&raw.to_string()),
"the raw id is missing from {rendered:?}"
);
assert!(
rendered.contains("videotoolbox_vld"),
"the FFmpeg name is missing from {rendered:?}"
);
}
#[test]
fn an_unnameable_format_still_reports_its_raw_id() {
let raw = 99_999;
let frame = frame_with_raw_format(raw);
let Err(err) = (unsafe { video_frame_from(&frame, Timebase::default(), FrameLimits::default()) })
else {
panic!("an unmappable format integer has no deliverable layout");
};
let ConvertError::UnsupportedPixelFormat(p) = &err else {
panic!("expected UnsupportedPixelFormat, got {err:?}");
};
assert!(p.name().is_none() || p.raw() != 0);
assert_eq!(p.raw(), raw);
assert_eq!(p.name(), None);
let rendered = err.to_string();
assert!(
rendered.contains("99999"),
"the raw id is missing from {rendered:?}"
);
assert!(
rendered.contains("unnamed by libavutil"),
"the absent-name case is not spelled out in {rendered:?}"
);
}
#[allow(clippy::too_many_arguments)]
fn mastering_display_bytes(
r_x: (i32, i32),
r_y: (i32, i32),
g_x: (i32, i32),
g_y: (i32, i32),
b_x: (i32, i32),
b_y: (i32, i32),
wp_x: (i32, i32),
wp_y: (i32, i32),
min_luminance: (i32, i32),
max_luminance: (i32, i32),
has_primaries: i32,
has_luminance: i32,
) -> Vec<u8> {
let mut out = Vec::with_capacity(MASTERING_DISPLAY_METADATA_BYTES);
for (num, den) in [
r_x,
r_y,
g_x,
g_y,
b_x,
b_y,
wp_x,
wp_y,
min_luminance,
max_luminance,
] {
out.extend_from_slice(&num.to_ne_bytes());
out.extend_from_slice(&den.to_ne_bytes());
}
out.extend_from_slice(&has_primaries.to_ne_bytes());
out.extend_from_slice(&has_luminance.to_ne_bytes());
out
}
const ZERO_RATIONAL: (i32, i32) = (0, 1);
#[test]
fn parse_mastering_display_reads_a_real_hdr10_payload() {
let bytes = mastering_display_bytes(
(34_000, 50_000), (16_000, 50_000), (13_250, 50_000), (34_500, 50_000), (7_500, 50_000), (3_000, 50_000), (15_635, 50_000), (16_450, 50_000), (1, 10_000), (10_000_000, 10_000), 1,
1,
);
let md = parse_mastering_display(&bytes).expect("a full 88-byte payload parses");
assert_eq!(
md.display_primaries(),
[(34_000, 16_000), (13_250, 34_500), (7_500, 3_000)],
"R, G, B chromaticities in ST 2086 fixed-point units"
);
assert_eq!(md.white_point(), (15_635, 16_450));
assert_eq!(md.min_luminance(), (1, 10_000));
assert_eq!(md.max_luminance(), (10_000_000, 10_000));
}
#[test]
fn parse_mastering_display_rescales_a_non_standard_chroma_denominator() {
let bytes = mastering_display_bytes(
(17, 200), ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
1,
1,
);
let md = parse_mastering_display(&bytes).expect("parses");
assert_eq!(md.display_primaries()[0].0, 4_250, "17/200 == 4250/50000");
}
#[test]
fn parse_mastering_display_refuses_a_short_payload() {
let bytes = mastering_display_bytes(
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
1,
1,
);
assert_eq!(bytes.len(), MASTERING_DISPLAY_METADATA_BYTES);
assert!(parse_mastering_display(&bytes[..bytes.len() - 1]).is_none());
assert!(parse_mastering_display(&[]).is_none());
}
#[test]
fn parse_mastering_display_refuses_a_negative_component() {
let negative_primary = mastering_display_bytes(
(-1, 50_000), ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
1,
1,
);
assert_eq!(negative_primary.len(), MASTERING_DISPLAY_METADATA_BYTES);
assert!(parse_mastering_display(&negative_primary).is_none());
let negative_luminance = mastering_display_bytes(
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
(-1, 10_000), ZERO_RATIONAL,
1,
1,
);
assert_eq!(negative_luminance.len(), MASTERING_DISPLAY_METADATA_BYTES);
assert!(parse_mastering_display(&negative_luminance).is_none());
}
#[test]
fn parse_mastering_display_refuses_the_flags_unset_default_record() {
let bytes = mastering_display_bytes(
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
0, 0, );
assert_eq!(bytes.len(), MASTERING_DISPLAY_METADATA_BYTES);
assert!(
parse_mastering_display(&bytes).is_none(),
"an unset-flags record must not be reported as real HDR metadata"
);
}
#[test]
fn parse_mastering_display_refuses_every_partial_flag_combination() {
let real = (
(34_000, 50_000),
(16_000, 50_000),
(13_250, 50_000),
(34_500, 50_000),
(7_500, 50_000),
(3_000, 50_000),
(15_635, 50_000),
(16_450, 50_000),
(1, 10_000),
(10_000_000, 10_000),
);
for (has_primaries, has_luminance) in [(1, 0), (0, 1)] {
let bytes = mastering_display_bytes(
real.0,
real.1,
real.2,
real.3,
real.4,
real.5,
real.6,
real.7,
real.8,
real.9,
has_primaries,
has_luminance,
);
assert!(
parse_mastering_display(&bytes).is_none(),
"has_primaries={has_primaries} has_luminance={has_luminance} must refuse, \
real numbers behind an unset flag notwithstanding"
);
}
let bytes = mastering_display_bytes(
real.0, real.1, real.2, real.3, real.4, real.5, real.6, real.7, real.8, real.9, 1, 1,
);
assert!(parse_mastering_display(&bytes).is_some());
}
#[test]
fn parse_mastering_display_refuses_a_non_positive_luminance_denominator() {
let zero_min_den = mastering_display_bytes(
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
(1, 0), ZERO_RATIONAL,
1,
1,
);
assert!(parse_mastering_display(&zero_min_den).is_none());
let negative_max_den = mastering_display_bytes(
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
ZERO_RATIONAL,
(1, -1), 1,
1,
);
assert!(parse_mastering_display(&negative_max_den).is_none());
}
#[test]
fn parse_content_light_level_reads_max_cll_and_max_fall() {
let mut bytes = Vec::with_capacity(CONTENT_LIGHT_METADATA_BYTES);
bytes.extend_from_slice(&1000u32.to_ne_bytes());
bytes.extend_from_slice(&400u32.to_ne_bytes());
let cll = parse_content_light_level(&bytes).expect("an 8-byte payload parses");
assert_eq!(cll.max_cll(), 1000);
assert_eq!(cll.max_fall(), 400);
}
#[test]
fn parse_content_light_level_refuses_a_short_payload() {
assert!(parse_content_light_level(&[0u8; 7]).is_none());
assert!(parse_content_light_level(&[]).is_none());
}
#[test]
fn absent_side_data_answers_none_for_both_hdr_seats() {
let side_data: Vec<SideDataEntry> = Vec::new();
assert!(find_mastering_display(&side_data).is_none());
assert!(find_content_light_level(&side_data).is_none());
}
#[test]
fn hdr_seats_are_found_by_kind_among_unrelated_side_data() {
let cll_bytes = {
let mut b = Vec::with_capacity(CONTENT_LIGHT_METADATA_BYTES);
b.extend_from_slice(&500u32.to_ne_bytes());
b.extend_from_slice(&100u32.to_ne_bytes());
b
};
let side_data = vec![
SideDataEntry::new(
0xDEAD_BEEFu32 as i32,
FfmpegBytes::copy_from_slice(&[1, 2, 3]),
),
SideDataEntry::new(
AVFrameSideDataType::AV_FRAME_DATA_CONTENT_LIGHT_LEVEL as i32,
FfmpegBytes::copy_from_slice(&cll_bytes),
),
];
assert!(find_mastering_display(&side_data).is_none());
let cll = find_content_light_level(&side_data).expect("the CLL entry is present");
assert_eq!(cll.max_cll(), 500);
assert_eq!(cll.max_fall(), 100);
}
#[test]
fn a_dangling_custom_map_is_refused_by_the_borrowed_conversion_unread() {
use ffmpeg_next::ffi::{AVChannelCustom, AVChannelOrder};
let mut frame = ffmpeg_next::frame::Audio::empty();
let raw = unsafe { frame.as_mut_ptr() };
unsafe {
(*raw).ch_layout.order = AVChannelOrder::AV_CHANNEL_ORDER_CUSTOM;
(*raw).ch_layout.nb_channels = 1;
(*raw).ch_layout.u.map = core::ptr::NonNull::<AVChannelCustom>::dangling().as_ptr();
}
let before = worker_probe::entries();
worker_probe::arm(true);
let result = unsafe { audio_frame_from(&frame, Timebase::default(), FrameLimits::default()) };
worker_probe::arm(false);
let entered = worker_probe::entries() - before;
unsafe {
(*raw).ch_layout.order = AVChannelOrder::AV_CHANNEL_ORDER_UNSPEC;
(*raw).ch_layout.nb_channels = 0;
(*raw).ch_layout.u.mask = 0;
}
assert_eq!(entered, 0, "the borrowed road must not enter the worker");
assert!(
matches!(result, Err(ConvertError::UnverifiableChannelMap(p)) if p.channels() == 1),
"a custom map is refused by name, got {:?}",
result.as_ref().err(),
);
}
#[test]
fn a_null_backed_audio_frame_is_refused_before_any_read() {
let frame = unsafe { ffmpeg_next::frame::Audio::wrap(core::ptr::null_mut()) };
assert!(matches!(
unsafe { audio_frame_from(&frame, Timebase::default(), FrameLimits::default()) },
Err(ConvertError::NullFrame)
));
}
#[test]
fn a_custom_layout_with_an_impossible_count_is_refused_for_the_count() {
use ffmpeg_next::ffi::{AVChannelCustom, AVChannelOrder};
let mut frame = ffmpeg_next::frame::Audio::empty();
let raw = unsafe { frame.as_mut_ptr() };
let refuse = |channels: i32, samples: i32| {
unsafe {
(*raw).nb_samples = samples;
(*raw).ch_layout.order = AVChannelOrder::AV_CHANNEL_ORDER_CUSTOM;
(*raw).ch_layout.nb_channels = channels;
(*raw).ch_layout.u.map = core::ptr::NonNull::<AVChannelCustom>::dangling().as_ptr();
}
let before = worker_probe::entries();
worker_probe::arm(true);
let result = unsafe { audio_frame_from(&frame, Timebase::default(), FrameLimits::default()) };
worker_probe::arm(false);
assert_eq!(
worker_probe::entries() - before,
0,
"the borrowed road must not enter the worker"
);
unsafe {
(*raw).nb_samples = 0;
(*raw).ch_layout.order = AVChannelOrder::AV_CHANNEL_ORDER_UNSPEC;
(*raw).ch_layout.nb_channels = 0;
(*raw).ch_layout.u.mask = 0;
}
result.err()
};
for (channels, samples) in [(-1, 0), (256, 0), (i32::MAX, 0), (0, 64)] {
assert!(
matches!(
refuse(channels, samples),
Some(ConvertError::UnsupportedChannelCount(p)) if p.channels() == channels
),
"{channels} channels under {samples} samples is refused for the count",
);
}
assert!(matches!(
refuse(2, -1),
Some(ConvertError::InvalidSampleCount(p)) if p.count() == -1
));
}
#[test]
fn a_libavcodec_owned_custom_layout_converts_through_the_unsafe_entry() {
use ffmpeg_next::{
ChannelLayout,
ffi::{AVChannel, av_channel_layout_custom_init, av_channel_layout_uninit},
format::{Sample, sample::Type},
};
let mut frame =
ffmpeg_next::frame::Audio::new(Sample::F32(Type::Planar), 64, ChannelLayout::STEREO);
write_every_plane(&mut frame);
let raw = unsafe { frame.as_mut_ptr() };
unsafe {
av_channel_layout_uninit(&raw mut (*raw).ch_layout);
assert_eq!(
av_channel_layout_custom_init(&raw mut (*raw).ch_layout, 2),
0
);
(*(*raw).ch_layout.u.map).id = AVChannel::AV_CHAN_FRONT_LEFT;
(*(*raw).ch_layout.u.map.add(1)).id = AVChannel::AV_CHAN_FRONT_RIGHT;
}
assert!(matches!(
unsafe { audio_frame_from(&frame, Timebase::default(), FrameLimits::default()) },
Err(ConvertError::UnverifiableChannelMap(p)) if p.channels() == 2
));
let converted = unsafe {
av_frame_to_audio_frame_as::<crate::Owned>(
frame.as_ptr(),
Timebase::default(),
FrameLimits::default(),
)
}
.expect("a custom map FFmpeg sized converts");
assert_eq!(converted.channel_count(), 2);
assert_eq!(converted.channel_layout().channels(), 2);
}
#[test]
fn a_bitmap_rect_is_refused_by_the_borrowed_subtitle_conversion_unread() {
use ffmpeg_next::subtitle::{RectMut, Type};
let mut subtitle = ffmpeg_next::Subtitle::new();
if let RectMut::Text(mut text) = subtitle.add_rect(Type::Text) {
text.set("before the bitmap");
}
let RectMut::Bitmap(mut bitmap) = subtitle.add_rect(Type::Bitmap) else {
panic!("a bitmap rect was asked for");
};
unsafe {
let rect = bitmap.as_mut_ptr();
let plane = ffmpeg_next::ffi::av_malloc(4).cast::<u8>();
assert!(!plane.is_null());
core::ptr::write_bytes(plane, 0, 4);
(*rect).data[0] = plane;
(*rect).linesize[0] = 2;
(*rect).w = 2;
(*rect).h = 2;
}
bitmap.set_height(1_000_000);
let before = worker_probe::entries();
worker_probe::arm(true);
let result = unsafe { subtitle_frame_from(&subtitle) };
worker_probe::arm(false);
assert_eq!(
worker_probe::entries() - before,
0,
"the borrowed road must not enter the worker"
);
assert!(
matches!(result, Err(ConvertError::UnverifiableBitmap(p)) if p.rect() == 1),
"a bitmap rect is refused by name, got {:?}",
result.as_ref().err(),
);
}
#[test]
fn a_text_subtitle_converts_on_the_borrowed_road() {
use ffmpeg_next::subtitle::{RectMut, Type};
let mut subtitle = ffmpeg_next::Subtitle::new();
if let RectMut::Text(mut text) = subtitle.add_rect(Type::Text) {
text.set("hello");
}
let frame = unsafe { subtitle_frame_from(&subtitle) }.expect("a text subtitle converts");
match frame.payload() {
mediaspine::subtitle::SubtitlePayload::Text(text) => {
assert_eq!(text.text().as_ref(), b"hello");
}
_ => panic!("a text payload was expected"),
}
}
fn subtitle_text<B: AsRef<[u8]>>(frame: &SubtitleFrame<SubtitleFrameExtra, B>) -> Vec<u8> {
match frame.payload() {
mediaspine::subtitle::SubtitlePayload::Text(text) => {
let bytes: &[u8] = text.text().as_ref();
bytes.to_vec()
}
_ => panic!("a text payload was expected"),
}
}
#[test]
fn a_text_rect_converts_whatever_its_bitmap_fields_hold() {
use ffmpeg_next::subtitle::{RectMut, Type};
let mut subtitle = ffmpeg_next::Subtitle::new();
let mut rects = Vec::new();
if let RectMut::Text(mut text) = subtitle.add_rect(Type::Text) {
text.set("the text");
rects.push(unsafe { text.as_mut_ptr() });
}
if let RectMut::Ass(mut ass) = subtitle.add_rect(Type::Ass) {
ass.set("the ass");
rects.push(unsafe { ass.as_mut_ptr() });
}
assert_eq!(rects.len(), 2, "a text rect and an ASS rect were asked for");
let nowhere = core::ptr::NonNull::<u8>::dangling().as_ptr();
for &rect in &rects {
unsafe {
(*rect).data[0] = nowhere;
(*rect).data[1] = nowhere;
(*rect).linesize[0] = i32::MAX;
(*rect).w = -7;
(*rect).h = i32::MAX;
(*rect).x = i32::MIN;
(*rect).y = -1;
}
}
let borrowed = unsafe { subtitle_frame_from(&subtitle) };
let viewed = unsafe { av_subtitle_to_subtitle_frame(subtitle.as_ptr()) };
let owned = unsafe { av_subtitle_to_owned_subtitle_frame(subtitle.as_ptr()) };
for &rect in &rects {
unsafe {
(*rect).data[0] = core::ptr::null_mut();
(*rect).data[1] = core::ptr::null_mut();
}
}
let expected: &[u8] = b"the text\nthe ass";
assert_eq!(
subtitle_text(&borrowed.expect("the borrowed road converts text rects")),
expected
);
assert_eq!(
subtitle_text(&viewed.expect("the view lane converts text rects")),
expected
);
assert_eq!(
subtitle_text(&owned.expect("the owned lane converts text rects")),
expected
);
}