diff --git a/contributors/people.toml b/contributors/people.toml index 2fa1927c..bc5259f4 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -66,7 +66,7 @@ sync_github_name = true display_name = "Jason Brashear" [people.nanox333] -display_name = "nanox333" +display_name = "nnaannoo" [people.Nael-Nathanael] sync_github_name = true diff --git a/crates/host/tests/tga_import.rs b/crates/host/tests/tga_import.rs new file mode 100644 index 00000000..5462f4be --- /dev/null +++ b/crates/host/tests/tga_import.rs @@ -0,0 +1,48 @@ +//! Targa import through the same host and commands used by the desktop and CLI. + +use effectcraft_engine::render::RenderOpts; +use effectcraft_project::{FootageKind, ItemId, ItemKind}; +use effectcraft_time::{FrameRate, Tick}; +use serde_json::json; + +#[test] +fn tga_stills_and_numbered_sequences_import_and_render() { + let dir = std::env::temp_dir().join(format!("effectcraft-tga-import-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + // A generated 4×4, 24-bit, top-origin TGA. No binary fixtures are retained. + let write = |name: &str, bgr: [u8; 3]| { + let mut bytes = vec![0; 18]; + bytes[2] = 2; + bytes[12] = 4; + bytes[14] = 4; + bytes[16] = 24; + bytes[17] = 0x20; + bytes.extend_from_slice(&bgr.repeat(16)); + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + path.to_string_lossy().to_string() + }; + let still = write("still.TGA", [0, 0, 255]); + let first = write("shot_0001.tga", [0, 0, 255]); + write("shot_0002.tga", [255, 0, 0]); + for (path, sequence) in [(still, false), (first, true)] { + let mut session = effectcraft_host::session(); + let imported = session.execute_checked("file.import", json!({"paths": [path], "sequence": sequence})).unwrap(); + assert_eq!(imported["errors"], json!([]), "{imported}"); + assert_eq!(imported["items"].as_array().unwrap().len(), 1); + let item = ItemId(imported["items"][0].as_u64().unwrap()); + let ItemKind::Footage(footage) = &session.project.item(item).unwrap().kind else { panic!("not footage") }; + assert_eq!(footage.kind, if sequence { FootageKind::Sequence } else { FootageKind::Still }); + assert_eq!(footage.sequence.len(), if sequence { 2 } else { 0 }); + session.execute_checked("comp.new", json!({"name": "TGA", "width": 4, "height": 4, "frameRate": 30, "duration": 1})).unwrap(); + session.execute_checked("layer.addItem", json!({"item": item.0})).unwrap(); + let comp = session.active_comp_id().unwrap(); + let image = session.render(comp, Tick::ZERO, RenderOpts::default()); + assert_eq!(image.get(2, 2), [1.0, 0.0, 0.0, 1.0]); + if sequence { + let image = session.render(comp, FrameRate::FPS_30.tick_of(1), RenderOpts::default()); + assert_eq!(image.get(2, 2), [0.0, 0.0, 1.0, 1.0]); + } + } + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/media/tests/tga.rs b/crates/media/tests/tga.rs new file mode 100644 index 00000000..61aacd95 --- /dev/null +++ b/crates/media/tests/tga.rs @@ -0,0 +1,115 @@ +//! Tiny synthetic Targa files exercise import without external fixtures or codecs. + +use std::path::Path; +use std::sync::Arc; + +use effectcraft_media::{MediaPool, is_importable, probe_bytes}; +use effectcraft_project::{AlphaMode, Footage, FootageKind}; +use effectcraft_time::Tick; + +/// A classic 2×2 TGA without a footer. True-colour pixels are stored BGR(A); RLE +/// packets repeat each row's colour twice. `pixels` is always in top-down RGBA order. +fn tga(pixels: [[u8; 4]; 4], alpha: bool, rle: bool, top: bool) -> Vec { + let mut bytes = vec![0; 18]; + bytes[2] = if rle { 10 } else { 2 }; + bytes[12] = 2; + bytes[14] = 2; + bytes[16] = if alpha { 32 } else { 24 }; + bytes[17] = if top { 0x20 } else { 0 } | if alpha { 8 } else { 0 }; + let order = if top { [0, 1, 2, 3] } else { [2, 3, 0, 1] }; + for row in order.chunks_exact(2) { + if rle { + assert_eq!(pixels[row[0]], pixels[row[1]]); + bytes.push(0x81); // A two-pixel run. + } + for &i in row.iter().take(if rle { 1 } else { 2 }) { + let [r, g, b, a] = pixels[i]; + bytes.extend_from_slice(&[b, g, r]); + if alpha { + bytes.push(a); + } + } + } + bytes +} + +#[test] +fn tga_raw_and_rle_preserve_colours_orientation_and_alpha() { + for alpha in [false, true] { + for rle in [false, true] { + for top in [false, true] { + let pixels = if rle { + [[255, 0, 0, 128], [255, 0, 0, 128], [0, 0, 255, 255], [0, 0, 255, 255]] + } else { + [[255, 0, 0, 128], [0, 255, 0, 255], [0, 0, 255, 64], [255, 255, 255, 0]] + }; + let path = if top { "/frame.TGA" } else { "/frame.tga" }; + assert!(is_importable(Path::new(path))); + let bytes: Arc<[u8]> = tga(pixels, alpha, rle, top).into(); + let footage = probe_bytes(path, bytes.clone()).unwrap(); + assert_eq!(footage.kind, FootageKind::Still); + assert_eq!((footage.width, footage.height), (2, 2)); + assert_eq!(footage.codec, "TGA"); + assert_eq!(footage.alpha, if alpha { AlphaMode::Straight } else { AlphaMode::Ignore }); + assert!(footage.has_video && !footage.has_audio); + let pool = MediaPool::new(); + pool.add_bytes(path, bytes); + let image = pool.frame_at(&footage, Tick::ZERO).unwrap(); + for (actual, [r, g, b, a]) in image.data.iter().zip(pixels) { + let a = if alpha { f32::from(a) / 255.0 } else { 1.0 }; + let expected = [f32::from(r) / 255.0 * a, f32::from(g) / 255.0 * a, f32::from(b) / 255.0 * a, a]; + assert!(actual.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-6), "{actual:?} != {expected:?}"); + } + } + } + } +} + +#[test] +fn tga_sequence_decodes_each_frame_from_memory() { + let pool = MediaPool::new(); + pool.set_read_ahead(false); + let paths = ["/shot_0001.tga", "/shot_0002.tga"]; + let colours = [[255, 0, 0, 255], [0, 0, 255, 255]]; + let mut footage = None; + for (path, colour) in paths.iter().zip(colours) { + let bytes: Arc<[u8]> = tga([colour; 4], false, true, true).into(); + footage.get_or_insert_with(|| probe_bytes(path, bytes.clone()).unwrap()); + pool.add_bytes(path, bytes); + } + let mut footage = Footage { kind: FootageKind::Sequence, sequence: paths.iter().map(|s| s.to_string()).collect(), ..footage.unwrap() }; + footage.sync_sequence_duration(); + for (n, expected) in [[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]].into_iter().enumerate() { + let image = pool.frame_at(&footage, footage.frame_rate.tick_of(n as i64)).unwrap(); + assert_eq!(image.get(0, 0), expected); + } +} + +#[test] +fn malformed_tga_returns_contextual_errors() { + for bytes in [vec![], vec![0; 17], vec![0; 18]] { + let error = probe_bytes("/broken.tga", bytes.into()).unwrap_err().to_string(); + assert!(error.contains("/broken.tga"), "{error}"); + } + let mut bytes = tga([[255, 0, 0, 255]; 4], true, false, true); + bytes.truncate(19); // Valid metadata, truncated pixel data. + let bytes: Arc<[u8]> = bytes.into(); + let footage = probe_bytes("/truncated.tga", bytes.clone()).unwrap(); + let pool = MediaPool::new(); + pool.add_bytes(&footage.path, bytes); + let error = pool.frame_at(&footage, Tick::ZERO).unwrap_err().to_string(); + assert!(error.contains("/truncated.tga"), "{error}"); +} + +#[test] +fn a_recognised_signature_takes_precedence_over_the_tga_extension() { + let mut bytes = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgb8(3, 2).write_to(&mut bytes, image::ImageFormat::Png).unwrap(); + let bytes: Arc<[u8]> = bytes.into_inner().into(); + let footage = probe_bytes("/actually-png.tga", bytes.clone()).unwrap(); + assert_eq!(footage.codec, "PNG"); + let pool = MediaPool::new(); + pool.add_bytes(&footage.path, bytes); + let image = pool.frame_at(&footage, Tick::ZERO).unwrap(); + assert_eq!((image.width, image.height), (3, 2)); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 26a7b66f..2309b81b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1366,7 +1366,9 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("path", Ask::Open(&["json", "csv", "txt"])), "essential.exportTemplate" => ("path", Ask::Save("Template.ectemplate")), "essential.importTemplate" => ("path", Ask::Open(&["ectemplate"])), - "file.setProxy" => ("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"])), + "file.setProxy" => { + ("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "exr"])) + } _ => return None, }; if params.get(key).is_some() @@ -2512,6 +2514,21 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn tga_is_available_in_import_replace_and_proxy_dialogs() { + use std::sync::{Arc, Mutex}; + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let ctx = egui::Context::default(); + let pending = Arc::new(Mutex::new(Vec::new())); + let queue = pending.clone(); + app.hooks.file_dialog_async = Some(Box::new(move |dialog, _done| queue.lock().unwrap().push(dialog))); + for command in ["file.import", "file.importMultiple", "file.replaceFootage", "file.setProxy"] { + assert_eq!(invoke(&mut app, &ctx, command, json!({})).unwrap(), json!({"dialog": command})); + let crate::FileDialog::Open { exts, .. } = pending.lock().unwrap().pop().unwrap() else { panic!("not an open dialog") }; + assert!(exts.iter().any(|ext| ext == "tga"), "{command}: {exts:?}"); + } + } + #[test] fn menu_items_cover_the_tree() { assert_eq!(menus().last(), Some(&"Help"));