M6: H.264 MP4 export (own encoder, High profile, VBR, B-frames with edit list) + AAC

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-09-30 09:38:45 -04:00
1 parent bd816ead68
commit 3f3265bb35
4 files changed
+102 -10

No files matched your search

Generated
+1
View File
@@ -1409,6 +1409,7 @@ dependencies = [
"filmcraft-aac",
"filmcraft-codecs",
"filmcraft-frame",
"filmcraft-h264enc",
"filmcraft-isobmff",
"filmcraft-media",
"filmcraft-project",
+1 -1
View File
@@ -1613,7 +1613,7 @@ fn export_media(s: &mut Session, p: &Value) -> Result<Value> {
let seq = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
let format = str_p(p, "format").and_then(filmcraft_export::Format::from_name).unwrap_or(filmcraft_export::Format::H264);
if !filmcraft_export::available(format) {
return Err(EngineError::Other(format!("{} export is not available yet (encoder in progress); use mjpeg, png, gif or wav", format.label())));
return Err(EngineError::Other(format!("{} export is not available (no encoder registered)", format.label())));
}
let path = str_p(p, "path").map(str::to_string).ok_or_else(|| bad("file.exportMedia", "need `path`"))?;
let settings = filmcraft_export::ExportSettings {
+1
View File
@@ -18,6 +18,7 @@ filmcraft-render = { workspace = true }
filmcraft-isobmff = { workspace = true }
filmcraft-prores = { workspace = true }
filmcraft-aac = { workspace = true }
filmcraft-h264enc = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+99 -9
View File
@@ -178,7 +178,7 @@ pub type AudioEncoderFactory = fn(format: Format, sample_rate: u32, channels: u3
fn video_factories() -> &'static RwLock<Vec<EncoderFactory>> {
static F: OnceLock<RwLock<Vec<EncoderFactory>>> = OnceLock::new();
F.get_or_init(|| RwLock::new(vec![prores_factory, mjpeg_factory]))
F.get_or_init(|| RwLock::new(vec![h264_factory, prores_factory, mjpeg_factory]))
}
fn audio_factories() -> &'static RwLock<Vec<AudioEncoderFactory>> {
static F: OnceLock<RwLock<Vec<AudioEncoderFactory>>> = OnceLock::new();
@@ -192,15 +192,10 @@ pub fn register_audio_encoder(f: AudioEncoderFactory) {
audio_factories().write().unwrap_or_else(|e| e.into_inner()).insert(0, f);
}
/// Whether a format can currently be exported (its encoder is registered).
/// Whether a format can currently be exported. Every [`Format`] has a built-in encoder; this stays
/// as the hook for formats whose encoders are registered at runtime.
pub fn available(format: Format) -> bool {
match format {
Format::PngSequence | Format::Gif | Format::Wav | Format::Mjpeg => true,
f => {
let s = ExportSettings { format: f, ..Default::default() };
video_factories().read().unwrap_or_else(|e| e.into_inner()).iter().any(|fac| fac(f, 64, 64, FrameRate::FPS_24, &s).is_some())
}
}
Format::ALL.contains(&format)
}
struct MjpegEncoder {
@@ -324,6 +319,101 @@ fn prores_factory(format: Format, w: u32, h: u32, rate: FrameRate, _s: &ExportSe
.then(|| Ok(Box::new(ProResEncoder { enc: filmcraft_prores::Encoder::new(filmcraft_prores::Profile::Hq, w, h), w, h, rate }) as Box<dyn VideoEncoder>))
}
/// H.264 High (our encoder): sRGB/709 RGBA8 → 8-bit limited-range BT.709 4:2:0, VBR at the
/// requested bitrate, length-prefixed samples with the `avcC` in the sample entry.
struct H264Encoder {
enc: filmcraft_h264enc::Encoder,
w: u32,
h: u32,
rate: FrameRate,
y: Vec<u8>,
u: Vec<u8>,
v: Vec<u8>,
}
impl H264Encoder {
fn packets(&self, ps: Vec<filmcraft_h264enc::Packet>) -> Vec<EncodedPacket> {
ps.into_iter()
.map(|p| EncodedPacket { data: p.data, key: p.keyframe, duration: self.rate.den as u32, composition_offset: (p.pts - p.dts) as i32 })
.collect()
}
}
impl VideoEncoder for H264Encoder {
fn sample_entry(&self) -> SampleEntry {
let cfg = filmcraft_isobmff::AvcConfig::parse(&self.enc.avcc()).unwrap_or_else(|_| {
let (sps, pps) = self.enc.sps_pps();
filmcraft_isobmff::AvcConfig::new(vec![sps], vec![pps], 4)
});
SampleEntry::avc(cfg, self.w as u16, self.h as u16)
}
fn timescale(&self) -> u32 {
self.rate.num as u32
}
fn encode(&mut self, f: &EncoderFrame) -> Result<Vec<EncodedPacket>> {
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()))?;
Ok(self.packets(ps))
}
fn flush(&mut self) -> Result<Vec<EncodedPacket>> {
let ps = self.enc.flush();
Ok(self.packets(ps))
}
fn media_start(&self) -> Option<i64> {
// With B-frames the first DTS is one frame before the first PTS.
(self.enc.delay() > 0).then_some(self.rate.den)
}
}
/// 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;
}
}
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;
}
});
}
fn h264_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option<Result<Box<dyn VideoEncoder>>> {
if format != Format::H264 {
return None;
}
let mut cfg = filmcraft_h264enc::EncoderConfig::new(w, h, rate.num as u32, rate.den as u32);
cfg.format = filmcraft_h264enc::PacketFormat::LengthPrefixed;
cfg.aud = false;
cfg.keyint = (rate.num as f64 / rate.den as f64 * 2.0).round().max(1.0) as u32;
let kbps = s.bitrate_kbps.max(100);
cfg.rate = filmcraft_h264enc::RateControl::Vbr { target_kbps: kbps, max_kbps: kbps * 3 / 2 };
Some(
filmcraft_h264enc::Encoder::new(cfg)
.map(|enc| Box::new(H264Encoder { enc, w, h, rate, y: Vec::new(), u: Vec::new(), v: Vec::new() }) as Box<dyn VideoEncoder>)
.map_err(|e| ExportError::Encode(e.to_string())),
)
}
/// The range to export (settings → In/Out → whole sequence).
pub fn export_range(project: &Project, seq: ItemId, settings: &ExportSettings) -> Result<TimeRange> {
let q = project.sequence(seq).ok_or(ExportError::NoSequence)?;