mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-28 12:07:48 -06:00
Overlap movie encoding with rendering
Movie exports (H.264, HEVC, AV1, ProRes, uncompressed) rendered a batch of frames on every core, then encoded it frame by frame while the render threads waited. Each batch now encodes on a pool shared by all exports while the next batch renders (Cx::frames_beside). With deferred GPU readbacks, or where no thread can be started (wasm without threads), the two still take turns. A panicking encoder becomes an export error. H.264 frames are converted to 4:2:0 on the render threads, per frame, instead of on the encoder's critical path. The conversion reads 8-bit levels from a table and rounds without a libm call; both give the same values as before (tested against the previous formula). Demo comp (1920x1080, 300 frames) to H.264 on a 16-thread Ryzen, export wall time -6% (median of 7 interleaved runs); output byte-identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
01c904b041
commit
d8bca3cdd9
+169
-24
@@ -26,9 +26,38 @@ pub(crate) struct Packet {
|
||||
pub(crate) cto: i32,
|
||||
}
|
||||
|
||||
pub(crate) trait VideoEncoder {
|
||||
/// A frame for the movie encoder: 8-bit RGBA, or 8-bit 4:2:0 planes already converted on the
|
||||
/// render threads (for encoders whose [`VideoEncoder::takes_yuv420`] is true).
|
||||
pub(crate) enum Pixels {
|
||||
Rgba(Vec<u8>),
|
||||
Yuv420(Yuv420),
|
||||
}
|
||||
|
||||
/// BT.709 limited-range 8-bit 4:2:0 planes ([`rgba_to_yuv420`]).
|
||||
pub(crate) struct Yuv420 {
|
||||
y: Vec<u8>,
|
||||
u: Vec<u8>,
|
||||
v: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Yuv420 {
|
||||
fn from_rgba(rgba: &[u8], w: usize, h: usize) -> Yuv420 {
|
||||
let (mut y, mut u, mut v) = (vec![], vec![], vec![]);
|
||||
rgba_to_yuv420(rgba, w, h, &mut y, &mut u, &mut v);
|
||||
Yuv420 { y, u, v }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) trait VideoEncoder: Send {
|
||||
fn sample_entry(&self) -> SampleEntry;
|
||||
fn encode(&mut self, rgba: &[u8], index: u64) -> Result<Vec<Packet>>;
|
||||
/// Whether frames should come as [`Pixels::Yuv420`] (converted while other frames render).
|
||||
fn takes_yuv420(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn encode_yuv420(&mut self, _frame: &Yuv420, _index: u64) -> Result<Vec<Packet>> {
|
||||
Err(ExportError::Unsupported("this encoder takes RGBA frames".into()))
|
||||
}
|
||||
fn flush(&mut self) -> Result<Vec<Packet>>;
|
||||
/// Edit-list media start (B-frame delay) in the track timescale.
|
||||
fn media_start(&self) -> Option<i64> {
|
||||
@@ -73,9 +102,18 @@ impl VideoEncoder for H264 {
|
||||
SampleEntry::avc(cfg, self.w as u16, self.h as u16)
|
||||
}
|
||||
fn encode(&mut self, rgba: &[u8], index: u64) -> Result<Vec<Packet>> {
|
||||
rgba_to_yuv420(rgba, self.w as usize, self.h as usize, &mut self.y, &mut self.u, &mut self.v);
|
||||
let mut planes = Yuv420 { y: std::mem::take(&mut self.y), u: std::mem::take(&mut self.u), v: std::mem::take(&mut self.v) };
|
||||
rgba_to_yuv420(rgba, self.w as usize, self.h as usize, &mut planes.y, &mut planes.u, &mut planes.v);
|
||||
let r = self.encode_yuv420(&planes, index);
|
||||
(self.y, self.u, self.v) = (planes.y, planes.u, planes.v);
|
||||
r
|
||||
}
|
||||
fn takes_yuv420(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn encode_yuv420(&mut self, p: &Yuv420, index: u64) -> Result<Vec<Packet>> {
|
||||
let cw = (self.w as usize).div_ceil(2);
|
||||
let frame = filmcraft_h264enc::YuvFrame { y: &self.y, u: &self.u, v: &self.v, y_stride: self.w as usize, uv_stride: cw };
|
||||
let frame = filmcraft_h264enc::YuvFrame { y: &p.y, u: &p.u, v: &p.v, y_stride: self.w as usize, uv_stride: cw };
|
||||
let ps = self.enc.try_encode(&frame, index as i64 * self.rate.den).map_err(enc)?;
|
||||
Ok(self.packets(ps))
|
||||
}
|
||||
@@ -88,6 +126,25 @@ impl VideoEncoder for H264 {
|
||||
}
|
||||
}
|
||||
|
||||
/// `v / 255` for every 8-bit `v` (the same values as dividing).
|
||||
pub(crate) const UNIT8: [f32; 256] = {
|
||||
let mut t = [0.0; 256];
|
||||
let mut i = 0;
|
||||
while i < 256 {
|
||||
t[i] = i as f32 / 255.0;
|
||||
i += 1;
|
||||
}
|
||||
t
|
||||
};
|
||||
|
||||
/// `v.round()` for `0 <= v < 2^23`, the range the colour conversions round in: truncation and
|
||||
/// the fraction are exact there, so this is `f32::round` without its libm call.
|
||||
#[inline(always)]
|
||||
pub(crate) fn round_pos(v: f32) -> f32 {
|
||||
let t = v as u32 as f32;
|
||||
if v - t >= 0.5 { t + 1.0 } else { t }
|
||||
}
|
||||
|
||||
/// BT.709 limited-range 8-bit 4:2:0 from RGBA8 (2×2 chroma average; alpha ignored).
|
||||
pub(crate) fn rgba_to_yuv420(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));
|
||||
@@ -95,14 +152,13 @@ pub(crate) fn rgba_to_yuv420(rgba: &[u8], w: usize, h: usize, y: &mut Vec<u8>, u
|
||||
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 acc = vec![(0f32, 0f32, 0f32); cw];
|
||||
for dy in 0..rows {
|
||||
for (dy, yrow) in yr.chunks_exact_mut(w).enumerate() {
|
||||
let src = &rgba[(cy * 2 + dy) * w * 4..][..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);
|
||||
for (x, (s, yo)) in src.as_chunks::<4>().0.iter().zip(yrow.iter_mut()).enumerate() {
|
||||
let (r, g, b) = (UNIT8[s[0] as usize], UNIT8[s[1] as usize], UNIT8[s[2] as usize]);
|
||||
let l = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
yr[dy * w + x] = (16.0 + 219.0 * l).round().clamp(1.0, 254.0) as u8;
|
||||
*yo = round_pos(16.0 + 219.0 * l).clamp(1.0, 254.0) as u8;
|
||||
let a = &mut acc[x / 2];
|
||||
a.0 += (b - l) / 1.8556;
|
||||
a.1 += (r - l) / 1.5748;
|
||||
@@ -110,8 +166,8 @@ pub(crate) fn rgba_to_yuv420(rgba: &[u8], w: usize, h: usize, y: &mut Vec<u8>, u
|
||||
}
|
||||
}
|
||||
for (cx, a) in acc.iter().enumerate() {
|
||||
ur[cx] = (128.0 + 224.0 * a.0 / a.2).round().clamp(1.0, 254.0) as u8;
|
||||
vr[cx] = (128.0 + 224.0 * a.1 / a.2).round().clamp(1.0, 254.0) as u8;
|
||||
ur[cx] = round_pos(128.0 + 224.0 * a.0 / a.2).clamp(1.0, 254.0) as u8;
|
||||
vr[cx] = round_pos(128.0 + 224.0 * a.1 / a.2).clamp(1.0, 254.0) as u8;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -265,15 +321,34 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
|
||||
};
|
||||
let brand = if matches!(fmt, OutputFormat::ProRes | OutputFormat::Uncompressed) { Brand::Mov } else { Brand::Mp4 };
|
||||
let batch = batch_size();
|
||||
let render = |_: u64, img: effectcraft_raster::Image| -> Vec<u8> { job.pixels(&img, comp, channels, w, h) };
|
||||
let yuv = venc.takes_yuv420();
|
||||
let render = |_: u64, img: effectcraft_raster::Image| -> Pixels {
|
||||
let px = job.pixels(&img, comp, channels, w, h);
|
||||
if yuv { Pixels::Yuv420(Yuv420::from_rgba(&px, w as usize, h as usize)) } else { Pixels::Rgba(px) }
|
||||
};
|
||||
let encode = |venc: &mut Box<dyn VideoEncoder>, px: &Pixels, index: u64| match px {
|
||||
Pixels::Rgba(rgba) => venc.encode(rgba, index),
|
||||
Pixels::Yuv420(planes) => venc.encode_yuv420(planes, index),
|
||||
};
|
||||
|
||||
let beside = crate::pipeline::Beside::shared();
|
||||
let total = st.total;
|
||||
|
||||
// Encode the first batch before creating tracks: encoders finalise their config on frame 1.
|
||||
let first_end = batch.min(st.total);
|
||||
let first: Vec<Vec<u8>> = job.frames(comp, (0..first_end).collect(), render).await;
|
||||
let mut pending = Vec::new();
|
||||
for (k, px) in first.iter().enumerate() {
|
||||
pending.extend(venc.encode(px, k as u64)?);
|
||||
}
|
||||
// Each batch encodes while the next one renders.
|
||||
let first_end = batch.min(total);
|
||||
let first: Vec<Pixels> = job.frames(comp, (0..first_end).collect(), render).await;
|
||||
let next_end = (first_end + batch).min(total);
|
||||
let (pending, mut ready) = job
|
||||
.frames_beside(beside, comp, (first_end..next_end).collect(), render, || -> Result<Vec<Packet>> {
|
||||
let mut pending = Vec::new();
|
||||
for (k, px) in first.iter().enumerate() {
|
||||
pending.extend(encode(&mut venc, px, k as u64)?);
|
||||
}
|
||||
Ok(pending)
|
||||
})
|
||||
.await?;
|
||||
let pending = pending?;
|
||||
drop(first);
|
||||
st.advance(first_end)?;
|
||||
|
||||
@@ -351,19 +426,25 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let total = st.total;
|
||||
let frame_end_time = |k: u64| -> Tick { if k >= total { span_end } else { job.settings.frame_time(comp, k) } };
|
||||
write_video(&mut mux, pending)?;
|
||||
write_audio_until(&mut mux, frame_end_time(first_end), &mut cursor)?;
|
||||
let mut i = first_end;
|
||||
while i < total {
|
||||
let end = (i + batch).min(total);
|
||||
let frames: Vec<Vec<u8>> = job.frames(comp, (i..end).collect(), render).await;
|
||||
for (k, px) in frames.iter().enumerate() {
|
||||
let ps = venc.encode(px, i + k as u64)?;
|
||||
write_video(&mut mux, ps)?;
|
||||
}
|
||||
write_audio_until(&mut mux, frame_end_time(end), &mut cursor)?;
|
||||
let next_end = (end + batch).min(total);
|
||||
let frames = std::mem::take(&mut ready);
|
||||
let (done, next) = job
|
||||
.frames_beside(beside, comp, (end..next_end).collect(), render, || -> Result<()> {
|
||||
for (k, px) in frames.iter().enumerate() {
|
||||
let ps = encode(&mut venc, px, i + k as u64)?;
|
||||
write_video(&mut mux, ps)?;
|
||||
}
|
||||
write_audio_until(&mut mux, frame_end_time(end), &mut cursor)
|
||||
})
|
||||
.await?;
|
||||
done?;
|
||||
ready = next;
|
||||
st.advance(end - i)?;
|
||||
i = end;
|
||||
}
|
||||
@@ -384,6 +465,70 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The conversion as first written (per-pixel division and `f32::round`).
|
||||
fn yuv420_reference(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 acc = vec![(0f32, 0f32, 0f32); cw];
|
||||
for row in cy * 2..(cy * 2 + 2).min(h) {
|
||||
for x in 0..w {
|
||||
let s = &rgba[(row * w + x) * 4..];
|
||||
let (r, g, b) = (s[0] as f32 / 255.0, s[1] as f32 / 255.0, s[2] as f32 / 255.0);
|
||||
let l = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
y[row * w + x] = (16.0 + 219.0 * l).round().clamp(1.0, 254.0) as u8;
|
||||
let a = &mut acc[x / 2];
|
||||
a.0 += (b - l) / 1.8556;
|
||||
a.1 += (r - l) / 1.5748;
|
||||
a.2 += 1.0;
|
||||
}
|
||||
}
|
||||
for (cx, a) in acc.iter().enumerate() {
|
||||
u[cy * cw + cx] = (128.0 + 224.0 * a.0 / a.2).round().clamp(1.0, 254.0) as u8;
|
||||
v[cy * cw + cx] = (128.0 + 224.0 * a.1 / a.2).round().clamp(1.0, 254.0) as u8;
|
||||
}
|
||||
}
|
||||
(y, u, v)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_pos_is_round() {
|
||||
for i in 0..2_000_000u32 {
|
||||
let v = i as f32 * 0.000_25;
|
||||
assert_eq!(round_pos(v).to_bits(), v.round().to_bits(), "{v}");
|
||||
let n = f32::from_bits(v.to_bits().wrapping_sub(1)).max(0.0);
|
||||
assert_eq!(round_pos(n).to_bits(), n.round().to_bits(), "{n}");
|
||||
}
|
||||
for k in 0..1024u32 {
|
||||
for v in [k as f32 + 0.5, f32::from_bits((k as f32 + 0.5).to_bits() - 1), f32::from_bits((k as f32 + 0.5).to_bits() + 1)] {
|
||||
assert_eq!(round_pos(v), v.round(), "{v}");
|
||||
}
|
||||
}
|
||||
for (i, u) in UNIT8.iter().enumerate() {
|
||||
assert_eq!(u.to_bits(), (i as f32 / 255.0).to_bits());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yuv420_matches_the_reference() {
|
||||
// Every 8-bit level in every channel, odd sizes, and pseudo-random pixels.
|
||||
for (w, h) in [(1, 1), (2, 2), (3, 5), (17, 9), (64, 33)] {
|
||||
let mut rgba = vec![0u8; w * h * 4];
|
||||
let mut seed = (w * 31 + h) as u32;
|
||||
for (i, b) in rgba.iter_mut().enumerate() {
|
||||
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
*b = if i % 7 == 0 { (i / 4 % 256) as u8 } else { (seed >> 24) as u8 };
|
||||
}
|
||||
let (mut y, mut u, mut v) = (vec![], vec![], vec![]);
|
||||
rgba_to_yuv420(&rgba, w, h, &mut y, &mut u, &mut v);
|
||||
assert_eq!((y, u, v), yuv420_reference(&rgba, w, h), "{w}x{h}");
|
||||
}
|
||||
let grey: Vec<u8> = (0..=255u8).flat_map(|g| [g, g, g, 255, g, 255 - g, g / 2, 255]).collect();
|
||||
let (mut y, mut u, mut v) = (vec![], vec![], vec![]);
|
||||
rgba_to_yuv420(&grey, 512, 1, &mut y, &mut u, &mut v);
|
||||
assert_eq!((y, u, v), yuv420_reference(&grey, 512, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yuv_of_primaries() {
|
||||
let rgba = [255u8, 255, 255, 255, 0, 0, 0, 255, 255, 0, 0, 255, 0, 255, 0, 255];
|
||||
|
||||
@@ -91,6 +91,24 @@ pub(crate) fn map_frames_while<I: Send, T: Send>(
|
||||
out
|
||||
}
|
||||
|
||||
/// Threads that encode one batch of frames while the next renders ([`Cx::frames_beside`]).
|
||||
/// Their own pool: the encoder's parallel steps (colour conversion) would otherwise queue behind
|
||||
/// the render's work in the global pool. `None` where threads can't be started (a browser
|
||||
/// without them): encoding and rendering then take turns.
|
||||
pub(crate) struct Beside(Option<rayon::ThreadPool>);
|
||||
|
||||
impl Beside {
|
||||
fn new() -> Beside {
|
||||
Beside(rayon::ThreadPoolBuilder::new().thread_name(|i| format!("export-encode-{i}")).build().ok())
|
||||
}
|
||||
|
||||
/// The process's encode threads, started by the first movie export and shared by all.
|
||||
pub fn shared() -> &'static Beside {
|
||||
static SHARED: std::sync::OnceLock<Beside> = std::sync::OnceLock::new();
|
||||
SHARED.get_or_init(Beside::new)
|
||||
}
|
||||
}
|
||||
|
||||
/// A job being exported: the job, the project with the Render Settings overrides applied, the
|
||||
/// job's layer cache and per-frame timing for the render log.
|
||||
pub(crate) struct Cx<'a> {
|
||||
@@ -214,6 +232,40 @@ impl<'a> Cx<'a> {
|
||||
map_frames_while(ks, self.gpu_cap(), gpu_live, |k| self.frame(comp, k), f)
|
||||
}
|
||||
|
||||
/// [`Cx::frames`] for `ks` while `encode` runs beside them on `beside`'s threads, so the
|
||||
/// render threads don't wait for the (largely sequential) encoder of the previous batch.
|
||||
/// With deferred readbacks, or where no encode thread could be started, `encode` runs first.
|
||||
pub async fn frames_beside<T: Send, A: Send>(
|
||||
&self,
|
||||
beside: &Beside,
|
||||
comp: &Comp,
|
||||
ks: Vec<u64>,
|
||||
f: impl Fn(u64, Image) -> T + Sync + Send,
|
||||
encode: impl FnOnce() -> A + Send,
|
||||
) -> crate::Result<(A, Vec<T>)> {
|
||||
match &beside.0 {
|
||||
Some(pool) if !self.deferred() => {
|
||||
let mut done = None;
|
||||
let frames = pool.in_place_scope(|s| {
|
||||
s.spawn(|_| done = Some(std::panic::catch_unwind(std::panic::AssertUnwindSafe(encode))));
|
||||
effectcraft_render::passes::block_on(self.frames(comp, ks, f))
|
||||
});
|
||||
match done {
|
||||
Some(Ok(a)) => Ok((a, frames)),
|
||||
Some(Err(panic)) => {
|
||||
let why = panic.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| panic.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
Err(crate::ExportError::Encode(format!("the encoder stopped unexpectedly: {why}")))
|
||||
}
|
||||
None => Err(crate::ExportError::Encode("the encoder stopped unexpectedly".into())),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let a = encode();
|
||||
Ok((a, self.frames(comp, ks, f).await))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GPU frames rendered at once (#879), decided once per batch: with a GPU accelerator every
|
||||
/// frame in flight holds its own working textures, so a whole batch rendered at once can
|
||||
/// exhaust VRAM (or shared memory, which crashes instead of falling back). `None` when the
|
||||
@@ -706,6 +758,45 @@ mod tests {
|
||||
assert!(stats.hits >= 9, "{stats:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_render_beside_the_encoder() {
|
||||
use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings};
|
||||
use effectcraft_render::NoFootage;
|
||||
use effectcraft_time::FrameRate;
|
||||
let mut p = Project::default();
|
||||
let comp = Comp::new(32, 16, FrameRate::FPS_25, Tick::from_seconds_f64(1.0));
|
||||
let cid = p.add_item("Comp", effectcraft_color::Label::Sandstone, None, effectcraft_project::ItemKind::Comp(comp.into()));
|
||||
let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::H264));
|
||||
let job = Job {
|
||||
project: &p,
|
||||
footage: &NoFootage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp: cid,
|
||||
settings: &settings,
|
||||
output: &output,
|
||||
path: "/c/out.mp4",
|
||||
sink: None,
|
||||
nested_switches: true,
|
||||
options: Default::default(),
|
||||
};
|
||||
let cx = Cx::new(&job);
|
||||
let comp = cx.comp().unwrap();
|
||||
use effectcraft_render::passes::block_on;
|
||||
let want = block_on(cx.frames(comp, (0..6).collect(), |k, img| (k, img.width, img.height)));
|
||||
// On threads of their own, and taking turns (no threads): the same frames, in order.
|
||||
for beside in [Beside::shared(), &Beside(None)] {
|
||||
let (a, frames) = block_on(cx.frames_beside(beside, comp, (0..6).collect(), |k, img| (k, img.width, img.height), || 7)).unwrap();
|
||||
assert_eq!((a, &frames), (7, &want));
|
||||
}
|
||||
// A panicking encoder fails the export instead of taking the render thread down.
|
||||
let r = block_on(cx.frames_beside(Beside::shared(), comp, vec![0], |k, _| k, || -> u32 { panic!("encoder bug") }));
|
||||
match r {
|
||||
Err(crate::ExportError::Encode(e)) => assert!(e.contains("encoder bug"), "{e}"),
|
||||
other => panic!("{:?}", other.map(|(a, f)| (a, f.len()))),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interleave_takes_alternate_lines() {
|
||||
let a = Image::filled(3, 4, [1.0, 0.0, 0.0, 1.0]);
|
||||
|
||||
Reference in New Issue
Block a user