mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-13 23:17:30 +00:00
* Release prep: bump to 0.8.0 and add a Linux riscv64 release build - release.yml: new `linux-riscv64` job, ported from GridCraft. It cross-compiles on ubuntu-24.04 against riscv64 libraries from ports.ubuntu.com (the Linux job's X11/Wayland/GL -dev packages plus ALSA), ships effectcraft-<v>-linux-riscv64.tar.gz, and smoke-tests effectcraft-cli under qemu-riscv64. The draft-release job now needs it, so its tarball is uploaded and covered by SHA256SUMS.txt. - packaging/linux/package.sh: cross-compiles when CROSS_ARCH/CROSS_TARGET/CROSS_COMPILE are set (cargo --target, the cross strip, the EMULATOR smoke test), as GridCraft's does. - Version 0.7.0 -> 0.8.0 (cargo xtask version set), Cargo.lock refreshed. - README and docs/releasing.md list the riscv64 tarball. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fix FreeBSD CI: pointer_wrap test harness, wasm32 target, clippy as_chunks - pointer_wrap tests: the harnesses dropped run_ui's FullOutput with the font atlas upload still in textures_delta, and epaint 0.36's drop check panics on that in debug builds. Every pointer_wrap test failed on every platform; FreeBSD is the only CI that runs them. Clear the delta, as the other UI tests do. - freebsd.yml: install wasm32-unknown-unknown in the VM. Without it the host plug-in test's build of examples/plugins/posterize-bands failed (E0463, no std) and the test skipped silently; now it builds and runs. - media/convert.rs: chunks_exact(2) -> as_chunks::<2>() (clippy chunks_exact_to_as_chunks, new in Rust 1.99); same pairs, same dropped remainder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
515 lines
22 KiB
Rust
515 lines
22 KiB
Rust
//! Decoded pictures → compositor [`Image`]s (premultiplied f32 RGBA, sRGB/Rec.709-encoded values).
|
||
//!
|
||
//! Y'CbCr is converted with the matrix and range the stream signals (BT.601 / BT.709 / BT.2020,
|
||
//! limited or full), upsampling chroma bilinearly with centred siting (like ffmpeg's swscale,
|
||
//! which is also how most tools subsample when encoding).
|
||
//! Values stay gamma-encoded: like After Effects, footage pixels enter the working space as they
|
||
//! are encoded in the file. Alpha follows the footage's [`AlphaMode`] interpretation.
|
||
|
||
use effectcraft_project::{AlphaMode, Footage};
|
||
use effectcraft_raster::{Image, Px};
|
||
use filmcraft_color::{Matrix, Range};
|
||
use filmcraft_frame::{Chroma, PixelData, VideoFrame};
|
||
use rayon::prelude::*;
|
||
|
||
/// How to turn a (colour, alpha) sample into a premultiplied pixel.
|
||
#[derive(Clone, Copy)]
|
||
pub(crate) struct AlphaOp {
|
||
mode: AlphaMode,
|
||
matte: [f32; 3],
|
||
/// Interpret Footage ▸ Preserve RGB: float values stay as the file stores them (not
|
||
/// sRGB-encoded).
|
||
preserve_rgb: bool,
|
||
}
|
||
|
||
impl AlphaOp {
|
||
pub(crate) fn new(mode: AlphaMode, matte: [f32; 3]) -> Self {
|
||
Self { mode, matte, preserve_rgb: false }
|
||
}
|
||
/// The interpretation of footage `f`.
|
||
pub(crate) fn of(f: &Footage) -> Self {
|
||
Self { preserve_rgb: f.preserve_rgb, ..Self::new(f.alpha, f.premul_color) }
|
||
}
|
||
/// A linear float value as the compositor takes it: sRGB-encoded, or the file's own value
|
||
/// under Preserve RGB.
|
||
#[inline(always)]
|
||
fn float(self, v: f32) -> f32 {
|
||
if self.preserve_rgb { v } else { encode(v) }
|
||
}
|
||
/// `c` is the file's colour (straight or premultiplied as the mode says), `a` its alpha.
|
||
#[inline(always)]
|
||
fn apply(self, c: [f32; 3], a: f32) -> Px {
|
||
match self.mode {
|
||
AlphaMode::Ignore => [c[0], c[1], c[2], 1.0],
|
||
AlphaMode::Straight => [c[0] * a, c[1] * a, c[2] * a, a],
|
||
AlphaMode::Premultiplied => {
|
||
// premultiplied against a matte colour m: c = s·a + m·(1−a) → s·a = c − m·(1−a)
|
||
let k = 1.0 - a;
|
||
let m = self.matte;
|
||
[(c[0] - m[0] * k).max(0.0), (c[1] - m[1] * k).max(0.0), (c[2] - m[2] * k).max(0.0), a]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Convert a FilmCraft frame to an [`Image`].
|
||
#[cfg(test)]
|
||
pub(crate) fn frame_to_image(f: &VideoFrame, op: AlphaOp) -> Image {
|
||
frame_to_image_in(f, op, Vec::new())
|
||
}
|
||
|
||
/// Convert a FilmCraft frame to an [`Image`], reusing `buf`'s allocation when it is large enough
|
||
/// (every pixel is overwritten, so a recycled frame's contents do not matter).
|
||
pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec<Px>) -> Image {
|
||
let (w, h) = (f.width as usize, f.height as usize);
|
||
if buf.len() != w * h {
|
||
buf = vec![[0.0; 4]; w * h];
|
||
}
|
||
let mut img = Image { width: f.width, height: f.height, data: buf };
|
||
if w == 0 || h == 0 {
|
||
return img;
|
||
}
|
||
match &f.data {
|
||
PixelData::Rgba8(d) => {
|
||
img.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
|
||
let src = &d[y * w * 4..(y + 1) * w * 4];
|
||
for (o, s) in row.iter_mut().zip(src.as_chunks::<4>().0.iter()) {
|
||
*o = op.apply([U8[s[0] as usize], U8[s[1] as usize], U8[s[2] as usize]], U8[s[3] as usize]);
|
||
}
|
||
});
|
||
}
|
||
PixelData::RgbaF32(d) => {
|
||
// linear premultiplied → encoded premultiplied
|
||
img.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
|
||
let src = &d[y * w * 4..(y + 1) * w * 4];
|
||
for (o, s) in row.iter_mut().zip(src.as_chunks::<4>().0.iter()) {
|
||
let a = s[3];
|
||
let inv = if a > 0.0 { 1.0 / a } else { 0.0 };
|
||
let c = [op.float(s[0] * inv) * a, op.float(s[1] * inv) * a, op.float(s[2] * inv) * a];
|
||
*o = if op.mode == AlphaMode::Ignore { [c[0], c[1], c[2], 1.0] } else { [c[0], c[1], c[2], a] };
|
||
}
|
||
});
|
||
}
|
||
PixelData::Yuv8 { planes, chroma, alpha } => {
|
||
let yuv = Yuv::new(f.color.matrix, f.color.range, 8);
|
||
let amax = 255.0;
|
||
convert_yuv(&mut img, w, h, *chroma, [&planes[0][..], &planes[1][..], &planes[2][..]], alpha.as_deref().map(|a| &a[..]), amax, &yuv, op);
|
||
}
|
||
PixelData::Yuv16 { planes, chroma, bits, alpha } => {
|
||
let yuv = Yuv::new(f.color.matrix, f.color.range, *bits);
|
||
let amax = ((1u32 << *bits) - 1) as f32;
|
||
convert_yuv(&mut img, w, h, *chroma, [&planes[0][..], &planes[1][..], &planes[2][..]], alpha.as_deref().map(|a| &a[..]), amax, &yuv, op);
|
||
}
|
||
}
|
||
img
|
||
}
|
||
|
||
/// A FilmCraft frame for a render that shows it at `w` × `h`, at most half its size each way:
|
||
/// what `resample(&frame_to_image(f, op), w, h)` makes, converting a quarter of the pixels.
|
||
/// Opaque 4:2:0 Y'CbCr is box-filtered to half size before conversion (each 2×2 block of luma,
|
||
/// and the [1 6 1]/8 neighbourhood of its chroma sample: the mean of the upsampled chroma its
|
||
/// pixels get), then resampled the rest of the way. Conversion is linear until it clips, so
|
||
/// this is the RGB box filter `resample` starts with, except in blocks where some colours clip.
|
||
/// `None` for other frames.
|
||
pub(crate) fn frame_to_image_scaled(f: &VideoFrame, w: u32, h: u32) -> Option<Image> {
|
||
let (fw, fh) = (f.width as usize, f.height as usize);
|
||
if w == 0 || h == 0 || fw % 2 != 0 || fh % 2 != 0 || (w as usize).saturating_mul(2) > fw || (h as usize).saturating_mul(2) > fh {
|
||
return None;
|
||
}
|
||
let half = match &f.data {
|
||
PixelData::Yuv8 { planes, chroma: Chroma::C420, alpha: None } => {
|
||
half_420(fw, fh, [&planes[0][..], &planes[1][..], &planes[2][..]], &Yuv::new(f.color.matrix, f.color.range, 8))?
|
||
}
|
||
PixelData::Yuv16 { planes, chroma: Chroma::C420, bits, alpha: None } => {
|
||
half_420(fw, fh, [&planes[0][..], &planes[1][..], &planes[2][..]], &Yuv::new(f.color.matrix, f.color.range, *bits))?
|
||
}
|
||
_ => return None,
|
||
};
|
||
Some(if (half.width, half.height) == (w, h) { half } else { effectcraft_raster::resample(&half, w, h) })
|
||
}
|
||
|
||
/// An opaque 4:2:0 frame of even size `w` × `h` converted at half that size (see
|
||
/// [`frame_to_image_scaled`]); `None` when the planes are too short.
|
||
fn half_420<T: Sample>(w: usize, h: usize, [luma, cb, cr]: [&[T]; 3], k: &Yuv) -> Option<Image> {
|
||
/// Row `r` of a plane `n` samples wide (empty past its end).
|
||
fn row_of<T>(p: &[T], r: usize, n: usize) -> &[T] {
|
||
p.get(r.saturating_mul(n)..(r + 1).saturating_mul(n)).unwrap_or_default()
|
||
}
|
||
let (cw, ch) = (w / 2, h / 2);
|
||
if luma.len() < w.checked_mul(h)? || cb.len() < cw * ch || cr.len() < cw * ch {
|
||
return None;
|
||
}
|
||
let mut img = Image::new(cw as u32, ch as u32);
|
||
img.data.par_chunks_mut(cw).enumerate().for_each_init(
|
||
// Chroma rows with their edge samples repeated on both ends, for the horizontal [1 6 1].
|
||
|| (vec![0f32; cw + 2], vec![0f32; cw + 2]),
|
||
|(uc, vc), (cy, row)| {
|
||
// The chroma rows above and below weigh 1/8, as `centred` upsampling averages out.
|
||
let rows = [cy.saturating_sub(1), cy, (cy + 1).min(ch - 1)];
|
||
for (dst, plane) in [(&mut *uc, cb), (&mut *vc, cr)] {
|
||
let [above, at, below] = rows.map(|r| row_of(plane, r, cw));
|
||
for (d, ((a, b), c)) in dst[1..=cw].iter_mut().zip(above.iter().zip(at).zip(below)) {
|
||
*d = ((a.f() + c.f()) * 0.125 + b.f() * 0.75 - k.c_off) * k.c_scale;
|
||
}
|
||
(dst[0], dst[cw + 1]) = (dst[1], dst[cw]);
|
||
}
|
||
let (top, bottom) = (row_of(luma, 2 * cy, w), row_of(luma, 2 * cy + 1, w));
|
||
let pixels = top.as_chunks::<2>().0.iter().zip(bottom.as_chunks::<2>().0).zip(uc.windows(3).zip(vc.windows(3)));
|
||
for (o, ((a, b), (u, v))) in row.iter_mut().zip(pixels) {
|
||
let (u, v) = ((u[0] + u[2]) * 0.125 + u[1] * 0.75, (v[0] + v[2]) * 0.125 + v[1] * 0.75);
|
||
let yy = ((a[0].f() + a[1].f() + b[0].f() + b[1].f()) * 0.25 - k.y_off) * k.y_scale;
|
||
*o = [(yy + k.cr_r * v).clamp(0.0, 1.0), (yy - k.cr_g * v - k.cb_g * u).clamp(0.0, 1.0), (yy + k.cb_b * u).clamp(0.0, 1.0), 1.0];
|
||
}
|
||
},
|
||
);
|
||
Some(img)
|
||
}
|
||
|
||
/// 0..255 → 0..1.
|
||
static U8: [f32; 256] = {
|
||
let mut t = [0f32; 256];
|
||
let mut i = 0;
|
||
while i < 256 {
|
||
t[i] = i as f32 / 255.0;
|
||
i += 1;
|
||
}
|
||
t
|
||
};
|
||
|
||
/// Linear → sRGB-encoded (sign-preserving, unclamped above 1 for float footage).
|
||
#[inline]
|
||
fn encode(v: f32) -> f32 {
|
||
if v < 0.0 {
|
||
-encode(-v)
|
||
} else if v <= 0.003_130_8 {
|
||
v * 12.92
|
||
} else {
|
||
1.055 * v.powf(1.0 / 2.4) - 0.055
|
||
}
|
||
}
|
||
|
||
/// Integer code values → normalised Y'CbCr and the matrix coefficients.
|
||
struct Yuv {
|
||
y_scale: f32,
|
||
y_off: f32,
|
||
c_scale: f32,
|
||
c_off: f32,
|
||
cr_r: f32,
|
||
cr_g: f32,
|
||
cb_g: f32,
|
||
cb_b: f32,
|
||
}
|
||
|
||
impl Yuv {
|
||
fn new(m: Matrix, range: Range, bits: u32) -> Self {
|
||
let s = (1u32 << (bits - 8)) as f32;
|
||
let (y_scale, y_off, c_scale, c_off) = match range {
|
||
Range::Limited => (1.0 / (219.0 * s), 16.0 * s, 1.0 / (224.0 * s), 128.0 * s),
|
||
Range::Full => {
|
||
let max = ((1u32 << bits) - 1) as f32;
|
||
(1.0 / max, 0.0, 1.0 / max, (1u32 << (bits - 1)) as f32)
|
||
}
|
||
};
|
||
let (kr, kb) = m.kr_kb();
|
||
let kg = 1.0 - kr - kb;
|
||
let cr_r = 2.0 * (1.0 - kr);
|
||
let cb_b = 2.0 * (1.0 - kb);
|
||
Self { y_scale, y_off, c_scale, c_off, cr_r, cr_g: cr_r * kr / kg, cb_g: cb_b * kb / kg, cb_b }
|
||
}
|
||
}
|
||
|
||
trait Sample: Copy + Send + Sync {
|
||
fn f(self) -> f32;
|
||
}
|
||
impl Sample for u8 {
|
||
#[inline(always)]
|
||
fn f(self) -> f32 {
|
||
self as f32
|
||
}
|
||
}
|
||
impl Sample for u16 {
|
||
#[inline(always)]
|
||
fn f(self) -> f32 {
|
||
self as f32
|
||
}
|
||
}
|
||
|
||
/// Centred chroma siting (as ffmpeg's swscale and JPEG): for a subsampled axis, full-resolution
|
||
/// sample `i` lies ¼ of a chroma sample from chroma sample `i/2` towards its neighbour on that
|
||
/// side. Returns (nearest chroma index, neighbour index); the weights are ¾ and ¼.
|
||
#[inline(always)]
|
||
fn centred(i: usize, n: usize) -> (usize, usize) {
|
||
let c = (i >> 1).min(n - 1);
|
||
let o = if i & 1 == 0 { c.saturating_sub(1) } else { (c + 1).min(n - 1) };
|
||
(c, o)
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn convert_yuv<T: Sample>(img: &mut Image, w: usize, h: usize, chroma: Chroma, planes: [&[T]; 3], alpha: Option<&[T]>, amax: f32, k: &Yuv, op: AlphaOp) {
|
||
let (sx, sy) = chroma.shifts();
|
||
let cw = w.div_ceil(1 << sx);
|
||
let ch = h.div_ceil(1 << sy);
|
||
let inv_amax = 1.0 / amax;
|
||
img.data.par_chunks_mut(w).enumerate().for_each_init(
|
||
|| (vec![0f32; cw], vec![0f32; cw], vec![0f32; w], vec![0f32; w]),
|
||
|(uc, vc, uf, vf), (y, row)| {
|
||
// vertical chroma interpolation into normalised rows (Cb, Cr in −0.5..0.5)
|
||
let (r0, r1, t) = if sy == 1 {
|
||
let (c, o) = centred(y, ch);
|
||
(c, o, 0.25)
|
||
} else {
|
||
(y, y, 0.0)
|
||
};
|
||
for (dst, plane) in [(&mut *uc, planes[1]), (&mut *vc, planes[2])] {
|
||
let a = &plane[r0 * cw..r0 * cw + cw];
|
||
let b = &plane[r1 * cw..r1 * cw + cw];
|
||
for ((d, a), b) in dst.iter_mut().zip(a).zip(b) {
|
||
let v = a.f() * (1.0 - t) + b.f() * t;
|
||
*d = (v - k.c_off) * k.c_scale;
|
||
}
|
||
}
|
||
// horizontal
|
||
if sx == 1 {
|
||
for x in 0..w {
|
||
let (c, o) = centred(x, cw);
|
||
uf[x] = uc[c] * 0.75 + uc[o] * 0.25;
|
||
vf[x] = vc[c] * 0.75 + vc[o] * 0.25;
|
||
}
|
||
} else {
|
||
uf.copy_from_slice(&uc[..w]);
|
||
vf.copy_from_slice(&vc[..w]);
|
||
}
|
||
let yrow = &planes[0][y * w..y * w + w];
|
||
let rgb = |x: usize| {
|
||
let (u, v) = (uf[x], vf[x]);
|
||
let yy = (yrow[x].f() - k.y_off) * k.y_scale;
|
||
[(yy + k.cr_r * v).clamp(0.0, 1.0), (yy - k.cr_g * v - k.cb_g * u).clamp(0.0, 1.0), (yy + k.cb_b * u).clamp(0.0, 1.0)]
|
||
};
|
||
match alpha {
|
||
// opaque: every alpha interpretation gives the colour itself
|
||
None => {
|
||
for (x, o) in row.iter_mut().enumerate() {
|
||
let c = rgb(x);
|
||
*o = [c[0], c[1], c[2], 1.0];
|
||
}
|
||
}
|
||
Some(al) => {
|
||
let arow = &al[y * w..y * w + w];
|
||
for (x, o) in row.iter_mut().enumerate() {
|
||
*o = op.apply(rgb(x), (arow[x].f() * inv_amax).clamp(0.0, 1.0));
|
||
}
|
||
}
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
/// Convert a still decoded by the `image` crate. Float images (OpenEXR, float TIFF) hold linear
|
||
/// light and are sRGB-encoded here (values above 1 are kept), unless the footage preserves RGB.
|
||
pub(crate) fn dynamic_to_image(img: &image::DynamicImage, op: AlphaOp) -> Image {
|
||
use image::DynamicImage as D;
|
||
let (w, h) = (img.width(), img.height());
|
||
let mut out = Image::new(w, h);
|
||
let wu = w as usize;
|
||
if wu == 0 || h == 0 {
|
||
return out;
|
||
}
|
||
match img {
|
||
D::ImageRgb32F(_) | D::ImageRgba32F(_) => {
|
||
let px = img.to_rgba32f();
|
||
let raw = px.as_raw();
|
||
let premul = op.mode == AlphaMode::Premultiplied;
|
||
out.data.par_chunks_mut(wu).enumerate().for_each(|(y, row)| {
|
||
for (x, o) in row.iter_mut().enumerate() {
|
||
let s = &raw[(y * wu + x) * 4..(y * wu + x) * 4 + 4];
|
||
let a = s[3];
|
||
let c = if premul {
|
||
let inv = if a > 0.0 { 1.0 / a } else { 0.0 };
|
||
[op.float(s[0] * inv) * a, op.float(s[1] * inv) * a, op.float(s[2] * inv) * a]
|
||
} else {
|
||
[op.float(s[0]), op.float(s[1]), op.float(s[2])]
|
||
};
|
||
*o = op.apply(c, a.clamp(0.0, 1.0));
|
||
}
|
||
});
|
||
}
|
||
D::ImageLuma16(_) | D::ImageLumaA16(_) | D::ImageRgb16(_) | D::ImageRgba16(_) => {
|
||
let px = img.to_rgba16();
|
||
let raw = px.as_raw();
|
||
let k = 1.0 / 65535.0;
|
||
out.data.par_chunks_mut(wu).enumerate().for_each(|(y, row)| {
|
||
for (x, o) in row.iter_mut().enumerate() {
|
||
let s = &raw[(y * wu + x) * 4..(y * wu + x) * 4 + 4];
|
||
*o = op.apply([s[0] as f32 * k, s[1] as f32 * k, s[2] as f32 * k], s[3] as f32 * k);
|
||
}
|
||
});
|
||
}
|
||
_ => {
|
||
let px = img.to_rgba8();
|
||
let raw = px.as_raw();
|
||
out.data.par_chunks_mut(wu).enumerate().for_each(|(y, row)| {
|
||
for (x, o) in row.iter_mut().enumerate() {
|
||
let s = &raw[(y * wu + x) * 4..(y * wu + x) * 4 + 4];
|
||
*o = op.apply([U8[s[0] as usize], U8[s[1] as usize], U8[s[2] as usize]], U8[s[3] as usize]);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Straight RGBA `f32` pixels (layered stills: Photoshop, SVG) to an [`Image`].
|
||
pub(crate) fn straight_to_image(w: u32, h: u32, px: &[[f32; 4]], op: AlphaOp) -> Image {
|
||
let mut out = Image::new(w, h);
|
||
let wu = w as usize;
|
||
if wu == 0 || h == 0 {
|
||
return out;
|
||
}
|
||
out.data.par_chunks_mut(wu).enumerate().for_each(|(y, row)| {
|
||
for (x, o) in row.iter_mut().enumerate() {
|
||
let s = px[y * wu + x];
|
||
*o = op.apply([s[0], s[1], s[2]], s[3].clamp(0.0, 1.0));
|
||
}
|
||
});
|
||
out
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::sync::Arc;
|
||
|
||
fn yuv_frame(y: u8, u: u8, v: u8, color: filmcraft_color::ColorInfo) -> VideoFrame {
|
||
VideoFrame {
|
||
width: 4,
|
||
height: 2,
|
||
data: PixelData::Yuv8 { planes: [Arc::new(vec![y; 8]), Arc::new(vec![u; 2]), Arc::new(vec![v; 2])], chroma: Chroma::C420, alpha: None },
|
||
color,
|
||
par: (1, 1),
|
||
pts: filmcraft_time::Tick::ZERO,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn limited_range_grey_and_white() {
|
||
let op = AlphaOp::new(AlphaMode::Straight, [0.0; 3]);
|
||
let img = frame_to_image(&yuv_frame(235, 128, 128, filmcraft_color::ColorInfo::REC709), op);
|
||
assert!(img.data.iter().all(|p| (p[0] - 1.0).abs() < 1e-5 && (p[1] - 1.0).abs() < 1e-5 && p[3] == 1.0));
|
||
let img = frame_to_image(&yuv_frame(126, 128, 128, filmcraft_color::ColorInfo::REC709), op);
|
||
assert!((img.data[0][0] - 110.0 / 219.0).abs() < 1e-5);
|
||
}
|
||
|
||
/// A smooth opaque 4:2:0 frame whose colours never clip.
|
||
fn smooth_420(w: usize, h: usize) -> VideoFrame {
|
||
let plane = |w: usize, h: usize, base: f32, amp: f32, fx: f32, fy: f32| -> Arc<Vec<u8>> {
|
||
Arc::new((0..w * h).map(|i| (base + amp * ((i % w) as f32 * fx + (i / w) as f32 * fy).sin()) as u8).collect())
|
||
};
|
||
VideoFrame {
|
||
width: w as u32,
|
||
height: h as u32,
|
||
data: PixelData::Yuv8 {
|
||
planes: [plane(w, h, 125.0, 45.0, 0.31, 0.17), plane(w / 2, h / 2, 128.0, 10.0, 0.4, -0.3), plane(w / 2, h / 2, 128.0, 10.0, -0.2, 0.5)],
|
||
chroma: Chroma::C420,
|
||
alpha: None,
|
||
},
|
||
color: filmcraft_color::ColorInfo::REC709,
|
||
par: (1, 1),
|
||
pts: filmcraft_time::Tick::ZERO,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn scaled_conversion_matches_resampling_the_converted_frame() {
|
||
let f = smooth_420(96, 64);
|
||
let full = frame_to_image(&f, AlphaOp::new(AlphaMode::Straight, [0.0; 3]));
|
||
for (w, h) in [(48, 32), (32, 21), (24, 16), (13, 9)] {
|
||
let scaled = frame_to_image_scaled(&f, w, h).expect("4:2:0, at most half size");
|
||
let want = effectcraft_raster::resample(&full, w, h);
|
||
assert_eq!((scaled.width, scaled.height), (w, h));
|
||
let worst = scaled.data.iter().zip(&want.data).flat_map(|(a, b)| (0..4).map(move |c| (a[c] - b[c]).abs())).fold(0.0f32, f32::max);
|
||
assert!(worst < 1e-5, "{w}×{h}: {worst}");
|
||
}
|
||
// More than half the size, odd sizes, other chroma layouts: convert the full frame.
|
||
assert!(frame_to_image_scaled(&f, 49, 32).is_none());
|
||
assert!(frame_to_image_scaled(&smooth_420(95, 64), 40, 30).is_none());
|
||
assert!(frame_to_image_scaled(&yuv_frame(100, 128, 128, filmcraft_color::ColorInfo::REC709), 2, 1).is_some());
|
||
let mut f444 = smooth_420(96, 64);
|
||
if let PixelData::Yuv8 { chroma, .. } = &mut f444.data {
|
||
*chroma = Chroma::C444;
|
||
}
|
||
assert!(frame_to_image_scaled(&f444, 48, 32).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn bt709_red() {
|
||
// 8-bit limited BT.709 red: Y 63, Cb 102, Cr 240
|
||
let op = AlphaOp::new(AlphaMode::Straight, [0.0; 3]);
|
||
let p = frame_to_image(&yuv_frame(63, 102, 240, filmcraft_color::ColorInfo::REC709), op).data[0];
|
||
assert!(p[0] > 0.98 && p[1] < 0.02 && p[2] < 0.02, "{p:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn alpha_modes() {
|
||
let c = [0.8, 0.4, 0.2];
|
||
let s = AlphaOp::new(AlphaMode::Straight, [0.0; 3]).apply(c, 0.5);
|
||
assert_eq!(s, [0.4, 0.2, 0.1, 0.5]);
|
||
let p = AlphaOp::new(AlphaMode::Premultiplied, [0.0; 3]).apply([0.4, 0.2, 0.1], 0.5);
|
||
assert_eq!(p, [0.4, 0.2, 0.1, 0.5]);
|
||
// premultiplied against white: c = s·a + 1·(1−a)
|
||
let p = AlphaOp::new(AlphaMode::Premultiplied, [1.0; 3]).apply([0.9, 0.7, 0.6], 0.5);
|
||
assert!((p[0] - 0.4).abs() < 1e-6 && (p[2] - 0.1).abs() < 1e-6);
|
||
let i = AlphaOp::new(AlphaMode::Ignore, [0.0; 3]).apply(c, 0.0);
|
||
assert_eq!(i, [0.8, 0.4, 0.2, 1.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn float_stills_are_encoded() {
|
||
let img = image::DynamicImage::ImageRgba32F(image::ImageBuffer::from_raw(1, 1, vec![0.2140, 1.0, 4.0, 1.0]).expect("buf"));
|
||
let p = dynamic_to_image(&img, AlphaOp::new(AlphaMode::Premultiplied, [0.0; 3])).data[0];
|
||
assert!((p[0] - 0.5).abs() < 1e-3 && (p[1] - 1.0).abs() < 1e-5 && p[2] > 1.5, "{p:?}");
|
||
}
|
||
|
||
/// `cargo test -p effectcraft-media --lib bench -- --ignored --nocapture`
|
||
#[test]
|
||
#[ignore]
|
||
fn bench_convert_1080p() {
|
||
let (w, h) = (1920usize, 1080usize);
|
||
let f = VideoFrame {
|
||
width: w as u32,
|
||
height: h as u32,
|
||
data: PixelData::Yuv8 {
|
||
planes: [Arc::new((0..w * h).map(|i| (i % 251) as u8).collect()), Arc::new(vec![100; w * h / 4]), Arc::new(vec![150; w * h / 4])],
|
||
chroma: Chroma::C420,
|
||
alpha: None,
|
||
},
|
||
color: filmcraft_color::ColorInfo::REC709,
|
||
par: (1, 1),
|
||
pts: filmcraft_time::Tick::ZERO,
|
||
};
|
||
let op = AlphaOp::new(AlphaMode::Straight, [0.0; 3]);
|
||
for _ in 0..3 {
|
||
let t = std::time::Instant::now();
|
||
for _ in 0..20 {
|
||
std::hint::black_box(Image::new(1920, 1080));
|
||
}
|
||
eprintln!("alloc {:?}", t.elapsed() / 20);
|
||
let t = std::time::Instant::now();
|
||
for _ in 0..20 {
|
||
std::hint::black_box(frame_to_image(&f, op));
|
||
}
|
||
eprintln!("convert {:?}", t.elapsed() / 20);
|
||
let mut keep = Vec::new();
|
||
let t = std::time::Instant::now();
|
||
for _ in 0..20 {
|
||
keep.push(frame_to_image(&f, op));
|
||
}
|
||
eprintln!("convert into fresh memory {:?}", t.elapsed() / 20);
|
||
let t = std::time::Instant::now();
|
||
for _ in 0..20 {
|
||
std::hint::black_box(frame_to_image_scaled(&f, 640, 360));
|
||
}
|
||
eprintln!("convert at a third {:?}", t.elapsed() / 20);
|
||
}
|
||
}
|
||
}
|