diff --git a/crates/effects/src/sim/particle_fx.rs b/crates/effects/src/sim/particle_fx.rs index c2f493ab5..e5d8fc69a 100644 --- a/crates/effects/src/sim/particle_fx.rs +++ b/crates/effects/src/sim/particle_fx.rs @@ -228,15 +228,28 @@ pub(super) fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option = 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, 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, 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 diff --git a/crates/effects/src/sim/registry.rs b/crates/effects/src/sim/registry.rs index 4df07c50a..47d014fee 100644 --- a/crates/effects/src/sim/registry.rs +++ b/crates/effects/src/sim/registry.rs @@ -159,6 +159,7 @@ pub fn specs() -> Vec { 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 { 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, diff --git a/crates/effects/src/sim/tests.rs b/crates/effects/src/sim/tests.rs index 4c708dae4..eb73f51fd 100644 --- a/crates/effects/src/sim/tests.rs +++ b/crates/effects/src/sim/tests.rs @@ -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))]; diff --git a/crates/engine/src/cmd/essential.rs b/crates/engine/src/cmd/essential.rs index 419bd7316..0a98f0d5a 100644 --- a/crates/engine/src/cmd/essential.rs +++ b/crates/engine/src/cmd/essential.rs @@ -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)) } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d0158a286..f49c0dc91 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -20,7 +20,8 @@ fn new_project(s: &mut Session, _: &Value) -> Result { 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 { // 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 { 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.", diff --git a/crates/engine/src/cmd/import_project.rs b/crates/engine/src/cmd/import_project.rs index af37771a8..bc89c2ba0 100644 --- a/crates/engine/src/cmd/import_project.rs +++ b/crates/engine/src/cmd/import_project.rs @@ -61,7 +61,7 @@ fn root_comps(project: &Project) -> Vec { fn import_project(s: &mut Session, p: &Value) -> Result { 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)); diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index c35a5a13b..c188b1d79 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -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) { + pub fn replace_project(&mut self, p: Project, path: Option) { + 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, 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) -> [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) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 87180e370..cacc591fc 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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; diff --git a/crates/engine/src/tests/legacy_particles.rs b/crates/engine/src/tests/legacy_particles.rs new file mode 100644 index 000000000..8cd38d83c --- /dev/null +++ b/crates/engine/src/tests/legacy_particles.rs @@ -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 { + 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"); +} diff --git a/docs/effects.md b/docs/effects.md index 723edd0d0..76a07d340 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,7 +3,7 @@ -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..`) 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 |