//! Movie export: FilmCraft H.264 → MP4 (+ AAC) and ProRes → MOV (+ PCM), muxed by //! FilmCraft's ISO BMFF / QuickTime writer. use std::io::Write; use effectcraft_project::Comp; use effectcraft_project::render_queue::{Channels, OutputFormat, ProResProfile}; use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; use filmcraft_isobmff::{Brand, FourCc, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions}; use rayon::prelude::*; use crate::{ExportError, Job, Report, Result, State, batch_size, render_frame, rgba8, wants_audio}; fn enc(e: impl std::fmt::Display) -> ExportError { ExportError::Encode(e.to_string()) } fn mux_err(e: impl std::fmt::Display) -> ExportError { ExportError::Io(e.to_string()) } struct Packet { data: Vec, key: bool, /// pts − dts in the track timescale. cto: i32, } trait VideoEncoder { fn sample_entry(&self) -> SampleEntry; fn encode(&mut self, rgba: &[u8], index: u64) -> Result>; fn flush(&mut self) -> Result>; /// Edit-list media start (B-frame delay) in the track timescale. fn media_start(&self) -> Option { None } } // ---------------------------------------------------------------- H.264 struct H264 { enc: filmcraft_h264enc::Encoder, w: u32, h: u32, rate: FrameRate, y: Vec, u: Vec, v: Vec, } impl H264 { fn new(w: u32, h: u32, rate: FrameRate, kbps: u32) -> Result { 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.as_f64() * 2.0).round().max(1.0) as u32; let kbps = kbps.max(100); cfg.rate = filmcraft_h264enc::RateControl::Vbr { target_kbps: kbps, max_kbps: kbps * 3 / 2 }; let enc = filmcraft_h264enc::Encoder::new(cfg).map_err(enc)?; Ok(H264 { enc, w, h, rate, y: vec![], u: vec![], v: vec![] }) } fn packets(&self, ps: Vec) -> Vec { ps.into_iter().map(|p| Packet { data: p.data, key: p.keyframe, cto: (p.pts - p.dts) as i32 }).collect() } } impl VideoEncoder for H264 { 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 encode(&mut self, rgba: &[u8], index: u64) -> Result> { rgba_to_yuv420(rgba, self.w as usize, self.h as usize, &mut self.y, &mut self.u, &mut self.v); 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 ps = self.enc.try_encode(&frame, index as i64 * self.rate.den).map_err(enc)?; Ok(self.packets(ps)) } fn flush(&mut self) -> Result> { let ps = self.enc.flush(); Ok(self.packets(ps)) } fn media_start(&self) -> Option { (self.enc.delay() > 0).then_some(self.rate.den) } } /// 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, u: &mut Vec, v: &mut Vec) { 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 acc = vec![(0f32, 0f32, 0f32); cw]; for dy in 0..rows { 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); 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; 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() { 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; } }); } // ---------------------------------------------------------------- ProRes struct ProRes { enc: filmcraft_prores::Encoder, profile: filmcraft_prores::Profile, w: u32, h: u32, alpha: bool, } fn prores_profile(p: ProResProfile) -> filmcraft_prores::Profile { use filmcraft_prores::Profile; match p { ProResProfile::Proxy => Profile::Proxy, ProResProfile::Lt => Profile::Lt, ProResProfile::Standard => Profile::Standard, ProResProfile::Hq => Profile::Hq, ProResProfile::P4444 => Profile::P4444, ProResProfile::P4444Xq => Profile::P4444Xq, } } impl ProRes { fn new(w: u32, h: u32, profile: ProResProfile, channels: Channels) -> ProRes { // Alpha needs a 4444 flavour. let profile = if channels == Channels::Rgba && !profile.is_4444() { ProResProfile::P4444 } else { profile }; let profile = prores_profile(profile); let alpha = channels == Channels::Rgba; let mut cfg = filmcraft_prores::EncoderConfig::new(profile, w, h); cfg.encode_alpha = alpha; ProRes { enc: filmcraft_prores::Encoder::with_config(cfg), profile, w, h, alpha } } fn is_444(&self) -> bool { matches!(self.profile, filmcraft_prores::Profile::P4444 | filmcraft_prores::Profile::P4444Xq) } } impl VideoEncoder for ProRes { fn sample_entry(&self) -> SampleEntry { SampleEntry::prores(FourCc(self.profile.fourcc()), self.w as u16, self.h as u16) } fn encode(&mut self, rgba: &[u8], _index: u64) -> Result> { let chroma = if self.is_444() { filmcraft_prores::ChromaFormat::Yuv444 } else { filmcraft_prores::ChromaFormat::Yuv422 }; let mut fr = filmcraft_prores::Frame::new(self.w, self.h, chroma, 10, self.alpha); rgba_to_yuv_10(rgba, self.w as usize, self.h as usize, self.is_444(), &mut fr.y, &mut fr.cb, &mut fr.cr); if let Some(a) = fr.alpha.as_mut() { for (d, s) in a.iter_mut().zip(rgba.chunks_exact(4)) { *d = ((s[3] as u32 * 1023 + 127) / 255) as u16; } } let data = self.enc.encode(&fr).map_err(enc)?; Ok(vec![Packet { data, key: true, cto: 0 }]) } fn flush(&mut self) -> Result> { Ok(vec![]) } } /// BT.709 limited-range 10-bit 4:2:2 (horizontal chroma average) or 4:4:4 from RGBA8. fn rgba_to_yuv_10(rgba: &[u8], w: usize, h: usize, full_chroma: bool, y: &mut [u16], cb: &mut [u16], cr: &mut [u16]) { let cw = if full_chroma { w } else { w.div_ceil(2) }; debug_assert!(y.len() >= w * h && rgba.len() >= w * h * 4); y.par_chunks_mut(w).zip(cb.par_chunks_mut(cw).zip(cr.par_chunks_mut(cw))).enumerate().for_each(|(row, (yr, (cbr, crr)))| { let src = &rgba[row * w * 4..(row + 1) * w * 4]; let mut us = vec![0f32; w]; let mut vs = vec![0f32; w]; 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 l = 0.2126 * r + 0.7152 * g + 0.0722 * b; yr[x] = (64.0 + 876.0 * l).round().clamp(4.0, 1019.0) as u16; us[x] = (b - l) / 1.8556; vs[x] = (r - l) / 1.5748; } for cx in 0..cw { let (u, v) = if full_chroma { (us[cx], vs[cx]) } else { let (a, b2) = (cx * 2, (cx * 2 + 1).min(w - 1)); ((us[a] + us[b2]) * 0.5, (vs[a] + vs[b2]) * 0.5) }; cbr[cx] = (512.0 + 896.0 * u).round().clamp(4.0, 1019.0) as u16; crr[cx] = (512.0 + 896.0 * v).round().clamp(4.0, 1019.0) as u16; } }); } // ---------------------------------------------------------------- audio enum Audio { Aac(Box), Pcm, } const AUDIO_CHANNELS: u32 = 2; fn deinterleave(buf: &[f32]) -> [Vec; 2] { let l = buf.chunks_exact(2).map(|p| p[0]).collect(); let r = buf.chunks_exact(2).map(|p| p[1]).collect(); [l, r] } // ---------------------------------------------------------------- the movie type Writer<'f, 's> = Mp4Writer<&'f mut crate::out::Out<'s>>; pub(crate) fn movie(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result { let rate = job.settings.rate(comp); let fmt = job.output.format; let channels = if fmt == OutputFormat::ProRes { job.output.channels } else { Channels::Rgb }; let mut venc: Box = match fmt { OutputFormat::H264 => Box::new(H264::new(w, h, rate, job.output.bitrate_kbps)?), _ => Box::new(ProRes::new(w, h, job.output.prores_profile, channels)), }; let brand = if fmt == OutputFormat::H264 { Brand::Mp4 } else { Brand::Mov }; let batch = batch_size(); let render = |k: u64| -> Vec { rgba8(&render_frame(job, comp, k), comp, channels, w, h) }; // Encode the first batch before creating tracks: encoders finalise their config on frame 1. let first_end = batch.min(st.total); let first: Vec> = (0..first_end).into_par_iter().map(render).collect(); let mut pending = Vec::new(); for (k, px) in first.iter().enumerate() { pending.extend(venc.encode(px, k as u64)?); } drop(first); st.advance(first_end)?; let mut file = crate::out::create(job.sink, job.path)?; let opts = WriterOptions::new(brand); let movie_ts = opts.movie_timescale.max(1) as i128; let mut mux: Writer = Mp4Writer::new(&mut file, opts).map_err(mux_err)?; let mut vcfg = TrackConfig::new(venc.sample_entry(), rate.num as u32); if let Some(start) = venc.media_start() { // An explicit edit: the convenience `media_start` edit would subtract the B-frame delay // from the media duration and drop the last frame. let frames_ts = st.total as i128 * rate.den as i128; let seg = (frames_ts * movie_ts + rate.num as i128 / 2) / rate.num as i128; vcfg.edits = vec![filmcraft_isobmff::Edit { segment_duration: seg as u64, media_time: start, media_rate: 0x10000 }]; } let vt = mux.add_track(vcfg).map_err(mux_err)?; let sr = job.output.audio_sample_rate.clamp(8_000, 192_000); let with_audio = wants_audio(job); let mut audio = None; if with_audio { let (a, entry, start) = if brand == Brand::Mp4 { let e = filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(sr, AUDIO_CHANNELS as usize, 320_000)).map_err(enc)?; let entry = SampleEntry::aac(e.audio_specific_config(), AUDIO_CHANNELS, sr); let priming = e.priming_samples() as i64; (Audio::Aac(Box::new(e)), entry, Some(priming)) } else { let pcm = PcmConfig { bits: 16, float: false, big_endian: false, signed: true, channels: AUDIO_CHANNELS, sample_rate: sr as f64 }; (Audio::Pcm, SampleEntry::pcm(pcm), None) }; let mut c = TrackConfig::new(entry, sr); c.media_start = start; let at = mux.add_track(c).map_err(mux_err)?; audio = Some((a, at)); } let (span_start, span_end) = job.settings.span(comp); let mut cursor = span_start.to_units_floor(sr as i64); let write_video = |mux: &mut Writer, ps: Vec| -> Result<()> { for p in ps { mux.write_sample(vt, WriteSample { data: &p.data, duration: rate.den as u32, composition_offset: p.cto, is_sync: p.key }).map_err(mux_err)?; } Ok(()) }; let mut write_audio_until = |mux: &mut Writer, until: Tick, cursor: &mut i64| -> Result<()> { let Some((a, at)) = audio.as_mut() else { return Ok(()) }; let end = until.to_units_floor(sr as i64); if end <= *cursor { return Ok(()); } let n = (end - *cursor) as usize; let start = Tick(((*cursor as i128 * TICKS_PER_SECOND as i128) / sr as i128) as i64); let buf = effectcraft_render::audio::mix_comp(job.project, job.footage, job.expr, job.comp, start, n, sr); *cursor = end; match a { Audio::Aac(e) => { let planar = deinterleave(&buf); let refs: Vec<&[f32]> = planar.iter().map(Vec::as_slice).collect(); for au in e.encode(&refs) { mux.write_sample(*at, WriteSample { data: &au, duration: 1024, composition_offset: 0, is_sync: true }).map_err(mux_err)?; } } Audio::Pcm => { let mut pcm = Vec::with_capacity(n * 4); for s in buf { pcm.extend_from_slice(&((s.clamp(-1.0, 1.0) * 32767.0).round() as i16).to_le_bytes()); } mux.write_sample(*at, WriteSample { data: &pcm, duration: n as u32, composition_offset: 0, is_sync: true }).map_err(mux_err)?; } } 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> = (i..end).into_par_iter().map(render).collect(); 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)?; st.advance(end - i)?; i = end; } write_video(&mut mux, venc.flush()?)?; write_audio_until(&mut mux, span_end, &mut cursor)?; if let Some((Audio::Aac(e), at)) = audio.as_mut() { for au in e.flush() { mux.write_sample(*at, WriteSample { data: &au, duration: 1024, composition_offset: 0, is_sync: true }).map_err(mux_err)?; } } let out = mux.finish().map_err(mux_err)?; out.flush().map_err(mux_err)?; let bytes = file.finish()?; Ok(Report { path: job.path.to_string(), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: with_audio }) } #[cfg(test)] mod tests { use super::*; #[test] fn yuv_of_primaries() { let rgba = [255u8, 255, 255, 255, 0, 0, 0, 255, 255, 0, 0, 255, 0, 255, 0, 255]; let (mut y, mut u, mut v) = (vec![], vec![], vec![]); rgba_to_yuv420(&rgba, 2, 2, &mut y, &mut u, &mut v); assert_eq!(y[0], 235); assert_eq!(y[1], 16); assert_eq!((u.len(), v.len()), (1, 1)); let (mut y10, mut cb, mut cr) = (vec![0u16; 4], vec![0u16; 4], vec![0u16; 4]); rgba_to_yuv_10(&rgba, 2, 2, true, &mut y10, &mut cb, &mut cr); assert_eq!(y10[0], 940); assert_eq!(y10[1], 64); assert_eq!(cb[0], 512); assert!(cr[2] > 900, "red has high Cr: {}", cr[2]); } }