mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-13 01:15:24 -06:00
Merge remote-tracking branch 'upstream/main' into fix/3087-aep-psd-layers
# Conflicts: # crates/aep/tests/it/import/synthetic.rs # docs/aep-import.md
This commit is contained in:
@@ -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
+39
-39
@@ -1420,7 +1420,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cpal",
|
||||
@@ -1448,7 +1448,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-aep"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
@@ -1462,7 +1462,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-automation"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-host",
|
||||
@@ -1476,14 +1476,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-av1enc"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"filmcraft-av1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-cli"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"cpu-time",
|
||||
"effectcraft-automation",
|
||||
@@ -1502,7 +1502,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-color"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1510,7 +1510,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-effects"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"effectcraft-color",
|
||||
@@ -1528,7 +1528,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-engine"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
@@ -1567,7 +1567,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-export"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"effectcraft-av1enc",
|
||||
"effectcraft-color",
|
||||
@@ -1598,7 +1598,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-expr"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-color",
|
||||
@@ -1615,7 +1615,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-geom"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1623,7 +1623,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-gpu"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"effectcraft-color",
|
||||
@@ -1646,7 +1646,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-helper-host"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"effectcraft-effects",
|
||||
@@ -1657,14 +1657,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-hevcenc"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"filmcraft-hevc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-host"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
@@ -1691,7 +1691,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-interchange"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1710,7 +1710,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-keyframe"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1722,7 +1722,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-lottie"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-color",
|
||||
@@ -1740,7 +1740,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-media"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-color",
|
||||
@@ -1773,7 +1773,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-model"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"image",
|
||||
@@ -1786,14 +1786,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-opusenc"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"filmcraft-opus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-path"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1805,7 +1805,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-pdf"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"effectcraft-raster",
|
||||
@@ -1821,7 +1821,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-plugin"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"effectcraft-effects",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1833,7 +1833,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-project"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"craft-ecproj-format",
|
||||
"effectcraft-color",
|
||||
@@ -1847,14 +1847,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-psd"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.9.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-raster"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1864,7 +1864,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-render"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
@@ -1887,7 +1887,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-sam2"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"candle-core",
|
||||
@@ -1902,7 +1902,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-script"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-engine",
|
||||
@@ -1920,7 +1920,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-segment"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.9.1",
|
||||
"ndarray",
|
||||
@@ -1929,7 +1929,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-svg"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-path",
|
||||
@@ -1941,7 +1941,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-text"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"effectcraft-keyframe",
|
||||
"harfrust",
|
||||
@@ -1953,7 +1953,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-time"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1961,7 +1961,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-track"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"effectcraft-raster",
|
||||
"effectcraft-segment",
|
||||
@@ -1973,7 +1973,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-ui-egui"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
@@ -2014,11 +2014,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-vp9enc"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-web"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
@@ -7835,7 +7835,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"pkg-config",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,8 +99,12 @@ pub struct Loss {
|
||||
pub category: LossCategory,
|
||||
/// How many occurrences (exact).
|
||||
pub count: usize,
|
||||
/// Up to [`MAX_DETAILS`] example lines.
|
||||
/// Example lines: up to [`MAX_DETAILS`], and for effects with no counterpart one more line per
|
||||
/// effect (with its number of instances).
|
||||
pub details: Vec<String>,
|
||||
/// How many of the `count` occurrences no line of `details` describes.
|
||||
#[serde(default)]
|
||||
pub unlisted: usize,
|
||||
}
|
||||
|
||||
/// What arrived in the project.
|
||||
@@ -282,7 +286,7 @@ const MAX_UNMAPPED_EFFECTS: usize = 100;
|
||||
impl Collector {
|
||||
/// Records one loss.
|
||||
pub fn lose(&mut self, category: LossCategory, detail: impl Into<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());
|
||||
@@ -293,7 +297,8 @@ impl Collector {
|
||||
/// made in [`Collector::finish`], one per effect with the number of instances, so that many
|
||||
/// other losses cannot hide which effects were dropped.
|
||||
pub fn lose_unmapped_effect(&mut self, match_name: &str, detail: String) {
|
||||
let loss = self.losses.entry(LossCategory::Effects).or_insert_with(|| Loss { category: LossCategory::Effects, count: 0, details: Vec::new() });
|
||||
let loss =
|
||||
self.losses.entry(LossCategory::Effects).or_insert_with(|| Loss { category: LossCategory::Effects, count: 0, details: Vec::new(), unlisted: 0 });
|
||||
loss.count = loss.count.saturating_add(1);
|
||||
if let Some(seen) = self.unmapped_effects.get_mut(match_name) {
|
||||
seen.1 = seen.1.saturating_add(1);
|
||||
@@ -304,12 +309,22 @@ impl Collector {
|
||||
}
|
||||
|
||||
pub fn finish(mut self) -> ImportReport {
|
||||
// Every line describes one occurrence, except an unmapped effect's, which describes all
|
||||
// its instances.
|
||||
for loss in self.losses.values_mut() {
|
||||
loss.unlisted = loss.count.saturating_sub(loss.details.len());
|
||||
}
|
||||
if let Some(loss) = self.losses.get_mut(&LossCategory::Effects) {
|
||||
let named = std::mem::take(&mut self.unmapped_effects).into_values().map(|(first, n)| match n {
|
||||
// The most used effects first.
|
||||
let mut named: Vec<(String, usize)> = std::mem::take(&mut self.unmapped_effects).into_values().collect();
|
||||
named.sort_by_key(|n| std::cmp::Reverse(n.1));
|
||||
let described = named.iter().fold(0usize, |sum, (_, n)| sum.saturating_add(*n));
|
||||
loss.unlisted = loss.unlisted.saturating_sub(described);
|
||||
let lines = named.into_iter().map(|(first, n)| match n {
|
||||
1 => first,
|
||||
_ => format!("{first}, and {} more like it", n - 1),
|
||||
});
|
||||
loss.details.splice(0..0, named);
|
||||
loss.details.splice(0..0, lines);
|
||||
}
|
||||
ImportReport {
|
||||
imported: self.imported,
|
||||
@@ -356,6 +371,25 @@ mod tests {
|
||||
// One instance stays the single line it was.
|
||||
assert_eq!(named("(Pseudo/)"), ["C: 'D' has the effect 'Dropdown' (Pseudo/)"]);
|
||||
assert!(effects.details.len() <= MAX_DETAILS + 2, "the examples stay bounded");
|
||||
// 20 parameter lines, the cylinders' line and the dropdown's describe 46; 10 parameters
|
||||
// have no line.
|
||||
assert_eq!(effects.unlisted, 10);
|
||||
// The most used effect comes first.
|
||||
assert!(effects.details[0].contains("(CC Cylinder)"), "{:?}", effects.details);
|
||||
}
|
||||
|
||||
/// #3365: each category says how many of its occurrences have no example line.
|
||||
#[test]
|
||||
fn a_loss_counts_the_occurrences_without_a_line() {
|
||||
let mut col = Collector::default();
|
||||
for i in 0..27 {
|
||||
col.lose(LossCategory::Masks, format!("mask {i}"));
|
||||
}
|
||||
col.lose(LossCategory::ShapeLayers, "one");
|
||||
let report = col.finish();
|
||||
let unlisted = |c| report.losses.iter().find(|l| l.category == c).map(|l| (l.details.len(), l.unlisted));
|
||||
assert_eq!(unlisted(LossCategory::Masks), Some((MAX_DETAILS, 7)));
|
||||
assert_eq!(unlisted(LossCategory::ShapeLayers), Some((1, 0)));
|
||||
}
|
||||
|
||||
/// A file with more than [`MAX_UNMAPPED_EFFECTS`] different unmapped effects names that many
|
||||
@@ -370,5 +404,6 @@ mod tests {
|
||||
let effects = report.losses.iter().find(|l| l.category == LossCategory::Effects).unwrap();
|
||||
assert_eq!(effects.count, MAX_UNMAPPED_EFFECTS + 1, "every instance is counted");
|
||||
assert_eq!(effects.details.len(), MAX_UNMAPPED_EFFECTS, "{} lines", effects.details.len());
|
||||
assert_eq!(effects.unlisted, 1, "the effect past the limit has no line");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -908,6 +908,48 @@ fn a_photoshop_layer_without_pixels_keeps_the_merged_image() {
|
||||
assert_eq!(the_footage(&p).layer, None);
|
||||
}
|
||||
|
||||
/// #3087: an expression on Orientation arrives with the stored value, as on any property; it was
|
||||
/// dropped together with the value and reported as "not decoded".
|
||||
#[test]
|
||||
fn an_orientation_expression_is_imported_with_its_value() {
|
||||
let bytes = mutate(&mut |c| {
|
||||
if !c.is_list(b"otst") {
|
||||
return;
|
||||
}
|
||||
for child in c.children_mut() {
|
||||
if child.is_list(b"tdbs") {
|
||||
let Body::List { children, .. } = &mut child.body else { continue };
|
||||
for k in children.iter_mut() {
|
||||
if k.is(b"tdsb") {
|
||||
set_data(k, 0, &1u32.to_be_bytes());
|
||||
} else if k.is(b"tdb4") {
|
||||
set_data(k, 120, &[1]);
|
||||
}
|
||||
}
|
||||
children.push(Chunk::data(*b"Utf8", b"[0, time * 90, 0]".to_vec()));
|
||||
} else if child.is_list(b"otky") {
|
||||
for k in child.children_mut() {
|
||||
let degrees: Vec<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.
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -245,6 +245,8 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Import Adobe After Effects Project", "Adobe After Effectsプロジェクトを読み込み"),
|
||||
("Some parts of this project cannot be imported exactly (left out or changed):", "このプロジェクトの一部は正確に読み込めません(省略または変更されます):"),
|
||||
("Import Anyway", "このまま読み込み"),
|
||||
("{}: {} left out or changed", "{}:{} 件が省略または変更"),
|
||||
("… and {} more", "…ほか {} 件"),
|
||||
("Casts Shadows", "シャドウを落とす"),
|
||||
("Change Renderer…", "レンダラーを変更…"),
|
||||
(
|
||||
@@ -1386,6 +1388,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("Import Adobe After Effects Project", "导入 Adobe After Effects 项目"),
|
||||
("Some parts of this project cannot be imported exactly (left out or changed):", "此项目的某些部分无法准确导入(将被省略或更改):"),
|
||||
("Import Anyway", "仍然导入"),
|
||||
("{}: {} left out or changed", "{}:{} 项被省略或更改"),
|
||||
("… and {} more", "…以及另外 {} 项"),
|
||||
("Casts Shadows", "投射阴影"),
|
||||
("Change Renderer…", "更改渲染器…"),
|
||||
("Changing a shortcut creates a custom preset; the default stays as it is.", "修改快捷键会创建自定义预设;默认预设保持不变。"),
|
||||
@@ -2505,6 +2509,8 @@ const SWEDISH: &[(&str, &str)] = &[
|
||||
"Vissa delar av projektet kan inte importeras exakt (utelämnas eller ändras):",
|
||||
),
|
||||
("Import Anyway", "Importera ändå"),
|
||||
("{}: {} left out or changed", "{}: {} utelämnas eller ändras"),
|
||||
("… and {} more", "… och {} till"),
|
||||
("Casts Shadows", "Kastar skuggor"),
|
||||
("Change Renderer…", "Byt renderare…"),
|
||||
(
|
||||
@@ -3778,6 +3784,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("Import Adobe After Effects Project", "匯入 Adobe After Effects 專案"),
|
||||
("Some parts of this project cannot be imported exactly (left out or changed):", "此專案的部分內容無法準確匯入(將被省略或變更):"),
|
||||
("Import Anyway", "仍要匯入"),
|
||||
("{}: {} left out or changed", "{}:{} 項被省略或變更"),
|
||||
("… and {} more", "…以及另外 {} 項"),
|
||||
("Casts Shadows", "投射陰影"),
|
||||
("Change Renderer…", "更改算圖引擎…"),
|
||||
("Changing a shortcut creates a custom preset; the default stays as it is.", "修改快速鍵會創建自定義預設集;預設預設集保持不變。"),
|
||||
@@ -4847,6 +4855,8 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
"Деякі частини цього проєкту неможливо імпортувати точно (їх пропущено або змінено):",
|
||||
),
|
||||
("Import Anyway", "Усе одно імпортувати"),
|
||||
("{}: {} left out or changed", "{}: {} пропущено або змінено"),
|
||||
("… and {} more", "… і ще {}"),
|
||||
("Casts Shadows", "Відкидає тіні"),
|
||||
("Change Renderer…", "Змінити рушій рендерингу…"),
|
||||
(
|
||||
@@ -5428,6 +5438,8 @@ const PORTUGUESE: &[(&str, &str)] = &[
|
||||
"Algumas partes deste projeto não podem ser importadas exatamente (omitidas ou alteradas):",
|
||||
),
|
||||
("Import Anyway", "Importar mesmo assim"),
|
||||
("{}: {} left out or changed", "{}: {} omitidos ou alterados"),
|
||||
("… and {} more", "… e mais {}"),
|
||||
("Casts Shadows", "Projeta sombras"),
|
||||
("Change Renderer…", "Alterar renderizador…"),
|
||||
(
|
||||
@@ -6201,6 +6213,8 @@ const POLISH: &[(&str, &str)] = &[
|
||||
"Niektórych części tego projektu nie można zaimportować dokładnie (zostaną pominięte lub zmienione):",
|
||||
),
|
||||
("Import Anyway", "Importuj mimo to"),
|
||||
("{}: {} left out or changed", "{}: {} pominięto lub zmieniono"),
|
||||
("… and {} more", "… i {} więcej"),
|
||||
("Casts Shadows", "Rzuca cienie"),
|
||||
("Change Renderer…", "Zmień renderer…"),
|
||||
(
|
||||
@@ -6957,6 +6971,8 @@ const CZECH: &[(&str, &str)] = &[
|
||||
"Některé části tohoto projektu nelze importovat přesně (budou vynechány nebo změněny):",
|
||||
),
|
||||
("Import Anyway", "Přesto importovat"),
|
||||
("{}: {} left out or changed", "{}: {} vynecháno nebo změněno"),
|
||||
("… and {} more", "… a {} dalších"),
|
||||
("Casts Shadows", "Vrhat stíny"),
|
||||
("Change Renderer…", "Změnit vykreslovací modul…"),
|
||||
(
|
||||
@@ -7532,6 +7548,8 @@ const SPANISH: &[(&str, &str)] = &[
|
||||
"Algunas partes de este proyecto no se pueden importar con exactitud (se omiten o cambian):",
|
||||
),
|
||||
("Import Anyway", "Importar de todos modos"),
|
||||
("{}: {} left out or changed", "{}: {} omitidos o cambiados"),
|
||||
("… and {} more", "… y {} más"),
|
||||
("Casts Shadows", "Proyecta sombras"),
|
||||
("Change Renderer…", "Cambiar motor de procesamiento…"),
|
||||
(
|
||||
@@ -8098,6 +8116,8 @@ const GEORGIAN: &[(&str, &str)] = &[
|
||||
"ამ პროექტის ზოგიერთი ნაწილის ზუსტად იმპორტი შეუძლებელია (გამოტოვებული ან შეცვლილი):",
|
||||
),
|
||||
("Import Anyway", "მაინც იმპორტი"),
|
||||
("{}: {} left out or changed", "{}: {} გამოტოვებული ან შეცვლილი"),
|
||||
("… and {} more", "… და კიდევ {}"),
|
||||
("Casts Shadows", "ჩრდილს აგდებს"),
|
||||
("Change Renderer…", "რენდერერის შეცვლა…"),
|
||||
("Changing a shortcut creates a custom preset; the default stays as it is.", "მალსახმობის შეცვლა ქმნის მორგებულ პრესეტს; ნაგულისხმევი უცვლელი რჩება."),
|
||||
@@ -9070,6 +9090,8 @@ const FRENCH: &[(&str, &str)] = &[
|
||||
"Certaines parties de ce projet ne peuvent pas être importées à l’identique (omises ou modifiées) :",
|
||||
),
|
||||
("Import Anyway", "Importer quand même"),
|
||||
("{}: {} left out or changed", "{} : {} omis ou modifiés"),
|
||||
("… and {} more", "… et {} de plus"),
|
||||
("Casts Shadows", "Projette des ombres"),
|
||||
("Change Renderer…", "Changer de moteur de rendu…"),
|
||||
(
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//!
|
||||
//! Automation ids: `dialog.aepLosses.continue`, `dialog.aepLosses.cancel`.
|
||||
|
||||
use crate::i18n::tr;
|
||||
use crate::i18n::{tr, tr_args};
|
||||
use egui::vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -23,9 +23,6 @@ pub struct Pending {
|
||||
pub queue: Vec<String>,
|
||||
}
|
||||
|
||||
/// How many detail lines of a loss category the prompt shows.
|
||||
const DETAILS: usize = 3;
|
||||
|
||||
/// Whether the prompt is showing with a conversion waiting on it.
|
||||
pub fn asking(app: &EffectcraftApp) -> bool {
|
||||
app.dialog == Some(Dialog::AepLosses) && app.dialog_state.aep_losses.command.is_some()
|
||||
@@ -77,14 +74,24 @@ fn category(c: &str) -> String {
|
||||
chars.next().map(|f| f.to_uppercase().chain(chars).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The report's losses as lines: one per category with its count, and a few of its details.
|
||||
/// The report's losses as lines: one per category with how many occurrences are left out or
|
||||
/// changed, then every detail line the report keeps and "… and N more" for the occurrences none
|
||||
/// describes (#3365).
|
||||
pub fn lines(report: &Value) -> Vec<(String, Vec<String>)> {
|
||||
let Some(losses) = report["losses"].as_array() else { return vec![] };
|
||||
losses
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let head = format!("{} ({})", category(l["category"].as_str().unwrap_or("other")), l["count"].as_u64().unwrap_or(0));
|
||||
let details = l["details"].as_array().map(|d| d.iter().filter_map(Value::as_str).take(DETAILS).map(str::to_string).collect()).unwrap_or_default();
|
||||
let count = l["count"].as_u64().unwrap_or(0);
|
||||
let head = tr_args("{}: {} left out or changed", &[&category(l["category"].as_str().unwrap_or("other")), &count]);
|
||||
let mut details: Vec<String> =
|
||||
l["details"].as_array().map(|d| d.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
|
||||
// An export report has no `unlisted`: each of its lines is one occurrence.
|
||||
let listed = u64::try_from(details.len()).unwrap_or(u64::MAX);
|
||||
let unlisted = l["unlisted"].as_u64().unwrap_or_else(|| count.saturating_sub(listed));
|
||||
if unlisted > 0 {
|
||||
details.push(tr_args("… and {} more", &[&unlisted]));
|
||||
}
|
||||
(head, details)
|
||||
})
|
||||
.collect()
|
||||
@@ -179,11 +186,25 @@ mod tests {
|
||||
fn the_report_reads_as_categories_with_counts_and_details() {
|
||||
let report = json!({"losses": [{"category": "layer_styles", "count": 4, "details": ["a", "b", "c", "d"]}, {"category": "effects", "count": 1}]});
|
||||
let l = lines(&report);
|
||||
assert_eq!(l[0], ("Layer styles (4)".to_string(), vec!["a".to_string(), "b".into(), "c".into()]));
|
||||
assert_eq!(l[1], ("Effects (1)".to_string(), vec![]));
|
||||
assert_eq!(l[0], ("Layer styles: 4 left out or changed".to_string(), vec!["a".to_string(), "b".into(), "c".into(), "d".into()]));
|
||||
assert_eq!(l[1], ("Effects: 1 left out or changed".to_string(), vec!["… and 1 more".to_string()]));
|
||||
assert!(lines(&json!({})).is_empty());
|
||||
}
|
||||
|
||||
/// #3365: every detail line shows, then how many occurrences have none: the report's
|
||||
/// `unlisted` (an effect line can describe many instances), else the lines past the count.
|
||||
#[test]
|
||||
fn every_detail_shows_and_the_rest_is_counted() {
|
||||
let details: Vec<String> = (0..20).map(|i| format!("d{i}")).collect();
|
||||
let l = lines(&json!({"losses": [{"category": "masks", "count": 27, "details": details}]}));
|
||||
assert_eq!(l[0].1.len(), 21);
|
||||
assert_eq!(l[0].1[20], "… and 7 more");
|
||||
let l = lines(&json!({"losses": [{"category": "effects", "count": 50, "details": ["X, and 44 more like it"], "unlisted": 5}]}));
|
||||
assert_eq!(l[0].1, ["X, and 44 more like it", "… and 5 more"]);
|
||||
let l = lines(&json!({"losses": [{"category": "effects", "count": 45, "details": ["X, and 44 more like it"], "unlisted": 0}]}));
|
||||
assert_eq!(l[0].1, ["X, and 44 more like it"]);
|
||||
}
|
||||
|
||||
/// Projects dropped or picked together wait only behind a prompt that shows; with none
|
||||
/// showing they are tried at once (a missing file is an error naming it).
|
||||
#[test]
|
||||
|
||||
+10
-6
File diff suppressed because one or more lines are too long
+7
-4
@@ -100,10 +100,13 @@ Commands (CLI, MCP, control channel):
|
||||
one; `EFFECTCRAFT_GPU_MEMCAP_MB` sets it), keeping room for the upload cache to fill. It
|
||||
renders one frame at a time where the memory can't be measured (Metal, WebGPU, GL, or the
|
||||
cap turned off) and once frames have had to wait for memory. The render log names the count.
|
||||
`EFFECTCRAFT_GPU_INFLIGHT` overrides it. CPU renders have no such setting: they keep one
|
||||
frame per thread in flight, so their memory grows with the core count. Set
|
||||
`EFFECTCRAFT_CPU_INFLIGHT` to a whole number of frames (at least 1) to cap the frames in
|
||||
flight of every Render Queue render and export, `effectcraft-cli render` included
|
||||
`EFFECTCRAFT_GPU_INFLIGHT` overrides it. CPU renders have no such setting. A job on GPU
|
||||
Acceleration that renders on the CPU (it measured the CPU faster, or the GPU stopped) fits
|
||||
its CPU frames in flight into half the memory the system has free, at the size a GPU frame
|
||||
measured, from 1 to one per thread; the app log names the count. Other CPU renders (Software
|
||||
Rendering projects) keep one frame per thread in flight, so their memory grows with the core
|
||||
count. Set `EFFECTCRAFT_CPU_INFLIGHT` to a whole number of frames (at least 1) to cap the
|
||||
frames in flight of every Render Queue render and export, `effectcraft-cli render` included
|
||||
([#1615](https://github.com/storytold/effectcraft/issues/1615)); previews don't read it. One
|
||||
exception: while a job on GPU Acceleration times both compositors on its first frames, it
|
||||
renders them under the cap but keeps them all (up to 12) until it has chosen. That timing
|
||||
|
||||
Reference in New Issue
Block a user