refactor(avhw): split encoder module
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
use std::ffi::CString;
|
||||
use std::path::Path;
|
||||
use std::ptr;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use ffmpeg_next as ff;
|
||||
use ffmpeg_next::ffi;
|
||||
|
||||
use super::ff_err;
|
||||
|
||||
pub(super) fn create_nv12_to_yuv420p_sws(width: u32, height: u32) -> Result<*mut ffi::SwsContext> {
|
||||
// SAFETY: sws_getContext creates an owned scaler context for same-size NV12 -> YUV420P.
|
||||
let ctx = unsafe {
|
||||
ffi::sws_getContext(
|
||||
width as i32,
|
||||
height as i32,
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_NV12,
|
||||
width as i32,
|
||||
height as i32,
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_YUV420P,
|
||||
2,
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if ctx.is_null() {
|
||||
bail!("Failed to create NV12 -> YUV420P sws_scale context");
|
||||
}
|
||||
Ok(ctx)
|
||||
}
|
||||
|
||||
pub(super) fn alloc_yuv420p_frame(width: u32, height: u32) -> Result<*mut ffi::AVFrame> {
|
||||
// SAFETY: Allocate an AVFrame, configure format/dimensions, then allocate writable buffers.
|
||||
unsafe {
|
||||
let mut frame = ffi::av_frame_alloc();
|
||||
if frame.is_null() {
|
||||
bail!("av_frame_alloc failed");
|
||||
}
|
||||
(*frame).width = width as i32;
|
||||
(*frame).height = height as i32;
|
||||
(*frame).format = ffi::AVPixelFormat::AV_PIX_FMT_YUV420P as i32;
|
||||
let ret = ffi::av_frame_get_buffer(frame, 0);
|
||||
if ret < 0 {
|
||||
ffi::av_frame_free(&mut frame);
|
||||
bail!("av_frame_get_buffer failed: {}", ff_err(ret));
|
||||
}
|
||||
Ok(frame)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn create_software_h264_muxer(
|
||||
output_path: &Path,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
bitrate: u64,
|
||||
gop_size: u32,
|
||||
) -> Result<(
|
||||
ff::codec::encoder::video::Video,
|
||||
ff::format::context::Output,
|
||||
)> {
|
||||
let output_cstr = CString::new(output_path.to_str().unwrap())?;
|
||||
let codec = ff::encoder::find_by_name("libx264")
|
||||
.or_else(|| ff::encoder::find_by_name("libopenh264"))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("No H.264 software encoder found (tried libx264, libopenh264)")
|
||||
})?;
|
||||
let codec_name = codec.name().to_string();
|
||||
|
||||
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::YUV420P);
|
||||
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(3);
|
||||
|
||||
// SAFETY: global headers are needed by MP4 and harmless for other common muxers.
|
||||
unsafe {
|
||||
(*enc.as_mut_ptr()).flags |= ffi::AV_CODEC_FLAG_GLOBAL_HEADER as i32;
|
||||
}
|
||||
|
||||
if codec_name == "libx264" {
|
||||
// SAFETY: priv_data and codec context belong to the unopened encoder;
|
||||
// strings live for each av_opt_set call.
|
||||
unsafe {
|
||||
let key = CString::new("preset").unwrap();
|
||||
let val = CString::new("fast").unwrap();
|
||||
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
|
||||
let key = CString::new("threads").unwrap();
|
||||
let val = CString::new("6").unwrap();
|
||||
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
|
||||
(*enc.as_mut_ptr()).profile = ffi::AV_PROFILE_H264_HIGH;
|
||||
// SAFETY: enc is a valid, initialized AVCodecContext from
|
||||
// avcodec_alloc_context3. Setting level is a simple i32 field
|
||||
// assignment on a properly aligned struct.
|
||||
(*enc.as_mut_ptr()).level = 40; // H.264 Level 4.0 (up to 1080p@30)
|
||||
}
|
||||
}
|
||||
|
||||
let opened = enc
|
||||
.open()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to open {codec_name} encoder: {e}"))?;
|
||||
let enc_video = opened.0;
|
||||
|
||||
let use_null = output_path
|
||||
.to_str()
|
||||
.map(|s| s.contains("null"))
|
||||
.unwrap_or(false);
|
||||
let fmt_name = if use_null {
|
||||
CString::new("null").unwrap()
|
||||
} else {
|
||||
CString::new("").unwrap()
|
||||
};
|
||||
let fmt_name_ptr = if use_null {
|
||||
fmt_name.as_ptr()
|
||||
} else {
|
||||
ptr::null()
|
||||
};
|
||||
|
||||
let mut fmt_ctx_ptr: *mut ffi::AVFormatContext = ptr::null_mut();
|
||||
// SAFETY: fmt_ctx_ptr is initialized by FFmpeg; C strings live across the call.
|
||||
let ret = unsafe {
|
||||
ffi::avformat_alloc_output_context2(
|
||||
&mut fmt_ctx_ptr,
|
||||
ptr::null_mut(),
|
||||
fmt_name_ptr,
|
||||
output_cstr.as_ptr(),
|
||||
)
|
||||
};
|
||||
if ret < 0 || fmt_ctx_ptr.is_null() {
|
||||
bail!("Failed to allocate output format context: {}", ff_err(ret));
|
||||
}
|
||||
|
||||
// SAFETY: fmt_ctx_ptr is valid; stream and codec parameters are owned by 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 output stream");
|
||||
}
|
||||
|
||||
// SAFETY: stream_ptr and encoder context are valid; parameters are copied into stream.
|
||||
let ret =
|
||||
unsafe { ffi::avcodec_parameters_from_context((*stream_ptr).codecpar, enc_video.as_ptr()) };
|
||||
if ret < 0 {
|
||||
bail!("Failed to copy codec parameters to stream: {}", ff_err(ret));
|
||||
}
|
||||
// SAFETY: stream_ptr is valid and writable during muxer setup.
|
||||
unsafe {
|
||||
(*stream_ptr).time_base = (*enc_video.as_ptr()).time_base;
|
||||
}
|
||||
|
||||
// SAFETY: open an AVIO only for muxers that require files; null muxer advertises NOFILE.
|
||||
unsafe {
|
||||
if (*(*fmt_ctx_ptr).oformat).flags & ffi::AVFMT_NOFILE == 0 {
|
||||
let ret = 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 '{}': {}",
|
||||
output_path.display(),
|
||||
ff_err(ret)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: fmt_ctx_ptr is fully configured.
|
||||
let ret = unsafe { ffi::avformat_write_header(fmt_ctx_ptr, ptr::null_mut()) };
|
||||
if ret < 0 {
|
||||
bail!("Failed to write output header: {}", ff_err(ret));
|
||||
}
|
||||
|
||||
// SAFETY: ownership of fmt_ctx_ptr transfers to ffmpeg-next Output wrapper.
|
||||
let octx = unsafe { ff::format::context::Output::wrap(fmt_ctx_ptr) };
|
||||
tracing::info!("Using software H.264 encoder: {codec_name}");
|
||||
Ok((enc_video, octx))
|
||||
}
|
||||
|
||||
pub(super) fn create_software_h264_encoder(
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
bitrate: u64,
|
||||
gop_size: u32,
|
||||
) -> Result<ff::codec::encoder::video::Video> {
|
||||
let codec = ff::encoder::find_by_name("libx264")
|
||||
.or_else(|| ff::encoder::find_by_name("libopenh264"))
|
||||
.ok_or_else(|| anyhow::anyhow!("No H.264 software encoder found"))?;
|
||||
let codec_name = codec.name().to_string();
|
||||
|
||||
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::YUV420P);
|
||||
enc.set_bit_rate(bitrate as usize);
|
||||
enc.set_gop(gop_size);
|
||||
// 90kHz media clock matches RTP directly. Eliminates 1/fps quantization
|
||||
// that previously caused sequential RTP timestamps during 60fps capture,
|
||||
// leading to 2x RTP time inflation and 10s+ browser jitter buffer growth.
|
||||
// See issue #25.
|
||||
enc.set_time_base(ff::Rational::new(1, 90_000));
|
||||
// Explicit framerate is REQUIRED when time_base is not 1/fps, otherwise
|
||||
// libx264 infers wrong fps from the 90kHz time_base and VBV rate control
|
||||
// breaks. Per Oracle review round for #25.
|
||||
enc.set_frame_rate(Some(ff::Rational::new(fps as i32, 1)));
|
||||
enc.set_max_b_frames(0);
|
||||
|
||||
if codec_name == "libx264" {
|
||||
// SAFETY: priv_data and codec context belong to the unopened encoder;
|
||||
// each CString lives for the duration of its av_opt_set call.
|
||||
unsafe {
|
||||
let key = CString::new("preset").unwrap();
|
||||
let val = CString::new("veryfast").unwrap();
|
||||
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
|
||||
let key = CString::new("tune").unwrap();
|
||||
let val = CString::new("zerolatency").unwrap();
|
||||
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
|
||||
let key = CString::new("threads").unwrap();
|
||||
let val = CString::new("6").unwrap();
|
||||
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
|
||||
// High profile via AVCodecContext.profile (not x264opts - x264 rejects it there).
|
||||
// High enables CABAC + 8x8dct automatically.
|
||||
(*enc.as_mut_ptr()).profile = ffi::AV_PROFILE_H264_HIGH;
|
||||
// SAFETY: enc is a valid, initialized AVCodecContext from
|
||||
// avcodec_alloc_context3. Setting level is a simple i32 field
|
||||
// assignment on a properly aligned struct.
|
||||
(*enc.as_mut_ptr()).level = 42; // H.264 Level 4.2 (up to 1440p@30)
|
||||
// SAFETY: priv_data belongs to the unopened libx264 encoder context.
|
||||
// `forced-idr` is an FFmpeg-level private option (not x264-native),
|
||||
// so it must be set via av_opt_set, NOT via the x264opts string.
|
||||
// With forced-idr=1, setting AV_PICTURE_TYPE_I on an input frame
|
||||
// produces a true IDR NALU with inline SPS/PPS (repeat_headers=1).
|
||||
let key = CString::new("forced-idr").unwrap();
|
||||
let val = CString::new("1").unwrap();
|
||||
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
|
||||
let key = CString::new("x264opts").unwrap();
|
||||
// x264's vbv-maxrate unit is kbit/s and vbv-bufsize is kbit (NOT bps).
|
||||
// Confirmed via x264 source encoder/ratecontrol.c:658-661 which multiplies
|
||||
// these values by 1000 to convert kbit -> bit at use site. Passing bps makes
|
||||
// VBV effectively unbounded (5.5 Mbps becomes 5.5 Gbps, clipped to 2 Gbps).
|
||||
// See https://github.com/mirror/x264/blob/c24e06c2e184345ceb33eb20a15d1024d9fd3497/encoder/ratecontrol.c#L658-L661
|
||||
let vbv_maxrate_kbps = bitrate / 1000;
|
||||
let vbv_bufsize_kbps = (bitrate / 4) / 1000;
|
||||
let val = CString::new(format!(
|
||||
"repeat_headers=1:vbv-maxrate={vbv_maxrate_kbps}:vbv-bufsize={vbv_bufsize_kbps}"
|
||||
))
|
||||
.unwrap();
|
||||
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
let opened = enc
|
||||
.open()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to open {codec_name} encoder: {e}"))?;
|
||||
tracing::info!("WebRTC encoder: {codec_name} {width}x{height} @ {fps}fps {bitrate}bps (profile High, preset veryfast)");
|
||||
Ok(opened.0)
|
||||
}
|
||||
Reference in New Issue
Block a user