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:
parent
30d6e3e950
commit
09947712d0
|
|
@ -1856,6 +1856,7 @@ dependencies = [
|
||||||
"filmcraft-h264enc",
|
"filmcraft-h264enc",
|
||||||
"filmcraft-isobmff",
|
"filmcraft-isobmff",
|
||||||
"filmcraft-media",
|
"filmcraft-media",
|
||||||
|
"filmcraft-mxf",
|
||||||
"filmcraft-project",
|
"filmcraft-project",
|
||||||
"filmcraft-prores",
|
"filmcraft-prores",
|
||||||
"filmcraft-render",
|
"filmcraft-render",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ SUBCOMMANDS
|
||||||
export <out> [--preset name] [--format f] [--range r] [--start s --end s] [--settings json]
|
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 the active sequence and wait for it to finish: with an
|
||||||
export preset (`export --list-presets`; built-in or the user's), or
|
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
|
from the extension); --range entire|inOut|workArea, or a custom
|
||||||
range in seconds; --settings is ExportSettings JSON merged over the
|
range in seconds; --settings is ExportSettings JSON merged over the
|
||||||
preset; --queue adds to the export queue and runs it instead
|
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() {
|
Some(match ext.as_str() {
|
||||||
"mp4" | "m4v" => "h264",
|
"mp4" | "m4v" => "h264",
|
||||||
"mov" => "prores",
|
"mov" => "prores",
|
||||||
"mxf" => "dnxhr",
|
"mxf" => "mxf-op1a",
|
||||||
"png" => "png",
|
"png" => "png",
|
||||||
"gif" => "gif",
|
"gif" => "gif",
|
||||||
"wav" => "wav",
|
"wav" => "wav",
|
||||||
|
|
@ -366,3 +366,13 @@ async fn main() {
|
||||||
other => usage(format!("unknown subcommand `{other}`")),
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -859,7 +859,7 @@ fn build() -> Vec<CommandSpec> {
|
||||||
"Media…",
|
"Media…",
|
||||||
["File", "Export"],
|
["File", "Export"],
|
||||||
None,
|
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,
|
has_seq,
|
||||||
crate::export_tools::export_media
|
crate::export_tools::export_media
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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));
|
assert_eq!((v["width"].as_u64().unwrap(), v["height"].as_u64().unwrap()), (640, 360));
|
||||||
continue;
|
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"));
|
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!((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}");
|
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}"),
|
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();
|
let favs = s.execute("export.presets.list", json!({"favorites": true})).unwrap()["presets"].as_array().unwrap().len();
|
||||||
assert_eq!(favs, 2);
|
assert_eq!(favs, 2);
|
||||||
let hits = s.execute("export.presets.list", json!({"query": "prores"})).unwrap()["presets"].as_array().unwrap().len();
|
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
|
// persisted: a fresh session with the same data directory sees both
|
||||||
let mut s2 = demo();
|
let mut s2 = demo();
|
||||||
s2.export_presets.set_dir(&dir.0);
|
s2.export_presets.set_dir(&dir.0);
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
if let Some(v) = str_p(p, "dnxProfile") {
|
||||||
settings.dnx_profile = v.to_string();
|
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") {
|
if let Some(v) = bool_p(p, "sdr") {
|
||||||
settings.sdr = v;
|
settings.sdr = v;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -618,7 +618,14 @@ fn track(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let r = match &err {
|
let r = match &err {
|
||||||
// stopping keeps what was tracked
|
// stopping keeps what was tracked
|
||||||
Some(e) if e != "stopped" => Err(e.clone()),
|
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 {
|
*lock(&prog.status) = match &err {
|
||||||
Some(e) => format!("{e} ({done} frame(s) tracked)"),
|
Some(e) => format!("{e} ({done} frame(s) tracked)"),
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,8 @@ fn events_report_background_jobs() {
|
||||||
s.jobs.extend([a.clone(), b.clone(), c.clone()]);
|
s.jobs.extend([a.clone(), b.clone(), c.clone()]);
|
||||||
s.poll_persistence();
|
s.poll_persistence();
|
||||||
assert_eq!(s.log.entries.iter().filter(|e| e.message.starts_with("Started:")).count(), 3);
|
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()));
|
*b.result.lock().unwrap() = Some(Err("disk full".into()));
|
||||||
c.progress.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
|
c.progress.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
*c.result.lock().unwrap() = Some(Err("cancelled".into()));
|
*c.result.lock().unwrap() = Some(Err("cancelled".into()));
|
||||||
|
|
|
||||||
|
|
@ -533,6 +533,7 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result<Value> {
|
||||||
seconds: secs,
|
seconds: secs,
|
||||||
bytes,
|
bytes,
|
||||||
render_fps: done_frames as f64 / secs.max(1e-6),
|
render_fps: done_frames as f64 / secs.max(1e-6),
|
||||||
|
extra_files: Vec::new(),
|
||||||
});
|
});
|
||||||
if let Err(e) = &r {
|
if let Err(e) = &r {
|
||||||
*prog.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(e.clone());
|
*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);
|
store.add_audio(&g.hash);
|
||||||
}
|
}
|
||||||
let secs = t0.elapsed().as_secs_f64();
|
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 {
|
if let Err(e) = &r {
|
||||||
*prog.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(e.clone());
|
*prog.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(e.clone());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,7 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
seconds: secs,
|
seconds: secs,
|
||||||
bytes,
|
bytes,
|
||||||
render_fps: 0.0,
|
render_fps: 0.0,
|
||||||
|
extra_files: Vec::new(),
|
||||||
});
|
});
|
||||||
if let Err(e) = &r {
|
if let Err(e) = &r {
|
||||||
*prog.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(e.clone());
|
*prog.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(e.clone());
|
||||||
|
|
|
||||||
|
|
@ -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());
|
*prog.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(e.clone());
|
||||||
Err(e)
|
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 {
|
*prog.status.lock().unwrap_or_else(|e| e.into_inner()) = match &r {
|
||||||
Ok(_) => format!("Done in {secs:.1}s"),
|
Ok(_) => format!("Done in {secs:.1}s"),
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,14 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
Some(e) => Err(e),
|
Some(e) => Err(e),
|
||||||
None => {
|
None => {
|
||||||
*lock(&out) = Some(found);
|
*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);
|
*lock(&res) = Some(r);
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ filmcraft-text = { workspace = true }
|
||||||
filmcraft-isobmff = { workspace = true }
|
filmcraft-isobmff = { workspace = true }
|
||||||
filmcraft-prores = { workspace = true }
|
filmcraft-prores = { workspace = true }
|
||||||
filmcraft-dnx = { workspace = true }
|
filmcraft-dnx = { workspace = true }
|
||||||
|
filmcraft-mxf = { workspace = true }
|
||||||
filmcraft-aac = { workspace = true }
|
filmcraft-aac = { workspace = true }
|
||||||
filmcraft-h264enc = { workspace = true }
|
filmcraft-h264enc = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ use filmcraft_time::FrameRate;
|
||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
|
|
||||||
use crate::audio_out::AudioOut;
|
use crate::audio_out::AudioOut;
|
||||||
|
use crate::mxf_out::{MxfMux, MxfSetup};
|
||||||
use crate::pipeline::Pipeline;
|
use crate::pipeline::Pipeline;
|
||||||
use crate::settings::{AudioCodec, BitrateMode, Multiplexer};
|
use crate::settings::{AudioCodec, BitrateMode, Multiplexer};
|
||||||
use crate::{
|
use crate::{
|
||||||
|
|
@ -59,6 +60,10 @@ pub struct Exporter {
|
||||||
first_pass: bool,
|
first_pass: bool,
|
||||||
/// Created after the first batch (encoders may finalise their codec config then).
|
/// Created after the first batch (encoders may finalise their codec config then).
|
||||||
mux: Option<Mp4Writer<Out>>,
|
mux: Option<Mp4Writer<Out>>,
|
||||||
|
/// MXF exports write through this instead of `mux`.
|
||||||
|
mxf: Option<MxfMux>,
|
||||||
|
seq: ItemId,
|
||||||
|
project: Arc<Project>,
|
||||||
vt: usize,
|
vt: usize,
|
||||||
at: Option<usize>,
|
at: Option<usize>,
|
||||||
t0: web_time::Instant,
|
t0: web_time::Instant,
|
||||||
|
|
@ -66,7 +71,7 @@ pub struct Exporter {
|
||||||
|
|
||||||
/// Whether [`Exporter`] handles a format.
|
/// Whether [`Exporter`] handles a format.
|
||||||
pub fn stepped(format: Format) -> bool {
|
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>> {
|
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()
|
.read()
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|fac| fac(settings.format, w, h, rate, settings))
|
.find_map(|fac| fac(settings.video_format(), w, h, rate, settings))
|
||||||
.ok_or_else(|| ExportError::Unsupported(format!("{} encoder not available yet", settings.format.label())))?
|
.ok_or_else(|| ExportError::Unsupported(format!("{} encoder not available yet", settings.video_format().label())))?
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Exporter {
|
impl Exporter {
|
||||||
|
|
@ -88,7 +93,7 @@ impl Exporter {
|
||||||
let q = project.sequence(seq).ok_or(ExportError::NoSequence)?;
|
let q = project.sequence(seq).ok_or(ExportError::NoSequence)?;
|
||||||
// HDR sequences export HDR (H.264 / ProRes / DNxHR) unless SDR is asked for
|
// HDR sequences export HDR (H.264 / ProRes / DNxHR) unless SDR is asked for
|
||||||
let pipe = q.settings.color;
|
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();
|
let mut settings = settings.clone();
|
||||||
settings.signal = match (hdr_out, pipe.working) {
|
settings.signal = match (hdr_out, pipe.working) {
|
||||||
(true, filmcraft_color::WorkingSpace::Rec2100Pq) => ColorSignal::PQ,
|
(true, filmcraft_color::WorkingSpace::Rec2100Pq) => ColorSignal::PQ,
|
||||||
|
|
@ -101,7 +106,7 @@ impl Exporter {
|
||||||
settings.max_bitrate_kbps = Some(r.max_kbps);
|
settings.max_bitrate_kbps = Some(r.max_kbps);
|
||||||
settings.keyframe_distance = Some(r.keyint);
|
settings.keyframe_distance = Some(r.keyint);
|
||||||
settings.adaptive_bitrate = None;
|
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 };
|
settings.h264_pass = if two_pass { H264Pass::First } else { H264Pass::Single };
|
||||||
let range = export_range(&project, seq, &settings)?;
|
let range = export_range(&project, seq, &settings)?;
|
||||||
let pipe = Pipeline::new(project.clone(), seq, &settings, hdr_out)?;
|
let pipe = Pipeline::new(project.clone(), seq, &settings, hdr_out)?;
|
||||||
|
|
@ -136,6 +141,9 @@ impl Exporter {
|
||||||
measured,
|
measured,
|
||||||
first_pass: two_pass,
|
first_pass: two_pass,
|
||||||
mux: None,
|
mux: None,
|
||||||
|
mxf: None,
|
||||||
|
seq,
|
||||||
|
project: project.clone(),
|
||||||
vt: 0,
|
vt: 0,
|
||||||
at: None,
|
at: None,
|
||||||
t0: web_time::Instant::now(),
|
t0: web_time::Instant::now(),
|
||||||
|
|
@ -158,6 +166,12 @@ impl Exporter {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_audio(&mut self, planar: Option<Vec<Vec<f32>>>) -> Result<()> {
|
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 (Some(at), Some(buf)) = (self.at, planar) else { return Ok(()) };
|
||||||
let n = buf.first().map_or(0, Vec::len);
|
let n = buf.first().map_or(0, Vec::len);
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
|
|
@ -192,6 +206,9 @@ impl Exporter {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_video(&mut self, packets: Vec<EncodedPacket>) -> Result<()> {
|
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");
|
let mux = self.mux.as_mut().expect("mux created");
|
||||||
for p in packets {
|
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 })
|
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).
|
/// Create the muxer and its tracks (after the first batch was encoded).
|
||||||
fn open_mux(&mut self) -> Result<()> {
|
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 file = Out::create(&self.settings)?;
|
||||||
let mut opts = WriterOptions::new(self.brand);
|
let mut opts = WriterOptions::new(self.brand);
|
||||||
opts.metadata = self.settings.metadata.udta();
|
opts.metadata = self.settings.metadata.udta();
|
||||||
|
|
@ -262,7 +296,7 @@ impl Exporter {
|
||||||
return Ok(Step::Progress);
|
return Ok(Step::Progress);
|
||||||
}
|
}
|
||||||
let mixed = match self.audio.as_ref().map(|a| a.sr) {
|
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);
|
let until = self.sample_at_frame(end, sr);
|
||||||
self.audio.as_mut().and_then(|a| a.pull(until, sources))
|
self.audio.as_mut().and_then(|a| a.pull(until, sources))
|
||||||
}
|
}
|
||||||
|
|
@ -272,7 +306,7 @@ impl Exporter {
|
||||||
return Ok(Step::Pending);
|
return Ok(Step::Pending);
|
||||||
}
|
}
|
||||||
let packets = self.encode(&frames, f)?;
|
let packets = self.encode(&frames, f)?;
|
||||||
if self.mux.is_none() {
|
if self.mux.is_none() && self.mxf.is_none() {
|
||||||
self.open_mux()?;
|
self.open_mux()?;
|
||||||
}
|
}
|
||||||
self.write_video(packets)?;
|
self.write_video(packets)?;
|
||||||
|
|
@ -295,7 +329,7 @@ impl Exporter {
|
||||||
if filmcraft_media::pending::take() {
|
if filmcraft_media::pending::take() {
|
||||||
return Ok(Step::Pending);
|
return Ok(Step::Pending);
|
||||||
}
|
}
|
||||||
if self.mux.is_none() {
|
if self.mux.is_none() && self.mxf.is_none() {
|
||||||
self.open_mux()?;
|
self.open_mux()?;
|
||||||
}
|
}
|
||||||
let tail = self.venc.flush()?;
|
let tail = self.venc.flush()?;
|
||||||
|
|
@ -309,15 +343,27 @@ impl Exporter {
|
||||||
.map_err(|e| ExportError::Io(e.to_string()))?;
|
.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, extra_files) = match self.mxf.take() {
|
||||||
let bytes = w.finish(&self.settings)?;
|
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 secs = self.t0.elapsed().as_secs_f64();
|
||||||
let nframes = self.frames();
|
let nframes = self.frames();
|
||||||
if !self.settings.part_of_batch {
|
if !self.settings.part_of_batch {
|
||||||
progress.finished.store(true, Ordering::Relaxed);
|
progress.finished.store(true, Ordering::Relaxed);
|
||||||
progress.set_status(format!("Done in {secs:.1}s"));
|
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>> {
|
fn encode(&mut self, frames: &[(Vec<u8>, Vec<f32>)], f: i64) -> Result<Vec<EncodedPacket>> {
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,14 @@ use std::io::Write;
|
||||||
|
|
||||||
mod audio_out;
|
mod audio_out;
|
||||||
mod job;
|
mod job;
|
||||||
|
mod mxf_out;
|
||||||
mod pcm;
|
mod pcm;
|
||||||
mod pipeline;
|
mod pipeline;
|
||||||
pub mod presets;
|
pub mod presets;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub use audio_out::LoudnessReport;
|
pub use audio_out::LoudnessReport;
|
||||||
pub use job::{Exporter, Step, stepped};
|
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 pcm::{image_sequence_path, write_aiff, write_wav};
|
||||||
pub use pipeline::limit_rgba8;
|
pub use pipeline::limit_rgba8;
|
||||||
pub use presets::{ExportPreset, builtin_presets};
|
pub use presets::{ExportPreset, builtin_presets};
|
||||||
|
|
@ -82,6 +84,13 @@ pub enum Format {
|
||||||
Wav,
|
Wav,
|
||||||
#[serde(rename = "aiff", alias = "Aiff")]
|
#[serde(rename = "aiff", alias = "Aiff")]
|
||||||
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 {
|
impl Format {
|
||||||
|
|
@ -89,7 +98,9 @@ impl Format {
|
||||||
Some(match s.to_ascii_lowercase().replace([' ', '-', '_', '.'], "").as_str() {
|
Some(match s.to_ascii_lowercase().replace([' ', '-', '_', '.'], "").as_str() {
|
||||||
"h264" | "mp4" | "avc" | "m4v" => Format::H264,
|
"h264" | "mp4" | "avc" | "m4v" => Format::H264,
|
||||||
"prores" | "mov" | "appleprores" => Format::ProRes,
|
"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,
|
"mjpeg" | "motionjpeg" | "jpeg" => Format::Mjpeg,
|
||||||
"png" | "pngsequence" => Format::PngSequence,
|
"png" | "pngsequence" => Format::PngSequence,
|
||||||
"tif" | "tiff" | "tiffsequence" => Format::TiffSequence,
|
"tif" | "tiff" | "tiffsequence" => Format::TiffSequence,
|
||||||
|
|
@ -113,6 +124,8 @@ impl Format {
|
||||||
Format::Gif => "gif",
|
Format::Gif => "gif",
|
||||||
Format::Wav => "wav",
|
Format::Wav => "wav",
|
||||||
Format::Aiff => "aiff",
|
Format::Aiff => "aiff",
|
||||||
|
Format::MxfOp1a => "mxf-op1a",
|
||||||
|
Format::MxfOpAtom => "mxf-opatom",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn extension(self) -> &'static str {
|
pub fn extension(self) -> &'static str {
|
||||||
|
|
@ -125,6 +138,7 @@ impl Format {
|
||||||
Format::Gif => "gif",
|
Format::Gif => "gif",
|
||||||
Format::Wav => "wav",
|
Format::Wav => "wav",
|
||||||
Format::Aiff => "aif",
|
Format::Aiff => "aif",
|
||||||
|
Format::MxfOp1a | Format::MxfOpAtom => "mxf",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn label(self) -> &'static str {
|
pub fn label(self) -> &'static str {
|
||||||
|
|
@ -139,9 +153,15 @@ impl Format {
|
||||||
Format::Gif => "Animated GIF",
|
Format::Gif => "Animated GIF",
|
||||||
Format::Wav => "Waveform Audio",
|
Format::Wav => "Waveform Audio",
|
||||||
Format::Aiff => "AIFF",
|
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::H264,
|
||||||
Format::ProRes,
|
Format::ProRes,
|
||||||
Format::DnxHr,
|
Format::DnxHr,
|
||||||
|
|
@ -152,6 +172,8 @@ impl Format {
|
||||||
Format::Gif,
|
Format::Gif,
|
||||||
Format::Wav,
|
Format::Wav,
|
||||||
Format::Aiff,
|
Format::Aiff,
|
||||||
|
Format::MxfOp1a,
|
||||||
|
Format::MxfOpAtom,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,6 +214,9 @@ pub struct ExportSettings {
|
||||||
/// DNxHR profile: `lb`, `sq`, `hq` or `hqx` (empty = HQ).
|
/// DNxHR profile: `lb`, `sq`, `hq` or `hqx` (empty = HQ).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dnx_profile: String,
|
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
|
/// 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
|
/// 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).
|
/// sequence's HDR space and signal it (VUI / `colr` / `mdcv` / `clli` / SEI).
|
||||||
|
|
@ -259,21 +284,30 @@ enum Out {
|
||||||
|
|
||||||
impl Out {
|
impl Out {
|
||||||
fn create(settings: &ExportSettings) -> Result<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() {
|
if settings.sink.is_some() {
|
||||||
return Ok(Out::Mem(std::io::Cursor::new(Vec::new())));
|
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)))
|
Ok(Out::File(std::io::BufWriter::new(f)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flush (and hand in-memory output to the sink); returns the file size.
|
/// Flush (and hand in-memory output to the sink); returns the file size.
|
||||||
fn finish(self, settings: &ExportSettings) -> Result<u64> {
|
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 {
|
match self {
|
||||||
Out::File(mut w) => {
|
Out::File(mut w) => {
|
||||||
w.flush().map_err(|e| ExportError::Io(e.to_string()))?;
|
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,
|
part_of_batch: false,
|
||||||
prores_profile: String::new(),
|
prores_profile: String::new(),
|
||||||
dnx_profile: String::new(),
|
dnx_profile: String::new(),
|
||||||
|
mxf_video_codec: MxfVideoCodec::default(),
|
||||||
sdr: false,
|
sdr: false,
|
||||||
frame_size: None,
|
frame_size: None,
|
||||||
frame_rate: None,
|
frame_rate: None,
|
||||||
|
|
@ -441,6 +476,9 @@ pub struct Report {
|
||||||
pub seconds: f64,
|
pub seconds: f64,
|
||||||
pub bytes: u64,
|
pub bytes: u64,
|
||||||
pub render_fps: f64,
|
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.
|
/// 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;
|
return None;
|
||||||
}
|
}
|
||||||
let mut cfg = filmcraft_h264enc::EncoderConfig::new(w, h, rate.num as u32, rate.den as u32);
|
let mut cfg = filmcraft_h264enc::EncoderConfig::new(w, h, rate.num as u32, rate.den as u32);
|
||||||
cfg.format = filmcraft_h264enc::PacketFormat::LengthPrefixed;
|
// MXF carries the Annex B byte stream (ST 381-3) with in-band parameter sets
|
||||||
cfg.aud = false;
|
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);
|
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 kbps = s.bitrate_kbps.max(100);
|
||||||
let max = s.max_bitrate_kbps.filter(|m| *m >= kbps).unwrap_or(kbps * 3 / 2);
|
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)
|
(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();
|
let secs = t0.elapsed().as_secs_f64();
|
||||||
if !settings.part_of_batch {
|
if !settings.part_of_batch {
|
||||||
progress.finished.store(true, Ordering::Relaxed);
|
progress.finished.store(true, Ordering::Relaxed);
|
||||||
progress.set_status(format!("Done in {secs:.1}s"));
|
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)]
|
#[cfg(test)]
|
||||||
|
|
@ -1087,3 +1126,6 @@ mod settings_tests;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod surround_tests;
|
mod surround_tests;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod mxf_tests;
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
use filmcraft_time::FrameRate;
|
use filmcraft_time::FrameRate;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::settings::{AudioCodec, AudioSettings, BitrateMode, Scaling};
|
use crate::settings::{AudioCodec, AudioSettings, BitrateMode, MxfVideoCodec, Scaling};
|
||||||
use crate::{ExportSettings, Format};
|
use crate::{ExportSettings, Format};
|
||||||
|
|
||||||
/// A named set of export settings (`path` and `range` are left empty).
|
/// A named set of export settings (`path` and `range` are left empty).
|
||||||
|
|
@ -64,6 +64,20 @@ fn mov(format: Format, profile: &str) -> ExportSettings {
|
||||||
s
|
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 {
|
fn audio_only(format: Format, bits: u16) -> ExportSettings {
|
||||||
ExportSettings {
|
ExportSettings {
|
||||||
format,
|
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 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 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("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(
|
p(
|
||||||
"PNG Sequence",
|
"PNG Sequence",
|
||||||
"Image Sequence",
|
"Image Sequence",
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,38 @@ pub enum Multiplexer {
|
||||||
Mov,
|
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.
|
/// Audio codec. `Auto` = AAC in MP4, PCM in QuickTime / WAV / AIFF.
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|
@ -307,6 +339,11 @@ pub struct Resolved {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExportSettings {
|
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.
|
/// Whether the output is a numbered image sequence.
|
||||||
pub fn is_image_sequence(&self) -> bool {
|
pub fn is_image_sequence(&self) -> bool {
|
||||||
matches!(self.format, Format::PngSequence | Format::TiffSequence | Format::BmpSequence)
|
matches!(self.format, Format::PngSequence | Format::TiffSequence | Format::BmpSequence)
|
||||||
|
|
@ -354,7 +391,7 @@ impl ExportSettings {
|
||||||
/// The audio codec actually used.
|
/// The audio codec actually used.
|
||||||
pub fn audio_codec(&self) -> AudioCodec {
|
pub fn audio_codec(&self) -> AudioCodec {
|
||||||
match (self.format, self.audio.codec) {
|
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,
|
(Format::H264, _) if self.multiplexer == Multiplexer::Mp4 => AudioCodec::Aac,
|
||||||
(_, AudioCodec::Auto) if self.format == Format::H264 => AudioCodec::Aac,
|
(_, AudioCodec::Auto) if self.format == Format::H264 => AudioCodec::Aac,
|
||||||
(_, AudioCodec::Auto) => AudioCodec::Pcm,
|
(_, AudioCodec::Auto) => AudioCodec::Pcm,
|
||||||
|
|
@ -383,7 +420,7 @@ impl ExportSettings {
|
||||||
let secs = duration.seconds().max(0.0);
|
let secs = duration.seconds().max(0.0);
|
||||||
let fps = r.rate.num as f64 / r.rate.den as f64;
|
let fps = r.rate.num as f64 / r.rate.den as f64;
|
||||||
let px = r.width as f64 * r.height 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::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::ProRes => crate::prores_profile(&self.prores_profile).nominal_mbps_1080p30() * 1e6 * px / (1920.0 * 1080.0) * fps / 29.97,
|
||||||
Format::DnxHr => {
|
Format::DnxHr => {
|
||||||
|
|
@ -400,7 +437,7 @@ impl ExportSettings {
|
||||||
Format::PngSequence => px * 4.0 * 0.45 * 8.0 * fps,
|
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::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::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) {
|
let audio_bps = if self.has_audio() || matches!(self.format, Format::Wav | Format::Aiff) {
|
||||||
match self.audio_codec() {
|
match self.audio_codec() {
|
||||||
|
|
@ -434,7 +471,7 @@ impl ExportSettings {
|
||||||
} else {
|
} 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 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());
|
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 => {
|
Format::H264 => {
|
||||||
v += &format!(
|
v += &format!(
|
||||||
", {} {}, {}",
|
", {} {}, {}",
|
||||||
|
|
@ -480,6 +517,7 @@ impl ExportSettings {
|
||||||
Format::H264 if self.multiplexer == Multiplexer::Mov => "QuickTime",
|
Format::H264 if self.multiplexer == Multiplexer::Mov => "QuickTime",
|
||||||
Format::H264 => "MP4",
|
Format::H264 => "MP4",
|
||||||
Format::ProRes | Format::DnxHr | Format::Mjpeg => "QuickTime",
|
Format::ProRes | Format::DnxHr | Format::Mjpeg => "QuickTime",
|
||||||
|
Format::MxfOp1a | Format::MxfOpAtom => self.mxf_video_codec.label(),
|
||||||
Format::PngSequence | Format::TiffSequence | Format::BmpSequence => "Image sequence",
|
Format::PngSequence | Format::TiffSequence | Format::BmpSequence => "Image sequence",
|
||||||
_ => "",
|
_ => "",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "filmcraft-mxf"
|
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
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# filmcraft-mxf
|
# 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
|
`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.
|
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 386 | 2004 | D-10 (IMX) mapping: container label, sound element type |
|
||||||
| SMPTE ST 2019-4 | 2009 | VC-3 (DNxHD / DNxHR) mapping |
|
| SMPTE ST 2019-4 | 2009 | VC-3 (DNxHD / DNxHR) mapping |
|
||||||
| SMPTE RDD 44 | 2017 | Apple ProRes 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
|
## 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
|
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).
|
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
|
## Tests
|
||||||
|
|
||||||
- `src/tests.rs`: a small OP1a writer builds synthetic files (temporal offsets, key flags, PCM,
|
- `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),
|
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;
|
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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@
|
||||||
//! presentation position from the index temporal offsets, random-access flag) and PCM chunk
|
//! presentation position from the index temporal offsets, random-access flag) and PCM chunk
|
||||||
//! tables for sound, plus the start timecode of the material package.
|
//! 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`.
|
//! Layer L0: no dependencies beyond `std`, no `unsafe`, builds for `wasm32-unknown-unknown`.
|
||||||
|
|
||||||
mod essence;
|
mod essence;
|
||||||
|
|
@ -20,12 +23,17 @@ mod index;
|
||||||
mod klv;
|
mod klv;
|
||||||
mod meta;
|
mod meta;
|
||||||
mod source;
|
mod source;
|
||||||
|
pub mod write;
|
||||||
|
|
||||||
pub use essence::{Codec, PictureInfo, SoundFormat, SoundInfo, aes3_element_samples, decode_pcm};
|
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 file::{Chunk, EssenceTrack, MxfFile, OperationalPattern, Partition, PartitionKind, Sample, Timecode, TrackKind, Wrapping, open};
|
||||||
pub use index::{IndexEntry, IndexSegment};
|
pub use index::{IndexEntry, IndexSegment};
|
||||||
pub use klv::{Rational, Ul};
|
pub use klv::{Rational, Ul};
|
||||||
pub use source::ByteSource;
|
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::fmt;
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,7 +22,8 @@
|
||||||
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
|
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
|
||||||
use filmcraft_engine::export::presets::{DEFAULT_PRESET, preset_key};
|
use filmcraft_engine::export::presets::{DEFAULT_PRESET, preset_key};
|
||||||
use filmcraft_engine::export::{
|
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 filmcraft_engine::time::{FrameRate, Tick};
|
||||||
use serde::{Deserialize, Serialize};
|
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) {
|
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);
|
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.is_image_sequence()
|
||||||
&& s.format != Format::Gif
|
&& s.format != Format::Gif
|
||||||
&& section(ui, &mut reg, &mut ex.open_sections, "audio", "Audio", &t)
|
&& 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;
|
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 => {
|
Format::H264 => {
|
||||||
row(ui, t, "Profile", |ui| {
|
row(ui, t, "Profile", |ui| {
|
||||||
let o = [H264Profile::Baseline, H264Profile::Main, H264Profile::High];
|
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| {
|
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() {
|
let cur = match s.audio_codec() {
|
||||||
AudioCodec::Aac => "AAC",
|
AudioCodec::Aac => "AAC",
|
||||||
_ => "Uncompressed (PCM)",
|
_ => "Uncompressed (PCM)",
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ and `filmcraft-cli`.
|
||||||
| `bitstream` | L0 | bit reader/writer, Exp-Golomb, emulation prevention |
|
| `bitstream` | L0 | bit reader/writer, Exp-Golomb, emulation prevention |
|
||||||
| `isobmff` | L0 | MP4/MOV demux and mux |
|
| `isobmff` | L0 | MP4/MOV demux and mux |
|
||||||
| `matroska` | L0 | MKV/WebM demux |
|
| `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 |
|
| `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 |
|
| `h264`, `h264enc` | L0 | H.264 decoder; H.264 encoder |
|
||||||
| `hevc` | L0 | H.265 Main/Main 10 decoder |
|
| `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 |
|
| `prores` | `filmcraft-prores` (Proxy / LT / 422 / HQ) | MOV |
|
||||||
| `dnxhr` | `filmcraft-dnx` (LB / SQ / HQ / HQX) | MOV (`AVdh`) |
|
| `dnxhr` | `filmcraft-dnx` (LB / SQ / HQ / HQX) | MOV (`AVdh`) |
|
||||||
| `mjpeg` | built in | MOV |
|
| `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>` … |
|
| `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) |
|
| `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.
|
`©cmt`). `settings.summary()` / `estimate_bytes()` feed Export mode's Summary.
|
||||||
- **Presets.** `export::presets::builtin_presets()` are our own definitions (Match Source adaptive
|
- **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,
|
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.*`).
|
`<data dir>/export-presets.json` (`export.presets.*`).
|
||||||
- **Queue.** `export.queue.add` snapshots the project and the resolved settings (several sequences
|
- **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
|
or ranges add several items); `export.queue.start` encodes ready items one after another as
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue