Merge pull request #755 from vangel76/gap-tga-hdr-import

#745: import Targa (.tga) and Radiance HDR (.hdr) stills and sequences

# Conflicts:
#	crates/ui-egui/src/menus.rs
This commit is contained in:
Brandon Thomas committed 2026-10-12 03:20:03 -04:00
commit 05f0d9ec62
8 files changed
+90 -10

No files matched your search

+1 -1
View File
@@ -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 }
+16 -2
View File
@@ -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<filmcraft_media::MediaError> 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<image::ImageFormat> {
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 }
}
+5 -1
View File
@@ -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}")))?,
+1 -1
View File
@@ -66,7 +66,7 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result<Footage> {
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)
+62
View File
@@ -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<PathBuf> {
+1 -1
View File
@@ -1237,7 +1237,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
}
const MEDIA: &[&str] = &[
"mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "wav", "aif", "aiff", "mp3", "flac", "ogg",
"opus", "svg", "pdf", "ai", "eps", "psd", "psb", "gltf", "glb", "obj", "stl", "fbx", "json", "csv", "tsv",
"opus", "svg", "pdf", "ai", "eps", "psd", "psb", "tga", "hdr", "gltf", "glb", "obj", "stl", "fbx", "json", "csv", "tsv",
];
// A host whose modal dialogs would run a nested event loop inside this frame (macOS: a
// drag over the window then re-entered winit's event handler and aborted, #511) shows the
+3 -3
View File
@@ -48,9 +48,9 @@ trips and renders, checked by tests, not against After Effects), **partial** (na
| GIF | R/W | yes | yes (animated) | good |
| BMP | R | yes | — | good |
| WebP | — | yes | — | beyond After Effects |
| Targa `.tga` | R/W | none | none | Media Browser lists it but import refuses (gap 17) |
| DPX / Cineon `.dpx` `.cin` | R/W | none | none | Same; common in film VFX |
| Radiance `.hdr` | R/W | none | none | Same |
| Targa `.tga` | R/W | yes (still and sequence; alpha interpreted on import) | none | good for import (#745); no Targa output |
| DPX / Cineon `.dpx` `.cin` | R/W | none | none | Media Browser lists it but import refuses (gap 17); common in film VFX |
| Radiance `.hdr` | R/W | yes (still and sequence, 32-bit linear float like OpenEXR) | none | good for import (#745); no Radiance output |
| SGI, PICT, ElectricImage, RLA/RPF images | R | none | none | Legacy; RPF camera data import exists (`keys.rpfCameraImport`) |
| Camera Raw (DNG, CR3, NEF…) | R (Camera Raw) | none | none | LightCraft has decoders that could be shared |
| HEIF / AVIF | R (platform) | none | none | — |
+1 -1
View File
@@ -73,7 +73,7 @@ are grouped by area; the issue tracker has the rest.
| 14 | **Object Matte** (26.2, disk cache in 26.5) | Not present; MobileSAM is already in `effectcraft-segment` and could back it | The new one-click roto; Roto Brush covers part of it | 15–30 h | G7 · [target-app-parity.md](target-app-parity.md) |
| 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) |
| 16 | **Localization depth** | Menus in 6 languages; panels in 4; effect names and ~1,650 effect parameter names untranslated; French, German, Korean, Italian, Russian (After Effects ships them) absent; #599 Hangul boxes on macOS | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) |
| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists TGA, DPX, HDR, AVI, MXF; `media::is_importable` has none of them; #431 any image imports as a sequence; #324 MP3 goes silent | Confusing failures on common VFX formats (DPX, TGA) | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) |
| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists DPX, AVI, MXF; `media::is_importable` has none of them (Targa and Radiance HDR import since #745); #431 any image imports as a sequence; #324 MP3 goes silent | Confusing failures on common VFX formats (DPX) | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) |
| 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) |
| 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) |
| 20 | **Multi-monitor and video output** | #486 multi-screen, floating panels on other screens, Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) |