.aep import: placed .ai/PDF footage framed by its ArtBox, Null anchors at the corner (#3099) (#3311)

* #3099: placed .ai pages fill the size After Effects stored; null anchors default to the corner

Footage of an .aep keeps the size After Effects stored for a placed PDF / Illustrator / EPS
file (the ArtBox, rounded up), and masks and anchors are measured against it. The page was
drawn as a PDF viewer frames it (CropBox, /Rotate) at its own size, scaled by the width ratio
at full resolution and stretched to the stored aspect below 3/4: masks too large, art off
centre, squeezed at half resolution.

- effectcraft-pdf: Framing::Placed / parse_page_framed (ArtBox clipped to the CropBox,
  /Rotate ignored); boxes with a non-finite corner or span are not used
- Footage::placed_page (set by the .aep importer, serialized only when true): the page is
  framed so and filled into the stored size (effectcraft_render::vector_fit), decoded,
  continuously rasterised and in Create Shapes from Vector Layer; Reload keeps the size;
  the frame cache keys it by size and framing
- .aep import/export: a null's absent Anchor Point is [0, 0], as After Effects reads it
- fixture ai-mask-3099.aep (own synthetic files, scrubbed, profiles stubbed) and tests
  against After Effects 2026's read-back

* #3099: placed-page framing only for .ai and .pdf, EPS as before; doc comments

After Effects' sizing was measured with PDFs named .ai only; an EPS from an .aep keeps its
bounding box at its own size (listed as not measured in docs/gaps.md and docs/aep-import.md).
Doc comments on the touched functions.

* #3099: shapes from a placed page keep its frame (track matte)

Create Shapes from Vector Layer converted every path of a placed page, so bleed, art past the
artboard or the page's background showed where the footage's raster hides them. The layers it
makes from a placed page now take a hidden "<layer> Frame" shape layer (the footage's
width x height) as their Alpha track matte: drawn at the comp's resolution, its edge is the
raster's on shape and image layers alike. The command returns the frame layer's id too.
This commit is contained in:
Valentin Klein
2026-11-08 21:52:32 -06:00
committed by GitHub
parent 9893eb0d8d
commit 7c62272305
28 changed files with 1132 additions and 49 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ and licence texts are in [`NOTICE`](NOTICE).
| `crates/model/tests/fixtures/cube.obj` | EffectCraft contributors | Original work: Wavefront OBJ test fixture (unit cube, two materials) | MIT OR Apache-2.0 |
| `crates/model/tests/fixtures/cube.mtl` | EffectCraft contributors | Original work: MTL test fixture (materials of cube.obj) | MIT OR Apache-2.0 |
One small After Effects test project of `crates/aep` is in this repository: `crates/aep/tests/fixtures/synthetic.aep` (original work, made with After Effects 2026 from own data, working space None, the embedded colour profile removed; MIT OR Apache-2.0, with its `.attribution` sidecar). The other After Effects test projects of `crates/aep` are not in this repository. They are original work of an EffectCraft contributor, generated with After Effects 2026 as own test data (MIT OR Apache-2.0, each file with its `.attribution` sidecar); they are in [storytold/effectcraft-corpus](https://github.com/storytold/effectcraft-corpus) with their sidecars, fetched by `cargo xtask aep-corpus` (commit-pinned, SHA-256-verified), and the tests read them only from the folder named by `EFFECTCRAFT_AEP_FIXTURES` (opt-in). Their embedded colour profiles are replaced by stubs made with `crates/aep/examples/stubicc.rs` (a header and a description, no colour data), so no vendor profile data is in them.
Two small After Effects test projects of `crates/aep` are in this repository: `crates/aep/tests/fixtures/synthetic.aep` (original work, made with After Effects 2026 from own data, working space None, the embedded colour profile removed; MIT OR Apache-2.0, with its `.attribution` sidecar) and `crates/aep/tests/fixtures/ai-mask-3099.aep` (original work, made with After Effects 2026 from four hand-written one-page PDFs named `.ai`, footage paths and host name neutralised, the embedded colour profiles replaced by stubs made with `crates/aep/examples/stubicc.rs`; MIT OR Apache-2.0, with its `.attribution` sidecar). The other After Effects test projects of `crates/aep` are not in this repository. They are original work of an EffectCraft contributor, generated with After Effects 2026 as own test data (MIT OR Apache-2.0, each file with its `.attribution` sidecar); they are in [storytold/effectcraft-corpus](https://github.com/storytold/effectcraft-corpus) with their sidecars, fetched by `cargo xtask aep-corpus` (commit-pinned, SHA-256-verified), and the tests read them only from the folder named by `EFFECTCRAFT_AEP_FIXTURES` (opt-in). Their embedded colour profiles are replaced by stubs made with `crates/aep/examples/stubicc.rs` (a header and a description, no colour data), so no vendor profile data is in them.
## Screenshots
+7 -1
View File
@@ -69,13 +69,19 @@ tests reach them only through `tests/it/common/mod.rs`, which hands a test its c
of its own cut from the combined file; a test whose fixture is missing (or with the variable not
set) prints `SKIPPED: fixture <name> missing` and passes.
`tests/fixtures/synthetic.aep` (115 KB) is the one project in the repository: made in After Effects 2026
`tests/fixtures/synthetic.aep` (115 KB) is one of the two projects in the repository: made in After Effects 2026
with the working space set to None (one composition, an image sequence of three 8x8 PNGs, a
solid, a folder, a Levels and a Glow effect with expressions). Every After Effects project embeds
the working space's ICC profile (HP sRGB IEC61966-2.1 for the default) in a JSON settings chunk;
the one `Utf8` chunk of this file that held a profile was replaced by `{}`, and nothing else was
changed. The tests in `tests/it/import/synthetic.rs` damage copies of it and need no other file.
`tests/fixtures/ai-mask-3099.aep` (131 KB, #3099) is the second: four hand-written one-page PDFs
named `.ai` with distinct page boxes, placed in a composition, one of them with two masks under a
Null parented to a Null (generated by `tests/fixtures/tools/ai-mask-3099.py` and
`ai-mask-3099.jsx`, scrubbed with `scrub.py` and `stubicc`). `tests/it/import/ai_mask.rs` checks
the import against After Effects' read-back, and the host's `vector_import` tests render it.
## Tools
- `tests/fixtures/tools/`: the generator of the test projects (regenerates the corpus with After
+9 -4
View File
@@ -817,7 +817,7 @@ fn plan_transform(layer: &Layer, src: &LayerSrc, comp: &Comp, source_size: (u32,
let cam_light = matches!(src, LayerSrc::Camera(_) | LayerSrc::Light(_));
let is_shape = matches!(src, LayerSrc::Shape(_));
let mut props = Vec::new();
let at = PropAt { comp, comp_name, layer: &layer.name, source: source_size, text: is_text || cam_light || is_shape };
let at = PropAt { comp, comp_name, layer: &layer.name, source: source_size, text: is_text || cam_light || is_shape, null: matches!(src, LayerSrc::Null) };
if let Some(tr) = layer.transform() {
for kind in PropKind::ALL {
// A camera's or light's point of interest is After Effects' Anchor Point.
@@ -1172,16 +1172,21 @@ struct PropAt<'a> {
source: (u32, u32),
/// The anchor is stored in pixels (text, camera, light and shape layers).
text: bool,
/// A null layer: an Anchor Point that is not written is its top-left corner (#3099).
null: bool,
}
/// What is written for transform property `p` (`kind`) of a layer: its value or keys in the
/// stored unit and its expression; `None` when it is at After Effects' default and has neither.
fn plan_prop(kind: PropKind, label: Option<&str>, p: &Property, at: PropAt<'_>, cp: &CompPlan, col: &mut Collector) -> Option<PropPlan> {
let PropAt { comp, comp_name, layer, source, text } = at;
let PropAt { comp, comp_name, layer, source, text, null } = at;
// A text layer's anchor is stored in pixels, with the origin as its default (385).
let (sw, sh) = if text { (1.0, 1.0) } else { (f64::from(source.0.max(1)), f64::from(source.1.max(1))) };
let what = format!("{comp_name}: '{layer}' {}", label.unwrap_or(kind.match_name()));
let dims = kind.dims();
let default: Vec<f64> = match kind {
PropKind::Anchor if text => vec![0.0, 0.0, 0.0],
// After Effects 2026 reads a null's absent Anchor Point as `[0, 0]` (#3099).
PropKind::Anchor if text || null => vec![0.0, 0.0, 0.0],
PropKind::Anchor => vec![0.5, 0.5, 0.0],
PropKind::Position => vec![f64::from(comp.width) / 2.0, f64::from(comp.height) / 2.0, 0.0],
PropKind::Scale => vec![1.0, 1.0, 1.0],
@@ -1319,7 +1324,7 @@ fn plan_prop(kind: PropKind, label: Option<&str>, p: &Property, at: PropAt<'_>,
/// back disabled).
fn plan_remap(p: &Property, comp: &Comp, cp: &CompPlan, layer: &str, comp_name: &str, col: &mut Collector) -> PropPlan {
// Rotation has the same layout (one component, stored as is, not spatial).
let at = PropAt { comp, comp_name, layer, source: (1, 1), text: false };
let at = PropAt { comp, comp_name, layer, source: (1, 1), text: false, null: false };
let planned = plan_prop(PropKind::Rotation, Some("Time Remap"), p, at, cp, col);
col.exported.time_remaps += 1;
let mut plan = planned.unwrap_or_else(|| PropPlan { kind: PropKind::Rotation, value: vec![0.0], keys: Vec::new(), expr: None });
+27 -2
View File
@@ -285,6 +285,8 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_
cx.col.missing_footage.push(shown);
}
let kind = if sequence.is_empty() { footage_kind(&path, f.native_frame_rate, f.audio_sample_rate.is_some()) } else { FootageKind::Sequence };
// A placed PDF / Illustrator file keeps After Effects' framing and size (#3099).
let placed_page = kind == FootageKind::Still && is_placed_page_path(&path);
let mut footage = Footage {
path,
kind,
@@ -295,6 +297,7 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_
// footage), and only then.
has_audio: kind == FootageKind::Audio || (kind == FootageKind::Video && f.audio_sample_rate.is_some()),
missing: !present,
placed_page,
sequence,
..Footage::default()
};
@@ -327,6 +330,14 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_
}
}
/// Whether `path` names a file whose page is framed as After Effects places it
/// ([`Footage::placed_page`]): an Illustrator or PDF file, by its extension. EPS files keep
/// their bounding box at their own size: how After Effects sizes them was not measured (#3099).
fn is_placed_page_path(path: &str) -> bool {
let ext = path.rsplit(['/', '\\']).next().and_then(|name| name.rsplit_once('.')).map(|(_, ext)| ext.to_ascii_lowercase());
matches!(ext.as_deref(), Some("pdf" | "ai"))
}
/// The name After Effects shows for a footage item that stores none: the file's name, or for a
/// sequence the name with the frame range (`shot_[0101-0103].exr`).
fn default_footage_name(item: &AepItem<'_, '_>, f: &crate::Footage) -> Option<String> {
@@ -453,11 +464,13 @@ fn camera_light_orient(auto_oriented: bool, light: Option<LightKind>) -> AutoOri
}
/// The unit a layer's anchor is stored in.
fn anchor_unit(camera_or_light: bool, text_or_shape: bool, size: (u32, u32)) -> AnchorUnit {
fn anchor_unit(camera_or_light: bool, text_or_shape: bool, null: bool, size: (u32, u32)) -> AnchorUnit {
if camera_or_light {
AnchorUnit::PointOfInterest
} else if text_or_shape {
AnchorUnit::Pixels
} else if null {
AnchorUnit::Null(size.0, size.1)
} else {
AnchorUnit::Source(size.0, size.1)
}
@@ -582,7 +595,7 @@ fn build_layers(project: &mut Project, cx: &mut Ctx<'_>, defs: &EffectDefinition
import_layer_styles(&layer, &mut l, &comp.global_light, &mut Ids(&mut project.next_id), &tm, &what, &mut cx.col);
// Text and shape layers store their anchor in pixels (385, 462); cameras and lights their
// point of interest.
let anchor = anchor_unit(is_camera || light.is_some(), is_text || is_shape, size);
let anchor = anchor_unit(is_camera || light.is_some(), is_text || is_shape, is_null, size);
if let Some(tr) = l.props.sub_mut("transform") {
import_transform(&layer, &name, comp_size, anchor, &tm, tr, &mut cx.col);
}
@@ -906,6 +919,18 @@ fn resolve_parents(
mod tests {
use super::*;
/// #3099: an `.ai` or `.pdf` from an `.aep` is a placed page; an EPS (not measured in After
/// Effects) and other files are not.
#[test]
fn only_illustrator_and_pdf_files_are_placed_pages() {
for path in [r"C:\fixture\x\logo.ai", "/work/Logo.AI", "art.pdf", r"C:\a.b\page.PDF"] {
assert!(is_placed_page_path(path), "{path}");
}
for path in [r"C:\fixture\x\still.eps", "a.epsf", "a.epsi", "still.png", r"C:\dir.ai\file", "ai", ""] {
assert!(!is_placed_page_path(path), "{path}");
}
}
#[test]
fn unlisted_extensions_follow_what_the_file_stores() {
assert_eq!(footage_kind("C:/clips/a.MP4", 0, false), FootageKind::Video);
+13 -1
View File
@@ -136,8 +136,12 @@ const IGNORED: [&str; 4] = ["ADBE Position_0", "ADBE Position_1", "ADBE Position
/// How the stored Anchor Point becomes pixels.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AnchorUnit {
/// A fraction of a source this big (pixels): AV layers.
/// A fraction of a source this big (pixels): AV layers; the source's centre when absent.
Source(u32, u32),
/// A fraction of a null object's source this big (pixels); its top-left corner when absent:
/// After Effects 2026 reads a null's absent Anchor Point as `[0, 0]` (#3099: two nulls made
/// with `addNull`, one left at its default, read back `[0, 0, 0]` and `[10, 20, 0]`).
Null(u32, u32),
/// Pixels as stored, the origin when absent: text (385) and shape (462) layers, which have no
/// source.
Pixels,
@@ -169,6 +173,14 @@ pub(crate) fn import_transform(
let (sw, sh) = (f64::from(w), f64::from(h));
(Kind::Anchor(sw, sh), tr.anchor_px(sw, sh))
}
AnchorUnit::Null(w, h) => {
let (sw, sh) = (f64::from(w), f64::from(h));
let px = match tr.anchor_fraction.source {
ValueSource::Default | ValueSource::Unreadable => [0.0; 3],
ValueSource::Stored | ValueSource::Keyframed => tr.anchor_px(sw, sh),
};
(Kind::Anchor(sw, sh), px)
}
AnchorUnit::Pixels | AnchorUnit::PointOfInterest => (Kind::Anchor(1.0, 1.0), tr.text_anchor_px()),
};
let poi = anchor == AnchorUnit::PointOfInterest;
Binary file not shown.
@@ -0,0 +1,7 @@
asset: ai-mask-3099.aep
title: Small After Effects test project: placed .ai files with masks under two parented Nulls (#3099)
author: EffectCraft contributors
source: original work (made in After Effects 2026 from own data: four hand-written one-page PDFs named .ai, generated by tools/ai-mask-3099.py, imported and laid out by tools/ai-mask-3099.jsx)
license: MIT OR Apache-2.0
added: 2026-11-08 by EffectCraft contributors
notes: Scrubbed with tools/scrub.py (footage paths C:\fixture\x\<name>.ai of the same length, host name HOST000, the XMP packet of an empty project of the contributor); its four embedded colour profiles replaced by stubs (description only, no colour data) with crates/aep/examples/stubicc.rs, then checked with the corpus repository's tools/aep/inspect-embedded.py (CLEAN). No Adobe asset is in it.
+1
View File
@@ -29,6 +29,7 @@ single-case files some cases are merged from, and the contributor's own animatio
| `scrub.py` | fixture hygiene of a saved `.aep`: footage alias paths replaced by neutral ones of the same length (`C:\fixtures\...`), host name `HOST000...`, neutral ascend counts, the XMP packet replaced by the base project's |
| `layers-comps-scrub-aepx.py` | the same XMP swap for the `.aepx` of the pair |
| `patch-text.py` | the byte edits of the text cases 732 and 733 (see below) |
| `ai-mask-3099.py`, `ai-mask-3099.jsx` | the committed `../ai-mask-3099.aep` (#3099): the `.py` writes four one-page PDFs named `.ai` with distinct page boxes, the `.jsx` (ExtendScript, run once through an After Effects MCP server's script call in an empty untitled project) imports them, builds composition `ai-mask` and returns After Effects' read-back that `../../it/import/ai_mask.rs` compares against |
## Run order (per file)
+94
View File
@@ -0,0 +1,94 @@
// Issue 3099: how After Effects sizes a placed .ai (PDF) and where masks / parented layers land.
// Generates ai-mask-3099.aep: run `python -I ai-mask-3099.py <work>` first, set `dir` to that
// folder, then run this script (ExtendScript) in an empty, untitled project. It returns After
// Effects' read-back (footage sizes, transforms, mask vertices, toComp points; the tests compare
// against it) and saves <work>/i3099-ai-mask.aep, which is then scrubbed (README: steps 5 and 6).
(function () {
var dir = "<work>/";
var out = {};
if (app.project.file !== null || app.project.numItems !== 0) {
return "ABORT: project not empty/untitled (" + app.project.numItems + " items)";
}
var target = new File(dir + "i3099-ai-mask.aep");
if (target.exists) { return "ABORT: target exists"; }
var names = ["i3099-a-boxes.ai", "i3099-b-frac.ai", "i3099-c-rot.ai", "i3099-d-trim.ai"];
var items = {};
out.footage = {};
for (var i = 0; i < names.length; i++) {
var io = new ImportOptions(new File(dir + names[i]));
io.importAs = ImportAsType.FOOTAGE;
var it = app.project.importFile(io);
items[names[i]] = it;
out.footage[names[i]] = { w: it.width, h: it.height, par: it.pixelAspect };
}
var comp = app.project.items.addComp("ai-mask", 1920, 1080, 1, 5, 25);
var art = comp.layers.add(items["i3099-a-boxes.ai"]);
art.name = "art";
var artD = comp.layers.add(items["i3099-d-trim.ai"]);
artD.name = "artD";
artD.collapseTransformation = true;
var artC = comp.layers.add(items["i3099-c-rot.ai"]);
artC.name = "artC";
artC.property("ADBE Transform Group").property("ADBE Position").setValue([1500, 800]);
var n1 = comp.layers.addNull(); n1.name = "N1";
var n2 = comp.layers.addNull(); n2.name = "N2";
n2.setParentWithJump(n1);
art.setParentWithJump(n2);
artD.setParentWithJump(n2);
var t = function (l, p, v) { l.property("ADBE Transform Group").property(p).setValue(v); };
t(n1, "ADBE Position", [700, 400]);
t(n1, "ADBE Rotate Z", 15);
t(n1, "ADBE Scale", [80, 120]);
t(n2, "ADBE Anchor Point", [10, 20]);
t(n2, "ADBE Position", [50, -30]);
t(n2, "ADBE Scale", [150, 150]);
t(art, "ADBE Position", [0, 0]);
t(artD, "ADBE Position", [300, 250]);
// Two rectangular masks on each art layer, in layer pixels.
var rect = function (l, nm, x0, y0, x1, y1) {
var m = l.property("ADBE Mask Parade").addProperty("ADBE Mask Atom");
m.name = nm;
var s = new Shape();
s.vertices = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
s.closed = true;
m.property("ADBE Mask Shape").setValue(s);
};
rect(art, "M1", 40, 30, 200, 90);
rect(art, "M2", 220, 100, 350, 160);
rect(artD, "M1", 40, 30, 200, 90);
rect(artD, "M2", 220, 100, 350, 160);
// World positions of layer points through the parent chain (toComp), read via a Point Control.
var probe = n1.property("ADBE Effect Parade").addProperty("ADBE Point Control");
var pt = probe.property(1);
var toComp = function (ln, p) {
pt.expression = "thisComp.layer('" + ln + "').toComp([" + p[0] + "," + p[1] + "])";
var v = pt.valueAtTime(0, false);
return [v[0], v[1]];
};
var rd = function (l) {
var tg = l.property("ADBE Transform Group");
var r = {
w: l.width, h: l.height,
anchor: tg.property("ADBE Anchor Point").value,
position: tg.property("ADBE Position").value,
scale: tg.property("ADBE Scale").value,
rot: tg.property("ADBE Rotate Z").value,
masks: []
};
var mp = l.property("ADBE Mask Parade");
for (var k = 1; k <= mp.numProperties; k++) {
r.masks.push(mp.property(k).property("ADBE Mask Shape").value.vertices);
}
return r;
};
out.layers = { art: rd(art), artD: rd(artD), artC: rd(artC), N1: rd(n1), N2: rd(n2) };
out.world = {};
var pts = { tl: [0, 0], tr: [art.width, 0], bl: [0, art.height], m1: [40, 30], m2: [350, 160] };
for (var key in pts) { out.world["art_" + key] = toComp("art", pts[key]); }
var ptsD = { tl: [0, 0], tr: [artD.width, 0], bl: [0, artD.height], m1: [40, 30] };
for (var key2 in ptsD) { out.world["artD_" + key2] = toComp("artD", ptsD[key2]); }
probe.remove();
app.project.save(target);
out.saved = target.fsName;
return out;
})();
+66
View File
@@ -0,0 +1,66 @@
"""Writes hand-made one-page PDFs named .ai with distinct page boxes (issue 3099 repro)."""
import os
import sys
out = sys.argv[1]
os.makedirs(out, exist_ok=True)
def pdf(page_extra, content):
objs = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
b"<< /Type /Page /Parent 2 0 R " + page_extra.encode() + b" /Contents 4 0 R >>",
b"<< /Length " + str(len(content)).encode() + b" >>\nstream\n" + content + b"\nendstream",
]
data = b"%PDF-1.4\n"
offs = []
for i, o in enumerate(objs, 1):
offs.append(len(data))
data += str(i).encode() + b" 0 obj\n" + o + b"\nendobj\n"
x = len(data)
data += b"xref\n0 " + str(len(objs) + 1).encode() + b"\n0000000000 65535 f \n"
for o in offs:
data += b"%010d 00000 n \n" % o
data += b"trailer\n<< /Size " + str(len(objs) + 1).encode() + b" /Root 1 0 R >>\nstartxref\n" + str(x).encode() + b"\n%%EOF\n"
return data
def boxes_content(boxes):
# One outline per box (different colours) plus a filled marker at the media box origin corner.
cols = ["0 0 0", "1 0 0", "0 0.6 0", "0 0 1", "1 0.5 0"]
s = b"0.85 0.85 0.85 rg "
x0, y0, x1, y1 = boxes[0]
s += b"%g %g %g %g re f\n" % (x0, y0, x1 - x0, y1 - y0)
for c, (a, b, cc, d) in zip(cols, boxes):
s += c.encode() + b" RG 1 w %g %g %g %g re S\n" % (a + 0.5, b + 0.5, cc - a - 1, d - b - 1)
# A solid square in the lower-left of the last (innermost) box: shows y orientation.
a, b, _, _ = boxes[-1]
s += b"0.2 0.2 0.8 rg %g %g 20 10 re f" % (a + 4, b + 4)
return s
def arr(b):
return "[%s]" % " ".join("%g" % v for v in b)
# A: every box distinct, nested, non-square.
media, crop, bleed, trim, art = [0, 0, 420, 220], [5, 5, 415, 215], [10, 10, 410, 210], [20, 20, 400, 200], [60, 50, 300.6, 170.4]
a = pdf(
f"/MediaBox {arr(media)} /CropBox {arr(crop)} /BleedBox {arr(bleed)} /TrimBox {arr(trim)} /ArtBox {arr(art)}",
boxes_content([media, crop, bleed, trim, art]),
)
# B: only a fractional media box.
mb = [0, 0, 300.6, 120.4]
b = pdf(f"/MediaBox {arr(mb)}", boxes_content([mb]))
# C: media box with an offset origin and /Rotate 90.
mc = [100, 50, 400, 150]
c = pdf(f"/MediaBox {arr(mc)} /Rotate 90", boxes_content([mc]))
# D: media + trim + art, no crop box (bleed around the artboard).
md, td, ad = [0, 0, 418, 218], [9, 9, 409, 209], [40, 30, 250, 150]
d = pdf(f"/MediaBox {arr(md)} /TrimBox {arr(td)} /BleedBox {arr(md)} /ArtBox {arr(ad)}", boxes_content([md, td, ad]))
for name, data in [("i3099-a-boxes.ai", a), ("i3099-b-frac.ai", b), ("i3099-c-rot.ai", c), ("i3099-d-trim.ai", d)]:
with open(os.path.join(out, name), "wb") as f:
f.write(data)
print(name, len(data))
+39
View File
@@ -83,6 +83,45 @@ fn ntsc_and_odd_frame_rates_use_the_time_bases_after_effects_writes() {
}
}
/// #3099: After Effects reads a null's absent Anchor Point as its top-left corner `[0, 0]`. A
/// null anchored at its centre is written (it was left out as "the default" and came back at the
/// corner); one at the corner is not, and both import back as they were.
#[test]
fn null_anchor_points_round_trip() {
let mut p = Project::default();
let c = add_comp(&mut p, "C", 800, 600, FrameRate::new(30, 1), 2.0, None);
add_layer(&mut p, c, "Corner", LayerSource::Null, (100, 100));
add_layer(&mut p, c, "Centre", LayerSource::Null, (100, 100));
for (idx, anchor) in [(0, [50.0, 50.0, 0.0]), (1, [0.0, 0.0, 0.0])] {
with_layer(&mut p, c, idx, |l| {
if let Some(a) = l.props.prop_mut("transform/anchor") {
a.value = Value::Vec3(anchor);
}
});
}
let r = export_all(&p);
let file = parse(&r.bytes).expect("parse");
let view = effectcraft_aep::Project::new(&file).expect("project");
let comp = view.all_items().into_iter().find(|i| i.comp().is_some()).expect("comp");
let stored: Vec<_> =
comp.comp().expect("c").layers().map(|l| (l.name().unwrap_or_default(), l.transform(800, 600).expect("transform").anchor_fraction.source)).collect();
assert_eq!(stored, [("Centre".to_string(), effectcraft_aep::ValueSource::Stored), ("Corner".to_string(), effectcraft_aep::ValueSource::Default)]);
let mut back = Project::default();
let none = |_: &str| false;
let res = effectcraft_aep::import::import(
&mut back,
&r.bytes,
&effectcraft_aep::import::ImportOptions { allow_losses: true, ..effectcraft_aep::import::ImportOptions::new(&none) },
)
.expect("import");
let comp = res.comps.first().and_then(|id| back.comp(*id)).expect("comp");
for (name, want) in [("Centre", [50.0, 50.0]), ("Corner", [0.0, 0.0])] {
let l = comp.layers.iter().find(|l| l.name == name).expect("layer");
let a = l.props.prop("transform/anchor").expect("anchor").value.as_vec2();
assert!(close(a[0], want[0]) && close(a[1], want[1]), "{name}: {a:?}");
}
}
#[test]
fn layer_header_fields() {
let mut p = Project::default();
+1
View File
@@ -4,6 +4,7 @@
mod helpers;
mod aepx;
mod ai_mask;
mod blend_modes;
mod cameras;
mod cases;
+113
View File
@@ -0,0 +1,113 @@
//! A placed Illustrator file with masks under two parented Nulls (#3099), against After Effects
//! 2026's own read-back of the committed project `tests/fixtures/ai-mask-3099.aep` (generator:
//! `tests/fixtures/tools/ai-mask-3099.py` and `ai-mask-3099.jsx`). Most of these guard After
//! Effects' values the importer already read so (the drawing of the page is tested in
//! `effectcraft-media` and the host's `vector_import` tests); the Anchor Point of the null left
//! at its default (`[0, 0]`, not the centre) was wrong before #3099.
//!
//! The four `.ai` files are hand-written one-page PDFs with distinct page boxes. After Effects
//! sized them by their ArtBox (a, d), by the MediaBox rounded up (b: 300.6 × 120.4 → 301 × 121)
//! and ignored `/Rotate 90` (c). Composition `ai-mask` (1920 × 1080): `art` (a) and `artD` (d,
//! Continuously Rasterize) are children of Null `N2`, itself a child of Null `N1`; both carry two
//! rectangular masks; `artC` (c) is on its own.
use effectcraft_aep::import::{ImportOptions, import};
use effectcraft_keyframe::Value;
use effectcraft_project::{ItemKind, Layer, Project};
use crate::import::helpers::comp;
const AI_MASK: &[u8] = include_bytes!("../../fixtures/ai-mask-3099.aep");
/// The fixture imported into an empty project (losses allowed, no footage file present).
fn load() -> (Project, effectcraft_aep::import::ImportResult) {
let mut project = Project::default();
let none = |_: &str| false;
let r = import(&mut project, AI_MASK, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap();
(project, r)
}
/// The first two components of the static value of property `path` of `l`.
fn vec2(l: &Layer, path: &str) -> [f64; 2] {
let v = l.props.prop(path).unwrap_or_else(|| panic!("{}: {path}", l.name)).value.as_vec2();
[v[0], v[1]]
}
/// `a` and `b` agree within float32 noise.
fn near(a: [f64; 2], b: [f64; 2]) -> bool {
(a[0] - b[0]).abs() < 1e-3 && (a[1] - b[1]).abs() < 1e-3
}
/// The four `.ai` items have After Effects' sizes and are placed pages.
#[test]
fn placed_ai_sizes_are_after_effects_sizes() {
let (p, _) = load();
let mut sizes: Vec<(String, (u32, u32))> = p
.items
.values()
.filter_map(|i| match &i.kind {
ItemKind::Footage(f) if f.path.ends_with(".ai") => {
// Framed and drawn as After Effects places the page (#3099).
assert!(f.placed_page, "{}", i.name);
Some((i.name.clone(), (f.width, f.height)))
}
_ => None,
})
.collect();
sizes.sort();
assert_eq!(
sizes,
[
("i3099-a-boxes.ai".to_string(), (241, 121)),
("i3099-b-frac.ai".to_string(), (301, 121)),
("i3099-c-rot.ai".to_string(), (300, 100)),
("i3099-d-trim.ai".to_string(), (210, 120)),
]
);
}
/// Parents, transforms (the default null anchor at `[0, 0]`) and both masks of each art layer
/// as After Effects read them back.
#[test]
fn masks_anchors_and_parents_are_after_effects_values() {
let (p, r) = load();
let (_, c) = comp(&p, &r, "ai-mask");
let layer = |name: &str| c.layers.iter().find(|l| l.name == name).unwrap_or_else(|| panic!("no layer {name}"));
let (art, art_d, art_c, n1, n2) = (layer("art"), layer("artD"), layer("artC"), layer("N1"), layer("N2"));
// Parents: art, artD -> N2 -> N1.
assert_eq!(art.parent, Some(n2.id));
assert_eq!(art_d.parent, Some(n2.id));
assert_eq!(n2.parent, Some(n1.id));
assert_eq!(n1.parent, None);
assert_eq!(art_c.parent, None);
// Transforms as After Effects read them back (anchor = half the source size for footage).
for (l, anchor, position, scale) in [
(art, [120.5, 60.5], [0.0, 0.0], [100.0, 100.0]),
(art_d, [105.0, 60.0], [300.0, 250.0], [100.0, 100.0]),
(art_c, [150.0, 50.0], [1500.0, 800.0], [100.0, 100.0]),
(n1, [0.0, 0.0], [700.0, 400.0], [80.0, 120.0]),
(n2, [10.0, 20.0], [50.0, -30.0], [150.0, 150.0]),
] {
assert!(near(vec2(l, "transform/anchor"), anchor), "{} anchor {:?}", l.name, vec2(l, "transform/anchor"));
assert!(near(vec2(l, "transform/position"), position), "{} position {:?}", l.name, vec2(l, "transform/position"));
assert!(near(vec2(l, "transform/scale"), scale), "{} scale {:?}", l.name, vec2(l, "transform/scale"));
}
match n1.props.prop("transform/rotation").map(|p| &p.value) {
Some(Value::Scalar(v)) => assert!((v - 15.0).abs() < 1e-9, "N1 rotation {v}"),
other => panic!("N1 rotation {other:?}"),
}
// Both art layers: M1 (40, 30)-(200, 90) and M2 (220, 100)-(350, 160) in layer pixels, the
// second reaching past the layer, as After Effects read the vertices back.
let rects = [[[40.0, 30.0], [200.0, 30.0], [200.0, 90.0], [40.0, 90.0]], [[220.0, 100.0], [350.0, 100.0], [350.0, 160.0], [220.0, 160.0]]];
for l in [art, art_d] {
let masks: Vec<_> = l.masks().expect("masks").groups().collect();
assert_eq!(masks.len(), 2, "{}", l.name);
for (m, want) in masks.iter().zip(&rects) {
let Some(Value::Path(path)) = m.get("path").map(|p| &p.value) else { panic!("{}: {} has no path", l.name, m.name) };
assert_eq!(path.vertices.len(), 4, "{}: {}", l.name, m.name);
for (v, w) in path.vertices.iter().zip(want) {
assert!(near(*v, *w), "{}: {} vertex {v:?} != {w:?}", l.name, m.name);
}
}
}
}
+25 -4
View File
@@ -5,7 +5,7 @@
use effectcraft_color::Label;
use effectcraft_keyframe::Value as KV;
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{ItemKind, LayerSource, Solid};
use effectcraft_project::{ItemKind, LayerSource, MatteKind, Solid, TrackMatte};
use serde_json::{Value, json};
use super::{CommandSpec, bad, has_layers, layers_p, str_p};
@@ -13,6 +13,8 @@ use crate::{EngineError, Result, Session, cmd};
const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector|instance3d";
/// Layer ▸ Create (`layer.create`): the `op` ([`OPS`]) on the given or selected layers; returns
/// the created layer ids and what was skipped.
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let op = str_p(p, "op").ok_or_else(|| bad("layer.create", format!("missing `op` ({OPS})")))?;
let (cid, lids) = layers_p(s, p)?;
@@ -99,8 +101,11 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
continue;
};
let bytes = s.services.read_file(&f.path).map_err(|e| EngineError::Other(format!("cannot read {}: {e}", f.path)))?;
let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref(), f.page)
// In the source layer's pixels, as the footage is drawn (a placed page of an After
// Effects project fills the stored size, #3099).
let doc = crate::vector::footage_doc(&f, &bytes)
.unwrap_or_else(|| Err(format!("{}: not a vector file", f.path)))
.map(|d| crate::vector::fitted_to_footage(d, &f))
.map_err(EngineError::Other)?;
// Images become footage layers (PNG files written next to the source and
// imported), parented to the shape layer below it.
@@ -123,7 +128,7 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
// Without images: one shape layer. With images: the shapes between them
// in layers of their own, so the stack keeps the document's paint order.
let segments = if image_items.is_empty() { vec![doc.clone()] } else { crate::vector::split_at_images(&doc) };
let top = crate::vector::shapes_from_vector(proj, &comp, &src, segments.last().unwrap_or(&doc));
let mut top = crate::vector::shapes_from_vector(proj, &comp, &src, segments.last().unwrap_or(&doc));
// Top to bottom: the last segment, image n, segment n − 1, … image 1, segment 0.
let mut stack = vec![];
for k in (0..segments.len().saturating_sub(1)).rev() {
@@ -140,9 +145,25 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
sl.name = format!("{} {}", top.name, k + 1);
stack.push(sl);
}
// A placed page shows only its frame (the footage's raster ends there): every
// layer made from it takes a hidden frame layer as its track matte (#3099).
let matte = crate::vector::frame_matte(proj, &comp, &src, &f);
if let Some(m) = &matte {
let tm = Some(TrackMatte { layer: m.id, kind: MatteKind::Alpha });
top.track_matte = tm;
for l in &mut stack {
l.track_matte = tm;
}
}
let mut ids = vec![top.id];
ids.extend(stack.iter().filter(|l| matches!(l.source, LayerSource::Shape)).map(|l| l.id));
// Each directly above the source: the top layer first.
// The frame matte is made too (last: the selection stays on the top layer).
ids.extend(matte.as_ref().map(|m| m.id));
// Each directly above the source: the frame matte (when there is one) above
// all, then the top layer first.
if let Some(m) = matte {
insert_above(proj, cid, lid, m, true)?;
}
insert_above(proj, cid, lid, top, true)?;
for l in stack {
insert_above(proj, cid, lid, l, true)?;
+7 -1
View File
@@ -176,9 +176,11 @@ fn psd_layer(s: &Session, path: &str, l: &SourceLayer) -> Option<(u32, Option<(u
Some((u32::try_from(found.index).ok()?, size))
}
/// The pixel size of vector footage `f` as its file is now (its page, framing and layer), or
/// `None` when the file cannot be read or shows nothing.
fn vector_size(s: &Session, f: &Footage) -> Option<(u32, u32)> {
let bytes = s.services.read_file(&f.path).ok()?;
let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref(), f.page)?.ok()?;
let doc = crate::vector::footage_doc(f, &bytes)?.ok()?;
(!doc.root.children.is_empty()).then(|| doc.pixel_size())
}
@@ -229,6 +231,10 @@ fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage {
// The layer is gone from the file: keep the item as it was.
None => (w, h) = (f.width, f.height),
},
// A placed page of an After Effects project keeps the size After Effects stored: the
// layers' anchors and masks are measured against it, and the page is drawn to fill it
// (the probe would measure the page as a viewer frames it, #3099).
None if f.placed_page => (w, h) = (f.width, f.height),
// A later page of a PDF: the probe measures the first page.
None if f.page != 0 => (w, h) = (f.width, f.height),
None => {}
+61 -3
View File
@@ -261,15 +261,73 @@ pub fn is_vector(f: &effectcraft_project::Footage) -> bool {
effectcraft_render::is_vector_footage(f) || f.path.to_ascii_lowercase().ends_with(".svg")
}
/// `doc` (the document of footage `f`) in the footage's layer pixels, as the renderer draws it
/// ([`effectcraft_render::vector_fit`]): a placed page of an After Effects project is scaled to
/// fill the size stored for it; other footage is unchanged.
pub fn fitted_to_footage(mut doc: Doc, f: &effectcraft_project::Footage) -> Doc {
let (_, [sx, sy]) = effectcraft_render::vector_fit(f, [doc.width, doc.height], doc.pixel_size(), 1.0);
if f.placed_page {
doc.root.transform = Affine::scale_non_uniform(sx, sy) * doc.root.transform;
(doc.width, doc.height) = (doc.width * sx, doc.height * sy);
}
doc
}
/// Create Shapes from Vector Layer on a placed page of an After Effects project: a hidden shape
/// layer filling the frame the footage is drawn in (its `width` × `height` raster, in the layer
/// pixels of [`fitted_to_footage`]'s document), to be the Alpha track matte of every layer made
/// from the page, so bleed, art past the artboard and the page's background stay hidden as on
/// the footage (#3099). `None` for other footage, whose shapes are converted whole as before.
///
/// A track matte is drawn at the composition's resolution, so its edge is the footage's raster
/// edge on shape and image layers alike; a mask on an image layer would be drawn at the image's
/// own resolution and blur its edge when the image is scaled up.
pub fn frame_matte(proj: &mut Project, comp: &Comp, src: &Layer, f: &effectcraft_project::Footage) -> Option<Layer> {
if !f.placed_page || f.width == 0 || f.height == 0 {
return None;
}
let svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{0}" height="{1}"><rect width="{0}" height="{1}" fill="#ffffff"/></svg>"##,
f.width, f.height
);
let doc = effectcraft_svg::parse(svg.as_bytes()).ok()?;
let mut l = build::layer(proj, comp, &format!("{} Frame", src.name), LayerSource::Shape, (0, 0), None);
add_doc_shapes(proj, &mut l, &doc);
copy_transform(proj, src, &mut l);
like_source(&mut l, src);
// The layers it mattes carry the source's opacity already.
if let Some(o) = l.props.prop_mut("transform/opacity") {
o.value = Value::Scalar(100.0);
o.keys.clear();
o.expr = None;
}
l.name = comp.unique_layer_name(&format!("{} Frame", src.name));
l.switches.video = false;
Some(l)
}
/// The vector document of footage `f` (its file read as `bytes`; `None` for other formats): its
/// page, framed as [`effectcraft_project::Footage::placed_page`] says, restricted to its layer.
pub fn footage_doc(f: &effectcraft_project::Footage, bytes: &[u8]) -> Option<Result<Doc, String>> {
let framing = if f.placed_page { effectcraft_pdf::Framing::Placed } else { effectcraft_pdf::Framing::Viewer };
vector_doc(&f.path, bytes, f.layer.as_ref(), f.page, framing)
}
/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats): page
/// `page` of a PDF, restricted to one layer when `layer` names it.
pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, page: u32) -> Option<Result<Doc, String>> {
/// `page` of a PDF framed as `framing` says, restricted to one layer when `layer` names it.
pub fn vector_doc(
path: &str,
bytes: &[u8],
layer: Option<&effectcraft_project::SourceLayer>,
page: u32,
framing: effectcraft_pdf::Framing,
) -> Option<Result<Doc, String>> {
if path.to_ascii_lowercase().ends_with(".svg") || effectcraft_svg::looks_like_svg(bytes) {
return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}")));
}
effectcraft_pdf::sniff(bytes)?;
Some(
effectcraft_pdf::parse_page(bytes, page as usize)
effectcraft_pdf::parse_page_framed(bytes, page as usize, framing)
.map(|d| match layer {
Some(l) => effectcraft_pdf::layer_doc_with_sizes(&d, l.index as usize, l.layer_size).0,
None => d,
+355 -1
View File
@@ -466,6 +466,11 @@ fn psd_smart_objects_with_perspective_and_warp_bake_as_placed() {
/// A PDF with two placed images (one turned a quarter) between a background and a shape.
fn image_pdf() -> Vec<u8> {
image_pdf_with("")
}
/// [`image_pdf`] with `boxes` added to the page dictionary.
fn image_pdf_with(boxes: &str) -> Vec<u8> {
let content = "0.9 0.9 0.9 rg 0 0 120 80 re f\nq 60 0 0 30 10 40 cm /Im Do Q\nq 0 40 -20 0 110 10 cm /Im Do Q\n0 0 1 rg 20 5 30 20 re f\n";
// 8×8 pixels in quadrants: red, green / white, black.
let quad = |x: usize, y: usize| -> [u8; 3] { [[[255, 0, 0], [0, 255, 0]], [[255, 255, 255], [0, 0, 0]]][y / 4][x / 4] };
@@ -474,7 +479,7 @@ fn image_pdf() -> Vec<u8> {
&[
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None),
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >>".into(), None),
(3, format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] {boxes} /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >>"), None),
(4, "<< >>".into(), Some(content.as_bytes().to_vec())),
(5, "<< /Type /XObject /Subtype /Image /Width 8 /Height 8 /ColorSpace /DeviceRGB /BitsPerComponent 8 >>".into(), Some(px)),
],
@@ -546,6 +551,355 @@ fn retain_pdf(width: u32) -> Vec<u8> {
)
}
/// A 200 × 100 pt page: red left half, blue right half.
fn halves_pdf() -> Vec<u8> {
effectcraft_pdf::write::pdf(
&[
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None),
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R >>".into(), None),
(4, "<< >>".into(), Some(b"1 0 0 rg 0 0 100 100 re f 0 0 1 rg 100 0 100 100 re f".to_vec())),
],
1,
)
}
/// #3099: footage from an After Effects project ([`effectcraft_project::Footage::placed_page`])
/// keeps the size After Effects stored for a placed .ai, which can differ from the page as read
/// here. The page fills that size at full and at reduced resolution alike: it was drawn at its own
/// aspect at full resolution and stretched to the stored size below 3/4 (the art jumped when the
/// viewer changed resolution, and the masks, stored against that size, came out too large at
/// full resolution). Two placed items of one file at different stored sizes each get their own
/// pixels, and neither reuses the file's frame at its own size, decoded first.
#[test]
fn placed_vector_footage_fills_its_stored_size_at_every_resolution() {
use effectcraft_project::ItemKind;
use effectcraft_time::Tick;
let path = tmp("stored-size.ai");
std::fs::write(&path, halves_pdf()).unwrap();
let mut s = effectcraft_host::session();
let item = ItemId(s.execute_checked("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap());
let red = |p: [f32; 4]| p[3] > 0.99 && p[0] > 0.9 && p[2] < 0.1;
let blue = |p: [f32; 4]| p[3] > 0.99 && p[2] > 0.9 && p[0] < 0.1;
// At the file's own size first (its frame goes to the pool's cache).
s.execute("comp.new", json!({"name": "Own", "width": 200, "height": 100, "frameRate": 25, "duration": 1})).unwrap();
s.execute_checked("layer.addItem", json!({"item": item.0})).unwrap();
let own_comp = s.active_comp_id().unwrap();
let own = s.render(own_comp, Tick::ZERO, RenderOpts::default());
assert!(red(own.get(50, 90)) && blue(own.get(150, 90)));
// Two placed items of the file with the sizes an After Effects project stores (other aspects).
let proj = std::sync::Arc::make_mut(&mut s.project);
let ItemKind::Footage(f) = &mut proj.item_mut(item).unwrap().kind else { panic!("footage") };
(f.width, f.height, f.placed_page) = (100, 80, true);
let tall = effectcraft_project::Footage { width: 60, height: 120, ..f.clone() };
let tall = proj.add_item("tall", Default::default(), None, ItemKind::Footage(tall));
for (item, (w, h)) in [(item, (100u32, 80u32)), (tall, (60, 120))] {
s.execute("comp.new", json!({"name": format!("Stored {w}"), "width": w, "height": h, "frameRate": 25, "duration": 1})).unwrap();
let lid = s.execute_checked("layer.addItem", json!({"item": item.0})).unwrap()["layer"].as_u64().unwrap();
let cid = s.active_comp_id().unwrap();
let (l, r, top, bottom) = (i64::from(w / 4), i64::from(w * 3 / 4), 3, i64::from(h) - 4);
for collapse in [false, true] {
s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "collapse", "value": collapse})).unwrap();
let full = s.render(cid, Tick::ZERO, RenderOpts::default());
assert_eq!((full.width, full.height), (w, h));
for y in [top, bottom] {
let (a, b) = (full.get(l, y), full.get(r, y));
assert!(red(a) && blue(b), "{w}×{h}, collapse {collapse}, full resolution, row {y}: {a:?} {b:?}");
}
let half = s.render(cid, Tick::ZERO, RenderOpts { scale: 0.5, ..RenderOpts::default() });
assert_eq!((half.width, half.height), (w / 2, h / 2));
for y in [top / 2, bottom / 2] {
let (a, b) = (half.get(l / 2, y), half.get(r / 2, y));
assert!(red(a) && blue(b), "{w}×{h}, collapse {collapse}, half resolution, row {y}: {a:?} {b:?}");
}
}
// Create Shapes from Vector Layer draws the shapes where the footage is drawn.
s.execute_checked("layer.create", json!({"op": "shapesFromVector", "layers": [lid]})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "video", "value": false})).unwrap();
let shapes = s.render(cid, Tick::ZERO, RenderOpts::default());
for y in [top, bottom] {
let (a, b) = (shapes.get(l, y), shapes.get(r, y));
assert!(red(a) && blue(b), "{w}×{h}, shapes, row {y}: {a:?} {b:?}");
}
}
}
/// #3099 review: Create Shapes from Vector Layer on a placed page keeps the page's frame. The
/// footage's raster shows only the ArtBox (241 × 121) of `art_box_ai()`, whose grey media box
/// reaches past it on every side; in a composition larger than the footage, the outlines show
/// the same colours inside the frame and nothing (the background) outside it. Without the clip
/// they showed the whole page: the grey background around the art.
#[test]
fn shapes_from_a_placed_page_stay_inside_its_frame() {
use effectcraft_project::ItemKind;
use effectcraft_time::Tick;
let path = tmp("placed-frame.ai");
std::fs::write(&path, art_box_ai()).unwrap();
let mut s = effectcraft_host::session();
let item = ItemId(s.execute_checked("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap());
// As an After Effects project stores it: the ArtBox's size, placed.
let ItemKind::Footage(f) = &mut std::sync::Arc::make_mut(&mut s.project).item_mut(item).unwrap().kind else { panic!("footage") };
(f.width, f.height, f.placed_page) = (241, 121, true);
s.execute("comp.new", json!({"name": "Big", "width": 400, "height": 300, "frameRate": 25, "duration": 1})).unwrap();
let lid = s.execute_checked("layer.addItem", json!({"item": item.0})).unwrap()["layer"].as_u64().unwrap();
let cid = s.active_comp_id().unwrap();
// The layer is centred: layer pixel (x, y) is comp (x + 79.5, y + 89.5).
let at = |img: &Image, x: f64, y: f64| img.get((x + 79.5).floor() as i64, (y + 89.5).floor() as i64);
let red = |p: [f32; 4]| p[3] > 0.99 && p[0] > 0.9 && p[1] < 0.1 && p[2] < 0.1;
let blue = |p: [f32; 4]| p[3] > 0.99 && p[2] > 0.9 && p[0] < 0.1 && p[1] < 0.1;
let empty = |p: [f32; 4]| p[0] < 0.02 && p[1] < 0.02 && p[2] < 0.02;
let inside = [([20.0, 20.0], true), ([110.0, 100.0], true), ([130.0, 20.0], false), ([230.0, 110.0], false)];
// Outside the frame, where the page's grey media box would show: left, right, above, below.
let outside = [[-20.0, 60.0], [-55.0, 100.0], [260.0, 60.0], [300.0, 10.0], [120.0, -20.0], [120.0, 140.0]];
let check = |img: &Image, what: &str| {
for (p, want_red) in inside {
let px = at(img, p[0], p[1]);
assert!(if want_red { red(px) } else { blue(px) }, "{what}: inside the frame at {p:?}: {px:?}");
}
for p in outside {
let px = at(img, p[0], p[1]);
assert!(empty(px), "{what}: outside the frame at {p:?}: {px:?}");
}
};
check(&s.render(cid, Tick::ZERO, RenderOpts::default()), "footage");
s.execute_checked("layer.create", json!({"op": "shapesFromVector", "layers": [lid]})).unwrap();
let comp = s.project.comp(cid).unwrap();
assert!(!comp.layers.iter().find(|l| l.id.0 == lid).unwrap().switches.video, "the source is switched off");
check(&s.render(cid, Tick::ZERO, RenderOpts::default()), "shapes");
}
/// #3099 review: the images of a placed page become footage layers clipped to the frame too.
/// `image_pdf_with` an ArtBox [20 15 100 70]: the upright image reaches past the frame's left
/// edge, the turned one past its right and bottom edges, the grey page around it. After the
/// conversion the layers inside the frame match the footage, and outside it nothing shows.
#[test]
fn images_from_a_placed_page_stay_inside_its_frame() {
use effectcraft_project::{ItemKind, MatteKind, TrackMatte};
use effectcraft_time::Tick;
let path = tmp("placed-images.pdf");
std::fs::write(&path, image_pdf_with("/ArtBox [20 15 100 70]")).unwrap();
let mut s = effectcraft_host::session();
let item = ItemId(s.execute_checked("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap());
let ItemKind::Footage(f) = &mut std::sync::Arc::make_mut(&mut s.project).item_mut(item).unwrap().kind else { panic!("footage") };
(f.width, f.height, f.placed_page) = (80, 55, true);
s.execute("comp.new", json!({"name": "Big", "width": 200, "height": 150, "frameRate": 25, "duration": 1})).unwrap();
let lid = s.execute_checked("layer.addItem", json!({"item": item.0})).unwrap()["layer"].as_u64().unwrap();
let cid = s.active_comp_id().unwrap();
// Centred: layer pixel (x, y) is comp (x + 60, y + 47.5).
let at = |img: &Image, x: f64, y: f64| img.get((x + 60.0).floor() as i64, (y + 47.5).floor() as i64);
let empty = |p: [f32; 4]| p[0] < 0.02 && p[1] < 0.02 && p[2] < 0.02;
// Inside: both images and the grey page; outside: the images past the frame, the grey page.
let inside = [[10.0, 8.0], [40.0, 8.0], [10.0, 22.0], [40.0, 22.0], [75.0, 25.0], [75.0, 50.0], [5.0, 40.0], [60.0, 10.0]];
let outside = [[-5.0, 15.0], [85.0, 40.0], [75.0, 58.0], [-15.0, 50.0], [40.0, -5.0]];
let footage = s.render(cid, Tick::ZERO, RenderOpts::default());
for p in outside {
assert!(empty(at(&footage, p[0], p[1])), "footage outside the frame at {p:?}: {:?}", at(&footage, p[0], p[1]));
}
let r = s.execute_checked("layer.create", json!({"op": "shapesFromVector", "layers": [lid]})).unwrap();
assert_eq!(r["skipped"], json!([]), "{r}");
// One hidden frame layer is the Alpha track matte of every layer made from the page.
let comp = s.project.comp(cid).unwrap();
let frame = comp.layers.iter().find(|l| l.name.ends_with(" Frame")).expect("the frame matte layer");
assert!(!frame.switches.video);
let made: Vec<_> = comp.layers.iter().filter(|l| l.id != frame.id && l.id.0 != lid).collect();
assert_eq!(made.iter().filter(|l| l.name == "Image").count(), 2);
for l in &made {
assert_eq!(l.track_matte, Some(TrackMatte { layer: frame.id, kind: MatteKind::Alpha }), "{}", l.name);
}
let shapes = s.render(cid, Tick::ZERO, RenderOpts::default());
for p in inside {
let (a, b) = (at(&shapes, p[0], p[1]), at(&footage, p[0], p[1]));
assert!((0..4).all(|k| (a[k] - b[k]).abs() < 0.05), "inside the frame at {p:?}: {a:?} vs footage {b:?}");
}
for p in outside {
assert!(empty(at(&shapes, p[0], p[1])), "shapes outside the frame at {p:?}: {:?}", at(&shapes, p[0], p[1]));
}
}
/// A PDF with an ArtBox, imported here (not from an After Effects project), and a project saved
/// with it before #3099: shown as a PDF viewer frames it (the crop box, 410 × 210) at its own
/// size, exactly as before; the project file has no new field.
#[test]
fn a_pdf_with_an_art_box_imported_here_keeps_the_viewer_framing() {
use effectcraft_project::{ItemKind, Project};
use effectcraft_time::Tick;
let bytes = art_box_ai();
let path = tmp("viewer-art-box.pdf");
std::fs::write(&path, &bytes).unwrap();
let mut s = effectcraft_host::session();
let item = ItemId(s.execute_checked("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap());
let ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!("footage") };
assert_eq!((f.width, f.height, f.placed_page), (410, 210, false));
s.execute("comp.new", json!({"name": "V", "width": 410, "height": 210, "frameRate": 25, "duration": 1})).unwrap();
s.execute_checked("layer.addItem", json!({"item": item.0})).unwrap();
let cid = s.active_comp_id().unwrap();
let direct = effectcraft_svg::rasterize(&effectcraft_pdf::parse(&bytes).unwrap(), 410, 210, 1.0);
assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 1.0 / 255.0);
let saved = s.project.to_json();
assert!(!saved.contains("placed_page"), "the saved project is in the earlier format");
s.project = std::sync::Arc::new(Project::from_json(&saved).unwrap());
assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 1.0 / 255.0, "reopened");
}
/// `i3099-a-boxes.ai` of `effectcraft-aep`'s `ai-mask-3099.aep` (its boxes as in
/// `tests/fixtures/tools/ai-mask-3099.py`): MediaBox 420 × 220, CropBox, BleedBox and TrimBox
/// inside it, ArtBox [60 50 300.6 170.4]. Here grey over the media box, the art box red on its
/// left half and blue on its right half.
fn art_box_ai() -> Vec<u8> {
let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 420 220] /CropBox [5 5 415 215] /BleedBox [10 10 410 210] /TrimBox [20 20 400 200] /ArtBox [60 50 300.6 170.4] /Contents 4 0 R >>";
let content = b"0.5 g 0 0 420 220 re f 1 0 0 rg 60 50 120.3 120.4 re f 0 0 1 rg 180.3 50 120.3 120.4 re f";
effectcraft_pdf::write::pdf(
&[
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None),
(3, page.into(), None),
(4, "<< >>".into(), Some(content.to_vec())),
],
1,
)
}
/// `i3099-d-trim.ai` of `ai-mask-3099.aep`: MediaBox 418 × 218, TrimBox, ArtBox [40 30 250 150]
/// (no CropBox). Grey over the media box, the art box red on its left half, blue on its right.
fn trim_ai() -> Vec<u8> {
let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 418 218] /TrimBox [9 9 409 209] /BleedBox [0 0 418 218] /ArtBox [40 30 250 150] /Contents 4 0 R >>";
let content = b"0.5 g 0 0 418 218 re f 1 0 0 rg 40 30 105 120 re f 0 0 1 rg 145 30 105 120 re f";
effectcraft_pdf::write::pdf(
&[
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None),
(3, page.into(), None),
(4, "<< >>".into(), Some(content.to_vec())),
],
1,
)
}
/// `i3099-c-rot.ai` of `ai-mask-3099.aep`: MediaBox [100 50 400 150] with `/Rotate 90`, grey,
/// with a blue 20 × 10 marker 4 pt in from the media box's lower-left corner.
fn rotated_ai() -> Vec<u8> {
effectcraft_pdf::write::pdf(
&[
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None),
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [100 50 400 150] /Rotate 90 /Contents 4 0 R >>".into(), None),
(4, "<< >>".into(), Some(b"0.5 g 100 50 300 100 re f 0 0 1 rg 104 54 20 10 re f".to_vec())),
],
1,
)
}
/// #3099 end to end, against After Effects 2026's own read-back of the committed project
/// `ai-mask-3099.aep`: a placed .ai (ArtBox inside a larger crop box) parented to a Null parented
/// to a Null, with two rectangular masks. After Effects sizes the footage 241 × 121 (the ArtBox)
/// and puts the layer's corners at the comp points below (`toComp`). The art fills the layer
/// between the masks at full and at half resolution; it was drawn from the crop box (410 × 210)
/// scaled to the layer's width, so the masks cut the wrong part of it and half resolution
/// squeezed it, and the Null left at its default anchor moved it. `artC` (`/Rotate 90`) is drawn
/// unturned as After Effects draws it, `artD` (Continuously Rasterize) as `art`.
#[test]
fn aep_ai_layer_with_masks_under_two_nulls_renders_like_after_effects() {
use effectcraft_engine::render::EvalCtx;
use effectcraft_project::ItemKind;
use effectcraft_time::Tick;
let dir = std::path::PathBuf::from(tmp("ai-mask-3099"));
std::fs::create_dir_all(&dir).unwrap();
let aep = dir.join("ai-mask-3099.aep");
// The fixture belongs to `effectcraft-aep` (its sidecar and generator are there); this test
// renders it through the wired session, which that crate cannot.
std::fs::write(&aep, include_bytes!("../../../aep/tests/fixtures/ai-mask-3099.aep")).unwrap();
let mut s = effectcraft_host::session();
s.execute_checked("file.importAep", json!({"path": aep.to_string_lossy(), "allowLosses": true})).unwrap();
// The project's footage paths are neutral (`C:\fixture\x\...`): the files are linked here.
for (name, bytes, size) in
[("i3099-a-boxes.ai", art_box_ai(), (241, 121)), ("i3099-c-rot.ai", rotated_ai(), (300, 100)), ("i3099-d-trim.ai", trim_ai(), (210, 120))]
{
let file = dir.join(name);
std::fs::write(&file, bytes).unwrap();
let item = s
.project
.items
.values()
.find(|i| matches!(&i.kind, ItemKind::Footage(f) if f.path.ends_with(name)))
.map(|i| i.id)
.unwrap_or_else(|| panic!("footage {name}"));
let ItemKind::Footage(f) = &mut std::sync::Arc::make_mut(&mut s.project).item_mut(item).unwrap().kind else { panic!("footage") };
assert_eq!((f.width, f.height, f.placed_page), (size.0, size.1, true), "{name}: After Effects' size of the placed .ai");
(f.path, f.missing) = (file.to_string_lossy().into_owned(), false);
}
let cid = s.project.items.values().find(|i| i.name == "ai-mask").map(|i| i.id).expect("comp ai-mask");
// After Effects' toComp of the art layer's points (layer pixels → comp pixels).
let ae = [
([0.0, 0.0], [634.193413913741, 195.085717365354]),
([241.0, 0.0], [913.539162876539, 269.936185209003]),
([0.0, 121.0], [577.822625890412, 405.464362331113]),
([40.0, 30.0], [666.58162514008, 259.669026149884]),
([350.0, 160.0], [965.342375965623, 581.976354279664]),
];
let comp = s.project.comp(cid).unwrap();
let art = comp.layer_by_name("art").unwrap();
let (m, _) = EvalCtx::new(&s.project, cid, comp, Tick::ZERO).layer_to_comp(art);
let to_comp = |[u, v]: [f64; 2]| [m.0[0][0] * u + m.0[0][1] * v + m.0[0][2], m.0[1][0] * u + m.0[1][1] * v + m.0[1][2]];
for (p, want) in ae {
let got = to_comp(p);
assert!((got[0] - want[0]).abs() < 1e-3 && (got[1] - want[1]).abs() < 1e-3, "{p:?}: {got:?} vs After Effects {want:?}");
}
// Inside the masks (M1: x 40..200, y 30..90; M2: x 220..350, y 100..160, cut by the layer).
let red = |p: [f32; 4]| p[0] > 0.9 && p[1] < 0.1 && p[2] < 0.1;
let blue = |p: [f32; 4]| p[2] > 0.9 && p[0] < 0.1 && p[1] < 0.1;
let full = s.render(cid, Tick::ZERO, RenderOpts::default());
let half = s.render(cid, Tick::ZERO, RenderOpts { scale: 0.5, ..RenderOpts::default() });
for (p, want_red) in [([60.0, 45.0], true), ([100.0, 75.0], true), ([150.0, 40.0], false), ([190.0, 80.0], false), ([232.0, 112.0], false)] {
let [x, y] = to_comp(p);
let at = |img: &Image, k: f64| img.get((x * k).round() as i64, (y * k).round() as i64);
for (img, k, what) in [(&full, 1.0, "full"), (&half, 0.5, "half")] {
let px = at(img, k);
let ok = if want_red { red(px) } else { blue(px) };
assert!(ok, "{what} resolution, layer point {p:?}: {px:?} (want {})", if want_red { "red" } else { "blue" });
}
}
// Outside the masks the art is cut away: the black background.
for p in [[100.0, 110.0], [20.0, 60.0]] {
let [x, y] = to_comp(p);
let px = full.get(x.round() as i64, y.round() as i64);
assert!(px[0] < 0.05 && px[1] < 0.05 && px[2] < 0.05, "layer point {p:?}: {px:?}");
}
// artC (/Rotate 90, position (1500, 800), anchor (150, 50)): After Effects draws the page
// unturned, 300 × 100, the marker at its lower left (comp 1354..1374, 836..846).
for (img, k, what) in [(&full, 1.0, "full"), (&half, 0.5, "half")] {
let at = |x: f64, y: f64| img.get((x * k).round() as i64, (y * k).round() as i64);
let marker = at(1364.0, 841.0);
assert!(blue(marker), "{what} resolution, artC marker: {marker:?}");
for (x, y) in [(1450.0, 780.0), (1630.0, 790.0), (1364.0, 770.0)] {
let grey = at(x, y);
assert!(grey[3] > 0.99 && (grey[0] - grey[2]).abs() < 0.02 && grey[0] > 0.1 && grey[0] < 0.9, "{what} resolution, artC at ({x}, {y}): {grey:?}");
}
}
for (x, y) in [(1340.0, 800.0), (1500.0, 870.0)] {
let px = full.get(x as i64, y as i64);
assert!(px[0] < 0.05 && px[2] < 0.05, "outside artC ({x}, {y}): {px:?}");
}
// artD (ArtBox 210 × 120, Continuously Rasterize, the same masks, also under N2): placed by
// After Effects' toComp of its corners.
let (tl, tr, bl) = ([883.191424310055, 728.610562914909], [1126.6047325349, 793.832962280744], [827.286510567911, 937.250541393348]);
let d_to_comp = |[u, v]: [f64; 2]| [0, 1].map(|i| tl[i] + u / 210.0 * (tr[i] - tl[i]) + v / 120.0 * (bl[i] - tl[i]));
for (p, want_red) in [([60.0, 45.0], true), ([90.0, 75.0], true), ([150.0, 40.0], false), ([190.0, 80.0], false)] {
let [x, y] = d_to_comp(p);
for (img, k, what) in [(&full, 1.0, "full"), (&half, 0.5, "half")] {
let px = img.get((x * k).round() as i64, (y * k).round() as i64);
let ok = if want_red { red(px) } else { blue(px) };
assert!(ok, "artD, {what} resolution, layer point {p:?}: {px:?} (want {})", if want_red { "red" } else { "blue" });
}
}
}
#[test]
fn ai_composition_retains_layer_sizes_and_positions() {
use effectcraft_project::{ItemKind, LayerSource, Project};
+90 -11
View File
@@ -14,19 +14,20 @@ fn is_svg(path: &str, bytes: &[u8]) -> bool {
}
/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats): page
/// `page` of a PDF, restricted to the footage's layer when it names one.
/// `page` of a PDF framed as `framing` says, restricted to the footage's layer when it names one.
pub fn vector_doc(
path: &str,
bytes: &[u8],
layer: Option<&effectcraft_project::SourceLayer>,
page: u32,
framing: effectcraft_pdf::Framing,
) -> Option<std::result::Result<effectcraft_svg::Doc, String>> {
if is_svg(path, bytes) {
return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}")));
}
effectcraft_pdf::sniff(bytes)?;
Some(
effectcraft_pdf::parse_page(bytes, page as usize)
effectcraft_pdf::parse_page_framed(bytes, page as usize, framing)
.map(|d| match layer {
Some(l) => effectcraft_pdf::layer_doc_with_sizes(&d, l.index as usize, l.layer_size).0,
None => d,
@@ -111,14 +112,14 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -
return Ok(Some(straight_to_image(px.width, px.height, &px.data, op)));
}
if is_svg(path, bytes) || is_pdf_like(path, bytes) {
let doc = vector_doc(path, bytes, footage.layer.as_ref(), footage.page).unwrap_or(Err(String::new())).map_err(MediaError::Decode)?;
let (w, h) = doc.pixel_size();
let doc = footage_doc(path, bytes, footage).unwrap_or(Err(String::new())).map_err(MediaError::Decode)?;
let ((w, h), to_image) = fit(&doc, footage, 1.0);
// A declared size up to 30000² would be a 14 GB allocation that aborts the app (#2136):
// past what Continuously Rasterize can draw, the footage reports an error instead.
if u64::from(w) * u64::from(h) > u64::from(MAX_VECTOR_SIDE) * u64::from(MAX_VECTOR_SIDE) {
return Err(MediaError::Decode(format!("{path}: {w}×{h} pixels is larger than vector footage can be drawn ({MAX_VECTOR_SIDE}×{MAX_VECTOR_SIDE})")));
}
let img = effectcraft_svg::rasterize(&doc, w, h, 1.0);
let img = effectcraft_svg::rasterize_with(&doc, w, h, to_image);
// The rasteriser produces premultiplied pixels already.
let _ = op;
return Ok(Some(img));
@@ -129,12 +130,29 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -
/// The largest side vector footage is drawn at, decoded or continuously rasterised.
const MAX_VECTOR_SIDE: u32 = 16384;
/// A vector file rasterised at `scale` × its pixel size (Continuously Rasterize).
pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, page: u32, scale: f64) -> Option<Image> {
let doc = vector_doc(path, bytes, layer, page)?.ok()?;
let (w, h) = doc.pixel_size();
let (sw, sh) = (((w as f64 * scale).ceil() as u32).clamp(1, MAX_VECTOR_SIDE), ((h as f64 * scale).ceil() as u32).clamp(1, MAX_VECTOR_SIDE));
Some(effectcraft_svg::rasterize(&doc, sw, sh, scale))
/// The vector footage `footage` (of the file `path` with these `bytes`) rasterised at `scale` ×
/// its size as [`effectcraft_render::vector_fit`] draws it (Continuously Rasterize).
pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], footage: &Footage, scale: f64) -> Option<Image> {
let doc = footage_doc(path, bytes, footage)?.ok()?;
let ((sw, sh), to_image) = fit(&doc, footage, scale);
Some(effectcraft_svg::rasterize_with(&doc, sw.clamp(1, MAX_VECTOR_SIDE), sh.clamp(1, MAX_VECTOR_SIDE), to_image))
}
/// The page framing of vector footage: as After Effects places it for footage from an After
/// Effects project ([`Footage::placed_page`]), as a PDF viewer shows it otherwise.
fn framing(footage: &Footage) -> effectcraft_pdf::Framing {
if footage.placed_page { effectcraft_pdf::Framing::Placed } else { effectcraft_pdf::Framing::Viewer }
}
/// [`vector_doc`] of `footage`'s page, layer and framing.
fn footage_doc(path: &str, bytes: &[u8], footage: &Footage) -> Option<std::result::Result<effectcraft_svg::Doc, String>> {
vector_doc(path, bytes, footage.layer.as_ref(), footage.page, framing(footage))
}
/// The image size and document → image mapping of `doc` drawn for `footage` at `scale`.
fn fit(doc: &effectcraft_svg::Doc, footage: &Footage, scale: f64) -> ((u32, u32), effectcraft_svg::Affine) {
let (size, [sx, sy]) = effectcraft_render::vector_fit(footage, [doc.width, doc.height], doc.pixel_size(), scale);
(size, effectcraft_svg::Affine::scale_non_uniform(sx, sy))
}
#[cfg(test)]
@@ -154,4 +172,65 @@ mod tests {
let img = decode("small.svg", small, &footage, AlphaOp::new(footage.alpha, footage.premul_color)).unwrap().unwrap();
assert_eq!((img.width, img.height), (40, 20));
}
/// A 200 × 100 pt page: red left half, blue right half.
fn halves_pdf() -> Vec<u8> {
effectcraft_pdf::write::pdf(
&[
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None),
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R >>".into(), None),
(4, "<< >>".into(), Some(b"1 0 0 rg 0 0 100 100 re f 0 0 1 rg 100 0 100 100 re f".to_vec())),
],
1,
)
}
/// An opaque red pixel.
fn is_red(p: [f32; 4]) -> bool {
p[3] > 0.99 && p[0] > 0.9 && p[2] < 0.1
}
/// An opaque blue pixel.
fn is_blue(p: [f32; 4]) -> bool {
p[3] > 0.99 && p[2] > 0.9 && p[0] < 0.1
}
/// #3099: an After Effects project keeps the size After Effects measured for a placed .ai or
/// PDF, which can differ from this reader's page size. A placed page fills that size (the
/// layer's anchor and masks are measured against it), decoded and continuously rasterised;
/// other footage is drawn at the page's own size as before, whatever size it says.
#[test]
fn placed_vector_footage_fills_the_footage_size() {
let pdf = halves_pdf();
let mut footage = probe("art.ai", &pdf).unwrap().unwrap();
assert_eq!((footage.width, footage.height), (200, 100));
assert!(!footage.placed_page);
let op = AlphaOp::new(footage.alpha, footage.premul_color);
let own = decode("art.ai", &pdf, &footage, op).unwrap().unwrap();
assert_eq!((own.width, own.height), (200, 100));
assert!(is_red(own.get(50, 50)) && is_blue(own.get(150, 50)));
(footage.width, footage.height) = (100, 80);
let unplaced = decode("art.ai", &pdf, &footage, op).unwrap().unwrap();
assert!(unplaced == own, "not placed: drawn as before");
assert_eq!(rasterize_vector("art.ai", &pdf, &footage, 0.5).map(|i| (i.width, i.height)), Some((100, 50)));
footage.placed_page = true;
let img = decode("art.ai", &pdf, &footage, op).unwrap().unwrap();
assert_eq!((img.width, img.height), (100, 80));
for (x, y) in [(25, 3), (25, 76)] {
assert!(is_red(img.get(x, y)), "red at ({x}, {y}): {:?}", img.get(x, y));
}
for (x, y) in [(75, 3), (75, 76)] {
assert!(is_blue(img.get(x, y)), "blue at ({x}, {y}): {:?}", img.get(x, y));
}
let sharp = rasterize_vector("art.ai", &pdf, &footage, 2.0).unwrap();
assert_eq!((sharp.width, sharp.height), (200, 160));
assert!(is_red(sharp.get(50, 155)) && is_blue(sharp.get(150, 155)));
// Placed at the page's own size: 1:1.
(footage.width, footage.height) = (200, 100);
assert!(decode("art.ai", &pdf, &footage, op).unwrap().unwrap() == own, "placed at its own size: 1:1");
}
}
+15 -5
View File
@@ -42,14 +42,16 @@ fn colour_bars(w: u32, h: u32) -> Image {
struct Key {
path: Arc<str>,
frame: i64,
/// The alpha interpretation (bits 0–1) and how float values are encoded (bits 2–3: sRGB,
/// as stored, a 2.4 power curve): they change the decoded pixels.
/// The alpha interpretation (bits 0–1), how float values are encoded (bits 2–3: sRGB, as
/// stored, a 2.4 power curve) and a placed PDF / Illustrator page's framing (bit 4,
/// [`Footage::placed_page`]): they change the decoded pixels.
interp: u8,
matte: [u32; 3],
/// A movie frame made at this size for a reduced-resolution render ((0, 0): as decoded).
size: (u32, u32),
/// A still at a reduced `size`: the footage's nominal size, which decides whether it is
/// resampled (see `Inner::still_at_size`); (0, 0) otherwise.
/// resampled (see `Inner::still_at_size`); also at its own size for a placed PDF /
/// Illustrator page, which is drawn to fill it ([`Footage::placed_page`]); (0, 0) otherwise.
nominal: (u32, u32),
}
@@ -756,6 +758,8 @@ impl Inner {
((d.0 as i128 * rate.num as i128 + den / 2) / den) as i64
}
/// Where the frame of `footage` at `t` comes from (file, frame or media time) and its cache
/// key, at the footage's own size.
fn locate(footage: &Footage, t: Tick, float: FloatCurve) -> Loc {
let alpha = match footage.alpha {
AlphaMode::Straight => 0,
@@ -768,7 +772,8 @@ impl Inner {
FloatCurve::Srgb => 0,
FloatCurve::Linear => 1,
FloatCurve::Gamma24 => 2,
} << 2);
} << 2)
| (u8::from(footage.placed_page) << 4);
let matte = if footage.alpha == AlphaMode::Premultiplied { footage.premul_color.map(f32::to_bits) } else { [0; 3] };
let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0), nominal: (0, 0) };
match footage.kind {
@@ -818,6 +823,11 @@ impl Inner {
loc.key.nominal = (footage.width, footage.height);
}
}
// A placed PDF / Illustrator page is drawn to fill the footage's size (`layered::decode`,
// #3099): two items of one file stored at different sizes have different pixels.
if footage.placed_page && !movie {
loc.key.nominal = (footage.width, footage.height);
}
let mut c = lock(&self.cache);
// sequential playback of a movie: read the next frame ahead
if playback && movie {
@@ -1041,7 +1051,7 @@ impl FootageSource for MediaPool {
return None;
}
let bytes = self.inner.read(&footage.path).ok()?;
crate::layered::rasterize_vector(&footage.path, &bytes, footage.layer.as_ref(), footage.page, scale).map(Arc::new)
crate::layered::rasterize_vector(&footage.path, &bytes, footage, scale).map(Arc::new)
}
fn set_aux_footage(&self, paths: &[String]) {
+7 -1
View File
@@ -29,7 +29,13 @@ ffmpeg elsewhere) to check the code tables against an independent implementation
the app's existing object model. References retain object and generation numbers. A bounded
compatibility repair recovers referenced objects from object streams when damaged xrefs omit
them; every recovery or unresolved reference is reported in `Doc::skipped` (up to 64 messages).
The existing page tree preserves inherited resources, MediaBox / CropBox and `/Rotate`.
The existing page tree preserves inherited resources, MediaBox / CropBox and `/Rotate`. A page
is its CropBox (else MediaBox), turned by `/Rotate` (`Framing::Viewer`, the default). Footage
from an After Effects project is framed as After Effects places a PDF or `.ai`
(`Framing::Placed`, `parse_page_framed`): the page's own ArtBox clipped to the CropBox (else
MediaBox) when it has one, else the CropBox, else the MediaBox, with `/Rotate` ignored
(measured in After Effects 2026 with own files, #3099). Not measured: a CropBox without an
ArtBox, files saved by Illustrator itself, `.pdf` names.
Content/image filters remain app-owned: Flate (with PNG predictors), LZW, ASCIIHex, ASCII85,
RunLength and CCITTFax. Encrypted files remain refused; `page_count` returns zero when the
reader refuses a document.
+26 -4
View File
@@ -92,8 +92,25 @@ pub fn parse(bytes: &[u8]) -> Result<Doc, Error> {
/// Parse page `index` of a PDF (EPS files have one page).
pub fn parse_page(bytes: &[u8], index: usize) -> Result<Doc, Error> {
parse_page_framed(bytes, index, Framing::Viewer)
}
/// Which region of a PDF page becomes the document, and whether `/Rotate` turns it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Framing {
/// As a PDF viewer shows the page: its CropBox (else MediaBox), turned by `/Rotate`.
#[default]
Viewer,
/// As After Effects places a PDF or `.ai` as footage: the page's ArtBox clipped to the
/// CropBox (else MediaBox) when it has one, else the CropBox, else the MediaBox, with
/// `/Rotate` ignored (measured in After Effects 2026 with own files, #3099).
Placed,
}
/// [`parse_page`] with the page framed as `framing` says (EPS files are their bounding box).
pub fn parse_page_framed(bytes: &[u8], index: usize, framing: Framing) -> Result<Doc, Error> {
match sniff(bytes) {
Some(Format::Pdf) => parse_pdf(bytes, index),
Some(Format::Pdf) => parse_pdf(bytes, index, framing),
Some(Format::Eps) => parse_eps(bytes),
None => Err(Error::NotVector),
}
@@ -142,17 +159,22 @@ fn finish(nodes: Vec<Node>, flags: Vec<bool>, skipped: Vec<String>, width: f64,
Doc { width, height, root, skipped }
}
fn parse_pdf(bytes: &[u8], index: usize) -> Result<Doc, Error> {
/// Page `index` of a PDF framed as `framing` says, as a document in points (y down).
fn parse_pdf(bytes: &[u8], index: usize, framing: Framing) -> Result<Doc, Error> {
let file = object::File::parse(bytes)?;
let pages = page::pages(&file);
if pages.is_empty() {
return Err(Error::NoPages);
}
let pg = pages.get(index).ok_or(Error::PageOutOfRange(index))?;
let [x0, y0, x1, y1] = pg.bbox;
let (frame, rotate) = match framing {
Framing::Viewer => (pg.bbox, pg.rotate),
Framing::Placed => (pg.art.unwrap_or(pg.bbox), 0),
};
let [x0, y0, x1, y1] = frame;
let (w, h) = (x1 - x0, y1 - y0);
// Default user space (y up) → document pixels (y down), with the page's /Rotate.
let (to_doc, dw, dh) = match pg.rotate {
let (to_doc, dw, dh) = match rotate {
90 => (Affine::new([0.0, 1.0, 1.0, 0.0, -y0, -x0]), h, w),
180 => (Affine::new([-1.0, 0.0, 0.0, 1.0, x1, -y0]), w, h),
270 => (Affine::new([0.0, -1.0, -1.0, 0.0, y1, x1]), h, w),
+37 -4
View File
@@ -23,6 +23,9 @@ pub struct Page {
pub resources: Dict,
/// CropBox (else MediaBox) `[x0, y0, x1, y1]`.
pub bbox: [f64; 4],
/// The page's own ArtBox clipped to [`Page::bbox`] (ISO 32000-1 §14.11.2), when it has one
/// that keeps an area there; not inherited (table 30).
pub art: Option<[f64; 4]>,
pub rotate: i32,
}
@@ -31,13 +34,15 @@ pub fn pages(file: &File) -> Vec<Page> {
let mut out = vec![];
let Some(cat) = file.catalog() else { return out };
let Some(root) = file.get_dict(cat, "Pages") else { return out };
/// Collects the pages under `node`, passing on the attributes pages inherit (resources,
/// MediaBox, CropBox, /Rotate); bounded in depth and page count.
fn walk(file: &File, node: &Dict, inherited: (Option<Dict>, Option<Vec<f64>>, Option<Vec<f64>>, i32), out: &mut Vec<Page>, depth: usize) {
if depth > 32 || out.len() > 10_000 {
return;
}
let res = file.get_dict(node, "Resources").cloned().or(inherited.0);
let media = file.get(node, "MediaBox").map(|m| file.nums(m)).filter(|v| v.len() == 4).or(inherited.1);
let crop = file.get(node, "CropBox").map(|m| file.nums(m)).filter(|v| v.len() == 4).or(inherited.2);
let media = file.get(node, "MediaBox").map(|m| file.nums(m)).filter(|v| is_area(v)).or(inherited.1);
let crop = file.get(node, "CropBox").map(|m| file.nums(m)).filter(|v| is_area(v)).or(inherited.2);
let rot = file.get_num(node, "Rotate").map(|r| r as i32).unwrap_or(inherited.3);
match file.get(node, "Kids").and_then(Obj::array) {
Some(kids) if file.get(node, "Type").and_then(Obj::name) != Some("Page") => {
@@ -49,8 +54,16 @@ pub fn pages(file: &File) -> Vec<Page> {
}
_ => {
let b = crop.or(media).unwrap_or_else(|| vec![0.0, 0.0, 612.0, 792.0]);
let bbox = [b[0].min(b[2]), b[1].min(b[3]), b[0].max(b[2]), b[1].max(b[3])];
out.push(Page { dict: node.clone(), resources: res.unwrap_or_default(), bbox, rotate: rot.rem_euclid(360) });
let bbox = normalized(&b).unwrap_or([0.0, 0.0, 612.0, 792.0]);
// The ArtBox is the page's own (not inherited), clipped to the crop box.
let art = file
.get(node, "ArtBox")
.map(|m| file.nums(m))
.filter(|v| is_area(v))
.and_then(|v| normalized(&v))
.map(|a| [a[0].max(bbox[0]), a[1].max(bbox[1]), a[2].min(bbox[2]), a[3].min(bbox[3])])
.filter(|a| a[2] > a[0] && a[3] > a[1]);
out.push(Page { dict: node.clone(), resources: res.unwrap_or_default(), bbox, art, rotate: rot.rem_euclid(360) });
}
}
}
@@ -58,6 +71,26 @@ pub fn pages(file: &File) -> Vec<Page> {
out
}
/// A box `[x0, y0, x1, y1]` (corners in any order) with finite corners and a finite span that
/// encloses an area.
fn is_area(v: &[f64]) -> bool {
match v {
[x0, y0, x1, y1] => {
let (w, h) = (x1 - x0, y1 - y0);
v.iter().all(|c| c.is_finite()) && w.is_finite() && h.is_finite() && w.abs() > 0.0 && h.abs() > 0.0
}
_ => false,
}
}
/// A box with its corners ordered (`x0 < x1`, `y0 < y1`), `None` unless [`is_area`].
fn normalized(v: &[f64]) -> Option<[f64; 4]> {
match v {
[x0, y0, x1, y1] if is_area(v) => Some([x0.min(*x1), y0.min(*y1), x0.max(*x1), y0.max(*y1)]),
_ => None,
}
}
/// The page's content streams, decoded and joined.
pub fn page_content(file: &File, page: &Page) -> Vec<u8> {
let mut out = vec![];
+80
View File
@@ -112,6 +112,86 @@ fn pdf_forms_rotation_cmyk_and_alpha() {
assert_eq!(img.get(30, 30)[3], 0.0, "form clipped to its bounding box");
}
/// A one-page PDF with a MediaBox [0 0 420 220], `pages` added to the /Pages node and `page` to
/// the page: grey over the media box, a blue 20 × 10 marker at PDF (64, 54).
fn boxes_pdf(pages: &str, page: &str) -> Vec<u8> {
let content = "0.5 g 0 0 420 220 re f 0 0 1 rg 64 54 20 10 re f";
pdf(
&[
(1, "<< /Type /Catalog /Pages 2 0 R >>".to_string(), None),
(2, format!("<< /Type /Pages /Kids [3 0 R] /Count 1 {pages} >>"), None),
(3, format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 420 220] {page} /Contents 4 0 R >>"), None),
(4, "<< >>".to_string(), Some(content.as_bytes().to_vec())),
],
1,
)
}
/// The first page of `bytes` framed as After Effects places it.
fn placed(bytes: &[u8]) -> Doc {
parse_page_framed(bytes, 0, Framing::Placed).unwrap()
}
/// #3099: After Effects 2026 places a .ai or PDF as footage by the page's ArtBox and shows only
/// that region (a page with MediaBox 420 × 220, CropBox 410 × 210, TrimBox 380 × 180 and ArtBox
/// [60 50 300.6 170.4] imports as 241 × 121). `Framing::Placed` frames it so; the viewer framing
/// (the default, used by File ▸ Import) stays the CropBox.
#[test]
fn placed_framing_is_the_art_box() {
let bytes = boxes_pdf("/CropBox [5 5 415 215]", "/TrimBox [20 20 400 200] /ArtBox [60 50 300.6 170.4]");
let doc = placed(&bytes);
assert!((doc.width - 240.6).abs() < 1e-9 && (doc.height - 120.4).abs() < 1e-9, "{} × {}", doc.width, doc.height);
assert_eq!(doc.pixel_size(), (241, 121));
// The art box's top-left corner is the document's origin: the marker (PDF x 64..84, y 54..64)
// is at x 4..24, y 106.4..116.4 from the top.
let (w, h) = doc.pixel_size();
let img = effectcraft_svg::rasterize(&doc, w, h, 1.0);
let marker = img.get(10, 110);
assert!(marker[2] > 0.99 && marker[0] < 0.01, "{marker:?}");
assert!(img.get(30, 110)[2] < 0.6, "{:?}", img.get(30, 110));
let viewer = parse(&bytes).unwrap();
assert_eq!((viewer.width, viewer.height), (410.0, 210.0), "the viewer framing is unchanged");
}
/// The ArtBox is clipped to the crop box (ISO 32000-1 §14.11.2); one that keeps no area there,
/// a degenerate or unreadable one, or one on the /Pages node (not inherited, table 30) leaves the
/// crop box (else the media box).
#[test]
fn placed_framing_clips_the_art_box_and_falls_back() {
let size = |pages: &str, page: &str| {
let d = placed(&boxes_pdf(pages, page));
(d.width, d.height)
};
let crop = "/CropBox [5 5 415 215]";
assert_eq!(size(crop, "/ArtBox [-100 -100 1000 1000]"), (410.0, 210.0), "larger than the page");
assert_eq!(size(crop, "/ArtBox [300 100 900 900]"), (115.0, 115.0), "partly outside");
let (w, h) = size(crop, "/ArtBox [300.6 170.4 60 50]");
assert!((w - 240.6).abs() < 1e-9 && (h - 120.4).abs() < 1e-9, "corners in any order: {w} × {h}");
// Finite corners whose span is not.
let big = format!("1{}", "0".repeat(308));
let unreadable = [format!("/ArtBox [-{big} 50 {big} 170]")];
for page in ["/ArtBox [500 300 600 400]", "/ArtBox [60 50 60 170]", "/ArtBox [60 50 300]"].map(String::from).iter().chain(&unreadable) {
assert_eq!(size(crop, page), (410.0, 210.0), "{page}");
}
assert_eq!(size("/ArtBox [60 50 300 170]", ""), (420.0, 220.0), "an ArtBox on /Pages is not inherited");
// A crop box that is not a box leaves the media box.
assert_eq!(size("", &format!("/CropBox [-{big} 5 {big} 215]")), (420.0, 220.0));
}
/// After Effects ignores /Rotate on placed footage (a page [100 50 400 150] with /Rotate 90
/// imports as 300 × 100, drawn unturned); the viewer framing turns it.
#[test]
fn placed_framing_ignores_rotate() {
let bytes = boxes_pdf("", "/Rotate 90");
let doc = placed(&bytes);
assert_eq!((doc.width, doc.height), (420.0, 220.0));
// Unturned: the marker at PDF (64..84, 54..64) is at x 64..84, y 156..166 from the top.
let img = effectcraft_svg::rasterize(&doc, 420, 220, 1.0);
assert!(img.get(70, 160)[2] > 0.99, "{:?}", img.get(70, 160));
let viewer = parse(&bytes).unwrap();
assert_eq!((viewer.width, viewer.height), (220.0, 420.0));
}
const EPS: &str = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n\
/m {moveto} bind def /l {lineto} bind def\n\
/cm { 6 array astore concat } bind def\n\
+8
View File
@@ -269,6 +269,13 @@ pub struct Footage {
/// made from the footage starts with them, and Update Markers From Source copies new ones.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub markers: Vec<Marker>,
/// PDF / Illustrator footage (`.pdf`, `.ai`; not EPS) from an After Effects project: the page
/// is framed as After Effects places it (the ArtBox, `/Rotate` ignored) and drawn to fill
/// `width` × `height`, the size After Effects stored, which the layers' anchors and masks are
/// measured against (#3099). Other footage (and every project saved before this field) shows
/// the page as a PDF viewer does, at its own size.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub placed_page: bool,
}
fn is_default<T: Default + PartialEq>(v: &T) -> bool {
@@ -307,6 +314,7 @@ impl Default for Footage {
start_timecode: None,
file_timecode: None,
markers: vec![],
placed_page: false,
}
}
}
+27 -4
View File
@@ -76,8 +76,9 @@ pub trait FootageSource: Send + Sync {
fn cache_budget(&self) -> Option<usize> {
None
}
/// Vector footage (SVG) rasterised at `scale` × its pixel size, for Continuously Rasterize.
/// `None` when the footage is not vector or cannot be read.
/// Vector footage (SVG, PDF, Illustrator, EPS: [`is_vector_footage`]) rasterised at `scale` ×
/// its size as [`vector_fit`] draws it, for Continuously Rasterize. `None` when the footage
/// is not vector or cannot be read.
fn vector_frame(&self, _item: ItemId, _footage: &Footage, _scale: f64) -> Option<Arc<Image>> {
None
}
@@ -216,9 +217,31 @@ impl FootageSource for CancellableFootage<'_> {
}
}
/// Footage that can be rasterised at any scale (SVG).
/// Footage that can be rasterised at any scale: SVG, PDF, Illustrator and EPS files, and a
/// placed page of an After Effects project (its codec is not stored there).
pub fn is_vector_footage(f: &Footage) -> bool {
matches!(f.codec.as_str(), "SVG" | "PDF" | "AI" | "EPS")
matches!(f.codec.as_str(), "SVG" | "PDF" | "AI" | "EPS") || f.placed_page
}
/// How vector footage `f` is drawn at `scale` × its size: the image size, and the x and y scale
/// from document pixels (`doc` = the document's width and height, `own` = its pixel size, the
/// size rounded up) to image pixels.
///
/// Footage from an After Effects project ([`Footage::placed_page`]) fills its stored size, which
/// can round or measure the page differently: the layer's anchor and masks are measured against
/// that size, and the renderer resamples a reduced-resolution frame to it, so drawn at its own
/// size the art was scaled by the width ratio alone at full resolution (off against the masks)
/// and stretched to the stored aspect below 3/4 resolution (#3099). Other footage is drawn 1:1 at
/// the document's own size, as it always was.
pub fn vector_fit(f: &Footage, doc: [f64; 2], own: (u32, u32), scale: f64) -> ((u32, u32), [f64; 2]) {
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
let side = |px: u32| (f64::from(px) * scale).ceil().clamp(1.0, f64::from(u32::MAX)) as u32;
let size = (f.width, f.height);
let fills = f.placed_page && size != own && size.0 > 0 && size.1 > 0 && doc.iter().all(|d| d.is_finite() && *d > 0.0);
if !fills {
return ((side(own.0), side(own.1)), [scale, scale]);
}
((side(size.0), side(size.1)), [scale * f64::from(size.0) / doc[0], scale * f64::from(size.1) / doc[1]])
}
/// No footage available (renders footage layers as transparent).
+1 -1
View File
@@ -47,7 +47,7 @@ mod opts;
mod pipeline;
pub use accel::{Accelerator, Backend, FxStep, FxTarget};
pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage, missing_item_problem};
pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage, missing_item_problem, vector_fit};
pub use frames::{
adaptive_samples, blend_frames, cineon_frame, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path,
render_frame, roi_inside,
+9 -1
View File
@@ -135,7 +135,15 @@ Units, the "absent = default" rule and the keyframe templates are the reader's
text does not have; the runs are cut to the text. A range style splits `Hello` into runs of 1,
2 and 2 characters (248, 340).
- **Null objects.** The hidden placeholder solid of a null object is not an item (null layers
have no source item here).
have no source item here). A null's Anchor Point that is not stored is its top-left corner
`[0, 0]`, not the centre, as After Effects 2026 reads it back (#3099).
- **Placed Illustrator and PDF files** (`.ai`, `.pdf`; `Footage::placed_page`): the page is
framed as After Effects places it (the ArtBox clipped to the crop box, `/Rotate` ignored) and
drawn to fill the stored footage size, which masks and anchors are measured against (#3099).
Create Shapes from Vector Layer on such a layer adds a hidden `<layer> Frame` shape layer, the
Alpha track matte of every layer it makes, so they show only the frame, as the footage does.
EPS files keep their bounding box at their own size: how After Effects sizes them is not
measured.
- **Track mattes.** The mode and the matte layer's id are in the target layer's header
(aep-format.md 3.1). The matte layer can be any layer of the composition (407: below its
target); it keeps its stored video switch (After Effects switches it off when the matte is set,
+6
View File
@@ -266,6 +266,12 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie
The second means reading another implementation of the format, which AGENTS.md rules out
unless the clean-room scope allows it.
- Also in this area: relative footage paths and relinking when a project moves (gap 18).
- Placed PDF / Illustrator footage of an `.aep` is framed as After Effects places it (the ArtBox
clipped to the crop box, `/Rotate` ignored) and drawn to fill the size After Effects stored
(#3099), measured with hand-written PDFs named `.ai`. Not yet measured: files saved by
Illustrator itself (artboards, bleed, art past the artboard, private data), a CropBox without
an ArtBox, `.pdf` names, and EPS files (they keep their bounding box at their own size, not
the placed framing).
- Done when: the decision is recorded and, if approved, a corpus of real-world-shaped projects
opens with its comps, layers, keyframes, effects and expressions intact.