Files
effectcraft/crates/media/tests/exif_orientation.rs
pravdomilandClaude Opus 5.5 683ec372d9 Import: stills respect their Exif orientation
A photo taken with the camera held sideways (or a mirrored scan) imported
as stored, lying on its side. Probing now reports the size the picture is
seen at, and the still and smart-object decodes apply the rotation or
mirroring to the pixels. A missing or invalid tag decodes as stored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:03:47 +02:00

85 lines
3.6 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<u8>) -> Vec<u8> {
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<u8>) -> ((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:?}");
}
}
}