mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
fix: preserve legacy CC particle compositing (#1898)
This commit is contained in:
@@ -1226,15 +1226,25 @@ 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 }
|
||||
fn particle_sprites(sprites: Vec<Sprite>, 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<EffectSpec> {
|
||||
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<EffectSpec> {
|
||||
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))];
|
||||
|
||||
@@ -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::<u64>().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)]
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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<String, D::Error> {
|
||||
// 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<String> {
|
||||
#[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<ItemId, Item>,
|
||||
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,
|
||||
|
||||
+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 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.<category>.<name>`) 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 |
|
||||
|
||||
Reference in New Issue
Block a user