diff --git a/Cargo.lock b/Cargo.lock index ca5d6d1f12..0e4c6a401c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1991,6 +1991,7 @@ dependencies = [ "filmcraft-avi", "filmcraft-bitstream", "filmcraft-color", + "filmcraft-dng", "filmcraft-dnx", "filmcraft-frame", "filmcraft-h264", @@ -2032,6 +2033,8 @@ version = "0.17.0" dependencies = [ "filmcraft-dng", "rayon", + "serde", + "serde_json", "thiserror 2.0.21", ] @@ -2282,6 +2285,7 @@ dependencies = [ "png", "rayon", "serde", + "serde_json", "thiserror 2.0.21", ] @@ -2499,6 +2503,7 @@ dependencies = [ "filmcraft-captions", "filmcraft-codecs", "filmcraft-color", + "filmcraft-dng", "filmcraft-edit", "filmcraft-engine", "filmcraft-export", diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 90b79e2e10..1b9ce18fc3 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -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 } diff --git a/crates/codecs/src/mxf.rs b/crates/codecs/src/mxf.rs index 4039daf40f..c04955e5f9 100644 --- a/crates/codecs/src/mxf.rs +++ b/crates/codecs/src/mxf.rs @@ -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; @@ -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, @@ -53,6 +66,8 @@ pub struct MxfSource { unsupported: Option, /// MPEG-2: the first sequence header (primes decoders that start without one). mpeg2_header: Vec, + /// CinemaDNG: every picture is intra coded and developed on its own, outside the GOP cache. + dng: Option, /// Sound tracks combined into one stream: (track, first stored sample frame). atracks: Vec<(usize, u64)>, /// Presented sample frames. @@ -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(); @@ -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); @@ -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); @@ -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); } @@ -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, 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 @@ -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) @@ -511,6 +571,18 @@ impl MediaSource for MxfSource { } Ok(out) } + + fn raw_info(&self) -> Option { + 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 { + 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> { diff --git a/crates/codecs/tests/cinemadng_mxf.rs b/crates/codecs/tests/cinemadng_mxf.rs new file mode 100644 index 0000000000..577d7d0e2b --- /dev/null +++ b/crates/codecs/tests/cinemadng_mxf.rs @@ -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 { + 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 = 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 { + 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| { + 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()); +} diff --git a/crates/dng/Cargo.toml b/crates/dng/Cargo.toml index 1fce4930ce..9f439da58d 100644 --- a/crates/dng/Cargo.toml +++ b/crates/dng/Cargo.toml @@ -11,13 +11,17 @@ default = ["threads"] threads = ["dep:rayon"] # A small DNG and lossless JPEG writer for synthesising test files (not used by the app). writer = [] +# serde derives on the raw settings (`ProcessOptions`) for UI state and project files. +serde = ["dep:serde"] [dependencies] rayon = { workspace = true, optional = true } +serde = { workspace = true, optional = true } thiserror.workspace = true [dev-dependencies] -filmcraft-dng = { path = ".", features = ["writer"] } +filmcraft-dng = { path = ".", features = ["writer", "serde"] } +serde_json.workspace = true [lints] workspace = true diff --git a/crates/dng/README.md b/crates/dng/README.md index c8678c1d37..488d8254d5 100644 --- a/crates/dng/README.md +++ b/crates/dng/README.md @@ -22,6 +22,7 @@ when it is installed, never linked. | Malvar, He, Cutler, "High-quality linear interpolation for demosaicing of Bayer-patterned color images", IEEE ICASSP | 2004 | the Bayer demosaic filters | | McCamy, "Correlated color temperature as an explicit function of chromaticity coordinates", Color Res. Appl. 17(2) | 1992 | white xy → temperature for the calibration interpolation | | IEC 61966-2-1 (sRGB) | 1999 | Rec. 709 / sRGB primaries and D65 white | +| Krystek, "An algorithm to calculate correlated colour temperature", Color Res. Appl. 10(1) | 1985 | the Planckian locus in CIE 1960 uv for temperature / tint white balance | ## API @@ -33,8 +34,18 @@ if filmcraft_dng::sniff(&bytes) { let img = dng.develop()?; // linear Rec. 709 RGBA f32 (alpha 1), info.width × info.height } let img = filmcraft_dng::decode(&bytes)?; // parse + develop +// raw settings: white balance (as shot or temperature / tint), exposure, highlights, demosaic +// quality, output space, and a target size for a reduced-resolution fast path +let o = filmcraft_dng::ProcessOptions { exposure: 0.5, ..Default::default() }; +let img = filmcraft_dng::decode_with(&bytes, &o)?; ``` +`ProcessOptions::default()` develops exactly like `decode`. `Highlights::Recover` is accepted but +not implemented yet (it develops like `Clip`). `Dng::as_shot_temperature_tint` gives the as-shot +white as kelvin and tint, the starting point of a white-balance control; temperature and tint +are a point on the Planckian locus (Krystek 1985) and a CIE 1960 Δuv (tint 150 = Δuv +0.05, a +greener white, so a more magenta picture; D65 is about tint +10). + `writer` (feature `writer`, tests only) writes synthetic DNGs: uncompressed at any depth or lossless JPEG with any predictor, strips or tiles, either byte order, an optional preview IFD with the raw image in a SubIFD, and arbitrary extra tags. @@ -60,8 +71,21 @@ the raw image in a SubIFD, and arbitrary extra tags. values to XYZ and a Bradford adaptation moves the as-shot white to D50. Decoders in the dcraw tradition (LibRaw included) scale the camera channels instead, so under a white far from D65 their saturated colours differ from ours (greys agree). -- **Speed:** a 4096 × 2160 12-bit frame develops in about 80–100 ms on 16 threads (raw decode - ~15 ms, the rest demosaic and colour). +- **Speed:** a 4096 × 2160 12-bit lossless-JPEG frame decodes and develops about 2–3 times + faster than in the first version (best of 9 on a shared 16-thread machine: tiles 256 × 256 + about 82 ms on 1 thread and 21 ms on 16, one strip 30 ms of serial entropy decoding plus the + processing). Lossless JPEG decodes a code and its difference bits with one 13-bit lookup and + refills its bit buffer eight bytes at a time; tiles decode in parallel straight into place (a + single strip is used without a copy); without black-level deltas a lookup table per CFA / + black-level site replaces the normalisation arithmetic; demosaic, crop and colour run row by + row without intermediate pictures. Output is bit-identical to the first version + (`tests/it/equivalence.rs` compares f32 bit patterns with the kept `reference` module). +- **Reduced resolution:** with `ProcessOptions::target_size`, a Bayer, LinearRaw or monochrome + picture develops at 1/2, 1/4 or 1/8 size (the smallest that still covers the target) by + averaging each colour over 2 × 2 (4 × 4, 8 × 8) blocks of the crop, without a demosaic: about + three times less processing at half size. +- `cargo run --release -p filmcraft-dng --example bench` times the current pipeline against the + reference at 1, 4 and 16 threads, interleaved in one run. ## Not supported (errors name them) diff --git a/crates/dng/examples/bench.rs b/crates/dng/examples/bench.rs new file mode 100644 index 0000000000..c994a99ec1 --- /dev/null +++ b/crates/dng/examples/bench.rs @@ -0,0 +1,102 @@ +//! Decode + develop speed of a synthetic 4096 × 2160 12-bit lossless-JPEG CinemaDNG frame, the +//! current pipeline against the reference one of PR #1068, interleaved in one run at 1, 4 and 16 +//! threads (rayon pools). `cargo run --release -p filmcraft-dng --features writer --example bench` +//! (`-- --reps N`, `-- --quick` for a 1920 × 1080 frame, `-- --target 1920x1080` for the reduced +//! path). Times are the best of the repetitions, in ms. + +use std::time::Instant; + +use filmcraft_dng::tiff::tags; +use filmcraft_dng::writer::{Compression, Layout, Options, Value, write}; +use filmcraft_dng::{Dng, ProcessOptions}; + +fn frame(w: usize, h: usize, layout: Layout) -> Vec { + // a smooth scene with sensor-like noise, black 256, white 4095 + let mut s = 0x1234_5678u32; + let px: Vec = (0..w * h) + .map(|i| { + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + let (x, y) = ((i % w) as f32 / w as f32, (i / w) as f32 / h as f32); + let c = (i % w) % 2 + (i / w) % 2; + let v = 0.15 + 0.6 * (x * 3.0 + y * 2.0 + c as f32).sin().abs() * y; + (256.0 + v * 3500.0 + (s % 48) as f32).min(4095.0) as u16 + }) + .collect(); + let cm = [0.9f64, -0.3, -0.05, -0.4, 1.2, 0.2, -0.05, 0.15, 0.6]; + let o = Options { + width: w, + height: h, + bits: 12, + layout, + compression: Compression::LosslessJpeg { predictor: 1, components: 2, restart_lines: 0, pt: 0 }, + main_tags: vec![ + (tags::COLOR_MATRIX_1, Value::srationals(&cm)), + (tags::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21])), + (tags::AS_SHOT_NEUTRAL, Value::rationals(&[0.55, 1.0, 0.7])), + (tags::FRAME_RATE, Value::SRational(vec![(24, 1)])), + ], + raw_tags: vec![(tags::BLACK_LEVEL, Value::Short(vec![256])), (tags::WHITE_LEVEL, Value::Short(vec![4095]))], + ..Default::default() + }; + write(&px, &o) +} + +/// Best of the repetitions: the least disturbed by other work on a shared machine. +fn best(v: Vec) -> f64 { + v.into_iter().fold(f64::INFINITY, f64::min) +} + +fn ms(t: Instant) -> f64 { + t.elapsed().as_secs_f64() * 1000.0 +} + +fn main() { + let args: Vec = std::env::args().collect(); + let quick = args.iter().any(|a| a == "--quick"); + // profiling: skip the reference + let only_new = args.iter().any(|a| a == "--only-new"); + let reps: usize = args.iter().position(|a| a == "--reps").and_then(|i| args.get(i + 1)).and_then(|v| v.parse().ok()).unwrap_or(7); + let (w, h) = if quick { (1920, 1080) } else { (4096, 2160) }; + let target = args.iter().position(|a| a == "--target").and_then(|i| args.get(i + 1)).and_then(|v| { + let (a, b) = v.split_once('x')?; + Some((a.parse().ok()?, b.parse().ok()?)) + }); + let opts = ProcessOptions { target_size: target, ..Default::default() }; + for (name, layout) in [("tiles 256x256", Layout::Tiles { width: 256, height: 256 }), ("one strip", Layout::Strips { rows: h })] { + let f = frame(w, h, layout); + println!("\n{w}x{h} 12-bit LJ92, {name}, {} MB, options {opts:?}", f.len() / 1_000_000); + println!("loadavg: {}", std::fs::read_to_string("/proc/loadavg").unwrap_or_default().trim()); + println!( + "{:>7} | {:>9} {:>9} {:>9} | {:>9} {:>9} {:>9} | {:>7}", + "threads", "ref raw", "ref dev", "ref all", "new raw", "new dev", "new all", "speedup" + ); + for threads in [1, 4, 16] { + let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap(); + let (mut rr, mut ra, mut nr, mut na) = (vec![], vec![], vec![], vec![]); + pool.install(|| { + for _ in 0..reps { + let d = Dng::parse(&f).unwrap(); + if !only_new { + let t = Instant::now(); + std::hint::black_box(d.reference_raw().unwrap()); + rr.push(ms(t)); + let t = Instant::now(); + std::hint::black_box(d.reference_develop(&opts).unwrap()); + ra.push(ms(t)); + } + let t = Instant::now(); + std::hint::black_box(d.raw().unwrap()); + nr.push(ms(t)); + let t = Instant::now(); + std::hint::black_box(d.develop_with(&opts).unwrap()); + na.push(ms(t)); + } + }); + let (rr, ra, nr, na) = (best(rr), best(ra), best(nr), best(na)); + println!("{threads:>7} | {rr:>9.1} {:>9.1} {ra:>9.1} | {nr:>9.1} {:>9.1} {na:>9.1} | {:>6.2}x", ra - rr, na - nr, ra / na); + } + println!("loadavg: {}", std::fs::read_to_string("/proc/loadavg").unwrap_or_default().trim()); + } +} diff --git a/crates/dng/src/color.rs b/crates/dng/src/color.rs index fb02f2c8d4..d2b9a6e8a0 100644 --- a/crates/dng/src/color.rs +++ b/crates/dng/src/color.rs @@ -101,6 +101,96 @@ pub fn cct((x, y): (f64, f64)) -> f64 { if t.is_finite() { t.clamp(1_000.0, 50_000.0) } else { 50_000.0 } } +/// Tint units per Δuv, with the sign of the usual raw-converter control ("more tint, more +/// magenta picture"): a tint of +150 is a white 0.05 above the Planckian locus in CIE 1960 uv +/// (a greener light, which the white balance compensates towards magenta), −150 as far below it. +/// D65 reads about +10. +pub const TINT_PER_DUV: f64 = 3000.0; + +/// CIE 1960 uv of a Planckian radiator at `t` kelvin, 1 000–15 000 K (M. Krystek, "An algorithm +/// to calculate correlated colour temperature", Color Research & Application 10(1), 1985: a +/// rational approximation accurate to better than 1e-4 in uv). +pub fn planck_uv(t: f64) -> (f64, f64) { + let t = if t.is_finite() { t.clamp(1_000.0, 15_000.0) } else { 6_500.0 }; + let u = (0.860_117_757 + 1.541_182_54e-4 * t + 1.286_412_12e-7 * t * t) / (1.0 + 8.424_202_35e-4 * t + 7.081_451_63e-7 * t * t); + let v = (0.317_398_726 + 4.228_062_45e-5 * t + 4.204_816_91e-8 * t * t) / (1.0 - 2.897_418_16e-5 * t + 1.614_560_53e-7 * t * t); + (u, v) +} + +/// The unit normal of the Planckian locus at `t` in uv, pointing towards green (larger v). +fn planck_normal(t: f64) -> (f64, f64) { + let t = if t.is_finite() { t.clamp(1_000.0, 15_000.0) } else { 6_500.0 }; + let (a, b) = (planck_uv((t - 1.0).max(1_000.0)), planck_uv((t + 1.0).min(15_000.0))); + let (du, dv) = (b.0 - a.0, b.1 - a.1); + let len = (du * du + dv * dv).sqrt(); + if len < 1e-15 { + return (0.0, 1.0); + } + // perpendicular to the tangent; the locus runs right to left as t grows, so (dv, -du) points up + let n = (-dv / len, du / len); + if n.1 < 0.0 { (-n.0, -n.1) } else { n } +} + +fn uv_to_xy((u, v): (f64, f64)) -> Option<(f64, f64)> { + let d = 2.0 * u - 8.0 * v + 4.0; + (d.abs() > 1e-12).then(|| (3.0 * u / d, 2.0 * v / d)) +} + +fn xy_to_uv((x, y): (f64, f64)) -> Option<(f64, f64)> { + let d = -2.0 * x + 12.0 * y + 3.0; + (d.abs() > 1e-12).then(|| (4.0 * x / d, 6.0 * y / d)) +} + +/// The white chromaticity of a colour temperature (kelvin, 1 000–15 000 on the Planckian locus) +/// and tint ([`TINT_PER_DUV`] units; positive makes the developed picture more magenta, negative +/// greener, ±150 is the useful range). +pub fn temperature_tint_to_xy(temperature: f64, tint: f64) -> Option<(f64, f64)> { + if !temperature.is_finite() || !tint.is_finite() { + return None; + } + let t = temperature.clamp(1_000.0, 15_000.0); + let (u, v) = planck_uv(t); + let (nu, nv) = planck_normal(t); + let duv = tint.clamp(-1_000.0, 1_000.0) / TINT_PER_DUV; + uv_to_xy((u + nu * duv, v + nv * duv)) +} + +/// The colour temperature and tint of a white chromaticity (the inverse of +/// [`temperature_tint_to_xy`]): the nearest point of the Planckian locus in uv, searched in +/// reciprocal temperature. +pub fn xy_to_temperature_tint(xy: (f64, f64)) -> Option<(f64, f64)> { + let (u, v) = xy_to_uv(xy)?; + if !u.is_finite() || !v.is_finite() { + return None; + } + let dist = |mired: f64| { + let (pu, pv) = planck_uv(1e6 / mired); + (u - pu).powi(2) + (v - pv).powi(2) + }; + // coarse scan over 1 000–15 000 K, then golden-section refinement + let (lo_m, hi_m) = (1e6 / 15_000.0, 1e6 / 1_000.0); + let steps = 200; + let mut best = lo_m; + for i in 0..=steps { + let m = lo_m + (hi_m - lo_m) * f64::from(i) / f64::from(steps); + if dist(m) < dist(best) { + best = m; + } + } + let step = (hi_m - lo_m) / f64::from(steps); + let (mut a, mut b) = ((best - step).max(lo_m), (best + step).min(hi_m)); + let g = (5f64.sqrt() - 1.0) / 2.0; + for _ in 0..80 { + let (c, d) = (b - g * (b - a), a + g * (b - a)); + if dist(c) < dist(d) { b = d } else { a = c } + } + let t = 1e6 / ((a + b) / 2.0); + let (pu, pv) = planck_uv(t); + let (nu, nv) = planck_normal(t); + let duv = (u - pu) * nu + (v - pv) * nv; + Some((t, duv * TINT_PER_DUV)) +} + /// Colour temperature of an EXIF LightSource code used as a DNG CalibrationIlluminant. pub fn illuminant_kelvin(code: u64) -> Option { Some(match code { @@ -372,6 +462,32 @@ mod tests { } } + #[test] + fn temperature_and_tint_round_trip() { + // the Planckian locus at 6504 K is within 0.0035 of D65 in uv (D65 sits above it) + let xy = temperature_tint_to_xy(6504.0, 0.0).unwrap(); + assert!((xy.0 - D65.0).abs() < 0.005 && (xy.1 - D65.1).abs() < 0.01, "{xy:?}"); + // illuminant A is a Planckian radiator at 2856 K + let a = temperature_tint_to_xy(2856.0, 0.0).unwrap(); + assert!((a.0 - 0.44757).abs() < 5e-4 && (a.1 - 0.40745).abs() < 5e-4, "{a:?}"); + for t in [2000.0, 3200.0, 5600.0, 9000.0, 14_000.0] { + for tint in [-100.0, 0.0, 37.0, 150.0] { + let (t2, tint2) = xy_to_temperature_tint(temperature_tint_to_xy(t, tint).unwrap()).unwrap(); + assert!((t2 - t).abs() / t < 1e-4, "{t} {tint}: {t2}"); + assert!((tint2 - tint).abs() < 1e-3, "{t} {tint}: {tint2}"); + } + } + // positive tint is a greener white (above the locus), so the picture turns magenta + let m = temperature_tint_to_xy(5000.0, 50.0).unwrap(); + let g = temperature_tint_to_xy(5000.0, -50.0).unwrap(); + assert!(m.1 > g.1); + // D65 sits above the locus: about +10 + let (_, d65_tint) = xy_to_temperature_tint(D65).unwrap(); + assert!((8.0..12.0).contains(&d65_tint), "{d65_tint}"); + assert!(temperature_tint_to_xy(f64::NAN, 0.0).is_none()); + assert!(xy_to_temperature_tint((0.0, 0.0)).is_some_and(|(t, _)| t.is_finite())); + } + #[test] fn hostile_calibrations_are_errors() { let zero = Calibration { color_matrix: [Some([[0.0; 3]; 3]), None], as_shot_neutral: Some([1.0; 3]), ..Default::default() }; diff --git a/crates/dng/src/demosaic.rs b/crates/dng/src/demosaic.rs index 911236929b..e35909b7dc 100644 --- a/crates/dng/src/demosaic.rs +++ b/crates/dng/src/demosaic.rs @@ -13,6 +13,8 @@ #[cfg(feature = "threads")] use rayon::prelude::*; +use crate::options::DemosaicQuality; + /// A CFA pattern: `rows` × `cols` colour indices (0 red, 1 green, 2 blue), origin at the top left /// of the image. #[derive(Clone, Debug, PartialEq, Eq)] @@ -71,12 +73,17 @@ fn padded(plane: &[f32], w: usize, h: usize) -> (Vec, usize) { /// Demosaic `plane` (`w` × `h`, one sample per pixel) into interleaved RGB. pub fn demosaic(plane: &[f32], w: usize, h: usize, pattern: &Pattern) -> Vec { + demosaic_with(plane, w, h, pattern, DemosaicQuality::High) +} + +/// [`demosaic`] at a chosen quality: [`DemosaicQuality::Fast`] is bilinear for every pattern. +pub fn demosaic_with(plane: &[f32], w: usize, h: usize, pattern: &Pattern, quality: DemosaicQuality) -> Vec { let mut out = vec![0f32; w * h * 3]; if w == 0 || h == 0 || plane.len() < w * h { return out; } let (p, pw) = padded(plane, w, h); - let bayer = pattern.is_bayer() && w >= 3 && h >= 3; + let bayer = quality == DemosaicQuality::High && pattern.is_bayer() && w >= 3 && h >= 3; let row = |y: usize, o: &mut [f32]| { if bayer { malvar_row(&p, pw, y, w, pattern, o); @@ -91,6 +98,126 @@ pub fn demosaic(plane: &[f32], w: usize, h: usize, pattern: &Pattern) -> Vec { + rows: [&'a [f32]; 5], + w: usize, + /// Colour of even and odd columns of this row. + colors: [u8; 2], + high: bool, +} + +impl<'a> BayerRow<'a> { + /// Row `y` of the `w` × `h` plane (`w`, `h` ≥ 3 and a 2 × 2 Bayer `pat`). + pub(crate) fn new(plane: &'a [f32], w: usize, h: usize, pat: &Pattern, quality: DemosaicQuality, y: usize) -> BayerRow<'a> { + let row = |dy: isize| { + let sy = mirror(y as isize + dy, h); + plane.get(sy * w..sy * w + w).unwrap_or(&[]) + }; + BayerRow { rows: [row(-2), row(-1), row(0), row(1), row(2)], w, colors: [pat.color(0, y), pat.color(1, y)], high: quality == DemosaicQuality::High } + } + + /// The RGB of columns `x0..x1`, in order, passed to `f`: the interior through 5 × 5 windows + /// sliding along the five rows, the two columns at either edge through [`BayerRow::pixel`]. + #[inline] + pub(crate) fn span(&mut self, x0: usize, x1: usize, mut f: impl FnMut([f32; 3])) { + let (lo, hi) = (2.min(self.w), self.w.saturating_sub(2).max(2)); + let mut rgb = [0f32; 3]; + let mut x = x0; + while x < x1.min(lo) { + self.pixel(x, &mut rgb); + f(rgb); + x += 1; + } + let end = x1.min(hi); + if x < end { + let [r0, r1, r2, r3, r4] = self.rows; + let win = |r: &'a [f32]| r.get(x - 2..end + 2).unwrap_or(&[]).windows(5); + let rows = win(r0).zip(win(r1)).zip(win(r2)).zip(win(r3)).zip(win(r4)); + let (high, colors) = (self.high, self.colors); + for ((((a, b), c), d), e) in rows { + let at = |dx: isize, dy: isize| { + let r = match dy { + -2 => a, + -1 => b, + 0 => c, + 1 => d, + _ => e, + }; + r.get((dx + 2) as usize).copied().unwrap_or(0.0) + }; + let me = colors[x & 1]; + let right_is_red = colors[(x + 1) & 1] == 0; + f(if high { malvar_px(at, me, right_is_red) } else { bilinear_px(at, me, right_is_red) }); + x += 1; + } + } + while x < x1 { + self.pixel(x, &mut rgb); + f(rgb); + x += 1; + } + } + + /// The RGB of column `x`. + #[inline] + pub(crate) fn pixel(&mut self, x: usize, out: &mut [f32; 3]) { + let me = self.colors[x & 1]; + let right_is_red = self.colors[(x + 1) & 1] == 0; + let rows = &self.rows; + if x >= 2 && x + 2 < self.w { + // the 5 × 5 window, bounds checked once per row + let win = rows.map(|r| r.get(x - 2..x + 3).and_then(|s| <[f32; 5]>::try_from(s).ok()).unwrap_or([0.0; 5])); + let at = |dx: isize, dy: isize| win[(dy + 2) as usize][(dx + 2) as usize]; + *out = if self.high { malvar_px(at, me, right_is_red) } else { bilinear_px(at, me, right_is_red) }; + } else { + let w = self.w; + let at = |dx: isize, dy: isize| rows[(dy + 2) as usize].get(mirror(x as isize + dx, w)).copied().unwrap_or(0.0); + *out = if self.high { malvar_px(at, me, right_is_red) } else { bilinear_px(at, me, right_is_red) }; + } + } +} + +/// Malvar–He–Cutler at one pixel of colour `me` (the expressions of [`malvar_row`]). +#[inline(always)] +fn malvar_px(at: impl Fn(isize, isize) -> f32, me: u8, right_is_red: bool) -> [f32; 3] { + let c = at(0, 0); + let diag = at(-1, -1) + at(1, -1) + at(-1, 1) + at(1, 1); + if me == 1 { + let h1 = at(-1, 0) + at(1, 0); + let h2 = at(-2, 0) + at(2, 0); + let v1 = at(0, -1) + at(0, 1); + let v2 = at(0, -2) + at(0, 2); + let along_row = (5.0 * c + 4.0 * h1 - h2 - diag + 0.5 * v2) / 8.0; + let along_col = (5.0 * c + 4.0 * v1 - v2 - diag + 0.5 * h2) / 8.0; + if right_is_red { [along_row, c, along_col] } else { [along_col, c, along_row] } + } else { + let cross1 = at(-1, 0) + at(1, 0) + at(0, -1) + at(0, 1); + let cross2 = at(-2, 0) + at(2, 0) + at(0, -2) + at(0, 2); + let g = (4.0 * c + 2.0 * cross1 - cross2) / 8.0; + let other = (6.0 * c + 2.0 * diag - 1.5 * cross2) / 8.0; + if me == 0 { [c, g, other] } else { [other, g, c] } + } +} + +/// Bilinear at one Bayer pixel: the sums of [`bilinear_row`]'s 3 × 3 window in its order. +#[inline(always)] +fn bilinear_px(at: impl Fn(isize, isize) -> f32, me: u8, right_is_red: bool) -> [f32; 3] { + let c = at(0, 0); + if me == 1 { + let along_row = (0.0 + at(-1, 0) + at(1, 0)) / 2.0; + let along_col = (0.0 + at(0, -1) + at(0, 1)) / 2.0; + if right_is_red { [along_row, c, along_col] } else { [along_col, c, along_row] } + } else { + let g = (0.0 + at(0, -1) + at(-1, 0) + at(1, 0) + at(0, 1)) / 4.0; + let other = (0.0 + at(-1, -1) + at(1, -1) + at(-1, 1) + at(1, 1)) / 4.0; + if me == 0 { [c, g, other] } else { [other, g, c] } + } +} + fn malvar_row(p: &[f32], pw: usize, y: usize, w: usize, pat: &Pattern, out: &mut [f32]) { let py = y + PAD; for x in 0..w { diff --git a/crates/dng/src/lib.rs b/crates/dng/src/lib.rs index c0b3e9e0ba..1c41fde6c6 100644 --- a/crates/dng/src/lib.rs +++ b/crates/dng/src/lib.rs @@ -17,14 +17,18 @@ pub mod color; pub mod demosaic; pub mod ljpeg; +pub mod options; pub mod process; pub mod raw; +#[cfg(any(test, feature = "writer"))] +pub mod reference; pub mod tiff; #[cfg(any(test, feature = "writer"))] pub mod writer; use color::{Calibration, M3, V3}; use demosaic::Pattern; +pub use options::{DemosaicQuality, Highlights, OutputSpace, ProcessOptions, WhiteBalance}; use process::Params; use raw::{PHOTOMETRIC_CFA, RawImage}; use tiff::{Entry, Ifd, Tiff, tags}; @@ -329,12 +333,37 @@ impl<'a> Dng<'a> { raw::read(&self.tiff, self.raw_ifd()) } - /// Develop the main image to scene-linear Rec. 709. + /// The as-shot white balance as (temperature in kelvin, tint), for a settings panel to start + /// from ([`WhiteBalance::Custom`] with these values develops like [`WhiteBalance::AsShot`]). + /// `None` for monochrome files and files without a ColorMatrix. + pub fn as_shot_temperature_tint(&self) -> Result> { + let calibration = self.calibration()?; + if calibration.color_matrix.iter().all(Option::is_none) || self.cfa()?.is_none() && raw::layout(&self.tiff, self.raw_ifd())?.samples_per_pixel == 1 { + return Ok(None); + } + Ok(color::xy_to_temperature_tint(calibration.solve()?.white_xy)) + } + + /// Develop the main image to scene-linear Rec. 709 as the file says + /// ([`ProcessOptions::default`]). pub fn develop(&self) -> Result { + self.develop_with(&ProcessOptions::default()) + } + + /// Develop the main image with raw settings. + pub fn develop_with(&self, o: &ProcessOptions) -> Result { let info = self.info()?; let p = self.params()?; let raw = self.raw()?; - let calibration = self.calibration()?; + let mut calibration = self.calibration()?; + calibration.baseline_exposure += o.exposure_stops(); + if let WhiteBalance::Custom { temperature, tint } = o.white_balance + && calibration.color_matrix.iter().any(Option::is_some) + { + let xy = color::temperature_tint_to_xy(temperature, tint).ok_or(Error::Unsupported("a non-finite white balance".into()))?; + calibration.as_shot_neutral = None; + calibration.as_shot_white_xy = Some(xy); + } let solved = if p.cfa.is_none() && raw.samples_per_pixel == 1 { // monochrome: no colour, only the exposure let e = 2f64.powf(calibration.baseline_exposure.clamp(-16.0, 16.0)); @@ -342,7 +371,7 @@ impl<'a> Dng<'a> { } else { calibration.solve()? }; - let d = process::develop(&raw, &p, &solved.neutral, &solved.matrix)?; + let d = process::develop_with(&raw, &p, &solved.neutral, &solved.matrix, o)?; Ok(Image { info, width: d.width, height: d.height, rgba: d.rgba }) } } @@ -352,6 +381,11 @@ pub fn decode(bytes: &[u8]) -> Result { Dng::parse(bytes)?.develop() } +/// Parse and develop a DNG file with raw settings. +pub fn decode_with(bytes: &[u8], o: &ProcessOptions) -> Result { + Dng::parse(bytes)?.develop_with(o) +} + /// Parse a DNG file's metadata. pub fn probe(bytes: &[u8]) -> Result { Dng::parse(bytes)?.info() diff --git a/crates/dng/src/ljpeg.rs b/crates/dng/src/ljpeg.rs index 15e39fa827..25cf4da8fd 100644 --- a/crates/dng/src/ljpeg.rs +++ b/crates/dng/src/ljpeg.rs @@ -27,6 +27,10 @@ pub struct Decoded { } const LOOKUP_BITS: u32 = 9; +/// Bits of the combined code + difference lookup. +const FULL_BITS: u32 = 13; +/// A combined lookup entry: bit 8 set, the bits used in 0–7, the difference in 16–31. +const FULL_VALID: u32 = 0x100; /// A Huffman table for decoding (T.81 Annex C and F.2.2.3). #[derive(Clone)] @@ -38,6 +42,10 @@ struct Huffman { valptr: [i32; 17], mincode: [i32; 17], values: Vec, + /// For a code and its extra bits that fit in FULL_BITS together (difference categories + /// 0–15): `FULL_VALID | used bits | difference << 16`, indexed by the next FULL_BITS bits; + /// 0 means "decode the slow way". + full: Vec, } impl Huffman { @@ -96,7 +104,28 @@ impl Huffman { } } } - Ok(Huffman { fast, maxcode, valptr, mincode, values }) + let mut full = vec![0u32; 1 << FULL_BITS]; + for (&c, (&s, &ssss)) in codes.iter().zip(sizes.iter().zip(&values)) { + let (s, ssss) = (u32::from(s), u32::from(ssss)); + if ssss >= 16 || s + ssss > FULL_BITS { + continue; + } + let total = s + ssss; + for v in 0..1u32 << ssss { + let diff = match ssss { + 0 => 0, + k if v < 1 << (k - 1) => v as i32 - (1 << k) + 1, + _ => v as i32, + }; + let entry = FULL_VALID | total | (u32::from(diff as i16 as u16) << 16); + let shift = FULL_BITS - total; + let base = (((c << ssss) | v) << shift) as usize; + for f in full.iter_mut().skip(base).take(1 << shift) { + *f = entry; + } + } + } + Ok(Huffman { fast, maxcode, valptr, mincode, values, full }) } } @@ -116,6 +145,23 @@ impl<'a> Bits<'a> { } fn fill(&mut self) { + // fast path: eight bytes without a 0xFF (no stuffing, no marker) go in at once + if self.marker.is_none() + && let Some(b) = self.data.get(self.pos..self.pos + 8) + { + let chunk = u64::from_be_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]); + let inv = !chunk; + let has_ff = inv.wrapping_sub(0x0101_0101_0101_0101) & !inv & 0x8080_8080_8080_8080 != 0; + if !has_ff { + let take = (64 - self.n) / 8; + if take > 0 { + self.acc |= (chunk >> (64 - 8 * take)) << (64 - self.n - 8 * take); + self.pos += take as usize; + self.n += 8 * take; + return; + } + } + } while self.n <= 56 { let mut byte = 0u8; if self.marker.is_none() { @@ -342,10 +388,16 @@ struct ScanParams { restart: usize, } -/// Read one difference value: a Huffman-coded category followed by its extra bits. -fn read_diff(bits: &mut Bits, h: &Huffman) -> Result { - let ssss = u32::from(bits.decode(h)?); - Ok(match ssss { +/// The next difference (F.2.2.1 and H.1.2.2): category and extra bits from the combined table +/// when they fit, else symbol by symbol. +#[inline(always)] +fn difference(bits: &mut Bits, h: &Huffman) -> Result { + let e = h.full.get(bits.peek(FULL_BITS) as usize).copied().unwrap_or(0); + if e & FULL_VALID != 0 { + bits.skip(e & 0xFF); + return Ok(i32::from((e >> 16) as u16 as i16)); + } + Ok(match u32::from(bits.decode(h)?) { 0 => 0, 16 => 32768, s => { @@ -373,26 +425,17 @@ fn scan(data: &[u8], start: usize, hufs: &[&Huffman], params: &ScanParams) -> Re bits.restart()?; line_in_interval = 0; } - let first_line = line_in_interval == 0; - let (above, cur) = if y == 0 { - (None, &mut out[..row_len]) - } else { - let (a, c) = out.split_at_mut(y * row_len); - (Some(&a[(y - 1) * row_len..]), &mut c[..row_len]) - }; - for x in 0..width { - for (c, h) in hufs.iter().enumerate() { - let i = x * nc + c; - let diff = read_diff(&mut bits, h)?; - let px = match above { - _ if first_line && x == 0 => initial, - _ if first_line => i32::from(cur[i - nc]), - Some(a) if x == 0 => i32::from(a[i]), - Some(a) => predict(predictor, i32::from(cur[i - nc]), i32::from(a[i]), i32::from(a[i - nc])), - None => initial, - }; - cur[i] = ((px + diff) & 0xFFFF) as u16; - } + let (above, cur) = out.split_at_mut(y * row_len); + let cur = cur.get_mut(..row_len).ok_or(Error::TooLarge)?; + let above = if line_in_interval == 0 { None } else { above.get((y - 1) * row_len..) }; + match predictor { + 1 => line::<1>(&mut bits, hufs, cur, above, initial)?, + 2 => line::<2>(&mut bits, hufs, cur, above, initial)?, + 3 => line::<3>(&mut bits, hufs, cur, above, initial)?, + 4 => line::<4>(&mut bits, hufs, cur, above, initial)?, + 5 => line::<5>(&mut bits, hufs, cur, above, initial)?, + 6 => line::<6>(&mut bits, hufs, cur, above, initial)?, + _ => line::<7>(&mut bits, hufs, cur, above, initial)?, } line_in_interval += 1; } @@ -404,7 +447,52 @@ fn scan(data: &[u8], start: usize, hufs: &[&Huffman], params: &ScanParams) -> Re Ok(out) } +/// One line of samples (H.1.2.1): the first line of a restart interval (`above` is `None`) +/// predicts from the left with `initial` for its first sample; other lines predict their first +/// sample from above and the rest with predictor `P`. +#[inline(always)] +fn line(bits: &mut Bits, hufs: &[&Huffman], cur: &mut [u16], above: Option<&[u16]>, initial: i32) -> Result<()> { + let nc = hufs.len(); + let row_len = cur.len(); + let above = match above { + Some(a) => Some(a.get(..row_len).ok_or(Error::TooLarge)?), + None => None, + }; + for (c, h) in hufs.iter().enumerate() { + let d = difference(bits, h)?; + let px = match above { + Some(a) => i32::from(a[c]), + None => initial, + }; + cur[c] = ((px + d) & 0xFFFF) as u16; + } + let mut i = nc; + match above { + None => { + while i < row_len { + for h in hufs { + let d = difference(bits, h)?; + cur[i] = ((i32::from(cur[i - nc]) + d) & 0xFFFF) as u16; + i += 1; + } + } + } + Some(a) => { + while i < row_len { + for h in hufs { + let d = difference(bits, h)?; + let px = predict(P, i32::from(cur[i - nc]), i32::from(a[i]), i32::from(a[i - nc])); + cur[i] = ((px + d) & 0xFFFF) as u16; + i += 1; + } + } + } + } + Ok(()) +} + /// Table H.1 predictors (`ra` left, `rb` above, `rc` above left). +#[inline(always)] pub fn predict(p: u8, ra: i32, rb: i32, rc: i32) -> i32 { match p { 1 => ra, diff --git a/crates/dng/src/options.rs b/crates/dng/src/options.rs new file mode 100644 index 0000000000..6e719af032 --- /dev/null +++ b/crates/dng/src/options.rs @@ -0,0 +1,80 @@ +//! Raw development settings ([`ProcessOptions`]): what a "Raw settings" panel can change about how +//! a DNG is developed. `ProcessOptions::default()` develops exactly as the camera file says (the +//! as-shot white balance, no exposure change, highlights clipped, the high-quality demosaic, +//! scene-linear Rec. 709, full resolution). Feature `serde` derives Serialize / Deserialize (missing +//! fields take their defaults). + +/// White balance. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum WhiteBalance { + /// The camera's AsShotNeutral / AsShotWhiteXY (D65 without either). + #[default] + AsShot, + /// A white of `temperature` kelvin (1 000–15 000, on the Planckian locus) and `tint` + /// (positive makes the picture more magenta, negative greener, as in common raw converters; + /// ±150 is the useful range, D65 is about +10, see [`crate::color::TINT_PER_DUV`]). Needs the file's ColorMatrix; a DNG without one has no + /// colour calibration to place the white in and keeps its as-shot balance. + Custom { temperature: f64, tint: f64 }, +} + +/// What happens to values the sensor clipped. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Highlights { + /// White-balanced samples are clipped at the sensor's white, so clipped highlights stay + /// neutral (no magenta or cyan casts). + #[default] + Clip, + /// Reconstruct clipped channels from the unclipped ones. **Not implemented yet: develops + /// exactly like [`Highlights::Clip`]**, so the setting can already be stored and offered. + Recover, +} + +/// Demosaic quality (CFA images only). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum DemosaicQuality { + /// Bilinear interpolation: softer, a little faster (playback, scrubbing). + Fast, + /// Malvar–He–Cutler gradient-corrected interpolation for 2 × 2 Bayer patterns. + #[default] + High, +} + +/// The colour space of developed pictures. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum OutputSpace { + /// Scene-linear light with Rec. 709 / sRGB primaries and a D65 white; a white surface the + /// sensor saw at its clipping level is 1.0 × 2^(BaselineExposure + exposure). + #[default] + SceneLinearRec709, +} + +/// How to develop a DNG ([`crate::Dng::develop_with`]). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(default))] +pub struct ProcessOptions { + pub white_balance: WhiteBalance, + /// Exposure change in stops, added to the file's BaselineExposure (the sum is limited to + /// ±16 stops; a non-finite value counts as 0). + pub exposure: f64, + pub highlights: Highlights, + pub demosaic: DemosaicQuality, + pub output: OutputSpace, + /// The smallest picture (width, height) the caller needs, e.g. a monitor or a proxy size. + /// `None` develops at full resolution. With a size, the decoder may develop a smaller + /// picture (half resolution when both dimensions still cover the target) that is never + /// smaller than the target; [`crate::Image`]'s `width` and `height` give the size made, + /// [`crate::Info`] still the full one. + pub target_size: Option<(usize, usize)>, +} + +impl ProcessOptions { + /// The exposure change in stops (0 for a non-finite value). + pub fn exposure_stops(&self) -> f64 { + if self.exposure.is_finite() { self.exposure } else { 0.0 } + } +} diff --git a/crates/dng/src/process.rs b/crates/dng/src/process.rs index 41d9b25aed..f7a6fb8468 100644 --- a/crates/dng/src/process.rs +++ b/crates/dng/src/process.rs @@ -18,7 +18,8 @@ use rayon::prelude::*; use crate::color::{M3, V3}; -use crate::demosaic::{Pattern, demosaic}; +use crate::demosaic::Pattern; +use crate::options::ProcessOptions; use crate::raw::RawImage; use crate::{Error, Result}; @@ -70,8 +71,69 @@ pub fn crop_rect(p: &Params, aw: usize, ah: usize) -> Rect { (x, y, w, h) } -/// Normalised, white-balanced samples of the active area (steps 1–4), one plane per sample. -fn normalise(raw: &RawImage, p: &Params, (ax, ay, aw, ah): Rect, neutral: &V3, planes: usize) -> Result> { +/// Rows-parallel map over `out` in rows of `row_len` values (sequential without `threads`). +fn for_rows(out: &mut [f32], row_len: usize, f: impl Fn(usize, &mut [f32]) + Sync + Send) { + let row_len = row_len.max(1); + #[cfg(feature = "threads")] + out.par_chunks_mut(row_len).enumerate().for_each(|(y, o)| f(y, o)); + #[cfg(not(feature = "threads"))] + out.chunks_mut(row_len).enumerate().for_each(|(y, o)| f(y, o)); +} + +fn gcd(a: usize, b: usize) -> usize { + if b == 0 { a } else { gcd(b, a % b) } +} + +fn lcm(a: usize, b: usize) -> usize { + a / gcd(a, b).max(1) * b +} + +/// Most values in a normalisation lookup table (sites × codes). +const MAX_LUT: usize = 1 << 21; + +/// Steps 2–4 for one sample: linearisation, black and white levels, and (with `wb`) white +/// balance with the clip at 1. +struct Normaliser<'a> { + spp: usize, + br: usize, + bc: usize, + black: Vec, + white: Vec, + lin: Option<&'a [u16]>, + inv_neutral: Vec, + cfa: Option<&'a Pattern>, + wb: bool, +} + +impl Normaliser<'_> { + #[inline] + fn value(&self, code: u16, x: usize, y: usize, s: usize, dh: f64, dv: f64) -> f32 { + let v = match self.lin { + Some(t) => f64::from(t.get(usize::from(code)).or(t.last()).copied().unwrap_or(0)), + None => f64::from(code), + }; + let b = self.black.get(((y % self.br) * self.bc + x % self.bc) * self.spp + s).copied().unwrap_or(0.0) + dh + dv; + let range = self.white.get(s).copied().unwrap_or(1.0) - b; + let n = if range > 0.0 { ((v - b) / range).clamp(0.0, 1.0) } else { 0.0 }; + if !self.wb { + return n as f32; + } + let plane = match self.cfa { + Some(c) => usize::from(c.color(x, y)), + None => s, + }; + let wb = self.inv_neutral.get(plane).copied().unwrap_or(1.0); + (n as f32 * wb).min(1.0) + } +} + +/// Normalised samples of the active area (steps 1–4), one plane per sample; white balanced and +/// clipped at 1 when `wb` (else only clipped to 0–1, for OpcodeList2). +/// +/// Without black-level deltas every sample is a function of its code and its position in the +/// black-level and CFA repeat, so a lookup table per site replaces the arithmetic (same values: +/// each entry is computed by the same expression). +fn normalise_with(raw: &RawImage, p: &Params, (ax, ay, aw, ah): Rect, neutral: &V3, planes: usize, wb: bool) -> Result> { let spp = raw.samples_per_pixel; let (br, bc) = (p.black_repeat.0.max(1), p.black_repeat.1.max(1)); if br > 64 || bc > 64 { @@ -84,44 +146,73 @@ fn normalise(raw: &RawImage, p: &Params, (ax, ay, aw, ah): Rect, neutral: &V3, p n if n >= need => p.black[..need].to_vec(), _ => return Err(Error::Malformed("BlackLevel has too few values")), }; - let max_code = ((1u32 << raw.bits) - 1) as f64; + let max_code = ((1u32 << raw.bits.min(16)) - 1) as f64; let white: Vec = (0..spp).map(|s| p.white.get(s).or(p.white.first()).copied().unwrap_or(max_code)).collect(); let lin = p.linearization.as_deref().filter(|t| !t.is_empty()); let inv_neutral: Vec = (0..planes).map(|c| (1.0 / neutral.get(c).copied().unwrap_or(1.0)) as f32).collect(); let cfa = p.cfa.as_ref(); + let n = Normaliser { spp, br, bc, black, white, lin, inv_neutral, cfa, wb }; + let row_len = aw * spp; + let src = |y: usize| raw.data.get((ay + y) * raw.width * spp + ax * spp..).unwrap_or(&[]); let mut out = vec![0f32; aw * ah * spp]; - let row = |y: usize, o: &mut [f32]| { - let dv = p.black_delta_v.get(y).copied().unwrap_or(0.0); - let src_row = (ay + y) * raw.width * spp; - for x in 0..aw { - let dh = p.black_delta_h.get(x).copied().unwrap_or(0.0); - for s in 0..spp { - let code = raw.data.get(src_row + (ax + x) * spp + s).copied().unwrap_or(0); - let v = match lin { - Some(t) => f64::from(t.get(usize::from(code)).or(t.last()).copied().unwrap_or(0)), - None => f64::from(code), - }; - let b = black[((y % br) * bc + x % bc) * spp + s] + dh + dv; - let range = white[s] - b; - let n = if range > 0.0 { ((v - b) / range).clamp(0.0, 1.0) } else { 0.0 }; - let plane = match cfa { - Some(c) => usize::from(c.color(x, y)), - None => s, + // the repeat of the black levels and the CFA together + let (rr, rc) = match cfa { + Some(c) => (lcm(br, c.rows.max(1)), lcm(bc, c.cols.max(1))), + None => (br, bc), + }; + let deltas = p.black_delta_h.iter().chain(&p.black_delta_v).any(|&d| d != 0.0); + let lut_len = 1usize << raw.bits.clamp(1, 16); + let sites = rr * rc * spp; + let use_lut = !deltas && sites.saturating_mul(lut_len) <= MAX_LUT && sites * lut_len <= out.len(); + if use_lut { + // lut[((y % rr) * rc + x % rc) * spp + s][code] + let mut lut = vec![0f32; sites * lut_len]; + let fill = |site: usize, t: &mut [f32]| { + let (s, xy) = (site % spp, site / spp); + let (y, x) = (xy / rc, xy % rc); + for (code, v) in t.iter_mut().enumerate() { + *v = n.value(code as u16, x, y, s, 0.0, 0.0); + } + }; + for_rows(&mut lut, lut_len, fill); + let lut = &lut; + for_rows(&mut out, row_len, |y, o| { + let src = src(y); + let row_sites = (y % rr) * rc * spp; + let mut site = row_sites; + let row_end = row_sites + rc * spp; + for (x, (o, &code)) in o.iter_mut().zip(src).enumerate() { + let table = lut.get(site * lut_len..(site + 1) * lut_len).unwrap_or(&[]); + *o = match table.get(usize::from(code)) { + Some(&v) => v, + None => n.value(code, x / spp, y, x % spp, 0.0, 0.0), }; - let wb = inv_neutral.get(plane).copied().unwrap_or(1.0); - if let Some(o) = o.get_mut(x * spp + s) { - *o = (n as f32 * wb).min(1.0); + site += 1; + if site == row_end { + site = row_sites; } } - } - }; - #[cfg(feature = "threads")] - out.par_chunks_mut((aw * spp).max(1)).enumerate().for_each(|(y, o)| row(y, o)); - #[cfg(not(feature = "threads"))] - out.chunks_mut((aw * spp).max(1)).enumerate().for_each(|(y, o)| row(y, o)); + }); + } else { + for_rows(&mut out, row_len, |y, o| { + let src = src(y); + let dv = p.black_delta_v.get(y).copied().unwrap_or(0.0); + for (x, (px, codes)) in o.chunks_exact_mut(spp).zip(src.chunks_exact(spp)).enumerate() { + let dh = p.black_delta_h.get(x).copied().unwrap_or(0.0); + for (s, (o, &code)) in px.iter_mut().zip(codes).enumerate() { + *o = n.value(code, x, y, s, dh, dv); + } + } + }); + } Ok(out) } +/// Normalised, white-balanced samples of the active area (steps 1–4), one plane per sample. +fn normalise(raw: &RawImage, p: &Params, rect: Rect, neutral: &V3, planes: usize) -> Result> { + normalise_with(raw, p, rect, neutral, planes, true) +} + /// A developed picture: premultiplied linear Rec. 709 RGBA floats (alpha 1). pub struct Developed { pub width: usize, @@ -131,6 +222,37 @@ pub struct Developed { /// Develop `raw` (steps 1–7). `neutral` and `matrix` come from [`crate::color::Calibration::solve`]. pub fn develop(raw: &RawImage, p: &Params, neutral: &V3, matrix: &M3) -> Result { + develop_with(raw, p, neutral, matrix, &ProcessOptions::default()) +} + +/// The output pixel of white-balanced camera values `c` (step 7). +#[inline(always)] +fn to_output(m: &[[f32; 3]; 3], mono: bool, c: [f32; 3], d: &mut [f32; 4]) { + let px = if mono { + // grey: the matrix's luminance gain only + let g = c[0] * (m[1][0] + m[1][1] + m[1][2]); + [g, g, g] + } else { + [0, 1, 2].map(|i| m[i][0] * c[0] + m[i][1] * c[1] + m[i][2] * c[2]) + }; + *d = [px[0].max(0.0), px[1].max(0.0), px[2].max(0.0), 1.0]; +} + +/// The reduction factor (1, 2, 4 or 8) for a `cw` × `ch` picture when the caller needs at least +/// `target`: the largest that still covers it. +pub fn reduction(target: Option<(usize, usize)>, cw: usize, ch: usize) -> usize { + let Some((tw, th)) = target else { return 1 }; + let mut f = 1; + while f < 8 && cw / (f * 2) >= tw.max(1) && ch / (f * 2) >= th.max(1) { + f *= 2; + } + f +} + +/// [`develop`] with raw settings. White balance and exposure are already in `neutral` and +/// `matrix` ([`crate::Dng::develop_with`] solves them); this applies the demosaic quality and the +/// reduced-resolution path. +pub fn develop_with(raw: &RawImage, p: &Params, neutral: &V3, matrix: &M3, o: &ProcessOptions) -> Result { let spp = raw.samples_per_pixel; if p.cfa.is_some() && spp != 1 { return Err(Error::Malformed("a CFA image with more than one sample per pixel")); @@ -138,41 +260,135 @@ pub fn develop(raw: &RawImage, p: &Params, neutral: &V3, matrix: &M3) -> Result< if p.cfa.is_none() && !matches!(spp, 1 | 3) { return Err(Error::Unsupported(format!("LinearRaw with {spp} samples per pixel"))); } + if raw.data.len() < raw.width.saturating_mul(raw.height).saturating_mul(spp) { + return Err(Error::Truncated); + } let active = active_rect(p, raw.width, raw.height)?; let (_, _, aw, ah) = active; let planes = if p.cfa.is_some() { 3 } else { spp }; let norm = normalise(raw, p, active, neutral, planes)?; - // three samples per pixel - let rgb = match (&p.cfa, spp) { - (Some(pat), _) => demosaic(&norm, aw, ah, pat), - (None, 3) => norm, - _ => norm.iter().flat_map(|&v| [v, v, v]).collect(), - }; - let (cx, cy, cw, ch) = crop_rect(p, aw, ah); + let crop = crop_rect(p, aw, ah); let m: [[f32; 3]; 3] = matrix.map(|r| r.map(|v| v as f32)); - let mono = p.cfa.is_none() && spp == 1; + let f = reduction(o.target_size, crop.2, crop.3); + if f > 1 && p.cfa.as_ref().is_none_or(Pattern::is_bayer) { + return Ok(binned(&norm, aw, spp, p.cfa.as_ref(), crop, f, &m)); + } + Ok(full(norm, (aw, ah, spp), p.cfa.as_ref(), crop, o, &m)) +} + +/// Steps 5–7 at full resolution: demosaic, crop and colour, row by row without intermediate +/// pictures for 2 × 2 Bayer patterns and LinearRaw. +fn full( + norm: Vec, + (aw, ah, spp): (usize, usize, usize), + cfa: Option<&Pattern>, + (cx, cy, cw, ch): Rect, + o: &ProcessOptions, + m: &[[f32; 3]; 3], +) -> Developed { + let mono = cfa.is_none() && spp == 1; let mut rgba = vec![0f32; cw * ch * 4]; - let row = |y: usize, o: &mut [f32]| { - let src = (cy + y) * aw + cx; - for x in 0..cw { - let Some(c) = rgb.get((src + x) * 3..(src + x) * 3 + 3) else { continue }; - let px = if mono { - // grey: the matrix's luminance gain only - let g = c[0] * (m[1][0] + m[1][1] + m[1][2]); - [g, g, g] - } else { - [0, 1, 2].map(|i| m[i][0] * c[0] + m[i][1] * c[1] + m[i][2] * c[2]) - }; - if let Some(d) = o.get_mut(x * 4..x * 4 + 4) { - d.copy_from_slice(&[px[0].max(0.0), px[1].max(0.0), px[2].max(0.0), 1.0]); + match cfa { + Some(pat) if pat.is_bayer() && aw >= 3 && ah >= 3 => { + let norm = &norm; + for_rows(&mut rgba, cw * 4, |y, out| { + let mut out = out.as_chunks_mut::<4>().0.iter_mut(); + crate::demosaic::BayerRow::new(norm, aw, ah, pat, o.demosaic, cy + y).span(cx, cx + cw, |rgb| { + if let Some(d) = out.next() { + to_output(m, false, rgb, d); + } + }); + }); + } + Some(pat) => { + let rgb = crate::demosaic::demosaic_with(&norm, aw, ah, pat, o.demosaic); + for_rows(&mut rgba, cw * 4, |y, out| { + let src = (cy + y) * aw + cx; + for (x, d) in out.as_chunks_mut::<4>().0.iter_mut().enumerate() { + if let Some(c) = rgb.get((src + x) * 3..(src + x) * 3 + 3) { + to_output(m, false, [c[0], c[1], c[2]], d); + } + } + }); + } + None => { + let norm = &norm; + for_rows(&mut rgba, cw * 4, |y, out| { + let src = (cy + y) * aw + cx; + for (x, d) in out.as_chunks_mut::<4>().0.iter_mut().enumerate() { + let c = if spp == 3 { + let Some(c) = norm.get((src + x) * 3..(src + x) * 3 + 3) else { continue }; + [c[0], c[1], c[2]] + } else { + let Some(&v) = norm.get(src + x) else { continue }; + [v, v, v] + }; + to_output(m, mono, c, d); + } + }); + } + } + Developed { width: cw, height: ch, rgba } +} + +/// Steps 5–7 at 1/`f` resolution: each output pixel averages every colour over an `f` × `f` +/// block of the crop (a 2 × 2 Bayer block has all three; no demosaic needed). +fn binned(norm: &[f32], aw: usize, spp: usize, cfa: Option<&Pattern>, (cx, cy, cw, ch): Rect, f: usize, m: &[[f32; 3]; 3]) -> Developed { + let mono = cfa.is_none() && spp == 1; + let (w, h) = ((cw / f).max(1), (ch / f).max(1)); + let mut rgba = vec![0f32; w * h * 4]; + // the colours of a 2 × 2 Bayer pattern (the only patterns binned), and how many samples of + // each an f × f block holds (f is even, so every block starts on the same phase) + let quad: [u8; 4] = cfa.map(|p| [p.color(0, 0), p.color(1, 0), p.color(0, 1), p.color(1, 1)]).unwrap_or([0; 4]); + let mut n = [0u32; 3]; + for by in 0..f { + for bx in 0..f { + match cfa { + Some(_) => n[usize::from(quad[((cy + by) & 1) * 2 + ((cx + bx) & 1)]).min(2)] += 1, + None => n = n.map(|v| v + 1), } } - }; - #[cfg(feature = "threads")] - rgba.par_chunks_mut(cw * 4).enumerate().for_each(|(y, o)| row(y, o)); - #[cfg(not(feature = "threads"))] - rgba.chunks_mut(cw * 4).enumerate().for_each(|(y, o)| row(y, o)); - Ok(Developed { width: cw, height: ch, rgba }) + } + let inv = n.map(|v| if v > 0 { 1.0 / v as f32 } else { 0.0 }); + for_rows(&mut rgba, w * 4, |y, out| { + let mut sums = vec![[0f32; 3]; w]; + for by in 0..f { + let ay = cy + y * f + by; + let start = (ay * aw + cx) * spp; + let Some(row) = norm.get(start..start + w * f * spp) else { continue }; + match cfa { + Some(_) => { + let even = usize::from(quad[(ay & 1) * 2 + (cx & 1)]).min(2); + let odd = usize::from(quad[(ay & 1) * 2 + ((cx + 1) & 1)]).min(2); + for (s, block) in sums.iter_mut().zip(row.chunks_exact(f)) { + for pair in block.as_chunks::<2>().0 { + s[even] += pair[0]; + s[odd] += pair[1]; + } + } + } + None if spp == 3 => { + for (s, block) in sums.iter_mut().zip(row.chunks_exact(f * 3)) { + for px in block.as_chunks::<3>().0 { + s[0] += px[0]; + s[1] += px[1]; + s[2] += px[2]; + } + } + } + None => { + for (s, block) in sums.iter_mut().zip(row.chunks_exact(f)) { + s[0] += block.iter().sum::(); + } + } + } + } + for (s, d) in sums.iter().zip(out.as_chunks_mut::<4>().0.iter_mut()) { + let c = if mono { [s[0] * inv[0]; 3] } else { [s[0] * inv[0], s[1] * inv[1], s[2] * inv[2]] }; + to_output(m, mono, c, d); + } + }); + Developed { width: w, height: h, rgba } } #[cfg(test)] diff --git a/crates/dng/src/raw.rs b/crates/dng/src/raw.rs index d6bfd6b6ae..331111689d 100644 --- a/crates/dng/src/raw.rs +++ b/crates/dng/src/raw.rs @@ -1,6 +1,9 @@ //! The raw image data of a DNG: finding the main raw IFD, and reading its strips or tiles //! (uncompressed or lossless JPEG) into one array of 16-bit samples. +#[cfg(feature = "threads")] +use rayon::prelude::*; + use crate::tiff::{Ifd, Tiff, tags}; use crate::{Error, Result, ljpeg}; @@ -251,21 +254,48 @@ pub fn read(t: &Tiff, ifd: &Ifd) -> Result { return Err(Error::Truncated); } } - let mut data = vec![0u16; total]; - let row_len = l.width * spp; - for c in &chunks { - // Copy each padded tile immediately. Retaining every decoded tile until assembly lets - // repeated ranges multiply memory beyond the final raw frame's bounded allocation. - let d = decode_chunk(c, &l, t.big_endian)?; - let cw = c.w.min(l.width - c.x); - let ch = c.h.min(l.height - c.y); - for r in 0..ch { - let src = d.get(r * c.w * spp..(r * c.w + cw) * spp).ok_or(Error::Truncated)?; - let at = (c.y + r) * row_len + c.x * spp; - data.get_mut(at..at + cw * spp).ok_or(Error::Truncated)?.copy_from_slice(src); + let raw = |data| RawImage { width: l.width, height: l.height, samples_per_pixel: spp, bits: l.bits, data }; + // one chunk covering the image (a single strip): its samples are the image, no copy + if let [c] = chunks.as_slice() + && (c.x, c.y, c.w) == (0, 0, l.width) + && c.h >= l.height + { + let mut d = decode_chunk(c, &l, t.big_endian)?; + d.truncate(total); + if d.len() == total { + return Ok(raw(d)); } + return Err(Error::Truncated); } - Ok(RawImage { width: l.width, height: l.height, samples_per_pixel: spp, bits: l.bits, data }) + // bands of one row of tiles (or one strip): decoded in parallel, each band copying its own + // chunks into place + let row_len = l.width * spp; + let (Some(first), true) = (chunks.first(), row_len > 0) else { return Err(Error::Malformed("no strips or tiles")) }; + let (band_rows, across) = (first.h.max(1), l.width.div_ceil(first.w.max(1))); + let mut data = vec![0u16; total]; + let band = |k: usize, out: &mut [u16]| -> Result<()> { + let mine = chunks.get(k * across..((k + 1) * across).min(chunks.len())).unwrap_or(&[]); + #[cfg(feature = "threads")] + let decoded: Vec>> = mine.par_iter().map(|c| decode_chunk(c, &l, t.big_endian)).collect(); + #[cfg(not(feature = "threads"))] + let decoded: Vec>> = mine.iter().map(|c| decode_chunk(c, &l, t.big_endian)).collect(); + for (c, d) in mine.iter().zip(decoded) { + let d = d?; + let cw = c.w.min(l.width.saturating_sub(c.x)); + let ch = c.h.min(l.height.saturating_sub(c.y)); + for r in 0..ch { + let src = d.get(r * c.w * spp..(r * c.w + cw) * spp).ok_or(Error::Truncated)?; + let at = r * row_len + c.x * spp; + out.get_mut(at..at + cw * spp).ok_or(Error::Truncated)?.copy_from_slice(src); + } + } + Ok(()) + }; + #[cfg(feature = "threads")] + data.par_chunks_mut(band_rows * row_len).enumerate().try_for_each(|(k, out)| band(k, out))?; + #[cfg(not(feature = "threads"))] + data.chunks_mut(band_rows * row_len).enumerate().try_for_each(|(k, out)| band(k, out))?; + Ok(raw(data)) } #[cfg(test)] diff --git a/crates/dng/src/reference/demosaic.rs b/crates/dng/src/reference/demosaic.rs new file mode 100644 index 0000000000..3ef013b77a --- /dev/null +++ b/crates/dng/src/reference/demosaic.rs @@ -0,0 +1,131 @@ +//! The demosaic of PR #1068, kept unchanged as the reference (tests and benchmarks only). + +#[cfg(feature = "threads")] +use rayon::prelude::*; + +use crate::demosaic::Pattern; +use crate::options::DemosaicQuality; + +const PAD: usize = 2; + +/// Mirror index `i` (which may be up to PAD outside) into 0..n. +fn mirror(i: isize, n: usize) -> usize { + let n = n as isize; + let mut i = i; + if i < 0 { + i = -i; + } + if i >= n { + i = 2 * (n - 1) - i; + } + i.clamp(0, n - 1) as usize +} + +/// The plane extended by PAD mirrored pixels on each side. +fn padded(plane: &[f32], w: usize, h: usize) -> (Vec, usize) { + let pw = w + 2 * PAD; + let ph = h + 2 * PAD; + let mut out = vec![0f32; pw * ph]; + for y in 0..ph { + let sy = mirror(y as isize - PAD as isize, h); + for x in 0..pw { + let sx = mirror(x as isize - PAD as isize, w); + out[y * pw + x] = plane.get(sy * w + sx).copied().unwrap_or(0.0); + } + } + (out, pw) +} + +/// Demosaic `plane` (`w` × `h`, one sample per pixel) into interleaved RGB. +pub fn demosaic(plane: &[f32], w: usize, h: usize, pattern: &Pattern) -> Vec { + demosaic_with(plane, w, h, pattern, DemosaicQuality::High) +} + +/// [`demosaic`] at a chosen quality: [`DemosaicQuality::Fast`] is bilinear for every pattern. +pub fn demosaic_with(plane: &[f32], w: usize, h: usize, pattern: &Pattern, quality: DemosaicQuality) -> Vec { + let mut out = vec![0f32; w * h * 3]; + if w == 0 || h == 0 || plane.len() < w * h { + return out; + } + let (p, pw) = padded(plane, w, h); + let bayer = quality == DemosaicQuality::High && pattern.is_bayer() && w >= 3 && h >= 3; + let row = |y: usize, o: &mut [f32]| { + if bayer { + malvar_row(&p, pw, y, w, pattern, o); + } else { + bilinear_row(&p, pw, y, w, h, pattern, o); + } + }; + #[cfg(feature = "threads")] + out.par_chunks_mut(w * 3).enumerate().for_each(|(y, o)| row(y, o)); + #[cfg(not(feature = "threads"))] + out.chunks_mut(w * 3).enumerate().for_each(|(y, o)| row(y, o)); + out +} + +fn malvar_row(p: &[f32], pw: usize, y: usize, w: usize, pat: &Pattern, out: &mut [f32]) { + let py = y + PAD; + for x in 0..w { + let px = x + PAD; + let at = |dx: isize, dy: isize| p[((py as isize + dy) as usize) * pw + (px as isize + dx) as usize]; + let c = at(0, 0); + let cross1 = at(-1, 0) + at(1, 0) + at(0, -1) + at(0, 1); + let cross2 = at(-2, 0) + at(2, 0) + at(0, -2) + at(0, 2); + let diag = at(-1, -1) + at(1, -1) + at(-1, 1) + at(1, 1); + let me = pat.color(x, y); + let rgb = match me { + 1 => { + let h1 = at(-1, 0) + at(1, 0); + let h2 = at(-2, 0) + at(2, 0); + let v1 = at(0, -1) + at(0, 1); + let v2 = at(0, -2) + at(0, 2); + // colour along the row and along the column + let along_row = (5.0 * c + 4.0 * h1 - h2 - diag + 0.5 * v2) / 8.0; + let along_col = (5.0 * c + 4.0 * v1 - v2 - diag + 0.5 * h2) / 8.0; + if pat.color(x + 1, y) == 0 { [along_row, c, along_col] } else { [along_col, c, along_row] } + } + _ => { + let g = (4.0 * c + 2.0 * cross1 - cross2) / 8.0; + let other = (6.0 * c + 2.0 * diag - 1.5 * cross2) / 8.0; + if me == 0 { [c, g, other] } else { [other, g, c] } + } + }; + if let Some(o) = out.get_mut(x * 3..x * 3 + 3) { + o.copy_from_slice(&rgb); + } + } +} + +fn bilinear_row(p: &[f32], pw: usize, y: usize, w: usize, h: usize, pat: &Pattern, out: &mut [f32]) { + for x in 0..w { + let me = pat.color(x, y); + let mut rgb = [0f32; 3]; + for (k, v) in rgb.iter_mut().enumerate() { + if usize::from(me) == k { + *v = p[(y + PAD) * pw + x + PAD]; + continue; + } + for r in 1..=PAD as isize { + let (mut sum, mut n) = (0f32, 0u32); + for dy in -r..=r { + for dx in -r..=r { + // the colour of a mirrored neighbour is the colour where it was mirrored from + let sx = mirror(x as isize + dx, w); + let sy = mirror(y as isize + dy, h); + if usize::from(pat.color(sx, sy)) == k { + sum += p[(sy + PAD) * pw + sx + PAD]; + n += 1; + } + } + } + if n > 0 { + *v = sum / n as f32; + break; + } + } + } + if let Some(o) = out.get_mut(x * 3..x * 3 + 3) { + o.copy_from_slice(&rgb); + } + } +} diff --git a/crates/dng/src/reference/ljpeg.rs b/crates/dng/src/reference/ljpeg.rs new file mode 100644 index 0000000000..8b3fa935d3 --- /dev/null +++ b/crates/dng/src/reference/ljpeg.rs @@ -0,0 +1,384 @@ +//! The lossless JPEG decoder of PR #1068, kept unchanged as the reference (tests and benchmarks +//! only). + +use crate::{Error, Result}; + +/// A decoded lossless JPEG image. +#[derive(Debug)] +pub struct Decoded { + pub width: usize, + pub height: usize, + pub components: usize, + pub precision: u8, + pub predictor: u8, + /// Samples, interleaved by component, line after line, shifted back up by the point transform. + pub samples: Vec, +} + +const LOOKUP_BITS: u32 = 9; + +/// A Huffman table for decoding (T.81 Annex C and F.2.2.3). +#[derive(Clone)] +struct Huffman { + /// For codes of up to LOOKUP_BITS bits: (length, value) indexed by the next LOOKUP_BITS bits; + /// length 0 means "longer code". + fast: Vec<(u8, u8)>, + maxcode: [i32; 17], + valptr: [i32; 17], + mincode: [i32; 17], + values: Vec, +} + +impl Huffman { + fn new(bits: &[u8; 16], values: Vec) -> Result { + // C.2: code sizes and codes + let mut sizes = Vec::new(); + for (l, &n) in bits.iter().enumerate() { + for _ in 0..n { + sizes.push(l as u8 + 1); + } + } + if sizes.len() != values.len() || sizes.is_empty() { + return Err(Error::Malformed("lossless JPEG: Huffman table counts")); + } + let mut codes = Vec::with_capacity(sizes.len()); + let mut code: u32 = 0; + let mut si = sizes[0]; + let mut k = 0; + while k < sizes.len() { + while k < sizes.len() && sizes[k] == si { + codes.push(code); + code += 1; + k += 1; + } + // the codes of one length must fit in that length + if code > (1u32 << si) { + return Err(Error::Malformed("lossless JPEG: Huffman table over-subscribed")); + } + code <<= 1; + si += 1; + } + // F.2.2.3 decoder tables + let mut maxcode = [-1i32; 17]; + let mut valptr = [0i32; 17]; + let mut mincode = [0i32; 17]; + let mut j = 0usize; + for l in 1..=16usize { + let n = usize::from(bits[l - 1]); + if n == 0 { + maxcode[l] = -1; + } else { + valptr[l] = j as i32; + mincode[l] = codes[j] as i32; + j += n; + maxcode[l] = codes[j - 1] as i32; + } + } + let mut fast = vec![(0u8, 0u8); 1 << LOOKUP_BITS]; + for (i, (&c, &s)) in codes.iter().zip(&sizes).enumerate() { + let s = u32::from(s); + if s <= LOOKUP_BITS { + let shift = LOOKUP_BITS - s; + let base = (c << shift) as usize; + for f in fast.iter_mut().skip(base).take(1 << shift) { + *f = (s as u8, values[i]); + } + } + } + Ok(Huffman { fast, maxcode, valptr, mincode, values }) + } +} + +/// Entropy-coded data reader: removes stuffed zero bytes and stops at markers (feeding zeros). +struct Bits<'a> { + data: &'a [u8], + pos: usize, + acc: u64, + n: u32, + /// The marker that ended the data (after 0xFF), if one was met. + marker: Option, +} + +impl<'a> Bits<'a> { + fn new(data: &'a [u8], pos: usize) -> Self { + Bits { data, pos, acc: 0, n: 0, marker: None } + } + + fn fill(&mut self) { + while self.n <= 56 { + let mut byte = 0u8; + if self.marker.is_none() { + match self.data.get(self.pos) { + Some(&0xFF) => match self.data.get(self.pos + 1) { + Some(&0x00) => { + byte = 0xFF; + self.pos += 2; + } + Some(&m) => self.marker = Some(m), + None => self.marker = Some(0xD9), + }, + Some(&b) => { + byte = b; + self.pos += 1; + } + None => self.marker = Some(0xD9), + } + } + self.acc |= u64::from(byte) << (56 - self.n); + self.n += 8; + } + } + + fn peek(&mut self, k: u32) -> u32 { + if self.n < k { + self.fill(); + } + (self.acc >> (64 - k)) as u32 + } + + fn skip(&mut self, k: u32) { + self.acc <<= k; + self.n -= k; + } + + fn bits(&mut self, k: u32) -> u32 { + if k == 0 { + return 0; + } + let v = self.peek(k); + self.skip(k); + v + } + + /// Decode one Huffman symbol (F.2.2.3). + fn decode(&mut self, h: &Huffman) -> Result { + let p = self.peek(LOOKUP_BITS) as usize; + if let Some(&(len, v)) = h.fast.get(p) + && len > 0 + { + self.skip(u32::from(len)); + return Ok(v); + } + let mut code = self.bits(1) as i32; + for l in 1..=16usize { + if code <= h.maxcode[l] { + let i = h.valptr[l] + code - h.mincode[l]; + return h.values.get(i as usize).copied().ok_or(Error::Malformed("lossless JPEG: bad Huffman code")); + } + code = (code << 1) | self.bits(1) as i32; + } + Err(Error::Malformed("lossless JPEG: bad Huffman code")) + } + + /// Skip to the byte after the next RSTn marker, starting a new bit stream there. + fn restart(&mut self) -> Result<()> { + // the decoder may have looked ahead into fill bits; find the marker in the bytes + let mut p = self.pos; + loop { + match (self.data.get(p), self.data.get(p + 1)) { + (Some(0xFF), Some(&m)) if (0xD0..=0xD7).contains(&m) => { + *self = Bits::new(self.data, p + 2); + return Ok(()); + } + (Some(_), Some(_)) => p += 1, + _ => return Err(Error::Malformed("lossless JPEG: missing restart marker")), + } + } + } +} + +struct Component { + id: u8, + table: usize, +} + +/// Decode a lossless JPEG stream. `max_samples` caps the picture (width × height × components). +pub fn decode(data: &[u8], max_samples: usize) -> Result { + if data.get(0..2) != Some(&[0xFF, 0xD8]) { + return Err(Error::Malformed("lossless JPEG: no SOI marker")); + } + let mut pos = 2usize; + let mut tables: [Option; 4] = [None, None, None, None]; + let mut frame: Option<(u8, usize, usize, Vec)> = None; + let mut restart_interval = 0usize; + loop { + // markers may be preceded by fill bytes 0xFF + while data.get(pos) == Some(&0xFF) && data.get(pos + 1) == Some(&0xFF) { + pos += 1; + } + let (Some(&0xFF), Some(&m)) = (data.get(pos), data.get(pos + 1)) else { + return Err(Error::Malformed("lossless JPEG: expected a marker")); + }; + pos += 2; + if m == 0xD9 { + return Err(Error::Malformed("lossless JPEG: no scan")); + } + if (0xD0..=0xD8).contains(&m) || m == 0x01 { + continue; // markers without a segment + } + let len_b = data.get(pos..pos + 2).ok_or(Error::Truncated)?; + let len = usize::from(u16::from_be_bytes([len_b[0], len_b[1]])); + if len < 2 { + return Err(Error::Malformed("lossless JPEG: segment length")); + } + let seg = data.get(pos + 2..pos + len).ok_or(Error::Truncated)?; + match m { + 0xC3 => { + let [p, y0, y1, x0, x1, nf, rest @ ..] = seg else { + return Err(Error::Malformed("lossless JPEG: short frame header")); + }; + let (precision, nf) = (*p, usize::from(*nf)); + let height = usize::from(u16::from_be_bytes([*y0, *y1])); + let width = usize::from(u16::from_be_bytes([*x0, *x1])); + if !(2..=16).contains(&precision) { + return Err(Error::Unsupported(format!("lossless JPEG precision {precision}"))); + } + if height == 0 { + return Err(Error::Unsupported("lossless JPEG with the height in a DNL marker".into())); + } + if width == 0 || !(1..=4).contains(&nf) || rest.len() < nf * 3 { + return Err(Error::Malformed("lossless JPEG: frame header")); + } + let mut comps = Vec::with_capacity(nf); + for c in rest.as_chunks::<3>().0.iter().take(nf) { + if c[1] != 0x11 { + return Err(Error::Unsupported("subsampled lossless JPEG components".into())); + } + comps.push(Component { id: c[0], table: 0 }); + } + frame = Some((precision, width, height, comps)); + } + 0xC4 => { + let mut s = seg; + while !s.is_empty() { + let [tcth, counts @ ..] = s else { break }; + let bits: [u8; 16] = counts.get(..16).and_then(|b| b.try_into().ok()).ok_or(Error::Truncated)?; + let n: usize = bits.iter().map(|&b| usize::from(b)).sum(); + let values = counts.get(16..16 + n).ok_or(Error::Truncated)?.to_vec(); + let th = usize::from(tcth & 0x0F); + if tcth >> 4 != 0 || th > 3 { + return Err(Error::Malformed("lossless JPEG: Huffman table class or id")); + } + if values.iter().any(|&v| v > 16) { + return Err(Error::Malformed("lossless JPEG: difference category above 16")); + } + tables[th] = Some(Huffman::new(&bits, values)?); + s = counts.get(16 + n..).unwrap_or(&[]); + } + } + 0xDD => { + let r = seg.get(0..2).ok_or(Error::Truncated)?; + restart_interval = usize::from(u16::from_be_bytes([r[0], r[1]])); + } + 0xDA => { + let Some((precision, width, height, mut comps)) = frame.take() else { + return Err(Error::Malformed("lossless JPEG: scan before the frame header")); + }; + let ns = usize::from(*seg.first().ok_or(Error::Truncated)?); + if ns != comps.len() { + return Err(Error::Unsupported("lossless JPEG with more than one scan".into())); + } + for k in 0..ns { + let c = seg.get(1 + 2 * k..3 + 2 * k).ok_or(Error::Truncated)?; + let comp = comps.iter_mut().find(|x| x.id == c[0]).ok_or(Error::Malformed("lossless JPEG: unknown scan component"))?; + comp.table = usize::from(c[1] >> 4); + if comp.table > 3 { + return Err(Error::Malformed("lossless JPEG: Huffman table id")); + } + } + let tail = seg.get(1 + 2 * ns..4 + 2 * ns).ok_or(Error::Truncated)?; + let (predictor, pt) = (tail[0], tail[2] & 0x0F); + if !(1..=7).contains(&predictor) { + return Err(Error::Unsupported(format!("lossless JPEG predictor {predictor}"))); + } + if pt >= precision { + return Err(Error::Malformed("lossless JPEG: point transform")); + } + let n = width.checked_mul(height).and_then(|v| v.checked_mul(comps.len())).ok_or(Error::TooLarge)?; + if n > max_samples { + return Err(Error::TooLarge); + } + let mut hufs = Vec::with_capacity(comps.len()); + for c in &comps { + hufs.push(tables.get(c.table).and_then(|t| t.as_ref()).ok_or(Error::Malformed("lossless JPEG: missing Huffman table"))?); + } + let samples = scan(data, pos + len, &hufs, width, height, precision, predictor, pt, restart_interval)?; + return Ok(Decoded { width, height, components: comps.len(), precision, predictor, samples }); + } + 0xC0..=0xC2 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => { + return Err(Error::Unsupported("lossy (DCT) JPEG in a DNG".into())); + } + _ => {} // APPn, COM, DQT … are skipped + } + pos += len; + } +} + +/// Decode the entropy-coded data of one scan (H.1.2). +fn scan(data: &[u8], start: usize, hufs: &[&Huffman], width: usize, height: usize, precision: u8, predictor: u8, pt: u8, restart: usize) -> Result> { + let nc = hufs.len(); + let row_len = width * nc; + if restart != 0 && !restart.is_multiple_of(width) { + return Err(Error::Unsupported("lossless JPEG restart interval that splits a line".into())); + } + let lines_per_interval = if restart == 0 { usize::MAX } else { restart / width }; + let mut out = vec![0u16; row_len * height]; + let mut bits = Bits::new(data, start); + let initial = 1i32 << (precision - pt - 1); + let mut line_in_interval = 0usize; + for y in 0..height { + if line_in_interval == lines_per_interval { + bits.restart()?; + line_in_interval = 0; + } + let first_line = line_in_interval == 0; + let (above, cur) = if y == 0 { + (None, &mut out[..row_len]) + } else { + let (a, c) = out.split_at_mut(y * row_len); + (Some(&a[(y - 1) * row_len..]), &mut c[..row_len]) + }; + for x in 0..width { + for (c, h) in hufs.iter().enumerate() { + let i = x * nc + c; + let ssss = u32::from(bits.decode(h)?); + let diff = match ssss { + 0 => 0, + 16 => 32768, + s => { + let v = bits.bits(s) as i32; + if v < (1 << (s - 1)) { v - (1 << s) + 1 } else { v } + } + }; + let px = match above { + _ if first_line && x == 0 => initial, + _ if first_line => i32::from(cur[i - nc]), + Some(a) if x == 0 => i32::from(a[i]), + Some(a) => predict(predictor, i32::from(cur[i - nc]), i32::from(a[i]), i32::from(a[i - nc])), + None => initial, + }; + cur[i] = ((px + diff) & 0xFFFF) as u16; + } + } + line_in_interval += 1; + } + if pt > 0 { + for s in &mut out { + *s <<= pt; + } + } + Ok(out) +} + +/// Table H.1 predictors (`ra` left, `rb` above, `rc` above left). +pub fn predict(p: u8, ra: i32, rb: i32, rc: i32) -> i32 { + match p { + 1 => ra, + 2 => rb, + 3 => rc, + 4 => ra + rb - rc, + 5 => ra + ((rb - rc) >> 1), + 6 => rb + ((ra - rc) >> 1), + _ => (ra + rb) >> 1, + } +} diff --git a/crates/dng/src/reference/mod.rs b/crates/dng/src/reference/mod.rs new file mode 100644 index 0000000000..9236526b5c --- /dev/null +++ b/crates/dng/src/reference/mod.rs @@ -0,0 +1,44 @@ +//! The decoder and pipeline of PR #1068, kept unchanged (feature `writer`, tests and benchmarks +//! only) so the faster implementation can be checked for bit-identical output and benchmarked +//! against it in the same run. + +#![allow(clippy::too_many_arguments)] + +pub mod demosaic; +pub mod ljpeg; +pub mod process; +pub mod raw; + +use crate::options::{ProcessOptions, WhiteBalance}; +use crate::{Dng, Error, Image, Result, color}; + +impl Dng<'_> { + /// The raw samples as PR #1068 read them. + pub fn reference_raw(&self) -> Result { + raw::read(&self.tiff, self.raw_ifd()) + } + + /// [`Dng::develop_with`] as PR #1068 (plus the `ProcessOptions` commit) developed. + pub fn reference_develop(&self, o: &ProcessOptions) -> Result { + let info = self.info()?; + let p = self.params()?; + let raw = self.reference_raw()?; + let mut calibration = self.calibration()?; + calibration.baseline_exposure += o.exposure_stops(); + if let WhiteBalance::Custom { temperature, tint } = o.white_balance + && calibration.color_matrix.iter().any(Option::is_some) + { + let xy = color::temperature_tint_to_xy(temperature, tint).ok_or(Error::Unsupported("a non-finite white balance".into()))?; + calibration.as_shot_neutral = None; + calibration.as_shot_white_xy = Some(xy); + } + let solved = if p.cfa.is_none() && raw.samples_per_pixel == 1 { + let e = 2f64.powf(calibration.baseline_exposure.clamp(-16.0, 16.0)); + color::Solved { neutral: [1.0; 3], matrix: color::scale(&color::IDENTITY, e), white_xy: color::D65 } + } else { + calibration.solve()? + }; + let d = process::develop_with(&raw, &p, &solved.neutral, &solved.matrix, o)?; + Ok(Image { info, width: d.width, height: d.height, rgba: d.rgba }) + } +} diff --git a/crates/dng/src/reference/process.rs b/crates/dng/src/reference/process.rs new file mode 100644 index 0000000000..13018717fa --- /dev/null +++ b/crates/dng/src/reference/process.rs @@ -0,0 +1,118 @@ +//! The processing of PR #1068 (normalise, demosaic, crop, matrix), kept unchanged as the reference +//! the faster pipeline is compared against (tests and benchmarks only). + +#[cfg(feature = "threads")] +use rayon::prelude::*; + +use super::demosaic::demosaic_with; +use crate::color::{M3, V3}; +use crate::options::ProcessOptions; +use crate::process::{Developed, Params, active_rect, crop_rect}; + +type Rect = (usize, usize, usize, usize); +use crate::raw::RawImage; +use crate::{Error, Result}; + +/// Normalised, white-balanced samples of the active area (steps 1–4), one plane per sample. +fn normalise(raw: &RawImage, p: &Params, (ax, ay, aw, ah): Rect, neutral: &V3, planes: usize) -> Result> { + let spp = raw.samples_per_pixel; + let (br, bc) = (p.black_repeat.0.max(1), p.black_repeat.1.max(1)); + if br > 64 || bc > 64 { + return Err(Error::Unsupported("a BlackLevelRepeatDim larger than 64".into())); + } + let need = br * bc * spp; + let black: Vec = match p.black.len() { + 0 => vec![0.0; need], + 1 => vec![p.black[0]; need], + n if n >= need => p.black[..need].to_vec(), + _ => return Err(Error::Malformed("BlackLevel has too few values")), + }; + let max_code = ((1u32 << raw.bits) - 1) as f64; + let white: Vec = (0..spp).map(|s| p.white.get(s).or(p.white.first()).copied().unwrap_or(max_code)).collect(); + let lin = p.linearization.as_deref().filter(|t| !t.is_empty()); + let inv_neutral: Vec = (0..planes).map(|c| (1.0 / neutral.get(c).copied().unwrap_or(1.0)) as f32).collect(); + let cfa = p.cfa.as_ref(); + let mut out = vec![0f32; aw * ah * spp]; + let row = |y: usize, o: &mut [f32]| { + let dv = p.black_delta_v.get(y).copied().unwrap_or(0.0); + let src_row = (ay + y) * raw.width * spp; + for x in 0..aw { + let dh = p.black_delta_h.get(x).copied().unwrap_or(0.0); + for s in 0..spp { + let code = raw.data.get(src_row + (ax + x) * spp + s).copied().unwrap_or(0); + let v = match lin { + Some(t) => f64::from(t.get(usize::from(code)).or(t.last()).copied().unwrap_or(0)), + None => f64::from(code), + }; + let b = black[((y % br) * bc + x % bc) * spp + s] + dh + dv; + let range = white[s] - b; + let n = if range > 0.0 { ((v - b) / range).clamp(0.0, 1.0) } else { 0.0 }; + let plane = match cfa { + Some(c) => usize::from(c.color(x, y)), + None => s, + }; + let wb = inv_neutral.get(plane).copied().unwrap_or(1.0); + if let Some(o) = o.get_mut(x * spp + s) { + *o = (n as f32 * wb).min(1.0); + } + } + } + }; + #[cfg(feature = "threads")] + out.par_chunks_mut((aw * spp).max(1)).enumerate().for_each(|(y, o)| row(y, o)); + #[cfg(not(feature = "threads"))] + out.chunks_mut((aw * spp).max(1)).enumerate().for_each(|(y, o)| row(y, o)); + Ok(out) +} + +/// Develop `raw` (steps 1–7). `neutral` and `matrix` come from [`crate::color::Calibration::solve`]. +pub fn develop(raw: &RawImage, p: &Params, neutral: &V3, matrix: &M3) -> Result { + develop_with(raw, p, neutral, matrix, &ProcessOptions::default()) +} + +/// [`develop`] with raw settings. White balance and exposure are already in `neutral` and +/// `matrix` ([`crate::Dng::develop_with`] solves them); this applies the demosaic quality. +pub fn develop_with(raw: &RawImage, p: &Params, neutral: &V3, matrix: &M3, o: &ProcessOptions) -> Result { + let spp = raw.samples_per_pixel; + if p.cfa.is_some() && spp != 1 { + return Err(Error::Malformed("a CFA image with more than one sample per pixel")); + } + if p.cfa.is_none() && !matches!(spp, 1 | 3) { + return Err(Error::Unsupported(format!("LinearRaw with {spp} samples per pixel"))); + } + let active = active_rect(p, raw.width, raw.height)?; + let (_, _, aw, ah) = active; + let planes = if p.cfa.is_some() { 3 } else { spp }; + let norm = normalise(raw, p, active, neutral, planes)?; + // three samples per pixel + let rgb = match (&p.cfa, spp) { + (Some(pat), _) => demosaic_with(&norm, aw, ah, pat, o.demosaic), + (None, 3) => norm, + _ => norm.iter().flat_map(|&v| [v, v, v]).collect(), + }; + let (cx, cy, cw, ch) = crop_rect(p, aw, ah); + let m: [[f32; 3]; 3] = matrix.map(|r| r.map(|v| v as f32)); + let mono = p.cfa.is_none() && spp == 1; + let mut rgba = vec![0f32; cw * ch * 4]; + let row = |y: usize, o: &mut [f32]| { + let src = (cy + y) * aw + cx; + for x in 0..cw { + let Some(c) = rgb.get((src + x) * 3..(src + x) * 3 + 3) else { continue }; + let px = if mono { + // grey: the matrix's luminance gain only + let g = c[0] * (m[1][0] + m[1][1] + m[1][2]); + [g, g, g] + } else { + [0, 1, 2].map(|i| m[i][0] * c[0] + m[i][1] * c[1] + m[i][2] * c[2]) + }; + if let Some(d) = o.get_mut(x * 4..x * 4 + 4) { + d.copy_from_slice(&[px[0].max(0.0), px[1].max(0.0), px[2].max(0.0), 1.0]); + } + } + }; + #[cfg(feature = "threads")] + rgba.par_chunks_mut(cw * 4).enumerate().for_each(|(y, o)| row(y, o)); + #[cfg(not(feature = "threads"))] + rgba.chunks_mut(cw * 4).enumerate().for_each(|(y, o)| row(y, o)); + Ok(Developed { width: cw, height: ch, rgba }) +} diff --git a/crates/dng/src/reference/raw.rs b/crates/dng/src/reference/raw.rs new file mode 100644 index 0000000000..0b14c6992a --- /dev/null +++ b/crates/dng/src/reference/raw.rs @@ -0,0 +1,156 @@ +//! The raw sample reading of PR #1068, kept unchanged as the reference the faster reader is +//! compared against (tests and benchmarks only). + +#[cfg(feature = "threads")] +use rayon::prelude::*; + +use super::ljpeg; +use crate::raw::{MAX_SAMPLES, RawImage, RawLayout, layout}; +use crate::tiff::{Ifd, Tiff, tags}; +use crate::{Error, Result}; + +fn uint_or(t: &Tiff, ifd: &Ifd, tag: u16, default: u64) -> Result { + match ifd.get(tag) { + Some(e) => t.uint(e), + None => Ok(default), + } +} + +/// One strip or tile: where it sits in the image and its bytes. +struct Chunk<'a> { + x: usize, + y: usize, + /// Pixel size of the chunk as coded (tiles are whole even at the image edge). + w: usize, + h: usize, + bytes: &'a [u8], +} + +fn chunks<'a>(t: &Tiff<'a>, ifd: &Ifd, l: &RawLayout) -> Result>> { + let (offs, counts, tw, th) = if let (Some(o), Some(c)) = (ifd.get(tags::TILE_OFFSETS), ifd.get(tags::TILE_BYTE_COUNTS)) { + let tw = uint_or(t, ifd, tags::TILE_WIDTH, 0)? as usize; + let th = uint_or(t, ifd, tags::TILE_LENGTH, 0)? as usize; + if tw == 0 || th == 0 || tw > 65_535 || th > 65_535 { + return Err(Error::Malformed("tile size")); + } + (t.uints(o)?, t.uints(c)?, tw, th) + } else if let (Some(o), Some(c)) = (ifd.get(tags::STRIP_OFFSETS), ifd.get(tags::STRIP_BYTE_COUNTS)) { + let rows = (uint_or(t, ifd, tags::ROWS_PER_STRIP, l.height as u64)? as usize).clamp(1, l.height); + (t.uints(o)?, t.uints(c)?, l.width, rows) + } else { + return Err(Error::Malformed("no strips or tiles")); + }; + let across = l.width.div_ceil(tw); + let down = l.height.div_ceil(th); + let n = across * down; + if offs.len() < n || counts.len() < n { + return Err(Error::Malformed("fewer strips or tiles than the image needs")); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let (x, y) = ((i % across) * tw, (i / across) * th); + let start = usize::try_from(offs[i]).map_err(|_| Error::Truncated)?; + let len = usize::try_from(counts[i]).map_err(|_| Error::Truncated)?; + let end = start.checked_add(len).ok_or(Error::Truncated)?; + let bytes = t.data.get(start..end).ok_or(Error::Truncated)?; + // strips end at the image; tiles are coded whole + let h = if ifd.get(tags::TILE_OFFSETS).is_some() { th } else { th.min(l.height - y) }; + out.push(Chunk { x, y, w: tw, h, bytes }); + } + Ok(out) +} + +/// Unpack `w` × `h` × `spp` uncompressed samples of `bits` bits: 8 and 16-bit samples as bytes / +/// words in the file's byte order, other depths packed most significant bit first with each row +/// starting on a byte boundary (TIFF 6.0 §7, DNG BitsPerSample). +pub fn unpack(bytes: &[u8], w: usize, h: usize, spp: usize, bits: u32, big_endian: bool) -> Result> { + let per_row = w * spp; + let row_bytes = (per_row * bits as usize).div_ceil(8); + let need = row_bytes.checked_mul(h).ok_or(Error::TooLarge)?; + if bytes.len() < need { + return Err(Error::Truncated); + } + let mut out = Vec::with_capacity(per_row * h); + for row in bytes.chunks_exact(row_bytes.max(1)).take(h) { + match bits { + 8 => out.extend(row.iter().map(|&b| u16::from(b))), + 16 => out.extend(row.as_chunks::<2>().0.iter().map(|&b| if big_endian { u16::from_be_bytes(b) } else { u16::from_le_bytes(b) })), + _ => { + let mut acc: u32 = 0; + let mut n: u32 = 0; + let mut it = row.iter(); + for _ in 0..per_row { + while n < bits { + acc = (acc << 8) | u32::from(*it.next().ok_or(Error::Truncated)?); + n += 8; + } + n -= bits; + out.push(((acc >> n) & ((1 << bits) - 1)) as u16); + acc &= (1 << n) - 1; + } + } + } + } + Ok(out) +} + +/// Decode one chunk into `w` × `h` × `spp` samples. +fn decode_chunk(c: &Chunk, l: &RawLayout, big_endian: bool) -> Result> { + let need = c.w * c.h * l.samples_per_pixel; + match l.compression { + 1 => unpack(c.bytes, c.w, c.h, l.samples_per_pixel, l.bits, big_endian), + _ => { + // the JPEG's samples fill the chunk in raster order (its own width × components may + // differ from the chunk's, e.g. two components of half the width) + let d = ljpeg::decode(c.bytes, need.saturating_add(65_536 * 4))?; + if d.samples.len() < need { + return Err(Error::Malformed("lossless JPEG smaller than its tile or strip")); + } + let mut s = d.samples; + s.truncate(need); + Ok(s) + } + } +} + +/// Read the raw image of `ifd`. +pub fn read(t: &Tiff, ifd: &Ifd) -> Result { + let l = layout(t, ifd)?; + let spp = l.samples_per_pixel; + let total = l.width.checked_mul(l.height).and_then(|v| v.checked_mul(spp)).ok_or(Error::TooLarge)?; + if total > MAX_SAMPLES { + return Err(Error::TooLarge); + } + let chunks = chunks(t, ifd, &l)?; + // every sample takes at least one bit of file (a Huffman code is at least one bit long) + let coded: usize = chunks.iter().map(|c| c.bytes.len()).fold(0usize, usize::saturating_add); + if total / 8 > coded { + return Err(Error::Truncated); + } + for c in &chunks { + let n = c.w.checked_mul(c.h).and_then(|v| v.checked_mul(spp)).ok_or(Error::TooLarge)?; + if n > MAX_SAMPLES { + return Err(Error::TooLarge); + } + if n / 8 > c.bytes.len() { + return Err(Error::Truncated); + } + } + #[cfg(feature = "threads")] + let decoded: Vec>> = chunks.par_iter().map(|c| decode_chunk(c, &l, t.big_endian)).collect(); + #[cfg(not(feature = "threads"))] + let decoded: Vec>> = chunks.iter().map(|c| decode_chunk(c, &l, t.big_endian)).collect(); + let mut data = vec![0u16; total]; + let row_len = l.width * spp; + for (c, d) in chunks.iter().zip(decoded) { + let d = d?; + let cw = c.w.min(l.width - c.x); + let ch = c.h.min(l.height - c.y); + for r in 0..ch { + let src = d.get(r * c.w * spp..(r * c.w + cw) * spp).ok_or(Error::Truncated)?; + let at = (c.y + r) * row_len + c.x * spp; + data.get_mut(at..at + cw * spp).ok_or(Error::Truncated)?.copy_from_slice(src); + } + } + Ok(RawImage { width: l.width, height: l.height, samples_per_pixel: spp, bits: l.bits, data }) +} diff --git a/crates/dng/tests/it/equivalence.rs b/crates/dng/tests/it/equivalence.rs new file mode 100644 index 0000000000..dfcfb44f6f --- /dev/null +++ b/crates/dng/tests/it/equivalence.rs @@ -0,0 +1,192 @@ +//! The faster decoder and pipeline must give exactly what the reference implementation of PR +//! #1068 (`filmcraft_dng::reference`) gives: the same raw samples, and the same f32 bit patterns +//! after development, over every Bayer phase, both demosaic qualities, crops with odd origins and +//! at the borders, active-area offsets, linearisation tables, black-level deltas, lossless-JPEG +//! codes above the stated bit depth, LinearRaw, other CFA patterns and tiny pictures. + +use filmcraft_dng::tiff::tags; +use filmcraft_dng::writer::{Compression, Layout, Options, Value, write}; +use filmcraft_dng::{DemosaicQuality, Dng, ProcessOptions}; + +fn noise(n: usize, max: u32, seed: u32) -> Vec { + let mut s = seed.wrapping_mul(2_654_435_761).wrapping_add(1); + (0..n) + .map(|i| { + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + // a ramp with noise, some samples clipped high and low + let v = (i as u32 * 37) % (max + 1); + (match s % 23 { + 0 => 0, + 1 => max, + _ => (v ^ (s & 63)).min(max), + }) as u16 + }) + .collect() +} + +fn same_bits(a: &[f32], b: &[f32]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.to_bits() == y.to_bits()) +} + +fn colour_tags() -> Vec<(u16, Value)> { + let cm = [0.9f64, -0.3, -0.05, -0.4, 1.2, 0.2, -0.05, 0.15, 0.6]; + vec![ + (tags::COLOR_MATRIX_1, Value::srationals(&cm)), + (tags::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21])), + (tags::AS_SHOT_NEUTRAL, Value::rationals(&[0.55, 1.0, 0.7])), + ] +} + +/// Raw samples and every development option of `f` agree with the reference. +fn check(f: &[u8], what: &str) { + let d = Dng::parse(f).unwrap(); + assert_eq!(d.raw().unwrap(), d.reference_raw().unwrap(), "{what}: raw"); + for demosaic in [DemosaicQuality::High, DemosaicQuality::Fast] { + let o = ProcessOptions { demosaic, ..Default::default() }; + let (a, b) = (d.develop_with(&o).unwrap(), d.reference_develop(&o).unwrap()); + assert_eq!((a.width, a.height), (b.width, b.height), "{what} {demosaic:?}"); + assert!(same_bits(&a.rgba, &b.rgba), "{what} {demosaic:?}: developed values differ"); + } +} + +#[test] +fn bayer_phases_crops_active_areas_linearisation_and_deltas() { + let phases = [[0u8, 1, 1, 2], [1, 0, 2, 1], [1, 2, 0, 1], [2, 1, 1, 0]]; + let sizes = [(1, 1), (2, 2), (3, 3), (4, 5), (5, 3), (7, 6), (26, 18)]; + for (pi, phase) in phases.iter().enumerate() { + for &(w, h) in &sizes { + for variant in 0..6 { + let px = noise(w * h, 4095, (pi * 100 + w * 7 + h + variant) as u32); + let mut raw_tags = vec![(tags::BLACK_LEVEL, Value::Short(vec![60])), (tags::WHITE_LEVEL, Value::Short(vec![4000]))]; + match variant { + // an active area with an odd offset, a crop with an odd origin inside it + 1 if w > 4 && h > 4 => { + raw_tags.push((tags::ACTIVE_AREA, Value::Long(vec![1, 1, h as u32 - 1, w as u32]))); + raw_tags.push((tags::DEFAULT_CROP_ORIGIN, Value::Long(vec![1, 1]))); + raw_tags.push((tags::DEFAULT_CROP_SIZE, Value::Long(vec![w as u32 - 3, h as u32 - 3]))); + } + // a crop touching the right and bottom borders + 2 if w > 2 && h > 2 => { + raw_tags.push((tags::DEFAULT_CROP_ORIGIN, Value::Long(vec![1, 2.min(h as u32 - 1)]))); + raw_tags.push((tags::DEFAULT_CROP_SIZE, Value::Long(vec![w as u32, h as u32]))); + } + // a linearisation table + 3 => raw_tags.push((tags::LINEARIZATION_TABLE, Value::Short((0..4096u32).map(|v| (v * v / 4096) as u16).collect()))), + // per-site black levels with row and column deltas (the direct path) + 4 => { + raw_tags.push((tags::BLACK_LEVEL_REPEAT_DIM, Value::Short(vec![2, 2]))); + raw_tags.push((tags::BLACK_LEVEL, Value::Short(vec![50, 61, 72, 83]))); + raw_tags.push((tags::BLACK_LEVEL_DELTA_H, Value::srationals(&(0..w).map(|x| x as f64 * 0.5).collect::>()))); + raw_tags.push((tags::BLACK_LEVEL_DELTA_V, Value::srationals(&(0..h).map(|y| -(y as f64) * 0.25).collect::>()))); + } + // per-site black levels without deltas (the lookup table path) + 5 => { + raw_tags.push((tags::BLACK_LEVEL_REPEAT_DIM, Value::Short(vec![2, 2]))); + raw_tags.push((tags::BLACK_LEVEL, Value::Short(vec![50, 61, 72, 83]))); + } + _ => {} + } + let o = Options { + width: w, + height: h, + bits: 12, + cfa: Some((2, 2, phase.to_vec())), + compression: Compression::LosslessJpeg { predictor: 1 + (variant % 7) as u8, components: 1, restart_lines: 0, pt: 0 }, + main_tags: colour_tags(), + raw_tags, + ..Default::default() + }; + check(&write(&px, &o), &format!("phase {phase:?} {w}x{h} variant {variant}")); + } + } + } +} + +#[test] +fn lossless_jpeg_codes_above_the_bit_depth_and_layouts() { + // 16-bit lossless JPEG data in a file that says 12 bits: codes above 4095 reach the decoder + let (w, h) = (40, 22); + let px = noise(w * h, 65_535, 9); + for (layout, components, restart_lines, predictor) in [ + (Layout::Strips { rows: h }, 2, 0, 1), + (Layout::Strips { rows: 5 }, 1, 2, 6), + (Layout::Tiles { width: 16, height: 16 }, 2, 0, 4), + (Layout::Tiles { width: 32, height: 8 }, 4, 1, 7), + ] { + for be in [false, true] { + let o = Options { + width: w, + height: h, + bits: 16, + big_endian: be, + layout, + compression: Compression::LosslessJpeg { predictor, components, restart_lines, pt: 0 }, + main_tags: colour_tags(), + raw_tags: vec![(tags::BITS_PER_SAMPLE, Value::Short(vec![12]))], + ..Default::default() + }; + check(&write(&px, &o), &format!("{layout:?} {components} components restart {restart_lines} be {be}")); + } + } +} + +#[test] +fn uncompressed_linear_raw_mono_and_other_patterns() { + let (w, h) = (19, 11); + for bits in [8, 10, 14, 16] { + let max = (1u32 << bits) - 1; + // three-sample LinearRaw + let px = noise(w * h * 3, max, bits); + let o = Options { + width: w, + height: h, + samples_per_pixel: 3, + bits, + cfa: None, + main_tags: colour_tags(), + layout: Layout::Strips { rows: 4 }, + ..Default::default() + }; + check(&write(&px, &o), &format!("LinearRaw {bits}-bit")); + // monochrome + let px = noise(w * h, max, bits + 1); + let o = Options { width: w, height: h, bits, cfa: None, layout: Layout::Tiles { width: 16, height: 16 }, ..Default::default() }; + check(&write(&px, &o), &format!("mono {bits}-bit")); + } + // non-Bayer colour filter arrays (bilinear for both qualities) + for (r, c, p) in [(1, 3, vec![0u8, 1, 2]), (2, 4, vec![0, 1, 2, 1, 1, 2, 1, 0]), (2, 2, vec![0, 1, 2, 1])] { + let px = noise(w * h, 4095, 3); + let o = Options { width: w, height: h, bits: 12, cfa: Some((r, c, p.clone())), main_tags: colour_tags(), ..Default::default() }; + check(&write(&px, &o), &format!("CFA {r}x{c} {p:?}")); + } +} + +#[test] +fn reduced_resolution_covers_the_target_and_keeps_flat_colours() { + let (w, h) = (64, 40); + // a flat colour: every 2 × 2 site has its own constant level + let pat = [0usize, 1, 1, 2]; + let level = [1000u16, 2000, 2000, 3000]; + let px: Vec = (0..w * h).map(|i| level[pat[(i / w % 2) * 2 + i % 2]]).collect(); + let o = Options { width: w, height: h, bits: 12, main_tags: colour_tags(), ..Default::default() }; + let f = write(&px, &o); + let d = Dng::parse(&f).unwrap(); + let full = d.develop().unwrap(); + for (target, want) in [((64, 40), (64, 40)), ((32, 20), (32, 20)), ((30, 10), (32, 20)), ((16, 10), (16, 10)), ((1, 1), (8, 5)), ((40, 40), (64, 40))] { + let img = d.develop_with(&ProcessOptions { target_size: Some(target), ..Default::default() }).unwrap(); + assert_eq!((img.width, img.height), want, "target {target:?}"); + assert_eq!(img.rgba.len(), want.0 * want.1 * 4); + // a flat field develops to the same colour at every size + for (a, b) in img.rgba.iter().zip(&full.rgba) { + assert!((a - b).abs() < 1e-5, "target {target:?}: {a} vs {b}"); + } + } + // LinearRaw and monochrome reduce too + let lin: Vec = (0..w * h).flat_map(|_| [500u16, 1500, 2500]).collect(); + let o = Options { width: w, height: h, samples_per_pixel: 3, bits: 12, cfa: None, ..Default::default() }; + let img = filmcraft_dng::decode_with(&write(&lin, &o), &ProcessOptions { target_size: Some((10, 10)), ..Default::default() }).unwrap(); + assert_eq!((img.width, img.height), (16, 10)); + assert!((img.rgba[0] - 500.0 / 4095.0).abs() < 1e-6); +} diff --git a/crates/dng/tests/it/main.rs b/crates/dng/tests/it/main.rs index fd498d961d..0cbb750e69 100644 --- a/crates/dng/tests/it/main.rs +++ b/crates/dng/tests/it/main.rs @@ -1,2 +1,3 @@ +mod equivalence; mod oracle_libraw; mod roundtrip; diff --git a/crates/dng/tests/it/roundtrip.rs b/crates/dng/tests/it/roundtrip.rs index af9d8e287f..1c98e0467b 100644 --- a/crates/dng/tests/it/roundtrip.rs +++ b/crates/dng/tests/it/roundtrip.rs @@ -5,7 +5,7 @@ use filmcraft_dng::color::{D50, D65, bradford, inverse, mul, rec709_to_xyz, xy_to_xyz}; use filmcraft_dng::tiff::tags; use filmcraft_dng::writer::{Compression, Layout, Lj92Params, Options, Value, lj92, write}; -use filmcraft_dng::{Dng, Error, Timecode, decode, ljpeg, probe, sniff}; +use filmcraft_dng::{DemosaicQuality, Dng, Error, Highlights, ProcessOptions, Timecode, WhiteBalance, decode, decode_with, ljpeg, probe, sniff}; /// A deterministic pseudo-random sequence. fn noise(n: usize, bits: u32, seed: u32) -> Vec { @@ -368,6 +368,87 @@ fn cyclic_ifds_huge_sizes_and_bad_offsets() { assert_eq!(decode(&write(&px, &o)).err(), Some(Error::Unsupported("lossy JPEG DNG".into()))); } +#[test] +fn process_options_default_is_the_files_development() { + let (w, h) = (24, 16); + let px = picture(w, h, 1, 12, 11); + // a camera that is not Rec. 709 already, white balanced away from D65 + let cm = [[0.9, -0.3, -0.05], [-0.4, 1.2, 0.2], [-0.05, 0.15, 0.6]]; + let main = vec![ + (tags::COLOR_MATRIX_1, Value::srationals(&cm.iter().flatten().copied().collect::>())), + (tags::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21])), + (tags::AS_SHOT_NEUTRAL, Value::rationals(&[0.55, 1.0, 0.7])), + ]; + let o = Options { width: w, height: h, bits: 12, main_tags: main, raw_tags: vec![(tags::WHITE_LEVEL, Value::Short(vec![4095]))], ..Default::default() }; + let f = write(&px, &o); + let base = decode(&f).unwrap(); + let same = decode_with(&f, &ProcessOptions::default()).unwrap(); + assert_eq!(base.rgba, same.rgba, "default options change nothing"); + // highlight recovery is not implemented yet: identical to clipping + let rec = decode_with(&f, &ProcessOptions { highlights: Highlights::Recover, ..Default::default() }).unwrap(); + assert_eq!(base.rgba, rec.rgba); + // +1 stop doubles every value + let up = decode_with(&f, &ProcessOptions { exposure: 1.0, ..Default::default() }).unwrap(); + for (a, b) in base.rgba.iter().zip(&up.rgba).filter(|(a, _)| **a != 1.0) { + assert!((b - 2.0 * a).abs() <= 1e-5 * a.max(1.0), "{a} {b}"); + } + let nan = decode_with(&f, &ProcessOptions { exposure: f64::NAN, ..Default::default() }).unwrap(); + assert_eq!(base.rgba, nan.rgba, "a non-finite exposure is ignored"); + // the as-shot white as temperature / tint develops like the as-shot neutral + let (t, tint) = Dng::parse(&f).unwrap().as_shot_temperature_tint().unwrap().unwrap(); + assert!((1_000.0..15_000.0).contains(&t), "{t}"); + let custom = decode_with(&f, &ProcessOptions { white_balance: WhiteBalance::Custom { temperature: t, tint }, ..Default::default() }).unwrap(); + for (a, b) in base.rgba.iter().zip(&custom.rgba) { + assert!((a - b).abs() < 2e-3, "{a} {b}"); + } + // a warmer setting makes the picture bluer (the white balance compensates a warmer light) + let warm = decode_with(&f, &ProcessOptions { white_balance: WhiteBalance::Custom { temperature: t * 0.6, tint }, ..Default::default() }).unwrap(); + let ratio = |img: &[f32]| img.chunks(4).map(|p| p[2] / p[0].max(1e-6)).sum::(); + assert!(ratio(&warm.rgba) > ratio(&base.rgba)); + assert!(decode_with(&f, &ProcessOptions { white_balance: WhiteBalance::Custom { temperature: f64::NAN, tint: 0.0 }, ..Default::default() }).is_err()); + // the fast demosaic keeps flat fields flat and differs from the high-quality one on detail + let fast = decode_with(&f, &ProcessOptions { demosaic: DemosaicQuality::Fast, ..Default::default() }).unwrap(); + assert_eq!((fast.width, fast.height), (w, h)); + assert_ne!(fast.rgba, base.rgba); +} + +#[test] +fn process_options_serde_round_trip() { + let o = ProcessOptions { + white_balance: WhiteBalance::Custom { temperature: 5600.0, tint: -12.5 }, + exposure: 0.75, + highlights: Highlights::Recover, + demosaic: DemosaicQuality::Fast, + target_size: Some((1920, 1080)), + ..Default::default() + }; + let j = serde_json::to_string(&o).unwrap(); + assert_eq!(serde_json::from_str::(&j).unwrap(), o); + // missing fields take their defaults + assert_eq!(serde_json::from_str::("{}").unwrap(), ProcessOptions::default()); +} + +#[test] +fn positive_tint_makes_a_grey_more_magenta() { + // a grey under D65 seen by a camera whose white-balanced values are Rec. 709 + let (w, h) = (16, 12); + let px = vec![2000u16; w * h]; + let mut main = rec709_camera_tags([1.0, 1.0, 1.0]); + main.retain(|t| t.0 != tags::FORWARD_MATRIX_1); + let o = Options { width: w, height: h, bits: 12, main_tags: main, raw_tags: vec![(tags::WHITE_LEVEL, Value::Short(vec![4095]))], ..Default::default() }; + let f = write(&px, &o); + let (t, tint) = Dng::parse(&f).unwrap().as_shot_temperature_tint().unwrap().unwrap(); + assert!((t - 6504.0).abs() < 60.0 && (8.0..12.0).contains(&tint), "as shot D65: {t} K tint {tint}"); + let dev = |tint: f64| { + let img = decode_with(&f, &ProcessOptions { white_balance: WhiteBalance::Custom { temperature: t, tint }, ..Default::default() }).unwrap(); + let p = &img.rgba[(6 * w + 6) * 4..][..3]; + p[1] / (0.5 * (p[0] + p[2])) + }; + let (minus, zero, plus) = (dev(tint - 50.0), dev(tint), dev(tint + 50.0)); + assert!((zero - 1.0).abs() < 0.01, "as-shot grey stays grey: {zero}"); + assert!(plus < zero && zero < minus, "green over red/blue: {minus} {zero} {plus}"); +} + #[test] fn lossless_jpeg_frame_larger_than_its_entropy_data_is_rejected() { // a few bytes of entropy data cannot code 512 x 512 samples (a code is at least one bit) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index c0f17b8bfc..9244be10d7 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -4036,6 +4036,7 @@ fn build() -> Vec { v.extend(crate::multicam::commands()); v.extend(crate::essential_sound::commands()); v.extend(crate::color::commands()); + v.extend(crate::raw_settings::commands()); v.extend(crate::graphics::commands()); v.extend(crate::graphic_templates::commands()); v.extend(crate::shortcuts::commands()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 56d05b7089..76ce69042b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -46,6 +46,7 @@ pub mod project_manager; pub mod project_panel; pub mod project_tools; pub mod proxies; +pub mod raw_settings; pub mod relink; pub mod remix; pub mod scene_detect; @@ -1420,6 +1421,8 @@ mod project_panel_tests; #[cfg(test)] mod proxies_tests; #[cfg(test)] +mod raw_settings_tests; +#[cfg(test)] mod relink_tests; #[cfg(test)] mod remix_tests; diff --git a/crates/engine/src/media_pool.rs b/crates/engine/src/media_pool.rs index f50d0c8c1a..62d040a817 100644 --- a/crates/engine/src/media_pool.rs +++ b/crates/engine/src/media_pool.rs @@ -89,7 +89,11 @@ pub const PROBE_WHOLE_FILE_MAX: u64 = 64 * 1024 * 1024; pub fn media_key(m: &MediaClip) -> String { match &m.media { MediaRef::File { path } if m.offline => format!("offline:{path}"), - MediaRef::File { path } => format!("file:{path}"), + // camera raw settings change the pictures: a change (or its undo) opens the file again + MediaRef::File { path } => match &m.interpret.raw { + Some(r) => format!("file:{path}|raw:{r:?}"), + None => format!("file:{path}"), + }, MediaRef::Generator(g) => format!("generator:{g:?}"), } } @@ -346,6 +350,14 @@ impl MediaPool { }) { Ok(s) => { self.offline.write().unwrap_or_else(PoisonError::into_inner).remove(&item); + // Clip ▸ Source Settings… of camera raw footage + let s = match &m.interpret.raw { + Some(r) => s.with_raw_settings(r).unwrap_or_else(|| { + log::warn!("{path}: camera raw settings not applied (not camera raw, or it no longer develops)"); + s + }), + None => s, + }; self.unsupported_as_slate(item, path, s) } Err(_) if filmcraft_media::pending::is_set() => { @@ -591,6 +603,9 @@ impl MediaSource for UnsupportedAsSlate { fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { self.inner.audio_stream(stream, start, frames, sample_rate) } + fn raw_info(&self) -> Option { + self.inner.raw_info() + } } /// Consecutive failed video frames after which a monitor treats a source as damaged. A single diff --git a/crates/engine/src/project_tools.rs b/crates/engine/src/project_tools.rs index 647c1dc82c..199622b687 100644 --- a/crates/engine/src/project_tools.rs +++ b/crates/engine/src/project_tools.rs @@ -16,7 +16,7 @@ //! | `edit.editOriginal` | Edit ▸ Edit Original (⌘E): the frontend opens the files (regular files with a media extension only) | //! | `file.reveal` | Project panel ▸ Reveal in Finder / Show in Explorer: the frontend reveals the media file | //! | `clip.editOffline` | Clip ▸ Edit Offline… | -//! | `clip.sourceSettings` | Clip ▸ Source Settings… | +//! | `clip.sourceSettings` | Clip ▸ Source Settings… (camera raw; `crate::raw_settings`, placed here) | //! | `clip.restoreCaptionsFromSource` | Clip ▸ Restore Captions from Source Clip (always disabled: no embedded caption reader) | //! | `clip.updateMetadata` | Clip ▸ Update Metadata… (XMP sidecars) | //! | `clip.generateAudioWaveform` | Clip ▸ Generate Audio Waveform (the frontend rebuilds its peaks) | @@ -176,7 +176,6 @@ pub(crate) fn commands() -> Vec { has_offline_selection, edit_offline, ), - query("clip.sourceSettings", "Source Settings…", &["Clip"], None, r#"{"item":id?}"#, has_media_selection, source_settings), spec("clip.restoreCaptionsFromSource", "Restore Captions from Source Clip", &["Clip"], None, "{}", no_embedded_captions, |_, _| { Err(EngineError::Other(NO_CAPTIONS.into())) }), @@ -348,7 +347,7 @@ fn has_find(s: &Session) -> std::result::Result<(), String> { /// Project items the command works on: `items`, else the Project panel selection, else the media /// of the selected timeline clips. -fn targets(s: &Session, p: &Value) -> Vec { +pub(crate) fn targets(s: &Session, p: &Value) -> Vec { if let Some(a) = p.get("items").and_then(Value::as_array) { return a.iter().filter_map(|v| v.as_u64().map(ItemId)).filter(|i| s.project.items.contains_key(i)).collect(); } @@ -1164,19 +1163,6 @@ fn edit_offline(s: &mut Session, p: &Value) -> Result { Ok(json!({"item": id.0, "name": it.name, "metadata": it.metadata})) } -fn source_settings(s: &mut Session, p: &Value) -> Result { - let id = targets(s, p).into_iter().find(|i| media_of(&s.project, *i).is_some()).ok_or_else(|| EngineError::Other("select a clip".into()))?; - let (_, m) = media_of(&s.project, id).ok_or_else(|| EngineError::Other("select a clip".into()))?; - let codec = m.info.video.as_ref().map(|v| v.codec.clone()).or_else(|| m.info.audio().map(|a| a.codec.clone())).unwrap_or_default(); - Ok(json!({ - "item": id.0, - "codec": codec, - "container": m.info.container, - "settings": [], - "message": format!("{} has no source settings in FilmCraft. Source settings apply to camera raw formats (R3D, ARRIRAW, CinemaDNG, ProRes RAW…), which FilmCraft does not decode.", if codec.is_empty() { "This clip".to_string() } else { codec.to_uppercase() }), - })) -} - fn xml_escape(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) } diff --git a/crates/engine/src/raw_settings.rs b/crates/engine/src/raw_settings.rs new file mode 100644 index 0000000000..146dd5963d --- /dev/null +++ b/crates/engine/src/raw_settings.rs @@ -0,0 +1,258 @@ +//! Camera raw Source Settings (Clip ▸ Source Settings…): how DNG footage (stills, CinemaDNG +//! folders, CinemaDNG in MXF) is developed, per media item. Behaviour follows Premiere's Source +//! Settings for camera raw formats as Adobe's public help describes it: the settings belong to +//! the source, so every clip of the item changes, and they are one undoable change. +//! +//! - `clip.sourceSettings {items?|item?, whiteBalance?, temperature?, tint?, exposure?, +//! highlights?, demosaic?, output?, reset?}`: change the settings of the camera raw items +//! addressed (default: the Project panel selection, else the media of the selected timeline +//! clips; subclips address their media). Values left out keep each item's current one. +//! `temperature` / `tint` set a custom white balance; `whiteBalance: "asShot"` goes back to the +//! camera's. `reset: true` develops as the file says. Without any setting it changes nothing +//! and describes the first item (as `media.rawInfo`; for other footage, why it has no source +//! settings), which is what the dialog shows. +//! - `media.rawInfo {item}`: whether the item is camera raw, its settings, and the file's as-shot +//! white balance as temperature and tint (the starting point of a custom one). +//! +//! The settings are stored in the item's `Interpretation::raw` (None = as the file says); the +//! media pool opens the item again with them ([`crate::media_pool::media_key`]). + +use filmcraft_media::raw::{self, DemosaicQuality, Highlights, OutputSpace, RawSettings, WhiteBalance}; +use filmcraft_project::{ItemId, ItemKind}; +use serde_json::{Value, json}; + +use crate::commands::{CommandSpec, always, bad, bool_p, f64_p, item_p, str_p}; +use crate::{Result, Session}; + +const CMD: &str = "clip.sourceSettings"; + +/// The parameters that change settings (any of them, other than null or `reset: false`, makes +/// the command an edit). +const SETTING_KEYS: [&str; 8] = ["whiteBalance", "temperature", "tint", "exposure", "highlights", "demosaic", "output", "reset"]; + +pub(crate) fn commands() -> Vec { + vec![ + CommandSpec { + id: CMD, + label: "Source Settings…", + menu: &["Clip"], + shortcut: None, + params: r#"{"items":[id]?,"item":id?,"whiteBalance":"asShot"|"custom"?,"temperature":kelvin?,"tint":n?,"exposure":stops?,"highlights":"clip"?,"demosaic":"fast"|"high"?,"output":"sceneLinearRec709"?,"reset":bool?}"#, + enabled: has_footage, + run: source_settings, + journal: true, + }, + CommandSpec { + id: "media.rawInfo", + label: "Camera Raw Info", + menu: &[], + shortcut: None, + params: r#"{"item":id}"#, + enabled: always, + run: raw_info, + journal: false, + }, + ] +} + +/// Whether a media item is camera raw footage FilmCraft develops. +fn is_raw(s: &Session, id: ItemId) -> bool { + s.project.item(id).and_then(|i| i.as_media()).is_some_and(|m| filmcraft_media::still::dng::is_camera_raw(&m.info)) +} + +/// The media items addressed by `p` (`items`, `item`, else the Project panel selection, else the +/// selected timeline clips), subclips resolved to their media, without duplicates. +fn footage(s: &Session, p: &Value) -> Vec { + let mut v = Vec::new(); + for i in crate::project_tools::targets(s, p) { + if let Some((root, _, _)) = s.project.resolve_media(i) + && !v.contains(&root) + { + v.push(root); + } + } + v +} + +/// Enabled with footage selected; for footage that is not camera raw the dialog says why it has +/// no source settings. +fn has_footage(s: &Session) -> std::result::Result<(), String> { + if footage(s, &Value::Null).is_empty() { Err("select a clip".into()) } else { Ok(()) } +} + +/// The settings as JSON (the command's parameter names). +pub fn settings_json(r: &RawSettings) -> Value { + let (wb, temperature, tint) = match r.white_balance { + WhiteBalance::AsShot => ("asShot", None, None), + WhiteBalance::Custom { temperature, tint } => ("custom", Some(temperature), Some(tint)), + }; + json!({ + "whiteBalance": wb, + "temperature": temperature, + "tint": tint, + "exposure": r.exposure, + "highlights": match r.highlights { Highlights::Clip => "clip", Highlights::Recover => "recover" }, + "demosaic": match r.demosaic { DemosaicQuality::Fast => "fast", DemosaicQuality::High => "high" }, + "output": match r.output { OutputSpace::SceneLinearRec709 => "sceneLinearRec709" }, + }) +} + +/// A finite number parameter (None when absent; an error when present but not a number). +fn num(p: &Value, k: &str) -> Result> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(_) => f64_p(p, k).filter(|v| v.is_finite()).map(Some).ok_or_else(|| bad(CMD, format!("`{k}` must be a number"))), + } +} + +/// A string parameter (None when absent; an error when present but not a string). +fn word<'a>(p: &'a Value, k: &str) -> Result> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) => Ok(Some(s)), + Some(_) => Err(bad(CMD, format!("`{k}` must be a string"))), + } +} + +/// `cur` with the parameters of `p` applied. +fn apply(cur: RawSettings, p: &Value) -> Result { + let mut r = cur; + for k in ["whiteBalance", "highlights", "demosaic", "output"] { + word(p, k)?; + } + if p.get("reset").is_some_and(|v| !v.is_boolean()) { + return Err(bad(CMD, "`reset` must be true or false")); + } + let (temperature, tint) = (num(p, "temperature")?, num(p, "tint")?); + match str_p(p, "whiteBalance") { + None => {} + Some("asShot") => { + if temperature.is_some() || tint.is_some() { + return Err(bad(CMD, "`temperature` / `tint` need `whiteBalance: \"custom\"` (or no `whiteBalance`)")); + } + r.white_balance = WhiteBalance::AsShot; + } + Some("custom") => { + if temperature.is_none() && !matches!(r.white_balance, WhiteBalance::Custom { .. }) { + return Err(bad(CMD, "a custom white balance needs `temperature` (kelvin)")); + } + } + Some(o) => return Err(bad(CMD, format!("whiteBalance must be \"asShot\" or \"custom\", got `{o}`"))), + } + if temperature.is_some() || tint.is_some() { + let (t0, n0) = match r.white_balance { + WhiteBalance::Custom { temperature, tint } => (Some(temperature), tint), + WhiteBalance::AsShot => (None, 0.0), + }; + let t = temperature.or(t0).ok_or_else(|| bad(CMD, "a custom white balance needs `temperature` (kelvin)"))?; + r.white_balance = WhiteBalance::Custom { temperature: t, tint: tint.unwrap_or(n0) }; + } + if let Some(e) = num(p, "exposure")? { + r.exposure = e; + } + match str_p(p, "highlights") { + None => {} + Some("clip") => r.highlights = Highlights::Clip, + // the decoder accepts Recover but does not reconstruct highlights yet: refuse it rather + // than store a setting that changes nothing + Some("recover") => return Err(bad(CMD, "highlight recovery is not available yet (highlights are clipped)")), + Some(o) => return Err(bad(CMD, format!("highlights must be \"clip\", got `{o}`"))), + } + match str_p(p, "demosaic") { + None => {} + Some("fast") => r.demosaic = DemosaicQuality::Fast, + Some("high") => r.demosaic = DemosaicQuality::High, + Some(o) => return Err(bad(CMD, format!("demosaic must be \"fast\" or \"high\", got `{o}`"))), + } + match str_p(p, "output") { + None | Some("sceneLinearRec709") => {} + Some(o) => return Err(bad(CMD, format!("output colour space must be \"sceneLinearRec709\" (scene-linear Rec. 709), got `{o}`"))), + } + r.validate().map_err(|e| bad(CMD, e))?; + Ok(r) +} + +fn source_settings(s: &mut Session, p: &Value) -> Result { + if !p.is_object() && !p.is_null() { + return Err(bad(CMD, "parameters must be an object")); + } + let all = footage(s, p); + // null is unset for every key, `reset: false` too; any other value (also `false` for a number or + // a string) is set, so `apply` rejects it with a type error + let is_set = |k: &&str| p.get(*k).is_some_and(|v| !v.is_null() && !(*k == "reset" && *v == Value::Bool(false))); + if !SETTING_KEYS.iter().any(is_set) { + // describe the first item + let first = all.first().copied().ok_or_else(|| bad(CMD, "select a clip"))?; + return describe(s, first); + } + if all.is_empty() { + return Err(bad(CMD, "select a clip")); + } + let items: Vec = all.into_iter().filter(|i| is_raw(s, *i)).collect(); + if items.is_empty() { + return Err(bad(CMD, "no camera raw footage selected: source settings apply to DNG stills, CinemaDNG folders and CinemaDNG MXF")); + } + let reset = bool_p(p, "reset").unwrap_or(false); + // check every item's result before changing any + let mut new = Vec::with_capacity(items.len()); + for i in &items { + let cur = s.project.item(*i).and_then(|it| it.as_media()).and_then(|m| m.interpret.raw).unwrap_or_default(); + let r = if reset { RawSettings::default() } else { apply(cur, p)? }; + new.push((*i, (r != RawSettings::default()).then_some(r), r != cur)); + } + // nothing changes: no empty undo step + if new.iter().any(|(_, _, changed)| *changed) { + s.edit("Source Settings", |pr, _| { + for (i, r, changed) in &new { + if let (true, Some(ItemKind::Media(m))) = (*changed, pr.item_mut(*i).map(|it| &mut it.kind)) { + m.interpret.raw = *r; + } + } + Ok(()) + })?; + } + let first = new.first().and_then(|(_, r, _)| *r).unwrap_or_default(); + Ok(json!({"items": items.iter().map(|i| i.0).collect::>(), "settings": settings_json(&first)})) +} + +fn raw_info(s: &mut Session, p: &Value) -> Result { + let id = item_p(p, "item").ok_or_else(|| bad("media.rawInfo", "need `item`"))?; + let Some((root, _, _)) = s.project.resolve_media(id) else { return Err(bad("media.rawInfo", "not a media item")) }; + describe(s, root) +} + +/// The Source Settings of media item `id`: its codec and container, whether it is camera raw, and +/// for camera raw its settings, the file's as-shot white balance and the value ranges. +fn describe(s: &Session, id: ItemId) -> Result { + let Some(m) = s.project.item(id).and_then(|i| i.as_media()) else { return Err(bad(CMD, "not a media item")) }; + let codec = m.info.video.as_ref().map(|v| v.codec.clone()).or_else(|| m.info.audio().map(|a| a.codec.clone())).unwrap_or_default(); + if !filmcraft_media::still::dng::is_camera_raw(&m.info) { + let what = if codec.is_empty() { "This clip".to_string() } else { codec.to_uppercase() }; + return Ok(json!({ + "item": id.0, + "raw": false, + "codec": codec, + "container": m.info.container, + "settings": null, + "message": format!("{what} has no source settings in FilmCraft. Source settings apply to camera raw formats; FilmCraft develops DNG and CinemaDNG (folders of DNG frames and MXF). R3D, ARRIRAW, BRAW and ProRes RAW are not decoded."), + })); + } + let pixel_format = m.info.video.as_ref().map(|v| v.pixel_format.clone()).unwrap_or_default(); + let settings = m.interpret.raw.unwrap_or_default(); + // the as-shot white comes from the file (None when it is offline or has no colour calibration) + let as_shot = s.media.full_res_source(&s.project, id, &*s.services).and_then(|src| src.raw_info()).and_then(|r| r.as_shot); + Ok(json!({ + "item": id.0, + "raw": true, + "codec": codec, + "container": m.info.container, + "pixelFormat": pixel_format, + "settings": settings_json(&settings), + "asShot": as_shot.map(|(t, n)| json!({"temperature": t, "tint": n})), + "ranges": { + "temperature": [raw::TEMPERATURE_RANGE.0, raw::TEMPERATURE_RANGE.1], + "tint": [raw::TINT_RANGE.0, raw::TINT_RANGE.1], + "exposure": [-raw::MAX_EXPOSURE, raw::MAX_EXPOSURE], + }, + })) +} diff --git a/crates/engine/src/raw_settings_tests.rs b/crates/engine/src/raw_settings_tests.rs new file mode 100644 index 0000000000..ddbf59eb1e --- /dev/null +++ b/crates/engine/src/raw_settings_tests.rs @@ -0,0 +1,350 @@ +//! Camera raw Source Settings (`clip.sourceSettings`, `media.rawInfo`): CinemaDNG folders, DNG +//! stills and CinemaDNG in MXF developed with the item's settings, one undo step, saved in the +//! project; hostile parameters change nothing. + +use std::path::Path; + +use filmcraft_media::FrameRequest; +use filmcraft_media::raw::{DemosaicQuality, RawSettings, WhiteBalance}; +use filmcraft_project::ItemId; +use filmcraft_time::FrameRate; +use serde_json::{Value, json}; + +use crate::Session; +use crate::media_test_util::tmp_dir; + +const W: usize = 24; +const H: usize = 16; + +/// The sRGB matrix from CIE XYZ (D65) to linear Rec. 709 (IEC 61966-2-1): as a DNG ColorMatrix +/// with calibration illuminant D65 it makes the camera's values linear Rec. 709. +const XYZ_TO_709: [f64; 9] = [3.2406, -1.5372, -0.4986, -0.9689, 1.8758, 0.0415, 0.0557, -0.2040, 1.0570]; + +/// A CinemaDNG frame: 24 × 16 RGGB, 12-bit lossless JPEG, a flat neutral grey at `level` shot +/// under D65 (as-shot neutral 1, 1, 1), 24 fps. +fn frame(level: f64) -> Vec { + use filmcraft_dng::tiff::tags; + use filmcraft_dng::writer::{Compression, Options, Value, write}; + let px: Vec = (0..W * H).map(|_| (level * 4095.0).round() as u16).collect(); + let o = Options { + width: W, + height: H, + bits: 12, + compression: Compression::LosslessJpeg { predictor: 1, components: 2, restart_lines: 0, pt: 0 }, + raw_tags: vec![(tags::WHITE_LEVEL, Value::Short(vec![4095]))], + main_tags: vec![ + (tags::COLOR_MATRIX_1, Value::srationals(&XYZ_TO_709)), + (tags::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21])), + (tags::AS_SHOT_NEUTRAL, Value::rationals(&[1.0, 1.0, 1.0])), + (tags::FRAME_RATE, Value::SRational(vec![(24, 1)])), + ], + ..Default::default() + }; + write(&px, &o) +} + +fn level(i: usize) -> f64 { + 0.1 + 0.15 * i as f64 +} + +fn demo() -> Session { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s +} + +fn item_of(r: &Value) -> ItemId { + ItemId(r["items"][0].as_u64().unwrap_or_else(|| panic!("{r}"))) +} + +/// Import a three-frame CinemaDNG folder. +fn import_folder(s: &mut Session, dir: &Path) -> ItemId { + let paths: Vec = (0..3) + .map(|i| { + let p = dir.join(format!("A001_C003_{i:06}.dng")); + std::fs::write(&p, frame(level(i))).unwrap(); + p.to_string_lossy().to_string() + }) + .collect(); + item_of(&s.execute("file.import", json!({"paths": [paths[0]], "imageSequence": true})).unwrap()) +} + +/// The (red, green, blue) at the centre of frame `i`, through the media pool as the renderer +/// reads it. +fn rgb(s: &Session, item: ItemId, i: i64) -> [f32; 3] { + let src = s.media.source_for(&s.project, item, &*s.services).unwrap(); + let f = src.video_frame(FrameRequest::full(FrameRate::FPS_24.tick_of(i))).unwrap(); + let filmcraft_frame::PixelData::RgbaF32(px) = &f.data else { panic!("linear float frames") }; + let at = ((H / 2) * f.width as usize + W / 2) * 4; + [px[at], px[at + 1], px[at + 2]] +} + +fn raw_of(s: &Session, item: ItemId) -> Option { + s.project.item(item).unwrap().as_media().unwrap().interpret.raw +} + +#[test] +fn exposure_changes_every_frame_and_is_one_undo_step() { + let dir = tmp_dir("raw-exposure"); + let mut s = demo(); + let id = import_folder(&mut s, &dir); + let before: Vec<[f32; 3]> = (0..3).map(|i| rgb(&s, id, i)).collect(); + for (i, c) in before.iter().enumerate() { + assert!((c[0] - level(i) as f32).abs() < 2e-3 && (c[2] - level(i) as f32).abs() < 2e-3, "frame {i}: {c:?}"); + } + let undo_depth = s.history.undo.len(); + let r = s.execute("clip.sourceSettings", json!({"items": [id.0], "exposure": 1.0})).unwrap(); + assert_eq!(r["settings"]["exposure"], 1.0); + assert_eq!(s.history.undo.len(), undo_depth + 1); + assert_eq!(s.history.undo.last().unwrap().0, "Source Settings"); + assert_eq!(raw_of(&s, id).unwrap().exposure, 1.0); + for (i, c) in before.iter().enumerate() { + let now = rgb(&s, id, i as i64); + for k in 0..3 { + assert!((now[k] - 2.0 * c[k]).abs() < 3e-3, "frame {i} channel {k}: {} vs {}", now[k], c[k]); + } + } + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(raw_of(&s, id), None); + assert!((rgb(&s, id, 2)[1] - before[2][1]).abs() < 1e-6, "undo develops as the file says again"); + s.execute("edit.redo", json!({})).unwrap(); + assert!((rgb(&s, id, 2)[1] - 2.0 * before[2][1]).abs() < 3e-3); + // reset goes back to the file's development and stores nothing + s.execute("clip.sourceSettings", json!({"items": [id.0], "reset": true})).unwrap(); + assert_eq!(raw_of(&s, id), None); + assert!((rgb(&s, id, 1)[1] - before[1][1]).abs() < 1e-6); +} + +#[test] +fn an_edit_that_changes_nothing_records_no_undo_step() { + let dir = tmp_dir("raw-noop"); + let mut s = demo(); + let id = import_folder(&mut s, &dir); + let depth = s.history.undo.len(); + // null and `reset: false` are unset: the command only describes the item + let r = s.execute("clip.sourceSettings", json!({"items": [id.0], "whiteBalance": null, "reset": false})).unwrap(); + assert_eq!(r["raw"], true); + // the settings the item already has, and a reset of an item without settings + s.execute("clip.sourceSettings", json!({"items": [id.0], "exposure": 0.0, "demosaic": "high"})).unwrap(); + s.execute("clip.sourceSettings", json!({"items": [id.0], "reset": true})).unwrap(); + assert_eq!(s.history.undo.len(), depth); + assert_eq!(raw_of(&s, id), None); + // a real change is still one step, and repeating it is not another + s.execute("clip.sourceSettings", json!({"items": [id.0], "exposure": 1.0})).unwrap(); + s.execute("clip.sourceSettings", json!({"items": [id.0], "exposure": 1.0})).unwrap(); + assert_eq!(s.history.undo.len(), depth + 1); +} + +#[test] +fn false_is_not_unset_for_value_parameters() { + let dir = tmp_dir("raw-false"); + let mut s = demo(); + let id = import_folder(&mut s, &dir); + let depth = s.history.undo.len(); + for k in ["exposure", "temperature", "tint", "whiteBalance", "highlights", "demosaic", "output"] { + assert!(s.execute("clip.sourceSettings", json!({"items": [id.0], k: false})).is_err(), "{k}: false"); + } + assert_eq!(s.history.undo.len(), depth); +} + +#[test] +fn an_item_that_already_has_the_settings_is_left_alone_in_a_multi_item_edit() { + let dir = tmp_dir("raw-multi"); + let mut s = demo(); + let a = import_folder(&mut s, &dir); + let dir2 = tmp_dir("raw-multi2"); + let b = import_folder(&mut s, &dir2); + s.execute("clip.sourceSettings", json!({"items": [a.0], "exposure": 1.0})).unwrap(); + let depth = s.history.undo.len(); + // `a` already has this exposure, `b` does not: one step, and only `b` changes + s.execute("clip.sourceSettings", json!({"items": [a.0, b.0], "exposure": 1.0})).unwrap(); + assert_eq!(s.history.undo.len(), depth + 1); + assert_eq!(raw_of(&s, a).unwrap().exposure, 1.0); + assert_eq!(raw_of(&s, b).unwrap().exposure, 1.0); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(raw_of(&s, a).unwrap().exposure, 1.0); + assert_eq!(raw_of(&s, b), None); +} + +#[test] +fn white_balance_from_the_as_shot_temperature() { + let dir = tmp_dir("raw-wb"); + let mut s = demo(); + let id = import_folder(&mut s, &dir); + let info = s.execute("media.rawInfo", json!({"item": id.0})).unwrap(); + assert_eq!(info["raw"], true); + assert_eq!(info["settings"]["whiteBalance"], "asShot"); + // shot under D65: about 6500 K; D65 is slightly green of the Planckian locus (Δuv +0.003), so + // the as-shot tint compensates with magenta (positive tint = a more magenta picture) + let t = info["asShot"]["temperature"].as_f64().unwrap(); + let tint = info["asShot"]["tint"].as_f64().unwrap(); + assert!((t - 6504.0).abs() < 60.0 && (7.0..12.0).contains(&tint), "{t} K, tint {tint}"); + // the dialog's description is the same + let d = s.execute("clip.sourceSettings", json!({"item": id.0})).unwrap(); + assert_eq!((d["raw"].clone(), d["asShot"].clone(), d["codec"].clone()), (json!(true), info["asShot"].clone(), json!("DNG sequence"))); + assert_eq!(d["pixelFormat"], "Bayer 12-bit raw (lossless JPEG)"); + let grey = rgb(&s, id, 1); + // the as-shot values as a custom white balance develop the same picture + s.execute("clip.sourceSettings", json!({"items": [id.0], "temperature": t, "tint": tint})).unwrap(); + assert!(matches!(raw_of(&s, id).unwrap().white_balance, WhiteBalance::Custom { .. })); + let same = rgb(&s, id, 1); + for k in 0..3 { + assert!((same[k] - grey[k]).abs() < 0.01 * grey[k].max(0.01), "channel {k}: {same:?} vs {grey:?}"); + } + // a tungsten white balance on daylight footage: the grey turns blue + s.execute("clip.sourceSettings", json!({"items": [id.0], "whiteBalance": "custom", "temperature": 3200})).unwrap(); + let warm = rgb(&s, id, 1); + assert!(warm[2] > 1.3 * warm[0], "3200 K on D65 footage: {warm:?}"); + // tint towards magenta lowers green + s.execute("clip.sourceSettings", json!({"items": [id.0], "temperature": t, "tint": 60})).unwrap(); + let magenta = rgb(&s, id, 1); + assert!(magenta[1] < 0.95 * magenta[0] && magenta[1] < 0.95 * magenta[2], "{magenta:?}"); + // back to the camera's + s.execute("clip.sourceSettings", json!({"items": [id.0], "whiteBalance": "asShot"})).unwrap(); + assert_eq!(raw_of(&s, id), None); + let back = rgb(&s, id, 1); + for k in 0..3 { + assert!((back[k] - grey[k]).abs() < 1e-6); + } +} + +#[test] +fn demosaic_quality_and_reduced_resolution_requests() { + let dir = tmp_dir("raw-demosaic"); + let mut s = demo(); + let id = import_folder(&mut s, &dir); + let high = rgb(&s, id, 0); + s.execute("clip.sourceSettings", json!({"items": [id.0], "demosaic": "fast"})).unwrap(); + assert_eq!(raw_of(&s, id).unwrap().demosaic, DemosaicQuality::Fast); + let fast = rgb(&s, id, 0); + for k in 0..3 { + assert!((fast[k] - high[k]).abs() < 2e-3, "a flat field is the same with either demosaic"); + } + // half-resolution playback asks the source for a smaller picture; it is never smaller than asked + let src = s.media.source_for(&s.project, id, &*s.services).unwrap(); + let half = src.video_frame(FrameRequest { time: filmcraft_time::Tick::ZERO, scale: 0.5 }).unwrap(); + assert!((12..24).contains(&half.width) && (8..16).contains(&half.height), "{}x{}", half.width, half.height); +} + +#[test] +fn settings_apply_to_dng_stills_and_cinemadng_mxf() { + let dir = tmp_dir("raw-still-mxf"); + let mut s = demo(); + // a single DNG is a still + let still = dir.join("still.dng"); + std::fs::write(&still, frame(0.25)).unwrap(); + let sid = item_of(&s.execute("file.import", json!({"paths": [still.to_string_lossy()]})).unwrap()); + // CinemaDNG in MXF (CinemaDNG specification §7), written by our MXF writer + let rate = filmcraft_mxf::Rational::new(24, 1); + let mut cfg = filmcraft_mxf::WriterConfig::new(filmcraft_mxf::Pattern::Op1a, rate, filmcraft_mxf::PackageIds::from_seed("raw", "A001_C004")); + cfg.picture = Some(filmcraft_mxf::PictureDesc::new(filmcraft_mxf::PictureCoding::CinemaDng { linear_raw: false }, W as u32, H as u32)); + let mut w = filmcraft_mxf::MxfWriter::new(std::io::Cursor::new(Vec::new()), cfg).unwrap(); + for i in 0..3 { + w.push_picture(&frame(level(i)), filmcraft_mxf::FrameInfo::intra()).unwrap(); + } + let mxf = dir.join("A001_C004.mxf"); + std::fs::write(&mxf, w.finish().unwrap().into_inner()).unwrap(); + let mid = item_of(&s.execute("file.import", json!({"paths": [mxf.to_string_lossy()]})).unwrap()); + assert_eq!(s.project.item(mid).unwrap().as_media().unwrap().info.video.as_ref().unwrap().codec, "CinemaDNG"); + let (s0, m0) = (rgb(&s, sid, 0), rgb(&s, mid, 2)); + assert!((m0[1] - level(2) as f32).abs() < 2e-3, "{m0:?}"); + // both in one command: one undo step + s.execute("clip.sourceSettings", json!({"items": [sid.0, mid.0], "exposure": -1.0})).unwrap(); + assert!((rgb(&s, sid, 0)[1] - 0.5 * s0[1]).abs() < 2e-3); + assert!((rgb(&s, mid, 2)[1] - 0.5 * m0[1]).abs() < 2e-3); + assert_eq!(s.execute("media.rawInfo", json!({"item": mid.0})).unwrap()["settings"]["exposure"], -1.0); + s.execute("edit.undo", json!({})).unwrap(); + assert!((rgb(&s, sid, 0)[1] - s0[1]).abs() < 1e-6 && (rgb(&s, mid, 2)[1] - m0[1]).abs() < 1e-6); +} + +#[test] +fn settings_are_saved_with_the_project() { + let dir = tmp_dir("raw-save"); + let mut s = demo(); + let id = import_folder(&mut s, &dir); + s.execute("clip.sourceSettings", json!({"items": [id.0], "exposure": 0.5, "temperature": 5000, "tint": -10, "demosaic": "fast"})).unwrap(); + let want = raw_of(&s, id).unwrap(); + assert_eq!( + want, + RawSettings { + white_balance: WhiteBalance::Custom { temperature: 5000.0, tint: -10.0 }, + exposure: 0.5, + demosaic: DemosaicQuality::Fast, + ..Default::default() + } + ); + let bright = rgb(&s, id, 1); + let path = dir.join("raw.fcproj").to_string_lossy().to_string(); + s.execute("file.saveAs", json!({"path": path})).unwrap(); + let mut t = Session::default(); + t.execute("file.open", json!({"path": path})).unwrap(); + assert_eq!(raw_of(&t, id), Some(want)); + let again = rgb(&t, id, 1); + for k in 0..3 { + assert!((again[k] - bright[k]).abs() < 1e-6); + } +} + +#[test] +fn hostile_parameters_change_nothing() { + let dir = tmp_dir("raw-hostile"); + let mut s = demo(); + let id = import_folder(&mut s, &dir); + s.execute("clip.sourceSettings", json!({"items": [id.0], "exposure": 0.25})).unwrap(); + let project = s.project.clone(); + let undo = s.history.undo.len(); + let bad = [ + json!({"items": [id.0], "exposure": 9.0}), + json!({"items": [id.0], "exposure": -1e308}), + json!({"items": [id.0], "exposure": "bright"}), + json!({"items": [id.0], "temperature": 100}), + json!({"items": [id.0], "temperature": 1e9}), + json!({"items": [id.0], "temperature": 5000, "tint": 151}), + json!({"items": [id.0], "tint": 10}), + json!({"items": [id.0], "whiteBalance": "custom"}), + json!({"items": [id.0], "whiteBalance": "asShot", "temperature": 5000}), + json!({"items": [id.0], "whiteBalance": "daylight"}), + json!({"items": [id.0], "highlights": "recover"}), + json!({"items": [id.0], "highlights": 3}), + json!({"items": [id.0], "demosaic": "ultra"}), + json!({"items": [id.0], "output": "rec2020"}), + json!({"items": [999_999], "exposure": 1.0}), + json!({"items": "all", "exposure": 1.0}), + json!({"items": [id.0], "reset": "yes"}), + json!([1, 2]), + ]; + for p in bad { + assert!(s.execute("clip.sourceSettings", p.clone()).is_err(), "{p} was accepted"); + assert!(std::sync::Arc::ptr_eq(&project, &s.project) || *project == *s.project, "{p} changed the project"); + assert_eq!(s.history.undo.len(), undo, "{p} added an undo step"); + } + assert!(s.execute("media.rawInfo", json!({})).is_err()); + assert!(s.execute("media.rawInfo", json!({"item": 999_999})).is_err()); +} + +#[test] +fn only_camera_raw_footage_has_source_settings() { + let mut s = demo(); + // the demo's footage is generated, not camera raw: the dialog says why, settings are refused + let demo_item = s.project.items.values().find(|i| i.as_media().is_some()).unwrap().id; + let info = s.execute("media.rawInfo", json!({"item": demo_item.0})).unwrap(); + assert_eq!((info["raw"].clone(), info["settings"].clone()), (json!(false), Value::Null)); + assert!(info["message"].as_str().unwrap().contains("no source settings"), "{info}"); + let undo = s.history.undo.len(); + assert!(s.execute("clip.sourceSettings", json!({"items": [demo_item.0], "exposure": 1.0})).is_err()); + assert_eq!(s.history.undo.len(), undo); + let c = crate::find_command("clip.sourceSettings").unwrap(); + s.state.project_selection = vec![]; + s.state.selection.clear(); + assert!((c.enabled)(&s).is_err(), "nothing selected"); + s.state.project_selection = vec![demo_item]; + assert!((c.enabled)(&s).is_ok()); + assert_eq!(c.menu, &["Clip"]); + // selected raw footage enables it, and the selection is the default target + let dir = tmp_dir("raw-enabled"); + let id = import_folder(&mut s, &dir); + s.state.project_selection = vec![id]; + assert!((c.enabled)(&s).is_ok()); + s.execute("clip.sourceSettings", json!({"exposure": 0.5})).unwrap(); + assert_eq!(raw_of(&s, id).unwrap().exposure, 0.5); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index d616d5e19c..7d46e15568 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -41,6 +41,9 @@ //! would drop them when saving. //! - **v16**: marker authors, named project swatches, and exact marker/clip hex colors. //! - **v17**: optional marker URLs, independent of notes and retained across marker-type changes. +//! - **v18**: camera raw Source Settings (`Interpretation::raw`: white balance, exposure, +//! highlights, demosaic quality, output colour space of DNG / CinemaDNG footage). No-op step; +//! older builds would drop them when saving. #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] @@ -63,7 +66,7 @@ pub type Migration = fn(Value) -> Result; /// edit a shipped one. pub const MIGRATIONS: &[Migration] = &[ v1_to_v2, v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, v6_to_v7, v7_to_v8, v8_to_v9, v9_to_v10, v10_to_v11, v11_to_v12, v12_to_v13, v13_to_v14, v14_to_v15, - v15_to_v16, v16_to_v17, + v15_to_v16, v16_to_v17, v17_to_v18, ]; /// The schema version this build writes (and the newest it reads). @@ -406,13 +409,19 @@ fn v16_to_v17(doc: Value) -> Result { Ok(doc) } +/// v17 → v18: camera raw Source Settings on media items (`Interpretation::raw`, absent = as the +/// file says). Existing data needs no change. +fn v17_to_v18(doc: Value) -> Result { + Ok(doc) +} + #[cfg(test)] mod tests { use super::*; #[test] fn schema_version_matches_table() { - assert_eq!(SCHEMA_VERSION, 17); + assert_eq!(SCHEMA_VERSION, 18); } #[test] diff --git a/crates/format/tests/fixtures.rs b/crates/format/tests/fixtures.rs index d92a474a0e..79ccb56d6d 100644 --- a/crates/format/tests/fixtures.rs +++ b/crates/format/tests/fixtures.rs @@ -277,6 +277,43 @@ fn v10_minimal_loads_with_m3_11_defaults() { assert_eq!(&again.project, p); } +/// Schema 15 (before camera raw Source Settings): a DNG sequence item whose Interpret Footage +/// has no `raw` settings loads with none (developed as the file says) and resaves unchanged. +#[test] +fn v15_dng_item_loads_without_raw_settings() { + let l = decode(&fixture("v15-dng.fcproj")).unwrap(); + assert_eq!(l.schema_version, 15); + assert!(l.migrated()); + let p = &l.project; + assert_eq!(p.name, "Before Raw Settings"); + let m = p.item(filmcraft_project::ItemId(1)).and_then(|i| i.as_media()).unwrap(); + assert_eq!(m.interpret.raw, None); + assert_eq!(m.interpret.par, Some((1, 1))); + assert_eq!(m.info.video.as_ref().unwrap().codec, "DNG sequence"); + let again = decode(&encode(p, true)).unwrap(); + assert_eq!(again.schema_version, SCHEMA_VERSION); + const { assert!(SCHEMA_VERSION >= 18) }; + assert_eq!(&again.project, p); + // no settings: nothing new is written + assert!(!String::from_utf8(encode(p, false)).unwrap().contains("\"raw\"")); +} + +/// Raw settings round-trip through the current schema. +#[test] +fn raw_settings_roundtrip() { + use filmcraft_media::raw::{DemosaicQuality, RawSettings, WhiteBalance}; + let mut p = decode(&fixture("v15-dng.fcproj")).unwrap().project; + let s = RawSettings { + white_balance: WhiteBalance::Custom { temperature: 3200.0, tint: 12.5 }, + exposure: -0.75, + demosaic: DemosaicQuality::Fast, + ..Default::default() + }; + p.item_mut(filmcraft_project::ItemId(1)).and_then(|i| i.as_media_mut()).unwrap().interpret.raw = Some(s); + let l = decode(&encode(&p, false)).unwrap(); + assert_eq!(l.project.item(filmcraft_project::ItemId(1)).and_then(|i| i.as_media()).unwrap().interpret.raw, Some(s)); +} + /// #2809: `next_id` is read from the file, and nothing raised it past the ids already in it. A file /// whose `next_id` lagged behind (an older build, a hand edit, a merge) handed out an id in use, and /// the new item silently replaced the old one. Each kind of id is raised past on its own, so a kind diff --git a/crates/format/tests/fixtures/v15-dng.fcproj b/crates/format/tests/fixtures/v15-dng.fcproj new file mode 100644 index 0000000000..72f46187d9 --- /dev/null +++ b/crates/format/tests/fixtures/v15-dng.fcproj @@ -0,0 +1 @@ +{"format":"filmcraft.project","generator":"FilmCraft 0.6.0","project":{"items":{"1":{"created":1,"id":1,"kind":{"Media":{"info":{"audio_streams":[],"container":"Image Sequence","duration":31752000000,"file_size":null,"kind":"ImageSequence","name":"A001_C001_0000.dng","start_timecode":864000,"video":{"bitrate":null,"codec":"DNG sequence","color":{"matrix":"Bt709","primaries":"Bt709","range":"Full","transfer":"Linear"},"frame_rate":{"den":1,"num":24},"has_alpha":false,"height":12,"par":[1,1],"pixel_format":"Bayer 12-bit raw (lossless JPEG)","width":16}},"interpret":{"frame_rate":null,"ignore_alpha":false,"invert_alpha":false,"par":[1,1]},"mark_in":null,"mark_out":null,"markers":[],"media":{"File":{"path":"/cards/A001_C001/A001_C001_0000.dng"}},"offline":false,"proxy":null}},"label":"Iris","metadata":{},"name":"A001_C001"}},"name":"Before Raw Settings","next_id":2,"root":{"children":[{"Item":1}],"id":0,"name":"Before Raw Settings"},"settings":{"action_safe":[10.0,10.0],"audio_display_samples":true,"capture_format":"DV","default_audio_transition_duration":254016000000,"default_still_duration":1270080000000,"default_transition_duration_frames":24,"ingest":{"action":"createProxies","destination":null,"enabled":false,"preset":""},"renderer":"FilmCraft GPU Acceleration (wgpu)","scratch":{"audioPreviews":null,"autoSave":null,"captured":null,"videoPreviews":null},"title_safe":[20.0,20.0],"video_display":"Timecode"}},"schema_version":15} \ No newline at end of file diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 3259e502f4..2f77aa1a9d 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -13,7 +13,7 @@ workspace = true filmcraft-time = { workspace = true } filmcraft-color = { workspace = true } filmcraft-frame = { workspace = true } -filmcraft-dng = { workspace = true } +filmcraft-dng = { workspace = true, features = ["serde"] } rayon = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } @@ -21,6 +21,7 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg", [dev-dependencies] filmcraft-testkit = { workspace = true } -filmcraft-dng = { workspace = true, features = ["writer"] } +filmcraft-dng = { workspace = true, features = ["writer", "serde"] } +serde_json = { workspace = true } # APNG fixtures for the animated-image tests (the image crate does not encode APNG) png = "0.18" diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 7b48ae3b46..267582d049 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -16,6 +16,7 @@ pub mod digits; pub mod dpx; pub mod generators; pub mod pending; +pub mod raw; pub mod reader; pub mod sequence; pub mod still; @@ -225,6 +226,16 @@ pub trait MediaSource: Send + Sync { fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> Result { if stream == 0 { self.audio(start, frames, sample_rate) } else { Err(MediaError::NoStream("audio")) } } + /// Camera raw sources (DNG stills and sequences, CinemaDNG in MXF): the as-shot white balance + /// and the settings in use. None for every other source. + fn raw_info(&self) -> Option { + None + } + /// Camera raw sources: the same media developed with `settings` (a new source; this one is + /// unchanged). None for every other source. + fn with_raw_settings(&self, _settings: &raw::RawSettings) -> Option { + None + } } pub type SharedSource = Arc; @@ -261,6 +272,10 @@ pub fn open_bytes(name: &str, bytes: Arc<[u8]>, extra: &[Opener]) -> Result filmcraft_dng::ProcessOptions { + filmcraft_dng::ProcessOptions { + white_balance: self.white_balance, + exposure: if self.exposure.is_finite() { self.exposure.clamp(-MAX_EXPOSURE, MAX_EXPOSURE) } else { 0.0 }, + highlights: self.highlights, + demosaic: self.demosaic, + output: self.output, + target_size: target_size(width, height, level), + } + } + + /// Whether every value is in range (finite, exposure within ±[`MAX_EXPOSURE`], white balance + /// within [`TEMPERATURE_RANGE`] and [`TINT_RANGE`]). + pub fn validate(&self) -> Result<(), String> { + if !self.exposure.is_finite() || self.exposure.abs() > MAX_EXPOSURE { + return Err(format!("exposure must be between -{MAX_EXPOSURE} and {MAX_EXPOSURE} stops")); + } + if let WhiteBalance::Custom { temperature, tint } = self.white_balance { + if !temperature.is_finite() || temperature < TEMPERATURE_RANGE.0 || temperature > TEMPERATURE_RANGE.1 { + return Err(format!("temperature must be between {} and {} K", TEMPERATURE_RANGE.0, TEMPERATURE_RANGE.1)); + } + if !tint.is_finite() || tint < TINT_RANGE.0 || tint > TINT_RANGE.1 { + return Err(format!("tint must be between {} and {}", TINT_RANGE.0, TINT_RANGE.1)); + } + } + Ok(()) + } +} + +/// What a camera raw source reports for its Source Settings. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct RawInfo { + /// The as-shot white balance as (temperature in kelvin, tint), where the file has the colour + /// calibration to say (the starting point of a custom white balance). + pub as_shot: Option<(f64, f64)>, + /// The settings the source develops with. + pub settings: RawSettings, +} + +/// The reduced-resolution level for a request at `scale` of full size: 0 = full resolution, `k` = +/// 1 / 2^k (at most 1/16), the smallest power-of-two fraction that still covers `scale`. +pub fn scale_level(scale: f32) -> u8 { + if !scale.is_finite() || scale >= 1.0 { + return 0; + } + let mut k = 0u8; + while k < 4 && scale <= 1.0 / f32::from(1u8 << (k + 1)) { + k += 1; + } + k +} + +/// The smallest picture the decoder must make at `level` of a `width` × `height` picture (None = +/// full resolution). +pub fn target_size(width: u32, height: u32, level: u8) -> Option<(usize, usize)> { + if level == 0 { + return None; + } + let d = 1u32 << level.min(4); + Some((width.div_ceil(d).max(1) as usize, height.div_ceil(d).max(1) as usize)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn levels_cover_the_request() { + assert_eq!(scale_level(1.0), 0); + assert_eq!(scale_level(2.0), 0); + assert_eq!(scale_level(f32::NAN), 0); + assert_eq!(scale_level(0.75), 0); + assert_eq!(scale_level(0.5), 1); + assert_eq!(scale_level(0.3), 1); + assert_eq!(scale_level(0.25), 2); + assert_eq!(scale_level(0.01), 4); + assert_eq!(scale_level(-1.0), 4); + assert_eq!(target_size(4096, 2160, 0), None); + assert_eq!(target_size(4096, 2160, 1), Some((2048, 1080))); + assert_eq!(target_size(15, 9, 2), Some((4, 3))); + assert_eq!(target_size(1, 1, 9), Some((1, 1))); + } + + #[test] + fn settings_map_to_decoder_options() { + let s = RawSettings { exposure: 1.5, demosaic: DemosaicQuality::Fast, ..Default::default() }; + let o = s.options(100, 50, 1); + assert_eq!((o.exposure, o.demosaic, o.target_size), (1.5, DemosaicQuality::Fast, Some((50, 25)))); + assert_eq!(RawSettings::default().options(8, 8, 0), filmcraft_dng::ProcessOptions::default()); + // hostile values never reach the decoder + assert_eq!(RawSettings { exposure: f64::INFINITY, ..Default::default() }.options(8, 8, 0).exposure, 0.0); + assert_eq!(RawSettings { exposure: 99.0, ..Default::default() }.options(8, 8, 0).exposure, MAX_EXPOSURE); + } + + #[test] + fn validation() { + assert!(RawSettings::default().validate().is_ok()); + assert!(RawSettings { exposure: f64::NAN, ..Default::default() }.validate().is_err()); + assert!(RawSettings { exposure: -9.0, ..Default::default() }.validate().is_err()); + let wb = |temperature, tint| RawSettings { white_balance: WhiteBalance::Custom { temperature, tint }, ..Default::default() }; + assert!(wb(5600.0, 10.0).validate().is_ok()); + assert!(wb(500.0, 0.0).validate().is_err()); + assert!(wb(5600.0, 151.0).validate().is_err()); + assert!(wb(f64::NAN, 0.0).validate().is_err()); + } + + #[test] + fn serde_defaults_and_shape() { + let s: RawSettings = serde_json::from_str("{}").unwrap(); + assert_eq!(s, RawSettings::default()); + let s = RawSettings { white_balance: WhiteBalance::Custom { temperature: 3200.0, tint: -5.0 }, exposure: 0.5, ..Default::default() }; + let j = serde_json::to_string(&s).unwrap(); + assert_eq!(serde_json::from_str::(&j).unwrap(), s); + } +} diff --git a/crates/media/src/sequence.rs b/crates/media/src/sequence.rs index b915d1481e..938ce1ff1c 100644 --- a/crates/media/src/sequence.rs +++ b/crates/media/src/sequence.rs @@ -9,13 +9,16 @@ //! nearest later frame before the first). //! - The frame rate is the caller's (Settings ▸ Media ▸ Indeterminate Media Timebase), except for //! CinemaDNG: a frame rate and start time code in the first frame's tags are the sequence's. +//! - DNG frames are developed with the item's camera raw settings ([`crate::raw`]), at reduced +//! resolution when the request's scale allows. use std::sync::Arc; use filmcraft_frame::{AudioBuffer, VideoFrame}; use filmcraft_time::FrameRate; -use crate::{FrameCache, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result, VideoStreamInfo}; +use crate::raw::{RawInfo, RawSettings}; +use crate::{FrameCache, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result, SharedSource, VideoStreamInfo}; /// Reads a file's bytes (the host's file access). pub type FrameLoader = Arc std::io::Result> + Send + Sync>; @@ -125,18 +128,27 @@ pub struct ImageSequenceSource { info: MediaInfo, frames: Vec>, loader: FrameLoader, - cache: FrameCache, + /// Developed frames by (index, reduced-resolution level; 0 unless camera raw). + cache: FrameCache<(usize, u8)>, + /// DNG frames: the raw settings they are developed with and the first frame's as-shot white. + raw: Option, } impl ImageSequenceSource { /// `frames`: path per frame (`None`: missing). The first present frame is decoded now (size, /// alpha); the rest on demand. pub fn new(name: &str, frames: Vec>, rate: FrameRate, loader: FrameLoader) -> Result { + Self::with_settings(name, frames, rate, loader, RawSettings::default()) + } + + /// [`Self::new`] developing DNG frames with raw `settings` (other formats ignore them). + pub fn with_settings(name: &str, frames: Vec>, rate: FrameRate, loader: FrameLoader, settings: RawSettings) -> Result { let first = frames.iter().flatten().next().ok_or_else(|| MediaError::Offline(format!("{name}: no frames")))?.clone(); let bytes = loader(&first).map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { MediaError::Offline(format!("{first}: {e}")) } else { MediaError::Io(format!("{first}: {e}")) } })?; - let (frame, has_alpha, pixel_format, timing) = decode_frame(&first, &bytes)?; + let (frame, has_alpha, pixel_format, timing) = decode_frame(&first, &bytes, &settings, 0)?; + let raw = timing.is_some().then(|| RawInfo { as_shot: crate::still::dng::as_shot(&bytes), settings }); let (w, h) = (frame.width, frame.height); let (rate, start_timecode) = match timing { Some(info) => crate::still::dng::sequence_timing(&info, rate), @@ -167,8 +179,8 @@ impl ImageSequenceSource { file_size: None, }; let cache = FrameCache::new(256 << 20); - cache.insert(frames.iter().position(Option::is_some).unwrap_or(0), Arc::new(frame)); - Ok(Self { info, frames, loader, cache }) + cache.insert((frames.iter().position(Option::is_some).unwrap_or(0), 0), Arc::new(frame)); + Ok(Self { info, frames, loader, cache, raw }) } pub fn frame_count(&self) -> usize { @@ -185,14 +197,16 @@ impl ImageSequenceSource { self.frames.get(i).and_then(|f| f.as_deref()) } - fn decode(&self, i: usize) -> Result> { - if let Some(f) = self.cache.get(&i) { + fn decode(&self, i: usize, level: u8) -> Result> { + let level = if self.raw.is_some() { level } else { 0 }; + if let Some(f) = self.cache.get(&(i, level)) { return Ok(f); } let path = self.frames.get(i).and_then(|f| f.as_deref()).ok_or(MediaError::NoStream("video"))?; let bytes = (self.loader)(path).map_err(|e| MediaError::Io(format!("{path}: {e}")))?; - let f = Arc::new(decode_frame(path, &bytes)?.0); - self.cache.insert(i, f.clone()); + let settings = self.raw.map(|r| r.settings).unwrap_or_default(); + let f = Arc::new(decode_frame(path, &bytes, &settings, level)?.0); + self.cache.insert((i, level), f.clone()); Ok(f) } } @@ -208,8 +222,9 @@ impl MediaSource for ImageSequenceSource { // the wanted frame, else the nearest earlier present frame, else the nearest later one let candidates = (0..=want).rev().chain(want + 1..n); let mut last_err = None; + let level = crate::raw::scale_level(req.scale); for i in candidates.filter(|&i| self.frames[i].is_some()).take(8) { - match self.decode(i) { + match self.decode(i, level) { Ok(f) => return Ok(f), Err(e) => last_err = Some(e), } @@ -220,12 +235,22 @@ impl MediaSource for ImageSequenceSource { fn audio(&self, _start: i64, _frames: usize, _sample_rate: u32) -> Result { Err(MediaError::NoStream("audio")) } + + fn raw_info(&self) -> Option { + self.raw + } + + fn with_raw_settings(&self, settings: &RawSettings) -> Option { + self.raw?; + let s = Self::with_settings(&self.info.name, self.frames.clone(), self.info.frame_rate(), self.loader.clone(), *settings).ok()?; + Some(Arc::new(s)) + } } /// Decode one frame file: OpenEXR, Radiance HDR and DNG camera raw as linear floats (DNG with its metadata, for /// the sequence's timing), every other format through the still decoders as RGBA8. Returns the /// frame, whether it has alpha, and its pixel format for the media info. -fn decode_frame(name: &str, bytes: &[u8]) -> Result<(VideoFrame, bool, String, Option)> { +fn decode_frame(name: &str, bytes: &[u8], settings: &RawSettings, level: u8) -> Result<(VideoFrame, bool, String, Option)> { if crate::still::exr::sniff(bytes) { let d = crate::still::exr::decode(name, bytes)?; return Ok((d.frame, d.has_alpha, d.pixel_format, None)); @@ -235,7 +260,7 @@ fn decode_frame(name: &str, bytes: &[u8]) -> Result<(VideoFrame, bool, String, O return Ok((frame, false, "RGB 32-bit float (RGBE)".to_string(), None)); } if crate::still::dng::sniff(bytes) { - let d = crate::still::dng::decode(name, bytes)?; + let d = crate::still::dng::decode_with(name, bytes, settings, level)?; return Ok((d.frame, false, d.pixel_format, Some(d.info))); } let img = crate::still::decode_raster(name, bytes)?; diff --git a/crates/media/src/still.rs b/crates/media/src/still.rs index 530db31be4..9204140846 100644 --- a/crates/media/src/still.rs +++ b/crates/media/src/still.rs @@ -102,10 +102,6 @@ pub struct StillSource { impl StillSource { pub fn decode(name: &str, bytes: &[u8]) -> Result { - // a DNG is a TIFF too: develop its raw image instead of reading its preview - if dng::sniff(bytes) { - return dng::still(name, bytes); - } if hdr::sniff(bytes) { return hdr::still(name, bytes); } diff --git a/crates/media/src/still/dng.rs b/crates/media/src/still/dng.rs index ecf76f9879..fc2dfb5210 100644 --- a/crates/media/src/still/dng.rs +++ b/crates/media/src/still/dng.rs @@ -12,11 +12,25 @@ use std::sync::Arc; use filmcraft_color::{ColorInfo, Transfer}; -use filmcraft_frame::VideoFrame; +use filmcraft_frame::{AudioBuffer, VideoFrame}; use filmcraft_time::FrameRate; -use super::{DEFAULT_STILL_DURATION, StillSource}; -use crate::{MediaError, MediaInfo, MediaKind, Result, VideoStreamInfo}; +use super::DEFAULT_STILL_DURATION; +use crate::raw::{self, RawInfo, RawSettings}; +use crate::{FrameCache, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result, SharedSource, VideoStreamInfo}; + +/// The codec name of DNG stills in the media info. +pub const CODEC: &str = "DNG"; +/// The codec name of numbered DNG folders (CinemaDNG sequences). +pub const SEQUENCE_CODEC: &str = "DNG sequence"; +/// The codec name of CinemaDNG in MXF. +pub const MXF_CODEC: &str = "CinemaDNG"; + +/// Whether media with this info is camera raw (developed by this module): a DNG still, a DNG +/// sequence or CinemaDNG in MXF. +pub fn is_camera_raw(info: &MediaInfo) -> bool { + info.video.as_ref().is_some_and(|v| matches!(v.codec.as_str(), CODEC | SEQUENCE_CODEC | MXF_CODEC)) +} /// Whether `b` is a DNG file. pub fn sniff(b: &[u8]) -> bool { @@ -24,7 +38,7 @@ pub fn sniff(b: &[u8]) -> bool { } /// The colour of developed frames: scene-linear light, Rec. 709 primaries. -const LINEAR_709: ColorInfo = ColorInfo { transfer: Transfer::Linear, ..ColorInfo::SRGB_FULL }; +pub const LINEAR_709: ColorInfo = ColorInfo { transfer: Transfer::Linear, ..ColorInfo::SRGB_FULL }; /// A developed DNG frame. pub struct Decoded { @@ -42,45 +56,122 @@ fn error(name: &str, e: filmcraft_dng::Error) -> MediaError { } } -/// Develop a DNG file. +/// The pixel format shown in the media info, e.g. "Bayer 12-bit raw (lossless JPEG)". +pub fn pixel_format(i: &filmcraft_dng::Info) -> String { + let kind = if i.cfa { "Bayer" } else { "linear" }; + format!("{kind} {}-bit raw ({})", i.bits, i.compression) +} + +/// Read a DNG's metadata without developing it: the developed size, pixel format and tags (for +/// CinemaDNG in MXF, whose descriptor may not describe the picture). +pub fn probe(name: &str, bytes: &[u8]) -> Result<(u32, u32, String, filmcraft_dng::Info)> { + let i = filmcraft_dng::probe(bytes).map_err(|e| error(name, e))?; + let (Ok(w), Ok(h)) = (u32::try_from(i.width), u32::try_from(i.height)) else { return Err(MediaError::Decode(format!("{name}: picture too large"))) }; + if w == 0 || h == 0 { + return Err(MediaError::Decode(format!("{name}: empty picture"))); + } + Ok((w, h, pixel_format(&i), i)) +} + +/// Develop a DNG file as the file says, at full resolution. pub fn decode(name: &str, bytes: &[u8]) -> Result { - let img = filmcraft_dng::decode(bytes).map_err(|e| error(name, e))?; + decode_with(name, bytes, &RawSettings::default(), 0) +} + +/// Develop a DNG file with raw `settings` at reduced-resolution `level` ([`raw::scale_level`]: the +/// picture may be smaller than the file's, never smaller than 1 / 2^level of it). +pub fn decode_with(name: &str, bytes: &[u8], settings: &RawSettings, level: u8) -> Result { + let dng = filmcraft_dng::Dng::parse(bytes).map_err(|e| error(name, e))?; + let target = if level == 0 { + None + } else { + let i = dng.info().map_err(|e| error(name, e))?; + Some((u32::try_from(i.width).unwrap_or(u32::MAX), u32::try_from(i.height).unwrap_or(u32::MAX))) + }; + let o = match target { + Some((w, h)) => settings.options(w, h, level), + None => settings.options(0, 0, 0), + }; + let img = dng.develop_with(&o).map_err(|e| error(name, e))?; let (w, h) = (u32::try_from(img.width), u32::try_from(img.height)); let (Ok(w), Ok(h)) = (w, h) else { return Err(MediaError::Decode(format!("{name}: picture too large"))) }; - let i = &img.info; - let kind = if i.cfa { "Bayer" } else { "linear" }; - let pixel_format = format!("{kind} {}-bit raw ({})", i.bits, i.compression); + let pixel_format = pixel_format(&img.info); let mut frame = VideoFrame::rgba_f32(w, h, img.rgba); frame.color = LINEAR_709; Ok(Decoded { frame, pixel_format, info: img.info }) } -/// Open a DNG file as a still. -pub fn still(name: &str, bytes: &[u8]) -> Result { - let d = decode(name, bytes)?; - let info = MediaInfo { - name: name.to_string(), - kind: MediaKind::Still, - duration: DEFAULT_STILL_DURATION, - video: Some(VideoStreamInfo { - width: d.frame.width, - height: d.frame.height, - frame_rate: FrameRate::FPS_29_97, - par: (1, 1), - codec: "DNG".into(), - pixel_format: d.pixel_format, - color: LINEAR_709, - has_alpha: false, - bitrate: None, - hdr: None, - color_assumed: None, - }), - audio_streams: Vec::new(), - container: "Image".into(), - start_timecode: None, - file_size: Some(bytes.len() as u64), - }; - Ok(StillSource { info, frame: Arc::new(d.frame) }) +/// The as-shot white balance of a DNG as (temperature in kelvin, tint), when it has the colour +/// calibration to say. +pub fn as_shot(bytes: &[u8]) -> Option<(f64, f64)> { + filmcraft_dng::Dng::parse(bytes).ok()?.as_shot_temperature_tint().ok().flatten() +} + +/// A DNG still: developed with its raw settings, once per reduced-resolution level asked for. +pub struct DngStill { + info: MediaInfo, + bytes: Arc<[u8]>, + settings: RawSettings, + as_shot: Option<(f64, f64)>, + /// Developed pictures by level ([`raw::scale_level`]). + cache: FrameCache, +} + +impl DngStill { + /// Develop `bytes` at full resolution with `settings` (an error if it cannot be developed). + pub fn open(name: &str, bytes: Arc<[u8]>, settings: RawSettings) -> Result { + let d = decode_with(name, &bytes, &settings, 0)?; + let info = MediaInfo { + name: name.to_string(), + kind: MediaKind::Still, + duration: DEFAULT_STILL_DURATION, + video: Some(VideoStreamInfo { + width: d.frame.width, + height: d.frame.height, + frame_rate: FrameRate::FPS_29_97, + par: (1, 1), + codec: CODEC.into(), + pixel_format: d.pixel_format, + color: LINEAR_709, + has_alpha: false, + bitrate: None, + hdr: None, + color_assumed: None, + }), + audio_streams: Vec::new(), + container: "Image".into(), + start_timecode: None, + file_size: Some(bytes.len() as u64), + }; + let cache = FrameCache::new(512 << 20); + cache.insert(0, Arc::new(d.frame)); + Ok(Self { info, as_shot: as_shot(&bytes), bytes, settings, cache }) + } +} + +impl MediaSource for DngStill { + fn info(&self) -> &MediaInfo { + &self.info + } + fn video_frame(&self, req: FrameRequest) -> Result> { + let level = raw::scale_level(req.scale); + if let Some(f) = self.cache.get(&level) { + return Ok(f); + } + let f = Arc::new(decode_with(&self.info.name, &self.bytes, &self.settings, level)?.frame); + self.cache.insert(level, f.clone()); + Ok(f) + } + fn audio(&self, _start: i64, _frames: usize, _sample_rate: u32) -> Result { + Err(MediaError::NoStream("audio")) + } + fn raw_info(&self) -> Option { + Some(RawInfo { as_shot: self.as_shot, settings: self.settings }) + } + fn with_raw_settings(&self, settings: &RawSettings) -> Option { + // a file that developed once develops again; if not, the caller keeps this source + DngStill::open(&self.info.name, self.bytes.clone(), *settings).ok().map(|s| Arc::new(s) as SharedSource) + } } /// The frame rate and start time code (in frames at that rate) of a CinemaDNG sequence whose first @@ -128,6 +219,13 @@ mod tests { write(&px, &o) } + fn red_at(f: &VideoFrame, x: usize, y: usize) -> f32 { + match &f.data { + PixelData::RgbaF32(p) => p[(y * f.width as usize + x) * 4], + _ => panic!("float frames"), + } + } + fn red(f: &VideoFrame) -> f32 { match &f.data { PixelData::RgbaF32(p) => p[(5 * 16 + 5) * 4], @@ -196,6 +294,142 @@ mod tests { } } + #[test] + fn raw_settings_change_the_developed_still() { + let f: Arc<[u8]> = grey_dng(0.25, vec![]).into(); + let src = crate::open_bytes("A001_C001_0001.dng", f.clone(), &[]).unwrap(); + // no ColorMatrix: no as-shot temperature to offer, default settings in use + assert_eq!(src.raw_info(), Some(RawInfo { as_shot: None, settings: RawSettings::default() })); + let base = red(&src.video_frame(FrameRequest::full(filmcraft_time::Tick(0))).unwrap()); + assert!((base - 0.25).abs() < 1e-3, "{base}"); + // +1 stop doubles the scene-linear values, -2 quarters them + for (stops, factor) in [(1.0, 2.0), (-2.0, 0.25)] { + let s = src.with_raw_settings(&RawSettings { exposure: stops, ..Default::default() }).unwrap(); + assert_eq!(s.raw_info().unwrap().settings.exposure, stops); + assert_eq!(s.info(), src.info()); + let v = red(&s.video_frame(FrameRequest::full(filmcraft_time::Tick(0))).unwrap()); + assert!((v - base * factor).abs() < 1e-3, "{stops}: {v}"); + } + // the original source is unchanged + assert!((red(&src.video_frame(FrameRequest::full(filmcraft_time::Tick(0))).unwrap()) - base).abs() < 1e-6); + // the fast demosaic gives the same flat field + let fast = src.with_raw_settings(&RawSettings { demosaic: raw::DemosaicQuality::Fast, ..Default::default() }).unwrap(); + assert!((red(&fast.video_frame(FrameRequest::full(filmcraft_time::Tick(0))).unwrap()) - base).abs() < 1e-3); + // other sources are not camera raw + let png = crate::still::StillSource::from_rgba("x.png", 2, 2, vec![0; 16], false, 16); + assert!(png.raw_info().is_none()); + assert!(png.with_raw_settings(&RawSettings::default()).is_none()); + assert!(is_camera_raw(src.info())); + assert!(!is_camera_raw(png.info())); + } + + #[test] + fn reduced_resolution_requests_develop_smaller_pictures() { + let f: Arc<[u8]> = grey_dng(0.5, vec![]).into(); + let src = crate::open_bytes("x.dng", f, &[]).unwrap(); + let full = src.video_frame(FrameRequest::full(filmcraft_time::Tick(0))).unwrap(); + assert_eq!((full.width, full.height), (16, 12)); + // the decoder may develop a smaller picture, never one smaller than the request + let half = src.video_frame(FrameRequest { time: filmcraft_time::Tick(0), scale: 0.5 }).unwrap(); + assert!((8..16).contains(&half.width) && (6..12).contains(&half.height), "{}x{}", half.width, half.height); + assert!((red_at(&half, 2, 2) - 0.5).abs() < 1e-3); + // the media info keeps the full size + assert_eq!(src.info().video.as_ref().unwrap().width, 16); + } + + /// A 64 x 48 RGGB DNG with a sharp vertical edge at x = 32 and a horizontal ramp on each side. + fn edge_dng() -> Vec { + let (w, h) = (64usize, 48usize); + let neutral = [0.5, 1.0, 0.5]; + let pat = [0usize, 1, 1, 2]; + let px: Vec = (0..w * h) + .map(|i| { + let x = i % w; + let level = if x < 32 { 0.1 + 0.2 * (x as f64 / 32.0) } else { 0.7 + 0.2 * ((x - 32) as f64 / 32.0) }; + (level * neutral[pat[(i / w % 2) * 2 + x % 2]] * 4095.0).round() as u16 + }) + .collect(); + let o = Options { + width: w, + height: h, + bits: 12, + compression: Compression::LosslessJpeg { predictor: 1, components: 2, restart_lines: 0, pt: 0 }, + raw_tags: vec![(tags::WHITE_LEVEL, Value::Short(vec![4095]))], + main_tags: vec![(tags::AS_SHOT_NEUTRAL, Value::rationals(&neutral))], + preview: true, + ..Default::default() + }; + write(&px, &o) + } + + /// The picture is as sharp as developing the full frame and then averaging it down: at 1/2 and + /// 1/4 the reduced development differs from that box-filtered full development by under 0.01 on average + /// (at most 0.053, in the two columns beside the edge), has no gain error (the signed mean offset + /// is -0.0062 at both 1/2 and 1/4: one pixel of the 0.2 / 32 = 0.00625 per pixel ramp, a fixed sub-block + /// siting shift of the reduced path, independent of the level) and keeps the edge at least as steep. + #[test] + fn reduced_development_matches_the_box_filtered_full_picture() { + let bytes = edge_dng(); + let src = crate::open_bytes("edge.dng", bytes.into(), &[]).unwrap(); + let full = src.video_frame(FrameRequest::full(filmcraft_time::Tick(0))).unwrap(); + for (scale, k) in [(0.5f32, 2usize), (0.25, 4)] { + let small = src.video_frame(FrameRequest { time: filmcraft_time::Tick(0), scale }).unwrap(); + assert_eq!((small.width as usize * k, small.height as usize * k), (full.width as usize, full.height as usize), "1/{k}"); + let (sw, sh) = (small.width as usize, small.height as usize); + let mut worst = 0.0f32; + let mut sum = 0.0f32; + let mut signed = 0.0f32; + for y in 0..sh { + for x in 0..sw { + let mut avg = 0.0; + for dy in 0..k { + for dx in 0..k { + avg += red_at(&full, x * k + dx, y * k + dy); + } + } + let d = red_at(&small, x, y) - avg / (k * k) as f32; + worst = worst.max(d.abs()); + sum += d.abs(); + signed += d; + } + } + // steepest step between neighbouring pixels in the middle row, in units of the picture's edge + let row = sh / 2; + let step_small = (1..sw).map(|x| (red_at(&small, x, row) - red_at(&small, x - 1, row)).abs()).fold(0.0f32, f32::max); + let step_full = (1..full.width as usize).map(|x| (red_at(&full, x, row * k) - red_at(&full, x - 1, row * k)).abs()).fold(0.0f32, f32::max); + // the edge is not softer than in the box-filtered full picture + assert!(step_small >= step_full, "1/{k}: edge step {step_small} < full {step_full}"); + let mean = sum / (sw * sh) as f32; + assert!(mean < 0.01, "1/{k}: mean abs diff {mean}"); + // measured -0.0062 at both 1/2 and 1/4 (the ramp slope, see above); the bound leaves + // 30 % margin, and since the mean level is about 0.5 it still catches a gain error of + // about 1.6 % (0.008 / 0.5) or more + let offset = signed / (sw * sh) as f32; + assert!(offset.abs() < 0.008, "1/{k}: signed mean offset {offset}"); + assert!(worst < 0.06, "1/{k}: worst diff {worst}"); + } + } + + #[test] + fn raw_settings_reach_every_sequence_frame() { + let files: HashMap> = (0..2).map(|i| (format!("/c/B_{i:04}.dng"), grey_dng(0.1 + 0.2 * f64::from(i), vec![]))).collect(); + let loader: FrameLoader = + Arc::new(move |p: &str| files.get(p).cloned().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, p.to_string()))); + let frames = (0..2).map(|i| Some(format!("/c/B_{i:04}.dng"))).collect(); + let s = ImageSequenceSource::new("B_0000.dng", frames, FrameRate::FPS_25, loader).unwrap(); + assert_eq!(s.raw_info().unwrap().settings, RawSettings::default()); + let bright = s.with_raw_settings(&RawSettings { exposure: 1.0, ..Default::default() }).unwrap(); + assert_eq!(bright.info(), s.info()); + for i in 0..2 { + let t = FrameRate::FPS_25.tick_of(i); + let (a, b) = (red(&s.video_frame(FrameRequest::full(t)).unwrap()), red(&bright.video_frame(FrameRequest::full(t)).unwrap())); + assert!((b - 2.0 * a).abs() < 1e-3, "frame {i}: {a} {b}"); + } + let quarter = bright.video_frame(FrameRequest { time: FrameRate::FPS_25.tick_of(1), scale: 0.25 }).unwrap(); + assert!((4..16).contains(&quarter.width) && quarter.height >= 3, "{}x{}", quarter.width, quarter.height); + assert!((red_at(&quarter, 1, 1) - 2.0 * 0.3).abs() < 1e-3); + } + #[test] fn timing_without_cinemadng_tags_is_the_callers() { let info = filmcraft_dng::probe(&grey_dng(0.5, vec![])).unwrap(); diff --git a/crates/mxf/README.md b/crates/mxf/README.md index 070045b98a..2e6df4a6ac 100644 --- a/crates/mxf/README.md +++ b/crates/mxf/README.md @@ -22,6 +22,7 @@ Implemented from the SMPTE documents (editions used): | SMPTE ST 386 | 2004 | D-10 (IMX) mapping: container label, sound element type | | SMPTE ST 2019-4 | 2009 | VC-3 (DNxHD / DNxHR) mapping | | SMPTE RDD 44 | 2017 | Apple ProRes mapping | +| Adobe CinemaDNG Image Data Format Specification | 1.0.0.0 (September 2009), §7 | CinemaDNG in MXF: frame-wrapped DNG picture elements (element type 42h), the essence container labels for CFA / LinearRaw content, RGBA descriptor; the labels are the specification's **provisional** values from Adobe's experimental range (byte 9 = 0Fh), not SMPTE-registered | | 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 | @@ -62,7 +63,10 @@ let pcm = mxf.read_pcm(&file, a, 48_000, 1920)?; // planar f32, sample-exa orders by the bitstream (`filmcraft-codecs` uses the AVC picture order counts). - **Codecs.** From the picture essence coding label, then the essence container label, then the essence bytes: AVC / AVC-Intra, VC-3, ProRes (profile from the label), MPEG-2 (decoded in `filmcraft-codecs`), - MPEG-4 visual, JPEG 2000, DV, uncompressed. + MPEG-4 visual, JPEG 2000, DV, uncompressed, CinemaDNG (the CinemaDNG container labels, the + registered MXF-GC TIFF/EP container and TIFF/EP picture coding labels, a 42h picture element + type, or a TIFF header at the start of the essence; developed by `filmcraft-dng` in + `filmcraft-codecs`). - **Sound.** PCM chunks (frame- or clip-wrapped) with sample counts; `read_pcm` decodes little-endian 8/16/24/32-bit PCM (wave / AES3 descriptors) and ST 331 AES3 elements (D-10). - **Timecode.** The material package timecode component (start frame count, rounded base, @@ -120,7 +124,10 @@ w.finish()?; 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). + (ST 382: container `02 06 01/02`, element types 0x01 / 0x02), CinemaDNG (frame-wrapped DNG + datasets, element type 0x42, the provisional container label `0F 0A 0D 0B 02 7F 01/02 00`, an + RGBA descriptor with pixel layout and reference levels of the decoded 16-bit RGB picture and no + coding label; OP-Atom is refused because it is clip-wrapped here and DNG datasets cannot be). - `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. @@ -134,6 +141,11 @@ w.finish()?; timecode, DNxHR LB (±2), ProRes 422 (±1), MPEG-2 long GOP, XDCAM HD422 and D-10 (±4), 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. +- `crates/codecs/tests/cinemadng_mxf.rs`: OP1a CinemaDNG files from our writer wrapping DNG frames + from the `filmcraft-dng` test writer (CFA lossless JPEG and LinearRaw): every frame developed to + its known level in any order, frame rate, timecode, sample-exact sound; a non-DNG picture is + an error; truncation and mutation never panic. No tool we can run reads CinemaDNG MXF, so no + real camera file has been tested. - `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 diff --git a/crates/mxf/src/essence.rs b/crates/mxf/src/essence.rs index 820dc02573..bd4163a901 100644 --- a/crates/mxf/src/essence.rs +++ b/crates/mxf/src/essence.rs @@ -27,6 +27,9 @@ pub enum Codec { Dv, /// Uncompressed pictures (ST 384). Uncompressed, + /// CinemaDNG: one DNG (TIFF) dataset per frame-wrapped picture element (CinemaDNG Image Data + /// Format Specification §7), or TIFF/EP pictures under the registered MXF-GC TIFF/EP labels. + CinemaDng, /// Linear PCM, little-endian (Broadcast Wave and AES3 descriptors, ST 382). Pcm, /// 8-channel AES3 element data (ST 331, D-10 sound). @@ -46,6 +49,7 @@ impl Codec { Codec::Jpeg2000 => "JPEG 2000", Codec::Dv => "DV", Codec::Uncompressed => "Uncompressed", + Codec::CinemaDng => "CinemaDNG", Codec::Pcm => "PCM", Codec::Aes3Element => "AES3 PCM", Codec::Unknown => "unknown", @@ -53,10 +57,26 @@ impl Codec { } /// Every picture is coded independently. pub fn intra_only(&self) -> bool { - matches!(self, Codec::Avc { intra: true } | Codec::Vc3 | Codec::ProRes { .. } | Codec::Jpeg2000 | Codec::Dv | Codec::Uncompressed) + matches!(self, Codec::Avc { intra: true } | Codec::Vc3 | Codec::ProRes { .. } | Codec::Jpeg2000 | Codec::Dv | Codec::Uncompressed | Codec::CinemaDng) } } +/// The CinemaDNG essence container labels (CinemaDNG Image Data Format Specification 1.0.0.0, +/// §7.2.1): provisional, unregistered labels from Adobe's experimental range (byte 9 = 0F h), +/// `…0F 0A 0D 0B 02 7F cc 00` with content kind `cc` 01 (CFA) or 02 (LinearRaw). +pub const CINEMADNG_CONTAINER_PREFIX: [u8; 6] = [0x0F, 0x0A, 0x0D, 0x0B, 0x02, 0x7F]; + +/// Element type (byte 15 of the picture element key) of a frame-wrapped CinemaDNG picture: the +/// "TIFF" type of ST 331 (CinemaDNG specification §7.1.1.2). +pub const CINEMADNG_ELEMENT_TYPE: u8 = 0x42; + +/// Whether `ul` is a CinemaDNG essence container label (CFA or LinearRaw content), or the +/// registered MXF-GC TIFF/EP container (`…0D 01 03 01 02 14 ww 00`, RP 224). +pub fn is_cinemadng_container(ul: &Ul) -> bool { + ul.is_smpte() + && (ul.item_starts_with(&CINEMADNG_CONTAINER_PREFIX) && matches!(ul.0[14], 0x01 | 0x02) || ul.item_starts_with(&[0x0D, 0x01, 0x03, 0x01, 0x02, 0x14])) +} + /// Identify a picture codec from the picture essence coding label and the essence container label. pub fn identify_picture(coding: Option
    , container: Option
      , mpeg_descriptor: bool) -> Codec { if let Some(c) = coding.filter(Ul::is_smpte) { @@ -64,6 +84,9 @@ pub fn identify_picture(coding: Option
        , container: Option
          , mpeg_descript // 04 01 02 02 .. : compressed picture coding if b[8..12] == [0x04, 0x01, 0x02, 0x02] { match (b[12], b[13]) { + // TIFF/EP Profile 2 picture coding (RP 224: 03 02 01 01 uncompressed CFA … 04 + // compressed LinearRaw), the coding of DNG pictures + (0x03, 0x02) if b[14] == 0x01 => return Codec::CinemaDng, // 01: MPEG compression (ISO/IEC 13818-2 profiles, 14496-2, 14496-10) (0x01, 0x01..=0x1F) => return Codec::Mpeg2, (0x01, 0x20..=0x2F) => return Codec::Mpeg4Visual, @@ -82,6 +105,9 @@ pub fn identify_picture(coding: Option
            , container: Option
              , mpeg_descript return Codec::Uncompressed; } } + if container.is_some_and(|c| is_cinemadng_container(&c)) { + return Codec::CinemaDng; + } if let Some(c) = container.filter(Ul::is_smpte) && c.item_starts_with(&[0x0D, 0x01, 0x03, 0x01, 0x02]) { @@ -102,6 +128,10 @@ pub fn identify_picture(coding: Option
                , container: Option
                  , mpeg_descript /// Identify a picture codec from the first bytes of an essence element. pub fn sniff_picture(b: &[u8]) -> Option { + // a TIFF header (TIFF 6.0 §2): a DNG dataset + if b.len() >= 8 && (b[..4] == *b"II*\0" || b[..4] == *b"MM\0*") { + return Some(Codec::CinemaDng); + } if b.len() >= 8 && &b[4..8] == b"icpf" { return Some(Codec::ProRes { profile: None }); } @@ -306,6 +336,20 @@ mod tests { assert_eq!(identify_picture(None, None, false), Codec::Unknown); } + #[test] + fn identifies_cinemadng() { + // CinemaDNG specification §7.2.1: CFA and LinearRaw containers (any registry version byte) + let adobe = |kind: u8| Ul([0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x0F, 0x0A, 0x0D, 0x0B, 0x02, 0x7F, kind, 0x00]); + assert_eq!(identify_picture(None, Some(adobe(1)), false), Codec::CinemaDng); + assert_eq!(identify_picture(None, Some(adobe(2)), false), Codec::CinemaDng); + assert_eq!(identify_picture(None, Some(adobe(3)), false), Codec::Unknown); + // RP 224: MXF-GC frame-wrapped TIFF/EP container, TIFF/EP picture coding + assert_eq!(identify_picture(None, Some(ul(&[0x0D, 1, 3, 1, 2, 0x14, 1])), false), Codec::CinemaDng); + assert_eq!(identify_picture(Some(ul(&[4, 1, 2, 2, 3, 2, 1, 3])), None, false), Codec::CinemaDng); + assert!(Codec::CinemaDng.intra_only()); + assert_eq!(Codec::CinemaDng.name(), "CinemaDNG"); + } + #[test] fn sniffs_essence() { assert_eq!(sniff_picture(&[0, 0, 0, 1, 0x09, 0x10]), Some(Codec::Avc { intra: false })); @@ -313,6 +357,9 @@ mod tests { assert_eq!(sniff_picture(&[0, 0, 2, 0x80, 1]), Some(Codec::Vc3)); assert_eq!(sniff_picture(&[0, 0, 0, 9, b'i', b'c', b'p', b'f']), Some(Codec::ProRes { profile: None })); assert_eq!(sniff_picture(&[1, 2, 3]), None); + assert_eq!(sniff_picture(b"II*\0\x08\0\0\0"), Some(Codec::CinemaDng)); + assert_eq!(sniff_picture(b"MM\0*\0\0\0\x08"), Some(Codec::CinemaDng)); + assert_eq!(sniff_picture(b"II*\0"), None); } #[test] diff --git a/crates/mxf/src/file.rs b/crates/mxf/src/file.rs index b91c4c28c5..e486b364b3 100644 --- a/crates/mxf/src/file.rs +++ b/crates/mxf/src/file.rs @@ -869,6 +869,10 @@ fn build_track( } } } + // CinemaDNG §7.1.1.2: a "TIFF" picture element type names DNG pictures + if codec == Codec::Unknown && elements.first().is_some_and(|e| e.key.0[12] == 0x15 && e.key.0[14] == crate::essence::CINEMADNG_ELEMENT_TYPE) { + codec = Codec::CinemaDng; + } t.codec = codec; t.picture = Some(pi); build_pictures(src, &mut t, elements, index, warnings)?; diff --git a/crates/mxf/src/lib.rs b/crates/mxf/src/lib.rs index dcbda257b7..682c3f9b2f 100644 --- a/crates/mxf/src/lib.rs +++ b/crates/mxf/src/lib.rs @@ -2,7 +2,8 @@ //! ST 377-1 (file format: KLV, partitions, primer pack, header metadata, index tables, random //! index pack), ST 378 (OP1a), ST 390 (OP-Atom), ST 379-1/2 (generic container), ST 381-1 (MPEG //! video mapping), ST 381-3 (AVC mapping), ST 382 (AES3 / Broadcast Wave audio mapping), ST 331 -//! (AES3 element data, D-10 sound), ST 2019-4 (VC-3 mapping) and RDD 44 (ProRes mapping). +//! (AES3 element data, D-10 sound), ST 2019-4 (VC-3 mapping), RDD 44 (ProRes mapping) and the CinemaDNG +//! specification's MXF mapping (frame-wrapped DNG pictures). //! //! [`open`] walks the file's KLV packets (keys and lengths only; essence payloads are never read //! while opening), parses the partition packs, the primer pack and header metadata of the most @@ -27,7 +28,7 @@ mod meta; mod source; pub mod write; -pub use essence::{Codec, PictureInfo, SoundFormat, SoundInfo, aes3_element_samples, decode_pcm}; +pub use essence::{CINEMADNG_CONTAINER_PREFIX, CINEMADNG_ELEMENT_TYPE, Codec, PictureInfo, SoundFormat, SoundInfo, aes3_element_samples, decode_pcm}; pub use file::{Chunk, EssenceTrack, MxfFile, OperationalPattern, Partition, PartitionKind, Sample, Timecode, TrackKind, Wrapping, open}; pub use index::{IndexEntry, IndexSegment}; pub use klv::{Rational, Ul}; diff --git a/crates/mxf/src/write.rs b/crates/mxf/src/write.rs index ef952e8fae..0bfb8ffa4f 100644 --- a/crates/mxf/src/write.rs +++ b/crates/mxf/src/write.rs @@ -107,6 +107,10 @@ pub enum PictureCoding { ProRes { profile: u8 }, /// H.264 / AVC Annex B byte stream; `profile_idc` from the SPS (66, 77, 100, …). Avc { profile_idc: u8, intra: bool }, + /// CinemaDNG: one DNG dataset per picture (CinemaDNG specification §7), frame-wrapped only, + /// with an RGBA descriptor of the decoded 16-bit RGB image. `linear_raw`: the pictures are + /// LinearRaw (PhotometricInterpretation 34892) rather than CFA (32803). + CinemaDng { linear_raw: bool }, } impl PictureCoding { @@ -116,9 +120,10 @@ impl PictureCoding { _ => true, } } - /// Picture essence coding label. - fn coding_label(&self) -> [u8; 16] { - match *self { + /// Picture essence coding label (None: CinemaDNG, which has no registered one, §7.2.2.2). + fn coding_label(&self) -> Option<[u8; 16]> { + Some(match *self { + PictureCoding::CinemaDng { .. } => return None, PictureCoding::Vc3 { cid } => { let code = if (1235..=1234 + 0x7F).contains(&cid) { (cid - 1234) as u8 } else { 0 }; [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x0A, 0x04, 0x01, 0x02, 0x02, 0x71, code, 0x00, 0x00] @@ -145,7 +150,7 @@ impl PictureCoding { }; [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x0A, 0x04, 0x01, 0x02, 0x02, 0x01, b13, b14, 0x01] } - } + }) } /// (essence container label, element type) for frame or clip wrapping. fn container(&self, clip: bool) -> ([u8; 16], u8) { @@ -154,6 +159,11 @@ impl PictureCoding { PictureCoding::Vc3 { .. } => (essence_container(0x0A, 0x11, w, 0x00), if clip { 0x0D } else { 0x0C }), PictureCoding::ProRes { .. } => (essence_container(0x0D, 0x1C, w, 0x00), if clip { 0x18 } else { 0x17 }), PictureCoding::Avc { .. } => (essence_container(0x0A, 0x10, 0x60, w), if clip { 0x16 } else { 0x15 }), + // CinemaDNG §7.2.1 (provisional label) and §7.1.1.2 (element type 42h); always frame + // wrapped (`MxfWriter::new` refuses clip wrapping) + PictureCoding::CinemaDng { linear_raw } => { + ([0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x0F, 0x0A, 0x0D, 0x0B, 0x02, 0x7F, if *linear_raw { 0x02 } else { 0x01 }, 0x00], 0x42) + } } } } @@ -195,6 +205,8 @@ impl PictureDesc { PictureCoding::Vc3 { cid } => { (if matches!(cid, 1235 | 1241 | 1250 | 1256 | 1270 | 1271) { 10 } else { 8 }, if matches!(cid, 1256 | 1270) { (1, 1) } else { (2, 1) }) } + // §7.2.2.3: the decoded image is described as 16 bits per component + PictureCoding::CinemaDng { .. } => (16, (1, 1)), }; PictureDesc { coding, @@ -572,6 +584,10 @@ impl MxfWriter { return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid PCM sound description")); } let clip = cfg.pattern == Pattern::OpAtom; + if clip && matches!(cfg.picture.as_ref().map(|p| p.coding), Some(PictureCoding::CinemaDng { .. })) { + // CinemaDNG §7.1.1: a TIFF dataset has no length field, so DNG pictures can't be clip wrapped + return Err(io::Error::new(io::ErrorKind::InvalidInput, "CinemaDNG pictures are frame wrapped only (this writer's OP-Atom is clip wrapped)")); + } let mut tracks = Vec::new(); let mut id = cfg.first_track_id.max(2); if let Some(p) = &cfg.picture { @@ -953,7 +969,8 @@ impl MxfWriter { TrackKind::Picture => { // Picture tracks exist only with a picture configuration. let Some(p) = cfg.picture.as_ref() else { continue }; - let mut s = Set::new(0x28, uid); + let rgba = matches!(p.coding, PictureCoding::CinemaDng { .. }); + let mut s = Set::new(if rgba { 0x29 } else { 0x28 }, uid); if multiple { s.u32(0x3006, t.id); } @@ -979,9 +996,22 @@ impl MxfWriter { .u32(0x3209, p.width) .u32(0x3208, p.height) .rat(0x320E, p.aspect) - .p(0x320D, &batch(&[&0u32.to_be_bytes(), &0u32.to_be_bytes()], 4)) - .p(0x3201, &p.coding.coding_label()) - .p(0x3210, &tcl) + .p(0x320D, &batch(&[&0u32.to_be_bytes(), &0u32.to_be_bytes()], 4)); + if let Some(label) = p.coding.coding_label() { + s.p(0x3201, &label); + } + if rgba { + // RGBA descriptor (ST 377-1 G.2.40): pixel layout R, G, B of `depth` bits + // each, and the component reference levels (CinemaDNG §7.2.2.3) + let d = depth as u8; + let mut layout = [0u8; 16]; + layout[..6].copy_from_slice(&[b'R', d, b'G', d, b'B', d]); + let max = if depth >= 32 { u32::MAX } else { (1u32 << depth) - 1 }; + s.p(0x3401, &layout).u32(0x3406, max).u32(0x3407, 0); + s.write(&mut out); + continue; + } + s.p(0x3210, &tcl) .p(0x321A, &ce) .p(0x3219, &cp) .u32(0x3301, depth) diff --git a/crates/mxf/src/write/labels.rs b/crates/mxf/src/write/labels.rs index eb8fbc040d..12f62c7fdb 100644 --- a/crates/mxf/src/write/labels.rs +++ b/crates/mxf/src/write/labels.rs @@ -120,6 +120,10 @@ pub const PRIMER: &[(u16, [u8; 16])] = &[ (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])), + // RGBA picture essence descriptor (ST 377-1; RP 210): PixelLayout, ComponentMaxRef, ComponentMinRef + (0x3401, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x01, 0x05, 0x03, 0x06])), + (0x3406, ul(&[0x01, 0x01, 0x01, 0x05, 0x04, 0x01, 0x05, 0x03, 0x0B])), + (0x3407, ul(&[0x01, 0x01, 0x01, 0x05, 0x04, 0x01, 0x05, 0x03, 0x0C])), (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])), diff --git a/crates/mxf/src/write/tests.rs b/crates/mxf/src/write/tests.rs index 0b3336c6b7..91af40afad 100644 --- a/crates/mxf/src/write/tests.rs +++ b/crates/mxf/src/write/tests.rs @@ -385,6 +385,56 @@ fn long_pcm_reads_backed_by_the_file_are_not_capped() { assert!(m.read_pcm(&f, 0, 0, frames + (1 << 24)).is_err()); } +/// A TIFF-headed stand-in for a DNG dataset (the demuxer only carries the bytes). +fn dng_like(i: usize) -> Vec { + let mut v = b"II*\0\x08\0\0\0".to_vec(); + v.extend(std::iter::repeat_n(i as u8 ^ 0x33, 300 + 11 * i)); + v +} + +#[test] +fn cinemadng_op1a_reads_back() { + for linear_raw in [false, true] { + let mut cfg = op1a_config(Rational::new(24, 1), PictureCoding::CinemaDng { linear_raw }); + cfg.sound.truncate(1); + let mut w = MxfWriter::new(Cursor::new(Vec::new()), cfg).unwrap(); + for i in 0..6 { + w.push_picture(&dng_like(i), FrameInfo::intra()).unwrap(); + w.push_sound(0, &pcm16(i * 2000, 2000)).unwrap(); + } + let f = w.finish().unwrap().into_inner(); + // CinemaDNG §7.1.1.2: picture element key 06.0E.2B.34.01.02.01.01.0D.01.03.01.15.01.42.01 + let key = [0x06, 0x0E, 0x2B, 0x34, 0x01, 0x02, 0x01, 0x01, 0x0D, 0x01, 0x03, 0x01, 0x15, 0x01, 0x42, 0x01]; + assert_eq!(f.windows(16).filter(|k| *k == key).count(), 6); + // §7.2.1: the essence container label of the content kind + let ec = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x0F, 0x0A, 0x0D, 0x0B, 0x02, 0x7F, if linear_raw { 2 } else { 1 }, 0x00]; + assert!(f.windows(16).any(|k| k == ec)); + let m = open(&f).unwrap(); + assert!(m.warnings.is_empty(), "{:?}", m.warnings); + let v = m.track_of_kind(TrackKind::Picture).unwrap(); + let t = &m.tracks[v]; + assert_eq!(t.codec, Codec::CinemaDng); + assert_eq!(t.wrapping, Wrapping::Frame); + assert_eq!(t.samples.len(), 6); + let p = t.picture.as_ref().unwrap(); + assert!(p.rgba, "RGBA picture essence descriptor (§7.2.2)"); + assert_eq!((p.frame_width(), p.frame_height()), (64, 36)); + assert!(p.picture_coding.is_none()); + for i in 0..6 { + assert!(t.samples[i].key); + assert_eq!(m.read_sample(&f, v, i).unwrap(), dng_like(i)); + } + assert_eq!(m.timecode.unwrap().format(), "01:02:30:00"); + } +} + +#[test] +fn cinemadng_is_never_clip_wrapped() { + let mut cfg = WriterConfig::new(Pattern::OpAtom, Rational::new(24, 1), PackageIds::from_seed("t", "T")); + cfg.picture = Some(PictureDesc::new(PictureCoding::CinemaDng { linear_raw: false }, 64, 36)); + assert!(MxfWriter::new(Cursor::new(Vec::new()), cfg).is_err()); +} + /// One `push_sound` of a long clip must not cost more than the clip itself: draining the buffered /// PCM from the front for every edit unit made this quadratic (about 16 s for 10 minutes). #[test] diff --git a/crates/project/src/item.rs b/crates/project/src/item.rs index 5990e62ebb..b3ab47d8df 100644 --- a/crates/project/src/item.rs +++ b/crates/project/src/item.rs @@ -27,6 +27,10 @@ pub struct Interpretation { /// is edited into a sequence. None = one stereo clip of the first two channels. #[serde(default, skip_serializing_if = "Option::is_none")] pub audio_channels: Option, + /// Clip ▸ Source Settings… for camera raw footage (DNG stills, CinemaDNG folders and MXF): + /// how the raw pictures are developed. None = as the file says. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw: Option, } /// Modify ▸ Audio Channels: the clip channel format and, per audio clip, the source channels it diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index ed17b29551..b693792a93 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -57,6 +57,8 @@ filmcraft-interchange = { workspace = true } # test root needs explicitly so glyph and accessibility assertions run against the bundled faces. egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } +# synthetic DNG footage for the Source Settings dialog test +filmcraft-dng = { workspace = true, features = ["writer"] } tempfile = "3" # build.rs bakes contributors/contributors.json into the binary (About ▸ Contributors). diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8d88d9856d..553eb73cd3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3600,6 +3600,22 @@ Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Notas de este proyecto: tareas pendientes, instrucciones, números de fotograma… Se guardan con el proyecto. Use Effect Shutter Angle Usar el ángulo de obturación del efecto +# Clip ▸ Source Settings… for camera raw footage (#1065) + White Balance Balance de blancos + White balance: Balance de blancos: + As Shot Como se tomó + Temperature: Temperatura: + Tint: Matiz: + As shot: {temperature} K, tint {tint} Como se tomó: {temperature} K, matiz {tint} + Exposure: Exposición: + stops pasos + Highlights: clipped (highlight recovery is not available yet) Altas luces: recortadas (la recuperación de altas luces aún no está disponible) + Decode Quality Calidad de decodificación + Demosaic: Interpolación cromática: + High (gradient-corrected) Alta (con corrección de gradiente) + Fast (bilinear) Rápida (bilineal) + Output color space: scene-linear Rec. 709 Espacio de color de salida: Rec. 709 lineal de escena + Reset to As Shot Restablecer a Como se tomó Appearance Settings… Ajustes de apariencia… Add to favorites Añadir a favoritos diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5980750c17..b9f7af83f1 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3565,6 +3565,22 @@ Use Effect Shutter Angle Utiliser l'angle d'obturation de l'effet Settings Préférences +# Clip ▸ Source Settings… for camera raw footage (#1065) + White Balance Balance des blancs + White balance: Balance des blancs : + As Shot Telle que prise + Temperature: Température : + Tint: Teinte : + As shot: {temperature} K, tint {tint} Telle que prise : {temperature} K, teinte {tint} + Exposure: Exposition : + stops IL + Highlights: clipped (highlight recovery is not available yet) Hautes lumières : écrêtées (la récupération des hautes lumières n’est pas encore disponible) + Decode Quality Qualité du décodage + Demosaic: Dématriçage : + High (gradient-corrected) Haute (corrigée par gradient) + Fast (bilinear) Rapide (bilinéaire) + Output color space: scene-linear Rec. 709 Espace colorimétrique de sortie : Rec. 709 linéaire scène + Reset to As Shot Rétablir Telle que prise Appearance Settings… Réglages d’apparence… Add to favorites Ajouter aux favoris diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c959f19278..f53f2d30fa 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3581,6 +3581,23 @@ Use Effect Shutter Angle Gunakan Sudut Rana Efek Settings Preferensi +# Clip ▸ Source Settings… for camera raw footage (#1065) + White Balance Keseimbangan Putih + White balance: Keseimbangan putih: + As Shot Sesuai Pengambilan + Temperature: Suhu: + Tint: Rona: + As shot: {temperature} K, tint {tint} Sesuai pengambilan: {temperature} K, rona {tint} + Exposure: Eksposur: + stops stop + Highlights: clipped (highlight recovery is not available yet) Sorotan: terpotong (pemulihan sorotan belum tersedia) + Decode Quality Kualitas Dekode + Demosaic: Demosaik: + High (gradient-corrected) Tinggi (dikoreksi gradien) + Fast (bilinear) Cepat (bilinear) + Output color space: scene-linear Rec. 709 Ruang warna output: Rec. 709 linear adegan + Reset to As Shot Atur Ulang ke Sesuai Pengambilan + Appearance Settings… Pengaturan Tampilan… Add to favorites Tambahkan ke favorit Remove from favorites Hapus dari favorit diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 24556489db..3768ff1fad 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3577,6 +3577,23 @@ Edit Project Notes Modifica note di progetto Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Note per questo progetto: cose da fare, istruzioni, numeri di fotogramma… Salvate con il progetto. Use Effect Shutter Angle Usa l'angolo otturatore dell'effetto +# Clip ▸ Source Settings… for camera raw footage (#1065) + White Balance Bilanciamento del bianco + White balance: Bilanciamento del bianco: + As Shot Come ripreso + Temperature: Temperatura: + Tint: Tinta: + As shot: {temperature} K, tint {tint} Come ripreso: {temperature} K, tinta {tint} + Exposure: Esposizione: + stops stop + Highlights: clipped (highlight recovery is not available yet) Luci: tagliate (il recupero delle luci non è ancora disponibile) + Decode Quality Qualità di decodifica + Demosaic: Demosaicizzazione: + High (gradient-corrected) Alta (correzione del gradiente) + Fast (bilinear) Veloce (bilineare) + Output color space: scene-linear Rec. 709 Spazio colore di uscita: Rec. 709 lineare della scena + Reset to As Shot Ripristina Come ripreso + Appearance Settings… Impostazioni di aspetto… Add to favorites Aggiungi ai preferiti Remove from favorites Rimuovi dai preferiti diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 87ee01508f..d736285a4e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3587,6 +3587,22 @@ Notes for this project: to-dos, instructions, frame numbers… Saved with the project. このプロジェクトのメモ: やること、指示、フレーム番号など… プロジェクトと一緒に保存されます。 GPU acceleration is unavailable; preview renders on the CPU GPU アクセラレーションを利用できません。プレビューは CPU でレンダリングされます +# Clip ▸ Source Settings… for camera raw footage (#1065) + White Balance ホワイトバランス + White balance: ホワイトバランス: + As Shot 撮影時の設定 + Temperature: 色温度: + Tint: 色かぶり補正: + As shot: {temperature} K, tint {tint} 撮影時の設定: {temperature} K、色かぶり補正 {tint} + Exposure: 露光量: + stops 段 + Highlights: clipped (highlight recovery is not available yet) ハイライト: クリップ (ハイライトの復元はまだ使用できません) + Decode Quality デコード品質 + Demosaic: デモザイク: + High (gradient-corrected) 高 (勾配補正) + Fast (bilinear) 高速 (バイリニア) + Output color space: scene-linear Rec. 709 出力カラースペース: シーンリニア Rec. 709 + Reset to As Shot 撮影時の設定に戻す Appearance Settings… 外観設定… Add to favorites お気に入りに追加 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 83a1f72c5e..1af629f2f0 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3604,6 +3604,22 @@ Shows the version, community links and credits in a FilmCraft window. FilmCraft 창에 버전, 커뮤니티 링크, 제작진을 보여 줍니다. Couldn't open your web browser 웹 브라우저를 열 수 없습니다 Open this address in your browser yourself: 이 주소를 브라우저에서 직접 여세요: +# Clip ▸ Source Settings… for camera raw footage (#1065) + White Balance 화이트 밸런스 + White balance: 화이트 밸런스: + As Shot 촬영 시 설정 + Temperature: 색온도: + Tint: 색조: + As shot: {temperature} K, tint {tint} 촬영 시 설정: {temperature} K, 색조 {tint} + Exposure: 노출: + stops 스톱 + Highlights: clipped (highlight recovery is not available yet) 밝은 영역: 클리핑됨 (밝은 영역 복구는 아직 지원되지 않습니다) + Decode Quality 디코드 품질 + Demosaic: 디모자이크: + High (gradient-corrected) 높음 (그레이디언트 보정) + Fast (bilinear) 빠름 (이중선형) + Output color space: scene-linear Rec. 709 출력 색 공간: 장면 선형 Rec. 709 + Reset to As Shot 촬영 시 설정으로 재설정 # View > Reset Interface Zoom (#2253). Reset Interface Zoom 인터페이스 확대/축소 재설정 Track Color 트랙 색상 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ef73606f8e..0f32f64246 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3575,6 +3575,23 @@ Edit Narration Editar narração Project Notes Notas do projeto +# Clip ▸ Source Settings… for camera raw footage (#1065) + White Balance Balanço de branco + White balance: Balanço de branco: + As Shot Como capturado + Temperature: Temperatura: + Tint: Tonalidade: + As shot: {temperature} K, tint {tint} Como capturado: {temperature} K, tonalidade {tint} + Exposure: Exposição: + stops stops + Highlights: clipped (highlight recovery is not available yet) Realces: cortados (a recuperação de realces ainda não está disponível) + Decode Quality Qualidade de decodificação + Demosaic: Demosaico: + High (gradient-corrected) Alta (corrigida por gradiente) + Fast (bilinear) Rápida (bilinear) + Output color space: scene-linear Rec. 709 Espaço de cor de saída: Rec. 709 linear da cena + Reset to As Shot Redefinir para Como capturado + Appearance Settings… Configurações de aparência… Add to favorites Adicionar aos favoritos diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index b006d9851f..fe196217e1 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3575,6 +3575,22 @@ Delete Caption Tracks 删除多个字幕轨道 Delete Empty Caption Tracks 删除空字幕轨道 +# Clip ▸ Source Settings… for camera raw footage (#1065) + White Balance 白平衡 + White balance: 白平衡: + As Shot 原照设置 + Temperature: 色温: + Tint: 色调: + As shot: {temperature} K, tint {tint} 原照设置:{temperature} K,色调 {tint} + Exposure: 曝光: + stops 档 + Highlights: clipped (highlight recovery is not available yet) 高光:剪切(高光恢复尚不可用) + Decode Quality 解码质量 + Demosaic: 去马赛克: + High (gradient-corrected) 高(梯度校正) + Fast (bilinear) 快速(双线性) + Output color space: scene-linear Rec. 709 输出色彩空间:场景线性 Rec. 709 + Reset to As Shot 重置为原照设置 Appearance Settings… 外观设置… Add to favorites 添加到收藏 diff --git a/crates/ui-egui/src/panels/menu_dialogs.rs b/crates/ui-egui/src/panels/menu_dialogs.rs index af10e5071a..f7224bbe1d 100644 --- a/crates/ui-egui/src/panels/menu_dialogs.rs +++ b/crates/ui-egui/src/panels/menu_dialogs.rs @@ -2,9 +2,10 @@ //! Scratch Disks / Ingest Settings), Get Media File Properties, Edit Offline, Source Settings, //! Update Metadata, Automate to Sequence, Scene Edit Detection, Normalize Mix Track, Simplify //! Sequence, Transcribe Sequence, Save as Template, Add Flash Cue Marker and the System -//! Compatibility Report; plus the frontend side of Edit Original / Reveal Log Files (the OS -//! opener), Reveal in Finder, Generate Audio Waveform (the timeline's peak cache), Import from -//! Media Browser (the Media Browser selection) and View ▸ Dynamic Audio Waveforms. +//! Compatibility Report (Source Settings edits camera raw footage, see `source_settings_ui`); +//! plus the frontend side of Edit Original / Reveal Log Files (the OS opener), Reveal in Finder, +//! Generate Audio Waveform (the timeline's peak cache), Import from Media Browser (the Media +//! Browser selection) and View ▸ Dynamic Audio Waveforms. //! //! The open dialog lives in `UiState::extras.dialog` (serde, like `clip_dialogs`): the engine //! command it runs and the parameters being edited, so agents open it from the menu, change @@ -323,7 +324,10 @@ fn defaults(app: &mut FilmcraftApp, cmd: &str, id: &str) -> Result<(Value, Value } (p, Value::Null) } - "clip.sourceSettings" => (json!({}), s.execute("clip.sourceSettings", json!({})).map_err(|e| e.to_string())?), + "clip.sourceSettings" => { + let info = s.execute("clip.sourceSettings", json!({})).map_err(|e| e.to_string())?; + (source_settings_params(&info), info) + } "clip.updateMetadata" => { let n = s.state.project_selection.len().max(s.state.selection.len()); (json!({}), json!({"count": n})) @@ -369,6 +373,76 @@ fn defaults(app: &mut FilmcraftApp, cmd: &str, id: &str) -> Result<(Value, Value }) } +/// The Source Settings dialog's parameters for camera raw footage (`clip.sourceSettings`'s +/// description `info`): the item's settings, a custom white balance starting from the file's +/// as-shot one. Empty for other footage. The dialog edits the first selected clip only: it shows +/// that clip's values, and applying them to other selected clips would overwrite their own settings +/// with it. `clip.sourceSettings` itself takes several `items`. +fn source_settings_params(info: &Value) -> Value { + if info["raw"] != true { + return json!({}); + } + let st = &info["settings"]; + let pick = |k: &str, fallback: f64| st[k].as_f64().or_else(|| info["asShot"][k].as_f64()).unwrap_or(fallback); + json!({ + "items": [info["item"]], + "whiteBalance": st["whiteBalance"], + "temperature": pick("temperature", 6500.0).round(), + "tint": pick("tint", 0.0).round(), + "exposure": st["exposure"], + "highlights": st["highlights"], + "demosaic": st["demosaic"], + "output": st["output"], + }) +} + +/// Source Settings of camera raw footage (Premiere's Source Settings for raw formats): white +/// balance (As Shot or a custom temperature and tint), exposure, highlights, demosaic quality and +/// the output colour space. Automation ids: `sourceSettings.whiteBalance.`, +/// `.temperature`, `.tint`, `.exposure`, `.demosaic.`, `.reset`, `.ok`, `.cancel`. +fn source_settings_ui(ui: &mut egui::Ui, elems: &mut Elems, pre: &str, p: &mut Value, info: &Value) { + ui.label(RichText::new(info["pixelFormat"].as_str().unwrap_or_default()).weak()); + ui.separator(); + ui.label(RichText::new(tl!("White Balance")).strong()); + radios(ui, elems, pre, p, "whiteBalance", tl!("White balance:"), &[("asShot", tl!("As Shot")), ("custom", tl!("Custom"))]); + if p["whiteBalance"] == "custom" { + let range = |k: &str, lo: f64, hi: f64| info["ranges"][k][0].as_f64().unwrap_or(lo)..=info["ranges"][k][1].as_f64().unwrap_or(hi); + number(ui, elems, pre, p, "temperature", tl!("Temperature:"), NumberOptions { range: range("temperature", 2000.0, 15000.0), suffix: " K" }); + number(ui, elems, pre, p, "tint", tl!("Tint:"), NumberOptions { range: range("tint", -150.0, 150.0), suffix: "" }); + } else if let (Some(t), Some(n)) = (info["asShot"]["temperature"].as_f64(), info["asShot"]["tint"].as_f64()) { + ui.label(RichText::new(tlf!("As shot: {temperature} K, tint {tint}", temperature = t.round(), tint = n.round())).weak()); + } + ui.label(RichText::new(tl!("Tone")).strong()); + let lo = info["ranges"]["exposure"][0].as_f64().unwrap_or(-8.0); + let hi = info["ranges"]["exposure"][1].as_f64().unwrap_or(8.0); + ui.horizontal(|ui| { + ui.label(tl!("Exposure:")); + let mut v = p["exposure"].as_f64().unwrap_or(0.0); + let r = ui.add(egui::Slider::new(&mut v, lo..=hi).step_by(0.05).suffix(tl!(" stops"))); + push(elems, format!("{pre}.exposure"), &r, "Exposure"); + if r.changed() { + p["exposure"] = json!(v); + } + }); + ui.label(tl!("Highlights: clipped (highlight recovery is not available yet)")); + ui.label(RichText::new(tl!("Decode Quality")).strong()); + radios(ui, elems, pre, p, "demosaic", tl!("Demosaic:"), &[("high", tl!("High (gradient-corrected)")), ("fast", tl!("Fast (bilinear)"))]); + ui.label(tl!("Output color space: scene-linear Rec. 709")); + let r = ui.button(tl!("Reset to As Shot")); + push(elems, format!("{pre}.reset"), &r, "Reset"); + if r.clicked() { + p["whiteBalance"] = json!("asShot"); + p["exposure"] = json!(0.0); + p["demosaic"] = json!("high"); + p["highlights"] = json!("clip"); + p["output"] = json!("sceneLinearRec709"); + if let (Some(t), Some(n)) = (info["asShot"]["temperature"].as_f64(), info["asShot"]["tint"].as_f64()) { + p["temperature"] = json!(t.round()); + p["tint"] = json!(n.round()); + } + } +} + fn check(ui: &mut egui::Ui, elems: &mut Elems, pre: &str, p: &mut Value, key: &str, label: &str) { let mut v = p.get(key).and_then(Value::as_bool).unwrap_or(false); let r = ui.checkbox(&mut v, label); @@ -468,7 +542,12 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut elems: Elems = Vec::new(); let mut action: Option<&'static str> = None; let mut browse: Option = None; - let info_only = matches!(d.command.as_str(), "file.mediaProperties" | "clip.sourceSettings" | "help.systemCompatibilityReport"); + let info_only = match d.command.as_str() { + "file.mediaProperties" | "help.systemCompatibilityReport" => true, + // camera raw footage has settings to change; other footage only says why it has none + "clip.sourceSettings" => d.info["raw"] != true, + _ => false, + }; // the title is translated; the window keeps one id whatever the interface language let id = egui::Id::unique(("menu-dialog", pre)); egui::Window::new(title).id(id).collapsible(false).resizable(false).default_width(420.0).centered(ctx).show(ctx, |ui| { @@ -692,7 +771,11 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } "clip.sourceSettings" => { ui.label(tlf!("Codec: {codec}", codec = d.info["codec"].as_str().unwrap_or_default())); - ui.label(RichText::new(d.info["message"].as_str().unwrap_or_default()).weak()); + if d.info["raw"] == true { + source_settings_ui(ui, &mut elems, pre, p, &d.info); + } else { + ui.label(RichText::new(d.info["message"].as_str().unwrap_or_default()).weak()); + } } "clip.updateMetadata" => { ui.label(tlf!("Write the metadata of the selected clip(s) ({n}) to XMP files next to their media?", n = d.info["count"])); @@ -902,6 +985,18 @@ fn run(app: &mut FilmcraftApp, d: &ClipDialogDraft) -> Result, St } Ok(None) } + "clip.sourceSettings" => { + // the as-shot white balance takes no temperature / tint + let mut q = p.clone(); + if q["whiteBalance"] == "asShot" + && let Some(m) = q.as_object_mut() + { + m.remove("temperature"); + m.remove("tint"); + } + s.execute("clip.sourceSettings", q).map_err(|e| e.to_string())?; + Ok(None) + } "file.saveAsTemplate" => { let r = s.execute("file.saveAsTemplate", p.clone()).map_err(|e| e.to_string())?; app.ui.status = tlf!("Saved template {path}", path = r["path"].as_str().unwrap_or_default()); diff --git a/crates/ui-egui/tests/it/menus_ui.rs b/crates/ui-egui/tests/it/menus_ui.rs index eafdb0eb95..ceca13be14 100644 --- a/crates/ui-egui/tests/it/menus_ui.rs +++ b/crates/ui-egui/tests/it/menus_ui.rs @@ -1,7 +1,8 @@ //! Headless UI tests of the M3.11 menu long tail: Find, Create Search Bin (and its Project panel //! rows), Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence, //! Normalize Mix Track, Dynamic Audio Waveforms, Reveal Log Files and the System Compatibility -//! Report, all driven by automation id through the control channel. +//! Report, and Clip ▸ Source Settings… for camera raw footage, all driven by automation id through +//! the control channel. use std::sync::mpsc::{Sender, channel}; @@ -333,6 +334,74 @@ fn color_matte_dialog_picks_and_changes_the_color() { assert!(!d.dialog_open()); } +/// A 24 × 16 RGGB DNG of a flat grey (12-bit, lossless JPEG): camera raw footage. +fn dng() -> Vec { + use filmcraft_dng::tiff::tags; + use filmcraft_dng::writer::{Compression, Options, Value, write}; + let o = Options { + width: 24, + height: 16, + bits: 12, + compression: Compression::LosslessJpeg { predictor: 1, components: 2, restart_lines: 0, pt: 0 }, + raw_tags: vec![(tags::WHITE_LEVEL, Value::Short(vec![4095]))], + main_tags: vec![(tags::AS_SHOT_NEUTRAL, Value::rationals(&[1.0, 1.0, 1.0]))], + ..Default::default() + }; + write(&vec![1000u16; 24 * 16], &o) +} + +/// Clip ▸ Source Settings… edits camera raw footage (white balance, exposure, demosaic) as one +/// undo step; for other footage it only says why there are no source settings. +#[test] +fn source_settings_dialog_for_camera_raw() { + let mut d = Driver::new(); + let dir = std::env::temp_dir().join(format!("filmcraft-source-settings-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("A001_C001_still.dng"); + std::fs::write(&path, dng()).unwrap(); + let r = d.exec("file.import", json!({"paths": [path.to_string_lossy()]})); + let id = r["items"][0].as_u64().unwrap(); + d.exec("project.select", json!({"items": [id]})); + assert_eq!(d.menu("clip.sourceSettings")["dialog"], "sourceSettings"); + for k in ["whiteBalance.asShot", "whiteBalance.custom", "exposure", "demosaic.high", "demosaic.fast", "reset", "ok", "cancel"] { + assert!(d.has(&format!("sourceSettings.{k}")), "{k}"); + } + assert!(!d.has("sourceSettings.temperature"), "As Shot has no temperature control"); + d.click("sourceSettings.whiteBalance.custom"); + assert!(d.has("sourceSettings.temperature") && d.has("sourceSettings.tint")); + d.ok("ui.set", json!({"menuDialog": {"temperature": 4000.0, "tint": 5.0, "exposure": 1.0}})); + d.frames(2); + d.click("sourceSettings.demosaic.fast"); + let undo = d.app().session.history.undo.len(); + d.click("sourceSettings.ok"); + assert!(!d.dialog_open()); + let raw = d.app().session.project.item(ItemId(id)).unwrap().as_media().unwrap().interpret.raw.unwrap(); + assert_eq!(raw.white_balance, filmcraft_dng::WhiteBalance::Custom { temperature: 4000.0, tint: 5.0 }); + assert_eq!((raw.exposure, raw.demosaic), (1.0, filmcraft_dng::DemosaicQuality::Fast)); + assert_eq!(d.app().session.history.undo.len(), undo + 1); + // reopened, the dialog shows the item's settings; Reset to As Shot + OK develops as the file says + d.menu("clip.sourceSettings"); + assert!(d.has("sourceSettings.temperature")); + assert_eq!(d.app().ui.extras.dialog.as_ref().unwrap().params["exposure"], 1.0); + d.click("sourceSettings.reset"); + d.click("sourceSettings.ok"); + assert!(d.app().session.project.item(ItemId(id)).unwrap().as_media().unwrap().interpret.raw.is_none()); + // Cancel changes nothing + d.menu("clip.sourceSettings"); + d.click("sourceSettings.whiteBalance.custom"); + d.click("sourceSettings.cancel"); + assert!(!d.dialog_open()); + assert!(d.app().session.project.item(ItemId(id)).unwrap().as_media().unwrap().interpret.raw.is_none()); + // other footage: an information dialog + let ocean = d.item_named("Ocean_Sunset.mp4"); + d.exec("project.select", json!({"items": [ocean]})); + d.menu("clip.sourceSettings"); + assert!(d.has("sourceSettings.ok") && !d.has("sourceSettings.cancel") && !d.has("sourceSettings.exposure")); + d.click("sourceSettings.ok"); + assert!(!d.dialog_open()); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn reveal_in_finder_opens_an_imported_clip() { let dir = std::env::temp_dir().join(format!("fc-ui-reveal-{}", std::process::id())); diff --git a/docs/codec-parity.md b/docs/codec-parity.md index 5644de3b73..4ad6b8f84d 100644 --- a/docs/codec-parity.md +++ b/docs/codec-parity.md @@ -1,6 +1,6 @@ # Codec parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (Apple Intermediate Codec row) · **Target:** Adobe Premiere Pro 2026 (26.5.2) and Media Encoder 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (CinemaDNG in MXF and camera raw Source Settings) · **Target:** Adobe Premiere Pro 2026 (26.5.2) and Media Encoder 2026 Video and audio codecs, decode and encode, with fidelity and how each is tested. Containers, project and interchange files are in [file-format-parity.md](file-format-parity.md); hardware @@ -31,7 +31,7 @@ contents). Percent is ready-for-real-work for that codec: depth, profiles, speed | MPEG-4 Part 2 | decode | none | none | — | 0% | 10–20 h | | Apple Intermediate Codec (`icod`, iMovie / Final Cut) | not in Adobe's supported-formats list | none (#3646): Apple publishes no specification, so a clean-room decoder needs a maintainer ruling ([AGENTS.md](../AGENTS.md) §2); import names the codec and the Project panel's Unreadable badge shows "no decoder for Apple Intermediate Codec video" on hover | none | — | 0% | — (no estimate without a specification) | | WMV / VC-1 | decode, encode on Windows | none | none | — | 0% | 20–35 h | -| CinemaDNG / DNG | decode | own decoder (#1065): uncompressed and lossless JPEG raw, Bayer and LinearRaw, DNG colour model to linear Rec. 709 floats; folders of `.dng` frames as one clip with the CinemaDNG frame rate and time code; **no** lossy / JPEG XL DNG, MXF-wrapped CinemaDNG, opcode lists or Raw settings UI | — | raw samples bit-exact (and identical to LibRaw's on our synthetic files), processing against hand-computed values | 60% | 10–20 h | +| CinemaDNG / DNG | decode | own decoder (#1065): uncompressed and lossless JPEG raw, Bayer and LinearRaw, DNG colour model to linear Rec. 709 floats; folders of `.dng` frames as one clip with the CinemaDNG frame rate and time code; CinemaDNG in MXF (frame-wrapped DNG, the specification's provisional labels; synthetic files only, no camera file tested); Clip ▸ Source Settings… (white balance as shot or temperature / tint, exposure, demosaic quality; output scene-linear Rec. 709), reduced-resolution development for ½–⅛ playback; **no** lossy / JPEG XL DNG, opcode lists or highlight recovery | — | raw samples bit-exact (and identical to LibRaw's on our synthetic files), processing against hand-computed values; MXF frames and Source Settings checked against the synthetic DNGs' known values | 70% | 10–20 h | | Camera RAW: R3D (incl. R3D NE), ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW (plug-in) | decode (vendor SDKs) | none (#383) | — | — | 0% | 50–90 h for the ones with public specs; vendor-SDK-only formats need an owner decision | | GIF (animated) | encode | decode as still | encode | — | 80% | — | | Uncompressed / v210 / RGB | decode + encode (AVI, MOV) | partial (MOV PCM-style video not verified) | none | — | 30% | 8–15 h | @@ -62,6 +62,7 @@ contents). Percent is ready-for-real-work for that codec: depth, profiles, speed | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | CinemaDNG in MXF and camera raw Source Settings (#1065) | | 2026-11-02 | minor | Apple Intermediate Codec row: named on import and in the no-decoder error, no decoder (#3646) | | 2026-10-22 | minor | ProRes alpha final-run syntax regression tests and native macOS HD decode validation | | 2026-10-11 | minor | CinemaDNG / DNG decode (#1065) | diff --git a/docs/gaps.md b/docs/gaps.md index b7897449fd..2ab8dcee50 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -193,19 +193,20 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** transform, enhanced coupling and the extra channels of 7.1 dependent substreams (played as the 5.1 core), WMV / ASF, DV / DVCPRO / DV100, MPEG-4 Part 2, camera RAW (R3D incl. R3D NE, ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW, ProRes RAW; #345, #383), JPEG 2000 / - JPEG XS MXF, the rest of CinemaDNG (lossy / JPEG XL DNG, MXF-wrapped, opcode lists, Raw - settings; DNG frames and folders import since #1065); stills: PSD layers (Merged Layers, Individual Layers, layers as a sequence) and PSB / CMYK / Lab / indexed / 32-bit / ZIP-compressed documents, EXR beyond scan-line RLE / ZIP / PXR24 (PIZ, B44, DWA, - tiled, multi-part, deep), HEIF / HEIC, - AI / EPS, and the rarer DPX layout, run-length encoding (Targa and DPX stills and sequences import - since #821, packed 10/12-bit and Y'CbCr DPX since #841, deeper than 8 bits kept at 8); transparent - video (#402); Sony start timecode in `rtmd` (#460); stereo 3D flag (#500); MKV multi-audio (#601); - EXIF Orientation on uncompressed / PackBits YCbCr TIFF and DNG stills and on the export image - overlay (`load_overlay` in `crates/export/src/pipeline.rs`); JPEG, PNG, WebP and other TIFF stills - apply it since #1583, except a WebP or PNG whose EXIF chunk starts with an `Exif\0\0` prefix - (decoded unturned). Projects saved before #1583 keep the stored size of a portrait still: one - already placed in a sequence shows upright but scaled up by the photo's aspect ratio (4/3 for a - 4:3 shot) and off-centre (turned twice if it was rotated by hand); reset the clip's Motion or - place it again. + JPEG XS MXF, the rest of CinemaDNG (lossy / JPEG XL DNG, opcode lists, highlight + recovery; DNG frames, folders and CinemaDNG MXF import, with Source Settings, since + #1065); stills: PSD layers (Merged Layers, Individual Layers, layers as a sequence) and + PSB / CMYK / Lab / indexed / 32-bit / ZIP-compressed documents, EXR beyond scan-line RLE / ZIP / + PXR24 (PIZ, B44, DWA, tiled, multi-part, deep), HEIF / HEIC, AI / EPS, and the rarer DPX layout, + run-length encoding (Targa and DPX stills and sequences import since #821, packed 10/12-bit and + Y'CbCr DPX since #841, deeper than 8 bits kept at 8); transparent video (#402); Sony start + timecode in `rtmd` (#460); stereo 3D flag (#500); MKV multi-audio (#601); EXIF Orientation on + uncompressed / PackBits YCbCr TIFF and DNG stills and on the export image overlay + (`load_overlay` in `crates/export/src/pipeline.rs`); JPEG, PNG, WebP and other TIFF stills apply + it since #1583, except a WebP or PNG whose EXIF chunk starts with an `Exif\0\0` prefix (decoded + unturned). Projects saved before #1583 keep the stored size of a portrait still: one already + placed in a sequence shows upright but scaled up by the photo's aspect ratio (4/3 for a 4:3 shot) + and off-centre (turned twice if it was rotated by hand); reset the clip's Motion or place it again. - **Estimate:** 120–200 h (camera RAW alone 60–100 h, and some SDKs have licences we cannot use: clean-room from public specs only where specs exist). Docs: [codec-parity.md](codec-parity.md), [file-format-parity.md](file-format-parity.md). diff --git a/docs/project-files.md b/docs/project-files.md index fcfd6f9c89..8bec2331fd 100644 --- a/docs/project-files.md +++ b/docs/project-files.md @@ -57,6 +57,7 @@ writing them is `crates/format` (`filmcraft-format`); the engine's `file.*` comm | 15 | Project notes | Project notes default empty; no-op step | | 16 | Marker and custom colors | Marker authors, custom marker/clip colors and named project palettes; existing presets remain unchanged and new fields default empty; no-op step | | 17 | Marker URLs | Marker URLs default empty; older builds refuse v17 instead of silently discarding saved URLs; no-op step | +| 18 | Camera raw settings | camera raw Source Settings on media items (`Interpretation::raw`: white balance, exposure, highlights, demosaic quality, output colour space); absent = as the file says; no-op step | ### Migrations