use super::error::FrameExportError;
use super::guard::is_hdr;
use super::options::{ColorPolicy, PixelLayout};
use super::sampler::UniformSpan;
use ffmpeg_sys_next::{
av_find_best_stream, av_rescale_q, AVFormatContext, AVMediaType::AVMEDIA_TYPE_VIDEO,
AV_NOPTS_VALUE, AV_TIME_BASE_Q,
};
use log::warn;
use std::ptr::null_mut;
pub(crate) struct ResolvePlan {
pub(crate) stream_index: Option<usize>,
pub(crate) width: Option<u32>,
pub(crate) height: Option<u32>,
pub(crate) pixel: PixelLayout,
pub(crate) color: ColorPolicy,
pub(crate) input_side_sampling: bool,
pub(crate) uniform: Option<UniformResolve>,
}
pub(crate) struct UniformResolve {
pub(crate) span_cell: UniformSpan,
pub(crate) duration_hint_us: Option<i64>,
pub(crate) duration_us: Option<i64>,
pub(crate) start_time_us: Option<i64>,
}
pub(crate) unsafe fn resolve_and_build_desc(
fmt_ctx: *mut AVFormatContext,
plan: &ResolvePlan,
) -> crate::error::Result<String> {
let stream_index = resolve_stream_index(fmt_ctx, plan.stream_index)?;
if plan.input_side_sampling {
if plan.stream_index.is_none() && count_video_streams(fmt_ctx) > 1 {
return Err(FrameExportError::InvalidOption(
"UniformN/EveryNth/EverySec on an input with multiple video streams requires \
video_stream_index()"
.to_string(),
)
.into());
}
}
if plan.stream_index.is_none() {
let first_video = first_video_stream_index(fmt_ctx);
if first_video != Some(stream_index) {
if matches!(plan.color, ColorPolicy::TaggedOrResolutionGuess) {
return Err(FrameExportError::InvalidOption(format!(
"TaggedOrResolutionGuess requires video_stream_index({stream_index}) on \
this input: the exported stream is not the first video stream, so the \
per-frame color stamp would not reach it"
))
.into());
}
warn!(
"frame export: exported stream {stream_index} is not the first video stream; \
mid-stream HDR splice detection binds to the first video stream and will not \
cover it (the open-time HDR check still applies). Set \
video_stream_index({stream_index}) to pin the runtime guard."
);
}
}
hdr_fast_fail(fmt_ctx, stream_index)?;
if let Some(u) = &plan.uniform {
let span = resolve_span(fmt_ctx, stream_index, u)?;
let _ = u.span_cell.set(span);
}
Ok(build_filter_desc(stream_index, plan))
}
unsafe fn count_video_streams(fmt_ctx: *mut AVFormatContext) -> usize {
let nb = (*fmt_ctx).nb_streams as usize;
(0..nb)
.filter(|&i| {
let stream = *(*fmt_ctx).streams.add(i);
let par = (*stream).codecpar;
!par.is_null() && (*par).codec_type == AVMEDIA_TYPE_VIDEO
})
.count()
}
unsafe fn first_video_stream_index(fmt_ctx: *mut AVFormatContext) -> Option<usize> {
let nb = (*fmt_ctx).nb_streams as usize;
(0..nb).find(|&i| {
let stream = *(*fmt_ctx).streams.add(i);
let par = (*stream).codecpar;
!par.is_null() && (*par).codec_type == AVMEDIA_TYPE_VIDEO
})
}
unsafe fn resolve_span(
fmt_ctx: *mut AVFormatContext,
stream_index: usize,
u: &UniformResolve,
) -> crate::error::Result<i64> {
if let Some(d) = u.duration_us {
if d > 0 {
return Ok(d);
}
}
if let Some(h) = u.duration_hint_us {
if h > 0 {
return Ok(h);
}
}
let start = u.start_time_us.unwrap_or(0).max(0);
let mut probed: Option<i64> = None;
let stream = *(*fmt_ctx).streams.add(stream_index);
let sdur = (*stream).duration;
if sdur != AV_NOPTS_VALUE && sdur > 0 {
let us = av_rescale_q(sdur, (*stream).time_base, AV_TIME_BASE_Q);
if us > 0 {
probed = Some(us);
}
}
if probed.is_none() {
let cdur = (*fmt_ctx).duration;
if cdur != AV_NOPTS_VALUE && cdur > 0 {
probed = Some(cdur);
}
}
match probed {
Some(dur) => {
let span = dur.saturating_sub(start);
if span > 0 {
Ok(span)
} else {
Err(FrameExportError::InvalidOption(format!(
"start_time_us ({start}) is at or beyond the input duration ({dur})"
))
.into())
}
}
None => Err(FrameExportError::UnknownDuration.into()),
}
}
unsafe fn resolve_stream_index(
fmt_ctx: *mut AVFormatContext,
explicit: Option<usize>,
) -> crate::error::Result<usize> {
let nb_streams = (*fmt_ctx).nb_streams as usize;
if let Some(index) = explicit {
if index >= nb_streams {
return Err(FrameExportError::StreamIndexOutOfBounds {
index,
count: nb_streams,
}
.into());
}
let stream = *(*fmt_ctx).streams.add(index);
let codecpar = (*stream).codecpar;
if codecpar.is_null() || (*codecpar).codec_type != AVMEDIA_TYPE_VIDEO {
return Err(FrameExportError::NotAVideoStream { index }.into());
}
Ok(index)
} else {
let ret = av_find_best_stream(fmt_ctx, AVMEDIA_TYPE_VIDEO, -1, -1, null_mut(), 0);
if ret < 0 {
return Err(FrameExportError::NoVideoStream.into());
}
Ok(ret as usize)
}
}
unsafe fn hdr_fast_fail(
fmt_ctx: *mut AVFormatContext,
stream_index: usize,
) -> crate::error::Result<()> {
let stream = *(*fmt_ctx).streams.add(stream_index);
let codecpar = (*stream).codecpar;
if codecpar.is_null() {
return Ok(());
}
if is_hdr(
(*codecpar).color_space,
(*codecpar).color_trc,
(*codecpar).color_primaries,
) {
return Err(FrameExportError::HdrRequiresToneMapping.into());
}
Ok(())
}
fn build_filter_desc(stream_index: usize, plan: &ResolvePlan) -> String {
let size = match (plan.width, plan.height) {
(Some(w), Some(h)) => format!("{w}:{h}"),
(Some(w), None) => format!("{w}:-2"),
(None, Some(h)) => format!("-2:{h}"),
(None, None) => "iw:ih".to_string(),
};
let (matrix, range) = match plan.color {
ColorPolicy::Tagged | ColorPolicy::TaggedOrResolutionGuess => {
("auto".to_string(), "auto".to_string())
}
ColorPolicy::Force { matrix, range } => (
matrix.in_color_matrix().to_string(),
range.in_range().to_string(),
),
};
format!(
"[0:{stream_index}]scale={size}:flags=bicubic+accurate_rnd+full_chroma_int:\
in_color_matrix={matrix}:in_range={range}:out_range=full,format={pix}[export]",
pix = plan.pixel.ffmpeg_format_name()
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::frame_export::options::{YuvMatrix, YuvRange};
fn plan(
width: Option<u32>,
height: Option<u32>,
pixel: PixelLayout,
color: ColorPolicy,
) -> ResolvePlan {
ResolvePlan {
stream_index: None,
width,
height,
pixel,
color,
input_side_sampling: false,
uniform: None,
}
}
#[test]
fn tagged_rgb_no_resize() {
let d = build_filter_desc(
0,
&plan(None, None, PixelLayout::Rgb24, ColorPolicy::Tagged),
);
assert_eq!(
d,
"[0:0]scale=iw:ih:flags=bicubic+accurate_rnd+full_chroma_int:\
in_color_matrix=auto:in_range=auto:out_range=full,format=rgb24[export]"
);
}
#[test]
fn force_709_full_resized_width_only() {
let color = ColorPolicy::Force {
matrix: YuvMatrix::Bt709,
range: YuvRange::Full,
};
let d = build_filter_desc(3, &plan(Some(336), None, PixelLayout::Rgb24, color));
assert!(d.starts_with("[0:3]scale=336:-2:"), "{d}");
assert!(
d.contains("in_color_matrix=bt709:in_range=pc:out_range=full"),
"{d}"
);
assert!(d.ends_with("format=rgb24[export]"), "{d}");
}
#[test]
fn gray_and_rgba_pixel_names() {
let g = build_filter_desc(
0,
&plan(None, None, PixelLayout::Gray8, ColorPolicy::Tagged),
);
assert!(g.ends_with("format=gray[export]"), "{g}");
let a = build_filter_desc(
0,
&plan(None, None, PixelLayout::Rgba32, ColorPolicy::Tagged),
);
assert!(a.ends_with("format=rgba[export]"), "{a}");
}
#[test]
fn height_only_resize() {
let d = build_filter_desc(
1,
&plan(None, Some(224), PixelLayout::Rgb24, ColorPolicy::Tagged),
);
assert!(d.starts_with("[0:1]scale=-2:224:"), "{d}");
}
#[test]
fn resolution_guess_uses_auto_graph_inputs() {
let tagged = build_filter_desc(
0,
&plan(None, None, PixelLayout::Rgb24, ColorPolicy::Tagged),
);
let guessed = build_filter_desc(
0,
&plan(
None,
None,
PixelLayout::Rgb24,
ColorPolicy::TaggedOrResolutionGuess,
),
);
assert_eq!(tagged, guessed);
assert!(
guessed.contains("in_color_matrix=auto:in_range=auto"),
"{guessed}"
);
}
}