mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-21 11:13:40 -05:00
Merge pull request #1216
This commit is contained in:
commit
25f8bb17f4
4 files changed
+182
-2
No files matched your search
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
Reference in new issue
Block a user