mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-02 18:09:21 +00:00
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:
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user