use bytes::Bytes;
use ffmpeg_next as ffmpeg;
use crate::Error;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum Kind {
#[default]
Auto,
Hardware,
Software,
Named(String),
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct Config {
pub width: u32,
pub height: u32,
pub framerate: u32,
pub bitrate: Option<u64>,
pub gop: u32,
pub kind: Kind,
}
impl Config {
pub fn new(width: u32, height: u32, framerate: u32) -> Self {
Self {
width,
height,
framerate,
bitrate: None,
gop: framerate.saturating_mul(2).max(1),
kind: Kind::Auto,
}
}
fn resolved_bitrate(&self) -> u64 {
self.bitrate.unwrap_or_else(|| {
let pixels = self.width as u64 * self.height as u64;
((pixels * self.framerate as u64) as f64 * 0.07) as u64
})
}
}
const HARDWARE_ENCODERS: &[&str] = &[
"h264_videotoolbox", "h264_nvenc", "h264_qsv", "h264_vaapi", "h264_amf", "h264_v4l2m2m", ];
const SOFTWARE_ENCODERS: &[&str] = &["libx264", "h264"];
pub struct Encoder {
encoder: ffmpeg::encoder::video::Encoder,
scaler: Option<Scaler>,
width: u32,
height: u32,
frame_count: i64,
name: String,
}
struct Scaler {
ctx: ffmpeg::software::scaling::Context,
src_format: ffmpeg::format::Pixel,
src_width: u32,
src_height: u32,
}
impl Encoder {
pub fn new(config: &Config) -> Result<Self, Error> {
if config.framerate == 0 {
return Err(Error::InvalidFramerate(0));
}
if config.width == 0 || config.height == 0 {
return Err(Error::Codec(anyhow::anyhow!(
"encoder dimensions must be non-zero (got {}x{})",
config.width,
config.height
)));
}
ffmpeg::init()?;
let candidates = encoder_candidates(&config.kind);
let mut tried = Vec::new();
for name in &candidates {
tried.push(name.clone());
match open_encoder(name, config) {
Ok(encoder) => {
tracing::info!(encoder = %name, width = config.width, height = config.height, "opened H.264 encoder");
return Ok(Self {
encoder,
scaler: None,
width: config.width,
height: config.height,
frame_count: 0,
name: name.clone(),
});
}
Err(e) => {
tracing::debug!(encoder = %name, error = %e, "encoder unavailable, trying next");
}
}
}
Err(Error::NoEncoder(tried.join(", ")))
}
pub fn name(&self) -> &str {
&self.name
}
pub fn encode_rgba(&mut self, rgba: &[u8], width: u32, height: u32, keyframe: bool) -> Result<Vec<Bytes>, Error> {
let frame = rgba_frame(rgba, width, height)?;
self.encode_frame(&frame, keyframe)
}
pub(crate) fn encode(&mut self, frame: &ffmpeg::frame::Video) -> Result<Vec<Bytes>, Error> {
self.encode_frame(frame, false)
}
fn encode_frame(&mut self, frame: &ffmpeg::frame::Video, keyframe: bool) -> Result<Vec<Bytes>, Error> {
let mut yuv = self.convert(frame)?;
if keyframe {
yuv.set_kind(ffmpeg::picture::Type::I);
}
self.encoder.send_frame(&yuv)?;
self.drain()
}
pub fn finish(&mut self) -> Result<Vec<Bytes>, Error> {
self.encoder.send_eof()?;
self.drain()
}
fn drain(&mut self) -> Result<Vec<Bytes>, Error> {
let mut out = Vec::new();
let mut packet = ffmpeg::Packet::empty();
loop {
match self.encoder.receive_packet(&mut packet) {
Ok(()) => {
if let Some(data) = packet.data() {
out.push(Bytes::copy_from_slice(data));
}
}
Err(ffmpeg::Error::Other { errno }) if errno == ffmpeg::util::error::EAGAIN => break,
Err(ffmpeg::Error::Eof) => break,
Err(e) => return Err(e.into()),
}
}
Ok(out)
}
fn convert(&mut self, frame: &ffmpeg::frame::Video) -> Result<ffmpeg::frame::Video, Error> {
let (src_format, src_w, src_h) = (frame.format(), frame.width(), frame.height());
let needs_rebuild = match &self.scaler {
Some(s) => s.src_format != src_format || s.src_width != src_w || s.src_height != src_h,
None => true,
};
if needs_rebuild {
let ctx = ffmpeg::software::scaling::Context::get(
src_format,
src_w,
src_h,
ffmpeg::format::Pixel::YUV420P,
self.width,
self.height,
ffmpeg::software::scaling::Flags::BILINEAR,
)?;
self.scaler = Some(Scaler {
ctx,
src_format,
src_width: src_w,
src_height: src_h,
});
}
let scaler = self.scaler.as_mut().expect("scaler built above");
let mut yuv = ffmpeg::frame::Video::empty();
scaler.ctx.run(frame, &mut yuv)?;
yuv.set_pts(Some(self.frame_count));
self.frame_count += 1;
Ok(yuv)
}
}
fn rgba_frame(rgba: &[u8], width: u32, height: u32) -> Result<ffmpeg::frame::Video, Error> {
let row_bytes = width as usize * 4;
let expected = row_bytes * height as usize;
if rgba.len() < expected {
return Err(Error::Codec(anyhow::anyhow!(
"RGBA buffer too small: {} < {expected} for {width}x{height}",
rgba.len()
)));
}
let mut frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBA, width, height);
let stride = frame.stride(0);
for y in 0..height as usize {
let src = y * row_bytes;
let dst = y * stride;
frame.data_mut(0)[dst..dst + row_bytes].copy_from_slice(&rgba[src..src + row_bytes]);
}
Ok(frame)
}
fn encoder_candidates(kind: &Kind) -> Vec<String> {
match kind {
Kind::Named(name) => vec![name.clone()],
Kind::Hardware => HARDWARE_ENCODERS.iter().map(|s| s.to_string()).collect(),
Kind::Software => SOFTWARE_ENCODERS.iter().map(|s| s.to_string()).collect(),
Kind::Auto => HARDWARE_ENCODERS
.iter()
.chain(SOFTWARE_ENCODERS)
.map(|s| s.to_string())
.collect(),
}
}
fn open_encoder(name: &str, config: &Config) -> Result<ffmpeg::encoder::video::Encoder, Error> {
let codec = ffmpeg::encoder::find_by_name(name).ok_or_else(|| Error::NoEncoder(name.to_string()))?;
let ctx = ffmpeg::codec::context::Context::new_with_codec(codec);
let mut enc = ctx.encoder().video()?;
enc.set_width(config.width);
enc.set_height(config.height);
enc.set_format(ffmpeg::format::Pixel::YUV420P);
enc.set_time_base(ffmpeg::Rational::new(1, config.framerate as i32));
enc.set_frame_rate(Some(ffmpeg::Rational::new(config.framerate as i32, 1)));
enc.set_gop(config.gop);
enc.set_max_b_frames(0); enc.set_bit_rate(config.resolved_bitrate() as usize);
let mut opts = ffmpeg::Dictionary::new();
if name == "libx264" {
opts.set("preset", "ultrafast");
opts.set("tune", "zerolatency");
} else if name == "h264_videotoolbox" {
opts.set("realtime", "1");
opts.set("allow_sw", "1");
}
Ok(enc.open_with(opts)?)
}
#[cfg(test)]
mod tests {
use super::*;
fn gray_frame(width: u32, height: u32) -> ffmpeg::frame::Video {
let mut frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::YUV420P, width, height);
for plane in 0..frame.planes() {
frame.data_mut(plane).fill(128);
}
frame
}
#[test]
fn software_encoder_emits_annexb() {
let config = Config {
kind: Kind::Software,
..Config::new(320, 240, 30)
};
let mut encoder = Encoder::new(&config).expect("libx264 should be available under nix ffmpeg");
assert_eq!(encoder.name(), "libx264");
let frame = gray_frame(320, 240);
let mut packets = Vec::new();
for _ in 0..30 {
packets.extend(encoder.encode(&frame).unwrap());
}
packets.extend(encoder.finish().unwrap());
assert!(!packets.is_empty(), "encoder produced no packets");
let first = &packets[0];
let has_start_code = first.starts_with(&[0, 0, 0, 1]) || first.starts_with(&[0, 0, 1]);
assert!(
has_start_code,
"first packet is not Annex-B: {:02x?}",
&first[..first.len().min(8)]
);
}
#[test]
fn encode_rgba_emits_annexb() {
let config = Config {
kind: Kind::Software,
..Config::new(320, 240, 30)
};
let mut encoder = Encoder::new(&config).unwrap();
let rgba = vec![0x40u8; 320 * 240 * 4];
let mut packets = encoder.encode_rgba(&rgba, 320, 240, true).unwrap();
packets.extend(encoder.finish().unwrap());
assert!(!packets.is_empty());
assert!(packets[0].starts_with(&[0, 0, 0, 1]) || packets[0].starts_with(&[0, 0, 1]));
}
#[test]
fn encode_rgba_rejects_short_buffer() {
let config = Config {
kind: Kind::Software,
..Config::new(320, 240, 30)
};
let mut encoder = Encoder::new(&config).unwrap();
assert!(matches!(
encoder.encode_rgba(&[0u8; 16], 320, 240, false),
Err(Error::Codec(_))
));
}
#[test]
fn new_rejects_zero_framerate() {
let config = Config {
kind: Kind::Software,
..Config::new(320, 240, 0)
};
assert!(matches!(Encoder::new(&config), Err(Error::InvalidFramerate(0))));
}
#[test]
fn unknown_named_encoder_errors() {
let config = Config {
kind: Kind::Named("definitely_not_a_codec".into()),
..Config::new(320, 240, 30)
};
assert!(matches!(Encoder::new(&config), Err(Error::NoEncoder(_))));
}
#[test]
fn default_bitrate_scales_with_resolution() {
let small = Config::new(320, 240, 30).resolved_bitrate();
let large = Config::new(1920, 1080, 30).resolved_bitrate();
assert!(large > small);
assert!(small > 0);
}
}