Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
336 changes: 335 additions & 1 deletion crates/ffpipeline/src/accel/qsv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::output_settings::VideoFilterOptions;
use crate::pipeline::{FrameState, FrameSurface, PixelFormat, SurfaceSet, VideoFormat};
use crate::probe::ProbeResultVideoStream;
use crate::video_codec::VideoCodec;
use crate::video_filter::{DeinterlaceFilter, ScaleFilter, VideoFilter, VideoFilterOp};
use crate::video_filter::{DeinterlaceFilter, PadFilter, ScaleFilter, VideoFilter, VideoFilterOp};

#[derive(Debug, Clone, Serialize)]
pub struct Qsv {
Expand Down Expand Up @@ -42,6 +42,18 @@ impl HwAccel for Qsv {
}
.into()
}
// vpp_qsv only supports padding in patched/newer ffmpeg builds, so
// gate on the pad_w option rather than filter presence; otherwise
// fall through to the software pad (hwdownload round-trip).
VideoFilter::Pad(PadFilter { size, .. })
if ffmpeg_info.video_filter_has_option(&KnownVideoFilter::VppQsv, "pad_w") =>
{
PadQsv {
size: *size,
scale: None,
}
.into()
}
_ => video_filter.clone(),
}
}
Expand Down Expand Up @@ -185,6 +197,65 @@ impl VideoFilterOp for ScaleQsv {
}
}

#[derive(Debug, Clone)]
pub struct PadQsv {
pub(crate) size: Option<FrameSize>,
/// When a `ScaleQsv` immediately precedes this pad in the resolved chain,
/// the optimize pass folds the scale into this field so we emit a single
/// combined `vpp_qsv` instance (scale + pad) rather than two chained ones.
/// Chaining two `vpp_qsv` scales drops the final frame at EOF on real
/// hardware (measured on Intel B50); a single instance delivers every frame
/// and uses one VPP session. `None` means pad-only (no fused scale).
pub(crate) scale: Option<ScaleQsv>,
}

impl VideoFilterOp for PadQsv {
fn evaluate(&self, _state: &FrameState, _ffmpeg_info: &FfmpegInfo) -> Option<VideoFilter> {
None
}

fn apply_to(&self, state: &mut FrameState) {
if let Some(size) = &self.size {
state.size = *size;
state.surface = FrameSurface::Qsv;
}
}

fn required_surface(&self) -> Option<FrameSurface> {
Some(FrameSurface::Qsv)
}

fn as_arg(&self) -> Option<String> {
let pad = self.size.as_ref()?;

// Fused scale + pad: emit a single vpp_qsv carrying both the scale
// (w/h) and pad (pad_*) options. Keep the trailing setsar=1 that the
// standalone ScaleQsv would have emitted; setsar is a metadata-only
// filter, not a second VPP session, so it doesn't reintroduce the
// EOF-frame-drop quirk.
if let Some(scale) = &self.scale
&& let Some(size) = &scale.size
{
let combined = format!(
"vpp_qsv=w={}:h={}:pad_w={}:pad_h={}:pad_x=-1:pad_y=-1:pad_color=black",
size.width, size.height, pad.width, pad.height
);

return Some(if scale.input_is_anamorphic {
format!("vpp_qsv=w=iw*sar:h=ih,{combined},setsar=1")
} else {
format!("{combined},setsar=1")
});
}

// Pad-only (no fused scale): unchanged single-instance emission.
Some(format!(
"vpp_qsv=pad_w={}:pad_h={}:pad_x=-1:pad_y=-1:pad_color=black",
pad.width, pad.height
))
}
}

#[derive(Debug, Clone)]
pub struct FormatQsv {
pub(crate) format: PixelFormat,
Expand Down Expand Up @@ -233,3 +304,266 @@ impl VideoFilterOp for DeinterlaceQsv {
Some(format!("deinterlace_qsv=mode={mode}"))
}
}

#[cfg(test)]
mod tests {
use std::collections::{HashMap, HashSet};

use super::*;
use crate::output_settings::{ScalingMode, VideoFilterOptions};
use crate::video_filter::PadFilter;

fn make_qsv() -> Qsv {
Qsv {
capabilities: QsvCapabilities {
supported_decoders: HashMap::new(),
supported_encoders: HashMap::new(),
vpp_pixel_formats: HashSet::new(),
},
}
}

fn make_ffmpeg_info(with_pad_option: bool) -> FfmpegInfo {
let mut video_filters = HashSet::new();
video_filters.insert(KnownVideoFilter::VppQsv.to_string());

let mut video_filter_options = HashMap::new();
let mut options = HashSet::from([String::from("deinterlace"), String::from("denoise")]);
if with_pad_option {
options.extend([
String::from("pad_w"),
String::from("pad_h"),
String::from("pad_x"),
String::from("pad_y"),
String::from("pad_color"),
]);
}
video_filter_options.insert(KnownVideoFilter::VppQsv.to_string(), options);

FfmpegInfo {
hwaccels: HashSet::new(),
video_filters,
preferred_filters: HashMap::new(),
video_filter_options,
}
}

fn make_frame_state() -> FrameState {
FrameState {
size: FrameSize {
width: 1440,
height: 1080,
},
is_anamorphic: false,
is_interlaced: false,
sample_aspect_ratio: None,
display_aspect_ratio: None,
surface: FrameSurface::Qsv,
pixel_format: PixelFormat::Nv12,
is_hdr: false,
}
}

fn pad_1920x1080() -> VideoFilter {
VideoFilter::Pad(PadFilter {
size: Some(FrameSize {
width: 1920,
height: 1080,
}),
scaling_mode: ScalingMode::ScaleAndPad,
})
}

#[test]
fn best_filter_selects_pad_qsv_when_vpp_qsv_has_pad_option() {
let qsv = make_qsv();
let ffmpeg_info = make_ffmpeg_info(true);
let state = make_frame_state();
let filter_options = VideoFilterOptions::default();

let result = qsv.best_filter(&pad_1920x1080(), &ffmpeg_info, &state, &filter_options);

match result {
VideoFilter::PadQsv(PadQsv {
size: Some(size),
scale: None,
}) => {
assert_eq!(size.width, 1920);
assert_eq!(size.height, 1080);
}
other => panic!("expected PadQsv, got {other:?}"),
}
}

#[test]
fn best_filter_falls_back_to_software_pad_without_pad_option() {
let qsv = make_qsv();
let ffmpeg_info = make_ffmpeg_info(false);
let state = make_frame_state();
let filter_options = VideoFilterOptions::default();

let result = qsv.best_filter(&pad_1920x1080(), &ffmpeg_info, &state, &filter_options);

assert!(
matches!(result, VideoFilter::Pad(_)),
"expected software Pad fallback, got {result:?}"
);
}

#[test]
fn pad_qsv_arg_and_state() {
// pad-only (no fused scale): unchanged single-instance emission
let pad = PadQsv {
size: Some(FrameSize {
width: 1920,
height: 1080,
}),
scale: None,
};

assert_eq!(
pad.as_arg().as_deref(),
Some("vpp_qsv=pad_w=1920:pad_h=1080:pad_x=-1:pad_y=-1:pad_color=black")
);

let mut state = make_frame_state();
pad.apply_to(&mut state);
assert_eq!(state.size.width, 1920);
assert_eq!(state.size.height, 1080);
assert_eq!(state.surface, FrameSurface::Qsv);
}

#[test]
fn pad_qsv_fused_scale_emits_single_instance() {
// scale 1440x1080 + pad 1920x1080 must collapse to ONE vpp_qsv
let pad = PadQsv {
size: Some(FrameSize {
width: 1920,
height: 1080,
}),
scale: Some(ScaleQsv {
size: Some(FrameSize {
width: 1440,
height: 1080,
}),
input_is_anamorphic: false,
}),
};

let arg = pad.as_arg().expect("fused pad should emit an arg");
assert_eq!(
arg,
"vpp_qsv=w=1440:h=1080:pad_w=1920:pad_h=1080:pad_x=-1:pad_y=-1:pad_color=black,setsar=1"
);
// exactly one vpp_qsv instance
assert_eq!(
arg.matches("vpp_qsv").count(),
1,
"expected a single vpp_qsv: {arg}"
);
}

#[test]
fn pad_qsv_fused_anamorphic_scale_prescales_then_combines() {
// anamorphic input keeps the sar pre-scale as its own vpp_qsv, but the
// pad still fuses into the second (square-pixel) scale instance.
let pad = PadQsv {
size: Some(FrameSize {
width: 1920,
height: 1080,
}),
scale: Some(ScaleQsv {
size: Some(FrameSize {
width: 1440,
height: 1080,
}),
input_is_anamorphic: true,
}),
};

assert_eq!(
pad.as_arg().as_deref(),
Some(
"vpp_qsv=w=iw*sar:h=ih,vpp_qsv=w=1440:h=1080:pad_w=1920:pad_h=1080:pad_x=-1:pad_y=-1:pad_color=black,setsar=1"
)
);
}

#[test]
fn scale_qsv_arg_unchanged() {
// scale-only emission must be byte-identical to before the pad-fusion work
let scale = ScaleQsv {
size: Some(FrameSize {
width: 1440,
height: 1080,
}),
input_is_anamorphic: false,
};
assert_eq!(
scale.as_arg().as_deref(),
Some("vpp_qsv=w=1440:h=1080,setsar=1")
);
}

#[test]
fn optimize_fuses_scale_then_pad_into_single_vpp_qsv() {
use crate::filter_chain::{FilterChain, PipelineFilter};
use crate::hw_accel::HardwareAccel;

let accel = HardwareAccel::Qsv(make_qsv());
let ffmpeg_info = make_ffmpeg_info(true);
let filter_options = VideoFilterOptions::default();

// source is 1920x1080 anamorphic-free but at 4:3 content that needs
// letterboxing to 1920x1080; drive it through Scale + Pad video filters.
let initial_state = FrameState {
size: FrameSize {
width: 1440,
height: 1080,
},
..make_frame_state()
};

let scale: VideoFilter = ScaleFilter {
size: Some(FrameSize {
width: 1440,
height: 1080,
}),
scaling_mode: ScalingMode::ScaleAndPad,
input_is_anamorphic: false,
force_original_aspect_ratio: None,
}
.into();

let mut chain = FilterChain::new(vec![
PipelineFilter::Video(scale),
PipelineFilter::Video(pad_1920x1080()),
]);

chain.resolve(
&ffmpeg_info,
&Some(accel),
&filter_options,
&initial_state,
&FrameSurface::Qsv,
&Some(PixelFormat::Nv12),
);
chain.optimize();
chain.build("0:a", "0:v", None, None);

let args = chain.as_arg();
let filter_complex = &args[1];

assert!(
filter_complex.contains(
"vpp_qsv=w=1440:h=1080:pad_w=1920:pad_h=1080:pad_x=-1:pad_y=-1:pad_color=black"
),
"expected a single combined vpp_qsv scale+pad: {filter_complex}"
);
assert_eq!(
filter_complex.matches("vpp_qsv").count(),
1,
"scale+pad must collapse to exactly one vpp_qsv: {filter_complex}"
);
}
}
1 change: 1 addition & 0 deletions crates/ffpipeline/src/accel/vaapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,7 @@ mod tests {
hwaccels: HashSet::new(),
video_filters,
preferred_filters: HashMap::new(),
video_filter_options: HashMap::new(),
}
}

Expand Down
Loading