feat: Phase 1 MVP with audit fixes — Wayland screen capture + VAAPI encoding

Phase 1 MVP implementation of wl-webrtc: Wayland screen capture tool
with hardware-accelerated VAAPI H.264 encoding and WebTransport output.

Includes all 9 runtime bug fixes from code audit (fix-audit-issues plan):

CRITICAL:
- C2: h264_metadata BSF with repeat_sps/repeat_pps in encode pipeline
- C4: FpsLimit wired as timing gate in on_copy_complete

HIGH:
- C3+A2: DRM device discovery via dmabuf feedback MainDevice event,
  unified resolve_drm_path() helper (CLI > compositor > auto > fallback)
- H2: Separate physical_size (mm) from mode_size (pixels) in wl_output
- H1+A3: Multi-output warning + named-output-not-found error

MEDIUM:
- M5: tv_sec u32->u64 to avoid Y2106 timestamp truncation
- M4: Guard against SHM Buffer event (DMA-BUF only)

Key components:
- src/avhw.rs: FFmpeg VAAPI encoder + filter graph + BSF pipeline
- src/state.rs: Wayland event loop + output negotiation + screencopy
- src/cap_wlr_screencopy.rs: wlr-screencopy capture source
- src/fps_limit.rs: Frame rate limiting with configurable target
- src/transform.rs: Frame format conversion utilities
This commit is contained in:
dailz
2026-04-05 23:35:00 +08:00
commit 6d49222de8
17 changed files with 6964 additions and 0 deletions
+672
View File
@@ -0,0 +1,672 @@
use std::ffi::CString;
use std::path::Path;
use std::ptr;
use anyhow::{bail, Result};
use ffmpeg_next as ff;
use ffmpeg_next::ffi as ffi;
use ffmpeg_next::packet::Mut as _;
// ---------------------------------------------------------------------------
// BSF FFI — ffmpeg-sys-next does not expose the BSF API; declare manually.
// Linked from libavcodec (always present when avcodec feature is enabled).
// ---------------------------------------------------------------------------
#[repr(C)]
pub struct AVBitStreamFilter {
_opaque: [u8; 0],
}
#[repr(C)]
pub struct AVBSFContext {
av_class: *const ffi::AVClass,
filter: *const AVBitStreamFilter,
priv_data: *mut libc::c_void,
par_in: *mut ffi::AVCodecParameters,
par_out: *mut ffi::AVCodecParameters,
time_base_in: ffi::AVRational,
time_base_out: ffi::AVRational,
}
extern "C" {
pub fn av_bsf_get_by_name(name: *const libc::c_char) -> *const AVBitStreamFilter;
pub fn av_bsf_alloc(
filter: *const AVBitStreamFilter,
ctx: *mut *mut AVBSFContext,
) -> libc::c_int;
pub fn av_bsf_init(ctx: *mut AVBSFContext) -> libc::c_int;
pub fn av_bsf_send_packet(ctx: *mut AVBSFContext, pkt: *mut ffi::AVPacket) -> libc::c_int;
pub fn av_bsf_receive_packet(ctx: *mut AVBSFContext, pkt: *mut ffi::AVPacket) -> libc::c_int;
pub fn av_bsf_free(ctx: *mut *mut AVBSFContext);
}
// ---------------------------------------------------------------------------
// AvHwDevCtx
// ---------------------------------------------------------------------------
pub struct AvHwDevCtx {
ptr: *mut ffi::AVBufferRef,
}
unsafe impl Send for AvHwDevCtx {}
impl AvHwDevCtx {
pub fn new_vaapi(drm_device: &Path) -> Result<Self> {
let device_cstr = CString::new(drm_device.to_str().unwrap())?;
let mut p: *mut ffi::AVBufferRef = ptr::null_mut();
let ret = unsafe {
ffi::av_hwdevice_ctx_create(
&mut p,
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
device_cstr.as_ptr(),
ptr::null_mut(),
0,
)
};
if ret < 0 {
bail!(
"Failed to create VAAPI device context from {}: error {ret}",
drm_device.display()
);
}
Ok(Self { ptr: p })
}
pub fn as_ptr(&self) -> *mut ffi::AVBufferRef {
self.ptr
}
pub fn ref_clone(&self) -> *mut ffi::AVBufferRef {
// SAFETY: av_buffer_ref atomically increments refcount and returns a new ref.
unsafe { ffi::av_buffer_ref(self.ptr) }
}
}
impl Drop for AvHwDevCtx {
fn drop(&mut self) {
if !self.ptr.is_null() {
// SAFETY: av_buffer_unref decrements refcount; frees the buffer when it hits zero.
unsafe { ffi::av_buffer_unref(&mut self.ptr) };
}
}
}
// ---------------------------------------------------------------------------
// AvHwFrameCtx
// ---------------------------------------------------------------------------
pub struct AvHwFrameCtx {
ptr: *mut ffi::AVBufferRef,
}
unsafe impl Send for AvHwFrameCtx {}
impl AvHwFrameCtx {
fn new_inner(
hw_dev: &AvHwDevCtx,
w: u32,
h: u32,
sw_fmt: ff::format::Pixel,
) -> Result<Self> {
let mut p = unsafe { ffi::av_hwframe_ctx_alloc(hw_dev.as_ptr()) };
if p.is_null() {
bail!("av_hwframe_ctx_alloc returned null");
}
// SAFETY: p is a valid AVBufferRef from av_hwframe_ctx_alloc.
// Its .data field points to an AVHWFramesContext that we must configure.
unsafe {
let fc = (*p).data as *mut ffi::AVHWFramesContext;
(*fc).format = ff::format::Pixel::VAAPI.into();
(*fc).sw_format = sw_fmt.into();
(*fc).width = w as i32;
(*fc).height = h as i32;
(*fc).initial_pool_size = 4;
}
let ret = unsafe { ffi::av_hwframe_ctx_init(p) };
if ret < 0 {
// SAFETY: p is valid but init failed; clean up.
unsafe { ffi::av_buffer_unref(&mut p) };
bail!("av_hwframe_ctx_init failed: error {ret}");
}
Ok(Self { ptr: p })
}
pub fn for_capture(
hw_dev: &AvHwDevCtx,
w: u32,
h: u32,
sw_fmt: ff::format::Pixel,
) -> Result<Self> {
Self::new_inner(hw_dev, w, h, sw_fmt)
}
pub fn for_encode(
hw_dev: &AvHwDevCtx,
w: u32,
h: u32,
sw_fmt: ff::format::Pixel,
) -> Result<Self> {
Self::new_inner(hw_dev, w, h, sw_fmt)
}
pub fn as_ptr(&self) -> *mut ffi::AVBufferRef {
self.ptr
}
pub fn ref_clone(&self) -> *mut ffi::AVBufferRef {
// SAFETY: av_buffer_ref atomically increments refcount and returns a new ref.
unsafe { ffi::av_buffer_ref(self.ptr) }
}
}
impl Drop for AvHwFrameCtx {
fn drop(&mut self) {
if !self.ptr.is_null() {
// SAFETY: av_buffer_unref decrements refcount; frees when zero.
unsafe { ffi::av_buffer_unref(&mut self.ptr) };
}
}
}
// ---------------------------------------------------------------------------
// EncState
// ---------------------------------------------------------------------------
pub struct EncState {
enc_video: ff::codec::encoder::video::Video,
bsf_ctx: *mut AVBSFContext,
frames_rgb: AvHwFrameCtx,
frames_yuv: AvHwFrameCtx,
video_filter: ff::filter::Graph,
hw_device_ctx: AvHwDevCtx,
octx: ff::format::context::Output,
starting_timestamp: Option<i64>,
frames_written: bool,
}
unsafe impl Send for EncState {}
#[allow(clippy::too_many_arguments)]
impl EncState {
pub fn new(
drm_device: &Path,
output_path: &Path,
width: u32,
height: u32,
bitrate: u64,
gop_size: u32,
fps: u32,
) -> Result<Self> {
// 1. VAAPI device
let hw_device_ctx = AvHwDevCtx::new_vaapi(drm_device)?;
// 2. Frame contexts (capture=XRGB/RGBZ, encode=NV12)
let frames_rgb = AvHwFrameCtx::for_capture(
&hw_device_ctx,
width,
height,
ff::format::Pixel::RGBZ,
)?;
let frames_yuv = AvHwFrameCtx::for_encode(
&hw_device_ctx,
width,
height,
ff::format::Pixel::NV12,
)?;
// 3. Find h264_vaapi encoder
let codec = ff::encoder::find_by_name("h264_vaapi")
.ok_or_else(|| anyhow::anyhow!("h264_vaapi encoder not found"))?;
let mut enc = {
let ctx = ff::codec::Context::new_with_codec(codec);
ctx.encoder().video()?
};
enc.set_width(width);
enc.set_height(height);
enc.set_format(ff::format::Pixel::VAAPI);
enc.set_bit_rate(bitrate as usize);
enc.set_gop(gop_size);
enc.set_time_base(ff::Rational::new(1, fps as i32));
enc.set_max_b_frames(0);
// SAFETY: AV_CODEC_FLAG_GLOBAL_HEADER must be set BEFORE opening the encoder.
// It triggers SPS/PPS extradata generation needed by the muxer for
// Annex B to AVCC conversion.
unsafe {
(*enc.as_mut_ptr()).flags |= ffi::AV_CODEC_FLAG_GLOBAL_HEADER as i32;
}
// SAFETY: Assign hw device and frames ctx to the encoder.
unsafe {
(*enc.as_mut_ptr()).hw_device_ctx = hw_device_ctx.ref_clone();
(*enc.as_mut_ptr()).hw_frames_ctx = frames_yuv.ref_clone();
}
// 4. Open encoder. Video::open() returns Encoder(Video); .0 extracts the Video.
let opened = enc.open().map_err(|e| {
anyhow::anyhow!("Failed to open h264_vaapi encoder: {e}")
})?;
let enc_video = opened.0;
// --- BSF init (after encoder open, before filter graph) ---
// SAFETY: av_bsf_get_by_name returns a pointer to a static filter definition.
let bsf_name = CString::new("h264_metadata").unwrap();
let filter = unsafe { av_bsf_get_by_name(bsf_name.as_ptr()) };
if filter.is_null() {
bail!("h264_metadata BSF not found in FFmpeg build");
}
let mut bsf_ctx: *mut AVBSFContext = ptr::null_mut();
let ret = unsafe { av_bsf_alloc(filter, &mut bsf_ctx) };
if ret < 0 {
bail!("av_bsf_alloc failed: error {ret}");
}
// SAFETY: avcodec_parameters_from_context copies FROM AVCodecContext TO AVCodecParameters.
let ret = unsafe {
ffi::avcodec_parameters_from_context((*bsf_ctx).par_in, enc_video.as_ptr())
};
if ret < 0 {
// SAFETY: bsf_ctx was allocated but not yet initialized — safe to free
unsafe { av_bsf_free(&mut bsf_ctx) };
bail!("avcodec_parameters_from_context for BSF failed: error {ret}");
}
// SAFETY: time_base_in is a plain AVRational field, safe to write
unsafe {
(*bsf_ctx).time_base_in = (*enc_video.as_ptr()).time_base;
}
// Set repeat_sps=1
let key_sps = CString::new("repeat_sps").unwrap();
let val_one = CString::new("1").unwrap();
let ret = unsafe {
ffi::av_opt_set((*bsf_ctx).priv_data, key_sps.as_ptr(), val_one.as_ptr(), 0)
};
if ret < 0 {
// SAFETY: bsf_ctx allocated but not fully initialized — safe to free
unsafe { av_bsf_free(&mut bsf_ctx) };
bail!("av_opt_set repeat_sps failed: error {ret}");
}
// Set repeat_pps=1
let key_pps = CString::new("repeat_pps").unwrap();
let ret = unsafe {
ffi::av_opt_set((*bsf_ctx).priv_data, key_pps.as_ptr(), val_one.as_ptr(), 0)
};
if ret < 0 {
// SAFETY: bsf_ctx allocated, repeat_sps set but not init'd — safe to free
unsafe { av_bsf_free(&mut bsf_ctx) };
bail!("av_opt_set repeat_pps failed: error {ret}");
}
// Initialize BSF
let ret = unsafe { av_bsf_init(bsf_ctx) };
if ret < 0 {
// SAFETY: bsf_ctx allocated, params set but init failed — safe to free
unsafe { av_bsf_free(&mut bsf_ctx) };
bail!("av_bsf_init failed: error {ret}");
}
// 5. Filter graph (inline)
let video_filter =
build_filter_graph(&hw_device_ctx, &frames_rgb, width, height, fps)?;
// 6. Muxer setup (strict order)
let output_cstr = CString::new(output_path.to_str().unwrap())?;
let mut fmt_ctx_ptr: *mut ffi::AVFormatContext = ptr::null_mut();
// SAFETY: avformat_alloc_output_context2 creates format context from
// the file extension. Does NOT open the file.
let ret = unsafe {
ffi::avformat_alloc_output_context2(
&mut fmt_ctx_ptr,
ptr::null_mut(),
ptr::null(),
output_cstr.as_ptr(),
)
};
if ret < 0 || fmt_ctx_ptr.is_null() {
bail!("Failed to allocate output format context: error {ret}");
}
// SAFETY: avformat_query_codec checks codec+format compatibility.
let codec_id = unsafe { (*enc_video.as_ptr()).codec_id };
let oformat = unsafe { (*fmt_ctx_ptr).oformat };
let compat = unsafe {
ffi::avformat_query_codec(oformat, codec_id, ffi::FF_COMPLIANCE_NORMAL as i32)
};
if compat < 0 {
bail!("H.264 codec not supported by output container format");
}
// SAFETY: avformat_new_stream creates a new stream in the format context.
let stream_ptr = unsafe { ffi::avformat_new_stream(fmt_ctx_ptr, ptr::null()) };
if stream_ptr.is_null() {
bail!("Failed to create new stream in output context");
}
// SAFETY: avcodec_parameters_from_context copies encoder params + extradata.
let ret = unsafe {
ffi::avcodec_parameters_from_context((*stream_ptr).codecpar, enc_video.as_ptr())
};
if ret < 0 {
bail!("Failed to copy encoder parameters to stream: error {ret}");
}
// SAFETY: Copy encoder time_base to stream.
unsafe {
(*stream_ptr).time_base = (*enc_video.as_ptr()).time_base;
}
// SAFETY: avio_open opens the output file for writing.
let ret = unsafe {
ffi::avio_open(
&mut (*fmt_ctx_ptr).pb,
output_cstr.as_ptr(),
ffi::AVIO_FLAG_WRITE,
)
};
if ret < 0 {
bail!(
"Failed to open output file '{}': error {ret}",
output_path.display()
);
}
// SAFETY: avformat_write_header writes the container header.
let ret = unsafe { ffi::avformat_write_header(fmt_ctx_ptr, ptr::null_mut()) };
if ret < 0 {
bail!("Failed to write output header: error {ret}");
}
// SAFETY: We created fmt_ctx_ptr above and it's valid.
let octx = unsafe { ff::format::context::Output::wrap(fmt_ctx_ptr) };
Ok(Self {
enc_video,
bsf_ctx,
frames_rgb,
frames_yuv,
video_filter,
hw_device_ctx,
octx,
starting_timestamp: None,
frames_written: false,
})
}
pub fn frames_rgb(&self) -> &AvHwFrameCtx {
&self.frames_rgb
}
pub fn encode_frame(&mut self, hw_frame: &ff::frame::Video) -> Result<()> {
let mut filter_src_ctx = self.video_filter.get("in").unwrap();
let mut filter_src = filter_src_ctx.source();
let mut filter_sink_ctx = self.video_filter.get("out").unwrap();
let mut filter_sink = filter_sink_ctx.sink();
// SAFETY: hw_frame is a valid VAAPI hardware frame from capture.
filter_src.add(hw_frame).map_err(|e| {
anyhow::anyhow!("Filter source add failed: {e}")
})?;
loop {
let mut filtered = ff::frame::Video::empty();
match filter_sink.frame(&mut filtered) {
Ok(()) => {
if filtered.pts().is_none() {
filtered.set_pts(hw_frame.pts());
}
}
Err(ff::Error::Other { errno }) if errno == ffi::EAGAIN => break,
Err(e) => bail!("Filter sink get frame failed: {e}"),
}
let pts = filtered.pts().unwrap_or(0);
if self.starting_timestamp.is_none() {
self.starting_timestamp = Some(pts);
}
let start_ts = self.starting_timestamp.unwrap();
// SAFETY: avcodec_send_frame sends a valid NV12 VAAPI surface to the encoder.
let ret = unsafe {
ffi::avcodec_send_frame(self.enc_video.as_mut_ptr(), filtered.as_ptr())
};
if ret < 0 {
bail!("avcodec_send_frame failed: error {ret}");
}
self.drain_encoder(start_ts)?;
}
Ok(())
}
pub fn flush(&mut self) -> Result<()> {
// Flush filter graph
let mut filter_src_ctx = self.video_filter.get("in").unwrap();
let mut filter_src = filter_src_ctx.source();
let _ = filter_src.flush();
// Drain filter
let mut filter_sink_ctx = self.video_filter.get("out").unwrap();
let mut filter_sink = filter_sink_ctx.sink();
loop {
let mut filtered = ff::frame::Video::empty();
match filter_sink.frame(&mut filtered) {
Ok(()) => {
let start_ts = self.starting_timestamp.unwrap_or(0);
let ret = unsafe {
ffi::avcodec_send_frame(self.enc_video.as_mut_ptr(), filtered.as_ptr())
};
if ret < 0 {
bail!("avcodec_send_frame failed during flush: error {ret}");
}
self.drain_encoder(start_ts)?;
}
Err(_) => break,
}
}
// SAFETY: Sending null frame signals end of stream.
unsafe {
ffi::avcodec_send_frame(self.enc_video.as_mut_ptr(), ptr::null());
}
let start_ts = self.starting_timestamp.unwrap_or(0);
self.drain_encoder(start_ts)?;
// SAFETY: Sending null packet signals end-of-stream to BSF
unsafe { av_bsf_send_packet(self.bsf_ctx, ptr::null_mut()) };
loop {
let mut bsf_pkt = ff::Packet::empty();
let ret = unsafe {
av_bsf_receive_packet(self.bsf_ctx, bsf_pkt.as_mut_ptr())
};
if ret < 0 { break; }
let enc_tb = self.enc_video.time_base();
let stream_tb = unsafe {
let streams = (*self.octx.as_ptr()).streams;
let st = *streams.add(0);
ff::Rational::from((*st).time_base)
};
bsf_pkt.rescale_ts(enc_tb, stream_tb);
if let Some(pts) = bsf_pkt.pts() {
bsf_pkt.set_pts(Some(pts - start_ts));
}
if let Some(dts) = bsf_pkt.dts() {
bsf_pkt.set_dts(Some(dts - start_ts));
}
bsf_pkt.set_stream(0);
bsf_pkt.write_interleaved(&mut self.octx).map_err(|e| {
anyhow::anyhow!("Failed to write BSF flush packet: {e}")
})?;
self.frames_written = true;
}
// Write trailer only if at least one frame was encoded.
if self.frames_written {
self.octx.write_trailer().map_err(|e| {
anyhow::anyhow!("Failed to write trailer: {e}")
})?;
}
Ok(())
}
fn drain_encoder(&mut self, start_ts: i64) -> Result<()> {
let stream_index: i32 = 0;
loop {
let mut pkt = ff::Packet::empty();
// SAFETY: avcodec_receive_packet retrieves an encoded packet.
let ret = unsafe {
ffi::avcodec_receive_packet(self.enc_video.as_mut_ptr(), pkt.as_mut_ptr())
};
if ret < 0 {
if ret == ffi::AVERROR(ffi::EAGAIN) || ret == ffi::AVERROR_EOF {
break;
}
bail!("avcodec_receive_packet failed: error {ret}");
}
// SAFETY: av_bsf_send_packet sends the encoded packet through the BSF filter.
// On success, the BSF takes ownership of the packet data (via av_packet_move_ref).
let ret = unsafe { av_bsf_send_packet(self.bsf_ctx, pkt.as_mut_ptr()) };
if ret == ffi::AVERROR(ffi::EAGAIN) {
// BSF buffer full — break and retry next drain cycle
break;
}
if ret < 0 {
bail!("av_bsf_send_packet failed: error {ret}");
}
// Drain all BSF output packets
loop {
let mut bsf_pkt = ff::Packet::empty();
// SAFETY: av_bsf_receive_packet retrieves a BSF-processed packet.
let ret = unsafe {
av_bsf_receive_packet(self.bsf_ctx, bsf_pkt.as_mut_ptr())
};
if ret == ffi::AVERROR(ffi::EAGAIN) {
break; // No more output yet
}
if ret == ffi::AVERROR_EOF {
break; // BSF drained
}
if ret < 0 {
bail!("av_bsf_receive_packet failed: error {ret}");
}
// Rescale and offset on BSF output packet (NOT original pkt)
let enc_tb = self.enc_video.time_base();
let stream_tb = unsafe {
let streams = (*self.octx.as_ptr()).streams;
let st = *streams.add(0);
ff::Rational::from((*st).time_base)
};
bsf_pkt.rescale_ts(enc_tb, stream_tb);
if let Some(pts) = bsf_pkt.pts() {
bsf_pkt.set_pts(Some(pts - start_ts));
}
if let Some(dts) = bsf_pkt.dts() {
bsf_pkt.set_dts(Some(dts - start_ts));
}
bsf_pkt.set_stream(stream_index as usize);
bsf_pkt.write_interleaved(&mut self.octx).map_err(|e| {
anyhow::anyhow!("Failed to write packet: {e}")
})?;
self.frames_written = true;
}
}
Ok(())
}
}
impl Drop for EncState {
fn drop(&mut self) {
// SAFETY: av_bsf_free releases the BSF context and all associated resources.
// It handles null safely (returns immediately if *pctx is null).
if !self.bsf_ctx.is_null() {
unsafe { av_bsf_free(&mut self.bsf_ctx) };
}
}
}
// ---------------------------------------------------------------------------
// Filter graph (inline)
// ---------------------------------------------------------------------------
fn build_filter_graph(
hw_dev: &AvHwDevCtx,
frames_rgb: &AvHwFrameCtx,
width: u32,
height: u32,
fps: u32,
) -> Result<ff::filter::Graph> {
let mut graph = ff::filter::Graph::new();
let buffersrc = ff::filter::find("buffer")
.ok_or_else(|| anyhow::anyhow!("filter 'buffer' not found"))?;
let buffersink = ff::filter::find("buffersink")
.ok_or_else(|| anyhow::anyhow!("filter 'buffersink' not found"))?;
let format_filter = ff::filter::find("format")
.ok_or_else(|| anyhow::anyhow!("filter 'format' not found"))?;
let scale_vaapi = ff::filter::find("scale_vaapi")
.ok_or_else(|| anyhow::anyhow!("filter 'scale_vaapi' not found"))?;
// buffersrc — use AVBufferSrcParameters to set hw_frames_ctx properly
let args = format!(
"video_size={}x{}:pix_fmt={}:time_base=1/{fps}:pixel_aspect=1/1",
width,
height,
Into::<ffi::AVPixelFormat>::into(ff::format::Pixel::VAAPI) as i32,
);
let mut src_ctx = graph.add(&buffersrc, "in", &args)?;
// SAFETY: av_buffersrc_parameters_alloc allocates params for the buffersrc.
let par = unsafe { ffi::av_buffersrc_parameters_alloc() };
if par.is_null() {
bail!("av_buffersrc_parameters_alloc returned null");
}
// SAFETY: Set hw_frames_ctx on the buffersrc parameters, then apply.
unsafe {
(*par).format = Into::<ffi::AVPixelFormat>::into(ff::format::Pixel::VAAPI) as i32;
(*par).width = width as i32;
(*par).height = height as i32;
(*par).time_base = ffi::AVRational { num: 1, den: fps as i32 };
(*par).hw_frames_ctx = frames_rgb.ref_clone();
let ret = ffi::av_buffersrc_parameters_set(src_ctx.as_mut_ptr(), par);
ffi::av_freep(par as *mut _ as *mut _);
if ret < 0 {
bail!("av_buffersrc_parameters_set failed: error {ret}");
}
}
// format filter: negotiate pixel format to NV12
let mut fmt_ctx = graph.add(&format_filter, "fmt", "pix_fmts=nv12")?;
// scale_vaapi: hardware scaling and colourspace conversion
let mut scale_ctx = graph.add(&scale_vaapi, "scale", &format!("{width}:{height}"))?;
// SAFETY: scale_vaapi needs hw_device_ctx for VAAPI device access.
unsafe {
(*scale_ctx.as_mut_ptr()).hw_device_ctx = hw_dev.ref_clone();
}
// buffersink
let mut sink_ctx = graph.add(&buffersink, "out", "")?;
// Link: src -> format -> scale -> sink
src_ctx.link(0, &mut fmt_ctx, 0);
fmt_ctx.link(0, &mut scale_ctx, 0);
scale_ctx.link(0, &mut sink_ctx, 0);
graph.validate().map_err(|e| {
anyhow::anyhow!("Filter graph validation failed: {e}")
})?;
Ok(graph)
}