From 4983ba447bedb7cd8ebe1a17fba9e59feed85fef Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Fri, 23 Oct 2026 09:01:10 -0600 Subject: [PATCH] fix: preserve legacy CC particle compositing (#1898) --- crates/effects/src/sim.rs | 41 +++- crates/engine/src/lib.rs | 14 ++ .../tests/legacy_particle_compositing.rs | 178 ++++++++++++++++++ crates/project/src/lib.rs | 16 ++ docs/effects.md | 6 +- 5 files changed, 246 insertions(+), 9 deletions(-) create mode 100644 crates/engine/tests/legacy_particle_compositing.rs diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index 14146a9f..80ceda50 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -1226,15 +1226,25 @@ fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option { .collect(); list.sort_by(|a, b| b.0.total_cmp(&a.0)); let sprites: Vec = 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 } +fn particle_sprites(sprites: Vec, mode: u32, with_original: bool) -> SpritePlan { + // New instances replace the layer. Projects saved before v0.10.0 keep the old transfer + // modes over the input, including any earlier particle effects (#1898). This plan is + // shared by CPU and GPU rendering. + let post = if with_original { + Post::Combine(match mode { + 0..=2 => mode, + 3 => 5, + _ => 0, + }) + } else { + Post::Replace + }; + SpritePlan { sprites, tints: vec![], acc: if mode == 2 { Acc::Add } else { Acc::Over }, post } } // ------------------------------------------------------------------ CC Particle Systems II @@ -1296,7 +1306,7 @@ 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 @@ -1556,6 +1566,7 @@ pub fn specs() -> Vec { p("extras/effectCamera/rotationZ", "Rotation Z", num(0.0), ParamUi::Angle), p("extras/effectCamera/fov", "FOV", num(PW_FOV), slider(1.0, 179.0, 10.0, 120.0, 1)), p("extras/randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), + p("compositeWithOriginal", "Composite With Original", Value::Bool(false), ParamUi::Checkbox), ], particle_world, ), @@ -1584,6 +1595,7 @@ pub fn specs() -> Vec { 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("randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), + p("compositeWithOriginal", "Composite With Original", Value::Bool(false), ParamUi::Checkbox), ], particle_systems2, ), @@ -2003,6 +2015,23 @@ mod tests { } } + #[test] + fn legacy_particle_compositing_keeps_the_original_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 mut vals = vec![("birthRate", num(3.0)), ("particle/transferMode", Value::Enum(mode))]; + let particles = run(id, &vals, base.clone(), 1.0); + vals.push(("compositeWithOriginal", Value::Bool(true))); + let legacy = run(id, &vals, base.clone(), 1.0); + assert_eq!(legacy, combine(&base, &particles, combine_mode), "{id}, transfer mode {mode}"); + } + let base = grey(48, 48); + let vals = [("birthRate", num(0.0)), ("compositeWithOriginal", Value::Bool(true))]; + assert_eq!(run(id, &vals, base.clone(), 1.0), base, "{id}: no particles leaves the source 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/lib.rs b/crates/engine/src/lib.rs index cdfc7468..c6c6c573 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1188,6 +1188,13 @@ impl Session { /// ([`effectcraft_effects::migrate::to_effect_space`]). Stamps the current schema. pub fn upgrade_effects(p: &mut Project) { let to_effect_space = p.schema < effectcraft_project::SCHEMA_EFFECT_SPACE; + // Before v0.10.0, the CC particle systems composited over their input. Applying the new + // replacement behavior to those files hides their artwork and earlier effects (#1898). + // A missing/unrecognised writer is not evidence of an old project. + let version = p.saved_by.split(['-', '+']).next().unwrap_or(""); + let legacy_particles = version.split('.').count() == 3 + && version.split('.').all(|part| part.parse::().is_ok()) + && effectcraft_project::is_newer_version("0.10.0", version); // Effect bounds per layer: (size, top-left in layer coordinates). let mut bounds = std::collections::HashMap::new(); for (cid, c) in p.comps() { @@ -1213,7 +1220,13 @@ pub fn upgrade_effects(p: &mut Project) { 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) { + let composite = legacy_particles + && matches!(spec.id, "ec.sim.ccparticlesystems2" | "ec.sim.ccparticleworld") + && g.get("compositeWithOriginal").is_none(); effectcraft_effects::migrate::upgrade_instance(spec, g, &mut effectcraft_project::build::Ids(&mut next), size); + if composite && let Some(pr) = g.get_mut("compositeWithOriginal") { + pr.value = effectcraft_keyframe::Value::Bool(true); + } if to_effect_space { effectcraft_effects::migrate::to_effect_space(spec, g, size, d, &clone_d); } @@ -1223,6 +1236,7 @@ pub fn upgrade_effects(p: &mut Project) { } p.next_id = next; p.schema = effectcraft_project::SCHEMA_VERSION; + p.saved_by = effectcraft_project::APP_VERSION.to_string(); } #[cfg(test)] diff --git a/crates/engine/tests/legacy_particle_compositing.rs b/crates/engine/tests/legacy_particle_compositing.rs new file mode 100644 index 00000000..13b2bd59 --- /dev/null +++ b/crates/engine/tests/legacy_particle_compositing.rs @@ -0,0 +1,178 @@ +//! Compatibility of particle effects in projects saved before v0.10.0 (#1898). + +use effectcraft_keyframe::Value as KV; +use effectcraft_project::{Layer, LayerId, PropGroup}; +use effectcraft_time::Tick; +use serde_json::json; + +use effectcraft_engine::Session; + +fn comp_with_two_solids() -> (Session, u64, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap(); + let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 400, "height": 300})).unwrap()["layer"].as_u64().unwrap(); + (s, a, b) +} + +fn layer(s: &Session, id: u64) -> Layer { + s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone() +} + +fn apply(s: &mut Session, lid: u64, effect: &str) -> u64 { + s.execute("effect.apply", json!({"layers": [lid], "effect": effect})).unwrap()["effects"][0].as_u64().unwrap() +} + +fn effect_group(s: &Session, lid: u64, uid: u64) -> PropGroup { + layer(s, lid).effects().unwrap().groups().find(|g| g.uid == uid).unwrap().clone() +} + +fn animate_effect(s: &mut Session, lid: u64, uid: u64, path: &str, keys: &[(f64, f64)]) { + let prop = effect_group(s, lid, uid).prop(path).unwrap().uid; + for &(time, value) in keys { + s.execute("prop.addKey", json!({"layer": lid, "prop": prop, "time": time, "timeBase": "comp", "value": value})).unwrap(); + } +} + +/// #1898: v0.6.0 composited CC Particle Systems II over its input, including earlier +/// particle effects. Opening that project must keep both emitters and the precomp artwork. +#[test] +fn legacy_particle_compositing_survives_open_and_save() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 96, "height": 64, "frameRate": 30, "duration": 3})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let lid = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 96, "height": 64})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.precompose", json!({"layers": [lid], "name": "Button", "mode": "leave"})).unwrap(); + for (x, color) in [(18.0, "#00ff00"), (78.0, "#0000ff")] { + let uid = apply(&mut s, lid, "ec.sim.ccparticlesystems2"); + for (path, value) in [ + ("producer/position", json!([x, 20.0])), + ("producer/radiusX", json!(0)), + ("producer/radiusY", json!(0)), + ("physics/velocity", json!(0)), + ("physics/gravity", json!(0)), + ("particle/particleType", json!(7)), + ("particle/birthSize", json!(3)), + ("particle/deathSize", json!(3)), + ("particle/birthColor", json!(color)), + ("particle/deathColor", json!(color)), + ("particle/maxOpacity", json!(100)), + ] { + let prop = effect_group(&s, lid, uid).prop(path).unwrap().uid; + s.execute("prop.set", json!({"layer": lid, "prop": prop, "value": value})).unwrap(); + } + animate_effect(&mut s, lid, uid, "birthRate", &[(0.0, 1.5), (1.0, 2.5)]); + } + let original: Vec<_> = layer(&s, lid).effects().unwrap().groups().cloned().collect(); + let file = legacy_particle_file(&s, Some("0.6.0")); + let dir = std::env::temp_dir().join(format!("ec-1898-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("old.ecproj").to_string_lossy().to_string(); + std::fs::write(&path, file.to_string()).unwrap(); + let mut opened = Session::default(); + opened.execute("file.open", json!({"path": path})).unwrap(); + let render = |s: &Session| s.render(cid, Tick::from_seconds_f64(0.5), Default::default()); + let frame = render(&opened); + let visible = |img: &effectcraft_raster::Image| { + assert_eq!(img.get(48, 48), [1.0, 0.0, 0.0, 1.0], "the precomp artwork survives"); + assert!(img.get(18, 20)[1] > 0.9, "the first particle effect survives the second"); + assert!(img.get(78, 20)[2] > 0.9, "the second particle effect is visible"); + }; + visible(&frame); + for old in &original { + let now = effect_group(&opened, lid, old.uid); + assert_eq!(now.prop("birthRate"), old.prop("birthRate"), "animated values, times and ids are kept"); + assert_eq!(now.prop("producer/position"), old.prop("producer/position")); + } + // Importing uses the same version-aware migration before remapping ids. + let mut imported = Session::default(); + imported.execute("file.importProject", json!({"path": path})).unwrap(); + let imported_cid = imported.project.items.values().find(|i| i.name == "Main").unwrap().id; + // Import remaps effect uids, hence particle seeds and sub-step birth offsets. The artwork + // and both emitters must survive, but antialiased edges need not be pixel-identical. + visible(&imported.render(imported_cid, Tick::from_seconds_f64(0.5), Default::default())); + let imported_fx: Vec<_> = imported.project.comp(imported_cid).unwrap().layers[0].effects().unwrap().groups().collect(); + assert_eq!(imported_fx.len(), original.len()); + for (now, old) in imported_fx.iter().zip(&original) { + assert_eq!(now.get("compositeWithOriginal").unwrap().value, KV::Bool(true)); + assert_eq!(now.get("birthRate").unwrap().keys, old.get("birthRate").unwrap().keys); + } + opened.execute("file.save", json!({})).unwrap(); + let mut reopened = Session::default(); + reopened.execute("file.open", json!({"path": path})).unwrap(); + assert!(render(&reopened) == frame, "saving as a current project keeps the migrated behavior"); + assert_eq!(opened.project, reopened.project, "migration is idempotent"); + std::fs::remove_dir_all(dir).unwrap(); +} + +fn legacy_particle_file(s: &Session, version: Option<&str>) -> serde_json::Value { + let mut file: serde_json::Value = serde_json::from_str(&s.project.to_file_json().unwrap()).unwrap(); + match version { + Some(v) => file["savedBy"] = json!(v), + None => { + file.as_object_mut().unwrap().remove("savedBy"); + } + } + // The compatibility control did not exist in v0.6.0. + fn remove_control(v: &mut serde_json::Value) { + match v { + serde_json::Value::Array(a) => { + a.retain(|n| n.get("match").and_then(serde_json::Value::as_str) != Some("compositeWithOriginal")); + for n in a { + remove_control(n); + } + } + serde_json::Value::Object(o) => { + for n in o.values_mut() { + remove_control(n); + } + } + _ => {} + } + } + remove_control(&mut file); + file +} + +#[test] +fn legacy_particle_compositing_uses_writer_version_and_preserves_explicit_choices() { + let (mut s, lid, _) = comp_with_two_solids(); + for id in ["ec.sim.ccparticlesystems2", "ec.sim.ccparticleworld"] { + apply(&mut s, lid, id); + } + 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.16.0"), false), + (Some("1.0.0"), false), + (None, false), + (Some("unknown"), false), + (Some("0.6"), false), + ] { + let file = legacy_particle_file(&s, version); + let mut p = effectcraft_project::Project::from_json(&file.to_string()).unwrap(); + effectcraft_engine::upgrade_effects(&mut p); + let fx = p.comp_mut(s.active_comp_id().unwrap()).unwrap().layer_mut(LayerId(lid)).unwrap().props.sub_mut("effects").unwrap(); + for n in &mut fx.children { + let effectcraft_project::Node::Group(g) = n else { continue }; + assert_eq!(g.get("compositeWithOriginal").unwrap().value, KV::Bool(legacy), "{version:?}: {}", g.match_id); + g.get_mut("compositeWithOriginal").unwrap().value = KV::Bool(!legacy); + } + // An explicit value wins even when the file still names the old writer. + let mut explicit: serde_json::Value = serde_json::from_str(&p.to_file_json().unwrap()).unwrap(); + explicit["savedBy"] = json!("0.6.0"); + let mut again = effectcraft_project::Project::from_json(&explicit.to_string()).unwrap(); + effectcraft_engine::upgrade_effects(&mut again); + assert_eq!(again, p); + } + for writer in [json!(null), json!(6), json!([]), json!({"version": "0.6.0"})] { + let mut file = legacy_particle_file(&s, None); + file["savedBy"] = writer; + let mut p = effectcraft_project::Project::from_json(&file.to_string()).unwrap(); + effectcraft_engine::upgrade_effects(&mut p); + let fx = p.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(lid)).unwrap().effects().unwrap(); + assert!(fx.groups().all(|g| g.get("compositeWithOriginal").unwrap().value == KV::Bool(false))); + } +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index ccc6be25..7cfcbe0f 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -39,6 +39,17 @@ pub const SCHEMA_EFFECT_SPACE: u32 = 2; /// The EffectCraft version, written into project files as `savedBy`. pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); +fn current_writer() -> String { + APP_VERSION.to_string() +} + +fn read_writer<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + // Writer metadata was previously ignored by Project. Keep accepting absent or malformed + // metadata, but do not guess an old version and apply a compatibility migration to it. + let v = serde_json::Value::deserialize(d)?; + Ok(v.as_str().map(str::to_string).unwrap_or_else(current_writer)) +} + /// The EffectCraft version that wrote a project file (its `savedBy`), when it says. pub fn saved_by(text: &str) -> Option { #[derive(Deserialize)] @@ -1361,6 +1372,10 @@ impl Item { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Project { pub schema: u32, + /// Source writer for version-specific migrations. File saves stamp the current writer; + /// internal snapshots omit it and already contain the migrated effect properties. + #[serde(rename = "savedBy", default = "current_writer", deserialize_with = "read_writer", skip_serializing)] + pub saved_by: String, pub settings: ProjectSettings, pub items: BTreeMap, pub next_id: u64, @@ -1379,6 +1394,7 @@ impl Default for Project { fn default() -> Self { Project { schema: SCHEMA_VERSION, + saved_by: current_writer(), settings: ProjectSettings::default(), items: BTreeMap::new(), next_id: 1, diff --git a/docs/effects.md b/docs/effects.md index b8214e43..5a153373 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,7 +3,7 @@ -EffectCraft ships 307 effects with 3032 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 307 effects with 3034 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**: 307 effects process 32-bit float (HDR, overbright) pixels without clamping. @@ -323,8 +323,8 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Drizzle | `ec.sim.ccdrizzle` | 14 | GPU | 32 | Implemented | | CC Hair | `ec.sim.cchair` | 20 | GPU | 32 | Implemented | | CC Mr. Mercury | `ec.sim.ccmrmercury` | 23 | GPU | 32 | Implemented | -| CC Particle Systems II | `ec.sim.ccparticlesystems2` | 21 | GPU | 32 | Implemented | -| CC Particle World | `ec.sim.ccparticleworld` | 35 | GPU | 32 | Implemented | +| CC Particle Systems II | `ec.sim.ccparticlesystems2` | 22 | GPU | 32 | Implemented | +| CC Particle World | `ec.sim.ccparticleworld` | 36 | GPU | 32 | Implemented | | CC Pixel Polly | `ec.sim.ccpixelpolly` | 10 | GPU | 32 | Implemented | | CC Rainfall | `ec.sim.ccrainfall` | 14 | GPU | 32 | Implemented | | CC Scatterize | `ec.sim.ccscatterize` | 4 | GPU | 32 | Implemented |