mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-15 06:42:31 +00:00
fix: preserve legacy CC particle compositing (#1898)
Before v0.10.0 the CC particle systems composited their particles over the layer, so a particle layer still showed the solid or precomp under it and any earlier effect in the stack. v0.10.0 (4e7eafa8) made them replace the layer to match After Effects 2026, which hides that artwork in projects written before it (#1898). Both effects get a Composite With Original checkbox, off by default so new instances keep the After-Effects-matching behaviour and the frames the effects are matched against stay green. upgrade_effects now takes the savedBy version of the file a project came from - opened, imported, read from an Essential Graphics template, or loaded as the New Project template - and turns the control on for an instance from a project written before v0.10.0 that does not already carry it (a saved value is the artist's choice). The legacy plan is the pre-v0.10.0 code restored: Post::Combine with the old Transfer Mode mapping (Composite, Screen and Add themselves, Black Matte 5) and the same Add/Over accumulation. savedBy first shipped in M3.14 (948f06fb), released after v0.2.0, so files from v0.2.1 on name their writer and get the fix; an older file has no savedBy and keeps replacing rather than migrating on a guess. Tests: crates/effects/src/sim/tests.rs composite_with_original_restores_the_old_particle_transfer_modes checks the restored mapping against combine() in every Transfer Mode; crates/engine/src/tests/legacy_particles.rs opens a 0.6.0 project (both particle effects over a Fill on a solid) and checks the artwork and the earlier effect show, that a current or anonymous file still replaces, the version gate, and the New Project template path. Fixes #1898.
This commit is contained in:
1 parent
dc2380b070
commit
69d641e30a
10 files changed
+287
-25
No files matched your search
@@ -228,15 +228,28 @@ pub(super) fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan
|
||||
.collect();
|
||||
list.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
let sprites: Vec<Sprite> = list.into_iter().map(|(_, s)| s).collect();
|
||||
Some(particle_sprites(sprites, pr.e("particle/transferMode")))
|
||||
Some(particle_sprites(sprites, pr.e("particle/transferMode"), pr.b("compositeWithOriginal")))
|
||||
}
|
||||
|
||||
/// The CC particle systems' sprites with their Transfer Mode (Composite, Screen, Add, Black
|
||||
/// Background).
|
||||
fn particle_sprites(sprites: Vec<Sprite>, mode: u32) -> SpritePlan {
|
||||
// The particles replace the layer (After Effects shows no trace of the source in any
|
||||
// Transfer Mode, which sets how the particles meet each other).
|
||||
SpritePlan { sprites, tints: vec![], acc: if mode == 2 { Acc::Add } else { Acc::Over }, post: Post::Replace }
|
||||
/// Background) and whether they composite over the layer.
|
||||
fn particle_sprites(sprites: Vec<Sprite>, mode: u32, with_original: bool) -> SpritePlan {
|
||||
// A new instance replaces the layer: After Effects 2026 shows no trace of the source in any
|
||||
// Transfer Mode, which sets how the particles meet each other. A project written before
|
||||
// v0.10.0 composited them over it instead, and gets that behaviour back when it is upgraded
|
||||
// (#1898, `compositeWithOriginal`).
|
||||
let post = if with_original { Post::Combine(transfer_combine(mode)) } else { Post::Replace };
|
||||
SpritePlan { sprites, tints: vec![], acc: if mode == 2 { Acc::Add } else { Acc::Over }, post }
|
||||
}
|
||||
|
||||
/// A Transfer Mode as the `combine` mode the particles meet the layer by, as before v0.10.0:
|
||||
/// Composite, Screen and Add are themselves, Black Matte draws the particles alone.
|
||||
fn transfer_combine(mode: u32) -> u32 {
|
||||
match mode {
|
||||
0..=2 => mode,
|
||||
3 => 5,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CC Particle Systems II
|
||||
@@ -299,7 +312,7 @@ pub(super) fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
|
||||
type_sprite(kind, bx as f32, by as f32, r, c, [q.v[0] * unit * s, q.v[1] * unit * s], (q.id % 628) as f32 * 0.01 + q.age * 2.0)
|
||||
})
|
||||
.collect();
|
||||
particle_sprites(sprites, pr.e("particle/transferMode"))
|
||||
particle_sprites(sprites, pr.e("particle/transferMode"), pr.b("compositeWithOriginal"))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CC Mr. Mercury
|
||||
|
||||
@@ -159,6 +159,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
p("particle/birthColor", "Birth Color", col(1.0, 1.0, 0.0), ParamUi::Color),
|
||||
p("particle/deathColor", "Death Color", col(0.6, 0.0, 0.0), ParamUi::Color),
|
||||
p("particle/transferMode", "Transfer Mode", Value::Enum(0), popup(&["Composite", "Screen", "Add", "Black Matte"])),
|
||||
p("compositeWithOriginal", "Composite With Original", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("extras/effectCamera/distance", "Distance", num(2.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
|
||||
p("extras/effectCamera/rotationX", "Rotation X", num(0.0), ParamUi::Angle),
|
||||
p("extras/effectCamera/rotationY", "Rotation Y", num(0.0), ParamUi::Angle),
|
||||
@@ -192,6 +193,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
p("particle/birthColor", "Birth Color", col(1.0, 1.0, 0.0), ParamUi::Color),
|
||||
p("particle/deathColor", "Death Color", col(0.6, 0.0, 0.0), ParamUi::Color),
|
||||
p("particle/transferMode", "Transfer Mode", Value::Enum(0), popup(&["Composite", "Screen", "Add", "Black Matte"])),
|
||||
p("compositeWithOriginal", "Composite With Original", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)),
|
||||
],
|
||||
particle_systems2,
|
||||
|
||||
@@ -536,6 +536,25 @@ fn cc_particles_replace_their_layer() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1898: a project written before v0.10.0 keeps the particles composited over the layer, as the
|
||||
/// Transfer Mode did then, so the artwork and any earlier effect stay visible.
|
||||
#[test]
|
||||
fn composite_with_original_restores_the_old_particle_transfer_modes() {
|
||||
for id in ["ec.sim.ccparticlesystems2", "ec.sim.ccparticleworld"] {
|
||||
for (mode, combine_mode) in [(0, 0), (1, 1), (2, 2), (3, 5)] {
|
||||
let base = ramp(48, 48);
|
||||
let v = [("birthRate", num(3.0)), ("particle/transferMode", Value::Enum(mode))];
|
||||
let particles = run(id, &v, base.clone(), 1.0);
|
||||
let legacy = [("birthRate", num(3.0)), ("particle/transferMode", Value::Enum(mode)), ("compositeWithOriginal", Value::Bool(true))];
|
||||
assert_eq!(run(id, &legacy, base.clone(), 1.0), super::sprites::combine(&base, &particles, combine_mode), "{id}, transfer mode {mode}");
|
||||
}
|
||||
// With nothing born the layer shows as it was, where a new instance's is replaced.
|
||||
let base = grey(20, 20);
|
||||
let none = [("birthRate", num(0.0)), ("compositeWithOriginal", Value::Bool(true))];
|
||||
assert_eq!(run(id, &none, base.clone(), 1.0), base, "{id}: the source stays visible");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mercury_blobs_and_seek() {
|
||||
let v = vec![("producer", pt(24.0, 16.0)), ("radiusX", num(5.0)), ("radiusY", num(5.0)), ("birthRate", num(3.0))];
|
||||
|
||||
@@ -888,7 +888,7 @@ pub fn read_template(bytes: &[u8]) -> std::result::Result<(Value, Project, Vec<(
|
||||
}
|
||||
let text = String::from_utf8(get("project.ecproj").ok_or("template has no project.ecproj")?).map_err(|e| e.to_string())?;
|
||||
let mut project = Project::from_json(&text).map_err(|e| e.to_string())?;
|
||||
crate::upgrade_effects(&mut project);
|
||||
crate::upgrade_effects(&mut project, effectcraft_project::saved_by(&text).as_deref());
|
||||
Ok((manifest, project, entries))
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ fn new_project(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("the new project template is not a project file".into()))?;
|
||||
let mut proj = Project::from_json(&text)?;
|
||||
crate::doc_path::resolve_footage(&mut proj, Some(&tpl));
|
||||
s.replace_project(proj, None);
|
||||
// A template is a project file: one written before v0.10.0 needs the same upgrades (#1898).
|
||||
s.replace_project_written_by(proj, None, effectcraft_project::saved_by(&text).as_deref());
|
||||
s.stored_paths.add(&s.project, Some(&tpl));
|
||||
s.update_sentinel();
|
||||
return Ok(json!({"template": tpl}));
|
||||
@@ -52,7 +53,9 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// written by hand) is found next to the project file, as After Effects looks for footage
|
||||
// relative to the project (#652).
|
||||
let refused = crate::doc_path::resolve_footage(&mut proj, Some(path));
|
||||
s.replace_project(proj, Some(path.to_string()));
|
||||
// The version that wrote the file decides some of the upgrades (see `upgrade_effects`).
|
||||
let saved_by = effectcraft_project::saved_by(&text);
|
||||
s.replace_project_written_by(proj, Some(path.to_string()), saved_by.as_deref());
|
||||
s.stored_paths.add(&s.project, Some(path));
|
||||
s.restore_view(&effectcraft_project::saved_view(&text));
|
||||
s.note_project_path(path);
|
||||
@@ -68,7 +71,6 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.toast(format!("{n} footage item{} marked missing: {}.", if n == 1 { "" } else { "s" }, crate::doc_path::refusal(first, *why)));
|
||||
}
|
||||
// Written by a newer EffectCraft: what this version doesn't know would be lost on saving.
|
||||
let saved_by = effectcraft_project::saved_by(&text);
|
||||
if let Some(v) = saved_by.as_deref().filter(|v| effectcraft_project::is_newer_version(v, effectcraft_project::APP_VERSION)) {
|
||||
s.toast(format!(
|
||||
"This project was saved by EffectCraft {v}, newer than this version ({}). Settings this version doesn't know are lost if you save it.",
|
||||
|
||||
@@ -61,7 +61,7 @@ fn root_comps(project: &Project) -> Vec<ItemId> {
|
||||
fn import_project(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad(C, "missing `path` (.ecproj)"))?.to_string();
|
||||
let (mut sub, saved_by) = read_project(s, &path)?;
|
||||
crate::upgrade_effects(&mut sub);
|
||||
crate::upgrade_effects(&mut sub, saved_by.as_deref());
|
||||
let file = std::path::Path::new(&path);
|
||||
let missing = resolve_footage(s, &mut sub, &path);
|
||||
s.stored_paths.add(&sub, Some(&path));
|
||||
|
||||
@@ -195,7 +195,14 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Replace the whole project (open/new), resetting history and state.
|
||||
pub fn replace_project(&mut self, mut p: Project, path: Option<String>) {
|
||||
pub fn replace_project(&mut self, p: Project, path: Option<String>) {
|
||||
self.replace_project_written_by(p, path, None);
|
||||
}
|
||||
|
||||
/// [`Session::replace_project`] for a project read from a file: `written_by` is the version
|
||||
/// the file names (`savedBy`), for the upgrades that depend on which version wrote it
|
||||
/// ([`upgrade_effects`]).
|
||||
pub fn replace_project_written_by(&mut self, mut p: Project, path: Option<String>, written_by: Option<&str>) {
|
||||
if self.stop_render()
|
||||
&& let Some(mut job) = self.render_job.take()
|
||||
{
|
||||
@@ -220,7 +227,7 @@ impl Session {
|
||||
self.drop_tasks();
|
||||
self.script_ui.project_epoch = self.script_ui.project_epoch.wrapping_add(1);
|
||||
p.fix_next_id();
|
||||
upgrade_effects(&mut p);
|
||||
upgrade_effects(&mut p, written_by);
|
||||
// The media layer keys decoded frames by path: files of the new project may have changed
|
||||
// since an earlier project decoded them (#1070).
|
||||
for it in p.items.values() {
|
||||
@@ -251,8 +258,15 @@ impl Session {
|
||||
/// ([`effectcraft_effects::migrate::to_effect_space`]). Shape path operations get the
|
||||
/// properties added since ([`effectcraft_project::build::upgrade_shape_contents`]). Stamps the
|
||||
/// current schema.
|
||||
pub fn upgrade_effects(p: &mut Project) {
|
||||
///
|
||||
/// `written_by` is the `savedBy` version of the file the project came from, when it came from one
|
||||
/// ([`Session::replace_project_written_by`]); the upgrade that depends on the version that wrote it
|
||||
/// (`compositeWithOriginal`) only runs on evidence of an old project.
|
||||
pub fn upgrade_effects(p: &mut Project, written_by: Option<&str>) {
|
||||
let to_effect_space = p.schema < effectcraft_project::SCHEMA_EFFECT_SPACE;
|
||||
// v0.10.0 made the CC particle systems replace their layer; projects written before it
|
||||
// composited the particles over the layer, and must keep doing so (#1898).
|
||||
let legacy_particles = written_before(written_by, "0.10.0");
|
||||
// Effect bounds per layer: (size, top-left in layer coordinates).
|
||||
let mut bounds = std::collections::HashMap::new();
|
||||
for (cid, c) in p.comps() {
|
||||
@@ -279,16 +293,58 @@ pub fn upgrade_effects(p: &mut Project) {
|
||||
let Some(fx) = l.props.sub_mut("effects") else { continue };
|
||||
for n in &mut fx.children {
|
||||
let effectcraft_project::Node::Group(g) = n else { continue };
|
||||
let effectcraft_project::GroupKind::Effect { effect } = &g.kind else { continue };
|
||||
if let Some(spec) = effectcraft_effects::find(effect) {
|
||||
effectcraft_effects::migrate::upgrade_instance(spec, g, &mut effectcraft_project::build::Ids(&mut next), size);
|
||||
if to_effect_space {
|
||||
effectcraft_effects::migrate::to_effect_space(spec, g, size, d, &clone_d);
|
||||
}
|
||||
}
|
||||
upgrade_effect(g, legacy_particles, to_effect_space, size, d, &clone_d, &mut next);
|
||||
}
|
||||
}
|
||||
}
|
||||
p.next_id = next;
|
||||
p.schema = effectcraft_project::SCHEMA_VERSION;
|
||||
}
|
||||
|
||||
/// The CC particle systems, which v0.10.0 made replace their layer instead of compositing the
|
||||
/// particles over it (#1898).
|
||||
const LEGACY_COMPOSITE_EFFECTS: [&str; 2] = ["ec.sim.ccparticlesystems2", "ec.sim.ccparticleworld"];
|
||||
|
||||
/// The control that gives a project from before v0.10.0 the particles' old compositing.
|
||||
const COMPOSITE_WITH_ORIGINAL: &str = "compositeWithOriginal";
|
||||
|
||||
/// One effect instance of a layer: refresh it from the registry, give a project from before
|
||||
/// v0.10.0 the particle systems' old compositing, and move it into effect space.
|
||||
fn upgrade_effect(
|
||||
g: &mut effectcraft_project::PropGroup,
|
||||
legacy_particles: bool,
|
||||
to_effect_space: bool,
|
||||
size: [f64; 2],
|
||||
d: [f64; 2],
|
||||
clone_d: &dyn Fn(Option<u64>) -> [f64; 2],
|
||||
next: &mut u64,
|
||||
) {
|
||||
let effectcraft_project::GroupKind::Effect { effect } = &g.kind else { return };
|
||||
let Some(spec) = effectcraft_effects::find(effect) else { return };
|
||||
// A property the instance already has is the artist's own choice; only an instance old enough
|
||||
// to predate it keeps the legacy behaviour.
|
||||
let composite = legacy_particles && LEGACY_COMPOSITE_EFFECTS.contains(&spec.id) && g.get(COMPOSITE_WITH_ORIGINAL).is_none();
|
||||
effectcraft_effects::migrate::upgrade_instance(spec, g, &mut effectcraft_project::build::Ids(next), size);
|
||||
if composite && let Some(pr) = g.get_mut(COMPOSITE_WITH_ORIGINAL) {
|
||||
pr.value = effectcraft_project::Value::Bool(true);
|
||||
}
|
||||
if to_effect_space {
|
||||
effectcraft_effects::migrate::to_effect_space(spec, g, size, d, clone_d);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a file whose `savedBy` is `saved_by` predates `version` (`major.minor.patch`). A file
|
||||
/// that doesn't name a plain three-part writer version is no evidence of an old project, so
|
||||
/// nothing is migrated on a guess.
|
||||
fn written_before(saved_by: Option<&str>, version: &str) -> bool {
|
||||
let (Some(wrote), Some(than)) = (saved_by.and_then(plain_version), plain_version(version)) else { return false };
|
||||
wrote < than
|
||||
}
|
||||
|
||||
/// `major.minor.patch` as numbers, ignoring a pre-release or build suffix; `None` unless the
|
||||
/// version is exactly those three parts (`"0.6"` and `"unknown"` are none).
|
||||
fn plain_version(v: &str) -> Option<[u64; 3]> {
|
||||
let mut parts = v.split(['-', '+']).next().unwrap_or("").split('.');
|
||||
let n = [parts.next()?.parse().ok()?, parts.next()?.parse().ok()?, parts.next()?.parse().ok()?];
|
||||
parts.next().is_none().then_some(n)
|
||||
}
|
||||
@@ -31,6 +31,7 @@ mod key_time;
|
||||
mod keyframe_text;
|
||||
mod keylight;
|
||||
mod legacy_analysis_keys;
|
||||
mod legacy_particles;
|
||||
mod limits;
|
||||
mod lottie;
|
||||
mod lut_files;
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
//! #1898: CC Particle Systems II and CC Particle World replace their layer since v0.10.0, which
|
||||
//! is what After Effects does. A project written before v0.10.0 composited the particles over the
|
||||
//! layer instead, and opening it must not hide its artwork or an earlier effect in the stack.
|
||||
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::{ItemId, ItemKind, LayerId, Node, Project, PropGroup};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::render::RenderOpts;
|
||||
|
||||
/// The two effects v0.10.0 made replace the layer.
|
||||
const PARTICLES: [&str; 2] = ["ec.sim.ccparticlesystems2", "ec.sim.ccparticleworld"];
|
||||
|
||||
/// The control that gives an older project the old compositing back.
|
||||
const COMPOSITE: &str = "compositeWithOriginal";
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("ec-1898-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.join(name)
|
||||
}
|
||||
|
||||
fn effect_group(s: &Session, lid: u64, uid: u64) -> PropGroup {
|
||||
s.active_comp().unwrap().layer(LayerId(lid)).unwrap().effects().unwrap().groups().find(|g| g.uid == uid).unwrap().clone()
|
||||
}
|
||||
|
||||
/// A 64×48 comp: a red solid with a Fill (green) below both particle effects, with no particles
|
||||
/// born, so only the artwork, the effect earlier in the stack and the compositing show.
|
||||
fn particle_comp() -> (Session, ItemId, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 48, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let lid = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 64, "height": 48})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("effect.apply", json!({"layers": [lid], "effect": "ec.generate.fill"})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": lid, "path": "effects/#1/color", "value": [0.0, 1.0, 0.0, 1.0]})).unwrap();
|
||||
for effect in PARTICLES {
|
||||
let uid = s.execute("effect.apply", json!({"layers": [lid], "effect": effect})).unwrap()["effects"][0].as_u64().unwrap();
|
||||
let rate = effect_group(&s, lid, uid).prop("birthRate").unwrap().uid;
|
||||
s.execute("prop.set", json!({"layer": lid, "prop": rate, "value": 0.0})).unwrap();
|
||||
}
|
||||
(s, cid, lid)
|
||||
}
|
||||
|
||||
/// `s` as the text of a project file written by `version`. `control` keeps the `Composite With
|
||||
/// Original` control: a project from before v0.10.0 has none.
|
||||
fn file_text(s: &Session, version: Option<&str>, control: bool) -> String {
|
||||
let mut p = (*s.project).clone();
|
||||
if !control {
|
||||
for it in p.items.values_mut() {
|
||||
let ItemKind::Comp(c) = &mut it.kind else { continue };
|
||||
for l in &mut std::sync::Arc::make_mut(c).layers {
|
||||
let Some(fx) = l.props.sub_mut("effects") else { continue };
|
||||
for n in &mut fx.children {
|
||||
let Node::Group(g) = n else { continue };
|
||||
g.children.retain(|c| c.match_id() != COMPOSITE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut file: serde_json::Value = serde_json::from_str(&p.to_file_json().unwrap()).unwrap();
|
||||
match version {
|
||||
Some(v) => file["savedBy"] = json!(v),
|
||||
None => {
|
||||
file.as_object_mut().unwrap().remove("savedBy");
|
||||
}
|
||||
}
|
||||
file.to_string()
|
||||
}
|
||||
|
||||
/// The control's value on every effect of the layer that has one, in the open project.
|
||||
fn controls(s: &Session, cid: ItemId, lid: u64) -> Vec<KV> {
|
||||
let fx = s.project.comp(cid).unwrap().layer(LayerId(lid)).unwrap().effects().unwrap();
|
||||
fx.groups().filter_map(|g| g.get(COMPOSITE).map(|p| p.value.clone())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_project_from_before_v0_10_0_keeps_the_artwork_under_its_particles() {
|
||||
let (s, cid, lid) = particle_comp();
|
||||
let path = tmp("legacy.ecproj").to_string_lossy().to_string();
|
||||
std::fs::write(&path, file_text(&s, Some("0.6.0"), false)).unwrap();
|
||||
let mut opened = Session::default();
|
||||
opened.execute("file.open", json!({"path": path})).unwrap();
|
||||
let img = opened.render(cid, Tick::from_seconds_f64(0.5), RenderOpts::default());
|
||||
assert_eq!(img.get(32, 24), [0.0, 1.0, 0.0, 1.0], "the artwork and the Fill below the particles show through");
|
||||
assert_eq!(img.get(1, 1), [0.0, 1.0, 0.0, 1.0], "all of it, not only under the producer");
|
||||
assert_eq!(controls(&opened, cid, lid), vec![KV::Bool(true); PARTICLES.len()], "both effects keep the old compositing");
|
||||
// File ▸ Import ▸ EffectCraft Project runs the same upgrade before it remaps the ids.
|
||||
let mut imported = Session::default();
|
||||
imported.execute("file.importProject", json!({"path": path})).unwrap();
|
||||
let (icid, ilid) = imported
|
||||
.project
|
||||
.items
|
||||
.values()
|
||||
.find(|i| i.name == "Main")
|
||||
.and_then(|i| match &i.kind {
|
||||
ItemKind::Comp(c) => Some((i.id, c.layers.iter().next()?.id)),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the imported comp");
|
||||
assert_eq!(controls(&imported, icid, ilid.0).len(), PARTICLES.len(), "both effects survive the import");
|
||||
assert!(controls(&imported, icid, ilid.0).iter().all(|v| *v == KV::Bool(true)), "{:?}", controls(&imported, icid, ilid.0));
|
||||
let imported_img = imported.render(icid, Tick::from_seconds_f64(0.5), RenderOpts::default());
|
||||
assert_eq!(imported_img.get(1, 1), [0.0, 1.0, 0.0, 1.0], "imported artwork shows too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_current_project_still_replaces_the_layer_with_its_particles() {
|
||||
let (s, cid, lid) = particle_comp();
|
||||
// A brand-new instance replaces the layer, as after v0.10.0 and as After Effects does: this is
|
||||
// what keeps the After Effects frames the effects are matched against green.
|
||||
assert_eq!(controls(&s, cid, lid), vec![KV::Bool(false); PARTICLES.len()], "a new instance composites nothing");
|
||||
for (name, version) in [("current", Some(effectcraft_project::APP_VERSION)), ("anonymous", None)] {
|
||||
let path = tmp(&format!("{name}.ecproj")).to_string_lossy().to_string();
|
||||
std::fs::write(&path, file_text(&s, version, true)).unwrap();
|
||||
let mut opened = Session::default();
|
||||
opened.execute("file.open", json!({"path": path})).unwrap();
|
||||
let img = opened.render(cid, Tick::from_seconds_f64(0.5), RenderOpts::default());
|
||||
assert_eq!(img.get(32, 24)[3], 0.0, "{name}: no particles born, nothing left of the layer");
|
||||
assert_eq!(controls(&opened, cid, lid), vec![KV::Bool(false); PARTICLES.len()], "{name}: the control stays off");
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings ▸ Project ▸ New Project Loads Template opens the template as an untitled project: a
|
||||
/// template written before v0.10.0 has to be upgraded like any other project file.
|
||||
#[test]
|
||||
fn a_new_project_template_from_before_v0_10_0_is_migrated() {
|
||||
let (s, _, _) = particle_comp();
|
||||
let path = tmp("template.ecproj").to_string_lossy().to_string();
|
||||
std::fs::write(&path, file_text(&s, Some("0.6.0"), false)).unwrap();
|
||||
let mut fresh = Session::default();
|
||||
fresh.prefs.project.use_template = true;
|
||||
fresh.prefs.project.template_path = path;
|
||||
fresh.execute("file.newProject", json!({})).unwrap();
|
||||
let cid = fresh.project.items.values().find(|i| i.name == "Main").map(|i| i.id).expect("the template's comp");
|
||||
let lid = fresh.project.comp(cid).unwrap().layers.iter().next().expect("its layer").id;
|
||||
assert_eq!(controls(&fresh, cid, lid.0), vec![KV::Bool(true); PARTICLES.len()], "the template's particles keep the old compositing");
|
||||
let img = fresh.render(cid, Tick::from_seconds_f64(0.5), RenderOpts::default());
|
||||
assert_eq!(img.get(1, 1), [0.0, 1.0, 0.0, 1.0], "and its artwork shows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_writer_before_v0_10_0_gets_the_old_compositing() {
|
||||
let (s, cid, lid) = particle_comp();
|
||||
for (version, legacy) in [
|
||||
(Some("0.6.0"), true),
|
||||
(Some("0.9.2"), true),
|
||||
(Some("0.9.2+build"), true),
|
||||
(Some("0.10.0"), false),
|
||||
(Some("0.24.0"), false),
|
||||
(Some("1.0.0"), false),
|
||||
(None, false),
|
||||
(Some("unknown"), false),
|
||||
(Some("0.9"), false),
|
||||
(Some("0.9.2.1"), false),
|
||||
] {
|
||||
let mut p = Project::from_json(&file_text(&s, version, false)).unwrap();
|
||||
crate::upgrade_effects(&mut p, version);
|
||||
let fx = p.comp(cid).unwrap().layer(LayerId(lid)).unwrap().effects().unwrap();
|
||||
let got: Vec<_> = fx.groups().filter_map(|g| g.get(COMPOSITE).map(|p| p.value.clone())).collect();
|
||||
assert_eq!(got, vec![KV::Bool(legacy); PARTICLES.len()], "{version:?}");
|
||||
}
|
||||
// A control the file already holds is the artist's own choice, whatever wrote it.
|
||||
let mut explicit = Project::from_json(&file_text(&s, Some("0.6.0"), true)).unwrap();
|
||||
crate::upgrade_effects(&mut explicit, Some("0.6.0"));
|
||||
let fx = explicit.comp(cid).unwrap().layer(LayerId(lid)).unwrap().effects().unwrap();
|
||||
assert!(fx.groups().all(|g| g.get(COMPOSITE).is_none_or(|p| p.value == KV::Bool(false))), "an explicit choice wins");
|
||||
}
|
||||
+3
-3
@@ -3,7 +3,7 @@
|
||||
<!-- Generated from the effect registry (crates/effects/src/catalog.rs); do not edit by hand.
|
||||
Regenerate: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current -->
|
||||
|
||||
EffectCraft ships 308 effects with 2988 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
|
||||
EffectCraft ships 308 effects with 2990 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
|
||||
|
||||
- **GPU**: 280 effects also run on the GPU compositor with identical results.
|
||||
- **32**: 308 effects process 32-bit float (HDR, overbright) pixels without clamping.
|
||||
@@ -325,8 +325,8 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
||||
| Caustics | `ec.sim.caustics` | 28 | GPU | 32 | Implemented |
|
||||
| Foam | `ec.sim.foam` | 36 | GPU | 32 | Implemented |
|
||||
| Liquid Particles | `ec.sim.ccmrmercury` | 23 | GPU | 32 | Implemented |
|
||||
| Particle Emitter 2D | `ec.sim.ccparticlesystems2` | 21 | GPU | 32 | Implemented |
|
||||
| Particle Emitter 3D | `ec.sim.ccparticleworld` | 36 | GPU | 32 | Implemented |
|
||||
| Particle Emitter 2D | `ec.sim.ccparticlesystems2` | 22 | GPU | 32 | Implemented |
|
||||
| Particle Emitter 3D | `ec.sim.ccparticleworld` | 37 | GPU | 32 | Implemented |
|
||||
| Particle Playground | `ec.sim.particleplayground` | 92 | GPU | 32 | Implemented |
|
||||
| Pixel Scatter | `ec.sim.ccscatterize` | 4 | GPU | 32 | Implemented |
|
||||
| Pixel Shatter | `ec.sim.ccpixelpolly` | 10 | GPU | 32 | Implemented |
|
||||
|
||||
Reference in new issue
Block a user