Take colour conversion off the H.264 export's critical path

- The H.264 encoder's RGBA -> 4:2:0 conversion runs on the render
  threads (VideoEncoder::prepare_ahead), which are idle while a batch is
  encoded; encode() picks the planes up by picture index and converts a
  picture itself when there are none (then on the encoder's own pool,
  not the busy global one).
- rgba_to_yuv420_8 uses per-code tables of the same products, rounds
  without roundf and works row by row so the divisions vectorise; the
  output is pinned against the float formula for all 2^24 colours.
- Full-size opaque 8-bit Y'CbCr -> linear conversion tabulates R' and
  B' per code pair with the expressions of the per-pixel loop.
- over_black_rgba8 fetches the sRGB table once per picture.

bench_export (1080p H.264 + AAC to H.264 + AAC, Ryzen 16 threads, 7
interleaved rounds) with the encoder change: median 7.86 s -> 6.14 s
(-21.9 %), min 7.78 s -> 6.02 s (-22.7 %). Same file (MD5) as
before, and the same for a ProRes export of the clip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-27 17:42:46 +01:00
parent 6cd0518fc6
commit ef4a5f83f4
11 changed files with 384 additions and 39 deletions
+8 -1
View File
@@ -243,8 +243,15 @@ pub fn linear_to_srgb_u8_table() -> &'static [u8; 4096] {
#[inline]
pub fn linear_to_srgb_u8(v: f32) -> u8 {
linear_to_srgb_u8_in(linear_to_srgb_u8_table(), v)
}
/// [`linear_to_srgb_u8`] through a table fetched once ([`linear_to_srgb_u8_table`]), for
/// per-pixel loops.
#[inline]
pub fn linear_to_srgb_u8_in(table: &[u8; 4096], v: f32) -> u8 {
let i = (v.clamp(0.0, 1.0) * 4095.0 + 0.5) as usize;
linear_to_srgb_u8_table()[i]
table[i.min(4095)]
}
/// Rec.709 luma of a linear or encoded RGB triple.
+159 -21
View File
@@ -94,34 +94,78 @@ pub fn rgbf_to_yuv422_10(rgb: &[f32], w: usize, h: usize, kr: f32, kb: f32, y: &
});
}
/// `x.round().clamp(1.0, 254.0) as u8` for the finite values the conversions produce, without
/// the `roundf` call. For `0.5 <= x < 2^23` the sum `x + 0.5` is a multiple of the spacing of
/// `x` and never rounds up across an integer, so truncating it rounds half away from zero
/// exactly as `round` does; anything below 0.5 clamps to 1 either way. NaN gives 0, as the
/// `round`/`clamp`/`as` chain does.
#[inline(always)]
fn code8(x: f32) -> u8 {
if x.is_nan() {
return 0;
}
((x + 0.5) as i32).clamp(1, 254) as u8
}
/// BT.709 limited-range 8-bit 4:2:0 from straight RGBA8 (2×2 chroma average).
pub fn rgba_to_yuv420_8(rgba: &[u8], w: usize, h: usize, y: &mut Vec<u8>, u: &mut Vec<u8>, v: &mut Vec<u8>) {
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
y.resize(w * h, 0);
u.resize(cw * ch, 0);
v.resize(cw * ch, 0);
y.par_chunks_mut(w * 2).zip(u.par_chunks_mut(cw).zip(v.par_chunks_mut(cw))).enumerate().for_each(|(cy, (yr, (ur, vr)))| {
let rows = yr.len() / w;
let mut us = vec![0f32; cw];
let mut vs = vec![0f32; cw];
let mut cnt = vec![0f32; cw];
for dy in 0..rows {
let row = cy * 2 + dy;
let src = &rgba[row * w * 4..(row + 1) * w * 4];
for x in 0..w {
let (r, g, b) = (src[x * 4] as f32 / 255.0, src[x * 4 + 1] as f32 / 255.0, src[x * 4 + 2] as f32 / 255.0);
let yy = 0.2126 * r + 0.7152 * g + 0.0722 * b;
yr[dy * w + x] = (16.0 + 219.0 * yy).round().clamp(1.0, 254.0) as u8;
us[x / 2] += (b - yy) / 1.8556;
vs[x / 2] += (r - yy) / 1.5748;
cnt[x / 2] += 1.0;
// per-code tables of exactly the products the formula takes: `c / 255.0` and `k * (c / 255.0)`
let unit: [f32; 256] = std::array::from_fn(|c| c as f32 / 255.0);
let (kr, kg, kb): ([f32; 256], [f32; 256], [f32; 256]) =
(std::array::from_fn(|c| 0.2126 * unit[c]), std::array::from_fn(|c| 0.7152 * unit[c]), std::array::from_fn(|c| 0.0722 * unit[c]));
// Per row: gather the luma sums, then the vectorisable part (codes, chroma differences) over
// whole rows, then add the differences pairwise in the order the per-pixel loop added them.
let rows_init = || (vec![0f32; w], vec![0f32; w], vec![0f32; w], vec![0f32; cw], vec![0f32; cw]);
y.par_chunks_mut(w * 2).zip(u.par_chunks_mut(cw).zip(v.par_chunks_mut(cw))).enumerate().for_each_init(
rows_init,
|(ty, tb, tr, us, vs), (cy, (yr, (ur, vr)))| {
let rows = yr.len() / w;
us.fill(0.0);
vs.fill(0.0);
for (dy, yrow) in yr.chunks_exact_mut(w).enumerate() {
let row = cy * 2 + dy;
let Some(src) = rgba.get(row * w * 4..(row + 1) * w * 4) else { continue };
for (p, (yy, (b, r))) in src.as_chunks::<4>().0.iter().zip(ty.iter_mut().zip(tb.iter_mut().zip(tr.iter_mut()))) {
let (pr, pg, pb) = (usize::from(p[0]), usize::from(p[1]), usize::from(p[2]));
*yy = kr[pr] + kg[pg] + kb[pb];
*b = unit[pb];
*r = unit[pr];
}
for (o, &yy) in yrow.iter_mut().zip(ty.iter()) {
*o = code8(16.0 + 219.0 * yy);
}
for (b, &yy) in tb.iter_mut().zip(ty.iter()) {
*b = (*b - yy) / 1.8556;
}
for (r, &yy) in tr.iter_mut().zip(ty.iter()) {
*r = (*r - yy) / 1.5748;
}
for (acc, pair) in us.iter_mut().zip(tb.as_chunks::<2>().0) {
*acc += pair[0];
*acc += pair[1];
}
for (acc, pair) in vs.iter_mut().zip(tr.as_chunks::<2>().0) {
*acc += pair[0];
*acc += pair[1];
}
if w % 2 == 1 {
// the odd last column has a chroma sample of its own
us[cw - 1] += tb[w - 1];
vs[cw - 1] += tr[w - 1];
}
}
}
for cx in 0..cw {
ur[cx] = (128.0 + 224.0 * us[cx] / cnt[cx]).round().clamp(1.0, 254.0) as u8;
vr[cx] = (128.0 + 224.0 * vs[cx] / cnt[cx]).round().clamp(1.0, 254.0) as u8;
}
});
for (cx, (uo, vo)) in ur.iter_mut().zip(vr.iter_mut()).enumerate() {
// pixels averaged into this chroma sample: 2 per row (1 in an odd last column), `rows` rows
let cnt = ((w - 2 * cx).min(2) * rows) as f32;
*uo = code8(128.0 + 224.0 * us[cx] / cnt);
*vo = code8(128.0 + 224.0 * vs[cx] / cnt);
}
},
);
}
/// Limited-range 8-bit 4:2:0 from encoded R'G'B' floats with matrix (Kr, Kb).
@@ -209,3 +253,97 @@ pub fn rgbf_to_yuv420_10(rgb: &[f32], w: usize, h: usize, kr: f32, kb: f32, y: &
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// The straightforward float formula `rgba_to_yuv420_8` must reproduce bit for bit.
fn reference_420_8(rgba: &[u8], w: usize, h: usize) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let (mut y, mut u, mut v) = (vec![0u8; w * h], vec![0u8; cw * ch], vec![0u8; cw * ch]);
for cy in 0..ch {
let mut us = vec![0f32; cw];
let mut vs = vec![0f32; cw];
let mut cnt = vec![0f32; cw];
for row in cy * 2..(cy * 2 + 2).min(h) {
for x in 0..w {
let p = &rgba[(row * w + x) * 4..];
let (r, g, b) = (p[0] as f32 / 255.0, p[1] as f32 / 255.0, p[2] as f32 / 255.0);
let yy = 0.2126 * r + 0.7152 * g + 0.0722 * b;
y[row * w + x] = (16.0 + 219.0 * yy).round().clamp(1.0, 254.0) as u8;
us[x / 2] += (b - yy) / 1.8556;
vs[x / 2] += (r - yy) / 1.5748;
cnt[x / 2] += 1.0;
}
}
for cx in 0..cw {
u[cy * cw + cx] = (128.0 + 224.0 * us[cx] / cnt[cx]).round().clamp(1.0, 254.0) as u8;
v[cy * cw + cx] = (128.0 + 224.0 * vs[cx] / cnt[cx]).round().clamp(1.0, 254.0) as u8;
}
}
(y, u, v)
}
fn check(rgba: &[u8], w: usize, h: usize) {
let (mut y, mut u, mut v) = (Vec::new(), Vec::new(), Vec::new());
rgba_to_yuv420_8(rgba, w, h, &mut y, &mut u, &mut v);
let (ry, ru, rv) = reference_420_8(rgba, w, h);
assert!(y == ry, "{w}x{h}: luma differs");
assert!(u == ru, "{w}x{h}: Cb differs");
assert!(v == rv, "{w}x{h}: Cr differs");
}
#[test]
fn code8_rounds_like_round_around_every_half() {
for n in 0..300 {
let half = n as f32 + 0.5;
let mut x = half;
let mut down = half;
for _ in 0..64 {
for v in [x, down] {
assert_eq!(code8(v), v.round().clamp(1.0, 254.0) as u8, "{v}");
}
x = f32::from_bits(x.to_bits() + 1);
down = f32::from_bits(down.to_bits() - 1);
}
}
for v in [-3.0f32, -0.5, -0.0, 0.0, 0.25, 0.49999997, 0.5, 1.0, 253.5, 254.49998, 1e9, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
assert_eq!(code8(v), v.round().clamp(1.0, 254.0) as u8, "{v}");
}
}
#[test]
fn yuv420_8_matches_the_float_formula_for_every_colour() {
// 4096 x 4096 holds each of the 2^24 colours once; neighbouring colours share chroma
let (w, h) = (4096, 4096);
let mut rgba = vec![255u8; w * h * 4];
for (i, p) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
p[0] = (i >> 16) as u8;
p[1] = (i >> 8) as u8;
p[2] = i as u8;
}
check(&rgba, w, h);
}
#[test]
fn yuv420_8_matches_the_float_formula_at_odd_sizes() {
let mut seed = 0x9e37_79b9u32;
for (w, h) in [(1, 1), (2, 2), (3, 5), (17, 9), (64, 33), (1921, 3)] {
let rgba: Vec<u8> = (0..w * h * 4)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
(seed >> 24) as u8
})
.collect();
check(&rgba, w, h);
}
// the extremes, where rounding and clamping meet
for c in [[0u8, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [0, 255, 255], [255, 0, 255]] {
let rgba: Vec<u8> = (0..6 * 4).flat_map(|_| [c[0], c[1], c[2], 255]).collect();
check(&rgba, 6, 4);
}
}
}
+45 -8
View File
@@ -1,6 +1,6 @@
use super::convert::{rgba_to_yuv420_8, rgba_to_yuv422_10, rgbf_to_yuv420_8, rgbf_to_yuv422_10, to_yuv444_10};
use super::format::{ColorSignal, DEFAULT_CRF, ExportSettings, Format, H264Pass};
use super::registry::{AudioEncoder, EncodedPacket, EncoderFrame, VideoEncoder};
use super::registry::{AudioEncoder, EncodedPacket, EncoderFrame, PrepareAhead, VideoEncoder};
use super::stats::{Stage, timed};
use super::*;
@@ -358,6 +358,22 @@ struct H264Encoder {
u: Vec<u8>,
v: Vec<u8>,
signal: ColorSignal,
/// 4:2:0 planes the render threads converted ahead ([`VideoEncoder::prepare_ahead`]), by
/// picture index.
ready: Arc<Mutex<std::collections::HashMap<u64, Yuv420>>>,
}
type Yuv420 = (Vec<u8>, Vec<u8>, Vec<u8>);
/// The encoder input of `f`: 8-bit limited-range 4:2:0 in the export's colour signal.
fn h264_input(f: &EncoderFrame, signal: ColorSignal, y: &mut Vec<u8>, u: &mut Vec<u8>, v: &mut Vec<u8>) {
match f.hdr {
Some(rgb) => {
let (kr, kb) = signal.kr_kb();
rgbf_to_yuv420_8(rgb, f.width as usize, f.height as usize, kr, kb, y, u, v)
}
None => rgba_to_yuv420_8(f.rgba, f.width as usize, f.height as usize, y, u, v),
}
}
impl H264Encoder {
@@ -382,13 +398,23 @@ impl VideoEncoder for H264Encoder {
self.rate.num as u32
}
fn encode(&mut self, f: &EncoderFrame) -> Result<Vec<EncodedPacket>> {
timed(Stage::Convert, || match f.hdr {
Some(rgb) => {
let (kr, kb) = self.signal.kr_kb();
rgbf_to_yuv420_8(rgb, f.width as usize, f.height as usize, kr, kb, &mut self.y, &mut self.u, &mut self.v)
let ready = {
let mut ready = self.ready.lock().unwrap_or_else(PoisonError::into_inner);
let mine = ready.remove(&f.index);
// anything older belongs to a batch that was rendered and then not encoded
ready.retain(|&i, _| i > f.index);
mine
};
let (w, h) = (f.width as usize, f.height as usize);
let chroma = w.div_ceil(2) * h.div_ceil(2);
match ready.filter(|(y, u, v)| y.len() == w * h && u.len() == chroma && v.len() == chroma) {
Some((y, u, v)) => (self.y, self.u, self.v) = (y, u, v),
None => {
let (enc, y, u, v, signal) = (&self.enc, &mut self.y, &mut self.u, &mut self.v, self.signal);
// on the encoder's own threads: the global pool is busy rendering the next batch
timed(Stage::Convert, || enc.install(|| h264_input(f, signal, y, u, v)));
}
None => rgba_to_yuv420_8(f.rgba, f.width as usize, f.height as usize, &mut self.y, &mut self.u, &mut self.v),
});
}
let cw = (f.width as usize).div_ceil(2);
let frame = filmcraft_h264enc::YuvFrame { y: &self.y, u: &self.u, v: &self.v, y_stride: f.width as usize, uv_stride: cw };
let ps = self.enc.try_encode(&frame, f.index as i64 * self.rate.den).map_err(|e| ExportError::Encode(e.to_string()))?;
@@ -405,6 +431,14 @@ impl VideoEncoder for H264Encoder {
fn pass_stats(&self) -> Option<filmcraft_h264enc::PassStats> {
self.enc.pass_stats()
}
fn prepare_ahead(&self) -> Option<PrepareAhead> {
let (ready, signal) = (self.ready.clone(), self.signal);
Some(Arc::new(move |f: &EncoderFrame| {
let (mut y, mut u, mut v) = (Vec::new(), Vec::new(), Vec::new());
h264_input(f, signal, &mut y, &mut u, &mut v);
ready.lock().unwrap_or_else(PoisonError::into_inner).insert(f.index, (y, u, v));
}))
}
}
/// Slices per H.264 picture: one per four macroblock rows. The encoder's default follows the core
@@ -457,7 +491,10 @@ pub(crate) fn h264_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &
}
Some(
filmcraft_h264enc::Encoder::new(cfg)
.map(|enc| Box::new(H264Encoder { enc, w, h, rate, y: Vec::new(), u: Vec::new(), v: Vec::new(), signal: s.signal }) as Box<dyn VideoEncoder>)
.map(|enc| {
Box::new(H264Encoder { enc, w, h, rate, y: Vec::new(), u: Vec::new(), v: Vec::new(), signal: s.signal, ready: Default::default() })
as Box<dyn VideoEncoder>
})
.map_err(|e| ExportError::Encode(e.to_string())),
)
}
+13 -5
View File
@@ -74,8 +74,8 @@ use crate::mxf_out::{MxfMux, MxfSetup};
use crate::pipeline::Pipeline;
use crate::settings::{AudioCodec, BitrateMode};
use crate::{
AudioEncoder, ColorSignal, EncodedPacket, EncoderFrame, ExportError, ExportSettings, Format, H264Pass, Out, Progress, Report, Result, Stage, VideoEncoder,
audio_factories, export_range, frame_span, note_stage, timed, video_factories,
AudioEncoder, ColorSignal, EncodedPacket, EncoderFrame, ExportError, ExportSettings, Format, H264Pass, Out, PrepareAhead, Progress, Report, Result, Stage,
VideoEncoder, audio_factories, export_range, frame_span, note_stage, timed, video_factories,
};
/// Output frames per interleaved group: the video packets of these frames, then the audio up to
@@ -113,7 +113,9 @@ struct Ahead {
/// Render frames `f..end` in parallel. `None` when a source was not ready for one of them (the
/// pending flag, taken after every frame because it is per thread): the batch is incomplete and
/// must be rendered again.
fn render_batch(pipe: &Pipeline, f: i64, end: i64, sources: &dyn SourceProvider) -> Option<Vec<Frame>> {
/// `prepare` (the encoder's [`VideoEncoder::prepare_ahead`]) then works on each frame, numbered
/// from `f0` like [`EncoderFrame::index`].
fn render_batch(pipe: &Pipeline, f: i64, end: i64, sources: &dyn SourceProvider, prepare: Option<&PrepareAhead>, f0: i64) -> Option<Vec<Frame>> {
let pending = AtomicBool::new(false);
let frames: Vec<Frame> = (f..end)
.into_par_iter()
@@ -122,6 +124,10 @@ fn render_batch(pipe: &Pipeline, f: i64, end: i64, sources: &dyn SourceProvider)
if filmcraft_media::pending::take() {
pending.store(true, Ordering::Relaxed);
}
if let Some(prepare) = prepare {
let index = u64::try_from(fi - f0).unwrap_or(u64::MAX);
prepare(&EncoderFrame { width: pipe.w, height: pipe.h, rgba: &frame.0, hdr: pipe.hdr_out.then_some(frame.1.as_slice()), index });
}
frame
})
.collect();
@@ -527,12 +533,14 @@ impl Exporter {
/// Encode (and mux) the batch starting at `self.next`, rendering the one after it meanwhile.
fn step_batch(&mut self, sources: &dyn SourceProvider, progress: &Progress) -> Result<Step> {
let prepare = self.venc.prepare_ahead();
let f0 = self.f0;
let (f, frames) = match self.ahead.take() {
Some(a) if a.first == self.next => (a.first, a.frames),
_ => {
let (f, end) = (self.next, self.next.saturating_add(self.batch_len()).min(self.f1));
let pipe = &self.pipe;
match timed(Stage::Render, || render_batch(pipe, f, end, sources)) {
match timed(Stage::Render, || render_batch(pipe, f, end, sources, prepare.as_ref(), f0)) {
Some(frames) => (f, frames),
None => return Ok(Step::Pending),
}
@@ -560,7 +568,7 @@ impl Exporter {
let mut encode_done: Option<web_time::Instant> = None;
let encoded = rayon::in_place_scope(|s| {
s.spawn(|_| {
slot = Some(catch_unwind(AssertUnwindSafe(|| timed(Stage::Render, || render_batch(&pipe, a, b, sources)))));
slot = Some(catch_unwind(AssertUnwindSafe(|| timed(Stage::Render, || render_batch(&pipe, a, b, sources, prepare.as_ref(), f0)))));
});
let r = self.encode_batch(f, frames, sources);
encode_done = Some(web_time::Instant::now());
+1 -1
View File
@@ -65,7 +65,7 @@ pub(crate) use registry::build_frame_renderer;
#[cfg(test)]
pub(crate) use registry::reset_frame_renderers_for_tests;
pub use registry::{
AudioEncoder, AudioEncoderFactory, EncodedPacket, EncoderFactory, EncoderFrame, FrameRenderer, FrameRendererFactory, VideoEncoder, available,
AudioEncoder, AudioEncoderFactory, EncodedPacket, EncoderFactory, EncoderFrame, FrameRenderer, FrameRendererFactory, PrepareAhead, VideoEncoder, available,
encoder_registered, frame_renderer_registered, hdr_available, register_audio_encoder, register_encoder, register_format_probe, register_frame_renderer,
register_hdr_probe,
};
+11
View File
@@ -24,6 +24,10 @@ pub struct EncoderFrame<'a> {
pub index: u64,
}
/// Per-picture work an encoder hands to the export's render threads, see
/// [`VideoEncoder::prepare_ahead`].
pub type PrepareAhead = std::sync::Arc<dyn Fn(&EncoderFrame) + Send + Sync>;
pub trait VideoEncoder: Send {
/// MP4/MOV sample entry (codec config) — may only be complete after the first frame.
fn sample_entry(&self) -> SampleEntry;
@@ -38,6 +42,13 @@ pub trait VideoEncoder: Send {
fn pass_stats(&self) -> Option<filmcraft_h264enc::PassStats> {
None
}
/// Work on a rendered picture that needs nothing of the encoder's state (H.264: the colour
/// conversion), for the render threads to do while earlier pictures are being encoded.
/// [`VideoEncoder::encode`] picks the result up by [`EncoderFrame::index`] and does the work
/// itself for a picture that has none; the encoded bytes are the same either way.
fn prepare_ahead(&self) -> Option<PrepareAhead> {
None
}
}
/// Audio encoder (AAC) plugged in by codec crates; PCM is built in.
+42
View File
@@ -517,3 +517,45 @@ impl FrameRenderer for FakeRenderer {
Some(filmcraft_render::Image::new(320, 180))
}
}
/// The render threads may convert H.264 input ahead ([`VideoEncoder::prepare_ahead`]): the packets
/// are the same whether a picture was converted ahead (in any order), converted by the encoder
/// itself (none ahead, or one ahead for another picture size), or never asked for.
#[test]
fn h264_input_converted_ahead_encodes_to_the_same_packets() {
let (w, h) = (96u32, 64u32);
let frames: Vec<Vec<u8>> = (0..8u32)
.map(|t| {
(0..w * h)
.flat_map(|i| {
let (x, y) = (i % w, i / w);
[(x * 3 + t * 7) as u8, (y * 5 + t) as u8, ((x + y) * 2 + t * 3) as u8, 255]
})
.collect()
})
.collect();
let s = ExportSettings { format: Format::H264, ..Default::default() };
let frame = |i: usize| EncoderFrame { width: w, height: h, rgba: &frames[i], hdr: None, index: i as u64 };
let encode_all = |ahead: bool| -> Vec<Vec<u8>> {
let mut enc = h264_factory(Format::H264, w, h, FrameRate::FPS_24, &s).unwrap().unwrap();
if ahead {
let prepare = enc.prepare_ahead().expect("the H.264 encoder converts ahead");
for i in (0..frames.len()).rev().filter(|&i| i != 3) {
prepare(&frame(i));
}
// a picture of another size under index 5: the encoder converts that one itself
prepare(&EncoderFrame { width: w / 2, height: h / 2, rgba: &frames[5][..(w * h) as usize], hdr: None, index: 5 });
// and one it is never asked to encode
prepare(&EncoderFrame { index: 99, ..frame(0) });
}
let mut out = Vec::new();
for i in 0..frames.len() {
out.extend(enc.encode(&frame(i)).unwrap().into_iter().map(|p| p.data));
}
out.extend(enc.flush().unwrap().into_iter().map(|p| p.data));
out
};
let plain = encode_all(false);
assert!(!plain.is_empty());
assert!(plain == encode_all(true), "converting ahead changed the packets");
}
+38
View File
@@ -16,6 +16,10 @@ use filmcraft_time::Tick;
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
/// Smallest full-size, opaque 8-bit Y'CbCr conversion that tabulates R' and B' per code pair
/// (two tables of 65 536 values): below it the tables cost more than they save.
const YUV8_PAIR_TABLE_MIN_PIXELS: usize = 1 << 18;
/// Chroma subsampling of planar Y'CbCr.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Chroma {
@@ -410,6 +414,40 @@ impl VideoFrame {
let kg = 1.0 - kr - kb;
let (cr_r, cb_b) = (2.0 * (1.0 - kr), 2.0 * (1.0 - kb));
let (cr_g, cb_g) = (cr_r * kr / kg, cb_b * kb / kg);
let ch = h.div_ceil(1 << sy);
let fits = planes[0].len() >= w * h && planes[1].len() >= cw * ch && planes[2].len() >= cw * ch;
if n == 1 && alpha.is_none() && fits && r.w * r.h >= YUV8_PAIR_TABLE_MIN_PIXELS {
// Full size and opaque (decoded video): R' depends on (Y', Cr) only and B' on
// (Y', Cb), so their decoded values are tabulated once for all 65 536 code
// pairs, with the very expressions the per-pixel loop below evaluates; G' is
// decoded per pixel. Each value is the loop's `(0 + q * 1) * inv` exactly.
let pair = |f: &(dyn Fn(f32, f32) -> f32 + Sync)| -> Vec<f32> {
(0..1usize << 16).into_par_iter().map(|i| f(ytab[i >> 8], ctab[i & 255])).collect()
};
let rt = pair(&|yy, v| (0.0 + q(yy + cr_r * v) * 1.0) * inv);
let bt = pair(&|yy, u| (0.0 + q(yy + cb_b * u) * 1.0) * inv);
let opaque = (0.0f32 + 1.0) * inv;
out.par_chunks_mut(r.w * 4).enumerate().for_each(|(ry, row)| {
let y = r.y + ry;
let cy = y >> sy;
let (Some(ys), Some(us), Some(vs)) =
(planes[0].get(y * w + r.x..y * w + r.x + r.w), planes[1].get(cy * cw..(cy + 1) * cw), planes[2].get(cy * cw..(cy + 1) * cw))
else {
row.fill(0.0);
return;
};
for (rx, (o, &yc)) in row.as_chunks_mut::<4>().0.iter_mut().zip(ys).enumerate() {
let cx = (r.x + rx) >> sx;
let (yc, uc, vc) = (usize::from(yc), us.get(cx).copied().map_or(0, usize::from), vs.get(cx).copied().map_or(0, usize::from));
let (yy, u, v) = (ytab[yc], ctab[uc], ctab[vc]);
o[0] = rt[yc << 8 | vc];
o[1] = (0.0 + q(yy - cr_g * v - cb_g * u) * 1.0) * inv;
o[2] = bt[yc << 8 | uc];
o[3] = opaque;
}
});
return;
}
out.par_chunks_mut(r.w * 4).enumerate().for_each(|(ry, row)| {
let oy = r.y + ry;
for rx in 0..r.w {
+43
View File
@@ -355,3 +355,46 @@ fn alpha_region_is_the_box_around_what_is_not_transparent() {
};
assert_eq!(short.alpha_region(1), None);
}
#[test]
fn full_size_yuv8_tables_give_the_per_pixel_values_bit_for_bit() {
// 640x480 takes the per-code-pair tables; single rows (below the size that builds them) take the
// per-pixel loop: every value must match to the bit, for each subsampling, range, matrix and curve.
let (w, h) = (640usize, 480usize);
let mut seed = 0x2545_f491u32;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
(seed >> 24) as u8
};
let curve = filmcraft_color::DecodeTable::new(0.0, 1.0, 4096, |v| v.max(0.0).powf(2.4));
for chroma in [Chroma::C420, Chroma::C422, Chroma::C444] {
let (sx, sy) = chroma.shifts();
let (cw, ch) = (w.div_ceil(1 << sx), h.div_ceil(1 << sy));
let y: Vec<u8> = (0..w * h).map(|_| next()).collect();
let u: Vec<u8> = (0..cw * ch).map(|_| next()).collect();
let v: Vec<u8> = (0..cw * ch).map(|_| next()).collect();
for range in [Range::Limited, Range::Full] {
for matrix in [Matrix::Bt601, Matrix::Bt709, Matrix::Bt2020Ncl] {
let f = VideoFrame {
width: w as u32,
height: h as u32,
data: PixelData::Yuv8 { planes: [Arc::new(y.clone()), Arc::new(u.clone()), Arc::new(v.clone())], chroma, alpha: None },
color: ColorInfo { matrix, range, ..ColorInfo::REC709 },
par: (1, 1),
pts: Tick::ZERO,
};
for decode in [None, Some(&curve)] {
let (_, _, full) = f.to_linear_f32_decimated_with(1, decode);
for row in [0, 1, 2, 239, 478, 479] {
let (_, px) = f.to_linear_f32_region(1, decode, Region { x: 0, y: row, w, h: 1 });
let want: Vec<u32> = px.iter().map(|x| x.to_bits()).collect();
let got: Vec<u32> = full[row * w * 4..(row + 1) * w * 4].iter().map(|x| x.to_bits()).collect();
assert!(want == got, "{chroma:?} {range:?} {matrix:?} decode {} row {row}", decode.is_some());
}
}
}
}
}
}
+4 -3
View File
@@ -229,11 +229,12 @@ impl Image {
return Vec::new();
}
let mut out = vec![0u8; self.w * self.h * 4];
let lut = filmcraft_color::linear_to_srgb_u8_table();
out.par_chunks_mut(self.w * 4).zip(self.px.par_chunks(self.w * 4)).for_each(|(o, s)| {
for (o, s) in o.as_chunks_mut::<4>().0.iter_mut().zip(s.as_chunks::<4>().0) {
o[0] = filmcraft_color::linear_to_srgb_u8(s[0]);
o[1] = filmcraft_color::linear_to_srgb_u8(s[1]);
o[2] = filmcraft_color::linear_to_srgb_u8(s[2]);
o[0] = filmcraft_color::linear_to_srgb_u8_in(lut, s[0]);
o[1] = filmcraft_color::linear_to_srgb_u8_in(lut, s[1]);
o[2] = filmcraft_color::linear_to_srgb_u8_in(lut, s[2]);
o[3] = 255;
}
});
+20
View File
@@ -344,6 +344,26 @@ the encoder's hand-off). Only NVENC, whose encode is light on the CPU, comes clo
encoder waits for the renderer), as expected. 1080p exports use 70-160 MB more (the second batch of 16
frames); 4K exports use less than before because the batch is smaller (8 for SDR, 2 for HDR instead of 16).
## Results (CPU H.264 export: encoder work and colour conversion, before → after)
`cargo run --release -p filmcraft-engine --example bench_export -- --runs 1`: 10 s of 1080p23.976
H.264 + AAC (ffmpeg `testsrc2` with noise) exported to H.264 + AAC MP4 on the CPU (software
encoder, no GPU renderer). Ryzen, 16 threads, Linux, 2026-10-27; 7 rounds with the builds in
rotating order, one export per run, wall time in seconds:
| build | median | min |
|---|---|---|
| before | 7.86 | 7.78 |
| after | 6.14 | 6.02 |
Every build writes the same file (same MD5), and so does a ProRes export of the same clip.
The H.264 encoder now keeps the winning 4x4/8x8 transform trial instead of coding it again, skips motion
search points it has already costed, codes the residual of an inter candidate only once it can
still be chosen, and computes 8- and 4-wide SATD lane by lane. The export converts the H.264
input on the render threads, which are idle while a batch is encoded, instead of on the encoding
thread; the conversions themselves (Y'CbCr → linear for decoded video, linear → sRGB, RGBA → 4:2:0)
use per-code tables built from the same expressions.
## Results (HW3: VideoToolbox hardware H.265 (HEVC) encoding, against hardware H.264)
Apple M1 (8 cores, 16 GB), 2026-10-07, single runs on an otherwise idle machine, release build. Both