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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 04:45:27 -04:00
parent 30d6e3e950
commit 09947712d0
27 changed files with 2460 additions and 44 deletions

1
Cargo.lock generated
View File

@ -1856,6 +1856,7 @@ dependencies = [
"filmcraft-h264enc",
"filmcraft-isobmff",
"filmcraft-media",
"filmcraft-mxf",
"filmcraft-project",
"filmcraft-prores",
"filmcraft-render",

View File

@ -197,7 +197,7 @@ MXF and hardware decode are next ([roadmap](ROADMAP.md)).
<p align="center"><sub>Export mode, set to write the trailer as H.264 MP4.</sub></p>
- **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.

View File

@ -27,7 +27,7 @@ SUBCOMMANDS
export <out> [--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);
}
}

View File

@ -859,7 +859,7 @@ fn build() -> Vec<CommandSpec> {
"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
),

View File

@ -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);

View File

@ -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;
}

View File

@ -618,7 +618,14 @@ fn track(s: &mut Session, p: &Value) -> Result<Value> {
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)"),

View File

@ -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()));

View File

@ -533,6 +533,7 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result<Value> {
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<Value> {
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());
}

View File

@ -217,6 +217,7 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
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());

View File

@ -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"),

View File

@ -193,7 +193,14 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> {
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);

View File

@ -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 }

View File

@ -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<Mp4Writer<Out>>,
/// MXF exports write through this instead of `mux`.
mxf: Option<MxfMux>,
seq: ItemId,
project: Arc<Project>,
vt: usize,
at: Option<usize>,
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<Box<dyn VideoEncoder>> {
@ -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<Vec<Vec<f32>>>) -> 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<EncodedPacket>) -> 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 (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()))?;
let bytes = w.finish(&self.settings)?;
(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<u8>, Vec<f32>)], f: i64) -> Result<Vec<EncodedPacket>> {

View File

@ -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> {
Out::create_path(settings, &settings.path)
}
/// An output file at `path` (OP-Atom exports write several).
fn create_path(settings: &ExportSettings, path: &str) -> Result<Out> {
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<u64> {
self.finish_path(settings, &settings.path)
}
fn finish_path(self, settings: &ExportSettings, path: &str) -> Result<u64> {
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<String>,
}
/// 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<Project>, 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;

View File

@ -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,
//! `<stem>_A1.mxf`, `<stem>_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<String> {
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<Out>,
/// OP-Atom: one writer per audio channel.
audio: Vec<(String, MxfWriter<Out>)>,
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<i64>,
max_display: i64,
frame_den: i64,
}
impl MxfMux {
pub fn new(s: MxfSetup) -> Result<Self> {
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<EncodedPacket>) -> 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<f32>]) -> 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<String>)> {
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))
}
}

View File

@ -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<Project>, 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<u8> {
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<String> = 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<u8> = 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");
}
}

View File

@ -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<ExportPreset> {
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",

View File

@ -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",
_ => "",
};

View File

@ -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

View File

@ -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.

View File

@ -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;

View File

@ -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
}

1190
crates/mxf/src/write/mod.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<usize> = (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<u64> = 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<i64> = t.samples.iter().map(|s| s.pts).collect();
assert_eq!(pts, vec![0, 3, 1, 2, 6, 4, 5, 7]);
let keys: Vec<bool> = t.samples.iter().map(|s| s.key).collect();
assert_eq!(keys, vec![true, false, false, false, false, false, false, true]);
let b: Vec<bool> = 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<i32> = (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::<usize>(), 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::<Vec<i32>>(),
)
.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);
}
}
}
}
}

View File

@ -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)",

View File

@ -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 (`<stem>_A1.mxf` …, `Report::extra_files`) |
| `png`, `tiff`, `bmp` | `image` | numbered stills `<name>000.<ext>`, `<name>001.<ext>` … |
| `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
`<data dir>/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