From 09947712d0a3c42f954dbd19f37ef91aae76324b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 04:45:27 -0400 Subject: [PATCH] M6.6: MXF OP1a / OP-Atom writer and MXF export formats filmcraft-mxf gains a streaming writer (ST 377-1 partitions, primer, header metadata, VBR index segments with temporal offsets, RIP; ST 378 OP1a frame wrapping, ST 390 OP-Atom clip wrapping) for VC-3, ProRes, AVC byte stream and PCM with start timecode and caller-supplied package UMIDs, plus write_opatom_pcm. filmcraft-export adds mxf-op1a / mxf-opatom formats (DNxHR, ProRes or H.264 + PCM; OP-Atom writes one mono file per channel), presets, the engine mxfVideoCodec parameter and CLI .mxf inference. Tests read our files back and check them against ffprobe / ffmpeg. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + README.md | 2 +- apps/filmcraft-cli/src/main.rs | 14 +- crates/engine/src/commands.rs | 2 +- crates/engine/src/export_tests.rs | 24 +- crates/engine/src/export_tools.rs | 3 + crates/engine/src/masks.rs | 9 +- crates/engine/src/panels_tests.rs | 3 +- crates/engine/src/previews.rs | 10 +- crates/engine/src/project_manager.rs | 1 + crates/engine/src/proxies.rs | 2 +- crates/engine/src/scene_detect.rs | 9 +- crates/export/Cargo.toml | 1 + crates/export/src/job.rs | 68 +- crates/export/src/lib.rs | 60 +- crates/export/src/mxf_out.rs | 208 ++++ crates/export/src/mxf_tests.rs | 261 +++++ crates/export/src/presets.rs | 25 +- crates/export/src/settings.rs | 46 +- crates/mxf/Cargo.toml | 2 +- crates/mxf/README.md | 66 +- crates/mxf/src/lib.rs | 8 + crates/mxf/src/write/labels.rs | 144 +++ crates/mxf/src/write/mod.rs | 1190 ++++++++++++++++++++++ crates/mxf/src/write/tests.rs | 318 ++++++ crates/ui-egui/src/panels/export_mode.rs | 21 +- docs/architecture.md | 6 +- 27 files changed, 2460 insertions(+), 44 deletions(-) create mode 100644 crates/export/src/mxf_out.rs create mode 100644 crates/export/src/mxf_tests.rs create mode 100644 crates/mxf/src/write/labels.rs create mode 100644 crates/mxf/src/write/mod.rs create mode 100644 crates/mxf/src/write/tests.rs diff --git a/Cargo.lock b/Cargo.lock index d874f8b5..e49bd2a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1856,6 +1856,7 @@ dependencies = [ "filmcraft-h264enc", "filmcraft-isobmff", "filmcraft-media", + "filmcraft-mxf", "filmcraft-project", "filmcraft-prores", "filmcraft-render", diff --git a/README.md b/README.md index 2f1b9679..d9f8edd1 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ MXF and hardware decode are next ([roadmap](ROADMAP.md)).

Export mode, set to write the trailer as H.264 MP4.

- **H.264 MP4 with AAC, using our own encoders.** A 6-second 960×540 render takes 1.3 seconds and decodes cleanly in ffmpeg with error concealment switched off. -- **Also:** Apple ProRes 422 HQ and Motion JPEG in QuickTime, PNG sequences, animated GIF and WAV. +- **Also:** Apple ProRes 422 HQ and Motion JPEG in QuickTime, MXF OP1a and Avid-style OP-Atom (DNxHR, ProRes or H.264 with PCM and start timecode), PNG sequences, animated GIF and WAV. - **Background jobs** with progress and cancel, so you keep editing while it renders. - **Render previews:** the render bar marks segments green, yellow or red; rendered previews are cached by content, so an edit only invalidates what it touches and undo brings the green back. diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs index 3ac1e7c5..36dcd7ba 100644 --- a/apps/filmcraft-cli/src/main.rs +++ b/apps/filmcraft-cli/src/main.rs @@ -27,7 +27,7 @@ SUBCOMMANDS export [--preset name] [--format f] [--range r] [--start s --end s] [--settings json] export the active sequence and wait for it to finish: with an export preset (`export --list-presets`; built-in or the user's), or - a format (h264|prores|dnxhr|mjpeg|png|tiff|bmp|gif|wav|aiff, guessed + a format (h264|prores|dnxhr|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff, guessed from the extension); --range entire|inOut|workArea, or a custom range in seconds; --settings is ExportSettings JSON merged over the preset; --queue adds to the export queue and runs it instead @@ -136,7 +136,7 @@ fn format_for(path: &str) -> Option<&'static str> { Some(match ext.as_str() { "mp4" | "m4v" => "h264", "mov" => "prores", - "mxf" => "dnxhr", + "mxf" => "mxf-op1a", "png" => "png", "gif" => "gif", "wav" => "wav", @@ -366,3 +366,13 @@ async fn main() { other => usage(format!("unknown subcommand `{other}`")), } } + +#[cfg(test)] +mod format_tests { + #[test] + fn export_format_from_extension() { + assert_eq!(super::format_for("out/clip.MXF"), Some("mxf-op1a")); + assert_eq!(super::format_for("a.mov"), Some("prores")); + assert_eq!(super::format_for("noext"), None); + } +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 69f9bf06..867605dc 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -859,7 +859,7 @@ fn build() -> Vec { "Media…", ["File", "Export"], None, - r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|prores|dnxhr|mjpeg|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, + r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|prores|dnxhr|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, has_seq, crate::export_tools::export_media ), diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index fc0c0381..4ecce8dc 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -124,6 +124,18 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { assert_eq!((v["width"].as_u64().unwrap(), v["height"].as_u64().unwrap()), (640, 360)); continue; } + "mxf-opatom" => { + // Avid style: picture only in Demo.mxf, one mono PCM file per channel + assert_eq!(fmt_name, "mxf", "{name}"); + assert_eq!(j["streams"].as_array().unwrap().len(), 1, "{name}"); + assert_eq!(stream(&j, "video").unwrap()["codec_name"], "dnxhd", "{name}"); + for k in 1..=2 { + let a = probe(&format!("{}_A{k}.mxf", out.trim_end_matches(".mxf"))).unwrap(); + let a = stream(&a, "audio").unwrap(); + assert_eq!((a["codec_name"].as_str(), a["channels"].as_u64()), (Some("pcm_s24le"), Some(1)), "{name} A{k}"); + } + continue; + } _ => {} } let v = stream(&j, "video").unwrap_or_else(|| panic!("{name}: no video")); @@ -167,6 +179,16 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { assert!((vbr / (mbps * px) - 1.0).abs() < 0.25, "{name}: {vbr:.1} Mb/s vs nominal {:.1}", mbps * px); assert_eq!(a["codec_name"], "pcm_s24le", "{name}"); } + "mxf-op1a" => { + assert_eq!(fmt_name, "mxf", "{name}"); + let codec = match settings["mxfVideoCodec"].as_str().unwrap() { + "proRes" => "prores", + "h264" => "h264", + _ => "dnxhd", + }; + assert_eq!(v["codec_name"], codec, "{name}"); + assert_eq!(a["codec_name"], "pcm_s24le", "{name}"); + } other => panic!("{name}: unexpected format {other}"), } } @@ -222,7 +244,7 @@ fn user_presets_persist_with_favourites_import_and_export() { let favs = s.execute("export.presets.list", json!({"favorites": true})).unwrap()["presets"].as_array().unwrap().len(); assert_eq!(favs, 2); let hits = s.execute("export.presets.list", json!({"query": "prores"})).unwrap()["presets"].as_array().unwrap().len(); - assert_eq!(hits, 4); + assert_eq!(hits, 5, "four QuickTime ProRes presets and MXF OP1a ProRes 422 HQ"); // persisted: a fresh session with the same data directory sees both let mut s2 = demo(); s2.export_presets.set_dir(&dir.0); diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index 53de39ee..c6b0f393 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -209,6 +209,9 @@ pub fn settings_from_params(s: &Session, p: &Value, cmd: &str) -> Result<(Option if let Some(v) = str_p(p, "dnxProfile") { settings.dnx_profile = v.to_string(); } + if let Some(v) = str_p(p, "mxfVideoCodec") { + settings.mxf_video_codec = serde_json::from_value(json!(v)).map_err(|_| bad(cmd, "mxfVideoCodec: dnxhr | proRes | h264"))?; + } if let Some(v) = bool_p(p, "sdr") { settings.sdr = v; } diff --git a/crates/engine/src/masks.rs b/crates/engine/src/masks.rs index e0704dce..1871aeed 100644 --- a/crates/engine/src/masks.rs +++ b/crates/engine/src/masks.rs @@ -618,7 +618,14 @@ fn track(s: &mut Session, p: &Value) -> Result { let r = match &err { // stopping keeps what was tracked Some(e) if e != "stopped" => Err(e.clone()), - _ => Ok(filmcraft_export::Report { path: String::new(), frames: done, seconds: secs, bytes: 0, render_fps: done as f64 / secs.max(1e-6) }), + _ => Ok(filmcraft_export::Report { + path: String::new(), + frames: done, + seconds: secs, + bytes: 0, + render_fps: done as f64 / secs.max(1e-6), + extra_files: Vec::new(), + }), }; *lock(&prog.status) = match &err { Some(e) => format!("{e} ({done} frame(s) tracked)"), diff --git a/crates/engine/src/panels_tests.rs b/crates/engine/src/panels_tests.rs index 5781c2a2..357e995a 100644 --- a/crates/engine/src/panels_tests.rs +++ b/crates/engine/src/panels_tests.rs @@ -141,7 +141,8 @@ fn events_report_background_jobs() { s.jobs.extend([a.clone(), b.clone(), c.clone()]); s.poll_persistence(); assert_eq!(s.log.entries.iter().filter(|e| e.message.starts_with("Started:")).count(), 3); - *a.result.lock().unwrap() = Some(Ok(filmcraft_export::Report { path: "a.mp4".into(), frames: 1, seconds: 0.1, bytes: 1, render_fps: 10.0 })); + *a.result.lock().unwrap() = + Some(Ok(filmcraft_export::Report { path: "a.mp4".into(), frames: 1, seconds: 0.1, bytes: 1, render_fps: 10.0, extra_files: Vec::new() })); *b.result.lock().unwrap() = Some(Err("disk full".into())); c.progress.cancel.store(true, std::sync::atomic::Ordering::Relaxed); *c.result.lock().unwrap() = Some(Err("cancelled".into())); diff --git a/crates/engine/src/previews.rs b/crates/engine/src/previews.rs index 130b1175..17a114d4 100644 --- a/crates/engine/src/previews.rs +++ b/crates/engine/src/previews.rs @@ -533,6 +533,7 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result { seconds: secs, bytes, render_fps: done_frames as f64 / secs.max(1e-6), + extra_files: Vec::new(), }); if let Err(e) = &r { *prog.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(e.clone()); @@ -612,7 +613,14 @@ pub fn render_audio(s: &mut Session, p: &Value) -> Result { store.add_audio(&g.hash); } let secs = t0.elapsed().as_secs_f64(); - let r = outcome.map(|_| filmcraft_export::Report { path: dir.to_string_lossy().to_string(), frames: 0, seconds: secs, bytes, render_fps: 0.0 }); + let r = outcome.map(|_| filmcraft_export::Report { + path: dir.to_string_lossy().to_string(), + frames: 0, + seconds: secs, + bytes, + render_fps: 0.0, + extra_files: Vec::new(), + }); if let Err(e) = &r { *prog.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(e.clone()); } diff --git a/crates/engine/src/project_manager.rs b/crates/engine/src/project_manager.rs index 04241876..f0319160 100644 --- a/crates/engine/src/project_manager.rs +++ b/crates/engine/src/project_manager.rs @@ -217,6 +217,7 @@ pub fn run(s: &mut Session, p: &Value) -> Result { seconds: secs, bytes, render_fps: 0.0, + extra_files: Vec::new(), }); if let Err(e) = &r { *prog.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(e.clone()); diff --git a/crates/engine/src/proxies.rs b/crates/engine/src/proxies.rs index ff625700..1c0db146 100644 --- a/crates/engine/src/proxies.rs +++ b/crates/engine/src/proxies.rs @@ -232,7 +232,7 @@ pub fn start_job(s: &mut Session, label: String, work: Vec<(ItemId, String, &'st *prog.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(e.clone()); Err(e) } - None => Ok(Report { path: String::new(), frames, seconds: secs, bytes, render_fps: frames as f64 / secs.max(1e-6) }), + None => Ok(Report { path: String::new(), frames, seconds: secs, bytes, render_fps: frames as f64 / secs.max(1e-6), extra_files: Vec::new() }), }; *prog.status.lock().unwrap_or_else(|e| e.into_inner()) = match &r { Ok(_) => format!("Done in {secs:.1}s"), diff --git a/crates/engine/src/scene_detect.rs b/crates/engine/src/scene_detect.rs index ab8d430c..c55a341a 100644 --- a/crates/engine/src/scene_detect.rs +++ b/crates/engine/src/scene_detect.rs @@ -193,7 +193,14 @@ fn detect(s: &mut Session, p: &Value) -> Result { Some(e) => Err(e), None => { *lock(&out) = Some(found); - Ok(filmcraft_export::Report { path: String::new(), frames: done, seconds: secs, bytes: 0, render_fps: done as f64 / secs.max(1e-6) }) + Ok(filmcraft_export::Report { + path: String::new(), + frames: done, + seconds: secs, + bytes: 0, + render_fps: done as f64 / secs.max(1e-6), + extra_files: Vec::new(), + }) } }; *lock(&res) = Some(r); diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml index ac44cb56..22c2de4c 100644 --- a/crates/export/Cargo.toml +++ b/crates/export/Cargo.toml @@ -22,6 +22,7 @@ filmcraft-text = { workspace = true } filmcraft-isobmff = { workspace = true } filmcraft-prores = { workspace = true } filmcraft-dnx = { workspace = true } +filmcraft-mxf = { workspace = true } filmcraft-aac = { workspace = true } filmcraft-h264enc = { workspace = true } serde = { workspace = true } diff --git a/crates/export/src/job.rs b/crates/export/src/job.rs index 04796560..22de58c6 100644 --- a/crates/export/src/job.rs +++ b/crates/export/src/job.rs @@ -22,6 +22,7 @@ use filmcraft_time::FrameRate; use rayon::prelude::*; use crate::audio_out::AudioOut; +use crate::mxf_out::{MxfMux, MxfSetup}; use crate::pipeline::Pipeline; use crate::settings::{AudioCodec, BitrateMode, Multiplexer}; use crate::{ @@ -59,6 +60,10 @@ pub struct Exporter { first_pass: bool, /// Created after the first batch (encoders may finalise their codec config then). mux: Option>, + /// MXF exports write through this instead of `mux`. + mxf: Option, + seq: ItemId, + project: Arc, vt: usize, at: Option, t0: web_time::Instant, @@ -66,7 +71,7 @@ pub struct Exporter { /// Whether [`Exporter`] handles a format. pub fn stepped(format: Format) -> bool { - matches!(format, Format::H264 | Format::ProRes | Format::DnxHr | Format::Mjpeg) + matches!(format, Format::H264 | Format::ProRes | Format::DnxHr | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom) } fn make_venc(settings: &ExportSettings, w: u32, h: u32, rate: FrameRate) -> Result> { @@ -74,8 +79,8 @@ fn make_venc(settings: &ExportSettings, w: u32, h: u32, rate: FrameRate) -> Resu .read() .unwrap_or_else(|e| e.into_inner()) .iter() - .find_map(|fac| fac(settings.format, w, h, rate, settings)) - .ok_or_else(|| ExportError::Unsupported(format!("{} encoder not available yet", settings.format.label())))? + .find_map(|fac| fac(settings.video_format(), w, h, rate, settings)) + .ok_or_else(|| ExportError::Unsupported(format!("{} encoder not available yet", settings.video_format().label())))? } impl Exporter { @@ -88,7 +93,7 @@ impl Exporter { let q = project.sequence(seq).ok_or(ExportError::NoSequence)?; // HDR sequences export HDR (H.264 / ProRes / DNxHR) unless SDR is asked for let pipe = q.settings.color; - let hdr_out = pipe.working.is_hdr() && !settings.sdr && matches!(settings.format, Format::H264 | Format::ProRes | Format::DnxHr); + let hdr_out = pipe.working.is_hdr() && !settings.sdr && matches!(settings.video_format(), Format::H264 | Format::ProRes | Format::DnxHr); let mut settings = settings.clone(); settings.signal = match (hdr_out, pipe.working) { (true, filmcraft_color::WorkingSpace::Rec2100Pq) => ColorSignal::PQ, @@ -101,7 +106,7 @@ impl Exporter { settings.max_bitrate_kbps = Some(r.max_kbps); settings.keyframe_distance = Some(r.keyint); settings.adaptive_bitrate = None; - let two_pass = settings.format == Format::H264 && settings.bitrate_mode == BitrateMode::Vbr2Pass; + let two_pass = settings.video_format() == Format::H264 && settings.bitrate_mode == BitrateMode::Vbr2Pass; settings.h264_pass = if two_pass { H264Pass::First } else { H264Pass::Single }; let range = export_range(&project, seq, &settings)?; let pipe = Pipeline::new(project.clone(), seq, &settings, hdr_out)?; @@ -136,6 +141,9 @@ impl Exporter { measured, first_pass: two_pass, mux: None, + mxf: None, + seq, + project: project.clone(), vt: 0, at: None, t0: web_time::Instant::now(), @@ -158,6 +166,12 @@ impl Exporter { } fn write_audio(&mut self, planar: Option>>) -> Result<()> { + if let Some(m) = self.mxf.as_mut() { + return match planar { + Some(buf) => m.write_audio(&buf), + None => Ok(()), + }; + } let (Some(at), Some(buf)) = (self.at, planar) else { return Ok(()) }; let n = buf.first().map_or(0, Vec::len); if n == 0 { @@ -192,6 +206,9 @@ impl Exporter { } fn write_video(&mut self, packets: Vec) -> Result<()> { + if let Some(m) = self.mxf.as_mut() { + return m.write_video(packets); + } let mux = self.mux.as_mut().expect("mux created"); for p in packets { mux.write_sample(self.vt, WriteSample { data: &p.data, duration: p.duration, composition_offset: p.composition_offset, is_sync: p.key }) @@ -202,6 +219,23 @@ impl Exporter { /// Create the muxer and its tracks (after the first batch was encoded). fn open_mux(&mut self) -> Result<()> { + if self.settings.format.is_mxf() { + let q = self.project.sequence(self.seq).ok_or(ExportError::NoSequence)?; + // start timecode of the first exported frame, at the output rate + let seq_rate = q.settings.frame_rate; + let start = self.pipe.rate.frame_at(seq_rate.tick_of(q.start_timecode)) + self.f0; + let name = self.project.item(self.seq).map(|i| i.name.clone()).unwrap_or_default(); + self.mxf = Some(MxfMux::new(MxfSetup { + settings: &self.settings, + name, + width: self.pipe.w, + height: self.pipe.h, + rate: self.pipe.rate, + audio: self.audio.as_ref().map(|a| (a.sr, a.channels)), + timecode: (start, q.settings.drop_frame), + })?); + return Ok(()); + } let file = Out::create(&self.settings)?; let mut opts = WriterOptions::new(self.brand); opts.metadata = self.settings.metadata.udta(); @@ -262,7 +296,7 @@ impl Exporter { return Ok(Step::Progress); } let mixed = match self.audio.as_ref().map(|a| a.sr) { - Some(sr) if self.mux.is_none() || self.at.is_some() => { + Some(sr) if (self.mux.is_none() && self.mxf.is_none()) || self.at.is_some() || self.mxf.is_some() => { let until = self.sample_at_frame(end, sr); self.audio.as_mut().and_then(|a| a.pull(until, sources)) } @@ -272,7 +306,7 @@ impl Exporter { return Ok(Step::Pending); } let packets = self.encode(&frames, f)?; - if self.mux.is_none() { + if self.mux.is_none() && self.mxf.is_none() { self.open_mux()?; } self.write_video(packets)?; @@ -295,7 +329,7 @@ impl Exporter { if filmcraft_media::pending::take() { return Ok(Step::Pending); } - if self.mux.is_none() { + if self.mux.is_none() && self.mxf.is_none() { self.open_mux()?; } let tail = self.venc.flush()?; @@ -309,15 +343,27 @@ impl Exporter { .map_err(|e| ExportError::Io(e.to_string()))?; } } - let w = self.mux.take().expect("mux created").finish().map_err(|e| ExportError::Io(e.to_string()))?; - let bytes = w.finish(&self.settings)?; + let (bytes, extra_files) = match self.mxf.take() { + Some(m) => m.finish(&self.settings)?, + None => { + let w = self.mux.take().expect("mux created").finish().map_err(|e| ExportError::Io(e.to_string()))?; + (w.finish(&self.settings)?, Vec::new()) + } + }; let secs = self.t0.elapsed().as_secs_f64(); let nframes = self.frames(); if !self.settings.part_of_batch { progress.finished.store(true, Ordering::Relaxed); progress.set_status(format!("Done in {secs:.1}s")); } - Ok(Step::Done(Report { path: self.settings.path.clone(), frames: nframes, seconds: secs, bytes, render_fps: nframes as f64 / secs.max(1e-6) })) + Ok(Step::Done(Report { + path: self.settings.path.clone(), + frames: nframes, + seconds: secs, + bytes, + render_fps: nframes as f64 / secs.max(1e-6), + extra_files, + })) } fn encode(&mut self, frames: &[(Vec, Vec)], f: i64) -> Result> { diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index d5373cbd..cf73c663 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -16,12 +16,14 @@ use std::io::Write; mod audio_out; mod job; +mod mxf_out; mod pcm; mod pipeline; pub mod presets; pub mod settings; pub use audio_out::LoudnessReport; pub use job::{Exporter, Step, stepped}; +pub use mxf_out::opatom_audio_paths; pub use pcm::{image_sequence_path, write_aiff, write_wav}; pub use pipeline::limit_rgba8; pub use presets::{ExportPreset, builtin_presets}; @@ -82,6 +84,13 @@ pub enum Format { Wav, #[serde(rename = "aiff", alias = "Aiff")] Aiff, + /// MXF OP1a (SMPTE ST 378): frame-wrapped DNxHR, ProRes or H.264 ([`MxfVideoCodec`]) + PCM. + #[serde(rename = "mxf-op1a", alias = "MxfOp1a")] + MxfOp1a, + /// MXF OP-Atom (SMPTE ST 390, Avid style): the picture in one file, one mono PCM file per + /// audio channel ([`opatom_audio_paths`]). + #[serde(rename = "mxf-opatom", alias = "MxfOpAtom")] + MxfOpAtom, } impl Format { @@ -89,7 +98,9 @@ impl Format { Some(match s.to_ascii_lowercase().replace([' ', '-', '_', '.'], "").as_str() { "h264" | "mp4" | "avc" | "m4v" => Format::H264, "prores" | "mov" | "appleprores" => Format::ProRes, - "dnxhr" | "dnxhd" | "dnx" | "avid" | "aviddnxhr" | "aviddnxhd" | "vc3" | "mxf" => Format::DnxHr, + "dnxhr" | "dnxhd" | "dnx" | "avid" | "aviddnxhr" | "aviddnxhd" | "vc3" => Format::DnxHr, + "mxf" | "mxfop1a" | "op1a" => Format::MxfOp1a, + "mxfopatom" | "opatom" | "mxfatom" | "avidmxf" => Format::MxfOpAtom, "mjpeg" | "motionjpeg" | "jpeg" => Format::Mjpeg, "png" | "pngsequence" => Format::PngSequence, "tif" | "tiff" | "tiffsequence" => Format::TiffSequence, @@ -113,6 +124,8 @@ impl Format { Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aiff", + Format::MxfOp1a => "mxf-op1a", + Format::MxfOpAtom => "mxf-opatom", } } pub fn extension(self) -> &'static str { @@ -125,6 +138,7 @@ impl Format { Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aif", + Format::MxfOp1a | Format::MxfOpAtom => "mxf", } } pub fn label(self) -> &'static str { @@ -139,9 +153,15 @@ impl Format { Format::Gif => "Animated GIF", Format::Wav => "Waveform Audio", Format::Aiff => "AIFF", + Format::MxfOp1a => "MXF OP1a", + Format::MxfOpAtom => "MXF OP-Atom", } } - pub const ALL: [Format; 10] = [ + /// An MXF container format. + pub fn is_mxf(self) -> bool { + matches!(self, Format::MxfOp1a | Format::MxfOpAtom) + } + pub const ALL: [Format; 12] = [ Format::H264, Format::ProRes, Format::DnxHr, @@ -152,6 +172,8 @@ impl Format { Format::Gif, Format::Wav, Format::Aiff, + Format::MxfOp1a, + Format::MxfOpAtom, ]; } @@ -192,6 +214,9 @@ pub struct ExportSettings { /// DNxHR profile: `lb`, `sq`, `hq` or `hqx` (empty = HQ). #[serde(default)] pub dnx_profile: String, + /// Video codec of the MXF formats (DNxHR unless set; the ProRes / DNxHR profile fields apply). + #[serde(default)] + pub mxf_video_codec: MxfVideoCodec, /// Encode display-referred SDR (Rec. 709, tone mapped) even when the sequence works in /// Rec. 2100 PQ/HLG. Otherwise H.264 and ProRes exports of an HDR sequence are encoded in the /// sequence's HDR space and signal it (VUI / `colr` / `mdcv` / `clli` / SEI). @@ -259,21 +284,30 @@ enum Out { impl Out { fn create(settings: &ExportSettings) -> Result { + Out::create_path(settings, &settings.path) + } + + /// An output file at `path` (OP-Atom exports write several). + fn create_path(settings: &ExportSettings, path: &str) -> Result { if settings.sink.is_some() { return Ok(Out::Mem(std::io::Cursor::new(Vec::new()))); } - let f = std::fs::File::create(&settings.path).map_err(|e| ExportError::Io(e.to_string()))?; + let f = std::fs::File::create(path).map_err(|e| ExportError::Io(format!("{path}: {e}")))?; Ok(Out::File(std::io::BufWriter::new(f))) } /// Flush (and hand in-memory output to the sink); returns the file size. fn finish(self, settings: &ExportSettings) -> Result { + self.finish_path(settings, &settings.path) + } + + fn finish_path(self, settings: &ExportSettings, path: &str) -> Result { match self { Out::File(mut w) => { w.flush().map_err(|e| ExportError::Io(e.to_string()))?; - Ok(std::fs::metadata(&settings.path).map(|m| m.len()).unwrap_or(0)) + Ok(std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)) } - Out::Mem(c) => write_output(settings, &settings.path, c.into_inner()), + Out::Mem(c) => write_output(settings, path, c.into_inner()), } } } @@ -372,6 +406,7 @@ impl Default for ExportSettings { part_of_batch: false, prores_profile: String::new(), dnx_profile: String::new(), + mxf_video_codec: MxfVideoCodec::default(), sdr: false, frame_size: None, frame_rate: None, @@ -441,6 +476,9 @@ pub struct Report { pub seconds: f64, pub bytes: u64, pub render_fps: f64, + /// Further files written besides `path` (MXF OP-Atom audio files). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub extra_files: Vec, } /// A packet produced by a video encoder. @@ -883,8 +921,9 @@ fn h264_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSetti 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; + // MXF carries the Annex B byte stream (ST 381-3) with in-band parameter sets + cfg.format = if s.format.is_mxf() { filmcraft_h264enc::PacketFormat::AnnexB } else { filmcraft_h264enc::PacketFormat::LengthPrefixed }; + cfg.aud = s.format.is_mxf(); cfg.keyint = s.keyframe_distance.filter(|k| *k > 0).unwrap_or_else(|| (rate.num as f64 / rate.den as f64 * 2.0).round().max(1.0) as u32); let kbps = s.bitrate_kbps.max(100); let max = s.max_bitrate_kbps.filter(|m| *m >= kbps).unwrap_or(kbps * 3 / 2); @@ -1069,14 +1108,14 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so } (total, count) } - Format::H264 | Format::ProRes | Format::DnxHr | Format::Mjpeg => unreachable!("stepped export"), + Format::H264 | Format::ProRes | Format::DnxHr | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom => unreachable!("stepped export"), }; let secs = t0.elapsed().as_secs_f64(); if !settings.part_of_batch { progress.finished.store(true, Ordering::Relaxed); progress.set_status(format!("Done in {secs:.1}s")); } - Ok(Report { path: settings.path.clone(), frames: nframes, seconds: secs, bytes, render_fps: nframes as f64 / secs.max(1e-6) }) + Ok(Report { path: settings.path.clone(), frames: nframes, seconds: secs, bytes, render_fps: nframes as f64 / secs.max(1e-6), extra_files: Vec::new() }) } #[cfg(test)] @@ -1087,3 +1126,6 @@ mod settings_tests; #[cfg(test)] mod surround_tests; + +#[cfg(test)] +mod mxf_tests; diff --git a/crates/export/src/mxf_out.rs b/crates/export/src/mxf_out.rs new file mode 100644 index 00000000..24233e7a --- /dev/null +++ b/crates/export/src/mxf_out.rs @@ -0,0 +1,208 @@ +//! MXF output of the stepped exporter: OP1a (one file, frame-wrapped picture + one PCM track) or +//! OP-Atom (Avid style: the picture in `path`, one mono PCM file per audio channel next to it, +//! `_A1.mxf`, `_A2.mxf`, …), written with [`filmcraft_mxf::MxfWriter`]. + +use filmcraft_mxf::{ + CodedKind, ColorSpace, FrameInfo, MxfWriter, PackageIds, Pattern, PictureCoding, PictureDesc, Rational, SoundDesc, StartTimecode, Timestamp, WriterConfig, +}; +use filmcraft_time::FrameRate; + +use crate::{ColorSignal, EncodedPacket, ExportError, ExportSettings, Format, H264Profile, Out, Result}; + +/// Paths of the OP-Atom audio files for an export to `path` with `channels` channels. +pub fn opatom_audio_paths(path: &str, channels: usize) -> Vec { + let p = std::path::Path::new(path); + let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "export".into()); + (1..=channels) + .map(|k| { + let name = format!("{stem}_A{k}.mxf"); + match p.parent().filter(|d| !d.as_os_str().is_empty()) { + Some(d) => d.join(name).to_string_lossy().to_string(), + None => name, + } + }) + .collect() +} + +fn io(e: std::io::Error) -> ExportError { + ExportError::Io(e.to_string()) +} + +/// The picture essence description for an export (codec from the MXF video codec setting). +fn picture(settings: &ExportSettings, w: u32, h: u32) -> PictureDesc { + let coding = match settings.video_format() { + Format::ProRes => { + use filmcraft_prores::Profile; + PictureCoding::ProRes { + profile: match crate::prores_profile(&settings.prores_profile) { + Profile::Proxy => 1, + Profile::Lt => 2, + Profile::Standard => 3, + _ => 4, + }, + } + } + Format::H264 => PictureCoding::Avc { + profile_idc: match settings.h264_profile { + H264Profile::Baseline => 66, + H264Profile::Main => 77, + H264Profile::High => 100, + }, + intra: false, + }, + _ => PictureCoding::Vc3 { cid: crate::dnx_profile(&settings.dnx_profile).cid() }, + }; + let mut d = PictureDesc::new(coding, w, h); + if matches!(coding, PictureCoding::ProRes { .. }) { + d.depth = 10; + } + d.color = match settings.signal { + s if s == ColorSignal::PQ => ColorSpace::Rec2020Pq, + s if s == ColorSignal::HLG => ColorSpace::Rec2020Hlg, + _ => ColorSpace::Rec709, + }; + if let Some((n, dn)) = settings.pixel_aspect.filter(|(n, d)| *n > 0 && *d > 0 && n != d) { + let (a, b) = (w as u64 * n as u64, h as u64 * dn as u64); + let g = gcd(a, b); + d.aspect = Rational::new((a / g) as i32, (b / g) as i32); + } + d +} + +fn gcd(a: u64, b: u64) -> u64 { + if b == 0 { a.max(1) } else { gcd(b, a % b) } +} + +/// Everything the MXF muxer needs from the export. +pub(crate) struct MxfSetup<'a> { + pub settings: &'a ExportSettings, + pub name: String, + pub width: u32, + pub height: u32, + pub rate: FrameRate, + /// (sample rate, channels) of the audio, if any. + pub audio: Option<(u32, usize)>, + /// Start timecode in frames of `rate`, and drop-frame. + pub timecode: (i64, bool), +} + +/// An MXF export in progress. +pub(crate) struct MxfMux { + video: MxfWriter, + /// OP-Atom: one writer per audio channel. + audio: Vec<(String, MxfWriter)>, + atom: bool, + bits: u32, + channels: usize, + /// Stored pictures so far, and the composition offset of the first (H.264 reorder delay). + stored: i64, + first_offset: Option, + max_display: i64, + frame_den: i64, +} + +impl MxfMux { + pub fn new(s: MxfSetup) -> Result { + let settings = s.settings; + let atom = settings.format == Format::MxfOpAtom; + let rate = Rational::new(s.rate.num as i32, s.rate.den as i32); + let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0); + let seed = format!("{}|{now}", settings.path); + let ids = PackageIds::from_seed(&seed, &s.name); + let bits = if settings.audio.bits >= 24 { 24 } else { 16 }; + let mut cfg = WriterConfig::new(if atom { Pattern::OpAtom } else { Pattern::Op1a }, rate, ids.clone()); + cfg.picture = Some(picture(settings, s.width, s.height)); + cfg.modified = Timestamp::from_unix((now / 1_000_000_000) as i64); + cfg.timecode = Some(StartTimecode { frames: s.timecode.0, rate, drop_frame: s.timecode.1 && s.rate.supports_drop_frame() }); + if !atom && let Some((sr, ch)) = s.audio { + cfg.sound = vec![SoundDesc { sample_rate: sr, channels: ch as u32, bits }]; + } + let mut audio = Vec::new(); + if atom && let Some((sr, ch)) = s.audio { + for (k, path) in opatom_audio_paths(&settings.path, ch).into_iter().enumerate() { + // Avid style: the files share the material package; each has its own file package. + let mut a = WriterConfig::new( + Pattern::OpAtom, + rate, + PackageIds { + material: ids.material, + file: filmcraft_mxf::Umid::from_seed(format!("file:{seed}:A{}", k + 1).as_bytes()), + material_name: ids.material_name.clone(), + file_name: format!("{} A{}", s.name, k + 1), + }, + ); + a.first_track_id = 3 + k as u32; + a.sound = vec![SoundDesc { sample_rate: sr, channels: 1, bits }]; + a.timecode = cfg.timecode; + a.modified = cfg.modified; + let out = Out::create_path(settings, &path)?; + audio.push((path, MxfWriter::new(out, a).map_err(io)?)); + } + } + let video = MxfWriter::new(Out::create_path(settings, &settings.path)?, cfg).map_err(io)?; + Ok(MxfMux { + video, + audio, + atom, + bits, + channels: s.audio.map_or(0, |a| a.1), + stored: 0, + first_offset: None, + max_display: -1, + frame_den: s.rate.den.max(1), + }) + } + + pub fn write_video(&mut self, packets: Vec) -> Result<()> { + for p in packets { + let intra = !matches!(self.video.config().picture.as_ref().map(|p| p.coding), Some(PictureCoding::Avc { .. })); + let info = if intra { + FrameInfo::intra() + } else { + // display position from the composition offset (pts − dts) relative to the first + let off = p.composition_offset as i64; + let first = *self.first_offset.get_or_insert(off); + let display = self.stored + (off - first) / self.frame_den; + let kind = if p.key { + CodedKind::Intra + } else if display < self.max_display { + CodedKind::Bidirectional + } else { + CodedKind::Predicted + }; + self.max_display = self.max_display.max(display); + FrameInfo { key: p.key, kind, display: Some(display) } + }; + self.video.push_picture(&p.data, info).map_err(io)?; + self.stored += 1; + } + Ok(()) + } + + pub fn write_audio(&mut self, planar: &[Vec]) -> Result<()> { + if self.channels == 0 || planar.first().is_none_or(Vec::is_empty) { + return Ok(()); + } + if self.atom { + for (k, (_, w)) in self.audio.iter_mut().enumerate() { + if let Some(c) = planar.get(k) { + w.push_sound(0, &filmcraft_mxf::encode_pcm(std::slice::from_ref(c), self.bits)).map_err(io)?; + } + } + Ok(()) + } else { + self.video.push_sound(0, &filmcraft_mxf::encode_pcm(planar, self.bits)).map_err(io) + } + } + + /// Finish every file; returns (total bytes, extra files written besides `settings.path`). + pub fn finish(self, settings: &ExportSettings) -> Result<(u64, Vec)> { + let mut total = self.video.finish().map_err(io)?.finish_path(settings, &settings.path)?; + let mut extra = Vec::new(); + for (path, w) in self.audio { + total += w.finish().map_err(io)?.finish_path(settings, &path)?; + extra.push(path); + } + Ok((total, extra)) + } +} diff --git a/crates/export/src/mxf_tests.rs b/crates/export/src/mxf_tests.rs new file mode 100644 index 00000000..27576e74 --- /dev/null +++ b/crates/export/src/mxf_tests.rs @@ -0,0 +1,261 @@ +//! MXF export (OP1a / OP-Atom): read back with our demuxer and decoders, and against ffprobe / +//! ffmpeg as external oracles (decode must succeed and match the same essence exported as MOV; +//! PCM sample-exact). + +use std::path::Path; +use std::process::Command; + +use super::*; +use filmcraft_media::generators::GeneratorSource; +use filmcraft_media::{FrameRequest, Generator, MediaSource}; +use filmcraft_project::{ItemKind, Label, MediaClip, MediaRef, SequenceSettings, TrackKind}; +use filmcraft_render::SourceMap; +use filmcraft_time::TICKS_PER_SECOND; + +/// 1 s at 24 fps, 320×180: the counting leader over V1, a 440 Hz tone on A1 (stereo mix), start +/// timecode 01:00:00:00. +fn project() -> (Arc, ItemId, SourceMap) { + let mut p = Project::new("mxf"); + let r = FrameRate::FPS_24; + let leader = GeneratorSource::new(Generator::CountingLeader, 320, 180, r, Tick(2 * TICKS_PER_SECOND)); + let tone = GeneratorSource::new(Generator::Tone { hz: 440.0, db: -6.0 }, 320, 180, r, Tick(2 * TICKS_PER_SECOND)); + let add = |p: &mut Project, g: &GeneratorSource| { + let info = g.info().clone(); + p.add_item( + &info.name.clone(), + Label::Iris, + ItemKind::Media(MediaClip { + media: MediaRef::Generator(g.generator.clone()), + info, + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: false, + proxy: None, + identity: None, + }), + None, + ) + }; + let v = add(&mut p, &leader); + let a = add(&mut p, &tone); + let seq = p.new_sequence("MXF Test", SequenceSettings { width: 320, height: 180, frame_rate: r, ..Default::default() }, 1, 1, None); + let vi = p.make_track_item(v, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, r.tick_of(24)), r).unwrap(); + let ai = p.make_track_item(a, TrackKind::Audio, Tick::ZERO, TimeRange::new(Tick::ZERO, r.tick_of(24)), r).unwrap(); + let q = p.sequence_mut(seq).unwrap(); + q.video_tracks[0].items.push(vi); + q.audio_tracks[0].items.push(ai); + q.start_timecode = 24 * 3600; + let mut m = SourceMap::default(); + m.0.insert(v, Arc::new(leader)); + m.0.insert(a, Arc::new(tone)); + (Arc::new(p), seq, m) +} + +fn tmp(name: &str) -> String { + let d = std::env::temp_dir().join(format!("fc-export-mxf-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d.join(name).to_string_lossy().to_string() +} + +fn settings(format: Format, codec: MxfVideoCodec, path: &str) -> ExportSettings { + ExportSettings { + format, + path: path.into(), + mxf_video_codec: codec, + audio: AudioSettings { bits: 24, sample_rate: Some(48_000), channels: 2, ..Default::default() }, + ..Default::default() + } +} + +fn ffprobe_json(ffprobe: &Path, path: &str) -> serde_json::Value { + let out = Command::new(ffprobe).args(["-v", "error", "-show_format", "-show_streams", "-count_frames", "-of", "json", path]).output().unwrap(); + assert!(out.status.success(), "ffprobe {path}: {}", String::from_utf8_lossy(&out.stderr)); + serde_json::from_slice(&out.stdout).unwrap() +} + +/// ffmpeg's decode of `path` (stream `map`) as raw bytes in `fmt`. +fn ffmpeg_raw(ffmpeg: &Path, path: &str, map: &str, args: &[&str]) -> Vec { + let out = Command::new(ffmpeg).args(["-v", "error", "-i", path, "-map", map]).args(args).arg("-").output().unwrap(); + assert!(out.status.success(), "ffmpeg {path}: {}", String::from_utf8_lossy(&out.stderr)); + out.stdout +} + +fn codec_name(c: MxfVideoCodec) -> &'static str { + match c { + MxfVideoCodec::Dnxhr => "dnxhd", + MxfVideoCodec::ProRes => "prores", + MxfVideoCodec::H264 => "h264", + } +} + +#[test] +fn op1a_reads_back_with_our_demuxer_and_decoders() { + let (p, seq, m) = project(); + for codec in MxfVideoCodec::ALL { + let path = tmp(&format!("op1a_{codec:?}.mxf")); + let r = export(&p, seq, &settings(Format::MxfOp1a, codec, &path), &m, &Progress::default()).unwrap(); + assert_eq!(r.frames, 24); + assert!(r.extra_files.is_empty()); + let bytes = std::fs::read(&path).unwrap(); + let mx = filmcraft_mxf::open(&bytes).unwrap(); + assert!(mx.warnings.is_empty(), "{codec:?}: {:?}", mx.warnings); + assert_eq!(mx.operational_pattern.name(), "OP1a"); + assert_eq!(mx.material_package_name.as_deref(), Some("MXF Test")); + assert_eq!(mx.timecode.unwrap().format(), "01:00:00:00"); + let v = &mx.tracks[mx.track_of_kind(filmcraft_mxf::TrackKind::Picture).unwrap()]; + assert_eq!(v.samples.len(), 24, "{codec:?}"); + assert_eq!(v.duration, Some(24)); + let a = &mx.tracks[mx.track_of_kind(filmcraft_mxf::TrackKind::Sound).unwrap()]; + assert_eq!(a.stored_sample_frames(), 48_000, "{codec:?}"); + assert_eq!(a.sound.as_ref().unwrap().bits, 24); + if codec == MxfVideoCodec::H264 { + assert!(v.temporal_offsets, "B pictures: the index gives the presentation order"); + assert!(v.samples[0].key && v.samples.iter().filter(|s| s.key).count() < 24); + } + // our MXF source decodes it like the MOV export of the same sequence + let src = filmcraft_codecs::open_bytes("x.mxf", bytes.into()).unwrap(); + let info = src.info(); + assert_eq!(info.video.as_ref().unwrap().width, 320, "{codec:?}"); + assert!((info.duration.seconds() - 1.0).abs() < 0.05, "{codec:?}: {}", info.duration.seconds()); + let mov = tmp(&format!("op1a_{codec:?}.mov")); + let ms = ExportSettings { format: codec.encoder_format(), multiplexer: Multiplexer::Mov, ..settings(Format::MxfOp1a, codec, &mov) }; + export(&p, seq, &ms, &m, &Progress::default()).unwrap(); + let msrc = filmcraft_codecs::open_bytes("x.mov", std::fs::read(&mov).unwrap().into()).unwrap(); + for f in [0i64, 5, 13, 23, 7] { + let t = FrameRate::FPS_24.tick_of(f) + Tick(1); + let a = src.video_frame(FrameRequest::full(t)).unwrap().to_rgba8(); + let b = msrc.video_frame(FrameRequest::full(t)).unwrap().to_rgba8(); + assert!(a == b, "{codec:?}: frame {f} differs from the MOV export"); + } + let pcm = src.audio(0, 48_000, 48_000).unwrap(); + assert!((pcm.peaks()[0] - 0.5).abs() < 0.05, "{codec:?}: tone at -6 dB: {}", pcm.peaks()[0]); + } +} + +#[test] +fn op_atom_writes_one_file_per_essence() { + let (p, seq, m) = project(); + let path = tmp("atom.mxf"); + let r = export(&p, seq, &settings(Format::MxfOpAtom, MxfVideoCodec::Dnxhr, &path), &m, &Progress::default()).unwrap(); + assert_eq!(r.extra_files, opatom_audio_paths(&path, 2)); + assert!(r.extra_files[0].ends_with("atom_A1.mxf") && r.extra_files[1].ends_with("atom_A2.mxf")); + let v = std::fs::read(&path).unwrap(); + let mv = filmcraft_mxf::open(&v).unwrap(); + assert_eq!(mv.operational_pattern.name(), "OP-Atom"); + assert_eq!(mv.tracks.len(), 1); + assert_eq!(mv.tracks[0].wrapping, filmcraft_mxf::Wrapping::Clip); + assert_eq!(mv.tracks[0].samples.len(), 24); + for (k, a) in r.extra_files.iter().enumerate() { + let b = std::fs::read(a).unwrap(); + let ma = filmcraft_mxf::open(&b).unwrap(); + assert_eq!(ma.operational_pattern.name(), "OP-Atom"); + let t = &ma.tracks[0]; + assert_eq!(t.kind, filmcraft_mxf::TrackKind::Sound); + assert_eq!(t.track_id, 3 + k as u32); + assert_eq!(t.sound.as_ref().unwrap().channels, 1); + assert_eq!(t.stored_sample_frames(), 48_000); + assert_eq!(t.duration, Some(24), "audio edit rate is the frame rate"); + assert_eq!(ma.timecode.unwrap().format(), "01:00:00:00"); + let pcm = ma.read_pcm(&b, 0, 0, 48_000).unwrap(); + assert!((pcm[0].iter().fold(0f32, |a, s| a.max(s.abs())) - 0.5).abs() < 0.05); + } +} + +#[test] +fn mxf_formats_and_presets() { + assert_eq!(Format::from_name("mxf"), Some(Format::MxfOp1a)); + assert_eq!(Format::from_name("mxf-op1a"), Some(Format::MxfOp1a)); + assert_eq!(Format::from_name("mxf-opatom"), Some(Format::MxfOpAtom)); + assert_eq!(Format::from_name(Format::MxfOpAtom.id()), Some(Format::MxfOpAtom)); + assert_eq!(Format::MxfOp1a.extension(), "mxf"); + let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "mxf-op1a", "mxfVideoCodec": "proRes"})).unwrap(); + assert_eq!((s.format, s.video_format()), (Format::MxfOp1a, Format::ProRes)); + assert_eq!(s.audio_codec(), AudioCodec::Pcm); + assert!(s.summary(1920, 1080, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)).format.contains("MXF OP1a (Apple ProRes)")); + let names: Vec = builtin_presets().into_iter().filter(|p| p.settings.format.is_mxf()).map(|p| p.name).collect(); + assert_eq!(names.len(), 4, "{names:?}"); + assert_eq!(opatom_audio_paths("/a/b/clip.mxf", 2), vec!["/a/b/clip_A1.mxf".to_string(), "/a/b/clip_A2.mxf".to_string()]); +} + +/// ffprobe identifies our files (operational pattern, codec, frame count, duration, timecode); +/// ffmpeg decodes them to exactly what it decodes from the same essence in a MOV export, and +/// the PCM is sample-exact against what we wrote. +#[test] +fn ffmpeg_oracle_op1a_and_op_atom() { + let (Some(ffprobe), Some(ffmpeg)) = (filmcraft_testkit::ffprobe_or_skip("MXF export probe"), filmcraft_testkit::ffmpeg_or_skip("MXF export decode")) else { + return; + }; + let (p, seq, m) = project(); + for codec in MxfVideoCodec::ALL { + let path = tmp(&format!("oracle_{codec:?}.mxf")); + export(&p, seq, &settings(Format::MxfOp1a, codec, &path), &m, &Progress::default()).unwrap(); + let j = ffprobe_json(&ffprobe, &path); + assert_eq!(j["format"]["format_name"], "mxf", "{codec:?}"); + let op = j["format"]["tags"]["operational_pattern_ul"].as_str().unwrap_or_default(); + assert!(op.starts_with("060e2b34.04010101.0d010201.0101"), "{codec:?}: OP1a label {op}"); + let streams = j["streams"].as_array().unwrap(); + let v = streams.iter().find(|s| s["codec_type"] == "video").unwrap(); + let a = streams.iter().find(|s| s["codec_type"] == "audio").unwrap(); + assert_eq!(v["codec_name"], codec_name(codec), "{codec:?}"); + assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(320), Some(180))); + assert_eq!(v["nb_read_frames"], "24", "{codec:?}"); + assert_eq!(v["r_frame_rate"], "24/1", "{codec:?}"); + let tc = j["format"]["tags"]["timecode"].as_str().or(v["tags"]["timecode"].as_str()).unwrap_or_default(); + assert_eq!(tc, "01:00:00:00", "{codec:?}"); + let dur: f64 = j["format"]["duration"].as_str().unwrap().parse().unwrap(); + assert!((dur - 1.0).abs() < 0.05, "{codec:?}: {dur}"); + assert_eq!(a["codec_name"], "pcm_s24le", "{codec:?}"); + assert_eq!(a["channels"], 2); + assert_eq!(a["sample_rate"], "48000"); + // ffmpeg's decode of the MXF equals its decode of the same essence in MOV + let mov = tmp(&format!("oracle_{codec:?}.mov")); + let ms = ExportSettings { format: codec.encoder_format(), multiplexer: Multiplexer::Mov, ..settings(Format::MxfOp1a, codec, &mov) }; + export(&p, seq, &ms, &m, &Progress::default()).unwrap(); + let raw = ["-f", "rawvideo", "-pix_fmt", "yuv422p10le"]; + let a_mxf = ffmpeg_raw(&ffmpeg, &path, "0:v:0", &raw); + let a_mov = ffmpeg_raw(&ffmpeg, &mov, "0:v:0", &raw); + assert_eq!(a_mxf.len(), 24 * 320 * 180 * 4, "{codec:?}: 24 frames decoded"); + assert!(a_mxf == a_mov, "{codec:?}: ffmpeg decodes the MXF and the MOV essence differently"); + // PCM exact: ffmpeg's samples are what our demuxer reads back + let pcm = ffmpeg_raw(&ffmpeg, &path, "0:a:0", &["-f", "s32le", "-acodec", "pcm_s32le"]); + let bytes = std::fs::read(&path).unwrap(); + let mx = filmcraft_mxf::open(&bytes).unwrap(); + let at = mx.track_of_kind(filmcraft_mxf::TrackKind::Sound).unwrap(); + let ours = mx.read_pcm(&bytes, at, 0, 48_000).unwrap(); + assert_eq!(pcm.len(), 48_000 * 2 * 4, "{codec:?}"); + for (i, c) in pcm.chunks_exact(4).enumerate() { + let s = i32::from_le_bytes([c[0], c[1], c[2], c[3]]) >> 8; + assert_eq!(s as f32 / 8_388_608.0, ours[i % 2][i / 2], "{codec:?}: sample {i}"); + } + } + // OP-Atom: picture file and one mono PCM file per channel + let path = tmp("oracle_atom.mxf"); + let r = export(&p, seq, &settings(Format::MxfOpAtom, MxfVideoCodec::Dnxhr, &path), &m, &Progress::default()).unwrap(); + let j = ffprobe_json(&ffprobe, &path); + let op = j["format"]["tags"]["operational_pattern_ul"].as_str().unwrap_or_default(); + assert!(op.starts_with("060e2b34.04010102.0d010201.10"), "OP-Atom label {op}"); + let v = &j["streams"][0]; + assert_eq!(v["codec_name"], "dnxhd"); + assert_eq!(v["nb_read_frames"], "24"); + assert_eq!(j["streams"].as_array().unwrap().len(), 1); + let mov = tmp("oracle_atom.mov"); + let ms = ExportSettings { format: Format::DnxHr, multiplexer: Multiplexer::Mov, ..settings(Format::MxfOpAtom, MxfVideoCodec::Dnxhr, &mov) }; + export(&p, seq, &ms, &m, &Progress::default()).unwrap(); + let raw = ["-f", "rawvideo", "-pix_fmt", "yuv422p10le"]; + assert!(ffmpeg_raw(&ffmpeg, &path, "0:v:0", &raw) == ffmpeg_raw(&ffmpeg, &mov, "0:v:0", &raw), "OP-Atom picture decode"); + let wav = tmp("oracle_atom.wav"); + let ws = ExportSettings { format: Format::Wav, ..settings(Format::Wav, MxfVideoCodec::Dnxhr, &wav) }; + export(&p, seq, &ws, &m, &Progress::default()).unwrap(); + let stereo = ffmpeg_raw(&ffmpeg, &wav, "0:a:0", &["-f", "s32le", "-acodec", "pcm_s32le"]); + for (k, a) in r.extra_files.iter().enumerate() { + let j = ffprobe_json(&ffprobe, a); + let s = &j["streams"][0]; + assert_eq!((s["codec_name"].as_str(), s["channels"].as_u64(), s["sample_rate"].as_str()), (Some("pcm_s24le"), Some(1), Some("48000")), "{a}"); + let mono = ffmpeg_raw(&ffmpeg, a, "0:a:0", &["-f", "s32le", "-acodec", "pcm_s32le"]); + assert_eq!(mono.len(), 48_000 * 4, "{a}"); + let want: Vec = stereo.chunks_exact(8).flat_map(|f| f[k * 4..k * 4 + 4].to_vec()).collect(); + assert!(mono == want, "{a}: channel {k} differs from the WAV export"); + } +} diff --git a/crates/export/src/presets.rs b/crates/export/src/presets.rs index c739ec73..e1545fda 100644 --- a/crates/export/src/presets.rs +++ b/crates/export/src/presets.rs @@ -5,7 +5,7 @@ use filmcraft_time::FrameRate; use serde::{Deserialize, Serialize}; -use crate::settings::{AudioCodec, AudioSettings, BitrateMode, Scaling}; +use crate::settings::{AudioCodec, AudioSettings, BitrateMode, MxfVideoCodec, Scaling}; use crate::{ExportSettings, Format}; /// A named set of export settings (`path` and `range` are left empty). @@ -64,6 +64,20 @@ fn mov(format: Format, profile: &str) -> ExportSettings { s } +fn mxf(format: Format, codec: MxfVideoCodec, profile: &str) -> ExportSettings { + let mut s = ExportSettings { mxf_video_codec: codec, ..mov(format, profile) }; + match codec { + MxfVideoCodec::ProRes => s.prores_profile = profile.into(), + MxfVideoCodec::Dnxhr => s.dnx_profile = profile.into(), + MxfVideoCodec::H264 => { + s.bitrate_kbps = 50_000; + s.adaptive_bitrate = Some(0.3); + s.bitrate_mode = BitrateMode::Vbr1Pass; + } + } + s +} + fn audio_only(format: Format, bits: u16) -> ExportSettings { ExportSettings { format, @@ -110,6 +124,15 @@ pub fn builtin_presets() -> Vec { p("Avid DNxHR HQ", bc, "QuickTime, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "hq")), p("Avid DNxHR SQ", bc, "QuickTime, DNxHR SQ (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "sq")), p("Avid DNxHR LB", bc, "QuickTime, DNxHR LB (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "lb")), + p("MXF OP1a DNxHR HQ", bc, "MXF OP1a, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM, start timecode", mxf(Format::MxfOp1a, MxfVideoCodec::Dnxhr, "hq")), + p("MXF OP1a ProRes 422 HQ", bc, "MXF OP1a, ProRes 422 HQ, 24-bit 48 kHz PCM, start timecode", mxf(Format::MxfOp1a, MxfVideoCodec::ProRes, "hq")), + p("MXF OP1a H.264", bc, "MXF OP1a, H.264 High long GOP (0.3 bits per pixel), 24-bit 48 kHz PCM", mxf(Format::MxfOp1a, MxfVideoCodec::H264, "")), + p( + "MXF OP-Atom DNxHR (Avid)", + bc, + "Avid-style OP-Atom: DNxHR HQ picture file plus one 24-bit 48 kHz PCM file per channel (_A1, _A2…)", + mxf(Format::MxfOpAtom, MxfVideoCodec::Dnxhr, "hq"), + ), p( "PNG Sequence", "Image Sequence", diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index 2522600f..90b2b163 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -94,6 +94,38 @@ pub enum Multiplexer { Mov, } +/// Video codec of an MXF export. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum MxfVideoCodec { + /// Avid DNxHR (VC-3, SMPTE ST 2019-4 mapping). + #[default] + Dnxhr, + /// Apple ProRes (RDD 44 mapping). + ProRes, + /// H.264 long GOP (ST 381-3 byte-stream mapping). + H264, +} + +impl MxfVideoCodec { + pub const ALL: [MxfVideoCodec; 3] = [MxfVideoCodec::Dnxhr, MxfVideoCodec::ProRes, MxfVideoCodec::H264]; + pub fn label(self) -> &'static str { + match self { + MxfVideoCodec::Dnxhr => "Avid DNxHR", + MxfVideoCodec::ProRes => "Apple ProRes", + MxfVideoCodec::H264 => "H.264", + } + } + /// The export format whose encoder this codec uses. + pub fn encoder_format(self) -> Format { + match self { + MxfVideoCodec::Dnxhr => Format::DnxHr, + MxfVideoCodec::ProRes => Format::ProRes, + MxfVideoCodec::H264 => Format::H264, + } + } +} + /// Audio codec. `Auto` = AAC in MP4, PCM in QuickTime / WAV / AIFF. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -307,6 +339,11 @@ pub struct Resolved { } impl ExportSettings { + /// The format whose video encoder runs: the MXF video codec for MXF exports, else the format. + pub fn video_format(&self) -> Format { + if self.format.is_mxf() { self.mxf_video_codec.encoder_format() } else { self.format } + } + /// Whether the output is a numbered image sequence. pub fn is_image_sequence(&self) -> bool { matches!(self.format, Format::PngSequence | Format::TiffSequence | Format::BmpSequence) @@ -354,7 +391,7 @@ impl ExportSettings { /// The audio codec actually used. pub fn audio_codec(&self) -> AudioCodec { match (self.format, self.audio.codec) { - (Format::Wav | Format::Aiff, _) => AudioCodec::Pcm, + (Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom, _) => AudioCodec::Pcm, (Format::H264, _) if self.multiplexer == Multiplexer::Mp4 => AudioCodec::Aac, (_, AudioCodec::Auto) if self.format == Format::H264 => AudioCodec::Aac, (_, AudioCodec::Auto) => AudioCodec::Pcm, @@ -383,7 +420,7 @@ impl ExportSettings { let secs = duration.seconds().max(0.0); let fps = r.rate.num as f64 / r.rate.den as f64; let px = r.width as f64 * r.height as f64; - let video_bps = match self.format { + let video_bps = match self.video_format() { Format::H264 => r.target_kbps as f64 * 1000.0, Format::ProRes => crate::prores_profile(&self.prores_profile).nominal_mbps_1080p30() * 1e6 * px / (1920.0 * 1080.0) * fps / 29.97, Format::DnxHr => { @@ -400,7 +437,7 @@ impl ExportSettings { Format::PngSequence => px * 4.0 * 0.45 * 8.0 * fps, Format::TiffSequence | Format::BmpSequence => px * if self.format == Format::BmpSequence { 3.0 } else { 4.0 } * 8.0 * fps, Format::Gif => px * 0.6 * 8.0 * fps, - Format::Wav | Format::Aiff => 0.0, + Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom => 0.0, }; let audio_bps = if self.has_audio() || matches!(self.format, Format::Wav | Format::Aiff) { match self.audio_codec() { @@ -434,7 +471,7 @@ impl ExportSettings { } else { let par = self.pixel_aspect.map(|(n, d)| format!("{:.2}", n.max(1) as f64 / d.max(1) as f64)).unwrap_or_else(|| "1.0".into()); let mut v = format!("{}x{} ({par}), {} fps, {}", r.width, r.height, r.rate.label(), self.field_order.label()); - match self.format { + match self.video_format() { Format::H264 => { v += &format!( ", {} {}, {}", @@ -480,6 +517,7 @@ impl ExportSettings { Format::H264 if self.multiplexer == Multiplexer::Mov => "QuickTime", Format::H264 => "MP4", Format::ProRes | Format::DnxHr | Format::Mjpeg => "QuickTime", + Format::MxfOp1a | Format::MxfOpAtom => self.mxf_video_codec.label(), Format::PngSequence | Format::TiffSequence | Format::BmpSequence => "Image sequence", _ => "", }; diff --git a/crates/mxf/Cargo.toml b/crates/mxf/Cargo.toml index 60e2060d..34b62227 100644 --- a/crates/mxf/Cargo.toml +++ b/crates/mxf/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filmcraft-mxf" -description = "Clean-room MXF (SMPTE ST 377-1) demuxer: OP1a and OP-Atom, index tables, AVC/VC-3/ProRes/MPEG-2/PCM essence" +description = "Clean-room MXF (SMPTE ST 377-1) demuxer and writer: OP1a and OP-Atom, index tables, AVC/VC-3/ProRes/MPEG-2/PCM essence" version.workspace = true edition.workspace = true license.workspace = true diff --git a/crates/mxf/README.md b/crates/mxf/README.md index 8c6ab6a4..13ed3c2d 100644 --- a/crates/mxf/README.md +++ b/crates/mxf/README.md @@ -1,6 +1,6 @@ # filmcraft-mxf -A clean-room MXF demuxer. Layer L0: no dependencies beyond `std`, no `unsafe`, builds for +A clean-room MXF demuxer and writer. Layer L0: no dependencies beyond `std`, no `unsafe`, builds for `wasm32-unknown-unknown`. No GPL/LGPL code (FFmpeg, libMXF, bmx, MXFLib) was consulted; FFmpeg is used only as an external fixture generator and test oracle. @@ -22,7 +22,8 @@ Implemented from the SMPTE documents (editions used): | SMPTE ST 386 | 2004 | D-10 (IMX) mapping: container label, sound element type | | SMPTE ST 2019-4 | 2009 | VC-3 (DNxHD / DNxHR) mapping | | SMPTE RDD 44 | 2017 | Apple ProRes mapping | -| SMPTE RP 224 / RP 210 | registers | labels: data definitions, picture coding, essence containers, operational patterns | +| SMPTE RP 224 / RP 210 | registers | labels: data definitions, picture coding, essence containers, operational patterns; property labels of the primer pack (writer) | +| SMPTE ST 330 | 2011 | basic UMIDs of the written packages | ## Reading a file @@ -72,6 +73,57 @@ Not supported: OP1b/OP2x+ edit lists beyond the first source clip, external esse material packages whose other tracks live in other files are opened track by track), partial or encrypted essence (ST 429-6), and descriptive metadata (parsed sets are ignored). +## Writing a file + +```rust +use filmcraft_mxf::*; +let ids = PackageIds::from_seed("unique seed (path + time)", "Clip name"); // material + file UMIDs +let mut cfg = WriterConfig::new(Pattern::Op1a, Rational::new(25, 1), ids); +cfg.picture = Some(PictureDesc::new(PictureCoding::Vc3 { cid: 1272 }, 1920, 1080)); +cfg.sound = vec![SoundDesc { sample_rate: 48_000, channels: 2, bits: 24 }]; +cfg.timecode = Some(StartTimecode { frames: 90_000, rate: Rational::new(25, 1), drop_frame: false }); +let mut w = MxfWriter::new(std::io::BufWriter::new(std::fs::File::create("out.mxf")?), cfg)?; +w.push_picture(&frame_bytes, FrameInfo::intra())?; // stored order; AVC: key / P / B + display position +w.push_sound(0, &interleaved_le_pcm)?; // any chunking: content packages are cut per edit unit +w.finish()?; +``` + +- **Streaming.** Any `Write + Seek` sink. `new` writes the header partition (open, incomplete, + durations unknown) and a body partition; essence follows as it is pushed; `finish` writes the + footer partition (closed, complete header metadata with the final durations and the index table + segments), the random index pack, then rewrites the header partition pack and header metadata in + place (the metadata's size does not depend on the durations; a reserved fill item absorbs any + difference) and the body partition pack's footer offset. Only the index entries (11 bytes per + edit unit, plus 4 per sound slice) are kept in memory. +- **OP1a** (ST 378): one content package per edit unit, frame wrapping (ST 379-1): the picture + element then one element per sound track. Sound is buffered and cut into edit units exactly + (`floor((n+1)·rate·den/num) − floor(n·rate·den/num)` samples: 1601/1602 at 29.97). Pictures + without their sound yet wait; `finish` pads the last units' sound with silence. A multiple + descriptor with one sub-descriptor per track (linked track ids), the "multiple wrappings" + container label. +- **OP-Atom** (ST 390, Avid style): exactly one essence track per file, clip-wrapped (one element + with a 9-byte BER length patched by `finish`). Several atom files of one clip can share the + material package UMID with distinct track ids (`WriterConfig::first_track_id`). Sound files may + use the sample rate as the edit rate (a constant-bytes index) or the frame rate (VBR entries). +- **Index tables** (ST 377-1 §11): VBR entries per edit unit — stream offset (from the first + essence byte; clip-wrapped: from the element value), key-frame offset, flags (`0x80` random + access, `0xC0` AVC IDR with its parameter sets, `0x22` P, `0x33` B) and temporal offsets from the + display positions (`TO[d] = stored(d) − d`); a delta entry and a slice per sound element. Split + into segments of at most ~60 KB (the entry array is a 2-byte-length local set item). +- **Metadata.** Preface, identification (company / product / version / modification date), + content storage, essence container data (BodySID 1, IndexSID 2), material and file packages + (`PackageIds`: UMIDs and names) with a timecode track (start, rounded base, drop frame) and one + timeline track per essence (source clip → file package track; track numbers = element keys), + CDCI picture descriptors (size, aspect ratio, depth, subsampling, black / white / range, BT.709 / + BT.2020 PQ / HLG colour labels, coding label) and wave audio descriptors. +- **Mappings.** VC-3 (ST 2019-4: coding label `…71 cc` from the compression id, container `02 11`, + element types 0x0C / 0x0D), ProRes (RDD 44: coding label profile byte, container `02 1C`), + AVC byte stream (ST 381-3: container `02 10 60`, coding label from `profile_idc`; Annex B access + units with in-band SPS / PPS — the AVC sub-descriptor is not written), Broadcast Wave PCM + (ST 382: container `02 06 01/02`, element types 0x01 / 0x02). +- `write_opatom_pcm(&OpAtomPcm, &[i32])` builds a complete in-memory OP-Atom PCM file (AAF media + consolidation); `encode_pcm` interleaves planar f32 as little-endian PCM. + ## Tests - `src/tests.rs`: a small OP1a writer builds synthetic files (temporal offsets, key flags, PCM, @@ -82,3 +134,13 @@ encrypted essence (ST 429-6), and descriptive metadata (parsed sets are ignored) timecode, DNxHR LB (±2), ProRes 422 (±1), MPEG-2 (reported unsupported, audio exact), OP-Atom VC-3 (clip-wrapped) and PCM, D-10 AES3 audio; PCM sample-exact in every file; truncated and corrupted files. `cargo xtask fixtures codecs` pre-generates them. +- `src/write/tests.rs`: writer output read back by `open`: OP1a with two sound tracks pushed in + uneven chunks (durations, codec label, aspect, PCM exact, index slices, closed partitions), + 29.97 sound per edit unit (1601/1602), a long-GOP AVC index (presentation order, key and B + flags), OP-Atom picture (clip wrapping) and PCM files at both edit rates, multi-segment index + of 12 000 units, UMIDs; truncation and random mutation of written files never panic. +- `crates/export/src/mxf_tests.rs` (ffprobe / ffmpeg as external oracles): OP1a exports with + DNxHR, ProRes and H.264 — ffprobe reports the OP1a label, codec, 24 frames, frame rate, duration + and `01:00:00:00` timecode; ffmpeg decodes the MXF picture exactly as it decodes the same + essence exported as MOV, and its PCM equals our demuxer's sample for sample; OP-Atom exports — + picture file and one mono file per channel, each channel equal to the WAV export's. diff --git a/crates/mxf/src/lib.rs b/crates/mxf/src/lib.rs index 7a46947d..45fe50bd 100644 --- a/crates/mxf/src/lib.rs +++ b/crates/mxf/src/lib.rs @@ -12,6 +12,9 @@ //! presentation position from the index temporal offsets, random-access flag) and PCM chunk //! tables for sound, plus the start timecode of the material package. //! +//! [`MxfWriter`] ([`write`]) writes OP1a and OP-Atom files (VC-3, ProRes, AVC byte stream, PCM) +//! with index tables and start timecode, streaming to any `Write + Seek` sink. +//! //! Layer L0: no dependencies beyond `std`, no `unsafe`, builds for `wasm32-unknown-unknown`. mod essence; @@ -20,12 +23,17 @@ mod index; mod klv; mod meta; mod source; +pub mod write; pub use essence::{Codec, PictureInfo, SoundFormat, SoundInfo, aes3_element_samples, decode_pcm}; pub use file::{Chunk, EssenceTrack, MxfFile, OperationalPattern, Partition, PartitionKind, Sample, Timecode, TrackKind, Wrapping, open}; pub use index::{IndexEntry, IndexSegment}; pub use klv::{Rational, Ul}; pub use source::ByteSource; +pub use write::{ + CodedKind, ColorSpace, FrameInfo, MxfWriter, OpAtomPcm, PackageIds, Pattern, PictureCoding, PictureDesc, SoundDesc, StartTimecode, Timestamp, Umid, + WriterConfig, encode_pcm, write_opatom_pcm, +}; use std::fmt; use std::io; diff --git a/crates/mxf/src/write/labels.rs b/crates/mxf/src/write/labels.rs new file mode 100644 index 00000000..eb8fbc04 --- /dev/null +++ b/crates/mxf/src/write/labels.rs @@ -0,0 +1,144 @@ +//! Labels and keys used by the writer (SMPTE RP 224 / RP 210 registers, ST 377-1, ST 379-2 and the +//! essence mapping documents). + +/// Header metadata set key: 06 0E 2B 34 02 53 01 01 0D 01 01 01 01 01 tt 00. +pub fn set_key(kind: u8) -> [u8; 16] { + [0x06, 0x0E, 0x2B, 0x34, 0x02, 0x53, 0x01, 0x01, 0x0D, 0x01, 0x01, 0x01, 0x01, 0x01, kind, 0x00] +} + +/// Partition pack key (kind 2 header, 3 body, 4 footer; status 1 open incomplete … 4 closed complete). +pub fn partition_key(kind: u8, status: u8) -> [u8; 16] { + [0x06, 0x0E, 0x2B, 0x34, 0x02, 0x05, 0x01, 0x01, 0x0D, 0x01, 0x02, 0x01, 0x01, kind, status, 0x00] +} + +pub const PRIMER_KEY: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x02, 0x05, 0x01, 0x01, 0x0D, 0x01, 0x02, 0x01, 0x01, 0x05, 0x01, 0x00]; +pub const INDEX_KEY: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x02, 0x53, 0x01, 0x01, 0x0D, 0x01, 0x02, 0x01, 0x01, 0x10, 0x01, 0x00]; +pub const RIP_KEY: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x02, 0x05, 0x01, 0x01, 0x0D, 0x01, 0x02, 0x01, 0x01, 0x11, 0x01, 0x00]; + +/// OP1a (ST 378); byte 14 qualifiers: bit 3 multi-track, bit 0 always set. +pub fn op1a(multi_track: bool) -> [u8; 16] { + [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x0D, 0x01, 0x02, 0x01, 0x01, 0x01, if multi_track { 0x09 } else { 0x01 }, 0x00] +} + +/// OP-Atom (ST 390). +pub const OP_ATOM: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x02, 0x0D, 0x01, 0x02, 0x01, 0x10, 0x00, 0x00, 0x00]; + +/// Data definitions (RP 224). +pub const DEF_PICTURE: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x01, 0x03, 0x02, 0x02, 0x01, 0x00, 0x00, 0x00]; +pub const DEF_SOUND: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x01, 0x03, 0x02, 0x02, 0x02, 0x00, 0x00, 0x00]; +pub const DEF_TIMECODE: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x01, 0x03, 0x02, 0x01, 0x01, 0x00, 0x00, 0x00]; + +/// Generic container: multiple wrappings (ST 379-2). +pub const EC_MULTIPLE: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x03, 0x0D, 0x01, 0x03, 0x01, 0x02, 0x7F, 0x01, 0x00]; + +/// Essence container label of a generic-container mapping: 06 0E 2B 34 04 01 01 vv 0D 01 03 01 02 mm ww xx. +pub fn essence_container(version: u8, mapping: u8, b14: u8, b15: u8) -> [u8; 16] { + [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, version, 0x0D, 0x01, 0x03, 0x01, 0x02, mapping, b14, b15] +} + +/// Generic container element key: item type (0x15 picture, 0x16 sound), element count, element type, number. +pub fn element_key(item: u8, count: u8, kind: u8, number: u8) -> [u8; 16] { + [0x06, 0x0E, 0x2B, 0x34, 0x01, 0x02, 0x01, 0x01, 0x0D, 0x01, 0x03, 0x01, item, count, kind, number] +} + +pub const KLV_FILL: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x01, 0x01, 0x01, 0x02, 0x03, 0x01, 0x02, 0x10, 0x01, 0x00, 0x00, 0x00]; + +/// Colour labels (RP 224): transfer characteristics, coding equations, colour primaries. +pub const TC_709: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x04, 0x01, 0x01, 0x01, 0x01, 0x02, 0x00, 0x00]; +pub const TC_PQ: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x0D, 0x04, 0x01, 0x01, 0x01, 0x01, 0x0A, 0x00, 0x00]; +pub const TC_HLG: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x0D, 0x04, 0x01, 0x01, 0x01, 0x01, 0x0B, 0x00, 0x00]; +pub const CE_709: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x04, 0x01, 0x01, 0x01, 0x02, 0x02, 0x00, 0x00]; +pub const CE_2020: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x0D, 0x04, 0x01, 0x01, 0x01, 0x02, 0x06, 0x00, 0x00]; +pub const CP_709: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x06, 0x04, 0x01, 0x01, 0x01, 0x03, 0x03, 0x00, 0x00]; +pub const CP_2020: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x0D, 0x04, 0x01, 0x01, 0x01, 0x03, 0x04, 0x00, 0x00]; + +/// Static local tags written, with their property labels (RP 210), for the primer pack. +pub const PRIMER: &[(u16, [u8; 16])] = &[ + (0x3C0A, ul(&[0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x15, 0x02])), + (0x3B02, ul(&[0x01, 0x01, 0x01, 0x02, 0x07, 0x02, 0x01, 0x10, 0x02, 0x04])), + (0x3B05, ul(&[0x01, 0x01, 0x01, 0x02, 0x03, 0x01, 0x02, 0x01, 0x05])), + (0x3B07, ul(&[0x01, 0x01, 0x01, 0x02, 0x03, 0x01, 0x02, 0x01, 0x04])), + (0x3B06, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x06, 0x04])), + (0x3B03, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x02, 0x01])), + (0x3B09, ul(&[0x01, 0x01, 0x01, 0x05, 0x01, 0x02, 0x02, 0x03])), + (0x3B0A, ul(&[0x01, 0x01, 0x01, 0x05, 0x01, 0x02, 0x02, 0x10, 0x02, 0x01])), + (0x3B0B, ul(&[0x01, 0x01, 0x01, 0x05, 0x01, 0x02, 0x02, 0x10, 0x02, 0x02])), + (0x3C09, ul(&[0x01, 0x01, 0x01, 0x02, 0x05, 0x20, 0x07, 0x01, 0x01])), + (0x3C01, ul(&[0x01, 0x01, 0x01, 0x02, 0x05, 0x20, 0x07, 0x01, 0x02, 0x01])), + (0x3C02, ul(&[0x01, 0x01, 0x01, 0x02, 0x05, 0x20, 0x07, 0x01, 0x03, 0x01])), + (0x3C04, ul(&[0x01, 0x01, 0x01, 0x02, 0x05, 0x20, 0x07, 0x01, 0x05, 0x01])), + (0x3C05, ul(&[0x01, 0x01, 0x01, 0x02, 0x05, 0x20, 0x07, 0x01, 0x07])), + (0x3C06, ul(&[0x01, 0x01, 0x01, 0x02, 0x07, 0x02, 0x01, 0x10, 0x02, 0x03])), + (0x1901, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x05, 0x01])), + (0x1902, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x05, 0x02])), + (0x2701, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x06, 0x01])), + (0x3F06, ul(&[0x01, 0x01, 0x01, 0x04, 0x01, 0x03, 0x04, 0x05])), + (0x3F07, ul(&[0x01, 0x01, 0x01, 0x04, 0x01, 0x03, 0x04, 0x04])), + (0x4401, ul(&[0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x15, 0x10])), + (0x4402, ul(&[0x01, 0x01, 0x01, 0x01, 0x01, 0x03, 0x03, 0x02, 0x01])), + (0x4405, ul(&[0x01, 0x01, 0x01, 0x02, 0x07, 0x02, 0x01, 0x10, 0x01, 0x03])), + (0x4404, ul(&[0x01, 0x01, 0x01, 0x02, 0x07, 0x02, 0x01, 0x10, 0x02, 0x05])), + (0x4403, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x06, 0x05])), + (0x4701, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x02, 0x03])), + (0x4801, ul(&[0x01, 0x01, 0x01, 0x02, 0x01, 0x07, 0x01, 0x01])), + (0x4804, ul(&[0x01, 0x01, 0x01, 0x02, 0x01, 0x04, 0x01, 0x03])), + (0x4802, ul(&[0x01, 0x01, 0x01, 0x02, 0x01, 0x07, 0x01, 0x02, 0x01])), + (0x4803, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x02, 0x04])), + (0x4B01, ul(&[0x01, 0x01, 0x01, 0x02, 0x05, 0x30, 0x04, 0x05])), + (0x4B02, ul(&[0x01, 0x01, 0x01, 0x02, 0x07, 0x02, 0x01, 0x03, 0x01, 0x03])), + (0x0201, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x07, 0x01])), + (0x0202, ul(&[0x01, 0x01, 0x01, 0x02, 0x07, 0x02, 0x02, 0x01, 0x01, 0x03])), + (0x1001, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x06, 0x09])), + (0x1201, ul(&[0x01, 0x01, 0x01, 0x02, 0x07, 0x02, 0x01, 0x03, 0x01, 0x04])), + (0x1101, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x03, 0x01])), + (0x1102, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x03, 0x02])), + (0x1502, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x04, 0x01, 0x01, 0x02, 0x06])), + (0x1501, ul(&[0x01, 0x01, 0x01, 0x02, 0x07, 0x02, 0x01, 0x03, 0x01, 0x05])), + (0x1503, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x04, 0x01, 0x01, 0x05])), + (0x3F01, ul(&[0x01, 0x01, 0x01, 0x04, 0x06, 0x01, 0x01, 0x04, 0x06, 0x0B])), + (0x3001, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x06, 0x01, 0x01])), + (0x3002, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x06, 0x01, 0x02])), + (0x3004, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x01, 0x02])), + (0x3006, ul(&[0x01, 0x01, 0x01, 0x05, 0x06, 0x01, 0x01, 0x03, 0x05])), + (0x320C, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x03, 0x01, 0x04])), + (0x3203, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x02, 0x02])), + (0x3202, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x02, 0x01])), + (0x3205, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x01, 0x08])), + (0x3204, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x01, 0x07])), + (0x3209, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x01, 0x0C])), + (0x3208, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x01, 0x0B])), + (0x320E, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x01, 0x01, 0x01])), + (0x320D, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x01, 0x03, 0x02, 0x05])), + (0x3201, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x01, 0x06, 0x01])), + (0x3210, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x01, 0x02, 0x01, 0x01, 0x01, 0x02])), + (0x321A, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x01, 0x02, 0x01, 0x01, 0x03, 0x01])), + (0x3219, ul(&[0x01, 0x01, 0x01, 0x09, 0x04, 0x01, 0x02, 0x01, 0x01, 0x06, 0x01])), + (0x3301, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x01, 0x05, 0x03, 0x0A])), + (0x3302, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x01, 0x05])), + (0x3308, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x01, 0x05, 0x01, 0x10])), + (0x3303, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x01, 0x06])), + (0x3304, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x03, 0x03])), + (0x3305, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x03, 0x04])), + (0x3306, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x01, 0x05, 0x03, 0x05])), + (0x3D03, ul(&[0x01, 0x01, 0x01, 0x05, 0x04, 0x02, 0x03, 0x01, 0x01, 0x01])), + (0x3D02, ul(&[0x01, 0x01, 0x01, 0x04, 0x04, 0x02, 0x03, 0x01, 0x04])), + (0x3D07, ul(&[0x01, 0x01, 0x01, 0x05, 0x04, 0x02, 0x01, 0x01, 0x04])), + (0x3D01, ul(&[0x01, 0x01, 0x01, 0x04, 0x04, 0x02, 0x03, 0x03, 0x04])), + (0x3D0A, ul(&[0x01, 0x01, 0x01, 0x05, 0x04, 0x02, 0x03, 0x02, 0x01])), + (0x3D09, ul(&[0x01, 0x01, 0x01, 0x05, 0x04, 0x02, 0x03, 0x03, 0x05])), +]; + +/// A property label: 06 0E 2B 34 followed by `rest`, zero padded. +const fn ul(rest: &[u8]) -> [u8; 16] { + let mut k = [0u8; 16]; + k[0] = 0x06; + k[1] = 0x0E; + k[2] = 0x2B; + k[3] = 0x34; + let mut i = 0; + while i < rest.len() && i < 12 { + k[4 + i] = rest[i]; + i += 1; + } + k +} diff --git a/crates/mxf/src/write/mod.rs b/crates/mxf/src/write/mod.rs new file mode 100644 index 00000000..99e1c063 --- /dev/null +++ b/crates/mxf/src/write/mod.rs @@ -0,0 +1,1190 @@ +//! Clean-room MXF writer: OP1a (ST 378, frame-wrapped generic container) and OP-Atom (ST 390, +//! Avid-style: one clip-wrapped essence per file). +//! +//! The writer streams to any `Write + Seek` sink: [`MxfWriter::new`] writes the header partition +//! (open, incomplete) with the header metadata, then a body partition; essence follows as it is +//! pushed. [`MxfWriter::finish`] writes the footer partition (closed, complete header metadata with +//! the final durations, index table segments), the random index pack, and rewrites the header and +//! body partition packs and the header metadata in place (same size) so the file's header is closed +//! and complete too. +//! +//! Layout written: +//! +//! ```text +//! header partition pack · primer · preface · identification · content storage · essence container +//! data · material package (+ tracks) · file package (+ tracks) · descriptor(s) · fill +//! body partition pack (BodySID 1) +//! OP1a: one content package per edit unit: picture element, then one element per sound track +//! OP-Atom: one clip-wrapped essence element +//! footer partition pack (IndexSID 2) · header metadata · index table segments +//! random index pack +//! ``` +//! +//! Essence mappings: VC-3 / DNxHD / DNxHR (ST 2019-4), Apple ProRes (RDD 44), AVC byte stream +//! (ST 381-3; Annex B access units with in-band SPS / PPS) with long-GOP index entries (temporal +//! offsets, key-frame offsets, prediction flags), and Broadcast Wave PCM (ST 382, 8–32 bit). + +mod labels; +#[cfg(test)] +mod tests; + +use std::collections::VecDeque; +use std::io::{self, Seek, SeekFrom, Write}; + +use crate::Rational; +use labels::*; + +/// A SMPTE ST 330 basic UMID (32 bytes): package identifiers. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct Umid(pub [u8; 32]); + +impl std::fmt::Debug for Umid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for b in self.0 { + write!(f, "{b:02x}")?; + } + Ok(()) + } +} + +impl Umid { + /// A basic UMID (universal label `06 0A 2B 34 01 01 01 05 01 01 0D 20`, length 0x13, instance + /// 0) whose 16-byte material number is a hash of `seed` (callers pass something unique: path, + /// time, track). The same seed always gives the same UMID. + pub fn from_seed(seed: &[u8]) -> Umid { + let mut u = [0u8; 32]; + u[..16].copy_from_slice(&[0x06, 0x0A, 0x2B, 0x34, 0x01, 0x01, 0x01, 0x05, 0x01, 0x01, 0x0D, 0x20, 0x13, 0x00, 0x00, 0x00]); + u[16..].copy_from_slice(&hash128(seed)); + Umid(u) + } + /// The material number (bytes 16..32). + pub fn material_number(&self) -> [u8; 16] { + self.0[16..].try_into().unwrap_or([0; 16]) + } + pub fn is_zero(&self) -> bool { + self.0 == [0; 32] + } +} + +fn hash128(seed: &[u8]) -> [u8; 16] { + let mut a = 0xcbf2_9ce4_8422_2325u64; + let mut b = 0x84222325_cbf29ce4u64 ^ seed.len() as u64; + for &x in seed { + a = (a ^ x as u64).wrapping_mul(0x0000_0100_0000_01B3); + b = (b ^ x as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15).rotate_left(23); + } + let mix = |mut z: u64| { + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + }; + let mut out = [0u8; 16]; + out[..8].copy_from_slice(&mix(a ^ b.rotate_left(17)).to_be_bytes()); + out[8..].copy_from_slice(&mix(b ^ a.rotate_left(41)).to_be_bytes()); + out +} + +/// Operational pattern of the written file. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Pattern { + /// OP1a: one material package playing one file package; all essence interleaved per edit + /// unit (frame wrapping). + Op1a, + /// OP-Atom: exactly one essence track (a picture or one sound track), clip-wrapped. + OpAtom, +} + +/// Picture essence coding. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PictureCoding { + /// SMPTE VC-3 (DNxHD / DNxHR) with its compression id (e.g. 1272 = DNxHR HQ). + Vc3 { cid: u32 }, + /// Apple ProRes; `profile` as in the RDD 44 coding label: 1 Proxy, 2 LT, 3 422, 4 HQ, 5 4444, + /// 6 4444 XQ. + ProRes { profile: u8 }, + /// H.264 / AVC Annex B byte stream; `profile_idc` from the SPS (66, 77, 100, …). + Avc { profile_idc: u8, intra: bool }, +} + +impl PictureCoding { + fn intra_only(&self) -> bool { + match self { + PictureCoding::Avc { intra, .. } => *intra, + _ => true, + } + } + /// Picture essence coding label. + fn coding_label(&self) -> [u8; 16] { + match *self { + PictureCoding::Vc3 { cid } => { + let code = if (1235..=1234 + 0x7F).contains(&cid) { (cid - 1234) as u8 } else { 0 }; + [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x0A, 0x04, 0x01, 0x02, 0x02, 0x71, code, 0x00, 0x00] + } + PictureCoding::ProRes { profile } => { + [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x0D, 0x04, 0x01, 0x02, 0x02, 0x03, 0x06, profile.clamp(1, 6), 0x00] + } + PictureCoding::Avc { profile_idc, intra } => { + let (b13, b14) = if intra { + (0x32, 0x20) + } else { + ( + 0x31, + match profile_idc { + 66 => 0x10, + 77 => 0x20, + 88 => 0x30, + 110 => 0x50, + 122 => 0x60, + 244 => 0x70, + _ => 0x40, + }, + ) + }; + [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x0A, 0x04, 0x01, 0x02, 0x02, 0x01, b13, b14, 0x01] + } + } + } + /// (essence container label, element type) for frame or clip wrapping. + fn container(&self, clip: bool) -> ([u8; 16], u8) { + let w = if clip { 0x02 } else { 0x01 }; + match self { + PictureCoding::Vc3 { .. } => (essence_container(0x0A, 0x11, w, 0x00), if clip { 0x0D } else { 0x0C }), + PictureCoding::ProRes { .. } => (essence_container(0x0D, 0x1C, w, 0x00), if clip { 0x18 } else { 0x17 }), + PictureCoding::Avc { .. } => (essence_container(0x0A, 0x10, 0x60, w), if clip { 0x16 } else { 0x15 }), + } + } +} + +/// Colour description of the picture (transfer, coding equations, primaries labels). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ColorSpace { + #[default] + Rec709, + /// BT.2020 primaries and matrix, SMPTE ST 2084 transfer. + Rec2020Pq, + /// BT.2020 primaries and matrix, ARIB STD-B67 (HLG) transfer. + Rec2020Hlg, +} + +/// The picture track. +#[derive(Clone, Debug, PartialEq)] +pub struct PictureDesc { + pub coding: PictureCoding, + pub width: u32, + pub height: u32, + /// Display aspect ratio of the frame (e.g. 16/9). + pub aspect: Rational, + /// Bits per component. + pub depth: u32, + /// Chroma subsampling: (2, 1) 4:2:2, (2, 2) 4:2:0, (1, 1) 4:4:4. + pub subsampling: (u32, u32), + pub color: ColorSpace, +} + +impl PictureDesc { + /// Square pixels; 8-bit 4:2:0 for AVC, 10-bit 4:2:2 for ProRes, 8-bit 4:2:2 for VC-3 + /// (10-bit for DNxHR HQX / 444 and the 10-bit DNxHD ids). + pub fn new(coding: PictureCoding, width: u32, height: u32) -> Self { + let g = gcd(width.max(1), height.max(1)); + let (depth, subsampling) = match coding { + PictureCoding::Avc { .. } => (8, (2, 2)), + PictureCoding::ProRes { profile } => (if profile >= 5 { 12 } else { 10 }, if profile >= 5 { (1, 1) } else { (2, 1) }), + PictureCoding::Vc3 { cid } => { + (if matches!(cid, 1235 | 1241 | 1250 | 1256 | 1270 | 1271) { 10 } else { 8 }, if matches!(cid, 1256 | 1270) { (1, 1) } else { (2, 1) }) + } + }; + PictureDesc { + coding, + width, + height, + aspect: Rational::new((width.max(1) / g) as i32, (height.max(1) / g) as i32), + depth, + subsampling, + color: ColorSpace::Rec709, + } + } +} + +fn gcd(a: u32, b: u32) -> u32 { + if b == 0 { a } else { gcd(b, a % b) } +} + +/// A PCM sound track (Broadcast Wave mapping, little-endian interleaved samples). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SoundDesc { + pub sample_rate: u32, + pub channels: u32, + /// 8, 16, 24 or 32. + pub bits: u32, +} + +impl SoundDesc { + pub fn block_align(&self) -> u32 { + self.channels.max(1) * self.bits.div_ceil(8) + } +} + +/// Start timecode of the packages. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StartTimecode { + /// Frame count of the first frame (at the timecode rate). + pub frames: i64, + /// Timecode frame rate (the track's edit rate; 30000/1001 for 29.97). + pub rate: Rational, + pub drop_frame: bool, +} + +impl StartTimecode { + pub fn rounded_base(&self) -> u16 { + if !self.rate.is_valid() { + return 25; + } + ((self.rate.num as i64 + self.rate.den as i64 - 1) / self.rate.den as i64) as u16 + } +} + +/// An MXF timestamp (UTC). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Timestamp { + pub year: u16, + pub month: u8, + pub day: u8, + pub hour: u8, + pub minute: u8, + pub second: u8, + pub millisecond: u16, +} + +impl Timestamp { + /// From seconds since the Unix epoch (proleptic Gregorian, UTC). + pub fn from_unix(secs: i64) -> Timestamp { + let days = secs.div_euclid(86_400); + let rem = secs.rem_euclid(86_400); + // civil-from-days (H. Hinnant) + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 }; + Timestamp { + year: y.clamp(0, 65535) as u16, + month: m as u8, + day: d as u8, + hour: (rem / 3600) as u8, + minute: (rem / 60 % 60) as u8, + second: (rem % 60) as u8, + millisecond: 0, + } + } + fn bytes(&self) -> [u8; 8] { + let y = self.year.to_be_bytes(); + [y[0], y[1], self.month, self.day, self.hour, self.minute, self.second, (self.millisecond / 4) as u8] + } +} + +/// Package identifiers and names. The material package is what an AAF composition (or an NLE) +/// references; the file package describes the stored essence. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PackageIds { + pub material: Umid, + pub file: Umid, + pub material_name: String, + pub file_name: String, +} + +impl PackageIds { + /// UMIDs derived from `seed` (material and file differ). + pub fn from_seed(seed: &str, name: &str) -> Self { + PackageIds { + material: Umid::from_seed(format!("material:{seed}").as_bytes()), + file: Umid::from_seed(format!("file:{seed}").as_bytes()), + material_name: name.to_string(), + file_name: name.to_string(), + } + } +} + +/// Everything the writer needs up front. +#[derive(Clone, Debug, PartialEq)] +pub struct WriterConfig { + pub pattern: Pattern, + /// Edit rate of the essence tracks (the frame rate; OP-Atom sound files may use the sample + /// rate, one edit unit per sample). + pub edit_rate: Rational, + pub picture: Option, + pub sound: Vec, + pub timecode: Option, + pub ids: PackageIds, + /// Track id of the first essence track (the timecode track is 1). OP-Atom files that share a + /// material package give their tracks distinct ids (Avid style: V1 = 2, A1 = 3, …). + pub first_track_id: u32, + pub company: String, + pub product: String, + pub version: String, + pub modified: Timestamp, +} + +impl WriterConfig { + pub fn new(pattern: Pattern, edit_rate: Rational, ids: PackageIds) -> Self { + WriterConfig { + pattern, + edit_rate, + picture: None, + sound: Vec::new(), + timecode: None, + ids, + first_track_id: 2, + company: "FilmCraft".into(), + product: "FilmCraft".into(), + version: env!("CARGO_PKG_VERSION").into(), + modified: Timestamp::default(), + } + } +} + +/// How a picture was coded. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CodedKind { + #[default] + Intra, + /// Forward predicted (P). + Predicted, + /// Bidirectionally predicted (B). + Bidirectional, +} + +/// Index information for one picture, in stored (decode) order. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct FrameInfo { + /// A decoder can start here (IDR / intra picture with its parameter sets). + pub key: bool, + pub kind: CodedKind, + /// Presentation position (edit units from the start); `None` = the stored position. + pub display: Option, +} + +impl FrameInfo { + /// An independently decodable picture presented in stored order. + pub fn intra() -> Self { + FrameInfo { key: true, kind: CodedKind::Intra, display: None } + } +} + +/// One index entry under construction. +#[derive(Clone, Debug)] +struct Entry { + /// Stream offset of the edit unit in the essence container. + offset: u64, + /// Offsets of slices 1.. from the edit unit start (OP1a sound elements). + slices: Vec, + info: FrameInfo, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TrackKind { + Picture, + Sound(usize), +} + +#[derive(Clone, Debug)] +struct TrackW { + id: u32, + kind: TrackKind, + element_key: [u8; 16], + container: [u8; 16], +} + +/// Header region: header metadata plus reserved fill, rewritten in place by `finish`. +const HEADER_RESERVE: usize = 4096; +const INDEX_SID: u32 = 2; +const BODY_SID: u32 = 1; +/// Entries per index table segment (the entry array's 2-byte local length limits a segment). +const MAX_SEGMENT_BYTES: usize = 60_000; + +/// Streaming MXF writer. See the [module docs](self). +pub struct MxfWriter { + w: W, + base: u64, + /// Bytes written so far (relative to `base`). + pos: u64, + cfg: WriterConfig, + tracks: Vec, + header_region: usize, + body_partition: u64, + /// File offset (relative) of the first byte of the essence container. + essence_start: u64, + entries: Vec, + // OP1a + pending: VecDeque<(Vec, FrameInfo)>, + sound_buf: Vec>, + // OP-Atom + clip_len: u64, + clip_kl: u64, + samples: u64, +} + +fn ber4(n: usize) -> [u8; 4] { + let b = (n as u32).to_be_bytes(); + [0x83, b[1], b[2], b[3]] +} + +fn klv(out: &mut Vec, key: &[u8; 16], v: &[u8]) { + out.extend_from_slice(key); + if v.len() < 1 << 24 { + out.extend_from_slice(&ber4(v.len())); + } else { + out.push(0x88); + out.extend_from_slice(&(v.len() as u64).to_be_bytes()); + } + out.extend_from_slice(v); +} + +fn fill(out: &mut Vec, total: usize) { + if total < 20 { + // too small for a fill item: zero-length fill items are not possible; caller avoids this + out.extend(std::iter::repeat_n(0u8, total)); + return; + } + out.extend_from_slice(&KLV_FILL); + out.extend_from_slice(&ber4(total - 20)); + out.extend(std::iter::repeat_n(0u8, total - 20)); +} + +fn batch(items: &[&[u8]], item_size: usize) -> Vec { + let mut v = (items.len() as u32).to_be_bytes().to_vec(); + v.extend_from_slice(&(item_size as u32).to_be_bytes()); + for i in items { + v.extend_from_slice(i); + } + v +} + +fn utf16(s: &str) -> Vec { + s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect() +} + +fn rational(r: Rational) -> [u8; 8] { + let mut b = [0u8; 8]; + b[..4].copy_from_slice(&r.num.to_be_bytes()); + b[4..].copy_from_slice(&r.den.to_be_bytes()); + b +} + +/// A header metadata local set under construction. +struct Set { + key: [u8; 16], + v: Vec, +} + +impl Set { + fn new(kind: u8, uid: [u8; 16]) -> Set { + let mut s = Set { key: set_key(kind), v: Vec::new() }; + s.p(0x3C0A, &uid); + s + } + fn p(&mut self, tag: u16, v: &[u8]) -> &mut Set { + self.v.extend_from_slice(&tag.to_be_bytes()); + self.v.extend_from_slice(&(v.len().min(0xFFFF) as u16).to_be_bytes()); + self.v.extend_from_slice(&v[..v.len().min(0xFFFF)]); + self + } + fn u8(&mut self, tag: u16, x: u8) -> &mut Set { + self.p(tag, &[x]) + } + fn u16(&mut self, tag: u16, x: u16) -> &mut Set { + self.p(tag, &x.to_be_bytes()) + } + fn u32(&mut self, tag: u16, x: u32) -> &mut Set { + self.p(tag, &x.to_be_bytes()) + } + fn i64(&mut self, tag: u16, x: i64) -> &mut Set { + self.p(tag, &x.to_be_bytes()) + } + fn rat(&mut self, tag: u16, r: Rational) -> &mut Set { + self.p(tag, &rational(r)) + } + fn refs(&mut self, tag: u16, uids: &[[u8; 16]]) -> &mut Set { + let items: Vec<&[u8]> = uids.iter().map(|u| &u[..]).collect(); + self.p(tag, &batch(&items, 16)) + } + fn write(&self, out: &mut Vec) { + klv(out, &self.key, &self.v); + } +} + +/// Final (or provisional, -1 = unknown) durations for the metadata. +#[derive(Clone, Copy, Debug)] +struct Durations { + /// Edit units of the essence tracks. + essence: i64, +} + +impl MxfWriter { + /// Start a file: writes the header partition and the body partition pack. + pub fn new(mut w: W, cfg: WriterConfig) -> io::Result { + if !cfg.edit_rate.is_valid() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "MXF edit rate must be positive")); + } + let n_essence = cfg.picture.is_some() as usize + cfg.sound.len(); + if n_essence == 0 { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "MXF file without essence tracks")); + } + if cfg.pattern == Pattern::OpAtom && n_essence != 1 { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "OP-Atom files hold exactly one essence track")); + } + if cfg.sound.iter().any(|s| s.sample_rate == 0 || s.channels == 0 || !(1..=32).contains(&s.bits)) { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid PCM sound description")); + } + let clip = cfg.pattern == Pattern::OpAtom; + let mut tracks = Vec::new(); + let mut id = cfg.first_track_id.max(2); + if let Some(p) = &cfg.picture { + let (container, et) = p.coding.container(clip); + tracks.push(TrackW { id, kind: TrackKind::Picture, element_key: element_key(0x15, 1, et, 1), container }); + id += 1; + } + let ns = cfg.sound.len() as u8; + for k in 0..cfg.sound.len() { + let container = essence_container(0x01, 0x06, if clip { 0x02 } else { 0x01 }, 0x00); + tracks.push(TrackW { id, kind: TrackKind::Sound(k), element_key: element_key(0x16, ns, if clip { 0x02 } else { 0x01 }, k as u8 + 1), container }); + id += 1; + } + let base = w.stream_position()?; + let nsound = cfg.sound.len(); + let mut me = MxfWriter { + w, + base, + pos: 0, + cfg, + tracks, + header_region: 0, + body_partition: 0, + essence_start: 0, + entries: Vec::new(), + pending: VecDeque::new(), + sound_buf: vec![Vec::new(); nsound], + clip_len: 0, + clip_kl: 0, + samples: 0, + }; + let meta = me.metadata(Durations { essence: -1 }); + me.header_region = meta.len() + HEADER_RESERVE; + let pack = me.partition_pack(2, 1, 0, 0, 0, me.header_region as u64, 0, 0, 0); + let mut out = pack; + out.extend_from_slice(&meta); + fill(&mut out, HEADER_RESERVE); + me.put(&out)?; + me.body_partition = me.pos; + let body = me.partition_pack(3, 4, me.pos, 0, 0, 0, 0, 0, BODY_SID); + me.put(&body)?; + me.essence_start = me.pos; + if clip { + // clip-wrapped element: key + 9-byte BER length, patched by finish() + me.clip_kl = me.pos; + let mut kl = me.tracks[0].element_key.to_vec(); + kl.push(0x88); + kl.extend_from_slice(&[0; 8]); + me.put(&kl)?; + } + Ok(me) + } + + /// The configuration (package UMIDs and names included). + pub fn config(&self) -> &WriterConfig { + &self.cfg + } + + /// Edit units written so far (content packages, or OP-Atom pictures). + pub fn edit_units(&self) -> u64 { + self.entries.len() as u64 + } + + fn put(&mut self, b: &[u8]) -> io::Result<()> { + self.w.write_all(b)?; + self.pos += b.len() as u64; + Ok(()) + } + + fn uid(&self, n: u32) -> [u8; 16] { + let mut u = self.cfg.ids.file.material_number(); + u[6] = (u[6] & 0x0F) | 0x40; + u[8] = (u[8] & 0x3F) | 0x80; + u[12..].copy_from_slice(&n.to_be_bytes()); + u + } + + fn op_label(&self) -> [u8; 16] { + match self.cfg.pattern { + Pattern::Op1a => op1a(self.tracks.len() > 1), + Pattern::OpAtom => OP_ATOM, + } + } + + fn containers(&self) -> Vec<[u8; 16]> { + let mut v: Vec<[u8; 16]> = Vec::new(); + for t in &self.tracks { + if !v.contains(&t.container) { + v.push(t.container); + } + } + if self.tracks.len() > 1 { + v.push(EC_MULTIPLE); + } + v + } + + #[allow(clippy::too_many_arguments)] + fn partition_pack(&self, kind: u8, status: u8, this: u64, prev: u64, footer: u64, hbc: u64, ibc: u64, index_sid: u32, body_sid: u32) -> Vec { + let mut v = Vec::with_capacity(160); + v.extend_from_slice(&1u16.to_be_bytes()); + v.extend_from_slice(&3u16.to_be_bytes()); + v.extend_from_slice(&1u32.to_be_bytes()); // KAG size + v.extend_from_slice(&this.to_be_bytes()); + v.extend_from_slice(&prev.to_be_bytes()); + v.extend_from_slice(&footer.to_be_bytes()); + v.extend_from_slice(&hbc.to_be_bytes()); + v.extend_from_slice(&ibc.to_be_bytes()); + v.extend_from_slice(&index_sid.to_be_bytes()); + v.extend_from_slice(&0u64.to_be_bytes()); // body offset + v.extend_from_slice(&body_sid.to_be_bytes()); + v.extend_from_slice(&self.op_label()); + let cs = self.containers(); + let items: Vec<&[u8]> = cs.iter().map(|c| &c[..]).collect(); + v.extend_from_slice(&batch(&items, 16)); + let mut out = Vec::new(); + klv(&mut out, &partition_key(kind, status), &v); + out + } + + /// Sound sample frames of edit unit `i` of OP1a track `k`. + fn unit_samples(&self, k: usize, i: u64) -> u64 { + let sr = self.cfg.sound[k].sample_rate as u128; + let r = self.cfg.edit_rate; + let at = |n: u64| (n as u128 * sr * r.den as u128 / r.num as u128) as u64; + at(i + 1) - at(i) + } + + /// Duration of an OP-Atom sound track in edit units. + fn sound_units(&self) -> i64 { + let sr = self.cfg.sound.first().map_or(1, |s| s.sample_rate) as u128; + let r = self.cfg.edit_rate; + let num = self.samples as u128 * r.num as u128; + let den = sr * r.den as u128; + num.div_ceil(den.max(1)) as i64 + } + + fn essence_units(&self) -> i64 { + match (self.cfg.pattern, self.cfg.picture.is_some()) { + (Pattern::OpAtom, false) => self.sound_units(), + _ => self.entries.len() as i64, + } + } + + /// Push one picture (stored order). OP1a: content packages are written as soon as the sound + /// of their edit unit is there too. + pub fn push_picture(&mut self, data: &[u8], info: FrameInfo) -> io::Result<()> { + if self.cfg.picture.is_none() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "this MXF file has no picture track")); + } + match self.cfg.pattern { + Pattern::Op1a => { + self.pending.push_back((data.to_vec(), info)); + self.emit(false) + } + Pattern::OpAtom => { + self.entries.push(Entry { offset: self.clip_len, slices: Vec::new(), info }); + self.put(data)?; + self.clip_len += data.len() as u64; + Ok(()) + } + } + } + + /// Push interleaved little-endian PCM of sound track `track` (whole sample frames). + pub fn push_sound(&mut self, track: usize, pcm: &[u8]) -> io::Result<()> { + let Some(sd) = self.cfg.sound.get(track).copied() else { + return Err(io::Error::new(io::ErrorKind::InvalidInput, format!("no sound track {track}"))); + }; + let ba = sd.block_align() as usize; + if !pcm.len().is_multiple_of(ba) { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "PCM data is not a whole number of sample frames")); + } + match self.cfg.pattern { + Pattern::Op1a => { + self.sound_buf[track].extend_from_slice(pcm); + self.emit(false) + } + Pattern::OpAtom => { + self.put(pcm)?; + self.clip_len += pcm.len() as u64; + self.samples += (pcm.len() / ba) as u64; + Ok(()) + } + } + } + + /// Write every complete OP1a content package (`flush`: pad the sound of the pending pictures + /// with silence, or, without a picture track, write the remaining sound). + fn emit(&mut self, flush: bool) -> io::Result<()> { + let has_pic = self.cfg.picture.is_some(); + loop { + let i = self.entries.len() as u64; + if has_pic && self.pending.is_empty() { + return Ok(()); + } + let need: Vec = (0..self.cfg.sound.len()).map(|k| self.unit_samples(k, i) as usize * self.cfg.sound[k].block_align() as usize).collect(); + let enough = need.iter().zip(&self.sound_buf).all(|(n, b)| b.len() >= *n); + if !enough && !flush { + return Ok(()); + } + if !has_pic && self.sound_buf.iter().all(Vec::is_empty) { + return Ok(()); + } + let pic = if has_pic { self.pending.pop_front() } else { None }; + let mut cp = Vec::new(); + let mut slices = Vec::new(); + if let Some((data, _)) = &pic { + klv(&mut cp, &self.tracks[0].element_key, data); + } + let first_sound = has_pic as usize; + for (k, n) in need.iter().enumerate() { + let take = (*n).min(self.sound_buf[k].len()); + let mut chunk: Vec = self.sound_buf[k].drain(..take).collect(); + chunk.resize(*n, 0); + if k > 0 || has_pic { + slices.push(cp.len() as u32); + } + klv(&mut cp, &self.tracks[first_sound + k].element_key, &chunk); + } + let info = pic.map(|p| p.1).unwrap_or_else(FrameInfo::intra); + self.entries.push(Entry { offset: self.pos - self.essence_start, slices, info }); + self.put(&cp)?; + } + } + + /// Header metadata (primer pack and sets). + fn metadata(&self, d: Durations) -> Vec { + let cfg = &self.cfg; + let mut out = Vec::new(); + let primer: Vec> = PRIMER + .iter() + .map(|(t, ul)| { + let mut e = t.to_be_bytes().to_vec(); + e.extend_from_slice(ul); + e + }) + .collect(); + let items: Vec<&[u8]> = primer.iter().map(Vec::as_slice).collect(); + klv(&mut out, &PRIMER_KEY, &batch(&items, 18)); + let ts = cfg.modified.bytes(); + let containers = self.containers(); + // Preface + let mut s = Set::new(0x2F, self.uid(1)); + s.p(0x3B02, &ts).u16(0x3B05, 0x0103).u32(0x3B07, 1).refs(0x3B06, &[self.uid(2)]).p(0x3B03, &self.uid(3)).p(0x3B09, &self.op_label()); + let ec: Vec<&[u8]> = containers.iter().map(|c| &c[..]).collect(); + s.p(0x3B0A, &batch(&ec, 16)).p(0x3B0B, &batch(&[], 16)); + s.write(&mut out); + // Identification + let mut s = Set::new(0x30, self.uid(2)); + s.p(0x3C09, &self.uid(100)) + .p(0x3C01, &utf16(&cfg.company)) + .p(0x3C02, &utf16(&cfg.product)) + .p(0x3C04, &utf16(&cfg.version)) + .p(0x3C05, &hash128(b"FilmCraft MXF writer")) + .p(0x3C06, &ts); + s.write(&mut out); + // Content storage, essence container data + let mut s = Set::new(0x18, self.uid(3)); + s.refs(0x1901, &[self.uid(1000), self.uid(2000)]).refs(0x1902, &[self.uid(4)]); + s.write(&mut out); + let mut s = Set::new(0x23, self.uid(4)); + s.p(0x2701, &cfg.ids.file.0).u32(0x3F06, INDEX_SID).u32(0x3F07, BODY_SID); + s.write(&mut out); + // Packages + for (pkg, base) in [(0u8, 1000u32), (1, 2000)] { + let material = pkg == 0; + let n = 1 + self.tracks.len() as u32; + let track_uids: Vec<[u8; 16]> = (0..n).map(|k| self.uid(base + 1 + 3 * k)).collect(); + let mut s = Set::new(if material { 0x36 } else { 0x37 }, self.uid(base)); + let (umid, name) = if material { (cfg.ids.material, &cfg.ids.material_name) } else { (cfg.ids.file, &cfg.ids.file_name) }; + s.p(0x4401, &umid.0); + if !name.is_empty() { + s.p(0x4402, &utf16(name)); + } + s.p(0x4405, &ts).p(0x4404, &ts).refs(0x4403, &track_uids); + if !material { + s.p(0x4701, &self.uid(3000)); + } + s.write(&mut out); + // timecode track (id 1) + let tc = cfg.timecode.unwrap_or(StartTimecode { frames: 0, rate: cfg.edit_rate, drop_frame: false }); + let tc_rate = if cfg.picture.is_some() || !tc.rate.is_valid() { cfg.edit_rate } else { tc.rate }; + let tc_dur = if d.essence < 0 { + -1 + } else { + let er = cfg.edit_rate; + { + let (a, b) = (d.essence as i128 * tc_rate.num as i128 * er.den as i128, (tc_rate.den as i128 * er.num as i128).max(1)); + ((a + b - 1) / b) as i64 + } + }; + self.track_sets(&mut out, base + 1, 1, 0, tc_rate, DEF_TIMECODE, tc_dur, if material { Some("TC1") } else { None }, |s| { + s.u16(0x1502, tc.rounded_base()).i64(0x1501, tc.frames).u8(0x1503, tc.drop_frame as u8); + }); + let mut vi = 0; + let mut ai = 0; + for (k, t) in self.tracks.iter().enumerate() { + let (def, label) = match t.kind { + TrackKind::Picture => { + vi += 1; + (DEF_PICTURE, format!("V{vi}")) + } + TrackKind::Sound(_) => { + ai += 1; + (DEF_SOUND, format!("A{ai}")) + } + }; + let number = if material { 0 } else { u32::from_be_bytes(t.element_key[12..16].try_into().unwrap_or([0; 4])) }; + let file = cfg.ids.file; + self.track_sets(&mut out, base + 4 + 3 * k as u32, t.id, number, cfg.edit_rate, def, d.essence, material.then_some(label.as_str()), |s| { + let (src, src_track) = if material { (file, t.id) } else { (Umid([0; 32]), 0) }; + s.i64(0x1201, 0).p(0x1101, &src.0).u32(0x1102, src_track); + }); + } + } + // Descriptors + let multiple = self.tracks.len() > 1; + let mut desc_uids = Vec::new(); + for (k, t) in self.tracks.iter().enumerate() { + let uid = if multiple { self.uid(3001 + k as u32) } else { self.uid(3000) }; + desc_uids.push(uid); + match t.kind { + TrackKind::Picture => { + let p = cfg.picture.as_ref().expect("picture track"); + let mut s = Set::new(0x28, uid); + if multiple { + s.u32(0x3006, t.id); + } + s.rat(0x3001, cfg.edit_rate); + if d.essence >= 0 { + s.i64(0x3002, d.essence); + } + let (tcl, ce, cp) = match p.color { + ColorSpace::Rec709 => (TC_709, CE_709, CP_709), + ColorSpace::Rec2020Pq => (TC_PQ, CE_2020, CP_2020), + ColorSpace::Rec2020Hlg => (TC_HLG, CE_2020, CP_2020), + }; + let depth = p.depth.clamp(8, 16); + let black = 16u32 << (depth - 8); + let white = 235u32 << (depth - 8); + let range = (225u32 << (depth - 8)) + 1; + s.p(0x3004, &t.container) + .u8(0x320C, 0) + .u32(0x3203, p.width) + .u32(0x3202, p.height) + .u32(0x3205, p.width) + .u32(0x3204, p.height) + .u32(0x3209, p.width) + .u32(0x3208, p.height) + .rat(0x320E, p.aspect) + .p(0x320D, &batch(&[&0u32.to_be_bytes(), &0u32.to_be_bytes()], 4)) + .p(0x3201, &p.coding.coding_label()) + .p(0x3210, &tcl) + .p(0x321A, &ce) + .p(0x3219, &cp) + .u32(0x3301, depth) + .u32(0x3302, p.subsampling.0) + .u32(0x3308, p.subsampling.1) + .u32(0x3304, black) + .u32(0x3305, white) + .u32(0x3306, range); + s.write(&mut out); + } + TrackKind::Sound(i) => { + let sd = cfg.sound[i]; + let mut s = Set::new(0x48, uid); + if multiple { + s.u32(0x3006, t.id); + } + s.rat(0x3001, cfg.edit_rate); + if d.essence >= 0 { + s.i64(0x3002, d.essence); + } + s.p(0x3004, &t.container) + .rat(0x3D03, Rational::new(sd.sample_rate as i32, 1)) + .u8(0x3D02, 1) + .u32(0x3D07, sd.channels) + .u32(0x3D01, sd.bits) + .u16(0x3D0A, sd.block_align() as u16) + .u32(0x3D09, sd.block_align() * sd.sample_rate); + s.write(&mut out); + } + } + } + if multiple { + let mut s = Set::new(0x44, self.uid(3000)); + s.rat(0x3001, cfg.edit_rate); + if d.essence >= 0 { + s.i64(0x3002, d.essence); + } + s.p(0x3004, &EC_MULTIPLE).refs(0x3F01, &desc_uids); + s.write(&mut out); + } + out + } + + /// A track, its sequence and its one component (`component` adds the component-specific + /// properties: source clip or timecode). + #[allow(clippy::too_many_arguments)] + fn track_sets( + &self, + out: &mut Vec, + uid0: u32, + id: u32, + number: u32, + rate: Rational, + def: [u8; 16], + duration: i64, + name: Option<&str>, + component: impl FnOnce(&mut Set), + ) { + let (tu, su, cu) = (self.uid(uid0), self.uid(uid0 + 1), self.uid(uid0 + 2)); + let mut t = Set::new(0x3B, tu); + t.u32(0x4801, id).u32(0x4804, number); + if let Some(n) = name { + t.p(0x4802, &utf16(n)); + } + t.rat(0x4B01, rate).i64(0x4B02, 0).p(0x4803, &su); + t.write(out); + let mut s = Set::new(0x0F, su); + s.p(0x0201, &def).i64(0x0202, duration).refs(0x1001, &[cu]); + s.write(out); + let mut c = Set::new(if def == DEF_TIMECODE { 0x14 } else { 0x11 }, cu); + c.p(0x0201, &def).i64(0x0202, duration); + component(&mut c); + c.write(out); + } + + /// Index table segments for the essence written. + fn index(&self) -> Vec { + let mut out = Vec::new(); + let rate = self.cfg.edit_rate; + let mut seg_uid = 4000; + let mut segment = |out: &mut Vec, f: &mut dyn FnMut(&mut Set)| { + let mut s = Set { key: INDEX_KEY, v: Vec::new() }; + s.p(0x3C0A, &self.uid(seg_uid)); + seg_uid += 1; + s.rat(0x3F0B, rate); + f(&mut s); + s.u32(0x3F06, INDEX_SID).u32(0x3F07, BODY_SID); + s.write(out); + }; + // OP-Atom sound at one sample per edit unit: constant bytes per edit unit + if self.cfg.pattern == Pattern::OpAtom && self.cfg.picture.is_none() { + let sd = self.cfg.sound[0]; + let per_unit = (sd.sample_rate as i64 * rate.den as i64) % rate.num as i64 == 0 && (sd.sample_rate as i64 * rate.den as i64) / rate.num as i64 == 1; + if per_unit { + let units = self.essence_units(); + segment(&mut out, &mut |s| { + s.i64(0x3F0C, 0).i64(0x3F0D, units).u32(0x3F05, sd.block_align()).u8(0x3F08, 0).u8(0x3F0E, 0); + }); + return out; + } + // other edit rates: one entry per edit unit from the sample counts + let units = self.essence_units().max(0) as u64; + let ba = sd.block_align() as u64; + let entries: Vec = (0..units) + .map(|i| { + let s = (i as u128 * sd.sample_rate as u128 * rate.den as u128 / rate.num as u128) as u64; + Entry { offset: s * ba, slices: Vec::new(), info: FrameInfo::intra() } + }) + .collect(); + self.entry_segments(&mut out, &entries, &mut segment); + return out; + } + self.entry_segments(&mut out, &self.entries, &mut segment); + out + } + + fn entry_segments(&self, out: &mut Vec, entries: &[Entry], segment: &mut dyn FnMut(&mut Vec, &mut dyn FnMut(&mut Set))) { + let n = entries.len(); + let nslices = entries.iter().map(|e| e.slices.len()).max().unwrap_or(0); + // temporal offsets: display d is stored at d + TO[d] + let mut stored_of = vec![usize::MAX; n]; + let mut valid = true; + for (s, e) in entries.iter().enumerate() { + let d = e.info.display.unwrap_or(s as i64); + if d < 0 || d as usize >= n || stored_of[d as usize] != usize::MAX { + valid = false; + break; + } + stored_of[d as usize] = s; + } + let intra = self.cfg.picture.as_ref().is_none_or(|p| p.coding.intra_only()); + let avc = matches!(self.cfg.picture.as_ref().map(|p| p.coding), Some(PictureCoding::Avc { .. })); + let mut rows: Vec> = Vec::with_capacity(n); + let mut last_key = 0usize; + for (s, e) in entries.iter().enumerate() { + let to = if valid { (stored_of[s] as i64 - s as i64).clamp(-128, 127) as i8 } else { 0 }; + if e.info.key || intra { + last_key = s; + } + let kfo = (last_key as i64 - s as i64).max(-128) as i8; + let flags: u8 = if e.info.key || intra { + if avc { 0xC0 } else { 0x80 } + } else { + match e.info.kind { + CodedKind::Intra => 0x00, + CodedKind::Predicted => 0x22, + CodedKind::Bidirectional => 0x33, + } + }; + let mut r = vec![to as u8, kfo as u8, flags]; + r.extend_from_slice(&e.offset.to_be_bytes()); + for k in 0..nslices { + r.extend_from_slice(&e.slices.get(k).copied().unwrap_or(0).to_be_bytes()); + } + rows.push(r); + } + // delta entries: element k of the content package is in slice k (picture in slice 0) + let n_el = if self.cfg.pattern == Pattern::Op1a { self.tracks.len() } else { 1 }; + let deltas: Vec<[u8; 6]> = (0..n_el).map(|k| [0, k.min(nslices) as u8, 0, 0, 0, 0]).collect(); + let delta_items: Vec<&[u8]> = deltas.iter().map(|d| &d[..]).collect(); + let delta = batch(&delta_items, 6); + let entry_size = 11 + 4 * nslices; + let per_seg = (MAX_SEGMENT_BYTES / entry_size).max(1); + let mut start = 0usize; + loop { + let end = (start + per_seg).min(n); + let items: Vec<&[u8]> = rows[start..end].iter().map(Vec::as_slice).collect(); + let arr = batch(&items, entry_size); + segment(out, &mut |s: &mut Set| { + s.i64(0x3F0C, start as i64) + .i64(0x3F0D, (end - start) as i64) + .u32(0x3F05, 0) + .u8(0x3F08, nslices as u8) + .u8(0x3F0E, 0) + .p(0x3F09, &delta) + .p(0x3F0A, &arr); + }); + start = end; + if start >= n { + break; + } + } + } + + /// Finish the file: footer partition with the final header metadata and the index, random + /// index pack, and the closed header. Returns the sink (positioned at the end). + pub fn finish(mut self) -> io::Result { + if self.cfg.pattern == Pattern::Op1a { + self.emit(true)?; + } else { + // patch the clip element length + let end = self.pos; + self.w.seek(SeekFrom::Start(self.base + self.clip_kl + 16))?; + let mut l = vec![0x88]; + l.extend_from_slice(&self.clip_len.to_be_bytes()); + self.w.write_all(&l)?; + self.w.seek(SeekFrom::Start(self.base + end))?; + } + let d = Durations { essence: self.essence_units() }; + let meta = self.metadata(d); + let index = self.index(); + let footer = self.pos; + let pack = self.partition_pack(4, 4, footer, self.body_partition, footer, meta.len() as u64, index.len() as u64, INDEX_SID, 0); + let mut out = pack; + out.extend_from_slice(&meta); + out.extend_from_slice(&index); + // random index pack: (BodySID, offset) per partition, then the pack's length + let mut rip = Vec::new(); + for (sid, off) in [(0u32, 0u64), (BODY_SID, self.body_partition), (0, footer)] { + rip.extend_from_slice(&sid.to_be_bytes()); + rip.extend_from_slice(&off.to_be_bytes()); + } + rip.extend_from_slice(&((16 + 4 + rip.len() + 4) as u32).to_be_bytes()); + klv(&mut out, &RIP_KEY, &rip); + self.put(&out)?; + let end = self.pos; + // closed, complete header partition (same size as the provisional one) + if meta.len() + 20 <= self.header_region { + let mut h = self.partition_pack(2, 4, 0, 0, footer, self.header_region as u64, 0, 0, 0); + h.extend_from_slice(&meta); + fill(&mut h, self.header_region - meta.len()); + self.w.seek(SeekFrom::Start(self.base))?; + self.w.write_all(&h)?; + } else { + // cannot happen (metadata sizes do not depend on the durations); keep the header open + let h = self.partition_pack(2, 1, 0, 0, footer, self.header_region as u64, 0, 0, 0); + self.w.seek(SeekFrom::Start(self.base))?; + self.w.write_all(&h)?; + } + let body = self.partition_pack(3, 4, self.body_partition, 0, footer, 0, 0, 0, BODY_SID); + self.w.seek(SeekFrom::Start(self.base + self.body_partition))?; + self.w.write_all(&body)?; + self.w.seek(SeekFrom::Start(self.base + end))?; + self.w.flush()?; + Ok(self.w) + } +} + +/// Interleave planar f32 samples as little-endian signed PCM of `bits` (16, 24 or 32; 8-bit is +/// unsigned as in WAVE). +pub fn encode_pcm(planar: &[Vec], bits: u32) -> Vec { + let n = planar.iter().map(Vec::len).min().unwrap_or(0); + let bps = bits.div_ceil(8).clamp(1, 4) as usize; + let mut out = Vec::with_capacity(n * planar.len() * bps); + for i in 0..n { + for c in planar { + // f32 products, as the WAV / QuickTime PCM writers quantise + let s = c[i].clamp(-1.0, 1.0); + match bps { + 1 => out.push(((s * 127.0).round() as i32 + 128) as u8), + 2 => out.extend_from_slice(&((s * 32767.0).round() as i16).to_le_bytes()), + 3 => out.extend_from_slice(&((s * 8_388_607.0).round() as i32).to_le_bytes()[..3]), + _ => out.extend_from_slice(&((s as f64 * 2_147_483_647.0).round() as i32).to_le_bytes()), + } + } + } + out +} + +/// Options of [`write_opatom_pcm`]. +#[derive(Clone, Debug, PartialEq)] +pub struct OpAtomPcm { + pub sample_rate: u32, + /// 16 or 24 (any 8–32). + pub bits: u32, + pub channels: u32, + /// Edit rate of the track (`None` = the sample rate: one edit unit per sample frame). + pub edit_rate: Option, + pub ids: PackageIds, + pub timecode: Option, +} + +/// A complete OP-Atom PCM file in memory. `samples` are interleaved integer sample values in the +/// range of `bits` (e.g. -32768..=32767 for 16 bits). +pub fn write_opatom_pcm(opts: &OpAtomPcm, samples: &[i32]) -> io::Result> { + let sd = SoundDesc { sample_rate: opts.sample_rate, channels: opts.channels.max(1), bits: opts.bits }; + let rate = opts.edit_rate.unwrap_or(Rational::new(opts.sample_rate as i32, 1)); + let mut cfg = WriterConfig::new(Pattern::OpAtom, rate, opts.ids.clone()); + cfg.sound = vec![sd]; + cfg.timecode = opts.timecode; + let mut w = MxfWriter::new(io::Cursor::new(Vec::new()), cfg)?; + let bps = sd.bits.div_ceil(8).clamp(1, 4) as usize; + let frames = samples.len() / sd.channels as usize; + let mut pcm = Vec::with_capacity(frames * sd.channels as usize * bps); + for &s in &samples[..frames * sd.channels as usize] { + if bps == 1 { + pcm.push((s + 128) as u8); + } else { + pcm.extend_from_slice(&s.to_le_bytes()[..bps]); + } + } + w.push_sound(0, &pcm)?; + Ok(w.finish()?.into_inner()) +} diff --git a/crates/mxf/src/write/tests.rs b/crates/mxf/src/write/tests.rs new file mode 100644 index 00000000..e4331fa1 --- /dev/null +++ b/crates/mxf/src/write/tests.rs @@ -0,0 +1,318 @@ +//! Writer tests: files read back with our own demuxer; truncation and mutation never panic. + +use std::io::Cursor; + +use super::*; +use crate::{Codec, OperationalPattern, PartitionKind, TrackKind, Wrapping, open}; + +fn prores_like(i: usize) -> Vec { + let mut v = vec![0, 0, 0, 40]; + v.extend_from_slice(b"icpf"); + v.extend(std::iter::repeat_n(i as u8, 32 + i * 3)); + v +} + +fn vc3_like(i: usize) -> Vec { + let mut v = vec![0x00, 0x00, 0x02, 0x80, 0x01]; + v.extend(std::iter::repeat_n(i as u8 ^ 0x5A, 500 + 7 * i)); + v +} + +/// 16-bit stereo PCM: frame k = (k, -k). +fn pcm16(start: usize, n: usize) -> Vec { + let mut v = Vec::new(); + for k in start..start + n { + let s = (k % 30000) as i16; + v.extend_from_slice(&s.to_le_bytes()); + v.extend_from_slice(&(-s).to_le_bytes()); + } + v +} + +/// 24-bit mono PCM: frame k = 1000 * k (wrapping inside the 24-bit range). +fn pcm24(start: usize, n: usize) -> Vec { + let mut v = Vec::new(); + for k in start..start + n { + let s = ((k as i64 * 1000) % 8_000_000) as i32 - 4_000_000; + v.extend_from_slice(&s.to_le_bytes()[..3]); + } + v +} + +fn op1a_config(rate: Rational, coding: PictureCoding) -> WriterConfig { + let mut cfg = WriterConfig::new(Pattern::Op1a, rate, PackageIds::from_seed("test", "Test Clip")); + cfg.picture = Some(PictureDesc::new(coding, 64, 36)); + cfg.sound = vec![SoundDesc { sample_rate: 48_000, channels: 2, bits: 16 }, SoundDesc { sample_rate: 48_000, channels: 1, bits: 24 }]; + cfg.timecode = Some(StartTimecode { frames: 90_000, rate, drop_frame: false }); + cfg +} + +/// 10 ProRes-like pictures at 25 fps with stereo 16-bit and mono 24-bit sound pushed in uneven chunks. +fn build_op1a() -> Vec { + let cfg = op1a_config(Rational::new(25, 1), PictureCoding::ProRes { profile: 3 }); + let mut w = MxfWriter::new(Cursor::new(Vec::new()), cfg).unwrap(); + let total = 10 * 1920; + let (mut a, mut b) = (0, 0); + for i in 0..10 { + // sound before and after the picture, in chunks unrelated to the edit units + let n = 1500.min(total - a); + w.push_sound(0, &pcm16(a, n)).unwrap(); + a += n; + w.push_picture(&prores_like(i), FrameInfo::intra()).unwrap(); + let m = 2500.min(total - b); + w.push_sound(1, &pcm24(b, m)).unwrap(); + b += m; + } + w.push_sound(0, &pcm16(a, total - a)).unwrap(); + assert_eq!(w.edit_units(), 10); + w.finish().unwrap().into_inner() +} + +#[test] +fn op1a_reads_back() { + let f = build_op1a(); + let m = open(&f).unwrap(); + assert!(m.warnings.is_empty(), "{:?}", m.warnings); + assert_eq!(m.operational_pattern, OperationalPattern::Generalized { item: 1, package: 1 }); + assert_eq!(m.material_package_name.as_deref(), Some("Test Clip")); + assert_eq!(m.product_name.as_deref(), Some("FilmCraft")); + let tc = m.timecode.unwrap(); + assert_eq!(tc.format(), "01:00:00:00"); + // header rewritten closed and complete; body and footer partitions; footer metadata used + let kinds: Vec<(PartitionKind, u8)> = m.partitions.iter().map(|p| (p.kind, p.status)).collect(); + assert_eq!(kinds, vec![(PartitionKind::Header, 4), (PartitionKind::Body, 4), (PartitionKind::Footer, 4)]); + assert!(m.partitions.iter().all(|p| p.footer_partition == m.partitions[2].offset)); + assert_eq!(m.tracks.len(), 3); + let v = m.track_of_kind(TrackKind::Picture).unwrap(); + let t = &m.tracks[v]; + assert_eq!(t.codec, Codec::ProRes { profile: Some(3) }); + assert_eq!(t.duration, Some(10)); + assert_eq!(t.edit_rate, Rational::new(25, 1)); + assert_eq!(t.wrapping, Wrapping::Frame); + assert!(t.indexed || t.codec.intra_only()); + let p = t.picture.as_ref().unwrap(); + assert_eq!((p.frame_width(), p.frame_height(), p.component_depth), (64, 36, 10)); + assert_eq!(p.aspect_ratio, Rational::new(16, 9)); + for i in 0..10 { + assert_eq!(m.read_sample(&f, v, i).unwrap(), prores_like(i)); + } + let sounds: Vec = (0..m.tracks.len()).filter(|&i| m.tracks[i].kind == TrackKind::Sound).collect(); + assert_eq!(sounds.len(), 2); + let s0 = &m.tracks[sounds[0]]; + assert_eq!(s0.codec, Codec::Pcm); + assert_eq!(s0.stored_sample_frames(), 10 * 1920); + assert_eq!(s0.chunks.len(), 10); + let pcm = m.read_pcm(&f, sounds[0], 1900, 100).unwrap(); + for k in 0..100 { + assert_eq!(pcm[0][k], (1900 + k) as f32 / 32768.0); + assert_eq!(pcm[1][k], -((1900 + k) as f32) / 32768.0); + } + let s1 = &m.tracks[sounds[1]]; + assert_eq!(s1.sound.as_ref().unwrap().bits, 24); + let pcm = m.read_pcm(&f, sounds[1], 0, 10 * 1920).unwrap(); + for k in (0..10 * 1920).step_by(97) { + let want = ((k as i64 * 1000) % 8_000_000) as i32 - 4_000_000; + assert_eq!(pcm[0][k], want as f32 / 8_388_608.0, "sample {k}"); + } + // index: one entry per content package, two sound slices + let entries: usize = m.index_segments.iter().map(|s| s.entries.len()).sum(); + assert_eq!(entries, 10); + assert_eq!(m.index_segments[0].slice_count, 2); + assert_eq!(m.index_segments[0].delta_entries.len(), 3); +} + +#[test] +fn ntsc_sound_follows_the_edit_unit_pattern() { + let rate = Rational::new(30000, 1001); + let mut cfg = op1a_config(rate, PictureCoding::Vc3 { cid: 1274 }); + cfg.sound.truncate(1); + cfg.timecode = Some(StartTimecode { frames: 107_892, rate, drop_frame: true }); + let mut w = MxfWriter::new(Cursor::new(Vec::new()), cfg).unwrap(); + let n = 12; + let total = (n as u64 * 48_000 * 1001 / 30_000) as usize; + w.push_sound(0, &pcm16(0, total)).unwrap(); + for i in 0..n { + w.push_picture(&vc3_like(i), FrameInfo::intra()).unwrap(); + } + let f = w.finish().unwrap().into_inner(); + let m = open(&f).unwrap(); + assert_eq!(m.timecode.unwrap().format(), "01:00:00;00"); + let a = m.track_of_kind(TrackKind::Sound).unwrap(); + let sizes: Vec = m.tracks[a].chunks.iter().map(|c| c.samples).collect(); + assert_eq!(sizes[..5], [1601, 1602, 1601, 1602, 1602]); + assert_eq!(m.tracks[a].stored_sample_frames(), total as u64); + let pcm = m.read_pcm(&f, a, 0, total).unwrap(); + assert!((0..total).all(|k| pcm[0][k] == (k % 30000) as f32 / 32768.0)); + let v = m.track_of_kind(TrackKind::Picture).unwrap(); + assert_eq!(m.tracks[v].codec, Codec::Vc3); +} + +#[test] +fn long_gop_index_gives_presentation_order_and_key_frames() { + let rate = Rational::new(25, 1); + let mut cfg = op1a_config(rate, PictureCoding::Avc { profile_idc: 100, intra: false }); + cfg.sound.clear(); + let mut w = MxfWriter::new(Cursor::new(Vec::new()), cfg).unwrap(); + // stored I0 P3 B1 B2 P6 B4 B5 I7(IDR) + let order = [(0, CodedKind::Intra, true), (3, CodedKind::Predicted, false), (1, CodedKind::Bidirectional, false), (2, CodedKind::Bidirectional, false)]; + let more = [(6, CodedKind::Predicted, false), (4, CodedKind::Bidirectional, false), (5, CodedKind::Bidirectional, false), (7, CodedKind::Intra, true)]; + for (i, (d, kind, key)) in order.iter().chain(more.iter()).enumerate() { + let au = vec![0, 0, 0, 1, if *key { 0x65 } else { 0x41 }, i as u8]; + w.push_picture(&au, FrameInfo { key: *key, kind: *kind, display: Some(*d) }).unwrap(); + } + let f = w.finish().unwrap().into_inner(); + let m = open(&f).unwrap(); + let t = &m.tracks[0]; + assert_eq!(t.codec, Codec::Avc { intra: false }); + assert!(t.temporal_offsets && t.indexed); + let pts: Vec = t.samples.iter().map(|s| s.pts).collect(); + assert_eq!(pts, vec![0, 3, 1, 2, 6, 4, 5, 7]); + let keys: Vec = t.samples.iter().map(|s| s.key).collect(); + assert_eq!(keys, vec![true, false, false, false, false, false, false, true]); + let b: Vec = t.samples.iter().map(|s| s.b_picture).collect(); + assert_eq!(b, vec![false, false, true, true, false, true, true, false]); + assert_eq!(t.sample_at(4), Some(5)); +} + +#[test] +fn op_atom_picture_is_clip_wrapped() { + let mut cfg = WriterConfig::new(Pattern::OpAtom, Rational::new(24, 1), PackageIds::from_seed("atom-v", "Atom V")); + cfg.picture = Some(PictureDesc::new(PictureCoding::Vc3 { cid: 1272 }, 64, 36)); + cfg.timecode = Some(StartTimecode { frames: 86_400, rate: Rational::new(24, 1), drop_frame: false }); + let mut w = MxfWriter::new(Cursor::new(Vec::new()), cfg).unwrap(); + for i in 0..9 { + w.push_picture(&vc3_like(i), FrameInfo::intra()).unwrap(); + } + assert!(w.push_sound(0, &[0, 0]).is_err()); + let f = w.finish().unwrap().into_inner(); + let m = open(&f).unwrap(); + assert!(m.warnings.is_empty(), "{:?}", m.warnings); + assert_eq!(m.operational_pattern, OperationalPattern::Atom); + assert_eq!(m.timecode.unwrap().format(), "01:00:00:00"); + let t = &m.tracks[0]; + assert_eq!(t.wrapping, Wrapping::Clip); + assert_eq!(t.codec, Codec::Vc3); + assert_eq!(t.samples.len(), 9); + for i in 0..9 { + assert_eq!(m.read_sample(&f, 0, i).unwrap(), vc3_like(i)); + } +} + +#[test] +fn op_atom_pcm_helper() { + let ids = PackageIds::from_seed("atom-a", "Atom A1"); + let samples: Vec = (0..4801).map(|k| (k * 7 % 65536) - 32768).collect(); + for rate in [None, Some(Rational::new(25, 1))] { + let opts = OpAtomPcm { sample_rate: 48_000, bits: 16, channels: 1, edit_rate: rate, ids: ids.clone(), timecode: None }; + let f = write_opatom_pcm(&opts, &samples).unwrap(); + let m = open(&f).unwrap(); + assert_eq!(m.operational_pattern, OperationalPattern::Atom); + let t = &m.tracks[0]; + assert_eq!(t.kind, TrackKind::Sound); + assert_eq!(t.stored_sample_frames(), 4801); + assert_eq!(t.duration, Some(if rate.is_some() { 3 } else { 4801 })); + let pcm = m.read_pcm(&f, 0, 0, 4801).unwrap(); + assert!(samples.iter().zip(&pcm[0]).all(|(s, p)| *s as f32 / 32768.0 == *p)); + } + // 24-bit stereo + let opts = OpAtomPcm { sample_rate: 48_000, bits: 24, channels: 2, edit_rate: None, ids, timecode: None }; + let f = write_opatom_pcm(&opts, &[8_388_607, -8_388_608, 1, -1]).unwrap(); + let m = open(&f).unwrap(); + let pcm = m.read_pcm(&f, 0, 0, 2).unwrap(); + assert_eq!(pcm[0], vec![8_388_607.0 / 8_388_608.0, 1.0 / 8_388_608.0]); + assert_eq!(pcm[1], vec![-1.0, -1.0 / 8_388_608.0]); +} + +#[test] +fn package_ids_are_written() { + let f = build_op1a(); + let ids = PackageIds::from_seed("test", "Test Clip"); + assert!(f.windows(32).any(|w| w == ids.material.0)); + assert!(f.windows(32).any(|w| w == ids.file.0)); + assert_ne!(ids.material, ids.file); + assert_eq!(Umid::from_seed(b"x"), Umid::from_seed(b"x")); + assert_ne!(Umid::from_seed(b"x"), Umid::from_seed(b"y")); +} + +#[test] +fn long_files_split_the_index_into_segments() { + let mut cfg = op1a_config(Rational::new(25, 1), PictureCoding::ProRes { profile: 4 }); + cfg.sound.clear(); + let mut w = MxfWriter::new(Cursor::new(Vec::new()), cfg).unwrap(); + for i in 0..12_000 { + w.push_picture(&[0, 0, 0, 9, b'i', b'c', b'p', b'f', i as u8], FrameInfo::intra()).unwrap(); + } + let f = w.finish().unwrap().into_inner(); + let m = open(&f).unwrap(); + assert!(m.index_segments.len() >= 2); + assert_eq!(m.index_segments.iter().map(|s| s.entries.len()).sum::(), 12_000); + assert_eq!(m.tracks[0].samples.len(), 12_000); + assert_eq!(m.read_sample(&f, 0, 11_999).unwrap()[8], (11_999 % 256) as u8); +} + +#[test] +fn rejects_bad_configs() { + let ids = PackageIds::from_seed("x", "x"); + let cfg = WriterConfig::new(Pattern::Op1a, Rational::new(25, 1), ids.clone()); + assert!(MxfWriter::new(Cursor::new(Vec::new()), cfg).is_err()); + let mut cfg = WriterConfig::new(Pattern::OpAtom, Rational::new(25, 1), ids); + cfg.sound = vec![SoundDesc { sample_rate: 48_000, channels: 1, bits: 16 }; 2]; + assert!(MxfWriter::new(Cursor::new(Vec::new()), cfg).is_err()); +} + +#[test] +fn timestamps() { + assert_eq!(Timestamp::from_unix(0), Timestamp { year: 1970, month: 1, day: 1, ..Default::default() }); + let t = Timestamp::from_unix(1_700_000_000); + assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (2023, 11, 14, 22, 13, 20)); + let t = Timestamp::from_unix(951_782_400); // 2000-02-29 + assert_eq!((t.year, t.month, t.day), (2000, 2, 29)); +} + +#[test] +fn truncated_and_mutated_files_never_panic() { + let f = build_op1a(); + let mut opened = 0; + for cut in (0..f.len()).step_by(41) { + let part = f[..cut].to_vec(); + if let Ok(m) = open(&part) { + opened += 1; + for (ti, t) in m.tracks.iter().enumerate() { + for i in 0..t.samples.len() { + assert!(m.read_sample(&part, ti, i).is_ok(), "cut {cut}"); + } + let _ = m.read_pcm(&part, ti, 0, 3000); + } + } + } + assert!(opened > 20, "a truncated file with a complete header still opens ({opened})"); + let atom = write_opatom_pcm( + &OpAtomPcm { sample_rate: 48_000, bits: 24, channels: 1, edit_rate: None, ids: PackageIds::from_seed("m", "m"), timecode: None }, + &(0..3000).collect::>(), + ) + .unwrap(); + let mut seed = 0x9E37_79B9u64; + for src in [&f, &atom] { + for cut in (0..src.len()).step_by(53) { + let _ = open(&src[..cut].to_vec()).map(|m| m.read_pcm(&src[..cut].to_vec(), 0, 0, 100).ok()); + } + for _ in 0..400 { + let mut g = src.clone(); + for _ in 0..6 { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + let at = (seed as usize) % g.len(); + g[at] = (seed >> 40) as u8; + } + if let Ok(m) = open(&g) { + for (ti, t) in m.tracks.iter().enumerate() { + for i in 0..t.samples.len().min(12) { + let _ = m.read_sample(&g, ti, i); + } + let _ = m.read_pcm(&g, ti, 0, 500); + } + } + } + } +} diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index b554cbe2..5cba1822 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -22,7 +22,8 @@ use egui::{Align2, Color32, Rect, Sense, pos2, vec2}; use filmcraft_engine::export::presets::{DEFAULT_PRESET, preset_key}; use filmcraft_engine::export::{ - AudioCodec, BitrateMode, ExportSettings, FieldOrder, Format, H264Profile, Multiplexer, Placement, Scaling, TextOverlay, builtin_presets, format_bytes, + AudioCodec, BitrateMode, ExportSettings, FieldOrder, Format, H264Profile, Multiplexer, MxfVideoCodec, Placement, Scaling, TextOverlay, builtin_presets, + format_bytes, }; use filmcraft_engine::time::{FrameRate, Tick}; use serde::{Deserialize, Serialize}; @@ -458,7 +459,11 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if s.has_video() && section(ui, &mut reg, &mut ex.open_sections, "video", "Video", &t) { video_section(ui, &mut reg, s, &t, seq_w, seq_h); } - if (s.has_audio() || !s.has_video() || s.format == Format::H264 || matches!(s.format, Format::ProRes | Format::DnxHr | Format::Mjpeg)) + if (s.has_audio() + || !s.has_video() + || s.format == Format::H264 + || matches!(s.format, Format::ProRes | Format::DnxHr | Format::Mjpeg) + || s.format.is_mxf()) && !s.is_image_sequence() && s.format != Format::Gif && section(ui, &mut reg, &mut ex.open_sections, "audio", "Audio", &t) @@ -579,7 +584,15 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T s.pixel_aspect = PARS[i].1; } }); - match s.format { + if s.format.is_mxf() { + row(ui, t, "Video Codec", |ui| { + let labels: Vec<(String, bool)> = MxfVideoCodec::ALL.iter().map(|c| (c.label().to_string(), true)).collect(); + if let Some(i) = combo(ui, reg, "export.video.mxfCodec", s.mxf_video_codec.label(), &labels, 180.0) { + s.mxf_video_codec = MxfVideoCodec::ALL[i]; + } + }); + } + match s.video_format() { Format::H264 => { row(ui, t, "Profile", |ui| { let o = [H264Profile::Baseline, H264Profile::Main, H264Profile::High]; @@ -683,7 +696,7 @@ fn audio_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } } row(ui, t, "Audio Format", |ui| { - let fixed = s.format == Format::H264 && s.multiplexer == Multiplexer::Mp4 || audio_only; + let fixed = s.format == Format::H264 && s.multiplexer == Multiplexer::Mp4 || audio_only || s.format.is_mxf(); let cur = match s.audio_codec() { AudioCodec::Aac => "AAC", _ => "Uncompressed (PCM)", diff --git a/docs/architecture.md b/docs/architecture.md index 05da2c71..96e7752e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -41,7 +41,7 @@ and `filmcraft-cli`. | `bitstream` | L0 | bit reader/writer, Exp-Golomb, emulation prevention | | `isobmff` | L0 | MP4/MOV demux and mux | | `matroska` | L0 | MKV/WebM demux | -| `mxf` | L0 | MXF demux (SMPTE ST 377-1): OP1a / OP-Atom, index tables, AVC / VC-3 / ProRes / MPEG-2 identification, PCM / AES3 sound, timecode ([README](../crates/mxf/README.md)) | +| `mxf` | L0 | MXF demux and mux (SMPTE ST 377-1): OP1a / OP-Atom, index tables, AVC / VC-3 / ProRes / MPEG-2 identification, PCM / AES3 sound, timecode; writer for OP1a / OP-Atom with VC-3, ProRes, AVC and PCM ([README](../crates/mxf/README.md)) | | `ogg` | L0 | Ogg demux (RFC 3533), Ogg Opus timing (RFC 7845 granules, pre-skip, end trimming), Vorbis headers | | `h264`, `h264enc` | L0 | H.264 decoder; H.264 encoder | | `hevc` | L0 | H.265 Main/Main 10 decoder | @@ -530,6 +530,8 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui | `prores` | `filmcraft-prores` (Proxy / LT / 422 / HQ) | MOV | | `dnxhr` | `filmcraft-dnx` (LB / SQ / HQ / HQX) | MOV (`AVdh`) | | `mjpeg` | built in | MOV | +| `mxf-op1a` | DNxHR (default), ProRes or H.264 (Annex B, long GOP), `mxfVideoCodec`; PCM 16/24-bit | MXF OP1a (`filmcraft-mxf` writer: frame-wrapped, index with temporal offsets, start timecode) | +| `mxf-opatom` | as `mxf-op1a` | Avid-style MXF OP-Atom: clip-wrapped picture at `path`, one mono PCM file per channel (`_A1.mxf` …, `Report::extra_files`) | | `png`, `tiff`, `bmp` | `image` | numbered stills `000.`, `001.` … | | `gif`, `wav`, `aiff` | built in / `image` | GIF / RIFF WAVE (`WAVE_FORMAT_EXTENSIBLE` for 5.1) / AIFF (16- or 24-bit PCM) | @@ -542,7 +544,7 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui `©cmt`). `settings.summary()` / `estimate_bytes()` feed Export mode's Summary. - **Presets.** `export::presets::builtin_presets()` are our own definitions (Match Source adaptive H.264 at 0.2 / 0.1 / 0.05 bits per pixel, 1080p / 2160p delivery, vertical 1080×1920, ProRes, - DNxHR, image sequences, GIF, WAV / AIFF). User presets and favourites persist in + DNxHR, MXF OP1a (DNxHR HQ / ProRes 422 HQ / H.264) and OP-Atom (DNxHR, Avid), image sequences, GIF, WAV / AIFF). User presets and favourites persist in `/export-presets.json` (`export.presets.*`). - **Queue.** `export.queue.add` snapshots the project and the resolved settings (several sequences or ranges add several items); `export.queue.start` encodes ready items one after another as