mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-26 02:59:51 -06:00
The CPU compositor, which is what export runs, works on premultiplied linear f32 images, 33 MB each at 1080p, and every layer was one of them. A profile of a 9:29 export with a hardware H.264 encoder (so the encoder is no longer the bottleneck) put 61-64 % of the busy CPU in compositing, 12 % in zeroing and copying memory, 8-11 % each in the float -> 8-bit and RGBA -> Y'CbCr conversions and 4-7 % in decoding the ProRes overlays: a banner that is transparent except for its bottom 100 rows still cost a whole conversion and a whole mix, and the bottom layer paid for a zero-filled canvas and a pass that mixes it over that canvas. Three changes, each of which gives the bits the general path gives: - The canvas is allocated when a layer needs it, and an opaque first layer (a Y'CbCr frame without an alpha plane, opacity 100 %, Normal blend, the size of the output) becomes the canvas instead of being mixed over zeros. - A clip of plain media whose frame has an alpha plane (a ProRes 4444 banner) is converted and mixed only inside the rectangle where its alpha is not zero: `VideoFrame::alpha_region`, `to_linear_f32_region` and `blend::composite_at` (`composite` is `composite_at` at the origin; Dissolve's pattern follows the pixel's place in the output). A clip with effects, opacity masks, frame blending, colour management or a picture that is not placed one to one on the output takes the general path, as before. - `filmcraft_frame::pool` keeps idle f32 images (320 MiB at most). `take_f32_overwritten` hands one back with its old contents, which the conversions overwrite whole (so no zero-fill), and the export pipeline gives each frame's image back after the 8-bit conversion. Apple M1 (8 cores), a machine that was not idle, so the two builds alternate and the median is reported: - Synthetic 1080p frame (`cargo run --release -p filmcraft-render --example frame_bench`, 5 rounds): camera only 8.9 -> 6.5 ms, camera + banner 16.2 -> 7.8 ms, same picture checksum. - A real project (1080p25 camera clip, ProRes 4444 banner and subscribe bar), 30 s with the banner exported with the built-in encoder, which is deterministic: 46.4 -> 36.7 s, and both exports are the same file (same MD5), so every frame of the real pipeline matches. - The same project with a hardware encoder, 3 rounds: 30 s with the banner 17.4 -> 11.2 s (CPU 99 -> 56 s), 30 s of the camera alone 11.4 -> 8.8 s, 20 s with the subscribe bar 11.1 -> 7.0 s. Tests: `render/src/region_tests.rs` compares each shortcut with the general path bit for bit (an opaque bottom layer, banners over and under a camera, an 8-bit sticker, every blend mode, several opacities, moved, scaled and effect clips, a bigger source, reduced-resolution rendering), each render twice so the second starts from recycled buffers, and checks which layers take a shortcut; the frame tests check that a region is the crop of the whole conversion, that `alpha_region` is the box around what is not transparent, and that a buffer full of NaN never leaks into a conversion, for every pixel format. Breaking a shortcut on purpose (a region one pixel off, an opaque layer taken as the canvas at half opacity, a conversion that skips the alpha channel) makes them fail. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
176 lines
7.4 KiB
Rust
176 lines
7.4 KiB
Rust
//! Per-frame cost of the CPU compositor at 1920×1080, stage by stage, with synthetic sources:
|
||
//! a camera-like 8-bit 4:2:0 picture and a ProRes-4444-like 10-bit 4:4:4 banner whose alpha is
|
||
//! transparent except for its bottom 100 rows.
|
||
//!
|
||
//! cargo run --release -p filmcraft-render --example frame_bench
|
||
//!
|
||
//! Prints the median of 40 frames (after 6 warm-up frames) in milliseconds, wall time on the
|
||
//! global rayon pool, and a checksum of the final 8-bit picture of each scenario so two builds can
|
||
//! be compared for identical output.
|
||
|
||
use std::sync::Arc;
|
||
use std::time::Instant;
|
||
|
||
use filmcraft_frame::{AudioBuffer, Chroma, PixelData, VideoFrame};
|
||
use filmcraft_media::generators::GeneratorSource;
|
||
use filmcraft_media::{FrameRequest, Generator, MediaInfo, MediaSource};
|
||
use filmcraft_project::{ItemKind, Label, MediaClip, MediaRef, Project, SequenceSettings, TrackKind};
|
||
use filmcraft_render::blend::{self, Blend};
|
||
use filmcraft_render::{Image, RenderOptions, SourceMap, render_sequence};
|
||
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange};
|
||
|
||
const W: usize = 1920;
|
||
const H: usize = 1080;
|
||
const FPS: FrameRate = FrameRate::FPS_25;
|
||
|
||
struct Still {
|
||
info: MediaInfo,
|
||
frame: Arc<VideoFrame>,
|
||
}
|
||
|
||
impl MediaSource for Still {
|
||
fn info(&self) -> &MediaInfo {
|
||
&self.info
|
||
}
|
||
fn video_frame(&self, _req: FrameRequest) -> filmcraft_media::Result<Arc<VideoFrame>> {
|
||
Ok(self.frame.clone())
|
||
}
|
||
fn audio(&self, _start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<AudioBuffer> {
|
||
Ok(AudioBuffer::silence(sample_rate, 2, frames))
|
||
}
|
||
}
|
||
|
||
/// A camera picture: 8-bit 4:2:0, limited range BT.709, no alpha.
|
||
fn camera() -> Arc<VideoFrame> {
|
||
let y: Vec<u8> = (0..W * H).map(|i| (16 + ((i % W) * 200 / W + (i / W) * 30 / H + (i * 7919) % 5) % 219) as u8).collect();
|
||
let (cw, ch) = (W / 2, H / 2);
|
||
let u: Vec<u8> = (0..cw * ch).map(|i| (100 + (i % cw) * 40 / cw) as u8).collect();
|
||
let v: Vec<u8> = (0..cw * ch).map(|i| (150 - (i / cw) * 40 / ch) as u8).collect();
|
||
Arc::new(VideoFrame {
|
||
width: W as u32,
|
||
height: H as u32,
|
||
data: PixelData::Yuv8 { planes: [Arc::new(y), Arc::new(u), Arc::new(v)], chroma: Chroma::C420, alpha: None },
|
||
color: filmcraft_color::ColorInfo::REC709,
|
||
par: (1, 1),
|
||
pts: Tick::ZERO,
|
||
})
|
||
}
|
||
|
||
/// A banner like the channel's: 10-bit 4:4:4 with an alpha plane, opaque only in the bottom `bar` rows
|
||
/// (with a soft one-row edge), transparent elsewhere.
|
||
fn banner(bar: usize) -> Arc<VideoFrame> {
|
||
let n = W * H;
|
||
let top = H - bar;
|
||
let alpha: Vec<u16> = (0..n)
|
||
.map(|i| match i / W {
|
||
r if r + 1 < top => 0,
|
||
r if r < top => 512,
|
||
_ => 1023,
|
||
})
|
||
.collect();
|
||
Arc::new(VideoFrame {
|
||
width: W as u32,
|
||
height: H as u32,
|
||
data: PixelData::Yuv16 {
|
||
planes: [Arc::new(vec![300u16; n]), Arc::new((0..n).map(|i| 512 + (i % 64) as u16).collect()), Arc::new(vec![600u16; n])],
|
||
chroma: Chroma::C444,
|
||
bits: 10,
|
||
alpha: Some(Arc::new(alpha)),
|
||
},
|
||
color: filmcraft_color::ColorInfo::REC709,
|
||
par: (1, 1),
|
||
pts: Tick::ZERO,
|
||
})
|
||
}
|
||
|
||
fn project(layers: &[Arc<VideoFrame>]) -> (Project, filmcraft_project::ItemId, SourceMap) {
|
||
let mut p = Project::new("bench");
|
||
let mut map = SourceMap::default();
|
||
let seq = p.new_sequence("s", SequenceSettings { width: W as u32, height: H as u32, frame_rate: FPS, ..Default::default() }, layers.len().max(1), 1, None);
|
||
for (track, frame) in layers.iter().enumerate() {
|
||
let g = GeneratorSource::new(Generator::ColorMatte { color: [0.0, 0.0, 0.0, 1.0] }, W as u32, H as u32, FPS, Tick(60 * TICKS_PER_SECOND));
|
||
let info = g.info().clone();
|
||
let item = p.add_item(
|
||
&format!("layer{track}"),
|
||
Label::Iris,
|
||
ItemKind::Media(MediaClip {
|
||
media: MediaRef::Generator(g.generator.clone()),
|
||
info: info.clone(),
|
||
interpret: Default::default(),
|
||
mark_in: None,
|
||
mark_out: None,
|
||
markers: vec![],
|
||
offline: false,
|
||
proxy: None,
|
||
identity: None,
|
||
}),
|
||
None,
|
||
);
|
||
let mut clip = p.make_track_item(item, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, FPS.tick_of(2000)), FPS).expect("clip");
|
||
clip.scale_to_frame = true;
|
||
if let Some(t) = p.sequence_mut(seq).and_then(|s| s.video_tracks.get_mut(track)) {
|
||
t.items.push(clip);
|
||
}
|
||
map.0.insert(item, Arc::new(Still { info, frame: frame.clone() }));
|
||
}
|
||
(p, seq, map)
|
||
}
|
||
|
||
fn median_ms(mut f: impl FnMut()) -> f64 {
|
||
for _ in 0..6 {
|
||
f();
|
||
}
|
||
let mut v: Vec<f64> = (0..40)
|
||
.map(|_| {
|
||
let t = Instant::now();
|
||
f();
|
||
t.elapsed().as_secs_f64() * 1000.0
|
||
})
|
||
.collect();
|
||
v.sort_by(|a, b| a.total_cmp(b));
|
||
v[v.len() / 2]
|
||
}
|
||
|
||
fn checksum(bytes: &[u8]) -> u64 {
|
||
bytes.iter().fold(0xcbf29ce484222325u64, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x100000001b3))
|
||
}
|
||
|
||
fn main() {
|
||
println!("rayon threads: {}", rayon::current_num_threads());
|
||
let (cam, ban) = (camera(), banner(100));
|
||
println!("\n-- stages (ms per frame, 1920x1080) --");
|
||
println!("camera frame -> linear f32 image : {:6.2}", median_ms(|| drop(std::hint::black_box(cam.to_linear_f32_decimated(1)))));
|
||
println!("banner frame -> linear f32 image : {:6.2}", median_ms(|| drop(std::hint::black_box(ban.to_linear_f32_decimated(1)))));
|
||
println!("Image::new (zeroed 33 MB canvas) : {:6.2}", median_ms(|| drop(std::hint::black_box(Image::new(W, H)))));
|
||
let (_, _, cpx) = cam.to_linear_f32_decimated(1);
|
||
let (_, _, bpx) = ban.to_linear_f32_decimated(1);
|
||
let (cam_img, ban_img) = (Image { w: W, h: H, px: cpx }, Image { w: W, h: H, px: bpx });
|
||
let mut canvas = Image::new(W, H);
|
||
println!("composite camera over empty : {:6.2}", median_ms(|| blend::composite(&mut canvas, &cam_img, 1.0, Blend::Normal)));
|
||
println!("composite banner over camera : {:6.2}", median_ms(|| blend::composite(&mut canvas, &ban_img, 1.0, Blend::Normal)));
|
||
println!("linear f32 -> sRGB 8-bit RGBA : {:6.2}", median_ms(|| drop(std::hint::black_box(canvas.over_black_rgba8()))));
|
||
|
||
// FC_RECYCLE=1 hands each frame's float image back to the pool, as the exporter does
|
||
let recycle = std::env::var_os("FC_RECYCLE").is_some();
|
||
println!("\n-- whole frames: render_sequence + 8-bit conversion (ms per frame){} --", if recycle { ", buffers recycled" } else { "" });
|
||
for (name, layers) in [("camera only", vec![cam.clone()]), ("camera + banner", vec![cam.clone(), ban.clone()])] {
|
||
let (p, seq, map) = project(&layers);
|
||
let opts = RenderOptions::default();
|
||
let mut last = Vec::new();
|
||
let ms = median_ms(|| {
|
||
let img = render_sequence(&p, seq, FPS.tick_of(10), opts, &map);
|
||
last = img.over_black_rgba8();
|
||
if recycle {
|
||
filmcraft_frame::pool::recycle_f32(img.px);
|
||
}
|
||
});
|
||
let render_only = median_ms(|| {
|
||
let img = std::hint::black_box(render_sequence(&p, seq, FPS.tick_of(10), opts, &map));
|
||
if recycle {
|
||
filmcraft_frame::pool::recycle_f32(img.px);
|
||
}
|
||
});
|
||
println!("{name:16}: render {render_only:6.2} render + 8-bit {ms:6.2} checksum {:016x}", checksum(&last));
|
||
}
|
||
}
|