Merge pull request #1216

This commit is contained in:
Brandon Thomas committed 2026-10-18 09:35:32 -04:00
commit 25f8bb17f4
4 files changed
+182 -2

No files matched your search

+1 -1
View File
@@ -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
+48
View File
@@ -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();
}
+115
View File
@@ -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<u8> {
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));
}
+18 -1
View File
@@ -1366,7 +1366,9 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
"keys.rpfCameraImport" => ("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"));