mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 10:03:51 +00:00
* craft-aep: a flat read-only tree index, borrowed text documents, fewer allocations The container part of storytold/effectcraft#3302: - `TreeIndex`: the chunk tree as one flat array in depth-first order, borrowed from the input, for readers that only look. Same chunks counted against the same limits and the same errors as `parse`. An `.aepx` is indexed by `index_aepx` into one buffer. The typed view reads through either tree (`Node`, `Root`, `Project::from_index`). - Chunk parser: each list of siblings is counted, then reserved once. `.aepx`: hex decoded into an output sized once, attribute values without an entity borrowed. `write` reserves the exact output size. - Text documents are parsed into a tree that borrows from the body; the style and paragraph defaults are built once. - Layer markers and Source Text are read where they are stored instead of searched for. - Container accessors that only tests used are removed. What the importer and exporter in EffectCraft reach across the crate boundary is public here: `Root`, `Draft`, `AepxDrafts`, `index_aepx`, `TreeIndex::from_drafts`, `Project::of`, `ProjectSettings::of`, `Node::marker_list`, `Root::file_version` and `saved_by_version`, `StyleRun::SAMPLED`. `TimeBase::NTSC` holds the rates as plain numerator and denominator instead of an EffectCraft type. * craft-aep: one decoder for an effect parameter definition (`Pard`) The container part of storytold/effectcraft#3319: the fields of a `pard` record (type code, name, default, valid range, slider range, popup item count) are read by one type, `Pard`, which `EffectParam` now reads through. It is public so that an exporter's tests can read back what they encode instead of keeping a second decoder. The rest of that PR (import and export bounds, loss reports) is in EffectCraft's importer and exporter. --------- Co-authored-by: vangel76 <vangel1976@googlemail.com>
343 lines
13 KiB
Rust
343 lines
13 KiB
Rust
//! The one place the tests reach the generated `.aep` / `.aepx` test projects.
|
|
//!
|
|
//! Every test addresses a case by its case name, the name of the single-case file it was first
|
|
//! written against (`"058-key-opacity-2key.aep"`). The cases now live in a few combined projects
|
|
//! (`tests/fixtures/CASES.md`): each case is the composition named like the case without its
|
|
//! extension (`058-key-opacity-2key`), built like the single-case file's composition `C` (plus
|
|
//! the compositions it uses). [`try_fixture`] hands a test the case as a project of its own: the
|
|
//! combined file with every project item removed that the case composition does not use
|
|
//! (directly or through precomposition layers), written back with [`craft_aep::write`].
|
|
//! The case composition is renamed `C` as in the single-case files; everything else of the file
|
|
//! (project settings, the effect definitions, the XMP packet) stays, and the layers and items keep
|
|
//! the ids they have in the combined file.
|
|
//!
|
|
//! A case name that is a file of its own in the directory (an old single-case fixture or a
|
|
//! combined file's own name) is read as it is. When the file is not
|
|
//! there the test prints `SKIPPED: fixture <name> missing` and returns without failing (the
|
|
//! macros below), so the suite runs without the projects. The directory is the one in the
|
|
//! `CRAFT_AEP_FIXTURES` environment variable (which may also hold the old single-case
|
|
//! files); without it every such test skips. With `CRAFT_AEP_FIXTURES_REQUIRED` set (to
|
|
//! anything but an empty string) a missing project fails the test instead, so a run against the
|
|
//! corpus cannot pass by skipping.
|
|
#![allow(dead_code, unused_macros)]
|
|
|
|
mod cases;
|
|
|
|
use std::collections::{BTreeSet, HashMap};
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
|
|
|
use craft_aep::{AepFile, Body, Chunk, ItemKind, Project};
|
|
|
|
pub use cases::CASES;
|
|
|
|
/// The directory the test projects are read from, or `None` when `CRAFT_AEP_FIXTURES` is
|
|
/// not set (the projects are opt-in).
|
|
pub fn dir() -> Option<PathBuf> {
|
|
std::env::var_os("CRAFT_AEP_FIXTURES").filter(|d| !d.is_empty()).map(PathBuf::from)
|
|
}
|
|
|
|
/// The combined files the cases live in (and the empty project, a file of its own), sorted.
|
|
pub const FILES: &[&str] = &[
|
|
"3d.aep",
|
|
"aepx-pair.aep",
|
|
"aepx-pair.aepx",
|
|
"effects.aep",
|
|
"empty.aep",
|
|
"footage-precomp.aep",
|
|
"fx-switch.aep",
|
|
"keyframes.aep",
|
|
"layers-comps.aep",
|
|
"markers.aep",
|
|
"masks.aep",
|
|
"settings-a.aep",
|
|
"settings-b.aep",
|
|
"settings-c.aep",
|
|
"shapes.aep",
|
|
"styles.aep",
|
|
"text.aep",
|
|
];
|
|
|
|
/// Where a case is read from.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Source {
|
|
/// A file of its own, read as it is.
|
|
File(PathBuf),
|
|
/// The composition `comp` of the combined project `file`.
|
|
Case { file: PathBuf, comp: String },
|
|
}
|
|
|
|
impl Source {
|
|
/// The file the case is in.
|
|
pub fn path(&self) -> &PathBuf {
|
|
match self {
|
|
Source::File(p) | Source::Case { file: p, .. } => p,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The combined file of a case name (`None` for a name that is not a case).
|
|
pub fn combined_file(name: &str) -> Option<&'static str> {
|
|
CASES.binary_search_by(|(n, _)| n.cmp(&name)).ok().and_then(|i| CASES.get(i)).map(|(_, f)| *f)
|
|
}
|
|
|
|
/// The name of the case's composition: the case name without its extension.
|
|
pub fn case_comp(name: &str) -> &str {
|
|
name.rsplit_once('.').map_or(name, |(stem, _)| stem)
|
|
}
|
|
|
|
/// Whether `path` is a file. Only a missing path is `false`: any other error (no permission, a
|
|
/// broken folder) fails the test, so a fixture that is there but unreadable never turns into a skip.
|
|
fn is_fixture(path: &std::path::Path) -> bool {
|
|
match std::fs::metadata(path) {
|
|
Ok(m) if m.is_file() => true,
|
|
Ok(_) => panic!("{}: not a file", path.display()),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
|
|
Err(e) => panic!("{}: {e}", path.display()),
|
|
}
|
|
}
|
|
|
|
/// Where a case is, or `None` (after printing the skip line) when its file is missing.
|
|
pub fn locate(name: &str) -> Option<Source> {
|
|
let Some(dir) = dir() else {
|
|
skipped(name);
|
|
return None;
|
|
};
|
|
let own = dir.join(name);
|
|
if is_fixture(&own) {
|
|
return Some(Source::File(own));
|
|
}
|
|
let Some(file) = combined_file(name) else {
|
|
skipped(name);
|
|
return None;
|
|
};
|
|
let path = dir.join(file);
|
|
if !is_fixture(&path) {
|
|
skipped(name);
|
|
return None;
|
|
}
|
|
// The empty project is a whole file: it has no composition.
|
|
if file == "empty.aep" {
|
|
return Some(Source::File(path));
|
|
}
|
|
Some(Source::Case { file: path, comp: case_comp(name).to_string() })
|
|
}
|
|
|
|
/// The file a case is in (the combined project for a case in one), or `None` when it is missing.
|
|
pub fn resolve(name: &str) -> Option<PathBuf> {
|
|
locate(name).map(|s| s.path().clone())
|
|
}
|
|
|
|
/// The ids of the compositions an item uses as precomposition layers, as the file stores them:
|
|
/// the little-endian `u32` of each `idpi` chunk (the importer does not read them; the exporter
|
|
/// writes them).
|
|
pub fn used_item_ids(item: &craft_aep::Item<'_, '_>) -> Vec<u32> {
|
|
item.chunk().children().filter(|c| c.is(b"idpi")).filter_map(|c| Some(u32::from_le_bytes(c.bytes()?.get(..4)?.try_into().ok()?))).collect()
|
|
}
|
|
|
|
/// The bytes of a case (see the module comment), or `None` when it is missing.
|
|
pub fn try_fixture(name: &str) -> Option<Vec<u8>> {
|
|
match locate(name)? {
|
|
Source::File(path) => Some(std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))),
|
|
Source::Case { file, comp } => Some(case_bytes(&file, &comp).as_ref().clone()),
|
|
}
|
|
}
|
|
|
|
/// The bytes of a whole file of the directory (a combined project), or `None` when it is missing.
|
|
pub fn try_file(file: &str) -> Option<Vec<u8>> {
|
|
let path = dir()?.join(file);
|
|
if is_fixture(&path) {
|
|
Some(std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())))
|
|
} else {
|
|
skipped(file);
|
|
None
|
|
}
|
|
}
|
|
|
|
/// The case names with this extension (sorted), or `None` when there are none: the cases of
|
|
/// [`CASES`] whose file (of its own or combined) is there, else the files with that extension.
|
|
pub fn names(ext: &str) -> Option<Vec<String>> {
|
|
let Some(dir) = dir() else {
|
|
skipped(&format!("*.{ext}"));
|
|
return None;
|
|
};
|
|
let mut v: Vec<String> = CASES
|
|
.iter()
|
|
.filter(|(n, f)| std::path::Path::new(n).extension().is_some_and(|x| x == ext) && (is_fixture(&dir.join(n)) || is_fixture(&dir.join(f))))
|
|
.map(|(n, _)| n.to_string())
|
|
.collect();
|
|
if v.is_empty() {
|
|
let entries = match std::fs::read_dir(&dir) {
|
|
Ok(entries) => entries.map(|e| e.unwrap_or_else(|e| panic!("{}: {e}", dir.display()))).collect(),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
|
|
Err(e) => panic!("{}: {e}", dir.display()),
|
|
};
|
|
v = entries
|
|
.into_iter()
|
|
.filter_map(|e| e.file_name().into_string().ok())
|
|
.filter(|n| std::path::Path::new(n).extension().is_some_and(|x| x == ext))
|
|
.collect();
|
|
}
|
|
v.sort();
|
|
if v.is_empty() {
|
|
skipped(&format!("*.{ext}"));
|
|
None
|
|
} else {
|
|
Some(v)
|
|
}
|
|
}
|
|
|
|
pub fn skipped(name: &str) {
|
|
assert!(
|
|
std::env::var_os("CRAFT_AEP_FIXTURES_REQUIRED").is_none_or(|v| v.is_empty()),
|
|
"fixture {name} missing (CRAFT_AEP_FIXTURES_REQUIRED is set; CRAFT_AEP_FIXTURES = {:?})",
|
|
std::env::var_os("CRAFT_AEP_FIXTURES")
|
|
);
|
|
println!("SKIPPED: fixture {name} missing");
|
|
}
|
|
|
|
/// A parsed combined file, read once per test binary.
|
|
fn parsed(file: &PathBuf) -> &'static AepFile<'static> {
|
|
static FILES: OnceLock<Mutex<HashMap<PathBuf, &'static AepFile<'static>>>> = OnceLock::new();
|
|
let mut files = FILES.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner());
|
|
files.entry(file.clone()).or_insert_with(|| {
|
|
let bytes: &'static [u8] = Box::leak(std::fs::read(file).unwrap_or_else(|e| panic!("{}: {e}", file.display())).into_boxed_slice());
|
|
Box::leak(Box::new(craft_aep::parse_any(bytes).unwrap_or_else(|e| panic!("{}: {e}", file.display()))))
|
|
})
|
|
}
|
|
|
|
/// The single-case project of composition `comp` in `file`, made once per test binary.
|
|
fn case_bytes(file: &PathBuf, comp: &str) -> Arc<Vec<u8>> {
|
|
static CASES_MADE: OnceLock<Mutex<HashMap<(PathBuf, String), Arc<Vec<u8>>>>> = OnceLock::new();
|
|
let key = (file.clone(), comp.to_string());
|
|
if let Some(b) = CASES_MADE.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
|
return b.clone();
|
|
}
|
|
let bytes = Arc::new(isolate(parsed(file), comp).unwrap_or_else(|e| panic!("{}: case {comp}: {e}", file.display())));
|
|
CASES_MADE.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner()).insert(key, bytes.clone());
|
|
bytes
|
|
}
|
|
|
|
/// The item id of an `Item` list (`idta` byte 16).
|
|
fn item_id(item: &Chunk<'_>) -> Option<u32> {
|
|
let idta = item.child(b"idta")?.bytes()?;
|
|
Some(u32::from_be_bytes(idta.get(16..20)?.try_into().ok()?))
|
|
}
|
|
|
|
/// The cases that import a footage file without using it in their composition, and how to find
|
|
/// that item: it stays with the case.
|
|
const UNUSED_FOOTAGE: &[(&str, &str, fn(&craft_aep::Item<'_, '_>) -> bool)] = &[
|
|
("350-footage-wav-imported", "the WAV", |i| i.footage().and_then(|f| f.path).is_some_and(|p| p.ends_with("test-tone.wav"))),
|
|
("352-footage-pngseq-imported", "the png sequence", |i| i.sequence_info().is_some_and(|s| s.prefix == "seq_" && s.suffix == ".png")),
|
|
];
|
|
|
|
/// `file` with only the composition `comp` and the project items it uses (layer sources,
|
|
/// transitively), and the folders that hold them; the composition is renamed `C`.
|
|
pub fn isolate(file: &AepFile<'_>, comp: &str) -> Result<Vec<u8>, String> {
|
|
let project = Project::new(file).ok_or("no project")?;
|
|
let items = project.all_items();
|
|
let case = items
|
|
.iter()
|
|
.find(|i| i.kind() == Some(ItemKind::Composition) && i.name().as_deref() == Some(comp))
|
|
.ok_or_else(|| format!("no composition {comp}"))?;
|
|
let mut keep = BTreeSet::new();
|
|
let mut todo = vec![case.id().ok_or("case without id")?];
|
|
for (_, what, is) in UNUSED_FOOTAGE.iter().filter(|(c, _, _)| *c == comp) {
|
|
todo.push(items.iter().find(|i| is(i)).and_then(|i| i.id()).ok_or_else(|| format!("no item {what}"))?);
|
|
}
|
|
while let Some(id) = todo.pop() {
|
|
if !keep.insert(id) {
|
|
continue;
|
|
}
|
|
let Some(item) = items.iter().find(|i| i.id() == Some(id)) else { continue };
|
|
if let Some(c) = item.comp() {
|
|
todo.extend(c.layers().filter_map(|l| l.header()?.source_id));
|
|
}
|
|
}
|
|
let mut out = file.clone();
|
|
let fold = out.chunks.iter_mut().find(|c| c.is_list(b"Fold")).ok_or("no LIST Fold")?;
|
|
prune(fold, &keep);
|
|
rename(fold, case.id().ok_or("case without id")?, "C");
|
|
craft_aep::write(&out).map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Removes the items of `list` (and of its folders) that are not kept; a folder stays while it
|
|
/// holds a kept item.
|
|
fn prune(list: &mut Chunk<'_>, keep: &BTreeSet<u32>) {
|
|
let Body::List { children, .. } = &mut list.body else { return };
|
|
children.retain_mut(|c| {
|
|
if !c.is_list(b"Item") {
|
|
return true;
|
|
}
|
|
if let Body::List { children: inner, .. } = &mut c.body
|
|
&& let Some(sfdr) = inner.iter_mut().find(|x| x.is_list(b"Sfdr"))
|
|
{
|
|
prune(sfdr, keep);
|
|
let holds = sfdr.children().iter().any(|x| x.is_list(b"Item"));
|
|
return holds || item_id(c).is_some_and(|id| keep.contains(&id));
|
|
}
|
|
item_id(c).is_some_and(|id| keep.contains(&id))
|
|
});
|
|
}
|
|
|
|
/// Renames the item with this id (searching folders too).
|
|
fn rename(list: &mut Chunk<'_>, id: u32, name: &str) -> bool {
|
|
let Body::List { children, .. } = &mut list.body else { return false };
|
|
for c in children.iter_mut().filter(|c| c.is_list(b"Item")) {
|
|
if item_id(c) == Some(id) {
|
|
let Body::List { children: inner, .. } = &mut c.body else { return false };
|
|
let Some(utf8) = inner.iter_mut().find(|x| x.is(b"Utf8")) else { return false };
|
|
*utf8 = Chunk::data(*b"Utf8", name.as_bytes().to_vec());
|
|
return true;
|
|
}
|
|
if let Body::List { children: inner, .. } = &mut c.body
|
|
&& let Some(sfdr) = inner.iter_mut().find(|x| x.is_list(b"Sfdr"))
|
|
&& rename(sfdr, id, name)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// The bytes of a case; returns from the calling test when it is missing.
|
|
macro_rules! fixture {
|
|
($name:expr) => {
|
|
match $crate::common::try_fixture(&$name) {
|
|
Some(bytes) => bytes,
|
|
None => return,
|
|
}
|
|
};
|
|
}
|
|
|
|
/// The path of a case's file; returns from the calling test when it is missing.
|
|
macro_rules! fixture_path {
|
|
($name:expr) => {
|
|
match $crate::common::resolve(&$name) {
|
|
Some(path) => path,
|
|
None => return,
|
|
}
|
|
};
|
|
}
|
|
|
|
/// The case names with this extension; returns from the calling test when there are none.
|
|
macro_rules! fixture_names {
|
|
($ext:expr) => {
|
|
match $crate::common::names($ext) {
|
|
Some(names) => names,
|
|
None => return,
|
|
}
|
|
};
|
|
}
|
|
|
|
/// The value of a helper that returns `None` for a missing case; returns from the calling test
|
|
/// then (the helper has printed the skip line).
|
|
macro_rules! skip {
|
|
($e:expr) => {
|
|
match $e {
|
|
Some(value) => value,
|
|
None => return,
|
|
}
|
|
};
|
|
}
|