diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index b903f82ea..ccf74f43a 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -28,6 +28,10 @@ jobs: name: Windows installation policy (no app build) runs-on: windows-latest steps: + - name: Harden runner + uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4 + with: + egress-policy: audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false 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..002edc8d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1184,7 +1184,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" +source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" dependencies = [ "chrono", "dunce", @@ -1194,6 +1194,7 @@ dependencies = [ "image", "log", "plist", + "rustix 1.1.5", "semver", "serde", "serde_json", @@ -1420,7 +1421,7 @@ dependencies = [ [[package]] name = "effectcraft" -version = "0.23.1" +version = "0.24.0" dependencies = [ "chrono", "cpal", @@ -1448,7 +1449,7 @@ dependencies = [ [[package]] name = "effectcraft-aep" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1462,7 +1463,7 @@ dependencies = [ [[package]] name = "effectcraft-automation" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-engine", "effectcraft-host", @@ -1476,14 +1477,15 @@ dependencies = [ [[package]] name = "effectcraft-av1enc" -version = "0.23.1" +version = "0.24.0" dependencies = [ "filmcraft-av1", + "tempfile", ] [[package]] name = "effectcraft-cli" -version = "0.23.1" +version = "0.24.0" dependencies = [ "cpu-time", "effectcraft-automation", @@ -1502,7 +1504,7 @@ dependencies = [ [[package]] name = "effectcraft-color" -version = "0.23.1" +version = "0.24.0" dependencies = [ "proptest", "serde", @@ -1510,7 +1512,7 @@ dependencies = [ [[package]] name = "effectcraft-effects" -version = "0.23.1" +version = "0.24.0" dependencies = [ "chrono", "effectcraft-color", @@ -1528,7 +1530,7 @@ dependencies = [ [[package]] name = "effectcraft-engine" -version = "0.23.1" +version = "0.24.0" dependencies = [ "chrono", "craft-docpath", @@ -1567,7 +1569,7 @@ dependencies = [ [[package]] name = "effectcraft-export" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-av1enc", "effectcraft-color", @@ -1592,13 +1594,14 @@ dependencies = [ "image", "log", "rayon", + "tempfile", "thiserror 2.0.21", "web-time", ] [[package]] name = "effectcraft-expr" -version = "0.23.1" +version = "0.24.0" dependencies = [ "boa_engine", "effectcraft-color", @@ -1615,7 +1618,7 @@ dependencies = [ [[package]] name = "effectcraft-geom" -version = "0.23.1" +version = "0.24.0" dependencies = [ "proptest", "serde", @@ -1623,7 +1626,7 @@ dependencies = [ [[package]] name = "effectcraft-gpu" -version = "0.23.1" +version = "0.24.0" dependencies = [ "bytemuck", "effectcraft-color", @@ -1646,7 +1649,7 @@ dependencies = [ [[package]] name = "effectcraft-helper-host" -version = "0.23.1" +version = "0.24.0" dependencies = [ "bytemuck", "effectcraft-effects", @@ -1657,14 +1660,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 +1694,7 @@ dependencies = [ [[package]] name = "effectcraft-interchange" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-color", "effectcraft-keyframe", @@ -1710,7 +1713,7 @@ dependencies = [ [[package]] name = "effectcraft-keyframe" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1722,7 +1725,7 @@ dependencies = [ [[package]] name = "effectcraft-lottie" -version = "0.23.1" +version = "0.24.0" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1740,7 +1743,7 @@ dependencies = [ [[package]] name = "effectcraft-media" -version = "0.23.1" +version = "0.24.0" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1768,12 +1771,13 @@ dependencies = [ "log", "rayon", "serde_json", + "tempfile", "thiserror 2.0.21", ] [[package]] name = "effectcraft-model" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-geom", "image", @@ -1786,14 +1790,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 +1809,7 @@ dependencies = [ [[package]] name = "effectcraft-pdf" -version = "0.23.1" +version = "0.24.0" dependencies = [ "craft-pdf", "effectcraft-raster", @@ -1821,7 +1825,7 @@ dependencies = [ [[package]] name = "effectcraft-plugin" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-effects", "effectcraft-keyframe", @@ -1833,7 +1837,7 @@ dependencies = [ [[package]] name = "effectcraft-project" -version = "0.23.1" +version = "0.24.0" dependencies = [ "craft-ecproj-format", "effectcraft-color", @@ -1847,14 +1851,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 +1868,7 @@ dependencies = [ [[package]] name = "effectcraft-render" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1887,7 +1891,7 @@ dependencies = [ [[package]] name = "effectcraft-sam2" -version = "0.23.1" +version = "0.24.0" dependencies = [ "bytemuck", "candle-core", @@ -1902,7 +1906,7 @@ dependencies = [ [[package]] name = "effectcraft-script" -version = "0.23.1" +version = "0.24.0" dependencies = [ "boa_engine", "effectcraft-engine", @@ -1920,7 +1924,7 @@ dependencies = [ [[package]] name = "effectcraft-segment" -version = "0.23.1" +version = "0.24.0" dependencies = [ "miniz_oxide 0.9.1", "ndarray", @@ -1929,19 +1933,20 @@ dependencies = [ [[package]] name = "effectcraft-svg" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-geom", "effectcraft-path", "effectcraft-raster", "kurbo", + "log", "rayon", "roxmltree", ] [[package]] name = "effectcraft-text" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-keyframe", "harfrust", @@ -1953,7 +1958,7 @@ dependencies = [ [[package]] name = "effectcraft-time" -version = "0.23.1" +version = "0.24.0" dependencies = [ "proptest", "serde", @@ -1961,7 +1966,7 @@ dependencies = [ [[package]] name = "effectcraft-track" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-raster", "effectcraft-segment", @@ -1973,7 +1978,7 @@ dependencies = [ [[package]] name = "effectcraft-ui-egui" -version = "0.23.1" +version = "0.24.0" dependencies = [ "bytemuck", "chrono", @@ -2014,11 +2019,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 +7840,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..6c12d867d 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" @@ -16,7 +16,7 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } craft-ecproj-format = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "ca1b5e2edb8568335c6c14f390307bd5beae8238" } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] } # internal effectcraft-time = { path = "crates/time" } effectcraft-geom = { path = "crates/geom" } diff --git a/crates/aep/src/container/effect_map.rs b/crates/aep/src/container/effect_map.rs index aea320395..49a53d2e7 100644 --- a/crates/aep/src/container/effect_map.rs +++ b/crates/aep/src/container/effect_map.rs @@ -1142,11 +1142,37 @@ pub const GLOW_MIDPOINT_MAX: f64 = 0.990_005_493_164_062_5; /// The key a popup item is matched by between the two programs: trimmed, lower case, and /// Set Matte's "Red Channel" ... "Alpha Channel" are EffectCraft's "Red" ... "Alpha" (904 E24). +/// +/// Lower case is Unicode's, not only ASCII's: After Effects stores popup items in its UI +/// language, so an item can start with a capital outside ASCII (French "Écran"), and its key must +/// be the same as for the same word in lower case. pub fn popup_key(item: &str) -> String { - let s = item.trim().to_ascii_lowercase(); + let s = item.trim().to_lowercase(); s.strip_suffix(" channel").map(str::to_string).unwrap_or(s) } +/// The English [`popup_key`] a non-English After Effects popup item name stands for, when this +/// table knows it; `None` otherwise. EffectCraft's own popup options are English only, so a +/// project saved by an After Effects set to another UI language never matches them by name and +/// the parameter falls back to its default (observed on a real "Niveaux" project, French After +/// Effects 2026 26.0.0x67: Levels' Channel and Clip to Output Black / White). Add an entry only +/// from a confirmed After Effects label, never a guess at a whole menu's translation. +pub fn popup_alias(key: &str) -> Option<&'static str> { + Some(match key { + // French: Levels' Channel popup (`ADBE Easy Levels2-0001`). "Alpha" is the same word in + // both languages, so it needs no entry. + "rvb" => "rgb", + "rouge" => "red", + "vert" => "green", + "bleu" => "blue", + // French: Levels' Clip to Output Black / White popups (`-0008`, `-0009`). + "activé" => "on", + "désactivé" => "off", + "désactivé pour la couleur 32 bpc" => "off for 32 bpc color", + _ => return None, + }) +} + /// The table row of an After Effects match name. pub fn by_match_name(match_name: &str) -> Option<&'static EffectMap> { EFFECTS.iter().find(|m| m.match_name == match_name) @@ -1164,3 +1190,17 @@ pub const HUE_SATURATION_MASTER: [&str; 3] = ["hue", "saturation", "lightness"]; /// The EffectCraft parameters Levels' RGB channel values (`Extra::Levels`) go to, in the order of /// the data (input black, input white, gamma, output black, output white: 512-516). pub const LEVELS_RGB: [&str; 5] = ["inBlack", "inWhite", "gamma", "outBlack", "outWhite"]; + +#[cfg(test)] +mod tests { + use super::*; + + /// A capital outside ASCII has the same key as its lower case; ASCII keys are unchanged. + #[test] + fn popup_key_lowers_non_ascii_capitals() { + assert_eq!(popup_key("ÉCRAN"), popup_key("écran")); + assert_eq!(popup_key(" Désactivé "), "désactivé"); + assert_eq!(popup_key("Off for 32 bpc Color"), "off for 32 bpc color"); + assert_eq!(popup_key("Red Channel"), "red"); + } +} diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index e53d6a9aa..a09ef37b6 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -34,7 +34,7 @@ //! import. Hue/Saturation's master values and Levels' values are arbitrary data, decoded by the //! reader (444-446, 512-516). -use crate::effect_map::{CRYPTOMATTE_OUTPUT, Conv, EFFECTS, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, ParamMap, popup_key}; +use crate::effect_map::{CRYPTOMATTE_OUTPUT, Conv, EFFECTS, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, ParamMap, popup_alias, popup_key}; use crate::{ CryptomatteData, CurvesData, DefinitionSource, Effect as AepEffect, EffectDefinitions, EffectParam, ExtractorChannels, HueSaturationData, Layer as AepLayer, LayerRef, LevelsData, MeshData, ParamKind, ParamValue, @@ -83,11 +83,11 @@ pub(crate) fn import_effects<'f, 'a>( } let shown = fx.display_name().unwrap_or_else(|| fx.match_name().to_string()); let Some(map) = EFFECTS.iter().find(|m| m.match_name == fx.match_name()) else { - col.lose(LossCategory::Effects, format!("{}: '{}' has the effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name())); + col.lose_unmapped_effect(fx.match_name(), format!("{}: '{}' has the effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name())); continue; }; let Some(spec) = effectcraft_effects::find(map.effect) else { - col.lose(LossCategory::Effects, format!("{}: '{}' has the effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name())); + col.lose_unmapped_effect(fx.match_name(), format!("{}: '{}' has the effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name())); continue; }; if defs.source(&fx) == DefinitionSource::Missing { @@ -413,8 +413,8 @@ pub(crate) fn resolve_layer_refs( } } -/// The EffectCraft item of each file item (by name, ignoring case and separators); `None` when -/// the parameter here is not a popup. +/// The EffectCraft item of each file item (by name, ignoring case and separators, and a known +/// non-English name for it, [`popup_alias`]); `None` when the parameter here is not a popup. fn popup_mapping(file_items: &[String], ui: &ParamUi) -> Option>> { let ParamUi::Popup { options } = ui else { return None }; let norm = popup_key; @@ -425,7 +425,12 @@ fn popup_mapping(file_items: &[String], ui: &ParamUi) -> Option> if item.trim() == "(-" { return None; } - options.iter().position(|o| norm(o) == norm(item)).and_then(|i| u32::try_from(i).ok()) + let key = norm(item); + options + .iter() + .position(|o| norm(o) == key) + .or_else(|| popup_alias(&key).and_then(|a| options.iter().position(|o| norm(o) == a))) + .and_then(|i| u32::try_from(i).ok()) }) .collect(), ) 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 129fcf143..4732592d9 100644 --- a/crates/aep/src/import/report.rs +++ b/crates/aep/src/import/report.rs @@ -98,8 +98,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. @@ -269,19 +273,58 @@ pub(crate) struct Collector { losses: BTreeMap, pub missing_footage: Vec, pub missing_fonts: Vec, + /// The effects with no counterpart, by match name: the first instance's report line and how + /// many instances there were. They get a line each in [`Collector::finish`], so one that + /// comes after the example lines are used up is still named (#3365). + unmapped_effects: BTreeMap, } +/// How many different unmapped effects get a line of their own (a file names at most this many). +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()); } } - pub fn finish(self) -> ImportReport { + /// Records one instance of an effect with no counterpart. The loss is counted now; its line is + /// 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(), 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); + } else if self.unmapped_effects.len() < MAX_UNMAPPED_EFFECTS { + self.unmapped_effects.insert(match_name.to_string(), (detail, 1)); + } + // (Past the limit an effect is only counted: its name is not worth a line of its own.) + } + + 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) { + // 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, lines); + } ImportReport { imported: self.imported, losses: self.losses.into_values().collect(), @@ -301,3 +344,65 @@ pub const LIMITATIONS: &[&str] = &[ "Text: per-character 3D, text on a path and several style slots of unknown meaning are not decoded (stored values that differ from the default are reported as text layer features).", "Footage that is not a solid is linked by its stored path with its stored frame rate and duration; the importer never opens the files (use Reload Footage after the files are found).", ]; + +#[cfg(test)] +mod tests { + use super::*; + + /// #3365: with other losses filling the 20 example lines, an effect with no counterpart still + /// gets its own line, with how many instances were lost. The category's count is unchanged. + #[test] + fn every_unmapped_effect_is_named_however_many_other_losses_there_are() { + let mut col = Collector::default(); + for i in 0..30 { + col.lose(LossCategory::Effects, format!("C: 'L{i}': parameter P not imported")); + } + for i in 0..25 { + col.lose_unmapped_effect("CC Cylinder", format!("C: 'Cyl{i}' has the effect 'CC Cylinder' (CC Cylinder)")); + } + col.lose_unmapped_effect("Pseudo/", "C: 'D' has the effect 'Dropdown' (Pseudo/)".to_string()); + let report = col.finish(); + let effects = report.losses.iter().find(|l| l.category == LossCategory::Effects).unwrap(); + assert_eq!(effects.count, 56, "30 parameters, 25 cylinders and 1 dropdown"); + let named = |what: &str| effects.details.iter().filter(|d| d.contains(what)).collect::>(); + assert_eq!(named("(CC Cylinder)").len(), 1, "{:?}", effects.details); + assert!(named("(CC Cylinder)")[0].contains("24 more"), "{:?}", named("(CC Cylinder)")); + // 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 + /// and counts the rest, whatever room the example lines have. + #[test] + fn at_most_the_limit_of_unmapped_effects_are_named() { + let mut col = Collector::default(); + for i in 0..=MAX_UNMAPPED_EFFECTS { + col.lose_unmapped_effect(&format!("Effect {i}"), format!("C: 'L' has the effect 'Effect {i}' (Effect {i})")); + } + let report = col.finish(); + 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 8e4c28f16..a31a61679 100644 --- a/crates/aep/tests/it/import/synthetic.rs +++ b/crates/aep/tests/it/import/synthetic.rs @@ -842,3 +842,101 @@ fn a_default_state_colour_record_holds_the_value() { assert_eq!(colour(&default_state), colour(&written)); assert_ne!(colour(&default_state), colour(&unchanged), "the recorded colour, not the default"); } + +/// #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. +fn with_french_levels_popups() -> Vec { + let (mut channel, mut clip) = (0, 0); + let bytes = mutate(&mut |c| { + if !c.is(b"Utf8") { + return; + } + if c.bytes() == Some(b"RGB|Red|Green|Blue|Alpha".as_slice()) { + *c = Chunk::data(*b"Utf8", b"RVB|Rouge|Vert|Bleu|Alpha".to_vec()); + channel += 1; + } else if c.bytes() == Some(b"On|Off|Off for 32 bpc Color".as_slice()) { + *c = Chunk::data(*b"Utf8", "Activé|Désactivé|Désactivé pour la couleur 32 bpc".as_bytes().to_vec()); + clip += 1; + } + }); + assert_eq!(channel, 2, "Levels' Channel pdnm"); + assert_eq!(clip, 4, "Levels' two Clip pdnm (Black and White), each stored twice"); + bytes +} + +/// A popup's item names in the file are matched to EffectCraft's own (English) ones by name +/// (`popup_key`): a project saved by a French After Effects stores "RVB", "Activé", "Désactivé +/// pour la couleur 32 bpc" for Levels' Channel and Clip to Output Black / White (observed in a +/// real "Niveaux" effect, After Effects 2026 26.0.0x67). Before `popup_alias`, none of these +/// matched EffectCraft's English-only options, so each came back "has no counterpart, default +/// used" and the real selection was silently lost. +#[test] +fn french_levels_popup_items_are_recognized() { + let (unchanged, r1) = run(SYNTHETIC, true); + let (french, r2) = run(&with_french_levels_popups(), true); + let (r1, r2) = (r1.unwrap(), r2.unwrap()); + let found = losses(&r2, LossCategory::Effects); + assert!(!found.iter().any(|d| d.contains("has no counterpart")), "{found:?}"); + for id in ["channel", "clipToOutputBlack", "clipToOutputWhite"] { + let want: Vec<_> = effect_values(&unchanged).into_iter().filter(|(p, _)| p == id).collect(); + let got: Vec<_> = effect_values(&french).into_iter().filter(|(p, _)| p == id).collect(); + assert_eq!(got, want, "{id}"); + } + assert_eq!(r1.report.imported.effects, r2.report.imported.effects); +} + +/// `popup_alias` must cover every item of a popup it claims to know, not only the one a first +/// report happened to show: Channel's French "Rouge", "Vert", "Bleu" ("Alpha" is the same word in +/// both languages) alongside the already-covered "RVB" (`effect_map.rs`, reviewed on #3432). +#[test] +fn french_levels_channel_aliases_cover_every_item() { + use effectcraft_aep::effect_map::popup_alias; + for (french, english) in [("rvb", "rgb"), ("rouge", "red"), ("vert", "green"), ("bleu", "blue")] { + assert_eq!(popup_alias(french), Some(english), "{french}"); + } + // "Alpha" needs no alias: the same word in both languages. + assert_eq!(popup_alias("alpha"), None); +} diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index 371085938..0407ba756 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -75,6 +75,9 @@ const REFUSED: &[&str] = &[ "library.preview", ]; +/// Most numbered folders `check_render_folder` inspects for one output; more are refused. +const MAX_NUMBERED_FOLDERS: usize = 4096; + /// Commands that read files named by these parameters. const READS: &[(&str, &[&str])] = &[ ("file.open", &["path"]), @@ -202,6 +205,80 @@ impl Confinement { if self.roots(access).any(|r| real.starts_with(r)) { Ok(real) } else { Err(denied("it is outside the automation roots")) } } + /// A render output's folder holds no symbolic link that leads outside the write roots. An + /// image sequence is written to numbered names (`out_[#####].png` → `out_00001.png`, and a + /// sequence output without `#` gets `_00001` too) that the check of its template never + /// resolved, and each frame file is created through whatever link already sits at its name, + /// truncating the file it points to. A link that can't be resolved counts as outside: the + /// render would create its target. When the frame number is in a folder name, the same goes for + /// each existing numbered folder and the folder it holds the frames in. + fn check_render_folder(&self, output: &str) -> Result<()> { + let real = self.check(output, Access::Write)?; + if let Some(dir) = real.parent() { + self.check_no_outward_links(dir, output)?; + } + // The frame number may sit in a folder name (`shots_[##]/f_[#####].png`): the folders the + // frames go to are the numbered ones that exist, which the template's own parent is not. + let (before, _, after) = effectcraft_engine::project::render_queue::sequence_parts(output); + let sep = ['/', '\\']; + let Some((dir_tail, rest)) = after.split_once(sep) else { return Ok(()) }; + let (parent, prefix) = before.rsplit_once(sep).unwrap_or(("", &before)); + // A parent that doesn't exist has no numbered folders; one that can't be listed might. + let scan = if parent.is_empty() { Path::new("/") } else { Path::new(parent) }; + let entries = match std::fs::read_dir(scan) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(Error::BadArgs(format!("cannot render to `{output}`: its numbered folders in `{}` can't be checked: {e}", scan.display()))), + }; + let mut seen = 0; + for e in entries { + let e = + e.map_err(|e| Error::BadArgs(format!("cannot render to `{output}`: its numbered folders in `{}` can't be checked: {e}", scan.display())))?; + let name = e.file_name().to_string_lossy().into_owned(); + let Some(digits) = name.strip_prefix(prefix).and_then(|n| n.strip_suffix(dir_tail)) else { continue }; + if !digits.strip_prefix('-').unwrap_or(digits).bytes().all(|b| b.is_ascii_digit()) || digits.is_empty() { + continue; + } + seen += 1; + if seen > MAX_NUMBERED_FOLDERS { + return Err(Error::BadArgs(format!("cannot render to `{output}`: more than {MAX_NUMBERED_FOLDERS} numbered folders already exist to check"))); + } + // The numbered folder itself may be a link out; `check` resolves every existing part. + let frame_file = e.path().join(rest); + let frame_file = frame_file.to_string_lossy(); + self.check(&frame_file, Access::Write)?; + if let Some(dir) = Path::new(&*frame_file).parent().and_then(resolve) { + self.check_no_outward_links(&dir, output)?; + } + } + Ok(()) + } + + /// `dir` holds no symbolic link that leads outside the write roots. + fn check_no_outward_links(&self, dir: &Path, output: &str) -> Result<()> { + // A folder that does not exist yet holds no link; one that can't be listed might. + let cannot = |e: std::io::Error| Error::BadArgs(format!("cannot render to `{output}`: its folder `{}` can't be checked for links: {e}", dir.display())); + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(cannot(e)), + }; + for e in entries { + let e = e.map_err(cannot)?; + if !e.file_type().map_err(cannot)?.is_symlink() { + continue; + } + let link = e.path(); + if !link.canonicalize().is_ok_and(|r| self.write.iter().any(|w| r.starts_with(w))) { + return Err(Error::BadArgs(format!( + "cannot render to `{output}`: its folder holds `{}`, a link that leads outside the automation write roots, where a numbered frame could be written", + link.display() + ))); + } + } + Ok(()) + } + /// Check engine command `id` with `params` before it runs. Returns what must still be looked /// up and passed to [`Confinement::check_lookup`] for commands that write implicitly. pub fn check_command(&self, id: &str, params: &Value) -> Result> { @@ -221,6 +298,12 @@ impl Confinement { for step in params.get("steps").and_then(Value::as_array).into_iter().flatten() { let Some(inner) = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str) else { continue }; let inner_params = step.get("params").cloned().unwrap_or(Value::Null); + // A whole `params` given as a `$N` reference becomes an earlier step's result + // only as the batch runs, so none of its file paths would have been checked + // (`essential.templateInfo` returns a manifest naming any file at all). + if !matches!(inner_params, Value::Null | Value::Object(_)) { + return Err(Error::BadArgs(format!("`{inner}` in a batch: `params` must be an object while file access is confined"))); + } let named = keys_of(READS, inner).or_else(|| keys_of(WRITES, inner)).unwrap_or(&[]); let mixed = keys_of(MIXED_WRITES, inner).unwrap_or(&[]); // A step's `$N` reference is resolved only as the batch runs: never for a file. @@ -258,6 +341,9 @@ impl Confinement { Some(s) => { gave_a_file |= v.is_string(); self.check(s, access)?; + if QUEUES_AN_OUTPUT.contains(&id) || id.starts_with("renderQueue.") { + self.check_render_folder(s)?; + } } None => return Err(Error::BadArgs(format!("`{id}`: `{key}` must be a path"))), } @@ -321,11 +407,15 @@ impl Confinement { let items = found.get("items").or(Some(found)).and_then(Value::as_array).cloned().unwrap_or_default(); for it in items.iter().filter(|it| it.get("render").and_then(Value::as_bool) != Some(false)) { let extra = it.get("extraOutputs").and_then(Value::as_array).into_iter().flatten().map(|o| o.get("outputPath")); - for p in std::iter::once(it.get("outputPath")).chain(extra).chain([it.get("logPath")]).flatten() { + for p in std::iter::once(it.get("outputPath")).chain(extra).flatten() { if let Some(p) = p.as_str() { self.check(p, Access::Write)?; + self.check_render_folder(p)?; } } + if let Some(p) = it.get("logPath").and_then(Value::as_str) { + self.check(p, Access::Write)?; + } } Ok(()) } @@ -757,6 +847,112 @@ mod tests { ] { assert!(c.check_command("effect.plugins.install", ¶ms).is_err(), "{params}"); } + } + + #[test] + fn a_batch_step_whose_params_are_a_reference_is_refused() { + let base = std::env::temp_dir().join(format!("ec-confine-batch-ref-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (read, write) = (base.join("in"), base.join("out")); + for d in [&read, &write] { + std::fs::create_dir_all(d).unwrap(); + } + let c = Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap(); + let info = read.join("t.ectemplate").to_string_lossy().into_owned(); + // Step 2's params are step 1's result, which can name any `path`. + let steps = serde_json::json!({"steps": [ + {"command": "essential.templateInfo", "params": {"path": info}}, + {"command": "file.saveAs", "params": "$1"}, + ]}); + let e = c.check_command("engine.batch", &steps).unwrap_err().to_string(); + assert!(e.contains("must be an object"), "{e}"); + for params in [serde_json::json!("$1.manifest"), serde_json::json!(["$1"]), serde_json::json!(7)] { + let steps = serde_json::json!({"steps": [{"command": "file.saveAs", "params": params}]}); + assert!(c.check_command("engine.batch", &steps).is_err(), "{params}"); + } + // Object params (or none) are checked as before. + let saved = write.join("p.ecproj").to_string_lossy().into_owned(); + let steps = serde_json::json!({"steps": [{"command": "file.saveAs", "params": {"path": saved}}, {"command": "edit.undo"}]}); + assert!(c.check_command("engine.batch", &steps).is_ok()); + let _ = std::fs::remove_dir_all(&base); + } + + #[cfg(unix)] + #[test] + fn a_render_folder_with_a_link_out_of_the_write_roots_is_refused() { + let base = std::env::temp_dir().join(format!("ec-confine-seq-link-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (read, write, outside) = (base.join("in"), base.join("out"), base.join("elsewhere")); + for d in [&read, &write, &outside] { + std::fs::create_dir_all(d).unwrap(); + } + let c = Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap(); + let template = write.join("frames_[#####].png").to_string_lossy().into_owned(); + let add = serde_json::json!({"output": template}); + let queue = serde_json::json!({"items": [{"outputPath": template}]}); + assert!(c.check_command("renderQueue.add", &add).is_ok()); + assert!(c.check_lookup(&Lookup::RenderOutputs, &queue).is_ok()); + // A link inside the write root is fine. + std::os::unix::fs::symlink(write.join("kept.png"), write.join("frames_00002.png")).unwrap(); + std::fs::write(write.join("kept.png"), b"x").unwrap(); + assert!(c.check_lookup(&Lookup::RenderOutputs, &queue).is_ok()); + // Frame 1's name leads outside: rendering would truncate that file. + std::os::unix::fs::symlink(outside.join("victim"), write.join("frames_00001.png")).unwrap(); + assert!(c.check_command("renderQueue.add", &add).is_err()); + assert!(c.check_command("render.preRender", &add).is_err()); + let e = c.check_lookup(&Lookup::RenderOutputs, &queue).unwrap_err().to_string(); + assert!(e.contains("frames_00001.png"), "{e}"); + let _ = std::fs::remove_dir_all(&base); + } + + #[cfg(unix)] + #[test] + fn a_render_folder_that_cannot_be_listed_is_refused() { + use std::os::unix::fs::PermissionsExt; + let base = std::env::temp_dir().join(format!("ec-confine-seq-nolist-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let write = base.join("out"); + std::fs::create_dir_all(&write).unwrap(); + let c = Confinement::new(&[], std::slice::from_ref(&write)).unwrap(); + let template = write.join("frames_[#####].png").to_string_lossy().into_owned(); + let add = serde_json::json!({"output": template}); + // Writable and searchable but not listable: a link in it could not be seen. + std::fs::set_permissions(&write, std::fs::Permissions::from_mode(0o300)).unwrap(); + // A user who can list it anyway (root) sees no difference to test. + if std::fs::read_dir(&write).is_err() { + assert!(c.check_command("renderQueue.add", &add).is_err()); + } + std::fs::set_permissions(&write, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert!(c.check_command("renderQueue.add", &add).is_ok()); + let _ = std::fs::remove_dir_all(&base); + } + + #[cfg(unix)] + #[test] + fn numbered_render_folders_with_links_out_of_the_write_roots_are_refused() { + let base = std::env::temp_dir().join(format!("ec-confine-seq-dir-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (write, outside) = (base.join("out"), base.join("elsewhere")); + for d in [&write, &outside] { + std::fs::create_dir_all(d).unwrap(); + } + let c = Confinement::new(&[], std::slice::from_ref(&write)).unwrap(); + let template = write.join("shot_[###]/f_[#####].png").to_string_lossy().into_owned(); + let add = serde_json::json!({"output": template}); + assert!(c.check_command("renderQueue.add", &add).is_ok(), "no numbered folder exists yet"); + // A numbered folder inside the roots holding a link out is refused. + std::fs::create_dir(write.join("shot_001")).unwrap(); + assert!(c.check_command("renderQueue.add", &add).is_ok()); + std::os::unix::fs::symlink(outside.join("victim"), write.join("shot_001/f_00001.png")).unwrap(); + assert!(c.check_command("renderQueue.add", &add).is_err()); + std::fs::remove_file(write.join("shot_001/f_00001.png")).unwrap(); + // A numbered folder that is itself a link out is refused. + std::os::unix::fs::symlink(&outside, write.join("shot_002")).unwrap(); + assert!(c.check_command("render.preRender", &add).is_err()); + // Unrelated names are left alone. + std::fs::remove_file(write.join("shot_002")).unwrap(); + std::os::unix::fs::symlink(&outside, write.join("shot_notes")).unwrap(); + assert!(c.check_command("renderQueue.add", &add).is_ok()); let _ = std::fs::remove_dir_all(&base); } } diff --git a/crates/av1enc/Cargo.toml b/crates/av1enc/Cargo.toml index 2a4c7ddfe..c70b885ce 100644 --- a/crates/av1enc/Cargo.toml +++ b/crates/av1enc/Cargo.toml @@ -12,4 +12,5 @@ workspace = true [dependencies] [dev-dependencies] +tempfile = { workspace = true } filmcraft-av1 = { workspace = true } diff --git a/crates/av1enc/tests/it/ffmpeg_oracle.rs b/crates/av1enc/tests/it/ffmpeg_oracle.rs index b35cbf496..ba206a680 100644 --- a/crates/av1enc/tests/it/ffmpeg_oracle.rs +++ b/crates/av1enc/tests/it/ffmpeg_oracle.rs @@ -57,8 +57,8 @@ fn check(name: &str, cfg: EncoderConfig, pics: &[Pic]) -> Vec { recons.push(enc.last_reconstruction().expect("reconstruction")); levels.push(enc.last_loop_filter_level().expect("level")); } - let dir = std::env::temp_dir().join(format!("effectcraft-av1enc-{}-{name}", std::process::id())); - std::fs::create_dir_all(&dir).expect("temp dir"); + let guard = tempfile::tempdir().expect("temp dir"); + let dir = guard.path().to_path_buf(); let input = dir.join("in.ivf"); let output = dir.join("out.yuv"); std::fs::write(&input, ivf(w, h, &packets)).expect("write ivf"); diff --git a/crates/effects/src/object_matte.rs b/crates/effects/src/object_matte.rs index 8a9750282..86534a178 100644 --- a/crates/effects/src/object_matte.rs +++ b/crates/effects/src/object_matte.rs @@ -301,7 +301,8 @@ pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w: let fr = fc.frames.get(&f)?; let [fw, fh] = fc.size; let expected = fw.checked_mul(fh)?; - if fw == 0 || fh == 0 || expected > rle::MAX_PLANE_BYTES { + // Only the layer's own full-resolution grid (see Roto Brush's frozen mattes). + if fc.size != roto::frozen_grid(layer_size) || fw == 0 || fh == 0 || expected > rle::MAX_PLANE_BYTES { return None; } let a = rle::decode_bounded(&fr.a, expected)?; @@ -719,6 +720,12 @@ mod tests { p.values.insert("objectPoint".into(), Value::Vec2([40.0, 40.0])); p.values.insert(FROZEN.into(), Value::Str(fc2.to_json())); assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, None), None); + // A compact plane claiming a size other than the layer's grid is not decoded. + let mut fc3 = fc.clone(); + fc3.size = [4000, 4000]; + fc3.frames.insert(2, roto::freeze_frame(&vec![1.0; 4000 * 4000], &[])); + p.values.insert(FROZEN.into(), Value::Str(fc3.to_json())); + assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, None), None); } /// Mattes frozen with a model stay usable in a session where that model is not loaded (a 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/puppet/mesh.rs b/crates/effects/src/puppet/mesh.rs index f01ee8f78..d5fd66dae 100644 --- a/crates/effects/src/puppet/mesh.rs +++ b/crates/effects/src/puppet/mesh.rs @@ -614,11 +614,72 @@ pub fn triangulate(outline: &[[f64; 2]], opts: &MeshOpts) -> Mesh { Mesh { verts, tris, outline: poly } } +/// The Expansion range the outline honours, in layer pixels (the property's slider range). A +/// project file can store any number; outside this range it is clamped, so the padded grid +/// below stays a few hundred pixels larger than the buffer. +pub const EXPANSION_RANGE: (f64, f64) = (-100.0, 200.0); +/// Most buffer pixels of padding the outline grid takes on each side (Expansion × buffer scale). +const MAX_PAD: f64 = 4096.0; +/// Most cells the padded outline grid may have (it is expanded with two `f64` distance buffers). +const MAX_GRID_CELLS: usize = 1 << 25; +/// Most the alpha is shrunk by, per axis, to make the padded grid fit; a layer needing more has no +/// outline. +const MAX_DOWNSAMPLE: usize = 8; + /// The outline of the alpha region under `seed` (buffer pixels → layer space via `scale` and -/// `offset`), expanded by `expansion` layer pixels. +/// `offset`), expanded by `expansion` layer pixels (clamped to [`EXPANSION_RANGE`]). When the +/// padded grid would pass [`MAX_PAD`] or [`MAX_GRID_CELLS`] (a high-scale layer with a large +/// Expansion), the alpha is traced at a coarser resolution instead of being skipped. pub fn outline(alpha: &[f32], w: usize, h: usize, scale: f64, offset: [f64; 2], seed: [f64; 2], expansion: f64) -> Vec<[f64; 2]> { - // Pad so expansion can grow past the buffer edge. - let pad = (expansion.max(0.0) * scale).ceil() as usize + 2; + let expansion = if expansion.is_finite() { expansion.clamp(EXPANSION_RANGE.0, EXPANSION_RANGE.1) } else { 0.0 }; + if alpha.len() < w.saturating_mul(h) { + return vec![]; + } + let want = (expansion.max(0.0) * scale).ceil(); + if !want.is_finite() { + return vec![]; + } + for k in 1..=MAX_DOWNSAMPLE { + let pad = (want / k as f64).ceil(); + let (kw, kh) = (w.div_ceil(k), h.div_ceil(k)); + let fits = pad <= MAX_PAD && { + let pad = pad as usize + 2; + kw.checked_add(2 * pad).zip(kh.checked_add(2 * pad)).and_then(|(pw, ph)| pw.checked_mul(ph)).is_some_and(|c| c <= MAX_GRID_CELLS) + }; + if !fits { + continue; + } + if k == 1 { + return outline_padded(alpha, [w, h], scale, offset, seed, expansion, pad as usize + 2); + } + let small = shrink(alpha, w, h, k); + let kf = k as f64; + return outline_padded(&small, [kw, kh], scale / kf, [offset[0] / kf, offset[1] / kf], seed, expansion, pad as usize + 2); + } + vec![] +} + +/// `alpha` (`w × h`) with every `k × k` block averaged into one pixel. +fn shrink(alpha: &[f32], w: usize, h: usize, k: usize) -> Vec { + let (kw, kh) = (w.div_ceil(k), h.div_ceil(k)); + let mut out = vec![0.0f32; kw * kh]; + for y in 0..kh { + for x in 0..kw { + let (x1, y1) = (((x + 1) * k).min(w), ((y + 1) * k).min(h)); + let (mut sum, mut n) = (0.0f32, 0u32); + for yy in y * k..y1 { + for xx in x * k..x1 { + sum += alpha[yy * w + xx]; + n += 1; + } + } + out[y * kw + x] = sum / n.max(1) as f32; + } + } + out +} + +fn outline_padded(alpha: &[f32], [w, h]: [usize; 2], scale: f64, offset: [f64; 2], seed: [f64; 2], expansion: f64, pad: usize) -> Vec<[f64; 2]> { let (pw, ph) = (w + 2 * pad, h + 2 * pad); let mut on = vec![false; pw * ph]; for y in 0..h { diff --git a/crates/effects/src/puppet/tests.rs b/crates/effects/src/puppet/tests.rs index 10426d46c..c2db999e1 100644 --- a/crates/effects/src/puppet/tests.rs +++ b/crates/effects/src/puppet/tests.rs @@ -106,6 +106,42 @@ fn expansion_grows_the_outline() { assert!((am - pi * 225.0).abs() < 60.0, "{am}"); } +/// An imported Expansion far past the property's range is clamped instead of padding the grid +/// by that many pixels (a 1e12 expansion would ask for terabytes). +#[test] +fn hostile_expansion_is_clamped() { + let (w, h) = (40, 40); + let alpha = disc_alpha(w, h, [20.0, 20.0], 10.0); + let max = signed_area(&outline(&alpha, w, h, 1.0, [0.0; 2], [20.0, 20.0], EXPANSION_RANGE.1)).abs(); + // A non-finite Expansion is taken as zero (the unexpanded outline), not clamped to the maximum. + let zero = signed_area(&outline(&alpha, w, h, 1.0, [0.0; 2], [20.0, 20.0], 0.0)).abs(); + for e in [1e12, f64::MAX, f64::INFINITY, f64::NAN] { + let a = signed_area(&outline(&alpha, w, h, 1.0, [0.0; 2], [20.0, 20.0], e)).abs(); + let want = if e.is_finite() { max } else { zero }; + assert!((a - want).abs() < 1.0, "{e}: {a} vs {want}"); + } + assert!(outline(&alpha, w, h, 1e9, [0.0; 2], [20.0, 20.0], 100.0).is_empty()); + let _ = outline(&alpha, w, h, 1.0, [0.0; 2], [20.0, 20.0], -1e12); +} + +/// A high-scale layer with a large Expansion is traced at a coarser resolution, not skipped, and a +/// tiny buffer with a huge padded grid stays within the grid budget. +#[test] +fn high_scale_expansion_is_traced_coarser_instead_of_skipped() { + let (w, h) = (40, 40); + let alpha = disc_alpha(w, h, [20.0, 20.0], 10.0); + // Scale 32, Expansion 130: 4160 px of padding, past MAX_PAD, so it is shrunk by 2. + let scale = 32.0; + let o = outline(&alpha, w, h, scale, [0.0; 2], [20.0, 20.0], 130.0); + let area = signed_area(&o).abs(); + // The disc has radius 10 / 32 layer px; expanded by 130. + let r = 10.0 / scale + 130.0; + assert!((area - std::f64::consts::PI * r * r).abs() < 0.03 * std::f64::consts::PI * r * r, "{area}"); + // One pixel at scale 20.48 and Expansion 1e12 (clamped): its 8197 px square grid is over budget. + let o = outline(&[1.0], 1, 1, 20.48, [0.0; 2], [0.5, 0.5], 1e12); + assert!(o.len() >= 3); +} + fn rect_mesh(w: f64, h: f64, tris: f64) -> Mesh { triangulate(&[[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]], &MeshOpts { triangles: tris, density: 50.0, expansion: 0.0 }) } diff --git a/crates/effects/src/roto.rs b/crates/effects/src/roto.rs index f9c26f417..733535b21 100644 --- a/crates/effects/src/roto.rs +++ b/crates/effects/src/roto.rs @@ -378,10 +378,22 @@ pub fn purge() { c.bytes = 0; } +/// The pixel size of the layer-space grid at `scale` (`round(size · scale)`, at least 1). +fn grid_size(layer_size: [f64; 2], scale: f64) -> (u32, u32) { + (((layer_size[0] * scale).round() as u32).max(1), ((layer_size[1] * scale).round() as u32).max(1)) +} + +/// The full-resolution layer grid frozen mattes are stored at. A frozen cache claiming any +/// other size did not come from freezing this layer and is not decoded. +pub fn frozen_grid(layer_size: [f64; 2]) -> [usize; 2] { + let (w, h) = grid_size(layer_size, 1.0); + [w as usize, h as usize] +} + /// The layer-space grid (`round(size · scale)` pixels) of a buffer. pub fn layer_grid(buf: &Buf, layer_size: [f64; 2]) -> Image { let s = if buf.scale > 0.0 { buf.scale } else { 1.0 }; - let (w, h) = (((layer_size[0] * s).round() as u32).max(1), ((layer_size[1] * s).round() as u32).max(1)); + let (w, h) = grid_size(layer_size, s); let (ox, oy) = (buf.offset[0].round() as i64, buf.offset[1].round() as i64); let mut img = Image::new(w, h); img.rows_mut().for_each(|(y, row)| { @@ -1212,8 +1224,11 @@ pub fn freeze_frame(alpha: &[f32], band: &[u8]) -> FrozenFrame { fn matte_for(ctx: &EffectCtx, f: i64, img: &Arc, scale: f64) -> Option<(Vec, Vec, f64)> { let (w, h) = (img.width as usize, img.height as usize); // Frozen mattes. + // Only the layer's own full-resolution grid: a project cannot make a tiny layer decode (and + // convert to floats) planes as large as the RLE allows. Any other size falls through to a computed matte. if let Some(fc) = frozen(ctx.params) && frozen_applies(&fc, ctx.params, ctx.layer_size, ctx.fps()) + && fc.size == frozen_grid(ctx.layer_size) && let Some(fr) = fc.frames.get(&f) { let [fw, fh] = fc.size; @@ -1409,6 +1424,33 @@ mod tests { params } + #[test] + fn mismatched_frozen_size_falls_back_to_a_computed_matte() { + let mut d = RotoData::default(); + d.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[35.0, 40.0], [45.0, 40.0]], object: 0 }, 20, [0, 24]); + let mut params = params_with(&d); + let fc = + FrozenCache { key: frozen_key(¶ms, [100.0, 80.0], 25.0), size: [10, 8], frames: BTreeMap::from([(5, freeze_frame(&[1.0; 80], &[0; 80]))]) }; + let host = Seq; + let render = |params: &Params| { + let ctx = EffectCtx { + params, + time: 5.0 / 25.0 + 0.001, + layer_size: [100.0, 80.0], + seed: 1, + adjustment: false, + env: EffectEnv { host: Some(&host), frame_rate: 25.0, ..Default::default() }, + }; + crate::apply(&specs()[0], &ctx, Buf { img: frame(5.0 / 25.0 + 0.001), offset: [0.0; 2], scale: 1.0 }).img + }; + let computed = render(¶ms); + params.values.insert(FROZEN.into(), Value::Str(fc.to_json())); + let out = render(¶ms); + assert!(out.data[(40 * 100 + 50) as usize][3] > 0.5, "the disk is missing from the output"); + // The cache's all-opaque matte would leave the background opaque too; the fallback must match a render with no cache. + assert!(out.data == computed.data, "the mismatched frozen cache was used"); + } + #[test] fn renders_the_segmented_disk_on_demand() { let mut d = RotoData::default(); @@ -1980,6 +2022,18 @@ mod tests { } } + /// Frozen mattes are only decoded at the size the freeze pass stores: the layer grid at + /// full resolution. + #[test] + fn frozen_grid_is_the_full_resolution_layer_grid() { + for size in [[100.0, 80.0], [99.6, 0.2], [1.0, 1.0]] { + let buf = Buf { img: Image::new(3, 3), offset: [0.0; 2], scale: 1.0 }; + let g = layer_grid(&buf, size); + assert_eq!(frozen_grid(size), [g.width as usize, g.height as usize]); + } + assert_eq!(frozen_grid([100.0, 80.0]), [100, 80]); + } + #[test] fn saved_tracking_decoders_validate_dimensions_and_plane_lengths() { let valid = rle::encode(&[255, 0, 255, 0]); diff --git a/crates/effects/src/sim/engine.rs b/crates/effects/src/sim/engine.rs index beda8c318..8d9914e31 100644 --- a/crates/effects/src/sim/engine.rs +++ b/crates/effects/src/sim/engine.rs @@ -8,9 +8,11 @@ use super::{Buf, EffectCtx, Image, IntoParallelRefMutIterator, ParallelIterator, /// Steps per second of the stepped particle simulations. pub(crate) const SPS: f64 = 60.0; -/// Number of whole steps reached at layer time `t`. +/// Number of whole steps reached at layer time `t` (at most [`crate::util::MAX_SIM_STEPS`]: the +/// per-step histories built from it, such as [`rate_history`], stay bounded however late a +/// project places the layer's time). pub(crate) fn steps_at(t: f64) -> u64 { - if t <= 0.0 { 0 } else { (t * SPS + 1e-6).floor() as u64 } + if t <= 0.0 { 0 } else { ((t * SPS + 1e-6).floor() as u64).min(crate::util::MAX_SIM_STEPS) } } #[derive(Clone, Debug)] diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index 9c14a6c69..390750c6e 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -562,13 +562,22 @@ struct SimEntry { /// Checkpoint every this many steps. pub const SIM_CHECKPOINT: u64 = 15; +/// Most steps a simulation runs: three hours of layer time at 60 steps per second. The layer +/// time comes from the project (start time, stretch), so without a limit a tiny file could ask +/// for years of steps; past this the state stays where it is. +pub const MAX_SIM_STEPS: u64 = 60 * 60 * 60 * 3; + +/// Most checkpoints kept per simulation (and made by one run, which spaces them further apart +/// as it goes). +const MAX_SIM_CHECKPOINTS: usize = 256; + impl SimCache { pub const fn new(cap: usize) -> SimCache { SimCache { inner: std::sync::Mutex::new(Vec::new()), cap } } - /// State after `target` steps. `init` builds step 0; `step(state, i)` advances from step - /// `i` to `i + 1`. + /// State after `target` steps (at most [`MAX_SIM_STEPS`]). `init` builds step 0; + /// `step(state, i)` advances from step `i` to `i + 1`. pub fn run(&self, key: u64, target: u64, init: impl FnOnce() -> S, step: impl FnMut(&mut S, u64)) -> std::sync::Arc { self.run_where(key, target, init, step, |_, _| true) } @@ -586,6 +595,7 @@ impl SimCache { valid: impl Fn(u64, &S) -> bool, ) -> std::sync::Arc { use std::sync::Arc; + let target = target.min(MAX_SIM_STEPS); let start = { let mut g = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let tick = g.iter().map(|e| e.used).max().unwrap_or(0) + 1; @@ -604,11 +614,17 @@ impl SimCache { if !had_start { fresh.push((0, Arc::new(st.clone()))); } + let mut every = SIM_CHECKPOINT; while i < target { step(&mut st, i); i += 1; - if i % SIM_CHECKPOINT == 0 && i != target { + if i % every == 0 && i != target { fresh.push((i, Arc::new(st.clone()))); + // A long run keeps a bounded number of states: spread them twice as far apart. + if fresh.len() > MAX_SIM_CHECKPOINTS { + every = every.saturating_mul(2); + fresh.retain(|(s, _)| s % every == 0); + } } } let out = Arc::new(st); @@ -628,19 +644,23 @@ impl SimCache { }; let e = &mut g[idx]; e.used = tick; - for (s, st) in fresh { - e.checkpoints.retain(|(c, _)| *c != s); - e.checkpoints.push((s, st)); - } - // Keep memory bounded: drop unaligned (exact-target) checkpoints first, then thin. - if e.checkpoints.len() > 256 { - e.checkpoints.retain(|(s, _)| s % SIM_CHECKPOINT == 0); + // Fresh checkpoints replace those at the same step (one pass, not one per checkpoint). + let steps: std::collections::HashSet = fresh.iter().map(|(s, _)| *s).collect(); + e.checkpoints.retain(|(c, _)| !steps.contains(c)); + e.checkpoints.extend(fresh); + // Keep memory bounded: drop unaligned (exact-target) checkpoints first, then thin. The + // state just asked for keeps its slot, so asking for it again is answered from the cache. + if e.checkpoints.len() > MAX_SIM_CHECKPOINTS { + e.checkpoints.retain(|(s, _)| s % SIM_CHECKPOINT == 0 && *s != target); e.checkpoints.sort_by_key(|(s, _)| *s); - let mut k = 0; + // Drop only the excess, evenly spaced, so the rest keep their seek distance. + let (len, excess) = (e.checkpoints.len(), (e.checkpoints.len() + 1).saturating_sub(MAX_SIM_CHECKPOINTS)); + let mut i = 0; e.checkpoints.retain(|_| { - k += 1; - k % 2 == 1 + i += 1; + (i * excess) / len == ((i - 1) * excess) / len }); + e.checkpoints.push((target, out.clone())); } out } @@ -818,6 +838,45 @@ mod tests { assert_eq!(d, e); } + /// Layer time from a project cannot run a simulation without end, and a long run keeps a + /// bounded number of checkpoints that still give the same answer when seeking. + #[test] + fn sim_cache_bounds_steps_and_checkpoints() { + let c: SimCache = SimCache::new(2); + let count = |s: &mut u64, _: u64| *s += 1; + assert_eq!(*c.run(1, u64::MAX, || 0, count), MAX_SIM_STEPS); + let n = c.inner.lock().map(|g| g.iter().map(|e| e.checkpoints.len()).max().unwrap_or(0)).unwrap_or(0); + assert!(n <= MAX_SIM_CHECKPOINTS + 1, "{n} checkpoints"); + let fresh: SimCache = SimCache::new(2); + let step = |s: &mut u64, i: u64| *s = s.wrapping_mul(6364136223846793005).wrapping_add(i); + let far = *fresh.run(1, 100_000, || 7, step); + let mid = *fresh.run(1, 54_321, || 7, step); + let again: SimCache = SimCache::new(2); + assert_eq!(*again.run(1, 54_321, || 7, step), mid); + assert_eq!(*again.run(1, 100_000, || 7, step), far); + } + + /// A request that leaves the cache over its checkpoint cap still finds its own state there. + #[test] + fn sim_cache_keeps_the_requested_state_when_trimming() { + let c: SimCache = SimCache::new(2); + let steps = std::cell::Cell::new(0u64); + let count = |s: &mut u64, _: u64| { + steps.set(steps.get() + 1); + *s += 1; + }; + let target = SIM_CHECKPOINT * (MAX_SIM_CHECKPOINTS as u64 - 1) + SIM_CHECKPOINT - 1; + assert_eq!(*c.run(1, target, || 0, count), target); + let n = c.inner.lock().map(|g| g.iter().map(|e| e.checkpoints.len()).max().unwrap_or(0)).unwrap_or(0); + assert!(n <= MAX_SIM_CHECKPOINTS, "{n} checkpoints"); + steps.set(0); + assert_eq!(*c.run(1, target, || 0, count), target); + assert_eq!(steps.get(), 0, "the second request is answered from the cache"); + // Only the excess is dropped: the other aligned checkpoints stay. + let kept = c.inner.lock().map(|g| g[0].checkpoints.len()).unwrap_or(0); + assert_eq!(kept, MAX_SIM_CHECKPOINTS, "trimmed to the cap, not halved"); + } + #[test] fn poly_helpers() { let sq = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.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/effects/tests/it/gen_golden.rs b/crates/effects/tests/it/gen_golden.rs index 0f5fced8b..45092841a 100644 --- a/crates/effects/tests/it/gen_golden.rs +++ b/crates/effects/tests/it/gen_golden.rs @@ -413,46 +413,46 @@ fn plan_effects_golden_hashes() { const GOLDEN: &[(&str, u64)] = &[ ("ec.obsolete.lightning/0/8bpc/t0.4", 0xd829bb3a79ec2d61), ("ec.obsolete.lightning/0/8bpc/t1.3", 0xb2221364c0defe9b), - ("ec.obsolete.lightning/0/32bpc/t0.4", 0x828cc4e53d579af7), - ("ec.obsolete.lightning/0/32bpc/t1.3", 0x15770238cde9e766), - ("ec.obsolete.lightning/0/32bpc/half/t0.4", 0xf64f2b87fa9a09d3), - ("ec.obsolete.lightning/0/32bpc/half/t1.3", 0x625a00bd52fde5aa), + ("ec.obsolete.lightning/0/32bpc/t0.4", 0x38c8b3ca5d03eb81), + ("ec.obsolete.lightning/0/32bpc/t1.3", 0x552241c6035fa660), + ("ec.obsolete.lightning/0/32bpc/half/t0.4", 0x6158941311ce44d9), + ("ec.obsolete.lightning/0/32bpc/half/t1.3", 0xc7c2a804de6140a3), ("ec.obsolete.lightning/1/8bpc/t0.4", 0xa7c8cb94beebca84), ("ec.obsolete.lightning/1/8bpc/t1.3", 0x6e583b1d1f455099), - ("ec.obsolete.lightning/1/32bpc/t0.4", 0x2ffd973cc7de98f7), - ("ec.obsolete.lightning/1/32bpc/t1.3", 0x8f35e6018cac57d7), - ("ec.obsolete.lightning/1/32bpc/half/t0.4", 0x96c14e911783f8d6), - ("ec.obsolete.lightning/1/32bpc/half/t1.3", 0x620ee605f56c2068), + ("ec.obsolete.lightning/1/32bpc/t0.4", 0xe34f30bf669e94d5), + ("ec.obsolete.lightning/1/32bpc/t1.3", 0xb1ec8fad75bbbc32), + ("ec.obsolete.lightning/1/32bpc/half/t0.4", 0x9e2934559f25d0d9), + ("ec.obsolete.lightning/1/32bpc/half/t1.3", 0x8d2f6967b73bbf54), ("ec.obsolete.lightning/2/8bpc/t0.4", 0x119e5cedac21cd9b), ("ec.obsolete.lightning/2/8bpc/t1.3", 0x04b775fba837ddce), - ("ec.obsolete.lightning/2/32bpc/t0.4", 0xfee767091adac4ce), - ("ec.obsolete.lightning/2/32bpc/t1.3", 0x2ae26f9670c93751), - ("ec.obsolete.lightning/2/32bpc/half/t0.4", 0x99111f77bf26704f), - ("ec.obsolete.lightning/2/32bpc/half/t1.3", 0x8eb781efc14183c3), + ("ec.obsolete.lightning/2/32bpc/t0.4", 0x551415572abb270a), + ("ec.obsolete.lightning/2/32bpc/t1.3", 0x3e1b68b5b9cf2e8c), + ("ec.obsolete.lightning/2/32bpc/half/t0.4", 0x69b347a75e8ac280), + ("ec.obsolete.lightning/2/32bpc/half/t1.3", 0xe89cc144b611a083), ("ec.generate.advancedlightning/0/8bpc/t0.4", 0x5a53bf1e0236225d), ("ec.generate.advancedlightning/0/8bpc/t1.3", 0x5a53bf1e0236225d), - ("ec.generate.advancedlightning/0/32bpc/t0.4", 0x6817bf5b3583e0a0), - ("ec.generate.advancedlightning/0/32bpc/t1.3", 0x6817bf5b3583e0a0), - ("ec.generate.advancedlightning/0/32bpc/half/t0.4", 0x5dcc055f6cca9922), - ("ec.generate.advancedlightning/0/32bpc/half/t1.3", 0x5dcc055f6cca9922), + ("ec.generate.advancedlightning/0/32bpc/t0.4", 0x8ae323866971eaf4), + ("ec.generate.advancedlightning/0/32bpc/t1.3", 0x8ae323866971eaf4), + ("ec.generate.advancedlightning/0/32bpc/half/t0.4", 0x74b4ba806ab8e78b), + ("ec.generate.advancedlightning/0/32bpc/half/t1.3", 0x74b4ba806ab8e78b), ("ec.generate.advancedlightning/1/8bpc/t0.4", 0xf07f11457fa683d0), ("ec.generate.advancedlightning/1/8bpc/t1.3", 0xf07f11457fa683d0), - ("ec.generate.advancedlightning/1/32bpc/t0.4", 0x1b21531df9187431), - ("ec.generate.advancedlightning/1/32bpc/t1.3", 0x1b21531df9187431), - ("ec.generate.advancedlightning/1/32bpc/half/t0.4", 0xf1032a0a9345edf1), - ("ec.generate.advancedlightning/1/32bpc/half/t1.3", 0xf1032a0a9345edf1), + ("ec.generate.advancedlightning/1/32bpc/t0.4", 0x1474a59cbdbcb6aa), + ("ec.generate.advancedlightning/1/32bpc/t1.3", 0x1474a59cbdbcb6aa), + ("ec.generate.advancedlightning/1/32bpc/half/t0.4", 0x4808561ce9d8a4a6), + ("ec.generate.advancedlightning/1/32bpc/half/t1.3", 0x4808561ce9d8a4a6), ("ec.generate.advancedlightning/2/8bpc/t0.4", 0x62b5d179f05c6a4c), ("ec.generate.advancedlightning/2/8bpc/t1.3", 0x62b5d179f05c6a4c), - ("ec.generate.advancedlightning/2/32bpc/t0.4", 0x192256fe81f73975), - ("ec.generate.advancedlightning/2/32bpc/t1.3", 0x192256fe81f73975), - ("ec.generate.advancedlightning/2/32bpc/half/t0.4", 0x953dd51008c3ce31), - ("ec.generate.advancedlightning/2/32bpc/half/t1.3", 0x953dd51008c3ce31), + ("ec.generate.advancedlightning/2/32bpc/t0.4", 0x73c4b44db599a5c4), + ("ec.generate.advancedlightning/2/32bpc/t1.3", 0x73c4b44db599a5c4), + ("ec.generate.advancedlightning/2/32bpc/half/t0.4", 0x3085cf1b5f6e8311), + ("ec.generate.advancedlightning/2/32bpc/half/t1.3", 0x3085cf1b5f6e8311), ("ec.generate.advancedlightning/3/8bpc/t0.4", 0x57b6ad61bd3d89e0), ("ec.generate.advancedlightning/3/8bpc/t1.3", 0x57b6ad61bd3d89e0), - ("ec.generate.advancedlightning/3/32bpc/t0.4", 0x7e7c9d20e94c8b6c), - ("ec.generate.advancedlightning/3/32bpc/t1.3", 0x7e7c9d20e94c8b6c), - ("ec.generate.advancedlightning/3/32bpc/half/t0.4", 0xa37940f5a490a0fe), - ("ec.generate.advancedlightning/3/32bpc/half/t1.3", 0xa37940f5a490a0fe), + ("ec.generate.advancedlightning/3/32bpc/t0.4", 0x86eee3d36bfd41f2), + ("ec.generate.advancedlightning/3/32bpc/t1.3", 0x86eee3d36bfd41f2), + ("ec.generate.advancedlightning/3/32bpc/half/t0.4", 0xb24444985859d820), + ("ec.generate.advancedlightning/3/32bpc/half/t1.3", 0xb24444985859d820), ("ec.generate.radiowaves/0/8bpc/t0.4", 0xbc3643b8af12c44b), ("ec.generate.radiowaves/0/8bpc/t1.3", 0x703e948dc562e132), ("ec.generate.radiowaves/0/32bpc/t0.4", 0x2b0db208ee7517c1), @@ -479,8 +479,8 @@ const GOLDEN: &[(&str, u64)] = &[ ("ec.generate.radiowaves/3/32bpc/half/t1.3", 0xcf62c191e2aa8cc4), ("ec.generate.vegas/0/8bpc/t0.4", 0x5e58d44254e9101b), ("ec.generate.vegas/0/8bpc/t1.3", 0x5e58d44254e9101b), - ("ec.generate.vegas/0/32bpc/t0.4", 0xe18712686e402759), - ("ec.generate.vegas/0/32bpc/t1.3", 0xe18712686e402759), + ("ec.generate.vegas/0/32bpc/t0.4", 0x2c6093dc655c6dbe), + ("ec.generate.vegas/0/32bpc/t1.3", 0x2c6093dc655c6dbe), ("ec.generate.vegas/0/32bpc/half/t0.4", 0xb037c97234bbd936), ("ec.generate.vegas/0/32bpc/half/t1.3", 0xb037c97234bbd936), ("ec.generate.vegas/1/8bpc/t0.4", 0x68566eb5dc411cd3), @@ -491,8 +491,8 @@ const GOLDEN: &[(&str, u64)] = &[ ("ec.generate.vegas/1/32bpc/half/t1.3", 0xe2f8f5f4a12628d6), ("ec.generate.vegas/2/8bpc/t0.4", 0x002c48a91aea2dcf), ("ec.generate.vegas/2/8bpc/t1.3", 0x002c48a91aea2dcf), - ("ec.generate.vegas/2/32bpc/t0.4", 0x4f550bbbf8b8316b), - ("ec.generate.vegas/2/32bpc/t1.3", 0x4f550bbbf8b8316b), + ("ec.generate.vegas/2/32bpc/t0.4", 0xd703631844687600), + ("ec.generate.vegas/2/32bpc/t1.3", 0xd703631844687600), ("ec.generate.vegas/2/32bpc/half/t0.4", 0x30036df3e56a01ed), ("ec.generate.vegas/2/32bpc/half/t1.3", 0x30036df3e56a01ed), ("ec.generate.stroke/0/8bpc/t0.4", 0xa580fbfb806d655c), @@ -557,10 +557,10 @@ const GOLDEN: &[(&str, u64)] = &[ ("ec.generate.paintbucket/1/32bpc/half/t1.3", 0xf5ea3b0a7ac224ed), ("ec.generate.paintbucket/2/8bpc/t0.4", 0x530d0a248dc22c72), ("ec.generate.paintbucket/2/8bpc/t1.3", 0x530d0a248dc22c72), - ("ec.generate.paintbucket/2/32bpc/t0.4", 0xe1b655f8815e1357), - ("ec.generate.paintbucket/2/32bpc/t1.3", 0xe1b655f8815e1357), - ("ec.generate.paintbucket/2/32bpc/half/t0.4", 0xb95b3b205255cd7e), - ("ec.generate.paintbucket/2/32bpc/half/t1.3", 0xb95b3b205255cd7e), + ("ec.generate.paintbucket/2/32bpc/t0.4", 0xf05e50b4261103a3), + ("ec.generate.paintbucket/2/32bpc/t1.3", 0xf05e50b4261103a3), + ("ec.generate.paintbucket/2/32bpc/half/t0.4", 0x7e74e09fcc83615c), + ("ec.generate.paintbucket/2/32bpc/half/t1.3", 0x7e74e09fcc83615c), ("ec.generate.paintbucket/3/8bpc/t0.4", 0xa7485b7277e03e66), ("ec.generate.paintbucket/3/8bpc/t1.3", 0xa7485b7277e03e66), ("ec.generate.paintbucket/3/32bpc/t0.4", 0xc8e06c6eed30448b), @@ -666,16 +666,16 @@ const GOLDEN: &[(&str, u64)] = &[ ("ec.key.innerouter/0/32bpc/half/t1.3", 0x003941eb2cd18339), ("ec.key.innerouter/1/8bpc/t0.4", 0xd7bfea3d9550db56), ("ec.key.innerouter/1/8bpc/t1.3", 0xd7bfea3d9550db56), - ("ec.key.innerouter/1/32bpc/t0.4", 0x62f7705e43cc6a71), - ("ec.key.innerouter/1/32bpc/t1.3", 0x62f7705e43cc6a71), - ("ec.key.innerouter/1/32bpc/half/t0.4", 0x22f15f770f22f072), - ("ec.key.innerouter/1/32bpc/half/t1.3", 0x22f15f770f22f072), + ("ec.key.innerouter/1/32bpc/t0.4", 0x36661c78c393b52a), + ("ec.key.innerouter/1/32bpc/t1.3", 0x36661c78c393b52a), + ("ec.key.innerouter/1/32bpc/half/t0.4", 0x73f19ba1aea7e496), + ("ec.key.innerouter/1/32bpc/half/t1.3", 0x73f19ba1aea7e496), ("ec.key.innerouter/2/8bpc/t0.4", 0x7bdece44f841bb11), ("ec.key.innerouter/2/8bpc/t1.3", 0x7bdece44f841bb11), - ("ec.key.innerouter/2/32bpc/t0.4", 0x5130a1fc09c42120), - ("ec.key.innerouter/2/32bpc/t1.3", 0x5130a1fc09c42120), - ("ec.key.innerouter/2/32bpc/half/t0.4", 0x46c0880853618fc8), - ("ec.key.innerouter/2/32bpc/half/t1.3", 0x46c0880853618fc8), + ("ec.key.innerouter/2/32bpc/t0.4", 0x093157401c50396e), + ("ec.key.innerouter/2/32bpc/t1.3", 0x093157401c50396e), + ("ec.key.innerouter/2/32bpc/half/t0.4", 0xbde58c0668a48529), + ("ec.key.innerouter/2/32bpc/half/t1.3", 0xbde58c0668a48529), ("ec.color.colorlink/0/8bpc/t0.4", 0x539d1da6b2ed744a), ("ec.color.colorlink/0/8bpc/t1.3", 0x539d1da6b2ed744a), ("ec.color.colorlink/0/32bpc/t0.4", 0x67ed14da67ce1a97), diff --git a/crates/effects/tests/it/sim_golden.rs b/crates/effects/tests/it/sim_golden.rs index 388790901..4a0f3858b 100644 --- a/crates/effects/tests/it/sim_golden.rs +++ b/crates/effects/tests/it/sim_golden.rs @@ -537,18 +537,18 @@ const GOLDEN: &[(&str, u64)] = &[ ("ec.sim.caustics/1/8bpc/t1.5", 0x282968317c36ccdf), ("ec.sim.caustics/1/8bpc/t0.8", 0x282968317c36ccdf), ("ec.sim.caustics/1/8bpc/t2.2", 0x282968317c36ccdf), - ("ec.sim.caustics/1/32bpc/t0.5", 0xc12df8561f396d45), - ("ec.sim.caustics/1/32bpc/t1.5", 0xc12df8561f396d45), - ("ec.sim.caustics/1/32bpc/t0.8", 0xc12df8561f396d45), - ("ec.sim.caustics/1/32bpc/t2.2", 0xc12df8561f396d45), + ("ec.sim.caustics/1/32bpc/t0.5", 0xaa63d15038f75de2), + ("ec.sim.caustics/1/32bpc/t1.5", 0xaa63d15038f75de2), + ("ec.sim.caustics/1/32bpc/t0.8", 0xaa63d15038f75de2), + ("ec.sim.caustics/1/32bpc/t2.2", 0xaa63d15038f75de2), ("ec.sim.caustics/2/8bpc/t0.5", 0x52ab258ae56e9b15), ("ec.sim.caustics/2/8bpc/t1.5", 0x52ab258ae56e9b15), ("ec.sim.caustics/2/8bpc/t0.8", 0x52ab258ae56e9b15), ("ec.sim.caustics/2/8bpc/t2.2", 0x52ab258ae56e9b15), - ("ec.sim.caustics/2/32bpc/t0.5", 0x9a820d0e7c6d11ea), - ("ec.sim.caustics/2/32bpc/t1.5", 0x9a820d0e7c6d11ea), - ("ec.sim.caustics/2/32bpc/t0.8", 0x9a820d0e7c6d11ea), - ("ec.sim.caustics/2/32bpc/t2.2", 0x9a820d0e7c6d11ea), + ("ec.sim.caustics/2/32bpc/t0.5", 0x51d4304b505b5462), + ("ec.sim.caustics/2/32bpc/t1.5", 0x51d4304b505b5462), + ("ec.sim.caustics/2/32bpc/t0.8", 0x51d4304b505b5462), + ("ec.sim.caustics/2/32bpc/t2.2", 0x51d4304b505b5462), ("ec.sim.waveworld/0/8bpc/t0.5", 0x425d5d639d2a6495), ("ec.sim.waveworld/0/8bpc/t1.5", 0x233d7370e580804a), ("ec.sim.waveworld/0/8bpc/t0.8", 0x866da86ce5e78eb2), 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/cmd/file_more.rs b/crates/engine/src/cmd/file_more.rs index 168529c47..3088e8828 100644 --- a/crates/engine/src/cmd/file_more.rs +++ b/crates/engine/src/cmd/file_more.rs @@ -395,6 +395,9 @@ fn collect_files(s: &mut Session, p: &Value) -> Result { // reads (other footage, a proxy, the project file itself), and then nothing is written. let protected = s.protected_files(); let mut used_names = BTreeSet::new(); + // Where each file name's numbering resumes: a name repeated many times is numbered from its + // own file name, not by prefixing the previous candidate again. + let mut next_k: BTreeMap = BTreeMap::new(); let mut plan: Vec<(ItemId, Vec<(String, String)>)> = vec![]; for it in s.project.items.values() { let ItemKind::Footage(f) = &it.kind else { continue }; @@ -404,11 +407,17 @@ fn collect_files(s: &mut Session, p: &Value) -> Result { let files: Vec<&String> = if f.sequence.is_empty() { vec![&f.path] } else { f.sequence.iter().collect() }; let mut pairs = vec![]; for src in files { - let mut name = std::path::Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "footage".into()); - let mut k = 2; - while !used_names.insert(name.clone()) { - name = format!("{k}_{name}"); - k += 1; + let file = std::path::Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "footage".into()); + let mut name = file.clone(); + if !used_names.insert(name.clone()) { + let k = next_k.entry(file.clone()).or_insert(2); + loop { + name = format!("{k}_{file}"); + *k += 1; + if used_names.insert(name.clone()) { + break; + } + } } let dst = format!("{folder}/(Footage)/{name}"); // A file that is already where its copy goes stays (it is not copied onto itself). diff --git a/crates/engine/src/cmd/frame_export.rs b/crates/engine/src/cmd/frame_export.rs index d6f491fb0..61e1ac82d 100644 --- a/crates/engine/src/cmd/frame_export.rs +++ b/crates/engine/src/cmd/frame_export.rs @@ -9,6 +9,8 @@ //! premultiplied): the composite as `R`, `G`, `B`, `A` plus one `.R/G/B/A` channel //! set per layer (the layer-prefixed channel convention multi-layer EXR readers use). +use std::collections::{HashMap, HashSet}; + use effectcraft_color::BlendMode; use effectcraft_project::{ItemId, LayerId, Project}; use effectcraft_raster::Image; @@ -141,17 +143,22 @@ pub fn layered_psd(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec /// A unique EXR layer name (no dots: they separate the layer from the channel). Printable ASCII only: /// the `exr` crate panics on characters it cannot store in a channel name (#1702). -fn exr_name(name: &str, used: &mut Vec) -> String { +/// `used` holds the names given so far, `next` the suffix each base's search resumes at: many +/// layers of one name take a name each without retrying every suffix before theirs. +fn exr_name(name: &str, used: &mut HashSet, next: &mut HashMap) -> String { let base: String = name.chars().map(|c| if c == '.' || !(c == ' ' || c.is_ascii_graphic()) { '_' } else { c }).collect(); let base = if base.trim().is_empty() { "Layer".to_string() } else { base }; - let mut n = base.clone(); - let mut k = 2; - while used.contains(&n) { - n = format!("{base} {k}"); - k += 1; + if used.insert(base.clone()) { + return base; + } + let k = next.entry(base.clone()).or_insert(2); + loop { + let n = format!("{base} {k}"); + *k += 1; + if used.insert(n.clone()) { + return n; + } } - used.push(n.clone()); - n } /// The multi-layer EXR bytes of a frame and the channel names. @@ -186,9 +193,9 @@ pub fn layered_exr(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec } }; push("", &full); - let mut used = vec![]; + let (mut used, mut next) = (HashSet::new(), HashMap::new()); for l in &layers { - let n = exr_name(&l.name, &mut used); + let n = exr_name(&l.name, &mut used, &mut next); push(&n, &l.image); } let layer = Layer::new((w, h), LayerAttributes::default(), Encoding::SMALL_LOSSLESS, AnyChannels::sort(channels.into())); @@ -248,3 +255,23 @@ pub fn specs() -> Vec { ), ] } + +#[cfg(test)] +mod tests { + use std::collections::{HashMap, HashSet}; + + use super::exr_name; + + #[test] + fn many_layers_of_one_name_take_a_name_each_in_order() { + let (mut used, mut next) = (HashSet::new(), HashMap::new()); + // `A 3` is taken by a layer of its own before the repeats reach it. + let names: Vec = ["A", "A", "A 3", "A", "A.b"].iter().map(|n| exr_name(n, &mut used, &mut next)).collect(); + assert_eq!(names, ["A", "A 2", "A 3", "A 4", "A_b"]); + // 20 000 layers of one name: each found without retrying the suffixes before it. + for _ in 0..20_000 { + exr_name("Same", &mut used, &mut next); + } + assert_eq!(next["Same"], 20_001); + } +} diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index f48fa26f8..de43454f4 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -24,7 +24,7 @@ mod transform; use contents::{add_mask, add_shape_item, mask_props, set_text}; use create::{add_item, new_adjustment, new_null, new_shape, new_solid, new_text}; -pub(crate) use create::{color_p, index_p, insert_layer, insert_layer_at, place, position_p}; +pub(crate) use create::{color_p, fit_span, index_p, insert_layer, insert_layer_at, new_layer_span, place, position_p, show_span}; pub(crate) use parent::parent_fixes; use parent::set_parent; use replace::{has_replace_source, replace_source}; diff --git a/crates/engine/src/cmd/layer/create.rs b/crates/engine/src/cmd/layer/create.rs index 0196b5aee..c46222e7e 100644 --- a/crates/engine/src/cmd/layer/create.rs +++ b/crates/engine/src/cmd/layer/create.rs @@ -14,6 +14,39 @@ pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> { } } +/// Settings ▸ General ▸ Fit New Layers to Work Area: the `(start, end)` that new text, solid, +/// shape, null and adjustment layers in `comp` take, or `None` for the whole composition. +pub(crate) fn new_layer_span(s: &Session, comp: &effectcraft_project::Comp) -> Option<(Tick, Tick)> { + let (start, end) = (comp.work_area.0, comp.work_area.1.min(comp.duration)); + (s.prefs.general.fit_new_layers_to_work_area && start >= Tick::ZERO && start < end).then_some((start, end)) +} + +/// Start a new layer at `span`'s start and end it at `span`'s end, when there is a span. +pub(crate) fn fit_span(l: &mut Layer, span: Option<(Tick, Tick)>) { + if let Some((start, end)) = span { + l.start_time = start; + l.in_point = start; + l.out_point = end; + } +} + +/// Tools draw on the layer they create: when it was fitted to a span the playhead is outside, +/// move `comp`'s playhead to the first frame in the span so the new layer shows. The layer's timing +/// is kept. The work area need not sit on frames (a typed start, a frame-rate change), so the +/// frame is rounded up from the start, not to the nearest; a span holding no frame moves nothing. +pub(crate) fn show_span(s: &mut Session, comp: ItemId, span: Option<(Tick, Tick)>) { + let Some(fr) = s.project.comp(comp).map(|c| c.frame_rate) else { return }; + if let Some((start, end)) = span + && !(start..end).contains(&s.time_of(comp)) + { + let first = fr.snap(start); + let first = if first < start { fr.tick_of(fr.frame_at(start).saturating_add(1)) } else { first }; + if first < end { + s.set_time_of(comp, first); + } + } +} + /// Insert a new layer above the selection (or at the top) and select it. pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result { insert_layer_at(proj, st, cid, layer, None) @@ -89,10 +122,12 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result let pixel_aspect = pixel_aspect_p(p)?.unwrap_or(comp.pixel_aspect); let default_name = if adjustment { "Adjustment Layer 1".to_string() } else { "Solid 1".to_string() }; let name = str_p(p, "name").map(str::to_string).unwrap_or(default_name); + let span = new_layer_span(s, &comp); let id = s.edit(if adjustment { "New Adjustment Layer" } else { "New Solid" }, None, |proj, st| { let folder = solids_folder(proj); let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect })); let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (w, h), None); + fit_span(&mut l, span); if adjustment { l.switches.adjustment = true; l.label = Label::Purple; @@ -186,9 +221,11 @@ pub(super) fn new_text(s: &mut Session, p: &Value) -> Result { } } let edit = b_p(p, "edit").unwrap_or(false); + let span = new_layer_span(s, &comp); let id = s.edit("New Text Layer", None, |proj, st| { let name: String = layer_name.clone().unwrap_or_else(|| effectcraft_project::text_layer_name(&doc.text)); let mut l = build::layer(proj, &comp, if name.is_empty() { "Text" } else { &name }, LayerSource::Text, (comp.width, comp.height), None); + fit_span(&mut l, span); // Named after its text, the name follows the text until renamed; a given name does not. l.name_follows_text = Some(layer_name.as_deref().is_none_or(str::is_empty)); if let Some(pr) = l.props.prop_mut("text/sourceText") { @@ -213,6 +250,9 @@ pub(super) fn new_text(s: &mut Session, p: &Value) -> Result { } Ok(id) })?; + if edit { + show_span(s, cid, span); + } Ok(json!({"layer": id.0})) } @@ -232,8 +272,9 @@ pub(super) fn new_shape(s: &mut Session, p: &Value) -> Result { .and_then(|v| v.as_array()) .map(|a| [a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]); let name = str_p(p, "name").unwrap_or("Shape Layer 1").to_string(); + let span = new_layer_span(s, &comp); let id = s.edit("New Shape Layer", None, |proj, st| { - let lid = crate::cmd::shape_tool::new_shape_layer(proj, st, &comp, cid, Some(&name), pos)?; + let lid = crate::cmd::shape_tool::new_shape_layer(proj, st, &comp, cid, Some(&name), pos, span)?; let mut next = proj.next_id; let g = crate::cmd::shape_tool::shape_group(&mut Ids(&mut next), &kind, size, &paint); proj.next_id = next; @@ -249,8 +290,10 @@ fn new_simple(s: &mut Session, p: &Value, src: LayerSource, name: &str, label: & let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let name = str_p(p, "name").unwrap_or(name).to_string(); + let span = new_layer_span(s, &comp); let id = s.edit(label, None, |proj, st| { - let l = build::layer(proj, &comp, &name, src, (comp.width, comp.height), None); + let mut l = build::layer(proj, &comp, &name, src, (comp.width, comp.height), None); + fit_span(&mut l, span); insert_layer(proj, st, cid, l) })?; Ok(json!({"layer": id.0})) diff --git a/crates/engine/src/cmd/shape_tool.rs b/crates/engine/src/cmd/shape_tool.rs index 57b22ba82..8b9cc28f2 100644 --- a/crates/engine/src/cmd/shape_tool.rs +++ b/crates/engine/src/cmd/shape_tool.rs @@ -8,6 +8,7 @@ use effectcraft_geom::vec2; use effectcraft_keyframe::{Gradient, ShapePath, Value as KV}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{Comp, ItemId, LayerId, LayerSource, Project, PropGroup}; +use effectcraft_time::Tick; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -263,7 +264,8 @@ pub(crate) fn draw_target(s: &Session, comp: &Comp, p: &Value, cmd: &str) -> Res } /// A new empty shape layer ("Shape Layer n" unless `name`) with its Position at `position` (comp -/// pixels; else the comp centre), above the selected layer and selected. +/// pixels; else the comp centre), above the selected layer and selected. `span` is the +/// [`new_layer_span`](super::layer::new_layer_span) the layer takes, if any. pub(crate) fn new_shape_layer( proj: &mut Project, st: &mut EditorState, @@ -271,11 +273,13 @@ pub(crate) fn new_shape_layer( cid: ItemId, name: Option<&str>, position: Option<[f64; 2]>, + span: Option<(Tick, Tick)>, ) -> Result { let count = comp.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count(); let name = name.map(str::to_string).unwrap_or_else(|| format!("Shape Layer {}", count + 1)); let mut l = build::layer(proj, comp, &name, LayerSource::Shape, (comp.width, comp.height), None); place(&mut l, position); + super::layer::fit_span(&mut l, span); insert_layer(proj, st, cid, l) } @@ -346,10 +350,11 @@ fn new_shape(s: &mut Session, p: &Value) -> Result { _ => "Rectangle Tool", }; let name = str_p(p, "name"); + let span = super::layer::new_layer_span(s, &comp); let (lid, group) = s.edit(label, None, |proj, st| { let lid = match target { Some(l) => l, - None => new_shape_layer(proj, st, &comp, cid, name, Some(pos))?, + None => new_shape_layer(proj, st, &comp, cid, name, Some(pos), span)?, }; let mut next = proj.next_id; let g = shape_group(&mut Ids(&mut next), kind, size, &paint).ok_or_else(|| bad(c, "unknown kind"))?; @@ -358,6 +363,9 @@ fn new_shape(s: &mut Session, p: &Value) -> Result { st.selected_layers = vec![lid]; Ok((lid, group)) })?; + if target.is_none() { + super::layer::show_span(s, cid, span); + } Ok(json!({"layer": lid.0, "group": group})) } diff --git a/crates/engine/src/cmd/time.rs b/crates/engine/src/cmd/time.rs index 556c02bcb..49a3caede 100644 --- a/crates/engine/src/cmd/time.rs +++ b/crates/engine/src/cmd/time.rs @@ -43,7 +43,9 @@ pub fn display_duration(s: &Session, comp: &Comp, len: Tick) -> String { } /// Parse a time typed in the project's Time Display Style (relative `+n`/`-n` from frame -/// `current`) into a comp frame. +/// `current`) into a comp frame. In Timecode style, in a comp whose Start Timecode is negative, a +/// `-` before a timecode with separators (`-0:00:00:12`, `-1.00`) is the sign of a displayed time, +/// not a step back; a bare `-n` still steps back. pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) -> std::result::Result { let fr = comp.frame_rate; let st = &s.project.settings; @@ -53,6 +55,20 @@ pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) -> effectcraft_time::parse_feet_frames(text, pf, current + start).map(|f| f - start).map_err(|e| e.0) } None => { + // A comp whose Start Timecode is negative shows the times before 0 with a `-` + // (`-0:00:00:12`), so there a `-` before a timecode with separators is the sign of a + // displayed time and typing what the display shows lands on the frame it shows. A bare + // `-n` still moves n frames back. + if st.time_display == TimeDisplayStyle::Timecode + && comp.display_start < Tick::ZERO + && let Some(body) = text.trim().strip_prefix('-').map(str::trim_start) + && body.contains([':', ';', '.', ',']) + && !body.starts_with(['+', '-']) + { + let drop_frame = fr.supports_drop_frame() && body.contains(';'); + let shown = effectcraft_time::parse_timecode(body, fr, drop_frame, current).map_err(|e| e.0)?; + return Ok(shown.saturating_neg().saturating_sub(fr.frame_at(comp.display_start))); + } // The Timeline shows non-drop timecode (`:`), so typed `:`/`.` text is read as non-drop // too; only explicit `;` fields mean drop-frame (#890). let drop_frame = fr.supports_drop_frame() && text.contains(';'); diff --git a/crates/engine/src/cmd/viewer_cmds.rs b/crates/engine/src/cmd/viewer_cmds.rs index 698a46f02..dfa6ce3e7 100644 --- a/crates/engine/src/cmd/viewer_cmds.rs +++ b/crates/engine/src/cmd/viewer_cmds.rs @@ -282,10 +282,11 @@ fn new_path(s: &mut Session, p: &Value) -> Result { path.vertices.iter().fold(([f64::MAX; 2], [f64::MIN; 2]), |(lo, hi), v| ([lo[0].min(v[0]), lo[1].min(v[1])], [hi[0].max(v[0]), hi[1].max(v[1])])); let bounds = [lo[0], lo[1], hi[0] - lo[0], hi[1] - lo[1]]; let name = str_p(p, "name").map(str::to_string); + let span = super::layer::new_layer_span(s, &comp); let (lid, group, path_uid) = s.edit("Pen Tool", None, |proj, st| { let lid = match target { Some(l) => l, - None => super::shape_tool::new_shape_layer(proj, st, &comp, cid, name.as_deref(), None)?, + None => super::shape_tool::new_shape_layer(proj, st, &comp, cid, name.as_deref(), None, span)?, }; let mut next = proj.next_id; let mut ids = Ids(&mut next); @@ -300,6 +301,9 @@ fn new_path(s: &mut Session, p: &Value) -> Result { st.selected_vertices = vec![VertexRef { layer: lid, mask: path_uid, index: n - 1 }]; Ok((lid, group, path_uid)) })?; + if target.is_none() { + super::layer::show_span(s, cid, span); + } Ok(json!({"layer": lid.0, "group": group, "path": path_uid})) } diff --git a/crates/engine/src/home/templates.rs b/crates/engine/src/home/templates.rs index 0b15baaf5..f411fa5bd 100644 --- a/crates/engine/src/home/templates.rs +++ b/crates/engine/src/home/templates.rs @@ -751,6 +751,9 @@ fn footage_dir(s: &Session, name: &str, footage_dir: Option<&str>, project_path: fn extract_footage(s: &Session, proj: &mut Project, entries: &[(String, Vec)], dir: &str) -> Result> { let mut written = std::collections::HashMap::new(); let mut used = std::collections::HashSet::new(); + // Where each (item, file) pair's numbering resumes, so repeated names don't retry every + // number before theirs. + let mut next = std::collections::HashMap::new(); for (k, v) in entries.iter().filter(|(k, _)| k.starts_with("media/")) { // `media//` → one folder; a name another item took gets the item's id. let file = safe_file_name(&base_name(k)); @@ -763,10 +766,10 @@ fn extract_footage(s: &Session, proj: &mut Project, entries: &[(String, Vec) // case-insensitive volume (Windows and macOS as they come), and Win32 drops a trailing // dot or space, so `a.png.` is `a.png` there. The name written stays as it is. let mut name = file.clone(); - let mut n = 0usize; + let n = next.entry((item, same_file_key(&file))).or_insert(0usize); while !used.insert(same_file_key(&name)) { - n += 1; - name = if n == 1 { format!("{item}-{file}") } else { format!("{item}-{n}-{file}") }; + *n += 1; + name = if *n == 1 { format!("{item}-{file}") } else { format!("{item}-{n}-{file}") }; } let out = format!("{dir}/{name}"); s.services.store_file(&out, v).map_err(|e| EngineError::Other(format!("cannot write {out}: {e}")))?; diff --git a/crates/engine/src/interchange/vector.rs b/crates/engine/src/interchange/vector.rs index b52517cb3..569e8e5bc 100644 --- a/crates/engine/src/interchange/vector.rs +++ b/crates/engine/src/interchange/vector.rs @@ -477,35 +477,51 @@ pub fn split_at_images(doc: &Doc) -> Vec { }) .sum() } - /// Keep the shapes between images `seg − 1` and `seg`; `seen` counts images passed. - fn prune(g: &effectcraft_svg::Group, seg: usize, seen: &mut usize) -> effectcraft_svg::Group { - let mut out = g.clone(); - out.children = vec![]; + /// `g` without its children, holding `children` instead (nothing below `g` is copied). + fn shell(g: &effectcraft_svg::Group, children: Vec) -> effectcraft_svg::Group { + effectcraft_svg::Group { + name: g.name.clone(), + transform: g.transform, + opacity: g.opacity, + children, + clip: g.clip.clone(), + blend: g.blend, + mask: g.mask.clone(), + isolated: g.isolated, + knockout: g.knockout, + } + } + /// One traversal for every segment: the pieces of `g` as (segment, group), by ascending + /// segment; `seen` counts images passed. Each shape is copied once, into its own segment. + fn split(g: &effectcraft_svg::Group, seen: &mut usize) -> Vec<(usize, effectcraft_svg::Group)> { + let mut by: Vec<(usize, Vec)> = vec![]; + let mut put = |seg: usize, node: Node| match by.last_mut() { + Some(last) if last.0 == seg => last.1.push(node), + _ => by.push((seg, vec![node])), + }; for c in &g.children { match c { Node::Image(_) => *seen += 1, - Node::Shape(_) => { - if *seen == seg { - out.children.push(c.clone()); - } - } + Node::Shape(_) => put(*seen, c.clone()), Node::Group(s) => { - let p = prune(s, seg, seen); - if !p.children.is_empty() { - out.children.push(Node::Group(p)); + for (seg, part) in split(s, seen) { + put(seg, Node::Group(part)); } } } } - out + by.into_iter().map(|(seg, children)| (seg, shell(g, children))).collect() } let n = count(&doc.root); - (0..=n) - .map(|seg| { - let mut d = doc.clone(); - d.root = prune(&doc.root, seg, &mut 0); - d - }) + let mut roots: Vec> = vec![None; n + 1]; + for (seg, part) in split(&doc.root, &mut 0) { + if let Some(slot) = roots.get_mut(seg) { + *slot = Some(part); + } + } + roots + .into_iter() + .map(|root| Doc { width: doc.width, height: doc.height, root: root.unwrap_or_else(|| shell(&doc.root, vec![])), skipped: doc.skipped.clone() }) .collect() } @@ -655,3 +671,73 @@ pub fn masks_layer(proj: &mut Project, comp: &Comp, name: &str, outlines: Vec Node { + let geom = Geom::Rect { x: 0.0, y: 0.0, w: 1.0, h: 1.0, rx: 0.0, ry: 0.0 }; + Node::Shape(Shape { + name: name.into(), + geom, + transform: Affine::IDENTITY, + fill: None, + fill_opacity: 1.0, + fill_rule: effectcraft_svg::FillRule::NonZero, + stroke: None, + opacity: 1.0, + }) + } + fn image() -> Node { + Node::Image(Image { name: "i".into(), transform: Affine::IDENTITY, width: 1, height: 1, rgba: std::sync::Arc::new(vec![0; 4]), opacity: 1.0 }) + } + fn group(name: &str, children: Vec) -> Node { + let mut g = Group::new(name); + g.children = children; + Node::Group(g) + } + fn names(g: &Group, out: &mut Vec) { + for c in &g.children { + match c { + Node::Shape(s) => out.push(s.name.clone()), + Node::Group(s) => { + out.push(format!("<{}", s.name)); + names(s, out); + out.push(format!("{}>", s.name)); + } + Node::Image(_) => out.push("IMG".into()), + } + } + } + + /// Segment `k` keeps the shapes between image `k - 1` and `k`, with the groups that hold them. + #[test] + fn split_keeps_each_segments_shapes_and_their_groups() { + let mut root = Group::new("root"); + root.children = vec![ + shape("a"), + image(), + group("g1", vec![shape("b"), image(), shape("c"), group("g2", vec![shape("d"), image(), shape("e")])]), + image(), + group("empty", vec![image()]), + shape("f"), + ]; + let doc = Doc { width: 10.0, height: 10.0, root, skipped: vec![] }; + let segs = split_at_images(&doc); + assert_eq!(segs.len(), 6); + let got: Vec> = segs + .iter() + .map(|d| { + let mut v = vec![]; + names(&d.root, &mut v); + v + }) + .collect(); + let want: Vec> = + vec![vec!["a"], vec![""], vec!["", "g1>"], vec!["", "g1>"], vec![], vec!["f"]]; + assert_eq!(got, want); + assert!(segs.iter().all(|d| d.root.name == "root" && d.width == 10.0)); + } +} 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/session/comps.rs b/crates/engine/src/session/comps.rs index 51d87e018..6ea79f0d9 100644 --- a/crates/engine/src/session/comps.rs +++ b/crates/engine/src/session/comps.rs @@ -1,8 +1,11 @@ use super::{Comp, CompSelection, Event, ItemId, LayerId, Result, Session, Tick, Value, commands}; -/// `t` as a current time of `comp`: a frame within its duration. +/// `t` as a current time of `comp`: a frame within its duration. The last frame is the last one +/// that starts before the end, so a final partial frame (a duration off the frame grid after a +/// frame-rate change) stays reachable; on the grid it is `duration - frame_duration`. fn comp_frame_at(comp: &Comp, t: Tick) -> Tick { - comp.frame_rate.snap_nearest(t.min(comp.duration - comp.frame_duration()).max(Tick::ZERO)) + let last = comp.frame_rate.snap(comp.duration - Tick(1)).max(Tick::ZERO); + comp.frame_rate.snap_nearest(t.max(Tick::ZERO)).min(last) } impl Session { @@ -114,18 +117,23 @@ impl Session { /// editor is open, so playback with no text editor does no text copying. pub fn set_time(&mut self, t: Tick) { if let Some(c) = self.state.active_comp { - let t = self.project.comp(c).map_or(t, |comp| comp_frame_at(comp, t)); - let edited = commands::text_edit::active_editor_snapshot(self); - self.state.times.insert(c, t); - if self.prefs.general.sync_time_related_items { - // Direct call: this `set_time` owns the editor snapshot for both time changes, - // so a synced move does not take a second snapshot or start a second epoch. - self.sync_related_times_inner(c, t); - } - commands::text_edit::after_external_change(self, edited.as_ref()); + self.set_time_of(c, t); } } + /// Move `comp`'s current time, as [`Session::set_time`] does for the active comp. + pub fn set_time_of(&mut self, c: ItemId, t: Tick) { + let t = self.project.comp(c).map_or(t, |comp| comp_frame_at(comp, t)); + let edited = commands::text_edit::active_editor_snapshot(self); + self.state.times.insert(c, t); + if self.prefs.general.sync_time_related_items { + // Direct call: this `set_time_of` owns the editor snapshot for both time changes, + // so a synced move does not take a second snapshot or start a second epoch. + self.sync_related_times_inner(c, t); + } + commands::text_edit::after_external_change(self, edited.as_ref()); + } + /// Settings ▸ General ▸ Synchronize Time of All Related Items: moving the current time of /// `comp` moves the current time of the comps nested in it (through their precomp layers' /// timing) and of the comps it is nested in, so every related viewer shows the same moment. @@ -139,8 +147,8 @@ impl Session { /// Sets the time of every comp related to `comp` through precomp layers: down into the comps /// it nests (through each layer's timing) and up into the comps nesting it. A seen-set keeps - /// cycles and diamonds finite; each found time is clamped to its comp's last frame, snapped to - /// the nearest frame and stored in `state.times`. The caller owns the text-editor boundary. + /// cycles and diamonds finite; each found time goes through [`comp_frame_at`] (as a direct move + /// does) and is stored in `state.times`. The caller owns the text-editor boundary. fn sync_related_times_inner(&mut self, comp: ItemId, t: Tick) { let mut seen = std::collections::BTreeSet::from([comp]); let mut todo = vec![(comp, t)]; @@ -170,7 +178,7 @@ impl Session { } for (cid, ct) in found { if let Some(c) = self.project.comp(cid) { - let ct = c.frame_rate.snap_nearest(ct.clamp(Tick::ZERO, (c.duration - c.frame_duration()).max(Tick::ZERO))); + let ct = comp_frame_at(c, ct); self.state.times.insert(cid, ct); } } diff --git a/crates/engine/src/session/render.rs b/crates/engine/src/session/render.rs index 2dd8a3e7f..c3740196c 100644 --- a/crates/engine/src/session/render.rs +++ b/crates/engine/src/session/render.rs @@ -103,15 +103,22 @@ impl Session { effectcraft_project::ItemKind::Footage(f) if f.has_video => { let f = f.clone(); Some(Box::new(move || { - let img = effectcraft_render::cineon_frame(footage.frame(item, &f, Tick(0))?, &f); - // Point-sample down to at most `max_side` on the long side. - let step = (img.width.max(img.height) as f32 / max_side.max(1) as f32).ceil().max(1.0) as u32; - let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step)); + // Vector footage is drawn at the thumbnail's size: its declared size can be a + // 16384² canvas, gigabytes of pixels to point-sample down to a few dozen. + let scale = (max_side.max(1) as f64 / f.width.max(f.height).max(1) as f64).min(1.0); + let src = footage.vector_frame(item, &f, scale).or_else(|| footage.frame(item, &f, Tick(0)))?; + let img = effectcraft_render::cineon_frame(src, &f); + // Point-sample down to at most `max_side` on the long side (a drawn size one + // pixel over it, from rounding, is shrunk by that pixel, not halved). + let (long, max) = (u64::from(img.width.max(img.height)), u64::from(max_side.max(1))); + let fit = |side: u32| if long <= max { side } else { ((u64::from(side) * max + long / 2) / long).max(1) as u32 }; + let (w, h) = (fit(img.width), fit(img.height)); let full = img.to_rgba8(); let mut px = Vec::with_capacity((w * h * 4) as usize); for y in 0..h { for x in 0..w { - let i = (((y * step) * img.width + x * step) * 4) as usize; + let (sx, sy) = (u64::from(x) * u64::from(img.width) / u64::from(w), u64::from(y) * u64::from(img.height) / u64::from(h)); + let i = ((sy * u64::from(img.width) + sx) * 4) as usize; px.extend_from_slice(&full[i..i + 4]); } } diff --git a/crates/engine/src/settings/prefs/dialog.rs b/crates/engine/src/settings/prefs/dialog.rs index c23b2b5b1..d7c50cdce 100644 --- a/crates/engine/src/settings/prefs/dialog.rs +++ b/crates/engine/src/settings/prefs/dialog.rs @@ -92,6 +92,7 @@ pub fn pages() -> Vec { s("general.showToolTips", "Show Tool Tips", B, true), s("general.viewerWheelScrubbing", "Scrub Composition Frames with Mouse Wheel", B, true), s("general.createLayersAtCompStart", "Create Layers at Composition Start Time", B, true), + s("general.fitNewLayersToWorkArea", "Fit New Layers to Work Area", B, true), s("general.switchesAffectNestedComps", "Switches Affect Nested Comps", B, true), s("general.defaultSpatialLinear", "Default Spatial Interpolation to Linear", B, true), s("general.preserveConstantVertexCount", "Preserve Constant Vertex and Feather Point Count when Editing Masks", B, true), diff --git a/crates/engine/src/settings/prefs/sections.rs b/crates/engine/src/settings/prefs/sections.rs index ca0e72bef..46d654ab0 100644 --- a/crates/engine/src/settings/prefs/sections.rs +++ b/crates/engine/src/settings/prefs/sections.rs @@ -36,6 +36,9 @@ page!(General { viewer_wheel_scrubbing: bool = false, /// Create New Layers at Composition Start Time (off: at the current time). create_layers_at_comp_start: bool = true, + /// Fit New Layers to Work Area: new text, solid, shape, null and adjustment layers span the + /// work area instead of the whole composition (off, as in After Effects). + fit_new_layers_to_work_area: bool = false, switches_affect_nested_comps: bool = true, /// Default Spatial Interpolation to Linear. default_spatial_linear: bool = false, diff --git a/crates/engine/src/settings/shortcuts.rs b/crates/engine/src/settings/shortcuts.rs index 44e2261a2..2d3996d57 100644 --- a/crates/engine/src/settings/shortcuts.rs +++ b/crates/engine/src/settings/shortcuts.rs @@ -550,6 +550,10 @@ impl Keymaps { } } +/// Most conflicts [`ShortcutTable::all_conflicts`] lists: a preset binding every command to one +/// key would otherwise list every pair of them. +pub const MAX_CONFLICTS: usize = 10_000; + /// The active preset resolved against every bindable command. #[derive(Clone, Debug, Default)] pub struct ShortcutTable { @@ -584,8 +588,15 @@ impl ShortcutTable { } /// Every active binding: (keys, bindable). + /// Each bindable's keys once: an imported preset can repeat a binding any number of times. pub fn bindings(&self) -> Vec<(&str, &Bindable)> { - self.bindables.iter().flat_map(|b| self.keys.get(&b.key).into_iter().flatten().map(move |k| (k.as_str(), b))).collect() + self.bindables + .iter() + .flat_map(|b| { + let mut seen = std::collections::HashSet::new(); + self.keys.get(&b.key).into_iter().flatten().filter(move |k| seen.insert(k.as_str())).map(move |k| (k.as_str(), b)) + }) + .collect() } /// Other bindables using `keys` in an overlapping scope. @@ -607,6 +618,7 @@ impl ShortcutTable { /// Every pair of commands sharing keys in the same scope, or in two contexts (which can be /// active at once): only one of the two can run, or neither ([`pick`]). (Other pairs in /// different scopes are intended, see [`Conflict::overrides`].) + /// At most [`MAX_CONFLICTS`] are listed. pub fn all_conflicts(&self) -> Vec<(String, Conflict)> { let mut by_keys: BTreeMap<&str, Vec<&Bindable>> = BTreeMap::new(); for (k, b) in self.bindings() { @@ -618,6 +630,9 @@ impl ShortcutTable { for (k, bs) in by_keys { for (i, a) in bs.iter().enumerate() { for b in &bs[i + 1..] { + if out.len() >= MAX_CONFLICTS { + return out; + } if a.scope == b.scope || (is_context(&a.scope) && is_context(&b.scope)) { out.push(( a.key.clone(), diff --git a/crates/engine/src/system/logging.rs b/crates/engine/src/system/logging.rs index 2bb3518a1..da9d0f4f6 100644 --- a/crates/engine/src/system/logging.rs +++ b/crates/engine/src/system/logging.rs @@ -125,7 +125,13 @@ fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) { /// One log line (without the newline): ` [] : `. fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String { - let message = message.strip_suffix('\n').unwrap_or(message); + // CRLF becomes LF first, so a message ending in "\r\n" loses its final break like one ending + // in "\n" (and its carriage return is not taken for a lone one). + let message = message.replace("\r\n", "\n"); + let message = message.strip_suffix('\n').unwrap_or(&message); + // A message's own line breaks (a path or name from a project or a script) continue it on + // indented lines, so they can't pass for records of their own. + let message = message.replace('\r', "\\r").replace('\n', "\n\t"); format!("{ts} {level:<5} [{thread}] {target}: {message}") } @@ -523,4 +529,22 @@ mod tests { "2026-10-08T07:59:17.728Z WARN [main] effectcraft_engine::autosave: disk full" ); } + + #[test] + fn line_breaks_in_a_message_continue_it_on_indented_lines() { + let forged = "cache at /tmp/x\n2026-10-08T07:59:18.000Z ERROR [main] effectcraft_engine: forged\rend"; + let line = format_line("2026-10-08T07:59:17.728Z", log::Level::Info, "main", "t", forged); + assert_eq!(line.lines().count(), 2); + assert!(line.lines().nth(1).unwrap().starts_with('\t'), "{line}"); + assert!(!line.contains('\r'), "{line}"); + } + + #[test] + fn a_trailing_crlf_is_one_line_break_not_a_lone_carriage_return() { + let line = |m| format_line("t", log::Level::Info, "main", "t", m); + assert_eq!(line("cache\r\n"), line("cache")); + assert_eq!(line("a\r\nb\r\n"), line("a\nb")); + // A lone one, even at the end, is still escaped. + assert!(line("cache\r").ends_with("cache\\r")); + } } diff --git a/crates/engine/src/tests/export_overwrite.rs b/crates/engine/src/tests/export_overwrite.rs index 5cc65b1d5..507a9be4b 100644 --- a/crates/engine/src/tests/export_overwrite.rs +++ b/crates/engine/src/tests/export_overwrite.rs @@ -331,3 +331,17 @@ fn background_pre_render_movies_are_checked_again_before_publication() { assert!(listing(&f.dir).iter().all(|name| !name.starts_with(".effectcraft-prerender-"))); } } + +/// Footage files sharing a name are numbered from that name (`3_a.mov`, not `3_2_a.mov`). +#[test] +fn collect_files_numbers_repeated_names_from_the_name() { + let mut f = collecting("collect-repeated", "a.mov"); + for d in ["in2", "in3"] { + std::fs::create_dir_all(f.path(d)).unwrap(); + f.footage(&format!("{d}/a.mov")); + } + collect(&mut f).unwrap(); + let mut names = listing(&f.path("out/(Footage)")); + names.sort(); + assert_eq!(names, ["2_a.mov", "3_a.mov", "a.mov"]); +} 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/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index b37c5ca3e..42bcefc97 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -382,6 +382,127 @@ fn new_layers_start_at_the_current_time_when_asked() { assert_eq!(s.active_comp().unwrap().layers[0].start_time.seconds().round(), 2.0); } +/// Settings ▸ General ▸ Fit New Layers to Work Area: new text, solid, shape, null and adjustment +/// layers span the work area, ahead of the start-time setting; footage keeps its own length. +#[test] +fn new_layers_fit_the_work_area_when_asked() { + let mut s = session_with_comp(); + s.execute("comp.workArea", json!({"start": 2, "end": 5})).unwrap(); + let span = |s: &Session| { + let l = &s.active_comp().unwrap().layers[0]; + let r = |t: effectcraft_time::Tick| (t.seconds() * 10.0).round() / 10.0; + (r(l.start_time), r(l.in_point), r(l.out_point)) + }; + s.execute("layer.newSolid", json!({})).unwrap(); + assert_eq!(span(&s), (0.0, 0.0, 10.0), "off by default"); + + s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap(); + s.execute("prefs.set", json!({"key": "general.createLayersAtCompStart", "value": false})).unwrap(); + // The tools that draw on their new layer (Type, shape tools, Pen) move the playhead into it; + // the menu commands leave it. + for (cmd, p, draws) in [ + ("layer.newSolid", json!({}), false), + ("layer.newAdjustment", json!({}), false), + ("layer.newNull", json!({}), false), + ("layer.newText", json!({"text": "a"}), false), + ("layer.newText", json!({"text": "a", "edit": true}), true), + ("layer.newShape", json!({"kind": "rect"}), false), + ("shape.newShape", json!({"kind": "ellipse"}), true), + ("shape.newPath", json!({"vertices": [[0, 0], [10, 0], [10, 10]]}), true), + ] { + s.set_time(effectcraft_time::Tick::from_seconds_f64(7.0)); + s.state.selected_layers.clear(); + s.state.text_edit = None; + s.execute(cmd, p).unwrap(); + assert_eq!(span(&s), (2.0, 2.0, 5.0), "{cmd}"); + assert_eq!(s.time().seconds().round(), if draws { 2.0 } else { 7.0 }, "{cmd} playhead"); + } + s.set_time(effectcraft_time::Tick::from_seconds_f64(7.0)); + + let nested = s.execute("comp.new", json!({"name": "Nested", "duration": 3, "open": false})).unwrap()["comp"].as_u64().unwrap(); + s.execute("layer.addItem", json!({"item": nested})).unwrap(); + assert_eq!(span(&s), (7.0, 7.0, 10.0), "footage and comps keep their own timing"); +} + +/// A tool command aimed at another comp moves that comp's playhead into its work area and +/// leaves the active comp's playhead where it was. +#[test] +fn fitted_layers_in_another_comp_move_that_comps_playhead() { + let mut s = session_with_comp(); + let active = s.active_comp_id().unwrap(); + s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap(); + let other = effectcraft_project::ItemId(s.execute("comp.new", json!({"name": "Other", "duration": 10, "open": false})).unwrap()["comp"].as_u64().unwrap()); + s.execute("comp.workArea", json!({"comp": other.0, "start": 2, "end": 5})).unwrap(); + s.execute("comp.workArea", json!({"start": 8, "end": 9})).unwrap(); + s.set_time(effectcraft_time::Tick::from_seconds_f64(7.0)); + for (cmd, p) in [ + ("layer.newText", json!({"comp": other.0, "text": "a", "edit": true})), + ("shape.newShape", json!({"comp": other.0, "kind": "ellipse"})), + ("shape.newPath", json!({"comp": other.0, "vertices": [[0, 0], [10, 0], [10, 10]]})), + ] { + s.set_time_of(other, effectcraft_time::Tick::from_seconds_f64(7.0)); + s.state.selected_layers.clear(); + s.state.text_edit = None; + s.execute(cmd, p).unwrap(); + assert_eq!(s.time_of(other).seconds().round(), 2.0, "{cmd}: the target comp's playhead"); + assert_eq!(s.time_of(active).seconds().round(), 7.0, "{cmd}: the active comp's playhead"); + } +} + +/// A work area off the frame grid (set at 25 fps, then the comp went to 30 fps): the playhead +/// goes to the first frame inside it, not the nearest frame before its start. +#[test] +fn fitted_layers_off_the_frame_grid_move_the_playhead_inside_them() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Grid", "duration": 2, "frameRate": 25})).unwrap(); + s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap(); + s.execute("comp.workArea", json!({"start": 0.04, "end": 0.08})).unwrap(); + s.execute("comp.settings", json!({"frameRate": 30})).unwrap(); + for (cmd, p) in [("layer.newText", json!({"text": "a", "edit": true})), ("shape.newPath", json!({"vertices": [[0, 0], [10, 0], [10, 10]]}))] { + s.set_time(effectcraft_time::Tick::from_seconds_f64(1.0)); + s.state.selected_layers.clear(); + s.state.text_edit = None; + s.execute(cmd, p).unwrap(); + let l = &s.active_comp().unwrap().layers[0]; + let t = s.time(); + assert!(l.in_point <= t && t < l.out_point, "{cmd}: {t:?} in {:?}..{:?}", l.in_point, l.out_point); + assert_eq!(t, effectcraft_time::Tick::from_seconds_f64(2.0 / 30.0), "{cmd}: the first frame inside"); + } +} + +/// A duration off the frame grid (2.01 s set at 100 fps, then 24 fps) keeps its final partial +/// frame reachable: a layer fitted to a work area inside it gets the playhead on that frame. +#[test] +fn fitted_layers_in_the_final_partial_frame_get_the_playhead() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Tail", "duration": 2.01, "frameRate": 100})).unwrap(); + s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap(); + s.execute("comp.workArea", json!({"start": 2.0, "end": 2.01})).unwrap(); + s.execute("comp.settings", json!({"frameRate": 24})).unwrap(); + s.set_time(effectcraft_time::Tick::ZERO); + s.execute("shape.newPath", json!({"vertices": [[0, 0], [10, 0], [10, 10]]})).unwrap(); + let l = &s.active_comp().unwrap().layers[0]; + let t = s.time(); + assert!(l.in_point <= t && t < l.out_point, "{t:?} in {:?}..{:?}", l.in_point, l.out_point); + assert_eq!(t, effectcraft_time::Tick::from_seconds_f64(2.0), "frame 48 of 24 fps"); + // The playhead can't go past that last frame. + s.set_time(effectcraft_time::Tick::from_seconds_f64(5.0)); + assert_eq!(s.time(), effectcraft_time::Tick::from_seconds_f64(2.0)); +} + +/// Synchronize Time of All Related Items reaches a nested comp's final partial frame as a direct +/// move does: the inner comp is 2.01 s (set at 100 fps, then 24 fps), the outer sits at 2.0 s. +#[test] +fn synced_time_reaches_a_nested_final_partial_frame() { + let mut s = Session::default(); + let inner = s.execute("comp.new", json!({"name": "Inner", "duration": 2.01, "frameRate": 100, "open": false})).unwrap()["comp"].as_u64().unwrap(); + s.execute("comp.settings", json!({"comp": inner, "frameRate": 24})).unwrap(); + s.execute("comp.new", json!({"name": "Outer", "duration": 4, "frameRate": 24})).unwrap(); + s.execute("layer.addItem", json!({"item": inner, "time": 0})).unwrap(); + s.set_time(effectcraft_time::Tick::from_seconds_f64(2.0)); + assert_eq!(s.time_of(effectcraft_project::ItemId(inner)), effectcraft_time::Tick::from_seconds_f64(2.0)); +} + #[test] fn default_spatial_interpolation_linear() { let mut s = session_with_comp(); @@ -667,6 +788,26 @@ fn context_shortcuts_on_the_same_keys_conflict() { assert!(t.conflicts(b, "F9").iter().any(|c| c.key == "test.paint" && !c.overrides)); } +/// A preset repeating one binding many times lists it once, and conflicts stay bounded. +#[test] +fn repeated_bindings_are_one_binding() { + let mut s = Session::default(); + s.set_ui_commands(vec![ + crate::shortcuts::UiCommand { id: "test.a".into(), label: "A".into(), shortcut: Some("F9".into()), scope: Some("Timeline".into()) }, + crate::shortcuts::UiCommand { id: "test.b".into(), label: "B".into(), shortcut: Some("F9".into()), scope: Some("Timeline".into()) }, + ]); + let mut t = s.shortcuts().clone(); + t.keys.insert("test.a".into(), vec!["F9".into(); 10_000]); + assert_eq!(t.bindings().iter().filter(|(_, b)| b.key == "test.a").count(), 1); + assert_eq!(t.all_conflicts().iter().filter(|(a, c)| a == "test.a" && c.key == "test.b").count(), 1); + // Many commands on one key: the listing stops at its cap. + for i in 0..200 { + t.bindables.push(crate::shortcuts::Bindable { key: format!("test.many{i}"), ..t.find("test.b").unwrap().clone() }); + t.keys.insert(format!("test.many{i}"), vec!["F9".into()]); + } + assert_eq!(t.all_conflicts().len(), crate::shortcuts::MAX_CONFLICTS); +} + // ---------------------------------------------------------------- auto-save / recovery /// The names in `d`, sorted. diff --git a/crates/engine/src/tests/timeline.rs b/crates/engine/src/tests/timeline.rs index 55475eef3..b5a44239b 100644 --- a/crates/engine/src/tests/timeline.rs +++ b/crates/engine/src/tests/timeline.rs @@ -649,6 +649,31 @@ fn time_set_keeps_the_frame_shown_in_the_current_time_field() { assert_eq!(r["frame"], 13); } +/// A comp that starts before 0 shows its first frames as negative timecode (`-0:00:00:12`). +/// Typing back what the current-time display shows (Go to Time opens with it) stays on that frame; +/// the leading `-` used to move back from the current frame instead. A bare `-n` still steps back, +/// and in a comp that starts at 0 a `-` stays relative. +#[test] +fn time_set_round_trips_negative_displayed_timecode() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Pre-roll", "width": 64, "height": 64, "frameRate": 24, "duration": 4, "startTime": -1})).unwrap(); + for frame in [0_i64, 1, 12, 23, 24, 25, 60] { + let r = s.execute("time.set", json!({"frame": frame})).unwrap(); + let display = r["display"].as_str().unwrap().to_string(); + let r = s.execute("time.set", json!({"timecode": display})).unwrap(); + assert_eq!(r["frame"], frame, "displayed {display}"); + } + s.execute("time.set", json!({"frame": 12})).unwrap(); + assert_eq!(s.execute("time.set", json!({"timecode": "-0:00:00:12"})).unwrap()["display"], "-0:00:00:12"); + assert_eq!(s.execute("time.set", json!({"timecode": "-5"})).unwrap()["frame"], 7, "a bare -n steps back"); + assert_eq!(s.execute("time.set", json!({"timecode": "-0:00:00:20"})).unwrap()["frame"], 4); + assert!(s.execute("time.set", json!({"timecode": "- -1:00"})).is_err(), "a second sign is still an error"); + + s.execute("comp.settings", json!({"startTime": 0})).unwrap(); + s.execute("time.set", json!({"frame": 12})).unwrap(); + assert_eq!(s.execute("time.set", json!({"timecode": "-0:00:00:02"})).unwrap()["frame"], 10, "relative when the comp starts at 0"); +} + /// Frames display style: typed digits are a frame count, so 00100 is frame 100, not 1 s (#530). #[test] fn time_set_reads_frames_style_digits_as_a_frame_count() { diff --git a/crates/engine/src/tests/vector_import.rs b/crates/engine/src/tests/vector_import.rs index 10c07c2ad..2f41b16b2 100644 --- a/crates/engine/src/tests/vector_import.rs +++ b/crates/engine/src/tests/vector_import.rs @@ -383,3 +383,58 @@ fn psd_with_damaged_layer_section_imports_the_flattened_image() { let ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!() }; assert!(f.layer.is_none(), "the merged image, not a Photoshop layer"); } + +/// A vector footage thumbnail is drawn at the thumbnail's size, never at its declared size. +#[test] +fn vector_footage_thumbnails_are_drawn_at_thumbnail_size() { + use std::sync::{Arc, Mutex}; + + use effectcraft_project::{Footage, ItemId}; + use effectcraft_raster::Image; + use effectcraft_time::Tick; + + struct Vector(Mutex>); + impl crate::render::FootageSource for Vector { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + panic!("the full-size frame was decoded"); + } + fn vector_frame(&self, _: ItemId, f: &Footage, scale: f64) -> Option> { + self.0.lock().unwrap().push(scale); + Some(Arc::new(Image::new((f.width as f64 * scale).ceil() as u32, (f.height as f64 * scale).ceil() as u32))) + } + } + let source = Arc::new(Vector(Mutex::new(vec![]))); + let mut s = Session { footage: source.clone(), ..Default::default() }; + let f = Footage { path: "/m/huge.svg".into(), codec: "SVG".into(), width: 16_000, height: 8_000, has_video: true, ..Default::default() }; + let item = std::sync::Arc::make_mut(&mut s.project).add_item("huge.svg", effectcraft_color::Label::Sandstone, None, ItemKind::Footage(f)); + let (w, h, _) = (s.thumbnail_job(item, Tick::ZERO, 64).unwrap())().unwrap(); + assert_eq!((w, h), (64, 32)); + assert_eq!(*source.0.lock().unwrap(), [64.0 / 16_000.0]); +} + +/// A vector footage thumbnail whose drawn size is a pixel over the requested one is shrunk by +/// that pixel, not halved by an integer step. +#[test] +fn a_vector_thumbnail_one_pixel_over_is_not_halved() { + use std::sync::Arc; + + use effectcraft_project::{Footage, ItemId}; + use effectcraft_raster::Image; + use effectcraft_time::Tick; + + struct Over; + impl crate::render::FootageSource for Over { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn vector_frame(&self, _: ItemId, _: &Footage, _: f64) -> Option> { + Some(Arc::new(Image::new(64, 64))) + } + } + let mut s = Session { footage: Arc::new(Over), ..Default::default() }; + let f = Footage { path: "/m/sq.svg".into(), codec: "SVG".into(), width: 121, height: 121, has_video: true, ..Default::default() }; + let item = std::sync::Arc::make_mut(&mut s.project).add_item("sq.svg", effectcraft_color::Label::Sandstone, None, ItemKind::Footage(f)); + let (w, h, px) = (s.thumbnail_job(item, Tick::ZERO, 63).unwrap())().unwrap(); + assert_eq!((w, h), (63, 63)); + assert_eq!(px.len(), 63 * 63 * 4); +} diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml index c477d2787..e0be45551 100644 --- a/crates/export/Cargo.toml +++ b/crates/export/Cargo.toml @@ -31,6 +31,7 @@ thiserror = { workspace = true } log = { workspace = true } [dev-dependencies] +tempfile = { workspace = true } effectcraft-media = { workspace = true } effectcraft-effects = { workspace = true } effectcraft-color = { workspace = true } 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/export/tests/it/opus_roundtrip.rs b/crates/export/tests/it/opus_roundtrip.rs index 3cd1edeee..4dfbc32ff 100644 --- a/crates/export/tests/it/opus_roundtrip.rs +++ b/crates/export/tests/it/opus_roundtrip.rs @@ -342,7 +342,8 @@ fn ffmpeg_oracle_decodes_ogg_opus() { } } let (enc, packets) = encode(&pcm, channels, rate); - let path = std::env::temp_dir().join(format!("effectcraft-opusenc-{}-{channels}.opus", std::process::id())); + let guard = tempfile::tempdir().unwrap(); + let path = guard.path().join("out.opus"); std::fs::write(&path, ogg_opus(&enc, &packets, len)).unwrap(); let out = Command::new("ffmpeg") .args(["-v", "error", "-i"]) diff --git a/crates/export/tests/it/render_settings.rs b/crates/export/tests/it/render_settings.rs index 39a7e88ff..99e3d9c19 100644 --- a/crates/export/tests/it/render_settings.rs +++ b/crates/export/tests/it/render_settings.rs @@ -389,8 +389,8 @@ fn wav_and_aiff_channels_and_formats() { #[test] fn cancelled_export_removes_only_files_it_opened() { use effectcraft_export::ExportError; - let dir = std::env::temp_dir().join(format!("ec-cancel-owned-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let guard = tempfile::tempdir().unwrap(); + let dir = guard.path().to_path_buf(); let (p, cid) = project(FrameRate::new(25, 1), |_, _| {}); struct Overflow; impl StorageQuota for Overflow { diff --git a/crates/export/tests/it/vp9_roundtrip.rs b/crates/export/tests/it/vp9_roundtrip.rs index 1432e3892..c1c6c142c 100644 --- a/crates/export/tests/it/vp9_roundtrip.rs +++ b/crates/export/tests/it/vp9_roundtrip.rs @@ -130,8 +130,8 @@ fn ffmpeg_decodes_our_stream() { ivf.extend_from_slice(&(frame.len() as u32).to_le_bytes()); ivf.extend_from_slice(&0u64.to_le_bytes()); ivf.extend_from_slice(&frame); - let dir = std::env::temp_dir().join(format!("effectcraft-vp9-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let guard = tempfile::tempdir().unwrap(); + let dir = guard.path().to_path_buf(); let path = dir.join("a.ivf"); std::fs::write(&path, &ivf).unwrap(); let out = std::process::Command::new("ffmpeg") @@ -291,8 +291,8 @@ fn ffmpeg_matches(w: usize, h: usize, frames: &[Vec], recon: &[[Vec; 3]] ivf.extend_from_slice(&(t as u64).to_le_bytes()); ivf.extend_from_slice(f); } - let dir = std::env::temp_dir().join(format!("effectcraft-vp9-{name}-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let guard = tempfile::Builder::new().prefix(&format!("effectcraft-vp9-{name}-")).tempdir().unwrap(); + let dir = guard.path().to_path_buf(); let path = dir.join("a.ivf"); std::fs::write(&path, &ivf).unwrap(); let out = std::process::Command::new("ffmpeg") 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/host/tests/it/plugins.rs b/crates/host/tests/it/plugins.rs index 33748d366..a5fc0b8e1 100644 --- a/crates/host/tests/it/plugins.rs +++ b/crates/host/tests/it/plugins.rs @@ -276,8 +276,8 @@ pub(super) fn wat_plugin(id: &str, api: i32, render: &str) -> String { #[test] fn plugin_loading_reports_bad_modules_and_survives_failing_renders() { - let dir = std::env::temp_dir().join(format!("effectcraft-host-plugins-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let guard = tempfile::tempdir().unwrap(); + let dir = guard.path().to_path_buf(); let files = [ ("a-good.wasm", wat_plugin("org.test.host.good", 1, "i32.const 0").into_bytes()), ("b-garbage.wasm", b"\0asm\x01\0\0\0garbage".to_vec()), diff --git a/crates/interchange/src/export.rs b/crates/interchange/src/export.rs index 375c1bf1b..fd396b00b 100644 --- a/crates/interchange/src/export.rs +++ b/crates/interchange/src/export.rs @@ -303,16 +303,41 @@ impl Exporter<'_> { } /// The bin mirroring a Project panel folder. + /// + /// Walks up the folder's parents without recursion: a damaged or hostile project can hold a + /// parent cycle (or a very long chain), which recursed until the stack ran out. A folder seen + /// twice in one walk ends it, and the cycle's topmost folder becomes a top-level bin. fn bin(&mut self, folder: Option) -> Option { let f = folder?; - if let Some(b) = self.bins.get(&f) { - return Some(*b); + // The uncached folders from `f` up, and the bin they hang from. + let mut chain: Vec = vec![]; + let mut in_chain = std::collections::HashSet::new(); + let mut parent = None; + let mut at = Some(f); + while let Some(id) = at { + if let Some(b) = self.bins.get(&id) { + parent = Some(*b); + break; + } + if !in_chain.insert(id) { + self.warn("a folder that contains itself was exported as a top-level bin"); + break; + } + let Some(it) = self.p.item(id) else { break }; + chain.push(id); + at = it.parent; } - let it = self.p.item(f)?; - let parent = self.bin(it.parent); - let b = self.fp.add_bin(&it.name, parent); - self.bins.insert(f, b); - Some(b) + if chain.is_empty() { + // `f` is cached already (`parent` is its bin), or is not an item (`None`). + return parent; + } + for id in chain.into_iter().rev() { + let name = self.p.item(id).map(|it| it.name.clone()).unwrap_or_default(); + let b = self.fp.add_bin(&name, parent); + self.bins.insert(id, b); + parent = Some(b); + } + parent } fn footage_item(&mut self, item: ItemId, f: &Footage) -> fp::ItemId { @@ -615,12 +640,34 @@ impl Exporter<'_> { } } +/// Whether `c` or a comp nested in it (at any depth) has a layer of footage with audio. +/// +/// A walk with a stack and a visited set, as in `Project::comp_contains`: recursion never ended +/// on a precomp cycle, and re-walked a comp shared by many layers once per path (exponential +/// for a chain of comps that each use the next one twice). fn comp_has_audio(p: &Project, c: &Comp) -> bool { - c.layers.iter().any(|l| match &l.source { - LayerSource::Footage { item } => matches!(p.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio), - LayerSource::Comp { item } => p.comp(*item).is_some_and(|c| comp_has_audio(p, c)), - _ => false, - }) + let mut seen = std::collections::HashSet::new(); + let mut stack = vec![c]; + while let Some(c) = stack.pop() { + for l in &c.layers { + match &l.source { + LayerSource::Footage { item } => { + if matches!(p.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio) { + return true; + } + } + LayerSource::Comp { item } => { + if seen.insert(*item) + && let Some(nested) = p.comp(*item) + { + stack.push(nested); + } + } + _ => {} + } + } + } + false } fn differs(v: &Value, def: [f64; 2]) -> bool { @@ -703,3 +750,37 @@ fn media_info(name: &str, kind: MediaKind, duration: Tick, video: Option fp::MediaClip { fp::MediaClip { media, info, interpret: Default::default(), mark_in: None, mark_out: None, markers: vec![], offline: false, proxy: None, identity: None } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A folder's bin is the same one each time it is asked for (a cached folder once answered + /// `None`, so a folder's second item went to the project root); a non-item has no bin. + #[test] + fn folder_bins_are_reused_once_cached() { + let mut p = Project::default(); + let a = p.add_item("A", effectcraft_color::Label::None, None, ItemKind::Folder); + let b = p.add_item("B", effectcraft_color::Label::None, Some(a), ItemKind::Folder); + let (opts, pre) = (ExportOptions::default(), HashMap::new()); + let mut ex = Exporter { + p: &p, + opts: &opts, + pre: &pre, + fp: fp::Project::new("x"), + out: ExportOutput::default(), + seqs: HashMap::new(), + media: HashMap::new(), + bins: HashMap::new(), + stack: vec![], + }; + let bb = ex.bin(Some(b)); + assert!(bb.is_some()); + assert_eq!(ex.bin(Some(b)), bb); + let ba = ex.bin(Some(a)); + assert!(ba.is_some() && ba != bb); + assert_eq!(ex.bin(Some(a)), ba); + assert_eq!(ex.bin(Some(ItemId(u64::MAX))), None); + assert_eq!(ex.bin(None), None); + } +} diff --git a/crates/interchange/src/import.rs b/crates/interchange/src/import.rs index 22e22b4f2..6faadd91a 100644 --- a/crates/interchange/src/import.rs +++ b/crates/interchange/src/import.rs @@ -177,10 +177,12 @@ pub fn import( } let folder_name = if opts.name.is_empty() { "Imported Timeline".to_string() } else { opts.name.clone() }; res.folder = project.add_item(&folder_name, Label::None, None, ItemKind::Folder); + let warned = res.warnings.iter().cloned().collect(); let mut im = Importer { src: &imported.project, project, res, + warned, items: HashMap::new(), solids: HashMap::new(), solids_folder: None, @@ -273,6 +275,8 @@ struct Importer<'a> { src: &'a fp::Project, project: &'a mut Project, res: ImportResult, + /// The warnings in `res`, to say each once without searching them all every time. + warned: HashSet, /// Source item → created item (footage, comp). items: HashMap, /// Generator / adjustment solids by (source item, width, height). @@ -290,7 +294,7 @@ struct Importer<'a> { impl Importer<'_> { fn warn(&mut self, m: impl Into) { let m = m.into(); - if !self.res.warnings.contains(&m) { + if self.warned.insert(m.clone()) { self.res.warnings.push(m); } } @@ -429,23 +433,8 @@ impl Importer<'_> { comp.markers = seq.markers.iter().map(marker_in).collect(); // Video: the top track's clips first (layer #1); later clips of a track above earlier ones // so incoming clips of dissolves cover outgoing ones. - for track in seq.video_tracks.iter().rev() { - let mut items: Vec<&fp::TrackItem> = track.items.iter().collect(); - items.sort_by_key(|i| std::cmp::Reverse(i.start)); - for ti in items { - if let Some(l) = self.clip_layer(&comp, seq, track, ti) { - comp.layers.push(l); - } - } - } - for track in &seq.audio_tracks { - let mut items: Vec<&fp::TrackItem> = track.items.iter().collect(); - items.sort_by_key(|i| std::cmp::Reverse(i.start)); - for ti in items { - if let Some(l) = self.clip_layer(&comp, seq, track, ti) { - comp.layers.push(l); - } - } + for track in seq.video_tracks.iter().rev().chain(&seq.audio_tracks) { + self.track_layers(&mut comp, seq, track); } if !seq.caption_tracks.is_empty() { self.warn("caption tracks are not imported"); @@ -508,7 +497,45 @@ impl Importer<'_> { } } - fn clip_layer(&mut self, comp: &Comp, seq: &fp::Sequence, track: &fp::Track, ti: &fp::TrackItem) -> Option { + /// A track's clips as layers of `comp`, the latest first. + fn track_layers(&mut self, comp: &mut Comp, seq: &fp::Sequence, track: &fp::Track) { + let mut items: Vec<&fp::TrackItem> = track.items.iter().collect(); + items.sort_by_key(|i| std::cmp::Reverse(i.start)); + // Each clip's transitions, found once per track: scanning every transition of the track + // for every clip made a long flat edit (many clips and many transitions) quadratic. + let mut transitions: HashMap> = HashMap::new(); + for tr in &track.transitions { + for clip in [tr.from, tr.to].into_iter().flatten() { + let list = transitions.entry(clip).or_default(); + // A transition from and to the same clip still counts once. + if !list.last().is_some_and(|last| std::ptr::eq(*last, tr)) { + list.push(tr); + } + } + } + let mut any = false; + for ti in items { + let own = transitions.get(&ti.id).map(Vec::as_slice).unwrap_or_default(); + if let Some(l) = self.clip_layer(comp, seq, track, ti, own) { + comp.layers.push(l); + any = true; + } + } + // What the track's transitions become, said once a clip of the track was imported. + if any && track.kind != fp::TrackKind::Audio { + for tr in &track.transitions { + let id = tr.effect.effect.as_str(); + if !matches!(id, "cross_dissolve" | "dip_to_black" | "dip_to_white" | "film_dissolve" | "additive_dissolve" | "non_additive_dissolve") { + self.warn(format!("transition \"{}\" was imported as a cross dissolve", tr.effect.def().map(|d| d.name).unwrap_or(id))); + } + } + } + if any && track.transitions.iter().any(|tr| tr.effect.effect == "dip_to_white") { + self.warn("Dip to White was imported as a dip to the background"); + } + } + + fn clip_layer(&mut self, comp: &Comp, seq: &fp::Sequence, track: &fp::Track, ti: &fp::TrackItem, transitions: &[&fp::Transition]) -> Option { let audio_track = track.kind == fp::TrackKind::Audio; let (source, size, has_audio) = self.source_of(ti, comp.width, comp.height)?; let is_adjustment = matches!(self.src.item(ti.item).map(|i| &i.kind), Some(fp::ItemKind::AdjustmentLayer { .. })); @@ -561,18 +588,9 @@ impl Importer<'_> { } // Transitions extend the layer over their handles. let mut fades: Vec<(Tick, Tick, f64, f64)> = vec![]; - for tr in &track.transitions { + for tr in transitions { let (ts, te) = (tick_in(tr.start), tick_in(tr.end())); - let id = tr.effect.effect.as_str(); - let dip = id.starts_with("dip_to"); - if !audio_track - && !matches!(id, "cross_dissolve" | "dip_to_black" | "dip_to_white" | "film_dissolve" | "additive_dissolve" | "non_additive_dissolve") - { - self.warn(format!("transition \"{}\" was imported as a cross dissolve", tr.effect.def().map(|d| d.name).unwrap_or(id))); - } - if dip && id == "dip_to_white" { - self.warn("Dip to White was imported as a dip to the background"); - } + let dip = tr.effect.effect.starts_with("dip_to"); let mid = ts + Tick((te - ts).0 / 2); if tr.to == Some(ti.id) { l.in_point = l.in_point.min(ts); diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs index b451ecf81..4ce892c15 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -787,3 +787,98 @@ fn deeply_nested_documents_error_instead_of_overflowing_the_stack() { assert_eq!(super::import::xml_depth(b"]]>"), 2); assert_eq!(super::import::json_depth(br#"{"a": "[[[{{", "b": [[1], {"c": "\\\"["}]}"#), 3); } + +/// A hostile project: footage in a folder whose parents loop back to it. Export walked the +/// folder's parents recursively and never reached the cache insert, so the stack ran out. +#[test] +fn export_survives_a_folder_parent_cycle() { + let (mut p, cid) = ae_project(); + let a = p.add_item("A", effectcraft_color::Label::None, None, ItemKind::Folder); + let b = p.add_item("B", effectcraft_color::Label::None, Some(a), ItemKind::Folder); + p.items.get_mut(&a).unwrap().parent = Some(b); + let footage: Vec = p.items.values().filter(|i| matches!(i.kind, ItemKind::Footage(_))).map(|i| i.id).collect(); + for f in footage { + p.items.get_mut(&f).unwrap().parent = Some(a); + } + let out = export(&p, cid, &ExportOptions::default(), &HashMap::new()).unwrap(); + assert!(out.warnings.iter().any(|w| w.contains("contains itself")), "{:?}", out.warnings); +} + +/// Precomps that use each other, and a chain of comps that each use the next one twice: the +/// audio check recursed forever on the first and 2^depth times on the second. +#[test] +fn export_audio_check_ends_on_precomp_cycles_and_shared_chains() { + let mut p = Project::default(); + let a = footage_item(&mut p, "/media/a.mov"); + let comp = || Comp::new(W, H, FrameRate::new(25, 1), t(2.0)); + // A chain of 60 comps, each using the next twice; the last one is silent footage. + let mut next = { + let mut c = comp(); + let l = effectcraft_project::build::layer(&mut p, &c, "a", LayerSource::Footage { item: a }, (W, H), None); + c.layers.push(l); + comp_item(&mut p, "Leaf", c) + }; + for i in 0..60 { + let mut c = comp(); + for n in 0..2 { + let l = effectcraft_project::build::layer(&mut p, &c, &format!("use {n}"), LayerSource::Comp { item: next }, (W, H), None); + c.layers.push(l); + } + next = comp_item(&mut p, &format!("Chain {i}"), c); + } + let started = std::time::Instant::now(); + export(&p, next, &ExportOptions::default(), &HashMap::new()).unwrap(); + assert!(started.elapsed() < std::time::Duration::from_secs(30), "{:?}", started.elapsed()); + // Two comps nesting each other. + let x = comp_item(&mut p, "X", comp()); + let mut y = comp(); + let l = effectcraft_project::build::layer(&mut p, &y, "x", LayerSource::Comp { item: x }, (W, H), None); + y.layers.push(l); + let y = comp_item(&mut p, "Y", y); + let mut xc = comp(); + let l = effectcraft_project::build::layer(&mut p, &xc, "y", LayerSource::Comp { item: y }, (W, H), None); + xc.layers.push(l); + if let Some(ItemKind::Comp(c)) = p.items.get_mut(&x).map(|i| &mut i.kind) { + *c = Arc::new(xc); + } + export(&p, x, &ExportOptions::default(), &HashMap::new()).unwrap(); +} + +/// A flat edit with many clips and many transitions: each clip finds its own transitions (it +/// scanned every transition of the track per clip), and they still extend and fade it. +#[test] +fn import_finds_each_clips_transitions_without_scanning_the_track() { + let mut p = fp::Project::new("Flat"); + let a = media(&mut p, "a.png", "/media/a.mov", filmcraft_media::MediaKind::Movie, true, false, None); + let settings = fp::SequenceSettings { width: W, height: H, frame_rate: rate(), ..Default::default() }; + let main = p.new_sequence("Main", settings, 1, 0, None); + const N: usize = 500; + let mut items = Vec::with_capacity(N); + let mut transitions = Vec::with_capacity(N); + for i in 0..N { + let ti = item(&mut p, a, fp::TrackKind::Video, i as f64 * 0.4, 0.0, 0.4); + if let Some(prev) = items.last().map(|x: &fp::TrackItem| x.id) { + transitions.push(fp::Transition { + id: fp::TransitionId(p.alloc_id()), + effect: fp::find_effect("cross_dissolve").unwrap().instance(), + start: secs(i as f64 * 0.4 - 0.08), + duration: secs(0.16), + from: Some(prev), + to: Some(ti.id), + align: fp::TransitionAlign::CenterAtCut, + reverse: false, + }); + } + items.push(ti); + } + let seq = p.sequence_mut(main).unwrap(); + seq.video_tracks[0].items = items; + seq.video_tracks[0].transitions = transitions; + let (bytes, _) = fc::export(&p, main, fc::Format::Otio, &Default::default()).unwrap(); + let (q, r) = import_doc(&bytes, "Flat.otio", &mut probe); + let m = q.comp(r.comps[0]).unwrap(); + assert_eq!(m.layers.len(), N); + // A middle clip reaches over both of its transitions' handles. + let mid = m.layers.iter().find(|l| near(l.in_point, t(100.0 - 0.08))).expect("clip 250 extended into its incoming transition"); + assert!(near(mid.out_point, t(100.4 + 0.08)), "{}", mid.out_point.seconds()); +} diff --git a/crates/lottie/src/anim.rs b/crates/lottie/src/anim.rs index 5b6dabf50..40a56a7eb 100644 --- a/crates/lottie/src/anim.rs +++ b/crates/lottie/src/anim.rs @@ -21,6 +21,9 @@ use serde_json::{Value as Json, json}; use crate::Timebase; +/// Most distinct warnings an import or export reports. +const MAX_WARNINGS: usize = 200; + /// Export state shared by the whole document. pub(crate) struct Ex { pub tb: Timebase, @@ -31,7 +34,8 @@ pub(crate) struct Ex { impl Ex { pub fn warn(&mut self, w: impl Into) { let w = w.into(); - if !self.warnings.contains(&w) { + // Each warning is looked for among the earlier ones: past a screenful, more add nothing. + if self.warnings.len() < MAX_WARNINGS && !self.warnings.contains(&w) { self.warnings.push(w); } } @@ -46,7 +50,8 @@ pub(crate) struct Im { impl Im { pub fn warn(&mut self, w: impl Into) { let w = w.into(); - if !self.warnings.contains(&w) { + // Each warning is looked for among the earlier ones: past a screenful, more add nothing. + if self.warnings.len() < MAX_WARNINGS && !self.warnings.contains(&w) { self.warnings.push(w); } } diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs index 57ef6427a..1e1430e12 100644 --- a/crates/lottie/src/import.rs +++ b/crates/lottie/src/import.rs @@ -1,6 +1,6 @@ //! Lottie document → EffectCraft compositions (the inverse of [`crate::export`]). -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; use effectcraft_color::Label; @@ -17,6 +17,9 @@ use crate::anim::{self, Im, import_prop}; use crate::export::{fx_by_id, fx_by_type, fx_params, mask_mode_from}; use crate::{LottieError, Timebase, shapes, text}; +/// Longest font family or style name kept (each text key using the font holds a copy). +const MAX_FONT_NAME_CHARS: usize = 256; + /// An embedded image asset handed to the caller to store (the returned path becomes the /// footage item's file). #[derive(Clone, Debug)] @@ -129,9 +132,12 @@ pub fn import( Ctx { im: Im { tb, warnings: vec![] }, assets: BTreeMap::new(), fonts: vec![], store, base_dir, items: vec![], folder: None, name: name.clone() }; if let Some(list) = doc.get("fonts").and_then(|f| f.get("list")).and_then(Json::as_array) { for fo in list { + // Every text key referring to the font copies its family and style: keep them to + // a font name's length. + let short = |v: &str| v.chars().take(MAX_FONT_NAME_CHARS).collect::(); let n = s(fo, "fName").unwrap_or("").to_string(); - let fam = s(fo, "fFamily").unwrap_or(&n).to_string(); - let st = s(fo, "fStyle").unwrap_or("Regular").to_string(); + let fam = short(s(fo, "fFamily").unwrap_or(&n)); + let st = short(s(fo, "fStyle").unwrap_or("Regular")); cx.fonts.push((n, fam, st)); } } @@ -150,11 +156,12 @@ pub fn import( let duration = tb.tick(op); // Pass 1: asset items (precomps empty for now, images stored). let assets = doc.get("assets").and_then(Json::as_array).cloned().unwrap_or_default(); + let layer_sizes = precomp_layer_sizes(&doc, &assets); for a in &assets { let Some(id) = s(a, "id") else { continue }; if a.get("layers").is_some() { let folder = cx.folder(project); - let layer = precomp_layer_size(&doc, &assets, id); + let layer = layer_sizes.get(id).copied(); let aw = f(a, "w").map(|v| v as u32).or(layer.map(|l| l.0)).unwrap_or(w); let ah = f(a, "h").map(|v| v as u32).or(layer.map(|l| l.1)).unwrap_or(h); let arate = f(a, "fr").map(FrameRate::from_f64).unwrap_or(rate); @@ -255,17 +262,20 @@ pub fn import( Ok(ImportResult { comp: id, items: cx.items, warnings: cx.im.warnings }) } -/// The `w`/`h` of the first precomp layer, in the main comp or in any asset, that shows asset `id`. -/// lottie-spec gives a precomposition asset no size: the layer's `w`/`h` are its clipping rect. -fn precomp_layer_size(doc: &Json, assets: &[Json], id: &str) -> Option<(u32, u32)> { +/// By asset id, the `w`/`h` of the first precomp layer, in the main comp or in any asset, that +/// shows it. lottie-spec gives a precomposition asset no size: the layer's `w`/`h` are its +/// clipping rect. One pass over all layers, not one per asset. +fn precomp_layer_sizes<'a>(doc: &'a Json, assets: &'a [Json]) -> HashMap<&'a str, (u32, u32)> { + let mut out = HashMap::new(); let lists = doc.get("layers").into_iter().chain(assets.iter().filter_map(|a| a.get("layers"))); - lists.filter_map(Json::as_array).flatten().find_map(|l| { - if l.get("ty").and_then(Json::as_u64) != Some(0) || s(l, "refId") != Some(id) { - return None; + for l in lists.filter_map(Json::as_array).flatten() { + if l.get("ty").and_then(Json::as_u64) != Some(0) { + continue; } - let (lw, lh) = f(l, "w").zip(f(l, "h"))?; - Some((lw.round().max(1.0) as u32, lh.round().max(1.0) as u32)) - }) + let (Some(id), Some((lw, lh))) = (s(l, "refId"), f(l, "w").zip(f(l, "h"))) else { continue }; + out.entry(id).or_insert((lw.round().max(1.0) as u32, lh.round().max(1.0) as u32)); + } + out } fn transform(im: &Im, ks: &Json, tr: &mut PropGroup, next: &mut u64) { @@ -411,8 +421,8 @@ fn layer_source(cx: &mut Ctx, project: &mut Project, comp: &Comp, j: &Json) -> O fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[Json]) { let tb = cx.im.tb; let mut by_ind: BTreeMap = BTreeMap::new(); - // (layer id, parent ind, matte: (kind, explicit tp ind), json position) - let mut pending: Vec<(LayerId, Option, Option<(MatteKind, Option)>, usize)> = vec![]; + // (layer id, its index in `comp.layers`, parent ind, matte: (kind, explicit tp ind), json position) + let mut pending: Vec<(LayerId, usize, Option, Option<(MatteKind, Option)>, usize)> = vec![]; let mut pos_ind: Vec> = vec![]; for (ji, j) in layers.iter().enumerate() { let ind = j.get("ind").and_then(Json::as_i64); @@ -463,8 +473,8 @@ fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[ let mut ids = Ids(&mut next); match &source { LayerSource::Shape => { - let items = j.get("shapes").and_then(Json::as_array).cloned().unwrap_or_default(); - let groups = shapes::import_items(&mut cx.im, &mut ids, &items); + let mut items = j.get("shapes").and_then(Json::as_array).into_iter().flatten(); + let groups = shapes::import_items(&mut cx.im, &mut ids, &mut items); if let Some(c) = l.props.sub_mut("contents") { c.children.extend(groups.into_iter().map(Node::Group)); } @@ -507,10 +517,10 @@ fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[ _ => None, } .map(|k| (k, j.get("tp").and_then(Json::as_i64))); - pending.push((l.id, parent, matte, ji)); + pending.push((l.id, comp.layers.len(), parent, matte, ji)); comp.layers.push(l); } - for (id, parent, matte, ji) in pending { + for (id, at, parent, matte, ji) in pending { let parent_id = parent.and_then(|p| by_ind.get(&p).copied()).filter(|p| *p != id); let matte_id = matte.and_then(|(k, tp)| { let target = match tp { @@ -520,7 +530,7 @@ fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[ }; target.filter(|t| *t != id).map(|t| TrackMatte { layer: t, kind: k }) }); - if let Some(l) = comp.layer_mut(id) { + if let Some(l) = comp.layers.get_mut(at) { l.parent = parent_id; l.track_matte = matte_id; } diff --git a/crates/lottie/src/shapes.rs b/crates/lottie/src/shapes.rs index fab8d3646..6cd1f09cc 100644 --- a/crates/lottie/src/shapes.rs +++ b/crates/lottie/src/shapes.rs @@ -436,7 +436,7 @@ fn import_shape_transform(im: &Im, j: &Json, tr: &mut PropGroup) { } /// Import Lottie shape items into shape contents groups. -pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec { +pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &mut dyn Iterator) -> Vec { let mut out = vec![]; for j in items { let ty = j.get("ty").and_then(Json::as_str).unwrap_or(""); @@ -447,9 +447,10 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec { - let list = j.get("it").and_then(Json::as_array).cloned().unwrap_or_default(); - let inner: Vec = list.iter().filter(|i| i.get("ty").and_then(Json::as_str) != Some("tr")).cloned().collect(); - let children = import_items(im, ids, &inner); + // Borrowed, not cloned: each nesting level copying its subtree costs memory + // quadratic in the depth. + let list = j.get("it").and_then(Json::as_array).map(Vec::as_slice).unwrap_or_default(); + let children = import_items(im, ids, &mut list.iter().filter(|i| i.get("ty").and_then(Json::as_str) != Some("tr"))); let mut g = build::shape_group(ids, "Group 1", children); if let Some(tr) = list.iter().find(|i| i.get("ty").and_then(Json::as_str) == Some("tr")) && let Some(t) = g.sub_mut("transform") diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index b941b4f61..8656852de 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -997,6 +997,53 @@ fn per_corner_roundness_exports_equal_radii_and_warns_when_mixed() { assert!(!warnings.iter().any(|w| w.contains("per-corner")), "{warnings:?}"); } +#[test] +fn many_layers_and_precomps_import_with_their_parents_and_sizes() { + // 5 000 null layers each parented to the previous, and 2 000 empty precomp assets: the + // relationship and size passes index the layers instead of searching them per layer. (Each + // has its own name: `Comp::unique_layer_name` numbering one repeated name is a separate cost.) + let n = 5_000; + let layers: Vec = (1..=n).map(|i| json!({"ty": 3, "nm": format!("N{i}"), "ind": i, "parent": i - 1, "ip": 0, "op": 30, "st": 0, "ks": {}})).collect(); + let mut assets: Vec = (0..2_000).map(|i| json!({"id": format!("c{i}"), "layers": []})).collect(); + assets.push(json!({"id": "last", "layers": []})); + let mut layers = layers; + layers.push(json!({"ty": 0, "ind": n + 1, "refId": "last", "w": 50, "h": 40, "ip": 0, "op": 30, "st": 0, "ks": {}})); + let doc = json!({"w": 200, "h": 120, "fr": 30, "ip": 0, "op": 30, "assets": assets, "layers": layers}); + let mut q = Project::default(); + let nc = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap().comp; + let c = q.comp(nc).unwrap(); + assert_eq!(c.layers.len(), n as usize + 1); + assert_eq!(c.layers.get(0).unwrap().parent, None); + assert_eq!(c.layers.get(1).unwrap().parent, Some(c.layers.get(0).unwrap().id)); + let LayerSource::Comp { item } = c.layers.get(n as usize).unwrap().source else { panic!("not a precomp") }; + let pc = q.comp(item).unwrap(); + assert_eq!((pc.width, pc.height), (50, 40)); +} + +#[test] +fn deeply_nested_shape_groups_and_long_font_names_import() { + let mut g = json!({"ty": "rc", "s": {"a": 0, "k": [10, 10]}, "p": {"a": 0, "k": [0, 0]}, "r": {"a": 0, "k": 0}}); + // 50 levels: each is two levels of JSON, within serde_json's recursion limit of 128. + for _ in 0..50 { + g = json!({"ty": "gr", "it": [g, {"ty": "tr", "o": {"a": 0, "k": 100}}]}); + } + let family = "F".repeat(100_000); + let keys: Vec = (0..50).map(|i| json!({"t": i, "s": {"t": "x", "f": "A", "s": 10}})).collect(); + let doc = json!({"w": 200, "h": 120, "fr": 30, "ip": 0, "op": 60, + "fonts": {"list": [{"fName": "A", "fFamily": family, "fStyle": "Regular"}]}, + "layers": [ + {"ty": 4, "ind": 1, "ip": 0, "op": 60, "st": 0, "ks": {}, "shapes": [g]}, + {"ty": 5, "ind": 2, "ip": 0, "op": 60, "st": 0, "ks": {}, "t": {"d": {"k": keys}}} + ]}); + let mut q = Project::default(); + let nc = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap().comp; + let c = q.comp(nc).unwrap(); + let st = c.layers.get(1).unwrap().props.prop("text/sourceText").unwrap(); + assert_eq!(st.keys.len(), 50); + let Value::Text(d) = &st.keys[0].value else { panic!("not text") }; + assert_eq!(d.font.len(), 256); +} + /// A footage item called a still whose file is not an image is not embedded. #[test] fn only_image_bytes_are_embedded() { diff --git a/crates/lottie/src/text.rs b/crates/lottie/src/text.rs index 125835c2b..4e50b461d 100644 --- a/crates/lottie/src/text.rs +++ b/crates/lottie/src/text.rs @@ -310,7 +310,8 @@ pub(crate) fn import_text(im: &mut Im, ids: &mut Ids, t: &Json, tg: &mut PropGro st.value = Value::Text(Box::new(first.clone())); } if docs.len() > 1 { - st.keys = docs.iter().map(|(f, d)| effectcraft_keyframe::Keyframe::new(im.tb.tick(*f), Value::Text(Box::new(d.clone()))).hold()).collect(); + // Moved into the keys, not cloned: the documents are not kept twice. + st.keys = docs.into_iter().map(|(f, d)| effectcraft_keyframe::Keyframe::new(im.tb.tick(f), Value::Text(Box::new(d))).hold()).collect(); } } if let Some(m) = t.get("m") diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 4947d928a..2fa54e0eb 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -38,5 +38,6 @@ thiserror = { workspace = true } log = { workspace = true } [dev-dependencies] +tempfile = { workspace = true } # (tests render comps that read layers through effects; already a dependency of effectcraft-render) effectcraft-effects = { workspace = true } diff --git a/crates/media/tests/it/footage.rs b/crates/media/tests/it/footage.rs index 7f0de6ae4..ed152541c 100644 --- a/crates/media/tests/it/footage.rs +++ b/crates/media/tests/it/footage.rs @@ -747,8 +747,8 @@ fn sequence_gaps_hold_the_previous_frame() { /// parallax project's 21120-px plates were decoded and resampled for every frame at Quarter). #[test] fn stills_at_reduced_sizes_are_resampled_once() { - let dir = std::env::temp_dir().join(format!("ec-still-size-{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("temp dir"); + let guard = tempfile::tempdir().expect("temp dir"); + let dir = guard.path().to_path_buf(); let p = dir.join("plate.png"); let px = |x: u32, y: u32| { image::Rgba([(x * 7 % 256) as u8, (y * 13 % 256) as u8, ((x ^ y) % 256) as u8, if (x + y).is_multiple_of(5) { 0 } else { 255 - (x % 128) as u8 }]) diff --git a/crates/path/src/interp.rs b/crates/path/src/interp.rs index 4087b26b5..89e7edc51 100644 --- a/crates/path/src/interp.rs +++ b/crates/path/src/interp.rs @@ -311,6 +311,9 @@ fn frame(v: &[P]) -> (P, f64) { (c, r) } +/// Vertex counts above this skip the shape contexts (quadratic to build) and search the starting +/// vertex coarsely; no mask comes near it (the Add Vertices option itself stops at 2000). +const MAX_CONTEXT_VERTICES: usize = 4096; const RBINS: usize = 5; const ABINS: usize = 12; @@ -412,10 +415,18 @@ pub fn correspond(a: &ShapePath, b: &ShapePath, o: &InterpOpts) -> (ShapePath, S } if !o.first_vertices_match { let n = ra.len(); - let use_sc = o.quality > 0.0; + let large = n > MAX_CONTEXT_VERTICES; + let use_sc = o.quality > 0.0 && !large; let (sa, sb) = if use_sc { (Some(shape_contexts(&ra.vertices)), Some(shape_contexts(&rb.vertices))) } else { (None, None) }; // Quality limits the offsets examined exhaustively (coarse step), then refines. - let step = if o.quality >= 50.0 { 1 } else { ((n as f64 / 24.0).ceil() as usize).max(1) }; + // (A very large shape tries about 3 √n offsets, not n, each costing a pass over it.) + let step = if large { + (n as f64).sqrt().ceil() as usize + } else if o.quality >= 50.0 { + 1 + } else { + ((n as f64 / 24.0).ceil() as usize).max(1) + }; let mut best = (f64::MAX, 0usize); let mut k = 0; while k < n { @@ -592,4 +603,22 @@ mod tests { let l2 = (lin.vertices[1][0] - lin.vertices[0][0]).hypot(lin.vertices[1][1] - lin.vertices[0][1]); assert!(l2 < 80.0); } + + /// A shape with tens of thousands of vertices is matched without the quadratic shape contexts + /// and exhaustive offset search. + #[test] + fn very_large_shapes_are_matched_in_bounded_time() { + let n = 30_000; + let circle = |rot: f64| -> ShapePath { + let pts: Vec

= (0..n) + .map(|i| [100.0 * (rot + i as f64 * std::f64::consts::TAU / n as f64).cos(), 100.0 * (rot + i as f64 * std::f64::consts::TAU / n as f64).sin()]) + .collect(); + ShapePath::polygon(&pts, true) + }; + let o = InterpOpts { one_to_one: true, quality: 100.0, ..Default::default() }; + let t = std::time::Instant::now(); + let (a, b) = correspond(&circle(0.0), &circle(1.0), &o); + assert_eq!((a.len(), b.len()), (n, n)); + assert!(t.elapsed().as_secs() < 30, "{:?}", t.elapsed()); + } } diff --git a/crates/path/src/varstroke.rs b/crates/path/src/varstroke.rs index 61e6a1ab7..da00b02d0 100644 --- a/crates/path/src/varstroke.rs +++ b/crates/path/src/varstroke.rs @@ -220,6 +220,56 @@ fn disc(out: &mut BezPath, c: Point, r: f64) { } } +/// `path` with each curve whose extent is over 10⁷ × `tol` replaced by lines flattened at 10⁻⁷ of +/// that extent. `None` when a coordinate is not finite. +fn coarsen_huge(path: &BezPath, tol: f64) -> Option { + use kurbo::Shape as _; + let mut out = BezPath::new(); + let (mut cur, mut start) = (Point::ZERO, Point::ZERO); + for el in path.elements() { + let seg = match *el { + PathEl::MoveTo(p) => { + (cur, start) = (p, p); + out.push(*el); + continue; + } + PathEl::LineTo(p) => { + cur = p; + out.push(*el); + continue; + } + PathEl::ClosePath => { + cur = start; + out.push(*el); + continue; + } + PathEl::QuadTo(_, p) | PathEl::CurveTo(_, _, p) => { + let mut one = BezPath::new(); + one.move_to(cur); + one.push(*el); + (one, p) + } + }; + let (one, end) = seg; + let bb = one.bounding_box(); + let extent = bb.width().max(bb.height()); + if !extent.is_finite() { + return None; + } + if extent * 1e-7 > tol { + kurbo::flatten(one.elements().iter().copied(), extent * 1e-7, |e| { + if let PathEl::LineTo(p) = e { + out.line_to(p); + } + }); + } else { + out.push(*el); + } + cur = end; + } + Some(out) +} + /// Outline of a tapered / waved stroke (local space; `res_scale` = device/local scale). pub fn outline(paths: &[BezPath], style: &StrokeStyle, res_scale: f64) -> Option { if style.width <= 0.0 { @@ -230,7 +280,11 @@ pub fn outline(paths: &[BezPath], style: &StrokeStyle, res_scale: f64) -> Option let half = style.width / 2.0; for path in paths { // Fine enough for the width profile: ½ device pixel, at most ~20 000 samples per path. - let rough = flatten(path, 0.1 / rs, f64::INFINITY); + // Curves with huge extents are first cut into lines at a tolerance of 10⁻⁷ of their own + // extent, so none subdivides into billions of points before the step below applies; + // every other segment (small curves in a large path included) keeps the ½-pixel tolerance. + let Some(path) = coarsen_huge(path, 0.1 / rs) else { continue }; + let rough = flatten(&path, 0.1 / rs, f64::INFINITY); for poly0 in rough { let len0 = poly0.len(); if len0 <= 0.0 { @@ -396,4 +450,37 @@ mod tests { let area: f32 = m.data.iter().sum(); assert!(area > 240.0 * 6.0 * 0.8 && area < 240.0 * 6.0 * 1.05, "{area}"); } + + /// A curve with enormous coordinates is approximated within a bounded number of points. + #[test] + fn huge_coordinates_do_not_explode_the_first_approximation() { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.curve_to((1e13, 0.0), (-1e13, 1e13), (1e13, 1e13)); + let st = StrokeStyle { + width: 4.0, + taper: Some(Taper { percent: true, start_len: 50.0, end_len: 50.0, start_width: 0.0, end_width: 0.0, start_ease: 0.0, end_ease: 0.0 }), + ..Default::default() + }; + let t = std::time::Instant::now(); + let o = outline(&[p], &st, 1.0).unwrap(); + assert!(o.elements().len() < 200_000, "{}", o.elements().len()); + assert!(t.elapsed().as_secs() < 20); + } + + /// Small curves keep their shape in a path that also has a huge segment. + #[test] + fn small_curves_survive_a_huge_segment() { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.curve_to((100.0, 0.0), (0.0, 100.0), (0.0, 0.0)); + p.line_to((1e10, 0.0)); + let c = coarsen_huge(&p, 0.1).unwrap(); + assert_eq!(c.elements().len(), p.elements().len(), "nothing here is huge enough to cut"); + let mut q = BezPath::new(); + q.move_to((0.0, 0.0)); + q.curve_to((1e13, 0.0), (-1e13, 1e13), (1e13, 1e13)); + let c = coarsen_huge(&q, 0.1).unwrap(); + assert!(c.elements().iter().skip(1).all(|e| matches!(e, PathEl::LineTo(_))) && c.elements().len() < 10_000); + } } diff --git a/crates/pdf/src/build.rs b/crates/pdf/src/build.rs index 0b1586f89..0f295a045 100644 --- a/crates/pdf/src/build.rs +++ b/crates/pdf/src/build.rs @@ -7,6 +7,9 @@ use effectcraft_svg::{Affine, BezPath, FillRule, Geom, Group, Node, Paint, Shape /// PostScript program, or the PDF content stream. pub(crate) const MAX_PATH: usize = 1 << 20; +/// The deepest the open groups nest (the render tree and its consumers recurse through them). +const MAX_DEPTH: usize = 256; + #[derive(Clone, Copy, PartialEq, Eq)] enum Kind { Plain, @@ -20,6 +23,9 @@ pub(crate) struct Builder { pub top_layers: Vec, pub skipped: Vec, pub shapes: usize, + /// Groups the finished nodes will sit under once placed (an enclosing builder's open groups + /// and the pattern or mask groups around them), counted against [`MAX_DEPTH`]. + base: usize, } pub(crate) fn group(name: &str) -> Group { @@ -28,13 +34,25 @@ pub(crate) fn group(name: &str) -> Group { impl Builder { pub fn new() -> Builder { - Builder { stack: vec![(group("page"), Kind::Plain)], top_layers: vec![], skipped: vec![], shapes: 0 } + Builder { stack: vec![(group("page"), Kind::Plain)], top_layers: vec![], skipped: vec![], shapes: 0, base: 0 } + } + + /// A builder for content that will be placed inside this one's open groups and `wrap` more + /// groups (a pattern's `Pattern` and `Tile`, a soft mask's two), so its nesting limit counts + /// them. + pub fn nested(&self, wrap: usize) -> Builder { + Builder { base: self.base.saturating_add(self.stack.len()).saturating_add(wrap), ..Builder::new() } } pub fn depth(&self) -> usize { self.stack.len() } + /// `wrap` more groups fit under the open ones within [`MAX_DEPTH`]. + pub fn has_room(&self, wrap: usize) -> bool { + self.base.saturating_add(self.stack.len()).saturating_add(wrap) <= MAX_DEPTH + } + pub fn skip(&mut self, what: &str) { if !self.skipped.iter().any(|s| s == what) { self.skipped.push(what.to_string()); @@ -62,23 +80,33 @@ impl Builder { } } + /// Open `g` as the group drawn into from now on; past [`MAX_DEPTH`] the group is dropped + /// (what would have been drawn inside it goes to the open group) and noted as skipped. + fn push_group(&mut self, g: Group, kind: Kind) { + if self.base.saturating_add(self.stack.len()) >= MAX_DEPTH { + self.skip("group nesting limit"); + return; + } + self.stack.push((g, kind)); + } + /// Children drawn from now on are clipped by `path` (page space) until the group closes. pub fn clip(&mut self, path: BezPath, rule: FillRule) { let mut g = group("Clip Group"); g.clip.push((path, rule)); // Clipping is not a transparency group: blend modes inside reach the backdrop. g.isolated = false; - self.stack.push((g, Kind::Clip)); + self.push_group(g, Kind::Clip); } /// Open an optional-content layer (or a named group). pub fn open(&mut self, name: &str, layer: bool) { - self.stack.push((group(name), if layer { Kind::Layer } else { Kind::Plain })); + self.push_group(group(name), if layer { Kind::Layer } else { Kind::Plain }); } /// Open a group (blend mode, soft mask) closed with the graphics state. pub fn open_group(&mut self, g: Group) { - self.stack.push((g, Kind::Plain)); + self.push_group(g, Kind::Plain); } /// Add a finished node (an image, pattern tiles). diff --git a/crates/pdf/src/page.rs b/crates/pdf/src/page.rs index 0ebc0421d..ba67b39aa 100644 --- a/crates/pdf/src/page.rs +++ b/crates/pdf/src/page.rs @@ -669,7 +669,12 @@ impl<'a> Interp<'a> { let bc = f.get(&sm, "BC").map(|b| f.nums(b)).unwrap_or_default(); let gcs = f.get_dict(&gd, "Group").and_then(|gr| f.get(gr, "CS")).map(|c| color_space(f, c, Some(res))).unwrap_or(Cs::Rgb); let backdrop = if bc.is_empty() { [0.0; 3] } else { gcs.to_rgb(&bc) }; - let saved_b = std::mem::replace(&mut self.b, Builder::new()); + // The mask it replaces closes first: its groups don't enclose the new one. + if let Some(d) = self.gs.mask_depth { + self.close_to(d); + } + let inner = self.b.nested(2); + let saved_b = std::mem::replace(&mut self.b, inner); let ctm = self.gs.ctm; let saved_gs = std::mem::replace(&mut self.gs, GState { ctm, ..GState::default() }); let saved_stack = std::mem::take(&mut self.saved); @@ -681,9 +686,6 @@ impl<'a> Interp<'a> { for s in sk { self.b.skip(&s); } - if let Some(d) = self.gs.mask_depth { - self.close_to(d); - } let mut content = Group::new("Soft Mask"); content.children = nodes; let mut grp = Group::new("Soft Mask Group"); @@ -784,8 +786,15 @@ impl<'a> Interp<'a> { if depth > 8 { return; } + // The `Pattern` and `Tile` wrappers must fit under the open groups too. + if !self.b.has_room(2) { + self.b.skip("group nesting limit"); + return; + } // The cell, drawn once in pattern space. - let saved_b = std::mem::replace(&mut self.b, Builder::new()); + // The cell ends up under the open groups and this pattern's `Pattern` and `Tile`. + let inner = self.b.nested(2); + let saved_b = std::mem::replace(&mut self.b, inner); let saved_gs = std::mem::take(&mut self.gs); let saved_stack = std::mem::take(&mut self.saved); let saved_path = std::mem::take(&mut self.path); diff --git a/crates/pdf/src/ps/builtin.rs b/crates/pdf/src/ps/builtin.rs index dcafa9417..a19aeb6da 100644 --- a/crates/pdf/src/ps/builtin.rs +++ b/crates/pdf/src/ps/builtin.rs @@ -1,4 +1,5 @@ use super::eps::find_from; +use super::exec::MAX_STACK; use super::scanner::Src; use super::text::encoding_array; use super::value::{Flow, V, array, matrix_of, matrix_val, new_dict}; @@ -181,8 +182,13 @@ impl Interp { match self.pop() { V::Num(k) => { let k = (k.max(0.0) as usize).min(self.st.len()); - let tail: Vec = self.st[self.st.len() - k..].to_vec(); - self.st.extend(tail); + // A copy past the operand limit copies nothing, and the values copied + // count as operations (one for eight): repeated doubling spends the budget. + if self.st.len().saturating_add(k) <= MAX_STACK { + self.charge(k / 8); + let tail: Vec = self.st[self.st.len() - k..].to_vec(); + self.st.extend(tail); + } } V::Array(dst) => { let s = self.pop().items().unwrap_or_default(); @@ -507,12 +513,16 @@ impl Interp { fn try_collection_op(&mut self, n: &str) -> bool { match n { // Arrays and strings. + // The storage made counts as operations (a value, or eight bytes, each), so a loop + // of large constructors spends the budget rather than the memory. "array" => { let k = self.popn().clamp(0.0, 1e6) as usize; + self.charge(k); self.push(array(vec![V::Null; k])); } "string" => { let k = self.popn().clamp(0.0, 1e7) as usize; + self.charge(k / 8); self.push(V::Str(Rc::new(RefCell::new(vec![0; k])))); } "length" => { diff --git a/crates/pdf/src/ps/eps.rs b/crates/pdf/src/ps/eps.rs index 90d60cfbe..8fb72698a 100644 --- a/crates/pdf/src/ps/eps.rs +++ b/crates/pdf/src/ps/eps.rs @@ -19,7 +19,8 @@ pub fn eps_parts(bytes: &[u8]) -> Option<(&[u8], [f64; 4])> { if v.len() == 4 && v[2] > v[0] && v[3] > v[1] { return Some([v[0], v[1], v[2], v[3]]); } - from = s; + // A rejected comment is done with: the scan goes on past its line, not over it again. + from = e; } None }; @@ -31,8 +32,15 @@ pub(super) fn find_from(hay: &[u8], needle: &[u8], from: usize) -> Option hay.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|p| p + from) } -fn rfind_before(hay: &[u8], needle: &[u8], to: usize) -> Option { - hay.get(..to)?.windows(needle.len()).rposition(|w| w == needle) +/// Every position of `needle` in `hay`, in order (one pass over the file). +fn positions(hay: &[u8], needle: &[u8]) -> Vec { + let mut out = vec![]; + let mut from = 0; + while let Some(p) = find_from(hay, needle, from) { + out.push(p); + from = p + needle.len(); + } + out } /// The name after `key` (`/FontName /Name`). @@ -48,20 +56,26 @@ fn name_after(b: &[u8], key: &[u8]) -> Option { /// … cleartomark`, hexadecimal or binary) and CFF font sets (`… StartData `). pub(super) fn embedded_fonts(ps: &[u8]) -> HashMap>, bool)> { let mut out = HashMap::new(); + // The headers are found once, in one pass each; every `eexec` then takes the last header + // before it that no earlier `eexec` has used, so no prefix of the file is searched again. + let mut heads = positions(ps, b"%!PS-AdobeFont"); + heads.extend(positions(ps, b"%!FontType1")); + heads.sort_unstable(); + let names = positions(ps, b"/FontName"); let mut from = 0; + let mut used = 0; while let Some(ee) = find_from(ps, b"eexec", from) { from = ee + 5; - let start = [b"%!PS-AdobeFont".as_slice(), b"%!FontType1"] - .iter() - .filter_map(|h| rfind_before(ps, h, ee)) - .max() - .or_else(|| rfind_before(ps, b"/FontName", ee).map(|p| p.saturating_sub(512))); + let last_before = |v: &[usize]| v[..v.partition_point(|&p| p < ee)].last().copied().filter(|&p| p >= used); + // The fallback look-back stops at the previous font's end, so it cannot pick up its header. + let start = last_before(&heads).or_else(|| last_before(&names).map(|p| p.saturating_sub(512).max(used))); let Some(start) = start else { continue }; let end = find_from(ps, b"cleartomark", ee).map_or(ps.len(), |p| p + 11); if let Some(name) = name_after(&ps[start..ee], b"/FontName") { out.insert(name, (Rc::new(ps[start..end].to_vec()), false)); } from = end; + used = end; } let mut from = 0; while let Some(sd) = find_from(ps, b"StartData", from) { @@ -77,3 +91,30 @@ pub(super) fn embedded_fonts(ps: &[u8]) -> HashMap>, bool)> } out } + +#[cfg(test)] +mod tests { + use super::embedded_fonts; + + #[test] + fn font_discovery_looks_back_once_per_header() { + // 20,000 `eexec` words before any font header: each used to search the whole prefix + // for a header again. The font after them is still found. + let mut ps = b"%!PS-Adobe-3.0 EPSF-3.0\n".to_vec(); + ps.extend_from_slice("eexec ".repeat(20_000).as_bytes()); + ps.extend_from_slice(b"\n%!PS-AdobeFont-1.0: Real\n/FontName /Real def\ncurrentfile eexec\n0000\ncleartomark\n"); + let fonts = embedded_fonts(&ps); + assert_eq!(fonts.len(), 1); + assert!(fonts.contains_key("Real")); + } + + #[test] + fn header_less_font_after_a_short_one_does_not_take_its_header() { + let a = "%!PS-AdobeFont-1.0: A\n/FontName /A def\ncurrentfile eexec\n0000\ncleartomark\n"; + let b = "/FontName /B def\ncurrentfile eexec\n0000\ncleartomark\n"; + let ps = format!("%!PS-Adobe-3.0 EPSF-3.0\n{a}{b}"); + let fonts = embedded_fonts(ps.as_bytes()); + let (data, _) = fonts.get("B").expect("second font found"); + assert!(!String::from_utf8_lossy(data).contains("AdobeFont"), "{}", String::from_utf8_lossy(data)); + } +} diff --git a/crates/pdf/src/ps/exec.rs b/crates/pdf/src/ps/exec.rs index 27697527c..233ff82b0 100644 --- a/crates/pdf/src/ps/exec.rs +++ b/crates/pdf/src/ps/exec.rs @@ -6,6 +6,10 @@ use super::{Affine, BezPath, Builder, Cap, HashMap, Interp, Join}; /// The deepest procedure nesting run (deeper recursion stops the program). const MAX_CALL_DEPTH: usize = 256; +/// The most values the operand stack holds: a push past it is dropped, and `copy` copies +/// nothing rather than grow the stack past it. +pub(super) const MAX_STACK: usize = 100_000; + impl Interp { pub fn new(page: [f64; 4]) -> Interp { Interp { @@ -56,11 +60,17 @@ impl Interp { } pub(super) fn push(&mut self, v: V) { - if self.st.len() < 100_000 { + if self.st.len() < MAX_STACK { self.st.push(v); } } + /// Count `n` units of work (values copied, storage allocated) against the budget without + /// stopping the program here: once it is spent, the next operator stops it. + pub(super) fn charge(&mut self, n: usize) { + self.budget = self.budget.saturating_sub(n); + } + pub(super) fn lookup(&self, k: &str) -> Option { self.dicts.iter().rev().find_map(|d| d.borrow().get(k).cloned()) } diff --git a/crates/pdf/src/ps/paths.rs b/crates/pdf/src/ps/paths.rs index b1fe07092..30b5a7087 100644 --- a/crates/pdf/src/ps/paths.rs +++ b/crates/pdf/src/ps/paths.rs @@ -22,7 +22,7 @@ impl Interp { } /// Stop the program when a path of `len` elements would be longer than [`MAX_PATH`]. - fn path_fits(&mut self, len: usize) -> Result<(), Flow> { + pub(super) fn path_fits(&mut self, len: usize) -> Result<(), Flow> { if len <= MAX_PATH { return Ok(()); } diff --git a/crates/pdf/src/ps/text.rs b/crates/pdf/src/ps/text.rs index b138965bb..d4cee07f8 100644 --- a/crates/pdf/src/ps/text.rs +++ b/crates/pdf/src/ps/text.rs @@ -95,7 +95,14 @@ impl Interp { let ctm = self.gs.ctm; for (i, &code) in bytes.iter().enumerate() { let code = code as u32; + // Every input character costs at least one unit, so empty outlines are not free. + self.spend(1)?; if let Some(g) = font.glyph(code) { + // Each outline element counts against the path limit and the budget before it + // is copied, so a long string cannot expand into unbounded geometry. + let n = g.elements().len(); + self.path_fits(path.elements().len().saturating_add(n))?; + self.spend(n)?; path.extend(ctm * Affine::translate(p.to_vec2()) * m * (*g).clone()); } text.push_str(&font.unicode(code)); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index cd3eecdb2..767930cd9 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -748,6 +748,63 @@ fn tiling_patterns_coloured_and_uncoloured() { assert_eq!(at(115, 15)[3], 0.0); } +/// A pattern cell nests under the page's open clipping groups and the pattern's own groups, and +/// the render tree stays within the group depth limit that counts all of them. +#[test] +fn a_pattern_cell_counts_the_groups_it_is_nested_in() { + fn depth(g: &effectcraft_svg::Group) -> usize { + 1 + g.children.iter().filter_map(|c| if let effectcraft_svg::Node::Group(g) = c { Some(depth(g)) } else { None }).max().unwrap_or(0) + } + let clips = "0 0 200 100 re W n ".repeat(250); + let cell = format!("{} 1 0 0 rg 0 0 10 10 re f", "0 0 20 20 re W n ".repeat(250)).into_bytes(); + let bytes = page_pdf( + &format!("{clips} /Pattern cs /P1 scn 0 0 100 100 re f"), + "/Pattern << /P1 10 0 R >>", + vec![(10, "<< /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 /Resources << >> >>".into(), Some(cell))], + ); + let doc = parse(&bytes).unwrap(); + assert!(depth(&doc.root) <= 260, "{}", depth(&doc.root)); + assert!(doc.skipped.iter().any(|s| s == "group nesting limit"), "{:?}", doc.skipped); +} + +/// A pattern whose `Pattern` and `Tile` wrappers would pass the depth limit is skipped, not built +/// past it. +#[test] +fn a_pattern_whose_wrappers_pass_the_depth_limit_is_skipped() { + let clips = "0 0 200 100 re W n ".repeat(255); + let bytes = page_pdf( + &format!("{clips} /Pattern cs /P1 scn 0 0 100 100 re f"), + "/Pattern << /P1 10 0 R >>", + vec![( + 10, + "<< /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 /Resources << >> >>".into(), + Some(b"0 0 10 10 re f".to_vec()), + )], + ); + let doc = parse(&bytes).unwrap(); + assert!(doc.skipped.iter().any(|s| s == "group nesting limit"), "{:?}", doc.skipped); +} + +/// A soft mask replacing another is nested under what is open once the old one has closed, so +/// groups inside the old mask don't count against the new mask's content. +#[test] +fn a_replaced_soft_mask_does_not_count_against_the_new_one() { + let form = |n: &str| { + ( + n.parse().unwrap(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 200 100] /Group << /S /Transparency /CS /DeviceGray >> >>".to_string(), + Some(format!("{} 1 g 0 0 100 100 re f", "0 0 200 100 re W n ".repeat(250)).into_bytes()), + ) + }; + let bytes = page_pdf( + &format!("/GS1 gs {} /GS2 gs 1 0 0 rg 0 0 200 100 re f", "0 0 200 100 re W n ".repeat(250)), + "/ExtGState << /GS1 << /SMask << /S /Luminosity /G 10 0 R >> >> /GS2 << /SMask << /S /Luminosity /G 11 0 R >> >> >>", + vec![form("10"), form("11")], + ); + let doc = parse(&bytes).unwrap(); + assert!(doc.skipped.iter().all(|s| s != "group nesting limit"), "{:?}", doc.skipped); +} + /// Build isolated interpreter objects without presenting a function or colour space as a /// document catalog. Complete-document parsing is covered by the page and reader fixtures. fn object_file(objs: Objs) -> object::File { diff --git a/crates/pdf/src/tests/import.rs b/crates/pdf/src/tests/import.rs index c7dcbee57..516ff054d 100644 --- a/crates/pdf/src/tests/import.rs +++ b/crates/pdf/src/tests/import.rs @@ -513,6 +513,9 @@ fn paths_stop_at_the_path_limit() { // The outlines of 100,000 H's, 13 elements each. "/Helvetica findfont 10 scalefont setfont /s 100000 string def 0 1 99999 { s exch 72 put } for \ 0 0 moveto s false charpath", + // The same outlines shown: the text path is bounded like the current path. + "/Helvetica findfont 10 scalefont setfont /s 100000 string def 0 1 99999 { s exch 72 put } for \ + 0 0 moveto s show", // 210,000 rectangles of five elements each. "/a 840000 array def 0 1 839999 { a exch 0 put } for a rectfill", ] { @@ -528,6 +531,45 @@ fn paths_stop_at_the_path_limit() { assert_eq!(doc.flatten().len(), 1); } +#[test] +fn copies_past_the_operand_limit_copy_nothing() { + // `count copy` doubles the operand stack: 40 doublings would be 2^40 values. A copy that would + // pass the operand limit (100,000) copies nothing, so the program runs on to its end. + let doc = eps_soon("0 0 10 10 rectfill 1 40 { count copy } repeat clear 20 20 10 10 rectfill"); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + assert_eq!(doc.flatten().len(), 2); +} + +#[test] +fn storage_made_in_a_loop_runs_out_of_the_budget() { + // Each value of an array and each eight bytes of a string count as an operation when they + // are made, so a loop keeping large arrays or strings spends the budget, not the memory. + for body in ["/a 1000 array def 0 1 999 { a exch 1000000 array put } for", "/a 1000 array def 0 1 999 { a exch 10000000 string put } for"] { + runs_out_of_budget(body); + } +} + +#[test] +fn groups_stop_nesting_at_the_depth_limit() { + // Every `rectclip` opens a clipping group inside the last: past 256 open groups the group is + // dropped (and noted), and what is drawn inside goes to the open one. + let doc = eps_soon("300 { 0 0 100 100 rectclip } repeat 0 0 10 10 rectfill"); + assert_eq!(doc.skipped, ["group nesting limit"]); + assert_eq!(doc.flatten().len(), 1); +} + +#[test] +fn bounding_box_comments_are_scanned_once() { + // A rejected `%%BoundingBox:` comment is not scanned again from each later marker on its + // line: 20,000 markers on one line would otherwise take quadratic work. + let mut src = b"%!PS-Adobe-3.0 EPSF-3.0\n".to_vec(); + src.extend_from_slice("%%BoundingBox: x ".repeat(20_000).as_bytes()); + src.extend_from_slice(b"\n%%BoundingBox: 0 0 50 50\n0 0 10 10 rectfill\n"); + let doc = parse(&src).unwrap(); + assert_eq!((doc.width, doc.height), (50.0, 50.0)); + assert_eq!(doc.flatten().len(), 1); +} + #[test] fn pdf_paths_stop_at_the_path_limit() { // A content stream's current path holds at most 1,048,576 elements: `re` (five elements each) diff --git a/crates/project/src/comp.rs b/crates/project/src/comp.rs index d8d7cda16..c0c401c82 100644 --- a/crates/project/src/comp.rs +++ b/crates/project/src/comp.rs @@ -268,8 +268,19 @@ impl Comp { /// `base`, or the next free numbered name when a layer already has it (`Shape Layer 2`, /// `v-b1.mp4 2`; see `numbered_name`). pub fn unique_layer_name(&self, base: &str) -> String { - let taken = |n: &str| self.layers.iter().any(|l| l.name == n); - if taken(base) { crate::item::numbered_name(base, taken) } else { base.to_string() } + if !self.layers.iter().any(|l| l.name == base) { + return base.to_string(); + } + let stem = crate::item::name_stem(base); + // The suffixes in use, gathered in one pass: trying each candidate against every layer + // made importing many layers of one name cubic. + let taken: std::collections::HashSet = self + .layers + .iter() + .filter_map(|l| l.name.strip_prefix(stem)?.strip_prefix(' ')) + .filter_map(|s| s.parse::().ok().filter(|n| n.to_string() == s)) + .collect(); + (2..).find(|i| !taken.contains(i)).map(|i| format!("{stem} {i}")).unwrap_or_default() } } diff --git a/crates/project/src/item.rs b/crates/project/src/item.rs index 4fcc06ab9..b055c60ba 100644 --- a/crates/project/src/item.rs +++ b/crates/project/src/item.rs @@ -8,12 +8,18 @@ use super::{Arc, BTreeMap, Deserialize, FrameRate, Label, Project, Serialize, Ti /// another layer of the same name. A number set off by a space is a counter and is replaced /// (`Red Solid 1` → `Red Solid 2`, `shot 2` → `shot 3`); digits that end a word belong to the /// name and stay (`v-b1.mp4` → `v-b1.mp4 2`, `clip2.mov` → `clip2.mov 2`). -pub(crate) fn numbered_name(base: &str, taken: impl Fn(&str) -> bool) -> String { +/// `base` without a trailing ` N`: digits are dropped only when a space separates them from a +/// nonempty stem, so `v-b1.mp4` and `Layer1` keep theirs. +pub(crate) fn name_stem(base: &str) -> &str { let head = base.trim_end_matches(|c: char| c.is_ascii_digit()); - let stem = match head.strip_suffix(' ') { + match head.strip_suffix(' ') { Some(stem) if head.len() < base.len() && !stem.trim_end().is_empty() => stem.trim_end(), _ => base.trim_end(), - }; + } +} + +pub(crate) fn numbered_name(base: &str, taken: impl Fn(&str) -> bool) -> String { + let stem = name_stem(base); (2u64..).map(|k| format!("{stem} {k}")).find(|n| !taken(n)).unwrap_or_default() } diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index 5418e5ac7..fa830cb1b 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -1713,7 +1713,22 @@ pub fn numbered_template(template: &str, n: u32) -> String { /// Make a name safe as a file name component. pub fn sanitize(name: &str) -> String { - name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect() + let mut s: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect(); + // At most a file name's worth (255 UTF-8 bytes, whole characters only): a template repeating + // a long name token must not expand to gigabytes. A cut name ends in a hash of the whole + // name so long names that share a prefix stay distinct. + if s.len() > 255 { + // shortcut: 64-bit FNV-1a, not collision-resistant against crafted names; use a real hash if names become untrusted keys. + let hash = name.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)); + let mut end = 255 - 17; + while !s.is_char_boundary(end) { + end -= 1; + } + s.truncate(end); + s.push_str(&format!("-{hash:016x}")); + } + // `.` and `..` would name the folder itself or its parent when the token is a path component. + if !s.is_empty() && s.chars().all(|c| c == '.') { "_".repeat(s.len()) } else { s } } #[cfg(test)] @@ -1737,10 +1752,32 @@ mod tests { use super::*; + #[test] + fn sanitize_limits_the_name_in_bytes() { + // 255 two-byte characters are cut to whole characters plus a 17-byte hash suffix. + let s = sanitize(&"é".repeat(255)); + assert!(s.len() <= 255 && s.len() > 235, "{}", s.len()); + assert!(s.starts_with("éé") && s.is_char_boundary(s.len() - 17)); + // Names that share their first 255 bytes stay distinct, and a name that fits is untouched. + let (a, b) = (sanitize(&format!("{}A", "界".repeat(85))), sanitize(&format!("{}B", "界".repeat(85)))); + assert_ne!(a, b); + assert!(a.len() <= 255 && b.len() <= 255); + assert_eq!(sanitize(&"n".repeat(255)), "n".repeat(255)); + assert_eq!(sanitize(&"é".repeat(127)), "é".repeat(127)); + } + #[test] fn templates_and_sequences() { let v = TemplateVars { comp_name: "Main/Title", project_name: "P", width: 1920, height: 1080, frame_rate: 29.97, start_frame: 0, end_frame: 9 }; assert_eq!(expand_template(DEFAULT_TEMPLATE, OutputFormat::H264, &v), "Main_Title.mp4"); + // A dot-only name is not a parent-folder component; a long one is cut to a file name. + for name in ["..", "."] { + let v = TemplateVars { comp_name: name, ..v }; + assert_eq!(expand_template("out/[compName]/x.[fileExtension]", OutputFormat::Gif, &v), format!("out/{}/x.gif", "_".repeat(name.len()))); + } + let long = "n".repeat(100_000); + let v2 = TemplateVars { comp_name: &long, ..v }; + assert_eq!(expand_template(&"[compName]".repeat(100), OutputFormat::Gif, &v2).len(), 255 * 100); assert_eq!(expand_template("[compName]_[width]x[height]@[frameRate].[fileExtension]", OutputFormat::Gif, &v), "Main_Title_1920x1080@29.97.gif"); assert_eq!(sequence_path("out/a_[#####].png", 42), "out/a_00042.png"); assert_eq!(sequence_path("a###.tif", 7), "a007.tif"); diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index d040c8f0b..80ba3c771 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -146,6 +146,18 @@ fn unique_names_and_cycles() { let c = p.comp(cid).unwrap(); assert_eq!(c.unique_layer_name("Red Solid 1"), "Red Solid 2"); assert_eq!(c.unique_layer_name("Shape Layer 1"), "Shape Layer 1"); + // A suffix written another way (`02`) is not the number taken. + let mut d = c.clone(); + for n in ["Red Solid 02", "Red Solid 3"] { + let mut l = d.layers[0].clone(); + l.name = n.into(); + d.layers.push(l); + } + assert_eq!(d.unique_layer_name("Red Solid 1"), "Red Solid 2"); + // Digits that end a word stay in the stem. + let mut e = c.clone(); + e.layers[0].name = "v-b1.mp4".into(); + assert_eq!(e.unique_layer_name("v-b1.mp4"), "v-b1.mp4 2"); assert!(p.comp_contains(cid, cid)); } diff --git a/crates/psd/src/limits.rs b/crates/psd/src/limits.rs index c1c23b390..6db366c00 100644 --- a/crates/psd/src/limits.rs +++ b/crates/psd/src/limits.rs @@ -107,7 +107,7 @@ impl Budget { /// Inflate a zlib stream to at most `limit` bytes. pub(crate) fn inflate(src: &[u8], limit: usize) -> Result> { - miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(src, limit).map_err(|e| Error::Invalid(format!("zip: {e:?}"))) + miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(src, limit).map_err(|e| Error::Invalid(format!("zip: {:?}", e.status))) } /// Give back what a channel buffer from `Psd::channel_bytes` / `Psd::decode_plane` was charged: diff --git a/crates/psd/src/memory_tests.rs b/crates/psd/src/memory_tests.rs index 108046b66..96ac5c70c 100644 --- a/crates/psd/src/memory_tests.rs +++ b/crates/psd/src/memory_tests.rs @@ -28,10 +28,11 @@ impl Psd { } 2 | 3 => { let rest = r.bytes(r.remaining())?; - let mut v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; + let mut v = + miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, rowb * h).map_err(|e| Error::Invalid(format!("zip: {:?}", e.status)))?; v.resize(rowb * h, 0); if comp == 3 { - self.unpredict(&mut v, w, h); + self.unpredict(&mut v, w, h, &mut Budget::new(u64::MAX))?; } v } @@ -128,11 +129,12 @@ impl Psd { } 2 | 3 => { let rest = r.bytes(r.remaining())?; - let v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, want * rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; + let v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, want * rowb * h) + .map_err(|e| Error::Invalid(format!("zip: {:?}", e.status)))?; for (c, p) in planes.iter_mut().enumerate().take(want) { let mut plane = v.get(c * rowb * h..(c + 1) * rowb * h).ok_or(Error::Truncated)?.to_vec(); if comp == 3 { - self.unpredict(&mut plane, w, h); + self.unpredict(&mut plane, w, h, &mut Budget::new(u64::MAX))?; } *p = Some(self.ref_samples(&plane, w, h)); } diff --git a/crates/psd/src/pixels.rs b/crates/psd/src/pixels.rs index 21a5c8845..290a7b6f4 100644 --- a/crates/psd/src/pixels.rs +++ b/crates/psd/src/pixels.rs @@ -37,7 +37,7 @@ impl Psd { PlaneStream::Rle(counts) => Cow::Owned(Self::unpack_plane(&counts, 0, r, rowb, h, budget)?), PlaneStream::Zip(mut v) => { v.resize(size, 0); - self.zip_plane(comp, &mut v, 0, w, h)?; + self.zip_plane(comp, &mut v, 0, w, h, budget)?; Cow::Owned(v) } }) @@ -90,7 +90,7 @@ impl Psd { Ok(match planes { PlaneStream::Raw => Cow::Borrowed(r.bytes(size)?), PlaneStream::Rle(counts) => Cow::Owned(Self::unpack_plane(counts, c, r, rowb, h, budget)?), - PlaneStream::Zip(v) => Cow::Borrowed(self.zip_plane(comp, v, c, w, h)?), + PlaneStream::Zip(v) => Cow::Borrowed(self.zip_plane(comp, v, c, w, h, budget)?), }) } @@ -106,11 +106,11 @@ impl Psd { } /// Plane `c` of the inflated ZIP data of consecutive planes, un-predicted in place (`comp` 3). - pub(crate) fn zip_plane<'v>(&self, comp: u16, v: &'v mut [u8], c: usize, w: usize, h: usize) -> Result<&'v [u8]> { + pub(crate) fn zip_plane<'v>(&self, comp: u16, v: &'v mut [u8], c: usize, w: usize, h: usize, budget: &mut Budget) -> Result<&'v [u8]> { let rowb = self.row_bytes(w); let plane = v.get_mut(c * rowb * h..(c + 1) * rowb * h).ok_or(Error::Truncated)?; if comp == 3 { - self.unpredict(plane, w, h); + self.unpredict(plane, w, h, budget)?; } Ok(plane) } @@ -132,7 +132,8 @@ impl Psd { if self.depth == 1 { w.div_ceil(8) } else { w * self.bytes_per_sample() } } - pub(crate) fn unpredict(&self, v: &mut [u8], w: usize, h: usize) { + /// Undo ZIP prediction in place; the 32-bit scratch row is charged to `budget`. + pub(crate) fn unpredict(&self, v: &mut [u8], w: usize, h: usize, budget: &mut Budget) -> Result<()> { match self.depth { 16 => { for row in v.chunks_exact_mut(w * 2).take(h) { @@ -145,8 +146,9 @@ impl Psd { } } 32 => { - // One scratch row for the whole plane. - let mut planar = vec![0u8; w * 4]; + // One scratch row for the whole plane, charged: a wide, short layer passes the + // decode's peak check and would otherwise allocate it on top. + let mut planar = budget.filled(w.checked_mul(4).ok_or_else(|| too_large(w, h))?, 0u8)?; #[cfg(test)] counters::bump(&counters::SCRATCH); for row in v.chunks_exact_mut(w * 4).take(h) { @@ -161,6 +163,7 @@ impl Psd { } } } + budget.release(planar.len() as u64); } _ => { for row in v.chunks_exact_mut(w).take(h) { @@ -170,6 +173,7 @@ impl Psd { } } } + Ok(()) } /// Hand every sample of `raw` (`w`×`h`, row-major, in order) to `f` as a float. Samples a short @@ -483,15 +487,17 @@ pub(crate) fn unpack_bits(src: &[u8], row: usize, out: &mut Vec) -> Result<( while i < src.len() && out.len() - start < row { let n = src[i] as i8; i += 1; + // A packet is written only up to the row's end, so the reserved plane never grows. + let room = row - (out.len() - start); if n >= 0 { let k = n as usize + 1; let s = src.get(i..i + k).ok_or(Error::Truncated)?; - out.extend_from_slice(s); + out.extend_from_slice(&s[..k.min(room)]); i += k; } else if n != -128 { let k = (1 - n as isize) as usize; let v = *src.get(i).ok_or(Error::Truncated)?; - out.extend(std::iter::repeat_n(v, k)); + out.extend(std::iter::repeat_n(v, k.min(room))); i += 1; } } diff --git a/crates/psd/src/tests.rs b/crates/psd/src/tests.rs index 074976510..fd041138a 100644 --- a/crates/psd/src/tests.rs +++ b/crates/psd/src/tests.rs @@ -493,3 +493,29 @@ fn rle_composite_matches_the_raw_one() { assert_eq!(rle.layer_pixels(2, true).unwrap().data, raw.layer_pixels(2, true).unwrap().data, "{depth} bit"); } } + +#[test] +fn packbits_packets_stop_at_the_row_end() { + // A 128-byte repeat and a 128-byte literal into 3-byte rows: the reserved row is never + // outgrown (capacity stays what was reserved). + for src in [vec![0x81u8, 7], { + let mut v = vec![0x7fu8]; + v.extend([1u8; 128]); + v + }] { + let mut out = Vec::with_capacity(3); + unpack_bits(&src, 3, &mut out).unwrap(); + assert_eq!(out.len(), 3); + assert_eq!(out.capacity(), 3); + } +} + +#[test] +fn a_failed_inflate_reports_its_status_not_its_output() { + // A stream that inflates a megabyte of zeros, then breaks its checksum. + let mut z = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 1 << 20], 6); + let n = z.len(); + z[n - 1] ^= 0xff; + let Err(Error::Invalid(msg)) = crate::limits::inflate(&z, 1 << 21) else { panic!("the checksum fails") }; + assert!(msg.len() < 100, "{} bytes", msg.len()); +} diff --git a/crates/psd/src/tests/opt.rs b/crates/psd/src/tests/opt.rs index 908026d07..44bcec071 100644 --- a/crates/psd/src/tests/opt.rs +++ b/crates/psd/src/tests/opt.rs @@ -69,7 +69,7 @@ fn ref_decode_channel(p: &Psd, offset: usize, len: usize, w: usize, h: usize) -> } 2 | 3 => { let rest = r.bytes(r.remaining())?; - let mut v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; + let mut v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, rowb * h).map_err(|e| Error::Invalid(format!("zip: {:?}", e.status)))?; v.resize(rowb * h, 0); if comp == 3 { ref_unpredict(p.depth, &mut v, w, h); @@ -112,7 +112,8 @@ fn ref_composite(p: &Psd) -> Result { } 2 | 3 => { let rest = r.bytes(r.remaining())?; - let v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, want * rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; + let v = + miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, want * rowb * h).map_err(|e| Error::Invalid(format!("zip: {:?}", e.status)))?; for (c, pl) in planes.iter_mut().enumerate().take(want) { let mut plane = v.get(c * rowb * h..(c + 1) * rowb * h).ok_or(Error::Truncated)?.to_vec(); if comp == 3 { @@ -448,7 +449,7 @@ fn unpredict_matches_the_reference_and_allocates_one_scratch_row() { let mut a: Vec = (0..w * h * bps).map(|i| (i * 31 % 251) as u8).collect(); let mut b = a.clone(); counters::take(&SCRATCH); - p.unpredict(&mut a, w, h); + p.unpredict(&mut a, w, h, &mut Budget::new(u64::MAX)).unwrap(); let scratch = counters::take(&SCRATCH); let old_allocs = ref_unpredict(depth, &mut b, w, h); assert_eq!(a, b, "{depth} bit"); @@ -473,11 +474,11 @@ fn raw_planes_are_borrowed_and_zip_planes_are_unpredicted_in_place() { let mut v: Vec = (0..3 * 40 * 30).map(|i| (i % 255) as u8).collect(); let range = v.as_ptr_range(); for comp in [2, 3] { - let got = q.zip_plane(comp, &mut v, 1, 40, 30).unwrap(); + let got = q.zip_plane(comp, &mut v, 1, 40, 30, &mut Budget::new(u64::MAX)).unwrap(); assert_eq!(got.len(), 1200); assert!(range.contains(&got.as_ptr()), "compression {comp}"); } - assert!(q.zip_plane(2, &mut v, 3, 40, 30).is_err()); + assert!(q.zip_plane(2, &mut v, 3, 40, 30, &mut Budget::new(u64::MAX)).is_err()); } #[test] diff --git a/crates/raster/src/blur.rs b/crates/raster/src/blur.rs index d92148658..76e51f865 100644 --- a/crates/raster/src/blur.rs +++ b/crates/raster/src/blur.rs @@ -27,13 +27,25 @@ fn box_h_into(src: &[Px], out: &mut [Px], w: usize, r: usize, repeat: bool) { row[i as usize] } }; + // The starting window: the pixels inside the row once each, and the repeated edge pixels + // counted rather than visited (a radius far past the row would otherwise loop over it). let mut acc = [0.0f32; 4]; - for i in -(r as isize)..=(r as isize) { - let p = get(i); + let inside = r.min(w - 1); + for p in &row[..=inside] { for c in 0..4 { acc[c] += p[c]; } } + if repeat { + let (left, right) = (r as f32, (r - inside) as f32); + for c in 0..4 { + acc[c] += row[0][c] * left; + // A zero count adds nothing (0 * NaN would be NaN). + if right > 0.0 { + acc[c] += row[w - 1][c] * right; + } + } + } for x in 0..w { o[x] = acc.map(|v| v * norm); let add = get(x as isize + r as isize + 1); @@ -65,14 +77,27 @@ fn box_v_into(src: &[Px], out: &mut [Px], w: usize, h: usize, r: usize, repeat: out.par_chunks_mut(w * block).enumerate().for_each(|(bi, chunk)| { let ya = (bi * block) as isize; let mut acc = vec![[0.0f32; 4]; w]; - for i in ya - r as isize..=ya + r as isize { - if let Some(rw) = row(i) { - for (a, p) in acc.iter_mut().zip(rw) { - for c in 0..4 { - a[c] += p[c]; - } + // The starting window: rows inside the image once each, the repeated edge rows counted. + let (lo, hi) = (ya - r as isize, ya + r as isize); + let add = |acc: &mut [Px], rw: &[Px], k: f32| { + // A zero count adds nothing (0 * NaN would be NaN). + if k == 0.0 { + return; + } + for (a, p) in acc.iter_mut().zip(rw) { + for c in 0..4 { + a[c] += p[c] * k; } } + }; + for i in lo.max(0)..=hi.min(h as isize - 1) { + add(&mut acc, &src[i as usize * w..(i as usize + 1) * w], 1.0); + } + if repeat { + let above = (-lo).max(0) as f32; + let below = (hi - (h as isize - 1)).max(0) as f32; + add(&mut acc, &src[0..w], above); + add(&mut acc, &src[(h - 1) * w..h * w], below); } for (yy, o) in chunk.chunks_mut(w).enumerate() { let y = ya + yy as isize; @@ -360,6 +385,59 @@ mod tests { } } + #[test] + fn a_non_finite_edge_pixel_outside_every_window_is_not_counted() { + // 9 wide, radius 1: the last pixel is in no window of the first pixels; likewise the last row. + let mut src = vec![[1.0f32; 4]; 81]; + src[80] = [f32::NAN; 4]; + let mut out = vec![[0.0f32; 4]; 81]; + box_h_into(&src, &mut out, 9, 1, true); + assert!(out[0].iter().all(|v| v.is_finite()), "{:?}", out[0]); + box_v_into(&src, &mut out, 9, 9, 1, true); + assert!(out[0].iter().all(|v| v.is_finite()), "{:?}", out[0]); + } + + #[test] + fn box_windows_count_repeated_edges_and_take_any_radius() { + // Against a window summed pixel by pixel, for radii inside and far past the image. + let (w, h) = (7usize, 5usize); + let src: Vec = (0..w * h).map(|i| [i as f32, (i % 3) as f32, 1.0, 0.5]).collect(); + let at = |x: isize, y: isize, repeat: bool| -> Px { + if !repeat && (x < 0 || y < 0 || x >= w as isize || y >= h as isize) { + return [0.0; 4]; + } + src[y.clamp(0, h as isize - 1) as usize * w + x.clamp(0, w as isize - 1) as usize] + }; + for repeat in [true, false] { + for r in [1usize, 3, 6, 9, 40] { + let (mut hx, mut vx) = (vec![[0.0; 4]; w * h], vec![[0.0; 4]; w * h]); + box_h_into(&src, &mut hx, w, r, repeat); + box_v_into(&src, &mut vx, w, h, r, repeat); + for y in 0..h as isize { + for x in 0..w as isize { + let (mut eh, mut ev) = ([0.0f32; 4], [0.0f32; 4]); + for d in -(r as isize)..=r as isize { + for c in 0..4 { + eh[c] += at(x + d, y, repeat)[c]; + ev[c] += at(x, y + d, repeat)[c]; + } + } + let n = (2 * r + 1) as f32; + let i = y as usize * w + x as usize; + for c in 0..4 { + assert!((hx[i][c] - eh[c] / n).abs() < 1e-3, "h r={r} repeat={repeat}"); + assert!((vx[i][c] - ev[c] / n).abs() < 1e-3, "v r={r} repeat={repeat}"); + } + } + } + } + } + // A radius of 2^40 finishes at once. + let mut out = vec![[0.0; 4]; w * h]; + box_h_into(&src, &mut out, w, 1 << 40, true); + box_v_into(&src, &mut out.clone(), w, h, 1 << 40, true); + } + #[test] fn gaussian_preserves_mass() { let mut img = Image::new(64, 64); 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/svg/Cargo.toml b/crates/svg/Cargo.toml index 0e1af7719..03cacaa7b 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -16,3 +16,4 @@ effectcraft-path = { workspace = true } kurbo = { workspace = true } roxmltree = "0.21" rayon = { workspace = true } +log = { workspace = true } diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 95c00a91a..c31617a92 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -603,8 +603,15 @@ struct Parser<'a, 'i> { /// The `` targets being expanded, outermost first: a reference back into one is a /// cycle and is skipped. using: Vec, + /// Path elements and gradient stops kept so far, `` copies and repeated gradient + /// references included; past [`MAX_RETAINED`] the import errs. + retained: std::cell::Cell, } +/// The most path elements and gradient stops an import keeps: a complex path reused many times, +/// or a gradient with many stops referenced by many shapes, is copied for each use. +const MAX_RETAINED: usize = 8_000_000; + fn is_svg_ns(n: &roxmltree::Node) -> bool { n.is_element() } @@ -628,6 +635,13 @@ impl<'a, 'i> Parser<'a, 'i> { out } + /// Charge `n` kept path elements or gradient stops; false once [`MAX_RETAINED`] is passed. + fn charge_retained(&self, n: usize) -> bool { + let r = self.retained.get().saturating_add(n); + self.retained.set(r); + r <= MAX_RETAINED + } + /// Charge `n` units of CSS work; false once the budget is spent. fn charge_css(&self, n: usize) -> bool { let w = self.css_work.get().saturating_add(n); @@ -875,6 +889,10 @@ impl<'a, 'i> Parser<'a, 'i> { // Stops: from the first element in the chain that has any. let mut stops = vec![]; if let Some(src) = chain.iter().find(|e| e.children().any(|c| c.is_element() && c.tag_name().name() == "stop")) { + // Charged before the stops are read: every reference reads and keeps them again. + if !self.charge_retained(src.children().filter(|c| c.is_element()).count()) { + return None; + } let mut last = 0.0f64; for s in src.children().filter(|c| c.is_element() && c.tag_name().name() == "stop") { let d = self.declared(s); @@ -1026,6 +1044,11 @@ impl<'a, 'i> Parser<'a, 'i> { return; } let Some(geom) = self.geometry(n, &st) else { return }; + if let Geom::Path(p) = &geom + && !self.charge_retained(p.elements().len()) + { + return; + } let fill = if matches!(tag, "line") { None } else { st.fill.as_ref().and_then(|p| self.resolve_paint(p, &st)) }; let (fill, fill_alpha) = match fill { Some((p, a)) => (Some(p), a), @@ -1304,6 +1327,7 @@ pub fn parse(bytes: &[u8]) -> Result { work: 0, budget, using: vec![], + retained: std::cell::Cell::new(0), }; let st = Style::default(); let pct_or = |name: &str, axis: char, def: f64| -> f64 { @@ -1344,6 +1368,9 @@ pub fn parse(bytes: &[u8]) -> Result { // Never a silently partial drawing. return Err(Error::Xml(format!("its references expand to more than {} elements", p.budget))); } + if p.retained.get() > MAX_RETAINED { + return Err(Error::Xml(format!("its paths and gradients, with their copies, hold more than {MAX_RETAINED} elements"))); + } if p.css_work.get() > MAX_CSS_WORK { // Never a silently unstyled drawing. return Err(Error::Xml(format!("matching its