Files
Valentin Klein ce9eaec650 Add After Effects project export (#2594)
Add After Effects project export with chunks built in code, explicit loss reporting, engine and UI integration, and regression coverage.
2026-11-06 09:23:16 +01:00

187 lines
8.9 KiB
Rust

//! Project settings (cases 620-634): written into the template's header chunks, read back with
//! `effectcraft-aep`, and the settings fixtures through import, export and import.
use crate::export::helpers::*;
use effectcraft_aep::export::LossCategory;
use effectcraft_aep::{TimeDisplay, WorkingSpace, parse, write};
use effectcraft_color::ColorSpace;
use effectcraft_project::{BitDepth, ColorEngine, Project, ProjectSettings, TimeDisplayStyle};
fn read_back(p: &Project) -> effectcraft_aep::ProjectSettings {
let r = export_all(p);
let file = parse(&r.bytes).expect("parse");
assert_eq!(write(&file).expect("write"), r.bytes, "container consistent");
effectcraft_aep::ProjectSettings::read(&file)
}
fn with(f: impl FnOnce(&mut ProjectSettings)) -> Project {
let (mut p, _, _) = simple();
f(&mut p.settings);
p
}
#[test]
fn every_setting_is_written_as_after_effects_stores_it() {
// EffectCraft's defaults: 8 bpc, no working space, gamma 2.2, timecode, frames from 0.
let d = read_back(&with(|_| {}));
assert_eq!(d.bits_per_channel, Some(8));
assert_eq!(d.working_space, Some(WorkingSpace::None));
assert_eq!(d.working_gamma_24, Some(false));
assert!(!d.linearize && !d.blend_linear);
assert_eq!(d.time_display, Some(TimeDisplay::Timecode));
assert_eq!(d.frame_start, Some(0));
assert_eq!(d.scene_compensation, Some(true), "kept from the template");
for (depth, bpc) in [(BitDepth::Bpc8, 8), (BitDepth::Bpc16, 16), (BitDepth::Bpc32, 32)] {
assert_eq!(read_back(&with(|s| s.bit_depth = depth)).bits_per_channel, Some(bpc));
}
let srgb = read_back(&with(|s| s.working_space = Some(ColorSpace::Srgb)));
assert_eq!(srgb.working_space, Some(WorkingSpace::Profile("sRGB IEC61966-2.1".into())));
assert_eq!(read_back(&with(|s| s.working_gamma = 2.4)).working_gamma_24, Some(true));
for (lin, blend) in [(true, false), (false, true), (true, true)] {
let r = read_back(&with(|s| {
s.linearize = lin;
s.blend_linear = blend;
}));
assert_eq!((r.linearize, r.blend_linear), (lin, blend));
}
for (style, want) in [
(TimeDisplayStyle::Frames, TimeDisplay::Frames),
(TimeDisplayStyle::Feet35, TimeDisplay::FeetFrames { frames_per_foot: 16 }),
(TimeDisplayStyle::Feet16, TimeDisplay::FeetFrames { frames_per_foot: 40 }),
(TimeDisplayStyle::Timecode, TimeDisplay::Timecode),
] {
assert_eq!(read_back(&with(|s| s.time_display = style)).time_display, Some(want));
}
assert_eq!(read_back(&with(|s| s.frame_start = 1)).frame_start, Some(1));
}
fn unbase64(s: &str) -> Vec<u8> {
let val = |c: u8| match c {
b'A'..=b'Z' => u32::from(c - b'A'),
b'a'..=b'z' => u32::from(c - b'a') + 26,
b'0'..=b'9' => u32::from(c - b'0') + 52,
b'+' => 62,
b'/' => 63,
_ => panic!("not base64: {c}"),
};
let mut out = Vec::new();
for q in s.as_bytes().chunks(4) {
let n = q.iter().filter(|&&c| c != b'=').count();
let v = q.iter().filter(|&&c| c != b'=').enumerate().fold(0u32, |acc, (i, &c)| acc | (val(c) << (18 - 6 * i)));
out.extend_from_slice(&v.to_be_bytes()[1..n]);
}
out
}
#[test]
fn rec709_gamma_24_is_written_with_a_profile_of_our_own() {
let p = with(|s| s.working_space = Some(ColorSpace::Rec709));
let r = export_all(&p);
assert!(r.report.is_lossless(), "{:?}", r.report.losses);
let file = parse(&r.bytes).expect("parse");
let s = effectcraft_aep::ProjectSettings::read(&file);
assert_eq!(s.working_space, Some(WorkingSpace::Profile("Rec.709 Gamma 2.4".into())));
let cpid = file.chunks.iter().find(|c| c.is(b"cpid")).and_then(|c| c.bytes()).expect("cpid").to_vec();
let pw = file.chunks.iter().position(|c| c.is(b"PwCs")).expect("PwCs");
let json: serde_json::Value = serde_json::from_slice(file.chunks[pw + 1].bytes().expect("Utf8")).expect("json");
assert_eq!(json["baseProfileType"], 2);
let icc = unbase64(json["baseColorProfile"]["colorProfileData"].as_str().expect("data"));
// An ICC v4 display profile: size, 'acsp', and the ID in cpid equals the one in the header.
assert_eq!(u32::from_be_bytes([icc[0], icc[1], icc[2], icc[3]]) as usize, icc.len());
assert_eq!(&icc[36..40], b"acsp");
assert_eq!(icc[8], 4);
assert_eq!(&icc[84..100], &cpid[..]);
assert_ne!(cpid, vec![0xffu8; 16]);
// Import maps it back to Rec.709.
let mut q = Project::default();
let none = |_: &str| false;
let back = effectcraft_aep::import::import(&mut q, &r.bytes, &effectcraft_aep::import::ImportOptions::new(&none)).expect("import");
assert_eq!(back.settings.working_space, Some(ColorSpace::Rec709));
}
#[test]
fn our_rec709_profile_without_its_name_is_still_rec709() {
// After Effects 26.2.1 drops the name on a re-save (settings-c): the name is then our profile's
// description. The name is blanked to spaces, so the JSON keeps its length.
let mut bytes = export_all(&with(|s| s.working_space = Some(ColorSpace::Rec709))).bytes;
let key = b",\"colorProfileName\":\"Rec.709 Gamma 2.4\"";
let at = bytes.windows(key.len()).position(|w| w == key).expect("name");
bytes[at..at + key.len()].fill(b' ');
let s = effectcraft_aep::ProjectSettings::read(&parse(&bytes).expect("parse"));
assert_eq!(s.working_space, Some(WorkingSpace::Profile("Rec.709 Gamma 2.4 (EffectCraft)".into())));
let mut q = Project::default();
let none = |_: &str| false;
let back = effectcraft_aep::import::import(&mut q, &bytes, &effectcraft_aep::import::ImportOptions::new(&none)).expect("import");
assert_eq!(back.settings.working_space, Some(ColorSpace::Rec709));
}
#[test]
fn what_the_file_cannot_hold_is_reported() {
let lossy = [
with(|s| s.working_space = Some(ColorSpace::Rec2020)),
with(|s| s.color_engine = ColorEngine::Ocio),
with(|s| s.output_space = Some(ColorSpace::Rec2100Pq)),
with(|s| s.frame_start = 7),
];
for p in &lossy {
let r = export_all(p);
assert_eq!(r.report.count(LossCategory::ProjectSettings), 1, "{:?}", r.report.losses);
}
// Rec. 2020 is written as sRGB.
assert_eq!(read_back(&lossy[0]).working_space, Some(WorkingSpace::Profile("sRGB IEC61966-2.1".into())));
assert!(export_all(&with(|_| {})).report.is_lossless());
}
#[test]
fn settings_fixtures_round_trip() {
let names = [
"000-base.aep",
"621-settings-bpc-8.aep",
"622-settings-bpc-16.aep",
"623-settings-workingspace-none.aep",
"625-settings-gamma-2.4.aep",
"629-settings-time-display-frames.aep",
"630-settings-frame-start-1.aep",
"632-settings-linearize-and-blend-linear.aep",
"633-settings-feet-frames-35.aep",
"634-settings-feet-frames-16.aep",
];
for name in names {
let bytes = fixture!(name);
let orig = effectcraft_aep::ProjectSettings::read(&parse(&bytes).expect("parse"));
let mut p = Project::default();
let none = |_: &str| false;
let r = effectcraft_aep::import::import(&mut p, &bytes, &effectcraft_aep::import::ImportOptions::new(&none)).expect(name);
p.settings = r.settings;
let back = read_back(&p);
// 621-630 share a file with 628 (scene compensation off), which the round trip does not
// carry (628 is not in this list); every other setting comes back.
let back = effectcraft_aep::ProjectSettings { scene_compensation: orig.scene_compensation, ..back };
assert_eq!(back, orig, "{name}");
}
}
#[test]
fn an_output_space_equal_to_the_working_space_is_the_normal_case() {
// After Effects renders in the working space (902): the importer sets the output space of a
// Rec.709 Gamma 2.4 project to Rec.709, and the export of such a project loses nothing.
let bytes = fixture!("902-settings-rec709-16bpc-steps-png-grey-solid.aep");
let mut p = Project::default();
let none = |_: &str| false;
let opts = effectcraft_aep::import::ImportOptions { allow_losses: true, ..effectcraft_aep::import::ImportOptions::new(&none) };
let r = effectcraft_aep::import::import(&mut p, &bytes, &opts).expect("import");
assert_eq!((r.settings.working_space, r.settings.output_space), (Some(ColorSpace::Rec709), Some(ColorSpace::Rec709)));
p.settings = r.settings;
let e = export_all(&p);
assert_eq!(e.report.count(LossCategory::ProjectSettings), 0, "{:?}", e.report.losses);
let mut q = Project::default();
let back = effectcraft_aep::import::import(&mut q, &e.bytes, &opts).expect("re-import");
assert_eq!((back.settings.working_space, back.settings.output_space), (Some(ColorSpace::Rec709), Some(ColorSpace::Rec709)));
// Another output space is still reported.
let other = with(|s| {
s.working_space = Some(ColorSpace::Rec709);
s.output_space = Some(ColorSpace::Srgb);
});
assert_eq!(export_all(&other).report.count(LossCategory::ProjectSettings), 1);
}