Files
effectcraft/crates/media/src/convert.rs
T
Brandon ThomasandClaude Opus 5.5 406706e3d5 OUT-2: WebM export (VP9 intra encoder + alpha, Opus), WAV/AIFF audio-only outputs
- effectcraft-vp9enc (L0): VP9 profile 0 key-frame encoder from the VP9 bitstream spec
  (8x8 intra blocks, DC/V/H/TM, 4x4 DCT/ADST, lossless WHT at quality 100, tile columns);
  round-trips exactly through FilmCraft's VP9 decoder and ffmpeg.
- export: WebM muxer (EBML/Matroska spec) with VP9, VP9 alpha in BlockAdditional
  (AlphaMode 1), Opus (CodecDelay = pre-skip); WAV and AIFF outputs.
- OutputFormat WebM/Wav/Aiff in the Render Queue, renderQueue.add formats, CLI --format.
- Tests: FilmCraft decode-back (frames, size, pixels, audio RMS), ffprobe/ffmpeg oracles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:28:15 -04:00

395 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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;
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],
}
impl AlphaOp {
pub(crate) fn new(mode: AlphaMode, matte: [f32; 3]) -> Self {
Self { mode, matte }
}
/// `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.chunks_exact(4)) {
*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.chunks_exact(4)) {
let a = s[3];
let inv = if a > 0.0 { 1.0 / a } else { 0.0 };
let c = [encode(s[0] * inv) * a, encode(s[1] * inv) * a, encode(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
}
/// 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).
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 };
[encode(s[0] * inv) * a, encode(s[1] * inv) * a, encode(s[2] * inv) * a]
} else {
[encode(s[0]), encode(s[1]), encode(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);
}
#[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);
}
}
}