diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 0d7803fd..19a7ba31 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -25,7 +25,7 @@ filmcraft-color = { workspace = true } filmcraft-time = { workspace = true } exr = { workspace = true } gif = { workspace = true } -image = { workspace = true, features = ["gif", "webp", "tiff", "bmp", "exr"] } +image = { workspace = true, features = ["gif", "webp", "tiff", "bmp", "exr", "tga", "hdr"] } rayon = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index fe338fca..70f7e5fe 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -10,7 +10,7 @@ //! Video containers (MP4/MOV, Matroska/WebM) and codecs (H.264, HEVC, VP9, AV1, ProRes, DNxHD, //! MJPEG; AAC, Opus, PCM, MP3/FLAC/Vorbis) come from FilmCraft's pure-Rust crates (git dependency, //! pinned; `plan/adr/0001`). FilmCraft types never leave this crate. Stills use the `image` crate -//! (PNG, JPEG, GIF, WebP, TIFF, BMP, OpenEXR); Photoshop documents (merged image or one layer) come +//! (PNG, JPEG, GIF, WebP, TIFF, BMP, OpenEXR, Targa, Radiance HDR); Photoshop documents (merged image or one layer) come //! from `effectcraft-psd` and SVG from `effectcraft-svg` (rasterised at any scale). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -51,7 +51,8 @@ impl From for MediaError { } /// File extensions recognised as stills. -pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "psd", "psb", "svg", "pdf", "ai", "eps"]; +pub const STILL_EXTENSIONS: &[&str] = + &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "tga", "hdr", "psd", "psb", "svg", "pdf", "ai", "eps"]; /// File extensions recognised as audio-only files. pub const AUDIO_EXTENSIONS: &[&str] = filmcraft_media::AUDIO_EXTENSIONS; /// File extensions recognised as movies. @@ -73,6 +74,19 @@ pub(crate) fn ext_of(path: &std::path::Path) -> String { path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase() } +/// The `image` crate format of a still: from its signature, else from its extension (Targa has +/// no signature; a `.hdr` without the Radiance one then fails with the decoder's own error). +pub(crate) fn still_format(path: &str, bytes: &[u8]) -> Option { + if let Ok(fmt) = image::guess_format(bytes) { + return Some(fmt); + } + match ext_of(std::path::Path::new(path)).as_str() { + "tga" => Some(image::ImageFormat::Tga), + "hdr" => Some(image::ImageFormat::Hdr), + _ => None, + } +} + pub(crate) fn rate_from_fc(r: filmcraft_time::FrameRate) -> effectcraft_time::FrameRate { if r.num > 0 && r.den > 0 { effectcraft_time::FrameRate::new(r.num, r.den) } else { effectcraft_time::FrameRate::FPS_30 } } diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 5026cdde..b54eafbc 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -746,7 +746,11 @@ impl Inner { { return Err(MediaError::Decode(format!("{path}: {why}"))); } - let img = match image::load_from_memory(&bytes) { + let loaded = match crate::still_format(path, &bytes) { + Some(fmt) => image::load_from_memory_with_format(&bytes, fmt), + None => image::load_from_memory(&bytes), + }; + let img = match loaded { 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 63bed490..7e405049 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -66,7 +66,7 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result { if crate::MODEL_EXTENSIONS.contains(&ext_of(Path::new(path)).as_str()) { return probe_model(path, &bytes, &|_| None); } - if let Ok(fmt) = image::guess_format(&bytes) { + if let Some(fmt) = crate::still_format(path, &bytes) { // An animated GIF is a movie; one with a single frame is a still. if fmt == image::ImageFormat::Gif && let Some(f) = crate::gif_anim::probe(path, &bytes) diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index c1353ee3..f988ff0c 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -579,6 +579,68 @@ fn exr_still_is_linear_float() { assert!(b[3] == 0.5 && b[0] > 0.85 && b[0] < 1.0, "{b:?}"); } +/// #745: a Targa file has no signature, so its format comes from the extension; it imports as +/// a still with straight alpha, identical to ffmpeg's own decode of the source. +#[test] +fn targa_still_matches_ffmpeg() { + let Some(png) = alpha_png() else { return }; + // ffmpeg's Targa encoder (run-length compressed) + let Some(tga) = ffmpeg_fixture("alpha.tga", &["-i", png.to_str().expect("utf8")]) else { return }; + assert!(effectcraft_media::is_importable(&tga)); + let f = probe(&tga).expect("probe"); + assert_eq!(f.kind, FootageKind::Still); + assert_eq!((f.width, f.height), (256, 128)); + assert_eq!(f.codec, "TGA"); + assert_eq!(f.alpha, AlphaMode::Straight); + let img = MediaPool::new().frame_at(&f, Tick::ZERO).expect("frame"); + assert_eq!(*img, load_png(&png)); + // hostile input: a truncated file is refused or decodes partially, never panics + let bytes = std::fs::read(&tga).expect("read"); + for n in [0, 5, 18, 40, bytes.len() / 2] { + let cut: Arc<[u8]> = bytes.get(..n).expect("cut").into(); + if let Ok(f) = effectcraft_media::probe_bytes("cut.tga", cut.clone()) { + let pool = MediaPool::new(); + pool.add_bytes("cut.tga", cut); + let _ = pool.frame_at(&f, Tick::ZERO); + } + } +} + +/// #745: a Radiance HDR file holds linear float light, sRGB-encoded like OpenEXR (values above 1 +/// kept); ffmpeg's encoder is the oracle for the file layout. +#[test] +fn radiance_hdr_still_is_linear_float() { + // written with the image crate's encoder: linear 0.214 ≈ sRGB 0.5, and super-white 4.0 + let dir = fixtures(); + std::fs::create_dir_all(&dir).expect("mkdir"); + let p = dir.join(format!("float-{}.hdr", std::process::id())); + let px = vec![0.214f32, 0.214, 0.214, 4.0, 4.0, 4.0]; + image::DynamicImage::ImageRgb32F(image::ImageBuffer::from_raw(2, 1, px).expect("buf")).save(&p).expect("write hdr"); + assert!(effectcraft_media::is_importable(&p)); + let f = probe(&p).expect("probe"); + assert_eq!(f.kind, FootageKind::Still); + assert_eq!((f.width, f.height), (2, 1)); + assert_eq!(f.codec, "HDR"); + assert_eq!(f.alpha, AlphaMode::Ignore); + let img = MediaPool::new().frame_at(&f, Tick::ZERO).expect("frame"); + std::fs::remove_file(&p).ok(); + let a = img.data[0]; + assert!((a[0] - 0.5).abs() < 5e-3 && a[3] == 1.0, "{a:?}"); + assert!(img.data[1][0] > 1.5, "super-white kept: {:?}", img.data[1]); + + // ffmpeg's Radiance encoder (run-length compressed scanlines): a mid grey of 0.5 linear + let Some(ff) = ffmpeg_fixture("grey.hdr", &["-f", "lavfi", "-i", "color=c=0x808080:size=64x16", "-frames:v", "1", "-pix_fmt", "gbrpf32le"]) else { + return; + }; + let f = probe(&ff).expect("probe ffmpeg hdr"); + assert_eq!((f.width, f.height), (64, 16)); + let img = MediaPool::new().frame_at(&f, Tick::ZERO).expect("frame"); + let want = 1.055 * (128.0f32 / 255.0).powf(1.0 / 2.4) - 0.055; + for q in &img.data { + assert!((q[0] - want).abs() < 0.01 && (q[1] - want).abs() < 0.01 && (q[2] - want).abs() < 0.01 && q[3] == 1.0, "{q:?} vs {want}"); + } +} + /// A FLAC of `secs` seconds at `rate` Hz with `channels` channels of a 440 Hz sine (first two: /// −24 dBFS RMS, the rest quieter), samples of `fmt` (`s16`, or `s32` with 24 significant bits). fn sine_flac(secs: u32, rate: u32, channels: u32, fmt: &str) -> Option { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 52e7625c..c34b98b1 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1237,7 +1237,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option