From 7c62272305312b88c25c59a2f24f08d676ee97b5 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 9 Nov 2026 04:52:32 +0100 Subject: [PATCH] .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 " 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. --- ATTRIBUTION.md | 2 +- crates/aep/README.md | 8 +- crates/aep/src/export/plan.rs | 13 +- crates/aep/src/import/build.rs | 29 +- crates/aep/src/import/props.rs | 14 +- crates/aep/tests/fixtures/ai-mask-3099.aep | Bin 0 -> 134446 bytes .../fixtures/ai-mask-3099.aep.attribution | 7 + crates/aep/tests/fixtures/tools/README.md | 1 + .../aep/tests/fixtures/tools/ai-mask-3099.jsx | 94 +++++ .../aep/tests/fixtures/tools/ai-mask-3099.py | 66 ++++ crates/aep/tests/it/export/built.rs | 39 ++ crates/aep/tests/it/import.rs | 1 + crates/aep/tests/it/import/ai_mask.rs | 113 ++++++ crates/engine/src/cmd/create.rs | 29 +- crates/engine/src/footage/footage_reload.rs | 8 +- crates/engine/src/interchange/vector.rs | 64 +++- crates/host/tests/it/vector_import.rs | 356 +++++++++++++++++- crates/media/src/layered.rs | 101 ++++- crates/media/src/pool.rs | 20 +- crates/pdf/README.md | 8 +- crates/pdf/src/lib.rs | 30 +- crates/pdf/src/page.rs | 41 +- crates/pdf/src/tests.rs | 80 ++++ crates/project/src/footage.rs | 8 + crates/render/src/footage.rs | 31 +- crates/render/src/lib.rs | 2 +- docs/aep-import.md | 10 +- docs/gaps.md | 6 + 28 files changed, 1132 insertions(+), 49 deletions(-) create mode 100644 crates/aep/tests/fixtures/ai-mask-3099.aep create mode 100644 crates/aep/tests/fixtures/ai-mask-3099.aep.attribution create mode 100644 crates/aep/tests/fixtures/tools/ai-mask-3099.jsx create mode 100644 crates/aep/tests/fixtures/tools/ai-mask-3099.py create mode 100644 crates/aep/tests/it/import/ai_mask.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 93b34b9bf..4bf3a044d 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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 diff --git a/crates/aep/README.md b/crates/aep/README.md index 965bea317..5e8035d8f 100644 --- a/crates/aep/README.md +++ b/crates/aep/README.md @@ -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 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 diff --git a/crates/aep/src/export/plan.rs b/crates/aep/src/export/plan.rs index f8aa2b3b8..a6789eda9 100644 --- a/crates/aep/src/export/plan.rs +++ b/crates/aep/src/export/plan.rs @@ -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 { - 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 = 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 }); diff --git a/crates/aep/src/import/build.rs b/crates/aep/src/import/build.rs index 5a55c557a..7d5b902f1 100644 --- a/crates/aep/src/import/build.rs +++ b/crates/aep/src/import/build.rs @@ -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 { @@ -453,11 +464,13 @@ fn camera_light_orient(auto_oriented: bool, light: Option) -> 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); diff --git a/crates/aep/src/import/props.rs b/crates/aep/src/import/props.rs index 838a2ee44..fd0a4f9fc 100644 --- a/crates/aep/src/import/props.rs +++ b/crates/aep/src/import/props.rs @@ -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; diff --git a/crates/aep/tests/fixtures/ai-mask-3099.aep b/crates/aep/tests/fixtures/ai-mask-3099.aep new file mode 100644 index 0000000000000000000000000000000000000000..7223fda82f8a520db8d01325cd5bde56de1ae26e GIT binary patch literal 134446 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 literal 0 HcmV?d00001 diff --git a/crates/aep/tests/fixtures/ai-mask-3099.aep.attribution b/crates/aep/tests/fixtures/ai-mask-3099.aep.attribution new file mode 100644 index 000000000..86efa3b65 --- /dev/null +++ b/crates/aep/tests/fixtures/ai-mask-3099.aep.attribution @@ -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\.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. diff --git a/crates/aep/tests/fixtures/tools/README.md b/crates/aep/tests/fixtures/tools/README.md index eee0174ee..f1c720b2e 100644 --- a/crates/aep/tests/fixtures/tools/README.md +++ b/crates/aep/tests/fixtures/tools/README.md @@ -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) diff --git a/crates/aep/tests/fixtures/tools/ai-mask-3099.jsx b/crates/aep/tests/fixtures/tools/ai-mask-3099.jsx new file mode 100644 index 000000000..bcac03d0b --- /dev/null +++ b/crates/aep/tests/fixtures/tools/ai-mask-3099.jsx @@ -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 ` 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 /i3099-ai-mask.aep, which is then scrubbed (README: steps 5 and 6). +(function () { + var dir = "/"; + 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; +})(); diff --git a/crates/aep/tests/fixtures/tools/ai-mask-3099.py b/crates/aep/tests/fixtures/tools/ai-mask-3099.py new file mode 100644 index 000000000..6b5b7facf --- /dev/null +++ b/crates/aep/tests/fixtures/tools/ai-mask-3099.py @@ -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)) diff --git a/crates/aep/tests/it/export/built.rs b/crates/aep/tests/it/export/built.rs index 14f9d8795..0da683cc4 100644 --- a/crates/aep/tests/it/export/built.rs +++ b/crates/aep/tests/it/export/built.rs @@ -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(); diff --git a/crates/aep/tests/it/import.rs b/crates/aep/tests/it/import.rs index eb6963d6f..2535edcff 100644 --- a/crates/aep/tests/it/import.rs +++ b/crates/aep/tests/it/import.rs @@ -4,6 +4,7 @@ mod helpers; mod aepx; +mod ai_mask; mod blend_modes; mod cameras; mod cases; diff --git a/crates/aep/tests/it/import/ai_mask.rs b/crates/aep/tests/it/import/ai_mask.rs new file mode 100644 index 000000000..e5f57abe0 --- /dev/null +++ b/crates/aep/tests/it/import/ai_mask.rs @@ -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); + } + } + } +} diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 6c4b02c87..250ca836d 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -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 { 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 { 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 { // 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 { 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)?; diff --git a/crates/engine/src/footage/footage_reload.rs b/crates/engine/src/footage/footage_reload.rs index e56e16a96..5a0576f10 100644 --- a/crates/engine/src/footage/footage_reload.rs +++ b/crates/engine/src/footage/footage_reload.rs @@ -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 => {} diff --git a/crates/engine/src/interchange/vector.rs b/crates/engine/src/interchange/vector.rs index 324d3a82a..b52517cb3 100644 --- a/crates/engine/src/interchange/vector.rs +++ b/crates/engine/src/interchange/vector.rs @@ -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 { + if !f.placed_page || f.width == 0 || f.height == 0 { + return None; + } + let svg = format!( + r##""##, + 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> { + 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> { +/// `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> { 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, diff --git a/crates/host/tests/it/vector_import.rs b/crates/host/tests/it/vector_import.rs index badd60b66..eff5b4b4b 100644 --- a/crates/host/tests/it/vector_import.rs +++ b/crates/host/tests/it/vector_import.rs @@ -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 { + image_pdf_with("") +} + +/// [`image_pdf`] with `boxes` added to the page dictionary. +fn image_pdf_with(boxes: &str) -> Vec { 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 { &[ (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 { ) } +/// A 200 × 100 pt page: red left half, blue right half. +fn halves_pdf() -> Vec { + 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 { + 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 { + 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 { + 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}; diff --git a/crates/media/src/layered.rs b/crates/media/src/layered.rs index b259c8b7f..64ade9394 100644 --- a/crates/media/src/layered.rs +++ b/crates/media/src/layered.rs @@ -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> { 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 { - 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 { + 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> { + 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 { + 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"); + } } diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index a60048b66..cd6676220 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -42,14 +42,16 @@ fn colour_bars(w: u32, h: u32) -> Image { struct Key { path: Arc, 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]) { diff --git a/crates/pdf/README.md b/crates/pdf/README.md index 20f655611..eb960cc6e 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -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. diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 567dca9c0..ff914e759 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -92,8 +92,25 @@ pub fn parse(bytes: &[u8]) -> Result { /// Parse page `index` of a PDF (EPS files have one page). pub fn parse_page(bytes: &[u8], index: usize) -> Result { + 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 { 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, flags: Vec, skipped: Vec, width: f64, Doc { width, height, root, skipped } } -fn parse_pdf(bytes: &[u8], index: usize) -> Result { +/// 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 { 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), diff --git a/crates/pdf/src/page.rs b/crates/pdf/src/page.rs index b7eb70fe0..0ebc0421d 100644 --- a/crates/pdf/src/page.rs +++ b/crates/pdf/src/page.rs @@ -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 { 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, Option>, Option>, i32), out: &mut Vec, 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 { } _ => { 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 { 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 { let mut out = vec![]; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 2d09a7406..cd3eecdb2 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -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 { + 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\ diff --git a/crates/project/src/footage.rs b/crates/project/src/footage.rs index 2cf887b1f..eeace5e81 100644 --- a/crates/project/src/footage.rs +++ b/crates/project/src/footage.rs @@ -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, + /// 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(v: &T) -> bool { @@ -307,6 +314,7 @@ impl Default for Footage { start_timecode: None, file_timecode: None, markers: vec![], + placed_page: false, } } } diff --git a/crates/render/src/footage.rs b/crates/render/src/footage.rs index 0e17f152d..6b7fdcbf8 100644 --- a/crates/render/src/footage.rs +++ b/crates/render/src/footage.rs @@ -76,8 +76,9 @@ pub trait FootageSource: Send + Sync { fn cache_budget(&self) -> Option { 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> { 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). diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e09794de2..65f3ee772 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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, diff --git a/docs/aep-import.md b/docs/aep-import.md index 8a83de81f..f41ba3081 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -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 ` 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, diff --git a/docs/gaps.md b/docs/gaps.md index d481f9810..d9de84675 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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.