mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-12 13:32:04 +00:00
Merge origin/main; one span limit (MAX_SPAN_FRAMES = MAX_CHAIN)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+45
-40
@@ -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",
|
||||
|
||||
+2
-2
@@ -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" }
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Vec<Option<u32>>> {
|
||||
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<Vec<Option<u32>>
|
||||
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(),
|
||||
)
|
||||
|
||||
@@ -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")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<LossCategory, Loss>,
|
||||
pub missing_footage: Vec<String>,
|
||||
pub missing_fonts: Vec<String>,
|
||||
/// 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<String, (String, usize)>,
|
||||
}
|
||||
|
||||
/// 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<String>) {
|
||||
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::<Vec<_>>();
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u8> = [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::<String>::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<u8> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<Vec<Lookup>> {
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,4 +12,5 @@ workspace = true
|
||||
[dependencies]
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
filmcraft-av1 = { workspace = true }
|
||||
|
||||
@@ -57,8 +57,8 @@ fn check(name: &str, cfg: EncoderConfig, pics: &[Pic]) -> Vec<u32> {
|
||||
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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -394,7 +394,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
};
|
||||
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]);
|
||||
|
||||
@@ -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<f32> {
|
||||
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 {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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<Image>, scale: f64) -> Option<(Vec<f32>, Vec<u8>, 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]);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
+72
-13
@@ -562,13 +562,22 @@ struct SimEntry<S> {
|
||||
/// 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<S: Clone> SimCache<S> {
|
||||
pub const fn new(cap: usize) -> SimCache<S> {
|
||||
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<S> {
|
||||
self.run_where(key, target, init, step, |_, _| true)
|
||||
}
|
||||
@@ -586,6 +595,7 @@ impl<S: Clone> SimCache<S> {
|
||||
valid: impl Fn(u64, &S) -> bool,
|
||||
) -> std::sync::Arc<S> {
|
||||
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<S: Clone> SimCache<S> {
|
||||
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<S: Clone> SimCache<S> {
|
||||
};
|
||||
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<u64> = 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<u64> = 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<u64> = 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<u64> = 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<u64> = 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]];
|
||||
|
||||
@@ -344,7 +344,9 @@ pub fn load_lut(src: &str) -> Option<Arc<Lut>> {
|
||||
{
|
||||
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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<String> {
|
||||
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)) {
|
||||
|
||||
@@ -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<String> {
|
||||
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}",
|
||||
|
||||
@@ -395,6 +395,9 @@ fn collect_files(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// 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<String, usize> = 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<Value> {
|
||||
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).
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
//! premultiplied): the composite as `R`, `G`, `B`, `A` plus one `<layer name>.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>) -> 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<String>, next: &mut HashMap<String, usize>) -> 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<CommandSpec> {
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[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<String> = ["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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<LayerId> {
|
||||
insert_layer_at(proj, st, cid, layer, None)
|
||||
@@ -89,10 +122,12 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value>
|
||||
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<Value> {
|
||||
}
|
||||
}
|
||||
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<Value> {
|
||||
}
|
||||
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<Value> {
|
||||
.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}))
|
||||
|
||||
@@ -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<LayerId> {
|
||||
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<Value> {
|
||||
_ => "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<Value> {
|
||||
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}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<i64, String> {
|
||||
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(';');
|
||||
|
||||
@@ -282,10 +282,11 @@ fn new_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<u8>)], dir: &str) -> Result<Vec<String>> {
|
||||
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/<item>/<file>` → 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<u8>)
|
||||
// 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}")))?;
|
||||
|
||||
@@ -477,35 +477,51 @@ pub fn split_at_images(doc: &Doc) -> Vec<Doc> {
|
||||
})
|
||||
.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<Node>) -> 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<Node>)> = 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<Option<effectcraft_svg::Group>> = 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<Sh
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use effectcraft_svg::{Geom, Group, Image, Shape};
|
||||
|
||||
fn shape(name: &str) -> 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>) -> Node {
|
||||
let mut g = Group::new(name);
|
||||
g.children = children;
|
||||
Node::Group(g)
|
||||
}
|
||||
fn names(g: &Group, out: &mut Vec<String>) {
|
||||
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<Vec<String>> = segs
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let mut v = vec![];
|
||||
names(&d.root, &mut v);
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let want: Vec<Vec<&str>> =
|
||||
vec![vec!["a"], vec!["<g1", "b", "g1>"], vec!["<g1", "c", "<g2", "d", "g2>", "g1>"], vec!["<g1", "<g2", "e", "g2>", "g1>"], vec![], vec!["f"]];
|
||||
assert_eq!(got, want);
|
||||
assert!(segs.iter().all(|d| d.root.name == "root" && d.width == 10.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ pub fn pages() -> Vec<Page> {
|
||||
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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -125,7 +125,13 @@ fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
|
||||
|
||||
/// One log line (without the newline): `<timestamp> <LEVEL> [<thread>] <target>: <message>`.
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<Vec<f64>>);
|
||||
impl crate::render::FootageSource for Vector {
|
||||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
panic!("the full-size frame was decoded");
|
||||
}
|
||||
fn vector_frame(&self, _: ItemId, f: &Footage, scale: f64) -> Option<Arc<Image>> {
|
||||
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<Arc<Image>> {
|
||||
None
|
||||
}
|
||||
fn vector_frame(&self, _: ItemId, _: &Footage, _: f64) -> Option<Arc<Image>> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<usize>,
|
||||
/// 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<u64> + Sync)>,
|
||||
}
|
||||
|
||||
/// The layer caches of a Render Queue run (see [`JobOptions::caches`]): one per compositor,
|
||||
|
||||
@@ -137,6 +137,17 @@ pub(crate) fn cpu_inflight(var: Option<&str>) -> Option<usize> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<u64>, free: Option<u64>, threads: usize) -> Option<usize> {
|
||||
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<Option<(usize, String)>>,
|
||||
/// 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<Option<Option<usize>>>,
|
||||
/// (output frame, seconds) of every rendered frame (Plus Per Frame Info logs).
|
||||
pub frame_times: Option<Mutex<Vec<(u64, f64)>>>,
|
||||
/// 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<usize> {
|
||||
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<usize> {
|
||||
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<u64>| 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);
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<u8>], recon: &[[Vec<u8>; 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")
|
||||
|
||||
@@ -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<u64> {
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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<ItemId>) -> Option<fp::BinId> {
|
||||
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<ItemId> = 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<VideoSp
|
||||
fn media_clip(media: fp::MediaRef, info: MediaInfo) -> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
/// Source item → created item (footage, comp).
|
||||
items: HashMap<fp::ItemId, ItemId>,
|
||||
/// Generator / adjustment solids by (source item, width, height).
|
||||
@@ -290,7 +294,7 @@ struct Importer<'a> {
|
||||
impl Importer<'_> {
|
||||
fn warn(&mut self, m: impl Into<String>) {
|
||||
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<Layer> {
|
||||
/// 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<fp::ClipId, Vec<&fp::Transition>> = 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<Layer> {
|
||||
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);
|
||||
|
||||
@@ -787,3 +787,98 @@ fn deeply_nested_documents_error_instead_of_overflowing_the_stack() {
|
||||
assert_eq!(super::import::xml_depth(b"<a><!-- <b><b> --><c x='>'/><![CDATA[<d>]]><e></e></a>"), 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<ItemId> = 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());
|
||||
}
|
||||
|
||||
@@ -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<String>) {
|
||||
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<String>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+30
-20
@@ -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::<String>();
|
||||
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<i64, LayerId> = BTreeMap::new();
|
||||
// (layer id, parent ind, matte: (kind, explicit tp ind), json position)
|
||||
let mut pending: Vec<(LayerId, Option<i64>, Option<(MatteKind, Option<i64>)>, 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<i64>, Option<(MatteKind, Option<i64>)>, usize)> = vec![];
|
||||
let mut pos_ind: Vec<Option<i64>> = 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;
|
||||
}
|
||||
|
||||
@@ -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<PropGroup> {
|
||||
pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &mut dyn Iterator<Item = &Json>) -> Vec<PropGroup> {
|
||||
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<Pr
|
||||
};
|
||||
let mut g = match ty {
|
||||
"gr" => {
|
||||
let list = j.get("it").and_then(Json::as_array).cloned().unwrap_or_default();
|
||||
let inner: Vec<Json> = 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")
|
||||
|
||||
@@ -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<Json> = (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<Json> = (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<Json> = (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() {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }])
|
||||
|
||||
@@ -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<P> = (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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<BezPath> {
|
||||
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<BezPath> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+32
-4
@@ -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<bool>,
|
||||
pub skipped: Vec<String>,
|
||||
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).
|
||||
|
||||
+14
-5
@@ -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);
|
||||
|
||||
@@ -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<V> = 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<V> = 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" => {
|
||||
|
||||
@@ -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<usize>
|
||||
hay.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|p| p + from)
|
||||
}
|
||||
|
||||
fn rfind_before(hay: &[u8], needle: &[u8], to: usize) -> Option<usize> {
|
||||
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<usize> {
|
||||
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<String> {
|
||||
/// … cleartomark`, hexadecimal or binary) and CFF font sets (`… <length> StartData <bytes>`).
|
||||
pub(super) fn embedded_fonts(ps: &[u8]) -> HashMap<String, (Rc<Vec<u8>>, 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<String, (Rc<Vec<u8>>, 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<V> {
|
||||
self.dicts.iter().rev().find_map(|d| d.borrow().get(k).cloned())
|
||||
}
|
||||
|
||||
@@ -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(());
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<u64> = self
|
||||
.layers
|
||||
.iter()
|
||||
.filter_map(|l| l.name.strip_prefix(stem)?.strip_prefix(' '))
|
||||
.filter_map(|s| s.parse::<u64>().ok().filter(|n| n.to_string() == s))
|
||||
.collect();
|
||||
(2..).find(|i| !taken.contains(i)).map(|i| format!("{stem} {i}")).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ impl Budget {
|
||||
|
||||
/// Inflate a zlib stream to at most `limit` bytes.
|
||||
pub(crate) fn inflate(src: &[u8], limit: usize) -> Result<Vec<u8>> {
|
||||
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:
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<u8>) -> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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<Pixels> {
|
||||
}
|
||||
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<u8> = (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<u8> = (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]
|
||||
|
||||
@@ -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<Px> = (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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -427,6 +427,8 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
|
||||
let p = p.to_string_lossy();
|
||||
Ok(json!(if op == "exists" { s.services.exists(&p) } else { s.services.is_dir(&p) }))
|
||||
}
|
||||
// The write gate alone, with no side effect on the file (export wrappers).
|
||||
"checkWrite" => 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}"))?;
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -16,3 +16,4 @@ effectcraft-path = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
roxmltree = "0.21"
|
||||
rayon = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -603,8 +603,15 @@ struct Parser<'a, 'i> {
|
||||
/// The `<use>` targets being expanded, outermost first: a reference back into one is a
|
||||
/// cycle and is skipped.
|
||||
using: Vec<roxmltree::NodeId>,
|
||||
/// Path elements and gradient stops kept so far, `<use>` copies and repeated gradient
|
||||
/// references included; past [`MAX_RETAINED`] the import errs.
|
||||
retained: std::cell::Cell<usize>,
|
||||
}
|
||||
|
||||
/// 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<Doc, Error> {
|
||||
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<Doc, Error> {
|
||||
// Never a silently partial drawing.
|
||||
return Err(Error::Xml(format!("its <use> 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 <style> rules takes more than {MAX_CSS_WORK} steps")));
|
||||
|
||||
@@ -20,7 +20,10 @@ pub fn rasterize(doc: &Doc, w: u32, h: u32, scale: f64) -> Image {
|
||||
|
||||
/// Rasterise `doc` with `m` mapping document pixels to image pixels.
|
||||
pub fn rasterize_with(doc: &Doc, w: u32, h: u32, m: Affine) -> Image {
|
||||
let mut budget = SurfaceBudget { live_bytes: 0, limit_bytes: MAX_LIVE_SURFACE_BYTES };
|
||||
// Nested knockout and non-isolated groups draw their children more than once; the number of
|
||||
// node draws is bounded by a multiple of the tree's size, past which drawing stops.
|
||||
let draws_left = node_count(&doc.root).saturating_mul(MAX_DRAWS_PER_NODE).max(MIN_DRAWS);
|
||||
let mut budget = SurfaceBudget { live_bytes: 0, limit_bytes: MAX_LIVE_SURFACE_BYTES, draws_left };
|
||||
let Some(mut img) = budget.image(w, h, None) else { return Image::default() };
|
||||
draw_group(&doc.root, m, 1.0, &mut img, &mut budget);
|
||||
img
|
||||
@@ -29,9 +32,31 @@ pub fn rasterize_with(doc: &Doc, w: u32, h: u32, m: Affine) -> Image {
|
||||
/// Bound the simultaneously live offscreen image surfaces created by nested groups.
|
||||
const MAX_LIVE_SURFACE_BYTES: usize = 512 * 1024 * 1024;
|
||||
|
||||
/// How many times each node of a document may be drawn, on average, in one rasterisation.
|
||||
const MAX_DRAWS_PER_NODE: usize = 64;
|
||||
const MIN_DRAWS: usize = 100_000;
|
||||
|
||||
/// The nodes in `g`'s tree, `g` included.
|
||||
fn node_count(g: &Group) -> usize {
|
||||
let mut n = 0usize;
|
||||
let mut stack = vec![g];
|
||||
while let Some(g) = stack.pop() {
|
||||
n = n.saturating_add(1);
|
||||
for c in &g.children {
|
||||
match c {
|
||||
Node::Group(cg) => stack.push(cg),
|
||||
_ => n = n.saturating_add(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
struct SurfaceBudget {
|
||||
live_bytes: usize,
|
||||
limit_bytes: usize,
|
||||
/// Node draws left (see [`MAX_DRAWS_PER_NODE`]).
|
||||
draws_left: usize,
|
||||
}
|
||||
|
||||
impl SurfaceBudget {
|
||||
@@ -394,6 +419,11 @@ fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] {
|
||||
}
|
||||
|
||||
fn draw_node(n: &Node, m: Affine, opacity: f64, dst: &mut Image, budget: &mut SurfaceBudget) {
|
||||
let Some(left) = budget.draws_left.checked_sub(1) else { return };
|
||||
budget.draws_left = left;
|
||||
if left == 0 {
|
||||
log::warn!("svg: draw budget reached, the rest of the drawing is skipped");
|
||||
}
|
||||
match n {
|
||||
Node::Group(g) => draw_group(g, m, opacity, dst, budget),
|
||||
Node::Shape(s) => {
|
||||
|
||||
@@ -446,3 +446,40 @@ fn at_rule_blocks_are_skipped_by_brace_depth() {
|
||||
assert_eq!(sheet.rules.len(), 1);
|
||||
assert_eq!(sheet.rules[0].classes, vec!["e".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_stops_copied_past_the_budget_are_refused() {
|
||||
// 4,000 stops referenced by 2,100 shapes would keep 8.4 million stops.
|
||||
let stops: String = (0..4000).map(|i| format!("<stop offset=\"{}\" stop-color=\"red\"/>", i as f64 / 4000.0)).collect();
|
||||
let rects = "<rect width=\"1\" height=\"1\" fill=\"url(#g)\"/>".repeat(2100);
|
||||
let svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\"><defs><linearGradient id=\"g\">{stops}</linearGradient></defs>{rects}</svg>");
|
||||
assert!(matches!(parse(svg.as_bytes()), Err(Error::Xml(e)) if e.contains("gradients")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_knockout_groups_stop_at_the_draw_budget() {
|
||||
// Each knockout level draws its children twice and each non-isolated level twice more:
|
||||
// 30 nested levels would be 4^30 draws. The draw budget ends it.
|
||||
let mut g = Group::new("leaf");
|
||||
g.children.push(Node::Shape(Shape {
|
||||
name: "s".into(),
|
||||
geom: Geom::Rect { x: 0.0, y: 0.0, w: 4.0, h: 4.0, rx: 0.0, ry: 0.0 },
|
||||
transform: Affine::IDENTITY,
|
||||
fill: Some(Paint::Color([1.0, 0.0, 0.0])),
|
||||
fill_opacity: 1.0,
|
||||
fill_rule: FillRule::NonZero,
|
||||
stroke: None,
|
||||
opacity: 1.0,
|
||||
}));
|
||||
for _ in 0..30 {
|
||||
let mut p = Group::new("k");
|
||||
p.knockout = true;
|
||||
p.isolated = false;
|
||||
p.children.push(Node::Group(g));
|
||||
g = p;
|
||||
}
|
||||
let doc = Doc { width: 4.0, height: 4.0, root: g, skipped: vec![] };
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || tx.send(rasterize(&doc, 4, 4, 1.0).width).ok());
|
||||
assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(20)).expect("rendering finishes"), 4);
|
||||
}
|
||||
|
||||
+55
-26
@@ -107,17 +107,29 @@ fn extra_dirs(var: Option<&std::ffi::OsStr>) -> Vec<PathBuf> {
|
||||
var.map(|v| std::env::split_paths(v).filter(|p| !p.as_os_str().is_empty()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec<PathBuf>) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
// Bounded, so a symlink loop ends.
|
||||
if depth < 8 {
|
||||
walk(&p, depth + 1, out);
|
||||
/// Font files under `roots`, each real folder listed once. Breadth first from every root, so a
|
||||
/// folder reached by several routes is listed at its shallowest and keeps the most depth below it.
|
||||
fn walk(roots: &[PathBuf], out: &mut Vec<PathBuf>) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut queue: std::collections::VecDeque<(PathBuf, u32)> = roots.iter().map(|r| (r.clone(), 0)).collect();
|
||||
while let Some((dir, depth)) = queue.pop_front() {
|
||||
// Symlinks fanning out to the same folders would otherwise be walked again along every
|
||||
// path to them (exponentially many within the depth bound).
|
||||
if !seen.insert(std::fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone())) {
|
||||
continue;
|
||||
}
|
||||
let Ok(rd) = std::fs::read_dir(&dir) else { continue };
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
// Bounded, so a symlink loop ends.
|
||||
if depth < 8 {
|
||||
queue.push_back((p, depth + 1));
|
||||
}
|
||||
} else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|x| matches!(x.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc"))
|
||||
{
|
||||
out.push(p);
|
||||
}
|
||||
} else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|x| matches!(x.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,9 +137,7 @@ fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec<PathBuf>) {
|
||||
impl FontSource for DirectorySource {
|
||||
fn faces(&self) -> Vec<FaceInfo> {
|
||||
let mut files = Vec::new();
|
||||
for d in &self.dirs {
|
||||
walk(d, 0, &mut files);
|
||||
}
|
||||
walk(&self.dirs, &mut files);
|
||||
files.sort();
|
||||
files.dedup();
|
||||
let mut out = Vec::new();
|
||||
@@ -158,17 +168,6 @@ fn info_from(n: FaceNames, data: FaceData, origin: &'static str) -> FaceInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// A font file's bytes and its metadata from the same open file (so the metadata describes the
|
||||
/// bytes, not a later version of the file).
|
||||
fn read_with_metadata(path: &std::path::Path) -> std::io::Result<(Vec<u8>, std::fs::Metadata)> {
|
||||
use std::io::Read;
|
||||
let mut f = std::fs::File::open(path)?;
|
||||
let meta = f.metadata()?;
|
||||
let mut bytes = Vec::new();
|
||||
f.read_to_end(&mut bytes)?;
|
||||
Ok((bytes, meta))
|
||||
}
|
||||
|
||||
/// A loaded (or loadable) face.
|
||||
pub struct Face {
|
||||
pub id: FaceId,
|
||||
@@ -207,6 +206,36 @@ pub struct VMetrics {
|
||||
pub underline_thickness: f32,
|
||||
}
|
||||
|
||||
/// A font file's bytes, shared by every face of it that is loaded (the faces of a collection
|
||||
/// each held their own copy of the whole file), with the metadata of the open file they were read
|
||||
/// from (so it describes the bytes, not a later version of the file).
|
||||
fn file_bytes(p: &std::path::Path) -> std::io::Result<(Arc<Vec<u8>>, std::fs::Metadata)> {
|
||||
use std::io::Read;
|
||||
type Key = (PathBuf, u64, Option<std::time::SystemTime>);
|
||||
type Entry = (std::sync::Weak<Vec<u8>>, std::fs::Metadata);
|
||||
static FILES: std::sync::LazyLock<std::sync::Mutex<std::collections::HashMap<Key, Entry>>> = std::sync::LazyLock::new(Default::default);
|
||||
let lock = || FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let hit = |files: &std::collections::HashMap<Key, Entry>, key: &Key| files.get(key).and_then(|(w, m)| Some((w.upgrade()?, m.clone())));
|
||||
let mut f = std::fs::File::open(p)?;
|
||||
// Keyed by size and modification time too: a file replaced at the same path is read afresh.
|
||||
let m = f.metadata()?;
|
||||
let key: Key = (p.to_path_buf(), m.len(), m.modified().ok());
|
||||
if let Some(found) = hit(&lock(), &key) {
|
||||
return Ok(found);
|
||||
}
|
||||
// Read outside the lock: a slow file must not hold up every other face's first load.
|
||||
let mut bytes = Vec::new();
|
||||
f.read_to_end(&mut bytes)?;
|
||||
let b = Arc::new(bytes);
|
||||
let mut files = lock();
|
||||
if let Some(found) = hit(&files, &key) {
|
||||
return Ok(found);
|
||||
}
|
||||
files.retain(|_, (w, _)| w.strong_count() > 0);
|
||||
files.insert(key, (Arc::downgrade(&b), m.clone()));
|
||||
Ok((b, m))
|
||||
}
|
||||
|
||||
impl Face {
|
||||
/// The font file bytes, loaded once on first use, or `None` when they cannot be read.
|
||||
fn bytes(&self) -> Option<&[u8]> {
|
||||
@@ -215,9 +244,9 @@ impl Face {
|
||||
FaceData::Static(b) => Some(FaceBytes::Static(b)),
|
||||
FaceData::Shared(b) => Some(FaceBytes::Shared(b.clone())),
|
||||
FaceData::File(p) => {
|
||||
let (bytes, meta) = read_with_metadata(p).ok()?;
|
||||
let (bytes, meta) = file_bytes(p).ok()?;
|
||||
let _ = self.file_meta.set(meta);
|
||||
Some(FaceBytes::Shared(Arc::new(bytes)))
|
||||
Some(FaceBytes::Shared(bytes))
|
||||
}
|
||||
})
|
||||
.as_ref()
|
||||
|
||||
@@ -567,3 +567,72 @@ fn unknown_face_id_returns_bundled_default() {
|
||||
assert!(fallback.font().is_some());
|
||||
assert!(Arc::ptr_eq(&fallback, &face(0)));
|
||||
}
|
||||
|
||||
/// A scratch folder this test created itself (never one that already exists), removed on drop.
|
||||
struct Scratch(std::path::PathBuf);
|
||||
impl Scratch {
|
||||
fn new(tag: &str) -> Self {
|
||||
let d = (0u32..)
|
||||
.map(|n| std::env::temp_dir().join(format!("effectcraft-{tag}-{}-{n}", std::process::id())))
|
||||
.find_map(|d| match std::fs::create_dir(&d) {
|
||||
Ok(()) => Some(d),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => None,
|
||||
Err(e) => panic!("create scratch dir {}: {e}", d.display()),
|
||||
})
|
||||
.unwrap();
|
||||
Scratch(d)
|
||||
}
|
||||
}
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_folder_reached_through_many_links_is_listed_once() {
|
||||
// Six links back to the folder itself: 6^8 listings within the depth bound before.
|
||||
let scratch = Scratch::new("font-walk");
|
||||
let d = &scratch.0;
|
||||
std::fs::write(d.join("a.ttf"), b"x").unwrap();
|
||||
for i in 0..6 {
|
||||
std::os::unix::fs::symlink(d, d.join(format!("l{i}"))).unwrap();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let start = std::time::Instant::now();
|
||||
walk(std::slice::from_ref(d), &mut out);
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(5));
|
||||
assert_eq!(out.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_inside_another_root_keeps_its_own_depth() {
|
||||
// root/a/b/c/d/e/f/g/h is also a root: the walk of `root` reaches it at depth 8 and could not
|
||||
// go below it, so it must be listed from the shallower visit.
|
||||
let scratch = Scratch::new("font-depth");
|
||||
let root = &scratch.0;
|
||||
let deep = root.join("a/b/c/d/e/f/g/h");
|
||||
std::fs::create_dir_all(deep.join("sub")).unwrap();
|
||||
std::fs::write(deep.join("sub/x.ttf"), b"x").unwrap();
|
||||
let mut out = Vec::new();
|
||||
walk(&[root.clone(), deep.clone()], &mut out);
|
||||
assert_eq!(out, vec![deep.join("sub/x.ttf")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_file_replaced_at_the_same_path_is_read_again() {
|
||||
let scratch = Scratch::new("font-bytes");
|
||||
let p = scratch.0.join("a.ttf");
|
||||
std::fs::write(&p, b"old").unwrap();
|
||||
let (old, old_meta) = file_bytes(&p).unwrap();
|
||||
// Faces of one file share its bytes, and the metadata is that of the file the bytes came from.
|
||||
let (again, _) = file_bytes(&p).unwrap();
|
||||
assert!(Arc::ptr_eq(&old, &again));
|
||||
assert_eq!(old_meta.len(), 3);
|
||||
std::fs::write(&p, b"new, longer").unwrap();
|
||||
let (new, new_meta) = file_bytes(&p).unwrap();
|
||||
assert_eq!(new.as_slice(), b"new, longer");
|
||||
assert_eq!(new_meta.len(), 11);
|
||||
assert_eq!(old.as_slice(), b"old");
|
||||
}
|
||||
|
||||
+37
-21
@@ -628,9 +628,31 @@ fn is_hanging_open(c: char) -> bool {
|
||||
matches!(c, '"' | '\'' | '\u{2018}' | '\u{201c}' | '(' | '\u{ab}')
|
||||
}
|
||||
|
||||
/// Whether no run is reversed and each starts at or after the end of the one before it (the runs
|
||||
/// of a document): the precondition of [`run_at`]'s bisection.
|
||||
fn runs_ordered(runs: &[(Range<usize>, TextStyle)]) -> bool {
|
||||
runs.iter().all(|(r, _)| r.start <= r.end) && runs.windows(2).all(|w| w[0].0.end <= w[1].0.start)
|
||||
}
|
||||
|
||||
/// Style runs with whether they are [`runs_ordered`].
|
||||
#[derive(Clone, Copy)]
|
||||
struct Runs<'a> {
|
||||
list: &'a [(Range<usize>, TextStyle)],
|
||||
ordered: bool,
|
||||
}
|
||||
|
||||
/// The style run index covering global byte `b` (the run before it at a boundary, for empty
|
||||
/// paragraphs at the end).
|
||||
fn run_at(runs: &[(Range<usize>, TextStyle)], b: usize) -> usize {
|
||||
/// paragraphs at the end). `ordered` is [`runs_ordered`] of `runs`.
|
||||
fn run_at(runs: &[(Range<usize>, TextStyle)], ordered: bool, b: usize) -> usize {
|
||||
// Ordered, non-overlapping runs: the last one starting at or before `b` is the only one that
|
||||
// can hold it, found by bisection (a scan from the first run per character was quadratic).
|
||||
// Anything else (a gap, overlapping or unordered runs) takes the scan, whose first match wins.
|
||||
if ordered {
|
||||
let i = runs.partition_point(|(r, _)| r.start <= b).saturating_sub(1);
|
||||
if runs.get(i).is_some_and(|(r, _)| r.contains(&b)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
runs.iter().position(|(r, _)| b >= r.start && b < r.end).unwrap_or_else(|| runs.iter().rposition(|(r, _)| r.start <= b).unwrap_or(0))
|
||||
}
|
||||
|
||||
@@ -706,7 +728,7 @@ impl<'a> Para<'a> {
|
||||
fn new(
|
||||
text: &'a str,
|
||||
base: usize,
|
||||
runs: &'a [(Range<usize>, TextStyle)],
|
||||
runs: Runs<'a>,
|
||||
primaries: &'a [Resolved],
|
||||
variation_ids: &'a [u32],
|
||||
para: &'a ParagraphStyle,
|
||||
@@ -716,7 +738,7 @@ impl<'a> Para<'a> {
|
||||
let bidi = BidiInfo::new(text, default_level);
|
||||
let base_rtl = bidi.paragraphs[0].level.is_rtl();
|
||||
let chars = char_forms(text, base, runs, primaries, &bidi);
|
||||
let items = build_items(text, &chars, runs);
|
||||
let items = build_items(text, &chars, runs.list);
|
||||
// Prefix widths and the preceding non-whitespace boundary make candidate line widths O(1).
|
||||
// Every-line composition evaluates O(n²) candidate breaks; summing each candidate's bytes
|
||||
// or rescanning its trailing whitespace would otherwise add another factor of text length.
|
||||
@@ -739,7 +761,7 @@ impl<'a> Para<'a> {
|
||||
}
|
||||
previous_non_ws[b + c.len_utf8()] = non_ws_end;
|
||||
}
|
||||
Para { text, base, runs, primaries, variation_ids, para, width, bidi, base_rtl, chars, items, char_adv, prefix_adv, previous_non_ws }
|
||||
Para { text, base, runs: runs.list, primaries, variation_ids, para, width, bidi, base_rtl, chars, items, char_adv, prefix_adv, previous_non_ws }
|
||||
}
|
||||
|
||||
fn range_w(&self, r: Range<usize>) -> f32 {
|
||||
@@ -1021,11 +1043,11 @@ impl<'a> Para<'a> {
|
||||
}
|
||||
|
||||
/// Each char with its shaped form, face, direction and run.
|
||||
fn char_forms(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primaries: &[Resolved], bidi: &BidiInfo) -> Vec<CharInfo> {
|
||||
fn char_forms(text: &str, base: usize, runs: Runs, primaries: &[Resolved], bidi: &BidiInfo) -> Vec<CharInfo> {
|
||||
text.char_indices()
|
||||
.map(|(b, c)| {
|
||||
let si = run_at(runs, base + b);
|
||||
let style = &runs[si].1;
|
||||
let si = run_at(runs.list, runs.ordered, base + b);
|
||||
let style = &runs.list[si].1;
|
||||
let primary = primaries[si].face;
|
||||
let face_of = |ch: char| if ch.is_whitespace() { primary } else { fonts::fallback_for(ch, primary) };
|
||||
let all_small = style.opentype.all_small_caps && style.caps != Caps::All;
|
||||
@@ -1079,17 +1101,9 @@ fn build_items(text: &str, chars: &[CharInfo], runs: &[(Range<usize>, TextStyle)
|
||||
items
|
||||
}
|
||||
|
||||
fn paragraph(
|
||||
text: &str,
|
||||
base: usize,
|
||||
runs: &[(Range<usize>, TextStyle)],
|
||||
primaries: &[Resolved],
|
||||
variation_ids: &[u32],
|
||||
para: &ParagraphStyle,
|
||||
width: Option<f32>,
|
||||
) -> Vec<ParaLine> {
|
||||
fn paragraph(text: &str, base: usize, runs: Runs, primaries: &[Resolved], variation_ids: &[u32], para: &ParagraphStyle, width: Option<f32>) -> Vec<ParaLine> {
|
||||
if text.is_empty() {
|
||||
let si = run_at(runs, base);
|
||||
let (runs, si) = (runs.list, run_at(runs.list, runs.ordered, base));
|
||||
let pm = style_metrics(primaries, runs, si);
|
||||
// No text to resolve a direction from: the explicit one.
|
||||
let rtl = para.rtl == Some(true);
|
||||
@@ -1167,18 +1181,20 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range<usize>, TextStyle)], para
|
||||
});
|
||||
let runs = cleared.as_deref().unwrap_or(runs);
|
||||
let variation_ids: Vec<u32> = runs.iter().map(|(_, s)| crate::variable::intern(&s.variations)).collect();
|
||||
let ordered = runs_ordered(runs);
|
||||
let mut lay = Layout { missing_font: primaries.iter().any(|p| p.missing), text_len: text.len(), ..Default::default() };
|
||||
let mut plines: Vec<(usize, ParaLine)> = Vec::new();
|
||||
for (pi, span) in paragraph_ranges(text).enumerate() {
|
||||
let p = text.get(span.bytes.clone()).unwrap_or_default();
|
||||
plines.extend(paragraph(p, span.bytes.start, runs, &primaries, &variation_ids, para_at(pi), width).into_iter().map(|l| (pi, l)));
|
||||
plines
|
||||
.extend(paragraph(p, span.bytes.start, Runs { list: runs, ordered }, &primaries, &variation_ids, para_at(pi), width).into_iter().map(|l| (pi, l)));
|
||||
}
|
||||
let mut baseline = 0.0f32;
|
||||
for (i, (pi, pl)) in plines.into_iter().enumerate() {
|
||||
let para = para_at(pi);
|
||||
if i == 0 {
|
||||
// Vertical type keeps its first column at the font ascent (not measured in AE).
|
||||
let vertical = runs.get(run_at(runs, pl.range.start)).is_some_and(|r| r.1.vertical)
|
||||
let vertical = runs.get(run_at(runs, ordered, pl.range.start)).is_some_and(|r| r.1.vertical)
|
||||
|| runs.iter().any(|r| r.1.vertical && r.0.start < pl.range.end && r.0.end > pl.range.start);
|
||||
baseline = if width.is_some() { if vertical { pl.ascent } else { pl.first } } else { 0.0 };
|
||||
} else {
|
||||
@@ -1198,7 +1214,7 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range<usize>, TextStyle)], para
|
||||
}));
|
||||
let x = shift + pl.left_trim;
|
||||
if let Some((u0, u1)) = pl.underline {
|
||||
let si = run_at(runs, pl.range.start);
|
||||
let si = run_at(runs, ordered, pl.range.start);
|
||||
let pm = fonts::face(primaries[si].face).metrics(runs[si].1.size);
|
||||
lay.underlines.push([u0 + shift, baseline + pm.underline_pos, u1 + shift, baseline + pm.underline_pos + pm.underline_thickness]);
|
||||
}
|
||||
|
||||
@@ -505,3 +505,29 @@ fn a_later_vertical_run_keeps_the_first_line_baseline() {
|
||||
let mixed = layout_rich_uncached(text, &[(0..4, horiz), (4..text.len(), vert)], &[p]);
|
||||
assert!((mixed.lines[0].baseline - plain.lines[0].baseline).abs() < 1e-3, "{} vs {}", mixed.lines[0].baseline, plain.lines[0].baseline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_lookup_by_bisection_matches_the_scan() {
|
||||
let scan = |runs: &[(Range<usize>, TextStyle)], b: usize| {
|
||||
runs.iter().position(|(r, _)| b >= r.start && b < r.end).unwrap_or_else(|| runs.iter().rposition(|(r, _)| r.start <= b).unwrap_or(0))
|
||||
};
|
||||
let runs: Vec<(Range<usize>, TextStyle)> = (0..50).map(|i| (i * 3..i * 3 + 3, st(10.0))).collect();
|
||||
let gapped: Vec<(Range<usize>, TextStyle)> = (0..50).map(|i| (i * 4..i * 4 + 3, st(10.0))).collect();
|
||||
// Overlapping runs: the first run holding a byte wins, as in the scan.
|
||||
let overlapping: Vec<(Range<usize>, TextStyle)> = vec![(0..10, st(10.0)), (5..15, st(20.0)), (12..20, st(30.0))];
|
||||
assert!(runs_ordered(&runs) && runs_ordered(&gapped) && !runs_ordered(&overlapping));
|
||||
for b in 0..220 {
|
||||
assert_eq!(run_at(&runs, true, b), scan(&runs, b), "{b}");
|
||||
assert_eq!(run_at(&gapped, true, b), scan(&gapped, b), "{b}");
|
||||
assert_eq!(run_at(&overlapping, false, b), scan(&overlapping, b), "{b}");
|
||||
}
|
||||
assert_eq!(run_at(&overlapping, false, 6), 0);
|
||||
// A reversed range (20..0) among others is not ordered either, and takes the scan.
|
||||
#[allow(clippy::reversed_empty_ranges)]
|
||||
let reversed: Vec<(Range<usize>, TextStyle)> = vec![(0..10, st(10.0)), (20..0, st(20.0)), (5..15, st(30.0)), (15..20, st(40.0))];
|
||||
assert!(!runs_ordered(&reversed));
|
||||
for b in 0..30 {
|
||||
assert_eq!(run_at(&reversed, runs_ordered(&reversed), b), scan(&reversed, b), "{b}");
|
||||
}
|
||||
assert_eq!(run_at(&[], true, 5), 0);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,26 @@ impl PathMeasure {
|
||||
let mut closed = false;
|
||||
let mut started = false;
|
||||
let mut done = false;
|
||||
kurbo::flatten(path.iter(), 0.05, |el| {
|
||||
// The tolerance grows with the path's size (0.05 for ordinary layer-sized paths): a
|
||||
// path with huge coordinates would otherwise flatten into hundreds of millions of points.
|
||||
// Only the first subpath is measured, so only it sets the tolerance and gets flattened.
|
||||
let mut first = BezPath::new();
|
||||
for el in path.iter() {
|
||||
if matches!(el, PathEl::MoveTo(_)) && !first.elements().is_empty() {
|
||||
break;
|
||||
}
|
||||
first.push(el);
|
||||
if matches!(el, PathEl::ClosePath) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// A non-finite coordinate (an infinite control point too) measures nothing.
|
||||
if !first.is_finite() {
|
||||
return PathMeasure::default();
|
||||
}
|
||||
let bbox = kurbo::Shape::bounding_box(&first);
|
||||
let size = bbox.width().max(bbox.height());
|
||||
kurbo::flatten(first.iter(), 0.05_f64.max(size * 1e-6), |el| {
|
||||
if done {
|
||||
return;
|
||||
}
|
||||
@@ -128,6 +147,38 @@ pub fn arc_positions(xs: &[f64], line: [f64; 2], align: u8, length: f64, first_m
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_huge_curve_flattens_into_a_bounded_number_of_points() {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((0.0, 0.0));
|
||||
p.curve_to((1e15, 0.0), (1e15, 1e15), (0.0, 1e15));
|
||||
let m = PathMeasure::new(&p, false);
|
||||
assert!(m.pts.len() < 100_000, "{}", m.pts.len());
|
||||
assert!(m.length() > 1e15);
|
||||
// Non-finite coordinates measure nothing.
|
||||
let mut q = BezPath::new();
|
||||
q.move_to((0.0, 0.0));
|
||||
q.curve_to((f64::INFINITY, 0.0), (1.0, 1.0), (2.0, 0.0));
|
||||
assert_eq!(PathMeasure::new(&q, false).pts.len(), 0);
|
||||
// Even with finite endpoints, an infinite control point is refused before flattening.
|
||||
let mut r = BezPath::new();
|
||||
r.move_to((0.0, 0.0));
|
||||
r.curve_to((f64::INFINITY, f64::INFINITY), (f64::INFINITY, 0.0), (1.0, 0.0));
|
||||
assert_eq!(PathMeasure::new(&r, false).pts.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_distant_second_subpath_does_not_coarsen_the_first() {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((0.0, 0.0));
|
||||
p.curve_to((0.0, 100.0), (100.0, 100.0), (100.0, 0.0));
|
||||
p.move_to((1e9, 1e9));
|
||||
p.line_to((1e9 + 1.0, 1e9));
|
||||
let m = PathMeasure::new(&p, false);
|
||||
assert!(m.pts.len() > 10, "{}", m.pts.len());
|
||||
assert!(m.length() > 150.0, "{}", m.length());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measures_a_line_and_a_circle() {
|
||||
let mut p = BezPath::new();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user