Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
705e9a1
#1065: ProcessOptions raw development settings for DNG
vangel76 Oct 11, 2026
e9b1217
#1065: optional serde derives on the DNG raw settings
vangel76 Oct 11, 2026
c6d6038
#1065: CinemaDNG in MXF
vangel76 Oct 11, 2026
ee7405e
Merge remote-tracking branch 'origin/dng-decode-plus' into dng-mxf-ui
vangel76 Oct 11, 2026
7055d45
#1065: faster DNG decode and development, reduced-resolution path
vangel76 Oct 11, 2026
14e2201
#1065: tint sign of the DNG white balance follows the raw-converter c…
vangel76 Oct 11, 2026
4405e02
Merge remote-tracking branch 'origin/dng-decode-plus' into dng-mxf-ui
vangel76 Oct 11, 2026
00c79fa
#1065: camera raw Source Settings for DNG and CinemaDNG
vangel76 Oct 11, 2026
ae6299b
#1065: Source Settings dialog for camera raw footage
vangel76 Oct 11, 2026
c85442d
Merge upstream/main into dng-mxf-ui
vangel76 Oct 12, 2026
dd8169a
Merge upstream/main into dng-mxf-ui
vangel76 Oct 13, 2026
5470c6e
i18n: add Source Settings strings to id, it, ko, pt-br
vangel76 Oct 19, 2026
e45787a
Merge remote-tracking branch 'upstream/main' into dng-mxf-ui
vangel76 Oct 19, 2026
41ba7f4
Merge upstream/main into dng-mxf-ui
vangel76 Oct 24, 2026
68e3250
Merge upstream/main into dng-mxf-ui
vangel76 Oct 24, 2026
9844830
Merge upstream/main into dng-mxf-ui
vangel76 Oct 25, 2026
d681220
Merge upstream/main into dng-mxf-ui
vangel76 Oct 26, 2026
08568de
Merge upstream/main into dng-mxf-ui
vangel76 Oct 27, 2026
2c615b2
Merge upstream/main into dng-mxf-ui
vangel76 Oct 29, 2026
09ecf35
dng: pass the plane dimensions of full() as one tuple (clippy too_man…
vangel76 Oct 29, 2026
a348205
style: cargo fmt
vangel76 Oct 31, 2026
37ee73a
Merge remote-tracking branch 'upstream/main' into merge-1078
claude Oct 31, 2026
e383043
style: cargo fmt after the main merge
claude Oct 31, 2026
bb0ab7a
Merge commit 'a3482059' into merge-1078
vangel76 Oct 31, 2026
1f46b2f
Merge upstream/main into dng-mxf-ui; raw Source Settings schema step …
vangel76 Nov 1, 2026
7d32638
Source Settings review: no empty undo steps, doc and nit fixes
vangel76 Nov 1, 2026
1ac503f
Source Settings review: false is only unset for reset, fix the offset…
vangel76 Nov 1, 2026
b8c4506
Merge upstream/main into dng-mxf-ui
vangel76 Nov 2, 2026
4d4e219
Merge upstream/main into dng-mxf-ui
vangel76 Nov 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/codecs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,6 @@ symphonia = { version = "0.5.5", default-features = false, features = ["aac", "m

[dev-dependencies]
filmcraft-testkit = { workspace = true }
# synthetic DNG frames for the CinemaDNG-in-MXF tests
filmcraft-dng = { workspace = true, features = ["writer"] }
serde_json = { workspace = true }
82 changes: 77 additions & 5 deletions crates/codecs/src/mxf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
//! - Audio: every PCM sound track (Broadcast Wave / AES3, ST 382; ST 331 AES3 elements in D-10)
//! is presented as one multichannel stream, sample-exact.
//! - Start timecode: the material package's timecode track.
//! - CinemaDNG (CinemaDNG Image Data Format Specification §7: one frame-wrapped DNG dataset per
//! edit unit): each picture is developed on its own by `filmcraft-dng` (through
//! `filmcraft_media::still::dng`, as DNG stills and CinemaDNG folders are) into scene-linear
//! Rec. 709 float frames; size and pixel format come from the first DNG, not the descriptor.

use std::sync::Arc;

Expand All @@ -35,6 +39,15 @@ pub fn sniff(head: &[u8]) -> bool {
filmcraft_mxf::sniff(head)
}

/// CinemaDNG pictures of an MXF file.
struct DngPictures {
/// Developed pictures by (stored index, reduced-resolution level).
cache: filmcraft_media::FrameCache<(usize, u8)>,
settings: filmcraft_media::raw::RawSettings,
/// The first picture's as-shot white balance (temperature, tint).
as_shot: Option<(f64, f64)>,
}

pub struct MxfSource {
info: MediaInfo,
bytes: crate::Src,
Expand All @@ -53,6 +66,8 @@ pub struct MxfSource {
unsupported: Option<String>,
/// MPEG-2: the first sequence header (primes decoders that start without one).
mpeg2_header: Vec<u8>,
/// CinemaDNG: every picture is intra coded and developed on its own, outside the GOP cache.
dng: Option<DngPictures>,
/// Sound tracks combined into one stream: (track, first stored sample frame).
atracks: Vec<(usize, u64)>,
/// Presented sample frames.
Expand Down Expand Up @@ -225,6 +240,7 @@ impl MxfSource {
let (mut vpts, mut vorder, mut vfirst, mut vcount) = (Vec::new(), Vec::new(), 0, 0);
let mut explicit_color = None;
let mut mpeg2_header = Vec::new();
let mut dng = None;
if let Some(vi) = vtrack {
let t = &file.tracks[vi];
let p = t.picture.clone().unwrap_or_default();
Expand Down Expand Up @@ -253,11 +269,23 @@ impl MxfSource {
.then(|| file.read_sample(&bytes, vi, 0).ok())
.flatten()
.and_then(|s| Some((filmcraft_mpeg2v::probe(&s)?, filmcraft_mpeg2v::scan_access_unit(&s), crate::video::mpeg2_sequence_header(&s)?)));
let (w, h) = match &mpeg {
Some((info, _, _)) => (info.width, info.height),
None => (p.frame_width(), p.frame_height()),
// CinemaDNG: the first DNG's developed size and format (the RGBA descriptor describes
// the decoded picture, but nothing checks it does)
let dng_probe = (t.codec == Codec::CinemaDng).then(|| {
file.read_sample(&bytes, vi, 0).map_err(|e| filmcraft_media::MediaError::Decode(e.to_string())).and_then(|s| {
let (w, h, pf, _) = filmcraft_media::still::dng::probe(name, &s)?;
Ok((w, h, pf, filmcraft_media::still::dng::as_shot(&s)))
})
});
let (w, h) = match (&mpeg, &dng_probe) {
(Some((info, _, _)), _) => (info.width, info.height),
(None, Some(Ok((w, h, _, _)))) => (*w, *h),
_ => (p.frame_width(), p.frame_height()),
};
let par = if p.aspect_ratio.is_valid() && w > 0 && h > 0 {
let par = if dng_probe.is_some() {
// DNG DefaultScale is not applied: pixels are shown square
(1, 1)
} else if p.aspect_ratio.is_valid() && w > 0 && h > 0 {
let n = p.aspect_ratio.num as u64 * h as u64;
let d = p.aspect_ratio.den as u64 * w as u64;
let g = gcd(n, d).max(1);
Expand All @@ -275,6 +303,17 @@ impl MxfSource {
Codec::ProRes { profile } => prores_name(profile).into(),
c @ Codec::Avc { .. } => c.name().into(),
Codec::Mpeg2 if mpeg.is_some() => mpeg.as_ref().map(|m| m.0.codec_name()).unwrap_or_default(),
Codec::CinemaDng => match &dng_probe {
Some(Ok((_, _, _, as_shot))) => {
dng = Some(DngPictures { cache: filmcraft_media::FrameCache::new(256 << 20), settings: Default::default(), as_shot: *as_shot });
filmcraft_media::still::dng::MXF_CODEC.into()
}
Some(Err(e)) => {
unsupported = Some(format!("CinemaDNG in MXF: {e}"));
"CinemaDNG (unsupported)".into()
}
None => "CinemaDNG".into(),
},
other => {
let why = format!("{} video in MXF (FilmCraft has no {} decoder)", other.name(), other.name());
unsupported = Some(why);
Expand All @@ -301,8 +340,13 @@ impl MxfSource {
pixel_format = crate::video::mpeg2_pixel_format(info, fo);
mpeg2_header = header.clone();
}
if let Some(Ok((_, _, pf, _))) = &dng_probe {
pixel_format = pf.clone();
}
let mut color = filmcraft_color::ColorInfo { matrix: filmcraft_frame::default_matrix(w, h), ..filmcraft_color::ColorInfo::REC709 };
if p.full_range() {
if dng.is_some() {
color = filmcraft_media::still::dng::LINEAR_709;
} else if p.full_range() {
color.range = filmcraft_color::Range::Full;
explicit_color = Some(color);
}
Expand Down Expand Up @@ -384,12 +428,25 @@ impl MxfSource {
video: GopCache::new(explicit_color),
unsupported,
mpeg2_header,
dng,
atracks: combined,
aframes,
file,
})
}

/// CinemaDNG: develop the picture shown at display position `d` at reduced-resolution `level`.
fn dng_frame(&self, track: usize, dng: &DngPictures, d: i64, level: u8) -> Result<Arc<VideoFrame>, MediaError> {
let stored = usize::try_from(d).ok().and_then(|d| self.vorder.get(d).copied()).ok_or(MediaError::Decode("MXF: picture out of range".into()))?;
if let Some(f) = dng.cache.get(&(stored, level)) {
return Ok(f);
}
let bytes = self.file.read_sample(&self.bytes, track, stored).map_err(|e| MediaError::Decode(e.to_string()))?;
let frame = Arc::new(filmcraft_media::still::dng::decode_with(&self.info.name, &bytes, &dng.settings, level)?.frame);
dng.cache.insert((stored, level), frame.clone());
Ok(frame)
}

/// The demuxed file (tracks, partitions, index, timecode).
pub fn file(&self) -> &MxfFile {
&self.file
Expand Down Expand Up @@ -458,6 +515,9 @@ impl MediaSource for MxfSource {
}
let rate = self.info.frame_rate();
let f = rate.frame_at(req.time.max(Tick::ZERO)).clamp(0, self.vcount - 1);
if let Some(dng) = &self.dng {
return self.dng_frame(ti, dng, self.vfirst + f, filmcraft_media::raw::scale_level(req.scale));
}
let late = filmcraft_media::cancel::catch_up().map(|m| self.vfirst + rate.frame_at(req.time - m));
let frame = self.video.frame_late(&MxfVideo { src: self, track: ti }, self.vfirst + f, late)?;
Ok(frame)
Expand Down Expand Up @@ -511,6 +571,18 @@ impl MediaSource for MxfSource {
}
Ok(out)
}

fn raw_info(&self) -> Option<filmcraft_media::raw::RawInfo> {
self.dng.as_ref().map(|d| filmcraft_media::raw::RawInfo { as_shot: d.as_shot, settings: d.settings })
}

fn with_raw_settings(&self, settings: &filmcraft_media::raw::RawSettings) -> Option<SharedSource> {
self.dng.as_ref()?;
// a fresh source over the same reader (the index and metadata are read again)
let mut s = MxfSource::open_reader(&self.info.name, self.bytes.0.clone()).ok()?;
s.dng.as_mut()?.settings = *settings;
Some(Arc::new(s))
}
}

pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option<Result<SharedSource, MediaError>> {
Expand Down
182 changes: 182 additions & 0 deletions crates/codecs/tests/cinemadng_mxf.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
//! CinemaDNG in MXF (CinemaDNG Image Data Format Specification 1.0.0.0, §7): synthetic OP1a
//! files from our MXF writer, wrapping DNG frames from the `filmcraft-dng` test writer, one
//! frame-wrapped DNG dataset per edit unit with PCM sound and a start timecode. No tool we can
//! run reads CinemaDNG MXF, so the expected values are the DNGs' own (known) contents.

use std::io::Cursor;
use std::sync::Arc;

use filmcraft_codecs::MxfSource;
use filmcraft_color::Transfer;
use filmcraft_dng::tiff::tags;
use filmcraft_dng::writer::{Compression, Options, Value, write};
use filmcraft_frame::{PixelData, VideoFrame};
use filmcraft_media::{FrameRequest, MediaKind, MediaSource};
use filmcraft_mxf::{FrameInfo, MxfWriter, PackageIds, Pattern, PictureCoding, PictureDesc, Rational, SoundDesc, StartTimecode, WriterConfig};
use filmcraft_time::FrameRate;

const W: usize = 16;
const H: usize = 12;

/// A 16 × 12 DNG of a flat grey at `level` of the 12-bit range: RGGB lossless JPEG white balanced
/// by neutral (0.5, 1, 0.5), or LinearRaw (three samples per pixel, uncompressed).
fn grey_dng(level: f64, linear_raw: bool) -> Vec<u8> {
let neutral = [0.5, 1.0, 0.5];
let mut o = Options {
width: W,
height: H,
bits: 12,
raw_tags: vec![(tags::WHITE_LEVEL, Value::Short(vec![4095; if linear_raw { 3 } else { 1 }]))],
main_tags: vec![(tags::AS_SHOT_NEUTRAL, Value::rationals(&neutral))],
..Default::default()
};
let px: Vec<u16> = if linear_raw {
o.samples_per_pixel = 3;
o.cfa = None;
(0..W * H * 3).map(|i| (level * neutral[i % 3] * 4095.0).round() as u16).collect()
} else {
o.compression = Compression::LosslessJpeg { predictor: 1, components: 2, restart_lines: 0, pt: 0 };
let pat = [0usize, 1, 1, 2];
(0..W * H).map(|i| (level * neutral[pat[(i / W % 2) * 2 + i % W % 2]] * 4095.0).round() as u16).collect()
};
write(&px, &o)
}

fn level(i: usize) -> f64 {
0.1 + 0.15 * i as f64
}

/// An OP1a CinemaDNG file: `n` frames at 24 fps, stereo 16-bit sound (sample k of channel 0 = k,
/// channel 1 = −k), start timecode 10:00:00:00.
fn cinemadng_mxf(n: usize, linear_raw: bool) -> Vec<u8> {
let rate = Rational::new(24, 1);
let mut cfg = WriterConfig::new(Pattern::Op1a, rate, PackageIds::from_seed("cinemadng test", "A001_C001"));
cfg.picture = Some(PictureDesc::new(PictureCoding::CinemaDng { linear_raw }, W as u32, H as u32));
cfg.sound = vec![SoundDesc { sample_rate: 48_000, channels: 2, bits: 16 }];
cfg.timecode = Some(StartTimecode { frames: 10 * 3600 * 24, rate, drop_frame: false });
let mut w = MxfWriter::new(Cursor::new(Vec::new()), cfg).unwrap();
for i in 0..n {
w.push_picture(&grey_dng(level(i), linear_raw), FrameInfo::intra()).unwrap();
let mut pcm = Vec::new();
for k in i * 2000..(i + 1) * 2000 {
pcm.extend_from_slice(&(k as i16).to_le_bytes());
pcm.extend_from_slice(&(-(k as i16)).to_le_bytes());
}
w.push_sound(0, &pcm).unwrap();
}
w.finish().unwrap().into_inner()
}

fn red(f: &VideoFrame) -> f32 {
match &f.data {
PixelData::RgbaF32(p) => p[(5 * W + 5) * 4],
_ => panic!("float frames"),
}
}

#[test]
fn cinemadng_mxf_plays_every_frame() {
for linear_raw in [false, true] {
let f = cinemadng_mxf(5, linear_raw);
let src = MxfSource::open("A001_C001.mxf", f.into()).unwrap();
let info = src.info();
assert_eq!(info.kind, MediaKind::Movie);
assert_eq!(info.container, "MXF OP1a");
let v = info.video.as_ref().unwrap();
assert_eq!((v.width, v.height, v.codec.as_str(), v.has_alpha, v.par), (16, 12, "CinemaDNG", false, (1, 1)));
let pf = if linear_raw { "linear 12-bit raw (uncompressed)" } else { "Bayer 12-bit raw (lossless JPEG)" };
assert_eq!(v.pixel_format, pf);
assert_eq!(v.color.transfer, Transfer::Linear);
assert_eq!(info.frame_rate(), FrameRate::FPS_24);
assert_eq!(info.duration, FrameRate::FPS_24.tick_of(5));
assert_eq!(info.start_timecode, Some(10 * 3600 * 24));
// every frame, in a scrambled order (each is developed on its own)
for i in [3usize, 0, 4, 1, 2, 3] {
let frame = src.video_frame(FrameRequest::full(FrameRate::FPS_24.tick_of(i as i64))).unwrap();
assert_eq!((frame.width, frame.height, frame.color.transfer), (16, 12, Transfer::Linear));
assert!((red(&frame) - level(i) as f32).abs() < 1e-3, "frame {i}: {}", red(&frame));
}
// the sound beside the pictures, sample-exact
let a = src.audio(1234, 3000, 48_000).unwrap();
for (j, k) in (1234..4234).enumerate() {
assert_eq!(a.channels[0][j], k as f32 / 32768.0);
assert_eq!(a.channels[1][j], -(k as f32) / 32768.0);
}
}
}

#[test]
fn cinemadng_mxf_opens_through_the_registered_openers() {
let f: Arc<[u8]> = cinemadng_mxf(2, false).into();
let src = filmcraft_media::open_bytes("clip.mxf", f, &filmcraft_codecs::openers()).unwrap();
assert_eq!(src.info().video.as_ref().unwrap().codec, "CinemaDNG");
assert!(src.video_frame(FrameRequest::full(FrameRate::FPS_24.tick_of(1))).is_ok());
}

#[test]
fn broken_dng_frames_are_errors_not_panics() {
// a picture that is not a DNG: the file opens (sound plays), the picture is unsupported
let rate = Rational::new(24, 1);
let mut cfg = WriterConfig::new(Pattern::Op1a, rate, PackageIds::from_seed("bad", "bad"));
cfg.picture = Some(PictureDesc::new(PictureCoding::CinemaDng { linear_raw: false }, 16, 12));
let mut w = MxfWriter::new(Cursor::new(Vec::new()), cfg).unwrap();
w.push_picture(b"II*\0\x08\0\0\0garbage", FrameInfo::intra()).unwrap();
let f = w.finish().unwrap().into_inner();
let src = MxfSource::open("bad.mxf", f.into()).unwrap();
assert!(src.info().video.as_ref().unwrap().codec.contains("unsupported"));
assert!(src.video_frame(FrameRequest::full(filmcraft_time::Tick::ZERO)).is_err());
// nothing to develop: no raw settings
assert!(src.raw_info().is_none());
assert!(src.with_raw_settings(&filmcraft_media::raw::RawSettings::default()).is_none());
}

#[test]
fn truncated_and_mutated_files_never_panic() {
let f = cinemadng_mxf(3, false);
let try_all = |b: Vec<u8>| {
let r = std::panic::catch_unwind(|| {
if let Ok(src) = MxfSource::open("x.mxf", b.into()) {
for i in 0..4 {
let _ = src.video_frame(FrameRequest::full(FrameRate::FPS_24.tick_of(i)));
}
let _ = src.audio(0, 5000, 48_000);
}
});
assert!(r.is_ok());
};
for n in (0..f.len()).step_by(97) {
try_all(f[..n].to_vec());
}
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
for _ in 0..400 {
let mut b = f.clone();
for _ in 0..4 {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
let at = (seed as usize) % b.len();
b[at] ^= (seed >> 32) as u8 | 1;
}
try_all(b);
}
}

#[test]
fn raw_settings_reach_every_mxf_picture() {
let src = MxfSource::open("A001_C001.mxf", cinemadng_mxf(3, false).into()).unwrap();
let raw = src.raw_info().unwrap();
assert_eq!(raw.settings, filmcraft_media::raw::RawSettings::default());
let bright = src.with_raw_settings(&filmcraft_media::raw::RawSettings { exposure: 1.0, ..Default::default() }).unwrap();
assert_eq!(bright.info(), src.info());
assert_eq!(bright.raw_info().unwrap().settings.exposure, 1.0);
for i in 0..3 {
let t = FrameRate::FPS_24.tick_of(i as i64);
let (a, b) = (red(&src.video_frame(FrameRequest::full(t)).unwrap()), red(&bright.video_frame(FrameRequest::full(t)).unwrap()));
assert!((b - 2.0 * a).abs() < 1e-3 && (a - level(i) as f32).abs() < 1e-3, "frame {i}: {a} {b}");
// a reduced-resolution request: never smaller than asked for, same values
let q = bright.video_frame(FrameRequest { time: t, scale: 0.25 }).unwrap();
assert!(q.width >= 4 && q.height >= 3 && q.width < 16, "{}x{}", q.width, q.height);
}
// the sound is still there
assert!(bright.audio(0, 100, 48_000).is_ok());
}
Loading
Loading