diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 5bff5cdb0..b463e79ea 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -129,6 +129,12 @@ configuration from these Apache-2.0 projects: - `crates/segment/src/mobilesam.rs` follows the MobileSAM, TinyViT and Segment Anything architectures and their Apache-2.0 reference code. +## Frozen matte regression fixture + +| Asset | Author | Source | Licence | +|---|---|---|---| +| `crates/engine/src/tests/fixtures/object-matte-0.22.ecproj` | lisn0 | Original work, synthetic project saved by EffectCraft 0.22.0 | MIT OR Apache-2.0 | + ## First-party brand marks The ArtCraft name, wordmark and logos are trademarks of the ArtCraft Team. They are not open source and are not diff --git a/Cargo.lock b/Cargo.lock index 37233ef58..a69346b9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1420,7 +1420,7 @@ dependencies = [ [[package]] name = "effectcraft" -version = "0.23.1" +version = "0.24.0" dependencies = [ "chrono", "cpal", @@ -1448,7 +1448,7 @@ dependencies = [ [[package]] name = "effectcraft-aep" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1462,7 +1462,7 @@ dependencies = [ [[package]] name = "effectcraft-automation" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-engine", "effectcraft-host", @@ -1476,14 +1476,14 @@ dependencies = [ [[package]] name = "effectcraft-av1enc" -version = "0.23.1" +version = "0.24.0" dependencies = [ "filmcraft-av1", ] [[package]] name = "effectcraft-cli" -version = "0.23.1" +version = "0.24.0" dependencies = [ "cpu-time", "effectcraft-automation", @@ -1502,7 +1502,7 @@ dependencies = [ [[package]] name = "effectcraft-color" -version = "0.23.1" +version = "0.24.0" dependencies = [ "proptest", "serde", @@ -1510,7 +1510,7 @@ dependencies = [ [[package]] name = "effectcraft-effects" -version = "0.23.1" +version = "0.24.0" dependencies = [ "chrono", "effectcraft-color", @@ -1528,7 +1528,7 @@ dependencies = [ [[package]] name = "effectcraft-engine" -version = "0.23.1" +version = "0.24.0" dependencies = [ "chrono", "craft-docpath", @@ -1567,7 +1567,7 @@ dependencies = [ [[package]] name = "effectcraft-export" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-av1enc", "effectcraft-color", @@ -1598,7 +1598,7 @@ dependencies = [ [[package]] name = "effectcraft-expr" -version = "0.23.1" +version = "0.24.0" dependencies = [ "boa_engine", "effectcraft-color", @@ -1615,7 +1615,7 @@ dependencies = [ [[package]] name = "effectcraft-geom" -version = "0.23.1" +version = "0.24.0" dependencies = [ "proptest", "serde", @@ -1623,7 +1623,7 @@ dependencies = [ [[package]] name = "effectcraft-gpu" -version = "0.23.1" +version = "0.24.0" dependencies = [ "bytemuck", "effectcraft-color", @@ -1646,7 +1646,7 @@ dependencies = [ [[package]] name = "effectcraft-helper-host" -version = "0.23.1" +version = "0.24.0" dependencies = [ "bytemuck", "effectcraft-effects", @@ -1657,14 +1657,14 @@ dependencies = [ [[package]] name = "effectcraft-hevcenc" -version = "0.23.1" +version = "0.24.0" dependencies = [ "filmcraft-hevc", ] [[package]] name = "effectcraft-host" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -1691,7 +1691,7 @@ dependencies = [ [[package]] name = "effectcraft-interchange" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-color", "effectcraft-keyframe", @@ -1710,7 +1710,7 @@ dependencies = [ [[package]] name = "effectcraft-keyframe" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1722,7 +1722,7 @@ dependencies = [ [[package]] name = "effectcraft-lottie" -version = "0.23.1" +version = "0.24.0" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1740,7 +1740,7 @@ dependencies = [ [[package]] name = "effectcraft-media" -version = "0.23.1" +version = "0.24.0" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1773,7 +1773,7 @@ dependencies = [ [[package]] name = "effectcraft-model" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-geom", "image", @@ -1786,14 +1786,14 @@ dependencies = [ [[package]] name = "effectcraft-opusenc" -version = "0.23.1" +version = "0.24.0" dependencies = [ "filmcraft-opus", ] [[package]] name = "effectcraft-path" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-geom", "effectcraft-keyframe", @@ -1805,7 +1805,7 @@ dependencies = [ [[package]] name = "effectcraft-pdf" -version = "0.23.1" +version = "0.24.0" dependencies = [ "craft-pdf", "effectcraft-raster", @@ -1821,7 +1821,7 @@ dependencies = [ [[package]] name = "effectcraft-plugin" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-effects", "effectcraft-keyframe", @@ -1833,7 +1833,7 @@ dependencies = [ [[package]] name = "effectcraft-project" -version = "0.23.1" +version = "0.24.0" dependencies = [ "craft-ecproj-format", "effectcraft-color", @@ -1847,14 +1847,14 @@ dependencies = [ [[package]] name = "effectcraft-psd" -version = "0.23.1" +version = "0.24.0" dependencies = [ "miniz_oxide 0.9.1", ] [[package]] name = "effectcraft-raster" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1864,7 +1864,7 @@ dependencies = [ [[package]] name = "effectcraft-render" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1887,7 +1887,7 @@ dependencies = [ [[package]] name = "effectcraft-sam2" -version = "0.23.1" +version = "0.24.0" dependencies = [ "bytemuck", "candle-core", @@ -1902,7 +1902,7 @@ dependencies = [ [[package]] name = "effectcraft-script" -version = "0.23.1" +version = "0.24.0" dependencies = [ "boa_engine", "effectcraft-engine", @@ -1920,7 +1920,7 @@ dependencies = [ [[package]] name = "effectcraft-segment" -version = "0.23.1" +version = "0.24.0" dependencies = [ "miniz_oxide 0.9.1", "ndarray", @@ -1929,7 +1929,7 @@ dependencies = [ [[package]] name = "effectcraft-svg" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-geom", "effectcraft-path", @@ -1941,7 +1941,7 @@ dependencies = [ [[package]] name = "effectcraft-text" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-keyframe", "harfrust", @@ -1953,7 +1953,7 @@ dependencies = [ [[package]] name = "effectcraft-time" -version = "0.23.1" +version = "0.24.0" dependencies = [ "proptest", "serde", @@ -1961,7 +1961,7 @@ dependencies = [ [[package]] name = "effectcraft-track" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-raster", "effectcraft-segment", @@ -1973,7 +1973,7 @@ dependencies = [ [[package]] name = "effectcraft-ui-egui" -version = "0.23.1" +version = "0.24.0" dependencies = [ "bytemuck", "chrono", @@ -2014,11 +2014,11 @@ dependencies = [ [[package]] name = "effectcraft-vp9enc" -version = "0.23.1" +version = "0.24.0" [[package]] name = "effectcraft-web" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -7835,7 +7835,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.23.1" +version = "0.24.0" dependencies = [ "chrono", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index 6f96cb012..18a92e409 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*"] [workspace.package] -version = "0.23.1" +version = "0.24.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/crates/aep/src/import/props.rs b/crates/aep/src/import/props.rs index fd0a4f9fc..8c331f1cb 100644 --- a/crates/aep/src/import/props.rs +++ b/crates/aep/src/import/props.rs @@ -231,15 +231,21 @@ pub(crate) fn import_transform( } import_expression(&node, prop, col); } - if let Some(PropNode::Other(_)) = group.get("ADBE Orientation") { + if let Some(PropNode::Other(otst)) = group.get("ADBE Orientation") { match (layer.orientation(), target.get_mut("orientation")) { - (Some(o), Some(prop)) if !o.animated && !o.has_expression && sane(&Value::Vec3(o.degrees)) => prop.value = Value::Vec3(o.degrees), + (Some(o), Some(prop)) if o.animated || sane(&Value::Vec3(o.degrees)) => { + if o.animated { + import_orientation_keys(layer, layer_name, tm, prop, col); + } else { + prop.value = Value::Vec3(o.degrees); + } + // The expression is stored in the `otst`'s `tdbs` as on any property (#3087). + if let Some(node) = otst.child_list(b"tdbs").and_then(AepProp::from_chunk) { + import_expression(&node, prop, col); + } + } // A layer without an orientation (an ambient light) loses nothing with the default. (Some(o), None) if !o.animated && !o.has_expression && o.degrees == [0.0; 3] => {} - (Some(o), Some(prop)) if o.animated && !o.has_expression => import_orientation_keys(layer, layer_name, tm, prop, col), - (Some(o), Some(_)) if o.has_expression => { - col.lose(LossCategory::TransformProperties, format!("{layer_name}: ADBE Orientation expression (not decoded)")); - } _ => col.lose(LossCategory::TransformProperties, format!("{layer_name}: ADBE Orientation")), } } diff --git a/crates/aep/src/import/report.rs b/crates/aep/src/import/report.rs index 454b2991b..f81d50af4 100644 --- a/crates/aep/src/import/report.rs +++ b/crates/aep/src/import/report.rs @@ -99,8 +99,12 @@ pub struct Loss { pub category: LossCategory, /// How many occurrences (exact). pub count: usize, - /// Up to [`MAX_DETAILS`] example lines. + /// Example lines: up to [`MAX_DETAILS`], and for effects with no counterpart one more line per + /// effect (with its number of instances). pub details: Vec, + /// How many of the `count` occurrences no line of `details` describes. + #[serde(default)] + pub unlisted: usize, } /// What arrived in the project. @@ -282,7 +286,7 @@ const MAX_UNMAPPED_EFFECTS: usize = 100; impl Collector { /// Records one loss. pub fn lose(&mut self, category: LossCategory, detail: impl Into) { - let loss = self.losses.entry(category).or_insert_with(|| Loss { category, count: 0, details: Vec::new() }); + let loss = self.losses.entry(category).or_insert_with(|| Loss { category, count: 0, details: Vec::new(), unlisted: 0 }); loss.count = loss.count.saturating_add(1); if loss.details.len() < MAX_DETAILS { loss.details.push(detail.into()); @@ -293,7 +297,8 @@ impl Collector { /// made in [`Collector::finish`], one per effect with the number of instances, so that many /// other losses cannot hide which effects were dropped. pub fn lose_unmapped_effect(&mut self, match_name: &str, detail: String) { - let loss = self.losses.entry(LossCategory::Effects).or_insert_with(|| Loss { category: LossCategory::Effects, count: 0, details: Vec::new() }); + let loss = + self.losses.entry(LossCategory::Effects).or_insert_with(|| Loss { category: LossCategory::Effects, count: 0, details: Vec::new(), unlisted: 0 }); loss.count = loss.count.saturating_add(1); if let Some(seen) = self.unmapped_effects.get_mut(match_name) { seen.1 = seen.1.saturating_add(1); @@ -304,12 +309,22 @@ impl Collector { } pub fn finish(mut self) -> ImportReport { + // Every line describes one occurrence, except an unmapped effect's, which describes all + // its instances. + for loss in self.losses.values_mut() { + loss.unlisted = loss.count.saturating_sub(loss.details.len()); + } if let Some(loss) = self.losses.get_mut(&LossCategory::Effects) { - let named = std::mem::take(&mut self.unmapped_effects).into_values().map(|(first, n)| match n { + // The most used effects first. + let mut named: Vec<(String, usize)> = std::mem::take(&mut self.unmapped_effects).into_values().collect(); + named.sort_by_key(|n| std::cmp::Reverse(n.1)); + let described = named.iter().fold(0usize, |sum, (_, n)| sum.saturating_add(*n)); + loss.unlisted = loss.unlisted.saturating_sub(described); + let lines = named.into_iter().map(|(first, n)| match n { 1 => first, _ => format!("{first}, and {} more like it", n - 1), }); - loss.details.splice(0..0, named); + loss.details.splice(0..0, lines); } ImportReport { imported: self.imported, @@ -356,6 +371,25 @@ mod tests { // One instance stays the single line it was. assert_eq!(named("(Pseudo/)"), ["C: 'D' has the effect 'Dropdown' (Pseudo/)"]); assert!(effects.details.len() <= MAX_DETAILS + 2, "the examples stay bounded"); + // 20 parameter lines, the cylinders' line and the dropdown's describe 46; 10 parameters + // have no line. + assert_eq!(effects.unlisted, 10); + // The most used effect comes first. + assert!(effects.details[0].contains("(CC Cylinder)"), "{:?}", effects.details); + } + + /// #3365: each category says how many of its occurrences have no example line. + #[test] + fn a_loss_counts_the_occurrences_without_a_line() { + let mut col = Collector::default(); + for i in 0..27 { + col.lose(LossCategory::Masks, format!("mask {i}")); + } + col.lose(LossCategory::ShapeLayers, "one"); + let report = col.finish(); + let unlisted = |c| report.losses.iter().find(|l| l.category == c).map(|l| (l.details.len(), l.unlisted)); + assert_eq!(unlisted(LossCategory::Masks), Some((MAX_DETAILS, 7))); + assert_eq!(unlisted(LossCategory::ShapeLayers), Some((1, 0))); } /// A file with more than [`MAX_UNMAPPED_EFFECTS`] different unmapped effects names that many @@ -370,5 +404,6 @@ mod tests { let effects = report.losses.iter().find(|l| l.category == LossCategory::Effects).unwrap(); assert_eq!(effects.count, MAX_UNMAPPED_EFFECTS + 1, "every instance is counted"); assert_eq!(effects.details.len(), MAX_UNMAPPED_EFFECTS, "{} lines", effects.details.len()); + assert_eq!(effects.unlisted, 1, "the effect past the limit has no line"); } } diff --git a/crates/aep/src/import/shapes.rs b/crates/aep/src/import/shapes.rs index 345346a6f..b86ee7492 100644 --- a/crates/aep/src/import/shapes.rs +++ b/crates/aep/src/import/shapes.rs @@ -457,18 +457,22 @@ fn shift_copy_offset(p: &mut effectcraft_project::Property, cx: &mut Cx<'_, '_>) } } -/// Reports a property whose only sampled value is the default 1. +/// Reports a property whose only sampled value is the default 1. A record in the default state +/// holds After Effects' default (docs/aep-format.md 3.4, state), so it holds nothing to lose. fn not_default(g: &AepGroup<'_, '_>, match_name: &str, what: &str, cx: &mut Cx<'_, '_>) { if let Some(PropNode::Property(p)) = g.get(match_name) + && stored(&p) && (p.is_animated() || p.expression().is_some() || p.static_value().and_then(|v| v.first().copied()).is_some_and(|v| v != 1.0)) { cx.col.lose(LossCategory::ShapeLayers, format!("{}: {what} not imported", cx.what)); } } -/// Reports a property whose only sampled value is 0. +/// Reports a property whose only sampled value is 0 (a record in the default state, as above, +/// holds nothing to lose). fn not_zero(g: &AepGroup<'_, '_>, match_name: &str, what: &str, cx: &mut Cx<'_, '_>) { if let Some(PropNode::Property(p)) = g.get(match_name) + && stored(&p) && (p.is_animated() || p.expression().is_some() || p.static_value().and_then(|v| v.first().copied()).is_some_and(|v| v != 0.0)) { cx.col.lose(LossCategory::ShapeLayers, format!("{}: {what} not imported", cx.what)); @@ -490,3 +494,41 @@ fn unknown(g: &AepGroup<'_, '_>, known: &[&str], what: &str, cx: &mut Cx<'_, '_> } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::Chunk; + + /// A group holding `match_name` as a one-component property in state `tdsb` with value `v`. + fn group_with(match_name: &str, tdsb: u32, v: f64) -> Chunk<'static> { + let mut name = match_name.as_bytes().to_vec(); + name.resize(40, 0); + let mut tdb4 = vec![0u8; 124]; + tdb4[3] = 1; + let tdbs = Chunk::list( + *b"tdbs", + vec![Chunk::data(*b"tdsb", tdsb.to_be_bytes().to_vec()), Chunk::data(*b"tdb4", tdb4), Chunk::data(*b"cdat", v.to_be_bytes().to_vec())], + ); + Chunk::list(*b"tdgp", vec![Chunk::data(*b"tdmn", name), tdbs]) + } + + fn reported(match_name: &str, tdsb: u32, v: f64, check: fn(&AepGroup<'_, '_>, &str, &str, &mut Cx<'_, '_>)) -> usize { + let chunk = group_with(match_name, tdsb, v); + let g = AepGroup::from_chunk(&chunk).unwrap(); + let tm = TimeMap { timescale: 30, frame_duration: 1, rate: crate::import::time::frame_rate(30, 1).unwrap() }; + let (mut next, mut col) = (1, Collector::default()); + check(&g, match_name, "it", &mut Cx { what: "L", tm: &tm, ids: &mut Ids(&mut next), col: &mut col }); + col.finish().losses.iter().map(|l| l.count).sum() + } + + /// #3087: a guide rectangle's Shape Direction recorded in the default state (`tdsb` 3, holding + /// 2) is After Effects' default and is no loss; written (`tdsb` 1) with 2, it is one. + #[test] + fn a_default_state_record_is_no_loss() { + assert_eq!(reported("ADBE Vector Shape Direction", 3, 2.0, not_default), 0); + assert_eq!(reported("ADBE Vector Shape Direction", 1, 2.0, not_default), 1); + assert_eq!(reported("ADBE Vector Grad HiLite Length", 3, 5.0, not_zero), 0); + assert_eq!(reported("ADBE Vector Grad HiLite Length", 1, 5.0, not_zero), 1); + } +} diff --git a/crates/aep/tests/it/import/synthetic.rs b/crates/aep/tests/it/import/synthetic.rs index f4d0894a9..0c9e79446 100644 --- a/crates/aep/tests/it/import/synthetic.rs +++ b/crates/aep/tests/it/import/synthetic.rs @@ -908,6 +908,48 @@ fn a_photoshop_layer_without_pixels_keeps_the_merged_image() { assert_eq!(the_footage(&p).layer, None); } +/// #3087: an expression on Orientation arrives with the stored value, as on any property; it was +/// dropped together with the value and reported as "not decoded". +#[test] +fn an_orientation_expression_is_imported_with_its_value() { + let bytes = mutate(&mut |c| { + if !c.is_list(b"otst") { + return; + } + for child in c.children_mut() { + if child.is_list(b"tdbs") { + let Body::List { children, .. } = &mut child.body else { continue }; + for k in children.iter_mut() { + if k.is(b"tdsb") { + set_data(k, 0, &1u32.to_be_bytes()); + } else if k.is(b"tdb4") { + set_data(k, 120, &[1]); + } + } + children.push(Chunk::data(*b"Utf8", b"[0, time * 90, 0]".to_vec())); + } else if child.is_list(b"otky") { + for k in child.children_mut() { + let degrees: Vec = [10.0f64, 20.0, 30.0].iter().flat_map(|d| d.to_be_bytes()).collect(); + set_data(k, 0, °rees); + } + } + } + }); + let (p, r) = run(&bytes, true); + assert_eq!(losses(&r.unwrap(), LossCategory::TransformProperties), Vec::::new()); + let mut seen = 0; + for item in p.items.values() { + let ItemKind::Comp(c) = &item.kind else { continue }; + for layer in &c.layers { + let Some(o) = layer.transform().and_then(|t| t.prop("orientation")).filter(|o| o.expr.is_some()) else { continue }; + assert_eq!(o.value, effectcraft_keyframe::Value::Vec3([10.0, 20.0, 30.0]), "{}", layer.name); + assert_eq!(o.expr.as_ref().map(|e| (e.text.as_str(), e.enabled)), Some(("[0, time * 90, 0]", true)), "{}", layer.name); + seen += 1; + } + } + assert!(seen > 0); +} + /// `SYNTHETIC` with Levels' Channel and Clip to Output Black / White popups' item names (`pdnm`) /// rewritten to the French labels a French-language After Effects stores there, keeping the same /// item order so the index each popup is set to is unaffected. diff --git a/crates/effects/src/perspective.rs b/crates/effects/src/perspective.rs index 69c63caf9..8f55bc408 100644 --- a/crates/effects/src/perspective.rs +++ b/crates/effects/src/perspective.rs @@ -394,7 +394,7 @@ pub fn specs() -> Vec { }; let render = || popup(&["Full", "Outside", "Inside"]); let mut cyl = vec![ - p("radius", "Radius", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("radius", "Radius", num(100.0), slider(0.0, 30000.0, 0.0, 200.0, 1)), p("position", "Position", pt(0.5, 0.5), ParamUi::Point), p("rotation", "Rotation", num(0.0), ParamUi::Angle), p("rotationX", "Rotation X", num(0.0), ParamUi::Angle), @@ -565,6 +565,23 @@ mod tests { assert_eq!(out.get(50 + 18, 10)[3], 0.0); } + /// #3368: Radius takes the range After Effects' CC Cylinder has, up to 30000 %, and a cylinder + /// that large still renders (nearly flat, fully opaque) instead of clamping to 400 %. + #[test] + fn cylinder_radius_goes_up_to_thirty_thousand_percent() { + let spec = find("ec.perspective.cccylinder").unwrap(); + let radius = spec.params.iter().find(|p| p.id == "radius").unwrap(); + assert!(matches!(radius.ui, ParamUi::Slider { min, max, .. } if min == 0.0 && max == 30000.0), "{:?}", radius.ui); + // The cylinder's axis on the left edge: at 400 % its radius is 400 / 100 * 100 / 2π ≈ 63.7 px, + // so a pixel 70 px along is past it there and inside the 30000 % one. + let img = Image::filled(100, 20, [0.5, 0.5, 0.5, 1.0]); + let at_edge = |radius: f64| run("ec.perspective.cccylinder", &img, &[("radius", num(radius)), ("position", Value::Vec2([0.0, 10.0]))]).img; + let clamped = at_edge(400.0).get(70, 10); + assert_eq!(clamped[3], 0.0, "beyond a 400 % cylinder: {clamped:?}"); + let px = at_edge(30000.0).get(70, 10); + assert!(px.iter().all(|v| v.is_finite()) && px[3] > 0.99, "{px:?}"); + } + #[test] fn cylinder_axis_rotations_affect_geometry_and_texture() { let img = Image::filled(100, 100, [0.5, 0.5, 0.5, 1.0]); diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index fca9d607e..f0275eab1 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -344,7 +344,9 @@ pub fn load_lut(src: &str) -> Option> { { return v.clone(); } - let inline = src.contains('\n') || src.contains("LUT_"); + // `.cube` text always spans lines (a size header, then data rows); a path never does, even one + // whose file name contains `LUT_` (#3454). Trimmed, as the path is, so a trailing newline is a path. + let inline = src.trim().contains('\n'); let parsed = if inline { parse_cube(src) } else { std::fs::read_to_string(src.trim()).ok().and_then(|t| parse_cube(&t)) }.map(Arc::new); if let Ok(mut m) = cache.lock() { m.insert(src.to_string(), parsed.clone()); @@ -484,6 +486,20 @@ mod tests { assert_eq!(out.img, img); } + /// #3454: a path containing `LUT_` was taken for `.cube` text and the file never loaded. + #[test] + fn a_path_whose_name_contains_lut_loads_the_file() { + let dir = std::env::temp_dir().join(format!("ec-fx-3454-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("LUT_x.cube"); + std::fs::write(&path, identity_cube(2)).unwrap(); + let path = path.to_string_lossy().into_owned(); + assert!(load_lut(&path).is_some(), "{path}"); + assert!(load_lut(&format!("{path}\r\n")).is_some(), "{path} with a trailing newline"); + assert!(load_lut(&identity_cube(2)).is_some(), "inline text"); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn one_d_lut_with_domain() { let lut = parse_cube("LUT_1D_SIZE 2\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 2 2 2\n1 1 1\n0 0 0\n").unwrap(); diff --git a/crates/engine/src/analysis/roto.rs b/crates/engine/src/analysis/roto.rs index 3fe049e20..246c8899c 100644 --- a/crates/engine/src/analysis/roto.rs +++ b/crates/engine/src/analysis/roto.rs @@ -219,7 +219,16 @@ fn fnv(s: &str) -> u64 { /// What the effect's input frames are made of: the layer's source and timing, its masks and the /// effects above the Roto Brush. pub fn input_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String { - let mut s = crate::warp::signature(p, comp, layer); + input_key_with_signature(crate::warp::signature(p, comp, layer), layer, uid) +} + +/// Recognize the input key serialized before 0.23 without re-keying its frozen matte. +fn input_key_before_placed_page(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> Option { + let signature = crate::warp::signature_before_placed_page(p, comp, layer)?; + Some(input_key_with_signature(signature, layer, uid)) +} + +fn input_key_with_signature(mut s: String, layer: &Layer, uid: Uid) -> String { if let Some(m) = layer.masks() { s.push_str(&format!("{m:?}")); } @@ -247,7 +256,9 @@ pub(crate) fn sync(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerI for g in fxg.groups().filter(|g| is_roto(g) || is_object_matte(g)) { let key = input_key(after, *cid, l, g.uid); let old = before.comp(*cid).and_then(|bc| bc.layer(l.id)).and_then(|ol| ol.props.find_group(g.uid)); - if str_of(g, INPUT_KEY) != key { + let saved_key = str_of(g, INPUT_KEY); + // Keep a matching old key: the frozen cache itself also hashes that key. + if saved_key != key && input_key_before_placed_page(after, *cid, l, g.uid).as_deref() != Some(saved_key.as_str()) { set.push((*cid, l.id, g.uid, key)); todo.push((*cid, l.id, g.uid)); } else if old.is_none_or(|o| guard_of(o) != guard_of(g)) { diff --git a/crates/engine/src/analysis/warp.rs b/crates/engine/src/analysis/warp.rs index 71ee45ee6..0ab576d2c 100644 --- a/crates/engine/src/analysis/warp.rs +++ b/crates/engine/src/analysis/warp.rs @@ -119,6 +119,20 @@ pub fn signature(p: &Project, comp: ItemId, layer: &Layer) -> String { }, _ => String::new(), }; + signature_with_source(p, comp, layer, &src) +} + +/// Before `Footage::placed_page` was added in 0.23, the persisted signature hashed the +/// same Debug representation without that last field. Only ordinary footage can match it. +pub(crate) fn signature_before_placed_page(p: &Project, comp: ItemId, layer: &Layer) -> Option { + let LayerSource::Footage { item } = &layer.source else { return None }; + let ItemKind::Footage(footage) = &p.item(*item)?.kind else { return None }; + let src = format!("{footage:?}"); + let old = src.strip_suffix(", placed_page: false }")?; + Some(signature_with_source(p, comp, layer, &format!("{old} }}"))) +} + +fn signature_with_source(p: &Project, comp: ItemId, layer: &Layer, src: &str) -> String { let rate = p.comp(comp).map(|c| format!("{:?}", c.frame_rate)).unwrap_or_default(); let s = format!( "{:?}|{src}|{:?}|{:?}|{:?}|{}|{:?}|{rate}", diff --git a/crates/engine/src/output_guard.rs b/crates/engine/src/output_guard.rs index 46c4356ed..73d938a32 100644 --- a/crates/engine/src/output_guard.rs +++ b/crates/engine/src/output_guard.rs @@ -509,12 +509,27 @@ impl Session { /// is not for an export to replace. For callers that write the file themselves; call it /// before the work, not after it. pub fn refuse_export(&self, path: &str) -> crate::Result<()> { + self.refuse_script_write(path)?; match self.blocking(path) { Some(source) => Err(crate::EngineError::Other(source.refusal(path, Use::Export))), None => Ok(()), } } + /// `Err` when a script issued the running command ([`Session::running_for_script`]) and + /// Settings ▸ Scripting & Expressions ▸ Allow Scripts to Write Files is off. The scripting + /// wrappers check this too, but a script can run any command directly (`app.run`, `__exec`), + /// so the commands that write a path they are given check here, through + /// [`Session::refuse_export`] and [`Session::write_output`]. + pub fn refuse_script_write(&self, path: &str) -> crate::Result<()> { + if self.running_for_script() && !self.prefs.scripting.allow_scripts_write_files { + return Err(crate::EngineError::Other(format!( + "Unable to write {path}: scripts can't write files unless Settings ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network is on" + ))); + } + Ok(()) + } + /// Write `bytes` to `path` through the host's services, unless the project reads the file /// there ([`Session::refuse_export`]). Every command that writes to a path it is given /// writes through this; Save and Save As (the project's own file) do not. @@ -525,6 +540,7 @@ impl Session { /// [`Session::write_output`] against files already found ([`Session::protected_files`]), /// for a command that writes many. pub(crate) fn write_output_with(&self, protected: &Protected, path: &str, bytes: &[u8]) -> crate::Result<()> { + self.refuse_script_write(path)?; if let Some(source) = protected.find(Path::new(path)) { return Err(crate::EngineError::Other(source.refusal(path, Use::Export))); } diff --git a/crates/engine/src/tests/fixtures/object-matte-0.22.ecproj b/crates/engine/src/tests/fixtures/object-matte-0.22.ecproj new file mode 100644 index 000000000..9a85aaa65 --- /dev/null +++ b/crates/engine/src/tests/fixtures/object-matte-0.22.ecproj @@ -0,0 +1,947 @@ +{ + "savedBy": "0.22.0", + "activeComp": 1, + "openComps": [ + 1 + ], + "schema": 2, + "settings": { + "bit_depth": "Bpc8", + "linearize": false, + "working_space": null, + "blend_linear": false, + "time_display": "Timecode", + "frame_start": 0, + "audio_sample_rate": 48000, + "gpu_acceleration": true, + "color_engine": "Adobe", + "hdr": "Clip", + "output_space": null, + "working_gamma": 2.2 + }, + "items": { + "1": { + "id": 1, + "name": "Main", + "label": "Sandstone", + "comment": "", + "parent": null, + "kind": { + "type": "Comp", + "width": 64, + "height": 48, + "pixel_aspect": 1.0, + "frame_rate": { + "num": 30, + "den": 1 + }, + "duration": 25401600000, + "display_start": 0, + "background": [ + 0.0, + 0.0, + 0.0 + ], + "work_area": [ + 0, + 25401600000 + ], + "layers": [ + { + "id": 3, + "name": "effectcraft-3430-subject.mp4", + "source": { + "type": "Footage", + "item": 2 + }, + "label": "Aqua", + "comment": "", + "start_time": 0, + "in_point": 0, + "out_point": 25401600000, + "stretch": 100.0, + "switches": { + "video": true, + "audio": true, + "solo": false, + "locked": false, + "shy": false, + "collapse": false, + "quality": "Best", + "sampling": "Bilinear", + "effects": true, + "frame_blend": "Off", + "motion_blur": false, + "adjustment": false, + "three_d": false, + "guide": false + }, + "blend_mode": "Normal", + "preserve_transparency": false, + "track_matte": null, + "parent": null, + "markers": [], + "auto_orient": "Off", + "props": { + "uid": 4, + "match": "layer", + "name": "effectcraft-3430-subject.mp4", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Group", + "uid": 5, + "match": "masks", + "name": "Masks", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [] + }, + { + "node": "Group", + "uid": 6, + "match": "effects", + "name": "Effects", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Group", + "uid": 26, + "match": "ec.matte.objectmatte", + "name": "Object Matte", + "kind": { + "kind": "Effect", + "effect": "ec.matte.objectmatte" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 27, + "match": "objectPoint", + "name": "Object Point", + "value": { + "t": "Vec2", + "v": [ + 32.0, + 24.0 + ] + }, + "ui": { + "kind": "Point" + }, + "spatial": true, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 28, + "match": "useBox", + "name": "Use Box", + "value": { + "t": "Bool", + "v": true + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 29, + "match": "boxTopLeft", + "name": "Box Top Left", + "value": { + "t": "Vec2", + "v": [ + 14.0, + 8.0 + ] + }, + "ui": { + "kind": "Point" + }, + "spatial": true, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 30, + "match": "boxBottomRight", + "name": "Box Bottom Right", + "value": { + "t": "Vec2", + "v": [ + 50.0, + 40.0 + ] + }, + "ui": { + "kind": "Point" + }, + "spatial": true, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Group", + "uid": 32, + "match": "objectTracking", + "name": "Tracking", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 31, + "match": "trackObject", + "name": "Track Object", + "value": { + "t": "Bool", + "v": true + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 33, + "match": "referenceFrame", + "name": "Reference Frame", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Number" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 34, + "match": "framesBefore", + "name": "Frames Before", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 600.0, + "slider_min": 0.0, + "slider_max": 120.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 35, + "match": "framesAfter", + "name": "Frames After", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 600.0, + "slider_min": 0.0, + "slider_max": 120.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Group", + "uid": 37, + "match": "objectMatte", + "name": "Matte", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 36, + "match": "feather", + "name": "Feather", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 100.0, + "slider_min": 0.0, + "slider_max": 20.0, + "decimals": 1 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 38, + "match": "contrast", + "name": "Contrast", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 100.0, + "slider_min": 0.0, + "slider_max": 100.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 39, + "match": "shiftEdge", + "name": "Shift Edge", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": -100.0, + "max": 100.0, + "slider_min": -100.0, + "slider_max": 100.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Prop", + "uid": 40, + "match": "invert", + "name": "Invert", + "value": { + "t": "Bool", + "v": false + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 41, + "match": "view", + "name": "View", + "value": { + "t": "Enum", + "v": 0 + }, + "ui": { + "kind": "Popup", + "options": [ + "Final Output", + "Matte" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 42, + "match": "inputKey", + "name": "Input Key", + "value": { + "t": "Str", + "v": "cbefe0c85977c4aa" + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 43, + "match": "frozen", + "name": "Frozen", + "value": { + "t": "Str", + "v": "{\"key\":\"1a097b69366afd7f\",\"size\":[64,48],\"frames\":{\"0\":{\"a\":\"gBgAkAX/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IACQBQ==\",\"b\":\"AA==\"}}}" + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + } + ] + }, + { + "node": "Group", + "uid": 15, + "match": "transform", + "name": "Transform", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 7, + "match": "anchor", + "name": "Anchor Point", + "value": { + "t": "Vec3", + "v": [ + 32.0, + 24.0, + 0.0 + ] + }, + "ui": { + "kind": "Point" + }, + "spatial": true, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 2, + "static_only": false + }, + { + "node": "Prop", + "uid": 8, + "match": "position", + "name": "Position", + "value": { + "t": "Vec3", + "v": [ + 32.0, + 24.0, + 0.0 + ] + }, + "ui": { + "kind": "Point" + }, + "spatial": true, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 2, + "static_only": false + }, + { + "node": "Prop", + "uid": 9, + "match": "scale", + "name": "Scale", + "value": { + "t": "Vec3", + "v": [ + 100.0, + 100.0, + 100.0 + ] + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 2, + "static_only": false + }, + { + "node": "Prop", + "uid": 10, + "match": "orientation", + "name": "Orientation", + "value": { + "t": "Vec3", + "v": [ + 0.0, + 0.0, + 0.0 + ] + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": true, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 11, + "match": "rotationX", + "name": "X Rotation", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": true, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 12, + "match": "rotationY", + "name": "Y Rotation", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": true, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 13, + "match": "rotation", + "name": "Rotation", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 14, + "match": "opacity", + "name": "Opacity", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 100.0, + "slider_min": 0.0, + "slider_max": 100.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Group", + "uid": 16, + "match": "materialOptions", + "name": "Material Options", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 17, + "match": "castsShadows", + "name": "Casts Shadows", + "value": { + "t": "Enum", + "v": 0 + }, + "ui": { + "kind": "Popup", + "options": [ + "Off", + "On", + "Only" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 18, + "match": "lightTransmission", + "name": "Light Transmission", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 19, + "match": "acceptsShadows", + "name": "Accepts Shadows", + "value": { + "t": "Enum", + "v": 1 + }, + "ui": { + "kind": "Popup", + "options": [ + "Off", + "On", + "Only" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 20, + "match": "acceptsLights", + "name": "Accepts Lights", + "value": { + "t": "Bool", + "v": true + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 21, + "match": "ambient", + "name": "Ambient", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 22, + "match": "diffuse", + "name": "Diffuse", + "value": { + "t": "Scalar", + "v": 50.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 23, + "match": "specularIntensity", + "name": "Specular Intensity", + "value": { + "t": "Scalar", + "v": 50.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 24, + "match": "specularShininess", + "name": "Specular Shininess", + "value": { + "t": "Scalar", + "v": 5.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 25, + "match": "metal", + "name": "Metal", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + } + ] + } + } + ], + "markers": [], + "shutter_angle": 180.0, + "shutter_phase": -90.0, + "motion_blur_samples": 16, + "motion_blur_adaptive_limit": 128, + "renderer": "Classic3D", + "hide_shy": false, + "enable_motion_blur": true, + "enable_frame_blending": true, + "draft_3d": false, + "preserve_frame_rate": false, + "preserve_resolution": false, + "poster_time": 0, + "global_light": { + "angle": { + "uid": 0, + "match": "globalLightAngle", + "name": "Global Light Angle", + "value": { + "t": "Scalar", + "v": 120.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + "altitude": { + "uid": 0, + "match": "globalLightAltitude", + "name": "Global Light Altitude", + "value": { + "t": "Scalar", + "v": 30.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + } + } + }, + "2": { + "id": 2, + "name": "effectcraft-3430-subject.mp4", + "label": "Aqua", + "comment": "", + "parent": null, + "kind": { + "type": "Footage", + "path": "/tmp/effectcraft-3430-subject.mp4", + "kind": "Video", + "width": 64, + "height": 48, + "pixel_aspect": 1.0, + "frame_rate": { + "num": 30, + "den": 1 + }, + "native_rate": null, + "duration": 25401600000, + "has_video": true, + "has_audio": false, + "alpha": "Ignore", + "premul_color": [ + 0.0, + 0.0, + 0.0 + ], + "loop_count": 1, + "codec": "H.264 (High)", + "missing": false, + "color_profile": "Rec709" + } + } + }, + "next_id": 44 +} \ No newline at end of file diff --git a/crates/engine/src/tests/fixtures/object-matte-0.22.ecproj.attribution b/crates/engine/src/tests/fixtures/object-matte-0.22.ecproj.attribution new file mode 100644 index 000000000..c20c8b02c --- /dev/null +++ b/crates/engine/src/tests/fixtures/object-matte-0.22.ecproj.attribution @@ -0,0 +1,8 @@ +asset: object-matte-0.22.ecproj +title: One frozen Object Matte from EffectCraft 0.22.0 +author: lisn0 +source: original work +license: MIT OR Apache-2.0 +license-file: ../../../../../LICENSE-MIT +added: 2026-11-11 by lisn0 +notes: Saved by the released 0.22.0 CLI using a generated 64x48 orange rectangle on a blue background. Contains one frozen alpha plane. The source path is a generic test path, and the source video is unnecessary for the state-preservation regression test. diff --git a/crates/engine/src/tests/roto.rs b/crates/engine/src/tests/roto.rs index 99c155265..e751681c6 100644 --- a/crates/engine/src/tests/roto.rs +++ b/crates/engine/src/tests/roto.rs @@ -603,6 +603,35 @@ fn object_matte_background_job_cancel_and_freeze() { assert_eq!(status(&mut s)["frozen"], true); } +/// A queue edit preserves a frozen matte made before the footage signature gained placed_page. +#[test] +fn render_queue_keeps_frozen_object_matte_with_legacy_footage_key() { + use effectcraft_effects::roto::{FROZEN, INPUT_KEY}; + use effectcraft_keyframe::Value::Str; + + hold_roto_cache_test_lock(); + // Saved by the released 0.22.0 CLI, independently of the compatibility helper. + let project = effectcraft_project::Project::from_json(include_str!("fixtures/object-matte-0.22.ecproj")).unwrap(); + let mut s = Session::default(); + s.replace_project(project, None); + let cid = effectcraft_project::ItemId(1); + let clip = LayerId(3); + let uid = 26; + s.open_comp(cid); + let params = |s: &Session| crate::roto::params_static(s.project.comp(cid).unwrap().layer(clip).unwrap().props.find_group(uid).unwrap()); + let legacy = params(&s).get(INPUT_KEY).unwrap().clone(); + let saved = params(&s).get(FROZEN).unwrap().clone(); + let layer = s.project.comp(cid).unwrap().layer(clip).unwrap(); + assert_ne!(legacy, Str(crate::roto::input_key(&s.project, cid, layer, uid))); + + s.execute_checked("renderQueue.add", json!({"comp": cid.0, "format": "png", "output": "matte.png"})).unwrap(); + assert_eq!(params(&s).get(INPUT_KEY), Some(&legacy)); + assert_eq!(params(&s).get(FROZEN), Some(&saved)); + + s.execute_checked("layer.timing", json!({"layer": clip.0, "out": 0.05})).unwrap(); + assert_eq!(params(&s).get(FROZEN), Some(&Str(String::new()))); +} + /// Computed Object Matte segmentations go to the persistent disk cache: a second session on the /// same folder and project gets them back without segmenting (its frames cannot even be read /// here), and another prompt (other keys) finds nothing. diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 21b7501fa..48cb07ac4 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -122,6 +122,11 @@ pub struct JobOptions<'a> { /// Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once: 1 to 3, or `None` for Auto (from /// the device memory a frame measured, #3335). `EFFECTCRAFT_GPU_INFLIGHT` overrides it. pub gpu_frames: Option, + /// Reads the bytes of memory the system can hand out now (`None`: unknown), for Auto's CPU + /// frames in flight (#1615): read at most once per job, when a job renders on the CPU after + /// a GPU frame measured a frame's working memory. `None`: CPU frames are capped only by + /// `EFFECTCRAFT_CPU_INFLIGHT`. + pub free_memory: Option<&'a (dyn Fn() -> Option + Sync)>, } /// The layer caches of a Render Queue run (see [`JobOptions::caches`]): one per compositor, diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 922b1b4ea..d447e0980 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -137,6 +137,17 @@ pub(crate) fn cpu_inflight(var: Option<&str>) -> Option { } } +/// Auto's CPU frames in flight (#1615): as many frames as half of the `free` bytes the system +/// can hand out hold at `frame` bytes each, 1 to `threads`. `frame` is a GPU frame's measured +/// working memory: both compositors keep 16-byte RGBA pixels (`[f32; 4]`, `Rgba32Float`), and +/// the other half is headroom for the layer caches, decoded footage, the encoder and the +/// estimate being low. `None` (not capped) when either is unknown or `frame` is 0. +pub(crate) fn auto_cpu_frames(frame: Option, free: Option, threads: usize) -> Option { + let (frame, free) = (frame.filter(|&f| f > 0)?, free?); + let n = free / 2 / frame; + Some(usize::try_from(n).unwrap_or(usize::MAX).clamp(1, threads.max(1))) +} + /// The frames-in-flight caps of a batch: `gpu` (#879) for the GPU's memory, `cpu` (#1615) for /// the process's. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -253,6 +264,9 @@ pub(crate) struct Cx<'a> { gpu_frames: Mutex>, /// A frame has rendered on the GPU compositor: its memory can be measured. gpu_rendered: std::sync::atomic::AtomicBool, + /// Auto's CPU frames in flight once settled ([`Cx::auto_cpu_frames`]; `Some(None)`: not + /// capped). + cpu_frames: Mutex>>, /// (output frame, seconds) of every rendered frame (Plus Per Frame Info logs). pub frame_times: Option>>, /// Files written to an overflow folder. @@ -337,6 +351,7 @@ impl<'a> Cx<'a> { choice: Mutex::new(None), gpu_frames: Mutex::new(None), gpu_rendered: Default::default(), + cpu_frames: Mutex::new(None), frame_times, overflowed: Mutex::new(vec![]), written: Mutex::new(vec![]), @@ -555,9 +570,11 @@ impl<'a> Cx<'a> { let timed = |on: crate::JobRenderer, frames: &[u64]| { let warm = render(first, on); // The GPU's frames in flight settle with its warm-up frame (Auto measures it). + // The CPU's frames are held to what the job would render at once on it, from the + // GPU frames just measured (#1615): uncapped, they could exhaust RAM here already. let caps = match on { crate::JobRenderer::Gpu => Inflight { gpu: Some(self.gpu_inflight()), cpu: cpu_cap }, - crate::JobRenderer::Cpu => Inflight { gpu: None, cpu: cpu_cap }, + crate::JobRenderer::Cpu => Inflight { gpu: None, cpu: cpu_cap.or_else(|| self.auto_cpu_frames()) }, }; let t0 = Instant::now(); let gpu_live = || on == crate::JobRenderer::Cpu || self.job.accel.is_some_and(|a| a.available()); @@ -660,7 +677,48 @@ impl<'a> Cx<'a> { /// The GPU and CPU frames-in-flight caps every frame loop shares (#1615). fn inflight(&self) -> Inflight { - Inflight { gpu: self.gpu_cap(), cpu: cpu_inflight(std::env::var(CPU_INFLIGHT_ENV).ok().as_deref()) } + Inflight { gpu: self.gpu_cap(), cpu: self.cpu_cap() } + } + + /// CPU frames in flight (#1615): `EFFECTCRAFT_CPU_INFLIGHT`, else Auto's + /// ([`Cx::auto_cpu_frames`]) while the job renders on the CPU (it chose the CPU's + /// compositor, or the GPU stopped for good). `None`: not capped. + fn cpu_cap(&self) -> Option { + if let Some(n) = cpu_inflight(std::env::var(CPU_INFLIGHT_ENV).ok().as_deref()) { + return Some(n); + } + let on_cpu = self.backend() == crate::JobRenderer::Cpu || self.job.accel.is_some_and(|a| !a.available()); + if on_cpu { self.auto_cpu_frames() } else { None } + } + + /// Auto's CPU frames in flight ([`auto_cpu_frames`]), settled once per job from the working + /// memory a GPU frame measured and the memory the system has free + /// ([`crate::JobOptions::free_memory`]). `None` (not capped, as before #1615) until a GPU + /// frame has rendered, and in a job that never renders one (a Software Rendering project): + /// there is no per-frame estimate then. + fn auto_cpu_frames(&self) -> Option { + if let Some(n) = *self.cpu_frames.lock().unwrap_or_else(std::sync::PoisonError::into_inner) { + return n; + } + if !self.gpu_rendered.load(std::sync::atomic::Ordering::Relaxed) { + return None; + } + // Read outside the lock: the allocator report walks every allocation, and reading the + // system's memory can take a second (PowerShell on Windows). + let frame = self.job.accel.and_then(|a| a.frame_memory()).map(|m| m.frame); + let free = self.job.options.free_memory.and_then(|read| read()); + let n = auto_cpu_frames(frame, free, rayon::current_num_threads()); + let mut slot = self.cpu_frames.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + *slot.get_or_insert_with(|| { + let mib = |b: Option| b.map_or_else(|| "unknown".to_string(), |b| format!("{} MiB", b >> 20)); + let (frame, free) = (mib(frame), mib(free)); + let label = &self.job.options.label; + match n { + Some(n) => log::info!(target: "effectcraft_export", "{label}: {n} CPU frame(s) at once (Auto: {frame} per frame on the GPU, {free} free)"), + None => log::info!(target: "effectcraft_export", "{label}: CPU frames not capped (Auto: {frame} per frame on the GPU, {free} free)"), + } + n + }) } /// Output frame `i`: sampled (fields, pulldown), at the colour depth, cropped and resized. @@ -1285,6 +1343,75 @@ mod tests { assert_eq!(describe_choice(&cx.choice().unwrap()), "Software Rendering (measured on the first frames: GPU 117.0 ms/frame, CPU 68.0 ms/frame)"); } + #[test] + fn auto_cpu_frames_fit_half_the_free_memory() { + // #1615: 16 threads at 1378 MiB a frame (a GTX 1050 Ti user's comp) with 10 GiB free + // render 3 frames at once, not 16. + assert_eq!(auto_cpu_frames(Some(1378 << 20), Some(10 << 30), 16), Some(3)); + assert_eq!(auto_cpu_frames(Some(1 << 28), Some(64 << 30), 16), Some(16), "at most one per thread"); + assert_eq!(auto_cpu_frames(Some(4 << 30), Some(2 << 30), 16), Some(1), "at least one"); + assert_eq!(auto_cpu_frames(Some(1 << 30), Some(0), 8), Some(1)); + assert_eq!(auto_cpu_frames(Some(1 << 20), Some(u64::MAX), 0), Some(1)); + // Unknown: not capped, as before. + assert_eq!(auto_cpu_frames(None, Some(8 << 30), 16), None); + assert_eq!(auto_cpu_frames(Some(1 << 30), None, 16), None); + assert_eq!(auto_cpu_frames(Some(0), Some(8 << 30), 16), None); + } + + #[test] + fn a_job_that_chose_the_cpu_after_measuring_the_gpu_caps_its_frames() { + // #1615: a GPU project that measured the CPU faster rendered one frame per thread at + // once, so a heavy comp ran out of RAM (an exit without an error, 0.23.1 on Windows). + // Its frames now fit half the free memory at the GPU's measured size per frame. + use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings}; + if std::env::var_os(CPU_INFLIGHT_ENV).is_some() || std::env::var_os(RENDERER_ENV).is_some() { + return; + } + let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::PngSequence)); + let mut p = Project::default(); + p.settings.gpu_acceleration = true; + let comp = Comp::new(16, 8, effectcraft_time::FrameRate::FPS_25, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", effectcraft_color::Label::Sandstone, None, effectcraft_project::ItemKind::Comp(comp.into())); + let accel = MemAccel(effectcraft_render::FrameMemory { budget: 3 << 30, peak: 2 << 30, frame: 1 << 30, reserve: 0 }); + let reads = AtomicUsize::new(0); + let free = || { + reads.fetch_add(1, Ordering::SeqCst); + Some(6u64 << 30) + }; + let job = Job { + project: &p, + footage: &effectcraft_render::NoFootage, + expr: None, + accel: Some(&accel), + comp: cid, + settings: &settings, + output: &output, + path: "/c/f_[#####].png", + sink: None, + nested_switches: true, + options: crate::JobOptions { free_memory: Some(&free), ..Default::default() }, + }; + let pool = rayon::ThreadPoolBuilder::new().num_threads(16).build().unwrap(); + pool.install(|| { + let cx = Cx::new(&job); + assert_eq!(cx.inflight().cpu, None, "nothing measured yet"); + let c = cx.comp().unwrap(); + cx.frame_on(c, 0, crate::JobRenderer::Gpu); + assert_eq!(cx.inflight().cpu, None, "the GPU renders: its own cap holds"); + assert_eq!(reads.load(Ordering::SeqCst), 0, "the free memory is read only for CPU frames"); + cx.decide(Choice { renderer: crate::JobRenderer::Cpu, gpu_ms: Some(1417.3), cpu_ms: Some(655.8), why: "measured on the first frames" }); + assert_eq!(cx.inflight(), Inflight { gpu: None, cpu: Some(3) }, "3 GiB of 6 GiB free at 1 GiB a frame"); + assert_eq!(cx.inflight().cpu, Some(3)); + assert_eq!(reads.load(Ordering::SeqCst), 1, "settled once per job"); + }); + // Without a free-memory reading the job renders uncapped, as before. + let job = Job { options: Default::default(), ..job }; + let cx = Cx::new(&job); + cx.frame_on(cx.comp().unwrap(), 0, crate::JobRenderer::Gpu); + cx.decide(Choice { renderer: crate::JobRenderer::Cpu, gpu_ms: Some(2.0), cpu_ms: Some(1.0), why: "measured on the first frames" }); + assert_eq!(cx.inflight().cpu, None); + } + #[test] fn cpu_inflight_is_off_unless_a_positive_number() { assert_eq!(cpu_inflight(None), None); diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 3d1384a90..0233f834c 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -65,6 +65,7 @@ impl Exporter for FileExporter { caches: job.caches.map(|c| effectcraft_export::JobCaches { gpu: &c.gpu, cpu: &c.cpu }), renderer: job.renderer, gpu_frames: job.gpu_frames, + free_memory: Some(&free_memory), }, nested_switches: job.nested_switches, }; @@ -87,6 +88,11 @@ impl Exporter for FileExporter { } } +/// The memory the system can hand out now, for an export's Auto CPU frames in flight (#1615). +fn free_memory() -> Option { + effectcraft_engine::sysinfo::memory().map(|m| m.available) +} + /// A new session with media, import, expressions, scripting and export enabled. pub fn session() -> Session { let mut s = Session { diff --git a/crates/host/src/tests.rs b/crates/host/src/tests.rs index 55240698f..687aafe77 100644 --- a/crates/host/src/tests.rs +++ b/crates/host/src/tests.rs @@ -547,6 +547,7 @@ fn script_imports_footage_and_saves() { std::fs::create_dir_all(&dir).unwrap(); let q = |n: &str| serde_json::to_string(&dir.join(n).to_string_lossy()).unwrap(); let mut s = super::session(); + s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap(); let code = format!( r#" var c = app.project.items.addComp("Src", 32, 32, 1, 1, 10); diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 0c39c4dfe..37910bf9e 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -867,6 +867,12 @@ Property.prototype.property = function () { return null; }; if (!idx) return null; return this.__layer.containingComp.layer(idx).__id; } + // A string parameter (a LUT or look file, a text field) takes the text, or a File's path (#3395). + // A hidden one (Mesh Warp's mesh) stays refused by the key setters too, as by setValue. + if (info.type === "string") { + if (info.hidden) throw __err("\"" + info.name + "\" can not be set"); + return __str(v); + } if (info.type === "enum") return __num(v) - 1; if (info.type === "bool") return typeof v === "boolean" ? v : __num(v) !== 0; if (info.type === "color") { @@ -1652,7 +1658,7 @@ CompItem.prototype.constructor = CompItem; P.saveFrameToPng = function (time, file) { var f = __str(file); if (!f) throw __err("saveFrameToPng: missing file"); - __file("write", f, ""); + __file("checkWrite", f); __call("comp.saveFrameAs", { comp: this.__id, path: f, time: __num(time, "time") }); }; P.openInEssentialGraphics = function () { __call("comp.openInEssentialGraphics", { comp: this.__id }); }; @@ -1662,8 +1668,8 @@ CompItem.prototype.constructor = CompItem; if (!/\.[A-Za-z0-9]+$/.test(f)) f += ".ectemplate"; if (!overwrite && JSON.parse(__file("exists", f)) === true) return false; if (this.__controllers().length === 0) return false; - // The file gate applies to templates too. - __file("write", f, ""); + // The file gate applies to templates too (checked without touching the file). + __file("checkWrite", f); __call("essential.exportTemplate", { comp: this.__id, path: f }); return true; }; diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index c23eaefb0..830e250e9 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -427,6 +427,8 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult check_access(s, &path, true).map(|_| json!(true)), "read" | "readBinary" => { let p = check_access(s, &path, false)?; let bytes = s.services.read_file(&p.to_string_lossy()).map_err(|e| format!("Unable to read {path}: {e}"))?; diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 78a16e5d3..c7cd8730c 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -700,6 +700,56 @@ fn save_frame_to_png_writes_behind_the_file_gate() { let _ = std::fs::remove_dir_all(&dir); } +/// A script that runs the export commands itself (`app.run`, `__exec`) meets the same file-write +/// preference as the wrappers; with the preference off nothing is created or replaced. +#[test] +fn export_commands_run_directly_by_a_script_respect_the_file_gate() { + let dir = std::env::temp_dir().join(format!("ec-direct-export-{}", std::process::id())); + // A leftover dir from an interrupted run (same pid) must not feed stale absence checks. + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let png = dir.join("frame.png"); + let tpl = dir.join("card.ectemplate"); + let kept = dir.join("kept.png"); + std::fs::write(&kept, b"original").unwrap(); + let mut s = session(); + s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "duration": 1})).unwrap(); + s.execute("layer.newText", json!({"text": "Hi"})).unwrap(); + ok(&mut s, "var c = app.project.activeItem; c.layer(1).property('ADBE Transform Group').property('ADBE Opacity').addToMotionGraphicsTemplate(c);"); + let q = |p: &std::path::Path| serde_json::to_string(&p.to_string_lossy()).unwrap(); + let comp = s.active_comp_id().unwrap().0; + for (code, target) in [ + (format!("app.run('comp.saveFrameAs', {{comp: {comp}, path: {}}})", q(&png)), &png), + (format!("__exec('comp.saveFrameAs', JSON.stringify({{comp: {comp}, path: {}}}))", q(&png)), &png), + (format!("app.run('essential.exportTemplate', {{comp: {comp}, path: {}}})", q(&tpl)), &tpl), + (format!("app.run('comp.saveFrameAs', {{comp: {comp}, path: {}}})", q(&kept)), &kept), + ] { + let o = run_code(&mut s, &code, "direct.jsx"); + let e = o.error.unwrap_or_else(|| panic!("`{code}` was not refused")); + assert!(e.message.contains("Allow Scripts to Write Files"), "{e:?}"); + if target == &kept { + assert_eq!(std::fs::read(&kept).unwrap(), b"original"); + } else { + assert!(!target.exists(), "{} was written", target.display()); + } + } + // The wrappers check the gate without touching the file: an existing one is left as it was. + let o = run_code(&mut s, &format!("app.project.activeItem.saveFrameToPng(0, new File({}))", q(&kept)), "wrap.jsx"); + assert!(o.error.is_some()); + assert_eq!(std::fs::read(&kept).unwrap(), b"original"); + let o = run_code(&mut s, &format!("app.project.activeItem.exportAsMotionGraphicsTemplate(true, {})", q(&tpl)), "wrap.jsx"); + assert!(o.error.is_some()); + assert!(!tpl.exists()); + // The user, from the UI, is not a script: the same command writes. + s.execute("comp.saveFrameAs", json!({"comp": comp, "path": png.to_string_lossy()})).unwrap(); + assert!(png.exists()); + // With the preference on, the script's direct call writes too. + s.prefs.scripting.allow_scripts_write_files = true; + ok(&mut s, &format!("app.run('essential.exportTemplate', {{comp: {comp}, path: {}}})", q(&tpl))); + assert!(std::fs::metadata(&tpl).unwrap().len() > 100); + let _ = std::fs::remove_dir_all(&dir); +} + /// #1246: with Warn User When Executing Files on, a script that asserts `confirmed` itself still /// gets the question; only the user's answer opens the file. #[test] @@ -1949,3 +1999,32 @@ fn setting_comp_width_and_height_leaves_the_layers_where_they_are() { let (_, c) = comp_named(&s, "w"); assert_eq!(layer(c, "s").props.prop("transform/position").unwrap().value, KV::Vec3([100.0, 100.0, 0.0])); } + +/// #3395: setValue on a string effect parameter stores the text, or a File's path, as `prop.set` +/// does. Before, every string fell through to the number check ("value must be a number"). +/// Hidden string parameters (Mesh Warp's Distortion Mesh) stay refused, keys included. +#[test] +fn set_value_stores_a_string_parameter() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Str", 64, 36, 1, 1, 24); + var l = c.layers.addSolid([1, 0, 0], "s", 64, 36, 1); + var fx = l.property("ADBE Effect Parade"); + var lut = fx.addProperty("Apply Color LUT").property("LUT"); + lut.setValue("/luts/look.cube"); + var fromString = lut.value; + lut.setValue(new File("/luts/other.cube")); + var mesh = fx.addProperty("Mesh Warp").property("Distortion Mesh"); + function attempt(f) { try { f(); return "no error"; } catch (e) { return e.message; } } + var hidden = attempt(function () { mesh.setValue("x"); }); + var keyed = attempt(function () { mesh.setValueAtTime(0, "x"); }); + [lut.propertyValueType === PropertyValueType.CUSTOM_VALUE, fromString, lut.value, hidden, keyed, mesh.numKeys] + "#, + ); + let refused = "\"Distortion Mesh\" can not be set"; + assert_eq!(o.result, json!([true, "/luts/look.cube", "/luts/other.cube", refused, refused, 0])); + let (_, c) = comp_named(&s, "Str"); + assert_eq!(layer(c, "s").props.prop("effects/#1/lut").unwrap().value, KV::Str("/luts/other.cube".into())); +} diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 1a162dab3..b2e0b3631 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -245,6 +245,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Import Adobe After Effects Project", "Adobe After Effectsプロジェクトを読み込み"), ("Some parts of this project cannot be imported exactly (left out or changed):", "このプロジェクトの一部は正確に読み込めません(省略または変更されます):"), ("Import Anyway", "このまま読み込み"), + ("{}: {} left out or changed", "{}:{} 件が省略または変更"), + ("… and {} more", "…ほか {} 件"), ("Casts Shadows", "シャドウを落とす"), ("Change Renderer…", "レンダラーを変更…"), ( @@ -1386,6 +1388,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Import Adobe After Effects Project", "导入 Adobe After Effects 项目"), ("Some parts of this project cannot be imported exactly (left out or changed):", "此项目的某些部分无法准确导入(将被省略或更改):"), ("Import Anyway", "仍然导入"), + ("{}: {} left out or changed", "{}:{} 项被省略或更改"), + ("… and {} more", "…以及另外 {} 项"), ("Casts Shadows", "投射阴影"), ("Change Renderer…", "更改渲染器…"), ("Changing a shortcut creates a custom preset; the default stays as it is.", "修改快捷键会创建自定义预设;默认预设保持不变。"), @@ -2505,6 +2509,8 @@ const SWEDISH: &[(&str, &str)] = &[ "Vissa delar av projektet kan inte importeras exakt (utelämnas eller ändras):", ), ("Import Anyway", "Importera ändå"), + ("{}: {} left out or changed", "{}: {} utelämnas eller ändras"), + ("… and {} more", "… och {} till"), ("Casts Shadows", "Kastar skuggor"), ("Change Renderer…", "Byt renderare…"), ( @@ -3778,6 +3784,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Import Adobe After Effects Project", "匯入 Adobe After Effects 專案"), ("Some parts of this project cannot be imported exactly (left out or changed):", "此專案的部分內容無法準確匯入(將被省略或變更):"), ("Import Anyway", "仍要匯入"), + ("{}: {} left out or changed", "{}:{} 項被省略或變更"), + ("… and {} more", "…以及另外 {} 項"), ("Casts Shadows", "投射陰影"), ("Change Renderer…", "更改算圖引擎…"), ("Changing a shortcut creates a custom preset; the default stays as it is.", "修改快速鍵會創建自定義預設集;預設預設集保持不變。"), @@ -4847,6 +4855,8 @@ const UKRAINIAN: &[(&str, &str)] = &[ "Деякі частини цього проєкту неможливо імпортувати точно (їх пропущено або змінено):", ), ("Import Anyway", "Усе одно імпортувати"), + ("{}: {} left out or changed", "{}: {} пропущено або змінено"), + ("… and {} more", "… і ще {}"), ("Casts Shadows", "Відкидає тіні"), ("Change Renderer…", "Змінити рушій рендерингу…"), ( @@ -5428,6 +5438,8 @@ const PORTUGUESE: &[(&str, &str)] = &[ "Algumas partes deste projeto não podem ser importadas exatamente (omitidas ou alteradas):", ), ("Import Anyway", "Importar mesmo assim"), + ("{}: {} left out or changed", "{}: {} omitidos ou alterados"), + ("… and {} more", "… e mais {}"), ("Casts Shadows", "Projeta sombras"), ("Change Renderer…", "Alterar renderizador…"), ( @@ -6201,6 +6213,8 @@ const POLISH: &[(&str, &str)] = &[ "Niektórych części tego projektu nie można zaimportować dokładnie (zostaną pominięte lub zmienione):", ), ("Import Anyway", "Importuj mimo to"), + ("{}: {} left out or changed", "{}: {} pominięto lub zmieniono"), + ("… and {} more", "… i {} więcej"), ("Casts Shadows", "Rzuca cienie"), ("Change Renderer…", "Zmień renderer…"), ( @@ -6957,6 +6971,8 @@ const CZECH: &[(&str, &str)] = &[ "Některé části tohoto projektu nelze importovat přesně (budou vynechány nebo změněny):", ), ("Import Anyway", "Přesto importovat"), + ("{}: {} left out or changed", "{}: {} vynecháno nebo změněno"), + ("… and {} more", "… a {} dalších"), ("Casts Shadows", "Vrhat stíny"), ("Change Renderer…", "Změnit vykreslovací modul…"), ( @@ -7532,6 +7548,8 @@ const SPANISH: &[(&str, &str)] = &[ "Algunas partes de este proyecto no se pueden importar con exactitud (se omiten o cambian):", ), ("Import Anyway", "Importar de todos modos"), + ("{}: {} left out or changed", "{}: {} omitidos o cambiados"), + ("… and {} more", "… y {} más"), ("Casts Shadows", "Proyecta sombras"), ("Change Renderer…", "Cambiar motor de procesamiento…"), ( @@ -8098,6 +8116,8 @@ const GEORGIAN: &[(&str, &str)] = &[ "ამ პროექტის ზოგიერთი ნაწილის ზუსტად იმპორტი შეუძლებელია (გამოტოვებული ან შეცვლილი):", ), ("Import Anyway", "მაინც იმპორტი"), + ("{}: {} left out or changed", "{}: {} გამოტოვებული ან შეცვლილი"), + ("… and {} more", "… და კიდევ {}"), ("Casts Shadows", "ჩრდილს აგდებს"), ("Change Renderer…", "რენდერერის შეცვლა…"), ("Changing a shortcut creates a custom preset; the default stays as it is.", "მალსახმობის შეცვლა ქმნის მორგებულ პრესეტს; ნაგულისხმევი უცვლელი რჩება."), @@ -9070,6 +9090,8 @@ const FRENCH: &[(&str, &str)] = &[ "Certaines parties de ce projet ne peuvent pas être importées à l’identique (omises ou modifiées) :", ), ("Import Anyway", "Importer quand même"), + ("{}: {} left out or changed", "{} : {} omis ou modifiés"), + ("… and {} more", "… et {} de plus"), ("Casts Shadows", "Projette des ombres"), ("Change Renderer…", "Changer de moteur de rendu…"), ( diff --git a/crates/ui-egui/src/panels/aep_losses.rs b/crates/ui-egui/src/panels/aep_losses.rs index 4eb71104d..4674c369a 100644 --- a/crates/ui-egui/src/panels/aep_losses.rs +++ b/crates/ui-egui/src/panels/aep_losses.rs @@ -8,7 +8,7 @@ //! //! Automation ids: `dialog.aepLosses.continue`, `dialog.aepLosses.cancel`. -use crate::i18n::tr; +use crate::i18n::{tr, tr_args}; use egui::vec2; use serde_json::{Value, json}; @@ -23,9 +23,6 @@ pub struct Pending { pub queue: Vec, } -/// How many detail lines of a loss category the prompt shows. -const DETAILS: usize = 3; - /// Whether the prompt is showing with a conversion waiting on it. pub fn asking(app: &EffectcraftApp) -> bool { app.dialog == Some(Dialog::AepLosses) && app.dialog_state.aep_losses.command.is_some() @@ -77,14 +74,24 @@ fn category(c: &str) -> String { chars.next().map(|f| f.to_uppercase().chain(chars).collect()).unwrap_or_default() } -/// The report's losses as lines: one per category with its count, and a few of its details. +/// The report's losses as lines: one per category with how many occurrences are left out or +/// changed, then every detail line the report keeps and "… and N more" for the occurrences none +/// describes (#3365). pub fn lines(report: &Value) -> Vec<(String, Vec)> { let Some(losses) = report["losses"].as_array() else { return vec![] }; losses .iter() .map(|l| { - let head = format!("{} ({})", category(l["category"].as_str().unwrap_or("other")), l["count"].as_u64().unwrap_or(0)); - let details = l["details"].as_array().map(|d| d.iter().filter_map(Value::as_str).take(DETAILS).map(str::to_string).collect()).unwrap_or_default(); + let count = l["count"].as_u64().unwrap_or(0); + let head = tr_args("{}: {} left out or changed", &[&category(l["category"].as_str().unwrap_or("other")), &count]); + let mut details: Vec = + l["details"].as_array().map(|d| d.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default(); + // An export report has no `unlisted`: each of its lines is one occurrence. + let listed = u64::try_from(details.len()).unwrap_or(u64::MAX); + let unlisted = l["unlisted"].as_u64().unwrap_or_else(|| count.saturating_sub(listed)); + if unlisted > 0 { + details.push(tr_args("… and {} more", &[&unlisted])); + } (head, details) }) .collect() @@ -179,11 +186,25 @@ mod tests { fn the_report_reads_as_categories_with_counts_and_details() { let report = json!({"losses": [{"category": "layer_styles", "count": 4, "details": ["a", "b", "c", "d"]}, {"category": "effects", "count": 1}]}); let l = lines(&report); - assert_eq!(l[0], ("Layer styles (4)".to_string(), vec!["a".to_string(), "b".into(), "c".into()])); - assert_eq!(l[1], ("Effects (1)".to_string(), vec![])); + assert_eq!(l[0], ("Layer styles: 4 left out or changed".to_string(), vec!["a".to_string(), "b".into(), "c".into(), "d".into()])); + assert_eq!(l[1], ("Effects: 1 left out or changed".to_string(), vec!["… and 1 more".to_string()])); assert!(lines(&json!({})).is_empty()); } + /// #3365: every detail line shows, then how many occurrences have none: the report's + /// `unlisted` (an effect line can describe many instances), else the lines past the count. + #[test] + fn every_detail_shows_and_the_rest_is_counted() { + let details: Vec = (0..20).map(|i| format!("d{i}")).collect(); + let l = lines(&json!({"losses": [{"category": "masks", "count": 27, "details": details}]})); + assert_eq!(l[0].1.len(), 21); + assert_eq!(l[0].1[20], "… and 7 more"); + let l = lines(&json!({"losses": [{"category": "effects", "count": 50, "details": ["X, and 44 more like it"], "unlisted": 5}]})); + assert_eq!(l[0].1, ["X, and 44 more like it", "… and 5 more"]); + let l = lines(&json!({"losses": [{"category": "effects", "count": 45, "details": ["X, and 44 more like it"], "unlisted": 0}]})); + assert_eq!(l[0].1, ["X, and 44 more like it"]); + } + /// Projects dropped or picked together wait only behind a prompt that shows; with none /// showing they are tried at once (a missing file is an error naming it). #[test] diff --git a/docs/aep-import.md b/docs/aep-import.md index 32e19fd42..d2a418b85 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -1,6 +1,6 @@ # After Effects project import -> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** an item made from one layer of a Photoshop file shows that layer (#3087); before that: dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** an item made from one layer of a Photoshop file shows that layer (#3087); before that: the losses prompt shows every detail line and how many have none (`unlisted`); unmapped effects are listed most used first (#3365); a shape property recorded in the default state is no loss; an expression on Orientation is imported with its value or keys (#3087); before that: dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 File ▸ Import ▸ Adobe After Effects Project… (`file.importAep`) reads an `.aep` or its XML form `.aepx` and adds its items to the open project, in a folder named after the file (as After Effects does when it imports a project). @@ -59,7 +59,7 @@ refused losses) leaves the project unchanged. outside the sampled ones keeps the current value and is reported as `project settings`. After Effects renders in the working space (902: in a Rec. 709 Gamma 2.4 project an sRGB PNG step of 128 comes out as 135 and a grey 0.5 solid as 128, in the Render Queue output and in the captures), while EffectCraft converts its output to `output_space` (sRGB when unset): a Rec. 709 working space therefore also sets the output space to Rec. 709 (902 within 1 level of After Effects, was up to 13). They come in `ImportResult::settings`; the library never changes the project's own settings, `file.importAep` applies them when the project has no compositions yet (or with `applySettings: true`) | | Composition | name, size, exact frame rate, duration, work area, background colour (8 bit), pixel aspect, composition motion blur switch, shutter angle and phase (as stored), display start time, Classic/Advanced 3D renderer | | Layers | solid, null, adjustment, footage, precomposition and **text** layers: name (the source's name when the layer has none; for a text layer the first 40 characters of its first line, as EffectCraft names a text layer after its text), in/out/start and time stretch (exact ratios, e.g. 50 % and 150 %); a layer of a clip, sequence or sound ends at its source's end as After Effects ends it (unless remapped, looped or less than a frame past; f1), enabled/audio/solo/shy/locked/effects/motion blur/collapse/3D/guide switches, draft quality, label, blend mode (Normal, Multiply, Add, Screen, Overlay, Difference; Soft Light, Hard Light, Darken, Lighten, Exclusion, Color Dodge, Color Burn by name, 906; Classic Difference, Hue, Saturation, Color, Luminosity, the Stencil and Silhouette modes, Classic Color Dodge / Burn, Linear Dodge / Burn, Linear, Vivid and Pin Light, Hard Mix by render, 907: each code rendered by After Effects matched exactly one EffectCraft mode; 906 renders within 1/255 of After Effects), parent, **track matte** (alpha, alpha inverted, luma, luma inverted; the matte layer anywhere in the composition, also below its target; 402-408) | -| Transform | Anchor Point, Position, Scale, Rotation, Opacity, **X and Y Rotation** (486, 487) and **Orientation**, static (488) or keyed (703: key values, times, linear and hold sides; a bezier side becomes linear and is reported; After Effects slerps between the keys, EffectCraft interpolates each angle, so in-between values differ, e.g. 703 at 1 s `[6.26, 9.35, 15.22]` against `[5, 10, 15]`; an expression-driven orientation is reported): the stored value, or the default when the file stores none; keyframes with time, linear/bezier/hold on each side, temporal ease (speed and influence, per dimension for Scale), automatic or manual spatial tangents, roving and key labels | +| Transform | Anchor Point, Position, Scale, Rotation, Opacity, **X and Y Rotation** (486, 487) and **Orientation**, static (488) or keyed (703: key values, times, linear and hold sides; a bezier side becomes linear and is reported; After Effects slerps between the keys, EffectCraft interpolates each angle, so in-between values differ, e.g. 703 at 1 s `[6.26, 9.35, 15.22]` against `[5, 10, 15]`), with its expression as on any property: the stored value, or the default when the file stores none; keyframes with time, linear/bezier/hold on each side, temporal ease (speed and influence, per dimension for Scale), automatic or manual spatial tangents, roving and key labels | | Expressions | on the transform and mask properties and on effect, camera and light parameters (534): source text and enable switch | | Cameras and lights | **cameras** (one-node and two-node, from `ldta` byte 38 bit `0x40`): point of interest, Position as stored (also the auto Position `[0, 0, -zoom]`, where After Effects renders it, 537), orientation and rotations, Zoom (absent: `-z` of the auto Position, else the 50 mm default), Depth of Field, Focus Distance (absent: the zoom), Aperture (absent: 25.309 at 1920 px, read back from 256; scaled with the width, which is not sampled, E); **spot, point, parallel and ambient lights** (light type 1, 2, 0, 3: 701, 702): point of interest (not for point and ambient lights), Position, Intensity, Color, Cone Angle, Cone Feather, Casts Shadows; keys and expressions as for the transform; 251-256, 341-345, 535-537, 701, 702. Environment lights are reported | | Text | the Source Text of a text layer: the text (paragraphs separated by CR; a hard line break is a CR too; U+0003 is laid out by After Effects exactly like a CR (386 against 387) and becomes a CR), per style run the font (PostScript name), size, fill and stroke colour, whether fill and stroke are drawn, stroke width, stroke over fill (one switch for the text), tracking, leading (automatic or explicit), faux bold, faux italic, all caps, small caps, superscript and subscript (526-528), horizontal and vertical scale, baseline shift, automatic kerning (metrics, optical, none), ligatures, tsume (538-578); per paragraph the justification (left, centre, right and the four justified variants), first-line / start / end indent, space before / after, direction, composer, hanging punctuation (in a right-to-left paragraph the stored justification is relative to the direction: After Effects lays 0 and 3 out flush right and 1 and 4 flush left, read-back test t7, so they arrive as right / left); point text or box text (box size and top-left in layer pixels); vertical right-to-left text (575) | @@ -322,8 +322,12 @@ parameter gets the file's value: ## What is reported `ImportReport` (`serde`, returned as JSON) has `imported` (counts, including `textLayers`), -`losses` (per category: `count` exact, up to 20 `details`), `missingFootage`, `missingFonts` -(PostScript names; not losses), `limitations` and `aeVersion`. The categories: +`losses` (per category: `count` exact; up to 20 `details`, plus one line per unmapped effect with its +number of instances, the most used first; `unlisted`, how many of the `count` no line describes), +`missingFootage`, `missingFonts` +(PostScript names; not losses), `limitations` and `aeVersion`. The losses prompt shows each +category with how many occurrences are left out or changed, every detail line, and "… and N more" +for the `unlisted` ones. The categories: | Category | Counted | |---|---| @@ -332,10 +336,10 @@ parameter gets the file's value: | `layer_styles` | per layer: a style in switch state 3 (not sampled; state 0 is imported as a style switched off, 988), Pattern Overlay, an unknown style or parameter, a gradient (`GCst`, not decoded: EffectCraft's default gradient stays), keys on a popup or checkbox, a value that does not fit, styles on a layer that takes none here (adjustment, camera, light) | | `markers` | a marker with an unknown cue type (imported as an event cue) | | `text_layers` ("text layer features") | per text layer, what of it is not imported: wiggly and expression selectors and unmapped animator properties, Path Options or More Options with set values, Source Text keyframes or an expression, character style or paragraph fields that are not decoded and differ from the default (named by their slot, e.g. `/68`), an unknown capitalisation or justification code, manual kerning, No Break, the stroke line join, the Japanese leading model, a digit set other than the default and Arabic, control characters of unknown meaning (they are kept in the text; U+0003 is not one of them, see Text above). The text layer itself is imported | -| `shape_layers` ("shape layers") | what of a shape layer is not imported: shape items other than groups, rectangle, ellipse, polystar, path, fill, stroke, gradient fill and stroke, trim paths, round corners, merge paths, repeater and offset paths (for example wiggle or twist), stroke dashes, taper and wave with values, item blend modes and composite order, shape direction, gradient colour stops other than the default, gradient highlight values other than 0, material options, groups nested deeper than 32, a line cap or join of the unsampled third kind (imported as it), a path without a readable path. The layer itself is imported | +| `shape_layers` ("shape layers") | what of a shape layer is not imported: shape items other than groups, rectangle, ellipse, polystar, path, fill, stroke, gradient fill and stroke, trim paths, round corners, merge paths, repeater and offset paths (for example wiggle or twist), stroke dashes, taper and wave with values, item blend modes and composite order, shape direction, gradient colour stops other than the default, gradient highlight values other than 0 (only from written records: one in the default state holds the default), material options, groups nested deeper than 32, a line cap or join of the unsampled third kind (imported as it), a path without a readable path. The layer itself is imported | | `lights` | each environment light (the layer is skipped; the other layers of the composition are imported) | | `blend_modes` | a blend mode code that is not mapped (1, 3 = Dissolve or Dancing Dissolve, 21 / 22 = Alpha Add or Luminescent Premul, codes above 34; the layer stays Normal) | -| `transform_properties` | a stored transform property that is not imported (keyed or expression-driven orientation, separated position dimensions), and the stored camera and light options that are not mapped (Blur Level, Iris, Falloff, ...) | +| `transform_properties` | a stored transform property that is not imported (an orientation that cannot be read, or set on a layer without one, separated position dimensions), and the stored camera and light options that are not mapped (Blur Level, Iris, Falloff, ...) | | `time_remapping` | an enabled Time Remap without a readable keyframe | | `approximated_keyframes` | a key imported in a simpler form (unknown interpolation code, bezier ease that cannot be decoded: a colour key with a non-zero ease, non-finite ease data; a mask path key with a speed other than 0, imported unverified) | | `parent_links` | a parent that was not imported (a skipped camera, a missing layer) or that forms a loop | diff --git a/docs/preferences.md b/docs/preferences.md index 86c4599b3..c35d2c793 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -100,10 +100,13 @@ Commands (CLI, MCP, control channel): one; `EFFECTCRAFT_GPU_MEMCAP_MB` sets it), keeping room for the upload cache to fill. It renders one frame at a time where the memory can't be measured (Metal, WebGPU, GL, or the cap turned off) and once frames have had to wait for memory. The render log names the count. - `EFFECTCRAFT_GPU_INFLIGHT` overrides it. CPU renders have no such setting: they keep one - frame per thread in flight, so their memory grows with the core count. Set - `EFFECTCRAFT_CPU_INFLIGHT` to a whole number of frames (at least 1) to cap the frames in - flight of every Render Queue render and export, `effectcraft-cli render` included + `EFFECTCRAFT_GPU_INFLIGHT` overrides it. CPU renders have no such setting. A job on GPU + Acceleration that renders on the CPU (it measured the CPU faster, or the GPU stopped) fits + its CPU frames in flight into half the memory the system has free, at the size a GPU frame + measured, from 1 to one per thread; the app log names the count. Other CPU renders (Software + Rendering projects) keep one frame per thread in flight, so their memory grows with the core + count. Set `EFFECTCRAFT_CPU_INFLIGHT` to a whole number of frames (at least 1) to cap the + frames in flight of every Render Queue render and export, `effectcraft-cli render` included ([#1615](https://github.com/storytold/effectcraft/issues/1615)); previews don't read it. One exception: while a job on GPU Acceleration times both compositors on its first frames, it renders them under the cap but keeps them all (up to 12) until it has chosen. That timing