mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-10 20:41:25 +00:00
* #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.
1349 lines
58 KiB
Rust
1349 lines
58 KiB
Rust
//! The plan: the EffectCraft project reduced to exactly the values the `.aep` can hold, in the
|
|
//! file's units, with everything that cannot be written already reported. Pure data; the chunks
|
|
//! are built from it in `emit`.
|
|
|
|
use std::collections::{BTreeMap, HashSet};
|
|
|
|
use effectcraft_color::{BlendMode, Label};
|
|
use effectcraft_keyframe::{Ease, Interp, Keyframe};
|
|
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerId, LayerSource, Project, Property, Quality, Renderer};
|
|
use effectcraft_time::{FrameRate, Tick};
|
|
|
|
use crate::export::report::{Collector, LossCategory};
|
|
use crate::export::timebase::{TimeBase, time_base};
|
|
|
|
/// No sensible coordinate, angle or percentage comes near this; larger numbers (and NaN
|
|
/// or infinity) are written as the default.
|
|
pub(crate) const LIMIT: f64 = 1e9;
|
|
/// The largest composition or solid side After Effects accepts.
|
|
pub(crate) const MAX_SIDE: u32 = 30_000;
|
|
/// The longest composition After Effects accepts (3 hours).
|
|
const MAX_SECONDS: i64 = 3 * 3600;
|
|
/// Deepest folder nesting that is exported.
|
|
const MAX_FOLDER_DEPTH: usize = 64;
|
|
|
|
/// The five Transform properties that are exported, in the file's canonical order.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub(crate) enum PropKind {
|
|
Anchor,
|
|
Position,
|
|
Scale,
|
|
Rotation,
|
|
Opacity,
|
|
}
|
|
|
|
impl PropKind {
|
|
pub const ALL: [PropKind; 5] = [PropKind::Anchor, PropKind::Position, PropKind::Scale, PropKind::Rotation, PropKind::Opacity];
|
|
|
|
/// The match name in the file.
|
|
pub fn match_name(self) -> &'static str {
|
|
match self {
|
|
PropKind::Anchor => "ADBE Anchor Point",
|
|
PropKind::Position => "ADBE Position",
|
|
PropKind::Scale => "ADBE Scale",
|
|
PropKind::Rotation => "ADBE Rotate Z",
|
|
PropKind::Opacity => "ADBE Opacity",
|
|
}
|
|
}
|
|
|
|
/// The property id in the project.
|
|
pub fn id(self) -> &'static str {
|
|
match self {
|
|
PropKind::Anchor => "anchor",
|
|
PropKind::Position => "position",
|
|
PropKind::Scale => "scale",
|
|
PropKind::Rotation => "rotation",
|
|
PropKind::Opacity => "opacity",
|
|
}
|
|
}
|
|
|
|
/// Components stored in a value.
|
|
pub fn dims(self) -> usize {
|
|
match self {
|
|
PropKind::Anchor | PropKind::Position | PropKind::Scale => 3,
|
|
PropKind::Rotation | PropKind::Opacity => 1,
|
|
}
|
|
}
|
|
|
|
/// Anchor and Position have motion paths (the spatial key template).
|
|
pub fn spatial(self) -> bool {
|
|
matches!(self, PropKind::Anchor | PropKind::Position)
|
|
}
|
|
|
|
/// The factor from the project's unit to the stored one (percent to fraction).
|
|
fn unit(self) -> f64 {
|
|
match self {
|
|
PropKind::Scale | PropKind::Opacity => 100.0,
|
|
_ => 1.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One side of a key's temporal ease: speed in stored units per second, influence 0..1.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub(crate) struct EaseSlot {
|
|
pub speed: f64,
|
|
pub influence: f64,
|
|
}
|
|
|
|
impl Default for EaseSlot {
|
|
fn default() -> Self {
|
|
EaseSlot { speed: 0.0, influence: 0.0 }
|
|
}
|
|
}
|
|
|
|
/// A keyframe in the file's units.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(crate) struct KeyPlan {
|
|
/// In the composition's time units (layer time).
|
|
pub time: i32,
|
|
pub value: Vec<f64>,
|
|
/// 1 linear, 2 bezier, 3 hold.
|
|
pub in_interp: u8,
|
|
pub out_interp: u8,
|
|
/// One slot per component (plain template) or one slot (spatial template).
|
|
pub in_ease: Vec<EaseSlot>,
|
|
pub out_ease: Vec<EaseSlot>,
|
|
pub spatial_in: [f64; 3],
|
|
pub spatial_out: [f64; 3],
|
|
pub spatial_auto: bool,
|
|
pub roving: bool,
|
|
pub label: u8,
|
|
}
|
|
|
|
/// A property that differs from its default: a static value, keys, or an expression.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(crate) struct PropPlan {
|
|
pub kind: PropKind,
|
|
pub value: Vec<f64>,
|
|
pub keys: Vec<KeyPlan>,
|
|
pub expr: Option<(String, bool)>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(crate) enum LayerSrc {
|
|
Solid(ItemId),
|
|
Null,
|
|
/// A precomposition layer: the composition item it shows.
|
|
Comp(ItemId),
|
|
/// A footage layer: the footage item it shows.
|
|
Footage(ItemId),
|
|
/// A text layer and its text document.
|
|
Text(Box<crate::export::text::TextPlan>),
|
|
/// A camera (256) and its options.
|
|
Camera(Box<crate::export::camlight::CameraPlan>),
|
|
/// A spot or point light (254, 252) and its options.
|
|
Light(Box<crate::export::camlight::LightPlan>),
|
|
/// A shape layer and its items (519).
|
|
Shape(Vec<crate::export::shapes::ShItem>),
|
|
}
|
|
|
|
/// A layer in the file's units.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(crate) struct LayerPlan {
|
|
pub name: String,
|
|
pub src: LayerSrc,
|
|
pub adjustment: bool,
|
|
pub label: u8,
|
|
/// 1 draft, 2 best.
|
|
pub quality: u8,
|
|
pub start: i32,
|
|
pub in_rel: i32,
|
|
pub out_rel: i32,
|
|
pub blend: u8,
|
|
pub parent: Option<LayerId>,
|
|
/// Track matte: the matte layer and the mode (`ldta@107`: 1 alpha, 2 alpha inverted, 3 luma,
|
|
/// 4 luma inverted; 403-406).
|
|
pub matte: Option<(LayerId, u8)>,
|
|
pub markers: Vec<crate::export::markers::MarkerPlan>,
|
|
pub ec_id: LayerId,
|
|
pub video: bool,
|
|
pub audio: bool,
|
|
pub solo: bool,
|
|
pub locked: bool,
|
|
pub shy: bool,
|
|
pub collapse: bool,
|
|
pub effects: bool,
|
|
pub motion_blur: bool,
|
|
pub guide: bool,
|
|
pub three_d: bool,
|
|
/// Time stretch as `numerator / denominator` (1 / 1 = 100 %, -1 / 1 = -100 %).
|
|
pub stretch: (i32, u32),
|
|
/// The time scale the layer's properties count in.
|
|
pub time_scale: u32,
|
|
pub props: Vec<PropPlan>,
|
|
/// The layer's masks, in stack order.
|
|
pub masks: Vec<crate::export::masks::MaskPlan>,
|
|
/// The layer's effects, in stack order.
|
|
pub fx: Vec<crate::export::effects::EffectPlan>,
|
|
/// Size of the layer's source in pixels (the Anchor Point divisor).
|
|
pub source_size: (u32, u32),
|
|
/// An enabled Time Remap (source seconds; keys in layer time), written into the layer's
|
|
/// `ADBE Time Remapping` record (319, 354).
|
|
pub remap: Option<PropPlan>,
|
|
/// The layer styles (None: none applied).
|
|
pub styles: Option<crate::export::styles::StylesPlan>,
|
|
}
|
|
|
|
/// `cp` without its layers.
|
|
fn comp_header(cp: &CompPlan) -> CompPlan {
|
|
CompPlan {
|
|
width: cp.width,
|
|
height: cp.height,
|
|
tb: cp.tb,
|
|
rate: cp.rate,
|
|
duration_units: cp.duration_units,
|
|
work_start: cp.work_start,
|
|
work_end: cp.work_end,
|
|
background: cp.background,
|
|
pixel_aspect: cp.pixel_aspect,
|
|
motion_blur: cp.motion_blur,
|
|
shutter_angle: cp.shutter_angle,
|
|
shutter_phase: cp.shutter_phase,
|
|
display_start: cp.display_start,
|
|
markers: Vec::new(),
|
|
layers: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// A composition in the file's units.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(crate) struct CompPlan {
|
|
pub width: u16,
|
|
pub height: u16,
|
|
pub tb: TimeBase,
|
|
pub rate: FrameRate,
|
|
pub duration_units: u32,
|
|
pub work_start: u32,
|
|
/// `None` = the whole composition (`0xffffffff`).
|
|
pub work_end: Option<u32>,
|
|
pub background: [u8; 3],
|
|
pub pixel_aspect: (u32, u32),
|
|
pub motion_blur: bool,
|
|
pub shutter_angle: u32,
|
|
pub shutter_phase: i32,
|
|
pub display_start: u32,
|
|
/// Composition markers (written into the `Markers` view layer).
|
|
pub markers: Vec<crate::export::markers::MarkerPlan>,
|
|
pub layers: Vec<LayerPlan>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(crate) struct SolidPlan {
|
|
pub color: [f32; 3],
|
|
pub width: u32,
|
|
pub height: u32,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(crate) enum ItemPlan {
|
|
Folder(Vec<Entry>),
|
|
Comp(Box<CompPlan>),
|
|
Solid(SolidPlan),
|
|
Footage(Box<crate::export::footage::FootagePlan>),
|
|
}
|
|
|
|
/// A project item with its name and label.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(crate) struct Entry {
|
|
pub ec: ItemId,
|
|
pub name: String,
|
|
pub label: u8,
|
|
pub plan: ItemPlan,
|
|
}
|
|
|
|
/// The whole project: the items at the root.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(crate) struct ProjectPlan {
|
|
pub root: Vec<Entry>,
|
|
pub settings: crate::export::settings::SettingsPlan,
|
|
}
|
|
|
|
/// `name` cut to `max` bytes at a character boundary (after Effects stores a name of up to 255
|
|
/// characters; a solid's name lives in a 256 byte field).
|
|
pub(crate) fn cut_name(name: &str, max: usize, what: &str, col: &mut Collector) -> String {
|
|
if name.len() <= max {
|
|
return name.to_string();
|
|
}
|
|
let mut end = max;
|
|
while end > 0 && !name.is_char_boundary(end) {
|
|
end -= 1;
|
|
}
|
|
col.lose(LossCategory::Names, format!("{what}: name of {} bytes cut to {end}", name.len()));
|
|
name.get(..end).unwrap_or_default().to_string()
|
|
}
|
|
|
|
/// A number that can be written, or `None`.
|
|
fn sane(v: f64) -> Option<f64> {
|
|
(v.is_finite() && v.abs() <= LIMIT).then_some(v)
|
|
}
|
|
|
|
pub(crate) fn label_index(l: Label) -> u8 {
|
|
Label::ALL.iter().position(|x| *x == l).and_then(|i| u8::try_from(i).ok()).unwrap_or(0)
|
|
}
|
|
|
|
fn blend_code(mode: BlendMode) -> Option<u8> {
|
|
Some(match mode {
|
|
BlendMode::Normal => 2,
|
|
BlendMode::Add => 4,
|
|
BlendMode::Multiply => 5,
|
|
BlendMode::Screen => 6,
|
|
BlendMode::Overlay => 7,
|
|
BlendMode::Difference => 26,
|
|
// The codes After Effects writes for the other modes (906, 907; the importer's table).
|
|
BlendMode::SoftLight => 8,
|
|
BlendMode::HardLight => 9,
|
|
BlendMode::Darken => 10,
|
|
BlendMode::Lighten => 11,
|
|
BlendMode::ClassicDifference => 12,
|
|
BlendMode::Hue => 13,
|
|
BlendMode::Saturation => 14,
|
|
BlendMode::Color => 15,
|
|
BlendMode::Luminosity => 16,
|
|
BlendMode::StencilAlpha => 17,
|
|
BlendMode::StencilLuma => 18,
|
|
BlendMode::SilhouetteAlpha => 19,
|
|
BlendMode::SilhouetteLuma => 20,
|
|
BlendMode::ClassicColorDodge => 23,
|
|
BlendMode::ClassicColorBurn => 24,
|
|
BlendMode::Exclusion => 25,
|
|
BlendMode::ColorDodge => 27,
|
|
BlendMode::ColorBurn => 28,
|
|
BlendMode::LinearDodge => 29,
|
|
BlendMode::LinearBurn => 30,
|
|
BlendMode::LinearLight => 31,
|
|
BlendMode::VividLight => 32,
|
|
BlendMode::PinLight => 33,
|
|
BlendMode::HardMix => 34,
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
/// Natural, case-insensitive order of names (digit runs compare as numbers), as After Effects
|
|
/// sorts the project panel and stores the items.
|
|
pub(crate) fn natural_key(name: &str) -> Vec<(bool, String, u128)> {
|
|
let mut out: Vec<(bool, String, u128)> = Vec::new();
|
|
let mut chars = name.chars().peekable();
|
|
while let Some(&c) = chars.peek() {
|
|
if c.is_ascii_digit() {
|
|
let mut n: u128 = 0;
|
|
let mut s = String::new();
|
|
while let Some(&d) = chars.peek() {
|
|
if !d.is_ascii_digit() {
|
|
break;
|
|
}
|
|
n = n.saturating_mul(10).saturating_add(u128::from(d as u8 - b'0'));
|
|
s.push(d);
|
|
chars.next();
|
|
}
|
|
out.push((true, s, n));
|
|
} else {
|
|
let mut s = String::new();
|
|
while let Some(&d) = chars.peek() {
|
|
if d.is_ascii_digit() {
|
|
break;
|
|
}
|
|
s.extend(d.to_lowercase());
|
|
chars.next();
|
|
}
|
|
out.push((false, s, 0));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn sort_entries(v: &mut [Entry]) {
|
|
v.sort_by(|a, b| {
|
|
let ka = natural_key(&a.name);
|
|
let kb = natural_key(&b.name);
|
|
let cmp = |x: &(bool, String, u128), y: &(bool, String, u128)| match (x.0, y.0) {
|
|
(true, true) => x.2.cmp(&y.2).then_with(|| x.1.cmp(&y.1)),
|
|
(false, false) => x.1.cmp(&y.1),
|
|
(true, false) => std::cmp::Ordering::Less,
|
|
(false, true) => std::cmp::Ordering::Greater,
|
|
};
|
|
for (x, y) in ka.iter().zip(kb.iter()) {
|
|
let c = cmp(x, y);
|
|
if c != std::cmp::Ordering::Equal {
|
|
return c;
|
|
}
|
|
}
|
|
ka.len().cmp(&kb.len()).then_with(|| a.ec.0.cmp(&b.ec.0))
|
|
});
|
|
}
|
|
|
|
/// Plans the whole project.
|
|
pub(crate) fn plan(project: &Project, col: &mut Collector) -> ProjectPlan {
|
|
// Items whose chain of folders reaches the root (no loop, not nested absurdly deep).
|
|
let mut ok: HashSet<ItemId> = HashSet::new();
|
|
for (id, item) in &project.items {
|
|
let mut at = item.parent.filter(|p| project.items.contains_key(p));
|
|
let mut steps = 0usize;
|
|
let reachable = loop {
|
|
match at {
|
|
None => break true,
|
|
Some(p) if p == *id => break false,
|
|
Some(p) => {
|
|
steps += 1;
|
|
if steps > MAX_FOLDER_DEPTH {
|
|
break false;
|
|
}
|
|
at = project.items.get(&p).and_then(|i| i.parent).filter(|q| project.items.contains_key(q));
|
|
}
|
|
}
|
|
};
|
|
if reachable {
|
|
ok.insert(*id);
|
|
} else {
|
|
col.lose(
|
|
LossCategory::FootageItems,
|
|
format!("'{}' is in a folder loop or nested deeper than {MAX_FOLDER_DEPTH} levels and was not exported", item.name),
|
|
);
|
|
}
|
|
}
|
|
// Children per folder, project order (creation order = id order).
|
|
let mut by_parent: BTreeMap<Option<ItemId>, Vec<ItemId>> = BTreeMap::new();
|
|
for (id, item) in &project.items {
|
|
if ok.contains(id) {
|
|
by_parent.entry(item.parent.filter(|p| project.items.contains_key(p))).or_default().push(*id);
|
|
}
|
|
}
|
|
let cx = PlanCx { project, by_parent: &by_parent, ok: &ok };
|
|
let root = plan_children(&cx, None, col);
|
|
// The base writes an empty render queue.
|
|
for job in &project.render_queue {
|
|
let comp = project.items.get(&job.comp).map_or("a missing composition", |i| i.name.as_str());
|
|
col.lose(LossCategory::RenderQueue, format!("the render queue item of '{comp}' is not written"));
|
|
}
|
|
ProjectPlan { root, settings: crate::export::settings::plan(&project.settings, col) }
|
|
}
|
|
|
|
struct PlanCx<'a> {
|
|
project: &'a Project,
|
|
by_parent: &'a BTreeMap<Option<ItemId>, Vec<ItemId>>,
|
|
ok: &'a HashSet<ItemId>,
|
|
}
|
|
|
|
fn plan_children(cx: &PlanCx<'_>, parent: Option<ItemId>, col: &mut Collector) -> Vec<Entry> {
|
|
let project = cx.project;
|
|
let mut out = Vec::new();
|
|
for id in cx.by_parent.get(&parent).map(Vec::as_slice).unwrap_or(&[]) {
|
|
let Some(item) = project.item(*id) else { continue };
|
|
let label = label_index(item.label);
|
|
let item_name = cut_name(&item.name, 255, "item", col);
|
|
if !item.comment.is_empty() {
|
|
col.lose(LossCategory::Names, format!("'{}': the item comment is not written", item.name));
|
|
}
|
|
if item.proxy.is_some() {
|
|
col.lose(LossCategory::FootageItems, format!("'{}': the proxy is not written", item.name));
|
|
}
|
|
match &item.kind {
|
|
ItemKind::Folder => {
|
|
col.exported.folders += 1;
|
|
let kids = plan_children(cx, Some(*id), col);
|
|
out.push(Entry { ec: *id, name: item_name, label, plan: ItemPlan::Folder(kids) });
|
|
}
|
|
ItemKind::Comp(comp) => {
|
|
let cp = plan_comp(cx, *id, &item.name, comp, col);
|
|
col.exported.compositions += 1;
|
|
out.push(Entry { ec: *id, name: item_name, label, plan: ItemPlan::Comp(Box::new(cp)) });
|
|
}
|
|
ItemKind::Solid(s) => {
|
|
let width = clamp_side(s.width, &item.name, col);
|
|
let height = clamp_side(s.height, &item.name, col);
|
|
let mut color = [0f32; 3];
|
|
for (c, v) in color.iter_mut().zip(s.color) {
|
|
*c = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
|
if c.to_bits() != v.to_bits() {
|
|
col.lose(LossCategory::InvalidValues, format!("solid '{}': colour channel {v} written as {c}", item.name));
|
|
}
|
|
}
|
|
if s.pixel_aspect.to_bits() != 1f64.to_bits() {
|
|
col.lose(LossCategory::FootageItems, format!("solid '{}': pixel aspect {} is not written (written as 1)", item.name, s.pixel_aspect));
|
|
}
|
|
col.exported.solids += 1;
|
|
out.push(Entry { ec: *id, name: item_name, label, plan: ItemPlan::Solid(SolidPlan { color, width, height }) });
|
|
}
|
|
ItemKind::Footage(f) => {
|
|
if !f.markers.is_empty() {
|
|
col.lose(LossCategory::Markers, format!("footage '{}': {} footage marker(s) are not written", item.name, f.markers.len()));
|
|
}
|
|
if let Some(fp) = crate::export::footage::plan_footage(&item.name, f, col) {
|
|
out.push(Entry { ec: *id, name: item_name, label, plan: ItemPlan::Footage(Box::new(fp)) });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
sort_entries(&mut out);
|
|
out
|
|
}
|
|
|
|
fn clamp_side(v: u32, what: &str, col: &mut Collector) -> u32 {
|
|
if (1..=MAX_SIDE).contains(&v) {
|
|
v
|
|
} else {
|
|
col.lose(LossCategory::CompositionSettings, format!("'{what}': size {v} is outside 1 to {MAX_SIDE}, written as {}", v.clamp(1, MAX_SIDE)));
|
|
v.clamp(1, MAX_SIDE)
|
|
}
|
|
}
|
|
|
|
/// The best fraction `n / d` of `v` with a denominator up to 1000 (continued fractions).
|
|
fn rational(v: f64) -> (u32, u32) {
|
|
if !(v.is_finite() && v > 0.0) {
|
|
return (1, 1);
|
|
}
|
|
let (mut h0, mut h1, mut k0, mut k1) = (0i64, 1i64, 1i64, 0i64);
|
|
let mut x = v;
|
|
for _ in 0..24 {
|
|
let a = x.floor();
|
|
if a > 1e6 {
|
|
break;
|
|
}
|
|
let a = a as i64;
|
|
let (h2, k2) = (a * h1 + h0, a * k1 + k0);
|
|
if k2 > 1000 || h2 > 1_000_000 {
|
|
break;
|
|
}
|
|
(h0, h1, k0, k1) = (h1, h2, k1, k2);
|
|
if (h1 as f64 / k1 as f64 - v).abs() < 1e-9 {
|
|
break;
|
|
}
|
|
let frac = x - a as f64;
|
|
if frac < 1e-12 {
|
|
break;
|
|
}
|
|
x = 1.0 / frac;
|
|
}
|
|
match (u32::try_from(h1), u32::try_from(k1)) {
|
|
(Ok(n), Ok(d)) if n > 0 && d > 0 => (n, d),
|
|
_ => (1, 1),
|
|
}
|
|
}
|
|
|
|
fn plan_comp(cx: &PlanCx<'_>, comp_id: ItemId, name: &str, comp: &Comp, col: &mut Collector) -> CompPlan {
|
|
let rate = comp.frame_rate;
|
|
let tb = time_base(rate).unwrap_or_else(|| {
|
|
col.lose(LossCategory::CompositionSettings, format!("'{name}': frame rate {} cannot be stored, 25 fps written", rate.label()));
|
|
TimeBase { frame_duration: 1024, timescale: 25600 }
|
|
});
|
|
let rate = if time_base(rate).is_some() { rate } else { FrameRate::new(25, 1) };
|
|
let width = clamp_side(comp.width, name, col);
|
|
let height = clamp_side(comp.height, name, col);
|
|
let max_frames = MAX_SECONDS.saturating_mul(rate.num) / rate.den.max(1);
|
|
let mut frames = tb.frames(comp.duration, rate);
|
|
if (1..=max_frames).contains(&frames) && rate.tick_of(frames) != comp.duration {
|
|
col.lose(LossCategory::RoundedTimes, format!("'{name}': duration moved to whole frames ({frames})"));
|
|
}
|
|
if frames < 1 || frames > max_frames {
|
|
let fixed = frames.clamp(1, max_frames.max(1));
|
|
col.lose(LossCategory::CompositionSettings, format!("'{name}': duration of {frames} frames is outside 1 frame to 3 hours, written as {fixed} frames"));
|
|
frames = fixed;
|
|
}
|
|
let fd = i64::from(tb.frame_duration);
|
|
let duration_units = u32::try_from(frames.saturating_mul(fd)).unwrap_or_else(|_| {
|
|
col.lose(LossCategory::CompositionSettings, format!("'{name}': the duration does not fit the time base of {} fps, written shorter", rate.label()));
|
|
u32::MAX
|
|
});
|
|
let ws = tb.frames(comp.work_area.0, rate).clamp(0, frames - 1);
|
|
let we = tb.frames(comp.work_area.1, rate).clamp(ws + 1, frames);
|
|
let whole = ws == 0 && we == frames;
|
|
if (comp.work_area.0 != rate.tick_of(ws) && comp.work_area.0 != Tick::ZERO) || (comp.work_area.1 != rate.tick_of(we) && comp.work_area.1 != comp.duration) {
|
|
col.lose(LossCategory::RoundedTimes, format!("'{name}': work area moved to whole frames ({ws} to {we})"));
|
|
}
|
|
let mut background = [0u8; 3];
|
|
for (b, v) in background.iter_mut().zip(comp.background) {
|
|
*b = if v.is_finite() { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 };
|
|
// Eight bits per channel is what the format holds (the nearest level is written); a
|
|
// channel outside 0 to 1 is reported.
|
|
if !(0.0..=1.0).contains(&v) {
|
|
col.lose(LossCategory::CompositionSettings, format!("'{name}': background channel {v} written as {b}/255"));
|
|
}
|
|
}
|
|
let pa = sane(comp.pixel_aspect).filter(|v| *v > 0.0).unwrap_or(1.0);
|
|
let (pn, pd) = rational(pa);
|
|
if (f64::from(pn) / f64::from(pd) - pa).abs() > 1e-9 || pa.to_bits() != comp.pixel_aspect.to_bits() {
|
|
col.lose(LossCategory::CompositionSettings, format!("'{name}': pixel aspect {pa} written as {pn}/{pd}"));
|
|
}
|
|
if matches!(comp.renderer, Renderer::Advanced3D) {
|
|
col.lose(LossCategory::CompositionOptions, format!("'{name}': the Advanced 3D renderer is written as Classic 3D"));
|
|
}
|
|
let comp_markers = crate::export::markers::plan(&comp.markers, &tb, &format!("'{name}'"), col);
|
|
if !comp.guides.is_empty() {
|
|
col.lose(LossCategory::CompositionOptions, format!("'{name}': {} guides", comp.guides.len()));
|
|
}
|
|
if comp.essential.is_some() {
|
|
col.lose(LossCategory::CompositionOptions, format!("'{name}': Essential Graphics"));
|
|
}
|
|
// Advanced settings that change rendered frames, which the base does not decode: written at
|
|
// After Effects' defaults (the samples per frame and adaptive limit are the 16 and 128 every
|
|
// sample holds at cdta 200 and 196, base.rs), so any other value is reported. Viewer state
|
|
// (Hide Shy, Draft 3D, the viewer resolution, the poster time) is a listed limitation.
|
|
let unwritten = [
|
|
(comp.preserve_frame_rate, "Preserve Frame Rate switched on"),
|
|
(comp.preserve_resolution, "Preserve Resolution switched on"),
|
|
(comp.motion_blur_samples != 16, "motion blur samples per frame other than 16"),
|
|
(comp.motion_blur_adaptive_limit != 128, "adaptive sample limit other than 128"),
|
|
];
|
|
for (_, what) in unwritten.iter().filter(|(lost, _)| *lost) {
|
|
col.lose(LossCategory::CompositionOptions, format!("'{name}': {what} (written as the default)"));
|
|
}
|
|
let (ds, inexact) = tb.units(comp.display_start);
|
|
let display_start = u32::try_from(ds).unwrap_or(0);
|
|
if inexact || i64::from(display_start) != ds {
|
|
col.lose(LossCategory::CompositionSettings, format!("'{name}': start timecode written as {} frames", i64::from(display_start) / fd.max(1)));
|
|
}
|
|
let shutter = |v: f64, (lo, hi): (f64, f64), default: f64, what: &str, col: &mut Collector| {
|
|
let out = sane(v).map_or(default, |v| v.round().clamp(lo, hi));
|
|
if out.to_bits() != v.to_bits() {
|
|
col.lose(LossCategory::CompositionSettings, format!("'{name}': shutter {what} {v} written as {out}"));
|
|
}
|
|
out
|
|
};
|
|
let shutter_angle = shutter(comp.shutter_angle, (0.0, 720.0), 180.0, "angle", col) as u32;
|
|
let shutter_phase = shutter(comp.shutter_phase, (-720.0, 720.0), 0.0, "phase", col) as i32;
|
|
|
|
let mut cp = CompPlan {
|
|
width: u16::try_from(width).unwrap_or(u16::MAX),
|
|
height: u16::try_from(height).unwrap_or(u16::MAX),
|
|
tb,
|
|
rate,
|
|
duration_units,
|
|
work_start: u32::try_from(ws.saturating_mul(fd)).unwrap_or(0),
|
|
// Both fit when the duration does (`ws < we <= frames`); a duration that does not is reported above.
|
|
work_end: if whole { None } else { u32::try_from(we.saturating_mul(fd)).ok() },
|
|
background,
|
|
pixel_aspect: (pn, pd),
|
|
motion_blur: comp.enable_motion_blur,
|
|
shutter_angle,
|
|
shutter_phase,
|
|
display_start,
|
|
markers: comp_markers,
|
|
layers: Vec::new(),
|
|
};
|
|
let ids: HashSet<LayerId> = comp.layers.iter().map(|l| l.id).collect();
|
|
let at = CompAt { id: comp_id, name, comp, ids: &ids };
|
|
for layer in &comp.layers {
|
|
if let Some(lp) = plan_layer(cx, at, &cp, layer, col) {
|
|
cp.layers.push(lp);
|
|
}
|
|
}
|
|
resolve_parents(&mut cp, name, col);
|
|
resolve_mattes(&mut cp, name, col);
|
|
resolve_effect_layers(&mut cp, name, col);
|
|
cp
|
|
}
|
|
|
|
/// An effect's layer parameter that names a layer that is not exported falls back to the owning
|
|
/// layer (After Effects' default) and is reported.
|
|
fn resolve_effect_layers(cp: &mut CompPlan, comp_name: &str, col: &mut Collector) {
|
|
let exported: HashSet<LayerId> = cp.layers.iter().map(|l| l.ec_id).collect();
|
|
for l in &mut cp.layers {
|
|
for fx in &mut l.fx {
|
|
let before = fx.layers.len();
|
|
fx.layers.retain(|(_, p)| !matches!(p, crate::export::effects::LayerPick::Layer(id) if !exported.contains(id)));
|
|
if fx.layers.len() != before {
|
|
col.lose(
|
|
LossCategory::Effects,
|
|
format!(
|
|
"{comp_name}: '{}' effect '{}': a layer parameter names a layer that is not exported, the owning layer was written",
|
|
l.name, fx.name
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A track matte whose layer is not exported, or is the layer itself, is dropped and reported.
|
|
fn resolve_mattes(cp: &mut CompPlan, comp_name: &str, col: &mut Collector) {
|
|
let exported: HashSet<LayerId> = cp.layers.iter().map(|l| l.ec_id).collect();
|
|
for l in &mut cp.layers {
|
|
if let Some((m, _)) = l.matte
|
|
&& (m == l.ec_id || !exported.contains(&m))
|
|
{
|
|
col.lose(LossCategory::TrackMattes, format!("{comp_name}: '{}': the track matte layer is not exported", l.name));
|
|
l.matte = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Parent links to a layer that was not exported, and links that lead into a loop, are cut.
|
|
fn resolve_parents(cp: &mut CompPlan, comp_name: &str, col: &mut Collector) {
|
|
let exported: HashSet<LayerId> = cp.layers.iter().map(|l| l.ec_id).collect();
|
|
let mut links: std::collections::HashMap<LayerId, LayerId> = cp.layers.iter().filter_map(|l| l.parent.map(|p| (l.ec_id, p))).collect();
|
|
for l in &mut cp.layers {
|
|
let Some(p) = l.parent else { continue };
|
|
let mut cut = !exported.contains(&p);
|
|
if !cut {
|
|
// Follow the chain; a chain longer than the number of links is a loop.
|
|
let mut at = p;
|
|
for _ in 0..=links.len() {
|
|
if at == l.ec_id {
|
|
cut = true;
|
|
break;
|
|
}
|
|
match links.get(&at) {
|
|
Some(next) => at = *next,
|
|
None => break,
|
|
}
|
|
}
|
|
if !cut && at == l.ec_id {
|
|
cut = true;
|
|
}
|
|
}
|
|
if cut {
|
|
col.lose(LossCategory::ParentLinks, format!("{comp_name}: '{}' has a parent that was not exported or forms a loop", l.name));
|
|
links.remove(&l.ec_id);
|
|
l.parent = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The source of a layer and its size; `None` (with a loss) for a layer that is not written.
|
|
fn plan_source(
|
|
cx: &PlanCx<'_>,
|
|
comp_id: ItemId,
|
|
comp_name: &str,
|
|
comp: &Comp,
|
|
layer: &Layer,
|
|
remapped: bool,
|
|
col: &mut Collector,
|
|
) -> Option<(LayerSrc, (u32, u32))> {
|
|
let what = |s: &str| format!("{comp_name}: {s} '{}'", layer.name);
|
|
Some(match &layer.source {
|
|
LayerSource::Solid { item } => match cx.project.item(*item).filter(|_| cx.ok.contains(item)).map(|i| &i.kind) {
|
|
Some(ItemKind::Solid(s)) => (LayerSrc::Solid(*item), (s.width.clamp(1, MAX_SIDE), s.height.clamp(1, MAX_SIDE))),
|
|
_ => {
|
|
col.lose(LossCategory::UnsupportedLayers, what("solid layer without a solid:"));
|
|
return None;
|
|
}
|
|
},
|
|
LayerSource::Null => (LayerSrc::Null, (100, 100)),
|
|
LayerSource::Text => {
|
|
// A text layer has no source; it spans the composition (the importer's convention).
|
|
let tp = crate::export::text::plan_text(layer, comp_name, col)?;
|
|
(LayerSrc::Text(Box::new(tp)), (comp.width.clamp(1, MAX_SIDE), comp.height.clamp(1, MAX_SIDE)))
|
|
}
|
|
// A shape layer has no source; its anchor is in pixels (462), its items are planned from
|
|
// the `contents` group.
|
|
// (planned below, once the layer's time base is known: their keys count in it)
|
|
LayerSource::Shape => (LayerSrc::Shape(Vec::new()), (comp.width.clamp(1, MAX_SIDE), comp.height.clamp(1, MAX_SIDE))),
|
|
// Cameras and lights have no source; their point of interest is the Anchor Point in
|
|
// pixels (3.12).
|
|
LayerSource::Camera => (LayerSrc::Camera(Box::new(crate::export::camlight::plan_camera(layer, comp.width, &what("camera"), col))), (1, 1)),
|
|
LayerSource::Light { kind } => (LayerSrc::Light(Box::new(crate::export::camlight::plan_light(layer, *kind, &what("light"), col)?)), (1, 1)),
|
|
LayerSource::Model { .. } | LayerSource::Primitive { .. } => {
|
|
col.lose(LossCategory::ModelLayers, what("3D layer"));
|
|
return None;
|
|
}
|
|
LayerSource::Footage { item } => match cx.project.item(*item).filter(|_| cx.ok.contains(item)).map(|i| (&i.name, &i.kind)) {
|
|
Some((name, ItemKind::Footage(f))) if let Some(fp) = crate::export::footage::plan_footage(name, f, &mut Collector::default()) => {
|
|
// After Effects ends a layer of timed footage at the source's end (f1: an mp4,
|
|
// two sequences and a WAV written 5 s long read back 1, 0.1, 0.1 and 1 s long).
|
|
// Source time the layer reaches: its span divided by the stretch (a reversed layer reaches
|
|
// it at its in point).
|
|
let span = (layer.out_point.seconds() - layer.start_time.seconds()).abs().max((layer.in_point.seconds() - layer.start_time.seconds()).abs())
|
|
/ (layer.stretch.abs() / 100.0).max(1e-9);
|
|
// A remapped layer may run past its source (the remap holds a frame); less than a
|
|
// composition frame past is the rounding of a clip's duration to frames.
|
|
let frame = comp.frame_rate.frame_duration().seconds();
|
|
if fp.seconds > 0.0 && span > fp.seconds + frame.max(1e-6) - 1e-9 && !remapped {
|
|
col.lose(
|
|
LossCategory::LayerTiming,
|
|
what(&format!("footage layer runs {span:.3} s, past its source's {:.3} s; After Effects ends it there:", fp.seconds)),
|
|
);
|
|
}
|
|
(LayerSrc::Footage(*item), (f.width.clamp(1, MAX_SIDE), f.height.clamp(1, MAX_SIDE)))
|
|
}
|
|
_ => {
|
|
col.lose(LossCategory::UnsupportedLayers, what("footage layer whose footage is not exported:"));
|
|
return None;
|
|
}
|
|
},
|
|
LayerSource::Comp { item } => match cx.project.item(*item).filter(|_| cx.ok.contains(item)).map(|i| &i.kind) {
|
|
Some(ItemKind::Comp(c)) if !precomp_loop(cx.project, comp_id, *item) => {
|
|
(LayerSrc::Comp(*item), (c.width.clamp(1, MAX_SIDE), c.height.clamp(1, MAX_SIDE)))
|
|
}
|
|
Some(ItemKind::Comp(_)) => {
|
|
col.lose(LossCategory::UnsupportedLayers, what("precomposition layer that closes a loop:"));
|
|
return None;
|
|
}
|
|
_ => {
|
|
col.lose(LossCategory::UnsupportedLayers, what("precomposition layer without its composition:"));
|
|
return None;
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
/// The exact time stretch ratio of a layer and the time scale its properties count in.
|
|
fn plan_stretch(layer: &Layer, comp_timescale: u32, comp_name: &str, col: &mut Collector) -> ((i32, u32), u32) {
|
|
let what = |s: &str| format!("{comp_name}: {s} '{}'", layer.name);
|
|
let mut stretch = (1i32, 1u32);
|
|
let mut time_scale = comp_timescale;
|
|
if layer.stretch.is_nan() || (layer.stretch - 100.0).abs() >= 1e-9 {
|
|
// A negative stretch is a time-reversed layer: the numerator is negative (981-983) and the
|
|
// properties count in the composition's time scale, as below 100 % (982, 983).
|
|
let r = layer.stretch / 100.0;
|
|
let reversed = r < 0.0;
|
|
let (n, d) = rational(r.abs());
|
|
let exact = r.is_finite() && r != 0.0 && (f64::from(n) / f64::from(d) - r.abs()).abs() <= 1e-9 * r.abs();
|
|
let ts = if n >= d && !reversed { u64::from(comp_timescale) * u64::from(n) / u64::from(d) } else { u64::from(comp_timescale) };
|
|
let ts_exact = n < d || reversed || (u64::from(comp_timescale) * u64::from(n)) % u64::from(d) == 0;
|
|
let num = i32::try_from(n).ok().map(|n| if reversed { -n } else { n });
|
|
match num {
|
|
Some(num) if exact && ts_exact && ts <= u64::from(u32::MAX) => {
|
|
if has_bezier_keys(&layer.props, 0) {
|
|
col.lose(
|
|
LossCategory::ApproximatedKeyframes,
|
|
what(&format!(
|
|
"bezier ease on a {:.2} % stretched layer (its speed unit there is not sampled; written as stored) on layer",
|
|
layer.stretch
|
|
)),
|
|
);
|
|
}
|
|
stretch = (num, d);
|
|
time_scale = u32::try_from(ts).unwrap_or(comp_timescale);
|
|
}
|
|
_ => col.lose(LossCategory::TimeStretch, what(&format!("stretch {:.2} % (not an exact small ratio) on layer", layer.stretch))),
|
|
}
|
|
}
|
|
(stretch, time_scale)
|
|
}
|
|
|
|
/// The Transform properties of a layer that are written.
|
|
fn plan_transform(layer: &Layer, src: &LayerSrc, comp: &Comp, source_size: (u32, u32), cp: &CompPlan, comp_name: &str, col: &mut Collector) -> Vec<PropPlan> {
|
|
let is_text = matches!(src, LayerSrc::Text(_));
|
|
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, 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.
|
|
let id = if cam_light && kind == PropKind::Anchor { "poi" } else { kind.id() };
|
|
let Some(p) = tr.get(id) else { continue };
|
|
if let Some(pp) = plan_prop(kind, None, p, at, cp, col) {
|
|
col.exported.properties += 1;
|
|
props.push(pp);
|
|
}
|
|
}
|
|
// A camera's or light's block holds After Effects' own Position and point of interest:
|
|
// a value at EffectCraft's default is written too.
|
|
if cam_light {
|
|
for (kind, id) in [(PropKind::Position, "position"), (PropKind::Anchor, "poi")] {
|
|
if props.iter().any(|p: &PropPlan| p.kind == kind) {
|
|
continue;
|
|
}
|
|
if let Some(effectcraft_keyframe::Value::Vec3(v)) = tr.get(id).map(|p| &p.value) {
|
|
let value: Vec<f64> = v.iter().map(|c| if c.is_finite() { *c } else { 0.0 }).collect();
|
|
props.push(PropPlan { kind, value, keys: Vec::new(), expr: None });
|
|
col.exported.properties += 1;
|
|
}
|
|
}
|
|
}
|
|
for node in &tr.children {
|
|
let id = node.match_id();
|
|
if PropKind::ALL.iter().any(|k| k.id() == id) || (cam_light && id == "poi") {
|
|
continue;
|
|
}
|
|
if let Some(p) = node.as_prop()
|
|
&& is_non_default_extra(id, p)
|
|
{
|
|
col.lose(LossCategory::TransformProperties, format!("{comp_name}: '{}' {}", layer.name, p.name));
|
|
}
|
|
}
|
|
}
|
|
props
|
|
}
|
|
|
|
/// The composition a layer is planned in.
|
|
#[derive(Clone, Copy)]
|
|
struct CompAt<'a> {
|
|
id: ItemId,
|
|
name: &'a str,
|
|
comp: &'a Comp,
|
|
/// The ids of its layers.
|
|
ids: &'a HashSet<LayerId>,
|
|
}
|
|
|
|
/// A time relative to the layer start (composition units) in source time: divided by the
|
|
/// stretch `n / d`. A result between two units is rounded, one beyond the format's range is
|
|
/// written as the nearest limit; both are reported.
|
|
fn unstretch(rel: i64, stretch: (i32, u32), what: &str, col: &mut Collector) -> i32 {
|
|
let v = if stretch == (1, 1) { rel as f64 } else { (rel as f64) * f64::from(stretch.1) / f64::from(stretch.0) };
|
|
let r = v.round();
|
|
if r != v {
|
|
col.lose(LossCategory::RoundedTimes, format!("{what}: rounded to the time base after the time stretch"));
|
|
}
|
|
let lim = r.clamp(f64::from(i32::MIN), f64::from(i32::MAX));
|
|
if lim != r {
|
|
col.lose(LossCategory::LayerTiming, format!("{what}: beyond the range the format holds, written as the nearest limit"));
|
|
}
|
|
lim as i32
|
|
}
|
|
|
|
/// The ease written for slot `i` of a key side: the slot's, else the last one. A side without an
|
|
/// ease is the first key's incoming or the last key's outgoing side, which no interpolation uses
|
|
/// (the importer clears them); After Effects' 33.33 % is written there.
|
|
pub(crate) fn key_ease(eases: &[Ease], i: usize) -> Ease {
|
|
eases.get(i).or(eases.last()).copied().unwrap_or(Ease { speed: 0.0, influence: 1.0 / 3.0 })
|
|
}
|
|
|
|
/// Reports a key speed or spatial tangent beyond the format's range (written as 0).
|
|
pub(crate) fn report_beyond(beyond: bool, what: &str, col: &mut Collector) {
|
|
if beyond {
|
|
col.lose(LossCategory::InvalidValues, format!("{what}: a key speed or spatial tangent beyond {LIMIT} is written as 0"));
|
|
}
|
|
}
|
|
|
|
/// Reports keys whose evaluation the format does not keep: muted keys (written as active keys) and
|
|
/// auto-Bezier keys (written with their stored ease, not the slope EffectCraft computes).
|
|
fn report_key_flags(layer: &Layer, what: &str, col: &mut Collector) {
|
|
layer.props.walk("", &mut |_, p| {
|
|
if p.keys.iter().any(|k| k.muted) {
|
|
col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: '{}': a muted key is written as an active key", p.name));
|
|
}
|
|
let n = p.keys.len();
|
|
let bezier = |k: &Keyframe| k.in_interp == Interp::Bezier || k.out_interp == Interp::Bezier;
|
|
if p.keys.iter().enumerate().any(|(i, k)| k.auto_bezier && bezier(k) && i > 0 && i + 1 < n) {
|
|
col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: '{}': an auto-Bezier key is written with its stored ease", p.name));
|
|
}
|
|
});
|
|
}
|
|
|
|
fn plan_layer(cx: &PlanCx<'_>, at: CompAt<'_>, cp: &CompPlan, layer: &Layer, col: &mut Collector) -> Option<LayerPlan> {
|
|
let CompAt { id: comp_id, name: comp_name, comp, ids } = at;
|
|
let what = |s: &str| format!("{comp_name}: {s} '{}'", layer.name);
|
|
// An enabled Time Remap (the importer's `timeRemap` property) is written on precomposition
|
|
// and footage layers, the layers After Effects can remap (319, 354; a solid cannot).
|
|
let remap_prop = layer.props.prop("timeRemap");
|
|
let remappable = matches!(layer.source, LayerSource::Comp { .. } | LayerSource::Footage { .. });
|
|
if remap_prop.is_some() && !remappable {
|
|
col.lose(LossCategory::TimeRemapping, what("time remapping on a layer After Effects cannot remap is not written; layer"));
|
|
}
|
|
let remapped = remap_prop.is_some() && remappable;
|
|
let (mut src, source_size) = plan_source(cx, comp_id, comp_name, comp, layer, remapped, col)?;
|
|
report_key_flags(layer, &format!("{comp_name}: '{}'", layer.name), col);
|
|
let tb = cp.tb;
|
|
let mut rounded = false;
|
|
let mut units = |t: Tick, col: &mut Collector, what: &str| -> i32 {
|
|
let (u, inexact) = tb.units(t);
|
|
rounded |= inexact;
|
|
i32::try_from(u).unwrap_or_else(|_| {
|
|
col.lose(LossCategory::InvalidValues, format!("{comp_name}: '{}' {what} is out of range, written as 0", layer.name));
|
|
0
|
|
})
|
|
};
|
|
let start = units(layer.start_time, col, "start time");
|
|
let in_abs = units(layer.in_point, col, "in point");
|
|
let out_abs = units(layer.out_point, col, "out point");
|
|
if rounded {
|
|
col.lose(LossCategory::RoundedTimes, what("start, in or out point moved to the time base; layer"));
|
|
}
|
|
// Time stretch: the exact ratio `ldta@8 / @108` (018 200 %: 2 / 1, 316 50 %: 1 / 2, 317 150 %:
|
|
// 3 / 2); in and out points relative to the start are in source time (absolute = start +
|
|
// relative x stretch), and the layer's properties count in the composition's time scale x
|
|
// stretch for a stretch of 100 % or more, in the composition's below (018, 317: 51 200 and
|
|
// 38 400; 316: 25 600). Keys count source time in that time scale (641: 200 %, a key at comp
|
|
// 1 s = source 0.5 s stored as 25 600 / 51 200; 642: 50 %, comp 1 s = source 2 s stored as
|
|
// 51 200 / 25 600; 643: the same with the layer starting at 1 s), which is EffectCraft's layer
|
|
// time in the layer's time scale.
|
|
let (stretch, time_scale) = plan_stretch(layer, cp.tb.timescale, comp_name, col);
|
|
// In and out points relative to the start, in source time (reversed, 981: After Effects' in
|
|
// point is the later end, source 0 at the start).
|
|
let (first, last) = if stretch.0 < 0 { (out_abs, in_abs) } else { (in_abs, out_abs) };
|
|
let in_rel = unstretch(i64::from(first) - i64::from(start), stretch, &what("in point; layer"), col);
|
|
let out_rel = unstretch(i64::from(last) - i64::from(start), stretch, &what("out point; layer"), col);
|
|
|
|
// The layer's properties count in its own time scale (keys in source time units).
|
|
let layer_cp;
|
|
let cp: &CompPlan = if time_scale == cp.tb.timescale {
|
|
cp
|
|
} else {
|
|
layer_cp = CompPlan { tb: TimeBase { timescale: time_scale, ..cp.tb }, layers: Vec::new(), ..comp_header(cp) };
|
|
&layer_cp
|
|
};
|
|
|
|
if let LayerSrc::Shape(items) = &mut src
|
|
&& let Some(c) = layer.props.sub("contents")
|
|
{
|
|
let mut sx = crate::export::shapes::ShCx { cp, col };
|
|
*items = crate::export::shapes::plan_contents(c, &what("shape layer"), 0, &mut sx);
|
|
}
|
|
// Text animators count their keys in the layer's time base too.
|
|
if let LayerSrc::Text(tp) = &mut src {
|
|
tp.animators = crate::export::textanim::plan(layer, cp, &format!("{comp_name}: '{}'", layer.name), col);
|
|
}
|
|
|
|
// Switches and features that are not written.
|
|
let sw = &layer.switches;
|
|
let unwritten = [
|
|
(sw.quality == Quality::Wireframe, "Wireframe quality (written as Best)"),
|
|
(sw.sampling != effectcraft_project::Sampling::Bilinear, "Bicubic sampling (written as Bilinear)"),
|
|
(sw.frame_blend != effectcraft_project::FrameBlend::Off, "frame blending"),
|
|
(layer.preserve_transparency, "Preserve Transparency"),
|
|
(layer.auto_orient != effectcraft_project::AutoOrient::Off && !matches!(src, LayerSrc::Camera(_) | LayerSrc::Light(_)), "Auto-Orient"),
|
|
(layer.environment || layer.environment_background, "the environment layer setting"),
|
|
(layer.markers_locked, "Lock Markers"),
|
|
];
|
|
for (_, what_lost) in unwritten.iter().filter(|(on, _)| *on) {
|
|
col.lose(LossCategory::LayerSwitches, what(&format!("{what_lost} is not written; layer")));
|
|
}
|
|
// The 3D switch is written (`ldta@38` bit 0x04, 015); a 3D layer's material options are not
|
|
// (reported below when changed), and its X / Y rotation and orientation are reported too.
|
|
let per_char_3d = matches!(layer.props.prop("text/perChar3d").map(|p| &p.value), Some(effectcraft_keyframe::Value::Bool(true)));
|
|
if layer.switches.three_d && matches!(src, LayerSrc::Text(_)) && per_char_3d {
|
|
col.lose(LossCategory::ThreeD, what("per-character 3D of the text layer is not written; 3D layer"));
|
|
}
|
|
// Material Options change how a 3D layer takes lights and shadows; they are not written, so a
|
|
// value other than After Effects' default, a key or an expression is reported (review
|
|
// finding). On a 2D layer they have no effect.
|
|
if layer.switches.three_d {
|
|
for name in changed_material_options(layer) {
|
|
col.lose(LossCategory::ThreeD, what(&format!("the material option {name} is not written; 3D layer")));
|
|
}
|
|
}
|
|
// Track matte (403-408): the mode and the matte layer's id; a matte layer that is not
|
|
// exported (or the layer itself) is reported in `resolve_mattes`.
|
|
let matte = layer.track_matte.map(|tm| {
|
|
let mode = match tm.kind {
|
|
effectcraft_project::MatteKind::Alpha => 1,
|
|
effectcraft_project::MatteKind::AlphaInverted => 2,
|
|
effectcraft_project::MatteKind::Luma => 3,
|
|
effectcraft_project::MatteKind::LumaInverted => 4,
|
|
};
|
|
(tm.layer, mode)
|
|
});
|
|
// Layer markers count layer time in the layer's units, as its keys do.
|
|
let markers = crate::export::markers::plan(&layer.markers, &cp.tb, &what("layer"), col);
|
|
let fx = crate::export::effects::plan_effects(layer, source_size, cp, comp_name, col);
|
|
let styles = crate::export::styles::plan_styles(layer, comp, cp, &format!("{comp_name}: '{}'", layer.name), col);
|
|
let is_text = matches!(src, LayerSrc::Text(_));
|
|
let cam_light = matches!(src, LayerSrc::Camera(_) | LayerSrc::Light(_));
|
|
let is_shape = matches!(src, LayerSrc::Shape(_));
|
|
let masks = if is_text || is_shape {
|
|
let n = layer.masks().map_or(0, |m| m.groups().count());
|
|
if n > 0 {
|
|
let kind = if is_text { "text" } else { "shape" };
|
|
col.lose(LossCategory::Masks, what(&format!("{n} mask(s) on a {kind} layer (the path unit on {kind} layers is not verified) on")));
|
|
}
|
|
Vec::new()
|
|
} else {
|
|
crate::export::masks::plan_masks(layer, source_size, cp, comp_name, col)
|
|
};
|
|
let blend = match blend_code(layer.blend_mode) {
|
|
Some(c) => c,
|
|
None => {
|
|
col.lose(LossCategory::BlendModes, what(&format!("blend mode {} on", layer.blend_mode.label())));
|
|
2
|
|
}
|
|
};
|
|
let parent = layer.parent.filter(|p| ids.contains(p));
|
|
if layer.parent.is_some() && parent.is_none() {
|
|
col.lose(LossCategory::ParentLinks, what("a parent that is not a layer of this composition, written without it, on"));
|
|
}
|
|
|
|
let props = plan_transform(layer, &src, comp, source_size, cp, comp_name, col);
|
|
let remap = remap_prop.filter(|_| remapped).map(|p| plan_remap(p, comp, cp, &layer.name, comp_name, col));
|
|
let mut name = cut_name(&layer.name, 255, &what("layer"), col);
|
|
// A text layer named after its text stores no name (110, 113: After Effects shows the text):
|
|
// the layer's own rule decides (#3233).
|
|
if let LayerSrc::Text(tp) = &src
|
|
&& layer.name_follows_text(&effectcraft_project::text_layer_name(&tp.text))
|
|
{
|
|
name.clear();
|
|
}
|
|
col.exported.layers += 1;
|
|
match (&src, layer.switches.adjustment) {
|
|
(LayerSrc::Null, _) => col.exported.null_layers += 1,
|
|
(LayerSrc::Comp(_), _) => col.exported.precomp_layers += 1,
|
|
(LayerSrc::Footage(_), _) => col.exported.footage_layers += 1,
|
|
(LayerSrc::Text(_), _) => col.exported.text_layers += 1,
|
|
(LayerSrc::Camera(_), _) => col.exported.cameras += 1,
|
|
(LayerSrc::Light(_), _) => col.exported.lights += 1,
|
|
(LayerSrc::Shape(_), _) => col.exported.shape_layers += 1,
|
|
(_, true) => col.exported.adjustment_layers += 1,
|
|
_ => col.exported.solid_layers += 1,
|
|
}
|
|
Some(LayerPlan {
|
|
name,
|
|
src,
|
|
adjustment: layer.switches.adjustment,
|
|
label: label_index(layer.label),
|
|
quality: if layer.switches.quality == Quality::Draft { 1 } else { 2 },
|
|
start,
|
|
in_rel,
|
|
out_rel,
|
|
stretch,
|
|
time_scale,
|
|
blend,
|
|
parent,
|
|
matte,
|
|
markers,
|
|
ec_id: layer.id,
|
|
video: layer.switches.video,
|
|
audio: layer.switches.audio,
|
|
solo: layer.switches.solo,
|
|
locked: layer.switches.locked,
|
|
shy: layer.switches.shy,
|
|
collapse: layer.switches.collapse || is_shape,
|
|
effects: layer.switches.effects,
|
|
motion_blur: layer.switches.motion_blur,
|
|
guide: layer.switches.guide,
|
|
three_d: layer.switches.three_d || cam_light,
|
|
props,
|
|
masks,
|
|
fx,
|
|
source_size,
|
|
remap,
|
|
styles,
|
|
})
|
|
}
|
|
|
|
/// Whether any property below `g` has keyframes.
|
|
/// Whether a property of the group has a key with a bezier side.
|
|
fn has_bezier_keys(g: &effectcraft_project::PropGroup, depth: usize) -> bool {
|
|
if depth > 64 {
|
|
return true;
|
|
}
|
|
g.children.iter().any(|n| match n {
|
|
effectcraft_project::Node::Group(s) => has_bezier_keys(s, depth + 1),
|
|
effectcraft_project::Node::Prop(p) => {
|
|
p.keys.iter().any(|k| k.in_interp == effectcraft_keyframe::Interp::Bezier || k.out_interp == effectcraft_keyframe::Interp::Bezier)
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Whether showing the composition `to` inside `from` closes a loop (`to` is `from`, or uses it
|
|
/// through its own precomposition layers). The walk is iterative and visits each item once.
|
|
fn precomp_loop(project: &Project, from: ItemId, to: ItemId) -> bool {
|
|
let mut seen: HashSet<ItemId> = HashSet::new();
|
|
let mut stack = vec![to];
|
|
while let Some(at) = stack.pop() {
|
|
if at == from {
|
|
return true;
|
|
}
|
|
if !seen.insert(at) {
|
|
continue;
|
|
}
|
|
if let Some(ItemKind::Comp(c)) = project.item(at).map(|i| &i.kind) {
|
|
for l in &c.layers {
|
|
if let LayerSource::Comp { item } = &l.source {
|
|
stack.push(*item);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// The names of a layer's Material Options that differ from a new layer's (value, keys or an
|
|
/// expression); a property EffectCraft's defaults do not list counts as changed.
|
|
fn changed_material_options(layer: &Layer) -> Vec<String> {
|
|
let Some(g) = layer.props.sub("materialOptions") else { return Vec::new() };
|
|
let mut next = 0;
|
|
let defaults = effectcraft_project::build::material_options(&mut effectcraft_project::build::Ids(&mut next));
|
|
g.props()
|
|
.filter(|p| !p.keys.is_empty() || p.has_expression() || defaults.props().find(|d| d.match_id == p.match_id).is_none_or(|d| d.value != p.value))
|
|
.map(|p| p.name.clone())
|
|
.collect()
|
|
}
|
|
|
|
/// Whether a transform property outside the five carries something (a keyed or changed value).
|
|
fn is_non_default_extra(id: &str, p: &Property) -> bool {
|
|
if !p.keys.is_empty() || p.has_expression() {
|
|
return true;
|
|
}
|
|
let comps = p.value.components();
|
|
match id {
|
|
"orientation" | "rotation_x" | "rotation_y" | "x_rotation" | "y_rotation" | "orient" => comps.iter().any(|c| c.abs() > 1e-9),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// The layer a Transform property belongs to.
|
|
#[derive(Clone, Copy)]
|
|
struct PropAt<'a> {
|
|
comp: &'a Comp,
|
|
comp_name: &'a str,
|
|
layer: &'a str,
|
|
/// The layer's source size.
|
|
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, 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 {
|
|
// 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],
|
|
PropKind::Rotation => vec![0.0],
|
|
PropKind::Opacity => vec![1.0],
|
|
};
|
|
// The project's value in the stored unit.
|
|
let to_file = |c: &[f64], what: &str, col: &mut Collector| -> Vec<f64> {
|
|
let mut out = Vec::with_capacity(dims);
|
|
for i in 0..dims {
|
|
let raw = c.get(i).copied();
|
|
let fallback = if kind == PropKind::Scale { 100.0 } else { 0.0 };
|
|
let v = raw.and_then(sane).or(if raw.is_none() { Some(fallback) } else { None });
|
|
let v = match v {
|
|
Some(v) => v,
|
|
None => {
|
|
col.lose(LossCategory::InvalidValues, format!("{what}: a value that is not a valid number, the default was written"));
|
|
default.get(i).copied().map(|d| d * kind.unit()).unwrap_or(0.0)
|
|
}
|
|
};
|
|
let stored = match kind {
|
|
PropKind::Anchor => match i {
|
|
0 => v / sw,
|
|
1 => v / sh,
|
|
_ => v,
|
|
},
|
|
PropKind::Scale | PropKind::Opacity => v / 100.0,
|
|
_ => v,
|
|
};
|
|
out.push(stored);
|
|
}
|
|
out
|
|
};
|
|
let static_value = to_file(&p.value.components(), &what, col);
|
|
let expr = p.expr.as_ref().filter(|e| !e.text.is_empty()).map(|e| (e.text.clone(), e.enabled));
|
|
if expr.is_some() {
|
|
col.exported.expressions += 1;
|
|
}
|
|
let is_default = static_value.iter().zip(&default).all(|(a, b)| (a - b).abs() < 1e-12);
|
|
if p.keys.is_empty() {
|
|
if is_default && expr.is_none() {
|
|
return None;
|
|
}
|
|
return Some(PropPlan { kind, value: static_value, keys: Vec::new(), expr });
|
|
}
|
|
// Keys, sorted and unique in time units.
|
|
let mut keys: Vec<(i64, &Keyframe)> = Vec::with_capacity(p.keys.len());
|
|
for k in &p.keys {
|
|
let (u, inexact) = cp.tb.units(k.time);
|
|
if inexact {
|
|
col.lose(LossCategory::RoundedTimes, format!("{what}: key at {:.4} s moved to the time base", k.time.seconds()));
|
|
}
|
|
keys.push((u, k));
|
|
}
|
|
keys.sort_by_key(|(u, _)| *u);
|
|
let before = keys.len();
|
|
keys.dedup_by_key(|(u, _)| *u);
|
|
if keys.len() != before {
|
|
col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: keys at the same time were merged"));
|
|
}
|
|
let anchor = kind == PropKind::Anchor;
|
|
let mut plans = Vec::with_capacity(keys.len());
|
|
let all: Vec<Keyframe> = keys.iter().map(|(_, k)| (*k).clone()).collect();
|
|
let beyond = std::cell::Cell::new(false);
|
|
for (i, (u, k)) in keys.iter().enumerate() {
|
|
let Ok(time) = i32::try_from(*u) else {
|
|
col.lose(LossCategory::InvalidValues, format!("{what}: key time out of range, key dropped"));
|
|
continue;
|
|
};
|
|
let value = to_file(&k.value.components(), &what, col);
|
|
let side = |interp: Interp| -> u8 {
|
|
match interp {
|
|
Interp::Linear => 1,
|
|
Interp::Hold => 3,
|
|
Interp::Bezier => 2,
|
|
}
|
|
};
|
|
let in_i = side(k.in_interp);
|
|
let out_i = side(k.out_interp);
|
|
let slots = if kind.spatial() || dims == 1 { 1 } else { dims };
|
|
let ease_of = |eases: &[Ease], i: usize| -> EaseSlot {
|
|
let e = key_ease(eases, i);
|
|
// An Anchor Point stored as a fraction of the source has its spatial speed in pixels
|
|
// per second over the source height (597, 730), as the importer reads it.
|
|
let per = if anchor { sh } else { 1.0 };
|
|
let speed = sane(e.speed / kind.unit() / per).unwrap_or_else(|| {
|
|
beyond.set(true);
|
|
0.0
|
|
});
|
|
let influence = if e.influence.is_finite() { e.influence.clamp(0.001, 1.0) } else { 1.0 / 3.0 };
|
|
EaseSlot { speed, influence }
|
|
};
|
|
let in_ease: Vec<EaseSlot> = (0..slots).map(|s| if in_i == 2 { ease_of(&k.in_ease, s) } else { EaseSlot::default() }).collect();
|
|
let out_ease: Vec<EaseSlot> = (0..slots).map(|s| if out_i == 2 { ease_of(&k.out_ease, s) } else { EaseSlot::default() }).collect();
|
|
let (mut tin, mut tout) = if kind.spatial() { effectcraft_keyframe::spatial_tangents(&all, i) } else { ([0.0; 3], [0.0; 3]) };
|
|
for t in tin.iter_mut().chain(tout.iter_mut()) {
|
|
if sane(*t).is_none() {
|
|
beyond.set(true);
|
|
*t = 0.0;
|
|
}
|
|
}
|
|
if anchor {
|
|
for t in [&mut tin, &mut tout] {
|
|
t[0] /= sw;
|
|
t[1] /= sh;
|
|
}
|
|
}
|
|
plans.push(KeyPlan {
|
|
time,
|
|
value,
|
|
in_interp: in_i,
|
|
out_interp: out_i,
|
|
in_ease,
|
|
out_ease,
|
|
spatial_in: tin,
|
|
spatial_out: tout,
|
|
spatial_auto: k.spatial_auto,
|
|
roving: k.roving,
|
|
label: k.label.min(16),
|
|
});
|
|
col.exported.keyframes += 1;
|
|
}
|
|
report_beyond(beyond.get(), &what, col);
|
|
if plans.is_empty() {
|
|
return None;
|
|
}
|
|
let value = plans.first().map(|k| k.value.clone()).unwrap_or(static_value);
|
|
Some(PropPlan { kind, value, keys: plans, expr })
|
|
}
|
|
|
|
/// The Time Remap in the file's terms: a one-component value in seconds, keys in layer time
|
|
/// with the plain 48-byte records of a one-dimensional property (319, 354). An enabled remap
|
|
/// without keys is written as one key at layer time 0 holding its value: After Effects reads a
|
|
/// remap record without keys as switched off (r1, "Freeze": `tdsb` 1 with a static value came
|
|
/// 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, 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 });
|
|
if plan.keys.is_empty() {
|
|
plan.keys.push(KeyPlan {
|
|
time: 0,
|
|
value: plan.value.clone(),
|
|
in_interp: 1,
|
|
out_interp: 1,
|
|
in_ease: vec![EaseSlot::default()],
|
|
out_ease: vec![EaseSlot::default()],
|
|
spatial_in: [0.0; 3],
|
|
spatial_out: [0.0; 3],
|
|
spatial_auto: false,
|
|
roving: false,
|
|
label: 0,
|
|
});
|
|
col.exported.keyframes += 1;
|
|
}
|
|
plan
|
|
}
|