diff --git a/crates/media/src/layered.rs b/crates/media/src/layered.rs index e65aa7934..6b46f1df0 100644 --- a/crates/media/src/layered.rs +++ b/crates/media/src/layered.rs @@ -81,7 +81,7 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - let inner = effectcraft_psd::Psd::parse(data.to_vec()).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?; inner.composite().map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))? } else { - let img = image::load_from_memory(data).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?; + let img = crate::probe::load_oriented(data).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?; let rgba = img.to_rgba32f(); effectcraft_psd::Pixels { width: rgba.width(), height: rgba.height(), data: rgba.pixels().map(|p| p.0).collect() } }; diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index ce980c563..cdec5e3fb 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -738,7 +738,7 @@ impl Inner { { return Err(MediaError::Decode(format!("{path}: {why}"))); } - let img = match image::load_from_memory(&bytes) { + let img = match crate::probe::load_oriented(&bytes) { Ok(img) => img, // A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer. Err(e) => crate::exr_channels::cached_layered_image(path, bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?, diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 6e8718fdf..d443edce9 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use effectcraft_project::{AlphaMode, Footage, FootageKind}; use effectcraft_time::{FrameRate, Tick}; +use image::metadata::Orientation; use crate::{MediaError, Result, ext_of, rate_from_fc}; @@ -165,7 +166,12 @@ fn probe_still_bytes(path: &str, bytes: &[u8], fmt: image::ImageFormat) -> Resul } let reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), fmt); let (w, h, has_alpha) = match reader.into_decoder() { - Ok(dec) => (dec.dimensions().0, dec.dimensions().1, dec.color_type().has_alpha()), + Ok(mut dec) => { + // The size as displayed: an Exif orientation of a quarter turn swaps the sides. + let (w, h) = dec.dimensions(); + let (w, h) = if swaps_sides(dec.orientation().unwrap_or(Orientation::NoTransforms)) { (h, w) } else { (w, h) }; + (w, h, dec.color_type().has_alpha()) + } Err(e) => { // A multi-layer OpenEXR file without an unnamed RGB layer. let img = (fmt == image::ImageFormat::OpenExr).then(|| crate::exr_channels::layered_image(bytes)).flatten(); @@ -175,3 +181,20 @@ fn probe_still_bytes(path: &str, bytes: &[u8], fmt: image::ImageFormat) -> Resul }; Ok(still_footage(path, w, h, fmt, has_alpha)) } + +/// Whether an Exif orientation turns the picture a quarter turn (its width and height trade places). +fn swaps_sides(o: Orientation) -> bool { + matches!(o, Orientation::Rotate90 | Orientation::Rotate270 | Orientation::Rotate90FlipH | Orientation::Rotate270FlipH) +} + +/// Decode a still the way it is meant to be seen: its Exif orientation (a camera held sideways, a +/// mirrored scan) is applied to the pixels, as image viewers do. A file without +/// the tag, or with a broken one, decodes as stored. +pub(crate) fn load_oriented(bytes: &[u8]) -> image::ImageResult { + use image::ImageDecoder; + let mut dec = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()?.into_decoder()?; + let orientation = dec.orientation().unwrap_or(Orientation::NoTransforms); + let mut img = image::DynamicImage::from_decoder(dec)?; + img.apply_orientation(orientation); + Ok(img) +} diff --git a/crates/media/tests/exif_orientation.rs b/crates/media/tests/exif_orientation.rs new file mode 100644 index 000000000..d5acc3c2a --- /dev/null +++ b/crates/media/tests/exif_orientation.rs @@ -0,0 +1,84 @@ +//! A still's Exif orientation (a camera held sideways, a mirrored scan) is applied on import: the +//! footage has the size the picture is seen at, and its frames are the picture the right way up. + +use std::sync::Arc; + +use effectcraft_media::{MediaPool, probe_bytes}; +use effectcraft_project::FootageKind; +use effectcraft_time::Tick; + +const RED: [f32; 3] = [1.0, 0.0, 0.0]; +const GREEN: [f32; 3] = [0.0, 1.0, 0.0]; +const BLUE: [f32; 3] = [0.0, 0.0, 1.0]; +const WHITE: [f32; 3] = [1.0, 1.0, 1.0]; + +/// A 32×16 JPEG with red, green / blue, white quarters, tagged with Exif orientation `tag` +/// (`None`: no Exif at all). +fn quartered_jpeg(tag: Option) -> Vec { + let img = image::RgbImage::from_fn(32, 16, |x, y| { + let c = match (x < 16, y < 8) { + (true, true) => RED, + (false, true) => GREEN, + (true, false) => BLUE, + (false, false) => WHITE, + }; + image::Rgb(c.map(|v| (v * 255.0) as u8)) + }); + let mut jpeg = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 100).encode_image(&img).expect("encode"); + let Some(tag) = tag else { return jpeg }; + // APP1 "Exif": a big-endian TIFF header and one IFD holding Orientation (0x0112, SHORT). + let mut app1 = vec![0xFF, 0xE1, 0x00, 0x22]; + app1.extend_from_slice(b"Exif\0\0MM\0\x2A\0\0\0\x08"); + app1.extend_from_slice(&[0x00, 0x01, 0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, tag, 0x00, 0x00]); + app1.extend_from_slice(&[0x00; 4]); + // (Straight after the SOI marker.) + jpeg.splice(2..2, app1); + jpeg +} + +/// The footage size and the colours of its four corners (top-left, top-right, bottom-left, +/// bottom-right), as imported. +fn imported(tag: Option) -> ((u32, u32), [[f32; 3]; 4]) { + let name = format!("oriented-{tag:?}.jpg"); + let bytes: Arc<[u8]> = quartered_jpeg(tag).into(); + let f = probe_bytes(&name, bytes.clone()).expect("probe"); + assert_eq!(f.kind, FootageKind::Still); + let pool = MediaPool::new(); + pool.add_bytes(&name, bytes); + let img = pool.frame_at(&f, Tick::ZERO).expect("frame"); + assert_eq!((img.width, img.height), (f.width, f.height), "orientation {tag:?}: the frame has the probed size"); + let (w, h) = (img.width as usize, img.height as usize); + let at = |x: usize, y: usize| { + let p = img.data[y * w + x]; + [p[0], p[1], p[2]] + }; + ((f.width, f.height), [at(2, 2), at(w - 3, 2), at(2, h - 3), at(w - 3, h - 3)]) +} + +#[test] +fn exif_orientation_is_applied_to_stills() { + let (landscape, portrait) = ((32, 16), (16, 32)); + let cases = [ + (None, landscape, [RED, GREEN, BLUE, WHITE]), + (Some(1), landscape, [RED, GREEN, BLUE, WHITE]), + (Some(2), landscape, [GREEN, RED, WHITE, BLUE]), + (Some(3), landscape, [WHITE, BLUE, GREEN, RED]), + (Some(4), landscape, [BLUE, WHITE, RED, GREEN]), + (Some(5), portrait, [RED, BLUE, GREEN, WHITE]), + (Some(6), portrait, [BLUE, RED, WHITE, GREEN]), + (Some(7), portrait, [WHITE, GREEN, BLUE, RED]), + (Some(8), portrait, [GREEN, WHITE, RED, BLUE]), + // (Not an orientation: the picture as stored.) + (Some(0), landscape, [RED, GREEN, BLUE, WHITE]), + (Some(9), landscape, [RED, GREEN, BLUE, WHITE]), + ]; + for (tag, size, corners) in cases { + let (got_size, got) = imported(tag); + assert_eq!(got_size, size, "orientation {tag:?}: size"); + for (i, (g, want)) in got.iter().zip(corners).enumerate() { + let near = g.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.2); + assert!(near, "orientation {tag:?}: corner {i} is {g:?}, expected {want:?}"); + } + } +}