use std::fmt;
use anyhow::{Context, Result, bail};
use bytes::BytesMut;
use crate::frame::{PixelFormat, VideoFrame};
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum VideoFilter {
Crop {
w: u32,
h: u32,
#[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
x: Option<u32>,
#[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
y: Option<u32>,
},
Pad {
w: u32,
h: u32,
#[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
x: Option<u32>,
#[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
y: Option<u32>,
},
#[cfg_attr(feature = "serde", serde(rename = "hflip"))]
HFlip,
#[cfg_attr(feature = "serde", serde(rename = "vflip"))]
VFlip,
Rotate(u32),
Grayscale,
Overlay {
image: String,
#[cfg_attr(feature = "serde", serde(default))]
x: u32,
#[cfg_attr(feature = "serde", serde(default))]
y: u32,
},
Invert,
Brightness(i32),
Contrast(f32),
Saturation(f32),
}
impl fmt::Display for VideoFilter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
VideoFilter::Crop { w, h, x: Some(x), y: Some(y) } => write!(f, "crop={w}:{h}:{x}:{y}"),
VideoFilter::Crop { w, h, .. } => write!(f, "crop={w}:{h}"),
VideoFilter::Pad { w, h, x: Some(x), y: Some(y) } => write!(f, "pad={w}:{h}:{x}:{y}"),
VideoFilter::Pad { w, h, .. } => write!(f, "pad={w}:{h}"),
VideoFilter::HFlip => write!(f, "hflip"),
VideoFilter::VFlip => write!(f, "vflip"),
VideoFilter::Rotate(d) => write!(f, "rotate={d}"),
VideoFilter::Grayscale => write!(f, "grayscale"),
VideoFilter::Overlay { image, x, y } => write!(f, "overlay={image}:{x}:{y}"),
VideoFilter::Invert => write!(f, "invert"),
VideoFilter::Brightness(b) => write!(f, "brightness={b}"),
VideoFilter::Contrast(c) => write!(f, "contrast={c}"),
VideoFilter::Saturation(s) => write!(f, "saturation={s}"),
}
}
}
pub fn chain_to_string(chain: &[VideoFilter]) -> String {
chain.iter().map(|f| f.to_string()).collect::<Vec<_>>().join(",")
}
#[cfg(feature = "serde")]
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[serde(untagged)]
pub enum FilterSpec {
Chain(String),
List(Vec<VideoFilter>),
}
#[cfg(feature = "serde")]
impl FilterSpec {
pub fn resolve(&self) -> Result<Vec<VideoFilter>> {
match self {
FilterSpec::Chain(s) => parse_chain(s),
FilterSpec::List(v) => parse_chain(&chain_to_string(v)),
}
}
pub fn to_chain(&self) -> String {
match self {
FilterSpec::Chain(s) => s.clone(),
FilterSpec::List(v) => chain_to_string(v),
}
}
}
pub fn parse_chain(s: &str) -> Result<Vec<VideoFilter>> {
let mut out = Vec::new();
for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) {
out.push(parse_one(part)?);
}
if out.is_empty() {
bail!("empty filter chain");
}
Ok(out)
}
fn parse_one(spec: &str) -> Result<VideoFilter> {
let (name, args) = match spec.split_once('=') {
Some((n, a)) => (n.trim(), a.trim()),
None => (spec.trim(), ""),
};
let parts: Vec<&str> = args.split(':').map(str::trim).filter(|s| !s.is_empty()).collect();
let nums = || -> Result<Vec<u32>> {
parts
.iter()
.map(|s| s.parse::<u32>().map_err(|_| anyhow::anyhow!("bad number '{s}' in '{spec}'")))
.collect()
};
let one_f32 = || -> Result<f32> {
parts
.first()
.ok_or_else(|| anyhow::anyhow!("'{name}' needs a value"))?
.parse::<f32>()
.map_err(|_| anyhow::anyhow!("bad number in '{spec}'"))
};
let f = match name {
"crop" => match nums()?.as_slice() {
[w, h] => VideoFilter::Crop { w: *w, h: *h, x: None, y: None },
[w, h, x, y] => VideoFilter::Crop { w: *w, h: *h, x: Some(*x), y: Some(*y) },
_ => bail!("crop wants W:H or W:H:X:Y, got '{args}'"),
},
"pad" => match nums()?.as_slice() {
[w, h] => VideoFilter::Pad { w: *w, h: *h, x: None, y: None },
[w, h, x, y] => VideoFilter::Pad { w: *w, h: *h, x: Some(*x), y: Some(*y) },
_ => bail!("pad wants W:H or W:H:X:Y, got '{args}'"),
},
"hflip" => VideoFilter::HFlip,
"vflip" => VideoFilter::VFlip,
"rotate" | "transpose" => {
let deg = if name == "transpose" {
90
} else {
*nums()?.first().unwrap_or(&90)
};
if !matches!(deg, 90 | 180 | 270) {
bail!("rotate wants 90|180|270, got {deg}");
}
VideoFilter::Rotate(deg)
}
"grayscale" | "gray" => VideoFilter::Grayscale,
"overlay" => {
let image = parts.first().ok_or_else(|| anyhow::anyhow!("overlay needs a PATH"))?.to_string();
let x = parts.get(1).map(|s| s.parse::<u32>()).transpose().map_err(|_| anyhow::anyhow!("bad overlay x in '{spec}'"))?.unwrap_or(0);
let y = parts.get(2).map(|s| s.parse::<u32>()).transpose().map_err(|_| anyhow::anyhow!("bad overlay y in '{spec}'"))?.unwrap_or(0);
VideoFilter::Overlay { image, x, y }
}
"invert" | "negate" => VideoFilter::Invert,
"brightness" => {
let b: i32 = parts.first().ok_or_else(|| anyhow::anyhow!("brightness needs a value"))?.parse().map_err(|_| anyhow::anyhow!("bad brightness in '{spec}'"))?;
VideoFilter::Brightness(b)
}
"contrast" => VideoFilter::Contrast(one_f32()?),
"saturation" => VideoFilter::Saturation(one_f32()?),
o => bail!("unknown filter '{o}'"),
};
Ok(f)
}
pub fn apply_chain(frame: VideoFrame, chain: &[VideoFilter]) -> Result<VideoFrame> {
let mut f = frame;
for filter in chain {
f = apply(&f, filter)?;
}
Ok(f)
}
fn bps(format: PixelFormat) -> Result<usize> {
match format {
PixelFormat::Yuv420p => Ok(1),
PixelFormat::Yuv420p10le => Ok(2),
other => bail!("video filters need Yuv420p / Yuv420p10le, got {other:?}"),
}
}
fn planes(frame: &VideoFrame, bps: usize) -> Result<(&[u8], &[u8], &[u8])> {
let w = frame.width as usize;
let h = frame.height as usize;
let y_len = w * h * bps;
let c_len = (w / 2) * (h / 2) * bps;
if frame.data.len() < y_len + 2 * c_len {
bail!("frame data too small: {} < {} for {}x{}", frame.data.len(), y_len + 2 * c_len, w, h);
}
let (y, rest) = frame.data.split_at(y_len);
let (u, v) = rest.split_at(c_len);
Ok((y, &u[..c_len], &v[..c_len]))
}
fn assemble(src: &VideoFrame, w: u32, h: u32, y: Vec<u8>, u: Vec<u8>, v: Vec<u8>) -> VideoFrame {
let mut data = BytesMut::with_capacity(y.len() + u.len() + v.len());
data.extend_from_slice(&y);
data.extend_from_slice(&u);
data.extend_from_slice(&v);
VideoFrame::new(data.freeze(), w, h, src.format, src.color_space, src.pts)
}
fn planes_8bit(frame: &VideoFrame, what: &str) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>)> {
if frame.format != PixelFormat::Yuv420p {
bail!("the `{what}` filter needs an 8-bit Yuv420p frame (got {:?}); it applies to SDR output", frame.format);
}
let (y, u, v) = planes(frame, 1)?;
Ok((y.to_vec(), u.to_vec(), v.to_vec()))
}
pub fn apply(frame: &VideoFrame, filter: &VideoFilter) -> Result<VideoFrame> {
let bps = bps(frame.format)?;
let w = frame.width as usize;
let h = frame.height as usize;
match filter {
VideoFilter::Crop { w: cw, h: ch, x, y: cy } => match (x, cy) {
(Some(x), Some(cy)) => crop(frame, *x, *cy, *cw, *ch),
_ => {
let cw = even((*cw).min(frame.width));
let ch = even((*ch).min(frame.height));
let cx = even(frame.width.saturating_sub(cw) / 2);
let cyc = even(frame.height.saturating_sub(ch) / 2);
crop(frame, cx, cyc, cw, ch)
}
},
VideoFilter::Pad { w: pw, h: ph, x, y: py } => {
let pw = even((*pw).max(frame.width));
let ph = even((*ph).max(frame.height));
let px = x.map(even).unwrap_or_else(|| even(pw.saturating_sub(frame.width) / 2));
let pyc = py.map(even).unwrap_or_else(|| even(ph.saturating_sub(frame.height) / 2));
pad(frame, pw, ph, px, pyc)
}
VideoFilter::HFlip | VideoFilter::VFlip | VideoFilter::Rotate(_) | VideoFilter::Grayscale => {
let (y, u, v) = planes(frame, bps)?;
geometric(frame, filter, y, u, v, w, h, bps)
}
VideoFilter::Invert => {
let (mut y, mut u, mut v) = planes_8bit(frame, "invert")?;
for b in y.iter_mut().chain(u.iter_mut()).chain(v.iter_mut()) {
*b = 255 - *b;
}
Ok(assemble(frame, frame.width, frame.height, y, u, v))
}
VideoFilter::Brightness(delta) => {
let (mut y, u, v) = planes_8bit(frame, "brightness")?;
for p in y.iter_mut() {
*p = (*p as i32 + delta).clamp(0, 255) as u8;
}
Ok(assemble(frame, frame.width, frame.height, y, u, v))
}
VideoFilter::Contrast(c) => {
let (mut y, u, v) = planes_8bit(frame, "contrast")?;
for p in y.iter_mut() {
*p = (((*p as f32 - 128.0) * c) + 128.0).round().clamp(0.0, 255.0) as u8;
}
Ok(assemble(frame, frame.width, frame.height, y, u, v))
}
VideoFilter::Saturation(s) => {
let (y, mut u, mut v) = planes_8bit(frame, "saturation")?;
for p in u.iter_mut().chain(v.iter_mut()) {
*p = (((*p as f32 - 128.0) * s) + 128.0).round().clamp(0.0, 255.0) as u8;
}
Ok(assemble(frame, frame.width, frame.height, y, u, v))
}
VideoFilter::Overlay { .. } => {
bail!("overlay is a resource filter — build a FilterChain::prepare(..) and call .apply()")
}
}
}
fn geometric(
frame: &VideoFrame,
filter: &VideoFilter,
y: &[u8],
u: &[u8],
v: &[u8],
w: usize,
h: usize,
bps: usize,
) -> Result<VideoFrame> {
Ok(match filter {
VideoFilter::HFlip => assemble(
frame, frame.width, frame.height,
hflip(y, w, h, bps), hflip(u, w / 2, h / 2, bps), hflip(v, w / 2, h / 2, bps),
),
VideoFilter::VFlip => assemble(
frame, frame.width, frame.height,
vflip(y, w, h, bps), vflip(u, w / 2, h / 2, bps), vflip(v, w / 2, h / 2, bps),
),
VideoFilter::Rotate(180) => assemble(
frame, frame.width, frame.height,
vflip(&hflip(y, w, h, bps), w, h, bps),
vflip(&hflip(u, w / 2, h / 2, bps), w / 2, h / 2, bps),
vflip(&hflip(v, w / 2, h / 2, bps), w / 2, h / 2, bps),
),
VideoFilter::Rotate(90) => assemble(
frame, frame.height, frame.width,
rot90(y, w, h, bps), rot90(u, w / 2, h / 2, bps), rot90(v, w / 2, h / 2, bps),
),
VideoFilter::Rotate(270) => assemble(
frame, frame.height, frame.width,
rot270(y, w, h, bps), rot270(u, w / 2, h / 2, bps), rot270(v, w / 2, h / 2, bps),
),
VideoFilter::Rotate(d) => bail!("rotate must be 90|180|270, got {d}"),
VideoFilter::Grayscale => {
let neutral = neutral_chroma(frame.format);
let mut uu = u.to_vec();
let mut vv = v.to_vec();
fill(&mut uu, &neutral);
fill(&mut vv, &neutral);
assemble(frame, frame.width, frame.height, y.to_vec(), uu, vv)
}
_ => unreachable!("geometric() called with a non-geometric filter"),
})
}
fn even(n: u32) -> u32 {
n & !1
}
fn crop(frame: &VideoFrame, x: u32, y: u32, w: u32, h: u32) -> Result<VideoFrame> {
let (x, y, w, h) = (even(x), even(y), even(w), even(h));
if w == 0 || h == 0 || x + w > frame.width || y + h > frame.height {
bail!("crop {w}x{h}+{x}+{y} out of bounds for {}x{}", frame.width, frame.height);
}
let bps = bps(frame.format)?;
let (yp, up, vp) = planes(frame, bps)?;
let fw = frame.width as usize;
let y_new = crop_plane(yp, fw, x as usize, y as usize, w as usize, h as usize, bps);
let u_new = crop_plane(up, fw / 2, (x / 2) as usize, (y / 2) as usize, (w / 2) as usize, (h / 2) as usize, bps);
let v_new = crop_plane(vp, fw / 2, (x / 2) as usize, (y / 2) as usize, (w / 2) as usize, (h / 2) as usize, bps);
Ok(assemble(frame, w, h, y_new, u_new, v_new))
}
fn pad(frame: &VideoFrame, pw: u32, ph: u32, x: u32, y: u32) -> Result<VideoFrame> {
let (pw, ph, x, y) = (even(pw), even(ph), even(x), even(y));
if x + frame.width > pw || y + frame.height > ph {
bail!("pad {pw}x{ph} with frame {}x{} at +{x}+{y} overflows", frame.width, frame.height);
}
let bps = bps(frame.format)?;
let (yp, up, vp) = planes(frame, bps)?;
let (luma_fill, chroma_fill) = black_fill(frame.format);
let fw = frame.width as usize;
let fh = frame.height as usize;
let y_new = pad_plane(yp, fw, fh, pw as usize, ph as usize, x as usize, y as usize, bps, &luma_fill);
let u_new = pad_plane(up, fw / 2, fh / 2, (pw / 2) as usize, (ph / 2) as usize, (x / 2) as usize, (y / 2) as usize, bps, &chroma_fill);
let v_new = pad_plane(vp, fw / 2, fh / 2, (pw / 2) as usize, (ph / 2) as usize, (x / 2) as usize, (y / 2) as usize, bps, &chroma_fill);
Ok(assemble(frame, pw, ph, y_new, u_new, v_new))
}
#[derive(Debug, Clone)]
struct PreparedOverlay {
w: usize,
h: usize,
x: usize,
y: usize,
y_o: Vec<u8>,
u_o: Vec<u8>,
v_o: Vec<u8>,
a_y: Vec<u8>, a_c: Vec<u8>, }
fn clamp8(v: i32) -> u8 {
v.clamp(0, 255) as u8
}
impl PreparedOverlay {
fn from_rgba(rgba: &[u8], src_w: u32, src_h: u32, x: u32, y: u32) -> Result<Self> {
let w = (src_w & !1) as usize; let h = (src_h & !1) as usize;
if w == 0 || h == 0 {
bail!("overlay image is too small ({src_w}x{src_h})");
}
let stride = src_w as usize * 4;
let mut y_o = vec![0u8; w * h];
let mut a_y = vec![0u8; w * h];
let (cw, ch) = (w / 2, h / 2);
let mut u_o = vec![0u8; cw * ch];
let mut v_o = vec![0u8; cw * ch];
let mut a_c = vec![0u8; cw * ch];
for r in 0..h {
for c in 0..w {
let p = r * stride + c * 4;
let (rr, gg, bb) = (rgba[p] as i32, rgba[p + 1] as i32, rgba[p + 2] as i32);
y_o[r * w + c] = clamp8(16 + ((47 * rr + 157 * gg + 16 * bb) >> 8));
a_y[r * w + c] = rgba[p + 3];
}
}
for r in 0..ch {
for c in 0..cw {
let (mut sr, mut sg, mut sb, mut sa) = (0i32, 0i32, 0i32, 0i32);
for dy in 0..2 {
for dx in 0..2 {
let p = (r * 2 + dy) * stride + (c * 2 + dx) * 4;
sr += rgba[p] as i32;
sg += rgba[p + 1] as i32;
sb += rgba[p + 2] as i32;
sa += rgba[p + 3] as i32;
}
}
let (rr, gg, bb) = (sr / 4, sg / 4, sb / 4);
u_o[r * cw + c] = clamp8(128 + ((-26 * rr - 87 * gg + 112 * bb) >> 8));
v_o[r * cw + c] = clamp8(128 + ((112 * rr - 102 * gg - 10 * bb) >> 8));
a_c[r * cw + c] = (sa / 4) as u8;
}
}
Ok(Self { w, h, x: (x & !1) as usize, y: (y & !1) as usize, y_o, u_o, v_o, a_y, a_c })
}
fn composite(&self, frame: &VideoFrame) -> Result<VideoFrame> {
let (mut y, mut u, mut v) = planes_8bit(frame, "overlay")?;
let (fw, fh) = (frame.width as usize, frame.height as usize);
for r in 0..self.h {
let fy = self.y + r;
if fy >= fh {
break;
}
for c in 0..self.w {
let fx = self.x + c;
if fx >= fw {
continue;
}
let a = self.a_y[r * self.w + c] as u32;
if a == 0 {
continue;
}
let i = fy * fw + fx;
y[i] = ((y[i] as u32 * (255 - a) + self.y_o[r * self.w + c] as u32 * a + 127) / 255) as u8;
}
}
let (cw, ch) = (self.w / 2, self.h / 2);
let (fcw, fch) = (fw / 2, fh / 2);
let (ocx, ocy) = (self.x / 2, self.y / 2);
for r in 0..ch {
let fy = ocy + r;
if fy >= fch {
break;
}
for c in 0..cw {
let fx = ocx + c;
if fx >= fcw {
continue;
}
let a = self.a_c[r * cw + c] as u32;
if a == 0 {
continue;
}
let i = fy * fcw + fx;
u[i] = ((u[i] as u32 * (255 - a) + self.u_o[r * cw + c] as u32 * a + 127) / 255) as u8;
v[i] = ((v[i] as u32 * (255 - a) + self.v_o[r * cw + c] as u32 * a + 127) / 255) as u8;
}
}
Ok(assemble(frame, frame.width, frame.height, y, u, v))
}
}
enum Step {
Plain(VideoFilter),
Overlay(PreparedOverlay),
}
pub struct FilterChain {
steps: Vec<Step>,
}
impl FilterChain {
pub fn prepare(filters: &[VideoFilter]) -> Result<Self> {
let mut steps = Vec::with_capacity(filters.len());
for f in filters {
match f {
VideoFilter::Overlay { image, x, y } => {
let img = image::ImageReader::open(image)
.with_context(|| format!("opening overlay image '{image}'"))?
.decode()
.with_context(|| format!("decoding overlay image '{image}'"))?
.to_rgba8();
let (w, h) = (img.width(), img.height());
steps.push(Step::Overlay(PreparedOverlay::from_rgba(img.as_raw(), w, h, *x, *y)?));
}
other => steps.push(Step::Plain(other.clone())),
}
}
Ok(Self { steps })
}
pub fn apply(&self, frame: VideoFrame) -> Result<VideoFrame> {
let mut f = frame;
for step in &self.steps {
f = match step {
Step::Plain(filt) => apply(&f, filt)?,
Step::Overlay(ov) => ov.composite(&f)?,
};
}
Ok(f)
}
pub fn is_empty(&self) -> bool {
self.steps.is_empty()
}
}
fn crop_plane(src: &[u8], pw: usize, x: usize, y: usize, cw: usize, ch: usize, bps: usize) -> Vec<u8> {
let mut out = Vec::with_capacity(cw * ch * bps);
for row in 0..ch {
let start = ((y + row) * pw + x) * bps;
out.extend_from_slice(&src[start..start + cw * bps]);
}
out
}
fn pad_plane(src: &[u8], sw: usize, sh: usize, dw: usize, dh: usize, ox: usize, oy: usize, bps: usize, fill_sample: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(dw * dh * bps);
for _ in 0..dw * dh {
out.extend_from_slice(fill_sample);
}
for row in 0..sh {
let s = row * sw * bps;
let d = ((oy + row) * dw + ox) * bps;
out[d..d + sw * bps].copy_from_slice(&src[s..s + sw * bps]);
}
out
}
fn hflip(src: &[u8], w: usize, h: usize, bps: usize) -> Vec<u8> {
let mut out = vec![0u8; w * h * bps];
for row in 0..h {
let base = row * w * bps;
for col in 0..w {
let s = base + col * bps;
let d = base + (w - 1 - col) * bps;
out[d..d + bps].copy_from_slice(&src[s..s + bps]);
}
}
out
}
fn vflip(src: &[u8], w: usize, h: usize, bps: usize) -> Vec<u8> {
let rb = w * bps;
let mut out = vec![0u8; w * h * bps];
for row in 0..h {
let s = row * rb;
let d = (h - 1 - row) * rb;
out[d..d + rb].copy_from_slice(&src[s..s + rb]);
}
out
}
fn rot90(src: &[u8], w: usize, h: usize, bps: usize) -> Vec<u8> {
let (dw, dh) = (h, w);
let mut out = vec![0u8; dw * dh * bps];
for r in 0..dh {
for c in 0..dw {
let s = ((h - 1 - c) * w + r) * bps;
let d = (r * dw + c) * bps;
out[d..d + bps].copy_from_slice(&src[s..s + bps]);
}
}
out
}
fn rot270(src: &[u8], w: usize, h: usize, bps: usize) -> Vec<u8> {
let (dw, dh) = (h, w);
let mut out = vec![0u8; dw * dh * bps];
for r in 0..dh {
for c in 0..dw {
let s = (c * w + (w - 1 - r)) * bps;
let d = (r * dw + c) * bps;
out[d..d + bps].copy_from_slice(&src[s..s + bps]);
}
}
out
}
fn fill(buf: &mut [u8], sample: &[u8]) {
for chunk in buf.chunks_exact_mut(sample.len()) {
chunk.copy_from_slice(sample);
}
}
fn neutral_chroma(format: PixelFormat) -> Vec<u8> {
match format {
PixelFormat::Yuv420p => vec![128],
_ => (512u16).to_le_bytes().to_vec(),
}
}
fn black_fill(format: PixelFormat) -> (Vec<u8>, Vec<u8>) {
match format {
PixelFormat::Yuv420p => (vec![16], vec![128]),
_ => ((64u16).to_le_bytes().to_vec(), (512u16).to_le_bytes().to_vec()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::frame::ColorSpace;
use bytes::Bytes;
fn frame(w: u32, h: u32) -> VideoFrame {
let (wu, hu) = (w as usize, h as usize);
let mut data = Vec::new();
for r in 0..hu {
for c in 0..wu {
data.push((r * wu + c) as u8);
}
}
data.extend(std::iter::repeat(100).take((wu / 2) * (hu / 2)));
data.extend(std::iter::repeat(200).take((wu / 2) * (hu / 2)));
VideoFrame::new(Bytes::from(data), w, h, PixelFormat::Yuv420p, ColorSpace::Bt709, 0)
}
fn flat(w: u32, h: u32, yv: u8, uv: u8, vv: u8) -> VideoFrame {
let (wu, hu) = (w as usize, h as usize);
let mut data = vec![yv; wu * hu];
data.extend(std::iter::repeat(uv).take((wu / 2) * (hu / 2)));
data.extend(std::iter::repeat(vv).take((wu / 2) * (hu / 2)));
VideoFrame::new(Bytes::from(data), w, h, PixelFormat::Yuv420p, ColorSpace::Bt709, 0)
}
fn luma(f: &VideoFrame) -> &[u8] {
&f.data[..(f.width * f.height) as usize]
}
#[test]
fn parse_and_display_round_trip() {
let c = parse_chain("crop=1280:720,hflip,overlay=logo.png:24:24,brightness=10,saturation=1.5,invert").unwrap();
assert_eq!(c[0], VideoFilter::Crop { w: 1280, h: 720, x: None, y: None });
assert_eq!(c[2], VideoFilter::Overlay { image: "logo.png".into(), x: 24, y: 24 });
assert_eq!(c[3], VideoFilter::Brightness(10));
assert_eq!(c[4], VideoFilter::Saturation(1.5));
assert_eq!(c[5], VideoFilter::Invert);
assert_eq!(chain_to_string(&c), "crop=1280:720,hflip,overlay=logo.png:24:24,brightness=10,saturation=1.5,invert");
assert_eq!(parse_chain("overlay=a.png").unwrap()[0], VideoFilter::Overlay { image: "a.png".into(), x: 0, y: 0 });
assert_eq!(parse_chain("negate").unwrap()[0], VideoFilter::Invert);
assert_eq!(parse_chain("contrast=1.2").unwrap()[0], VideoFilter::Contrast(1.2));
assert!(parse_chain("brightness=x").is_err());
assert!(parse_chain("rotate=45").is_err());
}
#[cfg(feature = "serde")]
#[test]
fn structured_json_round_trips() {
let json = r#"[{"crop":{"w":1280,"h":720}},"hflip",{"overlay":{"image":"logo.png","x":24,"y":24}},{"brightness":10},"invert"]"#;
let from_list: FilterSpec = serde_json::from_str(json).unwrap();
let expect = vec![
VideoFilter::Crop { w: 1280, h: 720, x: None, y: None },
VideoFilter::HFlip,
VideoFilter::Overlay { image: "logo.png".into(), x: 24, y: 24 },
VideoFilter::Brightness(10),
VideoFilter::Invert,
];
assert_eq!(from_list.resolve().unwrap(), expect);
assert_eq!(parse_chain(&chain_to_string(&expect)).unwrap(), expect);
}
#[test]
fn hflip_reverses_rows() {
let out = apply(&frame(4, 2), &VideoFilter::HFlip).unwrap();
assert_eq!(&luma(&out)[..4], &[3, 2, 1, 0]);
}
#[test]
fn rotate_dims_and_roundtrip() {
let f = frame(4, 2);
let r90 = apply(&f, &VideoFilter::Rotate(90)).unwrap();
assert_eq!((r90.width, r90.height), (2, 4));
let back = apply(&r90, &VideoFilter::Rotate(270)).unwrap();
assert_eq!(luma(&back), luma(&f));
assert!(apply(&f, &VideoFilter::Rotate(45)).is_err());
}
#[test]
fn color_filters() {
let b = apply(&flat(4, 4, 100, 128, 128), &VideoFilter::Brightness(20)).unwrap();
assert!(luma(&b).iter().all(|&p| p == 120));
let inv = apply(&flat(2, 2, 100, 128, 128), &VideoFilter::Invert).unwrap();
assert_eq!(luma(&inv)[0], 155);
assert_eq!(inv.data[4], 127);
let s0 = apply(&flat(4, 4, 100, 200, 60), &VideoFilter::Saturation(0.0)).unwrap();
assert!(s0.data[16..].iter().all(|&p| p == 128));
let ten = VideoFrame::new(Bytes::from(vec![0u8; 2 * (4 * 4 + 2 * 4)]), 4, 4, PixelFormat::Yuv420p10le, ColorSpace::Bt709, 0);
assert!(apply(&ten, &VideoFilter::Brightness(10)).is_err());
}
#[test]
fn overlay_composites_with_alpha() {
let red = [255u8, 0, 0, 255];
let clear = [0u8, 0, 0, 0];
let mut rgba = Vec::new();
rgba.extend_from_slice(&red);
rgba.extend_from_slice(&red);
rgba.extend_from_slice(&clear);
rgba.extend_from_slice(&clear);
let ov = PreparedOverlay::from_rgba(&rgba, 2, 2, 0, 0).unwrap();
let base = flat(4, 4, 100, 128, 128);
let out = ov.composite(&base).unwrap();
let y = luma(&out);
assert!(y[0] > 50 && y[0] < 90, "opaque red luma was {}", y[0]);
assert_eq!(y[2 * 4], 100);
assert_eq!(y[2], 100);
}
#[test]
fn overlay_via_apply_errors_without_prepare() {
let r = apply(&flat(4, 4, 100, 128, 128), &VideoFilter::Overlay { image: "x.png".into(), x: 0, y: 0 });
assert!(r.is_err());
}
#[test]
fn filter_chain_prepare_missing_image_errors() {
let r = FilterChain::prepare(&[VideoFilter::Overlay { image: "/nope/missing.png".into(), x: 0, y: 0 }]);
assert!(r.is_err());
}
#[test]
fn filter_chain_applies_stateless() {
let chain = FilterChain::prepare(&[VideoFilter::HFlip, VideoFilter::Brightness(10)]).unwrap();
assert!(!chain.is_empty());
let out = chain.apply(frame(4, 2)).unwrap();
assert_eq!((out.width, out.height), (4, 2));
}
#[test]
fn ten_bit_geometric_still_works() {
let mut data: Vec<u8> = Vec::new();
for s in [0u16, 1, 2, 3] {
data.extend_from_slice(&s.to_le_bytes());
}
data.extend_from_slice(&(512u16).to_le_bytes());
data.extend_from_slice(&(512u16).to_le_bytes());
let f = VideoFrame::new(Bytes::from(data), 2, 2, PixelFormat::Yuv420p10le, ColorSpace::Bt709, 0);
let out = apply(&f, &VideoFilter::HFlip).unwrap();
assert_eq!(&out.data[0..2], &1u16.to_le_bytes());
}
}