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