mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 08:52:32 +00:00
* Add craft-aep: After Effects project container and typed view from EffectCraft The .aep (RIFX 'Egg!') and .aepx container as a lossless chunk tree that writes back byte for byte, and the typed, read-only view over it: compositions, layers, properties, keyframes, effects and their settings data, masks, shapes, text documents, markers, cameras, track mattes, footage, project settings and the saving version. Moved from effectcraft crates/aep/src/container at f6b4da0a with its unit tests, the container integration tests, the aepdump and stubicc tools and docs/aep-format.md. The match-name tables (effect_map, style_map), the importer and the exporter are written against EffectCraft's model and stay there. ExtractorChannels::sources takes the caller's name for a copied channel instead of an EffectCraft constant. * craft-aep: read the Photoshop layer a footage item shows A footage item made from one layer of a .psd stores the layer in its `opti` (tag `8BPS`): the layer record's index (big-endian i32 at 14, -1 for the merged image), the document's two sides (little-endian u32 at 32 and 36), the layer's bounds (little-endian i32 at 78, 82, 86, 90) and its name (UTF-8 from 344). `Footage::psd_layer` reads them as `PsdLayer`. The format part of storytold/effectcraft#3449; the importer's use of it stays in EffectCraft. --------- Co-authored-by: arnav-hiwarkar <asharnav2008@gmail.com>
129 lines
5.9 KiB
Rust
129 lines
5.9 KiB
Rust
//! Shape layers: rectangle, fill, stroke, rounded corners, custom path, gradient fill and group
|
|
//! position, checked against the values the corpus was made with (its MANIFEST, batch 2). The shape
|
|
//! layer `S` is on top of the solid `L`; its group is `Group 1`.
|
|
|
|
use crate::container::helpers::{assert_near, first_comp, layer_named, open};
|
|
use craft_aep::{LayerType, Project, ShapeGroup, ShapeItem, StrokeCap, StrokeJoin, ValueSource};
|
|
|
|
fn groups(name: &str) -> Option<Vec<ShapeGroup>> {
|
|
Some({
|
|
let bytes = crate::common::try_fixture(name)?;
|
|
let file = open(&bytes);
|
|
let project = Project::new(&file).unwrap();
|
|
let layer = layer_named(&first_comp(&project), "S");
|
|
let h = layer.header().unwrap();
|
|
assert_eq!(h.layer_type, LayerType::Shape, "{name}");
|
|
assert!(h.collapses_transformations(), "{name}: shape layers have the collapse switch from creation");
|
|
layer.shape_groups()
|
|
})
|
|
}
|
|
|
|
fn only_group(name: &str) -> Option<ShapeGroup> {
|
|
Some({
|
|
let mut g = groups(name)?;
|
|
assert_eq!(g.len(), 1, "{name}");
|
|
g.remove(0)
|
|
})
|
|
}
|
|
|
|
fn rect_size(item: &ShapeItem) -> [f64; 2] {
|
|
let ShapeItem::Rect { name, size } = item else { panic!("{item:?}") };
|
|
assert_eq!(name.as_deref(), Some("Rectangle Path 1"));
|
|
let size = size.expect("rect size");
|
|
assert_eq!(size.source, ValueSource::Stored);
|
|
size.value
|
|
}
|
|
|
|
#[test]
|
|
fn rectangle_fill_and_rounded_corners() {
|
|
// 238: Rectangle Path 400x300, Fill [0,1,0], Round Corners radius 20 (in this order).
|
|
let g = skip!(only_group("238-shape-rect-rounded-corners.aep"));
|
|
assert_eq!(g.name.as_deref(), Some("Group 1"));
|
|
assert_eq!((g.position.value, g.position.source), ([0.0, 0.0], ValueSource::Default));
|
|
assert_eq!(g.items.len(), 3);
|
|
assert_eq!(rect_size(&g.items[0]), [400.0, 300.0]);
|
|
let ShapeItem::Fill { name, color } = &g.items[1] else { panic!("{:?}", g.items[1]) };
|
|
assert_eq!(name.as_deref(), Some("Fill 1"));
|
|
assert_eq!(color.unwrap().value, [1.0, 0.0, 1.0, 0.0]);
|
|
let ShapeItem::RoundCorners { name, radius } = &g.items[2] else { panic!("{:?}", g.items[2]) };
|
|
assert_eq!(name.as_deref(), Some("Round Corners 1"));
|
|
assert_eq!(radius.unwrap().value, 20.0);
|
|
}
|
|
|
|
#[test]
|
|
fn stroke_colour_width_cap_and_join() {
|
|
// 239: Stroke [0,1,0] width 8, Line Cap 2 (round), Line Join 2 (round); no Fill.
|
|
let g = skip!(only_group("239-shape-stroke-green-w8-round.aep"));
|
|
assert_eq!(g.items.len(), 2);
|
|
assert_eq!(rect_size(&g.items[0]), [400.0, 300.0]);
|
|
let ShapeItem::Stroke { name, stroke } = &g.items[1] else { panic!("{:?}", g.items[1]) };
|
|
assert_eq!(name.as_deref(), Some("Stroke 1"));
|
|
assert_eq!(stroke.color.unwrap().value, [1.0, 0.0, 1.0, 0.0]);
|
|
assert_eq!(stroke.width.unwrap().value, 8.0);
|
|
assert_eq!((stroke.cap.value, stroke.cap.source), (StrokeCap::Round, ValueSource::Stored));
|
|
assert_eq!((stroke.join.value, stroke.join.source), (StrokeJoin::Round, ValueSource::Stored));
|
|
// Touching the join stores the miter limit in its default state (4).
|
|
assert_eq!(stroke.miter_limit.unwrap().value, 4.0);
|
|
assert!(!g.items.iter().any(|i| matches!(i, ShapeItem::Fill { .. })));
|
|
}
|
|
|
|
#[test]
|
|
fn custom_path_with_tangents_in_pixels() {
|
|
// 240: Path 1, closed, vertices [0,-100] [100,100] [-100,100], inTangents [-10,-20] [30,20] [-60,10],
|
|
// outTangents [40,0] [-20,30] [0,-50] (the box of vertices and handles is -160,-120,130,130), then a Fill.
|
|
let g = skip!(only_group("240-shape-custom-path-tangents.aep"));
|
|
assert_eq!(g.items.len(), 2);
|
|
let ShapeItem::Path { name, paths } = &g.items[0] else { panic!("{:?}", g.items[0]) };
|
|
assert_eq!(name.as_deref(), Some("Path 1"));
|
|
assert_eq!(paths.len(), 1);
|
|
let p = &paths[0];
|
|
assert!(!p.open);
|
|
assert_near(&p.bbox.map(f64::from), &[-160.0, -120.0, 130.0, 130.0], 1e-6);
|
|
let v = p.vertices((1.0, 1.0));
|
|
let positions: Vec<[f64; 2]> = v.iter().map(|x| x.position).collect();
|
|
let in_t: Vec<[f64; 2]> = v.iter().map(|x| x.in_tangent()).collect();
|
|
let out_t: Vec<[f64; 2]> = v.iter().map(|x| x.out_tangent()).collect();
|
|
for (got, want) in positions.iter().zip([[0.0, -100.0], [100.0, 100.0], [-100.0, 100.0]]) {
|
|
assert_near(got, &want, 1e-3);
|
|
}
|
|
for (got, want) in in_t.iter().zip([[-10.0, -20.0], [30.0, 20.0], [-60.0, 10.0]]) {
|
|
assert_near(got, &want, 1e-3);
|
|
}
|
|
for (got, want) in out_t.iter().zip([[40.0, 0.0], [-20.0, 30.0], [0.0, -50.0]]) {
|
|
assert_near(got, &want, 1e-3);
|
|
}
|
|
assert!(matches!(g.items[1], ShapeItem::Fill { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn gradient_fill_start_and_end() {
|
|
// 241: Gradient Fill 1 (linear), start [-200,0], end [200,0] in pixels; type and stops are not stored.
|
|
let g = skip!(only_group("241-shape-gradient-fill-linear.aep"));
|
|
assert_eq!(g.items.len(), 2);
|
|
assert_eq!(rect_size(&g.items[0]), [400.0, 300.0]);
|
|
let ShapeItem::GradientFill { name, start, end } = &g.items[1] else { panic!("{:?}", g.items[1]) };
|
|
assert_eq!(name.as_deref(), Some("Gradient Fill 1"));
|
|
assert_eq!(start.unwrap().value, [-200.0, 0.0]);
|
|
assert_eq!(end.unwrap().value, [200.0, 0.0]);
|
|
}
|
|
|
|
#[test]
|
|
fn group_position() {
|
|
// 242: Group 1 / Transform / Position [0,0] -> [50,50]; the contents are unchanged.
|
|
let g = skip!(only_group("242-shape-group-position-50-50.aep"));
|
|
assert_eq!((g.position.value, g.position.source), ([50.0, 50.0], ValueSource::Stored));
|
|
assert_eq!(g.items.len(), 2);
|
|
assert_eq!(rect_size(&g.items[0]), [400.0, 300.0]);
|
|
assert!(matches!(g.items[1], ShapeItem::Fill { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn other_layers_have_no_shape_groups() {
|
|
let bytes = fixture!("238-shape-rect-rounded-corners.aep");
|
|
let file = open(&bytes);
|
|
let project = Project::new(&file).unwrap();
|
|
let comp = first_comp(&project);
|
|
let solid = comp.layers().find(|l| l.header().is_some_and(|h| h.layer_type == LayerType::Av)).unwrap();
|
|
assert!(solid.shape_groups().is_empty());
|
|
}
|