Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
bflatastic
2026-10-30 07:05:19 +07:00
79 changed files with 2305 additions and 174 deletions
+62
View File
@@ -1483,6 +1483,68 @@ fn key_selection_toggles_and_skips_locked_layers_and_hold_restores_bezier() {
assert!(s.state.selected_keys.is_empty());
}
#[test]
fn muting_keys_skips_them_in_the_timeline_value_and_undoes() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
for (t, v) in [(0.0, 0.0), (1.0, 100.0), (2.0, 300.0)] {
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap();
}
let value_at = |s: &Session, t: f64| layer(s, a).props.prop("transform/opacity").unwrap().value_at(Tick::from_seconds_f64(t)).as_f64();
let select = |s: &mut Session| s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap();
select(&mut s);
let before = value_at(&s, 0.5);
s.execute("keys.setMuted", json!({"muted": true})).unwrap();
assert!(layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted);
// The muted key no longer bends the 0 → 300 segment.
assert!((value_at(&s, 0.5) - 75.0).abs() < 1e-6);
assert!((value_at(&s, 1.5) - 225.0).abs() < 1e-6);
s.undo();
assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted);
assert!((value_at(&s, 0.5) - before).abs() < 1e-6);
// The same command unmutes.
select(&mut s);
s.execute("keys.setMuted", json!({"muted": true})).unwrap();
s.execute("keys.setMuted", json!({"muted": false})).unwrap();
assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted);
}
/// Both Animation-menu rows mute and unmute through `Session::execute`, and undo/redo work.
#[test]
fn mute_and_unmute_keyframes_menu_rows_work_and_undo() {
fn rows(nodes: &[crate::menus::MenuNode], out: &mut Vec<(String, Value)>) {
for n in nodes {
match n {
crate::menus::MenuNode::Item(e) if e.command == "keys.setMuted" => out.push((e.label.clone(), e.params.clone())),
crate::menus::MenuNode::Submenu { children, .. } => rows(children, out),
_ => {}
}
}
}
let mut menu = vec![];
rows(&crate::menus::parse(crate::menus::TREE, false).unwrap(), &mut menu);
assert_eq!(menu.iter().map(|(l, _)| l.as_str()).collect::<Vec<_>>(), ["Mute Keyframes", "Unmute Keyframes"], "{menu:?}");
let mut s = comp();
let a = solid(&mut s, "#ff0000");
for (t, v) in [(0.0, 0.0), (1.0, 100.0)] {
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap();
}
let muted = |s: &Session| layer(s, a).props.prop("transform/opacity").unwrap().keys[1].muted;
s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap();
let run = |s: &mut Session, params: &Value| {
s.execute("keys.setMuted", if params.is_null() { json!({}) } else { params.clone() }).unwrap();
};
run(&mut s, &menu[0].1);
assert!(muted(&s));
s.undo();
assert!(!muted(&s));
s.redo();
assert!(muted(&s));
run(&mut s, &menu[1].1);
assert!(!muted(&s));
}
#[test]
fn transform_drags_apply_their_whole_offset_to_the_keys_they_started_with() {
let mut s = comp();
+43
View File
@@ -159,6 +159,46 @@ fn background_imports_report_progress_and_select_what_arrived() {
assert_eq!(r["items"].as_array().map(Vec::len), Some(1));
}
/// #2573: New Composition From Footage (`file.import {newComp}`) makes one composition of what
/// the import made, first file on top, at once or when the background job lands; an import that
/// made nothing makes no comp.
#[test]
fn new_comp_from_footage_makes_a_comp_of_what_was_imported() {
use effectcraft_project::{Footage, FootageKind, ItemKind};
struct Probe;
impl crate::Importer for Probe {
fn probe(&self, path: &str) -> Result<Footage, String> {
if path.ends_with(".xyz") {
return Err("unsupported file".into());
}
Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, ..Default::default() })
}
}
let mut s = Session { importer: Some(std::sync::Arc::new(Probe)), ..Default::default() };
let comps = |s: &Session| s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_))).count();
let layers = |s: &Session| s.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>();
// At once.
s.execute("file.import", json!({"paths": ["/drop/a.png", "/drop/b.xyz"], "newComp": true})).unwrap();
assert_eq!(comps(&s), 1);
assert_eq!(layers(&s), ["a.png"]);
assert_eq!((s.active_comp().unwrap().width, s.active_comp().unwrap().height), (64, 32));
// In the background: the job's own items, whatever is selected or active meanwhile; it takes
// precedence over `addToComp`.
s.execute("file.import", json!({"paths": ["/drop/c.png", "/drop/d.png"], "background": true, "newComp": true, "addToComp": true})).unwrap();
s.state.project_selection.clear();
s.execute("jobs.wait", json!({})).unwrap();
assert_eq!(comps(&s), 2);
assert_eq!(layers(&s), ["c.png", "d.png"]);
// Nothing imported: no comp, and the failure is reported.
s.execute("file.import", json!({"paths": ["/drop/e.xyz"], "background": true, "newComp": true})).unwrap();
s.execute("jobs.wait", json!({})).unwrap();
assert_eq!(comps(&s), 2);
assert!(s.job_log.last().unwrap().message.contains("unsupported file"));
let r = s.execute("file.import", json!({"paths": ["/drop/f.xyz"], "newComp": true})).unwrap();
assert_eq!(comps(&s), 2);
assert_eq!(r["errors"].as_array().map(Vec::len), Some(1));
}
/// Audio, Lock and Shy don't change pixels: toggling them keeps the cached layers (#103).
#[test]
fn audio_lock_and_shy_keep_cached_layers() {
@@ -476,6 +516,9 @@ fn set_text_paragraph_fill_and_leading() {
s.execute("layer.setText", json!({"layer": t, "hScale": 120, "vScale": 80, "baselineShift": 4, "smallCaps": true, "strokeOverFill": true})).unwrap();
let d = doc(&s);
assert_eq!((d.h_scale, d.v_scale, d.baseline_shift, d.small_caps, d.stroke_over_fill), (120.0, 80.0, 4.0, true, true));
s.execute("layer.setText", json!({"layer": t, "lineJoin": "bevel"})).unwrap();
assert_eq!(doc(&s).line_join, effectcraft_keyframe::LineJoin::Bevel);
assert!(s.execute("layer.setText", json!({"layer": t, "lineJoin": "square"})).is_err());
}
#[test]
+31
View File
@@ -160,6 +160,37 @@ fn psd_imports_as_composition() {
assert!(s.project.comp(cid).is_none());
}
/// #2573: New Composition From Footage on a Photoshop file imported as a composition opens that
/// composition; it isn't wrapped in another.
#[test]
fn psd_imported_as_composition_with_new_comp_is_the_new_comp() {
let path = tmp("newcomp.psd");
std::fs::write(&path, sample_psd()).unwrap();
let mut s = Session::default();
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "newComp": true})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap());
assert_eq!(s.state.active_comp, Some(cid));
let roots = s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_)) && i.name.starts_with("newcomp")).count();
assert_eq!(roots, 1, "no wrapper comp");
}
/// A Photoshop file imported as a composition and dropped on the Timeline (`addToComp`) adds
/// its composition once, not its folder too.
#[test]
fn psd_imported_as_composition_into_a_comp_adds_one_layer() {
let path = tmp("dropped.psd");
std::fs::write(&path, sample_psd()).unwrap();
let mut s = Session::default();
let main = s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap()["comp"].as_u64().unwrap();
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "addToComp": true})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
let psd = r["comps"][0].as_u64().unwrap();
let layers = &s.project.comp(effectcraft_project::ItemId(main)).unwrap().layers;
assert_eq!(layers.len(), 1, "{layers:?}");
assert!(matches!(layers[0].source, LayerSource::Comp { item } if item.0 == psd));
}
#[test]
fn psd_retain_layer_sizes() {
let path = tmp("retain.psd");